codeupipe 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.
Files changed (60) hide show
  1. codeupipe/__init__.py +39 -0
  2. codeupipe/cli.py +1502 -0
  3. codeupipe/converter/__init__.py +9 -0
  4. codeupipe/converter/config.py +119 -0
  5. codeupipe/converter/filters/__init__.py +21 -0
  6. codeupipe/converter/filters/analyze.py +60 -0
  7. codeupipe/converter/filters/classify.py +52 -0
  8. codeupipe/converter/filters/classify_files.py +61 -0
  9. codeupipe/converter/filters/generate_export.py +187 -0
  10. codeupipe/converter/filters/generate_import.py +229 -0
  11. codeupipe/converter/filters/parse_config.py +26 -0
  12. codeupipe/converter/filters/scan_project.py +52 -0
  13. codeupipe/converter/pipelines/__init__.py +8 -0
  14. codeupipe/converter/pipelines/export_pipeline.py +40 -0
  15. codeupipe/converter/pipelines/import_pipeline.py +40 -0
  16. codeupipe/converter/taps/__init__.py +7 -0
  17. codeupipe/converter/taps/conversion_log.py +46 -0
  18. codeupipe/core/__init__.py +20 -0
  19. codeupipe/core/filter.py +27 -0
  20. codeupipe/core/hook.py +34 -0
  21. codeupipe/core/payload.py +94 -0
  22. codeupipe/core/pipeline.py +231 -0
  23. codeupipe/core/state.py +78 -0
  24. codeupipe/core/stream_filter.py +33 -0
  25. codeupipe/core/tap.py +27 -0
  26. codeupipe/core/valve.py +52 -0
  27. codeupipe/linter/__init__.py +57 -0
  28. codeupipe/linter/assemble_doc_report.py +97 -0
  29. codeupipe/linter/assemble_report.py +140 -0
  30. codeupipe/linter/check_bundle.py +47 -0
  31. codeupipe/linter/check_index.py +88 -0
  32. codeupipe/linter/check_naming.py +47 -0
  33. codeupipe/linter/check_protocols.py +73 -0
  34. codeupipe/linter/check_structure.py +41 -0
  35. codeupipe/linter/check_symbols.py +116 -0
  36. codeupipe/linter/check_tests.py +48 -0
  37. codeupipe/linter/coverage_pipeline.py +39 -0
  38. codeupipe/linter/detect_drift.py +44 -0
  39. codeupipe/linter/detect_orphans.py +106 -0
  40. codeupipe/linter/doc_check_pipeline.py +28 -0
  41. codeupipe/linter/git_history.py +130 -0
  42. codeupipe/linter/lint_pipeline.py +51 -0
  43. codeupipe/linter/map_coverage.py +84 -0
  44. codeupipe/linter/report_gaps.py +68 -0
  45. codeupipe/linter/report_pipeline.py +49 -0
  46. codeupipe/linter/resolve_refs.py +48 -0
  47. codeupipe/linter/scan_components.py +95 -0
  48. codeupipe/linter/scan_directory.py +123 -0
  49. codeupipe/linter/scan_docs.py +62 -0
  50. codeupipe/linter/scan_tests.py +104 -0
  51. codeupipe/py.typed +1 -0
  52. codeupipe/testing.py +344 -0
  53. codeupipe/utils/__init__.py +10 -0
  54. codeupipe/utils/error_handling.py +68 -0
  55. codeupipe-0.1.0.dist-info/METADATA +216 -0
  56. codeupipe-0.1.0.dist-info/RECORD +60 -0
  57. codeupipe-0.1.0.dist-info/WHEEL +5 -0
  58. codeupipe-0.1.0.dist-info/entry_points.txt +2 -0
  59. codeupipe-0.1.0.dist-info/licenses/LICENSE +190 -0
  60. codeupipe-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,123 @@
