Skip to content
Merged
Show file tree
Hide file tree
Changes from 9 commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
cac908d
ci: Pin transitive dependencies for tests suites
alexander-alderman-webb May 28, 2026
c332e3a
forgot tox.jinja
alexander-alderman-webb May 28, 2026
0db62e0
cleanup
alexander-alderman-webb May 28, 2026
b7b5385
find dependencies for each Python version
alexander-alderman-webb May 29, 2026
d34e749
fix cache
alexander-alderman-webb May 29, 2026
bb4bcc2
merge master
alexander-alderman-webb May 29, 2026
fc7f034
.
alexander-alderman-webb May 29, 2026
510d3bc
merge master
alexander-alderman-webb May 29, 2026
3457293
unpin fakeredis
alexander-alderman-webb May 29, 2026
601f8be
fix boolean logic
alexander-alderman-webb May 29, 2026
e00794f
simplify
alexander-alderman-webb May 29, 2026
9a2d30b
merge master
alexander-alderman-webb May 29, 2026
3e7ad9f
rerun script
alexander-alderman-webb May 29, 2026
47200a7
unpin setuptools
alexander-alderman-webb May 29, 2026
99ecfc9
pin setuptools for pyramid
alexander-alderman-webb May 29, 2026
1f729dc
add hash of constraints
alexander-alderman-webb Jun 1, 2026
18a1877
merge master
alexander-alderman-webb Jun 1, 2026
cdd43f2
rerun script
alexander-alderman-webb Jun 1, 2026
db82421
Merge branch 'master' into webb/populate-tox/transitive-dependencies
alexander-alderman-webb Jun 1, 2026
3dd3873
add setuptools to ray requirements
alexander-alderman-webb Jun 1, 2026
6ace6fd
add setuptools to gevent
alexander-alderman-webb Jun 1, 2026
cd47347
narrower pin
alexander-alderman-webb Jun 1, 2026
7d09c9e
ci: Only pin setuptools in relevant tests
alexander-alderman-webb Jun 1, 2026
2da376e
Merge branch 'webb/setuptools' into webb/populate-tox/transitive-depe…
alexander-alderman-webb Jun 1, 2026
d93dae6
trigger ci
alexander-alderman-webb Jun 1, 2026
d26dd8a
merge master
alexander-alderman-webb Jun 1, 2026
b01571c
add tox.ini to .gitattributes
alexander-alderman-webb Jun 1, 2026
28b7239
pin deps in *-latest environments
alexander-alderman-webb Jun 2, 2026
00e90a6
Merge branch 'master' into webb/populate-tox/transitive-dependencies
alexander-alderman-webb Jun 2, 2026
f005b38
Merge branch 'master' into webb/populate-tox/transitive-dependencies
alexander-alderman-webb Jun 2, 2026
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
585 changes: 534 additions & 51 deletions scripts/populate_tox/package_dependencies.jsonl

Large diffs are not rendered by default.

222 changes: 180 additions & 42 deletions scripts/populate_tox/populate_tox.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,8 @@
import os
import re
import subprocess
import sys

Check warning on line 13 in scripts/populate_tox/populate_tox.py

View check run for this annotation

@sentry/warden / warden: find-bugs

`.split("-")` on sdist filename in `_has_free_threading_dependencies` crashes for multi-hyphen package names

