Skip to content

slap check

Check your project configuration for errors, warnings or recommendations.

Configuration

Option scope: [tool.slap.check] or [check]

Option Type Default Description
plugins list[str] ["changelog", "general", "poetry", "release"] A list of check plugins to use. Note that the Poetry plugin only fire checks if your project appears to be using Poetry, so there is no harm in leaving it enabled even if you don't it. Additional plugins can be registered via an ApplicationPlugin under the CheckPlugin group.

Built-in check plugins

slap.ext.checks.changelog.ChangelogValidationCheckPlugin dataclass

Bases: CheckPlugin

This check plugin validates the structured changelog files, if any.

Plugin ID: changelog

Source code in slap/ext/checks/changelog.py
@dataclasses.dataclass
class ChangelogValidationCheckPlugin(CheckPlugin):
    """This check plugin validates the structured changelog files, if any.

    Plugin ID: `changelog`"""

    def get_project_checks(self, project: Project) -> t.Iterable[Check]:
        return get_checks(self, project)

    @check("validate")
    def _validate_changelogs(self, project: Project) -> tuple[CheckResult, str | None, str | None]:
        import tomli
        from databind.core.converter import ConversionError

        manager = get_changelog_manager(project.repository, project)
        bad_files = []
        bad_changelogs = []
        count = 0
        for changelog in manager.all():
            count += 1
            try:
                for entry in changelog.load().entries:
                    try:
                        manager.validate_entry(entry)
                    except (ConversionError, ValueError) as exc:
                        bad_changelogs.append((changelog.path.name, str(exc), entry.id))
            except (tomli.TOMLDecodeError, ConversionError) as exc:
                bad_files.append((changelog.path.name, str(exc)))

        if not count:
            return CheckResult.SKIPPED, None, None

        return (
            Check.ERROR if bad_changelogs else Check.Result.OK,
            "Broken or invalid changelogs" if bad_changelogs else f"All {count} changelogs are valid.",
            (
                "\n".join(f"<i>{fn}</i>: {err}" for fn, err in bad_files)
                if bad_files
                else (
                    ""
                    + "\n"
                    + "\n".join(
                        f'<i>{fn}</i>: id=<fg=yellow>"{entry_id}"</fg>: {err}' for fn, err, entry_id in bad_changelogs
                    )
                    if bad_changelogs
                    else ""
                )
            ).strip()
            or None,
        )

slap.ext.checks.general.GeneralChecksPlugin

Bases: CheckPlugin

This plugin provides general checks applicable to all types of projects managed with Slap.

Plugin ID: general.

Source code in slap/ext/checks/general.py
class GeneralChecksPlugin(CheckPlugin):
    """This plugin provides general checks applicable to all types of projects managed with Slap.

    Plugin ID: `general`."""

    # TODO (@NiklasRosenstein): Check if VCS remote is configured?

    def get_project_checks(self, project: Project) -> t.Iterable[Check]:
        return get_checks(self, project)

    @check("packages")
    def _check_detect_packages(self, project: Project) -> tuple[CheckResult, str | None]:
        """Checks if the project handler employed by Slap for your project is detecting any Python packages. If no
        Python packages can be detected, it might hint at a configuration issue."""

        packages = project.packages()
        result = Check.Result.SKIPPED if packages is None else Check.Result.OK if packages else Check.Result.ERROR
        message = "Detected " + ", ".join(f"<b>{p.root}/{p.name}</b>" for p in packages) if packages else None
        return result, message

    @check("typed")
    def _check_py_typed(self, project: Project) -> tuple[CheckResult, str]:
        expect_typed = project.config().typed
        if expect_typed is None:
            return Check.Result.WARNING, "<b>tool.slap.typed</b> is not set"

        has_py_typed = set[str]()
        has_no_py_typed = set[str]()
        for package in project.packages() or []:
            (has_py_typed if (package.path / "py.typed").is_file() else has_no_py_typed).add(package.name)

        if expect_typed and has_no_py_typed:
            error = True
            message = f'<b>py.typed</b> missing in package(s) <b>{", ".join(has_no_py_typed)}</b>'
        elif not expect_typed and has_py_typed:
            error = True
            message = f'<b>py.typed</b> in package(s) should not exist <b>{", ".join(has_py_typed)}</b>'
        else:
            error = False
            message = (
                "<b>py.typed</b> exists as expected" if expect_typed else "<b>py.typed</b> does not exist as expected"
            )

        return (Check.Result.ERROR if error else Check.Result.OK, message)

slap.ext.checks.poetry.PoetryChecksPlugin

Bases: CheckPlugin

