Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
213 changes: 213 additions & 0 deletions .github/workflows/python-dependency-range-validation.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,213 @@
name: Python - Dependency Range Validation
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please add short comment to describe the purpose of this workflow.


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:
UV_PYTHON: "3.13"
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
steps:
- uses: actions/checkout@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-ranges
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@v4
with:
name: dependency-range-results
path: python/scripts/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@v8
with:
script: |
const fs = require("fs")
const reportPath = "python/scripts/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(python): 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-ranges`
- 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
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -205,6 +205,8 @@ WARP.md
**/memory-bank/
**/projectBrief.md
**/tmpclaude*
python/scripts/dependency-range-results.json
python/scripts/dependency-lower-bound-results.json

# Azurite storage emulator files
*/__azurite_db_blob__.json*
Expand Down
33 changes: 32 additions & 1 deletion python/.github/skills/python-package-management/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,13 +33,32 @@ Uses [uv](https://github.com/astral-sh/uv) for dependency management and
# Full setup (venv + install + prek hooks)
uv run poe setup

# Install/update all dependencies
# Install dependencies from lockfile (frozen resolution with prerelease policy)
uv run poe install

# Create venv with specific Python version
uv run poe venv --python 3.12

# Intentionally upgrade a specific dependency to reduce lockfile conflicts
uv lock --upgrade-package <dependency-name> && uv run poe install

# After adding/changing an external dependency, extend min then max bounds
uv run poe validate-dependency-lower-bounds
uv run poe validate-dependency-ranges

# Add a dependency to one project and run both validators for that project/dependency
uv run poe add-dependency-and-validate-bounds --project <workspace-package-name> --dependency "<dependency-spec>"
```

### Dependency Bound Notes

- Stable dependencies (`>=1.0`) should typically be bounded as `>=<known-good>,<next-major>`.
- Prerelease (`dev`/`a`/`b`/`rc`) and `<1.0` dependencies should use hard bounds on a known-good line (avoid open-ended ranges).
- Prefer supporting multiple majors when practical; if APIs diverge across supported majors, use version-conditional imports/paths.
- For dependency changes, run lower-bound discovery first, then upper-bound validation to keep both minimum and maximum constraints current.
- Prefer targeted lock updates with `uv lock --upgrade-package <dependency-name>` to reduce `uv.lock` merge conflicts.
- Use `add-dependency-and-validate-bounds` for package-scoped dependency additions plus bound validation in one command.

## Lazy Loading Pattern

Provider folders in core use `__getattr__` to lazy load from connector packages:
Expand Down Expand Up @@ -74,6 +93,18 @@ def __getattr__(name: str) -> Any:
4. Do **NOT** add to `[all]` extra in `packages/core/pyproject.toml`
5. Do **NOT** create lazy loading in core yet

Recommended dependency workflow during connector implementation:

1. Add the dependency to the target package:
`uv run poe add-dependency-to-project --project <workspace-package-name> --dependency "<dependency-spec>"`
2. Implement connector code and tests.
3. Validate dependency bounds for that package/dependency:
- `uv run poe validate-dependency-lower-bounds-project --project <workspace-package-name> --dependency "<dependency-name>"`
- `uv run poe validate-dependency-ranges-project --project <workspace-package-name> --dependency "<dependency-name>"`
4. If the package has meaningful tests/checks that validate dependency compatibility, you can use the add + validation flow in one command:
`uv run poe add-dependency-and-validate-bounds --project <workspace-package-name> --dependency "<dependency-spec>"`
If compatibility checks are not in place yet, add the dependency first, then implement tests before running bound validation.

### Promotion to Stable

1. Move samples to root `samples/` folder
Expand Down
22 changes: 19 additions & 3 deletions python/CODING_STANDARD.md
Original file line number Diff line number Diff line change
Expand Up @@ -160,10 +160,14 @@ user_msg = Message("user", ["Hello, world!"])
asst_msg = Message("assistant", ["Hello, world!"])

# ❌ Not preferred - unnecessary inheritance
from agent_framework import UserMessage, AssistantMessage
class UserMessage(Message):
pass

user_msg = UserMessage(content="Hello, world!")
asst_msg = AssistantMessage(content="Hello, world!")
class AssistantMessage(Message):
pass

user_msg = UserMessage("user", ["Hello, world!"])
asst_msg = AssistantMessage("assistant", ["Hello, world!"])
```

### Import Structure
Expand Down Expand Up @@ -383,6 +387,18 @@ All non-core packages declare a lower bound on `agent-framework-core` (e.g., `"a
- **Core version changes**: When `agent-framework-core` is updated with breaking or significant changes and its version is bumped, update the `agent-framework-core>=...` lower bound in every other package's `pyproject.toml` to match the new core version.
- **Non-core version changes**: Non-core packages (connectors, extensions) can have their own versions incremented independently while keeping the existing core lower bound pinned. Only raise the core lower bound if the non-core package actually depends on new core APIs.

### External Dependency Version Bounds

The guiding principle for external dependencies is to make the range of allowed versions as broad as possible, even it that means we have to do some conditional imports, and other tricks to allow small changes in versions.
So we use bounded ranges for external package dependencies in `pyproject.toml`:


- For stable dependencies (`>=1.0.0`), use a lower bound at a known-good version and an explicit upper bound that reflects the maximum major version we currently support (for example: `openai>=1.99.0,<3`).
- For prerelease (`dev`/`a`/`b`/`rc`) dependencies, use a known-good lower bound with a hard upper boundary in the same prerelease line (for example: `azure-ai-projects>=2.0.0b3,<2.0.0b4`).
- For `<1.0.0` dependencies, use patch-bounded caps (`>=<known_good>,<next_patch>`), not minor-bounded caps (for example: `a2a-sdk>=0.3.5,<0.3.6`).
- Prefer keeping support for multiple major versions when practical. This may mean that the upper bound spans multiple major versions when the dependency maintains backward compatibility; if APIs differ between supported majors, version-conditional imports/branches are acceptable to preserve compatibility. For `<1.0.0>` and prerelease dependencies, also make the bounds as broad as possible but only for known packages, not for new ones, as the odds of breaking changes being introduced are higher.
- When adding or changing an external dependency, run `uv run poe validate-dependency-lower-bounds` first to extend the minimum supported bound, then run `uv run poe validate-dependency-ranges` to raise/set the maximum supported bound.

### Installation Options

Connectors are distributed as separate packages and are not imported by default in the core package. Users install the specific connectors they need:
Expand Down
27 changes: 26 additions & 1 deletion python/DEV_SETUP.md
Original file line number Diff line number Diff line change
Expand Up @@ -217,10 +217,11 @@ uv run poe setup --python 3.12
```

#### `install`
Install all dependencies including extras and dev dependencies, including updates:
Install all dependencies (including extras and dev dependencies) from the lockfile using frozen resolution:
```bash
uv run poe install
```
For intentional dependency upgrades, run `uv lock --upgrade-package <dependency-name>` and then run `uv run poe install`.

#### `venv`
Create a virtual environment with specified Python version or switch python version:
Expand Down Expand Up @@ -278,6 +279,30 @@ Lint markdown code blocks:
uv run poe markdown-code-lint
```

#### `validate-dependency-ranges`
Validate and extend external dependency upper bounds by running package checks/tests in isolated environments:
```bash
uv run poe validate-dependency-ranges
```

#### `validate-dependency-lower-bounds`
Validate and extend external dependency lower bounds by running package checks/tests in isolated environments:
```bash
uv run poe validate-dependency-lower-bounds
```

When adding or changing an external dependency, run lower bounds first, then upper bounds:
```bash
uv run poe validate-dependency-lower-bounds
uv run poe validate-dependency-ranges
```

#### `add-dependency-and-validate-bounds`
Add an external dependency to a workspace project and run both validators for that same project/dependency:
```bash
uv run poe add-dependency-and-validate-bounds --project <workspace-package-name> --dependency "<dependency-spec>"
```

### Comprehensive Checks

#### `check-packages`
Expand Down
4 changes: 2 additions & 2 deletions python/packages/a2a/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ classifiers = [
]
dependencies = [
"agent-framework-core>=1.0.0rc3",
"a2a-sdk>=0.3.5",
"a2a-sdk>=0.3.5,<0.3.24",
]

[tool.uv]
Expand Down Expand Up @@ -87,7 +87,7 @@ include = "../../shared_tasks.toml"

[tool.poe.tasks]
mypy = "mypy --config-file $POE_ROOT/pyproject.toml agent_framework_a2a"
test = "pytest -m \"not integration\" --cov=agent_framework_a2a --cov-report=term-missing:skip-covered tests"
test = 'pytest -m "not integration" --cov=agent_framework_a2a --cov-report=term-missing:skip-covered tests'

[build-system]
requires = ["flit-core >= 3.11,<4.0"]
Expand Down
Loading
Loading