Compare commits

...
1 Commits
Author SHA1 Message Date
Mervin Praison 03a3aa420b Release v3.7.2 2026-01-10 15:31:05 +00:00
24 changed files with 4000 additions and 7 deletions

No files matched your search

+1 -1
View File
@@ -16,7 +16,7 @@ RUN mkdir -p /root/.praison
# Install Python packages (using latest versions)
RUN pip install --no-cache-dir \
praisonai_tools \
"praisonai>=3.7.1" \
"praisonai>=3.7.2" \
"praisonai[chat]" \
"embedchain[github,youtube]"
+1 -1
View File
@@ -20,7 +20,7 @@ RUN mkdir -p /root/.praison
# Install Python packages (using latest versions)
RUN pip install --no-cache-dir \
praisonai_tools \
"praisonai>=3.7.1" \
"praisonai>=3.7.2" \
"praisonai[ui]" \
"praisonai[chat]" \
"praisonai[realtime]" \
+1 -1
View File
@@ -16,7 +16,7 @@ RUN mkdir -p /root/.praison
# Install Python packages (using latest versions)
RUN pip install --no-cache-dir \
praisonai_tools \
"praisonai>=3.7.1" \
"praisonai>=3.7.2" \
"praisonai[ui]" \
"praisonai[crewai]"
+2 -2
View File
@@ -3,8 +3,8 @@ class Praisonai < Formula
desc "AI tools for various AI applications"
homepage "https://github.com/MervinPraison/PraisonAI"
url "https://github.com/MervinPraison/PraisonAI/archive/refs/tags/v3.7.1.tar.gz"
sha256 `curl -sL https://github.com/MervinPraison/PraisonAI/archive/refs/tags/v3.7.1.tar.gz | shasum -a 256`.split.first
url "https://github.com/MervinPraison/PraisonAI/archive/refs/tags/v3.7.2.tar.gz"
sha256 `curl -sL https://github.com/MervinPraison/PraisonAI/archive/refs/tags/v3.7.2.tar.gz | shasum -a 256`.split.first
license "MIT"
depends_on "[email protected]"
@@ -0,0 +1,393 @@
"""
CLI commands for the FDEP standardisation system.
Commands:
- praisonai standardise check - Check for standardisation issues
- praisonai standardise report - Generate detailed report
- praisonai standardise fix - Fix issues (with --apply)
- praisonai standardise init - Initialise a new feature
"""
import sys
from pathlib import Path
def _lazy_import_engine():
"""Lazy import to avoid heavy imports at CLI startup."""
from praisonai.standardise import StandardiseEngine, StandardiseConfig
return StandardiseEngine, StandardiseConfig
def standardise_command(args=None):
"""Main entry point for standardise command."""
import argparse
parser = argparse.ArgumentParser(
prog="praisonai standardise",
description="Standardise documentation and examples (FDEP)",
)
subparsers = parser.add_subparsers(dest="subcommand", help="Subcommands")
# check subcommand
check_parser = subparsers.add_parser(
"check",
help="Check for standardisation issues (default)",
)
_add_common_args(check_parser)
# report subcommand
report_parser = subparsers.add_parser(
"report",
help="Generate detailed report",
)
_add_common_args(report_parser)
report_parser.add_argument(
"--format", "-f",
choices=["text", "json", "markdown"],
default="text",
help="Report format (default: text)",
)
report_parser.add_argument(
"--output", "-o",
type=str,
help="Output file path (default: stdout)",
)
# fix subcommand
fix_parser = subparsers.add_parser(
"fix",
help="Fix standardisation issues",
)
_add_common_args(fix_parser)
fix_parser.add_argument(
"--apply",
action="store_true",
help="Actually apply changes (default: dry-run)",
)
fix_parser.add_argument(
"--no-backup",
action="store_true",
help="Don't create backups before changes",
)
# init subcommand
init_parser = subparsers.add_parser(
"init",
help="Initialise a new feature with all required artifacts",
)
init_parser.add_argument(
"feature",
type=str,
help="Feature slug to initialise",
)
_add_common_args(init_parser)
init_parser.add_argument(
"--apply",
action="store_true",
help="Actually create files (default: dry-run)",
)
# Parse args
parsed = parser.parse_args(args)
# Default to check if no subcommand
if not parsed.subcommand:
parsed.subcommand = "check"
parsed = parser.parse_args(["check"] + (args or []))
# Execute subcommand
if parsed.subcommand == "check":
return _run_check(parsed)
elif parsed.subcommand == "report":
return _run_report(parsed)
elif parsed.subcommand == "fix":
return _run_fix(parsed)
elif parsed.subcommand == "init":
return _run_init(parsed)
return 0
def _add_common_args(parser):
"""Add common arguments to a parser."""
parser.add_argument(
"--path", "-p",
type=str,
default=".",
help="Project root path (default: current directory)",
)
parser.add_argument(
"--feature",
type=str,
help="Specific feature slug to check",
)
parser.add_argument(
"--scope",
choices=["all", "docs", "examples", "sdk", "cli"],
default="all",
help="Scope of check (default: all)",
)
parser.add_argument(
"--ci",
action="store_true",
help="CI mode (no prompts, machine-readable output)",
)
parser.add_argument(
"--dry-run",
action="store_true",
default=True,
help="Don't make changes (default: true)",
)
def _create_config(parsed):
"""Create config from parsed arguments."""
_, StandardiseConfig = _lazy_import_engine()
config = StandardiseConfig(
project_root=Path(parsed.path).resolve() if hasattr(parsed, "path") else None,
dry_run=getattr(parsed, "dry_run", True) and not getattr(parsed, "apply", False),
backup=not getattr(parsed, "no_backup", False),
scope=getattr(parsed, "scope", "all"),
feature_filter=getattr(parsed, "feature", None),
ci_mode=getattr(parsed, "ci", False),
report_format=getattr(parsed, "format", "text"),
)
return config
def _run_check(parsed) -> int:
"""Run the check subcommand."""
StandardiseEngine, StandardiseConfig = _lazy_import_engine()
config = _create_config(parsed)
engine = StandardiseEngine(config)
report = engine.check()
# Print report
output = engine.reports.generate(report, "text")
print(output)
# Return exit code
if config.ci_mode:
return engine.get_exit_code(report)
return 0
def _run_report(parsed) -> int:
"""Run the report subcommand."""
StandardiseEngine, StandardiseConfig = _lazy_import_engine()
config = _create_config(parsed)
engine = StandardiseEngine(config)
report = engine.check()
output = engine.reports.generate(report, parsed.format)
if parsed.output:
Path(parsed.output).write_text(output, encoding="utf-8")
print(f"Report saved to {parsed.output}")
else:
print(output)
if config.ci_mode:
return engine.get_exit_code(report)
return 0
def _run_fix(parsed) -> int:
"""Run the fix subcommand."""
StandardiseEngine, StandardiseConfig = _lazy_import_engine()
config = _create_config(parsed)
engine = StandardiseEngine(config)
actions = engine.fix(
feature=parsed.feature,
apply=parsed.apply,
)
if not actions["planned"]:
print("No fixes needed.")
return 0
print(f"Planned actions ({len(actions['planned'])}):")
for action in actions["planned"]:
print(f" • {action}")
if parsed.apply:
print(f"\nApplied actions ({len(actions['applied'])}):")
for action in actions["applied"]:
print(f" ✓ {action}")
else:
print("\nRun with --apply to execute these changes.")
return 0
def _run_init(parsed) -> int:
"""Run the init subcommand."""
StandardiseEngine, StandardiseConfig = _lazy_import_engine()
config = _create_config(parsed)
engine = StandardiseEngine(config)
try:
created = engine.init(parsed.feature)
except ValueError as e:
print(f"Error: {e}")
return 2
if not created:
print(f"Feature '{parsed.feature}' already has all required artifacts.")
return 0
print(f"{'Would create' if config.dry_run else 'Created'} artifacts for '{parsed.feature}':")
for artifact_type, path in created.items():
status = "📝" if config.dry_run else "✓"
print(f" {status} {artifact_type}: {path}")
if config.dry_run:
print("\nRun with --apply to create these files.")
return 0
# Click-based alternative for integration with main CLI
def register_click_commands(app):
"""Register click commands with the main CLI app."""
try:
import click
except ImportError:
return
@app.group(name="standardise")
def standardise_group():
"""Standardise documentation and examples (FDEP)."""
pass
@standardise_group.command(name="check")
@click.option("--path", "-p", default=".", help="Project root path")
@click.option("--feature", help="Specific feature slug to check")
@click.option("--scope", type=click.Choice(["all", "docs", "examples", "sdk", "cli"]), default="all")
@click.option("--ci", is_flag=True, help="CI mode")
def check_cmd(path, feature, scope, ci):
"""Check for standardisation issues."""
StandardiseEngine, StandardiseConfig = _lazy_import_engine()
config = StandardiseConfig(
project_root=Path(path).resolve(),
feature_filter=feature,
scope=scope,
ci_mode=ci,
)
engine = StandardiseEngine(config)
report = engine.check()
output = engine.reports.generate(report, "text")
click.echo(output)
if ci:
sys.exit(engine.get_exit_code(report))
@standardise_group.command(name="report")
@click.option("--path", "-p", default=".", help="Project root path")
@click.option("--format", "-f", type=click.Choice(["text", "json", "markdown"]), default="text")
@click.option("--output", "-o", help="Output file path")
@click.option("--ci", is_flag=True, help="CI mode")
def report_cmd(path, format, output, ci):
"""Generate detailed report."""
StandardiseEngine, StandardiseConfig = _lazy_import_engine()
config = StandardiseConfig(
project_root=Path(path).resolve(),
ci_mode=ci,
)
engine = StandardiseEngine(config)
report = engine.check()
content = engine.reports.generate(report, format)
if output:
Path(output).write_text(content, encoding="utf-8")
click.echo(f"Report saved to {output}")
else:
click.echo(content)
@standardise_group.command(name="fix")
@click.option("--path", "-p", default=".", help="Project root path")
@click.option("--feature", help="Specific feature slug to fix")
@click.option("--apply", is_flag=True, help="Actually apply changes")
@click.option("--no-backup", is_flag=True, help="Don't create backups")
def fix_cmd(path, feature, apply, no_backup):
"""Fix standardisation issues."""
StandardiseEngine, StandardiseConfig = _lazy_import_engine()
config = StandardiseConfig(
project_root=Path(path).resolve(),
feature_filter=feature,
dry_run=not apply,
backup=not no_backup,
)
engine = StandardiseEngine(config)
actions = engine.fix(feature=feature, apply=apply)
if not actions["planned"]:
click.echo("No fixes needed.")
return
click.echo(f"Planned actions ({len(actions['planned'])}):")
for action in actions["planned"]:
click.echo(f" • {action}")
if apply:
click.echo(f"\nApplied actions ({len(actions['applied'])}):")
for action in actions["applied"]:
click.echo(f" ✓ {action}")
else:
click.echo("\nRun with --apply to execute these changes.")
@standardise_group.command(name="init")
@click.argument("feature")
@click.option("--path", "-p", default=".", help="Project root path")
@click.option("--apply", is_flag=True, help="Actually create files")
def init_cmd(feature, path, apply):
"""Initialise a new feature with all required artifacts."""
StandardiseEngine, StandardiseConfig = _lazy_import_engine()
config = StandardiseConfig(
project_root=Path(path).resolve(),
dry_run=not apply,
)
engine = StandardiseEngine(config)
try:
created = engine.init(feature)
except ValueError as e:
click.echo(f"Error: {e}", err=True)
sys.exit(2)
if not created:
click.echo(f"Feature '{feature}' already has all required artifacts.")
return
action_word = "Would create" if config.dry_run else "Created"
click.echo(f"{action_word} artifacts for '{feature}':")
for artifact_type, artifact_path in created.items():
status = "📝" if config.dry_run else "✓"
click.echo(f" {status} {artifact_type}: {artifact_path}")
if config.dry_run:
click.echo("\nRun with --apply to create these files.")
return standardise_group
if __name__ == "__main__":
sys.exit(standardise_command())
+1 -1
View File
@@ -57,7 +57,7 @@ class CloudDeployer:
file.write("FROM python:3.11-slim\n")
file.write("WORKDIR /app\n")
file.write("COPY . .\n")
file.write("RUN pip install flask praisonai==3.7.1 gunicorn markdown\n")
file.write("RUN pip install flask praisonai==3.7.2 gunicorn markdown\n")
file.write("EXPOSE 8080\n")
file.write('CMD ["gunicorn", "-b", "0.0.0.0:8080", "api:app"]\n')
@@ -0,0 +1,73 @@
"""
PraisonAI Standardise Module
Feature Docs/Examples Protocol (FDEP) implementation for standardising
documentation and examples across the PraisonAI ecosystem.
All imports are lazy-loaded for zero performance impact.
"""
__all__ = [
# Models
"FeatureSlug",
"FeatureManifest",
"ArtifactStatus",
"ValidationResult",
"DuplicateCluster",
"DriftReport",
# Engine
"StandardiseEngine",
"FeatureDiscovery",
"ArtifactValidator",
"DedupeDetector",
"DriftDetector",
"TemplateGenerator",
"ReportGenerator",
# Config
"StandardiseConfig",
]
def __getattr__(name: str):
"""Lazy loading of standardise components."""
if name in ("FeatureSlug", "FeatureManifest", "ArtifactStatus",
"ValidationResult", "DuplicateCluster", "DriftReport"):
from .models import (
FeatureSlug, FeatureManifest, ArtifactStatus,
ValidationResult, DuplicateCluster, DriftReport
)
return locals()[name]
if name == "StandardiseEngine":
from .engine import StandardiseEngine
return StandardiseEngine
if name == "FeatureDiscovery":
from .discovery import FeatureDiscovery
return FeatureDiscovery
if name == "ArtifactValidator":
from .validator import ArtifactValidator
return ArtifactValidator
if name == "DedupeDetector":
from .dedupe import DedupeDetector
return DedupeDetector
if name == "DriftDetector":
from .drift import DriftDetector
return DriftDetector
if name == "TemplateGenerator":
from .templates import TemplateGenerator
return TemplateGenerator
if name == "ReportGenerator":
from .reports import ReportGenerator
return ReportGenerator
if name == "StandardiseConfig":
from .config import StandardiseConfig
return StandardiseConfig
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
@@ -0,0 +1,248 @@
"""
Configuration for the FDEP standardisation system.
Supports configuration via:
1. CLI flags (highest priority)
2. Config file (.praison/standardise.yaml)
3. Environment variables
4. Defaults (lowest priority)
"""
import os
from dataclasses import dataclass, field
from pathlib import Path
from typing import Dict, List, Optional
@dataclass
class StandardiseConfig:
"""Configuration for the standardiser engine."""
# Root paths (auto-detected if not specified)
project_root: Optional[Path] = None
docs_root: Optional[Path] = None
examples_root: Optional[Path] = None
sdk_root: Optional[Path] = None
cli_root: Optional[Path] = None
# Mintlify config
mintlify_config: Optional[Path] = None
# Manifest storage
manifest_dir: Optional[Path] = None
# Safety settings
dry_run: bool = True
backup: bool = True
force: bool = False
# Scope settings
scope: str = "all" # all, docs, examples, sdk, cli
feature_filter: Optional[str] = None # Specific feature slug to check
# Dedupe settings
dedupe_mode: str = "prompt" # prompt, auto, skip
similarity_threshold: float = 0.8
# Report settings
report_format: str = "text" # text, json, markdown
# CI settings
ci_mode: bool = False
# Excluded paths (relative to project root)
excluded_paths: List[str] = field(default_factory=lambda: [
"node_modules",
"__pycache__",
".git",
".venv",
"venv",
"dist",
"build",
])
# Required artifact types per feature
required_artifacts: List[str] = field(default_factory=lambda: [
"docs_concept",
"docs_feature",
"docs_cli",
"docs_sdk",
"example_basic",
"example_advanced",
])
def __post_init__(self):
"""Auto-detect paths if not specified."""
# Convert string paths to Path objects
if isinstance(self.project_root, str):
self.project_root = Path(self.project_root)
if isinstance(self.docs_root, str):
self.docs_root = Path(self.docs_root)
if isinstance(self.examples_root, str):
self.examples_root = Path(self.examples_root)
if isinstance(self.sdk_root, str):
self.sdk_root = Path(self.sdk_root)
if isinstance(self.cli_root, str):
self.cli_root = Path(self.cli_root)
if isinstance(self.mintlify_config, str):
self.mintlify_config = Path(self.mintlify_config)
if isinstance(self.manifest_dir, str):
self.manifest_dir = Path(self.manifest_dir)
if self.project_root is None:
self.project_root = self._detect_project_root()
if self.project_root:
self._auto_detect_paths()
def _detect_project_root(self) -> Optional[Path]:
"""Detect project root by looking for common markers."""
cwd = Path.cwd()
# Look for .praison directory or pyproject.toml
for parent in [cwd] + list(cwd.parents):
if (parent / ".praison").exists():
return parent
if (parent / "pyproject.toml").exists():
return parent
if (parent / "mint.json").exists():
return parent
return cwd
def _auto_detect_paths(self):
"""Auto-detect paths based on project root."""
if not self.project_root:
return
root = self.project_root
# Docs root detection
if self.docs_root is None:
for candidate in ["docs", "PraisonAIDocs/docs"]:
path = root / candidate
if path.exists():
self.docs_root = path
break
# Check parent for PraisonAIDocs
parent_docs = root.parent / "PraisonAIDocs"
if parent_docs.exists() and self.docs_root is None:
self.docs_root = parent_docs / "docs"
# Examples root detection
if self.examples_root is None:
for candidate in ["examples/python", "examples"]:
path = root / candidate
if path.exists():
self.examples_root = path
break
# SDK root detection
if self.sdk_root is None:
for candidate in [
"src/praisonai-agents/praisonaiagents",
"praisonaiagents",
]:
path = root / candidate
if path.exists():
self.sdk_root = path
break
# CLI root detection
if self.cli_root is None:
for candidate in [
"src/praisonai/praisonai/cli",
"praisonai/cli",
]:
path = root / candidate
if path.exists():
self.cli_root = path
break
# Mintlify config detection
if self.mintlify_config is None:
# Check docs root parent for mint.json
if self.docs_root:
mint_path = self.docs_root.parent / "mint.json"
if mint_path.exists():
self.mintlify_config = mint_path
# Check project root
if self.mintlify_config is None:
for name in ["mint.json", "mintlify.json", "docs.json"]:
path = root / name
if path.exists():
self.mintlify_config = path
break
# Manifest directory
if self.manifest_dir is None:
self.manifest_dir = root / ".praison" / "features"
@classmethod
def from_file(cls, path: Path) -> "StandardiseConfig":
"""Load configuration from a YAML file."""
try:
import yaml
except ImportError:
# Fallback to basic parsing if PyYAML not available
return cls()
if not path.exists():
return cls()
with open(path, "r") as f:
data = yaml.safe_load(f) or {}
return cls(
project_root=Path(data["project_root"]) if data.get("project_root") else None,
docs_root=Path(data["docs_root"]) if data.get("docs_root") else None,
examples_root=Path(data["examples_root"]) if data.get("examples_root") else None,
sdk_root=Path(data["sdk_root"]) if data.get("sdk_root") else None,
cli_root=Path(data["cli_root"]) if data.get("cli_root") else None,
mintlify_config=Path(data["mintlify_config"]) if data.get("mintlify_config") else None,
manifest_dir=Path(data["manifest_dir"]) if data.get("manifest_dir") else None,
dry_run=data.get("dry_run", True),
backup=data.get("backup", True),
force=data.get("force", False),
scope=data.get("scope", "all"),
feature_filter=data.get("feature_filter"),
dedupe_mode=data.get("dedupe_mode", "prompt"),
similarity_threshold=data.get("similarity_threshold", 0.8),
report_format=data.get("report_format", "text"),
ci_mode=data.get("ci_mode", False),
excluded_paths=data.get("excluded_paths", cls.__dataclass_fields__["excluded_paths"].default_factory()),
required_artifacts=data.get("required_artifacts", cls.__dataclass_fields__["required_artifacts"].default_factory()),
)
@classmethod
def from_env(cls) -> "StandardiseConfig":
"""Load configuration from environment variables."""
return cls(
project_root=Path(os.environ["PRAISON_PROJECT_ROOT"]) if os.environ.get("PRAISON_PROJECT_ROOT") else None,
docs_root=Path(os.environ["PRAISON_DOCS_ROOT"]) if os.environ.get("PRAISON_DOCS_ROOT") else None,
dry_run=os.environ.get("PRAISON_DRY_RUN", "true").lower() == "true",
ci_mode=os.environ.get("CI", "false").lower() == "true",
)
def to_dict(self) -> Dict:
"""Convert to dictionary for serialisation."""
return {
"project_root": str(self.project_root) if self.project_root else None,
"docs_root": str(self.docs_root) if self.docs_root else None,
"examples_root": str(self.examples_root) if self.examples_root else None,
"sdk_root": str(self.sdk_root) if self.sdk_root else None,
"cli_root": str(self.cli_root) if self.cli_root else None,
"mintlify_config": str(self.mintlify_config) if self.mintlify_config else None,
"manifest_dir": str(self.manifest_dir) if self.manifest_dir else None,
"dry_run": self.dry_run,
"backup": self.backup,
"force": self.force,
"scope": self.scope,
"feature_filter": self.feature_filter,
"dedupe_mode": self.dedupe_mode,
"similarity_threshold": self.similarity_threshold,
"report_format": self.report_format,
"ci_mode": self.ci_mode,
"excluded_paths": self.excluded_paths,
"required_artifacts": self.required_artifacts,
}
@@ -0,0 +1,221 @@
"""
Duplicate detection for the FDEP standardisation system.
Detects duplicates via:
- Same slug in multiple locations
- High title similarity (>80%)
- High code-block overlap (>40%)
"""
import re
from pathlib import Path
from typing import Dict, List, Optional, Tuple
from .config import StandardiseConfig
from .discovery import FeatureDiscovery
from .models import DuplicateCluster, FeatureSlug
class DedupeDetector:
"""Detects duplicate and near-duplicate content."""
def __init__(self, config: StandardiseConfig, discovery: FeatureDiscovery):
self.config = config
self.discovery = discovery
self._frontmatter_cache: Dict[Path, Dict] = {}
def detect_all(self) -> List[DuplicateCluster]:
"""Detect all duplicate clusters."""
clusters = []
# Detect same-slug duplicates
clusters.extend(self._detect_same_slug_duplicates())
# Detect title similarity duplicates
clusters.extend(self._detect_title_duplicates())
return clusters
def _detect_same_slug_duplicates(self) -> List[DuplicateCluster]:
"""Detect pages with the same slug in different locations."""
clusters = []
all_features = self.discovery.get_all_features()
for slug in all_features:
pages = self.discovery.find_docs_pages(slug)
if len(pages) > 1:
# Determine primary page (prefer features > concepts > cli > sdk)
primary = self._select_primary_page(pages)
clusters.append(DuplicateCluster(
slug=slug,
pages=pages,
similarity_score=1.0, # Exact slug match
issue_type="same_slug",
recommendation="merge" if len(pages) <= 4 else "review",
primary_page=primary,
))
return clusters
def _detect_title_duplicates(self) -> List[DuplicateCluster]:
"""Detect pages with similar titles."""
clusters = []
if not self.config.docs_root or not self.config.docs_root.exists():
return clusters
# Collect all page titles
title_to_pages: Dict[str, List[Tuple[Path, str]]] = {}
for mdx_file in self.config.docs_root.rglob("*.mdx"):
# Skip node_modules and other excluded paths
if any(excluded in str(mdx_file) for excluded in self.config.excluded_paths):
continue
title = self._extract_title(mdx_file)
if title:
normalised_title = self._normalise_title(title)
if normalised_title not in title_to_pages:
title_to_pages[normalised_title] = []
title_to_pages[normalised_title].append((mdx_file, title))
# Find duplicates
for normalised_title, pages in title_to_pages.items():
if len(pages) > 1:
# Check if these are already captured by same-slug detection
slugs = set()
for page, _ in pages:
slug = FeatureSlug.from_path(page, "docs")
slugs.add(slug.normalised)
if len(slugs) > 1:
# Different slugs but same title - this is a title duplicate
slug = FeatureSlug.from_string(normalised_title)
clusters.append(DuplicateCluster(
slug=slug,
pages=[p for p, _ in pages],
similarity_score=0.9,
issue_type="title_similarity",
recommendation="review",
primary_page=pages[0][0],
))
return clusters
def _extract_title(self, mdx_file: Path) -> Optional[str]:
"""Extract title from MDX frontmatter."""
if mdx_file in self._frontmatter_cache:
return self._frontmatter_cache[mdx_file].get("title")
try:
content = mdx_file.read_text(encoding="utf-8")
except Exception:
return None
frontmatter = self._parse_frontmatter(content)
self._frontmatter_cache[mdx_file] = frontmatter
return frontmatter.get("title")
def _parse_frontmatter(self, content: str) -> Dict:
"""Parse YAML frontmatter from MDX content."""
frontmatter = {}
# Check for frontmatter delimiters
if not content.startswith("---"):
return frontmatter
# Find end of frontmatter
end_match = re.search(r"\n---\s*\n", content[3:])
if not end_match:
return frontmatter
frontmatter_text = content[3:end_match.start() + 3]
# Simple YAML parsing (avoid dependency on PyYAML)
for line in frontmatter_text.split("\n"):
line = line.strip()
if ":" in line:
key, value = line.split(":", 1)
key = key.strip()
value = value.strip().strip('"').strip("'")
frontmatter[key] = value
return frontmatter
def _normalise_title(self, title: str) -> str:
"""Normalise a title for comparison."""
# Lowercase, remove special chars, collapse whitespace
normalised = title.lower()
normalised = re.sub(r"[^a-z0-9\s]", "", normalised)
normalised = re.sub(r"\s+", " ", normalised).strip()
return normalised
def _select_primary_page(self, pages: List[Path]) -> Optional[Path]:
"""Select the primary page from a list of duplicates."""
# Priority: features > concepts > cli > sdk
priority_order = ["features", "concepts", "cli", "sdk"]
for priority_dir in priority_order:
for page in pages:
if f"/{priority_dir}/" in str(page) or f"\\{priority_dir}\\" in str(page):
return page
# Default to first page
return pages[0] if pages else None
def calculate_similarity(self, text1: str, text2: str) -> float:
"""Calculate similarity between two texts using Levenshtein ratio."""
if not text1 or not text2:
return 0.0
# Simple character-level similarity
len1, len2 = len(text1), len(text2)
if len1 == 0 and len2 == 0:
return 1.0
if len1 == 0 or len2 == 0:
return 0.0
# Use set-based similarity for efficiency
set1 = set(text1.lower().split())
set2 = set(text2.lower().split())
intersection = len(set1 & set2)
union = len(set1 | set2)
if union == 0:
return 0.0
return intersection / union
def get_naming_inconsistencies(self) -> List[Tuple[Path, Path, str]]:
"""Find naming inconsistencies (e.g., handoff.mdx vs handoffs.mdx)."""
inconsistencies = []
if not self.config.docs_root or not self.config.docs_root.exists():
return inconsistencies
# Check each docs directory
for doc_dir in ["concepts", "features", "cli"]:
dir_path = self.config.docs_root / doc_dir
if not dir_path.exists():
continue
# Get all MDX files
mdx_files = list(dir_path.glob("*.mdx"))
file_stems = {f.stem.lower(): f for f in mdx_files}
# Check for singular/plural pairs
from .models import SINGULAR_PLURAL_MAP
for singular, plural in SINGULAR_PLURAL_MAP.items():
if singular in file_stems and plural in file_stems:
inconsistencies.append((
file_stems[singular],
file_stems[plural],
f"Both {singular}.mdx and {plural}.mdx exist in {doc_dir}/",
))
return inconsistencies
@@ -0,0 +1,319 @@
"""
Feature discovery for the FDEP standardisation system.
Discovers features from:
- SDK modules (praisonaiagents/*)
- CLI features (praisonai/cli/features/*)
- Docs pages (docs/**)
- Examples (examples/python/**)
"""
from pathlib import Path
from typing import Dict, List, Optional, Set
from .config import StandardiseConfig
from .models import FeatureSlug
class FeatureDiscovery:
"""Discovers features from various sources in the codebase."""
# Directories to skip during discovery
SKIP_DIRS = {
"__pycache__", ".git", ".venv", "venv", "node_modules",
"dist", "build", ".pytest_cache", ".mypy_cache",
}
# Files to skip
SKIP_FILES = {"__init__.py", "base.py", "utils.py", "helpers.py"}
def __init__(self, config: StandardiseConfig):
self.config = config
self._sdk_features: Optional[Set[FeatureSlug]] = None
self._cli_features: Optional[Set[FeatureSlug]] = None
self._docs_features: Optional[Set[FeatureSlug]] = None
self._example_features: Optional[Set[FeatureSlug]] = None
def discover_all(self) -> Dict[str, Set[FeatureSlug]]:
"""Discover features from all sources."""
return {
"sdk": self.discover_sdk_features(),
"cli": self.discover_cli_features(),
"docs": self.discover_docs_features(),
"examples": self.discover_example_features(),
}
def get_all_features(self) -> Set[FeatureSlug]:
"""Get union of all discovered features."""
all_sources = self.discover_all()
result: Set[FeatureSlug] = set()
for features in all_sources.values():
result.update(features)
return result
def discover_sdk_features(self) -> Set[FeatureSlug]:
"""Discover features from SDK modules."""
if self._sdk_features is not None:
return self._sdk_features
self._sdk_features = set()
if not self.config.sdk_root or not self.config.sdk_root.exists():
return self._sdk_features
# Each top-level directory in SDK is a feature module
for item in self.config.sdk_root.iterdir():
if not item.is_dir():
continue
if item.name in self.SKIP_DIRS:
continue
if item.name.startswith("."):
continue
slug = FeatureSlug.from_path(item, "sdk")
if slug.is_valid:
self._sdk_features.add(slug)
return self._sdk_features
def discover_cli_features(self) -> Set[FeatureSlug]:
"""Discover features from CLI feature files."""
if self._cli_features is not None:
return self._cli_features
self._cli_features = set()
if not self.config.cli_root or not self.config.cli_root.exists():
return self._cli_features
# Check features directory
features_dir = self.config.cli_root / "features"
if features_dir.exists():
for item in features_dir.iterdir():
if not item.is_file():
continue
if item.suffix != ".py":
continue
if item.name in self.SKIP_FILES:
continue
slug = FeatureSlug.from_path(item, "cli")
if slug.is_valid:
self._cli_features.add(slug)
# Check commands directory
commands_dir = self.config.cli_root / "commands"
if commands_dir.exists():
for item in commands_dir.iterdir():
if not item.is_file():
continue
if item.suffix != ".py":
continue
if item.name in self.SKIP_FILES:
continue
slug = FeatureSlug.from_path(item, "cli")
if slug.is_valid:
self._cli_features.add(slug)
return self._cli_features
def discover_docs_features(self) -> Set[FeatureSlug]:
"""Discover features from docs pages."""
if self._docs_features is not None:
return self._docs_features
self._docs_features = set()
if not self.config.docs_root or not self.config.docs_root.exists():
return self._docs_features
# Scan key docs directories
docs_dirs = ["concepts", "features", "cli", "sdk"]
for dir_name in docs_dirs:
dir_path = self.config.docs_root / dir_name
if not dir_path.exists():
continue
self._scan_docs_directory(dir_path)
return self._docs_features
def _scan_docs_directory(self, directory: Path, depth: int = 0):
"""Recursively scan a docs directory for feature slugs."""
if depth > 3: # Limit recursion depth
return
for item in directory.iterdir():
if item.name in self.SKIP_DIRS:
continue
if item.name.startswith("."):
continue
if item.is_file() and item.suffix == ".mdx":
slug = FeatureSlug.from_path(item, "docs")
if slug.is_valid:
self._docs_features.add(slug)
elif item.is_dir():
# Directory name itself might be a feature
slug = FeatureSlug.from_path(item, "docs")
if slug.is_valid:
self._docs_features.add(slug)
# Recurse into subdirectory
self._scan_docs_directory(item, depth + 1)
def discover_example_features(self) -> Set[FeatureSlug]:
"""Discover features from examples."""
if self._example_features is not None:
return self._example_features
self._example_features = set()
if not self.config.examples_root or not self.config.examples_root.exists():
return self._example_features
# Each top-level directory in examples is a feature
for item in self.config.examples_root.iterdir():
if not item.is_dir():
continue
if item.name in self.SKIP_DIRS:
continue
if item.name.startswith("."):
continue
slug = FeatureSlug.from_path(item, "examples")
if slug.is_valid:
self._example_features.add(slug)
return self._example_features
def get_feature_sources(self, slug: FeatureSlug) -> Dict[str, bool]:
"""Get which sources contain a given feature."""
return {
"sdk": slug in self.discover_sdk_features(),
"cli": slug in self.discover_cli_features(),
"docs": slug in self.discover_docs_features(),
"examples": slug in self.discover_example_features(),
}
def find_docs_pages(self, slug: FeatureSlug) -> List[Path]:
"""Find all docs pages for a given feature slug."""
pages = []
if not self.config.docs_root or not self.config.docs_root.exists():
return pages
# Search in key directories
search_dirs = [
self.config.docs_root / "concepts",
self.config.docs_root / "features",
self.config.docs_root / "cli",
]
# Also search SDK docs
sdk_docs = self.config.docs_root / "sdk" / "praisonaiagents"
if sdk_docs.exists():
search_dirs.append(sdk_docs)
slug_str = slug.normalised
slug_variants = {slug_str, slug_str.replace("-", "_")}
# Add singular/plural variants
from .models import SINGULAR_PLURAL_MAP
for singular, plural in SINGULAR_PLURAL_MAP.items():
if slug_str == plural:
slug_variants.add(singular)
elif slug_str == singular:
slug_variants.add(plural)
for search_dir in search_dirs:
if not search_dir.exists():
continue
self._find_pages_recursive(search_dir, slug_variants, pages)
return pages
def _find_pages_recursive(self, directory: Path, slug_variants: Set[str],
pages: List[Path], depth: int = 0):
"""Recursively find pages matching slug variants."""
if depth > 4:
return
for item in directory.iterdir():
if item.name in self.SKIP_DIRS:
continue
if item.is_file() and item.suffix == ".mdx":
stem = item.stem.lower().replace("_", "-")
if stem in slug_variants:
pages.append(item)
elif item.is_dir():
dir_name = item.name.lower().replace("_", "-")
if dir_name in slug_variants:
# Check for index or main file in this directory
for mdx_file in item.glob("*.mdx"):
pages.append(mdx_file)
else:
self._find_pages_recursive(item, slug_variants, pages, depth + 1)
def find_examples(self, slug: FeatureSlug) -> List[Path]:
"""Find all example files for a given feature slug."""
examples = []
if not self.config.examples_root or not self.config.examples_root.exists():
return examples
slug_str = slug.normalised
slug_variants = {slug_str, slug_str.replace("-", "_")}
# Check for feature directory
for variant in slug_variants:
feature_dir = self.config.examples_root / variant
if feature_dir.exists() and feature_dir.is_dir():
for py_file in feature_dir.glob("*.py"):
examples.append(py_file)
return examples
def get_sdk_module_path(self, slug: FeatureSlug) -> Optional[Path]:
"""Get the SDK module path for a feature."""
if not self.config.sdk_root or not self.config.sdk_root.exists():
return None
slug_str = slug.normalised
variants = [slug_str, slug_str.replace("-", "_")]
for variant in variants:
module_path = self.config.sdk_root / variant
if module_path.exists() and module_path.is_dir():
return module_path
return None
def get_cli_feature_path(self, slug: FeatureSlug) -> Optional[Path]:
"""Get the CLI feature file path for a feature."""
if not self.config.cli_root or not self.config.cli_root.exists():
return None
slug_str = slug.normalised
variants = [slug_str, slug_str.replace("-", "_")]
# Check features directory
features_dir = self.config.cli_root / "features"
if features_dir.exists():
for variant in variants:
feature_path = features_dir / f"{variant}.py"
if feature_path.exists():
return feature_path
# Check commands directory
commands_dir = self.config.cli_root / "commands"
if commands_dir.exists():
for variant in variants:
command_path = commands_dir / f"{variant}.py"
if command_path.exists():
return command_path
return None
@@ -0,0 +1,263 @@
"""
Drift detection for the FDEP standardisation system.
Detects drift between:
- SDK exports vs SDK docs
- CLI help/flags vs CLI docs
"""
import ast
import re
from pathlib import Path
from typing import List, Optional, Set
from .config import StandardiseConfig
from .discovery import FeatureDiscovery
from .models import DriftItem, DriftReport, FeatureSlug
class DriftDetector:
"""Detects drift between code and documentation."""
def __init__(self, config: StandardiseConfig, discovery: FeatureDiscovery):
self.config = config
self.discovery = discovery
def detect_all(self) -> List[DriftReport]:
"""Detect drift for all features."""
reports = []
all_features = self.discovery.get_all_features()
for slug in all_features:
report = self.detect_feature_drift(slug)
if report.has_drift:
reports.append(report)
return reports
def detect_feature_drift(self, slug: FeatureSlug) -> DriftReport:
"""Detect drift for a single feature."""
sdk_drift = self._detect_sdk_drift(slug)
cli_drift = self._detect_cli_drift(slug)
return DriftReport(
slug=slug,
sdk_drift=sdk_drift,
cli_drift=cli_drift,
)
def _detect_sdk_drift(self, slug: FeatureSlug) -> List[DriftItem]:
"""Detect drift between SDK module and SDK docs."""
drift_items = []
# Get SDK module path
sdk_module = self.discovery.get_sdk_module_path(slug)
if not sdk_module or not sdk_module.exists():
return drift_items
# Extract public exports from SDK module
sdk_exports = self._extract_sdk_exports(sdk_module)
# Extract documented items from SDK docs
sdk_docs = self._get_sdk_docs_path(slug)
if sdk_docs:
documented_items = self._extract_documented_items(sdk_docs)
else:
documented_items = set()
# Find items in SDK but not in docs
for export in sdk_exports:
if export not in documented_items:
drift_items.append(DriftItem(
name=export,
source="sdk",
missing_in="docs",
item_type="class" if export[0].isupper() else "function",
))
# Find items in docs but not in SDK
for item in documented_items:
if item not in sdk_exports:
drift_items.append(DriftItem(
name=item,
source="docs",
missing_in="sdk",
item_type="class" if item[0].isupper() else "function",
))
return drift_items
def _detect_cli_drift(self, slug: FeatureSlug) -> List[DriftItem]:
"""Detect drift between CLI feature and CLI docs."""
drift_items = []
# Get CLI feature path
cli_feature = self.discovery.get_cli_feature_path(slug)
if not cli_feature or not cli_feature.exists():
return drift_items
# Extract CLI flags/commands from code
cli_flags = self._extract_cli_flags(cli_feature)
# Extract documented flags from CLI docs
cli_docs = self._get_cli_docs_path(slug)
if cli_docs:
documented_flags = self._extract_documented_flags(cli_docs)
else:
documented_flags = set()
# Find flags in code but not in docs
for flag in cli_flags:
if flag not in documented_flags:
drift_items.append(DriftItem(
name=flag,
source="cli",
missing_in="docs",
item_type="flag",
))
return drift_items
def _extract_sdk_exports(self, module_path: Path) -> Set[str]:
"""Extract public exports from an SDK module."""
exports = set()
# Check __init__.py for __all__
init_file = module_path / "__init__.py"
if init_file.exists():
try:
content = init_file.read_text(encoding="utf-8")
# Look for __all__ = [...]
all_match = re.search(r"__all__\s*=\s*\[(.*?)\]", content, re.DOTALL)
if all_match:
items = re.findall(r'["\'](\w+)["\']', all_match.group(1))
exports.update(items)
except Exception:
pass
# Also scan for public classes and functions
for py_file in module_path.glob("*.py"):
if py_file.name.startswith("_"):
continue
try:
content = py_file.read_text(encoding="utf-8")
tree = ast.parse(content)
for node in ast.walk(tree):
if isinstance(node, ast.ClassDef):
if not node.name.startswith("_"):
exports.add(node.name)
elif isinstance(node, ast.FunctionDef):
if not node.name.startswith("_"):
exports.add(node.name)
except Exception:
pass
return exports
def _extract_cli_flags(self, cli_file: Path) -> Set[str]:
"""Extract CLI flags from a CLI feature file."""
flags = set()
try:
content = cli_file.read_text(encoding="utf-8")
# Look for click.option or argparse add_argument patterns
# click: @click.option("--flag-name", ...)
click_flags = re.findall(r'@click\.option\(["\'](-{1,2}[\w-]+)["\']', content)
flags.update(click_flags)
# argparse: parser.add_argument("--flag-name", ...)
argparse_flags = re.findall(r'add_argument\(["\'](-{1,2}[\w-]+)["\']', content)
flags.update(argparse_flags)
# Also look for flag definitions in strings
string_flags = re.findall(r'["\'](-{2}[\w-]+)["\']', content)
flags.update(string_flags)
except Exception:
pass
return flags
def _extract_documented_items(self, docs_path: Path) -> Set[str]:
"""Extract documented class/function names from docs."""
items = set()
try:
content = docs_path.read_text(encoding="utf-8")
# Look for class/function references in code blocks
# Pattern: `ClassName` or `function_name`
backtick_items = re.findall(r'`(\w+)`', content)
items.update(backtick_items)
# Look for headings that might be class/function names
heading_items = re.findall(r'^#{1,3}\s+`?(\w+)`?', content, re.MULTILINE)
items.update(heading_items)
except Exception:
pass
return items
def _extract_documented_flags(self, docs_path: Path) -> Set[str]:
"""Extract documented CLI flags from docs."""
flags = set()
try:
content = docs_path.read_text(encoding="utf-8")
# Look for flags in code blocks and inline code
flag_patterns = re.findall(r'`(-{1,2}[\w-]+)`', content)
flags.update(flag_patterns)
# Also look for flags in plain text
text_flags = re.findall(r'\s(-{2}[\w-]+)\s', content)
flags.update(text_flags)
except Exception:
pass
return flags
def _get_sdk_docs_path(self, slug: FeatureSlug) -> Optional[Path]:
"""Get the SDK docs path for a feature."""
if not self.config.docs_root:
return None
sdk_docs = self.config.docs_root / "sdk" / "praisonaiagents"
if not sdk_docs.exists():
return None
slug_str = slug.normalised
variants = [slug_str, slug_str.replace("-", "_")]
for variant in variants:
module_dir = sdk_docs / variant
if module_dir.exists():
for mdx_file in module_dir.glob("*.mdx"):
return mdx_file
return None
def _get_cli_docs_path(self, slug: FeatureSlug) -> Optional[Path]:
"""Get the CLI docs path for a feature."""
if not self.config.docs_root:
return None
cli_docs = self.config.docs_root / "cli"
if not cli_docs.exists():
return None
slug_str = slug.normalised
variants = [slug_str, slug_str.replace("-", "_")]
for variant in variants:
path = cli_docs / f"{variant}.mdx"
if path.exists():
return path
return None
@@ -0,0 +1,270 @@
"""
Main standardisation engine for the FDEP system.
Orchestrates all standardisation operations:
- Discovery
- Validation
- Dedupe detection
- Drift detection
- Report generation
- Fix application
"""
import shutil
from datetime import datetime
from pathlib import Path
from typing import Dict, List, Optional
from .config import StandardiseConfig
from .dedupe import DedupeDetector
from .discovery import FeatureDiscovery
from .drift import DriftDetector
from .models import (
ArtifactType,
FeatureSlug,
IssueType,
StandardiseIssue,
StandardiseReport,
)
from .reports import ReportGenerator
from .templates import TemplateGenerator
from .validator import ArtifactValidator
class StandardiseEngine:
"""Main engine for standardisation operations."""
def __init__(self, config: Optional[StandardiseConfig] = None):
self.config = config or StandardiseConfig()
self.discovery = FeatureDiscovery(self.config)
self.validator = ArtifactValidator(self.config, self.discovery)
self.dedupe = DedupeDetector(self.config, self.discovery)
self.drift = DriftDetector(self.config, self.discovery)
self.templates = TemplateGenerator()
self.reports = ReportGenerator()
def check(self) -> StandardiseReport:
"""Run a full standardisation check and return a report."""
timestamp = datetime.now().isoformat()
# Discover features
all_features = self.discovery.get_all_features()
# Count docs and examples
docs_count = self._count_docs()
examples_count = self._count_examples()
# Validate artifacts
validation_results = self.validator.validate_all()
missing_artifacts = self.validator.get_missing_artifacts()
# Detect duplicates
duplicates = self.dedupe.detect_all()
# Detect naming inconsistencies
naming_issues = self.dedupe.get_naming_inconsistencies()
# Detect drift
drift_reports = self.drift.detect_all()
# Compile issues
issues = []
# Add naming inconsistency issues
for path1, path2, message in naming_issues:
issues.append(StandardiseIssue(
issue_type=IssueType.NAMING_INCONSISTENCY,
slug=None,
path=path1,
message=message,
severity="warning",
auto_fixable=True,
fix_action=f"rename {path1} or {path2}",
))
# Add drift issues
for drift_report in drift_reports:
for item in drift_report.sdk_drift:
issues.append(StandardiseIssue(
issue_type=IssueType.DRIFT_SDK_DOCS,
slug=drift_report.slug,
path=None,
message=f"{item.name} in {item.source} but missing in {item.missing_in}",
severity="info",
auto_fixable=False,
))
for item in drift_report.cli_drift:
issues.append(StandardiseIssue(
issue_type=IssueType.DRIFT_CLI_DOCS,
slug=drift_report.slug,
path=None,
message=f"{item.name} in {item.source} but missing in {item.missing_in}",
severity="info",
auto_fixable=False,
))
return StandardiseReport(
timestamp=timestamp,
features_scanned=len(all_features),
docs_pages=docs_count,
examples_count=examples_count,
issues=issues,
duplicates=duplicates,
missing_artifacts=missing_artifacts,
drift_reports=drift_reports,
validation_results=list(validation_results.values()),
)
def report(self, format: str = "text") -> str:
"""Generate a report in the specified format."""
report = self.check()
return self.reports.generate(report, format)
def fix(self, feature: Optional[str] = None,
apply: bool = False) -> Dict[str, List[str]]:
"""
Fix standardisation issues.
Args:
feature: Specific feature slug to fix (None = all)
apply: Actually apply changes (False = dry-run)
Returns:
Dict with 'planned' and 'applied' actions
"""
actions = {"planned": [], "applied": []}
# Get current state
report = self.check()
# Plan fixes for missing artifacts
for slug_str, missing in report.missing_artifacts.items():
if feature and slug_str != feature:
continue
slug = FeatureSlug.from_string(slug_str)
for artifact_type in missing:
path = self.templates.get_expected_path(
slug, artifact_type,
docs_root=self.config.docs_root,
examples_root=self.config.examples_root,
)
if path:
action = f"CREATE {path}"
actions["planned"].append(action)
if apply and not self.config.dry_run:
self._create_artifact(slug, artifact_type, path)
actions["applied"].append(action)
# Plan fixes for naming inconsistencies
naming_issues = self.dedupe.get_naming_inconsistencies()
for path1, path2, message in naming_issues:
if feature:
slug1 = FeatureSlug.from_path(path1, "docs")
slug2 = FeatureSlug.from_path(path2, "docs")
if slug1.normalised != feature and slug2.normalised != feature:
continue
# Prefer plural form
if "singular" in str(path1).lower() or len(path1.stem) < len(path2.stem):
action = f"RENAME {path1} -> deprecated, keep {path2}"
else:
action = f"RENAME {path2} -> deprecated, keep {path1}"
actions["planned"].append(action)
return actions
def init(self, feature: str) -> Dict[str, str]:
"""
Initialise a new feature with all required artifacts.
Args:
feature: Feature slug to initialise
Returns:
Dict mapping artifact type to created path
"""
created = {}
slug = FeatureSlug.from_string(feature)
if not slug.is_valid:
raise ValueError(f"Invalid feature slug: {slug.validation_error}")
for artifact_type in ArtifactType:
if artifact_type == ArtifactType.MANIFEST:
continue
path = self.templates.get_expected_path(
slug, artifact_type,
docs_root=self.config.docs_root,
examples_root=self.config.examples_root,
)
if path and not path.exists():
if not self.config.dry_run:
self._create_artifact(slug, artifact_type, path)
created[artifact_type.value] = str(path)
return created
def _create_artifact(self, slug: FeatureSlug, artifact_type: ArtifactType,
path: Path) -> None:
"""Create an artifact file."""
# Backup if exists
if path.exists() and self.config.backup:
self._backup_file(path)
# Create parent directories
path.parent.mkdir(parents=True, exist_ok=True)
# Generate content
content = self.templates.generate(slug, artifact_type)
# Write file
path.write_text(content, encoding="utf-8")
def _backup_file(self, path: Path) -> Path:
"""Create a backup of a file."""
if not self.config.project_root:
return path
backup_dir = self.config.project_root / ".praison" / "backups"
backup_dir.mkdir(parents=True, exist_ok=True)
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
backup_name = f"{path.stem}_{timestamp}{path.suffix}"
backup_path = backup_dir / backup_name
shutil.copy2(path, backup_path)
return backup_path
def _count_docs(self) -> int:
"""Count total docs pages."""
if not self.config.docs_root or not self.config.docs_root.exists():
return 0
count = 0
for mdx_file in self.config.docs_root.rglob("*.mdx"):
if not any(excluded in str(mdx_file) for excluded in self.config.excluded_paths):
count += 1
return count
def _count_examples(self) -> int:
"""Count total example files."""
if not self.config.examples_root or not self.config.examples_root.exists():
return 0
count = 0
for py_file in self.config.examples_root.rglob("*.py"):
if not any(excluded in str(py_file) for excluded in self.config.excluded_paths):
count += 1
return count
def get_exit_code(self, report: StandardiseReport) -> int:
"""Get CI exit code based on report."""
if report.has_issues:
return 1 # Issues found
return 0 # No issues
@@ -0,0 +1,201 @@
"""
Manifest schema and storage for the FDEP standardisation system.
Manifests are stored in .praison/features/{slug}.yaml
"""
from pathlib import Path
from typing import List, Optional
from .models import FeatureManifest, FeatureSlug
class ManifestStorage:
"""Manages feature manifest files."""
def __init__(self, manifest_dir: Optional[Path] = None):
self.manifest_dir = manifest_dir or Path(".praison/features")
def get_manifest_path(self, slug: FeatureSlug) -> Path:
"""Get the path to a manifest file."""
return self.manifest_dir / f"{slug.normalised}.yaml"
def load(self, slug: FeatureSlug) -> Optional[FeatureManifest]:
"""Load a manifest from disk."""
path = self.get_manifest_path(slug)
if not path.exists():
return None
try:
import yaml
except ImportError:
return self._load_simple(path, slug)
try:
with open(path, "r", encoding="utf-8") as f:
data = yaml.safe_load(f)
return FeatureManifest.from_dict(data)
except Exception:
return None
def _load_simple(self, path: Path, slug: FeatureSlug) -> Optional[FeatureManifest]:
"""Simple YAML loading without PyYAML dependency."""
try:
content = path.read_text(encoding="utf-8")
data = {}
for line in content.split("\n"):
line = line.strip()
if ":" in line and not line.startswith("#"):
key, value = line.split(":", 1)
key = key.strip()
value = value.strip().strip('"').strip("'")
data[key] = value
return FeatureManifest.from_dict(data)
except Exception:
return None
def save(self, manifest: FeatureManifest) -> Path:
"""Save a manifest to disk."""
path = self.get_manifest_path(manifest.slug)
path.parent.mkdir(parents=True, exist_ok=True)
try:
import yaml
content = yaml.dump(manifest.to_dict(), default_flow_style=False, sort_keys=False)
except ImportError:
content = self._to_simple_yaml(manifest)
path.write_text(content, encoding="utf-8")
return path
def _to_simple_yaml(self, manifest: FeatureManifest) -> str:
"""Convert manifest to simple YAML without PyYAML."""
lines = []
data = manifest.to_dict()
for key, value in data.items():
if value is None:
continue
if isinstance(value, list):
if value:
lines.append(f"{key}:")
for item in value:
lines.append(f" - {item}")
elif isinstance(value, dict):
if value:
lines.append(f"{key}:")
for k, v in value.items():
lines.append(f" {k}: {v}")
else:
lines.append(f"{key}: {value}")
return "\n".join(lines)
def list_all(self) -> List[FeatureManifest]:
"""List all manifests."""
manifests = []
if not self.manifest_dir.exists():
return manifests
for yaml_file in self.manifest_dir.glob("*.yaml"):
slug = FeatureSlug.from_path(yaml_file, "manifest")
manifest = self.load(slug)
if manifest:
manifests.append(manifest)
return manifests
def delete(self, slug: FeatureSlug) -> bool:
"""Delete a manifest."""
path = self.get_manifest_path(slug)
if path.exists():
path.unlink()
return True
return False
def exists(self, slug: FeatureSlug) -> bool:
"""Check if a manifest exists."""
return self.get_manifest_path(slug).exists()
def create_default(self, slug: FeatureSlug,
sdk_module: Optional[str] = None,
cli_commands: Optional[List[str]] = None) -> FeatureManifest:
"""Create a default manifest for a feature."""
name = slug.normalised.replace("-", " ").title()
return FeatureManifest(
slug=slug,
name=name,
description=f"{name} feature for PraisonAI",
status="stable",
sdk_module=sdk_module or slug.normalised,
cli_commands=cli_commands or [],
)
# Schema for manifest validation
MANIFEST_SCHEMA = {
"type": "object",
"required": ["slug", "name", "description"],
"properties": {
"slug": {
"type": "string",
"pattern": "^[a-z][a-z0-9-]{0,62}[a-z0-9]$|^[a-z]$",
"description": "Feature slug (lowercase alphanumeric + hyphens)",
},
"name": {
"type": "string",
"minLength": 1,
"maxLength": 100,
"description": "Human-readable feature name",
},
"description": {
"type": "string",
"minLength": 1,
"maxLength": 500,
"description": "Brief description of the feature",
},
"status": {
"type": "string",
"enum": ["stable", "beta", "experimental", "deprecated"],
"default": "stable",
},
"min_version": {
"type": "string",
"pattern": "^\\d+\\.\\d+\\.\\d+$",
"description": "Minimum PraisonAI version required",
},
"tags": {
"type": "array",
"items": {"type": "string"},
"description": "Tags for categorisation",
},
"related_features": {
"type": "array",
"items": {"type": "string"},
"description": "Related feature slugs",
},
"sdk_module": {
"type": "string",
"description": "SDK module name (e.g., 'guardrails')",
},
"cli_commands": {
"type": "array",
"items": {"type": "string"},
"description": "CLI commands for this feature",
},
"api_classes": {
"type": "array",
"items": {"type": "string"},
"description": "Public API classes",
},
"artifacts": {
"type": "object",
"description": "Paths to artifacts",
"additionalProperties": {"type": "string"},
},
},
}
@@ -0,0 +1,355 @@
"""
Data models for the FDEP standardisation system.
All models use dataclasses for lightweight, stdlib-only implementation.
"""
import re
from dataclasses import dataclass, field
from enum import Enum
from pathlib import Path
from typing import Dict, List, Optional
class ArtifactType(Enum):
"""Types of required artifacts per feature."""
DOCS_CONCEPT = "docs_concept"
DOCS_FEATURE = "docs_feature"
DOCS_CLI = "docs_cli"
DOCS_SDK = "docs_sdk"
EXAMPLE_BASIC = "example_basic"
EXAMPLE_ADVANCED = "example_advanced"
MANIFEST = "manifest"
class ArtifactStatus(Enum):
"""Status of an artifact."""
PRESENT = "present"
MISSING = "missing"
DUPLICATE = "duplicate"
OUTDATED = "outdated"
class IssueType(Enum):
"""Types of standardisation issues."""
MISSING_ARTIFACT = "missing_artifact"
DUPLICATE_CONTENT = "duplicate_content"
NAMING_INCONSISTENCY = "naming_inconsistency"
DRIFT_SDK_DOCS = "drift_sdk_docs"
DRIFT_CLI_DOCS = "drift_cli_docs"
INVALID_FRONTMATTER = "invalid_frontmatter"
# Slug validation regex: lowercase alphanumeric + hyphens, 2-64 chars
SLUG_PATTERN = re.compile(r"^[a-z][a-z0-9-]{0,62}[a-z0-9]$|^[a-z]$")
# Singular to plural mappings for normalisation
SINGULAR_PLURAL_MAP = {
"agent": "agents",
"task": "tasks",
"tool": "tools",
"guardrail": "guardrails",
"handoff": "handoffs",
"workflow": "workflows",
"checkpoint": "checkpoints",
"hook": "hooks",
"skill": "skills",
"plugin": "plugins",
"session": "sessions",
"process": "processes",
}
# Legacy alias mappings
LEGACY_ALIASES = {
"rag": "knowledge",
"retrieval": "knowledge",
"llm": "models",
"model": "models",
"db": "databases",
"database": "databases",
}
@dataclass
class FeatureSlug:
"""
Represents a normalised feature slug.
Slug rules:
- Lowercase alphanumeric + hyphens only
- 1-64 characters
- Stable normalisation (singular/plural, legacy aliases)
"""
raw: str
normalised: str
is_valid: bool
validation_error: Optional[str] = None
@classmethod
def from_string(cls, value: str) -> "FeatureSlug":
"""Create a FeatureSlug from a raw string."""
raw = value.strip().lower()
# Replace underscores with hyphens
normalised = raw.replace("_", "-")
# Remove .py, .mdx extensions if present
for ext in (".py", ".mdx", ".md"):
if normalised.endswith(ext):
normalised = normalised[:-len(ext)]
# Apply legacy alias mapping
if normalised in LEGACY_ALIASES:
normalised = LEGACY_ALIASES[normalised]
# Apply singular to plural normalisation (prefer plural for collections)
if normalised in SINGULAR_PLURAL_MAP:
normalised = SINGULAR_PLURAL_MAP[normalised]
# Validate
is_valid = bool(SLUG_PATTERN.match(normalised))
validation_error = None
if not is_valid:
if not normalised:
validation_error = "Slug cannot be empty"
elif not normalised[0].isalpha():
validation_error = "Slug must start with a letter"
elif len(normalised) > 64:
validation_error = "Slug must be 64 characters or less"
else:
validation_error = "Slug must contain only lowercase letters, numbers, and hyphens"
return cls(raw=raw, normalised=normalised, is_valid=is_valid,
validation_error=validation_error)
@classmethod
def from_path(cls, path: Path, base_type: str = "unknown") -> "FeatureSlug":
"""
Derive a slug from a file or directory path.
Args:
path: Path to derive slug from
base_type: Type hint (sdk, cli, docs, examples)
"""
# Use the stem (filename without extension) or directory name
name = path.stem if path.is_file() else path.name
return cls.from_string(name)
def __str__(self) -> str:
return self.normalised
def __hash__(self) -> int:
return hash(self.normalised)
def __eq__(self, other) -> bool:
if isinstance(other, FeatureSlug):
return self.normalised == other.normalised
if isinstance(other, str):
return self.normalised == other.lower()
return False
@dataclass
class ArtifactPath:
"""Represents a path to a required artifact."""
artifact_type: ArtifactType
expected_path: Path
actual_path: Optional[Path] = None
status: ArtifactStatus = ArtifactStatus.MISSING
@property
def exists(self) -> bool:
return self.actual_path is not None and self.actual_path.exists()
@dataclass
class FeatureManifest:
"""
Manifest entry for a single feature.
This is the source of truth for what artifacts a feature should have.
"""
slug: FeatureSlug
name: str
description: str
status: str = "stable" # stable, beta, experimental, deprecated
min_version: Optional[str] = None
tags: List[str] = field(default_factory=list)
related_features: List[str] = field(default_factory=list)
sdk_module: Optional[str] = None
cli_commands: List[str] = field(default_factory=list)
api_classes: List[str] = field(default_factory=list)
artifacts: Dict[str, str] = field(default_factory=dict)
def to_dict(self) -> dict:
"""Convert to dictionary for YAML serialisation."""
return {
"slug": self.slug.normalised,
"name": self.name,
"description": self.description,
"status": self.status,
"min_version": self.min_version,
"tags": self.tags,
"related_features": self.related_features,
"sdk_module": self.sdk_module,
"cli_commands": self.cli_commands,
"api_classes": self.api_classes,
"artifacts": self.artifacts,
}
@classmethod
def from_dict(cls, data: dict) -> "FeatureManifest":
"""Create from dictionary."""
slug = FeatureSlug.from_string(data.get("slug", ""))
return cls(
slug=slug,
name=data.get("name", slug.normalised.replace("-", " ").title()),
description=data.get("description", ""),
status=data.get("status", "stable"),
min_version=data.get("min_version"),
tags=data.get("tags", []),
related_features=data.get("related_features", []),
sdk_module=data.get("sdk_module"),
cli_commands=data.get("cli_commands", []),
api_classes=data.get("api_classes", []),
artifacts=data.get("artifacts", {}),
)
@dataclass
class ValidationResult:
"""Result of validating a feature's artifacts."""
slug: FeatureSlug
is_valid: bool
missing_artifacts: List[ArtifactType] = field(default_factory=list)
present_artifacts: List[ArtifactPath] = field(default_factory=list)
issues: List[str] = field(default_factory=list)
@property
def missing_count(self) -> int:
return len(self.missing_artifacts)
@property
def present_count(self) -> int:
return len(self.present_artifacts)
@dataclass
class DuplicateCluster:
"""A cluster of duplicate or near-duplicate content."""
slug: FeatureSlug
pages: List[Path]
similarity_score: float # 0.0 to 1.0
issue_type: str # "same_slug", "title_similarity", "content_overlap"
recommendation: str # "merge", "keep_both", "review"
primary_page: Optional[Path] = None # Recommended canonical page
def __str__(self) -> str:
return f"DuplicateCluster({self.slug}, {len(self.pages)} pages, {self.issue_type})"
@dataclass
class DriftItem:
"""A single drift item (SDK export or CLI flag not in docs, or vice versa)."""
name: str
source: str # "sdk", "cli", "docs"
missing_in: str # Where it's missing
item_type: str # "class", "function", "flag", "command"
@dataclass
class DriftReport:
"""Report of drift between code and documentation."""
slug: FeatureSlug
sdk_drift: List[DriftItem] = field(default_factory=list)
cli_drift: List[DriftItem] = field(default_factory=list)
@property
def has_drift(self) -> bool:
return bool(self.sdk_drift or self.cli_drift)
@property
def total_drift_items(self) -> int:
return len(self.sdk_drift) + len(self.cli_drift)
@dataclass
class StandardiseIssue:
"""A single standardisation issue."""
issue_type: IssueType
slug: Optional[FeatureSlug]
path: Optional[Path]
message: str
severity: str = "warning" # error, warning, info
auto_fixable: bool = False
fix_action: Optional[str] = None
@dataclass
class StandardiseReport:
"""Complete standardisation report."""
timestamp: str
features_scanned: int
docs_pages: int
examples_count: int
issues: List[StandardiseIssue] = field(default_factory=list)
duplicates: List[DuplicateCluster] = field(default_factory=list)
missing_artifacts: Dict[str, List[ArtifactType]] = field(default_factory=dict)
drift_reports: List[DriftReport] = field(default_factory=list)
validation_results: List[ValidationResult] = field(default_factory=list)
@property
def total_issues(self) -> int:
return len(self.issues)
@property
def total_missing(self) -> int:
return sum(len(v) for v in self.missing_artifacts.values())
@property
def total_duplicates(self) -> int:
return len(self.duplicates)
@property
def has_issues(self) -> bool:
return self.total_issues > 0 or self.total_missing > 0 or self.total_duplicates > 0
def to_dict(self) -> dict:
"""Convert to dictionary for JSON serialisation."""
return {
"timestamp": self.timestamp,
"summary": {
"features_scanned": self.features_scanned,
"docs_pages": self.docs_pages,
"examples_count": self.examples_count,
"total_issues": self.total_issues,
"total_missing": self.total_missing,
"total_duplicates": self.total_duplicates,
},
"issues": [
{
"type": i.issue_type.value,
"slug": str(i.slug) if i.slug else None,
"path": str(i.path) if i.path else None,
"message": i.message,
"severity": i.severity,
"auto_fixable": i.auto_fixable,
}
for i in self.issues
],
"duplicates": [
{
"slug": str(d.slug),
"pages": [str(p) for p in d.pages],
"similarity": d.similarity_score,
"type": d.issue_type,
"recommendation": d.recommendation,
}
for d in self.duplicates
],
"missing_artifacts": {
slug: [a.value for a in artifacts]
for slug, artifacts in self.missing_artifacts.items()
},
}
@@ -0,0 +1,176 @@
"""
Report generators for the FDEP standardisation system.
Generates reports in:
- Text (terminal-friendly)
- JSON (machine-readable)
- Markdown (documentation-friendly)
"""
import json
from pathlib import Path
from .models import StandardiseReport
class ReportGenerator:
"""Generates standardisation reports in various formats."""
def generate(self, report: StandardiseReport, format: str = "text") -> str:
"""Generate a report in the specified format."""
if format == "text":
return self._generate_text(report)
elif format == "json":
return self._generate_json(report)
elif format == "markdown":
return self._generate_markdown(report)
else:
raise ValueError(f"Unknown format: {format}")
def _generate_text(self, report: StandardiseReport) -> str:
"""Generate a text report for terminal output."""
lines = []
# Header
lines.append("")
lines.append("🔍 PraisonAI Standardisation Check")
lines.append("══════════════════════════════════")
lines.append("")
# Summary
lines.append("📊 Summary")
lines.append("──────────")
lines.append(f"Features scanned: {report.features_scanned}")
lines.append(f"Docs pages: {report.docs_pages}")
lines.append(f"Examples: {report.examples_count}")
lines.append("")
if not report.has_issues:
lines.append("✅ No issues found!")
return "\n".join(lines)
lines.append(f"⚠️ Issues Found: {report.total_issues + report.total_missing + report.total_duplicates}")
lines.append("──────────────────────")
lines.append("")
# Duplicates
if report.duplicates:
lines.append(f"🔴 DUPLICATES ({len(report.duplicates)} clusters)")
for cluster in report.duplicates:
pages_str = ", ".join(self._short_path(p) for p in cluster.pages[:3])
if len(cluster.pages) > 3:
pages_str += f" (+{len(cluster.pages) - 3} more)"
lines.append(f" • {cluster.slug}: {len(cluster.pages)} pages ({cluster.issue_type})")
lines.append("")
# Missing artifacts
if report.missing_artifacts:
lines.append(f"🟡 MISSING ARTIFACTS ({len(report.missing_artifacts)} features)")
for slug, artifacts in list(report.missing_artifacts.items())[:10]:
artifact_names = [a.value.replace("_", " ") for a in artifacts]
lines.append(f" • {slug}: missing {', '.join(artifact_names[:3])}")
if len(artifact_names) > 3:
lines.append(f" (+{len(artifact_names) - 3} more)")
if len(report.missing_artifacts) > 10:
lines.append(f" ... and {len(report.missing_artifacts) - 10} more features")
lines.append("")
# Other issues
if report.issues:
naming_issues = [i for i in report.issues if "naming" in i.message.lower()]
if naming_issues:
lines.append(f"🟢 NAMING ISSUES ({len(naming_issues)})")
for issue in naming_issues[:5]:
lines.append(f" • {issue.message}")
lines.append("")
# Next actions
lines.append("📋 Next Actions")
lines.append("───────────────")
lines.append("1. Run `praisonai standardise report --format markdown` for details")
if report.duplicates:
lines.append("2. Run `praisonai standardise fix --feature <slug>` to consolidate")
if report.missing_artifacts:
lines.append("3. Run `praisonai standardise init --feature <slug>` to create missing")
lines.append("")
return "\n".join(lines)
def _generate_json(self, report: StandardiseReport) -> str:
"""Generate a JSON report."""
return json.dumps(report.to_dict(), indent=2)
def _generate_markdown(self, report: StandardiseReport) -> str:
"""Generate a Markdown report."""
lines = []
# Header
lines.append("# PraisonAI Standardisation Report")
lines.append("")
lines.append(f"Generated: {report.timestamp}")
lines.append("")
# Summary table
lines.append("## Summary")
lines.append("")
lines.append("| Metric | Value |")
lines.append("|--------|-------|")
lines.append(f"| Features scanned | {report.features_scanned} |")
lines.append(f"| Docs pages | {report.docs_pages} |")
lines.append(f"| Examples | {report.examples_count} |")
lines.append(f"| Total issues | {report.total_issues} |")
lines.append(f"| Missing artifacts | {report.total_missing} |")
lines.append(f"| Duplicate clusters | {report.total_duplicates} |")
lines.append("")
# Duplicates section
if report.duplicates:
lines.append("## Duplicate Clusters")
lines.append("")
for cluster in report.duplicates:
lines.append(f"### {cluster.slug}")
lines.append("")
lines.append(f"- **Type**: {cluster.issue_type}")
lines.append(f"- **Similarity**: {cluster.similarity_score:.0%}")
lines.append(f"- **Recommendation**: {cluster.recommendation}")
lines.append("")
lines.append("**Pages:**")
for page in cluster.pages:
lines.append(f"- `{page}`")
lines.append("")
# Missing artifacts section
if report.missing_artifacts:
lines.append("## Missing Artifacts")
lines.append("")
lines.append("| Feature | Missing |")
lines.append("|---------|---------|")
for slug, artifacts in report.missing_artifacts.items():
artifact_names = ", ".join(a.value for a in artifacts)
lines.append(f"| {slug} | {artifact_names} |")
lines.append("")
# Issues section
if report.issues:
lines.append("## Other Issues")
lines.append("")
for issue in report.issues:
severity_icon = {"error": "🔴", "warning": "🟡", "info": "🔵"}.get(issue.severity, "⚪")
lines.append(f"- {severity_icon} **{issue.issue_type.value}**: {issue.message}")
lines.append("")
return "\n".join(lines)
def _short_path(self, path: Path) -> str:
"""Get a shortened path for display."""
parts = path.parts
if len(parts) > 3:
return "/".join(parts[-3:])
return str(path)
def save(self, report: StandardiseReport, output_path: Path,
format: str = "text") -> None:
"""Save a report to a file."""
content = self.generate(report, format)
output_path.parent.mkdir(parents=True, exist_ok=True)
output_path.write_text(content, encoding="utf-8")
@@ -0,0 +1,399 @@
"""
Template generators for the FDEP standardisation system.
Generates:
- Example files (basic and advanced)
- Docs pages (concept, feature, cli, sdk)
"""
from pathlib import Path
from typing import Optional
from .models import ArtifactType, FeatureSlug
class TemplateGenerator:
"""Generates template files for features."""
def generate(self, slug: FeatureSlug, artifact_type: ArtifactType,
output_path: Optional[Path] = None) -> str:
"""Generate template content for an artifact type."""
if artifact_type == ArtifactType.EXAMPLE_BASIC:
return self._generate_basic_example(slug)
elif artifact_type == ArtifactType.EXAMPLE_ADVANCED:
return self._generate_advanced_example(slug)
elif artifact_type == ArtifactType.DOCS_CONCEPT:
return self._generate_concept_doc(slug)
elif artifact_type == ArtifactType.DOCS_FEATURE:
return self._generate_feature_doc(slug)
elif artifact_type == ArtifactType.DOCS_CLI:
return self._generate_cli_doc(slug)
elif artifact_type == ArtifactType.DOCS_SDK:
return self._generate_sdk_doc(slug)
else:
raise ValueError(f"Unknown artifact type: {artifact_type}")
def _generate_basic_example(self, slug: FeatureSlug) -> str:
"""Generate a basic example template."""
name = slug.normalised.replace("-", " ").title()
slug_str = slug.normalised
return f'''"""
{name} - Basic Example
Demonstrates minimal usage of {slug_str} with a single agent.
Expected Output:
Agent response demonstrating {slug_str} functionality.
"""
from praisonaiagents import Agent
# Basic {slug_str} usage
agent = Agent(
instructions="You are a helpful assistant",
# {slug_str}=... # Configure {slug_str} here
)
# Run the agent
result = agent.start("Hello! Demonstrate {slug_str} functionality.")
print(result)
'''
def _generate_advanced_example(self, slug: FeatureSlug) -> str:
"""Generate an advanced example template."""
name = slug.normalised.replace("-", " ").title()
slug_str = slug.normalised
return f'''"""
{name} - Advanced Example
Demonstrates advanced {slug_str} patterns including:
- Multi-agent configuration
- Custom callbacks/handlers
- Error handling
- All available options
Expected Output:
Comprehensive demonstration of {slug_str} with multiple agents.
"""
from praisonaiagents import Agent, Task, PraisonAIAgents
# ============================================================
# Section 1: Custom Configuration
# ============================================================
# Define custom {slug_str} configuration
# config = ...
# ============================================================
# Section 2: Multi-Agent Setup
# ============================================================
agent1 = Agent(
name="Agent1",
instructions="You are the first agent",
# {slug_str}=config,
)
agent2 = Agent(
name="Agent2",
instructions="You are the second agent",
# {slug_str}=config,
)
# ============================================================
# Section 3: Task Definition
# ============================================================
task = Task(
description="Demonstrate {slug_str} with multiple agents",
agent=agent1,
)
# ============================================================
# Section 4: Execution
# ============================================================
agents = PraisonAIAgents(agents=[agent1, agent2], tasks=[task])
result = agents.start()
print(result)
# ============================================================
# Section 5: All Options Reference
# ============================================================
"""
{name} Options:
──────────────────
| Option | Type | Default | Description |
|-----------------|----------|---------|----------------------|
| option1 | str | None | Description here |
| option2 | bool | False | Description here |
"""
'''
def _generate_concept_doc(self, slug: FeatureSlug) -> str:
"""Generate a concept documentation template."""
name = slug.normalised.replace("-", " ").title()
slug_str = slug.normalised
return f'''---
title: "{name}"
description: "Understanding {slug_str} in PraisonAI"
icon: "lightbulb"
---
## Overview
{name} provides [brief description of what this feature does and why it exists].
```mermaid
graph LR
A[Input] --> B[{name}]
B --> C[Output]
```
## When to Use
<CardGroup cols={{2}}>
<Card title="Use When" icon="check">
- Use case 1
- Use case 2
</Card>
<Card title="Don't Use When" icon="xmark">
- Anti-pattern 1
- Anti-pattern 2
</Card>
</CardGroup>
## How It Works
[Brief explanation of the mechanism, 3-5 sentences max.]
## Related Features
- [Related Feature 1](/docs/concepts/related1)
- [Related Feature 2](/docs/concepts/related2)
'''
def _generate_feature_doc(self, slug: FeatureSlug) -> str:
"""Generate a feature documentation template."""
name = slug.normalised.replace("-", " ").title()
slug_str = slug.normalised
return f'''---
title: "{name}"
description: "How to use {slug_str} in PraisonAI"
icon: "code"
---
## Quick Start
```python
from praisonaiagents import Agent
agent = Agent(
instructions="You are a helpful assistant",
# {slug_str}=...
)
result = agent.start("Hello!")
```
## Basic Usage
<Steps>
<Step title="Step 1: Configure">
Configure {slug_str} for your agent.
```python
# Configuration code
```
</Step>
<Step title="Step 2: Run">
Run the agent with {slug_str} enabled.
</Step>
</Steps>
## Advanced Patterns
<Tabs>
<Tab title="Pattern 1">
```python
# Pattern 1 code
```
</Tab>
<Tab title="Pattern 2">
```python
# Pattern 2 code
```
</Tab>
</Tabs>
## All Options
<ParamField body="option1" type="string" required>
Description of option1.
</ParamField>
<ParamField body="option2" type="boolean" default="false">
Description of option2.
</ParamField>
## See Also
- [CLI Reference](/docs/cli/{slug_str})
- [API Reference](/docs/sdk/praisonaiagents/{slug_str}/{slug_str})
'''
def _generate_cli_doc(self, slug: FeatureSlug) -> str:
"""Generate a CLI documentation template."""
name = slug.normalised.replace("-", " ").title()
slug_str = slug.normalised
return f'''---
title: "{name}"
description: "CLI commands for {slug_str}"
icon: "terminal"
---
## Quick Start
```bash
praisonai "Your prompt" --{slug_str}
```
## Commands
### `--{slug_str}`
Enable {slug_str} functionality.
```bash
praisonai "Your prompt" --{slug_str}
```
**Expected Output:**
```
[Example output here]
```
## Environment Variables
| Variable | Description | Default |
|----------|-------------|---------|
| `PRAISONAI_{slug_str.upper().replace("-", "_")}` | Configure {slug_str} | None |
## Examples
### Basic
```bash
praisonai "Hello" --{slug_str}
```
### With Other Flags
```bash
praisonai "Hello" --{slug_str} --verbose
```
## Troubleshooting
<Accordion title="Common Issue 1">
Solution for common issue 1.
</Accordion>
'''
def _generate_sdk_doc(self, slug: FeatureSlug) -> str:
"""Generate an SDK documentation template."""
name = slug.normalised.replace("-", " ").title()
slug_str = slug.normalised
class_name = "".join(word.title() for word in slug_str.split("-"))
return f'''---
title: "{name} Module"
description: "API reference for {slug_str}"
icon: "code"
---
# {name}
{name} module provides [brief description].
## Quick Start
```python
from praisonaiagents import {class_name}
# Basic usage
instance = {class_name}()
```
## Classes
### `{class_name}`
Main class for {slug_str} functionality.
#### Constructor
```python
{class_name}(
param1: str,
param2: bool = False
)
```
#### Parameters
<ParamField body="param1" type="string" required>
Description of param1.
</ParamField>
<ParamField body="param2" type="boolean" default="false">
Description of param2.
</ParamField>
#### Methods
##### `method_name()`
Description of the method.
```python
result = instance.method_name(args)
```
**Returns:** `ReturnType` - Description of return value.
## Type Definitions
```python
# Type aliases used in this module
```
'''
def get_expected_path(self, slug: FeatureSlug, artifact_type: ArtifactType,
docs_root: Optional[Path] = None,
examples_root: Optional[Path] = None) -> Optional[Path]:
"""Get the expected path for an artifact."""
slug_str = slug.normalised
if artifact_type == ArtifactType.EXAMPLE_BASIC:
if examples_root:
return examples_root / slug_str / f"{slug_str}-basic.py"
elif artifact_type == ArtifactType.EXAMPLE_ADVANCED:
if examples_root:
return examples_root / slug_str / f"{slug_str}-advanced.py"
elif artifact_type == ArtifactType.DOCS_CONCEPT:
if docs_root:
return docs_root / "concepts" / f"{slug_str}.mdx"
elif artifact_type == ArtifactType.DOCS_FEATURE:
if docs_root:
return docs_root / "features" / f"{slug_str}.mdx"
elif artifact_type == ArtifactType.DOCS_CLI:
if docs_root:
return docs_root / "cli" / f"{slug_str}.mdx"
elif artifact_type == ArtifactType.DOCS_SDK:
if docs_root:
return docs_root / "sdk" / "praisonaiagents" / slug_str / f"{slug_str}.mdx"
return None
@@ -0,0 +1,283 @@
"""
Artifact validation for the FDEP standardisation system.
Validates that features have all required artifacts:
- docs/concepts/{slug}.mdx
- docs/features/{slug}.mdx
- docs/cli/{slug}.mdx
- docs/sdk/praisonaiagents/{module}/{slug}.mdx
- examples/python/{slug}/{slug}-basic.py
- examples/python/{slug}/{slug}-advanced.py
"""
from pathlib import Path
from typing import Dict, List, Optional
from .config import StandardiseConfig
from .discovery import FeatureDiscovery
from .models import (
ArtifactPath,
ArtifactStatus,
ArtifactType,
FeatureSlug,
ValidationResult,
)
class ArtifactValidator:
"""Validates that features have all required artifacts."""
def __init__(self, config: StandardiseConfig, discovery: FeatureDiscovery):
self.config = config
self.discovery = discovery
def validate_feature(self, slug: FeatureSlug) -> ValidationResult:
"""Validate all artifacts for a single feature."""
missing = []
present = []
issues = []
# Check each required artifact type
for artifact_type in ArtifactType:
if artifact_type == ArtifactType.MANIFEST:
continue # Manifest is optional
artifact_path = self._get_expected_path(slug, artifact_type)
if artifact_path is None:
continue
if artifact_path.exists:
present.append(ArtifactPath(
artifact_type=artifact_type,
expected_path=artifact_path.expected_path,
actual_path=artifact_path.actual_path,
status=ArtifactStatus.PRESENT,
))
else:
missing.append(artifact_type)
present.append(ArtifactPath(
artifact_type=artifact_type,
expected_path=artifact_path.expected_path,
actual_path=None,
status=ArtifactStatus.MISSING,
))
is_valid = len(missing) == 0
return ValidationResult(
slug=slug,
is_valid=is_valid,
missing_artifacts=missing,
present_artifacts=present,
issues=issues,
)
def validate_all(self) -> Dict[str, ValidationResult]:
"""Validate all discovered features."""
results = {}
all_features = self.discovery.get_all_features()
for slug in all_features:
if self.config.feature_filter:
if slug.normalised != self.config.feature_filter:
continue
results[slug.normalised] = self.validate_feature(slug)
return results
def get_missing_artifacts(self) -> Dict[str, List[ArtifactType]]:
"""Get all missing artifacts across all features."""
missing = {}
results = self.validate_all()
for slug_str, result in results.items():
if result.missing_artifacts:
missing[slug_str] = result.missing_artifacts
return missing
def _get_expected_path(self, slug: FeatureSlug,
artifact_type: ArtifactType) -> Optional[ArtifactPath]:
"""Get the expected path for an artifact."""
slug_str = slug.normalised
if artifact_type == ArtifactType.DOCS_CONCEPT:
if not self.config.docs_root:
return None
expected = self.config.docs_root / "concepts" / f"{slug_str}.mdx"
actual = self._find_actual_path(expected, slug, "concepts")
return ArtifactPath(
artifact_type=artifact_type,
expected_path=expected,
actual_path=actual,
status=ArtifactStatus.PRESENT if actual else ArtifactStatus.MISSING,
)
elif artifact_type == ArtifactType.DOCS_FEATURE:
if not self.config.docs_root:
return None
expected = self.config.docs_root / "features" / f"{slug_str}.mdx"
actual = self._find_actual_path(expected, slug, "features")
return ArtifactPath(
artifact_type=artifact_type,
expected_path=expected,
actual_path=actual,
status=ArtifactStatus.PRESENT if actual else ArtifactStatus.MISSING,
)
elif artifact_type == ArtifactType.DOCS_CLI:
if not self.config.docs_root:
return None
expected = self.config.docs_root / "cli" / f"{slug_str}.mdx"
actual = self._find_actual_path(expected, slug, "cli")
return ArtifactPath(
artifact_type=artifact_type,
expected_path=expected,
actual_path=actual,
status=ArtifactStatus.PRESENT if actual else ArtifactStatus.MISSING,
)
elif artifact_type == ArtifactType.DOCS_SDK:
if not self.config.docs_root:
return None
# SDK docs are in docs/sdk/praisonaiagents/{module}/
sdk_docs = self.config.docs_root / "sdk" / "praisonaiagents"
expected = sdk_docs / slug_str / f"{slug_str}.mdx"
actual = self._find_sdk_docs_path(slug, sdk_docs)
return ArtifactPath(
artifact_type=artifact_type,
expected_path=expected,
actual_path=actual,
status=ArtifactStatus.PRESENT if actual else ArtifactStatus.MISSING,
)
elif artifact_type == ArtifactType.EXAMPLE_BASIC:
if not self.config.examples_root:
return None
expected = self.config.examples_root / slug_str / f"{slug_str}-basic.py"
actual = self._find_example_path(slug, "basic")
return ArtifactPath(
artifact_type=artifact_type,
expected_path=expected,
actual_path=actual,
status=ArtifactStatus.PRESENT if actual else ArtifactStatus.MISSING,
)
elif artifact_type == ArtifactType.EXAMPLE_ADVANCED:
if not self.config.examples_root:
return None
expected = self.config.examples_root / slug_str / f"{slug_str}-advanced.py"
actual = self._find_example_path(slug, "advanced")
return ArtifactPath(
artifact_type=artifact_type,
expected_path=expected,
actual_path=actual,
status=ArtifactStatus.PRESENT if actual else ArtifactStatus.MISSING,
)
return None
def _find_actual_path(self, expected: Path, slug: FeatureSlug,
doc_type: str) -> Optional[Path]:
"""Find the actual path, checking variants."""
if expected.exists():
return expected
if not self.config.docs_root:
return None
# Check variants
slug_str = slug.normalised
variants = [
slug_str,
slug_str.replace("-", "_"),
slug_str.replace("-", ""),
]
# Add singular/plural variants
from .models import SINGULAR_PLURAL_MAP
for singular, plural in SINGULAR_PLURAL_MAP.items():
if slug_str == plural:
variants.append(singular)
elif slug_str == singular:
variants.append(plural)
doc_dir = self.config.docs_root / doc_type
if not doc_dir.exists():
return None
for variant in variants:
path = doc_dir / f"{variant}.mdx"
if path.exists():
return path
return None
def _find_sdk_docs_path(self, slug: FeatureSlug,
sdk_docs: Path) -> Optional[Path]:
"""Find SDK docs path, checking module directories."""
if not sdk_docs.exists():
return None
slug_str = slug.normalised
variants = [
slug_str,
slug_str.replace("-", "_"),
]
# Check for module directory
for variant in variants:
module_dir = sdk_docs / variant
if module_dir.exists() and module_dir.is_dir():
# Look for any .mdx file in the module directory
for mdx_file in module_dir.glob("*.mdx"):
return mdx_file
return None
def _find_example_path(self, slug: FeatureSlug,
example_type: str) -> Optional[Path]:
"""Find example path, checking variants."""
if not self.config.examples_root:
return None
slug_str = slug.normalised
variants = [
slug_str,
slug_str.replace("-", "_"),
]
for variant in variants:
example_dir = self.config.examples_root / variant
if not example_dir.exists():
continue
# Check for basic/advanced files with various naming patterns
patterns = [
f"{variant}-{example_type}.py",
f"{variant}_{example_type}.py",
f"{example_type}.py",
f"{example_type}_{variant}.py",
]
for pattern in patterns:
path = example_dir / pattern
if path.exists():
return path
# If looking for basic, any simple example file counts
if example_type == "basic":
for py_file in example_dir.glob("*.py"):
if "advanced" not in py_file.name.lower():
return py_file
# If looking for advanced, any advanced/complex example counts
if example_type == "advanced":
for py_file in example_dir.glob("*.py"):
name_lower = py_file.name.lower()
if any(x in name_lower for x in ["advanced", "complex", "multi"]):
return py_file
return None
+1 -1
View File
@@ -1 +1 @@
__version__ = "3.7.1"
__version__ = "3.7.2"
+47
View File
@@ -0,0 +1,47 @@
#!/bin/bash
# CI script for standardisation checks
# Usage: ./scripts/standardise_check.sh [--strict]
#
# Exit codes:
# 0 - No issues found
# 1 - Issues found
# 2 - Error running check
set -e
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
# Check if strict mode
STRICT_MODE=false
if [[ "$1" == "--strict" ]]; then
STRICT_MODE=true
fi
echo "🔍 Running PraisonAI Standardisation Check..."
echo ""
# Run the check
cd "$PROJECT_ROOT"
python -m praisonai.cli.commands.standardise check --ci --path "$PROJECT_ROOT"
EXIT_CODE=$?
if [[ $EXIT_CODE -eq 0 ]]; then
echo ""
echo "✅ Standardisation check passed!"
exit 0
elif [[ $EXIT_CODE -eq 1 ]]; then
echo ""
echo "⚠️ Standardisation issues found."
if [[ "$STRICT_MODE" == "true" ]]; then
echo "Failing in strict mode."
exit 1
else
echo "Run 'praisonai standardise report' for details."
exit 0 # Non-strict mode: warn but don't fail
fi
else
echo ""
echo "❌ Error running standardisation check."
exit 2
fi
@@ -0,0 +1 @@
"""Tests for the standardise module."""
@@ -0,0 +1,217 @@
"""Tests for standardise discovery."""
from praisonai.standardise.config import StandardiseConfig
from praisonai.standardise.discovery import FeatureDiscovery
from praisonai.standardise.models import FeatureSlug
class TestFeatureDiscovery:
"""Tests for FeatureDiscovery class."""
def test_discover_sdk_features(self, tmp_path):
"""Test discovering features from SDK modules."""
# Create mock SDK structure
sdk_root = tmp_path / "praisonaiagents"
sdk_root.mkdir()
(sdk_root / "guardrails").mkdir()
(sdk_root / "memory").mkdir()
(sdk_root / "knowledge").mkdir()
(sdk_root / "__pycache__").mkdir() # Should be skipped
config = StandardiseConfig(sdk_root=sdk_root)
discovery = FeatureDiscovery(config)
features = discovery.discover_sdk_features()
assert len(features) == 3
slugs = {str(f) for f in features}
assert "guardrails" in slugs
assert "memory" in slugs
assert "knowledge" in slugs
def test_discover_cli_features(self, tmp_path):
"""Test discovering features from CLI files."""
# Create mock CLI structure
cli_root = tmp_path / "cli"
cli_root.mkdir()
features_dir = cli_root / "features"
features_dir.mkdir()
(features_dir / "guardrail.py").touch()
(features_dir / "memory.py").touch()
(features_dir / "__init__.py").touch() # Should be skipped
config = StandardiseConfig(cli_root=cli_root)
discovery = FeatureDiscovery(config)
features = discovery.discover_cli_features()
assert len(features) == 2
slugs = {str(f) for f in features}
assert "guardrails" in slugs # Normalised from guardrail
assert "memory" in slugs
def test_discover_docs_features(self, tmp_path):
"""Test discovering features from docs pages."""
# Create mock docs structure
docs_root = tmp_path / "docs"
docs_root.mkdir()
concepts = docs_root / "concepts"
concepts.mkdir()
(concepts / "guardrails.mdx").touch()
(concepts / "memory.mdx").touch()
features = docs_root / "features"
features.mkdir()
(features / "knowledge.mdx").touch()
config = StandardiseConfig(docs_root=docs_root)
discovery = FeatureDiscovery(config)
features_found = discovery.discover_docs_features()
slugs = {str(f) for f in features_found}
assert "guardrails" in slugs
assert "memory" in slugs
assert "knowledge" in slugs
def test_discover_example_features(self, tmp_path):
"""Test discovering features from examples."""
# Create mock examples structure
examples_root = tmp_path / "examples"
examples_root.mkdir()
(examples_root / "guardrails").mkdir()
(examples_root / "memory").mkdir()
(examples_root / "__pycache__").mkdir() # Should be skipped
config = StandardiseConfig(examples_root=examples_root)
discovery = FeatureDiscovery(config)
features = discovery.discover_example_features()
assert len(features) == 2
slugs = {str(f) for f in features}
assert "guardrails" in slugs
assert "memory" in slugs
def test_get_all_features(self, tmp_path):
"""Test getting union of all features."""
# Create mock structure with overlapping features
sdk_root = tmp_path / "sdk"
sdk_root.mkdir()
(sdk_root / "guardrails").mkdir()
(sdk_root / "memory").mkdir()
docs_root = tmp_path / "docs"
docs_root.mkdir()
concepts = docs_root / "concepts"
concepts.mkdir()
(concepts / "guardrails.mdx").touch()
(concepts / "knowledge.mdx").touch()
config = StandardiseConfig(sdk_root=sdk_root, docs_root=docs_root)
discovery = FeatureDiscovery(config)
all_features = discovery.get_all_features()
slugs = {str(f) for f in all_features}
assert "guardrails" in slugs
assert "memory" in slugs
assert "knowledge" in slugs
def test_get_feature_sources(self, tmp_path):
"""Test getting sources for a specific feature."""
# Create mock structure with explicit cli_root that doesn't have guardrails
sdk_root = tmp_path / "sdk"
sdk_root.mkdir()
(sdk_root / "guardrails").mkdir()
docs_root = tmp_path / "docs"
docs_root.mkdir()
concepts = docs_root / "concepts"
concepts.mkdir()
(concepts / "guardrails.mdx").touch()
# Create empty cli root to ensure no CLI features found
cli_root = tmp_path / "cli"
cli_root.mkdir()
(cli_root / "features").mkdir()
config = StandardiseConfig(sdk_root=sdk_root, docs_root=docs_root, cli_root=cli_root)
discovery = FeatureDiscovery(config)
slug = FeatureSlug.from_string("guardrails")
sources = discovery.get_feature_sources(slug)
assert sources["sdk"] is True
assert sources["docs"] is True
assert sources["cli"] is False
assert sources["examples"] is False
def test_find_docs_pages(self, tmp_path):
"""Test finding all docs pages for a feature."""
# Create mock docs with multiple pages for same feature
docs_root = tmp_path / "docs"
docs_root.mkdir()
concepts = docs_root / "concepts"
concepts.mkdir()
(concepts / "guardrails.mdx").touch()
features = docs_root / "features"
features.mkdir()
(features / "guardrails.mdx").touch()
cli = docs_root / "cli"
cli.mkdir()
(cli / "guardrail.mdx").touch() # Singular variant
config = StandardiseConfig(docs_root=docs_root)
discovery = FeatureDiscovery(config)
slug = FeatureSlug.from_string("guardrails")
pages = discovery.find_docs_pages(slug)
assert len(pages) >= 2 # At least concepts and features
def test_find_examples(self, tmp_path):
"""Test finding example files for a feature."""
# Create mock examples
examples_root = tmp_path / "examples"
examples_root.mkdir()
guardrails_dir = examples_root / "guardrails"
guardrails_dir.mkdir()
(guardrails_dir / "guardrails-basic.py").touch()
(guardrails_dir / "guardrails-advanced.py").touch()
config = StandardiseConfig(examples_root=examples_root)
discovery = FeatureDiscovery(config)
slug = FeatureSlug.from_string("guardrails")
examples = discovery.find_examples(slug)
assert len(examples) == 2
def test_caching(self, tmp_path):
"""Test that discovery results are cached."""
sdk_root = tmp_path / "sdk"
sdk_root.mkdir()
(sdk_root / "guardrails").mkdir()
config = StandardiseConfig(sdk_root=sdk_root)
discovery = FeatureDiscovery(config)
# First call
features1 = discovery.discover_sdk_features()
# Add another directory
(sdk_root / "memory").mkdir()
# Second call should return cached result
features2 = discovery.discover_sdk_features()
assert features1 is features2 # Same object (cached)
@@ -0,0 +1,210 @@
"""Tests for standardise engine."""
from praisonai.standardise.config import StandardiseConfig
from praisonai.standardise.engine import StandardiseEngine
class TestStandardiseEngine:
"""Tests for StandardiseEngine class."""
def test_check_returns_report(self, tmp_path):
"""Test that check returns a valid report."""
# Create minimal structure
sdk_root = tmp_path / "sdk"
sdk_root.mkdir()
(sdk_root / "guardrails").mkdir()
config = StandardiseConfig(
project_root=tmp_path,
sdk_root=sdk_root,
)
engine = StandardiseEngine(config)
report = engine.check()
assert report.timestamp
assert report.features_scanned >= 1
def test_report_text_format(self, tmp_path):
"""Test text report generation."""
config = StandardiseConfig(project_root=tmp_path)
engine = StandardiseEngine(config)
output = engine.report(format="text")
assert "Standardisation" in output
assert "Summary" in output
def test_report_json_format(self, tmp_path):
"""Test JSON report generation."""
config = StandardiseConfig(project_root=tmp_path)
engine = StandardiseEngine(config)
output = engine.report(format="json")
import json
data = json.loads(output)
assert "timestamp" in data
assert "summary" in data
def test_fix_dry_run(self, tmp_path):
"""Test fix in dry-run mode."""
sdk_root = tmp_path / "sdk"
sdk_root.mkdir()
(sdk_root / "guardrails").mkdir()
docs_root = tmp_path / "docs"
docs_root.mkdir()
config = StandardiseConfig(
project_root=tmp_path,
sdk_root=sdk_root,
docs_root=docs_root,
dry_run=True,
)
engine = StandardiseEngine(config)
actions = engine.fix(apply=False)
# Should have planned actions but no applied
assert len(actions["applied"]) == 0
def test_fix_apply(self, tmp_path):
"""Test fix with apply."""
sdk_root = tmp_path / "sdk"
sdk_root.mkdir()
(sdk_root / "guardrails").mkdir()
docs_root = tmp_path / "docs"
docs_root.mkdir()
(docs_root / "concepts").mkdir()
(docs_root / "features").mkdir()
(docs_root / "cli").mkdir()
examples_root = tmp_path / "examples"
examples_root.mkdir()
config = StandardiseConfig(
project_root=tmp_path,
sdk_root=sdk_root,
docs_root=docs_root,
examples_root=examples_root,
dry_run=False,
backup=False,
)
engine = StandardiseEngine(config)
actions = engine.fix(feature="guardrails", apply=True)
# Should have applied some actions
assert len(actions["applied"]) > 0
def test_init_feature(self, tmp_path):
"""Test initialising a new feature."""
docs_root = tmp_path / "docs"
docs_root.mkdir()
(docs_root / "concepts").mkdir()
(docs_root / "features").mkdir()
(docs_root / "cli").mkdir()
examples_root = tmp_path / "examples"
examples_root.mkdir()
config = StandardiseConfig(
project_root=tmp_path,
docs_root=docs_root,
examples_root=examples_root,
dry_run=False,
)
engine = StandardiseEngine(config)
created = engine.init("my-new-feature")
# Should create multiple artifacts
assert len(created) > 0
assert "docs_concept" in created or "docs_feature" in created
def test_init_invalid_slug(self, tmp_path):
"""Test init with invalid slug raises error."""
config = StandardiseConfig(project_root=tmp_path)
engine = StandardiseEngine(config)
try:
engine.init("123invalid")
assert False, "Should have raised ValueError"
except ValueError as e:
assert "letter" in str(e).lower()
def test_get_exit_code_no_issues(self, tmp_path):
"""Test exit code when no issues."""
config = StandardiseConfig(project_root=tmp_path)
engine = StandardiseEngine(config)
report = engine.check()
# With empty project, might have no issues
exit_code = engine.get_exit_code(report)
assert exit_code in [0, 1] # Either no issues or issues found
def test_backup_creation(self, tmp_path):
"""Test that backups are created."""
docs_root = tmp_path / "docs"
docs_root.mkdir()
concepts = docs_root / "concepts"
concepts.mkdir()
# Create existing file
existing = concepts / "guardrails.mdx"
existing.write_text("existing content")
config = StandardiseConfig(
project_root=tmp_path,
docs_root=docs_root,
dry_run=False,
backup=True,
)
engine = StandardiseEngine(config)
# Trigger backup
backup_path = engine._backup_file(existing)
assert backup_path.exists()
assert "guardrails" in backup_path.name
class TestStandardiseConfig:
"""Tests for StandardiseConfig class."""
def test_default_values(self):
"""Test default configuration values."""
config = StandardiseConfig()
assert config.dry_run is True
assert config.backup is True
assert config.force is False
assert config.scope == "all"
assert config.dedupe_mode == "prompt"
def test_auto_detect_paths(self, tmp_path):
"""Test auto-detection of paths."""
# Create structure
(tmp_path / ".praison").mkdir()
docs = tmp_path / "docs"
docs.mkdir()
examples = tmp_path / "examples" / "python"
examples.mkdir(parents=True)
config = StandardiseConfig(project_root=tmp_path)
assert config.docs_root == docs
assert config.examples_root == examples
def test_from_env(self, monkeypatch):
"""Test loading config from environment."""
monkeypatch.setenv("PRAISON_DRY_RUN", "false")
monkeypatch.setenv("CI", "true")
config = StandardiseConfig.from_env()
assert config.dry_run is False
assert config.ci_mode is True
@@ -0,0 +1,168 @@
"""Tests for standardise models."""
from pathlib import Path
from praisonai.standardise.models import (
FeatureSlug,
ArtifactType,
ArtifactStatus,
SINGULAR_PLURAL_MAP,
LEGACY_ALIASES,
)
class TestFeatureSlug:
"""Tests for FeatureSlug class."""
def test_from_string_simple(self):
"""Test simple slug creation."""
slug = FeatureSlug.from_string("guardrails")
assert slug.normalised == "guardrails"
assert slug.is_valid
assert slug.validation_error is None
def test_from_string_with_underscores(self):
"""Test slug with underscores converts to hyphens."""
slug = FeatureSlug.from_string("my_feature")
assert slug.normalised == "my-feature"
assert slug.is_valid
def test_from_string_uppercase(self):
"""Test uppercase is converted to lowercase."""
slug = FeatureSlug.from_string("MyFeature")
assert slug.normalised == "myfeature"
assert slug.is_valid
def test_from_string_with_extension(self):
"""Test file extensions are stripped."""
slug = FeatureSlug.from_string("guardrails.py")
assert slug.normalised == "guardrails"
slug = FeatureSlug.from_string("guardrails.mdx")
assert slug.normalised == "guardrails"
def test_singular_to_plural_normalisation(self):
"""Test singular forms are normalised to plural."""
slug = FeatureSlug.from_string("guardrail")
assert slug.normalised == "guardrails"
slug = FeatureSlug.from_string("agent")
assert slug.normalised == "agents"
slug = FeatureSlug.from_string("task")
assert slug.normalised == "tasks"
def test_legacy_alias_normalisation(self):
"""Test legacy aliases are normalised."""
slug = FeatureSlug.from_string("rag")
assert slug.normalised == "knowledge"
slug = FeatureSlug.from_string("retrieval")
assert slug.normalised == "knowledge"
def test_invalid_slug_empty(self):
"""Test empty slug is invalid."""
slug = FeatureSlug.from_string("")
assert not slug.is_valid
assert "empty" in slug.validation_error.lower()
def test_invalid_slug_starts_with_number(self):
"""Test slug starting with number is invalid."""
slug = FeatureSlug.from_string("123feature")
assert not slug.is_valid
assert "letter" in slug.validation_error.lower()
def test_invalid_slug_special_chars(self):
"""Test slug with special characters is invalid."""
slug = FeatureSlug.from_string("my@feature")
assert not slug.is_valid
def test_from_path_file(self):
"""Test slug from file path."""
path = Path("/some/path/guardrails.py")
slug = FeatureSlug.from_path(path, "sdk")
assert slug.normalised == "guardrails"
def test_from_path_directory(self):
"""Test slug from directory path."""
path = Path("/some/path/guardrails")
slug = FeatureSlug.from_path(path, "sdk")
assert slug.normalised == "guardrails"
def test_equality(self):
"""Test slug equality."""
slug1 = FeatureSlug.from_string("guardrails")
slug2 = FeatureSlug.from_string("guardrail") # Normalises to guardrails
assert slug1 == slug2
slug3 = FeatureSlug.from_string("memory")
assert slug1 != slug3
def test_equality_with_string(self):
"""Test slug equality with string."""
slug = FeatureSlug.from_string("guardrails")
assert slug == "guardrails"
assert slug == "GUARDRAILS" # Case insensitive
def test_hash(self):
"""Test slug hashing for use in sets/dicts."""
slug1 = FeatureSlug.from_string("guardrails")
slug2 = FeatureSlug.from_string("guardrail")
# Should have same hash since they normalise to same value
assert hash(slug1) == hash(slug2)
# Can be used in sets
slug_set = {slug1, slug2}
assert len(slug_set) == 1
def test_str(self):
"""Test string representation."""
slug = FeatureSlug.from_string("guardrails")
assert str(slug) == "guardrails"
class TestArtifactType:
"""Tests for ArtifactType enum."""
def test_all_types_exist(self):
"""Test all required artifact types exist."""
assert ArtifactType.DOCS_CONCEPT
assert ArtifactType.DOCS_FEATURE
assert ArtifactType.DOCS_CLI
assert ArtifactType.DOCS_SDK
assert ArtifactType.EXAMPLE_BASIC
assert ArtifactType.EXAMPLE_ADVANCED
assert ArtifactType.MANIFEST
def test_values(self):
"""Test artifact type values."""
assert ArtifactType.DOCS_CONCEPT.value == "docs_concept"
assert ArtifactType.EXAMPLE_BASIC.value == "example_basic"
class TestArtifactStatus:
"""Tests for ArtifactStatus enum."""
def test_all_statuses_exist(self):
"""Test all statuses exist."""
assert ArtifactStatus.PRESENT
assert ArtifactStatus.MISSING
assert ArtifactStatus.DUPLICATE
assert ArtifactStatus.OUTDATED
class TestMappings:
"""Tests for slug mapping constants."""
def test_singular_plural_map_has_common_terms(self):
"""Test common terms are in the mapping."""
assert "agent" in SINGULAR_PLURAL_MAP
assert "task" in SINGULAR_PLURAL_MAP
assert "guardrail" in SINGULAR_PLURAL_MAP
assert "handoff" in SINGULAR_PLURAL_MAP
def test_legacy_aliases_has_common_aliases(self):
"""Test common aliases are in the mapping."""
assert "rag" in LEGACY_ALIASES
assert "retrieval" in LEGACY_ALIASES
@@ -0,0 +1,149 @@
"""Tests for standardise validator."""
from praisonai.standardise.config import StandardiseConfig
from praisonai.standardise.discovery import FeatureDiscovery
from praisonai.standardise.validator import ArtifactValidator
from praisonai.standardise.models import ArtifactType, FeatureSlug
class TestArtifactValidator:
"""Tests for ArtifactValidator class."""
def test_validate_feature_all_present(self, tmp_path):
"""Test validation when all artifacts are present."""
# Create complete feature structure
docs_root = tmp_path / "docs"
docs_root.mkdir()
(docs_root / "concepts").mkdir()
(docs_root / "concepts" / "guardrails.mdx").touch()
(docs_root / "features").mkdir()
(docs_root / "features" / "guardrails.mdx").touch()
(docs_root / "cli").mkdir()
(docs_root / "cli" / "guardrails.mdx").touch()
sdk_docs = docs_root / "sdk" / "praisonaiagents" / "guardrails"
sdk_docs.mkdir(parents=True)
(sdk_docs / "guardrails.mdx").touch()
examples_root = tmp_path / "examples"
examples_root.mkdir()
guardrails_examples = examples_root / "guardrails"
guardrails_examples.mkdir()
(guardrails_examples / "guardrails-basic.py").touch()
(guardrails_examples / "guardrails-advanced.py").touch()
config = StandardiseConfig(docs_root=docs_root, examples_root=examples_root)
discovery = FeatureDiscovery(config)
validator = ArtifactValidator(config, discovery)
slug = FeatureSlug.from_string("guardrails")
result = validator.validate_feature(slug)
assert result.is_valid
assert len(result.missing_artifacts) == 0
def test_validate_feature_missing_artifacts(self, tmp_path):
"""Test validation when some artifacts are missing."""
# Create partial feature structure
docs_root = tmp_path / "docs"
docs_root.mkdir()
(docs_root / "concepts").mkdir()
(docs_root / "concepts" / "guardrails.mdx").touch()
# Missing: features, cli, sdk docs, examples
config = StandardiseConfig(docs_root=docs_root)
discovery = FeatureDiscovery(config)
validator = ArtifactValidator(config, discovery)
slug = FeatureSlug.from_string("guardrails")
result = validator.validate_feature(slug)
assert not result.is_valid
assert len(result.missing_artifacts) > 0
assert ArtifactType.DOCS_FEATURE in result.missing_artifacts
def test_validate_all(self, tmp_path):
"""Test validating all discovered features."""
# Create structure with multiple features
sdk_root = tmp_path / "sdk"
sdk_root.mkdir()
(sdk_root / "guardrails").mkdir()
(sdk_root / "memory").mkdir()
docs_root = tmp_path / "docs"
docs_root.mkdir()
(docs_root / "concepts").mkdir()
(docs_root / "concepts" / "guardrails.mdx").touch()
# memory has no docs
config = StandardiseConfig(sdk_root=sdk_root, docs_root=docs_root)
discovery = FeatureDiscovery(config)
validator = ArtifactValidator(config, discovery)
results = validator.validate_all()
assert "guardrails" in results
assert "memory" in results
def test_get_missing_artifacts(self, tmp_path):
"""Test getting all missing artifacts."""
sdk_root = tmp_path / "sdk"
sdk_root.mkdir()
(sdk_root / "guardrails").mkdir()
(sdk_root / "memory").mkdir()
config = StandardiseConfig(sdk_root=sdk_root)
discovery = FeatureDiscovery(config)
validator = ArtifactValidator(config, discovery)
missing = validator.get_missing_artifacts()
# Both features should have missing artifacts
assert len(missing) >= 2
def test_finds_variant_paths(self, tmp_path):
"""Test that validator finds files with variant naming."""
docs_root = tmp_path / "docs"
docs_root.mkdir()
(docs_root / "cli").mkdir()
# Using singular form
(docs_root / "cli" / "guardrail.mdx").touch()
config = StandardiseConfig(docs_root=docs_root)
discovery = FeatureDiscovery(config)
validator = ArtifactValidator(config, discovery)
slug = FeatureSlug.from_string("guardrails")
artifact_path = validator._find_actual_path(
docs_root / "cli" / "guardrails.mdx",
slug,
"cli"
)
# Should find the singular variant
assert artifact_path is not None
assert artifact_path.exists()
def test_feature_filter(self, tmp_path):
"""Test that feature filter limits validation."""
sdk_root = tmp_path / "sdk"
sdk_root.mkdir()
(sdk_root / "guardrails").mkdir()
(sdk_root / "memory").mkdir()
(sdk_root / "knowledge").mkdir()
config = StandardiseConfig(sdk_root=sdk_root, feature_filter="guardrails")
discovery = FeatureDiscovery(config)
validator = ArtifactValidator(config, discovery)
results = validator.validate_all()
# Only guardrails should be validated
assert len(results) == 1
assert "guardrails" in results