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.
- codeupipe/__init__.py +39 -0
- codeupipe/cli.py +1502 -0
- codeupipe/converter/__init__.py +9 -0
- codeupipe/converter/config.py +119 -0
- codeupipe/converter/filters/__init__.py +21 -0
- codeupipe/converter/filters/analyze.py +60 -0
- codeupipe/converter/filters/classify.py +52 -0
- codeupipe/converter/filters/classify_files.py +61 -0
- codeupipe/converter/filters/generate_export.py +187 -0
- codeupipe/converter/filters/generate_import.py +229 -0
- codeupipe/converter/filters/parse_config.py +26 -0
- codeupipe/converter/filters/scan_project.py +52 -0
- codeupipe/converter/pipelines/__init__.py +8 -0
- codeupipe/converter/pipelines/export_pipeline.py +40 -0
- codeupipe/converter/pipelines/import_pipeline.py +40 -0
- codeupipe/converter/taps/__init__.py +7 -0
- codeupipe/converter/taps/conversion_log.py +46 -0
- codeupipe/core/__init__.py +20 -0
- codeupipe/core/filter.py +27 -0
- codeupipe/core/hook.py +34 -0
- codeupipe/core/payload.py +94 -0
- codeupipe/core/pipeline.py +231 -0
- codeupipe/core/state.py +78 -0
- codeupipe/core/stream_filter.py +33 -0
- codeupipe/core/tap.py +27 -0
- codeupipe/core/valve.py +52 -0
- codeupipe/linter/__init__.py +57 -0
- codeupipe/linter/assemble_doc_report.py +97 -0
- codeupipe/linter/assemble_report.py +140 -0
- codeupipe/linter/check_bundle.py +47 -0
- codeupipe/linter/check_index.py +88 -0
- codeupipe/linter/check_naming.py +47 -0
- codeupipe/linter/check_protocols.py +73 -0
- codeupipe/linter/check_structure.py +41 -0
- codeupipe/linter/check_symbols.py +116 -0
- codeupipe/linter/check_tests.py +48 -0
- codeupipe/linter/coverage_pipeline.py +39 -0
- codeupipe/linter/detect_drift.py +44 -0
- codeupipe/linter/detect_orphans.py +106 -0
- codeupipe/linter/doc_check_pipeline.py +28 -0
- codeupipe/linter/git_history.py +130 -0
- codeupipe/linter/lint_pipeline.py +51 -0
- codeupipe/linter/map_coverage.py +84 -0
- codeupipe/linter/report_gaps.py +68 -0
- codeupipe/linter/report_pipeline.py +49 -0
- codeupipe/linter/resolve_refs.py +48 -0
- codeupipe/linter/scan_components.py +95 -0
- codeupipe/linter/scan_directory.py +123 -0
- codeupipe/linter/scan_docs.py +62 -0
- codeupipe/linter/scan_tests.py +104 -0
- codeupipe/py.typed +1 -0
- codeupipe/testing.py +344 -0
- codeupipe/utils/__init__.py +10 -0
- codeupipe/utils/error_handling.py +68 -0
- codeupipe-0.1.0.dist-info/METADATA +216 -0
- codeupipe-0.1.0.dist-info/RECORD +60 -0
- codeupipe-0.1.0.dist-info/WHEEL +5 -0
- codeupipe-0.1.0.dist-info/entry_points.txt +2 -0
- codeupipe-0.1.0.dist-info/licenses/LICENSE +190 -0
- 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"]
|