From 7120d443266988c72211dbf6da3aa8d9f53e5543 Mon Sep 17 00:00:00 2001 From: Abhijeet Prasad Date: Wed, 13 May 2026 15:46:39 -0400 Subject: [PATCH] chore(deps): limit dependency updates to older releases Configure uv exclude-newer to avoid packages uploaded in the last five days during weekly dependency updates. Teach the matrix latest updater to honor the same cutoff when selecting provider pins from PyPI. --- .github/workflows/dependency-updates.yml | 2 +- py/pyproject.toml | 1 + py/scripts/update-matrix-latest.py | 88 ++++++++++++++++++++++-- py/uv.lock | 5 +- 4 files changed, 87 insertions(+), 9 deletions(-) diff --git a/.github/workflows/dependency-updates.yml b/.github/workflows/dependency-updates.yml index f2a90bcc..b6d7a17c 100644 --- a/.github/workflows/dependency-updates.yml +++ b/.github/workflows/dependency-updates.yml @@ -57,7 +57,7 @@ jobs: with: title: "chore(deps): weekly dependency update" body: | - Automated weekly dependency update via `python scripts/update-matrix-latest.py && uv lock --upgrade`. + Automated weekly dependency update via `python scripts/update-matrix-latest.py && uv lock --upgrade`, limited by `tool.uv.exclude-newer = "5 days"`. ${{ steps.labels.outputs.needs_rerecord == 'true' && '⚠️ **Provider SDK packages changed.** A human needs to re-record cassettes locally before merging.' || '✅ Only test infrastructure deps changed. Safe to merge if CI passes.' }} branch: deps/weekly-update-${{ steps.date.outputs.date }} diff --git a/py/pyproject.toml b/py/pyproject.toml index 327f0823..72c67615 100644 --- a/py/pyproject.toml +++ b/py/pyproject.toml @@ -253,6 +253,7 @@ dev = [ # --------------------------------------------------------------------------- [tool.uv] +exclude-newer = "5 days" conflicts = [ # Groups that pin or transitively require conflicting openai versions. [ diff --git a/py/scripts/update-matrix-latest.py b/py/scripts/update-matrix-latest.py index 8831326c..1e8020e0 100644 --- a/py/scripts/update-matrix-latest.py +++ b/py/scripts/update-matrix-latest.py @@ -17,8 +17,10 @@ import urllib.error import urllib.parse import urllib.request +from datetime import datetime, timedelta, timezone import tomllib +from packaging.version import InvalidVersion, Version _PROJECT_DIR = pathlib.Path(__file__).resolve().parent.parent @@ -28,14 +30,20 @@ _EXACT_REQ_RE = re.compile(r"^(?P[A-Za-z0-9_.-]+)(?P\[[A-Za-z0-9_,.-]+\])?==(?P[^\s]+)$") _USER_AGENT = "braintrust-sdk-python dependency updater" _TIMEOUT_SECS = 30 +_FRIENDLY_DURATION_RE = re.compile(r"^\s*(?P\d+)\s*(?Phour|hours|day|days|week|weeks)\s*$") +_ISO_DURATION_RE = re.compile(r"^P(?:(?P\d+)D)?(?:T(?P\d+)H)?$") class UpdateError(RuntimeError): """Raised when the matrix latest pins cannot be updated safely.""" +def _load_pyproject() -> dict: + return tomllib.loads(_PYPROJECT.read_text()) + + def _load_matrix() -> dict[str, str]: - pyproject = tomllib.loads(_PYPROJECT.read_text()) + pyproject = _load_pyproject() matrix = pyproject.get("tool", {}).get("braintrust", {}).get("matrix", {}) latest_reqs: dict[str, str] = {} @@ -53,7 +61,56 @@ def _parse_exact_req(req: str) -> tuple[str, str, str]: return match.group("name"), match.group("extras") or "", match.group("version") -def _fetch_latest_version(package_name: str) -> str: +def _parse_exclude_newer(value: str) -> datetime: + now = datetime.now(timezone.utc) + friendly_match = _FRIENDLY_DURATION_RE.fullmatch(value) + if friendly_match: + count = int(friendly_match.group("count")) + unit = friendly_match.group("unit") + if unit.startswith("hour"): + return now - timedelta(hours=count) + if unit.startswith("day"): + return now - timedelta(days=count) + if unit.startswith("week"): + return now - timedelta(weeks=count) + + iso_match = _ISO_DURATION_RE.fullmatch(value) + if iso_match and (iso_match.group("days") or iso_match.group("hours")): + return now - timedelta(days=int(iso_match.group("days") or 0), hours=int(iso_match.group("hours") or 0)) + + timestamp = value.replace("Z", "+00:00") + try: + parsed = datetime.fromisoformat(timestamp) + except ValueError as exc: + raise UpdateError(f"Unsupported tool.uv.exclude-newer value: {value!r}") from exc + if parsed.tzinfo is None: + raise UpdateError(f"tool.uv.exclude-newer must include a timezone: {value!r}") + return parsed.astimezone(timezone.utc) + + +def _load_exclude_newer() -> datetime | None: + value = _load_pyproject().get("tool", {}).get("uv", {}).get("exclude-newer") + if value is None: + return None + if not isinstance(value, str): + raise UpdateError("tool.uv.exclude-newer must be a string") + return _parse_exclude_newer(value) + + +def _uploaded_before_cutoff(files: list[dict], cutoff: datetime) -> bool: + for file_info in files: + if file_info.get("yanked"): + continue + upload_time = file_info.get("upload_time_iso_8601") + if not upload_time: + continue + uploaded_at = datetime.fromisoformat(str(upload_time).replace("Z", "+00:00")).astimezone(timezone.utc) + if uploaded_at <= cutoff: + return True + return False + + +def _fetch_latest_version(package_name: str, *, exclude_newer: datetime | None = None) -> str: url = f"https://pypi.org/pypi/{urllib.parse.quote(package_name)}/json" request = urllib.request.Request(url, headers={"User-Agent": _USER_AGENT}) @@ -65,14 +122,31 @@ def _fetch_latest_version(package_name: str) -> str: except urllib.error.URLError as exc: raise UpdateError(f"Failed to fetch {package_name} metadata from PyPI: {exc.reason}") from exc - version = str(payload.get("info", {}).get("version", "")).strip() - if not version: - raise UpdateError(f"PyPI did not return a version for {package_name}") - return version + if exclude_newer is None: + version = str(payload.get("info", {}).get("version", "")).strip() + if not version: + raise UpdateError(f"PyPI did not return a version for {package_name}") + return version + + candidates: list[Version] = [] + for version, files in payload.get("releases", {}).items(): + try: + parsed_version = Version(version) + except InvalidVersion: + continue + if parsed_version.is_prerelease: + continue + if files and _uploaded_before_cutoff(files, exclude_newer): + candidates.append(parsed_version) + + if not candidates: + raise UpdateError(f"PyPI did not return a {package_name} version older than tool.uv.exclude-newer") + return str(max(candidates)) def _compute_updates() -> dict[str, tuple[str, str]]: latest_reqs = _load_matrix() + exclude_newer = _load_exclude_newer() package_cache: dict[str, str] = {} updates: dict[str, tuple[str, str]] = {} @@ -80,7 +154,7 @@ def _compute_updates() -> dict[str, tuple[str, str]]: package_name, extras, current_version = _parse_exact_req(current_req) latest_version = package_cache.get(package_name) if latest_version is None: - latest_version = _fetch_latest_version(package_name) + latest_version = _fetch_latest_version(package_name, exclude_newer=exclude_newer) package_cache[package_name] = latest_version if latest_version != current_version: diff --git a/py/uv.lock b/py/uv.lock index 6a3f8e62..a9c27ae8 100644 --- a/py/uv.lock +++ b/py/uv.lock @@ -70,6 +70,10 @@ conflicts = [[ { package = "braintrust", group = "test-pydantic-ai-logfire" }, ]] +[options] +exclude-newer = "2026-05-08T19:46:59.869452Z" +exclude-newer-span = "P5D" + [[package]] name = "ag-ui-protocol" version = "0.1.18" @@ -2057,7 +2061,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/94/6f/2adb571fda448d4afd2466e1cef2963fefdc6b37847da05249983e415f17/fastavro-1.12.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:bc44ba6289fb1f5ee318335958dde6ad6d742dcb4bb8930de843e9024c64b68c", size = 3281842, upload-time = "2026-04-24T14:37:20.833Z" }, { url = "https://files.pythonhosted.org/packages/17/07/4bad2e96c4c6bae40253be2573cc09c1e5b9ccf821e1ff74e0d33b64bf90/fastavro-1.12.2-cp314-cp314-win_amd64.whl", hash = "sha256:a475418f71c5aed69899813ecccf392429c08c3a63df3030129db71760b0db8f", size = 450903, upload-time = "2026-04-24T14:37:23.059Z" }, { url = "https://files.pythonhosted.org/packages/5b/b7/180f67ba9a46ba23a1ff6432f48d3087d4f2048579ecc262b00426cb1c63/fastavro-1.12.2-cp314-cp314-win_arm64.whl", hash = "sha256:daec9f9655a1d4636613c47d6d3343f6e039150d66cdce62543e20ca36612a8a", size = 391076, upload-time = "2026-04-24T14:37:24.756Z" }, - { url = "https://files.pythonhosted.org/packages/dd/8f/18f60329b627d2118a4a2b19e8741fbd807d60bf0470554e1bbfb7f1bca3/fastavro-1.12.2-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:57594b72cf663bbd0f3ad8a319a999fc3d7c71065a6799b2c1d1a6a137894c5b", size = 1055430, upload-time = "2026-05-09T21:53:14.364Z" }, { url = "https://files.pythonhosted.org/packages/d2/ac/a1fa1fc29df0efc89d4946a743b09bdc9500591b5b92083eaf8e93664916/fastavro-1.12.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:74412132bbfb153cbf704517f2c89f7d3e170feb681b13bceace690f66f8d5fa", size = 3503075, upload-time = "2026-04-24T14:37:26.826Z" }, { url = "https://files.pythonhosted.org/packages/82/bf/4f669e10b6bc38a731ee3400aed1a1e2d0a3e3cf411e72f6b320d3af0eaf/fastavro-1.12.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e367a84c9133018e0a3bc822abe78d7f1f9a6092991a0ec409468cf4ef260282", size = 3410900, upload-time = "2026-04-24T14:37:29.233Z" }, { url = "https://files.pythonhosted.org/packages/10/39/ecb19fdae4158a7730b5963fbf1b6d38d74678392d73083be518642af0c1/fastavro-1.12.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:044fafca0853e9ae14009de7763ac9e8e8f8b96f8a4e90bd58b695443266a370", size = 3335637, upload-time = "2026-04-24T14:37:31.472Z" },