1
+ """
2
+ ScanDirectory: Discover and AST-analyze all .py files in a directory.
3
+ """
4
+
5
+ import ast
6
+ import re
7
+ from pathlib import Path
8
+ from typing import Optional
9
+
10
+ from codeupipe import Payload
11
+
12
+
13
+ _SNAKE_RE = re.compile(r"^[a-z][a-z0-9]*(_[a-z0-9]+)*$")
14
+
15
+
16
+ def classify_class(node: ast.ClassDef) -> Optional[str]:
17
+ """Classify an AST class node as a CUP component type or None."""
18
+ methods = {
19
+ n.name
20
+ for n in ast.iter_child_nodes(node)
21
+ if isinstance(n, (ast.FunctionDef, ast.AsyncFunctionDef))
22
+ }
23
+
24
+ for base in node.bases:
25
+ base_name = getattr(base, "id", None) or getattr(
26
+ getattr(base, "attr", None), "__str__", lambda: ""
27
+ )()
28
+ if base_name == "Hook":
29
+ return "hook"
30
+
31
+ if "stream" in methods:
32
+ return "stream-filter"
33
+ if "call" in methods:
34
+ return "filter"
35
+ if "observe" in methods:
36
+ return "tap"
37
+
38
+ return None
39
+
40
+
41
+ def analyze_file(filepath: Path) -> dict:
42
+ """Analyze a single Python file and return component info.
43
+
44
+ Returns dict with:
45
+ 'path': str filepath
46
+ 'stem': filename without .py
47
+ 'classes': list of (name, component_type, methods)
48
+ 'functions': list of function names
49
+ 'error': parse error string or None
50
+ """
51
+ try:
52
+ source = filepath.read_text()
53
+ tree = ast.parse(source, filename=str(filepath))
54
+ except SyntaxError as e:
55
+ return {
56
+ "path": str(filepath),
57
+ "stem": filepath.stem,
58
+ "classes": [],
59
+ "functions": [],
60
+ "error": str(e),
61
+ }
62
+ except OSError as e:
63
+ return {
64
+ "path": str(filepath),
65
+ "stem": filepath.stem,
66
+ "classes": [],
67
+ "functions": [],
68
+ "error": str(e),
69
+ }
70
+
71
+ classes = []
72
+ functions = []
73
+
74
+ for node in ast.iter_child_nodes(tree):
75
+ if isinstance(node, ast.ClassDef) and not node.name.startswith("_"):
76
+ methods = {
77
+ n.name
78
+ for n in ast.iter_child_nodes(node)
79
+ if isinstance(n, (ast.FunctionDef, ast.AsyncFunctionDef))
80
+ }
81
+ ctype = classify_class(node)
82
+ classes.append((node.name, ctype, methods))
83
+ elif isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
84
+ if not node.name.startswith("_"):
85
+ functions.append(node.name)
86
+
87
+ return {
88
+ "path": str(filepath),
89
+ "stem": filepath.stem,
90
+ "classes": classes,
91
+ "functions": functions,
92
+ "error": None,
93
+ }
94
+
95
+
96
+ class ScanDirectory:
97
+ """
98
+ Filter (sync): Read a directory and AST-analyze every .py file.
99
+
100
+ Input keys:
101
+ - directory (str): path to the directory to scan
102
+
103
+ Output keys (added):
104
+ - files (list[dict]): analysis of each .py file
105
+ - issues (list): initialized empty issue list
106
+ """
107
+
108
+ def call(self, payload: Payload) -> Payload:
109
+ directory = payload.get("directory")
110
+ dir_path = Path(directory)
111
+
112
+ if not dir_path.is_dir():
113
+ raise FileNotFoundError(f"Directory not found: {directory}")
114
+
115
+ files = []
116
+ for py_file in sorted(dir_path.glob("*.py")):
117
+ if py_file.name == "__init__.py":
118
+ continue
119
+ files.append(analyze_file(py_file))
120
+
121
+ payload = payload.insert("files", files)
122
+ payload = payload.insert("issues", [])
123
+ return payload
@@ -0,0 +1,62 @@
1
+ """
2
+ ScanDocs: Extract cup:ref markers from markdown files.
3
+
4
+ Scans all .md files in a directory for <!-- cup:ref ... --> markers
5
+ and produces a list of doc-code references for downstream validation.
6
+ """
7
+
8
+ import re
9
+ from pathlib import Path
10
+
11
+ from codeupipe import Payload
12
+
13
+
14
+ _MARKER_RE = re.compile(
15
+ r"<!--\s*cup:ref\s+(.*?)\s*-->",
16
+ re.IGNORECASE,
17
+ )
18
+
19
+ _ATTR_RE = re.compile(r"(\w+)=(\S+)")
20
+
21
+
22
+ class ScanDocs:
23
+ """
24
+ Filter (sync): Scan .md files for cup:ref markers.
25
+
26
+ Input keys:
27
+ - directory (str): root directory to scan
28
+
29
+ Output keys (added):
30
+ - doc_refs (list[dict]): extracted references, each with:
31
+ file, symbols, hash, doc_path, line
32
+ """
33
+
34
+ def call(self, payload: Payload) -> Payload:
35
+ directory = Path(payload.get("directory", "."))
36
+ refs = []
37
+
38
+ for md_path in sorted(directory.glob("*.md")):
39
+ content = md_path.read_text(encoding="utf-8", errors="replace")
40
+ for line_num, line in enumerate(content.splitlines(), start=1):
41
+ match = _MARKER_RE.search(line)
42
+ if not match:
43
+ continue
44
+
45
+ attrs_str = match.group(1)
46
+ attrs = dict(_ATTR_RE.findall(attrs_str))
47
+
48
+ if "file" not in attrs:
49
+ continue
50
+
51
+ symbols_raw = attrs.get("symbols", "")
52
+ symbols = [s for s in symbols_raw.split(",") if s]
53
+
54
+ refs.append({
55
+ "file": attrs["file"],
56
+ "symbols": symbols,
57
+ "hash": attrs.get("hash", None),
58
+ "doc_path": str(md_path),
59
+ "line": line_num,
60
+ })
61
+
62
+ return payload.insert("doc_refs", refs)
@@ -0,0 +1,104 @@
1
+ """
2
+ ScanTests: Discover test files and extract tested symbols.
3
+ """
4
+
5
+ import ast
6
+ import re
7
+ from pathlib import Path
8
+
9
+ from codeupipe import Payload
10
+
11
+
12
+ def _extract_imports(tree: ast.Module) -> set:
13
+ """Extract all imported names from a module AST."""
14
+ names = set()
15
+ for node in ast.walk(tree):
16
+ if isinstance(node, ast.ImportFrom):
17
+ for alias in (node.names or []):
18
+ names.add(alias.name)
19
+ elif isinstance(node, ast.Import):
20
+ for alias in (node.names or []):
21
+ names.add(alias.name.split(".")[-1])
22
+ return names
23
+
24
+
25
+ def _extract_test_methods(tree: ast.Module) -> list:
26
+ """Extract test method names from test classes and top-level test functions."""
27
+ methods = []
28
+ for node in ast.iter_child_nodes(tree):
29
+ if isinstance(node, ast.ClassDef) and node.name.startswith("Test"):
30
+ for child in ast.iter_child_nodes(node):
31
+ if isinstance(child, (ast.FunctionDef, ast.AsyncFunctionDef)):
32
+ if child.name.startswith("test_"):
33
+ methods.append(child.name)
34
+ elif isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
35
+ if node.name.startswith("test_"):
36
+ methods.append(node.name)
37
+ return methods
38
+
39
+
40
+ def _extract_tested_methods(tree: ast.Module) -> set:
41
+ """Extract method names referenced in test bodies via attribute access.
42
+
43
+ Looks for patterns like `obj.call(`, `obj.observe(`, `filter.call(` etc.
44
+ """
45
+ tested = set()
46
+ for node in ast.walk(tree):
47
+ if isinstance(node, ast.Attribute):
48
+ tested.add(node.attr)
49
+ return tested
50
+
51
+
52
+ class ScanTests:
53
+ """
54
+ Filter (sync): Parse test files and map which component symbols are tested.
55
+
56
+ Input keys:
57
+ - components (list[dict]): from ScanComponents
58
+ - tests_dir (str, optional): path to tests directory (default: "tests")
59
+
60
+ Output keys (added):
61
+ - test_map (list[dict]): each with keys:
62
+ - test_file (str): path to the test file
63
+ - stem (str): component stem the test covers
64
+ - imports (set[str]): imported symbol names
65
+ - test_methods (list[str]): test_* method names
66
+ - referenced_methods (set[str]): methods called in test bodies
67
+ """
68
+
69
+ def call(self, payload: Payload) -> Payload:
70
+ components = payload.get("components", [])
71
+ tests_dir = Path(payload.get("tests_dir", "tests"))
72
+
73
+ test_map = []
74
+
75
+ # Build a set of stems we know about
76
+ known_stems = {c["stem"] for c in components}
77
+
78
+ # Scan test files that correspond to known component stems
79
+ if tests_dir.is_dir():
80
+ for test_file in sorted(tests_dir.glob("test_*.py")):
81
+ # test_validate_email.py → validate_email
82
+ stem = test_file.stem[5:] # strip "test_"
83
+ if stem not in known_stems:
84
+ continue
85
+
86
+ try:
87
+ source = test_file.read_text()
88
+ tree = ast.parse(source, filename=str(test_file))
89
+ except (SyntaxError, OSError):
90
+ continue
91
+
92
+ imports = _extract_imports(tree)
93
+ test_methods = _extract_test_methods(tree)
94
+ referenced = _extract_tested_methods(tree)
95
+
96
+ test_map.append({
97
+ "test_file": str(test_file),
98
+ "stem": stem,
99
+ "imports": imports,
100
+ "test_methods": test_methods,
101
+ "referenced_methods": referenced,
102
+ })
103
+
104
+ return payload.insert("test_map", test_map)
codeupipe/py.typed ADDED
@@ -0,0 +1 @@
1
+ # This file indicates that the package supports type hints
codeupipe/testing.py ADDED
@@ -0,0 +1,344 @@
1
+ """
2
+ codeupipe.testing — Test utilities optimized for CUP projects.
3
+
4
+ Provides zero-boilerplate helpers for testing filters, pipelines, taps,
5
+ hooks, and stream filters. One import, no asyncio or Pipeline wiring needed.
6
+
7
+ Usage:
8
+ from codeupipe.testing import run_filter, assert_payload, mock_filter
9
+
10
+ def test_my_filter():
11
+ result = run_filter(MyFilter(), {"input": "data"})
12
+ assert_payload(result, output="expected")
13
+ """
14
+
15
+ import asyncio
16
+ import inspect
17
+ from pathlib import Path
18
+ from typing import Any, Dict, List, Optional, Sequence, Union
19
+
20
+ from codeupipe import Payload, Pipeline
21
+ from codeupipe.core.hook import Hook
22
+ from codeupipe.core.state import State
23
+
24
+
25
+ __all__ = [
26
+ "run_filter",
27
+ "run_pipeline",
28
+ "assert_pipeline_streaming",
29
+ "assert_payload",
30
+ "assert_keys",
31
+ "assert_state",
32
+ "mock_filter",
33
+ "mock_tap",
34
+ "mock_hook",
35
+ "cup_component",
36
+ "RecordingTap",
37
+ "RecordingHook",
38
+ ]
39
+
40
+
41
+ # ── Runners ─────────────────────────────────────────────────────────
42
+
43
+ def _to_payload(data: Union[dict, Payload]) -> Payload:
44
+ """Coerce dict or Payload to Payload."""
45
+ if isinstance(data, Payload):
46
+ return data
47
+ return Payload(data)
48
+
49
+
50
+ def run_filter(filter_instance, data: Union[dict, Payload]) -> Payload:
51
+ """Run a single filter with minimal boilerplate.
52
+
53
+ Handles sync/async transparently. Accepts dict or Payload.
54
+ Returns the output Payload.
55
+ """
56
+ payload = _to_payload(data)
57
+ result = filter_instance.call(payload)
58
+ if inspect.isawaitable(result):
59
+ result = asyncio.run(result)
60
+ return result
61
+
62
+
63
+ def run_pipeline(
64
+ pipeline: Pipeline,
65
+ data: Union[dict, Payload],
66
+ return_state: bool = False,
67
+ ) -> Union[Payload, tuple]:
68
+ """Run a pipeline with minimal boilerplate.
69
+
70
+ Args:
71
+ pipeline: Wired Pipeline instance.
72
+ data: Input dict or Payload.
73
+ return_state: If True, returns (result, state) tuple.
74
+
75
+ Returns:
76
+ Payload, or (Payload, State) if return_state=True.
77
+ """
78
+ payload = _to_payload(data)
79
+ result = asyncio.run(pipeline.run(payload))
80
+ if return_state:
81
+ return result, pipeline.state
82
+ return result
83
+
84
+
85
+ def assert_pipeline_streaming(
86
+ pipeline: Pipeline,
87
+ chunks: Sequence[Union[dict, Payload]],
88
+ ) -> List[Payload]:
89
+ """Run a pipeline in stream mode and collect all output chunks.
90
+
91
+ Name reflects both dimensions: it runs a *pipeline* through its *stream*
92
+ path, collecting results for assertion.
93
+
94
+ Args:
95
+ pipeline: Pipeline with stream-capable filters.
96
+ chunks: Input chunks (dicts or Payloads).
97
+
98
+ Returns:
99
+ List of output Payloads.
100
+ """
101
+ payloads = [_to_payload(c) for c in chunks]
102
+
103
+ async def _source():
104
+ for p in payloads:
105
+ yield p
106
+
107
+ async def _collect():
108
+ results = []
109
+ async for chunk in pipeline.stream(_source()):
110
+ results.append(chunk)
111
+ return results
112
+
113
+ return asyncio.run(_collect())
114
+
115
+
116
+ # ── Assertions ──────────────────────────────────────────────────────
117
+
118
+ def assert_payload(payload: Payload, **expected: Any) -> None:
119
+ """Assert payload contains all expected key=value pairs.
120
+
121
+ Usage:
122
+ assert_payload(result, status="ok", count=3)
123
+ """
124
+ for key, expected_value in expected.items():
125
+ actual = payload.get(key)
126
+ assert actual == expected_value, (
127
+ f"Payload key '{key}': expected {expected_value!r}, got {actual!r}"
128
+ )
129
+
130
+
131
+ def assert_keys(payload: Payload, *keys: str) -> None:
132
+ """Assert payload contains all specified keys.
133
+
134
+ Usage:
135
+ assert_keys(result, "user_id", "name", "email")
136
+ """
137
+ data = payload.to_dict()
138
+ for key in keys:
139
+ assert key in data, f"Payload missing expected key '{key}'. Keys present: {list(data.keys())}"
140
+
141
+
142
+ def assert_state(state: State, executed: Optional[List[str]] = None) -> None:
143
+ """Assert pipeline state after execution.
144
+
145
+ Usage:
146
+ _, state = run_pipeline(pipeline, data, return_state=True)
147
+ assert_state(state, executed=["step_a", "step_b"])
148
+ """
149
+ if executed is not None:
150
+ for step in executed:
151
+ assert step in state.executed, (
152
+ f"Step '{step}' not in state.executed. "
153
+ f"Executed: {list(state.executed)}"
154
+ )
155
+
156
+
157
+ # ── Mocks ───────────────────────────────────────────────────────────
158
+
159
+ class _MockFilter:
160
+ """Filter mock that inserts predefined data and records calls."""
161
+
162
+ def __init__(self, **data: Any):
163
+ self._data = data
164
+ self.call_count = 0
165
+ self.last_payload: Optional[Payload] = None
166
+
167
+ def call(self, payload: Payload) -> Payload:
168
+ self.call_count += 1
169
+ self.last_payload = payload
170
+ result = payload
171
+ for key, value in self._data.items():
172
+ result = result.insert(key, value)
173
+ return result
174
+
175
+
176
+ def mock_filter(**data: Any) -> _MockFilter:
177
+ """Create a mock filter that inserts the given key-value pairs.
178
+
179
+ Usage:
180
+ f = mock_filter(status="ok", processed=True)
181
+ result = run_filter(f, {"input": 1})
182
+ """
183
+ return _MockFilter(**data)
184
+
185
+
186
+ class RecordingTap:
187
+ """Tap that records every payload it observes."""
188
+
189
+ def __init__(self):
190
+ self.payloads: List[Payload] = []
191
+ self.call_count = 0
192
+
193
+ def observe(self, payload: Payload) -> None:
194
+ self.call_count += 1
195
+ self.payloads.append(payload)
196
+
197
+
198
+ def mock_tap() -> RecordingTap:
199
+ """Create a recording tap for testing.
200
+
201
+ Usage:
202
+ tap = mock_tap()
203
+ pipeline.add_tap(tap, "spy")
204
+ run_pipeline(pipeline, data)
205
+ assert tap.call_count == 1
206
+ """
207
+ return RecordingTap()
208
+
209
+
210
+ class RecordingHook(Hook):
211
+ """Hook that records all lifecycle events."""
212
+
213
+ def __init__(self):
214
+ self.before_count = 0
215
+ self.after_count = 0
216
+ self.error_count = 0
217
+ self.before_payloads: List[Payload] = []
218
+ self.after_payloads: List[Payload] = []
219
+ self.errors: List[Exception] = []
220
+
221
+ async def before(self, filter, payload):
222
+ self.before_count += 1
223
+ self.before_payloads.append(payload)
224
+
225
+ async def after(self, filter, payload):
226
+ self.after_count += 1
227
+ self.after_payloads.append(payload)
228
+
229
+ async def on_error(self, filter, error, payload):
230
+ self.error_count += 1
231
+ self.errors.append(error)
232
+
233
+
234
+ def mock_hook() -> RecordingHook:
235
+ """Create a recording hook for testing.
236
+
237
+ Usage:
238
+ hook = mock_hook()
239
+ pipeline.use_hook(hook)
240
+ run_pipeline(pipeline, data)
241
+ assert hook.before_count > 0
242
+ """
243
+ return RecordingHook()
244
+
245
+
246
+ # ── Scaffolding ─────────────────────────────────────────────────────
247
+
248
+ def _to_class_name(snake: str) -> str:
249
+ """Convert snake_case to PascalCase."""
250
+ return "".join(word.capitalize() for word in snake.split("_"))
251
+
252
+
253
+ _TEMPLATES = {
254
+ "filter": (
255
+ "class {cls}:\n"
256
+ "{methods}\n"
257
+ ),
258
+ "tap": (
259
+ "class {cls}:\n"
260
+ " def observe(self, payload):\n"
261
+ " pass\n"
262
+ ),
263
+ "hook": (
264
+ "from codeupipe import Hook\n\n\n"
265
+ "class {cls}(Hook):\n"
266
+ " async def before(self, filter, payload):\n"
267
+ " pass\n\n"
268
+ " async def after(self, filter, payload):\n"
269
+ " pass\n\n"
270
+ " async def on_error(self, filter, error, payload):\n"
271
+ " pass\n"
272
+ ),
273
+ "stream-filter": (
274
+ "class {cls}:\n"
275
+ " async def stream(self, chunk):\n"
276
+ " yield chunk\n"
277
+ ),
278
+ "builder": (
279
+ "def build_{snake}():\n"
280
+ " pass\n"
281
+ ),
282
+ }
283
+
284
+ _TEST_TEMPLATE = (
285
+ "from codeupipe.testing import run_filter\n"
286
+ "from {module} import {symbol}\n\n\n"
287
+ "class Test{cls}:\n"
288
+ " def test_placeholder(self):\n"
289
+ " pass\n"
290
+ )
291
+
292
+
293
+ def cup_component(
294
+ directory: Path,
295
+ name: str,
296
+ kind: str,
297
+ *,
298
+ with_test: bool = False,
299
+ methods: Optional[List[str]] = None,
300
+ ) -> Path:
301
+ """Scaffold a CUP component file on disk for analysis tests.
302
+
303
+ Args:
304
+ directory: Where to create the .py file.
305
+ name: snake_case component name.
306
+ kind: filter, tap, hook, stream-filter, builder.
307
+ with_test: Also create tests/test_{name}.py.
308
+ methods: Custom method list (filter kind only).
309
+
310
+ Returns:
311
+ Path to the created component file.
312
+ """
313
+ cls = _to_class_name(name)
314
+ filepath = directory / f"{name}.py"
315
+
316
+ if kind == "filter":
317
+ if methods:
318
+ method_lines = []
319
+ for m in methods:
320
+ method_lines.append(f" def {m}(self, payload):\n pass\n")
321
+ methods_str = "\n".join(method_lines)
322
+ else:
323
+ methods_str = " def call(self, payload):\n return payload\n"
324
+ source = _TEMPLATES["filter"].format(cls=cls, methods=methods_str)
325
+ elif kind == "builder":
326
+ source = _TEMPLATES["builder"].format(snake=name)
327
+ else:
328
+ source = _TEMPLATES[kind].format(cls=cls)
329
+
330
+ filepath.write_text(source)
331
+
332
+ if with_test:
333
+ tests_dir = directory / "tests"
334
+ tests_dir.mkdir(exist_ok=True)
335
+ if kind == "builder":
336
+ symbol = f"build_{name}"
337
+ else:
338
+ symbol = cls
339
+ test_source = _TEST_TEMPLATE.format(
340
+ module=name, symbol=symbol, cls=cls,
341
+ )
342
+ (tests_dir / f"test_{name}.py").write_text(test_source)
343
+
344
+ return filepath
@@ -0,0 +1,10 @@
1
+ """
2
+ Utils Module: Shared Utilities
3
+
4
+ Common utilities that get reused across projects.
5
+ These are the helpers that make development easier.
6
+ """
7
+
8
+ from .error_handling import ErrorHandlingMixin, RetryFilter
9
+
10
+ __all__ = ["ErrorHandlingMixin", "RetryFilter"]