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/parser.py ADDED
@@ -0,0 +1,248 @@
1
+ """
2
+ Command line parser that understands semantic structure.
3
+
4
+ Purpose: Convert raw args like ['commit', '-m', 'msg', '--amend']
5
+ into structured data with flags, arguments, and commands.
6
+ No YAML knowledge, just pure argument parsing.
7
+ """
8
+ from dataclasses import dataclass, field
9
+ from typing import List, Dict, Any, Optional, Tuple
10
+
11
+
12
+ @dataclass
13
+ class ParsedCommand:
14
+ """Structured representation of a parsed command."""
15
+ command: str
16
+ subcommand: Optional[str] = None
17
+ flags: Dict[str, Any] = field(default_factory=dict)
18
+ positionals: List[str] = field(default_factory=list)
19
+ remaining: List[str] = field(default_factory=list) # After -- separator
20
+ raw_args: List[str] = field(default_factory=list) # Original args for reference
21
+
22
+
23
+ class CommandParser:
24
+ """
25
+ Parse command line arguments with flag schema awareness.
26
+
27
+ This parser understands:
28
+ - Flags with values: -m "message", --package backend
29
+ - Boolean flags: -a, --force
30
+ - Positional arguments
31
+ - Double dash separator: --
32
+ - Subcommands: remote add origin
33
+ """
34
+
35
+ def parse(self, args: List[str], schema: Dict[str, Any] = None) -> ParsedCommand:
36
+ """
37
+ Parse arguments with optional schema for flag types.
38
+
39
+ Args:
40
+ args: Raw command line arguments
41
+ schema: Optional schema defining flag types
42
+ {'m': 'string', 'force': 'bool', 'bump': 'enum'}
43
+
44
+ Returns:
45
+ ParsedCommand with structured data
46
+ """
47
+ if not args:
48
+ return ParsedCommand(command="", raw_args=args)
49
+
50
+ schema = schema or {}
51
+ result = ParsedCommand(command=args[0], raw_args=args.copy())
52
+
53
+ i = 1
54
+ while i < len(args):
55
+ arg = args[i]
56
+
57
+ # Double dash separator
58
+ if arg == '--':
59
+ result.remaining = args[i+1:]
60
+ break
61
+
62
+ # Flag (short or long)
63
+ if arg.startswith('-'):
64
+ flag_name, flag_value, consumed = self._parse_flag(args, i, schema)
65
+ if flag_name:
66
+ result.flags[flag_name] = flag_value
67
+ i += consumed
68
+ else:
69
+ # Positional argument
70
+ result.positionals.append(arg)
71
+ i += 1
72
+
73
+ # Check if first positional might be a subcommand
74
+ if result.positionals and not result.subcommand:
75
+ # This is a simple heuristic - could be enhanced
76
+ if result.command in ['git', 'docker', 'kubectl']:
77
+ result.subcommand = result.positionals.pop(0)
78
+
79
+ return result
80
+
81
+ def _parse_flag(self, args: List[str], index: int, schema: Dict) -> Tuple[str, Any, int]:
82
+ """
83
+ Parse a flag and its value if applicable.
84
+
85
+ Returns:
86
+ (flag_name, flag_value, args_consumed)
87
+ """
88
+ arg = args[index]
89
+
90
+ # Long flag with = (--message="hello")
91
+ if '--' in arg and '=' in arg:
92
+ flag_part, value = arg.split('=', 1)
93
+ flag_name = flag_part.lstrip('-')
94
+ return flag_name, value, 1
95
+
96
+ # Regular flag
97
+ flag_name = arg.lstrip('-')
98
+
99
+ # Check schema for flag type
100
+ flag_type = self._get_flag_type(flag_name, schema)
101
+
102
+ if flag_type == 'bool':
103
+ return flag_name, True, 1
104
+
105
+ # Flag takes a value - consume next arg
106
+ if index + 1 < len(args) and not args[index + 1].startswith('-'):
107
+ return flag_name, args[index + 1], 2
108
+
109
+ # Flag without value (treat as bool)
110
+ return flag_name, True, 1
111
+
112
+ def _get_flag_type(self, flag_name: str, schema: Dict) -> str:
113
+ """
114
+ Determine flag type from schema.
115
+
116
+ Handles aliases like m/message.
117
+ """
118
+ if not schema:
119
+ return 'string' # Default assumption
120
+
121
+ # Direct match
122
+ if flag_name in schema:
123
+ flag_def = schema[flag_name]
124
+ if isinstance(flag_def, str):
125
+ return flag_def
126
+ elif isinstance(flag_def, dict):
127
+ return flag_def.get('type', 'string')
128
+
129
+ # Check aliases (m/message format)
130
+ for key, value in schema.items():
131
+ if '/' in key:
132
+ short, long = key.split('/', 1)
133
+ if flag_name in [short, long]:
134
+ if isinstance(value, str):
135
+ return value
136
+ elif isinstance(value, dict):
137
+ return value.get('type', 'string')
138
+
139
+ return 'string' # Default
140
+
141
+ def parse_with_command_schema(self, args: List[str], command_schema: Dict) -> ParsedCommand:
142
+ """
143
+ Parse with knowledge of command structure.
144
+
145
+ Args:
146
+ args: Raw arguments
147
+ command_schema: Full command schema with flags and arguments definitions
148
+
149
+ Returns:
150
+ ParsedCommand with proper typing based on schema
151
+ """
152
+ # Extract flag schema from command schema
153
+ flag_schema = {}
154
+ if 'flags' in command_schema:
155
+ for flag_key, flag_def in command_schema['flags'].items():
156
+ if '/' in flag_key:
157
+ # Handle m/message style
158
+ short, long = flag_key.split('/', 1)
159
+ flag_schema[short] = flag_def
160
+ flag_schema[long] = flag_def
161
+ else:
162
+ flag_schema[flag_key] = flag_def
163
+
164
+ return self.parse(args, flag_schema)
165
+
166
+ def generate_help(self, library_config, command: str = None) -> str:
167
+ """
168
+ Generate help text for library or specific command.
169
+
170
+ Args:
171
+ library_config: LibraryConfig object with library metadata
172
+ command: Optional specific command to show help for
173
+
174
+ Returns:
175
+ Formatted help text
176
+ """
177
+ help_lines = []
178
+
179
+ if command and command in library_config.commands:
180
+ # Command-specific help
181
+ cmd = library_config.commands[command]
182
+ help_lines.append(f"{library_config.name} {command}")
183
+
184
+ if 'description' in cmd:
185
+ help_lines.append(f" {cmd['description']}")
186
+ help_lines.append("")
187
+
188
+ # Arguments
189
+ if 'arguments' in cmd:
190
+ help_lines.append("Arguments:")
191
+ for arg_name, arg_config in cmd['arguments'].items():
192
+ if isinstance(arg_config, str):
193
+ required = arg_config == 'required'
194
+ else:
195
+ required = arg_config.get('required', False)
196
+ desc = arg_config.get('description', '') if isinstance(arg_config, dict) else ''
197
+ status = 'required' if required else 'optional'
198
+ help_lines.append(f" {arg_name:<15} {status:<10} {desc}")
199
+ help_lines.append("")
200
+
201
+ # Flags
202
+ if 'flags' in cmd:
203
+ help_lines.append("Flags:")
204
+ for flag_name, flag_config in cmd['flags'].items():
205
+ # Handle alias format (m/message)
206
+ if '/' in flag_name:
207
+ short, long = flag_name.split('/', 1)
208
+ flag_display = f"-{short}, --{long}"
209
+ else:
210
+ flag_display = f"--{flag_name}"
211
+
212
+ if isinstance(flag_config, str):
213
+ flag_type = flag_config
214
+ desc = ''
215
+ else:
216
+ flag_type = flag_config.get('type', 'string')
217
+ desc = flag_config.get('description', '')
218
+
219
+ help_lines.append(f" {flag_display:<20} ({flag_type})")
220
+ if desc:
221
+ help_lines.append(f" {desc}")
222
+ help_lines.append("")
223
+
224
+ # Examples
225
+ if 'examples' in cmd:
226
+ help_lines.append("Examples:")
227
+ for example in cmd['examples']:
228
+ help_lines.append(f" {example}")
229
+ help_lines.append("")
230
+ else:
231
+ # Library help - list all commands
232
+ help_lines.append(f"{library_config.name} - {library_config.description}")
233
+ help_lines.append(f"Version: {library_config.version}")
234
+ help_lines.append(f"Type: {library_config.type}")
235
+ help_lines.append("")
236
+
237
+ if library_config.commands:
238
+ help_lines.append("Commands:")
239
+ for cmd_name, cmd_config in library_config.commands.items():
240
+ if cmd_name != '*': # Skip catch-all
241
+ desc = cmd_config.get('description', 'No description')
242
+ help_lines.append(f" {cmd_name:<15} {desc}")
243
+ help_lines.append("")
244
+ help_lines.append("Use: ry-next <library> <command> --help for command details")
245
+
246
+ return '\n'.join(help_lines)
247
+
248
+
ry_tool/template.py ADDED
@@ -0,0 +1,306 @@
1
+ """
2
+ Template processor for variable substitution.
3
+
4
+ Purpose: Replace template variables like {{flags.m}} with actual values.
5
+ Simple and focused - just template substitution, no execution logic.
6
+ """
7
+ import re
8
+ from typing import Any, Dict, TypeVar, Callable
9
+ from .context import ExecutionContext
10
+
11
+ # Generic type for recursive processing
12
+ T = TypeVar('T')
13
+
14
+
15
+ class TemplateProcessor:
16
+ """
17
+ Process templates with variable substitution.
18
+
19
+ Supports:
20
+ - Simple variables: {{flags.m}}
21
+ - Defaults: {{flags.m|default:"no message"}}
22
+ - Filters: {{flags.m|upper}}, {{positionals|join:", "}}
23
+ """
24
+
25
+ def __init__(self, context: ExecutionContext):
26
+ """
27
+ Initialize with execution context.
28
+
29
+ Args:
30
+ context: ExecutionContext with all available variables
31
+ """
32
+ self.context = context
33
+ self.filters = self._build_filters()
34
+
35
+ def process(self, template: str) -> str:
36
+ """
37
+ Process template string, replacing all variables.
38
+
39
+ Args:
40
+ template: String with {{variable}} placeholders
41
+
42
+ Returns:
43
+ Processed string with variables replaced
44
+ """
45
+ if not isinstance(template, str):
46
+ return str(template)
47
+
48
+ # Find all template variables
49
+ pattern = r'\{\{([^}]+)\}\}'
50
+
51
+ def replace_var(match):
52
+ var_expr = match.group(1).strip()
53
+ return str(self._evaluate_expression(var_expr))
54
+
55
+ return re.sub(pattern, replace_var, template)
56
+
57
+ def process_recursive(self, data: T) -> T:
58
+ """
59
+ Recursively process any data structure containing templates.
60
+
61
+ Uses type dispatch for clean handling of different data types.
62
+
63
+ Args:
64
+ data: Any data structure potentially containing template strings
65
+
66
+ Returns:
67
+ Data structure with all templates processed
68
+ """
69
+ return self._dispatch_process(data)
70
+
71
+ def _dispatch_process(self, data: Any) -> Any:
72
+ """Type-based dispatch for processing different data types."""
73
+ if isinstance(data, str):
74
+ return self.process(data)
75
+ elif isinstance(data, dict):
76
+ return {key: self._dispatch_process(value) for key, value in data.items()}
77
+ elif isinstance(data, list):
78
+ return [self._dispatch_process(item) for item in data]
79
+ elif isinstance(data, tuple):
80
+ return tuple(self._dispatch_process(item) for item in data)
81
+ else:
82
+ return data
83
+
84
+
85
+ def _evaluate_expression(self, expr: str) -> Any:
86
+ """
87
+ Evaluate a template expression.
88
+
89
+ Handles:
90
+ - Simple paths: flags.m
91
+ - Defaults: flags.m|default:"none"
92
+ - Filters: flags.m|upper
93
+ """
94
+ # Split by pipe for filters/defaults
95
+ parts = expr.split('|')
96
+ base_expr = parts[0].strip()
97
+
98
+ # Get base value
99
+ value = self._get_value(base_expr)
100
+
101
+ # Apply filters/defaults
102
+ for part in parts[1:]:
103
+ part = part.strip()
104
+ if part.startswith('default:'):
105
+ default_val = part[8:].strip().strip('"\'')
106
+ if value is None or value == "":
107
+ value = default_val
108
+ else:
109
+ # Apply filter
110
+ value = self._apply_filter(value, part)
111
+
112
+ return value
113
+
114
+ def _get_value(self, path: str) -> Any:
115
+ """
116
+ Get value from context by path.
117
+
118
+ Special variables:
119
+ - original: Reconstructed original command
120
+ - relay: Command to relay to native tool
121
+ - relay_base: Just the native tool path
122
+ """
123
+ # Check for special computed values
124
+ if path == 'original':
125
+ return self.context._reconstruct_original()
126
+ elif path == 'relay':
127
+ return self.context._build_relay_command()
128
+ elif path == 'relay_base':
129
+ return self.context.target or self.context.command
130
+
131
+ # Normal path lookup
132
+ value = self.context.get(path)
133
+
134
+ # Convert None to empty string
135
+ if value is None:
136
+ return ""
137
+
138
+ # Convert booleans to shell-friendly strings
139
+ if isinstance(value, bool):
140
+ return "true" if value else ""
141
+
142
+ return value
143
+
144
+ def _apply_filter(self, value: Any, filter_name: str) -> Any:
145
+ """Apply a filter to a value."""
146
+ if filter_name in self.filters:
147
+ return self.filters[filter_name](value)
148
+ return value
149
+
150
+ def _build_filters(self) -> Dict[str, Callable[[Any], Any]]:
151
+ """Build available filters with proper typing."""
152
+ return {
153
+ 'upper': self._filter_upper,
154
+ 'lower': self._filter_lower,
155
+ 'strip': self._filter_strip,
156
+ 'join': self._filter_join,
157
+ 'json': self._filter_json,
158
+ 'shell_escape': self._filter_shell_escape,
159
+ 'strip_prefix': self._filter_strip_prefix,
160
+ 'length': self._filter_length,
161
+ 'default': self._filter_default,
162
+ }
163
+
164
+ def _filter_upper(self, x: Any) -> str:
165
+ """Convert to uppercase."""
166
+ return str(x).upper()
167
+
168
+ def _filter_lower(self, x: Any) -> str:
169
+ """Convert to lowercase."""
170
+ return str(x).lower()
171
+
172
+ def _filter_strip(self, x: Any) -> str:
173
+ """Strip whitespace."""
174
+ return str(x).strip()
175
+
176
+ def _filter_join(self, x: Any, delimiter: str = ', ') -> str:
177
+ """Join list items or convert to string."""
178
+ if isinstance(x, list):
179
+ return delimiter.join(str(i) for i in x)
180
+ return str(x)
181
+
182
+ def _filter_json(self, x: Any) -> str:
183
+ """Convert to JSON string."""
184
+ import json
185
+ return json.dumps(x) if x else "{}"
186
+
187
+ def _filter_shell_escape(self, x: Any) -> str:
188
+ """Escape for shell usage."""
189
+ import shlex
190
+ return shlex.quote(str(x))
191
+
192
+ def _filter_strip_prefix(self, x: Any, prefix: str = 'v') -> str:
193
+ """Strip prefix from string."""
194
+ s = str(x)
195
+ return s.lstrip(prefix) if isinstance(x, str) else s
196
+
197
+ def _filter_length(self, x: Any) -> int:
198
+ """Get length of object."""
199
+ return len(x) if hasattr(x, '__len__') else 0
200
+
201
+ def _filter_default(self, x: Any, default_val: Any = '') -> Any:
202
+ """Return default if value is falsy."""
203
+ return x if x else default_val
204
+
205
+ def evaluate_condition(self, condition: str) -> bool:
206
+ """
207
+ Evaluate a conditional expression safely.
208
+
209
+ Examples:
210
+ flags.force
211
+ not flags.dry_run
212
+ flags.bump == "major"
213
+ arguments.branch in ["main", "master"]
214
+ """
215
+ # Process any template variables first
216
+ processed_condition = self.process(condition)
217
+
218
+ # Use safe evaluation with restricted builtins
219
+ return self._safe_eval(processed_condition)
220
+
221
+ def _safe_eval(self, expression: str) -> bool:
222
+ """Safely evaluate boolean expressions."""
223
+ # Create safe evaluation context
224
+ eval_context = self.context.to_dict()
225
+
226
+ # Allowed builtins for safe evaluation
227
+ safe_builtins = {
228
+ '__builtins__': {
229
+ 'len': len,
230
+ 'str': str,
231
+ 'int': int,
232
+ 'float': float,
233
+ 'bool': bool,
234
+ 'list': list,
235
+ 'dict': dict,
236
+ 'True': True,
237
+ 'False': False,
238
+ 'None': None,
239
+ }
240
+ }
241
+
242
+ try:
243
+ result = eval(expression, safe_builtins, eval_context)
244
+ return bool(result)
245
+ except Exception:
246
+ # Fallback to string comparison for simple cases
247
+ return self._fallback_condition_eval(expression, eval_context)
248
+
249
+ def _fallback_condition_eval(self, expression: str, context: Dict[str, Any]) -> bool:
250
+ """Fallback condition evaluation for simple cases."""
251
+ # Handle simple true/false checks
252
+ if expression.lower() in ['true', '1', 'yes']:
253
+ return True
254
+ if expression.lower() in ['false', '0', 'no', '']:
255
+ return False
256
+
257
+ # Check if it's a simple variable lookup
258
+ if expression in context:
259
+ return bool(context[expression])
260
+
261
+ # Default to False for unknown expressions
262
+ return False
263
+
264
+ def process_modifications(self, step: Dict[str, Any]) -> Dict[str, Any]:
265
+ """
266
+ Process modification directives in a step.
267
+
268
+ Supports explicit modification syntax for cleaner YAML:
269
+ modify:
270
+ flags.message: "cleaned message"
271
+ env.TOKEN: "{{captured.token}}"
272
+
273
+ Args:
274
+ step: Step dictionary potentially containing 'modify' directive
275
+
276
+ Returns:
277
+ Dictionary of modifications to apply to context
278
+ """
279
+ if 'modify' not in step:
280
+ return {}
281
+
282
+ modifications = {}
283
+ modify_spec = step['modify']
284
+
285
+ for path, value in modify_spec.items():
286
+ # Process template in value
287
+ processed_value = self.process(value) if isinstance(value, str) else value
288
+
289
+ # Parse the path (e.g., "flags.message" -> modify flags)
290
+ parts = path.split('.')
291
+ if len(parts) == 2:
292
+ category, key = parts
293
+ if category not in modifications:
294
+ modifications[category] = {}
295
+ if isinstance(modifications[category], dict):
296
+ modifications[category][key] = processed_value
297
+ elif len(parts) == 1:
298
+ # Direct modification (e.g., "positionals")
299
+ modifications[parts[0]] = processed_value
300
+
301
+ return modifications
302
+
303
+
304
+ # These are now imported at module level where needed
305
+
306
+