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.

@@ -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.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,2 @@
1
+ class DyngleError(Exception):
2
+ pass
@@ -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
- path = match.group(1).strip()
22
- # Try an expression first, then data
23
- if False:
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 = path.split('.')
25
+ parts = key.split('.')
27
26
  current = data
28
27
  for part in parts:
29
28
  current = current[part]
@@ -8,7 +8,7 @@ description = "Run lightweight local workflows"
8
8
  authors = ["Steampunk Wizard <dyngle@steamwiz.io>"]
9
9
  license = "MIT"
10
10
  readme = "PACKAGE.md"
11
- version = "0.3.0"
11
+ version = "0.4.0"
12
12
 
13
13
  [tool.poetry.dependencies]
14
14
  python = "~3.11"
File without changes