dyngle 0.4.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
@@ -1,11 +1,16 @@
1
1
  from functools import cached_property
2
+ from pathlib import Path
2
3
  from wizlib.app import WizApp
3
4
  from wizlib.stream_handler import StreamHandler
4
5
  from wizlib.config_handler import ConfigHandler
5
6
  from wizlib.ui_handler import UIHandler
6
7
 
7
8
  from dyngle.command import DyngleCommand
8
- from dyngle.expression import expression
9
+ from dyngle.error import DyngleError
10
+ from dyngle.model.dyngleverse import Dyngleverse
11
+ from dyngle.model.expression import expression
12
+ from dyngle.model.operation import Operation
13
+ from dyngle.model.template import Template
9
14
 
10
15
 
11
16
  class DyngleApp(WizApp):
@@ -14,10 +19,39 @@ class DyngleApp(WizApp):
14
19
  name = 'dyngle'
15
20
  handlers = [StreamHandler, ConfigHandler, UIHandler]
16
21
 
17
- @cached_property
18
- def expressions(self):
19
- expr_texts = self.config.get('dyngle-expressions')
20
- if expr_texts:
21
- return {k: expression(t) for k, t in expr_texts.items()}
22
- else:
23
- return {}
22
+ @property
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)
56
+ confs.append(ConfigHandler(full_filename))
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,11 +1,12 @@
1
+ from functools import cached_property
1
2
  import shlex
2
3
  import subprocess
3
4
  from wizlib.parser import WizParser
4
5
  from yaml import safe_load
5
6
 
6
7
  from dyngle.command import DyngleCommand
7
- from dyngle.expression import expression
8
- from dyngle.template import Template
8
+ from dyngle.model.expression import expression
9
+ from dyngle.model.template import Template
9
10
  from dyngle.error import DyngleError
10
11
 
11
12
 
@@ -17,33 +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='?')
23
+ parser.add_argument(
24
+ 'args', nargs='*', help='Optional operation arguments')
21
25
 
22
26
  def handle_vals(self):
23
27
  super().handle_vals()
24
-
25
- def _validate_operation_exists(self, operations):
26
- """Validate that the requested operation exists in configuration"""
27
- if self.operation not in operations:
28
- available_operations = ', '.join(operations.keys())
29
- raise DyngleError(
30
- f'Operation "{self.operation}" not found. " + \
31
- 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}.")
32
35
 
33
36
  @DyngleCommand.wrap
34
37
  def execute(self):
35
- expressions = self.app.expressions
36
- operations = self.app.config.get('dyngle-operations')
37
- self._validate_operation_exists(operations)
38
- steps = operations[self.operation]
39
38
  data_string = self.app.stream.text
40
- data = safe_load(data_string)
41
- for step_template in steps:
42
- step = Template(step_template).render(data, expressions)
43
- parts = shlex.split(step)
44
- result = subprocess.run(parts)
45
- if result.returncode != 0:
46
- raise DyngleError(
47
- f'Task failed with code {result.returncode}: {step}')
48
-
39
+ data = safe_load(data_string) or {}
40
+ operation = self.app.dyngleverse.operations[self.operation]
41
+ operation.run(data, self.args)
49
42
  return f'Operation "{self.operation}" completed successfully'
