|
3 | 3 | import shutil |
4 | 4 | from functools import wraps |
5 | 5 | import pathlib |
| 6 | +import urllib.request |
| 7 | +import json |
| 8 | +import re |
6 | 9 |
|
7 | 10 | import nox |
8 | 11 | import nox.command as nox_command |
@@ -154,6 +157,126 @@ def session( |
154 | 157 | ) |
155 | 158 |
|
156 | 159 |
|
| 160 | +# --- FastAPI compatibility matrix helpers --- |
| 161 | +PYPI_JSON_URL_TEMPLATE = "https://pypi.org/pypi/{package}/json" |
| 162 | + |
| 163 | + |
| 164 | +def _parse_strict_version_tuple(ver_str: str): |
| 165 | + """Parse a strict semantic version 'X.Y.Z' into a tuple of ints. |
| 166 | +
|
| 167 | + Returns None if version doesn't match strict pattern (filters out pre-releases). |
| 168 | + """ |
| 169 | + m = re.match(r"^(\d+)\.(\d+)\.(\d+)$", ver_str) |
| 170 | + if not m: |
| 171 | + return None |
| 172 | + return int(m.group(1)), int(m.group(2)), int(m.group(3)) |
| 173 | + |
| 174 | + |
| 175 | +def _version_tuple_to_str(t): |
| 176 | + return f"{t[0]}.{t[1]}.{t[2]}" |
| 177 | + |
| 178 | + |
| 179 | +def _cmp_major_minor(a, b): |
| 180 | + """Compare (major, minor) tuples only.""" |
| 181 | + if a[0] != b[0]: |
| 182 | + return a[0] - b[0] |
| 183 | + return a[1] - b[1] |
| 184 | + |
| 185 | + |
| 186 | +def _get_min_supported_version_from_pyproject( |
| 187 | + package_name: str, manifest: dict = PROJECT_MANIFEST |
| 188 | +): |
| 189 | + """Extract minimum supported version from pyproject for given package. |
| 190 | +
|
| 191 | + Supports entries like 'fastapi>=0.100.1' and 'fastapi[standard]>=0.100.1'. |
| 192 | + Returns a version tuple (major, minor, patch) or None if not found. |
| 193 | + """ |
| 194 | + deps = manifest.get("project", {}).get("dependencies", []) |
| 195 | + patterns = [ |
| 196 | + rf"^{re.escape(package_name)}>=([0-9]+\.[0-9]+\.[0-9]+)$", |
| 197 | + rf"^{re.escape(package_name)}\[[^\]]+\]>=([0-9]+\.[0-9]+\.[0-9]+)$", |
| 198 | + ] |
| 199 | + for dep in deps: |
| 200 | + for pat in patterns: |
| 201 | + m = re.match(pat, dep) |
| 202 | + if m: |
| 203 | + vt = _parse_strict_version_tuple(m.group(1)) |
| 204 | + if vt: |
| 205 | + return vt |
| 206 | + return None |
| 207 | + |
| 208 | + |
| 209 | +def _fetch_pypi_latest_and_releases(package_name: str): |
| 210 | + """Fetch latest version and releases list from PyPI JSON. |
| 211 | +
|
| 212 | + Returns (latest_version_tuple, releases_dict) where releases_dict maps |
| 213 | + (major, minor) -> max patch available for that minor. |
| 214 | + """ |
| 215 | + url = PYPI_JSON_URL_TEMPLATE.format(package=package_name) |
| 216 | + try: |
| 217 | + with urllib.request.urlopen(url) as resp: |
| 218 | + data = json.loads(resp.read().decode("utf-8")) |
| 219 | + except Exception: |
| 220 | + return None, {} |
| 221 | + |
| 222 | + latest_str = data.get("info", {}).get("version") |
| 223 | + latest_tuple = _parse_strict_version_tuple(latest_str) if latest_str else None |
| 224 | + |
| 225 | + releases = data.get("releases", {}) |
| 226 | + minor_to_max_patch = {} |
| 227 | + for ver_str in releases.keys(): |
| 228 | + vt = _parse_strict_version_tuple(ver_str) |
| 229 | + if not vt: |
| 230 | + # skip pre-release or non-strict versions |
| 231 | + continue |
| 232 | + major, minor, patch = vt |
| 233 | + key = (major, minor) |
| 234 | + prev = minor_to_max_patch.get(key) |
| 235 | + if prev is None or patch > prev: |
| 236 | + minor_to_max_patch[key] = patch |
| 237 | + |
| 238 | + return latest_tuple, minor_to_max_patch |
| 239 | + |
| 240 | + |
| 241 | +def _build_minor_matrix(min_vt, latest_vt, minor_to_max_patch): |
| 242 | + """Build a list of version strings representing the highest patch in each minor |
| 243 | + from min_vt to latest_vt inclusive. Only includes minors that exist in releases. |
| 244 | + """ |
| 245 | + if not min_vt or not latest_vt: |
| 246 | + return [] |
| 247 | + result = [] |
| 248 | + # Collect and sort available minor keys |
| 249 | + available_minors = sorted(minor_to_max_patch.keys(), key=lambda k: (k[0], k[1])) |
| 250 | + for major, minor in available_minors: |
| 251 | + # range filter: min <= (major, minor) <= latest |
| 252 | + if _cmp_major_minor((major, minor), (min_vt[0], min_vt[1])) < 0: |
| 253 | + continue |
| 254 | + if _cmp_major_minor((major, minor), (latest_vt[0], latest_vt[1])) > 0: |
| 255 | + continue |
| 256 | + patch = minor_to_max_patch[(major, minor)] |
| 257 | + result.append(_version_tuple_to_str((major, minor, patch))) |
| 258 | + return result |
| 259 | + |
| 260 | + |
| 261 | +def _compute_fastapi_minor_matrix(): |
| 262 | + package = "fastapi" |
| 263 | + min_vt = _get_min_supported_version_from_pyproject(package) |
| 264 | + latest_vt, minor_to_max_patch = _fetch_pypi_latest_and_releases(package) |
| 265 | + matrix = _build_minor_matrix(min_vt, latest_vt, minor_to_max_patch) |
| 266 | + # Fallbacks if network fails or parsing issues |
| 267 | + if not matrix: |
| 268 | + vals = [] |
| 269 | + if min_vt: |
| 270 | + vals.append(_version_tuple_to_str(min_vt)) |
| 271 | + if latest_vt and latest_vt != min_vt: |
| 272 | + vals.append(_version_tuple_to_str(latest_vt)) |
| 273 | + matrix = vals or ["0.100.1"] |
| 274 | + return matrix |
| 275 | + |
| 276 | + |
| 277 | +FASTAPI_MINOR_MATRIX = _compute_fastapi_minor_matrix() |
| 278 | + |
| 279 | + |
157 | 280 | def uv_install_group_dependencies(session: Session, dependency_group: str): |
158 | 281 | pyproject = nox.project.load_toml(MANIFEST_FILENAME) |
159 | 282 | dependencies = nox.project.dependency_groups(pyproject, dependency_group) |
@@ -256,6 +379,42 @@ def test(session: AlteredSession): |
256 | 379 | session.run(*command) |
257 | 380 |
|
258 | 381 |
|
| 382 | +@session( |
| 383 | + dependency_group=None, |
| 384 | + default_posargs=[TEST_DIR, "-s", "-vv", "-n", "auto", "--dist", "worksteal"], |
| 385 | + reuse_venv=False, |
| 386 | +) |
| 387 | +@nox.parametrize("fastapi_version", FASTAPI_MINOR_MATRIX) |
| 388 | +def test_compat_fastapi(session: AlteredSession, fastapi_version: str): |
| 389 | + """Run tests against a matrix of FastAPI minor versions. |
| 390 | +
|
| 391 | + The matrix is computed from pyproject's minimum supported version and |
| 392 | + PyPI's latest release, selecting the highest patch per minor. |
| 393 | + """ |
| 394 | + session.log(f"Testing compatibility with FastAPI versions: {FASTAPI_MINOR_MATRIX}") |
| 395 | + # Pin FastAPI (and extras) to the target minor's highest patch before running tests. |
| 396 | + # Install dev dependencies excluding FastAPI to avoid overriding the pinned version. |
| 397 | + pyproject = load_toml(MANIFEST_FILENAME) |
| 398 | + dev_deps = nox.project.dependency_groups(pyproject, "dev") |
| 399 | + filtered_dev_deps = [d for d in dev_deps if not d.startswith("fastapi")] |
| 400 | + if filtered_dev_deps: |
| 401 | + session.install(*filtered_dev_deps) |
| 402 | + # Pin FastAPI (and extras) to the target minor's highest patch before running tests. |
| 403 | + session.install(f"fastapi[standard]=={fastapi_version}") |
| 404 | + with alter_session(session, dependency_group=None) as session: |
| 405 | + session.install(f".") |
| 406 | + session.run( |
| 407 | + *( |
| 408 | + "python", |
| 409 | + "-c", |
| 410 | + f'from fastapi import __version__; assert __version__ == "{fastapi_version}", __version__', |
| 411 | + ) |
| 412 | + ) |
| 413 | + |
| 414 | + # Run pytest using the Nox-managed virtualenv (avoid external interpreter). |
| 415 | + session.run("pytest") |
| 416 | + |
| 417 | + |
259 | 418 | @contextlib.contextmanager |
260 | 419 | def alter_session( |
261 | 420 | session: AlteredSession, |
@@ -606,6 +765,42 @@ def ci(session: Session): |
606 | 765 | test(session) |
607 | 766 |
|
608 | 767 |
|
| 768 | +@session(reuse_venv=False) |
| 769 | +def install_latest_tarball(session: Session): |
| 770 | + import glob |
| 771 | + import re |
| 772 | + |
| 773 | + from packaging import version |
| 774 | + |
| 775 | + # Get all tarball files |
| 776 | + tarball_files = glob.glob(f"{DIST_DIR}/{PROJECT_NAME_NORMALIZED}-*.tar.gz") |
| 777 | + |
| 778 | + if not tarball_files: |
| 779 | + session.error("No tarball files found in dist/ directory") |
| 780 | + |
| 781 | + # Extract version numbers using regex |
| 782 | + version_pattern = re.compile( |
| 783 | + rf"{PROJECT_NAME_NORMALIZED}-([0-9]+\.[0-9]+\.[0-9]+(?:\.[0-9]+)?(?:(?:a|b|rc)[0-9]+)?(?:\.post[0-9]+)?(?:\.dev[0-9]+)?).tar.gz" |
| 784 | + ) |
| 785 | + |
| 786 | + # Create a list of (file_path, version) tuples |
| 787 | + versioned_files = [] |
| 788 | + for file_path in tarball_files: |
| 789 | + match = version_pattern.search(file_path) |
| 790 | + if match: |
| 791 | + ver_str = match.group(1) |
| 792 | + versioned_files.append((file_path, version.parse(ver_str))) |
| 793 | + |
| 794 | + if not versioned_files: |
| 795 | + session.error("Could not extract version information from tarball files") |
| 796 | + |
| 797 | + # Sort by version (highest first) and get the path |
| 798 | + latest_tarball = sorted(versioned_files, key=lambda x: x[1], reverse=True)[0][0] |
| 799 | + session.log(f"Installing latest version: {latest_tarball}") |
| 800 | + session.run("uv", "run", "pip", "uninstall", f"{PROJECT_NAME}", "-y") |
| 801 | + session.install(latest_tarball) |
| 802 | + |
| 803 | + |
609 | 804 | @session(reuse_venv=False) |
610 | 805 | def test_client_install_run(session: Session): |
611 | 806 | with alter_session(session, dependency_group="dev"): |
|
0 commit comments