sier2 0.29__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.

Potentially problematic release.


This version of sier2 might be problematic. Click here for more details.

sier2/panel/_panel.py ADDED
@@ -0,0 +1,412 @@
1
+ import ctypes
2
+ from datetime import datetime
3
+ import html
4
+ import panel as pn
5
+ import sys
6
+ import threading
7
+ from typing import Callable
8
+
9
+ from sier2 import Block, BlockValidateError, BlockState, Dag, BlockError
10
+ from .._dag import _InputValues
11
+ from ._feedlogger import getDagPanelLogger, getBlockPanelLogger
12
+ from ._panel_util import _get_state_color, dag_doc
13
+
14
+ NTHREADS = 2
15
+
16
+ # From https://tabler.io/icons/icon/info-circle
17
+ #
18
+ INFO_SVG = '''<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
19
+ <path stroke="none" d="M0 0h24v24H0z" fill="none" />
20
+ <path d="M3 12a9 9 0 1 0 18 0a9 9 0 0 0 -18 0" />
21
+ <path d="M12 9h.01" />
22
+ <path d="M11 12h1v4h1" />
23
+ </svg>
24
+ '''
25
+
26
+ if '_pyodide' in sys.modules:
27
+ # Pyodide (to be specific, WASM) doesn't allow threads.
28
+ # Specifying one thread for panel for some reason tries to start one, so we need to rely on the default.
29
+ #
30
+ pn.extension(
31
+ 'floatpanel',
32
+ inline=True,
33
+ loading_spinner='bar',
34
+ notifications=True,
35
+ )
36
+ else:
37
+ pn.extension(
38
+ 'floatpanel',
39
+ inline=True,
40
+ nthreads=NTHREADS,
41
+ loading_spinner='bar',
42
+ notifications=True,
43
+ )
44
+
45
+
46
+
47
+ def _hms(sec):
48
+ h, sec = divmod(int(sec), 3600)
49
+ m, sec = divmod(sec, 60)
50
+
51
+ return f'{h:02}:{m:02}:{sec:02}'
52
+
53
+ class _PanelContext:
54
+ """A context manager to wrap the execution of a block within a dag.
55
+
56
+ This default context manager handles the block state, the stopper,
57
+ and converts block execution errors to GimzoError exceptions.
58
+
59
+ It also uses the panel UI to provide extra information to the user.
60
+ """
61
+
62
+ def __init__(self, *, block: Block, dag: Dag, dag_logger=None):
63
+ self.block = block
64
+ self.dag = dag
65
+ self.dag_logger = dag_logger
66
+
67
+ def __enter__(self):
68
+ state = BlockState.EXECUTING
69
+ self.block._block_state = state
70
+ self.t0 = datetime.now()
71
+ if self.dag_logger:
72
+ self.dag_logger.info('Execute', block_name=self.block.name, block_state=state)
73
+
74
+ block_logger = getBlockPanelLogger(self.block.name)
75
+ self.block.logger = block_logger
76
+
77
+ # if self.block._progress:
78
+ # self.block._progress.active = True
79
+
80
+ return self.block
81
+
82
+ def __exit__(self, exc_type, exc_val, exc_tb):
83
+ delta = (datetime.now() - self.t0).total_seconds()
84
+
85
+ # if self.block._progress:
86
+ # self.block._progress.active = False
87
+
88
+ if exc_type is None:
89
+ state = BlockState.WAITING if self.block.block_pause_execution else BlockState.SUCCESSFUL
90
+ self.block._block_state = state
91
+ if self.dag_logger:
92
+ self.dag_logger.info(f'after {_hms(delta)}', block_name=self.block.name, block_state=state.value)
93
+ elif isinstance(exc_type, KeyboardInterrupt):
94
+ state = BlockState.INTERRUPTED
95
+ self.block_state._block_state = state
96
+ if not self.dag._is_pyodide:
97
+ self.dag._stopper.event.set()
98
+ if self.dag_logger:
99
+ self.dag_logger.exception(f'KEYBOARD INTERRUPT after {_hms(delta)}', block_name=self.block.name, block_state=state)
100
+ else:
101
+ state = BlockState.ERROR
102
+ self.block._block_state = state
103
+ if exc_type is not BlockValidateError:
104
+ if self.dag_logger:
105
+ self.dag_logger.exception(
106
+ f'after {_hms(delta)}',
107
+ block_name=self.block.name,
108
+ block_state=state
109
+ )
110
+
111
+ # msg = f'While in {self.block.name}.execute(): {exc_val}'
112
+ if not self.dag._is_pyodide:
113
+ self.dag._stopper.event.set()
114
+
115
+ if not issubclass(exc_type, BlockError):
116
+ # Convert the error in the block to a BlockError.
117
+ #
118
+ raise BlockError(f'Block {self.block.name}: {str(exc_val)}') from exc_val
119
+ return False
120
+
121
+ def _quit(session_context):
122
+ print(session_context)
123
+ sys.exit()
124
+
125
+ def interrupt_thread(tid, exctype):
126
+ """Raise exception exctype in thread tid."""
127
+
128
+ r = ctypes.pythonapi.PyThreadState_SetAsyncExc(
129
+ ctypes.c_ulong(tid),
130
+ ctypes.py_object(exctype)
131
+ )
132
+ if r==0:
133
+ raise ValueError('Invalid thread id')
134
+ elif r!=1:
135
+ # "if it returns a number greater than one, you're in trouble,
136
+ # and you should call it again with exc=NULL to revert the effect"
137
+ #
138
+ ctypes.pythonapi.PyThreadState_SetAsyncExc(ctypes.c_ulong(tid), None)
139
+ raise SystemError('PyThreadState_SetAsyncExc failed')
140
+
141
+ def _prepare_to_show(dag: Dag):
142
+ # Replace the default text-based context with the panel-based context.
143
+ #
144
+ dag._block_context = _PanelContext
145
+
146
+ info_button = pn.widgets.ButtonIcon(
147
+ icon=INFO_SVG,
148
+ active_icon=INFO_SVG,
149
+ description='Dag Help',
150
+ align='center'
151
+ )
152
+
153
+ # A place to stash the info FloatPanel.
154
+ #
155
+ info_fp_holder = pn.Column(visible=False)
156
+
157
+ sidebar_title = pn.Row(info_button, '## Blocks')
158
+ template = pn.template.BootstrapTemplate(
159
+ site=dag.site,
160
+ title=dag.title,
161
+ theme='dark',
162
+ sidebar=pn.Column(sidebar_title),
163
+ collapsed_sidebar=True,
164
+ sidebar_width=440
165
+ )
166
+
167
+ def display_info(_event):
168
+ """Display a FloatPanel containing help for the dag and blocks."""
169
+
170
+ text = dag_doc(dag)
171
+ config = {
172
+ 'headerControls': {'maximize': 'remove'},
173
+ 'contentOverflow': 'scroll'
174
+ }
175
+ fp = pn.layout.FloatPanel(text, name=dag.title, width=550, height=450, contained=False, position='center', theme='dark filleddark', config=config)
176
+ info_fp_holder[:] = [fp]
177
+
178
+ info_button.on_click(display_info)
179
+
180
+ switch = pn.widgets.Switch(name='Stop')
181
+
182
+ def on_switch(event):
183
+ if switch.value:
184
+ dag.stop()
185
+ reset()
186
+
187
+ # Which thread are we running on?
188
+ #
189
+ current_tid = threading.current_thread().ident
190
+
191
+ # What other threads are running?
192
+ # There are multiple threads running, including the main thread
193
+ # and the bokeh server thread. We need to find the panel threads.
194
+ # Unfortunately, there is nothing special about them.
195
+ #
196
+ print('THREADS', current_tid, [t for t in threading.enumerate()])
197
+ all_threads = [t for t in threading.enumerate() if t.name.startswith('ThreadPoolExecutor')]
198
+ assert len(all_threads)<=NTHREADS, f'{all_threads=}'
199
+ other_thread = [t for t in all_threads if t.ident!=current_tid]
200
+
201
+ # It's possible that since the user might not have done anything yet,
202
+ # another thread hasn't spun up.
203
+ #
204
+ if other_thread:
205
+ interrupt_thread(other_thread[0].ident, KeyboardInterrupt)
206
+ else:
207
+ dag.unstop()
208
+ # TODO reset status for each card
209
+
210
+ pn.bind(on_switch, switch, watch=True)
211
+
212
+ def reset():
213
+ """Experiment."""
214
+ col = template.main.objects[0]
215
+ for card in col:
216
+ status = card.header[0]
217
+
218
+ # We use a Panel Feed widget to display log messages.
219
+ #
220
+ log_feed = pn.Feed(
221
+ view_latest=True,
222
+ scroll_button_threshold=20,
223
+ auto_scroll_limit=1,
224
+ sizing_mode='stretch_width'
225
+ )
226
+ dag_logger = getDagPanelLogger(log_feed)
227
+
228
+ template.main.append(
229
+ pn.Column(
230
+ *(BlockCard(parent_template=template, dag=dag, w=gw, dag_logger=dag_logger) for gw in dag.get_sorted())
231
+ )
232
+ )
233
+ template.sidebar.append(
234
+ pn.Column(
235
+ switch,
236
+ pn.panel(dag.hv_graph().opts(invert_yaxis=True, xaxis=None, yaxis=None)),
237
+ log_feed,
238
+ info_fp_holder
239
+ )
240
+ )
241
+
242
+ return template
243
+
244
+ def _show_dag(dag: Dag):
245
+ """Display the dag in a panel template."""
246
+
247
+ template = _prepare_to_show(dag)
248
+
249
+ pn.state.on_session_destroyed(_quit)
250
+
251
+ # Execute the dag.
252
+ # Since this is a panel dag, we expect the first block to be an input nlock.
253
+ # This ensures that the first block's prepare() method is called.
254
+ # If the first block is not an input block, it must be primed, just like a plain dag.
255
+ #
256
+ dag.execute()
257
+
258
+ template.show(threaded=False)
259
+
260
+ def _serveable_dag(dag: Dag):
261
+ """Serve the dag in a panel template."""
262
+
263
+ template = _prepare_to_show(dag)
264
+
265
+ pn.state.on_session_destroyed(_quit)
266
+
267
+ # Execute the dag.
268
+ # Since this is a panel dag, we expect the first block to be an input nlock.
269
+ # This ensures that the first block's prepare() method is called.
270
+ # If the first block is not an input block, it must be primed, just like a plain dag.
271
+ #
272
+ dag.execute()
273
+
274
+ template.servable()
275
+
276
+ def _default_panel(self) -> Callable[[Block], pn.Param]:
277
+ """Provide a default __panel__() implementation for blocks that don't have one.
278
+
279
+ This default will display the in_ parameters.
280
+ """
281
+
282
+ in_names = [name for name in self.param.values() if name.startswith('in_')]
283
+
284
+ return pn.Param(self, parameters=in_names, show_name=False)
285
+
286
+ class BlockCard(pn.Card):
287
+ """A custom card to wrap around a block.
288
+
289
+ This adds the block title and a status light to the card header.
290
+ The light updates to match the block state.
291
+ """
292
+
293
+ @staticmethod
294
+ def _get_state_light(color: str) -> pn.Spacer:
295
+ return pn.Spacer(
296
+ margin=(8, 0, 0, 0),
297
+ styles={'width':'20px', 'height':'20px', 'background':color, 'border-radius': '10px'}
298
+ )
299
+
300
+ # def ui(self, message):
301
+ # """TODO connect this to the template"""
302
+ # print(message)
303
+
304
+ def __init__(self, *args, parent_template, dag: Dag, w: Block, dag_logger=None, **kwargs):
305
+ # Make this look like <h3> (the default Card header text).
306
+ #
307
+ name_text = pn.widgets.StaticText(
308
+ value=w.name,
309
+ css_classes=['card-title'],
310
+ styles={'font-size':'1.17em', 'font-weight':'bold'}
311
+ )
312
+ spacer = pn.HSpacer(
313
+ styles=dict(
314
+ min_width='1px', min_height='1px'
315
+ )
316
+ )
317
+
318
+ # If a block has no __panel__() method, Panel will by default
319
+ # inspect the class and display the param attributes.
320
+ # This is obviously not what we want.
321
+ #
322
+ # We just want to display the in_ params.
323
+ #
324
+ has_panel = '__panel__' in w.__class__.__dict__
325
+ if not has_panel:
326
+ # w._progress = pn.indicators.Progress(
327
+ # name='Block progress',
328
+ # bar_color='primary',
329
+ # active=False,
330
+ # value=-1
331
+ # )
332
+
333
+ # Go go gadget descriptor protocol.
334
+ #
335
+ w.__panel__ = _default_panel.__get__(w)
336
+
337
+ if w.block_pause_execution:
338
+ # This is an input block, so add a 'Continue' button.
339
+ #
340
+ def on_continue(_event):
341
+ # The user may not have changed anything from the default values,
342
+ # so there won't be anything on the block queue.
343
+ # Therefore, we trigger the output params to put their
344
+ # current values on the queue.
345
+ # If their values are already there, it doesn't matter.
346
+ #
347
+ parent_template.main[0].loading = True
348
+ w.param.trigger(*w._block_out_params)
349
+
350
+ try:
351
+ if dag_logger:
352
+ dag_logger.info('', block_name=None, block_state=None)
353
+ dag_logger.info('Execute dag', block_name='', block_state=BlockState.DAG)
354
+
355
+ # We want this block's execute() method to run first
356
+ # after the user clicks the "Continue" button.
357
+ # We make this happen by pushing this block on the head
358
+ # of the queue, but without any values - we don't want
359
+ # to trigger any param changes.
360
+ #
361
+ try:
362
+ dag.execute_after_input(w, dag_logger=dag_logger)
363
+ except BlockValidateError as e:
364
+ # Display the error as a notification.
365
+ #
366
+ block_name = html.escape(e.block_name)
367
+ error = html.escape(str(e))
368
+ notif = f'<b>{block_name}</b>:<br>{error}'
369
+ pn.state.notifications.error(notif, duration=0)
370
+ finally:
371
+ parent_template.main[0].loading = False
372
+ c_button = pn.widgets.Button(name=w.continue_label, button_type='primary')
373
+ c_button.on_click(on_continue)
374
+
375
+ w_ = pn.Column(
376
+ w,
377
+ pn.Row(c_button, align='end'),
378
+ sizing_mode='scale_width'
379
+ )
380
+ else:
381
+ w_ = w
382
+
383
+ super().__init__(w_, *args, sizing_mode='stretch_width', **kwargs)
384
+
385
+ self.header = pn.Row(
386
+ name_text,
387
+ pn.VSpacer(),
388
+ spacer,
389
+ self._get_state_light(_get_state_color(w._block_state))
390
+ )
391
+
392
+ # Watch the block state so we can update the staus light.
393
+ #
394
+ w.param.watch_values(self.state_change, '_block_state')
395
+
396
+ def state_change(self, _block_state: BlockState):
397
+ """Watcher for the block state.
398
+
399
+ Updates the state light.
400
+ """
401
+
402
+ self.header[-1] = self._get_state_light(_get_state_color(_block_state))
403
+
404
+ class PanelDag(Dag):
405
+ def __init__(self, *, site: str='Panel Dag', title: str, doc: str):
406
+ super().__init__(site=site, title=title, doc=doc)
407
+
408
+ def show(self):
409
+ _show_dag(self)
410
+
411
+ def servable(self):
412
+ _serveable_dag(self)
@@ -0,0 +1,83 @@
1
+ import sys
2
+ from .._block import BlockState
3
+ from .._util import trim
4
+
5
+ def _get_state_color(gs: BlockState) -> str:
6
+ """Convert a block state (as logged by the dag) to a color.
7
+
8
+ The colors are arbitrary, except for BLOCK. When a block logs a message,
9
+ it is executing by definition, so no color is required.
10
+ """
11
+
12
+ match gs:
13
+ case BlockState.BLOCK:
14
+ color = 'var(--panel-background-color)'
15
+ case BlockState.DAG:
16
+ color = 'grey'
17
+ case BlockState.INPUT:
18
+ color = '#f0c820'
19
+ case BlockState.READY:
20
+ color='white'
21
+ case BlockState.EXECUTING:
22
+ color='steelblue'
23
+ case BlockState.WAITING:
24
+ color='yellow'
25
+ case BlockState.SUCCESSFUL:
26
+ color = 'green'
27
+ case BlockState.INTERRUPTED:
28
+ color= 'orange'
29
+ case BlockState.ERROR:
30
+ color = 'red'
31
+ case _:
32
+ color = 'magenta'
33
+
34
+ return color
35
+
36
+ ########
37
+ # Documentation utilities
38
+ ########
39
+
40
+ def block_doc(block):
41
+ """Generate Markdown documentation for a block.
42
+
43
+ The documentation is taken from the docstring of the block class
44
+ and the doc of each 'in_' and 'out_' param.
45
+ """
46
+
47
+ # Force the first line of the block docstring to have a level 2 header.
48
+ #
49
+ b_doc = '## ' + trim(block.__doc__).lstrip(' #')
50
+
51
+ params = []
52
+ for name, p in block.param.objects().items():
53
+ if name.startswith(('in_', 'out_')):
54
+ doc = p.doc if p.doc else ''
55
+ params.append((name, doc.strip()))
56
+
57
+ params.sort()
58
+ text = ['| Name | Description |', '| ---- | ---- |']
59
+ for name, doc in params:
60
+ text.append(f'| {name} | {doc}')
61
+
62
+ return '---\n' + b_doc + '\n### Params\n' + '\n'.join(text)
63
+
64
+ def dag_doc(dag):
65
+ """Generate Markdown documentation for a dag and its blocks."""
66
+
67
+ # A Block may be in the dag more than once.
68
+ # Don't include the documentation for such blocks more than once.
69
+ #
70
+ blocks = dag.get_sorted()
71
+ uniq_blocks = []
72
+ seen_blocks = set()
73
+ for b in blocks:
74
+ if type(b) not in seen_blocks:
75
+ uniq_blocks.append(b)
76
+ seen_blocks.add(type(b))
77
+ block_docs = '\n\n'.join(block_doc(block) for block in uniq_blocks)
78
+
79
+ # Force the first line of the dag doc to have a level 1 header.
80
+ #
81
+ dag_text =f'# {dag.site} - {dag.title}\n\n# ' + trim(dag.doc).lstrip(' #')
82
+
83
+ return f'{dag_text}\n\n{block_docs}'
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2024 Algol60
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,68 @@
1
+ Metadata-Version: 2.3
2
+ Name: sier2
3
+ Version: 0.29
4
+ Summary: Blocks of code that are executed in dags
5
+ Author: Algol60
6
+ Author-email: algol60 <algol60@users.noreply.github.com>
7
+ Classifier: Programming Language :: Python :: 3.11
8
+ Classifier: Programming Language :: Python :: 3.12
9
+ Classifier: License :: OSI Approved :: MIT License
10
+ Classifier: Operating System :: OS Independent
11
+ Classifier: Intended Audience :: Science/Research
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: Topic :: Scientific/Engineering
14
+ Classifier: Topic :: Software Development :: Libraries
15
+ Project-URL: Homepage, https://github.com/algol60/sier2
16
+ Description-Content-Type: text/x-rst
17
+
18
+ Sier2
19
+ ======
20
+
21
+ Connect modular pieces of Python code ("blocks") into
22
+ a processing dag pipeline. Blocks are an improvement on libraries;
23
+ if you have a library, you still need to build an application.
24
+ Blocks are pieces of an application, you just have to connect them.
25
+
26
+ See the ``examples`` directory for examples.
27
+
28
+ Description
29
+ -----------
30
+
31
+ A ``block`` is a self-contained piece of code with input and output parameters.
32
+ Blocks can be connected to each other using a ``Dag`` to create
33
+ a dag of blocks.
34
+
35
+ More precisely, output parameters in one block can be connected to input parameters
36
+ in another block. The connections need not be one-to-one: parameters in multiple blocks
37
+ can be connected to parameters in a single block; conversely, parameters in a single block
38
+ can be connected to parameters in multiple blocks.
39
+
40
+ Block parameters use `param <https://param.holoviz.org/>`_, which not only implement
41
+ triggering and watching of events, but allow parameters to be named and documented.
42
+
43
+ A typical block implementation looks like this.
44
+
45
+ .. code-block:: python
46
+
47
+ from sier2 import Block
48
+
49
+ class Increment(Block):
50
+ """A block that adds one to the input value."""
51
+
52
+ int_in = param.Integer(label='The input', doc='An integer')
53
+ int_out = param.Integer(label='The output', doc='The incremented value')
54
+
55
+ def execute(self):
56
+ self.int_out = self.int_in + 1
57
+
58
+ See the examples in ``examples`` (Python scripts) and ``examples-panel`` (scripts that use `Panel <https://panel.holoviz.org/>`_ as a UI).
59
+
60
+ Documentation
61
+ -------------
62
+
63
+ To build the documentation from the repository root directory:
64
+
65
+ .. code-block:: powershell
66
+
67
+ docs/make html
68
+
@@ -0,0 +1,16 @@
1
+ sier2/__init__.py,sha256=_JNRxOnwTgKfuaNEF1P9iRgBNX2P8i-GuibUG0KVdS8,174
2
+ sier2/__main__.py,sha256=HZfzJLaD2_JOyKFkFYTD2vs-UARxNMjP4D7ZdJg405A,3140
3
+ sier2/_block.py,sha256=_0aBr84mikRoP_FfEcpg92JTqOdoB9kObu6Y0u8KeKc,7716
4
+ sier2/_dag.py,sha256=BPB22UEScfS1lYL8fzpGTx7IOqvnyTcFsz9QgFI4Fn4,23976
5
+ sier2/_library.py,sha256=BGEG78AH16IAhI7NEDL-3CrEwNYXFUo4K-XFfHgKn70,8011
6
+ sier2/_logger.py,sha256=lwRmYjXMkP7TyURo5gvOf266vwGDFkLGau-EXpo5eEw,2379
7
+ sier2/_util.py,sha256=NmXI7QMSdkoSMe6EYJ-q8zI9iGJeMUto3g4314UVoM8,1932
8
+ sier2/_version.py,sha256=K5EdVMOTOHqhr-mIMjXhh84WHTSES2K-MJ_b--KryBM,71
9
+ sier2/panel/__init__.py,sha256=wDEf_v859flQX4udAVYZW1m79sfB1NIrI3pyNIpNiEM,29
10
+ sier2/panel/_feedlogger.py,sha256=tsrA8R2FZUecVY2egifVu2qosRfjccgvGRE0lLZSXZY,5270
11
+ sier2/panel/_panel.py,sha256=_0BZRebzFFTnyX-Qr4pgbAbf09QzKXSsZG6JFU1DOWI,13913
12
+ sier2/panel/_panel_util.py,sha256=omcLO0OIHhH00l9YXv09Qv8lnaY6VKsQ1F0qbsrs3vk,2450
13
+ sier2-0.29.dist-info/LICENSE,sha256=2AKq0yxLLDdGsj6xQuNjDPG5d2IbFWFGiB_cnCBtMp4,1064
14
+ sier2-0.29.dist-info/METADATA,sha256=ARQRzlQc-1_QSNseo6sgQ1da5uufFWPeQtm0AWolrqo,2332
15
+ sier2-0.29.dist-info/WHEEL,sha256=aiTauIPAnqOMBuVimVqk4bevIILHLWXNdkzTocSR-tg,92
16
+ sier2-0.29.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: poetry-core 2.1.1
3
+ Root-Is-Purelib: true
4
+ Tag: py2.py3-none-any