File without changes
@@ -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
@@ -0,0 +1,161 @@
1
+ from typing import Callable
2
+
3
+ from dyngle.error import DyngleError
4
+ from dyngle.model.live_data import LiveData
5
+ from dyngle.model.safe_path import SafePath
6
+
7
+ from datetime import datetime as datetime, date, timedelta
8
+ import math
9
+ import json
10
+ import re
11
+ import yaml
12
+
13
+ from dyngle.model.template import Template
14
+
15
+
16
+ def formatted_datetime(dt: datetime, format_string=None) -> str:
17
+ """Safe datetime formatting using string operations"""
18
+ if format_string is None:
19
+ format_string = "{year:04d}{month:02d}{day:02d}"
20
+ components = {
21
+ 'year': dt.year,
22
+ 'month': dt.month,
23
+ 'day': dt.day,
24
+ 'hour': dt.hour,
25
+ 'minute': dt.minute,
26
+ 'second': dt.second,
27
+ 'microsecond': dt.microsecond,
28
+ 'weekday': dt.weekday(), # Monday is 0
29
+ }
30
+ return format_string.format(**components)
31
+
32
+
33
+ GLOBALS = {
34
+ "__builtins__": {
35
+ # Basic data types and conversions
36
+ "int": int,
37
+ "float": float,
38
+ "str": str,
39
+ "bool": bool,
40
+ "list": list,
41
+ "dict": dict,
42
+ "tuple": tuple,
43
+ "set": set,
44
+
45
+ # Essential functions
46
+ "len": len,
47
+ "min": min,
48
+ "max": max,
49
+ "sum": sum,
50
+ "abs": abs,
51
+ "round": round,
52
+ "sorted": sorted,
53
+ "reversed": reversed,
54
+ "enumerate": enumerate,
55
+ "zip": zip,
56
+ "range": range,
57
+ "type": type
58
+ },
59
+
60
+ # Mathematical operations
61
+ "math": math,
62
+
63
+ # Date and time handling
64
+ "datetime": datetime,
65
+ "date": date,
66
+ "timedelta": timedelta,
67
+ "formatted": formatted_datetime,
68
+
69
+ # Data parsing and manipulation
70
+ "json": json,
71
+ "yaml": yaml,
72
+ "re": re,
73
+
74
+ # Safe Path-like operations (within cwd)
75
+ "Path": SafePath
76
+ }
77
+
78
+
79
+ def _evaluate(expression: str, locals: dict) -> str:
80
+ """Evaluate a Python expression with safe globals and user data context.
81
+
82
+ Safely evaluates a Python expression string using a restricted set of
83
+ global functions and modules, combined with user-provided data. The
84
+ expression is evaluated in a sandboxed environment that includes basic
85
+ Python built-ins, mathematical operations, date/time handling, and data
86
+ manipulation utilities.
87
+
88
+ Parameters
89
+ ----------
90
+ expression : str
91
+ A valid Python expression string to be evaluated.
92
+ data : dict
93
+ Dictionary containing variables and values to be made available during
94
+ expression evaluation. Note that hyphens in keys will be replaced by
95
+ underscores to create valid Python names.
96
+
97
+ Returns
98
+ -------
99
+ str
100
+ String representation of the evaluated expression result. If the result
101
+ is a tuple, returns the string representation of the last element.
102
+
103
+ Raises
104
+ ------
105
+ DyngleError
106
+ If the expression contains invalid variable names that are not found in
107
+ the provided data dictionary or global context.
108
+ """
109
+ try:
110
+ result = eval(expression, GLOBALS, locals)
111
+ except KeyError as error:
112
+ raise DyngleError(f"The following expression contains " +
113
+ f"invalid name '{error}:\n{expression}")
114
+
115
+ # Allow the use of a comma to separate sub-expressions, which can then use
116
+ # warus to set values, and only the last exxpression in the list returns a
117
+ # value.
118
+ result = result[-1] if isinstance(result, tuple) else result
119
+
120
+ return result
121
+
122
+
123
+ # The 'expression' function returns the expression object itself, which is
124
+ # really just a function.
125
+
126
+ def expression(text: str) -> Callable[[dict], str]:
127
+ """Generate an expression, which is a function based on a string
128
+ expression"""
129
+
130
+ def definition(live_data: LiveData | dict | None = None) -> str:
131
+ """The expression function itself"""
132
+
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')
138
+
139
+ # Translate names to underscore-separated instead of hyphen-separated
140
+ # so they work within the Python namespace.
141
+
142
+ items = live_data.items() if live_data else ()
143
+ locals = LiveData({k.replace('-', '_'): v for k, v in items})
144
+
145
+ # Create a resolve function which allows references using the hyphen
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)
151
+
152
+ def resolve(key):
153
+ return live_data.resolve(key, str_only=False)
154
+
155
+ # Passing the live_data in again allows function(data) in expressions
156
+ locals = locals | {'resolve': resolve, 'data': live_data}
157
+
158
+ # Perform the Python eval, expanded above
159
+ return _evaluate(text, locals)
160
+
161
+ return definition
@@ -0,0 +1,38 @@
1
+ from collections import UserDict
2
+ from datetime import date, timedelta
3
+ from numbers import Number
4
+
5
+ from yaml import safe_dump
6
+
7
+ from dyngle.error import DyngleError
8
+ from dyngle.model.safe_path import SafePath
9
+
10
+
11
+ class LiveData(UserDict):
12
+
13
+ def resolve(self, key: str, str_only: bool = True):
14
+ """Given a key (which might be dot-separated), return
15
+ the value (which might include evaluating expressions)."""
16
+
17
+ parts = key.split('.')
18
+ current = self.data
19
+ for part in parts:
20
+ if part not in current:
21
+ raise DyngleError(
22
+ f"Invalid expression or data reference '{key}'")
23
+ current = current[part]
24
+ result = current(self) if callable(current) else current
25
+ return _stringify(result) if str_only else result
26
+
27
+
28
+ def _stringify(value) -> str:
29
+ if isinstance(value, bool):
30
+ return '.' if value is True else ''
31
+ elif isinstance(value, (Number, str, date, timedelta, SafePath)):
32
+ return str(value)
33
+ elif isinstance(value, (list, dict, tuple)):
34
+ return safe_dump(value)
35
+ elif isinstance(value, set):
36
+ return safe_dump(list(value))
37
+ else:
38
+ raise DyngleError(f'Unable to serialize value of type {type(value)}')
@@ -0,0 +1,127 @@
1
+ from dataclasses import dataclass
2
+ from functools import cached_property
3
+ import re
4
+ import shlex
5
+ import subprocess
6
+
7
+ from dyngle.error import DyngleError
8
+ from dyngle.model.live_data import LiveData
9
+ from dyngle.model.template import Template
10
+
11
+
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."""
15
+
16
+ all_locals = {}
17
+
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)
30
+
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)
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)
47
+
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."""
52
+
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)
59
+
60
+
61
+ class Step:
62
+
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:
74
+
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}}")
90
+
91
+ def run(self, live_data: LiveData):
92
+ command = [Template(word).render(live_data).strip()
93
+ for word in self.command_template]
94
+ pipes = {}
95
+ if self.input:
96
+ pipes["input"] = live_data.resolve(self.input)
97
+ if self.output:
98
+ pipes['stdout'] = subprocess.PIPE
99
+ result = subprocess.run(command, text=True, **pipes)
100
+ if result.returncode != 0:
101
+ raise DyngleError(
102
+ f'Step failed with code {result.returncode}: {self.markup}')
103
+ if self.output:
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)
@@ -0,0 +1,30 @@
1
+ from dataclasses import dataclass
2
+ from functools import partial
3
+ import re
4
+
5
+ from dyngle.error import DyngleError
6
+ from dyngle.model.live_data import LiveData
7
+
8
+
9
+ PATTERN = re.compile(r'\{\{\s*([^}]+)\s*\}\}')
10
+
11
+
12
+ @dataclass
13
+ class Template:
14
+
15
+ template: str
16
+
17
+ def render(self, live_data: LiveData | dict | None = None) -> str:
18
+ """Render the template with the provided LiveData (raw data and
19
+ expressions)."""
20
+
21
+ live_data = LiveData(live_data)
22
+ resolver = partial(self._resolve, live_data=live_data)
23
+ return PATTERN.sub(resolver, self.template)
24
+
25
+ def _resolve(self, match, *, live_data: LiveData):
26
+ """Resolve a single name/path from the template. The argument is a
27
+ merge of the raw data and the expressions, either of which are valid
28
+ substitutions."""
29
+ key = match.group(1).strip()
30
+ return live_data.resolve(key)
@@ -0,0 +1,316 @@
1
+ Metadata-Version: 2.4
2
+ Name: dyngle
3
+ Version: 1.5.1
4
+ Summary: Run lightweight local workflows
5
+ License: MIT
6
+ Author: Steampunk Wizard
7
+ Author-email: dyngle@steamwiz.io
8
+ Requires-Python: >=3.13,<4.0
9
+ Classifier: License :: OSI Approved :: MIT License
10
+ Classifier: Programming Language :: Python :: 3
11
+ Classifier: Programming Language :: Python :: 3.13
12
+ Classifier: Programming Language :: Python :: 3.14
13
+ Requires-Dist: requests (>=2.32.3,<3.0.0)
14
+ Requires-Dist: wizlib (>=3.3.11,<3.4.0)
15
+ Description-Content-Type: text/markdown
16
+
17
+ # Dyngle
18
+
19
+ An experimantal, lightweight, easily configurable workflow engine for
20
+ automating development, operations, data processing, and content management
21
+ tasks.
22
+
23
+ Technical foundations
24
+
25
+ - Configuration, task definition, and flow control in YAML
26
+ - Operations as system commands using a familiar shell-like syntax
27
+ - Expressions and logic in pure Python
28
+
29
+ ## Quick installation (MacOS)
30
+
31
+ ```bash
32
+ brew install python@3.11
33
+ python3.11 -m pip install pipx
34
+ pipx install dyngle
35
+ ```
36
+
37
+ ## Getting started
38
+
39
+ Create a file `.dyngle.yml`:
40
+
41
+ ```yaml
42
+ dyngle:
43
+ operations:
44
+ hello:
45
+ - echo "Hello world"
46
+ ```
47
+
48
+ Run an operation:
49
+
50
+ ```bash
51
+ dyngle run hello
52
+ ```
53
+
54
+ ## Configuration
55
+
56
+ Dyngle reads configuration from YAML files. Specify the config file location using any of the following (in order of precedence):
57
+
58
+ 1. A `--config` command line option, OR
59
+ 2. A `DYNGLE_CONFIG` environment variable, OR
60
+ 3. `.dyngle.yml` in current directory, OR
61
+ 4. `~/.dyngle.yml` in home directory
62
+
63
+ ## Operations
64
+
65
+ Operations are defined under `dyngle:` in the configuration. In its simplest form, an Operation is a YAML array defining the Steps, as system commands with space-separated arguments. In that sense, a Dyngle operation looks something akin to a phony Make target, a short Bash script, or a CI/CD job.
66
+
67
+ As a serious example, consider the `init` operation from the Dyngle configuration delivered with the project's source code.
68
+
69
+ ```yaml
70
+ dyngle:
71
+ operations:
72
+ init:
73
+ - rm -rf .venv
74
+ - python3.11 -m venv .venv
75
+ - .venv/bin/pip install --upgrade pip poetry
76
+ ```
77
+
78
+ The elements of the YAML array _look_ like lines of Bash, but Dyngle processes them directly as system commands, allowing for template substitution and Python expression evaluation (described below). So shell-specific syntax such as `|`, `>`, and `$VARIABLE` won't work.
79
+
80
+ ## Data and Templates
81
+
82
+ Dyngle maintains a block of "Live Data" throughout an operation, which is a set of named values (Python `dict`, YAML "mapping"). The values are usually strings but can also be other data types that are valid in both YAML and Python.
83
+
84
+ The `dyngle run` command feeds the contents of stdin to the Operation as Data, by converting a YAML mapping to named Python values. The values may be substituted into commands or arguments in Steps using double-curly-bracket syntax (`{{` and `}}`) similar to Jinja2.
85
+
86
+ For example, consider the following configuration:
87
+
88
+ ``` yaml
89
+ dyngle:
90
+ operations:
91
+ hello:
92
+ - echo "Hello {{name}}!"
93
+ ```
94
+
95
+ Cram some YAML into stdin to try it in your shell:
96
+
97
+ ```bash
98
+ echo "name: Francis" | dyngle run hello
99
+ ```
100
+
101
+ The output will say:
102
+
103
+ ```text
104
+ Hello Francis!
105
+ ```
106
+
107
+ ## Expressions
108
+
109
+ Operations may contain Expressions, written in Python, that can be referenced in Operation Step Templates using the same syntax as for Data. In the case of a naming conflict, an Expression takes precedence over Data with the same name. Expressions can reference names in the Data directly.
110
+
111
+ Expressions may be defined in either of two ways in the configuration:
112
+
113
+ 1. Global Expressions, under the `dyngle:` mapping, using the `expressions:` key.
114
+ 2. Local Expressions, within a single Operation, in which case the Steps of the operation require a `steps:` key.
115
+
116
+ Here's an example of a global Expression
117
+
118
+ ```yaml
119
+ dyngle:
120
+ expressions:
121
+ count: len(name)
122
+ operations:
123
+ say-hello:
124
+ - echo "Hello {{name}}! Your name has {{count}} characters."
125
+ ```
126
+
127
+ For completeness, consider the following example using a local Expression for the same purpose.
128
+
129
+ ```yaml
130
+ dyngle:
131
+ operations:
132
+ say-hello:
133
+ expressions:
134
+ count: len(name)
135
+ steps:
136
+ - echo "Hello {{name}}! Your name has {{count}} characters."
137
+ ```
138
+
139
+ Expressions can use a controlled subset of the Python standard library, including:
140
+
141
+ - Built-in data types such as `str()`
142
+ - Essential built-in functions such as `len()`
143
+ - The core modules from the `datetime` package (but some methods such as `strftime()` will fail)
144
+ - A specialized function called `formatted()` to perform string formatting operations on a `datetime` object
145
+ - A restricted version of `Path()` that only operates within the current working directory
146
+ - Various other useful utilities, mostly read-only, such as the `math` module
147
+ - A special function called `resolve` which resolves data expressions using the same logic as in templates
148
+ - An array `args` containing arguments passed to the `dyngle run` command after the Operation name
149
+
150
+ **NOTE** Some capabilities of the Expression namespace might be limited in the future. The goal is support purely read-only operations within Expressions.
151
+
152
+ Expressions behave like functions that take no arguments, using the Data as a namespace. So Expressions reference Data directly as local names in Python.
153
+
154
+ YAML keys can contain hyphens, which are fully supported in Dyngle. To reference a hyphenated key in an Expression, choose:
155
+
156
+ - Reference the name using underscores instead of hyphens (they are automatically replaced), OR
157
+ - Use the built-in special-purpose `resolve()` function (which can also be used to reference other expressions)
158
+
159
+ ```yaml
160
+ dyngle:
161
+ expressions:
162
+ say-hello: >-
163
+ 'Hello ' + full_name + '!'
164
+ ```
165
+
166
+ ... or using the `resolve()` function, which also allows expressions to essentially call other expressions, using the same underlying data set.
167
+
168
+ ```yaml
169
+ dyngle:
170
+ expressions:
171
+ hello: >-
172
+ 'Hello ' + resolve('formal-name') + '!'
173
+ formal-name: >-
174
+ 'Ms. ' + full_name
175
+ ```
176
+
177
+ Note it's also _possible_ to call other expressions by name as functions, if they only return hard-coded values (i.e. constants).
178
+
179
+ ```yaml
180
+ dyngle:
181
+ expressions:
182
+ author-name: Francis Potter
183
+ author-hello: >-
184
+ 'Hello ' + author_name()
185
+ ```
186
+
187
+ Here are some slightly more sophisticated exercises using Expression reference syntax:
188
+
189
+ ```yaml
190
+ dyngle:
191
+ operations:
192
+ reference-hyphenated-data-key:
193
+ expressions:
194
+ spaced-name: "' '.join([x for x in first_name])"
195
+ count-name: len(resolve('first-name'))
196
+ x-name: "'X' * int(resolve('count-name'))"
197
+ steps:
198
+ - echo "Your name is {{first-name}} with {{count-name}} characters, but I will call you '{{spaced-name}}' or maybe '{{x-name}}'"
199
+ reference-expression-using-function-syntax:
200
+ expressions:
201
+ name: "'George'"
202
+ works: "name()"
203
+ double: "name * 2"
204
+ fails: double()
205
+ steps:
206
+ - echo "It works to call you {{works}}"
207
+ # - echo "I have trouble calling you {{fails}}"
208
+ ```
209
+
210
+ Finally, here's an example using args:
211
+
212
+ ```yaml
213
+ dyngle:
214
+ operations:
215
+ name-from-arg:
216
+ expressions:
217
+ name: "args[0]"
218
+ steps:
219
+ - echo "Hello {{name}}"
220
+ ```
221
+
222
+ ## Passing values between Steps in an Operation
223
+
224
+ The Steps parser supports two special operators designed to move data between Steps in an explicit way.
225
+
226
+ - The data assignment operator (`=>`) assigns the contents of stdout from the command to an element in the data
227
+ - The data input operator (`->`) assigns the value of an element in the data (or an evaluated expression) to stdin for the command
228
+
229
+ The operators must appear in order in the step and must be isolated with whitespace, i.e.
230
+
231
+ ```
232
+ <input-variable-name> -> <command and arguments> => <output-variable-name>
233
+ ```
234
+
235
+ Here we get into more useful functionality, where commands can be strung together in meaningful ways without the need for Bash.
236
+
237
+ ```yaml
238
+ dyngle:
239
+ operations:
240
+ weather:
241
+ - curl -s "https://api.open-meteo.com/v1/forecast?latitude=52.52&longitude=13.41&current_weather=true" => weather-data
242
+ - weather-data -> jq -j '.current_weather.temperature' => temperature
243
+ - echo "It's {{temperature}} degrees out there!"
244
+ ```
245
+
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
+
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
+
287
+ ## Lifecycle
288
+
289
+ The lifecycle of an operation is:
290
+
291
+ 1. Load Data if it exists from YAML on stdin (if no tty)
292
+ 2. Find the named Operation in the configuration
293
+ 2. Perform template rendering on the first Step, using Data and Expressions
294
+ 3. Execute the Step in a subprocess, passing in an input value and populating an output value in the Data
295
+ 4. Continue with the next Step
296
+
297
+ Note that operations in the config are _not_ full shell lines. They are passed directly to the system.
298
+
299
+ ## Imports
300
+
301
+ Configuration files can import other configuration files, by providing an entry `imports:` with an array of filepaths. The most obvious example is a Dyngle config in a local directory which imports the user-level configuration.
302
+
303
+ ```yaml
304
+ dyngle:
305
+ imports:
306
+ - ~/.dyngle.yml
307
+ expressions:
308
+ operations:
309
+ ```
310
+
311
+ In the event of item name conflicts, expressions and operations are loaded from imports in the order specified, so imports lower in the array will override those higher up. The expressions and operations defined in the main file override the imports. Imports are not recursive.
312
+
313
+ ## Security
314
+
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.
316
+
@@ -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,4 +1,4 @@
1
1
  Wheel-Version: 1.0
