Skip to content

validation

ruff_sync.validation

Validation logic for ruff-sync merged configurations.

__all__ module-attribute

__all__ = [
    "validate_merged_config",
    "validate_ruff_accepts_config",
    "validate_toml_syntax",
]

LOGGER module-attribute

LOGGER = getLogger(__name__)

check_python_version_consistency

check_python_version_consistency(
    doc, strict=False, exclude=()
)

Warn if the merged ruff target-version conflicts with requires-python.

Parameters:

Name Type Description Default
doc TOMLDocument

The merged TOML document (pyproject.toml format).

required
strict bool

If True, treat version mismatch as a failure.

False
exclude Iterable[str]

List of keys excluded from the ruff configuration.

()

Returns:

Type Description
bool

True if versions are consistent or if check is skipped.

bool

False if strict=True and versions are inconsistent.

Source code in src/ruff_sync/validation.py
def check_python_version_consistency(
    doc: TOMLDocument, strict: bool = False, exclude: Iterable[str] = ()
) -> bool:
    """Warn if the merged ruff target-version conflicts with requires-python.

    Args:
        doc: The merged TOML document (pyproject.toml format).
        strict: If True, treat version mismatch as a failure.
        exclude: List of keys excluded from the ruff configuration.

    Returns:
        True if versions are consistent or if check is skipped.
        False if strict=True and versions are inconsistent.
    """
    if "target-version" in exclude or "tool.ruff.target-version" in exclude:
        LOGGER.warning(
            "Skipping Python version consistency check: 'target-version' is "
            "excluded in [tool.ruff-sync]."
        )
        return True
    try:
        ruff_section = doc.get("tool", {}).get("ruff", {})
        target_version = ruff_section.get("target-version")
        requires_python = doc.get("project", {}).get("requires-python")
    except (AttributeError, TypeError):
        return True  # Don't crash on unexpected doc shapes

    if not target_version or not requires_python:
        missing = []
        if not target_version:
            missing.append("[tool.ruff] target-version")
        if not requires_python:
            missing.append("[project] requires-python")
        LOGGER.warning(f"Skipping Python version consistency check: missing {', '.join(missing)}.")
        return True  # Nothing to compare

    ruff_min = _ruff_target_to_tuple(str(target_version))
    proj_min = _requires_python_min_version(str(requires_python))

    if ruff_min is None or proj_min is None:
        return True  # Couldn't parse one of the versions

    if ruff_min < proj_min:
        msg = (
            f"Version mismatch: upstream [tool.ruff] target-version='{target_version}' "
            f"targets Python {ruff_min[0]}.{ruff_min[1]}, but local [project] requires-python="
            f"'{requires_python}' requires Python >= {proj_min[0]}.{proj_min[1]}. "
            "Consider updating target-version in the upstream config."
        )
        if strict:
            LOGGER.error(f"❌ {msg}")
            return False
        LOGGER.warning(f"⚠️  {msg}")

    return True

check_deprecated_rules

check_deprecated_rules(
    doc,
    is_ruff_toml=False,
    strict=False,
    _deprecated_codes=None,
    exclude=(),
)

Warn if the merged config references any deprecated Ruff rules.

Parameters:

Name Type Description Default
doc TOMLDocument

The merged TOML document.

required
is_ruff_toml bool

True if the document is a standalone ruff.toml.

False
strict bool

If True, treat deprecated rules as a failure.

False
_deprecated_codes frozenset[str] | None

Optional set of deprecated codes to use (for testing).

None
exclude Iterable[str]

List of keys excluded from the ruff configuration.

()

Returns:

Type Description
bool

True if no deprecated rules are found, or if strict=False.

bool

False if strict=True and deprecated rules are found.

