Skip to content

Python API

behave-doctor can be used as a library for custom tooling, IDE plugins, CI integrations, or anything that needs programmatic access to Behave suite diagnostics.

Installation

pip install behave-doctor

No extra dependencies are needed — the library API is part of the core package.

scan_project

The high-level entry point. Scans a Behave project and returns a ProjectReport with diagnostics, statistics, and an exit code.

behave_doctor.scan_project

scan_project(path: str | Path, config: DoctorConfig | None = None) -> ProjectReport

Scan a Behave project and return a diagnostic report.

This is the high-level orchestrator: it loads configuration, scans features and step definitions, builds the dependency graph, runs all enabled rules, and returns a :class:ProjectReport.

Parameters:

Name Type Description Default
path str | Path

Root directory of the Behave project.

required
config DoctorConfig | None

Optional configuration. When None, configuration is loaded from pyproject.toml in path if present, otherwise defaults are used.

None

Returns:

Name Type Description
A ProjectReport

class:ProjectReport with diagnostics, statistics, and exit code.

Raises:

Type Description
TypeError

If path is None.

FileNotFoundError

If path does not exist.

ScanError

If path exists but is not a directory or cannot be scanned.

Source code in behave_doctor/__init__.py
def scan_project(
    path: str | Path,
    config: DoctorConfig | None = None,
) -> ProjectReport:
    """Scan a Behave project and return a diagnostic report.

    This is the high-level orchestrator: it loads configuration, scans
    features and step definitions, builds the dependency graph, runs all
    enabled rules, and returns a :class:`ProjectReport`.

    Args:
        path: Root directory of the Behave project.
        config: Optional configuration. When ``None``, configuration is
            loaded from ``pyproject.toml`` in ``path`` if present, otherwise
            defaults are used.

    Returns:
        A :class:`ProjectReport` with diagnostics, statistics, and exit code.

    Raises:
        TypeError: If ``path`` is ``None``.
        FileNotFoundError: If ``path`` does not exist.
        ScanError: If ``path`` exists but is not a directory or cannot be scanned.
    """
    if path is None:
        msg = "scan_project() requires a path argument, got None."
        raise TypeError(msg)
    project_path = Path(path).resolve()
    if not project_path.exists():
        raise FileNotFoundError(f"Project path does not exist: {project_path}")
    if config is None:
        pyproject = project_path / "pyproject.toml"
        config = DoctorConfig.from_pyproject(pyproject) if pyproject.exists() else DoctorConfig()
    return build_report(project_path, config)

Example: basic scan

from behave_doctor import scan_project

report = scan_project("path/to/project")

for d in report.diagnostics:
    print(f"{d.rule_id}: {d.message} at {d.file}:{d.line}")

print(f"Exit code: {report.exit_code}")

Example: with custom config

from behave_doctor import scan_project, DoctorConfig, Severity

config = DoctorConfig(
    features_dir="my_features",
    steps_dir="my_steps",
    min_severity=Severity.WARNING,
    rules={
        "BD101": {"enabled": False},
        "BD401": {"max_steps": 5},
        "BD203": {"max_scenarios": 10},
    },
)
report = scan_project("path/to/project", config=config)

Example: filter by severity

from behave_doctor import scan_project, Severity

report = scan_project("path/to/project")

errors = [d for d in report.diagnostics if d.severity is Severity.ERROR]
warnings = [d for d in report.diagnostics if d.severity is Severity.WARNING]

print(f"{len(errors)} errors, {len(warnings)} warnings")

ProjectReport

The result of a scan_project call. Contains diagnostics, statistics, and an exit code.

behave_doctor.ProjectReport dataclass

The full diagnostic report for a scanned project.

Attributes:

Name Type Description
project_path Path

Root path of the scanned project.

diagnostics list[Diagnostic]

All diagnostics produced by the rule engine.

statistics ProjectStatistics

Aggregate project statistics.

scanned_at datetime

