"""Load jamb configuration from pyproject.toml."""
import re
import warnings
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any
from jamb.core.models import MatrixColumnConfig
try:
import tomllib # type: ignore[import-not-found]
except ImportError:
import tomli as tomllib
[docs]
@dataclass
class JambConfig:
"""Configuration schema for jamb.
Attributes:
test_documents (list[str]): Document prefixes that represent test
specifications.
fail_uncovered (bool): Fail the pytest session when any normative item
lacks test coverage.
require_all_pass (bool): Require all linked tests to pass for an item
to be considered covered.
test_matrix_output (str | None): File path for the generated test records
matrix, or ``None`` to skip generation. Format is inferred from
file extension (``.html``, ``.json``, ``.csv``, ``.md``, ``.xlsx``).
trace_matrix_output (str | None): File path for the generated traceability
matrix, or ``None`` to skip generation. Format is inferred from
file extension (``.html``, ``.json``, ``.csv``, ``.md``, ``.xlsx``).
exclude_patterns (list[str]): Glob patterns for documents or items to
exclude from processing.
trace_to_ignore (list[str]): Document prefixes to exclude from the
"Traces To" column in the traceability matrix.
software_version (str | None): Software version for the traceability matrix.
If None, auto-parsed from ``[project].version`` in pyproject.toml.
trace_from (str | None): Starting document prefix for full chain trace
matrix generation. When set, generates a full chain matrix instead
of the simple trace matrix.
include_ancestors (bool): Whether to include a "Traces To" column
showing ancestors of the starting items in full chain matrices.
tc_id_prefix (str): Prefix for auto-generated test case IDs. Defaults
to ``"TC"``, producing IDs like ``TC001``, ``TC002``. Custom prefixes
allow project-specific formats (e.g., ``"TEST-"`` → ``TEST-001``).
Must contain only alphanumeric characters, hyphens, or underscores.
matrix_columns (list[MatrixColumnConfig]): Extra columns to display
in the full chain traceability matrix. Each entry defines a column
sourced from a custom attribute or a built-in resolver.
Examples:
Construct a config with custom settings::
>>> config = JambConfig(
... test_documents=["SRS"],
... fail_uncovered=True,
... test_matrix_output="test-records.html",
... )
>>> config.test_documents
['SRS']
>>> config.fail_uncovered
True
"""
test_documents: list[str] = field(default_factory=list)
fail_uncovered: bool = False
require_all_pass: bool = True
test_matrix_output: str | None = None
trace_matrix_output: str | None = None
exclude_patterns: list[str] = field(default_factory=list)
trace_to_ignore: list[str] = field(default_factory=list)
software_version: str | None = None
trace_from: str | None = None
include_ancestors: bool = False
tc_id_prefix: str = "TC"
matrix_columns: list[MatrixColumnConfig] = field(default_factory=list)
[docs]
def validate(self, available_documents: list[str]) -> list[str]:
"""Validate configuration against available documents.
Args:
available_documents: List of document prefixes discovered in the project.
Returns:
List of validation warning messages. Empty if no issues found.
Raises:
ValueError: If tc_id_prefix contains invalid characters.
"""
# Hard validation: tc_id_prefix must only contain alphanumeric, hyphens, underscores
if self.tc_id_prefix and not re.match(r"^[A-Za-z0-9_-]+$", self.tc_id_prefix):
raise ValueError(
f"tc_id_prefix '{self.tc_id_prefix}' contains invalid characters. "
"Only alphanumeric characters, hyphens, and underscores are allowed."
)
validation_warnings: list[str] = []
if self.trace_from and self.trace_from not in available_documents:
validation_warnings.append(
f"trace_from '{self.trace_from}' not found in documents: {', '.join(sorted(available_documents))}"
)
for doc in self.test_documents:
if doc not in available_documents:
validation_warnings.append(f"test_documents contains '{doc}' not in available documents")
for doc in self.trace_to_ignore:
if doc not in available_documents:
validation_warnings.append(f"trace_to_ignore contains '{doc}' not in available documents")
return validation_warnings
def _extract_version_from_file(version_file: Path) -> str | None:
"""Extract version string from a Python version file.
Looks for patterns like:
- __version__ = "1.2.3"
- __version__ = '1.2.3'
- __version__ = version = '1.2.3'
- VERSION = "1.2.3"
Args:
version_file: Path to the version file.
Returns:
The version string if found, None otherwise.
"""
if not version_file.exists():
return None
try:
content = version_file.read_text()
# Match __version__ = "..." or __version__ = version = "..." or VERSION = "..."
# The pattern captures the quoted version string at the end
pattern = (
r"""(?:__version__|VERSION)\s*=\s*"""
r"""(?:version\s*=\s*)?['"]([\d.]+(?:[-+.a-zA-Z0-9]+)?)['"]\s*$"""
)
match = re.search(pattern, content, re.MULTILINE)
if match:
return match.group(1)
except (OSError, UnicodeDecodeError):
pass
return None
def _get_dynamic_version(pyproject: dict[str, Any], project_root: Path) -> str | None:
"""Get version from dynamic version file configurations.
Checks common build tool configurations:
- [tool.hatch.build.hooks.vcs].version-file (hatch-vcs)
- [tool.hatch.version].path (hatch path-based)
- [tool.setuptools_scm].write_to (setuptools_scm)
Args:
pyproject: Parsed pyproject.toml content.
project_root: Root directory of the project.
Returns:
The version string if found, None otherwise.
"""
tool = pyproject.get("tool", {})
# Check hatch-vcs: [tool.hatch.build.hooks.vcs].version-file
version_file = tool.get("hatch", {}).get("build", {}).get("hooks", {}).get("vcs", {}).get("version-file")
if version_file:
resolved = (project_root / version_file).resolve()
if not resolved.is_relative_to(project_root.resolve()):
pass
else:
version = _extract_version_from_file(resolved)
if version:
return version
# Check hatch path-based: [tool.hatch.version].path
version_file = tool.get("hatch", {}).get("version", {}).get("path")
if version_file:
resolved = (project_root / version_file).resolve()
if not resolved.is_relative_to(project_root.resolve()):
pass
else:
version = _extract_version_from_file(resolved)
if version:
return version
# Check setuptools_scm: [tool.setuptools_scm].write_to
version_file = tool.get("setuptools_scm", {}).get("write_to")
if version_file:
resolved = (project_root / version_file).resolve()
if not resolved.is_relative_to(project_root.resolve()):
pass
else:
version = _extract_version_from_file(resolved)
if version:
return version
return None
[docs]
def load_config(config_path: Path | None = None) -> JambConfig:
"""
Load jamb configuration from pyproject.toml.
Looks for [tool.jamb] section. Also auto-parses software version from
[project].version if not explicitly set in [tool.jamb].
Args:
config_path: Optional path to pyproject.toml. If None, uses cwd.
Returns:
JambConfig with loaded values or defaults.
Examples:
Load configuration from the default path (``pyproject.toml`` in
the current working directory)::
>>> config = load_config()
>>> config.matrix_format
'html'
Load from a specific path::
>>> from pathlib import Path
>>> config = load_config(Path("myproject/pyproject.toml"))
"""
if config_path is None:
config_path = Path.cwd() / "pyproject.toml"
if not config_path.exists():
return JambConfig()
with open(config_path, "rb") as f:
pyproject = tomllib.load(f)
jamb_config = pyproject.get("tool", {}).get("jamb", {})
RECOGNIZED_KEYS = {
"test_documents",
"fail_uncovered",
"require_all_pass",
"test_matrix_output",
"trace_matrix_output",
"exclude_patterns",
"trace_to_ignore",
"software_version",
"trace_from",
"include_ancestors",
"tc_id_prefix",
"matrix_columns",
}
unknown = set(jamb_config.keys()) - RECOGNIZED_KEYS
if unknown:
warnings.warn(
f"Unrecognized keys in [tool.jamb]: {', '.join(sorted(unknown))}",
stacklevel=2,
)
# Get software_version with fallback chain:
# 1. [tool.jamb].software_version (explicit override)
# 2. [project].version (static version)
# 3. Dynamic version file (hatch-vcs, setuptools_scm, etc.)
software_version = jamb_config.get("software_version")
if software_version is None:
software_version = pyproject.get("project", {}).get("version")
if software_version is None:
# Check if version is dynamic and try to read from version file
dynamic = pyproject.get("project", {}).get("dynamic", [])
if "version" in dynamic:
software_version = _get_dynamic_version(pyproject, config_path.parent)
# Parse matrix_columns array-of-tables
BUILT_IN_COLUMNS = {"review_status"}
raw_columns = jamb_config.get("matrix_columns", [])
matrix_columns: list[MatrixColumnConfig] = []
for col in raw_columns:
if not isinstance(col, dict):
warnings.warn(
f"Invalid matrix_columns entry (expected table): {col}",
stacklevel=2,
)
continue
key = col.get("key")
if not key:
warnings.warn(
"matrix_columns entry missing required 'key' field",
stacklevel=2,
)
continue
source = col.get("source", "custom_attribute")
if source not in ("custom_attribute", "built_in"):
warnings.warn(
f"matrix_columns entry '{key}' has unknown source '{source}'. "
"Expected 'custom_attribute' or 'built_in'.",
stacklevel=2,
)
continue
if source == "built_in" and key not in BUILT_IN_COLUMNS:
warnings.warn(
f"matrix_columns entry '{key}' uses source 'built_in' but "
f"'{key}' is not a recognized built-in column. "
f"Available: {', '.join(sorted(BUILT_IN_COLUMNS))}",
stacklevel=2,
)
continue
matrix_columns.append(
MatrixColumnConfig(
key=key,
header=col.get("header", key),
source=source,
default=col.get("default", "-"),
)
)
return JambConfig(
test_documents=jamb_config.get("test_documents", []),
fail_uncovered=jamb_config.get("fail_uncovered", False),
require_all_pass=jamb_config.get("require_all_pass", True),
test_matrix_output=jamb_config.get("test_matrix_output"),
trace_matrix_output=jamb_config.get("trace_matrix_output"),
exclude_patterns=jamb_config.get("exclude_patterns", []),
trace_to_ignore=jamb_config.get("trace_to_ignore", []),
software_version=software_version,
trace_from=jamb_config.get("trace_from"),
include_ancestors=jamb_config.get("include_ancestors", False),
tc_id_prefix=jamb_config.get("tc_id_prefix", "TC"),
matrix_columns=matrix_columns,
)