@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.
@@ -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