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/_library.py ADDED
@@ -0,0 +1,256 @@
1
+ from collections.abc import Iterable
2
+ from dataclasses import dataclass
3
+ import importlib
4
+ from importlib.metadata import entry_points, EntryPoint
5
+ from typing import Any, cast
6
+ import warnings
7
+
8
+ from sier2 import Block, Dag, Connection, BlockError
9
+ from sier2.panel import PanelDag
10
+ from ._util import _import_item
11
+
12
+ # Store a mapping from a unique key to a Block class.
13
+ # When plugins are initially scanned, the classes are not loaded.
14
+ #
15
+ _block_library: dict[str, type[Block]|None] = {}
16
+ _dag_library: set[str] = set()
17
+
18
+ @dataclass
19
+ class Info:
20
+ key: str
21
+ doc: str
22
+
23
+ def docstring(func) -> str:
24
+ doc = func.__doc__.strip()
25
+
26
+ return doc.split('\n')[0].strip()
27
+
28
+ def _find_blocks():
29
+ yield from _find('blocks')
30
+
31
+ def _find_dags():
32
+ yield from _find('dags')
33
+
34
+ def run_dag(dag_name):
35
+ """Run the named dag."""
36
+
37
+ # TODO refactor this to allow specifying just the simple name when the re are no dups,
38
+ # but more of the qualified name where there are dups.
39
+ #
40
+
41
+ ix = dag_name.rfind('.')
42
+ if ix==-1:
43
+ found_dag = None
44
+ for _, d in _find_dags():
45
+ dparts = d.key.replace(':', '.').split('.')
46
+ if dparts[-1]==dag_name:
47
+ if found_dag:
48
+ raise BlockError(f'Found duplicate: {dag_name}, d')
49
+
50
+ found_dag = d
51
+
52
+ if found_dag is None:
53
+ raise BlockError('No such dag')
54
+
55
+ dag_name = found_dag.key
56
+ ix = dag_name.rfind('.')
57
+
58
+ # m = importlib.import_module(dag_name[:ix])
59
+ # func = getattr(m, dag_name[ix+1:])
60
+ # # if not issubclass(cls, Block):
61
+ # # raise BlockError(f'{key} is not a block')
62
+
63
+ func = _import_item(found_dag.key)
64
+
65
+ dag = func()
66
+ if not hasattr(dag, 'show'):
67
+ raise BlockError(f'Dag {dag_name} does not have a user interface')
68
+
69
+ dag.show()
70
+
71
+ def _find(func_name: str) -> Iterable[tuple[EntryPoint, Info]]:
72
+ """Use ``importlib.metadata.entry_points`` to look up entry points named ``sier2.library``.
73
+
74
+ For each entry point, call ``load()`` to get a module,
75
+ then call ``getattr(module, func_name)()`` to get a list of
76
+ ``BlockInfo`` instances.
77
+
78
+ Parameters
79
+ ----------
80
+ func_name: str
81
+ The name of the function that will be called to get a list[Info].
82
+ Either ``'blocks'`` or ``'dags'``.
83
+ """
84
+
85
+ library = entry_points(group='sier2.library')
86
+
87
+ for entry_point in library:
88
+ try:
89
+ lib = entry_point.load()
90
+ func = getattr(lib, func_name, None)
91
+ if func is not None:
92
+ if not callable(func):
93
+ warnings.warn(f'In {entry_point.module}, {func} is not a function')
94
+ else:
95
+ info_list: list[Info] = func()
96
+ if not isinstance(info_list, list) or any(not isinstance(s, Info) for s in info_list):
97
+ warnings.warn(f'In {entry_point.module}, {func} does not return a list of {Info.__name__} instances')
98
+ else:
99
+ for gi in info_list:
100
+ yield entry_point, gi
101
+ except Exception as e:
102
+ raise BlockError(f'While loading {entry_point}: {e}') from e
103
+
104
+ class Library:
105
+ @staticmethod
106
+ def collect_blocks():
107
+ """Collect block information.
108
+
109
+ Use ``_find_blocks()`` to yield ``BlockInfo`` instances.
110
+
111
+ Note that we don't load the blocks here. We don't want to import
112
+ any modules: this would cause every block module to be imported,
113
+ which would cause a lot of imports to happen. Therefore, we just
114
+ create the keys in the dictionary, and let ``get_block()`` import
115
+ block modules as required.
116
+ """
117
+
118
+ for entry_point, gi in _find_blocks():
119
+ if gi.key in _block_library:
120
+ warnings.warn(f'Block plugin {entry_point}: key {gi.key} already in library')
121
+ else:
122
+ _block_library[gi.key] = None
123
+
124
+ @staticmethod
125
+ def collect_dags():
126
+ for entry_point, gi in _find_dags():
127
+ if gi.key in _dag_library:
128
+ warnings.warn(f'Dag plugin {entry_point}: key {gi.key} already in library')
129
+ else:
130
+ _dag_library.add(gi.key)
131
+
132
+ @staticmethod
133
+ def add_block(block_class: type[Block], key: str|None=None):
134
+ """Add a local block class to the library.
135
+
136
+ The library initially loads block classes using Python's entry_points() mechanism.
137
+ This method allows local Blocks to be added to the library.
138
+
139
+ This is useful for testing, for example.
140
+
141
+ Parameters
142
+ ----------
143
+ block_class: type[Block]
144
+ The Block's class.
145
+ key: str
146
+ The Block's unique key string. By default, the block's :func:`sier2.Block.block_key`
147
+ class method will be used to obtain the key.
148
+ """
149
+
150
+ if not issubclass(block_class, Block):
151
+ print(f'{key} is not a Block')
152
+
153
+ key_ = key if key else block_class.block_key()
154
+
155
+ if key_ in _block_library:
156
+ raise BlockError(f'Block {key_} is already in the library')
157
+
158
+ _block_library[key_] = block_class
159
+
160
+ @staticmethod
161
+ def get_block(key: str) -> type[Block]:
162
+ """Return the block class specified by the key.
163
+
164
+ Note that this is a ``Block`` sub-class, not an instance of a block.
165
+ The class can be used to instantiate one or more blocks.
166
+
167
+ Parameters
168
+ ----------
169
+ key: str
170
+ The unique name of the block class to be returned.
171
+
172
+ Returns
173
+ -------
174
+ type[Block]
175
+ A block class.
176
+ """
177
+
178
+ if not _block_library:
179
+ Library.collect_blocks()
180
+
181
+ if key not in _block_library:
182
+ raise BlockError(f'Block name {key} is not in the library')
183
+
184
+ if _block_library[key] is None:
185
+ cls = _import_item(key)
186
+ if not issubclass(cls, Block):
187
+ raise BlockError(f'{key} is not a block')
188
+
189
+ # The fully qualified name of the class is probably not the same as
190
+ # the library key string. This matters when the dag is dumped and loaded.
191
+ # Therefore we tell the class what its key is so the key can be dumped,
192
+ # and when the dag is loaded, the block can be found using
193
+ # Library.get_block().
194
+ #
195
+ setattr(cls, Block.SIER2_KEY, key)
196
+
197
+ _block_library[key] = cls
198
+
199
+ return cast(type[Block], _block_library[key])
200
+
201
+ @staticmethod
202
+ def get_dag(key: str) -> Dag:
203
+ """Return the dag specified by the key.
204
+
205
+ Parameters
206
+ ----------
207
+ key: str
208
+ The unique name of the dag to be returned.
209
+
210
+ Returns
211
+ -------
212
+ Dag
213
+ A dag.
214
+ """
215
+
216
+ if not _dag_library:
217
+ Library.collect_dags()
218
+
219
+ if key not in _dag_library:
220
+ raise BlockError(f'Dag name {key} is not in the library')
221
+
222
+ if key in _dag_library:
223
+ func = _import_item(key)
224
+ dag = func()
225
+ if not isinstance(dag, Dag):
226
+ raise BlockError(f'{key} is not a dag')
227
+
228
+ return cast(type[Dag], dag)
229
+
230
+ @staticmethod
231
+ def load_dag(dump: dict[str, Any]) -> Dag:
232
+ """Load a dag from a serialised structure produced by Block.dump()."""
233
+
234
+ # Create new instances of the specified blocks.
235
+ #
236
+ instances = {}
237
+ for g in dump['blocks']:
238
+ block_key = g['block']
239
+ instance = g['instance']
240
+ if instance not in instances:
241
+ gclass = Library.get_block(block_key)
242
+ instances[instance] = gclass(**g['args'])
243
+ else:
244
+ raise BlockError(f'Instance {instance} ({block_key}) already exists')
245
+
246
+ # Connect the blocks.
247
+ #
248
+ DagType = PanelDag if dump['dag']['type']=='PanelDag' else Dag
249
+ dag = DagType(doc=dump['dag']['doc'], site=dump['dag']['site'], title=dump['dag']['title'])
250
+ for conn in dump['connections']:
251
+ conns = [Connection(**kwargs) for kwargs in conn['conn_args']]
252
+ dag.connect(instances[conn['src']], instances[conn['dst']], *conns)
253
+
254
+ return dag
255
+
256
+ # Library.collect()
sier2/_logger.py ADDED
@@ -0,0 +1,65 @@
1
+ import logging
2
+
3
+ _BLOCK_FORMATTER = logging.Formatter('%(asctime)s %(levelname)s [%(block_name)s] %(message)s', datefmt='%H:%M:%S')
4
+ # formatter = logging.Formatter('%(asctime)s %(levelname)s [%(block_name)s] - %(levelname)s - %(message)s', datefmt='%H:%M:%S')
5
+
6
+ # class BlockHandler(logging.StreamHandler):
7
+ # def format(self, record):
8
+ # fmt = info_formatter# if record.levelno==logging.INFO else formatter
9
+
10
+ # return fmt.format(record)
11
+
12
+ class BlockAdapter(logging.LoggerAdapter):
13
+ """An adapter that log messages from blocks.
14
+
15
+ Each block has its own adapter, so the log automatically includes the block name.
16
+ """
17
+
18
+ def __init__(self, logger, block_name: str, block_state):
19
+ super().__init__(logger)
20
+ self.block_name = block_name
21
+ self.block_state = block_state
22
+
23
+ def debug(self, msg, *args):
24
+ super().debug(msg, *args, extra={'block_name': self.block_name, 'block_state': self.block_state})
25
+
26
+ def info(self, msg, *args):
27
+ super().info(msg, *args, extra={'block_name': self.block_name, 'block_state': self.block_state})
28
+
29
+ def warning(self, msg, *args):
30
+ super().warning(msg, *args, extra={'block_name': self.block_name, 'block_state': self.block_state})
31
+
32
+ def error(self, msg, *args):
33
+ super().error(msg, *args, extra={'block_name': self.block_name, 'block_state': self.block_state})
34
+
35
+ def exception(self, msg, *args, exc_info=True):
36
+ super().error(msg, *args, exc_info=exc_info, extra={'block_name': self.block_name, 'block_state': self.block_state})
37
+
38
+ def critical(self, msg, *args):
39
+ super().critical(msg, *args, extra={'block_name': self.block_name, 'block_state': self.block_state})
40
+
41
+ def process(self, msg, kwargs):
42
+ # print(f'BLOCKADAPTER {msg=} {kwargs=} {self.extra=}')
43
+ if 'block_state' not in kwargs['extra']:
44
+ kwargs['extra']['block_state'] = '?'
45
+ if 'block_name' not in kwargs['extra']:
46
+ kwargs['extra']['block_name'] = 'g'
47
+
48
+ return msg, kwargs
49
+
50
+ _logger = logging.getLogger('block.stream')
51
+ _logger.setLevel(logging.INFO)
52
+
53
+ # _ph = BlockHandler()
54
+ # _ph.setLevel(logging.DEBUG)
55
+
56
+ _ph = logging.StreamHandler()
57
+ _ph.setFormatter(_BLOCK_FORMATTER)
58
+ _ph.setLevel(logging.DEBUG)
59
+
60
+ _logger.addHandler(_ph)
61
+
62
+ def get_logger(block_name: str):
63
+ adapter = BlockAdapter(_logger, block_name, None)
64
+
65
+ return adapter
sier2/_util.py ADDED
@@ -0,0 +1,127 @@
1
+ from functools import cache
2
+ import importlib
3
+ from importlib.metadata import entry_points
4
+ import sys
5
+ import warnings
6
+
7
+ from sier2 import BlockError
8
+
9
+ def _import_item(key):
10
+ """Look up an object by key.
11
+
12
+ The returned object may be a class (if a Block key) or a function (if a dag key).
13
+
14
+ See the Entry points specification at
15
+ https://packaging.python.org/en/latest/specifications/entry-points/#entry-points.
16
+ """
17
+
18
+ modname, qualname_separator, qualname = key.partition(':')
19
+ try:
20
+ obj = importlib.import_module(modname)
21
+ if qualname_separator:
22
+ for attr in qualname.split('.'):
23
+ obj = getattr(obj, attr)
24
+
25
+ return obj
26
+ except ModuleNotFoundError as e:
27
+ msg = str(e)
28
+ if not qualname_separator:
29
+ msg = f'{msg}. Is there a \':\' missing?'
30
+ raise BlockError(msg)
31
+
32
+ @cache
33
+ def get_block_config():
34
+ """A convenience function to get block configuration data.
35
+
36
+ Block can run in different environments; for example, a block that has access to the
37
+ Internet may use a different configuration to the same block running in a corporate
38
+ environment.
39
+
40
+ This function looks up a block configuration provider using the ``sier2.config`` entry point,
41
+ which has the form `module-name:function-name`.
42
+
43
+ If no config package is found, or more than one config package is found,
44
+ a warning will be produced (using `warnings.warn()`), and a default config will be returned,
45
+ with the ``'config'`` key having the value ``None``.
46
+
47
+ See the `sier2-blocks-config` package for an example.
48
+ """
49
+
50
+ eps = list(entry_points(group='sier2.config'))
51
+ if len(eps)==1:
52
+ ep = eps[0].value
53
+ config_func = _import_item(ep)
54
+ config = config_func()
55
+ else:
56
+ msg = 'No block configuration found' if not eps else 'Multiple configs found'
57
+ warnings.warn(f'{msg}: returning config None')
58
+ config = {'config': None}
59
+
60
+ if 'config' not in config:
61
+ raise BlockError('config dictionary does not contain "config" key')
62
+
63
+ return config
64
+
65
+ ########
66
+ # Documentation utilities
67
+ ########
68
+
69
+ def trim(docstring):
70
+ """From PEP-257: Fix docstring indentation"""
71
+
72
+ if not docstring:
73
+ return ''
74
+ # Convert tabs to spaces (following the normal Python rules)
75
+ # and split into a list of lines:
76
+ lines = docstring.expandtabs().splitlines()
77
+ # Determine minimum indentation (first line doesn't count):
78
+ indent = sys.maxsize
79
+ for line in lines[1:]:
80
+ stripped = line.lstrip()
81
+ if stripped:
82
+ indent = min(indent, len(line) - len(stripped))
83
+ # Remove indentation (first line is special):
84
+ trimmed = [lines[0].strip()]
85
+ if indent < sys.maxsize:
86
+ for line in lines[1:]:
87
+ trimmed.append(line[indent:].rstrip())
88
+ # Strip off trailing and leading blank lines:
89
+ while trimmed and not trimmed[-1]:
90
+ trimmed.pop()
91
+ while trimmed and not trimmed[0]:
92
+ trimmed.pop(0)
93
+ # Return a single string:
94
+ return '\n'.join(trimmed)
95
+
96
+ def block_doc_text(block):
97
+ """Generate text documentation for a block.
98
+
99
+ The documentation is taken from the docstring of the block class
100
+ and the doc of each 'in_' and 'out_' param.
101
+ """
102
+
103
+ # Force the first line of the block docstring to have a level 2 header.
104
+ #
105
+ b_doc = '## ' + trim(block.__doc__).lstrip(' #')
106
+
107
+ params = []
108
+ for name, p in block.param.objects().items():
109
+ if name.startswith(('in_', 'out_')):
110
+ doc = p.doc if p.doc else ''
111
+ params.append((name, doc.strip()))
112
+
113
+ params.sort()
114
+ text = []
115
+ for name, doc in params:
116
+ text.append(f'- {name}: {doc}\n')
117
+
118
+ return '---\n' + b_doc + '\n### Params\n' + '\n'.join(text)
119
+
120
+ def dag_doc_text(dag):
121
+ """Generate text documentation for a dag."""
122
+
123
+ # Force the first line of the dag doc to have a level 1 header.
124
+ #
125
+ dag_text =f'# {dag.site} - {dag.title}\n\n# ' + trim(dag.doc).lstrip(' #')
126
+
127
+ return dag_text
sier2/_version.py ADDED
@@ -0,0 +1,3 @@
1
+ from importlib.metadata import version
2
+
3
+ __version__ = version('sier2')
@@ -0,0 +1 @@
1
+ from ._panel import PanelDag