From 2573c89e107ab215b8ad3ba06bcda63484999cd8 Mon Sep 17 00:00:00 2001 From: "Philipp A." Date: Tue, 21 Jul 2026 12:36:14 +0200 Subject: [PATCH 1/4] fix: call hooks from project --- backend/src/hatchling/utils/context.py | 6 +- docs/history/hatch.md | 4 ++ src/hatch/env/plugin/interface.py | 90 ++++++++++++++------------ tests/env/plugin/test_interface.py | 64 ++++++++++++++++++ 4 files changed, 120 insertions(+), 44 deletions(-) diff --git a/backend/src/hatchling/utils/context.py b/backend/src/hatchling/utils/context.py index bd18138b8..acbeb0b99 100644 --- a/backend/src/hatchling/utils/context.py +++ b/backend/src/hatchling/utils/context.py @@ -14,6 +14,8 @@ class ContextFormatter(ABC): + CONTEXT_NAME: str + @abstractmethod def get_formatters(self) -> MutableMapping: """ @@ -109,7 +111,7 @@ def __init__(self, root: str) -> None: def format(self, *args: Any, **kwargs: Any) -> str: return self.__formatter.format(*args, **kwargs) - def add_context(self, context: DefaultContextFormatter) -> None: + def add_context(self, context: ContextFormatter) -> None: if context.CONTEXT_NAME in self.__configured_contexts: return @@ -117,7 +119,7 @@ def add_context(self, context: DefaultContextFormatter) -> None: self.__configured_contexts.add(context.CONTEXT_NAME) @contextmanager - def apply_context(self, context: DefaultContextFormatter) -> Iterator: + def apply_context(self, context: ContextFormatter) -> Iterator: self.__add_formatters(context.get_formatters()) try: yield diff --git a/docs/history/hatch.md b/docs/history/hatch.md index 70d506141..314a387cb 100644 --- a/docs/history/hatch.md +++ b/docs/history/hatch.md @@ -8,6 +8,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), ## Unreleased +***Fixed*** + +- Fix environment creation crashing when a metadata hook exists that doesn’t happen to be installed in the `hatch` CLI’s environment. + ## [1.17.1](https://github.com/pypa/hatch/releases/tag/hatch-v1.17.1) - 2026-07-08 ## {: #hatch-v1.17.1 } ***Fixed*** diff --git a/src/hatch/env/plugin/interface.py b/src/hatch/env/plugin/interface.py index 782e8db00..85124afb3 100644 --- a/src/hatch/env/plugin/interface.py +++ b/src/hatch/env/plugin/interface.py @@ -14,11 +14,15 @@ from hatch.utils.structures import EnvVars if TYPE_CHECKING: - from collections.abc import Generator, Iterable + from collections.abc import Generator, Iterable, Mapping + from hatch.cli.application import Application from hatch.dep.core import Dependency + from hatch.env.context import EnvironmentContextFormatter from hatch.project.core import Project from hatch.utils.fs import Path + from hatch.utils.platform import Platform + from hatchling.metadata.core import ProjectMetadata class EnvironmentInterface(ABC): @@ -80,14 +84,14 @@ def matrix_variables(self): return self.__matrix_variables @property - def app(self): + def app(self) -> Application: """ An instance of [Application](../utilities.md#hatchling.bridge.app.Application). """ return self.__app @cached_property - def context(self): + def context(self) -> EnvironmentContextFormatter: return self.get_context() @property @@ -102,7 +106,7 @@ def root(self): return self.__root @property - def metadata(self): + def metadata(self) -> ProjectMetadata: return self.__metadata @property @@ -113,7 +117,7 @@ def name(self) -> str: return self.__name @property - def platform(self): + def platform(self) -> Platform: """ An instance of [Platform](../utilities.md#hatch.utils.platform.Platform). """ @@ -185,7 +189,7 @@ def system_python(self) -> str: return system_python @cached_property - def env_vars(self) -> dict[str, str]: + def env_vars(self) -> Mapping[str, str]: """ ```toml config-example [tool.hatch.envs..env-vars] @@ -212,7 +216,7 @@ def env_vars(self) -> dict[str, str]: return new_env_vars @cached_property - def env_include(self) -> list[str]: + def env_include(self) -> tuple[str, ...]: """ ```toml config-example [tool.hatch.envs.] @@ -229,10 +233,10 @@ def env_include(self) -> list[str]: message = f"Pattern #{i} of field `tool.hatch.envs.{self.name}.env-include` must be a string" raise TypeError(message) - return ["HATCH_BUILD_*", *env_include] if env_include else env_include + return ("HATCH_BUILD_*", *env_include) if env_include else tuple(env_include) @cached_property - def env_exclude(self) -> list[str]: + def env_exclude(self) -> tuple[str, ...]: """ ```toml config-example [tool.hatch.envs.] @@ -249,10 +253,10 @@ def env_exclude(self) -> list[str]: message = f"Pattern #{i} of field `tool.hatch.envs.{self.name}.env-exclude` must be a string" raise TypeError(message) - return env_exclude + return tuple(env_exclude) @cached_property - def environment_dependencies_complex(self) -> list[Dependency]: + def environment_dependencies_complex(self) -> tuple[Dependency, ...]: from hatch.dep.core import Dependency, InvalidDependencyError dependencies_complex: list[Dependency] = [] @@ -274,17 +278,17 @@ def environment_dependencies_complex(self) -> list[Dependency]: message = f"Dependency #{i} of field `tool.hatch.envs.{self.name}.{option}` is invalid: {e}" raise ValueError(message) from None - return dependencies_complex + return tuple(dependencies_complex) @cached_property - def environment_dependencies(self) -> list[str]: + def environment_dependencies(self) -> tuple[str, ...]: """ The list of all [environment dependencies](../../config/environment/overview.md#dependencies). """ - return [str(dependency) for dependency in self.environment_dependencies_complex] + return tuple(str(dependency) for dependency in self.environment_dependencies_complex) @cached_property - def project_dependencies_complex(self) -> list[Dependency]: + def project_dependencies_complex(self) -> tuple[Dependency, ...]: workspace_dependencies = self.workspace.get_dependencies() if self.skip_install and not self.features and not self.dependency_groups and not workspace_dependencies: return [] @@ -327,20 +331,20 @@ def project_dependencies_complex(self) -> list[Dependency]: get_complex_dependency_group(self.app.project.dependency_groups, dependency_group) ) - return all_dependencies_complex + return tuple(all_dependencies_complex) @cached_property - def project_dependencies(self) -> list[str]: + def project_dependencies(self) -> tuple[str, ...]: """ The list of all [project dependencies](../../config/metadata.md#dependencies) (if [installed](../../config/environment/overview.md#skip-install)), selected [optional dependencies](../../config/environment/overview.md#features), and workspace dependencies. """ - return [str(dependency) for dependency in self.project_dependencies_complex] + return tuple(str(dependency) for dependency in self.project_dependencies_complex) @cached_property - def local_dependencies_complex(self) -> list[Dependency]: + def local_dependencies_complex(self) -> tuple[Dependency, ...]: from hatch.dep.core import Dependency local_dependencies_complex = [] @@ -354,10 +358,10 @@ def local_dependencies_complex(self) -> list[Dependency]: for member in self.workspace.members ) - return local_dependencies_complex + return tuple(local_dependencies_complex) @cached_property - def dependencies_complex(self) -> list[Dependency]: + def dependencies_complex(self) -> tuple[Dependency, ...]: from hatch.dep.core import Dependency all_dependencies_complex = list(self.environment_dependencies_complex) @@ -391,7 +395,7 @@ def dependencies_complex(self) -> list[Dependency]: target_config = self.app.project.config.build.target(target) all_dependencies_complex.extend(map(Dependency, target_config.dependencies)) - return all_dependencies_complex + return tuple(all_dependencies_complex) # Ensure these are checked last to speed up initial environment creation since # they will already be installed along with the project @@ -401,14 +405,14 @@ def dependencies_complex(self) -> list[Dependency]: return all_dependencies_complex @cached_property - def dependencies(self) -> list[str]: + def dependencies(self) -> tuple[str, ...]: """ The list of all [project dependencies](reference.md#hatch.env.plugin.interface.EnvironmentInterface.project_dependencies) (if in [dev mode](../../config/environment/overview.md#dev-mode)) and [environment dependencies](../../config/environment/overview.md#dependencies). """ - return [str(dependency) for dependency in self.dependencies_complex] + return tuple(str(dependency) for dependency in self.dependencies_complex) @cached_property def all_dependencies_complex(self) -> list[Dependency]: @@ -416,26 +420,28 @@ def all_dependencies_complex(self) -> list[Dependency]: local_deps = list(self.local_dependencies_complex) - # Map each locally-resolved package name to the metadata that owns its optional-dependencies, - # so extras on `[extra]` deps are expanded from the *referenced* package's own metadata - # rather than always the current project's. - local_metadata: dict[str, Any] = {} + # Map each locally-resolved package name to the project that owns its optional-dependencies, + # so extras on `[extra]` deps are expanded using the *referenced* project's own dependency + # resolution. `Project.get_dependencies` only evaluates metadata/version hooks (in that project's + # own build environment) when the relevant fields are actually dynamic, avoiding both dropped + # extras and spurious plugin errors from evaluating hooks in the wrong environment. + local_projects: dict[str, Project] = {} if not self.skip_install: - local_metadata[self.metadata.name.lower()] = self.metadata + local_projects[self.metadata.name.lower()] = self.app.project for member in self.workspace.members: - local_metadata[member.name.lower()] = member.project.metadata + local_projects[member.name.lower()] = member.project filtered_deps: list[Dependency] = [] for dep in self.dependencies_complex: dep_obj = dep if isinstance(dep, Dependency) else Dependency(str(dep)) name_lower = dep_obj.name.lower() - if name_lower in local_metadata and dep_obj.extras: - optional_dependencies = local_metadata[name_lower].core.optional_dependencies + if name_lower in local_projects and dep_obj.extras: + _, optional_dependencies = local_projects[name_lower].get_dependencies() for extra in dep_obj.extras: if extra in optional_dependencies: filtered_deps.extend(Dependency(d) for d in optional_dependencies[extra]) - elif name_lower not in local_metadata: + elif name_lower not in local_projects: filtered_deps.append(dep_obj) return local_deps + filtered_deps @@ -528,7 +534,7 @@ def builder(self) -> bool: return builder @cached_property - def features(self): + def features(self) -> tuple[str, ...]: from hatch.utils.metadata import normalize_project_name features = self.config.get("features", []) @@ -561,10 +567,10 @@ def features(self): all_features.add(normalized_feature) - return sorted(all_features) + return tuple(sorted(all_features)) @cached_property - def dependency_groups(self): + def dependency_groups(self) -> tuple[str, ...]: from hatch.utils.metadata import normalize_project_name dependency_groups = self.config.get("dependency-groups", []) @@ -597,7 +603,7 @@ def dependency_groups(self): all_dependency_groups.add(normalized_dependency_group) - return sorted(all_dependency_groups) + return tuple(sorted(all_dependency_groups)) @cached_property def description(self) -> str: @@ -661,7 +667,7 @@ def scripts(self): return config @cached_property - def pre_install_commands(self): + def pre_install_commands(self) -> list[str]: pre_install_commands = self.config.get("pre-install-commands", []) if not isinstance(pre_install_commands, list): message = f"Field `tool.hatch.envs.{self.name}.pre-install-commands` must be an array" @@ -672,10 +678,10 @@ def pre_install_commands(self): message = f"Command #{i} of field `tool.hatch.envs.{self.name}.pre-install-commands` must be a string" raise TypeError(message) - return list(pre_install_commands) + return list(pre_install_commands) # type: ignore @cached_property - def post_install_commands(self): + def post_install_commands(self) -> list[str]: post_install_commands = self.config.get("post-install-commands", []) if not isinstance(post_install_commands, list): message = f"Field `tool.hatch.envs.{self.name}.post-install-commands` must be an array" @@ -686,7 +692,7 @@ def post_install_commands(self): message = f"Command #{i} of field `tool.hatch.envs.{self.name}.post-install-commands` must be a string" raise TypeError(message) - return list(post_install_commands) + return list(post_install_commands) # type: ignore @cached_property def workspace(self) -> Workspace: @@ -1004,7 +1010,7 @@ def get_env_var_option(self, option: str) -> str: """ return get_env_var_option(plugin_name=self.PLUGIN_NAME, option=option) - def get_context(self): + def get_context(self) -> EnvironmentContextFormatter: """ Returns a subclass of [EnvironmentContextFormatter](../utilities.md#hatch.env.context.EnvironmentContextFormatter). diff --git a/tests/env/plugin/test_interface.py b/tests/env/plugin/test_interface.py index 45ecb4aaa..a05759c82 100644 --- a/tests/env/plugin/test_interface.py +++ b/tests/env/plugin/test_interface.py @@ -1685,6 +1685,70 @@ def update(self, metadata): # And the extras must be expanded even though a metadata hook is configured assert any("pytest" in dep.lower() for dep in all_deps_str) + def test_self_referencing_dependency_with_extras_and_unresolvable_metadata_hook( + self, temp_dir, isolated_data_dir, platform, global_application + ): + """Regression test for https://github.com/pypa/hatch/issues/2345. + + A metadata hook that can't be resolved by hatch's own plugin manager + (e.g. a third-party hook plugin that is only installed in the project's build environment, + not in hatch's own venv) must not crash extra expansion for a self-referencing dependency, + as long as `optional-dependencies` itself is static. + """ + project_dir = temp_dir / "my-app" + project_dir.mkdir() + + config = { + "project": { + "name": "my-app", + "version": "0.0.1", + "dynamic": ["description"], + "dependencies": [], + "optional-dependencies": { + "test": ["pytest>=7.0"], + }, + }, + "tool": { + "hatch": { + # This hook is not registered anywhere (no `hatch_build.py`, no installed + # plugin), simulating a third-party metadata hook that is only available in + # the project's own build environment. + "metadata": {"hooks": {"docstring-description": {}}}, + "envs": { + "dev": { + "skip-install": False, + "dependencies": ["my-app[test]"], + } + }, + } + }, + } + + project = Project(project_dir, config=config) + global_application.project = project + + environment = MockEnvironment( + project_dir, + project.metadata, + "dev", + project.config.envs["dev"], + {}, + isolated_data_dir, + isolated_data_dir, + platform, + 0, + global_application, + ) + + all_deps_str = [str(d) for d in environment.all_dependencies_complex] + + # The self-referenced project must still be installed locally + assert any("my-app" in dep and "file://" in dep for dep in all_deps_str) + + # And the extras must be expanded without hatch needing to resolve the unrelated, + # unresolvable metadata hook in its own process + assert any("pytest" in dep.lower() for dep in all_deps_str) + def test_dev_mode_true_returns_editable(self, temp_dir, isolated_data_dir, platform, temp_application): """Verify dev-mode=true creates editable local dependency.""" # Create a pyproject.toml file so skip_install defaults to False From fb55a3242a20e81bf3a8eb08b55283d1c5bbd621 Mon Sep 17 00:00:00 2001 From: "Philipp A." Date: Tue, 21 Jul 2026 12:55:52 +0200 Subject: [PATCH 2/4] fix more types --- .../hatchling/builders/hooks/plugin/hooks.py | 3 +- src/hatch/env/collectors/plugin/hooks.py | 3 +- src/hatch/env/plugin/interface.py | 12 ++--- src/hatch/utils/platform.py | 20 ++++---- src/hatch/utils/runner.py | 6 ++- src/hatch/utils/structures.py | 3 +- tests/env/plugin/test_interface.py | 50 +++++++++---------- 7 files changed, 51 insertions(+), 46 deletions(-) diff --git a/backend/src/hatchling/builders/hooks/plugin/hooks.py b/backend/src/hatchling/builders/hooks/plugin/hooks.py index e06795945..fda38c263 100644 --- a/backend/src/hatchling/builders/hooks/plugin/hooks.py +++ b/backend/src/hatchling/builders/hooks/plugin/hooks.py @@ -12,4 +12,5 @@ @hookimpl def hatch_register_build_hook() -> list[type[BuildHookInterface]]: - return [CustomBuildHook, VersionBuildHook] + # CustomBuildHook fakes its type via `__new__`; it is not a real subclass + return [CustomBuildHook, VersionBuildHook] # type: ignore[list-item] diff --git a/src/hatch/env/collectors/plugin/hooks.py b/src/hatch/env/collectors/plugin/hooks.py index f2c7f174d..6881a3e40 100644 --- a/src/hatch/env/collectors/plugin/hooks.py +++ b/src/hatch/env/collectors/plugin/hooks.py @@ -12,4 +12,5 @@ @hookimpl def hatch_register_environment_collector() -> list[type[EnvironmentCollectorInterface]]: - return [CustomEnvironmentCollector, DefaultEnvironmentCollector] + # CustomEnvironmentCollector fakes its type via `__new__`; it is not a real subclass + return [CustomEnvironmentCollector, DefaultEnvironmentCollector] # type: ignore[list-item] diff --git a/src/hatch/env/plugin/interface.py b/src/hatch/env/plugin/interface.py index 85124afb3..d2aaec0ed 100644 --- a/src/hatch/env/plugin/interface.py +++ b/src/hatch/env/plugin/interface.py @@ -291,7 +291,7 @@ def environment_dependencies(self) -> tuple[str, ...]: def project_dependencies_complex(self) -> tuple[Dependency, ...]: workspace_dependencies = self.workspace.get_dependencies() if self.skip_install and not self.features and not self.dependency_groups and not workspace_dependencies: - return [] + return () from hatch.dep.core import Dependency from hatch.utils.dep import get_complex_dependencies, get_complex_dependency_group, get_complex_features @@ -402,7 +402,7 @@ def dependencies_complex(self) -> tuple[Dependency, ...]: if self.dev_mode or self.features or self.dependency_groups: all_dependencies_complex.extend(self.project_dependencies_complex) - return all_dependencies_complex + return tuple(all_dependencies_complex) @cached_property def dependencies(self) -> tuple[str, ...]: @@ -667,7 +667,7 @@ def scripts(self): return config @cached_property - def pre_install_commands(self) -> list[str]: + def pre_install_commands(self) -> tuple[str, ...]: pre_install_commands = self.config.get("pre-install-commands", []) if not isinstance(pre_install_commands, list): message = f"Field `tool.hatch.envs.{self.name}.pre-install-commands` must be an array" @@ -678,10 +678,10 @@ def pre_install_commands(self) -> list[str]: message = f"Command #{i} of field `tool.hatch.envs.{self.name}.pre-install-commands` must be a string" raise TypeError(message) - return list(pre_install_commands) # type: ignore + return tuple(pre_install_commands) @cached_property - def post_install_commands(self) -> list[str]: + def post_install_commands(self) -> tuple[str, ...]: post_install_commands = self.config.get("post-install-commands", []) if not isinstance(post_install_commands, list): message = f"Field `tool.hatch.envs.{self.name}.post-install-commands` must be an array" @@ -692,7 +692,7 @@ def post_install_commands(self) -> list[str]: message = f"Command #{i} of field `tool.hatch.envs.{self.name}.post-install-commands` must be a string" raise TypeError(message) - return list(post_install_commands) # type: ignore + return tuple(post_install_commands) @cached_property def workspace(self) -> Workspace: diff --git a/src/hatch/utils/platform.py b/src/hatch/utils/platform.py index e56e7fe0d..0a8d3b892 100644 --- a/src/hatch/utils/platform.py +++ b/src/hatch/utils/platform.py @@ -7,7 +7,7 @@ from typing import TYPE_CHECKING, Any, cast if TYPE_CHECKING: - from collections.abc import Callable, Iterable + from collections.abc import Callable, Iterable, Sequence from subprocess import CompletedProcess, Popen from types import ModuleType @@ -51,7 +51,7 @@ def modules(self) -> LazilyLoadedModules: """ return self.__modules - def format_for_subprocess(self, command: str | list[str], *, shell: bool) -> str | list[str]: + def format_for_subprocess(self, command: str | Sequence[str | os.PathLike], *, shell: bool) -> str | Sequence[str | os.PathLike]: """ Format the given command in a cross-platform manner for immediate consumption by subprocess utilities. """ @@ -77,7 +77,7 @@ def exit_with_code(code: str | int | None) -> None: sys.exit(code) def _run_command_integrated( - self, command: str | list[str], *, shell: bool = False, **kwargs: Any + self, command: str | Sequence[str | os.PathLike], *, shell: bool = False, **kwargs: Any ) -> CompletedProcess: with self.capture_process(command, shell=shell, **kwargs) as process: for line in self.stream_process_output(process): @@ -87,7 +87,7 @@ def _run_command_integrated( return self.modules.subprocess.CompletedProcess(process.args, process.poll(), stdout, stderr) - def run_command(self, command: str | list[str], *, shell: bool = False, **kwargs: Any) -> CompletedProcess: + def run_command(self, command: str | Sequence[str | os.PathLike], *, shell: bool = False, **kwargs: Any) -> CompletedProcess: """ Equivalent to the standard library's [subprocess.run](https://docs.python.org/3/library/subprocess.html#subprocess.run), @@ -100,7 +100,7 @@ def run_command(self, command: str | list[str], *, shell: bool = False, **kwargs self.populate_default_popen_kwargs(kwargs, shell=shell) return self.modules.subprocess.run(self.format_for_subprocess(command, shell=shell), shell=shell, **kwargs) - def check_command(self, command: str | list[str], *, shell: bool = False, **kwargs: Any) -> CompletedProcess: + def check_command(self, command: str | Sequence[str | os.PathLike], *, shell: bool = False, **kwargs: Any) -> CompletedProcess: """ Equivalent to [run_command](utilities.md#hatch.utils.platform.Platform.run_command), but non-zero exit codes will gracefully end program execution. @@ -111,7 +111,7 @@ def check_command(self, command: str | list[str], *, shell: bool = False, **kwar return process - def check_command_output(self, command: str | list[str], *, shell: bool = False, **kwargs: Any) -> str: + def check_command_output(self, command: str | Sequence[str | os.PathLike], *, shell: bool = False, **kwargs: Any) -> str: """ Equivalent to the output from the process returned by [capture_process](utilities.md#hatch.utils.platform.Platform.capture_process), @@ -129,7 +129,7 @@ def check_command_output(self, command: str | list[str], *, shell: bool = False, return process.stdout.decode("utf-8") - def capture_process(self, command: str | list[str], *, shell: bool = False, **kwargs: Any) -> Popen: + def capture_process(self, command: str | Sequence[str | os.PathLike], *, shell: bool = False, **kwargs: Any) -> Popen: """ Equivalent to the standard library's [subprocess.Popen](https://docs.python.org/3/library/subprocess.html#subprocess.Popen), @@ -191,9 +191,9 @@ def default_shell(self) -> str: """ if self.__default_shell is None: if self.windows: - self.__default_shell = cast(str, os.environ.get("SHELL", os.environ.get("COMSPEC", "cmd"))) + self.__default_shell = os.environ.get("SHELL", os.environ.get("COMSPEC", "cmd")) else: - self.__default_shell = cast(str, os.environ.get("SHELL", "bash")) + self.__default_shell = os.environ.get("SHELL", "bash") return self.__default_shell @property @@ -237,7 +237,7 @@ def linux(self) -> bool: """ return not (self.windows or self.macos) - def exit_with_command(self, command: list[str]) -> None: + def exit_with_command(self, command: list[str | os.PathLike]) -> None: """ Run the given command and exit with its exit code. On non-Windows systems, this uses the standard library's [os.execvp](https://docs.python.org/3/library/os.html#os.execvp). diff --git a/src/hatch/utils/runner.py b/src/hatch/utils/runner.py index 156e465db..9a794f746 100644 --- a/src/hatch/utils/runner.py +++ b/src/hatch/utils/runner.py @@ -3,6 +3,8 @@ from typing import TYPE_CHECKING, Any if TYPE_CHECKING: + from collections.abc import Sequence + from hatch.env.plugin.interface import EnvironmentInterface @@ -11,7 +13,7 @@ def __init__( self, environment: EnvironmentInterface, *, - shell_commands: list[str] | None = None, + shell_commands: Sequence[str] | None = None, env_vars: dict[str, str] | None = None, force_continue: bool = False, show_code_on_error: bool = False, @@ -19,7 +21,7 @@ def __init__( source: str = "cmd", ) -> None: self.env = environment - self.shell_commands: list[str] = shell_commands or [] + self.shell_commands: list[str] = list(shell_commands) if shell_commands else [] self.env_vars: dict[str, str] = env_vars or {} self.force_continue = force_continue self.show_code_on_error = show_code_on_error diff --git a/src/hatch/utils/structures.py b/src/hatch/utils/structures.py index c2d0fbd2a..65c0ca146 100644 --- a/src/hatch/utils/structures.py +++ b/src/hatch/utils/structures.py @@ -5,12 +5,13 @@ from typing import TYPE_CHECKING if TYPE_CHECKING: + from collections.abc import Collection, Mapping from types import TracebackType class EnvVars(dict): def __init__( - self, env_vars: dict | None = None, include: list[str] | None = None, exclude: list[str] | None = None + self, env_vars: Mapping | None = None, include: Collection[str] | None = None, exclude: Collection[str] | None = None ) -> None: super().__init__(os.environ) self.old_env = dict(self) diff --git a/tests/env/plugin/test_interface.py b/tests/env/plugin/test_interface.py index a05759c82..9367df1d5 100644 --- a/tests/env/plugin/test_interface.py +++ b/tests/env/plugin/test_interface.py @@ -162,7 +162,7 @@ def test_default(self, isolation, isolated_data_dir, platform, global_applicatio global_application, ) - assert environment.env_include == environment.env_include == [] + assert environment.env_include == environment.env_include == () def test_not_array(self, isolation, isolated_data_dir, platform, global_application): config = { @@ -229,7 +229,7 @@ def test_correct(self, isolation, isolated_data_dir, platform, global_applicatio global_application, ) - assert environment.env_include == ["HATCH_BUILD_*", "FOO*"] + assert environment.env_include == ("HATCH_BUILD_*", "FOO*") class TestEnvExclude: @@ -249,7 +249,7 @@ def test_default(self, isolation, isolated_data_dir, platform, global_applicatio global_application, ) - assert environment.env_exclude == environment.env_exclude == [] + assert environment.env_exclude == environment.env_exclude == () def test_not_array(self, isolation, isolated_data_dir, platform, global_application): config = { @@ -316,7 +316,7 @@ def test_correct(self, isolation, isolated_data_dir, platform, global_applicatio global_application, ) - assert environment.env_exclude == ["FOO*"] + assert environment.env_exclude == ("FOO*",) class TestPlatforms: @@ -632,7 +632,7 @@ def test_default(self, isolation, isolated_data_dir, platform, global_applicatio global_application, ) - assert environment.features == environment.features == [] + assert environment.features == environment.features == () def test_invalid_type(self, isolation, isolated_data_dir, platform, global_application): config = { @@ -675,7 +675,7 @@ def test_correct(self, isolation, isolated_data_dir, platform, global_applicatio global_application, ) - assert environment.features == ["baz", "foo-bar"] + assert environment.features == ("baz", "foo-bar") def test_feature_not_string(self, isolation, isolated_data_dir, platform, global_application): config = { @@ -769,7 +769,7 @@ def test_default(self, isolation, isolated_data_dir, platform, global_applicatio global_application, ) - assert environment.dependency_groups == [] + assert environment.dependency_groups == () def test_not_array(self, isolation, isolated_data_dir, platform, global_application): config = { @@ -817,7 +817,7 @@ def test_correct(self, isolation, isolated_data_dir, platform, temp_application) temp_application, ) - assert environment.dependency_groups == ["baz", "foo-bar"] + assert environment.dependency_groups == ("baz", "foo-bar") def test_group_not_string(self, isolation, isolated_data_dir, platform, global_application): config = { @@ -990,7 +990,7 @@ def test_default(self, isolation, isolated_data_dir, platform, temp_application) temp_application, ) - assert environment.dependencies == environment.dependencies == ["dep1"] + assert environment.dependencies == environment.dependencies == ("dep1",) assert len(environment.dependencies) == len(environment.dependencies_complex) def test_not_array(self, isolation, isolated_data_dir, platform, global_application): @@ -1160,7 +1160,7 @@ def test_full(self, isolation, isolated_data_dir, platform, temp_application): temp_application, ) - assert environment.dependencies == ["dep2", "dep3", "dep1"] + assert environment.dependencies == ("dep2", "dep3", "dep1") def test_context_formatting(self, isolation, isolated_data_dir, platform, temp_application, uri_slash_prefix): config = { @@ -1194,7 +1194,7 @@ def test_context_formatting(self, isolation, isolated_data_dir, platform, temp_a ) normalized_path = str(isolation).replace("\\", "/") - assert environment.dependencies == ["dep2", f"proj @ file:{uri_slash_prefix}{normalized_path}", "dep1"] + assert environment.dependencies == ("dep2", f"proj @ file:{uri_slash_prefix}{normalized_path}", "dep1") def test_project_dependencies_context_formatting( self, temp_dir, isolated_data_dir, platform, temp_application, uri_slash_prefix @@ -1280,7 +1280,7 @@ def test_full_skip_install(self, isolation, isolated_data_dir, platform, global_ global_application, ) - assert environment.dependencies == ["dep2", "dep3"] + assert environment.dependencies == ("dep2", "dep3") def test_full_skip_install_and_features(self, isolation, isolated_data_dir, platform, temp_application): config = { @@ -1319,7 +1319,7 @@ def test_full_skip_install_and_features(self, isolation, isolated_data_dir, plat temp_application, ) - assert environment.dependencies == ["dep2", "dep3", "dep4"] + assert environment.dependencies == ("dep2", "dep3", "dep4") def test_full_skip_install_and_dependency_groups(self, isolation, isolated_data_dir, platform, temp_application): config = { @@ -1361,7 +1361,7 @@ def test_full_skip_install_and_dependency_groups(self, isolation, isolated_data_ temp_application, ) - assert environment.dependencies == ["dep2", "dep3", "dep4", "dep5"] + assert environment.dependencies == ("dep2", "dep3", "dep4", "dep5") def test_full_no_dev_mode(self, isolation, isolated_data_dir, platform, global_application): config = { @@ -1386,7 +1386,7 @@ def test_full_no_dev_mode(self, isolation, isolated_data_dir, platform, global_a global_application, ) - assert environment.dependencies == ["dep2", "dep3"] + assert environment.dependencies == ("dep2", "dep3") def test_builder(self, isolation, isolated_data_dir, platform, global_application): config = { @@ -1410,7 +1410,7 @@ def test_builder(self, isolation, isolated_data_dir, platform, global_applicatio global_application, ) - assert environment.dependencies == ["dep3", "dep2"] + assert environment.dependencies == ("dep3", "dep2") def test_workspace(self, temp_dir, isolated_data_dir, platform, temp_application): for i in range(3): @@ -1471,7 +1471,7 @@ def test_workspace(self, temp_dir, isolated_data_dir, platform, temp_application temp_application, ) - assert environment.dependencies == [ + assert environment.dependencies == ( "dep2", "dep3", "pkg-0", @@ -1484,7 +1484,7 @@ def test_workspace(self, temp_dir, isolated_data_dir, platform, temp_application "pkg-feature-22", "pkg-feature-32", "dep1", - ] + ) def test_self_referencing_dependency_with_extras(self, temp_dir, isolated_data_dir, platform, global_application): """Test that self-referencing dependencies with extras include the extra's dependencies.""" @@ -2408,7 +2408,7 @@ def test_default(self, isolation, isolated_data_dir, platform, global_applicatio global_application, ) - assert environment.pre_install_commands == environment.pre_install_commands == [] + assert environment.pre_install_commands == environment.pre_install_commands == () def test_not_array(self, isolation, isolated_data_dir, platform, global_application): config = { @@ -2475,7 +2475,7 @@ def test_correct(self, isolation, isolated_data_dir, platform, global_applicatio global_application, ) - assert environment.pre_install_commands == ["baz test"] + assert environment.pre_install_commands == ("baz test",) class TestPostInstallCommands: @@ -2495,7 +2495,7 @@ def test_default(self, isolation, isolated_data_dir, platform, global_applicatio global_application, ) - assert environment.post_install_commands == environment.post_install_commands == [] + assert environment.post_install_commands == environment.post_install_commands == () def test_not_array(self, isolation, isolated_data_dir, platform, global_application): config = { @@ -2562,7 +2562,7 @@ def test_correct(self, isolation, isolated_data_dir, platform, global_applicatio global_application, ) - assert environment.post_install_commands == ["baz test"] + assert environment.post_install_commands == ("baz test",) class TestEnvVarOption: @@ -2943,7 +2943,7 @@ def test_matrix_default(self, isolation, isolated_data_dir, platform, global_app global_application, ) - assert environment.dependencies == ["pkg==9000"] + assert environment.dependencies == ("pkg==9000",) def test_matrix_default_override(self, isolation, isolated_data_dir, platform, global_application): config = { @@ -2964,7 +2964,7 @@ def test_matrix_default_override(self, isolation, isolated_data_dir, platform, g global_application, ) - assert environment.dependencies == ["pkg==42"] + assert environment.dependencies == ("pkg==42",) def test_env_vars_override(self, isolation, isolated_data_dir, platform, global_application): config = { @@ -2996,7 +2996,7 @@ def test_env_vars_override(self, isolation, isolated_data_dir, platform, global_ global_application, ) - assert environment.dependencies == ["pkg"] + assert environment.dependencies == ("pkg",) class TestWorkspaceConfig: From 1b2a7a04ddf8c95cc4f0cd67867f143e4400d91c Mon Sep 17 00:00:00 2001 From: "Philipp A." Date: Tue, 21 Jul 2026 13:00:12 +0200 Subject: [PATCH 3/4] fix lints --- src/hatch/project/core.py | 1 - src/hatch/utils/platform.py | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/src/hatch/project/core.py b/src/hatch/project/core.py index cd9849681..1f64dde2d 100644 --- a/src/hatch/project/core.py +++ b/src/hatch/project/core.py @@ -2,7 +2,6 @@ import re from collections import defaultdict -from collections.abc import Generator from contextlib import contextmanager from functools import cached_property from typing import TYPE_CHECKING, Any, cast diff --git a/src/hatch/utils/platform.py b/src/hatch/utils/platform.py index 0a8d3b892..8d0924f37 100644 --- a/src/hatch/utils/platform.py +++ b/src/hatch/utils/platform.py @@ -4,7 +4,7 @@ import sys from functools import cache from importlib import import_module -from typing import TYPE_CHECKING, Any, cast +from typing import TYPE_CHECKING, Any if TYPE_CHECKING: from collections.abc import Callable, Iterable, Sequence From d67dd8a584322c711d9902c4e618163fb6dce1d7 Mon Sep 17 00:00:00 2001 From: "Philipp A." Date: Tue, 21 Jul 2026 13:01:09 +0200 Subject: [PATCH 4/4] fmt --- src/hatch/utils/platform.py | 20 +++++++++++++++----- src/hatch/utils/structures.py | 5 ++++- 2 files changed, 19 insertions(+), 6 deletions(-) diff --git a/src/hatch/utils/platform.py b/src/hatch/utils/platform.py index 8d0924f37..686971cb3 100644 --- a/src/hatch/utils/platform.py +++ b/src/hatch/utils/platform.py @@ -51,7 +51,9 @@ def modules(self) -> LazilyLoadedModules: """ return self.__modules - def format_for_subprocess(self, command: str | Sequence[str | os.PathLike], *, shell: bool) -> str | Sequence[str | os.PathLike]: + def format_for_subprocess( + self, command: str | Sequence[str | os.PathLike], *, shell: bool + ) -> str | Sequence[str | os.PathLike]: """ Format the given command in a cross-platform manner for immediate consumption by subprocess utilities. """ @@ -87,7 +89,9 @@ def _run_command_integrated( return self.modules.subprocess.CompletedProcess(process.args, process.poll(), stdout, stderr) - def run_command(self, command: str | Sequence[str | os.PathLike], *, shell: bool = False, **kwargs: Any) -> CompletedProcess: + def run_command( + self, command: str | Sequence[str | os.PathLike], *, shell: bool = False, **kwargs: Any + ) -> CompletedProcess: """ Equivalent to the standard library's [subprocess.run](https://docs.python.org/3/library/subprocess.html#subprocess.run), @@ -100,7 +104,9 @@ def run_command(self, command: str | Sequence[str | os.PathLike], *, shell: bool self.populate_default_popen_kwargs(kwargs, shell=shell) return self.modules.subprocess.run(self.format_for_subprocess(command, shell=shell), shell=shell, **kwargs) - def check_command(self, command: str | Sequence[str | os.PathLike], *, shell: bool = False, **kwargs: Any) -> CompletedProcess: + def check_command( + self, command: str | Sequence[str | os.PathLike], *, shell: bool = False, **kwargs: Any + ) -> CompletedProcess: """ Equivalent to [run_command](utilities.md#hatch.utils.platform.Platform.run_command), but non-zero exit codes will gracefully end program execution. @@ -111,7 +117,9 @@ def check_command(self, command: str | Sequence[str | os.PathLike], *, shell: bo return process - def check_command_output(self, command: str | Sequence[str | os.PathLike], *, shell: bool = False, **kwargs: Any) -> str: + def check_command_output( + self, command: str | Sequence[str | os.PathLike], *, shell: bool = False, **kwargs: Any + ) -> str: """ Equivalent to the output from the process returned by [capture_process](utilities.md#hatch.utils.platform.Platform.capture_process), @@ -129,7 +137,9 @@ def check_command_output(self, command: str | Sequence[str | os.PathLike], *, sh return process.stdout.decode("utf-8") - def capture_process(self, command: str | Sequence[str | os.PathLike], *, shell: bool = False, **kwargs: Any) -> Popen: + def capture_process( + self, command: str | Sequence[str | os.PathLike], *, shell: bool = False, **kwargs: Any + ) -> Popen: """ Equivalent to the standard library's [subprocess.Popen](https://docs.python.org/3/library/subprocess.html#subprocess.Popen), diff --git a/src/hatch/utils/structures.py b/src/hatch/utils/structures.py index 65c0ca146..e8c937cad 100644 --- a/src/hatch/utils/structures.py +++ b/src/hatch/utils/structures.py @@ -11,7 +11,10 @@ class EnvVars(dict): def __init__( - self, env_vars: Mapping | None = None, include: Collection[str] | None = None, exclude: Collection[str] | None = None + self, + env_vars: Mapping | None = None, + include: Collection[str] | None = None, + exclude: Collection[str] | None = None, ) -> None: super().__init__(os.environ) self.old_env = dict(self)