Utils Package

The utils package provides cross-cutting utility functions used by Luxar’s core runtime. Demo-owned download, dataset, and runtime helpers are exposed through the luxar.demos barrel instead.

Utility functions for Luxar.

Note: As of v1.4.0, scalar broadcasting is handled by ArrayEncoder in luxar.encoding. The broadcast_*_to_points() functions have been removed (obsolete).

luxar.utils.arbol_warnings() → Iterator[None][source]

Display warnings via arbol, or standard display when arbol hides them.

Usable as a decorator (@arbol_warnings()). No-op when warning display is already owned by someone else (see _default_display_active()).

luxar.utils.install_arbol_warnings() → None[source]

Process-wide install for application entry points (the luxar CLI).

Skipped when a recorder or custom hook already owns warning display, so in-process CLI test harnesses (CliRunner under pytest.warns) keep capturing warnings normally.

luxar.utils.ensure_float32(array: NDArray) → NDArray[float32][source]

Ensure array is float32 dtype, converting if necessary.

Parameters:

array – Input array

Returns:

Array with float32 dtype

luxar.utils.validate_array_shape(array: NDArray, expected_shape: Tuple[int, ...] | List[Tuple[int, ...]], name: str = 'array', check_finite: bool = False) → None[source]

Validate that an array has the expected shape.

