git-worktree-wrapper 0.1.0__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.
@@ -0,0 +1,281 @@
1
+ """Template evaluation engine using simpleeval with strict type checking."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from typing import Any, Callable
7
+
8
+ from simpleeval import EvalWithCompoundTypes, FunctionNotDefined, NameNotDefined
9
+
10
+ from gww.template.functions import TemplateContext, create_function_registry
11
+
12
+
13
+ class TemplateError(Exception):
14
+ """Base exception for template-related errors."""
15
+
16
+ pass
17
+
18
+
19
+ class FunctionTypeError(TemplateError):
20
+ """Raised when function arguments have wrong types."""
21
+
22
+ pass
23
+
24
+
25
+ class ContextError(TemplateError):
26
+ """Raised when required context variable is missing."""
27
+
28
+ pass
29
+
30
+
31
+ class StrictSimpleEval(EvalWithCompoundTypes): # type: ignore[misc]
32
+ """Subclass of simpleeval with strict type checking.
33
+
34
+ Provides:
35
+ - Type validation for function arguments
36
+ - Clear error messages with context
37
+ - Safe evaluation of predicates and templates
38
+ """
39
+
40
+ def __init__(
41
+ self,
42
+ functions: dict[str, Callable[..., Any]] | None = None,
43
+ names: dict[str, Any] | None = None,
44
+ ) -> None:
45
+ """Initialize with custom functions and names.
46
+
47
+ Args:
48
+ functions: Dictionary of functions available during evaluation.
49
+ names: Dictionary of variables available during evaluation.
50
+ """
51
+ super().__init__(functions=functions or {}, names=names or {})
52
+
53
+
54
+ # Pattern to match function calls in templates: function_name(args)
55
+ FUNCTION_CALL_PATTERN = re.compile(r"([a-zA-Z_][a-zA-Z0-9_]*)\s*\(([^()]*)\)")
56
+
57
+ # Escape sequences for literal parentheses
58
+ ESCAPE_OPEN = "\x00ESCAPE_OPEN\x00"
59
+ ESCAPE_CLOSE = "\x00ESCAPE_CLOSE\x00"
60
+
61
+
62
+ def _preprocess_template(template: str) -> tuple[str, list[tuple[str, str]]]:
63
+ """Preprocess template to extract function calls.
64
+
65
+ Handles:
66
+ - Escaped parentheses (( -> single (
67
+ - Function call extraction
68
+
69
+ Args:
70
+ template: Raw template string.
71
+
72
+ Returns:
73
+ Tuple of (preprocessed template with placeholders, list of (placeholder, expression) tuples).
74
+ """
75
+ # Replace escaped parentheses
76
+ processed = template.replace("((", ESCAPE_OPEN)
77
+ processed = processed.replace("))", ESCAPE_CLOSE)
78
+
79
+ # Find and extract function calls
80
+ function_calls: list[tuple[str, str]] = []
81
+ placeholder_idx = 0
82
+
83
+ def replace_function(match: re.Match[str]) -> str:
84
+ nonlocal placeholder_idx
85
+ func_name = match.group(1)
86
+ args = match.group(2)
87
+ expression = f"{func_name}({args})"
88
+ placeholder = f"\x01FUNC_{placeholder_idx}\x01"
89
+ function_calls.append((placeholder, expression))
90
+ placeholder_idx += 1
91
+ return placeholder
92
+
93
+ # Repeatedly find function calls (handles simple nesting)
94
+ prev = None
95
+ while prev != processed:
96
+ prev = processed
97
+ processed = FUNCTION_CALL_PATTERN.sub(replace_function, processed)
98
+
99
+ return processed, function_calls
100
+
101
+
102
+ def _postprocess_template(template: str) -> str:
103
+ """Restore escaped parentheses in template.
104
+
105
+ Args:
106
+ template: Template with escape sequences.
107
+
108
+ Returns:
109
+ Template with single parentheses restored.
110
+ """
111
+ result = template.replace(ESCAPE_OPEN, "(")
112
+ result = result.replace(ESCAPE_CLOSE, ")")
113
+ return result
114
+
115
+
116
+ def evaluate_template(
117
+ template: str,
118
+ context: TemplateContext,
119
+ ) -> str:
120
+ """Evaluate a path template string.
121
+
122
+ Template syntax:
123
+ - Function calls: path(-1), branch(), norm_branch("_")
124
+ - Escaped parentheses: (( -> (
125
+ - Static text: passed through as-is
126
+
127
+ Args:
128
+ template: Template string to evaluate.
129
+ context: Template context with URI, branch, etc.
130
+
131
+ Returns:
132
+ Evaluated template string.
133
+
134
+ Raises:
135
+ TemplateError: If evaluation fails.
136
+ FunctionTypeError: If function arguments are invalid.
137
+ ContextError: If required context is missing.
138
+ """
139
+ # Create function registry
140
+ functions = create_function_registry(context)
141
+
142
+ # Preprocess template
143
+ processed, function_calls = _preprocess_template(template)
144
+
145
+ # Evaluate each function call
146
+ evaluator = StrictSimpleEval(functions=functions)
147
+
148
+ for placeholder, expression in function_calls:
149
+ try:
150
+ result = evaluator.eval(expression)
151
+ if not isinstance(result, str):
152
+ result = str(result)
153
+ processed = processed.replace(placeholder, result)
154
+ except FunctionNotDefined as e:
155
+ raise TemplateError(f"Unknown function in template: {e}") from e
156
+ except NameNotDefined as e:
157
+ raise TemplateError(f"Unknown variable in template: {e}") from e
158
+ except ValueError as e:
159
+ raise ContextError(str(e)) from e
160
+ except TypeError as e:
161
+ raise FunctionTypeError(f"Invalid argument types: {e}") from e
162
+ except Exception as e:
163
+ raise TemplateError(f"Template evaluation failed: {e}") from e
164
+
165
+ # Postprocess to restore escaped parentheses
166
+ return _postprocess_template(processed)
167
+
168
+
169
+ def evaluate_predicate(
170
+ predicate: str,
171
+ variables: dict[str, Any],
172
+ ) -> bool:
173
+ """Evaluate a predicate expression.
174
+
175
+ Predicate syntax:
176
+ - Comparison: host == "github.com"
177
+ - Contains: "github" in host
178
+ - Logical: not contains(host, "scp") and len(path) > 1
179
+
180
+ Args:
181
+ predicate: Predicate expression string.
182
+ variables: Dictionary of variables and functions for evaluation.
183
+ Callable values are treated as functions, others as variables.
184
+
185
+ Returns:
186
+ Boolean result of predicate evaluation.
187
+
188
+ Raises:
189
+ TemplateError: If evaluation fails or result is not boolean.
190
+ """
191
+ # Separate functions from variables
192
+ functions: dict[str, Callable[..., Any]] = {}
193
+ names: dict[str, Any] = {}
194
+
195
+ for key, value in variables.items():
196
+ if callable(value):
197
+ functions[key] = value
198
+ else:
199
+ names[key] = value
200
+
201
+ evaluator = StrictSimpleEval(functions=functions, names=names)
202
+
203
+ try:
204
+ result = evaluator.eval(predicate)
205
+ except FunctionNotDefined as e:
206
+ raise TemplateError(f"Unknown function in predicate: {e}") from e
207
+ except NameNotDefined as e:
208
+ raise TemplateError(f"Unknown variable in predicate: {e}") from e
209
+ except Exception as e:
210
+ raise TemplateError(f"Predicate evaluation failed: {e}") from e
211
+
212
+ if not isinstance(result, bool):
213
+ raise TemplateError(
214
+ f"Predicate must evaluate to boolean, got {type(result).__name__}: {predicate}"
215
+ )
216
+
217
+ return result
218
+
219
+
220
+ def evaluate_command_template(
221
+ command: str,
222
+ variables: dict[str, Any],
223
+ ) -> str:
224
+ """Evaluate a command template string with function calls.
225
+
226
+ Command template syntax:
227
+ - Function calls: current_worktree(), tag("name"), source_path()
228
+ - Escaped parentheses: (( -> (
229
+ - Static text: passed through as-is
230
+
231
+ This is similar to evaluate_template() but accepts a pre-built context
232
+ dictionary (like evaluate_predicate()) instead of a TemplateContext.
233
+
234
+ Args:
235
+ command: Command template string to evaluate.
236
+ variables: Dictionary of variables and functions for evaluation.
237
+ Callable values are treated as functions, others as variables.
238
+
239
+ Returns:
240
+ Evaluated command string with all functions replaced.
241
+
242
+ Raises:
243
+ TemplateError: If evaluation fails.
244
+ FunctionTypeError: If function arguments are invalid.
245
+ ContextError: If required context is missing.
246
+ """
247
+ # Separate functions from variables
248
+ functions: dict[str, Callable[..., Any]] = {}
249
+ names: dict[str, Any] = {}
250
+
251
+ for key, value in variables.items():
252
+ if callable(value):
253
+ functions[key] = value
254
+ else:
255
+ names[key] = value
256
+
257
+ # Preprocess template to extract function calls
258
+ processed, function_calls = _preprocess_template(command)
259
+
260
+ # Evaluate each function call
261
+ evaluator = StrictSimpleEval(functions=functions, names=names)
262
+
263
+ for placeholder, expression in function_calls:
264
+ try:
265
+ result = evaluator.eval(expression)
266
+ if not isinstance(result, str):
267
+ result = str(result)
268
+ processed = processed.replace(placeholder, result)
269
+ except FunctionNotDefined as e:
270
+ raise TemplateError(f"Unknown function in command: {e}") from e
271
+ except NameNotDefined as e:
272
+ raise TemplateError(f"Unknown variable in command: {e}") from e
273
+ except ValueError as e:
274
+ raise ContextError(str(e)) from e
275
+ except TypeError as e:
276
+ raise FunctionTypeError(f"Invalid argument types: {e}") from e
277
+ except Exception as e:
278
+ raise TemplateError(f"Command template evaluation failed: {e}") from e
279
+
280
+ # Postprocess to restore escaped parentheses
281
+ return _postprocess_template(processed)
@@ -0,0 +1,378 @@
1
+ """Template function registry for path template evaluation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+ from datetime import datetime
7
+ from pathlib import Path
8
+ from typing import Any, Callable, Optional
9
+
10
+ from gww.utils.uri import ParsedURI
11
+
12
+
13
+ @dataclass
14
+ class TemplateContext:
15
+ """Context for template evaluation.
16
+
17
+ The same context object feeds every evaluation site that uses the unified
18
+ function registry: source-rule ``when`` predicates, ``default_sources``
19
+ and ``default_worktrees`` path templates, and project-rule ``when``
20
+ predicates plus their command templates.
21
+
22
+ Attributes:
23
+ uri: Parsed URI object. Populated for ``clone`` (from CLI) and for
24
+ ``add`` (from the source repo's ``origin`` remote — *not* the
25
+ original clone URL if the user later changed remotes).
26
+ branch: Git branch name. For ``add``, the user-supplied branch. For
27
+ ``clone``, the branch git checked out by default (the remote's
28
+ HEAD) after the clone operation completes; ``""`` if HEAD is
29
+ detached.
30
+ source_path: Source repository path. Always set for project-rule
31
+ predicates; feeds the ``source_path(extra?)`` template function
32
+ and the ``file_exists``/``dir_exists``/``path_exists`` helpers.
33
+ dest_path: Destination path for project-rule actions — the worktree
34
+ path for ``after_add`` and ``before_remove``, the source path
35
+ for ``after_clone``. Feeds the ``current_worktree(extra?)``
36
+ template function. ``None`` in non-project evaluation sites
37
+ (URI predicates, path templates).
38
+ tags: Dictionary of tag key-value pairs from the CLI.
39
+ """
40
+
41
+ uri: Optional[ParsedURI] = None
42
+ branch: Optional[str] = None
43
+ source_path: Optional[Path] = None
44
+ dest_path: Optional[Path] = None
45
+ tags: dict[str, str] = field(default_factory=dict)
46
+
47
+
48
+ class FunctionRegistry:
49
+ """Registry of template functions available during evaluation.
50
+
51
+ Provides shared functions available in templates, URI predicates, and project predicates:
52
+ - URI functions: host(), port(), protocol(), uri(), path(index)
53
+ - Branch functions: branch(), norm_branch(replacement)
54
+ - Tag functions: tag(name), tag_exist(name)
55
+
56
+ Template-only functions (not available in predicates):
57
+ - Utility functions: time_id(fmt) - generates datetime-based identifier strings
58
+ """
59
+
60
+ def __init__(self, context: TemplateContext) -> None:
61
+ """Initialize registry with evaluation context.
62
+
63
+ Args:
64
+ context: Template context with URI, branch, etc.
65
+ """
66
+ self._context = context
67
+ self._functions: dict[str, Callable[..., Any]] = {}
68
+ self._cached_datetime: Optional[datetime] = None
69
+ self._register_builtin_functions()
70
+
71
+ def _register_builtin_functions(self) -> None:
72
+ """Register all built-in template functions."""
73
+ # URI functions
74
+ self._functions["host"] = self._host
75
+ self._functions["port"] = self._port
76
+ self._functions["protocol"] = self._protocol
77
+ self._functions["uri"] = self._uri
78
+ self._functions["path"] = self._path
79
+ # Branch functions
80
+ self._functions["branch"] = self._branch
81
+ self._functions["norm_branch"] = self._norm_branch
82
+ # Tag functions
83
+ self._functions["tag"] = self._tag
84
+ self._functions["tag_exist"] = self._tag_exist
85
+ # Utility functions (template-only)
86
+ self._functions["time_id"] = self._time_id
87
+
88
+ def get_functions(self) -> dict[str, Callable[..., Any]]:
89
+ """Return dictionary of all registered functions.
90
+
91
+ Returns:
92
+ Dictionary mapping function names to callables.
93
+ """
94
+ return self._functions.copy()
95
+
96
+ # --- URI Functions ---
97
+
98
+ def _host(self) -> str:
99
+ """Get URI hostname.
100
+
101
+ Returns:
102
+ Hostname from URI (e.g., "github.com").
103
+
104
+ Raises:
105
+ ValueError: If no URI context available.
106
+ """
107
+ if self._context.uri is None:
108
+ raise ValueError("No URI context available for host() function")
109
+ return self._context.uri.host
110
+
111
+ def _port(self) -> str:
112
+ """Get URI port.
113
+
114
+ Returns:
115
+ Port from URI, empty string if not specified.
116
+
117
+ Raises:
118
+ ValueError: If no URI context available.
119
+ """
120
+ if self._context.uri is None:
121
+ raise ValueError("No URI context available for port() function")
122
+ return self._context.uri.port
123
+
124
+ def _protocol(self) -> str:
125
+ """Get URI protocol/scheme.
126
+
127
+ Returns:
128
+ Protocol from URI (e.g., "https", "ssh", "git").
129
+
130
+ Raises:
131
+ ValueError: If no URI context available.
132
+ """
133
+ if self._context.uri is None:
134
+ raise ValueError("No URI context available for protocol() function")
135
+ return self._context.uri.protocol
136
+
137
+ def _uri(self) -> str:
138
+ """Get full URI string.
139
+
140
+ Returns:
141
+ Full URI string.
142
+
143
+ Raises:
144
+ ValueError: If no URI context available.
145
+ """
146
+ if self._context.uri is None:
147
+ raise ValueError("No URI context available for uri() function")
148
+ return self._context.uri.uri
149
+
150
+ def _path(self, index: int) -> str:
151
+ """Get URI path segment by index.
152
+
153
+ Args:
154
+ index: Path segment index (0-based, negative for reverse).
155
+ Example: path(-1) returns last segment, path(0) returns first.
156
+
157
+ Returns:
158
+ Path segment string at the specified index.
159
+
160
+ Raises:
161
+ ValueError: If no URI context or index out of range.
162
+ """
163
+ if self._context.uri is None:
164
+ raise ValueError("No URI context available for path() function")
165
+
166
+ try:
167
+ return self._context.uri.path(index)
168
+ except IndexError:
169
+ raise ValueError(
170
+ f"Path segment index {index} out of range. "
171
+ f"Available segments: {self._context.uri.path_segments}"
172
+ )
173
+
174
+ def _branch(self) -> str:
175
+ """Get current branch name as-is.
176
+
177
+ Returns:
178
+ Branch name.
179
+
180
+ Raises:
181
+ ValueError: If no branch context available.
182
+ """
183
+ if self._context.branch is None:
184
+ raise ValueError("No branch context available for branch() function")
185
+ return self._context.branch
186
+
187
+ def _norm_branch(self, replacement: str = "-") -> str:
188
+ """Get branch name with '/' replaced.
189
+
190
+ Args:
191
+ replacement: Character to replace '/' with (default: '-').
192
+
193
+ Returns:
194
+ Normalized branch name.
195
+
196
+ Raises:
197
+ ValueError: If no branch context available.
198
+ """
199
+ if self._context.branch is None:
200
+ raise ValueError("No branch context available for norm_branch() function")
201
+ return self._context.branch.replace("/", replacement)
202
+
203
+ def _tag(self, name: str) -> str:
204
+ """Get tag value by name.
205
+
206
+ Args:
207
+ name: Tag name.
208
+
209
+ Returns:
210
+ Tag value if tag exists with a value, empty string otherwise.
211
+ """
212
+ return self._context.tags.get(name, "")
213
+
214
+ def _tag_exist(self, name: str) -> bool:
215
+ """Check if tag exists.
216
+
217
+ Args:
218
+ name: Tag name.
219
+
220
+ Returns:
221
+ True if tag exists (with or without value), False otherwise.
222
+ """
223
+ return name in self._context.tags
224
+
225
+ # --- Utility Functions ---
226
+
227
+ def _time_id(self, fmt: str = "%Y%m%d-%H%M.%S") -> str:
228
+ """Generate a datetime-based identifier string.
229
+
230
+ The datetime is captured on first call and cached for subsequent calls
231
+ within the same template evaluation session. This ensures consistent
232
+ timestamps when time_id() is called multiple times with different formats.
233
+
234
+ Args:
235
+ fmt: Optional strftime format string. If not provided, uses default
236
+ format "%Y%m%d-%H%M.%S" (e.g., "20260120-2134.03").
237
+
238
+ Returns:
239
+ Formatted datetime string.
240
+ """
241
+ if self._cached_datetime is None:
242
+ self._cached_datetime = datetime.now()
243
+
244
+ return self._cached_datetime.strftime(fmt)
245
+
246
+
247
+ def create_function_registry(context: TemplateContext) -> dict[str, Callable[..., Any]]:
248
+ """Create a function registry for template evaluation.
249
+
250
+ Args:
251
+ context: Template context with URI, branch, etc.
252
+
253
+ Returns:
254
+ Dictionary of functions to pass to simpleeval.
255
+ """
256
+ registry = FunctionRegistry(context)
257
+ return registry.get_functions()
258
+
259
+
260
+ def create_project_functions(
261
+ context: TemplateContext,
262
+ ) -> dict[str, Callable[..., Any]]:
263
+ """Create project-specific functions for project predicate evaluation.
264
+
265
+ These functions are only available in project predicates, not in templates
266
+ or URI predicates. The path-bearing helpers follow a fixed mapping that
267
+ does not vary by operation (ADR-0012 §"Uniform semantics across
268
+ operations"):
269
+
270
+ * ``source_path(extra?)`` is ``context.source_path`` (optionally joined
271
+ with ``extra``).
272
+ * ``current_worktree(extra?)`` is ``context.dest_path`` (optionally joined
273
+ with ``extra``).
274
+
275
+ Neither function aliases the other under any condition. They may resolve
276
+ to the same path string during ``gww clone`` because the CLI populates
277
+ *both* context fields with the clone target, but that is a CLI-side
278
+ property of how the calling command populates the context — never an
279
+ aliasing inside the helper itself.
280
+
281
+ Args:
282
+ context: Template context whose ``source_path`` and ``dest_path`` are
283
+ used by the returned functions. ``source_path`` must be set;
284
+ ``dest_path`` is required for ``current_worktree()`` to evaluate
285
+ and raises ``ValueError`` when ``None``.
286
+
287
+ Returns:
288
+ Dictionary of project-specific functions.
289
+
290
+ Raises:
291
+ ValueError: If ``context.source_path`` is ``None``.
292
+ """
293
+ if context.source_path is None:
294
+ raise ValueError(
295
+ "create_project_functions requires context.source_path"
296
+ )
297
+ source_path = context.source_path
298
+ dest_path = context.dest_path
299
+
300
+ def _source_path(extra: str = "") -> str:
301
+ """Get absolute path to source repository, optionally joined with ``extra``.
302
+
303
+ Args:
304
+ extra: Path segment to append to the source repository root. An
305
+ empty string returns the bare source path.
306
+
307
+ Returns:
308
+ Absolute, resolved path to the source repository (or the joined
309
+ path when ``extra`` is non-empty).
310
+ """
311
+ return str((source_path / extra).resolve())
312
+
313
+ def _current_worktree(extra: str = "") -> str:
314
+ """Get absolute path to the current worktree, optionally joined with ``extra``.
315
+
316
+ Args:
317
+ extra: Path segment to append to the worktree root. An empty
318
+ string returns the bare worktree path.
319
+
320
+ Returns:
321
+ Absolute, resolved path to the worktree (or the joined path when
322
+ ``extra`` is non-empty).
323
+
324
+ Raises:
325
+ ValueError: If ``context.dest_path`` is ``None``. The function
326
+ does not fall back to ``source_path()`` — that would re-
327
+ introduce the per-operation aliasing the uniform-mapping
328
+ principle rules out (ADR-0012).
329
+ """
330
+ if dest_path is None:
331
+ raise ValueError(
332
+ "current_worktree() requires context.dest_path"
333
+ )
334
+ return str((dest_path / extra).resolve())
335
+
336
+ def _file_exists(path: str) -> bool:
337
+ """Check if a file exists relative to source repository.
338
+
339
+ Args:
340
+ path: Relative path to check.
341
+
342
+ Returns:
343
+ True if file exists.
344
+ """
345
+ full_path = source_path / path
346
+ return full_path.is_file()
347
+
348
+ def _dir_exists(path: str) -> bool:
349
+ """Check if a directory exists relative to source repository.
350
+
351
+ Args:
352
+ path: Relative path to check.
353
+
354
+ Returns:
355
+ True if directory exists.
356
+ """
357
+ full_path = source_path / path
358
+ return full_path.is_dir()
359
+
360
+ def _path_exists(path: str) -> bool:
361
+ """Check if a path exists (file or directory) relative to source repository.
362
+
363
+ Args:
364
+ path: Relative path to check.
365
+
366
+ Returns:
367
+ True if path exists.
368
+ """
369
+ full_path = source_path / path
370
+ return full_path.exists()
371
+
372
+ return {
373
+ "source_path": _source_path,
374
+ "current_worktree": _current_worktree,
375
+ "file_exists": _file_exists,
376
+ "dir_exists": _dir_exists,
377
+ "path_exists": _path_exists,
378
+ }
gww/utils/__init__.py ADDED
@@ -0,0 +1 @@
1
+ """Utility functions."""