From 3bf2f600d34e79d34767e2bb2c5d6b828a6f2baa Mon Sep 17 00:00:00 2001 From: NavarrePratt Date: Wed, 1 Jul 2026 10:08:33 -0400 Subject: [PATCH 1/2] docs: remove em dashes from public docstrings The public docstrings are the source for an auto-generated API reference whose build rejects em dashes. Replace them with hyphens or colons. Scope is docstrings only; comment em dashes are left unchanged. --- src/cwsandbox/_defaults.py | 4 ++-- src/cwsandbox/_sandbox.py | 40 +++++++++++++++++------------------ src/cwsandbox/cli/__init__.py | 2 +- src/cwsandbox/cli/exec.py | 2 +- src/cwsandbox/cli/list.py | 2 +- src/cwsandbox/cli/logs.py | 4 ++-- src/cwsandbox/cli/shell.py | 2 +- src/cwsandbox/exceptions.py | 10 ++++----- 8 files changed, 33 insertions(+), 33 deletions(-) diff --git a/src/cwsandbox/_defaults.py b/src/cwsandbox/_defaults.py index b78ac944..f9cf8e4d 100644 --- a/src/cwsandbox/_defaults.py +++ b/src/cwsandbox/_defaults.py @@ -280,12 +280,12 @@ class SandboxDefaults: temp_dir: Temp directory path inside the sandbox. tags: Tags for filtering and organizing sandboxes. profile_ids: Legacy selector accepting profile IDs. Prefer - ``profile_names``. Resolves independently of ``profile_names`` — + ``profile_names``. Resolves independently of ``profile_names``: setting one explicitly does not suppress the other's default. Pass an empty list to explicitly clear any default; pass None (the default) to inherit any configured default. profile_names: Select sandboxes by profile name. Resolves - independently of ``profile_ids`` — both may be combined. + independently of ``profile_ids``: both may be combined. Pass an empty list to explicitly clear any default; pass None (the default) to inherit any configured default. runner_ids: Restrict to specific runner IDs. Pass an empty list to diff --git a/src/cwsandbox/_sandbox.py b/src/cwsandbox/_sandbox.py index 15c79ed1..6d2f1632 100644 --- a/src/cwsandbox/_sandbox.py +++ b/src/cwsandbox/_sandbox.py @@ -430,7 +430,7 @@ async def _iter_sync_source_in_executor(source: Iterable[bytes]) -> AsyncIterato A caller-supplied sync iterable (a file handle, an NFS/FUSE read, a network generator) can block on ``next()``; driving it inline on the shared - background loop would stall every other operation — heartbeats, other + background loop would stall every other operation: heartbeats, other sandboxes' RPCs. Each ``next()`` is instead run in the default executor, so the blocking call parks an executor thread rather than the event loop. @@ -822,9 +822,9 @@ def _is_resumable_transport_error(exc: BaseException) -> bool: """Return True if a streaming gRPC error is worth attempting to resume. Only the transport-level codes that typically map to a gateway pod - restart or a transient network blip qualify. Anything else — including + restart or a transient network blip qualify. Anything else, including DEADLINE_EXCEEDED (caller-requested timeout), NOT_FOUND, PERMISSION_DENIED, - INVALID_ARGUMENT — propagates as-is. + and INVALID_ARGUMENT, propagates as-is. Note: this classifier intentionally diverges from ``_translate_rpc_error``, the canonical translator that consults AIP-193 ``ErrorInfo`` reasons @@ -837,7 +837,7 @@ def _is_resumable_transport_error(exc: BaseException) -> bool: streaming error (e.g. a hypothetical ``CWSANDBOX_RUNNER_TERMINATED`` on ``INTERNAL``), this classifier should be updated to consult ``_translate_rpc_error`` first and dispatch on the resulting typed - exception — matching the pattern in ``_classify_poll_error``. + exception, matching the pattern in ``_classify_poll_error``. """ if not isinstance(exc, grpc.aio.AioRpcError): return False @@ -3885,7 +3885,7 @@ def _reject_unsatisfiable_snapshot_on_stop(self) -> None: only when this caller will own a fresh stop (creating the task) or when it joins a stop that is itself a snapshot-on-stop. Every other state means the sandbox is, or is about to be, torn down without archiving - the mount — joining would silently drop the snapshot. A sandbox that + the mount: joining would silently drop the snapshot. A sandbox that was never started has no mount to archive, so it is left to the normal no-op path rather than raising here. """ @@ -3995,8 +3995,8 @@ def stop( Multiple callers share the same underlying stop task: the first caller creates it, subsequent callers join it. A ``snapshot_on_stop=True`` request that would join (or observe) a stop that is not capturing a - snapshot — because the sandbox is already stopping, already stopped, or - a plain ``stop()`` is already in flight — raises + snapshot, because the sandbox is already stopping, already stopped, or + a plain ``stop()`` is already in flight, raises ``SnapshotOnStopConflictError`` instead of silently completing without an archive. Plain stops always coalesce. @@ -4187,7 +4187,7 @@ async def _stream_logs_async( and, on a transient transport failure, re-init with resume_session_id and resume_offset so the stream picks up where it left off. Servers that do not advertise a session_id (or reply with an in-band - SESSION_NOT_FOUND on the resume init) fall back to a fresh init — + SESSION_NOT_FOUND on the resume init) fall back to a fresh init: the live tail restarts from the current head with no replay of older bytes. ``tail_lines`` and ``since_time`` are only applied to the very first attempt; fresh-fallback re-inits send no replay @@ -5288,7 +5288,7 @@ async def _exec_streaming_binary_async( ) -> tuple[int, bytes, bytes]: """Run one non-TTY StreamExec and return raw stdout/stderr bytes. - See also ``_exec_streaming_async`` — the queue-driven public variant; + See also ``_exec_streaming_async``, the queue-driven public variant; keep handshake/timeout/error-translation in sync between the two. """ timeout = timeout_seconds if timeout_seconds is not None else self._request_timeout_seconds @@ -5530,7 +5530,7 @@ async def _stat_file_size_async(self, filepath: str, timeout: float) -> int | No unexpected output, transient transport error). Used by ``read_file_streaming`` to capture a pre-read size baseline for the truncation check; a ``None`` result means the check is skipped rather - than raising — a stat failure on its own is not a streaming-read failure. + than raising: a stat failure on its own is not a streaming-read failure. """ try: returncode, stdout, _stderr = await self._exec_streaming_binary_async( @@ -5567,7 +5567,7 @@ def _stat_budget(self, deadline: float | None) -> float: ``stat`` is an O(1) metadata lookup, so it is capped at ``STAT_INTEGRITY_TIMEOUT_SECONDS`` and never allowed to exceed the - operation's remaining wall-clock budget — the stat and the read it + operation's remaining wall-clock budget: the stat and the read it precedes share one deadline and together stay within the caller's timeout. """ @@ -5583,7 +5583,7 @@ def _verify_no_truncation( Pure comparison shared by read_file (exec-stream fallback) and read_file_streaming. ``expected`` is the file's size captured *before* - the read — the server-reported size for the read_file fallback, or a + the read: the server-reported size for the read_file fallback, or a pre-read ``stat`` for read_file_streaming. On a backend where the streaming channel silently truncates (e.g. the lossless gate is off), the read command exits 0 having produced the whole file while the client @@ -5591,7 +5591,7 @@ def _verify_no_truncation( read as if complete (issue #1172). Only a SHORT read (``delivered < expected``) is flagged. Specifically: - - ``expected is None`` (size unknown — server omitted it, or ``stat`` is + - ``expected is None`` (size unknown: server omitted it, or ``stat`` is unavailable on a distroless/scratch image): skip. The check is a best-effort backstop, not a guarantee; the public docstrings scope this. @@ -5600,7 +5600,7 @@ def _verify_no_truncation( avoid a false-positive on a fully-delivered read. - ``delivered >= expected``: not short, so no raise. Because ``expected`` is the *pre-read* size, a file appended to during the read grows the - delivered byte count above the baseline rather than below it — a + delivered byte count above the baseline rather than below it: a benign concurrent append never trips the check (the false-positive that an after-the-fact stat would produce). """ @@ -5692,7 +5692,7 @@ def read_file( Behavior: Files up to ~32 MiB are read in a single unary call. Larger files - (up to ~256 MiB) transparently fall back to a streaming read — the + (up to ~256 MiB) transparently fall back to a streaming read: the first such fallback per Sandbox logs once at INFO. When the server reports the file's size, files above ~256 MiB are refused with ``CWSANDBOX_FILE_TOO_LARGE``; use ``read_file_streaming`` for those. @@ -5701,7 +5701,7 @@ def read_file( cannot always know the remote size in advance (e.g. when the backend signals the oversized read via resource exhaustion rather than a sized ``CWSANDBOX_FILE_TOO_LARGE``), so a very large file can still - be buffered in full rather than refused — prefer + be buffered in full rather than refused: prefer ``read_file_streaming`` for anything large to consume it incrementally and bound memory. @@ -5997,7 +5997,7 @@ def write_file( Behavior: Payloads up to ~32 MiB are written in a single unary call. Larger payloads (up to ~256 MiB) transparently fall back to a streaming - write — the first such fallback per Sandbox logs once at INFO. + write: the first such fallback per Sandbox logs once at INFO. Payloads above ~256 MiB are refused; use ``write_file_streaming`` for those. @@ -6113,7 +6113,7 @@ def write_file_streaming( ``AsyncIterable[bytes]``. Each yielded chunk is sent as-is; ``bytes`` input is sliced into 1 MiB chunks internally. Yielded items must be ``bytes``, ``bytearray``, or - ``memoryview`` — anything else raises ``TypeError``. + ``memoryview``; anything else raises ``TypeError``. timeout_seconds: Wall-clock timeout for the streaming write. Returns: @@ -6360,7 +6360,7 @@ def stream_logs( ) -> StreamReader[str]: """Stream logs from the sandbox's main process. - Streams stdout/stderr from the sandbox's **main command** — the + Streams stdout/stderr from the sandbox's **main command**: the entrypoint passed to ``Sandbox.run()`` (or the default shell-trapped keep-alive). Output from commands started via ``exec()`` is **not** included; use ``Process.stdout``/``Process.stderr`` for those. @@ -6372,7 +6372,7 @@ def stream_logs( writes to stdout/stderr when calling ``Sandbox.run()``. Returns a StreamReader that yields log lines as strings. The method - returns immediately — iteration on the StreamReader blocks until + returns immediately; iteration on the StreamReader blocks until data arrives. Can also retrieve historical logs from stopped sandboxes when diff --git a/src/cwsandbox/cli/__init__.py b/src/cwsandbox/cli/__init__.py index 813bbc9c..437340dc 100644 --- a/src/cwsandbox/cli/__init__.py +++ b/src/cwsandbox/cli/__init__.py @@ -2,7 +2,7 @@ # SPDX-License-Identifier: Apache-2.0 # SPDX-PackageName: cwsandbox-client -"""CWSandbox CLI — terminal interface for CoreWeave sandboxes. +"""CWSandbox CLI - terminal interface for CoreWeave sandboxes. The functions in this package are intended to be called via the CLI, not from Python code. No backwards compatibility guarantees are made diff --git a/src/cwsandbox/cli/exec.py b/src/cwsandbox/cli/exec.py index 9ea096e1..a42b9cea 100644 --- a/src/cwsandbox/cli/exec.py +++ b/src/cwsandbox/cli/exec.py @@ -2,7 +2,7 @@ # SPDX-License-Identifier: Apache-2.0 # SPDX-PackageName: cwsandbox-client -"""cwsandbox exec — execute a command in a sandbox.""" +"""cwsandbox exec - execute a command in a sandbox.""" from __future__ import annotations diff --git a/src/cwsandbox/cli/list.py b/src/cwsandbox/cli/list.py index fd7b268d..84373cb1 100644 --- a/src/cwsandbox/cli/list.py +++ b/src/cwsandbox/cli/list.py @@ -2,7 +2,7 @@ # SPDX-License-Identifier: Apache-2.0 # SPDX-PackageName: cwsandbox-client -"""cwsandbox ls — list sandboxes.""" +"""cwsandbox ls - list sandboxes.""" from __future__ import annotations diff --git a/src/cwsandbox/cli/logs.py b/src/cwsandbox/cli/logs.py index cfd87cd8..a3b71235 100644 --- a/src/cwsandbox/cli/logs.py +++ b/src/cwsandbox/cli/logs.py @@ -2,7 +2,7 @@ # SPDX-License-Identifier: Apache-2.0 # SPDX-PackageName: cwsandbox-client -"""cwsandbox logs — stream logs from a sandbox.""" +"""cwsandbox logs - stream logs from a sandbox.""" from __future__ import annotations @@ -44,7 +44,7 @@ def logs( Streams stdout/stderr from the command used to create the sandbox (the entrypoint passed to Sandbox.run()). Output from exec commands is not - included — use 'cwsandbox exec' for those. + included; use 'cwsandbox exec' for those. Note: sandboxes created with the default keep-alive command do not produce any log output. diff --git a/src/cwsandbox/cli/shell.py b/src/cwsandbox/cli/shell.py index 59953c4d..6214ea4c 100644 --- a/src/cwsandbox/cli/shell.py +++ b/src/cwsandbox/cli/shell.py @@ -2,7 +2,7 @@ # SPDX-License-Identifier: Apache-2.0 # SPDX-PackageName: cwsandbox-client -"""cwsandbox shell — interactive shell in a sandbox.""" +"""cwsandbox shell - interactive shell in a sandbox.""" from __future__ import annotations diff --git a/src/cwsandbox/exceptions.py b/src/cwsandbox/exceptions.py index 3e6e9f54..31bd0e9e 100644 --- a/src/cwsandbox/exceptions.py +++ b/src/cwsandbox/exceptions.py @@ -133,14 +133,14 @@ class SandboxStreamBackpressureError(SandboxExecutionError): Attributes: stream_code: The terminal ``ExecStreamError.code`` that triggered this exception (``"STREAM_BACKPRESSURE"``). This is a streaming-channel - code, NOT an AIP-193 ErrorInfo ``reason`` — the two namespaces are + code, NOT an AIP-193 ErrorInfo ``reason``: the two namespaces are kept distinct, so ``.reason`` is ``None`` here and callers should branch on the exception class (or ``stream_code``) rather than ``.reason``. How to avoid it: - - Read the stream as output arrives — iterate the reader / drain stdout in + - Read the stream as output arrives: iterate the reader / drain stdout in a tight loop and move slow work (disk writes, network calls) off the read loop. The common cause is doing per-chunk work inline; drain into a fast local sink (e.g. a file) first and process afterward. See @@ -149,9 +149,9 @@ class SandboxStreamBackpressureError(SandboxExecutionError): ``write_file_streaming`` (chunked) instead of reading everything at once. - If the *destination* itself cannot keep up no matter how tight the loop (a rate-limited API, a slow disk, a human watching a terminal), no amount - of loop-tightening helps — split the work into smaller transfers, or move + of loop-tightening helps: split the work into smaller transfers, or move very large payloads out of the streaming path entirely. - - This is not a transient error — retrying the same consumer pattern will + - This is not a transient error: retrying the same consumer pattern will hit it again. Fix the read pace (or chunk the work) first, then retry. """ @@ -197,7 +197,7 @@ class SandboxStreamTruncatedError(SandboxExecutionError): Attributes: stream_code: The terminal ``ExecStreamError.code`` that triggered this exception (``"STREAM_TRUNCATED"``). This is a streaming-channel - code, NOT an AIP-193 ErrorInfo ``reason`` — the two namespaces are + code, NOT an AIP-193 ErrorInfo ``reason``: the two namespaces are kept distinct, so ``.reason`` is ``None`` here and callers should branch on the exception class (or ``stream_code``) rather than ``.reason``. From 3e0c3820110eba10496ee853022d1853e42c8d45 Mon Sep 17 00:00:00 2001 From: NavarrePratt Date: Wed, 1 Jul 2026 10:08:49 -0400 Subject: [PATCH 2/2] ci: guard docstrings against typography that breaks docs generation Add an AST-based guard that flags em dashes, en dashes, smart quotes, and ellipsis in docstrings under src/ (including PEP 257 attribute docstrings), skipping the generated _proto stubs. Wire it into a new PR workflow and the mise check aggregate so future docstrings stay clean. --- .github/workflows/docstring-hygiene.yaml | 36 +++++ mise.toml | 6 +- scripts/check_docstring_hygiene.py | 182 +++++++++++++++++++++++ 3 files changed, 223 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/docstring-hygiene.yaml create mode 100644 scripts/check_docstring_hygiene.py diff --git a/.github/workflows/docstring-hygiene.yaml b/.github/workflows/docstring-hygiene.yaml new file mode 100644 index 00000000..7ab1dd50 --- /dev/null +++ b/.github/workflows/docstring-hygiene.yaml @@ -0,0 +1,36 @@ +# SPDX-FileCopyrightText: 2026 CoreWeave, Inc. +# SPDX-License-Identifier: Apache-2.0 +# SPDX-PackageName: cwsandbox-client + +# This package's public docstrings are the source for an auto-generated API +# reference. This guard rejects typographic characters in docstrings that the +# documentation build does not accept, before they merge. +# See scripts/check_docstring_hygiene.py for the rationale. + +name: Docstring hygiene + +on: + pull_request: + branches: [main] + +jobs: + docstring-hygiene: + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Install uv + uses: astral-sh/setup-uv@v4 + with: + enable-cache: true + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version-file: ".python-version" + + - name: Check docstring hygiene + run: uv run --no-project python scripts/check_docstring_hygiene.py diff --git a/mise.toml b/mise.toml index 47c712e4..1fc0c81d 100644 --- a/mise.toml +++ b/mise.toml @@ -21,6 +21,10 @@ run = "uv run ruff check --fix ." description = "Lint code without auto-fix" run = "uv run ruff check ." +[tasks."lint:docstrings"] +description = "Guard src/ docstrings against patterns that break docs generation" +run = "uv run python scripts/check_docstring_hygiene.py" + # ============================================================================= # Type Checking # ============================================================================= @@ -98,7 +102,7 @@ echo "==> Packaging smoke test passed." [tasks.check] description = "Run all quality checks (runs in parallel)" -depends = ["format:check", "lint:check", "typecheck", "test"] +depends = ["format:check", "lint:check", "lint:docstrings", "typecheck", "test"] alias = "c" # ============================================================================= diff --git a/scripts/check_docstring_hygiene.py b/scripts/check_docstring_hygiene.py new file mode 100644 index 00000000..b7cc07d4 --- /dev/null +++ b/scripts/check_docstring_hygiene.py @@ -0,0 +1,182 @@ +# SPDX-FileCopyrightText: 2026 CoreWeave, Inc. +# SPDX-License-Identifier: Apache-2.0 +# SPDX-PackageName: cwsandbox-client + +"""Docstring hygiene guard for the cwsandbox public API surface. + +This package's public docstrings are the source for an auto-generated API +reference. The documentation build rejects certain typographic characters +(em dashes, en dashes, smart quotes, ellipsis), which otherwise pass straight +through into the rendered output. This guard flags those characters in +docstrings under ``src/`` so they are caught before merge. + +Both the leading docstring of every module/class/function and PEP 257 attribute +docstrings (a string literal following an assignment in a module or class body) +are inspected, since the docs generator extracts all of them. Comments and +ordinary string literals are left alone, and the generated ``_proto`` stubs are +skipped (mirroring ruff's extend-exclude), since their content regenerates from +upstream proto comments. + +Run via ``mise run lint:docstrings`` or ``python scripts/check_docstring_hygiene.py``. +""" + +from __future__ import annotations + +import ast +import sys +from pathlib import Path + +BANNED_CHARS: dict[str, str] = { + "—": "em dash (U+2014)", + "–": "en dash (U+2013)", + "“": "left double quotation mark (U+201C)", + "”": "right double quotation mark (U+201D)", + "‘": "left single quotation mark (U+2018)", + "’": "right single quotation mark (U+2019)", + "…": "horizontal ellipsis (U+2026)", +} + +_REMEDIATION = "use a hyphen, colon, or straight quote" + +REPO_ROOT = Path(__file__).resolve().parents[1] +DEFAULT_ROOTS = (str(REPO_ROOT / "src"),) +EXCLUDED_PARTS = ("_proto",) + +_DOCSTRING_OWNERS = ( + ast.Module, + ast.ClassDef, + ast.FunctionDef, + ast.AsyncFunctionDef, +) + +_ATTRIBUTE_OWNERS = (ast.Module, ast.ClassDef) +_ASSIGNMENTS = (ast.Assign, ast.AnnAssign) + + +class Violation: + """One banned character found inside one docstring.""" + + def __init__(self, path: Path, line: int, message: str) -> None: + self.path = path + self.line = line + self.message = message + + def render(self) -> str: + try: + shown: Path | str = self.path.relative_to(REPO_ROOT) + except ValueError: + shown = self.path + return f"{shown}:{self.line}: {self.message}" + + +def _string_expr(node: ast.stmt) -> ast.Constant | None: + if ( + isinstance(node, ast.Expr) + and isinstance(node.value, ast.Constant) + and isinstance(node.value.value, str) + ): + return node.value + return None + + +def _iter_docstrings(tree: ast.Module) -> list[ast.Constant]: + """Collect every docstring node the API-reference generator extracts. + + That is the leading docstring of each module/class/function, plus attribute + docstrings: a bare string statement immediately following an assignment in a + module or class body. + """ + docstrings: list[ast.Constant] = [] + for node in ast.walk(tree): + body = getattr(node, "body", None) + if not body: + continue + if isinstance(node, _DOCSTRING_OWNERS): + leading = _string_expr(body[0]) + if leading is not None: + docstrings.append(leading) + if isinstance(node, _ATTRIBUTE_OWNERS): + for previous, statement in zip(body, body[1:], strict=False): + if isinstance(previous, _ASSIGNMENTS): + attribute_doc = _string_expr(statement) + if attribute_doc is not None: + docstrings.append(attribute_doc) + return docstrings + + +def _scan_docstring(path: Path, doc: ast.Constant) -> list[Violation]: + """Scan the decoded docstring text. + + Scanning the decoded value rather than the raw source span means a banned + character written as an escape sequence is caught too, since the decoded + form is what ships to the rendered API reference. The line number is the + literal's start line plus the newlines preceding the match, which is exact + for the triple-quoted docstrings this repo uses. + """ + violations: list[Violation] = [] + value = doc.value + for char, name in BANNED_CHARS.items(): + index = value.find(char) + if index != -1: + line_no = doc.lineno + value.count("\n", 0, index) + violations.append( + Violation(path, line_no, f"banned character {name} in docstring; {_REMEDIATION}") + ) + return violations + + +def check_file(path: Path) -> list[Violation]: + source = path.read_text(encoding="utf-8") + + try: + tree = ast.parse(source, filename=str(path)) + except SyntaxError as exc: + return [Violation(path, exc.lineno or 0, f"failed to parse: {exc.msg}")] + + violations: list[Violation] = [] + for doc in _iter_docstrings(tree): + violations.extend(_scan_docstring(path, doc)) + return violations + + +def iter_python_files(roots: list[str]) -> list[Path]: + files: list[Path] = [] + for root in roots: + path = Path(root) + candidates = [path] if path.is_file() else sorted(path.rglob("*.py")) + for candidate in candidates: + if candidate.suffix != ".py": + continue + if any(part in EXCLUDED_PARTS for part in candidate.parts): + continue + files.append(candidate) + return files + + +def main(argv: list[str]) -> int: + roots = argv or list(DEFAULT_ROOTS) + + files = iter_python_files(roots) + if not files: + print(f"docstring hygiene: no Python files found under {roots}", file=sys.stderr) + return 1 + + all_violations: list[Violation] = [] + for path in files: + all_violations.extend(check_file(path)) + + if not all_violations: + print("docstring hygiene: OK") + return 0 + + for violation in all_violations: + print(violation.render()) + + count = len(all_violations) + noun = "violation" if count == 1 else "violations" + print(f"\ndocstring hygiene: {count} {noun} found", file=sys.stderr) + return 1 + + +if __name__ == "__main__": + raise SystemExit(main(sys.argv[1:]))