chore: deploy AGILAB release Space (2026-10-11T07:35:14Z) (part 4)
Browse files- src/agilab/lib/agi-app-data-quality-gate/pyproject.toml +1 -1
- src/agilab/lib/agi-app-flight-telemetry/pyproject.toml +1 -1
- src/agilab/lib/agi-app-learning-assessment/pyproject.toml +1 -1
- src/agilab/lib/agi-app-minimal/pyproject.toml +2 -2
- src/agilab/lib/agi-app-mission-decision/pyproject.toml +1 -1
- src/agilab/lib/agi-app-multi-dag/pyproject.toml +1 -1
- src/agilab/lib/agi-app-pandas-execution/pyproject.toml +1 -1
- src/agilab/lib/agi-app-polars-execution/pyproject.toml +1 -1
- src/agilab/lib/agi-app-pytorch-playground/pyproject.toml +1 -1
- src/agilab/lib/agi-app-r-runtime-bridge/pyproject.toml +2 -2
- src/agilab/lib/agi-app-sklearn-pipeline/pyproject.toml +1 -1
- src/agilab/lib/agi-app-uav-queue/pyproject.toml +1 -1
- src/agilab/lib/agi-app-uav-relay-queue/pyproject.toml +1 -1
- src/agilab/lib/agi-app-weather-forecast/pyproject.toml +1 -1
- src/agilab/lib/agi-apps/pyproject.toml +2 -2
- src/agilab/lib/agi-gui/pyproject.toml +2 -2
- src/agilab/lib/agi-gui/src/agi_gui/widgets/file_picker.py +4 -1
- src/agilab/lib/agi-gui/test/test_file_picker.py +5 -5
- src/agilab/lib/agi-pages/pyproject.toml +2 -2
- src/agilab/lib/agi-web/frontend/agilab_react_native_controls_and_progress.test.mjs +160 -0
- src/agilab/lib/agi-web/frontend/agilab_react_python_host.css +2 -0
- src/agilab/lib/agi-web/frontend/agilab_react_python_host.jsx +40 -0
- src/agilab/lib/agi-web/pyproject.toml +1 -1
- src/agilab/lib/agi-web/src/agi_web/python_ui.py +9 -1
- src/agilab/lib/agi-web/src/agi_web/react_python_host_assets/agilab_react_python_host.css +0 -0
- src/agilab/lib/agi-web/src/agi_web/react_python_host_assets/agilab_react_python_host.js +0 -0
- src/agilab/lib/agi-web/test/test_python_ui_rendering_and_input_contracts.py +126 -0
- src/agilab/notebooks/notebook_export_support.py +6 -1
- src/agilab/notebooks/notebook_helper_cell.py +18 -3
src/agilab/lib/agi-app-data-quality-gate/pyproject.toml
CHANGED
|
@@ -1,5 +1,5 @@
|
|
| 1 |
[project]
|
| 2 |
-
version = "2026.10.
|
| 3 |
name = "agi-app-data-quality-gate"
|
| 4 |
description = "AGILAB deterministic data contract, drift, leakage, and promotion gate"
|
| 5 |
requires-python = ">=3.12"
|
|
|
|
| 1 |
[project]
|
| 2 |
+
version = "2026.10.10.1"
|
| 3 |
name = "agi-app-data-quality-gate"
|
| 4 |
description = "AGILAB deterministic data contract, drift, leakage, and promotion gate"
|
| 5 |
requires-python = ">=3.12"
|
src/agilab/lib/agi-app-flight-telemetry/pyproject.toml
CHANGED
|
@@ -1,5 +1,5 @@
|
|
| 1 |
[project]
|
| 2 |
-
version = "2026.10.
|
| 3 |
name = "agi-app-flight-telemetry"
|
| 4 |
description = "AGILAB flight-telemetry ingestion demo with reusable dataframe and map-analysis outputs"
|
| 5 |
requires-python = ">=3.12"
|
|
|
|
| 1 |
[project]
|
| 2 |
+
version = "2026.10.10.1"
|
| 3 |
name = "agi-app-flight-telemetry"
|
| 4 |
description = "AGILAB flight-telemetry ingestion demo with reusable dataframe and map-analysis outputs"
|
| 5 |
requires-python = ">=3.12"
|
src/agilab/lib/agi-app-learning-assessment/pyproject.toml
CHANGED
|
@@ -1,5 +1,5 @@
|
|
| 1 |
[project]
|
| 2 |
-
version = "2026.10.
|
| 3 |
name = "agi-app-learning-assessment"
|
| 4 |
description = "AGILAB learning and assessment workflow with evidence scoring and regression-plan artifacts"
|
| 5 |
requires-python = ">=3.13"
|
|
|
|
| 1 |
[project]
|
| 2 |
+
version = "2026.10.10.1"
|
| 3 |
name = "agi-app-learning-assessment"
|
| 4 |
description = "AGILAB learning and assessment workflow with evidence scoring and regression-plan artifacts"
|
| 5 |
requires-python = ">=3.13"
|
src/agilab/lib/agi-app-minimal/pyproject.toml
CHANGED
|
@@ -1,5 +1,5 @@
|
|
| 1 |
[project]
|
| 2 |
-
version = "2026.10.
|
| 3 |
name = "agi-app-minimal"
|
| 4 |
description = "Built-in AGILAB minimal Polars manager and worker example"
|
| 5 |
requires-python = ">=3.12"
|
|
@@ -29,7 +29,7 @@ keywords = [
|
|
| 29 |
"workflow-orchestration",
|
| 30 |
]
|
| 31 |
|
| 32 |
-
dependencies = ["agi-env>=2026.10.04,<2027.0", "agi-node>=2026.10.04,<2027.0", "agi-web>=2026.10.
|
| 33 |
|
| 34 |
[project.urls]
|
| 35 |
Documentation = "https://thalesgroup.github.io/agilab"
|
|
|
|
| 1 |
[project]
|
| 2 |
+
version = "2026.10.10.1"
|
| 3 |
name = "agi-app-minimal"
|
| 4 |
description = "Built-in AGILAB minimal Polars manager and worker example"
|
| 5 |
requires-python = ">=3.12"
|
|
|
|
| 29 |
"workflow-orchestration",
|
| 30 |
]
|
| 31 |
|
| 32 |
+
dependencies = ["agi-env>=2026.10.04,<2027.0", "agi-node>=2026.10.04,<2027.0", "agi-web>=2026.10.10.1,<2027.0"]
|
| 33 |
|
| 34 |
[project.urls]
|
| 35 |
Documentation = "https://thalesgroup.github.io/agilab"
|
src/agilab/lib/agi-app-mission-decision/pyproject.toml
CHANGED
|
@@ -1,5 +1,5 @@
|
|
| 1 |
[project]
|
| 2 |
-
version = "2026.10.
|
| 3 |
name = "agi-app-mission-decision"
|
| 4 |
description = "Deterministic AGILAB mission-decision workflow with scenario scoring and evidence artifacts"
|
| 5 |
requires-python = ">=3.12"
|
|
|
|
| 1 |
[project]
|
| 2 |
+
version = "2026.10.10.1"
|
| 3 |
name = "agi-app-mission-decision"
|
| 4 |
description = "Deterministic AGILAB mission-decision workflow with scenario scoring and evidence artifacts"
|
| 5 |
requires-python = ">=3.12"
|
src/agilab/lib/agi-app-multi-dag/pyproject.toml
CHANGED
|
@@ -1,5 +1,5 @@
|
|
| 1 |
[project]
|
| 2 |
-
version = "2026.10.
|
| 3 |
name = "agi-app-multi-dag"
|
| 4 |
description = "AGILAB cross-app DAG preview showing artifact handoffs between demo projects"
|
| 5 |
requires-python = ">=3.12"
|
|
|
|
| 1 |
[project]
|
| 2 |
+
version = "2026.10.10.1"
|
| 3 |
name = "agi-app-multi-dag"
|
| 4 |
description = "AGILAB cross-app DAG preview showing artifact handoffs between demo projects"
|
| 5 |
requires-python = ">=3.12"
|
src/agilab/lib/agi-app-pandas-execution/pyproject.toml
CHANGED
|
@@ -1,5 +1,5 @@
|
|
| 1 |
[project]
|
| 2 |
-
version = "2026.10.
|
| 3 |
name = "agi-app-pandas-execution"
|
| 4 |
description = "AGILAB Pandas execution benchmark for deterministic worker and reducer validation"
|
| 5 |
requires-python = ">=3.12"
|
|
|
|
| 1 |
[project]
|
| 2 |
+
version = "2026.10.10.1"
|
| 3 |
name = "agi-app-pandas-execution"
|
| 4 |
description = "AGILAB Pandas execution benchmark for deterministic worker and reducer validation"
|
| 5 |
requires-python = ">=3.12"
|
src/agilab/lib/agi-app-polars-execution/pyproject.toml
CHANGED
|
@@ -1,5 +1,5 @@
|
|
| 1 |
[project]
|
| 2 |
-
version = "2026.10.
|
| 3 |
name = "agi-app-polars-execution"
|
| 4 |
description = "AGILAB Polars execution benchmark for deterministic worker and reducer validation"
|
| 5 |
requires-python = ">=3.12"
|
|
|
|
| 1 |
[project]
|
| 2 |
+
version = "2026.10.10.1"
|
| 3 |
name = "agi-app-polars-execution"
|
| 4 |
description = "AGILAB Polars execution benchmark for deterministic worker and reducer validation"
|
| 5 |
requires-python = ">=3.12"
|
src/agilab/lib/agi-app-pytorch-playground/pyproject.toml
CHANGED
|
@@ -1,5 +1,5 @@
|
|
| 1 |
[project]
|
| 2 |
-
version = "2026.10.
|
| 3 |
name = "agi-app-pytorch-playground"
|
| 4 |
description = "AGILAB PyTorch playground app for reproducible neural-network experiments"
|
| 5 |
requires-python = ">=3.12"
|
|
|
|
| 1 |
[project]
|
| 2 |
+
version = "2026.10.10.1"
|
| 3 |
name = "agi-app-pytorch-playground"
|
| 4 |
description = "AGILAB PyTorch playground app for reproducible neural-network experiments"
|
| 5 |
requires-python = ">=3.12"
|
src/agilab/lib/agi-app-r-runtime-bridge/pyproject.toml
CHANGED
|
@@ -1,5 +1,5 @@
|
|
| 1 |
[project]
|
| 2 |
-
version = "2026.10.
|
| 3 |
name = "agi-app-r-runtime-bridge"
|
| 4 |
description = "Built-in AGILAB Rscript JSON and artifact stage bridge"
|
| 5 |
requires-python = ">=3.12"
|
|
@@ -29,7 +29,7 @@ keywords = [
|
|
| 29 |
"workflow-orchestration",
|
| 30 |
]
|
| 31 |
|
| 32 |
-
dependencies = ["agi-env>=2026.10.04,<2027.0", "agi-node>=2026.10.04,<2027.0", "agi-web>=2026.10.
|
| 33 |
|
| 34 |
[project.urls]
|
| 35 |
Documentation = "https://thalesgroup.github.io/agilab"
|
|
|
|
| 1 |
[project]
|
| 2 |
+
version = "2026.10.10.1"
|
| 3 |
name = "agi-app-r-runtime-bridge"
|
| 4 |
description = "Built-in AGILAB Rscript JSON and artifact stage bridge"
|
| 5 |
requires-python = ">=3.12"
|
|
|
|
| 29 |
"workflow-orchestration",
|
| 30 |
]
|
| 31 |
|
| 32 |
+
dependencies = ["agi-env>=2026.10.04,<2027.0", "agi-node>=2026.10.04,<2027.0", "agi-web>=2026.10.10.1,<2027.0"]
|
| 33 |
|
| 34 |
[project.urls]
|
| 35 |
Documentation = "https://thalesgroup.github.io/agilab"
|
src/agilab/lib/agi-app-sklearn-pipeline/pyproject.toml
CHANGED
|
@@ -1,5 +1,5 @@
|
|
| 1 |
[project]
|
| 2 |
-
version = "2026.10.
|
| 3 |
name = "agi-app-sklearn-pipeline"
|
| 4 |
description = "AGILAB scikit-learn pipeline app with model, metrics, predictions, and evidence manifest"
|
| 5 |
requires-python = ">=3.12"
|
|
|
|
| 1 |
[project]
|
| 2 |
+
version = "2026.10.10.1"
|
| 3 |
name = "agi-app-sklearn-pipeline"
|
| 4 |
description = "AGILAB scikit-learn pipeline app with model, metrics, predictions, and evidence manifest"
|
| 5 |
requires-python = ">=3.12"
|
src/agilab/lib/agi-app-uav-queue/pyproject.toml
CHANGED
|
@@ -1,5 +1,5 @@
|
|
| 1 |
[project]
|
| 2 |
-
version = "2026.10.
|
| 3 |
name = "agi-app-uav-queue"
|
| 4 |
description = "AGILAB UAV queue simulation demo with scenario evidence and network-map artifacts"
|
| 5 |
requires-python = ">=3.13"
|
|
|
|
| 1 |
[project]
|
| 2 |
+
version = "2026.10.10.1"
|
| 3 |
name = "agi-app-uav-queue"
|
| 4 |
description = "AGILAB UAV queue simulation demo with scenario evidence and network-map artifacts"
|
| 5 |
requires-python = ">=3.13"
|
src/agilab/lib/agi-app-uav-relay-queue/pyproject.toml
CHANGED
|
@@ -1,5 +1,5 @@
|
|
| 1 |
[project]
|
| 2 |
-
version = "2026.10.
|
| 3 |
name = "agi-app-uav-relay-queue"
|
| 4 |
description = "AGILAB UAV relay queue simulation demo with routing, delay, and resilience artifacts"
|
| 5 |
requires-python = ">=3.13"
|
|
|
|
| 1 |
[project]
|
| 2 |
+
version = "2026.10.10.1"
|
| 3 |
name = "agi-app-uav-relay-queue"
|
| 4 |
description = "AGILAB UAV relay queue simulation demo with routing, delay, and resilience artifacts"
|
| 5 |
requires-python = ">=3.13"
|
src/agilab/lib/agi-app-weather-forecast/pyproject.toml
CHANGED
|
@@ -1,5 +1,5 @@
|
|
| 1 |
[project]
|
| 2 |
-
version = "2026.10.
|
| 3 |
name = "agi-app-weather-forecast"
|
| 4 |
description = "AGILAB weather-forecast notebook-migration demo with forecast analysis artifacts"
|
| 5 |
requires-python = ">=3.12"
|
|
|
|
| 1 |
[project]
|
| 2 |
+
version = "2026.10.10.1"
|
| 3 |
name = "agi-app-weather-forecast"
|
| 4 |
description = "AGILAB weather-forecast notebook-migration demo with forecast analysis artifacts"
|
| 5 |
requires-python = ">=3.12"
|
src/agilab/lib/agi-apps/pyproject.toml
CHANGED
|
@@ -1,5 +1,5 @@
|
|
| 1 |
[project]
|
| 2 |
-
version = "2026.10.
|
| 3 |
name = "agi-apps"
|
| 4 |
description = "AGILAB public app package umbrella and example assets"
|
| 5 |
requires-python = ">=3.12"
|
|
@@ -31,7 +31,7 @@ keywords = [
|
|
| 31 |
"workflow-orchestration",
|
| 32 |
]
|
| 33 |
|
| 34 |
-
dependencies = ["agi-core==2026.10.
|
| 35 |
|
| 36 |
[project.urls]
|
| 37 |
Documentation = "https://thalesgroup.github.io/agilab"
|
|
|
|
| 1 |
[project]
|
| 2 |
+
version = "2026.10.10.1"
|
| 3 |
name = "agi-apps"
|
| 4 |
description = "AGILAB public app package umbrella and example assets"
|
| 5 |
requires-python = ">=3.12"
|
|
|
|
| 31 |
"workflow-orchestration",
|
| 32 |
]
|
| 33 |
|
| 34 |
+
dependencies = ["agi-core==2026.10.10.1", "agi-app-mission-decision==2026.10.10.1", "agi-app-pandas-execution==2026.10.10.1", "agi-app-polars-execution==2026.10.10.1", "agi-app-flight-telemetry==2026.10.10.1", "agi-app-multi-dag==2026.10.10.1", "agi-app-weather-forecast==2026.10.10.1", "agi-app-sklearn-pipeline==2026.10.10.1", "agi-app-data-quality-gate==2026.10.10.1", "agi-app-pytorch-playground==2026.10.10.1; python_version >= '3.12'", "agi-app-learning-assessment==2026.10.10.1; python_version >= '3.13'", "agi-app-uav-relay-queue==2026.10.10.1; python_version >= '3.13'"]
|
| 35 |
|
| 36 |
[project.urls]
|
| 37 |
Documentation = "https://thalesgroup.github.io/agilab"
|
src/agilab/lib/agi-gui/pyproject.toml
CHANGED
|
@@ -1,5 +1,5 @@
|
|
| 1 |
[project]
|
| 2 |
-
version = "2026.10.
|
| 3 |
name = "agi-gui"
|
| 4 |
description = "AGILAB native React UI dependency bundle and Python view helpers"
|
| 5 |
requires-python = ">=3.12"
|
|
@@ -31,7 +31,7 @@ keywords = [
|
|
| 31 |
"python",
|
| 32 |
]
|
| 33 |
|
| 34 |
-
dependencies = ["agi-env==2026.10.04", "agi-web==2026.10.
|
| 35 |
|
| 36 |
[project.urls]
|
| 37 |
Documentation = "https://thalesgroup.github.io/agilab"
|
|
|
|
| 1 |
[project]
|
| 2 |
+
version = "2026.10.10.1"
|
| 3 |
name = "agi-gui"
|
| 4 |
description = "AGILAB native React UI dependency bundle and Python view helpers"
|
| 5 |
requires-python = ">=3.12"
|
|
|
|
| 31 |
"python",
|
| 32 |
]
|
| 33 |
|
| 34 |
+
dependencies = ["agi-env==2026.10.04", "agi-web==2026.10.10.1", "starlette>=1.0.1,<2", "watchdog>=6,<7"]
|
| 35 |
|
| 36 |
[project.urls]
|
| 37 |
Documentation = "https://thalesgroup.github.io/agilab"
|
src/agilab/lib/agi-gui/src/agi_gui/widgets/file_picker.py
CHANGED
|
@@ -229,7 +229,7 @@ def agi_file_picker(
|
|
| 229 |
help: str | None = None,
|
| 230 |
container: Any | None = None,
|
| 231 |
) -> str | list[str] | None:
|
| 232 |
-
"""Render a
|
| 233 |
|
| 234 |
if selection_mode not in {"single", "multi"}:
|
| 235 |
raise ValueError("selection_mode must be 'single' or 'multi'")
|
|
@@ -339,6 +339,7 @@ def agi_file_picker(
|
|
| 339 |
st.error(str(exc))
|
| 340 |
else:
|
| 341 |
st.session_state[state_key] = [str(selected_manual)]
|
|
|
|
| 342 |
st.rerun()
|
| 343 |
|
| 344 |
if allow_upload:
|
|
@@ -352,6 +353,8 @@ def agi_file_picker(
|
|
| 352 |
saved_paths = _save_uploaded_files(uploaded_files, upload_dir, root_items)
|
| 353 |
if saved_paths:
|
| 354 |
st.session_state[state_key] = saved_paths[:1] if selection_mode == "single" else saved_paths
|
|
|
|
|
|
|
| 355 |
st.rerun()
|
| 356 |
|
| 357 |
final_selection = _validated_selection(st.session_state.get(state_key), root_items, selection_mode)
|
|
|
|
| 229 |
help: str | None = None,
|
| 230 |
container: Any | None = None,
|
| 231 |
) -> str | list[str] | None:
|
| 232 |
+
"""Render a native popover file picker and return selected absolute paths."""
|
| 233 |
|
| 234 |
if selection_mode not in {"single", "multi"}:
|
| 235 |
raise ValueError("selection_mode must be 'single' or 'multi'")
|
|
|
|
| 339 |
st.error(str(exc))
|
| 340 |
else:
|
| 341 |
st.session_state[state_key] = [str(selected_manual)]
|
| 342 |
+
st.session_state.pop(table_key, None)
|
| 343 |
st.rerun()
|
| 344 |
|
| 345 |
if allow_upload:
|
|
|
|
| 353 |
saved_paths = _save_uploaded_files(uploaded_files, upload_dir, root_items)
|
| 354 |
if saved_paths:
|
| 355 |
st.session_state[state_key] = saved_paths[:1] if selection_mode == "single" else saved_paths
|
| 356 |
+
st.session_state.pop(table_key, None)
|
| 357 |
+
st.session_state[upload_key] = []
|
| 358 |
st.rerun()
|
| 359 |
|
| 360 |
final_selection = _validated_selection(st.session_state.get(state_key), root_items, selection_mode)
|
src/agilab/lib/agi-gui/test/test_file_picker.py
CHANGED
|
@@ -1,6 +1,6 @@
|
|
| 1 |
from __future__ import annotations
|
| 2 |
|
| 3 |
-
import
|
| 4 |
from pathlib import Path
|
| 5 |
from types import SimpleNamespace
|
| 6 |
|
|
@@ -263,7 +263,7 @@ def test_agi_file_picker_selects_dataframe_row(tmp_path: Path, monkeypatch) -> N
|
|
| 263 |
selected_file.write_text("a\n", encoding="utf-8")
|
| 264 |
(tmp_path / "beta.txt").write_text("b\n", encoding="utf-8")
|
| 265 |
fake_st = _FakeStreamlit(dataframe_state={"selection": {"rows": [0]}})
|
| 266 |
-
monkeypatch.setattr(agi_web, "python_ui", fake_st)
|
| 267 |
|
| 268 |
result = agi_file_picker(
|
| 269 |
"Browse",
|
|
@@ -294,7 +294,7 @@ def test_agi_file_picker_handles_multi_root_empty_search_and_manual_error(tmp_pa
|
|
| 294 |
button_values={"picker:use_manual_path": True},
|
| 295 |
pills_value="unknown",
|
| 296 |
)
|
| 297 |
-
monkeypatch.setattr(agi_web, "python_ui", fake_st)
|
| 298 |
|
| 299 |
result = agi_file_picker(
|
| 300 |
"Browse",
|
|
@@ -315,7 +315,7 @@ def test_agi_file_picker_manual_path_updates_selection(tmp_path: Path, monkeypat
|
|
| 315 |
text_values={"picker:manual_path": str(selected_file)},
|
| 316 |
button_values={"picker:use_manual_path": True},
|
| 317 |
)
|
| 318 |
-
monkeypatch.setattr(agi_web, "python_ui", fake_st)
|
| 319 |
|
| 320 |
result = agi_file_picker("Browse", roots={"Project": tmp_path}, key="picker")
|
| 321 |
|
|
@@ -330,7 +330,7 @@ def test_agi_file_picker_upload_saves_files(tmp_path: Path, monkeypatch) -> None
|
|
| 330 |
fake_st = _FakeStreamlit(
|
| 331 |
uploads=[_UploadedFile("nested/new.csv", b"payload")],
|
| 332 |
)
|
| 333 |
-
monkeypatch.setattr(agi_web, "python_ui", fake_st)
|
| 334 |
|
| 335 |
result = agi_file_picker(
|
| 336 |
"Upload",
|
|
|
|
| 1 |
from __future__ import annotations
|
| 2 |
|
| 3 |
+
from importlib import import_module
|
| 4 |
from pathlib import Path
|
| 5 |
from types import SimpleNamespace
|
| 6 |
|
|
|
|
| 263 |
selected_file.write_text("a\n", encoding="utf-8")
|
| 264 |
(tmp_path / "beta.txt").write_text("b\n", encoding="utf-8")
|
| 265 |
fake_st = _FakeStreamlit(dataframe_state={"selection": {"rows": [0]}})
|
| 266 |
+
monkeypatch.setattr(import_module("agi_web"), "python_ui", fake_st, raising=False)
|
| 267 |
|
| 268 |
result = agi_file_picker(
|
| 269 |
"Browse",
|
|
|
|
| 294 |
button_values={"picker:use_manual_path": True},
|
| 295 |
pills_value="unknown",
|
| 296 |
)
|
| 297 |
+
monkeypatch.setattr(import_module("agi_web"), "python_ui", fake_st, raising=False)
|
| 298 |
|
| 299 |
result = agi_file_picker(
|
| 300 |
"Browse",
|
|
|
|
| 315 |
text_values={"picker:manual_path": str(selected_file)},
|
| 316 |
button_values={"picker:use_manual_path": True},
|
| 317 |
)
|
| 318 |
+
monkeypatch.setattr(import_module("agi_web"), "python_ui", fake_st, raising=False)
|
| 319 |
|
| 320 |
result = agi_file_picker("Browse", roots={"Project": tmp_path}, key="picker")
|
| 321 |
|
|
|
|
| 330 |
fake_st = _FakeStreamlit(
|
| 331 |
uploads=[_UploadedFile("nested/new.csv", b"payload")],
|
| 332 |
)
|
| 333 |
+
monkeypatch.setattr(import_module("agi_web"), "python_ui", fake_st, raising=False)
|
| 334 |
|
| 335 |
result = agi_file_picker(
|
| 336 |
"Upload",
|
src/agilab/lib/agi-pages/pyproject.toml
CHANGED
|
@@ -1,5 +1,5 @@
|
|
| 1 |
[project]
|
| 2 |
-
version = "2026.10.
|
| 3 |
name = "agi-pages"
|
| 4 |
description = "AGILAB public analysis page umbrella and provider package"
|
| 5 |
requires-python = ">=3.12"
|
|
@@ -34,7 +34,7 @@ keywords = [
|
|
| 34 |
"page-bundles",
|
| 35 |
]
|
| 36 |
|
| 37 |
-
dependencies = ["agi-gui==2026.10.
|
| 38 |
|
| 39 |
[project.entry-points."agi_env.paths"]
|
| 40 |
pages = "agi_pages:bundles_root"
|
|
|
|
| 1 |
[project]
|
| 2 |
+
version = "2026.10.10.1"
|
| 3 |
name = "agi-pages"
|
| 4 |
description = "AGILAB public analysis page umbrella and provider package"
|
| 5 |
requires-python = ">=3.12"
|
|
|
|
| 34 |
"page-bundles",
|
| 35 |
]
|
| 36 |
|
| 37 |
+
dependencies = ["agi-gui==2026.10.10.1"]
|
| 38 |
|
| 39 |
[project.entry-points."agi_env.paths"]
|
| 40 |
pages = "agi_pages:bundles_root"
|
src/agilab/lib/agi-web/frontend/agilab_react_native_controls_and_progress.test.mjs
CHANGED
|
@@ -329,3 +329,163 @@ test("image descriptions and stretch widths reach the rendered plot", async () =
|
|
| 329 |
assert.equal(images[2].getAttribute("alt"), "", "Only an explicit decorative alt may be empty.");
|
| 330 |
assert.equal(images[3].hasAttribute("alt"), false, "An empty caption must not mark an undescribed plot decorative.");
|
| 331 |
});
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 329 |
assert.equal(images[2].getAttribute("alt"), "", "Only an explicit decorative alt may be empty.");
|
| 330 |
assert.equal(images[3].hasAttribute("alt"), false, "An empty caption must not mark an undescribed plot decorative.");
|
| 331 |
});
|
| 332 |
+
|
| 333 |
+
|
| 334 |
+
test("file popovers retain controls during rerenders and close with Escape or outside interaction", async () => {
|
| 335 |
+
const disclosure = revision => {
|
| 336 |
+
const item = node("dataset-picker", "popover", {
|
| 337 |
+
label: revision === 1 ? "Choose dataset" : "Dataset selected",
|
| 338 |
+
help: "Choose a CSV under the project", width: "stretch",
|
| 339 |
+
});
|
| 340 |
+
item.children = [
|
| 341 |
+
node("dataset-filter", "text_input", { label: "Filter files", value: "input", key: "filter" }),
|
| 342 |
+
node("dataset-select", "button", { label: "Select dataset", key: "choose" }),
|
| 343 |
+
];
|
| 344 |
+
return item;
|
| 345 |
+
};
|
| 346 |
+
const actions = [];
|
| 347 |
+
const element = await mount(payload(1, [disclosure(1)]), {
|
| 348 |
+
action: async action => { actions.push(action); return payload(2, [disclosure(2)]); },
|
| 349 |
+
});
|
| 350 |
+
const trigger = element.querySelector(".py-popover-trigger");
|
| 351 |
+
const panel = element.querySelector(".py-popover-panel");
|
| 352 |
+
const input = element.querySelector("#dataset-filter");
|
| 353 |
+
assert.equal(trigger.tagName, "BUTTON", "Use native keyboard activation.");
|
| 354 |
+
assert.equal(trigger.getAttribute("aria-expanded"), "false");
|
| 355 |
+
assert.equal(trigger.getAttribute("aria-controls"), panel.id);
|
| 356 |
+
assert.equal(panel.getAttribute("role"), "dialog");
|
| 357 |
+
assert.equal(panel.hidden, true);
|
| 358 |
+
assert.equal(element.querySelector(".py-popover").style.width, "100%");
|
| 359 |
+
await act(() => trigger.click());
|
| 360 |
+
assert.equal(panel.hidden, false);
|
| 361 |
+
assert.equal(trigger.getAttribute("aria-expanded"), "true");
|
| 362 |
+
assert.equal(actions.length, 0, "Opening a disclosure needs no server roundtrip.");
|
| 363 |
+
input.focus();
|
| 364 |
+
await act(() => input.dispatchEvent(new Event("pointerdown", { bubbles: true })));
|
| 365 |
+
assert.equal(panel.hidden, false);
|
| 366 |
+
await act(() => input.dispatchEvent(new dom.window.KeyboardEvent("keydown", { key: "Escape", bubbles: true })));
|
| 367 |
+
assert.equal(panel.hidden, true);
|
| 368 |
+
assert.equal(document.activeElement, trigger);
|
| 369 |
+
await act(() => trigger.click());
|
| 370 |
+
await act(async () => element.querySelector("#dataset-select").click());
|
| 371 |
+
assert.equal(actions.length, 1);
|
| 372 |
+
assert.equal(actions[0].id, "dataset-select");
|
| 373 |
+
assert.equal(element.querySelector(".py-popover-trigger"), trigger);
|
| 374 |
+
assert.equal(trigger.textContent, "Dataset selected");
|
| 375 |
+
assert.equal(panel.hidden, false);
|
| 376 |
+
assert.equal(element.querySelector("#dataset-filter"), input);
|
| 377 |
+
assert.equal(input.value, "input");
|
| 378 |
+
const outside = document.createElement("button");
|
| 379 |
+
document.body.append(outside);
|
| 380 |
+
await act(() => outside.focus());
|
| 381 |
+
assert.equal(panel.hidden, true, "Leaving the popover by keyboard closes it.");
|
| 382 |
+
await act(() => trigger.click());
|
| 383 |
+
assert.equal(panel.hidden, false);
|
| 384 |
+
await act(() => outside.dispatchEvent(new Event("pointerdown", { bubbles: true })));
|
| 385 |
+
assert.equal(panel.hidden, true);
|
| 386 |
+
assert.equal(document.activeElement, outside, "Closing outside must not steal focus.");
|
| 387 |
+
});
|
| 388 |
+
|
| 389 |
+
test("disabled popovers keep their child controls hidden", async () => {
|
| 390 |
+
const item = node("disabled-picker", "popover", { label: "Unavailable", disabled: true });
|
| 391 |
+
item.children = [node("disabled-child", "text_input", { label: "Hidden child", value: "" })];
|
| 392 |
+
const element = await mount(payload(1, [item]), { action: async () => assert.fail("Unexpected server action") });
|
| 393 |
+
const trigger = element.querySelector(".py-popover-trigger");
|
| 394 |
+
assert.equal(trigger.disabled, true);
|
| 395 |
+
await act(() => trigger.click());
|
| 396 |
+
assert.equal(trigger.getAttribute("aria-expanded"), "false");
|
| 397 |
+
assert.equal(element.querySelector(".py-popover-panel").hidden, true);
|
| 398 |
+
});
|
| 399 |
+
|
| 400 |
+
|
| 401 |
+
test("the packaged production bundle renders an interactive file popover", async () => {
|
| 402 |
+
const packaged = new URL("../src/agi_web/react_python_host_assets/agilab_react_python_host.js", import.meta.url);
|
| 403 |
+
const module = path.join(scratch, "agilab_native_packaged_popover_host_exact_bytes.mjs");
|
| 404 |
+
await copyFile(packaged, module);
|
| 405 |
+
assert.deepEqual(await readFile(module), await readFile(packaged));
|
| 406 |
+
const { mountPythonView: mountPackaged } = await import(pathToFileURL(module).href);
|
| 407 |
+
const item = node("packaged-picker", "popover", { label: "Choose CSV", width: "stretch" });
|
| 408 |
+
item.children = [node("packaged-filter", "text_input", { label: "Filter files", value: "" })];
|
| 409 |
+
const element = document.createElement("div"); document.body.append(element);
|
| 410 |
+
cleanup = mountPackaged(element, { initialPayload: payload(1, [item]), transport: {
|
| 411 |
+
action: async () => assert.fail("Popover interactions must stay local"),
|
| 412 |
+
} });
|
| 413 |
+
const waitFor = async predicate => {
|
| 414 |
+
for (let attempt = 0; attempt < 100; attempt++) {
|
| 415 |
+
if (predicate()) return;
|
| 416 |
+
await new Promise(resolve => setTimeout(resolve, 1));
|
| 417 |
+
}
|
| 418 |
+
assert.fail("The packaged popover did not reach its expected state.");
|
| 419 |
+
};
|
| 420 |
+
await waitFor(() => element.querySelector(".py-popover-trigger"));
|
| 421 |
+
const trigger = element.querySelector(".py-popover-trigger");
|
| 422 |
+
const panel = element.querySelector(".py-popover-panel");
|
| 423 |
+
assert.equal(panel.hidden, true);
|
| 424 |
+
trigger.click();
|
| 425 |
+
await waitFor(() => !panel.hidden);
|
| 426 |
+
const input = element.querySelector("#packaged-filter");
|
| 427 |
+
input.focus();
|
| 428 |
+
input.dispatchEvent(new dom.window.KeyboardEvent("keydown", { key: "Escape", bubbles: true }));
|
| 429 |
+
await waitFor(() => panel.hidden);
|
| 430 |
+
assert.equal(document.activeElement, trigger);
|
| 431 |
+
});
|
| 432 |
+
|
| 433 |
+
|
| 434 |
+
test("packaged nested popovers retain state on rerender and Escape closes only the deepest panel", async () => {
|
| 435 |
+
const packaged = new URL("../src/agi_web/react_python_host_assets/agilab_react_python_host.js", import.meta.url);
|
| 436 |
+
const module = path.join(scratch, "agilab_native_packaged_nested_popover_host_exact_bytes.mjs");
|
| 437 |
+
await copyFile(packaged, module);
|
| 438 |
+
assert.deepEqual(await readFile(module), await readFile(packaged));
|
| 439 |
+
const { mountPythonView: mountPackaged } = await import(pathToFileURL(module).href);
|
| 440 |
+
const nested = revision => {
|
| 441 |
+
const parent = node("outer-picker", "popover", { label: revision === 1 ? "Choose dataset" : "Dataset selected" });
|
| 442 |
+
const child = node("inner-picker", "popover", { label: "Advanced" });
|
| 443 |
+
child.children = [
|
| 444 |
+
node("inner-filter", "text_input", { label: "Filter files", value: "input" }),
|
| 445 |
+
node("inner-apply", "button", { label: "Apply filter" }),
|
| 446 |
+
];
|
| 447 |
+
parent.children = [child];
|
| 448 |
+
return payload(revision, [parent]);
|
| 449 |
+
};
|
| 450 |
+
const element = document.createElement("div"); document.body.append(element);
|
| 451 |
+
const actions = [];
|
| 452 |
+
cleanup = mountPackaged(element, { initialPayload: nested(1), transport: {
|
| 453 |
+
action: async action => { actions.push(action); return nested(2); },
|
| 454 |
+
} });
|
| 455 |
+
const waitFor = async predicate => {
|
| 456 |
+
for (let attempt = 0; attempt < 100; attempt++) {
|
| 457 |
+
if (predicate()) return;
|
| 458 |
+
await new Promise(resolve => setTimeout(resolve, 1));
|
| 459 |
+
}
|
| 460 |
+
assert.fail("The packaged nested popovers did not reach their expected state.");
|
| 461 |
+
};
|
| 462 |
+
await waitFor(() => element.querySelector("#outer-picker-popover"));
|
| 463 |
+
const parentPanel = element.querySelector("#outer-picker-popover");
|
| 464 |
+
const childPanel = element.querySelector("#inner-picker-popover");
|
| 465 |
+
const parentTrigger = element.querySelector('[aria-controls="outer-picker-popover"]');
|
| 466 |
+
const childTrigger = element.querySelector('[aria-controls="inner-picker-popover"]');
|
| 467 |
+
parentTrigger.focus();
|
| 468 |
+
parentTrigger.click();
|
| 469 |
+
await waitFor(() => !parentPanel.hidden);
|
| 470 |
+
childTrigger.focus();
|
| 471 |
+
childTrigger.click();
|
| 472 |
+
await waitFor(() => !childPanel.hidden);
|
| 473 |
+
const input = element.querySelector("#inner-filter");
|
| 474 |
+
input.focus();
|
| 475 |
+
element.querySelector("#inner-apply").click();
|
| 476 |
+
await waitFor(() => parentTrigger.textContent === "Dataset selected");
|
| 477 |
+
assert.equal(actions.length, 1);
|
| 478 |
+
assert.equal(element.querySelector("#inner-filter"), input);
|
| 479 |
+
assert.equal(document.activeElement, input);
|
| 480 |
+
assert.equal(childPanel.hidden, false, "The nested panel stays open across the Python rerender.");
|
| 481 |
+
input.dispatchEvent(new dom.window.KeyboardEvent("keydown", { key: "Escape", bubbles: true }));
|
| 482 |
+
await waitFor(() => childPanel.hidden);
|
| 483 |
+
assert.equal(parentPanel.hidden, false, "Escape first closes only the deepest panel.");
|
| 484 |
+
assert.equal(document.activeElement, childTrigger);
|
| 485 |
+
assert.equal(parentTrigger.getAttribute("aria-expanded"), "true");
|
| 486 |
+
assert.equal(childTrigger.getAttribute("aria-expanded"), "false");
|
| 487 |
+
childTrigger.dispatchEvent(new dom.window.KeyboardEvent("keydown", { key: "Escape", bubbles: true }));
|
| 488 |
+
await waitFor(() => parentPanel.hidden);
|
| 489 |
+
assert.equal(document.activeElement, parentTrigger);
|
| 490 |
+
assert.equal(parentTrigger.getAttribute("aria-expanded"), "false");
|
| 491 |
+
});
|
src/agilab/lib/agi-web/frontend/agilab_react_python_host.css
CHANGED
|
@@ -19,3 +19,5 @@
|
|
| 19 |
.py-options input {flex-shrink:0}
|
| 20 |
.py-app figure img {display:block}
|
| 21 |
.py-table-pagination{display:flex;align-items:center;gap:.75rem;flex-wrap:wrap;padding:.5rem 0}.py-table-pagination [role=status]{font-variant-numeric:tabular-nums}
|
|
|
|
|
|
|
|
|
| 19 |
.py-options input {flex-shrink:0}
|
| 20 |
.py-app figure img {display:block}
|
| 21 |
.py-table-pagination{display:flex;align-items:center;gap:.75rem;flex-wrap:wrap;padding:.5rem 0}.py-table-pagination [role=status]{font-variant-numeric:tabular-nums}
|
| 22 |
+
|
| 23 |
+
.py-popover{position:relative;width:fit-content}.py-popover-trigger{width:100%;text-align:left;overflow-wrap:anywhere}.py-popover-trigger[aria-expanded=true]{border-color:var(--py-accent)}.py-popover-trigger:focus-visible{outline:2px solid var(--py-accent);outline-offset:2px}.py-popover-panel{position:absolute;inset-block-start:calc(100% + .4rem);inset-inline-start:0;z-index:50;width:100%;max-width:34rem;box-sizing:border-box;max-height:70vh;overflow:auto;padding:1rem;border:1px solid var(--py-border);border-radius:.65rem;background:var(--py-surface);box-shadow:0 .5rem 1.5rem #18334826}.py-popover-panel[hidden]{display:none}
|
src/agilab/lib/agi-web/frontend/agilab_react_python_host.jsx
CHANGED
|
@@ -246,6 +246,45 @@ function LinkAction({node}) {
|
|
| 246 |
return <PythonLinkAction node={node} view={useContext(View)}/>;
|
| 247 |
}
|
| 248 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 249 |
// Pure traversal stays outside View so local controls do not repaint heavy siblings.
|
| 250 |
const Node = memo(function Node({node}) {
|
| 251 |
const p = node.props, content = <Nodes nodes={node.children}/>;
|
|
@@ -253,6 +292,7 @@ const Node = memo(function Node({node}) {
|
|
| 253 |
if (node.kind === "component") return <Island node={node}/>;
|
| 254 |
if (node.kind === "columns") return <div className="py-columns" style={{gridTemplateColumns: p.weights.map(weight => `${weight}fr`).join(" ")}}>{content}</div>;
|
| 255 |
if (node.kind === "tabs") return <Tabs node={node}/>;
|
|
|
|
| 256 |
if (node.kind === "expander" || node.kind === "status") return <details className="py-expander" data-widget-kind={node.kind} data-widget-key={p.key} data-state={p.state} open={p.expanded}><summary>{p.label}</summary>{content}</details>;
|
| 257 |
if (node.kind === "form") return <Form node={node}/>;
|
| 258 |
if (node.kind === "dialog") return <div className="py-dialog-backdrop"><section role="dialog" aria-modal="true" aria-label={p.title}><h2>{p.title}</h2>{content}</section></div>;
|
|
|
|
| 246 |
return <PythonLinkAction node={node} view={useContext(View)}/>;
|
| 247 |
}
|
| 248 |
|
| 249 |
+
function Popover({node}) {
|
| 250 |
+
const p = node.props, [open, setOpen] = useState(false), element = useRef(null), trigger = useRef(null), panel = useRef(null);
|
| 251 |
+
const visible = open && !p.disabled, panelId = `${node.id}-popover`, helpId = `${node.id}-help`;
|
| 252 |
+
useEffect(() => {
|
| 253 |
+
if (!visible) return;
|
| 254 |
+
const outside = event => { if (!element.current?.contains(event.target)) setOpen(false); };
|
| 255 |
+
const escape = event => {
|
| 256 |
+
// A nested panel handles Escape first, even when its document listener
|
| 257 |
+
// was registered after ours. Hidden panels may still await effect cleanup.
|
| 258 |
+
if (event.key === "Escape" && panel.current && !panel.current.closest("[hidden]")
|
| 259 |
+
&& !Array.from(panel.current.querySelectorAll(".py-popover-panel:not([hidden])"))
|
| 260 |
+
.some(child => !child.closest("[hidden]"))) {
|
| 261 |
+
event.preventDefault();
|
| 262 |
+
event.stopPropagation();
|
| 263 |
+
setOpen(false);
|
| 264 |
+
trigger.current?.focus();
|
| 265 |
+
}
|
| 266 |
+
};
|
| 267 |
+
document.addEventListener("pointerdown", outside);
|
| 268 |
+
document.addEventListener("focusin", outside);
|
| 269 |
+
document.addEventListener("keydown", escape);
|
| 270 |
+
return () => {
|
| 271 |
+
document.removeEventListener("pointerdown", outside);
|
| 272 |
+
document.removeEventListener("focusin", outside);
|
| 273 |
+
document.removeEventListener("keydown", escape);
|
| 274 |
+
};
|
| 275 |
+
}, [visible]);
|
| 276 |
+
return <div ref={element} className="py-popover" data-widget-kind="popover" data-widget-key={p.key}
|
| 277 |
+
style={{width: p.width === "stretch" ? "100%" : typeof p.width === "number" ? p.width : undefined}}>
|
| 278 |
+
<button ref={trigger} type="button" className="py-popover-trigger" disabled={p.disabled} title={p.help || undefined}
|
| 279 |
+
aria-expanded={visible} aria-controls={panelId} aria-haspopup="dialog" aria-describedby={p.help ? helpId : undefined}
|
| 280 |
+
onClick={() => setOpen(value => !value)}>{p.label}</button>
|
| 281 |
+
{p.help ? <span id={helpId} hidden>{p.help}</span> : null}
|
| 282 |
+
<div ref={panel} id={panelId} className="py-popover-panel" role="dialog" aria-label={p.label} hidden={!visible}>
|
| 283 |
+
<Nodes nodes={node.children}/>
|
| 284 |
+
</div>
|
| 285 |
+
</div>;
|
| 286 |
+
}
|
| 287 |
+
|
| 288 |
// Pure traversal stays outside View so local controls do not repaint heavy siblings.
|
| 289 |
const Node = memo(function Node({node}) {
|
| 290 |
const p = node.props, content = <Nodes nodes={node.children}/>;
|
|
|
|
| 292 |
if (node.kind === "component") return <Island node={node}/>;
|
| 293 |
if (node.kind === "columns") return <div className="py-columns" style={{gridTemplateColumns: p.weights.map(weight => `${weight}fr`).join(" ")}}>{content}</div>;
|
| 294 |
if (node.kind === "tabs") return <Tabs node={node}/>;
|
| 295 |
+
if (node.kind === "popover") return <Popover node={node}/>;
|
| 296 |
if (node.kind === "expander" || node.kind === "status") return <details className="py-expander" data-widget-kind={node.kind} data-widget-key={p.key} data-state={p.state} open={p.expanded}><summary>{p.label}</summary>{content}</details>;
|
| 297 |
if (node.kind === "form") return <Form node={node}/>;
|
| 298 |
if (node.kind === "dialog") return <div className="py-dialog-backdrop"><section role="dialog" aria-modal="true" aria-label={p.title}><h2>{p.title}</h2>{content}</section></div>;
|
src/agilab/lib/agi-web/pyproject.toml
CHANGED
|
@@ -1,5 +1,5 @@
|
|
| 1 |
[project]
|
| 2 |
-
version = "2026.10.
|
| 3 |
name = "agi-web"
|
| 4 |
description = "AGILAB portable web components with native React Python views and shared analysis components for Jupyter"
|
| 5 |
requires-python = ">=3.12"
|
|
|
|
| 1 |
[project]
|
| 2 |
+
version = "2026.10.10.1"
|
| 3 |
name = "agi-web"
|
| 4 |
description = "AGILAB portable web components with native React Python views and shared analysis components for Jupyter"
|
| 5 |
requires-python = ">=3.12"
|
src/agilab/lib/agi-web/src/agi_web/python_ui.py
CHANGED
|
@@ -151,6 +151,13 @@ def expander(label, expanded=False, **kwargs):
|
|
| 151 |
return Container(_node("expander", label=str(label), expanded=expanded, **kwargs))
|
| 152 |
|
| 153 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 154 |
def form(key, *, clear_on_submit=False, **kwargs):
|
| 155 |
if current_session().form_stack:
|
| 156 |
raise UIError("Forms cannot be nested.")
|
|
@@ -271,7 +278,8 @@ def select_slider(label, options, value=None, **kwargs):
|
|
| 271 |
def number_input(label, min_value=None, max_value=None, value="min", step=None, **kwargs):
|
| 272 |
if value == "min":
|
| 273 |
value = min_value if min_value is not None else (0.0 if isinstance(step, float) else 0)
|
| 274 |
-
|
|
|
|
| 275 |
return _widget("number_input", label, value, min_value=min_value, max_value=max_value,
|
| 276 |
step=step or (1 if integer else 0.01), integer=integer,
|
| 277 |
decoder=int if integer else float, **kwargs)
|
|
|
|
| 151 |
return Container(_node("expander", label=str(label), expanded=expanded, **kwargs))
|
| 152 |
|
| 153 |
|
| 154 |
+
def popover(label, *, help=None, disabled=False, width="content", key=None, **kwargs):
|
| 155 |
+
return Container(current_session().add_node(
|
| 156 |
+
"popover", {"label": str(label), "help": help, "disabled": bool(disabled),
|
| 157 |
+
"width": width, "key": key, **kwargs}, key=key,
|
| 158 |
+
))
|
| 159 |
+
|
| 160 |
+
|
| 161 |
def form(key, *, clear_on_submit=False, **kwargs):
|
| 162 |
if current_session().form_stack:
|
| 163 |
raise UIError("Forms cannot be nested.")
|
|
|
|
| 278 |
def number_input(label, min_value=None, max_value=None, value="min", step=None, **kwargs):
|
| 279 |
if value == "min":
|
| 280 |
value = min_value if min_value is not None else (0.0 if isinstance(step, float) else 0)
|
| 281 |
+
numeric_types = [item for item in ((min_value, max_value, step) if value is None else (value,)) if item is not None]
|
| 282 |
+
integer = bool(numeric_types) and all(isinstance(item, int) and not isinstance(item, bool) for item in numeric_types)
|
| 283 |
return _widget("number_input", label, value, min_value=min_value, max_value=max_value,
|
| 284 |
step=step or (1 if integer else 0.01), integer=integer,
|
| 285 |
decoder=int if integer else float, **kwargs)
|
src/agilab/lib/agi-web/src/agi_web/react_python_host_assets/agilab_react_python_host.css
CHANGED
|
The diff for this file is too large to render.
See raw diff
|
|
|
src/agilab/lib/agi-web/src/agi_web/react_python_host_assets/agilab_react_python_host.js
CHANGED
|
The diff for this file is too large to render.
See raw diff
|
|
|
src/agilab/lib/agi-web/test/test_python_ui_rendering_and_input_contracts.py
CHANGED
|
@@ -10,6 +10,7 @@ import pytest
|
|
| 10 |
|
| 11 |
from agi_web import python_ui as ui
|
| 12 |
from agi_web.python_view_session import RerunView, UIError, ViewSession, use_session
|
|
|
|
| 13 |
|
| 14 |
|
| 15 |
@pytest.fixture
|
|
@@ -83,6 +84,44 @@ def test_text_and_status_helpers_preserve_visible_content(session):
|
|
| 83 |
assert ui.status("Done", state="complete").node["props"]["state"] == "complete"
|
| 84 |
|
| 85 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 86 |
def test_duplicate_control_keys_and_numeric_defaults(session):
|
| 87 |
assert ui.toggle("Toggle", key="toggle") is False
|
| 88 |
assert ui.text_area("Notes", value="kept", key="notes") == "kept"
|
|
@@ -407,3 +446,90 @@ def test_component_duplicate_keys_config_and_private_secret_files(session, monke
|
|
| 407 |
assert ui.secrets.fixture == ui.secrets["fixture"] == "synthetic fixture value"
|
| 408 |
with pytest.raises(AttributeError):
|
| 409 |
_ = ui.secrets.missing
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 10 |
|
| 11 |
from agi_web import python_ui as ui
|
| 12 |
from agi_web.python_view_session import RerunView, UIError, ViewSession, use_session
|
| 13 |
+
from agi_web.testing import AppTest
|
| 14 |
|
| 15 |
|
| 16 |
@pytest.fixture
|
|
|
|
| 84 |
assert ui.status("Done", state="complete").node["props"]["state"] == "complete"
|
| 85 |
|
| 86 |
|
| 87 |
+
@pytest.mark.parametrize(
|
| 88 |
+
"bounds, entered, expected_type, expected_step",
|
| 89 |
+
[
|
| 90 |
+
({"min_value": 0, "max_value": 4}, 4, int, 1),
|
| 91 |
+
({"max_value": 4}, 2, int, 1),
|
| 92 |
+
({"step": 2}, 4, int, 2),
|
| 93 |
+
({"min_value": 0.0, "max_value": 4.0}, 1.5, float, 0.01),
|
| 94 |
+
({"min_value": 0, "max_value": 4.0}, 1.5, float, 0.01),
|
| 95 |
+
({"min_value": 0, "max_value": 4, "step": 0.5}, 1.5, float, 0.5),
|
| 96 |
+
({}, 1.5, float, 0.01),
|
| 97 |
+
],
|
| 98 |
+
)
|
| 99 |
+
def test_empty_numeric_control_infers_type_and_round_trips_native_input(
|
| 100 |
+
bounds, entered, expected_type, expected_step
|
| 101 |
+
):
|
| 102 |
+
app = AppTest.from_function(
|
| 103 |
+
lambda: ui.number_input("Score", value=None, key="score", **bounds)
|
| 104 |
+
).run()
|
| 105 |
+
assert not app.exception
|
| 106 |
+
assert app.number_input("score").value is None
|
| 107 |
+
assert app.number_input("score").proto.integer is (expected_type is int)
|
| 108 |
+
assert app.number_input("score").step == expected_step
|
| 109 |
+
app.number_input("score").set_value(entered).run()
|
| 110 |
+
assert not app.exception
|
| 111 |
+
assert type(app.session_state["score"]) is expected_type
|
| 112 |
+
assert app.session_state["score"] == entered
|
| 113 |
+
|
| 114 |
+
|
| 115 |
+
@pytest.mark.parametrize("invalid", [1.5, True, False])
|
| 116 |
+
def test_empty_integer_control_rejects_fractional_and_boolean_native_input(invalid):
|
| 117 |
+
app = AppTest.from_function(
|
| 118 |
+
lambda: ui.number_input("Score", 0, 4, value=None, key="score")
|
| 119 |
+
).run()
|
| 120 |
+
with pytest.raises(UIError, match="Expected (an integer|a number)"):
|
| 121 |
+
app.number_input("score").set_value(invalid).run()
|
| 122 |
+
assert app.session_state["score"] is None
|
| 123 |
+
|
| 124 |
+
|
| 125 |
def test_duplicate_control_keys_and_numeric_defaults(session):
|
| 126 |
assert ui.toggle("Toggle", key="toggle") is False
|
| 127 |
assert ui.text_area("Notes", value="kept", key="notes") == "kept"
|
|
|
|
| 446 |
assert ui.secrets.fixture == ui.secrets["fixture"] == "synthetic fixture value"
|
| 447 |
with pytest.raises(AttributeError):
|
| 448 |
_ = ui.secrets.missing
|
| 449 |
+
|
| 450 |
+
|
| 451 |
+
def test_popover_context_and_container_passthrough_keep_children_scoped(session):
|
| 452 |
+
outer = ui.container(key="file-picker")
|
| 453 |
+
with outer.popover("Choose dataset", help="Choose a CSV", width="stretch", key="datasets"):
|
| 454 |
+
ui.text_input("Filter files", key="dataset-filter")
|
| 455 |
+
with ui.popover("Advanced", disabled=True):
|
| 456 |
+
ui.caption("Nested details")
|
| 457 |
+
ui.text("Outside")
|
| 458 |
+
disclosure = outer.node["children"][0]
|
| 459 |
+
assert disclosure["kind"] == "popover"
|
| 460 |
+
assert disclosure["props"]["label"] == "Choose dataset"
|
| 461 |
+
assert disclosure["props"]["help"] == "Choose a CSV"
|
| 462 |
+
assert disclosure["props"]["width"] == "stretch"
|
| 463 |
+
assert disclosure["children"][0]["kind"] == "text_input"
|
| 464 |
+
assert disclosure["children"][1]["kind"] == "popover"
|
| 465 |
+
assert disclosure["children"][1]["props"]["disabled"] is True
|
| 466 |
+
assert disclosure["children"][1]["children"][0]["kind"] == "caption"
|
| 467 |
+
assert session.roots["main"][-1]["props"]["body"] == "Outside"
|
| 468 |
+
assert len(session.stack) == 1
|
| 469 |
+
|
| 470 |
+
|
| 471 |
+
def test_real_file_picker_csv_controls_select_filter_roots_manual_path_and_upload(tmp_path):
|
| 472 |
+
from agi_gui.file_picker import agi_file_picker
|
| 473 |
+
|
| 474 |
+
first = tmp_path / "datasets"
|
| 475 |
+
second = tmp_path / "exports"
|
| 476 |
+
first.mkdir()
|
| 477 |
+
second.mkdir()
|
| 478 |
+
dataset = first / "input_1000_rows.csv"
|
| 479 |
+
dataset.write_text("x,y\n" + "".join(f"{row},{row * 2}\n" for row in range(1_000)))
|
| 480 |
+
exported = second / "export.csv"
|
| 481 |
+
exported.write_text("x,y\n1,2\n")
|
| 482 |
+
|
| 483 |
+
def page():
|
| 484 |
+
ui.session_state["selected_csv"] = agi_file_picker(
|
| 485 |
+
"Dataset", key="dataset-picker", roots={"Datasets": first, "Exports": second},
|
| 486 |
+
patterns="*.csv", allow_dirs=False, container=ui.container(),
|
| 487 |
+
allow_upload=True, upload_dir=first, upload_types=["csv"],
|
| 488 |
+
)
|
| 489 |
+
|
| 490 |
+
app = AppTest.from_function(page).run()
|
| 491 |
+
assert not app.exception
|
| 492 |
+
assert len(app.get("popover")) == 1
|
| 493 |
+
assert app.get("pills")[0].value == "Datasets"
|
| 494 |
+
table = app.get("dataframe")[0]
|
| 495 |
+
assert len(table.node["props"]["rows"]) == 1
|
| 496 |
+
table.set_value(ui.SessionState(selection=ui.SessionState(rows=[0], columns=[]))).run()
|
| 497 |
+
assert not app.exception
|
| 498 |
+
assert app.session_state["selected_csv"] == str(dataset)
|
| 499 |
+
app.get("text_input")[0].input("no matching CSV").run()
|
| 500 |
+
assert not app.exception
|
| 501 |
+
assert not app.get("dataframe")
|
| 502 |
+
assert any("No matching files" in item.value for item in app.get("caption"))
|
| 503 |
+
app.get("text_input")[0].input("").run()
|
| 504 |
+
app.get("pills")[0].select("Exports").run()
|
| 505 |
+
assert not app.exception
|
| 506 |
+
assert app.get("dataframe")[0].node["props"]["rows"][0][0] == "export.csv"
|
| 507 |
+
app.get("text_input")[1].input(str(exported)).run()
|
| 508 |
+
next(item for item in app.get("button") if item.label == "Use path").click().run()
|
| 509 |
+
assert not app.exception
|
| 510 |
+
assert app.session_state["selected_csv"] == str(exported)
|
| 511 |
+
app.get("pills")[0].select("Datasets").run()
|
| 512 |
+
app.get("file_uploader")[0].upload("uploaded.csv", b"x,y\n3,6\n", "text/csv").run()
|
| 513 |
+
assert not app.exception
|
| 514 |
+
uploaded = first / "uploaded.csv"
|
| 515 |
+
assert uploaded.read_bytes() == b"x,y\n3,6\n"
|
| 516 |
+
assert app.session_state["selected_csv"] == str(uploaded)
|
| 517 |
+
assert app.session_state["dataset-picker:upload"] == []
|
| 518 |
+
app.run()
|
| 519 |
+
assert not app.exception
|
| 520 |
+
assert app.session_state["selected_csv"] == str(uploaded)
|
| 521 |
+
app.get("file_uploader")[0].upload("next_upload.csv", b"x,y\n4,8\n", "text/csv").run()
|
| 522 |
+
assert not app.exception
|
| 523 |
+
next_upload = first / "next_upload.csv"
|
| 524 |
+
assert next_upload.read_bytes() == b"x,y\n4,8\n"
|
| 525 |
+
assert app.session_state["selected_csv"] == str(next_upload)
|
| 526 |
+
assert uploaded.read_bytes() == b"x,y\n3,6\n"
|
| 527 |
+
app.get("dataframe")[0].set_value(ui.SessionState(selection=ui.SessionState(rows=[0], columns=[]))).run()
|
| 528 |
+
assert app.session_state["selected_csv"] == str(dataset)
|
| 529 |
+
app.get("text_input")[1].input(str(next_upload)).run()
|
| 530 |
+
next(item for item in app.get("button") if item.label == "Use path").click().run()
|
| 531 |
+
assert not app.exception
|
| 532 |
+
assert app.session_state["selected_csv"] == str(next_upload)
|
| 533 |
+
app.run()
|
| 534 |
+
assert not app.exception
|
| 535 |
+
assert app.session_state["selected_csv"] == str(next_upload)
|
src/agilab/notebooks/notebook_export_support.py
CHANGED
|
@@ -18,7 +18,7 @@ import tomllib
|
|
| 18 |
from agi_env.app_provider_registry import app_name_aliases
|
| 19 |
from agi_env.app_settings_support import read_app_settings
|
| 20 |
|
| 21 |
-
from agilab.notebooks.notebook_helper_cell import _helper_cell
|
| 22 |
|
| 23 |
from agilab.ui.page_bundle_registry import discover_page_bundle
|
| 24 |
|
|
@@ -1732,6 +1732,10 @@ def build_notebook_export_handoff_markdown(manifest: Mapping[str, Any]) -> str:
|
|
| 1732 |
"3. Run `run_agilab_stage(i)` for one stage, or `run_agilab_pipeline()` for the full workflow.",
|
| 1733 |
"4. Edit `STAGE_###_CODE` cells when you want the notebook to become the new source of truth.",
|
| 1734 |
"",
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1735 |
"## Commands",
|
| 1736 |
"",
|
| 1737 |
"```bash",
|
|
@@ -2272,6 +2276,7 @@ def build_notebook_document(
|
|
| 2272 |
f"- Export mode: `{export_context.export_mode}`",
|
| 2273 |
"- First run `validate_agilab_export()` to check local paths before executing workflow code.",
|
| 2274 |
"- Use `run_agilab_stage(i)` or `run_agilab_pipeline()` to execute workflow stages in their recorded runtime.",
|
|
|
|
| 2275 |
"- Disabled/skipped stages and output-skip rules remain enforced by the exported runner helpers.",
|
| 2276 |
"- The selected automation profile is applied; module max_workers is preserved for re-import, while notebook pipeline execution stays sequential.",
|
| 2277 |
"- ID-less stages can be re-imported only while the stage at the recorded module/index still matches its export fingerprint. Notebook source edits are accepted; if the AGILAB target changed, refresh the export or add an explicit stage ID.",
|
|
|
|
| 18 |
from agi_env.app_provider_registry import app_name_aliases
|
| 19 |
from agi_env.app_settings_support import read_app_settings
|
| 20 |
|
| 21 |
+
from agilab.notebooks.notebook_helper_cell import SDK_WORKER_DEPLOYMENT_HANDOFF, _helper_cell
|
| 22 |
|
| 23 |
from agilab.ui.page_bundle_registry import discover_page_bundle
|
| 24 |
|
|
|
|
| 1732 |
"3. Run `run_agilab_stage(i)` for one stage, or `run_agilab_pipeline()` for the full workflow.",
|
| 1733 |
"4. Edit `STAGE_###_CODE` cells when you want the notebook to become the new source of truth.",
|
| 1734 |
"",
|
| 1735 |
+
"## SDK deployment prerequisites",
|
| 1736 |
+
"",
|
| 1737 |
+
SDK_WORKER_DEPLOYMENT_HANDOFF,
|
| 1738 |
+
"",
|
| 1739 |
"## Commands",
|
| 1740 |
"",
|
| 1741 |
"```bash",
|
|
|
|
| 2276 |
f"- Export mode: `{export_context.export_mode}`",
|
| 2277 |
"- First run `validate_agilab_export()` to check local paths before executing workflow code.",
|
| 2278 |
"- Use `run_agilab_stage(i)` or `run_agilab_pipeline()` to execute workflow stages in their recorded runtime.",
|
| 2279 |
+
"- " + SDK_WORKER_DEPLOYMENT_HANDOFF,
|
| 2280 |
"- Disabled/skipped stages and output-skip rules remain enforced by the exported runner helpers.",
|
| 2281 |
"- The selected automation profile is applied; module max_workers is preserved for re-import, while notebook pipeline execution stays sequential.",
|
| 2282 |
"- ID-less stages can be re-imported only while the stage at the recorded module/index still matches its export fingerprint. Notebook source edits are accepted; if the AGILAB target changed, refresh the export or add an explicit stage ID.",
|
src/agilab/notebooks/notebook_helper_cell.py
CHANGED
|
@@ -8,9 +8,8 @@
|
|
| 8 |
`_helper_cell` is a single ~1250-line f-string holding the Python that exported
|
| 9 |
notebooks run to locate their app, rebuild stage context, and render analysis
|
| 10 |
pages. It carried a third of notebook_export_support.py while sharing nothing
|
| 11 |
-
with it -- the
|
| 12 |
-
|
| 13 |
-
code.
|
| 14 |
|
| 15 |
Keeping it here leaves the export logic readable and lets this file be reviewed
|
| 16 |
as what it is: emitted source, not control flow.
|
|
@@ -23,6 +22,14 @@ import textwrap
|
|
| 23 |
from typing import Any
|
| 24 |
|
| 25 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 26 |
def _helper_cell(payload: dict[str, Any]) -> str:
|
| 27 |
payload_literal = repr(json.dumps(payload, ensure_ascii=False))
|
| 28 |
return textwrap.dedent(
|
|
@@ -357,6 +364,10 @@ def _helper_cell(payload: dict[str, Any]) -> str:
|
|
| 357 |
"3. Render related analysis pages if configured.",
|
| 358 |
"4. If you edit stage code, re-import the notebook into AGILAB when you want lab_stages.toml to become the source of truth again.",
|
| 359 |
"",
|
|
|
|
|
|
|
|
|
|
|
|
|
| 360 |
"## Paths",
|
| 361 |
"",
|
| 362 |
"- Artifact directory: `" + str(AGILAB_NOTEBOOK_EXPORT.get("artifact_dir") or "(not set)") + "`",
|
|
@@ -988,6 +999,10 @@ def _helper_cell(payload: dict[str, Any]) -> str:
|
|
| 988 |
inherited_mode = run_args.pop("mode", None)
|
| 989 |
if inherited_mode not in (None, ""):
|
| 990 |
run_mode = inherited_mode
|
|
|
|
|
|
|
|
|
|
|
|
|
| 991 |
run_params = dict(run_args)
|
| 992 |
run_stages_payload = run_params.pop("stages", []) or []
|
| 993 |
if "args" in run_params:
|
|
|
|
| 8 |
`_helper_cell` is a single ~1250-line f-string holding the Python that exported
|
| 9 |
notebooks run to locate their app, rebuild stage context, and render analysis
|
| 10 |
pages. It carried a third of notebook_export_support.py while sharing nothing
|
| 11 |
+
with it -- the caller payload and the shared deployment instructions are embedded
|
| 12 |
+
as literals. Braces belonging to the generated Python are `{{`-escaped.
|
|
|
|
| 13 |
|
| 14 |
Keeping it here leaves the export logic readable and lets this file be reviewed
|
| 15 |
as what it is: emitted source, not control flow.
|
|
|
|
| 22 |
from typing import Any
|
| 23 |
|
| 24 |
|
| 25 |
+
SDK_WORKER_DEPLOYMENT_HANDOFF = (
|
| 26 |
+
"SDK run stages using `AGI.run` (including app shorthand) need deployed manager and worker environments, "
|
| 27 |
+
"including mode `0` (local Python). Before running them, use **ORCHESTRATE → Resources → Deploy scheduler & workers**, "
|
| 28 |
+
"or include an explicit `AGI.install` stage in your plan. Export preserves your plan and does not add an installation stage. "
|
| 29 |
+
"Python stages that do not call `AGI.run` remain independently editable and executable with their own dependencies."
|
| 30 |
+
)
|
| 31 |
+
|
| 32 |
+
|
| 33 |
def _helper_cell(payload: dict[str, Any]) -> str:
|
| 34 |
payload_literal = repr(json.dumps(payload, ensure_ascii=False))
|
| 35 |
return textwrap.dedent(
|
|
|
|
| 364 |
"3. Render related analysis pages if configured.",
|
| 365 |
"4. If you edit stage code, re-import the notebook into AGILAB when you want lab_stages.toml to become the source of truth again.",
|
| 366 |
"",
|
| 367 |
+
"## SDK deployment prerequisites",
|
| 368 |
+
"",
|
| 369 |
+
{SDK_WORKER_DEPLOYMENT_HANDOFF!r},
|
| 370 |
+
"",
|
| 371 |
"## Paths",
|
| 372 |
"",
|
| 373 |
"- Artifact directory: `" + str(AGILAB_NOTEBOOK_EXPORT.get("artifact_dir") or "(not set)") + "`",
|
|
|
|
| 999 |
inherited_mode = run_args.pop("mode", None)
|
| 1000 |
if inherited_mode not in (None, ""):
|
| 1001 |
run_mode = inherited_mode
|
| 1002 |
+
# Older Workflow templates used this label for SDK local Python.
|
| 1003 |
+
# Other values keep the SDK's own validation and mode semantics.
|
| 1004 |
+
if run_mode == "local":
|
| 1005 |
+
run_mode = 0
|
| 1006 |
run_params = dict(run_args)
|
| 1007 |
run_stages_payload = run_params.pop("stages", []) or []
|
| 1008 |
if "args" in run_params:
|