Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/components/facets-and-layers.md
Original file line number Diff line number Diff line change
Expand Up @@ -117,7 +117,7 @@ layered_legend_chart = xy.chart(
xy.line(layer_months, layer_actual, name="Actual", color="#1b212a", width=2.5),
xy.scatter(layer_months, layer_actual, name="Monthly close", color="#1b212a", size=6),
xy.hline(7.5, text="Goal", color="#dc2626"),
xy.legend(loc="top left"),
xy.legend(loc="upper left"),
xy.x_axis(label="month"),
xy.y_axis(label="revenue ($M)"),
title="Actuals over a forecast band",
Expand Down
28 changes: 22 additions & 6 deletions python/xy/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,9 @@

from __future__ import annotations

from importlib import import_module
from typing import TYPE_CHECKING, Any
from importlib import import_module as _import_module
from typing import TYPE_CHECKING
from typing import Any as _Any

__version__ = "0.0.1"

Expand Down Expand Up @@ -205,21 +206,26 @@
]


def _load_export(name: str) -> Any:
def _load_export(name: str) -> _Any:
module_name = _EXPORTS.get(name)
if module_name is None:
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
value = getattr(import_module(module_name, __name__), name)
value = getattr(_import_module(module_name, __name__), name)
globals()[name] = value
return value


def __getattr__(name: str) -> Any:
def __getattr__(name: str) -> _Any:
return _load_export(name)


def __dir__() -> list[str]:
return sorted(set(globals()) | set(__all__))
# `__all__` alone, not the module globals: the import machinery leaves
# helpers behind (`annotations` from the __future__ import, `Any`,
# `TYPE_CHECKING`, `import_module`), and `xy.annotations` in particular
# collides with the annotations components. `scripts/check_public_api.py`
# asserts nothing public leaks past this.
return sorted(__all__)


if TYPE_CHECKING:
Expand Down Expand Up @@ -298,3 +304,13 @@ def __dir__() -> list[str]:
)
from .dom import CHART_DOM_SLOTS
from .export import Engine, write_images


# Import machinery, not API. `annotations` in particular is the compile-time
# `from __future__` directive, whose runtime binding would otherwise resolve as
# `xy.annotations` and shadow the annotation components (`xy.hline`,
# `xy.callout`, …) documented under that word. `Any`/`TYPE_CHECKING` are only
# needed while this module executes: annotations are strings under the future
# import, and the `if TYPE_CHECKING:` block above has already run.
# `scripts/check_public_api.py` asserts no public name outlives this.
del annotations, TYPE_CHECKING
16 changes: 15 additions & 1 deletion python/xy/channels.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,15 @@


def is_colormap(name: str) -> bool:
"""Return whether *name* is a supported colormap, including ``_r`` variants."""
"""Return whether *name* is a supported colormap, including ``_r`` variants.

Non-strings answer False rather than raising: a caller passing a stop list
(``colormap=["#fff", "#f00"]``, the natural guess for a custom ramp) must
reach the ValueError that names the supported colormaps, not an
AttributeError from inside this helper.
"""
if not isinstance(name, str):
return False
return name in COLORMAPS or (name.endswith("_r") and name[:-2] in COLORMAPS)


Expand Down Expand Up @@ -401,6 +409,12 @@ def resolve_color(
vmin/vmax); values outside clip to the colormap ends.
"""
if not is_colormap(colormap):
if not isinstance(colormap, str):
raise ValueError(
f"colormap must be one of the built-in names, got {type(colormap).__name__}. "
"A custom stop list is not supported; pass an (n, 3) or (n, 4) float array "
f"as color= for arbitrary per-point color. Known: {COLORMAPS}"
)
raise ValueError(f"unknown colormap {colormap!r}; known: {COLORMAPS}")
if domain is not None:
lo, hi = float(domain[0]), float(domain[1])
Expand Down
21 changes: 21 additions & 0 deletions scripts/check_public_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,27 @@ def validate_public_api(pkg: ModuleType) -> list[str]:
dir_missing = sorted(public_set - dir_names)
if dir_missing:
errors.append(f"dir(xy) is missing public names: {dir_missing}")
dir_extra = sorted(dir_names - public_set)
if dir_extra:
errors.append(f"dir(xy) exposes names outside __all__: {dir_extra}")

# Import machinery must not survive as public API. `from __future__ import
# annotations` in particular leaves an `annotations` binding that resolves
# as `xy.annotations` and shadows the annotation components.
#
# Submodules are exempt: importing `xy.pyplot` (or any submodule, in any
# test) binds it as an attribute of the package, so their presence here
# depends on import order and is not an API statement.
leaked = sorted(
name
for name, value in vars(pkg).items()
if not name.startswith("_")
and name not in public_set
and name not in exports
and not isinstance(value, ModuleType)
)
if leaked:
errors.append(f"module globals leak non-public names (delete them after use): {leaked}")

for name, module_name in sorted(exports.items()):
if not isinstance(name, str) or not isinstance(module_name, str):
Expand Down
Loading