diff --git a/Makefile b/Makefile index 27e70c38..139949fd 100644 --- a/Makefile +++ b/Makefile @@ -19,7 +19,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' \ @@ -74,7 +74,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/events-and-callbacks.md b/docs/api-reference/events-and-callbacks.md index 07678dbd..acf72ac0 100644 --- a/docs/api-reference/events-and-callbacks.md +++ b/docs/api-reference/events-and-callbacks.md @@ -49,6 +49,8 @@ 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. - `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..7b109d4c 100644 --- a/docs/api-reference/figure-methods.md +++ b/docs/api-reference/figure-methods.md @@ -19,8 +19,10 @@ code should build charts through components and call the public methods below. | `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 `_repr_html_()` as the standalone-HTML path. Python callbacks require the +live widget or a framework adapter. ## HTML and Static Export @@ -106,6 +108,27 @@ 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. +- `view_state()` returns the last client-confirmed viewport and selection state; + reads after writes are eventually consistent. + ## Framework Chrome ~~~python diff --git a/scripts/check_public_api.py b/scripts/check_public_api.py index 8f813d85..92dffcc6 100644 --- a/scripts/check_public_api.py +++ b/scripts/check_public_api.py @@ -16,14 +16,17 @@ import argparse import ast import importlib +import inspect import json import os +import re import subprocess import sys import textwrap +from dataclasses import dataclass from pathlib import Path from types import ModuleType -from typing import Any, Optional +from typing import Any, Optional, get_type_hints ROOT = Path(__file__).resolve().parents[1] IMPORT_BUDGET_MS = 200.0 @@ -47,79 +50,176 @@ } 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, +CHROME_RETURN_TYPES = {"Colorbar", "Interaction", "Legend", "Modebar", "Theme", "Tooltip"} +SUPPORT_RETURN_TYPES = {"Animation", "ExportConfig", "FacetChart", "Spring"} +SPECIAL_PUBLIC_CHART_METHODS = {"_repr_html_"} +SPECIAL_PUBLIC_SELECTION_METHODS = {"__len__"} +EXPERIMENTAL_PUBLIC_EXPORTS: tuple[str, ...] = () +DEPRECATED_PUBLIC_EXPORTS: tuple[str, ...] = () +PRIVATE_PUBLIC_EXPORTS: tuple[str, ...] = () +CHART_METHOD_DOC = ROOT / "docs" / "api-reference" / "figure-methods.md" +SELECTION_METHOD_DOC = ROOT / "docs" / "api-reference" / "events-and-callbacks.md" + + +@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, ...] + experimental_exports: tuple[str, ...] = EXPERIMENTAL_PUBLIC_EXPORTS + deprecated_exports: tuple[str, ...] = DEPRECATED_PUBLIC_EXPORTS + private_exports: tuple[str, ...] = PRIVATE_PUBLIC_EXPORTS + + @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",), + 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", + "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", + "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", + ), + 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"), ) @@ -235,8 +335,205 @@ 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 = { + "mark_factories": [], + "annotation_factories": [], + "axis_factories": [], + "chrome_factories": [], + "chart_factories": [], + "support_factories": [], + } + 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) + if return_name == "Mark": + categories["mark_factories"].append(name) + elif return_name == "Annotation": + categories["annotation_factories"].append(name) + elif return_name == "Axis": + categories["axis_factories"].append(name) + elif return_name == "Chart": + categories["chart_factories"].append(name) + elif return_name in CHROME_RETURN_TYPES: + categories["chrome_factories"].append(name) + elif return_name in SUPPORT_RETURN_TYPES: + categories["support_factories"].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 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, + ) + 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, + mark_factories=tuple(categories["mark_factories"]), + annotation_factories=tuple(categories["annotation_factories"]), + axis_factories=tuple(categories["axis_factories"]), + chrome_factories=tuple(categories["chrome_factories"]), + chart_factories=tuple(categories["chart_factories"]), + support_factories=tuple(categories["support_factories"]), + chart_methods=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, + ) + ) + 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", + "mark_factories", + "annotation_factories", + "axis_factories", + "chrome_factories", + "chart_factories", + "support_factories", + "chart_methods", + "selection_methods", + ) + 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}") + return errors + + +def _has_doc_reference(text: str, name: str, *, receiver: str | None = None) -> bool: + tokens = [f"`{name}`", f"`{name}()`", f"`{name}(`"] + if receiver is not None: + tokens.extend( + ( + f"`{receiver}.{name}()`", + f"`{receiver}.{name}(`", + f"{receiver}.{name}(", + ) + ) + return any(token in text for token in tokens) or 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}"] + + for method in inventory.chart_methods: + if not _has_doc_reference(chart_text, method, receiver="chart"): + errors.append(f"Chart 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 +556,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 +587,11 @@ 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: + for method in inventory.chart_methods: value = getattr(chart_class, method, None) 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 +841,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..2e9d4c81 --- /dev/null +++ b/scripts/verify_docs_local.py @@ -0,0 +1,82 @@ +#!/usr/bin/env python3 +"""Run the local documentation quality gate used by ``make check-docs``.""" + +from __future__ import annotations + +import subprocess +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] + + +def run(command: tuple[str, ...]) -> int: + print("+ " + " ".join(command)) + return subprocess.run(command, cwd=ROOT, check=False).returncode + + +def main() -> int: + 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 97f67453..6930568e 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/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..7103eaae 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( @@ -271,21 +261,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 +322,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 +340,69 @@ 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_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 b65beb5e..10b93ebd 100644 --- a/tests/test_type_surface.py +++ b/tests/test_type_surface.py @@ -11,134 +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", - "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", -) -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 = tuple( + name for name in (*MARK_FACTORIES, *ANNOTATION_FACTORIES) if hasattr(figure_module.Figure, name) ) FIGURE_READOUTS = ( "build_payload", @@ -277,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) @@ -293,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): @@ -318,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) @@ -330,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 @@ -389,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"}: + if name not in {"radar_chart", "wind_rose", "pie_chart", "sankey_chart"}: assert chart.children == () assert chart._figure is None assert chart._widget is None @@ -494,3 +390,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],)