leika 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
leika/__init__.py ADDED
@@ -0,0 +1,49 @@
1
+ from ._gui_api import GuiApi as GuiApi
2
+ from ._gui_handles import CommandEvent as CommandEvent
3
+ from ._gui_handles import CommandHandle as CommandHandle
4
+ from ._gui_handles import GuiButtonGroupHandle as GuiButtonGroupHandle
5
+ from ._gui_handles import GuiButtonHandle as GuiButtonHandle
6
+ from ._gui_handles import GuiCheckboxHandle as GuiCheckboxHandle
7
+ from ._gui_handles import GuiContainer as GuiContainer
8
+ from ._gui_handles import GuiDividerHandle as GuiDividerHandle
9
+ from ._gui_handles import GuiDropdownHandle as GuiDropdownHandle
10
+ from ._gui_handles import GuiEvent as GuiEvent
11
+ from ._gui_handles import GuiFolderHandle as GuiFolderHandle
12
+ from ._gui_handles import GuiFormHandle as GuiFormHandle
13
+ from ._gui_handles import GuiHtmlHandle as GuiHtmlHandle
14
+ from ._gui_handles import GuiImageHandle as GuiImageHandle
15
+ from ._gui_handles import GuiInputHandle as GuiInputHandle
16
+ from ._gui_handles import GuiMarkdownHandle as GuiMarkdownHandle
17
+ from ._gui_handles import GuiModalHandle as GuiModalHandle
18
+ from ._gui_handles import GuiMultiSliderHandle as GuiMultiSliderHandle
19
+ from ._gui_handles import GuiNumberHandle as GuiNumberHandle
20
+ from ._gui_handles import GuiPlotlyHandle as GuiPlotlyHandle
21
+ from ._gui_handles import GuiProgressBarHandle as GuiProgressBarHandle
22
+ from ._gui_handles import GuiRgbaHandle as GuiRgbaHandle
23
+ from ._gui_handles import GuiRgbHandle as GuiRgbHandle
24
+ from ._gui_handles import GuiSliderHandle as GuiSliderHandle
25
+ from ._gui_handles import GuiTabGroupHandle as GuiTabGroupHandle
26
+ from ._gui_handles import GuiTabHandle as GuiTabHandle
27
+ from ._gui_handles import GuiTextHandle as GuiTextHandle
28
+ from ._gui_handles import GuiUploadButtonHandle as GuiUploadButtonHandle
29
+ from ._gui_handles import GuiUplotHandle as GuiUplotHandle
30
+ from ._gui_handles import GuiVector2Handle as GuiVector2Handle
31
+ from ._gui_handles import GuiVector3Handle as GuiVector3Handle
32
+ from ._gui_handles import UploadedFile as UploadedFile
33
+ from ._icons_enum import Icon as Icon
34
+ from ._icons_enum import IconName as IconName
35
+ from ._panes import ImageFit as ImageFit
36
+ from ._panes import ImagePaneHandle as ImagePaneHandle
37
+ from ._panes import PaneGrid as PaneGrid
38
+ from ._panes import PaneGroup as PaneGroup
39
+ from ._panes import PaneHandle as PaneHandle
40
+ from ._panes import PaneId as PaneId
41
+ from ._panes import Panes as Panes
42
+ from ._panes import Placement as Placement
43
+ from ._panes import PlotlyPaneHandle as PlotlyPaneHandle
44
+ from ._server import ClientHandle as ClientHandle
45
+ from ._server import Server as Server
46
+
47
+ GuiTabGroup = GuiTabGroupHandle
48
+
49
+ __version__ = "0.1.0"
@@ -0,0 +1,160 @@
1
+ from __future__ import annotations
2
+
3
+ import abc
4
+ from functools import cached_property
5
+ from typing import Any, Dict, Generic, Protocol, TypeVar, get_type_hints
6
+
7
+ import numpy as np
8
+ import numpy.typing as npt
9
+
10
+ # Type variable for props.
11
+
12
+
13
+ class HasProps(Protocol):
14
+ props: Any # One of the `*Props` objects in _messages.py.
15
+ removed: bool # Lifecycle flag; see AssignablePropsBase guard below.
16
+
17
+
18
+ TImpl = TypeVar("TImpl", bound=HasProps)
19
+
20
+
21
+ def colors_to_uint8(colors: np.ndarray) -> npt.NDArray[np.uint8]:
22
+ """Convert intensity values to uint8. We assume the range [0,1] for floats, and
23
+ [0,255] for integers. Accepts any shape."""
24
+ if colors.dtype != np.uint8:
25
+ if np.issubdtype(colors.dtype, np.floating):
26
+ colors = np.clip(colors * 255.0, 0, 255).astype(np.uint8)
27
+ if np.issubdtype(colors.dtype, np.integer):
28
+ colors = np.clip(colors, 0, 255).astype(np.uint8)
29
+ return colors
30
+
31
+
32
+ class AssignablePropsBase(Generic[TImpl]):
33
+ """Base class for all API objects with assignable properties."""
34
+
35
+ _impl: TImpl
36
+
37
+ def __init__(self, impl: TImpl):
38
+ # Make sure arrays are copied to avoid shared references.
39
+ # This will also make sure that our `np.array_equal` checks below work
40
+ # correctly.
41
+ for k, v in vars(impl.props).items():
42
+ if isinstance(v, np.ndarray):
43
+ setattr(impl.props, k, v.copy())
44
+
45
+ # Store the implementation object.
46
+ self._impl = impl
47
+
48
+ def _cast_value_recursive(self, hint: Any, value: Any, prop_name: str) -> Any:
49
+ """Recursively cast values to match type hints, handling arrays and tuples."""
50
+ # Handle numpy arrays.
51
+ if hint == npt.NDArray[np.float16]:
52
+ return np.asarray(value).astype(np.float16)
53
+ elif hint == npt.NDArray[np.float32]:
54
+ return np.asarray(value).astype(np.float32)
55
+ elif hint == npt.NDArray[np.float64]:
56
+ return np.asarray(value).astype(np.float64)
57
+ elif hint == npt.NDArray[np.uint8] and "color" in prop_name:
58
+ return colors_to_uint8(value)
59
+ if isinstance(value, np.ndarray):
60
+ return value
61
+
62
+ # Handle tuple[T, ...] pattern.
63
+ if (
64
+ isinstance(value, tuple)
65
+ and hasattr(hint, "__origin__")
66
+ and hint.__origin__ is tuple
67
+ and hasattr(hint, "__args__")
68
+ and len(hint.__args__) == 2
69
+ and hint.__args__[1] is ...
70
+ ):
71
+ element_type = hint.__args__[0]
72
+ return tuple(
73
+ self._cast_value_recursive(element_type, item, prop_name) for item in value
74
+ )
75
+
76
+ return value
77
+
78
+ @cached_property
79
+ def _prop_hints(self) -> Dict[str, Any]:
80
+ return get_type_hints(type(self._impl.props))
81
+
82
+ @abc.abstractmethod
83
+ def _queue_update(self, name: str, value: Any) -> None:
84
+ """Queue an update message with the property change."""
85
+
86
+ def _on_prop_assigned(self, name: str) -> None:
87
+ """Hook called after a props field is assigned and its update is queued.
88
+ Subclasses can override to enforce cross-field invariants (e.g. keeping
89
+ an array's dtype in sync with another field). No-op by default."""
90
+
91
+ def __setattr__(self, name: str, value: Any) -> None:
92
+ if name == "_impl":
93
+ return object.__setattr__(self, name, value)
94
+
95
+ prop = getattr(self.__class__, name, None)
96
+ prop_setter = prop.fset if isinstance(prop, property) and prop.fset is not None else None
97
+ is_prop_hint = name in self._prop_hints
98
+
99
+ # Reject property writes after the handle has been removed. This prevents
100
+ # stale update messages from being queued against a no-longer-registered
101
+ # entity, which would otherwise re-introduce it on the client (ghost
102
+ # entity) because the client blindly processes incoming updates. Guard
103
+ # both the @property.setter path (e.g. `value` on GUI input handles) and
104
+ # the _prop_hints path (generic props forwarded to _queue_update).
105
+ if (prop_setter is not None or is_prop_hint) and self._impl.removed:
106
+ raise RuntimeError(f"Cannot assign to {name!r} on a removed {type(self).__name__}.")
107
+
108
+ if prop_setter is not None:
109
+ prop_setter(self, value)
110
+ return
111
+
112
+ # Try to handle as a props field.
113
+ if is_prop_hint:
114
+ # Handle type casting (arrays, tuples of arrays, etc.).
115
+ value = self._cast_value_recursive(self._prop_hints[name], value, name)
116
+ current_value = getattr(self._impl.props, name)
117
+
118
+ # Skip update if value hasn't changed.
119
+ try:
120
+ hash(current_value)
121
+ if current_value == value:
122
+ return
123
+ except (TypeError, ValueError):
124
+ pass
125
+
126
+ # Update the value based on type.
127
+ if isinstance(value, np.ndarray):
128
+ if hasattr(current_value, "dtype"):
129
+ # Ensure consistent dtype.
130
+ if value.dtype != current_value.dtype:
131
+ value = value.astype(current_value.dtype)
132
+ if np.array_equal(current_value, value):
133
+ return
134
+
135
+ # In-place update for same shape arrays.
136
+ if hasattr(current_value, "shape") and value.shape == current_value.shape:
137
+ current_value[:] = value
138
+ else:
139
+ setattr(self._impl.props, name, value.copy())
140
+ # Queue a private snapshot for the wire. It must alias NEITHER the
141
+ # caller's ``value`` (which the caller may mutate after assignment,
142
+ # e.g. an animation loop reusing one buffer) NOR the server's stored
143
+ # array (which a later same-shape update mutates in place, possibly
144
+ # while the event-loop thread is still serializing this message).
145
+ queued: Any = value.copy()
146
+ else:
147
+ # Non-array properties (immutable / already a fresh cast).
148
+ setattr(self._impl.props, name, value)
149
+ queued = value
150
+ else:
151
+ return object.__setattr__(self, name, value)
152
+
153
+ self._queue_update(name, queued)
154
+ self._on_prop_assigned(name)
155
+
156
+ def __getattr__(self, name: str) -> Any:
157
+ if name in self._prop_hints:
158
+ return getattr(self._impl.props, name)
159
+ else:
160
+ raise AttributeError(f"'{self.__class__.__name__}' object has no attribute '{name}'")
@@ -0,0 +1,208 @@
1
+ import argparse
2
+ import os
3
+ import shutil
4
+ import subprocess
5
+ import sys
6
+ from pathlib import Path
7
+
8
+ client_dir = Path(__file__).resolve().parent / "client"
9
+ build_dir = client_dir / "build"
10
+
11
+
12
+ def _is_editable_install() -> bool:
13
+ """Check if Leika is installed in editable mode.
14
+
15
+ In editable installs, __file__ is in src/leika/ within the source tree.
16
+ In regular installs, __file__ is in site-packages/leika/.
17
+ """
18
+ leika_dir = Path(__file__).parent
19
+ return leika_dir.name == "leika" and leika_dir.parent.name == "src"
20
+
21
+
22
+ def _check_leika_dev_running() -> bool:
23
+ """Returns True if the viewer client has been launched via `npm run dev`."""
24
+ try:
25
+ import psutil
26
+ except ImportError:
27
+ # If psutil is not installed, we can't check for dev server.
28
+ # This is fine for normal usage - only needed for development.
29
+ return False
30
+
31
+ for process in psutil.process_iter():
32
+ try:
33
+ # Check if the process is running from the correct Leika client directory
34
+ # and is actually a vite dev server (not just any vite command).
35
+ cwd = Path(process.cwd()).resolve()
36
+ expected_cwd = client_dir.resolve()
37
+
38
+ if cwd == expected_cwd:
39
+ cmdline = process.cmdline()
40
+ # Check for vite with --host flag (which is our dev command)
41
+ # Make sure it's not a build command.
42
+ has_vite = any("vite" in part for part in cmdline)
43
+ has_host = any("--host" in part for part in cmdline)
44
+ not_build = not any("build" in part for part in cmdline)
45
+
46
+ if has_vite and has_host and not_build:
47
+ return True
48
+ except (psutil.AccessDenied, psutil.ZombieProcess, psutil.NoSuchProcess):
49
+ pass
50
+ return False
51
+
52
+
53
+ def ensure_client_is_built() -> None:
54
+ """Ensure that the client is built or already running."""
55
+
56
+ # For non-editable installs, just verify the build exists.
57
+ # Skip timestamp checks and dev server detection.
58
+ if not _is_editable_install() and (build_dir / "index.html").exists():
59
+ return
60
+
61
+ if not (client_dir / "src").exists():
62
+ # Can't build client.
63
+ assert (build_dir / "index.html").exists(), (
64
+ "Something went wrong! At least one of the client source or build"
65
+ " directories should be present."
66
+ )
67
+ return
68
+
69
+ # Do we need to re-trigger a build?
70
+ build = False
71
+ if _check_leika_dev_running():
72
+ # Don't run build if dev server is already running.
73
+ import rich
74
+
75
+ rich.print(
76
+ "[bold](leika)[/bold] The Leika viewer looks like it has been launched via"
77
+ " `npm run dev`. Skipping build check..."
78
+ )
79
+ build = False
80
+ elif not (build_dir / "index.html").exists():
81
+ import rich
82
+
83
+ rich.print("[bold](leika)[/bold] No client build found. Building now...")
84
+ build = True
85
+ elif (
86
+ # We should be at least 10 seconds newer than the last build.
87
+ # This buffer is important when we install from pip, and the src/ +
88
+ # build/ directories have very similar timestamps.
89
+ _modified_time_recursive(client_dir / "src") > _modified_time_recursive(build_dir) + 10.0
90
+ ):
91
+ import rich
92
+
93
+ rich.print("[bold](leika)[/bold] Client build looks out of date. Building now...")
94
+ build = True
95
+
96
+ # Install nodejs and build if necessary. We assume bash is installed.
97
+ if build:
98
+ _build_leika_client(out_dir=build_dir, cached=False)
99
+
100
+
101
+ def _build_leika_client(out_dir: Path, cached: bool = True) -> None:
102
+ """Create a build of the Leika client.
103
+
104
+ Args:
105
+ out_dir: The directory to write the built client to.
106
+ cached: If True, skip the build if the client is already built.
107
+ Instead, we'll simply copy the previous build to the new location.
108
+ """
109
+
110
+ if cached and build_dir.exists() and (build_dir / "index.html").exists():
111
+ import rich
112
+
113
+ rich.print(f"[bold](leika)[/bold] Copying client build from {build_dir} to {out_dir}.")
114
+ shutil.copytree(build_dir, out_dir)
115
+ return
116
+
117
+ node_bin_dir = _install_sandboxed_node()
118
+ npx_path = node_bin_dir / "npx"
119
+
120
+ subprocess_env = os.environ.copy()
121
+ subprocess_env["NODE_VIRTUAL_ENV"] = str(node_bin_dir.parent)
122
+ subprocess_env["PATH"] = (
123
+ str(node_bin_dir) + (";" if sys.platform == "win32" else ":") + subprocess_env["PATH"]
124
+ )
125
+ npm_path = node_bin_dir / "npm"
126
+
127
+ if sys.platform == "win32":
128
+ npx_path = npx_path.with_suffix(".cmd")
129
+ npm_path = npm_path.with_suffix(".cmd")
130
+
131
+ subprocess.run(
132
+ args=[str(npm_path), "install"],
133
+ env=subprocess_env,
134
+ cwd=client_dir,
135
+ check=False,
136
+ )
137
+ subprocess.run(
138
+ args=[
139
+ str(npx_path),
140
+ "vite",
141
+ "build",
142
+ "--base",
143
+ "./",
144
+ "--outDir",
145
+ # Relative path needs to be made absolute, since we change the CWD.
146
+ str(out_dir.absolute()),
147
+ ],
148
+ env=subprocess_env,
149
+ cwd=client_dir,
150
+ check=False,
151
+ )
152
+
153
+
154
+ def build_client_entrypoint() -> None:
155
+ """Build the Leika client entrypoint, which is used to launch the viewer."""
156
+ parser = argparse.ArgumentParser(description="Build the Leika client.")
157
+ parser.add_argument("--out-dir", required=True)
158
+ parser.add_argument(
159
+ "--no-cached",
160
+ action="store_false",
161
+ help="If set, skip the build if the client is already built.",
162
+ )
163
+ args = parser.parse_args()
164
+ out_dir = Path(args.out_dir) if args.out_dir else build_dir
165
+ _build_leika_client(out_dir=out_dir, cached=args.no_cached)
166
+
167
+
168
+ def _install_sandboxed_node() -> Path:
169
+ """Install a sandboxed copy of nodejs using nodeenv, and return a path to the
170
+ environment's bin directory (`.nodeenv/bin` or `.nodeenv/Scripts`).
171
+
172
+ On Windows, the `.nodeenv/bin` does not exist. Instead, executables are
173
+ installed to `.nodeenv/Scripts`."""
174
+
175
+ def get_node_bin_dir() -> Path:
176
+ env_dir = client_dir / ".nodeenv"
177
+ node_bin_dir = env_dir / "bin"
178
+ if not node_bin_dir.exists():
179
+ node_bin_dir = env_dir / "Scripts"
180
+ return node_bin_dir
181
+
182
+ node_bin_dir = get_node_bin_dir()
183
+ if (node_bin_dir / "npx").exists():
184
+ import rich
185
+
186
+ rich.print("[bold](leika)[/bold] nodejs is set up!")
187
+ return node_bin_dir
188
+
189
+ env_dir = client_dir / ".nodeenv"
190
+ result = subprocess.run(
191
+ [sys.executable, "-m", "nodeenv", "--node=24.12.0", env_dir], check=False
192
+ )
193
+
194
+ if result.returncode != 0:
195
+ raise RuntimeError(
196
+ "Failed to install Node.js using nodeenv. "
197
+ "To rebuild the Leika client, install nodeenv with: "
198
+ "pip install 'nodeenv>=1.9.1'"
199
+ )
200
+
201
+ node_bin_dir = get_node_bin_dir()
202
+ assert (node_bin_dir / "npx").exists()
203
+ return node_bin_dir
204
+
205
+
206
+ def _modified_time_recursive(dir: Path) -> float:
207
+ """Recursively get the last time a file was modified in a directory."""
208
+ return max([f.stat().st_mtime for f in dir.glob("**/*")])