ry-tool 1.0.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.
ry_tool/executor.py ADDED
@@ -0,0 +1,475 @@
1
+ """
2
+ Command executor with multiple language support.
3
+
4
+ Purpose: Execute commands via subprocess without shell escaping issues.
5
+ Supports shell, python, subprocess, and other language execution.
6
+ No YAML knowledge, just pure execution.
7
+ """
8
+ import subprocess
9
+ import sys
10
+ import os
11
+ import json
12
+ from typing import Dict, Any, List, Optional
13
+ from dataclasses import dataclass
14
+ from io import StringIO
15
+
16
+
17
+ @dataclass
18
+ class ExecutionResult:
19
+ """Result of executing a command."""
20
+ success: bool
21
+ stdout: str = ""
22
+ stderr: str = ""
23
+ returncode: int = 0
24
+ captured_var: Optional[str] = None
25
+ modifications: Optional[Dict[str, Any]] = None
26
+
27
+
28
+ class Executor:
29
+ """
30
+ Execute commands in various modes without shell escaping issues.
31
+
32
+ Key innovation: Pass strings directly to subprocess, no encoding needed.
33
+ """
34
+
35
+ def __init__(self, context: Any = None):
36
+ """
37
+ Initialize executor with execution context.
38
+
39
+ Args:
40
+ context: ExecutionContext object or dict with variables
41
+ """
42
+ # Accept both ExecutionContext objects and dicts
43
+ if hasattr(context, 'to_dict'):
44
+ # It's an ExecutionContext, convert to dict for internal use
45
+ self.context = context.to_dict()
46
+ else:
47
+ self.context = context or {}
48
+ self.dry_run = False
49
+ self.debug = False
50
+
51
+ def execute_step(self, step: Dict[str, Any], extra_env: Dict[str, str] = None) -> ExecutionResult:
52
+ """
53
+ Execute a single step based on its type.
54
+
55
+ Args:
56
+ step: Step definition with type and code/command
57
+ extra_env: Additional environment variables
58
+
59
+ Returns:
60
+ ExecutionResult
61
+ """
62
+ # Merge environment
63
+ env = step.get('env', {})
64
+ if extra_env:
65
+ env.update(extra_env)
66
+
67
+ if 'shell' in step:
68
+ return self.execute_shell(step['shell'], env,
69
+ interactive=step.get('interactive', False),
70
+ capture_file=step.get('_capture_file'))
71
+ elif 'python' in step:
72
+ return self.execute_python(step['python'])
73
+ elif 'subprocess' in step:
74
+ return self.execute_subprocess(step['subprocess'])
75
+ elif 'ruby' in step:
76
+ return self.execute_ruby(step['ruby'])
77
+ else:
78
+ raise ValueError(f"Unknown step type: {step.keys()}")
79
+
80
+ def execute_shell(self, command: str, env: Dict[str, str] = None,
81
+ interactive: bool = False, capture_file: str = None) -> ExecutionResult:
82
+ """
83
+ Execute shell command.
84
+
85
+ Args:
86
+ command: Shell command to execute
87
+ env: Additional environment variables
88
+ interactive: If True, preserve TTY for interactive tools
89
+ capture_file: If provided with interactive, redirect output here
90
+ """
91
+ if self.dry_run:
92
+ return ExecutionResult(
93
+ success=True,
94
+ stdout=f"[DRY RUN] Would execute: {command}"
95
+ )
96
+
97
+ # Merge environment
98
+ exec_env = os.environ.copy()
99
+ if env:
100
+ exec_env.update(env)
101
+
102
+ # Add context vars to environment
103
+ for key, value in self.context.items():
104
+ if isinstance(value, (str, int, float, bool)):
105
+ exec_env[f'RY_{key.upper()}'] = str(value)
106
+
107
+ try:
108
+ if interactive:
109
+ # Interactive mode - preserve TTY
110
+ if capture_file:
111
+ # Pass capture file as environment variable
112
+ exec_env['RY_CAPTURE_FILE'] = capture_file
113
+
114
+ # Run WITHOUT capture_output to preserve TTY
115
+ result = subprocess.run(
116
+ command,
117
+ shell=True,
118
+ env=exec_env
119
+ )
120
+
121
+ return ExecutionResult(
122
+ success=result.returncode == 0,
123
+ stdout="", # Empty - output went to TTY or file
124
+ stderr="",
125
+ returncode=result.returncode
126
+ )
127
+ else:
128
+ # Normal mode with capture
129
+ result = subprocess.run(
130
+ command,
131
+ shell=True,
132
+ capture_output=True,
133
+ text=True,
134
+ env=exec_env
135
+ )
136
+
137
+ return ExecutionResult(
138
+ success=result.returncode == 0,
139
+ stdout=result.stdout,
140
+ stderr=result.stderr,
141
+ returncode=result.returncode
142
+ )
143
+ except Exception as e:
144
+ return ExecutionResult(
145
+ success=False,
146
+ stderr=str(e),
147
+ returncode=1
148
+ )
149
+
150
+ def execute_relay(self, target: str, args: List[str], env: Dict[str, str] = None) -> ExecutionResult:
151
+ """
152
+ Relay execution to native command.
153
+
154
+ Args:
155
+ target: Target binary path (e.g., /usr/bin/git)
156
+ args: Command arguments to pass
157
+ env: Additional environment variables
158
+
159
+ Returns:
160
+ ExecutionResult
161
+ """
162
+ if self.dry_run:
163
+ return ExecutionResult(
164
+ success=True,
165
+ stdout=f"[DRY RUN] Would relay to: {target} {' '.join(args)}"
166
+ )
167
+
168
+ # Merge environment
169
+ exec_env = os.environ.copy()
170
+ if env:
171
+ exec_env.update(env)
172
+
173
+ # Add context vars to environment
174
+ for key, value in self.context.items():
175
+ if isinstance(value, (str, int, float, bool)):
176
+ exec_env[f'RY_{key.upper()}'] = str(value)
177
+
178
+ try:
179
+ # Direct execution without shell, preserves TTY
180
+ result = subprocess.run(
181
+ [target] + args,
182
+ env=exec_env,
183
+ capture_output=False, # Direct TTY passthrough
184
+ text=True
185
+ )
186
+
187
+ return ExecutionResult(
188
+ success=result.returncode == 0,
189
+ stdout="", # Output went directly to TTY
190
+ stderr="",
191
+ returncode=result.returncode
192
+ )
193
+ except FileNotFoundError:
194
+ return ExecutionResult(
195
+ success=False,
196
+ stderr=f"Command not found: {target}",
197
+ returncode=127
198
+ )
199
+ except Exception as e:
200
+ return ExecutionResult(
201
+ success=False,
202
+ stderr=str(e),
203
+ returncode=1
204
+ )
205
+
206
+ def execute_python(self, code: str) -> ExecutionResult:
207
+ """
208
+ Execute Python code with context.
209
+
210
+ Code has access to context variables and can modify them.
211
+ Modifications are tracked and returned in the ExecutionResult.
212
+ """
213
+ if self.dry_run:
214
+ return ExecutionResult(
215
+ success=True,
216
+ stdout="[DRY RUN] Would execute Python code"
217
+ )
218
+
219
+ # Helper to safely copy dict/list values
220
+ def safe_copy(value, default):
221
+ if isinstance(value, dict):
222
+ return value.copy()
223
+ elif isinstance(value, list):
224
+ return value.copy()
225
+ return default
226
+
227
+ # Store original values to detect modifications
228
+ original_flags = safe_copy(self.context.get('flags'), {})
229
+ original_arguments = safe_copy(self.context.get('arguments'), {})
230
+ original_env = safe_copy(self.context.get('env'), {})
231
+ original_captured = safe_copy(self.context.get('captured'), {})
232
+
233
+ # Prepare execution environment with mutable references
234
+ exec_globals = {
235
+ 'sys': sys,
236
+ 'os': os,
237
+ 'subprocess': subprocess,
238
+ 'json': json,
239
+ 'flags': safe_copy(self.context.get('flags'), {}),
240
+ 'arguments': safe_copy(self.context.get('arguments'), {}),
241
+ 'env': safe_copy(self.context.get('env'), {}),
242
+ 'captured': safe_copy(self.context.get('captured'), {}),
243
+ 'remaining_args': self.context.get('remaining_args', []),
244
+ 'positionals': safe_copy(self.context.get('positionals'), []),
245
+ 'remaining': safe_copy(self.context.get('remaining'), []),
246
+ 'context': self.context
247
+ }
248
+
249
+ # Capture stdout/stderr
250
+ old_stdout = sys.stdout
251
+ old_stderr = sys.stderr
252
+ stdout_capture = StringIO()
253
+ stderr_capture = StringIO()
254
+
255
+ try:
256
+ sys.stdout = stdout_capture
257
+ sys.stderr = stderr_capture
258
+
259
+ exec(code, exec_globals)
260
+
261
+ # Track all modifications
262
+ modifications = {}
263
+
264
+ # Check for flag modifications
265
+ if 'flags' in exec_globals and exec_globals['flags'] != original_flags:
266
+ modifications['flags'] = exec_globals['flags']
267
+
268
+ # Check for argument modifications
269
+ if 'arguments' in exec_globals and exec_globals['arguments'] != original_arguments:
270
+ modifications['arguments'] = exec_globals['arguments']
271
+
272
+ # Check for environment modifications
273
+ if 'env' in exec_globals and exec_globals['env'] != original_env:
274
+ modifications['env'] = exec_globals['env']
275
+
276
+ # Check for captured variable modifications
277
+ if 'captured' in exec_globals and exec_globals['captured'] != original_captured:
278
+ modifications['captured'] = exec_globals['captured']
279
+
280
+ # Check for positionals modifications
281
+ if 'positionals' in exec_globals and exec_globals['positionals'] != self.context.get('positionals', []):
282
+ modifications['positionals'] = exec_globals['positionals']
283
+
284
+ # Check for remaining modifications
285
+ if 'remaining' in exec_globals and exec_globals['remaining'] != self.context.get('remaining', []):
286
+ modifications['remaining'] = exec_globals['remaining']
287
+
288
+ return ExecutionResult(
289
+ success=True,
290
+ stdout=stdout_capture.getvalue(),
291
+ stderr=stderr_capture.getvalue(),
292
+ returncode=0,
293
+ modifications=modifications if modifications else None
294
+ )
295
+ except SystemExit as e:
296
+ # Even on exit, track modifications
297
+ modifications = {}
298
+ if 'flags' in exec_globals and exec_globals['flags'] != original_flags:
299
+ modifications['flags'] = exec_globals['flags']
300
+ if 'arguments' in exec_globals and exec_globals['arguments'] != original_arguments:
301
+ modifications['arguments'] = exec_globals['arguments']
302
+ if 'env' in exec_globals and exec_globals['env'] != original_env:
303
+ modifications['env'] = exec_globals['env']
304
+ if 'captured' in exec_globals and exec_globals['captured'] != original_captured:
305
+ modifications['captured'] = exec_globals['captured']
306
+
307
+ return ExecutionResult(
308
+ success=e.code == 0,
309
+ stdout=stdout_capture.getvalue(),
310
+ stderr=stderr_capture.getvalue(),
311
+ returncode=e.code or 0,
312
+ modifications=modifications if modifications else None
313
+ )
314
+ except Exception as e:
315
+ return ExecutionResult(
316
+ success=False,
317
+ stdout=stdout_capture.getvalue(),
318
+ stderr=stderr_capture.getvalue() + str(e),
319
+ returncode=1,
320
+ modifications=None
321
+ )
322
+ finally:
323
+ sys.stdout = old_stdout
324
+ sys.stderr = old_stderr
325
+
326
+ def execute_subprocess(self, config: Dict[str, Any]) -> ExecutionResult:
327
+ """
328
+ Execute with fine-grained subprocess control.
329
+
330
+ Args:
331
+ config: {
332
+ 'cmd': ['git', 'commit', '-m', 'message'],
333
+ 'env': {'KEY': 'value'},
334
+ 'cwd': '/path/to/dir',
335
+ 'stdin': 'input data',
336
+ 'capture': 'stdout' # or 'stderr' or 'both'
337
+ }
338
+ """
339
+ if self.dry_run:
340
+ return ExecutionResult(
341
+ success=True,
342
+ stdout=f"[DRY RUN] Would execute: {config.get('cmd', [])}"
343
+ )
344
+
345
+ cmd = config.get('cmd', [])
346
+ if not cmd:
347
+ return ExecutionResult(success=False, stderr="No command specified")
348
+
349
+ # Prepare subprocess arguments
350
+ kwargs = {
351
+ 'capture_output': config.get('capture', True),
352
+ 'text': True
353
+ }
354
+
355
+ if 'env' in config:
356
+ exec_env = os.environ.copy()
357
+ exec_env.update(config['env'])
358
+ kwargs['env'] = exec_env
359
+
360
+ if 'cwd' in config:
361
+ kwargs['cwd'] = config['cwd']
362
+
363
+ if 'stdin' in config:
364
+ kwargs['input'] = config['stdin']
365
+
366
+ try:
367
+ result = subprocess.run(cmd, **kwargs)
368
+
369
+ return ExecutionResult(
370
+ success=result.returncode == 0,
371
+ stdout=result.stdout if result.stdout else "",
372
+ stderr=result.stderr if result.stderr else "",
373
+ returncode=result.returncode
374
+ )
375
+ except Exception as e:
376
+ return ExecutionResult(
377
+ success=False,
378
+ stderr=str(e),
379
+ returncode=1
380
+ )
381
+
382
+ def execute_ruby(self, code: str) -> ExecutionResult:
383
+ """Execute Ruby code."""
384
+ if self.dry_run:
385
+ return ExecutionResult(
386
+ success=True,
387
+ stdout="[DRY RUN] Would execute Ruby code"
388
+ )
389
+
390
+ # Write context as JSON for Ruby to read
391
+ context_json = json.dumps(self.context)
392
+
393
+ # Ruby wrapper to load context
394
+ ruby_code = f"""
395
+ require 'json'
396
+ context = JSON.parse('{context_json}')
397
+ flags = context['flags'] || {{}}
398
+ env = context['env'] || {{}}
399
+
400
+ {code}
401
+ """
402
+
403
+ try:
404
+ result = subprocess.run(
405
+ ['ruby', '-e', ruby_code],
406
+ capture_output=True,
407
+ text=True
408
+ )
409
+
410
+ return ExecutionResult(
411
+ success=result.returncode == 0,
412
+ stdout=result.stdout,
413
+ stderr=result.stderr,
414
+ returncode=result.returncode
415
+ )
416
+ except FileNotFoundError:
417
+ return ExecutionResult(
418
+ success=False,
419
+ stderr="Ruby not found",
420
+ returncode=127
421
+ )
422
+ except Exception as e:
423
+ return ExecutionResult(
424
+ success=False,
425
+ stderr=str(e),
426
+ returncode=1
427
+ )
428
+
429
+ def show_execution_plan(self, steps: List[Dict[str, Any]]) -> str:
430
+ """
431
+ Show what would be executed (for --ry-run mode).
432
+
433
+ Returns formatted execution plan.
434
+ """
435
+ plan = ["=== Execution Plan ==="]
436
+
437
+ for i, step in enumerate(steps, 1):
438
+ if 'shell' in step:
439
+ cmd = step['shell'].strip().replace('\n', ' ')[:80]
440
+ plan.append(f"{i}. Shell: {cmd}{'...' if len(step['shell']) > 80 else ''}")
441
+
442
+ elif 'python' in step:
443
+ lines = step['python'].strip().split('\n')
444
+ first_line = lines[0][:60] if lines else "<empty>"
445
+ plan.append(f"{i}. Python: {first_line}{'...' if len(lines) > 1 else ''}")
446
+
447
+ elif 'subprocess' in step:
448
+ cmd = step['subprocess'].get('cmd', [])
449
+ plan.append(f"{i}. Subprocess: {' '.join(cmd)}")
450
+
451
+ elif 'ruby' in step:
452
+ lines = step['ruby'].strip().split('\n')
453
+ first_line = lines[0][:60] if lines else "<empty>"
454
+ plan.append(f"{i}. Ruby: {first_line}{'...' if len(lines) > 1 else ''}")
455
+
456
+ elif 'relay' in step and step['relay'] == 'native':
457
+ target = self.context.get('target', 'native-command')
458
+ plan.append(f"{i}. Relay: {target}")
459
+
460
+ elif 'require' in step:
461
+ plan.append(f"{i}. Require: {step['require']}")
462
+ if 'error' in step:
463
+ plan.append(f" Error if missing: {step['error'][:60]}...")
464
+
465
+ elif 'capture' in step:
466
+ plan.append(f"{i}. Capture: {step['capture']}")
467
+ if 'shell' in step:
468
+ cmd = step['shell'].strip()[:60]
469
+ plan.append(f" Via shell: {cmd}...")
470
+
471
+ elif 'error' in step:
472
+ plan.append(f"{i}. Error: {step['error']}")
473
+
474
+ return "\n".join(plan)
475
+
ry_tool/installer.py ADDED
@@ -0,0 +1,176 @@
1
+ """
2
+ Library installer and manager.
3
+
4
+ Purpose: Install libraries from various sources to user directory.
5
+ Handles copying library files and dependencies.
6
+ """
7
+ import os
8
+ import shutil
9
+ import json
10
+ from pathlib import Path
11
+ from typing import Dict
12
+ from .loader import LibraryLoader, LibraryConfig
13
+
14
+
15
+ class LibraryInstaller:
16
+ """Handles library installation to user directory."""
17
+
18
+ def __init__(self):
19
+ """Initialize installer with user install directory."""
20
+ xdg_data = os.environ.get("XDG_DATA_HOME", Path.home() / ".local/share")
21
+ self.install_dir = Path(xdg_data) / "ry-next" / "libraries"
22
+ self.install_dir.mkdir(parents=True, exist_ok=True)
23
+
24
+ # Track installed libraries
25
+ self.installed_file = self.install_dir.parent / "installed.json"
26
+ self.installed = self._load_installed()
27
+
28
+ def _load_installed(self) -> Dict:
29
+ """Load installed libraries tracking."""
30
+ if self.installed_file.exists():
31
+ with open(self.installed_file) as f:
32
+ return json.load(f)
33
+ return {}
34
+
35
+ def _save_installed(self):
36
+ """Save installed libraries tracking."""
37
+ with open(self.installed_file, 'w') as f:
38
+ json.dump(self.installed, f, indent=2)
39
+
40
+ def install_local(self, library: LibraryConfig) -> bool:
41
+ """
42
+ Install from local file to user directory.
43
+
44
+ Args:
45
+ library: LibraryConfig from a local source
46
+
47
+ Returns:
48
+ True if successful
49
+ """
50
+ target_dir = self.install_dir / library.name
51
+ target_dir.mkdir(exist_ok=True)
52
+
53
+ try:
54
+ # Copy library file
55
+ target_yaml = target_dir / f"{library.name}.yaml"
56
+ shutil.copy2(library.path, target_yaml)
57
+
58
+ # If library is in directory format, copy additional files
59
+ if library.path.parent.name == library.name:
60
+ source_dir = library.path.parent
61
+
62
+ # Copy lib/ directory if exists
63
+ lib_dir = source_dir / 'lib'
64
+ if lib_dir.exists():
65
+ target_lib = target_dir / 'lib'
66
+ if target_lib.exists():
67
+ shutil.rmtree(target_lib)
68
+ shutil.copytree(lib_dir, target_lib)
69
+
70
+ # Copy meta.yaml if exists
71
+ meta_file = source_dir / 'meta.yaml'
72
+ if meta_file.exists():
73
+ shutil.copy2(meta_file, target_dir / 'meta.yaml')
74
+
75
+ # Copy README.md if exists
76
+ readme = source_dir / 'README.md'
77
+ if readme.exists():
78
+ shutil.copy2(readme, target_dir / 'README.md')
79
+
80
+ # Update installed tracking
81
+ self.installed[library.name] = {
82
+ 'version': library.metadata.get('version', '0.0.0'),
83
+ 'source': str(library.path),
84
+ 'type': library.type
85
+ }
86
+ self._save_installed()
87
+
88
+ return True
89
+
90
+ except Exception as e:
91
+ print(f"Installation failed: {e}")
92
+ return False
93
+
94
+ def install_from_registry(self, library_name: str, version: str = None) -> bool:
95
+ """
96
+ Install from online registry.
97
+
98
+ Args:
99
+ library_name: Name of library to install
100
+ version: Optional version constraint
101
+
102
+ Returns:
103
+ True if successful
104
+ """
105
+ # For now, try to find in local development directories
106
+ loader = LibraryLoader()
107
+
108
+ try:
109
+ # Try to load from existing sources
110
+ library = loader.load(library_name)
111
+
112
+ # Install to user directory
113
+ return self.install_local(library)
114
+
115
+ except FileNotFoundError:
116
+ # Future: Download from online registry
117
+ print(f"Library '{library_name}' not found in local sources")
118
+ print("Online registry support coming soon!")
119
+ return False
120
+
121
+ def uninstall(self, library_name: str) -> bool:
122
+ """
123
+ Uninstall a library.
124
+
125
+ Args:
126
+ library_name: Name of library to remove
127
+
128
+ Returns:
129
+ True if successful
130
+ """
131
+ library_dir = self.install_dir / library_name
132
+
133
+ if not library_dir.exists():
134
+ print(f"Library '{library_name}' is not installed")
135
+ return False
136
+
137
+ try:
138
+ shutil.rmtree(library_dir)
139
+
140
+ # Update tracking
141
+ if library_name in self.installed:
142
+ del self.installed[library_name]
143
+ self._save_installed()
144
+
145
+ print(f"Uninstalled {library_name}")
146
+ return True
147
+
148
+ except Exception as e:
149
+ print(f"Failed to uninstall: {e}")
150
+ return False
151
+
152
+ def list_installed(self) -> Dict[str, Dict]:
153
+ """
154
+ List installed libraries.
155
+
156
+ Returns:
157
+ Dictionary of installed libraries with metadata
158
+ """
159
+ return self.installed
160
+
161
+ def update(self, library_name: str) -> bool:
162
+ """
163
+ Update an installed library.
164
+
165
+ Args:
166
+ library_name: Name of library to update
167
+
168
+ Returns:
169
+ True if successful
170
+ """
171
+ if library_name not in self.installed:
172
+ print(f"Library '{library_name}' is not installed")
173
+ return False
174
+
175
+ # Re-install from source
176
+ return self.install_from_registry(library_name)