fix windows numpy import and add node timestamps
This commit is contained in:
@@ -85,6 +85,7 @@ http://127.0.0.1:5173
|
|||||||
Notes:
|
Notes:
|
||||||
|
|
||||||
- The frontend dev server proxies API and WebSocket requests to the backend.
|
- The frontend dev server proxies API and WebSocket requests to the backend.
|
||||||
|
- If you open the backend directly in a browser instead of the Vite dev server, argonode now refreshes `frontend/dist` automatically when checked-out frontend sources are newer, such as after a `git pull`.
|
||||||
- If you want the frontend accessible from other devices on your LAN, run:
|
- If you want the frontend accessible from other devices on your LAN, run:
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
|
||||||
|
def _apply_numpy_compat_aliases() -> None:
|
||||||
|
"""Restore removed NumPy scalar aliases still used by some dependencies."""
|
||||||
|
aliases = {
|
||||||
|
"complex": complex,
|
||||||
|
"float": float,
|
||||||
|
"int": int,
|
||||||
|
}
|
||||||
|
for name, value in aliases.items():
|
||||||
|
if not hasattr(np, name):
|
||||||
|
setattr(np, name, value)
|
||||||
|
|
||||||
|
|
||||||
|
_apply_numpy_compat_aliases()
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ The engine:
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
import uuid
|
import uuid
|
||||||
from collections import defaultdict, deque
|
from collections import defaultdict, deque
|
||||||
|
from time import perf_counter
|
||||||
from typing import Any, Callable
|
from typing import Any, Callable
|
||||||
|
|
||||||
from backend.node_registry import NODE_CLASS_MAPPINGS
|
from backend.node_registry import NODE_CLASS_MAPPINGS
|
||||||
@@ -45,7 +46,7 @@ class ExecutionEngine:
|
|||||||
self,
|
self,
|
||||||
prompt: dict[str, dict],
|
prompt: dict[str, dict],
|
||||||
on_node_start: Callable[[str], None] | None = None,
|
on_node_start: Callable[[str], None] | None = None,
|
||||||
on_node_done: Callable[[str], None] | None = None,
|
on_node_done: Callable[[str, float], None] | None = None,
|
||||||
on_preview: Callable[[str, str], None] | None = None,
|
on_preview: Callable[[str, str], None] | None = None,
|
||||||
on_table: Callable[[str, list], None] | None = None,
|
on_table: Callable[[str, list], None] | None = None,
|
||||||
on_mesh: Callable[[str, dict], None] | None = None,
|
on_mesh: Callable[[str, dict], None] | None = None,
|
||||||
@@ -60,7 +61,7 @@ class ExecutionEngine:
|
|||||||
----------
|
----------
|
||||||
prompt : workflow dict (node_id → {class_type, inputs})
|
prompt : workflow dict (node_id → {class_type, inputs})
|
||||||
on_node_start : called with node_id just before a node executes
|
on_node_start : called with node_id just before a node executes
|
||||||
on_node_done : called with node_id just after a node executes
|
on_node_done : called with (node_id, elapsed_ms) just after a node executes
|
||||||
on_preview : called with (node_id, data_uri) when a display node runs
|
on_preview : called with (node_id, data_uri) when a display node runs
|
||||||
on_table : called with (node_id, table_list) when PrintTable runs
|
on_table : called with (node_id, table_list) when PrintTable runs
|
||||||
on_overlay : called with (node_id, data_uri) for interactive overlays
|
on_overlay : called with (node_id, data_uri) for interactive overlays
|
||||||
@@ -96,7 +97,9 @@ class ExecutionEngine:
|
|||||||
|
|
||||||
instance = cls()
|
instance = cls()
|
||||||
func = getattr(instance, cls.FUNCTION)
|
func = getattr(instance, cls.FUNCTION)
|
||||||
|
start_time = perf_counter()
|
||||||
result = func(**inputs)
|
result = func(**inputs)
|
||||||
|
elapsed_ms = (perf_counter() - start_time) * 1000.0
|
||||||
|
|
||||||
# Nodes must return a tuple; coerce single values just in case
|
# Nodes must return a tuple; coerce single values just in case
|
||||||
if not isinstance(result, tuple):
|
if not isinstance(result, tuple):
|
||||||
@@ -110,7 +113,7 @@ class ExecutionEngine:
|
|||||||
self._auto_preview(cls, node_id, result, on_preview, on_table)
|
self._auto_preview(cls, node_id, result, on_preview, on_table)
|
||||||
|
|
||||||
if on_node_done:
|
if on_node_done:
|
||||||
on_node_done(node_id)
|
on_node_done(node_id, elapsed_ms)
|
||||||
|
|
||||||
return node_outputs
|
return node_outputs
|
||||||
|
|
||||||
|
|||||||
116
backend/frontend_build.py
Normal file
116
backend/frontend_build.py
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import threading
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
_BUILD_LOCK = threading.Lock()
|
||||||
|
|
||||||
|
|
||||||
|
class FrontendBuildError(RuntimeError):
|
||||||
|
"""Raised when the source frontend could not be rebuilt."""
|
||||||
|
|
||||||
|
|
||||||
|
def _latest_mtime(root: Path) -> float:
|
||||||
|
if not root.exists():
|
||||||
|
return 0.0
|
||||||
|
return max(
|
||||||
|
(path.stat().st_mtime for path in root.rglob("*") if path.is_file()),
|
||||||
|
default=0.0,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def source_frontend_mtime(frontend_dir: Path) -> float:
|
||||||
|
tracked_files = (
|
||||||
|
frontend_dir / "index.html",
|
||||||
|
frontend_dir / "package.json",
|
||||||
|
frontend_dir / "package-lock.json",
|
||||||
|
frontend_dir / "vite.config.js",
|
||||||
|
)
|
||||||
|
mtimes = [path.stat().st_mtime for path in tracked_files if path.exists()]
|
||||||
|
mtimes.append(_latest_mtime(frontend_dir / "src"))
|
||||||
|
mtimes.append(_latest_mtime(frontend_dir / "public"))
|
||||||
|
return max(mtimes, default=0.0)
|
||||||
|
|
||||||
|
|
||||||
|
def dist_frontend_mtime(dist_dir: Path) -> float:
|
||||||
|
if not (dist_dir / "index.html").exists():
|
||||||
|
return 0.0
|
||||||
|
return _latest_mtime(dist_dir)
|
||||||
|
|
||||||
|
|
||||||
|
def frontend_dist_is_stale(frontend_dir: Path, dist_dir: Path) -> bool:
|
||||||
|
return dist_frontend_mtime(dist_dir) < source_frontend_mtime(frontend_dir)
|
||||||
|
|
||||||
|
|
||||||
|
def _npm_executable() -> str:
|
||||||
|
return "npm.cmd" if os.name == "nt" else "npm"
|
||||||
|
|
||||||
|
|
||||||
|
def _format_build_output(stdout: str, stderr: str) -> str:
|
||||||
|
combined = "\n".join(part.strip() for part in (stdout, stderr) if part.strip())
|
||||||
|
if not combined:
|
||||||
|
return ""
|
||||||
|
lines = combined.splitlines()
|
||||||
|
if len(lines) > 40:
|
||||||
|
lines = lines[-40:]
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
def _build_failure_hint(detail: str) -> str:
|
||||||
|
if "spawn EPERM" not in detail:
|
||||||
|
return ""
|
||||||
|
return (
|
||||||
|
"Windows note: this build hit `spawn EPERM`, which usually means the current Node.js "
|
||||||
|
"install is not cooperating with esbuild. Node 20 LTS is the safest option here. "
|
||||||
|
"After switching, run `npm --prefix frontend install` and try again."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_frontend_dist_ready(
|
||||||
|
project_root: Path,
|
||||||
|
frontend_dir: Path,
|
||||||
|
dist_dir: Path,
|
||||||
|
logger: logging.Logger | None = None,
|
||||||
|
) -> bool:
|
||||||
|
if not frontend_dist_is_stale(frontend_dir, dist_dir):
|
||||||
|
return False
|
||||||
|
|
||||||
|
with _BUILD_LOCK:
|
||||||
|
if not frontend_dist_is_stale(frontend_dir, dist_dir):
|
||||||
|
return False
|
||||||
|
|
||||||
|
if logger is not None:
|
||||||
|
logger.info("Frontend sources changed; rebuilding frontend/dist before serving the web app.")
|
||||||
|
|
||||||
|
result = subprocess.run(
|
||||||
|
[_npm_executable(), "run", "build"],
|
||||||
|
cwd=str(project_root),
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
|
if result.returncode != 0:
|
||||||
|
detail = _format_build_output(result.stdout, result.stderr)
|
||||||
|
message = (
|
||||||
|
"Frontend build failed while refreshing frontend/dist. "
|
||||||
|
"Run `npm run build` from the repo root to inspect the full error."
|
||||||
|
)
|
||||||
|
hint = _build_failure_hint(detail)
|
||||||
|
if hint:
|
||||||
|
message = f"{message}\n\n{hint}"
|
||||||
|
if detail:
|
||||||
|
message = f"{message}\n\n{detail}"
|
||||||
|
raise FrontendBuildError(message)
|
||||||
|
|
||||||
|
if not (dist_dir / "index.html").exists():
|
||||||
|
raise FrontendBuildError(
|
||||||
|
"Frontend rebuild finished, but frontend/dist/index.html is still missing."
|
||||||
|
)
|
||||||
|
|
||||||
|
if logger is not None:
|
||||||
|
logger.info("Frontend rebuild complete.")
|
||||||
|
|
||||||
|
return True
|
||||||
@@ -17,6 +17,7 @@ WebSocket message types sent to clients
|
|||||||
{"type": "preview", "data": {"node_id": "...", "image": "data:..."}}
|
{"type": "preview", "data": {"node_id": "...", "image": "data:..."}}
|
||||||
{"type": "table", "data": {"node_id": "...", "rows": [...]}}
|
{"type": "table", "data": {"node_id": "...", "rows": [...]}}
|
||||||
{"type": "scalar", "data": {"node_id": "...", "value": 1.23, "unit": "nm"}}
|
{"type": "scalar", "data": {"node_id": "...", "value": 1.23, "unit": "nm"}}
|
||||||
|
{"type": "node_timing", "data": {"node_id": "...", "elapsed_ms": 12.34}}
|
||||||
{"type": "execution_error", "data": {"node_id": "...", "message": "..."}}
|
{"type": "execution_error", "data": {"node_id": "...", "message": "..."}}
|
||||||
{"type": "execution_complete", "data": {"prompt_id": "..."}}
|
{"type": "execution_complete", "data": {"prompt_id": "..."}}
|
||||||
"""
|
"""
|
||||||
@@ -25,15 +26,18 @@ from __future__ import annotations
|
|||||||
import asyncio
|
import asyncio
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
|
import sys
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from aiohttp import web, WSMsgType
|
from aiohttp import web, WSMsgType
|
||||||
|
from backend.frontend_build import FrontendBuildError, ensure_frontend_dist_ready
|
||||||
from backend.runtime_paths import (
|
from backend.runtime_paths import (
|
||||||
ensure_runtime_dirs,
|
ensure_runtime_dirs,
|
||||||
frontend_dir,
|
frontend_dir,
|
||||||
frontend_dist_dir,
|
frontend_dist_dir,
|
||||||
input_dir,
|
input_dir,
|
||||||
output_dir,
|
output_dir,
|
||||||
|
project_root,
|
||||||
)
|
)
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
@@ -136,13 +140,30 @@ def create_app(loop: asyncio.AbstractEventLoop) -> web.Application:
|
|||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
async def index(request: web.Request) -> web.Response:
|
async def index(request: web.Request) -> web.Response:
|
||||||
# Serve Vite build output if available, else raw frontend
|
if not getattr(sys, "frozen", False):
|
||||||
|
try:
|
||||||
|
await loop.run_in_executor(
|
||||||
|
None,
|
||||||
|
lambda: ensure_frontend_dist_ready(
|
||||||
|
project_root(),
|
||||||
|
FRONTEND_DIR,
|
||||||
|
DIST_DIR,
|
||||||
|
logger=log,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
except FrontendBuildError as exc:
|
||||||
|
log.error("Unable to refresh frontend build: %s", exc)
|
||||||
|
return web.Response(status=500, text=str(exc), content_type="text/plain")
|
||||||
|
|
||||||
if (DIST_DIR / "index.html").exists():
|
if (DIST_DIR / "index.html").exists():
|
||||||
return web.FileResponse(DIST_DIR / "index.html")
|
return web.FileResponse(DIST_DIR / "index.html")
|
||||||
if (FRONTEND_DIR / "index.html").exists():
|
return web.Response(
|
||||||
return web.FileResponse(FRONTEND_DIR / "index.html")
|
status=500,
|
||||||
raise web.HTTPInternalServerError(
|
text=(
|
||||||
reason="Frontend build not found. Run `npm run build` before launching the packaged app."
|
"Frontend build not found. Run `npm run build` from the repo root, "
|
||||||
|
"or use `npm run dev` for the Vite development server."
|
||||||
|
),
|
||||||
|
content_type="text/plain",
|
||||||
)
|
)
|
||||||
|
|
||||||
async def get_nodes(request: web.Request) -> web.Response:
|
async def get_nodes(request: web.Request) -> web.Response:
|
||||||
@@ -264,12 +285,19 @@ def create_app(loop: asyncio.AbstractEventLoop) -> web.Application:
|
|||||||
def on_start(node_id: str) -> None:
|
def on_start(node_id: str) -> None:
|
||||||
broadcast({"type": "executing", "data": {"node": node_id, "prompt_id": prompt_id}})
|
broadcast({"type": "executing", "data": {"node": node_id, "prompt_id": prompt_id}})
|
||||||
|
|
||||||
|
def on_done(node_id: str, elapsed_ms: float) -> None:
|
||||||
|
broadcast({
|
||||||
|
"type": "node_timing",
|
||||||
|
"data": {"node_id": node_id, "elapsed_ms": elapsed_ms},
|
||||||
|
})
|
||||||
|
|
||||||
try:
|
try:
|
||||||
await loop.run_in_executor(
|
await loop.run_in_executor(
|
||||||
None,
|
None,
|
||||||
lambda: engine.execute(
|
lambda: engine.execute(
|
||||||
prompt,
|
prompt,
|
||||||
on_node_start=on_start,
|
on_node_start=on_start,
|
||||||
|
on_node_done=on_done,
|
||||||
on_preview=on_preview,
|
on_preview=on_preview,
|
||||||
on_table=on_table,
|
on_table=on_table,
|
||||||
on_mesh=on_mesh,
|
on_mesh=on_mesh,
|
||||||
|
|||||||
@@ -492,6 +492,10 @@ function Flow() {
|
|||||||
console.log('[argonode] WS:', msg.type, msg.data?.node_id || msg.data?.node || '');
|
console.log('[argonode] WS:', msg.type, msg.data?.node_id || msg.data?.node || '');
|
||||||
switch (msg.type) {
|
switch (msg.type) {
|
||||||
case 'execution_start':
|
case 'execution_start':
|
||||||
|
setNodes((ns) => ns.map((n) => ({
|
||||||
|
...n,
|
||||||
|
data: { ...n.data, processingTimeMs: null },
|
||||||
|
})));
|
||||||
setStatus({ text: 'Running workflow…', level: 'info' });
|
setStatus({ text: 'Running workflow…', level: 'info' });
|
||||||
break;
|
break;
|
||||||
case 'executing':
|
case 'executing':
|
||||||
@@ -518,6 +522,9 @@ function Flow() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
break;
|
break;
|
||||||
|
case 'node_timing':
|
||||||
|
updateNodeData(msg.data.node_id, { processingTimeMs: msg.data.elapsed_ms });
|
||||||
|
break;
|
||||||
case 'mesh3d':
|
case 'mesh3d':
|
||||||
updateNodeData(msg.data.node_id, { meshData: msg.data.mesh });
|
updateNodeData(msg.data.node_id, { meshData: msg.data.mesh });
|
||||||
break;
|
break;
|
||||||
@@ -696,6 +703,7 @@ function Flow() {
|
|||||||
meshData: null,
|
meshData: null,
|
||||||
overlay: null,
|
overlay: null,
|
||||||
scalarValue: null,
|
scalarValue: null,
|
||||||
|
processingTimeMs: null,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -326,6 +326,16 @@ function formatScalarDisplay(scalarValue) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatProcessingTime(value) {
|
||||||
|
const ms = Number(value);
|
||||||
|
if (!Number.isFinite(ms) || ms < 0) return null;
|
||||||
|
if (ms < 1) return `${ms.toFixed(2)} ms`;
|
||||||
|
if (ms < 10) return `${ms.toFixed(1)} ms`;
|
||||||
|
if (ms < 1000) return `${Math.round(ms)} ms`;
|
||||||
|
if (ms < 10000) return `${(ms / 1000).toFixed(2)} s`;
|
||||||
|
return `${(ms / 1000).toFixed(1)} s`;
|
||||||
|
}
|
||||||
|
|
||||||
function getSourceTypeForInput(store, nodeId, inputName) {
|
function getSourceTypeForInput(store, nodeId, inputName) {
|
||||||
const targetHandle = `input::${inputName}::`;
|
const targetHandle = `input::${inputName}::`;
|
||||||
const edge = store.edges?.find((e) => e.target === nodeId && e.targetHandle?.startsWith(targetHandle));
|
const edge = store.edges?.find((e) => e.target === nodeId && e.targetHandle?.startsWith(targetHandle));
|
||||||
@@ -428,6 +438,7 @@ function CustomNode({ id, data }) {
|
|||||||
const ctx = useContext(NodeContext);
|
const ctx = useContext(NodeContext);
|
||||||
const def = data.definition;
|
const def = data.definition;
|
||||||
const scalarDisplay = formatScalarDisplay(data.scalarValue);
|
const scalarDisplay = formatScalarDisplay(data.scalarValue);
|
||||||
|
const processingTimeText = formatProcessingTime(data.processingTimeMs);
|
||||||
|
|
||||||
// Parse inputs into data handles and widgets
|
// Parse inputs into data handles and widgets
|
||||||
const required = def.input.required || {};
|
const required = def.input.required || {};
|
||||||
@@ -699,6 +710,11 @@ function CustomNode({ id, data }) {
|
|||||||
<NodeTable rows={data.tableRows} />
|
<NodeTable rows={data.tableRows} />
|
||||||
</CollapsibleSection>
|
</CollapsibleSection>
|
||||||
)}
|
)}
|
||||||
|
{processingTimeText && (
|
||||||
|
<div className="node-benchmark" title={`Processed in ${processingTimeText}`}>
|
||||||
|
{processingTimeText}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -150,6 +150,8 @@ html, body, #root {
|
|||||||
|
|
||||||
.node-body {
|
.node-body {
|
||||||
padding: 4px 0;
|
padding: 4px 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
|
||||||
.node-warning {
|
.node-warning {
|
||||||
@@ -628,6 +630,20 @@ html, body, #root {
|
|||||||
text-align: right !important;
|
text-align: right !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.node-benchmark {
|
||||||
|
align-self: flex-end;
|
||||||
|
margin: 8px 10px 4px;
|
||||||
|
padding: 3px 7px;
|
||||||
|
border: 1px solid rgba(148, 163, 184, 0.28);
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(15, 23, 42, 0.92);
|
||||||
|
color: #94a3b8;
|
||||||
|
font-family: "SF Mono", "Fira Code", monospace;
|
||||||
|
font-size: 10px;
|
||||||
|
line-height: 1;
|
||||||
|
font-variant-numeric: tabular-nums lining-nums;
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Node resize handles ───────────────────────────────────────────── */
|
/* ── Node resize handles ───────────────────────────────────────────── */
|
||||||
.node-resize-line {
|
.node-resize-line {
|
||||||
border-color: #90caf9 !important;
|
border-color: #90caf9 !important;
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import react from '@vitejs/plugin-react';
|
|||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [react()],
|
plugins: [react()],
|
||||||
server: {
|
server: {
|
||||||
|
host: true,
|
||||||
port: 5173,
|
port: 5173,
|
||||||
proxy: {
|
proxy: {
|
||||||
'/nodes': 'http://127.0.0.1:8188',
|
'/nodes': 'http://127.0.0.1:8188',
|
||||||
|
|||||||
40
tests/test_frontend_build.py
Normal file
40
tests/test_frontend_build.py
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from backend.frontend_build import frontend_dist_is_stale
|
||||||
|
|
||||||
|
|
||||||
|
def _write_with_mtime(path: Path, content: str, timestamp: float) -> None:
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
path.write_text(content, encoding="utf-8")
|
||||||
|
os.utime(path, (timestamp, timestamp))
|
||||||
|
|
||||||
|
|
||||||
|
def test_frontend_dist_is_stale_when_dist_is_missing(tmp_path: Path):
|
||||||
|
frontend_dir = tmp_path / "frontend"
|
||||||
|
dist_dir = frontend_dir / "dist"
|
||||||
|
|
||||||
|
_write_with_mtime(frontend_dir / "src" / "main.jsx", "export default 1;", 200.0)
|
||||||
|
|
||||||
|
assert frontend_dist_is_stale(frontend_dir, dist_dir) is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_frontend_dist_is_stale_when_source_is_newer(tmp_path: Path):
|
||||||
|
frontend_dir = tmp_path / "frontend"
|
||||||
|
dist_dir = frontend_dir / "dist"
|
||||||
|
|
||||||
|
_write_with_mtime(dist_dir / "index.html", "<html></html>", 100.0)
|
||||||
|
_write_with_mtime(frontend_dir / "src" / "App.jsx", "export default 1;", 200.0)
|
||||||
|
|
||||||
|
assert frontend_dist_is_stale(frontend_dir, dist_dir) is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_frontend_dist_is_current_when_dist_is_newer(tmp_path: Path):
|
||||||
|
frontend_dir = tmp_path / "frontend"
|
||||||
|
dist_dir = frontend_dir / "dist"
|
||||||
|
|
||||||
|
_write_with_mtime(frontend_dir / "src" / "App.jsx", "export default 1;", 100.0)
|
||||||
|
_write_with_mtime(dist_dir / "assets" / "app.js", "console.log('ok');", 200.0)
|
||||||
|
_write_with_mtime(dist_dir / "index.html", "<html></html>", 200.0)
|
||||||
|
|
||||||
|
assert frontend_dist_is_stale(frontend_dir, dist_dir) is False
|
||||||
8
tests/test_numpy_compat.py
Normal file
8
tests/test_numpy_compat.py
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
import backend # noqa: F401
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
|
||||||
|
def test_numpy_compat_aliases_are_available_after_backend_import():
|
||||||
|
assert np.complex is complex
|
||||||
|
assert np.float is float
|
||||||
|
assert np.int is int
|
||||||
Reference in New Issue
Block a user