Check plugin to validate the Poetry configuration and compare it with Slap's expectations.

Plugin ID: poetry

Source code in slap/ext/checks/poetry.py
class PoetryChecksPlugin(CheckPlugin):
    """Check plugin to validate the Poetry configuration and compare it with Slap's expectations.

    Plugin ID: `poetry`"""

    def get_project_checks(self, project: Project) -> t.Iterable[Check]:
        self.project = project
        pyproject: dict[str, t.Any] = project.pyproject_toml.value_or({})
        if isinstance(project.handler(), PoetryProjectHandler):
            self.poetry = pyproject.get("tool", {}).get("poetry")
            if self.poetry is None:
                yield Check(
                    "config", CheckResult.ERROR, "No [tool.poetry] configuration in <code>pyproject.toml</code>"
                )
                return
            yield from get_checks(self, project)

    @check("readme")
    def get_readme_check(self, project: Project) -> tuple[CheckResult, str]:
        """Checks if Poetry will be able to pick up the right readme file."""

        default_readmes = ["README.md", "README.rst"]
        detected_readme = (
            Optional(get_readme_path(self.project))
            .map(lambda p: str(p.resolve().relative_to(Path.cwd())))
            .or_else(None)
        )
        poetry_readme = self.poetry.get("readme")
        if poetry_readme is None and detected_readme in default_readmes:
            return Check.Result.OK, f"Poetry will autodetect your readme (<b>{detected_readme}</b>)"
        if poetry_readme == detected_readme:
            return Check.Result.OK, f"Poetry readme is configured correctly (path: <b>{detected_readme}</b>)"
        return (
            Check.Result.WARNING,
            f"Poetry readme appears to be misconfigured (detected: <b>{detected_readme}</b>, "
            f"configured: <b>{poetry_readme}</b>)",
        )

    @check("urls")
    def get_urls_check(self, project: Project) -> tuple[CheckResult, str]:
        """Checks if URLs are configured in the Poetry configuration and recommends to configure the `Homepage`,
        `Repository`, `Documentation` and `Bug Tracker` URLs under `[tool.poetry.urls]`."""

        has_homepage = "homepage" in self.poetry or "homepage" in {
            x.lower() for x in self.poetry.get("urls", {}).keys()
        }
        has_repository = "repository" in {x.lower() for x in self.poetry.get("urls", {}).keys()}
        has_documentation = "documentation" in {x.lower() for x in self.poetry.get("urls", {}).keys()}
        has_bug_tracker = "bug tracker" in {x.lower() for x in self.poetry.get("urls", {}).keys()}

        if has_homepage and has_repository and has_documentation and has_bug_tracker:
            return Check.OK, "Your project URLs are in top condition."
        else:
            missing = [
                k
                for k, v in {
                    "Homepage": has_homepage,
                    "Repository": has_repository,
                    "Documentation": has_documentation,
                    "Bug Tracker": has_bug_tracker,
                }.items()
                if not v
            ]
            result = Check.RECOMMENDATION if has_homepage else Check.WARNING
            message = "Please configure the following URLs: " + ", ".join(f'<s>"{k}"</s>' for k in missing)
            return result, message

    @check("classifiers")
    def get_classifiers_check(self, project: Project) -> tuple[CheckResult, str]:
        """Checks if all Python package classifiers are valid and recommends to configure them if none are set."""

        # TODO: Check for recommended classifier topics (Development State, Environment,
        #       Programming Language, Topic, Typing, etc.)
        classifiers = self.poetry.get("classifiers")  # TODO: Support classifiers in [project]
        if not classifiers:
            return Check.RECOMMENDATION, "Please configure classifiers."
        else:
            try:
                good_classifiers = get_classifiers()
            except requests.RequestException as exc:
                return Check.WARNING, f"Could not validate classifiers because list could not be fetched ({exc})"
            else:
                bad_classifiers = set(classifiers) - set(good_classifiers)
                if bad_classifiers:
                    return Check.ERROR, "Found bad classifiers: " + ",".join(f'<s>"{c}"</s>' for c in bad_classifiers)
                else:
                    return Check.OK, "All classifiers are valid."

    @check("license")
    def get_license_check(self, project: Project) -> tuple[CheckResult, str]:
        """Checks if package license is a valid SPDX license identifier and recommends to configure a license if
        none is set."""

        from slap.util.external.licenses import get_spdx_licenses

        license = self.poetry.get("license")
        if not license:
            return Check.ERROR, "Missing license"
        else:
            if license not in get_spdx_licenses():
                return Check.WARNING, f'License <s>"{license}"</s> is not a known SPDX license identifier.'
            else:
                return Check.OK, f'License <s>"{license}"</s> is a valid SPDX identifier.'