Source code in src/ruff_sync/validation.py
def check_deprecated_rules(
    doc: TOMLDocument,
    is_ruff_toml: bool = False,
    strict: bool = False,
    _deprecated_codes: frozenset[str] | None = None,
    exclude: Iterable[str] = (),
) -> bool:
    """Warn if the merged config references any deprecated Ruff rules.

    Args:
        doc: The merged TOML document.
        is_ruff_toml: True if the document is a standalone ruff.toml.
        strict: If True, treat deprecated rules as a failure.
        _deprecated_codes: Optional set of deprecated codes to use (for testing).
        exclude: List of keys excluded from the ruff configuration.

    Returns:
        True if no deprecated rules are found, or if strict=False.
        False if strict=True and deprecated rules are found.
    """
    deprecated_codes = (
        _deprecated_codes if _deprecated_codes is not None else _get_deprecated_rule_codes()
    )
    if not deprecated_codes:
        return True  # Nothing to check (ruff not found or returned no deprecated rules)

    if is_ruff_toml:
        lint_section = doc.get("lint", {})
    else:
        lint_section = doc.get("tool", {}).get("ruff", {}).get("lint", {})

    if not isinstance(lint_section, dict):
        return True

    found_deprecated = False
    for key in _RULE_LIST_KEYS:
        full_key = f"lint.{key}" if is_ruff_toml else f"tool.ruff.lint.{key}"
        if key in exclude or full_key in exclude:
            continue

        rule_list = lint_section.get(key, [])
        if not isinstance(rule_list, list):
            continue
        for rule_code in rule_list:
            rule_str = str(rule_code).strip().upper()
            if rule_str in deprecated_codes:
                found_deprecated = True
                msg = (
                    f"Upstream config uses deprecated rule '{rule_str}' "
                    f"(found in [{'lint' if is_ruff_toml else 'tool.ruff.lint'}].{key}). "
                    "This rule may be removed in a future Ruff version."
                )
                if strict:
                    LOGGER.error(f"❌ {msg}")
                else:
                    LOGGER.warning(f"⚠️  {msg}")

    return not (strict and found_deprecated)

validate_toml_syntax

validate_toml_syntax(doc)

Return True if the document serializes to valid TOML.

tomlkit always produces valid TOML, so this catches any edge cases where serialization itself raises an unexpected exception.

Source code in src/ruff_sync/validation.py
def validate_toml_syntax(doc: TOMLDocument) -> bool:
    """Return True if the document serializes to valid TOML.

    tomlkit always produces valid TOML, so this catches any edge cases
    where serialization itself raises an unexpected exception.
    """
    try:
        tomlkit.parse(doc.as_string())
        return True  # noqa: TRY300
    except tomlkit.exceptions.TOMLKitError:
        LOGGER.exception("❌ Merged config failed TOML syntax check")
        return False

validate_ruff_accepts_config

validate_ruff_accepts_config(
    doc, is_ruff_toml=False, strict=False
)

Return True if Ruff accepts the merged configuration.

Writes the merged config to a temporary file and runs

ruff check --isolated --config

Parameters:

Name Type Description Default
doc TOMLDocument

The merged TOML document to validate.

required
is_ruff_toml bool

True if the document is a ruff.toml (not pyproject.toml).

False
strict bool

If True, treat configuration warnings (deprecated rules, etc.) as failures.

False

Returns:

Type Description
bool

True if Ruff accepts the config without errors, False otherwise.

