Skip to content

Builder plugins


See the documentation for build configuration.

BuilderInterface

Example usage:

from hatchling.builders.plugin.interface import BuilderInterface


class SpecialBuilder(BuilderInterface):
    PLUGIN_NAME = 'special'
    ...
from hatchling.plugin import hookimpl

from .plugin import SpecialBuilder


@hookimpl
def hatch_register_builder():
    return SpecialBuilder
Source code in hatchling/builders/plugin/interface.py
class BuilderInterface(ABC):
    """
    Example usage:

    === ":octicons-file-code-16: plugin.py"

        ```python
        from hatchling.builders.plugin.interface import BuilderInterface


        class SpecialBuilder(BuilderInterface):
            PLUGIN_NAME = 'special'
            ...
        ```

    === ":octicons-file-code-16: hooks.py"

        ```python
        from hatchling.plugin import hookimpl

        from .plugin import SpecialBuilder


        @hookimpl
        def hatch_register_builder():
            return SpecialBuilder
        ```
    """

    PLUGIN_NAME = ''
    """The name used for selection."""

    def __init__(self, root, plugin_manager=None, config=None, metadata=None, app=None):
        self.__root = root
        self.__plugin_manager = plugin_manager
        self.__raw_config = config
        self.__metadata = metadata
        self.__app = app
        self.__config = None
        self.__project_config = None
        self.__hatch_config = None
        self.__build_config = None
        self.__build_targets = None
        self.__target_config = None

        # Metadata
        self.__project_id = None

    def build(
        self,
        directory=None,
        versions=None,
        hooks_only=None,
        clean=None,
        clean_hooks_after=None,
        clean_only=False,
    ) -> Generator[str, None, None]:
        # Fail early for invalid project metadata
        self.metadata.validate_fields()

        if directory is None:
            if BuildEnvVars.LOCATION in os.environ:
                directory = self.config.normalize_build_directory(os.environ[BuildEnvVars.LOCATION])
            else:
                directory = self.config.directory

        if not os.path.isdir(directory):
            os.makedirs(directory)

        version_api = self.get_version_api()

        if not versions:
            versions = self.config.versions
        else:
            unknown_versions = set(versions) - set(version_api)
            if unknown_versions:
                raise ValueError(
                    f'Unknown versions for target `{self.PLUGIN_NAME}`: {", ".join(map(str, sorted(unknown_versions)))}'
                )

        if hooks_only is None:
            hooks_only = env_var_enabled(BuildEnvVars.HOOKS_ONLY)

        configured_build_hooks = self.get_build_hooks(directory)
        build_hooks = list(configured_build_hooks.values())

        if clean_only:
            clean = True
        elif clean is None:
            clean = env_var_enabled(BuildEnvVars.CLEAN)
        if clean:
            if not hooks_only:
                self.clean(directory, versions)

            for build_hook in build_hooks:
                build_hook.clean(versions)

            if clean_only:
                return

        if clean_hooks_after is None:
            clean_hooks_after = env_var_enabled(BuildEnvVars.CLEAN_HOOKS_AFTER)

        for version in versions:
            self.app.display_debug(f'Building `{self.PLUGIN_NAME}` version `{version}`')

            build_data = self.get_default_build_data()
            self.set_build_data_defaults(build_data)

            # Allow inspection of configured build hooks and the order in which they run
            build_data['build_hooks'] = tuple(configured_build_hooks)

            # Execute all `initialize` build hooks
            for build_hook in build_hooks:
                build_hook.initialize(version, build_data)

            if hooks_only:
                self.app.display_debug(f'Only ran build hooks for `{self.PLUGIN_NAME}` version `{version}`')
                continue

            # Build the artifact
            with self.config.set_build_data(build_data):
                artifact = version_api[version](directory, **build_data)

            # Execute all `finalize` build hooks
            for build_hook in build_hooks:
                build_hook.finalize(version, build_data, artifact)

            if clean_hooks_after:
                for build_hook in build_hooks:
                    build_hook.clean([version])

            yield artifact

    def recurse_included_files(self) -> Generator[IncludedFile, None, None]:
        """
        Returns a consistently generated series of file objects for every file that should be distributed. Each file
        object has three `str` attributes:

        - `path` - the absolute path
        - `relative_path` - the path relative to the project root; will be an empty string for external files
        - `distribution_path` - the path to be distributed as
        """
        if self.config.only_include:
            for explicit_file in self.recurse_explicit_files(self.config.only_include):
                yield explicit_file
        else:
            for project_file in self.recurse_project_files():
                yield project_file

        for explicit_file in self.recurse_forced_files(self.config.get_force_include()):
            yield explicit_file

    def recurse_project_files(self) -> Generator[IncludedFile, None, None]:
        for root, dirs, files in safe_walk(self.root):
            relative_path = get_relative_path(root, self.root)

            dirs[:] = sorted(d for d in dirs if not self.config.directory_is_excluded(d, relative_path))

            files.sort()
            is_package = '__init__.py' in files
            for f in files:
                relative_file_path = os.path.join(relative_path, f)
                if self.config.include_path(relative_file_path, is_package=is_package):
                    yield IncludedFile(
                        os.path.join(root, f), relative_file_path, self.config.get_distribution_path(relative_file_path)
                    )

    def recurse_forced_files(self, inclusion_map) -> Generator[IncludedFile, None, None]:
        for source, target_path in inclusion_map.items():
            external = not source.startswith(self.root)
            if os.path.isfile(source):
                yield IncludedFile(
                    source,
                    '' if external else os.path.relpath(source, self.root),
                    self.config.get_distribution_path(target_path),
                )
            elif os.path.isdir(source):
                for root, dirs, files in safe_walk(source):
                    relative_path = get_relative_path(root, source)

                    dirs[:] = sorted(d for d in dirs if d not in EXCLUDED_DIRECTORIES)

                    files.sort()
                    for f in files:
                        relative_file_path = os.path.join(relative_path, f)
                        distribution_path = os.path.join(target_path, relative_file_path)
                        if not self.config.path_is_reserved(distribution_path):
                            yield IncludedFile(
                                os.path.join(root, f),
                                '' if external else os.path.relpath(relative_file_path, self.root),
                                self.config.get_distribution_path(distribution_path),
                            )

    def recurse_explicit_files(self, inclusion_map) -> Generator[IncludedFile, None, None]:
        for source, target_path in inclusion_map.items():
            external = not source.startswith(self.root)
            if os.path.isfile(source):
                yield IncludedFile(
                    source,
                    '' if external else os.path.relpath(source, self.root),
                    self.config.get_distribution_path(target_path),
                )
            elif os.path.isdir(source):
                for root, dirs, files in safe_walk(source):
                    relative_path = get_relative_path(root, source)

                    dirs[:] = sorted(d for d in dirs if d not in EXCLUDED_DIRECTORIES)

                    files.sort()
                    is_package = '__init__.py' in files
                    for f in files:
                        relative_file_path = os.path.join(relative_path, f)
                        distribution_path = os.path.join(target_path, relative_file_path)
                        if self.config.include_path(distribution_path, explicit=True, is_package=is_package):
                            yield IncludedFile(
                                os.path.join(root, f),
                                '' if external else os.path.relpath(relative_file_path, self.root),
                                self.config.get_distribution_path(distribution_path),
                            )

    @property
    def root(self):
        """
        The root of the project tree.
        """
        return self.__root

    @property
    def plugin_manager(self):
        if self.__plugin_manager is None:
            from hatchling.plugin.manager import PluginManager

            self.__plugin_manager = PluginManager()

        return self.__plugin_manager

    @property
    def metadata(self):
        if self.__metadata is None:
            from hatchling.metadata.core import ProjectMetadata

            self.__metadata = ProjectMetadata(self.root, self.plugin_manager, self.__raw_config)

        return self.__metadata

    @property
    def app(self):
        """
        An instance of [Application](../utilities.md#hatchling.bridge.app.Application).
        """
        if self.__app is None:
            from hatchling.bridge.app import Application

            self.__app = Application().get_safe_application()

        return self.__app

    @property
    def raw_config(self):
        if self.__raw_config is None:
            self.__raw_config = self.metadata.config

        return self.__raw_config

    @property
    def project_config(self):
        if self.__project_config is None:
            self.__project_config = self.metadata.core.config

        return self.__project_config

    @property
    def hatch_config(self):
        if self.__hatch_config is None:
            self.__hatch_config = self.metadata.hatch.config

        return self.__hatch_config

    @property
    def config(self):
        """
        An instance of [BuilderConfig](../utilities.md#hatchling.builders.config.BuilderConfig).
        """
        if self.__config is None:
            self.__config = self.get_config_class()(
                self, self.root, self.PLUGIN_NAME, self.build_config, self.target_config
            )

        return self.__config

    @property
    def build_config(self):
        """
        === ":octicons-file-code-16: pyproject.toml"

            ```toml
            [tool.hatch.build]
            ```

        === ":octicons-file-code-16: hatch.toml"

            ```toml
            [build]
            ```
        """
        if self.__build_config is None:
            self.__build_config = self.metadata.hatch.build_config

        return self.__build_config

    @property
    def target_config(self):
        """
        === ":octicons-file-code-16: pyproject.toml"

            ```toml
            [tool.hatch.build.targets.<PLUGIN_NAME>]
            ```

        === ":octicons-file-code-16: hatch.toml"

            ```toml
            [build.targets.<PLUGIN_NAME>]
            ```
        """
        if self.__target_config is None:
            target_config = self.metadata.hatch.build_targets.get(self.PLUGIN_NAME, {})
            if not isinstance(target_config, dict):
                raise TypeError(f'Field `tool.hatch.build.targets.{self.PLUGIN_NAME}` must be a table')

            self.__target_config = target_config

        return self.__target_config

    @property
    def project_id(self):
        if self.__project_id is None:
            # https://discuss.python.org/t/clarify-naming-of-dist-info-directories/5565
            self.__project_id = f'{self.normalize_file_name_component(self.metadata.core.name)}-{self.metadata.version}'

        return self.__project_id

    def get_build_hooks(self, directory):
        configured_build_hooks = {}
        for hook_name, config in self.config.hook_config.items():
            build_hook = self.plugin_manager.build_hook.get(hook_name)
            if build_hook is None:
                from hatchling.plugin.exceptions import UnknownPluginError

                raise UnknownPluginError(f'Unknown build hook: {hook_name}')

            configured_build_hooks[hook_name] = build_hook(
                self.root, config, self.config, self.metadata, directory, self.PLUGIN_NAME, self.app
            )

        return configured_build_hooks

    @abstractmethod
    def get_version_api(self) -> dict[str, Callable]:
        """
        A mapping of `str` versions to a callable that is used for building.
        Each callable must have the following signature:

        ```python
        def ...(build_dir: str, build_data: dict) -> str:
        ```

        The return value must be the absolute path to the built artifact.
        """

    def get_default_versions(self):
        """
        A list of versions to build when users do not specify any, defaulting to all versions.
        """
        return list(self.get_version_api())

    def get_default_build_data(self):
        """
        A mapping that can be modified by [build hooks](../build-hook/reference.md) to influence the behavior of builds.
        """
        return {}

    def set_build_data_defaults(self, build_data):
        build_data.setdefault('artifacts', [])
        build_data.setdefault('force_include', {})

    def clean(self, directory, versions):
        """
        Called before builds if the `-c`/`--clean` flag was passed to the
        [`build`](../../cli/reference.md#hatch-build) command.
        """

    @classmethod
    def get_config_class(cls):
        """
        Must return a subclass of [BuilderConfig](../utilities.md#hatchling.builders.config.BuilderConfig).
        """
        return BuilderConfig

    @staticmethod
    def normalize_file_name_component(file_name):
        """
        https://peps.python.org/pep-0427/#escaping-and-unicode
        """
        return re.sub(r'[^\w\d.]+', '_', file_name, re.UNICODE)

