spectra-plot 0.2.0__tar.gz → 0.2.1__tar.gz
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.
- {spectra_plot-0.2.0/spectra_plot.egg-info → spectra_plot-0.2.1}/PKG-INFO +1 -1
- spectra_plot-0.2.1/VERSION +1 -0
- {spectra_plot-0.2.0 → spectra_plot-0.2.1}/pyproject.toml +3 -0
- spectra_plot-0.2.1/spectra/_cli.py +61 -0
- spectra_plot-0.2.1/spectra/_download.py +191 -0
- {spectra_plot-0.2.0 → spectra_plot-0.2.1}/spectra/_easy.py +18 -0
- {spectra_plot-0.2.0 → spectra_plot-0.2.1}/spectra/_launcher.py +66 -10
- {spectra_plot-0.2.0 → spectra_plot-0.2.1/spectra_plot.egg-info}/PKG-INFO +1 -1
- {spectra_plot-0.2.0 → spectra_plot-0.2.1}/spectra_plot.egg-info/SOURCES.txt +3 -0
- spectra_plot-0.2.1/spectra_plot.egg-info/entry_points.txt +2 -0
- spectra_plot-0.2.1/tests/test_download.py +110 -0
- {spectra_plot-0.2.0 → spectra_plot-0.2.1}/tests/test_easy.py +1 -1
- {spectra_plot-0.2.0 → spectra_plot-0.2.1}/tests/test_phase2.py +1 -1
- spectra_plot-0.2.0/VERSION +0 -1
- spectra_plot-0.2.0/spectra/_cli.py +0 -23
- {spectra_plot-0.2.0 → spectra_plot-0.2.1}/MANIFEST.in +0 -0
- {spectra_plot-0.2.0 → spectra_plot-0.2.1}/setup.cfg +0 -0
- {spectra_plot-0.2.0 → spectra_plot-0.2.1}/spectra/__init__.py +0 -0
- {spectra_plot-0.2.0 → spectra_plot-0.2.1}/spectra/_animation.py +0 -0
- {spectra_plot-0.2.0 → spectra_plot-0.2.1}/spectra/_axes.py +0 -0
- {spectra_plot-0.2.0 → spectra_plot-0.2.1}/spectra/_blob.py +0 -0
- {spectra_plot-0.2.0 → spectra_plot-0.2.1}/spectra/_codec.py +0 -0
- {spectra_plot-0.2.0 → spectra_plot-0.2.1}/spectra/_embed.py +0 -0
- {spectra_plot-0.2.0 → spectra_plot-0.2.1}/spectra/_errors.py +0 -0
- {spectra_plot-0.2.0 → spectra_plot-0.2.1}/spectra/_figure.py +0 -0
- {spectra_plot-0.2.0 → spectra_plot-0.2.1}/spectra/_log.py +0 -0
- {spectra_plot-0.2.0 → spectra_plot-0.2.1}/spectra/_persistence.py +0 -0
- {spectra_plot-0.2.0 → spectra_plot-0.2.1}/spectra/_protocol.py +0 -0
- {spectra_plot-0.2.0 → spectra_plot-0.2.1}/spectra/_series.py +0 -0
- {spectra_plot-0.2.0 → spectra_plot-0.2.1}/spectra/_session.py +0 -0
- {spectra_plot-0.2.0 → spectra_plot-0.2.1}/spectra/_transport.py +0 -0
- {spectra_plot-0.2.0 → spectra_plot-0.2.1}/spectra/backends/__init__.py +0 -0
- {spectra_plot-0.2.0 → spectra_plot-0.2.1}/spectra/backends/_qt_compat.py +0 -0
- {spectra_plot-0.2.0 → spectra_plot-0.2.1}/spectra/backends/backend_qtagg.py +0 -0
- {spectra_plot-0.2.0 → spectra_plot-0.2.1}/spectra/embed.py +0 -0
- {spectra_plot-0.2.0 → spectra_plot-0.2.1}/spectra_plot.egg-info/dependency_links.txt +0 -0
- {spectra_plot-0.2.0 → spectra_plot-0.2.1}/spectra_plot.egg-info/requires.txt +0 -0
- {spectra_plot-0.2.0 → spectra_plot-0.2.1}/spectra_plot.egg-info/top_level.txt +0 -0
- {spectra_plot-0.2.0 → spectra_plot-0.2.1}/tests/test_codec.py +0 -0
- {spectra_plot-0.2.0 → spectra_plot-0.2.1}/tests/test_cross_codec.py +0 -0
- {spectra_plot-0.2.0 → spectra_plot-0.2.1}/tests/test_easy_embed.py +0 -0
- {spectra_plot-0.2.0 → spectra_plot-0.2.1}/tests/test_embed.py +0 -0
- {spectra_plot-0.2.0 → spectra_plot-0.2.1}/tests/test_phase3.py +0 -0
- {spectra_plot-0.2.0 → spectra_plot-0.2.1}/tests/test_phase4.py +0 -0
- {spectra_plot-0.2.0 → spectra_plot-0.2.1}/tests/test_phase5.py +0 -0
- {spectra_plot-0.2.0 → spectra_plot-0.2.1}/tests/test_qt_backend.py +0 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
0.2.1
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""Command-line entry points for spectra."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import sys
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def backend_main():
|
|
8
|
+
"""Entry point for 'spectra-backend' console script.
|
|
9
|
+
|
|
10
|
+
Forwards all arguments to the bundled spectra-backend binary.
|
|
11
|
+
Supports --download to pre-fetch the binary without running it.
|
|
12
|
+
"""
|
|
13
|
+
# Handle --download: fetch binary and exit
|
|
14
|
+
if len(sys.argv) > 1 and sys.argv[1] == "--download":
|
|
15
|
+
from ._download import download_backend
|
|
16
|
+
try:
|
|
17
|
+
path = download_backend()
|
|
18
|
+
print(f"spectra-backend ready: {path}")
|
|
19
|
+
except Exception as exc:
|
|
20
|
+
print(f"Download failed: {exc}", file=sys.stderr)
|
|
21
|
+
sys.exit(1)
|
|
22
|
+
sys.exit(0)
|
|
23
|
+
|
|
24
|
+
from ._launcher import _find_backend_binary
|
|
25
|
+
|
|
26
|
+
binary = _find_backend_binary()
|
|
27
|
+
|
|
28
|
+
# If not found locally, try auto-download
|
|
29
|
+
if binary is None:
|
|
30
|
+
try:
|
|
31
|
+
from ._download import download_backend
|
|
32
|
+
download_backend()
|
|
33
|
+
binary = _find_backend_binary()
|
|
34
|
+
except Exception as exc:
|
|
35
|
+
print(f"Auto-download failed: {exc}", file=sys.stderr)
|
|
36
|
+
|
|
37
|
+
if binary is None:
|
|
38
|
+
print(
|
|
39
|
+
"spectra-backend native binary not found.\n"
|
|
40
|
+
"Install the spectra-plot wheel (includes the binary), "
|
|
41
|
+
"set SPECTRA_BACKEND_PATH, or build with CMake "
|
|
42
|
+
"(cmake -B build -DSPECTRA_RUNTIME_MODE=multiproc).\n"
|
|
43
|
+
"Or run: spectra-backend --download",
|
|
44
|
+
file=sys.stderr,
|
|
45
|
+
)
|
|
46
|
+
sys.exit(1)
|
|
47
|
+
|
|
48
|
+
# Safety: the binary must be a native executable, not this script.
|
|
49
|
+
# _find_backend_binary already filters non-native binaries, but
|
|
50
|
+
# guard against future regressions that could cause an exec loop.
|
|
51
|
+
resolved = os.path.realpath(binary)
|
|
52
|
+
this_script = os.path.realpath(sys.argv[0])
|
|
53
|
+
if resolved == this_script:
|
|
54
|
+
print(
|
|
55
|
+
"spectra-backend: refusing to exec self (would loop). "
|
|
56
|
+
"Set SPECTRA_BACKEND_PATH to the native binary.",
|
|
57
|
+
file=sys.stderr,
|
|
58
|
+
)
|
|
59
|
+
sys.exit(1)
|
|
60
|
+
|
|
61
|
+
os.execv(binary, [binary] + sys.argv[1:])
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
"""Download pre-built spectra-backend binaries from GitHub Releases.
|
|
2
|
+
|
|
3
|
+
When spectra-plot is installed as a pure-Python package (e.g. via sdist or
|
|
4
|
+
editable install), no compiled backend is bundled. This module downloads the
|
|
5
|
+
correct platform-specific binary on first use and caches it locally.
|
|
6
|
+
|
|
7
|
+
Cache location (respects XDG / platform conventions):
|
|
8
|
+
Linux : $XDG_DATA_HOME/spectra/bin/ or ~/.local/share/spectra/bin/
|
|
9
|
+
macOS : ~/Library/Application Support/spectra/bin/
|
|
10
|
+
Windows : %LOCALAPPDATA%/spectra/bin/
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
import io
|
|
14
|
+
import os
|
|
15
|
+
import platform
|
|
16
|
+
import stat
|
|
17
|
+
import sys
|
|
18
|
+
import tarfile
|
|
19
|
+
import zipfile
|
|
20
|
+
from typing import Optional, Tuple
|
|
21
|
+
from urllib.error import URLError
|
|
22
|
+
from urllib.request import urlopen, Request
|
|
23
|
+
|
|
24
|
+
# ── Constants ────────────────────────────────────────────────────────────────
|
|
25
|
+
|
|
26
|
+
GITHUB_REPO = "danlil240/Spectra"
|
|
27
|
+
_ASSET_PREFIX = "spectra-backend-"
|
|
28
|
+
|
|
29
|
+
# Map (system, machine) to the asset suffix used in GitHub Releases.
|
|
30
|
+
_PLATFORM_MAP = {
|
|
31
|
+
("Linux", "x86_64"): "linux-x86_64",
|
|
32
|
+
("Linux", "aarch64"): "linux-aarch64",
|
|
33
|
+
("Darwin", "arm64"): "macos-arm64",
|
|
34
|
+
("Darwin", "x86_64"): "macos-x86_64",
|
|
35
|
+
("Windows", "AMD64"): "windows-x86_64",
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _detect_platform() -> str:
|
|
40
|
+
"""Return the platform tag (e.g. 'linux-x86_64')."""
|
|
41
|
+
key = (platform.system(), platform.machine())
|
|
42
|
+
tag = _PLATFORM_MAP.get(key)
|
|
43
|
+
if tag is None:
|
|
44
|
+
raise RuntimeError(
|
|
45
|
+
f"No pre-built spectra-backend available for {key[0]}/{key[1]}. "
|
|
46
|
+
"Build from source: cmake -B build -DSPECTRA_RUNTIME_MODE=multiproc"
|
|
47
|
+
)
|
|
48
|
+
return tag
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _cache_dir() -> str:
|
|
52
|
+
"""Return the platform-appropriate cache directory for Spectra binaries."""
|
|
53
|
+
system = platform.system()
|
|
54
|
+
if system == "Linux":
|
|
55
|
+
base = os.environ.get("XDG_DATA_HOME", os.path.expanduser("~/.local/share"))
|
|
56
|
+
elif system == "Darwin":
|
|
57
|
+
base = os.path.expanduser("~/Library/Application Support")
|
|
58
|
+
elif system == "Windows":
|
|
59
|
+
base = os.environ.get("LOCALAPPDATA", os.path.expanduser("~/AppData/Local"))
|
|
60
|
+
else:
|
|
61
|
+
base = os.path.expanduser("~/.local/share")
|
|
62
|
+
return os.path.join(base, "spectra", "bin")
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _read_version() -> str:
|
|
66
|
+
"""Read the package version to match against GitHub Release tags."""
|
|
67
|
+
try:
|
|
68
|
+
from importlib.metadata import version
|
|
69
|
+
return version("spectra-plot")
|
|
70
|
+
except Exception:
|
|
71
|
+
pass
|
|
72
|
+
# Fallback: read VERSION file shipped next to this module
|
|
73
|
+
version_file = os.path.join(os.path.dirname(__file__), "..", "VERSION")
|
|
74
|
+
if os.path.isfile(version_file):
|
|
75
|
+
return open(version_file).read().strip()
|
|
76
|
+
return ""
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _asset_url(tag: str, plat: str) -> Tuple[str, str]:
|
|
80
|
+
"""Return (url, archive_name) for the asset.
|
|
81
|
+
|
|
82
|
+
Archive naming convention set by CI:
|
|
83
|
+
spectra-backend-{platform}.tar.gz (Linux/macOS)
|
|
84
|
+
spectra-backend-{platform}.zip (Windows)
|
|
85
|
+
"""
|
|
86
|
+
ext = "zip" if plat.startswith("windows") else "tar.gz"
|
|
87
|
+
name = f"{_ASSET_PREFIX}{plat}.{ext}"
|
|
88
|
+
url = f"https://github.com/{GITHUB_REPO}/releases/download/v{tag}/{name}"
|
|
89
|
+
return url, name
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _download_and_extract(url: str, dest_dir: str) -> None:
|
|
93
|
+
"""Download an archive from *url* and extract binaries into *dest_dir*."""
|
|
94
|
+
req = Request(url, headers={"Accept": "application/octet-stream"})
|
|
95
|
+
try:
|
|
96
|
+
resp = urlopen(req, timeout=60) # noqa: S310 — URL is hardcoded to GitHub
|
|
97
|
+
data = resp.read()
|
|
98
|
+
except URLError as exc:
|
|
99
|
+
raise RuntimeError(
|
|
100
|
+
f"Failed to download spectra-backend from {url}: {exc}"
|
|
101
|
+
) from exc
|
|
102
|
+
|
|
103
|
+
os.makedirs(dest_dir, mode=0o755, exist_ok=True)
|
|
104
|
+
|
|
105
|
+
if url.endswith(".tar.gz"):
|
|
106
|
+
with tarfile.open(fileobj=io.BytesIO(data), mode="r:gz") as tf:
|
|
107
|
+
# Only extract known binary names to avoid path traversal
|
|
108
|
+
for member in tf.getmembers():
|
|
109
|
+
basename = os.path.basename(member.name)
|
|
110
|
+
if basename in ("spectra-backend", "spectra-window"):
|
|
111
|
+
member.name = basename # flatten into dest_dir
|
|
112
|
+
tf.extract(member, dest_dir)
|
|
113
|
+
_make_executable(os.path.join(dest_dir, basename))
|
|
114
|
+
elif url.endswith(".zip"):
|
|
115
|
+
with zipfile.ZipFile(io.BytesIO(data)) as zf:
|
|
116
|
+
for info in zf.infolist():
|
|
117
|
+
basename = os.path.basename(info.filename)
|
|
118
|
+
if basename in ("spectra-backend.exe", "spectra-window.exe",
|
|
119
|
+
"spectra-backend", "spectra-window"):
|
|
120
|
+
target = os.path.join(dest_dir, basename)
|
|
121
|
+
with zf.open(info) as src, open(target, "wb") as dst:
|
|
122
|
+
dst.write(src.read())
|
|
123
|
+
_make_executable(target)
|
|
124
|
+
else:
|
|
125
|
+
raise RuntimeError(f"Unknown archive format: {url}")
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _make_executable(path: str) -> None:
|
|
129
|
+
"""chmod +x on non-Windows."""
|
|
130
|
+
if platform.system() != "Windows":
|
|
131
|
+
st = os.stat(path)
|
|
132
|
+
os.chmod(path, st.st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _version_marker(cache: str) -> str:
|
|
136
|
+
return os.path.join(cache, ".version")
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def find_cached_backend() -> Optional[str]:
|
|
140
|
+
"""Return path to a cached spectra-backend binary, or None."""
|
|
141
|
+
cache = _cache_dir()
|
|
142
|
+
suffix = ".exe" if platform.system() == "Windows" else ""
|
|
143
|
+
backend = os.path.join(cache, f"spectra-backend{suffix}")
|
|
144
|
+
if os.path.isfile(backend) and os.access(backend, os.X_OK):
|
|
145
|
+
return backend
|
|
146
|
+
return None
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def download_backend(version: Optional[str] = None) -> str:
|
|
150
|
+
"""Download the spectra-backend binary for this platform.
|
|
151
|
+
|
|
152
|
+
Returns the path to the downloaded binary.
|
|
153
|
+
Raises RuntimeError on failure.
|
|
154
|
+
"""
|
|
155
|
+
plat = _detect_platform()
|
|
156
|
+
ver = version or _read_version()
|
|
157
|
+
if not ver:
|
|
158
|
+
raise RuntimeError(
|
|
159
|
+
"Cannot determine spectra-plot version for download. "
|
|
160
|
+
"Set SPECTRA_BACKEND_PATH or install a platform wheel."
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
cache = _cache_dir()
|
|
164
|
+
marker = _version_marker(cache)
|
|
165
|
+
|
|
166
|
+
# Skip download if version already matches
|
|
167
|
+
if os.path.isfile(marker):
|
|
168
|
+
cached_ver = open(marker).read().strip()
|
|
169
|
+
cached_bin = find_cached_backend()
|
|
170
|
+
if cached_ver == ver and cached_bin:
|
|
171
|
+
return cached_bin
|
|
172
|
+
|
|
173
|
+
url, _ = _asset_url(ver, plat)
|
|
174
|
+
print(
|
|
175
|
+
f"[spectra] Downloading backend for {plat} (v{ver})...",
|
|
176
|
+
file=sys.stderr,
|
|
177
|
+
)
|
|
178
|
+
_download_and_extract(url, cache)
|
|
179
|
+
|
|
180
|
+
# Record version
|
|
181
|
+
with open(marker, "w") as f:
|
|
182
|
+
f.write(ver)
|
|
183
|
+
|
|
184
|
+
result = find_cached_backend()
|
|
185
|
+
if result is None:
|
|
186
|
+
raise RuntimeError(
|
|
187
|
+
f"Download succeeded but spectra-backend not found in {cache}. "
|
|
188
|
+
"This is a packaging bug — please file an issue."
|
|
189
|
+
)
|
|
190
|
+
print(f"[spectra] Backend ready: {result}", file=sys.stderr)
|
|
191
|
+
return result
|
|
@@ -136,6 +136,7 @@ class _EasyState:
|
|
|
136
136
|
self._live_stop_events: List[threading.Event] = []
|
|
137
137
|
self._shutting_down = False
|
|
138
138
|
self._axes_bounds: dict = {} # id(axes) -> [xmin, xmax, ymin, ymax]
|
|
139
|
+
self._subplot_cache: dict = {} # (figure_id, rows, cols, index) -> Axes
|
|
139
140
|
|
|
140
141
|
def _ensure_session(self):
|
|
141
142
|
"""Lazily create the backend session."""
|
|
@@ -197,6 +198,8 @@ class _EasyState:
|
|
|
197
198
|
self._current_axes3d = None
|
|
198
199
|
self._figures.clear()
|
|
199
200
|
self._axes_bounds.clear()
|
|
201
|
+
self._subplot_cache.clear()
|
|
202
|
+
self._shutting_down = False
|
|
200
203
|
|
|
201
204
|
|
|
202
205
|
_state = _EasyState()
|
|
@@ -267,9 +270,17 @@ def subplot(rows: int, cols: int, index: int):
|
|
|
267
270
|
Uses 1-based indexing: subplot(2, 1, 1) = top of 2-row layout.
|
|
268
271
|
"""
|
|
269
272
|
fig = _state._ensure_figure()
|
|
273
|
+
cache_key = (fig.id, rows, cols, index)
|
|
274
|
+
cached = _state._subplot_cache.get(cache_key)
|
|
275
|
+
if cached is not None:
|
|
276
|
+
_state._current_axes_key = (rows, cols, index)
|
|
277
|
+
_state._current_axes = cached
|
|
278
|
+
_state._current_axes3d = None
|
|
279
|
+
return cached
|
|
270
280
|
_state._current_axes_key = (rows, cols, index)
|
|
271
281
|
_state._current_axes = fig.subplot(rows, cols, index)
|
|
272
282
|
_state._current_axes3d = None
|
|
283
|
+
_state._subplot_cache[cache_key] = _state._current_axes
|
|
273
284
|
return _state._current_axes
|
|
274
285
|
|
|
275
286
|
|
|
@@ -279,8 +290,15 @@ def subplot3d(rows: int = 1, cols: int = 1, index: int = 1):
|
|
|
279
290
|
Uses 1-based indexing: subplot3d(2, 1, 1) = top of 2-row layout.
|
|
280
291
|
"""
|
|
281
292
|
fig = _state._ensure_figure()
|
|
293
|
+
cache_key = (fig.id, rows, cols, index, '3d')
|
|
294
|
+
cached = _state._subplot_cache.get(cache_key)
|
|
295
|
+
if cached is not None:
|
|
296
|
+
_state._current_axes3d = cached
|
|
297
|
+
_state._current_axes = None
|
|
298
|
+
return cached
|
|
282
299
|
_state._current_axes3d = fig.subplot3d(rows, cols, index)
|
|
283
300
|
_state._current_axes = None
|
|
301
|
+
_state._subplot_cache[cache_key] = _state._current_axes3d
|
|
284
302
|
return _state._current_axes3d
|
|
285
303
|
|
|
286
304
|
|
|
@@ -59,14 +59,33 @@ def _can_connect(path: str) -> bool:
|
|
|
59
59
|
return False
|
|
60
60
|
|
|
61
61
|
|
|
62
|
+
def _is_native_binary(path: str) -> bool:
|
|
63
|
+
"""Return True if *path* looks like a compiled (ELF / Mach-O) executable
|
|
64
|
+
rather than a script wrapper (e.g. the pip-installed console_scripts shim).
|
|
65
|
+
"""
|
|
66
|
+
try:
|
|
67
|
+
with open(path, "rb") as f:
|
|
68
|
+
magic = f.read(4)
|
|
69
|
+
# ELF: \x7fELF | Mach-O: \xfe\xed\xfa\xce / \xcf\xfa\xed\xfe
|
|
70
|
+
return magic[:4] == b"\x7fELF" or magic[:4] in (
|
|
71
|
+
b"\xfe\xed\xfa\xce",
|
|
72
|
+
b"\xfe\xed\xfa\xcf",
|
|
73
|
+
b"\xcf\xfa\xed\xfe",
|
|
74
|
+
b"\xce\xfa\xed\xfe",
|
|
75
|
+
)
|
|
76
|
+
except OSError:
|
|
77
|
+
return False
|
|
78
|
+
|
|
79
|
+
|
|
62
80
|
def _find_backend_binary() -> Optional[str]:
|
|
63
81
|
"""Find the spectra-backend binary.
|
|
64
82
|
|
|
65
83
|
Search order:
|
|
66
84
|
1. $SPECTRA_BACKEND_PATH env var
|
|
67
85
|
2. Bundled binary inside pip-installed package (_bin/spectra-backend)
|
|
68
|
-
3.
|
|
69
|
-
4.
|
|
86
|
+
3. Heuristic: common build directories relative to project root
|
|
87
|
+
4. System PATH (filtered — skips script wrappers to avoid exec loops)
|
|
88
|
+
5. Previously downloaded binary in user cache
|
|
70
89
|
"""
|
|
71
90
|
# 1. Explicit env var
|
|
72
91
|
env_path = os.environ.get("SPECTRA_BACKEND_PATH")
|
|
@@ -90,11 +109,20 @@ def _find_backend_binary() -> Optional[str]:
|
|
|
90
109
|
if os.path.isfile(candidate) and os.access(candidate, os.X_OK):
|
|
91
110
|
return candidate
|
|
92
111
|
|
|
93
|
-
# 4. System PATH (fallback — may find stale installed binaries)
|
|
112
|
+
# 4. System PATH (fallback — may find stale installed binaries).
|
|
113
|
+
# Skip script wrappers (e.g. the pip console_scripts shim) to avoid
|
|
114
|
+
# an infinite os.execv loop where the wrapper calls _find_backend_binary
|
|
115
|
+
# which finds the wrapper again.
|
|
94
116
|
found = shutil.which("spectra-backend")
|
|
95
|
-
if found:
|
|
117
|
+
if found and _is_native_binary(found):
|
|
96
118
|
return found
|
|
97
119
|
|
|
120
|
+
# 5. Previously downloaded binary in user cache
|
|
121
|
+
from ._download import find_cached_backend
|
|
122
|
+
cached = find_cached_backend()
|
|
123
|
+
if cached:
|
|
124
|
+
return cached
|
|
125
|
+
|
|
98
126
|
return None
|
|
99
127
|
|
|
100
128
|
|
|
@@ -109,10 +137,27 @@ def ensure_backend(socket_path: str, timeout: float = 5.0) -> str:
|
|
|
109
137
|
|
|
110
138
|
binary = _find_backend_binary()
|
|
111
139
|
if binary is None:
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
140
|
+
# No binary found locally — try downloading from GitHub Releases
|
|
141
|
+
if os.environ.get("SPECTRA_NO_DOWNLOAD", "").lower() not in ("1", "true", "yes"):
|
|
142
|
+
try:
|
|
143
|
+
from ._download import download_backend
|
|
144
|
+
download_backend()
|
|
145
|
+
binary = _find_backend_binary()
|
|
146
|
+
except Exception as dl_err:
|
|
147
|
+
# Download failed — fall through to the error below with a hint
|
|
148
|
+
_dl_hint = f"\nAuto-download failed: {dl_err}"
|
|
149
|
+
else:
|
|
150
|
+
_dl_hint = ""
|
|
151
|
+
else:
|
|
152
|
+
_dl_hint = "\n(Auto-download disabled by SPECTRA_NO_DOWNLOAD)"
|
|
153
|
+
if binary is None:
|
|
154
|
+
raise RuntimeError(
|
|
155
|
+
"spectra-backend binary not found. "
|
|
156
|
+
"Install the spectra-plot wheel (includes the binary), "
|
|
157
|
+
"set SPECTRA_BACKEND_PATH, or build with CMake "
|
|
158
|
+
"(cmake -B build -DSPECTRA_RUNTIME_MODE=multiproc)."
|
|
159
|
+
+ _dl_hint
|
|
160
|
+
)
|
|
116
161
|
|
|
117
162
|
# Ensure socket directory exists
|
|
118
163
|
sock_dir = os.path.dirname(socket_path)
|
|
@@ -129,7 +174,7 @@ def ensure_backend(socket_path: str, timeout: float = 5.0) -> str:
|
|
|
129
174
|
# Launch backend
|
|
130
175
|
_debug_log = os.environ.get("SPECTRA_DEBUG_LOG")
|
|
131
176
|
_stderr_target = open(_debug_log, "w") if _debug_log else subprocess.PIPE
|
|
132
|
-
subprocess.Popen(
|
|
177
|
+
proc = subprocess.Popen(
|
|
133
178
|
[binary, "--socket", socket_path],
|
|
134
179
|
stdout=subprocess.DEVNULL,
|
|
135
180
|
stderr=_stderr_target,
|
|
@@ -141,9 +186,20 @@ def ensure_backend(socket_path: str, timeout: float = 5.0) -> str:
|
|
|
141
186
|
while time.monotonic() < deadline:
|
|
142
187
|
if _can_connect(socket_path):
|
|
143
188
|
return socket_path
|
|
189
|
+
# If the process already exited, don't keep waiting
|
|
190
|
+
if proc.poll() is not None:
|
|
191
|
+
break
|
|
144
192
|
time.sleep(0.05)
|
|
145
193
|
|
|
194
|
+
# Collect stderr for the error message if available
|
|
195
|
+
hint = ""
|
|
196
|
+
if proc.poll() is not None and _stderr_target == subprocess.PIPE:
|
|
197
|
+
stderr_bytes = proc.stderr.read() if proc.stderr else b""
|
|
198
|
+
if stderr_bytes:
|
|
199
|
+
hint = f"\nBackend stderr:\n{stderr_bytes.decode(errors='replace').rstrip()}"
|
|
200
|
+
|
|
146
201
|
raise RuntimeError(
|
|
147
202
|
f"spectra-backend did not start within {timeout}s. "
|
|
148
|
-
f"
|
|
203
|
+
f"Binary: {binary}\n"
|
|
204
|
+
f"Socket: {socket_path}{hint}"
|
|
149
205
|
)
|
|
@@ -7,6 +7,7 @@ spectra/_axes.py
|
|
|
7
7
|
spectra/_blob.py
|
|
8
8
|
spectra/_cli.py
|
|
9
9
|
spectra/_codec.py
|
|
10
|
+
spectra/_download.py
|
|
10
11
|
spectra/_easy.py
|
|
11
12
|
spectra/_embed.py
|
|
12
13
|
spectra/_errors.py
|
|
@@ -25,10 +26,12 @@ spectra/backends/backend_qtagg.py
|
|
|
25
26
|
spectra_plot.egg-info/PKG-INFO
|
|
26
27
|
spectra_plot.egg-info/SOURCES.txt
|
|
27
28
|
spectra_plot.egg-info/dependency_links.txt
|
|
29
|
+
spectra_plot.egg-info/entry_points.txt
|
|
28
30
|
spectra_plot.egg-info/requires.txt
|
|
29
31
|
spectra_plot.egg-info/top_level.txt
|
|
30
32
|
tests/test_codec.py
|
|
31
33
|
tests/test_cross_codec.py
|
|
34
|
+
tests/test_download.py
|
|
32
35
|
tests/test_easy.py
|
|
33
36
|
tests/test_easy_embed.py
|
|
34
37
|
tests/test_embed.py
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Quick test for _download module logic."""
|
|
3
|
+
import io
|
|
4
|
+
import os
|
|
5
|
+
import sys
|
|
6
|
+
import tarfile
|
|
7
|
+
import tempfile
|
|
8
|
+
import shutil
|
|
9
|
+
|
|
10
|
+
sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), "python"))
|
|
11
|
+
|
|
12
|
+
from spectra._download import (
|
|
13
|
+
_detect_platform,
|
|
14
|
+
_cache_dir,
|
|
15
|
+
_asset_url,
|
|
16
|
+
_read_version,
|
|
17
|
+
_download_and_extract,
|
|
18
|
+
find_cached_backend,
|
|
19
|
+
)
|
|
20
|
+
import spectra._download as dl
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def test_platform_detection():
|
|
24
|
+
plat = _detect_platform()
|
|
25
|
+
assert plat == "linux-x86_64"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def test_cache_directory():
|
|
29
|
+
cache = _cache_dir()
|
|
30
|
+
assert "spectra" in cache and "bin" in cache
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def test_asset_url_linux():
|
|
34
|
+
url, name = _asset_url("0.2.1", "linux-x86_64")
|
|
35
|
+
assert url == "https://github.com/danlil240/Spectra/releases/download/v0.2.1/spectra-backend-linux-x86_64.tar.gz"
|
|
36
|
+
assert name == "spectra-backend-linux-x86_64.tar.gz"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def test_asset_url_windows():
|
|
40
|
+
url_w, name_w = _asset_url("0.2.1", "windows-x86_64")
|
|
41
|
+
assert url_w.endswith(".zip")
|
|
42
|
+
assert name_w.endswith(".zip")
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class FakeResp:
|
|
46
|
+
def __init__(self, data):
|
|
47
|
+
self._data = data
|
|
48
|
+
def read(self):
|
|
49
|
+
return self._data
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def test_tar_extraction():
|
|
53
|
+
buf = io.BytesIO()
|
|
54
|
+
with tarfile.open(fileobj=buf, mode="w:gz") as tf:
|
|
55
|
+
for n in ("bin/spectra-backend", "bin/spectra-window"):
|
|
56
|
+
info = tarfile.TarInfo(name=n)
|
|
57
|
+
data = b"\x7fELF" + b"\x00" * 100
|
|
58
|
+
info.size = len(data)
|
|
59
|
+
tf.addfile(info, io.BytesIO(data))
|
|
60
|
+
buf.seek(0)
|
|
61
|
+
tmpdir = tempfile.mkdtemp()
|
|
62
|
+
orig_urlopen = dl.urlopen
|
|
63
|
+
|
|
64
|
+
dl.urlopen = lambda req, timeout=60: FakeResp(buf.read())
|
|
65
|
+
try:
|
|
66
|
+
dl._download_and_extract("http://fake/test.tar.gz", tmpdir)
|
|
67
|
+
assert os.path.isfile(os.path.join(tmpdir, "spectra-backend"))
|
|
68
|
+
assert os.path.isfile(os.path.join(tmpdir, "spectra-window"))
|
|
69
|
+
assert os.access(os.path.join(tmpdir, "spectra-backend"), os.X_OK)
|
|
70
|
+
finally:
|
|
71
|
+
dl.urlopen = orig_urlopen
|
|
72
|
+
shutil.rmtree(tmpdir)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def test_path_traversal_protection():
|
|
76
|
+
"""Verify only allowed binaries are extracted, regardless of tar member names."""
|
|
77
|
+
buf2 = io.BytesIO()
|
|
78
|
+
with tarfile.open(fileobj=buf2, mode="w:gz") as tf:
|
|
79
|
+
info = tarfile.TarInfo(name="spectra-backend")
|
|
80
|
+
data = b"\x7fELF" + b"\x00" * 50
|
|
81
|
+
info.size = len(data)
|
|
82
|
+
tf.addfile(info, io.BytesIO(data))
|
|
83
|
+
# Malicious entry — basename "evilfile" is not in the allowed list
|
|
84
|
+
info2 = tarfile.TarInfo(name="../../tmp/evilfile")
|
|
85
|
+
data2 = b"evil"
|
|
86
|
+
info2.size = len(data2)
|
|
87
|
+
tf.addfile(info2, io.BytesIO(data2))
|
|
88
|
+
# Another malicious entry with an allowed-sounding directory
|
|
89
|
+
info3 = tarfile.TarInfo(name="../malicious")
|
|
90
|
+
data3 = b"bad"
|
|
91
|
+
info3.size = len(data3)
|
|
92
|
+
tf.addfile(info3, io.BytesIO(data3))
|
|
93
|
+
buf2.seek(0)
|
|
94
|
+
tmpdir2 = tempfile.mkdtemp()
|
|
95
|
+
orig_urlopen = dl.urlopen
|
|
96
|
+
dl.urlopen = lambda req, timeout=60: FakeResp(buf2.read())
|
|
97
|
+
try:
|
|
98
|
+
dl._download_and_extract("http://fake/test.tar.gz", tmpdir2)
|
|
99
|
+
assert os.path.isfile(os.path.join(tmpdir2, "spectra-backend"))
|
|
100
|
+
# Only spectra-backend should exist — nothing else
|
|
101
|
+
extracted = os.listdir(tmpdir2)
|
|
102
|
+
assert sorted(extracted) == ["spectra-backend"], f"Unexpected files: {extracted}"
|
|
103
|
+
finally:
|
|
104
|
+
dl.urlopen = orig_urlopen
|
|
105
|
+
shutil.rmtree(tmpdir2)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def test_version_reading():
|
|
109
|
+
ver = _read_version()
|
|
110
|
+
assert ver, "Version should not be empty"
|
|
@@ -175,7 +175,7 @@ class TestEasyState:
|
|
|
175
175
|
def test_shutdown_without_session(self):
|
|
176
176
|
state = _EasyState()
|
|
177
177
|
state.shutdown() # should not raise
|
|
178
|
-
assert state._shutting_down is
|
|
178
|
+
assert state._shutting_down is False # reset so new plots can be created
|
|
179
179
|
assert state._session is None
|
|
180
180
|
|
|
181
181
|
def test_double_shutdown(self):
|
|
@@ -279,7 +279,7 @@ class TestConvenienceAPI:
|
|
|
279
279
|
def test_version_exists(self):
|
|
280
280
|
import spectra as sp
|
|
281
281
|
assert hasattr(sp, "__version__")
|
|
282
|
-
assert sp.__version__ == "0.
|
|
282
|
+
assert sp.__version__ == "0.2.0"
|
|
283
283
|
|
|
284
284
|
def test_session_class_exported(self):
|
|
285
285
|
from spectra import Session
|
spectra_plot-0.2.0/VERSION
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
0.2.0
|
|
@@ -1,23 +0,0 @@
|
|
|
1
|
-
"""Command-line entry points for spectra."""
|
|
2
|
-
|
|
3
|
-
import os
|
|
4
|
-
import sys
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
def backend_main():
|
|
8
|
-
"""Entry point for 'spectra-backend' console script.
|
|
9
|
-
|
|
10
|
-
Forwards all arguments to the bundled spectra-backend binary.
|
|
11
|
-
"""
|
|
12
|
-
from ._launcher import _find_backend_binary
|
|
13
|
-
|
|
14
|
-
binary = _find_backend_binary()
|
|
15
|
-
if binary is None:
|
|
16
|
-
print(
|
|
17
|
-
"spectra-backend binary not found. "
|
|
18
|
-
"Set SPECTRA_BACKEND_PATH or rebuild with CMake.",
|
|
19
|
-
file=sys.stderr,
|
|
20
|
-
)
|
|
21
|
-
sys.exit(1)
|
|
22
|
-
|
|
23
|
-
os.execv(binary, [binary] + sys.argv[1:])
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|