Skip to content
Merged
Show file tree
Hide file tree
Changes from 8 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions .github/workflows/lint.yml
Original file line number Diff line number Diff line change
Expand Up @@ -25,9 +25,9 @@

- name: Run ruff
run: |
ruff check src/ tests/
ruff format --check src/ tests/
ruff check src/ tests/ scripts/
ruff format --check src/ tests/ scripts/

- name: Run mypy
run: |
mypy src/
mypy src/ scripts/

Check warning on line 33 in .github/workflows/lint.yml

View check run for this annotation

Claude / Claude Code Review

publish.yml embedded lint job still runs the pre-PR narrow scope

The lint job embedded in `.github/workflows/publish.yml` (lines 51-58) still runs the pre-PR narrow scope — `ruff check src/ tests/`, `ruff format --check src/ tests/`, `mypy src/` — while this PR widens the identical commands in lint.yml to include `scripts/`, so the manual-release gate now enforces a strictly weaker check than the PR's stated goal. This is a third stale artifact beyond the CLAUDE.md and scripts/pre-push drift already flagged (and that comment's claim that "CI itself is correct
Comment thread
qing-ant marked this conversation as resolved.
Comment thread
qing-ant marked this conversation as resolved.
6 changes: 6 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,10 @@ jobs:

- name: Install Claude Code (Linux/macOS)
if: runner.os == 'Linux' || runner.os == 'macOS'
# `shell: bash` runs with -eo pipefail. Without pipefail the step takes
# its status from `bash`, which exits 0 on an empty script, so a failed
# `curl` leaves the CLI uninstalled and the step green.
shell: bash
run: |
curl -fsSL https://claude.ai/install.sh | bash
Comment thread
qing-ant marked this conversation as resolved.
Outdated
echo "$HOME/.local/bin" >> $GITHUB_PATH
Expand Down Expand Up @@ -149,6 +153,8 @@ jobs:

- name: Install Claude Code (Linux)
if: runner.os == 'Linux'
# `shell: bash` runs with -eo pipefail; see the note in the test job.
shell: bash
run: |
curl -fsSL https://claude.ai/install.sh | bash
echo "$HOME/.local/bin" >> $GITHUB_PATH
Expand Down
6 changes: 6 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,12 @@ strict_equality = true
module = ["opentelemetry", "opentelemetry.*"]
ignore_missing_imports = true

[[tool.mypy.overrides]]
# twine is not a declared dependency (not even in the [dev] extra); the
# import in scripts/build_wheel.py is guarded by try/except ImportError.
module = ["twine"]
ignore_missing_imports = true

[tool.ruff]
target-version = "py310"
line-length = 88
Expand Down
138 changes: 138 additions & 0 deletions scripts/_cli_version_validation.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
r"""Shared validation for Claude Code CLI version strings.

Two scripts constrain the same value: update_cli_version.py writes it into
src/claude_agent_sdk/_cli_version.py, and download_cli.py (reached from
build_wheel.py) reads it back out and hands it to an installer. A second copy
of the rule would let the writer emit a value the reader rejects, so the
pattern and its validation helper live here once.

The installer is the authority on what a version may be. install.sh enforces

^(stable|latest|[0-9]+\.[0-9]+\.[0-9]+(-[^[:space:]]+)?)$

and install.ps1 enforces the same rule, so a value this module admits but the
installer does not is not a version -- it is an error we defer to install time,
where it surfaces behind a retry loop and a misleading "Error downloading CLI"
headline. VERSION_PATTERN therefore mirrors that grammar: three dot-separated
numeric components with an optional prerelease/build suffix, which covers both
releases ("2.1.207") and dev builds
("2.1.146-dev.20260519.t105443.shaece3dab").

We deliberately accept a strict *subset* of what the installer allows: the
installer's suffix is `-[^\s]+`, which would admit quotes, backslashes,
semicolons and every other non-space character, so the suffix here is narrowed
to the alphanumeric/dot/plus/hyphen set that real versions use. Never widen
this pattern back toward the installer's.

That narrowing is a security boundary, not just input hygiene:

* update_cli_version.update_cli_version() writes the version into a Python
string literal in a real source file, so it must never admit a double
quote, a backslash, or a newline.
* download_cli.download_cli() hands the version to an installer. Neither of
its paths interpolates it into a command string -- Unix passes it as its
own argv element, Windows passes it in the environment -- so for that
caller the allowlist is defense in depth rather than the only barrier.

"latest" and "stable" are the installer's dist-tags. Both are *moving*: they
resolve to whatever build is current at install time. That is fine for a
download, and wrong for a pin -- _cli_version.py is the only record of which
build went into the wheels, so it must name one concrete build. Hence
``allow_dist_tag``.

Widening any of this requires re-reading tests/test_download_cli.py and
tests/test_update_cli_version.py.

VERSION_PATTERN is deliberately unanchored, and matched with fullmatch()
rather than match(): with "^...$" a swap to match() would silently accept a
trailing newline ("1.0.0\n"); unanchored, the same swap accepts obvious
prefixes like "1.0.0; id" and fails immediately in tests.
"""

