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.
Files changed (46) hide show
  1. {spectra_plot-0.2.0/spectra_plot.egg-info → spectra_plot-0.2.1}/PKG-INFO +1 -1
  2. spectra_plot-0.2.1/VERSION +1 -0
  3. {spectra_plot-0.2.0 → spectra_plot-0.2.1}/pyproject.toml +3 -0
  4. spectra_plot-0.2.1/spectra/_cli.py +61 -0
  5. spectra_plot-0.2.1/spectra/_download.py +191 -0
  6. {spectra_plot-0.2.0 → spectra_plot-0.2.1}/spectra/_easy.py +18 -0
  7. {spectra_plot-0.2.0 → spectra_plot-0.2.1}/spectra/_launcher.py +66 -10
  8. {spectra_plot-0.2.0 → spectra_plot-0.2.1/spectra_plot.egg-info}/PKG-INFO +1 -1
  9. {spectra_plot-0.2.0 → spectra_plot-0.2.1}/spectra_plot.egg-info/SOURCES.txt +3 -0
  10. spectra_plot-0.2.1/spectra_plot.egg-info/entry_points.txt +2 -0
  11. spectra_plot-0.2.1/tests/test_download.py +110 -0
  12. {spectra_plot-0.2.0 → spectra_plot-0.2.1}/tests/test_easy.py +1 -1
  13. {spectra_plot-0.2.0 → spectra_plot-0.2.1}/tests/test_phase2.py +1 -1
  14. spectra_plot-0.2.0/VERSION +0 -1
  15. spectra_plot-0.2.0/spectra/_cli.py +0 -23
  16. {spectra_plot-0.2.0 → spectra_plot-0.2.1}/MANIFEST.in +0 -0
  17. {spectra_plot-0.2.0 → spectra_plot-0.2.1}/setup.cfg +0 -0
  18. {spectra_plot-0.2.0 → spectra_plot-0.2.1}/spectra/__init__.py +0 -0
  19. {spectra_plot-0.2.0 → spectra_plot-0.2.1}/spectra/_animation.py +0 -0
  20. {spectra_plot-0.2.0 → spectra_plot-0.2.1}/spectra/_axes.py +0 -0
  21. {spectra_plot-0.2.0 → spectra_plot-0.2.1}/spectra/_blob.py +0 -0
  22. {spectra_plot-0.2.0 → spectra_plot-0.2.1}/spectra/_codec.py +0 -0
  23. {spectra_plot-0.2.0 → spectra_plot-0.2.1}/spectra/_embed.py +0 -0
  24. {spectra_plot-0.2.0 → spectra_plot-0.2.1}/spectra/_errors.py +0 -0
  25. {spectra_plot-0.2.0 → spectra_plot-0.2.1}/spectra/_figure.py +0 -0
  26. {spectra_plot-0.2.0 → spectra_plot-0.2.1}/spectra/_log.py +0 -0
  27. {spectra_plot-0.2.0 → spectra_plot-0.2.1}/spectra/_persistence.py +0 -0
  28. {spectra_plot-0.2.0 → spectra_plot-0.2.1}/spectra/_protocol.py +0 -0
  29. {spectra_plot-0.2.0 → spectra_plot-0.2.1}/spectra/_series.py +0 -0
  30. {spectra_plot-0.2.0 → spectra_plot-0.2.1}/spectra/_session.py +0 -0
  31. {spectra_plot-0.2.0 → spectra_plot-0.2.1}/spectra/_transport.py +0 -0
  32. {spectra_plot-0.2.0 → spectra_plot-0.2.1}/spectra/backends/__init__.py +0 -0
  33. {spectra_plot-0.2.0 → spectra_plot-0.2.1}/spectra/backends/_qt_compat.py +0 -0
  34. {spectra_plot-0.2.0 → spectra_plot-0.2.1}/spectra/backends/backend_qtagg.py +0 -0
  35. {spectra_plot-0.2.0 → spectra_plot-0.2.1}/spectra/embed.py +0 -0
  36. {spectra_plot-0.2.0 → spectra_plot-0.2.1}/spectra_plot.egg-info/dependency_links.txt +0 -0
  37. {spectra_plot-0.2.0 → spectra_plot-0.2.1}/spectra_plot.egg-info/requires.txt +0 -0
  38. {spectra_plot-0.2.0 → spectra_plot-0.2.1}/spectra_plot.egg-info/top_level.txt +0 -0
  39. {spectra_plot-0.2.0 → spectra_plot-0.2.1}/tests/test_codec.py +0 -0
  40. {spectra_plot-0.2.0 → spectra_plot-0.2.1}/tests/test_cross_codec.py +0 -0
  41. {spectra_plot-0.2.0 → spectra_plot-0.2.1}/tests/test_easy_embed.py +0 -0
  42. {spectra_plot-0.2.0 → spectra_plot-0.2.1}/tests/test_embed.py +0 -0
  43. {spectra_plot-0.2.0 → spectra_plot-0.2.1}/tests/test_phase3.py +0 -0
  44. {spectra_plot-0.2.0 → spectra_plot-0.2.1}/tests/test_phase4.py +0 -0
  45. {spectra_plot-0.2.0 → spectra_plot-0.2.1}/tests/test_phase5.py +0 -0
  46. {spectra_plot-0.2.0 → spectra_plot-0.2.1}/tests/test_qt_backend.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: spectra-plot
3
- Version: 0.2.0
3
+ Version: 0.2.1
4
4
  Summary: GPU-accelerated scientific plotting via IPC
5
5
  License: MIT
6
6
  Classifier: Development Status :: 3 - Alpha
@@ -0,0 +1 @@
1
+ 0.2.1
@@ -26,6 +26,9 @@ classifiers = [
26
26
  numpy = ["numpy>=1.20"]
27
27
  dev = ["pytest>=7.0", "numpy>=1.20"]
28
28
 
29
+ [project.scripts]
30
+ spectra-backend = "spectra._cli:backend_main"
31
+
29
32
  [tool.setuptools.dynamic]
30
33
  version = {file = "VERSION"}
31
34
 
@@ -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. System PATH
69
- 4. Heuristic: common build directories relative to project root
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
- raise RuntimeError(
113
- "spectra-backend binary not found. "
114
- "Set SPECTRA_BACKEND_PATH or add it to PATH."
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"Socket: {socket_path}"
203
+ f"Binary: {binary}\n"
204
+ f"Socket: {socket_path}{hint}"
149
205
  )
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: spectra-plot
3
- Version: 0.2.0
3
+ Version: 0.2.1
4
4
  Summary: GPU-accelerated scientific plotting via IPC
5
5
  License: MIT
6
6
  Classifier: Development Status :: 3 - Alpha
@@ -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,2 @@
1
+ [console_scripts]
2
+ spectra-backend = spectra._cli:backend_main
@@ -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 True
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.1.0"
282
+ assert sp.__version__ == "0.2.0"
283
283
 
284
284
  def test_session_class_exported(self):
285
285
  from spectra import Session
@@ -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