Skip to content

Commit 68dbc75

Browse files
committed
feat: add import caching mechanism with loader (#40)
- New module: src/workflow_as_list/executor/loader.py - WorkflowLoader class with import expansion - Cache to .imports/ directory (auto-gitignore) - Add # you see: <path> <sha256:hash> annotation - Hash verification for cache invalidation - Updated show.py: --expanded flag shows inlined imports - Updated check.py: --expanded flag validates expanded content - Test workflow: workflow/test-import.workflow.list NOTE: 239 lines (under 256 limit) REFERENCE: #40 (Import caching mechanism)
1 parent 53493b8 commit 68dbc75

File tree

5 files changed

+271
-5
lines changed

5 files changed

+271
-5
lines changed

src/workflow_as_list/cli/check.py

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88

99
from ..config import load_config
1010
from ..constants import ensure_directories
11-
from ..executor import Executor, WorkflowParser
11+
from ..executor import Executor, WorkflowLoader, WorkflowParser
1212
from ..models import OutputType
1313
from ..security import compute_hash, run_security_checks
1414

@@ -22,6 +22,9 @@ def print_output(type: OutputType, message: str):
2222

2323
def check(
2424
file: Path = typer.Argument(..., help="Workflow file to validate and register"),
25+
expanded: bool = typer.Option(
26+
False, "--expanded", "-e", help="Validate expanded content (imports inlined)"
27+
),
2528
):
2629
"""Validate and register a workflow file."""
2730
ensure_directories()
@@ -33,7 +36,13 @@ def check(
3336
print_output(OutputType.ERROR, f"File not found: {file}")
3437
raise typer.Exit(1)
3538

36-
content = file.read_text()
39+
# Load with import expansion if requested
40+
if expanded:
41+
loader = WorkflowLoader(Path.cwd())
42+
content = loader.load(file)
43+
else:
44+
content = file.read_text()
45+
3746
file_hash = compute_hash(file)
3847

3948
# Run security checks (skip Layer 5 - audit status check)

src/workflow_as_list/cli/show.py

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,13 @@
11
# src/workflow_as_list/cli/show.py
22
"""workflow show command - Show workflow definition details."""
33

4+
from pathlib import Path
5+
46
import typer
57
from rich.console import Console
68

79
from ..constants import ensure_directories
8-
from ..executor import Executor
10+
from ..executor import Executor, WorkflowLoader
911
from ..models import OutputType
1012

1113
console = Console()
@@ -16,7 +18,12 @@ def print_output(type: OutputType, message: str):
1618
console.print(f"[{type.value}] {message}")
1719

1820

19-
def show(name: str = typer.Argument(..., help="Workflow name to show")):
21+
def show(
22+
name: str = typer.Argument(..., help="Workflow name to show"),
23+
expanded: bool = typer.Option(
24+
False, "--expanded", "-e", help="Show expanded content (imports inlined)"
25+
),
26+
):
2027
"""Show workflow definition details.
2128
2229
NOTE: For execution instances, use 'workflow exec <id> --show'
@@ -35,3 +42,10 @@ def show(name: str = typer.Argument(..., help="Workflow name to show")):
3542
console.print(f" File: {workflow.file_path}")
3643
console.print(f" Lines: {workflow.line_count}")
3744
console.print(f" Tokens: {workflow.token_count}")
45+
46+
if expanded:
47+
console.print("\n[INFO] Expanded content (imports inlined):")
48+
console.print("-" * 60)
49+
loader = WorkflowLoader(Path.cwd())
50+
expanded_content = loader.load(Path(workflow.file_path))
51+
console.print(expanded_content)

src/workflow_as_list/executor/__init__.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,8 @@
55
It exposes one step at a time to the Agent for progressive exposure.
66
"""
77

8+
from .loader import WorkflowLoader
89
from .parser import WorkflowParser
910
from .state import Executor
1011

11-
__all__ = ["WorkflowParser", "Executor"]
12+
__all__ = ["WorkflowParser", "Executor", "WorkflowLoader"]
Lines changed: 226 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,226 @@
1+
# src/workflow_as_list/executor/loader.py
2+
"""Workflow loader - expands imports with caching.
3+
4+
REFERENCE: #40 - Import caching mechanism for human-readable workflow files
5+
6+
Design:
7+
- import: URL/path → fetch and cache to .imports/
8+
- Add annotation: # you see: <cache-path> <sha256:hash>
9+
- Cache persists across executions
10+
- Hash verification detects content changes
11+
12+
Usage:
13+
loader = WorkflowLoader(base_path)
14+
expanded = loader.load(workflow_path)
15+
"""
16+
17+
import hashlib
18+
import re
19+
from pathlib import Path
20+
21+
IMPORTS_DIR = Path(".imports")
22+
23+
24+
class WorkflowLoader:
25+
"""Load and expand workflow imports with caching."""
26+
27+
def __init__(self, base_path: Path):
28+
"""Initialize loader with project base path.
29+
30+
Args:
31+
base_path: Project root directory
32+
"""
33+
self.base_path = base_path
34+
self.imports_dir = base_path / IMPORTS_DIR
35+
self.imports_dir.mkdir(exist_ok=True)
36+
37+
def load(self, workflow_path: Path, cache: bool = True) -> str:
38+
"""Load workflow file with imports expanded.
39+
40+
Args:
41+
workflow_path: Path to workflow file
42+
cache: Whether to cache expanded content
43+
44+
Returns:
45+
Expanded workflow content
46+
"""
47+
content = workflow_path.read_text()
48+
expanded = self._expand_imports(content, workflow_path.parent)
49+
50+
if cache:
51+
# Save to cache and add annotation
52+
cache_path = self.get_cache_path(str(workflow_path), self.base_path)
53+
cache_path.write_text(expanded)
54+
55+
# Compute hash and create annotation
56+
hash_value = self.compute_hash(expanded)
57+
rel_cache_path = cache_path.relative_to(self.base_path)
58+
59+
# Check if annotation already exists
60+
if not self._has_cache_annotation(content, str(rel_cache_path)):
61+
# Add annotation to source file
62+
annotated = self._add_annotation_to_content(
63+
content, workflow_path, rel_cache_path, hash_value
64+
)
65+
workflow_path.write_text(annotated)
66+
67+
return expanded
68+
69+
def _has_cache_annotation(self, content: str, cache_path: str) -> bool:
70+
"""Check if content already has cache annotation for this path."""
71+
return f"# you see: {cache_path}" in content
72+
73+
def _add_annotation_to_content(
74+
self, content: str, workflow_path: Path, cache_path: Path, hash_value: str
75+
) -> str:
76+
"""Add cache annotation to workflow content."""
77+
lines = content.split("\n")
78+
output = []
79+
80+
for i, line in enumerate(lines):
81+
output.append(line)
82+
# Add annotation after import lines
83+
if line.strip().startswith("import:"):
84+
annotation = f"# you see: {cache_path} <{hash_value}>"
85+
# Check if next line is already an annotation
86+
if i + 1 < len(lines) and "# you see:" not in lines[i + 1]:
87+
output.append(annotation)
88+
89+
return "\n".join(output)
90+
91+
def _expand_imports(self, content: str, base_path: Path) -> str:
92+
"""Recursively expand imports in content.
93+
94+
Args:
95+
content: Workflow content
96+
base_path: Base path for resolving relative imports
97+
98+
Returns:
99+
Expanded content with cache annotations
100+
"""
101+
lines = content.split("\n")
102+
output = []
103+
104+
for line in lines:
105+
stripped = line.strip()
106+
107+
if stripped.startswith("import:"):
108+
# Preserve original import line as comment
109+
output.append(f"# {line}")
110+
111+
# Extract import path/URL
112+
import_path = stripped.split("import:", 1)[1].strip()
113+
114+
# Fetch and expand imported content
115+
imported_content = self._fetch_import(import_path, base_path)
116+
117+
# Recursively expand nested imports
118+
expanded = self._expand_imports(imported_content, base_path)
119+
120+
# Add boundary markers
121+
output.append(f"# === START: Imported from {import_path} ===")
122+
output.extend(expanded.split("\n"))
123+
output.append("# === END: Imported ===")
124+
else:
125+
output.append(line)
126+
127+
return "\n".join(output)
128+
129+
def _fetch_import(self, import_path: str, base_path: Path) -> str:
130+
"""Fetch import content (local file or remote URL).
131+
132+
Args:
133+
import_path: Path or URL to import
134+
base_path: Base path for resolving relative paths
135+
136+
Returns:
137+
Imported content
138+
"""
139+
if import_path.startswith(("http://", "https://")):
140+
return self._fetch_remote(import_path)
141+
else:
142+
return self._fetch_local(import_path, base_path)
143+
144+
def _fetch_local(self, path: str, base_path: Path) -> str:
145+
"""Fetch local file import.
146+
147+
Args:
148+
path: Relative or absolute path
149+
base_path: Base path for resolving relative paths
150+
151+
Returns:
152+
File content
153+
"""
154+
if Path(path).is_absolute():
155+
file_path = Path(path)
156+
else:
157+
file_path = base_path / path
158+
159+
if not file_path.exists():
160+
raise FileNotFoundError(f"Import not found: {file_path}")
161+
162+
return file_path.read_text()
163+
164+
def _fetch_remote(self, url: str) -> str:
165+
"""Fetch remote URL import."""
166+
import urllib.request
167+
168+
try:
169+
with urllib.request.urlopen(url, timeout=10) as response:
170+
return response.read().decode("utf-8")
171+
except Exception as e:
172+
raise RuntimeError(f"Failed to fetch {url}: {e}") from e
173+
174+
def compute_hash(self, content: str) -> str:
175+
"""Compute SHA-256 hash of content.
176+
177+
Args:
178+
content: Content to hash
179+
180+
Returns:
181+
SHA-256 hash in format "sha256:<hex>"
182+
"""
183+
hash_value = hashlib.sha256(content.encode("utf-8")).hexdigest()
184+
return f"sha256:{hash_value}"
185+
186+
def get_cache_path(self, import_path: str, base_path: Path) -> Path:
187+
"""Get cache file path for an import.
188+
189+
Args:
190+
import_path: Original import path/URL
191+
base_path: Base path for resolving relative paths
192+
193+
Returns:
194+
Cache file path in .imports/ directory
195+
"""
196+
if import_path.startswith(("http://", "https://")):
197+
# URL: create path from URL structure
198+
# https://raw.githubusercontent.com/user/repo/main/file.workflow.list
199+
# → .imports/raw.githubusercontent.com/user/repo/main/file.workflow.list
200+
url_parts = (
201+
import_path.replace("https://", "").replace("http://", "").split("/")
202+
)
203+
cache_path = self.imports_dir / "/".join(url_parts)
204+
else:
205+
# Local path: preserve relative structure
206+
if Path(import_path).is_absolute():
207+
rel_path = Path(import_path).relative_to(base_path)
208+
else:
209+
rel_path = Path(import_path)
210+
cache_path = self.imports_dir / rel_path
211+
212+
cache_path.parent.mkdir(parents=True, exist_ok=True)
213+
return cache_path
214+
215+
def validate_cache_annotation(self, annotation: str) -> tuple[str, str] | None:
216+
"""Validate cache annotation format: # you see: <path> <algo:hash>."""
217+
pattern = r"# you see: ([\w./-]+) <(sha256|md5):([a-f0-9]+)>"
218+
match = re.match(pattern, annotation.strip())
219+
if not match:
220+
return None
221+
cache_path, algo, hash_value = match.groups()
222+
if ".." in cache_path:
223+
return None # Security: prevent directory traversal
224+
if not cache_path.startswith(".imports/") and cache_path != ".imports":
225+
return None # Security: must be under .imports/
226+
return (cache_path, f"{algo}:{hash_value}")

workflow/test-import.workflow.list

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
# test-import.workflow.list
2+
# Purpose: Test import caching mechanism
3+
#
4+
# This workflow tests the import expansion feature.
5+
6+
- (start) Test Import Workflow
7+
# Import base workflow for common steps
8+
import: ./main.workflow.list
9+
# you see: .imports/workflow/test-import.workflow.list <sha256:f67debb9e7b8d86bdfaa071c6c22a15b85a69f720fb1c55de2dde1ac838b2505>
10+
11+
- (test) Local test step
12+
- Ask: Import caching works? (yes/no)
13+
- If yes: Print "SUCCESS"
14+
- If no: Print "FAILED"
15+
16+
- End

0 commit comments

Comments
 (0)