garphield 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- garphield/__init__.py +85 -0
- garphield/_bindings.py +322 -0
- garphield/_canonical.py +88 -0
- garphield/_chrome.py +57 -0
- garphield/_codegen.py +98 -0
- garphield/_colab.py +34 -0
- garphield/_environment.py +156 -0
- garphield/_html.py +153 -0
- garphield/_identity.py +251 -0
- garphield/_networkx.py +447 -0
- garphield/_pandas.py +452 -0
- garphield/_project.py +731 -0
- garphield/_scalars.py +157 -0
- garphield/_schema.py +56 -0
- garphield/_server.py +190 -0
- garphield/_snowpark.py +51 -0
- garphield/_transform_runtime.py +292 -0
- garphield/_transforms.py +135 -0
- garphield/_transport.py +568 -0
- garphield/_view.py +329 -0
- garphield/_widget.py +1232 -0
- garphield/data/message-transport-budget.v1.json +6 -0
- garphield/py.typed +1 -0
- garphield/schema/gfd.schema.json +4214 -0
- garphield/schema/transforms.v1.json +49 -0
- garphield/static/widget.js +935 -0
- garphield-0.1.0.dist-info/METADATA +108 -0
- garphield-0.1.0.dist-info/RECORD +30 -0
- garphield-0.1.0.dist-info/WHEEL +4 -0
- garphield-0.1.0.dist-info/licenses/LICENSE +21 -0
garphield/__init__.py
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"""Validated Python primitives for Garphield project files."""
|
|
2
|
+
|
|
3
|
+
from ._canonical import CanonicalizationError, canonicalize_jcs_v1
|
|
4
|
+
from ._bindings import AlgorithmSpec, BindingOptions, algorithm
|
|
5
|
+
from ._html import save_html
|
|
6
|
+
from ._identity import AttributeNameCodecV1, EdgeIdentityCodecV1, IdentityCodecV1
|
|
7
|
+
from ._project import (
|
|
8
|
+
DuplicatePropertyError,
|
|
9
|
+
Project,
|
|
10
|
+
ProjectValidationError,
|
|
11
|
+
canonicalize_project_v1,
|
|
12
|
+
canonicalize_semantic_graph_v1,
|
|
13
|
+
load,
|
|
14
|
+
)
|
|
15
|
+
from ._transport import (
|
|
16
|
+
AnyWidgetTransportEndpoint,
|
|
17
|
+
TRANSPORT_RUNTIME_CONFIG_V1,
|
|
18
|
+
PreparedBudgetedJson,
|
|
19
|
+
TransportError,
|
|
20
|
+
TransportRuntimeConfigV1,
|
|
21
|
+
prepare_budgeted_json,
|
|
22
|
+
receive_budgeted_json,
|
|
23
|
+
)
|
|
24
|
+
from ._transforms import Transformation, TransformSpec, transform
|
|
25
|
+
from ._view import GraphView
|
|
26
|
+
from ._widget import (
|
|
27
|
+
OwnershipError,
|
|
28
|
+
ProjectWidget,
|
|
29
|
+
WidgetEnvironmentUnsupportedError,
|
|
30
|
+
WidgetNotDisplayedError,
|
|
31
|
+
WidgetTransportError,
|
|
32
|
+
show,
|
|
33
|
+
show_snowpark,
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def __getattr__(name: str) -> object:
|
|
38
|
+
if name == "Tables":
|
|
39
|
+
from ._pandas import Tables
|
|
40
|
+
|
|
41
|
+
return Tables
|
|
42
|
+
if name == "IdentityCollisionError":
|
|
43
|
+
from ._networkx import IdentityCollisionError
|
|
44
|
+
|
|
45
|
+
return IdentityCollisionError
|
|
46
|
+
raise AttributeError(name)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
__all__ = [
|
|
50
|
+
"AttributeNameCodecV1",
|
|
51
|
+
"AlgorithmSpec",
|
|
52
|
+
"AnyWidgetTransportEndpoint",
|
|
53
|
+
"CanonicalizationError",
|
|
54
|
+
"BindingOptions",
|
|
55
|
+
"DuplicatePropertyError",
|
|
56
|
+
"EdgeIdentityCodecV1",
|
|
57
|
+
"IdentityCodecV1",
|
|
58
|
+
"GraphView",
|
|
59
|
+
"OwnershipError",
|
|
60
|
+
"Project",
|
|
61
|
+
"ProjectWidget",
|
|
62
|
+
"ProjectValidationError",
|
|
63
|
+
"PreparedBudgetedJson",
|
|
64
|
+
"TransportError",
|
|
65
|
+
"TransportRuntimeConfigV1",
|
|
66
|
+
"TransformSpec",
|
|
67
|
+
"Transformation",
|
|
68
|
+
"TRANSPORT_RUNTIME_CONFIG_V1",
|
|
69
|
+
"WidgetEnvironmentUnsupportedError",
|
|
70
|
+
"WidgetNotDisplayedError",
|
|
71
|
+
"WidgetTransportError",
|
|
72
|
+
"IdentityCollisionError",
|
|
73
|
+
"Tables",
|
|
74
|
+
"canonicalize_jcs_v1",
|
|
75
|
+
"algorithm",
|
|
76
|
+
"transform",
|
|
77
|
+
"canonicalize_project_v1",
|
|
78
|
+
"canonicalize_semantic_graph_v1",
|
|
79
|
+
"load",
|
|
80
|
+
"prepare_budgeted_json",
|
|
81
|
+
"receive_budgeted_json",
|
|
82
|
+
"show",
|
|
83
|
+
"show_snowpark",
|
|
84
|
+
"save_html",
|
|
85
|
+
]
|
garphield/_bindings.py
ADDED
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
"""NetworkX-shaped visual binding normalization for notebook views."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import inspect
|
|
6
|
+
from collections.abc import Callable, Iterable, Mapping, Sequence, Set
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
from typing import Any, Literal
|
|
9
|
+
|
|
10
|
+
from ._project import Project
|
|
11
|
+
from ._scalars import normalize_scalar
|
|
12
|
+
|
|
13
|
+
JsonScalar = str | int | float | bool | None
|
|
14
|
+
VariableData = (
|
|
15
|
+
str
|
|
16
|
+
| Mapping[object, object]
|
|
17
|
+
| Iterable[object]
|
|
18
|
+
| Set[object]
|
|
19
|
+
| Callable[..., object]
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass(frozen=True)
|
|
24
|
+
class AlgorithmSpec:
|
|
25
|
+
"""An explicit browser-side graph algorithm used as a visual source."""
|
|
26
|
+
|
|
27
|
+
name: str
|
|
28
|
+
params: Mapping[str, JsonScalar] = field(default_factory=dict)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def algorithm(name: str, /, **params: JsonScalar) -> AlgorithmSpec:
|
|
32
|
+
"""Describe a browser-side algorithm without confusing it with a field."""
|
|
33
|
+
|
|
34
|
+
if not isinstance(name, str) or not name.strip():
|
|
35
|
+
raise ValueError("algorithm name must be a non-empty string")
|
|
36
|
+
normalized = {
|
|
37
|
+
key: normalize_scalar(value, f"/algorithm/{name}/{key}")
|
|
38
|
+
for key, value in params.items()
|
|
39
|
+
}
|
|
40
|
+
return AlgorithmSpec(name=name, params=normalized)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass(frozen=True)
|
|
44
|
+
class BindingOptions:
|
|
45
|
+
node_color: VariableData | AlgorithmSpec | None = None
|
|
46
|
+
node_size: VariableData | AlgorithmSpec | None = None
|
|
47
|
+
node_label: VariableData | None = None
|
|
48
|
+
edge_color: VariableData | AlgorithmSpec | None = None
|
|
49
|
+
edge_width: VariableData | AlgorithmSpec | None = None
|
|
50
|
+
pos: Mapping[object, Sequence[float]] | None = None
|
|
51
|
+
layout: Literal["force", "levels", "geo", "quality"] | None = None
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
_CHANNELS = (
|
|
55
|
+
("node_color", "color", "node"),
|
|
56
|
+
("node_size", "size", "node"),
|
|
57
|
+
("node_label", "nodeLabel", "node"),
|
|
58
|
+
("edge_color", "edgeColor", "edge"),
|
|
59
|
+
("edge_width", "edgeWidth", "edge"),
|
|
60
|
+
)
|
|
61
|
+
_NUMERIC_CHANNELS = {"size", "edgeWidth"}
|
|
62
|
+
_ALGORITHM_RESULTS = {
|
|
63
|
+
"degree": "num",
|
|
64
|
+
"betweenness": "num",
|
|
65
|
+
"closeness": "num",
|
|
66
|
+
"pagerank": "num",
|
|
67
|
+
"louvain": "cat",
|
|
68
|
+
"k_core": "num",
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _call_value(function: Callable[..., object], args: tuple[object, ...]) -> object:
|
|
73
|
+
try:
|
|
74
|
+
signature = inspect.signature(function)
|
|
75
|
+
except (TypeError, ValueError):
|
|
76
|
+
return function(args[0])
|
|
77
|
+
positional = [
|
|
78
|
+
parameter
|
|
79
|
+
for parameter in signature.parameters.values()
|
|
80
|
+
if parameter.kind
|
|
81
|
+
in (parameter.POSITIONAL_ONLY, parameter.POSITIONAL_OR_KEYWORD)
|
|
82
|
+
]
|
|
83
|
+
if any(
|
|
84
|
+
parameter.kind == parameter.VAR_POSITIONAL
|
|
85
|
+
for parameter in signature.parameters.values()
|
|
86
|
+
):
|
|
87
|
+
return function(*args)
|
|
88
|
+
return function(*args[: len(positional)])
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _partition_values(
|
|
92
|
+
value: object,
|
|
93
|
+
items: list[object],
|
|
94
|
+
) -> dict[object, int] | None:
|
|
95
|
+
if not isinstance(value, (list, tuple)) or not value:
|
|
96
|
+
return None
|
|
97
|
+
if not all(isinstance(group, (set, frozenset)) for group in value):
|
|
98
|
+
return None
|
|
99
|
+
known = set(items)
|
|
100
|
+
assigned: dict[object, int] = {}
|
|
101
|
+
for index, group in enumerate(value):
|
|
102
|
+
for item in group:
|
|
103
|
+
if item not in known:
|
|
104
|
+
raise ValueError(f"partition contains unknown node {item!r}")
|
|
105
|
+
if item in assigned:
|
|
106
|
+
raise ValueError(f"partition contains node {item!r} twice")
|
|
107
|
+
assigned[item] = index
|
|
108
|
+
return assigned
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _mapping_values(value: object, items: list[object]) -> list[object] | None:
|
|
112
|
+
if isinstance(value, Mapping):
|
|
113
|
+
return [value[item] for item in items]
|
|
114
|
+
if isinstance(value, (str, bytes, list, tuple, set, frozenset)):
|
|
115
|
+
return None
|
|
116
|
+
if not hasattr(value, "__getitem__"):
|
|
117
|
+
return None
|
|
118
|
+
try:
|
|
119
|
+
return [value[item] for item in items] # type: ignore[index]
|
|
120
|
+
except (KeyError, TypeError, IndexError):
|
|
121
|
+
return None
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _node_values(graph: Any, value: VariableData) -> tuple[list[object], str]:
|
|
125
|
+
nodes = list(graph.nodes)
|
|
126
|
+
partition = _partition_values(value, nodes)
|
|
127
|
+
if partition is not None:
|
|
128
|
+
missing = [node for node in nodes if node not in partition]
|
|
129
|
+
if missing:
|
|
130
|
+
raise ValueError(f"partition omits node {missing[0]!r}")
|
|
131
|
+
return [partition[node] for node in nodes], "cat"
|
|
132
|
+
if isinstance(value, (set, frozenset)):
|
|
133
|
+
unknown = set(value).difference(nodes)
|
|
134
|
+
if unknown:
|
|
135
|
+
raise ValueError(f"set contains unknown node {next(iter(unknown))!r}")
|
|
136
|
+
return [node in value for node in nodes], "set"
|
|
137
|
+
mapped = _mapping_values(value, nodes)
|
|
138
|
+
if mapped is not None:
|
|
139
|
+
return mapped, _infer_result(mapped)
|
|
140
|
+
if callable(value):
|
|
141
|
+
values = [_call_value(value, (node, dict(graph.nodes[node]))) for node in nodes]
|
|
142
|
+
return values, _infer_result(values)
|
|
143
|
+
values = list(value)
|
|
144
|
+
if len(values) != len(nodes):
|
|
145
|
+
raise ValueError(
|
|
146
|
+
f"node binding has {len(values)} values for {len(nodes)} nodes"
|
|
147
|
+
)
|
|
148
|
+
return values, _infer_result(values)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _edge_rows(
|
|
152
|
+
graph: Any,
|
|
153
|
+
) -> list[tuple[object, object, object | None, dict[str, object]]]:
|
|
154
|
+
if graph.is_multigraph():
|
|
155
|
+
return [
|
|
156
|
+
(source, target, key, dict(attrs))
|
|
157
|
+
for source, target, key, attrs in graph.edges(keys=True, data=True)
|
|
158
|
+
]
|
|
159
|
+
return [
|
|
160
|
+
(source, target, None, dict(attrs))
|
|
161
|
+
for source, target, attrs in graph.edges(data=True)
|
|
162
|
+
]
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _edge_values(graph: Any, value: VariableData) -> tuple[list[object], str]:
|
|
166
|
+
rows = _edge_rows(graph)
|
|
167
|
+
if callable(value):
|
|
168
|
+
values = [
|
|
169
|
+
_call_value(value, (source, target, attrs))
|
|
170
|
+
for source, target, _key, attrs in rows
|
|
171
|
+
]
|
|
172
|
+
return values, _infer_result(values)
|
|
173
|
+
if isinstance(value, Mapping):
|
|
174
|
+
values = []
|
|
175
|
+
for source, target, key, _attrs in rows:
|
|
176
|
+
candidates = (
|
|
177
|
+
((source, target, key), (target, source, key))
|
|
178
|
+
if key is not None
|
|
179
|
+
else ((source, target), (target, source))
|
|
180
|
+
)
|
|
181
|
+
found = next(
|
|
182
|
+
(candidate for candidate in candidates if candidate in value), None
|
|
183
|
+
)
|
|
184
|
+
if found is None:
|
|
185
|
+
raise ValueError(f"edge binding has no value for {(source, target)!r}")
|
|
186
|
+
values.append(value[found])
|
|
187
|
+
return values, _infer_result(values)
|
|
188
|
+
values = list(value)
|
|
189
|
+
if len(values) != len(rows):
|
|
190
|
+
raise ValueError(f"edge binding has {len(values)} values for {len(rows)} edges")
|
|
191
|
+
return values, _infer_result(values)
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _infer_result(values: Sequence[object]) -> str:
|
|
195
|
+
non_null = [value for value in values if value is not None]
|
|
196
|
+
if non_null and all(
|
|
197
|
+
isinstance(value, (int, float)) and not isinstance(value, bool)
|
|
198
|
+
for value in non_null
|
|
199
|
+
):
|
|
200
|
+
return "num"
|
|
201
|
+
return "cat"
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def _unique_attribute(graph: Any, target: str, channel: str) -> str:
|
|
205
|
+
base = f"__garphield_{channel}"
|
|
206
|
+
name = base
|
|
207
|
+
suffix = 1
|
|
208
|
+
if target == "node":
|
|
209
|
+
occupied = {key for _node, attrs in graph.nodes(data=True) for key in attrs}
|
|
210
|
+
else:
|
|
211
|
+
occupied = {
|
|
212
|
+
key for *_identity, attrs in graph.edges(data=True) for key in attrs
|
|
213
|
+
}
|
|
214
|
+
while name in occupied:
|
|
215
|
+
name = f"{base}_{suffix}"
|
|
216
|
+
suffix += 1
|
|
217
|
+
return name
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def _internal_attribute(project: Project, target: str, logical: str) -> str:
|
|
221
|
+
config = project.to_dict()["config"]
|
|
222
|
+
manifest = config.get("interop", {})
|
|
223
|
+
maps = manifest.get("attributeNames", {})
|
|
224
|
+
table = maps.get("nodes" if target == "node" else "edges", {})
|
|
225
|
+
for internal, decoded in table.items():
|
|
226
|
+
if decoded == logical:
|
|
227
|
+
return internal
|
|
228
|
+
return logical
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def project_from_graph(graph: Any, options: BindingOptions) -> Project:
|
|
232
|
+
"""Create a Project from a copied graph and declarative visual bindings."""
|
|
233
|
+
|
|
234
|
+
working = graph.copy()
|
|
235
|
+
pending: list[tuple[str, str, str, object]] = []
|
|
236
|
+
for option_name, channel, target in _CHANNELS:
|
|
237
|
+
value = getattr(options, option_name)
|
|
238
|
+
if value is None:
|
|
239
|
+
continue
|
|
240
|
+
if isinstance(value, AlgorithmSpec):
|
|
241
|
+
if target == "edge":
|
|
242
|
+
raise ValueError("browser-side edge algorithms are not supported")
|
|
243
|
+
result_type = _ALGORITHM_RESULTS.get(value.name, "cat")
|
|
244
|
+
pending.append((channel, target, result_type, value))
|
|
245
|
+
continue
|
|
246
|
+
if isinstance(value, str):
|
|
247
|
+
pending.append((channel, target, "field", value))
|
|
248
|
+
continue
|
|
249
|
+
logical = _unique_attribute(working, target, channel)
|
|
250
|
+
values, inferred = (
|
|
251
|
+
_node_values(working, value)
|
|
252
|
+
if target == "node"
|
|
253
|
+
else _edge_values(working, value)
|
|
254
|
+
)
|
|
255
|
+
if target == "node":
|
|
256
|
+
for node, child in zip(working.nodes, values, strict=True):
|
|
257
|
+
working.nodes[node][logical] = child
|
|
258
|
+
else:
|
|
259
|
+
rows = _edge_rows(working)
|
|
260
|
+
for (source, target_node, key, _attrs), child in zip(
|
|
261
|
+
rows, values, strict=True
|
|
262
|
+
):
|
|
263
|
+
if key is None:
|
|
264
|
+
working.edges[source, target_node][logical] = child
|
|
265
|
+
else:
|
|
266
|
+
working.edges[source, target_node, key][logical] = child
|
|
267
|
+
result_type = "num" if channel in _NUMERIC_CHANNELS else inferred
|
|
268
|
+
pending.append((channel, target, result_type, logical))
|
|
269
|
+
|
|
270
|
+
if options.pos is not None:
|
|
271
|
+
for node in working.nodes:
|
|
272
|
+
if node not in options.pos:
|
|
273
|
+
raise ValueError(f"pos omits node {node!r}")
|
|
274
|
+
position = options.pos[node]
|
|
275
|
+
if len(position) < 2:
|
|
276
|
+
raise ValueError(f"pos for node {node!r} must contain x and y")
|
|
277
|
+
working.nodes[node]["x"] = position[0]
|
|
278
|
+
working.nodes[node]["y"] = position[1]
|
|
279
|
+
|
|
280
|
+
project = Project.from_networkx(working)
|
|
281
|
+
document = project.to_dict()
|
|
282
|
+
bindings: list[dict[str, object]] = []
|
|
283
|
+
for channel, target, result_type, source_value in pending:
|
|
284
|
+
if isinstance(source_value, AlgorithmSpec):
|
|
285
|
+
source: dict[str, object] = {
|
|
286
|
+
"kind": "algorithm",
|
|
287
|
+
"id": source_value.name,
|
|
288
|
+
}
|
|
289
|
+
if source_value.params:
|
|
290
|
+
source["params"] = dict(source_value.params)
|
|
291
|
+
else:
|
|
292
|
+
internal = _internal_attribute(project, target, str(source_value))
|
|
293
|
+
source = {
|
|
294
|
+
"kind": "field",
|
|
295
|
+
"id": f"{'e' if target == 'edge' else ''}field:{internal}",
|
|
296
|
+
**({"target": "edge"} if target == "edge" else {}),
|
|
297
|
+
}
|
|
298
|
+
if result_type == "field":
|
|
299
|
+
rows = document["datasets"][0]["graph"][
|
|
300
|
+
"nodes" if target == "node" else "edges"
|
|
301
|
+
]
|
|
302
|
+
values = [row.get(internal) for row in rows]
|
|
303
|
+
result_type = (
|
|
304
|
+
"num" if channel in _NUMERIC_CHANNELS else _infer_result(values)
|
|
305
|
+
)
|
|
306
|
+
bindings.append(
|
|
307
|
+
{"channel": channel, "source": source, "resultType": result_type}
|
|
308
|
+
)
|
|
309
|
+
if bindings:
|
|
310
|
+
document["config"]["bindings"] = bindings
|
|
311
|
+
if options.layout is not None:
|
|
312
|
+
document["config"]["layout"] = options.layout
|
|
313
|
+
return Project.from_dict(document)
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
__all__ = [
|
|
317
|
+
"AlgorithmSpec",
|
|
318
|
+
"BindingOptions",
|
|
319
|
+
"VariableData",
|
|
320
|
+
"algorithm",
|
|
321
|
+
"project_from_graph",
|
|
322
|
+
]
|
garphield/_canonical.py
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"""The one RFC 8785/JCS canonical-byte boundary used by the package."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import math
|
|
6
|
+
|
|
7
|
+
import rfc8785
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class CanonicalizationError(ValueError):
|
|
11
|
+
"""A JSON value cannot be represented by the v1 canonicalizer."""
|
|
12
|
+
|
|
13
|
+
def __init__(self, message: str, path: str = "/") -> None:
|
|
14
|
+
self.path = path
|
|
15
|
+
super().__init__(f"JSON Pointer {path}: {message}")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _pointer(path: str, component: object) -> str:
|
|
19
|
+
escaped = str(component).replace("~", "~0").replace("/", "~1")
|
|
20
|
+
return f"/{escaped}" if path == "/" else f"{path}/{escaped}"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _validate_json_value(value: object, path: str = "/", seen: set[int] | None = None) -> None:
|
|
24
|
+
"""Validate the plain I-JSON tree accepted by the pinned JCS library."""
|
|
25
|
+
|
|
26
|
+
if seen is None:
|
|
27
|
+
seen = set()
|
|
28
|
+
if value is None or isinstance(value, bool):
|
|
29
|
+
return
|
|
30
|
+
if isinstance(value, str):
|
|
31
|
+
for index, character in enumerate(value):
|
|
32
|
+
code = ord(character)
|
|
33
|
+
if 0xD800 <= code <= 0xDFFF:
|
|
34
|
+
raise CanonicalizationError(
|
|
35
|
+
"lone surrogate is not valid UTF-8", _pointer(path, index)
|
|
36
|
+
)
|
|
37
|
+
return
|
|
38
|
+
if isinstance(value, (int, float)) and not isinstance(value, bool):
|
|
39
|
+
if isinstance(value, int) and abs(value) > 9007199254740991:
|
|
40
|
+
raise CanonicalizationError(
|
|
41
|
+
"integer exceeds the safe JSON number domain", path
|
|
42
|
+
)
|
|
43
|
+
if isinstance(value, float) and not math.isfinite(value):
|
|
44
|
+
raise CanonicalizationError("non-finite number is not valid JCS", path)
|
|
45
|
+
return
|
|
46
|
+
if isinstance(value, dict):
|
|
47
|
+
identity = id(value)
|
|
48
|
+
if identity in seen:
|
|
49
|
+
raise CanonicalizationError("cyclic value is not valid JSON", path)
|
|
50
|
+
seen.add(identity)
|
|
51
|
+
try:
|
|
52
|
+
for key, child in value.items():
|
|
53
|
+
if not isinstance(key, str):
|
|
54
|
+
raise CanonicalizationError("object keys must be strings", path)
|
|
55
|
+
_validate_json_value(child, _pointer(path, key), seen)
|
|
56
|
+
finally:
|
|
57
|
+
seen.remove(identity)
|
|
58
|
+
return
|
|
59
|
+
if isinstance(value, list):
|
|
60
|
+
identity = id(value)
|
|
61
|
+
if identity in seen:
|
|
62
|
+
raise CanonicalizationError("cyclic value is not valid JSON", path)
|
|
63
|
+
seen.add(identity)
|
|
64
|
+
try:
|
|
65
|
+
for index, child in enumerate(value):
|
|
66
|
+
_validate_json_value(child, _pointer(path, index), seen)
|
|
67
|
+
finally:
|
|
68
|
+
seen.remove(identity)
|
|
69
|
+
return
|
|
70
|
+
raise CanonicalizationError(
|
|
71
|
+
f"unsupported value type {type(value).__name__}", path
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def validate_json_value(value: object) -> None:
|
|
76
|
+
"""Validate a plain JSON value before schema or canonical processing."""
|
|
77
|
+
|
|
78
|
+
_validate_json_value(value)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def canonicalize_jcs_v1(value: object) -> bytes:
|
|
82
|
+
"""Return RFC 8785 canonical UTF-8 bytes using the pinned implementation."""
|
|
83
|
+
|
|
84
|
+
_validate_json_value(value)
|
|
85
|
+
try:
|
|
86
|
+
return rfc8785.dumps(value)
|
|
87
|
+
except rfc8785.CanonicalizationError as error:
|
|
88
|
+
raise CanonicalizationError(str(error)) from error
|
garphield/_chrome.py
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""Shared language-level validation for lightweight embed chrome."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Sequence
|
|
6
|
+
from urllib.parse import ParseResult, parse_qsl, urlencode, urlparse, urlunparse
|
|
7
|
+
|
|
8
|
+
CHROME_SLOTS = ("minimap", "toolbar")
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def normalize_chrome(chrome: Sequence[str] | None) -> list[str] | None:
|
|
12
|
+
"""Return canonical chrome order, preserving None as renderer defaults."""
|
|
13
|
+
|
|
14
|
+
if chrome is None:
|
|
15
|
+
return None
|
|
16
|
+
values = list(chrome)
|
|
17
|
+
unknown = [slot for slot in values if slot not in CHROME_SLOTS]
|
|
18
|
+
if unknown:
|
|
19
|
+
raise ValueError(
|
|
20
|
+
"unsupported chrome slot(s): "
|
|
21
|
+
+ ", ".join(repr(slot) for slot in dict.fromkeys(unknown))
|
|
22
|
+
)
|
|
23
|
+
return [slot for slot in CHROME_SLOTS if slot in values]
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def validate_embed_url(value: str | None) -> str:
|
|
27
|
+
"""Validate and return an absolute HTTP(S) embed URL."""
|
|
28
|
+
|
|
29
|
+
url = value or "https://garphield.com/embed"
|
|
30
|
+
parsed = urlparse(url)
|
|
31
|
+
if parsed.scheme not in {"http", "https"} or not parsed.netloc:
|
|
32
|
+
raise ValueError("app_url must be an absolute HTTP(S) URL")
|
|
33
|
+
if not parsed.path.rstrip("/").endswith("/embed"):
|
|
34
|
+
raise ValueError("app_url must point to the Garphield /embed route")
|
|
35
|
+
return url
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def build_embed_url(value: str | None, chrome: Sequence[str] | None = None) -> str:
|
|
39
|
+
"""Apply the optional chrome query without disturbing other URL options."""
|
|
40
|
+
|
|
41
|
+
normalized = normalize_chrome(chrome)
|
|
42
|
+
validated = validate_embed_url(value)
|
|
43
|
+
if normalized is None:
|
|
44
|
+
return validated
|
|
45
|
+
parsed = urlparse(validated)
|
|
46
|
+
query = [(key, value) for key, value in parse_qsl(parsed.query, keep_blank_values=True) if key != "chrome"]
|
|
47
|
+
query.append(("chrome", ",".join(normalized)))
|
|
48
|
+
return urlunparse(
|
|
49
|
+
ParseResult(
|
|
50
|
+
parsed.scheme,
|
|
51
|
+
parsed.netloc,
|
|
52
|
+
parsed.path,
|
|
53
|
+
parsed.params,
|
|
54
|
+
urlencode(query, safe=","),
|
|
55
|
+
parsed.fragment,
|
|
56
|
+
)
|
|
57
|
+
)
|
garphield/_codegen.py
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""Deterministic exact Python replay for validated Garphield Projects."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import keyword
|
|
7
|
+
from collections.abc import Mapping
|
|
8
|
+
from typing import TYPE_CHECKING
|
|
9
|
+
|
|
10
|
+
if TYPE_CHECKING:
|
|
11
|
+
from ._project import Project
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _validate_variable(variable: str) -> None:
|
|
15
|
+
if not variable.isidentifier() or keyword.iskeyword(variable):
|
|
16
|
+
raise ValueError("variable must be a valid non-keyword Python identifier")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def exact_code(project: "Project", *, variable: str = "project", display: bool = True) -> str:
|
|
20
|
+
_validate_variable(variable)
|
|
21
|
+
|
|
22
|
+
payload = json.dumps(
|
|
23
|
+
project.to_dict(),
|
|
24
|
+
ensure_ascii=False,
|
|
25
|
+
sort_keys=True,
|
|
26
|
+
separators=(",", ":"),
|
|
27
|
+
allow_nan=False,
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
lines = [
|
|
31
|
+
"import json",
|
|
32
|
+
"from garphield import Project",
|
|
33
|
+
"",
|
|
34
|
+
f"{variable} = Project.from_dict(json.loads({payload!r}))",
|
|
35
|
+
]
|
|
36
|
+
if display:
|
|
37
|
+
lines.append(f"view = {variable}.widget()")
|
|
38
|
+
return "\n".join(lines) + "\n"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def idiomatic_code(
|
|
42
|
+
project: "Project", *, variable: str = "project", display: bool = True
|
|
43
|
+
) -> str:
|
|
44
|
+
"""Emit readable transform calls over a lossless exact base project."""
|
|
45
|
+
|
|
46
|
+
from ._project import Project
|
|
47
|
+
from ._transforms import transform_catalog
|
|
48
|
+
|
|
49
|
+
_validate_variable(variable)
|
|
50
|
+
transformations = project.transformations
|
|
51
|
+
if any(entry.name not in transform_catalog() for entry in transformations):
|
|
52
|
+
return exact_code(project, variable=variable, display=display)
|
|
53
|
+
if not transformations:
|
|
54
|
+
return exact_code(project, variable=variable, display=display)
|
|
55
|
+
|
|
56
|
+
removed_ids = {entry.id for entry in transformations}
|
|
57
|
+
document = project.to_dict()
|
|
58
|
+
config = document.get("config")
|
|
59
|
+
if isinstance(config, dict):
|
|
60
|
+
stack = config.get("filterStack")
|
|
61
|
+
if isinstance(stack, list):
|
|
62
|
+
config["filterStack"] = [
|
|
63
|
+
entry
|
|
64
|
+
for entry in stack
|
|
65
|
+
if not (
|
|
66
|
+
isinstance(entry, Mapping) and entry.get("id") in removed_ids
|
|
67
|
+
)
|
|
68
|
+
]
|
|
69
|
+
current = config.get("current")
|
|
70
|
+
state = current.get("state") if isinstance(current, dict) else None
|
|
71
|
+
current_stack = state.get("filterStack") if isinstance(state, dict) else None
|
|
72
|
+
if isinstance(current_stack, list):
|
|
73
|
+
state["filterStack"] = [
|
|
74
|
+
entry
|
|
75
|
+
for entry in current_stack
|
|
76
|
+
if not (
|
|
77
|
+
isinstance(entry, Mapping) and entry.get("id") in removed_ids
|
|
78
|
+
)
|
|
79
|
+
]
|
|
80
|
+
|
|
81
|
+
base = exact_code(Project.from_dict(document), variable=variable, display=False)
|
|
82
|
+
lines = ["import garphield as gf", base.rstrip(), "", "# Garphield transformations"]
|
|
83
|
+
for entry in transformations:
|
|
84
|
+
arguments = ", ".join(
|
|
85
|
+
f"{name}={value!r}" for name, value in sorted(entry.params.items())
|
|
86
|
+
)
|
|
87
|
+
spec = (
|
|
88
|
+
f"gf.transform({json.dumps(entry.name)}"
|
|
89
|
+
f'{", " if arguments else ""}{arguments})'
|
|
90
|
+
)
|
|
91
|
+
lines.append(
|
|
92
|
+
f"{variable} = {variable}.with_transform("
|
|
93
|
+
f"{spec}, mode={json.dumps(entry.mode)}, id={json.dumps(entry.id)}, "
|
|
94
|
+
f"enabled={entry.enabled!r})"
|
|
95
|
+
)
|
|
96
|
+
if display:
|
|
97
|
+
lines.append(f"view = {variable}.widget()")
|
|
98
|
+
return "\n".join(lines) + "\n"
|
garphield/_colab.py
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""Small adapter for Colab's documented ``proxyPort`` callback."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Callable
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def proxy_port_adapter() -> Callable[[int], str]:
|
|
9
|
+
"""Return a callable backed by ``google.colab.kernel.proxyPort``.
|
|
10
|
+
|
|
11
|
+
The import is intentionally lazy so importing Garphield remains safe in a
|
|
12
|
+
regular Python process. Colab owns the proxy URL and may change its host or
|
|
13
|
+
token between calls; the adapter therefore does not cache a result.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from google.colab import kernel # type: ignore[import-not-found]
|
|
17
|
+
|
|
18
|
+
callback = kernel.proxyPort
|
|
19
|
+
|
|
20
|
+
def proxy_port(port: int) -> str:
|
|
21
|
+
if isinstance(port, bool) or not isinstance(port, int) or port <= 0 or port > 65535:
|
|
22
|
+
raise ValueError("Colab proxy port must be an integer from 1 through 65535")
|
|
23
|
+
value = callback(port)
|
|
24
|
+
if not isinstance(value, str):
|
|
25
|
+
raise TypeError("google.colab.kernel.proxyPort must return a URL string")
|
|
26
|
+
return value
|
|
27
|
+
|
|
28
|
+
return proxy_port
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def proxy_port(port: int) -> str:
|
|
32
|
+
"""Resolve one port through the live Colab kernel callback."""
|
|
33
|
+
|
|
34
|
+
return proxy_port_adapter()(port)
|