In `_has_free_threading_dependencies` (populate_tox.py ~line 730), when a transitive dependency only ships as a source distribution, the code does: ```python package_release = wheel_filename.rstrip(".tar.gz") dependency_name, dependency_version = package_release.split("-") ``` The unpack assumes exactly two parts. For any sdist whose package name contains a hyphen (e.g. `azure-core-1.29.5.tar.gz`, `google-auth-2.x.y.tar.gz`, `typing-extensions-...tar.gz`), `split("-")` yields ≥3 elements and raises `ValueError: too many values to unpack (expected 2)`, aborting tox generation for that combination. Use `package_release.rsplit("-", 1)` to split off only the version (PEP 440 versions never contain `-` in this context once the sdist filename is formed). Note also that `rstrip(".tar.gz")` is character-based and will over-strip filenames ending in characters from `{., t, a, r, g, z}` (e.g. `foo-1.0.tar.gz` → `foo-1.0` ok, but `foobar-1.0.tar.gz` → `foobar-1.0` also ok; however `foo-1.2.tar.gz` is fine while a name like `boto3-...` is fine — but the pattern is still fragile and should be `removesuffix(".tar.gz")`).
Comment thread
alexander-alderman-webb marked this conversation as resolved.
import tempfile
import time
from bisect import bisect_left
from collections import defaultdict
Expand All @@ -20,6 +21,7 @@
from pathlib import Path
from typing import Optional, Union

from packaging.requirements import Requirement
from packaging.specifiers import SpecifierSet
from packaging.version import Version

Expand Down Expand Up @@ -77,6 +79,19 @@
MIN_FREE_THREADING_SUPPORT = Version("3.14")


class DryRunFailed(Exception):
def __init__(self, result: subprocess.CompletedProcess[str]) -> None:
self.result = result
message = f"Command failed with exit code {result.returncode}.\n"
stderr = result.stderr.strip()
if stderr:
message += f"\nStderr:\n{stderr}"
stdout = result.stdout.strip()
if stdout:
message += f"\nStdout:\n{stdout}"
super().__init__(message)


class PackageVersion(Version):
# Convenience wrapper around Version. It's convenient to be able to set
# attributes on a Version in toxgen, but we can't because the class now
Expand All @@ -94,8 +109,11 @@
self.version = Version(version) if isinstance(version, str) else version
self.no_gil = no_gil

def __hash__(self):
return hash((self.version, self.no_gil))

def __str__(self):
version = f"py{self.version.major}.{self.version.minor}"
version = f"{self.version.major}.{self.version.minor}"
if self.no_gil:
Comment thread
alexander-alderman-webb marked this conversation as resolved.
version += "t"

Expand Down Expand Up @@ -146,34 +164,94 @@
return release


def _get_dependency_probe_constraints(
integration: str,
release: Version,
python_version: ThreadedVersion,
) -> tuple[str, ...]:
constraints = []
for rule, dependencies in TEST_SUITE_CONFIG[integration].get("deps", {}).items():
# Skip if rule does not apply to current package or Python version
if (
rule == "*"
or (rule.startswith("py3") and f"py{python_version}" not in rule.split(","))
or (
not rule.startswith("py3")
Comment thread
alexander-alderman-webb marked this conversation as resolved.
and release not in SpecifierSet(rule, prereleases=True)
)
):
continue
Comment thread
alexander-alderman-webb marked this conversation as resolved.
Outdated

for dependency in dependencies:
requirement = Requirement(dependency)

# Constraints are useful only when they actually constrain versions.
if (
requirement.specifier
and not requirement.extras
and requirement.url is None
):
constraints.append(dependency)
return tuple(constraints)


@functools.cache
def fetch_package_dependencies(package: str, version: Version) -> dict:
def fetch_package_dependencies(
package: str,
version: Version,
python_version: ThreadedVersion,
constraints: tuple[str, ...] = (),
) -> dict:
"""Fetch package dependencies metadata from cache or, failing that, PyPI."""
package_dependencies = _fetch_package_dependencies_from_cache(package, version)
package_dependencies = _fetch_package_dependencies_from_cache(
package, version, python_version
)
if package_dependencies is not None:
return package_dependencies

# Removing non-report output with -qqq may be brittle, but avoids file I/O.
# Currently -qqq supresses all non-report output that would break json.loads().
pip_report = subprocess.run(
[
sys.executable,
"-m",
"pip",
"install",
f"{package}=={str(version)}",
"--dry-run",
"--ignore-installed",
"--report",
"-",
"-qqq",
],
capture_output=True,
text=True,
).stdout.strip()
cmd = [
"uv",
"run",
"--no-project",
"--python",
str(python_version),
"--with",
"pip",
"python",
"-m",
"pip",
"install",
f"{package}=={version}",
"--dry-run",
"--ignore-installed",
"--report",
"-",
"-qqq",
]

