Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
8 changes: 8 additions & 0 deletions .github/workflows/publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,14 @@ jobs:
TAG: ${{ github.event.release.tag_name || inputs.tag }}
run: bash .github/scripts/publish/parse_tag.sh

# A *.dev dependency pin references an unpublished version, so it must never reach
# released package metadata. Scoped to the package being published so a dependency
# can still be released while dependents temporarily dev-pin it.
- name: Reject development-release dependency pins
env:
PACKAGE: ${{ steps.parse.outputs.package }}
run: uv run --no-project python scripts/check_min_deps.py --check-dev-pins "$PACKAGE"

- name: Pin reflex-base to exact version
if: steps.parse.outputs.package == 'reflex'
env:
Expand Down
3 changes: 2 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,8 @@ uv run pytest tests/integration # integration t
uv run ruff check . # lint
uv run ruff format . # format
uv run pyright reflex tests # type check
uv run python scripts/check_min_deps.py # validate each package's declared minimum dep versions (pyright in isolated min-version envs)
uv run python scripts/check_min_deps.py # validate each package's declared minimum dep versions (pyright in isolated min-version envs; *.dev pins resolve from the local workspace, all other deps from PyPI)
uv run python scripts/check_min_deps.py --check-dev-pins [pkg] # publish gate: fail if pkg (default: all) declares an unpublishable *.dev dependency pin
uv run python scripts/make_pyi.py # regenerate .pyi stubs
uv run pre-commit run --all-files # all pre-commit hooks
```
Expand Down
205 changes: 202 additions & 3 deletions scripts/check_min_deps.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,17 @@
allows — exactly the bug this catches (e.g. calling a pydantic 2.x API while declaring
``pydantic >=1.10``).

Development-release pins are the exception to ``--no-sources``. A package may pin a sibling
workspace package to an unreleased ``*.dev`` version (e.g. ``reflex-base >= 0.9.5.dev1``)
while that version is still unpublished, which would otherwise make resolution from PyPI
impossible. For such pins — and only those — the depended-on package is installed editable
from its local workspace checkout in both environments, so every *non-dev* dependency is
still required to resolve from PyPI.

Run with ``uv run python scripts/check_min_deps.py [package ...]``. With no arguments,
every checkable package is validated.
every checkable package is validated. ``--check-dev-pins [package ...]`` instead scans the
declared dependencies for development-release pins and fails if any are found (used by the
publish pipeline to keep ``*.dev`` pins out of released package metadata).
"""

# /// script
Expand All @@ -30,9 +39,11 @@

import argparse
import json
import re
import subprocess
import sys
import tempfile
from collections.abc import Iterator
from concurrent.futures import ThreadPoolExecutor
from dataclasses import dataclass
from pathlib import Path
Expand All @@ -58,6 +69,49 @@
"reflex-site-shared",
})

# Leading distribution name (and optional ``[extras]``) of a PEP 508 requirement.
_REQUIREMENT_NAME = re.compile(r"\s*([A-Za-z0-9._-]+)\s*(?:\[[^\]]*\])?")

# A PEP 440 development-release segment within a version specifier: a digit, an optional
# ``.``/``-``/``_`` separator, then ``dev`` (e.g. ``0.9.5.dev1``, ``1.0dev``, ``1.0-dev0``).
# Anchoring on a preceding digit keeps it from matching a stray ``dev`` (such as a ``.dev``
# URL host); it is only ever matched against the version-specifier region of a requirement,
# never the package name or the environment marker.
_DEV_RELEASE = re.compile(r"[0-9][._-]?dev", re.IGNORECASE)
Comment thread
masenf marked this conversation as resolved.
Outdated


def _normalize_name(name: str) -> str:
"""Normalize a distribution name to its PEP 503 canonical form.

Args:
name: A distribution name as written in a requirement or ``[project].name``.

Returns:
The lowercased name with runs of ``-``, ``_`` and ``.`` collapsed to a single ``-``.
"""
return re.sub(r"[-_.]+", "-", name).lower()


def _parse_requirement(requirement: str) -> tuple[str, bool]:
"""Split a PEP 508 requirement into its name and whether its lower bound is a dev release.

Args:
requirement: A dependency string such as ``"reflex-base >= 0.9.5.dev1"``.

Returns:
A ``(normalized_name, is_dev_pinned)`` tuple. ``is_dev_pinned`` is ``True`` when the
requirement's version specifier references a development release, which by convention
is not published to PyPI.
"""
match = _REQUIREMENT_NAME.match(requirement)
if not match:
return "", False
name = _normalize_name(match.group(1))
specifier = requirement[match.end() :].split(";", 1)[0]
if specifier.lstrip().startswith("@"): # direct URL reference, not a version pin
return name, False
return name, bool(_DEV_RELEASE.search(specifier))


@dataclass(frozen=True)
class Package:
Expand All @@ -75,6 +129,13 @@ class Package:
extras: tuple[str, ...]
"""Names of optional-dependency groups to install alongside the package."""

local_dev_sources: tuple[Path, ...] = ()
"""Project dirs of sibling workspace packages this package pins to a ``*.dev`` release.

These are installed editable from the local checkout (rather than PyPI) in both
resolutions, because the pinned development version is not published.
"""

def install_target(self) -> str:
"""Build the editable install target, including any extras.

Expand Down Expand Up @@ -119,21 +180,91 @@ def _single_source_dir(src: Path) -> Path:
return children[0]


def _workspace_pyprojects() -> Iterator[tuple[str, Path]]:
"""Yield every workspace package's directory name paired with its ``pyproject.toml``.

Yields:
``(directory_name, pyproject_path)`` for the root package (named ``"reflex"``) and
each ``packages/*`` member. The directory name is the publish/CLI identifier.
"""
yield "reflex", REPO_ROOT / "pyproject.toml"
for project_file in sorted((REPO_ROOT / "packages").glob("*/pyproject.toml")):
yield project_file.parent.name, project_file


def _workspace_package_dirs() -> dict[str, Path]:
"""Map each workspace package's normalized distribution name to its project directory.

Returns:
A mapping from canonical distribution name to the directory containing its
``pyproject.toml``, used to resolve a ``*.dev`` dependency pin to a local checkout.
"""
dirs: dict[str, Path] = {}
for _, project_file in _workspace_pyprojects():
name = _load_pyproject(project_file).get("project", {}).get("name")
if name:
dirs[_normalize_name(name)] = project_file.parent
return dirs


def _published_dependencies(project: dict) -> list[str]:
"""Collect the requirements that become a package's published metadata.

Args:
project: The ``[project]`` table of a parsed ``pyproject.toml``.

Returns:
The core runtime dependencies plus every optional-dependency group — exactly the
requirements emitted as ``Requires-Dist``. Dependency groups (PEP 735) are excluded
because they are development-only and never published.
"""
deps = list(project.get("dependencies", []))
for group in project.get("optional-dependencies", {}).values():
deps.extend(group)
return deps


def _local_dev_sources(
project: dict, workspace_dirs: dict[str, Path]
) -> tuple[Path, ...]:
"""Resolve a package's ``*.dev`` dependency pins to local workspace project directories.

Args:
project: The ``[project]`` table of the package being checked.
workspace_dirs: Mapping from distribution name to project dir (see
:func:`_workspace_package_dirs`).

Returns:
The project directories of the sibling workspace packages this package pins to an
unpublished development release, deduplicated and in declaration order.
"""
sources: list[Path] = []
seen: set[str] = set()
for dependency in _published_dependencies(project):
name, is_dev = _parse_requirement(dependency)
if is_dev and name in workspace_dirs and name not in seen:
seen.add(name)
sources.append(workspace_dirs[name])
return tuple(sources)


def discover_packages() -> list[Package]:
"""Discover every checkable workspace package.

Returns:
The checkable packages, sorted by name, with the root ``reflex`` package first.
"""
workspace_dirs = _workspace_package_dirs()
packages: list[Package] = []

root_pyproject = _load_pyproject(REPO_ROOT / "pyproject.toml")
root_project = _load_pyproject(REPO_ROOT / "pyproject.toml")["project"]
packages.append(
Package(
name="reflex",
project_dir=REPO_ROOT,
source_dir=REPO_ROOT / "reflex",
extras=tuple(root_pyproject["project"].get("optional-dependencies", {})),
extras=tuple(root_project.get("optional-dependencies", {})),
local_dev_sources=_local_dev_sources(root_project, workspace_dirs),
)
)

Expand All @@ -150,6 +281,7 @@ def discover_packages() -> list[Package]:
project_dir=project_file.parent,
source_dir=_single_source_dir(project_file.parent / "src"),
extras=tuple(project.get("optional-dependencies", {})),
local_dev_sources=_local_dev_sources(project, workspace_dirs),
)
)

Expand Down Expand Up @@ -269,6 +401,11 @@ def _resolve_and_check(
if lowest:
install_cmd += ["--resolution", "lowest-direct"]
install_cmd += ["-e", package.install_target()]
# ``--no-sources`` forces every dependency to resolve from PyPI; the lone exception is a
# sibling pinned to an unpublished ``*.dev`` release, which is provided here as an explicit
# editable from its local checkout so resolution can succeed without reaching PyPI for it.
for source in package.local_dev_sources:
install_cmd += ["-e", str(source)]
install = _run(install_cmd, cwd=REPO_ROOT)
if install.returncode != 0:
return None, install.stdout
Expand Down Expand Up @@ -346,6 +483,59 @@ def check_package(package: Package, python_version: str) -> Result:
return Result(package.name, True, "ok", "")


def check_dev_pins(package_names: list[str]) -> int:
"""Fail if a workspace package declares a development-release dependency pin.

Development releases (``*.dev``) are not published to PyPI, so a package whose published
metadata pins one cannot be installed by downstream users. This gate keeps such pins out
of a release. Only each package's *own* published dependencies are inspected — siblings
are not followed — so the usual leaf-first release flow (publish the depended-on package,
then drop the dev pin in the dependent) is never deadlocked by a pin in another package.

Args:
package_names: Directory names to inspect (e.g. ``"reflex"``, ``"reflex-lucide"``).
Empty inspects every workspace package.

Returns:
``0`` if no development-release pins are found, ``1`` otherwise.
"""
pyprojects = dict(_workspace_pyprojects())
unknown = [name for name in package_names if name not in pyprojects]
if unknown:
print(
f"unknown package(s): {', '.join(unknown)}. "
f"Choose from: {', '.join(pyprojects)}"
)
return 1
targets = (
{name: pyprojects[name] for name in package_names}
if package_names
else pyprojects
)

offenders: list[tuple[str, str]] = []
for name, project_file in targets.items():
project = _load_pyproject(project_file).get("project", {})
offenders += [
(name, dependency)
for dependency in _published_dependencies(project)
if _parse_requirement(dependency)[1]
]

if offenders:
print("Development-release dependency pins must not be published:\n")
for name, dependency in offenders:
print(f" {name}: {dependency}")
print(
f"\n{len(offenders)} development-release pin(s) found. Release the depended-on "
"package(s) and re-pin to a published version before publishing."
)
return 1

print(f"No development-release dependency pins found in {len(targets)} package(s).")
return 0


def main() -> int:
"""Validate the declared minimum dependency versions of workspace packages.

Expand All @@ -363,6 +553,12 @@ def main() -> int:
action="store_true",
help="Print the checkable packages as a JSON array and exit.",
)
parser.add_argument(
"--check-dev-pins",
action="store_true",
help="Instead of type-checking, scan the selected packages' declared dependencies "
"for development-release (*.dev) pins and exit non-zero if any are found.",
)
parser.add_argument(
"--python",
default=DEFAULT_PYTHON,
Expand All @@ -377,6 +573,9 @@ def main() -> int:
)
args = parser.parse_args()

if args.check_dev_pins:
return check_dev_pins(args.packages)

all_packages = discover_packages()

if args.list:
Expand Down
Loading
Loading