sier2 1.0.1__py2.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.
- sier2/__init__.py +6 -0
- sier2/__main__.py +111 -0
- sier2/_block.py +273 -0
- sier2/_config.py +251 -0
- sier2/_dag.py +648 -0
- sier2/_library.py +256 -0
- sier2/_logger.py +65 -0
- sier2/_util.py +127 -0
- sier2/_version.py +3 -0
- sier2/panel/__init__.py +1 -0
- sier2/panel/_chart.py +313 -0
- sier2/panel/_feedlogger.py +153 -0
- sier2/panel/_panel.py +505 -0
- sier2/panel/_panel_util.py +83 -0
- sier2-1.0.1.dist-info/METADATA +69 -0
- sier2-1.0.1.dist-info/RECORD +18 -0
- sier2-1.0.1.dist-info/WHEEL +4 -0
- sier2-1.0.1.dist-info/licenses/LICENSE +21 -0
sier2/panel/_chart.py
ADDED
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
# Generate a dag chart.
|
|
2
|
+
# We stick to raw python/html here to keep sier's dependencies to a minimum,
|
|
3
|
+
# even if other libraries would be easier to get plots from.
|
|
4
|
+
#
|
|
5
|
+
|
|
6
|
+
from sier2 import Block, Dag
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
import panel as pn
|
|
10
|
+
import math
|
|
11
|
+
|
|
12
|
+
class HTMLGraph:
|
|
13
|
+
"""A class to generate network graph visualizations using Panel HTML pane"""
|
|
14
|
+
|
|
15
|
+
def __init__(self, width=400, height=400, background="#f8f9fa"):
|
|
16
|
+
self.width = width
|
|
17
|
+
self.height = height
|
|
18
|
+
self.background = background
|
|
19
|
+
self.nodes = {}
|
|
20
|
+
self.edges = []
|
|
21
|
+
self.node_styles = {
|
|
22
|
+
"default": {
|
|
23
|
+
"background-color": "#4a86e8",
|
|
24
|
+
"color": "black",
|
|
25
|
+
"border": "none",
|
|
26
|
+
"font-weight": "bold"
|
|
27
|
+
},
|
|
28
|
+
"input": {
|
|
29
|
+
"background-color": "#f2df0c",
|
|
30
|
+
"color": "black",
|
|
31
|
+
"font-weight": "bold"
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
self.edge_styles = {
|
|
35
|
+
"default": {
|
|
36
|
+
"background-color": "#666",
|
|
37
|
+
"height": "2px"
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
def add_node_style(self, style_name, style_dict):
|
|
42
|
+
"""Add a new node style"""
|
|
43
|
+
self.node_styles[style_name] = style_dict
|
|
44
|
+
|
|
45
|
+
def add_edge_style(self, style_name, style_dict):
|
|
46
|
+
"""Add a new edge style"""
|
|
47
|
+
self.edge_styles[style_name] = style_dict
|
|
48
|
+
|
|
49
|
+
def add_node(self, node_id, label, x=None, y=None, style="default"):
|
|
50
|
+
"""Add a node to the graph"""
|
|
51
|
+
# If no position specified, assign random position
|
|
52
|
+
if x is None:
|
|
53
|
+
x = random.randint(50, self.width - 50)
|
|
54
|
+
if y is None:
|
|
55
|
+
y = random.randint(50, self.height - 50)
|
|
56
|
+
|
|
57
|
+
self.nodes[node_id] = {
|
|
58
|
+
"label": label,
|
|
59
|
+
"x": x,
|
|
60
|
+
"y": y,
|
|
61
|
+
"style": style
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
def add_edge(self, source_id, target_id, label=None, style="default"):
|
|
65
|
+
"""Add an edge between two nodes"""
|
|
66
|
+
if source_id in self.nodes and target_id in self.nodes:
|
|
67
|
+
self.edges.append({
|
|
68
|
+
"source": source_id,
|
|
69
|
+
"target": target_id,
|
|
70
|
+
"label": label,
|
|
71
|
+
"style": style
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
# def layout_circle(self, center_x=None, center_y=None, radius=None):
|
|
75
|
+
# """Arrange nodes in a circle"""
|
|
76
|
+
# if center_x is None:
|
|
77
|
+
# center_x = self.width / 2
|
|
78
|
+
# if center_y is None:
|
|
79
|
+
# center_y = self.height / 2
|
|
80
|
+
# if radius is None:
|
|
81
|
+
# radius = min(self.width, self.height) / 3
|
|
82
|
+
|
|
83
|
+
# node_ids = list(self.nodes.keys())
|
|
84
|
+
# for i, node_id in enumerate(node_ids):
|
|
85
|
+
# angle = 2 * math.pi * i / len(node_ids)
|
|
86
|
+
# self.nodes[node_id]["x"] = center_x + radius * math.cos(angle)
|
|
87
|
+
# self.nodes[node_id]["y"] = center_y + radius * math.sin(angle)
|
|
88
|
+
|
|
89
|
+
def generate_html(self):
|
|
90
|
+
"""Generate HTML representation of the network graph"""
|
|
91
|
+
html = f"""
|
|
92
|
+
<style>
|
|
93
|
+
.graph-container {{
|
|
94
|
+
position: relative;
|
|
95
|
+
width: {self.width}px;
|
|
96
|
+
height: {self.height}px;
|
|
97
|
+
background-color: {self.background};
|
|
98
|
+
border: 1px solid #ddd;
|
|
99
|
+
overflow: hidden;
|
|
100
|
+
}}
|
|
101
|
+
|
|
102
|
+
.node {{
|
|
103
|
+
position: absolute;
|
|
104
|
+
width: 60px;
|
|
105
|
+
height: 60px;
|
|
106
|
+
border-radius: 50%;
|
|
107
|
+
display: flex;
|
|
108
|
+
justify-content: center;
|
|
109
|
+
align-items: center;
|
|
110
|
+
text-align: center;
|
|
111
|
+
font-size: 14px;
|
|
112
|
+
box-shadow: 0 2px 5px rgba(0,0,0,0.2);
|
|
113
|
+
cursor: pointer;
|
|
114
|
+
transform-origin: center;
|
|
115
|
+
transition: transform 0.2s ease, box-shadow 0.2s ease;
|
|
116
|
+
z-index: 10;
|
|
117
|
+
}}
|
|
118
|
+
|
|
119
|
+
# .node:hover {{
|
|
120
|
+
# transform: scale(1.1);
|
|
121
|
+
# box-shadow: 0 4px 8px rgba(0,0,0,0.3);
|
|
122
|
+
# z-index: 20;
|
|
123
|
+
# }}
|
|
124
|
+
|
|
125
|
+
.edge {{
|
|
126
|
+
position: absolute;
|
|
127
|
+
height: 2px;
|
|
128
|
+
transform-origin: left center;
|
|
129
|
+
z-index: 5;
|
|
130
|
+
}}
|
|
131
|
+
|
|
132
|
+
.edge-label {{
|
|
133
|
+
position: absolute;
|
|
134
|
+
background-color: white;
|
|
135
|
+
padding: 2px 6px;
|
|
136
|
+
border-radius: 3px;
|
|
137
|
+
font-size: 12px;
|
|
138
|
+
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
|
|
139
|
+
z-index: 15;
|
|
140
|
+
}}
|
|
141
|
+
</style>
|
|
142
|
+
|
|
143
|
+
<div class="graph-container">
|
|
144
|
+
"""
|
|
145
|
+
|
|
146
|
+
# Add edges first (so they're behind nodes)
|
|
147
|
+
for edge in self.edges:
|
|
148
|
+
source = self.nodes[edge["source"]]
|
|
149
|
+
target = self.nodes[edge["target"]]
|
|
150
|
+
|
|
151
|
+
# Calculate edge properties
|
|
152
|
+
x1, y1 = source["x"], source["y"]
|
|
153
|
+
x2, y2 = target["x"], target["y"]
|
|
154
|
+
|
|
155
|
+
# Calculate length and angle
|
|
156
|
+
length = math.sqrt((x2 - x1)**2 + (y2 - y1)**2)
|
|
157
|
+
angle = math.atan2(y2 - y1, x2 - x1) * (180 / math.pi)
|
|
158
|
+
|
|
159
|
+
# Get style properties
|
|
160
|
+
style_props = self.edge_styles[edge["style"]]
|
|
161
|
+
style_attr = "; ".join([f"{k}: {v}" for k, v in style_props.items()])
|
|
162
|
+
|
|
163
|
+
# Create edge element
|
|
164
|
+
html += f"""
|
|
165
|
+
<div class="edge"
|
|
166
|
+
style="width: {length}px;
|
|
167
|
+
left: {x1}px;
|
|
168
|
+
top: {y1}px;
|
|
169
|
+
transform: rotate({angle}deg);
|
|
170
|
+
{style_attr}">
|
|
171
|
+
</div>
|
|
172
|
+
"""
|
|
173
|
+
|
|
174
|
+
# Add edge label if specified
|
|
175
|
+
if edge["label"]:
|
|
176
|
+
# Position label at the middle of the edge
|
|
177
|
+
label_x = (x1 + x2) / 2 - 15
|
|
178
|
+
label_y = (y1 + y2) / 2 - 10
|
|
179
|
+
|
|
180
|
+
html += f"""
|
|
181
|
+
<div class="edge-label" style="left: {label_x}px; top: {label_y}px;">
|
|
182
|
+
{edge["label"]}
|
|
183
|
+
</div>
|
|
184
|
+
"""
|
|
185
|
+
|
|
186
|
+
# Add nodes
|
|
187
|
+
for node_id, node in self.nodes.items():
|
|
188
|
+
# Get style properties
|
|
189
|
+
style_props = self.node_styles[node["style"]]
|
|
190
|
+
style_attr = "; ".join([f"{k}: {v}" for k, v in style_props.items()])
|
|
191
|
+
|
|
192
|
+
# Create node element
|
|
193
|
+
html += f"""
|
|
194
|
+
<div class="node" id="node-{node_id}"
|
|
195
|
+
style="left: {node['x'] - 30}px;
|
|
196
|
+
top: {node['y'] - 30}px;
|
|
197
|
+
{style_attr}"
|
|
198
|
+
onclick="highlightNode('{node_id}')">
|
|
199
|
+
{node["label"]}
|
|
200
|
+
</div>
|
|
201
|
+
"""
|
|
202
|
+
|
|
203
|
+
# # Add some basic interactivity with JavaScript
|
|
204
|
+
# html += """
|
|
205
|
+
# <script>
|
|
206
|
+
# function highlightNode(nodeId) {
|
|
207
|
+
# // Reset all nodes
|
|
208
|
+
# const nodes = document.querySelectorAll('.node');
|
|
209
|
+
# nodes.forEach(n => n.style.transform = 'scale(1)');
|
|
210
|
+
|
|
211
|
+
# // Highlight the selected node
|
|
212
|
+
# const node = document.getElementById('node-' + nodeId);
|
|
213
|
+
# node.style.transform = 'scale(1.2)';
|
|
214
|
+
# }
|
|
215
|
+
# </script>
|
|
216
|
+
# """
|
|
217
|
+
|
|
218
|
+
html += "</div>"
|
|
219
|
+
return html
|
|
220
|
+
|
|
221
|
+
def get_pane(self, width=None, height=None):
|
|
222
|
+
"""Get a Panel pane with the graph"""
|
|
223
|
+
if width is None:
|
|
224
|
+
width = self.width + 50
|
|
225
|
+
if height is None:
|
|
226
|
+
height = self.height + 50
|
|
227
|
+
|
|
228
|
+
return pn.pane.HTML(self.generate_html(), width=width, height=height)
|
|
229
|
+
|
|
230
|
+
def html_graph(dag: Dag):
|
|
231
|
+
"""Build a Bokeh figure to visualise the block connections."""
|
|
232
|
+
|
|
233
|
+
src: list[Block] = []
|
|
234
|
+
dst: list[Block] = []
|
|
235
|
+
|
|
236
|
+
def build_layers():
|
|
237
|
+
"""Traverse the block pairs and organise them into layers.
|
|
238
|
+
|
|
239
|
+
The first layer contains the root (no input) nodes.
|
|
240
|
+
"""
|
|
241
|
+
|
|
242
|
+
ranks = {}
|
|
243
|
+
remaining = dag._block_pairs[:]
|
|
244
|
+
|
|
245
|
+
# Find the root nodes and assign them a layer.
|
|
246
|
+
#
|
|
247
|
+
src[:], dst[:] = zip(*remaining)
|
|
248
|
+
S = list(set([s for s in src if s not in dst]))
|
|
249
|
+
for s in S:
|
|
250
|
+
ranks[s.name] = 0
|
|
251
|
+
|
|
252
|
+
n_layers = 1
|
|
253
|
+
while remaining:
|
|
254
|
+
for s, d in remaining:
|
|
255
|
+
if s.name in ranks:
|
|
256
|
+
# This destination could be from sources at different layers.
|
|
257
|
+
# Make sure the deepest one is used.
|
|
258
|
+
#
|
|
259
|
+
ranks[d.name] = max(ranks.get(d.name, 0), ranks[s.name] + 1)
|
|
260
|
+
n_layers = max(n_layers, ranks[d.name])
|
|
261
|
+
|
|
262
|
+
remaining = [(s,d) for s,d in remaining if d.name not in ranks]
|
|
263
|
+
|
|
264
|
+
return n_layers, ranks
|
|
265
|
+
|
|
266
|
+
def layout():
|
|
267
|
+
"""Arrange the graph nodes."""
|
|
268
|
+
|
|
269
|
+
max_width = 0
|
|
270
|
+
|
|
271
|
+
# Arrange the graph y by layer from top to bottom.
|
|
272
|
+
# For x, for now we start at 0 and +1 in each layer.
|
|
273
|
+
#
|
|
274
|
+
yx = {y:0 for y in ranks.values()}
|
|
275
|
+
gxy = {}
|
|
276
|
+
for g, y in ranks.items():
|
|
277
|
+
gxy[g] = [yx[y], y]
|
|
278
|
+
yx[y] += 1
|
|
279
|
+
max_width = max(max_width, yx[y])
|
|
280
|
+
|
|
281
|
+
# Balance out the x in each layer.
|
|
282
|
+
#
|
|
283
|
+
for y in range(n_layers+1):
|
|
284
|
+
layer = {name: xy for name,xy in gxy.items() if xy[1]==y}
|
|
285
|
+
if len(layer)<max_width:
|
|
286
|
+
for x, (name, xy) in enumerate(layer.items(), 1):
|
|
287
|
+
gxy[name][0] = x/max_width
|
|
288
|
+
|
|
289
|
+
return gxy
|
|
290
|
+
|
|
291
|
+
graph = HTMLGraph()
|
|
292
|
+
|
|
293
|
+
n_layers, ranks = build_layers()
|
|
294
|
+
|
|
295
|
+
ly = layout()
|
|
296
|
+
|
|
297
|
+
max_layer_width = max([ly[n][0] for n in ly.keys()])
|
|
298
|
+
|
|
299
|
+
vertical_scale = graph.height / (n_layers + 2)
|
|
300
|
+
horizontal_scale = graph.width / (max_layer_width + 2)
|
|
301
|
+
|
|
302
|
+
for node in ly.keys():
|
|
303
|
+
graph.add_node(
|
|
304
|
+
node, node,
|
|
305
|
+
x=(ly[node][0] + 1) * horizontal_scale,
|
|
306
|
+
y=(ly[node][1] + 1) * vertical_scale,
|
|
307
|
+
style='default' if not dag.block_by_name(node).block_pause_execution else 'input'
|
|
308
|
+
)
|
|
309
|
+
|
|
310
|
+
for s, d in dag._block_pairs:
|
|
311
|
+
graph.add_edge(s.name, d.name)
|
|
312
|
+
|
|
313
|
+
return graph.get_pane()
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
|
|
2
|
+
"""A logger that logs to a panel.widget.Feed."""
|
|
3
|
+
|
|
4
|
+
from datetime import datetime
|
|
5
|
+
import html
|
|
6
|
+
import logging
|
|
7
|
+
import panel as pn
|
|
8
|
+
|
|
9
|
+
from .._block import BlockState
|
|
10
|
+
from ._panel_util import _get_state_color
|
|
11
|
+
|
|
12
|
+
_INFO_FORMATTER = logging.Formatter('%(asctime)s %(block_state)s %(block_name)s %(message)s', datefmt='%H:%M:%S')
|
|
13
|
+
_FORMATTER = logging.Formatter('%(asctime)s %(block_state)s %(block_name)s - %(levelname)s - %(message)s', datefmt='%H:%M:%S')
|
|
14
|
+
|
|
15
|
+
class PanelHandler(logging.Handler):
|
|
16
|
+
"""A handler that emits log strings to a panel template sidebar Feed pane."""
|
|
17
|
+
|
|
18
|
+
def __init__(self, log_feed):
|
|
19
|
+
super().__init__()
|
|
20
|
+
self.log_feed = log_feed
|
|
21
|
+
|
|
22
|
+
def format(self, record):
|
|
23
|
+
# TODO override logging.Formatter.formatException to <pre> the exception string.
|
|
24
|
+
|
|
25
|
+
color = _get_state_color(record.block_state)
|
|
26
|
+
|
|
27
|
+
record.block_name = f'[{html.escape(record.block_name)}]' if record.block_name else ''
|
|
28
|
+
record.block_state = f'<span style="color:{color};">■</span>'
|
|
29
|
+
record.msg = html.escape(record.msg)
|
|
30
|
+
fmt = _INFO_FORMATTER if record.levelno==logging.INFO else _FORMATTER
|
|
31
|
+
|
|
32
|
+
return fmt.format(record)
|
|
33
|
+
|
|
34
|
+
def emit(self, record):
|
|
35
|
+
if record.block_state is None:
|
|
36
|
+
self.log_feed.clear()
|
|
37
|
+
return
|
|
38
|
+
|
|
39
|
+
try:
|
|
40
|
+
msg = self.format(record)
|
|
41
|
+
self.log_feed.append(pn.pane.HTML(msg))
|
|
42
|
+
except RecursionError: # See issue 36272
|
|
43
|
+
raise
|
|
44
|
+
except Exception:
|
|
45
|
+
self.handleError(record)
|
|
46
|
+
|
|
47
|
+
class DagPanelAdapter(logging.LoggerAdapter):
|
|
48
|
+
"""An adapter that logs messages from a dag.
|
|
49
|
+
|
|
50
|
+
Each message also specifies a block name and state.
|
|
51
|
+
"""
|
|
52
|
+
|
|
53
|
+
def debug(self, msg, *args, block_name, block_state):
|
|
54
|
+
super().debug(msg, *args, extra={'block_name': block_name, 'block_state': block_state})
|
|
55
|
+
|
|
56
|
+
def info(self, msg, *args, block_name, block_state):
|
|
57
|
+
super().info(msg, *args, extra={'block_name': block_name, 'block_state': block_state})
|
|
58
|
+
|
|
59
|
+
def warning(self, msg, *args, block_name, block_state):
|
|
60
|
+
super().warning(msg, *args, extra={'block_name': block_name, 'block_state': block_state})
|
|
61
|
+
|
|
62
|
+
def error(self, msg, *args, block_name, block_state):
|
|
63
|
+
super().error(msg, *args, extra={'block_name': block_name, 'block_state': block_state})
|
|
64
|
+
|
|
65
|
+
def exception(self, msg, *args, block_name, block_state):
|
|
66
|
+
super().exception(msg, *args, extra={'block_name': block_name, 'block_state': block_state})
|
|
67
|
+
|
|
68
|
+
def critical(self, msg, *args, block_name, block_state):
|
|
69
|
+
super().critical(msg, *args, extra={'block_name': block_name, 'block_state': block_state})
|
|
70
|
+
|
|
71
|
+
def process(self, msg, kwargs):
|
|
72
|
+
# print(f'ADAPTER {msg=} {kwargs=} {self.extra=}')
|
|
73
|
+
if 'block_state' not in kwargs['extra']:
|
|
74
|
+
kwargs['extra']['block_state'] = '?'
|
|
75
|
+
if 'block_name' not in kwargs['extra']:
|
|
76
|
+
kwargs['extra']['block_name'] = 'g'
|
|
77
|
+
|
|
78
|
+
return msg, kwargs
|
|
79
|
+
|
|
80
|
+
_logger = logging.getLogger('block.panel')
|
|
81
|
+
_logger.setLevel(logging.INFO)
|
|
82
|
+
|
|
83
|
+
# ph = PanelHandler(log_feed)
|
|
84
|
+
# ph.log_feed = log_feed
|
|
85
|
+
# ph.setLevel(logging.INFO)
|
|
86
|
+
|
|
87
|
+
# _logger.addHandler(ph)
|
|
88
|
+
|
|
89
|
+
def getDagPanelLogger(log_feed):
|
|
90
|
+
# _logger = logging.getLogger('block.panel')
|
|
91
|
+
# _logger.setLevel(logging.INFO)
|
|
92
|
+
|
|
93
|
+
ph = PanelHandler(log_feed)
|
|
94
|
+
ph.log_feed = log_feed
|
|
95
|
+
ph.setLevel(logging.INFO)
|
|
96
|
+
|
|
97
|
+
_logger.addHandler(ph)
|
|
98
|
+
|
|
99
|
+
adapter = DagPanelAdapter(_logger)
|
|
100
|
+
|
|
101
|
+
return adapter
|
|
102
|
+
|
|
103
|
+
####
|
|
104
|
+
|
|
105
|
+
class BlockPanelAdapter(logging.LoggerAdapter):
|
|
106
|
+
"""An adapter that logs messages from a block.
|
|
107
|
+
|
|
108
|
+
A state isn't required, because if a block is logging something,
|
|
109
|
+
it's executing by definition.
|
|
110
|
+
|
|
111
|
+
A name isn't required in the logging methods, because the name is
|
|
112
|
+
implicit.
|
|
113
|
+
"""
|
|
114
|
+
|
|
115
|
+
def __init__(self, logger, block_name, extra=None):
|
|
116
|
+
super().__init__(logger, extra)
|
|
117
|
+
self.block_name = block_name
|
|
118
|
+
|
|
119
|
+
def debug(self, msg, *args):
|
|
120
|
+
super().debug(msg, *args, extra={'block_name': self.block_name, 'block_state': BlockState.BLOCK})
|
|
121
|
+
|
|
122
|
+
def info(self, msg, *args):
|
|
123
|
+
super().info(msg, *args, extra={'block_name': self.block_name, 'block_state': BlockState.BLOCK})
|
|
124
|
+
|
|
125
|
+
def warning(self, msg, *args):
|
|
126
|
+
super().warning(msg, *args, extra={'block_name': self.block_name, 'block_state': BlockState.BLOCK})
|
|
127
|
+
|
|
128
|
+
def error(self, msg, *args):
|
|
129
|
+
super().error(msg, *args, extra={'block_name': self.block_name, 'block_state': BlockState.BLOCK})
|
|
130
|
+
|
|
131
|
+
def exception(self, msg, *args):
|
|
132
|
+
super().exception(msg, *args, extra={'block_name': self.block_name, 'block_state': BlockState.BLOCK})
|
|
133
|
+
|
|
134
|
+
def critical(self, msg, *args):
|
|
135
|
+
super().critical(msg, *args, extra={'block_name': self.block_name, 'block_state': BlockState.BLOCK})
|
|
136
|
+
|
|
137
|
+
def process(self, msg, kwargs):
|
|
138
|
+
# print(f'GP ADAPTER {msg=} {kwargs=} {self.extra=}')
|
|
139
|
+
if 'block_state' not in kwargs['extra']:
|
|
140
|
+
kwargs['extra']['block_state'] = BlockState.BLOCK
|
|
141
|
+
if 'block_name' not in kwargs['extra']:
|
|
142
|
+
kwargs['extra']['block_name'] = self.block_name
|
|
143
|
+
|
|
144
|
+
return msg, kwargs
|
|
145
|
+
|
|
146
|
+
def getBlockPanelLogger(block_name: str):
|
|
147
|
+
"""A logger for blocks.
|
|
148
|
+
|
|
149
|
+
The dag gets its logger first, so we can reuse _logger."""
|
|
150
|
+
|
|
151
|
+
adapter = BlockPanelAdapter(_logger, block_name)
|
|
152
|
+
|
|
153
|
+
return adapter
|