dyngle 0.2.0__tar.gz → 0.4.0__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 dyngle might be problematic. Click here for more details.
- {dyngle-0.2.0 → dyngle-0.4.0}/PACKAGE.md +20 -0
- {dyngle-0.2.0 → dyngle-0.4.0}/PKG-INFO +21 -1
- {dyngle-0.2.0 → dyngle-0.4.0}/dyngle/__init__.py +10 -0
- {dyngle-0.2.0 → dyngle-0.4.0}/dyngle/__main__.py +1 -2
- dyngle-0.4.0/dyngle/command/run_command.py +49 -0
- dyngle-0.4.0/dyngle/error.py +2 -0
- dyngle-0.4.0/dyngle/expression.py +88 -0
- dyngle-0.4.0/dyngle/safe_path.py +117 -0
- dyngle-0.4.0/dyngle/template.py +29 -0
- {dyngle-0.2.0 → dyngle-0.4.0}/pyproject.toml +1 -1
- dyngle-0.2.0/dyngle/command/run_command.py +0 -52
- {dyngle-0.2.0 → dyngle-0.4.0}/dyngle/command/__init__.py +0 -0
|
@@ -58,6 +58,26 @@ dyngle:
|
|
|
58
58
|
- pip install -r requirements.txt
|
|
59
59
|
```
|
|
60
60
|
|
|
61
|
+
## Expressions
|
|
62
|
+
|
|
63
|
+
Configs can also contain expressions.
|
|
64
|
+
|
|
65
|
+
```yaml
|
|
66
|
+
dyngle:
|
|
67
|
+
expressions:
|
|
68
|
+
say-hello: >-
|
|
69
|
+
'Hello ' + name + '!'
|
|
70
|
+
operations:
|
|
71
|
+
say-hello: echo {{say-hello}}
|
|
72
|
+
```
|
|
73
|
+
Expressions can use a controlled subset of the Python standard library, including:
|
|
74
|
+
|
|
75
|
+
- Built-in data types such as `str()`
|
|
76
|
+
- Essential built-in functions such as `len()`
|
|
77
|
+
- The core modules from the `datetime` package (but some methods such as `strftime()` will fail)
|
|
78
|
+
- A specialized function called `formatted()` to perform string formatting operations on a `datetime` object
|
|
79
|
+
- A restricted version of `Path()` that only operates within the current working directory
|
|
80
|
+
|
|
61
81
|
## Security
|
|
62
82
|
|
|
63
83
|
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.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.3
|
|
2
2
|
Name: dyngle
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.4.0
|
|
4
4
|
Summary: Run lightweight local workflows
|
|
5
5
|
License: MIT
|
|
6
6
|
Author: Steampunk Wizard
|
|
@@ -73,6 +73,26 @@ dyngle:
|
|
|
73
73
|
- pip install -r requirements.txt
|
|
74
74
|
```
|
|
75
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
|
+
|
|
76
96
|
## Security
|
|
77
97
|
|
|
78
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.
|
|
@@ -1,9 +1,11 @@
|
|
|
1
|
+
from functools import cached_property
|
|
1
2
|
from wizlib.app import WizApp
|
|
2
3
|
from wizlib.stream_handler import StreamHandler
|
|
3
4
|
from wizlib.config_handler import ConfigHandler
|
|
4
5
|
from wizlib.ui_handler import UIHandler
|
|
5
6
|
|
|
6
7
|
from dyngle.command import DyngleCommand
|
|
8
|
+
from dyngle.expression import expression
|
|
7
9
|
|
|
8
10
|
|
|
9
11
|
class DyngleApp(WizApp):
|
|
@@ -11,3 +13,11 @@ class DyngleApp(WizApp):
|
|
|
11
13
|
base = DyngleCommand
|
|
12
14
|
name = 'dyngle'
|
|
13
15
|
handlers = [StreamHandler, ConfigHandler, UIHandler]
|
|
16
|
+
|
|
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 {}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import shlex
|
|
2
|
+
import subprocess
|
|
3
|
+
from wizlib.parser import WizParser
|
|
4
|
+
from yaml import safe_load
|
|
5
|
+
|
|
6
|
+
from dyngle.command import DyngleCommand
|
|
7
|
+
from dyngle.expression import expression
|
|
8
|
+
from dyngle.template import Template
|
|
9
|
+
from dyngle.error import DyngleError
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class RunCommand(DyngleCommand):
|
|
13
|
+
"""Run a workflow defined in the configuration"""
|
|
14
|
+
|
|
15
|
+
name = 'run'
|
|
16
|
+
|
|
17
|
+
@classmethod
|
|
18
|
+
def add_args(cls, parser: WizParser):
|
|
19
|
+
super().add_args(parser)
|
|
20
|
+
parser.add_argument('operation', help='Operation name to run')
|
|
21
|
+
|
|
22
|
+
def handle_vals(self):
|
|
23
|
+
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}')
|
|
32
|
+
|
|
33
|
+
@DyngleCommand.wrap
|
|
34
|
+
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
|
+
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
|
+
|
|
49
|
+
return f'Operation "{self.operation}" completed successfully'
|
|
@@ -0,0 +1,88 @@
|
|
|
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
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
import os
|
|
3
|
+
from typing import Union
|
|
4
|
+
|
|
5
|
+
CWD = Path.cwd()
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class SafePath:
|
|
9
|
+
"""A Path-like class that restricts operations to cwd and its
|
|
10
|
+
descendants"""
|
|
11
|
+
|
|
12
|
+
def __init__(self, path_str: Union[str, 'SafePath'] = "."):
|
|
13
|
+
|
|
14
|
+
self._cwd = CWD.resolve()
|
|
15
|
+
|
|
16
|
+
if isinstance(path_str, SafePath):
|
|
17
|
+
self._path = path_str._path
|
|
18
|
+
else:
|
|
19
|
+
# Convert to Path and resolve to handle .. and . components
|
|
20
|
+
try:
|
|
21
|
+
resolved_path = (self._cwd / path_str).resolve()
|
|
22
|
+
except (OSError, ValueError):
|
|
23
|
+
raise ValueError(f"Invalid path: {path_str}")
|
|
24
|
+
|
|
25
|
+
# Validate it's within cwd - could probably shorten this
|
|
26
|
+
if not resolved_path.is_relative_to(self._cwd):
|
|
27
|
+
raise ValueError(f"Path outside allowed directory: {path_str}")
|
|
28
|
+
|
|
29
|
+
self._path = resolved_path
|
|
30
|
+
|
|
31
|
+
def __str__(self):
|
|
32
|
+
return str(self._path.relative_to(self._cwd))
|
|
33
|
+
|
|
34
|
+
def __repr__(self):
|
|
35
|
+
return f"SafePath('{self}')"
|
|
36
|
+
|
|
37
|
+
def __truediv__(self, other):
|
|
38
|
+
"""Support path / 'subdir' syntax"""
|
|
39
|
+
new_path_str = str(self._path / str(other))
|
|
40
|
+
return SafePath(new_path_str)
|
|
41
|
+
|
|
42
|
+
# File content operations
|
|
43
|
+
def read_text(self, encoding='utf-8'):
|
|
44
|
+
if not self._path.is_file():
|
|
45
|
+
raise FileNotFoundError(f"File not found: {self}")
|
|
46
|
+
return self._path.read_text(encoding=encoding)
|
|
47
|
+
|
|
48
|
+
def write_text(self, content, encoding='utf-8'):
|
|
49
|
+
# Ensure parent directories exist
|
|
50
|
+
self._path.parent.mkdir(parents=True, exist_ok=True)
|
|
51
|
+
self._path.write_text(content, encoding=encoding)
|
|
52
|
+
return self
|
|
53
|
+
|
|
54
|
+
def read_bytes(self):
|
|
55
|
+
if not self._path.is_file():
|
|
56
|
+
raise FileNotFoundError(f"File not found: {self}")
|
|
57
|
+
return self._path.read_bytes()
|
|
58
|
+
|
|
59
|
+
def write_bytes(self, data):
|
|
60
|
+
self._path.parent.mkdir(parents=True, exist_ok=True)
|
|
61
|
+
self._path.write_bytes(data)
|
|
62
|
+
return self
|
|
63
|
+
|
|
64
|
+
# Path properties and checks
|
|
65
|
+
def exists(self):
|
|
66
|
+
return self._path.exists()
|
|
67
|
+
|
|
68
|
+
def is_file(self):
|
|
69
|
+
return self._path.is_file()
|
|
70
|
+
|
|
71
|
+
def is_dir(self):
|
|
72
|
+
return self._path.is_dir()
|
|
73
|
+
|
|
74
|
+
def is_absolute(self):
|
|
75
|
+
return self._path.is_absolute()
|
|
76
|
+
|
|
77
|
+
@property
|
|
78
|
+
def name(self):
|
|
79
|
+
return self._path.name
|
|
80
|
+
|
|
81
|
+
@property
|
|
82
|
+
def stem(self):
|
|
83
|
+
return self._path.stem
|
|
84
|
+
|
|
85
|
+
@property
|
|
86
|
+
def suffix(self):
|
|
87
|
+
return self._path.suffix
|
|
88
|
+
|
|
89
|
+
@property
|
|
90
|
+
def parent(self):
|
|
91
|
+
return SafePath(str(self._path.parent))
|
|
92
|
+
|
|
93
|
+
# Directory operations
|
|
94
|
+
def mkdir(self, parents=True, exist_ok=True):
|
|
95
|
+
self._path.mkdir(parents=parents, exist_ok=exist_ok)
|
|
96
|
+
return self
|
|
97
|
+
|
|
98
|
+
def iterdir(self):
|
|
99
|
+
if not self._path.is_dir():
|
|
100
|
+
raise NotADirectoryError(f"Not a directory: {self}")
|
|
101
|
+
return [SafePath(str(p)) for p in self._path.iterdir()]
|
|
102
|
+
|
|
103
|
+
def glob(self, pattern):
|
|
104
|
+
return [SafePath(str(p)) for p in self._path.glob(pattern)]
|
|
105
|
+
|
|
106
|
+
def rglob(self, pattern):
|
|
107
|
+
return [SafePath(str(p)) for p in self._path.rglob(pattern)]
|
|
108
|
+
|
|
109
|
+
# File metadata
|
|
110
|
+
def stat(self):
|
|
111
|
+
return self._path.stat()
|
|
112
|
+
|
|
113
|
+
@property
|
|
114
|
+
def size(self):
|
|
115
|
+
if not self._path.is_file():
|
|
116
|
+
raise FileNotFoundError(f"File not found: {self}")
|
|
117
|
+
return self._path.stat().st_size
|
|
@@ -0,0 +1,29 @@
|
|
|
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,52 +0,0 @@
|
|
|
1
|
-
import shlex
|
|
2
|
-
import subprocess
|
|
3
|
-
from wizlib.parser import WizParser
|
|
4
|
-
|
|
5
|
-
from dyngle.command import DyngleCommand
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
class RunCommand(DyngleCommand):
|
|
9
|
-
"""Run a workflow defined in the configuration"""
|
|
10
|
-
|
|
11
|
-
name = 'run'
|
|
12
|
-
|
|
13
|
-
@classmethod
|
|
14
|
-
def add_args(cls, parser: WizParser):
|
|
15
|
-
super().add_args(parser)
|
|
16
|
-
parser.add_argument('flow', help='Operation name to run')
|
|
17
|
-
|
|
18
|
-
def handle_vals(self):
|
|
19
|
-
super().handle_vals()
|
|
20
|
-
if not self.provided('flow'):
|
|
21
|
-
self.flow = self.app.ui.get_input('Enter flow name: ')
|
|
22
|
-
|
|
23
|
-
def _validate_flow_exists(self, operations):
|
|
24
|
-
"""Validate that the requested flow exists in configuration"""
|
|
25
|
-
if not operations:
|
|
26
|
-
raise RuntimeError('No operations configured')
|
|
27
|
-
|
|
28
|
-
if self.flow not in operations:
|
|
29
|
-
available_operations = ', '.join(operations.keys())
|
|
30
|
-
raise RuntimeError(
|
|
31
|
-
f'Operation "{self.flow}" not found. " + \
|
|
32
|
-
f"Available operations: {available_operations}')
|
|
33
|
-
|
|
34
|
-
def _execute_task(self, task_str):
|
|
35
|
-
"""Execute a single task and handle errors"""
|
|
36
|
-
task_parts = shlex.split(task_str)
|
|
37
|
-
result = subprocess.run(task_parts)
|
|
38
|
-
|
|
39
|
-
if result.returncode != 0:
|
|
40
|
-
raise RuntimeError(
|
|
41
|
-
f'Task failed with code {result.returncode}: {task_str}')
|
|
42
|
-
|
|
43
|
-
@DyngleCommand.wrap
|
|
44
|
-
def execute(self):
|
|
45
|
-
operations = self.app.config.get('dyngle-operations')
|
|
46
|
-
self._validate_flow_exists(operations)
|
|
47
|
-
|
|
48
|
-
tasks = operations[self.flow]
|
|
49
|
-
for task_str in tasks:
|
|
50
|
-
self._execute_task(task_str)
|
|
51
|
-
|
|
52
|
-
return f'Operation "{self.flow}" completed successfully'
|
|
File without changes
|