if constraints:
with tempfile.NamedTemporaryFile("w", encoding="utf-8") as f:
f.write("\n".join(constraints))
f.flush()
result = subprocess.run(
[*cmd, "--constraint", f.name],
capture_output=True,
text=True,
)
else:
result = subprocess.run(cmd, capture_output=True, text=True)

if result.returncode != 0:
# Some failures are expected because uv installs packages which pip rejects for having bad metadata.
raise DryRunFailed(result)
Comment thread
alexander-alderman-webb marked this conversation as resolved.

pip_report = result.stdout.strip()
dependencies_info = json.loads(pip_report)["install"]
_save_to_package_dependencies_cache(package, version, dependencies_info)
_save_to_package_dependencies_cache(
package, version, python_version, dependencies_info
)

return dependencies_info

Expand All @@ -185,15 +263,20 @@
return CACHE[package][str(version)]

return None


def _fetch_package_dependencies_from_cache(
package: str, version: Version
package: str,
version: Version,
python_version: ThreadedVersion,
) -> Optional[dict]:
package = _normalize_name(package)
if package in DEPENDENCIES_CACHE and str(version) in DEPENDENCIES_CACHE[package]:
DEPENDENCIES_CACHE[package][str(version)]["_accessed"] = True
return DEPENDENCIES_CACHE[package][str(version)]["dependencies"]
cache_entry = (
DEPENDENCIES_CACHE[package].get(str(version), {}).get(str(python_version), None)
)
if cache_entry is not None:
cache_entry["_accessed"] = True
return cache_entry["dependencies"]

Check warning on line 279 in scripts/populate_tox/populate_tox.py

View check run for this annotation

@sentry/warden / warden: code-review

Constraints are not part of the on-disk cache key, causing stale dependency pins after config changes

The file cache in `package_dependencies.jsonl` is keyed on `(name, version, python_version)` only; if `deps` constraints for an integration change in `TEST_SUITE_CONFIG`, a subsequent run will return the previously-cached (now incorrect) resolved dependencies without re-running the pip dry-run, silently producing wrong transitive dependency pins.

return None

Expand All @@ -207,18 +290,30 @@


def _save_to_package_dependencies_cache(
package: str, version: Version, release: Optional[dict]
package: str,
version: Version,
python_version: ThreadedVersion,
release: Optional[dict],
) -> None:
normalized_dependencies = _normalize_package_dependencies(release)

with open(DEPENDENCIES_CACHE_FILE, "a") as releases_cache:
line = {
"name": package,
"version": str(version),
"dependencies": _normalize_package_dependencies(release),
}
releases_cache.write(json.dumps(line) + "\n")
releases_cache.write(
json.dumps(
{
"name": package,
"version": str(version),
"python_version": str(python_version),
"dependencies": normalized_dependencies,
}
)
+ "\n"
)

DEPENDENCIES_CACHE[_normalize_name(package)][str(version)] = {
"info": release,
DEPENDENCIES_CACHE[_normalize_name(package)].setdefault(str(version), {})[
str(python_version)
] = {
"dependencies": normalized_dependencies,
"_accessed": True,
}

Expand Down Expand Up @@ -623,7 +718,9 @@
- no wheel targets the platform on which the script is run, but PyPI distributes a wheel
satisfying one of the above conditions.
"""
dependencies_info = fetch_package_dependencies(package_name, release)
dependencies_info = fetch_package_dependencies(
package_name, release, ThreadedVersion(python_version, no_gil=True)
)

for dependency_info in dependencies_info:
wheel_filename = dependency_info["download_info"]["url"].split("/")[-1]
Expand Down Expand Up @@ -698,7 +795,7 @@


def _render_python_versions(python_versions: list[ThreadedVersion]) -> str:
return "{" + ",".join(str(version) for version in python_versions) + "}"
return "{" + ",".join(f"py{str(version)}" for version in python_versions) + "}"


def _render_dependencies(integration: str, releases: list[Version]) -> list[str]:
Expand All @@ -724,9 +821,7 @@
return rendered


def _render_latest_dependencies(
integration: str, latest_release: Version
) -> list[str]:
def _render_latest_dependencies(integration: str, latest_release: Version) -> list[str]:
"""Render version-specific dependencies for the 'latest' alias.

Dependencies with "*" or "py3.*" constraints already match the latest
Expand Down Expand Up @@ -829,6 +924,27 @@
)