Timestamp of the scan.

scan_duration_ms int

Scan duration in milliseconds.

Source code in behave_doctor/model/project_report.py
@dataclass
class ProjectReport:
    """The full diagnostic report for a scanned project.

    Attributes:
        project_path: Root path of the scanned project.
        diagnostics: All diagnostics produced by the rule engine.
        statistics: Aggregate project statistics.
        scanned_at: Timestamp of the scan.
        scan_duration_ms: Scan duration in milliseconds.
    """

    project_path: Path
    diagnostics: list[Diagnostic] = field(default_factory=list)
    statistics: ProjectStatistics = field(default_factory=ProjectStatistics)
    scanned_at: datetime = field(default_factory=datetime.now)
    scan_duration_ms: int = 0

    @property
    def errors(self) -> list[Diagnostic]:
        """Diagnostics with severity ``ERROR``."""
        return [d for d in self.diagnostics if d.severity is Severity.ERROR]

    @property
    def warnings(self) -> list[Diagnostic]:
        """Diagnostics with severity ``WARNING``."""
        return [d for d in self.diagnostics if d.severity is Severity.WARNING]

    @property
    def has_errors(self) -> bool:
        """``True`` if the report contains at least one error."""
        return bool(self.errors)

    @property
    def has_warnings(self) -> bool:
        """``True`` if the report contains at least one warning."""
        return any(d.severity is Severity.WARNING for d in self.diagnostics)

    @property
    def exit_code(self) -> int:
        """Exit code: ``0`` if clean, ``1`` if any errors or warnings were found."""
        return 1 if (self.has_errors or self.has_warnings) else 0

errors property

errors: list[Diagnostic]

Diagnostics with severity ERROR.

exit_code property

exit_code: int

Exit code: 0 if clean, 1 if any errors or warnings were found.

has_errors property

has_errors: bool

True if the report contains at least one error.

has_warnings property

has_warnings: bool

True if the report contains at least one warning.

warnings property

warnings: list[Diagnostic]

Diagnostics with severity WARNING.

Attributes

Attribute Type Description
diagnostics list[Diagnostic] All diagnostics found during the scan.
statistics ProjectStatistics Aggregate statistics about the project.
exit_code int 0 = clean, 1 = issues found.
scan_duration_ms int Scan duration in milliseconds.
scanned_at datetime Timestamp of the scan.
project_path Path Root path of the scanned project.

Diagnostic

A single diagnostic finding produced by a rule.

behave_doctor.Diagnostic dataclass

A single finding produced by a diagnostic rule.

Attributes:

Name Type Description
rule_id str

Stable rule identifier (e.g. "BD301").

rule_name str

Human-readable rule name (e.g. "unused-step-def").

severity Severity

Severity of the finding.

category Category

Category the rule belongs to.

message str

Human-readable description of the finding.

file Path | None

File where the issue was found, if applicable.

line int | None

1-indexed line number, if applicable.

suggestion str | None

Optional fix suggestion.

metadata dict[str, Any]

Extra context for reporters.

Source code in behave_doctor/model/diagnostic.py
@dataclass(frozen=True)
class Diagnostic:
    """A single finding produced by a diagnostic rule.

    Attributes:
        rule_id: Stable rule identifier (e.g. ``"BD301"``).
        rule_name: Human-readable rule name (e.g. ``"unused-step-def"``).
        severity: Severity of the finding.
        category: Category the rule belongs to.
        message: Human-readable description of the finding.
        file: File where the issue was found, if applicable.
        line: 1-indexed line number, if applicable.
        suggestion: Optional fix suggestion.
        metadata: Extra context for reporters.
    """

    rule_id: str
    rule_name: str
    severity: Severity
    category: Category
    message: str
    file: Path | None = None
    line: int | None = None
    suggestion: str | None = None
    metadata: dict[str, Any] = field(default_factory=dict)

Attributes

