forked from cleveragents/cleveragents-core
17fe46d925
There had been over 100 behave tests failing. There should be none failing now.
437 lines
16 KiB
Python
437 lines
16 KiB
Python
"""Step definitions for architecture validation."""
|
|
|
|
import ast
|
|
import importlib
|
|
import pkgutil
|
|
import re
|
|
from pathlib import Path
|
|
|
|
from behave import given, then, when
|
|
|
|
|
|
@given('the ADR directory exists at "{path}"')
|
|
def step_adr_directory_exists(context, path):
|
|
"""Check if the ADR directory exists."""
|
|
context.adr_dir = Path(path)
|
|
assert context.adr_dir.exists(), f"ADR directory {path} does not exist"
|
|
assert context.adr_dir.is_dir(), f"{path} is not a directory"
|
|
|
|
|
|
@when("I check for ADR files")
|
|
def step_check_adr_files(context):
|
|
"""Find all ADR files in the directory."""
|
|
context.adr_files = list(context.adr_dir.glob("ADR-*.md"))
|
|
|
|
|
|
@then("I should find at least {count:d} ADR files")
|
|
def step_verify_adr_count(context, count):
|
|
"""Verify minimum number of ADR files."""
|
|
assert len(context.adr_files) >= count, (
|
|
f"Expected at least {count} ADR files, found {len(context.adr_files)}"
|
|
)
|
|
|
|
|
|
@then("each ADR should have a valid format with status")
|
|
def step_verify_adr_format(context):
|
|
"""Verify each ADR follows the expected format."""
|
|
for adr_file in context.adr_files:
|
|
content = adr_file.read_text()
|
|
# Check for required sections
|
|
assert "## Status" in content, f"{adr_file.name} missing Status section"
|
|
assert "## Context" in content, f"{adr_file.name} missing Context section"
|
|
assert "## Decision" in content, f"{adr_file.name} missing Decision section"
|
|
assert "## Consequences" in content, (
|
|
f"{adr_file.name} missing Consequences section"
|
|
)
|
|
|
|
|
|
@given('the source directory exists at "{path}"')
|
|
def step_source_directory_exists(context, path):
|
|
"""Check if the source directory exists."""
|
|
context.src_dir = Path(path)
|
|
assert context.src_dir.exists(), f"Source directory {path} does not exist"
|
|
|
|
|
|
@when("I check the package structure")
|
|
def step_check_package_structure(context):
|
|
"""List all packages in the source directory."""
|
|
try:
|
|
# Ensure the directory exists
|
|
if not context.src_dir.exists():
|
|
raise AssertionError(f"Source directory {context.src_dir} does not exist")
|
|
|
|
# List all items in the directory with better error handling
|
|
context.packages = {}
|
|
for item in context.src_dir.iterdir():
|
|
try:
|
|
if item.is_dir() and not item.name.startswith("__"):
|
|
# Store all packages including discovery
|
|
# Handle expected vs actual in verification
|
|
context.packages[item.name] = item
|
|
except (OSError, PermissionError) as e:
|
|
# Log the error but continue processing other items
|
|
print(f"Warning: Could not check {item}: {e}")
|
|
continue
|
|
|
|
except Exception as e:
|
|
raise AssertionError(f"Failed to check package structure: {e}") from e
|
|
|
|
|
|
@then("I should find these main packages")
|
|
def step_verify_packages(context):
|
|
"""Verify expected packages exist and are proper Python packages."""
|
|
# Check if packages were properly collected
|
|
if not hasattr(context, "packages"):
|
|
raise AssertionError(
|
|
"Package structure was not checked properly - 'packages' attribute missing"
|
|
)
|
|
|
|
# Parse the table to get expected packages and their descriptions
|
|
expected_packages = {
|
|
row["package"]: row.get("description", "") for row in context.table
|
|
}
|
|
actual_packages = set(context.packages.keys())
|
|
|
|
# Check for missing packages
|
|
missing = set(expected_packages.keys()) - actual_packages
|
|
if missing:
|
|
extra = actual_packages - set(expected_packages.keys())
|
|
error_msg = f"Missing packages: {missing}"
|
|
if extra:
|
|
error_msg += f"\nUnexpected packages found: {extra}"
|
|
error_msg += f"\nActual packages: {sorted(actual_packages)}"
|
|
error_msg += f"\nExpected packages: {sorted(expected_packages.keys())}"
|
|
raise AssertionError(error_msg)
|
|
|
|
# Verify each expected package is a proper Python package
|
|
for package_name in expected_packages:
|
|
package_path = context.packages[package_name]
|
|
init_file = package_path / "__init__.py"
|
|
if not init_file.exists():
|
|
raise AssertionError(
|
|
f"Package '{package_name}' at {package_path} is not a "
|
|
f"proper Python package (__init__.py not found)"
|
|
)
|
|
|
|
|
|
@given("the package structure is defined")
|
|
def step_package_structure_defined(context):
|
|
"""Set up package structure for import analysis."""
|
|
context.src_dir = Path("src/cleveragents")
|
|
context.packages = {
|
|
p.name: p
|
|
for p in context.src_dir.iterdir()
|
|
if p.is_dir() and not p.name.startswith("__")
|
|
}
|
|
|
|
|
|
@when("I analyze package imports")
|
|
def step_analyze_imports(context):
|
|
"""Analyze imports in all Python files."""
|
|
context.imports = {}
|
|
for package_name, package_path in context.packages.items():
|
|
package_imports = set()
|
|
for py_file in package_path.rglob("*.py"):
|
|
if py_file.name == "__init__.py" and py_file.read_text().strip() == "":
|
|
continue
|
|
try:
|
|
tree = ast.parse(py_file.read_text())
|
|
for node in ast.walk(tree):
|
|
if isinstance(node, ast.Import):
|
|
for alias in node.names:
|
|
if alias.name.startswith("cleveragents."):
|
|
package_imports.add(alias.name.split(".")[1])
|
|
elif (
|
|
isinstance(node, ast.ImportFrom)
|
|
and node.module
|
|
and node.module.startswith("cleveragents.")
|
|
):
|
|
package_imports.add(node.module.split(".")[1])
|
|
except SyntaxError:
|
|
pass # Skip files with syntax errors
|
|
context.imports[package_name] = package_imports
|
|
|
|
|
|
@then("{package} should not import from {target}")
|
|
def step_verify_no_import(context, package, target):
|
|
"""Verify a package doesn't import from another."""
|
|
if package not in context.imports:
|
|
return # Package doesn't exist yet
|
|
imports = context.imports.get(package, set())
|
|
assert target not in imports, f"{package} imports from {target}"
|
|
|
|
|
|
@then("the core package should be self-contained")
|
|
def step_verify_core_self_contained(context):
|
|
"""Verify core package doesn't import from any other internal package."""
|
|
package = "core"
|
|
if package not in context.imports:
|
|
return # Package doesn't exist yet
|
|
imports = context.imports.get(package, set())
|
|
# Filter out self-imports and allowed imports
|
|
# Core can import from config for dependency injection container setup
|
|
allowed_imports = {"core", "config"}
|
|
other_imports = imports - allowed_imports
|
|
assert not other_imports, f"{package} imports from: {other_imports}"
|
|
|
|
|
|
@given("the settings module exists")
|
|
def step_settings_module_exists(context):
|
|
"""Check if settings module exists."""
|
|
context.settings_file = Path("src/cleveragents/config/settings.py")
|
|
assert context.settings_file.exists(), "Settings module does not exist"
|
|
|
|
|
|
@when("I check environment variable definitions")
|
|
def step_check_env_vars(context):
|
|
"""Extract environment variable definitions from settings."""
|
|
content = context.settings_file.read_text()
|
|
# Find all environment variable references
|
|
context.env_vars = re.findall(r'["\']([A-Z_]+)["\']', content)
|
|
|
|
|
|
@then("all application variables should use {prefix} prefix")
|
|
def step_verify_env_prefix(context, prefix):
|
|
"""Verify application environment variables use correct prefix."""
|
|
# Provider prefixes that should be excluded
|
|
provider_prefixes = [
|
|
"OPENAI_",
|
|
"ANTHROPIC_",
|
|
"CLAUDE_",
|
|
"GEMINI_",
|
|
"AZURE_",
|
|
"GOOGLE_",
|
|
"OPENROUTER_",
|
|
"HF_",
|
|
"HUGGINGFACEHUB_",
|
|
"HUGGING_FACE_HUB_",
|
|
"COHERE_",
|
|
"PERPLEXITY_",
|
|
"GROQ_",
|
|
"TOGETHER_",
|
|
]
|
|
|
|
# Common constants and system variables to exclude
|
|
excluded_constants = [
|
|
"LANGCHAIN_PROJECT_TYPE", # LangChain internal constant
|
|
"LANGCHAIN_CHAT_MODEL", # LangChain testing constant
|
|
"LANGCHAIN_HANDLER", # LangChain handler prefix
|
|
"INFO", # Logging level constants pulled from settings
|
|
"DEBUG",
|
|
"WARNING",
|
|
"ERROR",
|
|
"CRITICAL",
|
|
]
|
|
|
|
langsmith_allowlist = {
|
|
# LangChain/LangSmith compatibility variables that Settings syncs automatically
|
|
"LANGCHAIN_TRACING_V2",
|
|
"LANGCHAIN_API_KEY",
|
|
"LANGCHAIN_PROJECT",
|
|
"LANGCHAIN_ENDPOINT",
|
|
"LANGSMITH_TRACING_V2",
|
|
"LANGSMITH_API_KEY",
|
|
"LANGSMITH_PROJECT",
|
|
"LANGSMITH_ENDPOINT",
|
|
"LANGSMITH_USER_ID",
|
|
}
|
|
|
|
app_vars = [
|
|
var
|
|
for var in context.env_vars
|
|
if not any(var.startswith(p) for p in provider_prefixes)
|
|
and var not in excluded_constants
|
|
]
|
|
|
|
invalid_vars = [
|
|
var
|
|
for var in app_vars
|
|
if not var.startswith(prefix) and var not in langsmith_allowlist
|
|
]
|
|
assert not invalid_vars, f"Variables not using {prefix} prefix: {invalid_vars}"
|
|
|
|
|
|
@then("provider variables should keep their original prefix")
|
|
def step_verify_provider_vars(context):
|
|
"""Verify provider variables keep their original prefix."""
|
|
provider_prefixes = [
|
|
"OPENAI_",
|
|
"ANTHROPIC_",
|
|
"CLAUDE_",
|
|
"GEMINI_",
|
|
"AZURE_",
|
|
"GOOGLE_",
|
|
"OPENROUTER_",
|
|
"HF_",
|
|
"HUGGINGFACEHUB_",
|
|
"HUGGING_FACE_HUB_",
|
|
"COHERE_",
|
|
"PERPLEXITY_",
|
|
"GROQ_",
|
|
"TOGETHER_",
|
|
]
|
|
provider_vars = [
|
|
var
|
|
for var in context.env_vars
|
|
if any(var.startswith(p) for p in provider_prefixes)
|
|
]
|
|
# Just verify they exist and aren't renamed
|
|
for var in provider_vars:
|
|
assert not var.startswith("CLEVERAGENTS_"), f"Provider var renamed: {var}"
|
|
|
|
|
|
@given("the source code exists")
|
|
def step_source_code_exists(context):
|
|
"""Set up source code for analysis."""
|
|
context.src_dir = Path("src/cleveragents")
|
|
assert context.src_dir.exists()
|
|
|
|
|
|
@when("I check for type annotations")
|
|
def step_check_type_annotations(context):
|
|
"""Analyze type annotations in the codebase."""
|
|
context.functions_with_hints = 0
|
|
context.functions_without_hints = 0
|
|
context.pydantic_models = 0
|
|
context.regular_dataclasses = 0
|
|
|
|
for py_file in context.src_dir.rglob("*.py"):
|
|
if "discovery" in str(py_file):
|
|
continue # Skip discovery module as per pyright config
|
|
|
|
try:
|
|
tree = ast.parse(py_file.read_text())
|
|
for node in ast.walk(tree):
|
|
if isinstance(node, ast.FunctionDef):
|
|
# Check if function has return type annotation
|
|
if node.returns is not None:
|
|
context.functions_with_hints += 1
|
|
else:
|
|
# Skip __init__, __str__, __repr__ methods
|
|
if not node.name.startswith("_"):
|
|
context.functions_without_hints += 1
|
|
|
|
elif isinstance(node, ast.ClassDef):
|
|
# Check if it's a Pydantic model or dataclass
|
|
for base in node.bases:
|
|
base_name = (
|
|
ast.unparse(base) if hasattr(ast, "unparse") else str(base)
|
|
)
|
|
if "BaseModel" in base_name or "BaseSettings" in base_name:
|
|
context.pydantic_models += 1
|
|
elif "dataclass" in [
|
|
d.id for d in node.decorator_list if isinstance(d, ast.Name)
|
|
]:
|
|
context.regular_dataclasses += 1
|
|
except Exception:
|
|
pass # Skip files with syntax errors
|
|
|
|
|
|
@then("all public functions should have type hints")
|
|
def step_verify_public_functions_type_hints(context):
|
|
"""Verify public functions have type hints."""
|
|
missing_annotations = []
|
|
for py_file in context.src_dir.rglob("*.py"):
|
|
try:
|
|
tree = ast.parse(py_file.read_text())
|
|
except SyntaxError:
|
|
continue
|
|
|
|
for node in ast.walk(tree):
|
|
if isinstance(node, ast.FunctionDef) and not node.name.startswith("_"):
|
|
args = node.args
|
|
has_annotations = True
|
|
for arg in args.args + args.kwonlyargs:
|
|
if arg.arg not in ("self", "cls") and arg.annotation is None:
|
|
has_annotations = False
|
|
break
|
|
if args.vararg and args.vararg.annotation is None:
|
|
has_annotations = False
|
|
if args.kwarg and args.kwarg.annotation is None:
|
|
has_annotations = False
|
|
if node.returns is None:
|
|
has_annotations = False
|
|
if not has_annotations:
|
|
missing_annotations.append(f"{py_file}:{node.name}")
|
|
|
|
assert not missing_annotations, (
|
|
"Public functions missing type hints:\n" + "\n".join(missing_annotations)
|
|
)
|
|
|
|
|
|
@then("all dataclasses should use Pydantic models")
|
|
def step_verify_dataclasses_pydantic(context):
|
|
"""Verify dataclasses are Pydantic models."""
|
|
missing_pydantic = []
|
|
for py_file in context.src_dir.rglob("*.py"):
|
|
try:
|
|
tree = ast.parse(py_file.read_text())
|
|
except SyntaxError:
|
|
continue
|
|
|
|
for node in ast.walk(tree):
|
|
if isinstance(node, ast.ClassDef):
|
|
bases = [base.id for base in node.bases if isinstance(base, ast.Name)]
|
|
if "BaseModel" in bases:
|
|
continue
|
|
decorators = [
|
|
decorator.id
|
|
for decorator in node.decorator_list
|
|
if isinstance(decorator, ast.Name)
|
|
]
|
|
if "dataclass" in decorators:
|
|
missing_pydantic.append(f"{py_file}:{node.name}")
|
|
|
|
assert not missing_pydantic, (
|
|
"Dataclasses missing Pydantic BaseModel inheritance:\n"
|
|
+ "\n".join(missing_pydantic)
|
|
)
|
|
|
|
|
|
@when("I read the CONTRIBUTING guidelines")
|
|
def step_read_contributing_guidelines(context):
|
|
"""Load the CONTRIBUTING.md document."""
|
|
contributing_path = Path("CONTRIBUTING.md")
|
|
assert contributing_path.exists(), "CONTRIBUTING.md not found"
|
|
context.contributing_text = contributing_path.read_text(encoding="utf-8")
|
|
|
|
|
|
@then('the contributing document should include "{text}"')
|
|
def step_contributing_includes_text(context, text):
|
|
"""Ensure CONTRIBUTING.md contains the expected text."""
|
|
content = getattr(context, "contributing_text", "")
|
|
assert text in content, f"Expected '{text}' in CONTRIBUTING.md"
|
|
|
|
|
|
@when("I import every module under the source directory")
|
|
def step_import_every_module(context):
|
|
"""Import every module beneath src/cleveragents so coverage sees them."""
|
|
|
|
if not hasattr(context, "src_dir"):
|
|
context.src_dir = Path("src/cleveragents")
|
|
assert context.src_dir.exists(), "Source directory missing"
|
|
|
|
importlib.import_module("cleveragents")
|
|
|
|
context.module_import_errors = []
|
|
package_root = str(context.src_dir)
|
|
|
|
for module_info in pkgutil.walk_packages([package_root], prefix="cleveragents."):
|
|
module_name = module_info.name
|
|
if module_name.endswith(".__main__"):
|
|
continue
|
|
try:
|
|
importlib.import_module(module_name)
|
|
except Exception as exc: # pragma: no cover - only runs on failure
|
|
context.module_import_errors.append((module_name, repr(exc)))
|
|
|
|
|
|
@then("every module should import without errors")
|
|
def step_assert_module_imports(context):
|
|
"""Fail if any module imports raised an exception."""
|
|
|
|
errors = getattr(context, "module_import_errors", [])
|
|
assert not errors, "Some modules failed to import:\n" + "\n".join(
|
|
f"- {name}: {error}" for name, error in errors
|
|
)
|