get_classifiers_check

get_classifiers_check(project: Project) -> tuple[CheckResult, str]

Checks if all Python package classifiers are valid and recommends to configure them if none are set.

Source code in slap/ext/checks/poetry.py
@check("classifiers")
def get_classifiers_check(self, project: Project) -> tuple[CheckResult, str]:
    """Checks if all Python package classifiers are valid and recommends to configure them if none are set."""

    # TODO: Check for recommended classifier topics (Development State, Environment,
    #       Programming Language, Topic, Typing, etc.)
    classifiers = self.poetry.get("classifiers")  # TODO: Support classifiers in [project]
    if not classifiers:
        return Check.RECOMMENDATION, "Please configure classifiers."
    else:
        try:
            good_classifiers = get_classifiers()
        except requests.RequestException as exc:
            return Check.WARNING, f"Could not validate classifiers because list could not be fetched ({exc})"
        else:
            bad_classifiers = set(classifiers) - set(good_classifiers)
            if bad_classifiers:
                return Check.ERROR, "Found bad classifiers: " + ",".join(f'<s>"{c}"</s>' for c in bad_classifiers)
            else:
                return Check.OK, "All classifiers are valid."

get_license_check

get_license_check(project: Project) -> tuple[CheckResult, str]

Checks if package license is a valid SPDX license identifier and recommends to configure a license if none is set.

Source code in slap/ext/checks/poetry.py
@check("license")
def get_license_check(self, project: Project) -> tuple[CheckResult, str]:
    """Checks if package license is a valid SPDX license identifier and recommends to configure a license if
    none is set."""

    from slap.util.external.licenses import get_spdx_licenses

    license = self.poetry.get("license")
    if not license:
        return Check.ERROR, "Missing license"
    else:
        if license not in get_spdx_licenses():
            return Check.WARNING, f'License <s>"{license}"</s> is not a known SPDX license identifier.'
        else:
            return Check.OK, f'License <s>"{license}"</s> is a valid SPDX identifier.'

get_readme_check

get_readme_check(project: Project) -> tuple[CheckResult, str]

Checks if Poetry will be able to pick up the right readme file.

Source code in slap/ext/checks/poetry.py
@check("readme")
def get_readme_check(self, project: Project) -> tuple[CheckResult, str]:
    """Checks if Poetry will be able to pick up the right readme file."""

    default_readmes = ["README.md", "README.rst"]
    detected_readme = (
        Optional(get_readme_path(self.project))
        .map(lambda p: str(p.resolve().relative_to(Path.cwd())))
        .or_else(None)
    )
    poetry_readme = self.poetry.get("readme")
    if poetry_readme is None and detected_readme in default_readmes:
        return Check.Result.OK, f"Poetry will autodetect your readme (<b>{detected_readme}</b>)"
    if poetry_readme == detected_readme:
        return Check.Result.OK, f"Poetry readme is configured correctly (path: <b>{detected_readme}</b>)"
    return (
        Check.Result.WARNING,
        f"Poetry readme appears to be misconfigured (detected: <b>{detected_readme}</b>, "
        f"configured: <b>{poetry_readme}</b>)",
    )

get_urls_check

get_urls_check(project: Project) -> tuple[CheckResult, str]

Checks if URLs are configured in the Poetry configuration and recommends to configure the Homepage, Repository, Documentation and Bug Tracker URLs under [tool.poetry.urls].

Source code in slap/ext/checks/poetry.py
@check("urls")
def get_urls_check(self, project: Project) -> tuple[CheckResult, str]:
    """Checks if URLs are configured in the Poetry configuration and recommends to configure the `Homepage`,
    `Repository`, `Documentation` and `Bug Tracker` URLs under `[tool.poetry.urls]`."""

    has_homepage = "homepage" in self.poetry or "homepage" in {
        x.lower() for x in self.poetry.get("urls", {}).keys()
    }
    has_repository = "repository" in {x.lower() for x in self.poetry.get("urls", {}).keys()}
    has_documentation = "documentation" in {x.lower() for x in self.poetry.get("urls", {}).keys()}
    has_bug_tracker = "bug tracker" in {x.lower() for x in self.poetry.get("urls", {}).keys()}

    if has_homepage and has_repository and has_documentation and has_bug_tracker:
        return Check.OK, "Your project URLs are in top condition."
    else:
        missing = [
            k
            for k, v in {
                "Homepage": has_homepage,
                "Repository": has_repository,
                "Documentation": has_documentation,
                "Bug Tracker": has_bug_tracker,
            }.items()
            if not v
        ]
        result = Check.RECOMMENDATION if has_homepage else Check.WARNING
        message = "Please configure the following URLs: " + ", ".join(f'<s>"{k}"</s>' for k in missing)
        return result, message

