dyngle 0.3.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.3.0 → dyngle-0.4.0}/PACKAGE.md +20 -0
- {dyngle-0.3.0 → dyngle-0.4.0}/PKG-INFO +21 -1
- {dyngle-0.3.0 → dyngle-0.4.0}/dyngle/__init__.py +10 -4
- {dyngle-0.3.0 → dyngle-0.4.0}/dyngle/command/run_command.py +4 -2
- 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.3.0 → dyngle-0.4.0}/dyngle/template.py +7 -8
- {dyngle-0.3.0 → dyngle-0.4.0}/pyproject.toml +1 -1
- {dyngle-0.3.0 → dyngle-0.4.0}/dyngle/__main__.py +0 -0
- {dyngle-0.3.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,13 +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
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
class DyngleError(Exception):
|
|
10
|
-
pass
|
|
8
|
+
from dyngle.expression import expression
|
|
11
9
|
|
|
12
10
|
|
|
13
11
|
class DyngleApp(WizApp):
|
|
@@ -15,3 +13,11 @@ class DyngleApp(WizApp):
|
|
|
15
13
|
base = DyngleCommand
|
|
16
14
|
name = 'dyngle'
|
|
17
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 {}
|
|
@@ -4,8 +4,9 @@ from wizlib.parser import WizParser
|
|
|
4
4
|
from yaml import safe_load
|
|
5
5
|
|
|
6
6
|
from dyngle.command import DyngleCommand
|
|
7
|
+
from dyngle.expression import expression
|
|
7
8
|
from dyngle.template import Template
|
|
8
|
-
from dyngle import DyngleError
|
|
9
|
+
from dyngle.error import DyngleError
|
|
9
10
|
|
|
10
11
|
|
|
11
12
|
class RunCommand(DyngleCommand):
|
|
@@ -31,13 +32,14 @@ class RunCommand(DyngleCommand):
|
|
|
31
32
|
|
|
32
33
|
@DyngleCommand.wrap
|
|
33
34
|
def execute(self):
|
|
35
|
+
expressions = self.app.expressions
|
|
34
36
|
operations = self.app.config.get('dyngle-operations')
|
|
35
37
|
self._validate_operation_exists(operations)
|
|
36
38
|
steps = operations[self.operation]
|
|
37
39
|
data_string = self.app.stream.text
|
|
38
40
|
data = safe_load(data_string)
|
|
39
41
|
for step_template in steps:
|
|
40
|
-
step = Template(step_template).render(data)
|
|
42
|
+
step = Template(step_template).render(data, expressions)
|
|
41
43
|
parts = shlex.split(step)
|
|
42
44
|
result = subprocess.run(parts)
|
|
43
45
|
if result.returncode != 0:
|
|
@@ -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
|
|
@@ -11,19 +11,18 @@ class Template:
|
|
|
11
11
|
|
|
12
12
|
template: str
|
|
13
13
|
|
|
14
|
-
def render(self, data):
|
|
14
|
+
def render(self, data: dict, expressions: dict = None) -> str:
|
|
15
15
|
"""Render the template with the provided data."""
|
|
16
|
-
resolver = partial(self._resolve, data=data)
|
|
16
|
+
resolver = partial(self._resolve, data=data, expressions=expressions)
|
|
17
17
|
return PATTERN.sub(resolver, self.template)
|
|
18
18
|
|
|
19
|
-
def _resolve(self, match, *, data):
|
|
19
|
+
def _resolve(self, match, *, data: dict, expressions: dict):
|
|
20
20
|
"""Resolve a single name/path from the template."""
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
pass
|
|
21
|
+
key = match.group(1).strip()
|
|
22
|
+
if expressions and (key in expressions):
|
|
23
|
+
return expressions[key](data)
|
|
25
24
|
else:
|
|
26
|
-
parts =
|
|
25
|
+
parts = key.split('.')
|
|
27
26
|
current = data
|
|
28
27
|
for part in parts:
|
|
29
28
|
current = current[part]
|
|
File without changes
|
|
File without changes
|