Parameters:
  • array – Array to validate

  • expected_shape – Expected shape or list of acceptable shapes

  • name – Name for error messages

  • check_finite – If True, additionally reject arrays containing NaN or ±Inf. Defaults to False for backward compatibility; opt in from callers whose contract forbids non-finite content (most validation/* uses already check this separately; new call sites should set check_finite=True at the boundary rather than re-validating downstream).

Raises:

ValueError – If array doesn’t match expected shape(s), or if check_finite=True and the array contains NaN / ±Inf.

luxar.utils.atomic_copy_file(src: Path, dst: Path) → Path[source]

Copy the FILE src onto dst atomically (temp sibling + rename).

shutil.copy2 writes straight into dst, so an interruption (Ctrl-C, full disk, SIGKILL) leaves a truncated file under the final name — which a later run may mistake for a complete cache entry, or must quarantine and re-fetch. Copying to a sibling temp file and renaming makes dst appear only once it is complete. The temp file’s contents are flushed with os.fsync before the rename, so dst cannot surface with unwritten blocks under a complete-looking name after a crash: os.replace orders the rename against other renames, not against the preceding writes.

Unlike atomic_copytree(), an existing dst IS replaced: every caller is refreshing a cache entry and wants overwrite semantics. Metadata is preserved (copystat, i.e. copy2 semantics), so mtime-based staleness checks keep working — including a read-only source mode, which is applied only after the flush so the copy itself never needs a writable dst.

Parameters:
  • src – Existing regular file to copy.

  • dst – Destination path; replaced if present. Parents are created.

Returns:

dst.

Raises:
  • FileNotFoundError – src is missing or is not a regular file.

  • OSError – Any I/O failure. The temp file is removed before re-raising, so dst keeps whatever it had before the call.

luxar.utils.atomic_copytree(src: Path, dst: Path) → None[source]

Copy the directory tree at src to dst atomically.

Writes to a sibling temp directory dst.parent/.tmp_<dst.name>_<uuid>, then performs an atomic os.replace(tmp, dst) on success. On failure the temp directory is removed and the exception is re-raised, so the destination either exists in full or does not exist at all.

Parameters:
  • src – Source directory to copy. Must exist.

  • dst – Destination path. Must NOT already exist — the caller is responsible for clearing it if overwrite is desired (matches shutil.copytree’s default behaviour).

Raises:
  • FileExistsError – dst already exists.

  • FileNotFoundError – src does not exist or isn’t a directory.

  • OSError – Any I/O failure during the copy. The temp directory is cleaned up before re-raising.

Note

os.replace is atomic on POSIX when the source and destination live on the same filesystem (always true here — we use a sibling temp dir). On Windows, os.replace is atomic for files but the directory rename is implemented via MoveFileExW, which gives the same atomicity guarantees in practice.

luxar.utils.deprecated_kwarg_alias(kwargs: MutableMapping[str, object], old: str, new: str, *, since: str, remove_after: str, stacklevel: int = 3) → MutableMapping[str, object][source]

Forward a renamed keyword argument from its old name to its new one.

Mutates kwargs in place: when old is present it is popped, its value stored under new, and warn_deprecated() fires. When old is absent nothing happens and nothing is emitted, so the call is free on the modern path. Passing both spellings is an error — silently preferring one would hide a real mistake in the caller.

Parameters:
  • kwargs – The **kwargs mapping of the function accepting the alias.

  • old – The deprecated keyword name.

  • new – The current keyword name.

  • since – Release that introduced the deprecation (YYYY.MM.DD).

  • remove_after – Earliest release or date the alias may be removed after.

  • stacklevel – As for warn_deprecated(); the default attributes the warning to the caller of the function that owns kwargs.

Returns:

The same mapping, for callers that prefer an expression.

Raises:

TypeError – If both old and new are present in kwargs.

Example

>>> def add_points(name, positions, **kwargs):
...     deprecated_kwarg_alias(
...         kwargs, "reveal_centre", "reveal_center",
...         since="2026.10.01", remove_after="2027.04.01",
...     )
...     reveal_center = kwargs.pop("reveal_center", None)
luxar.utils.deprecation_message(old: str, new: str | None, *, since: str, remove_after: str) → str[source]

Build the one sentence every Luxar deprecation notice uses.

Parameters:
  • old – The deprecated spelling as the user would type it ("optimise", "--reveal-centre", "reveal_centre=").

  • new – The replacement, or None when the feature is going away with no successor.

  • since – The Luxar release (CalVer, YYYY.MM.DD) that introduced the deprecation.

  • remove_after – The earliest release or date after which the alias may be removed, per the compatibility policy.

Returns:

The formatted notice, ending in a full stop.

luxar.utils.warn_deprecated(old: str, new: str | None = None, *, since: str, remove_after: str, stacklevel: int = 3) → None[source]

Emit the standard deprecation notice as a DeprecationWarning.

Parameters:
  • old – The deprecated spelling.

  • new – The replacement spelling, or None when there is none.

  • since – Release that introduced the deprecation (YYYY.MM.DD).

  • remove_after – Earliest release or date the alias may be removed after.

  • stacklevel – Frames to skip so the warning is attributed to the user’s code. The default of 3 is right when this is called directly from inside the deprecated function: one frame for this helper, one for the deprecated function, landing on its caller.

Example

>>> def optimise_store(*args, **kwargs):  # the alias
...     warn_deprecated(
...         "luxar.io.optimise_store",
...         "luxar.io.optimize_store",
...         since="2026.10.01",
...         remove_after="2027.04.01",
...     )
...     return optimize_store(*args, **kwargs)
luxar.utils.create_lorenz_attractor(store_path: str | Path, n_points: int = 10000, seed: int | None = None) → None[source]

Create a demo scene with a Lorenz attractor visualization.

This creates a beautiful butterfly-shaped 3D structure with colors that transition smoothly over time, demonstrating the Luxar scene format with an aesthetically pleasing mathematical visualization.

Parameters:
  • store_path – Path to the Zarr store to create

  • n_points – Number of points to generate along the attractor

  • seed – Random seed for reproducible results

Example

>>> from luxar.utils import create_lorenz_attractor
>>> create_lorenz_attractor('demo.zarr', n_points=50000)
luxar.utils.create_random_spheres(store_path: str | Path, n_spheres: int = 100, points_per_sphere: int = 1000, seed: int | None = None) → None[source]

Create a demo scene with random colored spheres.

Parameters:
  • store_path – Path to the Zarr store to create

  • n_spheres – Number of spheres to generate

  • points_per_sphere – Points per sphere

  • seed – Random seed for reproducible results

luxar.utils.create_time_series_demo(store_path: str | Path, n_timepoints: int = 10, n_points_per_time: int = 1000, seed: int | None = None) → None[source]

Create a 4D time series demo scene.

Parameters:
  • store_path – Path to the Zarr store to create

  • n_timepoints – Number of time points

  • n_points_per_time – Points per time step

  • seed – Random seed

luxar.utils.get_datasets_dir(create: bool = True) → Path[source]

Get the datasets directory at project root.

Parameters:

create – Create the directory if it doesn’t exist (default True). Pass False to resolve the path read-only (e.g. when only checking whether an output exists, so listing does not create dirs).

Returns:

Path to datasets/ directory

luxar.utils.get_demos_output_dir(create: bool = True) → Path[source]

Get output directory for demo scripts.

Parameters:

create – Create the directory (and its parent) if missing (default True). Pass False for read-only path resolution (status/inventory), so a read-only command like luxar demo list never creates datasets/.

Returns:

Path to datasets/demos/ directory

luxar.utils.get_examples_output_dir(create: bool = True) → Path[source]

Get output directory for example scripts.

Parameters:

create – Create the directory (and its parent) if missing (default True).

Returns:

Path to datasets/examples/ directory

luxar.utils.get_project_root() → Path[source]

Find the Luxar project root directory.

Traverses up from this file’s location looking for pyproject.toml. Result is cached for performance.

Returns:

Path to project root containing pyproject.toml

Raises:

RuntimeError – If project root cannot be found

Array Utilities

Array utility functions for Luxar processing.

This module provides efficient utilities for array processing and validation.

Note: As of v1.4.0, scalar broadcasting is handled by ArrayEncoder in luxar.encoding. The broadcast_*_to_points() functions have been removed (obsolete).

Key Functions:
  • ensure_float32: Convert arrays to float32 dtype for GPU compatibility

  • validate_array_shape: Validate array dimensions with helpful errors

Example

>>> # Ensure array is float32
>>> data = ensure_float32(my_array)
>>> # Validate shape
>>> validate_array_shape(colors, (1000, 3), name="colors")
luxar.utils.array.ensure_float32(array: NDArray) → NDArray[float32][source]

Ensure array is float32 dtype, converting if necessary.

Parameters:

array – Input array

Returns:

Array with float32 dtype

luxar.utils.array.validate_array_shape(array: NDArray, expected_shape: Tuple[int, ...] | List[Tuple[int, ...]], name: str = 'array', check_finite: bool = False) → None[source]

Validate that an array has the expected shape.

Parameters:
  • array – Array to validate

  • expected_shape – Expected shape or list of acceptable shapes

  • name – Name for error messages

  • check_finite – If True, additionally reject arrays containing NaN or ±Inf. Defaults to False for backward compatibility; opt in from callers whose contract forbids non-finite content (most validation/* uses already check this separately; new call sites should set check_finite=True at the boundary rather than re-validating downstream).

Raises:

ValueError – If array doesn’t match expected shape(s), or if check_finite=True and the array contains NaN / ±Inf.

Reusable Demo Generators

Color conversion and stacking helpers for Luxar demos.

luxar.utils.colors.hsv_to_rgb(h: ndarray, s: Any = 1.0, v: Any = 1.0) → ndarray[source]

Vectorized HSV→RGB for arrays of hues (all in [0, 1]).

A single shared implementation for the rainbow/hue-ramp colouring several demos each re-derived by hand. h is an array (or scalar); s/v may be scalars or broadcastable arrays.

Returns:

(..., 3) float32 RGB in [0, 1] with the same leading shape as h.

class luxar.utils.colors.StackedColorings(positions: np.ndarray, colors: np.ndarray, labels: list[str] | None, categories: list[str], keys: list[str] | None = None)[source]

Result of stack_colorings() — a point cloud replicated once per coloring scheme along a leading categorical axis.

positions: ndarray

Alias for field number 0

colors: ndarray

Alias for field number 1

labels: list[str] | None

Alias for field number 2

categories: list[str]

Alias for field number 3

keys: list[str] | None

Alias for field number 4

luxar.utils.colors.stack_colorings(coords: ndarray, colorings: list[dict], keys: Sequence[str] | None = None) → StackedColorings[source]

Replicate a point cloud once per coloring scheme along a categorical axis.

This is the render-proven pattern used by the census / peak-UMAP demos to give a single embedding several switchable color views: the N points are stacked K times (one block per coloring) and a leading coloring index column is prepended so a categorical coloring dimension can select which block (which colour scheme) is shown. Concentrating the stacking here keeps the per-point alignment of positions / colours / hover-labels correct and tested in one place instead of hand-rolled in each demo.

Parameters:
  • coords – (N, D) spatial coordinates (D is usually 3).

  • colorings –

    ordered list of dicts, one per color scheme. Each dict has:

    • "label": category name shown on the coloring dimension.

    • "colors": (N, 3) float RGB for this scheme.

    • "labels" (optional): (N,) per-point hover strings for this scheme. If ANY coloring omits labels, the combined labels is None (hover disabled) rather than misaligned.

  • keys – optional (N,) machine-readable per-point strings for link / copy templates to substitute as {hover_key} (#1917). Passed once for the whole cloud rather than per coloring, because a point’s IDENTITY does not change with the colour scheme — only its label does. Tiled K times here so it stays aligned with the stacked positions, which is the alignment this helper exists to own.

Returns:

A StackedColorings. positions has shape (N*K, D+1) with column 0 = the coloring index; categories is the ordered label list for building Dimension("coloring", categories=...).

Reusable demo scene generators.

luxar.utils.scenes.create_lorenz_attractor(store_path: str | Path, n_points: int = 10000, seed: int | None = None) → None[source]

Create a demo scene with a Lorenz attractor visualization.

This creates a beautiful butterfly-shaped 3D structure with colors that transition smoothly over time, demonstrating the Luxar scene format with an aesthetically pleasing mathematical visualization.

Parameters:
  • store_path – Path to the Zarr store to create

  • n_points – Number of points to generate along the attractor

  • seed – Random seed for reproducible results

Example

>>> from luxar.utils import create_lorenz_attractor
>>> create_lorenz_attractor('demo.zarr', n_points=50000)
luxar.utils.scenes.create_random_spheres(store_path: str | Path, n_spheres: int = 100, points_per_sphere: int = 1000, seed: int | None = None) → None[source]

Create a demo scene with random colored spheres.

Parameters:
  • store_path – Path to the Zarr store to create

  • n_spheres – Number of spheres to generate

  • points_per_sphere – Points per sphere

  • seed – Random seed for reproducible results

luxar.utils.scenes.create_time_series_demo(store_path: str | Path, n_timepoints: int = 10, n_points_per_time: int = 1000, seed: int | None = None) → None[source]

Create a 4D time series demo scene.

Parameters:
  • store_path – Path to the Zarr store to create

  • n_timepoints – Number of time points

  • n_points_per_time – Points per time step

  • seed – Random seed

Deprecation Notices

The post-release deprecation mechanism, built ahead of its first use. Public names that change after the first release keep working for the window promised in Compatibility & Deprecation Policy and announce the rename through these helpers; the CLI half, luxar.cli.utils.deprecated_option, prints the same sentence to stderr.

Deprecation helpers for the post-release compatibility window.

Before the first PyPI release a rename is a hard cut — the old spelling is rejected with a pointer to the new one (see _reject_renamed_method_flags in luxar.cli.lod and LEGACY_RECIPE_NAMES in luxar.gsplats.lod.recipes). After it, a public name that changes keeps working for the window the compatibility policy promises (docs/guides/user/COMPATIBILITY_POLICY.md: two releases or six months, whichever is longer) while warning, and these helpers are how every such alias says so in one voice.

Two entry points:

  • warn_deprecated() emits the standard sentence as a DeprecationWarning. Call it from inside the deprecated function, property, or alias so the default stacklevel points at the user’s call site rather than at Luxar.

  • deprecated_kwarg_alias() handles the commonest case, a renamed keyword argument: it moves the old key onto the new one in the caller’s **kwargs and warns, so the function body only ever reads the new name.

The CLI counterpart, luxar.cli.utils.deprecated_option, prints to stderr instead of warning: Python hides DeprecationWarning by default outside __main__, which is the right default for a library and the wrong one for a command someone just typed.

Every message carries the release the deprecation started in and the point after which the alias may go, so a reader of the warning never has to look the window up.

luxar.utils.deprecation.deprecated_kwarg_alias(kwargs: MutableMapping[str, object], old: str, new: str, *, since: str, remove_after: str, stacklevel: int = 3) → MutableMapping[str, object][source]

Forward a renamed keyword argument from its old name to its new one.

Mutates kwargs in place: when old is present it is popped, its value stored under new, and warn_deprecated() fires. When old is absent nothing happens and nothing is emitted, so the call is free on the modern path. Passing both spellings is an error — silently preferring one would hide a real mistake in the caller.

Parameters:
  • kwargs – The **kwargs mapping of the function accepting the alias.

  • old – The deprecated keyword name.

  • new – The current keyword name.

  • since – Release that introduced the deprecation (YYYY.MM.DD).

  • remove_after – Earliest release or date the alias may be removed after.

  • stacklevel – As for warn_deprecated(); the default attributes the warning to the caller of the function that owns kwargs.

Returns:

The same mapping, for callers that prefer an expression.

Raises:

TypeError – If both old and new are present in kwargs.

Example

>>> def add_points(name, positions, **kwargs):
...     deprecated_kwarg_alias(
...         kwargs, "reveal_centre", "reveal_center",
...         since="2026.10.01", remove_after="2027.04.01",
...     )
...     reveal_center = kwargs.pop("reveal_center", None)
luxar.utils.deprecation.deprecation_message(old: str, new: str | None, *, since: str, remove_after: str) → str[source]

Build the one sentence every Luxar deprecation notice uses.

Parameters:
  • old – The deprecated spelling as the user would type it ("optimise", "--reveal-centre", "reveal_centre=").

  • new – The replacement, or None when the feature is going away with no successor.

  • since – The Luxar release (CalVer, YYYY.MM.DD) that introduced the deprecation.

  • remove_after – The earliest release or date after which the alias may be removed, per the compatibility policy.

Returns:

The formatted notice, ending in a full stop.

luxar.utils.deprecation.warn_deprecated(old: str, new: str | None = None, *, since: str, remove_after: str, stacklevel: int = 3) → None[source]

Emit the standard deprecation notice as a DeprecationWarning.

Parameters:
  • old – The deprecated spelling.

  • new – The replacement spelling, or None when there is none.

  • since – Release that introduced the deprecation (YYYY.MM.DD).

  • remove_after – Earliest release or date the alias may be removed after.

  • stacklevel – Frames to skip so the warning is attributed to the user’s code. The default of 3 is right when this is called directly from inside the deprecated function: one frame for this helper, one for the deprecated function, landing on its caller.

Example

>>> def optimise_store(*args, **kwargs):  # the alias
...     warn_deprecated(
...         "luxar.io.optimise_store",
...         "luxar.io.optimize_store",
...         since="2026.10.01",
...         remove_after="2027.04.01",
...     )
...     return optimize_store(*args, **kwargs)

Console Output

luxar.utils.verbosity turns Luxar’s own narration down, or off. The library layers below the CLI narrate through arbol — the right default for a long CLI run, and the wrong one in a notebook cell or a napari plugin. Reachable from the package root as luxar.set_verbosity() / luxar.verbosity().

Note that this writes process-global arbol state, so it is neither per-call nor thread-safe; the module docstring states the constraints in full.

Turn Luxar’s console output down, or off.

Luxar’s library layers narrate what they are doing through arbol — 547 aprint calls below the CLI, in gsplats (314), io (146) and core (75). That is the right default for a long CLI run, and the wrong one for a notebook cell or a napari plugin, where the same tree scrolls past the result you asked for. Until this module there was no way to say so: no verbose or quiet parameter on Scene.save() or fit_gaussian_splats(), and no global switch that was part of Luxar’s own API (audit finding A15-08).

Two entry points, one mechanism:

import luxar

luxar.set_verbosity("silent")     # process-wide, until changed again
with luxar.verbosity("summary"):  # scoped, restores on exit
    scene.save()

Levels, and exactly what each one does:

"silent"

Normal arbol narration is suppressed; Python warnings still surface via their standard display.

"summary"

Top-level lines and one level of nesting; deeper sections become truncation notices (depth 1).

"normal"

Three levels of nesting (depth 3).

"full"

Everything. The default — unchanged behaviour.

an int

That many levels of nesting. 0 is not silent (see below); use "silent".

Implementation, stated plainly because it constrains how you can use this: these functions set arbol.Arbol.enable_output and arbol.Arbol.max_depth, which are class attributes. So:

  • The setting is process-global, not per-call and not per-object. A verbosity() block around one save() also quiets anything else running concurrently.

  • It is not thread-safe. Two threads entering different verbosity() blocks will interleave and the loser’s restore will win. The switches and arbol’s depth counter are shared class state.

  • It changes the shared settings for all arbol users in the process, including other libraries. There is no Luxar-only namespace to scope it to.

A per-call verbosity= argument on the public entry points, and routing library messages through logging so a handler could filter by module, are both possible later; neither is needed to make the output silenceable, which is what was actually missing.

max_depth=0 is deliberately not the silent level: arbol still prints depth-0 lines plus a “(log tree truncated here)” notice for each section it truncates at the cap, so a caller asking for silence and getting truncation notices would be worse than the status quo. Measured, not assumed — see tests/test_verbosity.py::TestTheDocumentedEffectsAreReal::test_depth_zero_is_not_silence.

luxar.utils.verbosity.get_verbosity() → Literal['silent', 'summary', 'normal', 'full'] | int[source]

Return the current verbosity as a level name, or an int depth.

Output disabled reports "silent" regardless of the current depth. Otherwise a name is returned when the live arbol settings match a named level exactly, and the raw depth is returned when they do not. Consequently, set_verbosity(get_verbosity()) is lossy for a manually disabled finite depth; verbosity() still restores the exact switch pair.

Returns:

One of the level names, or the current maximum depth as an int.

luxar.utils.verbosity.set_verbosity(level: Literal['silent', 'summary', 'normal', 'full'] | int) → None[source]

Set how much Luxar narrates, process-wide.

Parameters:

level – "silent", "summary", "normal", "full", or an int giving the maximum section nesting depth to show. 0 shows top-level lines only and is not silence — pass "silent".

Raises:
  • ValueError – If level is an unknown name or a negative depth.

  • TypeError – If level is a bool.

Example

>>> import luxar
>>> luxar.set_verbosity("silent")
>>> luxar.set_verbosity("full")  # back to the default

See the module docstring for the scope and thread-safety caveats: this writes process-global arbol state.

luxar.utils.verbosity.verbosity(level: Literal['silent', 'summary', 'normal', 'full'] | int) → Iterator[None][source]

Set the verbosity for the duration of a block, then restore it.

Restores the exact previous enable_output / max_depth pair rather than the level name it resolves to, so a caller who had set Arbol.max_depth by hand gets their own value back.

Parameters:

level – As set_verbosity().

Yields:

Nothing.

Example

>>> import luxar
>>> with luxar.verbosity("silent"):
...     pass  # nothing this block does will print

Not thread-safe — see the module docstring.