2
- Generator: poetry-core 2.1.3
2
+ Generator: poetry-core 2.2.1
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any
dyngle/expression.py DELETED
@@ -1,88 +0,0 @@
1
- from typing import Callable
2
-
3
- from dyngle.error import DyngleError
4
- from dyngle.safe_path import SafePath
5
-
6
- from datetime import datetime as datetime, date, timedelta
7
- import math
8
- import json
9
- import re
10
- import yaml
11
-
12
-
13
- def formatted_datetime(dt: datetime, format_string=None) -> str:
14
- """Safe datetime formatting using string operations"""
15
- if format_string is None:
16
- format_string = "{year:04d}{month:02d}{day:02d}"
17
- components = {
18
- 'year': dt.year,
19
- 'month': dt.month,
20
- 'day': dt.day,
21
- 'hour': dt.hour,
22
- 'minute': dt.minute,
23
- 'second': dt.second,
24
- 'microsecond': dt.microsecond,
25
- 'weekday': dt.weekday(), # Monday is 0
26
- }
27
- return format_string.format(**components)
28
-
29
-
30
- GLOBALS = {
31
- "__builtins__": {
32
- # Basic data types and conversions
33
- "int": int,
34
- "float": float,
35
- "str": str,
36
- "bool": bool,
37
- "list": list,
38
- "dict": dict,
39
- "tuple": tuple,
40
- "set": set,
41
-
42
- # Essential functions
43
- "len": len,
44
- "min": min,
45
- "max": max,
46
- "sum": sum,
47
- "abs": abs,
48
- "round": round,
49
- "sorted": sorted,
50
- "reversed": reversed,
51
- "enumerate": enumerate,
52
- "zip": zip,
53
- "range": range,
54
- },
55
-
56
- # Mathematical operations
57
- "math": math,
58
-
59
- # Date and time handling
60
- "datetime": datetime,
61
- "date": date,
62
- "timedelta": timedelta,
63
- "formatted": formatted_datetime,
64
-
65
- # Data parsing and manipulation
66
- "json": json,
67
- "yaml": yaml,
68
- "re": re,
69
-
70
- # Safe Path-like operations (within cwd)
71
- "Path": SafePath
72
- }
73
-
74
-
75
- def _evaluate(expression: str, data: dict) -> str:
76
- try:
77
- result = eval(expression, GLOBALS, data)
78
- except KeyError:
79
- raise DyngleError(f"The following expression contains " +
80
- f"at least one invalid name: {expression}")
81
- result = result[-1] if isinstance(result, tuple) else result
82
- return str(result)
83
-
84
-
85
- def expression(text: str) -> Callable[[dict], str]:
86
- def evaluate(data: dict) -> str:
87
- return _evaluate(text, data)
88
- return evaluate
dyngle/template.py DELETED
@@ -1,29 +0,0 @@
1
- from dataclasses import dataclass
2
- from functools import partial
3
- import re
4
-
5
-
6
- PATTERN = re.compile(r'\{\{\s*([^}]+)\s*\}\}')
7
-
8
-
9
- @dataclass
10
- class Template:
11
-
12
- template: str
13
-
14
- def render(self, data: dict, expressions: dict = None) -> str:
15
- """Render the template with the provided data."""
16
- resolver = partial(self._resolve, data=data, expressions=expressions)
17
- return PATTERN.sub(resolver, self.template)
18
-
19
- def _resolve(self, match, *, data: dict, expressions: dict):
20
- """Resolve a single name/path from the template."""
21
- key = match.group(1).strip()
22
- if expressions and (key in expressions):
23
- return expressions[key](data)
24
- else:
25
- parts = key.split('.')
26
- current = data
27
- for part in parts:
28
- current = current[part]
29
- return current
@@ -1,107 +0,0 @@
1
- Metadata-Version: 2.3
2
- Name: dyngle
3
- Version: 0.4.0
4
- Summary: Run lightweight local workflows
5
- License: MIT
6
- Author: Steampunk Wizard
7
- Author-email: dyngle@steamwiz.io
8
- Requires-Python: >=3.11,<3.12
9
- Classifier: License :: OSI Approved :: MIT License
10
- Classifier: Programming Language :: Python :: 3
11
- Classifier: Programming Language :: Python :: 3.11
12
- Requires-Dist: requests (>=2.32.3,<3.0.0)
13
- Requires-Dist: wizlib (>=3.1.4,<4.0.0)
14
- Description-Content-Type: text/markdown
15
-
16
- # Dyngle
17
-
18
- ## Run lightweight local workflows
19
-
20
- Dyngle is a simple workflow runner that executes sequences of commands defined in configuration files. It's like a lightweight combination of Make and a task runner, designed for automating common development and operational tasks.
21
-
22
- ## Basic usage
23
-
24
- Create a configuration file (e.g., `.dyngle.yml`) with your workflows:
25
-
26
- ```yaml
27
- dyngle:
28
- operations:
29
- build:
30
- - python -m pip install -e .
31
- - python -m pytest
32
- deploy:
33
- - docker build -t myapp .
34
- - docker push myapp
35
- clean:
36
- - rm -rf __pycache__
37
- - rm -rf .pytest_cache
38
- ```
39
-
40
- Run an operation:
41
-
42
- ```bash
43
- dyngle run build
44
- ```
45
-
46
- ## Configuration
47
-
48
- Dyngle reads configuration from YAML files. You can specify the config file location using:
49
-
50
- - `--config` command line option
51
- - `DYNGLE_CONFIG` environment variable
52
- - `.dyngle.yml` in current directory
53
- - `~/.dyngle.yml` in home directory
54
-
55
- ## Workflow structure
56
-
57
- Each operation is defined as a list of tasks under `dyngle.operations`. Tasks are executed sequentially using Python's subprocess module for security.
58
-
59
- Example with multiple operations:
60
-
61
- ```yaml
62
- dyngle:
63
- operations:
64
- test:
65
- - python -m unittest discover
66
- - python -m coverage report
67
- docs:
68
- - sphinx-build docs docs/_build
69
- - open docs/_build/index.html
70
- setup:
71
- - python -m venv venv
72
- - source venv/bin/activate
73
- - pip install -r requirements.txt
74
- ```
75
-
76
- ## Expressions
77
-
78
- Configs can also contain expressions.
79
-
80
- ```yaml
81
- dyngle:
82
- expressions:
83
- say-hello: >-
84
- 'Hello ' + name + '!'
85
- operations:
86
- say-hello: echo {{say-hello}}
87
- ```
88
- Expressions can use a controlled subset of the Python standard library, including:
89
-
90
- - Built-in data types such as `str()`
91
- - Essential built-in functions such as `len()`
92
- - The core modules from the `datetime` package (but some methods such as `strftime()` will fail)
93
- - A specialized function called `formatted()` to perform string formatting operations on a `datetime` object
94
- - A restricted version of `Path()` that only operates within the current working directory
95
-
96
- ## Security
97
-
98
- 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.
99
-
100
- ## Quick installation (MacOS)
101
-
102
- ```bash
103
- brew install python@3.11
104
- python3.11 -m pip install pipx
105
- pipx install dyngle
106
- ```
107
-
@@ -1,12 +0,0 @@
1
- dyngle/__init__.py,sha256=drqd18F3T7LMTNqAU38WYjanczl1kANAA1vVBjbPEyU,653
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=0E8Bg7TRa8qXdfZNyC4Wygb1cEG4-ejRJ5ZfYz_L1_Y,1718
5
- dyngle/error.py,sha256=CGcTa8L4O1qsHEYnzp_JBbkvntJTv2Qz46wj_TI8NLk,39
6
- dyngle/expression.py,sha256=x-2Ald34lmXfG8070H96Wszln5Rd1AWGyYuYtf7iZFA,2134
7
- dyngle/safe_path.py,sha256=Hk2AhP6e3yKGh3kKrLLwhvAlMNx-j2jObBYJL-_doAU,3339
8
- dyngle/template.py,sha256=5R8gUrv6BhppchlMqr_SZtkSVsUo8FYseCw4ETAJxeE,855
9
- dyngle-0.4.0.dist-info/METADATA,sha256=Vx4RonlVHvFKAW2y1a_OD8_XUvyI2LSI0CMf2tZ8KsM,2941
10
- dyngle-0.4.0.dist-info/WHEEL,sha256=b4K_helf-jlQoXBBETfwnf4B04YC67LOev0jo4fX5m8,88
11
- dyngle-0.4.0.dist-info/entry_points.txt,sha256=rekiGhtweiHKm9g1jdGb3FhzqDrk1kigJDeSNollZSA,48
12
- dyngle-0.4.0.dist-info/RECORD,,
File without changes