slap.ext.checks.release.ReleaseChecksPlugin

Bases: CheckPlugin

Performs some checks relevant for the slap release command.

Source code in slap/ext/checks/release.py
class ReleaseChecksPlugin(CheckPlugin):
    """Performs some checks relevant for the `slap release` command."""

    def get_project_checks(self, project: Project) -> t.Iterable[Check]:
        return get_checks(self, project)

    def get_application_checks(self, app: Application) -> t.Iterable[Check]:
        return get_checks(self, app)

    @check("source-code-version")
    def check_packages_have_source_code_version(self, project: Project) -> tuple[CheckResult, str]:
        """Checks if all Python packages in the project have a version defined in the source code."""

        if not project.packages():
            return Check.WARNING, "No packages detected"

        matcher = SourceCodeVersionReferencesPlugin()
        matcher.io = NullIO()
        version_refs = matcher.get_version_refs(project)
        packages_without_version = {p.name for p in project.packages() or []}

        for ref in version_refs:
            for package in project.packages() or []:
                if ref.file.is_relative_to(package.path):
                    packages_without_version.discard(package.name)

        return (
            Check.ERROR if packages_without_version else Check.OK,
            (
                (f'The following packages have no <b>__version__</b>: <b>{", ".join(packages_without_version)}</b>')
                if packages_without_version
                else f'Found <b>__version__</b> in <b>{", ".join(x.name for x in project.packages() or [])}</b>'
            ),
        )

    @check("consistent-versions")
    def check_version_number_consistency(self, app: Application) -> tuple[CheckResult, str]:
        """Checks if the version numbers in the project source code, project configuration and any other instances
        that are detected by release plugins or in the `[tool.slap.release].references` option are consistent."""

        releaser = ReleaseCommandPlugin(app)
        releaser.load_configuration(app)

        version_refs = releaser._get_version_refs()
        cardinality = len(set(r.value for r in version_refs))

        if cardinality == 0:
            result = Check.WARNING
            message = "No version references found"
        elif cardinality == 1:
            result = Check.OK
            message = "All version references are equal"
        else:
            result = Check.ERROR
            message = f"Found <b>{cardinality}</b> differing version references"

        return result, message

check_packages_have_source_code_version

check_packages_have_source_code_version(project: Project) -> tuple[CheckResult, str]

Checks if all Python packages in the project have a version defined in the source code.

Source code in slap/ext/checks/release.py
@check("source-code-version")
def check_packages_have_source_code_version(self, project: Project) -> tuple[CheckResult, str]:
    """Checks if all Python packages in the project have a version defined in the source code."""

    if not project.packages():
        return Check.WARNING, "No packages detected"

    matcher = SourceCodeVersionReferencesPlugin()
    matcher.io = NullIO()
    version_refs = matcher.get_version_refs(project)
    packages_without_version = {p.name for p in project.packages() or []}

    for ref in version_refs:
        for package in project.packages() or []:
            if ref.file.is_relative_to(package.path):
                packages_without_version.discard(package.name)

    return (
        Check.ERROR if packages_without_version else Check.OK,
        (
            (f'The following packages have no <b>__version__</b>: <b>{", ".join(packages_without_version)}</b>')
            if packages_without_version
            else f'Found <b>__version__</b> in <b>{", ".join(x.name for x in project.packages() or [])}</b>'
        ),
    )

check_version_number_consistency

check_version_number_consistency(app: Application) -> tuple[CheckResult, str]

Checks if the version numbers in the project source code, project configuration and any other instances that are detected by release plugins or in the [tool.slap.release].references option are consistent.

Source code in slap/ext/checks/release.py
@check("consistent-versions")
def check_version_number_consistency(self, app: Application) -> tuple[CheckResult, str]:
    """Checks if the version numbers in the project source code, project configuration and any other instances
    that are detected by release plugins or in the `[tool.slap.release].references` option are consistent."""

    releaser = ReleaseCommandPlugin(app)
    releaser.load_configuration(app)

    version_refs = releaser._get_version_refs()
    cardinality = len(set(r.value for r in version_refs))

    if cardinality == 0:
        result = Check.WARNING
        message = "No version references found"
    elif cardinality == 1:
        result = Check.OK
        message = "All version references are equal"
    else:
        result = Check.ERROR
        message = f"Found <b>{cardinality}</b> differing version references"

    return result, message