Attribute Type Description
rule_id str Rule identifier (e.g. "BD301").
rule_name str Human-readable rule name (e.g. "unused-step-def").
severity Severity One of ERROR, WARNING, INFO, HINT.
category Category One of STRUCTURE, QUALITY, COVERAGE, COMPLEXITY, DEPENDENCY.
message str Human-readable description of the issue.
file Path \| None File path where the issue was found.
line int \| None Line number (1-based), if applicable.
suggestion str \| None Optional fix suggestion.
metadata dict Optional rule-specific metadata.

Severity

Enum of diagnostic severity levels.

behave_doctor.Severity

Bases: Enum

Diagnostic severity levels, ordered from most to least severe.

Source code in behave_doctor/model/enums.py
class Severity(Enum):
    """Diagnostic severity levels, ordered from most to least severe."""

    ERROR = "error"
    WARNING = "warning"
    INFO = "info"
    HINT = "hint"
Value Description
ERROR Must fix — the suite is broken or unsafe.
WARNING Should fix — quality or maintainability.
INFO Informational — no action required.
HINT Suggestion — optional improvement.

Category

Enum of diagnostic categories.

behave_doctor.Category

Bases: Enum

Diagnostic category, grouping rules by concern.

Source code in behave_doctor/model/enums.py
class Category(Enum):
    """Diagnostic category, grouping rules by concern."""

    STRUCTURE = "structure"
    QUALITY = "quality"
    COVERAGE = "coverage"
    COMPLEXITY = "complexity"
    DEPENDENCY = "dependency"
Value Description
STRUCTURE Informational metrics (BD101-104).
QUALITY Suite quality issues (BD201-204).
COVERAGE Unused/undefined steps and tags (BD301-304).
COMPLEXITY Size and complexity limits (BD401-403).
DEPENDENCY Import and module analysis (BD501-503).

DoctorConfig

Configuration for a scan. All fields have defaults.

behave_doctor.DoctorConfig dataclass

Configuration for a behave-doctor scan.

Attributes:

Name Type Description
features_dir str

Relative path to the features directory.

steps_dir str

Relative path to the step definitions directory.

min_severity Severity

Minimum severity to report.

rules dict[str, dict[str, Any]]

Per-rule overrides keyed by rule ID. Each rule dict defaults to {"enabled": True} merged with any user-provided overrides.

exclude_paths list[str]

Glob patterns of paths to exclude from scanning.

exclude_tags list[str]

Tags to exclude from coverage analysis.

