diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 22db68fc604..eb30cc78c29 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -24,12 +24,14 @@ updates: - ".NET" - "dependencies" - # Maintain dependencies for python + # Maintain dependencies for python. + # TODO: Remove these Python Dependabot entries after we have confidence in the + # Python dependency-maintenance workflow. - package-ecosystem: "pip" directory: "python/" schedule: interval: "weekly" - day: "monday" + day: "thursday" labels: - "python" - "dependencies" @@ -37,7 +39,7 @@ updates: directory: "python/" schedule: interval: "weekly" - day: "monday" + day: "thursday" labels: - "python" - "dependencies" diff --git a/.github/workflows/python-dependency-maintenance.yml b/.github/workflows/python-dependency-maintenance.yml new file mode 100644 index 00000000000..dfb7ca175d1 --- /dev/null +++ b/.github/workflows/python-dependency-maintenance.yml @@ -0,0 +1,365 @@ +name: Python - Dependency Maintenance + +on: + workflow_dispatch: + schedule: + - cron: "0 4 * * 1" + +permissions: + contents: write + issues: write + pull-requests: write + +concurrency: + group: python-dependency-maintenance + cancel-in-progress: false + +env: + UV_CACHE_DIR: /tmp/.uv-cache + +jobs: + dependency-maintenance: + name: Dependency Maintenance + runs-on: ubuntu-latest + env: + # Match the existing Python dependency maintenance workflows. Reevaluate if package + # installability starts differing across supported Python versions. + UV_PYTHON: "3.13" + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + with: + fetch-depth: 0 + + - name: Set up python and install the project + uses: ./.github/actions/python-setup + with: + python-version: ${{ env.UV_PYTHON }} + os: ${{ runner.os }} + env: + UV_CACHE_DIR: /tmp/.uv-cache + + - name: Set dependency release cutoff + run: | + cutoff="$(date -u -d '7 days ago' '+%Y-%m-%dT%H:%M:%SZ')" + echo "UV_EXCLUDE_NEWER=${cutoff}" >> "$GITHUB_ENV" + echo "Using dependency release cutoff: ${cutoff}" + + - name: Repin dev dependency declarations + run: uv run poe upgrade-dev-dependency-pins + working-directory: ./python + + - name: Refresh lockfile after dev pin updates + run: uv lock --upgrade + working-directory: ./python + + - name: Save dev dependency changes + run: | + DEV_PATCH="${RUNNER_TEMP}/python-dev-dependency-updates.patch" + git diff -- python/pyproject.toml "python/packages/*/pyproject.toml" python/uv.lock > "${DEV_PATCH}" + if [ -s "${DEV_PATCH}" ]; then + echo "has_dev_changes=true" >> "$GITHUB_OUTPUT" + else + echo "has_dev_changes=false" >> "$GITHUB_OUTPUT" + fi + echo "patch=${DEV_PATCH}" >> "$GITHUB_OUTPUT" + id: dev_changes + + - name: Run dependency bounds test scenarios + id: validate_bounds_test + continue-on-error: true + run: uv run poe validate-dependency-bounds-test --package "*" + working-directory: ./python + + - name: Run dependency upper-bound validation + id: validate_ranges + if: steps.validate_bounds_test.outcome == 'success' + continue-on-error: true + run: uv run poe validate-dependency-bounds-project --mode upper --package "*" + working-directory: ./python + + - name: Upload dependency validation reports + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: dependency-maintenance-results + path: | + python/scripts/dependencies/dependency-bounds-test-results.json + python/scripts/dependencies/dependency-range-results.json + if-no-files-found: warn + + - name: Create issue for failed dependency bounds test + if: steps.validate_bounds_test.outcome != 'success' + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + script: | + const fs = require("fs") + const reportPath = "python/scripts/dependencies/dependency-bounds-test-results.json" + + const owner = context.repo.owner + const repo = context.repo.repo + const openIssues = await github.paginate(github.rest.issues.listForRepo, { + owner, + repo, + state: "open", + per_page: 100, + }) + const openIssueTitles = new Set( + openIssues.filter((issue) => !issue.pull_request).map((issue) => issue.title) + ) + + const formatError = (message) => String(message || "No error output captured.").replace(/```/g, "'''") + const title = "Dependency bounds test failed" + if (openIssueTitles.has(title)) { + core.info(`Issue already exists: ${title}`) + return + } + + const bodyLines = [ + "Automated dependency bounds test mode failed before dependency upper-bound validation could run.", + "", + "The weekly dependency maintenance workflow kept only dev dependency updates for the generated PR, if any, and skipped dependency range updates for this run.", + "", + ] + + if (fs.existsSync(reportPath)) { + const report = JSON.parse(fs.readFileSync(reportPath, "utf8")) + const failedScenarios = (report.scenarios ?? []).filter((scenario) => scenario.status === "failed") + for (const scenario of failedScenarios) { + bodyLines.push(`### ${scenario.name} scenario (${scenario.resolution})`) + const failedPackages = (scenario.packages ?? []).filter((pkg) => pkg.status === "failed") + for (const pkg of failedPackages.slice(0, 10)) { + bodyLines.push( + "", + `- Package: \`${pkg.package_name}\``, + `- Project path: \`${pkg.project_path}\``, + "", + "```", + formatError(pkg.error).slice(0, 3500), + "```" + ) + } + if (failedPackages.length > 10) { + bodyLines.push("", `_Additional failed packages omitted: ${failedPackages.length - 10}_`) + } + } + } else { + bodyLines.push(`No dependency bounds test report was found at \`${reportPath}\`.`) + } + + bodyLines.push("", `Workflow run: ${context.serverUrl}/${owner}/${repo}/actions/runs/${context.runId}`) + + await github.rest.issues.create({ + owner, + repo, + title, + body: bodyLines.join("\n"), + }) + core.info(`Created issue: ${title}`) + + - name: Create issues for failed dependency candidates + if: always() + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + script: | + const fs = require("fs") + const reportPath = "python/scripts/dependencies/dependency-range-results.json" + + if (!fs.existsSync(reportPath)) { + core.info(`No dependency range report found at ${reportPath}`) + return + } + + const report = JSON.parse(fs.readFileSync(reportPath, "utf8")) + const dependencyFailures = [] + + for (const packageResult of report.packages ?? []) { + for (const dependency of packageResult.dependencies ?? []) { + const candidateVersions = new Set(dependency.candidate_versions ?? []) + const failedAttempts = (dependency.attempts ?? []).filter( + (attempt) => attempt.status === "failed" && candidateVersions.has(attempt.trial_upper) + ) + if (!failedAttempts.length) { + continue + } + + const failuresByVersion = new Map() + for (const attempt of failedAttempts) { + const version = attempt.trial_upper || "unknown" + if (!failuresByVersion.has(version)) { + failuresByVersion.set(version, attempt.error || "No error output captured.") + } + } + + dependencyFailures.push({ + packageName: packageResult.package_name, + projectPath: packageResult.project_path, + dependencyName: dependency.name, + originalRequirements: dependency.original_requirements ?? [], + finalRequirements: dependency.final_requirements ?? [], + failedVersions: [...failuresByVersion.entries()].map(([version, error]) => ({ version, error })), + }) + } + } + + if (!dependencyFailures.length) { + core.info("No failing dependency candidates found.") + return + } + + const owner = context.repo.owner + const repo = context.repo.repo + const openIssues = await github.paginate(github.rest.issues.listForRepo, { + owner, + repo, + state: "open", + per_page: 100, + }) + const openIssueTitles = new Set( + openIssues.filter((issue) => !issue.pull_request).map((issue) => issue.title) + ) + + const formatError = (message) => String(message || "No error output captured.").replace(/```/g, "'''") + + for (const failure of dependencyFailures) { + const title = `Dependency validation failed: ${failure.dependencyName} (${failure.packageName})` + if (openIssueTitles.has(title)) { + core.info(`Issue already exists: ${title}`) + continue + } + + const visibleFailures = failure.failedVersions.slice(0, 5) + const omittedCount = failure.failedVersions.length - visibleFailures.length + const failureDetails = visibleFailures + .map( + (entry) => + `- \`${entry.version}\`\n\n\`\`\`\n${formatError(entry.error).slice(0, 3500)}\n\`\`\`` + ) + .join("\n\n") + + const body = [ + "Automated dependency range validation found candidate versions that failed checks.", + "", + `- Package: \`${failure.packageName}\``, + `- Project path: \`${failure.projectPath}\``, + `- Dependency: \`${failure.dependencyName}\``, + `- Original requirements: ${ + failure.originalRequirements.length + ? failure.originalRequirements.map((value) => `\`${value}\``).join(", ") + : "_none_" + }`, + `- Final requirements after run: ${ + failure.finalRequirements.length + ? failure.finalRequirements.map((value) => `\`${value}\``).join(", ") + : "_none_" + }`, + "", + "### Failed versions and errors", + failureDetails, + omittedCount > 0 ? `\n_Additional failed versions omitted: ${omittedCount}_` : "", + "", + `Workflow run: ${context.serverUrl}/${owner}/${repo}/actions/runs/${context.runId}`, + ].join("\n") + + await github.rest.issues.create({ + owner, + repo, + title, + body, + }) + openIssueTitles.add(title) + core.info(`Created issue: ${title}`) + } + + - name: Keep only dev updates when range validation fails + if: steps.validate_bounds_test.outcome != 'success' || steps.validate_ranges.outcome != 'success' + env: + DEV_PATCH: ${{ steps.dev_changes.outputs.patch }} + HAS_DEV_CHANGES: ${{ steps.dev_changes.outputs.has_dev_changes }} + run: | + git restore python/pyproject.toml python/packages/*/pyproject.toml python/uv.lock + if [ "${HAS_DEV_CHANGES}" = "true" ]; then + git apply "${DEV_PATCH}" + fi + + - name: Refresh lockfile after dependency range updates + if: steps.validate_bounds_test.outcome == 'success' && steps.validate_ranges.outcome == 'success' + run: uv lock --upgrade + working-directory: ./python + + - name: Install final dependency set + run: uv run poe install + working-directory: ./python + + - name: Run final checks + run: uv run poe check + working-directory: ./python + + - name: Run final typing + run: uv run poe typing + working-directory: ./python + + - name: Commit and push dependency updates + id: commit_updates + run: | + BRANCH="automation/python-dependency-maintenance" + + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git checkout -B "${BRANCH}" + + git add python/pyproject.toml python/packages/*/pyproject.toml python/uv.lock + if git diff --cached --quiet; then + echo "has_changes=false" >> "$GITHUB_OUTPUT" + echo "No dependency updates to commit." + exit 0 + fi + + git commit -m "Python: chore: update dependencies" + git push --force-with-lease --set-upstream origin "${BRANCH}" + echo "has_changes=true" >> "$GITHUB_OUTPUT" + + - name: Create or update pull request with GitHub CLI + if: steps.commit_updates.outputs.has_changes == 'true' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + BRANCH="automation/python-dependency-maintenance" + PR_TITLE="Python: chore: update dependencies" + PR_BODY_FILE="$(mktemp)" + + cat > "${PR_BODY_FILE}" <<'EOF' + ### Motivation & Context + + This automated update keeps Python dependency metadata coherent across the uv workspace. Python dependencies can be declared in multiple `pyproject.toml` files, but the workspace has one shared `python/uv.lock`, so dependency maintenance should update and validate them together instead of through per-manifest Dependabot PRs. + + ### Description & Review Guide + + - **What are the major changes?** Refresh Python dev dependency pins, update package dependency ranges when the bounds tooling succeeds, and refresh `python/uv.lock`. + - **What is the impact of these changes?** Keeps the Python workspace dependency set current while producing at most one dependency PR for the week. If dependency range validation fails, this PR contains only the dev dependency updates that still pass final validation, and separate issues track failed range candidates. + - **What do you want reviewers to focus on?** Review the generated dependency metadata changes and any dependency-range updates for package-specific compatibility concerns. + + + + ### Related Issue + + No linked issue; this PR is generated by scheduled Python dependency maintenance. + + ### Contribution Checklist + + - [x] The code builds clean without any errors or warnings + - [x] All unit tests pass, and I have added new tests where possible + - [x] The PR follows the [Contribution Guidelines](https://github.com/microsoft/agent-framework/blob/main/CONTRIBUTING.md) + - [ ] This PR is linked to an issue and there is no other open PR for this issue (see Related Issue above). + - [x] **This is not a breaking change.** If it _is_ a breaking change, add the `breaking change` label (or add "[BREAKING]" to the title prefix, before or after any language prefix) — a workflow keeps the label and title prefix in sync automatically. + EOF + + PR_NUMBER="$(gh pr list --head "${BRANCH}" --base main --state open --json number --jq '.[0].number')" + if [ -n "${PR_NUMBER}" ]; then + gh pr edit "${PR_NUMBER}" --title "${PR_TITLE}" --body-file "${PR_BODY_FILE}" + else + gh pr create --base main --head "${BRANCH}" --title "${PR_TITLE}" --body-file "${PR_BODY_FILE}" + fi diff --git a/.github/workflows/python-dependency-range-validation.yml b/.github/workflows/python-dependency-range-validation.yml deleted file mode 100644 index 67c8d92bc80..00000000000 --- a/.github/workflows/python-dependency-range-validation.yml +++ /dev/null @@ -1,216 +0,0 @@ -# Probe the highest allowed dependency versions, then open issues/PRs from the passing updates. -name: Python - Dependency Range Validation - -on: - workflow_dispatch: - -permissions: - contents: write - issues: write - pull-requests: write - -env: - UV_CACHE_DIR: /tmp/.uv-cache - -jobs: - dependency-range-validation: - name: Dependency Range Validation - runs-on: ubuntu-latest - env: - # For now only run 3.13, if we do encounter situations where there are mismatches between packages and python versions (other then 3.10 and 3.14 which are known to not be able to install everything) - # then we will have to reevaluate. - UV_PYTHON: "3.13" - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - with: - fetch-depth: 0 - - - name: Set up python and install the project - uses: ./.github/actions/python-setup - with: - python-version: ${{ env.UV_PYTHON }} - os: ${{ runner.os }} - env: - UV_CACHE_DIR: /tmp/.uv-cache - - - name: Run dependency range validation - id: validate_ranges - # Keep workflow running so we can still publish diagnostics from this run. - continue-on-error: true - run: uv run poe validate-dependency-bounds-project --mode upper --package "*" - working-directory: ./python - - - name: Upload dependency range report - # Always publish the report so failures are inspectable even when validation fails. - if: always() - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 - with: - name: dependency-range-results - path: python/scripts/dependencies/dependency-range-results.json - if-no-files-found: warn - - - name: Create issues for failed dependency candidates - # Always process the report so failed candidates create actionable tracking issues. - if: always() - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 - with: - script: | - const fs = require("fs") - const reportPath = "python/scripts/dependencies/dependency-range-results.json" - - if (!fs.existsSync(reportPath)) { - core.warning(`No dependency range report found at ${reportPath}`) - return - } - - const report = JSON.parse(fs.readFileSync(reportPath, "utf8")) - const dependencyFailures = [] - - for (const packageResult of report.packages ?? []) { - for (const dependency of packageResult.dependencies ?? []) { - const candidateVersions = new Set(dependency.candidate_versions ?? []) - const failedAttempts = (dependency.attempts ?? []).filter( - (attempt) => attempt.status === "failed" && candidateVersions.has(attempt.trial_upper) - ) - if (!failedAttempts.length) { - continue - } - - const failuresByVersion = new Map() - for (const attempt of failedAttempts) { - const version = attempt.trial_upper || "unknown" - if (!failuresByVersion.has(version)) { - failuresByVersion.set(version, attempt.error || "No error output captured.") - } - } - - dependencyFailures.push({ - packageName: packageResult.package_name, - projectPath: packageResult.project_path, - dependencyName: dependency.name, - originalRequirements: dependency.original_requirements ?? [], - finalRequirements: dependency.final_requirements ?? [], - failedVersions: [...failuresByVersion.entries()].map(([version, error]) => ({ version, error })), - }) - } - } - - if (!dependencyFailures.length) { - core.info("No failing dependency candidates found.") - return - } - - const owner = context.repo.owner - const repo = context.repo.repo - const openIssues = await github.paginate(github.rest.issues.listForRepo, { - owner, - repo, - state: "open", - per_page: 100, - }) - const openIssueTitles = new Set( - openIssues.filter((issue) => !issue.pull_request).map((issue) => issue.title) - ) - - const formatError = (message) => String(message || "No error output captured.").replace(/```/g, "'''") - - for (const failure of dependencyFailures) { - const title = `Dependency validation failed: ${failure.dependencyName} (${failure.packageName})` - if (openIssueTitles.has(title)) { - core.info(`Issue already exists: ${title}`) - continue - } - - const visibleFailures = failure.failedVersions.slice(0, 5) - const omittedCount = failure.failedVersions.length - visibleFailures.length - const failureDetails = visibleFailures - .map( - (entry) => - `- \`${entry.version}\`\n\n\`\`\`\n${formatError(entry.error).slice(0, 3500)}\n\`\`\`` - ) - .join("\n\n") - - const body = [ - "Automated dependency range validation found candidate versions that failed checks.", - "", - `- Package: \`${failure.packageName}\``, - `- Project path: \`${failure.projectPath}\``, - `- Dependency: \`${failure.dependencyName}\``, - `- Original requirements: ${ - failure.originalRequirements.length - ? failure.originalRequirements.map((value) => `\`${value}\``).join(", ") - : "_none_" - }`, - `- Final requirements after run: ${ - failure.finalRequirements.length - ? failure.finalRequirements.map((value) => `\`${value}\``).join(", ") - : "_none_" - }`, - "", - "### Failed versions and errors", - failureDetails, - omittedCount > 0 ? `\n_Additional failed versions omitted: ${omittedCount}_` : "", - "", - `Workflow run: ${context.serverUrl}/${owner}/${repo}/actions/runs/${context.runId}`, - ].join("\n") - - await github.rest.issues.create({ - owner, - repo, - title, - body, - }) - openIssueTitles.add(title) - core.info(`Created issue: ${title}`) - } - - - name: Refresh lockfile - # Only refresh lockfile after a clean validation to avoid committing known-bad ranges. - if: steps.validate_ranges.outcome == 'success' - run: uv lock --upgrade - working-directory: ./python - - - name: Commit and push dependency updates - id: commit_updates - if: steps.validate_ranges.outcome == 'success' - run: | - BRANCH="automation/python-dependency-range-updates" - - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git checkout -B "${BRANCH}" - - git add python/packages/*/pyproject.toml python/uv.lock - if git diff --cached --quiet; then - echo "has_changes=false" >> "$GITHUB_OUTPUT" - echo "No dependency updates to commit." - exit 0 - fi - - git commit -m "chore: update dependency ranges" - git push --force-with-lease --set-upstream origin "${BRANCH}" - echo "has_changes=true" >> "$GITHUB_OUTPUT" - - - name: Create or update pull request with GitHub CLI - # Only open/update PRs for validated updates to keep automation branches trustworthy. - if: steps.validate_ranges.outcome == 'success' && steps.commit_updates.outputs.has_changes == 'true' - run: | - BRANCH="automation/python-dependency-range-updates" - PR_TITLE="Python: chore: update dependency ranges" - PR_BODY_FILE="$(mktemp)" - - cat > "${PR_BODY_FILE}" <<'EOF' - This PR was generated by the dependency range validation workflow. - - - Ran `uv run poe validate-dependency-bounds-project --mode upper --package "*"` - - Updated package dependency bounds - - Refreshed `python/uv.lock` with `uv lock --upgrade` - EOF - - PR_NUMBER="$(gh pr list --head "${BRANCH}" --base main --state open --json number --jq '.[0].number')" - if [ -n "${PR_NUMBER}" ]; then - gh pr edit "${PR_NUMBER}" --title "${PR_TITLE}" --body-file "${PR_BODY_FILE}" - else - gh pr create --base main --head "${BRANCH}" --title "${PR_TITLE}" --body-file "${PR_BODY_FILE}" - fi diff --git a/.github/workflows/python-dev-dependency-upgrade.yml b/.github/workflows/python-dev-dependency-upgrade.yml deleted file mode 100644 index dc55da9227a..00000000000 --- a/.github/workflows/python-dev-dependency-upgrade.yml +++ /dev/null @@ -1,91 +0,0 @@ -name: Python - Dev Dependency Upgrade - -on: - workflow_dispatch: - -permissions: - contents: write - pull-requests: write - -env: - UV_CACHE_DIR: /tmp/.uv-cache - -jobs: - upgrade-dev-dependencies: - name: Upgrade Dev Dependencies - runs-on: ubuntu-latest - env: - UV_PYTHON: "3.13" - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - with: - fetch-depth: 0 - - - name: Set up python and install the project - uses: ./.github/actions/python-setup - with: - python-version: ${{ env.UV_PYTHON }} - os: ${{ runner.os }} - env: - UV_CACHE_DIR: /tmp/.uv-cache - - - name: Upgrade dev dependencies and validate workspace - run: uv run poe upgrade-dev-dependencies - working-directory: ./python - - - name: Commit and push dev dependency updates - id: commit_updates - run: | - BRANCH="automation/python-dev-dependency-updates" - - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git checkout -B "${BRANCH}" - - git add python/pyproject.toml python/packages/*/pyproject.toml python/uv.lock - if git diff --cached --quiet; then - echo "has_changes=false" >> "$GITHUB_OUTPUT" - echo "No dev dependency updates to commit." - exit 0 - fi - - git commit -F- <<'EOF' - Python: chore: upgrade dev dependencies - EOF - git push --force-with-lease --set-upstream origin "${BRANCH}" - echo "has_changes=true" >> "$GITHUB_OUTPUT" - - - name: Create or update pull request with GitHub CLI - if: steps.commit_updates.outputs.has_changes == 'true' - run: | - BRANCH="automation/python-dev-dependency-updates" - PR_TITLE="Python: chore: upgrade dev dependencies" - PR_BODY_FILE="$(mktemp)" - - cat > "${PR_BODY_FILE}" <<'EOF' - ### Motivation and Context - - This automated update refreshes Python dev dependency pins across the workspace and reruns the repo validation gates before opening a pull request. - - ### Description - - - Ran `uv run poe upgrade-dev-dependencies` - - Refreshed dev dependency pins in workspace `pyproject.toml` files - - Refreshed `python/uv.lock` with `uv lock --upgrade` - - Reinstalled from the frozen lockfile and reran `check`, `typing`, and `test` - - ### Contribution Checklist - - - [x] The code builds clean without any errors or warnings - - [x] The PR follows the [Contribution Guidelines](https://github.com/microsoft/agent-framework/blob/main/CONTRIBUTING.md) - - [x] All unit tests pass, and I have added new tests where possible - - [ ] **Is this a breaking change?** If yes, add "[BREAKING]" prefix to the title of the PR. - EOF - - PR_NUMBER="$(gh pr list --head "${BRANCH}" --base main --state open --json number --jq '.[0].number')" - if [ -n "${PR_NUMBER}" ]; then - gh pr edit "${PR_NUMBER}" --title "${PR_TITLE}" --body-file "${PR_BODY_FILE}" - else - gh pr create --base main --head "${BRANCH}" --title "${PR_TITLE}" --body-file "${PR_BODY_FILE}" - fi diff --git a/python/scripts/dependencies/_dependency_bounds_lower_impl.py b/python/scripts/dependencies/_dependency_bounds_lower_impl.py index 308e2063506..3e272b3eed0 100644 --- a/python/scripts/dependencies/_dependency_bounds_lower_impl.py +++ b/python/scripts/dependencies/_dependency_bounds_lower_impl.py @@ -332,10 +332,16 @@ def _load_lock_versions(workspace_root: Path) -> dict[str, list[Version]]: class VersionCatalog: """Cache and fetch available dependency versions.""" - def __init__(self, lock_versions: dict[str, list[Version]], source: str) -> None: + def __init__( + self, + lock_versions: dict[str, list[Version]], + source: str, + exclude_newer: datetime | None = None, + ) -> None: """Initialize the catalog with lock-based fallback and fetch source.""" self._lock_versions = lock_versions self._source = source + self._exclude_newer = exclude_newer if exclude_newer is not None else _load_exclude_newer_from_env() self._cache: dict[str, list[Version]] = {} self._lock = threading.Lock() @@ -365,7 +371,11 @@ def _fetch(self, package_name: str) -> list[Version]: for raw_version, files in payload.get("releases", {}).items(): if not files: continue - non_yanked = any(not bool(file_info.get("yanked", False)) for file_info in files) + non_yanked = any( + not bool(file_info.get("yanked", False)) + and _upload_is_not_newer(file_info, exclude_newer=self._exclude_newer) + for file_info in files + ) if not non_yanked: continue try: @@ -377,6 +387,35 @@ def _fetch(self, package_name: str) -> list[Version]: return self._lock_versions.get(package_name, []) +def _load_exclude_newer_from_env() -> datetime | None: + raw_value = os.environ.get("UV_EXCLUDE_NEWER") + if not raw_value: + return None + normalized = raw_value.removesuffix("Z") + try: + parsed = datetime.fromisoformat(normalized) + except ValueError: + return None + if parsed.tzinfo is None: + return parsed.replace(tzinfo=timezone.utc) + return parsed.astimezone(timezone.utc) + + +def _upload_is_not_newer(file_info: dict[str, object], *, exclude_newer: datetime | None) -> bool: + if exclude_newer is None: + return True + upload_time = file_info.get("upload_time_iso_8601") or file_info.get("upload_time") + if not isinstance(upload_time, str) or not upload_time: + return False + normalized = upload_time.removesuffix("Z") + try: + parsed = datetime.fromisoformat(normalized) + except ValueError: + return False + parsed = parsed.replace(tzinfo=timezone.utc) if parsed.tzinfo is None else parsed.astimezone(timezone.utc) + return parsed <= exclude_newer + + def _load_package_name(pyproject_file: Path) -> str: with pyproject_file.open("rb") as f: data = tomli.load(f) diff --git a/python/scripts/dependencies/_dependency_bounds_upper_impl.py b/python/scripts/dependencies/_dependency_bounds_upper_impl.py index 352a251c36c..d086643a65f 100644 --- a/python/scripts/dependencies/_dependency_bounds_upper_impl.py +++ b/python/scripts/dependencies/_dependency_bounds_upper_impl.py @@ -426,10 +426,16 @@ def _load_lock_versions(workspace_root: Path) -> dict[str, list[Version]]: class VersionCatalog: """Cache and fetch available dependency versions.""" - def __init__(self, lock_versions: dict[str, list[Version]], source: str) -> None: + def __init__( + self, + lock_versions: dict[str, list[Version]], + source: str, + exclude_newer: datetime | None = None, + ) -> None: """Initialize the catalog with lock-based fallback and fetch source.""" self._lock_versions = lock_versions self._source = source + self._exclude_newer = exclude_newer if exclude_newer is not None else _load_exclude_newer_from_env() self._cache: dict[str, list[Version]] = {} self._lock = threading.Lock() @@ -463,7 +469,11 @@ def _fetch(self, package_name: str) -> list[Version]: for raw_version, files in payload.get("releases", {}).items(): if not files: continue - non_yanked = any(not bool(file_info.get("yanked", False)) for file_info in files) + non_yanked = any( + not bool(file_info.get("yanked", False)) + and _upload_is_not_newer(file_info, exclude_newer=self._exclude_newer) + for file_info in files + ) if not non_yanked: continue try: @@ -475,6 +485,35 @@ def _fetch(self, package_name: str) -> list[Version]: return self._lock_versions.get(package_name, []) +def _load_exclude_newer_from_env() -> datetime | None: + raw_value = os.environ.get("UV_EXCLUDE_NEWER") + if not raw_value: + return None + normalized = raw_value.removesuffix("Z") + try: + parsed = datetime.fromisoformat(normalized) + except ValueError: + return None + if parsed.tzinfo is None: + return parsed.replace(tzinfo=timezone.utc) + return parsed.astimezone(timezone.utc) + + +def _upload_is_not_newer(file_info: dict[str, object], *, exclude_newer: datetime | None) -> bool: + if exclude_newer is None: + return True + upload_time = file_info.get("upload_time_iso_8601") or file_info.get("upload_time") + if not isinstance(upload_time, str) or not upload_time: + return False + normalized = upload_time.removesuffix("Z") + try: + parsed = datetime.fromisoformat(normalized) + except ValueError: + return False + parsed = parsed.replace(tzinfo=timezone.utc) if parsed.tzinfo is None else parsed.astimezone(timezone.utc) + return parsed <= exclude_newer + + def _load_package_name(pyproject_file: Path) -> str: with pyproject_file.open("rb") as f: data = tomli.load(f)