import re

# A concrete version: MAJOR.MINOR.PATCH with an optional suffix. The suffix is
# the installer's `-[^\s]+` narrowed to characters that appear in real
# versions -- see the module docstring.
VERSION_PATTERN = re.compile(r"[0-9]+\.[0-9]+\.[0-9]+(?:-[0-9A-Za-z.+-]+)?")
Comment thread
qing-ant marked this conversation as resolved.

# The moving tags the installer resolves at install time. Compared lowercased,
# so "LATEST" is the sentinel rather than a mysterious "concrete version".
DIST_TAGS = ("latest", "stable")

# Anything word-shaped that is not a version: "next", "beta", "nightly". Named
# so the error can say *why* it was rejected instead of printing a regex.
_DIST_TAG_SHAPED = re.compile(r"[A-Za-z][0-9A-Za-z-]*")

_SUPPORTED_TAGS = ", ".join(repr(tag) for tag in DIST_TAGS)


def _expected(allow_dist_tag: bool) -> str:
"""The phrase naming what the caller should have passed instead."""
if allow_dist_tag:
return f"{_SUPPORTED_TAGS}, or a concrete version"
return "a concrete version"


def validate_version(version: str, *, source: str, allow_dist_tag: bool) -> str:
"""Return the usable form of ``version``, or raise.

Surrounding whitespace is stripped before anything else: a trailing "\\n"
from a file read, a "\\r" from a CRLF checkout, or a stray space from YAML
is unambiguous in intent, and the stripped value is what the caller gets
back and must use downstream.

Args:
version: The candidate version string.
source: Name of where the value came from, used in the error message
(e.g. "CLAUDE_CLI_VERSION").
allow_dist_tag: Whether a moving dist-tag ("latest", "stable") is
acceptable. It is for a download, which resolves it at install
time; it is not for a value pinned into _cli_version.py, which must
name the one concrete build that went into the wheels.

Returns:
The stripped version, with a dist-tag normalized to lowercase.

Raises:
ValueError: If ``version`` is neither an allowed dist-tag nor a
fullmatch of VERSION_PATTERN.
"""
candidate = version.strip()

# A dist-tag fails VERSION_PATTERN, so it is recognized by name -- and
# case-insensitively, so "LATEST" is not mistaken for something else.
if candidate.lower() in DIST_TAGS:
if allow_dist_tag:
return candidate.lower()
raise ValueError(
f"Invalid {source}: {candidate!r} is a moving dist-tag, not a concrete "
f"version. A pinned version must name the one build that goes into the "
f"wheels. Expected a version matching {VERSION_PATTERN.pattern}"
)

if VERSION_PATTERN.fullmatch(candidate):
return candidate

# Rejected from here on; what is left is choosing the most useful reason.

# "v2.1.207" is the single most likely typo, and the installer rejects it.
# Say so, rather than printing the pattern and leaving the reader to spot
# the leading "v". Not normalized away: the caller asked for something we
# do not support, and silently installing a different string is worse.
if candidate[:1] in ("v", "V") and VERSION_PATTERN.fullmatch(candidate[1:]):
raise ValueError(
f"Invalid {source}: {candidate!r}. "
f"Did you mean {candidate[1:]!r}? (no leading 'v')"
)

if _DIST_TAG_SHAPED.fullmatch(candidate):
raise ValueError(
f"Invalid {source}: {candidate!r} is not a supported dist-tag; "
f"use {_expected(allow_dist_tag)}"
)

raise ValueError(
f"Invalid {source}: {version!r}. "
f"Expected {_expected(allow_dist_tag)} matching {VERSION_PATTERN.pattern}"
)
14 changes: 8 additions & 6 deletions scripts/check_pypi_quota.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,12 +15,13 @@
import sys
import urllib.request
from pathlib import Path
from typing import Any, cast

PYPI_PROJECT_LIMIT_BYTES = 50 * 1024**3 # 50 GiB (increased from PyPI default 10 GiB)
PYPI_FILE_LIMIT_BYTES = 100 * 1024**2 # 100 MiB


def fetch_project_files(package: str) -> list[dict]:
def fetch_project_files(package: str) -> list[dict[str, Any]]:
req = urllib.request.Request(
f"https://pypi.org/simple/{package}/",
headers={
Expand All @@ -30,15 +31,16 @@ def fetch_project_files(package: str) -> list[dict]:
)
with urllib.request.urlopen(req, timeout=30) as resp:
data = json.load(resp)
return data.get("files", [])
return cast("list[dict[str, Any]]", data.get("files", []))


def human(n: int) -> str:
size = float(n)
for unit in ("B", "KiB", "MiB", "GiB", "TiB"):
if abs(n) < 1024 or unit == "TiB":
return f"{n:.2f} {unit}"
n /= 1024
return f"{n:.2f} TiB"
if abs(size) < 1024 or unit == "TiB":
return f"{size:.2f} {unit}"
size /= 1024
return f"{size:.2f} TiB"


def main() -> int:
Expand Down
Loading
Loading