Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/)
- **Applications:** each method now shows the inputs it expects and a live status telling whether the current selection can be analyzed, and why not. _Run on selection…_ runs a ready method directly and asks for the input assignment only when it is ambiguous or invalid; the assignment dialog describes each input, flags objects lacking required metadata, and checks the chosen objects before _Continue_. Examples are listed under the methods they are designed for: _Try with this example_ opens the example and runs the method with prefilled parameters. One example may serve several methods, and examples designed for no method are listed as datasets.
- **Applications window:** the strip between the application list and the application page hides the list, or shows it again; the window shrinks or grows accordingly and remembers this choice. The application page now scrolls on its own, so the list and the run status stay in view.
- **Application methods:** the methods and datasets of an application now form an accordion. One section is open at a time (the deep-linked method, or else the first one), and a colored dot shows whether each method can run on the current selection without opening it.
- **Application metadata:** when required metadata are missing, the status of a method points to _Edit > Metadata > Add metadata…_, whose **Known keys** list now also offers the keys expected by application methods. The status is updated as soon as objects are modified, and long keys no longer widen parameter dialogs.
- **Welcome page:** the "NEW" badge on the release notes entry has been removed.
- **Histogram range editors:** generic parameter intervals now respect linked numeric constraints and read-only states. Brightness and contrast retains its specialized controls, including exact float64 window editing outside the source image's observed range.
- **Guidata compatibility:** browser runtimes now require guidata 3.15 and use
Expand Down
10 changes: 10 additions & 0 deletions src/components/ApplicationsDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,15 @@ export function ApplicationsDialog({
const selectedPluginId = selected?.plugin_id ?? null;
const recipeKey = selected?.recipes.map((recipe) => recipe.id).join("\n");
const candidateKey = candidateIds.join("\n");
// Edits such as Add metadata change readiness without changing the selection
const [objectsRevision, setObjectsRevision] = useState(0);
useEffect(
() =>
runtime?.onWorkspaceMutation(() =>
setObjectsRevision((revision) => revision + 1),
),
[runtime],
);
useEffect(() => {
if (!runtime || !selectedPluginId || !recipeKey || busy) return;
let cancelled = false;
Expand Down Expand Up @@ -235,6 +244,7 @@ export function ApplicationsDialog({
busy,
candidateKey,
exampleContext,
objectsRevision,
recipeKey,
runtime,
selectedPluginId,
Expand Down
1 change: 1 addition & 0 deletions src/locales/fr.json
Original file line number Diff line number Diff line change
Expand Up @@ -1069,6 +1069,7 @@
"Time deviation…": "Ecart-type temporel…",
"Title": "Titre",
"to load an HDF5 file.": "pour charger un fichier HDF5.",
"To set it, use Edit > Metadata > Add metadata…": "Pour la définir, utilisez Édition > Métadonnées > Ajouter des métadonnées…",
"Toolbar": "Barre d'outils",
"Total variance": "Variance totale",
"Total variance…": "Variance totale…",
Expand Down
15 changes: 13 additions & 2 deletions src/runtime/bootstrap.py
Original file line number Diff line number Diff line change
Expand Up @@ -2045,8 +2045,11 @@ class AddMetadataParam(

def _metadata_key_suggestions(objs: list[Any]) -> list[tuple[str, str]]:
"""Return the user-visible scalar metadata keys of *objs*, with an example
value (mirrors Qt's ``collect_metadata_keys``)."""
value, then the keys expected by application methods (mirrors Qt's
``BaseDataPanel.get_known_metadata_keys``)."""
import dlw_plugins
from sigima.config import _
from sigima.objects import SignalObj
from sigima.objects.base import ROI_KEY

examples: dict[str, Any] = {}
Expand All @@ -2058,7 +2061,15 @@ def _metadata_key_suggestions(objs: list[Any]) -> list[tuple[str, str]]:
continue
if isinstance(value, (str, bool, int, float, np.integer, np.floating)):
examples[key] = value
return [(key, _("e.g. %s") % repr(examples[key])) for key in sorted(examples)]
keys = [(key, _("e.g. %s") % repr(examples[key])) for key in sorted(examples)]
kinds = {"signal" if isinstance(obj, SignalObj) else "image" for obj in objs}
if len(kinds) == 1:
keys.extend(
(key, description)
for key, description in dlw_plugins.declared_metadata_keys(kinds.pop())
if key not in examples
)
return keys


def _build_metadata_values(objs: list[Any], param: Any) -> list[Any]:
Expand Down
31 changes: 30 additions & 1 deletion src/runtime/dlw_plugins.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@
# pattern that lets ``install_main`` swap the live bridge after HMR.
# pylint: disable=import-error,global-statement,broad-exception-caught
from datalab import registries # noqa: F401 # used elsewhere in module
from datalab.plugins import PluginBase, PluginRegistry # noqa: F401
from datalab.plugins import PluginBase, PluginCapability, PluginRegistry # noqa: F401
from dlw_wheels import inspect_wheel

PLUGINS_ROOT = "/home/pyodide/dlw_plugins"
Expand Down Expand Up @@ -630,6 +630,34 @@ def get_plugin_class(
return record.classes[0]


def declared_metadata_keys(object_type: str) -> list[tuple[str, str]]:
"""Return the metadata keys expected by the methods of active applications.

Args:
object_type: type of the objects carrying the metadata ("signal" or
"image")

Returns:
``(key, description)`` pairs, in declaration order, without duplicates
"""
keys: dict[str, str] = {}
for record in _RECORDS.values():
instance = record.instance
if not record.enabled or instance is None or not record.classes:
continue
if instance.info is None or (
PluginCapability.APPLICATION not in instance.info.capabilities
):
continue
for recipe in record.classes[0].get_recipes():
for slot in recipe.inputs:
if slot.object_type.value != object_type:
continue
for requirement in slot.metadata:
keys.setdefault(requirement.key, requirement.description)
return list(keys.items())


def slot_payload(slot: Any) -> dict[str, Any]:
"""Return the JSON description of one recipe input slot."""
return {
Expand Down Expand Up @@ -738,6 +766,7 @@ def _record_payload(record: PluginRecord) -> dict[str, Any]:
"PLUGINS_ROOT",
"PluginRecord",
"add_change_listener",
"declared_metadata_keys",
"discover_plugins_in_dir",
"get_plugin_class",
"inspect_plugin_wheel",
Expand Down
2 changes: 2 additions & 0 deletions src/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -3879,6 +3879,8 @@ input {
display: flex;
align-items: center;
gap: 6px;
/* Long choices (e.g. plugin metadata keys) must not widen the form. */
min-width: 0;
}

.dataset-form-control > input,
Expand Down
3 changes: 3 additions & 0 deletions src/utils/recipeInputs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,9 @@ export function formatReadiness(
const reasons = assessment.issues.map((issue) =>
formatInputIssue(issue, slots),
);
if (assessment.issues.some((issue) => issue.code === "missing_metadata")) {
reasons.push(t("To set it, use Edit > Metadata > Add metadata…"));
}
if (assessment.status !== "no_input") {
reasons.push(...assessment.diagnostics.map(formatDiagnostic));
}
Expand Down
27 changes: 27 additions & 0 deletions tests/python/test_applications.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@

from __future__ import annotations

import dataclasses

import dlw_applications
import dlw_plugins
import numpy as np
Expand Down Expand Up @@ -136,6 +138,31 @@ def _add_input_signal(bootstrap) -> str:
return bootstrap.add_signal_from_arrays("Input", [0.0, 1.0], [1.0, 3.0])


def test_add_metadata_suggests_keys_declared_by_applications(
applications_env, monkeypatch
) -> None:
bootstrap, record = applications_env
key = "plugin.org.example.generic-application.gain"
slot = RecipeInputSlot(
"source",
RecipeObjectType.SIGNAL,
RecipeCardinality.ONE,
metadata=(RecipeMetadataRequirement(key, "Detector gain"),),
)
recipe = dataclasses.replace(GENERIC_RECIPE, inputs=(slot,))
monkeypatch.setattr(GenericApplicationPlugin, "RECIPES", (recipe,))
signal = bootstrap._MODEL.get(_add_input_signal(bootstrap))
signal.metadata["operator"] = "Ada"

assert bootstrap._metadata_key_suggestions([signal]) == [
("operator", "e.g. 'Ada'"),
(key, "Detector gain"),
]
assert dlw_plugins.declared_metadata_keys("image") == []
record.enabled = False
assert dlw_plugins.declared_metadata_keys("signal") == []


def test_prepare_recipe_returns_schema_and_unambiguous_binding(
applications_env,
) -> None:
Expand Down
46 changes: 46 additions & 0 deletions tests/ts/components/ApplicationsDialog.test.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import {
act,
fireEvent,
render,
screen,
Expand Down Expand Up @@ -30,8 +31,11 @@ const runtimeMock = vi.hoisted(() => ({
resolvePluginRecipeChoices: vi.fn(),
resolvePluginRecipeCallbacks: vi.fn(),
resolvePluginRecipeActive: vi.fn(),
onWorkspaceMutation: vi.fn(),
}));

const mutationListeners: ((name: string) => void)[] = [];

vi.mock("../../../src/runtime/RuntimeContext", () => ({
useRuntime: () => ({ runtime: runtimeMock as unknown as RuntimeApi }),
}));
Expand Down Expand Up @@ -224,6 +228,15 @@ function renderDialog(

beforeEach(() => {
vi.clearAllMocks();
mutationListeners.length = 0;
runtimeMock.onWorkspaceMutation.mockImplementation(
(listener: (name: string) => void) => {
mutationListeners.push(listener);
return () => {
mutationListeners.splice(mutationListeners.indexOf(listener), 1);
};
},
);
runtimeMock.listPlugins.mockResolvedValue([APPLICATION]);
runtimeMock.assessPluginRecipes.mockResolvedValue({ [RECIPE_ID]: READY });
runtimeMock.preparePluginRecipe.mockResolvedValue(PREPARATION);
Expand Down Expand Up @@ -321,6 +334,39 @@ describe("ApplicationsDialog", () => {
);
});

it("re-assesses the methods when workspace objects change", async () => {
renderDialog();
await screen.findByText("Ready to run on the current selection");
const assessments = runtimeMock.assessPluginRecipes.mock.calls.length;

runtimeMock.assessPluginRecipes.mockResolvedValueOnce({
[RECIPE_ID]: {
status: "not_ready",
bindings: { frames: ["image-1", "image-2"] },
issues: [
{
code: "missing_metadata",
slot_id: "frames",
details: { key: "exposure_time", count: 2, titles: ["A", "B"] },
},
],
diagnostics: [],
},
});
act(() => {
for (const listener of [...mutationListeners]) {
listener("delete_object_metadata");
}
});

expect(
await screen.findByText("To set it, use Edit > Metadata > Add metadata…"),
).toBeInTheDocument();
expect(runtimeMock.assessPluginRecipes).toHaveBeenCalledTimes(
assessments + 1,
);
});

it("runs a ready method directly on the current selection", async () => {
const onCommitted = vi.fn();
renderDialog({ onCommitted });
Expand Down