diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 087688fd..8607b73e 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -13,6 +13,8 @@ on: - "hatch_build.py" - "pyproject.toml" - "scripts/verify_docs_quickstart.py" + - "scripts/verify_docs_local.py" + - "scripts/check_public_api.py" - ".pre-commit-config.yaml" - ".github/workflows/docs.yml" pull_request: @@ -26,6 +28,8 @@ on: - "hatch_build.py" - "pyproject.toml" - "scripts/verify_docs_quickstart.py" + - "scripts/verify_docs_local.py" + - "scripts/check_public_api.py" - ".pre-commit-config.yaml" - ".github/workflows/docs.yml" workflow_dispatch: @@ -60,17 +64,9 @@ jobs: - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version: "22" - - name: Install docs dependencies - run: uv sync --project docs/app --frozen --group dev - - name: Run docs tests - run: uv run --project docs/app --no-sync pytest docs/app/tests -v - - name: Run public docs quickstart against the checkout - run: uv run --project docs/app --no-sync python scripts/verify_docs_quickstart.py - - name: Run docs quality hooks - run: | - uv run --project docs/app --no-sync pre-commit run ruff-format --all-files - uv run --project docs/app --no-sync pre-commit run ruff-check --all-files - uv run --project docs/app --no-sync pre-commit run docs-app-codespell --all-files + - name: Run the local docs quality gate + shell: bash + run: uv run --no-project python scripts/verify_docs_local.py production: name: Production docs (Python ${{ matrix.python-version }}) diff --git a/Makefile b/Makefile index 6c950b6a..872944de 100644 --- a/Makefile +++ b/Makefile @@ -29,7 +29,7 @@ help: ' make check-full run JS, Rust, and ABI gates too' \ ' make check-browser run browser smokes (set CHROMIUM=/path/to/chrome)' \ ' make check-conformance run accessibility + Chromium/Firefox/WebKit conformance' \ - ' make check-docs run documentation examples' \ + ' make check-docs run docs tests, quickstart, and quality hooks' \ ' make check-examples run canonical API examples and Reflex asset registry checks' \ ' make check-security run standalone HTML safety and client text-sink checks' \ ' make check-errors run public error, LOD, and mutation-safety tests' \ @@ -89,7 +89,7 @@ check-conformance: node scripts/browser_conformance.mjs check-docs: - $(PYTHON) scripts/verify_local.py --only examples + $(PYTHON) scripts/verify_local.py --only docs check-examples: $(PYTHON) scripts/verify_local.py --only examples diff --git a/docs/api-reference/contributing.md b/docs/api-reference/contributing.md index 67a1f9ab..add659cf 100644 --- a/docs/api-reference/contributing.md +++ b/docs/api-reference/contributing.md @@ -22,7 +22,7 @@ make check `make setup` installs the editable development package and builds the required native core. Source development needs Python 3.11+, a Rust toolchain for that -core, and Node 18+ for bundle checks. Confirm the active compute backend +core, and Node.js ^20.19.0 or >=22.12.0 for bundle checks. Confirm the active compute backend explicitly: ~~~bash @@ -38,7 +38,8 @@ import error rather than silently selecting another backend. | --- | --- | | Fast local verification | `make check` | | Production-facing change | `make check-full` | -| Public docs and examples | `make check-docs` | +| Public docs | `make check-docs` | +| Example apps and Reflex chart registry/assets | `make check-examples` | | Public exports or annotations | `make check-api` | | Lazy imports and dependency boundaries | `make check-import` | | Validation and mutation behavior | `make check-errors` | diff --git a/docs/api-reference/events-and-callbacks.md b/docs/api-reference/events-and-callbacks.md index 07678dbd..6d94a533 100644 --- a/docs/api-reference/events-and-callbacks.md +++ b/docs/api-reference/events-and-callbacks.md @@ -49,6 +49,11 @@ chart = xy.scatter_chart( - `index` — all selected indices concatenated; use `per_trace` when trace identity matters. - `xy(trace_id=0)` — canonical f64 x/y arrays for one selected trace. +- `rows(limit=None)` — exact canonical row dictionaries for selected rows, + optionally capped for large selections. Rows are returned in ascending trace + ID, then ascending canonical row index. A limit keeps the first N rows; this + method does not return a truncation flag, so compare `len(rows)` with + `len(selection)` to detect an incomplete result. - `len(selection)` — total selected rows across traces. Clearing selection delivers an empty `Selection`. `Chart.select_range()` diff --git a/docs/api-reference/figure-methods.md b/docs/api-reference/figure-methods.md index dc2a323e..f00fef3e 100644 --- a/docs/api-reference/figure-methods.md +++ b/docs/api-reference/figure-methods.md @@ -15,12 +15,14 @@ code should build charts through components and call the public methods below. | Method | Contract | | --- | --- | | `chart.widget()` | Build or return the cached live AnyWidget. | -| `chart.show()` | Return the same widget; it does not open a desktop browser. | +| `chart.show()` | Return the live widget in widget mode or an `HtmlView` in HTML mode; it does not open a desktop browser. | | `chart.figure()` | Build or return the cached internal engine figure. | In a compatible notebook, leaving a chart as the final cell expression invokes -its display hook automatically. Python callbacks require the live widget or a -framework adapter. +its display hook automatically. In `display="auto"` mode, controlled by +`XY_NOTEBOOK_DISPLAY`, supported notebooks use the live widget and HTML mode +uses `chart._repr_html_()` as the standalone-HTML path. Python callbacks require the +live widget or a framework adapter. ## HTML and Static Export @@ -106,6 +108,29 @@ selection: xy.Selection = chart.select_range(x0, x1, y0, y1, trace_id=None) Streaming has additional channel and monotonic-line constraints documented in [Real-time and streaming data](/docs/xy/guides/real-time-and-streaming-data/). +## View and Selection State + +~~~python +chart.set_view(ranges, animate=True, history=True) +chart.reset_view(axes=None) +chart.select(range=(x0, x1, y0, y1), history=True) +chart.clear_selection() +state: dict = chart.view_state() +~~~ + +- `set_view()` applies explicit axis ranges to the live chart state. +- `reset_view()` navigates axes to their home ranges; `axes=None` uses the + configured `reset_axes`. +- `select()` accepts one of `range=`, `polygon=`, or `rows=`. Geometric forms + follow the browser gesture state path; rows resolve kernel-side and are + non-durable. +- `clear_selection()` clears the current selection, including range, polygon, + and row selections. +- `chart.view_state()` returns the last client-confirmed viewport and selection + state; reads after writes are eventually consistent. For `chart.select(rows=...)`, + the state contains an opaque `{"rows": true}` marker rather than the selected + row indices. + ## Framework Chrome ~~~python @@ -119,11 +144,14 @@ objects. `reflex_components()` is an alias retained for adapter code. ## FacetChart Methods -`FacetChart` provides `figure()`, `widget()`, `show()`, `to_html()`/`html()`, -`to_svg()`, `to_png()`, `to_image()`, `write_image()`, and `memory_report()`. -Its widget methods return one -widget per panel, and its figure escape hatch returns an internal facet grid. -Grid dimensions come from `facet_chart()`, so the facet SVG/PNG methods do not +Assign the value returned by `xy.facet_chart(...)` to `facet_chart` to use its +instance methods: `facet_chart.figure()`, `facet_chart.widget()`, +`facet_chart.show()`, `facet_chart.to_html()`/`facet_chart.html()`, +`facet_chart._repr_html_()`, `facet_chart.to_svg()`, `facet_chart.to_png()`, +`facet_chart.to_image()`, `facet_chart.write_image()`, and +`facet_chart.memory_report()`. Its widget methods return one widget per panel, +and its figure escape hatch returns an internal facet grid. Grid dimensions come +from `facet_chart()`, so the facet SVG/PNG methods do not accept per-call width or height. Facets do not expose append, pick, or Python-side range selection methods. diff --git a/docs/api-reference/public-types.md b/docs/api-reference/public-types.md index abf021c8..4bdd5cbd 100644 --- a/docs/api-reference/public-types.md +++ b/docs/api-reference/public-types.md @@ -26,6 +26,7 @@ construct lowercase factory results and only name these types in annotations. | `Spring` | Serializable spring easing policy used by `animation()` | | `Chart` | Public composed chart with display, export, and readout methods | | `FacetChart` | Public small-multiple wrapper | +| `ExportConfig` | Validated defaults for HTML and static image export | Directly constructing node dataclasses is supported for inspection and typing, but the lowercase factories perform the intended validation and normalization. diff --git a/docs/app/tests/test_docs_site.py b/docs/app/tests/test_docs_site.py index 26a69e10..6cd12f31 100644 --- a/docs/app/tests/test_docs_site.py +++ b/docs/app/tests/test_docs_site.py @@ -43,6 +43,7 @@ AXES_AND_ANNOTATIONS, CHART_FACTORY_GROUPS, CHROME_AND_BEHAVIOR, + FACTORY_DOCSTRING_ALIASES, MARKS, append_component_api_markdown, axes_and_annotations_api, @@ -973,9 +974,10 @@ def test_handwritten_output_reference_tracks_the_public_api() -> None: block for block in parse_document(method_reference).blocks if isinstance(block, CodeBlock) ] - assert len(method_blocks) == 4 + assert len(method_blocks) == 5 assert all(block.language == "python" for block in method_blocks) assert "animation_progress=None" in method_blocks[0].content + for method in (xy.Chart.to_html, xy.Chart.html): assert "animation_progress" in inspect.signature(method).parameters @@ -3064,7 +3066,8 @@ def test_documented_factories_describe_every_parameter() -> None: ) for factory in factories: - docstring = inspect.getdoc(factory) or "" + documentation_source = FACTORY_DOCSTRING_ALIASES.get(factory, factory) + docstring = inspect.getdoc(documentation_source) or "" assert "Args:" in docstring, factory.__name__ for parameter in inspect.signature(factory).parameters.values(): assert f"{parameter.name}:" in docstring, ( diff --git a/docs/app/xy_docs/api_reference.py b/docs/app/xy_docs/api_reference.py index 9e8523b8..cec42143 100644 --- a/docs/app/xy_docs/api_reference.py +++ b/docs/app/xy_docs/api_reference.py @@ -18,6 +18,7 @@ import reflex_xy import xy +from xy._public_api_manifest import PUBLIC_API_MANIFEST from xy.components import _POLAR_INERT_AXIS_KEYWORDS COMPONENT_API_METADATA_KEY = "components" @@ -29,53 +30,9 @@ _COMPONENT_API_COLUMN_WIDTHS = ("w-[20%]", "w-[25%]", "w-[55%]") _NON_ID_CHARACTER = re.compile(r"[^a-z0-9]+") -CHART_FACTORY_GROUPS = ( - ( - "Line and Area", - (xy.line_chart, xy.area_chart, xy.step_chart, xy.stairs_chart), - ), - ("Scatter", (xy.scatter_chart,)), - # The polar compositions are public factories like any other, so they belong - # in the generated inventory. `pie_chart` is listed with them: without an - # entry here the API reference implied the only way to draw a pie was to - # hand-roll wedge bars — the exact thing the factory exists to replace. - ( - "Polar", - ( - xy.polar_chart, - xy.radar_chart, - xy.polar_bar_chart, - xy.pie_chart, - xy.wind_rose, - ), - ), - ("Bar and Column", (xy.bar_chart, xy.column_chart)), - ( - "Distributions", - ( - xy.histogram_chart, - xy.ecdf_chart, - xy.box_chart, - xy.violin_chart, - ), - ), - ( - "Density and Grids", - (xy.hexbin_chart, xy.heatmap_chart, xy.contour_chart), - ), - ("Uncertainty", (xy.error_band_chart, xy.errorbar_chart)), - ( - "Specialized", - ( - xy.stem_chart, - xy.segments_chart, - xy.sankey_chart, - xy.funnel_chart, - xy.triangle_mesh_chart, - ), - ), - ("Annotations and Layers", (xy.chart,)), - ("Facets", (xy.facet_chart,)), +CHART_FACTORY_GROUPS = tuple( + (group_name, tuple(getattr(xy, name) for name in factory_names)) + for group_name, factory_names in PUBLIC_API_MANIFEST.chart_factory_groups ) _CHART_FACTORY_COMPONENTS = frozenset( factory for _group_name, factories in CHART_FACTORY_GROUPS for factory in factories @@ -94,59 +51,25 @@ xy.r_axis: frozenset(_POLAR_INERT_AXIS_KEYWORDS), } -MARKS = ( - xy.line, - xy.scatter, - xy.area, - xy.bar, - xy.column, - xy.histogram, - xy.box, - xy.violin, - xy.ecdf, - xy.heatmap, - xy.hexbin, - xy.contour, - xy.errorbar, - xy.error_band, - xy.step, - xy.stairs, - xy.stem, - xy.segments, - xy.ribbon, - xy.sankey, - xy.funnel, - xy.triangle_mesh, -) +MARKS = tuple(getattr(xy, name) for name in PUBLIC_API_MANIFEST.mark_factories) -AXES_AND_ANNOTATIONS = ( - xy.x_axis, - xy.y_axis, - xy.theta_axis, - xy.r_axis, - xy.vline, - xy.hline, - xy.x_band, - xy.y_band, - xy.threshold, - xy.threshold_zone, - xy.text, - xy.label, - xy.marker, - xy.arrow, - xy.callout, +AXES_AND_ANNOTATIONS = tuple( + getattr(xy, name) + for name in (*PUBLIC_API_MANIFEST.axis_factories, *PUBLIC_API_MANIFEST.annotation_factories) ) -CHROME_AND_BEHAVIOR = ( - xy.legend, - xy.tooltip, - xy.colorbar, - xy.modebar, - xy.theme, - xy.interaction_config, - xy.animation, - xy.spring, +CHROME_AND_BEHAVIOR = tuple( + getattr(xy, name) + for name in ( + *PUBLIC_API_MANIFEST.chrome_factories, + *( + name + for name in PUBLIC_API_MANIFEST.support_factories + if name in {"animation", "spring"} + ), + ) ) +FACTORY_DOCSTRING_ALIASES = {xy.hist: xy.histogram} @dataclass(frozen=True, slots=True) @@ -360,7 +283,8 @@ def _component_api_references( msg = f"Unknown documentation component: {component_path!r}" raise ValueError(msg) - docstring = inspect.getdoc(component) or "" + documentation_source = FACTORY_DOCSTRING_ALIASES.get(component, component) + docstring = inspect.getdoc(documentation_source) or "" descriptions = _parameter_descriptions(docstring) parameters: list[ComponentApiParameter] = [] for parameter, parameter_descriptions in _documented_parameters( diff --git a/docs/components/marks.md b/docs/components/marks.md index 240fb446..3fdee9dd 100644 --- a/docs/components/marks.md +++ b/docs/components/marks.md @@ -8,6 +8,7 @@ components: - xy.bar - xy.column - xy.histogram + - xy.hist - xy.box - xy.violin - xy.ecdf @@ -22,6 +23,7 @@ components: - xy.segments - xy.ribbon - xy.sankey + - xy.mark - xy.funnel - xy.triangle_mesh --- diff --git a/news/458.docs.md b/news/458.docs.md new file mode 100644 index 00000000..69a73ce7 --- /dev/null +++ b/news/458.docs.md @@ -0,0 +1 @@ +Document chart view and selection methods, including Selection.rows(), and validate documentation against a shared public API inventory. diff --git a/python/xy/_public_api_manifest.py b/python/xy/_public_api_manifest.py new file mode 100644 index 00000000..b0629e75 --- /dev/null +++ b/python/xy/_public_api_manifest.py @@ -0,0 +1,201 @@ +from __future__ import annotations + +from dataclasses import dataclass + +EXPERIMENTAL_PUBLIC_EXPORTS: tuple[str, ...] = () +DEPRECATED_PUBLIC_EXPORTS: tuple[str, ...] = () +PRIVATE_PUBLIC_EXPORTS: tuple[str, ...] = () + + +@dataclass(frozen=True) +class PublicApiInventory: + """Machine-readable inventory of the supported public composition API.""" + + component_reexports: tuple[str, ...] + component_types: tuple[str, ...] + mark_factories: tuple[str, ...] + annotation_factories: tuple[str, ...] + axis_factories: tuple[str, ...] + chrome_factories: tuple[str, ...] + chart_factories: tuple[str, ...] + support_factories: tuple[str, ...] + chart_methods: tuple[str, ...] + selection_methods: tuple[str, ...] + facet_chart_methods: tuple[str, ...] = () + experimental_exports: tuple[str, ...] = EXPERIMENTAL_PUBLIC_EXPORTS + deprecated_exports: tuple[str, ...] = DEPRECATED_PUBLIC_EXPORTS + private_exports: tuple[str, ...] = PRIVATE_PUBLIC_EXPORTS + chart_factory_groups: tuple[tuple[str, tuple[str, ...]], ...] = () + + @property + def component_factories(self) -> tuple[str, ...]: + return ( + *self.mark_factories, + *self.annotation_factories, + *self.axis_factories, + *self.chrome_factories, + *self.chart_factories, + *self.support_factories, + ) + + @property + def declarative_exports(self) -> tuple[str, ...]: + return ( + *self.component_types, + *self.component_factories, + ) + + @property + def classified_component_exports(self) -> tuple[str, ...]: + return ( + *self.component_reexports, + *self.declarative_exports, + *self.experimental_exports, + *self.deprecated_exports, + *self.private_exports, + ) + + +PUBLIC_API_MANIFEST = PublicApiInventory( + component_reexports=("CHART_DOM_SLOTS", "structural_probe"), + component_types=( + "Animation", + "Annotation", + "Axis", + "Chart", + "Colorbar", + "Component", + "ExportConfig", + "FacetChart", + "Interaction", + "Legend", + "Mark", + "Modebar", + "Spring", + "Theme", + "Tooltip", + ), + mark_factories=( + "area", + "bar", + "box", + "column", + "contour", + "ecdf", + "error_band", + "errorbar", + "funnel", + "heatmap", + "hexbin", + "hist", + "histogram", + "line", + "mark", + "ribbon", + "sankey", + "scatter", + "segments", + "stairs", + "stem", + "step", + "triangle_mesh", + "violin", + ), + annotation_factories=( + "arrow", + "callout", + "hline", + "label", + "marker", + "text", + "threshold", + "threshold_zone", + "vline", + "x_band", + "y_band", + ), + axis_factories=("r_axis", "theta_axis", "x_axis", "y_axis"), + chrome_factories=("colorbar", "interaction_config", "legend", "modebar", "theme", "tooltip"), + chart_factories=( + "area_chart", + "bar_chart", + "box_chart", + "chart", + "column_chart", + "contour_chart", + "ecdf_chart", + "error_band_chart", + "errorbar_chart", + "funnel_chart", + "heatmap_chart", + "hexbin_chart", + "histogram_chart", + "line_chart", + "pie_chart", + "polar_bar_chart", + "polar_chart", + "radar_chart", + "sankey_chart", + "scatter_chart", + "segments_chart", + "stairs_chart", + "stem_chart", + "step_chart", + "triangle_mesh_chart", + "violin_chart", + "wind_rose", + ), + chart_factory_groups=( + ("Line and Area", ("line_chart", "area_chart", "step_chart", "stairs_chart")), + ("Scatter", ("scatter_chart",)), + ("Polar", ("polar_chart", "radar_chart", "polar_bar_chart", "pie_chart", "wind_rose")), + ("Bar and Column", ("bar_chart", "column_chart")), + ("Distributions", ("histogram_chart", "ecdf_chart", "box_chart", "violin_chart")), + ("Density and Grids", ("hexbin_chart", "heatmap_chart", "contour_chart")), + ("Uncertainty", ("error_band_chart", "errorbar_chart")), + ( + "Specialized", + ("stem_chart", "segments_chart", "sankey_chart", "funnel_chart", "triangle_mesh_chart"), + ), + ("Annotations", ("chart",)), + ("Facets and Layers", ("chart", "facet_chart")), + ), + support_factories=("animation", "export_config", "facet_chart", "spring"), + chart_methods=( + "figure", + "chrome_components", + "reflex_components", + "widget", + "show", + "set_view", + "reset_view", + "select", + "clear_selection", + "view_state", + "to_html", + "html", + "_repr_html_", + "to_svg", + "to_png", + "to_image", + "write_image", + "memory_report", + "append", + "pick", + "select_range", + ), + selection_methods=("index", "__len__", "xy", "rows"), + facet_chart_methods=( + "figure", + "widget", + "show", + "to_html", + "html", + "_repr_html_", + "to_svg", + "to_png", + "to_image", + "write_image", + "memory_report", + ), +) diff --git a/scripts/check_public_api.py b/scripts/check_public_api.py index 8f813d85..8d32de29 100644 --- a/scripts/check_public_api.py +++ b/scripts/check_public_api.py @@ -16,14 +16,18 @@ import argparse import ast import importlib +import inspect import json import os +import re import subprocess import sys import textwrap from pathlib import Path from types import ModuleType -from typing import Any, Optional +from typing import Any, Optional, get_type_hints + +from xy._public_api_manifest import PUBLIC_API_MANIFEST, PublicApiInventory ROOT = Path(__file__).resolve().parents[1] IMPORT_BUDGET_MS = 200.0 @@ -46,81 +50,37 @@ "xy.widget", } HEAVY_IMPORTS = HEAVY_THIRD_PARTY_IMPORTS | HEAVY_XY_IMPORTS -COMPONENT_REEXPORTS = {"CHART_DOM_SLOTS"} -DECLARATIVE_MARK_EXPORTS = ( - "scatter", - "segments", - "triangle_mesh", - "line", - "area", - "histogram", - "hist", - "bar", - "column", - "heatmap", -) -DECLARATIVE_ANNOTATION_EXPORTS = ( - "arrow", - "callout", - "label", - "marker", - "threshold", - "threshold_zone", - "vline", - "hline", - "x_band", - "y_band", - "text", -) -DECLARATIVE_AXIS_EXPORTS = ("x_axis", "y_axis", "theta_axis", "r_axis") -DECLARATIVE_CHROME_EXPORTS = ( - "legend", - "tooltip", - "colorbar", - "modebar", - "theme", - "interaction_config", -) -DECLARATIVE_CHART_EXPORTS = ( - "chart", - "scatter_chart", - "polar_chart", - "radar_chart", - "polar_bar_chart", - "pie_chart", - "wind_rose", - "segments_chart", - "triangle_mesh_chart", - "line_chart", - "area_chart", - "histogram_chart", - "bar_chart", - "column_chart", - "heatmap_chart", -) -DECLARATIVE_CHART_READOUTS = ( - "figure", - "widget", - "show", - "to_html", - "html", - "_repr_html_", - "to_svg", - "to_png", - "memory_report", - "chrome_components", - "reflex_components", - "append", - "pick", - "select_range", -) -DECLARATIVE_API_EXPORTS = ( - *DECLARATIVE_MARK_EXPORTS, - *DECLARATIVE_ANNOTATION_EXPORTS, - *DECLARATIVE_AXIS_EXPORTS, - *DECLARATIVE_CHROME_EXPORTS, - *DECLARATIVE_CHART_EXPORTS, +COMPONENT_REEXPORTS = set(PUBLIC_API_MANIFEST.component_reexports) +MARK_FACTORY_CATEGORY = "mark_factories" +ANNOTATION_FACTORY_CATEGORY = "annotation_factories" +AXIS_FACTORY_CATEGORY = "axis_factories" +CHROME_FACTORY_CATEGORY = "chrome_factories" +CHART_FACTORY_CATEGORY = "chart_factories" +SUPPORT_FACTORY_CATEGORY = "support_factories" +FACTORY_CATEGORY_FIELDS = ( + MARK_FACTORY_CATEGORY, + ANNOTATION_FACTORY_CATEGORY, + AXIS_FACTORY_CATEGORY, + CHROME_FACTORY_CATEGORY, + CHART_FACTORY_CATEGORY, + SUPPORT_FACTORY_CATEGORY, ) +CHROME_RETURN_TYPES = {"Colorbar", "Interaction", "Legend", "Modebar", "Theme", "Tooltip"} +SUPPORT_RETURN_TYPES = {"Animation", "ExportConfig", "FacetChart", "Spring"} +FACTORY_CATEGORY_BY_RETURN_TYPE = { + "Mark": MARK_FACTORY_CATEGORY, + "Annotation": ANNOTATION_FACTORY_CATEGORY, + "Axis": AXIS_FACTORY_CATEGORY, + "Chart": CHART_FACTORY_CATEGORY, + **dict.fromkeys(CHROME_RETURN_TYPES, CHROME_FACTORY_CATEGORY), + **dict.fromkeys(SUPPORT_RETURN_TYPES, SUPPORT_FACTORY_CATEGORY), +} +SPECIAL_PUBLIC_CHART_METHODS = {"_repr_html_"} +SPECIAL_PUBLIC_SELECTION_METHODS = {"__len__"} +SPECIAL_PUBLIC_FACET_CHART_METHODS = {"_repr_html_"} +CHART_METHOD_DOC = ROOT / "docs" / "api-reference" / "figure-methods.md" +SELECTION_METHOD_DOC = ROOT / "docs" / "api-reference" / "events-and-callbacks.md" +PUBLIC_TYPES_DOC = ROOT / "docs" / "api-reference" / "public-types.md" def _string_list(value: Any, label: str, errors: list[str]) -> list[str]: @@ -235,8 +195,248 @@ def validate_component_public_api( return errors +def _return_type_name(value: Any) -> str | None: + try: + return_type = get_type_hints(value).get("return") + except Exception: + return None + return getattr(return_type, "__name__", None) + + +def _component_factory_categories(components_module: ModuleType) -> dict[str, list[str]]: + categories = {field_name: [] for field_name in FACTORY_CATEGORY_FIELDS} + for name in getattr(components_module, "__all__", ()): + if name in COMPONENT_REEXPORTS: + continue + value = getattr(components_module, name, None) + if not inspect.isfunction(value): + continue + return_name = _return_type_name(value) + category = FACTORY_CATEGORY_BY_RETURN_TYPE.get(return_name) + if category is not None: + categories[category].append(name) + return categories + + +def _public_methods( + cls: type[Any], + *, + special_public_methods: set[str], +) -> tuple[str, ...]: + methods: list[str] = [] + for name, value in cls.__dict__.items(): + if name == "__init__": + continue + if isinstance(value, (classmethod, staticmethod)): + value = value.__func__ + if not callable(value) and not isinstance(value, property): + continue + if name.startswith("_") and name not in special_public_methods: + continue + methods.append(name) + return tuple(methods) + + +def build_public_api_inventory( + pkg: ModuleType, + components_module: ModuleType | None = None, +) -> PublicApiInventory: + """Build the public API inventory from exported objects and annotations.""" + if components_module is None: + components_module = importlib.import_module(".components", pkg.__name__) + + component_names = tuple(getattr(components_module, "__all__", ())) + categories = _component_factory_categories(components_module) + component_types = tuple( + name + for name in component_names + if name not in COMPONENT_REEXPORTS + and inspect.isclass(getattr(components_module, name, None)) + ) + chart_methods = _public_methods( + components_module.Chart, + special_public_methods=SPECIAL_PUBLIC_CHART_METHODS, + ) + facet_chart_class = getattr(components_module, "FacetChart", None) + facet_chart_methods = ( + _public_methods( + facet_chart_class, + special_public_methods=SPECIAL_PUBLIC_FACET_CHART_METHODS, + ) + if inspect.isclass(facet_chart_class) + else () + ) + figure_module = importlib.import_module("._figure", pkg.__name__) + selection_methods = _public_methods( + figure_module.Selection, + special_public_methods=SPECIAL_PUBLIC_SELECTION_METHODS, + ) + return PublicApiInventory( + component_reexports=tuple(name for name in component_names if name in COMPONENT_REEXPORTS), + component_types=component_types, + **{field_name: tuple(categories[field_name]) for field_name in FACTORY_CATEGORY_FIELDS}, + chart_methods=chart_methods, + facet_chart_methods=facet_chart_methods, + selection_methods=selection_methods, + ) + + +def validate_public_api_inventory( + inventory: PublicApiInventory, + components_module: ModuleType, +) -> list[str]: + """Ensure every component export is explicitly classified in the inventory.""" + errors: list[str] = [] + component_names = set( + _string_list( + getattr(components_module, "__all__", None), + f"{components_module.__name__}.__all__", + errors, + ) + ) + classifications = ( + inventory.component_reexports, + inventory.component_types, + *(getattr(inventory, field_name) for field_name in FACTORY_CATEGORY_FIELDS), + inventory.experimental_exports, + inventory.deprecated_exports, + inventory.private_exports, + ) + seen: set[str] = set() + for names in classifications: + repeated = sorted({name for name in names if names.count(name) > 1}) + if repeated: + errors.append(f"public API inventory repeats names within a classification: {repeated}") + duplicates = sorted(seen.intersection(names)) + if duplicates: + errors.append(f"public API inventory classifies names more than once: {duplicates}") + seen.update(names) + classified = set(inventory.classified_component_exports) + missing = sorted(component_names - classified) + stale = sorted(classified - component_names) + if missing: + errors.append(f"component public exports are unclassified: {missing}") + if stale: + errors.append(f"public API inventory classifies non-exported names: {stale}") + return errors + + +def validate_public_api_manifest( + inventory: PublicApiInventory, + manifest: PublicApiInventory = PUBLIC_API_MANIFEST, +) -> list[str]: + """Ensure discovery neither adds nor silently removes supported names.""" + errors: list[str] = [] + fields = ( + "component_reexports", + "component_types", + *FACTORY_CATEGORY_FIELDS, + "chart_methods", + "facet_chart_methods", + "selection_methods", + "experimental_exports", + "deprecated_exports", + "private_exports", + ) + for field_name in fields: + expected = set(getattr(manifest, field_name)) + actual = set(getattr(inventory, field_name)) + missing = sorted(expected - actual) + added = sorted(actual - expected) + if missing: + errors.append( + f"public API manifest names missing from discovery ({field_name}): {missing}" + ) + if added: + errors.append( + f"public API discovery contains unmanifested names ({field_name}): {added}" + ) + grouped_chart_factories = { + name for _group, names in manifest.chart_factory_groups for name in names + } + missing_from_groups = sorted(set(manifest.chart_factories) - grouped_chart_factories) + documented_factory_names = set(manifest.chart_factories) | set(manifest.support_factories) + stale_groups = sorted(grouped_chart_factories - documented_factory_names) + if missing_from_groups: + errors.append(f"chart factories missing from documentation groups: {missing_from_groups}") + if stale_groups: + errors.append(f"documentation groups contain unmanifested chart factories: {stale_groups}") + return errors + + +def _has_doc_reference( + text: str, + name: str, + *, + receiver: str | None = None, + allow_bare: bool = False, +) -> bool: + if receiver is not None: + reference = re.escape(f"{receiver}.{name}") + return bool( + re.search(rf"(? list[str]: + """Ensure public methods have API reference coverage.""" + errors: list[str] = [] + try: + chart_text = chart_doc.read_text(encoding="utf-8") + except OSError as exc: + return [f"cannot read Chart API docs {chart_doc}: {exc}"] + try: + selection_text = selection_doc.read_text(encoding="utf-8") + except OSError as exc: + return [f"cannot read Selection API docs {selection_doc}: {exc}"] + try: + types_text = types_doc.read_text(encoding="utf-8") + except OSError as exc: + return [f"cannot read public types docs {types_doc}: {exc}"] + + for type_name in inventory.component_types: + if f"`{type_name}`" not in types_text: + errors.append(f"public component type {type_name!r} is missing from {types_doc}") + + for receiver, methods in ( + ("chart", inventory.chart_methods), + ("facet_chart", inventory.facet_chart_methods), + ): + for method in methods: + if not _has_doc_reference(chart_text, method, receiver=receiver): + errors.append(f"{receiver} public method {method!r} is missing from {chart_doc}") + selection_aliases = {"__len__": "len(selection)"} + for method in inventory.selection_methods: + token = selection_aliases.get(method) + if token is not None: + if token not in selection_text: + errors.append(f"Selection public method {method!r} is missing from {selection_doc}") + elif not _has_doc_reference(selection_text, method): + errors.append(f"Selection public method {method!r} is missing from {selection_doc}") + return errors + + def validate_declarative_api_contract( - pkg: ModuleType, components_module: ModuleType | None = None + pkg: ModuleType, + components_module: ModuleType | None = None, + *, + manifest: PublicApiInventory | None = None, ) -> list[str]: """Ensure the Reflex-shaped composition API remains a named public contract.""" errors: list[str] = [] @@ -259,8 +459,20 @@ def validate_declarative_api_contract( errors, ) ) + if errors: + return errors - for name in DECLARATIVE_API_EXPORTS: + chart_class = getattr(components_module, "Chart", None) + if chart_class is None: + errors.append(f"{components_module.__name__}.Chart is missing") + return errors + + inventory = build_public_api_inventory(pkg, components_module) + errors.extend(validate_public_api_inventory(inventory, components_module)) + if manifest is not None: + errors.extend(validate_public_api_manifest(inventory, manifest)) + + for name in inventory.declarative_exports: if name not in public_names: errors.append(f"declarative API export {name!r} is missing from xy.__all__") if exports.get(name) != ".components": @@ -278,14 +490,15 @@ def validate_declarative_api_contract( f"declarative API export {name!r} is undefined in {components_module.__name__}" ) - chart_class = getattr(components_module, "Chart", None) - if chart_class is None: - errors.append(f"{components_module.__name__}.Chart is missing") - return errors - for method in DECLARATIVE_CHART_READOUTS: - value = getattr(chart_class, method, None) + for method in inventory.chart_methods: + value = inspect.getattr_static(chart_class, method, None) + if isinstance(value, property): + continue + if isinstance(value, (classmethod, staticmethod)): + value = value.__func__ if not callable(value): errors.append(f"declarative Chart readout {method!r} must be callable") + errors.extend(validate_docs_inventory(inventory)) return errors @@ -535,7 +748,7 @@ def check_public_api(*, check_lazy_import: bool = True) -> list[str]: errors.extend(validate_static_typing_surface(pkg)) errors.extend(validate_public_api(pkg)) errors.extend(validate_component_public_api(pkg)) - errors.extend(validate_declarative_api_contract(pkg)) + errors.extend(validate_declarative_api_contract(pkg, manifest=PUBLIC_API_MANIFEST)) if check_lazy_import: eager = sorted(after_import - before) errors[:0] = _format_eager_import_findings("in-process", eager) diff --git a/scripts/verify_docs_local.py b/scripts/verify_docs_local.py new file mode 100644 index 00000000..49de1fec --- /dev/null +++ b/scripts/verify_docs_local.py @@ -0,0 +1,112 @@ +#!/usr/bin/env python3 +"""Run the local documentation quality gate used by ``make check-docs``.""" + +from __future__ import annotations + +import re +import shutil +import subprocess +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +NODE_VERSION_PATTERN = re.compile(r"v?(\d+)\.(\d+)\.(\d+)\s*") +VITE_MINIMUM_NODE_VERSION = (20, 19, 0) +VITE_MINIMUM_CURRENT_NODE_VERSION = (22, 12, 0) + + +def run(command: tuple[str, ...]) -> int: + print("+ " + " ".join(command)) + return subprocess.run(command, cwd=ROOT, check=False).returncode + + +def check_node_version() -> bool: + """The editable package build needs the same Node major as docs CI.""" + node = shutil.which("node") + if node is None: + print("Node.js 22 is required for the docs gate; install Node.js 22 and retry.") + return False + result = subprocess.run((node, "--version"), check=False, capture_output=True, text=True) + match = NODE_VERSION_PATTERN.fullmatch(result.stdout) + version = tuple(map(int, match.groups())) if match is not None else None + supported = version is not None and ( + (version[0] == 20 and version >= VITE_MINIMUM_NODE_VERSION) + or (version[0] >= 22 and version >= VITE_MINIMUM_CURRENT_NODE_VERSION) + ) + if result.returncode != 0 or not supported: + version = result.stdout.strip() or result.stderr.strip() or "unknown" + print( + "The docs build needs Node.js ^20.19.0 or >=22.12.0, as required by the locked Vite version; " + f"found {version}." + ) + return False + return True + + +def main() -> int: + if not check_node_version(): + return 1 + commands = ( + ("uv", "sync", "--project", "docs/app", "--frozen", "--group", "dev"), + ("uv", "run", "--project", "docs/app", "--no-sync", "pytest", "docs/app/tests", "-v"), + ( + "uv", + "run", + "--project", + "docs/app", + "--no-sync", + "python", + "scripts/check_public_api.py", + "--skip-lazy-import-check", + ), + ( + "uv", + "run", + "--project", + "docs/app", + "--no-sync", + "python", + "scripts/verify_docs_quickstart.py", + ), + ( + "uv", + "run", + "--project", + "docs/app", + "--no-sync", + "pre-commit", + "run", + "ruff-format", + "--all-files", + ), + ( + "uv", + "run", + "--project", + "docs/app", + "--no-sync", + "pre-commit", + "run", + "ruff-check", + "--all-files", + ), + ( + "uv", + "run", + "--project", + "docs/app", + "--no-sync", + "pre-commit", + "run", + "docs-app-codespell", + "--all-files", + ), + ) + for command in commands: + rc = run(command) + if rc != 0: + return rc + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/verify_local.py b/scripts/verify_local.py index 75b71b41..eea3f0aa 100644 --- a/scripts/verify_local.py +++ b/scripts/verify_local.py @@ -130,6 +130,12 @@ def _base_checks( ), requires_modules=("pytest",), ), + Check( + "docs", + "docs app tests, quickstart, and quality hooks", + (py, "scripts/verify_docs_local.py"), + requires_executables=("uv",), + ), Check( "security_export", "standalone HTML escaping, atomic writes, and client text-sink guardrails", diff --git a/spec/process/contributing.md b/spec/process/contributing.md index 7fd1bb57..3c9ccc52 100644 --- a/spec/process/contributing.md +++ b/spec/process/contributing.md @@ -59,7 +59,7 @@ toolchain with `cargo`, `rustc`, and clippy for native-core checks. If clippy is missing, run `rustup component add clippy`. If you are only touching docs or pure Python shell code, `make check` is the fast non-JS/non-Rust gate. -When canonical API prose or examples change, run the focused docs gate: +When canonical API prose changes, run the docs gate: ```bash make check-docs @@ -127,8 +127,7 @@ regression comparison scripts, or report-schema validation, run: make check-benchmark-harness ``` -When you edit `spec/api/api-examples.md` or the Reflex example dashboard -registry/assets, run: +When you edit the Reflex example dashboard registry/assets, run: ```bash make check-examples diff --git a/spec/process/production-readiness.md b/spec/process/production-readiness.md index f7d916a0..a2826a65 100644 --- a/spec/process/production-readiness.md +++ b/spec/process/production-readiness.md @@ -308,7 +308,7 @@ These must pass before publishing. | Area | Gate | Command or evidence | |---|---|---| | Python floor | `pyproject.toml`, Ruff, docs, syntax, and annotations stay on the Python 3.11+ floor | `python scripts/check_python_floor.py` | -| Public API | `__all__`, lazy exports, `__version__`, the source `py.typed` marker, focused type-surface tests, and fresh-process import-time budget stay coherent | `make check-api` | +| Public API | Component exports, public types, lifecycle classifications, and `Chart`, `FacetChart`, and `Selection` methods match `PUBLIC_API_MANIFEST` and their docs; `__all__`/lazy exports, `__version__`, `py.typed`, type-surface tests, and import-time budget stay covered | `make check-api` and `make check-docs` | | Import-time budget | `xy.__init__`, `dir(xy)`, export helpers, chart construction, and `.widget()` keep their lazy import boundaries | `make check-import` | | CI/CodSpeed workflows | Hard gates, non-blocking benchmarks, best-effort benchmark artifact upload/download, and no-Rust clear-error jobs stay wired. The release workflows are `reflex-release`'s and are not re-checked here | `make check-ci` | | GitHub Actions token scope | CI, release, and manual benchmark workflows declare an explicit least-privilege `GITHUB_TOKEN` default; privileged jobs use narrow job-level overrides | GitHub code scanning (`actions/missing-workflow-permissions`) | @@ -379,8 +379,8 @@ production-facing push: | Changed surface | Focused gate | |---|---| -| API prose, examples, public benchmark wording | `make check-docs` | -| `spec/api/api-examples.md`, Reflex chart registry/assets | `make check-examples` | +| API prose, public benchmark wording | `make check-docs` | +| Example apps and Reflex chart registry/assets | `make check-examples` | | Public validation, error messages, builder rollback, LOD/drill mutation boundaries, chart/widget caching | `make check-errors` | | Public exports, lazy import mappings, component factories, public annotations | `make check-api` | | Import-time budget, `xy.__init__`, dependency boundaries, widget/export/backend import boundaries | `make check-import` | @@ -402,7 +402,7 @@ Use this before pushing production-facing changes: make check-full ``` -Use this after editing API docs, example snippets, or public benchmark wording: +Use this after editing API docs or public benchmark wording: ```bash make check-docs diff --git a/tests/_public_api_test_utils.py b/tests/_public_api_test_utils.py new file mode 100644 index 00000000..c12f911f --- /dev/null +++ b/tests/_public_api_test_utils.py @@ -0,0 +1,25 @@ +"""Shared helpers for tests that load the standalone public API checker.""" + +from __future__ import annotations + +import importlib.util +import sys +from pathlib import Path + + +def load_public_api_module(module_name: str): + """Load the checker without leaving a temporary module in ``sys.modules``.""" + path = Path(__file__).resolve().parents[1] / "scripts" / "check_public_api.py" + spec = importlib.util.spec_from_file_location(module_name, path) + assert spec is not None and spec.loader is not None + module = importlib.util.module_from_spec(spec) + previous = sys.modules.get(module_name) + sys.modules[module_name] = module + try: + spec.loader.exec_module(module) + finally: + if previous is None: + sys.modules.pop(module_name, None) + else: + sys.modules[module_name] = previous + return module diff --git a/tests/test_public_api.py b/tests/test_public_api.py index aef53823..0c5b8c32 100644 --- a/tests/test_public_api.py +++ b/tests/test_public_api.py @@ -1,27 +1,17 @@ from __future__ import annotations import importlib.metadata -import importlib.util import json import subprocess -import sys +from dataclasses import replace from pathlib import Path from types import ModuleType import pytest +from _public_api_test_utils import load_public_api_module -def _load_public_api_module(): - path = Path(__file__).resolve().parents[1] / "scripts" / "check_public_api.py" - spec = importlib.util.spec_from_file_location("check_public_api", path) - assert spec is not None and spec.loader is not None - module = importlib.util.module_from_spec(spec) - sys.modules[spec.name] = module - spec.loader.exec_module(module) - return module - - -check_public_api = _load_public_api_module() +check_public_api = load_public_api_module("_xy_test_public_api_checker") def _fresh_import_stdout( @@ -47,6 +37,20 @@ def test_public_api_checker_accepts_current_surface() -> None: assert check_public_api.check_public_api() == [] +def test_public_api_manifest_catches_removed_component_factory() -> None: + fake, fake_components = _fake_declarative_modules() + fake_components.__all__.remove("tooltip") + del fake_components.tooltip + + errors = check_public_api.validate_declarative_api_contract( + fake, + fake_components, + manifest=check_public_api.PUBLIC_API_MANIFEST, + ) + + assert any("chrome_factories" in error and "tooltip" in error for error in errors) + + def test_fresh_import_budget_rejects_eager_modules(monkeypatch: pytest.MonkeyPatch) -> None: def fake_run(*args, **kwargs): del args, kwargs @@ -271,21 +275,52 @@ def test_public_api_checker_accepts_component_module_all() -> None: def _fake_declarative_modules() -> tuple[ModuleType, ModuleType]: - fake = ModuleType("xy") - fake.__all__ = ["__version__", *check_public_api.DECLARATIVE_API_EXPORTS] - fake._EXPORTS = {name: ".components" for name in check_public_api.DECLARATIVE_API_EXPORTS} + class Chart: + def figure(self): + return None - fake_components = ModuleType("xy.components") - fake_components.__all__ = list(check_public_api.DECLARATIVE_API_EXPORTS) - for name in check_public_api.DECLARATIVE_API_EXPORTS: - setattr(fake_components, name, object()) + def html(self): + return None - class Chart: + class Mark: + pass + + class Annotation: + pass + + class Axis: pass - for method in check_public_api.DECLARATIVE_CHART_READOUTS: - setattr(Chart, method, lambda self: None) - fake_components.Chart = Chart + class Legend: + pass + + def factory(return_type): + def inner(): + return return_type() + + inner.__annotations__ = {"return": return_type} + return inner + + names = { + "Chart": Chart, + "Mark": Mark, + "Annotation": Annotation, + "Axis": Axis, + "Legend": Legend, + "chart": factory(Chart), + "scatter": factory(Mark), + "label": factory(Annotation), + "x_axis": factory(Axis), + "tooltip": factory(Legend), + } + fake = ModuleType("xy") + fake.__all__ = ["__version__", *names] + fake._EXPORTS = {name: ".components" for name in names} + + fake_components = ModuleType("xy.components") + fake_components.__all__ = list(names) + for name, value in names.items(): + setattr(fake_components, name, value) return fake, fake_components @@ -301,17 +336,11 @@ def test_public_api_checker_rejects_missing_declarative_export() -> None: fake, fake_components = _fake_declarative_modules() fake.__all__.remove("tooltip") fake._EXPORTS.pop("tooltip") - fake_components.__all__.remove("tooltip") - del fake_components.tooltip - delattr(fake_components.Chart, "html") errors = check_public_api.validate_declarative_api_contract(fake, fake_components) assert any("tooltip" in error and "xy.__all__" in error for error in errors) assert any("tooltip" in error and "'.components'" in error for error in errors) - assert any("tooltip" in error and "xy.components.__all__" in error for error in errors) - assert any("tooltip" in error and "undefined" in error for error in errors) - assert any("html" in error and "readout" in error for error in errors) def test_public_api_checker_rejects_misrouted_declarative_export() -> None: @@ -325,6 +354,134 @@ def test_public_api_checker_rejects_misrouted_declarative_export() -> None: ) +def test_declarative_api_checker_reports_missing_chart_before_inventory() -> None: + fake, fake_components = _fake_declarative_modules() + del fake_components.Chart + + errors = check_public_api.validate_declarative_api_contract(fake, fake_components) + + assert errors == ["xy.components.Chart is missing"] + + +def test_declarative_api_checker_reports_non_string_component_export() -> None: + fake, fake_components = _fake_declarative_modules() + fake_components.__all__.append(42) + + errors = check_public_api.validate_declarative_api_contract(fake, fake_components) + + assert any("xy.components.__all__" in error and "42" in error for error in errors) + + +def test_public_api_docs_require_receiver_qualified_chart_methods() -> None: + assert not check_public_api._has_doc_reference( + "The prose mentions figure() for another object.", + "figure", + receiver="chart", + ) + assert check_public_api._has_doc_reference( + "Call `chart.figure()` to build the figure.", + "figure", + receiver="chart", + ) + assert check_public_api._has_doc_reference( + "See `chart.figure` for the cached figure.", "figure", receiver="chart" + ) + assert not check_public_api._has_doc_reference( + "Call `figure()` to build the figure.", "figure", receiver="chart" + ) + assert not check_public_api._has_doc_reference( + "Call `facet_chart.show()` to display the facets.", "show", receiver="chart" + ) + assert check_public_api._has_doc_reference( + "Call `chart.show()` to display the chart.", "show", receiver="chart" + ) + assert check_public_api._has_doc_reference("Use `xy(trace_id=0)` for one trace.", "xy") + assert check_public_api._has_doc_reference("Use `rows(limit=None)` for row data.", "rows") + assert not check_public_api._has_doc_reference("unrelated figure() text", "figure") + assert check_public_api._has_doc_reference( + "unrelated figure() text", + "figure", + allow_bare=True, + ) + + +def test_public_method_inventory_includes_classmethod_descriptors() -> None: + class Example: + @classmethod + def documented(cls) -> None: + del cls + + assert check_public_api._public_methods(Example, special_public_methods=set()) == ( + "documented", + ) + + +def test_public_api_manifest_validates_lifecycle_classifications() -> None: + inventory = replace( + check_public_api.PUBLIC_API_MANIFEST, + experimental_exports=("scatter",), + ) + + errors = check_public_api.validate_public_api_manifest(inventory) + + assert any("experimental_exports" in error for error in errors) + + +def test_public_api_inventory_rejects_overlapping_export_classifications() -> None: + fake, components = _fake_declarative_modules() + inventory = check_public_api.build_public_api_inventory(fake, components) + inventory = replace(inventory, component_types=(*inventory.component_types, "scatter")) + + errors = check_public_api.validate_public_api_inventory(inventory, components) + + assert any("classifies names more than once" in error for error in errors) + + +def test_public_api_checker_rejects_missing_method_docs(tmp_path: Path) -> None: + chart_doc = tmp_path / "figure-methods.md" + selection_doc = tmp_path / "events-and-callbacks.md" + chart_doc.write_text("documented chart.visible_method()\n", encoding="utf-8") + selection_doc.write_text("documented `rows(limit=None)` and len(selection)\n", encoding="utf-8") + inventory = check_public_api.PublicApiInventory( + component_reexports=(), + component_types=(), + mark_factories=(), + annotation_factories=(), + axis_factories=(), + chrome_factories=(), + chart_factories=(), + support_factories=(), + chart_methods=("visible_method", "missing_method"), + selection_methods=("rows", "__len__", "missing_selection_method"), + ) + + errors = check_public_api.validate_docs_inventory( + inventory, + chart_doc=chart_doc, + selection_doc=selection_doc, + ) + + assert any("missing_method" in error for error in errors) + assert any("missing_selection_method" in error for error in errors) + assert not any("visible_method" in error for error in errors) + assert not any("'rows'" in error for error in errors) + + +def test_public_api_manifest_rejects_removed_supported_method() -> None: + inventory = replace( + check_public_api.PUBLIC_API_MANIFEST, + chart_methods=tuple( + method + for method in check_public_api.PUBLIC_API_MANIFEST.chart_methods + if method != "select" + ), + ) + + errors = check_public_api.validate_public_api_manifest(inventory) + + assert any("chart_methods" in error and "select" in error for error in errors) + + def test_public_api_checker_rejects_stale_component_module_all() -> None: fake = ModuleType("xy") fake._EXPORTS = {"Chart": ".components", "scatter": ".components", "line": ".components"} diff --git a/tests/test_type_surface.py b/tests/test_type_surface.py index f5e12505..05817116 100644 --- a/tests/test_type_surface.py +++ b/tests/test_type_surface.py @@ -11,136 +11,25 @@ import xy import xy._figure as figure_module import xy.components as components +from _public_api_test_utils import load_public_api_module from xy.export import Engine ROOT = Path(__file__).resolve().parents[1] -MARK_FACTORIES = ( - "scatter", - "ribbon", - "sankey", - "funnel", - "line", - "area", - "histogram", - "hist", - "bar", - "column", - "heatmap", - "error_band", - "errorbar", - "box", - "violin", - "ecdf", - "hexbin", - "contour", - "step", - "stairs", - "stem", - "segments", - "triangle_mesh", -) -ANNOTATION_FACTORIES = ( - "arrow", - "callout", - "label", - "marker", - "threshold", - "threshold_zone", - "vline", - "hline", - "x_band", - "y_band", - "text", -) -AXIS_FACTORIES = ( - "x_axis", - "y_axis", - "theta_axis", - "r_axis", -) -CHART_FACTORIES = ( - "chart", - "scatter_chart", - "polar_chart", - "radar_chart", - "polar_bar_chart", - "pie_chart", - "wind_rose", - "line_chart", - "area_chart", - "histogram_chart", - "bar_chart", - "column_chart", - "heatmap_chart", - "error_band_chart", - "errorbar_chart", - "box_chart", - "violin_chart", - "ecdf_chart", - "hexbin_chart", - "contour_chart", - "step_chart", - "stairs_chart", - "stem_chart", - "segments_chart", - "triangle_mesh_chart", - "funnel_chart", -) -CHROME_FACTORIES = ( - "legend", - "tooltip", - "colorbar", - "modebar", - "theme", - "interaction_config", -) -CHART_READOUTS = ( - "figure", - "widget", - "show", - "to_html", - "html", - "_repr_html_", - "to_svg", - "to_png", - "memory_report", - "chrome_components", - "reflex_components", - "append", - "pick", - "select_range", -) -FIGURE_BUILDERS = ( - "line", - "scatter", - "area", - "histogram", - "hist", - "bar", - "column", - "heatmap", - "error_band", - "errorbar", - "box", - "violin", - "ecdf", - "hexbin", - "contour", - "step", - "stairs", - "stem", - "arrow", - "callout", - "label", - "marker", - "threshold", - "threshold_zone", - "vline", - "hline", - "x_band", - "y_band", - "text", +PUBLIC_API_INVENTORY = load_public_api_module( + "_xy_test_check_public_api" +).build_public_api_inventory(xy, components) +MARK_FACTORIES = PUBLIC_API_INVENTORY.mark_factories +ANNOTATION_FACTORIES = PUBLIC_API_INVENTORY.annotation_factories +AXIS_FACTORIES = PUBLIC_API_INVENTORY.axis_factories +CHART_FACTORIES = PUBLIC_API_INVENTORY.chart_factories +CHROME_FACTORIES = PUBLIC_API_INVENTORY.chrome_factories +SUPPORT_FACTORIES = PUBLIC_API_INVENTORY.support_factories +CHART_READOUTS = PUBLIC_API_INVENTORY.chart_methods +REGISTERED_MARK_FACTORIES = tuple(name for name in MARK_FACTORIES if name != "mark") +FIGURE_BUILDERS = (*REGISTERED_MARK_FACTORIES, *ANNOTATION_FACTORIES) +MISSING_FIGURE_BUILDERS = tuple( + name for name in FIGURE_BUILDERS if not hasattr(figure_module.Figure, name) ) FIGURE_READOUTS = ( "build_payload", @@ -279,6 +168,7 @@ def test_public_factories_are_typed_root_exports() -> None: *AXIS_FACTORIES, *CHROME_FACTORIES, *CHART_FACTORIES, + *SUPPORT_FACTORIES, ): root_fn = getattr(xy, name) component_fn = getattr(components, name) @@ -295,6 +185,7 @@ def test_composition_alpha_contract_is_explicitly_exported() -> None: *CHART_FACTORIES, *CHROME_FACTORIES, *AXIS_FACTORIES, + *SUPPORT_FACTORIES, } for name in sorted(contract): @@ -320,7 +211,10 @@ def test_public_component_factories_have_typed_signatures() -> None: "theme": components.Theme, "interaction_config": components.Interaction, **{name: components.Chart for name in CHART_FACTORIES}, + "animation": components.Animation, + "export_config": components.ExportConfig, "facet_chart": components.FacetChart, + "spring": components.Spring, } for name, expected_return in expected_returns.items(): fn = getattr(components, name) @@ -332,7 +226,7 @@ def test_public_component_factories_have_typed_signatures() -> None: def test_mark_factory_kinds_are_registered_with_typed_appliers() -> None: - factory_kinds = {getattr(components, name)().kind for name in MARK_FACTORIES} + factory_kinds = {getattr(components, name)().kind for name in REGISTERED_MARK_FACTORIES} applier_kinds = set(components._MARK_APPLIERS) assert factory_kinds == applier_kinds @@ -391,7 +285,7 @@ def test_chart_factories_construct_named_lazy_charts() -> None: chart = getattr(components, name)() assert isinstance(chart, components.Chart), name assert chart.kind == name - if name not in {"radar_chart", "wind_rose", "pie_chart", "funnel_chart"}: + if name not in {"radar_chart", "wind_rose", "pie_chart", "funnel_chart", "sankey_chart"}: assert chart.children == () assert chart._figure is None assert chart._widget is None @@ -461,6 +355,7 @@ def test_chart_and_figure_png_export_types_stay_in_sync() -> None: def test_public_figure_methods_have_typed_signatures() -> None: + assert not MISSING_FIGURE_BUILDERS for name in (*FIGURE_BUILDERS, *FIGURE_READOUTS): fn = getattr(figure_module.Figure, name) hints = get_type_hints(fn) @@ -496,3 +391,9 @@ def test_selection_callback_payload_types_are_specific() -> None: xy_return = get_type_hints(figure_module.Selection.xy)["return"] assert get_origin(xy_return) is tuple assert get_args(xy_return) == (np.ndarray, np.ndarray) + + rows_hints = get_type_hints(figure_module.Selection.rows) + assert rows_hints["limit"] == int | None + rows_return = rows_hints["return"] + assert get_origin(rows_return) is list + assert get_args(rows_return) == (dict[str, Any],) diff --git a/tests/test_verify_local.py b/tests/test_verify_local.py index d874ee69..7fa10f69 100644 --- a/tests/test_verify_local.py +++ b/tests/test_verify_local.py @@ -34,6 +34,54 @@ def _load_verify_local_module(): return module +def _load_verify_docs_local_module(): + path = Path(__file__).resolve().parents[1] / "scripts" / "verify_docs_local.py" + spec = importlib.util.spec_from_file_location("verify_docs_local", path) + assert spec is not None and spec.loader is not None + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +@pytest.mark.parametrize( + ("node_version", "supported"), + [ + ("v20.19.0\n", True), + ("v20.18.1\n", False), + ("v21.7.3\n", False), + ("v22.11.0\n", False), + ("v22.12.0\n", True), + ("v24.14.0\n", True), + ], +) +def test_docs_gate_checks_the_locked_vite_node_range( + monkeypatch: pytest.MonkeyPatch, + node_version: str, + supported: bool, +) -> None: + module = _load_verify_docs_local_module() + monkeypatch.setattr(module.shutil, "which", lambda _name: "node.exe") + monkeypatch.setattr( + module.subprocess, + "run", + lambda *_args, **_kwargs: subprocess.CompletedProcess([], 0, node_version, ""), + ) + + assert module.check_node_version() is supported + + +def test_docs_ci_runs_the_local_quality_gate() -> None: + root = Path(__file__).resolve().parents[1] + workflow = (root / ".github/workflows/docs.yml").read_text(encoding="utf-8") + + assert ( + "shell: bash\n run: uv run --no-project python scripts/verify_docs_local.py" + in workflow + ) + assert workflow.count('"scripts/verify_docs_local.py"') == 2 + assert workflow.count('"scripts/check_public_api.py"') == 2 + + verify_local = _load_verify_local_module() ROOT = Path(__file__).resolve().parents[1] @@ -199,7 +247,7 @@ def test_ty_executable_falls_back_to_path(tmp_path: Path, monkeypatch) -> None: python.touch() monkeypatch.setattr(verify_local.shutil, "which", lambda _executable: "/path/ty") - assert verify_local._ty_tools.resolve_ty_executable(str(python)) == Path("/path/ty") + assert verify_local._ty_tools.resolve_ty_executable(str(python)) == Path("/path/ty").absolute() def test_ty_executable_skips_non_executable_sibling(tmp_path: Path, monkeypatch) -> None: @@ -210,7 +258,7 @@ def test_ty_executable_skips_non_executable_sibling(tmp_path: Path, monkeypatch) monkeypatch.setattr(verify_local.os, "access", lambda _path, _mode: False) monkeypatch.setattr(verify_local.shutil, "which", lambda _executable: "/path/ty") - assert verify_local._ty_tools.resolve_ty_executable(str(python)) == Path("/path/ty") + assert verify_local._ty_tools.resolve_ty_executable(str(python)) == Path("/path/ty").absolute() @pytest.mark.parametrize(("os_name", "expected"), [("posix", "ty"), ("nt", "ty.exe")]) @@ -418,11 +466,14 @@ def test_missing_common_python_modules_name_specific_setup_commands( assert any("numpy anywidget" in reason for reason in reasons) -def test_chromium_path_must_be_existing_executable_file(tmp_path: Path) -> None: +def test_chromium_path_must_be_existing_executable_file( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: directory = tmp_path / "Chrome.app" directory.mkdir() not_executable = tmp_path / "chrome" not_executable.write_text("#!/bin/sh\n", encoding="utf-8") + monkeypatch.setattr(verify_local.os, "access", lambda _path, _mode: False) for path, match in ((directory, "not a file"), (not_executable, "not executable")): check = verify_local.Check( @@ -654,10 +705,9 @@ def test_makefile_exposes_example_verification_shortcut() -> None: def test_makefile_exposes_docs_verification_shortcut() -> None: makefile = (ROOT / "Makefile").read_text(encoding="utf-8") - assert "check-docs:" in makefile - assert "scripts/verify_local.py --only examples" in makefile + assert "check-docs:\n\t$(PYTHON) scripts/verify_local.py --only docs" in makefile assert "make check-docs" in makefile - assert "run documentation examples" in makefile + assert "docs tests, quickstart, and quality hooks" in makefile def test_makefile_exposes_security_verification_shortcut() -> None: