dyngle 1.3.0__py3-none-any.whl → 1.5.1__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.
dyngle/__init__.py CHANGED
@@ -7,6 +7,7 @@ from wizlib.ui_handler import UIHandler
7
7
 
8
8
  from dyngle.command import DyngleCommand
9
9
  from dyngle.error import DyngleError
10
+ from dyngle.model.dyngleverse import Dyngleverse
10
11
  from dyngle.model.expression import expression
11
12
  from dyngle.model.operation import Operation
12
13
  from dyngle.model.template import Template
@@ -18,62 +19,39 @@ class DyngleApp(WizApp):
18
19
  name = 'dyngle'
19
20
  handlers = [StreamHandler, ConfigHandler, UIHandler]
20
21
 
21
- # For possible upstreaming to WizLib, a mechanism to "import" configuration
22
- # settings from external files.
23
-
24
22
  @property
25
- def _imported_configrations(self):
26
- if not hasattr(self, '__imported_configurations'):
27
- imports = self.config.get('dyngle-imports')
28
- confs = []
29
- if imports:
30
- for filename in imports:
31
- full_filename = Path(filename).expanduser()
23
+ def dyngleverse(self):
24
+ """Offload the indexing of operation and expression definitions to
25
+ another class. But we keep import handling here in the app because we
26
+ might want to upstream import/include to WizLib at some point."""
27
+
28
+ if not hasattr(self, '_dyngleverse'):
29
+ self._dyngleverse = Dyngleverse()
30
+ imports = self._get_imports(self.config, [])
31
+ for imported_config in imports:
32
+ definitions = imported_config.get('dyngle')
33
+ self._dyngleverse.load_config(definitions)
34
+ self._dyngleverse.load_config(self.config.get('dyngle'))
35
+ return self._dyngleverse
36
+
37
+ def _get_imports(self,
38
+ config_handler: ConfigHandler,
39
+ no_loops: list) -> dict:
40
+ imports = config_handler.get('dyngle-imports')
41
+ confs = []
42
+ if imports:
43
+ for filename in imports:
44
+ import_path = Path(filename).expanduser()
45
+ # If the path is relative, resolve it relative to the importing
46
+ # config file
47
+ if not import_path.is_absolute() and config_handler.file:
48
+ config_dir = Path(config_handler.file).parent
49
+ full_filename = (config_dir / import_path).resolve()
50
+ else:
51
+ full_filename = import_path
52
+ if full_filename not in no_loops:
53
+ no_loops.append(full_filename)
54
+ child_handler = ConfigHandler(full_filename)
55
+ confs += self._get_imports(child_handler, no_loops)
32
56
  confs.append(ConfigHandler(full_filename))
33
- self.__imported_configurations = confs
34
- return self.__imported_configurations
35
-
36
- def _get_configuration_details(self, type: str):
37
- label = f'dyngle-{type}'
38
- details = {}
39
- for conf in self._imported_configrations:
40
- if (imported_details := conf.get(label)):
41
- details |= imported_details
42
- configured_details = self.config.get(label)
43
- if configured_details:
44
- details |= configured_details
45
- return details
46
-
47
- @cached_property
48
- def operations(self):
49
- operations_configs = self._get_configuration_details('operations')
50
- if not operations_configs:
51
- raise DyngleError("No operations defined in configuration")
52
- operations = {}
53
- for key, config in operations_configs.items():
54
- if isinstance(config, list):
55
- operation = Operation({}, config)
56
- elif isinstance(config, dict):
57
- expr_texts = config.get('expressions') or {}
58
- expressions = _expressions_from_texts(expr_texts)
59
- values = config.get('values') or {}
60
- steps = config.get('steps') or []
61
- operation = Operation(expressions | values, steps)
62
- else:
63
- raise DyngleError(f"Invalid operation configuration for {key}")
64
- operations[key] = operation
65
- return operations
66
-
67
- @cached_property
68
- def globals(self):
69
- expr_texts = self._get_configuration_details('expressions')
70
- expressions = _expressions_from_texts(expr_texts)
71
- values = self._get_configuration_details('values')
72
- return expressions | (values if values else {})
73
-
74
-
75
- def _expressions_from_texts(expr_texts):
76
- if expr_texts:
77
- return {k: expression(t) for k, t in expr_texts.items()}
78
- else:
79
- return {}
57
+ return confs
@@ -3,4 +3,4 @@ from wizlib.command import WizCommand
3
3
 
4
4
  class DyngleCommand(WizCommand):
5
5
 
6
- default = 'run'
6
+ default = 'null'
@@ -0,0 +1,6 @@
1
+ from dyngle.command import DyngleCommand
2
+
3
+
4
+ class NullCommand(DyngleCommand):
5
+
6
+ name = 'null'
@@ -1,3 +1,4 @@
1
+ from functools import cached_property
1
2
  import shlex
2
3
  import subprocess
3
4
  from wizlib.parser import WizParser
@@ -17,31 +18,25 @@ class RunCommand(DyngleCommand):
17
18
  @classmethod
18
19
  def add_args(cls, parser: WizParser):
19
20
  super().add_args(parser)
20
- parser.add_argument('operation', help='Operation name to run')
21
+ parser.add_argument(
22
+ 'operation', help='Operation name to run', nargs='?')
21
23
  parser.add_argument(
22
24
  'args', nargs='*', help='Optional operation arguments')
23
25
 
24
26
  def handle_vals(self):
25
27
  super().handle_vals()
26
-
27
- def _validate_operation_exists(self, operations):
28
- """Validate that the requested operation exists in configuration"""
29
- if self.operation not in operations:
30
- available_operations = ', '.join(operations.keys())
31
- raise DyngleError(
32
- f"Operation '{self.operation}' not found. " +
33
- f"Available operations: {available_operations}")
28
+ keys = self.app.dyngleverse.operations.keys()
29
+ if not self.provided('operation'):
30
+ self.operation = self.app.ui.get_text('Operation: ', sorted(keys))
31
+ if not self.operation:
32
+ raise DyngleError(f"Operation required.")
33
+ if self.operation not in keys:
34
+ raise DyngleError(f"Invalid operation {self.operation}.")
34
35
 
35
36
  @DyngleCommand.wrap
36
37
  def execute(self):
37
38
  data_string = self.app.stream.text
38
39
  data = safe_load(data_string) or {}
39
- data['args'] = self.args
40
-
41
- operations = self.app.operations
42
- self._validate_operation_exists(operations)
43
- operation = operations[self.operation]
44
-
45
- operation.run(data, self.app.globals)
46
-
40
+ operation = self.app.dyngleverse.operations[self.operation]
41
+ operation.run(data, self.args)
47
42
  return f'Operation "{self.operation}" completed successfully'
@@ -0,0 +1,37 @@
1
+ from functools import cached_property
2
+
3
+ from dyngle.model.expression import expression
4
+ from dyngle.model.live_data import LiveData
5
+ from dyngle.model.operation import Operation
6
+
7
+
8
+ class Dyngleverse:
9
+ """Represents the entire immutable set of definitions for operations,
10
+ expresssions, and values. Operates as a sort of index/database."""
11
+
12
+ def __init__(self):
13
+ self.operations = {}
14
+ self.all_globals = {}
15
+
16
+ def load_config(self, config: dict):
17
+ """
18
+ Load additional configuration, which will always take higher precedence
19
+ than previously loaded configuration.
20
+ """
21
+ ops_defs = config.get('operations') or {}
22
+ for key, op_def in ops_defs.items():
23
+ operation = Operation(self, op_def, key)
24
+ self.operations[key] = operation
25
+ self.all_globals |= Dyngleverse.parse_constants(config)
26
+
27
+ @staticmethod
28
+ def parse_constants(definition: dict):
29
+ """
30
+ At either the global (dyngleverse) or local (within an operation)
31
+ level, we might find values and expressions.
32
+ """
33
+
34
+ expr_texts = definition.get('expressions') or {}
35
+ expressions = {k: expression(t) for k, t in expr_texts.items()}
36
+ values = definition.get('values') or {}
37
+ return expressions | values
@@ -130,8 +130,11 @@ def expression(text: str) -> Callable[[dict], str]:
130
130
  def definition(live_data: LiveData | dict | None = None) -> str:
131
131
  """The expression function itself"""
132
132
 
133
- # Allow for blankness and testability
134
- live_data = LiveData(live_data)
133
+ # We only work if passed some data to use - also we don't know our name
134
+ # so can't report it.
135
+
136
+ if live_data is None:
137
+ raise DyngleError('Expression called with no argument')
135
138
 
136
139
  # Translate names to underscore-separated instead of hyphen-separated
137
140
  # so they work within the Python namespace.
@@ -140,11 +143,17 @@ def expression(text: str) -> Callable[[dict], str]:
140
143
  locals = LiveData({k.replace('-', '_'): v for k, v in items})
141
144
 
142
145
  # Create a resolve function which allows references using the hyphen
143
- # syntax too
146
+ # syntax too - note it relies on the original live_data object (not the
147
+ # locals with the key replacement). We're converting it to LiveData in
148
+ # case for some reason we were passed a raw dict.
149
+
150
+ live_data = LiveData(live_data)
144
151
 
145
152
  def resolve(key):
146
153
  return live_data.resolve(key, str_only=False)
147
- locals = locals | {'resolve': resolve}
154
+
155
+ # Passing the live_data in again allows function(data) in expressions
156
+ locals = locals | {'resolve': resolve, 'data': live_data}
148
157
 
149
158
  # Perform the Python eval, expanded above
150
159
  return _evaluate(text, locals)
dyngle/model/operation.py CHANGED
@@ -9,42 +9,84 @@ from dyngle.model.live_data import LiveData
9
9
  from dyngle.model.template import Template
10
10
 
11
11
 
12
- @dataclass
13
12
  class Operation:
13
+ """A named operation defined in configuration. Can be called from a Dyngle
14
+ command (i.e. `dyngle run`) or as a sub-operation."""
14
15
 
15
- # Expressions and values defined within the operation
16
- locals: dict
16
+ all_locals = {}
17
17
 
18
- steps: list
18
+ def __init__(self, dyngleverse, definition: dict | list, key: str):
19
+ """
20
+ definition: Either a dict containing steps and local
21
+ expressions/values, or a list containing only steps
22
+ """
23
+ self.dyngleverse = dyngleverse
24
+ if isinstance(definition, list):
25
+ steps_def = definition
26
+ elif isinstance(definition, dict):
27
+ steps_def = definition.get('steps') or []
28
+ self.all_locals = dyngleverse.parse_constants(definition)
29
+ self.sequence = Sequence(dyngleverse, self, steps_def)
19
30
 
20
- def run(self, data: dict, globals: dict):
21
- live_data = LiveData(globals) | self.locals | data
22
- for markup in self.steps:
23
- step = Step(markup)
24
- step.run(live_data)
31
+ def run(self, data: dict | LiveData, args: list):
32
+ """
33
+ data - The main set of data going into the operation
34
+
35
+ args - Arguments to the operation
36
+ """
37
+
38
+ # The tye of data tells us the run condition - if already a LiveData
39
+ # object then we don't recreate it (i.e. sub-operation)
25
40
 
41
+ if not isinstance(data, LiveData):
42
+ live_data = LiveData(data) | self.dyngleverse.all_globals
43
+ else:
44
+ live_data = data
45
+ live_data |= self.all_locals | {'args': args}
46
+ self.sequence.run(live_data)
26
47
 
27
- STEP_PATTERN = re.compile(
28
- r'^\s*(?:([\w.-]+)\s+->\s+)?(.+?)(?:\s+=>\s+([\w.-]+))?\s*$')
29
48
 
49
+ class Sequence:
50
+ """We allow for the possibility that a sequence of steps might run at other
51
+ levels than the operation itself, for example in a conditional block."""
30
52
 
31
- def parse_step(markup):
32
- if match := STEP_PATTERN.match(markup):
33
- input, command_text, output = match.groups()
34
- command_template = shlex.split(command_text.strip())
35
- return input, command_template, output
36
- else:
37
- raise DyngleError(f"Invalid step markup {{markup}}")
53
+ def __init__(self, dyngleverse, operation: Operation, steps_def: list):
54
+ self.steps = [Step.parse_def(dyngleverse, d) for d in steps_def]
55
+
56
+ def run(self, live_data: LiveData):
57
+ for step in self.steps:
58
+ step.run(live_data)
38
59
 
39
60
 
40
- @dataclass
41
61
  class Step:
42
62
 
43
- markup: str
63
+ @staticmethod
64
+ def parse_def(dyngleverse, definition: dict | str):
65
+ for step_type in [CommandStep, SubOperationStep]:
66
+ if step_type.fits(definition):
67
+ return step_type(dyngleverse, definition)
68
+ raise DyngleError(f"Unknown step definition\n{definition}")
69
+
70
+
71
+ # Ideally these would be subclasses in a ClassFamily (or use an ABC)
72
+
73
+ class CommandStep:
44
74
 
45
- def __post_init__(self):
46
- self.input, self.command_template, self.output = \
47
- parse_step(self.markup)
75
+ PATTERN = re.compile(
76
+ r'^\s*(?:([\w.-]+)\s+->\s+)?(.+?)(?:\s+=>\s+([\w.-]+))?\s*$')
77
+
78
+ @classmethod
79
+ def fits(cls, definition: dict | str):
80
+ return isinstance(definition, str)
81
+
82
+ def __init__(self, dyngleverse, markup: str):
83
+ self.markup = markup
84
+ if match := self.PATTERN.match(markup):
85
+ self.input, command_text, self.output = match.groups()
86
+ command_template = shlex.split(command_text.strip())
87
+ self.command_template = command_template
88
+ else:
89
+ raise DyngleError(f"Invalid step markup {{markup}}")
48
90
 
49
91
  def run(self, live_data: LiveData):
50
92
  command = [Template(word).render(live_data).strip()
@@ -60,3 +102,26 @@ class Step:
60
102
  f'Step failed with code {result.returncode}: {self.markup}')
61
103
  if self.output:
62
104
  live_data[self.output] = result.stdout.rstrip()
105
+
106
+
107
+ class SubOperationStep:
108
+ """Instead of calling a system command, call another operation in the same
109
+ Dyngleverse"""
110
+
111
+ @classmethod
112
+ def fits(cls, definition: dict | str):
113
+ return isinstance(definition, dict) and 'sub' in definition
114
+
115
+ def __init__(self, dyngleverse, definition: dict):
116
+ self.dyngleverse = dyngleverse
117
+ self.operation_key = definition['sub']
118
+ self.args_template = definition.get('args') or ''
119
+
120
+ def run(self, live_data: LiveData):
121
+ # Resolve the operation at runtime, not at init time
122
+ operation = self.dyngleverse.operations.get(self.operation_key)
123
+ if not operation:
124
+ raise DyngleError(f"Unknown operation {self.operation_key}")
125
+ args = [Template(word).render(live_data).strip()
126
+ for word in self.args_template]
127
+ operation.run(live_data, args)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: dyngle
3
- Version: 1.3.0
3
+ Version: 1.5.1
4
4
  Summary: Run lightweight local workflows
5
5
  License: MIT
6
6
  Author: Steampunk Wizard
@@ -11,7 +11,7 @@ Classifier: Programming Language :: Python :: 3
11
11
  Classifier: Programming Language :: Python :: 3.13
12
12
  Classifier: Programming Language :: Python :: 3.14
13
13
  Requires-Dist: requests (>=2.32.3,<3.0.0)
14
- Requires-Dist: wizlib (>=3.3.8,<3.4.0)
14
+ Requires-Dist: wizlib (>=3.3.11,<3.4.0)
15
15
  Description-Content-Type: text/markdown
16
16
 
17
17
  # Dyngle
@@ -245,6 +245,45 @@ dyngle:
245
245
 
246
246
  If names overlap, data items populated using the data assignment operator take precedence over expressions and data in the original input from the beginning of the Operation.
247
247
 
248
+ ## Sub-operations
249
+
250
+ Operations can call other operations as steps using the `sub:` key. This allows for composability and reuse of operation logic.
251
+
252
+ Basic example:
253
+
254
+ ```yaml
255
+ dyngle:
256
+ operations:
257
+ greet:
258
+ - echo "Hello!"
259
+
260
+ greet-twice:
261
+ steps:
262
+ - sub: greet
263
+ - sub: greet
264
+ ```
265
+
266
+ Sub-operations can accept arguments using the `args:` key. The called operation can access these via the `args` array in expressions:
267
+
268
+ ```yaml
269
+ dyngle:
270
+ operations:
271
+ greet-person:
272
+ expressions:
273
+ person: "args[0]"
274
+ steps:
275
+ - echo "Hello, {{person}}!"
276
+
277
+ greet-team:
278
+ steps:
279
+ - sub: greet-person
280
+ args: ['Alice']
281
+ - sub: greet-person
282
+ args: ['Bob']
283
+ ```
284
+
285
+ Sub-operations share the same Live Data context, so data assignments and expressions from the parent operation are available to the sub-operation, and any data populated by the sub-operation is available to subsequent steps in the parent.
286
+
248
287
  ## Lifecycle
249
288
 
250
289
  The lifecycle of an operation is:
@@ -275,4 +314,3 @@ In the event of item name conflicts, expressions and operations are loaded from
275
314
 
276
315
  Commands are executed using Python's `subprocess.run()` with arguments split in a shell-like fashion. The shell is not used, which reduces the likelihood of shell injection attacks. However, note that Dyngle is not robust to malicious configuration. Use with caution.
277
316
 