Source code in behave_doctor/model/config.py
@dataclass
class DoctorConfig:
    """Configuration for a behave-doctor scan.

    Attributes:
        features_dir: Relative path to the features directory.
        steps_dir: Relative path to the step definitions directory.
        min_severity: Minimum severity to report.
        rules: Per-rule overrides keyed by rule ID. Each rule dict defaults
            to ``{"enabled": True}`` merged with any user-provided overrides.
        exclude_paths: Glob patterns of paths to exclude from scanning.
        exclude_tags: Tags to exclude from coverage analysis.
    """

    features_dir: str = "features/"
    steps_dir: str = "features/steps/"
    min_severity: Severity = Severity.INFO
    rules: dict[str, dict[str, Any]] = field(default_factory=dict)
    exclude_paths: list[str] = field(default_factory=list)
    exclude_tags: list[str] = field(default_factory=list)

    def is_excluded(self, path: Path) -> bool:
        """Return ``True`` if ``path`` matches any configured exclude glob pattern.

        Both the path itself and each of its parent directories are checked
        so that directory-level patterns (``"excluded/**"``) and file-level
        patterns (``"*.feature"``) both work.
        """
        if not self.exclude_paths:
            return False
        parts = [path, *path.parents]
        for pattern in self.exclude_paths:
            if not pattern:
                continue
            for part in parts:
                try:
                    if part.match(pattern):
                        return True
                except ValueError:
                    # Invalid glob pattern; ignore it.
                    continue
        return False

    @classmethod
    def from_pyproject(cls, path: Path) -> DoctorConfig:
        """Load configuration from the ``[tool.behave-doctor]`` section of a TOML file.

        Args:
            path: Path to a ``pyproject.toml`` (or any TOML) file.

        Returns:
            A ``DoctorConfig``. If the ``[tool.behave-doctor]`` section is
            missing, the default config is returned.

        Raises:
            TypeError: If ``path`` is ``None``.
            FileNotFoundError: If ``path`` does not exist.
            IsADirectoryError: If ``path`` is a directory, not a file.
            ValueError: If ``min_severity`` is not a valid severity name.
        """
        if path is None:
            msg = "from_pyproject() requires a Path argument, got None."
            raise TypeError(msg)
        if not path.exists():
            raise FileNotFoundError(f"Config file not found: {path}")
        if path.is_dir():
            raise IsADirectoryError(f"Config path is a directory, not a file: {path}")

        with path.open("rb") as handle:
            data = tomllib.load(handle)

        section = data.get("tool", {}).get("behave-doctor")
        if not section:
            return cls()

        features_dir = str(section.get("features_dir", "features/"))
        steps_dir = str(section.get("steps_dir", "features/steps/"))

        min_severity_raw = str(section.get("min_severity", "info")).strip().lower()
        try:
            min_severity = Severity(min_severity_raw)
        except ValueError as exc:
            valid = ", ".join(s.value for s in Severity)
            msg = f"Invalid min_severity {min_severity_raw!r} in {path}. Valid values: {valid}."
            raise ValueError(msg) from exc

        raw_rules = section.get("rules", {})
        rules: dict[str, dict[str, Any]] = {}
        if isinstance(raw_rules, dict):
            for rule_id, overrides in raw_rules.items():
                merged: dict[str, Any] = {"enabled": True}
                if isinstance(overrides, bool):
                    merged["enabled"] = overrides
                elif isinstance(overrides, dict):
                    merged.update(overrides)
                rules[str(rule_id)] = merged

        # Read exclude_paths and exclude_tags from both the top level and
        # the [tool.behave-doctor.exclude] sub-table. Both sources are merged.
        def _as_str_list(value: Any) -> list[str]:
            if isinstance(value, str):
                return [value]
            if isinstance(value, (list, tuple)):
                return [str(item) for item in value]
            return []

        def _as_list(value: Any) -> list[str]:
            if isinstance(value, (list, tuple)):
                return [str(item) for item in value]
            return []

        top_paths = section.get("exclude_paths", [])
        top_tags = section.get("exclude_tags", [])
        exclude = section.get("exclude", {})
        if isinstance(exclude, dict):
            sub_paths = exclude.get("paths", [])
            sub_tags = exclude.get("tags", [])
        else:
            sub_paths = []
            sub_tags = []
        raw_paths = _as_str_list(top_paths) + _as_list(sub_paths)
        raw_tags = _as_str_list(top_tags) + _as_list(sub_tags)
        exclude_paths = [str(p) for p in raw_paths]
        exclude_tags = [str(t) for t in raw_tags]

        return cls(
            features_dir=features_dir,
            steps_dir=steps_dir,
            min_severity=min_severity,
            rules=rules,
            exclude_paths=exclude_paths,
            exclude_tags=exclude_tags,
        )

from_pyproject classmethod

from_pyproject(path: Path) -> DoctorConfig

Load configuration from the [tool.behave-doctor] section of a TOML file.

Parameters:

Name Type Description Default
path Path

Path to a pyproject.toml (or any TOML) file.

required

Returns:

Type Description
DoctorConfig

A DoctorConfig. If the [tool.behave-doctor] section is

DoctorConfig

missing, the default config is returned.

Raises:

Type Description
TypeError

If path is None.

FileNotFoundError

If path does not exist.

IsADirectoryError

If path is a directory, not a file.

ValueError

If min_severity is not a valid severity name.

