cuiman 0.3.0__tar.gz → 0.4.0__tar.gz

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.
Files changed (79) hide show
  1. {cuiman-0.3.0 → cuiman-0.4.0}/.gitignore +1 -1
  2. {cuiman-0.3.0 → cuiman-0.4.0}/PKG-INFO +5 -3
  3. {cuiman-0.3.0 → cuiman-0.4.0}/cuiman/api/__init__.py +13 -0
  4. cuiman-0.4.0/cuiman/api/assets/__init__.py +76 -0
  5. cuiman-0.4.0/cuiman/api/assets/common.py +208 -0
  6. cuiman-0.4.0/cuiman/api/assets/job_results.py +71 -0
  7. cuiman-0.4.0/cuiman/api/assets/stac.py +454 -0
  8. {cuiman-0.3.0 → cuiman-0.4.0}/cuiman/api/async_client.py +1 -1
  9. {cuiman-0.3.0 → cuiman-0.4.0}/cuiman/api/async_client_mixin.py +118 -21
  10. cuiman-0.4.0/cuiman/api/branding.py +84 -0
  11. {cuiman-0.3.0 → cuiman-0.4.0}/cuiman/api/client.py +1 -1
  12. {cuiman-0.3.0 → cuiman-0.4.0}/cuiman/api/client_mixin.py +117 -25
  13. {cuiman-0.3.0 → cuiman-0.4.0}/cuiman/api/client_mixin_base.py +71 -3
  14. {cuiman-0.3.0 → cuiman-0.4.0}/cuiman/api/config.py +99 -12
  15. {cuiman-0.3.0 → cuiman-0.4.0}/cuiman/api/exceptions.py +1 -1
  16. {cuiman-0.3.0 → cuiman-0.4.0}/cuiman/api/jobs.py +10 -3
  17. {cuiman-0.3.0 → cuiman-0.4.0}/cuiman/api/opener/__init__.py +2 -1
  18. cuiman-0.4.0/cuiman/api/opener/context.py +245 -0
  19. {cuiman-0.3.0 → cuiman-0.4.0}/cuiman/api/opener/errors.py +14 -1
  20. {cuiman-0.3.0 → cuiman-0.4.0}/cuiman/api/opener/impl/__init__.py +4 -0
  21. {cuiman-0.3.0 → cuiman-0.4.0}/cuiman/api/opener/impl/_gpd.py +2 -0
  22. {cuiman-0.3.0 → cuiman-0.4.0}/cuiman/api/opener/impl/_image.py +14 -1
  23. cuiman-0.4.0/cuiman/api/opener/impl/_paths.py +27 -0
  24. {cuiman-0.3.0 → cuiman-0.4.0}/cuiman/api/opener/impl/_pd.py +2 -0
  25. cuiman-0.4.0/cuiman/api/opener/impl/_stac/__init__.py +1 -0
  26. cuiman-0.4.0/cuiman/api/opener/impl/_stac/asset.py +104 -0
  27. cuiman-0.4.0/cuiman/api/opener/impl/_stac/composition.py +111 -0
  28. cuiman-0.4.0/cuiman/api/opener/impl/_stac/locations.py +59 -0
  29. cuiman-0.4.0/cuiman/api/opener/impl/_stac/opener.py +145 -0
  30. cuiman-0.4.0/cuiman/api/opener/impl/_stac/reader.py +613 -0
  31. cuiman-0.4.0/cuiman/api/opener/impl/_stac/support.py +82 -0
  32. {cuiman-0.3.0 → cuiman-0.4.0}/cuiman/api/opener/impl/_xr.py +2 -0
  33. {cuiman-0.3.0 → cuiman-0.4.0}/cuiman/api/opener/impl/base.py +42 -33
  34. {cuiman-0.3.0 → cuiman-0.4.0}/cuiman/api/opener/impl/openers.py +12 -1
  35. {cuiman-0.3.0 → cuiman-0.4.0}/cuiman/api/opener/opener.py +55 -10
  36. {cuiman-0.3.0 → cuiman-0.4.0}/cuiman/api/opener/registry.py +2 -0
  37. {cuiman-0.3.0 → cuiman-0.4.0}/cuiman/api/service_client.py +2 -2
  38. {cuiman-0.3.0 → cuiman-0.4.0}/cuiman/api/transport/args.py +1 -1
  39. {cuiman-0.3.0 → cuiman-0.4.0}/cuiman/api/transport/httpx2.py +17 -2
  40. {cuiman-0.3.0 → cuiman-0.4.0}/cuiman/api/transport/transport.py +12 -0
  41. {cuiman-0.3.0 → cuiman-0.4.0}/cuiman/app/__init__.py +5 -0
  42. {cuiman-0.3.0 → cuiman-0.4.0}/cuiman/app/app.py +1 -1
  43. cuiman-0.4.0/cuiman/app/dist/assets/github-CDab0zVI.css +1 -0
  44. cuiman-0.4.0/cuiman/app/dist/assets/github-dark-Dfs9RUU9.css +1 -0
  45. cuiman-0.4.0/cuiman/app/dist/assets/index-7PXY_U-I.js +90 -0
  46. cuiman-0.4.0/cuiman/app/dist/assets/index-BCVM7_WB.css +1 -0
  47. {cuiman-0.3.0 → cuiman-0.4.0}/cuiman/app/dist/index.html +2 -2
  48. {cuiman-0.3.0 → cuiman-0.4.0}/cuiman/app/launch.py +91 -0
  49. {cuiman-0.3.0 → cuiman-0.4.0}/cuiman/app/serve.py +5 -1
  50. {cuiman-0.3.0 → cuiman-0.4.0}/cuiman/cli/config.py +8 -2
  51. {cuiman-0.3.0 → cuiman-0.4.0}/cuiman/cli/output.py +1 -1
  52. {cuiman-0.3.0 → cuiman-0.4.0}/pyproject.toml +5 -2
  53. cuiman-0.3.0/cuiman/api/opener/context.py +0 -165
  54. cuiman-0.3.0/cuiman/app/dist/assets/index-Br7nJ6aR.js +0 -85
  55. cuiman-0.3.0/cuiman/app/dist/assets/index-DKE7nU5k.css +0 -1
  56. {cuiman-0.3.0 → cuiman-0.4.0}/LICENSE +0 -0
  57. {cuiman-0.3.0 → cuiman-0.4.0}/README.md +0 -0
  58. {cuiman-0.3.0 → cuiman-0.4.0}/cuiman/__init__.py +0 -0
  59. {cuiman-0.3.0 → cuiman-0.4.0}/cuiman/api/auth/__init__.py +0 -0
  60. {cuiman-0.3.0 → cuiman-0.4.0}/cuiman/api/auth/config.py +0 -0
  61. {cuiman-0.3.0 → cuiman-0.4.0}/cuiman/api/auth/interactive.py +0 -0
  62. {cuiman-0.3.0 → cuiman-0.4.0}/cuiman/api/auth/login.py +0 -0
  63. {cuiman-0.3.0 → cuiman-0.4.0}/cuiman/api/auth/oauth2_client.py +0 -0
  64. {cuiman-0.3.0 → cuiman-0.4.0}/cuiman/api/auth/oidc.py +0 -0
  65. {cuiman-0.3.0 → cuiman-0.4.0}/cuiman/api/auth/secret_store.py +0 -0
  66. {cuiman-0.3.0 → cuiman-0.4.0}/cuiman/api/client_app_mixin.py +0 -0
  67. {cuiman-0.3.0 → cuiman-0.4.0}/cuiman/api/defaults.py +0 -0
  68. {cuiman-0.3.0 → cuiman-0.4.0}/cuiman/api/ishell.py +0 -0
  69. {cuiman-0.3.0 → cuiman-0.4.0}/cuiman/api/transport/__init__.py +0 -0
  70. {cuiman-0.3.0 → cuiman-0.4.0}/cuiman/app/display.py +0 -0
  71. {cuiman-0.3.0 → cuiman-0.4.0}/cuiman/app/dist/eozilla-small.svg +0 -0
  72. {cuiman-0.3.0 → cuiman-0.4.0}/cuiman/app/dist/eozilla.svg +0 -0
  73. {cuiman-0.3.0 → cuiman-0.4.0}/cuiman/app/dist/privacy.md +0 -0
  74. {cuiman-0.3.0 → cuiman-0.4.0}/cuiman/app/notebook-display.js +0 -0
  75. {cuiman-0.3.0 → cuiman-0.4.0}/cuiman/app/url.py +0 -0
  76. {cuiman-0.3.0 → cuiman-0.4.0}/cuiman/cli/__init__.py +0 -0
  77. {cuiman-0.3.0 → cuiman-0.4.0}/cuiman/cli/cli.py +0 -0
  78. {cuiman-0.3.0 → cuiman-0.4.0}/cuiman/cli/client.py +0 -0
  79. {cuiman-0.3.0 → cuiman-0.4.0}/cuiman/py.typed +0 -0