PLUGIN_NAME = '' class-attribute

The name used for selection.

app() property

An instance of Application.

Source code in hatchling/builders/plugin/interface.py
@property
def app(self):
    """
    An instance of [Application](../utilities.md#hatchling.bridge.app.Application).
    """
    if self.__app is None:
        from hatchling.bridge.app import Application

        self.__app = Application().get_safe_application()

    return self.__app

root() property

The root of the project tree.

Source code in hatchling/builders/plugin/interface.py
@property
def root(self):
    """
    The root of the project tree.
    """
    return self.__root

build_config() property

[tool.hatch.build]
[build]
Source code in hatchling/builders/plugin/interface.py
@property
def build_config(self):
    """
    === ":octicons-file-code-16: pyproject.toml"

        ```toml
        [tool.hatch.build]
        ```

    === ":octicons-file-code-16: hatch.toml"

        ```toml
        [build]
        ```
    """
    if self.__build_config is None:
        self.__build_config = self.metadata.hatch.build_config

    return self.__build_config

target_config() property

[tool.hatch.build.targets.<PLUGIN_NAME>]
[build.targets.<PLUGIN_NAME>]
Source code in hatchling/builders/plugin/interface.py
@property
def target_config(self):
    """
    === ":octicons-file-code-16: pyproject.toml"

        ```toml
        [tool.hatch.build.targets.<PLUGIN_NAME>]
        ```

    === ":octicons-file-code-16: hatch.toml"

        ```toml
        [build.targets.<PLUGIN_NAME>]
        ```
    """
    if self.__target_config is None:
        target_config = self.metadata.hatch.build_targets.get(self.PLUGIN_NAME, {})
        if not isinstance(target_config, dict):
            raise TypeError(f'Field `tool.hatch.build.targets.{self.PLUGIN_NAME}` must be a table')

        self.__target_config = target_config

    return self.__target_config