Source code in behave_doctor/model/config.py
@classmethod
def from_pyproject(cls, path: Path) -> DoctorConfig:
    """Load configuration from the ``[tool.behave-doctor]`` section of a TOML file.

    Args:
        path: Path to a ``pyproject.toml`` (or any TOML) file.

    Returns:
        A ``DoctorConfig``. If the ``[tool.behave-doctor]`` section is
        missing, the default config is returned.

    Raises:
        TypeError: If ``path`` is ``None``.
        FileNotFoundError: If ``path`` does not exist.
        IsADirectoryError: If ``path`` is a directory, not a file.
        ValueError: If ``min_severity`` is not a valid severity name.
    """
    if path is None:
        msg = "from_pyproject() requires a Path argument, got None."
        raise TypeError(msg)
    if not path.exists():
        raise FileNotFoundError(f"Config file not found: {path}")
    if path.is_dir():
        raise IsADirectoryError(f"Config path is a directory, not a file: {path}")

    with path.open("rb") as handle:
        data = tomllib.load(handle)

    section = data.get("tool", {}).get("behave-doctor")
    if not section:
        return cls()

    features_dir = str(section.get("features_dir", "features/"))
    steps_dir = str(section.get("steps_dir", "features/steps/"))

    min_severity_raw = str(section.get("min_severity", "info")).strip().lower()
    try:
        min_severity = Severity(min_severity_raw)
    except ValueError as exc:
        valid = ", ".join(s.value for s in Severity)
        msg = f"Invalid min_severity {min_severity_raw!r} in {path}. Valid values: {valid}."
        raise ValueError(msg) from exc

    raw_rules = section.get("rules", {})
    rules: dict[str, dict[str, Any]] = {}
    if isinstance(raw_rules, dict):
        for rule_id, overrides in raw_rules.items():
            merged: dict[str, Any] = {"enabled": True}
            if isinstance(overrides, bool):
                merged["enabled"] = overrides
            elif isinstance(overrides, dict):
                merged.update(overrides)
            rules[str(rule_id)] = merged

    # Read exclude_paths and exclude_tags from both the top level and
    # the [tool.behave-doctor.exclude] sub-table. Both sources are merged.
    def _as_str_list(value: Any) -> list[str]:
        if isinstance(value, str):
            return [value]
        if isinstance(value, (list, tuple)):
            return [str(item) for item in value]
        return []

    def _as_list(value: Any) -> list[str]:
        if isinstance(value, (list, tuple)):
            return [str(item) for item in value]
        return []

    top_paths = section.get("exclude_paths", [])
    top_tags = section.get("exclude_tags", [])
    exclude = section.get("exclude", {})
    if isinstance(exclude, dict):
        sub_paths = exclude.get("paths", [])
        sub_tags = exclude.get("tags", [])
    else:
        sub_paths = []
        sub_tags = []
    raw_paths = _as_str_list(top_paths) + _as_list(sub_paths)
    raw_tags = _as_str_list(top_tags) + _as_list(sub_tags)
    exclude_paths = [str(p) for p in raw_paths]
    exclude_tags = [str(t) for t in raw_tags]

    return cls(
        features_dir=features_dir,
        steps_dir=steps_dir,
        min_severity=min_severity,
        rules=rules,
        exclude_paths=exclude_paths,
        exclude_tags=exclude_tags,
    )

is_excluded

is_excluded(path: Path) -> bool

Return True if path matches any configured exclude glob pattern.

Both the path itself and each of its parent directories are checked so that directory-level patterns ("excluded/**") and file-level patterns ("*.feature") both work.

Source code in behave_doctor/model/config.py
def is_excluded(self, path: Path) -> bool:
    """Return ``True`` if ``path`` matches any configured exclude glob pattern.

    Both the path itself and each of its parent directories are checked
    so that directory-level patterns (``"excluded/**"``) and file-level
    patterns (``"*.feature"``) both work.
    """
    if not self.exclude_paths:
        return False
    parts = [path, *path.parents]
    for pattern in self.exclude_paths:
        if not pattern:
            continue
        for part in parts:
            try:
                if part.match(pattern):
                    return True
            except ValueError:
                # Invalid glob pattern; ignore it.
                continue
    return False

