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 +49 -0
- leika/_assignable_props_api.py +160 -0
- leika/_client_autobuild.py +208 -0
- leika/_gui_api.py +2241 -0
- leika/_gui_handles.py +1367 -0
- leika/_icons/lucide-icons.zip +0 -0
- leika/_icons.py +28 -0
- leika/_icons_enum.py +2021 -0
- leika/_icons_enum.pyi +2010 -0
- leika/_icons_generate_enum.py +128 -0
- leika/_image_encoding.py +86 -0
- leika/_licenses/base-ui-LICENSE.txt +21 -0
- leika/_licenses/cmdk-next-themes-MIT-LICENSE.txt +21 -0
- leika/_licenses/geist-OFL.txt +93 -0
- leika/_licenses/lucide-LICENSE.txt +43 -0
- leika/_licenses/shadcn-io-PROVENANCE.md +19 -0
- leika/_licenses/shadcn-ui-LICENSE.md +21 -0
- leika/_licenses/shadcn-ui-PROVENANCE.md +18 -0
- leika/_messages.py +1045 -0
- leika/_notification_handle.py +70 -0
- leika/_panes.py +880 -0
- leika/_server.py +446 -0
- leika/_threadpool_exceptions.py +19 -0
- leika/client/build/index.html +20 -0
- leika/infra/__init__.py +24 -0
- leika/infra/_async_message_buffer.py +191 -0
- leika/infra/_infra.py +704 -0
- leika/infra/_messages.py +236 -0
- leika/infra/_typescript_interface_gen.py +217 -0
- leika/py.typed +0 -0
- leika/theme/__init__.py +6 -0
- leika/theme/_titlebar.py +25 -0
- leika/uplot/__init__.py +26 -0
- leika/uplot/_generate_types.py +1739 -0
- leika/uplot/_uplot_types.py +601 -0
- leika/uplot/uPlot.d.ts +1591 -0
- leika-0.1.0.dist-info/METADATA +315 -0
- leika-0.1.0.dist-info/RECORD +41 -0
- leika-0.1.0.dist-info/WHEEL +4 -0
- leika-0.1.0.dist-info/entry_points.txt +2 -0
- leika-0.1.0.dist-info/licenses/LICENSE +201 -0
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("**/*")])
|