diff --git a/.github/workflows/build_release_artifacts.yml b/.github/workflows/build_release_artifacts.yml index d82e9186..5dd20a61 100644 --- a/.github/workflows/build_release_artifacts.yml +++ b/.github/workflows/build_release_artifacts.yml @@ -196,7 +196,7 @@ jobs: shell: bash run: | uv venv smoke - uv pip install -p smoke dist/*.whl numpy anywidget "reflex>=0.9.6" + uv pip install -p smoke dist/*.whl numpy anywidget "reflex>=0.9.6,<=0.10.0" ./smoke/bin/python -c "import importlib.metadata as m, reflex_xy, xy.kernels as k; assert k.BACKEND=='native', k.BACKEND; assert reflex_xy.__version__ == m.version('xy'); print('native', k.__file__)" \ || ./smoke/Scripts/python.exe -c "import importlib.metadata as m, reflex_xy, xy.kernels as k; assert k.BACKEND=='native', k.BACKEND; assert reflex_xy.__version__ == m.version('xy'); print('native')" - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 @@ -318,7 +318,7 @@ jobs: XY_REQUIRE_CARGO: "1" run: | uv venv smoke-native - uv pip install --no-cache -p smoke-native dist/*.tar.gz numpy anywidget "reflex>=0.9.6" + uv pip install --no-cache -p smoke-native dist/*.tar.gz numpy anywidget "reflex>=0.9.6,<=0.10.0" ./smoke-native/bin/python - <<'PY' import importlib.metadata as metadata @@ -338,7 +338,7 @@ jobs: XY_SKIP_CARGO: "1" run: | uv venv smoke-no-rust - uv pip install --no-cache -p smoke-no-rust dist/*.tar.gz numpy anywidget "reflex>=0.9.6" + uv pip install --no-cache -p smoke-no-rust dist/*.tar.gz numpy anywidget "reflex>=0.9.6,<=0.10.0" ./smoke-no-rust/bin/python - <<'PY' import importlib.metadata as metadata diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index de13c895..2064c4b2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -538,6 +538,51 @@ jobs: .venv/bin/python scripts/verify_benchmark_report.py \ dashboard-smoke.json --kind dashboard-browser + reflex_compatibility: + name: Reflex compatibility (${{ matrix.reflex-version }}) + needs: [changes, build] + if: needs.changes.outputs.runtime == 'true' + runs-on: ubuntu-24.04 + env: + XY_SKIP_CARGO: "1" + strategy: + fail-fast: false + matrix: + # Verify the minimum supported release, newest 0.9.x, and 0.10.0. + # The extra is capped at the newest version represented by this matrix. + reflex-version: ["0.9.6", "0.9.13", "0.10.0"] + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + fetch-depth: 0 + ref: ${{ github.event.pull_request.head.sha || github.sha }} + - name: Restore shared native core and client + uses: ./.github/actions/restore-ci-build + - uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2 + with: + version: "0.13.5" + enable-cache: false + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: "22.23.3" + - name: Install exact Reflex compatibility target + run: | + npm ci + uv venv .venv + uv pip install -p .venv/bin/python \ + "reflex==${{ matrix.reflex-version }}" \ + "numpy>=1.24" "anywidget>=0.9" "pytest>=8" "aiohttp>=3.9" "uvicorn>=0.23" + uv pip install -p .venv/bin/python -e . --no-deps + - name: Verify exact Reflex target and metadata window + run: | + .venv/bin/python -c "import importlib.metadata as m; assert m.version('reflex') == '${{ matrix.reflex-version }}'" + .venv/bin/python -c "from packaging.requirements import Requirement; import importlib.metadata as m; req = next(Requirement(value) for value in m.metadata('xy').get_all('Requires-Dist') if Requirement(value).name == 'reflex'); assert str(req.specifier) == '<=0.10.0,>=0.9.6'" + - name: Component compile and event smoke + run: .venv/bin/pytest -q tests/reflex_adapter/test_component.py::test_component_compiles_with_events + - name: State event and rebuild smoke + run: .venv/bin/pytest -q tests/reflex_adapter/test_state_bridge.py::test_rebuild_reads_session_state + regression_checks: name: Blocking regression checks needs: [changes, build] @@ -1086,7 +1131,7 @@ jobs: XY_REQUIRE_CARGO: "1" run: | uv venv smoke-native - uv pip install --no-cache -p smoke-native dist/*.tar.gz numpy anywidget "reflex>=0.9.6" + uv pip install --no-cache -p smoke-native dist/*.tar.gz numpy anywidget "reflex>=0.9.6,<=0.10.0" ./smoke-native/bin/python - <<'PY' import importlib.metadata as metadata @@ -1107,7 +1152,7 @@ jobs: XY_SKIP_CARGO: "1" run: | uv venv smoke-no-rust - uv pip install --no-cache -p smoke-no-rust dist/*.tar.gz numpy anywidget "reflex>=0.9.6" + uv pip install --no-cache -p smoke-no-rust dist/*.tar.gz numpy anywidget "reflex>=0.9.6,<=0.10.0" ./smoke-no-rust/bin/python - <<'PY' import importlib.metadata as metadata @@ -1313,7 +1358,7 @@ jobs: name: CI result runs-on: ubuntu-24.04 if: always() - needs: [changes, build, test, rust_checks, browser_smokes, regression_checks, python_floor, matplotlib_reference, matplotlib_patch_smoke, pyplot_gallery, pyplot_gallery_extended, browser_conformance, sdist, wheels, install_without_rust] + needs: [changes, build, test, rust_checks, browser_smokes, regression_checks, python_floor, matplotlib_reference, matplotlib_patch_smoke, pyplot_gallery, pyplot_gallery_extended, browser_conformance, sdist, wheels, install_without_rust, reflex_compatibility] steps: - name: Require selected checks to succeed env: diff --git a/docs/advanced/runtime-and-deployment.md b/docs/advanced/runtime-and-deployment.md index bd89e5b7..26701483 100644 --- a/docs/advanced/runtime-and-deployment.md +++ b/docs/advanced/runtime-and-deployment.md @@ -27,7 +27,8 @@ interaction math. The integration is experimental and ships inside the published `xy` distribution. Install `xy[reflex]` so the environment also selects a supported -Reflex version, then import it as `reflex_xy`. See +Reflex version (`>=0.9.6,<=0.10.0`), then import it as `reflex_xy`. CI tests +the minimum, newest 0.9.x, and 0.10.0 releases. See [Deployment Recipes](/docs/xy/guides/deployment-recipes/). ## Choose a data tier diff --git a/docs/overview/installation.md b/docs/overview/installation.md index d56958d4..2cc058f6 100644 --- a/docs/overview/installation.md +++ b/docs/overview/installation.md @@ -142,8 +142,8 @@ uv add pyarrow ### Reflex integration The bundled Reflex integration supports state-backed application charts and -remains experimental. Install the extra to select a compatible Reflex -version. With uv: +remains experimental. Install the extra to select a supported Reflex version +(`>=0.9.6,<=0.10.0`). With uv: ~~~bash uv add "xy[reflex]" @@ -155,9 +155,9 @@ Or with pip: python -m pip install "xy[reflex]" ~~~ -The `xy` wheel already carries the `reflex_xy` integration; the extra adds -only the supported Reflex dependency floor. Pin resolved versions for -production deployments. Continue with the +The `xy` wheel already carries the `reflex_xy` integration; the extra selects +the supported Reflex bound. CI smoke coverage includes 0.9.6, 0.9.13, and +0.10.0. Continue with the [Reflex integration guide](/docs/xy/integrations/reflex/) for its current limitations and setup. diff --git a/news/475.misc.md b/news/475.misc.md new file mode 100644 index 00000000..090779ed --- /dev/null +++ b/news/475.misc.md @@ -0,0 +1 @@ +Bound the supported Reflex compatibility window and run its version matrix as a selected blocking CI lane. diff --git a/pyproject.toml b/pyproject.toml index 33c1e35c..a5a2abc8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -21,10 +21,10 @@ matplotlib = [ "matplotlib>=3.11,<3.12", ] # The adapter source ships in every xy wheel so the framework integration and -# render client can never drift. The extra only selects the supported Reflex -# floor; importing plain `xy` remains framework-free. +# render client can never drift. The extra selects the tested Reflex window; +# importing plain `xy` remains framework-free. reflex = [ - "reflex>=0.9.6", + "reflex>=0.9.6,<=0.10.0", ] [project.urls] diff --git a/scripts/artifact_metadata.py b/scripts/artifact_metadata.py index f340a32b..fb6c1564 100644 --- a/scripts/artifact_metadata.py +++ b/scripts/artifact_metadata.py @@ -6,8 +6,11 @@ from email.message import Message BASE_DEPENDENCY_FLOORS = (("anywidget", "0.9"), ("numpy", "1.24")) +REFLEX_MINIMUM = "0.9.6" +REFLEX_MAXIMUM = "0.10.0" +REFLEX_SPECIFIER = f"reflex>={REFLEX_MINIMUM},<={REFLEX_MAXIMUM}" +REFLEX_REQUIREMENT = f"Requires-Dist: {REFLEX_SPECIFIER}; extra == 'reflex'" MATPLOTLIB_REQUIREMENT = "Requires-Dist: matplotlib>=3.11,<3.12; extra == 'matplotlib'" -REFLEX_REQUIREMENT = "Requires-Dist: reflex>=0.9.6; extra == 'reflex'" def _dependency_name(requirement: str) -> str: @@ -33,10 +36,10 @@ def _is_valid_base_requirement(requirement: str, package: str, minimum: str) -> def _is_exact_reflex_extra(requirement: str) -> bool: - """Accept whitespace/quote normalization, but no extra constraints.""" + """Accept the bounded Reflex window in either metadata ordering.""" return bool( re.fullmatch( - r"\s*reflex\s*>=\s*0\.9\.6\s*;\s*extra\s*==\s*['\"]reflex['\"]\s*", + rf"\s*reflex\s*(?:(?:>=\s*{re.escape(REFLEX_MINIMUM)}\s*,\s*<=\s*{re.escape(REFLEX_MAXIMUM)})|(?:<=\s*{re.escape(REFLEX_MAXIMUM)}\s*,\s*>=\s*{re.escape(REFLEX_MINIMUM)}))\s*;\s*extra\s*==\s*['\"]reflex['\"]\s*", requirement, flags=re.IGNORECASE, ) diff --git a/scripts/verify_ci_workflow.py b/scripts/verify_ci_workflow.py index 08f8f4f4..54f6334c 100644 --- a/scripts/verify_ci_workflow.py +++ b/scripts/verify_ci_workflow.py @@ -18,6 +18,7 @@ import json import re import sys +import tomllib from pathlib import Path from typing import Optional @@ -25,6 +26,7 @@ DEFAULT_CI_WORKFLOW = ROOT / ".github" / "workflows" / "ci.yml" DEFAULT_CODSPEED_WORKFLOW = ROOT / ".github" / "workflows" / "codspeed.yml" DEFAULT_WORKFLOW = DEFAULT_CI_WORKFLOW +REFLEX_COMPATIBILITY_JOB = "reflex_compatibility" REQUIRED_CI_JOBS = { "result", "changes", @@ -35,6 +37,7 @@ "pyplot_gallery_extended", "test", "rust_checks", + "reflex_compatibility", "browser_smokes", "regression_checks", "python_floor", @@ -44,8 +47,11 @@ "sdist", "wheels", "install_without_rust", + REFLEX_COMPATIBILITY_JOB, } REQUIRED_CODSPEED_JOBS = {"benchmarks"} +REFLEX_OPTIONAL_DEPENDENCY = "reflex>=0.9.6,<=0.10.0" +REFLEX_SPECIFIER = "<=0.10.0,>=0.9.6" CI_SUMMARY_DEPENDENCIES = ( "changes", @@ -63,6 +69,7 @@ "sdist", "wheels", "install_without_rust", + "reflex_compatibility", ) CI_SUMMARY_COMMANDS = [ "python3 - <<'PY'", @@ -595,7 +602,12 @@ def _step_run_lines(step_block: str) -> list[str]: def _require_step_runs_exactly( - errors: list[str], job_text: str, step: str, description: str, *commands: str + errors: list[str], + job_text: str, + step: str, + description: str, + *commands: str, + allow_matrix_strategy: bool = False, ) -> None: """Require the exact active command list in a hard-gate named step.""" block = _named_step_blocks(job_text).get(step) @@ -610,16 +622,19 @@ def _require_step_runs_exactly( "continue-on-error", "defaults", "container", - "strategy", - ) + ) + (() if allow_matrix_strategy else ("strategy",)) has_forbidden_job_key = any( _has_yaml_key(job_text, key, indent=4) for key in forbidden_job_keys ) # Scope/dependency controls are allowed only in their reviewed form. group = "pyplot" if job_text.startswith(" matplotlib_reference:") else "runtime" for key, expected in ( - ("if", f"needs.changes.outputs.{group} == 'true'"), - ("needs", "changes" if job_text.startswith(" rust_checks:") else "[changes, build]"), + () + if allow_matrix_strategy + else ( + ("if", f"needs.changes.outputs.{group} == 'true'"), + ("needs", "changes" if job_text.startswith(" rust_checks:") else "[changes, build]"), + ) ): values, ambiguous = _direct_yaml_key_values(job_text, key, indent=4) if ambiguous or values != [expected]: @@ -634,6 +649,20 @@ def _require_step_runs_exactly( errors.append(f"CI step {step!r} missing {description}: {list(commands)!r}") +def _require_matrix_step_runs_exactly( + errors: list[str], job_text: str, step: str, description: str, *commands: str +) -> None: + """Require the complete active command list in one matrix step.""" + _require_step_runs_exactly( + errors, + job_text, + step, + description, + *commands, + allow_matrix_strategy=True, + ) + + def _require_job_contains( errors: list[str], jobs: dict[str, str], @@ -1251,6 +1280,78 @@ def validate_ci_workflow(path: Path = DEFAULT_CI_WORKFLOW) -> list[str]: "tests/pyplot/test_gallery_behavior.py", "tests/pyplot/test_public_protocols.py", ) + _require_job_contains( + errors, + jobs, + REFLEX_COMPATIBILITY_JOB, + "CI", + "minimum and maximum supported Reflex compatibility matrix", + "fail-fast: false", + 'reflex-version: ["0.9.6", "0.9.13", "0.10.0"]', + ) + reflex_job = jobs.get(REFLEX_COMPATIBILITY_JOB, "") + matrix_values, matrix_unsafe = _direct_yaml_key_values(reflex_job, "reflex-version", indent=8) + if matrix_values != ['["0.9.6", "0.9.13", "0.10.0"]'] or matrix_unsafe: + errors.append( + "CI reflex_compatibility job must test exactly Reflex 0.9.6, 0.9.13, and 0.10.0" + ) + if _has_yaml_key(reflex_job, "exclude", indent=8): + errors.append("CI reflex_compatibility job must not exclude compatibility versions") + if _has_yaml_key(reflex_job, "include", indent=8): + errors.append("CI reflex_compatibility job must not include additional versions") + _require_matrix_step_runs_exactly( + errors, + reflex_job, + "Install exact Reflex compatibility target", + "exact Reflex installation", + "npm ci", + "uv venv .venv", + "uv pip install -p .venv/bin/python \\", + '"reflex==${{ matrix.reflex-version }}" \\', + '"numpy>=1.24" "anywidget>=0.9" "pytest>=8" "aiohttp>=3.9" "uvicorn>=0.23"', + "uv pip install -p .venv/bin/python -e . --no-deps", + ) + _require_matrix_step_runs_exactly( + errors, + reflex_job, + "Verify exact Reflex target and metadata window", + "Reflex version and metadata assertions", + ".venv/bin/python -c \"import importlib.metadata as m; assert m.version('reflex') == '${{ matrix.reflex-version }}'\"", + f".venv/bin/python -c \"from packaging.requirements import Requirement; import importlib.metadata as m; req = next(Requirement(value) for value in m.metadata('xy').get_all('Requires-Dist') if Requirement(value).name == 'reflex'); assert str(req.specifier) == '{REFLEX_SPECIFIER}'\"", + ) + _require_matrix_step_runs_exactly( + errors, + reflex_job, + "Component compile and event smoke", + "component compile and event smoke", + ".venv/bin/pytest -q tests/reflex_adapter/test_component.py::test_component_compiles_with_events", + ) + _require_matrix_step_runs_exactly( + errors, + reflex_job, + "State event and rebuild smoke", + "state event and rebuild smoke", + ".venv/bin/pytest -q tests/reflex_adapter/test_state_bridge.py::test_rebuild_reads_session_state", + ) + try: + project_metadata = (ROOT / "pyproject.toml").read_text(encoding="utf-8") + except OSError as exc: + errors.append(f"cannot read project metadata for Reflex compatibility gate: {exc}") + else: + try: + project = tomllib.loads(project_metadata).get("project", {}) + except tomllib.TOMLDecodeError as exc: + errors.append(f"cannot parse pyproject.toml for Reflex compatibility gate: {exc}") + else: + try: + optional_dependencies = project.get("optional-dependencies", {}) + reflex_dependencies = optional_dependencies.get("reflex") + except AttributeError: + reflex_dependencies = None + if reflex_dependencies != [REFLEX_OPTIONAL_DEPENDENCY]: + errors.append( + f"pyproject.toml must bound the xy[reflex] extra to {REFLEX_OPTIONAL_DEPENDENCY!r}" + ) _require_job_contains( errors, jobs, @@ -1535,7 +1636,7 @@ def validate_ci_workflow(path: Path = DEFAULT_CI_WORKFLOW) -> list[str]: "Rust-backed sdist install contract", "XY_REQUIRE_CARGO", "uv pip install --no-cache", - '"reflex>=0.9.6"', + f'"{REFLEX_OPTIONAL_DEPENDENCY}"', "import reflex_xy", "import xy.kernels as kernels", 'kernels.BACKEND == "native"', @@ -1547,6 +1648,7 @@ def validate_ci_workflow(path: Path = DEFAULT_CI_WORKFLOW) -> list[str]: "coreless sdist import contract", "XY_SKIP_CARGO", "uv pip install --no-cache", + f'"{REFLEX_OPTIONAL_DEPENDENCY}"', "import reflex_xy", "assert reflex_xy.__version__ == version", "native Rust core", diff --git a/spec/design/reflex-integration.md b/spec/design/reflex-integration.md index 31c5349c..94d3c5b1 100644 --- a/spec/design/reflex-integration.md +++ b/spec/design/reflex-integration.md @@ -1123,13 +1123,16 @@ tests/reflex_adapter/ token/registry/var/data-var/plan/factory/bridge/ root beside `register()`/`release()`. The core `python/xy` package itself stays Reflex-free (CLAUDE.md rule). -`xy[reflex]` adds full `reflex>=0.9.6` for now — the `reflex-base` split covers -components/vars but not yet App/state-manager access; revisit when a smaller -supported surface exists. +`xy[reflex]` declares the compatibility bound `>=0.9.6,<=0.10.0` — the +`reflex-base` split covers components/vars but not yet App/state-manager access; +revisit when a smaller supported surface exists. CI samples this bound with +Reflex 0.9.6, 0.9.13, and 0.10.0. Its upper limit is the newest release covered +by both compatibility smokes; raise it only after adding and passing the new +release in the matrix. **Versioning & releases.** The integration ships in every `xy` wheel and sdist, so it shares the core's version and bare `vX.Y.Z` release tags. The published -extra is dependency metadata only: it adds the supported Reflex floor without +extra is dependency metadata only: it selects the tested Reflex range without creating another distribution or release pipeline. ## 8. Superseded: the HTTP-routes draft diff --git a/spec/process/production-readiness.md b/spec/process/production-readiness.md index f7d916a0..f34631a6 100644 --- a/spec/process/production-readiness.md +++ b/spec/process/production-readiness.md @@ -100,6 +100,11 @@ The runtime selection runs four independent blocking jobs: | `browser_smokes` — Browser rendering + integration | Selector and shared wheel | JS parsing, native ABI, stdlib render/append/export probes, real-chart rendering, lifecycle, visuals, stress and dashboard reliability; focused pyplot gate when selected | | `regression_checks` — Blocking regression checks | Selector and shared wheel | Native/scatter/transport regression reports, deterministic payload gates, report upload and the benchmark smoke harness | +The additional `reflex_compatibility` matrix is selected by runtime changes, +reuses the shared native/client artifact, and remains a blocking dependency of +`CI result`. Its exact-version installs exercise the supported Reflex window +independently of the locked development environment. + Runtime job and step mappings reject `continue-on-error` structurally, including quoted and escaped key spellings. Step conditions are structurally forbidden except the exact pyplot-selection @@ -324,9 +329,9 @@ These must pass before publishing. | Real chart render | A real composed chart exports and paints in Chromium | `python scripts/smoke_render.py ` | | Step tier update | A decimated `step` chart keeps its risers after a synthetic kernel `tier_update` replaces the vertex buffers | `python scripts/step_tier_smoke.py ` | | Dashboard reliability | Attempts 10/20/50/60 charts, hard-gates the 10-chart row as loss-free and nonblank, retains partial larger rows, and applies the production shader-cache oracle to a complete, fully nonblank, loss-free 60-chart row | `python benchmarks/bench_dashboard.py --chart-counts 10,20,50,60 --chromium --json dashboard-smoke.json` then `python scripts/verify_benchmark_report.py dashboard-smoke.json --kind dashboard-browser` | -| sdist | Build-input-only source archive contains the `xy` and bundled `reflex_xy` packages, JSX/render-client bundles, complete JS/Rust build sources, and `PKG-INFO` version/dependencies (including the `matplotlib` and `reflex` extras with their supported ranges) matching the archive's own `xy-` root; repository-only material, duplicate/unsafe members, native binaries, and generated junk are absent | `python scripts/verify_sdist.py dist/*.tar.gz` | -| Native wheel | Platform wheel contains package-only `xy` and `reflex_xy` files, exactly one native library, the JSX wrapper but no duplicate render client, `METADATA` version/base dependencies and `matplotlib`/`reflex` extras matching the wheel's own filename and `.dist-info` (numeric local versions compare without leading zeros), complete hash-checked `RECORD`, public export-surface markers, matching filename/`WHEEL` tags, and is tagged non-pure | `python scripts/verify_wheel.py dist/*.whl --expect-native` | -| Fallback wheel | No-toolchain wheel contains package-only `xy` and `reflex_xy` files, `METADATA` version/base dependencies and `matplotlib`/`reflex` extras matching the wheel's own filename and `.dist-info`, complete hash-checked `RECORD`, public export-surface markers, matching filename/`WHEEL` tags, is pure, and contains no native library | `python scripts/verify_wheel.py dist/*.whl --expect-pure` | +| sdist | Build-input-only source archive contains the `xy` and bundled `reflex_xy` packages, JSX/render-client bundles, complete JS/Rust build sources, and `PKG-INFO` version/dependencies (including the `matplotlib` extra and bounded `reflex>=0.9.6,<=0.10.0` extra) matching the archive's own `xy-` root; repository-only material, duplicate/unsafe members, native binaries, and generated junk are absent | `python scripts/verify_sdist.py dist/*.tar.gz` | +| Native wheel | Platform wheel contains package-only `xy` and `reflex_xy` files, exactly one native library, the JSX wrapper but no duplicate render client, `METADATA` version/base dependencies and `matplotlib`/bounded `reflex` extras matching the wheel's own filename and `.dist-info` (numeric local versions compare without leading zeros), complete hash-checked `RECORD`, public export-surface markers, matching filename/`WHEEL` tags, and is tagged non-pure | `python scripts/verify_wheel.py dist/*.whl --expect-native` | +| Fallback wheel | No-toolchain wheel contains package-only `xy` and `reflex_xy` files, `METADATA` version/base dependencies and `matplotlib`/bounded `reflex` extras matching the wheel's own filename and `.dist-info` (numeric local versions compare without leading zeros), complete hash-checked `RECORD`, public export-surface markers, matching filename/`WHEEL` tags, is pure, and contains no native library | `python scripts/verify_wheel.py dist/*.whl --expect-pure` | | Wheel size | Platform wheel remains small enough for notebook installs | CI budget: 15 MB | | Benchmark artifact | JSON benchmark reports carry schema, environment, categories, row status, and finite non-negative metrics; native reports must declare the native backend | `python scripts/verify_benchmark_report.py benchmark.json --kind scatter-vs`; repeat for line, install, core-2D, pyplot-vs-matplotlib, native, interaction, dashboard, and workflow artifacts | @@ -839,8 +844,8 @@ Before merging a release pull request: the documented native-core error. Wheel `METADATA` must keep `Name: xy`, `Requires-Python: >=3.11`, `anywidget>=0.9`, and `numpy>=1.24` as base requirements, plus - `matplotlib>=3.11,<3.12` and `reflex>=0.9.6` guarded by their respective - `matplotlib` and `reflex` extras. The wheel + `matplotlib>=3.11,<3.12` and `reflex>=0.9.6,<=0.10.0` guarded by their + respective `matplotlib` and `reflex` extras. The wheel must contain `reflex_xy` and `XYChart.jsx`, and `RECORD` must list every archive file exactly once with matching `sha256` and size fields. Wheels and the sdist remain distribution/build-input-only: docs, tests, benchmarks, @@ -852,9 +857,13 @@ Before merging a release pull request: Every `xy` release carries the `reflex_xy` Python package and JSX wrapper. The wrapper links to the render client in the same installed distribution, so client, kernel, and framework bridge share one version. Plain `xy` must not -install Reflex; `xy[reflex]` must install the declared supported floor. -Release smoke tests install Reflex, import `reflex_xy`, and assert that its -reported version matches the `xy` distribution version. +install Reflex; `xy[reflex]` declares the compatibility bound +`>=0.9.6,<=0.10.0`. CI samples it with component compile and state-event rebuild +smokes on the minimum `0.9.6`, newest `0.9.x` release `0.9.13`, and `0.10.0`. +The upper limit is the newest release covered by both smokes; raise it only +after adding the new release to the matrix and passing both smokes. Release smoke tests +install the bounded extra, import `reflex_xy`, and assert that its reported +version matches the `xy` distribution version. ## Hardening Backlog diff --git a/tests/test_artifact_metadata.py b/tests/test_artifact_metadata.py new file mode 100644 index 00000000..5ff87725 --- /dev/null +++ b/tests/test_artifact_metadata.py @@ -0,0 +1,29 @@ +from email.parser import Parser + +from scripts.artifact_metadata import dependency_metadata_errors + + +def _metadata(reflex_requirement: str) -> object: + return Parser().parsestr( + "\n".join( + [ + "Requires-Dist: anywidget>=0.9", + "Requires-Dist: numpy>=1.24", + 'Requires-Dist: matplotlib>=3.11,<3.12; extra == "matplotlib"', + f"Requires-Dist: {reflex_requirement}", + "Provides-Extra: matplotlib", + "Provides-Extra: reflex", + "", + ] + ) + ) + + +def test_bounded_reflex_metadata_is_accepted() -> None: + assert dependency_metadata_errors(_metadata('reflex<=0.10.0,>=0.9.6; extra == "reflex"')) == [] + + +def test_unbounded_reflex_metadata_is_rejected() -> None: + errors = dependency_metadata_errors(_metadata('reflex>=0.9.6; extra == "reflex"')) + + assert any("reflex" in error and "0.9.6" in error and "0.10.0" in error for error in errors) diff --git a/tests/test_verify_ci_workflow.py b/tests/test_verify_ci_workflow.py index 63bda644..067a2d13 100644 --- a/tests/test_verify_ci_workflow.py +++ b/tests/test_verify_ci_workflow.py @@ -56,6 +56,173 @@ def test_ci_workflow_requires_locked_reflex_environment(tmp_path: Path) -> None: assert any("uv sync --locked --extra reflex --group dev" in error for error in errors) +def test_reflex_compatibility_gate_requires_named_active_steps(tmp_path: Path) -> None: + workflow = Path(".github/workflows/ci.yml").read_text(encoding="utf-8") + cases = { + "renamed": workflow.replace( + "- name: Install exact Reflex compatibility target", + "- name: Unrelated install step", + ), + "disabled": workflow.replace( + " - name: Install exact Reflex compatibility target\n", + " - name: Install exact Reflex compatibility target\n if: false\n", + ), + } + for name, mutated in cases.items(): + path = tmp_path / f"ci-{name}.yml" + path.write_text(mutated, encoding="utf-8") + + errors = verify_ci_workflow.validate_ci_workflow(path) + + assert any("Install exact Reflex compatibility target" in error for error in errors) + + +def test_reflex_compatibility_gate_rejects_echoed_commands(tmp_path: Path) -> None: + workflow = Path(".github/workflows/ci.yml").read_text(encoding="utf-8") + workflow = workflow.replace( + " run: .venv/bin/pytest -q tests/reflex_adapter/test_component.py::test_component_compiles_with_events", + ' run: echo \\".venv/bin/pytest -q tests/reflex_adapter/test_component.py::test_component_compiles_with_events\\"', + ) + path = tmp_path / "ci.yml" + path.write_text(workflow, encoding="utf-8") + + errors = verify_ci_workflow.validate_ci_workflow(path) + + assert any("component compile and event smoke" in error for error in errors) + + +def test_reflex_compatibility_gate_rejects_heredoc_commands(tmp_path: Path) -> None: + workflow = Path(".github/workflows/ci.yml").read_text(encoding="utf-8") + workflow = workflow.replace( + " run: .venv/bin/pytest -q tests/reflex_adapter/test_component.py::test_component_compiles_with_events", + " run: |\n" + " python - <<'PY'\n" + " .venv/bin/pytest -q tests/reflex_adapter/test_component.py::test_component_compiles_with_events\n" + " PY", + ) + path = tmp_path / "ci.yml" + path.write_text(workflow, encoding="utf-8") + + errors = verify_ci_workflow.validate_ci_workflow(path) + + assert any("component compile and event smoke" in error for error in errors) + + +def test_reflex_compatibility_gate_rejects_inactive_shell_commands(tmp_path: Path) -> None: + workflow = Path(".github/workflows/ci.yml").read_text(encoding="utf-8") + expected = ".venv/bin/pytest -q tests/reflex_adapter/test_component.py::test_component_compiles_with_events" + inactive_forms = { + "function": ( + f" run: |\n smoke() {{\n {expected}\n }}\n" + ), + "condition": ( + f" run: |\n if false; then\n {expected}\n fi\n" + ), + } + for name, run in inactive_forms.items(): + mutated = workflow.replace(f" run: {expected}", run) + path = tmp_path / f"ci-{name}.yml" + path.write_text(mutated, encoding="utf-8") + + errors = verify_ci_workflow.validate_ci_workflow(path) + + assert any("component compile and event smoke" in error for error in errors) + + +def test_reflex_compatibility_gate_requires_state_rebuild_smoke(tmp_path: Path) -> None: + workflow = Path(".github/workflows/ci.yml").read_text(encoding="utf-8") + step = " - name: State event and rebuild smoke\n" + path = tmp_path / "ci-missing-state-smoke.yml" + path.write_text( + workflow.replace(step, " - name: Unrelated state check\n"), encoding="utf-8" + ) + + errors = verify_ci_workflow.validate_ci_workflow(path) + + assert any("State event and rebuild smoke" in error for error in errors) + + +def test_reflex_compatibility_gate_rejects_inactive_state_rebuild_command( + tmp_path: Path, +) -> None: + workflow = Path(".github/workflows/ci.yml").read_text(encoding="utf-8") + command = ".venv/bin/pytest -q tests/reflex_adapter/test_state_bridge.py::test_rebuild_reads_session_state" + path = tmp_path / "ci-inactive-state-smoke.yml" + path.write_text( + workflow.replace(f" run: {command}", f' run: echo "{command}"'), + encoding="utf-8", + ) + + errors = verify_ci_workflow.validate_ci_workflow(path) + + assert any("state event and rebuild smoke" in error for error in errors) + + +def test_reflex_compatibility_gate_rejects_excluded_versions(tmp_path: Path) -> None: + workflow = Path(".github/workflows/ci.yml").read_text(encoding="utf-8") + workflow = workflow.replace( + ' reflex-version: ["0.9.6", "0.9.13", "0.10.0"]\n', + ' reflex-version: ["0.9.6", "0.9.13", "0.10.0"]\n' + " exclude:\n" + ' - reflex-version: "0.9.6"\n' + ' - reflex-version: "0.10.0"\n', + ) + path = tmp_path / "ci.yml" + path.write_text(workflow, encoding="utf-8") + + errors = verify_ci_workflow.validate_ci_workflow(path) + + assert any("must not exclude" in error for error in errors) + + +def test_reflex_compatibility_gate_rejects_included_versions(tmp_path: Path) -> None: + workflow = Path(".github/workflows/ci.yml").read_text(encoding="utf-8") + workflow = workflow.replace( + ' reflex-version: ["0.9.6", "0.9.13", "0.10.0"]\n', + ' reflex-version: ["0.9.6", "0.9.13", "0.10.0"]\n' + " include:\n" + ' - reflex-version: "0.10.1"\n', + ) + path = tmp_path / "ci.yml" + path.write_text(workflow, encoding="utf-8") + + errors = verify_ci_workflow.validate_ci_workflow(path) + + assert any("must not include" in error for error in errors) + + +def test_reflex_extra_check_reads_the_optional_dependency_table( + tmp_path: Path, monkeypatch +) -> None: + workflow = Path(".github/workflows/ci.yml").read_text(encoding="utf-8") + path = tmp_path / "ci.yml" + path.write_text(workflow, encoding="utf-8") + (tmp_path / "pyproject.toml").write_text( + "[project.optional-dependencies]\n" + 'reflex = ["reflex>=0.9.6"]\n' + 'other = ["reflex>=0.9.6,<=0.10.0"]\n', + encoding="utf-8", + ) + monkeypatch.setattr(verify_ci_workflow, "ROOT", tmp_path) + + errors = verify_ci_workflow.validate_ci_workflow(path) + + assert any("pyproject.toml must bound" in error for error in errors) + + +def test_reflex_extra_check_reports_malformed_project_toml(tmp_path: Path, monkeypatch) -> None: + workflow = Path(".github/workflows/ci.yml").read_text(encoding="utf-8") + workflow_path = tmp_path / "ci.yml" + workflow_path.write_text(workflow, encoding="utf-8") + (tmp_path / "pyproject.toml").write_text("[project\n", encoding="utf-8") + monkeypatch.setattr(verify_ci_workflow, "ROOT", tmp_path) + + errors = verify_ci_workflow.validate_ci_workflow(workflow_path) + + assert any("cannot parse pyproject.toml" in error for error in errors) + assert not any("pyproject.toml must bound" in error for error in errors) + + def test_locked_reflex_environment_must_be_in_named_install_step(tmp_path: Path) -> None: workflow = Path(".github/workflows/ci.yml").read_text(encoding="utf-8") required = " uv sync --locked --extra reflex --group dev" @@ -1742,6 +1909,29 @@ def test_ci_workflow_rejects_missing_coreless_sdist_reflex_import(tmp_path: Path ) +def test_ci_workflow_rejects_unbounded_coreless_sdist_reflex_install(tmp_path: Path) -> None: + workflow = Path(".github/workflows/ci.yml").read_text(encoding="utf-8") + prefix, smoke = workflow.split(" - name: Verify coreless sdist imports reflex_xy\n", 1) + path = tmp_path / "ci.yml" + path.write_text( + prefix + + " - name: Verify coreless sdist imports reflex_xy\n" + + smoke.replace( + '"reflex>=0.9.6,<=0.10.0"', + '"reflex>=0.9.6"', + 1, + ), + encoding="utf-8", + ) + + errors = verify_ci_workflow.validate_ci_workflow(path) + + assert any( + "Verify coreless sdist imports reflex_xy" in error and "reflex>=0.9.6,<=0.10.0" in error + for error in errors + ) + + # step has to be anchored on the upload step itself rather than on the first # occurrence of the predicate in the file. def test_ci_workflow_rejects_shallow_checkout(tmp_path: Path) -> None: diff --git a/tests/test_verify_sdist.py b/tests/test_verify_sdist.py index 2027f295..52a6d056 100644 --- a/tests/test_verify_sdist.py +++ b/tests/test_verify_sdist.py @@ -34,9 +34,9 @@ "Requires-Python: >=3.11\n" "Requires-Dist: anywidget>=0.9\n" "Requires-Dist: numpy>=1.24\n" + "Requires-Dist: reflex>=0.9.6,<=0.10.0; extra == 'reflex'\n" "Provides-Extra: matplotlib\n" "Requires-Dist: matplotlib<3.12,>=3.11; extra == 'matplotlib'\n" - "Requires-Dist: reflex>=0.9.6; extra == 'reflex'\n" "Provides-Extra: reflex\n" ) diff --git a/tests/test_verify_wheel.py b/tests/test_verify_wheel.py index 9ee6ecff..4cdfda05 100644 --- a/tests/test_verify_wheel.py +++ b/tests/test_verify_wheel.py @@ -85,9 +85,9 @@ def __getattr__(name): "Requires-Python: >=3.11", "Requires-Dist: anywidget>=0.9", "Requires-Dist: numpy>=1.24", + "Requires-Dist: reflex>=0.9.6,<=0.10.0; extra == 'reflex'", "Provides-Extra: matplotlib", "Requires-Dist: matplotlib<3.12,>=3.11; extra == 'matplotlib'", - "Requires-Dist: reflex>=0.9.6; extra == 'reflex'", "Provides-Extra: reflex", ] ) diff --git a/uv.lock b/uv.lock index ca8020b6..73ee65e8 100644 --- a/uv.lock +++ b/uv.lock @@ -2581,7 +2581,7 @@ requires-dist = [ { name = "anywidget", specifier = ">=0.9" }, { name = "matplotlib", marker = "extra == 'matplotlib'", specifier = ">=3.11,<3.12" }, { name = "numpy", specifier = ">=1.24" }, - { name = "reflex", marker = "extra == 'reflex'", specifier = ">=0.9.6" }, + { name = "reflex", marker = "extra == 'reflex'", specifier = ">=0.9.6,<=0.10.0" }, ] provides-extras = ["matplotlib", "reflex"]