dyngle 0.2.0__py3-none-any.whl → 0.4.0__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,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 {}
dyngle/__main__.py CHANGED
@@ -1,4 +1,3 @@
1
- from . import DyngleApp
2
-
3
1
  if __name__ == '__main__': # pragma: nocover
2
+ from . import DyngleApp
4
3
  DyngleApp.main()
@@ -1,8 +1,12 @@
1
1
  import shlex
2
2
  import subprocess
3
3
  from wizlib.parser import WizParser
4
+ from yaml import safe_load
4
5
 
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
6
10
 
7
11
 
8
12
  class RunCommand(DyngleCommand):
@@ -13,40 +17,33 @@ class RunCommand(DyngleCommand):
13
17
  @classmethod
14
18
  def add_args(cls, parser: WizParser):
15
19
  super().add_args(parser)
16
- parser.add_argument('flow', help='Operation name to run')
20
+ parser.add_argument('operation', help='Operation name to run')
17
21
 
18
22
  def handle_vals(self):
19
23
  super().handle_vals()
20
- if not self.provided('flow'):
21
- self.flow = self.app.ui.get_input('Enter flow name: ')
22
24
 
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:
25
+ def _validate_operation_exists(self, operations):
26
+ """Validate that the requested operation exists in configuration"""
27
+ if self.operation not in operations:
29
28
  available_operations = ', '.join(operations.keys())
30
- raise RuntimeError(
31
- f'Operation "{self.flow}" not found. " + \
29
+ raise DyngleError(
30
+ f'Operation "{self.operation}" not found. " + \
32
31
  f"Available operations: {available_operations}')
33
32
 
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
33
  @DyngleCommand.wrap
44
34
  def execute(self):
35
+ expressions = self.app.expressions
45
36
  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'
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'
dyngle/error.py ADDED
@@ -0,0 +1,2 @@
1
+ class DyngleError(Exception):
2
+ pass
dyngle/expression.py ADDED
@@ -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
dyngle/safe_path.py ADDED
@@ -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
dyngle/template.py ADDED
@@ -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,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: dyngle
3
- Version: 0.2.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.
@@ -0,0 +1,12 @@
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,,
@@ -1,8 +0,0 @@
1
- dyngle/__init__.py,sha256=2KD52sWHfMhk1g1wxHdnxjRtEKeYNJn7sTPkKRpSqNA,338
2
- dyngle/__main__.py,sha256=mXOQ5tiUi5mEfp1NG2viz5kW2DEeWg1oCPFhfXgxJ4U,92
3
- dyngle/command/__init__.py,sha256=1S86gbef8MYvG-TWD5JRIWzFg7qV5xKhp9QXx9zEx5c,94
4
- dyngle/command/run_command.py,sha256=UbkzqlrbAkxQSbN8lHBaTaV23xYmVBlGEOZTiiuARjo,1667
5
- dyngle-0.2.0.dist-info/METADATA,sha256=naF-xfNR-gcqrJGOOur2pM0gX5tRTwbrTcqfePuhntQ,2288
6
- dyngle-0.2.0.dist-info/WHEEL,sha256=b4K_helf-jlQoXBBETfwnf4B04YC67LOev0jo4fX5m8,88
7
- dyngle-0.2.0.dist-info/entry_points.txt,sha256=rekiGhtweiHKm9g1jdGb3FhzqDrk1kigJDeSNollZSA,48
8
- dyngle-0.2.0.dist-info/RECORD,,
File without changes