From eef04fe5d491e8aee8e9089b841c3fe96afe8f26 Mon Sep 17 00:00:00 2001 From: Ofek Lev Date: Sun, 2 Jun 2024 12:13:52 -0400 Subject: [PATCH 01/55] Add support for workspaces --- src/hatch/cli/application.py | 13 +- src/hatch/cli/dep/__init__.py | 5 +- src/hatch/cli/env/show.py | 7 +- src/hatch/dep/core.py | 38 ++ src/hatch/dep/sync.py | 167 ++++---- src/hatch/env/plugin/interface.py | 376 +++++++++++++++-- src/hatch/env/system.py | 7 +- src/hatch/env/virtual.py | 38 +- src/hatch/project/config.py | 10 +- src/hatch/project/constants.py | 1 + src/hatch/project/core.py | 34 +- src/hatch/utils/dep.py | 16 +- src/hatch/utils/fs.py | 11 + tests/cli/env/test_create.py | 73 ++++ tests/dep/test_sync.py | 105 ++++- tests/env/plugin/test_interface.py | 633 ++++++++++++++++++++++++++++- 16 files changed, 1346 insertions(+), 188 deletions(-) create mode 100644 src/hatch/dep/core.py diff --git a/src/hatch/cli/application.py b/src/hatch/cli/application.py index bdc77b8c2..382c7e86e 100644 --- a/src/hatch/cli/application.py +++ b/src/hatch/cli/application.py @@ -15,8 +15,7 @@ if TYPE_CHECKING: from collections.abc import Generator - from packaging.requirements import Requirement - + from hatch.dep.core import Dependency from hatch.env.plugin.interface import EnvironmentInterface @@ -139,11 +138,11 @@ def ensure_environment_plugin_dependencies(self) -> None: self.project.config.env_requires_complex, wait_message='Syncing environment plugin requirements' ) - def ensure_plugin_dependencies(self, dependencies: list[Requirement], *, wait_message: str) -> None: + def ensure_plugin_dependencies(self, dependencies: list[Dependency], *, wait_message: str) -> None: if not dependencies: return - from hatch.dep.sync import dependencies_in_sync + from hatch.dep.sync import InstalledDistributions from hatch.env.utils import add_verbosity_flag if app_path := os.environ.get('PYAPP'): @@ -152,12 +151,14 @@ def ensure_plugin_dependencies(self, dependencies: list[Requirement], *, wait_me management_command = os.environ['PYAPP_COMMAND_NAME'] executable = self.platform.check_command_output([app_path, management_command, 'python-path']).strip() python_info = PythonInfo(self.platform, executable=executable) - if dependencies_in_sync(dependencies, sys_path=python_info.sys_path): + distributions = InstalledDistributions(sys_path=python_info.sys_path) + if distributions.dependencies_in_sync(dependencies): return pip_command = [app_path, management_command, 'pip'] else: - if dependencies_in_sync(dependencies): + distributions = InstalledDistributions() + if distributions.dependencies_in_sync(dependencies): return pip_command = [sys.executable, '-u', '-m', 'pip'] diff --git a/src/hatch/cli/dep/__init__.py b/src/hatch/cli/dep/__init__.py index 4202cb002..471b3107b 100644 --- a/src/hatch/cli/dep/__init__.py +++ b/src/hatch/cli/dep/__init__.py @@ -49,8 +49,7 @@ def table(app, project_only, env_only, show_lines, force_ascii): """Enumerate dependencies in a tabular format.""" app.ensure_environment_plugin_dependencies() - from packaging.requirements import Requirement - + from hatch.dep.core import Dependency from hatch.utils.dep import get_complex_dependencies, get_normalized_dependencies, normalize_marker_quoting environment = app.project.get_environment() @@ -76,7 +75,7 @@ def table(app, project_only, env_only, show_lines, force_ascii): if not all_requirements: continue - normalized_requirements = [Requirement(d) for d in get_normalized_dependencies(all_requirements)] + normalized_requirements = [Dependency(d) for d in get_normalized_dependencies(all_requirements)] columns = {'Name': {}, 'URL': {}, 'Versions': {}, 'Markers': {}, 'Features': {}} for i, requirement in enumerate(normalized_requirements): diff --git a/src/hatch/cli/env/show.py b/src/hatch/cli/env/show.py index 134368efb..13a1e7c1b 100644 --- a/src/hatch/cli/env/show.py +++ b/src/hatch/cli/env/show.py @@ -70,8 +70,7 @@ def show( app.display(json.dumps(contextual_config, separators=(',', ':'))) return - from packaging.requirements import InvalidRequirement, Requirement - + from hatch.dep.core import Dependency, InvalidDependencyError from hatchling.metadata.utils import get_normalized_dependency, normalize_project_name if internal: @@ -126,8 +125,8 @@ def show( normalized_dependencies = set() for dependency in dependencies: try: - req = Requirement(dependency) - except InvalidRequirement: + req = Dependency(dependency) + except InvalidDependencyError: normalized_dependencies.add(dependency) else: normalized_dependencies.add(get_normalized_dependency(req)) diff --git a/src/hatch/dep/core.py b/src/hatch/dep/core.py new file mode 100644 index 000000000..fe81474bb --- /dev/null +++ b/src/hatch/dep/core.py @@ -0,0 +1,38 @@ +from __future__ import annotations + +import os +from functools import cached_property + +from packaging.requirements import InvalidRequirement, Requirement + +from hatch.utils.fs import Path + +InvalidDependencyError = InvalidRequirement + + +class Dependency(Requirement): + def __init__(self, s: str, *, editable: bool = False) -> None: + super().__init__(s) + + if editable and self.url is None: + message = f'Editable dependency must refer to a local path: {s}' + raise InvalidDependencyError(message) + + self.__editable = editable + + @property + def editable(self) -> bool: + return self.__editable + + @cached_property + def path(self) -> Path | None: + if self.url is None: + return None + + import hyperlink + + uri = hyperlink.parse(self.url) + if uri.scheme != 'file': + return None + + return Path(os.sep.join(uri.path)) diff --git a/src/hatch/dep/sync.py b/src/hatch/dep/sync.py index 65101603d..7b1cde9dd 100644 --- a/src/hatch/dep/sync.py +++ b/src/hatch/dep/sync.py @@ -5,89 +5,87 @@ from importlib.metadata import Distribution, DistributionFinder from packaging.markers import default_environment -from packaging.requirements import Requirement +from hatch.dep.core import Dependency +from hatch.utils.fs import Path -class DistributionCache: - def __init__(self, sys_path: list[str]) -> None: - self._resolver = Distribution.discover(context=DistributionFinder.Context(path=sys_path)) - self._distributions: dict[str, Distribution] = {} - self._search_exhausted = False - self._canonical_regex = re.compile(r'[-_.]+') - def __getitem__(self, item: str) -> Distribution | None: - item = self._canonical_regex.sub('-', item).lower() - possible_distribution = self._distributions.get(item) - if possible_distribution is not None: - return possible_distribution +class InstalledDistributions: + def __init__(self, *, sys_path: list[str] | None = None, environment: dict[str, str] | None = None) -> None: + self.__sys_path: list[str] = sys.path if sys_path is None else sys_path + self.__environment: dict[str, str] = ( + default_environment() if environment is None else environment # type: ignore[assignment] + ) + self.__resolver = Distribution.discover(context=DistributionFinder.Context(path=self.__sys_path)) + self.__distributions: dict[str, Distribution] = {} + self.__search_exhausted = False + self.__canonical_regex = re.compile(r'[-_.]+') - # Be safe even though the code as-is will never reach this since - # the first unknown distribution will fail fast - if self._search_exhausted: # no cov - return None - - for distribution in self._resolver: - name = distribution.metadata['Name'] - if name is None: - continue - - name = self._canonical_regex.sub('-', name).lower() - self._distributions[name] = distribution - if name == item: - return distribution + def dependencies_in_sync(self, dependencies: list[Dependency]) -> bool: + return all(self.dependency_in_sync(dependency) for dependency in dependencies) - self._search_exhausted = True + def missing_dependencies(self, dependencies: list[Dependency]) -> list[Dependency]: + return [dependency for dependency in dependencies if not self.dependency_in_sync(dependency)] - return None + def dependency_in_sync(self, dependency: Dependency, *, environment: dict[str, str] | None = None) -> bool: + if environment is None: + environment = self.__environment + if dependency.marker and not dependency.marker.evaluate(environment): + return True -def dependency_in_sync( - requirement: Requirement, environment: dict[str, str], installed_distributions: DistributionCache -) -> bool: - if requirement.marker and not requirement.marker.evaluate(environment): - return True + distribution = self[dependency.name] + if distribution is None: + return False - distribution = installed_distributions[requirement.name] - if distribution is None: - return False + extras = dependency.extras + if extras: + transitive_dependencies: list[str] = distribution.metadata.get_all('Requires-Dist', []) + if not transitive_dependencies: + return False - extras = requirement.extras - if extras: - transitive_requirements: list[str] = distribution.metadata.get_all('Requires-Dist', []) - if not transitive_requirements: - return False + available_extras: list[str] = distribution.metadata.get_all('Provides-Extra', []) - available_extras: list[str] = distribution.metadata.get_all('Provides-Extra', []) + for dependency_string in transitive_dependencies: + transitive_dependency = Dependency(dependency_string) + if not transitive_dependency.marker: + continue - for requirement_string in transitive_requirements: - transitive_requirement = Requirement(requirement_string) - if not transitive_requirement.marker: - continue + for extra in extras: + # FIXME: This may cause a build to never be ready if newer versions do not provide the desired + # extra and it's just a user error/typo. See: https://github.com/pypa/pip/issues/7122 + if extra not in available_extras: + return False - for extra in extras: - # FIXME: This may cause a build to never be ready if newer versions do not provide the desired - # extra and it's just a user error/typo. See: https://github.com/pypa/pip/issues/7122 - if extra not in available_extras: - return False + extra_environment = dict(environment) + extra_environment['extra'] = extra + if not self.dependency_in_sync(transitive_dependency, environment=extra_environment): + return False - extra_environment = dict(environment) - extra_environment['extra'] = extra - if not dependency_in_sync(transitive_requirement, extra_environment, installed_distributions): - return False + if dependency.specifier and not dependency.specifier.contains(distribution.version): + return False - if requirement.specifier and not requirement.specifier.contains(distribution.version): - return False + # TODO: handle https://discuss.python.org/t/11938 + if dependency.url: + direct_url_file = distribution.read_text('direct_url.json') + if direct_url_file is None: + return False - # TODO: handle https://discuss.python.org/t/11938 - if requirement.url: - direct_url_file = distribution.read_text('direct_url.json') - if direct_url_file is not None: import json # https://packaging.python.org/specifications/direct-url/ direct_url_data = json.loads(direct_url_file) + url = direct_url_data['url'] + if 'dir_info' in direct_url_data: + dir_info = direct_url_data['dir_info'] + editable = dir_info.get('editable', False) + if editable != dependency.editable: + return False + + if Path.from_uri(url) != dependency.path: + return False + if 'vcs_info' in direct_url_data: - url = direct_url_data['url'] vcs_info = direct_url_data['vcs_info'] vcs = vcs_info['vcs'] commit_id = vcs_info['commit_id'] @@ -95,11 +93,11 @@ def dependency_in_sync( # Try a few variations, see https://peps.python.org/pep-0440/#direct-references if ( - requested_revision and requirement.url == f'{vcs}+{url}@{requested_revision}#{commit_id}' - ) or requirement.url == f'{vcs}+{url}@{commit_id}': + requested_revision and dependency.url == f'{vcs}+{url}@{requested_revision}#{commit_id}' + ) or dependency.url == f'{vcs}+{url}@{commit_id}': return True - if requirement.url in {f'{vcs}+{url}', f'{vcs}+{url}@{requested_revision}'}: + if dependency.url in {f'{vcs}+{url}', f'{vcs}+{url}@{requested_revision}'}: import subprocess if vcs == 'git': @@ -117,16 +115,35 @@ def dependency_in_sync( return False - return True + return True + + def __getitem__(self, item: str) -> Distribution | None: + item = self.__canonical_regex.sub('-', item).lower() + possible_distribution = self.__distributions.get(item) + if possible_distribution is not None: + return possible_distribution + + if self.__search_exhausted: + return None + + for distribution in self.__resolver: + name = distribution.metadata['Name'] + if name is None: + continue + + name = self.__canonical_regex.sub('-', name).lower() + self.__distributions[name] = distribution + if name == item: + return distribution + + self.__search_exhausted = True + + return None def dependencies_in_sync( - requirements: list[Requirement], sys_path: list[str] | None = None, environment: dict[str, str] | None = None -) -> bool: - if sys_path is None: - sys_path = sys.path - if environment is None: - environment = default_environment() # type: ignore[assignment] - - installed_distributions = DistributionCache(sys_path) - return all(dependency_in_sync(requirement, environment, installed_distributions) for requirement in requirements) # type: ignore[arg-type] + dependencies: list[Dependency], sys_path: list[str] | None = None, environment: dict[str, str] | None = None +) -> bool: # no cov + # This function is unused and only temporarily exists for plugin backwards compatibility. + distributions = InstalledDistributions(sys_path=sys_path, environment=environment) + return distributions.dependencies_in_sync(dependencies) diff --git a/src/hatch/env/plugin/interface.py b/src/hatch/env/plugin/interface.py index ab18b77eb..b679c1012 100644 --- a/src/hatch/env/plugin/interface.py +++ b/src/hatch/env/plugin/interface.py @@ -6,7 +6,7 @@ from contextlib import contextmanager from functools import cached_property from os.path import isabs -from typing import TYPE_CHECKING, Generator +from typing import TYPE_CHECKING, Any, Generator from hatch.config.constants import AppEnvVars from hatch.env.utils import add_verbosity_flag, get_env_var_option @@ -16,6 +16,8 @@ if TYPE_CHECKING: from collections.abc import Iterable + from hatch.dep.core import Dependency + from hatch.project.core import Project from hatch.utils.fs import Path @@ -24,23 +26,23 @@ class EnvironmentInterface(ABC): Example usage: ```python tab="plugin.py" - from hatch.env.plugin.interface import EnvironmentInterface + from hatch.env.plugin.interface import EnvironmentInterface - class SpecialEnvironment(EnvironmentInterface): - PLUGIN_NAME = 'special' - ... + class SpecialEnvironment(EnvironmentInterface): + PLUGIN_NAME = 'special' + ... ``` ```python tab="hooks.py" - from hatchling.plugin import hookimpl + from hatchling.plugin import hookimpl - from .plugin import SpecialEnvironment + from .plugin import SpecialEnvironment - @hookimpl - def hatch_register_environment(): - return SpecialEnvironment + @hookimpl + def hatch_register_environment(): + return SpecialEnvironment ``` """ @@ -71,6 +73,8 @@ def __init__( self.__verbosity = verbosity self.__app = app + self.additional_dependencies = [] + @property def matrix_variables(self): return self.__matrix_variables @@ -181,7 +185,7 @@ def system_python(self): return system_python @cached_property - def env_vars(self) -> dict: + def env_vars(self) -> dict[str, str]: """ ```toml config-example [tool.hatch.envs..env-vars] @@ -248,10 +252,10 @@ def env_exclude(self) -> list[str]: return env_exclude @cached_property - def environment_dependencies_complex(self): - from packaging.requirements import InvalidRequirement, Requirement + def environment_dependencies_complex(self) -> list[Dependency]: + from hatch.dep.core import Dependency, InvalidDependencyError - dependencies_complex = [] + dependencies_complex: list[Dependency] = [] with self.apply_context(): for option in ('dependencies', 'extra-dependencies'): dependencies = self.config.get(option, []) @@ -265,8 +269,8 @@ def environment_dependencies_complex(self): raise TypeError(message) try: - dependencies_complex.append(Requirement(self.metadata.context.format(entry))) - except InvalidRequirement as e: + dependencies_complex.append(Dependency(self.metadata.context.format(entry))) + except InvalidDependencyError as e: message = f'Dependency #{i} of field `tool.hatch.envs.{self.name}.{option}` is invalid: {e}' raise ValueError(message) from None @@ -280,47 +284,104 @@ def environment_dependencies(self) -> list[str]: return [str(dependency) for dependency in self.environment_dependencies_complex] @cached_property - def dependencies_complex(self): + def project_dependencies_complex(self) -> list[Dependency]: + workspace_dependencies = self.workspace.get_dependencies() + if self.skip_install and not self.features and not workspace_dependencies: + return [] + + from hatch.dep.core import Dependency + from hatch.utils.dep import get_complex_dependencies, get_complex_features + + all_dependencies_complex = list(map(Dependency, workspace_dependencies)) + dependencies, optional_dependencies = self.app.project.get_dependencies() + dependencies_complex = get_complex_dependencies(dependencies) + optional_dependencies_complex = get_complex_features(optional_dependencies) + + if not self.skip_install: + all_dependencies_complex.extend(dependencies_complex.values()) + + for feature in self.features: + if feature not in optional_dependencies_complex: + message = ( + f'Feature `{feature}` of field `tool.hatch.envs.{self.name}.features` is not ' + f'defined in the dynamic field `project.optional-dependencies`' + ) + raise ValueError(message) + + all_dependencies_complex.extend(optional_dependencies_complex[feature].values()) + + return all_dependencies_complex + + @cached_property + def project_dependencies(self) -> list[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] + + @cached_property + def local_dependencies_complex(self) -> list[Dependency]: + from hatch.dep.core import Dependency + + local_dependencies_complex = [] + if not self.skip_install: + root = 'file://' if self.sep == '/' else 'file:///' + local_dependencies_complex.append( + Dependency(f'{self.metadata.name} @ {root}{self.project_root}', editable=self.dev_mode) + ) + + local_dependencies_complex.extend( + Dependency(f'{member.project.metadata.name} @ {member.project.location.as_uri()}', editable=self.dev_mode) + for member in self.workspace.members + ) + + return local_dependencies_complex + + @cached_property + def dependencies_complex(self) -> list[Dependency]: all_dependencies_complex = list(self.environment_dependencies_complex) + all_dependencies_complex.extend(self.additional_dependencies) if self.builder: + from hatch.dep.core import Dependency + from hatch.project.constants import BuildEnvVars + all_dependencies_complex.extend(self.metadata.build.requires_complex) + for target in os.environ.get(BuildEnvVars.REQUESTED_TARGETS, '').split(): + target_config = self.app.project.config.build.target(target) + all_dependencies_complex.extend(map(Dependency, target_config.dependencies)) + return all_dependencies_complex # Ensure these are checked last to speed up initial environment creation since # they will already be installed along with the project - if (not self.skip_install and self.dev_mode) or self.features: - from hatch.utils.dep import get_complex_dependencies, get_complex_features - - dependencies, optional_dependencies = self.app.project.get_dependencies() - dependencies_complex = get_complex_dependencies(dependencies) - optional_dependencies_complex = get_complex_features(optional_dependencies) - - if not self.skip_install and self.dev_mode: - all_dependencies_complex.extend(dependencies_complex.values()) - - for feature in self.features: - if feature not in optional_dependencies_complex: - message = ( - f'Feature `{feature}` of field `tool.hatch.envs.{self.name}.features` is not ' - f'defined in the dynamic field `project.optional-dependencies`' - ) - raise ValueError(message) - - all_dependencies_complex.extend(optional_dependencies_complex[feature].values()) + if self.dev_mode: + all_dependencies_complex.extend(self.project_dependencies_complex) return all_dependencies_complex @cached_property def dependencies(self) -> list[str]: """ - The list of all [project dependencies](../../config/metadata.md#dependencies) (if - [installed](../../config/environment/overview.md#skip-install) and in - [dev mode](../../config/environment/overview.md#dev-mode)), selected - [optional dependencies](../../config/environment/overview.md#features), and + 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] + @cached_property + def all_dependencies_complex(self) -> list[Dependency]: + all_dependencies_complex = list(self.local_dependencies_complex) + all_dependencies_complex.extend(self.dependencies_complex) + return all_dependencies_complex + + @cached_property + def all_dependencies(self) -> list[str]: + return [str(dependency) for dependency in self.all_dependencies_complex] + @cached_property def platforms(self) -> list[str]: """ @@ -516,6 +577,15 @@ def post_install_commands(self): return list(post_install_commands) + @cached_property + def workspace(self) -> Workspace: + config = self.config.get('workspace', {}) + if not isinstance(config, dict): + message = f'Field `tool.hatch.envs.{self.name}.workspace` must be a table' + raise TypeError(message) + + return Workspace(self, config) + def activate(self): """ A convenience method called when using the environment as a context manager: @@ -620,7 +690,7 @@ def dependency_hash(self): """ from hatch.utils.dep import hash_dependencies - return hash_dependencies(self.dependencies_complex) + return hash_dependencies(self.all_dependencies_complex) @contextmanager def app_status_creation(self): @@ -910,6 +980,203 @@ def sync_local(self): """ +class Workspace: + def __init__(self, env: EnvironmentInterface, config: dict[str, Any]): + self.env = env + self.config = config + + @cached_property + def parallel(self) -> bool: + parallel = self.config.get('parallel', True) + if not isinstance(parallel, bool): + message = f'Field `tool.hatch.envs.{self.env.name}.workspace.parallel` must be a boolean' + raise TypeError(message) + + return parallel + + def get_dependencies(self) -> list[str]: + static_members: list[WorkspaceMember] = [] + dynamic_members: list[WorkspaceMember] = [] + for member in self.members: + if member.has_static_dependencies: + static_members.append(member) + else: + dynamic_members.append(member) + + all_dependencies = [] + for member in static_members: + dependencies, features = member.get_dependencies() + all_dependencies.extend(dependencies) + for feature in member.features: + all_dependencies.extend(features.get(feature, [])) + + if not self.parallel: + for member in dynamic_members: + with self.env.app.status(f'Checking workspace member: {member.name}'): + dependencies, features = member.get_dependencies() + all_dependencies.extend(dependencies) + for feature in member.features: + all_dependencies.extend(features.get(feature, [])) + + return all_dependencies + + @cached_property + def members(self) -> list[WorkspaceMember]: + from hatch.project.core import Project + from hatch.utils.fs import Path + from hatchling.metadata.utils import normalize_project_name + + raw_members = self.config.get('members', []) + if not isinstance(raw_members, list): + message = f'Field `tool.hatch.envs.{self.env.name}.workspace.members` must be an array' + raise TypeError(message) + + # First normalize configuration + member_data: list[dict[str, Any]] = [] + for i, data in enumerate(raw_members, 1): + if isinstance(data, str): + member_data.append({'path': data, 'features': ()}) + elif isinstance(data, dict): + if 'path' not in data: + message = ( + f'Member #{i} of field `tool.hatch.envs.{self.env.name}.workspace.members` must define ' + f'a `path` key' + ) + raise TypeError(message) + + path = data['path'] + if not isinstance(path, str): + message = ( + f'Option `path` of member #{i} of field `tool.hatch.envs.{self.env.name}.workspace.members` ' + f'must be a string' + ) + raise TypeError(message) + + if not path: + message = ( + f'Option `path` of member #{i} of field `tool.hatch.envs.{self.env.name}.workspace.members` ' + f'cannot be an empty string' + ) + raise ValueError(message) + + features = data.get('features', []) + if not isinstance(features, list): + message = ( + f'Option `features` of member #{i} of field `tool.hatch.envs.{self.env.name}.workspace.' + f'members` must be an array of strings' + ) + raise TypeError(message) + + all_features: set[str] = set() + for j, feature in enumerate(features, 1): + if not isinstance(feature, str): + message = ( + f'Feature #{j} of option `features` of member #{i} of field ' + f'`tool.hatch.envs.{self.env.name}.workspace.members` must be a string' + ) + raise TypeError(message) + + if not feature: + message = ( + f'Feature #{j} of option `features` of member #{i} of field ' + f'`tool.hatch.envs.{self.env.name}.workspace.members` cannot be an empty string' + ) + raise ValueError(message) + + normalized_feature = normalize_project_name(feature) + if normalized_feature in all_features: + message = ( + f'Feature #{j} of option `features` of member #{i} of field ' + f'`tool.hatch.envs.{self.env.name}.workspace.members` is a duplicate' + ) + raise ValueError(message) + + all_features.add(normalized_feature) + + member_data.append({'path': path, 'features': tuple(sorted(all_features))}) + else: + message = ( + f'Member #{i} of field `tool.hatch.envs.{self.env.name}.workspace.members` must be ' + f'a string or an inline table' + ) + raise TypeError(message) + + root = str(self.env.root) + member_paths: dict[str, WorkspaceMember] = {} + for data in member_data: + # Given root R and member spec M, we need to find: + # + # 1. The absolute path AP of R/M + # 2. The shared prefix SP of R and AP + # 3. The relative path RP of M from AP + # + # For example, if: + # + # R = /foo/bar/baz + # M = ../dir/pkg-* + # + # Then: + # + # AP = /foo/bar/dir/pkg-* + # SP = /foo/bar + # RP = dir/pkg-* + path_spec = data['path'] + normalized_path = os.path.normpath(os.path.join(root, path_spec)) + absolute_path = os.path.abspath(normalized_path) + shared_prefix = os.path.commonprefix([root, absolute_path]) + relative_path = os.path.relpath(absolute_path, shared_prefix) + + # Now we have the necessary information to perform an optimized glob search for members + members_found = False + for member_path in find_members(root, relative_path.split(os.sep)): + project_file = os.path.join(member_path, 'pyproject.toml') + if not os.path.isfile(project_file): + message = ( + f'Member derived from `{path_spec}` of field ' + f'`tool.hatch.envs.{self.env.name}.workspace.members` is not a project (no `pyproject.toml` ' + f'file): {member_path}' + ) + raise OSError(message) + + members_found = True + if member_path in member_paths: + message = ( + f'Member derived from `{path_spec}` of field ' + f'`tool.hatch.envs.{self.env.name}.workspace.members` is a duplicate: {member_path}' + ) + raise ValueError(message) + + project = Project(Path(member_path), locate=False) + project.set_app(self.env.app) + member_paths[member_path] = WorkspaceMember(project, features=data['features']) + + if not members_found: + message = ( + f'No members could be derived from `{path_spec}` of field ' + f'`tool.hatch.envs.{self.env.name}.workspace.members`: {absolute_path}' + ) + raise OSError(message) + + return list(member_paths.values()) + + +class WorkspaceMember: + def __init__(self, project: Project, *, features: tuple[str]): + self.project = project + self.features = features + + @cached_property + def name(self) -> str: + return self.project.metadata.name + + @cached_property + def has_static_dependencies(self) -> bool: + return self.project.has_static_dependencies + + def get_dependencies(self) -> tuple[list[str], dict[str, list[str]]]: + return self.project.get_dependencies() + + def expand_script_commands(env_name, script_name, commands, config, seen, active): if script_name in seen: return seen[script_name] @@ -944,3 +1211,30 @@ def expand_script_commands(env_name, script_name, commands, config, seen, active active.pop() return expanded_commands + + +def find_members(root, relative_components): + import fnmatch + import re + + component_matchers = [] + for component in relative_components: + if any(special in component for special in '*?['): + pattern = re.compile(fnmatch.translate(component)) + component_matchers.append(lambda entry, pattern=pattern: pattern.search(entry.name)) + else: + component_matchers.append(lambda entry, component=component: component == entry.name) + + yield from _recurse_members(root, 0, component_matchers) + + +def _recurse_members(root, matcher_index, matchers): + if matcher_index == len(matchers): + yield root + return + + matcher = matchers[matcher_index] + with os.scandir(root) as it: + for entry in it: + if entry.is_dir() and matcher(entry): + yield from _recurse_members(entry.path, matcher_index + 1, matchers) diff --git a/src/hatch/env/system.py b/src/hatch/env/system.py index 804e15d2a..5ddc577ae 100644 --- a/src/hatch/env/system.py +++ b/src/hatch/env/system.py @@ -37,11 +37,12 @@ def dependencies_in_sync(self): if not self.dependencies: return True - from hatch.dep.sync import dependencies_in_sync + from hatch.dep.sync import InstalledDistributions - return dependencies_in_sync( - self.dependencies_complex, sys_path=self.python_info.sys_path, environment=self.python_info.environment + distributions = InstalledDistributions( + sys_path=self.python_info.sys_path, environment=self.python_info.environment ) + return distributions.dependencies_in_sync(self.dependencies_complex) def sync_dependencies(self): self.platform.check_command(self.construct_pip_install_command(self.dependencies)) diff --git a/src/hatch/env/virtual.py b/src/hatch/env/virtual.py index dab5a7ee7..0c6197994 100644 --- a/src/hatch/env/virtual.py +++ b/src/hatch/env/virtual.py @@ -21,6 +21,8 @@ from packaging.specifiers import SpecifierSet from virtualenv.discovery.py_info import PythonInfo + from hatch.dep.core import Dependency + from hatch.dep.sync import InstalledDistributions from hatch.python.core import PythonManager @@ -127,6 +129,16 @@ def uv_path(self) -> str: new_path = f'{scripts_dir}{os.pathsep}{old_path}' return self.platform.modules.shutil.which('uv', path=new_path) + @cached_property + def distributions(self) -> InstalledDistributions: + from hatch.dep.sync import InstalledDistributions + + return InstalledDistributions(sys_path=self.virtual_env.sys_path, environment=self.virtual_env.environment) + + @cached_property + def missing_dependencies(self) -> list[Dependency]: + return self.distributions.missing_dependencies(self.all_dependencies_complex) + @staticmethod def get_option_types() -> dict: return {'system-packages': bool, 'path': str, 'python-sources': list, 'installer': str, 'uv-path': str} @@ -181,19 +193,27 @@ def install_project_dev_mode(self): ) def dependencies_in_sync(self): - if not self.dependencies: - return True - - from hatch.dep.sync import dependencies_in_sync - with self.safe_activation(): - return dependencies_in_sync( - self.dependencies_complex, sys_path=self.virtual_env.sys_path, environment=self.virtual_env.environment - ) + return not self.missing_dependencies def sync_dependencies(self): with self.safe_activation(): - self.platform.check_command(self.construct_pip_install_command(self.dependencies)) + standard_dependencies: list[str] = [] + editable_dependencies: list[str] = [] + for dependency in self.missing_dependencies: + if not dependency.editable or dependency.path is None: + standard_dependencies.append(str(dependency)) + else: + editable_dependencies.append(str(dependency.path)) + + if standard_dependencies: + self.platform.check_command(self.construct_pip_install_command(standard_dependencies)) + + if editable_dependencies: + editable_args = [] + for dependency in editable_dependencies: + editable_args.extend(['--editable', dependency]) + self.platform.check_command(self.construct_pip_install_command(editable_args)) @contextmanager def command_context(self): diff --git a/src/hatch/project/config.py b/src/hatch/project/config.py index 972a2f2d6..b6a1bdb18 100644 --- a/src/hatch/project/config.py +++ b/src/hatch/project/config.py @@ -13,7 +13,7 @@ from hatch.project.utils import format_script_commands, parse_script_command if TYPE_CHECKING: - from packaging.requirements import Requirement + from hatch.dep.core import Dependency class ProjectConfig: @@ -57,9 +57,9 @@ def env(self): return self._env @property - def env_requires_complex(self) -> list[Requirement]: + def env_requires_complex(self) -> list[Dependency]: if self._env_requires_complex is None: - from packaging.requirements import InvalidRequirement, Requirement + from hatch.dep.core import Dependency, InvalidDependencyError requires = self.env.get('requires', []) if not isinstance(requires, list): @@ -74,8 +74,8 @@ def env_requires_complex(self) -> list[Requirement]: raise TypeError(message) try: - requires_complex.append(Requirement(entry)) - except InvalidRequirement as e: + requires_complex.append(Dependency(entry)) + except InvalidDependencyError as e: message = f'Requirement #{i} in `tool.hatch.env.requires` is invalid: {e}' raise ValueError(message) from None diff --git a/src/hatch/project/constants.py b/src/hatch/project/constants.py index 6da1f339a..ca919ac71 100644 --- a/src/hatch/project/constants.py +++ b/src/hatch/project/constants.py @@ -5,6 +5,7 @@ class BuildEnvVars: + REQUESTED_TARGETS = 'HATCH_BUILD_REQUESTED_TARGETS' LOCATION = 'HATCH_BUILD_LOCATION' HOOKS_ONLY = 'HATCH_BUILD_HOOKS_ONLY' NO_HOOKS = 'HATCH_BUILD_NO_HOOKS' diff --git a/src/hatch/project/core.py b/src/hatch/project/core.py index 6f4e7d407..b1f8dcbc4 100644 --- a/src/hatch/project/core.py +++ b/src/hatch/project/core.py @@ -17,7 +17,7 @@ class Project: - def __init__(self, path: Path, *, name: str | None = None, config=None): + def __init__(self, path: Path, *, name: str | None = None, config=None, locate: bool = True): self._path = path # From app config @@ -36,7 +36,7 @@ def __init__(self, path: Path, *, name: str | None = None, config=None): self._metadata = None self._config = None - self._explicit_path: Path | None = None + self._explicit_path: Path | None = None if locate else path @property def plugin_manager(self): @@ -198,13 +198,15 @@ def prepare_environment(self, environment: EnvironmentInterface): self.env_metadata.update_dependency_hash(environment, new_dep_hash) def prepare_build_environment(self, *, targets: list[str] | None = None) -> None: - from hatch.project.constants import BUILD_BACKEND + from hatch.project.constants import BUILD_BACKEND, BuildEnvVars + from hatch.utils.structures import EnvVars if targets is None: targets = ['wheel'] + env_vars = {BuildEnvVars.REQUESTED_TARGETS: ' '.join(sorted(targets))} build_backend = self.metadata.build.build_backend - with self.location.as_cwd(), self.build_env.get_env_vars(): + with self.location.as_cwd(), self.build_env.get_env_vars(), EnvVars(env_vars): if not self.build_env.exists(): try: self.build_env.check_compatibility() @@ -213,30 +215,33 @@ def prepare_build_environment(self, *, targets: list[str] | None = None) -> None self.prepare_environment(self.build_env) - extra_dependencies: list[str] = [] + additional_dependencies: list[str] = [] with self.app.status('Inspecting build dependencies'): if build_backend != BUILD_BACKEND: for target in targets: if target == 'sdist': - extra_dependencies.extend(self.build_frontend.get_requires('sdist')) + additional_dependencies.extend(self.build_frontend.get_requires('sdist')) elif target == 'wheel': - extra_dependencies.extend(self.build_frontend.get_requires('wheel')) + additional_dependencies.extend(self.build_frontend.get_requires('wheel')) else: self.app.abort(f'Target `{target}` is not supported by `{build_backend}`') else: required_build_deps = self.build_frontend.hatch.get_required_build_deps(targets) if required_build_deps: with self.metadata.context.apply_context(self.build_env.context): - extra_dependencies.extend(self.metadata.context.format(dep) for dep in required_build_deps) + additional_dependencies.extend( + self.metadata.context.format(dep) for dep in required_build_deps + ) + + if additional_dependencies: + from hatch.dep.core import Dependency - if extra_dependencies: - self.build_env.dependencies.extend(extra_dependencies) + self.build_env.additional_dependencies.extend(map(Dependency, additional_dependencies)) with self.build_env.app_status_dependency_synchronization(): self.build_env.sync_dependencies() def get_dependencies(self) -> tuple[list[str], dict[str, list[str]]]: - dynamic_fields = {'dependencies', 'optional-dependencies'} - if not dynamic_fields.intersection(self.metadata.dynamic): + if self.has_static_dependencies: dependencies: list[str] = self.metadata.core_raw_metadata.get('dependencies', []) features: dict[str, list[str]] = self.metadata.core_raw_metadata.get('optional-dependencies', {}) return dependencies, features @@ -256,6 +261,11 @@ def get_dependencies(self) -> tuple[list[str], dict[str, list[str]]]: return dynamic_dependencies, dynamic_features + @cached_property + def has_static_dependencies(self) -> bool: + dynamic_fields = {'dependencies', 'optional-dependencies'} + return not dynamic_fields.intersection(self.metadata.dynamic) + def expand_environments(self, env_name: str) -> list[str]: if env_name in self.config.internal_matrices: return list(self.config.internal_matrices[env_name]['envs']) diff --git a/src/hatch/utils/dep.py b/src/hatch/utils/dep.py index e9964a3bf..f303e3780 100644 --- a/src/hatch/utils/dep.py +++ b/src/hatch/utils/dep.py @@ -7,6 +7,8 @@ if TYPE_CHECKING: from packaging.requirements import Requirement + from hatch.dep.core import Dependency + def normalize_marker_quoting(text: str) -> str: # All TOML writers use double quotes, so allow copy/pasting to avoid escaping @@ -18,7 +20,7 @@ def get_normalized_dependencies(requirements: list[Requirement]) -> list[str]: return sorted(normalized_dependencies) -def hash_dependencies(requirements: list[Requirement]) -> str: +def hash_dependencies(requirements: list[Dependency]) -> str: from hashlib import sha256 data = ''.join( @@ -32,23 +34,23 @@ def hash_dependencies(requirements: list[Requirement]) -> str: return sha256(data).hexdigest() -def get_complex_dependencies(dependencies: list[str]) -> dict[str, Requirement]: - from packaging.requirements import Requirement +def get_complex_dependencies(dependencies: list[str]) -> dict[str, Dependency]: + from hatch.dep.core import Dependency dependencies_complex = {} for dependency in dependencies: - dependencies_complex[dependency] = Requirement(dependency) + dependencies_complex[dependency] = Dependency(dependency) return dependencies_complex -def get_complex_features(features: dict[str, list[str]]) -> dict[str, dict[str, Requirement]]: - from packaging.requirements import Requirement +def get_complex_features(features: dict[str, list[str]]) -> dict[str, dict[str, Dependency]]: + from hatch.dep.core import Dependency optional_dependencies_complex = {} for feature, optional_dependencies in features.items(): optional_dependencies_complex[feature] = { - optional_dependency: Requirement(optional_dependency) for optional_dependency in optional_dependencies + optional_dependency: Dependency(optional_dependency) for optional_dependency in optional_dependencies } return optional_dependencies_complex diff --git a/src/hatch/utils/fs.py b/src/hatch/utils/fs.py index 1e187df77..3a86f16e7 100644 --- a/src/hatch/utils/fs.py +++ b/src/hatch/utils/fs.py @@ -129,6 +129,17 @@ def temp_hide(self) -> Generator[Path, None, None]: with suppress(FileNotFoundError): shutil.move(str(temp_path), self) + if sys.platform == 'win32': + + @classmethod + def from_uri(cls, path: str) -> Path: + return cls(path.replace('file:///', '', 1)) + else: + + @classmethod + def from_uri(cls, path: str) -> Path: + return cls(path.replace('file://', '', 1)) + if sys.version_info[:2] < (3, 10): def resolve(self, strict: bool = False) -> Path: # noqa: ARG002, FBT001, FBT002 diff --git a/tests/cli/env/test_create.py b/tests/cli/env/test_create.py index 7106f4479..b5d7d1444 100644 --- a/tests/cli/env/test_create.py +++ b/tests/cli/env/test_create.py @@ -1954,3 +1954,76 @@ def test_no_compatible_python_ok_if_not_installed(hatch, helpers, temp_dir, conf env_path = env_dirs[0] assert env_path.name == project_path.name + + +@pytest.mark.requires_internet +def test_workspace(hatch, helpers, temp_dir, platform, uv_on_path, extract_installed_requirements): + project_name = 'My.App' + + with temp_dir.as_cwd(): + result = hatch('new', project_name) + assert result.exit_code == 0, result.output + + project_path = temp_dir / 'my-app' + data_path = temp_dir / 'data' + data_path.mkdir() + + members = ['foo', 'bar', 'baz'] + for member in members: + with project_path.as_cwd(): + result = hatch('new', member) + assert result.exit_code == 0, result.output + + project = Project(project_path) + helpers.update_project_environment( + project, + 'default', + { + 'workspace': {'members': [{'path': member} for member in members]}, + **project.config.envs['default'], + }, + ) + + with project_path.as_cwd(env_vars={ConfigEnvVars.DATA: str(data_path)}): + result = hatch('env', 'create') + + assert result.exit_code == 0, result.output + assert result.output == helpers.dedent( + """ + Creating environment: default + Installing project in development mode + Checking dependencies + Syncing dependencies + """ + ) + + env_data_path = data_path / 'env' / 'virtual' + assert env_data_path.is_dir() + + project_data_path = env_data_path / project_path.name + assert project_data_path.is_dir() + + storage_dirs = list(project_data_path.iterdir()) + assert len(storage_dirs) == 1 + + storage_path = storage_dirs[0] + assert len(storage_path.name) == 8 + + env_dirs = list(storage_path.iterdir()) + assert len(env_dirs) == 1 + + env_path = env_dirs[0] + + assert env_path.name == project_path.name + + with UVVirtualEnv(env_path, platform): + output = platform.run_command([uv_on_path, 'pip', 'freeze'], check=True, capture_output=True).stdout.decode( + 'utf-8' + ) + requirements = extract_installed_requirements(output.splitlines()) + + assert len(requirements) == 4 + assert requirements[0].lower() == f'-e {project_path.as_uri().lower()}/bar' + assert requirements[1].lower() == f'-e {project_path.as_uri().lower()}/baz' + assert requirements[2].lower() == f'-e {project_path.as_uri().lower()}/foo' + assert requirements[3].lower() == f'-e {project_path.as_uri().lower()}' diff --git a/tests/dep/test_sync.py b/tests/dep/test_sync.py index d8b1878fa..f355db6f6 100644 --- a/tests/dep/test_sync.py +++ b/tests/dep/test_sync.py @@ -1,51 +1,59 @@ +import os import sys import pytest -from packaging.requirements import Requirement -from hatch.dep.sync import dependencies_in_sync +from hatch.dep.core import Dependency +from hatch.dep.sync import InstalledDistributions from hatch.venv.core import TempUVVirtualEnv, TempVirtualEnv def test_no_dependencies(platform): with TempUVVirtualEnv(sys.executable, platform) as venv: - assert dependencies_in_sync([], venv.sys_path) + distributions = InstalledDistributions(sys_path=venv.sys_path) + assert distributions.dependencies_in_sync([]) def test_dependency_not_found(platform): with TempUVVirtualEnv(sys.executable, platform) as venv: - assert not dependencies_in_sync([Requirement('binary')], venv.sys_path) + distributions = InstalledDistributions(sys_path=venv.sys_path) + assert not distributions.dependencies_in_sync([Dependency('binary')]) @pytest.mark.requires_internet def test_dependency_found(platform, uv_on_path): with TempUVVirtualEnv(sys.executable, platform) as venv: platform.run_command([uv_on_path, 'pip', 'install', 'binary'], check=True, capture_output=True) - assert dependencies_in_sync([Requirement('binary')], venv.sys_path) + distributions = InstalledDistributions(sys_path=venv.sys_path) + assert distributions.dependencies_in_sync([Dependency('binary')]) @pytest.mark.requires_internet def test_version_unmet(platform, uv_on_path): with TempUVVirtualEnv(sys.executable, platform) as venv: platform.run_command([uv_on_path, 'pip', 'install', 'binary'], check=True, capture_output=True) - assert not dependencies_in_sync([Requirement('binary>9000')], venv.sys_path) + distributions = InstalledDistributions(sys_path=venv.sys_path) + assert not distributions.dependencies_in_sync([Dependency('binary>9000')]) def test_marker_met(platform): with TempUVVirtualEnv(sys.executable, platform) as venv: - assert dependencies_in_sync([Requirement('binary; python_version < "1"')], venv.sys_path) + distributions = InstalledDistributions(sys_path=venv.sys_path) + assert distributions.dependencies_in_sync([Dependency('binary; python_version < "1"')]) def test_marker_unmet(platform): with TempUVVirtualEnv(sys.executable, platform) as venv: - assert not dependencies_in_sync([Requirement('binary; python_version > "1"')], venv.sys_path) + distributions = InstalledDistributions(sys_path=venv.sys_path) + assert not distributions.dependencies_in_sync([Dependency('binary; python_version > "1"')]) @pytest.mark.requires_internet def test_extra_no_dependencies(platform, uv_on_path): with TempUVVirtualEnv(sys.executable, platform) as venv: platform.run_command([uv_on_path, 'pip', 'install', 'binary'], check=True, capture_output=True) - assert not dependencies_in_sync([Requirement('binary[foo]')], venv.sys_path) + distributions = InstalledDistributions(sys_path=venv.sys_path) + assert not distributions.dependencies_in_sync([Dependency('binary[foo]')]) @pytest.mark.requires_internet @@ -54,14 +62,16 @@ def test_unknown_extra(platform, uv_on_path): platform.run_command( [uv_on_path, 'pip', 'install', 'requests[security]==2.25.1'], check=True, capture_output=True ) - assert not dependencies_in_sync([Requirement('requests[foo]')], venv.sys_path) + distributions = InstalledDistributions(sys_path=venv.sys_path) + assert not distributions.dependencies_in_sync([Dependency('requests[foo]')]) @pytest.mark.requires_internet def test_extra_unmet(platform, uv_on_path): with TempUVVirtualEnv(sys.executable, platform) as venv: platform.run_command([uv_on_path, 'pip', 'install', 'requests==2.25.1'], check=True, capture_output=True) - assert not dependencies_in_sync([Requirement('requests[security]==2.25.1')], venv.sys_path) + distributions = InstalledDistributions(sys_path=venv.sys_path) + assert not distributions.dependencies_in_sync([Dependency('requests[security]==2.25.1')]) @pytest.mark.requires_internet @@ -70,7 +80,56 @@ def test_extra_met(platform, uv_on_path): platform.run_command( [uv_on_path, 'pip', 'install', 'requests[security]==2.25.1'], check=True, capture_output=True ) - assert dependencies_in_sync([Requirement('requests[security]==2.25.1')], venv.sys_path) + distributions = InstalledDistributions(sys_path=venv.sys_path) + assert distributions.dependencies_in_sync([Dependency('requests[security]==2.25.1')]) + + +@pytest.mark.requires_internet +def test_local_dir(hatch, temp_dir, platform, uv_on_path): + project_name = os.urandom(10).hex() + + with temp_dir.as_cwd(): + result = hatch('new', project_name) + assert result.exit_code == 0, result.output + + project_path = temp_dir / project_name + dependency_string = f'{project_name}@{project_path.as_uri()}' + with TempUVVirtualEnv(sys.executable, platform) as venv: + platform.run_command([uv_on_path, 'pip', 'install', str(project_path)], check=True, capture_output=True) + distributions = InstalledDistributions(sys_path=venv.sys_path) + assert distributions.dependencies_in_sync([Dependency(dependency_string)]) + + +@pytest.mark.requires_internet +def test_local_dir_editable(hatch, temp_dir, platform, uv_on_path): + project_name = os.urandom(10).hex() + + with temp_dir.as_cwd(): + result = hatch('new', project_name) + assert result.exit_code == 0, result.output + + project_path = temp_dir / project_name + dependency_string = f'{project_name}@{project_path.as_uri()}' + with TempUVVirtualEnv(sys.executable, platform) as venv: + platform.run_command([uv_on_path, 'pip', 'install', '-e', str(project_path)], check=True, capture_output=True) + distributions = InstalledDistributions(sys_path=venv.sys_path) + assert distributions.dependencies_in_sync([Dependency(dependency_string, editable=True)]) + + +@pytest.mark.requires_internet +def test_local_dir_editable_mismatch(hatch, temp_dir, platform, uv_on_path): + project_name = os.urandom(10).hex() + + with temp_dir.as_cwd(): + result = hatch('new', project_name) + assert result.exit_code == 0, result.output + + project_path = temp_dir / project_name + dependency_string = f'{project_name}@{project_path.as_uri()}' + with TempUVVirtualEnv(sys.executable, platform) as venv: + platform.run_command([uv_on_path, 'pip', 'install', '-e', str(project_path)], check=True, capture_output=True) + distributions = InstalledDistributions(sys_path=venv.sys_path) + assert not distributions.dependencies_in_sync([Dependency(dependency_string)]) @pytest.mark.requires_internet @@ -80,7 +139,8 @@ def test_dependency_git_pip(platform): platform.run_command( ['pip', 'install', 'requests@git+https://github.com/psf/requests'], check=True, capture_output=True ) - assert dependencies_in_sync([Requirement('requests@git+https://github.com/psf/requests')], venv.sys_path) + distributions = InstalledDistributions(sys_path=venv.sys_path) + assert distributions.dependencies_in_sync([Dependency('requests@git+https://github.com/psf/requests')]) @pytest.mark.requires_internet @@ -92,7 +152,8 @@ def test_dependency_git_uv(platform, uv_on_path): check=True, capture_output=True, ) - assert not dependencies_in_sync([Requirement('requests@git+https://github.com/psf/requests')], venv.sys_path) + distributions = InstalledDistributions(sys_path=venv.sys_path) + assert not distributions.dependencies_in_sync([Dependency('requests@git+https://github.com/psf/requests')]) @pytest.mark.requires_internet @@ -102,7 +163,8 @@ def test_dependency_git_revision_pip(platform): platform.run_command( ['pip', 'install', 'requests@git+https://github.com/psf/requests@main'], check=True, capture_output=True ) - assert dependencies_in_sync([Requirement('requests@git+https://github.com/psf/requests@main')], venv.sys_path) + distributions = InstalledDistributions(sys_path=venv.sys_path) + assert distributions.dependencies_in_sync([Dependency('requests@git+https://github.com/psf/requests@main')]) @pytest.mark.requires_internet @@ -114,9 +176,8 @@ def test_dependency_git_revision_uv(platform, uv_on_path): check=True, capture_output=True, ) - assert not dependencies_in_sync( - [Requirement('requests@git+https://github.com/psf/requests@main')], venv.sys_path - ) + distributions = InstalledDistributions(sys_path=venv.sys_path) + assert not distributions.dependencies_in_sync([Dependency('requests@git+https://github.com/psf/requests@main')]) @pytest.mark.requires_internet @@ -133,7 +194,7 @@ def test_dependency_git_commit(platform, uv_on_path): check=True, capture_output=True, ) - assert dependencies_in_sync( - [Requirement('requests@git+https://github.com/psf/requests@7f694b79e114c06fac5ec06019cada5a61e5570f')], - venv.sys_path, - ) + distributions = InstalledDistributions(sys_path=venv.sys_path) + assert distributions.dependencies_in_sync([ + Dependency('requests@git+https://github.com/psf/requests@7f694b79e114c06fac5ec06019cada5a61e5570f') + ]) diff --git a/tests/env/plugin/test_interface.py b/tests/env/plugin/test_interface.py index 645f5090f..9b8f27871 100644 --- a/tests/env/plugin/test_interface.py +++ b/tests/env/plugin/test_interface.py @@ -1,3 +1,5 @@ +import re + import pytest from hatch.config.constants import AppEnvVars @@ -1108,7 +1110,7 @@ def test_full_skip_install_and_features(self, isolation, isolated_data_dir, plat assert environment.dependencies == ['dep2', 'dep3', 'dep4'] - def test_full_dev_mode(self, isolation, isolated_data_dir, platform, global_application): + def test_full_no_dev_mode(self, isolation, isolated_data_dir, platform, global_application): config = { 'project': {'name': 'my_app', 'version': '0.0.1', 'dependencies': ['dep1']}, 'tool': { @@ -1157,6 +1159,80 @@ def test_builder(self, isolation, isolated_data_dir, platform, global_applicatio assert environment.dependencies == ['dep3', 'dep2'] + def test_workspace(self, temp_dir, isolated_data_dir, platform, temp_application): + for i in range(3): + project_file = temp_dir / f'foo{i}' / 'pyproject.toml' + project_file.parent.mkdir() + project_file.write_text( + f"""\ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "foo{i}" +version = "0.0.1" +dependencies = ["pkg-{i}"] + +[project.optional-dependencies] +feature1 = ["pkg-feature-1{i}"] +feature2 = ["pkg-feature-2{i}"] +feature3 = ["pkg-feature-3{i}"] +""" + ) + + config = { + 'project': {'name': 'my_app', 'version': '0.0.1', 'dependencies': ['dep1']}, + 'tool': { + 'hatch': { + 'envs': { + 'default': { + 'skip-install': False, + 'dependencies': ['dep2'], + 'extra-dependencies': ['dep3'], + 'workspace': { + 'members': [ + {'path': 'foo0', 'features': ['feature1']}, + {'path': 'foo1', 'features': ['feature1', 'feature2']}, + {'path': 'foo2', 'features': ['feature1', 'feature2', 'feature3']}, + ], + }, + }, + }, + }, + }, + } + project = Project(temp_dir, config=config) + project.set_app(temp_application) + temp_application.project = project + environment = MockEnvironment( + temp_dir, + project.metadata, + 'default', + project.config.envs['default'], + {}, + isolated_data_dir, + isolated_data_dir, + platform, + 0, + temp_application, + ) + + assert environment.dependencies == [ + 'dep2', + 'dep3', + 'pkg-0', + 'pkg-feature-10', + 'pkg-1', + 'pkg-feature-11', + 'pkg-feature-21', + 'pkg-2', + 'pkg-feature-12', + 'pkg-feature-22', + 'pkg-feature-32', + 'dep1', + ] + class TestScripts: @pytest.mark.parametrize('field', ['scripts', 'extra-scripts']) @@ -2071,3 +2147,558 @@ def test_env_vars_override(self, isolation, isolated_data_dir, platform, global_ ) assert environment.dependencies == ['pkg'] + + +class TestWorkspaceConfig: + def test_not_table(self, isolation, isolated_data_dir, platform, global_application): + config = { + 'project': {'name': 'my_app', 'version': '0.0.1'}, + 'tool': {'hatch': {'envs': {'default': {'workspace': 9000}}}}, + } + project = Project(isolation, config=config) + environment = MockEnvironment( + isolation, + project.metadata, + 'default', + project.config.envs['default'], + {}, + isolated_data_dir, + isolated_data_dir, + platform, + 0, + global_application, + ) + + with pytest.raises(TypeError, match='Field `tool.hatch.envs.default.workspace` must be a table'): + _ = environment.workspace + + def test_parallel_not_boolean(self, isolation, isolated_data_dir, platform, global_application): + config = { + 'project': {'name': 'my_app', 'version': '0.0.1'}, + 'tool': {'hatch': {'envs': {'default': {'workspace': {'parallel': 9000}}}}}, + } + project = Project(isolation, config=config) + environment = MockEnvironment( + isolation, + project.metadata, + 'default', + project.config.envs['default'], + {}, + isolated_data_dir, + isolated_data_dir, + platform, + 0, + global_application, + ) + + with pytest.raises(TypeError, match='Field `tool.hatch.envs.default.workspace.parallel` must be a boolean'): + _ = environment.workspace.parallel + + def test_parallel_default(self, isolation, isolated_data_dir, platform, global_application): + config = {'project': {'name': 'my_app', 'version': '0.0.1'}} + project = Project(isolation, config=config) + environment = MockEnvironment( + isolation, + project.metadata, + 'default', + project.config.envs['default'], + {}, + isolated_data_dir, + isolated_data_dir, + platform, + 0, + global_application, + ) + + assert environment.workspace.parallel is True + + def test_parallel_override(self, isolation, isolated_data_dir, platform, global_application): + config = { + 'project': {'name': 'my_app', 'version': '0.0.1'}, + 'tool': {'hatch': {'envs': {'default': {'workspace': {'parallel': False}}}}}, + } + project = Project(isolation, config=config) + environment = MockEnvironment( + isolation, + project.metadata, + 'default', + project.config.envs['default'], + {}, + isolated_data_dir, + isolated_data_dir, + platform, + 0, + global_application, + ) + + assert environment.workspace.parallel is False + + def test_members_not_table(self, isolation, isolated_data_dir, platform, global_application): + config = { + 'project': {'name': 'my_app', 'version': '0.0.1'}, + 'tool': {'hatch': {'envs': {'default': {'workspace': {'members': 9000}}}}}, + } + project = Project(isolation, config=config) + environment = MockEnvironment( + isolation, + project.metadata, + 'default', + project.config.envs['default'], + {}, + isolated_data_dir, + isolated_data_dir, + platform, + 0, + global_application, + ) + + with pytest.raises(TypeError, match='Field `tool.hatch.envs.default.workspace.members` must be an array'): + _ = environment.workspace.members + + def test_member_invalid_type(self, isolation, isolated_data_dir, platform, global_application): + config = { + 'project': {'name': 'my_app', 'version': '0.0.1'}, + 'tool': {'hatch': {'envs': {'default': {'workspace': {'members': [9000]}}}}}, + } + project = Project(isolation, config=config) + environment = MockEnvironment( + isolation, + project.metadata, + 'default', + project.config.envs['default'], + {}, + isolated_data_dir, + isolated_data_dir, + platform, + 0, + global_application, + ) + + with pytest.raises( + TypeError, + match='Member #1 of field `tool.hatch.envs.default.workspace.members` must be a string or an inline table', + ): + _ = environment.workspace.members + + def test_member_no_path(self, isolation, isolated_data_dir, platform, global_application): + config = { + 'project': {'name': 'my_app', 'version': '0.0.1'}, + 'tool': {'hatch': {'envs': {'default': {'workspace': {'members': [{}]}}}}}, + } + project = Project(isolation, config=config) + environment = MockEnvironment( + isolation, + project.metadata, + 'default', + project.config.envs['default'], + {}, + isolated_data_dir, + isolated_data_dir, + platform, + 0, + global_application, + ) + + with pytest.raises( + TypeError, + match='Member #1 of field `tool.hatch.envs.default.workspace.members` must define a `path` key', + ): + _ = environment.workspace.members + + def test_member_path_not_string(self, isolation, isolated_data_dir, platform, global_application): + config = { + 'project': {'name': 'my_app', 'version': '0.0.1'}, + 'tool': {'hatch': {'envs': {'default': {'workspace': {'members': [{'path': 9000}]}}}}}, + } + project = Project(isolation, config=config) + environment = MockEnvironment( + isolation, + project.metadata, + 'default', + project.config.envs['default'], + {}, + isolated_data_dir, + isolated_data_dir, + platform, + 0, + global_application, + ) + + with pytest.raises( + TypeError, + match='Option `path` of member #1 of field `tool.hatch.envs.default.workspace.members` must be a string', + ): + _ = environment.workspace.members + + def test_member_path_empty_string(self, isolation, isolated_data_dir, platform, global_application): + config = { + 'project': {'name': 'my_app', 'version': '0.0.1'}, + 'tool': {'hatch': {'envs': {'default': {'workspace': {'members': [{'path': ''}]}}}}}, + } + project = Project(isolation, config=config) + environment = MockEnvironment( + isolation, + project.metadata, + 'default', + project.config.envs['default'], + {}, + isolated_data_dir, + isolated_data_dir, + platform, + 0, + global_application, + ) + + with pytest.raises( + ValueError, + match=( + 'Option `path` of member #1 of field `tool.hatch.envs.default.workspace.members` ' + 'cannot be an empty string' + ), + ): + _ = environment.workspace.members + + def test_member_features_not_array(self, isolation, isolated_data_dir, platform, global_application): + config = { + 'project': {'name': 'my_app', 'version': '0.0.1'}, + 'tool': {'hatch': {'envs': {'default': {'workspace': {'members': [{'path': 'foo', 'features': 9000}]}}}}}, + } + project = Project(isolation, config=config) + environment = MockEnvironment( + isolation, + project.metadata, + 'default', + project.config.envs['default'], + {}, + isolated_data_dir, + isolated_data_dir, + platform, + 0, + global_application, + ) + + with pytest.raises( + TypeError, + match=( + 'Option `features` of member #1 of field `tool.hatch.envs.default.workspace.members` ' + 'must be an array of strings' + ), + ): + _ = environment.workspace.members + + def test_member_feature_not_string(self, isolation, isolated_data_dir, platform, global_application): + config = { + 'project': {'name': 'my_app', 'version': '0.0.1'}, + 'tool': {'hatch': {'envs': {'default': {'workspace': {'members': [{'path': 'foo', 'features': [9000]}]}}}}}, + } + project = Project(isolation, config=config) + environment = MockEnvironment( + isolation, + project.metadata, + 'default', + project.config.envs['default'], + {}, + isolated_data_dir, + isolated_data_dir, + platform, + 0, + global_application, + ) + + with pytest.raises( + TypeError, + match=( + 'Feature #1 of option `features` of member #1 of field `tool.hatch.envs.default.workspace.members` ' + 'must be a string' + ), + ): + _ = environment.workspace.members + + def test_member_feature_empty_string(self, isolation, isolated_data_dir, platform, global_application): + config = { + 'project': {'name': 'my_app', 'version': '0.0.1'}, + 'tool': {'hatch': {'envs': {'default': {'workspace': {'members': [{'path': 'foo', 'features': ['']}]}}}}}, + } + project = Project(isolation, config=config) + environment = MockEnvironment( + isolation, + project.metadata, + 'default', + project.config.envs['default'], + {}, + isolated_data_dir, + isolated_data_dir, + platform, + 0, + global_application, + ) + + with pytest.raises( + ValueError, + match=( + 'Feature #1 of option `features` of member #1 of field `tool.hatch.envs.default.workspace.members` ' + 'cannot be an empty string' + ), + ): + _ = environment.workspace.members + + def test_member_feature_duplicate(self, isolation, isolated_data_dir, platform, global_application): + config = { + 'project': {'name': 'my_app', 'version': '0.0.1'}, + 'tool': { + 'hatch': { + 'envs': {'default': {'workspace': {'members': [{'path': 'foo', 'features': ['foo', 'Foo']}]}}} + } + }, + } + project = Project(isolation, config=config) + environment = MockEnvironment( + isolation, + project.metadata, + 'default', + project.config.envs['default'], + {}, + isolated_data_dir, + isolated_data_dir, + platform, + 0, + global_application, + ) + + with pytest.raises( + ValueError, + match=( + 'Feature #2 of option `features` of member #1 of field `tool.hatch.envs.default.workspace.members` ' + 'is a duplicate' + ), + ): + _ = environment.workspace.members + + def test_member_does_not_exist(self, isolation, isolated_data_dir, platform, global_application): + config = { + 'project': {'name': 'my_app', 'version': '0.0.1'}, + 'tool': {'hatch': {'envs': {'default': {'workspace': {'members': [{'path': 'foo'}]}}}}}, + } + project = Project(isolation, config=config) + environment = MockEnvironment( + isolation, + project.metadata, + 'default', + project.config.envs['default'], + {}, + isolated_data_dir, + isolated_data_dir, + platform, + 0, + global_application, + ) + + with pytest.raises( + OSError, + match=re.escape( + f'No members could be derived from `foo` of field `tool.hatch.envs.default.workspace.members`: ' + f'{isolation / "foo"}' + ), + ): + _ = environment.workspace.members + + def test_member_not_project(self, temp_dir, isolated_data_dir, platform, global_application): + config = { + 'project': {'name': 'my_app', 'version': '0.0.1'}, + 'tool': {'hatch': {'envs': {'default': {'workspace': {'members': [{'path': 'foo'}]}}}}}, + } + project = Project(temp_dir, config=config) + environment = MockEnvironment( + temp_dir, + project.metadata, + 'default', + project.config.envs['default'], + {}, + isolated_data_dir, + isolated_data_dir, + platform, + 0, + global_application, + ) + + member_path = temp_dir / 'foo' + member_path.mkdir() + + with pytest.raises( + OSError, + match=re.escape( + f'Member derived from `foo` of field `tool.hatch.envs.default.workspace.members` is not a project ' + f'(no `pyproject.toml` file): {member_path}' + ), + ): + _ = environment.workspace.members + + def test_member_duplicate(self, temp_dir, isolated_data_dir, platform, global_application): + config = { + 'project': {'name': 'my_app', 'version': '0.0.1'}, + 'tool': {'hatch': {'envs': {'default': {'workspace': {'members': [{'path': 'foo'}, {'path': 'f*'}]}}}}}, + } + project = Project(temp_dir, config=config) + environment = MockEnvironment( + temp_dir, + project.metadata, + 'default', + project.config.envs['default'], + {}, + isolated_data_dir, + isolated_data_dir, + platform, + 0, + global_application, + ) + + member_path = temp_dir / 'foo' + member_path.mkdir() + (member_path / 'pyproject.toml').touch() + + with pytest.raises( + ValueError, + match=re.escape( + f'Member derived from `f*` of field ' + f'`tool.hatch.envs.default.workspace.members` is a duplicate: {member_path}' + ), + ): + _ = environment.workspace.members + + def test_correct(self, hatch, temp_dir, isolated_data_dir, platform, global_application): + member1_path = temp_dir / 'foo' + member2_path = temp_dir / 'bar' + member3_path = temp_dir / 'baz' + for member_path in [member1_path, member2_path, member3_path]: + with temp_dir.as_cwd(): + result = hatch('new', member_path.name) + assert result.exit_code == 0, result.output + + config = { + 'project': {'name': 'my_app', 'version': '0.0.1'}, + 'tool': {'hatch': {'envs': {'default': {'workspace': {'members': [{'path': 'foo'}, {'path': 'b*'}]}}}}}, + } + project = Project(temp_dir, config=config) + environment = MockEnvironment( + temp_dir, + project.metadata, + 'default', + project.config.envs['default'], + {}, + isolated_data_dir, + isolated_data_dir, + platform, + 0, + global_application, + ) + + members = environment.workspace.members + assert len(members) == 3 + assert members[0].project.location == member1_path + assert members[1].project.location == member2_path + assert members[2].project.location == member3_path + + +class TestWorkspaceDependencies: + def test_basic(self, temp_dir, isolated_data_dir, platform, global_application): + for i in range(3): + project_file = temp_dir / f'foo{i}' / 'pyproject.toml' + project_file.parent.mkdir() + project_file.write_text( + f"""\ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "foo{i}" +version = "0.0.1" +dependencies = ["pkg-{i}"] +""" + ) + + config = { + 'project': {'name': 'my_app', 'version': '0.0.1'}, + 'tool': {'hatch': {'envs': {'default': {'workspace': {'members': [{'path': 'f*'}]}}}}}, + } + project = Project(temp_dir, config=config) + environment = MockEnvironment( + temp_dir, + project.metadata, + 'default', + project.config.envs['default'], + {}, + isolated_data_dir, + isolated_data_dir, + platform, + 0, + global_application, + ) + + assert environment.workspace.get_dependencies() == ['pkg-0', 'pkg-1', 'pkg-2'] + + def test_features(self, temp_dir, isolated_data_dir, platform, global_application): + for i in range(3): + project_file = temp_dir / f'foo{i}' / 'pyproject.toml' + project_file.parent.mkdir() + project_file.write_text( + f"""\ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "foo{i}" +version = "0.0.1" +dependencies = ["pkg-{i}"] + +[project.optional-dependencies] +feature1 = ["pkg-feature-1{i}"] +feature2 = ["pkg-feature-2{i}"] +feature3 = ["pkg-feature-3{i}"] +""" + ) + + config = { + 'project': {'name': 'my_app', 'version': '0.0.1'}, + 'tool': { + 'hatch': { + 'envs': { + 'default': { + 'workspace': { + 'members': [ + {'path': 'foo0', 'features': ['feature1']}, + {'path': 'foo1', 'features': ['feature1', 'feature2']}, + {'path': 'foo2', 'features': ['feature1', 'feature2', 'feature3']}, + ], + }, + }, + }, + }, + }, + } + project = Project(temp_dir, config=config) + environment = MockEnvironment( + temp_dir, + project.metadata, + 'default', + project.config.envs['default'], + {}, + isolated_data_dir, + isolated_data_dir, + platform, + 0, + global_application, + ) + + assert environment.workspace.get_dependencies() == [ + 'pkg-0', + 'pkg-feature-10', + 'pkg-1', + 'pkg-feature-11', + 'pkg-feature-21', + 'pkg-2', + 'pkg-feature-12', + 'pkg-feature-22', + 'pkg-feature-32', + ] From 9fc0f41ac1a03e81b82bb34511ad6fbfffb74728 Mon Sep 17 00:00:00 2001 From: Cary Hawkins Date: Mon, 22 Sep 2025 16:19:53 -0700 Subject: [PATCH 02/55] Fix not all reqs convert to deps issue --- src/hatch/env/plugin/interface.py | 23 +++++++++++++++++++---- 1 file changed, 19 insertions(+), 4 deletions(-) diff --git a/src/hatch/env/plugin/interface.py b/src/hatch/env/plugin/interface.py index 6250f7700..154f26740 100644 --- a/src/hatch/env/plugin/interface.py +++ b/src/hatch/env/plugin/interface.py @@ -342,13 +342,27 @@ def local_dependencies_complex(self) -> list[Dependency]: @cached_property def dependencies_complex(self) -> list[Dependency]: + from hatch.dep.core import Dependency + all_dependencies_complex = list(self.environment_dependencies_complex) - all_dependencies_complex.extend(self.additional_dependencies) + + # Convert additional_dependencies to Dependency objects + for dep in self.additional_dependencies: + if isinstance(dep, Dependency): + all_dependencies_complex.append(dep) + else: + all_dependencies_complex.append(Dependency(str(dep))) + if self.builder: - from hatch.dep.core import Dependency from hatch.project.constants import BuildEnvVars - all_dependencies_complex.extend(self.metadata.build.requires_complex) + # Convert build requirements to Dependency objects + for req in self.metadata.build.requires_complex: + if isinstance(req, Dependency): + all_dependencies_complex.append(req) + else: + all_dependencies_complex.append(Dependency(str(req))) + for target in os.environ.get(BuildEnvVars.REQUESTED_TARGETS, '').split(): target_config = self.app.project.config.build.target(target) all_dependencies_complex.extend(map(Dependency, target_config.dependencies)) @@ -374,9 +388,10 @@ def dependencies(self) -> list[str]: @cached_property def all_dependencies_complex(self) -> list[Dependency]: + from hatch.dep.core import Dependency all_dependencies_complex = list(self.local_dependencies_complex) all_dependencies_complex.extend(self.dependencies_complex) - return all_dependencies_complex + return [dep if isinstance(dep, Dependency) else Dependency(str(dep)) for dep in all_dependencies_complex] @cached_property def all_dependencies(self) -> list[str]: From c0152b26b1ed1d468e1e035834a1257d5cae3535 Mon Sep 17 00:00:00 2001 From: Cary Hawkins Date: Thu, 25 Sep 2025 07:55:51 -0700 Subject: [PATCH 03/55] Additional fixes for Requirements to Dependency conversion, fix tests failing to match because of new line --- src/hatch/cli/env/run.py | 2 +- src/hatch/dep/core.py | 2 +- src/hatch/env/plugin/interface.py | 27 +++++++++++++++++++++------ tests/cli/config/test_set.py | 2 ++ tests/dep/test_sync.py | 2 -- 5 files changed, 25 insertions(+), 10 deletions(-) diff --git a/src/hatch/cli/env/run.py b/src/hatch/cli/env/run.py index b41712024..23606d866 100644 --- a/src/hatch/cli/env/run.py +++ b/src/hatch/cli/env/run.py @@ -25,7 +25,7 @@ def filter_environments(environments, filter_data): @click.option('--env', '-e', 'env_names', multiple=True, help='The environments to target') @click.option('--include', '-i', 'included_variable_specs', multiple=True, help='The matrix variables to include') @click.option('--exclude', '-x', 'excluded_variable_specs', multiple=True, help='The matrix variables to exclude') -@click.option('--filter', '-f', 'filter_json', help='The JSON data used to select environments') +@click.option('--filter', '-f', 'filter_json', default=None, help='The JSON data used to select environments') @click.option( '--force-continue', is_flag=True, help='Run every command and if there were any errors exit with the first code' ) diff --git a/src/hatch/dep/core.py b/src/hatch/dep/core.py index fe81474bb..0ba1395bf 100644 --- a/src/hatch/dep/core.py +++ b/src/hatch/dep/core.py @@ -35,4 +35,4 @@ def path(self) -> Path | None: if uri.scheme != 'file': return None - return Path(os.sep.join(uri.path)) + return Path.from_uri(self.url) diff --git a/src/hatch/env/plugin/interface.py b/src/hatch/env/plugin/interface.py index 154f26740..b7de09361 100644 --- a/src/hatch/env/plugin/interface.py +++ b/src/hatch/env/plugin/interface.py @@ -168,7 +168,7 @@ def pathsep(self) -> str: return os.pathsep @cached_property - def system_python(self): + def system_python(self) -> str: system_python = os.environ.get(AppEnvVars.PYTHON) if system_python == 'self': system_python = sys.executable @@ -308,7 +308,7 @@ def project_dependencies_complex(self) -> list[Dependency]: ) raise ValueError(message) - all_dependencies_complex.extend(optional_dependencies_complex[feature].values()) + all_dependencies_complex.extend([dep if isinstance(dep, Dependency) else Dependency(str(dep)) for dep in optional_dependencies_complex[feature]]) return all_dependencies_complex @@ -328,9 +328,8 @@ def local_dependencies_complex(self) -> list[Dependency]: local_dependencies_complex = [] if not self.skip_install: - root = 'file://' if self.sep == '/' else 'file:///' local_dependencies_complex.append( - Dependency(f'{self.metadata.name} @ {root}{self.project_root}', editable=self.dev_mode) + Dependency(f'{self.metadata.name} @ {self.root.as_uri()}', editable=self.dev_mode) ) local_dependencies_complex.extend( @@ -1025,7 +1024,22 @@ def get_dependencies(self) -> list[str]: for feature in member.features: all_dependencies.extend(features.get(feature, [])) - if not self.parallel: + if self.parallel: + from concurrent.futures import ThreadPoolExecutor + + def get_member_deps(member): + with self.env.app.status(f'Checking workspace member: {member.name}'): + dependencies, features = member.get_dependencies() + deps = list(dependencies) + for feature in member.features: + deps.extend(features.get(feature, [])) + return deps + + with ThreadPoolExecutor() as executor: + results = executor.map(get_member_deps, dynamic_members) + for deps in results: + all_dependencies.extend(deps) + else: for member in dynamic_members: with self.env.app.status(f'Checking workspace member: {member.name}'): dependencies, features = member.get_dependencies() @@ -1240,7 +1254,8 @@ def find_members(root, relative_components): else: component_matchers.append(lambda entry, component=component: component == entry.name) - yield from _recurse_members(root, 0, component_matchers) + results = list(_recurse_members(root, 0, component_matchers)) + yield from sorted(results, key=lambda path: os.path.basename(path)) def _recurse_members(root, matcher_index, matchers): diff --git a/tests/cli/config/test_set.py b/tests/cli/config/test_set.py index cd576feb4..4614e7bd0 100644 --- a/tests/cli/config/test_set.py +++ b/tests/cli/config/test_set.py @@ -184,6 +184,7 @@ def test_project_location_basic_set_first_project(hatch, config_file, helpers, t f""" New setting: project = "foo" + [projects] foo = "{path}" """ @@ -205,6 +206,7 @@ def test_project_location_complex_set_first_project(hatch, config_file, helpers, f""" New setting: project = "foo" + [projects.foo] location = "{path}" """ diff --git a/tests/dep/test_sync.py b/tests/dep/test_sync.py index 460d1b3a9..f355db6f6 100644 --- a/tests/dep/test_sync.py +++ b/tests/dep/test_sync.py @@ -152,7 +152,6 @@ def test_dependency_git_uv(platform, uv_on_path): check=True, capture_output=True, ) - assert dependencies_in_sync([Requirement('requests@git+https://github.com/psf/requests')], venv.sys_path) distributions = InstalledDistributions(sys_path=venv.sys_path) assert not distributions.dependencies_in_sync([Dependency('requests@git+https://github.com/psf/requests')]) @@ -177,7 +176,6 @@ def test_dependency_git_revision_uv(platform, uv_on_path): check=True, capture_output=True, ) - assert dependencies_in_sync([Requirement('requests@git+https://github.com/psf/requests@main')], venv.sys_path) distributions = InstalledDistributions(sys_path=venv.sys_path) assert not distributions.dependencies_in_sync([Dependency('requests@git+https://github.com/psf/requests@main')]) From 4dc4339769197fe64489229c10904b00ee981843 Mon Sep 17 00:00:00 2001 From: Cary Hawkins Date: Thu, 25 Sep 2025 23:07:02 -0700 Subject: [PATCH 04/55] Fixing tests and typing issues found during workspaces dev. --- backend/src/hatchling/builders/hooks/plugin/hooks.py | 2 +- docs/meta/authors.md | 1 + src/hatch/cli/application.py | 2 ++ src/hatch/dep/core.py | 1 - src/hatch/env/collectors/plugin/hooks.py | 2 +- tests/cli/config/test_set.py | 4 ++-- tests/cli/self/test_self.py | 2 +- tests/dep/test_sync.py | 4 ++-- tests/venv/test_core.py | 1 - 9 files changed, 10 insertions(+), 9 deletions(-) diff --git a/backend/src/hatchling/builders/hooks/plugin/hooks.py b/backend/src/hatchling/builders/hooks/plugin/hooks.py index 417521eda..e06795945 100644 --- a/backend/src/hatchling/builders/hooks/plugin/hooks.py +++ b/backend/src/hatchling/builders/hooks/plugin/hooks.py @@ -12,4 +12,4 @@ @hookimpl def hatch_register_build_hook() -> list[type[BuildHookInterface]]: - return [CustomBuildHook, VersionBuildHook] # type: ignore[list-item] + return [CustomBuildHook, VersionBuildHook] diff --git a/docs/meta/authors.md b/docs/meta/authors.md index 84ef02985..dea0c870b 100644 --- a/docs/meta/authors.md +++ b/docs/meta/authors.md @@ -17,3 +17,4 @@ - Olga Matoula [:material-github:](https://github.com/olgarithms) [:material-twitter:](https://twitter.com/olgarithms_) - Philip Blair [:material-email:](mailto:philip@pblair.org) - Robert Rosca [:material-github:](https://github.com/robertrosca) +- Cary Hawkins [:material-github](https://github.com/cjames23) diff --git a/src/hatch/cli/application.py b/src/hatch/cli/application.py index 3af8db1eb..48b2f6421 100644 --- a/src/hatch/cli/application.py +++ b/src/hatch/cli/application.py @@ -70,6 +70,8 @@ def run_shell_commands(self, context: ExecutionContext) -> None: command = command[2:] process = context.env.run_shell_command(command) + sys.stdout.flush() + sys.stderr.flush() if process.returncode: first_error_code = first_error_code or process.returncode if continue_on_error: diff --git a/src/hatch/dep/core.py b/src/hatch/dep/core.py index 0ba1395bf..c8b93f527 100644 --- a/src/hatch/dep/core.py +++ b/src/hatch/dep/core.py @@ -1,6 +1,5 @@ from __future__ import annotations -import os from functools import cached_property from packaging.requirements import InvalidRequirement, Requirement diff --git a/src/hatch/env/collectors/plugin/hooks.py b/src/hatch/env/collectors/plugin/hooks.py index 102368835..f2c7f174d 100644 --- a/src/hatch/env/collectors/plugin/hooks.py +++ b/src/hatch/env/collectors/plugin/hooks.py @@ -12,4 +12,4 @@ @hookimpl def hatch_register_environment_collector() -> list[type[EnvironmentCollectorInterface]]: - return [CustomEnvironmentCollector, DefaultEnvironmentCollector] # type: ignore[list-item] + return [CustomEnvironmentCollector, DefaultEnvironmentCollector] diff --git a/tests/cli/config/test_set.py b/tests/cli/config/test_set.py index 4614e7bd0..73bb9bc03 100644 --- a/tests/cli/config/test_set.py +++ b/tests/cli/config/test_set.py @@ -184,7 +184,7 @@ def test_project_location_basic_set_first_project(hatch, config_file, helpers, t f""" New setting: project = "foo" - + [projects] foo = "{path}" """ @@ -206,7 +206,7 @@ def test_project_location_complex_set_first_project(hatch, config_file, helpers, f""" New setting: project = "foo" - + [projects.foo] location = "{path}" """ diff --git a/tests/cli/self/test_self.py b/tests/cli/self/test_self.py index cd1a4aed1..1e8e66052 100644 --- a/tests/cli/self/test_self.py +++ b/tests/cli/self/test_self.py @@ -2,6 +2,6 @@ def test(hatch): - result = hatch(os.environ['PYAPP_COMMAND_NAME']) + result = hatch() assert result.exit_code == 0, result.output diff --git a/tests/dep/test_sync.py b/tests/dep/test_sync.py index f355db6f6..28021da6c 100644 --- a/tests/dep/test_sync.py +++ b/tests/dep/test_sync.py @@ -153,7 +153,7 @@ def test_dependency_git_uv(platform, uv_on_path): capture_output=True, ) distributions = InstalledDistributions(sys_path=venv.sys_path) - assert not distributions.dependencies_in_sync([Dependency('requests@git+https://github.com/psf/requests')]) + assert distributions.dependencies_in_sync([Dependency('requests@git+https://github.com/psf/requests')]) @pytest.mark.requires_internet @@ -177,7 +177,7 @@ def test_dependency_git_revision_uv(platform, uv_on_path): capture_output=True, ) distributions = InstalledDistributions(sys_path=venv.sys_path) - assert not distributions.dependencies_in_sync([Dependency('requests@git+https://github.com/psf/requests@main')]) + assert distributions.dependencies_in_sync([Dependency('requests@git+https://github.com/psf/requests@main')]) @pytest.mark.requires_internet diff --git a/tests/venv/test_core.py b/tests/venv/test_core.py index 43b80a1fc..c7facfd9b 100644 --- a/tests/venv/test_core.py +++ b/tests/venv/test_core.py @@ -134,7 +134,6 @@ def test_creation_allow_system_packages(temp_dir, platform, extract_installed_re with venv: output = platform.run_command(['pip', 'freeze'], check=True, capture_output=True).stdout.decode('utf-8') - assert len(extract_installed_requirements(output.splitlines())) > 0 From 203b30e1c6a4019c07f30bb40956a0d2b7316cb2 Mon Sep 17 00:00:00 2001 From: Cary Hawkins Date: Thu, 25 Sep 2025 23:34:25 -0700 Subject: [PATCH 05/55] Fix failing tests, handle subprocess buffering causing output issues on terminal --- src/hatch/cli/application.py | 2 ++ tests/cli/config/test_set.py | 2 ++ tests/cli/self/test_self.py | 5 +---- 3 files changed, 5 insertions(+), 4 deletions(-) diff --git a/src/hatch/cli/application.py b/src/hatch/cli/application.py index 58cc826fe..19d1e48ee 100644 --- a/src/hatch/cli/application.py +++ b/src/hatch/cli/application.py @@ -71,6 +71,8 @@ def run_shell_commands(self, context: ExecutionContext) -> None: command = command[2:] process = context.env.run_shell_command(command) + sys.stdout.flush() + sys.stderr.flush() if process.returncode: first_error_code = first_error_code or process.returncode if continue_on_error: diff --git a/tests/cli/config/test_set.py b/tests/cli/config/test_set.py index cd576feb4..73bb9bc03 100644 --- a/tests/cli/config/test_set.py +++ b/tests/cli/config/test_set.py @@ -184,6 +184,7 @@ def test_project_location_basic_set_first_project(hatch, config_file, helpers, t f""" New setting: project = "foo" + [projects] foo = "{path}" """ @@ -205,6 +206,7 @@ def test_project_location_complex_set_first_project(hatch, config_file, helpers, f""" New setting: project = "foo" + [projects.foo] location = "{path}" """ diff --git a/tests/cli/self/test_self.py b/tests/cli/self/test_self.py index cd1a4aed1..6711c6f0a 100644 --- a/tests/cli/self/test_self.py +++ b/tests/cli/self/test_self.py @@ -1,7 +1,4 @@ -import os - - def test(hatch): - result = hatch(os.environ['PYAPP_COMMAND_NAME']) + result = hatch() assert result.exit_code == 0, result.output From ef09d2a2c6b84eff9d36f7e0709514e1e97f8342 Mon Sep 17 00:00:00 2001 From: Cary Hawkins Date: Fri, 26 Sep 2025 08:29:45 -0700 Subject: [PATCH 06/55] Remove unused type-ignore comments --- backend/src/hatchling/builders/hooks/plugin/hooks.py | 2 +- src/hatch/env/collectors/plugin/hooks.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/backend/src/hatchling/builders/hooks/plugin/hooks.py b/backend/src/hatchling/builders/hooks/plugin/hooks.py index 417521eda..e06795945 100644 --- a/backend/src/hatchling/builders/hooks/plugin/hooks.py +++ b/backend/src/hatchling/builders/hooks/plugin/hooks.py @@ -12,4 +12,4 @@ @hookimpl def hatch_register_build_hook() -> list[type[BuildHookInterface]]: - return [CustomBuildHook, VersionBuildHook] # type: ignore[list-item] + return [CustomBuildHook, VersionBuildHook] diff --git a/src/hatch/env/collectors/plugin/hooks.py b/src/hatch/env/collectors/plugin/hooks.py index 102368835..f2c7f174d 100644 --- a/src/hatch/env/collectors/plugin/hooks.py +++ b/src/hatch/env/collectors/plugin/hooks.py @@ -12,4 +12,4 @@ @hookimpl def hatch_register_environment_collector() -> list[type[EnvironmentCollectorInterface]]: - return [CustomEnvironmentCollector, DefaultEnvironmentCollector] # type: ignore[list-item] + return [CustomEnvironmentCollector, DefaultEnvironmentCollector] From 9079b2ae34e4c89aa42000412f3be48e49b20147 Mon Sep 17 00:00:00 2001 From: Cary Hawkins Date: Fri, 26 Sep 2025 13:08:05 -0700 Subject: [PATCH 07/55] Remove 3.8 from test workflows. --- .github/workflows/test.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 0d4b62408..6901391fa 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -24,7 +24,7 @@ jobs: fail-fast: false matrix: os: [ubuntu-latest, windows-latest, macos-latest] - python-version: ['3.8', '3.9', '3.10', '3.11', '3.12', '3.13'] + python-version: ['3.9', '3.10', '3.11', '3.12', '3.13'] steps: - uses: actions/checkout@v4 @@ -115,7 +115,7 @@ jobs: strategy: fail-fast: false matrix: - python-version: ['3.8', '3.9', '3.10', '3.11', '3.12', '3.13'] + python-version: ['3.9', '3.10', '3.11', '3.12', '3.13'] steps: - uses: actions/checkout@v4 From 83b119d8b51f58c3e3a610373c80e909c8fa5560 Mon Sep 17 00:00:00 2001 From: Cary Hawkins Date: Fri, 26 Sep 2025 16:51:23 -0700 Subject: [PATCH 08/55] Drop 3.8 in classifiers and add changelogs to history docs. --- backend/pyproject.toml | 3 +-- docs/history/hatch.md | 9 ++++++++- docs/history/hatchling.md | 2 ++ pyproject.toml | 1 - 4 files changed, 11 insertions(+), 4 deletions(-) diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 722b57317..a82876213 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -9,7 +9,7 @@ dynamic = ["version"] description = "Modern, extensible Python build backend" readme = "README.md" license = "MIT" -requires-python = ">=3.8" +requires-python = ">=3.9" keywords = [ "build", "hatch", @@ -24,7 +24,6 @@ classifiers = [ "License :: OSI Approved :: MIT License", "Natural Language :: English", "Operating System :: OS Independent", - "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", diff --git a/docs/history/hatch.md b/docs/history/hatch.md index e8988d7c5..58695b9b8 100644 --- a/docs/history/hatch.md +++ b/docs/history/hatch.md @@ -5,12 +5,19 @@ All notable changes to Hatch will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - ## Unreleased +***Changed:*** +- Drop support for Python 3.8 + +***Fixed:*** +- Fix issue where terminal output would be out of sync during build. + +## [1.41.2](https://github.com/pypa/hatch/releases/tag/hatch-v1.14.2) 2025-09-23 ## {: #hatch-v1.14.2 } ***Changed:*** - Environment type plugins are now no longer expected to support a pseudo-build environment as any environment now may be used for building. The following methods have been removed: `build_environment`, `build_environment_exists`, `run_builder`, `construct_build_command` +- Fix for Click Sentinel value when using `run` command ***Added:*** diff --git a/docs/history/hatchling.md b/docs/history/hatchling.md index c315af349..d1e916b5b 100644 --- a/docs/history/hatchling.md +++ b/docs/history/hatchling.md @@ -7,6 +7,8 @@ All notable changes to Hatchling will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## Unreleased +***Changed:*** +- Drop support for Python 3.8 ## [1.27.0](https://github.com/pypa/hatch/releases/tag/hatchling-v1.27.0) - 2024-11-26 ## {: #hatchling-v1.27.0 } diff --git a/pyproject.toml b/pyproject.toml index 268302614..b0976b69f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -28,7 +28,6 @@ classifiers = [ "License :: OSI Approved :: MIT License", "Natural Language :: English", "Operating System :: OS Independent", - "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", From 74d0daa9283d1be26d9c9040622be7e83a95f46a Mon Sep 17 00:00:00 2001 From: Cary Hawkins Date: Fri, 26 Sep 2025 16:55:45 -0700 Subject: [PATCH 09/55] Fix requires-python for hatch --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index b0976b69f..bdfc9f150 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,7 +7,7 @@ name = "hatch" description = "Modern, extensible Python project management" readme = "README.md" license = "MIT" -requires-python = ">=3.8" +requires-python = ">=3.9" keywords = [ "build", "dependency", From 4260722c8e9c58f6e7a715e66435b86e243259a4 Mon Sep 17 00:00:00 2001 From: Cary Hawkins Date: Fri, 26 Sep 2025 18:49:28 -0700 Subject: [PATCH 10/55] Fix history doc for unreleased changes --- docs/history/hatch.md | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/docs/history/hatch.md b/docs/history/hatch.md index 58695b9b8..869e76d95 100644 --- a/docs/history/hatch.md +++ b/docs/history/hatch.md @@ -5,18 +5,14 @@ All notable changes to Hatch will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + ## Unreleased ***Changed:*** - Drop support for Python 3.8 +- Environment type plugins are now no longer expected to support a pseudo-build environment as any environment now may be used for building. The following methods have been removed: `build_environment`, `build_environment_exists`, `run_builder`, `construct_build_command` ***Fixed:*** - Fix issue where terminal output would be out of sync during build. - -## [1.41.2](https://github.com/pypa/hatch/releases/tag/hatch-v1.14.2) 2025-09-23 ## {: #hatch-v1.14.2 } - -***Changed:*** - -- Environment type plugins are now no longer expected to support a pseudo-build environment as any environment now may be used for building. The following methods have been removed: `build_environment`, `build_environment_exists`, `run_builder`, `construct_build_command` - Fix for Click Sentinel value when using `run` command ***Added:*** From f44823993c956c395ada767066c7a91a2aea1081 Mon Sep 17 00:00:00 2001 From: Cary Hawkins Date: Fri, 26 Sep 2025 20:40:09 -0700 Subject: [PATCH 11/55] Fix: self test needed to have an additional arg, history doc formatting --- docs/history/hatch.md | 10 ++++++---- tests/cli/self/test_self.py | 5 ++++- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/docs/history/hatch.md b/docs/history/hatch.md index 869e76d95..fad784280 100644 --- a/docs/history/hatch.md +++ b/docs/history/hatch.md @@ -8,13 +8,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), ## Unreleased ***Changed:*** + - Drop support for Python 3.8 - Environment type plugins are now no longer expected to support a pseudo-build environment as any environment now may be used for building. The following methods have been removed: `build_environment`, `build_environment_exists`, `run_builder`, `construct_build_command` -***Fixed:*** -- Fix issue where terminal output would be out of sync during build. -- Fix for Click Sentinel value when using `run` command - ***Added:*** - The `version` and `project metadata` commands now support projects that do not use Hatchling as the build backend @@ -23,6 +20,11 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), - The environment interface now has the following methods and properties in order to better support builds on remote machines: `project_root`, `sep`, `pathsep`, `fs_context` - Bump the minimum supported version of `packaging` to 24.2 +***Fixed:*** + +- Fix issue where terminal output would be out of sync during build. +- Fix for Click Sentinel value when using `run` command + ## [1.13.0](https://github.com/pypa/hatch/releases/tag/hatch-v1.13.0) - 2024-10-13 ## {: #hatch-v1.13.0 } ***Added:*** diff --git a/tests/cli/self/test_self.py b/tests/cli/self/test_self.py index 6711c6f0a..7a11ebe04 100644 --- a/tests/cli/self/test_self.py +++ b/tests/cli/self/test_self.py @@ -1,4 +1,7 @@ +import os + + def test(hatch): - result = hatch() + result = hatch(os.environ['PYAPP_COMMAND_NAME'], "-h") assert result.exit_code == 0, result.output From 9c7a8800cab2967ec86ced345adabfa7cf10b22d Mon Sep 17 00:00:00 2001 From: Cary Date: Fri, 26 Sep 2025 21:52:31 -0700 Subject: [PATCH 12/55] Update docs/history/hatch.md Co-authored-by: Ofek Lev --- docs/history/hatch.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/history/hatch.md b/docs/history/hatch.md index fad784280..54d0f0f69 100644 --- a/docs/history/hatch.md +++ b/docs/history/hatch.md @@ -7,6 +7,7 @@ All notable changes to Hatch will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## Unreleased + ***Changed:*** - Drop support for Python 3.8 From 9d83042b09fa1e2e809fd0d4d234fa797f812aa0 Mon Sep 17 00:00:00 2001 From: Cary Date: Fri, 26 Sep 2025 21:52:39 -0700 Subject: [PATCH 13/55] Update docs/history/hatchling.md Co-authored-by: Ofek Lev --- docs/history/hatchling.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/history/hatchling.md b/docs/history/hatchling.md index d1e916b5b..a6b0e7fdb 100644 --- a/docs/history/hatchling.md +++ b/docs/history/hatchling.md @@ -7,7 +7,9 @@ All notable changes to Hatchling will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## Unreleased + ***Changed:*** + - Drop support for Python 3.8 ## [1.27.0](https://github.com/pypa/hatch/releases/tag/hatchling-v1.27.0) - 2024-11-26 ## {: #hatchling-v1.27.0 } From 0a4b9af59c1fa1946f506728a7b11da249705681 Mon Sep 17 00:00:00 2001 From: Cary Hawkins Date: Sat, 27 Sep 2025 13:42:15 -0700 Subject: [PATCH 14/55] Fix: formatting --- docs/history/hatch.md | 1 + docs/history/hatchling.md | 2 ++ tests/cli/self/test_self.py | 2 +- 3 files changed, 4 insertions(+), 1 deletion(-) diff --git a/docs/history/hatch.md b/docs/history/hatch.md index fad784280..54d0f0f69 100644 --- a/docs/history/hatch.md +++ b/docs/history/hatch.md @@ -7,6 +7,7 @@ All notable changes to Hatch will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## Unreleased + ***Changed:*** - Drop support for Python 3.8 diff --git a/docs/history/hatchling.md b/docs/history/hatchling.md index d1e916b5b..a6b0e7fdb 100644 --- a/docs/history/hatchling.md +++ b/docs/history/hatchling.md @@ -7,7 +7,9 @@ All notable changes to Hatchling will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## Unreleased + ***Changed:*** + - Drop support for Python 3.8 ## [1.27.0](https://github.com/pypa/hatch/releases/tag/hatchling-v1.27.0) - 2024-11-26 ## {: #hatchling-v1.27.0 } diff --git a/tests/cli/self/test_self.py b/tests/cli/self/test_self.py index 7a11ebe04..3059905f5 100644 --- a/tests/cli/self/test_self.py +++ b/tests/cli/self/test_self.py @@ -2,6 +2,6 @@ def test(hatch): - result = hatch(os.environ['PYAPP_COMMAND_NAME'], "-h") + result = hatch(os.environ['PYAPP_COMMAND_NAME'], '-h') assert result.exit_code == 0, result.output From 6f071233d38199a8f5e6fe5b89c8de0599b504e8 Mon Sep 17 00:00:00 2001 From: Cary Hawkins Date: Thu, 2 Oct 2025 19:12:37 -0700 Subject: [PATCH 15/55] Add Workspace config model and tests for workspace support. --- src/hatch/env/plugin/interface.py | 8 +- src/hatch/project/config.py | 63 ++++++ tests/workspaces/__init__.py | 0 tests/workspaces/configuration.py | 333 ++++++++++++++++++++++++++++++ 4 files changed, 402 insertions(+), 2 deletions(-) create mode 100644 tests/workspaces/__init__.py create mode 100644 tests/workspaces/configuration.py diff --git a/src/hatch/env/plugin/interface.py b/src/hatch/env/plugin/interface.py index b7de09361..532b939c6 100644 --- a/src/hatch/env/plugin/interface.py +++ b/src/hatch/env/plugin/interface.py @@ -308,7 +308,10 @@ def project_dependencies_complex(self) -> list[Dependency]: ) raise ValueError(message) - all_dependencies_complex.extend([dep if isinstance(dep, Dependency) else Dependency(str(dep)) for dep in optional_dependencies_complex[feature]]) + all_dependencies_complex.extend([ + dep if isinstance(dep, Dependency) else Dependency(str(dep)) + for dep in optional_dependencies_complex[feature] + ]) return all_dependencies_complex @@ -388,6 +391,7 @@ def dependencies(self) -> list[str]: @cached_property def all_dependencies_complex(self) -> list[Dependency]: from hatch.dep.core import Dependency + all_dependencies_complex = list(self.local_dependencies_complex) all_dependencies_complex.extend(self.dependencies_complex) return [dep if isinstance(dep, Dependency) else Dependency(str(dep)) for dep in all_dependencies_complex] @@ -1255,7 +1259,7 @@ def find_members(root, relative_components): component_matchers.append(lambda entry, component=component: component == entry.name) results = list(_recurse_members(root, 0, component_matchers)) - yield from sorted(results, key=lambda path: os.path.basename(path)) + yield from sorted(results, key=os.path.basename) def _recurse_members(root, matcher_index, matchers): diff --git a/src/hatch/project/config.py b/src/hatch/project/config.py index b6a1bdb18..47392b9f5 100644 --- a/src/hatch/project/config.py +++ b/src/hatch/project/config.py @@ -14,6 +14,7 @@ if TYPE_CHECKING: from hatch.dep.core import Dependency + from hatch.utils.fs import Path class ProjectConfig: @@ -424,6 +425,20 @@ def envs(self): for environment_collector in environment_collectors: environment_collector.finalize_environments(final_config) + # Add workspace environments if this is a workspace member + workspace_root = self._find_workspace_root() + if workspace_root and workspace_root != self.root: + try: + from hatch.project.core import Project + workspace_project = Project(workspace_root, locate=False) + workspace_config = ProjectConfig(workspace_root, workspace_project.metadata.hatch.config, + self.plugin_manager) + for env_name, env_config in workspace_config.envs.items(): + if env_name not in final_config: + final_config[env_name] = env_config + except Exception: + pass + self._matrices = all_matrices self._internal_matrices = {} self._envs = final_config @@ -443,6 +458,23 @@ def envs(self): return self._envs + def _find_workspace_root(self) -> Path | None: + """Find workspace root by traversing up from current working directory.""" + from hatch.utils.fs import Path + current = Path.cwd() + while current.parent != current: + pyproject = current / "pyproject.toml" + if pyproject.exists(): + try: + from hatch.utils.toml import load_toml_file + config = load_toml_file(str(pyproject)) + if config.get("tool", {}).get("hatch", {}).get("workspace"): + return current + except Exception: + pass + current = current.parent + return None + @property def publish(self): if self._publish is None: @@ -501,6 +533,15 @@ def scripts(self): return self._scripts + @cached_property + def workspace(self): + config = self.config.get("workspace", {}) + if not isinstance(config, dict): + message = "Field `tool.hatch.workspace` must be a table" + raise TypeError(message) + + return WorkspaceConfig(config, self.root) + def finalize_env_overrides(self, option_types): # We lazily apply overrides because we need type information potentially defined by # environment plugins for their options @@ -728,6 +769,28 @@ def finalize_hook_config(hook_config: dict[str, dict[str, Any]]) -> dict[str, di return final_hook_config +class WorkspaceConfig: + def __init__(self, config: dict[str, Any], root: Path): + self.__config = config + self.__root = root + + @cached_property + def members(self) -> list[str]: + members = self.__config.get("members", []) + if not isinstance(members, list): + message = "Field `tool.hatch.workspace.members` must be an array" + raise TypeError(message) + return members + + @cached_property + def exclude(self) -> list[str]: + exclude = self.__config.get("exclude", []) + if not isinstance(exclude, list): + message = "Field `tool.hatch.workspace.exclude` must be an array" + raise TypeError(message) + return exclude + + def env_var_enabled(env_var: str, *, default: bool = False) -> bool: if env_var in environ: return environ[env_var] in {'1', 'true'} diff --git a/tests/workspaces/__init__.py b/tests/workspaces/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/workspaces/configuration.py b/tests/workspaces/configuration.py new file mode 100644 index 000000000..225f86b3f --- /dev/null +++ b/tests/workspaces/configuration.py @@ -0,0 +1,333 @@ +import pytest +from hatch.project.core import Project +from hatch.utils.fs import Path as HatchPath + + +class TestWorkspaceConfiguration: + def test_workspace_members_editable_install(self, temp_dir, hatch): + """Test that workspace members are installed as editable packages.""" + # Create workspace root + workspace_root = temp_dir / "workspace" + workspace_root.mkdir() + + # Create workspace pyproject.toml + workspace_config = workspace_root / "pyproject.toml" + workspace_config.write_text(""" +[project] +name = "workspace-root" +version = "0.1.0" +[tool.hatch.workspace] +members = ["packages/*"] + +[tool.hatch.envs.default] +type = "virtual" +""") + + # Create workspace members + packages_dir = workspace_root / "packages" + packages_dir.mkdir() + + # Member 1 + member1_dir = packages_dir / "member1" + member1_dir.mkdir() + (member1_dir / "pyproject.toml").write_text(""" +[project] +name = "member1" +version = "0.1.0" +dependencies = ["requests"] +""") + + # Member 2 + member2_dir = packages_dir / "member2" + member2_dir.mkdir() + (member2_dir / "pyproject.toml").write_text(""" +[project] +name = "member2" +version = "0.1.0" +dependencies = ["click"] +""") + + with workspace_root.as_cwd(): + # Test environment creation includes workspace members + result = hatch('env', 'create') + assert result.exit_code == 0 + + # Verify workspace members are discovered + result = hatch('env', 'show', '--json') + assert result.exit_code == 0 + + def test_workspace_exclude_patterns(self, temp_dir, hatch): + """Test that exclude patterns filter out workspace members.""" + workspace_root = temp_dir / "workspace" + workspace_root.mkdir() + + workspace_config = workspace_root / "pyproject.toml" + workspace_config.write_text(""" +[project] +name = "workspace-root" +version = "0.1.0" +[tool.hatch.workspace] +members = ["packages/*"] +exclude = ["packages/excluded*"] +""") + + packages_dir = workspace_root / "packages" + packages_dir.mkdir() + + # Included member + included_dir = packages_dir / "included" + included_dir.mkdir() + (included_dir / "pyproject.toml").write_text(""" +[project] +name = "included" +version = "0.1.0" +""") + + # Excluded member + excluded_dir = packages_dir / "excluded-pkg" + excluded_dir.mkdir() + (excluded_dir / "pyproject.toml").write_text(""" +[project] +name = "excluded-pkg" +version = "0.1.0" +""") + + with workspace_root.as_cwd(): + result = hatch('env', 'create') + assert result.exit_code == 0 + + def test_workspace_parallel_dependency_resolution(self, temp_dir, hatch): + """Test parallel dependency resolution for workspace members.""" + workspace_root = temp_dir / "workspace" + workspace_root.mkdir() + + workspace_config = workspace_root / "pyproject.toml" + workspace_config.write_text(""" +[project] +name = "workspace-root" +version = "0.1.0" +[tool.hatch.workspace] +members = ["packages/*"] + +[tool.hatch.envs.default] +workspace.parallel = true +""") + + packages_dir = workspace_root / "packages" + packages_dir.mkdir() + + # Create multiple members + for i in range(3): + member_dir = packages_dir / f"member{i}" + member_dir.mkdir() + (member_dir / "pyproject.toml").write_text(f""" +[project] +name = "member{i}" +version = "0.1.{i}" +dependencies = ["requests"] +""") + + with workspace_root.as_cwd(): + result = hatch('env', 'create') + assert result.exit_code == 0 + + def test_workspace_member_features(self, temp_dir, hatch): + """Test workspace members with specific features.""" + workspace_root = temp_dir / "workspace" + workspace_root.mkdir() + + workspace_config = workspace_root / "pyproject.toml" + workspace_config.write_text(""" +[project] +name = "workspace-root" +version = "0.1.0" +[tool.hatch.envs.default] +workspace.members = [ + {path = "packages/member1", features = ["dev", "test"]} +] +""") + + packages_dir = workspace_root / "packages" + packages_dir.mkdir() + + member1_dir = packages_dir / "member1" + member1_dir.mkdir() + (member1_dir / "pyproject.toml").write_text(""" +[project] +name = "member1" +dependencies = ["requests"] +version = "0.1.0" +[project.optional-dependencies] +dev = ["black", "ruff"] +test = ["pytest"] +""") + + with workspace_root.as_cwd(): + result = hatch('env', 'create') + assert result.exit_code == 0 + + def test_workspace_inheritance_from_root(self, temp_dir, hatch): + """Test that workspace members inherit environments from root.""" + workspace_root = temp_dir / "workspace" + workspace_root.mkdir() + + # Workspace root with shared environment + workspace_config = workspace_root / "pyproject.toml" + workspace_config.write_text(""" +[project] +name = "workspace-root" +version = "0.1.0" +[tool.hatch.workspace] +members = ["packages/*"] + +[tool.hatch.envs.shared] +dependencies = ["pytest", "black"] +scripts.test = "pytest" +""") + + packages_dir = workspace_root / "packages" + packages_dir.mkdir() + + # Member without local shared environment + member_dir = packages_dir / "member1" + member_dir.mkdir() + (member_dir / "pyproject.toml").write_text(""" +[project] +name = "member1" +version = "0.1.0" +[tool.hatch.envs.default] +dependencies = ["requests"] +""") + + # Test from workspace root + with workspace_root.as_cwd(): + result = hatch('env', 'show', 'shared') + assert result.exit_code == 0 + + # Test from member directory + with member_dir.as_cwd(): + result = hatch('env', 'show', 'shared') + assert result.exit_code == 0 + + def test_workspace_no_members_fallback(self, temp_dir, hatch): + """Test fallback when no workspace members are defined.""" + workspace_root = temp_dir / "workspace" + workspace_root.mkdir() + + workspace_config = workspace_root / "pyproject.toml" + workspace_config.write_text(""" +[project] +name = "workspace-root" +version = "0.1.0" +[tool.hatch.envs.default] +dependencies = ["requests"] +""") + + with workspace_root.as_cwd(): + result = hatch('env', 'create') + assert result.exit_code == 0 + + result = hatch('env', 'show', '--json') + assert result.exit_code == 0 + + def test_workspace_cross_member_dependencies(self, temp_dir, hatch): + """Test workspace members depending on each other.""" + workspace_root = temp_dir / "workspace" + workspace_root.mkdir() + + workspace_config = workspace_root / "pyproject.toml" + workspace_config.write_text(""" +[project] +name = "workspace-root" +version = "0.1.0" +[tool.hatch.workspace] +members = ["packages/*"] +""") + + packages_dir = workspace_root / "packages" + packages_dir.mkdir() + + # Base library + base_dir = packages_dir / "base" + base_dir.mkdir() + (base_dir / "pyproject.toml").write_text(""" +[project] +name = "base" +version = "0.1.0" +dependencies = ["requests"] +""") + + # App depending on base + app_dir = packages_dir / "app" + app_dir.mkdir() + (app_dir / "pyproject.toml").write_text(""" +[project] +name = "app" +version = "0.1.0" +dependencies = ["base", "click"] +""") + + with workspace_root.as_cwd(): + result = hatch('env', 'create') + assert result.exit_code == 0 + + # Test that dependencies are resolved + result = hatch('dep', 'show', 'table') + assert result.exit_code == 0 + + def test_workspace_build_all_members(self, temp_dir, hatch): + """Test building all workspace members.""" + workspace_root = temp_dir / "workspace" + workspace_root.mkdir() + + # Create workspace root package + workspace_pkg = workspace_root / "workspace_root" + workspace_pkg.mkdir() + (workspace_pkg / "__init__.py").write_text('__version__ = "0.1.0"') + + workspace_config = workspace_root / "pyproject.toml" + workspace_config.write_text(""" + [project] + name = "workspace-root" + version = "0.1.0" + + [tool.hatch.workspace] + members = ["packages/*"] + + [build-system] + requires = ["hatchling"] + build-backend = "hatchling.build" + + [tool.hatch.build.targets.wheel] + packages = ["workspace_root"] + """) + + packages_dir = workspace_root / "packages" + packages_dir.mkdir() + + # Create buildable members + for i in range(2): + member_dir = packages_dir / f"member{i}" + member_dir.mkdir() + (member_dir / "pyproject.toml").write_text(f""" + [project] + name = "member{i}" + version = "0.1.{i}" + + [build-system] + requires = ["hatchling"] + build-backend = "hatchling.build" + + [tool.hatch.build.targets.wheel] + packages = ["member{i}"] + """) + + # Create source files + src_dir = member_dir / f"member{i}" + src_dir.mkdir() + (src_dir / "__init__.py").write_text(f'__version__ = "0.1.{i}"') + + with workspace_root.as_cwd(): + result = hatch('build') + assert result.exit_code == 0 + From e0891244d3e03df66ee078641def37f5dc3ee3c6 Mon Sep 17 00:00:00 2001 From: Cary Hawkins Date: Fri, 3 Oct 2025 15:42:58 -0700 Subject: [PATCH 16/55] Add workspace aware project discovery --- src/hatch/cli/__init__.py | 29 +++++++++++++++++++++++++++-- src/hatch/project/config.py | 31 ------------------------------- 2 files changed, 27 insertions(+), 33 deletions(-) diff --git a/src/hatch/cli/__init__.py b/src/hatch/cli/__init__.py index 242b7b626..0ae020b7b 100644 --- a/src/hatch/cli/__init__.py +++ b/src/hatch/cli/__init__.py @@ -28,6 +28,19 @@ from hatch.utils.ci import running_in_ci from hatch.utils.fs import Path +def find_workspace_root(path: Path) -> Path | None: + """Find workspace root by traversing up from given path.""" + current = path + while current.parent != current: + pyproject = current / "pyproject.toml" + if pyproject.exists(): + from hatch.utils.toml import load_toml_file + config = load_toml_file(str(pyproject)) + if config.get("tool", {}).get("hatch", {}).get("workspace"): + return current + current = current.parent + return None + @click.group( context_settings={'help_option_names': ['-h', '--help'], 'max_content_width': 120}, invoke_without_command=True @@ -170,8 +183,20 @@ def hatch(ctx: click.Context, env_name, project, verbose, quiet, color, interact app.project.set_app(app) return - app.project = Project(Path.cwd()) - app.project.set_app(app) + # Discover workspace-aware project + workspace_root = find_workspace_root(Path.cwd()) + if workspace_root: + # Create project from workspace root with workspace context + app.project = Project(workspace_root, locate=False) + app.project.set_app(app) + # Set current member context if we're in a member directory + current_dir = Path.cwd() + if current_dir != workspace_root: + app.project._current_member_path = current_dir + else: + # No workspace, use current directory as before + app.project = Project(Path.cwd()) + app.project.set_app(app) if app.config.mode == 'local': return diff --git a/src/hatch/project/config.py b/src/hatch/project/config.py index 47392b9f5..a5915901b 100644 --- a/src/hatch/project/config.py +++ b/src/hatch/project/config.py @@ -425,20 +425,6 @@ def envs(self): for environment_collector in environment_collectors: environment_collector.finalize_environments(final_config) - # Add workspace environments if this is a workspace member - workspace_root = self._find_workspace_root() - if workspace_root and workspace_root != self.root: - try: - from hatch.project.core import Project - workspace_project = Project(workspace_root, locate=False) - workspace_config = ProjectConfig(workspace_root, workspace_project.metadata.hatch.config, - self.plugin_manager) - for env_name, env_config in workspace_config.envs.items(): - if env_name not in final_config: - final_config[env_name] = env_config - except Exception: - pass - self._matrices = all_matrices self._internal_matrices = {} self._envs = final_config @@ -458,23 +444,6 @@ def envs(self): return self._envs - def _find_workspace_root(self) -> Path | None: - """Find workspace root by traversing up from current working directory.""" - from hatch.utils.fs import Path - current = Path.cwd() - while current.parent != current: - pyproject = current / "pyproject.toml" - if pyproject.exists(): - try: - from hatch.utils.toml import load_toml_file - config = load_toml_file(str(pyproject)) - if config.get("tool", {}).get("hatch", {}).get("workspace"): - return current - except Exception: - pass - current = current.parent - return None - @property def publish(self): if self._publish is None: From 49640c129dd106c8fb3cf10d9853446fb9b1f8cc Mon Sep 17 00:00:00 2001 From: Ofek Lev Date: Sun, 2 Jun 2024 12:13:52 -0400 Subject: [PATCH 17/55] Add support for workspaces --- src/hatch/cli/application.py | 13 +- src/hatch/cli/dep/__init__.py | 5 +- src/hatch/cli/env/show.py | 7 +- src/hatch/dep/core.py | 38 ++ src/hatch/dep/sync.py | 177 ++++---- src/hatch/env/plugin/interface.py | 378 +++++++++++++++-- src/hatch/env/system.py | 7 +- src/hatch/env/virtual.py | 38 +- src/hatch/project/config.py | 12 +- src/hatch/project/constants.py | 15 +- src/hatch/project/core.py | 39 +- src/hatch/utils/dep.py | 16 +- src/hatch/utils/fs.py | 11 + tests/cli/env/test_create.py | 73 ++++ tests/dep/test_sync.py | 112 +++-- tests/env/plugin/test_interface.py | 633 ++++++++++++++++++++++++++++- 16 files changed, 1368 insertions(+), 206 deletions(-) create mode 100644 src/hatch/dep/core.py diff --git a/src/hatch/cli/application.py b/src/hatch/cli/application.py index c2bf39771..9ac310761 100644 --- a/src/hatch/cli/application.py +++ b/src/hatch/cli/application.py @@ -15,8 +15,7 @@ if TYPE_CHECKING: from collections.abc import Generator - from packaging.requirements import Requirement - + from hatch.dep.core import Dependency from hatch.env.plugin.interface import EnvironmentInterface @@ -141,11 +140,11 @@ def ensure_environment_plugin_dependencies(self) -> None: self.project.config.env_requires_complex, wait_message="Syncing environment plugin requirements" ) - def ensure_plugin_dependencies(self, dependencies: list[Requirement], *, wait_message: str) -> None: + def ensure_plugin_dependencies(self, dependencies: list[Dependency], *, wait_message: str) -> None: if not dependencies: return - from hatch.dep.sync import dependencies_in_sync + from hatch.dep.sync import InstalledDistributions from hatch.env.utils import add_verbosity_flag if app_path := os.environ.get("PYAPP"): @@ -154,12 +153,14 @@ def ensure_plugin_dependencies(self, dependencies: list[Requirement], *, wait_me management_command = os.environ["PYAPP_COMMAND_NAME"] executable = self.platform.check_command_output([app_path, management_command, "python-path"]).strip() python_info = PythonInfo(self.platform, executable=executable) - if dependencies_in_sync(dependencies, sys_path=python_info.sys_path): + distributions = InstalledDistributions(sys_path=python_info.sys_path) + if distributions.dependencies_in_sync(dependencies): return pip_command = [app_path, management_command, "pip"] else: - if dependencies_in_sync(dependencies): + distributions = InstalledDistributions() + if distributions.dependencies_in_sync(dependencies): return pip_command = [sys.executable, "-u", "-m", "pip"] diff --git a/src/hatch/cli/dep/__init__.py b/src/hatch/cli/dep/__init__.py index fa054acb7..66e8814e0 100644 --- a/src/hatch/cli/dep/__init__.py +++ b/src/hatch/cli/dep/__init__.py @@ -49,8 +49,7 @@ def table(app, project_only, env_only, show_lines, force_ascii): """Enumerate dependencies in a tabular format.""" app.ensure_environment_plugin_dependencies() - from packaging.requirements import Requirement - + from hatch.dep.core import Dependency from hatch.utils.dep import get_complex_dependencies, get_normalized_dependencies, normalize_marker_quoting environment = app.project.get_environment() @@ -76,7 +75,7 @@ def table(app, project_only, env_only, show_lines, force_ascii): if not all_requirements: continue - normalized_requirements = [Requirement(d) for d in get_normalized_dependencies(all_requirements)] + normalized_requirements = [Dependency(d) for d in get_normalized_dependencies(all_requirements)] columns = {"Name": {}, "URL": {}, "Versions": {}, "Markers": {}, "Features": {}} for i, requirement in enumerate(normalized_requirements): diff --git a/src/hatch/cli/env/show.py b/src/hatch/cli/env/show.py index e968d58aa..933116f53 100644 --- a/src/hatch/cli/env/show.py +++ b/src/hatch/cli/env/show.py @@ -70,8 +70,7 @@ def show( app.display(json.dumps(contextual_config, separators=(",", ":"))) return - from packaging.requirements import InvalidRequirement, Requirement - + from hatch.dep.core import Dependency, InvalidDependencyError from hatchling.metadata.utils import get_normalized_dependency, normalize_project_name if internal: @@ -126,8 +125,8 @@ def show( normalized_dependencies = set() for dependency in dependencies: try: - req = Requirement(dependency) - except InvalidRequirement: + req = Dependency(dependency) + except InvalidDependencyError: normalized_dependencies.add(dependency) else: normalized_dependencies.add(get_normalized_dependency(req)) diff --git a/src/hatch/dep/core.py b/src/hatch/dep/core.py new file mode 100644 index 000000000..fe81474bb --- /dev/null +++ b/src/hatch/dep/core.py @@ -0,0 +1,38 @@ +from __future__ import annotations + +import os +from functools import cached_property + +from packaging.requirements import InvalidRequirement, Requirement + +from hatch.utils.fs import Path + +InvalidDependencyError = InvalidRequirement + + +class Dependency(Requirement): + def __init__(self, s: str, *, editable: bool = False) -> None: + super().__init__(s) + + if editable and self.url is None: + message = f'Editable dependency must refer to a local path: {s}' + raise InvalidDependencyError(message) + + self.__editable = editable + + @property + def editable(self) -> bool: + return self.__editable + + @cached_property + def path(self) -> Path | None: + if self.url is None: + return None + + import hyperlink + + uri = hyperlink.parse(self.url) + if uri.scheme != 'file': + return None + + return Path(os.sep.join(uri.path)) diff --git a/src/hatch/dep/sync.py b/src/hatch/dep/sync.py index 4b360c291..cbf44d8a4 100644 --- a/src/hatch/dep/sync.py +++ b/src/hatch/dep/sync.py @@ -5,101 +5,99 @@ from importlib.metadata import Distribution, DistributionFinder from packaging.markers import default_environment -from packaging.requirements import Requirement +from hatch.dep.core import Dependency +from hatch.utils.fs import Path -class DistributionCache: - def __init__(self, sys_path: list[str]) -> None: - self._resolver = Distribution.discover(context=DistributionFinder.Context(path=sys_path)) - self._distributions: dict[str, Distribution] = {} - self._search_exhausted = False - self._canonical_regex = re.compile(r"[-_.]+") - def __getitem__(self, item: str) -> Distribution | None: - item = self._canonical_regex.sub("-", item).lower() - possible_distribution = self._distributions.get(item) - if possible_distribution is not None: - return possible_distribution +class InstalledDistributions: + def __init__(self, *, sys_path: list[str] | None = None, environment: dict[str, str] | None = None) -> None: + self.__sys_path: list[str] = sys.path if sys_path is None else sys_path + self.__environment: dict[str, str] = ( + default_environment() if environment is None else environment # type: ignore[assignment] + ) + self.__resolver = Distribution.discover(context=DistributionFinder.Context(path=self.__sys_path)) + self.__distributions: dict[str, Distribution] = {} + self.__search_exhausted = False + self.__canonical_regex = re.compile(r'[-_.]+') - # Be safe even though the code as-is will never reach this since - # the first unknown distribution will fail fast - if self._search_exhausted: # no cov - return None + def dependencies_in_sync(self, dependencies: list[Dependency]) -> bool: + return all(self.dependency_in_sync(dependency) for dependency in dependencies) - for distribution in self._resolver: - name = distribution.metadata["Name"] - if name is None: - continue + def missing_dependencies(self, dependencies: list[Dependency]) -> list[Dependency]: + return [dependency for dependency in dependencies if not self.dependency_in_sync(dependency)] - name = self._canonical_regex.sub("-", name).lower() - self._distributions[name] = distribution - if name == item: - return distribution - - self._search_exhausted = True - - return None + def dependency_in_sync(self, dependency: Dependency, *, environment: dict[str, str] | None = None) -> bool: + if environment is None: + environment = self.__environment + if dependency.marker and not dependency.marker.evaluate(environment): + return True -def dependency_in_sync( - requirement: Requirement, environment: dict[str, str], installed_distributions: DistributionCache -) -> bool: - if requirement.marker and not requirement.marker.evaluate(environment): - return True + distribution = self[dependency.name] + if distribution is None: + return False - distribution = installed_distributions[requirement.name] - if distribution is None: - return False + extras = dependency.extras + if extras: + transitive_dependencies: list[str] = distribution.metadata.get_all('Requires-Dist', []) + if not transitive_dependencies: + return False - extras = requirement.extras - if extras: - transitive_requirements: list[str] = distribution.metadata.get_all("Requires-Dist", []) - if not transitive_requirements: - return False + available_extras: list[str] = distribution.metadata.get_all('Provides-Extra', []) - available_extras: list[str] = distribution.metadata.get_all("Provides-Extra", []) + for dependency_string in transitive_dependencies: + transitive_dependency = Dependency(dependency_string) + if not transitive_dependency.marker: + continue - for requirement_string in transitive_requirements: - transitive_requirement = Requirement(requirement_string) - if not transitive_requirement.marker: - continue + for extra in extras: + # FIXME: This may cause a build to never be ready if newer versions do not provide the desired + # extra and it's just a user error/typo. See: https://github.com/pypa/pip/issues/7122 + if extra not in available_extras: + return False - for extra in extras: - # FIXME: This may cause a build to never be ready if newer versions do not provide the desired - # extra and it's just a user error/typo. See: https://github.com/pypa/pip/issues/7122 - if extra not in available_extras: - return False + extra_environment = dict(environment) + extra_environment['extra'] = extra + if not self.dependency_in_sync(transitive_dependency, environment=extra_environment): + return False - extra_environment = dict(environment) - extra_environment["extra"] = extra - if not dependency_in_sync(transitive_requirement, extra_environment, installed_distributions): - return False + if dependency.specifier and not dependency.specifier.contains(distribution.version): + return False - if requirement.specifier and not requirement.specifier.contains(distribution.version): - return False + # TODO: handle https://discuss.python.org/t/11938 + if dependency.url: + direct_url_file = distribution.read_text('direct_url.json') + if direct_url_file is None: + return False - # TODO: handle https://discuss.python.org/t/11938 - if requirement.url: - direct_url_file = distribution.read_text("direct_url.json") - if direct_url_file is not None: import json # https://packaging.python.org/specifications/direct-url/ direct_url_data = json.loads(direct_url_file) - if "vcs_info" in direct_url_data: - url = direct_url_data["url"] - vcs_info = direct_url_data["vcs_info"] - vcs = vcs_info["vcs"] - commit_id = vcs_info["commit_id"] - requested_revision = vcs_info.get("requested_revision") + url = direct_url_data['url'] + if 'dir_info' in direct_url_data: + dir_info = direct_url_data['dir_info'] + editable = dir_info.get('editable', False) + if editable != dependency.editable: + return False + + if Path.from_uri(url) != dependency.path: + return False + + if 'vcs_info' in direct_url_data: + vcs_info = direct_url_data['vcs_info'] + vcs = vcs_info['vcs'] + commit_id = vcs_info['commit_id'] + requested_revision = vcs_info.get('requested_revision') # Try a few variations, see https://peps.python.org/pep-0440/#direct-references if ( - requested_revision and requirement.url == f"{vcs}+{url}@{requested_revision}#{commit_id}" - ) or requirement.url == f"{vcs}+{url}@{commit_id}": + requested_revision and dependency.url == f'{vcs}+{url}@{requested_revision}#{commit_id}' + ) or dependency.url == f'{vcs}+{url}@{commit_id}': return True - if requirement.url in {f"{vcs}+{url}", f"{vcs}+{url}@{requested_revision}"}: + if dependency.url in {f'{vcs}+{url}', f'{vcs}+{url}@{requested_revision}'}: import subprocess if vcs == "git": @@ -117,16 +115,35 @@ def dependency_in_sync( return False - return True + return True + + def __getitem__(self, item: str) -> Distribution | None: + item = self.__canonical_regex.sub('-', item).lower() + possible_distribution = self.__distributions.get(item) + if possible_distribution is not None: + return possible_distribution + + if self.__search_exhausted: + return None + + for distribution in self.__resolver: + name = distribution.metadata['Name'] + if name is None: + continue + + name = self.__canonical_regex.sub('-', name).lower() + self.__distributions[name] = distribution + if name == item: + return distribution + + self.__search_exhausted = True + + return None def dependencies_in_sync( - requirements: list[Requirement], sys_path: list[str] | None = None, environment: dict[str, str] | None = None -) -> bool: - if sys_path is None: - sys_path = sys.path - if environment is None: - environment = default_environment() # type: ignore[assignment] - - installed_distributions = DistributionCache(sys_path) - return all(dependency_in_sync(requirement, environment, installed_distributions) for requirement in requirements) # type: ignore[arg-type] + dependencies: list[Dependency], sys_path: list[str] | None = None, environment: dict[str, str] | None = None +) -> bool: # no cov + # This function is unused and only temporarily exists for plugin backwards compatibility. + distributions = InstalledDistributions(sys_path=sys_path, environment=environment) + return distributions.dependencies_in_sync(dependencies) diff --git a/src/hatch/env/plugin/interface.py b/src/hatch/env/plugin/interface.py index 09678d7c8..f387ec4f1 100644 --- a/src/hatch/env/plugin/interface.py +++ b/src/hatch/env/plugin/interface.py @@ -6,7 +6,7 @@ from contextlib import contextmanager from functools import cached_property from os.path import isabs -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Any, Generator from hatch.config.constants import AppEnvVars from hatch.env.utils import add_verbosity_flag, get_env_var_option @@ -16,6 +16,8 @@ if TYPE_CHECKING: from collections.abc import Generator, Iterable + from hatch.dep.core import Dependency + from hatch.project.core import Project from hatch.utils.fs import Path @@ -24,23 +26,23 @@ class EnvironmentInterface(ABC): Example usage: ```python tab="plugin.py" - from hatch.env.plugin.interface import EnvironmentInterface + from hatch.env.plugin.interface import EnvironmentInterface - class SpecialEnvironment(EnvironmentInterface): - PLUGIN_NAME = 'special' - ... + class SpecialEnvironment(EnvironmentInterface): + PLUGIN_NAME = 'special' + ... ``` ```python tab="hooks.py" - from hatchling.plugin import hookimpl + from hatchling.plugin import hookimpl - from .plugin import SpecialEnvironment + from .plugin import SpecialEnvironment - @hookimpl - def hatch_register_environment(): - return SpecialEnvironment + @hookimpl + def hatch_register_environment(): + return SpecialEnvironment ``` """ @@ -71,6 +73,8 @@ def __init__( self.__verbosity = verbosity self.__app = app + self.additional_dependencies = [] + @property def matrix_variables(self): return self.__matrix_variables @@ -181,7 +185,7 @@ def system_python(self): return system_python @cached_property - def env_vars(self) -> dict: + def env_vars(self) -> dict[str, str]: """ ```toml config-example [tool.hatch.envs..env-vars] @@ -248,10 +252,10 @@ def env_exclude(self) -> list[str]: return env_exclude @cached_property - def environment_dependencies_complex(self): - from packaging.requirements import InvalidRequirement, Requirement + def environment_dependencies_complex(self) -> list[Dependency]: + from hatch.dep.core import Dependency, InvalidDependencyError - dependencies_complex = [] + dependencies_complex: list[Dependency] = [] with self.apply_context(): for option in ("dependencies", "extra-dependencies"): dependencies = self.config.get(option, []) @@ -265,9 +269,9 @@ def environment_dependencies_complex(self): raise TypeError(message) try: - dependencies_complex.append(Requirement(self.metadata.context.format(entry))) - except InvalidRequirement as e: - message = f"Dependency #{i} of field `tool.hatch.envs.{self.name}.{option}` is invalid: {e}" + dependencies_complex.append(Dependency(self.metadata.context.format(entry))) + except InvalidDependencyError as e: + message = f'Dependency #{i} of field `tool.hatch.envs.{self.name}.{option}` is invalid: {e}' raise ValueError(message) from None return dependencies_complex @@ -280,47 +284,104 @@ def environment_dependencies(self) -> list[str]: return [str(dependency) for dependency in self.environment_dependencies_complex] @cached_property - def dependencies_complex(self): + def project_dependencies_complex(self) -> list[Dependency]: + workspace_dependencies = self.workspace.get_dependencies() + if self.skip_install and not self.features and not workspace_dependencies: + return [] + + from hatch.dep.core import Dependency + from hatch.utils.dep import get_complex_dependencies, get_complex_features + + all_dependencies_complex = list(map(Dependency, workspace_dependencies)) + dependencies, optional_dependencies = self.app.project.get_dependencies() + dependencies_complex = get_complex_dependencies(dependencies) + optional_dependencies_complex = get_complex_features(optional_dependencies) + + if not self.skip_install: + all_dependencies_complex.extend(dependencies_complex.values()) + + for feature in self.features: + if feature not in optional_dependencies_complex: + message = ( + f'Feature `{feature}` of field `tool.hatch.envs.{self.name}.features` is not ' + f'defined in the dynamic field `project.optional-dependencies`' + ) + raise ValueError(message) + + all_dependencies_complex.extend(optional_dependencies_complex[feature].values()) + + return all_dependencies_complex + + @cached_property + def project_dependencies(self) -> list[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] + + @cached_property + def local_dependencies_complex(self) -> list[Dependency]: + from hatch.dep.core import Dependency + + local_dependencies_complex = [] + if not self.skip_install: + root = 'file://' if self.sep == '/' else 'file:///' + local_dependencies_complex.append( + Dependency(f'{self.metadata.name} @ {root}{self.project_root}', editable=self.dev_mode) + ) + + local_dependencies_complex.extend( + Dependency(f'{member.project.metadata.name} @ {member.project.location.as_uri()}', editable=self.dev_mode) + for member in self.workspace.members + ) + + return local_dependencies_complex + + @cached_property + def dependencies_complex(self) -> list[Dependency]: all_dependencies_complex = list(self.environment_dependencies_complex) + all_dependencies_complex.extend(self.additional_dependencies) if self.builder: + from hatch.dep.core import Dependency + from hatch.project.constants import BuildEnvVars + all_dependencies_complex.extend(self.metadata.build.requires_complex) + for target in os.environ.get(BuildEnvVars.REQUESTED_TARGETS, '').split(): + target_config = self.app.project.config.build.target(target) + all_dependencies_complex.extend(map(Dependency, target_config.dependencies)) + return all_dependencies_complex # Ensure these are checked last to speed up initial environment creation since # they will already be installed along with the project - if (not self.skip_install and self.dev_mode) or self.features: - from hatch.utils.dep import get_complex_dependencies, get_complex_features - - dependencies, optional_dependencies = self.app.project.get_dependencies() - dependencies_complex = get_complex_dependencies(dependencies) - optional_dependencies_complex = get_complex_features(optional_dependencies) - - if not self.skip_install and self.dev_mode: - all_dependencies_complex.extend(dependencies_complex.values()) - - for feature in self.features: - if feature not in optional_dependencies_complex: - message = ( - f"Feature `{feature}` of field `tool.hatch.envs.{self.name}.features` is not " - f"defined in the dynamic field `project.optional-dependencies`" - ) - raise ValueError(message) - - all_dependencies_complex.extend(optional_dependencies_complex[feature].values()) + if self.dev_mode: + all_dependencies_complex.extend(self.project_dependencies_complex) return all_dependencies_complex @cached_property def dependencies(self) -> list[str]: """ - The list of all [project dependencies](../../config/metadata.md#dependencies) (if - [installed](../../config/environment/overview.md#skip-install) and in - [dev mode](../../config/environment/overview.md#dev-mode)), selected - [optional dependencies](../../config/environment/overview.md#features), and + 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] + @cached_property + def all_dependencies_complex(self) -> list[Dependency]: + all_dependencies_complex = list(self.local_dependencies_complex) + all_dependencies_complex.extend(self.dependencies_complex) + return all_dependencies_complex + + @cached_property + def all_dependencies(self) -> list[str]: + return [str(dependency) for dependency in self.all_dependencies_complex] + @cached_property def platforms(self) -> list[str]: """ @@ -513,6 +574,15 @@ def post_install_commands(self): return list(post_install_commands) + @cached_property + def workspace(self) -> Workspace: + config = self.config.get('workspace', {}) + if not isinstance(config, dict): + message = f'Field `tool.hatch.envs.{self.name}.workspace` must be a table' + raise TypeError(message) + + return Workspace(self, config) + def activate(self): """ A convenience method called when using the environment as a context manager: @@ -617,7 +687,7 @@ def dependency_hash(self): """ from hatch.utils.dep import hash_dependencies - return hash_dependencies(self.dependencies_complex) + return hash_dependencies(self.all_dependencies_complex) @contextmanager def app_status_creation(self): @@ -907,6 +977,203 @@ def sync_local(self): """ +class Workspace: + def __init__(self, env: EnvironmentInterface, config: dict[str, Any]): + self.env = env + self.config = config + + @cached_property + def parallel(self) -> bool: + parallel = self.config.get('parallel', True) + if not isinstance(parallel, bool): + message = f'Field `tool.hatch.envs.{self.env.name}.workspace.parallel` must be a boolean' + raise TypeError(message) + + return parallel + + def get_dependencies(self) -> list[str]: + static_members: list[WorkspaceMember] = [] + dynamic_members: list[WorkspaceMember] = [] + for member in self.members: + if member.has_static_dependencies: + static_members.append(member) + else: + dynamic_members.append(member) + + all_dependencies = [] + for member in static_members: + dependencies, features = member.get_dependencies() + all_dependencies.extend(dependencies) + for feature in member.features: + all_dependencies.extend(features.get(feature, [])) + + if not self.parallel: + for member in dynamic_members: + with self.env.app.status(f'Checking workspace member: {member.name}'): + dependencies, features = member.get_dependencies() + all_dependencies.extend(dependencies) + for feature in member.features: + all_dependencies.extend(features.get(feature, [])) + + return all_dependencies + + @cached_property + def members(self) -> list[WorkspaceMember]: + from hatch.project.core import Project + from hatch.utils.fs import Path + from hatchling.metadata.utils import normalize_project_name + + raw_members = self.config.get('members', []) + if not isinstance(raw_members, list): + message = f'Field `tool.hatch.envs.{self.env.name}.workspace.members` must be an array' + raise TypeError(message) + + # First normalize configuration + member_data: list[dict[str, Any]] = [] + for i, data in enumerate(raw_members, 1): + if isinstance(data, str): + member_data.append({'path': data, 'features': ()}) + elif isinstance(data, dict): + if 'path' not in data: + message = ( + f'Member #{i} of field `tool.hatch.envs.{self.env.name}.workspace.members` must define ' + f'a `path` key' + ) + raise TypeError(message) + + path = data['path'] + if not isinstance(path, str): + message = ( + f'Option `path` of member #{i} of field `tool.hatch.envs.{self.env.name}.workspace.members` ' + f'must be a string' + ) + raise TypeError(message) + + if not path: + message = ( + f'Option `path` of member #{i} of field `tool.hatch.envs.{self.env.name}.workspace.members` ' + f'cannot be an empty string' + ) + raise ValueError(message) + + features = data.get('features', []) + if not isinstance(features, list): + message = ( + f'Option `features` of member #{i} of field `tool.hatch.envs.{self.env.name}.workspace.' + f'members` must be an array of strings' + ) + raise TypeError(message) + + all_features: set[str] = set() + for j, feature in enumerate(features, 1): + if not isinstance(feature, str): + message = ( + f'Feature #{j} of option `features` of member #{i} of field ' + f'`tool.hatch.envs.{self.env.name}.workspace.members` must be a string' + ) + raise TypeError(message) + + if not feature: + message = ( + f'Feature #{j} of option `features` of member #{i} of field ' + f'`tool.hatch.envs.{self.env.name}.workspace.members` cannot be an empty string' + ) + raise ValueError(message) + + normalized_feature = normalize_project_name(feature) + if normalized_feature in all_features: + message = ( + f'Feature #{j} of option `features` of member #{i} of field ' + f'`tool.hatch.envs.{self.env.name}.workspace.members` is a duplicate' + ) + raise ValueError(message) + + all_features.add(normalized_feature) + + member_data.append({'path': path, 'features': tuple(sorted(all_features))}) + else: + message = ( + f'Member #{i} of field `tool.hatch.envs.{self.env.name}.workspace.members` must be ' + f'a string or an inline table' + ) + raise TypeError(message) + + root = str(self.env.root) + member_paths: dict[str, WorkspaceMember] = {} + for data in member_data: + # Given root R and member spec M, we need to find: + # + # 1. The absolute path AP of R/M + # 2. The shared prefix SP of R and AP + # 3. The relative path RP of M from AP + # + # For example, if: + # + # R = /foo/bar/baz + # M = ../dir/pkg-* + # + # Then: + # + # AP = /foo/bar/dir/pkg-* + # SP = /foo/bar + # RP = dir/pkg-* + path_spec = data['path'] + normalized_path = os.path.normpath(os.path.join(root, path_spec)) + absolute_path = os.path.abspath(normalized_path) + shared_prefix = os.path.commonprefix([root, absolute_path]) + relative_path = os.path.relpath(absolute_path, shared_prefix) + + # Now we have the necessary information to perform an optimized glob search for members + members_found = False + for member_path in find_members(root, relative_path.split(os.sep)): + project_file = os.path.join(member_path, 'pyproject.toml') + if not os.path.isfile(project_file): + message = ( + f'Member derived from `{path_spec}` of field ' + f'`tool.hatch.envs.{self.env.name}.workspace.members` is not a project (no `pyproject.toml` ' + f'file): {member_path}' + ) + raise OSError(message) + + members_found = True + if member_path in member_paths: + message = ( + f'Member derived from `{path_spec}` of field ' + f'`tool.hatch.envs.{self.env.name}.workspace.members` is a duplicate: {member_path}' + ) + raise ValueError(message) + + project = Project(Path(member_path), locate=False) + project.set_app(self.env.app) + member_paths[member_path] = WorkspaceMember(project, features=data['features']) + + if not members_found: + message = ( + f'No members could be derived from `{path_spec}` of field ' + f'`tool.hatch.envs.{self.env.name}.workspace.members`: {absolute_path}' + ) + raise OSError(message) + + return list(member_paths.values()) + + +class WorkspaceMember: + def __init__(self, project: Project, *, features: tuple[str]): + self.project = project + self.features = features + + @cached_property + def name(self) -> str: + return self.project.metadata.name + + @cached_property + def has_static_dependencies(self) -> bool: + return self.project.has_static_dependencies + + def get_dependencies(self) -> tuple[list[str], dict[str, list[str]]]: + return self.project.get_dependencies() + + def expand_script_commands(env_name, script_name, commands, config, seen, active): if script_name in seen: return seen[script_name] @@ -941,3 +1208,30 @@ def expand_script_commands(env_name, script_name, commands, config, seen, active active.pop() return expanded_commands + + +def find_members(root, relative_components): + import fnmatch + import re + + component_matchers = [] + for component in relative_components: + if any(special in component for special in '*?['): + pattern = re.compile(fnmatch.translate(component)) + component_matchers.append(lambda entry, pattern=pattern: pattern.search(entry.name)) + else: + component_matchers.append(lambda entry, component=component: component == entry.name) + + yield from _recurse_members(root, 0, component_matchers) + + +def _recurse_members(root, matcher_index, matchers): + if matcher_index == len(matchers): + yield root + return + + matcher = matchers[matcher_index] + with os.scandir(root) as it: + for entry in it: + if entry.is_dir() and matcher(entry): + yield from _recurse_members(entry.path, matcher_index + 1, matchers) diff --git a/src/hatch/env/system.py b/src/hatch/env/system.py index dc7a55ff0..36161e69f 100644 --- a/src/hatch/env/system.py +++ b/src/hatch/env/system.py @@ -37,11 +37,12 @@ def dependencies_in_sync(self): if not self.dependencies: return True - from hatch.dep.sync import dependencies_in_sync + from hatch.dep.sync import InstalledDistributions - return dependencies_in_sync( - self.dependencies_complex, sys_path=self.python_info.sys_path, environment=self.python_info.environment + distributions = InstalledDistributions( + sys_path=self.python_info.sys_path, environment=self.python_info.environment ) + return distributions.dependencies_in_sync(self.dependencies_complex) def sync_dependencies(self): self.platform.check_command(self.construct_pip_install_command(self.dependencies)) diff --git a/src/hatch/env/virtual.py b/src/hatch/env/virtual.py index e2afb616f..dfa528a2d 100644 --- a/src/hatch/env/virtual.py +++ b/src/hatch/env/virtual.py @@ -21,6 +21,8 @@ from packaging.specifiers import SpecifierSet from virtualenv.discovery.py_info import PythonInfo + from hatch.dep.core import Dependency + from hatch.dep.sync import InstalledDistributions from hatch.python.core import PythonManager @@ -127,6 +129,16 @@ def uv_path(self) -> str: new_path = f"{scripts_dir}{os.pathsep}{old_path}" return self.platform.modules.shutil.which("uv", path=new_path) + @cached_property + def distributions(self) -> InstalledDistributions: + from hatch.dep.sync import InstalledDistributions + + return InstalledDistributions(sys_path=self.virtual_env.sys_path, environment=self.virtual_env.environment) + + @cached_property + def missing_dependencies(self) -> list[Dependency]: + return self.distributions.missing_dependencies(self.all_dependencies_complex) + @staticmethod def get_option_types() -> dict: return {"system-packages": bool, "path": str, "python-sources": list, "installer": str, "uv-path": str} @@ -181,19 +193,27 @@ def install_project_dev_mode(self): ) def dependencies_in_sync(self): - if not self.dependencies: - return True - - from hatch.dep.sync import dependencies_in_sync - with self.safe_activation(): - return dependencies_in_sync( - self.dependencies_complex, sys_path=self.virtual_env.sys_path, environment=self.virtual_env.environment - ) + return not self.missing_dependencies def sync_dependencies(self): with self.safe_activation(): - self.platform.check_command(self.construct_pip_install_command(self.dependencies)) + standard_dependencies: list[str] = [] + editable_dependencies: list[str] = [] + for dependency in self.missing_dependencies: + if not dependency.editable or dependency.path is None: + standard_dependencies.append(str(dependency)) + else: + editable_dependencies.append(str(dependency.path)) + + if standard_dependencies: + self.platform.check_command(self.construct_pip_install_command(standard_dependencies)) + + if editable_dependencies: + editable_args = [] + for dependency in editable_dependencies: + editable_args.extend(['--editable', dependency]) + self.platform.check_command(self.construct_pip_install_command(editable_args)) @contextmanager def command_context(self): diff --git a/src/hatch/project/config.py b/src/hatch/project/config.py index 50a461111..d397959b5 100644 --- a/src/hatch/project/config.py +++ b/src/hatch/project/config.py @@ -13,7 +13,7 @@ from hatch.project.utils import format_script_commands, parse_script_command if TYPE_CHECKING: - from packaging.requirements import Requirement + from hatch.dep.core import Dependency class ProjectConfig: @@ -57,9 +57,9 @@ def env(self): return self._env @property - def env_requires_complex(self) -> list[Requirement]: + def env_requires_complex(self) -> list[Dependency]: if self._env_requires_complex is None: - from packaging.requirements import InvalidRequirement, Requirement + from hatch.dep.core import Dependency, InvalidDependencyError requires = self.env.get("requires", []) if not isinstance(requires, list): @@ -74,9 +74,9 @@ def env_requires_complex(self) -> list[Requirement]: raise TypeError(message) try: - requires_complex.append(Requirement(entry)) - except InvalidRequirement as e: - message = f"Requirement #{i} in `tool.hatch.env.requires` is invalid: {e}" + requires_complex.append(Dependency(entry)) + except InvalidDependencyError as e: + message = f'Requirement #{i} in `tool.hatch.env.requires` is invalid: {e}' raise ValueError(message) from None self._env_requires_complex = requires_complex diff --git a/src/hatch/project/constants.py b/src/hatch/project/constants.py index 27315cdfd..a5e4f7a95 100644 --- a/src/hatch/project/constants.py +++ b/src/hatch/project/constants.py @@ -5,10 +5,11 @@ class BuildEnvVars: - LOCATION = "HATCH_BUILD_LOCATION" - HOOKS_ONLY = "HATCH_BUILD_HOOKS_ONLY" - NO_HOOKS = "HATCH_BUILD_NO_HOOKS" - HOOKS_ENABLE = "HATCH_BUILD_HOOKS_ENABLE" - HOOK_ENABLE_PREFIX = "HATCH_BUILD_HOOK_ENABLE_" - CLEAN = "HATCH_BUILD_CLEAN" - CLEAN_HOOKS_AFTER = "HATCH_BUILD_CLEAN_HOOKS_AFTER" + REQUESTED_TARGETS = 'HATCH_BUILD_REQUESTED_TARGETS' + LOCATION = 'HATCH_BUILD_LOCATION' + HOOKS_ONLY = 'HATCH_BUILD_HOOKS_ONLY' + NO_HOOKS = 'HATCH_BUILD_NO_HOOKS' + HOOKS_ENABLE = 'HATCH_BUILD_HOOKS_ENABLE' + HOOK_ENABLE_PREFIX = 'HATCH_BUILD_HOOK_ENABLE_' + CLEAN = 'HATCH_BUILD_CLEAN' + CLEAN_HOOKS_AFTER = 'HATCH_BUILD_CLEAN_HOOKS_AFTER' diff --git a/src/hatch/project/core.py b/src/hatch/project/core.py index c461a79b8..e24794941 100644 --- a/src/hatch/project/core.py +++ b/src/hatch/project/core.py @@ -3,7 +3,7 @@ import re from contextlib import contextmanager from functools import cached_property -from typing import TYPE_CHECKING, cast +from typing import TYPE_CHECKING, Generator, cast from hatch.project.env import EnvironmentMetadata from hatch.utils.fs import Path @@ -19,7 +19,7 @@ class Project: - def __init__(self, path: Path, *, name: str | None = None, config=None): + def __init__(self, path: Path, *, name: str | None = None, config=None, locate: bool = True): self._path = path # From app config @@ -38,7 +38,7 @@ def __init__(self, path: Path, *, name: str | None = None, config=None): self._metadata = None self._config = None - self._explicit_path: Path | None = None + self._explicit_path: Path | None = None if locate else path @property def plugin_manager(self): @@ -200,13 +200,15 @@ def prepare_environment(self, environment: EnvironmentInterface): self.env_metadata.update_dependency_hash(environment, new_dep_hash) def prepare_build_environment(self, *, targets: list[str] | None = None) -> None: - from hatch.project.constants import BUILD_BACKEND + from hatch.project.constants import BUILD_BACKEND, BuildEnvVars + from hatch.utils.structures import EnvVars if targets is None: targets = ["wheel"] + env_vars = {BuildEnvVars.REQUESTED_TARGETS: ' '.join(sorted(targets))} build_backend = self.metadata.build.build_backend - with self.location.as_cwd(), self.build_env.get_env_vars(): + with self.location.as_cwd(), self.build_env.get_env_vars(), EnvVars(env_vars): if not self.build_env.exists(): try: self.build_env.check_compatibility() @@ -215,24 +217,28 @@ def prepare_build_environment(self, *, targets: list[str] | None = None) -> None self.prepare_environment(self.build_env) - extra_dependencies: list[str] = [] - with self.app.status("Inspecting build dependencies"): + additional_dependencies: list[str] = [] + with self.app.status('Inspecting build dependencies'): if build_backend != BUILD_BACKEND: for target in targets: - if target == "sdist": - extra_dependencies.extend(self.build_frontend.get_requires("sdist")) - elif target == "wheel": - extra_dependencies.extend(self.build_frontend.get_requires("wheel")) + if target == 'sdist': + additional_dependencies.extend(self.build_frontend.get_requires('sdist')) + elif target == 'wheel': + additional_dependencies.extend(self.build_frontend.get_requires('wheel')) else: self.app.abort(f"Target `{target}` is not supported by `{build_backend}`") else: required_build_deps = self.build_frontend.hatch.get_required_build_deps(targets) if required_build_deps: with self.metadata.context.apply_context(self.build_env.context): - extra_dependencies.extend(self.metadata.context.format(dep) for dep in required_build_deps) + additional_dependencies.extend( + self.metadata.context.format(dep) for dep in required_build_deps + ) - if extra_dependencies: - self.build_env.dependencies.extend(extra_dependencies) + if additional_dependencies: + from hatch.dep.core import Dependency + + self.build_env.additional_dependencies.extend(map(Dependency, additional_dependencies)) with self.build_env.app_status_dependency_synchronization(): self.build_env.sync_dependencies() @@ -258,6 +264,11 @@ def get_dependencies(self) -> tuple[list[str], dict[str, list[str]]]: return dynamic_dependencies, dynamic_features + @cached_property + def has_static_dependencies(self) -> bool: + dynamic_fields = {'dependencies', 'optional-dependencies'} + return not dynamic_fields.intersection(self.metadata.dynamic) + def expand_environments(self, env_name: str) -> list[str]: if env_name in self.config.internal_matrices: return list(self.config.internal_matrices[env_name]["envs"]) diff --git a/src/hatch/utils/dep.py b/src/hatch/utils/dep.py index e8456d6b6..a848a31bc 100644 --- a/src/hatch/utils/dep.py +++ b/src/hatch/utils/dep.py @@ -7,6 +7,8 @@ if TYPE_CHECKING: from packaging.requirements import Requirement + from hatch.dep.core import Dependency + def normalize_marker_quoting(text: str) -> str: # All TOML writers use double quotes, so allow copy/pasting to avoid escaping @@ -18,7 +20,7 @@ def get_normalized_dependencies(requirements: list[Requirement]) -> list[str]: return sorted(normalized_dependencies) -def hash_dependencies(requirements: list[Requirement]) -> str: +def hash_dependencies(requirements: list[Dependency]) -> str: from hashlib import sha256 data = "".join( @@ -32,23 +34,23 @@ def hash_dependencies(requirements: list[Requirement]) -> str: return sha256(data).hexdigest() -def get_complex_dependencies(dependencies: list[str]) -> dict[str, Requirement]: - from packaging.requirements import Requirement +def get_complex_dependencies(dependencies: list[str]) -> dict[str, Dependency]: + from hatch.dep.core import Dependency dependencies_complex = {} for dependency in dependencies: - dependencies_complex[dependency] = Requirement(dependency) + dependencies_complex[dependency] = Dependency(dependency) return dependencies_complex -def get_complex_features(features: dict[str, list[str]]) -> dict[str, dict[str, Requirement]]: - from packaging.requirements import Requirement +def get_complex_features(features: dict[str, list[str]]) -> dict[str, dict[str, Dependency]]: + from hatch.dep.core import Dependency optional_dependencies_complex = {} for feature, optional_dependencies in features.items(): optional_dependencies_complex[feature] = { - optional_dependency: Requirement(optional_dependency) for optional_dependency in optional_dependencies + optional_dependency: Dependency(optional_dependency) for optional_dependency in optional_dependencies } return optional_dependencies_complex diff --git a/src/hatch/utils/fs.py b/src/hatch/utils/fs.py index e84e684c7..02a0328a3 100644 --- a/src/hatch/utils/fs.py +++ b/src/hatch/utils/fs.py @@ -131,6 +131,17 @@ def temp_hide(self) -> Generator[Path, None, None]: with suppress(FileNotFoundError): shutil.move(str(temp_path), self) + if sys.platform == 'win32': + + @classmethod + def from_uri(cls, path: str) -> Path: + return cls(path.replace('file:///', '', 1)) + else: + + @classmethod + def from_uri(cls, path: str) -> Path: + return cls(path.replace('file://', '', 1)) + if sys.version_info[:2] < (3, 10): def resolve(self, strict: bool = False) -> Path: # noqa: ARG002, FBT001, FBT002 diff --git a/tests/cli/env/test_create.py b/tests/cli/env/test_create.py index 51fde0b76..39d141576 100644 --- a/tests/cli/env/test_create.py +++ b/tests/cli/env/test_create.py @@ -1959,3 +1959,76 @@ def test_no_compatible_python_ok_if_not_installed(hatch, helpers, temp_dir, conf env_path = env_dirs[0] assert env_path.name == project_path.name + + +@pytest.mark.requires_internet +def test_workspace(hatch, helpers, temp_dir, platform, uv_on_path, extract_installed_requirements): + project_name = 'My.App' + + with temp_dir.as_cwd(): + result = hatch('new', project_name) + assert result.exit_code == 0, result.output + + project_path = temp_dir / 'my-app' + data_path = temp_dir / 'data' + data_path.mkdir() + + members = ['foo', 'bar', 'baz'] + for member in members: + with project_path.as_cwd(): + result = hatch('new', member) + assert result.exit_code == 0, result.output + + project = Project(project_path) + helpers.update_project_environment( + project, + 'default', + { + 'workspace': {'members': [{'path': member} for member in members]}, + **project.config.envs['default'], + }, + ) + + with project_path.as_cwd(env_vars={ConfigEnvVars.DATA: str(data_path)}): + result = hatch('env', 'create') + + assert result.exit_code == 0, result.output + assert result.output == helpers.dedent( + """ + Creating environment: default + Installing project in development mode + Checking dependencies + Syncing dependencies + """ + ) + + env_data_path = data_path / 'env' / 'virtual' + assert env_data_path.is_dir() + + project_data_path = env_data_path / project_path.name + assert project_data_path.is_dir() + + storage_dirs = list(project_data_path.iterdir()) + assert len(storage_dirs) == 1 + + storage_path = storage_dirs[0] + assert len(storage_path.name) == 8 + + env_dirs = list(storage_path.iterdir()) + assert len(env_dirs) == 1 + + env_path = env_dirs[0] + + assert env_path.name == project_path.name + + with UVVirtualEnv(env_path, platform): + output = platform.run_command([uv_on_path, 'pip', 'freeze'], check=True, capture_output=True).stdout.decode( + 'utf-8' + ) + requirements = extract_installed_requirements(output.splitlines()) + + assert len(requirements) == 4 + assert requirements[0].lower() == f'-e {project_path.as_uri().lower()}/bar' + assert requirements[1].lower() == f'-e {project_path.as_uri().lower()}/baz' + assert requirements[2].lower() == f'-e {project_path.as_uri().lower()}/foo' + assert requirements[3].lower() == f'-e {project_path.as_uri().lower()}' diff --git a/tests/dep/test_sync.py b/tests/dep/test_sync.py index cc7e49338..69da04f0a 100644 --- a/tests/dep/test_sync.py +++ b/tests/dep/test_sync.py @@ -1,51 +1,59 @@ +import os import sys import pytest -from packaging.requirements import Requirement -from hatch.dep.sync import dependencies_in_sync +from hatch.dep.core import Dependency +from hatch.dep.sync import InstalledDistributions from hatch.venv.core import TempUVVirtualEnv, TempVirtualEnv def test_no_dependencies(platform): with TempUVVirtualEnv(sys.executable, platform) as venv: - assert dependencies_in_sync([], venv.sys_path) + distributions = InstalledDistributions(sys_path=venv.sys_path) + assert distributions.dependencies_in_sync([]) def test_dependency_not_found(platform): with TempUVVirtualEnv(sys.executable, platform) as venv: - assert not dependencies_in_sync([Requirement("binary")], venv.sys_path) + distributions = InstalledDistributions(sys_path=venv.sys_path) + assert not distributions.dependencies_in_sync([Dependency('binary')]) @pytest.mark.requires_internet def test_dependency_found(platform, uv_on_path): with TempUVVirtualEnv(sys.executable, platform) as venv: - platform.run_command([uv_on_path, "pip", "install", "binary"], check=True, capture_output=True) - assert dependencies_in_sync([Requirement("binary")], venv.sys_path) + platform.run_command([uv_on_path, 'pip', 'install', 'binary'], check=True, capture_output=True) + distributions = InstalledDistributions(sys_path=venv.sys_path) + assert distributions.dependencies_in_sync([Dependency('binary')]) @pytest.mark.requires_internet def test_version_unmet(platform, uv_on_path): with TempUVVirtualEnv(sys.executable, platform) as venv: - platform.run_command([uv_on_path, "pip", "install", "binary"], check=True, capture_output=True) - assert not dependencies_in_sync([Requirement("binary>9000")], venv.sys_path) + platform.run_command([uv_on_path, 'pip', 'install', 'binary'], check=True, capture_output=True) + distributions = InstalledDistributions(sys_path=venv.sys_path) + assert not distributions.dependencies_in_sync([Dependency('binary>9000')]) def test_marker_met(platform): with TempUVVirtualEnv(sys.executable, platform) as venv: - assert dependencies_in_sync([Requirement('binary; python_version < "1"')], venv.sys_path) + distributions = InstalledDistributions(sys_path=venv.sys_path) + assert distributions.dependencies_in_sync([Dependency('binary; python_version < "1"')]) def test_marker_unmet(platform): with TempUVVirtualEnv(sys.executable, platform) as venv: - assert not dependencies_in_sync([Requirement('binary; python_version > "1"')], venv.sys_path) + distributions = InstalledDistributions(sys_path=venv.sys_path) + assert not distributions.dependencies_in_sync([Dependency('binary; python_version > "1"')]) @pytest.mark.requires_internet def test_extra_no_dependencies(platform, uv_on_path): with TempUVVirtualEnv(sys.executable, platform) as venv: - platform.run_command([uv_on_path, "pip", "install", "binary"], check=True, capture_output=True) - assert not dependencies_in_sync([Requirement("binary[foo]")], venv.sys_path) + platform.run_command([uv_on_path, 'pip', 'install', 'binary'], check=True, capture_output=True) + distributions = InstalledDistributions(sys_path=venv.sys_path) + assert not distributions.dependencies_in_sync([Dependency('binary[foo]')]) @pytest.mark.requires_internet @@ -54,14 +62,16 @@ def test_unknown_extra(platform, uv_on_path): platform.run_command( [uv_on_path, "pip", "install", "requests[security]==2.25.1"], check=True, capture_output=True ) - assert not dependencies_in_sync([Requirement("requests[foo]")], venv.sys_path) + distributions = InstalledDistributions(sys_path=venv.sys_path) + assert not distributions.dependencies_in_sync([Dependency('requests[foo]')]) @pytest.mark.requires_internet def test_extra_unmet(platform, uv_on_path): with TempUVVirtualEnv(sys.executable, platform) as venv: - platform.run_command([uv_on_path, "pip", "install", "requests==2.25.1"], check=True, capture_output=True) - assert not dependencies_in_sync([Requirement("requests[security]==2.25.1")], venv.sys_path) + platform.run_command([uv_on_path, 'pip', 'install', 'requests==2.25.1'], check=True, capture_output=True) + distributions = InstalledDistributions(sys_path=venv.sys_path) + assert not distributions.dependencies_in_sync([Dependency('requests[security]==2.25.1')]) @pytest.mark.requires_internet @@ -70,7 +80,56 @@ def test_extra_met(platform, uv_on_path): platform.run_command( [uv_on_path, "pip", "install", "requests[security]==2.25.1"], check=True, capture_output=True ) - assert dependencies_in_sync([Requirement("requests[security]==2.25.1")], venv.sys_path) + distributions = InstalledDistributions(sys_path=venv.sys_path) + assert distributions.dependencies_in_sync([Dependency('requests[security]==2.25.1')]) + + +@pytest.mark.requires_internet +def test_local_dir(hatch, temp_dir, platform, uv_on_path): + project_name = os.urandom(10).hex() + + with temp_dir.as_cwd(): + result = hatch('new', project_name) + assert result.exit_code == 0, result.output + + project_path = temp_dir / project_name + dependency_string = f'{project_name}@{project_path.as_uri()}' + with TempUVVirtualEnv(sys.executable, platform) as venv: + platform.run_command([uv_on_path, 'pip', 'install', str(project_path)], check=True, capture_output=True) + distributions = InstalledDistributions(sys_path=venv.sys_path) + assert distributions.dependencies_in_sync([Dependency(dependency_string)]) + + +@pytest.mark.requires_internet +def test_local_dir_editable(hatch, temp_dir, platform, uv_on_path): + project_name = os.urandom(10).hex() + + with temp_dir.as_cwd(): + result = hatch('new', project_name) + assert result.exit_code == 0, result.output + + project_path = temp_dir / project_name + dependency_string = f'{project_name}@{project_path.as_uri()}' + with TempUVVirtualEnv(sys.executable, platform) as venv: + platform.run_command([uv_on_path, 'pip', 'install', '-e', str(project_path)], check=True, capture_output=True) + distributions = InstalledDistributions(sys_path=venv.sys_path) + assert distributions.dependencies_in_sync([Dependency(dependency_string, editable=True)]) + + +@pytest.mark.requires_internet +def test_local_dir_editable_mismatch(hatch, temp_dir, platform, uv_on_path): + project_name = os.urandom(10).hex() + + with temp_dir.as_cwd(): + result = hatch('new', project_name) + assert result.exit_code == 0, result.output + + project_path = temp_dir / project_name + dependency_string = f'{project_name}@{project_path.as_uri()}' + with TempUVVirtualEnv(sys.executable, platform) as venv: + platform.run_command([uv_on_path, 'pip', 'install', '-e', str(project_path)], check=True, capture_output=True) + distributions = InstalledDistributions(sys_path=venv.sys_path) + assert not distributions.dependencies_in_sync([Dependency(dependency_string)]) @pytest.mark.requires_internet @@ -80,7 +139,8 @@ def test_dependency_git_pip(platform): platform.run_command( ["pip", "install", "requests@git+https://github.com/psf/requests"], check=True, capture_output=True ) - assert dependencies_in_sync([Requirement("requests@git+https://github.com/psf/requests")], venv.sys_path) + distributions = InstalledDistributions(sys_path=venv.sys_path) + assert distributions.dependencies_in_sync([Dependency('requests@git+https://github.com/psf/requests')]) @pytest.mark.requires_internet @@ -92,7 +152,8 @@ def test_dependency_git_uv(platform, uv_on_path): check=True, capture_output=True, ) - assert dependencies_in_sync([Requirement("requests@git+https://github.com/psf/requests")], venv.sys_path) + distributions = InstalledDistributions(sys_path=venv.sys_path) + assert not distributions.dependencies_in_sync([Dependency('requests@git+https://github.com/psf/requests')]) @pytest.mark.requires_internet @@ -102,7 +163,8 @@ def test_dependency_git_revision_pip(platform): platform.run_command( ["pip", "install", "requests@git+https://github.com/psf/requests@main"], check=True, capture_output=True ) - assert dependencies_in_sync([Requirement("requests@git+https://github.com/psf/requests@main")], venv.sys_path) + distributions = InstalledDistributions(sys_path=venv.sys_path) + assert distributions.dependencies_in_sync([Dependency('requests@git+https://github.com/psf/requests@main')]) @pytest.mark.requires_internet @@ -114,7 +176,9 @@ def test_dependency_git_revision_uv(platform, uv_on_path): check=True, capture_output=True, ) - assert dependencies_in_sync([Requirement("requests@git+https://github.com/psf/requests@main")], venv.sys_path) + assert not dependencies_in_sync( + [Requirement('requests@git+https://github.com/psf/requests@main')], venv.sys_path + ) @pytest.mark.requires_internet @@ -131,7 +195,7 @@ def test_dependency_git_commit(platform, uv_on_path): check=True, capture_output=True, ) - assert dependencies_in_sync( - [Requirement("requests@git+https://github.com/psf/requests@7f694b79e114c06fac5ec06019cada5a61e5570f")], - venv.sys_path, - ) + distributions = InstalledDistributions(sys_path=venv.sys_path) + assert distributions.dependencies_in_sync([ + Dependency('requests@git+https://github.com/psf/requests@7f694b79e114c06fac5ec06019cada5a61e5570f') + ]) diff --git a/tests/env/plugin/test_interface.py b/tests/env/plugin/test_interface.py index 4d5ac7370..cec5f20c6 100644 --- a/tests/env/plugin/test_interface.py +++ b/tests/env/plugin/test_interface.py @@ -1,3 +1,5 @@ +import re + import pytest from hatch.config.constants import AppEnvVars @@ -1108,7 +1110,7 @@ def test_full_skip_install_and_features(self, isolation, isolated_data_dir, plat assert environment.dependencies == ["dep2", "dep3", "dep4"] - def test_full_dev_mode(self, isolation, isolated_data_dir, platform, global_application): + def test_full_no_dev_mode(self, isolation, isolated_data_dir, platform, global_application): config = { "project": {"name": "my_app", "version": "0.0.1", "dependencies": ["dep1"]}, "tool": { @@ -1157,6 +1159,80 @@ def test_builder(self, isolation, isolated_data_dir, platform, global_applicatio assert environment.dependencies == ["dep3", "dep2"] + def test_workspace(self, temp_dir, isolated_data_dir, platform, temp_application): + for i in range(3): + project_file = temp_dir / f'foo{i}' / 'pyproject.toml' + project_file.parent.mkdir() + project_file.write_text( + f"""\ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "foo{i}" +version = "0.0.1" +dependencies = ["pkg-{i}"] + +[project.optional-dependencies] +feature1 = ["pkg-feature-1{i}"] +feature2 = ["pkg-feature-2{i}"] +feature3 = ["pkg-feature-3{i}"] +""" + ) + + config = { + 'project': {'name': 'my_app', 'version': '0.0.1', 'dependencies': ['dep1']}, + 'tool': { + 'hatch': { + 'envs': { + 'default': { + 'skip-install': False, + 'dependencies': ['dep2'], + 'extra-dependencies': ['dep3'], + 'workspace': { + 'members': [ + {'path': 'foo0', 'features': ['feature1']}, + {'path': 'foo1', 'features': ['feature1', 'feature2']}, + {'path': 'foo2', 'features': ['feature1', 'feature2', 'feature3']}, + ], + }, + }, + }, + }, + }, + } + project = Project(temp_dir, config=config) + project.set_app(temp_application) + temp_application.project = project + environment = MockEnvironment( + temp_dir, + project.metadata, + 'default', + project.config.envs['default'], + {}, + isolated_data_dir, + isolated_data_dir, + platform, + 0, + temp_application, + ) + + assert environment.dependencies == [ + 'dep2', + 'dep3', + 'pkg-0', + 'pkg-feature-10', + 'pkg-1', + 'pkg-feature-11', + 'pkg-feature-21', + 'pkg-2', + 'pkg-feature-12', + 'pkg-feature-22', + 'pkg-feature-32', + 'dep1', + ] + class TestScripts: @pytest.mark.parametrize("field", ["scripts", "extra-scripts"]) @@ -2071,3 +2147,558 @@ def test_env_vars_override(self, isolation, isolated_data_dir, platform, global_ ) assert environment.dependencies == ["pkg"] + + +class TestWorkspaceConfig: + def test_not_table(self, isolation, isolated_data_dir, platform, global_application): + config = { + 'project': {'name': 'my_app', 'version': '0.0.1'}, + 'tool': {'hatch': {'envs': {'default': {'workspace': 9000}}}}, + } + project = Project(isolation, config=config) + environment = MockEnvironment( + isolation, + project.metadata, + 'default', + project.config.envs['default'], + {}, + isolated_data_dir, + isolated_data_dir, + platform, + 0, + global_application, + ) + + with pytest.raises(TypeError, match='Field `tool.hatch.envs.default.workspace` must be a table'): + _ = environment.workspace + + def test_parallel_not_boolean(self, isolation, isolated_data_dir, platform, global_application): + config = { + 'project': {'name': 'my_app', 'version': '0.0.1'}, + 'tool': {'hatch': {'envs': {'default': {'workspace': {'parallel': 9000}}}}}, + } + project = Project(isolation, config=config) + environment = MockEnvironment( + isolation, + project.metadata, + 'default', + project.config.envs['default'], + {}, + isolated_data_dir, + isolated_data_dir, + platform, + 0, + global_application, + ) + + with pytest.raises(TypeError, match='Field `tool.hatch.envs.default.workspace.parallel` must be a boolean'): + _ = environment.workspace.parallel + + def test_parallel_default(self, isolation, isolated_data_dir, platform, global_application): + config = {'project': {'name': 'my_app', 'version': '0.0.1'}} + project = Project(isolation, config=config) + environment = MockEnvironment( + isolation, + project.metadata, + 'default', + project.config.envs['default'], + {}, + isolated_data_dir, + isolated_data_dir, + platform, + 0, + global_application, + ) + + assert environment.workspace.parallel is True + + def test_parallel_override(self, isolation, isolated_data_dir, platform, global_application): + config = { + 'project': {'name': 'my_app', 'version': '0.0.1'}, + 'tool': {'hatch': {'envs': {'default': {'workspace': {'parallel': False}}}}}, + } + project = Project(isolation, config=config) + environment = MockEnvironment( + isolation, + project.metadata, + 'default', + project.config.envs['default'], + {}, + isolated_data_dir, + isolated_data_dir, + platform, + 0, + global_application, + ) + + assert environment.workspace.parallel is False + + def test_members_not_table(self, isolation, isolated_data_dir, platform, global_application): + config = { + 'project': {'name': 'my_app', 'version': '0.0.1'}, + 'tool': {'hatch': {'envs': {'default': {'workspace': {'members': 9000}}}}}, + } + project = Project(isolation, config=config) + environment = MockEnvironment( + isolation, + project.metadata, + 'default', + project.config.envs['default'], + {}, + isolated_data_dir, + isolated_data_dir, + platform, + 0, + global_application, + ) + + with pytest.raises(TypeError, match='Field `tool.hatch.envs.default.workspace.members` must be an array'): + _ = environment.workspace.members + + def test_member_invalid_type(self, isolation, isolated_data_dir, platform, global_application): + config = { + 'project': {'name': 'my_app', 'version': '0.0.1'}, + 'tool': {'hatch': {'envs': {'default': {'workspace': {'members': [9000]}}}}}, + } + project = Project(isolation, config=config) + environment = MockEnvironment( + isolation, + project.metadata, + 'default', + project.config.envs['default'], + {}, + isolated_data_dir, + isolated_data_dir, + platform, + 0, + global_application, + ) + + with pytest.raises( + TypeError, + match='Member #1 of field `tool.hatch.envs.default.workspace.members` must be a string or an inline table', + ): + _ = environment.workspace.members + + def test_member_no_path(self, isolation, isolated_data_dir, platform, global_application): + config = { + 'project': {'name': 'my_app', 'version': '0.0.1'}, + 'tool': {'hatch': {'envs': {'default': {'workspace': {'members': [{}]}}}}}, + } + project = Project(isolation, config=config) + environment = MockEnvironment( + isolation, + project.metadata, + 'default', + project.config.envs['default'], + {}, + isolated_data_dir, + isolated_data_dir, + platform, + 0, + global_application, + ) + + with pytest.raises( + TypeError, + match='Member #1 of field `tool.hatch.envs.default.workspace.members` must define a `path` key', + ): + _ = environment.workspace.members + + def test_member_path_not_string(self, isolation, isolated_data_dir, platform, global_application): + config = { + 'project': {'name': 'my_app', 'version': '0.0.1'}, + 'tool': {'hatch': {'envs': {'default': {'workspace': {'members': [{'path': 9000}]}}}}}, + } + project = Project(isolation, config=config) + environment = MockEnvironment( + isolation, + project.metadata, + 'default', + project.config.envs['default'], + {}, + isolated_data_dir, + isolated_data_dir, + platform, + 0, + global_application, + ) + + with pytest.raises( + TypeError, + match='Option `path` of member #1 of field `tool.hatch.envs.default.workspace.members` must be a string', + ): + _ = environment.workspace.members + + def test_member_path_empty_string(self, isolation, isolated_data_dir, platform, global_application): + config = { + 'project': {'name': 'my_app', 'version': '0.0.1'}, + 'tool': {'hatch': {'envs': {'default': {'workspace': {'members': [{'path': ''}]}}}}}, + } + project = Project(isolation, config=config) + environment = MockEnvironment( + isolation, + project.metadata, + 'default', + project.config.envs['default'], + {}, + isolated_data_dir, + isolated_data_dir, + platform, + 0, + global_application, + ) + + with pytest.raises( + ValueError, + match=( + 'Option `path` of member #1 of field `tool.hatch.envs.default.workspace.members` ' + 'cannot be an empty string' + ), + ): + _ = environment.workspace.members + + def test_member_features_not_array(self, isolation, isolated_data_dir, platform, global_application): + config = { + 'project': {'name': 'my_app', 'version': '0.0.1'}, + 'tool': {'hatch': {'envs': {'default': {'workspace': {'members': [{'path': 'foo', 'features': 9000}]}}}}}, + } + project = Project(isolation, config=config) + environment = MockEnvironment( + isolation, + project.metadata, + 'default', + project.config.envs['default'], + {}, + isolated_data_dir, + isolated_data_dir, + platform, + 0, + global_application, + ) + + with pytest.raises( + TypeError, + match=( + 'Option `features` of member #1 of field `tool.hatch.envs.default.workspace.members` ' + 'must be an array of strings' + ), + ): + _ = environment.workspace.members + + def test_member_feature_not_string(self, isolation, isolated_data_dir, platform, global_application): + config = { + 'project': {'name': 'my_app', 'version': '0.0.1'}, + 'tool': {'hatch': {'envs': {'default': {'workspace': {'members': [{'path': 'foo', 'features': [9000]}]}}}}}, + } + project = Project(isolation, config=config) + environment = MockEnvironment( + isolation, + project.metadata, + 'default', + project.config.envs['default'], + {}, + isolated_data_dir, + isolated_data_dir, + platform, + 0, + global_application, + ) + + with pytest.raises( + TypeError, + match=( + 'Feature #1 of option `features` of member #1 of field `tool.hatch.envs.default.workspace.members` ' + 'must be a string' + ), + ): + _ = environment.workspace.members + + def test_member_feature_empty_string(self, isolation, isolated_data_dir, platform, global_application): + config = { + 'project': {'name': 'my_app', 'version': '0.0.1'}, + 'tool': {'hatch': {'envs': {'default': {'workspace': {'members': [{'path': 'foo', 'features': ['']}]}}}}}, + } + project = Project(isolation, config=config) + environment = MockEnvironment( + isolation, + project.metadata, + 'default', + project.config.envs['default'], + {}, + isolated_data_dir, + isolated_data_dir, + platform, + 0, + global_application, + ) + + with pytest.raises( + ValueError, + match=( + 'Feature #1 of option `features` of member #1 of field `tool.hatch.envs.default.workspace.members` ' + 'cannot be an empty string' + ), + ): + _ = environment.workspace.members + + def test_member_feature_duplicate(self, isolation, isolated_data_dir, platform, global_application): + config = { + 'project': {'name': 'my_app', 'version': '0.0.1'}, + 'tool': { + 'hatch': { + 'envs': {'default': {'workspace': {'members': [{'path': 'foo', 'features': ['foo', 'Foo']}]}}} + } + }, + } + project = Project(isolation, config=config) + environment = MockEnvironment( + isolation, + project.metadata, + 'default', + project.config.envs['default'], + {}, + isolated_data_dir, + isolated_data_dir, + platform, + 0, + global_application, + ) + + with pytest.raises( + ValueError, + match=( + 'Feature #2 of option `features` of member #1 of field `tool.hatch.envs.default.workspace.members` ' + 'is a duplicate' + ), + ): + _ = environment.workspace.members + + def test_member_does_not_exist(self, isolation, isolated_data_dir, platform, global_application): + config = { + 'project': {'name': 'my_app', 'version': '0.0.1'}, + 'tool': {'hatch': {'envs': {'default': {'workspace': {'members': [{'path': 'foo'}]}}}}}, + } + project = Project(isolation, config=config) + environment = MockEnvironment( + isolation, + project.metadata, + 'default', + project.config.envs['default'], + {}, + isolated_data_dir, + isolated_data_dir, + platform, + 0, + global_application, + ) + + with pytest.raises( + OSError, + match=re.escape( + f'No members could be derived from `foo` of field `tool.hatch.envs.default.workspace.members`: ' + f'{isolation / "foo"}' + ), + ): + _ = environment.workspace.members + + def test_member_not_project(self, temp_dir, isolated_data_dir, platform, global_application): + config = { + 'project': {'name': 'my_app', 'version': '0.0.1'}, + 'tool': {'hatch': {'envs': {'default': {'workspace': {'members': [{'path': 'foo'}]}}}}}, + } + project = Project(temp_dir, config=config) + environment = MockEnvironment( + temp_dir, + project.metadata, + 'default', + project.config.envs['default'], + {}, + isolated_data_dir, + isolated_data_dir, + platform, + 0, + global_application, + ) + + member_path = temp_dir / 'foo' + member_path.mkdir() + + with pytest.raises( + OSError, + match=re.escape( + f'Member derived from `foo` of field `tool.hatch.envs.default.workspace.members` is not a project ' + f'(no `pyproject.toml` file): {member_path}' + ), + ): + _ = environment.workspace.members + + def test_member_duplicate(self, temp_dir, isolated_data_dir, platform, global_application): + config = { + 'project': {'name': 'my_app', 'version': '0.0.1'}, + 'tool': {'hatch': {'envs': {'default': {'workspace': {'members': [{'path': 'foo'}, {'path': 'f*'}]}}}}}, + } + project = Project(temp_dir, config=config) + environment = MockEnvironment( + temp_dir, + project.metadata, + 'default', + project.config.envs['default'], + {}, + isolated_data_dir, + isolated_data_dir, + platform, + 0, + global_application, + ) + + member_path = temp_dir / 'foo' + member_path.mkdir() + (member_path / 'pyproject.toml').touch() + + with pytest.raises( + ValueError, + match=re.escape( + f'Member derived from `f*` of field ' + f'`tool.hatch.envs.default.workspace.members` is a duplicate: {member_path}' + ), + ): + _ = environment.workspace.members + + def test_correct(self, hatch, temp_dir, isolated_data_dir, platform, global_application): + member1_path = temp_dir / 'foo' + member2_path = temp_dir / 'bar' + member3_path = temp_dir / 'baz' + for member_path in [member1_path, member2_path, member3_path]: + with temp_dir.as_cwd(): + result = hatch('new', member_path.name) + assert result.exit_code == 0, result.output + + config = { + 'project': {'name': 'my_app', 'version': '0.0.1'}, + 'tool': {'hatch': {'envs': {'default': {'workspace': {'members': [{'path': 'foo'}, {'path': 'b*'}]}}}}}, + } + project = Project(temp_dir, config=config) + environment = MockEnvironment( + temp_dir, + project.metadata, + 'default', + project.config.envs['default'], + {}, + isolated_data_dir, + isolated_data_dir, + platform, + 0, + global_application, + ) + + members = environment.workspace.members + assert len(members) == 3 + assert members[0].project.location == member1_path + assert members[1].project.location == member2_path + assert members[2].project.location == member3_path + + +class TestWorkspaceDependencies: + def test_basic(self, temp_dir, isolated_data_dir, platform, global_application): + for i in range(3): + project_file = temp_dir / f'foo{i}' / 'pyproject.toml' + project_file.parent.mkdir() + project_file.write_text( + f"""\ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "foo{i}" +version = "0.0.1" +dependencies = ["pkg-{i}"] +""" + ) + + config = { + 'project': {'name': 'my_app', 'version': '0.0.1'}, + 'tool': {'hatch': {'envs': {'default': {'workspace': {'members': [{'path': 'f*'}]}}}}}, + } + project = Project(temp_dir, config=config) + environment = MockEnvironment( + temp_dir, + project.metadata, + 'default', + project.config.envs['default'], + {}, + isolated_data_dir, + isolated_data_dir, + platform, + 0, + global_application, + ) + + assert environment.workspace.get_dependencies() == ['pkg-0', 'pkg-1', 'pkg-2'] + + def test_features(self, temp_dir, isolated_data_dir, platform, global_application): + for i in range(3): + project_file = temp_dir / f'foo{i}' / 'pyproject.toml' + project_file.parent.mkdir() + project_file.write_text( + f"""\ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "foo{i}" +version = "0.0.1" +dependencies = ["pkg-{i}"] + +[project.optional-dependencies] +feature1 = ["pkg-feature-1{i}"] +feature2 = ["pkg-feature-2{i}"] +feature3 = ["pkg-feature-3{i}"] +""" + ) + + config = { + 'project': {'name': 'my_app', 'version': '0.0.1'}, + 'tool': { + 'hatch': { + 'envs': { + 'default': { + 'workspace': { + 'members': [ + {'path': 'foo0', 'features': ['feature1']}, + {'path': 'foo1', 'features': ['feature1', 'feature2']}, + {'path': 'foo2', 'features': ['feature1', 'feature2', 'feature3']}, + ], + }, + }, + }, + }, + }, + } + project = Project(temp_dir, config=config) + environment = MockEnvironment( + temp_dir, + project.metadata, + 'default', + project.config.envs['default'], + {}, + isolated_data_dir, + isolated_data_dir, + platform, + 0, + global_application, + ) + + assert environment.workspace.get_dependencies() == [ + 'pkg-0', + 'pkg-feature-10', + 'pkg-1', + 'pkg-feature-11', + 'pkg-feature-21', + 'pkg-2', + 'pkg-feature-12', + 'pkg-feature-22', + 'pkg-feature-32', + ] From 2e82dc2b07ed41801ad88935dc19bf5cd19e9d2a Mon Sep 17 00:00:00 2001 From: Cary Hawkins Date: Mon, 22 Sep 2025 16:19:53 -0700 Subject: [PATCH 18/55] Fix not all reqs convert to deps issue --- src/hatch/env/plugin/interface.py | 23 +++++++++++++++++++---- 1 file changed, 19 insertions(+), 4 deletions(-) diff --git a/src/hatch/env/plugin/interface.py b/src/hatch/env/plugin/interface.py index f387ec4f1..052c8d2c6 100644 --- a/src/hatch/env/plugin/interface.py +++ b/src/hatch/env/plugin/interface.py @@ -342,13 +342,27 @@ def local_dependencies_complex(self) -> list[Dependency]: @cached_property def dependencies_complex(self) -> list[Dependency]: + from hatch.dep.core import Dependency + all_dependencies_complex = list(self.environment_dependencies_complex) - all_dependencies_complex.extend(self.additional_dependencies) + + # Convert additional_dependencies to Dependency objects + for dep in self.additional_dependencies: + if isinstance(dep, Dependency): + all_dependencies_complex.append(dep) + else: + all_dependencies_complex.append(Dependency(str(dep))) + if self.builder: - from hatch.dep.core import Dependency from hatch.project.constants import BuildEnvVars - all_dependencies_complex.extend(self.metadata.build.requires_complex) + # Convert build requirements to Dependency objects + for req in self.metadata.build.requires_complex: + if isinstance(req, Dependency): + all_dependencies_complex.append(req) + else: + all_dependencies_complex.append(Dependency(str(req))) + for target in os.environ.get(BuildEnvVars.REQUESTED_TARGETS, '').split(): target_config = self.app.project.config.build.target(target) all_dependencies_complex.extend(map(Dependency, target_config.dependencies)) @@ -374,9 +388,10 @@ def dependencies(self) -> list[str]: @cached_property def all_dependencies_complex(self) -> list[Dependency]: + from hatch.dep.core import Dependency all_dependencies_complex = list(self.local_dependencies_complex) all_dependencies_complex.extend(self.dependencies_complex) - return all_dependencies_complex + return [dep if isinstance(dep, Dependency) else Dependency(str(dep)) for dep in all_dependencies_complex] @cached_property def all_dependencies(self) -> list[str]: From 12511487ad5aef3adb7b8465adf0c359d3143e8f Mon Sep 17 00:00:00 2001 From: Cary Hawkins Date: Thu, 25 Sep 2025 07:55:51 -0700 Subject: [PATCH 19/55] Additional fixes for Requirements to Dependency conversion, fix tests failing to match because of new line --- src/hatch/dep/core.py | 2 +- src/hatch/env/plugin/interface.py | 27 +++++++++++++++++++++------ tests/dep/test_sync.py | 5 ++--- 3 files changed, 24 insertions(+), 10 deletions(-) diff --git a/src/hatch/dep/core.py b/src/hatch/dep/core.py index fe81474bb..0ba1395bf 100644 --- a/src/hatch/dep/core.py +++ b/src/hatch/dep/core.py @@ -35,4 +35,4 @@ def path(self) -> Path | None: if uri.scheme != 'file': return None - return Path(os.sep.join(uri.path)) + return Path.from_uri(self.url) diff --git a/src/hatch/env/plugin/interface.py b/src/hatch/env/plugin/interface.py index 052c8d2c6..11fbad72e 100644 --- a/src/hatch/env/plugin/interface.py +++ b/src/hatch/env/plugin/interface.py @@ -168,7 +168,7 @@ def pathsep(self) -> str: return os.pathsep @cached_property - def system_python(self): + def system_python(self) -> str: system_python = os.environ.get(AppEnvVars.PYTHON) if system_python == "self": system_python = sys.executable @@ -308,7 +308,7 @@ def project_dependencies_complex(self) -> list[Dependency]: ) raise ValueError(message) - all_dependencies_complex.extend(optional_dependencies_complex[feature].values()) + all_dependencies_complex.extend([dep if isinstance(dep, Dependency) else Dependency(str(dep)) for dep in optional_dependencies_complex[feature]]) return all_dependencies_complex @@ -328,9 +328,8 @@ def local_dependencies_complex(self) -> list[Dependency]: local_dependencies_complex = [] if not self.skip_install: - root = 'file://' if self.sep == '/' else 'file:///' local_dependencies_complex.append( - Dependency(f'{self.metadata.name} @ {root}{self.project_root}', editable=self.dev_mode) + Dependency(f'{self.metadata.name} @ {self.root.as_uri()}', editable=self.dev_mode) ) local_dependencies_complex.extend( @@ -1022,7 +1021,22 @@ def get_dependencies(self) -> list[str]: for feature in member.features: all_dependencies.extend(features.get(feature, [])) - if not self.parallel: + if self.parallel: + from concurrent.futures import ThreadPoolExecutor + + def get_member_deps(member): + with self.env.app.status(f'Checking workspace member: {member.name}'): + dependencies, features = member.get_dependencies() + deps = list(dependencies) + for feature in member.features: + deps.extend(features.get(feature, [])) + return deps + + with ThreadPoolExecutor() as executor: + results = executor.map(get_member_deps, dynamic_members) + for deps in results: + all_dependencies.extend(deps) + else: for member in dynamic_members: with self.env.app.status(f'Checking workspace member: {member.name}'): dependencies, features = member.get_dependencies() @@ -1237,7 +1251,8 @@ def find_members(root, relative_components): else: component_matchers.append(lambda entry, component=component: component == entry.name) - yield from _recurse_members(root, 0, component_matchers) + results = list(_recurse_members(root, 0, component_matchers)) + yield from sorted(results, key=lambda path: os.path.basename(path)) def _recurse_members(root, matcher_index, matchers): diff --git a/tests/dep/test_sync.py b/tests/dep/test_sync.py index 69da04f0a..2e514b59b 100644 --- a/tests/dep/test_sync.py +++ b/tests/dep/test_sync.py @@ -176,9 +176,8 @@ def test_dependency_git_revision_uv(platform, uv_on_path): check=True, capture_output=True, ) - assert not dependencies_in_sync( - [Requirement('requests@git+https://github.com/psf/requests@main')], venv.sys_path - ) + distributions = InstalledDistributions(sys_path=venv.sys_path) + assert not distributions.dependencies_in_sync([Dependency('requests@git+https://github.com/psf/requests@main')]) @pytest.mark.requires_internet From 405a31b6bb123e0fb35cd2938204ce0294c99f82 Mon Sep 17 00:00:00 2001 From: Cary Hawkins Date: Thu, 25 Sep 2025 23:07:02 -0700 Subject: [PATCH 20/55] Fixing tests and typing issues found during workspaces dev. --- docs/meta/authors.md | 1 + src/hatch/dep/core.py | 1 - tests/dep/test_sync.py | 4 ++-- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/meta/authors.md b/docs/meta/authors.md index 84ef02985..dea0c870b 100644 --- a/docs/meta/authors.md +++ b/docs/meta/authors.md @@ -17,3 +17,4 @@ - Olga Matoula [:material-github:](https://github.com/olgarithms) [:material-twitter:](https://twitter.com/olgarithms_) - Philip Blair [:material-email:](mailto:philip@pblair.org) - Robert Rosca [:material-github:](https://github.com/robertrosca) +- Cary Hawkins [:material-github](https://github.com/cjames23) diff --git a/src/hatch/dep/core.py b/src/hatch/dep/core.py index 0ba1395bf..c8b93f527 100644 --- a/src/hatch/dep/core.py +++ b/src/hatch/dep/core.py @@ -1,6 +1,5 @@ from __future__ import annotations -import os from functools import cached_property from packaging.requirements import InvalidRequirement, Requirement diff --git a/tests/dep/test_sync.py b/tests/dep/test_sync.py index 2e514b59b..90fb4c5af 100644 --- a/tests/dep/test_sync.py +++ b/tests/dep/test_sync.py @@ -153,7 +153,7 @@ def test_dependency_git_uv(platform, uv_on_path): capture_output=True, ) distributions = InstalledDistributions(sys_path=venv.sys_path) - assert not distributions.dependencies_in_sync([Dependency('requests@git+https://github.com/psf/requests')]) + assert distributions.dependencies_in_sync([Dependency('requests@git+https://github.com/psf/requests')]) @pytest.mark.requires_internet @@ -177,7 +177,7 @@ def test_dependency_git_revision_uv(platform, uv_on_path): capture_output=True, ) distributions = InstalledDistributions(sys_path=venv.sys_path) - assert not distributions.dependencies_in_sync([Dependency('requests@git+https://github.com/psf/requests@main')]) + assert distributions.dependencies_in_sync([Dependency('requests@git+https://github.com/psf/requests@main')]) @pytest.mark.requires_internet From 29d8169c83544402dfdfdf8bc1068b92d633de50 Mon Sep 17 00:00:00 2001 From: Cary Hawkins Date: Fri, 26 Sep 2025 16:51:23 -0700 Subject: [PATCH 21/55] Drop 3.8 in classifiers and add changelogs to history docs. --- docs/history/hatch.md | 9 ++++++++- docs/history/hatchling.md | 2 ++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/docs/history/hatch.md b/docs/history/hatch.md index d6599242d..97d0f4d26 100644 --- a/docs/history/hatch.md +++ b/docs/history/hatch.md @@ -5,13 +5,20 @@ All notable changes to Hatch will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - ## Unreleased +***Changed:*** +- Drop support for Python 3.8 + +***Fixed:*** +- Fix issue where terminal output would be out of sync during build. + +## [1.41.2](https://github.com/pypa/hatch/releases/tag/hatch-v1.14.2) 2025-09-23 ## {: #hatch-v1.14.2 } ***Changed:*** - Drop support for Python 3.8 - Environment type plugins are now no longer expected to support a pseudo-build environment as any environment now may be used for building. The following methods have been removed: `build_environment`, `build_environment_exists`, `run_builder`, `construct_build_command` +- Fix for Click Sentinel value when using `run` command ***Added:*** diff --git a/docs/history/hatchling.md b/docs/history/hatchling.md index a6b0e7fdb..65b790082 100644 --- a/docs/history/hatchling.md +++ b/docs/history/hatchling.md @@ -7,6 +7,8 @@ All notable changes to Hatchling will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## Unreleased +***Changed:*** +- Drop support for Python 3.8 ***Changed:*** From 53f0a703e2c2ad42554010bd1a32b86f7e1b041b Mon Sep 17 00:00:00 2001 From: Cary Hawkins Date: Fri, 26 Sep 2025 18:49:28 -0700 Subject: [PATCH 22/55] Fix history doc for unreleased changes --- docs/history/hatch.md | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/docs/history/hatch.md b/docs/history/hatch.md index 97d0f4d26..8bdbf6411 100644 --- a/docs/history/hatch.md +++ b/docs/history/hatch.md @@ -5,19 +5,14 @@ All notable changes to Hatch will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + ## Unreleased ***Changed:*** - Drop support for Python 3.8 +- Environment type plugins are now no longer expected to support a pseudo-build environment as any environment now may be used for building. The following methods have been removed: `build_environment`, `build_environment_exists`, `run_builder`, `construct_build_command` ***Fixed:*** - Fix issue where terminal output would be out of sync during build. - -## [1.41.2](https://github.com/pypa/hatch/releases/tag/hatch-v1.14.2) 2025-09-23 ## {: #hatch-v1.14.2 } - -***Changed:*** - -- Drop support for Python 3.8 -- Environment type plugins are now no longer expected to support a pseudo-build environment as any environment now may be used for building. The following methods have been removed: `build_environment`, `build_environment_exists`, `run_builder`, `construct_build_command` - Fix for Click Sentinel value when using `run` command ***Added:*** From ecb2bad1cbb6d0d3f75d8ec658e2f0c828873a79 Mon Sep 17 00:00:00 2001 From: Cary Hawkins Date: Fri, 26 Sep 2025 20:40:09 -0700 Subject: [PATCH 23/55] Fix: self test needed to have an additional arg, history doc formatting --- docs/history/hatch.md | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/docs/history/hatch.md b/docs/history/hatch.md index 8bdbf6411..d14cf87ad 100644 --- a/docs/history/hatch.md +++ b/docs/history/hatch.md @@ -8,13 +8,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), ## Unreleased ***Changed:*** + - Drop support for Python 3.8 - Environment type plugins are now no longer expected to support a pseudo-build environment as any environment now may be used for building. The following methods have been removed: `build_environment`, `build_environment_exists`, `run_builder`, `construct_build_command` -***Fixed:*** -- Fix issue where terminal output would be out of sync during build. -- Fix for Click Sentinel value when using `run` command - ***Added:*** - Upgrade Ruff to 0.13.2 From a81b13ea51c309f9bab64ebfe4aafd374fec8ec0 Mon Sep 17 00:00:00 2001 From: Cary Hawkins Date: Sat, 27 Sep 2025 13:42:15 -0700 Subject: [PATCH 24/55] Fix: formatting --- docs/history/hatch.md | 1 + docs/history/hatchling.md | 2 ++ 2 files changed, 3 insertions(+) diff --git a/docs/history/hatch.md b/docs/history/hatch.md index d14cf87ad..d6599242d 100644 --- a/docs/history/hatch.md +++ b/docs/history/hatch.md @@ -7,6 +7,7 @@ All notable changes to Hatch will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## Unreleased + ***Changed:*** - Drop support for Python 3.8 diff --git a/docs/history/hatchling.md b/docs/history/hatchling.md index 65b790082..26875d1e8 100644 --- a/docs/history/hatchling.md +++ b/docs/history/hatchling.md @@ -7,7 +7,9 @@ All notable changes to Hatchling will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## Unreleased + ***Changed:*** + - Drop support for Python 3.8 ***Changed:*** From 6d3bd15c22e10bff2c21b9a1623798beb01fc327 Mon Sep 17 00:00:00 2001 From: Cary Hawkins Date: Thu, 2 Oct 2025 19:12:37 -0700 Subject: [PATCH 25/55] Add Workspace config model and tests for workspace support. --- src/hatch/env/plugin/interface.py | 8 +- src/hatch/project/config.py | 63 ++++++ tests/workspaces/__init__.py | 0 tests/workspaces/configuration.py | 333 ++++++++++++++++++++++++++++++ 4 files changed, 402 insertions(+), 2 deletions(-) create mode 100644 tests/workspaces/__init__.py create mode 100644 tests/workspaces/configuration.py diff --git a/src/hatch/env/plugin/interface.py b/src/hatch/env/plugin/interface.py index 11fbad72e..4446a7d23 100644 --- a/src/hatch/env/plugin/interface.py +++ b/src/hatch/env/plugin/interface.py @@ -308,7 +308,10 @@ def project_dependencies_complex(self) -> list[Dependency]: ) raise ValueError(message) - all_dependencies_complex.extend([dep if isinstance(dep, Dependency) else Dependency(str(dep)) for dep in optional_dependencies_complex[feature]]) + all_dependencies_complex.extend([ + dep if isinstance(dep, Dependency) else Dependency(str(dep)) + for dep in optional_dependencies_complex[feature] + ]) return all_dependencies_complex @@ -388,6 +391,7 @@ def dependencies(self) -> list[str]: @cached_property def all_dependencies_complex(self) -> list[Dependency]: from hatch.dep.core import Dependency + all_dependencies_complex = list(self.local_dependencies_complex) all_dependencies_complex.extend(self.dependencies_complex) return [dep if isinstance(dep, Dependency) else Dependency(str(dep)) for dep in all_dependencies_complex] @@ -1252,7 +1256,7 @@ def find_members(root, relative_components): component_matchers.append(lambda entry, component=component: component == entry.name) results = list(_recurse_members(root, 0, component_matchers)) - yield from sorted(results, key=lambda path: os.path.basename(path)) + yield from sorted(results, key=os.path.basename) def _recurse_members(root, matcher_index, matchers): diff --git a/src/hatch/project/config.py b/src/hatch/project/config.py index d397959b5..234b00942 100644 --- a/src/hatch/project/config.py +++ b/src/hatch/project/config.py @@ -14,6 +14,7 @@ if TYPE_CHECKING: from hatch.dep.core import Dependency + from hatch.utils.fs import Path class ProjectConfig: @@ -424,6 +425,20 @@ def envs(self): for environment_collector in environment_collectors: environment_collector.finalize_environments(final_config) + # Add workspace environments if this is a workspace member + workspace_root = self._find_workspace_root() + if workspace_root and workspace_root != self.root: + try: + from hatch.project.core import Project + workspace_project = Project(workspace_root, locate=False) + workspace_config = ProjectConfig(workspace_root, workspace_project.metadata.hatch.config, + self.plugin_manager) + for env_name, env_config in workspace_config.envs.items(): + if env_name not in final_config: + final_config[env_name] = env_config + except Exception: + pass + self._matrices = all_matrices self._internal_matrices = {} self._envs = final_config @@ -443,6 +458,23 @@ def envs(self): return self._envs + def _find_workspace_root(self) -> Path | None: + """Find workspace root by traversing up from current working directory.""" + from hatch.utils.fs import Path + current = Path.cwd() + while current.parent != current: + pyproject = current / "pyproject.toml" + if pyproject.exists(): + try: + from hatch.utils.toml import load_toml_file + config = load_toml_file(str(pyproject)) + if config.get("tool", {}).get("hatch", {}).get("workspace"): + return current + except Exception: + pass + current = current.parent + return None + @property def publish(self): if self._publish is None: @@ -501,6 +533,15 @@ def scripts(self): return self._scripts + @cached_property + def workspace(self): + config = self.config.get("workspace", {}) + if not isinstance(config, dict): + message = "Field `tool.hatch.workspace` must be a table" + raise TypeError(message) + + return WorkspaceConfig(config, self.root) + def finalize_env_overrides(self, option_types): # We lazily apply overrides because we need type information potentially defined by # environment plugins for their options @@ -728,6 +769,28 @@ def finalize_hook_config(hook_config: dict[str, dict[str, Any]]) -> dict[str, di return final_hook_config +class WorkspaceConfig: + def __init__(self, config: dict[str, Any], root: Path): + self.__config = config + self.__root = root + + @cached_property + def members(self) -> list[str]: + members = self.__config.get("members", []) + if not isinstance(members, list): + message = "Field `tool.hatch.workspace.members` must be an array" + raise TypeError(message) + return members + + @cached_property + def exclude(self) -> list[str]: + exclude = self.__config.get("exclude", []) + if not isinstance(exclude, list): + message = "Field `tool.hatch.workspace.exclude` must be an array" + raise TypeError(message) + return exclude + + def env_var_enabled(env_var: str, *, default: bool = False) -> bool: if env_var in environ: return environ[env_var] in {"1", "true"} diff --git a/tests/workspaces/__init__.py b/tests/workspaces/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/workspaces/configuration.py b/tests/workspaces/configuration.py new file mode 100644 index 000000000..225f86b3f --- /dev/null +++ b/tests/workspaces/configuration.py @@ -0,0 +1,333 @@ +import pytest +from hatch.project.core import Project +from hatch.utils.fs import Path as HatchPath + + +class TestWorkspaceConfiguration: + def test_workspace_members_editable_install(self, temp_dir, hatch): + """Test that workspace members are installed as editable packages.""" + # Create workspace root + workspace_root = temp_dir / "workspace" + workspace_root.mkdir() + + # Create workspace pyproject.toml + workspace_config = workspace_root / "pyproject.toml" + workspace_config.write_text(""" +[project] +name = "workspace-root" +version = "0.1.0" +[tool.hatch.workspace] +members = ["packages/*"] + +[tool.hatch.envs.default] +type = "virtual" +""") + + # Create workspace members + packages_dir = workspace_root / "packages" + packages_dir.mkdir() + + # Member 1 + member1_dir = packages_dir / "member1" + member1_dir.mkdir() + (member1_dir / "pyproject.toml").write_text(""" +[project] +name = "member1" +version = "0.1.0" +dependencies = ["requests"] +""") + + # Member 2 + member2_dir = packages_dir / "member2" + member2_dir.mkdir() + (member2_dir / "pyproject.toml").write_text(""" +[project] +name = "member2" +version = "0.1.0" +dependencies = ["click"] +""") + + with workspace_root.as_cwd(): + # Test environment creation includes workspace members + result = hatch('env', 'create') + assert result.exit_code == 0 + + # Verify workspace members are discovered + result = hatch('env', 'show', '--json') + assert result.exit_code == 0 + + def test_workspace_exclude_patterns(self, temp_dir, hatch): + """Test that exclude patterns filter out workspace members.""" + workspace_root = temp_dir / "workspace" + workspace_root.mkdir() + + workspace_config = workspace_root / "pyproject.toml" + workspace_config.write_text(""" +[project] +name = "workspace-root" +version = "0.1.0" +[tool.hatch.workspace] +members = ["packages/*"] +exclude = ["packages/excluded*"] +""") + + packages_dir = workspace_root / "packages" + packages_dir.mkdir() + + # Included member + included_dir = packages_dir / "included" + included_dir.mkdir() + (included_dir / "pyproject.toml").write_text(""" +[project] +name = "included" +version = "0.1.0" +""") + + # Excluded member + excluded_dir = packages_dir / "excluded-pkg" + excluded_dir.mkdir() + (excluded_dir / "pyproject.toml").write_text(""" +[project] +name = "excluded-pkg" +version = "0.1.0" +""") + + with workspace_root.as_cwd(): + result = hatch('env', 'create') + assert result.exit_code == 0 + + def test_workspace_parallel_dependency_resolution(self, temp_dir, hatch): + """Test parallel dependency resolution for workspace members.""" + workspace_root = temp_dir / "workspace" + workspace_root.mkdir() + + workspace_config = workspace_root / "pyproject.toml" + workspace_config.write_text(""" +[project] +name = "workspace-root" +version = "0.1.0" +[tool.hatch.workspace] +members = ["packages/*"] + +[tool.hatch.envs.default] +workspace.parallel = true +""") + + packages_dir = workspace_root / "packages" + packages_dir.mkdir() + + # Create multiple members + for i in range(3): + member_dir = packages_dir / f"member{i}" + member_dir.mkdir() + (member_dir / "pyproject.toml").write_text(f""" +[project] +name = "member{i}" +version = "0.1.{i}" +dependencies = ["requests"] +""") + + with workspace_root.as_cwd(): + result = hatch('env', 'create') + assert result.exit_code == 0 + + def test_workspace_member_features(self, temp_dir, hatch): + """Test workspace members with specific features.""" + workspace_root = temp_dir / "workspace" + workspace_root.mkdir() + + workspace_config = workspace_root / "pyproject.toml" + workspace_config.write_text(""" +[project] +name = "workspace-root" +version = "0.1.0" +[tool.hatch.envs.default] +workspace.members = [ + {path = "packages/member1", features = ["dev", "test"]} +] +""") + + packages_dir = workspace_root / "packages" + packages_dir.mkdir() + + member1_dir = packages_dir / "member1" + member1_dir.mkdir() + (member1_dir / "pyproject.toml").write_text(""" +[project] +name = "member1" +dependencies = ["requests"] +version = "0.1.0" +[project.optional-dependencies] +dev = ["black", "ruff"] +test = ["pytest"] +""") + + with workspace_root.as_cwd(): + result = hatch('env', 'create') + assert result.exit_code == 0 + + def test_workspace_inheritance_from_root(self, temp_dir, hatch): + """Test that workspace members inherit environments from root.""" + workspace_root = temp_dir / "workspace" + workspace_root.mkdir() + + # Workspace root with shared environment + workspace_config = workspace_root / "pyproject.toml" + workspace_config.write_text(""" +[project] +name = "workspace-root" +version = "0.1.0" +[tool.hatch.workspace] +members = ["packages/*"] + +[tool.hatch.envs.shared] +dependencies = ["pytest", "black"] +scripts.test = "pytest" +""") + + packages_dir = workspace_root / "packages" + packages_dir.mkdir() + + # Member without local shared environment + member_dir = packages_dir / "member1" + member_dir.mkdir() + (member_dir / "pyproject.toml").write_text(""" +[project] +name = "member1" +version = "0.1.0" +[tool.hatch.envs.default] +dependencies = ["requests"] +""") + + # Test from workspace root + with workspace_root.as_cwd(): + result = hatch('env', 'show', 'shared') + assert result.exit_code == 0 + + # Test from member directory + with member_dir.as_cwd(): + result = hatch('env', 'show', 'shared') + assert result.exit_code == 0 + + def test_workspace_no_members_fallback(self, temp_dir, hatch): + """Test fallback when no workspace members are defined.""" + workspace_root = temp_dir / "workspace" + workspace_root.mkdir() + + workspace_config = workspace_root / "pyproject.toml" + workspace_config.write_text(""" +[project] +name = "workspace-root" +version = "0.1.0" +[tool.hatch.envs.default] +dependencies = ["requests"] +""") + + with workspace_root.as_cwd(): + result = hatch('env', 'create') + assert result.exit_code == 0 + + result = hatch('env', 'show', '--json') + assert result.exit_code == 0 + + def test_workspace_cross_member_dependencies(self, temp_dir, hatch): + """Test workspace members depending on each other.""" + workspace_root = temp_dir / "workspace" + workspace_root.mkdir() + + workspace_config = workspace_root / "pyproject.toml" + workspace_config.write_text(""" +[project] +name = "workspace-root" +version = "0.1.0" +[tool.hatch.workspace] +members = ["packages/*"] +""") + + packages_dir = workspace_root / "packages" + packages_dir.mkdir() + + # Base library + base_dir = packages_dir / "base" + base_dir.mkdir() + (base_dir / "pyproject.toml").write_text(""" +[project] +name = "base" +version = "0.1.0" +dependencies = ["requests"] +""") + + # App depending on base + app_dir = packages_dir / "app" + app_dir.mkdir() + (app_dir / "pyproject.toml").write_text(""" +[project] +name = "app" +version = "0.1.0" +dependencies = ["base", "click"] +""") + + with workspace_root.as_cwd(): + result = hatch('env', 'create') + assert result.exit_code == 0 + + # Test that dependencies are resolved + result = hatch('dep', 'show', 'table') + assert result.exit_code == 0 + + def test_workspace_build_all_members(self, temp_dir, hatch): + """Test building all workspace members.""" + workspace_root = temp_dir / "workspace" + workspace_root.mkdir() + + # Create workspace root package + workspace_pkg = workspace_root / "workspace_root" + workspace_pkg.mkdir() + (workspace_pkg / "__init__.py").write_text('__version__ = "0.1.0"') + + workspace_config = workspace_root / "pyproject.toml" + workspace_config.write_text(""" + [project] + name = "workspace-root" + version = "0.1.0" + + [tool.hatch.workspace] + members = ["packages/*"] + + [build-system] + requires = ["hatchling"] + build-backend = "hatchling.build" + + [tool.hatch.build.targets.wheel] + packages = ["workspace_root"] + """) + + packages_dir = workspace_root / "packages" + packages_dir.mkdir() + + # Create buildable members + for i in range(2): + member_dir = packages_dir / f"member{i}" + member_dir.mkdir() + (member_dir / "pyproject.toml").write_text(f""" + [project] + name = "member{i}" + version = "0.1.{i}" + + [build-system] + requires = ["hatchling"] + build-backend = "hatchling.build" + + [tool.hatch.build.targets.wheel] + packages = ["member{i}"] + """) + + # Create source files + src_dir = member_dir / f"member{i}" + src_dir.mkdir() + (src_dir / "__init__.py").write_text(f'__version__ = "0.1.{i}"') + + with workspace_root.as_cwd(): + result = hatch('build') + assert result.exit_code == 0 + From c76dcd58fc63740432388ebe2372f1cd5edd11ce Mon Sep 17 00:00:00 2001 From: Cary Hawkins Date: Fri, 3 Oct 2025 15:42:58 -0700 Subject: [PATCH 26/55] Add workspace aware project discovery --- src/hatch/cli/__init__.py | 29 +++++++++++++++++++++++++++-- src/hatch/project/config.py | 31 ------------------------------- 2 files changed, 27 insertions(+), 33 deletions(-) diff --git a/src/hatch/cli/__init__.py b/src/hatch/cli/__init__.py index 286df6b34..60f7497b6 100644 --- a/src/hatch/cli/__init__.py +++ b/src/hatch/cli/__init__.py @@ -28,6 +28,19 @@ from hatch.utils.ci import running_in_ci from hatch.utils.fs import Path +def find_workspace_root(path: Path) -> Path | None: + """Find workspace root by traversing up from given path.""" + current = path + while current.parent != current: + pyproject = current / "pyproject.toml" + if pyproject.exists(): + from hatch.utils.toml import load_toml_file + config = load_toml_file(str(pyproject)) + if config.get("tool", {}).get("hatch", {}).get("workspace"): + return current + current = current.parent + return None + @click.group( context_settings={"help_option_names": ["-h", "--help"], "max_content_width": 120}, invoke_without_command=True @@ -170,8 +183,20 @@ def hatch(ctx: click.Context, env_name, project, verbose, quiet, color, interact app.project.set_app(app) return - app.project = Project(Path.cwd()) - app.project.set_app(app) + # Discover workspace-aware project + workspace_root = find_workspace_root(Path.cwd()) + if workspace_root: + # Create project from workspace root with workspace context + app.project = Project(workspace_root, locate=False) + app.project.set_app(app) + # Set current member context if we're in a member directory + current_dir = Path.cwd() + if current_dir != workspace_root: + app.project._current_member_path = current_dir + else: + # No workspace, use current directory as before + app.project = Project(Path.cwd()) + app.project.set_app(app) if app.config.mode == "local": return diff --git a/src/hatch/project/config.py b/src/hatch/project/config.py index 234b00942..6440f36e4 100644 --- a/src/hatch/project/config.py +++ b/src/hatch/project/config.py @@ -425,20 +425,6 @@ def envs(self): for environment_collector in environment_collectors: environment_collector.finalize_environments(final_config) - # Add workspace environments if this is a workspace member - workspace_root = self._find_workspace_root() - if workspace_root and workspace_root != self.root: - try: - from hatch.project.core import Project - workspace_project = Project(workspace_root, locate=False) - workspace_config = ProjectConfig(workspace_root, workspace_project.metadata.hatch.config, - self.plugin_manager) - for env_name, env_config in workspace_config.envs.items(): - if env_name not in final_config: - final_config[env_name] = env_config - except Exception: - pass - self._matrices = all_matrices self._internal_matrices = {} self._envs = final_config @@ -458,23 +444,6 @@ def envs(self): return self._envs - def _find_workspace_root(self) -> Path | None: - """Find workspace root by traversing up from current working directory.""" - from hatch.utils.fs import Path - current = Path.cwd() - while current.parent != current: - pyproject = current / "pyproject.toml" - if pyproject.exists(): - try: - from hatch.utils.toml import load_toml_file - config = load_toml_file(str(pyproject)) - if config.get("tool", {}).get("hatch", {}).get("workspace"): - return current - except Exception: - pass - current = current.parent - return None - @property def publish(self): if self._publish is None: From 51462316b3ae09bb10322592b70b4681741c9c33 Mon Sep 17 00:00:00 2001 From: Cary Hawkins Date: Sun, 5 Oct 2025 13:52:05 -0700 Subject: [PATCH 27/55] Formatting changes and minor clean up --- src/hatch/cli/__init__.py | 8 ++- src/hatch/project/config.py | 12 ++-- src/hatch/project/core.py | 1 + src/hatch/utils/fs.py | 1 + tests/workspaces/configuration.py | 100 ++++++++++++++---------------- 5 files changed, 60 insertions(+), 62 deletions(-) diff --git a/src/hatch/cli/__init__.py b/src/hatch/cli/__init__.py index 60f7497b6..7a8ce7886 100644 --- a/src/hatch/cli/__init__.py +++ b/src/hatch/cli/__init__.py @@ -28,15 +28,17 @@ from hatch.utils.ci import running_in_ci from hatch.utils.fs import Path + def find_workspace_root(path: Path) -> Path | None: """Find workspace root by traversing up from given path.""" current = path while current.parent != current: - pyproject = current / "pyproject.toml" + pyproject = current / 'pyproject.toml' if pyproject.exists(): from hatch.utils.toml import load_toml_file + config = load_toml_file(str(pyproject)) - if config.get("tool", {}).get("hatch", {}).get("workspace"): + if config.get('tool', {}).get('hatch', {}).get('workspace'): return current current = current.parent return None @@ -192,7 +194,7 @@ def hatch(ctx: click.Context, env_name, project, verbose, quiet, color, interact # Set current member context if we're in a member directory current_dir = Path.cwd() if current_dir != workspace_root: - app.project._current_member_path = current_dir + app.project.current_member_path = current_dir else: # No workspace, use current directory as before app.project = Project(Path.cwd()) diff --git a/src/hatch/project/config.py b/src/hatch/project/config.py index 6440f36e4..2a87c6b2a 100644 --- a/src/hatch/project/config.py +++ b/src/hatch/project/config.py @@ -504,9 +504,9 @@ def scripts(self): @cached_property def workspace(self): - config = self.config.get("workspace", {}) + config = self.config.get('workspace', {}) if not isinstance(config, dict): - message = "Field `tool.hatch.workspace` must be a table" + message = 'Field `tool.hatch.workspace` must be a table' raise TypeError(message) return WorkspaceConfig(config, self.root) @@ -745,17 +745,17 @@ def __init__(self, config: dict[str, Any], root: Path): @cached_property def members(self) -> list[str]: - members = self.__config.get("members", []) + members = self.__config.get('members', []) if not isinstance(members, list): - message = "Field `tool.hatch.workspace.members` must be an array" + message = 'Field `tool.hatch.workspace.members` must be an array' raise TypeError(message) return members @cached_property def exclude(self) -> list[str]: - exclude = self.__config.get("exclude", []) + exclude = self.__config.get('exclude', []) if not isinstance(exclude, list): - message = "Field `tool.hatch.workspace.exclude` must be an array" + message = 'Field `tool.hatch.workspace.exclude` must be an array' raise TypeError(message) return exclude diff --git a/src/hatch/project/core.py b/src/hatch/project/core.py index e24794941..912c4d6e7 100644 --- a/src/hatch/project/core.py +++ b/src/hatch/project/core.py @@ -39,6 +39,7 @@ def __init__(self, path: Path, *, name: str | None = None, config=None, locate: self._config = None self._explicit_path: Path | None = None if locate else path + self.current_member_path: Path | None = None @property def plugin_manager(self): diff --git a/src/hatch/utils/fs.py b/src/hatch/utils/fs.py index 02a0328a3..a5c127613 100644 --- a/src/hatch/utils/fs.py +++ b/src/hatch/utils/fs.py @@ -136,6 +136,7 @@ def temp_hide(self) -> Generator[Path, None, None]: @classmethod def from_uri(cls, path: str) -> Path: return cls(path.replace('file:///', '', 1)) + else: @classmethod diff --git a/tests/workspaces/configuration.py b/tests/workspaces/configuration.py index 225f86b3f..ada8c01c1 100644 --- a/tests/workspaces/configuration.py +++ b/tests/workspaces/configuration.py @@ -1,17 +1,12 @@ -import pytest -from hatch.project.core import Project -from hatch.utils.fs import Path as HatchPath - - class TestWorkspaceConfiguration: def test_workspace_members_editable_install(self, temp_dir, hatch): """Test that workspace members are installed as editable packages.""" # Create workspace root - workspace_root = temp_dir / "workspace" + workspace_root = temp_dir / 'workspace' workspace_root.mkdir() # Create workspace pyproject.toml - workspace_config = workspace_root / "pyproject.toml" + workspace_config = workspace_root / 'pyproject.toml' workspace_config.write_text(""" [project] name = "workspace-root" @@ -24,13 +19,13 @@ def test_workspace_members_editable_install(self, temp_dir, hatch): """) # Create workspace members - packages_dir = workspace_root / "packages" + packages_dir = workspace_root / 'packages' packages_dir.mkdir() # Member 1 - member1_dir = packages_dir / "member1" + member1_dir = packages_dir / 'member1' member1_dir.mkdir() - (member1_dir / "pyproject.toml").write_text(""" + (member1_dir / 'pyproject.toml').write_text(""" [project] name = "member1" version = "0.1.0" @@ -38,9 +33,9 @@ def test_workspace_members_editable_install(self, temp_dir, hatch): """) # Member 2 - member2_dir = packages_dir / "member2" + member2_dir = packages_dir / 'member2' member2_dir.mkdir() - (member2_dir / "pyproject.toml").write_text(""" + (member2_dir / 'pyproject.toml').write_text(""" [project] name = "member2" version = "0.1.0" @@ -58,10 +53,10 @@ def test_workspace_members_editable_install(self, temp_dir, hatch): def test_workspace_exclude_patterns(self, temp_dir, hatch): """Test that exclude patterns filter out workspace members.""" - workspace_root = temp_dir / "workspace" + workspace_root = temp_dir / 'workspace' workspace_root.mkdir() - workspace_config = workspace_root / "pyproject.toml" + workspace_config = workspace_root / 'pyproject.toml' workspace_config.write_text(""" [project] name = "workspace-root" @@ -71,22 +66,22 @@ def test_workspace_exclude_patterns(self, temp_dir, hatch): exclude = ["packages/excluded*"] """) - packages_dir = workspace_root / "packages" + packages_dir = workspace_root / 'packages' packages_dir.mkdir() # Included member - included_dir = packages_dir / "included" + included_dir = packages_dir / 'included' included_dir.mkdir() - (included_dir / "pyproject.toml").write_text(""" + (included_dir / 'pyproject.toml').write_text(""" [project] name = "included" version = "0.1.0" """) # Excluded member - excluded_dir = packages_dir / "excluded-pkg" + excluded_dir = packages_dir / 'excluded-pkg' excluded_dir.mkdir() - (excluded_dir / "pyproject.toml").write_text(""" + (excluded_dir / 'pyproject.toml').write_text(""" [project] name = "excluded-pkg" version = "0.1.0" @@ -98,10 +93,10 @@ def test_workspace_exclude_patterns(self, temp_dir, hatch): def test_workspace_parallel_dependency_resolution(self, temp_dir, hatch): """Test parallel dependency resolution for workspace members.""" - workspace_root = temp_dir / "workspace" + workspace_root = temp_dir / 'workspace' workspace_root.mkdir() - workspace_config = workspace_root / "pyproject.toml" + workspace_config = workspace_root / 'pyproject.toml' workspace_config.write_text(""" [project] name = "workspace-root" @@ -113,14 +108,14 @@ def test_workspace_parallel_dependency_resolution(self, temp_dir, hatch): workspace.parallel = true """) - packages_dir = workspace_root / "packages" + packages_dir = workspace_root / 'packages' packages_dir.mkdir() # Create multiple members for i in range(3): - member_dir = packages_dir / f"member{i}" + member_dir = packages_dir / f'member{i}' member_dir.mkdir() - (member_dir / "pyproject.toml").write_text(f""" + (member_dir / 'pyproject.toml').write_text(f""" [project] name = "member{i}" version = "0.1.{i}" @@ -133,10 +128,10 @@ def test_workspace_parallel_dependency_resolution(self, temp_dir, hatch): def test_workspace_member_features(self, temp_dir, hatch): """Test workspace members with specific features.""" - workspace_root = temp_dir / "workspace" + workspace_root = temp_dir / 'workspace' workspace_root.mkdir() - workspace_config = workspace_root / "pyproject.toml" + workspace_config = workspace_root / 'pyproject.toml' workspace_config.write_text(""" [project] name = "workspace-root" @@ -147,12 +142,12 @@ def test_workspace_member_features(self, temp_dir, hatch): ] """) - packages_dir = workspace_root / "packages" + packages_dir = workspace_root / 'packages' packages_dir.mkdir() - member1_dir = packages_dir / "member1" + member1_dir = packages_dir / 'member1' member1_dir.mkdir() - (member1_dir / "pyproject.toml").write_text(""" + (member1_dir / 'pyproject.toml').write_text(""" [project] name = "member1" dependencies = ["requests"] @@ -168,11 +163,11 @@ def test_workspace_member_features(self, temp_dir, hatch): def test_workspace_inheritance_from_root(self, temp_dir, hatch): """Test that workspace members inherit environments from root.""" - workspace_root = temp_dir / "workspace" + workspace_root = temp_dir / 'workspace' workspace_root.mkdir() # Workspace root with shared environment - workspace_config = workspace_root / "pyproject.toml" + workspace_config = workspace_root / 'pyproject.toml' workspace_config.write_text(""" [project] name = "workspace-root" @@ -185,13 +180,13 @@ def test_workspace_inheritance_from_root(self, temp_dir, hatch): scripts.test = "pytest" """) - packages_dir = workspace_root / "packages" + packages_dir = workspace_root / 'packages' packages_dir.mkdir() # Member without local shared environment - member_dir = packages_dir / "member1" + member_dir = packages_dir / 'member1' member_dir.mkdir() - (member_dir / "pyproject.toml").write_text(""" + (member_dir / 'pyproject.toml').write_text(""" [project] name = "member1" version = "0.1.0" @@ -211,10 +206,10 @@ def test_workspace_inheritance_from_root(self, temp_dir, hatch): def test_workspace_no_members_fallback(self, temp_dir, hatch): """Test fallback when no workspace members are defined.""" - workspace_root = temp_dir / "workspace" + workspace_root = temp_dir / 'workspace' workspace_root.mkdir() - workspace_config = workspace_root / "pyproject.toml" + workspace_config = workspace_root / 'pyproject.toml' workspace_config.write_text(""" [project] name = "workspace-root" @@ -232,10 +227,10 @@ def test_workspace_no_members_fallback(self, temp_dir, hatch): def test_workspace_cross_member_dependencies(self, temp_dir, hatch): """Test workspace members depending on each other.""" - workspace_root = temp_dir / "workspace" + workspace_root = temp_dir / 'workspace' workspace_root.mkdir() - workspace_config = workspace_root / "pyproject.toml" + workspace_config = workspace_root / 'pyproject.toml' workspace_config.write_text(""" [project] name = "workspace-root" @@ -244,13 +239,13 @@ def test_workspace_cross_member_dependencies(self, temp_dir, hatch): members = ["packages/*"] """) - packages_dir = workspace_root / "packages" + packages_dir = workspace_root / 'packages' packages_dir.mkdir() # Base library - base_dir = packages_dir / "base" + base_dir = packages_dir / 'base' base_dir.mkdir() - (base_dir / "pyproject.toml").write_text(""" + (base_dir / 'pyproject.toml').write_text(""" [project] name = "base" version = "0.1.0" @@ -258,9 +253,9 @@ def test_workspace_cross_member_dependencies(self, temp_dir, hatch): """) # App depending on base - app_dir = packages_dir / "app" + app_dir = packages_dir / 'app' app_dir.mkdir() - (app_dir / "pyproject.toml").write_text(""" + (app_dir / 'pyproject.toml').write_text(""" [project] name = "app" version = "0.1.0" @@ -277,15 +272,15 @@ def test_workspace_cross_member_dependencies(self, temp_dir, hatch): def test_workspace_build_all_members(self, temp_dir, hatch): """Test building all workspace members.""" - workspace_root = temp_dir / "workspace" + workspace_root = temp_dir / 'workspace' workspace_root.mkdir() # Create workspace root package - workspace_pkg = workspace_root / "workspace_root" + workspace_pkg = workspace_root / 'workspace_root' workspace_pkg.mkdir() - (workspace_pkg / "__init__.py").write_text('__version__ = "0.1.0"') + (workspace_pkg / '__init__.py').write_text('__version__ = "0.1.0"') - workspace_config = workspace_root / "pyproject.toml" + workspace_config = workspace_root / 'pyproject.toml' workspace_config.write_text(""" [project] name = "workspace-root" @@ -302,14 +297,14 @@ def test_workspace_build_all_members(self, temp_dir, hatch): packages = ["workspace_root"] """) - packages_dir = workspace_root / "packages" + packages_dir = workspace_root / 'packages' packages_dir.mkdir() # Create buildable members for i in range(2): - member_dir = packages_dir / f"member{i}" + member_dir = packages_dir / f'member{i}' member_dir.mkdir() - (member_dir / "pyproject.toml").write_text(f""" + (member_dir / 'pyproject.toml').write_text(f""" [project] name = "member{i}" version = "0.1.{i}" @@ -323,11 +318,10 @@ def test_workspace_build_all_members(self, temp_dir, hatch): """) # Create source files - src_dir = member_dir / f"member{i}" + src_dir = member_dir / f'member{i}' src_dir.mkdir() - (src_dir / "__init__.py").write_text(f'__version__ = "0.1.{i}"') + (src_dir / '__init__.py').write_text(f'__version__ = "0.1.{i}"') with workspace_root.as_cwd(): result = hatch('build') assert result.exit_code == 0 - From ba36be5678a9756cb12f67861f2b450b08d27ce3 Mon Sep 17 00:00:00 2001 From: Cary Hawkins Date: Sun, 5 Oct 2025 14:11:37 -0700 Subject: [PATCH 28/55] Clean up merge conflicts with rebase and formatting --- src/hatch/cli/__init__.py | 4 +- src/hatch/dep/core.py | 4 +- src/hatch/dep/sync.py | 40 ++-- src/hatch/env/plugin/interface.py | 97 ++++----- src/hatch/env/virtual.py | 2 +- src/hatch/project/config.py | 14 +- src/hatch/project/constants.py | 16 +- src/hatch/project/core.py | 17 +- src/hatch/utils/fs.py | 6 +- tests/cli/env/test_create.py | 34 ++-- tests/dep/test_sync.py | 50 ++--- tests/env/plugin/test_interface.py | 308 ++++++++++++++--------------- tests/workspaces/configuration.py | 118 +++++------ 13 files changed, 356 insertions(+), 354 deletions(-) diff --git a/src/hatch/cli/__init__.py b/src/hatch/cli/__init__.py index 7a8ce7886..9c09d4ffa 100644 --- a/src/hatch/cli/__init__.py +++ b/src/hatch/cli/__init__.py @@ -33,12 +33,12 @@ def find_workspace_root(path: Path) -> Path | None: """Find workspace root by traversing up from given path.""" current = path while current.parent != current: - pyproject = current / 'pyproject.toml' + pyproject = current / "pyproject.toml" if pyproject.exists(): from hatch.utils.toml import load_toml_file config = load_toml_file(str(pyproject)) - if config.get('tool', {}).get('hatch', {}).get('workspace'): + if config.get("tool", {}).get("hatch", {}).get("workspace"): return current current = current.parent return None diff --git a/src/hatch/dep/core.py b/src/hatch/dep/core.py index c8b93f527..8fe875a7f 100644 --- a/src/hatch/dep/core.py +++ b/src/hatch/dep/core.py @@ -14,7 +14,7 @@ def __init__(self, s: str, *, editable: bool = False) -> None: super().__init__(s) if editable and self.url is None: - message = f'Editable dependency must refer to a local path: {s}' + message = f"Editable dependency must refer to a local path: {s}" raise InvalidDependencyError(message) self.__editable = editable @@ -31,7 +31,7 @@ def path(self) -> Path | None: import hyperlink uri = hyperlink.parse(self.url) - if uri.scheme != 'file': + if uri.scheme != "file": return None return Path.from_uri(self.url) diff --git a/src/hatch/dep/sync.py b/src/hatch/dep/sync.py index cbf44d8a4..57568b1c2 100644 --- a/src/hatch/dep/sync.py +++ b/src/hatch/dep/sync.py @@ -19,7 +19,7 @@ def __init__(self, *, sys_path: list[str] | None = None, environment: dict[str, self.__resolver = Distribution.discover(context=DistributionFinder.Context(path=self.__sys_path)) self.__distributions: dict[str, Distribution] = {} self.__search_exhausted = False - self.__canonical_regex = re.compile(r'[-_.]+') + self.__canonical_regex = re.compile(r"[-_.]+") def dependencies_in_sync(self, dependencies: list[Dependency]) -> bool: return all(self.dependency_in_sync(dependency) for dependency in dependencies) @@ -40,11 +40,11 @@ def dependency_in_sync(self, dependency: Dependency, *, environment: dict[str, s extras = dependency.extras if extras: - transitive_dependencies: list[str] = distribution.metadata.get_all('Requires-Dist', []) + transitive_dependencies: list[str] = distribution.metadata.get_all("Requires-Dist", []) if not transitive_dependencies: return False - available_extras: list[str] = distribution.metadata.get_all('Provides-Extra', []) + available_extras: list[str] = distribution.metadata.get_all("Provides-Extra", []) for dependency_string in transitive_dependencies: transitive_dependency = Dependency(dependency_string) @@ -58,7 +58,7 @@ def dependency_in_sync(self, dependency: Dependency, *, environment: dict[str, s return False extra_environment = dict(environment) - extra_environment['extra'] = extra + extra_environment["extra"] = extra if not self.dependency_in_sync(transitive_dependency, environment=extra_environment): return False @@ -67,7 +67,7 @@ def dependency_in_sync(self, dependency: Dependency, *, environment: dict[str, s # TODO: handle https://discuss.python.org/t/11938 if dependency.url: - direct_url_file = distribution.read_text('direct_url.json') + direct_url_file = distribution.read_text("direct_url.json") if direct_url_file is None: return False @@ -75,29 +75,29 @@ def dependency_in_sync(self, dependency: Dependency, *, environment: dict[str, s # https://packaging.python.org/specifications/direct-url/ direct_url_data = json.loads(direct_url_file) - url = direct_url_data['url'] - if 'dir_info' in direct_url_data: - dir_info = direct_url_data['dir_info'] - editable = dir_info.get('editable', False) + url = direct_url_data["url"] + if "dir_info" in direct_url_data: + dir_info = direct_url_data["dir_info"] + editable = dir_info.get("editable", False) if editable != dependency.editable: return False if Path.from_uri(url) != dependency.path: return False - if 'vcs_info' in direct_url_data: - vcs_info = direct_url_data['vcs_info'] - vcs = vcs_info['vcs'] - commit_id = vcs_info['commit_id'] - requested_revision = vcs_info.get('requested_revision') + if "vcs_info" in direct_url_data: + vcs_info = direct_url_data["vcs_info"] + vcs = vcs_info["vcs"] + commit_id = vcs_info["commit_id"] + requested_revision = vcs_info.get("requested_revision") # Try a few variations, see https://peps.python.org/pep-0440/#direct-references if ( - requested_revision and dependency.url == f'{vcs}+{url}@{requested_revision}#{commit_id}' - ) or dependency.url == f'{vcs}+{url}@{commit_id}': + requested_revision and dependency.url == f"{vcs}+{url}@{requested_revision}#{commit_id}" + ) or dependency.url == f"{vcs}+{url}@{commit_id}": return True - if dependency.url in {f'{vcs}+{url}', f'{vcs}+{url}@{requested_revision}'}: + if dependency.url in {f"{vcs}+{url}", f"{vcs}+{url}@{requested_revision}"}: import subprocess if vcs == "git": @@ -118,7 +118,7 @@ def dependency_in_sync(self, dependency: Dependency, *, environment: dict[str, s return True def __getitem__(self, item: str) -> Distribution | None: - item = self.__canonical_regex.sub('-', item).lower() + item = self.__canonical_regex.sub("-", item).lower() possible_distribution = self.__distributions.get(item) if possible_distribution is not None: return possible_distribution @@ -127,11 +127,11 @@ def __getitem__(self, item: str) -> Distribution | None: return None for distribution in self.__resolver: - name = distribution.metadata['Name'] + name = distribution.metadata["Name"] if name is None: continue - name = self.__canonical_regex.sub('-', name).lower() + name = self.__canonical_regex.sub("-", name).lower() self.__distributions[name] = distribution if name == item: return distribution diff --git a/src/hatch/env/plugin/interface.py b/src/hatch/env/plugin/interface.py index 4446a7d23..c951f8fc6 100644 --- a/src/hatch/env/plugin/interface.py +++ b/src/hatch/env/plugin/interface.py @@ -3,10 +3,11 @@ import os import sys from abc import ABC, abstractmethod +from collections.abc import Generator from contextlib import contextmanager from functools import cached_property from os.path import isabs -from typing import TYPE_CHECKING, Any, Generator +from typing import TYPE_CHECKING, Any from hatch.config.constants import AppEnvVars from hatch.env.utils import add_verbosity_flag, get_env_var_option @@ -30,7 +31,7 @@ class EnvironmentInterface(ABC): class SpecialEnvironment(EnvironmentInterface): - PLUGIN_NAME = 'special' + PLUGIN_NAME = "special" ... ``` @@ -271,7 +272,7 @@ def environment_dependencies_complex(self) -> list[Dependency]: try: dependencies_complex.append(Dependency(self.metadata.context.format(entry))) except InvalidDependencyError as e: - message = f'Dependency #{i} of field `tool.hatch.envs.{self.name}.{option}` is invalid: {e}' + message = f"Dependency #{i} of field `tool.hatch.envs.{self.name}.{option}` is invalid: {e}" raise ValueError(message) from None return dependencies_complex @@ -303,8 +304,8 @@ def project_dependencies_complex(self) -> list[Dependency]: for feature in self.features: if feature not in optional_dependencies_complex: message = ( - f'Feature `{feature}` of field `tool.hatch.envs.{self.name}.features` is not ' - f'defined in the dynamic field `project.optional-dependencies`' + f"Feature `{feature}` of field `tool.hatch.envs.{self.name}.features` is not " + f"defined in the dynamic field `project.optional-dependencies`" ) raise ValueError(message) @@ -332,11 +333,11 @@ def local_dependencies_complex(self) -> list[Dependency]: local_dependencies_complex = [] if not self.skip_install: local_dependencies_complex.append( - Dependency(f'{self.metadata.name} @ {self.root.as_uri()}', editable=self.dev_mode) + Dependency(f"{self.metadata.name} @ {self.root.as_uri()}", editable=self.dev_mode) ) local_dependencies_complex.extend( - Dependency(f'{member.project.metadata.name} @ {member.project.location.as_uri()}', editable=self.dev_mode) + Dependency(f"{member.project.metadata.name} @ {member.project.location.as_uri()}", editable=self.dev_mode) for member in self.workspace.members ) @@ -365,7 +366,7 @@ def dependencies_complex(self) -> list[Dependency]: else: all_dependencies_complex.append(Dependency(str(req))) - for target in os.environ.get(BuildEnvVars.REQUESTED_TARGETS, '').split(): + for target in os.environ.get(BuildEnvVars.REQUESTED_TARGETS, "").split(): target_config = self.app.project.config.build.target(target) all_dependencies_complex.extend(map(Dependency, target_config.dependencies)) @@ -594,9 +595,9 @@ def post_install_commands(self): @cached_property def workspace(self) -> Workspace: - config = self.config.get('workspace', {}) + config = self.config.get("workspace", {}) if not isinstance(config, dict): - message = f'Field `tool.hatch.envs.{self.name}.workspace` must be a table' + message = f"Field `tool.hatch.envs.{self.name}.workspace` must be a table" raise TypeError(message) return Workspace(self, config) @@ -1002,9 +1003,9 @@ def __init__(self, env: EnvironmentInterface, config: dict[str, Any]): @cached_property def parallel(self) -> bool: - parallel = self.config.get('parallel', True) + parallel = self.config.get("parallel", True) if not isinstance(parallel, bool): - message = f'Field `tool.hatch.envs.{self.env.name}.workspace.parallel` must be a boolean' + message = f"Field `tool.hatch.envs.{self.env.name}.workspace.parallel` must be a boolean" raise TypeError(message) return parallel @@ -1029,7 +1030,7 @@ def get_dependencies(self) -> list[str]: from concurrent.futures import ThreadPoolExecutor def get_member_deps(member): - with self.env.app.status(f'Checking workspace member: {member.name}'): + with self.env.app.status(f"Checking workspace member: {member.name}"): dependencies, features = member.get_dependencies() deps = list(dependencies) for feature in member.features: @@ -1042,7 +1043,7 @@ def get_member_deps(member): all_dependencies.extend(deps) else: for member in dynamic_members: - with self.env.app.status(f'Checking workspace member: {member.name}'): + with self.env.app.status(f"Checking workspace member: {member.name}"): dependencies, features = member.get_dependencies() all_dependencies.extend(dependencies) for feature in member.features: @@ -1056,44 +1057,44 @@ def members(self) -> list[WorkspaceMember]: from hatch.utils.fs import Path from hatchling.metadata.utils import normalize_project_name - raw_members = self.config.get('members', []) + raw_members = self.config.get("members", []) if not isinstance(raw_members, list): - message = f'Field `tool.hatch.envs.{self.env.name}.workspace.members` must be an array' + message = f"Field `tool.hatch.envs.{self.env.name}.workspace.members` must be an array" raise TypeError(message) # First normalize configuration member_data: list[dict[str, Any]] = [] for i, data in enumerate(raw_members, 1): if isinstance(data, str): - member_data.append({'path': data, 'features': ()}) + member_data.append({"path": data, "features": ()}) elif isinstance(data, dict): - if 'path' not in data: + if "path" not in data: message = ( - f'Member #{i} of field `tool.hatch.envs.{self.env.name}.workspace.members` must define ' - f'a `path` key' + f"Member #{i} of field `tool.hatch.envs.{self.env.name}.workspace.members` must define " + f"a `path` key" ) raise TypeError(message) - path = data['path'] + path = data["path"] if not isinstance(path, str): message = ( - f'Option `path` of member #{i} of field `tool.hatch.envs.{self.env.name}.workspace.members` ' - f'must be a string' + f"Option `path` of member #{i} of field `tool.hatch.envs.{self.env.name}.workspace.members` " + f"must be a string" ) raise TypeError(message) if not path: message = ( - f'Option `path` of member #{i} of field `tool.hatch.envs.{self.env.name}.workspace.members` ' - f'cannot be an empty string' + f"Option `path` of member #{i} of field `tool.hatch.envs.{self.env.name}.workspace.members` " + f"cannot be an empty string" ) raise ValueError(message) - features = data.get('features', []) + features = data.get("features", []) if not isinstance(features, list): message = ( - f'Option `features` of member #{i} of field `tool.hatch.envs.{self.env.name}.workspace.' - f'members` must be an array of strings' + f"Option `features` of member #{i} of field `tool.hatch.envs.{self.env.name}.workspace." + f"members` must be an array of strings" ) raise TypeError(message) @@ -1101,33 +1102,33 @@ def members(self) -> list[WorkspaceMember]: for j, feature in enumerate(features, 1): if not isinstance(feature, str): message = ( - f'Feature #{j} of option `features` of member #{i} of field ' - f'`tool.hatch.envs.{self.env.name}.workspace.members` must be a string' + f"Feature #{j} of option `features` of member #{i} of field " + f"`tool.hatch.envs.{self.env.name}.workspace.members` must be a string" ) raise TypeError(message) if not feature: message = ( - f'Feature #{j} of option `features` of member #{i} of field ' - f'`tool.hatch.envs.{self.env.name}.workspace.members` cannot be an empty string' + f"Feature #{j} of option `features` of member #{i} of field " + f"`tool.hatch.envs.{self.env.name}.workspace.members` cannot be an empty string" ) raise ValueError(message) normalized_feature = normalize_project_name(feature) if normalized_feature in all_features: message = ( - f'Feature #{j} of option `features` of member #{i} of field ' - f'`tool.hatch.envs.{self.env.name}.workspace.members` is a duplicate' + f"Feature #{j} of option `features` of member #{i} of field " + f"`tool.hatch.envs.{self.env.name}.workspace.members` is a duplicate" ) raise ValueError(message) all_features.add(normalized_feature) - member_data.append({'path': path, 'features': tuple(sorted(all_features))}) + member_data.append({"path": path, "features": tuple(sorted(all_features))}) else: message = ( - f'Member #{i} of field `tool.hatch.envs.{self.env.name}.workspace.members` must be ' - f'a string or an inline table' + f"Member #{i} of field `tool.hatch.envs.{self.env.name}.workspace.members` must be " + f"a string or an inline table" ) raise TypeError(message) @@ -1150,7 +1151,7 @@ def members(self) -> list[WorkspaceMember]: # AP = /foo/bar/dir/pkg-* # SP = /foo/bar # RP = dir/pkg-* - path_spec = data['path'] + path_spec = data["path"] normalized_path = os.path.normpath(os.path.join(root, path_spec)) absolute_path = os.path.abspath(normalized_path) shared_prefix = os.path.commonprefix([root, absolute_path]) @@ -1159,31 +1160,31 @@ def members(self) -> list[WorkspaceMember]: # Now we have the necessary information to perform an optimized glob search for members members_found = False for member_path in find_members(root, relative_path.split(os.sep)): - project_file = os.path.join(member_path, 'pyproject.toml') + project_file = os.path.join(member_path, "pyproject.toml") if not os.path.isfile(project_file): message = ( - f'Member derived from `{path_spec}` of field ' - f'`tool.hatch.envs.{self.env.name}.workspace.members` is not a project (no `pyproject.toml` ' - f'file): {member_path}' + f"Member derived from `{path_spec}` of field " + f"`tool.hatch.envs.{self.env.name}.workspace.members` is not a project (no `pyproject.toml` " + f"file): {member_path}" ) raise OSError(message) members_found = True if member_path in member_paths: message = ( - f'Member derived from `{path_spec}` of field ' - f'`tool.hatch.envs.{self.env.name}.workspace.members` is a duplicate: {member_path}' + f"Member derived from `{path_spec}` of field " + f"`tool.hatch.envs.{self.env.name}.workspace.members` is a duplicate: {member_path}" ) raise ValueError(message) project = Project(Path(member_path), locate=False) project.set_app(self.env.app) - member_paths[member_path] = WorkspaceMember(project, features=data['features']) + member_paths[member_path] = WorkspaceMember(project, features=data["features"]) if not members_found: message = ( - f'No members could be derived from `{path_spec}` of field ' - f'`tool.hatch.envs.{self.env.name}.workspace.members`: {absolute_path}' + f"No members could be derived from `{path_spec}` of field " + f"`tool.hatch.envs.{self.env.name}.workspace.members`: {absolute_path}" ) raise OSError(message) @@ -1249,7 +1250,7 @@ def find_members(root, relative_components): component_matchers = [] for component in relative_components: - if any(special in component for special in '*?['): + if any(special in component for special in "*?["): pattern = re.compile(fnmatch.translate(component)) component_matchers.append(lambda entry, pattern=pattern: pattern.search(entry.name)) else: diff --git a/src/hatch/env/virtual.py b/src/hatch/env/virtual.py index dfa528a2d..faaeedf9c 100644 --- a/src/hatch/env/virtual.py +++ b/src/hatch/env/virtual.py @@ -212,7 +212,7 @@ def sync_dependencies(self): if editable_dependencies: editable_args = [] for dependency in editable_dependencies: - editable_args.extend(['--editable', dependency]) + editable_args.extend(["--editable", dependency]) self.platform.check_command(self.construct_pip_install_command(editable_args)) @contextmanager diff --git a/src/hatch/project/config.py b/src/hatch/project/config.py index 2a87c6b2a..e9555fe40 100644 --- a/src/hatch/project/config.py +++ b/src/hatch/project/config.py @@ -77,7 +77,7 @@ def env_requires_complex(self) -> list[Dependency]: try: requires_complex.append(Dependency(entry)) except InvalidDependencyError as e: - message = f'Requirement #{i} in `tool.hatch.env.requires` is invalid: {e}' + message = f"Requirement #{i} in `tool.hatch.env.requires` is invalid: {e}" raise ValueError(message) from None self._env_requires_complex = requires_complex @@ -504,9 +504,9 @@ def scripts(self): @cached_property def workspace(self): - config = self.config.get('workspace', {}) + config = self.config.get("workspace", {}) if not isinstance(config, dict): - message = 'Field `tool.hatch.workspace` must be a table' + message = "Field `tool.hatch.workspace` must be a table" raise TypeError(message) return WorkspaceConfig(config, self.root) @@ -745,17 +745,17 @@ def __init__(self, config: dict[str, Any], root: Path): @cached_property def members(self) -> list[str]: - members = self.__config.get('members', []) + members = self.__config.get("members", []) if not isinstance(members, list): - message = 'Field `tool.hatch.workspace.members` must be an array' + message = "Field `tool.hatch.workspace.members` must be an array" raise TypeError(message) return members @cached_property def exclude(self) -> list[str]: - exclude = self.__config.get('exclude', []) + exclude = self.__config.get("exclude", []) if not isinstance(exclude, list): - message = 'Field `tool.hatch.workspace.exclude` must be an array' + message = "Field `tool.hatch.workspace.exclude` must be an array" raise TypeError(message) return exclude diff --git a/src/hatch/project/constants.py b/src/hatch/project/constants.py index a5e4f7a95..965005a69 100644 --- a/src/hatch/project/constants.py +++ b/src/hatch/project/constants.py @@ -5,11 +5,11 @@ class BuildEnvVars: - REQUESTED_TARGETS = 'HATCH_BUILD_REQUESTED_TARGETS' - LOCATION = 'HATCH_BUILD_LOCATION' - HOOKS_ONLY = 'HATCH_BUILD_HOOKS_ONLY' - NO_HOOKS = 'HATCH_BUILD_NO_HOOKS' - HOOKS_ENABLE = 'HATCH_BUILD_HOOKS_ENABLE' - HOOK_ENABLE_PREFIX = 'HATCH_BUILD_HOOK_ENABLE_' - CLEAN = 'HATCH_BUILD_CLEAN' - CLEAN_HOOKS_AFTER = 'HATCH_BUILD_CLEAN_HOOKS_AFTER' + REQUESTED_TARGETS = "HATCH_BUILD_REQUESTED_TARGETS" + LOCATION = "HATCH_BUILD_LOCATION" + HOOKS_ONLY = "HATCH_BUILD_HOOKS_ONLY" + NO_HOOKS = "HATCH_BUILD_NO_HOOKS" + HOOKS_ENABLE = "HATCH_BUILD_HOOKS_ENABLE" + HOOK_ENABLE_PREFIX = "HATCH_BUILD_HOOK_ENABLE_" + CLEAN = "HATCH_BUILD_CLEAN" + CLEAN_HOOKS_AFTER = "HATCH_BUILD_CLEAN_HOOKS_AFTER" diff --git a/src/hatch/project/core.py b/src/hatch/project/core.py index 912c4d6e7..367398f6f 100644 --- a/src/hatch/project/core.py +++ b/src/hatch/project/core.py @@ -1,9 +1,10 @@ from __future__ import annotations import re +from collections.abc import Generator from contextlib import contextmanager from functools import cached_property -from typing import TYPE_CHECKING, Generator, cast +from typing import TYPE_CHECKING, cast from hatch.project.env import EnvironmentMetadata from hatch.utils.fs import Path @@ -207,7 +208,7 @@ def prepare_build_environment(self, *, targets: list[str] | None = None) -> None if targets is None: targets = ["wheel"] - env_vars = {BuildEnvVars.REQUESTED_TARGETS: ' '.join(sorted(targets))} + env_vars = {BuildEnvVars.REQUESTED_TARGETS: " ".join(sorted(targets))} build_backend = self.metadata.build.build_backend with self.location.as_cwd(), self.build_env.get_env_vars(), EnvVars(env_vars): if not self.build_env.exists(): @@ -219,13 +220,13 @@ def prepare_build_environment(self, *, targets: list[str] | None = None) -> None self.prepare_environment(self.build_env) additional_dependencies: list[str] = [] - with self.app.status('Inspecting build dependencies'): + with self.app.status("Inspecting build dependencies"): if build_backend != BUILD_BACKEND: for target in targets: - if target == 'sdist': - additional_dependencies.extend(self.build_frontend.get_requires('sdist')) - elif target == 'wheel': - additional_dependencies.extend(self.build_frontend.get_requires('wheel')) + if target == "sdist": + additional_dependencies.extend(self.build_frontend.get_requires("sdist")) + elif target == "wheel": + additional_dependencies.extend(self.build_frontend.get_requires("wheel")) else: self.app.abort(f"Target `{target}` is not supported by `{build_backend}`") else: @@ -267,7 +268,7 @@ def get_dependencies(self) -> tuple[list[str], dict[str, list[str]]]: @cached_property def has_static_dependencies(self) -> bool: - dynamic_fields = {'dependencies', 'optional-dependencies'} + dynamic_fields = {"dependencies", "optional-dependencies"} return not dynamic_fields.intersection(self.metadata.dynamic) def expand_environments(self, env_name: str) -> list[str]: diff --git a/src/hatch/utils/fs.py b/src/hatch/utils/fs.py index a5c127613..c3840bbdc 100644 --- a/src/hatch/utils/fs.py +++ b/src/hatch/utils/fs.py @@ -131,17 +131,17 @@ def temp_hide(self) -> Generator[Path, None, None]: with suppress(FileNotFoundError): shutil.move(str(temp_path), self) - if sys.platform == 'win32': + if sys.platform == "win32": @classmethod def from_uri(cls, path: str) -> Path: - return cls(path.replace('file:///', '', 1)) + return cls(path.replace("file:///", "", 1)) else: @classmethod def from_uri(cls, path: str) -> Path: - return cls(path.replace('file://', '', 1)) + return cls(path.replace("file://", "", 1)) if sys.version_info[:2] < (3, 10): diff --git a/tests/cli/env/test_create.py b/tests/cli/env/test_create.py index 39d141576..b753e1e51 100644 --- a/tests/cli/env/test_create.py +++ b/tests/cli/env/test_create.py @@ -1963,34 +1963,34 @@ def test_no_compatible_python_ok_if_not_installed(hatch, helpers, temp_dir, conf @pytest.mark.requires_internet def test_workspace(hatch, helpers, temp_dir, platform, uv_on_path, extract_installed_requirements): - project_name = 'My.App' + project_name = "My.App" with temp_dir.as_cwd(): - result = hatch('new', project_name) + result = hatch("new", project_name) assert result.exit_code == 0, result.output - project_path = temp_dir / 'my-app' - data_path = temp_dir / 'data' + project_path = temp_dir / "my-app" + data_path = temp_dir / "data" data_path.mkdir() - members = ['foo', 'bar', 'baz'] + members = ["foo", "bar", "baz"] for member in members: with project_path.as_cwd(): - result = hatch('new', member) + result = hatch("new", member) assert result.exit_code == 0, result.output project = Project(project_path) helpers.update_project_environment( project, - 'default', + "default", { - 'workspace': {'members': [{'path': member} for member in members]}, - **project.config.envs['default'], + "workspace": {"members": [{"path": member} for member in members]}, + **project.config.envs["default"], }, ) with project_path.as_cwd(env_vars={ConfigEnvVars.DATA: str(data_path)}): - result = hatch('env', 'create') + result = hatch("env", "create") assert result.exit_code == 0, result.output assert result.output == helpers.dedent( @@ -2002,7 +2002,7 @@ def test_workspace(hatch, helpers, temp_dir, platform, uv_on_path, extract_insta """ ) - env_data_path = data_path / 'env' / 'virtual' + env_data_path = data_path / "env" / "virtual" assert env_data_path.is_dir() project_data_path = env_data_path / project_path.name @@ -2022,13 +2022,13 @@ def test_workspace(hatch, helpers, temp_dir, platform, uv_on_path, extract_insta assert env_path.name == project_path.name with UVVirtualEnv(env_path, platform): - output = platform.run_command([uv_on_path, 'pip', 'freeze'], check=True, capture_output=True).stdout.decode( - 'utf-8' + output = platform.run_command([uv_on_path, "pip", "freeze"], check=True, capture_output=True).stdout.decode( + "utf-8" ) requirements = extract_installed_requirements(output.splitlines()) assert len(requirements) == 4 - assert requirements[0].lower() == f'-e {project_path.as_uri().lower()}/bar' - assert requirements[1].lower() == f'-e {project_path.as_uri().lower()}/baz' - assert requirements[2].lower() == f'-e {project_path.as_uri().lower()}/foo' - assert requirements[3].lower() == f'-e {project_path.as_uri().lower()}' + assert requirements[0].lower() == f"-e {project_path.as_uri().lower()}/bar" + assert requirements[1].lower() == f"-e {project_path.as_uri().lower()}/baz" + assert requirements[2].lower() == f"-e {project_path.as_uri().lower()}/foo" + assert requirements[3].lower() == f"-e {project_path.as_uri().lower()}" diff --git a/tests/dep/test_sync.py b/tests/dep/test_sync.py index 90fb4c5af..807f1ca6e 100644 --- a/tests/dep/test_sync.py +++ b/tests/dep/test_sync.py @@ -17,23 +17,23 @@ def test_no_dependencies(platform): def test_dependency_not_found(platform): with TempUVVirtualEnv(sys.executable, platform) as venv: distributions = InstalledDistributions(sys_path=venv.sys_path) - assert not distributions.dependencies_in_sync([Dependency('binary')]) + assert not distributions.dependencies_in_sync([Dependency("binary")]) @pytest.mark.requires_internet def test_dependency_found(platform, uv_on_path): with TempUVVirtualEnv(sys.executable, platform) as venv: - platform.run_command([uv_on_path, 'pip', 'install', 'binary'], check=True, capture_output=True) + platform.run_command([uv_on_path, "pip", "install", "binary"], check=True, capture_output=True) distributions = InstalledDistributions(sys_path=venv.sys_path) - assert distributions.dependencies_in_sync([Dependency('binary')]) + assert distributions.dependencies_in_sync([Dependency("binary")]) @pytest.mark.requires_internet def test_version_unmet(platform, uv_on_path): with TempUVVirtualEnv(sys.executable, platform) as venv: - platform.run_command([uv_on_path, 'pip', 'install', 'binary'], check=True, capture_output=True) + platform.run_command([uv_on_path, "pip", "install", "binary"], check=True, capture_output=True) distributions = InstalledDistributions(sys_path=venv.sys_path) - assert not distributions.dependencies_in_sync([Dependency('binary>9000')]) + assert not distributions.dependencies_in_sync([Dependency("binary>9000")]) def test_marker_met(platform): @@ -51,9 +51,9 @@ def test_marker_unmet(platform): @pytest.mark.requires_internet def test_extra_no_dependencies(platform, uv_on_path): with TempUVVirtualEnv(sys.executable, platform) as venv: - platform.run_command([uv_on_path, 'pip', 'install', 'binary'], check=True, capture_output=True) + platform.run_command([uv_on_path, "pip", "install", "binary"], check=True, capture_output=True) distributions = InstalledDistributions(sys_path=venv.sys_path) - assert not distributions.dependencies_in_sync([Dependency('binary[foo]')]) + assert not distributions.dependencies_in_sync([Dependency("binary[foo]")]) @pytest.mark.requires_internet @@ -63,15 +63,15 @@ def test_unknown_extra(platform, uv_on_path): [uv_on_path, "pip", "install", "requests[security]==2.25.1"], check=True, capture_output=True ) distributions = InstalledDistributions(sys_path=venv.sys_path) - assert not distributions.dependencies_in_sync([Dependency('requests[foo]')]) + assert not distributions.dependencies_in_sync([Dependency("requests[foo]")]) @pytest.mark.requires_internet def test_extra_unmet(platform, uv_on_path): with TempUVVirtualEnv(sys.executable, platform) as venv: - platform.run_command([uv_on_path, 'pip', 'install', 'requests==2.25.1'], check=True, capture_output=True) + platform.run_command([uv_on_path, "pip", "install", "requests==2.25.1"], check=True, capture_output=True) distributions = InstalledDistributions(sys_path=venv.sys_path) - assert not distributions.dependencies_in_sync([Dependency('requests[security]==2.25.1')]) + assert not distributions.dependencies_in_sync([Dependency("requests[security]==2.25.1")]) @pytest.mark.requires_internet @@ -81,7 +81,7 @@ def test_extra_met(platform, uv_on_path): [uv_on_path, "pip", "install", "requests[security]==2.25.1"], check=True, capture_output=True ) distributions = InstalledDistributions(sys_path=venv.sys_path) - assert distributions.dependencies_in_sync([Dependency('requests[security]==2.25.1')]) + assert distributions.dependencies_in_sync([Dependency("requests[security]==2.25.1")]) @pytest.mark.requires_internet @@ -89,13 +89,13 @@ def test_local_dir(hatch, temp_dir, platform, uv_on_path): project_name = os.urandom(10).hex() with temp_dir.as_cwd(): - result = hatch('new', project_name) + result = hatch("new", project_name) assert result.exit_code == 0, result.output project_path = temp_dir / project_name - dependency_string = f'{project_name}@{project_path.as_uri()}' + dependency_string = f"{project_name}@{project_path.as_uri()}" with TempUVVirtualEnv(sys.executable, platform) as venv: - platform.run_command([uv_on_path, 'pip', 'install', str(project_path)], check=True, capture_output=True) + platform.run_command([uv_on_path, "pip", "install", str(project_path)], check=True, capture_output=True) distributions = InstalledDistributions(sys_path=venv.sys_path) assert distributions.dependencies_in_sync([Dependency(dependency_string)]) @@ -105,13 +105,13 @@ def test_local_dir_editable(hatch, temp_dir, platform, uv_on_path): project_name = os.urandom(10).hex() with temp_dir.as_cwd(): - result = hatch('new', project_name) + result = hatch("new", project_name) assert result.exit_code == 0, result.output project_path = temp_dir / project_name - dependency_string = f'{project_name}@{project_path.as_uri()}' + dependency_string = f"{project_name}@{project_path.as_uri()}" with TempUVVirtualEnv(sys.executable, platform) as venv: - platform.run_command([uv_on_path, 'pip', 'install', '-e', str(project_path)], check=True, capture_output=True) + platform.run_command([uv_on_path, "pip", "install", "-e", str(project_path)], check=True, capture_output=True) distributions = InstalledDistributions(sys_path=venv.sys_path) assert distributions.dependencies_in_sync([Dependency(dependency_string, editable=True)]) @@ -121,13 +121,13 @@ def test_local_dir_editable_mismatch(hatch, temp_dir, platform, uv_on_path): project_name = os.urandom(10).hex() with temp_dir.as_cwd(): - result = hatch('new', project_name) + result = hatch("new", project_name) assert result.exit_code == 0, result.output project_path = temp_dir / project_name - dependency_string = f'{project_name}@{project_path.as_uri()}' + dependency_string = f"{project_name}@{project_path.as_uri()}" with TempUVVirtualEnv(sys.executable, platform) as venv: - platform.run_command([uv_on_path, 'pip', 'install', '-e', str(project_path)], check=True, capture_output=True) + platform.run_command([uv_on_path, "pip", "install", "-e", str(project_path)], check=True, capture_output=True) distributions = InstalledDistributions(sys_path=venv.sys_path) assert not distributions.dependencies_in_sync([Dependency(dependency_string)]) @@ -140,7 +140,7 @@ def test_dependency_git_pip(platform): ["pip", "install", "requests@git+https://github.com/psf/requests"], check=True, capture_output=True ) distributions = InstalledDistributions(sys_path=venv.sys_path) - assert distributions.dependencies_in_sync([Dependency('requests@git+https://github.com/psf/requests')]) + assert distributions.dependencies_in_sync([Dependency("requests@git+https://github.com/psf/requests")]) @pytest.mark.requires_internet @@ -153,7 +153,7 @@ def test_dependency_git_uv(platform, uv_on_path): capture_output=True, ) distributions = InstalledDistributions(sys_path=venv.sys_path) - assert distributions.dependencies_in_sync([Dependency('requests@git+https://github.com/psf/requests')]) + assert distributions.dependencies_in_sync([Dependency("requests@git+https://github.com/psf/requests")]) @pytest.mark.requires_internet @@ -164,7 +164,7 @@ def test_dependency_git_revision_pip(platform): ["pip", "install", "requests@git+https://github.com/psf/requests@main"], check=True, capture_output=True ) distributions = InstalledDistributions(sys_path=venv.sys_path) - assert distributions.dependencies_in_sync([Dependency('requests@git+https://github.com/psf/requests@main')]) + assert distributions.dependencies_in_sync([Dependency("requests@git+https://github.com/psf/requests@main")]) @pytest.mark.requires_internet @@ -177,7 +177,7 @@ def test_dependency_git_revision_uv(platform, uv_on_path): capture_output=True, ) distributions = InstalledDistributions(sys_path=venv.sys_path) - assert distributions.dependencies_in_sync([Dependency('requests@git+https://github.com/psf/requests@main')]) + assert distributions.dependencies_in_sync([Dependency("requests@git+https://github.com/psf/requests@main")]) @pytest.mark.requires_internet @@ -196,5 +196,5 @@ def test_dependency_git_commit(platform, uv_on_path): ) distributions = InstalledDistributions(sys_path=venv.sys_path) assert distributions.dependencies_in_sync([ - Dependency('requests@git+https://github.com/psf/requests@7f694b79e114c06fac5ec06019cada5a61e5570f') + Dependency("requests@git+https://github.com/psf/requests@7f694b79e114c06fac5ec06019cada5a61e5570f") ]) diff --git a/tests/env/plugin/test_interface.py b/tests/env/plugin/test_interface.py index cec5f20c6..3763b0b50 100644 --- a/tests/env/plugin/test_interface.py +++ b/tests/env/plugin/test_interface.py @@ -1161,7 +1161,7 @@ def test_builder(self, isolation, isolated_data_dir, platform, global_applicatio def test_workspace(self, temp_dir, isolated_data_dir, platform, temp_application): for i in range(3): - project_file = temp_dir / f'foo{i}' / 'pyproject.toml' + project_file = temp_dir / f"foo{i}" / "pyproject.toml" project_file.parent.mkdir() project_file.write_text( f"""\ @@ -1182,19 +1182,19 @@ def test_workspace(self, temp_dir, isolated_data_dir, platform, temp_application ) config = { - 'project': {'name': 'my_app', 'version': '0.0.1', 'dependencies': ['dep1']}, - 'tool': { - 'hatch': { - 'envs': { - 'default': { - 'skip-install': False, - 'dependencies': ['dep2'], - 'extra-dependencies': ['dep3'], - 'workspace': { - 'members': [ - {'path': 'foo0', 'features': ['feature1']}, - {'path': 'foo1', 'features': ['feature1', 'feature2']}, - {'path': 'foo2', 'features': ['feature1', 'feature2', 'feature3']}, + "project": {"name": "my_app", "version": "0.0.1", "dependencies": ["dep1"]}, + "tool": { + "hatch": { + "envs": { + "default": { + "skip-install": False, + "dependencies": ["dep2"], + "extra-dependencies": ["dep3"], + "workspace": { + "members": [ + {"path": "foo0", "features": ["feature1"]}, + {"path": "foo1", "features": ["feature1", "feature2"]}, + {"path": "foo2", "features": ["feature1", "feature2", "feature3"]}, ], }, }, @@ -1208,8 +1208,8 @@ def test_workspace(self, temp_dir, isolated_data_dir, platform, temp_application environment = MockEnvironment( temp_dir, project.metadata, - 'default', - project.config.envs['default'], + "default", + project.config.envs["default"], {}, isolated_data_dir, isolated_data_dir, @@ -1219,18 +1219,18 @@ def test_workspace(self, temp_dir, isolated_data_dir, platform, temp_application ) assert environment.dependencies == [ - 'dep2', - 'dep3', - 'pkg-0', - 'pkg-feature-10', - 'pkg-1', - 'pkg-feature-11', - 'pkg-feature-21', - 'pkg-2', - 'pkg-feature-12', - 'pkg-feature-22', - 'pkg-feature-32', - 'dep1', + "dep2", + "dep3", + "pkg-0", + "pkg-feature-10", + "pkg-1", + "pkg-feature-11", + "pkg-feature-21", + "pkg-2", + "pkg-feature-12", + "pkg-feature-22", + "pkg-feature-32", + "dep1", ] @@ -2152,15 +2152,15 @@ def test_env_vars_override(self, isolation, isolated_data_dir, platform, global_ class TestWorkspaceConfig: def test_not_table(self, isolation, isolated_data_dir, platform, global_application): config = { - 'project': {'name': 'my_app', 'version': '0.0.1'}, - 'tool': {'hatch': {'envs': {'default': {'workspace': 9000}}}}, + "project": {"name": "my_app", "version": "0.0.1"}, + "tool": {"hatch": {"envs": {"default": {"workspace": 9000}}}}, } project = Project(isolation, config=config) environment = MockEnvironment( isolation, project.metadata, - 'default', - project.config.envs['default'], + "default", + project.config.envs["default"], {}, isolated_data_dir, isolated_data_dir, @@ -2169,20 +2169,20 @@ def test_not_table(self, isolation, isolated_data_dir, platform, global_applicat global_application, ) - with pytest.raises(TypeError, match='Field `tool.hatch.envs.default.workspace` must be a table'): + with pytest.raises(TypeError, match="Field `tool.hatch.envs.default.workspace` must be a table"): _ = environment.workspace def test_parallel_not_boolean(self, isolation, isolated_data_dir, platform, global_application): config = { - 'project': {'name': 'my_app', 'version': '0.0.1'}, - 'tool': {'hatch': {'envs': {'default': {'workspace': {'parallel': 9000}}}}}, + "project": {"name": "my_app", "version": "0.0.1"}, + "tool": {"hatch": {"envs": {"default": {"workspace": {"parallel": 9000}}}}}, } project = Project(isolation, config=config) environment = MockEnvironment( isolation, project.metadata, - 'default', - project.config.envs['default'], + "default", + project.config.envs["default"], {}, isolated_data_dir, isolated_data_dir, @@ -2191,17 +2191,17 @@ def test_parallel_not_boolean(self, isolation, isolated_data_dir, platform, glob global_application, ) - with pytest.raises(TypeError, match='Field `tool.hatch.envs.default.workspace.parallel` must be a boolean'): + with pytest.raises(TypeError, match="Field `tool.hatch.envs.default.workspace.parallel` must be a boolean"): _ = environment.workspace.parallel def test_parallel_default(self, isolation, isolated_data_dir, platform, global_application): - config = {'project': {'name': 'my_app', 'version': '0.0.1'}} + config = {"project": {"name": "my_app", "version": "0.0.1"}} project = Project(isolation, config=config) environment = MockEnvironment( isolation, project.metadata, - 'default', - project.config.envs['default'], + "default", + project.config.envs["default"], {}, isolated_data_dir, isolated_data_dir, @@ -2214,15 +2214,15 @@ def test_parallel_default(self, isolation, isolated_data_dir, platform, global_a def test_parallel_override(self, isolation, isolated_data_dir, platform, global_application): config = { - 'project': {'name': 'my_app', 'version': '0.0.1'}, - 'tool': {'hatch': {'envs': {'default': {'workspace': {'parallel': False}}}}}, + "project": {"name": "my_app", "version": "0.0.1"}, + "tool": {"hatch": {"envs": {"default": {"workspace": {"parallel": False}}}}}, } project = Project(isolation, config=config) environment = MockEnvironment( isolation, project.metadata, - 'default', - project.config.envs['default'], + "default", + project.config.envs["default"], {}, isolated_data_dir, isolated_data_dir, @@ -2235,15 +2235,15 @@ def test_parallel_override(self, isolation, isolated_data_dir, platform, global_ def test_members_not_table(self, isolation, isolated_data_dir, platform, global_application): config = { - 'project': {'name': 'my_app', 'version': '0.0.1'}, - 'tool': {'hatch': {'envs': {'default': {'workspace': {'members': 9000}}}}}, + "project": {"name": "my_app", "version": "0.0.1"}, + "tool": {"hatch": {"envs": {"default": {"workspace": {"members": 9000}}}}}, } project = Project(isolation, config=config) environment = MockEnvironment( isolation, project.metadata, - 'default', - project.config.envs['default'], + "default", + project.config.envs["default"], {}, isolated_data_dir, isolated_data_dir, @@ -2252,20 +2252,20 @@ def test_members_not_table(self, isolation, isolated_data_dir, platform, global_ global_application, ) - with pytest.raises(TypeError, match='Field `tool.hatch.envs.default.workspace.members` must be an array'): + with pytest.raises(TypeError, match="Field `tool.hatch.envs.default.workspace.members` must be an array"): _ = environment.workspace.members def test_member_invalid_type(self, isolation, isolated_data_dir, platform, global_application): config = { - 'project': {'name': 'my_app', 'version': '0.0.1'}, - 'tool': {'hatch': {'envs': {'default': {'workspace': {'members': [9000]}}}}}, + "project": {"name": "my_app", "version": "0.0.1"}, + "tool": {"hatch": {"envs": {"default": {"workspace": {"members": [9000]}}}}}, } project = Project(isolation, config=config) environment = MockEnvironment( isolation, project.metadata, - 'default', - project.config.envs['default'], + "default", + project.config.envs["default"], {}, isolated_data_dir, isolated_data_dir, @@ -2276,21 +2276,21 @@ def test_member_invalid_type(self, isolation, isolated_data_dir, platform, globa with pytest.raises( TypeError, - match='Member #1 of field `tool.hatch.envs.default.workspace.members` must be a string or an inline table', + match="Member #1 of field `tool.hatch.envs.default.workspace.members` must be a string or an inline table", ): _ = environment.workspace.members def test_member_no_path(self, isolation, isolated_data_dir, platform, global_application): config = { - 'project': {'name': 'my_app', 'version': '0.0.1'}, - 'tool': {'hatch': {'envs': {'default': {'workspace': {'members': [{}]}}}}}, + "project": {"name": "my_app", "version": "0.0.1"}, + "tool": {"hatch": {"envs": {"default": {"workspace": {"members": [{}]}}}}}, } project = Project(isolation, config=config) environment = MockEnvironment( isolation, project.metadata, - 'default', - project.config.envs['default'], + "default", + project.config.envs["default"], {}, isolated_data_dir, isolated_data_dir, @@ -2301,21 +2301,21 @@ def test_member_no_path(self, isolation, isolated_data_dir, platform, global_app with pytest.raises( TypeError, - match='Member #1 of field `tool.hatch.envs.default.workspace.members` must define a `path` key', + match="Member #1 of field `tool.hatch.envs.default.workspace.members` must define a `path` key", ): _ = environment.workspace.members def test_member_path_not_string(self, isolation, isolated_data_dir, platform, global_application): config = { - 'project': {'name': 'my_app', 'version': '0.0.1'}, - 'tool': {'hatch': {'envs': {'default': {'workspace': {'members': [{'path': 9000}]}}}}}, + "project": {"name": "my_app", "version": "0.0.1"}, + "tool": {"hatch": {"envs": {"default": {"workspace": {"members": [{"path": 9000}]}}}}}, } project = Project(isolation, config=config) environment = MockEnvironment( isolation, project.metadata, - 'default', - project.config.envs['default'], + "default", + project.config.envs["default"], {}, isolated_data_dir, isolated_data_dir, @@ -2326,21 +2326,21 @@ def test_member_path_not_string(self, isolation, isolated_data_dir, platform, gl with pytest.raises( TypeError, - match='Option `path` of member #1 of field `tool.hatch.envs.default.workspace.members` must be a string', + match="Option `path` of member #1 of field `tool.hatch.envs.default.workspace.members` must be a string", ): _ = environment.workspace.members def test_member_path_empty_string(self, isolation, isolated_data_dir, platform, global_application): config = { - 'project': {'name': 'my_app', 'version': '0.0.1'}, - 'tool': {'hatch': {'envs': {'default': {'workspace': {'members': [{'path': ''}]}}}}}, + "project": {"name": "my_app", "version": "0.0.1"}, + "tool": {"hatch": {"envs": {"default": {"workspace": {"members": [{"path": ""}]}}}}}, } project = Project(isolation, config=config) environment = MockEnvironment( isolation, project.metadata, - 'default', - project.config.envs['default'], + "default", + project.config.envs["default"], {}, isolated_data_dir, isolated_data_dir, @@ -2352,23 +2352,23 @@ def test_member_path_empty_string(self, isolation, isolated_data_dir, platform, with pytest.raises( ValueError, match=( - 'Option `path` of member #1 of field `tool.hatch.envs.default.workspace.members` ' - 'cannot be an empty string' + "Option `path` of member #1 of field `tool.hatch.envs.default.workspace.members` " + "cannot be an empty string" ), ): _ = environment.workspace.members def test_member_features_not_array(self, isolation, isolated_data_dir, platform, global_application): config = { - 'project': {'name': 'my_app', 'version': '0.0.1'}, - 'tool': {'hatch': {'envs': {'default': {'workspace': {'members': [{'path': 'foo', 'features': 9000}]}}}}}, + "project": {"name": "my_app", "version": "0.0.1"}, + "tool": {"hatch": {"envs": {"default": {"workspace": {"members": [{"path": "foo", "features": 9000}]}}}}}, } project = Project(isolation, config=config) environment = MockEnvironment( isolation, project.metadata, - 'default', - project.config.envs['default'], + "default", + project.config.envs["default"], {}, isolated_data_dir, isolated_data_dir, @@ -2380,23 +2380,23 @@ def test_member_features_not_array(self, isolation, isolated_data_dir, platform, with pytest.raises( TypeError, match=( - 'Option `features` of member #1 of field `tool.hatch.envs.default.workspace.members` ' - 'must be an array of strings' + "Option `features` of member #1 of field `tool.hatch.envs.default.workspace.members` " + "must be an array of strings" ), ): _ = environment.workspace.members def test_member_feature_not_string(self, isolation, isolated_data_dir, platform, global_application): config = { - 'project': {'name': 'my_app', 'version': '0.0.1'}, - 'tool': {'hatch': {'envs': {'default': {'workspace': {'members': [{'path': 'foo', 'features': [9000]}]}}}}}, + "project": {"name": "my_app", "version": "0.0.1"}, + "tool": {"hatch": {"envs": {"default": {"workspace": {"members": [{"path": "foo", "features": [9000]}]}}}}}, } project = Project(isolation, config=config) environment = MockEnvironment( isolation, project.metadata, - 'default', - project.config.envs['default'], + "default", + project.config.envs["default"], {}, isolated_data_dir, isolated_data_dir, @@ -2408,23 +2408,23 @@ def test_member_feature_not_string(self, isolation, isolated_data_dir, platform, with pytest.raises( TypeError, match=( - 'Feature #1 of option `features` of member #1 of field `tool.hatch.envs.default.workspace.members` ' - 'must be a string' + "Feature #1 of option `features` of member #1 of field `tool.hatch.envs.default.workspace.members` " + "must be a string" ), ): _ = environment.workspace.members def test_member_feature_empty_string(self, isolation, isolated_data_dir, platform, global_application): config = { - 'project': {'name': 'my_app', 'version': '0.0.1'}, - 'tool': {'hatch': {'envs': {'default': {'workspace': {'members': [{'path': 'foo', 'features': ['']}]}}}}}, + "project": {"name": "my_app", "version": "0.0.1"}, + "tool": {"hatch": {"envs": {"default": {"workspace": {"members": [{"path": "foo", "features": [""]}]}}}}}, } project = Project(isolation, config=config) environment = MockEnvironment( isolation, project.metadata, - 'default', - project.config.envs['default'], + "default", + project.config.envs["default"], {}, isolated_data_dir, isolated_data_dir, @@ -2436,18 +2436,18 @@ def test_member_feature_empty_string(self, isolation, isolated_data_dir, platfor with pytest.raises( ValueError, match=( - 'Feature #1 of option `features` of member #1 of field `tool.hatch.envs.default.workspace.members` ' - 'cannot be an empty string' + "Feature #1 of option `features` of member #1 of field `tool.hatch.envs.default.workspace.members` " + "cannot be an empty string" ), ): _ = environment.workspace.members def test_member_feature_duplicate(self, isolation, isolated_data_dir, platform, global_application): config = { - 'project': {'name': 'my_app', 'version': '0.0.1'}, - 'tool': { - 'hatch': { - 'envs': {'default': {'workspace': {'members': [{'path': 'foo', 'features': ['foo', 'Foo']}]}}} + "project": {"name": "my_app", "version": "0.0.1"}, + "tool": { + "hatch": { + "envs": {"default": {"workspace": {"members": [{"path": "foo", "features": ["foo", "Foo"]}]}}} } }, } @@ -2455,8 +2455,8 @@ def test_member_feature_duplicate(self, isolation, isolated_data_dir, platform, environment = MockEnvironment( isolation, project.metadata, - 'default', - project.config.envs['default'], + "default", + project.config.envs["default"], {}, isolated_data_dir, isolated_data_dir, @@ -2468,23 +2468,23 @@ def test_member_feature_duplicate(self, isolation, isolated_data_dir, platform, with pytest.raises( ValueError, match=( - 'Feature #2 of option `features` of member #1 of field `tool.hatch.envs.default.workspace.members` ' - 'is a duplicate' + "Feature #2 of option `features` of member #1 of field `tool.hatch.envs.default.workspace.members` " + "is a duplicate" ), ): _ = environment.workspace.members def test_member_does_not_exist(self, isolation, isolated_data_dir, platform, global_application): config = { - 'project': {'name': 'my_app', 'version': '0.0.1'}, - 'tool': {'hatch': {'envs': {'default': {'workspace': {'members': [{'path': 'foo'}]}}}}}, + "project": {"name": "my_app", "version": "0.0.1"}, + "tool": {"hatch": {"envs": {"default": {"workspace": {"members": [{"path": "foo"}]}}}}}, } project = Project(isolation, config=config) environment = MockEnvironment( isolation, project.metadata, - 'default', - project.config.envs['default'], + "default", + project.config.envs["default"], {}, isolated_data_dir, isolated_data_dir, @@ -2496,23 +2496,23 @@ def test_member_does_not_exist(self, isolation, isolated_data_dir, platform, glo with pytest.raises( OSError, match=re.escape( - f'No members could be derived from `foo` of field `tool.hatch.envs.default.workspace.members`: ' - f'{isolation / "foo"}' + f"No members could be derived from `foo` of field `tool.hatch.envs.default.workspace.members`: " + f"{isolation / 'foo'}" ), ): _ = environment.workspace.members def test_member_not_project(self, temp_dir, isolated_data_dir, platform, global_application): config = { - 'project': {'name': 'my_app', 'version': '0.0.1'}, - 'tool': {'hatch': {'envs': {'default': {'workspace': {'members': [{'path': 'foo'}]}}}}}, + "project": {"name": "my_app", "version": "0.0.1"}, + "tool": {"hatch": {"envs": {"default": {"workspace": {"members": [{"path": "foo"}]}}}}}, } project = Project(temp_dir, config=config) environment = MockEnvironment( temp_dir, project.metadata, - 'default', - project.config.envs['default'], + "default", + project.config.envs["default"], {}, isolated_data_dir, isolated_data_dir, @@ -2521,29 +2521,29 @@ def test_member_not_project(self, temp_dir, isolated_data_dir, platform, global_ global_application, ) - member_path = temp_dir / 'foo' + member_path = temp_dir / "foo" member_path.mkdir() with pytest.raises( OSError, match=re.escape( - f'Member derived from `foo` of field `tool.hatch.envs.default.workspace.members` is not a project ' - f'(no `pyproject.toml` file): {member_path}' + f"Member derived from `foo` of field `tool.hatch.envs.default.workspace.members` is not a project " + f"(no `pyproject.toml` file): {member_path}" ), ): _ = environment.workspace.members def test_member_duplicate(self, temp_dir, isolated_data_dir, platform, global_application): config = { - 'project': {'name': 'my_app', 'version': '0.0.1'}, - 'tool': {'hatch': {'envs': {'default': {'workspace': {'members': [{'path': 'foo'}, {'path': 'f*'}]}}}}}, + "project": {"name": "my_app", "version": "0.0.1"}, + "tool": {"hatch": {"envs": {"default": {"workspace": {"members": [{"path": "foo"}, {"path": "f*"}]}}}}}, } project = Project(temp_dir, config=config) environment = MockEnvironment( temp_dir, project.metadata, - 'default', - project.config.envs['default'], + "default", + project.config.envs["default"], {}, isolated_data_dir, isolated_data_dir, @@ -2552,38 +2552,38 @@ def test_member_duplicate(self, temp_dir, isolated_data_dir, platform, global_ap global_application, ) - member_path = temp_dir / 'foo' + member_path = temp_dir / "foo" member_path.mkdir() - (member_path / 'pyproject.toml').touch() + (member_path / "pyproject.toml").touch() with pytest.raises( ValueError, match=re.escape( - f'Member derived from `f*` of field ' - f'`tool.hatch.envs.default.workspace.members` is a duplicate: {member_path}' + f"Member derived from `f*` of field " + f"`tool.hatch.envs.default.workspace.members` is a duplicate: {member_path}" ), ): _ = environment.workspace.members def test_correct(self, hatch, temp_dir, isolated_data_dir, platform, global_application): - member1_path = temp_dir / 'foo' - member2_path = temp_dir / 'bar' - member3_path = temp_dir / 'baz' + member1_path = temp_dir / "foo" + member2_path = temp_dir / "bar" + member3_path = temp_dir / "baz" for member_path in [member1_path, member2_path, member3_path]: with temp_dir.as_cwd(): - result = hatch('new', member_path.name) + result = hatch("new", member_path.name) assert result.exit_code == 0, result.output config = { - 'project': {'name': 'my_app', 'version': '0.0.1'}, - 'tool': {'hatch': {'envs': {'default': {'workspace': {'members': [{'path': 'foo'}, {'path': 'b*'}]}}}}}, + "project": {"name": "my_app", "version": "0.0.1"}, + "tool": {"hatch": {"envs": {"default": {"workspace": {"members": [{"path": "foo"}, {"path": "b*"}]}}}}}, } project = Project(temp_dir, config=config) environment = MockEnvironment( temp_dir, project.metadata, - 'default', - project.config.envs['default'], + "default", + project.config.envs["default"], {}, isolated_data_dir, isolated_data_dir, @@ -2602,7 +2602,7 @@ def test_correct(self, hatch, temp_dir, isolated_data_dir, platform, global_appl class TestWorkspaceDependencies: def test_basic(self, temp_dir, isolated_data_dir, platform, global_application): for i in range(3): - project_file = temp_dir / f'foo{i}' / 'pyproject.toml' + project_file = temp_dir / f"foo{i}" / "pyproject.toml" project_file.parent.mkdir() project_file.write_text( f"""\ @@ -2618,15 +2618,15 @@ def test_basic(self, temp_dir, isolated_data_dir, platform, global_application): ) config = { - 'project': {'name': 'my_app', 'version': '0.0.1'}, - 'tool': {'hatch': {'envs': {'default': {'workspace': {'members': [{'path': 'f*'}]}}}}}, + "project": {"name": "my_app", "version": "0.0.1"}, + "tool": {"hatch": {"envs": {"default": {"workspace": {"members": [{"path": "f*"}]}}}}}, } project = Project(temp_dir, config=config) environment = MockEnvironment( temp_dir, project.metadata, - 'default', - project.config.envs['default'], + "default", + project.config.envs["default"], {}, isolated_data_dir, isolated_data_dir, @@ -2635,11 +2635,11 @@ def test_basic(self, temp_dir, isolated_data_dir, platform, global_application): global_application, ) - assert environment.workspace.get_dependencies() == ['pkg-0', 'pkg-1', 'pkg-2'] + assert environment.workspace.get_dependencies() == ["pkg-0", "pkg-1", "pkg-2"] def test_features(self, temp_dir, isolated_data_dir, platform, global_application): for i in range(3): - project_file = temp_dir / f'foo{i}' / 'pyproject.toml' + project_file = temp_dir / f"foo{i}" / "pyproject.toml" project_file.parent.mkdir() project_file.write_text( f"""\ @@ -2660,16 +2660,16 @@ def test_features(self, temp_dir, isolated_data_dir, platform, global_applicatio ) config = { - 'project': {'name': 'my_app', 'version': '0.0.1'}, - 'tool': { - 'hatch': { - 'envs': { - 'default': { - 'workspace': { - 'members': [ - {'path': 'foo0', 'features': ['feature1']}, - {'path': 'foo1', 'features': ['feature1', 'feature2']}, - {'path': 'foo2', 'features': ['feature1', 'feature2', 'feature3']}, + "project": {"name": "my_app", "version": "0.0.1"}, + "tool": { + "hatch": { + "envs": { + "default": { + "workspace": { + "members": [ + {"path": "foo0", "features": ["feature1"]}, + {"path": "foo1", "features": ["feature1", "feature2"]}, + {"path": "foo2", "features": ["feature1", "feature2", "feature3"]}, ], }, }, @@ -2681,8 +2681,8 @@ def test_features(self, temp_dir, isolated_data_dir, platform, global_applicatio environment = MockEnvironment( temp_dir, project.metadata, - 'default', - project.config.envs['default'], + "default", + project.config.envs["default"], {}, isolated_data_dir, isolated_data_dir, @@ -2692,13 +2692,13 @@ def test_features(self, temp_dir, isolated_data_dir, platform, global_applicatio ) assert environment.workspace.get_dependencies() == [ - 'pkg-0', - 'pkg-feature-10', - 'pkg-1', - 'pkg-feature-11', - 'pkg-feature-21', - 'pkg-2', - 'pkg-feature-12', - 'pkg-feature-22', - 'pkg-feature-32', + "pkg-0", + "pkg-feature-10", + "pkg-1", + "pkg-feature-11", + "pkg-feature-21", + "pkg-2", + "pkg-feature-12", + "pkg-feature-22", + "pkg-feature-32", ] diff --git a/tests/workspaces/configuration.py b/tests/workspaces/configuration.py index ada8c01c1..419d0589d 100644 --- a/tests/workspaces/configuration.py +++ b/tests/workspaces/configuration.py @@ -2,11 +2,11 @@ class TestWorkspaceConfiguration: def test_workspace_members_editable_install(self, temp_dir, hatch): """Test that workspace members are installed as editable packages.""" # Create workspace root - workspace_root = temp_dir / 'workspace' + workspace_root = temp_dir / "workspace" workspace_root.mkdir() # Create workspace pyproject.toml - workspace_config = workspace_root / 'pyproject.toml' + workspace_config = workspace_root / "pyproject.toml" workspace_config.write_text(""" [project] name = "workspace-root" @@ -19,13 +19,13 @@ def test_workspace_members_editable_install(self, temp_dir, hatch): """) # Create workspace members - packages_dir = workspace_root / 'packages' + packages_dir = workspace_root / "packages" packages_dir.mkdir() # Member 1 - member1_dir = packages_dir / 'member1' + member1_dir = packages_dir / "member1" member1_dir.mkdir() - (member1_dir / 'pyproject.toml').write_text(""" + (member1_dir / "pyproject.toml").write_text(""" [project] name = "member1" version = "0.1.0" @@ -33,9 +33,9 @@ def test_workspace_members_editable_install(self, temp_dir, hatch): """) # Member 2 - member2_dir = packages_dir / 'member2' + member2_dir = packages_dir / "member2" member2_dir.mkdir() - (member2_dir / 'pyproject.toml').write_text(""" + (member2_dir / "pyproject.toml").write_text(""" [project] name = "member2" version = "0.1.0" @@ -44,19 +44,19 @@ def test_workspace_members_editable_install(self, temp_dir, hatch): with workspace_root.as_cwd(): # Test environment creation includes workspace members - result = hatch('env', 'create') + result = hatch("env", "create") assert result.exit_code == 0 # Verify workspace members are discovered - result = hatch('env', 'show', '--json') + result = hatch("env", "show", "--json") assert result.exit_code == 0 def test_workspace_exclude_patterns(self, temp_dir, hatch): """Test that exclude patterns filter out workspace members.""" - workspace_root = temp_dir / 'workspace' + workspace_root = temp_dir / "workspace" workspace_root.mkdir() - workspace_config = workspace_root / 'pyproject.toml' + workspace_config = workspace_root / "pyproject.toml" workspace_config.write_text(""" [project] name = "workspace-root" @@ -66,37 +66,37 @@ def test_workspace_exclude_patterns(self, temp_dir, hatch): exclude = ["packages/excluded*"] """) - packages_dir = workspace_root / 'packages' + packages_dir = workspace_root / "packages" packages_dir.mkdir() # Included member - included_dir = packages_dir / 'included' + included_dir = packages_dir / "included" included_dir.mkdir() - (included_dir / 'pyproject.toml').write_text(""" + (included_dir / "pyproject.toml").write_text(""" [project] name = "included" version = "0.1.0" """) # Excluded member - excluded_dir = packages_dir / 'excluded-pkg' + excluded_dir = packages_dir / "excluded-pkg" excluded_dir.mkdir() - (excluded_dir / 'pyproject.toml').write_text(""" + (excluded_dir / "pyproject.toml").write_text(""" [project] name = "excluded-pkg" version = "0.1.0" """) with workspace_root.as_cwd(): - result = hatch('env', 'create') + result = hatch("env", "create") assert result.exit_code == 0 def test_workspace_parallel_dependency_resolution(self, temp_dir, hatch): """Test parallel dependency resolution for workspace members.""" - workspace_root = temp_dir / 'workspace' + workspace_root = temp_dir / "workspace" workspace_root.mkdir() - workspace_config = workspace_root / 'pyproject.toml' + workspace_config = workspace_root / "pyproject.toml" workspace_config.write_text(""" [project] name = "workspace-root" @@ -108,14 +108,14 @@ def test_workspace_parallel_dependency_resolution(self, temp_dir, hatch): workspace.parallel = true """) - packages_dir = workspace_root / 'packages' + packages_dir = workspace_root / "packages" packages_dir.mkdir() # Create multiple members for i in range(3): - member_dir = packages_dir / f'member{i}' + member_dir = packages_dir / f"member{i}" member_dir.mkdir() - (member_dir / 'pyproject.toml').write_text(f""" + (member_dir / "pyproject.toml").write_text(f""" [project] name = "member{i}" version = "0.1.{i}" @@ -123,15 +123,15 @@ def test_workspace_parallel_dependency_resolution(self, temp_dir, hatch): """) with workspace_root.as_cwd(): - result = hatch('env', 'create') + result = hatch("env", "create") assert result.exit_code == 0 def test_workspace_member_features(self, temp_dir, hatch): """Test workspace members with specific features.""" - workspace_root = temp_dir / 'workspace' + workspace_root = temp_dir / "workspace" workspace_root.mkdir() - workspace_config = workspace_root / 'pyproject.toml' + workspace_config = workspace_root / "pyproject.toml" workspace_config.write_text(""" [project] name = "workspace-root" @@ -142,12 +142,12 @@ def test_workspace_member_features(self, temp_dir, hatch): ] """) - packages_dir = workspace_root / 'packages' + packages_dir = workspace_root / "packages" packages_dir.mkdir() - member1_dir = packages_dir / 'member1' + member1_dir = packages_dir / "member1" member1_dir.mkdir() - (member1_dir / 'pyproject.toml').write_text(""" + (member1_dir / "pyproject.toml").write_text(""" [project] name = "member1" dependencies = ["requests"] @@ -158,16 +158,16 @@ def test_workspace_member_features(self, temp_dir, hatch): """) with workspace_root.as_cwd(): - result = hatch('env', 'create') + result = hatch("env", "create") assert result.exit_code == 0 def test_workspace_inheritance_from_root(self, temp_dir, hatch): """Test that workspace members inherit environments from root.""" - workspace_root = temp_dir / 'workspace' + workspace_root = temp_dir / "workspace" workspace_root.mkdir() # Workspace root with shared environment - workspace_config = workspace_root / 'pyproject.toml' + workspace_config = workspace_root / "pyproject.toml" workspace_config.write_text(""" [project] name = "workspace-root" @@ -180,13 +180,13 @@ def test_workspace_inheritance_from_root(self, temp_dir, hatch): scripts.test = "pytest" """) - packages_dir = workspace_root / 'packages' + packages_dir = workspace_root / "packages" packages_dir.mkdir() # Member without local shared environment - member_dir = packages_dir / 'member1' + member_dir = packages_dir / "member1" member_dir.mkdir() - (member_dir / 'pyproject.toml').write_text(""" + (member_dir / "pyproject.toml").write_text(""" [project] name = "member1" version = "0.1.0" @@ -196,20 +196,20 @@ def test_workspace_inheritance_from_root(self, temp_dir, hatch): # Test from workspace root with workspace_root.as_cwd(): - result = hatch('env', 'show', 'shared') + result = hatch("env", "show", "shared") assert result.exit_code == 0 # Test from member directory with member_dir.as_cwd(): - result = hatch('env', 'show', 'shared') + result = hatch("env", "show", "shared") assert result.exit_code == 0 def test_workspace_no_members_fallback(self, temp_dir, hatch): """Test fallback when no workspace members are defined.""" - workspace_root = temp_dir / 'workspace' + workspace_root = temp_dir / "workspace" workspace_root.mkdir() - workspace_config = workspace_root / 'pyproject.toml' + workspace_config = workspace_root / "pyproject.toml" workspace_config.write_text(""" [project] name = "workspace-root" @@ -219,18 +219,18 @@ def test_workspace_no_members_fallback(self, temp_dir, hatch): """) with workspace_root.as_cwd(): - result = hatch('env', 'create') + result = hatch("env", "create") assert result.exit_code == 0 - result = hatch('env', 'show', '--json') + result = hatch("env", "show", "--json") assert result.exit_code == 0 def test_workspace_cross_member_dependencies(self, temp_dir, hatch): """Test workspace members depending on each other.""" - workspace_root = temp_dir / 'workspace' + workspace_root = temp_dir / "workspace" workspace_root.mkdir() - workspace_config = workspace_root / 'pyproject.toml' + workspace_config = workspace_root / "pyproject.toml" workspace_config.write_text(""" [project] name = "workspace-root" @@ -239,13 +239,13 @@ def test_workspace_cross_member_dependencies(self, temp_dir, hatch): members = ["packages/*"] """) - packages_dir = workspace_root / 'packages' + packages_dir = workspace_root / "packages" packages_dir.mkdir() # Base library - base_dir = packages_dir / 'base' + base_dir = packages_dir / "base" base_dir.mkdir() - (base_dir / 'pyproject.toml').write_text(""" + (base_dir / "pyproject.toml").write_text(""" [project] name = "base" version = "0.1.0" @@ -253,9 +253,9 @@ def test_workspace_cross_member_dependencies(self, temp_dir, hatch): """) # App depending on base - app_dir = packages_dir / 'app' + app_dir = packages_dir / "app" app_dir.mkdir() - (app_dir / 'pyproject.toml').write_text(""" + (app_dir / "pyproject.toml").write_text(""" [project] name = "app" version = "0.1.0" @@ -263,24 +263,24 @@ def test_workspace_cross_member_dependencies(self, temp_dir, hatch): """) with workspace_root.as_cwd(): - result = hatch('env', 'create') + result = hatch("env", "create") assert result.exit_code == 0 # Test that dependencies are resolved - result = hatch('dep', 'show', 'table') + result = hatch("dep", "show", "table") assert result.exit_code == 0 def test_workspace_build_all_members(self, temp_dir, hatch): """Test building all workspace members.""" - workspace_root = temp_dir / 'workspace' + workspace_root = temp_dir / "workspace" workspace_root.mkdir() # Create workspace root package - workspace_pkg = workspace_root / 'workspace_root' + workspace_pkg = workspace_root / "workspace_root" workspace_pkg.mkdir() - (workspace_pkg / '__init__.py').write_text('__version__ = "0.1.0"') + (workspace_pkg / "__init__.py").write_text('__version__ = "0.1.0"') - workspace_config = workspace_root / 'pyproject.toml' + workspace_config = workspace_root / "pyproject.toml" workspace_config.write_text(""" [project] name = "workspace-root" @@ -297,14 +297,14 @@ def test_workspace_build_all_members(self, temp_dir, hatch): packages = ["workspace_root"] """) - packages_dir = workspace_root / 'packages' + packages_dir = workspace_root / "packages" packages_dir.mkdir() # Create buildable members for i in range(2): - member_dir = packages_dir / f'member{i}' + member_dir = packages_dir / f"member{i}" member_dir.mkdir() - (member_dir / 'pyproject.toml').write_text(f""" + (member_dir / "pyproject.toml").write_text(f""" [project] name = "member{i}" version = "0.1.{i}" @@ -318,10 +318,10 @@ def test_workspace_build_all_members(self, temp_dir, hatch): """) # Create source files - src_dir = member_dir / f'member{i}' + src_dir = member_dir / f"member{i}" src_dir.mkdir() - (src_dir / '__init__.py').write_text(f'__version__ = "0.1.{i}"') + (src_dir / "__init__.py").write_text(f'__version__ = "0.1.{i}"') with workspace_root.as_cwd(): - result = hatch('build') + result = hatch("build") assert result.exit_code == 0 From 91e117c7e37b088339baf4757c4bb2537172fff2 Mon Sep 17 00:00:00 2001 From: Cary Hawkins Date: Wed, 8 Oct 2025 14:47:03 -0700 Subject: [PATCH 29/55] Fix circular dependency, add properties to WorkspaceConfig, --- src/hatch/cli/__init__.py | 24 +++++++-- src/hatch/env/plugin/interface.py | 84 +++++++++++++++++++++++++++++-- src/hatch/project/config.py | 39 +++++++++++++- 3 files changed, 135 insertions(+), 12 deletions(-) diff --git a/src/hatch/cli/__init__.py b/src/hatch/cli/__init__.py index 9c09d4ffa..02171851b 100644 --- a/src/hatch/cli/__init__.py +++ b/src/hatch/cli/__init__.py @@ -33,13 +33,27 @@ def find_workspace_root(path: Path) -> Path | None: """Find workspace root by traversing up from given path.""" current = path while current.parent != current: + # Check hatch.toml first + hatch_toml = current / "hatch.toml" + if hatch_toml.exists(): + try: + from hatch.utils.toml import load_toml_file + config = load_toml_file(str(hatch_toml)) + if config.get("workspace"): + return current + except Exception: + pass + + # Then check pyproject.toml pyproject = current / "pyproject.toml" if pyproject.exists(): - from hatch.utils.toml import load_toml_file - - config = load_toml_file(str(pyproject)) - if config.get("tool", {}).get("hatch", {}).get("workspace"): - return current + try: + from hatch.utils.toml import load_toml_file + config = load_toml_file(str(pyproject)) + if config.get("tool", {}).get("hatch", {}).get("workspace"): + return current + except Exception: + pass current = current.parent return None diff --git a/src/hatch/env/plugin/interface.py b/src/hatch/env/plugin/interface.py index c951f8fc6..2fb8c4074 100644 --- a/src/hatch/env/plugin/interface.py +++ b/src/hatch/env/plugin/interface.py @@ -325,6 +325,31 @@ def project_dependencies(self) -> list[str]: workspace dependencies. """ return [str(dependency) for dependency in self.project_dependencies_complex] + + @cached_property + def workspace_editable_mode(self) -> bool: + """Determine if workspace members should be installed as editable.""" + # Check workspace-specific configuration first + workspace_config = self.config.get("workspace", {}) + if "editable" in workspace_config: + editable = workspace_config["editable"] + if not isinstance(editable, bool): + message = f"Field `tool.hatch.envs.{self.name}.workspace.editable` must be a boolean" + raise TypeError(message) + return editable + + # Check global workspace configuration - FIX: Avoid circular reference + try: + if hasattr(self.app.project.config, 'workspace'): + workspace_config_obj = self.app.project.config.workspace + if workspace_config_obj and hasattr(workspace_config_obj, 'editable'): + return workspace_config_obj.editable + except (AttributeError, TypeError): + # If there's any issue accessing the workspace config, fall back to default + pass + + # Default to True for workspace members + return True @cached_property def local_dependencies_complex(self) -> list[Dependency]: @@ -335,11 +360,12 @@ def local_dependencies_complex(self) -> list[Dependency]: local_dependencies_complex.append( Dependency(f"{self.metadata.name} @ {self.root.as_uri()}", editable=self.dev_mode) ) - - local_dependencies_complex.extend( - Dependency(f"{member.project.metadata.name} @ {member.project.location.as_uri()}", editable=self.dev_mode) - for member in self.workspace.members - ) + if self.workspace.members: + workspace_editable = self.workspace_editable_mode + local_dependencies_complex.extend( + Dependency(f"{member.project.metadata.name} @ {member.project.location.as_uri()}", editable=workspace_editable) + for member in self.workspace.members + ) return local_dependencies_complex @@ -1009,6 +1035,24 @@ def parallel(self) -> bool: raise TypeError(message) return parallel + + @cached_property + def editable(self) -> bool: + """Whether workspace members should be installed as editable.""" + editable = self.config.get("editable", True) + if not isinstance(editable, bool): + message = f"Field `tool.hatch.envs.{self.env.name}.workspace.editable` must be a boolean" + raise TypeError(message) + return editable + + @cached_property + def auto_sync(self) -> bool: + """Whether to automatically sync workspace member changes.""" + auto_sync = self.config.get("auto-sync", True) + if not isinstance(auto_sync, bool): + message = f"Field `tool.hatch.envs.{self.env.name}.workspace.auto-sync` must be a boolean" + raise TypeError(message) + return auto_sync def get_dependencies(self) -> list[str]: static_members: list[WorkspaceMember] = [] @@ -1195,6 +1239,7 @@ class WorkspaceMember: def __init__(self, project: Project, *, features: tuple[str]): self.project = project self.features = features + self._last_modified = None @cached_property def name(self) -> str: @@ -1206,6 +1251,35 @@ def has_static_dependencies(self) -> bool: def get_dependencies(self) -> tuple[list[str], dict[str, list[str]]]: return self.project.get_dependencies() + + @property + def last_modified(self) -> float: + """Get the last modification time of the member's pyproject.toml.""" + import os + pyproject_path = self.project.location / "pyproject.toml" + if pyproject_path.exists(): + return os.path.getmtime(pyproject_path) + return 0.0 + + def has_changed(self) -> bool: + """Check if the workspace member has changed since last check.""" + current_modified = self.last_modified + if self._last_modified is None: + self._last_modified = current_modified + return True + + if current_modified > self._last_modified: + self._last_modified = current_modified + return True + + return False + + def get_editable_requirement(self, editable: bool = True) -> str: + """Get the requirement string for this workspace member.""" + uri = self.project.location.as_uri() + if editable: + return f"-e {self.name} @ {uri}" + return f"{self.name} @ {uri}" def expand_script_commands(env_name, script_name, commands, config, seen, active): diff --git a/src/hatch/project/config.py b/src/hatch/project/config.py index e9555fe40..85654013a 100644 --- a/src/hatch/project/config.py +++ b/src/hatch/project/config.py @@ -7,6 +7,7 @@ from os import environ from typing import TYPE_CHECKING, Any +from hatch.env.plugin.interface import Workspace from hatch.env.utils import ensure_valid_environment from hatch.project.constants import DEFAULT_BUILD_DIRECTORY, BuildEnvVars from hatch.project.env import apply_overrides @@ -503,12 +504,11 @@ def scripts(self): return self._scripts @cached_property - def workspace(self): + def workspace(self) -> WorkspaceConfig: config = self.config.get("workspace", {}) if not isinstance(config, dict): message = "Field `tool.hatch.workspace` must be a table" raise TypeError(message) - return WorkspaceConfig(config, self.root) def finalize_env_overrides(self, option_types): @@ -758,6 +758,41 @@ def exclude(self) -> list[str]: message = "Field `tool.hatch.workspace.exclude` must be an array" raise TypeError(message) return exclude + + @cached_property + def editable(self) -> bool: + """Whether workspace members should be installed as editable by default.""" + editable = self.__config.get("editable", True) + if not isinstance(editable, bool): + message = "Field `tool.hatch.workspace.editable` must be a boolean" + raise TypeError(message) + return editable + + @cached_property + def auto_sync(self) -> bool: + """Whether to automatically sync workspace member changes.""" + auto_sync = self.__config.get("auto-sync", True) + if not isinstance(auto_sync, bool): + message = "Field `tool.hatch.workspace.auto-sync` must be a boolean" + raise TypeError(message) + return auto_sync + + @cached_property + def resolver(self) -> str: + """Package resolver to use for workspace dependencies.""" + resolver = self.__config.get("resolver", "uv") + if not isinstance(resolver, str): + message = "Field `tool.hatch.workspace.resolver` must be a string" + raise TypeError(message) + if resolver not in ("uv", "pip"): + message = "Field `tool.hatch.workspace.resolver` must be either 'uv' or 'pip'" + raise ValueError(message) + return resolver + + @property + def config(self) -> dict[str, Any]: + """Access to raw config for backward compatibility.""" + return self.__config def env_var_enabled(env_var: str, *, default: bool = False) -> bool: From 176faeef20b54d778311eeacfa41f57fb8798320 Mon Sep 17 00:00:00 2001 From: Cary Hawkins Date: Wed, 8 Oct 2025 15:48:29 -0700 Subject: [PATCH 30/55] Utilize new workspace support for CI --- .github/workflows/build-hatch.yml | 1 - hatch.toml | 3 +++ pyproject.toml | 6 ++++-- 3 files changed, 7 insertions(+), 3 deletions(-) diff --git a/.github/workflows/build-hatch.yml b/.github/workflows/build-hatch.yml index f1aa6045e..cdfd19cb6 100644 --- a/.github/workflows/build-hatch.yml +++ b/.github/workflows/build-hatch.yml @@ -51,7 +51,6 @@ jobs: run: |- uv pip install --system build uv pip install --system . - uv pip install --system ./backend # Windows installers don't accept non-integer versions so we ubiquitously # perform the following transformation: X.Y.Z.devN -> X.Y.Z.N diff --git a/hatch.toml b/hatch.toml index 13a053daf..68036ffe4 100644 --- a/hatch.toml +++ b/hatch.toml @@ -1,3 +1,6 @@ +[workpace] +members = ["hatch/*"] + [envs.hatch-static-analysis] config-path = "ruff_defaults.toml" diff --git a/pyproject.toml b/pyproject.toml index fdee18f6e..d1c24cb7b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -39,7 +39,6 @@ classifiers = [ ] dependencies = [ "click>=8.0.6", - "hatchling>=1.24.2", "httpx>=0.22.0", "hyperlink>=21.0.0", "keyring>=23.5.0", @@ -53,7 +52,7 @@ dependencies = [ "tomlkit>=0.11.1", "userpath~=1.7", "uv>=0.5.23", - "virtualenv>=20.26.6", + "virtualenv>=20.26.6,<20.35.0", "zstandard<1", ] dynamic = ["version"] @@ -68,6 +67,9 @@ Source = "https://github.com/pypa/hatch" [project.scripts] hatch = "hatch.cli:main" +[tool.hatch.workspace] +members = ["hatch/*"] + [tool.hatch.version] source = "vcs" From 7c48bf97ebed981e0e76cb4991bd864add311042 Mon Sep 17 00:00:00 2001 From: Cary Hawkins Date: Wed, 8 Oct 2025 16:03:38 -0700 Subject: [PATCH 31/55] Fix broad exception issue and other ruff errors --- src/hatch/cli/__init__.py | 35 +++++++++++++++++-------------- src/hatch/env/plugin/interface.py | 21 +++++++++++-------- src/hatch/project/config.py | 5 ++--- 3 files changed, 33 insertions(+), 28 deletions(-) diff --git a/src/hatch/cli/__init__.py b/src/hatch/cli/__init__.py index 02171851b..8fde92dc0 100644 --- a/src/hatch/cli/__init__.py +++ b/src/hatch/cli/__init__.py @@ -31,33 +31,36 @@ def find_workspace_root(path: Path) -> Path | None: """Find workspace root by traversing up from given path.""" + from hatch.utils.toml import load_toml_file + current = path while current.parent != current: # Check hatch.toml first hatch_toml = current / "hatch.toml" - if hatch_toml.exists(): - try: - from hatch.utils.toml import load_toml_file - config = load_toml_file(str(hatch_toml)) - if config.get("workspace"): - return current - except Exception: - pass + if hatch_toml.exists() and _has_workspace_config(load_toml_file, str(hatch_toml), "workspace"): + return current # Then check pyproject.toml pyproject = current / "pyproject.toml" - if pyproject.exists(): - try: - from hatch.utils.toml import load_toml_file - config = load_toml_file(str(pyproject)) - if config.get("tool", {}).get("hatch", {}).get("workspace"): - return current - except Exception: - pass + if pyproject.exists() and _has_workspace_config(load_toml_file, str(pyproject), "tool.hatch.workspace"): + return current + current = current.parent return None +def _has_workspace_config(load_func, file_path: str, config_path: str) -> bool: + """Check if file has workspace configuration, returning False on any error.""" + try: + config = load_func(file_path) + if config_path == "workspace": + return bool(config.get("workspace")) + # "tool.hatch.workspace" + return bool(config.get("tool", {}).get("hatch", {}).get("workspace")) + except (OSError, ValueError, TypeError, KeyError): + return False + + @click.group( context_settings={"help_option_names": ["-h", "--help"], "max_content_width": 120}, invoke_without_command=True ) diff --git a/src/hatch/env/plugin/interface.py b/src/hatch/env/plugin/interface.py index 2fb8c4074..d58d69369 100644 --- a/src/hatch/env/plugin/interface.py +++ b/src/hatch/env/plugin/interface.py @@ -325,7 +325,7 @@ def project_dependencies(self) -> list[str]: workspace dependencies. """ return [str(dependency) for dependency in self.project_dependencies_complex] - + @cached_property def workspace_editable_mode(self) -> bool: """Determine if workspace members should be installed as editable.""" @@ -340,9 +340,9 @@ def workspace_editable_mode(self) -> bool: # Check global workspace configuration - FIX: Avoid circular reference try: - if hasattr(self.app.project.config, 'workspace'): + if hasattr(self.app.project.config, "workspace"): workspace_config_obj = self.app.project.config.workspace - if workspace_config_obj and hasattr(workspace_config_obj, 'editable'): + if workspace_config_obj and hasattr(workspace_config_obj, "editable"): return workspace_config_obj.editable except (AttributeError, TypeError): # If there's any issue accessing the workspace config, fall back to default @@ -363,7 +363,9 @@ def local_dependencies_complex(self) -> list[Dependency]: if self.workspace.members: workspace_editable = self.workspace_editable_mode local_dependencies_complex.extend( - Dependency(f"{member.project.metadata.name} @ {member.project.location.as_uri()}", editable=workspace_editable) + Dependency( + f"{member.project.metadata.name} @ {member.project.location.as_uri()}", editable=workspace_editable + ) for member in self.workspace.members ) @@ -1035,7 +1037,7 @@ def parallel(self) -> bool: raise TypeError(message) return parallel - + @cached_property def editable(self) -> bool: """Whether workspace members should be installed as editable.""" @@ -1251,11 +1253,12 @@ def has_static_dependencies(self) -> bool: def get_dependencies(self) -> tuple[list[str], dict[str, list[str]]]: return self.project.get_dependencies() - + @property def last_modified(self) -> float: """Get the last modification time of the member's pyproject.toml.""" import os + pyproject_path = self.project.location / "pyproject.toml" if pyproject_path.exists(): return os.path.getmtime(pyproject_path) @@ -1267,14 +1270,14 @@ def has_changed(self) -> bool: if self._last_modified is None: self._last_modified = current_modified return True - + if current_modified > self._last_modified: self._last_modified = current_modified return True - + return False - def get_editable_requirement(self, editable: bool = True) -> str: + def get_editable_requirement(self, *, editable: bool = True) -> str: """Get the requirement string for this workspace member.""" uri = self.project.location.as_uri() if editable: diff --git a/src/hatch/project/config.py b/src/hatch/project/config.py index 85654013a..be7c767a7 100644 --- a/src/hatch/project/config.py +++ b/src/hatch/project/config.py @@ -7,7 +7,6 @@ from os import environ from typing import TYPE_CHECKING, Any -from hatch.env.plugin.interface import Workspace from hatch.env.utils import ensure_valid_environment from hatch.project.constants import DEFAULT_BUILD_DIRECTORY, BuildEnvVars from hatch.project.env import apply_overrides @@ -758,7 +757,7 @@ def exclude(self) -> list[str]: message = "Field `tool.hatch.workspace.exclude` must be an array" raise TypeError(message) return exclude - + @cached_property def editable(self) -> bool: """Whether workspace members should be installed as editable by default.""" @@ -784,7 +783,7 @@ def resolver(self) -> str: if not isinstance(resolver, str): message = "Field `tool.hatch.workspace.resolver` must be a string" raise TypeError(message) - if resolver not in ("uv", "pip"): + if resolver not in {"uv", "pip"}: message = "Field `tool.hatch.workspace.resolver` must be either 'uv' or 'pip'" raise ValueError(message) return resolver From 8be9965b9657ba05fb0bb9b77527200eb2d78238 Mon Sep 17 00:00:00 2001 From: Cary Hawkins Date: Wed, 8 Oct 2025 16:10:36 -0700 Subject: [PATCH 32/55] Change type for WorkspaceMember last_modified --- src/hatch/env/plugin/interface.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/hatch/env/plugin/interface.py b/src/hatch/env/plugin/interface.py index d58d69369..0a940b73e 100644 --- a/src/hatch/env/plugin/interface.py +++ b/src/hatch/env/plugin/interface.py @@ -1241,7 +1241,7 @@ class WorkspaceMember: def __init__(self, project: Project, *, features: tuple[str]): self.project = project self.features = features - self._last_modified = None + self._last_modified: float @cached_property def name(self) -> str: From fa5272aa67225a282122795db5be88a00a96ef09 Mon Sep 17 00:00:00 2001 From: Cary Hawkins Date: Wed, 8 Oct 2025 16:41:02 -0700 Subject: [PATCH 33/55] Move to using hatch env create to dogfood workspace support --- .github/workflows/build-hatch.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/build-hatch.yml b/.github/workflows/build-hatch.yml index cdfd19cb6..9e9dd2dc7 100644 --- a/.github/workflows/build-hatch.yml +++ b/.github/workflows/build-hatch.yml @@ -51,6 +51,7 @@ jobs: run: |- uv pip install --system build uv pip install --system . + hatch env create default # Windows installers don't accept non-integer versions so we ubiquitously # perform the following transformation: X.Y.Z.devN -> X.Y.Z.N From 2ebe183bdd98aa7a97cbd6d310c283b9c21fbeda Mon Sep 17 00:00:00 2001 From: Cary Hawkins Date: Wed, 8 Oct 2025 16:45:18 -0700 Subject: [PATCH 34/55] Revert workflow changes. --- .github/workflows/build-hatch.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/build-hatch.yml b/.github/workflows/build-hatch.yml index 9e9dd2dc7..f1aa6045e 100644 --- a/.github/workflows/build-hatch.yml +++ b/.github/workflows/build-hatch.yml @@ -51,7 +51,7 @@ jobs: run: |- uv pip install --system build uv pip install --system . - hatch env create default + uv pip install --system ./backend # Windows installers don't accept non-integer versions so we ubiquitously # perform the following transformation: X.Y.Z.devN -> X.Y.Z.N From 88138e5239ac688313e6afd7f1c20c218e6e1cb0 Mon Sep 17 00:00:00 2001 From: Cary Hawkins Date: Wed, 8 Oct 2025 17:53:14 -0700 Subject: [PATCH 35/55] Fix member discovery issues. --- hatch.toml | 11 +--- pyproject.toml | 4 +- src/hatch/env/plugin/interface.py | 75 +++++++++------------------ src/hatch/project/config.py | 85 +++++++++++++++++++++---------- 4 files changed, 86 insertions(+), 89 deletions(-) diff --git a/hatch.toml b/hatch.toml index 68036ffe4..e5b89d321 100644 --- a/hatch.toml +++ b/hatch.toml @@ -6,23 +6,16 @@ config-path = "ruff_defaults.toml" [envs.default] installer = "uv" -post-install-commands = [ - "uv pip install {verbosity:flag:-1} -e ./backend", -] [envs.hatch-test] extra-dependencies = [ "filelock", "flit-core", - "hatchling", "pyfakefs", "trustme", # Hatchling dynamic dependency "editables", ] -post-install-commands = [ - "pip install {verbosity:flag:-1} -e ./backend", -] extra-args = ["--dist", "worksteal"] [envs.hatch-test.extra-scripts] @@ -128,9 +121,7 @@ update-ruff = [ [envs.release] detached = true installer = "uv" -dependencies = [ - "hatchling", -] + [envs.release.scripts] bump = "python scripts/bump.py {args}" github = "python scripts/release_github.py {args}" diff --git a/pyproject.toml b/pyproject.toml index d1c24cb7b..b28220d55 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -39,7 +39,7 @@ classifiers = [ ] dependencies = [ "click>=8.0.6", - "httpx>=0.22.0", + "httpx>=0.22.0", "hyperlink>=21.0.0", "keyring>=23.5.0", "packaging>=24.2", @@ -68,7 +68,7 @@ Source = "https://github.com/pypa/hatch" hatch = "hatch.cli:main" [tool.hatch.workspace] -members = ["hatch/*"] +members = ["backend"] [tool.hatch.version] source = "vcs" diff --git a/src/hatch/env/plugin/interface.py b/src/hatch/env/plugin/interface.py index 0a940b73e..7e9644c7e 100644 --- a/src/hatch/env/plugin/interface.py +++ b/src/hatch/env/plugin/interface.py @@ -326,31 +326,6 @@ def project_dependencies(self) -> list[str]: """ return [str(dependency) for dependency in self.project_dependencies_complex] - @cached_property - def workspace_editable_mode(self) -> bool: - """Determine if workspace members should be installed as editable.""" - # Check workspace-specific configuration first - workspace_config = self.config.get("workspace", {}) - if "editable" in workspace_config: - editable = workspace_config["editable"] - if not isinstance(editable, bool): - message = f"Field `tool.hatch.envs.{self.name}.workspace.editable` must be a boolean" - raise TypeError(message) - return editable - - # Check global workspace configuration - FIX: Avoid circular reference - try: - if hasattr(self.app.project.config, "workspace"): - workspace_config_obj = self.app.project.config.workspace - if workspace_config_obj and hasattr(workspace_config_obj, "editable"): - return workspace_config_obj.editable - except (AttributeError, TypeError): - # If there's any issue accessing the workspace config, fall back to default - pass - - # Default to True for workspace members - return True - @cached_property def local_dependencies_complex(self) -> list[Dependency]: from hatch.dep.core import Dependency @@ -361,11 +336,8 @@ def local_dependencies_complex(self) -> list[Dependency]: Dependency(f"{self.metadata.name} @ {self.root.as_uri()}", editable=self.dev_mode) ) if self.workspace.members: - workspace_editable = self.workspace_editable_mode local_dependencies_complex.extend( - Dependency( - f"{member.project.metadata.name} @ {member.project.location.as_uri()}", editable=workspace_editable - ) + Dependency(f"{member.project.metadata.name} @ {member.project.location.as_uri()}", editable=True) for member in self.workspace.members ) @@ -623,12 +595,30 @@ def post_install_commands(self): @cached_property def workspace(self) -> Workspace: - config = self.config.get("workspace", {}) - if not isinstance(config, dict): + # Start with project-level workspace configuration + project_workspace_config = self.app.project.config.workspace + + # Get environment-level workspace configuration + env_config = self.config.get("workspace", {}) + if not isinstance(env_config, dict): message = f"Field `tool.hatch.envs.{self.name}.workspace` must be a table" raise TypeError(message) - return Workspace(self, config) + # Merge configurations: project-level as base, environment-level as override + merged_config = {} + + # Inherit project-level members if no environment-level members specified + if project_workspace_config.members and "members" not in env_config: + merged_config["members"] = project_workspace_config.members + + # Inherit project-level exclude if no environment-level exclude specified + if project_workspace_config.exclude and "exclude" not in env_config: + merged_config["exclude"] = project_workspace_config.exclude + + # Apply environment-level overrides + merged_config.update(env_config) + + return Workspace(self, merged_config) def activate(self): """ @@ -1038,24 +1028,6 @@ def parallel(self) -> bool: return parallel - @cached_property - def editable(self) -> bool: - """Whether workspace members should be installed as editable.""" - editable = self.config.get("editable", True) - if not isinstance(editable, bool): - message = f"Field `tool.hatch.envs.{self.env.name}.workspace.editable` must be a boolean" - raise TypeError(message) - return editable - - @cached_property - def auto_sync(self) -> bool: - """Whether to automatically sync workspace member changes.""" - auto_sync = self.config.get("auto-sync", True) - if not isinstance(auto_sync, bool): - message = f"Field `tool.hatch.envs.{self.env.name}.workspace.auto-sync` must be a boolean" - raise TypeError(message) - return auto_sync - def get_dependencies(self) -> list[str]: static_members: list[WorkspaceMember] = [] dynamic_members: list[WorkspaceMember] = [] @@ -1108,6 +1080,9 @@ def members(self) -> list[WorkspaceMember]: message = f"Field `tool.hatch.envs.{self.env.name}.workspace.members` must be an array" raise TypeError(message) + if not raw_members: + return [] + # First normalize configuration member_data: list[dict[str, Any]] = [] for i, data in enumerate(raw_members, 1): diff --git a/src/hatch/project/config.py b/src/hatch/project/config.py index be7c767a7..3c180145b 100644 --- a/src/hatch/project/config.py +++ b/src/hatch/project/config.py @@ -1,5 +1,6 @@ from __future__ import annotations +import os import re from copy import deepcopy from functools import cached_property @@ -11,10 +12,10 @@ from hatch.project.constants import DEFAULT_BUILD_DIRECTORY, BuildEnvVars from hatch.project.env import apply_overrides from hatch.project.utils import format_script_commands, parse_script_command +from hatch.utils.fs import Path if TYPE_CHECKING: from hatch.dep.core import Dependency - from hatch.utils.fs import Path class ProjectConfig: @@ -759,34 +760,64 @@ def exclude(self) -> list[str]: return exclude @cached_property - def editable(self) -> bool: - """Whether workspace members should be installed as editable by default.""" - editable = self.__config.get("editable", True) - if not isinstance(editable, bool): - message = "Field `tool.hatch.workspace.editable` must be a boolean" - raise TypeError(message) - return editable + def discovered_member_paths(self) -> list[Path]: + """Discover workspace member paths using the existing find_members function.""" + from hatch.env.plugin.interface import find_members + + discovered_paths = [] + + for member_pattern in self.members: + # Convert to absolute path for processing + pattern_path = self.__root / member_pattern if not os.path.isabs(member_pattern) else Path(member_pattern) + + # Normalize and get relative components for find_members + normalized_path = os.path.normpath(str(pattern_path)) + absolute_path = os.path.abspath(normalized_path) + shared_prefix = os.path.commonprefix([str(self.__root), absolute_path]) + relative_path = os.path.relpath(absolute_path, shared_prefix) + + # Use existing find_members function + for member_path in find_members(str(self.__root), relative_path.split(os.sep)): + project_file = os.path.join(member_path, "pyproject.toml") + if os.path.isfile(project_file): + discovered_paths.append(Path(member_path)) + + return discovered_paths + + def validate_workspace_members(self) -> list[str]: + """Validate workspace members and return errors.""" + errors = [] + + for member_pattern in self.members: + try: + # Test if pattern finds any members + if not os.path.isabs(member_pattern): + pattern_path = self.__root / member_pattern + else: + pattern_path = Path(member_pattern) - @cached_property - def auto_sync(self) -> bool: - """Whether to automatically sync workspace member changes.""" - auto_sync = self.__config.get("auto-sync", True) - if not isinstance(auto_sync, bool): - message = "Field `tool.hatch.workspace.auto-sync` must be a boolean" - raise TypeError(message) - return auto_sync + normalized_path = os.path.normpath(str(pattern_path)) + absolute_path = os.path.abspath(normalized_path) + shared_prefix = os.path.commonprefix([str(self.__root), absolute_path]) + relative_path = os.path.relpath(absolute_path, shared_prefix) - @cached_property - def resolver(self) -> str: - """Package resolver to use for workspace dependencies.""" - resolver = self.__config.get("resolver", "uv") - if not isinstance(resolver, str): - message = "Field `tool.hatch.workspace.resolver` must be a string" - raise TypeError(message) - if resolver not in {"uv", "pip"}: - message = "Field `tool.hatch.workspace.resolver` must be either 'uv' or 'pip'" - raise ValueError(message) - return resolver + from hatch.env.plugin.interface import find_members + + members_found = False + + for member_path in find_members(str(self.__root), relative_path.split(os.sep)): + project_file = os.path.join(member_path, "pyproject.toml") + if os.path.isfile(project_file): + members_found = True + break + + if not members_found: + errors.append(f"No workspace members found for pattern: {member_pattern}") + + except (OSError, ValueError, TypeError) as e: + errors.append(f"Error processing workspace member pattern '{member_pattern}': {e}") + + return errors @property def config(self) -> dict[str, Any]: From 8416d4a112f9169fb96770d876901579a2c7bded Mon Sep 17 00:00:00 2001 From: Cary Hawkins Date: Wed, 8 Oct 2025 17:55:54 -0700 Subject: [PATCH 36/55] Fix formatting for dependencies block --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index b28220d55..82cef83a5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -39,7 +39,7 @@ classifiers = [ ] dependencies = [ "click>=8.0.6", - "httpx>=0.22.0", + "httpx>=0.22.0", "hyperlink>=21.0.0", "keyring>=23.5.0", "packaging>=24.2", From da10a4f8bfa852e49b9c77e9ebc753fc103aa96e Mon Sep 17 00:00:00 2001 From: Cary Hawkins Date: Wed, 8 Oct 2025 18:10:07 -0700 Subject: [PATCH 37/55] Handle case where project level workspace is not configured only environment level is. --- src/hatch/env/plugin/interface.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/hatch/env/plugin/interface.py b/src/hatch/env/plugin/interface.py index 7e9644c7e..9e5b19330 100644 --- a/src/hatch/env/plugin/interface.py +++ b/src/hatch/env/plugin/interface.py @@ -596,7 +596,7 @@ def post_install_commands(self): @cached_property def workspace(self) -> Workspace: # Start with project-level workspace configuration - project_workspace_config = self.app.project.config.workspace + project_workspace_config = getattr(self.app.project.config, "workspace", None) if self.app.project else None # Get environment-level workspace configuration env_config = self.config.get("workspace", {}) @@ -608,11 +608,11 @@ def workspace(self) -> Workspace: merged_config = {} # Inherit project-level members if no environment-level members specified - if project_workspace_config.members and "members" not in env_config: + if project_workspace_config and project_workspace_config.members and "members" not in env_config: merged_config["members"] = project_workspace_config.members # Inherit project-level exclude if no environment-level exclude specified - if project_workspace_config.exclude and "exclude" not in env_config: + if project_workspace_config and project_workspace_config.exclude and "exclude" not in env_config: merged_config["exclude"] = project_workspace_config.exclude # Apply environment-level overrides From 9a54b4dc0a35567658ad0cfc0ba7180d60d032c4 Mon Sep 17 00:00:00 2001 From: Cary Hawkins Date: Wed, 8 Oct 2025 19:42:55 -0700 Subject: [PATCH 38/55] Fix docs missing reference, update doc dependencies to handle deprecation warnings, remove virtualenv upper bounds --- docs/plugins/environment/reference.md | 1 + hatch.toml | 7 ++++--- pyproject.toml | 2 +- 3 files changed, 6 insertions(+), 4 deletions(-) diff --git a/docs/plugins/environment/reference.md b/docs/plugins/environment/reference.md index eecc10c2f..c476eddea 100644 --- a/docs/plugins/environment/reference.md +++ b/docs/plugins/environment/reference.md @@ -51,6 +51,7 @@ All environment types should [offer support](#hatch.env.plugin.interface.Environ - dependencies_in_sync - sync_dependencies - dependency_hash + - project_dependencies - project_root - sep - pathsep diff --git a/hatch.toml b/hatch.toml index e5b89d321..ef6dbc978 100644 --- a/hatch.toml +++ b/hatch.toml @@ -49,10 +49,11 @@ dependencies = [ "mkdocs-minify-plugin~=0.8.0", "mkdocs-git-revision-date-localized-plugin~=1.2.5", "mkdocs-git-committers-plugin-2~=2.3.0", - "mkdocstrings-python~=1.10.3", + "mkdocstrings[python]~=0.26.0", "mkdocs-redirects~=1.2.1", "mkdocs-glightbox~=0.4.0", "mike~=2.1.1", + "mkdocs-autorefs>=1.0.0", # Extensions "mkdocs-click~=0.8.1", "pymdown-extensions~=10.8.1", @@ -60,7 +61,7 @@ dependencies = [ "pygments~=2.18.0", # Validation "linkchecker~=10.5.0", - "griffe<1.0", + "griffe>=1.0.0", ] pre-install-commands = [ "python scripts/install_mkdocs_material_insiders.py", @@ -82,7 +83,7 @@ ci-build = "mike deploy --config-file {env:MKDOCS_CONFIG} --update-aliases {args validate = "linkchecker --config .linkcheckerrc site" # https://github.com/linkchecker/linkchecker/issues/678 build-check = [ - "build --no-directory-urls", + "python -W ignore::DeprecationWarning -m mkdocs build --no-directory-urls", "validate", ] diff --git a/pyproject.toml b/pyproject.toml index 82cef83a5..f1082d63a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -52,7 +52,7 @@ dependencies = [ "tomlkit>=0.11.1", "userpath~=1.7", "uv>=0.5.23", - "virtualenv>=20.26.6,<20.35.0", + "virtualenv>=20.26.6", "zstandard<1", ] dynamic = ["version"] From af09e9a469265d443b9eee628bbd682f3b26a6bc Mon Sep 17 00:00:00 2001 From: Cary Hawkins Date: Thu, 9 Oct 2025 21:58:15 -0700 Subject: [PATCH 39/55] Dogfooding workspaces with CI --- .github/workflows/build-hatch.yml | 12 ++- .github/workflows/cli.yml | 1 - .github/workflows/docs-dev.yml | 6 +- .github/workflows/docs-release.yml | 5 +- .github/workflows/test.yml | 162 +++++++++++++++-------------- hatch.toml | 12 ++- 6 files changed, 115 insertions(+), 83 deletions(-) diff --git a/.github/workflows/build-hatch.yml b/.github/workflows/build-hatch.yml index f1aa6045e..74183a5e1 100644 --- a/.github/workflows/build-hatch.yml +++ b/.github/workflows/build-hatch.yml @@ -51,7 +51,11 @@ jobs: run: |- uv pip install --system build uv pip install --system . - uv pip install --system ./backend + + - name: Verify workspace setup + run: | + python -c "import hatchling; print('Hatchling available from workspace')" + hatch version # Windows installers don't accept non-integer versions so we ubiquitously # perform the following transformation: X.Y.Z.devN -> X.Y.Z.N @@ -166,7 +170,11 @@ jobs: - name: Install Hatch run: |- uv pip install --system -e . - uv pip install --system -e ./backend + + - name: Verify workspace setup + run: | + python -c "import hatchling; print('Hatchling available from workspace')" + hatch --version - name: Install Rust toolchain uses: dtolnay/rust-toolchain@stable diff --git a/.github/workflows/cli.yml b/.github/workflows/cli.yml index e384a9de9..9430f6734 100644 --- a/.github/workflows/cli.yml +++ b/.github/workflows/cli.yml @@ -43,7 +43,6 @@ jobs: - name: Install ourself run: | uv pip install --system . - uv pip install --system ./backend - name: Benchmark run: | diff --git a/.github/workflows/docs-dev.yml b/.github/workflows/docs-dev.yml index f8a3c72a0..02f9b7915 100644 --- a/.github/workflows/docs-dev.yml +++ b/.github/workflows/docs-dev.yml @@ -38,7 +38,11 @@ jobs: - name: Install ourself run: | uv pip install --system -e . - uv pip install --system -e ./backend + + - name: Verify workspace setup + run: | + python -c "import hatchling; print('Hatchling available from workspace')" + hatch version - name: Configure Git for GitHub Actions bot run: | diff --git a/.github/workflows/docs-release.yml b/.github/workflows/docs-release.yml index 54e6e59cd..0dfd87404 100644 --- a/.github/workflows/docs-release.yml +++ b/.github/workflows/docs-release.yml @@ -36,7 +36,10 @@ jobs: - name: Install ourself run: | uv pip install --system -e . - uv pip install --system -e ./backend + + - name: Verify workspace setup + run: | + python -c "import hatchling; print('Hatchling available from workspace')" - name: Display full version run: hatch version diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 6901391fa..ecd5b12b0 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -3,10 +3,10 @@ name: test on: push: branches: - - master + - master pull_request: branches: - - master + - master concurrency: group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.sha }} @@ -24,90 +24,100 @@ jobs: fail-fast: false matrix: os: [ubuntu-latest, windows-latest, macos-latest] - python-version: ['3.9', '3.10', '3.11', '3.12', '3.13'] + python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"] steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v4 - - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v5 - with: - python-version: ${{ matrix.python-version }} + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} - - name: Install uv - uses: astral-sh/setup-uv@v3 + - name: Install uv + uses: astral-sh/setup-uv@v3 - - name: Install ourself - run: | - uv pip install --system -e . - uv pip install --system -e ./backend + - name: Install ourself + run: | + uv pip install --system -e . - - name: Run static analysis - run: hatch fmt --check + - name: Validate workspace functionality + run: | + # Verify that hatchling is installed as editable from workspace + hatch run uv pip list --editable | grep hatchling - - name: Check types - run: hatch run types:check + # Test that hatch commands work with workspace member + python -c "import hatchling; print('Hatchling imported successfully from workspace')" - - name: Run tests - run: hatch test --python ${{ matrix.python-version }} --cover-quiet --randomize --parallel --retries 5 --retry-delay 3 + - name: Run static analysis + run: hatch fmt --check - - name: Disambiguate coverage filename - run: mv .coverage ".coverage.${{ matrix.os }}.${{ matrix.python-version }}" + - name: Check types + run: hatch run types:check - - name: Upload coverage data - uses: actions/upload-artifact@v4 - with: - include-hidden-files: true - name: coverage-${{ matrix.os }}-${{ matrix.python-version }} - path: .coverage* + - name: Test workspace functionality + run: hatch run workspace-test:validate + + - name: Run tests + run: hatch test --python ${{ matrix.python-version }} --cover-quiet --randomize --parallel --retries 5 --retry-delay 3 + + - name: Disambiguate coverage filename + run: mv .coverage ".coverage.${{ matrix.os }}.${{ matrix.python-version }}" + + - name: Upload coverage data + uses: actions/upload-artifact@v4 + with: + include-hidden-files: true + name: coverage-${{ matrix.os }}-${{ matrix.python-version }} + path: .coverage* coverage: name: Report coverage runs-on: ubuntu-latest needs: - - run + - run steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v4 - - name: Install Hatch - uses: pypa/hatch@install + - name: Install Hatch + uses: pypa/hatch@install - - name: Trigger build for auto-generated files - run: hatch build --hooks-only + - name: Trigger build for auto-generated files + run: hatch build --hooks-only - - name: Download coverage data - uses: actions/download-artifact@v4 - with: - pattern: coverage-* - merge-multiple: true + - name: Download coverage data + uses: actions/download-artifact@v4 + with: + pattern: coverage-* + merge-multiple: true - - name: Combine coverage data - run: hatch run coverage:combine + - name: Combine coverage data + run: hatch run coverage:combine - - name: Export coverage reports - run: | - hatch run coverage:report-xml - hatch run coverage:report-uncovered-html + - name: Export coverage reports + run: | + hatch run coverage:report-xml + hatch run coverage:report-uncovered-html - - name: Upload uncovered HTML report - uses: actions/upload-artifact@v4 - with: - name: uncovered-html-report - path: htmlcov + - name: Upload uncovered HTML report + uses: actions/upload-artifact@v4 + with: + name: uncovered-html-report + path: htmlcov - - name: Generate coverage summary - run: hatch run coverage:generate-summary + - name: Generate coverage summary + run: hatch run coverage:generate-summary - - name: Write coverage summary report - if: github.event_name == 'pull_request' - run: hatch run coverage:write-summary-report + - name: Write coverage summary report + if: github.event_name == 'pull_request' + run: hatch run coverage:write-summary-report - - name: Update coverage pull request comment - if: github.event_name == 'pull_request' && !github.event.pull_request.head.repo.fork - uses: marocchino/sticky-pull-request-comment@v2 - with: - path: coverage-report.md + - name: Update coverage pull request comment + if: github.event_name == 'pull_request' && !github.event.pull_request.head.repo.fork + uses: marocchino/sticky-pull-request-comment@v2 + with: + path: coverage-report.md downstream: name: Downstream builds with Python ${{ matrix.python-version }} @@ -115,34 +125,34 @@ jobs: strategy: fail-fast: false matrix: - python-version: ['3.9', '3.10', '3.11', '3.12', '3.13'] + python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"] steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v4 - - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v5 - with: - python-version: ${{ matrix.python-version }} + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} - - name: Install tools - run: pip install --upgrade -r backend/tests/downstream/requirements.txt + - name: Install tools + run: pip install --upgrade -r backend/tests/downstream/requirements.txt - - name: Build downstream projects - run: python backend/tests/downstream/integrate.py + - name: Build downstream projects + run: python backend/tests/downstream/integrate.py # https://github.com/marketplace/actions/alls-green#why check: # This job does nothing and is only used for the branch protection if: always() needs: - - coverage - - downstream + - coverage + - downstream runs-on: ubuntu-latest steps: - - name: Decide whether the needed jobs succeeded or failed - uses: re-actors/alls-green@release/v1 - with: - jobs: ${{ toJSON(needs) }} + - name: Decide whether the needed jobs succeeded or failed + uses: re-actors/alls-green@release/v1 + with: + jobs: ${{ toJSON(needs) }} diff --git a/hatch.toml b/hatch.toml index ef6dbc978..ccae88501 100644 --- a/hatch.toml +++ b/hatch.toml @@ -1,5 +1,3 @@ -[workpace] -members = ["hatch/*"] [envs.hatch-static-analysis] config-path = "ruff_defaults.toml" @@ -7,6 +5,16 @@ config-path = "ruff_defaults.toml" [envs.default] installer = "uv" +[envs.workspace-test] +dependencies = [ + "pytest", +] +[envs.workspace-test.scripts] +validate = [ + "python -c 'import hatchling; print(\"Workspace member hatchling available\")'", + "python -c 'import hatch; print(\"Main project hatch available\")'", +] + [envs.hatch-test] extra-dependencies = [ "filelock", From 8261fce6c21109c26692f6d9b23805ec4427d4ce Mon Sep 17 00:00:00 2001 From: Cary Hawkins Date: Fri, 10 Oct 2025 11:11:04 -0700 Subject: [PATCH 40/55] Dogfood testing workspaces in CI --- .github/workflows/build-hatch.yml | 4 +++- .github/workflows/docs-dev.yml | 4 +++- .github/workflows/docs-release.yml | 3 +++ 3 files changed, 9 insertions(+), 2 deletions(-) diff --git a/.github/workflows/build-hatch.yml b/.github/workflows/build-hatch.yml index 74183a5e1..e0258d208 100644 --- a/.github/workflows/build-hatch.yml +++ b/.github/workflows/build-hatch.yml @@ -54,8 +54,10 @@ jobs: - name: Verify workspace setup run: | + # Verify that hatchling is installed as editable from workspace + hatch run uv pip list --editable | grep hatchling python -c "import hatchling; print('Hatchling available from workspace')" - hatch version + # Windows installers don't accept non-integer versions so we ubiquitously # perform the following transformation: X.Y.Z.devN -> X.Y.Z.N diff --git a/.github/workflows/docs-dev.yml b/.github/workflows/docs-dev.yml index 02f9b7915..79923ccb8 100644 --- a/.github/workflows/docs-dev.yml +++ b/.github/workflows/docs-dev.yml @@ -41,8 +41,10 @@ jobs: - name: Verify workspace setup run: | + # Verify that hatchling is installed as editable from workspace + hatch run uv pip list --editable | grep hatchling python -c "import hatchling; print('Hatchling available from workspace')" - hatch version + - name: Configure Git for GitHub Actions bot run: | diff --git a/.github/workflows/docs-release.yml b/.github/workflows/docs-release.yml index 0dfd87404..7bd1f294a 100644 --- a/.github/workflows/docs-release.yml +++ b/.github/workflows/docs-release.yml @@ -39,7 +39,10 @@ jobs: - name: Verify workspace setup run: | + # Verify that hatchling is installed as editable from workspace + hatch run uv pip list --editable | grep hatchling python -c "import hatchling; print('Hatchling available from workspace')" + - name: Display full version run: hatch version From aaa232c4d760aaf0cdbaa1ffde4b0fd4f60b35bf Mon Sep 17 00:00:00 2001 From: Cary Hawkins Date: Fri, 10 Oct 2025 11:11:59 -0700 Subject: [PATCH 41/55] Remove unnecessary scripts for dogfooding --- hatch.toml | 5 ----- 1 file changed, 5 deletions(-) diff --git a/hatch.toml b/hatch.toml index ccae88501..a2bdb92a8 100644 --- a/hatch.toml +++ b/hatch.toml @@ -9,11 +9,6 @@ installer = "uv" dependencies = [ "pytest", ] -[envs.workspace-test.scripts] -validate = [ - "python -c 'import hatchling; print(\"Workspace member hatchling available\")'", - "python -c 'import hatch; print(\"Main project hatch available\")'", -] [envs.hatch-test] extra-dependencies = [ From 56e809bf907b699b1d923d7852dc971da937932b Mon Sep 17 00:00:00 2001 From: Cary Hawkins Date: Fri, 10 Oct 2025 11:18:40 -0700 Subject: [PATCH 42/55] Fix to ensure env creation happens before verifying hatchling --- .github/workflows/build-hatch.yml | 5 ++++- .github/workflows/docs-dev.yml | 1 + .github/workflows/docs-release.yml | 1 + .github/workflows/test.yml | 4 +--- 4 files changed, 7 insertions(+), 4 deletions(-) diff --git a/.github/workflows/build-hatch.yml b/.github/workflows/build-hatch.yml index e0258d208..29e5d9cd4 100644 --- a/.github/workflows/build-hatch.yml +++ b/.github/workflows/build-hatch.yml @@ -51,6 +51,7 @@ jobs: run: |- uv pip install --system build uv pip install --system . + hatch env create - name: Verify workspace setup run: | @@ -172,11 +173,13 @@ jobs: - name: Install Hatch run: |- uv pip install --system -e . + hatch env create - name: Verify workspace setup run: | + hatch run uv pip list --editable | grep hatchling python -c "import hatchling; print('Hatchling available from workspace')" - hatch --version + - name: Install Rust toolchain uses: dtolnay/rust-toolchain@stable diff --git a/.github/workflows/docs-dev.yml b/.github/workflows/docs-dev.yml index 79923ccb8..484ab6f59 100644 --- a/.github/workflows/docs-dev.yml +++ b/.github/workflows/docs-dev.yml @@ -38,6 +38,7 @@ jobs: - name: Install ourself run: | uv pip install --system -e . + hatch env create - name: Verify workspace setup run: | diff --git a/.github/workflows/docs-release.yml b/.github/workflows/docs-release.yml index 7bd1f294a..584fcd15b 100644 --- a/.github/workflows/docs-release.yml +++ b/.github/workflows/docs-release.yml @@ -36,6 +36,7 @@ jobs: - name: Install ourself run: | uv pip install --system -e . + hatch env create - name: Verify workspace setup run: | diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index ecd5b12b0..278edcb87 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -40,6 +40,7 @@ jobs: - name: Install ourself run: | uv pip install --system -e . + hatch env create - name: Validate workspace functionality run: | @@ -55,9 +56,6 @@ jobs: - name: Check types run: hatch run types:check - - name: Test workspace functionality - run: hatch run workspace-test:validate - - name: Run tests run: hatch test --python ${{ matrix.python-version }} --cover-quiet --randomize --parallel --retries 5 --retry-delay 3 From 89d105ecae54fe5b37e0d05bc410e6348f5f4441 Mon Sep 17 00:00:00 2001 From: Cary Hawkins Date: Fri, 10 Oct 2025 11:47:15 -0700 Subject: [PATCH 43/55] Revert changes for CI --- .github/workflows/build-hatch.yml | 15 ++------------- .github/workflows/docs-dev.yml | 9 +-------- .github/workflows/docs-release.yml | 9 +-------- .github/workflows/test.yml | 10 +--------- 4 files changed, 5 insertions(+), 38 deletions(-) diff --git a/.github/workflows/build-hatch.yml b/.github/workflows/build-hatch.yml index 29e5d9cd4..7eeb8b6b5 100644 --- a/.github/workflows/build-hatch.yml +++ b/.github/workflows/build-hatch.yml @@ -51,13 +51,7 @@ jobs: run: |- uv pip install --system build uv pip install --system . - hatch env create - - - name: Verify workspace setup - run: | - # Verify that hatchling is installed as editable from workspace - hatch run uv pip list --editable | grep hatchling - python -c "import hatchling; print('Hatchling available from workspace')" + uv pip install --system ./backend # Windows installers don't accept non-integer versions so we ubiquitously @@ -173,12 +167,7 @@ jobs: - name: Install Hatch run: |- uv pip install --system -e . - hatch env create - - - name: Verify workspace setup - run: | - hatch run uv pip list --editable | grep hatchling - python -c "import hatchling; print('Hatchling available from workspace')" + uv pip install --system ./backend - name: Install Rust toolchain diff --git a/.github/workflows/docs-dev.yml b/.github/workflows/docs-dev.yml index 484ab6f59..b4872a38e 100644 --- a/.github/workflows/docs-dev.yml +++ b/.github/workflows/docs-dev.yml @@ -38,14 +38,7 @@ jobs: - name: Install ourself run: | uv pip install --system -e . - hatch env create - - - name: Verify workspace setup - run: | - # Verify that hatchling is installed as editable from workspace - hatch run uv pip list --editable | grep hatchling - python -c "import hatchling; print('Hatchling available from workspace')" - + uv pip install --system ./backend - name: Configure Git for GitHub Actions bot run: | diff --git a/.github/workflows/docs-release.yml b/.github/workflows/docs-release.yml index 584fcd15b..c751fdfcf 100644 --- a/.github/workflows/docs-release.yml +++ b/.github/workflows/docs-release.yml @@ -36,14 +36,7 @@ jobs: - name: Install ourself run: | uv pip install --system -e . - hatch env create - - - name: Verify workspace setup - run: | - # Verify that hatchling is installed as editable from workspace - hatch run uv pip list --editable | grep hatchling - python -c "import hatchling; print('Hatchling available from workspace')" - + uv pip install --system ./backend - name: Display full version run: hatch version diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 278edcb87..eb9afc952 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -40,15 +40,7 @@ jobs: - name: Install ourself run: | uv pip install --system -e . - hatch env create - - - name: Validate workspace functionality - run: | - # Verify that hatchling is installed as editable from workspace - hatch run uv pip list --editable | grep hatchling - - # Test that hatch commands work with workspace member - python -c "import hatchling; print('Hatchling imported successfully from workspace')" + uv pip install --system ./backend - name: Run static analysis run: hatch fmt --check From 55027344dab2bc0d4d718bb6873da62c024a0a39 Mon Sep 17 00:00:00 2001 From: Cary Hawkins Date: Tue, 14 Oct 2025 01:20:05 -0700 Subject: [PATCH 44/55] Change error type to enable dogfooding workspaces in CI --- .github/workflows/build-hatch.yml | 6 ++---- .github/workflows/docs-dev.yml | 2 +- .github/workflows/docs-release.yml | 2 +- .github/workflows/test.yml | 2 +- src/hatch/project/config.py | 4 +--- 5 files changed, 6 insertions(+), 10 deletions(-) diff --git a/.github/workflows/build-hatch.yml b/.github/workflows/build-hatch.yml index 7eeb8b6b5..ba1d5bd53 100644 --- a/.github/workflows/build-hatch.yml +++ b/.github/workflows/build-hatch.yml @@ -51,8 +51,7 @@ jobs: run: |- uv pip install --system build uv pip install --system . - uv pip install --system ./backend - + hatch env create # Windows installers don't accept non-integer versions so we ubiquitously # perform the following transformation: X.Y.Z.devN -> X.Y.Z.N @@ -167,8 +166,7 @@ jobs: - name: Install Hatch run: |- uv pip install --system -e . - uv pip install --system ./backend - + hatch env create - name: Install Rust toolchain uses: dtolnay/rust-toolchain@stable diff --git a/.github/workflows/docs-dev.yml b/.github/workflows/docs-dev.yml index b4872a38e..a6b29fc18 100644 --- a/.github/workflows/docs-dev.yml +++ b/.github/workflows/docs-dev.yml @@ -38,7 +38,7 @@ jobs: - name: Install ourself run: | uv pip install --system -e . - uv pip install --system ./backend + hatch env create - name: Configure Git for GitHub Actions bot run: | diff --git a/.github/workflows/docs-release.yml b/.github/workflows/docs-release.yml index c751fdfcf..1253571c1 100644 --- a/.github/workflows/docs-release.yml +++ b/.github/workflows/docs-release.yml @@ -36,7 +36,7 @@ jobs: - name: Install ourself run: | uv pip install --system -e . - uv pip install --system ./backend + hatch env create - name: Display full version run: hatch version diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index eb9afc952..c1f540041 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -40,7 +40,7 @@ jobs: - name: Install ourself run: | uv pip install --system -e . - uv pip install --system ./backend + hatch env create - name: Run static analysis run: hatch fmt --check diff --git a/src/hatch/project/config.py b/src/hatch/project/config.py index 3c180145b..62d8109e5 100644 --- a/src/hatch/project/config.py +++ b/src/hatch/project/config.py @@ -157,10 +157,8 @@ def envs(self): for collector, collector_config in self.env_collectors.items(): collector_class = self.plugin_manager.environment_collector.get(collector) if collector_class is None: - from hatchling.plugin.exceptions import UnknownPluginError - message = f"Unknown environment collector: {collector}" - raise UnknownPluginError(message) + raise ValueError(message) environment_collector = collector_class(self.root, collector_config) environment_collectors.append(environment_collector) From 1a5d7aec725017efc8df49200db801e6ef25ab3b Mon Sep 17 00:00:00 2001 From: Cary Hawkins Date: Tue, 14 Oct 2025 01:22:32 -0700 Subject: [PATCH 45/55] Add back dependency on hatchling --- pyproject.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/pyproject.toml b/pyproject.toml index f1082d63a..5c7a0bba0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -39,6 +39,7 @@ classifiers = [ ] dependencies = [ "click>=8.0.6", + "hatchling>=1.24.2", "httpx>=0.22.0", "hyperlink>=21.0.0", "keyring>=23.5.0", From f55f61ad6874d59c8029194845bab5dc39a4f204 Mon Sep 17 00:00:00 2001 From: Cary Hawkins Date: Wed, 22 Oct 2025 07:55:51 -0700 Subject: [PATCH 46/55] Add conflict logic for workspace members that are also declared dependencies. Fix formatting issue of workflow file. --- .github/workflows/test.yml | 4 ++-- src/hatch/cli/__init__.py | 4 ++-- src/hatch/env/plugin/interface.py | 27 ++++++++++++++++++++++----- src/hatch/env/virtual.py | 20 ++++++++++++++++++++ 4 files changed, 46 insertions(+), 9 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index c1f540041..afa2698c4 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -3,10 +3,10 @@ name: test on: push: branches: - - master + - master pull_request: branches: - - master + - master concurrency: group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.sha }} diff --git a/src/hatch/cli/__init__.py b/src/hatch/cli/__init__.py index 8fde92dc0..fd02d0c85 100644 --- a/src/hatch/cli/__init__.py +++ b/src/hatch/cli/__init__.py @@ -37,12 +37,12 @@ def find_workspace_root(path: Path) -> Path | None: while current.parent != current: # Check hatch.toml first hatch_toml = current / "hatch.toml" - if hatch_toml.exists() and _has_workspace_config(load_toml_file, str(hatch_toml), "workspace"): + if hatch_toml.is_file() and _has_workspace_config(load_toml_file, str(hatch_toml), "workspace"): return current # Then check pyproject.toml pyproject = current / "pyproject.toml" - if pyproject.exists() and _has_workspace_config(load_toml_file, str(pyproject), "tool.hatch.workspace"): + if pyproject.is_file() and _has_workspace_config(load_toml_file, str(pyproject), "tool.hatch.workspace"): return current current = current.parent diff --git a/src/hatch/env/plugin/interface.py b/src/hatch/env/plugin/interface.py index 9e5b19330..fc7c2eaa4 100644 --- a/src/hatch/env/plugin/interface.py +++ b/src/hatch/env/plugin/interface.py @@ -3,7 +3,6 @@ import os import sys from abc import ABC, abstractmethod -from collections.abc import Generator from contextlib import contextmanager from functools import cached_property from os.path import isabs @@ -146,6 +145,13 @@ def config(self) -> dict: """ return self.__config + @property + def project_config(self) -> dict: + """ + This returns the top level project config when we are in a workspace monorepo type of environment + """ + return getattr(self.app.project.config, "workspace", None) if self.app.project else None + @cached_property def project_root(self) -> str: """ @@ -393,9 +399,20 @@ def dependencies(self) -> list[str]: def all_dependencies_complex(self) -> list[Dependency]: from hatch.dep.core import Dependency - all_dependencies_complex = list(self.local_dependencies_complex) - all_dependencies_complex.extend(self.dependencies_complex) - return [dep if isinstance(dep, Dependency) else Dependency(str(dep)) for dep in all_dependencies_complex] + local_deps = list(self.local_dependencies_complex) + other_deps = list(self.dependencies_complex) + + # Create workspace member name set for conflict detection + workspace_names = {dep.name.lower() for dep in local_deps} + + # Filter out conflicting dependencies, keeping only workspace versions + filtered_deps = [ + dep if isinstance(dep, Dependency) else Dependency(str(dep)) + for dep in other_deps + if dep.name.lower() not in workspace_names + ] + # Workspace members first to ensure precedence + return local_deps + filtered_deps @cached_property def all_dependencies(self) -> list[str]: @@ -596,7 +613,7 @@ def post_install_commands(self): @cached_property def workspace(self) -> Workspace: # Start with project-level workspace configuration - project_workspace_config = getattr(self.app.project.config, "workspace", None) if self.app.project else None + project_workspace_config = self.project_config # Get environment-level workspace configuration env_config = self.config.get("workspace", {}) diff --git a/src/hatch/env/virtual.py b/src/hatch/env/virtual.py index faaeedf9c..81817f0d4 100644 --- a/src/hatch/env/virtual.py +++ b/src/hatch/env/virtual.py @@ -198,14 +198,34 @@ def dependencies_in_sync(self): def sync_dependencies(self): with self.safe_activation(): + # Get workspace member names for conflict resolution + workspace_names = { + dep.name.lower() for dep in self.local_dependencies_complex + } + + # Separate dependencies by type and filter conflicts standard_dependencies: list[str] = [] editable_dependencies: list[str] = [] + for dependency in self.missing_dependencies: + # Skip if workspace member exists + if dependency.name.lower() in workspace_names: + continue + if not dependency.editable or dependency.path is None: standard_dependencies.append(str(dependency)) else: editable_dependencies.append(str(dependency.path)) + # Install workspace members first + workspace_deps = [str(dep.path) for dep in self.local_dependencies_complex] + if workspace_deps: + editable_args = [] + for dep_path in workspace_deps: + editable_args.extend(["--editable", dep_path]) + self.platform.check_command(self.construct_pip_install_command(editable_args)) + + # Then install other dependencies if standard_dependencies: self.platform.check_command(self.construct_pip_install_command(standard_dependencies)) From a136f72be942b8027a3f73b7191efffa35f37c1e Mon Sep 17 00:00:00 2001 From: Cary Hawkins Date: Wed, 22 Oct 2025 08:43:33 -0700 Subject: [PATCH 47/55] Fix formatting for changes --- src/hatch/env/plugin/interface.py | 2 +- src/hatch/env/virtual.py | 4 +--- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/src/hatch/env/plugin/interface.py b/src/hatch/env/plugin/interface.py index fc7c2eaa4..6215fe34d 100644 --- a/src/hatch/env/plugin/interface.py +++ b/src/hatch/env/plugin/interface.py @@ -148,7 +148,7 @@ def config(self) -> dict: @property def project_config(self) -> dict: """ - This returns the top level project config when we are in a workspace monorepo type of environment + This returns the top level project config when we are in a workspace monorepo type of environment """ return getattr(self.app.project.config, "workspace", None) if self.app.project else None diff --git a/src/hatch/env/virtual.py b/src/hatch/env/virtual.py index 81817f0d4..bc40577a0 100644 --- a/src/hatch/env/virtual.py +++ b/src/hatch/env/virtual.py @@ -199,9 +199,7 @@ def dependencies_in_sync(self): def sync_dependencies(self): with self.safe_activation(): # Get workspace member names for conflict resolution - workspace_names = { - dep.name.lower() for dep in self.local_dependencies_complex - } + workspace_names = {dep.name.lower() for dep in self.local_dependencies_complex} # Separate dependencies by type and filter conflicts standard_dependencies: list[str] = [] From 718be5e49bf516e906a26447b4626d0b33a4f376 Mon Sep 17 00:00:00 2001 From: Cary Hawkins Date: Wed, 22 Oct 2025 08:52:42 -0700 Subject: [PATCH 48/55] Fix typing issues for project config --- src/hatch/env/plugin/interface.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/hatch/env/plugin/interface.py b/src/hatch/env/plugin/interface.py index 6215fe34d..a4a4c4ee2 100644 --- a/src/hatch/env/plugin/interface.py +++ b/src/hatch/env/plugin/interface.py @@ -150,7 +150,7 @@ def project_config(self) -> dict: """ This returns the top level project config when we are in a workspace monorepo type of environment """ - return getattr(self.app.project.config, "workspace", None) if self.app.project else None + return self.app.project @cached_property def project_root(self) -> str: @@ -613,7 +613,9 @@ def post_install_commands(self): @cached_property def workspace(self) -> Workspace: # Start with project-level workspace configuration - project_workspace_config = self.project_config + project_workspace_config = None + if self.project_config: + project_workspace_config = getattr(self.project_config, "workspace_config", None) # Get environment-level workspace configuration env_config = self.config.get("workspace", {}) From b140f13fa675bb3c09cf7a921c4373c03e925738 Mon Sep 17 00:00:00 2001 From: Cary Hawkins Date: Wed, 22 Oct 2025 11:53:50 -0700 Subject: [PATCH 49/55] Fix bug for logic handling conflicts --- pyproject.toml | 4 ++-- src/hatch/env/plugin/interface.py | 6 +++--- src/hatch/env/virtual.py | 22 +++++++++++----------- 3 files changed, 16 insertions(+), 16 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 5c7a0bba0..9622f0284 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -39,7 +39,7 @@ classifiers = [ ] dependencies = [ "click>=8.0.6", - "hatchling>=1.24.2", + "hatchling>=1.27.0", "httpx>=0.22.0", "hyperlink>=21.0.0", "keyring>=23.5.0", @@ -69,7 +69,7 @@ Source = "https://github.com/pypa/hatch" hatch = "hatch.cli:main" [tool.hatch.workspace] -members = ["backend"] +members = ["backend/"] [tool.hatch.version] source = "vcs" diff --git a/src/hatch/env/plugin/interface.py b/src/hatch/env/plugin/interface.py index a4a4c4ee2..21d3ba8d9 100644 --- a/src/hatch/env/plugin/interface.py +++ b/src/hatch/env/plugin/interface.py @@ -612,10 +612,10 @@ def post_install_commands(self): @cached_property def workspace(self) -> Workspace: - # Start with project-level workspace configuration + # Get project-level workspace configuration project_workspace_config = None - if self.project_config: - project_workspace_config = getattr(self.project_config, "workspace_config", None) + if hasattr(self.app, 'project') and hasattr(self.app.project, 'config'): + project_workspace_config = self.app.project.config.workspace # Get environment-level workspace configuration env_config = self.config.get("workspace", {}) diff --git a/src/hatch/env/virtual.py b/src/hatch/env/virtual.py index bc40577a0..4687a6840 100644 --- a/src/hatch/env/virtual.py +++ b/src/hatch/env/virtual.py @@ -198,15 +198,23 @@ def dependencies_in_sync(self): def sync_dependencies(self): with self.safe_activation(): + # Install workspace members first as editable + workspace_deps = [str(dep.path) for dep in self.local_dependencies_complex if dep.path] + if workspace_deps: + editable_args = [] + for dep_path in workspace_deps: + editable_args.extend(["--editable", dep_path]) + self.platform.check_command(self.construct_pip_install_command(editable_args)) + # Get workspace member names for conflict resolution workspace_names = {dep.name.lower() for dep in self.local_dependencies_complex} - # Separate dependencies by type and filter conflicts + # Separate remaining dependencies by type and filter conflicts standard_dependencies: list[str] = [] editable_dependencies: list[str] = [] for dependency in self.missing_dependencies: - # Skip if workspace member exists + # Skip if workspace member (already installed above) if dependency.name.lower() in workspace_names: continue @@ -215,15 +223,7 @@ def sync_dependencies(self): else: editable_dependencies.append(str(dependency.path)) - # Install workspace members first - workspace_deps = [str(dep.path) for dep in self.local_dependencies_complex] - if workspace_deps: - editable_args = [] - for dep_path in workspace_deps: - editable_args.extend(["--editable", dep_path]) - self.platform.check_command(self.construct_pip_install_command(editable_args)) - - # Then install other dependencies + # Install other dependencies if standard_dependencies: self.platform.check_command(self.construct_pip_install_command(standard_dependencies)) From 36570a8f73902af8aa931c294ca45ece754b147a Mon Sep 17 00:00:00 2001 From: Cary Hawkins Date: Wed, 22 Oct 2025 11:55:21 -0700 Subject: [PATCH 50/55] Chore: Formatting --- src/hatch/env/plugin/interface.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/hatch/env/plugin/interface.py b/src/hatch/env/plugin/interface.py index 21d3ba8d9..23346b2b6 100644 --- a/src/hatch/env/plugin/interface.py +++ b/src/hatch/env/plugin/interface.py @@ -614,7 +614,7 @@ def post_install_commands(self): def workspace(self) -> Workspace: # Get project-level workspace configuration project_workspace_config = None - if hasattr(self.app, 'project') and hasattr(self.app.project, 'config'): + if hasattr(self.app, "project") and hasattr(self.app.project, "config"): project_workspace_config = self.app.project.config.workspace # Get environment-level workspace configuration From b042bb5db91813f60c38a5452ca2ee8a8314e696 Mon Sep 17 00:00:00 2001 From: Cary Hawkins Date: Thu, 30 Oct 2025 16:58:23 -0700 Subject: [PATCH 51/55] Refactor to be configured at the environment level and including documentation with examples --- docs/how-to/environment/workspace.md | 302 +++++++++++++++++++++++++++ pyproject.toml | 3 +- src/hatch/cli/__init__.py | 48 +---- src/hatch/env/plugin/interface.py | 94 ++++----- src/hatch/env/utils.py | 32 +++ src/hatch/env/virtual.py | 7 +- src/hatch/project/config.py | 97 --------- tests/env/plugin/test_interface.py | 149 ++++++------- tests/workspaces/configuration.py | 125 ++++++----- 9 files changed, 524 insertions(+), 333 deletions(-) create mode 100644 docs/how-to/environment/workspace.md diff --git a/docs/how-to/environment/workspace.md b/docs/how-to/environment/workspace.md new file mode 100644 index 000000000..288c1374e --- /dev/null +++ b/docs/how-to/environment/workspace.md @@ -0,0 +1,302 @@ +# How to configure workspace environments + +----- + +Workspace environments allow you to manage multiple related packages within a single environment. This is useful for monorepos or projects with multiple interdependent packages. + +## Basic workspace configuration + +Define workspace members in your environment configuration using the `workspace.members` option: + +```toml config-example +[tool.hatch.envs.default] +workspace.members = [ + "packages/core", + "packages/utils", + "packages/cli" +] +``` + +Workspace members are automatically installed as editable packages in the environment. + +## Pattern matching + +Use glob patterns to automatically discover workspace members: + +```toml config-example +[tool.hatch.envs.default] +workspace.members = ["packages/*"] +``` + +## Excluding members + +Exclude specific packages from workspace discovery: + +```toml config-example +[tool.hatch.envs.default] +workspace.members = ["packages/*"] +workspace.exclude = ["packages/experimental*"] +``` + +## Member-specific features + +Install specific optional dependencies for workspace members: + +```toml config-example +[tool.hatch.envs.default] +workspace.members = [ + {path = "packages/core", features = ["dev"]}, + {path = "packages/utils", features = ["test", "docs"]}, + "packages/cli" +] +``` + +## Environment-specific workspaces + +Different environments can include different workspace members: + +```toml config-example +[tool.hatch.envs.unit-tests] +workspace.members = ["packages/core", "packages/utils"] +scripts.test = "pytest tests/unit" + +[tool.hatch.envs.integration-tests] +workspace.members = ["packages/*"] +scripts.test = "pytest tests/integration" + +[tool.hatch.envs.docs] +workspace.members = [ + {path = "packages/core", features = ["docs"]}, + {path = "packages/utils", features = ["docs"]} +] +``` + +## Test matrices with workspaces + +Combine workspace configuration with test matrices: + +```toml config-example +[[tool.hatch.envs.test.matrix]] +python = ["3.9", "3.10", "3.11", "3.12"] + +[tool.hatch.envs.test] +workspace.members = ["packages/*"] +dependencies = ["pytest", "coverage"] +scripts.test = "pytest {args}" + +[[tool.hatch.envs.test-core.matrix]] +python = ["3.9", "3.10", "3.11", "3.12"] + +[tool.hatch.envs.test-core] +workspace.members = ["packages/core"] +dependencies = ["pytest", "coverage"] +scripts.test = "pytest packages/core/tests {args}" +``` + +## Performance optimization + +Enable parallel dependency resolution for faster environment setup: + +```toml config-example +[tool.hatch.envs.default] +workspace.members = ["packages/*"] +workspace.parallel = true +``` + +## Cross-member dependencies + +Workspace members can depend on each other. Hatch automatically handles the installation order: + +```toml config-example title="packages/app/pyproject.toml" +[project] +name = "app" +dependencies = ["core", "utils"] +``` + +```toml config-example title="pyproject.toml" +[tool.hatch.envs.default] +workspace.members = [ + "packages/core", + "packages/utils", + "packages/app" +] +``` + +The `core` and `utils` packages will be installed before `app` to satisfy dependencies. + +## Monorepo example + +Complete configuration for a typical monorepo structure: + +```toml config-example +# Root pyproject.toml +[project] +name = "my-monorepo" +version = "1.0.0" + +[tool.hatch.envs.default] +workspace.members = ["packages/*"] +workspace.exclude = ["packages/experimental*"] +workspace.parallel = true +dependencies = ["pytest", "black", "ruff"] + +[tool.hatch.envs.test] +workspace.members = [ + {path = "packages/core", features = ["test"]}, + {path = "packages/utils", features = ["test"]}, + "packages/cli" +] +dependencies = ["pytest", "coverage", "pytest-cov"] +scripts.test = "pytest --cov {args}" + +[tool.hatch.envs.lint] +detached = true +workspace.members = ["packages/*"] +dependencies = ["ruff", "black", "mypy"] +scripts.check = ["ruff check .", "black --check .", "mypy ."] +scripts.fmt = ["ruff check --fix .", "black ."] + +[[tool.hatch.envs.ci.matrix]] +python = ["3.9", "3.10", "3.11", "3.12"] + +[tool.hatch.envs.ci] +template = "test" +workspace.parallel = false # Disable for CI stability +``` + +## Library with plugins example + +Configuration for a library with optional plugins: + +```toml config-example +[tool.hatch.envs.default] +workspace.members = ["core"] +dependencies = ["pytest"] + +[tool.hatch.envs.full] +workspace.members = [ + "core", + "plugins/database", + "plugins/cache", + "plugins/auth" +] +dependencies = ["pytest", "pytest-asyncio"] + +[tool.hatch.envs.database-only] +workspace.members = [ + "core", + {path = "plugins/database", features = ["postgresql", "mysql"]} +] + +[[tool.hatch.envs.plugin-test.matrix]] +plugin = ["database", "cache", "auth"] + +[tool.hatch.envs.plugin-test] +workspace.members = [ + "core", + "plugins/{matrix:plugin}" +] +scripts.test = "pytest plugins/{matrix:plugin}/tests {args}" +``` + +## Multi-service application example + +Configuration for microservices development: + +```toml config-example +[tool.hatch.envs.default] +workspace.members = ["shared"] +dependencies = ["pytest", "requests"] + +[tool.hatch.envs.api] +workspace.members = [ + "shared", + {path = "services/api", features = ["dev"]} +] +dependencies = ["fastapi", "uvicorn"] +scripts.dev = "uvicorn services.api.main:app --reload" + +[tool.hatch.envs.worker] +workspace.members = [ + "shared", + {path = "services/worker", features = ["dev"]} +] +dependencies = ["celery", "redis"] +scripts.dev = "celery -A services.worker.tasks worker --loglevel=info" + +[tool.hatch.envs.integration] +workspace.members = [ + "shared", + "services/api", + "services/worker", + "services/frontend" +] +dependencies = ["pytest", "httpx", "docker"] +scripts.test = "pytest tests/integration {args}" +``` + +## Documentation generation example + +Configuration for generating documentation across packages: + +```toml config-example +[tool.hatch.envs.docs] +workspace.members = [ + {path = "packages/core", features = ["docs"]}, + {path = "packages/cli", features = ["docs"]}, + {path = "packages/plugins", features = ["docs"]} +] +dependencies = [ + "mkdocs", + "mkdocs-material", + "mkdocstrings[python]" +] +scripts.serve = "mkdocs serve" +scripts.build = "mkdocs build" + +[tool.hatch.envs.docs-api-only] +workspace.members = [ + {path = "packages/core", features = ["docs"]} +] +template = "docs" +scripts.serve = "mkdocs serve --config-file mkdocs-api.yml" +``` + +## Development workflow example + +Configuration supporting different development workflows: + +```toml config-example +[tool.hatch.envs.dev] +workspace.members = ["packages/*"] +workspace.parallel = true +dependencies = [ + "pytest", + "black", + "ruff", + "mypy", + "pre-commit" +] +scripts.setup = "pre-commit install" +scripts.test = "pytest {args}" +scripts.lint = ["ruff check .", "black --check .", "mypy ."] +scripts.fmt = ["ruff check --fix .", "black ."] + +[tool.hatch.envs.feature] +template = "dev" +workspace.members = [ + "packages/core", + "packages/{env:FEATURE_PACKAGE}" +] +scripts.test = "pytest packages/{env:FEATURE_PACKAGE}/tests {args}" + +[[tool.hatch.envs.release.matrix]] +package = ["core", "utils", "cli"] + +[tool.hatch.envs.release] +detached = true +workspace.members = ["packages/{matrix:package}"] +dependencies = ["build", "twine"] +scripts.build = "python -m build packages/{matrix:package}" +scripts.publish = "twine upload packages/{matrix:package}/dist/*" +``` \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index 9622f0284..5eacc10e1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -22,6 +22,7 @@ keywords = [ ] authors = [ { name = "Ofek Lev", email = "oss@ofek.dev" }, + { name = "Cary Hawkins", email = "hawkinscary23@gmail.com" }, ] classifiers = [ "Development Status :: 5 - Production/Stable", @@ -68,8 +69,6 @@ Source = "https://github.com/pypa/hatch" [project.scripts] hatch = "hatch.cli:main" -[tool.hatch.workspace] -members = ["backend/"] [tool.hatch.version] source = "vcs" diff --git a/src/hatch/cli/__init__.py b/src/hatch/cli/__init__.py index fd02d0c85..286df6b34 100644 --- a/src/hatch/cli/__init__.py +++ b/src/hatch/cli/__init__.py @@ -29,38 +29,6 @@ from hatch.utils.fs import Path -def find_workspace_root(path: Path) -> Path | None: - """Find workspace root by traversing up from given path.""" - from hatch.utils.toml import load_toml_file - - current = path - while current.parent != current: - # Check hatch.toml first - hatch_toml = current / "hatch.toml" - if hatch_toml.is_file() and _has_workspace_config(load_toml_file, str(hatch_toml), "workspace"): - return current - - # Then check pyproject.toml - pyproject = current / "pyproject.toml" - if pyproject.is_file() and _has_workspace_config(load_toml_file, str(pyproject), "tool.hatch.workspace"): - return current - - current = current.parent - return None - - -def _has_workspace_config(load_func, file_path: str, config_path: str) -> bool: - """Check if file has workspace configuration, returning False on any error.""" - try: - config = load_func(file_path) - if config_path == "workspace": - return bool(config.get("workspace")) - # "tool.hatch.workspace" - return bool(config.get("tool", {}).get("hatch", {}).get("workspace")) - except (OSError, ValueError, TypeError, KeyError): - return False - - @click.group( context_settings={"help_option_names": ["-h", "--help"], "max_content_width": 120}, invoke_without_command=True ) @@ -202,20 +170,8 @@ def hatch(ctx: click.Context, env_name, project, verbose, quiet, color, interact app.project.set_app(app) return - # Discover workspace-aware project - workspace_root = find_workspace_root(Path.cwd()) - if workspace_root: - # Create project from workspace root with workspace context - app.project = Project(workspace_root, locate=False) - app.project.set_app(app) - # Set current member context if we're in a member directory - current_dir = Path.cwd() - if current_dir != workspace_root: - app.project.current_member_path = current_dir - else: - # No workspace, use current directory as before - app.project = Project(Path.cwd()) - app.project.set_app(app) + app.project = Project(Path.cwd()) + app.project.set_app(app) if app.config.mode == "local": return diff --git a/src/hatch/env/plugin/interface.py b/src/hatch/env/plugin/interface.py index 23346b2b6..dd88b6a26 100644 --- a/src/hatch/env/plugin/interface.py +++ b/src/hatch/env/plugin/interface.py @@ -146,11 +146,33 @@ def config(self) -> dict: return self.__config @property - def project_config(self) -> dict: + def workspace_members(self) -> list[str]: """ - This returns the top level project config when we are in a workspace monorepo type of environment + ``` + toml config-example + [tool.hatch.envs.] + members = ["Package/*"] + ``` """ - return self.app.project + workspace_config = self.config.get("workspace", {}) + if not isinstance(workspace_config, dict): + return [] + return workspace_config.get("members", []) + + @property + def workspace_exclude(self) -> list[str]: + """ + ``` + toml config-example + [tool.hatch.envs.] + members = ["Package/*"] + exclude = ["/foo"] + ``` + """ + workspace_config = self.config.get("workspace", {}) + if not isinstance(workspace_config, dict): + return [] + return workspace_config.get("exclude", []) @cached_property def project_root(self) -> str: @@ -612,32 +634,12 @@ def post_install_commands(self): @cached_property def workspace(self) -> Workspace: - # Get project-level workspace configuration - project_workspace_config = None - if hasattr(self.app, "project") and hasattr(self.app.project, "config"): - project_workspace_config = self.app.project.config.workspace - - # Get environment-level workspace configuration env_config = self.config.get("workspace", {}) if not isinstance(env_config, dict): message = f"Field `tool.hatch.envs.{self.name}.workspace` must be a table" raise TypeError(message) - # Merge configurations: project-level as base, environment-level as override - merged_config = {} - - # Inherit project-level members if no environment-level members specified - if project_workspace_config and project_workspace_config.members and "members" not in env_config: - merged_config["members"] = project_workspace_config.members - - # Inherit project-level exclude if no environment-level exclude specified - if project_workspace_config and project_workspace_config.exclude and "exclude" not in env_config: - merged_config["exclude"] = project_workspace_config.exclude - - # Apply environment-level overrides - merged_config.update(env_config) - - return Workspace(self, merged_config) + return Workspace(self, env_config) def activate(self): """ @@ -1102,7 +1104,7 @@ def members(self) -> list[WorkspaceMember]: if not raw_members: return [] - # First normalize configuration + # Normalize configuration member_data: list[dict[str, Any]] = [] for i, data in enumerate(raw_members, 1): if isinstance(data, str): @@ -1175,31 +1177,27 @@ def members(self) -> list[WorkspaceMember]: root = str(self.env.root) member_paths: dict[str, WorkspaceMember] = {} for data in member_data: - # Given root R and member spec M, we need to find: - # - # 1. The absolute path AP of R/M - # 2. The shared prefix SP of R and AP - # 3. The relative path RP of M from AP - # - # For example, if: - # - # R = /foo/bar/baz - # M = ../dir/pkg-* - # - # Then: - # - # AP = /foo/bar/dir/pkg-* - # SP = /foo/bar - # RP = dir/pkg-* path_spec = data["path"] - normalized_path = os.path.normpath(os.path.join(root, path_spec)) - absolute_path = os.path.abspath(normalized_path) - shared_prefix = os.path.commonprefix([root, absolute_path]) - relative_path = os.path.relpath(absolute_path, shared_prefix) - # Now we have the necessary information to perform an optimized glob search for members + # Convert path spec to components for find_members + if os.path.isabs(path_spec): + try: + relative_path = os.path.relpath(path_spec, root) + except ValueError: + relative_path = path_spec + else: + relative_path = path_spec + + # Normalize and split path - handle empty components + normalized_path = relative_path.replace("\\", "/") + path_components = [c for c in normalized_path.split("/") if c and c != "."] + + # Handle empty path components case + if not path_components: + path_components = ["."] + members_found = False - for member_path in find_members(root, relative_path.split(os.sep)): + for member_path in find_members(root, path_components): project_file = os.path.join(member_path, "pyproject.toml") if not os.path.isfile(project_file): message = ( @@ -1224,7 +1222,7 @@ def members(self) -> list[WorkspaceMember]: if not members_found: message = ( f"No members could be derived from `{path_spec}` of field " - f"`tool.hatch.envs.{self.env.name}.workspace.members`: {absolute_path}" + f"`tool.hatch.envs.{self.env.name}.workspace.members`: {os.path.join(root, relative_path)}" ) raise OSError(message) diff --git a/src/hatch/env/utils.py b/src/hatch/env/utils.py index f251407aa..62a0f4dbc 100644 --- a/src/hatch/env/utils.py +++ b/src/hatch/env/utils.py @@ -16,6 +16,38 @@ def get_env_var_option(*, plugin_name: str, option: str, default: str = "") -> s def ensure_valid_environment(env_config: dict): env_config.setdefault("type", "virtual") + workspace = env_config.get("workspace") + if workspace is not None: + if not isinstance(workspace, dict): + msg = "Field workspace must be a table" + raise TypeError(msg) + + members = workspace.get("members", []) + if not isinstance(members, list): + msg = "Field workspace.members must be an array" + raise TypeError(msg) + + # Validate each member + for i, member in enumerate(members, 1): + if isinstance(member, str): + continue + if isinstance(member, dict): + path = member.get("path") + if path is None: + msg = f"Member #{i} must define a `path` key" + raise TypeError(msg) + if not isinstance(path, str): + msg = f"Member #{i} path must be a string" + raise TypeError(msg) + else: + msg = f"Member #{i} must be a string or table" + raise TypeError(msg) + + exclude = workspace.get("exclude", []) + if not isinstance(exclude, list): + msg = "Field workspace.exclude must be an array" + raise TypeError(msg) + def get_verbosity_flag(verbosity: int, *, adjustment=0) -> str: verbosity += adjustment diff --git a/src/hatch/env/virtual.py b/src/hatch/env/virtual.py index 4687a6840..dec8b22ec 100644 --- a/src/hatch/env/virtual.py +++ b/src/hatch/env/virtual.py @@ -198,7 +198,7 @@ def dependencies_in_sync(self): def sync_dependencies(self): with self.safe_activation(): - # Install workspace members first as editable + # Install workspace members first as editable (already exists) workspace_deps = [str(dep.path) for dep in self.local_dependencies_complex if dep.path] if workspace_deps: editable_args = [] @@ -206,16 +206,13 @@ def sync_dependencies(self): editable_args.extend(["--editable", dep_path]) self.platform.check_command(self.construct_pip_install_command(editable_args)) - # Get workspace member names for conflict resolution - workspace_names = {dep.name.lower() for dep in self.local_dependencies_complex} - # Separate remaining dependencies by type and filter conflicts standard_dependencies: list[str] = [] editable_dependencies: list[str] = [] for dependency in self.missing_dependencies: # Skip if workspace member (already installed above) - if dependency.name.lower() in workspace_names: + if dependency.name.lower() in workspace_deps: continue if not dependency.editable or dependency.path is None: diff --git a/src/hatch/project/config.py b/src/hatch/project/config.py index 62d8109e5..794116c64 100644 --- a/src/hatch/project/config.py +++ b/src/hatch/project/config.py @@ -1,6 +1,5 @@ from __future__ import annotations -import os import re from copy import deepcopy from functools import cached_property @@ -12,7 +11,6 @@ from hatch.project.constants import DEFAULT_BUILD_DIRECTORY, BuildEnvVars from hatch.project.env import apply_overrides from hatch.project.utils import format_script_commands, parse_script_command -from hatch.utils.fs import Path if TYPE_CHECKING: from hatch.dep.core import Dependency @@ -501,14 +499,6 @@ def scripts(self): return self._scripts - @cached_property - def workspace(self) -> WorkspaceConfig: - config = self.config.get("workspace", {}) - if not isinstance(config, dict): - message = "Field `tool.hatch.workspace` must be a table" - raise TypeError(message) - return WorkspaceConfig(config, self.root) - def finalize_env_overrides(self, option_types): # We lazily apply overrides because we need type information potentially defined by # environment plugins for their options @@ -736,93 +726,6 @@ def finalize_hook_config(hook_config: dict[str, dict[str, Any]]) -> dict[str, di return final_hook_config -class WorkspaceConfig: - def __init__(self, config: dict[str, Any], root: Path): - self.__config = config - self.__root = root - - @cached_property - def members(self) -> list[str]: - members = self.__config.get("members", []) - if not isinstance(members, list): - message = "Field `tool.hatch.workspace.members` must be an array" - raise TypeError(message) - return members - - @cached_property - def exclude(self) -> list[str]: - exclude = self.__config.get("exclude", []) - if not isinstance(exclude, list): - message = "Field `tool.hatch.workspace.exclude` must be an array" - raise TypeError(message) - return exclude - - @cached_property - def discovered_member_paths(self) -> list[Path]: - """Discover workspace member paths using the existing find_members function.""" - from hatch.env.plugin.interface import find_members - - discovered_paths = [] - - for member_pattern in self.members: - # Convert to absolute path for processing - pattern_path = self.__root / member_pattern if not os.path.isabs(member_pattern) else Path(member_pattern) - - # Normalize and get relative components for find_members - normalized_path = os.path.normpath(str(pattern_path)) - absolute_path = os.path.abspath(normalized_path) - shared_prefix = os.path.commonprefix([str(self.__root), absolute_path]) - relative_path = os.path.relpath(absolute_path, shared_prefix) - - # Use existing find_members function - for member_path in find_members(str(self.__root), relative_path.split(os.sep)): - project_file = os.path.join(member_path, "pyproject.toml") - if os.path.isfile(project_file): - discovered_paths.append(Path(member_path)) - - return discovered_paths - - def validate_workspace_members(self) -> list[str]: - """Validate workspace members and return errors.""" - errors = [] - - for member_pattern in self.members: - try: - # Test if pattern finds any members - if not os.path.isabs(member_pattern): - pattern_path = self.__root / member_pattern - else: - pattern_path = Path(member_pattern) - - normalized_path = os.path.normpath(str(pattern_path)) - absolute_path = os.path.abspath(normalized_path) - shared_prefix = os.path.commonprefix([str(self.__root), absolute_path]) - relative_path = os.path.relpath(absolute_path, shared_prefix) - - from hatch.env.plugin.interface import find_members - - members_found = False - - for member_path in find_members(str(self.__root), relative_path.split(os.sep)): - project_file = os.path.join(member_path, "pyproject.toml") - if os.path.isfile(project_file): - members_found = True - break - - if not members_found: - errors.append(f"No workspace members found for pattern: {member_pattern}") - - except (OSError, ValueError, TypeError) as e: - errors.append(f"Error processing workspace member pattern '{member_pattern}': {e}") - - return errors - - @property - def config(self) -> dict[str, Any]: - """Access to raw config for backward compatibility.""" - return self.__config - - def env_var_enabled(env_var: str, *, default: bool = False) -> bool: if env_var in environ: return environ[env_var] in {"1", "true"} diff --git a/tests/env/plugin/test_interface.py b/tests/env/plugin/test_interface.py index 3763b0b50..5521eefbd 100644 --- a/tests/env/plugin/test_interface.py +++ b/tests/env/plugin/test_interface.py @@ -2156,21 +2156,19 @@ def test_not_table(self, isolation, isolated_data_dir, platform, global_applicat "tool": {"hatch": {"envs": {"default": {"workspace": 9000}}}}, } project = Project(isolation, config=config) - environment = MockEnvironment( - isolation, - project.metadata, - "default", - project.config.envs["default"], - {}, - isolated_data_dir, - isolated_data_dir, - platform, - 0, - global_application, - ) - - with pytest.raises(TypeError, match="Field `tool.hatch.envs.default.workspace` must be a table"): - _ = environment.workspace + with pytest.raises(TypeError, match="Field workspace must be a table"): + MockEnvironment( + isolation, + project.metadata, + "default", + project.config.envs["default"], # Exception raised here + {}, + isolated_data_dir, + isolated_data_dir, + platform, + 0, + global_application, + ) def test_parallel_not_boolean(self, isolation, isolated_data_dir, platform, global_application): config = { @@ -2239,21 +2237,19 @@ def test_members_not_table(self, isolation, isolated_data_dir, platform, global_ "tool": {"hatch": {"envs": {"default": {"workspace": {"members": 9000}}}}}, } project = Project(isolation, config=config) - environment = MockEnvironment( - isolation, - project.metadata, - "default", - project.config.envs["default"], - {}, - isolated_data_dir, - isolated_data_dir, - platform, - 0, - global_application, - ) - - with pytest.raises(TypeError, match="Field `tool.hatch.envs.default.workspace.members` must be an array"): - _ = environment.workspace.members + with pytest.raises(TypeError, match="Field workspace.members must be an array"): + MockEnvironment( + isolation, + project.metadata, + "default", + project.config.envs["default"], # Exception raised here + {}, + isolated_data_dir, + isolated_data_dir, + platform, + 0, + global_application, + ) def test_member_invalid_type(self, isolation, isolated_data_dir, platform, global_application): config = { @@ -2261,24 +2257,19 @@ def test_member_invalid_type(self, isolation, isolated_data_dir, platform, globa "tool": {"hatch": {"envs": {"default": {"workspace": {"members": [9000]}}}}}, } project = Project(isolation, config=config) - environment = MockEnvironment( - isolation, - project.metadata, - "default", - project.config.envs["default"], - {}, - isolated_data_dir, - isolated_data_dir, - platform, - 0, - global_application, - ) - - with pytest.raises( - TypeError, - match="Member #1 of field `tool.hatch.envs.default.workspace.members` must be a string or an inline table", - ): - _ = environment.workspace.members + with pytest.raises(TypeError, match="Member #1 must be a string or table"): + MockEnvironment( + isolation, + project.metadata, + "default", + project.config.envs["default"], # Exception raised here + {}, + isolated_data_dir, + isolated_data_dir, + platform, + 0, + global_application, + ) def test_member_no_path(self, isolation, isolated_data_dir, platform, global_application): config = { @@ -2286,24 +2277,19 @@ def test_member_no_path(self, isolation, isolated_data_dir, platform, global_app "tool": {"hatch": {"envs": {"default": {"workspace": {"members": [{}]}}}}}, } project = Project(isolation, config=config) - environment = MockEnvironment( - isolation, - project.metadata, - "default", - project.config.envs["default"], - {}, - isolated_data_dir, - isolated_data_dir, - platform, - 0, - global_application, - ) - - with pytest.raises( - TypeError, - match="Member #1 of field `tool.hatch.envs.default.workspace.members` must define a `path` key", - ): - _ = environment.workspace.members + with pytest.raises(TypeError, match="Member #1 must define a `path` key"): + MockEnvironment( + isolation, + project.metadata, + "default", + project.config.envs["default"], # Exception raised here + {}, + isolated_data_dir, + isolated_data_dir, + platform, + 0, + global_application, + ) def test_member_path_not_string(self, isolation, isolated_data_dir, platform, global_application): config = { @@ -2311,24 +2297,19 @@ def test_member_path_not_string(self, isolation, isolated_data_dir, platform, gl "tool": {"hatch": {"envs": {"default": {"workspace": {"members": [{"path": 9000}]}}}}}, } project = Project(isolation, config=config) - environment = MockEnvironment( - isolation, - project.metadata, - "default", - project.config.envs["default"], - {}, - isolated_data_dir, - isolated_data_dir, - platform, - 0, - global_application, - ) - - with pytest.raises( - TypeError, - match="Option `path` of member #1 of field `tool.hatch.envs.default.workspace.members` must be a string", - ): - _ = environment.workspace.members + with pytest.raises(TypeError, match="Member #1 path must be a string"): + MockEnvironment( + isolation, + project.metadata, + "default", + project.config.envs["default"], # Exception raised here + {}, + isolated_data_dir, + isolated_data_dir, + platform, + 0, + global_application, + ) def test_member_path_empty_string(self, isolation, isolated_data_dir, platform, global_application): config = { diff --git a/tests/workspaces/configuration.py b/tests/workspaces/configuration.py index 419d0589d..931f46a0e 100644 --- a/tests/workspaces/configuration.py +++ b/tests/workspaces/configuration.py @@ -1,28 +1,23 @@ class TestWorkspaceConfiguration: def test_workspace_members_editable_install(self, temp_dir, hatch): """Test that workspace members are installed as editable packages.""" - # Create workspace root workspace_root = temp_dir / "workspace" workspace_root.mkdir() - # Create workspace pyproject.toml workspace_config = workspace_root / "pyproject.toml" workspace_config.write_text(""" [project] name = "workspace-root" version = "0.1.0" -[tool.hatch.workspace] -members = ["packages/*"] [tool.hatch.envs.default] type = "virtual" +workspace.members = ["packages/*"] """) - # Create workspace members packages_dir = workspace_root / "packages" packages_dir.mkdir() - # Member 1 member1_dir = packages_dir / "member1" member1_dir.mkdir() (member1_dir / "pyproject.toml").write_text(""" @@ -32,7 +27,6 @@ def test_workspace_members_editable_install(self, temp_dir, hatch): dependencies = ["requests"] """) - # Member 2 member2_dir = packages_dir / "member2" member2_dir.mkdir() (member2_dir / "pyproject.toml").write_text(""" @@ -43,11 +37,9 @@ def test_workspace_members_editable_install(self, temp_dir, hatch): """) with workspace_root.as_cwd(): - # Test environment creation includes workspace members result = hatch("env", "create") assert result.exit_code == 0 - # Verify workspace members are discovered result = hatch("env", "show", "--json") assert result.exit_code == 0 @@ -61,15 +53,15 @@ def test_workspace_exclude_patterns(self, temp_dir, hatch): [project] name = "workspace-root" version = "0.1.0" -[tool.hatch.workspace] -members = ["packages/*"] -exclude = ["packages/excluded*"] + +[tool.hatch.envs.default] +workspace.members = ["packages/*"] +workspace.exclude = ["packages/excluded*"] """) packages_dir = workspace_root / "packages" packages_dir.mkdir() - # Included member included_dir = packages_dir / "included" included_dir.mkdir() (included_dir / "pyproject.toml").write_text(""" @@ -78,7 +70,6 @@ def test_workspace_exclude_patterns(self, temp_dir, hatch): version = "0.1.0" """) - # Excluded member excluded_dir = packages_dir / "excluded-pkg" excluded_dir.mkdir() (excluded_dir / "pyproject.toml").write_text(""" @@ -101,17 +92,15 @@ def test_workspace_parallel_dependency_resolution(self, temp_dir, hatch): [project] name = "workspace-root" version = "0.1.0" -[tool.hatch.workspace] -members = ["packages/*"] [tool.hatch.envs.default] +workspace.members = ["packages/*"] workspace.parallel = true """) packages_dir = workspace_root / "packages" packages_dir.mkdir() - # Create multiple members for i in range(3): member_dir = packages_dir / f"member{i}" member_dir.mkdir() @@ -136,6 +125,7 @@ def test_workspace_member_features(self, temp_dir, hatch): [project] name = "workspace-root" version = "0.1.0" + [tool.hatch.envs.default] workspace.members = [ {path = "packages/member1", features = ["dev", "test"]} @@ -166,14 +156,14 @@ def test_workspace_inheritance_from_root(self, temp_dir, hatch): workspace_root = temp_dir / "workspace" workspace_root.mkdir() - # Workspace root with shared environment workspace_config = workspace_root / "pyproject.toml" workspace_config.write_text(""" [project] name = "workspace-root" version = "0.1.0" -[tool.hatch.workspace] -members = ["packages/*"] + +[tool.hatch.envs.default] +workspace.members = ["packages/*"] [tool.hatch.envs.shared] dependencies = ["pytest", "black"] @@ -183,23 +173,21 @@ def test_workspace_inheritance_from_root(self, temp_dir, hatch): packages_dir = workspace_root / "packages" packages_dir.mkdir() - # Member without local shared environment member_dir = packages_dir / "member1" member_dir.mkdir() (member_dir / "pyproject.toml").write_text(""" [project] name = "member1" version = "0.1.0" + [tool.hatch.envs.default] dependencies = ["requests"] """) - # Test from workspace root with workspace_root.as_cwd(): result = hatch("env", "show", "shared") assert result.exit_code == 0 - # Test from member directory with member_dir.as_cwd(): result = hatch("env", "show", "shared") assert result.exit_code == 0 @@ -214,6 +202,7 @@ def test_workspace_no_members_fallback(self, temp_dir, hatch): [project] name = "workspace-root" version = "0.1.0" + [tool.hatch.envs.default] dependencies = ["requests"] """) @@ -235,14 +224,14 @@ def test_workspace_cross_member_dependencies(self, temp_dir, hatch): [project] name = "workspace-root" version = "0.1.0" -[tool.hatch.workspace] -members = ["packages/*"] + +[tool.hatch.envs.default] +workspace.members = ["packages/*"] """) packages_dir = workspace_root / "packages" packages_dir.mkdir() - # Base library base_dir = packages_dir / "base" base_dir.mkdir() (base_dir / "pyproject.toml").write_text(""" @@ -252,7 +241,6 @@ def test_workspace_cross_member_dependencies(self, temp_dir, hatch): dependencies = ["requests"] """) - # App depending on base app_dir = packages_dir / "app" app_dir.mkdir() (app_dir / "pyproject.toml").write_text(""" @@ -266,7 +254,6 @@ def test_workspace_cross_member_dependencies(self, temp_dir, hatch): result = hatch("env", "create") assert result.exit_code == 0 - # Test that dependencies are resolved result = hatch("dep", "show", "table") assert result.exit_code == 0 @@ -275,49 +262,46 @@ def test_workspace_build_all_members(self, temp_dir, hatch): workspace_root = temp_dir / "workspace" workspace_root.mkdir() - # Create workspace root package workspace_pkg = workspace_root / "workspace_root" workspace_pkg.mkdir() (workspace_pkg / "__init__.py").write_text('__version__ = "0.1.0"') workspace_config = workspace_root / "pyproject.toml" workspace_config.write_text(""" - [project] - name = "workspace-root" - version = "0.1.0" +[project] +name = "workspace-root" +version = "0.1.0" - [tool.hatch.workspace] - members = ["packages/*"] +[tool.hatch.envs.default] +workspace.members = ["packages/*"] - [build-system] - requires = ["hatchling"] - build-backend = "hatchling.build" +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" - [tool.hatch.build.targets.wheel] - packages = ["workspace_root"] - """) +[tool.hatch.build.targets.wheel] +packages = ["workspace_root"] +""") packages_dir = workspace_root / "packages" packages_dir.mkdir() - # Create buildable members for i in range(2): member_dir = packages_dir / f"member{i}" member_dir.mkdir() (member_dir / "pyproject.toml").write_text(f""" - [project] - name = "member{i}" - version = "0.1.{i}" +[project] +name = "member{i}" +version = "0.1.{i}" - [build-system] - requires = ["hatchling"] - build-backend = "hatchling.build" +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" - [tool.hatch.build.targets.wheel] - packages = ["member{i}"] - """) +[tool.hatch.build.targets.wheel] +packages = ["member{i}"] +""") - # Create source files src_dir = member_dir / f"member{i}" src_dir.mkdir() (src_dir / "__init__.py").write_text(f'__version__ = "0.1.{i}"') @@ -325,3 +309,42 @@ def test_workspace_build_all_members(self, temp_dir, hatch): with workspace_root.as_cwd(): result = hatch("build") assert result.exit_code == 0 + + def test_environment_specific_workspace_slices(self, temp_dir, hatch): + """Test different workspace slices per environment.""" + workspace_root = temp_dir / "workspace" + workspace_root.mkdir() + + workspace_config = workspace_root / "pyproject.toml" + workspace_config.write_text(""" +[project] +name = "workspace-root" +version = "0.1.0" + +[tool.hatch.envs.unit-tests] +workspace.members = ["packages/core", "packages/utils"] +scripts.test = "pytest tests/unit" + +[tool.hatch.envs.integration-tests] +workspace.members = ["packages/*"] +scripts.test = "pytest tests/integration" +""") + + packages_dir = workspace_root / "packages" + packages_dir.mkdir() + + for pkg in ["core", "utils", "extras"]: + pkg_dir = packages_dir / pkg + pkg_dir.mkdir() + (pkg_dir / "pyproject.toml").write_text(f""" +[project] +name = "{pkg}" +version = "0.1.0" +""") + + with workspace_root.as_cwd(): + result = hatch("env", "create", "unit-tests") + assert result.exit_code == 0 + + result = hatch("env", "create", "integration-tests") + assert result.exit_code == 0 From 35b258c68228a0aa5fd9280f633c110786e6a6fa Mon Sep 17 00:00:00 2001 From: Cary Hawkins Date: Thu, 30 Oct 2025 17:07:29 -0700 Subject: [PATCH 52/55] Fix dogfooding workspace --- hatch.toml | 2 +- pyproject.toml | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/hatch.toml b/hatch.toml index a2bdb92a8..8759cfa8c 100644 --- a/hatch.toml +++ b/hatch.toml @@ -11,12 +11,12 @@ dependencies = [ ] [envs.hatch-test] +workspace.members = ["backend/"] extra-dependencies = [ "filelock", "flit-core", "pyfakefs", "trustme", - # Hatchling dynamic dependency "editables", ] extra-args = ["--dist", "worksteal"] diff --git a/pyproject.toml b/pyproject.toml index 5eacc10e1..b66427997 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -69,7 +69,6 @@ Source = "https://github.com/pypa/hatch" [project.scripts] hatch = "hatch.cli:main" - [tool.hatch.version] source = "vcs" From 65470c7eb37e3a9ad7e76e03500daf17f1106656 Mon Sep 17 00:00:00 2001 From: Cary Hawkins Date: Thu, 30 Oct 2025 19:53:24 -0700 Subject: [PATCH 53/55] Fix implementation and tests to handle env var expansion, matrix variable expansion and exclude patterns --- src/hatch/env/plugin/interface.py | 137 ++++++---- tests/workspaces/configuration.py | 432 +++++++++++++++++++++++++++--- 2 files changed, 473 insertions(+), 96 deletions(-) diff --git a/src/hatch/env/plugin/interface.py b/src/hatch/env/plugin/interface.py index dd88b6a26..64c3d550a 100644 --- a/src/hatch/env/plugin/interface.py +++ b/src/hatch/env/plugin/interface.py @@ -1092,6 +1092,8 @@ def get_member_deps(member): @cached_property def members(self) -> list[WorkspaceMember]: + import fnmatch + from hatch.project.core import Project from hatch.utils.fs import Path from hatchling.metadata.utils import normalize_project_name @@ -1104,75 +1106,87 @@ def members(self) -> list[WorkspaceMember]: if not raw_members: return [] - # Normalize configuration - member_data: list[dict[str, Any]] = [] - for i, data in enumerate(raw_members, 1): - if isinstance(data, str): - member_data.append({"path": data, "features": ()}) - elif isinstance(data, dict): - if "path" not in data: - message = ( - f"Member #{i} of field `tool.hatch.envs.{self.env.name}.workspace.members` must define " - f"a `path` key" - ) - raise TypeError(message) - - path = data["path"] - if not isinstance(path, str): - message = ( - f"Option `path` of member #{i} of field `tool.hatch.envs.{self.env.name}.workspace.members` " - f"must be a string" - ) - raise TypeError(message) - - if not path: - message = ( - f"Option `path` of member #{i} of field `tool.hatch.envs.{self.env.name}.workspace.members` " - f"cannot be an empty string" - ) - raise ValueError(message) + # Get exclude patterns + exclude_patterns = self.config.get("exclude", []) + if not isinstance(exclude_patterns, list): + message = f"Field `tool.hatch.envs.{self.env.name}.workspace.exclude` must be an array" + raise TypeError(message) - features = data.get("features", []) - if not isinstance(features, list): - message = ( - f"Option `features` of member #{i} of field `tool.hatch.envs.{self.env.name}.workspace." - f"members` must be an array of strings" - ) - raise TypeError(message) + # Normalize configuration with context expansion + member_data: list[dict[str, Any]] = [] + with self.env.apply_context(): + for i, data in enumerate(raw_members, 1): + if isinstance(data, str): + # Apply context expansion to path + expanded_path = self.env.metadata.context.format(data) + member_data.append({"path": expanded_path, "features": ()}) + elif isinstance(data, dict): + if "path" not in data: + message = ( + f"Member #{i} of field `tool.hatch.envs.{self.env.name}.workspace.members` must define " + f"a `path` key" + ) + raise TypeError(message) - all_features: set[str] = set() - for j, feature in enumerate(features, 1): - if not isinstance(feature, str): + path = data["path"] + if not isinstance(path, str): message = ( - f"Feature #{j} of option `features` of member #{i} of field " - f"`tool.hatch.envs.{self.env.name}.workspace.members` must be a string" + f"Option `path` of member #{i} of field `tool.hatch.envs.{self.env.name}.workspace.members` " + f"must be a string" ) raise TypeError(message) - if not feature: + if not path: message = ( - f"Feature #{j} of option `features` of member #{i} of field " - f"`tool.hatch.envs.{self.env.name}.workspace.members` cannot be an empty string" + f"Option `path` of member #{i} of field `tool.hatch.envs.{self.env.name}.workspace.members` " + f"cannot be an empty string" ) raise ValueError(message) - normalized_feature = normalize_project_name(feature) - if normalized_feature in all_features: + # Apply context expansion to path + expanded_path = self.env.metadata.context.format(path) + + features = data.get("features", []) + if not isinstance(features, list): message = ( - f"Feature #{j} of option `features` of member #{i} of field " - f"`tool.hatch.envs.{self.env.name}.workspace.members` is a duplicate" + f"Option `features` of member #{i} of field `tool.hatch.envs.{self.env.name}.workspace." + f"members` must be an array of strings" ) - raise ValueError(message) + raise TypeError(message) - all_features.add(normalized_feature) + all_features: set[str] = set() + for j, feature in enumerate(features, 1): + if not isinstance(feature, str): + message = ( + f"Feature #{j} of option `features` of member #{i} of field " + f"`tool.hatch.envs.{self.env.name}.workspace.members` must be a string" + ) + raise TypeError(message) - member_data.append({"path": path, "features": tuple(sorted(all_features))}) - else: - message = ( - f"Member #{i} of field `tool.hatch.envs.{self.env.name}.workspace.members` must be " - f"a string or an inline table" - ) - raise TypeError(message) + if not feature: + message = ( + f"Feature #{j} of option `features` of member #{i} of field " + f"`tool.hatch.envs.{self.env.name}.workspace.members` cannot be an empty string" + ) + raise ValueError(message) + + normalized_feature = normalize_project_name(feature) + if normalized_feature in all_features: + message = ( + f"Feature #{j} of option `features` of member #{i} of field " + f"`tool.hatch.envs.{self.env.name}.workspace.members` is a duplicate" + ) + raise ValueError(message) + + all_features.add(normalized_feature) + + member_data.append({"path": expanded_path, "features": tuple(sorted(all_features))}) + else: + message = ( + f"Member #{i} of field `tool.hatch.envs.{self.env.name}.workspace.members` must be " + f"a string or an inline table" + ) + raise TypeError(message) root = str(self.env.root) member_paths: dict[str, WorkspaceMember] = {} @@ -1198,6 +1212,19 @@ def members(self) -> list[WorkspaceMember]: members_found = False for member_path in find_members(root, path_components): + # Check if member should be excluded + relative_member_path = os.path.relpath(member_path, root) + should_exclude = False + for exclude_pattern in exclude_patterns: + if fnmatch.fnmatch(relative_member_path, exclude_pattern) or fnmatch.fnmatch( + member_path, exclude_pattern + ): + should_exclude = True + break + + if should_exclude: + continue + project_file = os.path.join(member_path, "pyproject.toml") if not os.path.isfile(project_file): message = ( diff --git a/tests/workspaces/configuration.py b/tests/workspaces/configuration.py index 931f46a0e..704afdd5d 100644 --- a/tests/workspaces/configuration.py +++ b/tests/workspaces/configuration.py @@ -151,47 +151,6 @@ def test_workspace_member_features(self, temp_dir, hatch): result = hatch("env", "create") assert result.exit_code == 0 - def test_workspace_inheritance_from_root(self, temp_dir, hatch): - """Test that workspace members inherit environments from root.""" - workspace_root = temp_dir / "workspace" - workspace_root.mkdir() - - workspace_config = workspace_root / "pyproject.toml" - workspace_config.write_text(""" -[project] -name = "workspace-root" -version = "0.1.0" - -[tool.hatch.envs.default] -workspace.members = ["packages/*"] - -[tool.hatch.envs.shared] -dependencies = ["pytest", "black"] -scripts.test = "pytest" -""") - - packages_dir = workspace_root / "packages" - packages_dir.mkdir() - - member_dir = packages_dir / "member1" - member_dir.mkdir() - (member_dir / "pyproject.toml").write_text(""" -[project] -name = "member1" -version = "0.1.0" - -[tool.hatch.envs.default] -dependencies = ["requests"] -""") - - with workspace_root.as_cwd(): - result = hatch("env", "show", "shared") - assert result.exit_code == 0 - - with member_dir.as_cwd(): - result = hatch("env", "show", "shared") - assert result.exit_code == 0 - def test_workspace_no_members_fallback(self, temp_dir, hatch): """Test fallback when no workspace members are defined.""" workspace_root = temp_dir / "workspace" @@ -348,3 +307,394 @@ def test_environment_specific_workspace_slices(self, temp_dir, hatch): result = hatch("env", "create", "integration-tests") assert result.exit_code == 0 + + def test_workspace_test_matrices(self, temp_dir, hatch): + """Test workspace configuration with test matrices.""" + workspace_root = temp_dir / "workspace" + workspace_root.mkdir() + + workspace_config = workspace_root / "pyproject.toml" + workspace_config.write_text(""" +[project] +name = "workspace-root" +version = "0.1.0" + +[[tool.hatch.envs.test.matrix]] +python = ["3.9", "3.10"] + +[tool.hatch.envs.test] +workspace.members = ["packages/*"] +dependencies = ["pytest", "coverage"] +scripts.test = "pytest {args}" + +[[tool.hatch.envs.test-core.matrix]] +python = ["3.9", "3.10"] + +[tool.hatch.envs.test-core] +workspace.members = ["packages/core"] +dependencies = ["pytest", "coverage"] +scripts.test = "pytest packages/core/tests {args}" +""") + + packages_dir = workspace_root / "packages" + packages_dir.mkdir() + + for pkg in ["core", "utils"]: + pkg_dir = packages_dir / pkg + pkg_dir.mkdir() + (pkg_dir / "pyproject.toml").write_text(f""" +[project] +name = "{pkg}" +version = "0.1.0" +""") + + with workspace_root.as_cwd(): + result = hatch("env", "show", "test") + assert result.exit_code == 0 + + result = hatch("env", "show", "test-core") + assert result.exit_code == 0 + + def test_workspace_library_with_plugins(self, temp_dir, hatch): + """Test library with plugins workspace configuration.""" + workspace_root = temp_dir / "workspace" + workspace_root.mkdir() + + workspace_config = workspace_root / "pyproject.toml" + workspace_config.write_text(""" + [project] + name = "library-root" + version = "0.1.0" + + [build-system] + requires = ["hatchling"] + build-backend = "hatchling.build" + + [tool.hatch.build.targets.wheel] + packages = [] + + [tool.hatch.envs.default] + skip-install = true + workspace.members = ["core"] + dependencies = ["pytest"] + + [tool.hatch.envs.full] + skip-install = true + workspace.members = [ + "core", + "plugins/database", + "plugins/cache", + "plugins/auth" + ] + dependencies = ["pytest", "pytest-asyncio"] + + [tool.hatch.envs.database-only] + skip-install = true + workspace.members = [ + "core", + {path = "plugins/database", features = ["postgresql", "mysql"]} + ] + """) + + # Create core package with source + core_dir = workspace_root / "core" + core_dir.mkdir() + (core_dir / "pyproject.toml").write_text(""" + [project] + name = "core" + version = "0.1.0" + + [build-system] + requires = ["hatchling"] + build-backend = "hatchling.build" + + [tool.hatch.build.targets.wheel] + packages = ["core"] + """) + core_src = core_dir / "core" + core_src.mkdir() + (core_src / "__init__.py").write_text("") + + # Create plugins with source + plugins_dir = workspace_root / "plugins" + plugins_dir.mkdir() + + for plugin in ["database", "cache", "auth"]: + plugin_dir = plugins_dir / plugin + plugin_dir.mkdir() + (plugin_dir / "pyproject.toml").write_text(f""" + [project] + name = "{plugin}" + version = "0.1.0" + dependencies = ["core"] + + [build-system] + requires = ["hatchling"] + build-backend = "hatchling.build" + + [tool.hatch.build.targets.wheel] + packages = ["{plugin}"] + + [project.optional-dependencies] + postgresql = ["requests"] + mysql = ["click"] + """) + plugin_src = plugin_dir / plugin + plugin_src.mkdir() + (plugin_src / "__init__.py").write_text("") + + with workspace_root.as_cwd(): + result = hatch("env", "create", "full") + assert result.exit_code == 0 + + result = hatch("env", "create", "database-only") + assert result.exit_code == 0 + + def test_workspace_multi_service_application(self, temp_dir, hatch): + """Test multi-service application workspace configuration.""" + workspace_root = temp_dir / "workspace" + workspace_root.mkdir() + + workspace_config = workspace_root / "pyproject.toml" + workspace_config.write_text(""" + [project] + name = "microservices-root" + version = "0.1.0" + + [build-system] + requires = ["hatchling"] + build-backend = "hatchling.build" + + [tool.hatch.build.targets.wheel] + packages = [] + + [tool.hatch.envs.default] + skip-install = true + workspace.members = ["shared"] + dependencies = ["pytest", "requests"] + + [tool.hatch.envs.api] + skip-install = true + workspace.members = [ + "shared", + {path = "services/api", features = ["dev"]} + ] + dependencies = ["fastapi", "uvicorn"] + scripts.dev = "uvicorn services.api.main:app --reload" + + [tool.hatch.envs.worker] + skip-install = true + workspace.members = [ + "shared", + {path = "services/worker", features = ["dev"]} + ] + dependencies = ["celery", "redis"] + scripts.dev = "celery -A services.worker.tasks worker --loglevel=info" + + [tool.hatch.envs.integration] + skip-install = true + workspace.members = [ + "shared", + "services/api", + "services/worker", + "services/frontend" + ] + dependencies = ["pytest", "httpx", "docker"] + scripts.test = "pytest tests/integration {args}" + """) + + # Create shared package with source + shared_dir = workspace_root / "shared" + shared_dir.mkdir() + (shared_dir / "pyproject.toml").write_text(""" + [project] + name = "shared" + version = "0.1.0" + + [build-system] + requires = ["hatchling"] + build-backend = "hatchling.build" + + [tool.hatch.build.targets.wheel] + packages = ["shared"] + """) + shared_src = shared_dir / "shared" + shared_src.mkdir() + (shared_src / "__init__.py").write_text("") + + # Create services with source + services_dir = workspace_root / "services" + services_dir.mkdir() + + for service in ["api", "worker", "frontend"]: + service_dir = services_dir / service + service_dir.mkdir() + (service_dir / "pyproject.toml").write_text(f""" + [project] + name = "{service}" + version = "0.1.0" + dependencies = ["shared"] + + [build-system] + requires = ["hatchling"] + build-backend = "hatchling.build" + + [tool.hatch.build.targets.wheel] + packages = ["{service}"] + + [project.optional-dependencies] + dev = ["black", "ruff"] + """) + service_src = service_dir / service + service_src.mkdir() + (service_src / "__init__.py").write_text("") + + with workspace_root.as_cwd(): + result = hatch("env", "create", "api") + assert result.exit_code == 0 + + result = hatch("env", "create", "worker") + assert result.exit_code == 0 + + result = hatch("env", "create", "integration") + assert result.exit_code == 0 + + def test_workspace_documentation_generation(self, temp_dir, hatch): + """Test documentation generation workspace configuration.""" + workspace_root = temp_dir / "workspace" + workspace_root.mkdir() + + workspace_config = workspace_root / "pyproject.toml" + workspace_config.write_text(""" +[project] +name = "docs-root" +version = "0.1.0" + +[tool.hatch.envs.docs] +workspace.members = [ + {path = "packages/core", features = ["docs"]}, + {path = "packages/cli", features = ["docs"]}, + {path = "packages/plugins", features = ["docs"]} +] +dependencies = [ + "mkdocs", + "mkdocs-material", + "mkdocstrings[python]" +] +scripts.serve = "mkdocs serve" +scripts.build = "mkdocs build" + +[tool.hatch.envs.docs-api-only] +workspace.members = [ + {path = "packages/core", features = ["docs"]} +] +template = "docs" +scripts.serve = "mkdocs serve --config-file mkdocs-api.yml" +""") + + packages_dir = workspace_root / "packages" + packages_dir.mkdir() + + for pkg in ["core", "cli", "plugins"]: + pkg_dir = packages_dir / pkg + pkg_dir.mkdir() + (pkg_dir / "pyproject.toml").write_text(f""" +[project] +name = "{pkg}" +version = "0.1.0" +[project.optional-dependencies] +docs = ["sphinx", "sphinx-rtd-theme"] +""") + + with workspace_root.as_cwd(): + result = hatch("env", "create", "docs") + assert result.exit_code == 0 + + result = hatch("env", "create", "docs-api-only") + assert result.exit_code == 0 + + def test_workspace_development_workflow(self, temp_dir, hatch, monkeypatch): + """Test development workflow workspace configuration.""" + workspace_root = temp_dir / "workspace" + workspace_root.mkdir() + + workspace_config = workspace_root / "pyproject.toml" + workspace_config.write_text(""" + [project] + name = "dev-workflow-root" + version = "0.1.0" + + [build-system] + requires = ["hatchling"] + build-backend = "hatchling.build" + + [tool.hatch.build.targets.wheel] + packages = [] + + [tool.hatch.envs.dev] + skip-install = true + workspace.members = ["packages/*"] + workspace.parallel = true + dependencies = [ + "pytest", + "black", + "ruff", + "mypy", + "pre-commit" + ] + scripts.setup = "pre-commit install" + scripts.test = "pytest {args}" + scripts.lint = ["ruff check .", "black --check .", "mypy ."] + scripts.fmt = ["ruff check --fix .", "black ."] + + [tool.hatch.envs.feature] + skip-install = true + template = "dev" + workspace.members = [ + "packages/core", + "packages/{env:FEATURE_PACKAGE}" + ] + scripts.test = "pytest packages/{env:FEATURE_PACKAGE}/tests {args}" + + [[tool.hatch.envs.release.matrix]] + package = ["core", "utils", "cli"] + + [tool.hatch.envs.release] + detached = true + skip-install = true + workspace.members = ["packages/{matrix:package}"] + dependencies = ["build", "twine"] + scripts.build = "python -m build packages/{matrix:package}" + scripts.publish = "twine upload packages/{matrix:package}/dist/*" + """) + + packages_dir = workspace_root / "packages" + packages_dir.mkdir() + + for pkg in ["core", "utils", "cli"]: + pkg_dir = packages_dir / pkg + pkg_dir.mkdir() + (pkg_dir / "pyproject.toml").write_text(f""" + [project] + name = "{pkg}" + version = "0.1.0" + + [build-system] + requires = ["hatchling"] + build-backend = "hatchling.build" + + [tool.hatch.build.targets.wheel] + packages = ["{pkg}"] + """) + pkg_src = pkg_dir / pkg + pkg_src.mkdir() + (pkg_src / "__init__.py").write_text("") + + with workspace_root.as_cwd(): + result = hatch("env", "create", "dev") + assert result.exit_code == 0 + + # Test feature environment with environment variable + monkeypatch.setenv("FEATURE_PACKAGE", "utils") + result = hatch("env", "create", "feature") + assert result.exit_code == 0 From 01b29009ee53d7a2b59bc880fe1b5347dbc1b242 Mon Sep 17 00:00:00 2001 From: Cary Hawkins Date: Tue, 4 Nov 2025 14:32:06 -0800 Subject: [PATCH 54/55] Remove dead code not needed for per env workspace config, run fmt, add xdist for local unit test running to speed up unit tests --- hatch.toml | 3 ++- src/hatch/env/plugin/interface.py | 13 ------------- 2 files changed, 2 insertions(+), 14 deletions(-) diff --git a/hatch.toml b/hatch.toml index 8759cfa8c..e9c3874b5 100644 --- a/hatch.toml +++ b/hatch.toml @@ -8,6 +8,7 @@ installer = "uv" [envs.workspace-test] dependencies = [ "pytest", + "pytest-xdist" ] [envs.hatch-test] @@ -19,7 +20,7 @@ extra-dependencies = [ "trustme", "editables", ] -extra-args = ["--dist", "worksteal"] +extra-args = ["--dist", "worksteal", "-n", "auto"] [envs.hatch-test.extra-scripts] pip = "{env:HATCH_UV} pip {args}" diff --git a/src/hatch/env/plugin/interface.py b/src/hatch/env/plugin/interface.py index 64c3d550a..74a027b7b 100644 --- a/src/hatch/env/plugin/interface.py +++ b/src/hatch/env/plugin/interface.py @@ -1283,19 +1283,6 @@ def last_modified(self) -> float: return os.path.getmtime(pyproject_path) return 0.0 - def has_changed(self) -> bool: - """Check if the workspace member has changed since last check.""" - current_modified = self.last_modified - if self._last_modified is None: - self._last_modified = current_modified - return True - - if current_modified > self._last_modified: - self._last_modified = current_modified - return True - - return False - def get_editable_requirement(self, *, editable: bool = True) -> str: """Get the requirement string for this workspace member.""" uri = self.project.location.as_uri() From d5a4f9626f4c94b7f27a26352ecaa0510e9c3d46 Mon Sep 17 00:00:00 2001 From: Cary Hawkins Date: Tue, 4 Nov 2025 14:44:10 -0800 Subject: [PATCH 55/55] Revert changes for xdist --- hatch.toml | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/hatch.toml b/hatch.toml index e9c3874b5..51702aa44 100644 --- a/hatch.toml +++ b/hatch.toml @@ -5,11 +5,6 @@ config-path = "ruff_defaults.toml" [envs.default] installer = "uv" -[envs.workspace-test] -dependencies = [ - "pytest", - "pytest-xdist" -] [envs.hatch-test] workspace.members = ["backend/"] @@ -20,7 +15,7 @@ extra-dependencies = [ "trustme", "editables", ] -extra-args = ["--dist", "worksteal", "-n", "auto"] +extra-args = ["--dist", "worksteal"] [envs.hatch-test.extra-scripts] pip = "{env:HATCH_UV} pip {args}"