config() property

An instance of BuilderConfig.

Source code in hatchling/builders/plugin/interface.py
@property
def config(self):
    """
    An instance of [BuilderConfig](../utilities.md#hatchling.builders.config.BuilderConfig).
    """
    if self.__config is None:
        self.__config = self.get_config_class()(
            self, self.root, self.PLUGIN_NAME, self.build_config, self.target_config
        )

    return self.__config

get_config_class() classmethod

Must return a subclass of BuilderConfig.

Source code in hatchling/builders/plugin/interface.py
@classmethod
def get_config_class(cls):
    """
    Must return a subclass of [BuilderConfig](../utilities.md#hatchling.builders.config.BuilderConfig).
    """
    return BuilderConfig

get_version_api() -> dict[str, Callable] abstractmethod

A mapping of str versions to a callable that is used for building. Each callable must have the following signature:

def ...(build_dir: str, build_data: dict) -> str:

The return value must be the absolute path to the built artifact.

Source code in hatchling/builders/plugin/interface.py
@abstractmethod
def get_version_api(self) -> dict[str, Callable]:
    """
    A mapping of `str` versions to a callable that is used for building.
    Each callable must have the following signature:

    ```python
    def ...(build_dir: str, build_data: dict) -> str:
    ```

    The return value must be the absolute path to the built artifact.
    """

