Skip to content

Commit 1b1cfde

Browse files
Add Pytest & Example Pipeline Validation with CI Workflow (#11)
Signed-off-by: VaniHaripriya <[email protected]>
1 parent 6c14670 commit 1b1cfde

File tree

13 files changed

+640
-4
lines changed

13 files changed

+640
-4
lines changed
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
---
2+
name: Run Tests and Validate Example Pipelines for Updated Components
3+
4+
on:
5+
pull_request:
6+
paths:
7+
- "components/**"
8+
- "pipelines/**"
9+
- "scripts/tests/**"
10+
- "scripts/validate_examples/**"
11+
- ".github/workflows/component-pipeline-tests.yml"
12+
push:
13+
branches:
14+
- main
15+
- release-*
16+
paths:
17+
- "components/**"
18+
- "pipelines/**"
19+
- "scripts/tests/**"
20+
- "scripts/validate_examples/**"
21+
- ".github/workflows/component-pipeline-tests.yml"
22+
23+
jobs:
24+
targeted-tests:
25+
runs-on: ubuntu-24.04
26+
permissions:
27+
contents: read
28+
steps:
29+
- uses: actions/checkout@v4
30+
with:
31+
fetch-depth: 0
32+
33+
- name: Setup Python CI
34+
uses: ./.github/actions/setup-python-ci
35+
with:
36+
python-version: "3.11"
37+
38+
- name: Detect changed files
39+
id: changes
40+
uses: ./.github/actions/detect-changed-assets
41+
42+
- name: Run targeted pytest suites
43+
if: ${{ steps.changes.outputs.has-changes == 'true' }}
44+
run: |
45+
echo "Running pytest for the following paths:"
46+
PATHS="${{ steps.changes.outputs.changed-components }} ${{ steps.changes.outputs.changed-pipelines }}"
47+
echo "$PATHS"
48+
uv run python -m scripts.tests.run_component_tests $PATHS
49+
50+
- name: Validate example pipelines
51+
if: ${{ steps.changes.outputs.has-changes == 'true' }}
52+
run: |
53+
echo "Validating example pipelines for:"
54+
PATHS="${{ steps.changes.outputs.changed-components }} ${{ steps.changes.outputs.changed-pipelines }}"
55+
echo "$PATHS"
56+
uv run python -m scripts.validate_examples $PATHS
57+
58+
- name: No component changes detected
59+
if: ${{ steps.changes.outputs.has-changes != 'true' }}
60+
run: echo "No components or pipelines changed. Skipping targeted tests."

docs/BESTPRACTICES.md

Lines changed: 29 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,36 @@
11
# Component Development Best Practices
22

3-
*This guide is under development. Please check back soon for comprehensive best practices for developing Kubeflow
4-
Pipelines components.*
3+
The sections below capture the minimum expectations for component and pipeline
4+
authors. Additional guidance will be added over time as the repository matures.
55

6-
## Coming Soon
6+
## Writing Component/Pipeline Smoke Tests
77

8-
This document will cover:
8+
- Keep test dependencies limited to `pytest` and the Python standard library.
9+
- Place tests in a `tests/` folder that sits next to the component or pipeline
10+
(for example `components/training/my_component/tests/`).
11+
- Aim for fast smoke coverage (import checks, signature validation, lightweight
12+
golden tests) that finishes well under the two-minute timeout enforced by
13+
CI.
14+
- Run `python scripts/tests/run_component_tests.py <path-to-component>` locally
15+
to execute only the suites you are touching. The helper automatically enforces
16+
the `pytest-timeout` guardrail used in CI.
17+
18+
## Validating Example Pipelines
19+
20+
- Keep example pipelines in an `example_pipelines.py` module beside the asset.
21+
Every pipeline exported from the module must be decorated with
22+
`@dsl.pipeline`.
23+
- The CI workflow imports each module by running
24+
`python -m scripts.validate_examples <paths>` for
25+
every changed component or pipeline and compiles the matching sample pipelines
26+
using `kfp.compiler`. Run the same command locally (with or without explicit
27+
paths) before submitting a PR to catch syntax or dependency regressions early.
28+
- Keep the examples dependency-light; they should compile without extra
29+
installations beyond the repo's base tooling (`kfp`, `pytest`, stdlib).
30+
31+
## Additional Topics (Coming Soon)
32+
33+
Future revisions of this guide will capture:
934

1035
- Component design patterns
1136
- Performance optimization

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ test = [
3030
]
3131
ci = [
3232
"pytest",
33+
"pytest-timeout",
3334
"docstring-parser",
3435
"jinja2",
3536
"semver",

scripts/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
"""Utility scripts for components and pipelines."""

scripts/tests/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
"""Pytest discovery and execution utilities for component and pipeline tests."""
Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
1+
#!/usr/bin/env python3
2+
"""Pytest discovery helper for Kubeflow components and pipelines.
3+
4+
This script discovers `tests/` directories under the provided component or
5+
pipeline paths and runs pytest with a two-minute timeout per test.
6+
"""
7+
8+
from __future__ import annotations
9+
10+
import argparse
11+
import sys
12+
import warnings
13+
from pathlib import Path
14+
from typing import List, Sequence
15+
16+
import pytest
17+
18+
from ..utils import get_repo_root, normalize_targets
19+
20+
REPO_ROOT = get_repo_root()
21+
TIMEOUT_SECONDS = 120
22+
23+
24+
def parse_args() -> argparse.Namespace:
25+
"""Parse command-line arguments.
26+
27+
Returns:
28+
Parsed command-line arguments.
29+
"""
30+
parser = argparse.ArgumentParser(
31+
description=(
32+
"Discover tests/ directories for the specified components or pipelines "
33+
"and execute pytest with a two-minute timeout per test."
34+
)
35+
)
36+
parser.add_argument(
37+
"paths",
38+
metavar="PATH",
39+
nargs="*",
40+
help=(
41+
"Component or pipeline directories (or files within them) to test. "
42+
"If omitted, all components and pipelines are scanned."
43+
),
44+
)
45+
parser.add_argument(
46+
"--timeout",
47+
type=int,
48+
default=TIMEOUT_SECONDS,
49+
help="Per-test timeout in seconds (default: 120).",
50+
)
51+
parser.add_argument(
52+
"--verbose",
53+
action="store_true",
54+
help="Pass the -vv flag to pytest for more detailed output.",
55+
)
56+
return parser.parse_args()
57+
58+
59+
def discover_test_dirs(targets: Sequence[Path]) -> List[Path]:
60+
"""Discover tests/ directories under the given targets.
61+
62+
Args:
63+
targets: Sequence of component or pipeline paths to search.
64+
65+
Returns:
66+
List of discovered tests/ directory paths.
67+
"""
68+
discovered: List[Path] = []
69+
70+
for target in targets:
71+
search_root = target if target.is_dir() else target.parent
72+
if not search_root.exists():
73+
continue
74+
75+
direct = search_root / "tests"
76+
if direct.is_dir() and _is_member_of_pipeline_or_component(direct):
77+
if direct not in discovered:
78+
discovered.append(direct)
79+
80+
return discovered
81+
82+
83+
def _is_member_of_pipeline_or_component(candidate: Path) -> bool:
84+
"""Check if a path is within components/ or pipelines/ directory.
85+
86+
Args:
87+
candidate: Path to check.
88+
89+
Returns:
90+
True if the path is within components/ or pipelines/, False otherwise.
91+
"""
92+
try:
93+
relative = candidate.relative_to(REPO_ROOT)
94+
except ValueError:
95+
warnings.warn(
96+
f"Unable to determine relative path for {candidate} " f"relative to repo root {REPO_ROOT}. Skipping.",
97+
)
98+
return False
99+
100+
return relative.parts and relative.parts[0] in {"components", "pipelines"}
101+
102+
103+
def build_pytest_args(
104+
test_dirs: Sequence[Path],
105+
timeout_seconds: int,
106+
verbose: bool,
107+
) -> List[str]:
108+
"""Build pytest command-line arguments.
109+
110+
Args:
111+
test_dirs: Directories containing tests to run.
112+
timeout_seconds: Per-test timeout in seconds.
113+
verbose: Whether to enable verbose pytest output.
114+
115+
Returns:
116+
List of pytest command-line arguments.
117+
"""
118+
args: List[str] = [
119+
f"--timeout={timeout_seconds}",
120+
"--timeout-method=signal",
121+
]
122+
if verbose:
123+
args.append("-vv")
124+
125+
args.extend(str(directory) for directory in test_dirs)
126+
return args
127+
128+
129+
def main() -> int:
130+
"""Main entry point for running component/pipeline tests.
131+
132+
Returns:
133+
Exit code (0 for success, non-zero for failure).
134+
"""
135+
args = parse_args()
136+
targets = normalize_targets(args.paths)
137+
test_dirs = discover_test_dirs(targets)
138+
139+
if not test_dirs:
140+
print("No tests/ directories found under the supplied paths. Nothing to do.")
141+
return 0
142+
143+
relative_dirs = ", ".join(str(directory.relative_to(REPO_ROOT)) for directory in test_dirs)
144+
print(f"Running pytest for: {relative_dirs}")
145+
146+
pytest_args = build_pytest_args(
147+
test_dirs=test_dirs,
148+
timeout_seconds=args.timeout,
149+
verbose=args.verbose,
150+
)
151+
152+
exit_code = pytest.main(pytest_args)
153+
if exit_code == 0:
154+
print("✅ Pytest completed successfully.")
155+
else:
156+
print("❌ Pytest reported failures. See log above for details.")
157+
158+
return exit_code
159+
160+
161+
if __name__ == "__main__":
162+
sys.exit(main())

scripts/utils/__init__.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
"""Shared utilities for scripts."""
2+
3+
from .parsing import (
4+
find_functions_with_decorator,
5+
find_pipeline_functions,
6+
)
7+
from .paths import (
8+
get_default_targets,
9+
get_repo_root,
10+
normalize_targets,
11+
)
12+
13+
__all__ = [
14+
"find_functions_with_decorator",
15+
"find_pipeline_functions",
16+
"get_default_targets",
17+
"get_repo_root",
18+
"normalize_targets",
19+
]

scripts/utils/parsing.py

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
"""Parsing-related utility functions."""
2+
3+
from __future__ import annotations
4+
5+
import ast
6+
from pathlib import Path
7+
from typing import List
8+
9+
10+
def get_ast_tree(file_path: Path) -> ast.AST:
11+
"""Get the parsed AST tree for a Python file.
12+
13+
Args:
14+
file_path: Path to the Python file to parse.
15+
16+
Returns:
17+
The parsed AST tree.
18+
"""
19+
with open(file_path, "r", encoding="utf-8") as f:
20+
source = f.read()
21+
return ast.parse(source)
22+
23+
24+
def is_target_decorator(decorator: ast.AST, decorator_type: str) -> bool:
25+
"""Check if a decorator is a KFP component or pipeline decorator.
26+
27+
Supports the following decorator formats (using component as an example):
28+
- @component (direct import: from kfp.dsl import component)
29+
- @dsl.component (from kfp import dsl)
30+
- @kfp.dsl.component (import kfp)
31+
- All of the above with parentheses: @component(), @dsl.component(), etc.
32+
33+
Args:
34+
decorator: AST node representing the decorator.
35+
decorator_type: Type of decorator to find ('component' or 'pipeline').
36+
37+
Returns:
38+
True if the decorator is the given decoration_type, False otherwise.
39+
"""
40+
if isinstance(decorator, ast.Attribute):
41+
# Handle attribute-based decorators
42+
if decorator.attr == decorator_type:
43+
# Check for @dsl.<function_type>
44+
if isinstance(decorator.value, ast.Name) and decorator.value.id == "dsl":
45+
return True
46+
# Check for @kfp.dsl.<function_type>
47+
if (
48+
isinstance(decorator.value, ast.Attribute)
49+
and decorator.value.attr == "dsl"
50+
and isinstance(decorator.value.value, ast.Name)
51+
and decorator.value.value.id == "kfp"
52+
):
53+
return True
54+
return False
55+
elif isinstance(decorator, ast.Call):
56+
# Handle decorators with arguments (e.g., @<function_type>(), @dsl.<function_type>())
57+
return is_target_decorator(decorator.func, decorator_type)
58+
elif isinstance(decorator, ast.Name):
59+
# Handle @<function_type> (if imported directly)
60+
return decorator.id == decorator_type
61+
return False
62+
63+
64+
def find_pipeline_functions(file_path: Path) -> List[str]:
65+
"""Find all function names decorated with @dsl.pipeline.
66+
67+
Args:
68+
file_path: Path to the Python file to parse.
69+
70+
Returns:
71+
List of function names that are decorated with @dsl.pipeline.
72+
"""
73+
return find_functions_with_decorator(file_path, "pipeline")
74+
75+
76+
def find_functions_with_decorator(file_path: Path, decorator_type: str) -> List[str]:
77+
"""Find all function names decorated with a specific KFP decorator
78+
79+
Args:
80+
file_path: Path to the Python file to parse.
81+
decorator_type: Type of decorator to find ('component' or 'pipeline').
82+
83+
Returns:
84+
List of function names that are decorated with the specified decorator.
85+
"""
86+
tree = get_ast_tree(file_path)
87+
88+
functions: List[str] = []
89+
90+
for node in ast.walk(tree):
91+
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
92+
for decorator in node.decorator_list:
93+
if is_target_decorator(decorator, decorator_type):
94+
functions.append(node.name)
95+
break # Found the decorator, no need to check other decorators
96+
97+
return functions

0 commit comments

Comments
 (0)