@voodocs/cli 0.3.2 → 0.4.0
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.
- package/CHANGELOG.md +441 -0
- package/cli.py +31 -2
- package/lib/darkarts/annotations/DARKARTS_SYMBOLS.md +529 -0
- package/lib/darkarts/annotations/TRANSFORMATION_EXAMPLES.md +478 -0
- package/lib/darkarts/annotations/__init__.py +42 -0
- package/lib/darkarts/annotations/darkarts_parser.py +238 -0
- package/lib/darkarts/annotations/parser.py +186 -5
- package/lib/darkarts/annotations/symbols.py +244 -0
- package/lib/darkarts/annotations/translator.py +386 -0
- package/lib/darkarts/context/ai_instructions.py +8 -1
- package/lib/darkarts/context/checker.py +290 -62
- package/lib/darkarts/context/commands.py +374 -290
- package/lib/darkarts/context/errors.py +164 -0
- package/lib/darkarts/context/models.py +23 -1
- package/lib/darkarts/context/module_utils.py +198 -0
- package/lib/darkarts/context/ui.py +337 -0
- package/lib/darkarts/context/validation.py +311 -0
- package/lib/darkarts/context/yaml_utils.py +117 -33
- package/lib/darkarts/exceptions.py +5 -0
- package/lib/darkarts/plugins/voodocs/instruction_generator.py +8 -1
- package/package.json +1 -1
|
@@ -0,0 +1,337 @@
|
|
|
1
|
+
"""@darkarts
|
|
2
|
+
⊢ui:ctx.interactive
|
|
3
|
+
∂{tqdm,contextlib}
|
|
4
|
+
⚠{term:ansi,tqdm:installed,users:visual-feedback}
|
|
5
|
+
⊨{colors→reset,progress→close,confirm→bool,∀output→safe}
|
|
6
|
+
🔒{no-implications}
|
|
7
|
+
⚡{O(1)}
|
|
8
|
+
|
|
9
|
+
User Interface Utilities
|
|
10
|
+
|
|
11
|
+
Provides progress indicators, colored output, and user interaction utilities.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from typing import Optional, Iterator, Any
|
|
15
|
+
from contextlib import contextmanager
|
|
16
|
+
import sys
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
# Check if tqdm is available
|
|
20
|
+
try:
|
|
21
|
+
from tqdm import tqdm
|
|
22
|
+
TQDM_AVAILABLE = True
|
|
23
|
+
except ImportError:
|
|
24
|
+
TQDM_AVAILABLE = False
|
|
25
|
+
# Fallback: no-op progress bar
|
|
26
|
+
class tqdm:
|
|
27
|
+
def __init__(self, *args, **kwargs):
|
|
28
|
+
self.total = kwargs.get('total', 0)
|
|
29
|
+
self.n = 0
|
|
30
|
+
|
|
31
|
+
def update(self, n=1):
|
|
32
|
+
self.n += n
|
|
33
|
+
|
|
34
|
+
def close(self):
|
|
35
|
+
pass
|
|
36
|
+
|
|
37
|
+
def __enter__(self):
|
|
38
|
+
return self
|
|
39
|
+
|
|
40
|
+
def __exit__(self, *args):
|
|
41
|
+
self.close()
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class Colors:
|
|
45
|
+
"""ANSI color codes for terminal output."""
|
|
46
|
+
RESET = '\033[0m'
|
|
47
|
+
RED = '\033[91m'
|
|
48
|
+
GREEN = '\033[92m'
|
|
49
|
+
YELLOW = '\033[93m'
|
|
50
|
+
BLUE = '\033[94m'
|
|
51
|
+
MAGENTA = '\033[95m'
|
|
52
|
+
CYAN = '\033[96m'
|
|
53
|
+
WHITE = '\033[97m'
|
|
54
|
+
BOLD = '\033[1m'
|
|
55
|
+
DIM = '\033[2m'
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def success(message: str) -> None:
|
|
59
|
+
"""
|
|
60
|
+
Print success message with green checkmark.
|
|
61
|
+
|
|
62
|
+
Args:
|
|
63
|
+
message: Success message to display
|
|
64
|
+
|
|
65
|
+
Example:
|
|
66
|
+
>>> success("Context initialized successfully!")
|
|
67
|
+
✅ Context initialized successfully!
|
|
68
|
+
"""
|
|
69
|
+
print(f"{Colors.GREEN}✅ {message}{Colors.RESET}")
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def error(message: str) -> None:
|
|
73
|
+
"""
|
|
74
|
+
Print error message with red X.
|
|
75
|
+
|
|
76
|
+
Args:
|
|
77
|
+
message: Error message to display
|
|
78
|
+
|
|
79
|
+
Example:
|
|
80
|
+
>>> error("Context file not found")
|
|
81
|
+
❌ Context file not found
|
|
82
|
+
"""
|
|
83
|
+
print(f"{Colors.RED}❌ {message}{Colors.RESET}", file=sys.stderr)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def warning(message: str) -> None:
|
|
87
|
+
"""
|
|
88
|
+
Print warning message with yellow warning sign.
|
|
89
|
+
|
|
90
|
+
Args:
|
|
91
|
+
message: Warning message to display
|
|
92
|
+
|
|
93
|
+
Example:
|
|
94
|
+
>>> warning("Git not available, some features disabled")
|
|
95
|
+
⚠️ Git not available, some features disabled
|
|
96
|
+
"""
|
|
97
|
+
print(f"{Colors.YELLOW}⚠️ {message}{Colors.RESET}")
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def info(message: str) -> None:
|
|
101
|
+
"""
|
|
102
|
+
Print info message with blue info icon.
|
|
103
|
+
|
|
104
|
+
Args:
|
|
105
|
+
message: Info message to display
|
|
106
|
+
|
|
107
|
+
Example:
|
|
108
|
+
>>> info("Context file created at: .voodocs.context")
|
|
109
|
+
ℹ️ Context file created at: .voodocs.context
|
|
110
|
+
"""
|
|
111
|
+
print(f"{Colors.BLUE}ℹ️ {message}{Colors.RESET}")
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def step(message: str) -> None:
|
|
115
|
+
"""
|
|
116
|
+
Print step message with cyan arrow.
|
|
117
|
+
|
|
118
|
+
Args:
|
|
119
|
+
message: Step message to display
|
|
120
|
+
|
|
121
|
+
Example:
|
|
122
|
+
>>> step("Initializing VooDocs context...")
|
|
123
|
+
▶️ Initializing VooDocs context...
|
|
124
|
+
"""
|
|
125
|
+
print(f"{Colors.CYAN}▶️ {message}{Colors.RESET}")
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def debug(message: str, verbose: bool = False) -> None:
|
|
129
|
+
"""
|
|
130
|
+
Print debug message (only if verbose=True).
|
|
131
|
+
|
|
132
|
+
Args:
|
|
133
|
+
message: Debug message to display
|
|
134
|
+
verbose: If True, print the message
|
|
135
|
+
|
|
136
|
+
Example:
|
|
137
|
+
>>> debug("Loaded 10 modules from context", verbose=True)
|
|
138
|
+
🐛 Loaded 10 modules from context
|
|
139
|
+
"""
|
|
140
|
+
if verbose:
|
|
141
|
+
print(f"{Colors.DIM}🐛 {message}{Colors.RESET}")
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
@contextmanager
|
|
145
|
+
def progress(description: str, total: Optional[int] = None, verbose: bool = True):
|
|
146
|
+
"""
|
|
147
|
+
Context manager for progress indication.
|
|
148
|
+
|
|
149
|
+
Args:
|
|
150
|
+
description: Description of the operation
|
|
151
|
+
total: Total number of items (None for indeterminate)
|
|
152
|
+
verbose: If True, show progress bar; if False, just print step
|
|
153
|
+
|
|
154
|
+
Yields:
|
|
155
|
+
tqdm progress bar or None
|
|
156
|
+
|
|
157
|
+
Example:
|
|
158
|
+
>>> with progress("Scanning files", total=100, verbose=True) as pbar:
|
|
159
|
+
... for file in files:
|
|
160
|
+
... process(file)
|
|
161
|
+
... if pbar:
|
|
162
|
+
... pbar.update(1)
|
|
163
|
+
"""
|
|
164
|
+
if verbose and total is not None and TQDM_AVAILABLE:
|
|
165
|
+
pbar = tqdm(total=total, desc=description, unit="item", ncols=80)
|
|
166
|
+
try:
|
|
167
|
+
yield pbar
|
|
168
|
+
finally:
|
|
169
|
+
pbar.close()
|
|
170
|
+
else:
|
|
171
|
+
# No progress bar, just print the step
|
|
172
|
+
step(description)
|
|
173
|
+
yield None
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def confirm(message: str, default: bool = True) -> bool:
|
|
177
|
+
"""
|
|
178
|
+
Ask user for confirmation.
|
|
179
|
+
|
|
180
|
+
Args:
|
|
181
|
+
message: Confirmation message
|
|
182
|
+
default: Default value if user just presses Enter
|
|
183
|
+
|
|
184
|
+
Returns:
|
|
185
|
+
True if confirmed, False otherwise
|
|
186
|
+
|
|
187
|
+
Example:
|
|
188
|
+
>>> if confirm("Overwrite existing file?", default=False):
|
|
189
|
+
... overwrite_file()
|
|
190
|
+
❓ Overwrite existing file? [y/N]: y
|
|
191
|
+
True
|
|
192
|
+
"""
|
|
193
|
+
default_str = "Y/n" if default else "y/N"
|
|
194
|
+
try:
|
|
195
|
+
response = input(f"{Colors.YELLOW}❓ {message} [{default_str}]: {Colors.RESET}").strip().lower()
|
|
196
|
+
except (EOFError, KeyboardInterrupt):
|
|
197
|
+
print() # New line after ^C
|
|
198
|
+
return False
|
|
199
|
+
|
|
200
|
+
if not response:
|
|
201
|
+
return default
|
|
202
|
+
|
|
203
|
+
return response in ['y', 'yes']
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def prompt(message: str, default: Optional[str] = None, required: bool = True) -> str:
|
|
207
|
+
"""
|
|
208
|
+
Prompt user for input.
|
|
209
|
+
|
|
210
|
+
Args:
|
|
211
|
+
message: Prompt message
|
|
212
|
+
default: Default value if user just presses Enter
|
|
213
|
+
required: If True, keep asking until non-empty input
|
|
214
|
+
|
|
215
|
+
Returns:
|
|
216
|
+
User's response or default value
|
|
217
|
+
|
|
218
|
+
Raises:
|
|
219
|
+
KeyboardInterrupt: If user presses Ctrl+C
|
|
220
|
+
|
|
221
|
+
Example:
|
|
222
|
+
>>> name = prompt("Project name", default="my-project")
|
|
223
|
+
Project name [my-project]:
|
|
224
|
+
'my-project'
|
|
225
|
+
"""
|
|
226
|
+
if default is not None:
|
|
227
|
+
response = input(f"{message} [{default}]: ").strip()
|
|
228
|
+
return response if response else default
|
|
229
|
+
else:
|
|
230
|
+
response = input(f"{message}: ").strip()
|
|
231
|
+
if required:
|
|
232
|
+
while not response:
|
|
233
|
+
warning("This field is required.")
|
|
234
|
+
response = input(f"{message}: ").strip()
|
|
235
|
+
return response
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def print_header(title: str) -> None:
|
|
239
|
+
"""
|
|
240
|
+
Print a formatted header.
|
|
241
|
+
|
|
242
|
+
Args:
|
|
243
|
+
title: Header title
|
|
244
|
+
|
|
245
|
+
Example:
|
|
246
|
+
>>> print_header("VooDocs Context System")
|
|
247
|
+
|
|
248
|
+
═══════════════════════════════════════════════════════════════
|
|
249
|
+
VooDocs Context System
|
|
250
|
+
═══════════════════════════════════════════════════════════════
|
|
251
|
+
"""
|
|
252
|
+
width = 80
|
|
253
|
+
print()
|
|
254
|
+
print(f"{Colors.BOLD}{'═' * width}{Colors.RESET}")
|
|
255
|
+
print(f"{Colors.BOLD}{title.center(width)}{Colors.RESET}")
|
|
256
|
+
print(f"{Colors.BOLD}{'═' * width}{Colors.RESET}")
|
|
257
|
+
print()
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def print_section(title: str) -> None:
|
|
261
|
+
"""
|
|
262
|
+
Print a formatted section header.
|
|
263
|
+
|
|
264
|
+
Args:
|
|
265
|
+
title: Section title
|
|
266
|
+
|
|
267
|
+
Example:
|
|
268
|
+
>>> print_section("Project Information")
|
|
269
|
+
|
|
270
|
+
─── Project Information ────────────────────────────────────────
|
|
271
|
+
"""
|
|
272
|
+
width = 80
|
|
273
|
+
title_with_spaces = f" {title} "
|
|
274
|
+
line = "─" * ((width - len(title_with_spaces)) // 2)
|
|
275
|
+
print()
|
|
276
|
+
print(f"{Colors.BOLD}{line}{title_with_spaces}{line}{Colors.RESET}")
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def print_key_value(key: str, value: str, indent: int = 2) -> None:
|
|
280
|
+
"""
|
|
281
|
+
Print a key-value pair.
|
|
282
|
+
|
|
283
|
+
Args:
|
|
284
|
+
key: Key name
|
|
285
|
+
value: Value
|
|
286
|
+
indent: Number of spaces to indent
|
|
287
|
+
|
|
288
|
+
Example:
|
|
289
|
+
>>> print_key_value("Name", "my-project")
|
|
290
|
+
Name: my-project
|
|
291
|
+
"""
|
|
292
|
+
spaces = " " * indent
|
|
293
|
+
print(f"{spaces}{Colors.BOLD}{key}:{Colors.RESET} {value}")
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def print_list(items: list, indent: int = 2, bullet: str = "•") -> None:
|
|
297
|
+
"""
|
|
298
|
+
Print a bulleted list.
|
|
299
|
+
|
|
300
|
+
Args:
|
|
301
|
+
items: List of items to print
|
|
302
|
+
indent: Number of spaces to indent
|
|
303
|
+
bullet: Bullet character
|
|
304
|
+
|
|
305
|
+
Example:
|
|
306
|
+
>>> print_list(["Item 1", "Item 2", "Item 3"])
|
|
307
|
+
• Item 1
|
|
308
|
+
• Item 2
|
|
309
|
+
• Item 3
|
|
310
|
+
"""
|
|
311
|
+
spaces = " " * indent
|
|
312
|
+
for item in items:
|
|
313
|
+
print(f"{spaces}{bullet} {item}")
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
def clear_line() -> None:
|
|
317
|
+
"""Clear the current line in the terminal."""
|
|
318
|
+
print('\r\033[K', end='')
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
def spinner_frames():
|
|
322
|
+
"""
|
|
323
|
+
Generator for spinner animation frames.
|
|
324
|
+
|
|
325
|
+
Yields:
|
|
326
|
+
Spinner frame characters
|
|
327
|
+
|
|
328
|
+
Example:
|
|
329
|
+
>>> frames = spinner_frames()
|
|
330
|
+
>>> for _ in range(4):
|
|
331
|
+
... print(next(frames), end='\\r')
|
|
332
|
+
... time.sleep(0.1)
|
|
333
|
+
"""
|
|
334
|
+
frames = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']
|
|
335
|
+
while True:
|
|
336
|
+
for frame in frames:
|
|
337
|
+
yield frame
|
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
"""@darkarts
|
|
2
|
+
⊢validation:ctx.comprehensive
|
|
3
|
+
∂{re,pathlib,errors}
|
|
4
|
+
⚠{name∈fs-safe,v∈semver,path∈rel∨abs}
|
|
5
|
+
⊨{∀validate_*→(⊥⇒raise)∧(⊤⇒norm),deterministic,msg:actionable}
|
|
6
|
+
🔒{validate-input,¬injection,¬fs-attack}
|
|
7
|
+
⚡{O(1)|regex-fast-on-short-str}
|
|
8
|
+
|
|
9
|
+
Input Validation Utilities
|
|
10
|
+
|
|
11
|
+
Provides comprehensive validation for user inputs with helpful error messages.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
import re
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Optional, List
|
|
17
|
+
from .errors import (
|
|
18
|
+
InvalidProjectNameError,
|
|
19
|
+
InvalidVersionError,
|
|
20
|
+
InvalidPathError
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def validate_project_name(name: str) -> str:
|
|
25
|
+
"""
|
|
26
|
+
Validate and normalize project name.
|
|
27
|
+
|
|
28
|
+
Project names must be:
|
|
29
|
+
- Non-empty
|
|
30
|
+
- 100 characters or less
|
|
31
|
+
- Contain only letters, numbers, hyphens, and underscores
|
|
32
|
+
|
|
33
|
+
Args:
|
|
34
|
+
name: Project name to validate
|
|
35
|
+
|
|
36
|
+
Returns:
|
|
37
|
+
Normalized project name (stripped of whitespace)
|
|
38
|
+
|
|
39
|
+
Raises:
|
|
40
|
+
InvalidProjectNameError: If name is invalid
|
|
41
|
+
|
|
42
|
+
Examples:
|
|
43
|
+
>>> validate_project_name("my-project")
|
|
44
|
+
'my-project'
|
|
45
|
+
>>> validate_project_name(" my_project ")
|
|
46
|
+
'my_project'
|
|
47
|
+
>>> validate_project_name("my project!")
|
|
48
|
+
InvalidProjectNameError: Invalid project name: 'my project!'
|
|
49
|
+
"""
|
|
50
|
+
name = name.strip()
|
|
51
|
+
|
|
52
|
+
if not name:
|
|
53
|
+
raise InvalidProjectNameError(name, "Project name cannot be empty")
|
|
54
|
+
|
|
55
|
+
if len(name) > 100:
|
|
56
|
+
raise InvalidProjectNameError(name, "Project name too long (max 100 characters)")
|
|
57
|
+
|
|
58
|
+
if not re.match(r'^[a-zA-Z0-9_-]+$', name):
|
|
59
|
+
raise InvalidProjectNameError(
|
|
60
|
+
name,
|
|
61
|
+
"Can only contain letters, numbers, hyphens, and underscores"
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
return name
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def validate_version(version: str, allow_empty: bool = True) -> str:
|
|
68
|
+
"""
|
|
69
|
+
Validate version number (semantic versioning).
|
|
70
|
+
|
|
71
|
+
Accepts:
|
|
72
|
+
- MAJOR.MINOR (e.g., "1.0")
|
|
73
|
+
- MAJOR.MINOR.PATCH (e.g., "1.0.0")
|
|
74
|
+
- Empty string if allow_empty=True
|
|
75
|
+
|
|
76
|
+
Args:
|
|
77
|
+
version: Version string to validate
|
|
78
|
+
allow_empty: If True, empty string is valid
|
|
79
|
+
|
|
80
|
+
Returns:
|
|
81
|
+
Validated version string (stripped of whitespace)
|
|
82
|
+
|
|
83
|
+
Raises:
|
|
84
|
+
InvalidVersionError: If version is invalid
|
|
85
|
+
|
|
86
|
+
Examples:
|
|
87
|
+
>>> validate_version("1.0.0")
|
|
88
|
+
'1.0.0'
|
|
89
|
+
>>> validate_version("1.0")
|
|
90
|
+
'1.0'
|
|
91
|
+
>>> validate_version("")
|
|
92
|
+
''
|
|
93
|
+
>>> validate_version("v1.0.0")
|
|
94
|
+
InvalidVersionError: Invalid version: 'v1.0.0'
|
|
95
|
+
"""
|
|
96
|
+
version = version.strip()
|
|
97
|
+
|
|
98
|
+
# Allow empty version if specified
|
|
99
|
+
if not version and allow_empty:
|
|
100
|
+
return version
|
|
101
|
+
|
|
102
|
+
if not version and not allow_empty:
|
|
103
|
+
raise InvalidVersionError(version)
|
|
104
|
+
|
|
105
|
+
# Check semantic versioning format (MAJOR.MINOR or MAJOR.MINOR.PATCH)
|
|
106
|
+
if not re.match(r'^\d+\.\d+(\.\d+)?$', version):
|
|
107
|
+
raise InvalidVersionError(version)
|
|
108
|
+
|
|
109
|
+
return version
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def validate_path(path: str, must_exist: bool = False, must_be_dir: bool = False, must_be_file: bool = False) -> Path:
|
|
113
|
+
"""
|
|
114
|
+
Validate and normalize file path.
|
|
115
|
+
|
|
116
|
+
Args:
|
|
117
|
+
path: Path string to validate
|
|
118
|
+
must_exist: If True, path must exist
|
|
119
|
+
must_be_dir: If True, path must be a directory
|
|
120
|
+
must_be_file: If True, path must be a file
|
|
121
|
+
|
|
122
|
+
Returns:
|
|
123
|
+
Normalized Path object (resolved to absolute path)
|
|
124
|
+
|
|
125
|
+
Raises:
|
|
126
|
+
InvalidPathError: If path is invalid
|
|
127
|
+
|
|
128
|
+
Examples:
|
|
129
|
+
>>> validate_path("./src")
|
|
130
|
+
PosixPath('/home/user/project/src')
|
|
131
|
+
>>> validate_path("", must_exist=True)
|
|
132
|
+
InvalidPathError: Invalid path: ''
|
|
133
|
+
"""
|
|
134
|
+
if not path:
|
|
135
|
+
raise InvalidPathError(path, "Path cannot be empty")
|
|
136
|
+
|
|
137
|
+
try:
|
|
138
|
+
path_obj = Path(path).resolve()
|
|
139
|
+
except Exception as e:
|
|
140
|
+
raise InvalidPathError(path, f"Invalid path format: {e}")
|
|
141
|
+
|
|
142
|
+
if must_exist and not path_obj.exists():
|
|
143
|
+
raise InvalidPathError(path, "Path does not exist")
|
|
144
|
+
|
|
145
|
+
if must_be_dir and path_obj.exists() and not path_obj.is_dir():
|
|
146
|
+
raise InvalidPathError(path, "Path must be a directory")
|
|
147
|
+
|
|
148
|
+
if must_be_file and path_obj.exists() and not path_obj.is_file():
|
|
149
|
+
raise InvalidPathError(path, "Path must be a file")
|
|
150
|
+
|
|
151
|
+
return path_obj
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def validate_context_structure(data: dict) -> None:
|
|
155
|
+
"""
|
|
156
|
+
Validate context file structure.
|
|
157
|
+
|
|
158
|
+
Checks for required sections and fields:
|
|
159
|
+
- versioning.code_version
|
|
160
|
+
- versioning.context_version
|
|
161
|
+
- project.name
|
|
162
|
+
- project.purpose
|
|
163
|
+
|
|
164
|
+
Args:
|
|
165
|
+
data: Parsed context data dictionary
|
|
166
|
+
|
|
167
|
+
Raises:
|
|
168
|
+
ValueError: If structure is invalid (with descriptive message)
|
|
169
|
+
|
|
170
|
+
Examples:
|
|
171
|
+
>>> validate_context_structure({'versioning': {'code_version': '1.0', 'context_version': '1.0'}, 'project': {'name': 'test', 'purpose': 'testing'}})
|
|
172
|
+
# No exception - valid structure
|
|
173
|
+
>>> validate_context_structure({})
|
|
174
|
+
ValueError: Missing required section: versioning
|
|
175
|
+
"""
|
|
176
|
+
required_sections = ['versioning', 'project']
|
|
177
|
+
|
|
178
|
+
for section in required_sections:
|
|
179
|
+
if section not in data:
|
|
180
|
+
raise ValueError(f"Missing required section: {section}")
|
|
181
|
+
|
|
182
|
+
# Validate versioning section
|
|
183
|
+
versioning = data['versioning']
|
|
184
|
+
if not isinstance(versioning, dict):
|
|
185
|
+
raise ValueError("Section 'versioning' must be a dictionary")
|
|
186
|
+
|
|
187
|
+
if 'code_version' not in versioning:
|
|
188
|
+
raise ValueError("Missing required field: versioning.code_version")
|
|
189
|
+
if 'context_version' not in versioning:
|
|
190
|
+
raise ValueError("Missing required field: versioning.context_version")
|
|
191
|
+
|
|
192
|
+
# Validate project section
|
|
193
|
+
project = data['project']
|
|
194
|
+
if not isinstance(project, dict):
|
|
195
|
+
raise ValueError("Section 'project' must be a dictionary")
|
|
196
|
+
|
|
197
|
+
if 'name' not in project:
|
|
198
|
+
raise ValueError("Missing required field: project.name")
|
|
199
|
+
if 'purpose' not in project:
|
|
200
|
+
raise ValueError("Missing required field: project.purpose")
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def validate_url(url: str, allow_empty: bool = True) -> Optional[str]:
|
|
204
|
+
"""
|
|
205
|
+
Validate URL format.
|
|
206
|
+
|
|
207
|
+
Args:
|
|
208
|
+
url: URL string to validate
|
|
209
|
+
allow_empty: If True, empty string is valid
|
|
210
|
+
|
|
211
|
+
Returns:
|
|
212
|
+
Validated URL or None if empty and allow_empty=True
|
|
213
|
+
|
|
214
|
+
Raises:
|
|
215
|
+
ValueError: If URL is invalid
|
|
216
|
+
|
|
217
|
+
Examples:
|
|
218
|
+
>>> validate_url("https://github.com/user/repo")
|
|
219
|
+
'https://github.com/user/repo'
|
|
220
|
+
>>> validate_url("")
|
|
221
|
+
None
|
|
222
|
+
>>> validate_url("not-a-url")
|
|
223
|
+
ValueError: Invalid URL format
|
|
224
|
+
"""
|
|
225
|
+
url = url.strip()
|
|
226
|
+
|
|
227
|
+
if not url and allow_empty:
|
|
228
|
+
return None
|
|
229
|
+
|
|
230
|
+
if not url and not allow_empty:
|
|
231
|
+
raise ValueError("URL cannot be empty")
|
|
232
|
+
|
|
233
|
+
# Basic URL validation (http/https)
|
|
234
|
+
if not re.match(r'^https?://', url):
|
|
235
|
+
raise ValueError("Invalid URL format (must start with http:// or https://)")
|
|
236
|
+
|
|
237
|
+
return url
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def validate_email(email: str, allow_empty: bool = True) -> Optional[str]:
|
|
241
|
+
"""
|
|
242
|
+
Validate email format.
|
|
243
|
+
|
|
244
|
+
Args:
|
|
245
|
+
email: Email string to validate
|
|
246
|
+
allow_empty: If True, empty string is valid
|
|
247
|
+
|
|
248
|
+
Returns:
|
|
249
|
+
Validated email or None if empty and allow_empty=True
|
|
250
|
+
|
|
251
|
+
Raises:
|
|
252
|
+
ValueError: If email is invalid
|
|
253
|
+
|
|
254
|
+
Examples:
|
|
255
|
+
>>> validate_email("user@example.com")
|
|
256
|
+
'user@example.com'
|
|
257
|
+
>>> validate_email("")
|
|
258
|
+
None
|
|
259
|
+
>>> validate_email("not-an-email")
|
|
260
|
+
ValueError: Invalid email format
|
|
261
|
+
"""
|
|
262
|
+
email = email.strip()
|
|
263
|
+
|
|
264
|
+
if not email and allow_empty:
|
|
265
|
+
return None
|
|
266
|
+
|
|
267
|
+
if not email and not allow_empty:
|
|
268
|
+
raise ValueError("Email cannot be empty")
|
|
269
|
+
|
|
270
|
+
# Basic email validation
|
|
271
|
+
if not re.match(r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$', email):
|
|
272
|
+
raise ValueError("Invalid email format")
|
|
273
|
+
|
|
274
|
+
return email
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def validate_choice(value: str, choices: List[str], case_sensitive: bool = False) -> str:
|
|
278
|
+
"""
|
|
279
|
+
Validate that value is one of the allowed choices.
|
|
280
|
+
|
|
281
|
+
Args:
|
|
282
|
+
value: Value to validate
|
|
283
|
+
choices: List of allowed values
|
|
284
|
+
case_sensitive: If True, comparison is case-sensitive
|
|
285
|
+
|
|
286
|
+
Returns:
|
|
287
|
+
Validated value (original case)
|
|
288
|
+
|
|
289
|
+
Raises:
|
|
290
|
+
ValueError: If value is not in choices
|
|
291
|
+
|
|
292
|
+
Examples:
|
|
293
|
+
>>> validate_choice("python", ["python", "typescript", "javascript"])
|
|
294
|
+
'python'
|
|
295
|
+
>>> validate_choice("Python", ["python", "typescript"], case_sensitive=False)
|
|
296
|
+
'Python'
|
|
297
|
+
>>> validate_choice("rust", ["python", "typescript"])
|
|
298
|
+
ValueError: Invalid choice: 'rust'. Must be one of: python, typescript
|
|
299
|
+
"""
|
|
300
|
+
value = value.strip()
|
|
301
|
+
|
|
302
|
+
if case_sensitive:
|
|
303
|
+
if value not in choices:
|
|
304
|
+
raise ValueError(f"Invalid choice: '{value}'. Must be one of: {', '.join(choices)}")
|
|
305
|
+
else:
|
|
306
|
+
value_lower = value.lower()
|
|
307
|
+
choices_lower = [c.lower() for c in choices]
|
|
308
|
+
if value_lower not in choices_lower:
|
|
309
|
+
raise ValueError(f"Invalid choice: '{value}'. Must be one of: {', '.join(choices)}")
|
|
310
|
+
|
|
311
|
+
return value
|