sier2 0.29__tar.gz → 0.37__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.

Potentially problematic release.


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

@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: sier2
3
- Version: 0.29
3
+ Version: 0.37
4
4
  Summary: Blocks of code that are executed in dags
5
5
  Author: Algol60
6
6
  Author-email: algol60 <algol60@users.noreply.github.com>
@@ -12,7 +12,7 @@ Classifier: Intended Audience :: Science/Research
12
12
  Classifier: Intended Audience :: Developers
13
13
  Classifier: Topic :: Scientific/Engineering
14
14
  Classifier: Topic :: Software Development :: Libraries
15
- Project-URL: Homepage, https://github.com/algol60/sier2
15
+ Project-URL: Homepage, https://github.com/sier2/sier2
16
16
  Description-Content-Type: text/x-rst
17
17
 
18
18
  Sier2
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "sier2"
3
- version = "0.29"
3
+ version = "0.37"
4
4
  description = "Blocks of code that are executed in dags"
5
5
  authors = [
6
6
  {name="Algol60", email="algol60 <algol60@users.noreply.github.com>"}
@@ -21,20 +21,17 @@ classifiers = [
21
21
  [dependencies]
22
22
  python = "^3.11"
23
23
 
24
- holoviews = ">=1.19.0"
25
24
  panel = ">=1.4.4"
26
25
  param = ">=2.1.0"
27
26
 
28
27
  [[tool.mypy.overrides]]
29
28
  module = [
30
- "holoviews",
31
- "param",
32
- "networkx",
29
+ "param"
33
30
  ]
34
31
  ignore_missing_imports = true
35
32
 
36
33
  [project.urls]
37
- Homepage = "https://github.com/algol60/sier2"
34
+ Homepage = "https://github.com/sier2/sier2"
38
35
 
39
36
  [build-system]
40
37
  requires = ["poetry-core>=2.1.1"]
@@ -1,4 +1,6 @@
1
1
  from ._block import Block, BlockError, BlockValidateError
2
+ from ._config import Config
2
3
  from ._dag import Connection, Dag, BlockState
3
4
  from ._library import Library, Info
4
5
  from ._version import __version__
6
+ from ._util import get_block_config
@@ -1,7 +1,7 @@
1
1
  import argparse
2
2
  from importlib.metadata import version
3
3
 
4
- from sier2 import Library
4
+ from sier2 import Library, Config
5
5
  from ._library import _find_blocks, _find_dags, run_dag
6
6
  from ._util import block_doc_text, dag_doc_text
7
7
 
@@ -66,6 +66,19 @@ def dags_cmd(args):
66
66
  seen.add(gi.key)
67
67
 
68
68
  def run_cmd(args):
69
+ if args.update_config:
70
+ args = args.update_config.split(',')
71
+ block_name = args[0]
72
+ update_arg = ','.join(args[1:])
73
+ write_to_file = True
74
+ else:
75
+ block_name = None
76
+ update_arg = None
77
+ write_to_file = False
78
+
79
+ if args.config or args.update_config:
80
+ Config.update(location=args.config, config_block=block_name, update_arg=update_arg, write_to_file=write_to_file)
81
+
69
82
  run_dag(args.dag)
70
83
 
71
84
  def main():
@@ -74,6 +87,8 @@ def main():
74
87
 
75
88
  run = subparsers.add_parser('run', help='Run a dag')
76
89
  run.add_argument('dag', type=str, help='A dag to run')
90
+ run.add_argument('-C', '--config', default=None, help='Use this config file (default to personal config file)')
91
+ run.add_argument('-U', '--update_config', default=None, help='A block that provides a config that updates the personal config file')
77
92
  run.set_defaults(func=run_cmd)
78
93
 
79
94
  blocks = subparsers.add_parser('blocks', help='Show available blocks')
@@ -4,6 +4,7 @@ import param
4
4
  from typing import Any
5
5
 
6
6
  from . import _logger
7
+ from ._config import Config
7
8
 
8
9
  class BlockError(Exception):
9
10
  """Raised if a Block configuration is invalid.
@@ -40,6 +41,20 @@ same input block be used immediately.) This causes the block's
40
41
  Dag execution then continues as normal.
41
42
  '''
42
43
 
44
+ _VISIBLE_DOC = '''If True, the block will be visible in a GUI.
45
+
46
+ A block may not need to be visible in a dag with a GUI. For example,
47
+ it may be applying a pre-defined filter, or running an algorithm that
48
+ takes an indeterminate amount of time. Setting this parameter to False
49
+ tells the GUI not display this block. Dag execution will otherwise
50
+ proceed as normal.
51
+
52
+ This is also useful if a GUI application only requires a single block.
53
+ A dag requires at least two blocks, because blocks can only be added
54
+ by connecting them to another block. By making one block a "dummy"
55
+ that is not visible, the GUI effectivly has a single block.
56
+ '''
57
+
43
58
  class Block(param.Parameterized):
44
59
  """The base class for blocks.
45
60
 
@@ -50,31 +65,62 @@ class Block(param.Parameterized):
50
65
  class MyBlock(Block):
51
66
  ...
52
67
 
68
+ The ``Block`` class inherits from ``param.Parameterized``, and uses parameters
69
+ as described at https://param.holoviz.org/user_guide/Parameters.html.
70
+ There are three kinds of parameters:
71
+ * Input parameters start with ``in_``. These parameters are set before a block is executed.
72
+ * Output parameters start with ``out_``. The block sets these in its ``execute()`` method.
73
+ * Block parameters start with ``block_``. THese are reserved for use by blocks.
74
+
53
75
  A typical block will have at least one input parameter, and an ``execute()``
54
76
  method that is called when an input parameter value changes.
55
77
 
56
78
  .. code-block:: python
57
79
 
58
80
  class MyBlock(Block):
59
- value_in = param.String(label='Input Value')
81
+ in_value = param.String(label='Input Value')
82
+ out_upper = param.String(label='Output value)
60
83
 
61
84
  def execute(self):
62
- print(f'New value is {self.value_in}')
85
+ self.out_value = self.in_value.upper()
86
+ print(f'New value is {self.out_value}')
87
+
88
+ The block parameter ``block_pause_execution`` allows a block to act as an "input" block,
89
+ particularly when the block hsa a GUI interface. When set to True and dag execution
90
+ reaches this block, the block's ``prepare()`` method is called, then the dag stops executing.
91
+ This allows the user to interact with a user interface.
92
+
93
+ The dag is then restarted using ``dag.execute_after_input(input_block)`` (typically by
94
+ a "Continue" button in the GUI.) When the dag is continued at this block,
95
+ the block's ``execute()`` method is called, and dag execution continues.
63
96
  """
64
97
 
65
98
  block_pause_execution = param.Boolean(default=False, label='Pause execution', doc=_PAUSE_EXECUTION_DOC)
99
+ block_visible = param.Boolean(default=True, label='Display block', doc=_VISIBLE_DOC)
66
100
 
67
101
  _block_state = param.String(default=BlockState.READY)
68
102
 
69
103
  SIER2_KEY = '_sier2__key'
70
104
 
71
- def __init__(self, *args, block_pause_execution=False, continue_label='Continue', **kwargs):
105
+ def __init__(self, *args, block_pause_execution: bool=False, block_visible: bool=True, block_doc: str|None=None, continue_label='Continue', **kwargs):
106
+ """
107
+ Parameters
108
+ ----------
109
+ block_pause_execution: bool
110
+ If True, ``prepare()`` is called and dag execution stops.
111
+ block_visible: bool
112
+ If True (the default), the block will be visible in a GUI.
113
+ block_doc: str|None
114
+ Markdown documentation that may displayed in the user interface.
115
+ """
72
116
  super().__init__(*args, **kwargs)
73
117
 
74
118
  if not self.__doc__:
75
119
  raise BlockError(f'Class {self.__class__} must have a docstring')
76
120
 
77
121
  self.block_pause_execution = block_pause_execution
122
+ self.block_visible = block_visible
123
+ self.block_doc = block_doc
78
124
  self.continue_label = continue_label
79
125
  # self._block_state = BlockState.READY
80
126
  self.logger = _logger.get_logger(self.name)
@@ -111,6 +157,66 @@ class Block(param.Parameterized):
111
157
 
112
158
  return f'{im.__name__}.{cls.__qualname__}'
113
159
 
160
+ def get_config(self, *, block: 'Block'=None):
161
+ """Return a dictionary containing keys and values from the section specified by
162
+ the block in the sier2 config file.
163
+
164
+ The config file has the format described by the Python ``configparser`` module,
165
+ with the added feature that values are evaluated using :func:`ast.literal_eval`,
166
+ and therefore must be syntactically correct Python literals.
167
+
168
+ They keys and values are read from the section ``[block.name]``, where ``name`` is
169
+ this block's unique key as specified by :func:`sier2.Block.block_key`.
170
+ If the ``block`` parameter is unspecified, the calling block is used by default.
171
+
172
+ If the section is not present in the config file, an empty dictionary is returned.
173
+
174
+ The default config file is looked for at
175
+ (the default user config directory) / 'sier2sier2.ini'.
176
+ On Windows, the config directory is ``$ENV:APPDATA``; on Linux, ``$XDG_CONFIG_HOME``
177
+ or ``$HOME/.config``.
178
+
179
+ An alternative config file can be specified by setting ``Config.location`` before
180
+ any dag or block is executed. THis can be done from a command line using
181
+ the ``--config`` option.
182
+
183
+ Parameters
184
+ ----------
185
+ block: Block
186
+ The specified block's config section will be returned. Defaults to ``self``.
187
+
188
+ Returns
189
+ -------
190
+ A dictionary containing the section's keys and values.
191
+ """
192
+
193
+ b = block if block is not None else self
194
+ name = f'block.{b.block_key()}'
195
+
196
+ return Config[name]
197
+
198
+ def get_config_value(self, key: str, default: Any=None, *, block: 'Block'=None):
199
+ """Return an individual value from the section specified by
200
+ the block in the sier2 config file.
201
+
202
+ See :func:`sier2.Block.get_config` for more details.
203
+
204
+ Parameters
205
+ ----------
206
+ key: str
207
+ The key of the value to be returned.
208
+ default: Any
209
+ The default value to return if the section or key are not present in the config file.
210
+ block: Block
211
+ The specified block's config section will be returned. Defaults to ``self``.
212
+ """
213
+
214
+ b = block if block is not None else self
215
+ name = f'block.{b.block_key()}'
216
+ value = Config[name, key]
217
+
218
+ return value if value is not None else default
219
+
114
220
  def prepare(self):
115
221
  """If blockpause_execution is True, called by a dag before calling ``execute()```.
116
222
 
@@ -140,25 +246,6 @@ class Block(param.Parameterized):
140
246
  # print(f'** EXECUTE {self.__class__=}')
141
247
  pass
142
248
 
143
- # def __panel__(self):
144
- # """A default Panel component.
145
-
146
- # When run in a Panel context, a block will typically implement
147
- # its own __panel__() method. If it doesn't, this method will be
148
- # used as a default. When a block without a __panel__() is wrapped
149
- # in a Card, self.progress will be assigned a pn.indicators.Progress()
150
- # widget which is returned here. The Panel context will make it active
151
- # before executing the block, and non-active after executing the block.
152
- # (Why not have a default Progress()? Because we don't want any
153
- # Panel-related code in the core implementation.)
154
-
155
- # If the block implements __panel__(), this will obviously be overridden.
156
-
157
- # When run in non-Panel context, this will remain unused.
158
- # """
159
-
160
- # return self._progress
161
-
162
249
  def __call__(self, **kwargs) -> dict[str, Any]:
163
250
  """Allow a block to be called directly."""
164
251
 
@@ -177,37 +264,6 @@ class Block(param.Parameterized):
177
264
 
178
265
  return result
179
266
 
180
- # class InputBlock(Block):
181
- # """A ``Block`` that accepts user input.
182
-
183
- # An ``InputBlock`` executes in two steps().
184
-
185
- # When the block is executed by a dag, the dag first sets the input
186
- # params, then calls ``prepare()``. Execution of the dag then stops.
187
-
188
- # The dag is then restarted using ``dag.execute_after_input(input_block)``.
189
- # (An input block must be specified because it is not required that the
190
- # same input block be used immediately.) This causes the block's
191
- # ``execute()`` method to be called without resetting the input params.
192
-
193
- # Dag execution then continues as normal.
194
- # """
195
-
196
- # def __init__(self, *args, continue_label='Continue', **kwargs):
197
- # super().__init__(*args, continue_label=continue_label, **kwargs)
198
- # self._block_state = BlockState.INPUT
199
-
200
- # def prepare(self):
201
- # """Called by a dag before calling ``execute()```.
202
-
203
- # This gives the block author an opportunity to validate the
204
- # input params and set up a user inteface.
205
-
206
- # After the dag restarts on this block, ``execute()`` will be called.
207
- # """
208
-
209
- # pass
210
-
211
267
  class BlockValidateError(BlockError):
212
268
  """Raised if ``Block.prepare()`` or ``Block.execute()`` determines that input data is invalid.
213
269
 
@@ -0,0 +1,251 @@
1
+ # A configuration module.
2
+ #
3
+
4
+ import ast
5
+ import configparser
6
+ import os
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+ # If this key exists in a section and has the value False,
11
+ # this section will not be updated.
12
+ #
13
+ CONFIG_UPDATE = 'config_update'
14
+
15
+ def _default_config_file():
16
+ """Determine the location of the config file sier2.ini.
17
+
18
+ If the environment variable ``SIER2_INI`` is set,
19
+ it specifies the path of the config file.
20
+
21
+ Otherwise, if Windows, use ``$env:APPDATA/sier2/sier2.ini``.
22
+
23
+ Otherwise, if ``XDG_CONFIG_HOME`` is set, use ``$XDG_CONFIG_HOME/sier2/sier2.ini``.
24
+
25
+ Otherwise, use ``$HOME/.config/sier2/sier2.ini``.
26
+
27
+ If not using ``SIER2_INI``, the ``sier2`` directory will be created
28
+ if it does not exist.
29
+
30
+ TODO don't create the sier2 directory until the ini file is written.
31
+ """
32
+
33
+ # If a config file has been explicitly set in an environment variable,
34
+ # use it.
35
+ #
36
+ ini = os.environ.get('SIER2_INI', None)
37
+ if ini:
38
+ return Path(ini)
39
+
40
+ if os.name=='nt':
41
+ # Windows.
42
+ #
43
+ prdir = Path(os.environ['APPDATA']) / 'sier2'
44
+ else:
45
+ # Linux.
46
+ #
47
+ prdir = os.environ.get('XDG_CONFIG_HOME', None)
48
+ if prdir:
49
+ prdir = Path(prdir)
50
+ else:
51
+ prdir = Path.home() / '.config'
52
+
53
+ prdir = prdir / 'sier2'
54
+
55
+ if not prdir.exists():
56
+ prdir.mkdir(parents=True)
57
+
58
+ return prdir / 'sier2.ini'
59
+
60
+ class _Config:
61
+ """This class is for internal use.
62
+
63
+ A single instance of this class is exposed publicly as ``Config``.
64
+ """
65
+
66
+ def __init__(self):
67
+ self._clear()
68
+
69
+ @staticmethod
70
+ def update(*, location: Path|str|None=None, config_block: str|None=None, update_arg: str|None=None, write_to_file: bool=False):
71
+ """Update the config.
72
+
73
+ If ``location`` has a value, that config file will be used instead of the default.
74
+
75
+ If config_block has a value, it must be the name of a block that has
76
+ an ``out_config`` param. The ``out_config`` param must contain a string that is
77
+ the content of a sier2 ini file. If ``update_arg`` is specified and the block has
78
+ an ``in_arg`` param, ``in_arg`` is set to ``update_arg``.
79
+
80
+ Parameters
81
+ ----------
82
+ location: Path|str|None
83
+ The location of a config file that will be loaded when a config is read.
84
+ config_block: str|None
85
+ A sier2 block that returns the contents of a config file in ``out_config``.
86
+ update_arg: str|None
87
+ A string that is passed to the ``config_block`` block is it has an ``in_arg`` param.
88
+ write_to_file: bool
89
+ If True, the config file at ``location`` is overwritten with the merged config.
90
+ """
91
+
92
+ if location:
93
+ Config.location = location
94
+
95
+ if config_block:
96
+ # Import here, otherwise there's a circular dependency Library -> Config -> Library.
97
+ # Config blocks better not have any config.
98
+ #
99
+ from sier2 import Library
100
+
101
+ block = Library.get_block(config_block)()
102
+
103
+ if not hasattr(block, 'out_config'):
104
+ raise ValueError('config block does not have out param "out_config"')
105
+
106
+ if hasattr(block, 'in_arg'):
107
+ block.in_arg = update_arg
108
+
109
+ block.execute()
110
+ Config._update(block.out_config, write_to_file=write_to_file)
111
+
112
+ def _clear(self):
113
+ self._location = _default_config_file()
114
+ self._config = {}
115
+ self._loaded = False
116
+
117
+ @property
118
+ def location(self) -> Path:
119
+ """Get or set the config file location.
120
+
121
+ By default, this is `$ENV:APPDATA/sier2/sier2.ini` on Window,
122
+ and `$XDG_CONFIG_HOME/sier2/sier2.ini` on Linux.
123
+
124
+ The location cannot be set if the config file has already been loaded.
125
+ """
126
+
127
+ return self._location
128
+
129
+ @location.setter
130
+ def location(self, config_file: Path|str):
131
+ if self._loaded:
132
+ raise ValueError('Config is already loaded')
133
+
134
+ if not isinstance(config_file, Path):
135
+ config_file = Path(config_file)
136
+
137
+ self._location = config_file
138
+
139
+ def _update(self, ini: str, write_to_file: bool=False):
140
+ """Update the config file using ini, which contains the contents of another config file.
141
+
142
+ The current config is also updated.
143
+
144
+ The config file cannot be updated if it has already been loaded.
145
+ """
146
+
147
+ if self._loaded:
148
+ raise ValueError('Config is already loaded')
149
+
150
+ new_config = configparser.ConfigParser()
151
+ new_config.read_string(ini)
152
+
153
+ config = configparser.ConfigParser()
154
+ if self._location.is_file():
155
+ config.read(self._location)
156
+
157
+ for section_name in new_config.sections():
158
+ if not config.has_section(section_name):
159
+ config.add_section(section_name)
160
+ config[section_name].update(new_config[section_name])
161
+ else:
162
+ update_section = True
163
+ if CONFIG_UPDATE in config[section_name]:
164
+ update_section = ast.literal_eval(config[section_name][CONFIG_UPDATE])
165
+ if not isinstance(update_section, bool):
166
+ raise ValueError(f'Value of [{section_name}].{CONFIG_UPDATE} is not a bool')
167
+
168
+ if update_section:
169
+ config[section_name].update(new_config[section_name])
170
+ # for k, v in new_config[section_name].items():
171
+ # config[section_name][k] = new_config[section_name][k]
172
+
173
+ if write_to_file:
174
+ with open(self._location, 'w', encoding='utf-8') as f:
175
+ config.write(f)
176
+
177
+ self._config = config
178
+ self._loaded = True
179
+
180
+ def _load(self):
181
+ """Load the config.
182
+
183
+ Overwrites any previous config. If the location does not exist, the config will be empty.
184
+ """
185
+
186
+ self._config = configparser.ConfigParser()
187
+ if self._location.is_file():
188
+ self._config.read(self._location)
189
+
190
+ # The config has been loaded, even if the file didn't exist.
191
+ #
192
+ self._loaded = True
193
+
194
+ def _load_string(self, sconfig):
195
+ """For testing."""
196
+
197
+ self._config = configparser.ConfigParser()
198
+ self._config.read_string(sconfig)
199
+ self._loaded = True
200
+
201
+ def __getitem__(self, section_name: str|tuple[str, str]) -> Any|dict[str, Any]:
202
+ """If section_name is a string, get the config values for the given section name.
203
+
204
+ The config file is lazily loaded. Non-existence of the file is normal.
205
+ The keys and values for the specified section are loaded into a new dictionary,
206
+ which is returned.
207
+
208
+ Since configparser always returns values as strings, the values are evaluated
209
+ using :func:`ast.literal_eval` to be correctly typed. This means that strings in the
210
+ .ini file must be surrounded by quotes.
211
+
212
+ A section name for a block is of the form 'block.block_key_name'.
213
+
214
+ The name need not to exist; if it doesn't, an empty dictionary is returned.
215
+
216
+ If section_name is a tuple, it is interpreted as (section_name, key). If the
217
+ section_name and key exist, the value of the key is returned, else None is returned.
218
+ Only that single value is evaluated.
219
+ """
220
+
221
+ if not self._loaded:
222
+ self._load()
223
+
224
+ if isinstance(section_name, tuple):
225
+ section_name, key = section_name
226
+ if section_name not in self._config:
227
+ return None
228
+
229
+ section = self._config[section_name]
230
+ if key not in section:
231
+ return None
232
+
233
+ try:
234
+ v = section[key]
235
+ return ast.literal_eval(v)
236
+ except ValueError:
237
+ raise ValueError(f'Cannot eval section [{section_name}], key {key}, value {v}')
238
+
239
+ if section_name not in self._config:
240
+ return {}
241
+
242
+ c = {}
243
+ for key, v in self._config[section_name].items():
244
+ try:
245
+ c[key] = ast.literal_eval(v)
246
+ except ValueError:
247
+ raise ValueError(f'Cannot eval section [{section_name}], key {key}, value {v}')
248
+
249
+ return c
250
+
251
+ Config = _Config()
@@ -1,7 +1,6 @@
1
1
  from ._block import Block, BlockError, BlockValidateError, BlockState
2
2
  from dataclasses import dataclass, field #, KW_ONLY, field
3
- from collections import defaultdict, deque
4
- import holoviews as hv
3
+ from collections import deque
5
4
  from importlib.metadata import entry_points
6
5
  import threading
7
6
  import sys
@@ -144,12 +143,29 @@ _RESTART = ':restart:'
144
143
  class Dag:
145
144
  """A directed acyclic graph of blocks."""
146
145
 
147
- def __init__(self, *, site: str='Block', title: str, doc: str, author: dict[str, str]=None):
146
+ def __init__(self, *, site: str='Block', title: str, doc: str, author: dict[str, str]=None, show_doc: bool=True):
147
+ """A new dag.
148
+
149
+ Parameters
150
+ ----------
151
+ site: str
152
+ Name of the site.
153
+ title: str
154
+ A title to show in the header.
155
+ doc: str
156
+ Dag documentation.
157
+ author: str
158
+ The dag author.
159
+ show_doc: bool
160
+ Show the dag docstring if True.
161
+ """
162
+
148
163
  self._block_pairs: list[tuple[Block, Block]] = []
149
164
 
150
165
  self.site = site
151
166
  self.title = title
152
167
  self.doc = doc
168
+ self.show_doc = show_doc
153
169
 
154
170
  if author is not None:
155
171
  if 'name' in author and 'email' in author:
@@ -561,75 +577,6 @@ class Dag:
561
577
  'connections': connections
562
578
  }
563
579
 
564
- def hv_graph(self):
565
- """Build a HoloViews Graph to visualise the block connections."""
566
-
567
- src: list[Block] = []
568
- dst: list[Block] = []
569
-
570
- def build_layers():
571
- """Traverse the block pairs and organise them into layers.
572
-
573
- The first layer contains the root (no input) nodes.
574
- """
575
-
576
- ranks = {}
577
- remaining = self._block_pairs[:]
578
-
579
- # Find the root nodes and assign them a layer.
580
- #
581
- src[:], dst[:] = zip(*remaining)
582
- S = list(set([s for s in src if s not in dst]))
583
- for s in S:
584
- ranks[s.name] = 0
585
-
586
- n_layers = 1
587
- while remaining:
588
- for s, d in remaining:
589
- if s.name in ranks:
590
- # This destination could be from sources at different layers.
591
- # Make sure the deepest one is used.
592
- #
593
- ranks[d.name] = max(ranks.get(d.name, 0), ranks[s.name] + 1)
594
- n_layers = max(n_layers, ranks[d.name])
595
-
596
- remaining = [(s,d) for s,d in remaining if d.name not in ranks]
597
-
598
- return n_layers, ranks
599
-
600
- def layout(_):
601
- """Arrange the graph nodes."""
602
-
603
- max_width = 0
604
-
605
- # Arrange the graph y by layer from top to bottom.
606
- # For x, for no we start at 0 and +1 in each layer.
607
- #
608
- yx = {y:0 for y in ranks.values()}
609
- gxy = {}
610
- for g, y in ranks.items():
611
- gxy[g] = [yx[y], y]
612
- yx[y] += 1
613
- max_width = max(max_width, yx[y])
614
-
615
- # Balance out the x in each layer.
616
- #
617
- for y in range(n_layers+1):
618
- layer = {name: xy for name,xy in gxy.items() if xy[1]==y}
619
- if len(layer)<max_width:
620
- for x, (name, xy) in enumerate(layer.items(), 1):
621
- gxy[name][0] = x/max_width
622
-
623
- return gxy
624
-
625
- n_layers, ranks = build_layers()
626
-
627
- src_names = [g.name for g in src]
628
- dst_names = [g.name for g in dst]
629
- g = hv.Graph(((src_names, dst_names),))
630
-
631
- return hv.element.graphs.layout_nodes(g, layout=layout)
632
-
633
580
  def topological_sort(pairs):
634
581
  """Implement a topological sort as described at
635
582
  `Topological sorting <https://en.wikipedia.org/wiki/Topological_sorting>`_.
@@ -7,6 +7,7 @@ import warnings
7
7
 
8
8
  from sier2 import Block, Dag, Connection, BlockError
9
9
  from sier2.panel import PanelDag
10
+ from ._util import _import_item
10
11
 
11
12
  # Store a mapping from a unique key to a Block class.
12
13
  # When plugins are initially scanned, the classes are not loaded.
@@ -24,29 +25,6 @@ def docstring(func) -> str:
24
25
 
25
26
  return doc.split('\n')[0].strip()
26
27
 
27
- def _import_item(key):
28
- """Look up an object by key.
29
-
30
- The returned object may be a class (if a Block key) or a function (if a dag key).
31
-
32
- See the Entry points specification at
33
- https://packaging.python.org/en/latest/specifications/entry-points/#entry-points.
34
- """
35
-
36
- modname, qualname_separator, qualname = key.partition(':')
37
- try:
38
- obj = importlib.import_module(modname)
39
- if qualname_separator:
40
- for attr in qualname.split('.'):
41
- obj = getattr(obj, attr)
42
-
43
- return obj
44
- except ModuleNotFoundError as e:
45
- msg = str(e)
46
- if not qualname_separator:
47
- msg = f'{msg}. Is there a \':\' missing?'
48
- raise BlockError(msg)
49
-
50
28
  def _find_blocks():
51
29
  yield from _find('blocks')
52
30
 
@@ -56,11 +34,15 @@ def _find_dags():
56
34
  def run_dag(dag_name):
57
35
  """Run the named dag."""
58
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
+
59
41
  ix = dag_name.rfind('.')
60
42
  if ix==-1:
61
43
  found_dag = None
62
44
  for _, d in _find_dags():
63
- dparts = d.key.split('.')
45
+ dparts = d.key.replace(':', '.').split('.')
64
46
  if dparts[-1]==dag_name:
65
47
  if found_dag:
66
48
  raise BlockError(f'Found duplicate: {dag_name}, d')
@@ -73,10 +55,12 @@ def run_dag(dag_name):
73
55
  dag_name = found_dag.key
74
56
  ix = dag_name.rfind('.')
75
57
 
76
- m = importlib.import_module(dag_name[:ix])
77
- func = getattr(m, dag_name[ix+1:])
78
- # if not issubclass(cls, Block):
79
- # raise BlockError(f'{key} is not a block')
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)
80
64
 
81
65
  dag = func()
82
66
  if not hasattr(dag, 'show'):
@@ -159,7 +143,7 @@ class Library:
159
143
  block_class: type[Block]
160
144
  The Block's class.
161
145
  key: str
162
- The Block's unique key string. By default, the block's block_key()
146
+ The Block's unique key string. By default, the block's :func:`sier2.Block.block_key`
163
147
  class method will be used to obtain the key.
164
148
  """
165
149
 
@@ -175,6 +159,22 @@ class Library:
175
159
 
176
160
  @staticmethod
177
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
178
  if not _block_library:
179
179
  Library.collect_blocks()
180
180
 
@@ -199,7 +199,20 @@ class Library:
199
199
  return cast(type[Block], _block_library[key])
200
200
 
201
201
  @staticmethod
202
- def get_dag(key: str) -> type[Dag]:
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
+
203
216
  if not _dag_library:
204
217
  Library.collect_dags()
205
218
 
@@ -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
@@ -0,0 +1,106 @@
1
+ # Generate a dag chart.
2
+ #
3
+
4
+ from sier2 import Block, Dag
5
+
6
+ from bokeh.plotting import figure, show, ColumnDataSource, curdoc, output_notebook
7
+ from bokeh.models import HoverTool
8
+
9
+ from bokeh.resources import INLINE
10
+ output_notebook(resources=INLINE)
11
+
12
+ def bokeh_graph(dag: Dag):
13
+ """Build a Bokeh figure to visualise the block connections."""
14
+
15
+ src: list[Block] = []
16
+ dst: list[Block] = []
17
+
18
+ def build_layers():
19
+ """Traverse the block pairs and organise them into layers.
20
+
21
+ The first layer contains the root (no input) nodes.
22
+ """
23
+
24
+ ranks = {}
25
+ remaining = dag._block_pairs[:]
26
+
27
+ # Find the root nodes and assign them a layer.
28
+ #
29
+ src[:], dst[:] = zip(*remaining)
30
+ S = list(set([s for s in src if s not in dst]))
31
+ for s in S:
32
+ ranks[s.name] = 0
33
+
34
+ n_layers = 1
35
+ while remaining:
36
+ for s, d in remaining:
37
+ if s.name in ranks:
38
+ # This destination could be from sources at different layers.
39
+ # Make sure the deepest one is used.
40
+ #
41
+ ranks[d.name] = max(ranks.get(d.name, 0), ranks[s.name] + 1)
42
+ n_layers = max(n_layers, ranks[d.name])
43
+
44
+ remaining = [(s,d) for s,d in remaining if d.name not in ranks]
45
+
46
+ return n_layers, ranks
47
+
48
+ def layout():
49
+ """Arrange the graph nodes."""
50
+
51
+ max_width = 0
52
+
53
+ # Arrange the graph y by layer from top to bottom.
54
+ # For x, for now we start at 0 and +1 in each layer.
55
+ #
56
+ yx = {y:0 for y in ranks.values()}
57
+ gxy = {}
58
+ for g, y in ranks.items():
59
+ gxy[g] = [yx[y], y]
60
+ yx[y] += 1
61
+ max_width = max(max_width, yx[y])
62
+
63
+ # Balance out the x in each layer.
64
+ #
65
+ for y in range(n_layers+1):
66
+ layer = {name: xy for name,xy in gxy.items() if xy[1]==y}
67
+ if len(layer)<max_width:
68
+ for x, (name, xy) in enumerate(layer.items(), 1):
69
+ gxy[name][0] = x/max_width
70
+
71
+ return gxy
72
+
73
+ n_layers, ranks = build_layers()
74
+
75
+ ly = layout()
76
+
77
+ linexs = []
78
+ lineys = []
79
+ for s, d in dag._block_pairs:
80
+ print(s.name, d.name)
81
+ linexs.append((ly[s.name][0], ly[d.name][0]))
82
+ lineys.append((ly[s.name][1], ly[d.name][1]))
83
+
84
+ xs, ys = zip(*ly.values())
85
+
86
+ c_source = ColumnDataSource({
87
+ 'xs': xs,
88
+ 'ys': ys,
89
+ 'names': list(ly.keys())
90
+ })
91
+ l_source = ColumnDataSource({
92
+ 'linexs': linexs,
93
+ 'lineys': lineys
94
+ })
95
+
96
+ curdoc().theme = 'dark_minimal'
97
+ p = figure(tools='pan,wheel_zoom,box_zoom,reset', height=300, width=300)
98
+ p.axis.visible = False
99
+ p.xgrid.visible = False
100
+ p.ygrid.visible = False
101
+ p.y_range.flipped = True # y-axis goes down instead of up.
102
+ l = p.multi_line(xs='linexs', ys='lineys', source=l_source)
103
+ c = p.circle(x='xs', y='ys', radius=0.05, line_color='black', fill_color='steelblue', hover_fill_color='#7f7f7f', source=c_source)
104
+ p.add_tools(HoverTool(tooltips=[('Block', '@names')], renderers=[c]))
105
+
106
+ return p
@@ -8,8 +8,10 @@ from typing import Callable
8
8
 
9
9
  from sier2 import Block, BlockValidateError, BlockState, Dag, BlockError
10
10
  from .._dag import _InputValues
11
+ from .._util import trim
11
12
  from ._feedlogger import getDagPanelLogger, getBlockPanelLogger
12
13
  from ._panel_util import _get_state_color, dag_doc
14
+ from ._chart import bokeh_graph
13
15
 
14
16
  NTHREADS = 2
15
17
 
@@ -54,7 +56,7 @@ class _PanelContext:
54
56
  """A context manager to wrap the execution of a block within a dag.
55
57
 
56
58
  This default context manager handles the block state, the stopper,
57
- and converts block execution errors to GimzoError exceptions.
59
+ and converts block execution errors to BlockError exceptions.
58
60
 
59
61
  It also uses the panel UI to provide extra information to the user.
60
62
  """
@@ -225,15 +227,35 @@ def _prepare_to_show(dag: Dag):
225
227
  )
226
228
  dag_logger = getDagPanelLogger(log_feed)
227
229
 
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())
230
+ cards = []
231
+ if dag.show_doc:
232
+ # The first line of the dag doc is the card header.
233
+ #
234
+ doc = dag.doc.strip()
235
+ ix = doc.find('\n')
236
+ if ix>=0:
237
+ header = doc[:ix]
238
+ doc = doc[ix:].strip()
239
+ else:
240
+ header = ''
241
+
242
+ name_text = pn.widgets.StaticText(
243
+ value=header,
244
+ css_classes=['card-title'],
245
+ styles={'font-size':'1.17em', 'font-weight':'bold'}
231
246
  )
232
- )
247
+
248
+ card = pn.Card(pn.pane.Markdown(doc, sizing_mode='stretch_width'), header=pn.Row(name_text), sizing_mode='stretch_width')
249
+ cards.append(card)
250
+
251
+ cards.extend(BlockCard(parent_template=template, dag=dag, w=gw, dag_logger=dag_logger) for gw in dag.get_sorted() if gw.block_visible)
252
+
253
+ template.main.append(pn.Column(*cards))
233
254
  template.sidebar.append(
234
255
  pn.Column(
235
256
  switch,
236
- pn.panel(dag.hv_graph().opts(invert_yaxis=True, xaxis=None, yaxis=None)),
257
+ # pn.panel(dag.hv_graph().opts(invert_yaxis=True, xaxis=None, yaxis=None)),
258
+ pn.Row(pn.panel(bokeh_graph(dag)), max_width=400, max_height=200),
237
259
  log_feed,
238
260
  info_fp_holder
239
261
  )
@@ -315,6 +337,10 @@ class BlockCard(pn.Card):
315
337
  )
316
338
  )
317
339
 
340
+ # Does this block have documentation to be displayed in the card?
341
+ #
342
+ doc = pn.pane.Markdown(w.block_doc, sizing_mode='scale_width') if w.block_doc else None
343
+
318
344
  # If a block has no __panel__() method, Panel will by default
319
345
  # inspect the class and display the param attributes.
320
346
  # This is obviously not what we want.
@@ -369,14 +395,17 @@ class BlockCard(pn.Card):
369
395
  pn.state.notifications.error(notif, duration=0)
370
396
  finally:
371
397
  parent_template.main[0].loading = False
372
- c_button = pn.widgets.Button(name=w.continue_label, button_type='primary')
398
+ c_button = pn.widgets.Button(name=w.continue_label, button_type='primary', align='end')
373
399
  c_button.on_click(on_continue)
374
400
 
401
+ row = [doc, c_button] if doc else [c_button]
375
402
  w_ = pn.Column(
376
403
  w,
377
- pn.Row(c_button, align='end'),
378
- sizing_mode='scale_width'
404
+ pn.Row(*row),
405
+ sizing_mode='stretch_width'
379
406
  )
407
+ elif doc:
408
+ w_ = pn.Column(w, doc)
380
409
  else:
381
410
  w_ = w
382
411
 
@@ -1,65 +0,0 @@
1
- import sys
2
-
3
- ########
4
- # Documentation utilities
5
- ########
6
-
7
- def trim(docstring):
8
- """From PEP-257: Fix docstring indentation"""
9
-
10
- if not docstring:
11
- return ''
12
- # Convert tabs to spaces (following the normal Python rules)
13
- # and split into a list of lines:
14
- lines = docstring.expandtabs().splitlines()
15
- # Determine minimum indentation (first line doesn't count):
16
- indent = sys.maxsize
17
- for line in lines[1:]:
18
- stripped = line.lstrip()
19
- if stripped:
20
- indent = min(indent, len(line) - len(stripped))
21
- # Remove indentation (first line is special):
22
- trimmed = [lines[0].strip()]
23
- if indent < sys.maxsize:
24
- for line in lines[1:]:
25
- trimmed.append(line[indent:].rstrip())
26
- # Strip off trailing and leading blank lines:
27
- while trimmed and not trimmed[-1]:
28
- trimmed.pop()
29
- while trimmed and not trimmed[0]:
30
- trimmed.pop(0)
31
- # Return a single string:
32
- return '\n'.join(trimmed)
33
-
34
- def block_doc_text(block):
35
- """Generate text documentation for a block.
36
-
37
- The documentation is taken from the docstring of the block class
38
- and the doc of each 'in_' and 'out_' param.
39
- """
40
-
41
- # Force the first line of the block docstring to have a level 2 header.
42
- #
43
- b_doc = '## ' + trim(block.__doc__).lstrip(' #')
44
-
45
- params = []
46
- for name, p in block.param.objects().items():
47
- if name.startswith(('in_', 'out_')):
48
- doc = p.doc if p.doc else ''
49
- params.append((name, doc.strip()))
50
-
51
- params.sort()
52
- text = []
53
- for name, doc in params:
54
- text.append(f'- {name}: {doc}\n')
55
-
56
- return '---\n' + b_doc + '\n### Params\n' + '\n'.join(text)
57
-
58
- def dag_doc_text(dag):
59
- """Generate text documentation for a dag."""
60
-
61
- # Force the first line of the dag doc to have a level 1 header.
62
- #
63
- dag_text =f'# {dag.site} - {dag.title}\n\n# ' + trim(dag.doc).lstrip(' #')
64
-
65
- return dag_text
File without changes
File without changes
File without changes
File without changes
File without changes