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 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
+ ]
@@ -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)