@@ -205,4 +205,4 @@ cython_debug/
205
205
  .cursorindexingignore
206
206
 
207
207
  # Downloaded font assets
208
- docs/assets/fonts/*.ttf
208
+ docs/assets/downloaded/
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.4
1
+ Metadata-Version: 2.5
2
2
  Name: cuiman
3
- Version: 0.3.0
3
+ Version: 0.4.0
4
4
  Summary: Provides a client Python API, GUI, and CLI for servers compliant with OGC API - Processes
5
5
  Project-URL: Documentation, https://eo-tools.github.io/eozilla
6
6
  Project-URL: Repository, https://github.com/eo-tools/eozilla
@@ -28,7 +28,7 @@ Classifier: Topic :: Software Development
28
28
  Classifier: Typing :: Typed
29
29
  Requires-Python: >=3.10
30
30
  Requires-Dist: authlib<1.9,>=1.8
31
- Requires-Dist: gavicore>=0.3.0
31
+ Requires-Dist: gavicore>=0.4.0
32
32
  Requires-Dist: httpx2
33
33
  Requires-Dist: ipython
34
34
  Requires-Dist: joserfc<2,>=1.6
@@ -39,6 +39,8 @@ Requires-Dist: pyyaml
39
39
  Requires-Dist: remotestate<1,>=0.3.4
40
40
  Requires-Dist: typer<1,>=0.26.0
41
41
  Requires-Dist: uri-template
42
+ Provides-Extra: stac
43
+ Requires-Dist: pystac<1.16,>=1.15.2; extra == 'stac'
42
44
  Description-Content-Type: text/markdown
43
45
 
44
46
  # Eozilla Cuiman
@@ -2,6 +2,8 @@
2
2
  # Permissions are hereby granted under the terms of the Apache 2.0 License:
3
3
  # https://opensource.org/license/apache-2-0.
4
4
 
5
+ from gavicore.models.core import JobResults
6
+
5
7
  from .async_client import AsyncClient
6
8
  from .client import Client
7
9
  from .config import ClientConfig
@@ -16,3 +18,14 @@ __all__ = [
16
18
  "JobMonitor",
17
19
  "JobOptions",
18
20
  ]
21
+
22
+
23
+ def _repr_job_results_as_html(self: JobResults) -> str:
24
+ """Render job outputs in notebooks without fetching or displaying data."""
25
+ from .assets.job_results import _job_results_html
26
+
27
+ return _job_results_html(self)
28
+
29
+
30
+ # Install Cuiman's presentation without introducing a gavicore dependency on it.
31
+ JobResults._repr_html_ = _repr_job_results_as_html # type: ignore[attr-defined]
@@ -0,0 +1,76 @@
1
+ # Copyright (c) 2026 by the Eozilla team and contributors
2
+ # Permissions are hereby granted under the terms of the Apache 2.0 License:
3
+ # https://opensource.org/license/apache-2-0.
4
+
5
+ """Notebook display utilities for STAC assets and job results."""
6
+
7
+ from typing import TYPE_CHECKING, Any, Callable, Iterable, Sequence
8
+
9
+ from gavicore.models.core import JobResults, QualifiedValue
10
+
11
+ from .common import _get_items_expression
12
+ from .stac import as_stac_asset
13
+
14
+ if TYPE_CHECKING:
15
+ from pystac import Asset, Item, ItemCollection
16
+
17
+ __all__ = ["as_stac_asset", "display_assets"]
18
+
19
+
20
+ async def display_assets(
21
+ items: "JobResults | QualifiedValue | dict[str, Any] | Item | Sequence[Item] | ItemCollection",
22
+ *,
23
+ roles: str | Iterable[str] | None = None,
24
+ previews: bool = True,
25
+ open_asset: "Callable[[Asset], Any] | None" = None,
26
+ ) -> None:
27
+ """Render job result tables or grouped STAC assets and optional previews.
28
+
29
+ Args:
30
+ items: JobResults, STAC objects, or inline STAC JSON dictionaries
31
+ (optionally wrapped in QualifiedValue).
32
+ roles: Show assets matching any of these roles. A string matches one
33
+ role, None shows all assets, and an empty iterable matches none.
34
+ Job result rows are not filtered by roles.
35
+ previews: Open and display thumbnail, overview, and visual assets.
36
+ Set to ``False`` to prevent asset preview reads. Geometry previews
37
+ remain enabled. Asset previews are independent of the table's
38
+ role filter; unreadable or unsupported previews are skipped.
39
+ open_asset: A synchronous or asynchronous asset opener. If omitted,
40
+ asset previews are skipped; tables and geometry maps remain enabled.
41
+ Job result tables never open linked outputs.
42
+ """
43
+ await _display_assets(
44
+ items,
45
+ expression=_get_items_expression("display_assets"),
46
+ roles=roles,
47
+ previews=previews,
48
+ open_asset=open_asset,
49
+ )
50
+
51
+
52
+ async def _display_assets(
53
+ items: "JobResults | QualifiedValue | dict[str, Any] | Item | Sequence[Item] | ItemCollection",
54
+ *,
55
+ expression: str | None,
56
+ roles: str | Iterable[str] | None = None,
57
+ previews: bool = True,
58
+ open_asset: "Callable[[Asset], Any] | None" = None,
59
+ ) -> None:
60
+ """Dispatch notebook display without importing optional STAC dependencies."""
61
+ if isinstance(items, JobResults):
62
+ from IPython.display import display
63
+
64
+ from .job_results import _render_job_results
65
+
66
+ display(_render_job_results(items, expression=expression))
67
+ else:
68
+ from .stac import _display_stac_assets
69
+
70
+ await _display_stac_assets(
71
+ items,
72
+ expression=expression,
73
+ roles=roles,
74
+ previews=previews,
75
+ open_asset=open_asset,
76
+ )
@@ -0,0 +1,208 @@
1
+ # Copyright (c) 2026 by the Eozilla team and contributors
2
+ # Permissions are hereby granted under the terms of the Apache 2.0 License:
3
+ # https://opensource.org/license/apache-2-0.
4
+
5
+ """Shared notebook styles, links, copy buttons, and caller expressions."""
6
+
7
+ from html import escape
8
+ from inspect import currentframe
9
+ from types import FrameType
10
+ from urllib.parse import unquote, urlsplit
11
+
12
+
13
+ def _stac_label(value: object) -> str | None:
14
+ """Recognize inline STAC items and collections without optional imports."""
15
+ if not isinstance(value, dict):
16
+ return None
17
+ if (
18
+ value.get("type") == "Feature"
19
+ and isinstance(value.get("stac_version"), str)
20
+ and isinstance(value.get("assets"), dict)
21
+ ):
22
+ return "STAC Item"
23
+ features = value.get("features")
24
+ if (
25
+ value.get("type") == "FeatureCollection"
26
+ and isinstance(features, list)
27
+ and features
28
+ and all(_stac_label(feature) == "STAC Item" for feature in features)
29
+ ):
30
+ return "STAC ItemCollection"
31
+ return None
32
+
33
+
34
+ def _styles() -> str:
35
+ """Return the shared asset table and card styles."""
36
+ return (
37
+ "<style>"
38
+ ".cuiman-assets-card{border-radius:12px;box-shadow:0 3px 12px #0001}"
39
+ ".cuiman-assets-map{border-radius:8px}"
40
+ ".cuiman-assets{font:14px/1.5 system-ui,sans-serif;color:inherit}"
41
+ ".cuiman-assets header{padding:0;"
42
+ "overflow-wrap:anywhere}"
43
+ ".cuiman-assets header strong{font-size:20px;font-weight:650}"
44
+ ".cuiman-assets-meta{opacity:.65;font-size:12px;margin-top:4px}"
45
+ ".cuiman-assets-properties{display:grid;grid-template-columns:minmax(100px,auto) 1fr;"
46
+ "gap:4px 16px;margin:14px 0 0;font-size:12px;max-height:180px;overflow:auto}"
47
+ ".cuiman-assets-properties dt{opacity:.6;font-weight:400}"
48
+ ".cuiman-assets-properties dd{margin:0;overflow-wrap:anywhere}"
49
+ ".cuiman-assets-table{overflow-x:auto;padding:8px 20px 16px}"
50
+ # Outrank notebook HTML table rules in both plain HTML and widget outputs.
51
+ ".cuiman-assets .cuiman-assets-table table{width:100%;border-collapse:collapse;"
52
+ "text-align:left;background:transparent}"
53
+ ".cuiman-assets .cuiman-assets-table table th{font-size:11px;text-transform:uppercase;"
54
+ "letter-spacing:.06em;opacity:.6;font-weight:600;text-align:left}"
55
+ ".cuiman-assets .cuiman-assets-table table td,"
56
+ ".cuiman-assets .cuiman-assets-table table th{padding:10px 12px;"
57
+ "border-bottom:1px solid #8882;text-align:left}"
58
+ ".cuiman-assets .cuiman-assets-table table td:first-child{font-weight:600}"
59
+ ".cuiman-assets .cuiman-assets-table table td:last-child,"
60
+ ".cuiman-assets .cuiman-assets-table table th:last-child{width:32px;text-align:center}"
61
+ ".cuiman-assets .cuiman-assets-table table tbody tr:last-child td{border-bottom:0}"
62
+ ".cuiman-assets .cuiman-assets-table table tbody tr:nth-child(odd){background:#8881}"
63
+ ".cuiman-assets .cuiman-assets-table table tbody tr:nth-child(even){background:transparent}"
64
+ ".cuiman-assets .cuiman-assets-table table tbody tr:hover{background:#8882}"
65
+ ".cuiman-assets a{color:#3b9fa5;text-decoration:none}"
66
+ ".cuiman-assets a:hover{text-decoration:underline}"
67
+ ".cuiman-assets-empty{padding:0 20px}"
68
+ ".cuiman-assets-previews figure{margin:0;width:160px;min-width:0}"
69
+ ".cuiman-assets-previews img{display:block;width:100%;"
70
+ "height:140px;object-fit:contain;background:#8881;border-radius:8px}"
71
+ ".cuiman-assets-previews figcaption{font:12px/1.5 system-ui,sans-serif;"
72
+ "opacity:.65;padding:8px 2px}"
73
+ "</style>"
74
+ )
75
+
76
+
77
+ def _render_link(href: str) -> str:
78
+ """Render a file link, escaping labels and rejecting active URL schemes."""
79
+ path = urlsplit(href.replace("\\", "/")).path.rstrip("/")
80
+ filename = unquote(path.rsplit("/", 1)[-1]) or href
81
+ scheme = urlsplit(href).scheme.lower()
82
+
83
+ if scheme in ("", "http", "https", "file"):
84
+ return (
85
+ f"<a href='{escape(href, quote=True)}' "
86
+ "style='overflow-wrap:anywhere'>"
87
+ f"{escape(filename)}</a>"
88
+ )
89
+ else:
90
+ return escape(filename)
91
+
92
+
93
+ def _copy_button(expression: str) -> str:
94
+ """Render a button that copies an escaped Python access expression."""
95
+ return (
96
+ f"<button data-copy='{escape(expression, quote=True)}' "
97
+ "type='button' title='Copy expression' aria-label='Copy expression' "
98
+ "style='background:transparent;border:0;padding:0.25em;"
99
+ "color:inherit;cursor:pointer;line-height:0' "
100
+ 'onclick="(async b => {'
101
+ "try { await navigator.clipboard.writeText(b.dataset.copy); "
102
+ "b.title = 'Copied'; } "
103
+ "catch (_) { window.prompt('Copy expression:', b.dataset.copy); }"
104
+ '})(this)">'
105
+ "<svg xmlns='http://www.w3.org/2000/svg' width='18' height='18' "
106
+ "viewBox='0 0 24 24' fill='none' stroke='currentColor' "
107
+ "stroke-width='2' stroke-linecap='round' stroke-linejoin='round' "
108
+ "aria-hidden='true' focusable='false'>"
109
+ "<rect x='9' y='9' width='13' height='13' rx='2'/>"
110
+ "<path d='M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9"
111
+ "a2 2 0 0 1 2 2v1'/></svg></button>"
112
+ )
113
+
114
+
115
+ def _get_items_expression(function_name: str = "show_assets") -> str | None:
116
+ """Capture the public caller's items expression without retaining its frame."""
117
+ frame = currentframe()
118
+ try:
119
+ caller = frame.f_back.f_back if frame and frame.f_back else None
120
+ expression = _get_call_argument_source(caller, function_name)
121
+ finally:
122
+ del frame
123
+ if expression is None:
124
+ return None
125
+
126
+ import ast
127
+
128
+ node = ast.parse(f"({expression})", mode="eval").body
129
+ # Attribute access binds correctly to these expressions without parentheses.
130
+ return (
131
+ expression
132
+ if isinstance(node, (ast.Name, ast.Attribute, ast.Subscript, ast.Call))
133
+ else f"({expression})"
134
+ )
135
+
136
+
137
+ def _get_call_argument_source(
138
+ frame: FrameType | None, function_name: str
139
+ ) -> str | None:
140
+ """Recover the items argument from the caller's cached source, without eval.
141
+
142
+ Instruction positions distinguish calls on the same line and aliases.
143
+ When positions are unavailable, accept only one matching named call.
144
+ Missing source and ambiguous calls return None.
145
+ """
146
+ import ast
147
+ import dis
148
+ import linecache
149
+
150
+ if frame is None:
151
+ return None
152
+ source = "".join(linecache.getlines(frame.f_code.co_filename, frame.f_globals))
153
+ try:
154
+ tree = ast.parse(source)
155
+ except SyntaxError:
156
+ return None
157
+
158
+ positions = next(
159
+ (
160
+ instruction.positions
161
+ for instruction in dis.get_instructions(frame.f_code, show_caches=True)
162
+ if instruction.offset == frame.f_lasti
163
+ ),
164
+ None,
165
+ )
166
+ position_span = (
167
+ tuple(positions)
168
+ if positions is not None and all(value is not None for value in positions)
169
+ else None
170
+ )
171
+ candidates = []
172
+ for node in ast.walk(tree):
173
+ if not isinstance(node, (ast.Call, ast.Await)):
174
+ continue
175
+ call = node.value if isinstance(node, ast.Await) else node
176
+ if not isinstance(call, ast.Call):
177
+ continue
178
+ if position_span is not None:
179
+ span = (node.lineno, node.end_lineno, node.col_offset, node.end_col_offset)
180
+ if span != position_span:
181
+ continue
182
+ else:
183
+ if not isinstance(node, ast.Call):
184
+ continue
185
+ name = (
186
+ call.func.id
187
+ if isinstance(call.func, ast.Name)
188
+ else call.func.attr
189
+ if isinstance(call.func, ast.Attribute)
190
+ else None
191
+ )
192
+ if name != function_name or not (
193
+ node.lineno <= frame.f_lineno <= (node.end_lineno or node.lineno)
194
+ ):
195
+ continue
196
+ candidates.append(call)
197
+
198
+ if len(candidates) != 1:
199
+ return None
200
+ call = candidates[0]
201
+ argument = (
202
+ call.args[0]
203
+ if call.args
204
+ else next((kw.value for kw in call.keywords if kw.arg == "items"), None)
205
+ )
206
+ if argument is None or isinstance(argument, ast.Starred):
207
+ return None
208
+ return ast.get_source_segment(source, argument)
@@ -0,0 +1,71 @@
1
+ # Copyright (c) 2026 by the Eozilla team and contributors
2
+ # Permissions are hereby granted under the terms of the Apache 2.0 License:
3
+ # https://opensource.org/license/apache-2-0.
4
+
5
+ """Notebook tables for raw job outputs, without fetching linked resources."""
6
+
7
+ from html import escape
8
+ from json import dumps
9
+ from typing import Any
10
+
11
+ from gavicore.models.core import JobResults, Link, QualifiedValue
12
+
13
+ from .common import _copy_button, _render_link, _stac_label, _styles
14
+
15
+
16
+ def _render_value(value: Any) -> str:
17
+ """Render JSON scalars directly and structured values as expandable JSON."""
18
+ if isinstance(value, (dict, list)):
19
+ return (
20
+ f"<details><summary>{_stac_label(value) or 'JSON'}</summary>"
21
+ "<pre style='max-height:320px;overflow:auto;white-space:pre-wrap'>"
22
+ f"{escape(dumps(value, ensure_ascii=False, indent=2))}</pre></details>"
23
+ )
24
+ return escape(dumps(value, ensure_ascii=False))
25
+
26
+
27
+ def _render_job_results(results: JobResults, *, expression: str | None = None):
28
+ """Build an output table with safe links and copyable root access expressions."""
29
+ from IPython.display import HTML
30
+
31
+ return HTML(_job_results_html(results, expression=expression))
32
+
33
+
34
+ def _job_results_html(results: JobResults, *, expression: str | None = None) -> str:
35
+ """Return the shared output table HTML without display or optional imports."""
36
+ rows = []
37
+ json_values = results.model_dump(mode="json", by_alias=True) or {}
38
+ for name, result in (results.root or {}).items():
39
+ media_type = ""
40
+ if isinstance(result, Link):
41
+ kind = "Link"
42
+ media_type = result.type or ""
43
+ value_html = _render_link(result.href)
44
+ elif isinstance(result, QualifiedValue):
45
+ kind = "Inline"
46
+ media_type = result.mediaType or ""
47
+ value_html = _render_value(json_values[name]["value"])
48
+ else:
49
+ kind = "Inline"
50
+ value_html = _render_value(json_values[name])
51
+ access = f"{expression or 'results'}.root[{name!r}]"
52
+ rows.append(
53
+ "<tr>"
54
+ f"<td>{escape(name)}</td><td>{kind}</td>"
55
+ f"<td>{escape(media_type)}</td><td>{value_html}</td>"
56
+ f"<td>{_copy_button(access)}</td></tr>"
57
+ )
58
+ table = (
59
+ "<div class='cuiman-assets-table'><table>"
60
+ "<thead><tr><th>Output</th><th>Kind</th><th>Media type</th>"
61
+ "<th>Value / location</th><th>Code</th></tr></thead>"
62
+ f"<tbody>{''.join(rows)}</tbody></table></div>"
63
+ if rows
64
+ else "<p class='cuiman-assets-empty'><em>No outputs</em></p>"
65
+ )
66
+ return (
67
+ _styles() + "<section class='cuiman-assets cuiman-assets-card' "
68
+ "style='border:1px solid #8884;overflow:hidden;margin:12px 0 24px'>"
69
+ "<header style='padding:16px 20px 8px'><strong>Job results</strong></header>"
70
+ f"{table}</section>"
71
+ )