Attributes

Attribute Type Default Description
features_dir str "features/" Relative path to features.
steps_dir str "features/steps/" Relative path to step definitions.
min_severity Severity Severity.INFO Minimum severity to report.
exclude_tags list[str] [] Tags excluded from BD303.
exclude_paths list[str] [] Glob patterns to exclude from scan.
rules dict[str, dict] {} Per-rule configuration.

Loading from pyproject.toml

from behave_doctor import DoctorConfig

config = DoctorConfig.from_pyproject("path/to/project/pyproject.toml")

ProjectStatistics

Aggregate statistics about the scanned project.

behave_doctor.ProjectStatistics dataclass

Aggregate metrics for a scanned project.

Attributes:

Name Type Description
features int

Total number of features.

scenarios int

Total number of scenarios.

steps int

Total number of steps.

step_definitions int

Total number of step definitions.

tags int

Number of unique tags.

total_tag_usages int

Total tag usages across the project.

average_steps_per_scenario float

Mean steps per scenario.

unused_step_definitions int

Count of unused step definitions.

undefined_steps int

Count of undefined feature steps.

Source code in behave_doctor/model/project_report.py
@dataclass(frozen=True)
class ProjectStatistics:
    """Aggregate metrics for a scanned project.

    Attributes:
        features: Total number of features.
        scenarios: Total number of scenarios.
        steps: Total number of steps.
        step_definitions: Total number of step definitions.
        tags: Number of unique tags.
        total_tag_usages: Total tag usages across the project.
        average_steps_per_scenario: Mean steps per scenario.
        unused_step_definitions: Count of unused step definitions.
        undefined_steps: Count of undefined feature steps.
    """

    features: int = 0
    scenarios: int = 0
    steps: int = 0
    step_definitions: int = 0
    tags: int = 0
    total_tag_usages: int = 0
    average_steps_per_scenario: float = 0.0
    unused_step_definitions: int = 0
    undefined_steps: int = 0

Attributes

Attribute Type Description
features int Total number of features.
scenarios int Total number of scenarios.
steps int Total number of steps.
step_definitions int Total number of step definitions.
tags int Number of unique tags.
total_tag_usages int Total tag usages across all scenarios.
average_steps_per_scenario float Average steps per scenario.
unused_step_definitions int Step definitions never matched.
undefined_steps int Steps with no matching definition.

Example: custom reporter

import json
from behave_doctor import scan_project, Severity

report = scan_project("path/to/project")

output = {
    "errors": [],
    "warnings": [],
    "stats": {
        "features": report.statistics.features,
        "scenarios": report.statistics.scenarios,
        "unused_step_definitions": report.statistics.unused_step_definitions,
        "undefined_steps": report.statistics.undefined_steps,
    },
}

for d in report.diagnostics:
    entry = {
        "rule": d.rule_id,
        "message": d.message,
        "file": d.file,
        "line": d.line,
    }
    if d.severity is Severity.ERROR:
        output["errors"].append(entry)
    elif d.severity is Severity.WARNING:
        output["warnings"].append(entry)

print(json.dumps(output, indent=2))

Example: IDE plugin integration

from behave_doctor import scan_project, Severity

def on_save(project_path: str) -> list[dict]:
    """Run behave-doctor on save and return diagnostics for the editor."""
    report = scan_project(project_path)

    return [
        {
            "file": d.file,
            "line": d.line,
            "severity": d.severity.name.lower(),
            "message": f"[{d.rule_id}] {d.message}",
            "source": "behave-doctor",
        }
        for d in report.diagnostics
        if d.severity in (Severity.ERROR, Severity.WARNING)
    ]