evo-widgets 0.2.0__tar.gz → 0.2.2__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.
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.4
1
+ Metadata-Version: 2.5
2
2
  Name: evo-widgets
3
- Version: 0.2.0
3
+ Version: 0.2.2
4
4
  Summary: Widgets and presentation layer for Evo SDK - HTML rendering and IPython formatters for Jupyter notebooks
5
5
  Project-URL: Source, https://github.com/SeequentEvo/evo-python-sdk
6
6
  Project-URL: Tracker, https://github.com/SeequentEvo/evo-python-sdk/issues
@@ -9,7 +9,7 @@ Project-URL: Documentation, https://developer.seequent.com/
9
9
  Author-email: Seequent <support@seequent.com>
10
10
  License-File: LICENSE.md
11
11
  Requires-Python: >=3.10
12
- Requires-Dist: evo-sdk-common>=0.5.8
12
+ Requires-Dist: evo-sdk-common>=0.5.25
13
13
  Requires-Dist: ipython>=7.0.0
14
14
  Description-Content-Type: text/markdown
15
15
 
@@ -1,7 +1,7 @@
1
1
  [project]
2
2
  name = "evo-widgets"
3
3
  description = "Widgets and presentation layer for Evo SDK - HTML rendering and IPython formatters for Jupyter notebooks"
4
- version = "0.2.0"
4
+ version = "0.2.2"
5
5
  requires-python = ">=3.10"
6
6
  license-files = ["LICENSE.md"]
7
7
  dynamic = ["readme"]
@@ -10,7 +10,7 @@ authors = [
10
10
  ]
11
11
 
12
12
  dependencies = [
13
- "evo-sdk-common>=0.5.8",
13
+ "evo-sdk-common>=0.5.25",
14
14
  "ipython>=7.0.0",
15
15
  ]
16
16
 
