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/__init__.py +27 -0
- ry_tool/__main__.py +9 -0
- ry_tool/_cli.py +244 -0
- ry_tool/app.py +420 -0
- ry_tool/context.py +297 -0
- ry_tool/executor.py +475 -0
- ry_tool/installer.py +176 -0
- ry_tool/loader.py +280 -0
- ry_tool/matcher.py +233 -0
- ry_tool/parser.py +248 -0
- ry_tool/template.py +306 -0
- ry_tool/utils.py +396 -0
- ry_tool-1.0.1.dist-info/METADATA +112 -0
- ry_tool-1.0.1.dist-info/RECORD +16 -0
- ry_tool-1.0.1.dist-info/WHEEL +4 -0
- ry_tool-1.0.1.dist-info/entry_points.txt +3 -0
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
|
+
|