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/loader.py ADDED
@@ -0,0 +1,280 @@
1
+ """
2
+ Library loader and validator.
3
+
4
+ Purpose: Load YAML library files and validate their schema.
5
+ Understands v2 library format but doesn't execute anything.
6
+ """
7
+ import os
8
+ import yaml
9
+ from pathlib import Path
10
+ from typing import Dict, Any, Optional, List
11
+ from dataclasses import dataclass, field
12
+
13
+
14
+ @dataclass
15
+ class LibraryConfig:
16
+ """Validated library configuration."""
17
+ name: str
18
+ version: str = "2.0"
19
+ type: str = "augmentation" # augmentation, utility, hybrid
20
+ target: Optional[str] = None # For augmentation libraries
21
+ description: str = ""
22
+ commands: Dict[str, Any] = field(default_factory=dict)
23
+ metadata: Dict[str, Any] = field(default_factory=dict)
24
+ path: Optional[Path] = None
25
+
26
+
27
+ class LibraryLoader:
28
+ """
29
+ Load libraries from multiple sources with priority ordering.
30
+
31
+ Search order (first match wins):
32
+ 1. Direct file path (*.yaml)
33
+ 2. Local workspace (./docs/libraries/, ./libraries/)
34
+ 3. User libraries (~/.local/share/ry-next/libraries/)
35
+ 4. System libraries (/usr/local/share/ry-next/libraries/)
36
+ 5. Online registry (future)
37
+ """
38
+
39
+ def __init__(self, library_paths: List[Path] = None):
40
+ """
41
+ Initialize with search paths for libraries.
42
+
43
+ Args:
44
+ library_paths: List of directories to search for libraries
45
+ """
46
+ self.registry_url = os.environ.get('RY_REGISTRY_URL', None)
47
+ self._cache_dir = Path.home() / '.cache' / 'ry-next' / 'libraries'
48
+ self.library_paths = library_paths or self._default_paths()
49
+
50
+ def _default_paths(self) -> List[Path]:
51
+ """Get default library search paths in priority order."""
52
+ paths = []
53
+
54
+ # 1. Local development (highest priority)
55
+ paths.append(Path.cwd() / "docs" / "libraries")
56
+ paths.append(Path.cwd() / "libraries")
57
+
58
+ # 2. User libraries
59
+ xdg_data = os.environ.get("XDG_DATA_HOME", Path.home() / ".local/share")
60
+ paths.append(Path(xdg_data) / "ry-next" / "libraries")
61
+
62
+ # 3. User config
63
+ xdg_config = os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config")
64
+ paths.append(Path(xdg_config) / "ry-next" / "libraries")
65
+
66
+ # 4. System libraries
67
+ paths.append(Path("/usr/local/share/ry-next/libraries"))
68
+
69
+ # 5. Cached online libraries
70
+ paths.append(self._cache_dir)
71
+
72
+ return [p for p in paths if p.exists()]
73
+
74
+ def load(self, library_name: str) -> LibraryConfig:
75
+ """
76
+ Load a library by name.
77
+
78
+ Args:
79
+ library_name: Name of the library to load
80
+
81
+ Returns:
82
+ Validated LibraryConfig
83
+
84
+ Raises:
85
+ FileNotFoundError: Library not found
86
+ ValueError: Invalid library format
87
+ """
88
+ # Find library file
89
+ library_path = self._find_library(library_name)
90
+ if not library_path:
91
+ raise FileNotFoundError(f"Library not found: {library_name}")
92
+
93
+ # Load YAML
94
+ try:
95
+ with open(library_path) as f:
96
+ data = yaml.safe_load(f)
97
+ except yaml.YAMLError as e:
98
+ raise ValueError(f"YAML syntax error in {library_path}:\n{e}\n\nHint: Use block style (|) for shell commands with templates")
99
+
100
+ # Validate and create config
101
+ return self._validate_library(data, library_path)
102
+
103
+ def load_file(self, path: Path) -> LibraryConfig:
104
+ """Load a library from a specific file."""
105
+ if not path.exists():
106
+ raise FileNotFoundError(f"File not found: {path}")
107
+
108
+ try:
109
+ with open(path) as f:
110
+ data = yaml.safe_load(f)
111
+ except yaml.YAMLError as e:
112
+ raise ValueError(f"YAML syntax error in {path}:\n{e}\n\nHint: Use block style (|) for shell commands with templates")
113
+
114
+ return self._validate_library(data, path)
115
+
116
+ def _find_library(self, name: str) -> Optional[Path]:
117
+ """
118
+ Find library file in search paths.
119
+
120
+ Looks for:
121
+ - name/name.yaml (directory format)
122
+ - name.yaml (single file format)
123
+ """
124
+ for base_path in self.library_paths:
125
+ # Check directory format
126
+ dir_path = base_path / name / f"{name}.yaml"
127
+ if dir_path.exists():
128
+ return dir_path
129
+
130
+ # Check single file format
131
+ file_path = base_path / f"{name}.yaml"
132
+ if file_path.exists():
133
+ return file_path
134
+
135
+ return None
136
+
137
+ def _validate_library(self, data: Dict[str, Any], path: Path) -> LibraryConfig:
138
+ """
139
+ Validate library data and create LibraryConfig.
140
+
141
+ Args:
142
+ data: Raw YAML data
143
+ path: Path to library file
144
+
145
+ Returns:
146
+ Validated LibraryConfig
147
+
148
+ Raises:
149
+ ValueError: Invalid library format
150
+ """
151
+ # Check version
152
+ version = data.get('version', '2.0')
153
+ if not version.startswith('2'):
154
+ raise ValueError(f"Unsupported library version: {version}")
155
+
156
+ # Required fields
157
+ if 'name' not in data:
158
+ raise ValueError("Library missing required field: name")
159
+
160
+ # Extract metadata
161
+ metadata = {}
162
+ if path.parent.name == data['name']:
163
+ # Directory format - check for meta.yaml
164
+ meta_path = path.parent / 'meta.yaml'
165
+ if meta_path.exists():
166
+ with open(meta_path) as f:
167
+ metadata = yaml.safe_load(f)
168
+
169
+ # Validate commands
170
+ commands = data.get('commands', {})
171
+ for cmd_name, cmd_config in commands.items():
172
+ self._validate_command(cmd_name, cmd_config)
173
+
174
+ return LibraryConfig(
175
+ name=data['name'],
176
+ version=version,
177
+ type=data.get('type', 'augmentation'),
178
+ target=data.get('target'),
179
+ description=data.get('description', ''),
180
+ commands=commands,
181
+ metadata=metadata,
182
+ path=path
183
+ )
184
+
185
+ def _validate_command(self, name: str, config: Dict[str, Any]):
186
+ """
187
+ Validate a single command configuration.
188
+
189
+ Args:
190
+ name: Command name
191
+ config: Command configuration
192
+
193
+ Raises:
194
+ ValueError: Invalid command format
195
+ """
196
+ # Check for execution mode
197
+ has_mode = any(key in config for key in [
198
+ 'execute', 'augment', 'handlers', 'relay'
199
+ ])
200
+
201
+ if not has_mode:
202
+ raise ValueError(f"Command '{name}' has no execution mode")
203
+
204
+ # Validate flags if present
205
+ if 'flags' in config:
206
+ self._validate_flags(config['flags'])
207
+
208
+ # Validate arguments if present
209
+ if 'arguments' in config:
210
+ self._validate_arguments(config['arguments'])
211
+
212
+ # Validate handlers if present
213
+ if 'handlers' in config:
214
+ for handler in config['handlers']:
215
+ if 'when' not in handler and 'default' not in handler:
216
+ raise ValueError(f"Handler in '{name}' missing 'when' or 'default'")
217
+
218
+ def _validate_flags(self, flags: Dict[str, Any]):
219
+ """Validate flag definitions."""
220
+ for flag_name, flag_config in flags.items():
221
+ if isinstance(flag_config, str):
222
+ # Simple type: string, bool, int
223
+ if flag_config not in ['string', 'bool', 'int', 'enum']:
224
+ raise ValueError(f"Unknown flag type: {flag_config}")
225
+ elif isinstance(flag_config, dict):
226
+ # Complex definition
227
+ if 'type' in flag_config:
228
+ if flag_config['type'] == 'enum' and 'values' not in flag_config:
229
+ raise ValueError(f"Enum flag missing values: {flag_name}")
230
+
231
+ def _validate_arguments(self, arguments: Dict[str, Any]):
232
+ """Validate argument definitions."""
233
+ for arg_name, arg_config in arguments.items():
234
+ if isinstance(arg_config, str):
235
+ # Simple: required, optional
236
+ if arg_config not in ['required', 'optional']:
237
+ raise ValueError(f"Unknown argument type: {arg_config}")
238
+ elif isinstance(arg_config, dict):
239
+ # Complex definition
240
+ if 'required' not in arg_config:
241
+ arg_config['required'] = False
242
+
243
+ def list_available(self) -> List[str]:
244
+ """List all available libraries."""
245
+ libraries = set()
246
+
247
+ for base_path in self.library_paths:
248
+ if not base_path.exists():
249
+ continue
250
+
251
+ # Check for directory format libraries
252
+ for item in base_path.iterdir():
253
+ if item.is_dir():
254
+ yaml_file = item / f"{item.name}.yaml"
255
+ if yaml_file.exists():
256
+ libraries.add(item.name)
257
+ elif item.suffix == '.yaml':
258
+ libraries.add(item.stem)
259
+
260
+ return sorted(libraries)
261
+
262
+ def list_from_path(self, path: Path) -> List[str]:
263
+ """List libraries from a specific path."""
264
+ libraries = []
265
+
266
+ if not path.exists():
267
+ return libraries
268
+
269
+ # Check for directory format libraries
270
+ for item in path.iterdir():
271
+ if item.is_dir():
272
+ yaml_file = item / f"{item.name}.yaml"
273
+ if yaml_file.exists():
274
+ libraries.append(item.name)
275
+ elif item.suffix == '.yaml':
276
+ libraries.append(item.stem)
277
+
278
+ return sorted(libraries)
279
+
280
+
ry_tool/matcher.py ADDED
@@ -0,0 +1,233 @@
1
+ """
2
+ Command matcher that determines which handler to execute.
3
+
4
+ Purpose: Match parsed commands to library command definitions.
5
+ Evaluates conditions to select the right handler.
6
+ No execution, just matching logic.
7
+ """
8
+ from typing import Dict, Any, Optional, List
9
+ from dataclasses import dataclass
10
+
11
+ from .parser import ParsedCommand
12
+ from .loader import LibraryConfig
13
+ from .context import ExecutionContext
14
+ from .template import TemplateProcessor
15
+ from .utils import ContextFactory
16
+
17
+
18
+ @dataclass
19
+ class MatchResult:
20
+ """Result of matching a command to a handler."""
21
+ matched: bool
22
+ command_config: Optional[Dict[str, Any]] = None
23
+ handler: Optional[Dict[str, Any]] = None
24
+ context: Optional[ExecutionContext] = None
25
+ reason: str = ""
26
+
27
+
28
+ class CommandMatcher:
29
+ """
30
+ Match parsed commands to library handlers.
31
+
32
+ Responsibilities:
33
+ - Find matching command in library
34
+ - Evaluate handler conditions
35
+ - Build execution context
36
+ - NO execution
37
+ """
38
+
39
+ def match(self, parsed: ParsedCommand, library: LibraryConfig) -> MatchResult:
40
+ """
41
+ Match a parsed command to a library handler.
42
+
43
+ Args:
44
+ parsed: Parsed command from CommandParser
45
+ library: Loaded library configuration
46
+
47
+ Returns:
48
+ MatchResult with matched handler and context
49
+ """
50
+ # Find matching command in library
51
+ command_config = self._find_command(parsed, library)
52
+ if not command_config:
53
+ return MatchResult(
54
+ matched=False,
55
+ reason=f"No command '{parsed.command}' in library '{library.name}'"
56
+ )
57
+
58
+ # Build execution context using factory
59
+ context = ContextFactory.from_parsed_command(parsed, library, command_config)
60
+
61
+ # Find matching handler
62
+ handler = self._find_handler(command_config, context)
63
+ if not handler:
64
+ return MatchResult(
65
+ matched=False,
66
+ reason=f"No matching handler for command '{parsed.command}'"
67
+ )
68
+
69
+ return MatchResult(
70
+ matched=True,
71
+ command_config=command_config,
72
+ handler=handler,
73
+ context=context
74
+ )
75
+
76
+ def _find_command(self, parsed: ParsedCommand, library: LibraryConfig) -> Optional[Dict[str, Any]]:
77
+ """
78
+ Find matching command definition in library.
79
+
80
+ Handles:
81
+ - Exact match: commit
82
+ - Subcommand match: remote add
83
+ - Wildcard: * (catch-all)
84
+ """
85
+ commands = library.commands
86
+
87
+ # Try exact match
88
+ if parsed.command in commands:
89
+ return commands[parsed.command]
90
+
91
+ # Try with subcommand
92
+ if parsed.subcommand:
93
+ full_command = f"{parsed.command} {parsed.subcommand}"
94
+ if full_command in commands:
95
+ return commands[full_command]
96
+
97
+ # Try command with wildcard subcommand
98
+ wildcard = f"{parsed.command} *"
99
+ if wildcard in commands:
100
+ return commands[wildcard]
101
+
102
+ # Try catch-all
103
+ if '*' in commands:
104
+ return commands['*']
105
+
106
+ # For augmentation libraries, default to relay if no match
107
+ if library.type == 'augmentation' and library.target:
108
+ # Return a default relay handler
109
+ return {
110
+ 'relay': 'native',
111
+ 'description': 'Pass through to native command'
112
+ }
113
+
114
+ return None
115
+
116
+
117
+ def _find_handler(self, command_config: Dict[str, Any],
118
+ context: ExecutionContext) -> Optional[Dict[str, Any]]:
119
+ """
120
+ Find matching handler based on conditions.
121
+
122
+ Evaluates 'when' conditions to find the right handler.
123
+ """
124
+ # Simple execution modes (no conditions)
125
+ if 'execute' in command_config:
126
+ return {'execute': command_config['execute']}
127
+
128
+ # Check for augmentation with relay (common pattern)
129
+ if 'augment' in command_config and 'relay' in command_config:
130
+ return {
131
+ 'augment': command_config['augment'],
132
+ 'relay': command_config['relay']
133
+ }
134
+
135
+ if 'augment' in command_config:
136
+ return {'augment': command_config['augment']}
137
+
138
+ if 'relay' in command_config:
139
+ return {'relay': command_config['relay']}
140
+
141
+ # Conditional handlers
142
+ if 'handlers' in command_config:
143
+ template_processor = TemplateProcessor(context)
144
+
145
+ for handler in command_config['handlers']:
146
+ if 'default' in handler:
147
+ # Default handler (always matches)
148
+ return handler
149
+
150
+ if 'when' in handler:
151
+ # Evaluate condition
152
+ condition = handler['when']
153
+ if template_processor.evaluate_condition(condition):
154
+ return handler
155
+
156
+ return None
157
+
158
+ def get_execution_plan(self, match_result: MatchResult) -> List[Dict[str, Any]]:
159
+ """
160
+ Get list of steps to execute from a match result.
161
+
162
+ Args:
163
+ match_result: Result from match()
164
+
165
+ Returns:
166
+ List of execution steps
167
+ """
168
+ if not match_result.matched:
169
+ return []
170
+
171
+ handler = match_result.handler
172
+ steps = []
173
+
174
+ # Handle different execution modes
175
+ if 'execute' in handler:
176
+ # Direct execution steps
177
+ steps.extend(self._normalize_steps(handler['execute']))
178
+
179
+ elif 'augment' in handler:
180
+ # Augmentation mode
181
+ augment = handler['augment']
182
+
183
+ # Before steps
184
+ if 'before' in augment:
185
+ steps.extend(self._normalize_steps(augment['before']))
186
+
187
+ # Relay to native command
188
+ if 'relay' in augment:
189
+ if augment['relay'] == 'native':
190
+ steps.append({
191
+ 'subprocess': {
192
+ 'cmd': match_result.context._build_relay_command().split()
193
+ }
194
+ })
195
+
196
+ # After steps
197
+ if 'after' in augment:
198
+ steps.extend(self._normalize_steps(augment['after']))
199
+
200
+ elif 'relay' in handler:
201
+ # Simple relay
202
+ if handler['relay'] == 'native':
203
+ steps.append({
204
+ 'subprocess': {
205
+ 'cmd': match_result.context._build_relay_command().split()
206
+ }
207
+ })
208
+
209
+ return steps
210
+
211
+ def _normalize_steps(self, steps: Any) -> List[Dict[str, Any]]:
212
+ """
213
+ Normalize steps to consistent format.
214
+
215
+ Handles both list and single step formats.
216
+ """
217
+ if not steps:
218
+ return []
219
+
220
+ if not isinstance(steps, list):
221
+ steps = [steps]
222
+
223
+ normalized = []
224
+ for step in steps:
225
+ if isinstance(step, str):
226
+ # Assume shell command
227
+ normalized.append({'shell': step})
228
+ elif isinstance(step, dict):
229
+ normalized.append(step)
230
+
231
+ return normalized
232
+
233
+