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
8 changes: 5 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,14 +27,16 @@ appears in RustGIS:
|---|---|---|
| [`commands`](starters/commands) (the default) | buttons in a group named after the plugin on the **Plugins** tab | reading the map, running RustGIS commands, adding a layer, translated messages |
| [`tab`](starters/tab) | a ribbon tab of its own, with groups, a dropdown menu and a tool button | `tab` in `plugin.json`, moving the map, a tool opened from the ribbon |
| [`panel`](starters/panel) | a pane beside the map, opened from the **Plugins** tab | `panel` in `plugin.json`: a slider, a checkbox, texts and buttons bound to commands, and the state the plugin answers |
| [`panel`](starters/panel) | a pane beside the map, opened from the **Plugins** tab | `panel` in `plugin.json`: a slider, a checkbox, texts and buttons bound to commands, and the state the plugin answers (a `tree` of nodes it gives too) |
| [`tools`](starters/tools) | geoprocessing tools in the **Tools** pane, under **Plugins ›** | tool parameters (layer, field, choice, number, bool), new layers and a selection, made with Shapely from `requirements.txt` |

The sections are independent: one plugin can have commands, tools, a tab and a panel. Start
from one starter and copy what you need from the others.

Panels need a RustGIS newer than 0.3.0. Older versions ignore `panel`, and the panel starter's
commands still appear on the Plugins tab.
commands still appear on the Plugins tab. A panel `tree` (nodes the plugin gives with
`Panel().nodes(...)` and `Node`, the chosen one's id in `req.panel`) needs a RustGIS newer than
0.4.0, which refuses a manifest with one.

## Quick start

Expand All @@ -51,7 +53,7 @@ Everything RustGIS runs is in [`plugin/`](plugin):
|---|---|
| `plugin.json` | the manifest: id, version, permissions, commands, tools, tab, panel, translations, and `runtime`, `python` and `run` |
| `main.py` | the plugin |
| `rustgis_plugin.py` | the SDK: the protocol, `host` requests, `Request`, `ToolOutput`, `Panel` (standard library only) |
| `rustgis_plugin.py` | the SDK: the protocol, `host` requests, `Request`, `ToolOutput`, `Panel`, `Node` (standard library only) |
| `requirements.txt` | the packages the plugin needs |

Put any other modules or data files the plugin needs in `plugin/` as well; the package holds
Expand Down
57 changes: 53 additions & 4 deletions plugin/rustgis_plugin.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,11 +26,12 @@ def handle(req: Request):
import base64
import json
import sys
from collections.abc import Callable
from collections.abc import Callable, Iterable
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any

__all__ = ["ABI", "Panel", "PluginError", "Request", "ToolOutput", "geojson", "host", "serve"]
__all__ = ["ABI", "Node", "Panel", "PluginError", "Request", "ToolOutput", "geojson", "host", "serve"]

#: The plugin ABI this SDK speaks. plugin.json's `abi`, when given, must match it.
ABI = 1
Expand Down Expand Up @@ -381,6 +382,39 @@ def to_dict(self) -> dict:
return out


@dataclass
class Node:
"""A node of a panel tree: a theme, a dataset...

Its id is the tree's value when it is chosen:

env = Node("env", "Environment", count=2, children=[Node("parks", "Parks"), Node("rivers", "Rivers")])
return Panel().nodes("catalogue", [env, Node("roads", "Roads")]).answer()
"""

#: What `req.panel.get(tree)` gives when this node is chosen.
id: str
#: The text the tree shows.
label: str
#: A number shown beside the label (the datasets in a theme, say); None shows none.
count: int | None = None
#: The nodes under this one.
children: list[Node] = field(default_factory=list)

def to_dict(self) -> dict:
"""The node, as RustGIS reads it: `count` and `children` only when it has them.

Returns:
`{"id", "label", "count"?, "children"?}`.
"""
out: dict[str, Any] = {"id": self.id, "label": self.label}
if self.count is not None:
out["count"] = self.count
if self.children:
out["children"] = [c.to_dict() for c in self.children]
return out


class Panel:
"""A panel's new state, which any command of a plugin with a panel can answer.

Expand All @@ -393,8 +427,9 @@ def __init__(self) -> None:
self.values: dict[str, Any] = {}
self.items: dict[str, dict] = {}

def value(self, widget: str, value: bool | int) -> Panel:
"""Sets a checkbox (`True`/`False`) or a slider (a whole number).
def value(self, widget: str, value: bool | int | str | None) -> Panel:
"""Sets a checkbox (`True`/`False`), a slider (a whole number) or a tree (a node's id, or
`None` for no node chosen).

Args:
widget: The widget's id.
Expand Down Expand Up @@ -471,6 +506,20 @@ def slider(self, widget: str, lo: int, hi: int, labels: list[str] | None = None)
fields["labels"] = labels
return self._item(widget, **fields)

def nodes(self, widget: str, nodes: Iterable[Node]) -> Panel:
"""Gives a tree its nodes, replacing the ones it had. RustGIS keeps at most 5,000 nodes, 8
levels deep, and leaves out a node without an id, or whose id is taken already (with its
children).

Args:
widget: The tree's id.
nodes: Its top nodes, with their children.

Returns:
This state.
"""
return self._item(widget, nodes=[n.to_dict() for n in nodes])

def _item(self, widget: str, **fields: Any) -> Panel:
self.items.setdefault(widget, {}).update(fields)
return self
Expand Down
28 changes: 28 additions & 0 deletions tests/test_sdk.py
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,34 @@ def test_tool_output_and_panel_state():
}


def test_panel_trees_take_nodes_and_give_the_chosen_one():
req = rp.Request({"panel": {"values": {"catalogue": "parks", "empty": None}, "changed": ["catalogue"]}})
assert (req.panel.get("catalogue"), req.panel.get("empty"), req.panel.get("gone")) == ("parks", None, None)

env = rp.Node("env", "Environment", count=2, children=[rp.Node("parks", "Parks"), rp.Node("rivers", "Rivers", count=0)])
p = rp.Panel().nodes("catalogue", [env, rp.Node("roads", "Roads")]).value("catalogue", "parks")
assert p.to_dict() == {
"values": {"catalogue": "parks"},
"items": {
"catalogue": {
"nodes": [
{
"id": "env",
"label": "Environment",
"count": 2,
"children": [{"id": "parks", "label": "Parks"}, {"id": "rivers", "label": "Rivers", "count": 0}],
},
{"id": "roads", "label": "Roads"},
]
}
},
}
assert rp.Panel().nodes("catalogue", []).value("catalogue", None).to_dict() == {"values": {"catalogue": None}, "items": {"catalogue": {"nodes": []}}}
# Nodes replace the ones the tree had; other fields of the widget stay.
p = rp.Panel().label("catalogue", "Data").nodes("catalogue", [env]).nodes("catalogue", iter([rp.Node("roads", "Roads")]))
assert p.items == {"catalogue": {"label": "Data", "nodes": [{"id": "roads", "label": "Roads"}]}}


def test_geojson_helpers():
assert rp.geojson.polygon([[0, 0], [1, 0], [1, 1]])["coordinates"][0][-1] == [0, 0]
multi = {"type": "MultiPolygon", "coordinates": [[[[0, 0], [1, 0], [0, 1], [0, 0]]]]}
Expand Down
Loading