Source code in src/ruff_sync/validation.py
def validate_ruff_accepts_config(
    doc: TOMLDocument, is_ruff_toml: bool = False, strict: bool = False
) -> bool:
    """Return True if Ruff accepts the merged configuration.

    Writes the merged config to a temporary file and runs:
        ruff check --isolated --config <tmp> <dummy-py-file>

    Args:
        doc: The merged TOML document to validate.
        is_ruff_toml: True if the document is a ruff.toml (not pyproject.toml).
        strict: If True, treat configuration warnings (deprecated rules, etc.) as failures.

    Returns:
        True if Ruff accepts the config without errors, False otherwise.
    """
    # The filename matters: ruff.toml uses flat format, pyproject.toml uses [tool.ruff]
    config_filename = "ruff.toml" if is_ruff_toml else "pyproject.toml"

    with tempfile.TemporaryDirectory() as tmp_dir:
        tmp_path = pathlib.Path(tmp_dir) / config_filename
        tmp_path.write_text(doc.as_string(), encoding="utf-8")

        # Create a minimal dummy Python file for ruff to lint
        dummy_py = pathlib.Path(tmp_dir) / "dummy.py"
        dummy_py.write_text("# ruff-sync config validation\n", encoding="utf-8")

        config_flag = f"--config={tmp_path}"
        # Using --config should be enough to isolate from project config
        cmd = ["ruff", "check", config_flag, str(dummy_py)]
        LOGGER.debug(f"Running ruff config validation: {' '.join(cmd)}")

        try:
            result = subprocess.run(  # noqa: S603
                cmd,
                capture_output=True,
                text=True,
                timeout=30,
                check=False,
            )
        except FileNotFoundError:
            LOGGER.warning("⚠️  `ruff` not found on PATH — skipping Ruff config validation.")
            return True  # Soft fail: don't block if ruff isn't installed
        except subprocess.TimeoutExpired:
            LOGGER.warning("⚠️  Ruff config validation timed out — skipping.")
            return True  # Soft fail on timeout

        # Exit 0 = no issues found, exit 1 = issues found — both mean ruff
        # parsed the config successfully. Exit 2 = config/usage error.
        if result.returncode not in (0, 1):
            LOGGER.error(
                f"❌ Ruff rejected the merged config (exit {result.returncode}):\n"
                f"{result.stderr.strip()}"
            )
            return False

        # If strict mode is enabled, check stderr for configuration warnings
        if strict and result.stderr:
            # Ruff emits "warning: ..." or "deprecated ..." to stderr for config issues
            # that aren't fatal enough to cause exit code 2.
            lower_stderr = result.stderr.lower()
            if "warning:" in lower_stderr or "deprecated" in lower_stderr:
                LOGGER.error(
                    "❌ Ruff validation warning(s) detected in strict mode:\n"
                    f"{result.stderr.strip()}"
                )
                return False

        return True

validate_merged_config

validate_merged_config(
    doc, is_ruff_toml=False, strict=False, exclude=()
)

Run all validation checks on the merged TOML document.

Returns True only if all checks pass. Returns False and logs errors if any check fails.

Parameters:

Name Type Description Default
doc TOMLDocument

The merged TOML document to validate.

required
is_ruff_toml bool

True if the document is a standalone ruff.toml.

False
strict bool

If True, treat configuration warnings as hard failures.

False
exclude Iterable[str]

List of keys excluded from the ruff configuration.

()

Returns:

Type Description
bool

True if all validation checks pass, False otherwise.

Source code in src/ruff_sync/validation.py
def validate_merged_config(
    doc: TOMLDocument,
    is_ruff_toml: bool = False,
    strict: bool = False,
    exclude: Iterable[str] = (),
) -> bool:
    """Run all validation checks on the merged TOML document.

    Returns True only if all checks pass. Returns False and logs errors
    if any check fails.

    Args:
        doc: The merged TOML document to validate.
        is_ruff_toml: True if the document is a standalone ruff.toml.
        strict: If True, treat configuration warnings as hard failures.
        exclude: List of keys excluded from the ruff configuration.

    Returns:
        True if all validation checks pass, False otherwise.
    """
    if not validate_toml_syntax(doc):
        return False
    if not validate_ruff_accepts_config(doc, is_ruff_toml=is_ruff_toml, strict=strict):
        return False

    version_ok = True
    if not is_ruff_toml:
        version_ok = check_python_version_consistency(doc, strict=strict, exclude=exclude)

    deprecated_ok = check_deprecated_rules(
        doc, is_ruff_toml=is_ruff_toml, strict=strict, exclude=exclude
    )

    return version_ok and deprecated_ok