@@ -29,7 +29,7 @@ test = [
29
29
  dev = [
30
30
  "bumpver",
31
31
  "coverage[toml]",
32
- "ruff==0.14.0",
32
+ "ruff==0.15.16",
33
33
  {include-group="test"},
34
34
  ]
35
35
 
@@ -1,4 +1,4 @@
1
- # Copyright © 2025 Bentley Systems, Incorporated
1
+ # Copyright © 2026 Bentley Systems, Incorporated
2
2
  # Licensed under the Apache License, Version 2.0 (the "License");
3
3
  # you may not use this file except in compliance with the License.
4
4
  # You may obtain a copy of the License at
@@ -39,6 +39,7 @@ from .formatters import (
39
39
  format_block_model,
40
40
  format_block_model_attributes,
41
41
  format_block_model_version,
42
+ format_downhole_collection,
42
43
  format_report,
43
44
  format_report_result,
44
45
  format_task_result_list,
@@ -159,6 +160,12 @@ def _register_formatters(ipython: InteractiveShell) -> None:
159
160
  format_block_model_attributes,
160
161
  )
161
162
 
163
+ html_formatter.for_type_by_name(
164
+ "evo.objects.typed.downhole_collection",
165
+ "DownholeCollection",
166
+ format_downhole_collection,
167
+ )
168
+
162
169
  # Register formatters for compute task results
163
170
 
164
171
  html_formatter.for_type_by_name(
@@ -1,4 +1,4 @@
1
- # Copyright © 2025 Bentley Systems, Incorporated
1
+ # Copyright © 2026 Bentley Systems, Incorporated
2
2
  # Licensed under the Apache License, Version 2.0 (the "License");
3
3
  # you may not use this file except in compliance with the License.
4
4
  # You may obtain a copy of the License at
@@ -17,7 +17,8 @@ These formatters are registered with IPython when the extension is loaded.
17
17
 
18
18
  from __future__ import annotations
19
19
 
20
- from typing import Any
20
+ from html import escape
21
+ from typing import TYPE_CHECKING, Any
21
22
 
22
23
  from .html import (
23
24
  STYLESHEET,
@@ -25,6 +26,7 @@ from .html import (
25
26
  build_table_row,
26
27
  build_table_row_vtop,
27
28
  build_title,
29
+ markup,
28
30
  )
29
31
  from .urls import (
30
32
  get_blocksync_block_model_url_from_environment,
@@ -33,12 +35,17 @@ from .urls import (
33
35
  get_viewer_url_for_object,
34
36
  )
35
37
 
38
+ if TYPE_CHECKING:
39
+ from evo.objects.typed import DownholeCollection
40
+
41
+
36
42
  __all__ = [
37
43
  "format_attributes_collection",
38
44
  "format_base_object",
39
45
  "format_block_model",
40
46
  "format_block_model_attributes",
41
47
  "format_block_model_version",
48
+ "format_downhole_collection",
42
49
  "format_report",
43
50
  "format_report_result",
44
51
  "format_task_result_list",
@@ -98,9 +105,9 @@ def _format_bounding_box(bbox: dict[str, Any]) -> str:
98
105
  :return: HTML string for the bounding box table.
99
106
  """
100
107
  bbox_rows = [
101
- ["<strong>X:</strong>", f"{bbox.get('min_x', 0):.2f}", f"{bbox.get('max_x', 0):.2f}"],
102
- ["<strong>Y:</strong>", f"{bbox.get('min_y', 0):.2f}", f"{bbox.get('max_y', 0):.2f}"],
103
- ["<strong>Z:</strong>", f"{bbox.get('min_z', 0):.2f}", f"{bbox.get('max_z', 0):.2f}"],
108
+ [markup("<strong>X:</strong>"), f"{bbox.get('min_x', 0):.2f}", f"{bbox.get('max_x', 0):.2f}"],
109
+ [markup("<strong>Y:</strong>"), f"{bbox.get('min_y', 0):.2f}", f"{bbox.get('max_y', 0):.2f}"],
110
+ [markup("<strong>Z:</strong>"), f"{bbox.get('min_z', 0):.2f}", f"{bbox.get('max_z', 0):.2f}"],
104
111
  ]
105
112
  return build_nested_table(["", "Min", "Max"], bbox_rows)
106
113
 
@@ -149,6 +156,34 @@ def _build_html_from_rows(
149
156
  return html
150
157
 
151
158
 
159
+ def _format_attributes_spec(attributes) -> str:
160
+ attr_rows = []
161
+ for attr in attributes:
162
+ attr_info = attr.as_dict()
163
+ attr_name = attr_info.get("name", "Unknown")
164
+ attr_type = attr_info.get("attribute_type", "Unknown")
165
+ attr_rows.append([attr_name, attr_type])
166
+
167
+ return build_nested_table(["Attribute", "Type"], attr_rows)
168
+
169
+
170
+ def _format_all_attribute_specs(obj, rows) -> None:
171
+ # Build datasets section - add as rows to the main table
172
+ top_level_attrs = None
173
+ if hasattr(obj, "attributes") and len(obj.attributes) > 0:
174
+ top_level_attrs = obj.attributes
175
+ rows.append(("Attributes:", _format_attributes_spec(obj.attributes)))
176
+
177
+ sub_models = getattr(obj, "_sub_models", [])
178
+ for dataset_name in sub_models:
179
+ dataset = getattr(obj, dataset_name, None)
180
+ if dataset and hasattr(dataset, "attributes") and len(dataset.attributes) > 0:
181
+ # Skip sub-model attributes that are an alias for the already-rendered top-level attributes
182
+ if dataset.attributes is top_level_attrs:
183
+ continue
184
+ rows.append((f"{dataset_name} attributes:", _format_attributes_spec(dataset.attributes)))
185
+
186
+
152
187
  def format_base_object(obj: Any) -> str:
153
188
  """Format a BaseObject (or subclass) as HTML.
154
189
 
@@ -169,21 +204,7 @@ def format_base_object(obj: Any) -> str:
169
204
  if crs := doc.get("coordinate_reference_system"):
170
205
  rows.append(("CRS:", _format_crs(crs)))
171
206
 
172
- # Build datasets section - add as rows to the main table
173
- sub_models = getattr(obj, "_sub_models", [])
174
- for dataset_name in sub_models:
175
- dataset = getattr(obj, dataset_name, None)
176
- if dataset and hasattr(dataset, "attributes") and len(dataset.attributes) > 0:
177
- # Build attribute rows
178
- attr_rows = []
179
- for attr in dataset.attributes:
180
- attr_info = attr.as_dict()
181
- attr_name = attr_info.get("name", "Unknown")
182
- attr_type = attr_info.get("attribute_type", "Unknown")
183
- attr_rows.append([attr_name, attr_type])
184
-
185
- attrs_table = build_nested_table(["Attribute", "Type"], attr_rows)
186
- rows.append((f"{dataset_name}:", attrs_table))
207
+ _format_all_attribute_specs(obj, rows)
187
208
 
188
209
  return _build_html_from_rows(name, title_links, rows)
189
210
 
@@ -301,6 +322,43 @@ def format_variogram(obj: Any) -> str:
301
322
  return _build_html_from_rows(name, title_links, rows, extra_content)
302
323
 
303
324
 
325
+ def _format_downhole_collection_collections(obj, rows):
326
+ for i, collection in enumerate(obj.collections):
327
+ name = f"Collection {i + 1}"
328
+ rows.append((f"{name} name:", collection.name))
329
+ rows.append((f"{name} type:", collection.collection_type))
330
+ if collection.collection_type == "distance":
331
+ rows.append((f"{name} distance unit:", collection.distance.unit))
332
+ if attributes := collection.distance.attributes:
333
+ rows.append((f"{name} attributes:", _format_attributes_spec(attributes)))
334
+
335
+
336
+ def format_downhole_collection(obj: DownholeCollection) -> str:
337
+ """Format a DownholeCollection object as HTML.
338
+
339
+ This formatter renders a downhole collection by extracting metadata and rendering it as a styled HTML table with
340
+ Portal/Viewer links.
341
+
342
+ :param obj: A DownholeCollection object from evo.objects.typed.downhole_collection.
343
+ :return: HTML string representation.
344
+ """
345
+ doc = obj.as_dict()
346
+ name, title_links, rows = _get_base_metadata(obj)
347
+ rows.append(("Bounding box:", _format_bounding_box(doc["bounding_box"])))
348
+ rows.append(("CRS:", _format_crs(doc["coordinate_reference_system"])))
349
+
350
+ # DownholeCollection-specific scalar properties
351
+ rows.append(("Distance unit:", str(obj.distance_unit)))
352
+ rows.append(("Desurvey method:", str(obj.desurvey)))
353
+ rows.append(("Type:", obj.type))
354
+ rows.append(("Number of holes:", str(obj.location.hole_id.length)))
355
+
356
+ _format_all_attribute_specs(obj, rows)
357
+ _format_downhole_collection_collections(obj, rows)
358
+
359
+ return _build_html_from_rows(name, title_links, rows)
360
+
361
+
304
362
  def format_block_model_version(obj: Any) -> str:
305
363
  """Format a block model Version object as HTML.
306
364
 
@@ -521,17 +579,17 @@ def format_block_model(obj: Any) -> str:
521
579
  # Add geometry info
522
580
  geom = obj.geometry
523
581
  geom_rows = [
524
- ["<strong>Origin:</strong>", f"({geom.origin.x:.2f}, {geom.origin.y:.2f}, {geom.origin.z:.2f})"],
525
- ["<strong>N Blocks:</strong>", f"({geom.n_blocks.nx}, {geom.n_blocks.ny}, {geom.n_blocks.nz})"],
582
+ [markup("<strong>Origin:</strong>"), f"({geom.origin.x:.2f}, {geom.origin.y:.2f}, {geom.origin.z:.2f})"],
583
+ [markup("<strong>N Blocks:</strong>"), f"({geom.n_blocks.nx}, {geom.n_blocks.ny}, {geom.n_blocks.nz})"],
526
584
  [
527
- "<strong>Block Size:</strong>",
585
+ markup("<strong>Block Size:</strong>"),
528
586
  f"({geom.block_size.dx:.2f}, {geom.block_size.dy:.2f}, {geom.block_size.dz:.2f})",
529
587
  ],
530
588
  ]
531
589
  if geom.rotation:
532
590
  geom_rows.append(
533
591
  [
534
- "<strong>Rotation:</strong>",
592
+ markup("<strong>Rotation:</strong>"),
535
593
  f"({geom.rotation.dip_azimuth:.2f}, {geom.rotation.dip:.2f}, {geom.rotation.pitch:.2f})",
536
594
  ]
537
595
  )
@@ -643,7 +701,7 @@ def _format_single_task_result_inner(result: Any, index: int | None = None) -> s
643
701
  rows = [
644
702
  ("Target:", target_name),
645
703
  ("Schema:", schema_display),
646
- ("Attribute:", f'<span class="attr-highlight">{attribute_name}</span>'),
704
+ ("Attribute:", markup(f'<span class="attr-highlight">{escape(attribute_name)}</span>')),
647
705
  ]
648
706
  else:
649
707
  rows = []
@@ -11,6 +11,8 @@
11
11
 
12
12
  """Shared HTML styles for Jupyter notebook representations across all Evo SDK packages."""
13
13
 
14
+ from collections.abc import Sequence
15
+ from html import escape
14
16
  from pathlib import Path
15
17
 
16
18
  # Load CSS from external file
@@ -21,14 +23,27 @@ _CSS_CONTENT = _CSS_PATH.read_text(encoding="utf-8")
21
23
  STYLESHEET = f"<style>\n{_CSS_CONTENT}</style>\n"
22
24
 
23
25
 
24
- def build_container(content: str, css_class: str = "evo") -> str:
26
+ class HtmlMarkup(str):
27
+ """Markup deliberately produced by this package and safe to embed in HTML."""
28
+
29
+
30
+ def markup(value: str) -> HtmlMarkup:
31
+ """Mark package-generated HTML for explicit insertion by the HTML builders."""
32
+ return HtmlMarkup(value)
33
+
34
+
35
+ def _html_text(value: object) -> str:
36
+ return str(value) if isinstance(value, HtmlMarkup) else escape(str(value))
37
+
38
+
39
+ def build_container(content: str | HtmlMarkup, css_class: str = "evo") -> str:
25
40
  """Wrap content in a styled container div.
26
41
 
27
- :param content: HTML content to wrap.
42
+ :param content: Content to wrap, escaped unless wrapped with :func:`markup`.
28
43
  :param css_class: CSS class for the container (default: "evo").
29
44
  :return: Wrapped HTML string.
30
45
  """
31
- return f'{STYLESHEET}<div class="{css_class}">{content}</div>'
46
+ return f'{STYLESHEET}<div class="{escape(css_class)}">{_html_text(content)}</div>'
32
47
 
33
48
 
34
49
  def build_title(text: str, links: list[tuple[str, str]] | None = None) -> str:
@@ -39,31 +54,31 @@ def build_title(text: str, links: list[tuple[str, str]] | None = None) -> str:
39
54
  :return: HTML string.
40
55
  """
41
56
  if links:
42
- link_html = " | ".join([f'<a href="{url}" target="_blank">{label}</a>' for label, url in links])
43
- return f'<div class="title"><span>{text}</span><span class="title-links">{link_html}</span></div>'
44
- return f'<div class="title">{text}</div>'
57
+ link_html = " | ".join([f'<a href="{escape(url)}" target="_blank">{escape(label)}</a>' for label, url in links])
58
+ return f'<div class="title"><span>{escape(text)}</span><span class="title-links">{link_html}</span></div>'
59
+ return f'<div class="title">{escape(text)}</div>'
45
60
 
46
61
 
47
- def build_table_row(label: str, value: str, is_last: bool = False) -> str:
62
+ def build_table_row(label: str, value: str | HtmlMarkup, is_last: bool = False) -> str:
48
63
  """Create a table row with label and value.
49
64
 
50
65
  :param label: Label text.
51
- :param value: Value text (can contain HTML).
52
- :param is_last: If True, don't add bottom border.
66
+ :param value: Value text, escaped unless wrapped with :func:`markup`.
67
+ :param is_last: Retained for backwards compatibility; has no effect.
53
68
  :return: HTML string.
54
69
  """
55
- return f'<tr><td class="label">{label}</td><td class="value">{value}</td></tr>'
70
+ return f'<tr><td class="label">{_html_text(label)}</td><td class="value">{_html_text(value)}</td></tr>'
56
71
 
57
72
 
58
- def build_table_row_vtop(label: str, value: str, is_last: bool = False) -> str:
73
+ def build_table_row_vtop(label: str, value: str | HtmlMarkup, is_last: bool = False) -> str:
59
74
  """Create a table row with label and value (label top-aligned).
60
75
 
61
76
  :param label: Label text.
62
- :param value: Value text (can contain HTML).
63
- :param is_last: If True, don't add bottom border.
77
+ :param value: Value text, escaped unless wrapped with :func:`markup`.
78
+ :param is_last: Retained for backwards compatibility; has no effect.
64
79
  :return: HTML string.
65
80
  """
66
- return f'<tr><td class="label-vtop">{label}</td><td class="value">{value}</td></tr>'
81
+ return f'<tr><td class="label-vtop">{_html_text(label)}</td><td class="value">{_html_text(value)}</td></tr>'
67
82
 
68
83
 
69
84
  def build_section_divider(title: str) -> str:
@@ -72,10 +87,10 @@ def build_section_divider(title: str) -> str:
72
87
  :param title: Section title.
73
88
  :return: HTML string.
74
89
  """
75
- return f'<div class="section"><div class="section-heading">{title}</div>'
90
+ return f'<div class="section"><div class="section-heading">{_html_text(title)}</div>'
76
91
 
77
92
 
78
- def build_table(rows: list[tuple[str, str]]) -> str:
93
+ def build_table(rows: list[tuple[str, str | HtmlMarkup]]) -> str:
79
94
  """Build an HTML table from rows of (label, value) tuples.
80
95
 
81
96
  :param rows: List of (label, value) tuples.
@@ -85,7 +100,9 @@ def build_table(rows: list[tuple[str, str]]) -> str:
85
100
  return f"<table>{''.join(table_rows)}</table>"
86
101
 
87
102
 
88
- def build_nested_table(headers: list[str], rows: list[list[str]], css_class: str = "") -> str:
103
+ def build_nested_table(
104
+ headers: Sequence[str], rows: Sequence[Sequence[str | int | float]], css_class: str = ""
105
+ ) -> HtmlMarkup:
89
106
  """Build a nested HTML table with headers and data rows.
90
107
 
91
108
  :param headers: List of header strings.
@@ -93,12 +110,12 @@ def build_nested_table(headers: list[str], rows: list[list[str]], css_class: str
93
110
  :param css_class: Additional CSS classes to add to the table (optional).
94
111
  :return: HTML string for a nested table.
95
112
  """
96
- class_attr = f' class="nested {css_class}"' if css_class else ' class="nested"'
113
+ class_attr = f' class="nested {escape(css_class)}"' if css_class else ' class="nested"'
97
114
 
98
115
  # Build header row
99
116
  header_cells = []
100
117
  for i, header in enumerate(headers):
101
- header_cells.append(f"<th>{header}</th>")
118
+ header_cells.append(f"<th>{_html_text(header)}</th>")
102
119
 
103
120
  # Build data rows
104
121
  data_rows = []
@@ -109,20 +126,31 @@ def build_nested_table(headers: list[str], rows: list[list[str]], css_class: str
109
126
  if isinstance(cell, (int, float)):
110
127
  formatted_cell = f"{cell:.2f}"
111
128
  else:
112
- formatted_cell = str(cell)
129
+ formatted_cell = _html_text(cell)
113
130
  cells.append(f"<td>{formatted_cell}</td>")
114
131
  data_rows.append(f"<tr>{''.join(cells)}</tr>")
115
132
 
116
- return f"<table{class_attr}><tr>{''.join(header_cells)}</tr>{''.join(data_rows)}</table>"
133
+ return markup(f"<table{class_attr}><tr>{''.join(header_cells)}</tr>{''.join(data_rows)}</table>")
117
134
 
118
135
 
119
- def build_object_html(title: str, rows: list[tuple[str, str]], extra_content: str = "") -> str:
136
+ def build_object_html(
137
+ title: str,
138
+ rows: list[tuple[str, str | HtmlMarkup]],
139
+ extra_content: str | HtmlMarkup = "",
140
+ ) -> str:
120
141
  """Build a complete object HTML representation.
121
142
 
122
143
  :param title: Object title/name.
123
144
  :param rows: List of (label, value) tuples for the main properties table.
124
- :param extra_content: Additional HTML content to append after the table (optional).
145
+ :param extra_content: Additional content, escaped unless wrapped with :func:`markup`.
125
146
  :return: Complete HTML string with stylesheet.
126
147
  """
127
- html_parts = [STYLESHEET, '<div class="evo">', build_title(title), build_table(rows), extra_content, "</div>"]
148
+ html_parts = [
149
+ STYLESHEET,
150
+ '<div class="evo">',
151
+ build_title(title),
152
+ build_table(rows),
153
+ _html_text(extra_content),
154
+ "</div>",
155
+ ]
128
156
  return "".join(html_parts)
@@ -17,7 +17,7 @@ This module provides functions to generate URLs for viewing objects in the Evo P
17
17
  from __future__ import annotations
18
18
 
19
19
  from typing import TYPE_CHECKING, Any
20
- from urllib.parse import urlparse
20
+ from urllib.parse import urlencode, urlparse
21
21
  from uuid import UUID
22
22
 
23
23
  from evo.objects import ObjectReference
@@ -121,7 +121,7 @@ def get_blocksync_report_url(
121
121
  base_url = get_blocksync_base_url("")
122
122
  url = f"{base_url}/{org_id}/{hub_code}/{workspace_id}/blockmodel/{block_model_id}/reports/{report_id}"
123
123
  if result_id:
124
- url += f"?result_id={result_id}"
124
+ url += f"?{urlencode({'result_id': result_id})}"
125
125
  return url
126
126
 
127
127
 
File without changes
File without changes