get_default_versions()

A list of versions to build when users do not specify any, defaulting to all versions.

Source code in hatchling/builders/plugin/interface.py
def get_default_versions(self):
    """
    A list of versions to build when users do not specify any, defaulting to all versions.
    """
    return list(self.get_version_api())

clean(directory, versions)

Called before builds if the -c/--clean flag was passed to the build command.

Source code in hatchling/builders/plugin/interface.py
def clean(self, directory, versions):
    """
    Called before builds if the `-c`/`--clean` flag was passed to the
    [`build`](../../cli/reference.md#hatch-build) command.
    """

recurse_included_files() -> Generator[IncludedFile, None, None]

Returns a consistently generated series of file objects for every file that should be distributed. Each file object has three str attributes:

  • path - the absolute path
  • relative_path - the path relative to the project root; will be an empty string for external files
  • distribution_path - the path to be distributed as
Source code in hatchling/builders/plugin/interface.py
def recurse_included_files(self) -> Generator[IncludedFile, None, None]:
    """
    Returns a consistently generated series of file objects for every file that should be distributed. Each file
    object has three `str` attributes:

    - `path` - the absolute path
    - `relative_path` - the path relative to the project root; will be an empty string for external files
    - `distribution_path` - the path to be distributed as
    """
    if self.config.only_include:
        for explicit_file in self.recurse_explicit_files(self.config.only_include):
            yield explicit_file
    else:
        for project_file in self.recurse_project_files():
            yield project_file

    for explicit_file in self.recurse_forced_files(self.config.get_force_include()):
        yield explicit_file

get_default_build_data()

A mapping that can be modified by build hooks to influence the behavior of builds.

Source code in hatchling/builders/plugin/interface.py
def get_default_build_data(self):
    """
    A mapping that can be modified by [build hooks](../build-hook/reference.md) to influence the behavior of builds.
    """
    return {}

Last update: July 2, 2022