Skip to content
Merged
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
95 changes: 2 additions & 93 deletions .github/scripts/check-hosted-agent-python-requirements.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,9 +47,7 @@
HASH_OPTION = re.compile(r"(?:^|\s)--hash(?:=|\s+)\S+")
INLINE_COMMENT = re.compile(r"\s+#.*$")
VCS_PREFIXES = ("git+", "hg+", "svn+", "bzr+")
PINNED_UV = re.compile(r"(?<![\w-])uv==\d+\.\d+\.\d+(?:[A-Za-z0-9.-]*)?")
FROZEN_UV_SYNC = re.compile(r"\buv\s+sync\b[^;&\n]*\s--frozen(?:\s|$)")
VALID_CODES = {f"PYREQ{number:03d}" for number in range(1, 14)}
VALID_CODES = {f"PYREQ{number:03d}" for number in range(1, 13)}


@dataclasses.dataclass(frozen=True)
Expand Down Expand Up @@ -699,87 +697,6 @@ def validate_uv_resolution(
return []


def validate_uv_runtime_contract(
repo: Path,
revision: str,
files: set[PurePosixPath],
service: ServiceRoot,
trigger: PurePosixPath,
) -> list[Finding]:
sample_yaml = service.manifest.parent / "sample.yaml"
if sample_yaml not in files:
return [
Finding(
"PYREQ013",
"uv-native runtime requires sample.yaml build validation with a pinned uv version and uv sync --frozen",
service.path,
trigger,
sample_yaml,
)
]
try:
sample = yaml.safe_load(read_tree_file(repo, revision, sample_yaml)) or {}
except (yaml.YAMLError, UnicodeDecodeError) as exc:
return [
Finding(
"PYREQ013",
f"cannot validate uv-native sample.yaml build contract: {exc}",
service.path,
trigger,
sample_yaml,
)
]
build = sample.get("build") if isinstance(sample, Mapping) else None
if (
not isinstance(build, str)
or not PINNED_UV.search(build)
or not FROZEN_UV_SYNC.search(build)
):
return [
Finding(
"PYREQ013",
"uv-native sample.yaml build must install an exact uv version and run uv sync --frozen",
service.path,
trigger,
sample_yaml,
)
]

manifest = yaml.safe_load(read_tree_file(repo, revision, service.manifest)) or {}
services = manifest.get("services", {}) if isinstance(manifest, Mapping) else {}
declaration = (
services.get(service.service, {}) if isinstance(services, Mapping) else {}
)
code_configuration = (
declaration.get("codeConfiguration", {})
if isinstance(declaration, Mapping)
else {}
)
remote_build = (
isinstance(code_configuration, Mapping)
and code_configuration.get("dependencyResolution") == "remote_build"
)
dockerfile = service.path / "Dockerfile"
docker_build = False
if dockerfile in files:
content = read_tree_file(repo, revision, dockerfile).decode("utf-8")
docker_build = bool(
re.search(r"ghcr\.io/astral-sh/uv:\d+\.\d+\.\d+", content)
and FROZEN_UV_SYNC.search(content)
)
if not remote_build and not docker_build:
return [
Finding(
"PYREQ013",
"uv-native deployment must use remote dependency resolution or a Dockerfile with pinned uv and uv sync --frozen",
service.path,
trigger,
service.manifest,
)
]
return []


def load_exceptions(path: Path) -> list[ExceptionRule]:
if not path.exists():
return []
Expand Down Expand Up @@ -963,21 +880,13 @@ def collect_findings(
)
)
else:
service = head_services.get(root)
if service is not None:
contract_findings = validate_uv_runtime_contract(
repo, head, head_files, service, trigger
)
findings.extend(contract_findings)
else:
contract_findings = []
lock_content = read_tree_file(repo, head, uv_source).decode("utf-8")
project_content = read_tree_file(repo, head, uv_manifest).decode("utf-8")
uv_findings = parse_uv_lock(
lock_content, project_content, root, trigger, uv_source
)
findings.extend(uv_findings)
if resolve and not contract_findings and not uv_findings:
if resolve and not uv_findings:
findings.extend(
validate_uv_resolution(repo, head, head_files, root, trigger, uv)
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -96,18 +96,6 @@ def add_uv_service(
service=service,
requirements=None,
)
self.repo.write(
f"{sample}/azure.yaml",
"name: example\nservices:\n example:\n language: python\n"
f" project: {service}\n"
" codeConfiguration:\n"
" dependencyResolution: remote_build\n",
)
self.repo.write(
f"{sample}/sample.yaml",
"name: uv example\n"
'build: python -m pip install "uv==0.11.7" && uv sync --frozen\n',
)
self.repo.write(
f"{root}/pyproject.toml",
'[project]\nname = "example"\nversion = "0.1.0"\n'
Expand Down Expand Up @@ -223,51 +211,6 @@ def test_uv_config_change_requires_updated_lock(self) -> None:
self.repo.commit("change uv configuration")
self.assertEqual(["PYREQ006"], [finding.code for finding in self.findings()])

def test_uv_project_requires_pinned_frozen_validation(self) -> None:
root = self.add_uv_service()
sample = Path(root).parents[1].as_posix()
self.repo.write(f"{sample}/sample.yaml", "name: uv example\nbuild: uv sync\n")
self.repo.commit("add invalid uv sample")
self.assertEqual(["PYREQ013"], [finding.code for finding in self.findings()])

def test_uv_project_requires_lock_aware_deployment(self) -> None:
root = self.add_uv_service()
sample = Path(root).parents[1].as_posix()
self.repo.write(
f"{sample}/azure.yaml",
"name: example\nservices:\n example:\n language: python\n"
" project: src/example\n",
)
self.repo.commit("add uv sample without lock-aware deployment")
self.assertEqual(["PYREQ013"], [finding.code for finding in self.findings()])

def test_uv_project_accepts_lock_aware_docker_deployment(self) -> None:
root = self.add_uv_service()
sample = Path(root).parents[1].as_posix()
self.repo.write(
f"{sample}/azure.yaml",
"name: example\nservices:\n example:\n language: python\n"
" project: src/example\n",
)
self.repo.write(
f"{root}/Dockerfile",
"FROM ghcr.io/astral-sh/uv:0.11.7 AS uv\n"
"RUN uv sync --frozen --no-dev\n",
)
self.repo.commit("add containerized uv sample")
self.assertEqual([], self.findings())

def test_adopting_uv_requires_the_validation_and_deployment_contract(self) -> None:
root = self.add_uv_service()
sample = Path(root).parents[1].as_posix()
self.repo.write(f"{root}/requirements.txt", "six==1.16.0\n")
self.repo.write(f"{sample}/sample.yaml", "name: uv example\nbuild: uv sync\n")
self.repo.commit("add pip sample with supplemental uv files")
self.base = self.repo.run("rev-parse", "HEAD")
self.repo.remove(f"{root}/requirements.txt")
self.repo.commit("adopt uv without complete runtime contract")
self.assertEqual(["PYREQ013"], [finding.code for finding in self.findings()])

def test_uv_resolution_is_wired_through_repository_check(self) -> None:
root = self.add_uv_service()
self.repo.commit("add uv sample")
Expand Down
9 changes: 4 additions & 5 deletions samples/python/hosted-agents/AGENTS-template.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,11 +47,10 @@ Follow the shared Python Hosted Agent dependency policy at
Use `pyproject.toml` and `uv.lock` by default. A fully resolved
`requirements.txt` remains accepted as a backward-compatible fallback. The
selected artifact must pin the complete direct and transitive runtime graph; if
both forms exist, the uv pair is authoritative. A uv-native sample must use a
pinned uv version and `uv sync --frozen` in its validation and deployment paths.
Update the selected artifact whenever a dependency input changes. Run the policy
checker, including its `--resolve` closure check when network access is available,
before completing dependency changes.
both forms exist, the uv pair is authoritative. Update the selected artifact
whenever a dependency input changes. Run the policy checker, including its
`--resolve` closure check when network access is available, before completing
dependency changes.

## Runtime logging

Expand Down
7 changes: 3 additions & 4 deletions samples/python/hosted-agents/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,10 +43,9 @@ Python Hosted Agent runtime or change its dependencies.
- Before finishing a dependency change, run the checker documented in
`DEPENDENCY_POLICY.md`, including `--resolve` when network access is available.

A uv-native sample may omit `requirements.txt` when `sample.yaml` installs a
pinned uv version and runs `uv sync --frozen`. Its deployment must consume the
lock through `dependencyResolution: remote_build`, or through a Dockerfile that
uses a versioned `ghcr.io/astral-sh/uv` image and runs `uv sync --frozen`.
A uv-native sample may omit `requirements.txt`. Keep `pyproject.toml` and
`uv.lock` synchronized, and run the frozen resolution check before submitting
dependency changes.

## Cloud E2E contract for new samples

Expand Down
3 changes: 1 addition & 2 deletions samples/python/hosted-agents/DEPENDENCY_POLICY.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ sample/
└── uv.lock
```

CI validates that the lock matches the project and exports a complete runtime graph. The sample's `sample.yaml` build command must install a pinned uv version and use `uv sync --frozen`. Deployment must use Hosted Agent remote dependency resolution or a Dockerfile that installs a pinned uv version and uses `uv sync --frozen`. Consumers use uv directly; a duplicate `requirements.txt` export is not required.
CI uses a pinned uv version to verify that the lock matches the project and to export a complete runtime graph without updating the lock. Consumers use uv directly; a duplicate `requirements.txt` export is not required.

### Backward-compatible fallback: requirements.txt

Expand Down Expand Up @@ -210,4 +210,3 @@ Common failures:
| `PYREQ010` | A requirement or uv TOML document is syntactically invalid. |
| `PYREQ011` | A direct URL dependency is present without an approved exception. |
| `PYREQ012` | Pip or uv could not validate the committed artifact. |
| `PYREQ013` | A uv-native runtime does not declare pinned, frozen uv validation and a lock-aware deployment path. |
2 changes: 1 addition & 1 deletion samples/python/hosted-agents/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -205,7 +205,7 @@ Already built an agent with CrewAI or your own code? The protocol SDKs (`azure-a

## Python dependencies

New Python Hosted Agent runtimes use `pyproject.toml` with `uv.lock` by default. A fully resolved `requirements.txt` remains accepted as a backward-compatible fallback. The selected artifact must capture the complete dependency graph; uv-native validation and deployment use a pinned uv version with `uv sync --frozen`.
New Python Hosted Agent runtimes use `pyproject.toml` with `uv.lock` by default. A fully resolved `requirements.txt` remains accepted as a backward-compatible fallback. The selected artifact must capture the complete dependency graph; CI validates uv locks with a pinned uv version and a frozen production export.

See the [Python Hosted Agent dependency policy](DEPENDENCY_POLICY.md) for pinning rules, authoring-tool examples, validation commands, ratchet behavior, and exceptions.

Expand Down
Loading