diff --git a/.github/workflows/build-hatch.yml b/.github/workflows/build-hatch.yml index f1aa6045e..ba1d5bd53 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 . - 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 @@ -166,7 +166,7 @@ jobs: - name: Install Hatch run: |- uv pip install --system -e . - uv pip install --system -e ./backend + hatch env create - 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..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 -e ./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 54e6e59cd..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 -e ./backend + hatch env create - name: Display full version run: hatch version diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 6901391fa..afa2698c4 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -24,90 +24,90 @@ 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 . + hatch env create - - name: Run static analysis - run: hatch fmt --check + - name: Run static analysis + run: hatch fmt --check - - name: Check types - run: hatch run types:check + - name: Check types + run: hatch run types:check - - name: Run tests - run: hatch test --python ${{ matrix.python-version }} --cover-quiet --randomize --parallel --retries 5 --retry-delay 3 + - 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: 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* + - 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 +115,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/docs/history/hatchling.md b/docs/history/hatchling.md index a6b0e7fdb..26875d1e8 100644 --- a/docs/history/hatchling.md +++ b/docs/history/hatchling.md @@ -12,6 +12,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), - Drop support for Python 3.8 +***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 } ***Added:*** 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/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 13a053daf..a2bdb92a8 100644 --- a/hatch.toml +++ b/hatch.toml @@ -1,25 +1,24 @@ + [envs.hatch-static-analysis] config-path = "ruff_defaults.toml" [envs.default] installer = "uv" -post-install-commands = [ - "uv pip install {verbosity:flag:-1} -e ./backend", + +[envs.workspace-test] +dependencies = [ + "pytest", ] [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] @@ -53,10 +52,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", @@ -64,7 +64,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", @@ -86,7 +86,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", ] @@ -125,9 +125,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 fdee18f6e..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", @@ -68,6 +68,9 @@ 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 286df6b34..fd02d0c85 100644 --- a/src/hatch/cli/__init__.py +++ b/src/hatch/cli/__init__.py @@ -29,6 +29,38 @@ 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 ) @@ -170,8 +202,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/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..8fe875a7f --- /dev/null +++ b/src/hatch/dep/core.py @@ -0,0 +1,37 @@ +from __future__ import annotations + +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.from_uri(self.url) diff --git a/src/hatch/dep/sync.py b/src/hatch/dep/sync.py index 4b360c291..57568b1c2 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 09678d7c8..23346b2b6 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 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 @@ -141,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 self.app.project + @cached_property def project_root(self) -> str: """ @@ -164,7 +175,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 @@ -181,7 +192,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 +259,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 +276,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 +291,133 @@ def environment_dependencies(self) -> list[str]: return [str(dependency) for dependency in self.environment_dependencies_complex] @cached_property - def dependencies_complex(self): - all_dependencies_complex = list(self.environment_dependencies_complex) - if self.builder: - all_dependencies_complex.extend(self.metadata.build.requires_complex) - return all_dependencies_complex + 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 [] - # 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 + from hatch.dep.core import Dependency + 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) + 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 and self.dev_mode: - all_dependencies_complex.extend(dependencies_complex.values()) + 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) + 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()) + all_dependencies_complex.extend([ + dep if isinstance(dep, Dependency) else Dependency(str(dep)) + for dep in optional_dependencies_complex[feature] + ]) return all_dependencies_complex @cached_property - def dependencies(self) -> list[str]: + def project_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 + [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: + local_dependencies_complex.append( + Dependency(f"{self.metadata.name} @ {self.root.as_uri()}", editable=self.dev_mode) + ) + if self.workspace.members: + local_dependencies_complex.extend( + Dependency(f"{member.project.metadata.name} @ {member.project.location.as_uri()}", editable=True) + for member in self.workspace.members + ) + + return local_dependencies_complex + + @cached_property + def dependencies_complex(self) -> list[Dependency]: + from hatch.dep.core import Dependency + + all_dependencies_complex = list(self.environment_dependencies_complex) + + # 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.project.constants import BuildEnvVars + + # 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)) + + 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 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](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]: + from hatch.dep.core import Dependency + + 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]: + return [str(dependency) for dependency in self.all_dependencies_complex] + @cached_property def platforms(self) -> list[str]: """ @@ -513,6 +610,35 @@ def post_install_commands(self): return list(post_install_commands) + @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) + def activate(self): """ A convenience method called when using the environment as a context manager: @@ -617,7 +743,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 +1033,252 @@ 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 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() + 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) + + if not raw_members: + return [] + + # 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 + self._last_modified: float + + @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() + + @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): if script_name in seen: return seen[script_name] @@ -941,3 +1313,31 @@ 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) + + results = list(_recurse_members(root, 0, component_matchers)) + yield from sorted(results, key=os.path.basename) + + +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..4687a6840 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,45 @@ 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)) + # 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 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: + continue + + if not dependency.editable or dependency.path is None: + standard_dependencies.append(str(dependency)) + else: + editable_dependencies.append(str(dependency.path)) + + # Install other dependencies + 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..62d8109e5 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,9 +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 packaging.requirements import Requirement + from hatch.dep.core import Dependency class ProjectConfig: @@ -57,9 +59,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 +76,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 @@ -155,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) @@ -501,6 +501,14 @@ 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 @@ -728,6 +736,93 @@ 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/src/hatch/project/constants.py b/src/hatch/project/constants.py index 27315cdfd..965005a69 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 c461a79b8..367398f6f 100644 --- a/src/hatch/project/core.py +++ b/src/hatch/project/core.py @@ -1,6 +1,7 @@ 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, cast @@ -19,7 +20,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 +39,8 @@ 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 + self.current_member_path: Path | None = None @property def plugin_manager(self): @@ -200,13 +202,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 +219,28 @@ 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() @@ -258,6 +266,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..c3840bbdc 100644 --- a/src/hatch/utils/fs.py +++ b/src/hatch/utils/fs.py @@ -131,6 +131,18 @@ 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..b753e1e51 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..807f1ca6e 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 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 @@ -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,8 @@ 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 distributions.dependencies_in_sync([Dependency("requests@git+https://github.com/psf/requests@main")]) @pytest.mark.requires_internet @@ -131,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 4d5ac7370..3763b0b50 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", + ] 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..419d0589d --- /dev/null +++ b/tests/workspaces/configuration.py @@ -0,0 +1,327 @@ +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