def _render_transitive_dependencies(
integration: str,
package: str,
release: Version,
python_version: ThreadedVersion,
) -> list[str]:
deps = []
for dependency in fetch_package_dependencies(
package,
release,
python_version,
_get_dependency_probe_constraints(integration, release, python_version),
):

Check warning on line 939 in scripts/populate_tox/populate_tox.py

View check run for this annotation

@sentry/warden / warden: find-bugs

Constraints passed to `fetch_package_dependencies` are silently ignored when the disk cache has a prior unconstrained entry

When `_render_transitive_dependencies` calls `fetch_package_dependencies` with a non-empty `constraints` tuple, `_fetch_package_dependencies_from_cache` ignores `constraints` in its key lookup, so a prior unconstrained disk-cache entry (e.g. one written by `_has_free_threading_dependencies` for the same `(package, version, "3.14t")` triple) is returned as-is, silently discarding the constraints and producing incorrect pinned transitive dependencies for that Python version.
name = dependency["metadata"]["name"]
Comment thread
sentry-warden[bot] marked this conversation as resolved.
version = dependency["metadata"]["version"]
if _normalize_name(name) == _normalize_name(package):
Comment thread
sentry-warden[bot] marked this conversation as resolved.
continue
deps.append(f"py{python_version}-{integration}-v{release}: {name}=={version}")
return deps


def _add_python_versions_to_release(
integration: str, package: str, release: PackageVersion
) -> None:
Expand Down Expand Up @@ -945,9 +1061,13 @@
"""Filter out unneeded parts of the package dependencies JSON."""
normalized = [
{
"download_info": {"url": depedency["download_info"]["url"]},
"metadata": {
"name": dependency["metadata"]["name"],
"version": dependency["metadata"]["version"],
},
"download_info": {"url": dependency["download_info"]["url"]},
}
for depedency in package_dependencies
for dependency in package_dependencies
]

return normalized
Expand Down Expand Up @@ -1036,7 +1156,8 @@
release = json.loads(line)
name = _normalize_name(release["name"])
version = release["version"]
DEPENDENCIES_CACHE[name][version] = {
python_version = release["python_version"]
DEPENDENCIES_CACHE[name].setdefault(version, {})[python_version] = {
"dependencies": release["dependencies"],
"_accessed": False,
Comment thread
alexander-alderman-webb marked this conversation as resolved.
}
Expand Down Expand Up @@ -1081,6 +1202,23 @@
_add_python_versions_to_release(integration, package, release)
if not release.python_versions:
print(f" Release {release} has no Python versions, skipping.")
continue

release.transitive_dependencies = []
for python_version in release.python_versions:
if python_version < ThreadedVersion("3.8"):
continue
try:
dependencies = _render_transitive_dependencies(
integration, package, release, python_version
)
except DryRunFailed as error:
Comment thread
alexander-alderman-webb marked this conversation as resolved.
print(
f"\npip dry run failed for version {release} of {package} on Python {python_version}:\n{error}"
)
continue
Comment thread
alexander-alderman-webb marked this conversation as resolved.

release.transitive_dependencies.append(dependencies)

test_releases = [
release for release in test_releases if release.python_versions
Expand Down Expand Up @@ -1126,7 +1264,7 @@
if (
DEPENDENCIES_CACHE[_normalize_name(release["name"])][
release["version"]
]["_accessed"]
][release["python_version"]]["_accessed"]
is True
):
releases_cache.write(json.dumps(release) + "\n")
Expand Down
Loading
Loading