278
-
@@ -0,0 +1,17 @@
1
+ dyngle/__init__.py,sha256=BxgXnQ_cyEuZiMpkv-zkDCTRs9UP8vZsKX2kWeZj_Ck,2336
2
+ dyngle/__main__.py,sha256=pYRIwzix_AL8CdJaDDis_8yMBBWO2N72NNwkroo1dQo,95
3
+ dyngle/command/__init__.py,sha256=ngNOb_k9COcXOs7It3HoFJRW0hzBDpAzxXcGUy6hhko,95
4
+ dyngle/command/null_command.py,sha256=OX1u0z4zjlquxuV0_yu7uE1_K2Lk523WydJu-0Z82QE,96
5
+ dyngle/command/run_command.py,sha256=YOYuTzR1l2t24G1Z2uLjl1Ya1unvE4lHWpaOopMO1Tg,1441
6
+ dyngle/error.py,sha256=CGcTa8L4O1qsHEYnzp_JBbkvntJTv2Qz46wj_TI8NLk,39
7
+ dyngle/model/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
8
+ dyngle/model/dyngleverse.py,sha256=tFzx4AhPalODknPqbuFcR4G7QF1VOPlAAy2VDPfRmTA,1301
9
+ dyngle/model/expression.py,sha256=XvHlWqnN-4gkN4ck5iTuwP3OqXXbRzTwIRTX0lzJAdY,4889
10
+ dyngle/model/live_data.py,sha256=FxbMjfaiBIUorEbhRx5I0o-WAFFWdjYaqzw_zhFq86w,1251
11
+ dyngle/model/operation.py,sha256=IrLx6iDZ2_D7384qKsjpS7VBrHqTsSmsOWw_lJFWimI,4465
12
+ dyngle/model/safe_path.py,sha256=Hk2AhP6e3yKGh3kKrLLwhvAlMNx-j2jObBYJL-_doAU,3339
13
+ dyngle/model/template.py,sha256=MeXu--ZNtj_ujABU1GjjcQ1Ea_o_M-50LocuXFeOLRE,887
14
+ dyngle-1.5.1.dist-info/METADATA,sha256=kdyZXIVC7aEXcVvN9gMLweEfpqvh3nlkB2LVSGX-QmQ,10681
15
+ dyngle-1.5.1.dist-info/WHEEL,sha256=zp0Cn7JsFoX2ATtOhtaFYIiE2rmFAD4OcMhtUki8W3U,88
16
+ dyngle-1.5.1.dist-info/entry_points.txt,sha256=rekiGhtweiHKm9g1jdGb3FhzqDrk1kigJDeSNollZSA,48
17
+ dyngle-1.5.1.dist-info/RECORD,,
@@ -1,15 +0,0 @@
1
- dyngle/__init__.py,sha256=ZSBChSHOOTW68vZ-2q4ts8VhmEJ5hssG45jhbTTMvk0,2919
2
- dyngle/__main__.py,sha256=pYRIwzix_AL8CdJaDDis_8yMBBWO2N72NNwkroo1dQo,95
3
- dyngle/command/__init__.py,sha256=1S86gbef8MYvG-TWD5JRIWzFg7qV5xKhp9QXx9zEx5c,94
4
- dyngle/command/run_command.py,sha256=gYTQjATRMu_81HLP-hrMz7GNw9Lcz2VjuhP7h_qYLcE,1514
5
- dyngle/error.py,sha256=CGcTa8L4O1qsHEYnzp_JBbkvntJTv2Qz46wj_TI8NLk,39
6
- dyngle/model/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
7
- dyngle/model/expression.py,sha256=9nwfahjOHtScjdDzjwWIleo60lJjtXGrPGfroCbslvg,4431
8
- dyngle/model/live_data.py,sha256=FxbMjfaiBIUorEbhRx5I0o-WAFFWdjYaqzw_zhFq86w,1251
9
- dyngle/model/operation.py,sha256=nSz-8Eh03xAvQlc6Wzn56t-dMArp9uJWY_8XJJZEIwc,1743
10
- dyngle/model/safe_path.py,sha256=Hk2AhP6e3yKGh3kKrLLwhvAlMNx-j2jObBYJL-_doAU,3339
11
- dyngle/model/template.py,sha256=MeXu--ZNtj_ujABU1GjjcQ1Ea_o_M-50LocuXFeOLRE,887
12
- dyngle-1.3.0.dist-info/METADATA,sha256=LcfQLPcClIK9_TOWRUHGJ4nt7GN8SMtvqkQSqxDQaFg,9710
13
- dyngle-1.3.0.dist-info/WHEEL,sha256=zp0Cn7JsFoX2ATtOhtaFYIiE2rmFAD4OcMhtUki8W3U,88
14
- dyngle-1.3.0.dist-info/entry_points.txt,sha256=rekiGhtweiHKm9g1jdGb3FhzqDrk1kigJDeSNollZSA,48
15
- dyngle-1.3.0.dist-info/RECORD,,
File without changes