CLI Package
The CLI package provides command-line tools for serving and inspecting Luxar datasets.
See also
This page documents the Python CLI modules (autodoc). For the
authoritative catalog of user-facing luxar ... commands and the workflow
guides that cover them, see the Luxar CLI Reference.
Luxar CLI package - Command-line interface for Luxar.
Main Application
The main application provides the luxar command-line interface with commands for
serving data and inspecting datasets. (There is no separate viewer-build command —
the bundled viewer is served automatically when you serve data with --viewer.)
luxar.cli – Command-line interface for building, serving, and inspecting Luxar Zarr scenes.
- IMPORTANT: URL Construction
When constructing data URLs for the viewer’s ?src= parameter, DO NOT include trailing slashes. The viewer’s fetch logic treats them differently: - CORRECT: http://host:port (joins correctly: http://host:port/.zmetadata) - WRONG: http://host:port/ (creates double-slash: http://host:port//.zmetadata)
Key Functions
- luxar.cli.main.create_server_app(path: str, *, cors_origin: str = 'local', allow_sensitive_path: bool = False, bind_host: str | None = None) FastAPI[source]
Create a FastAPI server application for serving Zarr data.
This function is used by both the CLI and integration tests to create a configured server instance.
- Parameters:
path – Path to directory or Zarr dataset to serve
cors_origin – Allowed CORS origin.
"local"allows loopback origins.allow_sensitive_path – If True, permit serving system directories.
bind_host – Bind address, so a LAN bind also allows its own origin under
cors_origin="local"(see_add_cors()).
- Returns:
FastAPI application instance
This function is particularly useful for:
Integration Testing: Create test servers that serve real Zarr data
Programmatic Server Creation: Embed Luxar server in larger applications
Custom Deployments: Configure and run servers with custom settings
Example usage in tests:
from luxar.cli.main import create_server_app import uvicorn # Create server app app = create_server_app("/path/to/data.luxar.zarr") # Run with uvicorn uvicorn.run(app, host="127.0.0.1", port=8000)
Serving
The server application factory and CORS/serving helpers. create_server_app
is re-exported from luxar.cli.main (used above) but its real home is
luxar.cli.serving.
HTTP serving + path-safety helpers for the luxar CLI.
These are the Typer-free building blocks the serve / viewer / demo
commands (and the gsplat view command) share: CORS configuration, the
loopback / sensitive-path guards, the directory-listing static handler, and the
foreground/background server runners. They live here — not in cli/main.py —
so subcommand modules can reach _serve_data / _serve_viewer without
importing main (which would form a cycle through the gsplat subcommand
mount at the bottom of main.py).
cli/main.py re-exports the public names so existing
luxar.cli.main.create_server_app / ._serve_data / ._serve_viewer
imports and unittest.mock.patch targets keep resolving unchanged.
- class luxar.cli.serving.DirectoryListingStaticFiles(*, directory: str | PathLike[str] | None = None, packages: list[str | tuple[str, str]] | None = None, html: bool = False, check_dir: bool = True, follow_symlink: bool = False)[source]
Mutable data files with JSON listings and forced revalidation.
- luxar.cli.serving.create_server_app(path: str, *, cors_origin: str = 'local', allow_sensitive_path: bool = False, bind_host: str | None = None) FastAPI[source]
Create a FastAPI server application for serving Zarr data.
This function is used by both the CLI and integration tests to create a configured server instance.
- Parameters:
path – Path to directory or Zarr dataset to serve
cors_origin – Allowed CORS origin.
"local"allows loopback origins.allow_sensitive_path – If True, permit serving system directories.
bind_host – Bind address, so a LAN bind also allows its own origin under
cors_origin="local"(see_add_cors()).
- Returns:
FastAPI application instance
- class luxar.cli.serving.ServedStore(host: str, port: int)[source]
URLs of a store + viewer served by
served_store().- data_url
The data root — what goes into the viewer’s
?src=(no trailing slash).
- viewer_url
The viewer’s
index.html(trailing slash, so a relative?srcresolves).
- luxar.cli.serving.served_store(path: str | Path, *, host: str = '127.0.0.1', port: int | None = None, cors_origin: str = 'local', ready_timeout: float = 30.0) Iterator[ServedStore][source]
Serve
pathat/and the built viewer under/viewer— and STOP.The serve-family commands all end in a blocking
uvicorn.runon a daemon thread, which is right for a terminal and useless for a caller that needs the server for the duration of one job (luxar env bake). This is the same single-process layoutluxar exportships, run through auvicorn.Serverhandle so thewithblock’s exit shuts it down, and readiness is a polled/healthrather than a sleep. Needs a built viewer (ensure_viewer_built()).
Utilities
Utility functions for port management, viewer building, and dataset inspection.
luxar.cli_utils – Helper utilities for the Luxar CLI.
- luxar.cli.utils.deprecated_option(value: T, old_flag: str, new_flag: str | None, *, since: str, remove_after: str) T[source]
Report a deprecated CLI flag on stderr and hand its value back.
The CLI half of
luxar.utils.deprecation. Pair it with a hidden typer alias that defaults toNone(the idiom already used for--methodinluxar.cli.lod):reveal_center: Optional[str] = typer.Option(None, "--reveal-center"), legacy_centre: Optional[str] = typer.Option( None, "--reveal-centre", hidden=True ), ... reveal_center = reveal_center or deprecated_option( legacy_centre, "--reveal-centre", "--reveal-center", since="2026.10.01", remove_after="2027.04.01", )
Nothing is printed when
valueisNone— the alias was not typed — so the modern path stays silent. The notice goes to stderr throughtyper.echo(err=True)rather thanwarnings.warn: Python hidesDeprecationWarningby default outside__main__, and a command the user just typed must tell them about the rename now, not in a future release when the alias disappears.- Parameters:
value – What typer parsed for the hidden alias (
None= not given).old_flag – The deprecated spelling, e.g.
"--reveal-centre".new_flag – The replacement, or
Nonewhen the flag is going away.since – Release that introduced the deprecation (
YYYY.MM.DD).remove_after – Earliest release or date the alias may be removed after.
- Returns:
valueunchanged, so the call can sit inline in an assignment.
- luxar.cli.utils.primary_lan_address() str | None[source]
Return this machine’s primary outbound IPv4 address, or None.
Asks the routing table which local interface would carry traffic towards the outside world. Nothing is sent:
connect()on a datagram socket only fixes the route, so this is free and works on a machine with no route at all (it simply returns None). The peer is an RFC 5737 documentation address that is guaranteed never to be routed anywhere.This is a best guess, not the only answer: a machine can hold several addresses on the same LAN (Wi-Fi and Ethernet both up, say) and any of them may be the one a tablet dials. The default route is the most useful single choice for a URL, and it is only ever used where one address is unavoidable — printing a URL. The CORS allowance deliberately does NOT build on this, because a guess there would reject a client that arrived on a sibling address; see
_SameHostCORSMiddlewarein serving.py.
- luxar.cli.utils.advertised_host(bind_host: str) str[source]
Return the address to put in a URL for a server bound to
bind_host.A concrete bind address is already the answer. A wildcard bind is not:
http://0.0.0.0:5173is not reachable from another machine (and on some platforms not from this one), so a printed URL containing it is simply wrong. Resolve it to the primary LAN address, which is what a tablet on the same network needs, and fall back to loopback on a machine with no route — where loopback is the only thing that could have worked.
- luxar.cli.utils.origin_hostname(origin: str) str[source]
The hostname of an
Originheader, or""if malformed.Port deliberately excluded. This answers “is this origin the same machine as the request’s
Host?”, and the viewer and the data server are two different ports on one host by construction, so a port-sensitive comparison would reject every real pairing.Anything that is not a clean
http(s)://host[:port]returns""and therefore matches nothing — includingnull(a sandboxed iframe), an origin carrying a path or fragment, and one with an embedded CRLF.Contrast
origin_authority(), which keeps the port for the control hub, where the socket is served from the very port that served the page.
- luxar.cli.utils.authority_hostname(authority: str) str[source]
The hostname of a
Hostheader value, or""if malformed.Fails closed: a
Hostthis cannot parse yields no match rather than a guess, so a malformed or smuggled header cannot widen an allowance.
- luxar.cli.utils.origin_authority(origin: str) str[source]
The lowercased
hostorhost:portof an origin,""if malformed.The control hub compares this against its request’s
Host— port INCLUDED, because the control WebSocket is served from the same port as the page that opens it, so a differing port there is a genuine mismatch rather than the expected two-port pairingorigin_hostname()allows.
- luxar.cli.utils.open_browser(url: str, suppress_errors: bool = False) bool[source]
Open a URL in the default web browser.
- Parameters:
url – URL to open.
suppress_errors – If True, don’t print error messages.
- Returns:
True if successful, False otherwise.
- luxar.cli.utils.check_port_available(port: int, host: str = '127.0.0.1') bool[source]
Check if a port is available for binding.
The probe sets
SO_REUSEADDRso it matches the bind it predicts: the real server is uvicorn’sloop.create_server, and asyncio passesreuse_address=Trueon POSIX. A plain probe is stricter than that — a lingering non-listening socket left by a previous run (TIME_WAIT / FIN_WAIT2) makes it report “busy” on a port the server would have bound fine, so a serve-family command shifts to the next port with only a warning — which a harness that discards the server’s stdout never sees, leaving it to wait out its timeout on a port nothing came up on. On Linux a LISTENING socket on the same address still conflicts regardless ofSO_REUSEADDR, so a genuinely running server is still detected.The option is applied under asyncio’s own condition rather than unconditionally: on Windows asyncio deliberately omits it, because there
SO_REUSEADDRpermits binding over an active listener — the probe would call an occupied port free and the real bind would then fail hard, trading a warned port shift for a crash.The address family follows the host: an IPv6 literal (
::1,::) needs anAF_INET6socket, and probing it onAF_INETfails for every port — whichpick_portreports as “No available ports found”, soluxar serve --host ::1died on a completely free port. Only literals are switched; names (localhost) stay onAF_INETas before rather than inheriting whatever order the resolver happens to return.- Parameters:
port – Port number to check.
host – Host address to check.
- Returns:
True if port is available, False if in use.
- luxar.cli.utils.find_available_port(start_port: int = 8000, max_attempts: int = 100, *, end_port: int | None = None, host: str = '127.0.0.1') int | None[source]
Find an available port starting from the given port.
- Parameters:
start_port – Port to start searching from.
max_attempts – Maximum number of ports to try.
end_port – Inclusive upper bound for the search; overrides
max_attemptswhen given (capped at 65535).host – Host address the port must be bindable on — pass the same host the server will bind so availability is checked on the interface actually used.
- Returns:
Available port number, or None if none found.
- luxar.cli.utils.pick_port(requested: int, host: str = '127.0.0.1', label: str = '') int | None[source]
Resolve a usable port near
requested, warning when it shifts.Wraps
find_available_port()with the uniform “port busy” warning every serve-family command should print, so callers can’t silently bind a different port than the user asked for.- Returns:
The chosen port, or None if no port is available.
- luxar.cli.utils.dataset_title(path: Path | str | None) str | None[source]
A human-recognizable title for a dataset path, or None.
Peels an archive extension (
.zip,.tar.gz,.tgz) and then a dataset suffix (.luxar.zarr,.gsplats.zarr,.zarr) off the file name —global_rivers_earthfromglobal_rivers_earth.luxar.zarr, and equally fromglobal_rivers_earth.luxar.zarr.zip. Both layers matter:gsplat viewtakes.gsplats.zarr.zip/.gsplats.zarr.tar.gzarchives, and a shipped demo scene is a.luxar.zarr.zip.Serve-family commands pass the result as the viewer’s
?title=parameter so every browser tab names the scene it shows (several demo/dev tabs are otherwise indistinguishable). A scene’s authoredviewer_config.titleoverrides it in the viewer.
- luxar.cli.utils.append_title_param(viewer_url: str, title: str | None) str[source]
Append
&title=<url-encoded>to a viewer URL, or return it unchanged.The viewer URL always already carries
?src=, so&is the right separator. Shared by every serve-family command so the tab title is spelled identically wherever a viewer URL is printed or opened.
- luxar.cli.utils.wait_for_server(host: str, port: int, thread: Thread | None = None, timeout: float = 5.0, poll_interval: float = 0.05) bool[source]
Poll until
(host, port)accepts a TCP connection.Replaces the old fixed
time.sleep(1)startup delays: returns as soon as the server is actually reachable, and fails fast when the optionalthreadrunning the server has died (e.g. lost a bind race) instead of letting the caller open a browser onto a dead server.- Parameters:
host – Host the server binds; all-interfaces sentinels (
0.0.0.0/::) are probed via loopback.port – Port the server binds.
thread – Server thread to watch; a dead thread returns False early.
timeout – Total seconds to wait before giving up.
poll_interval – Delay between connection attempts.
- Returns:
True once the server accepts a connection, False on timeout or thread death.
- luxar.cli.utils.check_viewer_built() bool[source]
Check if the Luxar viewer is built.
- Returns:
True if viewer dist directory exists, False otherwise.
- luxar.cli.utils.get_viewer_dist_path() Path[source]
Get the path to the viewer distribution directory.
Checks two locations in order: 1. Bundled viewer inside the installed package (
luxar/_viewer_dist/) 2. Development source tree (packages/luxar-viewer/dist/)- Returns:
Path to the viewer dist directory.
- luxar.cli.utils.exit_code_from(returncode: int) int[source]
Map a
subprocessreturncode to a shell-conventional exit code.POSIX
subprocess.runreports a signal-killed child as-N(signal number). Passing that straight totyper.Exit/sys.exittruncates to256 - N(SIGKILL → 247), which no tooling recognizes; the shell convention is128 + N(SIGKILL → 137). Non-negative codes pass through.
- luxar.cli.utils.ensure_viewer_built(auto_build: bool = True) bool[source]
Return True when a CURRENT viewer dist exists, building it if possible.
The serve-family commands previously disagreed on what to do when the viewer wasn’t built (warn-and-skip vs auto-build vs error). This is the single policy: auto-build via pnpm only when running from a dev source tree; from an installed wheel the bundled
_viewer_distshould already exist, so a missing viewer is a packaging problem, not a build step.In a dev tree “built” also means “not stale”: a dist older than the newest viewer source triggers a rebuild too. A FAILED rebuild of a merely-stale dist degrades to serving the stale build — with a loud warning — instead of taking serving down entirely.
- luxar.cli.utils.build_viewer() bool[source]
Build the Luxar viewer using pnpm.
- Returns:
True if build successful, False otherwise.
- luxar.cli.utils.format_tree_node(name: str, depth: int, is_last: bool, prefix: str = '', node_type: str | None = None, attrs: dict[str, Any] | None = None) str[source]
Format a tree node for display.
- Parameters:
name – Node name.
depth – Current depth in tree.
is_last – Whether this is the last child.
prefix – Prefix for the current line.
node_type – Type of node (“scene”, “group”, “points”, “lines”, “gsplats”, or “mesh”). Unknown values are rendered without a type icon.
attrs – Node attributes to display.
- Returns:
Formatted tree node string.
- luxar.cli.utils.format_memory_size(bytes_size: float) str[source]
Format bytes size to human-readable string.
- Parameters:
bytes_size – Size in bytes.
- Returns:
Human-readable size string.
- luxar.cli.utils.get_zarr_info(store_path: Path, detailed: bool = False) dict[str, Any][source]
Get detailed information about a Zarr store.
- Parameters:
store_path – Path to the Zarr store.
detailed – If True, include extra per-object details (shapes, dtypes).
- Returns:
Dictionary with store information.
Network Simulation
Network simulation middleware for testing viewer performance under various network conditions.
Network simulation middleware for testing Luxar viewer under various network conditions.
This module provides ASGI middleware to simulate realistic network conditions including: - Bandwidth throttling (limit bytes per second) - Network latency (fixed delay + optional jitter) - Packet loss (random request dropping)
⚠️ WARNING: For development and testing only. Never use in production.
- luxar.cli.network_simulation.parse_bandwidth(bandwidth_str: str) float[source]
Parse bandwidth string to Mbps.
- Parameters:
bandwidth_str – Bandwidth with unit (e.g., “1mbps”, “500kbps”, “2.5gbps”)
- Returns:
Bandwidth in megabits per second
- Raises:
ValueError – If format is invalid
Examples
>>> parse_bandwidth("1mbps") 1.0 >>> parse_bandwidth("500kbps") 0.5 >>> parse_bandwidth("2.5gbps") 2500.0
- luxar.cli.network_simulation.parse_latency(latency_str: str) float[source]
Parse latency string to milliseconds.
- Parameters:
latency_str – Latency with unit (e.g., “100ms”, “1s”, “0.5s”)
- Returns:
Latency in milliseconds
- Raises:
ValueError – If format is invalid
Examples
>>> parse_latency("100ms") 100.0 >>> parse_latency("1s") 1000.0 >>> parse_latency("0.5s") 500.0
- luxar.cli.network_simulation.parse_jitter(jitter_str: str) float[source]
Parse jitter string to decimal (0.0-1.0).
- Parameters:
jitter_str – Jitter as percentage or decimal (e.g., “10%”, “0.1”)
- Returns:
Jitter as decimal (0.0-1.0)
- Raises:
ValueError – If format is invalid or out of range
Examples
>>> parse_jitter("10%") 0.1 >>> parse_jitter("0.15") 0.15 >>> parse_jitter("25%") 0.25
- luxar.cli.network_simulation.parse_packet_loss(loss_str: str) float[source]
Parse packet loss string to decimal (0.0-1.0).
- Parameters:
loss_str – Packet loss as percentage or decimal (e.g., “1%”, “0.01”)
- Returns:
Packet loss rate as decimal (0.0-1.0)
- Raises:
ValueError – If format is invalid or out of range
Examples
>>> parse_packet_loss("1%") 0.01 >>> parse_packet_loss("0.05") 0.05 >>> parse_packet_loss("10%") 0.1
- luxar.cli.network_simulation.parse_network_options(profile: str | None = None, bandwidth: str | None = None, latency: str | None = None, jitter: str | None = None, packet_loss: str | None = None) tuple[float | None, float | None, float, float][source]
Parse and merge network simulation CLI parameters.
Loads a profile first (if given), then overrides with individual params.
- Parameters:
profile – Profile name (e.g., “3g”, “satellite”).
bandwidth – Bandwidth string (e.g., “1mbps”, “500kbps”).
latency – Latency string (e.g., “100ms”, “1s”).
jitter – Jitter string (e.g., “10%”, “0.1”).
packet_loss – Packet loss string (e.g., “1%”, “0.01”).
- Returns:
Tuple of (bandwidth_mbps, latency_ms, jitter_percent, packet_loss_rate).
- Raises:
ValueError – If any parameter has invalid format.
- luxar.cli.network_simulation.has_network_simulation(bandwidth_mbps: float | None, latency_ms: float | None, jitter_percent: float, packet_loss_rate: float) bool[source]
Check if any network simulation parameters are active.
Returns True when at least one of bandwidth, latency, jitter, or packet loss is set to a non-zero/non-None value.
- luxar.cli.network_simulation.print_network_params(bandwidth_mbps: float | None, latency_ms: float | None, jitter_percent: float, packet_loss_rate: float, qualifier: str = '') None[source]
Print active network simulation parameters.
- Parameters:
bandwidth_mbps – Bandwidth in Mbps.
latency_ms – Latency in milliseconds.
jitter_percent – Jitter as decimal (0.0-1.0).
packet_loss_rate – Packet loss as decimal (0.0-1.0).
qualifier – Optional qualifier for the message (e.g., “data server only”).
- luxar.cli.network_simulation.load_network_profile(profile_name: str) NetworkProfile[source]
Load network profile by name.
- Parameters:
profile_name – Profile identifier (e.g., “3g”, “4g”, “satellite”)
- Returns:
NetworkProfile dictionary with all parameters
- Raises:
ValueError – If profile name is not recognized
- class luxar.cli.network_simulation.NetworkSimulationMiddleware(app: Any, bandwidth_limit_mbps: float | None = None, latency_ms: float | None = None, jitter_percent: float = 0.0, packet_loss_rate: float = 0.0)[source]
ASGI middleware to simulate realistic network conditions.
This middleware simulates: - Bandwidth throttling (limits bytes per second) - Network latency (fixed delay + optional jitter) - Packet loss (random request dropping)
The middleware intercepts HTTP requests and responses, applying the configured simulation parameters.
⚠️ WARNING: For development and testing only. Never use in production.
- __init__(app: Any, bandwidth_limit_mbps: float | None = None, latency_ms: float | None = None, jitter_percent: float = 0.0, packet_loss_rate: float = 0.0)[source]
Initialize network simulation middleware.
- Parameters:
app – ASGI application to wrap
bandwidth_limit_mbps – Maximum bandwidth in megabits per second
latency_ms – Fixed latency to add to each response (milliseconds)
jitter_percent – Latency variation as percentage (0.0-1.0)
packet_loss_rate – Probability of dropping a request (0.0-1.0)
Network Profiles
The network simulation supports several built-in profiles that simulate real-world network conditions:
3g: Mobile 3G connection (384 kbps, 300ms latency, 1% packet loss)
4g: Mobile 4G/LTE (10 Mbps, 100ms latency, 0.5% packet loss)
5g: 5G mobile (100 Mbps, 30ms latency, 0.1% packet loss)
satellite: Satellite internet (25 Mbps, 600ms latency, 1% packet loss)
rural: Rural DSL (1 Mbps, 100ms latency, 2% packet loss)
congested: Congested network (2 Mbps, 200ms latency, 3% packet loss)
slow-broadband: Slow broadband (5 Mbps, 50ms latency, 0.5% packet loss)
broadband: Home broadband (50 Mbps, 20ms latency, 0.1% packet loss)
fast-broadband: Fast broadband (200 Mbps, 10ms latency, 0.05% packet loss)
Example usage:
# Simulate 3G connection
luxar serve data.luxar.zarr --profile 3g --viewer
# Custom slow connection
luxar serve data.luxar.zarr --bandwidth 500kbps --latency 200ms --packet-loss 2%
# Override profile settings
luxar serve data.luxar.zarr --profile 4g --latency 300ms
See the Network Simulation for Luxar CLI - Technical Specification for detailed specifications.
Export
Export Luxar scenes as standalone offline viewer bundles.
Export a Luxar zarr scene + viewer into a standalone offline folder.
The exported folder contains everything needed to view the scene:
The Luxar viewer (HTML, JS, CSS, WASM) – including the touch-panel page
The zarr dataset (copied as-is)
A serve.py script (Python 3 stdlib only), which can also host the remote-control relay so the folder alone runs a kiosk
A README.txt with usage instructions
Usage:
luxar export my_scene.luxar.zarr -o my_export/
cd my_export && python3 serve.py
cd my_export && python3 serve.py --control --host 0.0.0.0 # kiosk
- luxar.cli.export.export_scene(source: Path, output: Path, data_dir_name: str = 'data', overwrite: bool = False) Path[source]
Export a zarr scene with viewer into a standalone folder.
- Parameters:
source – Path to the source zarr store.
output – Path to the output folder.
data_dir_name – Name for the data directory inside output.
overwrite – Whether to overwrite existing output.
- Returns:
Path to the created output folder.
- Raises:
FileExistsError – If output exists and overwrite is False.
FileNotFoundError – If source doesn’t exist or viewer not built.
ValueError – If source is not a valid zarr store.
- class luxar.cli.export.SceneFacts(title: str | None = None, has_control_panel: bool = False, chapter_dimension: str | None = None, chapter_count: int = 0, panel_tiles: int = 0, dimensions: tuple[str, ...] = (), displayed: tuple[str, ...] = (), citation: str | None = None)[source]
What the exporter can learn about a scene from its own attributes.
Read through
luxar._zarr_compat.read_node_attrs(), never by naming a metadata document: the attributes live in.zattrsat zarr format 2 and nested underattributesinzarr.jsonat format 3, and a tree holding both is the normal steady state here. A literal filename would answer “no control panel” for half the stores in existence, and the export would look like it worked.- has_control_panel: bool = False
it carries a
viewer_config.control_panel. Decides the exported serve.py’s defaults and what the README leads with.- Type:
A scene authored to be driven from a tablet
- chapter_count: int = 0
a chapter only supplies a slot’s sublabel, so a dimension coordinate with no authored chapter still gets a tile, labelled from the dimension’s categories.
- Type:
Authored
Chapterentries. NOT the number of tiles
- panel_tiles: int = 0
Tiles the panel draws – one per coordinate of the chapter dimension. Differs from chapter_count whenever a coordinate carries no authored chapter (an unauthored overview slot, say), so this is the number any operator-facing text must quote.
- property stops: int
Tiles the panel draws, for operator-facing text.
Falls back to the authored chapter count so a SceneFacts built by hand, without going through read_scene_facts, still reports a sensible number rather than zero (which would also drop the README’s scene summary, since that block only renders when there is a count).
- luxar.cli.export.read_scene_facts(source: Path) SceneFacts[source]
Introspect
sourcefor the facts the export needs. Never raises.A store this cannot read is not an export failure: the folder is still valid, it just gets the generic README and the conservative serve.py defaults. So every lookup is defensive.
- luxar.cli.export.THIRD_PARTY_LICENSES = 'THIRD_PARTY_LICENSES.txt'
Emitted into the viewer build by
packages/luxar-viewer/scripts/generate-third-party-licenses.mjs.
- luxar.cli.export.SERVE_TEMPLATE = PosixPath('/home/runner/work/luxar/luxar/packages/luxar/src/luxar/cli/_export_serve_template.py')
The generated
serve.pyis this module, copied with two values substituted. It is a real module rather than a string literal so ruff, mypy and the test suite all see it – a WebSocket relay hidden inside an f-string (where every brace has to be doubled) is unreviewable, and this one is the fourth implementation of a relay that three other files already agree on.
- luxar.cli.export.QR_MODULE_SOURCE = PosixPath('/home/runner/work/luxar/luxar/packages/luxar/src/luxar/cli/_qr.py')
The QR encoder, copied beside serve.py under this name. serve.py imports it by this exact name, so the two move together.
Native Bundles
Producers for double-clickable native bundles wrapped around the Go-compiled
launcher binary (packages/luxar-launcher). Used internally by the
luxar export --native subcommand to emit .app bundles on macOS and
portable folders on Linux. The bundle layout, Info.plist generator, and
launcher-binary lookup all live in this module.
Native bundle producers for luxar export --native.
Wraps a copy of the viewer dist + a zarr scene around a precompiled Go
launcher (built via make build-launchers) so end users can double-click
the result instead of running a Python server.
Two bundle formats are supported in the v1 prototype:
macOS .app: standard
<name>.app/Contents/{Info.plist, MacOS, Resources}layout. The launcher lives atContents/MacOS/launcher; viewer and data atContents/Resources/{viewer,data}.Linux portable folder:
<name>-linux-<arch>/containing the launcher binary alongsideviewer/anddata/plus a small README. Most desktop file managers happily run the binary on double-click; CLI users can run./luxar-launcher.
Windows + AppImage are intentionally out of scope for this prototype.
- luxar.cli.native_app.validate_bundle_name(name: str) str[source]
Validate that
nameis a safe, opaque app/file name — never a path.A native bundle name is interpolated directly into on-disk paths (e.g.
output / f"{name}.app"). It must therefore be a single, opaque display/file name component and never smuggle in path structure that could escape the requested output directory (issue #686). This rejects path separators,./..components, absolute paths, NUL/control characters, and empty/whitespace-only names; ordinary names with spaces and non-ASCII Unicode letters (e.g."My Scéne 2") stay valid.- Parameters:
name – The candidate app/bundle name.
- Returns:
nameunchanged when it is valid.- Raises:
ValueError – If
nameis not a safe, opaque name component.
- exception luxar.cli.native_app.LauncherNotBuiltError[source]
Raised when a launcher binary is missing for the requested platform.
- luxar.cli.native_app.get_launcher_path(platform_name: str) Path[source]
Return the path to the launcher binary for
platform_name.- Parameters:
platform_name – One of
SUPPORTED_PLATFORMS.- Raises:
ValueError – If
platform_nameis not recognised.LauncherNotBuiltError – If the binary has not been built yet.
- luxar.cli.native_app.bundle_macos_app(*, viewer_dist: Path, zarr_data: Path, output: Path, app_name: str) Path[source]
Build a macOS
.appbundle atoutput / f"{app_name}.app".The launcher binary is the Go-compiled
darwin-universalartifact; viewer + data live underContents/Resources.
- luxar.cli.native_app.bundle_linux_folder(*, arch: str, viewer_dist: Path, zarr_data: Path, output: Path, app_name: str) Path[source]
Build a portable Linux folder at
output / f"{app_name}-linux-{arch}/".- Parameters:
arch –
"amd64"or"arm64".
- luxar.cli.native_app.zip_macos_app(app_path: Path) Path[source]
Zip a macOS
.appbundle next to itself.Prefers
ditto -c -k --keepParenton Darwin so resource forks, extended attributes, and the bundle’s directory layout are preserved exactly. On non-Darwin hosts (e.g. Linux producing a macOS bundle as a cross-build), falls back tozipfilewith executable-bit preservation so the embedded launcher remains runnable after extraction.The archive lands at
<app_path>.zip(i.e.Foo.app.zipnext toFoo.app). It is built to a sibling temp path and atomically swapped into place (issue #687), so an interrupted zip (Ctrl-C, ENOSPC) never leaves a truncated-but-openable archive, and a prior good.zipsurvives until the new one is complete.Returns the path to the produced
.zip.
Mesh Commands
Registration surface for the luxar mesh import and luxar mesh lod
commands.
The luxar mesh command group.
A pure registration surface, matching gsplat_commands.py: the Typer object plus a
series of register_*_commands calls. Command bodies live in cli/mesh_ops/.
Mesh Operations
Implementations behind luxar mesh .... Each module registers one command
family on the shared Typer application.
Thematic command groups for luxar mesh.
Mirrors cli/gsplat_ops/: each module exposes a register_*_commands(app) that
attaches its commands to the shared app_mesh Typer, so registration order in
mesh_commands.py is also --help display order.
GSplat Commands
CLI commands for fitting, converting, rendering, merging, and managing Gaussian splats.
Aggregator for the luxar gsplat command group.
The command implementations live in cli/gsplat_ops/ — one root module or
subpackage per thematic group (inspect / transforms / fitting / scene /
interchange / benchmark / batch), plus the lod recipe command in
cli/lod.py. This module builds the app_gsplat
Typer and registers each group onto it, keeping itself a thin registration
surface rather than a multi-thousand-line god-file.
main.py mounts app_gsplat.
GSplat Configuration
Configuration loading and validation for GSplat CLI commands.
Configuration system for gsplat CLI commands.
Provides: - Fitting presets (draft/standard/hifi/ultra/n2s) - YAML config loading with priority chain - Commented YAML config dump - Volume file loaders (.npy, .npz, .tiff, .zarr, imageio fallback) - OME-Zarr shape discovery - Utility parsers for CLI arguments
- class luxar.cli.gsplat_config.OMEZarrInfo(axes: ~typing.List[str], shape: ~typing.Tuple[int, ...], n_timepoints: int, n_channels: int, channel_axes: ~typing.List[str], channel_shape: ~typing.Tuple[int, ...], spatial_shape: ~typing.Tuple[int, ...], spatial_axes: ~typing.List[str], time_axis: int | None = None, channel_indices: ~typing.Tuple[int, ...] = (), spatial_indices: ~typing.Tuple[int, ...] = (), axis_units: ~typing.List[str | None] = <factory>, axis_scales: ~typing.Tuple[float, ...] | None = None, voxel_size: ~typing.Tuple[float, ...] | None = None, unit: str | None = None, resolution_levels: int = 1, path: ~pathlib.Path | None = None)[source]
Metadata about an OME-Zarr dataset’s structure.
- time_axis: int | None = None
Index into
shapeof the time axis, orNonewhen there is none.Part of the
(time_axis, channel_indices, spatial_indices)trio below: the decomposition discovery ACTUALLY used to deriven_timepoints/n_channels/spatial_shape.
- channel_indices: Tuple[int, ...] = ()
Indices into
shapeof the axes folded into the flat channel index.In the order they are folded, so
decode_flat_channel_index(c, channel_shape)maps position-for-position onto them.
- spatial_indices: Tuple[int, ...] = ()
Indices into
shapeof the spatial axes, inspatial_shapeorder.Publishing all three indices closes a standing hazard: a consumer that needs to know which axis played which role had to RE-DERIVE it from
axeswith a second vocabulary, and the vocabularies disagree in both directions. NGFF metadata is classified by thetypefield, so a channel axis namedstainis a channel here but not to any name-driven rule, while an axis typedviewfalls through to SPATIAL here but is channel-like toclassify_axis_labels(). Two vocabularies deciding the same question is how a consumer silently plans against a layout discovery never reported — read these fields instead of re-classifying the labels.
- axis_scales: Tuple[float, ...] | None = None
Composed NGFF scale for every source axis, or
Nonewhen unavailable.
- voxel_size: Tuple[float, ...] | None = None
Physical spacing from coordinateTransformations (spatial axes only).
- __init__(axes: ~typing.List[str], shape: ~typing.Tuple[int, ...], n_timepoints: int, n_channels: int, channel_axes: ~typing.List[str], channel_shape: ~typing.Tuple[int, ...], spatial_shape: ~typing.Tuple[int, ...], spatial_axes: ~typing.List[str], time_axis: int | None = None, channel_indices: ~typing.Tuple[int, ...] = (), spatial_indices: ~typing.Tuple[int, ...] = (), axis_units: ~typing.List[str | None] = <factory>, axis_scales: ~typing.Tuple[float, ...] | None = None, voxel_size: ~typing.Tuple[float, ...] | None = None, unit: str | None = None, resolution_levels: int = 1, path: ~pathlib.Path | None = None) None
- luxar.cli.gsplat_config.build_dimensions_from_data(centers: ndarray, dimension_metadata: Sequence[Mapping[str, Any]] | None = None) Any[source]
Build a
Dimensionsobject from a gsplat center bounding box.- Parameters:
centers – Splat center positions (N, D)
dimension_metadata – Optional ordered descriptors to overlay. Any length, shape, or name mismatch is ignored in favor of inferred defaults.
- Returns:
Dimensions with ranges matching the data extent
- luxar.cli.gsplat_config.decode_flat_channel_index(channel: int, channel_shape: Tuple[int, ...]) Tuple[int, ...][source]
Decode a flat channel task index into folded channel-axis coordinates.
For data with multiple non-spatial, channel-like axes (for example
cameraandchannel), batch planning treats each axis combination as one flat channel task. This helper uses row-major order to map the flat index back to per-axis coordinates.
- luxar.cli.gsplat_config.discover_ome_zarr_shape(path: Path, axes_override: List[str] | None = None, array_key: str | None = None) OMEZarrInfo[source]
Discover the shape and axis structure of an OME-Zarr dataset.
Which array is read is ONE rule shared with the two volume-loading entry points (
_select_zarr_array()), so a re-fit that re-opens a store cannot land on a different (e.g. downsampled) array than the command that read its shape.Parses that array’s NGFF
multiscalesmetadata in BOTH OME-Zarr layouts — top-level (0.4) and nested under anomekey (0.5); seeresolve_ngff_attrs(). The block is read off the GROUP THAT OWNS the selected array when that group’s block is evidence about it (_owner_ngff_attrs()), off the root otherwise. A block whoseaxescount disagrees with the SELECTED array’s ndim is not metadata about that array (a 5D image beside its 3Dlabels/…) and is skipped. Falls back to a customaxesattribute — root-first, owner second, see_usable_custom_axes()— then to a shape-based heuristic (5D→TCZYX, 4D→CZYX, 3D→ZYX) for non-NGFF zarr stores. That last fallback GUESSES the T/C roles and recovers no voxel size, so for an ambiguous (≥4D) store it says so on the console — stating whether nothing was declared or something was declared but unusable — and points ataxes_override.Accepts both plain
.zarrdirectories and.zarr.ziparchives — zarr’s ZipStore handles the latter transparently.- Parameters:
path – Path to the
.zarrstore or.zarr.ziparchive.axes_override – Explicit axis labels (e.g.
["time","channel","z","y","x"]). Overrides axis classification and names while retaining positional NGFF units/scales from the selected array when available.array_key –
Key path to a specific array within the zarr store (e.g.
"h2afva/fused"). When provided, skips auto-selection and navigates directly to this array (which may itself be a group — then its own full-resolution level is taken).The
datasets[]entry for the voxel size is matched against the array that was SELECTED, whether or not a key was passed — so a coarser pyramid level reports its own spacing, not level 0’s, and that holds for the auto path too (which can perfectly well land on a coarser level, e.g. when level 0’s declared path does not resolve). The match is exact, but on the NORMALISED spellings, so a block writing its own levels explicitly relative ("./0"for the array at"0") still names them; see_selected_dataset().
- Returns:
OMEZarrInfowith discovered metadata.- Raises:
ValueError – If the zarr store has no arrays,
array_keyis not found, or the store is unreadable.
- luxar.cli.gsplat_config.dump_default_config(preset: str = 'standard') str[source]
Generate a fully-commented YAML config with defaults from a preset.
- Parameters:
preset – Base preset to use for default values
- Returns:
YAML string with all parameters and comments
- luxar.cli.gsplat_config.get_fit_defaults() Dict[str, Any][source]
Extract default parameter values from fit_gaussian_splats signature.
- luxar.cli.gsplat_config.load_fit_config(preset: str | None = None, config_path: Path | None = None, cli_overrides: Dict[str, Any] | None = None, command_defaults: Dict[str, Any] | None = None) Dict[str, Any][source]
Build a merged fit config from preset, YAML file, and CLI overrides.
- Priority chain (highest wins):
CLI flags > YAML config > preset > command defaults > function defaults
- Parameters:
preset – Preset name (“draft”, “standard”, “hifi”, “ultra”, “n2s”) or None
config_path – Path to YAML config file or None
cli_overrides – Dict of CLI-provided values (None values are ignored)
command_defaults – Per-command defaults that displace the harvested function defaults but yield to preset / YAML / CLI (None values are ignored, so a caller can pass a sentinel-free dict). Lets one command carry a different baseline from a bare
fit— e.g. the content-tiling path’s near-losslesscull_retention.
- Returns:
Merged config dict ready to pass as
**kwargsto fit_gaussian_splats
- luxar.cli.gsplat_config.load_volume(path: Path, channel: int | None = None, timepoint: int | None = None, array_key: str | None = None, axes: str | None = None, info: Dict[str, Any] | None = None, region: Tuple[slice, ...] | None = None) ndarray[source]
CLI wrapper around
luxar.io.volume.load_volume().Converts the domain-layer
ImportError(raised when an optional reader such as tifffile/imageio is missing) into a cleantyper.Exit(1)with an install hint, so the CLI shows a friendly message instead of a traceback. All loading behaviour is identical to the domain function.
- luxar.cli.gsplat_config.parse_hex_color(s: str) Tuple[float, float, float][source]
Parse a hex color string to RGB floats.
- Parameters:
s – Hex color like “#ff0080” or “ff0080”
- Returns:
Tuple of (R, G, B) floats in [0, 1]
- luxar.cli.gsplat_config.parse_seeds(value: str | None) int | float | None[source]
Parse the –seeds CLI argument.
- Parameters:
value – “auto” | None | integer string | float string
- Returns:
None (auto), int (exact count), or float (compression ratio)
GSplat Operations
Implementations behind luxar gsplat ...: fitting/calibration (fitting/),
whole-timelapse batch fitting (batch/), post-fit editing and inspection
(transforms/ plus the scene/inspect/interchange root modules).
The package __init__ is docstring-only — the Typer groups are assembled in
luxar.cli.gsplat_commands, and each family’s registration surface is its own
commands.py.
Command-group modules for the luxar gsplat CLI.
Each thematic group exposes a register_<group>(app) (or, for batch-fit,
an app_batch sub-app) that wires its commands onto the shared app_gsplat
Typer defined in cli/gsplat_commands.py. The aggregator imports and calls
them — keeping gsplat_commands.py a thin registration surface rather than a
4.7k-line god-file.
Small groups stay as single root modules (scene_commands,
inspect_commands, interchange_commands, benchmark) alongside the
shared root helpers (recipe_shared, planner, encoding). The three
large groups are subpackages whose registration surface is commands.py:
fitting/—fit/cal/render/denoisebatch/— thebatch-fitgroup (local multi-GPU + Slurm)transforms/— edit-style commands on a fitted.gsplats.zarr
Their __init__.py files are docstring-only: importers name the owning module
directly, never a re-export root.
LOD Command
Implementation of the luxar gsplat lod command (recipe-driven LOD topology
construction).
luxar gsplat lod --recipe — build a representation topology from a fit.
A thin CLI wrapper over luxar.gsplats.lod.recipes.build_recipe(). It parses
the option superset, validates that the options given are relevant to the chosen
recipe, fills scale-derived defaults, loads the input, builds the recipe, and
writes the output .gsplats.zarr.
The single --recipe flag builds one of the intent-first topologies
(flat / stream / levels / tiles / overview / adaptive);
it replaced the historical lod additive / lod substitutive /
lod pyramid subcommands (now the stream / levels recipes).
- luxar.cli.lod.lod_recipe(input_path: Path = <typer.models.ArgumentInfo object>, output_path: Path = <typer.models.ArgumentInfo object>, recipe: str | None = <typer.models.OptionInfo object>, n_lods: int | None = <typer.models.OptionInfo object>, method: str | None = <typer.models.OptionInfo object>, legacy_method: str | None = <typer.models.OptionInfo object>, legacy_substitutive_method: str | None = <typer.models.OptionInfo object>, breakpoints: str | None = <typer.models.OptionInfo object>, target_ms: float | None = <typer.models.OptionInfo object>, bandwidth_mbps: float | None = <typer.models.OptionInfo object>, bytes_per_splat: float | None = <typer.models.OptionInfo object>, truncation_sigmas: float | None = <typer.models.OptionInfo object>, max_n_dense: int | None = <typer.models.OptionInfo object>, max_elements: int | None = <typer.models.OptionInfo object>, parts: int | None = <typer.models.OptionInfo object>, partition_rule: str | None = <typer.models.OptionInfo object>, compression_factor: int | None = <typer.models.OptionInfo object>, levels: int | None = <typer.models.OptionInfo object>, substitutive_method: str | None = <typer.models.OptionInfo object>, lloyd_iterations: int | None = <typer.models.OptionInfo object>, candidate_bins_k: int | None = <typer.models.OptionInfo object>, coverage_inflation: float | None = <typer.models.OptionInfo object>, additive_ladders: bool | None = <typer.models.OptionInfo object>, conserve_mass: bool | None = <typer.models.OptionInfo object>, refine: str | None = <typer.models.OptionInfo object>, refine_iters: int | None = <typer.models.OptionInfo object>, target_path: Path | None = <typer.models.OptionInfo object>, target_channel: int | None = <typer.models.OptionInfo object>, target_timepoint: int | None = <typer.models.OptionInfo object>, target_axes: str | None = <typer.models.OptionInfo object>, target_array_key: str | None = <typer.models.OptionInfo object>, reveal_center: str | None = <typer.models.OptionInfo object>, spatial_dims: str | None = <typer.models.OptionInfo object>, coarsen_dims: str | None = <typer.models.OptionInfo object>, quality_stamps: bool | None = <typer.models.OptionInfo object>, quality_max_pair_splats: int | None = <typer.models.OptionInfo object>, ordering: str = <typer.models.OptionInfo object>, device: str | None = <typer.models.OptionInfo object>, seed: int | None = <typer.models.OptionInfo object>, overwrite: bool = <typer.models.OptionInfo object>, encoding: str = <typer.models.OptionInfo object>, compress: str | None = <typer.models.OptionInfo object>, quiet: bool = <typer.models.OptionInfo object>) None[source]
Build a representation topology from a fitted gsplat dataset.
One recipe, scale-ordered; every recipe streams by default (each leaf carries a progressive “stream” ladder unless –no-additive). Pick by what you need:
recipe structure use when flat one bare leaf tiny data / debugging stream one leaf + progressive ladder small data, fast first paint levels coarse->fine replacement levels zooming across scales tiles spatial tiles (culled), each large scene, one scale with its own ladder overview instant coarse overview level huge scene, "see everything + fine tiles on zoom first" (detail where you look) adaptive tiles where EVERY tile picks largest scenes, locally its own detail level adaptive detailScale is not the only axis. When first paint is request-constrained, the recipe is picked by how many levels and tiles the viewer fetches EAGERLY, not by N: overview defers its whole fine branch behind a selector, while tiles and adaptive load every part at once. Measured on one 1.58 GiB 4D timelapse, an adaptive build (44 parts) needed ~18x more requests to first paint than the same data as one stacked leaf with a stream ladder.
(Renamed 2026-07: additive->stream, substitutive/pyramid->levels, partitioned->tiles, multiscale->overview, mosaic->adaptive.)
Input must be a fitted / flat .gsplats.zarr (output of
luxar gsplat fit). Canonical pipeline:cal->fit --seeds K*->lod --recipe ...... admonition:: Examples
luxar gsplat lod fit.gsplats.zarr out.gsplats.zarr –recipe stream –n-lods 6
- luxar gsplat lod fit.gsplats.zarr out.gsplats.zarr –recipe tiles
–max-elements 250000
- luxar gsplat lod fit.gsplats.zarr out.gsplats.zarr –recipe overview
–compression-factor 8
Info Command
Implementation of the luxar info dataset-inspection command.
The luxar info command — inspect a Zarr scene’s hierarchy and stats.
Registered onto the root Typer app by register_info_command() (mirroring
the cli/gsplat_ops/*::register_*_commands pattern), so cli/main.py stays
a thin entry point. _print_tree / _dfs are module-level helpers;
_dfs is re-exported from cli/main.py for the existing test import.
- luxar.cli.info_command.register_info_command(app: Typer) None[source]
Attach the
infocommand toapp.
- luxar.cli.info_command.HTTP1_RTT_BOUND_CHUNK_BYTES = 32768
Mean chunk payload below which a full load is dominated by per-request round trips on an HTTP/1.1 host (six connections, no multiplexing). Distinct from
MIN_CHUNK_BYTES(the optimizer’s re-chunk floor): a 20 KB chunk is above the floor and still costs a 30 ms RTT per 20 KB on such a host.
Optimize Command
Implementation of the luxar optimize re-chunking command — a thin Typer
layer over luxar.io.optimize, which owns every decision about what a
re-chunk may change and what it must not.
The luxar optimize command — re-chunk an existing store for streaming.
A thin Typer layer over luxar.io.optimize; every decision about what may
change and what must not lives there. Registered onto the root app by
register_optimize_command(), mirroring info_command.py.