` variable is the version of the distribution. This value is used to determine whether updates are required and is displayed in the output of the [`python show`](../../cli/reference.md#hatch-python-show) command.
================================================
FILE: docs/how-to/run/python-scripts.md
================================================
# How to run Python scripts
-----
The [`run`](../../cli/reference.md#hatch-run) command supports executing Python scripts with [inline metadata](https://packaging.python.org/en/latest/specifications/inline-script-metadata/), such that a dedicated [environment](../../config/environment/overview.md) is automatically created with the required dependencies and with the correct version of Python.
A script metadata block is a comment block that starts with `# /// script` and ends with `# ///`. Every line between those two lines must be a comment line that starts with `#` and contains a [TOML](https://github.com/toml-lang/toml) document when the comment characters are removed.
The top-level fields are:
- `dependencies`: A list of strings that specifies the runtime dependencies of the script. Each entry must be a valid [dependency specifier](https://packaging.python.org/en/latest/specifications/dependency-specifiers/#dependency-specifiers).
- `requires-python`: A string that specifies the Python version(s) with which the script is compatible. The value of this field must be a valid [version specifier](https://packaging.python.org/en/latest/specifications/version-specifiers/#version-specifiers).
The following is an example of Python script with a valid metadata block:
```python tab="script.py"
# /// script
# requires-python = ">=3.11"
# dependencies = [
# "httpx",
# "rich",
# ]
# ///
import httpx
from rich.pretty import pprint
resp = httpx.get("https://peps.python.org/api/peps.json")
data = resp.json()
pprint([(k, v["title"]) for k, v in data.items()][:10])
```
Run it directly:
```
$ hatch run /path/to/script.py
Creating environment: SyB4bPbL
Checking dependencies
Syncing dependencies
[
│ ('1', 'PEP Purpose and Guidelines'),
│ ('2', 'Procedure for Adding New Modules'),
│ ('3', 'Guidelines for Handling Bug Reports'),
│ ('4', 'Deprecation of Standard Modules'),
│ ('5', 'Guidelines for Language Evolution'),
│ ('6', 'Bug Fix Releases'),
│ ('7', 'Style Guide for C Code'),
│ ('8', 'Style Guide for Python Code'),
│ ('9', 'Sample Plaintext PEP Template'),
│ ('10', 'Voting Guidelines')
]
```
!!! note "notes"
- The informational text in this example is only temporarily shown in your terminal on the first run.
- Although the environment name is based on the script's absolute path, the command line argument does not have to be.
## Environment configuration
You may use the `[tool.hatch]` table directly to control the script's [environment](../../config/environment/overview.md). For example, if you wanted to disable UV (which is [enabled](../environment/select-installer.md#enabling-uv) by default for scripts), you could add the following:
```python tab="script.py"
# /// script
# ...
# [tool.hatch]
# installer = "pip"
# ///
```
================================================
FILE: docs/how-to/static-analysis/behavior.md
================================================
# Customize static analysis behavior
-----
You can [fully alter](../../config/internal/static-analysis.md#customize-behavior) the static analysis performed by the [`fmt`](../../cli/reference.md#hatch-fmt) command by modifying the reserved [environment](../../config/environment/overview.md) named `hatch-static-analysis`. For example, you could define the following if you wanted to replace the default behavior with a mix of [Black](https://github.com/psf/black), [isort](https://github.com/PyCQA/isort) and basic [flake8](https://github.com/PyCQA/flake8):
```toml config-example
[tool.hatch.envs.hatch-static-analysis]
dependencies = ["black", "flake8", "isort"]
[tool.hatch.envs.hatch-static-analysis.scripts]
format-check = [
"black --check --diff {args:.}",
"isort --check-only --diff {args:.}",
]
format-fix = [
"isort {args:.}",
"black {args:.}",
]
lint-check = "flake8 {args:.}"
lint-fix = "lint-check"
```
The `format-*` scripts correspond to the `--formatter`/`-f` flag while the `lint-*` scripts correspond to the `--linter`/`-l` flag. The `*-fix` scripts run by default while the `*-check` scripts correspond to the `--check` flag. Based on this example, the following shows how the various scripts influence behavior:
| Command | Expanded scripts |
| --- | --- |
| `hatch fmt` | |
| `hatch fmt src tests` | flake8 src testsisort src testsblack src tests |
| `hatch fmt -f` | |
| `hatch fmt -l` | |
| `hatch fmt --check` | flake8 .black --check --diff .isort --check-only --diff . |
| `hatch fmt --check -f` | black --check --diff .isort --check-only --diff . |
| `hatch fmt --check -l` | |
================================================
FILE: docs/index.md
================================================
# Hatch
{ role="img" }
| | |
| --- | --- |
| CI/CD | [{ loading=lazy .off-glb }](https://github.com/pypa/hatch/actions/workflows/test.yml) [{ loading=lazy .off-glb }](https://github.com/pypa/hatch/actions/workflows/build-hatch.yml) [{ loading=lazy .off-glb }](https://github.com/pypa/hatch/actions/workflows/build-hatchling.yml) |
| Docs | [{ loading=lazy .off-glb }](https://github.com/pypa/hatch/actions/workflows/docs-release.yml) [{ loading=lazy .off-glb }](https://github.com/pypa/hatch/actions/workflows/docs-dev.yml) |
| Package | [{ loading=lazy .off-glb }](https://pypi.org/project/hatch/) [{ loading=lazy .off-glb }](https://pypi.org/project/hatch/) [{ loading=lazy .off-glb }](https://pypi.org/project/hatch/) [{ loading=lazy .off-glb }](https://github.com/pypa/hatch/releases) |
| Meta | [{ loading=lazy .off-glb }](https://github.com/pypa/hatch) [{ loading=lazy .off-glb }](https://github.com/astral-sh/ruff) [{ loading=lazy .off-glb }](https://github.com/python/mypy) [{ loading=lazy .off-glb }](https://spdx.org/licenses/) [{ loading=lazy .off-glb }](https://github.com/sponsors/ofek) |
-----
Hatch is a modern, extensible Python project manager. See the [Why Hatch?](why.md) page for more information.
- :material-hammer-wrench:{ .lg .middle } __Build system__
---
Reproducible builds by default with a rich ecosystem of plugins
[:octicons-arrow-right-24: Configure builds](config/build.md#build-system)
- :material-lock:{ .lg .middle } __Environments__
---
Robust environment management with support for custom scripts and UV
[:octicons-arrow-right-24: Getting started](environment.md)
- :material-language-python:{ .lg .middle } __Python management__
---
Choose between easy manual installations or automatic as part of environments
[:octicons-arrow-right-24: Try it](tutorials/python/manage.md)
- :octicons-shield-check-24:{ .lg .middle } __Testing__
---
Test execution with known best practices
[:octicons-arrow-right-24: Run](tutorials/testing/overview.md)
- :material-magnify-scan:{ .lg .middle } __Static analysis__
---
Static analysis backed by Ruff with up-to-date, sane defaults
[:octicons-arrow-right-24: Learn](config/internal/static-analysis.md)
- :material-console-line:{ .lg .middle } __Script runner__
---
Execute Python scripts with specific dependencies and Python versions
[:octicons-arrow-right-24: Execute](how-to/run/python-scripts.md)
- :material-publish:{ .lg .middle } __Publishing__
---
Easily upload to PyPI or other indices
[:octicons-arrow-right-24: See how](publish.md)
- :octicons-number-24:{ .lg .middle } __Versioning__
---
Streamlined workflow for bumping versions
[:octicons-arrow-right-24: Managing versions](version.md)
- :octicons-project-template-24:{ .lg .middle } __Project generation__
---
Create new projects from templates with known best practices
[:octicons-arrow-right-24: Project setup](intro.md#setup)
- :material-speedometer:{ .lg .middle } __Responsive CLI__
---
Hatch is up to 3x faster than equivalent tools
[:octicons-arrow-right-24: CLI reference](cli/about.md)
## License
Hatch is distributed under the terms of the [MIT](https://spdx.org/licenses/MIT.html) license.
## Navigation
Documentation for specific `MAJOR.MINOR` versions can be chosen by using the dropdown on the top of every page. The `dev` version reflects changes that have not yet been released.
Also, desktop readers can use special keyboard shortcuts:
| Keys | Action |
| --- | --- |
| | Navigate to the "previous" page |
| | Navigate to the "next" page |
| | Display the search modal |
================================================
FILE: docs/install.md
================================================
# Installation
-----
## GitHub Actions
```yaml
- name: Install Hatch
uses: pypa/hatch@install
```
Refer to the [official action](https://github.com/pypa/hatch/tree/install) for more information.
## Installers
=== "macOS"
=== "GUI installer"
1. In your browser, download the `.pkg` file: [hatch-universal.pkg](https://github.com/pypa/hatch/releases/latest/download/hatch-universal.pkg)
2. Run your downloaded file and follow the on-screen instructions.
3. Restart your terminal.
4. To verify that the shell can find and run the `hatch` command in your `PATH`, use the following command.
```
$ hatch --version
```
=== "Command line installer"
1. Download the file using the `curl` command. The `-o` option specifies the file name that the downloaded package is written to. In this example, the file is written to `hatch-universal.pkg` in the current directory.
```
curl -Lo hatch-universal.pkg https://github.com/pypa/hatch/releases/latest/download/hatch-universal.pkg
```
2. Run the standard macOS [`installer`](https://ss64.com/osx/installer.html) program, specifying the downloaded `.pkg` file as the source. Use the `-pkg` parameter to specify the name of the package to install, and the `-target /` parameter for the drive in which to install the package. The files are installed to `/usr/local/hatch`, and an entry is created at `/etc/paths.d/hatch` that instructs shells to add the `/usr/local/hatch` directory to. You must include sudo on the command to grant write permissions to those folders.
```
sudo installer -pkg ./hatch-universal.pkg -target /
```
3. Restart your terminal.
4. To verify that the shell can find and run the `hatch` command in your `PATH`, use the following command.
```
$ hatch --version
```
=== "Windows"
=== "GUI installer"
1. In your browser, download one the `.msi` files:
- [hatch-x64.msi](https://github.com/pypa/hatch/releases/latest/download/hatch-x64.msi)
2. Run your downloaded file and follow the on-screen instructions.
3. Restart your terminal.
4. To verify that the shell can find and run the `hatch` command in your `PATH`, use the following command.
```
$ hatch --version
```
=== "Command line installer"
1. Download and run the installer using the standard Windows [`msiexec`](https://learn.microsoft.com/en-us/windows-server/administration/windows-commands/msiexec) program, specifying one of the `.msi` files as the source. Use the `/passive` and `/i` parameters to request an unattended, normal installation.
=== "x64"
```
msiexec /passive /i https://github.com/pypa/hatch/releases/latest/download/hatch-x64.msi
```
=== "x86"
```
msiexec /passive /i https://github.com/pypa/hatch/releases/latest/download/hatch-x86.msi
```
2. Restart your terminal.
3. To verify that the shell can find and run the `hatch` command in your `PATH`, use the following command.
```
$ hatch --version
```
## Standalone binaries
After downloading the archive corresponding to your platform and architecture, extract the binary to a directory that is on your PATH and rename to `hatch`.
=== "Linux"
- [hatch-aarch64-unknown-linux-gnu.tar.gz](https://github.com/pypa/hatch/releases/latest/download/hatch-aarch64-unknown-linux-gnu.tar.gz)
- [hatch-x86_64-unknown-linux-gnu.tar.gz](https://github.com/pypa/hatch/releases/latest/download/hatch-x86_64-unknown-linux-gnu.tar.gz)
- [hatch-x86_64-unknown-linux-musl.tar.gz](https://github.com/pypa/hatch/releases/latest/download/hatch-x86_64-unknown-linux-musl.tar.gz)
- [hatch-powerpc64le-unknown-linux-gnu.tar.gz](https://github.com/pypa/hatch/releases/latest/download/hatch-powerpc64le-unknown-linux-gnu.tar.gz)
=== "macOS"
- [hatch-aarch64-apple-darwin.tar.gz](https://github.com/pypa/hatch/releases/latest/download/hatch-aarch64-apple-darwin.tar.gz)
- [hatch-x86_64-apple-darwin.tar.gz](https://github.com/pypa/hatch/releases/latest/download/hatch-x86_64-apple-darwin.tar.gz)
=== "Windows"
- [hatch-x86_64-pc-windows-msvc.zip](https://github.com/pypa/hatch/releases/latest/download/hatch-x86_64-pc-windows-msvc.zip)
- [hatch-i686-pc-windows-msvc.zip](https://github.com/pypa/hatch/releases/latest/download/hatch-i686-pc-windows-msvc.zip)
## pip
Hatch is available on PyPI and can be installed with [pip](https://github.com/pypa/pip).
```
pip install hatch
```
!!! warning
This method modifies the Python environment in which you choose to install. Consider instead using [pipx](#pipx) to avoid dependency conflicts.
## pipx
[pipx](https://github.com/pypa/pipx) allows for the global installation of Python applications in isolated environments.
```
pipx install hatch
```
## Homebrew
See the [formula](https://formulae.brew.sh/formula/hatch) for more details.
```
brew install hatch
```
## Conda
See the [feedstock](https://github.com/conda-forge/hatch-feedstock) for more details.
```
conda install -c conda-forge hatch
```
or with [mamba](https://github.com/mamba-org/mamba):
```
mamba install hatch
```
!!! warning
This method modifies the Conda environment in which you choose to install. Consider instead using [pipx](#pipx) or [condax](https://github.com/mariusvniekerk/condax) to avoid dependency conflicts.
## MacPorts
See the [port](https://ports.macports.org/port/hatch/) for more details.
```
sudo port install hatch
```
## Fedora
The minimum supported version is 37, currently in development as [Rawhide](https://docs.fedoraproject.org/en-US/releases/rawhide/).
```
sudo dnf install hatch
```
## Void Linux
```
xbps-install hatch
```
## Build system availability
Hatchling is Hatch's [build backend](config/build.md#build-system) which you will never need to install manually. See its [changelog](history/hatchling.md) for version information.
[{ loading=lazy .off-glb }](https://repology.org/project/hatchling/versions)
================================================
FILE: docs/intro.md
================================================
# Introduction
-----
## Setup
Projects can be set up for use by Hatch using the [`new`](cli/reference.md#hatch-new) command.
### New project
Let's say you want to create a project named `Hatch Demo`. You would run:
```
hatch new "Hatch Demo"
```
This would create the following structure in your current working directory:
```
hatch-demo
├── src
│ └── hatch_demo
│ ├── __about__.py
│ └── __init__.py
├── tests
│ └── __init__.py
├── LICENSE.txt
├── README.md
└── pyproject.toml
```
!!! tip
There are many ways to [customize](config/project-templates.md) project generation.
### Existing project
To initialize an existing project, enter the directory containing the project and run the following:
```
hatch new --init
```
If your project has a `setup.py` file the command will automatically migrate `setuptools` configuration for you. Otherwise, this will interactively guide you through the setup process.
## Project metadata
Next you'll want to define more of your project's [metadata](config/metadata.md) located in the `pyproject.toml` file. You can specify things like its [license](config/metadata.md#license), the [supported versions of Python](config/metadata.md#python-support), and [URLs](config/metadata.md#urls) referring to various parts of your project, like documentation.
## Dependencies
The last step of the setup process is to define any [dependencies](config/dependency.md) that you'd like your project to begin with.
## Configuration
All project-specific configuration recognized by Hatch can be defined in either the `pyproject.toml` file, or a file named `hatch.toml` where options are not contained within the `tool.hatch` table:
```toml config-example
[tool.hatch]
option = "..."
[tool.hatch.table1]
option = "..."
[tool.hatch.table2]
option = "..."
```
Top level keys in the latter file take precedence when defined in both.
!!! tip
If you want to make your file more compact, you can use [dotted keys](https://toml.io/en/v1.0.0#table), turning the above example into:
```toml config-example
[tool.hatch]
option = "..."
table1.option = "..."
table2.option = "..."
```
================================================
FILE: docs/meta/authors.md
================================================
# Authors
-----
## Maintainers
- Ofek Lev [:material-web:](https://ofek.dev) [:material-github:](https://github.com/ofek) [:material-twitter:](https://twitter.com/Ofekmeister)
- Cary Hawkins [:material-github:](https://github.com/cjames23)[:material-web:](https://cjameshawkins.com)
## Contributors
- Amjith Ramanujam [:material-twitter:](https://twitter.com/amjithr)
- Arnaud Crowther [:material-github:](https://github.com/areknow)
- Chaojie [:material-web:](https://chaojie.fun) [:material-github:](https://github.com/ischaojie)
- Chris Warrick [:material-twitter:](https://twitter.com/Kwpolska)
- Lumír 'Frenzy' Balhar [:material-email:](mailto:frenzy.madness@gmail.com) [:material-twitter:](https://twitter.com/lumirbalhar)
- Ofek Lev [:material-web:](https://ofek.dev) [:material-github:](https://github.com/ofek) [:material-twitter:](https://twitter.com/Ofekmeister)
- 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)
================================================
FILE: docs/meta/faq.md
================================================
# FAQ
-----
## Interoperability
***Q***: What is the risk of lock-in?
***A***: Not much! Other than the [plugin system](../plugins/about.md), everything uses Python's established standards by default. Project metadata is based entirely on [the standard][project metadata standard], the build system is compatible with [PEP 517][]/[PEP 660][], versioning uses the scheme specified by [PEP 440](https://peps.python.org/pep-0440/#public-version-identifiers), dependencies are defined with [PEP 508][] strings, and environments use [virtualenv](https://github.com/pypa/virtualenv).
***Q***: Must one use all features?
***A***: No, all features are optional! You can use [just the build system](../build.md#packaging-ecosystem), publish wheels and source distributions that were built by other tools, only use the environment management, etc.
## Libraries vs applications
***Q***: Are workflows for both libraries and applications supported?
***A***: Yes, mostly! Applications can utilize environment management just like libraries, and plugins can be used to [build](../plugins/builder/reference.md) projects in arbitrary formats or [publish](../plugins/publisher/reference.md) artifacts to arbitrary destinations.
The only caveat is that currently there is no support for re-creating an environment given a set of dependencies in a reproducible manner. Although a standard lock file format may be far off since [PEP 665][] was rejected, resolving capabilities are [coming to pip](https://github.com/pypa/pip/pull/10748). When that is stabilized, Hatch will add locking functionality and dedicated documentation for managing applications.
## Tool migration
***Q***: How to migrate to Hatch?
### Build system
=== "Setuptools"
```python tab="setup.py"
import os
from io import open
from setuptools import find_packages, setup
about = {}
with open(os.path.join('src', 'foo', '__about__.py'), 'r', 'utf-8') as f:
exec(f.read(), about)
with open('README.md', 'r', 'utf-8') as f:
readme = f.read()
setup(
# Metadata
name='foo',
version=about['__version__'],
description='...',
long_description=readme,
long_description_content_type='text/markdown',
author='...',
author_email='...',
project_urls={
'Documentation': '...',
'Source': '...',
},
classifiers=[
'...',
],
keywords=[
'...',
],
python_requires='>=3.8',
install_requires=[
'...',
],
extras_require={
'feature': ['...'],
},
# Packaging
packages=find_packages(where='src'),
package_dir={'': 'src'},
package_data={
'foo': ['py.typed'],
},
zip_safe=False,
entry_points={
'console_scripts': [
'foo = foo.cli:main',
],
},
)
```
```text tab="MANIFEST.in"
graft tests
global-exclude *.py[cod] __pycache__
```
=== "Hatch"
```toml tab="pyproject.toml"
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[project]
name = "foo"
description = "..."
readme = "README.md"
authors = [
{ name = "...", email = "..." },
]
classifiers = [
"...",
]
keywords = [
"...",
]
requires-python = ">=3.8"
dependencies = [
"...",
]
dynamic = ["version"]
[project.urls]
Documentation = "..."
Source = "..."
[project.optional-dependencies]
feature = ["..."]
[project.scripts]
foo = "foo.cli:main"
[tool.hatch.version]
path = "src/foo/__about__.py"
[tool.hatch.build.targets.sdist]
include = [
"/src",
"/tests",
]
```
### Environments
=== "Tox"
Invocation:
```
tox
```
```ini tab="tox.ini"
[tox]
envlist =
py{38,39}-{42,3.14}
py{39,310}-{9000}-{foo,bar}
[testenv]
usedevelop = true
deps =
coverage[toml]
pytest
pytest-cov
foo: cryptography
commands =
pytest --cov-report=term-missing --cov-config=pyproject.toml --cov=pkg --cov=tests {posargs}
setenv =
3.14: PRODUCT_VERSION=3.14
42: PRODUCT_VERSION=42
9000: PRODUCT_VERSION=9000
{foo,bar}: EXPERIMENTAL=true
```
=== "Hatch"
Invocation:
```
hatch run test
```
```toml config-example
[tool.hatch.envs.default]
dependencies = [
"coverage[toml]",
"pytest",
"pytest-cov",
]
[tool.hatch.envs.default.scripts]
test = 'pytest --cov-report=term-missing --cov-config=pyproject.toml --cov=pkg --cov=tests'
[tool.hatch.envs.default.overrides]
matrix.version.env-vars = "PRODUCT_VERSION"
matrix.feature.env-vars = "EXPERIMENTAL=true"
matrix.feature.dependencies = [
{ value = "cryptography", if = ["foo"] },
]
[[tool.hatch.envs.default.matrix]]
python = ["3.8", "3.9"]
version = ["42", "3.14"]
[[tool.hatch.envs.default.matrix]]
python = ["3.9", "3.10"]
version = ["9000"]
feature = ["foo", "bar"]
```
## Fast CLI?
The claim about being faster than other tools is [based on timings](https://github.com/pypa/hatch/actions/workflows/cli.yml) that are always checked in CI.
Hatch achieves this by using lazy imports, lazily performing computation manually and with [functools.cached_property](https://docs.python.org/3/library/functools.html#functools.cached_property), using hacks like `not not ...` instead of `bool(...)`, etc.
================================================
FILE: docs/next-steps.md
================================================
# Next steps
-----
## Learn more
At this point you should have a basic understanding of how to use Hatch.
Now you may want to check out advanced configuration for [environments](config/environment/overview.md) or [builds](config/build.md), set up your [preferred shell](config/hatch.md#shell), or read more about Hatch's [CLI](cli/about.md).
After that, check out the [Hatch Showcase](https://github.com/ofek/hatch-showcase) project to see examples of what is possible.
Finally, if you see a need, feel free to write a [plugin](plugins/about.md) for extended functionality.
## Community
For any projects using Hatch, you may add its official badge somewhere prominent like the README.
[{ loading=lazy .off-glb }](https://github.com/pypa/hatch)
=== "Markdown"
```md
[](https://github.com/pypa/hatch)
```
=== "reStructuredText"
```rst
.. image:: https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/pypa/hatch/master/docs/assets/badge/v0.json
:alt: Hatch project
:target: https://github.com/pypa/hatch
```
================================================
FILE: docs/plugins/about.md
================================================
# Plugins
-----
Hatch utilizes [pluggy](https://github.com/pytest-dev/pluggy) for its plugin functionality.
## Overview
All plugins provide registration hooks that return one or more classes that inherit from a particular [type](#types) interface.
Each registration hook must be decorated by Hatch's hook marker. For example, if you wanted to create a new kind of environment you could do:
```python tab="hooks.py"
from hatchling.plugin import hookimpl
from .plugin import SpecialEnvironment
@hookimpl
def hatch_register_environment():
return SpecialEnvironment
```
The hooks can return a single class or a list of classes.
Every class must define an attribute called `PLUGIN_NAME` that users will select when they wish to use the plugin. So in the example above, the class might be defined like:
```python tab="plugin.py"
...
class SpecialEnvironment(...):
PLUGIN_NAME = 'special'
...
```
## Project configuration
### Naming
It is recommended that plugin project names are prefixed with `hatch-`. For example, if you wanted to make a plugin that provides some functionality for a product named `foo` you might do:
```toml tab="pyproject.toml"
[project]
name = "hatch-foo"
```
### Discovery
You'll need to define your project as a [Python plugin](../config/metadata.md#plugins) for Hatch:
```toml tab="pyproject.toml"
[project.entry-points.hatch]
foo = "pkg.hooks"
```
The name of the plugin should be the project name (excluding any `hatch-` prefix) and the path should represent the module that contains the registration hooks.
### Classifier
Add [`Framework :: Hatch`](https://pypi.org/search/?c=Framework+%3A%3A+Hatch) to your project's [classifiers](../config/metadata.md#classifiers) to make it easy to search for Hatch plugins:
```toml tab="pyproject.toml"
[project]
classifiers = [
...
"Framework :: Hatch",
...
]
```
## Types
### Hatchling
These are all involved in building projects and therefore any defined dependencies are automatically installed in each build environment.
- [Builder](builder/reference.md)
- [Build hook](build-hook/reference.md)
- [Metadata hook](metadata-hook/reference.md)
- [Version source](version-source/reference.md)
- [Version scheme](version-scheme/reference.md)
### Hatch
These must be installed in the same environment as Hatch itself.
- [Environment](environment/reference.md)
- [Environment collector](environment-collector/reference.md)
- [Publisher](publisher/reference.md)
================================================
FILE: docs/plugins/build-hook/custom.md
================================================
# Custom build hook
-----
This is a custom class in a given Python file that inherits from the [BuildHookInterface](reference.md#hatchling.builders.hooks.plugin.interface.BuildHookInterface).
## Configuration
The build hook plugin name is `custom`.
```toml config-example
[tool.hatch.build.hooks.custom]
[tool.hatch.build.targets..hooks.custom]
```
## Options
| Option | Default | Description |
| --- | --- | --- |
| `path` | `hatch_build.py` | The path of the Python file |
## Example
```python tab="hatch_build.py"
from hatchling.builders.hooks.plugin.interface import BuildHookInterface
class CustomBuildHook(BuildHookInterface):
...
```
If multiple subclasses are found, you must define a function named `get_build_hook` that returns the desired build hook.
!!! note
Any defined [PLUGIN_NAME](reference.md#hatchling.builders.hooks.plugin.interface.BuildHookInterface.PLUGIN_NAME) is ignored and will always be `custom`.
================================================
FILE: docs/plugins/build-hook/reference.md
================================================
# Build hook plugins
-----
A build hook provides code that will be executed at various stages of the build process. See the documentation for [build hook configuration](../../config/build.md#build-hooks).
## Known third-party
- [hatch-argparse-manpage](https://github.com/damonlynch/hatch-argparse-manpage) - generate man pages for [argparse](https://docs.python.org/3/library/argparse.html#argparse.ArgumentParser)-based CLIs
- [hatch-autorun](https://github.com/ofek/hatch-autorun) - used to inject code into an installation that will automatically run before the first import
- [hatch-build-scripts](https://github.com/rmorshea/hatch-build-scripts) - run arbitrary shell commands that create artifacts
- [hatch-cython](https://github.com/joshua-auchincloss/hatch-cython) - build [Cython](https://github.com/cython/cython) extensions
- [hatch-gettext](https://github.com/damonlynch/hatch-gettext) - compiles multi-lingual messages with GNU `gettext` tools
- [hatch-jupyter-builder](https://github.com/jupyterlab/hatch-jupyter-builder) - used for packages in the Project Jupyter ecosystem
- [hatch-mypyc](https://github.com/ofek/hatch-mypyc) - compiles code with [Mypyc](https://github.com/mypyc/mypyc)
- [hatch-odoo](https://github.com/acsone/hatch-odoo) - package Odoo add-ons into the appropriate namespace
- [hatch-project-name](https://github.com/valentinoli/hatch-project-name/) - writes the project name to a file
- [scikit-build-core](https://github.com/scikit-build/scikit-build-core) - build extension modules with CMake
## Overview
Build hooks run for every selected [version](../../config/build.md#versions) of build targets.
The [initialization](#hatchling.builders.hooks.plugin.interface.BuildHookInterface.initialize) stage occurs immediately before each build and the [finalization](#hatchling.builders.hooks.plugin.interface.BuildHookInterface.finalize) stage occurs immediately after. Each stage has the opportunity to view or modify [build data](#build-data).
## Build data
Build data is a simple mapping whose contents can influence the behavior of builds. Which fields exist and are recognized depends on each build target.
The following fields are always present and recognized by the build system itself:
| Field | Type | Description |
| --- | --- | --- |
| `artifacts` | `#!python list[str]` | This is a list of extra [`artifact` patterns](../../config/build.md#artifacts) and should generally only be appended to |
| `force_include` | `#!python dict[str, str]` | This is a mapping of extra [forced inclusion paths](../../config/build.md#forced-inclusion), with this mapping taking precedence in case of conflicts |
| `build_hooks` | `#!python tuple[str, ...]` | This is an immutable sequence of the names of the configured build hooks and matches the order in which they run |
!!! attention
While user-facing TOML options are hyphenated, build data fields should be named with underscores to allow plugins to use them as valid Python identifiers.
## Notes
In some cases it may be necessary to use `force_include` rather than `artifacts`. For example, say that you want to install a `lib.so` directly at the root of `site-packages` and a project defines a [package](../../config/build.md#packages) `src/foo`. If you create `src/lib.so`, there will never be a match because the directory traversal starts at `src/foo` rather than `src`. In that case you must do either:
```python
build_data['force_include']['src/lib.so'] = 'src/lib.so'
```
or
```python
build_data['force_include']['/absolute/path/to/src/lib.so'] = 'src/lib.so'
```
::: hatchling.builders.hooks.plugin.interface.BuildHookInterface
options:
members:
- PLUGIN_NAME
- app
- root
- config
- build_config
- target_name
- directory
- dependencies
- clean
- initialize
- finalize
================================================
FILE: docs/plugins/build-hook/version.md
================================================
# Version build hook
-----
This writes the project's version to a file.
## Configuration
The build hook plugin name is `version`.
```toml config-example
[tool.hatch.build.hooks.version]
[tool.hatch.build.targets..hooks.version]
```
## Options
| Option | Description |
| --- | --- |
| `path` (required) | A relative path to the desired file |
| `template` | A string representing the entire contents of `path` that will be formatted with a `version` variable |
| `pattern` | Rather than updating the entire file, a regular expression may be used that has a named group called `version` that represents the version. If set to `true`, a pattern will be used that looks for a variable named `__version__` or `VERSION` that is set to a string containing the version, optionally prefixed with the lowercase letter `v`. |
================================================
FILE: docs/plugins/builder/binary.md
================================================
# Binary builder
-----
This uses [PyApp](https://github.com/ofek/pyapp) to build an application that is able to bootstrap itself at runtime.
!!! note
This requires an installation of [Rust](https://www.rust-lang.org).
## Configuration
The builder plugin name is `binary`.
```toml config-example
[tool.hatch.build.targets.binary]
```
## Options
| Option | Default | Description |
| --- | --- | --- |
| `scripts` | all defined | An array of defined [script](../../config/metadata.md#cli) names to limit what gets built |
| `python-version` | latest compatible Python minor version | The [Python version ID](https://ofek.dev/pyapp/latest/config/#known) to use |
| `pyapp-version` | | The version of PyApp to use |
## Build behavior
If any [scripts](../../config/metadata.md#cli) are defined then each one will be built (limited by the `scripts` option). Otherwise, a single executable will be built based on the project name assuming there is an equivalently named module with a `__main__.py` file.
Every executable will be built inside an `app` directory in the [output directory](../../config/build.md#output-directory).
If the `CARGO` environment variable is set then that path will be used as the executable for performing builds.
If the [`CARGO_BUILD_TARGET`](https://doc.rust-lang.org/cargo/reference/config.html#buildtarget) environment variable is set then its value will be appended to the file name stems.
If the `PYAPP_REPO` environment variable is set then a local build will be performed inside that directory rather than installing from [crates.io](https://crates.io). Note that this is [required](https://github.com/cross-rs/cross/issues/1215) if the `CARGO` environment variable refers to [cross](https://github.com/cross-rs/cross).
================================================
FILE: docs/plugins/builder/custom.md
================================================
# Custom builder
-----
This is a custom class in a given Python file that inherits from the [BuilderInterface](reference.md#hatchling.builders.plugin.interface.BuilderInterface).
## Configuration
The builder plugin name is `custom`.
```toml config-example
[tool.hatch.build.targets.custom]
```
## Options
| Option | Default | Description |
| --- | --- | --- |
| `path` | `hatch_build.py` | The path of the Python file |
## Example
```python tab="hatch_build.py"
from hatchling.builders.plugin.interface import BuilderInterface
class CustomBuilder(BuilderInterface):
...
```
If multiple subclasses are found, you must define a function named `get_builder` that returns the desired builder.
!!! note
Any defined [PLUGIN_NAME](reference.md#hatchling.builders.plugin.interface.BuilderInterface.PLUGIN_NAME) is ignored and will always be `custom`.
================================================
FILE: docs/plugins/builder/reference.md
================================================
# Builder plugins
-----
See the documentation for [build configuration](../../config/build.md).
## Known third-party
- [hatch-aws](https://github.com/aka-raccoon/hatch-aws) - used for building AWS Lambda functions with SAM
- [hatch-zipped-directory](https://github.com/dairiki/hatch-zipped-directory) - used for building ZIP archives for installation into various foreign package installation systems
::: hatchling.builders.plugin.interface.BuilderInterface
options:
members:
- PLUGIN_NAME
- app
- root
- build_config
- target_config
- config
- get_config_class
- get_version_api
- get_default_versions
- clean
- recurse_included_files
- get_default_build_data
================================================
FILE: docs/plugins/builder/sdist.md
================================================
# Source distribution builder
-----
A source distribution, or `sdist`, is an archive of Python "source code". Although largely unspecified, by convention it should include everything that is required to build a [wheel](wheel.md) without making network requests.
## Configuration
The builder plugin name is `sdist`.
```toml config-example
[tool.hatch.build.targets.sdist]
```
## Options
| Option | Default | Description |
| --- | --- | --- |
| `core-metadata-version` | `"2.4"` | The version of [core metadata](https://packaging.python.org/specifications/core-metadata/) to use |
| `strict-naming` | `true` | Whether or not file names should contain the normalized version of the project name |
| `support-legacy` | `false` | Whether or not to include a `setup.py` file to support legacy installation mechanisms |
## Versions
| Version | Description |
| --- | --- |
| `standard` (default) | The latest conventional format |
## Default file selection
When the user has not set any [file selection](../../config/build.md#file-selection) options, all files that are not [ignored by your VCS](../../config/build.md#vcs) will be included.
!!! note
The following files are always included and cannot be excluded:
- `/pyproject.toml`
- `/hatch.toml`
- `/hatch_build.py`
- `/.gitignore` or `/.hgignore`
- Any defined [`readme`](../../config/metadata.md#readme) file
- All defined [`license-files`](../../config/metadata.md#license)
## Reproducibility
[Reproducible builds](../../config/build.md#reproducible-builds) are supported.
## Build data
This is data that can be modified by [build hooks](../build-hook/reference.md).
| Data | Default | Description |
| --- | --- | --- |
| `dependencies` | | Extra [project dependencies](../../config/metadata.md#required) |
================================================
FILE: docs/plugins/builder/wheel.md
================================================
# Wheel builder
-----
A [wheel](https://packaging.python.org/specifications/binary-distribution-format/) is a binary distribution of a Python package that can be installed directly into an environment.
## Configuration
The builder plugin name is `wheel`.
```toml config-example
[tool.hatch.build.targets.wheel]
```
## Options
| Option | Default | Description |
| --- | --- | --- |
| `core-metadata-version` | `"2.4"` | The version of [core metadata](https://packaging.python.org/specifications/core-metadata/) to use |
| `shared-data` | | A mapping similar to the [forced inclusion](../../config/build.md#forced-inclusion) option corresponding to the `data` subdirectory within the standard [data directory](https://packaging.python.org/en/latest/specifications/binary-distribution-format/#the-data-directory) that will be installed globally in a given Python environment, usually under `#!python sys.prefix` |
| `shared-scripts` | | A mapping similar to the [forced inclusion](../../config/build.md#forced-inclusion) option corresponding to the `scripts` subdirectory within the standard [data directory](https://packaging.python.org/en/latest/specifications/binary-distribution-format/#the-data-directory) that will be installed in a given Python environment, usually under `Scripts` on Windows or `bin` otherwise, and would normally be available on PATH |
| `extra-metadata` | | A mapping similar to the [forced inclusion](../../config/build.md#forced-inclusion) option corresponding to extra [metadata](https://peps.python.org/pep-0427/#the-dist-info-directory) that will be shipped in a directory named `extra_metadata` |
| `strict-naming` | `true` | Whether or not file names should contain the normalized version of the project name |
| `macos-max-compat` | `false` | Whether or not on macOS, when build hooks have set the `infer_tag` [build data](#build-data), the wheel name should signal broad support rather than specific versions for newer SDK versions. Note: This option will eventually be removed. |
| `bypass-selection` | `false` | Whether or not to suppress the error when one has not defined any file selection options and all heuristics have failed to determine what to ship |
| `sbom-files` | | A list of paths to [Software Bill of Materials](https://peps.python.org/pep-0770/) files that will be included in the `.dist-info/sboms/` directory of the wheel |
!!! note
Many build frontends will build the wheel from a source distribution. This is the recommended approach, but it means you need to ensure the SBOM files passed to `sbom-files` are also [included in the source distribution](https://hatch.pypa.io/latest/config/build/#file-selection).
## Versions
| Version | Description |
| --- | --- |
| `standard` (default) | The latest standardized format |
| `editable` | A wheel that only ships `.pth` files or import hooks for real-time development |
## Default file selection
When the user has not set any [file selection](../../config/build.md#file-selection) options, the [project name](../../config/metadata.md#name) will be used to determine the package to ship in the following heuristic order:
1. `/__init__.py`
2. `src//__init__.py`
3. `.py`
4. `//__init__.py`
If none of these heuristics are satisfied, an error will be raised.
## Reproducibility
[Reproducible builds](../../config/build.md#reproducible-builds) are supported.
## Build data
This is data that can be modified by [build hooks](../build-hook/reference.md).
| Data | Default | Description |
| --- | --- | --- |
| `tag` | | The full [tag](https://peps.python.org/pep-0425/) part of the filename (e.g. `py3-none-any`), defaulting to a cross-platform wheel with the supported major versions of Python based on [project metadata](../../config/metadata.md#python-support) |
| `infer_tag` | `#!python False` | When `tag` is not set, this may be enabled to use the one most specific to the platform, Python interpreter, and ABI |
| `pure_python` | `#!python True` | Whether or not to write metadata indicating that the package does not contain any platform-specific files |
| `dependencies` | | Extra [project dependencies](../../config/metadata.md#required) |
| `shared_data` | | Additional [`shared-data`](#options) entries, which take precedence in case of conflicts |
| `shared_scripts` | | Additional [`shared-scripts`](#options) entries, which take precedence in case of conflicts |
| `extra_metadata` | | Additional [`extra-metadata`](#options) entries, which take precedence in case of conflicts |
| `force_include_editable` | | Similar to the [`force_include` option](../build-hook/reference.md#build-data) but specifically for the `editable` [version](#versions) and takes precedence |
| `sbom_files` | | This is a list of the sbom files that should be included under `.dist-info/sboms`. |
================================================
FILE: docs/plugins/environment/reference.md
================================================
# Environment plugins
-----
See the documentation for [environment configuration](../../config/environment/overview.md).
## Known third-party
- [hatch-conda](https://github.com/OldGrumpyViking/hatch-conda) - environments backed by Conda/Mamba
- [hatch-containers](https://github.com/ofek/hatch-containers) - environments run inside containers
- [hatch-pip-compile](https://github.com/juftin/hatch-pip-compile) - use [pip-compile](https://github.com/jazzband/pip-tools) to manage project dependencies and lockfiles
- [hatch-pip-deepfreeze](https://github.com/sbidoul/hatch-pip-deepfreeze) - [virtual](virtual.md) environments with dependency locking by [pip-deepfreeze](https://github.com/sbidoul/pip-deepfreeze)
## Installation
Any required environment types that are not built-in must be manually installed alongside Hatch or listed in the `tool.hatch.env.requires` array for automatic management:
```toml config-example
[tool.hatch.env]
requires = [
"...",
]
```
## Life cycle
Whenever an environment is used, the following logic is performed:
::: hatch.project.core.Project.prepare_environment
options:
show_root_heading: false
show_root_toc_entry: false
## Build environments
All environment types should [offer support](#hatch.env.plugin.interface.EnvironmentInterface.fs_context) for synchronized storage between the local file system and the environment. This functionality is used in the following scenarios:
- the [`build`](../../cli/reference.md#hatch-build) command
- commands that read dependencies, like [`dep hash`](../../cli/reference.md#hatch-dep-hash), if any [project dependencies](../../config/metadata.md#dependencies) are [set dynamically](../../config/metadata.md#dynamic)
::: hatch.env.plugin.interface.EnvironmentInterface
options:
members:
- PLUGIN_NAME
- find
- create
- remove
- exists
- install_project
- install_project_dev_mode
- dependencies_in_sync
- sync_dependencies
- dependency_hash
- project_dependencies
- project_root
- sep
- pathsep
- fs_context
- activate
- deactivate
- app_status_creation
- app_status_pre_installation
- app_status_post_installation
- app_status_project_installation
- app_status_dependency_state_check
- app_status_dependency_installation_check
- app_status_dependency_synchronization
- app
- root
- name
- data_directory
- isolated_data_directory
- config
- platform
- environment_dependencies
- dependencies
- env_vars
- env_include
- env_exclude
- platforms
- skip_install
- dev_mode
- description
- command_context
- enter_shell
- run_shell_command
- resolve_commands
- get_env_vars
- apply_features
- construct_pip_install_command
- join_command_args
- check_compatibility
- get_option_types
- get_env_var_option
- get_context
================================================
FILE: docs/plugins/environment/virtual.md
================================================
# Virtual environment
-----
This uses virtual environments backed by [virtualenv](https://github.com/pypa/virtualenv) or [UV](https://github.com/astral-sh/uv).
## Configuration
The environment plugin name is `virtual`.
```toml config-example
[tool.hatch.envs.]
type = "virtual"
```
## Options
| Option | Default | Description |
| --- | --- | --- |
| `python` | | The version of Python to find on your system and subsequently use to create the environment, defaulting to the `HATCH_PYTHON` environment variable, followed by the [normal resolution logic](#python-resolution). Setting the `HATCH_PYTHON` environment variable to `self` will force the use of the Python executable Hatch is running on. For more information, see the [documentation](https://virtualenv.pypa.io/en/latest/user_guide.html#python-discovery). |
| `python-sources` | `['external', 'internal']` | This may be set to an array of strings that are either the literal `internal` or `external`. External considers only Python executables that are already on `PATH`. Internal considers only [internally managed Python distributions](#internal-distributions). |
| `path` | | An explicit path to the virtual environment. The path may be absolute or relative to the project root. Any environments that [inherit](../../config/environment/overview.md#inheritance) this option will also use this path. The environment variable `HATCH_ENV_TYPE_VIRTUAL_PATH` may be used, which will take precedence. |
| `system-packages` | `false` | Whether or not to give the virtual environment access to the system `site-packages` directory |
| `installer` | `pip` | When set to `uv`, [UV](https://github.com/astral-sh/uv) will be used in place of virtualenv & pip for virtual environment creation and dependency management, respectively. If you intend to provide UV yourself, you may set the `HATCH_ENV_TYPE_VIRTUAL_UV_PATH` environment variable which should be the absolute path to a UV binary. This environment variable implicitly sets the `installer` option to `uv` (if unset). |
## Location
The [location](../../cli/reference.md#hatch-env-find) of environments is determined in the following heuristic order:
1. The `path` option
2. A directory named after the environment within the configured `virtual` [environment directory](../../config/hatch.md#environments) if the directory resides somewhere within the project root or if it is set to a `.virtualenvs` directory within the user's home directory
3. Otherwise, environments are stored within the configured `virtual` [environment directory](../../config/hatch.md#environments) in a deeply nested structure in order to support multiple projects
Additionally, when the `path` option is not used, the name of the directory for the `default` environment will be the normalized project name to provide a more meaningful default [shell](../../cli/reference.md#hatch-shell) prompt.
## Python resolution
Virtual environments necessarily require a parent installation of Python. The following rules determine how the parent is resolved.
The Python choice is determined by the [`python` option](#options) followed by the `HATCH_PYTHON` environment variable. If the choice is via the environment variable, then resolution stops and that path is used unconditionally.
The resolvers will be based on the [`python-sources` option](#options) and all resolved interpreters will ensure compatibility with the project's defined [Python support](../../config/metadata.md#python-support).
If a Python version has been chosen then each resolver will try to find an interpreter that satisfies that version.
If no version has been chosen, then each resolver will try to find a version that matches the version of Python that Hatch is currently running on. If not found then each resolver will try to find the highest compatible version.
!!! note
Some external Python paths are considered unstable and are ignored during resolution. For example, if Hatch is installed via Homebrew then `sys.executable` will be ignored because the interpreter could change or be removed at any time.
!!! note
When resolution finds a match using an [internally managed distribution](#internal-distributions) and an update is available, the latest distribution will automatically be downloaded before environment creation.
## Internal distributions
The following options are recognized for internal Python resolution.
!!! tip
You can set custom sources for distributions by setting the `HATCH_PYTHON_SOURCE_` environment variable where `` is the uppercased version of the distribution name with periods replaced by underscores e.g. `HATCH_PYTHON_SOURCE_PYPY3_10`.
### CPython
| NAME |
| --- |
| `3.7` |
| `3.8` |
| `3.9` |
| `3.10` |
| `3.11` |
| `3.12` |
| `3.13` |
The source of distributions is the [python-build-standalone](https://github.com/indygreg/python-build-standalone) project.
Some distributions have [variants](https://gregoryszorc.com/docs/python-build-standalone/main/running.html) that may be configured with environment variables. Options may be combined.
| Option | Platforms | Allowed values |
| --- | --- | --- |
| `HATCH_PYTHON_VARIANT_CPU` | | |
| `HATCH_PYTHON_VARIANT_GIL` | | |
### PyPy
| NAME |
| --- |
| `pypy2.7` |
| `pypy3.9` |
| `pypy3.10` |
The source of distributions is the [PyPy](https://www.pypy.org) project.
## Troubleshooting
### macOS SIP
If you need to set linker environment variables like those starting with `DYLD_` or `LD_`, any executable secured by [System Integrity Protection](https://en.wikipedia.org/wiki/System_Integrity_Protection) that is invoked when [running commands](../../environment.md#command-execution) will not see those environment variable modifications as macOS strips those.
Hatch interprets such commands as shell commands but deliberately ignores such paths to protected shells. This workaround suffices for the majority of use cases but there are 2 situations in which it may not:
1. There are no unprotected `sh`, `bash`, `zsh`, nor `fish` executables found along PATH.
2. The desired executable is a project's [script](../../config/metadata.md#cli), and the [location](#location) of environments contains spaces or is longer than 124[^1] characters. In this case `pip` and other installers will create such an entry point with a shebang pointing to `/bin/sh` (which is protected) to avoid shebang limitations. Rather than changing the location, you could invoke the script as e.g. `python -m pytest` (if the project supports that method of invocation by shipping a `__main__.py`).
[^1]: The shebang length limit is [usually](https://web.archive.org/web/20221231220856/https://www.in-ulm.de/~mascheck/various/shebang/#length) 127 but 3 characters surround the executable path: `#!\n`
================================================
FILE: docs/plugins/environment-collector/custom.md
================================================
# Custom environment collector
-----
This is a custom class in a given Python file that inherits from the [EnvironmentCollectorInterface](reference.md#hatch.env.collectors.plugin.interface.EnvironmentCollectorInterface).
## Configuration
The environment collector plugin name is `custom`.
```toml config-example
[tool.hatch.env.collectors.custom]
```
## Options
| Option | Default | Description |
| --- | --- | --- |
| `path` | `hatch_plugins.py` | The path of the Python file |
## Example
```python tab="hatch_plugins.py"
from hatch.env.collectors.plugin.interface import EnvironmentCollectorInterface
class CustomEnvironmentCollector(EnvironmentCollectorInterface):
...
```
If multiple subclasses are found, you must define a function named `get_environment_collector` that returns the desired environment collector.
!!! note
Any defined [PLUGIN_NAME](reference.md#hatch.env.collectors.plugin.interface.EnvironmentCollectorInterface.PLUGIN_NAME) is ignored and will always be `custom`.
================================================
FILE: docs/plugins/environment-collector/default.md
================================================
# Default environment collector
-----
This adds the `default` environment with [type](../../config/environment/overview.md#type) set to [virtual](../environment/virtual.md) and will always be applied.
## Configuration
The environment collector plugin name is `default`.
```toml config-example
[tool.hatch.env.collectors.default]
```
## Options
There are no options available currently.
================================================
FILE: docs/plugins/environment-collector/reference.md
================================================
# Environment collector plugins
-----
Environment collectors allow for dynamically modifying environments or adding environments beyond those defined in config. Users can override default values provided by each environment.
## Known third-party
- [hatch-mkdocs](https://github.com/mkdocs/hatch-mkdocs) - integrate [MkDocs](https://github.com/mkdocs/mkdocs) and infer dependencies into an env
## Installation
Any required environment collectors that are not built-in must be manually installed alongside Hatch or listed in the `tool.hatch.env.requires` array for automatic management:
```toml config-example
[tool.hatch.env]
requires = [
"...",
]
```
::: hatch.env.collectors.plugin.interface.EnvironmentCollectorInterface
options:
members:
- PLUGIN_NAME
- root
- config
- get_initial_config
- finalize_config
- finalize_environments
================================================
FILE: docs/plugins/metadata-hook/custom.md
================================================
# Custom metadata hook
-----
This is a custom class in a given Python file that inherits from the [MetadataHookInterface](reference.md#hatchling.metadata.plugin.interface.MetadataHookInterface).
## Configuration
The metadata hook plugin name is `custom`.
```toml config-example
[tool.hatch.metadata.hooks.custom]
```
## Options
| Option | Default | Description |
| --- | --- | --- |
| `path` | `hatch_build.py` | The path of the Python file |
## Example
```python tab="hatch_build.py"
from hatchling.metadata.plugin.interface import MetadataHookInterface
class CustomMetadataHook(MetadataHookInterface):
...
```
If multiple subclasses are found, you must define a function named `get_metadata_hook` that returns the desired build hook.
!!! note
Any defined [PLUGIN_NAME](reference.md#hatchling.metadata.plugin.interface.MetadataHookInterface.PLUGIN_NAME) is ignored and will always be `custom`.
================================================
FILE: docs/plugins/metadata-hook/reference.md
================================================
# Metadata hook plugins
-----
Metadata hooks allow for the modification of [project metadata](../../config/metadata.md) after it has been loaded.
## Known third-party
- [hatch-docstring-description](https://github.com/flying-sheep/hatch-docstring-description) - set the project description using docstrings
- [hatch-fancy-pypi-readme](https://github.com/hynek/hatch-fancy-pypi-readme) - dynamically construct the README
- [hatch-nodejs-version](https://github.com/agoose77/hatch-nodejs-version) - uses fields from NodeJS `package.json` files
- [hatch-odoo](https://github.com/acsone/hatch-odoo) - determine dependencies based on manifests of Odoo add-ons
- [hatch-requirements-txt](https://github.com/repo-helper/hatch-requirements-txt) - read project dependencies from `requirements.txt` files
- [UniDep](https://github.com/basnijholt/unidep) - for unified `pip` and `conda` dependency management using a single `requirements.yaml` file for both
::: hatchling.metadata.plugin.interface.MetadataHookInterface
options:
members:
- PLUGIN_NAME
- root
- config
- update
- get_known_classifiers
================================================
FILE: docs/plugins/publisher/package-index.md
================================================
# Index publisher
-----
See the documentation for [publishing](../../publish.md).
## Options
| Flag | Config name | Description |
| --- | --- | --- |
| `-r`/`--repo` | `repo` | The repository with which to publish artifacts |
| `-u`/`--user` | `user` | The user with which to authenticate |
| `-a`/`--auth` | `auth` | The credentials to use for authentication |
| `--ca-cert` | `ca-cert` | The path to a CA bundle |
| `--client-cert` | `client-cert` | The path to a client certificate, optionally containing the private key |
| `--client-key` | `client-key` | The path to the client certificate's private key |
| | `repos` | A table of named [repositories](#repositories) to their respective options |
## Configuration
The publisher plugin name is `index`.
```toml tab="config.toml"
[publish.index]
```
### Repositories
All top-level options can be overridden per repository using the `repos` table
with a required `url` attribute for each repository. The following shows the
default configuration:
```toml tab="config.toml"
[publish.index.repos.main]
url = "https://upload.pypi.org/legacy/"
[publish.index.repos.test]
url = "https://test.pypi.org/legacy/"
```
The `repo` and `repos` options have no effect.
### Confirmation prompt
You can require a confirmation prompt or use of the `-y`/`--yes` flag by
setting publishers' `disable` option to `true` in either Hatch's
[config file](../../config/hatch.md) or project-specific configuration (which takes
precedence):
```toml tab="config.toml"
[publish.index]
disable = true
```
```toml config-example
[tool.hatch.publish.index]
disable = true
```
================================================
FILE: docs/plugins/publisher/reference.md
================================================
# Publisher plugins
-----
## Known third-party
- [hatch-aws-publisher](https://github.com/aka-raccoon/hatch-aws-publisher) - publish AWS Lambda functions with SAM
::: hatch.publish.plugin.interface.PublisherInterface
options:
members:
- PLUGIN_NAME
- app
- root
- cache_dir
- project_config
- plugin_config
- disable
- publish
================================================
FILE: docs/plugins/utilities.md
================================================
# Plugin utilities
-----
::: hatchling.builders.utils.get_reproducible_timestamp
options:
show_root_full_path: true
::: hatchling.builders.config.BuilderConfig
options:
show_source: false
members:
- directory
- ignore_vcs
- reproducible
- dev_mode_dirs
- versions
- dependencies
- default_include
- default_exclude
- default_packages
- default_only_include
::: hatchling.bridge.app.Application
options:
show_source: false
members:
- abort
- verbosity
- display_debug
- display_error
- display_info
- display_success
- display_waiting
- display_warning
::: hatch.utils.platform.Platform
options:
show_source: false
members:
- format_for_subprocess
- run_command
- check_command
- check_command_output
- capture_process
- exit_with_command
- default_shell
- modules
- home
- name
- display_name
- windows
- macos
- linux
::: hatch.env.context.EnvironmentContextFormatter
options:
show_source: false
members:
- formatters
::: hatch.env.plugin.interface.FileSystemContext
options:
show_source: false
members:
- env
- sync_local
- sync_env
- local_path
- env_path
- join
================================================
FILE: docs/plugins/version-scheme/reference.md
================================================
# Version scheme plugins
-----
## Known third-party
- [hatch-semver](https://github.com/Nagidal/hatch-semver) - uses [semantic versioning](https://semver.org)
::: hatchling.version.scheme.plugin.interface.VersionSchemeInterface
options:
members:
- PLUGIN_NAME
- root
- config
- validate_bump
- update
================================================
FILE: docs/plugins/version-scheme/standard.md
================================================
# Standard version scheme
-----
See the documentation for [versioning](../../version.md#updating).
## Configuration
The version scheme plugin name is `standard`.
```toml config-example
[tool.hatch.version]
scheme = "standard"
```
## Options
| Option | Description |
| --- | --- |
| `validate-bump` | When setting a specific version, this determines whether to check that the new version is higher than the original. The default is `true`. |
================================================
FILE: docs/plugins/version-source/code.md
================================================
# Code version source
-----
## Updates
Setting the version is not supported.
## Configuration
The version source plugin name is `code`.
```toml config-example
[tool.hatch.version]
source = "code"
```
## Options
| Option | Description |
| --- | --- |
| `path` (required) | A relative path to a Python file or extension module that will be loaded |
| `expression` | A Python expression that when evaluated in the context of the loaded file returns the version. The default expression is simply `__version__`. |
| `search-paths` | A list of relative paths to directories that will be prepended to Python's search path |
## Missing imports
If the chosen path imports another module in your project, then you'll need to use absolute imports coupled with the `search-paths` option. For example, say you need to load the following file:
```python tab="src/pkg/\_\_init\_\_.py"
from ._version import get_version
__version__ = get_version()
```
You should change it to:
```python tab="src/pkg/\_\_init\_\_.py"
from pkg._version import get_version
__version__ = get_version()
```
and the configuration would become:
```toml config-example
[tool.hatch.version]
source = "code"
path = "src/pkg/__init__.py"
search-paths = ["src"]
```
================================================
FILE: docs/plugins/version-source/env.md
================================================
# Environment version source
-----
Retrieves the version from an environment variable. This can be useful in build pipelines where the version is set by an external trigger.
## Updates
Setting the version is not supported.
## Configuration
The version source plugin name is `env`.
```toml config-example
[tool.hatch.version]
source = "env"
```
## Options
| Option | Description |
| --- | --- |
| `variable` (required) | The name of the environment variable |
================================================
FILE: docs/plugins/version-source/reference.md
================================================
# Version source plugins
-----
## Known third-party
- [hatch-vcs](https://github.com/ofek/hatch-vcs) - uses your preferred version control system (like Git)
- [hatch-nodejs-version](https://github.com/agoose77/hatch-nodejs-version) - uses the `version` field of NodeJS `package.json` files
- [hatch-regex-commit](https://github.com/frankie567/hatch-regex-commit) - automatically creates a Git commit and tag after version bumping
- [versioningit](https://github.com/jwodder/versioningit) - determines version from Git or Mercurial tags, with customizable version formatting
::: hatchling.version.source.plugin.interface.VersionSourceInterface
options:
members:
- PLUGIN_NAME
- root
- config
- get_version_data
- set_version
================================================
FILE: docs/plugins/version-source/regex.md
================================================
# Regex version source
-----
See the documentation for [versioning](../../version.md).
## Updates
Setting the version is supported.
## Configuration
The version source plugin name is `regex`.
```toml config-example
[tool.hatch.version]
source = "regex"
```
## Options
| Option | Description |
| --- | --- |
| `path` (required) | A relative path to a file containing the project's version |
| `pattern` | A regular expression that has a named group called `version` that represents the version. The default pattern looks for a variable named `__version__` or `VERSION` that is set to a string containing the version, optionally prefixed with the lowercase letter `v`. |
================================================
FILE: docs/publish.md
================================================
# Publishing
-----
After your project is [built](build.md), you can distribute it using the [`publish`](cli/reference.md#hatch-publish) command.
The `-p`/`--publisher` option controls which publisher to use, with the default being [index](plugins/publisher/package-index.md).
## Artifact selection
By default, the `dist` directory located at the root of your project will be used:
```console
$ hatch publish
dist/hatch_demo-1rc0-py3-none-any.whl ... success
dist/hatch_demo-1rc0.tar.gz ... success
[hatch-demo]
https://pypi.org/project/hatch-demo/1rc0/
```
You can instead pass specific paths as arguments:
```
hatch publish /path/to/artifacts foo-1.tar.gz
```
Only files ending with `.whl` or `.tar.gz` will be published.
## Further resources
Please refer to the publisher plugin [reference](plugins/publisher/package-index.md)
for configuration options.
There's a How-To on [authentication](how-to/publish/auth.md)
and on options to select the target [repository](how-to/publish/repo.md).
The `publish` command is implemented as a built-in plugin, if you're
planning your own plugin, read about the [publisher plugin API](plugins/publisher/reference.md).
================================================
FILE: docs/tutorials/environment/basic-usage.md
================================================
# Managing environments
-----
Hatch [environments](../../environment.md) are isolated workspaces that can be used for project tasks including running tests, building documentation and running code formatters and linters.
## The default environment
When you start using Hatch, you can create the `default` environment. To do this use the [`env create`](../../cli/reference.md#hatch-env-create) command:
```
hatch env create
```
This will not only create will the `default` environment for you to work in but will also install your project in [dev mode](../../config/environment/overview.md#dev-mode) in this `default` environment.
!!! tip
You never need to manually create environments as [spawning a shell](#launching-a-shell-within-a-specific-environment) or [running commands](#run-commands-within-a-specific-environment) within one will automatically trigger creation.
### Using the default environment
Hatch will always use the `default` environment if an environment is not chosen explicitly when [running a command](../../environment.md#command-execution).
For instance, the following shows how to get version information for the Python in use.
```console
$ hatch run python -V
Python 3.12.1
```
### Configure the default environment
You can customize the tools that are installed into the `default` environment by adding a table called `tool.hatch.envs.default` to your `pyproject.toml` file. Below is an example of adding the [dependencies](../../config/environment/overview.md#dependencies) `pydantic` and `numpy` to the `default` environment.
```toml config-example
[tool.hatch.envs.default]
dependencies = [
"pydantic",
"numpy",
]
```
You can declare versions for your dependencies as well within this configuration.
```toml config-example
[tool.hatch.envs.default]
dependencies = [
"pydantic>=2.0",
"numpy",
]
```
## Create custom environment
You can create custom environments in Hatch by adding a section to your `pyproject.toml` file `[tool.hatch.envs.]`. Below you define an environment called `test` and you add the `pytest` and `pytest-cov` dependencies to that environment's configuration.
```toml config-example
[tool.hatch.envs.test]
dependencies = [
"pytest",
"pytest-cov"
]
```
The first time that you call the test environment, Hatch will:
1. Create the environment
2. Install your project into that environment in [dev mode](../../config/environment/overview.md#dev-mode) (by default) along with its [dependencies](../../config/metadata.md#dependencies).
3. Install the environment's [dependencies](../../config/environment/overview.md#dependencies)
## Run commands within a specific environment
Hatch offers a unique environment feature that allows you run a specific command within a specific environment rather than needing to activate the environment as you would using a tool such as [Conda](https://conda.org) or [venv](https://docs.python.org/3/library/venv.html).
For instance, if you define an environment called `test` that contains the dependencies from the previous section, you can run the `pytest` command from the `test` environment using the syntax:
```
hatch run :command
```
To access the `test` environment and run `pytest`, you can run:
```console
$ hatch run test:pytest
============================== test session starts ===============================
platform darwin -- Python 3.12.1, pytest-7.4.4, pluggy-1.3.0
rootdir: /your/path/to/yourproject
collected 0 items
```
!!! note
`test:pytest` represents the name of the environment to call (`test`) and the command to run (`pytest`).
## View current environments
Above you defined and created a new test environment in your `pyproject.toml` file. You can now use the [`env show`](../../cli/reference.md#hatch-env-show) command to see both the currently created environments and the dependencies in each environment.
```
$ hatch env show
Standalone
┏━━━━━━━━━┳━━━━━━━━━┳━━━━━━━━━━━━━━┓
┃ Name ┃ Type ┃ Dependencies ┃
┡━━━━━━━━━╇━━━━━━━━━╇━━━━━━━━━━━━━━┩
│ default │ virtual │ │
├─────────┼─────────┼──────────────┤
│ test │ virtual │ pytest │
│ │ │ pytest-cov │
└─────────┴─────────┴──────────────┘
```
!!! note
The output may have more columns depending on your environment configuration.
## Locating environments
To see where your current environment is located you can use the [`env find`](../../cli/reference.md#hatch-env-find) command.
```
$ hatch env find test
/your/path/Application Support/hatch/env/virtual/yourproject/twO2iQR3/test
```
!!! note
That path is what you would see on macOS but differs for each platform, and is [configurable](../../plugins/environment/virtual.md#location).
## Launching a shell within a specific environment
If you wish to [launch a shell](../../environment.md#entering-environments) for a specific environment that you have created, like the previous `test` environment, you can use:
```
hatch -e test shell
```
Once the environment is active, you can run commands like you would in any Python environment.
Notice below that when running `pip list` in the test environment, you can see:
1. That your package is installed in editable mode.
2. That the environment contains both `pytest` and `pytest-cov` as specified above in the `pyproject.toml` file.
```
$ pip list
Package Version Editable project location
----------- ------- ----------------------------------------------------
coverage 7.4.1
iniconfig 2.0.0
packaging 23.2
pip 23.3.1
pluggy 1.4.0
pytest 8.0.0
pytest-cov 4.1.0
yourproject 0.1.0 /your/path/to/yourproject
```
## Conda environments
If you prefer to use [Conda](https://conda.org) environments with Hatch, you can check out the [hatch-conda plugin](https://github.com/OldGrumpyViking/hatch-conda).
================================================
FILE: docs/tutorials/python/manage.md
================================================
# Managing Python distributions
-----
The [`python`](../../cli/reference.md#hatch-python) command group provides a set of commands to manage Python distributions that may be used by other tools.
!!! note
When using environments, manual management is not necessary since by default Hatch will [automatically](../../plugins/environment/virtual.md#python-resolution) download and manage Python distributions internally when a requested version cannot be found.
## Location
There are two ways to control where Python distributions are installed. Both methods make it so that each installed distribution is placed in a subdirectory of the configured location named after the distribution.
1. The globally configured [default directory](../../config/hatch.md#python-installations) for Python installations.
2. The `-d`/`--dir` option of every [`python`](../../cli/reference.md#hatch-python) subcommand, which takes precedence over the default directory.
## Installation
To install a Python distribution, use the [`python install`](../../cli/reference.md#hatch-python-install) command. For example:
```
hatch python install 3.12
```
This will:
1. Download the `3.12` Python distribution
2. Unpack it into a directory named `3.12` within the configured [default directory](../../config/hatch.md#python-installations) for Python installations
3. Add the installation to the user PATH
Now its `python` executable can be used by you or other tools.
!!! note
For PATH changes to take effect in the current shell, you will need to restart it.
### Multiple
You can install multiple Python distributions at once by providing multiple distribution names. For example:
```
hatch python install 3.12 3.11 pypy3.10
```
If you would like to install all available Python distributions that are compatible with your system, use `all` as the distribution name:
```
hatch python install all
```
!!! tip
The commands for [updating](#updates) and [removing](#removal) also support this functionality.
### Private
By default, installing Python distributions will add them to the user PATH. To disable this behavior, use the `--private` flag like so:
```
hatch python install 3.12 --private
```
This when combined with the [directory option](#location) can be used to create private, isolated installations.
## Listing distributions
You can see all of the available and installed Python distributions by using the [`python show`](../../cli/reference.md#hatch-python-show) command. For example, if you already installed the `3.12` distribution you may see something like this:
```
$ hatch python show
Installed
┏━━━━━━┳━━━━━━━━━┓
┃ Name ┃ Version ┃
┡━━━━━━╇━━━━━━━━━┩
│ 3.12 │ 3.12.7 │
└──────┴─────────┘
Available
┏━━━━━━━━━━┳━━━━━━━━━┓
┃ Name ┃ Version ┃
┡━━━━━━━━━━╇━━━━━━━━━┩
│ 3.7 │ 3.7.9 │
├──────────┼─────────┤
│ 3.8 │ 3.8.20 │
├──────────┼─────────┤
│ 3.9 │ 3.9.20 │
├──────────┼─────────┤
│ 3.10 │ 3.10.15 │
├──────────┼─────────┤
│ 3.11 │ 3.11.10 │
├──────────┼─────────┤
│ 3.13 │ 3.13.0 │
├──────────┼─────────┤
│ pypy2.7 │ 7.3.15 │
├──────────┼─────────┤
│ pypy3.9 │ 7.3.15 │
├──────────┼─────────┤
│ pypy3.10 │ 7.3.15 │
└──────────┴─────────┘
```
## Finding installations
The Python executable of an installed distribution can be found by using the [`python find`](../../cli/reference.md#hatch-python-find) command. For example:
```
$ hatch python find 3.12
/home/.local/share/hatch/pythons/3.12/python/bin/python3
```
You can instead output its parent directory by using the `-p`/`--parent` flag:
```
$ hatch python find 3.12 --parent
/home/.local/share/hatch/pythons/3.12/python/bin
```
This is useful when other tools do not need to use the executable directly but require knowing the directory containing it.
## Updates
To update installed Python distributions, use the [`python update`](../../cli/reference.md#hatch-python-update) command. For example:
```
hatch python update 3.12 3.11 pypy3.10
```
When there are no updates available for a distribution, a warning will be displayed:
```
$ hatch python update 3.12
The latest version is already installed: 3.12.7
```
## Removal
To remove installed Python distributions, use the [`python remove`](../../cli/reference.md#hatch-python-remove) command. For example:
```
hatch python remove 3.12 3.11 pypy3.10
```
================================================
FILE: docs/tutorials/testing/overview.md
================================================
# Testing projects
-----
The [`test`](../../cli/reference.md#hatch-test) command ([by default](../../config/internal/testing.md#customize-environment)) uses [pytest](https://github.com/pytest-dev/pytest) with select plugins and [coverage.py](https://github.com/nedbat/coveragepy). View the [testing configuration](../../config/internal/testing.md) for more information.
The majority of projects can be fully tested this way without the need for custom [environments](../../config/environment/overview.md).
## Passing arguments
When you run the `test` command without any arguments, `tests` is passed as the [default argument](../../config/internal/testing.md#default-arguments) to `pytest` (this assumes that you have a `tests` directory). For example, the following command invocation:
```
hatch test
```
would be translated roughly to:
```
pytest tests
```
You can pass arguments to `pytest` by appending them to the `test` command. For example, the following command invocation:
```
hatch test -vv tests/test_foo.py::test_bar
```
would be translated roughly to:
```
pytest -vv tests/test_foo.py::test_bar
```
You can force the treatment of arguments as positional by using the `--` separator, especially useful when built-in flags of the `test` command conflict with those of `pytest`, such as the `--help` flag. For example, the following command invocation:
```
hatch test -r -- -r fE -- tests
```
would be translated roughly to:
```
pytest -r fE -- tests
```
!!! note
It's important to ensure that `pytest` receives an argument instructing what to run/where to locate tests. It's default behavior is `.` meaning that it will exhaustively search for tests in the current directory. This can not just be slow but also lead to unexpected behavior.
## Environment selection
### Single environment
If no environment options are selected, the `test` command will only run tests in the first defined environment that either already exists or is compatible. Additionally, the checking order will prioritize environments that define a [version of Python](../../config/environment/overview.md#python-version) that matches the interpreter that Hatch is running on.
For example, if you overrode the [default matrix](../../config/internal/testing.md#matrix) as follows:
```toml config-example
[[tool.hatch.envs.hatch-test.matrix]]
python = ["3.12", "3.11"]
[[tool.hatch.envs.hatch-test.matrix]]
python = ["3.11"]
feature = ["foo", "bar"]
```
the expanded environments would normally be:
```
hatch-test.py3.12
hatch-test.py3.11
hatch-test.py3.11-foo
hatch-test.py3.11-bar
```
If you install Hatch on Python 3.11, the checking order would be:
```
hatch-test.py3.11
hatch-test.py3.11-foo
hatch-test.py3.11-bar
hatch-test.py3.12
```
!!! note
If you installed Hatch with an official [installer](../../install.md#installers) or are using one of the [standalone binaries](../../install.md#standalone-binaries), the version of Python that Hatch runs on is out of your control. If you are relying on the single environment resolution behavior, consider [explicitly selecting environments](#specific-environments) based on the Python version instead.
### All environments
You can run tests in all compatible environments by using the `--all` flag. For example, say you defined the matrix and [overrides](../../config/environment/advanced.md#option-overrides) as follows:
```toml config-example
[[tool.hatch.envs.hatch-test.matrix]]
python = ["3.12", "3.11"]
feature = ["foo", "bar"]
[tool.hatch.envs.hatch-test.overrides]
matrix.feature.platforms = [
{ value = "linux", if = ["foo", "bar"] },
{ value = "windows", if = ["foo"] },
{ value = "macos", if = ["bar"] },
]
```
The following table shows the environments in which tests would be run:
| Environment | Linux | Windows | macOS |
| --- | --- | --- | --- |
| `hatch-test.py3.12-foo` | :white_check_mark: | :white_check_mark: | :x: |
| `hatch-test.py3.12-bar` | :white_check_mark: | :x: | :white_check_mark: |
| `hatch-test.py3.11-foo` | :white_check_mark: | :white_check_mark: | :x: |
| `hatch-test.py3.11-bar` | :white_check_mark: | :x: | :white_check_mark: |
### Specific environments
You can select subsets of environments by using the `--include`/`-i` and `--exclude`/`-x` options. These options may be used to include or exclude certain matrix variables, optionally followed by specific comma-separated values, and may be selected multiple times.
For example, say you defined the matrix as follows:
```toml config-example
[[tool.hatch.envs.hatch-test.matrix]]
python = ["3.12", "3.11"]
feature = ["foo", "bar", "baz"]
```
If you wanted to run tests in all environments that have Python 3.12 and either the `foo` or `bar` feature, you could use the following command invocation:
```
hatch test -i python=3.12 -i feature=foo,bar
```
Alternatively, we could exclude the `baz` feature to achieve the same result:
```
hatch test -i python=3.12 -x feature=baz
```
!!! tip
Since selecting the version of Python is a common use case, you can use the `--python`/`-py` option as a shorthand. For example, the previous commands could have been written as:
```
hatch test -py 3.12 -i feature=foo,bar
hatch test -py 3.12 -x feature=baz
```
## Measuring code coverage
You can enable [code coverage](https://github.com/nedbat/coveragepy) by using the `--cover` flag. For example, the following command invocation:
```
hatch test --cover
```
would be translated roughly to:
```
coverage run -m pytest tests
```
After tests run in all of the [selected environments](#environment-selection), the coverage data is combined and a report is shown. The `--cover-quiet` flag can be used to suppress the report and implicitly enables the `--cover` flag:
```
hatch test --cover-quiet
```
!!! note
Coverage data files are generated at the root of the project. Be sure to exclude them from version control with the following glob-style pattern:
```
.coverage*
```
## Retry failed tests
You can [retry](https://github.com/pytest-dev/pytest-rerunfailures) failed tests with the `--retries` option:
```
hatch test --retries 2
```
If a test fails every time and the number of retries is set to `2`, the test will be run a total of three times.
You can also set the number of seconds to wait between retries with the `--retry-delay` option:
```
hatch test --retries 2 --retry-delay 1
```
## Parallelize test execution
You can [parallelize](https://github.com/pytest-dev/pytest-xdist) test execution with the `--parallel`/`-p` flag:
```
hatch test --parallel
```
This distributes tests within an environment across multiple workers. The number of workers corresponds to the number of logical rather than physical CPUs that are available.
## Randomize test order
You can [randomize](https://github.com/pytest-dev/pytest-randomly) the order of tests with the `--randomize`/`-r` flag:
```
hatch test --randomize
```
================================================
FILE: docs/version.md
================================================
# Versioning
-----
## Configuration
When the version is not [statically set](config/metadata.md#version), configuration is defined in the `tool.hatch.version` table. The `source` option determines the [source](plugins/version-source/reference.md) to use for [retrieving](#display) and [updating](#updating) the version. The [regex](plugins/version-source/regex.md) source is used by default.
The `regex` source requires an option `path` that represents a relative path to a file containing the project's version:
```toml config-example
[tool.hatch.version]
path = "src/hatch_demo/__about__.py"
```
The default pattern looks for a variable named `__version__` or `VERSION` that is set to a string containing the version, optionally prefixed with the lowercase letter `v`.
If this doesn't reflect how you store the version, you can define a different regular expression using the `pattern` option:
```toml config-example
[tool.hatch.version]
path = "pkg/__init__.py"
pattern = "BUILD = 'b(?P[^']+)'"
```
The pattern must have a named group called `version` that represents the version.
## Display
Invoking the [`version`](cli/reference.md#hatch-version) command without any arguments will display the current version of the project:
```console
$ hatch version
0.0.1
```
## Updating
You can update the version like so:
```console
$ hatch version "0.1.0"
Old: 0.0.1
New: 0.1.0
```
The `scheme` option determines the [scheme](plugins/version-scheme/reference.md) to use for parsing both the existing and new versions. The [standard](plugins/version-scheme/standard.md) scheme is used by default, which is based on [PEP 440](https://peps.python.org/pep-0440/#public-version-identifiers).
Rather than setting the version explicitly, you can select the name of a [segment](#supported-segments) used to increment the version:
```console
$ hatch version minor
Old: 0.1.0
New: 0.2.0
```
You can chain multiple segment updates with a comma. For example, if you wanted to release a preview of your project's first major version, you could do:
```console
$ hatch version major,rc
Old: 0.2.0
New: 1.0.0rc0
```
When you want to release the final version, you would do:
```console
$ hatch version release
Old: 1.0.0rc0
New: 1.0.0
```
### Supported segments
Here are the supported segments and how they would influence an existing version of `1.0.0`:
| Segments | New version |
| --- | --- |
| `release` | `1.0.0` |
| `major` | `2.0.0` |
| `minor` | `1.1.0` |
| `micro` `patch` `fix` | `1.0.1` |
| `a` `alpha` | `1.0.0a0` |
| `b` `beta` | `1.0.0b0` |
| `c` `rc` `pre` `preview` | `1.0.0rc0` |
| `r` `rev` `post` | `1.0.0.post0` |
| `dev` | `1.0.0.dev0` |
================================================
FILE: docs/why.md
================================================
# Why Hatch?
-----
The high level value proposition of Hatch is that if one adopts all functionality then many other tools become unnecessary since there is support for everything one might require. Further, if one chooses to use only specific features then there are still benefits compared to alternatives.
## Build backend
Hatchling, the [build backend](config/build.md#build-system) sister project, has many benefits compared to [setuptools](https://github.com/pypa/setuptools). Here we only compare setuptools as that is the one most people are familiar with.
- **Better defaults:** The default behavior for setuptools is often not desirable for the average user.
- For source distributions, setuptools has a custom enumeration of files that get included and excluded by default. Hatchling takes the [defaults](plugins/builder/sdist.md#default-file-selection) from your version control system such as Git's `.gitignore` file.
- For wheels, setuptools attempts to find every directory that looks like a Python package. This is often undesirable as you might ship files to the end-user unintentionally such as test or tooling directories. Hatchling [defaults](plugins/builder/wheel.md#default-file-selection) to very specific inclusion based on the project name and errors if no heuristic is satisfied.
- **Ease of configurability:** Hatchling was designed based on a history of significant challenges when configuring setuptools.
- Hatchling [uses](config/build.md#patterns) the same glob pattern syntax as Git itself for every option which is what most users are familiar with. On the other hand, setuptools uses shell-style glob patterns for source distributions while wheels use a mix of shell-style globs and Python package syntax.
- Configuring what gets included in source distributions requires a separate [`MANIFEST.in` file](https://setuptools.pypa.io/en/latest/userguide/miscellaneous.html#using-manifest-in). The custom syntax and directives must be learned and it is difficult knowing which options in the main files like `setup.py` influence the behavior and under what conditions. For Hatchling, everything gets [configured](config/build.md) in a single file under dedicated sections for specific targets like `[tool.hatch.build.targets.wheel]`.
- By default, non-Python files are excluded from wheels. Including such files requires usually verbose rules for every nested package directory. Hatchling makes no such distinction between file types and acts more like a general build system one might already be familiar with.
- **Editable installations:** The default behavior of Hatchling allows for proper static analysis by external tools such as IDEs. With setuptools, you must provide [additional configuration](https://setuptools.pypa.io/en/latest/userguide/development_mode.html#legacy-behavior) which means that by default, for example, you would not get autocompletion in Visual Studio Code. This is marked as a legacy feature and may in fact be removed in future versions of setuptools.
- **Reproducibility:** Hatchling builds reproducible wheels and source distributions by default. setuptools [does not support this](https://github.com/pypa/setuptools/issues/2133) for source distributions and there is no guarantee that wheels are reproducible.
- **Extensibility:** Although it is possible to [extend](https://setuptools.pypa.io/en/latest/userguide/extension.html) setuptools, the API is quite low level. Hatchling has the concept of [plugins](https://hatch.pypa.io/latest/plugins/about/) that are separated into discrete types and only expose what is necessary, leading to an easier developer experience.
***Why not?:***
If building extension modules is required then it is recommended that you continue using setuptools, or even other backends that specialize in interfacing with compilers.
## Environment management
Here we compare to both `tox` and `nox`. At a high level, there are a few common advantages:
- **Python management:** Hatch is able to automatically download [Python distributions](plugins/environment/virtual.md#internal-distributions) on the fly when specific versions that environments request cannot be found. The alternatives will raise an error, with the option to ignore unknown distributions.
- **Philosophy:** In the alternatives, environments are for the most part treated as executable units where a dependency set is associated with an action. If you are familiar with container ecosystems, this would be like defining a `CMD` at the end of a Dockerfile but without the ability to change the action at runtime. This involves significant wasted disk space usually because one often requires slight modifications to the actions and therefore will define entirely different environments inherited from a base config just to perform different logic. Additionally, this can be confusing to users not just configuration-wise but also for execution of the different environments.
In Hatch, [environments](environment.md) are treated as isolated areas where you can execute arbitrary commands at runtime. For example, you can define a single test environment with named [scripts](config/environment/overview.md#scripts) that runs unit vs non-unit tests, each command being potentially very long but named however you wish so you get to control the interface. Since environments are treated as places where work is performed, you can also [spawn a shell](environment.md#entering-environments) into any which will execute a subprocess that automatically drops into your [shell of choice](config/hatch.md#shell). Your shell will be configured appropriately like `python` on PATH being updated and the prompt being changed to reflect the chosen environment.
- **Configuration:**
- `nox` config is defined in Python which often leads to increased verbosity and makes it challenging to onboard folks compared to a standardized format with known behaviors.
- **Extensibility:**
- `tox` allows for [extending](https://tox.wiki/en/4.11.4/plugins_api.html) most aspects of its functionality however the API is so low-level and attached to internals that creating plugins may be challenging. For example, [here](https://github.com/DataDog/integrations-core/blob/4f4cf10613797e97e7155c75859532a0732d1dff/datadog_checks_dev/datadog_checks/dev/plugin/tox.py) is a `tox` plugin that was [migrated](https://github.com/DataDog/integrations-core/blob/4eb2a1d530bcf810542cf9e45b48fadc7057301c/datadog_checks_dev/datadog_checks/dev/plugin/hatch/environment_collector.py#L100-L148) to an equivalent Hatch [environment collector plugin](plugins/environment-collector/reference.md).
- `nox` is configured with Python so for the local project you can do whatever you want, however there is no concept of third-party plugins per se. To achieve that, you must usually use a package that wraps `nox` and use that package's imports instead ([example](https://github.com/cjolowicz/nox-poetry)).
***Why not?:***
If you are using `nox` and you wish to migrate, and for some reason you [notify](https://nox.thea.codes/en/stable/config.html#nox.sessions.Session.notify) sessions, then migration wouldn't be a straight translation but rather you might have to redesign that conditional step.
## Python management
Here we compare [Python management](tutorials/python/manage.md) to that of [pyenv](https://github.com/pyenv/pyenv).
- ***Cross-platform:*** Hatch allows for the same experience no matter the system whereas `pyenv` does not support Windows so you must use an [entirely different project](https://github.com/pyenv-win/pyenv-win) that tries to emulate the functionality.
- ***No build dependencies:*** Hatch guarantees that every [available distribution](cli/reference.md#hatch-python-show) is prebuilt whereas the alternative requires one to maintain a precise [build environment](https://github.com/pyenv/pyenv/wiki#suggested-build-environment) which differs by platform and potentially Python version. Another benefit to this is extremely fast installations since the distributions are simply downloaded and unpacked.
- ***Optimized by default:*** The [CPython distributions](plugins/environment/virtual.md#cpython) are built with profile guided optimization and link-time optimization, resulting in a 10-30% performance improvement depending on the workload. These distributions have seen wide adoption throughout the industry and are even used by the build system [Bazel](https://bazel.build).
- ***Simplicity:*** Hatch treats Python installations as just another directory that one would add to PATH. It can do this for you or you can manage PATH yourself, even allowing for custom install locations. On the other hand, `pyenv` operates by adding [shims](https://github.com/pyenv/pyenv/tree/74a2523c97d2e5c1dbdca7b58f3372324ccad4e6#understanding-shims) which then act as wrappers around the actual underlying binaries. This has many unfortunate side effects:
- It is incumbent upon the user to manage which specific Python comes first via the CLI, switch when necessary, and/or have a mental model of which versions are exposed globally and locally per-project. This can become confusing quite quickly. When working with Hatch, your global Python installations are only important insofar as they are on PATH somewhere since environments do not use them directly but rather create virtual environments from them, always using a version that is compatible with your project.
- Configuration is required for each shell to properly set up `pyenv` on start, leading to inconsistencies when running processes that do not spawn a shell.
- Debugging issues with Python search paths can be extremely difficult, especially for users of software. If you or users have ever ran into an issue where code was being executed that you did not anticipate, the issue is almost always `pyenv` influencing the `python` on PATH.
***Why not?:***
Currently, Hatch does not allow for the installation of specific patch release versions but rather only uses minor release granularity that tracks the latest patch release. If specific patch releases are important to you then it is best to use an alternative installation mechanism.
================================================
FILE: hatch.toml
================================================
[envs.hatch-static-analysis]
config-path = "ruff_defaults.toml"
[envs.default]
installer = "uv"
[envs.hatch-test]
workspace.members = ["backend/"]
extra-dependencies = [
"filelock",
"flit-core",
"trustme",
"editables",
]
extra-args = ["--dist", "worksteal"]
[envs.hatch-test.extra-scripts]
pip = "{env:HATCH_UV} pip {args}"
[envs.coverage]
detached = true
dependencies = [
"coverage[toml]>=6.2",
"lxml",
]
[envs.coverage.scripts]
combine = "coverage combine {args}"
report-xml = "coverage xml"
report-uncovered-html = "coverage html --skip-covered --skip-empty"
generate-summary = "python scripts/generate_coverage_summary.py"
write-summary-report = "python scripts/write_coverage_summary_report.py"
[envs.types]
extra-dependencies = [
"mypy>=1.0.0",
]
[envs.types.scripts]
check = "mypy --install-types --non-interactive {args:backend/src/hatchling src/hatch tests}"
[envs.docs]
dependencies = [
"mkdocs~=1.6.0",
"mkdocs-material~=9.5.24",
# Plugins
"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]~=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",
# Necessary for syntax highlighting in code blocks
"pygments~=2.18.0",
# Validation
"linkchecker~=10.5.0",
"griffe>=1.0.0",
]
pre-install-commands = [
"python scripts/install_mkdocs_material_insiders.py",
]
[envs.docs.overrides]
env.GH_TOKEN_MKDOCS_MATERIAL_INSIDERS.env-vars = [
{ key = "MKDOCS_CONFIG", value = "mkdocs.insiders.yml" },
{ key = "MKDOCS_CONFIG", value = "mkdocs.yml", if = [""] },
{ key = "MKDOCS_IMAGE_PROCESSING", value = "true" },
]
[envs.docs.env-vars]
SOURCE_DATE_EPOCH = "1580601600"
PYTHONUNBUFFERED = "1"
MKDOCS_CONFIG = "mkdocs.yml"
[envs.docs.scripts]
build = "mkdocs build --config-file {env:MKDOCS_CONFIG} --clean --strict {args}"
serve = "mkdocs serve --config-file {env:MKDOCS_CONFIG} --dev-addr localhost:8000 {args}"
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 = [
"python -W ignore::DeprecationWarning -m mkdocs build --no-directory-urls",
"validate",
]
[envs.backend]
detached = true
installer = "uv"
dependencies = [
"build~=0.7.0",
"httpx",
]
[envs.backend.env-vars]
HATCH_BUILD_CLEAN = "true"
[envs.backend.scripts]
build = "python -m build backend"
publish = "hatch publish backend/dist"
version = "cd backend && hatch version {args}"
[envs.upkeep]
detached = true
installer = "uv"
dependencies = [
"httpx",
"ruff",
]
[envs.upkeep.scripts]
update-hatch = [
"update-distributions",
"update-ruff",
]
update-distributions = "python scripts/update_distributions.py"
update-ruff = [
"{env:HATCH_UV} pip install --upgrade ruff",
"python scripts/update_ruff.py",
]
[envs.release]
workspace.members = ["backend/"]
detached = true
installer = "uv"
[envs.release.scripts]
bump = "python scripts/bump.py {args}"
github = "python scripts/release_github.py {args}"
================================================
FILE: mkdocs.insiders.yml
================================================
INHERIT: mkdocs.yml
plugins:
git-committers:
repository: pypa/hatch
enabled: !ENV [GITHUB_ACTIONS, false]
token: !ENV [GH_TOKEN]
social:
cards_layout_options:
logo: docs/assets/images/logo.svg
enabled: !ENV [MKDOCS_IMAGE_PROCESSING, false]
material/blog:
categories_allowed:
- General
- News
- Release
- Roadmap
post_excerpt: required
post_slugify: !!python/object/apply:pymdownx.slugs.slugify
kwds:
case: lower
categories_slugify: !!python/object/apply:pymdownx.slugs.slugify
kwds:
case: lower
================================================
FILE: mkdocs.yml
================================================
site_name: Hatch
site_description: Modern, extensible Python project management
site_author: Ofek Lev
site_url: https://hatch.pypa.io
repo_name: pypa/hatch
repo_url: https://github.com/pypa/hatch
edit_uri: blob/master/docs
copyright: 'Copyright © Ofek Lev 2017-present'
docs_dir: docs
site_dir: site
theme:
name: material
custom_dir: docs/.overrides
language: en
favicon: assets/images/logo.svg
icon:
repo: fontawesome/brands/github-alt
logo: material/egg
font:
text: Roboto
code: Roboto Mono
palette:
- media: "(prefers-color-scheme: dark)"
scheme: slate
primary: indigo
accent: indigo
toggle:
icon: material/weather-night
name: Switch to light mode
- media: "(prefers-color-scheme: light)"
scheme: default
primary: indigo
accent: indigo
toggle:
icon: material/weather-sunny
name: Switch to dark mode
features:
- content.action.edit
- content.code.copy
- content.tabs.link
- content.tooltips
- navigation.expand
- navigation.footer
- navigation.instant
- navigation.sections
- navigation.tabs
- navigation.tabs.sticky
nav:
- Home:
- About: index.md
- Walkthrough:
- Installation: install.md
- Introduction: intro.md
- Environments: environment.md
- Versioning: version.md
- Builds: build.md
- Publishing: publish.md
- Learn:
- Next steps: next-steps.md
- Why Hatch?: why.md
- History:
- Hatch: history/hatch.md
- Hatchling: history/hatchling.md
- Community:
- Users: community/users.md
- Highlights: community/highlights.md
- Contributing: community/contributing.md
- Configuration:
- Metadata: config/metadata.md
- Dependencies: config/dependency.md
- Build: config/build.md
- Environments:
- Overview: config/environment/overview.md
- Advanced: config/environment/advanced.md
- Internal:
- Testing: config/internal/testing.md
- Static analysis: config/internal/static-analysis.md
- Building: config/internal/build.md
- Context formatting: config/context.md
- Project templates: config/project-templates.md
- Hatch: config/hatch.md
- CLI:
- About: cli/about.md
- Reference: cli/reference.md
- Plugins:
- About: plugins/about.md
- Builder:
- Reference: plugins/builder/reference.md
- Wheel: plugins/builder/wheel.md
- Source distribution: plugins/builder/sdist.md
- Binary: plugins/builder/binary.md
- Custom: plugins/builder/custom.md
- Build hook:
- Reference: plugins/build-hook/reference.md
- Version: plugins/build-hook/version.md
- Custom: plugins/build-hook/custom.md
- Metadata hook:
- Reference: plugins/metadata-hook/reference.md
- Custom: plugins/metadata-hook/custom.md
- Environment:
- Reference: plugins/environment/reference.md
- Virtual: plugins/environment/virtual.md
- Environment collector:
- Reference: plugins/environment-collector/reference.md
- Custom: plugins/environment-collector/custom.md
- Default: plugins/environment-collector/default.md
- Publisher:
- Reference: plugins/publisher/reference.md
- Index: plugins/publisher/package-index.md
- Version source:
- Reference: plugins/version-source/reference.md
- Regex: plugins/version-source/regex.md
- Code: plugins/version-source/code.md
- Environment: plugins/version-source/env.md
- Version scheme:
- Reference: plugins/version-scheme/reference.md
- Standard: plugins/version-scheme/standard.md
- Utilities: plugins/utilities.md
- How-to:
- Meta:
- Report issues: how-to/meta/report-issues.md
- Integrate:
- Visual Studio Code: how-to/integrate/vscode.md
- Run:
- Python scripts: how-to/run/python-scripts.md
- Config:
- Dynamic metadata: how-to/config/dynamic-metadata.md
- Environments:
- Select installer: how-to/environment/select-installer.md
- Dependency resolution: how-to/environment/dependency-resolution.md
- Workspace: how-to/environment/workspace.md
- Static analysis:
- Customize behavior: how-to/static-analysis/behavior.md
- Python:
- Custom distributions: how-to/python/custom.md
- Publishing:
- Authentication: how-to/publish/auth.md
- Repository selection: how-to/publish/repo.md
- Plugins:
- Testing builds: how-to/plugins/testing-builds.md
- Tutorials:
- Python:
- Management: tutorials/python/manage.md
- Environments:
- Basic usage: tutorials/environment/basic-usage.md
- Testing:
- Overview: tutorials/testing/overview.md
- Meta:
- FAQ: meta/faq.md
- Authors: meta/authors.md
- Blog:
- blog/index.md
watch:
- backend/src/hatchling
- src/hatch
hooks:
- docs/.hooks/plugin_register.py
- docs/.hooks/title_from_content.py
plugins:
# Enable for bug reports
# info: {}
# Built-in
search: {}
# Extra
glightbox: {}
minify:
minify_html: true
git-revision-date-localized:
type: date
strict: false
# Required for blog plugin's generated indices
fallback_to_build_date: true
exclude:
- blog/**/*
mike:
alias_type: copy
mkdocstrings:
default_handler: python
handlers:
python:
paths:
- src
options:
# Headings
show_root_heading: true
show_root_full_path: false
# Docstrings
show_if_no_docstring: true
# Signatures/annotations
show_signature_annotations: true
# Other
show_bases: false
redirects:
redirect_maps:
config/environment.md: config/environment/overview.md
config/static-analysis.md: config/internal/static-analysis.md
history.md: history/hatch.md
how-to/environment/package-indices.md: how-to/environment/dependency-resolution.md
plugins/builder.md: plugins/builder/reference.md
plugins/build-hook.md: plugins/build-hook/reference.md
plugins/metadata-hook.md: plugins/metadata-hook/reference.md
plugins/environment.md: plugins/environment/reference.md
plugins/environment-collector.md: plugins/environment-collector/reference.md
plugins/publisher.md: plugins/publisher/reference.md
plugins/version-source.md: plugins/version-source/reference.md
plugins/version-scheme.md: plugins/version-scheme/reference.md
plugins/builder/app.md: plugins/builder/binary.md
users.md: community/users.md
markdown_extensions:
# Built-in
- markdown.extensions.abbr:
- markdown.extensions.admonition:
- markdown.extensions.attr_list:
- markdown.extensions.footnotes:
- markdown.extensions.md_in_html:
- markdown.extensions.meta:
- markdown.extensions.tables:
- markdown.extensions.toc:
permalink: true
# Extra
- mkdocs-click:
- pymdownx.arithmatex:
- pymdownx.betterem:
smart_enable: all
- pymdownx.caret:
- pymdownx.critic:
- pymdownx.details:
- pymdownx.emoji:
# https://github.com/twitter/twemoji
# https://raw.githubusercontent.com/facelessuser/pymdown-extensions/master/pymdownx/twemoji_db.py
emoji_index: !!python/name:material.extensions.emoji.twemoji
emoji_generator: !!python/name:material.extensions.emoji.to_svg
- pymdownx.highlight:
guess_lang: false
linenums_style: pymdownx-inline
use_pygments: true
- pymdownx.inlinehilite:
- pymdownx.keys:
- pymdownx.magiclink:
repo_url_shortener: true
repo_url_shorthand: true
social_url_shortener: true
social_url_shorthand: true
normalize_issue_symbols: true
provider: github
user: pypa
repo: hatch
- pymdownx.mark:
- pymdownx.progressbar:
- pymdownx.saneheaders:
- pymdownx.smartsymbols:
- pymdownx.snippets:
check_paths: true
base_path:
- docs/.snippets
auto_append:
- links.txt
- abbrs.txt
- pymdownx.superfences:
- pymdownx.tabbed:
alternate_style: true
slugify: !!python/object/apply:pymdownx.slugs.slugify
kwds:
case: lower
- pymdownx.tasklist:
custom_checkbox: true
- pymdownx.tilde:
extra:
version:
provider: mike
social:
- icon: fontawesome/brands/github-alt
link: https://github.com/ofek
- icon: fontawesome/solid/blog
link: https://ofek.dev/words/
- icon: fontawesome/brands/twitter
link: https://twitter.com/Ofekmeister
- icon: fontawesome/brands/linkedin
link: https://www.linkedin.com/in/ofeklev/
extra_css:
- assets/css/custom.css
- https://cdn.jsdelivr.net/npm/firacode@6.2.0/distr/fira_code.css
================================================
FILE: pyoxidizer.bzl
================================================
VERSION = VARS["version"]
APP_NAME = "hatch"
DISPLAY_NAME = "Hatch"
AUTHOR = "Python Packaging Authority"
def make_msi(target):
if target == "x86_64-pc-windows-msvc":
arch = "x64"
elif target == "i686-pc-windows-msvc":
arch = "x86"
else:
arch = "unknown"
# https://gregoryszorc.com/docs/pyoxidizer/main/tugger_starlark_type_wix_msi_builder.html
msi = WiXMSIBuilder(
id_prefix=APP_NAME,
product_name=DISPLAY_NAME,
product_version=VERSION,
product_manufacturer=AUTHOR,
arch=arch,
)
msi.msi_filename = APP_NAME + "-" + arch + ".msi"
msi.help_url = "https://hatch.pypa.io/latest/"
msi.license_path = CWD + "/LICENSE.txt"
# https://gregoryszorc.com/docs/pyoxidizer/main/tugger_starlark_type_file_manifest.html
m = FileManifest()
exe_prefix = "targets/" + target + "/"
m.add_path(
path=exe_prefix + APP_NAME + ".exe",
strip_prefix=exe_prefix,
)
msi.add_program_files_manifest(m)
return msi
def make_exe_installer():
# https://gregoryszorc.com/docs/pyoxidizer/main/tugger_starlark_type_wix_bundle_builder.html
bundle = WiXBundleBuilder(
id_prefix=APP_NAME,
name=DISPLAY_NAME,
version=VERSION,
manufacturer=AUTHOR,
)
bundle.add_vc_redistributable("x64")
bundle.add_vc_redistributable("x86")
bundle.add_wix_msi_builder(
builder=make_msi("x86_64-pc-windows-msvc"),
display_internal_ui=True,
install_condition="VersionNT64",
)
bundle.add_wix_msi_builder(
builder=make_msi("i686-pc-windows-msvc"),
display_internal_ui=True,
install_condition="Not VersionNT64",
)
return bundle
def make_macos_universal_binary():
# https://gregoryszorc.com/docs/pyoxidizer/main/tugger_starlark_type_apple_universal_binary.html
universal = AppleUniversalBinary(APP_NAME)
for target in ["aarch64-apple-darwin", "x86_64-apple-darwin"]:
universal.add_path("targets/" + target + "/" + APP_NAME)
m = FileManifest()
m.add_file(universal.to_file_content())
return m
register_target("windows_installers", make_exe_installer, default=True)
register_target("macos_universal_binary", make_macos_universal_binary)
resolve_targets()
================================================
FILE: pyproject.toml
================================================
[build-system]
requires = ["hatchling>=1.27", "hatch-vcs>=0.3.0"]
build-backend = "hatchling.build"
[project]
name = "hatch"
description = "Modern, extensible Python project management"
readme = "README.md"
license = "MIT"
license-files = ["LICENSE.txt"]
requires-python = ">=3.10"
keywords = [
"build",
"dependency",
"environment",
"hatch",
"packaging",
"plugin",
"publishing",
"release",
"versioning",
]
authors = [
{ name = "Ofek Lev", email = "oss@ofek.dev" },
{ name = "Cary Hawkins", email = "hawkinscary23@gmail.com" },
]
classifiers = [
"Development Status :: 5 - Production/Stable",
"Intended Audience :: Developers",
"Natural Language :: English",
"Operating System :: OS Independent",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13",
"Programming Language :: Python :: 3.14",
"Programming Language :: Python :: Implementation :: CPython",
"Programming Language :: Python :: Implementation :: PyPy",
"Topic :: Software Development :: Build Tools",
]
dependencies = [
"click>=8.0.6",
"hatchling>=1.27.0",
"httpx>=0.22.0",
"hyperlink>=21.0.0",
"keyring>=23.5.0",
"packaging>=24.2",
"pexpect~=4.8",
"python-discovery>=1.1",
"platformdirs>=2.5.0",
"pyproject-hooks",
"rich>=11.2.0",
"shellingham>=1.4.0",
"tomli-w>=1.0",
"tomlkit>=0.11.1",
"userpath~=1.7",
"uv>=0.5.23",
"virtualenv>=21",
"backports.zstd>=1.0.0 ; python_version<'3.14'",
]
dynamic = ["version"]
[project.urls]
Homepage = "https://hatch.pypa.io/latest/"
Sponsor = "https://github.com/sponsors/ofek"
History = "https://hatch.pypa.io/dev/history/hatch/"
Tracker = "https://github.com/pypa/hatch/issues"
Source = "https://github.com/pypa/hatch"
[project.scripts]
hatch = "hatch.cli:main"
[tool.hatch.version]
source = "vcs"
[tool.hatch.version.raw-options]
version_scheme = "python-simplified-semver"
local_scheme = "no-local-version"
parentdir_prefix_version = "hatch-"
git_describe_command = ["git", "describe", "--dirty", "--tags", "--long", "--match", "hatch-v*"]
[tool.hatch.build.hooks.vcs]
version-file = "src/hatch/_version.py"
[tool.hatch.build.targets.sdist]
exclude = [
"/.github",
"/backend",
"/scripts",
]
[tool.mypy]
disallow_untyped_defs = false
disallow_incomplete_defs = false
enable_error_code = ["ignore-without-code", "truthy-bool"]
follow_imports = "normal"
ignore_missing_imports = true
pretty = true
show_column_numbers = true
warn_no_return = false
warn_unused_ignores = true
[[tool.mypy.overrides]]
module = [
"*.hatchling.*",
"*.hatch.utils.*",
]
disallow_untyped_defs = true
disallow_incomplete_defs = true
warn_no_return = true
[tool.coverage.run]
branch = true
source_pkgs = ["hatch", "hatchling", "tests"]
omit = [
"backend/src/hatchling/__main__.py",
"backend/src/hatchling/bridge/*",
"backend/src/hatchling/cli/dep/*",
"backend/src/hatchling/ouroboros.py",
"src/hatch/__main__.py",
"src/hatch/cli/new/migrate.py",
"src/hatch/project/frontend/scripts/*",
"src/hatch/utils/shells.py",
]
[tool.coverage.paths]
hatch = ["src/hatch", "*/hatch/src/hatch"]
hatchling = ["backend/src/hatchling", "*/hatch/backend/src/hatchling"]
tests = ["tests", "*/hatch/tests"]
[tool.coverage.report]
exclude_lines = [
"no cov",
"if __name__ == .__main__.:",
"if TYPE_CHECKING:",
]
================================================
FILE: release/README.md
================================================
# Release assets
-----
This directory stores files related to building binaries and installers for each platform.
================================================
FILE: release/macos/build_pkg.py
================================================
"""
This script must be run from the root of the repository.
At a high level, the goal is to have a directory that emulates the full path structure of the
target machine which then gets packaged by tools that are only available on macOS.
"""
from __future__ import annotations
import argparse
import shutil
import subprocess
import sys
from pathlib import Path
from tempfile import TemporaryDirectory
REPO_DIR = Path.cwd()
ASSETS_DIR = Path(__file__).parent / "pkg"
IDENTIFIER = "org.python.hatch"
COMPONENT_PACKAGE_NAME = f"{IDENTIFIER}.pkg"
README = """\
This will install Hatch v{version} globally.
For more information on installing and upgrading Hatch, see our Installation Guide .
"""
def run_command(command: list[str]) -> None:
process = subprocess.run(command) # noqa: PLW1510
if process.returncode:
sys.exit(process.returncode)
def main():
parser = argparse.ArgumentParser()
parser.add_argument("directory")
parser.add_argument("--binary", required=True)
parser.add_argument("--version", required=True)
args = parser.parse_args()
directory = Path(args.directory).absolute()
staged_binary = Path(args.binary).absolute()
binary_name = staged_binary.stem
version = args.version
with TemporaryDirectory() as d:
temp_dir = Path(d)
# This is where we assemble files required for builds
resources_dir = temp_dir / "resources"
shutil.copytree(str(ASSETS_DIR / "resources"), str(resources_dir))
resources_dir.joinpath("README.html").write_text(README.format(version=version), encoding="utf-8")
shutil.copy2(REPO_DIR / "LICENSE.txt", resources_dir)
# This is what gets shipped to users starting at / (the root directory)
root_dir = temp_dir / "root"
root_dir.mkdir()
# This is where we globally install Hatch. We choose to not offer per-user installs because we can't
# find out where the location is and therefore cannot add to PATH usually. For more information, see:
# https://github.com/aws/aws-cli/commit/f3c3eb8262786142a1712b6da5a1515ad9dc66c5
relative_binary_dir = Path("usr", "local", binary_name, "bin")
binary_dir = root_dir / relative_binary_dir
binary_dir.mkdir(parents=True)
shutil.copy2(staged_binary, binary_dir)
# This is how we add the installation directory to PATH and is also what Go does,
# although there are some caveats: https://apple.stackexchange.com/q/126725
path_file = root_dir / "etc" / "paths.d" / binary_name
path_file.parent.mkdir(parents=True)
path_file.write_text(f"/{relative_binary_dir}\n", encoding="utf-8")
# This is where we build the intermediate components
components_dir = temp_dir / "components"
components_dir.mkdir()
run_command([
"pkgbuild",
"--root",
str(root_dir),
"--identifier",
IDENTIFIER,
"--version",
version,
"--install-location",
"/",
str(components_dir / COMPONENT_PACKAGE_NAME),
])
# This is where we build the final artifact
build_dir = temp_dir / "build"
build_dir.mkdir()
product_archive = build_dir / f"{binary_name}-universal.pkg"
run_command([
"productbuild",
"--distribution",
str(ASSETS_DIR / "distribution.xml"),
"--resources",
str(resources_dir),
"--package-path",
str(components_dir),
str(product_archive),
])
# Copy the final artifact to the target directory
directory.mkdir(parents=True, exist_ok=True)
shutil.copy2(product_archive, directory)
if __name__ == "__main__":
main()
================================================
FILE: release/macos/pkg/distribution.xml
================================================
Hatch
org.python.hatch.pkg
================================================
FILE: release/unix/make_scripts_portable.py
================================================
from __future__ import annotations
import sys
import sysconfig
from io import BytesIO
from pathlib import Path
def main():
interpreter = Path(sys.executable).resolve()
# https://github.com/indygreg/python-build-standalone/blob/20240415/cpython-unix/build-cpython.sh#L812-L813
portable_shebang = b'#!/bin/sh\n"exec" "$(dirname $0)/%s" "$0" "$@"\n' % interpreter.name.encode()
scripts_dir = Path(sysconfig.get_path("scripts"))
for script in scripts_dir.iterdir():
if not script.is_file():
continue
with script.open("rb") as f:
data = BytesIO()
for line in f:
# Ignore leading blank lines
if not line.strip():
continue
# Ignore binaries
if not line.startswith(b"#"):
break
if line.startswith(b"#!%s" % interpreter.parent):
executable = Path(line[2:].rstrip().decode()).resolve()
data.write(portable_shebang if executable == interpreter else line)
else:
data.write(line)
data.write(f.read())
break
contents = data.getvalue()
if not contents:
continue
with script.open("wb") as f:
f.write(contents)
if __name__ == "__main__":
main()
================================================
FILE: release/windows/make_scripts_portable.py
================================================
from __future__ import annotations
import sys
import sysconfig
from contextlib import closing
from importlib.metadata import entry_points
from io import BytesIO
from os.path import relpath
from pathlib import Path
from tempfile import TemporaryDirectory
from urllib.request import urlopen
from zipfile import ZIP_DEFLATED, ZipFile, ZipInfo
LAUNCHERS_URL = "https://raw.githubusercontent.com/astral-sh/uv/main/crates/uv-trampoline-builder/trampolines"
SCRIPT_TEMPLATE = """\
#!{executable}
import re
import sys
from {module} import {import_name}
if __name__ == "__main__":
sys.argv[0] = re.sub(r"(-script\\.pyw|\\.exe)?$", "", sys.argv[0])
sys.exit({function}())
"""
def select_entry_points(ep, group):
return ep.select(group=group) if sys.version_info[:2] >= (3, 10) else ep.get(group, [])
def fetch_launcher(launcher_name):
with urlopen(f"{LAUNCHERS_URL}/{launcher_name}") as f: # noqa: S310
return f.read()
def main():
interpreters_dir = Path(sys.executable).parent
scripts_dir = Path(sysconfig.get_path("scripts"))
ep = entry_points()
for group, interpreter_name, launcher_name in (
("console_scripts", "python.exe", "uv-trampoline-x86_64-console.exe"),
("gui_scripts", "pythonw.exe", "uv-trampoline-x86_64-gui.exe"),
):
interpreter = interpreters_dir / interpreter_name
relative_interpreter_path = relpath(interpreter, scripts_dir)
launcher_data = fetch_launcher(launcher_name)
for script in select_entry_points(ep, group):
# https://github.com/astral-sh/uv/tree/main/crates/uv-trampoline#how-do-you-use-it
with closing(BytesIO()) as buf:
# Launcher
buf.write(launcher_data)
# Zipped script
with TemporaryDirectory() as td:
zip_path = Path(td) / "script.zip"
with ZipFile(zip_path, "w") as zf:
# Ensure reproducibility
zip_info = ZipInfo("__main__.py", (2020, 2, 2, 0, 0, 0))
zip_info.external_attr = (0o644 & 0xFFFF) << 16
module, _, attrs = script.value.partition(":")
contents = SCRIPT_TEMPLATE.format(
executable=relative_interpreter_path,
module=module,
import_name=attrs.split(".")[0],
function=attrs,
)
zf.writestr(zip_info, contents, compress_type=ZIP_DEFLATED)
buf.write(zip_path.read_bytes())
# Interpreter path
interpreter_path = relative_interpreter_path.encode("utf-8")
buf.write(interpreter_path)
# Interpreter path length
interpreter_path_length = len(interpreter_path).to_bytes(4, "little")
buf.write(interpreter_path_length)
# Magic number
buf.write(b"UVUV")
script_data = buf.getvalue()
script_path = scripts_dir / f"{script.name}.exe"
script_path.write_bytes(script_data)
if __name__ == "__main__":
main()
================================================
FILE: ruff.toml
================================================
extend = "ruff_defaults.toml"
# https://github.com/astral-sh/ruff/issues/8627
exclude = [".git", ".mypy_cache", ".ruff_cache", ".venv", "dist"]
[format]
preview = true
[lint]
preview = true
ignore = [
# Allow lazy imports for responsive CLI
"PLC0415",
]
[lint.extend-per-file-ignores]
"backend/src/hatchling/bridge/app.py" = ["T201"]
"backend/tests/downstream/integrate.py" = ["INP001", "T201"]
"docs/.hooks/*" = ["INP001", "T201"]
"release/**/*" = ["INP001"]
[lint.isort]
known-first-party = ["hatch", "hatchling"]
================================================
FILE: ruff_defaults.toml
================================================
line-length = 120
[format]
docstring-code-format = true
docstring-code-line-length = 80
[lint]
select = [
"A001",
"A002",
"A003",
"ARG001",
"ARG001",
"ARG002",
"ARG003",
"ARG004",
"ARG005",
"ASYNC100",
"B002",
"B003",
"B004",
"B005",
"B006",
"B007",
"B008",
"B009",
"B010",
"B011",
"B012",
"B013",
"B014",
"B015",
"B016",
"B017",
"B018",
"B019",
"B020",
"B021",
"B022",
"B023",
"B024",
"B025",
"B026",
"B028",
"B029",
"B030",
"B031",
"B032",
"B033",
"B034",
"B035",
"B904",
"B905",
"B909",
"BLE001",
"C400",
"C401",
"C402",
"C403",
"C404",
"C405",
"C406",
"C408",
"C409",
"C410",
"C411",
"C413",
"C414",
"C415",
"C416",
"C417",
"C418",
"C419",
"COM818",
"DTZ001",
"DTZ002",
"DTZ003",
"DTZ004",
"DTZ005",
"DTZ006",
"DTZ007",
"DTZ011",
"DTZ012",
"E101",
"E112",
"E113",
"E115",
"E116",
"E201",
"E202",
"E203",
"E211",
"E221",
"E222",
"E223",
"E224",
"E225",
"E226",
"E227",
"E228",
"E231",
"E241",
"E242",
"E251",
"E252",
"E261",
"E262",
"E265",
"E266",
"E271",
"E272",
"E273",
"E274",
"E275",
"E401",
"E402",
"E502",
"E701",
"E702",
"E703",
"E711",
"E712",
"E713",
"E714",
"E721",
"E722",
"E731",
"E741",
"E742",
"E743",
"E902",
"EM101",
"EM102",
"EM103",
"EXE001",
"EXE002",
"EXE003",
"EXE004",
"EXE005",
"F401",
"F402",
"F403",
"F404",
"F405",
"F406",
"F407",
"F501",
"F502",
"F503",
"F504",
"F505",
"F506",
"F507",
"F508",
"F509",
"F521",
"F522",
"F523",
"F524",
"F525",
"F541",
"F601",
"F602",
"F621",
"F622",
"F631",
"F632",
"F633",
"F634",
"F701",
"F702",
"F704",
"F706",
"F707",
"F722",
"F811",
"F821",
"F822",
"F823",
"F841",
"F842",
"F901",
"FA100",
"FA102",
"FBT001",
"FBT002",
"FLY002",
"FURB105",
"FURB110",
"FURB113",
"FURB116",
"FURB118",
"FURB129",
"FURB131",
"FURB132",
"FURB136",
"FURB142",
"FURB145",
"FURB148",
"FURB152",
"FURB157",
"FURB161",
"FURB163",
"FURB164",
"FURB166",
"FURB167",
"FURB168",
"FURB169",
"FURB171",
"FURB177",
"FURB180",
"FURB181",
"FURB187",
"FURB192",
"G001",
"G002",
"G003",
"G004",
"G010",
"G101",
"G201",
"G202",
"I001",
"I002",
"ICN001",
"ICN002",
"ICN003",
"INP001",
"INT001",
"INT002",
"INT003",
"ISC003",
"LOG001",
"LOG002",
"LOG007",
"LOG009",
"N801",
"N802",
"N803",
"N804",
"N805",
"N806",
"N807",
"N811",
"N812",
"N813",
"N814",
"N815",
"N816",
"N817",
"N818",
"N999",
"PERF101",
"PERF102",
"PERF401",
"PERF402",
"PERF403",
"PGH005",
"PIE790",
"PIE794",
"PIE796",
"PIE800",
"PIE804",
"PIE807",
"PIE808",
"PIE810",
"PLC0105",
"PLC0131",
"PLC0132",
"PLC0205",
"PLC0208",
"PLC0414",
"PLC0415",
"PLC1901",
"PLC2401",
"PLC2403",
"PLC2701",
"PLC2801",
"PLC3002",
"PLE0100",
"PLE0101",
"PLE0115",
"PLE0116",
"PLE0117",
"PLE0118",
"PLE0237",
"PLE0241",
"PLE0302",
"PLE0303",
"PLE0304",
"PLE0305",
"PLE0307",
"PLE0308",
"PLE0309",
"PLE0604",
"PLE0605",
"PLE0643",
"PLE0704",
"PLE1132",
"PLE1141",
"PLE1142",
"PLE1205",
"PLE1206",
"PLE1300",
"PLE1307",
"PLE1310",
"PLE1507",
"PLE1519",
"PLE1520",
"PLE1700",
"PLE2502",
"PLE2510",
"PLE2512",
"PLE2513",
"PLE2514",
"PLE2515",
"PLE4703",
"PLR0124",
"PLR0133",
"PLR0202",
"PLR0203",
"PLR0206",
"PLR0402",
"PLR1704",
"PLR1711",
"PLR1714",
"PLR1722",
"PLR1730",
"PLR1733",
"PLR1736",
"PLR2004",
"PLR2044",
"PLR5501",
"PLR6104",
"PLR6201",
"PLR6301",
"PLW0108",
"PLW0120",
"PLW0127",
"PLW0128",
"PLW0129",
"PLW0131",
"PLW0133",
"PLW0177",
"PLW0211",
"PLW0245",
"PLW0406",
"PLW0602",
"PLW0603",
"PLW0604",
"PLW0642",
"PLW0711",
"PLW1501",
"PLW1508",
"PLW1509",
"PLW1510",
"PLW1514",
"PLW1641",
"PLW2101",
"PLW2901",
"PLW3201",
"PLW3301",
"PT001",
"PT002",
"PT003",
"PT006",
"PT007",
"PT008",
"PT009",
"PT010",
"PT011",
"PT012",
"PT013",
"PT014",
"PT015",
"PT016",
"PT017",
"PT018",
"PT019",
"PT020",
"PT021",
"PT022",
"PT023",
"PT024",
"PT025",
"PT026",
"PT027",
"PYI001",
"PYI002",
"PYI003",
"PYI004",
"PYI005",
"PYI006",
"PYI007",
"PYI008",
"PYI009",
"PYI010",
"PYI011",
"PYI012",
"PYI013",
"PYI014",
"PYI015",
"PYI016",
"PYI017",
"PYI018",
"PYI019",
"PYI020",
"PYI021",
"PYI024",
"PYI025",
"PYI026",
"PYI029",
"PYI030",
"PYI032",
"PYI033",
"PYI034",
"PYI035",
"PYI036",
"PYI041",
"PYI042",
"PYI043",
"PYI044",
"PYI045",
"PYI046",
"PYI047",
"PYI048",
"PYI049",
"PYI050",
"PYI051",
"PYI052",
"PYI053",
"PYI054",
"PYI055",
"PYI056",
"PYI058",
"PYI059",
"PYI062",
"RET503",
"RET504",
"RET505",
"RET506",
"RET507",
"RET508",
"RSE102",
"RUF001",
"RUF002",
"RUF003",
"RUF005",
"RUF006",
"RUF007",
"RUF008",
"RUF009",
"RUF010",
"RUF012",
"RUF013",
"RUF015",
"RUF016",
"RUF017",
"RUF018",
"RUF019",
"RUF020",
"RUF021",
"RUF022",
"RUF023",
"RUF024",
"RUF026",
"RUF027",
"RUF028",
"RUF029",
"RUF100",
"RUF101",
"S101",
"S102",
"S103",
"S104",
"S105",
"S106",
"S107",
"S108",
"S110",
"S112",
"S113",
"S201",
"S202",
"S301",
"S302",
"S303",
"S304",
"S305",
"S306",
"S307",
"S308",
"S310",
"S311",
"S312",
"S313",
"S314",
"S315",
"S316",
"S317",
"S318",
"S319",
"S321",
"S323",
"S324",
"S401",
"S402",
"S403",
"S405",
"S406",
"S407",
"S408",
"S409",
"S411",
"S412",
"S413",
"S415",
"S501",
"S502",
"S503",
"S504",
"S505",
"S506",
"S507",
"S508",
"S509",
"S601",
"S602",
"S604",
"S605",
"S606",
"S607",
"S608",
"S609",
"S610",
"S611",
"S612",
"S701",
"S702",
"SIM101",
"SIM102",
"SIM103",
"SIM105",
"SIM107",
"SIM108",
"SIM109",
"SIM110",
"SIM112",
"SIM113",
"SIM114",
"SIM115",
"SIM116",
"SIM117",
"SIM118",
"SIM201",
"SIM202",
"SIM208",
"SIM210",
"SIM211",
"SIM212",
"SIM220",
"SIM221",
"SIM222",
"SIM223",
"SIM300",
"SIM910",
"SIM911",
"SLF001",
"SLOT000",
"SLOT001",
"SLOT002",
"T100",
"T201",
"T203",
"TC001",
"TC002",
"TC003",
"TC004",
"TC005",
"TC010",
"TD004",
"TD005",
"TD006",
"TD007",
"TID251",
"TID252",
"TID253",
"TRY002",
"TRY003",
"TRY004",
"TRY201",
"TRY203",
"TRY300",
"TRY301",
"TRY400",
"TRY401",
"UP001",
"UP003",
"UP004",
"UP005",
"UP006",
"UP007",
"UP008",
"UP009",
"UP010",
"UP011",
"UP012",
"UP013",
"UP014",
"UP015",
"UP017",
"UP018",
"UP019",
"UP020",
"UP021",
"UP022",
"UP023",
"UP024",
"UP025",
"UP026",
"UP028",
"UP029",
"UP030",
"UP031",
"UP032",
"UP033",
"UP034",
"UP035",
"UP036",
"UP037",
"UP039",
"UP040",
"UP041",
"UP042",
"W291",
"W292",
"W293",
"W391",
"W505",
"W605",
"YTT101",
"YTT102",
"YTT103",
"YTT201",
"YTT202",
"YTT203",
"YTT204",
"YTT301",
"YTT302",
"YTT303",
]
[lint.per-file-ignores]
"**/scripts/*" = [
"INP001",
"T201",
]
"**/tests/**/*" = [
"PLC1901",
"PLR2004",
"PLR6301",
"S",
"TID252",
]
[lint.flake8-tidy-imports]
ban-relative-imports = "all"
[lint.isort]
known-first-party = ["hatch"]
[lint.flake8-pytest-style]
fixture-parentheses = false
mark-parentheses = false
================================================
FILE: scripts/bump.py
================================================
import argparse
import re
import subprocess
from datetime import datetime, timezone
from utils import ROOT, get_latest_release
TEMPLATE = (
"## [{version}](https://github.com/pypa/hatch/releases/tag/{project}-v{version}) - "
"{year}-{month:02}-{day:02} ## {{: #{project}-v{version} }}"
)
def main():
parser = argparse.ArgumentParser()
parser.add_argument("project", choices=["hatch", "hatchling"])
parser.add_argument("version")
args = parser.parse_args()
root_dir = project_dir = ROOT
if args.project == "hatchling":
project_dir = root_dir / "backend"
history_file = root_dir / "docs" / "history" / f"{args.project}.md"
if args.project == "hatchling":
process = subprocess.run( # noqa: PLW1510
["hatch", "version", args.version], # noqa: S607
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
encoding="utf-8",
cwd=str(project_dir),
)
if process.returncode:
raise OSError(process.stdout)
new_version = re.search(r"New: (.+)$", process.stdout, re.MULTILINE).group(1)
else:
from hatchling.version.scheme.standard import StandardScheme
latest_version, _ = get_latest_release(args.project)
scheme = StandardScheme(str(project_dir), {})
new_version = scheme.update(args.version, latest_version, {})
now = datetime.now(timezone.utc)
history_file_lines = history_file.read_text(encoding="utf-8").splitlines()
insertion_index = history_file_lines.index("## Unreleased") + 1
history_file_lines.insert(
insertion_index,
TEMPLATE.format(project=args.project, version=new_version, year=now.year, month=now.month, day=now.day),
)
history_file_lines.insert(insertion_index, "")
history_file_lines.append("")
history_file.write_text("\n".join(history_file_lines), encoding="utf-8")
for command in (
["git", "add", "--all"],
["git", "commit", "-m", f"release {args.project.capitalize()} v{new_version}"],
):
subprocess.run(command, check=True)
if __name__ == "__main__":
main()
================================================
FILE: scripts/generate_coverage_summary.py
================================================
import json
from collections import defaultdict
from lxml import etree # nosec B410
from utils import ROOT
PACKAGES = {
"backend/src/hatchling/": "hatchling",
"src/hatch/": "hatch",
"tests/": "tests",
}
def main():
coverage_report = ROOT / "coverage.xml"
root = etree.fromstring(coverage_report.read_text()) # nosec B320
raw_package_data = defaultdict(lambda: {"hits": 0, "misses": 0})
for package in root.find("packages"):
for module in package.find("classes"):
filename = module.attrib["filename"]
for relative_path, package_name in PACKAGES.items():
if filename.startswith(relative_path):
data = raw_package_data[package_name]
break
else:
message = f"unknown package: {module}"
raise ValueError(message)
for line in module.find("lines"):
if line.attrib["hits"] == "1":
data["hits"] += 1
else:
data["misses"] += 1
total_statements_covered = 0
total_statements = 0
coverage_data = {}
for package_name, data in sorted(raw_package_data.items()):
statements_covered = data["hits"]
statements = statements_covered + data["misses"]
total_statements_covered += statements_covered
total_statements += statements
coverage_data[package_name] = {"statements_covered": statements_covered, "statements": statements}
coverage_data["total"] = {"statements_covered": total_statements_covered, "statements": total_statements}
coverage_summary = ROOT / "coverage-summary.json"
coverage_summary.write_text(json.dumps(coverage_data, indent=4), encoding="utf-8")
if __name__ == "__main__":
main()
================================================
FILE: scripts/install_mkdocs_material_insiders.py
================================================
import os
import subprocess
import sys
TOKEN = os.environ.get("GH_TOKEN_MKDOCS_MATERIAL_INSIDERS", "")
DEP_REF = f"git+https://{TOKEN}@github.com/squidfunk/mkdocs-material-insiders.git"
GIT_REF = "f2d5b41b2e590baf73ae5f51166d88b233ba96aa"
def main():
if not TOKEN:
print("No token is set, skipping")
return
dependency = f"mkdocs-material[imaging] @ {DEP_REF}@{GIT_REF}"
try:
process = subprocess.Popen(
["uv", "pip", "install", dependency], # noqa: S607
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
encoding="utf-8",
)
except Exception as e: # noqa: BLE001
print(str(e).replace(TOKEN, "*****"))
sys.exit(1)
with process:
for line in iter(process.stdout.readline, ""):
print(line.replace(TOKEN, "*****"), end="")
sys.exit(process.returncode)
if __name__ == "__main__":
main()
================================================
FILE: scripts/release_github.py
================================================
import argparse
import subprocess
import sys
import webbrowser
from urllib.parse import urlencode
from utils import get_latest_release
def main():
parser = argparse.ArgumentParser()
parser.add_argument("project", choices=["hatch", "hatchling"])
args = parser.parse_args()
version, notes = get_latest_release(args.project)
tag = f"{args.project}-v{version}"
# Create and push tag first
try:
subprocess.run(["git", "tag", tag], check=True) # noqa: S607
subprocess.run(["git", "push", "origin", tag], check=True) # noqa: S607
print(f"Created and pushed tag: {tag}")
except subprocess.CalledProcessError as e:
print(f"Error creating tag: {e}")
sys.exit(1)
# Open GitHub UI to create draft release
params = urlencode({
"title": f"{args.project.capitalize()} v{version}",
"tag": tag,
"body": notes,
"draft": "true",
})
url = f"https://github.com/pypa/hatch/releases/new?{params}"
webbrowser.open_new_tab(url)
if __name__ == "__main__":
main()
================================================
FILE: scripts/set_release_version.py
================================================
import os
from utils import get_latest_release
def main():
version, _ = get_latest_release("hatch")
parts = version.split(".")
with open(os.environ["GITHUB_ENV"], "a", encoding="utf-8") as f:
f.write(f"HATCH_DOCS_VERSION={parts[0]}.{parts[1]}\n")
if __name__ == "__main__":
main()
================================================
FILE: scripts/update_distributions.py
================================================
from __future__ import annotations
import re
from ast import literal_eval
from collections import defaultdict
import httpx
from utils import ROOT
URL = "https://raw.githubusercontent.com/ofek/pyapp/master/build.rs"
OUTPUT_FILE = ROOT / "src" / "hatch" / "python" / "distributions.py"
ARCHES = {("linux", "x86"): "i686", ("windows", "x86_64"): "amd64", ("windows", "x86"): "i386"}
# system, architecture, ABI, CPU variant, GIL variant
MAX_IDENTIFIER_COMPONENTS = 5
def parse_distributions(contents: str, constant: str):
match = re.search(f"^const {constant}.+?^];$", contents, flags=re.DOTALL | re.MULTILINE)
if not match:
message = f"Could not find {constant} in {URL}"
raise ValueError(message)
block = match.group(0).replace('",\n', '",')
for raw_line in block.splitlines()[1:-1]:
line = raw_line.strip()
if not line or line.startswith("//"):
continue
identifier, *data, source = literal_eval(line[:-1])
os, arch = data[:2]
if arch == "powerpc64":
arch = "ppc64le"
elif os == "macos" and arch == "aarch64":
arch = "arm64"
# Force everything to have the proper number of variants to maintain structure
if len(data) != MAX_IDENTIFIER_COMPONENTS:
data.extend(("", ""))
data[1] = ARCHES.get((os, arch), arch)
yield identifier, tuple(data), source
def main():
response = httpx.get(URL)
response.raise_for_status()
contents = response.text
distributions = defaultdict(list)
ordering_data = defaultdict(dict)
for i, distribution_type in enumerate(("DEFAULT_CPYTHON_DISTRIBUTIONS", "DEFAULT_PYPY_DISTRIBUTIONS")):
for identifier, data, source in parse_distributions(contents, distribution_type):
ordering_data[i][identifier] = None
distributions[identifier].append((data, source))
ordered = [identifier for identifiers in ordering_data.values() for identifier in reversed(identifiers)]
output = [
"from __future__ import annotations",
"",
"# fmt: off",
"ORDERED_DISTRIBUTIONS: tuple[str, ...] = (",
]
output.extend(f" {identifier!r}," for identifier in ordered)
output.extend((")", "DISTRIBUTIONS: dict[str, dict[tuple[str, ...], str]] = {"))
for identifier, data in distributions.items():
output.append(f" {identifier!r}: {{")
for d, source in data:
output.extend((f" {d!r}:", f" {source!r},"))
output.append(" },")
output.extend(("}", ""))
output = "\n".join(output)
with open(OUTPUT_FILE, "w", encoding="utf-8") as f:
f.write(output)
if __name__ == "__main__":
main()
================================================
FILE: scripts/update_ruff.py
================================================
from __future__ import annotations
import json
import re
import subprocess
import sys
import typing
from importlib.metadata import version
from utils import ROOT
if typing.TYPE_CHECKING:
from pathlib import Path
# fmt: off
UNSELECTED_RULE_PATTERNS: list[str] = [
# Allow non-abstract empty methods in abstract base classes
'B027',
# Allow boolean positional values in function calls, like `dict.get(... True)`
'FBT003',
# Ignore complexity
'C901', 'PLR0904', 'PLR0911', 'PLR0912', 'PLR0913', 'PLR0914', 'PLR0915', 'PLR0916', 'PLR0917', 'PLR1702',
# These are dependent on projects themselves
'AIR\\d+', 'CPY\\d+', 'D\\d+', 'DJ\\d+', 'NPY\\d+', 'PD\\d+',
# Many projects either don't have type annotations or it would take much effort to satisfy this
'ANN\\d+',
# Don't be too strict about TODOs as not everyone uses them the same way
'FIX\\d+', 'TD001', 'TD002', 'TD003',
# There are valid reasons to not use pathlib such as performance and import cost
'PTH\\d+', 'FURB101', 'FURB103',
# Conflicts with type checking
'RET501', 'RET502',
# Under review https://github.com/astral-sh/ruff/issues/8796
'PT004', 'PT005',
# Buggy https://github.com/astral-sh/ruff/issues/4845
'ERA001',
# Business logic relying on other programs has no choice but to use subprocess
'S404',
# Too prone to false positives and might be removed https://github.com/astral-sh/ruff/issues/4045
'S603',
# Too prone to false positives https://github.com/astral-sh/ruff/issues/8761
'SIM401',
# Allow for easy ignores
'PGH003', 'PGH004',
# This is required sometimes, and doesn't matter on Python 3.11+
'PERF203',
# Potentially unnecessary on Python 3.12+
'FURB140',
# Conflicts with formatter, see:
# https://docs.astral.sh/ruff/formatter/#conflicting-lint-rules
'COM812', 'COM819', 'D206', 'D300', 'E111', 'E114', 'E117', 'E301', 'E302', 'E303', 'E304', 'E305', 'E306', "E501", 'ISC001', 'ISC002', 'Q000', 'Q001', 'Q002', 'Q003', 'Q004', 'W191',
# Conflicts with context formatting in dependencies
'RUF200',
# Currently broken
]
PER_FILE_IGNORED_RULES: dict[str, list[str]] = {
'**/scripts/*': [
# Implicit namespace packages
'INP001',
# Print statements
'T201',
],
'**/tests/**/*': [
# Empty string comparisons
'PLC1901',
# Magic values
'PLR2004',
# Methods that don't use `self`
'PLR6301',
# Potential security issues like assert statements and hardcoded passwords
'S',
# Relative imports
'TID252',
],
}
# fmt: on
def get_lines_until(file_path: Path, marker: str) -> list[str]:
lines = file_path.read_text(encoding="utf-8").splitlines()
for i, line in enumerate(lines):
if line.startswith(marker):
block_start = i
break
else:
message = f"Could not find {marker}: {file_path.relative_to(ROOT)}"
raise ValueError(message)
del lines[block_start:]
return lines
def main():
process = subprocess.run( # noqa: PLW1510
[sys.executable, "-m", "ruff", "rule", "--all", "--output-format", "json"],
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
encoding="utf-8",
cwd=str(ROOT),
)
if process.returncode:
raise OSError(process.stdout)
data_file = ROOT / "src" / "hatch" / "cli" / "fmt" / "core.py"
lines = get_lines_until(data_file, "STABLE_RULES")
ignored_pattern = re.compile(f"^({'|'.join(UNSELECTED_RULE_PATTERNS)})$")
# https://github.com/astral-sh/ruff/issues/9891#issuecomment-1951403651
removed_pattern = re.compile(r"^\s*#+\s+(removed|removal)", flags=re.IGNORECASE | re.MULTILINE)
stable_rules: set[str] = set()
preview_rules: set[str] = set()
unselected_rules: set[str] = set()
for rule in json.loads(process.stdout):
code = rule["code"]
if ignored_pattern.match(code) or removed_pattern.search(rule["explanation"]):
unselected_rules.add(code)
continue
if rule["preview"]:
preview_rules.add(code)
else:
stable_rules.add(code)
lines.append("STABLE_RULES: tuple[str, ...] = (")
lines.extend(f" {rule!r}," for rule in sorted(stable_rules))
lines.append(")")
lines.append("PREVIEW_RULES: tuple[str, ...] = (")
lines.extend(f" {rule!r}," for rule in sorted(preview_rules))
lines.append(")")
lines.append("PER_FILE_IGNORED_RULES: dict[str, list[str]] = {")
for ignored_glob, ignored_rules in sorted(PER_FILE_IGNORED_RULES.items()):
lines.append(f" {ignored_glob!r}: [")
lines.extend(f" {rule!r}," for rule in sorted(ignored_rules))
lines.append(" ],")
lines.append("}")
lines.append("")
data_file.write_text("\n".join(lines), encoding="utf-8")
version_file = ROOT / "src" / "hatch" / "env" / "internal" / "static_analysis.py"
latest_version = version("ruff")
version_file.write_text(
re.sub(
r"^(RUFF_DEFAULT_VERSION.+=.+\').+?(\')$",
rf"\g<1>{latest_version}\g<2>",
version_file.read_text(encoding="utf-8"),
count=1,
flags=re.MULTILINE,
),
encoding="utf-8",
)
data_file = ROOT / "docs" / ".hooks" / "render_ruff_defaults.py"
lines = get_lines_until(data_file, "UNSELECTED_RULES")
lines.append("UNSELECTED_RULES: tuple[str, ...] = (")
lines.extend(f" {rule!r}," for rule in sorted(unselected_rules))
lines.append(")")
lines.append("")
data_file.write_text("\n".join(lines), encoding="utf-8")
print(f"Stable rules: {len(stable_rules)}")
print(f"Preview rules: {len(preview_rules)}")
print(f"Unselected rules: {len(unselected_rules)}")
if __name__ == "__main__":
main()
================================================
FILE: scripts/utils.py
================================================
import re
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
def get_latest_release(project):
history_file = ROOT / "docs" / "history" / f"{project}.md"
release_headers = 0
history_file_lines = []
with history_file.open(encoding="utf-8") as f:
for line in f:
history_file_lines.append(line.rstrip())
if line.startswith("## "):
release_headers += 1
if release_headers == 3: # noqa: PLR2004
break
release_lines = history_file_lines[history_file_lines.index("## Unreleased") + 1 : -1]
while True:
release_header = release_lines.pop(0)
if release_header.startswith("## "):
break
return re.search(r"\[(.+)\]", release_header).group(1), "\n".join(release_lines).strip()
================================================
FILE: scripts/validate_history.py
================================================
import re
import sys
from utils import ROOT
HEADER_PATTERN = (
r"^\[([a-z0-9.]+)\]\(https://github\.com/pypa/hatch/releases/tag/({package}-v\1)\)"
r" - [0-9]{{4}}-[0-9]{{2}}-[0-9]{{2}} ## \{{: #\2 \}}$"
)
def main():
for package in ("hatch", "hatchling"):
history_file = ROOT / "docs" / "history" / f"{package}.md"
current_pattern = HEADER_PATTERN.format(package=package)
with history_file.open("r", encoding="utf-8") as f:
for raw_line in f:
line = raw_line.strip()
if not line:
continue
if line.startswith("## "):
_, _, header = line.partition(" ")
if header == "Unreleased":
continue
if not re.search(current_pattern, header):
print("Invalid header:")
print(header)
sys.exit(1)
if __name__ == "__main__":
main()
================================================
FILE: scripts/write_coverage_summary_report.py
================================================
import json
from decimal import ROUND_DOWN, Decimal
from pathlib import Path
PRECISION = Decimal(".01")
def main():
project_root = Path(__file__).resolve().parent.parent
coverage_summary = project_root / "coverage-summary.json"
coverage_data = json.loads(coverage_summary.read_text(encoding="utf-8"))
total_data = coverage_data.pop("total")
lines = [
"\n",
"Package | Statements\n",
"--- | ---\n",
]
for package, data in sorted(coverage_data.items()):
statements_covered = data["statements_covered"]
statements = data["statements"]
rate = Decimal(statements_covered) / Decimal(statements) * 100
rate = rate.quantize(PRECISION, rounding=ROUND_DOWN)
lines.append(
f"{package} | {100 if rate == 100 else rate}% ({statements_covered} / {statements})\n" # noqa: PLR2004
)
total_statements_covered = total_data["statements_covered"]
total_statements = total_data["statements"]
total_rate = Decimal(total_statements_covered) / Decimal(total_statements) * 100
total_rate = total_rate.quantize(PRECISION, rounding=ROUND_DOWN)
color = "ok" if float(total_rate) >= 95 else "critical" # noqa: PLR2004
lines.insert(0, f"\n")
lines.append(
f"**Summary** | {100 if total_rate == 100 else total_rate}% " # noqa: PLR2004
f"({total_statements_covered} / {total_statements})\n"
)
coverage_report = project_root / "coverage-report.md"
with coverage_report.open("w", encoding="utf-8") as f:
f.write("".join(lines))
if __name__ == "__main__":
main()
================================================
FILE: src/hatch/__init__.py
================================================
================================================
FILE: src/hatch/__main__.py
================================================
if __name__ == "__main__":
from hatch.cli import main
main()
================================================
FILE: src/hatch/cli/__init__.py
================================================
from __future__ import annotations
import os
from typing import cast
import click
from hatch._version import __version__
from hatch.cli.application import Application
from hatch.cli.build import build
from hatch.cli.clean import clean
from hatch.cli.config import config
from hatch.cli.dep import dep
from hatch.cli.env import env
from hatch.cli.fmt import fmt
from hatch.cli.new import new
from hatch.cli.project import project
from hatch.cli.publish import publish
from hatch.cli.python import python
from hatch.cli.run import run
from hatch.cli.self import self_command
from hatch.cli.shell import shell
from hatch.cli.status import status
from hatch.cli.test import test
from hatch.cli.version import version
from hatch.config.constants import AppEnvVars, ConfigEnvVars
from hatch.project.core import Project
from hatch.utils.ci import running_in_ci
from hatch.utils.fs import Path
@click.group(
context_settings={"help_option_names": ["-h", "--help"], "max_content_width": 120}, invoke_without_command=True
)
@click.option(
"--env",
"-e",
"env_name",
envvar=AppEnvVars.ENV,
default="default",
help="The name of the environment to use [env var: `HATCH_ENV`]",
)
@click.option(
"--project",
"-p",
envvar=ConfigEnvVars.PROJECT,
help="The name of the project to work on [env var: `HATCH_PROJECT`]",
)
@click.option(
"--verbose",
"-v",
envvar=AppEnvVars.VERBOSE,
count=True,
help="Increase verbosity (can be used additively) [env var: `HATCH_VERBOSE`]",
)
@click.option(
"--quiet",
"-q",
envvar=AppEnvVars.QUIET,
count=True,
help="Decrease verbosity (can be used additively) [env var: `HATCH_QUIET`]",
)
@click.option(
"--color/--no-color",
default=None,
help="Whether or not to display colored output (default is auto-detection) [env vars: `FORCE_COLOR`/`NO_COLOR`]",
)
@click.option(
"--interactive/--no-interactive",
envvar=AppEnvVars.INTERACTIVE,
default=None,
help=(
"Whether or not to allow features like prompts and progress bars (default is auto-detection) "
"[env var: `HATCH_INTERACTIVE`]"
),
)
@click.option(
"--data-dir",
envvar=ConfigEnvVars.DATA,
help="The path to a custom directory used to persist data [env var: `HATCH_DATA_DIR`]",
)
@click.option(
"--cache-dir",
envvar=ConfigEnvVars.CACHE,
help="The path to a custom directory used to cache data [env var: `HATCH_CACHE_DIR`]",
)
@click.option(
"--config",
"config_file",
envvar=ConfigEnvVars.CONFIG,
help="The path to a custom config file to use [env var: `HATCH_CONFIG`]",
)
@click.version_option(version=__version__, prog_name="Hatch")
@click.pass_context
def hatch(
ctx: click.Context,
env_name,
project,
verbose,
quiet,
color,
interactive,
data_dir,
cache_dir,
config_file,
):
"""
\b
_ _ _ _
| | | | | | | |
| |_| | __ _| |_ ___| |__
| _ |/ _` | __/ __| '_ \\
| | | | (_| | || (__| | | |
\\_| |_/\\__,_|\\__\\___|_| |_|
"""
if color is None:
if os.environ.get(AppEnvVars.NO_COLOR) == "1":
color = False
elif os.environ.get(AppEnvVars.FORCE_COLOR) == "1":
color = True
if interactive is None and running_in_ci():
interactive = False
app = Application(ctx.exit, verbosity=verbose - quiet, enable_color=color, interactive=interactive)
app.env_active = os.environ.get(AppEnvVars.ENV_ACTIVE)
if (
app.env_active
and (param_source := ctx.get_parameter_source("env_name")) is not None
and param_source.name == "DEFAULT"
):
app.env = app.env_active
else:
app.env = env_name
if config_file:
app.config_file.path = Path(config_file).resolve()
if not app.config_file.path.is_file():
app.abort(f"The selected config file `{app.config_file.path}` does not exist.")
elif not app.config_file.path.is_file():
if app.verbose:
app.display_waiting("No config file found, creating one with default settings now...")
try:
app.config_file.restore()
if app.verbose:
app.display_success("Success! Please see `hatch config`.")
except OSError: # no cov
app.abort(
f"Unable to create config file located at `{app.config_file.path}`. Please check your permissions."
)
if not ctx.invoked_subcommand:
app.display_info(ctx.get_help())
return
# Persist app data for sub-commands
ctx.obj = app
try:
app.config_file.load()
except OSError as e: # no cov
app.abort(f"Error loading configuration: {e}")
app.config.terminal.styles.parse_fields()
errors = app.initialize_styles(app.config.terminal.styles.raw_data)
if errors and color is not False and not app.quiet: # no cov
for error in errors:
app.display_warning(error)
app.data_dir = Path(data_dir or app.config.dirs.data).expand()
app.cache_dir = Path(cache_dir or app.config.dirs.cache).expand()
if project:
potential_project = Project.from_config(app.config, project)
if potential_project is None or potential_project.root is None:
app.abort(f"Unable to locate project {project}")
app.project = cast(Project, potential_project)
app.project.set_app(app)
return
app.project = Project(Path.cwd())
app.project.set_app(app)
if app.config.mode == "local":
return
# The following logic is mostly duplicated for each branch so coverage can be asserted
if app.config.mode == "project":
if not app.config.project:
app.display_warning("Mode is set to `project` but no project is set, defaulting to the current directory")
return
possible_project = Project.from_config(app.config, app.config.project)
if possible_project is None:
app.display_warning(f"Unable to locate project {app.config.project}, defaulting to the current directory")
else:
app.project = possible_project
app.project.set_app(app)
return
if app.config.mode == "aware" and app.project.root is None:
if not app.config.project:
app.display_warning("Mode is set to `aware` but no project is set, defaulting to the current directory")
return
possible_project = Project.from_config(app.config, app.config.project)
if possible_project is None:
app.display_warning(f"Unable to locate project {app.config.project}, defaulting to the current directory")
else:
app.project = possible_project
app.project.set_app(app)
return
hatch.add_command(build)
hatch.add_command(clean)
hatch.add_command(config)
hatch.add_command(dep)
hatch.add_command(env)
hatch.add_command(fmt)
hatch.add_command(new)
hatch.add_command(project)
hatch.add_command(publish)
hatch.add_command(python)
hatch.add_command(run)
hatch.add_command(self_command)
hatch.add_command(shell)
hatch.add_command(status)
hatch.add_command(test)
hatch.add_command(version)
def main(): # no cov
try:
hatch(prog_name="hatch", windows_expand_args=False)
except Exception: # noqa: BLE001
import sys
from rich.console import Console
console = Console()
hatch_debug = os.getenv("HATCH_DEBUG") in {"1", "true"}
console.print_exception(suppress=[click], show_locals=hatch_debug)
sys.exit(1)
================================================
FILE: src/hatch/cli/application.py
================================================
from __future__ import annotations
import os
import sys
from functools import cached_property
from typing import TYPE_CHECKING, cast
from hatch.cli.terminal import Terminal
from hatch.config.user import ConfigFile, RootConfig
from hatch.project.core import Project
from hatch.utils.fs import Path
from hatch.utils.platform import Platform
from hatch.utils.runner import ExecutionContext
if TYPE_CHECKING:
from collections.abc import Generator
from hatch.dep.core import Dependency
from hatch.env.plugin.interface import EnvironmentInterface
class Application(Terminal):
def __init__(self, exit_func, *args, **kwargs):
super().__init__(*args, **kwargs)
self.platform = Platform(self.output)
self.__exit_func = exit_func
self.config_file = ConfigFile()
self.quiet = self.verbosity < 0
self.verbose = self.verbosity > 0
# Lazily set these as we acquire more knowledge about the environment
self.data_dir = cast(Path, None)
self.cache_dir = cast(Path, None)
self.project = cast(Project, None)
self.env = cast(str, None)
self.env_active = cast(str, None)
@property
def plugins(self):
return self.project.plugin_manager
@property
def config(self) -> RootConfig:
return self.config_file.model
def get_environment(self, env_name: str | None = None) -> EnvironmentInterface:
return self.project.get_environment(env_name)
def prepare_environment(self, environment: EnvironmentInterface, *, keep_env: bool = False):
self.project.prepare_environment(environment, keep_env=keep_env)
def run_shell_commands(self, context: ExecutionContext) -> None:
with context.env.command_context():
try:
resolved_commands = list(context.env.resolve_commands(context.shell_commands))
except Exception as e: # noqa: BLE001
self.abort(str(e))
first_error_code = None
should_display_command = not context.hide_commands and (self.verbose or len(resolved_commands) > 1)
for i, raw_command in enumerate(resolved_commands, 1):
if should_display_command:
self.display_info(f"{context.source} [{i}] | {raw_command}")
command = raw_command
continue_on_error = context.force_continue
if raw_command.startswith("- "):
continue_on_error = True
command = command[2:]
process = context.env.run_shell_command(command)
sys.stdout.flush()
sys.stderr.flush()
if process.returncode:
first_error_code = first_error_code or process.returncode
if continue_on_error:
continue
if context.show_code_on_error:
self.abort(f"Failed with exit code: {process.returncode}", code=process.returncode)
else:
self.abort(code=process.returncode)
if first_error_code and context.force_continue:
self.abort(code=first_error_code)
def runner_context(
self,
environments: list[str],
*,
ignore_compat: bool = False,
display_header: bool = False,
keep_env: bool = False,
) -> Generator[ExecutionContext, None, None]:
if self.verbose or len(environments) > 1:
display_header = True
any_compatible = False
incompatible = {}
with self.project.ensure_cwd():
for env_name in environments:
environment = self.get_environment(env_name)
if not environment.exists():
try:
environment.check_compatibility()
except Exception as e: # noqa: BLE001
if ignore_compat:
incompatible[environment.name] = str(e)
continue
self.abort(f"Environment `{env_name}` is incompatible: {e}")
any_compatible = True
if display_header:
self.display_header(environment.name)
context = ExecutionContext(environment)
yield context
self.prepare_environment(environment, keep_env=keep_env)
self.execute_context(context)
if incompatible:
num_incompatible = len(incompatible)
padding = "\n" if any_compatible else ""
self.display_warning(
f"{padding}Skipped {num_incompatible} incompatible environment{'s' if num_incompatible > 1 else ''}:"
)
for env_name, reason in incompatible.items():
self.display_warning(f"{env_name} -> {reason}")
def execute_context(self, context: ExecutionContext) -> None:
from hatch.utils.structures import EnvVars
with EnvVars(context.env_vars):
self.run_shell_commands(context)
def ensure_environment_plugin_dependencies(self) -> None:
self.ensure_plugin_dependencies(
self.project.config.env_requires_complex, wait_message="Syncing environment plugin requirements"
)
def ensure_plugin_dependencies(self, dependencies: list[Dependency], *, wait_message: str) -> None:
if not dependencies:
return
from hatch.dep.sync import InstalledDistributions
from hatch.env.utils import add_verbosity_flag
if app_path := os.environ.get("PYAPP"):
from hatch.utils.env import PythonInfo
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)
distributions = InstalledDistributions(sys_path=python_info.sys_path)
if distributions.dependencies_in_sync(dependencies):
return
pip_command = [app_path, management_command, "pip"]
else:
distributions = InstalledDistributions()
if distributions.dependencies_in_sync(dependencies):
return
pip_command = [sys.executable, "-u", "-m", "pip"]
pip_command.extend(["install", "--disable-pip-version-check"])
# Default to -1 verbosity
add_verbosity_flag(pip_command, self.verbosity, adjustment=-1)
pip_command.extend(str(dependency) for dependency in dependencies)
with self.status(wait_message):
self.platform.check_command(pip_command)
def get_env_directory(self, environment_type):
directories = self.config.dirs.env
if environment_type in directories:
path = Path(directories[environment_type]).expand()
if os.path.isabs(path):
return path
return self.project.location / path
return self.data_dir / "env" / environment_type
def get_python_manager(self, directory: str | None = None):
from hatch.python.core import PythonManager
configured_dir = directory or self.config.dirs.python
if configured_dir == "isolated":
return PythonManager(self.data_dir / "pythons")
return PythonManager(Path(configured_dir).expand())
@cached_property
def shell_data(self) -> tuple[str, str]:
from hatch.utils.shells import detect_shell
return detect_shell(self.platform)
def abort(self, text="", code=1, **kwargs):
if text:
self.display_error(text, **kwargs)
self.__exit_func(code)
================================================
FILE: src/hatch/cli/build/__init__.py
================================================
from __future__ import annotations
from typing import TYPE_CHECKING
import click
if TYPE_CHECKING:
from hatch.cli.application import Application
@click.command(short_help="Build a project")
@click.argument("location", required=False)
@click.option(
"--target",
"-t",
"targets",
multiple=True,
help=(
"The target to build, overriding project defaults. This may be selected multiple times e.g. `-t sdist -t wheel`"
),
)
@click.option(
"--hooks-only", is_flag=True, help="Whether or not to only execute build hooks [env var: `HATCH_BUILD_HOOKS_ONLY`]"
)
@click.option(
"--no-hooks", is_flag=True, help="Whether or not to disable build hooks [env var: `HATCH_BUILD_NO_HOOKS`]"
)
@click.option(
"--ext",
is_flag=True,
help=(
"Whether or not to only execute build hooks for distributing binary Python packages, such as "
"compiling extensions. Equivalent to `--hooks-only -t wheel`"
),
)
@click.option(
"--clean",
"-c",
is_flag=True,
help="Whether or not existing artifacts should first be removed [env var: `HATCH_BUILD_CLEAN`]",
)
@click.option(
"--clean-hooks-after",
is_flag=True,
help=(
"Whether or not build hook artifacts should be removed after each build "
"[env var: `HATCH_BUILD_CLEAN_HOOKS_AFTER`]"
),
)
@click.option("--clean-only", is_flag=True, hidden=True)
@click.pass_obj
def build(app: Application, location, targets, hooks_only, no_hooks, ext, clean, clean_hooks_after, clean_only):
"""Build a project."""
app.ensure_environment_plugin_dependencies()
from hatch.config.constants import AppEnvVars
from hatch.project.config import env_var_enabled
from hatch.project.constants import BUILD_BACKEND, DEFAULT_BUILD_DIRECTORY, BuildEnvVars
from hatch.utils.fs import Path
from hatch.utils.runner import ExecutionContext
from hatch.utils.structures import EnvVars
build_dir = Path(location).resolve() if location else None
if ext:
hooks_only = True
targets = ("wheel",)
elif not targets:
targets = ("sdist", "wheel")
env_vars = {}
if app.verbose:
env_vars[AppEnvVars.VERBOSE] = str(app.verbosity)
elif app.quiet:
env_vars[AppEnvVars.QUIET] = str(abs(app.verbosity))
with EnvVars(env_vars):
app.project.prepare_build_environment(targets=[target.split(":")[0] for target in targets])
build_backend = app.project.metadata.build.build_backend
with app.project.location.as_cwd(), app.project.build_env.get_env_vars():
for target in targets:
target_name, _, _ = target.partition(":")
if not clean_only:
app.display_header(target_name)
if build_backend != BUILD_BACKEND:
if target_name == "sdist":
directory = build_dir or app.project.location / DEFAULT_BUILD_DIRECTORY
directory.ensure_dir_exists()
artifact_path = app.project.build_frontend.build_sdist(directory)
elif target_name == "wheel":
directory = build_dir or app.project.location / DEFAULT_BUILD_DIRECTORY
directory.ensure_dir_exists()
artifact_path = app.project.build_frontend.build_wheel(directory)
else:
app.abort(f"Target `{target_name}` is not supported by `{build_backend}`")
app.display_info(
str(artifact_path.relative_to(app.project.location))
if app.project.location in artifact_path.parents
else str(artifact_path)
)
else:
command = ["python", "-u", "-m", "hatchling", "build", "--target", target]
# We deliberately pass the location unchanged so that absolute paths may be non-local
# and reflect wherever builds actually take place
if location:
command.extend(("--directory", location))
if hooks_only or env_var_enabled(BuildEnvVars.HOOKS_ONLY):
command.append("--hooks-only")
if no_hooks or env_var_enabled(BuildEnvVars.NO_HOOKS):
command.append("--no-hooks")
if clean or env_var_enabled(BuildEnvVars.CLEAN):
command.append("--clean")
if clean_hooks_after or env_var_enabled(BuildEnvVars.CLEAN_HOOKS_AFTER):
command.append("--clean-hooks-after")
if clean_only:
command.append("--clean-only")
context = ExecutionContext(app.project.build_env)
context.add_shell_command(command)
context.env_vars.update(env_vars)
app.execute_context(context)
================================================
FILE: src/hatch/cli/clean/__init__.py
================================================
import click
@click.command(short_help="Remove build artifacts")
@click.argument("location", required=False)
@click.option(
"--target",
"-t",
"targets",
multiple=True,
help=(
"The target with which to remove artifacts, overriding project defaults. "
"This may be selected multiple times e.g. `-t sdist -t wheel`"
),
)
@click.option(
"--hooks-only",
is_flag=True,
help="Whether or not to only remove artifacts from build hooks [env var: `HATCH_BUILD_HOOKS_ONLY`]",
)
@click.option(
"--no-hooks",
is_flag=True,
help="Whether or not to ignore artifacts from build hooks [env var: `HATCH_BUILD_NO_HOOKS`]",
)
@click.option(
"--ext",
is_flag=True,
help=(
"Whether or not to only remove artifacts from build hooks for distributing binary Python packages, such as "
"compiled extensions. Equivalent to `--hooks-only -t wheel`"
),
)
@click.pass_context
def clean(ctx, location, targets, hooks_only, no_hooks, ext):
"""Remove build artifacts."""
from hatch.cli.build import build
ctx.invoke(
build, clean_only=True, location=location, targets=targets, hooks_only=hooks_only, no_hooks=no_hooks, ext=ext
)
================================================
FILE: src/hatch/cli/config/__init__.py
================================================
import os
import click
@click.group(short_help="Manage the config file")
def config():
pass
@config.command(short_help="Open the config location in your file manager")
@click.pass_obj
def explore(app):
"""Open the config location in your file manager."""
click.launch(str(app.config_file.path), locate=True)
@config.command(short_help="Show the location of the config file")
@click.pass_obj
def find(app):
"""Show the location of the config file."""
app.display(str(app.config_file.path))
@config.command(short_help="Show the contents of the config file")
@click.option("--all", "-a", "all_keys", is_flag=True, help="Do not scrub secret fields")
@click.pass_obj
def show(app, all_keys):
"""Show the contents of the config file."""
if not app.config_file.path.is_file(): # no cov
app.display_critical("No config file found! Please try `hatch config restore`.")
else:
text = app.config_file.read() if all_keys else app.config_file.read_scrubbed()
app.display_syntax(text.rstrip(), "toml")
@config.command(short_help="Update the config file with any new fields")
@click.pass_obj
def update(app): # no cov
"""Update the config file with any new fields."""
app.config_file.update()
app.display_success("Settings were successfully updated.")
@config.command(short_help="Restore the config file to default settings")
@click.pass_obj
def restore(app):
"""Restore the config file to default settings."""
app.config_file.restore()
app.display_success("Settings were successfully restored.")
@config.command("set", short_help="Assign values to config file entries")
@click.argument("key")
@click.argument("value", required=False)
@click.pass_obj
def set_value(app, key, value):
"""
Assign values to config file entries. If the value is omitted,
you will be prompted, with the input hidden if it is sensitive.
"""
from fnmatch import fnmatch
import tomlkit
from hatch.config.model import ConfigurationError, RootConfig
from hatch.config.utils import create_toml_document, save_toml_document
scrubbing = key.startswith("publish.")
if value is None:
value = click.prompt(f"Value for `{key}`", hide_input=scrubbing)
setting_project_location = bool(fnmatch(key, "projects.*") or fnmatch(key, "projects.*.location"))
if setting_project_location and not value.startswith("~"):
value = os.path.abspath(value)
user_config = new_config = tomlkit.parse(app.config_file.read())
data = [value]
data.extend(reversed(key.split(".")))
key = data.pop()
value = data.pop()
# Use a separate mapping to show only what has changed in the end
branch_config_root = branch_config = {}
# Consider dots as keys
while data:
default_branch = {value: ""}
branch_config[key] = default_branch
branch_config = branch_config[key]
new_value = new_config.get(key)
if not hasattr(new_value, "get"):
new_value = default_branch
new_config[key] = new_value
new_config = new_config[key]
key = value
value = data.pop()
if value.startswith(("{", "[")):
from ast import literal_eval
value = literal_eval(value)
elif value.lower() == "true":
value = True
elif value.lower() == "false":
value = False
branch_config[key] = new_config[key] = value
# https://github.com/sdispater/tomlkit/issues/48
if new_config.__class__.__name__ == "Table": # no cov
table_body = getattr(new_config.value, "body", [])
possible_whitespace = table_body[-2:]
if len(possible_whitespace) == 2: # noqa: PLR2004
for key, item in possible_whitespace:
if key is not None:
break
if item.__class__.__name__ != "Whitespace":
break
else:
del table_body[-2]
try:
RootConfig(user_config).parse_fields()
except ConfigurationError as e:
app.display_error(str(e))
app.abort()
else:
if not user_config["project"] and setting_project_location:
project = next(iter(branch_config_root["projects"]))
user_config["project"] = project
branch_config_root["project"] = project
save_toml_document(user_config, app.config_file.path)
document = create_toml_document(branch_config_root)
if scrubbing and "publish" in document:
for data in document["publish"].values():
for field in list(data):
data[field] = "<...>"
from rich.syntax import Syntax
app.display_success("New setting:")
app.output(Syntax(tomlkit.dumps(document).rstrip(), "toml", background_color="default"))
================================================
FILE: src/hatch/cli/dep/__init__.py
================================================
import click
@click.group(short_help="Manage environment dependencies")
def dep():
pass
@dep.command("hash", short_help="Output a hash of the currently defined dependencies")
@click.option("--project-only", "-p", is_flag=True, help="Whether or not to exclude environment dependencies")
@click.option("--env-only", "-e", is_flag=True, help="Whether or not to exclude project dependencies")
@click.pass_obj
def hash_dependencies(app, project_only, env_only):
"""Output a hash of the currently defined dependencies."""
app.ensure_environment_plugin_dependencies()
from hatch.utils.dep import get_complex_dependencies, hash_dependencies
environment = app.project.get_environment()
all_requirements = []
if project_only:
dependencies, _ = app.project.get_dependencies()
dependencies_complex = get_complex_dependencies(dependencies)
all_requirements.extend(dependencies_complex.values())
elif env_only:
all_requirements.extend(environment.environment_dependencies_complex)
else:
dependencies, _ = app.project.get_dependencies()
dependencies_complex = get_complex_dependencies(dependencies)
all_requirements.extend(dependencies_complex.values())
all_requirements.extend(environment.environment_dependencies_complex)
app.display(hash_dependencies(all_requirements))
@dep.group(short_help="Display dependencies in various formats")
def show():
pass
@show.command(short_help="Enumerate dependencies in a tabular format")
@click.option("--project-only", "-p", is_flag=True, help="Whether or not to exclude environment dependencies")
@click.option("--env-only", "-e", is_flag=True, help="Whether or not to exclude project dependencies")
@click.option("--lines", "-l", "show_lines", is_flag=True, help="Whether or not to show lines between table rows")
@click.option("--ascii", "force_ascii", is_flag=True, help="Whether or not to only use ASCII characters")
@click.pass_obj
def table(app, project_only, env_only, show_lines, force_ascii):
"""Enumerate dependencies in a tabular format."""
app.ensure_environment_plugin_dependencies()
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()
project_requirements = []
environment_requirements = []
if project_only:
dependencies, _ = app.project.get_dependencies()
dependencies_complex = get_complex_dependencies(dependencies)
project_requirements.extend(dependencies_complex.values())
elif env_only:
environment_requirements.extend(environment.environment_dependencies_complex)
else:
dependencies, _ = app.project.get_dependencies()
dependencies_complex = get_complex_dependencies(dependencies)
project_requirements.extend(dependencies_complex.values())
environment_requirements.extend(environment.environment_dependencies_complex)
for all_requirements, table_title in (
(project_requirements, "Project"),
(environment_requirements, f"Env: {app.env}"),
):
if not all_requirements:
continue
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):
columns["Name"][i] = requirement.name
if requirement.url:
columns["URL"][i] = str(requirement.url)
if requirement.specifier:
columns["Versions"][i] = str(requirement.specifier)
if requirement.marker:
columns["Markers"][i] = normalize_marker_quoting(str(requirement.marker))
if requirement.extras:
columns["Features"][i] = ", ".join(sorted(requirement.extras))
column_options = {}
for column_title in columns:
if column_title != "URL":
column_options[column_title] = {"no_wrap": True}
app.display_table(
table_title, columns, show_lines=show_lines, column_options=column_options, force_ascii=force_ascii
)
@show.command(short_help="Enumerate dependencies as a list of requirements")
@click.option("--project-only", "-p", is_flag=True, help="Whether or not to exclude environment dependencies")
@click.option("--env-only", "-e", is_flag=True, help="Whether or not to exclude project dependencies")
@click.option(
"--feature",
"-f",
"features",
multiple=True,
help="Whether or not to only show the dependencies of the specified features",
)
@click.option("--all", "all_features", is_flag=True, help="Whether or not to include the dependencies of all features")
@click.pass_obj
def requirements(app, project_only, env_only, features, all_features):
"""Enumerate dependencies as a list of requirements."""
app.ensure_environment_plugin_dependencies()
from hatch.utils.dep import get_complex_dependencies, get_complex_features, get_normalized_dependencies
from hatchling.metadata.utils import normalize_project_name
environment = app.project.get_environment()
dependencies, optional_dependencies = app.project.get_dependencies()
dependencies_complex = get_complex_dependencies(dependencies)
optional_dependencies_complex = get_complex_features(optional_dependencies)
all_requirements = []
if features:
for raw_feature in features:
feature = normalize_project_name(raw_feature)
if feature not in optional_dependencies_complex:
app.abort(f"Feature `{feature}` is not defined in field `project.optional-dependencies`")
all_requirements.extend(optional_dependencies_complex[feature].values())
elif project_only:
all_requirements.extend(dependencies_complex.values())
elif env_only:
all_requirements.extend(environment.environment_dependencies_complex)
else:
all_requirements.extend(dependencies_complex.values())
all_requirements.extend(environment.environment_dependencies_complex)
if not features and all_features:
for optional_dependencies in optional_dependencies_complex.values():
all_requirements.extend(optional_dependencies.values())
for dependency in get_normalized_dependencies(all_requirements):
app.display(dependency)
================================================
FILE: src/hatch/cli/env/__init__.py
================================================
import click
from hatch.cli.env.create import create
from hatch.cli.env.find import find
from hatch.cli.env.prune import prune
from hatch.cli.env.remove import remove
from hatch.cli.env.run import run
from hatch.cli.env.show import show
@click.group(short_help="Manage project environments")
def env():
pass
env.add_command(create)
env.add_command(find)
env.add_command(prune)
env.add_command(remove)
env.add_command(run)
env.add_command(show)
================================================
FILE: src/hatch/cli/env/create.py
================================================
from __future__ import annotations
import os
from typing import TYPE_CHECKING
import click
from hatch.config.constants import AppEnvVars
if TYPE_CHECKING:
from hatch.cli.application import Application
@click.command(short_help="Create environments")
@click.argument("env_name", default="default")
@click.pass_obj
def create(app: Application, env_name: str):
"""Create environments."""
app.ensure_environment_plugin_dependencies()
environments = app.project.expand_environments(env_name)
if not environments:
app.abort(f"Environment `{env_name}` is not defined by project config")
incompatible = {}
for env in environments:
environment = app.project.get_environment(env)
if environment.exists():
app.display_warning(f"Environment `{env}` already exists")
continue
try:
environment.check_compatibility()
except Exception as e: # noqa: BLE001
if env_name in app.project.config.matrices:
incompatible[env] = str(e)
continue
app.abort(f"Environment `{env}` is incompatible: {e}")
app.project.prepare_environment(environment, keep_env=bool(os.environ.get(AppEnvVars.KEEP_ENV)))
if incompatible:
num_incompatible = len(incompatible)
app.display_warning(
f"Skipped {num_incompatible} incompatible environment{'s' if num_incompatible > 1 else ''}:"
)
for env, reason in incompatible.items():
app.display_warning(f"{env} -> {reason}")
================================================
FILE: src/hatch/cli/env/find.py
================================================
from __future__ import annotations
from typing import TYPE_CHECKING
import click
if TYPE_CHECKING:
from hatch.cli.application import Application
@click.command(short_help="Locate environments")
@click.argument("env_name", default="default")
@click.pass_obj
def find(app: Application, env_name: str):
"""Locate environments."""
app.ensure_environment_plugin_dependencies()
environments = app.project.expand_environments(env_name)
if not environments:
app.abort(f"Environment `{env_name}` is not defined by project config")
for env in environments:
environment = app.project.get_environment(env)
app.display(environment.find())
================================================
FILE: src/hatch/cli/env/prune.py
================================================
from __future__ import annotations
from typing import TYPE_CHECKING
import click
if TYPE_CHECKING:
from hatch.cli.application import Application
@click.command(short_help="Remove all environments")
@click.pass_obj
def prune(app: Application):
"""Remove all environments."""
app.ensure_environment_plugin_dependencies()
environment_types = app.plugins.environment.collect()
for environments in (app.project.config.envs, app.project.config.internal_envs):
for env_name in environments:
if env_name == app.env_active:
app.abort(f"Cannot remove active environment: {env_name}")
for env_name, config in environments.items():
environment_type = config["type"]
if environment_type not in environment_types:
app.abort(f"Environment `{env_name}` has unknown type: {environment_type}")
environment = environment_types[environment_type](
app.project.location,
app.project.metadata,
env_name,
config,
app.project.config.matrix_variables.get(env_name, {}),
app.get_env_directory(environment_type),
app.data_dir / "env" / environment_type,
app.platform,
app.verbosity,
app,
)
if environment.exists():
with app.status(f"Removing environment: {env_name}"):
environment.remove()
================================================
FILE: src/hatch/cli/env/remove.py
================================================
from __future__ import annotations
from typing import TYPE_CHECKING
import click
if TYPE_CHECKING:
from hatch.cli.application import Application
@click.command(short_help="Remove environments")
@click.argument("env_name", default="default")
@click.pass_context
def remove(ctx: click.Context, env_name: str):
"""Remove environments."""
app: Application = ctx.obj
app.ensure_environment_plugin_dependencies()
if (parameter_source := ctx.get_parameter_source("env_name")) is not None and parameter_source.name == "DEFAULT":
env_name = app.env
environments = app.project.expand_environments(env_name)
if not environments:
app.abort(f"Environment `{env_name}` is not defined by project config")
for env_name in environments:
if env_name == app.env_active:
app.abort(f"Cannot remove active environment: {env_name}")
for env_name in environments:
environment = app.project.get_environment(env_name)
if environment.exists():
with app.status(f"Removing environment: {env_name}"):
environment.remove()
================================================
FILE: src/hatch/cli/env/run.py
================================================
from __future__ import annotations
import os
from typing import TYPE_CHECKING
import click
from hatch.config.constants import AppEnvVars
if TYPE_CHECKING:
from hatch.cli.application import Application
def filter_environments(environments, filter_data):
selected_environments = []
for env_name, env_data in environments.items():
for key, value in filter_data.items():
if key not in env_data or env_data[key] != value:
break
else:
selected_environments.append(env_name)
return selected_environments
@click.command(short_help="Run commands within project environments")
@click.argument("args", required=True, nargs=-1)
@click.option("--env", "-e", "env_names", multiple=True, help="The environments to target")
@click.option("--include", "-i", "included_variable_specs", multiple=True, help="The matrix variables to include")
@click.option("--exclude", "-x", "excluded_variable_specs", multiple=True, help="The matrix variables to exclude")
@click.option("--filter", "-f", "filter_json", default=None, help="The JSON data used to select environments")
@click.option(
"--force-continue", is_flag=True, help="Run every command and if there were any errors exit with the first code"
)
@click.option("--ignore-compat", is_flag=True, help="Ignore incompatibility when selecting specific environments")
@click.pass_obj
def run(
app: Application,
*,
args: tuple[str, ...],
env_names: tuple[str, ...],
included_variable_specs: tuple[str, ...],
excluded_variable_specs: tuple[str, ...],
filter_json: str | None,
force_continue: bool,
ignore_compat: bool,
):
"""
Run commands within project environments.
The `-e`/`--env` option overrides the equivalent [root option](#hatch) and the `HATCH_ENV` environment variable.
The `-i`/`--include` and `-x`/`--exclude` options may be used to include or exclude certain
variables, optionally followed by specific comma-separated values, and may be selected multiple
times. For example, if you have the following configuration:
\b
```toml config-example
[[tool.hatch.envs.test.matrix]]
python = ["3.9", "3.10"]
version = ["42", "3.14", "9000"]
```
then running:
\b
```
hatch env run -i py=3.10 -x version=9000 test:pytest
```
would execute `pytest` in the environments `test.py3.10-42` and `test.py3.10-3.14`.
Note that `py` may be used as an alias for `python`.
\b
!!! note
The inclusion option is treated as an intersection while the exclusion option is treated as a
union i.e. an environment must match all of the included variables to be selected while matching
any of the excluded variables will prevent selection.
"""
from hatch.utils.runner import parse_matrix_variables, select_environments
try:
included_variables = parse_matrix_variables(included_variable_specs)
except ValueError as e:
app.abort(f"Duplicate included variable: {e}")
try:
excluded_variables = parse_matrix_variables(excluded_variable_specs)
except ValueError as e:
app.abort(f"Duplicate excluded variable: {e}")
app.ensure_environment_plugin_dependencies()
project = app.project
if not env_names:
env_names = (app.env,)
elif "system" in env_names:
project.config.config["envs"] = {
"system": {
"type": "system",
"skip-install": True,
"scripts": project.config.scripts,
}
}
# Deduplicate
ordered_env_names = list(dict.fromkeys(env_names))
environments = []
matrix_selected = False
for env_name in ordered_env_names:
if env_name in project.config.matrices:
matrix_selected = True
env_data = project.config.matrices[env_name]["envs"]
if not env_data:
app.abort(f"No variables defined for matrix: {env_name}")
environments.extend(select_environments(env_data, included_variables, excluded_variables))
else:
environments.append(env_name)
if filter_json:
import json
filter_data = json.loads(filter_json)
if not isinstance(filter_data, dict):
app.abort("The --filter/-f option must be a JSON mapping")
environments[:] = filter_environments(project.config.envs, filter_data)
if not environments:
app.abort("No environments were selected")
elif not matrix_selected and (included_variables or excluded_variables):
app.abort(f"Variable selection is unsupported for non-matrix environments: {', '.join(ordered_env_names)}")
for context in app.runner_context(
environments,
ignore_compat=ignore_compat or matrix_selected,
display_header=matrix_selected,
keep_env=bool(os.environ.get(AppEnvVars.KEEP_ENV)),
):
if context.env.name == "system":
context.env.exists = lambda: True # type: ignore[method-assign]
context.force_continue = force_continue
context.add_shell_command(list(args))
================================================
FILE: src/hatch/cli/env/show.py
================================================
from __future__ import annotations
from typing import TYPE_CHECKING
import click
if TYPE_CHECKING:
from hatch.cli.application import Application
@click.command(short_help="Show the available environments")
@click.argument("envs", required=False, nargs=-1)
@click.option("--ascii", "force_ascii", is_flag=True, help="Whether or not to only use ASCII characters")
@click.option("--json", "as_json", is_flag=True, help="Whether or not to output in JSON format")
@click.option("--internal", "-i", is_flag=True, help="Show internal environments")
@click.option("--hide-titles", is_flag=True, hidden=True)
@click.pass_obj
def show(
app: Application,
*,
envs: tuple[str, ...],
force_ascii: bool,
as_json: bool,
internal: bool,
hide_titles: bool,
):
"""Show the available environments."""
app.ensure_environment_plugin_dependencies()
from hatch.config.constants import AppEnvVars
if as_json:
import json
contextual_config = {}
for environments in (app.project.config.envs, app.project.config.internal_envs):
for env_name, config in environments.items():
environment = app.project.get_environment(env_name)
new_config = contextual_config[env_name] = dict(config)
env_vars = dict(environment.env_vars)
env_vars.pop(AppEnvVars.ENV_ACTIVE)
if env_vars:
new_config["env-vars"] = env_vars
num_dependencies = len(config.get("dependencies", []))
dependencies = environment.environment_dependencies[:num_dependencies]
if dependencies:
new_config["dependencies"] = dependencies
extra_dependencies = environment.environment_dependencies[num_dependencies:]
if extra_dependencies:
new_config["extra-dependencies"] = extra_dependencies
if environment.pre_install_commands:
new_config["pre-install-commands"] = list(
environment.resolve_commands(environment.pre_install_commands)
)
if environment.post_install_commands:
new_config["post-install-commands"] = list(
environment.resolve_commands(environment.post_install_commands)
)
if environment.scripts:
new_config["scripts"] = {
script: list(environment.resolve_commands([script])) for script in environment.scripts
}
app.display(json.dumps(contextual_config, separators=(",", ":")))
return
from hatch.dep.core import Dependency, InvalidDependencyError
from hatchling.metadata.utils import get_normalized_dependency, normalize_project_name
if internal:
target_standalone_envs = app.project.config.internal_envs
target_matrices = app.project.config.internal_matrices
else:
target_standalone_envs = app.project.config.envs
target_matrices = app.project.config.matrices
for env_name in envs:
if env_name not in target_standalone_envs and env_name not in target_matrices:
app.abort(f"Environment `{env_name}` is not defined by project config")
env_names = set(envs)
matrix_columns: dict[str, dict[int, str]] = {
"Name": {},
"Type": {},
"Envs": {},
"Features": {},
"Dependencies": {},
"Environment variables": {},
"Scripts": {},
"Description": {},
}
matrix_envs = set()
for i, (matrix_name, matrix_data) in enumerate(target_matrices.items()):
matrix_envs.update(matrix_data["envs"])
if env_names and matrix_name not in env_names:
continue
config = matrix_data["config"]
matrix_columns["Name"][i] = matrix_name
matrix_columns["Type"][i] = config["type"]
matrix_columns["Envs"][i] = "\n".join(matrix_data["envs"])
if config.get("features"):
if app.project.metadata.hatch.metadata.allow_ambiguous_features:
matrix_columns["Features"][i] = "\n".join(sorted(set(config["features"])))
else:
matrix_columns["Features"][i] = "\n".join(
sorted({normalize_project_name(f) for f in config["features"]})
)
dependencies = []
if config.get("dependencies"):
dependencies.extend(config["dependencies"])
if config.get("extra-dependencies"):
dependencies.extend(config["extra-dependencies"])
if dependencies:
normalized_dependencies = set()
for dependency in dependencies:
try:
req = Dependency(dependency)
except InvalidDependencyError:
normalized_dependencies.add(dependency)
else:
normalized_dependencies.add(get_normalized_dependency(req))
matrix_columns["Dependencies"][i] = "\n".join(sorted(normalized_dependencies))
if config.get("env-vars"):
matrix_columns["Environment variables"][i] = "\n".join(
"=".join(item) for item in sorted(config["env-vars"].items())
)
if config.get("scripts"):
matrix_columns["Scripts"][i] = "\n".join(
sorted(script for script in config["scripts"] if app.verbose or not script.startswith("_"))
)
if config.get("description"):
matrix_columns["Description"][i] = config["description"].strip()
standalone_columns: dict[str, dict[int, str]] = {
"Name": {},
"Type": {},
"Features": {},
"Dependencies": {},
"Environment variables": {},
"Scripts": {},
"Description": {},
}
standalone_envs = (
(env_name, config)
for env_name, config in target_standalone_envs.items()
if env_names or env_name not in matrix_envs
)
for i, (env_name, config) in enumerate(standalone_envs):
if env_names and env_name not in env_names:
continue
environment = app.project.get_environment(env_name)
standalone_columns["Name"][i] = env_name
standalone_columns["Type"][i] = config["type"]
if environment.features:
standalone_columns["Features"][i] = "\n".join(environment.features)
if environment.environment_dependencies_complex:
standalone_columns["Dependencies"][i] = "\n".join(
sorted({get_normalized_dependency(d) for d in environment.environment_dependencies_complex})
)
env_vars = dict(environment.env_vars)
env_vars.pop(AppEnvVars.ENV_ACTIVE)
if env_vars:
standalone_columns["Environment variables"][i] = "\n".join(
"=".join(item) for item in sorted(env_vars.items())
)
if environment.scripts:
standalone_columns["Scripts"][i] = "\n".join(
sorted(script for script in environment.scripts if app.verbose or not script.startswith("_"))
)
if environment.description:
standalone_columns["Description"][i] = environment.description.strip()
column_options = {}
for title in matrix_columns:
if title != "Description":
column_options[title] = {"no_wrap": True}
app.display_table(
"" if hide_titles else "Standalone",
standalone_columns,
show_lines=True,
column_options=column_options,
force_ascii=force_ascii,
)
app.display_table(
"" if hide_titles else "Matrices",
matrix_columns,
show_lines=True,
column_options=column_options,
force_ascii=force_ascii,
)
================================================
FILE: src/hatch/cli/fmt/__init__.py
================================================
from __future__ import annotations
from typing import TYPE_CHECKING
import click
if TYPE_CHECKING:
from hatch.cli.application import Application
@click.command(short_help="Format and lint source code", context_settings={"ignore_unknown_options": True})
@click.argument("args", nargs=-1)
@click.option("--check", is_flag=True, help="Only check for errors rather than fixing them")
@click.option("--linter", "-l", is_flag=True, help="Only run the linter")
@click.option("--formatter", "-f", is_flag=True, help="Only run the formatter")
@click.option("--sync", is_flag=True, help="Sync the default config file with the current version of Hatch")
@click.pass_obj
def fmt(
app: Application,
*,
args: tuple[str, ...],
check: bool,
linter: bool,
formatter: bool,
sync: bool,
):
"""Format and lint source code."""
if linter and formatter:
app.abort("Cannot specify both --linter and --formatter")
from hatch.cli.fmt.core import StaticAnalysisEnvironment
app.ensure_environment_plugin_dependencies()
for context in app.runner_context(["hatch-static-analysis"]):
sa_env = StaticAnalysisEnvironment(context.env)
# TODO: remove in a few minor releases, this is very new but we don't want to break users on the cutting edge
if legacy_config_path := app.project.config.config.get("format", {}).get("config-path", ""):
app.display_warning(
"The `tool.hatch.format.config-path` option is deprecated and will be removed in a future release. "
"Use `tool.hatch.envs.hatch-static-analysis.config-path` instead."
)
sa_env.config_path = legacy_config_path
if sync and not sa_env.config_path:
app.abort("The --sync flag can only be used when the `tool.hatch.format.config-path` option is defined")
scripts: list[str] = []
if not formatter:
scripts.append("lint-check" if check else "lint-fix")
if not linter:
scripts.append("format-check" if check else "format-fix")
default_args = sa_env.get_default_args()
arguments = list(args)
try:
arguments.remove("--preview")
except ValueError:
preview = False
else:
preview = True
default_args.append("--preview")
internal_args = context.env.join_command_args(default_args)
if internal_args:
# Add an extra space if required
internal_args = f" {internal_args}"
formatted_args = context.env.join_command_args(arguments)
for script in scripts:
context.add_shell_command(f"{script} {formatted_args}")
context.env_vars["HATCH_FMT_ARGS"] = internal_args
if not sa_env.config_path or sync:
sa_env.write_config_file(preview=preview)
================================================
FILE: src/hatch/cli/fmt/core.py
================================================
from __future__ import annotations
from functools import cached_property
from typing import TYPE_CHECKING, Any
if TYPE_CHECKING:
from hatch.env.plugin.interface import EnvironmentInterface
from hatch.utils.fs import Path
class StaticAnalysisEnvironment:
def __init__(self, env: EnvironmentInterface) -> None:
self.env = env
@cached_property
def config_path(self) -> str:
return self.env.config.get("config-path", "")
def get_default_args(self) -> list[str]:
default_args = []
if not self.config_path:
if self.internal_user_config_file is None:
default_args.extend(["--config", str(self.internal_config_file)])
else:
default_args.extend(["--config", str(self.internal_user_config_file)])
return default_args
@cached_property
def internal_config_file(self) -> Path:
return self.env.isolated_data_directory / ".config" / self.env.root.id / "ruff_defaults.toml"
def construct_config_file(self, *, preview: bool) -> str:
lines = [
"line-length = 120",
"",
"[format]",
"docstring-code-format = true",
"docstring-code-line-length = 80",
"",
"[lint]",
]
# Selected rules
rules = list(STABLE_RULES)
if preview or self.linter_preview:
rules.extend(PREVIEW_RULES)
rules.sort()
lines.append("select = [")
lines.extend(f' "{rule}",' for rule in rules)
lines.extend(("]", ""))
# Ignored rules
lines.append("[lint.per-file-ignores]")
for glob, ignored_rules in PER_FILE_IGNORED_RULES.items():
lines.append(f'"{glob}" = [')
lines.extend(f' "{ignored_rule}",' for ignored_rule in ignored_rules)
lines.append("]")
# Default config
lines.extend((
"",
"[lint.flake8-tidy-imports]",
'ban-relative-imports = "all"',
"",
"[lint.isort]",
f'known-first-party = ["{self.env.metadata.name.replace("-", "_")}"]',
"",
"[lint.flake8-pytest-style]",
"fixture-parentheses = false",
"mark-parentheses = false",
))
# Ensure the file ends with a newline to satisfy other linters
lines.append("")
return "\n".join(lines)
def write_config_file(self, *, preview: bool) -> None:
config_contents = self.construct_config_file(preview=preview)
if self.config_path:
(self.env.root / self.config_path).write_atomic(config_contents, "w", encoding="utf-8")
return
self.internal_config_file.parent.ensure_dir_exists()
self.internal_config_file.write_text(config_contents)
# TODO: remove everything below once this is fixed https://github.com/astral-sh/ruff/issues/8737
if self.internal_user_config_file is None:
return
if self.user_config_file is None:
return
old_contents = self.user_config_file.read_text()
config_path = str(self.internal_config_file).replace("\\", "\\\\")
if self.user_config_file.name == "pyproject.toml":
lines = old_contents.splitlines()
try:
index = lines.index("[tool.ruff]")
except ValueError:
lines.extend((
"",
"[tool.ruff]",
f'extend = "{config_path}"',
))
else:
lines.insert(index + 1, f'extend = "{config_path}"')
contents = "\n".join(lines)
else:
contents = f'extend = "{config_path}"\n{old_contents}'
self.internal_user_config_file.write_text(contents)
@cached_property
def internal_user_config_file(self) -> Path | None:
if self.user_config_file is None:
return None
return self.internal_config_file.parent / self.user_config_file.name
@cached_property
def user_config_file(self) -> Path | None:
# https://docs.astral.sh/ruff/configuration/#config-file-discovery
for possible_config in (".ruff.toml", "ruff.toml", "pyproject.toml"):
if (config_file := (self.env.root / possible_config)).is_file():
return config_file
return None
@cached_property
def user_config(self) -> dict[str, Any]:
if self.user_config_file is None:
return {}
from hatch.utils.toml import load_toml_data
return load_toml_data(self.user_config_file.read_text())
@cached_property
def linter_preview(self) -> bool:
return self.get_config("lint").get("preview", False)
@cached_property
def formatter_preview(self) -> bool:
return self.get_config("format").get("preview", False)
def get_config(self, section: str) -> dict[str, Any]:
if self.user_config_file is None:
return {}
if self.user_config_file.name == "pyproject.toml":
return self.user_config.get("tool", {}).get("ruff", {}).get(section, {})
return self.user_config.get(section, {})
STABLE_RULES: tuple[str, ...] = (
"A001",
"A002",
"A003",
"A004",
"A005",
"A006",
"ARG001",
"ARG002",
"ARG003",
"ARG004",
"ARG005",
"ASYNC100",
"ASYNC105",
"ASYNC109",
"ASYNC110",
"ASYNC115",
"ASYNC116",
"ASYNC210",
"ASYNC220",
"ASYNC221",
"ASYNC222",
"ASYNC230",
"ASYNC251",
"B002",
"B003",
"B004",
"B005",
"B006",
"B007",
"B008",
"B009",
"B010",
"B011",
"B012",
"B013",
"B014",
"B015",
"B016",
"B017",
"B018",
"B019",
"B020",
"B021",
"B022",
"B023",
"B024",
"B025",
"B026",
"B028",
"B029",
"B030",
"B031",
"B032",
"B033",
"B034",
"B035",
"B039",
"B904",
"B905",
"B911",
"BLE001",
"C400",
"C401",
"C402",
"C403",
"C404",
"C405",
"C406",
"C408",
"C409",
"C410",
"C411",
"C413",
"C414",
"C415",
"C416",
"C417",
"C418",
"C419",
"C420",
"COM818",
"DTZ001",
"DTZ002",
"DTZ003",
"DTZ004",
"DTZ005",
"DTZ006",
"DTZ007",
"DTZ011",
"DTZ012",
"DTZ901",
"E101",
"E401",
"E402",
"E701",
"E702",
"E703",
"E711",
"E712",
"E713",
"E714",
"E721",
"E722",
"E731",
"E741",
"E742",
"E743",
"E902",
"EM101",
"EM102",
"EM103",
"EXE001",
"EXE002",
"EXE003",
"EXE004",
"EXE005",
"F401",
"F402",
"F403",
"F404",
"F405",
"F406",
"F407",
"F501",
"F502",
"F503",
"F504",
"F505",
"F506",
"F507",
"F508",
"F509",
"F521",
"F522",
"F523",
"F524",
"F525",
"F541",
"F601",
"F602",
"F621",
"F622",
"F631",
"F632",
"F633",
"F634",
"F701",
"F702",
"F704",
"F706",
"F707",
"F722",
"F811",
"F821",
"F822",
"F823",
"F841",
"F842",
"F901",
"FA100",
"FA102",
"FAST001",
"FAST002",
"FAST003",
"FBT001",
"FBT002",
"FLY002",
"FURB105",
"FURB116",
"FURB122",
"FURB129",
"FURB132",
"FURB136",
"FURB157",
"FURB161",
"FURB162",
"FURB163",
"FURB166",
"FURB167",
"FURB168",
"FURB169",
"FURB177",
"FURB181",
"FURB187",
"FURB188",
"G001",
"G002",
"G003",
"G004",
"G010",
"G101",
"G201",
"G202",
"I001",
"I002",
"ICN001",
"ICN002",
"ICN003",
"INP001",
"INT001",
"INT002",
"INT003",
"ISC003",
"LOG001",
"LOG002",
"LOG007",
"LOG009",
"LOG014",
"LOG015",
"N801",
"N802",
"N803",
"N804",
"N805",
"N806",
"N807",
"N811",
"N812",
"N813",
"N814",
"N815",
"N816",
"N817",
"N818",
"N999",
"PERF101",
"PERF102",
"PERF401",
"PERF402",
"PERF403",
"PGH005",
"PIE790",
"PIE794",
"PIE796",
"PIE800",
"PIE804",
"PIE807",
"PIE808",
"PIE810",
"PLC0105",
"PLC0131",
"PLC0132",
"PLC0205",
"PLC0206",
"PLC0208",
"PLC0414",
"PLC0415",
"PLC1802",
"PLC2401",
"PLC2403",
"PLC3002",
"PLE0100",
"PLE0101",
"PLE0115",
"PLE0116",
"PLE0117",
"PLE0118",
"PLE0237",
"PLE0241",
"PLE0302",
"PLE0303",
"PLE0305",
"PLE0307",
"PLE0308",
"PLE0309",
"PLE0604",
"PLE0605",
"PLE0643",
"PLE0704",
"PLE1132",
"PLE1142",
"PLE1205",
"PLE1206",
"PLE1300",
"PLE1307",
"PLE1310",
"PLE1507",
"PLE1519",
"PLE1520",
"PLE1700",
"PLE2502",
"PLE2510",
"PLE2512",
"PLE2513",
"PLE2514",
"PLE2515",
"PLR0124",
"PLR0133",
"PLR0206",
"PLR0402",
"PLR1704",
"PLR1711",
"PLR1714",
"PLR1716",
"PLR1722",
"PLR1730",
"PLR1733",
"PLR1736",
"PLR2004",
"PLR2044",
"PLR5501",
"PLW0120",
"PLW0127",
"PLW0128",
"PLW0129",
"PLW0131",
"PLW0133",
"PLW0177",
"PLW0211",
"PLW0245",
"PLW0406",
"PLW0602",
"PLW0603",
"PLW0604",
"PLW0642",
"PLW0711",
"PLW1501",
"PLW1507",
"PLW1508",
"PLW1509",
"PLW1510",
"PLW1641",
"PLW2101",
"PLW2901",
"PLW3301",
"PT001",
"PT002",
"PT003",
"PT006",
"PT007",
"PT008",
"PT009",
"PT010",
"PT011",
"PT012",
"PT013",
"PT014",
"PT015",
"PT016",
"PT017",
"PT018",
"PT019",
"PT020",
"PT021",
"PT022",
"PT023",
"PT024",
"PT025",
"PT026",
"PT027",
"PT028",
"PT030",
"PT031",
"PYI001",
"PYI002",
"PYI003",
"PYI004",
"PYI005",
"PYI006",
"PYI007",
"PYI008",
"PYI009",
"PYI010",
"PYI011",
"PYI012",
"PYI013",
"PYI014",
"PYI015",
"PYI016",
"PYI017",
"PYI018",
"PYI019",
"PYI020",
"PYI021",
"PYI024",
"PYI025",
"PYI026",
"PYI029",
"PYI030",
"PYI032",
"PYI033",
"PYI034",
"PYI035",
"PYI036",
"PYI041",
"PYI042",
"PYI043",
"PYI044",
"PYI045",
"PYI046",
"PYI047",
"PYI048",
"PYI049",
"PYI050",
"PYI051",
"PYI052",
"PYI053",
"PYI054",
"PYI055",
"PYI056",
"PYI057",
"PYI058",
"PYI059",
"PYI061",
"PYI062",
"PYI063",
"PYI064",
"PYI066",
"RET503",
"RET504",
"RET505",
"RET506",
"RET507",
"RET508",
"RSE102",
"RUF001",
"RUF002",
"RUF003",
"RUF005",
"RUF006",
"RUF007",
"RUF008",
"RUF009",
"RUF010",
"RUF012",
"RUF013",
"RUF015",
"RUF016",
"RUF017",
"RUF018",
"RUF019",
"RUF020",
"RUF021",
"RUF022",
"RUF023",
"RUF024",
"RUF026",
"RUF028",
"RUF030",
"RUF032",
"RUF033",
"RUF034",
"RUF040",
"RUF041",
"RUF043",
"RUF046",
"RUF048",
"RUF049",
"RUF051",
"RUF053",
"RUF057",
"RUF058",
"RUF059",
"RUF100",
"RUF101",
"S101",
"S102",
"S103",
"S104",
"S105",
"S106",
"S107",
"S108",
"S110",
"S112",
"S113",
"S201",
"S202",
"S301",
"S302",
"S303",
"S304",
"S305",
"S306",
"S307",
"S308",
"S310",
"S311",
"S312",
"S313",
"S314",
"S315",
"S316",
"S317",
"S318",
"S319",
"S321",
"S323",
"S324",
"S501",
"S502",
"S503",
"S504",
"S505",
"S506",
"S507",
"S508",
"S509",
"S601",
"S602",
"S604",
"S605",
"S606",
"S607",
"S608",
"S609",
"S610",
"S611",
"S612",
"S701",
"S702",
"S704",
"SIM101",
"SIM102",
"SIM103",
"SIM105",
"SIM107",
"SIM108",
"SIM109",
"SIM110",
"SIM112",
"SIM113",
"SIM114",
"SIM115",
"SIM116",
"SIM117",
"SIM118",
"SIM201",
"SIM202",
"SIM208",
"SIM210",
"SIM211",
"SIM212",
"SIM220",
"SIM221",
"SIM222",
"SIM223",
"SIM300",
"SIM905",
"SIM910",
"SIM911",
"SLF001",
"SLOT000",
"SLOT001",
"SLOT002",
"T100",
"T201",
"T203",
"TC001",
"TC002",
"TC003",
"TC004",
"TC005",
"TC006",
"TC007",
"TC010",
"TD004",
"TD005",
"TD006",
"TD007",
"TID251",
"TID252",
"TID253",
"TRY002",
"TRY003",
"TRY004",
"TRY201",
"TRY203",
"TRY300",
"TRY301",
"TRY400",
"TRY401",
"UP001",
"UP003",
"UP004",
"UP005",
"UP006",
"UP007",
"UP008",
"UP009",
"UP010",
"UP011",
"UP012",
"UP013",
"UP014",
"UP015",
"UP017",
"UP018",
"UP019",
"UP020",
"UP021",
"UP022",
"UP023",
"UP024",
"UP025",
"UP026",
"UP028",
"UP029",
"UP030",
"UP031",
"UP032",
"UP033",
"UP034",
"UP035",
"UP036",
"UP037",
"UP039",
"UP040",
"UP041",
"UP043",
"UP044",
"UP045",
"UP046",
"UP047",
"UP049",
"UP050",
"W291",
"W292",
"W293",
"W505",
"W605",
"YTT101",
"YTT102",
"YTT103",
"YTT201",
"YTT202",
"YTT203",
"YTT204",
"YTT301",
"YTT302",
"YTT303",
)
PREVIEW_RULES: tuple[str, ...] = (
"ASYNC212",
"ASYNC240",
"ASYNC250",
"B901",
"B903",
"B909",
"B912",
"DOC201",
"DOC202",
"DOC402",
"DOC403",
"DOC501",
"DOC502",
"E112",
"E113",
"E115",
"E116",
"E201",
"E202",
"E203",
"E204",
"E211",
"E221",
"E222",
"E223",
"E224",
"E225",
"E226",
"E227",
"E228",
"E231",
"E241",
"E242",
"E251",
"E252",
"E261",
"E262",
"E265",
"E266",
"E271",
"E272",
"E273",
"E274",
"E275",
"E502",
"FURB110",
"FURB113",
"FURB118",
"FURB131",
"FURB142",
"FURB145",
"FURB148",
"FURB152",
"FURB154",
"FURB156",
"FURB164",
"FURB171",
"FURB180",
"FURB189",
"FURB192",
"LOG004",
"PLC0207",
"PLC1901",
"PLC2701",
"PLC2801",
"PLE0304",
"PLE1141",
"PLE4703",
"PLR0202",
"PLR0203",
"PLR6104",
"PLR6201",
"PLR6301",
"PLW0108",
"PLW0244",
"PLW1514",
"PLW3201",
"PT029",
"RUF027",
"RUF029",
"RUF031",
"RUF036",
"RUF037",
"RUF038",
"RUF039",
"RUF045",
"RUF047",
"RUF052",
"RUF054",
"RUF055",
"RUF056",
"RUF060",
"RUF061",
"RUF063",
"RUF064",
"RUF065",
"RUF102",
"S401",
"S402",
"S403",
"S405",
"S406",
"S407",
"S408",
"S409",
"S411",
"S412",
"S413",
"S415",
"TC008",
"UP042",
"W391",
)
PER_FILE_IGNORED_RULES: dict[str, list[str]] = {
"**/scripts/*": [
"INP001",
"T201",
],
"**/tests/**/*": [
"PLC1901",
"PLR2004",
"PLR6301",
"S",
"TID252",
],
}
================================================
FILE: src/hatch/cli/new/__init__.py
================================================
import click
@click.command(short_help="Create or initialize a project")
@click.argument("name", required=False)
@click.argument("location", required=False)
@click.option("--interactive", "-i", "interactive", is_flag=True, help="Interactively choose details about the project")
@click.option("--cli", "feature_cli", is_flag=True, help="Give the project a command line interface")
@click.option("--init", "initialize", is_flag=True, help="Initialize an existing project")
@click.option("-so", "setuptools_options", multiple=True, hidden=True)
@click.pass_obj
def new(app, name, location, interactive, feature_cli, initialize, setuptools_options):
"""Create or initialize a project."""
import sys
from copy import deepcopy
from datetime import datetime, timezone
from hatch.config.model import TemplateConfig
from hatch.project.core import Project
from hatch.template import File
from hatch.utils.fs import Path
if location:
location = Path(location).resolve()
migration_possible = False
if initialize:
interactive = True
location = location or Path.cwd()
if (location / "setup.py").is_file() or (location / "setup.cfg").is_file():
migration_possible = True
if not name:
name = "temporary"
if not name:
if not interactive:
app.abort("Missing required argument for the project name, use the -i/--interactive flag.")
name = app.prompt("Project name")
normalized_name = Project.canonicalize_name(name, strict=False)
if not location:
location = Path(normalized_name).resolve()
needs_config_update = False
if location.is_file():
app.abort(f"Path `{location}` points to a file.")
elif location.is_dir() and any(location.iterdir()):
if not initialize:
app.abort(f"Directory `{location}` is not empty.")
needs_config_update = (location / "pyproject.toml").is_file()
if migration_possible:
from hatch.cli.new.migrate import migrate
from hatch.venv.core import TempVirtualEnv
try:
with (
app.status("Migrating project metadata from setuptools"),
TempVirtualEnv(sys.executable, app.platform) as venv,
):
app.platform.run_command(["python", "-m", "pip", "install", "-q", "setuptools"])
migrate(str(location), setuptools_options, venv.sys_path)
except Exception as e: # noqa: BLE001
app.display_error(f"Could not automatically migrate from setuptools: {e}")
if name == "temporary":
name = app.prompt("Project name")
else:
return
default_config = {
"description": "",
"dependencies": set(),
"package_name": normalized_name.replace("-", "_"),
"project_name": name,
"project_name_normalized": normalized_name,
"args": {"cli": feature_cli},
}
if interactive:
default_config["description"] = app.prompt("Description", default="")
app.display_info()
if needs_config_update:
app.project.initialize(str(location / "pyproject.toml"), default_config)
app.display_success("Updated: pyproject.toml")
return
template_config = deepcopy(app.config.template.raw_data)
if "plugins" in template_config and not template_config["plugins"]:
del template_config["plugins"]
TemplateConfig(template_config, ("template",)).parse_fields()
plugin_config = template_config.pop("plugins")
# Set up default config for template files
template_config.update(default_config)
template_classes = app.plugins.template.collect()
templates = []
for template_name, template_class in sorted(template_classes.items(), key=lambda item: -item[1].PRIORITY):
if template_name in plugin_config:
templates.append(
template_class(plugin_config.pop(template_name), app.cache_dir, datetime.now(timezone.utc))
)
if not templates:
app.abort(f"None of the defined plugins were found: {', '.join(sorted(plugin_config))}")
elif plugin_config:
app.abort(f"Some of the defined plugins were not found: {', '.join(sorted(plugin_config))}")
for template in templates:
template.initialize_config(template_config)
template_files = []
for template in templates:
for possible_template_file in template.get_files(config=deepcopy(template_config)):
template_file = (
possible_template_file(deepcopy(template_config), template.plugin_config)
if possible_template_file.__class__ is not File
else possible_template_file
)
if template_file.path is None or (initialize and str(template_file.path) != "pyproject.toml"):
continue
template_files.append(template_file)
for template in templates:
template.finalize_files(config=deepcopy(template_config), files=template_files)
for template_file in template_files:
template_file.write(location)
if initialize:
app.display_success("Wrote: pyproject.toml")
return
from rich.markup import escape
from rich.tree import Tree
def recurse_directory(directory, tree):
paths = sorted(Path(directory).iterdir(), key=lambda p: (p.is_file(), p.name))
for path in paths:
if path.is_dir():
recurse_directory(
path, tree.add(f"[bold magenta][link={app.platform.format_file_uri(path)}]{escape(path.name)}")
)
else:
tree.add(f"[green][link={app.platform.format_file_uri(path)}]{escape(path.name)}")
root = Tree(
f"[bold magenta][link={app.platform.format_file_uri(location)}]{escape(location.name)}",
guide_style="bright_blue",
hide_root=location == Path.cwd(),
)
recurse_directory(location, root)
app.output(root, markup=True)
================================================
FILE: src/hatch/cli/new/migrate.py
================================================
import os
import sys
FILE = os.path.abspath(__file__)
HERE = os.path.dirname(FILE)
ENV_VAR_PREFIX = "_HATCHLING_PORT_ADD_"
def _apply_env_vars(kwargs):
from ast import literal_eval
for key, value in os.environ.items():
if key.startswith(ENV_VAR_PREFIX):
kwargs[key.replace(ENV_VAR_PREFIX, "", 1).lower()] = literal_eval(value)
def _parse_dependencies(dependency_definition):
dependencies = []
for line in dependency_definition.splitlines():
dependency = line.split(" #", 1)[0].strip()
if dependency:
dependencies.append(dependency)
return dependencies
def _collapse_data(output, data):
import tomli_w
expected_output = new_output = tomli_w.dumps(data)
new_output = new_output.replace(" ", "")
new_output = new_output.replace("[\n", "[")
new_output = new_output.replace('",\n]', '"]')
return output.replace(expected_output, new_output, 1)
def _parse_setup_cfg(kwargs):
from configparser import ConfigParser
setup_cfg_file = os.path.join(HERE, "setup.cfg")
if not os.path.isfile(setup_cfg_file):
return
setup_cfg = ConfigParser()
setup_cfg.read(setup_cfg_file, encoding="utf-8")
if not setup_cfg.has_section("metadata"):
return
metadata = setup_cfg["metadata"]
if "name" in metadata and "name" not in kwargs:
kwargs["name"] = metadata["name"]
if "description" in metadata and "description" not in kwargs:
kwargs["description"] = metadata["description"]
if "license" in metadata and "license" not in kwargs:
kwargs["license"] = metadata["license"]
if "author" in metadata and "author" not in kwargs:
kwargs["author"] = metadata["author"]
if "author_email" in metadata and "author_email" not in kwargs:
kwargs["author_email"] = metadata["author_email"]
if "keywords" in metadata and "keywords" not in kwargs:
keywords = metadata["keywords"].strip().splitlines()
kwargs["keywords"] = keywords if len(keywords) > 1 else keywords[0]
if "classifiers" in metadata and "classifiers" not in kwargs:
kwargs["classifiers"] = metadata["classifiers"].strip().splitlines()
if "url" in metadata and "url" not in kwargs:
kwargs["url"] = metadata["url"]
if "download_url" in metadata and "download_url" not in kwargs:
kwargs["download_url"] = metadata["download_url"]
if "project_urls" in metadata and "project_urls" not in kwargs:
kwargs["project_urls"] = dict(
url.replace(" = ", "=", 1).split("=") for url in metadata["project_urls"].strip().splitlines()
)
if setup_cfg.has_section("options"):
options = setup_cfg["options"]
if "python_requires" in options and "python_requires" not in kwargs:
kwargs["python_requires"] = options["python_requires"]
if "install_requires" in options and "install_requires" not in kwargs:
kwargs["install_requires"] = _parse_dependencies(options["install_requires"])
if "packages" in options and "packages" not in kwargs:
packages = []
for package_spec in options["packages"].strip().splitlines():
package = package_spec.replace("find:", "", 1).replace("find_namespace:", "", 1).strip()
if package:
packages.append(package)
if packages:
kwargs["packages"] = packages
if "package_dir" in options and "package_dir" not in kwargs:
kwargs["package_dir"] = dict(
d.replace(" = ", "=", 1).split("=") for d in options["package_dir"].strip().splitlines()
)
if setup_cfg.has_section("options.extras_require") and "extras_require" not in kwargs:
kwargs["extras_require"] = {
feature: _parse_dependencies(dependencies)
for feature, dependencies in setup_cfg["options.extras_require"].items()
}
if setup_cfg.has_section("options.entry_points") and "entry_points" not in kwargs:
kwargs["entry_points"] = {
entry_point: definitions.strip().splitlines()
for entry_point, definitions in setup_cfg["options.entry_points"].items()
}
def setup(**kwargs):
import itertools
import re
import tomli_w
project_metadata = {}
hatch_metadata = {}
tool_metadata = {"hatch": hatch_metadata}
project_data = {
"build-system": {"requires": ["hatchling"], "build-backend": "hatchling.build"},
"project": project_metadata,
"tool": tool_metadata,
}
_parse_setup_cfg(kwargs)
_apply_env_vars(kwargs)
name = kwargs["name"]
project_name = name.replace("_", "-")
packages = sorted(kwargs.get("packages") or [name.replace("-", "_")])
package_name = package_path = package_source = packages[0].split(".")[0].lower().strip()
project_metadata["name"] = project_name
project_metadata["dynamic"] = ["version"]
if "description" in kwargs:
project_metadata["description"] = kwargs["description"]
for readme_file in ("README.md", "README.rst", "README.txt"):
if os.path.isfile(os.path.join(HERE, readme_file)):
project_metadata["readme"] = readme_file
break
project_metadata["license"] = kwargs.get("license", "")
if "python_requires" in kwargs:
project_metadata["requires-python"] = kwargs["python_requires"]
for collaborator in ("author", "maintainer"):
collaborators = []
collaborator_names = []
collaborator_emails = []
if collaborator in kwargs:
collaborator_names.extend(
collaborator_name.strip() for collaborator_name in kwargs[collaborator].split(",")
)
if f"{collaborator}_email" in kwargs:
collaborator_emails.extend(
collaborator_email.strip() for collaborator_email in kwargs[f"{collaborator}_email"].split(",")
)
for collaborator_name, collaborator_email in itertools.zip_longest(collaborator_names, collaborator_emails):
data = {}
if collaborator_name is not None:
data["name"] = collaborator_name
if collaborator_email is not None:
data["email"] = collaborator_email
if data:
collaborators.append(data)
if collaborators:
project_metadata[f"{collaborator}s"] = collaborators
if "keywords" in kwargs:
keywords = kwargs["keywords"]
if isinstance(keywords, str):
keywords = keywords.replace(",", " ").split()
project_metadata["keywords"] = sorted(keywords)
if "classifiers" in kwargs:
project_metadata["classifiers"] = sorted(kwargs["classifiers"])
fixed_indices = []
final_index = 0
for i, classifier in enumerate(project_metadata["classifiers"]):
if classifier.startswith("Programming Language :: Python :: "):
final_index = i
for python_version in ("3.10", "3.11", "3.12"):
if classifier.endswith(python_version):
fixed_indices.append(i)
break
for i, index in enumerate(fixed_indices):
project_metadata["classifiers"].insert(final_index, project_metadata["classifiers"].pop(index - i))
if "install_requires" in kwargs:
project_metadata["dependencies"] = sorted(
[entry.strip() for entry in kwargs["install_requires"]]
if isinstance(kwargs["install_requires"], (list, tuple))
else _parse_dependencies(kwargs["install_requires"]),
key=lambda d: d.lower(),
)
if "extras_require" in kwargs:
project_metadata["optional-dependencies"] = {
group: sorted(
[entry.strip() for entry in dependencies]
if isinstance(dependencies, (list, tuple))
else _parse_dependencies(dependencies),
key=lambda d: d.lower(),
)
for group, dependencies in sorted(kwargs["extras_require"].items())
}
if "entry_points" in kwargs and isinstance(kwargs["entry_points"], dict):
entry_points = {}
for entry_point, raw_definitions in kwargs["entry_points"].items():
definitions = [raw_definitions] if isinstance(raw_definitions, str) else raw_definitions
definitions = dict(sorted(d.replace(" ", "").split("=", 1) for d in definitions))
if entry_point == "console_scripts":
project_metadata["scripts"] = definitions
elif entry_point == "gui_scripts":
project_metadata["gui-scripts"] = definitions
else:
entry_points[entry_point] = definitions
if entry_points:
project_metadata["entry-points"] = dict(sorted(entry_points.items()))
urls = {}
if "url" in kwargs:
urls["Homepage"] = kwargs["url"]
if "download_url" in kwargs:
urls["Download"] = kwargs["download_url"]
if "project_urls" in kwargs:
urls.update(kwargs["project_urls"])
if urls:
project_metadata["urls"] = dict(sorted(urls.items()))
build_targets = {}
build_data = {}
if "use_scm_version" in kwargs:
project_data["build-system"]["requires"].append("hatch-vcs")
hatch_metadata["version"] = {"source": "vcs"}
build_data["hooks"] = {"vcs": {"version-file": f"{package_path}/_version.py"}}
else:
hatch_metadata["version"] = {"path": f"{package_path}/__init__.py"}
build_data["targets"] = build_targets
if "" in kwargs.get("package_dir", {}):
package_source = kwargs["package_dir"][""]
package = (kwargs.get("packages") or [package_name])[0]
package_path = f"{package_source}/{package}"
if package_path != f"src/{package_name}":
build_targets.setdefault("wheel", {})["packages"] = [package_path]
if kwargs.get("data_files", []):
shared_data = {}
for shared_directory, relative_paths in kwargs["data_files"]:
relative_files = {}
for relative_path in relative_paths:
relative_directory, filename = os.path.split(relative_path)
relative_files.setdefault(relative_directory, []).append(filename)
for relative_directory, files in sorted(relative_files.items()):
if not os.path.isdir(relative_directory) or set(os.listdir(relative_directory)) != set(files):
for filename in sorted(files):
local_path = os.path.join(relative_directory, filename).replace("\\", "/")
shared_data[local_path] = f"{shared_directory}/{filename}"
else:
shared_data[relative_directory] = shared_directory
build_targets.setdefault("wheel", {})["shared-data"] = shared_data
build_targets["sdist"] = {
"include": [
f"/{package_source}",
]
}
hatch_metadata["build"] = build_data
output = tomli_w.dumps(project_data)
output = _collapse_data(output, {"requires": project_data["build-system"]["requires"]})
output = _collapse_data(output, {"dynamic": project_data["project"]["dynamic"]})
project_file = os.path.join(HERE, "pyproject.toml")
if os.path.isfile(project_file):
with open(project_file, encoding="utf-8") as f:
current_contents = f.read()
for section in ("build-system", "project"):
for pattern in (rf"^\[{section}].*?(?=^\[)", rf"^\[{section}].*"):
current_contents = re.sub(pattern, "", current_contents, flags=re.MULTILINE | re.DOTALL)
output += f"\n{current_contents}"
with open(project_file, "w", encoding="utf-8") as f:
f.write(output)
if __name__ == "setuptools":
__this_shim = sys.modules.pop("setuptools")
__current_directory = sys.path.pop(0)
import setuptools as __real_setuptools
sys.path.insert(0, __current_directory)
sys.modules["setuptools"] = __this_shim
def __getattr__(name):
return getattr(__real_setuptools, name)
del __this_shim
del __current_directory
def migrate(root, setuptools_options, sys_paths):
import shutil
import subprocess
from tempfile import TemporaryDirectory
with TemporaryDirectory() as temp_dir:
repo_dir = os.path.join(os.path.realpath(temp_dir), "repo")
shutil.copytree(root, repo_dir, ignore=shutil.ignore_patterns(".git", ".tox"), copy_function=shutil.copy)
shutil.copy(FILE, os.path.join(repo_dir, "setuptools.py"))
setup_py = os.path.join(repo_dir, "setup.py")
if not os.path.isfile(setup_py):
# Synthesize a small setup.py file since there is none
with open(setup_py, "w", encoding="utf-8") as f:
f.write("from setuptools import setup\nsetup()\n")
env = dict(os.environ)
for arg in setuptools_options:
key, value = arg.split("=", 1)
env[f"{ENV_VAR_PREFIX}{key}"] = value
# When PYTHONSAFEPATH is non-empty, the current directory is not added automatically
python_paths = [repo_dir]
python_paths.extend(p for p in sys_paths if p)
if python_path := env.get("PYTHONPATH", ""):
python_paths.append(python_path)
env["PYTHONPATH"] = os.pathsep.join(python_paths)
subprocess.run([sys.executable, setup_py], env=env, cwd=repo_dir, check=True)
old_project_file = os.path.join(root, "pyproject.toml")
new_project_file = os.path.join(repo_dir, "pyproject.toml")
shutil.copyfile(new_project_file, old_project_file)
================================================
FILE: src/hatch/cli/project/__init__.py
================================================
import click
from hatch.cli.project.metadata import metadata
@click.group(short_help="View project information")
def project():
pass
project.add_command(metadata)
================================================
FILE: src/hatch/cli/project/metadata.py
================================================
from __future__ import annotations
from typing import TYPE_CHECKING
import click
if TYPE_CHECKING:
from hatch.cli.application import Application
@click.command(short_help="Display project metadata")
@click.argument("field", required=False)
@click.pass_obj
def metadata(app: Application, field: str | None):
"""
Display project metadata.
If you want to view the raw readme file without rendering, you can use a JSON parser
like [jq](https://github.com/stedolan/jq):
\b
```
hatch project metadata | jq -r .readme
```
"""
app.ensure_environment_plugin_dependencies()
import json
from hatch.project.constants import BUILD_BACKEND
app.project.prepare_build_environment()
build_backend = app.project.metadata.build.build_backend
with app.project.location.as_cwd(), app.project.build_env.get_env_vars():
if build_backend != BUILD_BACKEND:
project_metadata = app.project.build_frontend.get_core_metadata()
else:
project_metadata = app.project.build_frontend.hatch.get_core_metadata()
if field:
if field not in project_metadata:
app.abort(f"Unknown metadata field: {field}")
elif field == "readme":
if project_metadata[field]["content-type"] == "text/markdown": # no cov
app.display_markdown(project_metadata[field]["text"])
else:
app.display(project_metadata[field]["text"])
elif isinstance(project_metadata[field], str):
app.display(project_metadata[field])
else:
app.display(json.dumps(project_metadata[field], indent=4))
else:
for key, value in list(project_metadata.items()):
if not value:
project_metadata.pop(key)
app.display(json.dumps(project_metadata, indent=4))
================================================
FILE: src/hatch/cli/publish/__init__.py
================================================
import click
from hatch.config.constants import PublishEnvVars
@click.command(short_help="Publish build artifacts")
@click.argument("artifacts", nargs=-1)
@click.option(
"--repo",
"-r",
envvar=PublishEnvVars.REPO,
help="The repository with which to publish artifacts [env var: `HATCH_INDEX_REPO`]",
)
@click.option(
"--user", "-u", envvar=PublishEnvVars.USER, help="The user with which to authenticate [env var: `HATCH_INDEX_USER`]"
)
@click.option(
"--auth",
"-a",
envvar=PublishEnvVars.AUTH,
help="The credentials to use for authentication [env var: `HATCH_INDEX_AUTH`]",
)
@click.option(
"--ca-cert",
envvar=PublishEnvVars.CA_CERT,
help="The path to a CA bundle [env var: `HATCH_INDEX_CA_CERT`]",
)
@click.option(
"--client-cert",
envvar=PublishEnvVars.CLIENT_CERT,
help="The path to a client certificate, optionally containing the private key [env var: `HATCH_INDEX_CLIENT_CERT`]",
)
@click.option(
"--client-key",
envvar=PublishEnvVars.CLIENT_KEY,
help="The path to the client certificate's private key [env var: `HATCH_INDEX_CLIENT_KEY`]",
)
@click.option("--no-prompt", "-n", is_flag=True, help="Disable prompts, such as for missing required fields")
@click.option(
"--initialize-auth", is_flag=True, help="Save first-time authentication information even if nothing was published"
)
@click.option(
"--publisher",
"-p",
"publisher_name",
envvar=PublishEnvVars.PUBLISHER,
default="index",
help="The publisher plugin to use (default is `index`) [env var: `HATCH_PUBLISHER`]",
)
@click.option(
"--option",
"-o",
"options",
envvar=PublishEnvVars.OPTIONS,
multiple=True,
help=(
"Options to pass to the publisher plugin. This may be selected multiple "
"times e.g. `-o foo=bar -o baz=23` [env var: `HATCH_PUBLISHER_OPTIONS`]"
),
)
@click.option("--yes", "-y", is_flag=True, help="Confirm without prompting when the plugin is disabled")
@click.pass_obj
def publish(
app,
artifacts,
repo,
user,
auth,
ca_cert,
client_cert,
client_key,
no_prompt,
initialize_auth,
publisher_name,
options,
yes,
):
"""Publish build artifacts."""
option_map = {"no_prompt": no_prompt, "initialize_auth": initialize_auth}
if publisher_name == "index":
if options:
app.abort("Use the standard CLI flags rather than passing explicit options when using the `index` plugin")
if repo:
option_map["repo"] = repo
if user:
option_map["user"] = user
if auth:
option_map["auth"] = auth
if ca_cert:
option_map["ca_cert"] = ca_cert
if client_cert:
option_map["client_cert"] = client_cert
if client_key:
option_map["client_key"] = client_key
else: # no cov
for option in options:
key, _, value = option.partition("=")
option_map[key] = value
publisher_class = app.plugins.publisher.get(publisher_name)
if publisher_class is None:
app.abort(f"Unknown publisher: {publisher_name}")
publisher = publisher_class(
app,
app.project.location,
app.cache_dir / "publish" / publisher_name,
app.project.config.publish.get(publisher_name, {}),
app.config.publish.get(publisher_name, {}),
)
if publisher.disable and not (yes or (not no_prompt and app.confirm(f"Confirm `{publisher_name}` publishing"))):
app.abort(f"Publisher is disabled: {publisher_name}")
publisher.publish(list(artifacts), option_map)
================================================
FILE: src/hatch/cli/python/__init__.py
================================================
import click
from hatch.cli.python.find import find
from hatch.cli.python.install import install
from hatch.cli.python.remove import remove
from hatch.cli.python.show import show
from hatch.cli.python.update import update
@click.group(short_help="Manage Python installations")
def python():
pass
python.add_command(find)
python.add_command(install)
python.add_command(remove)
python.add_command(show)
python.add_command(update)
================================================
FILE: src/hatch/cli/python/find.py
================================================
from __future__ import annotations
from typing import TYPE_CHECKING
import click
if TYPE_CHECKING:
from hatch.cli.application import Application
@click.command(short_help="Locate Python binaries")
@click.argument("name")
@click.option("-p", "--parent", is_flag=True, help="Show the parent directory of the Python binary")
@click.option("--dir", "-d", "directory", help="The directory in which distributions reside")
@click.pass_obj
def find(app: Application, *, name: str, parent: bool, directory: str | None):
"""Locate Python binaries."""
manager = app.get_python_manager(directory)
installed = manager.get_installed()
if name not in installed:
app.abort(f"Distribution not installed: {name}")
dist = installed[name]
app.display(str(dist.python_path.parent if parent else dist.python_path))
================================================
FILE: src/hatch/cli/python/install.py
================================================
from __future__ import annotations
from typing import TYPE_CHECKING
import click
if TYPE_CHECKING:
from hatch.cli.application import Application
def ensure_path_public(path: str, shells: list[str]) -> bool:
import userpath
if userpath.in_current_path(path) or userpath.in_new_path(path, shells):
return True
userpath.append(path, shells=shells)
return False
@click.command(short_help="Install Python distributions")
@click.argument("names", required=True, nargs=-1)
@click.option("--private", is_flag=True, help="Do not add distributions to the user PATH")
@click.option("--update", "-u", is_flag=True, help="Update existing installations")
@click.option(
"--dir", "-d", "directory", help="The directory in which to install distributions, overriding configuration"
)
@click.pass_obj
def install(app: Application, *, names: tuple[str, ...], private: bool, update: bool, directory: str | None):
"""
Install Python distributions.
You may select `all` to install all compatible distributions:
\b
```
hatch python install all
```
You can set custom sources for distributions by setting the `HATCH_PYTHON_SOURCE_` environment variable
where `` is the uppercased version of the distribution name with periods replaced by underscores e.g.
`HATCH_PYTHON_SOURCE_PYPY3_10`.
"""
from hatch.errors import PythonDistributionResolutionError, PythonDistributionUnknownError
from hatch.python.distributions import ORDERED_DISTRIBUTIONS
from hatch.python.resolve import get_distribution
shells = []
if not private and not app.platform.windows:
shell_name, _ = app.shell_data
shells.append(shell_name)
manager = app.get_python_manager(directory)
installed = manager.get_installed()
selection = ORDERED_DISTRIBUTIONS if "all" in names else names
unknown = []
compatible = []
incompatible = []
for name in selection:
if name in installed:
compatible.append(name)
continue
try:
get_distribution(name)
except PythonDistributionUnknownError:
unknown.append(name)
except PythonDistributionResolutionError:
incompatible.append(name)
else:
compatible.append(name)
if unknown:
app.abort(f"Unknown distributions: {', '.join(unknown)}")
elif incompatible and (not compatible or "all" not in names):
app.abort(f"Incompatible distributions: {', '.join(incompatible)}")
directories_made_public = []
for name in compatible:
needs_update = False
if name in installed:
installed_dist = installed[name]
needs_update = installed_dist.needs_update()
if not needs_update:
app.display_warning(f"The latest version is already installed: {installed_dist.version}")
continue
if not (update or app.confirm(f"Update {name}?")):
app.abort(f"Distribution is already installed: {name}")
with app.status(f"{'Updating' if needs_update else 'Installing'} {name}"):
dist = manager.install(name)
if not private:
python_directory = str(dist.python_path.parent)
if not ensure_path_public(python_directory, shells=shells):
directories_made_public.append(python_directory)
app.display_success(f"{'Updated' if needs_update else 'Installed'} {name} @ {dist.path}")
if directories_made_public:
multiple = len(directories_made_public) > 1
app.display(
f"\nThe following director{'ies' if multiple else 'y'} ha{'ve' if multiple else 's'} "
f"been added to your PATH (pending a shell restart):\n"
)
for public_directory in directories_made_public:
app.display(public_directory)
================================================
FILE: src/hatch/cli/python/remove.py
================================================
from __future__ import annotations
from typing import TYPE_CHECKING
import click
if TYPE_CHECKING:
from hatch.cli.application import Application
@click.command(short_help="Remove Python distributions")
@click.argument("names", required=True, nargs=-1)
@click.option("--dir", "-d", "directory", help="The directory in which distributions reside")
@click.pass_obj
def remove(app: Application, *, names: tuple[str, ...], directory: str | None):
"""
Remove Python distributions.
You may select `all` to remove all installed distributions:
\b
```
hatch python remove all
```
"""
manager = app.get_python_manager(directory)
installed = manager.get_installed()
selection = tuple(installed) if "all" in names else names
for name in selection:
if name not in installed:
app.display_warning(f"Distribution is not installed: {name}")
continue
dist = installed[name]
with app.status(f"Removing {name}"):
manager.remove(dist)
================================================
FILE: src/hatch/cli/python/show.py
================================================
from __future__ import annotations
from typing import TYPE_CHECKING
import click
if TYPE_CHECKING:
from hatch.cli.application import Application
@click.command(short_help="Show the available Python distributions")
@click.option("--ascii", "force_ascii", is_flag=True, help="Whether or not to only use ASCII characters")
@click.option("--dir", "-d", "directory", help="The directory in which distributions reside")
@click.pass_obj
def show(app: Application, *, force_ascii: bool, directory: str | None):
"""Show the available Python distributions."""
from hatch.python.resolve import get_compatible_distributions
manager = app.get_python_manager(directory)
installed = manager.get_installed()
installed_columns: dict[str, dict[int, str]] = {"Name": {}, "Version": {}, "Status": {}}
for i, (name, installed_dist) in enumerate(installed.items()):
installed_columns["Name"][i] = name
installed_columns["Version"][i] = installed_dist.version
if installed_dist.needs_update():
installed_columns["Status"][i] = "Update available"
available_columns: dict[str, dict[int, str]] = {"Name": {}, "Version": {}}
for i, (name, dist) in enumerate(get_compatible_distributions().items()):
if name in installed:
continue
available_columns["Name"][i] = name
available_columns["Version"][i] = dist.version.base_version
app.display_table("Installed", installed_columns, show_lines=True, force_ascii=force_ascii)
app.display_table("Available", available_columns, show_lines=True, force_ascii=force_ascii)
================================================
FILE: src/hatch/cli/python/update.py
================================================
from __future__ import annotations
from typing import TYPE_CHECKING
import click
from hatch.cli.python.install import install
if TYPE_CHECKING:
from hatch.cli.application import Application
@click.command(short_help="Update Python distributions")
@click.argument("names", required=True, nargs=-1)
@click.option("--dir", "-d", "directory", help="The directory in which distributions reside")
@click.pass_context
def update(ctx: click.Context, *, names: tuple[str, ...], directory: str | None):
"""
Update Python distributions.
You may select `all` to update all installed distributions:
\b
```
hatch python update all
```
"""
app: Application = ctx.obj
manager = app.get_python_manager(directory)
installed = manager.get_installed()
selection = tuple(installed) if "all" in names else names
not_installed = [name for name in selection if name not in installed]
if not_installed:
app.abort(f"Distributions not installed: {', '.join(not_installed)}")
ctx.invoke(install, names=selection, directory=directory, private=True, update=True)
================================================
FILE: src/hatch/cli/run/__init__.py
================================================
from __future__ import annotations
from typing import TYPE_CHECKING
import click
if TYPE_CHECKING:
from hatch.cli.application import Application
@click.command(
short_help="Run commands within project environments",
context_settings={"help_option_names": [], "ignore_unknown_options": True},
)
@click.argument("args", metavar="[ENV:]ARGS...", required=True, nargs=-1)
@click.pass_context
def run(ctx: click.Context, args: tuple[str, ...]):
"""
Run commands within project environments.
This is a convenience wrapper around the [`env run`](#hatch-env-run) command.
If the first argument contains a colon, then the preceding component will be
interpreted as the name of the environment to target, overriding the `-e`/`--env`
[root option](#hatch) and the `HATCH_ENV` environment variable.
If the environment provides matrices, then you may also provide leading arguments
starting with a `+` or `-` to select or exclude certain variables, optionally
followed by specific comma-separated values. For example, if you have the
following configuration:
\b
```toml tab="pyproject.toml"
[[tool.hatch.envs.test.matrix]]
python = ["3.9", "3.10"]
version = ["42", "3.14", "9000"]
```
```toml tab="hatch.toml"
[[envs.test.matrix]]
python = ["3.9", "3.10"]
version = ["42", "3.14", "9000"]
```
then running:
\b
```
hatch run +py=3.10 -version=9000 test:pytest
```
would execute `pytest` in the environments `test.py3.10-42` and `test.py3.10-3.14`.
Note that `py` may be used as an alias for `python`.
\b
!!! note
Inclusions are treated as an intersection while exclusions are treated as a union i.e.
an environment must match all of the included variables to be selected while matching
any of the excluded variables will prevent selection.
"""
app: Application = ctx.obj
first_arg = args[0]
if first_arg in {"-h", "--help"}:
app.display_info(ctx.get_help())
return
from hatch.utils.fs import Path
if first_arg.endswith(".py") and (script := Path(first_arg)).is_file():
from hatch.project.utils import parse_inline_script_metadata
# Ensure consistent IDs for storage
script = script.resolve()
try:
metadata = parse_inline_script_metadata(script.read_text())
except ValueError as e:
app.abort(f"{e}, {first_arg}")
# Ignore scripts that don't define metadata blocks or define empty metadata blocks
if metadata:
from hatch.env.utils import ensure_valid_environment
config = metadata.get("tool", {}).get("hatch", {})
config["skip-install"] = True
config.setdefault("installer", "uv")
config.setdefault("dependencies", [])
config["dependencies"].extend(metadata.get("dependencies", []))
if "python" not in config and (requires_python := metadata.get("requires-python")) is not None:
import re
import sys
import sysconfig
from packaging.specifiers import SpecifierSet
from hatch.python.distributions import DISTRIBUTIONS
current_version = ".".join(map(str, sys.version_info[:2]))
if bool(sysconfig.get_config_var("Py_GIL_DISABLED")):
current_version += "t"
# Strip "t" suffix for distribution lookup since DISTRIBUTIONS keys don't include it
current_version_base = current_version.rstrip("t")
distributions = [name for name in DISTRIBUTIONS if re.match(r"^\d+\.\d+$", name)]
distributions.sort(key=lambda name: name != current_version_base)
python_constraint = SpecifierSet(requires_python)
for distribution in distributions:
# Try an artificially high patch version to account for
# common cases like `>=3.11.4` or `>=3.10,<3.11`
if python_constraint.contains(f"{distribution}.100"):
# Only set config["python"] if it doesn't match the current Python's base version
# This allows free-threaded builds (e.g. 3.14t) to match their base version (3.14)
if distribution != current_version_base:
config["python"] = distribution
break
else:
app.abort(f"Unable to satisfy Python version constraint: {requires_python}")
ensure_valid_environment(config)
app.project.config.envs[script.id] = config
app.project.set_path(script)
for context in app.runner_context([script.id]):
context.add_shell_command(["python", first_arg, *args[1:]])
return
from hatch.cli.env.run import run as run_command
command_start = 0
included_variables = []
excluded_variables = []
for i, arg in enumerate(args):
command_start = i
if arg.startswith("+"):
included_variables.append(arg[1:])
elif arg.startswith("-"):
excluded_variables.append(arg[1:])
else:
break
else:
command_start += 1
args = args[command_start:]
if not args:
app.abort("Missing argument `MATRIX:ARGS...`")
command, *final_args = args
env_name, separator, command = command.rpartition(":")
if not separator:
env_name = app.env
elif not env_name:
env_name = "system"
ctx.invoke(
run_command,
args=[command, *final_args],
env_names=[env_name],
included_variable_specs=included_variables,
excluded_variable_specs=excluded_variables,
)
================================================
FILE: src/hatch/cli/self/__init__.py
================================================
import os
import click
from hatch.cli.self.report import report
__management_command = os.environ.get("PYAPP_COMMAND_NAME", "self")
@click.group(name=__management_command, short_help="Manage Hatch")
def self_command():
pass
self_command.add_command(report)
if __management_command:
from hatch.cli.self.restore import restore
from hatch.cli.self.update import update
self_command.add_command(restore)
self_command.add_command(update)
================================================
FILE: src/hatch/cli/self/report.py
================================================
from __future__ import annotations
from typing import TYPE_CHECKING
import click
if TYPE_CHECKING:
from hatch.cli.application import Application
def get_install_source(platform_name: str) -> str:
import os
import sys
import sysconfig
from platformdirs import user_data_dir
from hatch.utils.fs import Path
default_source = "pip"
python_path = Path(sys.executable).resolve()
parent_paths = python_path.parents
# https://github.com/ofek/pyapp/blob/v0.13.0/src/app.rs#L27
if Path(user_data_dir("pyapp", appauthor=False)) in parent_paths:
return "binary"
# https://pypa.github.io/pipx/how-pipx-works/
if (Path.home() / ".local" / "pipx" / "venvs") in parent_paths:
return "pipx"
# https://packaging.python.org/en/latest/specifications/externally-managed-environments/#marking-an-interpreter-as-using-an-external-package-manager
try:
stdlib_path_config = sysconfig.get_path("stdlib")
# https://docs.python.org/3/library/sysconfig.html#sysconfig.get_path
except KeyError:
stdlib_path_config = ""
if (
# This does not work on NixOS, see: https://github.com/NixOS/nixpkgs/issues/201037
sys.prefix == sys.base_prefix
and stdlib_path_config
and (stdlib_path := Path(stdlib_path_config)).is_dir()
and any(p.name == "EXTERNALLY-MANAGED" and p.is_file() for p in stdlib_path.iterdir())
):
return "system"
if platform_name == "windows":
if sys.executable.endswith("WindowsApps\\python.exe"):
return "Windows Store"
# Break early because nothing after is applicable
return default_source
if platform_name == "macos" and Path("/usr/local/Cellar") in parent_paths: # no cov
return "Homebrew"
# https://github.com/pyenv/pyenv/tree/v2.3.35#set-up-your-shell-environment-for-pyenv
if Path(os.environ.get("PYENV_ROOT", "~/.pyenv")).expand() in parent_paths:
return "Pyenv"
return default_source
@click.command(short_help="Generate a pre-populated GitHub issue")
@click.option("--no-open", "-n", is_flag=True, help="Show the URL instead of opening it")
@click.pass_obj
def report(app: Application, *, no_open: bool) -> None:
"""Generate a pre-populated GitHub issue."""
import sys
import webbrowser
from textwrap import indent
from urllib.parse import quote_plus
import tomlkit
from hatch._version import __version__
from hatch.utils.toml import load_toml_data
# Retain the config that would be most useful
full_config = load_toml_data(app.config_file.read_scrubbed())
relevant_config = {}
for setting in ("mode", "shell"):
if setting in full_config:
relevant_config[setting] = full_config[setting]
if env_dirs := relevant_config.get("dirs", {}).get("envs"):
relevant_config["dirs"] = {"envs": env_dirs}
# Try to determine how Hatch was installed
source = get_install_source(app.platform.name)
element_padding = " " * 4
body = f"""\
## Current behavior
## Expected behavior
## Additional context
## Debug
### Installation
- Source: {source}
- Version: {__version__}
- Platform: {app.platform.display_name}
- Python version:
{element_padding}```
{indent(sys.version, element_padding)}
{element_padding}```
### Configuration
```toml
{tomlkit.dumps(relevant_config).rstrip()}
```
"""
url = f"https://github.com/pypa/hatch/issues/new?body={quote_plus(body)}"
if no_open:
app.display(url)
else:
webbrowser.open_new_tab(url)
================================================
FILE: src/hatch/cli/self/restore.py
================================================
from __future__ import annotations
from typing import TYPE_CHECKING
import click
if TYPE_CHECKING:
from hatch.cli.application import Application
@click.command(
short_help="Restore the installation", context_settings={"help_option_names": [], "ignore_unknown_options": True}
)
@click.argument("args", nargs=-1)
@click.pass_obj
def restore(app: Application, *, args: tuple[str, ...]): # noqa: ARG001
app.abort("Hatch is not installed as a binary")
================================================
FILE: src/hatch/cli/self/update.py
================================================
from __future__ import annotations
from typing import TYPE_CHECKING
import click
if TYPE_CHECKING:
from hatch.cli.application import Application
@click.command(
short_help="Install the latest version", context_settings={"help_option_names": [], "ignore_unknown_options": True}
)
@click.argument("args", nargs=-1)
@click.pass_obj
def update(app: Application, *, args: tuple[str, ...]): # noqa: ARG001
app.abort("Hatch is not installed as a binary")
================================================
FILE: src/hatch/cli/shell/__init__.py
================================================
from __future__ import annotations
import os
from typing import TYPE_CHECKING
import click
from hatch.config.constants import AppEnvVars
if TYPE_CHECKING:
from hatch.cli.application import Application
@click.command(short_help="Enter a shell within a project's environment")
@click.argument("env_name", required=False)
@click.option("--name")
@click.option("--path")
@click.pass_obj
def shell(app: Application, env_name: str | None, name: str, path: str): # no cov
"""Enter a shell within a project's environment."""
app.ensure_environment_plugin_dependencies()
chosen_env = env_name or app.env
if chosen_env == app.env_active:
app.abort(f"Already in environment: {chosen_env}")
for matrices in (app.project.config.matrices, app.project.config.internal_matrices):
if chosen_env in matrices:
app.display_error(f"Environment `{chosen_env}` defines a matrix, choose one of the following instead:\n")
for generated_name in matrices[chosen_env]["envs"]:
app.display_error(generated_name)
app.abort()
if not name:
name = app.config.shell.name
if not path:
path = app.config.shell.path
args = app.config.shell.args
if not path:
name, path = app.shell_data
if not app.platform.windows:
path, *args = app.platform.modules.shlex.split(path)
with app.project.ensure_cwd():
environment = app.project.get_environment(chosen_env)
app.project.prepare_environment(environment, keep_env=bool(os.environ.get(AppEnvVars.KEEP_ENV)))
first_run_indicator = app.cache_dir / "shell" / "first_run"
if not first_run_indicator.is_file():
app.display_waiting(
"You are about to enter a new shell, exit as you usually would e.g. "
"by typing `exit` or pressing `ctrl+d`..."
)
first_run_indicator.parent.ensure_dir_exists()
first_run_indicator.touch()
environment.enter_shell(name, path, args)
================================================
FILE: src/hatch/cli/status/__init__.py
================================================
import click
@click.command(short_help="Show information about the current environment")
@click.pass_obj
def status(app):
"""Show information about the current environment."""
def display_pair(key, value, display_func=None, link=None):
app.display_info("[", end="")
app.display_success(key, end="")
app.display_info("]", end="")
app.display_info(" - ", end="")
(display_func or app.display_info)(value, link=link)
if app.project.root is None:
if app.project.chosen_name is None:
display_pair("Project", "", app.display_warning)
else:
display_pair("Project", f"{app.project.chosen_name} (not a project)", app.display_warning)
elif app.project.chosen_name is None:
display_pair("Project", f"{app.project.root.name} (current directory)")
else:
display_pair("Project", app.project.chosen_name)
display_pair("Location", str(app.project.location))
display_pair("Config", str(app.config_file.path), link=f"file:///{app.config_file.path}")
================================================
FILE: src/hatch/cli/terminal.py
================================================
from __future__ import annotations
import os
from abc import ABC, abstractmethod
from functools import cached_property
from textwrap import indent as indent_text
from typing import TYPE_CHECKING
import click
from rich.console import Console
from rich.style import Style
from rich.text import Text
if TYPE_CHECKING:
from collections.abc import Callable
from rich.status import Status
class TerminalStatus(ABC):
@abstractmethod
def stop(self) -> None: ...
def __enter__(self) -> TerminalStatus: # noqa: PYI034
return self
@abstractmethod
def __exit__(self, exc_type, exc_val, exc_tb): ...
class NullStatus(TerminalStatus):
def stop(self):
pass
def __exit__(self, exc_type, exc_value, traceback):
pass
class BorrowedStatus(TerminalStatus):
def __init__(
self,
console: Console,
*,
is_interactive: bool,
verbosity: int,
spinner_style: str,
waiting_style: Style | str,
success_style: Style | str,
initializer: Callable,
finalizer: Callable,
):
self.__console = console
self.__is_interactive = is_interactive
self.__verbosity = verbosity
self.__spinner_style = spinner_style
self.__waiting_style = waiting_style
self.__success_style = success_style
self.__initializer = initializer
self.__finalizer = finalizer
# This is the possibly active current status
self.__status: Status | None = None
# This is used as a stack to display the current message
self.__messages: list[tuple[Text, str]] = []
def stop(self) -> None:
active = self.__active()
if self.__status is not None:
self.__status.stop()
self.__finalizer()
old_message, final_text = self.__messages[-1]
if self.__verbosity > 0 and active:
if not final_text:
final_text = old_message.plain
final_text = f"Finished {final_text[:1].lower()}{final_text[1:]}"
self.__output(Text(final_text, style=self.__success_style))
def __call__(self, message: str, final_text: str = "") -> BorrowedStatus:
self.__messages.append((Text(message, style=self.__waiting_style), final_text))
return self
def __enter__(self) -> BorrowedStatus: # noqa: PYI034
if not self.__messages:
return self
message, _ = self.__messages[-1]
if not self.__is_interactive:
self.__output(message)
return self
if self.__status is None:
self.__initializer()
else:
self.__status.stop()
self.__status = self.__console.status(message, spinner=self.__spinner_style)
self.__status.start()
return self
def __exit__(self, exc_type, exc_val, exc_tb):
old_message, final_text = self.__messages.pop()
if self.__verbosity > 0 and self.__active():
if not final_text:
final_text = old_message.plain
final_text = f"Finished {final_text[:1].lower()}{final_text[1:]}"
self.__output(Text(final_text, style=self.__success_style))
if not self.__is_interactive:
return
self.__status.stop()
if not self.__messages:
self.__status = None
self.__finalizer()
else:
self.__initializer()
message, _ = self.__messages[-1]
self.__status = self.__console.status(message, spinner=self.__spinner_style)
self.__status.start()
def __active(self) -> bool:
return self.__status is not None and self.__status._live.is_started # noqa: SLF001
def __output(self, text):
self.__console.stderr = True
try:
self.__console.print(text, overflow="ignore", no_wrap=True, crop=False)
finally:
self.__console.stderr = False
class Terminal:
def __init__(self, *, verbosity: int, enable_color: bool | None, interactive: bool | None):
# Force consistent output for test assertions
self.testing = "HATCH_SELF_TESTING" in os.environ
self.verbosity = verbosity
self.console = Console(
force_terminal=enable_color,
force_interactive=interactive,
no_color=enable_color is False,
markup=False,
emoji=False,
highlight=False,
legacy_windows=False if self.testing else None,
)
# Set defaults so we can pretty print before loading user config
self._style_level_success: Style | str = "bold cyan"
self._style_level_error: Style | str = "bold red"
self._style_level_warning: Style | str = "bold yellow"
self._style_level_waiting: Style | str = "bold magenta"
# Default is simply bold rather than bold white for shells that have been configured with a white background
self._style_level_info: Style | str = "bold"
self._style_level_debug: Style | str = "bold"
# Chosen as the default since it's compatible everywhere and looks nice
self._style_spinner = "simpleDotsScrolling"
@cached_property
def kv_separator(self) -> Text:
return self.style_warning("->")
def style_success(self, text: str) -> Text:
return Text(text, style=self._style_level_success)
def style_error(self, text: str) -> Text:
return Text(text, style=self._style_level_error)
def style_warning(self, text: str) -> Text:
return Text(text, style=self._style_level_warning)
def style_waiting(self, text: str) -> Text:
return Text(text, style=self._style_level_waiting)
def style_info(self, text: str) -> Text:
return Text(text, style=self._style_level_info)
def style_debug(self, text: str) -> Text:
return Text(text, style=self._style_level_debug)
def initialize_styles(self, styles: dict): # no cov
from rich.errors import StyleSyntaxError
from rich.spinner import Spinner
# Lazily display errors so that they use the correct style
errors = []
for option, style in styles.items():
attribute = f"_style_level_{option}"
default_level = getattr(self, attribute, None)
if default_level:
try:
parsed_style = Style.parse(style)
except StyleSyntaxError as e: # no cov
errors.append(f"Invalid style definition for `{option}`, defaulting to `{default_level}`: {e}")
parsed_style = Style.parse(default_level)
setattr(self, attribute, parsed_style)
elif option == "spinner":
try:
Spinner(style)
except KeyError as e:
errors.append(
f"Invalid style definition for `{option}`, defaulting to `{self._style_spinner}`: {e.args[0]}"
)
else:
self._style_spinner = style
else:
setattr(self, f"_style_{option}", style)
return errors
def display(self, text="", **kwargs):
self.console.print(text, style=self._style_level_info, overflow="ignore", no_wrap=True, crop=False, **kwargs)
def display_critical(self, text="", **kwargs):
self.console.stderr = True
try:
self.console.print(
text, style=self._style_level_error, overflow="ignore", no_wrap=True, crop=False, **kwargs
)
finally:
self.console.stderr = False
def display_error(self, text="", *, stderr=True, indent=None, link=None, **kwargs):
if self.verbosity < -2: # noqa: PLR2004
return
self._output(text, self._style_level_error, stderr=stderr, indent=indent, link=link, **kwargs)
def display_warning(self, text="", *, stderr=True, indent=None, link=None, **kwargs):
if self.verbosity < -1:
return
self._output(text, self._style_level_warning, stderr=stderr, indent=indent, link=link, **kwargs)
def display_info(self, text="", *, stderr=True, indent=None, link=None, **kwargs):
if self.verbosity < 0:
return
self._output(text, self._style_level_info, stderr=stderr, indent=indent, link=link, **kwargs)
def display_success(self, text="", *, stderr=True, indent=None, link=None, **kwargs):
if self.verbosity < 0:
return
self._output(text, self._style_level_success, stderr=stderr, indent=indent, link=link, **kwargs)
def display_waiting(self, text="", *, stderr=True, indent=None, link=None, **kwargs):
if self.verbosity < 0:
return
self._output(text, self._style_level_waiting, stderr=stderr, indent=indent, link=link, **kwargs)
def display_debug(self, text="", level=1, *, stderr=True, indent=None, link=None, **kwargs):
if not 1 <= level <= 3: # noqa: PLR2004
error_message = "Debug output can only have verbosity levels between 1 and 3 (inclusive)"
raise ValueError(error_message)
if self.verbosity < level:
return
self._output(text, self._style_level_debug, stderr=stderr, indent=indent, link=link, **kwargs)
def display_mini_header(self, text, *, stderr=False, indent=None, link=None):
if self.verbosity < 0:
return
self.display_info("[", stderr=stderr, indent=indent, end="")
self.display_success(text, stderr=stderr, link=link, end="")
self.display_info("]", stderr=stderr)
def display_header(self, title=""):
self.console.rule(Text(title, self._style_level_success))
def display_syntax(self, *args, **kwargs):
from rich.syntax import Syntax
kwargs.setdefault("background_color", "default" if self.testing else None)
self.output(Syntax(*args, **kwargs))
def display_markdown(self, text, **kwargs): # no cov
from rich.markdown import Markdown
self.output(Markdown(text), **kwargs)
def display_pair(self, key, value):
self.output(self.style_success(key), self.kv_separator, value)
def display_table(self, title, columns, *, show_lines=False, column_options=None, force_ascii=False, num_rows=0):
from rich.table import Table
if column_options is None:
column_options = {}
table_options = {}
if force_ascii:
from rich.box import ASCII_DOUBLE_HEAD
table_options["box"] = ASCII_DOUBLE_HEAD
table_options["safe_box"] = True
table = Table(title=title, show_lines=show_lines, title_style="", **table_options)
columns = dict(columns)
for column_title, indices in list(columns.items()):
if indices:
table.add_column(column_title, style="bold", **column_options.get(column_title, {}))
else:
columns.pop(column_title)
if not columns:
return
for i in range(num_rows or max(map(max, columns.values())) + 1):
row = [indices.get(i, "") for indices in columns.values()]
if any(row):
table.add_row(*row)
self.output(table)
@cached_property
def status(self) -> BorrowedStatus:
return BorrowedStatus(
self.console,
is_interactive=self.console.is_interactive,
verbosity=self.verbosity,
spinner_style=self._style_spinner,
waiting_style=self._style_level_waiting,
success_style=self._style_level_success,
initializer=lambda: setattr(self.platform, "displaying_status", True), # type: ignore[attr-defined]
finalizer=lambda: setattr(self.platform, "displaying_status", False), # type: ignore[attr-defined]
)
def status_if(self, *args, condition: bool, **kwargs) -> TerminalStatus:
return self.status(*args, **kwargs) if condition else NullStatus()
def _output(self, text="", style=None, *, stderr=False, indent=None, link=None, **kwargs):
if indent:
text = indent_text(text, indent)
if link:
style = style.update_link(self.platform.format_file_uri(link))
self.output(text, stderr=stderr, style=style, **kwargs)
def output(self, *args, stderr=False, **kwargs):
kwargs.setdefault("overflow", "ignore")
kwargs.setdefault("no_wrap", True)
kwargs.setdefault("crop", False)
if not stderr:
self.console.print(*args, **kwargs)
else:
self.console.stderr = True
try:
self.console.print(*args, **kwargs)
finally:
self.console.stderr = False
@staticmethod
def prompt(text, **kwargs):
return click.prompt(text, **kwargs)
@staticmethod
def confirm(text, **kwargs):
return click.confirm(text, **kwargs)
================================================
FILE: src/hatch/cli/test/__init__.py
================================================
from __future__ import annotations
from typing import TYPE_CHECKING
import click
if TYPE_CHECKING:
from hatch.cli.application import Application
from hatch.env.plugin.interface import EnvironmentInterface
@click.command(short_help="Run tests", context_settings={"ignore_unknown_options": True})
@click.argument("args", nargs=-1)
@click.option("--randomize", "-r", is_flag=True, help="Randomize the order of test execution")
@click.option("--parallel", "-p", is_flag=True, help="Parallelize test execution")
@click.option("--retries", type=int, help="Number of times to retry failed tests")
@click.option("--retry-delay", type=float, help="Seconds to wait between retries")
@click.option("--cover", "-c", is_flag=True, help="Measure code coverage")
@click.option("--cover-quiet", is_flag=True, help="Disable coverage reporting after tests, implicitly enabling --cover")
@click.option("--all", "-a", "test_all", is_flag=True, help="Test all environments in the matrix")
@click.option("--python", "-py", help="The Python versions to test, equivalent to: -i py=...")
@click.option("--include", "-i", "included_variable_specs", multiple=True, help="The matrix variables to include")
@click.option("--exclude", "-x", "excluded_variable_specs", multiple=True, help="The matrix variables to exclude")
@click.option("--show", "-s", is_flag=True, help="Show information about environments in the matrix")
@click.pass_context
def test(
ctx: click.Context,
*,
args: tuple[str, ...],
randomize: bool,
parallel: bool,
retries: int | None,
retry_delay: float | None,
cover: bool,
cover_quiet: bool,
test_all: bool,
python: str | None,
included_variable_specs: tuple[str, ...],
excluded_variable_specs: tuple[str, ...],
show: bool,
):
"""Run tests using the `hatch-test` environment matrix.
If no filtering options are selected, then tests will be run in the first compatible environment
found in the matrix with priority given to those matching the current interpreter.
The `-i`/`--include` and `-x`/`--exclude` options may be used to include or exclude certain
variables, optionally followed by specific comma-separated values, and may be selected multiple
times. For example, if you have the following configuration:
\b
```toml config-example
[[tool.hatch.envs.hatch-test.matrix]]
python = ["3.9", "3.10"]
version = ["42", "3.14", "9000"]
```
then running:
\b
```
hatch test -i py=3.10 -x version=9000
```
would run tests in the environments `hatch-test.py3.10-42` and `hatch-test.py3.10-3.14`.
The `-py`/`--python` option is a shortcut for specifying the inclusion `-i py=...`.
\b
!!! note
The inclusion option is treated as an intersection while the exclusion option is treated as a
union i.e. an environment must match all of the included variables to be selected while matching
any of the excluded variables will prevent selection.
"""
app: Application = ctx.obj
if show:
import os
from hatch.cli.env.show import show as show_env
ctx.invoke(
show_env,
internal=True,
hide_titles=True,
force_ascii=os.environ.get("HATCH_SELF_TESTING") == "true",
envs=ctx.obj.project.config.internal_matrices["hatch-test"]["envs"] if app.verbose else ["hatch-test"],
)
return
if cover_quiet:
cover = True
import sys
from hatch.cli.test.core import PatchedCoverageConfig
from hatch.utils.runner import parse_matrix_variables, select_environments
if python is not None:
included_variable_specs = (f"py={python}", *included_variable_specs)
try:
included_variables = parse_matrix_variables(included_variable_specs)
except ValueError as e:
app.abort(f"Duplicate included variable: {e}")
try:
excluded_variables = parse_matrix_variables(excluded_variable_specs)
except ValueError as e:
app.abort(f"Duplicate excluded variable: {e}")
if test_all and (included_variables or excluded_variables):
app.abort("The --all option cannot be used with the --include or --exclude options.")
if retries is None and retry_delay is not None:
app.abort("The --retry-delay option requires the --retries option to be set as well.")
app.ensure_environment_plugin_dependencies()
test_envs = app.project.config.internal_matrices["hatch-test"]["envs"]
selected_envs: list[str] = []
multiple_possible = True
if test_all:
selected_envs.extend(test_envs)
elif included_variables or excluded_variables:
selected_envs.extend(select_environments(test_envs, included_variables, excluded_variables))
else:
multiple_possible = False
# Prioritize candidates that seems to match the running interpreter
current_version = ".".join(map(str, sys.version_info[:2]))
candidate_names: list[str] = list(test_envs)
candidate_names.sort(key=lambda name: test_envs[name].get("python") != current_version)
candidate_envs: list[EnvironmentInterface] = []
for candidate_name in candidate_names:
environment = app.project.get_environment(candidate_name)
if environment.exists():
selected_envs.append(candidate_name)
break
candidate_envs.append(environment)
if not selected_envs:
# If none of the candidates exist, then do a check for compatibility
for candidate_env in candidate_envs:
try:
candidate_env.check_compatibility()
except Exception: # noqa: BLE001, S110
pass
else:
selected_envs.append(candidate_env.name)
break
else:
app.abort(f"No compatible environments found: {candidate_envs}")
test_script = "run-cov" if cover else "run"
patched_coverage = PatchedCoverageConfig(app.project.location, app.data_dir / ".config")
coverage_config_file = str(patched_coverage.internal_config_path)
if cover:
patched_coverage.write_config_file()
for context in app.runner_context(selected_envs, ignore_compat=multiple_possible, display_header=multiple_possible):
internal_arguments: list[str] = list(context.env.config.get("extra-args", []))
if not context.env.config.get("randomize", randomize):
internal_arguments.extend(["-p", "no:randomly"])
if context.env.config.get("parallel", parallel):
internal_arguments.extend(["-n", "logical"])
if (num_retries := context.env.config.get("retries", retries)) is not None:
if "-r" not in args:
internal_arguments.extend(["-r", "aR"])
internal_arguments.extend(["--reruns", str(num_retries)])
if (seconds_delay := context.env.config.get("retry-delay", retry_delay)) is not None:
internal_arguments.extend(["--reruns-delay", str(seconds_delay)])
internal_args = context.env.join_command_args(internal_arguments)
if internal_args:
# Add an extra space if required
internal_args = f" {internal_args}"
arguments: list[str] = []
if args:
arguments.extend(args)
else:
arguments.extend(context.env.config.get("default-args", ["tests"]))
context.add_shell_command([test_script, *arguments])
context.env_vars["HATCH_TEST_ARGS"] = internal_args
if cover:
context.env_vars["COVERAGE_RCFILE"] = coverage_config_file
context.env_vars["COVERAGE_PROCESS_START"] = coverage_config_file
if cover:
for context in app.runner_context([selected_envs[0]]):
context.add_shell_command("cov-combine")
if not cover_quiet:
for context in app.runner_context([selected_envs[0]]):
context.add_shell_command("cov-report")
================================================
FILE: src/hatch/cli/test/core.py
================================================
from __future__ import annotations
from functools import cached_property
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from configparser import ConfigParser
from hatch.utils.fs import Path
class PatchedCoverageConfig:
def __init__(self, project_root: Path, data_dir: Path) -> None:
self.project_root = project_root
self.data_dir = data_dir
@cached_property
def user_config_path(self) -> Path:
# https://coverage.readthedocs.io/en/7.4.4/config.html#sample-file
return (
dedicated_coverage_file
if (dedicated_coverage_file := self.project_root.joinpath(".coveragerc")).is_file()
else self.project_root.joinpath("pyproject.toml")
)
@cached_property
def internal_config_path(self) -> Path:
return self.data_dir / "coverage" / self.project_root.id / self.user_config_path.name
def write_config_file(self) -> None:
self.internal_config_path.parent.ensure_dir_exists()
if self.internal_config_path.name == ".coveragerc":
from configparser import ConfigParser
cfg = ConfigParser()
cfg.read(str(self.user_config_path))
if "run" not in cfg:
cfg["run"] = {"parallel": "true"}
self._write_ini(cfg)
return
cfg["run"]["parallel"] = "true"
self._write_ini(cfg)
else:
import tomli_w
from hatch.utils.toml import load_toml_data
project_data = load_toml_data(self.user_config_path.read_text())
project_data.setdefault("tool", {}).setdefault("coverage", {}).setdefault("run", {})["parallel"] = True
self.internal_config_path.write_text(tomli_w.dumps(project_data))
def _write_ini(self, cfg: ConfigParser) -> None:
with self.internal_config_path.open("w", encoding="utf-8") as f:
cfg.write(f)
================================================
FILE: src/hatch/cli/version/__init__.py
================================================
from __future__ import annotations
from typing import TYPE_CHECKING
import click
if TYPE_CHECKING:
from hatch.cli.application import Application
@click.command(short_help="View or set a project's version")
@click.argument("desired_version", required=False)
@click.option(
"--force",
"-f",
is_flag=True,
help="Allow an explicit downgrading version to be given",
)
@click.pass_obj
def version(app: Application, *, desired_version: str | None, force: bool):
"""View or set a project's version."""
if app.project.root is None:
if app.project.chosen_name is None:
app.abort("No project detected")
else:
app.abort(f"Project {app.project.chosen_name} (not a project)")
if "version" in app.project.metadata.config.get("project", {}):
if desired_version:
app.abort("Cannot set version when it is statically defined by the `project.version` field")
else:
app.display(app.project.metadata.config["project"]["version"])
return
from hatch.config.constants import VersionEnvVars
from hatch.project.constants import BUILD_BACKEND
with app.project.location.as_cwd():
if app.project.metadata.build.build_backend != BUILD_BACKEND:
if desired_version:
app.abort("The version can only be set when Hatchling is the build backend")
app.ensure_environment_plugin_dependencies()
app.project.prepare_build_environment()
with app.project.location.as_cwd(), app.project.build_env.get_env_vars():
project_metadata = app.project.build_frontend.get_core_metadata()
app.display(project_metadata["version"])
else:
from hatch.utils.runner import ExecutionContext
app.ensure_environment_plugin_dependencies()
app.project.prepare_build_environment()
context = ExecutionContext(app.project.build_env)
command = ["python", "-u", "-m", "hatchling", "version"]
if desired_version:
command.append(desired_version)
if force:
context.env_vars[VersionEnvVars.VALIDATE_BUMP] = "false"
context.add_shell_command(command)
app.execute_context(context)
================================================
FILE: src/hatch/config/__init__.py
================================================
================================================
FILE: src/hatch/config/constants.py
================================================
class AppEnvVars:
ENV = "HATCH_ENV"
ENV_ACTIVE = "HATCH_ENV_ACTIVE"
ENV_OPTION_PREFIX = "HATCH_ENV_TYPE_"
QUIET = "HATCH_QUIET"
VERBOSE = "HATCH_VERBOSE"
INTERACTIVE = "HATCH_INTERACTIVE"
PYTHON = "HATCH_PYTHON"
# https://no-color.org
NO_COLOR = "NO_COLOR"
FORCE_COLOR = "FORCE_COLOR"
KEEP_ENV = "HATCH_KEEP_ENV"
class ConfigEnvVars:
PROJECT = "HATCH_PROJECT"
DATA = "HATCH_DATA_DIR"
CACHE = "HATCH_CACHE_DIR"
CONFIG = "HATCH_CONFIG"
class PublishEnvVars:
USER = "HATCH_INDEX_USER"
AUTH = "HATCH_INDEX_AUTH"
REPO = "HATCH_INDEX_REPO"
CA_CERT = "HATCH_INDEX_CA_CERT"
CLIENT_CERT = "HATCH_INDEX_CLIENT_CERT"
CLIENT_KEY = "HATCH_INDEX_CLIENT_KEY"
PUBLISHER = "HATCH_PUBLISHER"
OPTIONS = "HATCH_PUBLISHER_OPTIONS"
class PythonEnvVars:
CUSTOM_SOURCE_PREFIX = "HATCH_PYTHON_CUSTOM_SOURCE_"
CUSTOM_PATH_PREFIX = "HATCH_PYTHON_CUSTOM_PATH_"
CUSTOM_VERSION_PREFIX = "HATCH_PYTHON_CUSTOM_VERSION_"
class VersionEnvVars:
VALIDATE_BUMP = "HATCH_VERSION_VALIDATE_BUMP"
================================================
FILE: src/hatch/config/model.py
================================================
import os
FIELD_TO_PARSE = object()
class ConfigurationError(Exception):
def __init__(self, *args, location):
self.location = location
super().__init__(*args)
def __str__(self):
return f"Error parsing config:\n{self.location}\n {super().__str__()}"
def parse_config(obj):
if isinstance(obj, LazilyParsedConfig):
obj.parse_fields()
elif isinstance(obj, list):
for o in obj:
parse_config(o)
elif isinstance(obj, dict):
for o in obj.values():
parse_config(o)
class LazilyParsedConfig:
def __init__(self, config: dict, steps: tuple = ()):
self.raw_data = config
self.steps = steps
def parse_fields(self):
for attribute in self.__dict__:
_, prefix, name = attribute.partition("_field_")
if prefix:
parse_config(getattr(self, name))
def raise_error(self, message, *, extra_steps=()):
import inspect
field = inspect.currentframe().f_back.f_code.co_name
raise ConfigurationError(message, location=" -> ".join([*self.steps, field, *extra_steps]))
class RootConfig(LazilyParsedConfig):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self._field_mode = FIELD_TO_PARSE
self._field_project = FIELD_TO_PARSE
self._field_shell = FIELD_TO_PARSE
self._field_dirs = FIELD_TO_PARSE
self._field_projects = FIELD_TO_PARSE
self._field_publish = FIELD_TO_PARSE
self._field_template = FIELD_TO_PARSE
self._field_terminal = FIELD_TO_PARSE
@property
def mode(self):
if self._field_mode is FIELD_TO_PARSE:
if "mode" in self.raw_data:
mode = self.raw_data["mode"]
if not isinstance(mode, str):
self.raise_error("must be a string")
valid_modes = ("aware", "local", "project")
if mode not in valid_modes:
self.raise_error(f"must be one of: {', '.join(valid_modes)}")
self._field_mode = mode
else:
self._field_mode = self.raw_data["mode"] = "local"
return self._field_mode
@mode.setter
def mode(self, value):
self.raw_data["mode"] = value
self._field_mode = FIELD_TO_PARSE
@property
def project(self):
if self._field_project is FIELD_TO_PARSE:
if "project" in self.raw_data:
project = self.raw_data["project"]
if not isinstance(project, str):
self.raise_error("must be a string")
self._field_project = project
else:
self._field_project = self.raw_data["project"] = ""
return self._field_project
@project.setter
def project(self, value):
self.raw_data["project"] = value
self._field_project = FIELD_TO_PARSE
@property
def shell(self):
if self._field_shell is FIELD_TO_PARSE:
if "shell" in self.raw_data:
shell = self.raw_data["shell"]
if isinstance(shell, str):
self._field_shell = ShellConfig({"name": shell}, ("shell",))
elif isinstance(shell, dict):
self._field_shell = ShellConfig(shell, ("shell",))
else:
self.raise_error("must be a string or table")
else:
self.raw_data["shell"] = ""
self._field_shell = ShellConfig({"name": ""}, ("shell",))
return self._field_shell
@shell.setter
def shell(self, value):
self.raw_data["shell"] = value
self._field_shell = FIELD_TO_PARSE
@property
def dirs(self):
if self._field_dirs is FIELD_TO_PARSE:
if "dirs" in self.raw_data:
dirs = self.raw_data["dirs"]
if not isinstance(dirs, dict):
self.raise_error("must be a table")
self._field_dirs = DirsConfig(dirs, ("dirs",))
else:
dirs = {}
self.raw_data["dirs"] = dirs
self._field_dirs = DirsConfig(dirs, ("dirs",))
return self._field_dirs
@dirs.setter
def dirs(self, value):
self.raw_data["dirs"] = value
self._field_dirs = FIELD_TO_PARSE
@property
def projects(self):
if self._field_projects is FIELD_TO_PARSE:
if "projects" in self.raw_data:
projects = self.raw_data["projects"]
if not isinstance(projects, dict):
self.raise_error("must be a table")
project_data = {}
for name, data in projects.items():
if isinstance(data, str):
project_data[name] = ProjectConfig({"location": data}, ("projects", name))
elif isinstance(data, dict):
project_data[name] = ProjectConfig(data, ("projects", name))
else:
self.raise_error("must be a string or table", extra_steps=(name,))
self._field_projects = project_data
else:
self._field_projects = self.raw_data["projects"] = {}
return self._field_projects
@projects.setter
def projects(self, value):
self.raw_data["projects"] = value
self._field_projects = FIELD_TO_PARSE
@property
def publish(self):
if self._field_publish is FIELD_TO_PARSE:
if "publish" in self.raw_data:
publish = self.raw_data["publish"]
if not isinstance(publish, dict):
self.raise_error("must be a table")
for name, data in publish.items():
if not isinstance(data, dict):
self.raise_error("must be a table", extra_steps=(name,))
self._field_publish = publish
else:
self._field_publish = self.raw_data["publish"] = {"index": {"repo": "main"}}
return self._field_publish
@publish.setter
def publish(self, value):
self.raw_data["publish"] = value
self._field_publish = FIELD_TO_PARSE
@property
def template(self):
if self._field_template is FIELD_TO_PARSE:
if "template" in self.raw_data:
template = self.raw_data["template"]
if not isinstance(template, dict):
self.raise_error("must be a table")
self._field_template = TemplateConfig(template, ("template",))
else:
template = {}
self.raw_data["template"] = template
self._field_template = TemplateConfig(template, ("template",))
return self._field_template
@template.setter
def template(self, value):
self.raw_data["template"] = value
self._field_template = FIELD_TO_PARSE
@property
def terminal(self):
if self._field_terminal is FIELD_TO_PARSE:
if "terminal" in self.raw_data:
terminal = self.raw_data["terminal"]
if not isinstance(terminal, dict):
self.raise_error("must be a table")
self._field_terminal = TerminalConfig(terminal, ("terminal",))
else:
terminal = {}
self.raw_data["terminal"] = terminal
self._field_terminal = TerminalConfig(terminal, ("terminal",))
return self._field_terminal
@terminal.setter
def terminal(self, value):
self.raw_data["terminal"] = value
self._field_terminal = FIELD_TO_PARSE
class ShellConfig(LazilyParsedConfig):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self._field_name = FIELD_TO_PARSE
self._field_path = FIELD_TO_PARSE
self._field_args = FIELD_TO_PARSE
@property
def name(self):
if self._field_name is FIELD_TO_PARSE:
if "name" in self.raw_data:
name = self.raw_data["name"]
if not isinstance(name, str):
self.raise_error("must be a string")
self._field_name = name
else:
self.raise_error("required field")
return self._field_name
@name.setter
def name(self, value):
self.raw_data["name"] = value
self._field_name = FIELD_TO_PARSE
@property
def path(self):
if self._field_path is FIELD_TO_PARSE:
if "path" in self.raw_data:
path = self.raw_data["path"]
if not isinstance(path, str):
self.raise_error("must be a string")
self._field_path = path
else:
self._field_path = self.raw_data["path"] = self.name
return self._field_path
@path.setter
def path(self, value):
self.raw_data["path"] = value
self._field_path = FIELD_TO_PARSE
@property
def args(self):
if self._field_args is FIELD_TO_PARSE:
if "args" in self.raw_data:
args = self.raw_data["args"]
if not isinstance(args, list):
self.raise_error("must be an array")
for i, entry in enumerate(args, 1):
if not isinstance(entry, str):
self.raise_error("must be a string", extra_steps=(str(i),))
self._field_args = args
else:
self._field_args = self.raw_data["args"] = []
return self._field_args
@args.setter
def args(self, value):
self.raw_data["args"] = value
self._field_args = FIELD_TO_PARSE
class DirsConfig(LazilyParsedConfig):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self._field_project = FIELD_TO_PARSE
self._field_env = FIELD_TO_PARSE
self._field_python = FIELD_TO_PARSE
self._field_data = FIELD_TO_PARSE
self._field_cache = FIELD_TO_PARSE
@property
def project(self):
if self._field_project is FIELD_TO_PARSE:
if "project" in self.raw_data:
project = self.raw_data["project"]
if not isinstance(project, list):
self.raise_error("must be an array")
for i, entry in enumerate(project, 1):
if not isinstance(entry, str):
self.raise_error("must be a string", extra_steps=(str(i),))
self._field_project = project
else:
self._field_project = self.raw_data["project"] = []
return self._field_project
@project.setter
def project(self, value):
self.raw_data["project"] = value
self._field_project = FIELD_TO_PARSE
@property
def env(self):
if self._field_env is FIELD_TO_PARSE:
if "env" in self.raw_data:
env = self.raw_data["env"]
if not isinstance(env, dict):
self.raise_error("must be a table")
for key, value in env.items():
if not isinstance(value, str):
self.raise_error("must be a string", extra_steps=(key,))
self._field_env = env
else:
self._field_env = self.raw_data["env"] = {}
return self._field_env
@env.setter
def env(self, value):
self.raw_data["env"] = value
self._field_env = FIELD_TO_PARSE
@property
def python(self):
if self._field_python is FIELD_TO_PARSE:
if "python" in self.raw_data:
python = self.raw_data["python"]
if not isinstance(python, str):
self.raise_error("must be a string")
self._field_python = python
else:
self._field_python = self.raw_data["python"] = "isolated"
return self._field_python
@python.setter
def python(self, value):
self.raw_data["python"] = value
self._field_python = FIELD_TO_PARSE
@property
def data(self):
if self._field_data is FIELD_TO_PARSE:
if "data" in self.raw_data:
data = self.raw_data["data"]
if not isinstance(data, str):
self.raise_error("must be a string")
self._field_data = data
else:
from platformdirs import user_data_dir
self._field_data = self.raw_data["data"] = user_data_dir("hatch", appauthor=False)
return self._field_data
@data.setter
def data(self, value):
self.raw_data["data"] = value
self._field_data = FIELD_TO_PARSE
@property
def cache(self):
if self._field_cache is FIELD_TO_PARSE:
if "cache" in self.raw_data:
cache = self.raw_data["cache"]
if not isinstance(cache, str):
self.raise_error("must be a string")
self._field_cache = cache
else:
from platformdirs import user_cache_dir
self._field_cache = self.raw_data["cache"] = user_cache_dir("hatch", appauthor=False)
return self._field_cache
@cache.setter
def cache(self, value):
self.raw_data["cache"] = value
self._field_cache = FIELD_TO_PARSE
class ProjectConfig(LazilyParsedConfig):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self._field_location = FIELD_TO_PARSE
@property
def location(self):
if self._field_location is FIELD_TO_PARSE:
if "location" in self.raw_data:
location = self.raw_data["location"]
if not isinstance(location, str):
self.raise_error("must be a string")
self._field_location = location
else:
self.raise_error("required field")
return self._field_location
@location.setter
def location(self, value):
self.raw_data["location"] = value
self._field_location = FIELD_TO_PARSE
class TemplateConfig(LazilyParsedConfig):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self._field_name = FIELD_TO_PARSE
self._field_email = FIELD_TO_PARSE
self._field_licenses = FIELD_TO_PARSE
self._field_plugins = FIELD_TO_PARSE
@property
def name(self):
if self._field_name is FIELD_TO_PARSE:
if "name" in self.raw_data:
name = self.raw_data["name"]
if not isinstance(name, str):
self.raise_error("must be a string")
self._field_name = name
else:
name = os.environ.get("GIT_AUTHOR_NAME")
if name is None:
import subprocess
try:
name = subprocess.check_output(
["git", "config", "--get", "user.name"], # noqa: S607
text=True,
).strip()
except Exception: # noqa: BLE001
name = "U.N. Owen"
self._field_name = self.raw_data["name"] = name
return self._field_name
@name.setter
def name(self, value):
self.raw_data["name"] = value
self._field_name = FIELD_TO_PARSE
@property
def email(self):
if self._field_email is FIELD_TO_PARSE:
if "email" in self.raw_data:
email = self.raw_data["email"]
if not isinstance(email, str):
self.raise_error("must be a string")
self._field_email = email
else:
email = os.environ.get("GIT_AUTHOR_EMAIL")
if email is None:
import subprocess
try:
email = subprocess.check_output(
["git", "config", "--get", "user.email"], # noqa: S607
text=True,
).strip()
except Exception: # noqa: BLE001
email = "void@some.where"
self._field_email = self.raw_data["email"] = email
return self._field_email
@email.setter
def email(self, value):
self.raw_data["email"] = value
self._field_email = FIELD_TO_PARSE
@property
def licenses(self):
if self._field_licenses is FIELD_TO_PARSE:
if "licenses" in self.raw_data:
licenses = self.raw_data["licenses"]
if not isinstance(licenses, dict):
self.raise_error("must be a table")
self._field_licenses = LicensesConfig(licenses, (*self.steps, "licenses"))
else:
licenses = {}
self.raw_data["licenses"] = licenses
self._field_licenses = LicensesConfig(licenses, (*self.steps, "licenses"))
return self._field_licenses
@licenses.setter
def licenses(self, value):
self.raw_data["licenses"] = value
self._field_licenses = FIELD_TO_PARSE
@property
def plugins(self):
if self._field_plugins is FIELD_TO_PARSE:
if "plugins" in self.raw_data:
plugins = self.raw_data["plugins"]
if not isinstance(plugins, dict):
self.raise_error("must be a table")
for name, data in plugins.items():
if not isinstance(data, dict):
self.raise_error("must be a table", extra_steps=(name,))
self._field_plugins = plugins
else:
self._field_plugins = self.raw_data["plugins"] = {
"default": {"tests": True, "ci": False, "src-layout": True}
}
return self._field_plugins
@plugins.setter
def plugins(self, value):
self.raw_data["plugins"] = value
self._field_plugins = FIELD_TO_PARSE
class LicensesConfig(LazilyParsedConfig):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self._field_headers = FIELD_TO_PARSE
self._field_default = FIELD_TO_PARSE
@property
def headers(self):
if self._field_headers is FIELD_TO_PARSE:
if "headers" in self.raw_data:
headers = self.raw_data["headers"]
if not isinstance(headers, bool):
self.raise_error("must be a boolean")
self._field_headers = headers
else:
self._field_headers = self.raw_data["headers"] = True
return self._field_headers
@headers.setter
def headers(self, value):
self.raw_data["headers"] = value
self._field_headers = FIELD_TO_PARSE
@property
def default(self):
if self._field_default is FIELD_TO_PARSE:
if "default" in self.raw_data:
default = self.raw_data["default"]
if not isinstance(default, list):
self.raise_error("must be an array")
for i, entry in enumerate(default, 1):
if not isinstance(entry, str):
self.raise_error("must be a string", extra_steps=(str(i),))
self._field_default = default
else:
self._field_default = self.raw_data["default"] = ["MIT"]
return self._field_default
@default.setter
def default(self, value):
self.raw_data["default"] = value
self._field_default = FIELD_TO_PARSE
class TerminalConfig(LazilyParsedConfig):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self._field_styles = FIELD_TO_PARSE
@property
def styles(self):
if self._field_styles is FIELD_TO_PARSE:
if "styles" in self.raw_data:
styles = self.raw_data["styles"]
if not isinstance(styles, dict):
self.raise_error("must be a table")
self._field_styles = StylesConfig(styles, (*self.steps, "styles"))
else:
styles = {}
self.raw_data["styles"] = styles
self._field_styles = StylesConfig(styles, (*self.steps, "styles"))
return self._field_styles
@styles.setter
def styles(self, value):
self.raw_data["styles"] = value
self._field_styles = FIELD_TO_PARSE
class StylesConfig(LazilyParsedConfig):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self._field_info = FIELD_TO_PARSE
self._field_success = FIELD_TO_PARSE
self._field_error = FIELD_TO_PARSE
self._field_warning = FIELD_TO_PARSE
self._field_waiting = FIELD_TO_PARSE
self._field_debug = FIELD_TO_PARSE
self._field_spinner = FIELD_TO_PARSE
@property
def info(self):
if self._field_info is FIELD_TO_PARSE:
if "info" in self.raw_data:
info = self.raw_data["info"]
if not isinstance(info, str):
self.raise_error("must be a string")
self._field_info = info
else:
self._field_info = self.raw_data["info"] = "bold"
return self._field_info
@info.setter
def info(self, value):
self.raw_data["info"] = value
self._field_info = FIELD_TO_PARSE
@property
def success(self):
if self._field_success is FIELD_TO_PARSE:
if "success" in self.raw_data:
success = self.raw_data["success"]
if not isinstance(success, str):
self.raise_error("must be a string")
self._field_success = success
else:
self._field_success = self.raw_data["success"] = "bold cyan"
return self._field_success
@success.setter
def success(self, value):
self.raw_data["success"] = value
self._field_success = FIELD_TO_PARSE
@property
def error(self):
if self._field_error is FIELD_TO_PARSE:
if "error" in self.raw_data:
error = self.raw_data["error"]
if not isinstance(error, str):
self.raise_error("must be a string")
self._field_error = error
else:
self._field_error = self.raw_data["error"] = "bold red"
return self._field_error
@error.setter
def error(self, value):
self.raw_data["error"] = value
self._field_error = FIELD_TO_PARSE
@property
def warning(self):
if self._field_warning is FIELD_TO_PARSE:
if "warning" in self.raw_data:
warning = self.raw_data["warning"]
if not isinstance(warning, str):
self.raise_error("must be a string")
self._field_warning = warning
else:
self._field_warning = self.raw_data["warning"] = "bold yellow"
return self._field_warning
@warning.setter
def warning(self, value):
self.raw_data["warning"] = value
self._field_warning = FIELD_TO_PARSE
@property
def waiting(self):
if self._field_waiting is FIELD_TO_PARSE:
if "waiting" in self.raw_data:
waiting = self.raw_data["waiting"]
if not isinstance(waiting, str):
self.raise_error("must be a string")
self._field_waiting = waiting
else:
self._field_waiting = self.raw_data["waiting"] = "bold magenta"
return self._field_waiting
@waiting.setter
def waiting(self, value):
self.raw_data["waiting"] = value
self._field_waiting = FIELD_TO_PARSE
@property
def debug(self):
if self._field_debug is FIELD_TO_PARSE:
if "debug" in self.raw_data:
debug = self.raw_data["debug"]
if not isinstance(debug, str):
self.raise_error("must be a string")
self._field_debug = debug
else:
self._field_debug = self.raw_data["debug"] = "bold"
return self._field_debug
@debug.setter
def debug(self, value):
self.raw_data["debug"] = value
self._field_debug = FIELD_TO_PARSE
@property
def spinner(self):
if self._field_spinner is FIELD_TO_PARSE:
if "spinner" in self.raw_data:
spinner = self.raw_data["spinner"]
if not isinstance(spinner, str):
self.raise_error("must be a string")
self._field_spinner = spinner
else:
self._field_spinner = self.raw_data["spinner"] = "simpleDotsScrolling"
return self._field_spinner
@spinner.setter
def spinner(self, value):
self.raw_data["spinner"] = value
self._field_spinner = FIELD_TO_PARSE
================================================
FILE: src/hatch/config/user.py
================================================
from __future__ import annotations
from typing import cast
from hatch.config.model import RootConfig
from hatch.utils.fs import Path
from hatch.utils.toml import load_toml_data
class ConfigFile:
def __init__(self, path: Path | None = None):
self._path: Path | None = path
self.model = cast(RootConfig, None)
@property
def path(self):
if self._path is None:
self._path = self.get_default_location()
return self._path
@path.setter
def path(self, value):
self._path = value
def save(self, content=None):
import tomli_w
if not content:
content = tomli_w.dumps(self.model.raw_data)
self.path.ensure_parent_dir_exists()
self.path.write_atomic(content, "w", encoding="utf-8")
def load(self):
self.model = RootConfig(load_toml_data(self.read()))
def read(self) -> str:
return self.path.read_text("utf-8")
def read_scrubbed(self) -> str:
import tomli_w
config = RootConfig(load_toml_data(self.read()))
config.raw_data.pop("publish", None)
return tomli_w.dumps(config.raw_data)
def restore(self):
import tomli_w
config = RootConfig({})
config.parse_fields()
content = tomli_w.dumps(config.raw_data)
self.save(content)
self.model = config
def update(self): # no cov
self.model.parse_fields()
self.save()
@classmethod
def get_default_location(cls) -> Path:
from platformdirs import user_config_dir
return Path(user_config_dir("hatch", appauthor=False)) / "config.toml"
================================================
FILE: src/hatch/config/utils.py
================================================
from __future__ import annotations
from typing import TYPE_CHECKING
import tomlkit
if TYPE_CHECKING:
from tomlkit.items import InlineTable
from tomlkit.toml_document import TOMLDocument
from hatch.utils.fs import Path
def save_toml_document(document: TOMLDocument, path: Path):
path.ensure_parent_dir_exists()
path.write_atomic(tomlkit.dumps(document), "w", encoding="utf-8")
def create_toml_document(config: dict) -> InlineTable:
return tomlkit.item(config)
================================================
FILE: src/hatch/dep/__init__.py
================================================
================================================
FILE: src/hatch/dep/core.py
================================================
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:
from urllib.parse import unquote
if self.url is None:
return None
import hyperlink
uri = hyperlink.parse(self.url)
if uri.scheme != "file":
return None
decoded_url = unquote(self.url)
return Path.from_uri(decoded_url)
================================================
FILE: src/hatch/dep/sync.py
================================================
from __future__ import annotations
import re
import sys
from importlib.metadata import Distribution, DistributionFinder
from packaging.markers import default_environment
from hatch.dep.core import Dependency
from hatch.utils.fs import Path
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"[-_.]+")
def dependencies_in_sync(self, dependencies: list[Dependency]) -> bool:
return all(self.dependency_in_sync(dependency) for dependency in dependencies)
def missing_dependencies(self, dependencies: list[Dependency]) -> list[Dependency]:
return [dependency for dependency in dependencies if not self.dependency_in_sync(dependency)]
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
distribution = self[dependency.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
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 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
if dependency.specifier and not dependency.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
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:
vcs_info = direct_url_data["vcs_info"]
vcs = vcs_info["vcs"]
commit_id = vcs_info["commit_id"]
requested_revision = vcs_info.get("requested_revision")
# Try a few variations, see https://peps.python.org/pep-0440/#direct-references
if (
requested_revision and dependency.url == f"{vcs}+{url}@{requested_revision}#{commit_id}"
) or dependency.url == f"{vcs}+{url}@{commit_id}":
return True
if dependency.url in {f"{vcs}+{url}", f"{vcs}+{url}@{requested_revision}"}:
import subprocess
if vcs == "git":
vcs_cmd = [vcs, "ls-remote", url]
if requested_revision:
vcs_cmd.append(requested_revision)
# TODO: add elifs for hg, svn, and bzr https://github.com/pypa/hatch/issues/760
else:
return False
result = subprocess.run(vcs_cmd, capture_output=True, text=True) # noqa: PLW1510
if result.returncode or not result.stdout.strip():
return False
latest_commit_id, *_ = result.stdout.split()
return commit_id == latest_commit_id
return False
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(
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)
================================================
FILE: src/hatch/env/__init__.py
================================================
================================================
FILE: src/hatch/env/collectors/__init__.py
================================================
================================================
FILE: src/hatch/env/collectors/custom.py
================================================
from __future__ import annotations
import os
from typing import Any
from hatch.env.collectors.plugin.interface import EnvironmentCollectorInterface
from hatch.plugin.constants import DEFAULT_CUSTOM_SCRIPT
from hatch.plugin.utils import load_plugin_from_script
class CustomEnvironmentCollector:
PLUGIN_NAME = "custom"
def __new__( # type: ignore[misc]
cls,
root: str,
config: dict[str, Any],
*args: Any,
**kwargs: Any,
) -> EnvironmentCollectorInterface:
custom_script = config.get("path", DEFAULT_CUSTOM_SCRIPT)
if not isinstance(custom_script, str):
message = f"Option `path` for environment collector `{cls.PLUGIN_NAME}` must be a string"
raise TypeError(message)
if not custom_script:
message = f"Option `path` for environment collector `{cls.PLUGIN_NAME}` must not be empty if defined"
raise ValueError(message)
path = os.path.normpath(os.path.join(root, custom_script))
if not os.path.isfile(path):
message = f"Plugin script does not exist: {custom_script}"
raise OSError(message)
hook_class = load_plugin_from_script(
path, custom_script, EnvironmentCollectorInterface, "environment_collector"
)
hook = hook_class(root, config, *args, **kwargs)
# Always keep the name to avoid confusion
hook.PLUGIN_NAME = cls.PLUGIN_NAME
return hook
================================================
FILE: src/hatch/env/collectors/default.py
================================================
from hatch.env.collectors.plugin.interface import EnvironmentCollectorInterface
from hatch.env.internal import get_internal_env_config
from hatch.env.utils import ensure_valid_environment
class DefaultEnvironmentCollector(EnvironmentCollectorInterface):
PLUGIN_NAME = "default"
def get_initial_config(self): # noqa: PLR6301
default_config = {}
ensure_valid_environment(default_config)
return {"default": default_config, **get_internal_env_config()}
================================================
FILE: src/hatch/env/collectors/plugin/__init__.py
================================================
================================================
FILE: src/hatch/env/collectors/plugin/hooks.py
================================================
from __future__ import annotations
from typing import TYPE_CHECKING
from hatch.env.collectors.custom import CustomEnvironmentCollector
from hatch.env.collectors.default import DefaultEnvironmentCollector
from hatchling.plugin import hookimpl
if TYPE_CHECKING:
from hatch.env.collectors.plugin.interface import EnvironmentCollectorInterface
@hookimpl
def hatch_register_environment_collector() -> list[type[EnvironmentCollectorInterface]]:
return [CustomEnvironmentCollector, DefaultEnvironmentCollector]
================================================
FILE: src/hatch/env/collectors/plugin/interface.py
================================================
from __future__ import annotations
class EnvironmentCollectorInterface:
"""
Example usage:
```python tab="plugin.py"
from hatch.env.collectors.plugin.interface import EnvironmentCollectorInterface
class SpecialEnvironmentCollector(EnvironmentCollectorInterface):
PLUGIN_NAME = 'special'
...
```
```python tab="hooks.py"
from hatchling.plugin import hookimpl
from .plugin import SpecialEnvironmentCollector
@hookimpl
def hatch_register_environment_collector():
return SpecialEnvironmentCollector
```
"""
PLUGIN_NAME = ""
"""The name used for selection."""
def __init__(self, root, config):
self.__root = root
self.__config = config
@property
def root(self):
"""
The root of the project tree as a path-like object.
"""
return self.__root
@property
def config(self) -> dict:
"""
```toml config-example
[tool.hatch.env.collectors.]
```
"""
return self.__config
def get_initial_config(self) -> dict[str, dict]: # noqa: PLR6301
"""
Returns configuration for environments keyed by the environment or matrix name.
"""
return {}
def finalize_config(self, config: dict[str, dict]):
"""
Finalizes configuration for environments keyed by the environment or matrix name. This will override
any user-defined settings and any collectors that ran before this call.
This is called before matrices are turned into concrete environments.
"""
def finalize_environments(self, config: dict[str, dict]):
"""
Finalizes configuration for environments keyed by the environment name. This will override
any user-defined settings and any collectors that ran before this call.
This is called after matrices are turned into concrete environments.
"""
================================================
FILE: src/hatch/env/context.py
================================================
from abc import ABC, abstractmethod
from hatch.env.utils import get_verbosity_flag
from hatchling.utils.context import ContextFormatter
class EnvironmentContextFormatterBase(ContextFormatter, ABC):
@abstractmethod
def formatters(self):
return {}
class EnvironmentContextFormatter(EnvironmentContextFormatterBase):
def __init__(self, environment):
self.environment = environment
self.CONTEXT_NAME = f"environment_{environment.PLUGIN_NAME}"
def formatters(self): # noqa: PLR6301
"""
This returns a mapping of supported field names to their respective formatting functions. Each function
accepts 2 arguments:
- the `value` that was passed to the format call, defaulting to `None`
- the modifier `data`, defaulting to an empty string
"""
return {}
def get_formatters(self):
formatters = {
"args": self.__format_args,
"env_name": self.__format_env_name,
"env_type": self.__format_env_type,
"matrix": self.__format_matrix,
"verbosity": self.__format_verbosity,
}
formatters.update(self.formatters())
return formatters
def __format_args(self, value, data): # noqa: PLR6301
if value is not None:
return value
return data or ""
def __format_env_name(self, value, data): # noqa: ARG002
return self.environment.name
def __format_env_type(self, value, data): # noqa: ARG002
return self.environment.PLUGIN_NAME
def __format_matrix(self, value, data): # noqa: ARG002
if not data:
message = "The `matrix` context formatting field requires a modifier"
raise ValueError(message)
variable, separator, default = data.partition(":")
if variable in self.environment.matrix_variables:
return self.environment.matrix_variables[variable]
if not separator:
message = f"Nonexistent matrix variable must set a default: {variable}"
raise ValueError(message)
return default
def __format_verbosity(self, value, data): # noqa: ARG002
if not data:
return str(self.environment.verbosity)
modifier, _, adjustment = data.partition(":")
if modifier != "flag":
message = f"Unknown verbosity modifier: {modifier}"
raise ValueError(message)
if not adjustment:
adjustment = "0"
try:
adjustment = int(adjustment)
except ValueError:
message = f"Verbosity flag adjustment must be an integer: {adjustment}"
raise TypeError(message) from None
return get_verbosity_flag(self.environment.verbosity, adjustment=adjustment)
================================================
FILE: src/hatch/env/internal/__init__.py
================================================
from __future__ import annotations
from typing import Any
from hatch.env.utils import ensure_valid_environment
def get_internal_env_config() -> dict[str, Any]:
from hatch.env.internal import build, static_analysis, test, uv
internal_config = {}
for env_name, env_config in (
("hatch-build", build.get_default_config()),
("hatch-static-analysis", static_analysis.get_default_config()),
("hatch-test", test.get_default_config()),
("hatch-uv", uv.get_default_config()),
):
env_config["template"] = env_name
ensure_valid_environment(env_config)
internal_config[env_name] = env_config
return internal_config
def is_isolated_environment(env_name: str, config: dict[str, Any]) -> bool:
# Provide super isolation and immunity to project-level environment removal only when the environment:
#
# 1. Is not used for builds
# 2. Does not require the project being installed
# 3. The default configuration is used
#
# For example, the environment for static analysis depends only on Ruff at a specific default
# version. This environment does not require the project and can be reused by every project to
# improve responsiveness. However, if the user for some reason chooses to override the dependencies
# to use a different version of Ruff, then the project would get its own environment.
return (
not config.get("builder", False)
and config.get("skip-install", False)
and is_default_environment(env_name, config)
)
def is_default_environment(env_name: str, config: dict[str, Any]) -> bool:
# Standalone environment
internal_config = get_internal_env_config().get(env_name)
if not internal_config:
# Environment generated from matrix
internal_config = get_internal_env_config().get(env_name.split(".")[0])
if not internal_config:
return False
# Only consider things that would modify the actual installation, other options like extra scripts don't matter
for key in ("dependencies", "extra-dependencies", "features"):
if config.get(key) != internal_config.get(key):
return False
return True
================================================
FILE: src/hatch/env/internal/build.py
================================================
from __future__ import annotations
from typing import Any
def get_default_config() -> dict[str, Any]:
return {
"skip-install": True,
"builder": True,
"installer": "uv",
}
================================================
FILE: src/hatch/env/internal/static_analysis.py
================================================
from __future__ import annotations
from typing import Any
def get_default_config() -> dict[str, Any]:
return {
"skip-install": True,
"installer": "uv",
"dependencies": [f"ruff=={RUFF_DEFAULT_VERSION}"],
"scripts": {
"format-check": "ruff format{env:HATCH_FMT_ARGS:} --check --diff {args:.}",
"format-fix": "ruff format{env:HATCH_FMT_ARGS:} {args:.}",
"lint-check": "ruff check{env:HATCH_FMT_ARGS:} {args:.}",
"lint-fix": "ruff check{env:HATCH_FMT_ARGS:} --fix {args:.}",
},
}
RUFF_DEFAULT_VERSION: str = "0.13.2"
================================================
FILE: src/hatch/env/internal/test.py
================================================
from __future__ import annotations
from typing import Any
def get_default_config() -> dict[str, Any]:
return {
"installer": "uv",
"dependencies": [
"coverage-enable-subprocess==1.0",
"coverage[toml]~=7.11",
"pytest~=9.0",
"pytest-mock~=3.12",
"pytest-randomly~=3.15",
"pytest-rerunfailures~=14.0",
"pytest-xdist[psutil]~=3.5",
],
"scripts": {
"run": "pytest{env:HATCH_TEST_ARGS:} {args}",
"run-cov": "coverage run -m pytest{env:HATCH_TEST_ARGS:} {args}",
"cov-combine": "coverage combine",
"cov-report": "coverage report",
},
"matrix": [{"python": ["3.14", "3.14t", "3.13", "3.12", "3.11", "3.10"]}],
}
================================================
FILE: src/hatch/env/internal/uv.py
================================================
from __future__ import annotations
from typing import Any
def get_default_config() -> dict[str, Any]:
return {
"skip-install": True,
"installer": "uv",
}
================================================
FILE: src/hatch/env/plugin/__init__.py
================================================
================================================
FILE: src/hatch/env/plugin/hooks.py
================================================
from hatch.env.system import SystemEnvironment
from hatch.env.virtual import VirtualEnvironment
from hatchling.plugin import hookimpl
@hookimpl
def hatch_register_environment():
return [SystemEnvironment, VirtualEnvironment]
================================================
FILE: src/hatch/env/plugin/interface.py
================================================
from __future__ import annotations
import os
import sys
from abc import ABC, abstractmethod
from contextlib import contextmanager
from functools import cached_property
from os.path import isabs
from typing import TYPE_CHECKING, Any
from hatch.config.constants import AppEnvVars
from hatch.env.utils import add_verbosity_flag, get_env_var_option
from hatch.project.utils import format_script_commands, parse_script_command
from hatch.utils.structures import EnvVars
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
class EnvironmentInterface(ABC):
"""
Example usage:
```python tab="plugin.py"
from hatch.env.plugin.interface import EnvironmentInterface
class SpecialEnvironment(EnvironmentInterface):
PLUGIN_NAME = "special"
...
```
```python tab="hooks.py"
from hatchling.plugin import hookimpl
from .plugin import SpecialEnvironment
@hookimpl
def hatch_register_environment():
return SpecialEnvironment
```
"""
PLUGIN_NAME = ""
"""The name used for selection."""
def __init__(
self,
root,
metadata,
name,
config,
matrix_variables,
data_directory,
isolated_data_directory,
platform,
verbosity,
app,
):
self.__root = root
self.__metadata = metadata
self.__name = name
self.__config = config
self.__matrix_variables = matrix_variables
self.__data_directory = data_directory
self.__isolated_data_directory = isolated_data_directory
self.__platform = platform
self.__verbosity = verbosity
self.__app = app
self.additional_dependencies = []
@property
def matrix_variables(self):
return self.__matrix_variables
@property
def app(self):
"""
An instance of [Application](../utilities.md#hatchling.bridge.app.Application).
"""
return self.__app
@cached_property
def context(self):
return self.get_context()
@property
def verbosity(self):
return self.__verbosity
@property
def root(self):
"""
The root of the local project tree as a path-like object.
"""
return self.__root
@property
def metadata(self):
return self.__metadata
@property
def name(self) -> str:
"""
The name of the environment.
"""
return self.__name
@property
def platform(self):
"""
An instance of [Platform](../utilities.md#hatch.utils.platform.Platform).
"""
return self.__platform
@property
def data_directory(self):
"""
The [directory](../../config/hatch.md#environments) this plugin should use for storage as a path-like object.
If the user has not configured one then this will be the same as the
[isolated data directory](reference.md#hatch.env.plugin.interface.EnvironmentInterface.isolated_data_directory).
"""
return self.__data_directory
@property
def isolated_data_directory(self):
"""
The default [directory](../../config/hatch.md#environments) reserved exclusively for this plugin as a path-like
object.
"""
return self.__isolated_data_directory
@property
def config(self) -> dict:
"""
```toml config-example
[tool.hatch.envs.]
```
"""
return self.__config
@cached_property
def project_root(self) -> str:
"""
The root of the project tree as a string. If the environment is not running locally,
this should be the remote path to the project.
"""
return str(self.root)
@cached_property
def sep(self) -> str:
"""
The character used to separate directories in paths. By default, this is `\\` on Windows and `/` otherwise.
"""
return os.sep
@cached_property
def pathsep(self) -> str:
"""
The character used to separate paths. By default, this is `;` on Windows and `:` otherwise.
"""
return os.pathsep
@cached_property
def system_python(self) -> str:
system_python = os.environ.get(AppEnvVars.PYTHON)
if system_python == "self":
system_python = sys.executable
system_python = (
system_python
or self.platform.modules.shutil.which("python")
or self.platform.modules.shutil.which("python3")
or sys.executable
)
if not isabs(system_python):
system_python = self.platform.modules.shutil.which(system_python)
return system_python
@cached_property
def env_vars(self) -> dict[str, str]:
"""
```toml config-example
[tool.hatch.envs..env-vars]
```
"""
env_vars = self.config.get("env-vars", {})
if not isinstance(env_vars, dict):
message = f"Field `tool.hatch.envs.{self.name}.env-vars` must be a mapping"
raise TypeError(message)
for key, value in env_vars.items():
if not isinstance(value, str):
message = (
f"Environment variable `{key}` of field `tool.hatch.envs.{self.name}.env-vars` must be a string"
)
raise TypeError(message)
new_env_vars = {}
with self.metadata.context.apply_context(self.context):
for key, value in env_vars.items():
new_env_vars[key] = self.metadata.context.format(value)
new_env_vars[AppEnvVars.ENV_ACTIVE] = self.name
return new_env_vars
@cached_property
def env_include(self) -> list[str]:
"""
```toml config-example
[tool.hatch.envs.]
env-include = [...]
```
"""
env_include = self.config.get("env-include", [])
if not isinstance(env_include, list):
message = f"Field `tool.hatch.envs.{self.name}.env-include` must be an array"
raise TypeError(message)
for i, pattern in enumerate(env_include, 1):
if not isinstance(pattern, str):
message = f"Pattern #{i} of field `tool.hatch.envs.{self.name}.env-include` must be a string"
raise TypeError(message)
return ["HATCH_BUILD_*", *env_include] if env_include else env_include
@cached_property
def env_exclude(self) -> list[str]:
"""
```toml config-example
[tool.hatch.envs.]
env-exclude = [...]
```
"""
env_exclude = self.config.get("env-exclude", [])
if not isinstance(env_exclude, list):
message = f"Field `tool.hatch.envs.{self.name}.env-exclude` must be an array"
raise TypeError(message)
for i, pattern in enumerate(env_exclude, 1):
if not isinstance(pattern, str):
message = f"Pattern #{i} of field `tool.hatch.envs.{self.name}.env-exclude` must be a string"
raise TypeError(message)
return env_exclude
@cached_property
def environment_dependencies_complex(self) -> list[Dependency]:
from hatch.dep.core import Dependency, InvalidDependencyError
dependencies_complex: list[Dependency] = []
with self.apply_context():
for option in ("dependencies", "extra-dependencies"):
dependencies = self.config.get(option, [])
if not isinstance(dependencies, list):
message = f"Field `tool.hatch.envs.{self.name}.{option}` must be an array"
raise TypeError(message)
for i, entry in enumerate(dependencies, 1):
if not isinstance(entry, str):
message = f"Dependency #{i} of field `tool.hatch.envs.{self.name}.{option}` must be a string"
raise TypeError(message)
try:
dependencies_complex.append(Dependency(self.metadata.context.format(entry)))
except InvalidDependencyError as e:
message = f"Dependency #{i} of field `tool.hatch.envs.{self.name}.{option}` is invalid: {e}"
raise ValueError(message) from None
return dependencies_complex
@cached_property
def environment_dependencies(self) -> list[str]:
"""
The list of all [environment dependencies](../../config/environment/overview.md#dependencies).
"""
return [str(dependency) for dependency in self.environment_dependencies_complex]
@cached_property
def project_dependencies_complex(self) -> list[Dependency]:
workspace_dependencies = self.workspace.get_dependencies()
if self.skip_install and not self.features and not self.dependency_groups and not workspace_dependencies:
return []
from hatch.dep.core import Dependency
from hatch.utils.dep import get_complex_dependencies, get_complex_dependency_group, get_complex_features
all_dependencies_complex = list(map(Dependency, workspace_dependencies))
dependencies, optional_dependencies = self.app.project.get_dependencies()
# Format dependencies with context before creating Dependency objects
with self.apply_context():
formatted_dependencies = [self.metadata.context.format(dep) for dep in dependencies]
formatted_optional_dependencies = {
feature: [self.metadata.context.format(dep) for dep in deps]
for feature, deps in optional_dependencies.items()
}
dependencies_complex = get_complex_dependencies(formatted_dependencies)
optional_dependencies_complex = get_complex_features(formatted_optional_dependencies)
if not self.skip_install:
all_dependencies_complex.extend(dependencies_complex.values())
for feature in self.features:
if feature not in optional_dependencies_complex:
message = (
f"Feature `{feature}` of field `tool.hatch.envs.{self.name}.features` is not "
f"defined in the dynamic field `project.optional-dependencies`"
)
raise ValueError(message)
all_dependencies_complex.extend([
dep if isinstance(dep, Dependency) else Dependency(str(dep))
for dep in optional_dependencies_complex[feature]
])
for dependency_group in self.dependency_groups:
all_dependencies_complex.extend(
get_complex_dependency_group(self.app.project.dependency_groups, dependency_group)
)
return all_dependencies_complex
@cached_property
def project_dependencies(self) -> list[str]:
"""
The list of all [project dependencies](../../config/metadata.md#dependencies) (if
[installed](../../config/environment/overview.md#skip-install)), selected
[optional dependencies](../../config/environment/overview.md#features), and
workspace dependencies.
"""
return [str(dependency) for dependency in self.project_dependencies_complex]
@cached_property
def local_dependencies_complex(self) -> list[Dependency]:
from hatch.dep.core import Dependency
local_dependencies_complex = []
if not self.skip_install:
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.dependency_groups and not self.skip_install:
from hatch.utils.dep import get_complex_dependency_group
for dependency_group in self.dependency_groups:
all_dependencies_complex.extend(
get_complex_dependency_group(self.app.project.dependency_groups, dependency_group)
)
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 or self.features or self.dependency_groups:
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)
workspace_names = {dep.name.lower() for dep in local_deps}
filtered_deps: list[Dependency] = []
for dep in self.dependencies_complex:
dep_obj = dep if isinstance(dep, Dependency) else Dependency(str(dep))
if dep_obj.name.lower() in workspace_names and dep_obj.extras:
# Only expand if we have static optional dependencies to avoid recursion
if not self.metadata.hatch.metadata.hook_config:
optional_dependencies = self.metadata.core.optional_dependencies
for extra in dep_obj.extras:
if extra in optional_dependencies:
filtered_deps.extend(Dependency(d) for d in optional_dependencies[extra])
elif dep_obj.name.lower() not in workspace_names:
filtered_deps.append(dep_obj)
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]:
"""
All names are stored as their lower-cased version.
```toml config-example
[tool.hatch.envs.]
platforms = [...]
```
"""
platforms = self.config.get("platforms", [])
if not isinstance(platforms, list):
message = f"Field `tool.hatch.envs.{self.name}.platforms` must be an array"
raise TypeError(message)
for i, command in enumerate(platforms, 1):
if not isinstance(command, str):
message = f"Platform #{i} of field `tool.hatch.envs.{self.name}.platforms` must be a string"
raise TypeError(message)
return [platform.lower() for platform in platforms]
@cached_property
def skip_install(self) -> bool:
"""
```toml config-example
[tool.hatch.envs.]
skip-install = ...
```
"""
skip_install = self.config.get("skip-install", not self.metadata.has_project_file())
if not isinstance(skip_install, bool):
message = f"Field `tool.hatch.envs.{self.name}.skip-install` must be a boolean"
raise TypeError(message)
return skip_install
@cached_property
def dev_mode(self) -> bool:
"""
```toml config-example
[tool.hatch.envs.]
dev-mode = ...
```
"""
dev_mode = self.config.get("dev-mode", True)
if not isinstance(dev_mode, bool):
message = f"Field `tool.hatch.envs.{self.name}.dev-mode` must be a boolean"
raise TypeError(message)
return dev_mode
@cached_property
def builder(self) -> bool:
"""
```toml config-example
[tool.hatch.envs.]
builder = ...
```
"""
builder = self.config.get("builder", False)
if not isinstance(builder, bool):
message = f"Field `tool.hatch.envs.{self.name}.builder` must be a boolean"
raise TypeError(message)
return builder
@cached_property
def features(self):
from hatch.utils.metadata import normalize_project_name
features = self.config.get("features", [])
if not isinstance(features, list):
message = f"Field `tool.hatch.envs.{self.name}.features` must be an array of strings"
raise TypeError(message)
all_features = set()
for i, feature in enumerate(features, 1):
if not isinstance(feature, str):
message = f"Feature #{i} of field `tool.hatch.envs.{self.name}.features` must be a string"
raise TypeError(message)
if not feature:
message = f"Feature #{i} of field `tool.hatch.envs.{self.name}.features` cannot be an empty string"
raise ValueError(message)
normalized_feature = (
feature if self.metadata.hatch.metadata.allow_ambiguous_features else normalize_project_name(feature)
)
if (
not self.metadata.hatch.metadata.hook_config
and normalized_feature not in self.metadata.core.optional_dependencies
):
message = (
f"Feature `{normalized_feature}` of field `tool.hatch.envs.{self.name}.features` is not "
f"defined in field `project.optional-dependencies`"
)
raise ValueError(message)
all_features.add(normalized_feature)
return sorted(all_features)
@cached_property
def dependency_groups(self):
from hatch.utils.metadata import normalize_project_name
dependency_groups = self.config.get("dependency-groups", [])
if not isinstance(dependency_groups, list):
message = f"Field `tool.hatch.envs.{self.name}.dependency-groups` must be an array of strings"
raise TypeError(message)
all_dependency_groups = set()
for i, dependency_group in enumerate(dependency_groups, 1):
if not isinstance(dependency_group, str):
message = (
f"Dependency Group #{i} of field `tool.hatch.envs.{self.name}.dependency-groups` must be a string"
)
raise TypeError(message)
if not dependency_group:
message = f"Dependency Group #{i} of field `tool.hatch.envs.{self.name}.dependency-groups` cannot be an empty string"
raise ValueError(message)
normalized_dependency_group = normalize_project_name(dependency_group)
if (
not self.metadata.hatch.metadata.hook_config
and normalized_dependency_group not in self.app.project.dependency_groups
):
message = (
f"Dependency Group `{normalized_dependency_group}` of field `tool.hatch.envs.{self.name}.dependency-groups` is not "
f"defined in field `dependency-groups`"
)
raise ValueError(message)
all_dependency_groups.add(normalized_dependency_group)
return sorted(all_dependency_groups)
@cached_property
def description(self) -> str:
"""
```toml config-example
[tool.hatch.envs.]
description = ...
```
"""
description = self.config.get("description", "")
if not isinstance(description, str):
message = f"Field `tool.hatch.envs.{self.name}.description` must be a string"
raise TypeError(message)
return description
@cached_property
def scripts(self):
config = {}
# Extra scripts should come first to give less precedence
for field in ("extra-scripts", "scripts"):
script_config = self.config.get(field, {})
if not isinstance(script_config, dict):
message = f"Field `tool.hatch.envs.{self.name}.{field}` must be a table"
raise TypeError(message)
for name, data in script_config.items():
if " " in name:
message = (
f"Script name `{name}` in field `tool.hatch.envs.{self.name}.{field}` must not contain spaces"
)
raise ValueError(message)
commands = []
if isinstance(data, str):
commands.append(data)
elif isinstance(data, list):
for i, command in enumerate(data, 1):
if not isinstance(command, str):
message = (
f"Command #{i} in field `tool.hatch.envs.{self.name}.{field}.{name}` must be a string"
)
raise TypeError(message)
commands.append(command)
else:
message = (
f"Field `tool.hatch.envs.{self.name}.{field}.{name}` must be a string or an array of strings"
)
raise TypeError(message)
config[name] = commands
seen = {}
active = []
for script_name, commands in config.items():
commands[:] = expand_script_commands(self.name, script_name, commands, config, seen, active)
return config
@cached_property
def pre_install_commands(self):
pre_install_commands = self.config.get("pre-install-commands", [])
if not isinstance(pre_install_commands, list):
message = f"Field `tool.hatch.envs.{self.name}.pre-install-commands` must be an array"
raise TypeError(message)
for i, command in enumerate(pre_install_commands, 1):
if not isinstance(command, str):
message = f"Command #{i} of field `tool.hatch.envs.{self.name}.pre-install-commands` must be a string"
raise TypeError(message)
return list(pre_install_commands)
@cached_property
def post_install_commands(self):
post_install_commands = self.config.get("post-install-commands", [])
if not isinstance(post_install_commands, list):
message = f"Field `tool.hatch.envs.{self.name}.post-install-commands` must be an array"
raise TypeError(message)
for i, command in enumerate(post_install_commands, 1):
if not isinstance(command, str):
message = f"Command #{i} of field `tool.hatch.envs.{self.name}.post-install-commands` must be a string"
raise TypeError(message)
return list(post_install_commands)
@cached_property
def workspace(self) -> Workspace:
env_config = self.config.get("workspace", {})
if not isinstance(env_config, dict):
message = f"Field `tool.hatch.envs.{self.name}.workspace` must be a table"
raise TypeError(message)
return Workspace(self, env_config)
def activate(self):
"""
A convenience method called when using the environment as a context manager:
```python
with environment:
...
```
"""
def deactivate(self):
"""
A convenience method called after using the environment as a context manager:
```python
with environment:
...
```
"""
@abstractmethod
def find(self):
"""
:material-align-horizontal-left: **REQUIRED** :material-align-horizontal-right:
This should return information about how to locate the environment or represent its ID in
some way. Additionally, this is expected to return something even if the environment is
[incompatible](reference.md#hatch.env.plugin.interface.EnvironmentInterface.check_compatibility).
"""
@abstractmethod
def create(self):
"""
:material-align-horizontal-left: **REQUIRED** :material-align-horizontal-right:
This should perform the necessary steps to set up the environment.
"""
@abstractmethod
def remove(self):
"""
:material-align-horizontal-left: **REQUIRED** :material-align-horizontal-right:
This should perform the necessary steps to completely remove the environment from the system and will only
be triggered manually by users with the [`env remove`](../../cli/reference.md#hatch-env-remove) or
[`env prune`](../../cli/reference.md#hatch-env-prune) commands.
"""
@abstractmethod
def exists(self) -> bool:
"""
:material-align-horizontal-left: **REQUIRED** :material-align-horizontal-right:
This should indicate whether or not the environment has already been created.
"""
@abstractmethod
def install_project(self):
"""
:material-align-horizontal-left: **REQUIRED** :material-align-horizontal-right:
This should install the project in the environment.
"""
@abstractmethod
def install_project_dev_mode(self):
"""
:material-align-horizontal-left: **REQUIRED** :material-align-horizontal-right:
This should install the project in the environment such that the environment
always reflects the current state of the project.
"""
@abstractmethod
def dependencies_in_sync(self) -> bool:
"""
:material-align-horizontal-left: **REQUIRED** :material-align-horizontal-right:
This should indicate whether or not the environment is compatible with the current
[dependencies](reference.md#hatch.env.plugin.interface.EnvironmentInterface.dependencies).
"""
@abstractmethod
def sync_dependencies(self):
"""
:material-align-horizontal-left: **REQUIRED** :material-align-horizontal-right:
This should install the
[dependencies](reference.md#hatch.env.plugin.interface.EnvironmentInterface.dependencies)
in the environment.
"""
def dependency_hash(self):
"""
This should return a hash of the environment's
[dependencies](reference.md#hatch.env.plugin.interface.EnvironmentInterface.dependencies)
and any other data that is handled by the
[sync_dependencies](reference.md#hatch.env.plugin.interface.EnvironmentInterface.sync_dependencies)
and
[dependencies_in_sync](reference.md#hatch.env.plugin.interface.EnvironmentInterface.dependencies_in_sync)
methods.
"""
from hatch.utils.dep import hash_dependencies
return hash_dependencies(self.all_dependencies_complex)
@contextmanager
def app_status_creation(self):
"""
See the [life cycle of environments](reference.md#life-cycle).
"""
with self.app.status(f"Creating environment: {self.name}"):
yield
@contextmanager
def app_status_pre_installation(self):
"""
See the [life cycle of environments](reference.md#life-cycle).
"""
with self.app.status("Running pre-installation commands"):
yield
@contextmanager
def app_status_post_installation(self):
"""
See the [life cycle of environments](reference.md#life-cycle).
"""
with self.app.status("Running post-installation commands"):
yield
@contextmanager
def app_status_project_installation(self):
"""
See the [life cycle of environments](reference.md#life-cycle).
"""
if self.dev_mode:
with self.app.status("Installing project in development mode"):
yield
else:
with self.app.status("Installing project"):
yield
@contextmanager
def app_status_dependency_state_check(self):
"""
See the [life cycle of environments](reference.md#life-cycle).
"""
if not self.skip_install and (
"dependencies" in self.metadata.dynamic or "optional-dependencies" in self.metadata.dynamic
):
with self.app.status("Polling dependency state"):
yield
else:
yield
@contextmanager
def app_status_dependency_installation_check(self):
"""
See the [life cycle of environments](reference.md#life-cycle).
"""
with self.app.status("Checking dependencies"):
yield
@contextmanager
def app_status_dependency_synchronization(self):
"""
See the [life cycle of environments](reference.md#life-cycle).
"""
with self.app.status("Syncing dependencies"):
yield
@contextmanager
def fs_context(self) -> Generator[FileSystemContext, None, None]:
"""
A context manager that must yield a subclass of
[FileSystemContext](../utilities.md#hatch.env.plugin.interface.FileSystemContext).
"""
from hatch.utils.fs import temp_directory
with temp_directory() as temp_dir:
yield FileSystemContext(self, local_path=temp_dir, env_path=str(temp_dir))
def enter_shell(
self,
name: str, # noqa: ARG002
path: str,
args: Iterable[str],
):
"""
Spawn a [shell](../../config/hatch.md#shell) within the environment.
This should either use
[command_context](reference.md#hatch.env.plugin.interface.EnvironmentInterface.command_context)
directly or provide the same guarantee.
"""
with self.command_context():
self.platform.exit_with_command([path, *args])
def run_shell_command(self, command: str, **kwargs):
"""
This should return the standard library's
[subprocess.CompletedProcess](https://docs.python.org/3/library/subprocess.html#subprocess.CompletedProcess)
and will always be called when the
[command_context](reference.md#hatch.env.plugin.interface.EnvironmentInterface.command_context)
is active, with the expectation of providing the same guarantee.
"""
kwargs.setdefault("shell", True)
return self.platform.run_command(command, **kwargs)
@contextmanager
def command_context(self):
"""
A context manager that when active should make executed shell commands reflect any
[environment variables](reference.md#hatch.env.plugin.interface.EnvironmentInterface.get_env_vars)
the user defined either currently or at the time of
[creation](reference.md#hatch.env.plugin.interface.EnvironmentInterface.create).
For an example, open the default implementation below:
"""
with self.get_env_vars():
yield
def resolve_commands(self, commands: list[str]):
"""
This expands each command into one or more commands based on any
[scripts](../../config/environment/overview.md#scripts) that the user defined.
"""
for command in commands:
yield from self.expand_command(command)
def expand_command(self, command):
possible_script, args, _ignore_exit_code = parse_script_command(command)
# Indicate undefined
if not args:
args = None
with self.apply_context():
if possible_script in self.scripts:
if args is not None:
args = self.metadata.context.format(args)
for cmd in self.scripts[possible_script]:
yield self.metadata.context.format(cmd, args=args).strip()
else:
yield self.metadata.context.format(command, args=args).strip()
def construct_pip_install_command(self, args: list[str]):
"""
A convenience method for constructing a [`pip install`](https://pip.pypa.io/en/stable/cli/pip_install/)
command with the given verbosity. The default verbosity is set to one less than Hatch's verbosity.
"""
command = ["python", "-u", "-m", "pip", "install", "--disable-pip-version-check"]
# Default to -1 verbosity
add_verbosity_flag(command, self.verbosity, adjustment=-1)
command.extend(args)
return command
def join_command_args(self, args: list[str]):
"""
This is used by the [`run`](../../cli/reference.md#hatch-run) command to construct the root command string
from the received arguments.
"""
return self.platform.join_command_args(args)
def apply_features(self, requirement: str):
"""
A convenience method that applies any user defined [features](../../config/environment/overview.md#features)
to the given requirement.
"""
if self.features:
features = ",".join(self.features)
return f"{requirement}[{features}]"
return requirement
def check_compatibility(self):
"""
This raises an exception if the environment is not compatible with the user's setup. The default behavior
checks for [platform compatibility](../../config/environment/overview.md#supported-platforms)
and any method override should keep this check.
This check is never performed if the environment has been
[created](reference.md#hatch.env.plugin.interface.EnvironmentInterface.create).
"""
if self.platforms and self.platform.name not in self.platforms:
message = "unsupported platform"
raise OSError(message)
def get_env_vars(self) -> EnvVars:
"""
Returns a mapping of environment variables that should be available to the environment. The object can
be used as a context manager to temporarily apply the environment variables to the current process.
!!! note
The environment variable `HATCH_ENV_ACTIVE` will always be set to the name of the environment.
"""
return EnvVars(self.env_vars, self.env_include, self.env_exclude)
def get_env_var_option(self, option: str) -> str:
"""
Returns the value of the upper-cased environment variable `HATCH_ENV_TYPE__`.
"""
return get_env_var_option(plugin_name=self.PLUGIN_NAME, option=option)
def get_context(self):
"""
Returns a subclass of
[EnvironmentContextFormatter](../utilities.md#hatch.env.context.EnvironmentContextFormatter).
"""
from hatch.env.context import EnvironmentContextFormatter
return EnvironmentContextFormatter(self)
@staticmethod
def get_option_types() -> dict:
"""
Returns a mapping of supported options to their respective types so that they can be used by
[overrides](../../config/environment/advanced.md#option-overrides).
"""
return {}
@contextmanager
def apply_context(self):
with self.get_env_vars(), self.metadata.context.apply_context(self.context):
yield
def __enter__(self):
self.activate()
return self
def __exit__(self, exc_type, exc_value, traceback):
self.deactivate()
class FileSystemContext:
"""
This class represents a synchronized path between the local file system and a potentially remote environment.
"""
def __init__(self, env: EnvironmentInterface, *, local_path: Path, env_path: str):
self.__env = env
self.__local_path = local_path
self.__env_path = env_path
@property
def env(self) -> EnvironmentInterface:
"""
Returns the environment to which this context belongs.
"""
return self.__env
@property
def local_path(self) -> Path:
"""
Returns the local path to which this context refers as a path-like object.
"""
return self.__local_path
@property
def env_path(self) -> str:
"""
Returns the environment path to which this context refers as a string. The environment
may not be on the local file system.
"""
return self.__env_path
def join(self, relative_path: str) -> FileSystemContext:
"""
Returns a new instance of this class with the given relative path appended to the local and
environment paths.
This method should not need overwriting.
"""
local_path = self.local_path / relative_path
env_path = f"{self.env_path}{self.__env.sep.join(['', *os.path.normpath(relative_path).split(os.sep)])}"
return FileSystemContext(self.__env, local_path=local_path, env_path=env_path)
def sync_env(self):
"""
Synchronizes the [environment path](utilities.md#hatch.env.plugin.interface.FileSystemContext.env_path)
with the [local path](utilities.md#hatch.env.plugin.interface.FileSystemContext.local_path) as the source.
"""
def sync_local(self):
"""
Synchronizes the [local path](utilities.md#hatch.env.plugin.interface.FileSystemContext.local_path) as the
source with the [environment path](utilities.md#hatch.env.plugin.interface.FileSystemContext.env_path) as
the source.
"""
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]:
import fnmatch
from hatch.project.core import Project
from hatch.utils.fs import Path
from hatch.utils.metadata 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)
# Get exclude patterns
exclude_patterns = self.config.get("exclude", [])
if not isinstance(exclude_patterns, list):
message = f"Field `tool.hatch.envs.{self.env.name}.workspace.exclude` must be an array"
raise TypeError(message)
# First normalize configuration with context expansion
member_data: list[dict[str, Any]] = []
with self.env.apply_context():
for i, data in enumerate(raw_members, 1):
if isinstance(data, str):
expanded_path = self.env.metadata.context.format(data)
member_data.append({"path": expanded_path, "features": ()})
elif isinstance(data, dict):
if "path" not in data:
message = (
f"Member #{i} of field `tool.hatch.envs.{self.env.name}.workspace.members` must define "
f"a `path` key"
)
raise TypeError(message)
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)
expanded_path = self.env.metadata.context.format(path)
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": expanded_path, "features": tuple(sorted(all_features))})
else:
message = (
f"Member #{i} of field `tool.hatch.envs.{self.env.name}.workspace.members` must be "
f"a string or an inline table"
)
raise TypeError(message)
root = str(self.env.root)
member_paths: dict[str, WorkspaceMember] = {}
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.commonpath([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(shared_prefix, relative_path.split(os.sep)):
# Check if member should be excluded
relative_member_path = os.path.relpath(member_path, shared_prefix)
should_exclude = False
for exclude_pattern in exclude_patterns:
if fnmatch.fnmatch(relative_member_path, exclude_pattern) or fnmatch.fnmatch(
member_path, exclude_pattern
):
should_exclude = True
break
if should_exclude:
continue
project_file = os.path.join(member_path, "pyproject.toml")
if not os.path.isfile(project_file):
message = (
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 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]
if script_name in active:
active.append(script_name)
message = f"Circular expansion detected for field `tool.hatch.envs.{env_name}.scripts`: {' -> '.join(active)}"
raise ValueError(message)
active.append(script_name)
expanded_commands = []
for command in commands:
possible_script, args, ignore_exit_code = parse_script_command(command)
if possible_script in config:
expanded_commands.extend(
format_script_commands(
commands=expand_script_commands(
env_name, possible_script, config[possible_script], config, seen, active
),
args=args,
ignore_exit_code=ignore_exit_code,
)
)
else:
expanded_commands.append(command)
seen[script_name] = expanded_commands
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)
================================================
FILE: src/hatch/env/system.py
================================================
import os
from hatch.env.plugin.interface import EnvironmentInterface
from hatch.utils.env import PythonInfo
class SystemEnvironment(EnvironmentInterface):
PLUGIN_NAME = "system"
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.python_info = PythonInfo(self.platform)
self.install_indicator = self.data_directory / str(self.root).encode("utf-8").hex()
def find(self):
return os.path.dirname(os.path.dirname(self.system_python))
def create(self):
self.install_indicator.touch()
def remove(self):
self.install_indicator.remove()
def exists(self):
return self.install_indicator.is_file()
def install_project(self):
self.platform.check_command(self.construct_pip_install_command([self.apply_features(str(self.root))]))
def install_project_dev_mode(self):
self.platform.check_command(
self.construct_pip_install_command(["--editable", self.apply_features(str(self.root))])
)
def dependencies_in_sync(self):
if not self.dependencies:
return True
from hatch.dep.sync import InstalledDistributions
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))
================================================
FILE: src/hatch/env/utils.py
================================================
from __future__ import annotations
import os
from hatch.config.constants import AppEnvVars
def get_env_var(*, plugin_name: str, option: str) -> str:
return f"{AppEnvVars.ENV_OPTION_PREFIX}{plugin_name}_{option.replace('-', '_')}".upper()
def get_env_var_option(*, plugin_name: str, option: str, default: str = "") -> str:
return os.environ.get(get_env_var(plugin_name=plugin_name, option=option), default)
def ensure_valid_environment(env_config: dict):
env_config.setdefault("type", "virtual")
workspace = env_config.get("workspace")
if workspace is not None:
if not isinstance(workspace, dict):
msg = "Field workspace must be a table"
raise TypeError(msg)
members = workspace.get("members", [])
if not isinstance(members, list):
msg = "Field workspace.members must be an array"
raise TypeError(msg)
# Validate each member
for i, member in enumerate(members, 1):
if isinstance(member, str):
continue
if isinstance(member, dict):
path = member.get("path")
if path is None:
msg = f"Member #{i} must define a `path` key"
raise TypeError(msg)
if not isinstance(path, str):
msg = f"Member #{i} path must be a string"
raise TypeError(msg)
else:
msg = f"Member #{i} must be a string or table"
raise TypeError(msg)
exclude = workspace.get("exclude", [])
if not isinstance(exclude, list):
msg = "Field workspace.exclude must be an array"
raise TypeError(msg)
def get_verbosity_flag(verbosity: int, *, adjustment=0) -> str:
verbosity += adjustment
if not verbosity:
return ""
if verbosity > 0:
return f"-{'v' * abs(min(verbosity, 3))}"
return f"-{'q' * abs(max(verbosity, -3))}"
def add_verbosity_flag(command: list[str], verbosity: int, *, adjustment=0):
flag = get_verbosity_flag(verbosity, adjustment=adjustment)
if flag:
command.append(flag)
================================================
FILE: src/hatch/env/virtual.py
================================================
from __future__ import annotations
import os
import sys
import sysconfig
from contextlib import contextmanager, nullcontext, suppress
from functools import cached_property
from os.path import isabs
from typing import TYPE_CHECKING
from hatch.config.constants import AppEnvVars
from hatch.env.plugin.interface import EnvironmentInterface
from hatch.env.utils import add_verbosity_flag
from hatch.utils.fs import Path
from hatch.utils.shells import ShellManager
from hatch.utils.structures import EnvVars
from hatch.venv.core import UVVirtualEnv, VirtualEnv
FREETHREADED_BUILD = bool(sysconfig.get_config_var("Py_GIL_DISABLED"))
if TYPE_CHECKING:
from collections.abc import Callable, Iterable
from packaging.specifiers import SpecifierSet
from python_discovery import PythonInfo
from hatch.dep.core import Dependency
from hatch.dep.sync import InstalledDistributions
from hatch.python.core import PythonManager
class VirtualEnvironment(EnvironmentInterface):
PLUGIN_NAME = "virtual"
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
project_id = self.root.id
project_is_script = self.root.is_file()
project_name = (
project_id
if project_is_script
else self.metadata.name
if "project" in self.metadata.config
else f"{project_id}-unmanaged"
)
venv_name = project_name if self.name == "default" else self.name
# Conditions requiring a flat structure for build env
if (
self.isolated_data_directory == self.platform.home / ".virtualenvs"
or self.root in self.isolated_data_directory.resolve().parents
):
app_virtual_env_path = self.isolated_data_directory / venv_name
else:
app_virtual_env_path = self.isolated_data_directory / project_name / project_id / venv_name
# Explicit path
chosen_directory = self.get_env_var_option("path") or self.config.get("path", "")
if chosen_directory:
self.storage_path = self.data_directory / project_name / project_id
self.virtual_env_path = (
Path(chosen_directory) if isabs(chosen_directory) else (self.root / chosen_directory).resolve()
)
elif project_is_script:
self.storage_path = self.virtual_env_path = self.isolated_data_directory / venv_name
# Conditions requiring a flat structure
elif (
self.data_directory == self.platform.home / ".virtualenvs"
or self.root in self.data_directory.resolve().parents
):
self.storage_path = self.data_directory
self.virtual_env_path = self.storage_path / venv_name
# Otherwise the defined app path
else:
self.storage_path = self.data_directory / project_name / project_id
self.virtual_env_path = self.storage_path / venv_name
self.virtual_env = self.virtual_env_cls(self.virtual_env_path, self.platform, self.verbosity)
self.build_virtual_env = self.virtual_env_cls(
app_virtual_env_path.parent / f"{app_virtual_env_path.name}-build", self.platform, self.verbosity
)
self.shells = ShellManager(self)
self._parent_python = None
@cached_property
def use_uv(self) -> bool:
return self.installer == "uv" or bool(self.explicit_uv_path)
@cached_property
def installer(self) -> str:
return self.config.get("installer", "pip")
@cached_property
def explicit_uv_path(self) -> str:
return self.get_env_var_option("uv_path") or self.config.get("uv-path", "")
@cached_property
def virtual_env_cls(self) -> type[VirtualEnv]:
return UVVirtualEnv if self.use_uv else VirtualEnv
def expose_uv(self):
if not (self.use_uv or self.uv_path):
return nullcontext()
return EnvVars({"HATCH_UV": self.uv_path})
@cached_property
def uv_path(self) -> str:
if self.explicit_uv_path:
return self.explicit_uv_path
from hatch.env.internal import is_default_environment
env_name = "hatch-uv"
if not (
# Prevent recursive loop
self.name == env_name
# Only if dependencies have been set by the user
or is_default_environment(env_name, self.app.project.config.internal_envs[env_name])
):
uv_env = self.app.project.get_environment(env_name)
self.app.project.prepare_environment(uv_env, keep_env=bool(os.environ.get(AppEnvVars.KEEP_ENV)))
with uv_env:
return self.platform.modules.shutil.which("uv")
import sysconfig
scripts_dir = sysconfig.get_path("scripts")
old_path = os.environ.get("PATH", os.defpath)
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}
def activate(self):
self.virtual_env.activate()
def deactivate(self):
self.virtual_env.deactivate()
def find(self):
return self.virtual_env_path
def create(self):
if self.root in self.storage_path.parents:
# Although it would be nice to support Mercurial, only Git supports multiple ignore files. See:
# https://github.com/pytest-dev/pytest/issues/3286#issuecomment-421439197
vcs_ignore_file = self.storage_path / ".gitignore"
if not vcs_ignore_file.is_file():
vcs_ignore_file.ensure_parent_dir_exists()
vcs_ignore_file.write_text(
"""\
# This file was automatically created by Hatch
*
"""
)
with self.expose_uv():
self.virtual_env.create(self.parent_python, allow_system_packages=self.config.get("system-packages", False))
def remove(self):
self.virtual_env.remove()
self.build_virtual_env.remove()
# Clean up root directory of all virtual environments belonging to the project
if self.storage_path != self.platform.home / ".virtualenvs" and self.storage_path.is_dir():
entries = [entry.name for entry in self.storage_path.iterdir()]
if not entries or (entries == [".gitignore"] and self.root in self.storage_path.parents):
self.storage_path.remove()
def exists(self):
return self.virtual_env.exists()
def install_project(self):
with self.safe_activation():
self.platform.check_command(self.construct_pip_install_command([self.apply_features(str(self.root))]))
def install_project_dev_mode(self):
with self.safe_activation():
self.platform.check_command(
self.construct_pip_install_command(["--editable", self.apply_features(str(self.root))])
)
def dependencies_in_sync(self):
with self.safe_activation():
return not self.missing_dependencies
def sync_dependencies(self):
with self.safe_activation():
# If we do not have missing dependencies we should not sync
if not self.missing_dependencies:
return
all_install_args = []
workspace_deps = [dep for dep in self.local_dependencies_complex if dep.path]
workspace_names = {dep.name.lower() for dep in self.local_dependencies_complex if dep.path}
for dep in workspace_deps:
if dep.editable:
all_install_args.extend(["--editable", dep.path])
else:
all_install_args.append(dep.path)
standard_dependencies = []
user_editable_dependencies = []
for dependency in self.missing_dependencies:
# Skip if already handled as workspace dependency
if dependency.name.lower() in workspace_names:
continue
if dependency.editable and dependency.path is not None:
user_editable_dependencies.append(str(dependency.path))
else:
standard_dependencies.append(str(dependency))
all_install_args.extend(standard_dependencies)
for dep_path in user_editable_dependencies:
all_install_args.extend(["--editable", dep_path])
if all_install_args:
self.platform.check_command(self.construct_pip_install_command(all_install_args))
@contextmanager
def command_context(self):
with self.safe_activation():
yield
def construct_pip_install_command(self, args: list[str]):
if not self.use_uv:
return super().construct_pip_install_command(args)
command = [self.uv_path, "pip", "install"]
# Default to -1 verbosity
add_verbosity_flag(command, self.verbosity, adjustment=-1)
command.extend(args)
return command
def enter_shell(self, name: str, path: str, args: Iterable[str]):
shell_executor = getattr(self.shells, f"enter_{name}", None)
if shell_executor is None:
# Manually activate in lieu of an activation script
with self.safe_activation():
self.platform.exit_with_command([path, *args])
else:
with self.expose_uv(), self.get_env_vars():
shell_executor(path, args, self.virtual_env.executables_directory)
def check_compatibility(self):
super().check_compatibility()
python_version = self.config.get("python", "")
if (
os.environ.get(AppEnvVars.PYTHON)
or self._find_existing_interpreter(python_version) is not None
or self._get_available_distribution(python_version) is not None
):
return
message = (
f"cannot locate Python: {python_version}"
if python_version
else "no compatible Python distribution available"
)
raise OSError(message)
@cached_property
def _preferred_python_version(self):
version = f"{sys.version_info.major}.{sys.version_info.minor}"
if FREETHREADED_BUILD:
version += "t"
return version
@cached_property
def parent_python(self):
if python_choice := self.config.get("python", ""):
return self._get_concrete_interpreter_path(python_choice)
if explicit_default := os.environ.get(AppEnvVars.PYTHON):
return sys.executable if explicit_default == "self" else explicit_default
return self._get_concrete_interpreter_path()
@cached_property
def python_manager(self) -> PythonManager:
from hatch.python.core import PythonManager
return PythonManager(self.isolated_data_directory / ".pythons")
def get_interpreter_resolver_env(self) -> dict[str, str]:
env = dict(os.environ)
python_dirs = [str(dist.python_path.parent) for dist in self.python_manager.get_installed().values()]
if not python_dirs:
return env
internal_path = os.pathsep.join(python_dirs)
old_path = env.pop("PATH", None)
env["PATH"] = internal_path if old_path is None else f"{old_path}{os.pathsep}{internal_path}"
return env
def upgrade_possible_internal_python(self, python_path: str) -> None:
if "internal" not in self._python_sources:
return
for dist in self.python_manager.get_installed().values():
if dist.python_path == Path(python_path):
if dist.needs_update():
with self.app.status(f"Updating Python distribution: {dist.name}"):
self.python_manager.install(dist.name)
break
def _interpreter_is_compatible(self, interpreter: PythonInfo) -> bool:
return (
interpreter.executable is not None
and self._is_stable_path(interpreter.executable)
and (self.skip_install or self._python_constraint.contains(interpreter.version_str))
)
def _get_concrete_interpreter_path(self, python_version: str = "") -> str | None:
known_resolvers = self._python_resolvers()
resolvers = [known_resolvers[source] for source in self._python_sources]
if python_version:
for resolver in resolvers:
if (concrete_path := resolver(python_version)) is not None:
return concrete_path
else:
# Prefer the Python version Hatch is currently using
for resolver in resolvers:
if (concrete_path := resolver(self._preferred_python_version)) is not None:
return concrete_path
# Fallback to whatever is compatible
for resolver in resolvers:
if (concrete_path := resolver("")) is not None:
return concrete_path
return None
def _resolve_external_interpreter_path(self, python_version: str) -> str | None:
if (existing_path := self._find_existing_interpreter(python_version)) is not None:
self.upgrade_possible_internal_python(existing_path)
return existing_path
return None
def _resolve_internal_interpreter_path(self, python_version: str) -> str | None:
if (available_distribution := self._get_available_distribution(python_version)) is not None:
with self.app.status(f"Installing Python distribution: {available_distribution}"):
dist = self.python_manager.install(available_distribution)
return str(dist.python_path)
return None
def _find_existing_interpreter(self, python_version: str = "") -> str | None:
import python_discovery
python_info = python_discovery.get_interpreter(
python_version, (), env=self.get_interpreter_resolver_env(), predicate=self._interpreter_is_compatible
)
return None if python_info is None else python_info.executable
def _get_available_distribution(self, python_version: str = "") -> str | None:
from hatch.python.resolve import get_compatible_distributions
compatible_distributions = get_compatible_distributions()
for installed_distribution in self.python_manager.get_installed():
compatible_distributions.pop(installed_distribution, None)
if not python_version:
# Only try providing CPython distributions
available_distributions = [d for d in compatible_distributions if not d.startswith("pypy")]
# Prioritize the version that Hatch is currently using, if available
with suppress(ValueError):
available_distributions.remove(self._preferred_python_version)
available_distributions.append(self._preferred_python_version)
# Latest first
available_distributions.reverse()
elif python_version in compatible_distributions:
available_distributions = [python_version]
else:
return None
for available_distribution in available_distributions:
minor_version = (
available_distribution.replace("pypy", "", 1)
if available_distribution.startswith("pypy")
else available_distribution
)
if not self._python_constraint.contains(minor_version):
continue
return available_distribution
return None
def _is_stable_path(self, executable: str) -> bool:
path = Path(executable).resolve()
parents = path.parents
# https://pypa.github.io/pipx/how-pipx-works/
if (Path.home() / ".local" / "pipx" / "venvs") in parents:
return False
from platformdirs import user_data_dir
# https://github.com/ofek/pyapp/blob/v0.13.0/src/app.rs#L27
if Path(user_data_dir("pyapp", appauthor=False)) in parents:
return False
# via Windows store
if self.platform.windows and str(path).endswith("WindowsApps\\python.exe"):
return False
# via Homebrew
return not (self.platform.macos and Path("/usr/local/Cellar") in parents)
@cached_property
def _python_sources(self) -> list[str]:
return self.config.get("python-sources") or ["external", "internal"]
def _python_resolvers(self) -> dict[str, Callable[[str], str | None]]:
return {
"external": self._resolve_external_interpreter_path,
"internal": self._resolve_internal_interpreter_path,
}
@cached_property
def _python_constraint(self) -> SpecifierSet:
from packaging.specifiers import SpecifierSet
# Note that we do not support this field being dynamic because if we were to set up the
# build environment to retrieve the field then we would be stuck because we need to use
# a satisfactory version to set up the environment
return SpecifierSet(self.metadata.config.get("project", {}).get("requires-python", ""))
@contextmanager
def safe_activation(self):
# In order of precedence:
# - This environment
# - UV
# - User-defined environment variables
with self.get_env_vars(), self.expose_uv(), self:
yield
================================================
FILE: src/hatch/errors/__init__.py
================================================
class HatchError(Exception):
pass
class PythonDistributionUnknownError(HatchError):
pass
class PythonDistributionResolutionError(HatchError):
pass
================================================
FILE: src/hatch/index/__init__.py
================================================
================================================
FILE: src/hatch/index/core.py
================================================
from __future__ import annotations
import sys
from functools import cached_property
from typing import TYPE_CHECKING
import hyperlink
from hatch._version import __version__
if TYPE_CHECKING:
import httpx
from hatch.utils.fs import Path
class IndexURLs:
def __init__(self, repo: str):
self.repo = hyperlink.parse(repo).normalize()
# PyPI
if self.repo.host.endswith("pypi.org"): # no cov
repo_url = self.repo.replace(host="pypi.org") if self.repo.host == "upload.pypi.org" else self.repo
self.simple = repo_url.click("/simple/")
self.project = repo_url.click("/project/")
# Assume devpi
else:
self.simple = self.repo.child("+simple", "")
self.project = self.repo
class PackageIndex:
def __init__(self, repo: str, *, user="", auth="", ca_cert=None, client_cert=None, client_key=None):
self.urls = IndexURLs(repo)
self.repo = str(self.urls.repo)
self.user = user
self.auth = auth
self.__cert = None
if client_cert:
self.__cert = client_cert
if client_key:
self.__cert = (client_cert, client_key)
self.__verify = True
if ca_cert:
self.__verify = ca_cert
@cached_property
def client(self) -> httpx.Client:
import httpx
from hatch.utils.network import DEFAULT_TIMEOUT
user_agent = (
f"Hatch/{__version__} "
f"{sys.implementation.name}/{'.'.join(map(str, sys.version_info[:3]))} "
f"HTTPX/{httpx.__version__}"
)
return httpx.Client(
headers={"User-Agent": user_agent},
transport=httpx.HTTPTransport(retries=3, verify=self.__verify, cert=self.__cert),
timeout=DEFAULT_TIMEOUT,
)
def upload_artifact(self, artifact: Path, data: dict):
import hashlib
import io
data[":action"] = "file_upload"
data["protocol_version"] = "1"
with artifact.open("rb") as f:
# https://github.com/pypa/warehouse/blob/7fc3ce5bd7ecc93ef54c1652787fb5e7757fe6f2/tests/unit/packaging/test_tasks.py#L189-L191
md5_hash = hashlib.md5() # noqa: S324
sha256_hash = hashlib.sha256()
blake2_256_hash = hashlib.blake2b(digest_size=32)
while True:
chunk = f.read(io.DEFAULT_BUFFER_SIZE)
if not chunk:
break
md5_hash.update(chunk)
sha256_hash.update(chunk)
blake2_256_hash.update(chunk)
data["md5_digest"] = md5_hash.hexdigest()
data["sha256_digest"] = sha256_hash.hexdigest()
data["blake2_256_digest"] = blake2_256_hash.hexdigest()
f.seek(0)
response = self.client.post(
self.repo,
data=data,
files={"content": (artifact.name, f, "application/octet-stream")},
auth=(self.user, self.auth),
)
response.raise_for_status()
def get_simple_api(self, project: str) -> httpx.Response:
return self.client.get(
str(self.urls.simple.child(project, "")),
headers={"Cache-Control": "no-cache"},
auth=(self.user, self.auth),
)
================================================
FILE: src/hatch/index/errors.py
================================================
class ArtifactMetadataError(Exception):
pass
================================================
FILE: src/hatch/index/publish.py
================================================
from hatch.index.errors import ArtifactMetadataError
MULTIPLE_USE_METADATA_FIELDS = {
"classifier",
"dynamic",
"license_file",
"obsoletes_dist",
"platform",
"project_url",
"provides_dist",
"provides_extra",
"requires_dist",
"requires_external",
"supported_platform",
}
RENAMED_METADATA_FIELDS = {"classifier": "classifiers", "project_url": "project_urls"}
def get_wheel_form_data(artifact):
import zipfile
from packaging.tags import parse_tag
with zipfile.ZipFile(str(artifact), "r") as zip_archive:
for path in zip_archive.namelist():
root = path.split("/", 1)[0]
if root.endswith(".dist-info"):
dist_info_dir = root
break
else: # no cov
message = f"Could not find the `.dist-info` directory in wheel: {artifact}"
raise ArtifactMetadataError(message)
try:
with zip_archive.open(f"{dist_info_dir}/METADATA") as zip_file:
metadata_file_contents = zip_file.read().decode("utf-8")
except KeyError: # no cov
message = f"Could not find a `METADATA` file in the `{dist_info_dir}` directory"
raise ArtifactMetadataError(message) from None
else:
data = parse_headers(metadata_file_contents)
data["filetype"] = "bdist_wheel"
# Examples:
# cryptography-3.4.7-pp37-pypy37_pp73-manylinux2014_x86_64.whl -> pp37
# hatchling-1rc1-py2.py3-none-any.whl -> py2.py3
tag_component = "-".join(artifact.stem.split("-")[-3:])
data["pyversion"] = ".".join(sorted({tag.interpreter for tag in parse_tag(tag_component)}))
return data
def get_sdist_form_data(artifact):
import tarfile
with tarfile.open(str(artifact), "r:gz") as tar_archive:
pkg_info_dir_parts = []
for tar_info in tar_archive:
if tar_info.isfile():
pkg_info_dir_parts.append(tar_info.name.split("/", 1)[0])
break
else: # no cov
message = f"Could not find any files in sdist: {artifact}"
raise ArtifactMetadataError(message)
pkg_info_dir_parts.append("PKG-INFO")
pkg_info_path = "/".join(pkg_info_dir_parts)
try:
with tar_archive.extractfile(pkg_info_path) as tar_file:
metadata_file_contents = tar_file.read().decode("utf-8")
except KeyError: # no cov
message = f"Could not find file: {pkg_info_path}"
raise ArtifactMetadataError(message) from None
else:
data = parse_headers(metadata_file_contents)
data["filetype"] = "sdist"
data["pyversion"] = "source"
return data
def parse_headers(metadata_file_contents):
import email
message = email.message_from_string(metadata_file_contents)
headers = {"description": message.get_payload()}
for header, value in message.items():
normalized_header = header.lower().replace("-", "_")
header_name = RENAMED_METADATA_FIELDS.get(normalized_header, normalized_header)
if normalized_header in MULTIPLE_USE_METADATA_FIELDS:
if header_name in headers:
headers[header_name].append(value)
else:
headers[header_name] = [value]
else:
headers[header_name] = value
return headers
================================================
FILE: src/hatch/plugin/__init__.py
================================================
================================================
FILE: src/hatch/plugin/constants.py
================================================
DEFAULT_CUSTOM_SCRIPT = "hatch_plugins.py"
================================================
FILE: src/hatch/plugin/manager.py
================================================
from hatchling.plugin.manager import PluginManager as _PluginManager
class PluginManager(_PluginManager):
def initialize(self):
super().initialize()
from hatch.plugin import specs
self.manager.add_hookspecs(specs)
def hatch_register_environment(self):
from hatch.env.plugin import hooks
self.manager.register(hooks)
def hatch_register_environment_collector(self):
from hatch.env.collectors.plugin import hooks
self.manager.register(hooks)
def hatch_register_publisher(self):
from hatch.publish.plugin import hooks
self.manager.register(hooks)
def hatch_register_template(self):
from hatch.template.plugin import hooks
self.manager.register(hooks)
================================================
FILE: src/hatch/plugin/specs.py
================================================
from hatchling.plugin.specs import hookspec
@hookspec
def hatch_register_environment():
"""Register new classes that adhere to the environment interface."""
@hookspec
def hatch_register_environment_collector():
"""Register new classes that adhere to the environment collector interface."""
@hookspec
def hatch_register_version_scheme():
"""Register new classes that adhere to the version scheme interface."""
@hookspec
def hatch_register_publisher():
"""Register new classes that adhere to the publisher interface."""
@hookspec
def hatch_register_template():
"""Register new classes that adhere to the template interface."""
================================================
FILE: src/hatch/plugin/utils.py
================================================
from __future__ import annotations
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from hatch.env.collectors.plugin.interface import EnvironmentCollectorInterface
def load_plugin_from_script(
path: str, script_name: str, plugin_class: type[EnvironmentCollectorInterface], plugin_id: str
) -> type[EnvironmentCollectorInterface]:
from importlib.util import module_from_spec, spec_from_file_location
spec = spec_from_file_location(script_name, path)
module = module_from_spec(spec) # type: ignore[arg-type]
spec.loader.exec_module(module) # type: ignore[union-attr]
plugin_finder = f"get_{plugin_id}"
names = dir(module)
if plugin_finder in names:
return getattr(module, plugin_finder)()
subclasses = []
for name in names:
obj = getattr(module, name)
if obj is plugin_class:
continue
try:
if issubclass(obj, plugin_class):
subclasses.append(obj)
except TypeError:
continue
if not subclasses:
message = f"Unable to find a subclass of `{plugin_class.__name__}` in `{script_name}`: {path}"
raise ValueError(message)
if len(subclasses) > 1:
message = (
f"Multiple subclasses of `{plugin_class.__name__}` found in `{script_name}`, "
f"select one by defining a function named `{plugin_finder}`: {path}"
)
raise ValueError(message)
return subclasses[0]
================================================
FILE: src/hatch/project/__init__.py
================================================
================================================
FILE: src/hatch/project/config.py
================================================
from __future__ import annotations
import re
from copy import deepcopy
from functools import cached_property
from itertools import product
from os import environ
from typing import TYPE_CHECKING, Any
from hatch.env.utils import ensure_valid_environment
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
if TYPE_CHECKING:
from hatch.dep.core import Dependency
class ProjectConfig:
def __init__(self, root, config, plugin_manager=None):
self.root = root
self.config = config
self.plugin_manager = plugin_manager
self._matrices = None
self._env = None
self._env_requires_complex = None
self._env_requires = None
self._env_collectors = None
self._envs = None
self._internal_envs = None
self._internal_matrices = None
self._matrix_variables = None
self._publish = None
self._scripts = None
self._cached_env_overrides = {}
@cached_property
def build(self):
config = self.config.get("build", {})
if not isinstance(config, dict):
message = "Field `tool.hatch.build` must be a table"
raise TypeError(message)
return BuildConfig(config)
@property
def env(self):
if self._env is None:
config = self.config.get("env", {})
if not isinstance(config, dict):
message = "Field `tool.hatch.env` must be a table"
raise TypeError(message)
self._env = config
return self._env
@property
def env_requires_complex(self) -> list[Dependency]:
if self._env_requires_complex is None:
from hatch.dep.core import Dependency, InvalidDependencyError
requires = self.env.get("requires", [])
if not isinstance(requires, list):
message = "Field `tool.hatch.env.requires` must be an array"
raise TypeError(message)
requires_complex = []
for i, entry in enumerate(requires, 1):
if not isinstance(entry, str):
message = f"Requirement #{i} in `tool.hatch.env.requires` must be a string"
raise TypeError(message)
try:
requires_complex.append(Dependency(entry))
except InvalidDependencyError as e:
message = f"Requirement #{i} in `tool.hatch.env.requires` is invalid: {e}"
raise ValueError(message) from None
self._env_requires_complex = requires_complex
return self._env_requires_complex
@property
def env_requires(self):
if self._env_requires is None:
self._env_requires = [str(r) for r in self.env_requires_complex]
return self._env_requires
@property
def env_collectors(self):
if self._env_collectors is None:
collectors = self.env.get("collectors", {})
if not isinstance(collectors, dict):
message = "Field `tool.hatch.env.collectors` must be a table"
raise TypeError(message)
final_config = {"default": {}}
for collector, config in collectors.items():
if not isinstance(config, dict):
message = f"Field `tool.hatch.env.collectors.{collector}` must be a table"
raise TypeError(message)
final_config[collector] = config
self._env_collectors = final_config
return self._env_collectors
@property
def matrices(self):
if self._matrices is None:
_ = self.envs
return self._matrices
@property
def matrix_variables(self):
if self._matrix_variables is None:
_ = self.envs
return self._matrix_variables
@property
def internal_envs(self):
if self._internal_envs is None:
_ = self.envs
return self._internal_envs
@property
def internal_matrices(self):
if self._internal_matrices is None:
_ = self.envs
return self._internal_matrices
@property
def envs(self):
from hatch.env.internal import get_internal_env_config
from hatch.utils.platform import get_platform_name
if self._envs is None:
env_config = self.config.get("envs", {})
if not isinstance(env_config, dict):
message = "Field `tool.hatch.envs` must be a table"
raise TypeError(message)
config = {}
environment_collectors = []
for collector, collector_config in self.env_collectors.items():
collector_class = self.plugin_manager.environment_collector.get(collector)
if collector_class is None:
message = f"Unknown environment collector: {collector}"
raise ValueError(message)
environment_collector = collector_class(self.root, collector_config)
environment_collectors.append(environment_collector)
for env_name, data in environment_collector.get_initial_config().items():
config.setdefault(env_name, data)
for env_name, data in env_config.items():
if not isinstance(data, dict):
message = f"Field `tool.hatch.envs.{env_name}` must be a table"
raise TypeError(message)
config.setdefault(env_name, {}).update(data)
for environment_collector in environment_collectors:
environment_collector.finalize_config(config)
# Prevent plugins from removing the default environment
ensure_valid_environment(config.setdefault("default", {}))
seen = set()
active = []
for env_name, data in config.items():
_populate_default_env_values(env_name, data, config, seen, active)
current_platform = get_platform_name()
all_matrices = {}
generated_envs = {}
final_config = {}
cached_overrides = {}
for env_name, raw_initial_config in config.items():
current_cached_overrides = cached_overrides[env_name] = {
"platform": [],
"env": [],
"matrix": [],
"name": [],
}
# Only shallow copying is necessary since we just want to modify keys
initial_config = raw_initial_config.copy()
matrix_name_format = initial_config.pop("matrix-name-format", "{value}")
if not isinstance(matrix_name_format, str):
message = f"Field `tool.hatch.envs.{env_name}.matrix-name-format` must be a string"
raise TypeError(message)
if "{value}" not in matrix_name_format:
message = (
f"Field `tool.hatch.envs.{env_name}.matrix-name-format` must "
f"contain at least the `{{value}}` placeholder"
)
raise ValueError(message)
overrides = initial_config.pop("overrides", {})
if not isinstance(overrides, dict):
message = f"Field `tool.hatch.envs.{env_name}.overrides` must be a table"
raise TypeError(message)
# Apply any configuration based on the current platform
platform_overrides = overrides.get("platform", {})
if not isinstance(platform_overrides, dict):
message = f"Field `tool.hatch.envs.{env_name}.overrides.platform` must be a table"
raise TypeError(message)
for platform, options in platform_overrides.items():
if not isinstance(options, dict):
message = f"Field `tool.hatch.envs.{env_name}.overrides.platform.{platform}` must be a table"
raise TypeError(message)
if platform != current_platform:
continue
apply_overrides(env_name, "platform", platform, current_platform, options, initial_config)
current_cached_overrides["platform"].append((platform, current_platform, options))
# Apply any configuration based on environment variables
env_var_overrides = overrides.get("env", {})
if not isinstance(env_var_overrides, dict):
message = f"Field `tool.hatch.envs.{env_name}.overrides.env` must be a table"
raise TypeError(message)
for env_var, options in env_var_overrides.items():
if not isinstance(options, dict):
message = f"Field `tool.hatch.envs.{env_name}.overrides.env.{env_var}` must be a table"
raise TypeError(message)
if env_var not in environ:
continue
apply_overrides(env_name, "env", env_var, environ[env_var], options, initial_config)
current_cached_overrides["env"].append((env_var, environ[env_var], options))
if "matrix" not in initial_config:
final_config[env_name] = initial_config
continue
matrices = initial_config.pop("matrix")
if not isinstance(matrices, list):
message = f"Field `tool.hatch.envs.{env_name}.matrix` must be an array"
raise TypeError(message)
matrix_overrides = overrides.get("matrix", {})
if not isinstance(matrix_overrides, dict):
message = f"Field `tool.hatch.envs.{env_name}.overrides.matrix` must be a table"
raise TypeError(message)
name_overrides = overrides.get("name", {})
if not isinstance(name_overrides, dict):
message = f"Field `tool.hatch.envs.{env_name}.overrides.name` must be a table"
raise TypeError(message)
matrix_data = all_matrices[env_name] = {"config": deepcopy(initial_config)}
all_envs = matrix_data["envs"] = {}
for i, raw_matrix in enumerate(matrices, 1):
matrix = raw_matrix
if not isinstance(matrix, dict):
message = f"Entry #{i} in field `tool.hatch.envs.{env_name}.matrix` must be a table"
raise TypeError(message)
if not matrix:
message = f"Matrix #{i} in field `tool.hatch.envs.{env_name}.matrix` cannot be empty"
raise ValueError(message)
for j, (variable, values) in enumerate(matrix.items(), 1):
if not variable:
message = (
f"Variable #{j} in matrix #{i} in field `tool.hatch.envs.{env_name}.matrix` "
f"cannot be an empty string"
)
raise ValueError(message)
if not isinstance(values, list):
message = (
f"Variable `{variable}` in matrix #{i} in field `tool.hatch.envs.{env_name}.matrix` "
f"must be an array"
)
raise TypeError(message)
if not values:
message = (
f"Variable `{variable}` in matrix #{i} in field `tool.hatch.envs.{env_name}.matrix` "
f"cannot be empty"
)
raise ValueError(message)
existing_values = set()
for k, value in enumerate(values, 1):
if not isinstance(value, str):
message = (
f"Value #{k} of variable `{variable}` in matrix #{i} in field "
f"`tool.hatch.envs.{env_name}.matrix` must be a string"
)
raise TypeError(message)
if not value:
message = (
f"Value #{k} of variable `{variable}` in matrix #{i} in field "
f"`tool.hatch.envs.{env_name}.matrix` cannot be an empty string"
)
raise ValueError(message)
if value in existing_values:
message = (
f"Value #{k} of variable `{variable}` in matrix #{i} in field "
f"`tool.hatch.envs.{env_name}.matrix` is a duplicate"
)
raise ValueError(message)
existing_values.add(value)
variables = {}
# Ensure that any Python variable comes first
python_selected = False
for variable in ("py", "python"):
if variable in matrix:
if python_selected:
message = (
f"Matrix #{i} in field `tool.hatch.envs.{env_name}.matrix` "
f"cannot contain both `py` and `python` variables"
)
raise ValueError(message)
python_selected = True
# Only shallow copying is necessary since we just want to remove a key
matrix = matrix.copy()
variables[variable] = matrix.pop(variable)
variables.update(matrix)
for result in product(*variables.values()):
# Make a value mapping for easy referencing
variable_values = dict(zip(variables, result, strict=False))
# Create the environment's initial configuration
new_config = deepcopy(initial_config)
cached_matrix_overrides = []
# Apply any configuration based on matrix variables
for variable, options in matrix_overrides.items():
if not isinstance(options, dict):
message = (
f"Field `tool.hatch.envs.{env_name}.overrides.matrix.{variable}` must be a table"
)
raise TypeError(message)
if variable not in variables:
continue
apply_overrides(
env_name, "matrix", variable, variable_values[variable], options, new_config
)
cached_matrix_overrides.append((variable, variable_values[variable], options))
# Construct the environment name
final_matrix_name_format = new_config.pop("matrix-name-format", matrix_name_format)
env_name_parts = []
for j, (variable, value) in enumerate(variable_values.items()):
if j == 0 and python_selected:
new_config["python"] = value
env_name_parts.append(value if value.startswith("py") else f"py{value}")
else:
env_name_parts.append(final_matrix_name_format.format(variable=variable, value=value))
new_env_name = "-".join(env_name_parts)
cached_name_overrides = []
# Apply any configuration based on the final name, minus the prefix for non-default environments
for pattern, options in name_overrides.items():
if not isinstance(options, dict):
message = f"Field `tool.hatch.envs.{env_name}.overrides.name.{pattern}` must be a table"
raise TypeError(message)
if not re.search(pattern, new_env_name):
continue
apply_overrides(env_name, "name", pattern, new_env_name, options, new_config)
cached_name_overrides.append((pattern, new_env_name, options))
if env_name != "default":
new_env_name = f"{env_name}.{new_env_name}"
# Save the generated environment
final_config[new_env_name] = new_config
cached_overrides[new_env_name] = {
"platform": current_cached_overrides["platform"],
"env": current_cached_overrides["env"],
"matrix": cached_matrix_overrides,
"name": cached_name_overrides,
}
all_envs[new_env_name] = variable_values
if "py" in variable_values:
all_envs[new_env_name] = {"python": variable_values.pop("py"), **variable_values}
# Remove the root matrix generator
del cached_overrides[env_name]
# Save the variables used to generate the environments
generated_envs.update(all_envs)
for environment_collector in environment_collectors:
environment_collector.finalize_environments(final_config)
self._matrices = all_matrices
self._internal_matrices = {}
self._envs = final_config
self._matrix_variables = generated_envs
self._cached_env_overrides.update(cached_overrides)
# Extract the internal environments
self._internal_envs = {}
for internal_name in get_internal_env_config():
try:
self._internal_envs[internal_name] = self._envs.pop(internal_name)
# Matrix
except KeyError:
self._internal_matrices[internal_name] = self._matrices.pop(internal_name)
for env_name in [env_name for env_name in self._envs if env_name.startswith(f"{internal_name}.")]:
self._internal_envs[env_name] = self._envs.pop(env_name)
return self._envs
@property
def publish(self):
if self._publish is None:
config = self.config.get("publish", {})
if not isinstance(config, dict):
message = "Field `tool.hatch.publish` must be a table"
raise TypeError(message)
for publisher, data in config.items():
if not isinstance(data, dict):
message = f"Field `tool.hatch.publish.{publisher}` must be a table"
raise TypeError(message)
self._publish = config
return self._publish
@property
def scripts(self):
if self._scripts is None:
script_config = self.config.get("scripts", {})
if not isinstance(script_config, dict):
message = "Field `tool.hatch.scripts` must be a table"
raise TypeError(message)
config = {}
for name, data in script_config.items():
if " " in name:
message = f"Script name `{name}` in field `tool.hatch.scripts` must not contain spaces"
raise ValueError(message)
commands = []
if isinstance(data, str):
commands.append(data)
elif isinstance(data, list):
for i, command in enumerate(data, 1):
if not isinstance(command, str):
message = f"Command #{i} in field `tool.hatch.scripts.{name}` must be a string"
raise TypeError(message)
commands.append(command)
else:
message = f"Field `tool.hatch.scripts.{name}` must be a string or an array of strings"
raise TypeError(message)
config[name] = commands
seen = {}
active = []
for script_name, commands in config.items():
commands[:] = expand_script_commands(script_name, commands, config, seen, active)
self._scripts = config
return self._scripts
def finalize_env_overrides(self, option_types):
# We lazily apply overrides because we need type information potentially defined by
# environment plugins for their options
if not self._cached_env_overrides:
return
for environments in (self.envs, self.internal_envs):
for env_name, config in environments.items():
for override_name, data in self._cached_env_overrides.get(env_name, {}).items():
for condition, condition_value, options in data:
apply_overrides(
env_name, override_name, condition, condition_value, options, config, option_types
)
self._cached_env_overrides.clear()
class BuildConfig:
def __init__(self, config: dict[str, Any]) -> None:
self.__config = config
self.__targets: dict[str, BuildTargetConfig] = config
@cached_property
def directory(self) -> str:
directory = self.__config.get("directory", DEFAULT_BUILD_DIRECTORY)
if not isinstance(directory, str):
message = "Field `tool.hatch.build.directory` must be a string"
raise TypeError(message)
return directory
@cached_property
def dependencies(self) -> list[str]:
dependencies: list[str] = self.__config.get("dependencies", [])
if not isinstance(dependencies, list):
message = "Field `tool.hatch.build.dependencies` must be an array"
raise TypeError(message)
for i, dependency in enumerate(dependencies, 1):
if not isinstance(dependency, str):
message = f"Dependency #{i} in field `tool.hatch.build.dependencies` must be a string"
raise TypeError(message)
return list(dependencies)
@cached_property
def hook_config(self) -> dict[str, dict[str, Any]]:
hook_config: dict[str, dict[str, Any]] = self.__config.get("hooks", {})
if not isinstance(hook_config, dict):
message = "Field `tool.hatch.build.hooks` must be a table"
raise TypeError(message)
for hook_name, config in hook_config.items():
if not isinstance(config, dict):
message = f"Field `tool.hatch.build.hooks.{hook_name}` must be a table"
raise TypeError(message)
return finalize_hook_config(hook_config)
@cached_property
def __target_config(self) -> dict[str, Any]:
config = self.__config.get("targets", {})
if not isinstance(config, dict):
message = "Field `tool.hatch.build.targets` must be a table"
raise TypeError(message)
return config
def target(self, target: str) -> BuildTargetConfig:
if target in self.__targets:
return self.__targets[target]
config = self.__target_config.get(target, {})
if not isinstance(config, dict):
message = f"Field `tool.hatch.build.targets.{target}` must be a table"
raise TypeError(message)
target_config = BuildTargetConfig(target, config, self)
self.__targets[target] = target_config
return target_config
class BuildTargetConfig:
def __init__(self, name: str, config: dict[str, Any], global_config: BuildConfig) -> None:
self.__name = name
self.__config = config
self.__global_config = global_config
@cached_property
def directory(self) -> str:
directory = self.__config.get("directory", self.__global_config.directory)
if not isinstance(directory, str):
message = f"Field `tool.hatch.build.targets.{self.__name}.directory` must be a string"
raise TypeError(message)
return directory
@cached_property
def dependencies(self) -> list[str]:
dependencies: list[str] = self.__config.get("dependencies", [])
if not isinstance(dependencies, list):
message = f"Field `tool.hatch.build.targets.{self.__name}.dependencies` must be an array"
raise TypeError(message)
for i, dependency in enumerate(dependencies, 1):
if not isinstance(dependency, str):
message = (
f"Dependency #{i} in field `tool.hatch.build.targets.{self.__name}.dependencies` must be a string"
)
raise TypeError(message)
all_dependencies = list(self.__global_config.dependencies)
all_dependencies.extend(dependencies)
return all_dependencies
@cached_property
def hook_config(self) -> dict[str, dict[str, Any]]:
hook_config: dict[str, dict[str, Any]] = self.__config.get("hooks", {})
if not isinstance(hook_config, dict):
message = f"Field `tool.hatch.build.targets.{self.__name}.hooks` must be a table"
raise TypeError(message)
for hook_name, config in hook_config.items():
if not isinstance(config, dict):
message = f"Field `tool.hatch.build.targets.{self.__name}.hooks.{hook_name}` must be a table"
raise TypeError(message)
config = self.__global_config.hook_config.copy()
config.update(hook_config)
return finalize_hook_config(config)
def expand_script_commands(script_name, commands, config, seen, active):
if script_name in seen:
return seen[script_name]
if script_name in active:
active.append(script_name)
message = f"Circular expansion detected for field `tool.hatch.scripts`: {' -> '.join(active)}"
raise ValueError(message)
active.append(script_name)
expanded_commands = []
for command in commands:
possible_script, args, ignore_exit_code = parse_script_command(command)
if possible_script in config:
expanded_commands.extend(
format_script_commands(
commands=expand_script_commands(possible_script, config[possible_script], config, seen, active),
args=args,
ignore_exit_code=ignore_exit_code,
)
)
else:
expanded_commands.append(command)
seen[script_name] = expanded_commands
active.pop()
return expanded_commands
def _populate_default_env_values(env_name, data, config, seen, active):
if env_name in seen:
return
if data.pop("detached", False):
data["template"] = env_name
data["skip-install"] = True
template_name = data.pop("template", "default")
if template_name not in config:
message = f"Field `tool.hatch.envs.{env_name}.template` refers to an unknown environment `{template_name}`"
raise ValueError(message)
if env_name in active:
active.append(env_name)
message = f"Circular inheritance detected for field `tool.hatch.envs.*.template`: {' -> '.join(active)}"
raise ValueError(message)
if template_name == env_name:
ensure_valid_environment(data)
seen.add(env_name)
return
active.append(env_name)
template_config = config[template_name]
_populate_default_env_values(template_name, template_config, config, seen, active)
for key, value in template_config.items():
if key == "matrix":
continue
if key == "scripts":
scripts = data["scripts"] if "scripts" in data else data.setdefault("scripts", {})
for script, commands in value.items():
scripts.setdefault(script, commands)
else:
data.setdefault(key, value)
seen.add(env_name)
active.pop()
def finalize_hook_config(hook_config: dict[str, dict[str, Any]]) -> dict[str, dict[str, Any]]:
if env_var_enabled(BuildEnvVars.NO_HOOKS):
return {}
all_hooks_enabled = env_var_enabled(BuildEnvVars.HOOKS_ENABLE)
final_hook_config: dict[str, dict[str, Any]] = {
hook_name: config
for hook_name, config in hook_config.items()
if (
all_hooks_enabled
or config.get("enable-by-default", True)
or env_var_enabled(f"{BuildEnvVars.HOOK_ENABLE_PREFIX}{hook_name.upper()}")
)
}
return final_hook_config
def env_var_enabled(env_var: str, *, default: bool = False) -> bool:
if env_var in environ:
return environ[env_var] in {"1", "true"}
return default
================================================
FILE: src/hatch/project/constants.py
================================================
BUILD_BACKEND = "hatchling.build"
DEFAULT_BUILD_DIRECTORY = "dist"
DEFAULT_BUILD_SCRIPT = "hatch_build.py"
DEFAULT_CONFIG_FILE = "hatch.toml"
class BuildEnvVars:
REQUESTED_TARGETS = "HATCH_BUILD_REQUESTED_TARGETS"
LOCATION = "HATCH_BUILD_LOCATION"
HOOKS_ONLY = "HATCH_BUILD_HOOKS_ONLY"
NO_HOOKS = "HATCH_BUILD_NO_HOOKS"
HOOKS_ENABLE = "HATCH_BUILD_HOOKS_ENABLE"
HOOK_ENABLE_PREFIX = "HATCH_BUILD_HOOK_ENABLE_"
CLEAN = "HATCH_BUILD_CLEAN"
CLEAN_HOOKS_AFTER = "HATCH_BUILD_CLEAN_HOOKS_AFTER"
================================================
FILE: src/hatch/project/core.py
================================================
from __future__ import annotations
import re
from collections import defaultdict
from collections.abc import Generator
from contextlib import contextmanager
from functools import cached_property
from typing import TYPE_CHECKING, Any, cast
from hatch.project.env import EnvironmentMetadata
from hatch.utils.fs import Path
from hatch.utils.runner import ExecutionContext
if TYPE_CHECKING:
from collections.abc import Generator
from hatch.cli.application import Application
from hatch.config.model import RootConfig
from hatch.env.plugin.interface import EnvironmentInterface
from hatch.project.frontend.core import BuildFrontend
class Project:
def __init__(self, path: Path, *, name: str | None = None, config=None, locate: bool = True):
self._path = path
# From app config
self.chosen_name = name
# Lazily attach the current app
self.__app: Application | None = None
# Location of pyproject.toml
self._project_file_path: Path | None = None
self._root_searched = False
self._root: Path | None = None
self._raw_config = config
self._plugin_manager = None
self._metadata = None
self._config = None
self._explicit_path: Path | None = None if locate else path
self.current_member_path: Path | None = None
@property
def plugin_manager(self):
if self._plugin_manager is None:
from hatch.plugin.manager import PluginManager
self._plugin_manager = PluginManager()
return self._plugin_manager
@property
def config(self):
if self._config is None:
from hatch.project.config import ProjectConfig
self._config = ProjectConfig(self.location, self.metadata.hatch.config, self.plugin_manager)
return self._config
@property
def root(self) -> Path | None:
if not self._root_searched:
self._root = self.find_project_root()
self._root_searched = True
return self._root
@property
def location(self) -> Path:
return self._explicit_path or self.root or self._path
def set_path(self, path: Path) -> None:
self._explicit_path = path
def set_app(self, app: Application) -> None:
self.__app = app
@cached_property
def app(self) -> Application:
if self.__app is None: # no cov
message = "The application has not been set"
raise RuntimeError(message)
from hatch.cli.application import Application
return cast(Application, self.__app)
@cached_property
def build_env(self) -> EnvironmentInterface:
# Prevent the default environment from being used as a builder environment
environment = self.get_environment("hatch-build" if self.app.env == "default" else self.app.env)
if not environment.builder:
self.app.abort(f"Environment `{environment.name}` is not a builder environment")
return environment
@cached_property
def build_frontend(self) -> BuildFrontend:
from hatch.project.frontend.core import BuildFrontend
return BuildFrontend(self, self.build_env)
@cached_property
def env_metadata(self) -> EnvironmentMetadata:
return EnvironmentMetadata(self.app.data_dir / "env" / ".metadata", self.location)
@cached_property
def dependency_groups(self) -> dict[str, Any]:
"""
https://peps.python.org/pep-0735/
"""
from hatch.utils.metadata import normalize_project_name
dependency_groups = self.raw_config.get("dependency-groups", {})
if not isinstance(dependency_groups, dict):
message = "Field `dependency-groups` must be a table"
raise TypeError(message)
original_names = defaultdict(list)
normalized_groups = {}
for group_name, value in dependency_groups.items():
normed_group_name = normalize_project_name(group_name)
original_names[normed_group_name].append(group_name)
normalized_groups[normed_group_name] = value
errors = []
for normed_name, names in original_names.items():
if len(names) > 1:
errors.append(f"{normed_name} ({', '.join(names)})")
if errors:
msg = f"Field `dependency-groups` contains duplicate names: {', '.join(errors)}"
raise ValueError(msg)
return normalized_groups
def get_environment(self, env_name: str | None = None) -> EnvironmentInterface:
if env_name is None:
env_name = self.app.env
if env_name in self.config.internal_envs:
config = self.config.internal_envs[env_name]
elif env_name in self.config.envs:
config = self.config.envs[env_name]
else:
self.app.abort(f"Unknown environment: {env_name}")
environment_type = config["type"]
environment_class = self.plugin_manager.environment.get(environment_type)
if environment_class is None:
self.app.abort(f"Environment `{env_name}` has unknown type: {environment_type}")
from hatch.env.internal import is_isolated_environment
if self.location.is_file():
data_directory = isolated_data_directory = self.app.data_dir / "env" / environment_type / ".scripts"
elif is_isolated_environment(env_name, config):
data_directory = isolated_data_directory = self.app.data_dir / "env" / ".internal" / env_name
else:
data_directory = self.app.get_env_directory(environment_type)
isolated_data_directory = self.app.data_dir / "env" / environment_type
self.config.finalize_env_overrides(environment_class.get_option_types())
return environment_class(
self.location,
self.metadata,
env_name,
config,
self.config.matrix_variables.get(env_name, {}),
data_directory,
isolated_data_directory,
self.app.platform,
self.app.verbosity,
self.app,
)
@staticmethod
@contextmanager
def managed_environment(
environment: EnvironmentInterface, *, keep_env: bool = False
) -> Generator[EnvironmentInterface, None, None]:
"""Context manager that removes environment on error unless keep_env is True."""
try:
yield environment
except Exception:
if not keep_env and environment.exists():
environment.remove()
raise
# Ensure that this method is clearly written since it is
# used for documenting the life cycle of environments.
def prepare_environment(self, environment: EnvironmentInterface, *, keep_env: bool):
if not environment.exists():
with self.managed_environment(environment, keep_env=keep_env):
self.env_metadata.reset(environment)
with environment.app_status_creation():
environment.create()
if not environment.skip_install:
if environment.pre_install_commands:
with environment.app_status_pre_installation():
self.app.run_shell_commands(
ExecutionContext(
environment,
shell_commands=environment.pre_install_commands,
source="pre-install",
show_code_on_error=True,
)
)
with environment.app_status_project_installation():
if environment.dev_mode:
environment.install_project_dev_mode()
else:
environment.install_project()
if environment.post_install_commands:
with environment.app_status_post_installation():
self.app.run_shell_commands(
ExecutionContext(
environment,
shell_commands=environment.post_install_commands,
source="post-install",
show_code_on_error=True,
)
)
with environment.app_status_dependency_state_check():
new_dep_hash = environment.dependency_hash()
current_dep_hash = self.env_metadata.dependency_hash(environment)
if new_dep_hash != current_dep_hash:
with environment.app_status_dependency_installation_check():
dependencies_in_sync = environment.dependencies_in_sync()
if not dependencies_in_sync:
with environment.app_status_dependency_synchronization():
environment.sync_dependencies()
new_dep_hash = environment.dependency_hash()
self.env_metadata.update_dependency_hash(environment, new_dep_hash)
def prepare_build_environment(self, *, targets: list[str] | None = None, keep_env: bool = False) -> None:
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(), EnvVars(env_vars):
if not self.build_env.exists():
try:
self.build_env.check_compatibility()
except Exception as e: # noqa: BLE001
self.app.abort(f"Environment `{self.build_env.name}` is incompatible: {e}")
self.prepare_environment(self.build_env, keep_env=keep_env)
additional_dependencies: list[str] = []
with self.app.status("Inspecting build dependencies"):
if build_backend != BUILD_BACKEND:
for target in targets:
if target == "sdist":
additional_dependencies.extend(self.build_frontend.get_requires("sdist"))
elif target == "wheel":
additional_dependencies.extend(self.build_frontend.get_requires("wheel"))
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):
additional_dependencies.extend(
self.metadata.context.format(dep) for dep in required_build_deps
)
if additional_dependencies:
from hatch.dep.core import Dependency
self.build_env.additional_dependencies.extend(map(Dependency, additional_dependencies))
with self.build_env.app_status_dependency_synchronization():
self.build_env.sync_dependencies()
def get_dependencies(self) -> tuple[list[str], dict[str, list[str]]]:
dynamic_fields = {"dependencies", "optional-dependencies"}
if not dynamic_fields.intersection(self.metadata.dynamic):
dependencies: list[str] = self.metadata.core_raw_metadata.get("dependencies", [])
features: dict[str, list[str]] = self.metadata.core_raw_metadata.get("optional-dependencies", {})
return dependencies, features
from hatch.project.constants import BUILD_BACKEND
self.prepare_build_environment()
build_backend = self.metadata.build.build_backend
with self.location.as_cwd(), self.build_env.get_env_vars():
if build_backend != BUILD_BACKEND:
project_metadata = self.build_frontend.get_core_metadata()
else:
project_metadata = self.build_frontend.hatch.get_core_metadata()
dynamic_dependencies: list[str] = project_metadata.get("dependencies", [])
dynamic_features: dict[str, list[str]] = project_metadata.get("optional-dependencies", {})
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"])
if env_name in self.config.matrices:
return list(self.config.matrices[env_name]["envs"])
if env_name in self.config.internal_envs:
return [env_name]
if env_name in self.config.envs:
return [env_name]
return []
@classmethod
def from_config(cls, config: RootConfig, project: str) -> Project | None:
# Disallow empty strings
if not project:
return None
if project in config.projects:
location = config.projects[project].location
if location:
return cls(Path(location).resolve(), name=project)
else:
for project_dir in config.dirs.project:
if not project_dir:
continue
location = Path(project_dir, project)
if location.is_dir():
return cls(Path(location).resolve(), name=project)
return None
def find_project_root(self) -> Path | None:
path = self._path
while True:
possible_file = path.joinpath("pyproject.toml")
if possible_file.is_file():
self._project_file_path = possible_file
return path
if path.joinpath("setup.py").is_file():
return path
new_path = path.parent
if new_path == path:
return None
path = new_path
@contextmanager
def ensure_cwd(self) -> Generator[Path, None, None]:
cwd = Path.cwd()
location = self.location
if location.is_file() or cwd == location or location in cwd.parents:
yield cwd
else:
with location.as_cwd():
yield location
@staticmethod
def canonicalize_name(name: str, *, strict=True) -> str:
if strict:
return re.sub(r"[-_.]+", "-", name).lower()
# Used for creating new projects
return re.sub(r"[-_. ]+", "-", name).lower()
@property
def metadata(self):
if self._metadata is None:
from hatchling.metadata.core import ProjectMetadata
self._metadata = ProjectMetadata(self.location, self.plugin_manager, self.raw_config)
return self._metadata
@property
def raw_config(self):
if self._raw_config is None:
if self.root is None or self._project_file_path is None:
# Assume no pyproject.toml e.g. environment management only
self._raw_config = {"project": {"name": self.location.name}}
else:
from hatch.utils.toml import load_toml_file
raw_config = load_toml_file(str(self._project_file_path))
# Assume environment management only
if "project" not in raw_config:
raw_config["project"] = {"name": self.location.name}
self._raw_config = raw_config
return self._raw_config
def save_config(self, config):
import tomlkit
with open(str(self._project_file_path), "w", encoding="utf-8") as f:
f.write(tomlkit.dumps(config))
@staticmethod
def initialize(project_file_path, template_config):
import tomlkit
with open(str(project_file_path), encoding="utf-8") as f:
raw_config = tomlkit.parse(f.read())
build_system_config = raw_config.setdefault("build-system", {})
build_system_config.clear()
build_system_config["requires"] = ["hatchling"]
build_system_config["build-backend"] = "hatchling.build"
project_config = raw_config.get("project")
if project_config is None:
raw_config["project"] = project_config = {}
project_name = project_config.get("name")
if not project_name:
project_config["name"] = template_config["project_name_normalized"]
project_description = project_config.get("description")
if not project_description:
project_config["description"] = template_config["description"]
project_config["dynamic"] = ["version"]
tool_config = raw_config.get("tool")
if tool_config is None:
raw_config["tool"] = tool_config = {}
hatch_config = tool_config.get("hatch")
if hatch_config is None:
tool_config["hatch"] = hatch_config = {}
version_config = hatch_config.get("version")
if version_config is None:
hatch_config["version"] = version_config = {}
version_config.clear()
version_config["path"] = f"{template_config['package_name']}/__init__.py"
with open(str(project_file_path), "w", encoding="utf-8") as f:
f.write(tomlkit.dumps(raw_config))
================================================
FILE: src/hatch/project/env.py
================================================
from __future__ import annotations
from functools import cached_property
from os import environ
from typing import TYPE_CHECKING, Any
from hatch.utils.platform import get_platform_name
if TYPE_CHECKING:
from hatch.env.plugin.interface import EnvironmentInterface
from hatch.utils.fs import Path
RESERVED_OPTIONS = {
"builder": bool,
"dependencies": list,
"dependency-groups": list,
"extra-dependencies": list,
"dev-mode": bool,
"env-exclude": list,
"env-include": list,
"env-vars": dict,
"features": list,
"matrix-name-format": str,
"platforms": list,
"post-install-commands": list,
"pre-install-commands": list,
"python": str,
"scripts": dict,
"skip-install": bool,
"type": str,
"workspace": dict,
}
def apply_overrides(env_name, source, condition, condition_value, options, new_config, option_types=None):
if option_types is None:
option_types = RESERVED_OPTIONS
for raw_option, data in options.items():
_, separator, option = raw_option.rpartition("set-")
overwrite = bool(separator)
# Prevent manipulation of reserved options
if option_types is not RESERVED_OPTIONS and option in RESERVED_OPTIONS:
continue
override_type = option_types.get(option)
if option == "workspace":
_apply_override_to_workspace(
env_name, option, data, source, condition, condition_value, new_config, overwrite
)
elif override_type in TYPE_OVERRIDES:
TYPE_OVERRIDES[override_type](
env_name, option, data, source, condition, condition_value, new_config, overwrite
)
elif isinstance(data, dict) and "value" in data:
if _resolve_condition(env_name, option, source, condition, condition_value, data):
new_config[option] = data["value"]
elif option_types is not RESERVED_OPTIONS:
message = (
f"Untyped option `tool.hatch.envs.{env_name}.overrides.{source}.{condition}.{option}` "
f"must be defined as a table with a `value` key"
)
raise ValueError(message)
def _apply_override_to_mapping(env_name, option, data, source, condition, condition_value, new_config, overwrite):
new_mapping = {}
if isinstance(data, str):
key, separator, value = data.partition("=")
if not separator:
value = condition_value
new_mapping[key] = value
elif isinstance(data, list):
for i, entry in enumerate(data, 1):
if isinstance(entry, str):
key, separator, value = entry.partition("=")
if not separator:
value = condition_value
new_mapping[key] = value
elif isinstance(entry, dict):
if "key" not in entry:
message = (
f"Entry #{i} in field `tool.hatch.envs.{env_name}.overrides.{source}.{condition}.{option}` "
f"must have an option named `key`"
)
raise ValueError(message)
key = entry["key"]
if not isinstance(key, str):
message = (
f"Option `key` in entry #{i} in field `tool.hatch.envs.{env_name}.overrides.{source}."
f"{condition}.{option}` must be a string"
)
raise TypeError(message)
if not key:
message = (
f"Option `key` in entry #{i} in field `tool.hatch.envs.{env_name}.overrides.{source}."
f"{condition}.{option}` cannot be an empty string"
)
raise ValueError(message)
value = entry.get("value", condition_value)
if not isinstance(value, str):
message = (
f"Option `value` in entry #{i} in field `tool.hatch.envs.{env_name}.overrides.{source}."
f"{condition}.{option}` must be a string"
)
raise TypeError(message)
if _resolve_condition(env_name, option, source, condition, condition_value, entry, i):
new_mapping[key] = value
else:
message = (
f"Entry #{i} in field `tool.hatch.envs.{env_name}.overrides.{source}.{condition}.{option}` "
f"must be a string or an inline table"
)
raise TypeError(message)
else:
message = (
f"Field `tool.hatch.envs.{env_name}.overrides.{source}.{condition}.{option}` must be a string or an array"
)
raise TypeError(message)
if overwrite:
new_config[option] = new_mapping
elif option in new_config:
new_config[option].update(new_mapping)
elif new_mapping:
new_config[option] = new_mapping
def _apply_override_to_array(env_name, option, data, source, condition, condition_value, new_config, overwrite):
if not isinstance(data, list):
message = f"Field `tool.hatch.envs.{env_name}.overrides.{source}.{condition}.{option}` must be an array"
raise TypeError(message)
new_array = []
for i, entry in enumerate(data, 1):
if isinstance(entry, str):
new_array.append(entry)
elif isinstance(entry, dict):
if "value" not in entry:
message = (
f"Entry #{i} in field `tool.hatch.envs.{env_name}.overrides.{source}.{condition}.{option}` "
f"must have an option named `value`"
)
raise ValueError(message)
value = entry["value"]
if not isinstance(value, str):
message = (
f"Option `value` in entry #{i} in field `tool.hatch.envs.{env_name}.overrides.{source}."
f"{condition}.{option}` must be a string"
)
raise TypeError(message)
if not value:
message = (
f"Option `value` in entry #{i} in field `tool.hatch.envs.{env_name}.overrides.{source}."
f"{condition}.{option}` cannot be an empty string"
)
raise ValueError(message)
if _resolve_condition(env_name, option, source, condition, condition_value, entry, i):
new_array.append(value)
else:
message = (
f"Entry #{i} in field `tool.hatch.envs.{env_name}.overrides.{source}.{condition}.{option}` "
f"must be a string or an inline table"
)
raise TypeError(message)
if overwrite:
new_config[option] = new_array
elif option in new_config:
new_config[option].extend(new_array)
elif new_array:
new_config[option] = new_array
def _apply_override_to_string(
env_name,
option,
data,
source,
condition,
condition_value,
new_config,
overwrite, # noqa: ARG001
):
if isinstance(data, str):
new_config[option] = data
elif isinstance(data, dict):
if "value" not in data:
message = (
f"Field `tool.hatch.envs.{env_name}.overrides.{source}.{condition}.{option}` "
f"must have an option named `value`"
)
raise ValueError(message)
value = data["value"]
if not isinstance(value, str):
message = (
f"Option `value` in field `tool.hatch.envs.{env_name}.overrides.{source}."
f"{condition}.{option}` must be a string"
)
raise TypeError(message)
if _resolve_condition(env_name, option, source, condition, condition_value, data):
new_config[option] = value
elif isinstance(data, list):
for i, entry in enumerate(data, 1):
if isinstance(entry, str):
new_config[option] = entry
break
if isinstance(entry, dict):
if "value" not in entry:
message = (
f"Entry #{i} in field `tool.hatch.envs.{env_name}.overrides.{source}.{condition}.{option}` "
f"must have an option named `value`"
)
raise ValueError(message)
value = entry["value"]
if not isinstance(value, str):
message = (
f"Option `value` in entry #{i} in field `tool.hatch.envs.{env_name}.overrides.{source}."
f"{condition}.{option}` must be a string"
)
raise TypeError(message)
if _resolve_condition(env_name, option, source, condition, condition_value, entry, i):
new_config[option] = value
break
else:
message = (
f"Entry #{i} in field `tool.hatch.envs.{env_name}.overrides.{source}.{condition}.{option}` "
f"must be a string or an inline table"
)
raise TypeError(message)
else:
message = (
f"Field `tool.hatch.envs.{env_name}.overrides.{source}.{condition}.{option}` "
f"must be a string, inline table, or an array"
)
raise TypeError(message)
def _apply_override_to_boolean(
env_name,
option,
data,
source,
condition,
condition_value,
new_config,
overwrite, # noqa: ARG001
):
if isinstance(data, bool):
new_config[option] = data
elif isinstance(data, dict):
if "value" not in data:
message = (
f"Field `tool.hatch.envs.{env_name}.overrides.{source}.{condition}.{option}` "
f"must have an option named `value`"
)
raise ValueError(message)
value = data["value"]
if not isinstance(value, bool):
message = (
f"Option `value` in field `tool.hatch.envs.{env_name}.overrides.{source}."
f"{condition}.{option}` must be a boolean"
)
raise TypeError(message)
if _resolve_condition(env_name, option, source, condition, condition_value, data):
new_config[option] = value
elif isinstance(data, list):
for i, entry in enumerate(data, 1):
if isinstance(entry, bool):
new_config[option] = entry
break
if isinstance(entry, dict):
if "value" not in entry:
message = (
f"Entry #{i} in field `tool.hatch.envs.{env_name}.overrides.{source}.{condition}.{option}` "
f"must have an option named `value`"
)
raise ValueError(message)
value = entry["value"]
if not isinstance(value, bool):
message = (
f"Option `value` in entry #{i} in field `tool.hatch.envs.{env_name}.overrides.{source}."
f"{condition}.{option}` must be a boolean"
)
raise TypeError(message)
if _resolve_condition(env_name, option, source, condition, condition_value, entry, i):
new_config[option] = value
break
else:
message = (
f"Entry #{i} in field `tool.hatch.envs.{env_name}.overrides.{source}.{condition}.{option}` "
f"must be a boolean or an inline table"
)
raise TypeError(message)
else:
message = (
f"Field `tool.hatch.envs.{env_name}.overrides.{source}.{condition}.{option}` "
f"must be a boolean, inline table, or an array"
)
raise TypeError(message)
def _apply_override_to_workspace(env_name, option, data, source, condition, condition_value, new_config, overwrite):
"""Handle workspace dict with nested members/exclude/parallel."""
if not isinstance(data, dict):
message = f"Field `tool.hatch.envs.{env_name}.overrides.{source}.{condition}.{option}` must be a table"
raise TypeError(message)
# Get or create workspace dict
workspace = {} if overwrite else new_config.setdefault(option, {})
for key, value in data.items():
if key in {"members", "exclude"}:
# Delegate to array handler - pass workspace dict
_apply_override_to_array(env_name, key, value, source, condition, condition_value, workspace, overwrite)
elif key == "parallel":
# Delegate to boolean handler - pass workspace dict
_apply_override_to_boolean(env_name, key, value, source, condition, condition_value, workspace, overwrite)
else:
message = f"Unknown workspace option: {key}"
raise ValueError(message)
# Update new_config with the workspace dict
if overwrite or workspace:
new_config[option] = workspace
def _resolve_condition(env_name, option, source, condition, condition_value, condition_config, condition_index=None):
location = "field" if condition_index is None else f"entry #{condition_index} in field"
if "if" in condition_config:
allowed_values = condition_config["if"]
if not isinstance(allowed_values, list):
message = (
f"Option `if` in {location} `tool.hatch.envs.{env_name}.overrides.{source}."
f"{condition}.{option}` must be an array"
)
raise TypeError(message)
if condition_value not in allowed_values:
return False
if "platform" in condition_config:
allowed_platforms = condition_config["platform"]
if not isinstance(allowed_platforms, list):
message = (
f"Option `platform` in {location} `tool.hatch.envs.{env_name}.overrides.{source}."
f"{condition}.{option}` must be an array"
)
raise TypeError(message)
for i, entry in enumerate(allowed_platforms, 1):
if not isinstance(entry, str):
message = (
f"Item #{i} in option `platform` in {location} `tool.hatch.envs.{env_name}.overrides.{source}."
f"{condition}.{option}` must be a string"
)
raise TypeError(message)
if get_platform_name() not in allowed_platforms:
return False
if "env" in condition_config:
env_vars = condition_config["env"]
if not isinstance(env_vars, list):
message = (
f"Option `env` in {location} `tool.hatch.envs.{env_name}.overrides.{source}."
f"{condition}.{option}` must be an array"
)
raise TypeError(message)
required_env_vars = {}
for i, entry in enumerate(env_vars, 1):
if not isinstance(entry, str):
message = (
f"Item #{i} in option `env` in {location} `tool.hatch.envs.{env_name}.overrides.{source}."
f"{condition}.{option}` must be a string"
)
raise TypeError(message)
# Allow matching empty strings
if "=" in entry:
env_var, _, value = entry.partition("=")
required_env_vars[env_var] = value
else:
required_env_vars[entry] = None
for env_var, value in required_env_vars.items():
if env_var not in environ or (value is not None and value != environ[env_var]):
return False
return True
TYPE_OVERRIDES = {
dict: _apply_override_to_mapping,
list: _apply_override_to_array,
str: _apply_override_to_string,
bool: _apply_override_to_boolean,
}
class EnvironmentMetadata:
def __init__(self, data_dir: Path, project_path: Path):
self.__data_dir = data_dir
self.__project_path = project_path
def dependency_hash(self, environment: EnvironmentInterface) -> str:
return self._read(environment).get("dependency_hash", "")
def update_dependency_hash(self, environment: EnvironmentInterface, dependency_hash: str) -> None:
metadata = self._read(environment)
metadata["dependency_hash"] = dependency_hash
self._write(environment, metadata)
def reset(self, environment: EnvironmentInterface) -> None:
self._metadata_file(environment).unlink(missing_ok=True)
def _read(self, environment: EnvironmentInterface) -> dict[str, Any]:
import json
metadata_file = self._metadata_file(environment)
if not metadata_file.is_file():
return {}
return json.loads(metadata_file.read_text())
def _write(self, environment: EnvironmentInterface, metadata: dict[str, Any]) -> None:
import json
metadata_file = self._metadata_file(environment)
metadata_file.parent.ensure_dir_exists()
metadata_file.write_text(json.dumps(metadata))
def _metadata_file(self, environment: EnvironmentInterface) -> Path:
from hatch.env.internal import is_isolated_environment
if is_isolated_environment(environment.name, environment.config):
return self.__data_dir / ".internal" / f"{environment.name}.json"
return self._storage_dir / environment.config["type"] / f"{environment.name}.json"
@cached_property
def _storage_dir(self) -> Path:
return self.__data_dir / self.__project_path.id
================================================
FILE: src/hatch/project/frontend/__init__.py
================================================
================================================
FILE: src/hatch/project/frontend/core.py
================================================
from __future__ import annotations
import json
from functools import cache
from typing import TYPE_CHECKING, Any, Literal
from hatch.utils.fs import Path
from hatch.utils.runner import ExecutionContext
if TYPE_CHECKING:
from hatch.env.plugin.interface import EnvironmentInterface
from hatch.project.core import Project
class BuildFrontend:
def __init__(self, project: Project, env: EnvironmentInterface) -> None:
self.__project = project
self.__env = env
self.__scripts = StandardBuildFrontendScripts(self.__project, self.__env)
self.__hatch = HatchBuildFrontend(self.__project, self.__env)
@property
def scripts(self) -> StandardBuildFrontendScripts:
return self.__scripts
@property
def hatch(self) -> HatchBuildFrontend:
return self.__hatch
def build_sdist(self, directory: Path) -> Path:
with self.__env.fs_context() as fs_context:
output_context = fs_context.join("output")
output_context.local_path.ensure_dir_exists()
script = self.scripts.build_sdist(project_root=self.__env.project_root, output_dir=output_context.env_path)
script_context = fs_context.join("build_sdist.py")
script_context.local_path.parent.ensure_dir_exists()
script_context.local_path.write_text(script)
script_context.sync_env()
context = ExecutionContext(self.__env)
context.add_shell_command(["python", "-u", script_context.env_path])
self.__env.app.execute_context(context)
output_context.sync_local()
output_path = output_context.local_path / "output.json"
output = json.loads(output_path.read_text())
work_dir = output_context.local_path / "work"
artifact_path = Path(work_dir / output["return_val"])
artifact_path.move(directory)
return directory / artifact_path.name
def build_wheel(self, directory: Path) -> Path:
with self.__env.fs_context() as fs_context:
output_context = fs_context.join("output")
output_context.local_path.ensure_dir_exists()
script = self.scripts.build_wheel(project_root=self.__env.project_root, output_dir=output_context.env_path)
script_context = fs_context.join("build_wheel.py")
script_context.local_path.parent.ensure_dir_exists()
script_context.local_path.write_text(script)
script_context.sync_env()
context = ExecutionContext(self.__env)
context.add_shell_command(["python", "-u", script_context.env_path])
self.__env.app.execute_context(context)
output_context.sync_local()
output_path = output_context.local_path / "output.json"
output = json.loads(output_path.read_text())
work_dir = output_context.local_path / "work"
artifact_path = Path(work_dir / output["return_val"])
artifact_path.move(directory)
return directory / artifact_path.name
def get_requires(self, build: Literal["sdist", "wheel", "editable"]) -> list[str]:
with self.__env.fs_context() as fs_context:
output_context = fs_context.join("output")
output_context.local_path.ensure_dir_exists()
script = self.scripts.get_requires(
project_root=self.__env.project_root, output_dir=output_context.env_path, build=build
)
script_context = fs_context.join(f"get_requires_{build}.py")
script_context.local_path.parent.ensure_dir_exists()
script_context.local_path.write_text(script)
script_context.sync_env()
context = ExecutionContext(self.__env)
context.add_shell_command(["python", "-u", script_context.env_path])
self.__env.app.execute_context(context)
output_context.sync_local()
output_path = output_context.local_path / "output.json"
output = json.loads(output_path.read_text())
return output["return_val"]
def get_core_metadata(self, *, editable: bool = False) -> dict[str, Any]:
from hatchling.metadata.spec import project_metadata_from_core_metadata
with self.__env.fs_context() as fs_context:
output_context = fs_context.join("output")
output_context.local_path.ensure_dir_exists()
script = self.scripts.prepare_metadata(
project_root=self.__env.project_root, output_dir=output_context.env_path, editable=editable
)
script_context = fs_context.join("get_core_metadata.py")
script_context.local_path.parent.ensure_dir_exists()
script_context.local_path.write_text(script)
script_context.sync_env()
context = ExecutionContext(self.__env)
context.add_shell_command(["python", "-u", script_context.env_path])
self.__env.app.execute_context(context)
output_context.sync_local()
output_path = output_context.local_path / "output.json"
output = json.loads(output_path.read_text())
work_dir = output_context.local_path / "work"
metadata_file = Path(work_dir) / output["return_val"] / "METADATA"
return project_metadata_from_core_metadata(metadata_file.read_text())
class HatchBuildFrontend:
def __init__(self, project: Project, env: EnvironmentInterface) -> None:
self.__project = project
self.__env = env
self.__scripts = HatchBuildFrontendScripts(self.__project, self.__env)
@property
def scripts(self) -> HatchBuildFrontendScripts:
return self.__scripts
def get_build_deps(self, targets: list[str]) -> list[str]:
with self.__env.fs_context() as fs_context:
output_context = fs_context.join("output")
output_context.local_path.ensure_dir_exists()
script = self.scripts.get_build_deps(
project_root=self.__env.project_root, output_dir=output_context.env_path, targets=targets
)
script_context = fs_context.join(f"get_build_deps_{'_'.join(targets)}.py")
script_context.local_path.parent.ensure_dir_exists()
script_context.local_path.write_text(script)
script_context.sync_env()
context = ExecutionContext(self.__env)
context.add_shell_command(["python", "-u", script_context.env_path])
self.__env.app.execute_context(context)
output_context.sync_local()
output_path = output_context.local_path / "output.json"
output: list[str] = json.loads(output_path.read_text())
return output
def get_core_metadata(self) -> dict[str, Any]:
with self.__env.fs_context() as fs_context:
output_context = fs_context.join("output")
output_context.local_path.ensure_dir_exists()
script = self.scripts.get_core_metadata(
project_root=self.__env.project_root, output_dir=output_context.env_path
)
script_context = fs_context.join("get_core_metadata.py")
script_context.local_path.parent.ensure_dir_exists()
script_context.local_path.write_text(script)
script_context.sync_env()
context = ExecutionContext(self.__env)
context.add_shell_command(["python", "-u", script_context.env_path])
self.__env.app.execute_context(context)
output_context.sync_local()
output_path = output_context.local_path / "output.json"
output: dict[str, Any] = json.loads(output_path.read_text())
return output
def get_required_build_deps(self, targets: list[str]) -> list[str]:
target_dependencies: list[str] = []
hooks: set[str] = set()
for target in targets:
target_config = self.__project.config.build.target(target)
target_dependencies.extend(target_config.dependencies)
hooks.update(target_config.hook_config)
# Remove any build hooks that are known to not define any dependencies dynamically
hooks.difference_update((
# Built-in
"version",
# Popular third-party
"vcs",
))
if hooks:
return self.get_build_deps(targets)
return target_dependencies
class BuildFrontendScripts:
def __init__(self, project: Project, env: EnvironmentInterface) -> None:
self._project = project
self._env = env
@staticmethod
def inject_data(script: str, data: dict[str, Any]) -> str:
# All scripts have a constant dictionary on top
return script.replace("{}", repr(data), 1)
class StandardBuildFrontendScripts(BuildFrontendScripts):
def get_runner_script(
self,
*,
project_root: str,
output_dir: str,
hook: str,
kwargs: dict[str, Any],
) -> str:
return self.inject_data(
runner_script(),
{
"project_root": project_root,
"output_dir": output_dir,
"hook": hook,
"kwargs": kwargs,
"backend": self._project.metadata.build.build_backend,
"backend_path": self._env.pathsep.join(self._project.metadata.build.backend_path),
"hook_caller_script": hook_caller_script(),
},
)
def get_requires(
self,
*,
project_root: str,
output_dir: str,
build: Literal["sdist", "wheel", "editable"],
) -> str:
return self.get_runner_script(
project_root=project_root,
output_dir=output_dir,
hook=f"get_requires_for_build_{build}",
kwargs={"config_settings": None},
)
def prepare_metadata(self, *, output_dir: str, project_root: str, editable: bool = False) -> str:
return self.get_runner_script(
project_root=project_root,
output_dir=output_dir,
hook="prepare_metadata_for_build_editable" if editable else "prepare_metadata_for_build_wheel",
kwargs={"work_dir": "metadata_directory", "config_settings": None, "_allow_fallback": True},
)
def build_wheel(self, *, output_dir: str, project_root: str, editable: bool = False) -> str:
return self.get_runner_script(
project_root=project_root,
output_dir=output_dir,
hook="build_editable" if editable else "build_wheel",
kwargs={"work_dir": "wheel_directory", "config_settings": None, "metadata_directory": None},
)
def build_sdist(self, *, output_dir: str, project_root: str) -> str:
return self.get_runner_script(
project_root=project_root,
output_dir=output_dir,
hook="build_sdist",
kwargs={"work_dir": "sdist_directory", "config_settings": None},
)
class HatchBuildFrontendScripts(BuildFrontendScripts):
def get_build_deps(self, *, output_dir: str, project_root: str, targets: list[str]) -> str:
return self.inject_data(
hatch_build_deps_script(),
{
"project_root": project_root,
"output_dir": output_dir,
"targets": targets,
},
)
def get_core_metadata(self, *, output_dir: str, project_root: str) -> str:
return self.inject_data(
hatch_core_metadata_script(),
{
"project_root": project_root,
"output_dir": output_dir,
},
)
@cache
def hook_caller_script() -> str:
from importlib.resources import files
script = files("pyproject_hooks._in_process") / "_in_process.py"
return script.read_text(encoding="utf-8")
@cache
def runner_script() -> str:
from importlib.resources import files
script = files("hatch.project.frontend.scripts") / "standard.py"
return script.read_text(encoding="utf-8")
@cache
def hatch_build_deps_script() -> str:
from importlib.resources import files
script = files("hatch.project.frontend.scripts") / "build_deps.py"
return script.read_text(encoding="utf-8")
@cache
def hatch_core_metadata_script() -> str:
from importlib.resources import files
script = files("hatch.project.frontend.scripts") / "core_metadata.py"
return script.read_text(encoding="utf-8")
================================================
FILE: src/hatch/project/frontend/scripts/__init__.py
================================================
================================================
FILE: src/hatch/project/frontend/scripts/build_deps.py
================================================
from __future__ import annotations
import json
import os
from hatchling.bridge.app import Application
from hatchling.metadata.core import ProjectMetadata
from hatchling.plugin.manager import PluginManager
RUNNER: dict = {}
def main() -> None:
project_root: str = RUNNER["project_root"]
output_dir: str = RUNNER["output_dir"]
targets: list[str] = RUNNER["targets"]
app = Application()
plugin_manager = PluginManager()
metadata = ProjectMetadata(project_root, plugin_manager)
dependencies: dict[str, None] = {}
for target_name in targets:
builder_class = plugin_manager.builder.get(target_name)
if builder_class is None:
continue
builder = builder_class(
project_root, plugin_manager=plugin_manager, metadata=metadata, app=app.get_safe_application()
)
for dependency in builder.config.dependencies:
dependencies[dependency] = None
output = json.dumps(list(dependencies))
with open(os.path.join(output_dir, "output.json"), "w", encoding="utf-8") as f:
f.write(output)
if __name__ == "__main__":
main()
================================================
FILE: src/hatch/project/frontend/scripts/core_metadata.py
================================================
from __future__ import annotations
import json
import os
from hatchling.metadata.core import ProjectMetadata
from hatchling.metadata.utils import resolve_metadata_fields
from hatchling.plugin.manager import PluginManager
RUNNER: dict = {}
def main() -> None:
project_root: str = RUNNER["project_root"]
output_dir: str = RUNNER["output_dir"]
project_metadata = ProjectMetadata(project_root, PluginManager())
core_metadata = resolve_metadata_fields(project_metadata)
for key, value in list(core_metadata.items()):
if not value:
core_metadata.pop(key)
output = json.dumps(core_metadata)
with open(os.path.join(output_dir, "output.json"), "w", encoding="utf-8") as f:
f.write(output)
if __name__ == "__main__":
main()
================================================
FILE: src/hatch/project/frontend/scripts/standard.py
================================================
from __future__ import annotations
import json
import os
import shutil
import subprocess
import sys
from tempfile import TemporaryDirectory
RUNNER: dict = {}
def main() -> int:
project_root: str = RUNNER["project_root"]
output_dir: str = RUNNER["output_dir"]
hook: str = RUNNER["hook"]
kwargs: dict[str, str] = RUNNER["kwargs"]
backend: str = RUNNER["backend"]
backend_path: str = RUNNER["backend_path"]
hook_caller_script: str = RUNNER["hook_caller_script"]
with TemporaryDirectory() as d:
temp_dir = os.path.realpath(d)
control_dir = os.path.join(temp_dir, "control")
os.mkdir(control_dir)
input_file = os.path.join(control_dir, "input.json")
output_file = os.path.join(control_dir, "output.json")
env_vars = dict(os.environ)
env_vars["_PYPROJECT_HOOKS_BUILD_BACKEND"] = backend
if backend_path:
env_vars["_PYPROJECT_HOOKS_BACKEND_PATH"] = backend_path
if "work_dir" in kwargs:
work_dir = os.path.join(temp_dir, "work")
os.mkdir(work_dir)
kwargs[kwargs.pop("work_dir")] = work_dir
else:
work_dir = ""
with open(input_file, "w", encoding="utf-8") as f:
f.write(json.dumps({"kwargs": kwargs}))
script_path = os.path.join(temp_dir, "script.py")
with open(script_path, "w", encoding="utf-8") as f:
f.write(hook_caller_script)
process = subprocess.run(
[sys.executable, script_path, hook, str(control_dir)],
cwd=project_root,
env=env_vars,
check=False,
)
if process.returncode:
return process.returncode
with open(output_file, encoding="utf-8") as f:
output = json.loads(f.read())
if output.get("no_backend", False):
sys.stderr.write(f"{output['traceback']}\n{output['backend_error']}\n")
return 1
if output.get("unsupported", False):
sys.stderr.write(output["traceback"])
return 1
if output.get("hook_missing", False):
sys.stderr.write(f"Build backend API `{backend}` is missing hook: {output['missing_hook_name']}\n")
return 1
shutil.move(output_file, output_dir)
if work_dir:
shutil.move(work_dir, output_dir)
return 0
if __name__ == "__main__":
code = main()
sys.stderr.flush()
os._exit(code)
================================================
FILE: src/hatch/project/utils.py
================================================
from __future__ import annotations
from typing import TYPE_CHECKING, Any
if TYPE_CHECKING:
from collections.abc import Iterable
def parse_script_command(command: str) -> tuple[str, str, bool]:
possible_script, _, args = command.partition(" ")
if possible_script == "-":
ignore_exit_code = True
possible_script, _, args = args.partition(" ")
else:
ignore_exit_code = False
return possible_script, args, ignore_exit_code
def format_script_commands(*, commands: list[str], args: str, ignore_exit_code: bool) -> Iterable[str]:
for command in commands:
if args:
yield f"{command} {args}"
elif ignore_exit_code and not command.startswith("- "):
yield f"- {command}"
else:
yield command
def parse_inline_script_metadata(script: str) -> dict[str, Any] | None:
"""
https://peps.python.org/pep-0723/#reference-implementation
"""
import re
from hatch.utils.toml import load_toml_data
block_type = "script"
pattern = re.compile(r"(?m)^# /// (?P[a-zA-Z0-9-]+)$\s(?P(^#(| .*)$\s)+)^# ///$")
matches = list(filter(lambda m: m.group("type") == block_type, pattern.finditer(script)))
if len(matches) > 1:
message = f"Multiple inline metadata blocks found for type: {block_type}"
raise ValueError(message)
if len(matches) == 1:
content = "".join(
line[2:] if line.startswith("# ") else line[1:]
for line in matches[0].group("content").splitlines(keepends=True)
)
return load_toml_data(content)
return None
================================================
FILE: src/hatch/publish/__init__.py
================================================
================================================
FILE: src/hatch/publish/auth.py
================================================
from __future__ import annotations
from hatch.utils.fs import Path
class AuthenticationCredentials:
def __init__(
self,
app,
cache_dir: Path,
options: dict,
repo: str,
repo_config: dict[str, str],
):
self._app = app
self._pwu_path = cache_dir / "previous_working_users.json"
self._options = options
self._repo = repo
self._repo_config = repo_config
self.__username: str | None = None
self.__password: str | None = None
self.__username_was_read = False
self.__password_was_read = False
self.__pwu_data: dict[str, str] = {}
@property
def password(self) -> str:
if self.__password is None:
self.__password = self.__get_password()
return self.__password
@property
def username(self) -> str:
if self.__username is None:
self.__username = self.__get_username()
return self.__username
def __get_password(self) -> str:
# this method doesn't consider .pypirc as the __password attribute would have
# been set when it was looked up during username retrieval
password = self._options.get("auth") or self._repo_config.get("auth")
if password is not None:
return password
import keyring
keyring_service = self._repo_config["url"]
password = keyring.get_password(keyring_service, self.username)
if password is not None:
return password
if self._options["no_prompt"]:
self._app.abort("Missing required option: auth")
self.__password_was_read = True
return self._app.prompt("Password / Token", hide_input=True)
def __get_username(self) -> str:
username = (
self._options.get("user")
or self._repo_config.get("user")
or self._read_pypirc()
or self._read_previous_working_user_data()
)
if username is not None:
return username
if self._options["no_prompt"]:
self._app.abort("Missing required option: user")
self.__username_was_read = True
return self._app.prompt(f"Username for '{self._repo_config['url']}' [__token__]") or "__token__"
def _read_previous_working_user_data(self) -> str | None:
if self._pwu_path.is_file():
contents = self._pwu_path.read_text()
if contents:
import json
self.__pwu_data = json.loads(contents)
return self.__pwu_data.get(self._repo)
def _read_pypirc(self) -> str | None:
import configparser
pypirc = configparser.ConfigParser()
pypirc.read(Path.home() / ".pypirc")
repo = self._repo or "pypi"
if pypirc.has_section(repo):
self.__password = pypirc.get(section=repo, option="password", fallback=None)
return pypirc.get(section=repo, option="username", fallback=None)
repo_url = self._repo_config["url"]
for section in pypirc.sections():
if pypirc.get(section=section, option="repository", fallback=None) == repo_url:
self.__password = pypirc.get(section=section, option="password", fallback=None)
return pypirc.get(section=section, option="username", fallback=None)
return None
def write_updated_data(self):
if self.__username_was_read:
import json
self.__pwu_data[self._repo] = self.__username
self._pwu_path.ensure_parent_dir_exists()
self._pwu_path.write_text(json.dumps(self.__pwu_data))
if self.__password_was_read:
import keyring
keyring_service = self._repo_config["url"]
keyring.set_password(keyring_service, self.__username, self.__password)
================================================
FILE: src/hatch/publish/index.py
================================================
from __future__ import annotations
import re
from typing import TYPE_CHECKING
from hatch.publish.plugin.interface import PublisherInterface
from hatch.utils.fs import Path
from hatchling.metadata.utils import normalize_project_name
if TYPE_CHECKING:
from collections.abc import Iterable
class IndexPublisher(PublisherInterface):
PLUGIN_NAME = "index"
def get_repos(self):
global_plugin_config = self.plugin_config.copy()
defined_repos = self.plugin_config.pop("repos", {})
self.plugin_config.pop("repo", None)
# Normalize type
repos = {}
for repo, data in defined_repos.items():
if isinstance(data, str):
repos[repo] = {"url": data}
elif not isinstance(data, dict):
self.app.abort(f"Hatch config field `publish.index.repos.{repo}` must be a string or a mapping")
elif "url" not in data:
self.app.abort(f"Hatch config field `publish.index.repos.{repo}` must define a `url` key")
else:
repos[repo] = data
# Ensure PyPI correct
for repo, url in (
("main", "https://upload.pypi.org/legacy/"),
("test", "https://test.pypi.org/legacy/"),
):
repos.setdefault(repo, {})["url"] = url
# Populate defaults
for config in repos.values():
for key, value in global_plugin_config.items():
config.setdefault(key, value)
return repos
def publish(self, artifacts: list, options: dict):
"""
https://warehouse.readthedocs.io/api-reference/legacy.html#upload-api
"""
from collections import defaultdict
from hatch.index.core import PackageIndex
from hatch.index.publish import get_sdist_form_data, get_wheel_form_data
from hatch.publish.auth import AuthenticationCredentials
if not artifacts:
from hatchling.builders.constants import DEFAULT_BUILD_DIRECTORY
artifacts = [DEFAULT_BUILD_DIRECTORY]
repo = options["repo"] if "repo" in options else self.plugin_config.get("repo", "main")
repos = self.get_repos()
repo_config: dict[str, str] = repos[repo] if repo in repos else {"url": repo}
credentials = AuthenticationCredentials(
app=self.app,
cache_dir=self.cache_dir,
options=options,
repo=repo,
repo_config=repo_config,
)
index = PackageIndex(
repo_config["url"],
user=credentials.username,
auth=credentials.password,
ca_cert=options.get("ca_cert", repo_config.get("ca-cert")),
client_cert=options.get("client_cert", repo_config.get("client-cert")),
client_key=options.get("client_key", repo_config.get("client-key")),
)
existing_artifacts: dict[str, set[str]] = {}
# Use as an ordered set
project_versions: dict[str, dict[str, None]] = defaultdict(dict)
artifacts_found = False
for artifact in recurse_artifacts(artifacts, self.root):
if artifact.name.endswith(".whl"):
data = get_wheel_form_data(artifact)
elif artifact.name.endswith(".tar.gz"):
data = get_sdist_form_data(artifact)
else:
continue
artifacts_found = True
for field in ("name", "version"):
if field not in data:
self.app.abort(f"Missing required field `{field}` in artifact: {artifact}")
try:
displayed_path = str(artifact.relative_to(self.root))
except ValueError:
displayed_path = str(artifact)
self.app.display_info(f"{displayed_path} ...", end=" ")
project_name = normalize_project_name(data["name"])
if project_name not in existing_artifacts:
try:
response = index.get_simple_api(project_name)
response.raise_for_status()
except Exception: # no cov # noqa: BLE001
existing_artifacts[project_name] = set()
else:
existing_artifacts[project_name] = set(parse_artifacts(response.text))
if artifact.name in existing_artifacts[project_name]:
self.app.display_warning("already exists")
continue
try:
index.upload_artifact(artifact, data)
except Exception as e: # noqa: BLE001
self.app.display_error("failed")
self.app.abort(f"Error uploading to repository: {index.repo} - {e}".replace(index.auth, "*****"))
else:
self.app.display_success("success")
existing_artifacts[project_name].add(artifact.name)
project_versions[project_name][data["version"]] = None
if not options["initialize_auth"]:
if not artifacts_found:
self.app.abort("No artifacts found")
elif not project_versions:
self.app.abort(code=0)
for project_name, versions in project_versions.items():
self.app.display_info()
self.app.display_mini_header(project_name)
for version in versions:
self.app.display_info(str(index.urls.project.child(project_name, version, "").to_iri()))
credentials.write_updated_data()
def recurse_artifacts(artifacts: list, root) -> Iterable[Path]:
for raw_artifact in artifacts:
artifact = Path(raw_artifact)
if not artifact.is_absolute():
artifact = root / artifact
if artifact.is_file():
yield artifact
elif artifact.is_dir():
yield from artifact.iterdir()
def parse_artifacts(artifact_payload):
for match in re.finditer(r"]+>([^<]+) ", artifact_payload):
yield match.group(1)
================================================
FILE: src/hatch/publish/plugin/__init__.py
================================================
================================================
FILE: src/hatch/publish/plugin/hooks.py
================================================
from hatch.publish.index import IndexPublisher
from hatchling.plugin import hookimpl
@hookimpl
def hatch_register_publisher():
return IndexPublisher
================================================
FILE: src/hatch/publish/plugin/interface.py
================================================
from __future__ import annotations
from abc import ABC, abstractmethod
class PublisherInterface(ABC):
"""
Example usage:
```python tab="plugin.py"
from hatch.publish.plugin.interface import PublisherInterface
class SpecialPublisher(PublisherInterface):
PLUGIN_NAME = 'special'
...
```
```python tab="hooks.py"
from hatchling.plugin import hookimpl
from .plugin import SpecialPublisher
@hookimpl
def hatch_register_publisher():
return SpecialPublisher
```
"""
PLUGIN_NAME = ""
"""The name used for selection."""
def __init__(self, app, root, cache_dir, project_config, plugin_config):
self.__app = app
self.__root = root
self.__cache_dir = cache_dir
self.__project_config = project_config
self.__plugin_config = plugin_config
self.__disable = None
@property
def app(self):
"""
An instance of [Application](../utilities.md#hatchling.bridge.app.Application).
"""
return self.__app
@property
def root(self):
"""
The root of the project tree as a path-like object.
"""
return self.__root
@property
def cache_dir(self):
"""
The directory reserved exclusively for this plugin as a path-like object.
"""
return self.__cache_dir
@property
def project_config(self) -> dict:
"""
```toml config-example
[tool.hatch.publish.]
```
"""
return self.__project_config
@property
def plugin_config(self) -> dict:
"""
This is defined in Hatch's [config file](../../config/hatch.md).
```toml tab="config.toml"
[publish.]
```
"""
return self.__plugin_config
@property
def disable(self):
"""
Whether this plugin is disabled, thus requiring confirmation when publishing. Local
[project configuration](reference.md#hatch.publish.plugin.interface.PublisherInterface.project_config)
takes precedence over global
[plugin configuration](reference.md#hatch.publish.plugin.interface.PublisherInterface.plugin_config).
"""
if self.__disable is None:
if "disable" in self.project_config:
disable = self.project_config["disable"]
if not isinstance(disable, bool):
message = f"Field `tool.hatch.publish.{self.PLUGIN_NAME}.disable` must be a boolean"
raise TypeError(message)
else:
disable = self.plugin_config.get("disable", False)
if not isinstance(disable, bool):
message = f"Global plugin configuration `publish.{self.PLUGIN_NAME}.disable` must be a boolean"
raise TypeError(message)
self.__disable = disable
return self.__disable
@abstractmethod
def publish(self, artifacts: list[str], options: dict):
"""
:material-align-horizontal-left: **REQUIRED** :material-align-horizontal-right:
This is called directly by the [`publish`](../../cli/reference.md#hatch-publish) command
with the arguments and options it receives.
"""
================================================
FILE: src/hatch/py.typed
================================================
================================================
FILE: src/hatch/python/__init__.py
================================================
================================================
FILE: src/hatch/python/core.py
================================================
from __future__ import annotations
from typing import TYPE_CHECKING, Any
from hatch.python.distributions import DISTRIBUTIONS, ORDERED_DISTRIBUTIONS
from hatch.python.resolve import get_distribution
from hatch.utils.fs import temp_directory
if TYPE_CHECKING:
from hatch.python.resolve import Distribution
from hatch.utils.fs import Path
class InstalledDistribution:
def __init__(self, path: Path, distribution: Distribution, metadata: dict[str, Any]) -> None:
self.__path = path
self.__current_dist = distribution
self.__metadata = metadata
@property
def path(self) -> Path:
return self.__path
@property
def name(self) -> str:
return self.__current_dist.name
@property
def python_path(self) -> Path:
return self.path / self.__current_dist.python_path
@property
def version(self) -> str:
return self.__current_dist.version.base_version
@property
def metadata(self) -> dict[str, Any]:
return self.__metadata
def needs_update(self) -> bool:
new_dist = get_distribution(self.__current_dist.name)
return new_dist.version > self.__current_dist.version
@classmethod
def metadata_filename(cls) -> str:
return "hatch-dist.json"
class PythonManager:
def __init__(self, directory: Path) -> None:
self.__directory = directory
@property
def directory(self) -> Path:
return self.__directory
def get_installed(self) -> dict[str, InstalledDistribution]:
if not self.directory.is_dir():
return {}
import json
installed_distributions: list[InstalledDistribution] = []
for path in self.directory.iterdir():
if not (path.name in DISTRIBUTIONS and path.is_dir()):
continue
metadata_file = path / InstalledDistribution.metadata_filename()
if not metadata_file.is_file():
continue
metadata = json.loads(metadata_file.read_text())
distribution = get_distribution(path.name, source=metadata.get("source", ""))
if not (path / distribution.python_path).is_file():
continue
installed_distributions.append(InstalledDistribution(path, distribution, metadata))
installed_distributions.sort(key=lambda d: ORDERED_DISTRIBUTIONS.index(d.name))
return {dist.name: dist for dist in installed_distributions}
def install(self, identifier: str) -> InstalledDistribution:
import json
from hatch.utils.network import download_file
dist = get_distribution(identifier)
path = self.directory / identifier
self.directory.ensure_dir_exists()
with temp_directory() as temp_dir:
archive_path = temp_dir / dist.archive_name
unpack_path = temp_dir / identifier
download_file(archive_path, dist.source, follow_redirects=True)
dist.unpack(archive_path, unpack_path)
backup_path = path.with_suffix(".bak")
if backup_path.is_dir():
backup_path.wait_for_dir_removed()
if path.is_dir():
path.replace(backup_path)
try:
unpack_path.replace(path)
except OSError:
import shutil
try:
shutil.move(str(unpack_path), str(path))
except OSError:
path.wait_for_dir_removed()
if backup_path.is_dir():
backup_path.replace(path)
raise
metadata = {"source": dist.source, "python_path": dist.python_path}
metadata_file = path / InstalledDistribution.metadata_filename()
metadata_file.write_text(json.dumps(metadata, indent=2))
return InstalledDistribution(path, dist, metadata)
@staticmethod
def remove(dist: InstalledDistribution) -> None:
dist.path.wait_for_dir_removed()
================================================
FILE: src/hatch/python/distributions.py
================================================
from __future__ import annotations
# fmt: off
ORDERED_DISTRIBUTIONS: tuple[str, ...] = (
'3.7',
'3.8',
'3.9',
'3.10',
'3.11',
'3.12',
'3.13',
'3.14',
'pypy2.7',
'pypy3.9',
'pypy3.10',
'pypy3.11',
)
DISTRIBUTIONS: dict[str, dict[tuple[str, ...], str]] = {
'3.14': {
('linux', 'aarch64', 'gnu', '', ''):
'https://github.com/astral-sh/python-build-standalone/releases/download/20251014/cpython-3.14.0%2B20251014-aarch64-unknown-linux-gnu-install_only_stripped.tar.gz',
('linux', 'aarch64', 'gnu', '', 'freethreaded'):
'https://github.com/astral-sh/python-build-standalone/releases/download/20251014/cpython-3.14.0%2B20251014-aarch64-unknown-linux-gnu-freethreaded%2Bpgo%2Blto-full.tar.zst',
('linux', 'aarch64', 'musl', '', ''):
'https://github.com/astral-sh/python-build-standalone/releases/download/20251014/cpython-3.14.0%2B20251014-aarch64-unknown-linux-musl-install_only_stripped.tar.gz',
('linux', 'aarch64', 'musl', '', 'freethreaded'):
'https://github.com/astral-sh/python-build-standalone/releases/download/20251014/cpython-3.14.0%2B20251014-aarch64-unknown-linux-musl-freethreaded%2Bnoopt-full.tar.zst',
('linux', 'armv7', 'gnueabi', '', ''):
'https://github.com/astral-sh/python-build-standalone/releases/download/20251014/cpython-3.14.0%2B20251014-armv7-unknown-linux-gnueabi-install_only_stripped.tar.gz',
('linux', 'armv7', 'gnueabi', '', 'freethreaded'):
'https://github.com/astral-sh/python-build-standalone/releases/download/20251014/cpython-3.14.0%2B20251014-armv7-unknown-linux-gnueabi-freethreaded%2Bnoopt-full.tar.zst',
('linux', 'armv7', 'gnueabihf', '', ''):
'https://github.com/astral-sh/python-build-standalone/releases/download/20251014/cpython-3.14.0%2B20251014-armv7-unknown-linux-gnueabihf-install_only_stripped.tar.gz',
('linux', 'armv7', 'gnueabihf', '', 'freethreaded'):
'https://github.com/astral-sh/python-build-standalone/releases/download/20251014/cpython-3.14.0%2B20251014-armv7-unknown-linux-gnueabihf-freethreaded%2Bnoopt-full.tar.zst',
('linux', 'ppc64le', 'gnu', '', ''):
'https://github.com/astral-sh/python-build-standalone/releases/download/20251014/cpython-3.14.0%2B20251014-ppc64le-unknown-linux-gnu-install_only_stripped.tar.gz',
('linux', 'ppc64le', 'gnu', '', 'freethreaded'):
'https://github.com/astral-sh/python-build-standalone/releases/download/20251014/cpython-3.14.0%2B20251014-ppc64le-unknown-linux-gnu-freethreaded%2Bnoopt-full.tar.zst',
('linux', 'riscv64', 'gnu', '', ''):
'https://github.com/astral-sh/python-build-standalone/releases/download/20251014/cpython-3.14.0%2B20251014-riscv64-unknown-linux-gnu-install_only_stripped.tar.gz',
('linux', 'riscv64', 'gnu', '', 'freethreaded'):
'https://github.com/astral-sh/python-build-standalone/releases/download/20251014/cpython-3.14.0%2B20251014-riscv64-unknown-linux-gnu-freethreaded%2Bnoopt-full.tar.zst',
('linux', 's390x', 'gnu', '', ''):
'https://github.com/astral-sh/python-build-standalone/releases/download/20251014/cpython-3.14.0%2B20251014-s390x-unknown-linux-gnu-install_only_stripped.tar.gz',
('linux', 's390x', 'gnu', '', 'freethreaded'):
'https://github.com/astral-sh/python-build-standalone/releases/download/20251014/cpython-3.14.0%2B20251014-s390x-unknown-linux-gnu-freethreaded%2Bnoopt-full.tar.zst',
('linux', 'x86_64', 'gnu', 'v1', ''):
'https://github.com/astral-sh/python-build-standalone/releases/download/20251014/cpython-3.14.0%2B20251014-x86_64-unknown-linux-gnu-install_only_stripped.tar.gz',
('linux', 'x86_64', 'gnu', 'v1', 'freethreaded'):
'https://github.com/astral-sh/python-build-standalone/releases/download/20251014/cpython-3.14.0%2B20251014-x86_64-unknown-linux-gnu-freethreaded%2Bpgo%2Blto-full.tar.zst',
('linux', 'x86_64', 'gnu', 'v2', ''):
'https://github.com/astral-sh/python-build-standalone/releases/download/20251014/cpython-3.14.0%2B20251014-x86_64_v2-unknown-linux-gnu-install_only_stripped.tar.gz',
('linux', 'x86_64', 'gnu', 'v2', 'freethreaded'):
'https://github.com/astral-sh/python-build-standalone/releases/download/20251014/cpython-3.14.0%2B20251014-x86_64_v2-unknown-linux-gnu-freethreaded%2Bpgo%2Blto-full.tar.zst',
('linux', 'x86_64', 'gnu', 'v3', ''):
'https://github.com/astral-sh/python-build-standalone/releases/download/20251014/cpython-3.14.0%2B20251014-x86_64_v3-unknown-linux-gnu-install_only_stripped.tar.gz',
('linux', 'x86_64', 'gnu', 'v3', 'freethreaded'):
'https://github.com/astral-sh/python-build-standalone/releases/download/20251014/cpython-3.14.0%2B20251014-x86_64_v3-unknown-linux-gnu-freethreaded%2Bpgo%2Blto-full.tar.zst',
('linux', 'x86_64', 'gnu', 'v4', ''):
'https://github.com/astral-sh/python-build-standalone/releases/download/20251014/cpython-3.14.0%2B20251014-x86_64_v4-unknown-linux-gnu-install_only_stripped.tar.gz',
('linux', 'x86_64', 'gnu', 'v4', 'freethreaded'):
'https://github.com/astral-sh/python-build-standalone/releases/download/20251014/cpython-3.14.0%2B20251014-x86_64_v4-unknown-linux-gnu-freethreaded%2Bpgo%2Blto-full.tar.zst',
('linux', 'x86_64', 'musl', 'v1', ''):
'https://github.com/astral-sh/python-build-standalone/releases/download/20251014/cpython-3.14.0%2B20251014-x86_64-unknown-linux-musl-install_only_stripped.tar.gz',
('linux', 'x86_64', 'musl', 'v1', 'freethreaded'):
'https://github.com/astral-sh/python-build-standalone/releases/download/20251014/cpython-3.14.0%2B20251014-x86_64-unknown-linux-musl-freethreaded%2Bnoopt-full.tar.zst',
('linux', 'x86_64', 'musl', 'v2', ''):
'https://github.com/astral-sh/python-build-standalone/releases/download/20251014/cpython-3.14.0%2B20251014-x86_64_v2-unknown-linux-musl-install_only_stripped.tar.gz',
('linux', 'x86_64', 'musl', 'v2', 'freethreaded'):
'https://github.com/astral-sh/python-build-standalone/releases/download/20251014/cpython-3.14.0%2B20251014-x86_64_v2-unknown-linux-musl-freethreaded%2Bnoopt-full.tar.zst',
('linux', 'x86_64', 'musl', 'v3', ''):
'https://github.com/astral-sh/python-build-standalone/releases/download/20251014/cpython-3.14.0%2B20251014-x86_64_v3-unknown-linux-musl-install_only_stripped.tar.gz',
('linux', 'x86_64', 'musl', 'v3', 'freethreaded'):
'https://github.com/astral-sh/python-build-standalone/releases/download/20251014/cpython-3.14.0%2B20251014-x86_64_v3-unknown-linux-musl-freethreaded%2Bnoopt-full.tar.zst',
('linux', 'x86_64', 'musl', 'v4', ''):
'https://github.com/astral-sh/python-build-standalone/releases/download/20251014/cpython-3.14.0%2B20251014-x86_64_v4-unknown-linux-musl-install_only_stripped.tar.gz',
('linux', 'x86_64', 'musl', 'v4', 'freethreaded'):
'https://github.com/astral-sh/python-build-standalone/releases/download/20251014/cpython-3.14.0%2B20251014-x86_64_v4-unknown-linux-musl-freethreaded%2Bnoopt-full.tar.zst',
('windows', 'aarch64', 'msvc', '', ''):
'https://github.com/astral-sh/python-build-standalone/releases/download/20251014/cpython-3.14.0%2B20251014-aarch64-pc-windows-msvc-install_only_stripped.tar.gz',
('windows', 'aarch64', 'msvc', '', 'freethreaded'):
'https://github.com/astral-sh/python-build-standalone/releases/download/20251014/cpython-3.14.0%2B20251014-aarch64-pc-windows-msvc-freethreaded%2Bpgo-full.tar.zst',
('windows', 'i386', 'msvc', '', ''):
'https://github.com/astral-sh/python-build-standalone/releases/download/20251014/cpython-3.14.0%2B20251014-i686-pc-windows-msvc-install_only_stripped.tar.gz',
('windows', 'i386', 'msvc', '', 'freethreaded'):
'https://github.com/astral-sh/python-build-standalone/releases/download/20251014/cpython-3.14.0%2B20251014-i686-pc-windows-msvc-freethreaded%2Bpgo-full.tar.zst',
('windows', 'amd64', 'msvc', '', ''):
'https://github.com/astral-sh/python-build-standalone/releases/download/20251014/cpython-3.14.0%2B20251014-x86_64-pc-windows-msvc-install_only_stripped.tar.gz',
('windows', 'amd64', 'msvc', '', 'freethreaded'):
'https://github.com/astral-sh/python-build-standalone/releases/download/20251014/cpython-3.14.0%2B20251014-x86_64-pc-windows-msvc-freethreaded%2Bpgo-full.tar.zst',
('macos', 'arm64', '', '', ''):
'https://github.com/astral-sh/python-build-standalone/releases/download/20251014/cpython-3.14.0%2B20251014-aarch64-apple-darwin-install_only_stripped.tar.gz',
('macos', 'arm64', '', '', 'freethreaded'):
'https://github.com/astral-sh/python-build-standalone/releases/download/20251014/cpython-3.14.0%2B20251014-aarch64-apple-darwin-freethreaded%2Bpgo%2Blto-full.tar.zst',
('macos', 'x86_64', '', '', ''):
'https://github.com/astral-sh/python-build-standalone/releases/download/20251014/cpython-3.14.0%2B20251014-x86_64-apple-darwin-install_only_stripped.tar.gz',
('macos', 'x86_64', '', '', 'freethreaded'):
'https://github.com/astral-sh/python-build-standalone/releases/download/20251014/cpython-3.14.0%2B20251014-x86_64-apple-darwin-freethreaded%2Bpgo%2Blto-full.tar.zst',
},
'3.13': {
('linux', 'aarch64', 'gnu', '', ''):
'https://github.com/astral-sh/python-build-standalone/releases/download/20251014/cpython-3.13.9%2B20251014-aarch64-unknown-linux-gnu-install_only_stripped.tar.gz',
('linux', 'aarch64', 'gnu', '', 'freethreaded'):
'https://github.com/astral-sh/python-build-standalone/releases/download/20251014/cpython-3.13.9%2B20251014-aarch64-unknown-linux-gnu-freethreaded%2Bpgo%2Blto-full.tar.zst',
('linux', 'aarch64', 'musl', '', ''):
'https://github.com/astral-sh/python-build-standalone/releases/download/20251014/cpython-3.13.9%2B20251014-aarch64-unknown-linux-musl-install_only_stripped.tar.gz',
('linux', 'aarch64', 'musl', '', 'freethreaded'):
'https://github.com/astral-sh/python-build-standalone/releases/download/20251014/cpython-3.13.9%2B20251014-aarch64-unknown-linux-musl-freethreaded%2Bnoopt-full.tar.zst',
('linux', 'armv7', 'gnueabi', '', ''):
'https://github.com/astral-sh/python-build-standalone/releases/download/20251014/cpython-3.13.9%2B20251014-armv7-unknown-linux-gnueabi-install_only_stripped.tar.gz',
('linux', 'armv7', 'gnueabi', '', 'freethreaded'):
'https://github.com/astral-sh/python-build-standalone/releases/download/20251014/cpython-3.13.9%2B20251014-armv7-unknown-linux-gnueabi-freethreaded%2Bnoopt-full.tar.zst',
('linux', 'armv7', 'gnueabihf', '', ''):
'https://github.com/astral-sh/python-build-standalone/releases/download/20251014/cpython-3.13.9%2B20251014-armv7-unknown-linux-gnueabihf-install_only_stripped.tar.gz',
('linux', 'armv7', 'gnueabihf', '', 'freethreaded'):
'https://github.com/astral-sh/python-build-standalone/releases/download/20251014/cpython-3.13.9%2B20251014-armv7-unknown-linux-gnueabihf-freethreaded%2Bnoopt-full.tar.zst',
('linux', 'ppc64le', 'gnu', '', ''):
'https://github.com/astral-sh/python-build-standalone/releases/download/20251014/cpython-3.13.9%2B20251014-ppc64le-unknown-linux-gnu-install_only_stripped.tar.gz',
('linux', 'ppc64le', 'gnu', '', 'freethreaded'):
'https://github.com/astral-sh/python-build-standalone/releases/download/20251014/cpython-3.13.9%2B20251014-ppc64le-unknown-linux-gnu-freethreaded%2Bnoopt-full.tar.zst',
('linux', 'riscv64', 'gnu', '', ''):
'https://github.com/astral-sh/python-build-standalone/releases/download/20251014/cpython-3.13.9%2B20251014-riscv64-unknown-linux-gnu-install_only_stripped.tar.gz',
('linux', 'riscv64', 'gnu', '', 'freethreaded'):
'https://github.com/astral-sh/python-build-standalone/releases/download/20251014/cpython-3.13.9%2B20251014-riscv64-unknown-linux-gnu-freethreaded%2Bnoopt-full.tar.zst',
('linux', 's390x', 'gnu', '', ''):
'https://github.com/astral-sh/python-build-standalone/releases/download/20251014/cpython-3.13.9%2B20251014-s390x-unknown-linux-gnu-install_only_stripped.tar.gz',
('linux', 's390x', 'gnu', '', 'freethreaded'):
'https://github.com/astral-sh/python-build-standalone/releases/download/20251014/cpython-3.13.9%2B20251014-s390x-unknown-linux-gnu-freethreaded%2Bnoopt-full.tar.zst',
('linux', 'x86_64', 'gnu', 'v1', ''):
'https://github.com/astral-sh/python-build-standalone/releases/download/20251014/cpython-3.13.9%2B20251014-x86_64-unknown-linux-gnu-install_only_stripped.tar.gz',
('linux', 'x86_64', 'gnu', 'v1', 'freethreaded'):
'https://github.com/astral-sh/python-build-standalone/releases/download/20251014/cpython-3.13.9%2B20251014-x86_64-unknown-linux-gnu-freethreaded%2Bpgo%2Blto-full.tar.zst',
('linux', 'x86_64', 'gnu', 'v2', ''):
'https://github.com/astral-sh/python-build-standalone/releases/download/20251014/cpython-3.13.9%2B20251014-x86_64_v2-unknown-linux-gnu-install_only_stripped.tar.gz',
('linux', 'x86_64', 'gnu', 'v2', 'freethreaded'):
'https://github.com/astral-sh/python-build-standalone/releases/download/20251014/cpython-3.13.9%2B20251014-x86_64_v2-unknown-linux-gnu-freethreaded%2Bpgo%2Blto-full.tar.zst',
('linux', 'x86_64', 'gnu', 'v3', ''):
'https://github.com/astral-sh/python-build-standalone/releases/download/20251014/cpython-3.13.9%2B20251014-x86_64_v3-unknown-linux-gnu-install_only_stripped.tar.gz',
('linux', 'x86_64', 'gnu', 'v3', 'freethreaded'):
'https://github.com/astral-sh/python-build-standalone/releases/download/20251014/cpython-3.13.9%2B20251014-x86_64_v3-unknown-linux-gnu-freethreaded%2Bpgo%2Blto-full.tar.zst',
('linux', 'x86_64', 'gnu', 'v4', ''):
'https://github.com/astral-sh/python-build-standalone/releases/download/20251014/cpython-3.13.9%2B20251014-x86_64_v4-unknown-linux-gnu-install_only_stripped.tar.gz',
('linux', 'x86_64', 'gnu', 'v4', 'freethreaded'):
'https://github.com/astral-sh/python-build-standalone/releases/download/20251014/cpython-3.13.9%2B20251014-x86_64_v4-unknown-linux-gnu-freethreaded%2Bpgo%2Blto-full.tar.zst',
('linux', 'x86_64', 'musl', 'v1', ''):
'https://github.com/astral-sh/python-build-standalone/releases/download/20251014/cpython-3.13.9%2B20251014-x86_64-unknown-linux-musl-install_only_stripped.tar.gz',
('linux', 'x86_64', 'musl', 'v1', 'freethreaded'):
'https://github.com/astral-sh/python-build-standalone/releases/download/20251014/cpython-3.13.9%2B20251014-x86_64-unknown-linux-musl-freethreaded%2Bnoopt-full.tar.zst',
('linux', 'x86_64', 'musl', 'v2', ''):
'https://github.com/astral-sh/python-build-standalone/releases/download/20251014/cpython-3.13.9%2B20251014-x86_64_v2-unknown-linux-musl-install_only_stripped.tar.gz',
('linux', 'x86_64', 'musl', 'v2', 'freethreaded'):
'https://github.com/astral-sh/python-build-standalone/releases/download/20251014/cpython-3.13.9%2B20251014-x86_64_v2-unknown-linux-musl-freethreaded%2Bnoopt-full.tar.zst',
('linux', 'x86_64', 'musl', 'v3', ''):
'https://github.com/astral-sh/python-build-standalone/releases/download/20251014/cpython-3.13.9%2B20251014-x86_64_v3-unknown-linux-musl-install_only_stripped.tar.gz',
('linux', 'x86_64', 'musl', 'v3', 'freethreaded'):
'https://github.com/astral-sh/python-build-standalone/releases/download/20251014/cpython-3.13.9%2B20251014-x86_64_v3-unknown-linux-musl-freethreaded%2Bnoopt-full.tar.zst',
('linux', 'x86_64', 'musl', 'v4', ''):
'https://github.com/astral-sh/python-build-standalone/releases/download/20251014/cpython-3.13.9%2B20251014-x86_64_v4-unknown-linux-musl-install_only_stripped.tar.gz',
('linux', 'x86_64', 'musl', 'v4', 'freethreaded'):
'https://github.com/astral-sh/python-build-standalone/releases/download/20251014/cpython-3.13.9%2B20251014-x86_64_v4-unknown-linux-musl-freethreaded%2Bnoopt-full.tar.zst',
('windows', 'aarch64', 'msvc', '', ''):
'https://github.com/astral-sh/python-build-standalone/releases/download/20251014/cpython-3.13.9%2B20251014-aarch64-pc-windows-msvc-install_only_stripped.tar.gz',
('windows', 'aarch64', 'msvc', '', 'freethreaded'):
'https://github.com/astral-sh/python-build-standalone/releases/download/20251014/cpython-3.13.9%2B20251014-aarch64-pc-windows-msvc-freethreaded%2Bpgo-full.tar.zst',
('windows', 'i386', 'msvc', '', ''):
'https://github.com/astral-sh/python-build-standalone/releases/download/20251014/cpython-3.13.9%2B20251014-i686-pc-windows-msvc-install_only_stripped.tar.gz',
('windows', 'i386', 'msvc', '', 'freethreaded'):
'https://github.com/astral-sh/python-build-standalone/releases/download/20251014/cpython-3.13.9%2B20251014-i686-pc-windows-msvc-freethreaded%2Bpgo-full.tar.zst',
('windows', 'amd64', 'msvc', '', ''):
'https://github.com/astral-sh/python-build-standalone/releases/download/20251014/cpython-3.13.9%2B20251014-x86_64-pc-windows-msvc-install_only_stripped.tar.gz',
('windows', 'amd64', 'msvc', '', 'freethreaded'):
'https://github.com/astral-sh/python-build-standalone/releases/download/20251014/cpython-3.13.9%2B20251014-x86_64-pc-windows-msvc-freethreaded%2Bpgo-full.tar.zst',
('macos', 'arm64', '', '', ''):
'https://github.com/astral-sh/python-build-standalone/releases/download/20251014/cpython-3.13.9%2B20251014-aarch64-apple-darwin-install_only_stripped.tar.gz',
('macos', 'arm64', '', '', 'freethreaded'):
'https://github.com/astral-sh/python-build-standalone/releases/download/20251014/cpython-3.13.9%2B20251014-aarch64-apple-darwin-freethreaded%2Bpgo%2Blto-full.tar.zst',
('macos', 'x86_64', '', '', ''):
'https://github.com/astral-sh/python-build-standalone/releases/download/20251014/cpython-3.13.9%2B20251014-x86_64-apple-darwin-install_only_stripped.tar.gz',
('macos', 'x86_64', '', '', 'freethreaded'):
'https://github.com/astral-sh/python-build-standalone/releases/download/20251014/cpython-3.13.9%2B20251014-x86_64-apple-darwin-freethreaded%2Bpgo%2Blto-full.tar.zst',
},
'3.12': {
('linux', 'aarch64', 'gnu', '', ''):
'https://github.com/astral-sh/python-build-standalone/releases/download/20251014/cpython-3.12.12%2B20251014-aarch64-unknown-linux-gnu-install_only_stripped.tar.gz',
('linux', 'aarch64', 'musl', '', ''):
'https://github.com/astral-sh/python-build-standalone/releases/download/20251014/cpython-3.12.12%2B20251014-aarch64-unknown-linux-musl-install_only_stripped.tar.gz',
('linux', 'armv7', 'gnueabi', '', ''):
'https://github.com/astral-sh/python-build-standalone/releases/download/20251014/cpython-3.12.12%2B20251014-armv7-unknown-linux-gnueabi-install_only_stripped.tar.gz',
('linux', 'armv7', 'gnueabihf', '', ''):
'https://github.com/astral-sh/python-build-standalone/releases/download/20251014/cpython-3.12.12%2B20251014-armv7-unknown-linux-gnueabihf-install_only_stripped.tar.gz',
('linux', 'ppc64le', 'gnu', '', ''):
'https://github.com/astral-sh/python-build-standalone/releases/download/20251014/cpython-3.12.12%2B20251014-ppc64le-unknown-linux-gnu-install_only_stripped.tar.gz',
('linux', 'riscv64', 'gnu', '', ''):
'https://github.com/astral-sh/python-build-standalone/releases/download/20251014/cpython-3.12.12%2B20251014-riscv64-unknown-linux-gnu-install_only_stripped.tar.gz',
('linux', 's390x', 'gnu', '', ''):
'https://github.com/astral-sh/python-build-standalone/releases/download/20251014/cpython-3.12.12%2B20251014-s390x-unknown-linux-gnu-install_only_stripped.tar.gz',
('linux', 'x86_64', 'gnu', 'v1', ''):
'https://github.com/astral-sh/python-build-standalone/releases/download/20251014/cpython-3.12.12%2B20251014-x86_64-unknown-linux-gnu-install_only_stripped.tar.gz',
('linux', 'x86_64', 'gnu', 'v2', ''):
'https://github.com/astral-sh/python-build-standalone/releases/download/20251014/cpython-3.12.12%2B20251014-x86_64_v2-unknown-linux-gnu-install_only_stripped.tar.gz',
('linux', 'x86_64', 'gnu', 'v3', ''):
'https://github.com/astral-sh/python-build-standalone/releases/download/20251014/cpython-3.12.12%2B20251014-x86_64_v3-unknown-linux-gnu-install_only_stripped.tar.gz',
('linux', 'x86_64', 'gnu', 'v4', ''):
'https://github.com/astral-sh/python-build-standalone/releases/download/20251014/cpython-3.12.12%2B20251014-x86_64_v4-unknown-linux-gnu-install_only_stripped.tar.gz',
('linux', 'x86_64', 'musl', 'v1', ''):
'https://github.com/astral-sh/python-build-standalone/releases/download/20251014/cpython-3.12.12%2B20251014-x86_64-unknown-linux-musl-install_only_stripped.tar.gz',
('linux', 'x86_64', 'musl', 'v2', ''):
'https://github.com/astral-sh/python-build-standalone/releases/download/20251014/cpython-3.12.12%2B20251014-x86_64_v2-unknown-linux-musl-install_only_stripped.tar.gz',
('linux', 'x86_64', 'musl', 'v3', ''):
'https://github.com/astral-sh/python-build-standalone/releases/download/20251014/cpython-3.12.12%2B20251014-x86_64_v3-unknown-linux-musl-install_only_stripped.tar.gz',
('linux', 'x86_64', 'musl', 'v4', ''):
'https://github.com/astral-sh/python-build-standalone/releases/download/20251014/cpython-3.12.12%2B20251014-x86_64_v4-unknown-linux-musl-install_only_stripped.tar.gz',
('windows', 'aarch64', 'msvc', '', ''):
'https://github.com/astral-sh/python-build-standalone/releases/download/20251014/cpython-3.12.12%2B20251014-aarch64-pc-windows-msvc-install_only_stripped.tar.gz',
('windows', 'i386', 'msvc', '', ''):
'https://github.com/astral-sh/python-build-standalone/releases/download/20251014/cpython-3.12.12%2B20251014-i686-pc-windows-msvc-install_only_stripped.tar.gz',
('windows', 'amd64', 'msvc', '', ''):
'https://github.com/astral-sh/python-build-standalone/releases/download/20251014/cpython-3.12.12%2B20251014-x86_64-pc-windows-msvc-install_only_stripped.tar.gz',
('macos', 'arm64', '', '', ''):
'https://github.com/astral-sh/python-build-standalone/releases/download/20251014/cpython-3.12.12%2B20251014-aarch64-apple-darwin-install_only_stripped.tar.gz',
('macos', 'x86_64', '', '', ''):
'https://github.com/astral-sh/python-build-standalone/releases/download/20251014/cpython-3.12.12%2B20251014-x86_64-apple-darwin-install_only_stripped.tar.gz',
},
'3.11': {
('linux', 'aarch64', 'gnu', '', ''):
'https://github.com/astral-sh/python-build-standalone/releases/download/20251014/cpython-3.11.14%2B20251014-aarch64-unknown-linux-gnu-install_only_stripped.tar.gz',
('linux', 'aarch64', 'musl', '', ''):
'https://github.com/astral-sh/python-build-standalone/releases/download/20251014/cpython-3.11.14%2B20251014-aarch64-unknown-linux-musl-install_only_stripped.tar.gz',
('linux', 'armv7', 'gnueabi', '', ''):
'https://github.com/astral-sh/python-build-standalone/releases/download/20251014/cpython-3.11.14%2B20251014-armv7-unknown-linux-gnueabi-install_only_stripped.tar.gz',
('linux', 'armv7', 'gnueabihf', '', ''):
'https://github.com/astral-sh/python-build-standalone/releases/download/20251014/cpython-3.11.14%2B20251014-armv7-unknown-linux-gnueabihf-install_only_stripped.tar.gz',
('linux', 'ppc64le', 'gnu', '', ''):
'https://github.com/astral-sh/python-build-standalone/releases/download/20251014/cpython-3.11.14%2B20251014-ppc64le-unknown-linux-gnu-install_only_stripped.tar.gz',
('linux', 'riscv64', 'gnu', '', ''):
'https://github.com/astral-sh/python-build-standalone/releases/download/20251014/cpython-3.11.14%2B20251014-riscv64-unknown-linux-gnu-install_only_stripped.tar.gz',
('linux', 's390x', 'gnu', '', ''):
'https://github.com/astral-sh/python-build-standalone/releases/download/20251014/cpython-3.11.14%2B20251014-s390x-unknown-linux-gnu-install_only_stripped.tar.gz',
('linux', 'x86_64', 'gnu', 'v1', ''):
'https://github.com/astral-sh/python-build-standalone/releases/download/20251014/cpython-3.11.14%2B20251014-x86_64-unknown-linux-gnu-install_only_stripped.tar.gz',
('linux', 'x86_64', 'gnu', 'v2', ''):
'https://github.com/astral-sh/python-build-standalone/releases/download/20251014/cpython-3.11.14%2B20251014-x86_64_v2-unknown-linux-gnu-install_only_stripped.tar.gz',
('linux', 'x86_64', 'gnu', 'v3', ''):
'https://github.com/astral-sh/python-build-standalone/releases/download/20251014/cpython-3.11.14%2B20251014-x86_64_v3-unknown-linux-gnu-install_only_stripped.tar.gz',
('linux', 'x86_64', 'gnu', 'v4', ''):
'https://github.com/astral-sh/python-build-standalone/releases/download/20251014/cpython-3.11.14%2B20251014-x86_64_v4-unknown-linux-gnu-install_only_stripped.tar.gz',
('linux', 'x86_64', 'musl', 'v1', ''):
'https://github.com/astral-sh/python-build-standalone/releases/download/20251014/cpython-3.11.14%2B20251014-x86_64-unknown-linux-musl-install_only_stripped.tar.gz',
('linux', 'x86_64', 'musl', 'v2', ''):
'https://github.com/astral-sh/python-build-standalone/releases/download/20251014/cpython-3.11.14%2B20251014-x86_64_v2-unknown-linux-musl-install_only_stripped.tar.gz',
('linux', 'x86_64', 'musl', 'v3', ''):
'https://github.com/astral-sh/python-build-standalone/releases/download/20251014/cpython-3.11.14%2B20251014-x86_64_v3-unknown-linux-musl-install_only_stripped.tar.gz',
('linux', 'x86_64', 'musl', 'v4', ''):
'https://github.com/astral-sh/python-build-standalone/releases/download/20251014/cpython-3.11.14%2B20251014-x86_64_v4-unknown-linux-musl-install_only_stripped.tar.gz',
('windows', 'aarch64', 'msvc', '', ''):
'https://github.com/astral-sh/python-build-standalone/releases/download/20251014/cpython-3.11.14%2B20251014-aarch64-pc-windows-msvc-install_only_stripped.tar.gz',
('windows', 'i386', 'msvc', '', ''):
'https://github.com/astral-sh/python-build-standalone/releases/download/20251014/cpython-3.11.14%2B20251014-i686-pc-windows-msvc-install_only_stripped.tar.gz',
('windows', 'amd64', 'msvc', '', ''):
'https://github.com/astral-sh/python-build-standalone/releases/download/20251014/cpython-3.11.14%2B20251014-x86_64-pc-windows-msvc-install_only_stripped.tar.gz',
('macos', 'arm64', '', '', ''):
'https://github.com/astral-sh/python-build-standalone/releases/download/20251014/cpython-3.11.14%2B20251014-aarch64-apple-darwin-install_only_stripped.tar.gz',
('macos', 'x86_64', '', '', ''):
'https://github.com/astral-sh/python-build-standalone/releases/download/20251014/cpython-3.11.14%2B20251014-x86_64-apple-darwin-install_only_stripped.tar.gz',
('linux', 'i686', 'gnu', '', ''):
'https://github.com/astral-sh/python-build-standalone/releases/download/20230826/cpython-3.11.5%2B20230826-i686-unknown-linux-gnu-install_only.tar.gz',
},
'3.10': {
('linux', 'aarch64', 'gnu', '', ''):
'https://github.com/astral-sh/python-build-standalone/releases/download/20251014/cpython-3.10.19%2B20251014-aarch64-unknown-linux-gnu-install_only_stripped.tar.gz',
('linux', 'aarch64', 'musl', '', ''):
'https://github.com/astral-sh/python-build-standalone/releases/download/20251014/cpython-3.10.19%2B20251014-aarch64-unknown-linux-musl-install_only_stripped.tar.gz',
('linux', 'armv7', 'gnueabi', '', ''):
'https://github.com/astral-sh/python-build-standalone/releases/download/20251014/cpython-3.10.19%2B20251014-armv7-unknown-linux-gnueabi-install_only_stripped.tar.gz',
('linux', 'armv7', 'gnueabihf', '', ''):
'https://github.com/astral-sh/python-build-standalone/releases/download/20251014/cpython-3.10.19%2B20251014-armv7-unknown-linux-gnueabihf-install_only_stripped.tar.gz',
('linux', 'ppc64le', 'gnu', '', ''):
'https://github.com/astral-sh/python-build-standalone/releases/download/20251014/cpython-3.10.19%2B20251014-ppc64le-unknown-linux-gnu-install_only_stripped.tar.gz',
('linux', 'riscv64', 'gnu', '', ''):
'https://github.com/astral-sh/python-build-standalone/releases/download/20251014/cpython-3.10.19%2B20251014-riscv64-unknown-linux-gnu-install_only_stripped.tar.gz',
('linux', 's390x', 'gnu', '', ''):
'https://github.com/astral-sh/python-build-standalone/releases/download/20251014/cpython-3.10.19%2B20251014-s390x-unknown-linux-gnu-install_only_stripped.tar.gz',
('linux', 'x86_64', 'gnu', 'v1', ''):
'https://github.com/astral-sh/python-build-standalone/releases/download/20251014/cpython-3.10.19%2B20251014-x86_64-unknown-linux-gnu-install_only_stripped.tar.gz',
('linux', 'x86_64', 'gnu', 'v2', ''):
'https://github.com/astral-sh/python-build-standalone/releases/download/20251014/cpython-3.10.19%2B20251014-x86_64_v2-unknown-linux-gnu-install_only_stripped.tar.gz',
('linux', 'x86_64', 'gnu', 'v3', ''):
'https://github.com/astral-sh/python-build-standalone/releases/download/20251014/cpython-3.10.19%2B20251014-x86_64_v3-unknown-linux-gnu-install_only_stripped.tar.gz',
('linux', 'x86_64', 'gnu', 'v4', ''):
'https://github.com/astral-sh/python-build-standalone/releases/download/20251014/cpython-3.10.19%2B20251014-x86_64_v4-unknown-linux-gnu-install_only_stripped.tar.gz',
('linux', 'x86_64', 'musl', 'v1', ''):
'https://github.com/astral-sh/python-build-standalone/releases/download/20251014/cpython-3.10.19%2B20251014-x86_64-unknown-linux-musl-install_only_stripped.tar.gz',
('linux', 'x86_64', 'musl', 'v2', ''):
'https://github.com/astral-sh/python-build-standalone/releases/download/20251014/cpython-3.10.19%2B20251014-x86_64_v2-unknown-linux-musl-install_only_stripped.tar.gz',
('linux', 'x86_64', 'musl', 'v3', ''):
'https://github.com/astral-sh/python-build-standalone/releases/download/20251014/cpython-3.10.19%2B20251014-x86_64_v3-unknown-linux-musl-install_only_stripped.tar.gz',
('linux', 'x86_64', 'musl', 'v4', ''):
'https://github.com/astral-sh/python-build-standalone/releases/download/20251014/cpython-3.10.19%2B20251014-x86_64_v4-unknown-linux-musl-install_only_stripped.tar.gz',
('windows', 'i386', 'msvc', '', ''):
'https://github.com/astral-sh/python-build-standalone/releases/download/20251014/cpython-3.10.19%2B20251014-i686-pc-windows-msvc-install_only_stripped.tar.gz',
('windows', 'amd64', 'msvc', '', ''):
'https://github.com/astral-sh/python-build-standalone/releases/download/20251014/cpython-3.10.19%2B20251014-x86_64-pc-windows-msvc-install_only_stripped.tar.gz',
('macos', 'arm64', '', '', ''):
'https://github.com/astral-sh/python-build-standalone/releases/download/20251014/cpython-3.10.19%2B20251014-aarch64-apple-darwin-install_only_stripped.tar.gz',
('macos', 'x86_64', '', '', ''):
'https://github.com/astral-sh/python-build-standalone/releases/download/20251014/cpython-3.10.19%2B20251014-x86_64-apple-darwin-install_only_stripped.tar.gz',
('linux', 'i686', 'gnu', '', ''):
'https://github.com/astral-sh/python-build-standalone/releases/download/20230826/cpython-3.10.13%2B20230826-i686-unknown-linux-gnu-install_only.tar.gz',
},
'3.9': {
('linux', 'aarch64', 'gnu', '', ''):
'https://github.com/astral-sh/python-build-standalone/releases/download/20251014/cpython-3.9.24%2B20251014-aarch64-unknown-linux-gnu-install_only_stripped.tar.gz',
('linux', 'aarch64', 'musl', '', ''):
'https://github.com/astral-sh/python-build-standalone/releases/download/20251014/cpython-3.9.24%2B20251014-aarch64-unknown-linux-musl-install_only_stripped.tar.gz',
('linux', 'armv7', 'gnueabi', '', ''):
'https://github.com/astral-sh/python-build-standalone/releases/download/20251014/cpython-3.9.24%2B20251014-armv7-unknown-linux-gnueabi-install_only_stripped.tar.gz',
('linux', 'armv7', 'gnueabihf', '', ''):
'https://github.com/astral-sh/python-build-standalone/releases/download/20251014/cpython-3.9.24%2B20251014-armv7-unknown-linux-gnueabihf-install_only_stripped.tar.gz',
('linux', 'ppc64le', 'gnu', '', ''):
'https://github.com/astral-sh/python-build-standalone/releases/download/20251014/cpython-3.9.24%2B20251014-ppc64le-unknown-linux-gnu-install_only_stripped.tar.gz',
('linux', 'riscv64', 'gnu', '', ''):
'https://github.com/astral-sh/python-build-standalone/releases/download/20251014/cpython-3.9.24%2B20251014-riscv64-unknown-linux-gnu-install_only_stripped.tar.gz',
('linux', 's390x', 'gnu', '', ''):
'https://github.com/astral-sh/python-build-standalone/releases/download/20251014/cpython-3.9.24%2B20251014-s390x-unknown-linux-gnu-install_only_stripped.tar.gz',
('linux', 'x86_64', 'gnu', 'v1', ''):
'https://github.com/astral-sh/python-build-standalone/releases/download/20251014/cpython-3.9.24%2B20251014-x86_64-unknown-linux-gnu-install_only_stripped.tar.gz',
('linux', 'x86_64', 'gnu', 'v2', ''):
'https://github.com/astral-sh/python-build-standalone/releases/download/20251014/cpython-3.9.24%2B20251014-x86_64_v2-unknown-linux-gnu-install_only_stripped.tar.gz',
('linux', 'x86_64', 'gnu', 'v3', ''):
'https://github.com/astral-sh/python-build-standalone/releases/download/20251014/cpython-3.9.24%2B20251014-x86_64_v3-unknown-linux-gnu-install_only_stripped.tar.gz',
('linux', 'x86_64', 'gnu', 'v4', ''):
'https://github.com/astral-sh/python-build-standalone/releases/download/20251014/cpython-3.9.24%2B20251014-x86_64_v4-unknown-linux-gnu-install_only_stripped.tar.gz',
('linux', 'x86_64', 'musl', 'v1', ''):
'https://github.com/astral-sh/python-build-standalone/releases/download/20251014/cpython-3.9.24%2B20251014-x86_64-unknown-linux-musl-install_only_stripped.tar.gz',
('linux', 'x86_64', 'musl', 'v2', ''):
'https://github.com/astral-sh/python-build-standalone/releases/download/20251014/cpython-3.9.24%2B20251014-x86_64_v2-unknown-linux-musl-install_only_stripped.tar.gz',
('linux', 'x86_64', 'musl', 'v3', ''):
'https://github.com/astral-sh/python-build-standalone/releases/download/20251014/cpython-3.9.24%2B20251014-x86_64_v3-unknown-linux-musl-install_only_stripped.tar.gz',
('linux', 'x86_64', 'musl', 'v4', ''):
'https://github.com/astral-sh/python-build-standalone/releases/download/20251014/cpython-3.9.24%2B20251014-x86_64_v4-unknown-linux-musl-install_only_stripped.tar.gz',
('windows', 'i386', 'msvc', '', ''):
'https://github.com/astral-sh/python-build-standalone/releases/download/20251014/cpython-3.9.24%2B20251014-i686-pc-windows-msvc-install_only_stripped.tar.gz',
('windows', 'amd64', 'msvc', '', ''):
'https://github.com/astral-sh/python-build-standalone/releases/download/20251014/cpython-3.9.24%2B20251014-x86_64-pc-windows-msvc-install_only_stripped.tar.gz',
('macos', 'arm64', '', '', ''):
'https://github.com/astral-sh/python-build-standalone/releases/download/20251014/cpython-3.9.24%2B20251014-aarch64-apple-darwin-install_only_stripped.tar.gz',
('macos', 'x86_64', '', '', ''):
'https://github.com/astral-sh/python-build-standalone/releases/download/20251014/cpython-3.9.24%2B20251014-x86_64-apple-darwin-install_only_stripped.tar.gz',
('linux', 'i686', 'gnu', '', ''):
'https://github.com/astral-sh/python-build-standalone/releases/download/20230826/cpython-3.9.18%2B20230826-i686-unknown-linux-gnu-install_only.tar.gz',
},
'3.8': {
('linux', 'aarch64', 'gnu', '', ''):
'https://github.com/astral-sh/python-build-standalone/releases/download/20241002/cpython-3.8.20%2B20241002-aarch64-unknown-linux-gnu-install_only_stripped.tar.gz',
('linux', 'x86_64', 'gnu', 'v1', ''):
'https://github.com/astral-sh/python-build-standalone/releases/download/20241002/cpython-3.8.20%2B20241002-x86_64-unknown-linux-gnu-install_only_stripped.tar.gz',
('linux', 'x86_64', 'musl', 'v1', ''):
'https://github.com/astral-sh/python-build-standalone/releases/download/20241002/cpython-3.8.20%2B20241002-x86_64-unknown-linux-musl-install_only_stripped.tar.gz',
('windows', 'i386', 'msvc', '', ''):
'https://github.com/astral-sh/python-build-standalone/releases/download/20241002/cpython-3.8.20%2B20241002-i686-pc-windows-msvc-install_only_stripped.tar.gz',
('windows', 'amd64', 'msvc', '', ''):
'https://github.com/astral-sh/python-build-standalone/releases/download/20241002/cpython-3.8.20%2B20241002-x86_64-pc-windows-msvc-install_only_stripped.tar.gz',
('macos', 'arm64', '', '', ''):
'https://github.com/astral-sh/python-build-standalone/releases/download/20241002/cpython-3.8.20%2B20241002-aarch64-apple-darwin-install_only_stripped.tar.gz',
('macos', 'x86_64', '', '', ''):
'https://github.com/astral-sh/python-build-standalone/releases/download/20241002/cpython-3.8.20%2B20241002-x86_64-apple-darwin-install_only_stripped.tar.gz',
('linux', 'i686', 'gnu', '', ''):
'https://github.com/astral-sh/python-build-standalone/releases/download/20230826/cpython-3.8.17%2B20230826-i686-unknown-linux-gnu-install_only.tar.gz',
},
'3.7': {
('linux', 'x86_64', 'gnu', '', ''):
'https://github.com/astral-sh/python-build-standalone/releases/download/20200822/cpython-3.7.9-x86_64-unknown-linux-gnu-pgo-20200823T0036.tar.zst',
('linux', 'x86_64', 'musl', '', ''):
'https://github.com/astral-sh/python-build-standalone/releases/download/20200822/cpython-3.7.9-x86_64-unknown-linux-musl-noopt-20200823T0036.tar.zst',
('windows', 'i386', 'msvc', '', ''):
'https://github.com/astral-sh/python-build-standalone/releases/download/20200822/cpython-3.7.9-i686-pc-windows-msvc-shared-pgo-20200823T0159.tar.zst',
('windows', 'amd64', 'msvc', '', ''):
'https://github.com/astral-sh/python-build-standalone/releases/download/20200822/cpython-3.7.9-x86_64-pc-windows-msvc-shared-pgo-20200823T0118.tar.zst',
('macos', 'x86_64', '', '', ''):
'https://github.com/astral-sh/python-build-standalone/releases/download/20200823/cpython-3.7.9-x86_64-apple-darwin-pgo-20200823T2228.tar.zst',
},
'pypy3.11': {
('linux', 'aarch64', 'gnu', '', ''):
'https://downloads.python.org/pypy/pypy3.11-v7.3.20-aarch64.tar.bz2',
('linux', 'x86_64', 'gnu', '', ''):
'https://downloads.python.org/pypy/pypy3.11-v7.3.20-linux64.tar.bz2',
('windows', 'amd64', 'msvc', '', ''):
'https://downloads.python.org/pypy/pypy3.11-v7.3.20-win64.zip',
('macos', 'arm64', '', '', ''):
'https://downloads.python.org/pypy/pypy3.11-v7.3.20-macos_arm64.tar.bz2',
('macos', 'x86_64', '', '', ''):
'https://downloads.python.org/pypy/pypy3.11-v7.3.20-macos_x86_64.tar.bz2',
},
'pypy3.10': {
('linux', 'aarch64', 'gnu', '', ''):
'https://downloads.python.org/pypy/pypy3.10-v7.3.19-aarch64.tar.bz2',
('linux', 'x86_64', 'gnu', '', ''):
'https://downloads.python.org/pypy/pypy3.10-v7.3.19-linux64.tar.bz2',
('windows', 'amd64', 'msvc', '', ''):
'https://downloads.python.org/pypy/pypy3.10-v7.3.19-win64.zip',
('macos', 'arm64', '', '', ''):
'https://downloads.python.org/pypy/pypy3.10-v7.3.19-macos_arm64.tar.bz2',
('macos', 'x86_64', '', '', ''):
'https://downloads.python.org/pypy/pypy3.10-v7.3.19-macos_x86_64.tar.bz2',
},
'pypy3.9': {
('linux', 'aarch64', 'gnu', '', ''):
'https://downloads.python.org/pypy/pypy3.9-v7.3.16-aarch64.tar.bz2',
('linux', 'x86_64', 'gnu', '', ''):
'https://downloads.python.org/pypy/pypy3.9-v7.3.16-linux64.tar.bz2',
('windows', 'amd64', 'msvc', '', ''):
'https://downloads.python.org/pypy/pypy3.9-v7.3.16-win64.zip',
('macos', 'arm64', '', '', ''):
'https://downloads.python.org/pypy/pypy3.9-v7.3.16-macos_arm64.tar.bz2',
('macos', 'x86_64', '', '', ''):
'https://downloads.python.org/pypy/pypy3.9-v7.3.16-macos_x86_64.tar.bz2',
},
'pypy2.7': {
('linux', 'aarch64', 'gnu', '', ''):
'https://downloads.python.org/pypy/pypy2.7-v7.3.20-aarch64.tar.bz2',
('linux', 'x86_64', 'gnu', '', ''):
'https://downloads.python.org/pypy/pypy2.7-v7.3.20-linux64.tar.bz2',
('windows', 'amd64', 'msvc', '', ''):
'https://downloads.python.org/pypy/pypy2.7-v7.3.20-win64.zip',
('macos', 'arm64', '', '', ''):
'https://downloads.python.org/pypy/pypy2.7-v7.3.20-macos_arm64.tar.bz2',
('macos', 'x86_64', '', '', ''):
'https://downloads.python.org/pypy/pypy2.7-v7.3.20-macos_x86_64.tar.bz2',
},
}
================================================
FILE: src/hatch/python/resolve.py
================================================
from __future__ import annotations
import os
import platform
import sys
from abc import ABC, abstractmethod
from functools import cached_property
from typing import TYPE_CHECKING, Literal
from hatch.config.constants import PythonEnvVars
from hatch.errors import PythonDistributionResolutionError, PythonDistributionUnknownError
from hatch.python.distributions import DISTRIBUTIONS, ORDERED_DISTRIBUTIONS
if TYPE_CHECKING:
from packaging.version import Version
from hatch.utils.fs import Path
# Use an artificially high epoch to ensure that custom distributions are always considered newer
CUSTOM_DISTRIBUTION_VERSION_EPOCH = 100
def custom_env_var(prefix: str, name: str) -> str:
return f"{prefix}{name.upper().replace('.', '_')}"
def get_custom_source(name: str) -> str | None:
return os.environ.get(custom_env_var(PythonEnvVars.CUSTOM_SOURCE_PREFIX, name))
def get_custom_version(name: str) -> str | None:
return os.environ.get(custom_env_var(PythonEnvVars.CUSTOM_VERSION_PREFIX, name))
def get_custom_path(name: str) -> str | None:
return os.environ.get(custom_env_var(PythonEnvVars.CUSTOM_PATH_PREFIX, name))
class Distribution(ABC):
def __init__(self, name: str, source: str) -> None:
self.__name = name
self.__source = source
@property
def name(self) -> str:
return self.__name
@cached_property
def source(self) -> str:
return self.__source if (custom_source := get_custom_source(self.name)) is None else custom_source
@cached_property
def archive_name(self) -> str:
return self.source.rsplit("/", 1)[-1]
def unpack(self, archive: Path, directory: Path) -> None:
if self.source.endswith(".zip"):
import zipfile
with zipfile.ZipFile(archive, "r") as zf:
zf.extractall(directory)
elif self.source.endswith((".tar.gz", ".tgz")):
self.__unpack_tarfile(archive, directory, "r:gz")
elif self.source.endswith((".tar.bz2", ".bz2")):
self.__unpack_tarfile(archive, directory, "r:bz2")
elif self.source.endswith((".tar.zst", ".tar.zstd")):
self.__unpack_tarfile(archive, directory, "r:zst")
else:
message = f"Unknown archive type: {archive}"
raise ValueError(message)
@staticmethod
def __unpack_tarfile(archive: Path, directory: Path, mode: Literal["r:gz", "r:bz2", "r:zst"]) -> None:
if sys.version_info >= (3, 14):
import tarfile
else:
# for zstd support (introduced in Python 3.14)
# and filter kwarg (introduced in Python 3.12)
from backports.zstd import tarfile
with tarfile.open(archive, mode) as tf:
tf.extractall(directory, filter="data")
@property
@abstractmethod
def version(self) -> Version:
pass
@property
@abstractmethod
def python_path(self) -> str:
pass
class CPythonStandaloneDistribution(Distribution):
@cached_property
def version(self) -> Version:
from packaging.version import Version
if (custom_version := get_custom_version(self.name)) is not None:
return Version(f"{CUSTOM_DISTRIBUTION_VERSION_EPOCH}!{custom_version}")
# .../cpython-3.12.0%2B20231002-...
# .../cpython-3.7.9-...
_, _, remaining = self.source.partition("/cpython-")
# 3.12.0%2B20231002-...
# 3.7.9-...
version = remaining.split("%2B")[0] if "%2B" in remaining else remaining.split("-")[0]
return Version(f"0!{version}")
@cached_property
def python_path(self) -> str:
if (custom_path := get_custom_path(self.name)) is not None:
return custom_path
if self.name == "3.7":
if sys.platform == "win32":
return r"python\install\python.exe"
return "python/install/bin/python3"
if sys.platform == "win32":
return r"python\python.exe"
return "python/bin/python3"
class PyPyOfficialDistribution(Distribution):
@cached_property
def version(self) -> Version:
from packaging.version import Version
if (custom_version := get_custom_version(self.name)) is not None:
return Version(f"{CUSTOM_DISTRIBUTION_VERSION_EPOCH}!{custom_version}")
*_, remaining = self.source.partition("/pypy/")
_, version, *_ = remaining.split("-")
return Version(f"0!{version[1:]}")
@cached_property
def python_path(self) -> str:
if (custom_path := get_custom_path(self.name)) is not None:
return custom_path
directory = self.archive_name
for extension in (".tar.bz2", ".zip"):
if directory.endswith(extension):
directory = directory[: -len(extension)]
break
if sys.platform == "win32":
return rf"{directory}\pypy.exe"
return f"{directory}/bin/pypy"
def get_distribution(name: str, source: str = "", variant_cpu: str = "", variant_gil: str = "") -> Distribution:
if source:
return _get_distribution_class(source)(name, source)
if name not in DISTRIBUTIONS:
message = f"Unknown distribution: {name}"
raise PythonDistributionUnknownError(message)
arch = platform.machine().lower()
if sys.platform == "win32":
system = "windows"
abi = "msvc"
elif sys.platform == "darwin":
system = "macos"
abi = ""
else:
system = "linux"
abi = "gnu" if any(platform.libc_ver()) else "musl"
if not variant_cpu:
variant_cpu = _get_default_variant_cpu(name, system, arch)
if not variant_gil:
variant_gil = _get_default_variant_gil()
key = (system, arch, abi, variant_cpu, variant_gil)
keys: dict[tuple, str] = DISTRIBUTIONS[name]
if key not in keys:
message = f"Could not find a default source for {name=} {system=} {arch=} {abi=} {variant_cpu=} {variant_gil=}"
raise PythonDistributionResolutionError(message)
source = keys[key]
return _get_distribution_class(source)(name, source)
def get_compatible_distributions() -> dict[str, Distribution]:
distributions: dict[str, Distribution] = {}
for name in ORDERED_DISTRIBUTIONS:
try:
dist = get_distribution(name)
except PythonDistributionResolutionError:
pass
else:
distributions[name] = dist
return distributions
def _guess_linux_variant_cpu() -> str:
# Use the highest that we know is most common when we can't parse CPU data
default = "v3"
try:
# Don't use our utility Path so we can properly mock
with open("/proc/cpuinfo", encoding="utf-8") as f:
contents = f.read()
except OSError:
return default
# See https://clang.llvm.org/docs/UsersManual.html#x86 for the
# instructions for each architecture variant and
# https://github.com/torvalds/linux/blob/master/arch/x86/include/asm/cpufeatures.h
# for the corresponding Linux flags
v2_flags = {"cx16", "lahf_lm", "popcnt", "pni", "sse4_1", "sse4_2", "ssse3"}
v3_flags = {"avx", "avx2", "bmi1", "bmi2", "f16c", "fma", "movbe", "xsave"} | v2_flags
v4_flags = {"avx512f", "avx512bw", "avx512cd", "avx512dq", "avx512vl"} | v3_flags
for line in contents.splitlines():
key, _, value = line.partition(":")
if key.strip() == "flags":
flags = set(value.strip().split())
if flags.issuperset(v4_flags):
return "v4"
if flags.issuperset(v3_flags):
return "v3"
if flags.issuperset(v2_flags):
return "v2"
return "v1"
return default
def _get_default_variant_cpu(name: str, system: str, arch: str) -> str:
# not PyPy
if name[0].isdigit():
variant = os.environ.get(
"HATCH_PYTHON_VARIANT_CPU",
# Legacy name
os.environ.get(f"HATCH_PYTHON_VARIANT_{system.upper()}", ""),
).lower()
# https://gregoryszorc.com/docs/python-build-standalone/main/running.html
if system == "linux" and arch == "x86_64":
# Intel-specific optimizations depending on age of release
if variant:
return variant
if name == "3.8":
return "v1"
if name != "3.7":
return _guess_linux_variant_cpu()
return ""
def _get_default_variant_gil() -> str:
return os.environ.get("HATCH_PYTHON_VARIANT_GIL", "").lower()
def _get_distribution_class(source: str) -> type[Distribution]:
if "/python-build-standalone/releases/download/" in source:
return CPythonStandaloneDistribution
if source.startswith("https://downloads.python.org/pypy/"):
return PyPyOfficialDistribution
message = f"Unknown distribution source: {source}"
raise ValueError(message)
================================================
FILE: src/hatch/template/__init__.py
================================================
from __future__ import annotations
from contextlib import suppress
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from hatch.utils.fs import Path
class File:
def __init__(self, path: Path | None, contents: str = ""):
self.path = path
self.contents = contents
self.feature = None
def write(self, root):
if self.path is None: # no cov
return
path = root / self.path
path.ensure_parent_dir_exists()
path.write_text(self.contents, encoding="utf-8")
def find_template_files(module):
for name in dir(module):
obj = getattr(module, name)
if obj is File:
continue
with suppress(TypeError):
if issubclass(obj, File):
yield obj
================================================
FILE: src/hatch/template/default.py
================================================
from hatch.template import File, files_default, find_template_files
from hatch.template.plugin.interface import TemplateInterface
from hatch.utils.fs import Path
from hatch.utils.network import download_file
class DefaultTemplate(TemplateInterface):
PLUGIN_NAME = "default"
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.plugin_config.setdefault("ci", False)
self.plugin_config.setdefault("src-layout", True)
self.plugin_config.setdefault("tests", True)
def initialize_config(self, config):
# Default values
config["readme_file_path"] = "README.md"
config["package_metadata_file_path"] = f"src/{config['package_name']}/__about__.py"
license_data = {}
# Licenses
license_ids = config["licenses"]["default"]
if not license_ids:
config["license_data"] = license_data
config["license_expression"] = ""
config["license_files"] = ""
config["license_header"] = ""
return
cached_licenses_dir = self.cache_dir / "licenses"
cached_licenses_dir.ensure_dir_exists()
license_ids = sorted(set(license_ids))
for license_id in sorted(set(license_ids)):
license_file_name = f"{license_id}.txt"
cached_license_path = cached_licenses_dir / license_file_name
if not cached_license_path.is_file():
from packaging.licenses._spdx import VERSION # noqa: PLC2701
url = f"https://raw.githubusercontent.com/spdx/license-list-data/v{VERSION}/text/{license_file_name}"
for _ in range(5):
try:
download_file(cached_license_path, url)
except Exception: # noqa: BLE001, S112
continue
else:
break
license_data[license_id] = cached_license_path.read_text(encoding="utf-8")
config["license_data"] = license_data
config["license_expression"] = " OR ".join(license_data)
config["license_header"] = (
""
if not config["licenses"]["headers"]
else f"""\
# SPDX-FileCopyrightText: {self.creation_time.year}-present {config["name"]} <{config["email"]}>
#
# SPDX-License-Identifier: {config["license_expression"]}
"""
)
if len(license_ids) == 1:
config["license_files"] = ""
else:
config["license_files"] = '\nlicense-files = { globs = ["LICENSES/*"] }'
if config["args"]["cli"]:
config["dependencies"].add("click")
if not self.plugin_config["src-layout"]:
config["package_metadata_file_path"] = f"{config['package_metadata_file_path'][4:]}"
def get_files(self, config):
files = list(find_template_files(files_default))
# Add any licenses
license_data = config["license_data"]
if license_data:
if len(license_data) == 1:
license_id, text = next(iter(license_data.items()))
license_text = get_license_text(config, license_id, text, self.creation_time)
files.append(File(Path("LICENSE.txt"), license_text))
else:
# https://reuse.software/faq/#multi-licensing
for license_id, text in license_data.items():
license_text = get_license_text(config, license_id, text, self.creation_time)
files.append(File(Path("LICENSES", f"{license_id}.txt"), license_text))
if config["args"]["cli"]:
from hatch.template import files_feature_cli
files.extend(find_template_files(files_feature_cli))
if self.plugin_config["tests"]:
from hatch.template import files_feature_tests
files.extend(find_template_files(files_feature_tests))
if self.plugin_config["ci"]:
from hatch.template import files_feature_ci
files.extend(find_template_files(files_feature_ci))
return files
def finalize_files(self, config, files):
if config["licenses"]["headers"] and config["license_data"]:
for template_file in files:
if template_file.path.name.endswith(".py"):
template_file.contents = config["license_header"] + template_file.contents
if self.plugin_config["src-layout"]:
for template_file in files:
if template_file.path.parts[0] == config["package_name"]:
template_file.path = Path("src", template_file.path)
def get_license_text(config, license_id, license_text, creation_time):
if license_id == "MIT":
license_text = license_text.replace("", f"{creation_time.year}-present", 1)
license_text = license_text.replace("", f"{config['name']} <{config['email']}>", 1)
elif license_id == "BSD-3-Clause":
license_text = license_text.replace("", f"{creation_time.year}-present", 1)
license_text = license_text.replace("", f"{config['name']} <{config['email']}>", 1)
return f"{license_text.rstrip()}\n"
================================================
FILE: src/hatch/template/files_default.py
================================================
from hatch.template import File
from hatch.utils.fs import Path
class PackageRoot(File):
def __init__(
self,
template_config: dict,
plugin_config: dict, # noqa: ARG002
):
super().__init__(Path(template_config["package_name"], "__init__.py"), "")
class MetadataFile(File):
def __init__(
self,
template_config: dict,
plugin_config: dict, # noqa: ARG002
):
super().__init__(Path(template_config["package_name"], "__about__.py"), '__version__ = "0.0.1"\n')
class Readme(File):
TEMPLATE = """\
# {project_name}
[](https://pypi.org/project/{project_name_normalized})
[](https://pypi.org/project/{project_name_normalized})
{extra_badges}
-----
## Table of Contents
- [Installation](#installation)
{extra_toc}
## Installation
```console
pip install {project_name_normalized}
```{license_info}
"""
def __init__(
self,
template_config: dict,
plugin_config: dict, # noqa: ARG002
):
extra_badges = ""
extra_toc = ""
license_info = ""
if template_config["license_data"]:
extra_toc += "- [License](#license)\n"
license_info += (
f"\n\n## License\n\n`{template_config['project_name_normalized']}` is distributed under the terms of "
)
license_data = template_config["license_data"]
if len(license_data) == 1:
license_id = next(iter(license_data))
license_info += f"the [{license_id}](https://spdx.org/licenses/{license_id}.html) license."
else:
license_info += "any of the following licenses:\n"
for license_id in sorted(license_data):
license_info += f"\n- [{license_id}](https://spdx.org/licenses/{license_id}.html)"
super().__init__(
Path(template_config["readme_file_path"]),
self.TEMPLATE.format(
extra_badges=extra_badges, extra_toc=extra_toc, license_info=license_info, **template_config
),
)
class PyProject(File):
TEMPLATE = """\
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[project]
name = "{project_name_normalized}"
dynamic = ["version"]
description = {description!r}
readme = "{readme_file_path}"
requires-python = ">=3.8"
license = "{license_expression}"{license_files}
keywords = []
authors = [
{{ name = "{name}", email = "{email}" }},
]
classifiers = [
"Development Status :: 4 - Beta",
"Programming Language :: Python",
"Programming Language :: Python :: 3.8",
"Programming Language :: Python :: 3.9",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: Implementation :: CPython",
"Programming Language :: Python :: Implementation :: PyPy",
]
dependencies = {dependency_data}
[project.urls]{project_url_data}{cli_scripts}
[tool.hatch.version]
path = "{package_metadata_file_path}"{tests_section}
"""
def __init__(self, template_config: dict, plugin_config: dict):
template_config = dict(template_config)
template_config["name"] = repr(template_config["name"])[1:-1]
project_url_data = ""
project_urls = (
plugin_config["project_urls"]
if "project_urls" in plugin_config
else {
"Documentation": "https://github.com/{name}/{project_name_normalized}#readme",
"Issues": "https://github.com/{name}/{project_name_normalized}/issues",
"Source": "https://github.com/{name}/{project_name_normalized}",
}
)
if project_urls:
for label, url in project_urls.items():
normalized_label = f'"{label}"' if " " in label else label
project_url_data += f'\n{normalized_label} = "{url.format(**template_config)}"'
dependency_data = "["
if template_config["dependencies"]:
for dependency in sorted(template_config["dependencies"]):
dependency_data += f'\n "{dependency}",\n'
dependency_data += "]"
cli_scripts = ""
if template_config["args"]["cli"]:
cli_scripts = f"""
[project.scripts]
{template_config["project_name_normalized"]} = "{template_config["package_name"]}.cli:{template_config["package_name"]}"\
"""
tests_section = ""
if plugin_config["tests"]:
package_location = "src/" if plugin_config["src-layout"] else ""
tests_section = f"""
[tool.hatch.envs.types]
extra-dependencies = [
"mypy>=1.0.0",
]
[tool.hatch.envs.types.scripts]
check = "mypy --install-types --non-interactive {{args:{package_location}{template_config["package_name"]} tests}}"
[tool.coverage.run]
source_pkgs = ["{template_config["package_name"]}", "tests"]
branch = true
parallel = true
omit = [
"{package_location}{template_config["package_name"]}/__about__.py",
]
[tool.coverage.paths]
{template_config["package_name"]} = ["{package_location}{template_config["package_name"]}", "*/{template_config["project_name_normalized"]}/{package_location}{template_config["package_name"]}"]
tests = ["tests", "*/{template_config["project_name_normalized"]}/tests"]
[tool.coverage.report]
exclude_lines = [
"no cov",
"if __name__ == .__main__.:",
"if TYPE_CHECKING:",
]"""
super().__init__(
Path("pyproject.toml"),
self.TEMPLATE.format(
project_url_data=project_url_data,
dependency_data=dependency_data,
cli_scripts=cli_scripts,
tests_section=tests_section,
**template_config,
),
)
================================================
FILE: src/hatch/template/files_feature_ci.py
================================================
from hatch.template import File
from hatch.utils.fs import Path
class CommandLinePackage(File):
TEMPLATE = """\
name: test
on:
push:
branches: [main, master]
pull_request:
branches: [main, master]
concurrency:
group: test-${{ github.head_ref }}
cancel-in-progress: true
env:
PYTHONUNBUFFERED: "1"
FORCE_COLOR: "1"
jobs:
run:
name: Python ${{ matrix.python-version }} on ${{ startsWith(matrix.os, 'macos-') && 'macOS' || startsWith(matrix.os, 'windows-') && 'Windows' || 'Linux' }}
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, windows-latest, macos-latest]
python-version: ['3.8', '3.9', '3.10', '3.11', '3.12', '3.13']
steps:
- uses: actions/checkout@v3
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v4
with:
python-version: ${{ matrix.python-version }}
- name: Install Hatch
run: pip install --upgrade hatch
- name: Run static analysis
run: hatch fmt --check
- name: Run tests
run: hatch test --python ${{ matrix.python-version }} --cover --randomize --parallel --retries 2 --retry-delay 1
"""
def __init__(
self,
template_config: dict, # noqa: ARG002
plugin_config: dict, # noqa: ARG002
):
super().__init__(Path(".github", "workflows", "test.yml"), self.TEMPLATE)
================================================
FILE: src/hatch/template/files_feature_cli.py
================================================
from hatch.template import File
from hatch.utils.fs import Path
class PackageEntryPoint(File):
TEMPLATE = """\
import sys
if __name__ == "__main__":
from {package_name}.cli import {package_name}
sys.exit({package_name}())
"""
def __init__(
self,
template_config: dict,
plugin_config: dict, # noqa: ARG002
):
super().__init__(Path(template_config["package_name"], "__main__.py"), self.TEMPLATE.format(**template_config))
class CommandLinePackage(File):
TEMPLATE = """\
import click
from {package_name}.__about__ import __version__
@click.group(context_settings={{"help_option_names": ["-h", "--help"]}}, invoke_without_command=True)
@click.version_option(version=__version__, prog_name="{project_name}")
def {package_name}():
click.echo("Hello world!")
"""
def __init__(
self,
template_config: dict,
plugin_config: dict, # noqa: ARG002
):
super().__init__(
Path(template_config["package_name"], "cli", "__init__.py"), self.TEMPLATE.format(**template_config)
)
================================================
FILE: src/hatch/template/files_feature_tests.py
================================================
from hatch.template import File
from hatch.utils.fs import Path
class TestsPackageRoot(File):
def __init__(
self,
template_config: dict, # noqa: ARG002
plugin_config: dict, # noqa: ARG002
):
super().__init__(Path("tests", "__init__.py"))
================================================
FILE: src/hatch/template/plugin/__init__.py
================================================
================================================
FILE: src/hatch/template/plugin/hooks.py
================================================
from hatch.template.default import DefaultTemplate
from hatchling.plugin import hookimpl
@hookimpl
def hatch_register_template():
return DefaultTemplate
================================================
FILE: src/hatch/template/plugin/interface.py
================================================
class TemplateInterface:
PLUGIN_NAME = ""
PRIORITY = 100
def __init__(self, plugin_config: dict, cache_dir, creation_time):
self.plugin_config = plugin_config
self.cache_dir = cache_dir
self.creation_time = creation_time
def initialize_config(self, config):
"""
Allow modification of the configuration passed to every file for new projects
before the list of files are determined.
"""
def get_files(self, config): # noqa: ARG002, PLR6301
"""Add to the list of files for new projects that are written to the file system."""
return []
def finalize_files(self, config, files):
"""Allow modification of files for new projects before they are written to the file system."""
================================================
FILE: src/hatch/utils/__init__.py
================================================
================================================
FILE: src/hatch/utils/ci.py
================================================
import os
def running_in_ci() -> bool:
return any(os.environ.get(env_var) in {"true", "1"} for env_var in ("CI", "GITHUB_ACTIONS"))
================================================
FILE: src/hatch/utils/dep.py
================================================
from __future__ import annotations
from typing import TYPE_CHECKING, Any
from hatchling.metadata.utils import get_normalized_dependency, normalize_project_name
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
return text.replace('"', "'")
def get_normalized_dependencies(requirements: list[Requirement]) -> list[str]:
normalized_dependencies = {get_normalized_dependency(requirement) for requirement in requirements}
return sorted(normalized_dependencies)
def hash_dependencies(requirements: list[Dependency]) -> str:
from hashlib import sha256
data = "".join(
sorted(
# Internal spacing is ignored by PEP 440
normalized_dependency.replace(" ", "")
for normalized_dependency in {get_normalized_dependency(req) for req in requirements}
)
).encode("utf-8")
return sha256(data).hexdigest()
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] = Dependency(dependency)
return dependencies_complex
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: Dependency(optional_dependency) for optional_dependency in optional_dependencies
}
return optional_dependencies_complex
def get_complex_dependency_group(
dependency_groups: dict[str, Any], group: str, past_groups: tuple[str, ...] = ()
) -> list[Dependency]:
from hatch.dep.core import Dependency
if group in past_groups:
msg = f"Cyclic dependency group include: {group} -> {past_groups}"
raise ValueError(msg)
if group not in dependency_groups:
msg = f"Dependency group '{group}' not found"
raise LookupError(msg)
raw_group = dependency_groups[group]
if not isinstance(raw_group, list):
msg = f"Dependency group '{group}' is not a list"
raise TypeError(msg)
realized_group = []
for item in raw_group:
if isinstance(item, str):
realized_group.append(Dependency(item))
elif isinstance(item, dict):
if tuple(item.keys()) != ("include-group",):
msg = f"Invalid dependency group item: {item}"
raise ValueError(msg)
include_group = normalize_project_name(next(iter(item.values())))
realized_group.extend(get_complex_dependency_group(dependency_groups, include_group, (*past_groups, group)))
else:
msg = f"Invalid dependency group item: {item}"
raise TypeError(msg)
return realized_group
================================================
FILE: src/hatch/utils/env.py
================================================
from __future__ import annotations
from ast import literal_eval
from typing import TYPE_CHECKING, Any
if TYPE_CHECKING:
from hatch.utils.platform import Platform
class PythonInfo:
def __init__(self, platform: Platform, executable: str = "python") -> None:
self.platform = platform
self.executable = executable
self.__dep_check_data: dict[str, Any] | None = None
self.__environment: dict[str, str] | None = None
self.__sys_path: list[str] | None = None
@property
def dep_check_data(self) -> dict[str, Any]:
if self.__dep_check_data is None:
process = self.platform.check_command(
[self.executable, "-W", "ignore", "-"], capture_output=True, input=DEP_CHECK_DATA_SCRIPT
)
self.__dep_check_data = literal_eval(process.stdout.strip().decode("utf-8"))
return self.__dep_check_data
@property
def environment(self) -> dict[str, str]:
if self.__environment is None:
self.__environment = self.dep_check_data["environment"]
return self.__environment
@property
def sys_path(self) -> list[str]:
if self.__sys_path is None:
self.__sys_path = self.dep_check_data["sys_path"]
return self.__sys_path
# Keep support for Python 2 for a while:
# https://github.com/pypa/packaging/blob/20.9/packaging/markers.py#L267-L300
DEP_CHECK_DATA_SCRIPT = b"""\
import os
import platform
import sys
if hasattr(sys, 'implementation'):
info = sys.implementation.version
iver = '{0.major}.{0.minor}.{0.micro}'.format(info)
kind = info.releaselevel
if kind != 'final':
iver += kind[0] + str(info.serial)
implementation_name = sys.implementation.name
else:
iver = '0'
implementation_name = ''
environment = {
'implementation_name': implementation_name,
'implementation_version': iver,
'os_name': os.name,
'platform_machine': platform.machine(),
'platform_python_implementation': platform.python_implementation(),
'platform_release': platform.release(),
'platform_system': platform.system(),
'platform_version': platform.version(),
'python_full_version': platform.python_version(),
'python_version': '.'.join(platform.python_version_tuple()[:2]),
'sys_platform': sys.platform,
}
sys_path = [path for path in sys.path if path]
print({'environment': environment, 'sys_path': sys_path})
"""
================================================
FILE: src/hatch/utils/fs.py
================================================
from __future__ import annotations
import os
import pathlib
import sys
from contextlib import contextmanager, suppress
from functools import cached_property
from typing import TYPE_CHECKING, Any
from hatch.utils.structures import EnvVars
if TYPE_CHECKING:
from collections.abc import Generator
from _typeshed import FileDescriptorLike
# There is special recognition in Mypy for `sys.platform`, not `os.name`
# https://github.com/python/cpython/blob/09d7319bfe0006d9aa3fc14833b69c24ccafdca6/Lib/pathlib.py#L957
if sys.platform == "win32":
_PathBase = pathlib.WindowsPath
else:
_PathBase = pathlib.PosixPath
disk_sync = os.fsync
# https://mjtsai.com/blog/2022/02/17/apple-ssd-benchmarks-and-f_fullsync/
# https://developer.apple.com/library/archive/documentation/System/Conceptual/ManPages_iPhoneOS/man2/fsync.2.html
if sys.platform == "darwin":
import fcntl
if hasattr(fcntl, "F_FULLFSYNC"):
def disk_sync(fd: FileDescriptorLike) -> None:
fcntl.fcntl(fd, fcntl.F_FULLFSYNC)
class Path(_PathBase):
@cached_property
def long_id(self) -> str:
from base64 import urlsafe_b64encode
from hashlib import sha256
path = str(self)
if sys.platform == "win32" or sys.platform == "darwin":
path = path.casefold()
digest = sha256(path.encode("utf-8")).digest()
return urlsafe_b64encode(digest).decode("utf-8")
@cached_property
def id(self) -> str:
return self.long_id[:8]
def ensure_dir_exists(self) -> None:
self.mkdir(parents=True, exist_ok=True)
def ensure_parent_dir_exists(self) -> None:
self.parent.mkdir(parents=True, exist_ok=True)
def expand(self) -> Path:
return Path(os.path.expanduser(os.path.expandvars(self)))
def remove(self) -> None:
if self.is_file():
os.remove(self)
elif self.is_dir():
import shutil
shutil.rmtree(self, ignore_errors=False)
def move(self, target):
try:
self.replace(target)
except OSError:
import shutil
shutil.copy2(self, target)
self.unlink()
def wait_for_dir_removed(self, timeout: int = 5) -> None:
import shutil
import time
for _ in range(timeout * 2):
if self.is_dir():
shutil.rmtree(self, ignore_errors=True)
time.sleep(0.5)
else:
return
if self.is_dir():
shutil.rmtree(self, ignore_errors=False)
def write_atomic(self, data: str | bytes, *args: Any, **kwargs: Any) -> None:
from tempfile import mkstemp
fd, path = mkstemp(dir=self.parent)
with os.fdopen(fd, *args, **kwargs) as f:
f.write(data)
f.flush()
disk_sync(fd)
os.replace(path, self)
@contextmanager
def as_cwd(self, *args: Any, **kwargs: Any) -> Generator[Path, None, None]:
origin = os.getcwd()
os.chdir(self)
try:
if args or kwargs:
with EnvVars(*args, **kwargs):
yield self
else:
yield self
finally:
os.chdir(origin)
@contextmanager
def temp_hide(self) -> Generator[Path, None, None]:
import shutil
with temp_directory() as temp_dir:
temp_path = Path(temp_dir, self.name)
with suppress(FileNotFoundError):
shutil.move(str(self), temp_dir / self.name)
try:
yield temp_path
finally:
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))
@contextmanager
def temp_directory() -> Generator[Path, None, None]:
from tempfile import TemporaryDirectory
with TemporaryDirectory() as d:
yield Path(d).resolve()
@contextmanager
def temp_chdir(env_vars: dict[str, str] | None = None) -> Generator[Path, None, None]:
with temp_directory() as d, d.as_cwd(env_vars=env_vars):
yield d
================================================
FILE: src/hatch/utils/metadata.py
================================================
from __future__ import annotations
import re
def normalize_project_name(name: str) -> str:
# https://peps.python.org/pep-0503/#normalized-names
return re.sub(r"[-_.]+", "-", name).lower()
================================================
FILE: src/hatch/utils/network.py
================================================
from __future__ import annotations
import time
from contextlib import contextmanager
from typing import TYPE_CHECKING, Any
if TYPE_CHECKING:
from collections.abc import Generator
import httpx
from hatch.utils.fs import Path
MINIMUM_SLEEP = 2
MAXIMUM_SLEEP = 20
# The timeout should be slightly larger than a multiple of 3,
# which is the default TCP packet retransmission window. See:
# https://tools.ietf.org/html/rfc2988
DEFAULT_TIMEOUT = 10
@contextmanager
def streaming_response(*args: Any, **kwargs: Any) -> Generator[httpx.Response, None, None]:
from secrets import choice
import httpx
attempts = 0
while True:
attempts += 1
try:
with httpx.stream(*args, **kwargs) as response:
response.raise_for_status()
yield response
break
except httpx.HTTPError:
sleep = min(MAXIMUM_SLEEP, MINIMUM_SLEEP * 2**attempts)
if sleep == MAXIMUM_SLEEP:
raise
time.sleep(choice(range(sleep + 1)))
def download_file(path: Path, *args: Any, **kwargs: Any) -> None:
kwargs.setdefault("timeout", DEFAULT_TIMEOUT)
with path.open(mode="wb", buffering=0) as f, streaming_response("GET", *args, **kwargs) as response:
for chunk in response.iter_bytes(16384):
f.write(chunk)
================================================
FILE: src/hatch/utils/platform.py
================================================
from __future__ import annotations
import os
import sys
from functools import cache
from importlib import import_module
from typing import TYPE_CHECKING, Any, cast
if TYPE_CHECKING:
from collections.abc import Callable, Iterable
from subprocess import CompletedProcess, Popen
from types import ModuleType
from hatch.utils.fs import Path
@cache
def get_platform_name() -> str:
import platform
return normalize_platform_name(platform.system())
def normalize_platform_name(platform_name: str) -> str:
platform_name = platform_name.lower()
return "macos" if platform_name == "darwin" else platform_name
class Platform:
def __init__(self, display_func: Callable = print) -> None:
self.__display_func = display_func
# Lazily loaded constants
self.__default_shell: str | None = None
self.__format_file_uri: Callable[[str], str] | None = None
self.__join_command_args: Callable[[list[str]], str] | None = None
self.__name: str | None = None
self.__display_name: str | None = None
self.__home: Path | None = None
# Whether or not an interactive status is being displayed
self.displaying_status = False
self.__modules = LazilyLoadedModules()
@property
def modules(self) -> LazilyLoadedModules:
"""
Accessor for lazily loading modules that either take multiple milliseconds to import
(like `shutil` and `subprocess`) or are not used on all platforms (like `shlex`).
"""
return self.__modules
def format_for_subprocess(self, command: str | list[str], *, shell: bool) -> str | list[str]:
"""
Format the given command in a cross-platform manner for immediate consumption by subprocess utilities.
"""
if self.windows:
# Manually locate executables on Windows to avoid multiple cases in which `shell=True` is required:
#
# - If the `PATH` environment variable has been modified, see:
# https://github.com/python/cpython/issues/52803
# - Executables that do not have the extension `.exe`, see:
# https://docs.microsoft.com/en-us/windows/win32/api/processthreadsapi/nf-processthreadsapi-createprocessw
if not shell and not isinstance(command, str):
executable = command[0]
new_command = [self.modules.shutil.which(executable) or executable]
new_command.extend(command[1:])
return new_command
elif not shell and isinstance(command, str):
return self.modules.shlex.split(command)
return command
@staticmethod
def exit_with_code(code: str | int | None) -> None:
sys.exit(code)
def _run_command_integrated(
self, command: str | list[str], *, shell: bool = False, **kwargs: Any
) -> CompletedProcess:
with self.capture_process(command, shell=shell, **kwargs) as process:
for line in self.stream_process_output(process):
self.__display_func(line, end="")
stdout, stderr = process.communicate()
return self.modules.subprocess.CompletedProcess(process.args, process.poll(), stdout, stderr)
def run_command(self, command: str | list[str], *, shell: bool = False, **kwargs: Any) -> CompletedProcess:
"""
Equivalent to the standard library's
[subprocess.run](https://docs.python.org/3/library/subprocess.html#subprocess.run),
with the command first being
[properly formatted](utilities.md#hatch.utils.platform.Platform.format_for_subprocess).
"""
if self.displaying_status and not kwargs.get("capture_output"):
return self._run_command_integrated(command, shell=shell, **kwargs)
self.populate_default_popen_kwargs(kwargs, shell=shell)
return self.modules.subprocess.run(self.format_for_subprocess(command, shell=shell), shell=shell, **kwargs)
def check_command(self, command: str | list[str], *, shell: bool = False, **kwargs: Any) -> CompletedProcess:
"""
Equivalent to [run_command](utilities.md#hatch.utils.platform.Platform.run_command),
but non-zero exit codes will gracefully end program execution.
"""
process = self.run_command(command, shell=shell, **kwargs)
if process.returncode:
self.exit_with_code(process.returncode)
return process
def check_command_output(self, command: str | list[str], *, shell: bool = False, **kwargs: Any) -> str:
"""
Equivalent to the output from the process returned by
[capture_process](utilities.md#hatch.utils.platform.Platform.capture_process),
but non-zero exit codes will gracefully end program execution.
"""
kwargs.setdefault("stdout", self.modules.subprocess.PIPE)
kwargs.setdefault("stderr", self.modules.subprocess.STDOUT)
self.populate_default_popen_kwargs(kwargs, shell=shell)
process = self.modules.subprocess.run(self.format_for_subprocess(command, shell=shell), shell=shell, **kwargs)
if process.returncode:
# Callers might not want to merge both streams so try stderr first
self.__display_func((process.stderr or process.stdout).decode("utf-8"))
self.exit_with_code(process.returncode)
return process.stdout.decode("utf-8")
def capture_process(self, command: str | list[str], *, shell: bool = False, **kwargs: Any) -> Popen:
"""
Equivalent to the standard library's
[subprocess.Popen](https://docs.python.org/3/library/subprocess.html#subprocess.Popen),
with all output captured by `stdout` and the command first being
[properly formatted](utilities.md#hatch.utils.platform.Platform.format_for_subprocess).
"""
self.populate_default_popen_kwargs(kwargs, shell=shell)
return self.modules.subprocess.Popen(
self.format_for_subprocess(command, shell=shell),
shell=shell,
stdout=self.modules.subprocess.PIPE,
stderr=self.modules.subprocess.STDOUT,
**kwargs,
)
def populate_default_popen_kwargs(self, kwargs: dict[str, Any], *, shell: bool) -> None:
# https://support.apple.com/en-us/HT204899
# https://en.wikipedia.org/wiki/System_Integrity_Protection
if (
"executable" not in kwargs
and self.macos
and shell
and any(env_var.startswith(("DYLD_", "LD_")) for env_var in os.environ)
):
default_paths = os.environ.get("PATH", os.defpath).split(os.pathsep)
unprotected_paths = []
for path in default_paths:
normalized_path = os.path.normpath(path)
if not normalized_path.startswith((
"/System",
"/usr",
"/bin",
"/sbin",
"/var",
)) or normalized_path.startswith("/usr/local"):
unprotected_paths.append(path)
search_path = os.pathsep.join(unprotected_paths)
for exe_name in ("sh", "bash", "zsh", "fish"):
executable = self.modules.shutil.which(exe_name, path=search_path)
if executable:
kwargs["executable"] = executable
break
@staticmethod
def stream_process_output(process: Popen) -> Iterable[str]:
# To avoid blocking never use a pipe's file descriptor iterator. See https://bugs.python.org/issue3907
for line in iter(process.stdout.readline, b""): # type: ignore[union-attr]
yield line.decode("utf-8")
@property
def default_shell(self) -> str:
"""
Returns the default shell of the system.
On Windows systems first try the `SHELL` environment variable, if present, followed by
the `COMSPEC` environment variable, defaulting to `cmd`. On all other platforms only
the `SHELL` environment variable will be used, defaulting to `bash`.
"""
if self.__default_shell is None:
if self.windows:
self.__default_shell = cast(str, os.environ.get("SHELL", os.environ.get("COMSPEC", "cmd")))
else:
self.__default_shell = cast(str, os.environ.get("SHELL", "bash"))
return self.__default_shell
@property
def join_command_args(self) -> Callable[[list[str]], str]:
if self.__join_command_args is None:
if self.windows:
self.__join_command_args = self.modules.subprocess.list2cmdline
else:
self.__join_command_args = self.modules.shlex.join
return self.__join_command_args
@property
def format_file_uri(self) -> Callable[[str], str]:
if self.__format_file_uri is None:
if self.windows:
self.__format_file_uri = lambda p: f"file:///{p}".replace("\\", "/")
else:
self.__format_file_uri = lambda p: f"file://{p}"
return self.__format_file_uri
@property
def windows(self) -> bool:
"""
Indicates whether Hatch is running on Windows.
"""
return self.name == "windows"
@property
def macos(self) -> bool:
"""
Indicates whether Hatch is running on macOS.
"""
return self.name == "macos"
@property
def linux(self) -> bool:
"""
Indicates whether Hatch is running on neither Windows nor macOS.
"""
return not (self.windows or self.macos)
def exit_with_command(self, command: list[str]) -> None:
"""
Run the given command and exit with its exit code. On non-Windows systems, this uses the standard library's
[os.execvp](https://docs.python.org/3/library/os.html#os.execvp).
"""
if self.windows:
process = self.run_command(command)
self.exit_with_code(process.returncode)
else:
os.execvp(command[0], command) # noqa: S606
@property
def name(self) -> str:
"""
One of the following:
- `linux`
- `windows`
- `macos`
"""
if self.__name is None:
self.__name = get_platform_name()
return self.__name
@property
def display_name(self) -> str:
"""
One of the following:
- `Linux`
- `Windows`
- `macOS`
"""
if self.__display_name is None:
self.__display_name = "macOS" if self.macos else self.name.capitalize()
return self.__display_name
@property
def home(self) -> Path:
"""
The user's home directory as a path-like object.
"""
if self.__home is None:
from hatch.utils.fs import Path
self.__home = Path(os.path.expanduser("~"))
return self.__home
class LazilyLoadedModules:
def __getattr__(self, name: str) -> ModuleType:
module = import_module(name)
setattr(self, name, module)
return module
================================================
FILE: src/hatch/utils/runner.py
================================================
from __future__ import annotations
from typing import TYPE_CHECKING, Any
if TYPE_CHECKING:
from hatch.env.plugin.interface import EnvironmentInterface
class ExecutionContext:
def __init__(
self,
environment: EnvironmentInterface,
*,
shell_commands: list[str] | None = None,
env_vars: dict[str, str] | None = None,
force_continue: bool = False,
show_code_on_error: bool = False,
hide_commands: bool = False,
source: str = "cmd",
) -> None:
self.env = environment
self.shell_commands: list[str] = shell_commands or []
self.env_vars: dict[str, str] = env_vars or {}
self.force_continue = force_continue
self.show_code_on_error = show_code_on_error
self.hide_commands = hide_commands
self.source = source
def add_shell_command(self, command: str | list[str]) -> None:
self.shell_commands.append(command if isinstance(command, str) else self.env.join_command_args(command))
def parse_matrix_variables(specs: tuple[str, ...]) -> dict[str, set[str]]:
variables: dict[str, set[str]] = {}
for spec in specs:
variable, _, values = spec.partition("=")
if variable == "py":
variable = "python"
if variable in variables:
raise ValueError(variable)
variables[variable] = set(values.split(",")) if values else set()
return variables
def select_environments(
environments: dict[str, dict[str, Any]],
included_variables: dict[str, set[str]],
excluded_variables: dict[str, set[str]],
):
selected_environments = []
for env_name, variables in environments.items():
exclude = False
for excluded_variable, excluded_values in excluded_variables.items():
if excluded_variable not in variables:
continue
value = variables[excluded_variable]
if not excluded_values or value in excluded_values:
exclude = True
break
if exclude:
continue
for included_variable, included_values in included_variables.items():
if included_variable not in variables:
exclude = True
break
value = variables[included_variable]
if included_values and value not in included_values:
exclude = True
break
if not exclude:
selected_environments.append(env_name)
return selected_environments
================================================
FILE: src/hatch/utils/shells.py
================================================
from __future__ import annotations
import sys
from typing import TYPE_CHECKING
from hatch.utils.fs import Path
if TYPE_CHECKING:
from collections.abc import Callable, Iterable
from types import FrameType
from hatch.env.plugin.interface import EnvironmentInterface
from hatch.utils.platform import Platform
def detect_shell(platform: Platform) -> tuple[str, str]:
import shellingham
try:
return shellingham.detect_shell()
except shellingham.ShellDetectionFailure:
path = platform.default_shell
return Path(path).stem, path
class ShellManager:
def __init__(self, environment: EnvironmentInterface) -> None:
self.environment = environment
def enter_cmd(self, path: str, args: Iterable[str], exe_dir: Path) -> None: # noqa: ARG002
self.environment.platform.exit_with_command([path or "cmd", "/k", str(exe_dir / "activate.bat")])
def enter_powershell(self, path: str, args: Iterable[str], exe_dir: Path) -> None: # noqa: ARG002
self.environment.platform.exit_with_command([
path or "powershell",
"-executionpolicy",
"bypass",
"-NoExit",
"-NoLogo",
"-File",
str(exe_dir / "activate.ps1"),
])
def enter_pwsh(self, path: str, args: Iterable[str], exe_dir: Path) -> None:
self.enter_powershell(path or "pwsh", args, exe_dir)
def enter_xonsh(self, path: str, args: Iterable[str], exe_dir: Path) -> None:
if self.environment.platform.windows:
with self.environment:
self.environment.platform.exit_with_command([
path or "xonsh",
*(args or ["-i"]),
"-D",
f"VIRTUAL_ENV={exe_dir.parent.name}",
])
else:
self.spawn_linux_shell(
path or "xonsh",
[*(args or ["-i"]), "-D", f"VIRTUAL_ENV={exe_dir.parent.name}"],
# Just in case pyenv works with xonsh, supersede it.
callback=lambda terminal: terminal.sendline(f"$PATH.insert(0, {str(exe_dir)!r})"),
)
def enter_bash(self, path: str, args: Iterable[str], exe_dir: Path) -> None:
if self.environment.platform.windows:
self.environment.platform.exit_with_command([
path or "bash",
"--init-file",
exe_dir / "activate",
*(args or ["-i"]),
])
else:
self.spawn_linux_shell(path or "bash", args or ["-i"], script=exe_dir / "activate")
def enter_fish(self, path: str, args: Iterable[str], exe_dir: Path) -> None:
self.spawn_linux_shell(path or "fish", args or ["-i"], script=exe_dir / "activate.fish")
def enter_zsh(self, path: str, args: Iterable[str], exe_dir: Path) -> None:
self.spawn_linux_shell(path or "zsh", args or ["-i"], script=exe_dir / "activate")
def enter_ash(self, path: str, args: Iterable[str], exe_dir: Path) -> None:
self.spawn_linux_shell(path or "ash", args or ["-i"], script=exe_dir / "activate")
def enter_nu(self, path: str, args: Iterable[str], exe_dir: Path) -> None: # noqa: ARG002
executable = path or "nu"
activation_script = exe_dir / "activate.nu"
self.environment.platform.exit_with_command([executable, "-e", f"overlay use {str(activation_script)!r}"])
def enter_tcsh(self, path: str, args: Iterable[str], exe_dir: Path) -> None:
self.spawn_linux_shell(path or "tcsh", args or ["-i"], script=exe_dir / "activate.csh")
def enter_csh(self, path: str, args: Iterable[str], exe_dir: Path) -> None:
self.spawn_linux_shell(path or "csh", args or ["-i"], script=exe_dir / "activate.csh")
if sys.platform == "win32":
def spawn_linux_shell(
self,
path: str,
args: Iterable[str] | None = None,
*,
script: Path | None = None,
callback: Callable | None = None,
) -> None:
raise NotImplementedError
else:
def spawn_linux_shell(
self,
path: str,
args: Iterable[str] | None = None,
*,
script: Path | None = None,
callback: Callable | None = None,
) -> None:
import shutil
import signal
import pexpect
columns, lines = shutil.get_terminal_size()
# pexpect only accepts lists
terminal = pexpect.spawn(path, args=list(args or ()), dimensions=(lines, columns))
def sigwinch_passthrough(sig: int, data: FrameType | None) -> None: # noqa: ARG001
new_columns, new_lines = shutil.get_terminal_size()
terminal.setwinsize(new_lines, new_columns)
signal.signal(signal.SIGWINCH, sigwinch_passthrough)
if script is not None:
terminal.sendline(f'source "{script}"')
if callback is not None:
callback(terminal)
terminal.interact(escape_character=None)
terminal.close()
self.environment.platform.exit_with_code(terminal.exitstatus)
================================================
FILE: src/hatch/utils/structures.py
================================================
from __future__ import annotations
import os
from fnmatch import fnmatch
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from types import TracebackType
class EnvVars(dict):
def __init__(
self, env_vars: dict | None = None, include: list[str] | None = None, exclude: list[str] | None = None
) -> None:
super().__init__(os.environ)
self.old_env = dict(self)
if include:
self.clear()
for env_var, value in self.old_env.items():
for pattern in include:
if fnmatch(env_var, pattern):
self[env_var] = value
break
if exclude:
for env_var in list(self):
for pattern in exclude:
if fnmatch(env_var, pattern):
self.pop(env_var)
break
if env_vars:
self.update(env_vars)
def __enter__(self) -> None:
os.environ.clear()
os.environ.update(self)
def __exit__(
self, exc_type: type[BaseException] | None, exc_value: BaseException | None, traceback: TracebackType | None
) -> None:
os.environ.clear()
os.environ.update(self.old_env)
================================================
FILE: src/hatch/utils/toml.py
================================================
from __future__ import annotations
import sys
from typing import Any
if sys.version_info >= (3, 11):
import tomllib
else:
import tomli as tomllib
def load_toml_data(data: str) -> dict[str, Any]:
return tomllib.loads(data)
def load_toml_file(path: str) -> dict[str, Any]:
with open(path, encoding="utf-8") as f:
return tomllib.loads(f.read())
================================================
FILE: src/hatch/venv/__init__.py
================================================
================================================
FILE: src/hatch/venv/core.py
================================================
import os
from tempfile import TemporaryDirectory
from hatch.env.utils import add_verbosity_flag
from hatch.utils.env import PythonInfo
from hatch.utils.fs import Path
from hatch.venv.utils import get_random_venv_name
class VirtualEnv:
IGNORED_ENV_VARS = ("__PYVENV_LAUNCHER__", "PYTHONHOME")
def __init__(self, directory, platform, verbosity=0):
self.directory = directory
self.platform = platform
self.verbosity = verbosity
self.python_info = PythonInfo(platform)
self._env_vars_to_restore = {}
self._executables_directory = None
def activate(self):
self._env_vars_to_restore["VIRTUAL_ENV"] = os.environ.pop("VIRTUAL_ENV", None)
os.environ["VIRTUAL_ENV"] = str(self.directory)
old_path = os.environ.pop("PATH", None)
self._env_vars_to_restore["PATH"] = old_path
if old_path is None:
os.environ["PATH"] = f"{self.executables_directory}{os.pathsep}{os.defpath}"
else:
os.environ["PATH"] = f"{self.executables_directory}{os.pathsep}{old_path}"
for env_var in self.IGNORED_ENV_VARS:
self._env_vars_to_restore[env_var] = os.environ.pop(env_var, None)
def deactivate(self):
for env_var, value in self._env_vars_to_restore.items():
if value is None:
os.environ.pop(env_var, None)
else:
os.environ[env_var] = value
self._env_vars_to_restore.clear()
def create(self, python, *, allow_system_packages=False):
# WARNING: extremely slow import
from virtualenv import cli_run
self.directory.ensure_parent_dir_exists()
command = [str(self.directory), "--no-download", "--no-periodic-update", "--python", python]
if allow_system_packages:
command.append("--system-site-packages")
# Decrease verbosity since the virtualenv CLI defaults to something like +2 verbosity
add_verbosity_flag(command, self.verbosity, adjustment=-1)
cli_run(command)
def remove(self):
self.directory.remove()
def exists(self):
return self.directory.is_dir()
@property
def executables_directory(self):
if self._executables_directory is None:
exe_dir = self.directory / ("Scripts" if self.platform.windows else "bin")
if exe_dir.is_dir():
self._executables_directory = exe_dir
# PyPy
elif self.platform.windows:
exe_dir = self.directory / "bin"
if exe_dir.is_dir():
self._executables_directory = exe_dir
else:
msg = f"Unable to locate executables directory within: {self.directory}"
raise OSError(msg)
# Debian
elif (self.directory / "local").is_dir(): # no cov
exe_dir = self.directory / "local" / "bin"
if exe_dir.is_dir():
self._executables_directory = exe_dir
else:
msg = f"Unable to locate executables directory within: {self.directory}"
raise OSError(msg)
else:
msg = f"Unable to locate executables directory within: {self.directory}"
raise OSError(msg)
return self._executables_directory
@property
def environment(self):
return self.python_info.environment
@property
def sys_path(self):
return self.python_info.sys_path
def __enter__(self):
self.activate()
return self
def __exit__(self, exc_type, exc_value, traceback):
self.deactivate()
class TempVirtualEnv(VirtualEnv):
def __init__(self, parent_python, platform, verbosity=0):
self.parent_python = parent_python
self.parent_dir = TemporaryDirectory()
directory = Path(self.parent_dir.name).resolve() / get_random_venv_name()
super().__init__(directory, platform, verbosity)
def remove(self):
super().remove()
self.parent_dir.cleanup()
def __enter__(self):
self.create(self.parent_python)
return super().__enter__()
def __exit__(self, exc_type, exc_value, traceback):
super().__exit__(exc_type, exc_value, traceback)
self.remove()
class UVVirtualEnv(VirtualEnv):
def create(self, python, *, allow_system_packages=False):
command = [os.environ.get("HATCH_UV", "uv"), "venv", str(self.directory), "--python", python]
if allow_system_packages:
command.append("--system-site-packages")
add_verbosity_flag(command, self.verbosity, adjustment=-1)
self.platform.run_command(command)
class TempUVVirtualEnv(TempVirtualEnv, UVVirtualEnv): ...
================================================
FILE: src/hatch/venv/utils.py
================================================
from base64 import urlsafe_b64encode
from os import urandom
def get_random_venv_name():
# Will be length 4
return urlsafe_b64encode(urandom(3)).decode("ascii")
================================================
FILE: tests/__init__.py
================================================
================================================
FILE: tests/backend/__init__.py
================================================
================================================
FILE: tests/backend/builders/__init__.py
================================================
================================================
FILE: tests/backend/builders/hooks/__init__.py
================================================
================================================
FILE: tests/backend/builders/hooks/test_custom.py
================================================
import re
import pytest
from hatchling.builders.hooks.custom import CustomBuildHook
from hatchling.utils.constants import DEFAULT_BUILD_SCRIPT
def test_no_path(isolation):
config = {"path": ""}
with pytest.raises(ValueError, match="Option `path` for build hook `custom` must not be empty if defined"):
CustomBuildHook(str(isolation), config, None, None, "", "")
def test_path_not_string(isolation):
config = {"path": 3}
with pytest.raises(TypeError, match="Option `path` for build hook `custom` must be a string"):
CustomBuildHook(str(isolation), config, None, None, "", "")
def test_nonexistent(isolation):
config = {"path": "test.py"}
with pytest.raises(OSError, match="Build script does not exist: test.py"):
CustomBuildHook(str(isolation), config, None, None, "", "")
def test_default(temp_dir, helpers):
config = {}
file_path = temp_dir / DEFAULT_BUILD_SCRIPT
file_path.write_text(
helpers.dedent(
"""
from hatchling.builders.hooks.plugin.interface import BuildHookInterface
class CustomHook(BuildHookInterface):
def foo(self):
return self.PLUGIN_NAME, self.root
"""
)
)
with temp_dir.as_cwd():
hook = CustomBuildHook(str(temp_dir), config, None, None, "", "")
assert hook.foo() == ("custom", str(temp_dir))
def test_explicit_path(temp_dir, helpers):
config = {"path": f"foo/{DEFAULT_BUILD_SCRIPT}"}
file_path = temp_dir / "foo" / DEFAULT_BUILD_SCRIPT
file_path.ensure_parent_dir_exists()
file_path.write_text(
helpers.dedent(
"""
from hatchling.builders.hooks.plugin.interface import BuildHookInterface
class CustomHook(BuildHookInterface):
def foo(self):
return self.PLUGIN_NAME, self.root
"""
)
)
with temp_dir.as_cwd():
hook = CustomBuildHook(str(temp_dir), config, None, None, "", "")
assert hook.foo() == ("custom", str(temp_dir))
def test_no_subclass(temp_dir, helpers):
config = {"path": f"foo/{DEFAULT_BUILD_SCRIPT}"}
file_path = temp_dir / "foo" / DEFAULT_BUILD_SCRIPT
file_path.ensure_parent_dir_exists()
file_path.write_text(
helpers.dedent(
"""
from hatchling.builders.hooks.plugin.interface import BuildHookInterface
foo = None
bar = 'baz'
class CustomHook:
pass
"""
)
)
with (
pytest.raises(
ValueError,
match=re.escape(
f"Unable to find a subclass of `BuildHookInterface` in `foo/{DEFAULT_BUILD_SCRIPT}`: {temp_dir}"
),
),
temp_dir.as_cwd(),
):
CustomBuildHook(str(temp_dir), config, None, None, "", "")
================================================
FILE: tests/backend/builders/hooks/test_version.py
================================================
import pytest
from hatchling.builders.hooks.version import VersionBuildHook
from hatchling.metadata.core import ProjectMetadata
from hatchling.plugin.manager import PluginManager
from hatchling.utils.constants import DEFAULT_BUILD_SCRIPT
class TestConfigPath:
def test_correct(self, isolation):
config = {"path": "foo/bar.py"}
hook = VersionBuildHook(str(isolation), config, None, None, "", "")
assert hook.config_path == hook.config_path == "foo/bar.py"
def test_missing(self, isolation):
config = {"path": ""}
hook = VersionBuildHook(str(isolation), config, None, None, "", "")
with pytest.raises(ValueError, match="Option `path` for build hook `version` is required"):
_ = hook.config_path
def test_not_string(self, isolation):
config = {"path": 9000}
hook = VersionBuildHook(str(isolation), config, None, None, "", "")
with pytest.raises(TypeError, match="Option `path` for build hook `version` must be a string"):
_ = hook.config_path
class TestConfigTemplate:
def test_correct(self, isolation):
config = {"template": "foo"}
hook = VersionBuildHook(str(isolation), config, None, None, "", "")
assert hook.config_template == hook.config_template == "foo"
def test_not_string(self, isolation):
config = {"template": 9000}
hook = VersionBuildHook(str(isolation), config, None, None, "", "")
with pytest.raises(TypeError, match="Option `template` for build hook `version` must be a string"):
_ = hook.config_template
class TestConfigPattern:
def test_correct(self, isolation):
config = {"pattern": "foo"}
hook = VersionBuildHook(str(isolation), config, None, None, "", "")
assert hook.config_pattern == hook.config_pattern == "foo"
def test_not_string(self, isolation):
config = {"pattern": 9000}
hook = VersionBuildHook(str(isolation), config, None, None, "", "")
with pytest.raises(TypeError, match="Option `pattern` for build hook `version` must be a string"):
_ = hook.config_pattern
class TestTemplate:
def test_default(self, temp_dir, helpers):
config = {"path": "baz.py"}
metadata = ProjectMetadata(
str(temp_dir),
PluginManager(),
{
"project": {"name": "foo", "dynamic": ["version"]},
"tool": {"hatch": {"metadata": {"hooks": {"custom": {}}}}},
},
)
file_path = temp_dir / DEFAULT_BUILD_SCRIPT
file_path.write_text(
helpers.dedent(
"""
from hatchling.metadata.plugin.interface import MetadataHookInterface
class CustomHook(MetadataHookInterface):
def update(self, metadata):
metadata['version'] = '1.2.3'
"""
)
)
build_data = {"artifacts": []}
hook = VersionBuildHook(str(temp_dir), config, None, metadata, "", "")
hook.initialize([], build_data)
expected_file = temp_dir / "baz.py"
assert expected_file.is_file()
assert expected_file.read_text() == helpers.dedent(
"""
# This file is auto-generated by Hatchling. As such, do not:
# - modify
# - track in version control e.g. be sure to add to .gitignore
__version__ = VERSION = '1.2.3'
"""
)
assert build_data["artifacts"] == ["/baz.py"]
def test_create_necessary_directories(self, temp_dir, helpers):
config = {"path": "bar/baz.py"}
metadata = ProjectMetadata(
str(temp_dir),
PluginManager(),
{
"project": {"name": "foo", "dynamic": ["version"]},
"tool": {"hatch": {"metadata": {"hooks": {"custom": {}}}}},
},
)
file_path = temp_dir / DEFAULT_BUILD_SCRIPT
file_path.write_text(
helpers.dedent(
"""
from hatchling.metadata.plugin.interface import MetadataHookInterface
class CustomHook(MetadataHookInterface):
def update(self, metadata):
metadata['version'] = '1.2.3'
"""
)
)
build_data = {"artifacts": []}
hook = VersionBuildHook(str(temp_dir), config, None, metadata, "", "")
hook.initialize([], build_data)
expected_file = temp_dir / "bar" / "baz.py"
assert expected_file.is_file()
assert expected_file.read_text() == helpers.dedent(
"""
# This file is auto-generated by Hatchling. As such, do not:
# - modify
# - track in version control e.g. be sure to add to .gitignore
__version__ = VERSION = '1.2.3'
"""
)
assert build_data["artifacts"] == ["/bar/baz.py"]
def test_custom(self, temp_dir, helpers):
config = {"path": "baz.py", "template": "VER = {version!r}\n"}
metadata = ProjectMetadata(
str(temp_dir),
PluginManager(),
{
"project": {"name": "foo", "dynamic": ["version"]},
"tool": {"hatch": {"metadata": {"hooks": {"custom": {}}}}},
},
)
file_path = temp_dir / DEFAULT_BUILD_SCRIPT
file_path.write_text(
helpers.dedent(
"""
from hatchling.metadata.plugin.interface import MetadataHookInterface
class CustomHook(MetadataHookInterface):
def update(self, metadata):
metadata['version'] = '1.2.3'
"""
)
)
build_data = {"artifacts": []}
hook = VersionBuildHook(str(temp_dir), config, None, metadata, "", "")
hook.initialize([], build_data)
expected_file = temp_dir / "baz.py"
assert expected_file.is_file()
assert expected_file.read_text() == helpers.dedent(
"""
VER = '1.2.3'
"""
)
assert build_data["artifacts"] == ["/baz.py"]
class TestPattern:
def test_default(self, temp_dir, helpers):
config = {"path": "baz.py", "pattern": True}
metadata = ProjectMetadata(
str(temp_dir),
PluginManager(),
{
"project": {"name": "foo", "dynamic": ["version"]},
"tool": {"hatch": {"metadata": {"hooks": {"custom": {}}}}},
},
)
file_path = temp_dir / DEFAULT_BUILD_SCRIPT
file_path.write_text(
helpers.dedent(
"""
from hatchling.metadata.plugin.interface import MetadataHookInterface
class CustomHook(MetadataHookInterface):
def update(self, metadata):
metadata['version'] = '1.2.3'
"""
)
)
version_file = temp_dir / "baz.py"
version_file.write_text(
helpers.dedent(
"""
__version__ = '0.0.0'
"""
)
)
build_data = {"artifacts": []}
hook = VersionBuildHook(str(temp_dir), config, None, metadata, "", "")
hook.initialize([], build_data)
assert version_file.read_text() == helpers.dedent(
"""
__version__ = '1.2.3'
"""
)
assert build_data["artifacts"] == ["/baz.py"]
def test_custom(self, temp_dir, helpers):
config = {"path": "baz.py", "pattern": 'v = "(?P.+)"'}
metadata = ProjectMetadata(
str(temp_dir),
PluginManager(),
{
"project": {"name": "foo", "dynamic": ["version"]},
"tool": {"hatch": {"metadata": {"hooks": {"custom": {}}}}},
},
)
file_path = temp_dir / DEFAULT_BUILD_SCRIPT
file_path.write_text(
helpers.dedent(
"""
from hatchling.metadata.plugin.interface import MetadataHookInterface
class CustomHook(MetadataHookInterface):
def update(self, metadata):
metadata['version'] = '1.2.3'
"""
)
)
version_file = temp_dir / "baz.py"
version_file.write_text(
helpers.dedent(
"""
v = "0.0.0"
"""
)
)
build_data = {"artifacts": []}
hook = VersionBuildHook(str(temp_dir), config, None, metadata, "", "")
hook.initialize([], build_data)
assert version_file.read_text() == helpers.dedent(
"""
v = "1.2.3"
"""
)
assert build_data["artifacts"] == ["/baz.py"]
================================================
FILE: tests/backend/builders/plugin/__init__.py
================================================
================================================
FILE: tests/backend/builders/plugin/test_interface.py
================================================
from os.path import sep as path_sep
import pytest
from hatchling.builders.constants import EXCLUDED_DIRECTORIES, EXCLUDED_FILES
from hatchling.metadata.core import ProjectMetadata
from hatchling.plugin.manager import PluginManager
from ..utils import MockBuilder
class TestClean:
def test_default(self, isolation):
builder = MockBuilder(str(isolation))
builder.clean(None, None)
class TestPluginManager:
def test_default(self, isolation):
builder = MockBuilder(str(isolation))
assert isinstance(builder.plugin_manager, PluginManager)
def test_reuse(self, isolation):
plugin_manager = PluginManager()
builder = MockBuilder(str(isolation), plugin_manager=plugin_manager)
assert builder.plugin_manager is plugin_manager
class TestRawConfig:
def test_default(self, isolation):
builder = MockBuilder(str(isolation))
assert builder.raw_config == builder.raw_config == {}
def test_reuse(self, isolation):
config = {}
builder = MockBuilder(str(isolation), config=config)
assert builder.raw_config is builder.raw_config is config
def test_read(self, temp_dir):
project_file = temp_dir / "pyproject.toml"
project_file.write_text("foo = 5")
with temp_dir.as_cwd():
builder = MockBuilder(str(temp_dir))
assert builder.raw_config == builder.raw_config == {"foo": 5}
class TestMetadata:
def test_base(self, isolation):
config = {"project": {"name": "foo"}}
builder = MockBuilder(str(isolation), config=config)
assert isinstance(builder.metadata, ProjectMetadata)
assert builder.metadata.core.name == "foo"
def test_core(self, isolation):
config = {"project": {}}
builder = MockBuilder(str(isolation), config=config)
assert builder.project_config == builder.project_config == config["project"]
def test_hatch(self, isolation):
config = {"tool": {"hatch": {}}}
builder = MockBuilder(str(isolation), config=config)
assert builder.hatch_config is builder.hatch_config is config["tool"]["hatch"]
def test_build_config(self, isolation):
config = {"tool": {"hatch": {"build": {}}}}
builder = MockBuilder(str(isolation), config=config)
assert builder.build_config is builder.build_config is config["tool"]["hatch"]["build"]
def test_build_config_not_table(self, isolation):
config = {"tool": {"hatch": {"build": "foo"}}}
builder = MockBuilder(str(isolation), config=config)
with pytest.raises(TypeError, match="Field `tool.hatch.build` must be a table"):
_ = builder.build_config
def test_target_config(self, isolation):
config = {"tool": {"hatch": {"build": {"targets": {"foo": {}}}}}}
builder = MockBuilder(str(isolation), config=config)
builder.PLUGIN_NAME = "foo"
assert builder.target_config is builder.target_config is config["tool"]["hatch"]["build"]["targets"]["foo"]
def test_target_config_not_table(self, isolation):
config = {"tool": {"hatch": {"build": {"targets": {"foo": "bar"}}}}}
builder = MockBuilder(str(isolation), config=config)
builder.PLUGIN_NAME = "foo"
with pytest.raises(TypeError, match="Field `tool.hatch.build.targets.foo` must be a table"):
_ = builder.target_config
class TestProjectID:
def test_normalization(self, isolation):
config = {"project": {"name": "my-app", "version": "1.0.0-rc.1"}}
builder = MockBuilder(str(isolation), config=config)
assert builder.project_id == builder.project_id == "my_app-1.0.0rc1"
class TestBuildValidation:
def test_unknown_version(self, isolation):
config = {
"project": {"name": "foo", "version": "0.1.0"},
"tool": {"hatch": {"build": {"targets": {"foo": {"versions": ["1"]}}}}},
}
builder = MockBuilder(str(isolation), config=config)
builder.PLUGIN_NAME = "foo"
builder.get_version_api = lambda: {"1": str}
with pytest.raises(ValueError, match="Unknown versions for target `foo`: 42, 9000"):
next(builder.build(directory=str(isolation), versions=["9000", "42"]))
def test_invalid_metadata(self, isolation):
config = {
"project": {"name": "foo", "version": "0.1.0", "dynamic": ["version"]},
"tool": {"hatch": {"build": {"targets": {"foo": {"versions": ["1"]}}}}},
}
builder = MockBuilder(str(isolation), config=config)
builder.PLUGIN_NAME = "foo"
with pytest.raises(
ValueError,
match="Metadata field `version` cannot be both statically defined and listed in field `project.dynamic`",
):
next(builder.build(directory=str(isolation)))
class TestHookConfig:
def test_unknown(self, isolation):
config = {"tool": {"hatch": {"build": {"hooks": {"foo": {"bar": "baz"}}}}}}
builder = MockBuilder(str(isolation), config=config)
with pytest.raises(ValueError, match="Unknown build hook: foo"):
_ = builder.get_build_hooks(str(isolation))
class TestDirectoryRecursion:
@pytest.mark.requires_unix
def test_infinite_loop_prevention(self, temp_dir):
project_dir = temp_dir / "project"
project_dir.ensure_dir_exists()
with project_dir.as_cwd():
config = {"tool": {"hatch": {"build": {"include": ["foo", "README.md"]}}}}
builder = MockBuilder(str(project_dir), config=config)
(project_dir / "README.md").touch()
foo = project_dir / "foo"
foo.ensure_dir_exists()
(foo / "bar.txt").touch()
(foo / "baz").symlink_to(project_dir)
assert [f.path for f in builder.recurse_included_files()] == [
str(project_dir / "README.md"),
str(project_dir / "foo" / "bar.txt"),
]
def test_only_include(self, temp_dir):
project_dir = temp_dir / "project"
project_dir.ensure_dir_exists()
with project_dir.as_cwd():
config = {"tool": {"hatch": {"build": {"only-include": ["foo"], "artifacts": ["README.md"]}}}}
builder = MockBuilder(str(project_dir), config=config)
(project_dir / "README.md").touch()
foo = project_dir / "foo"
foo.ensure_dir_exists()
(foo / "bar.txt").touch()
assert [f.path for f in builder.recurse_included_files()] == [str(project_dir / "foo" / "bar.txt")]
def test_no_duplication_force_include_only(self, temp_dir):
project_dir = temp_dir / "project"
project_dir.ensure_dir_exists()
with project_dir.as_cwd():
config = {
"tool": {
"hatch": {
"build": {
"force-include": {
"../external.txt": "new/target2.txt",
"old": "new",
},
}
}
}
}
builder = MockBuilder(str(project_dir), config=config)
(project_dir / "foo.txt").touch()
old = project_dir / "old"
old.ensure_dir_exists()
(old / "target1.txt").touch()
(old / "target2.txt").touch()
(temp_dir / "external.txt").touch()
build_data = builder.get_default_build_data()
builder.set_build_data_defaults(build_data)
with builder.config.set_build_data(build_data):
assert [(f.path, f.distribution_path) for f in builder.recurse_included_files()] == [
(str(project_dir / "foo.txt"), "foo.txt"),
(str(project_dir / "old" / "target1.txt"), f"new{path_sep}target1.txt"),
(str(temp_dir / "external.txt"), f"new{path_sep}target2.txt"),
]
def test_no_duplication_force_include_and_selection(self, temp_dir):
project_dir = temp_dir / "project"
project_dir.ensure_dir_exists()
with project_dir.as_cwd():
config = {
"tool": {
"hatch": {
"build": {
"include": ["foo.txt", "bar.txt", "baz.txt"],
"force-include": {"../external.txt": "new/file.txt"},
}
}
}
}
builder = MockBuilder(str(project_dir), config=config)
(project_dir / "foo.txt").touch()
(project_dir / "bar.txt").touch()
(project_dir / "baz.txt").touch()
(temp_dir / "external.txt").touch()
build_data = builder.get_default_build_data()
builder.set_build_data_defaults(build_data)
build_data["force_include"]["bar.txt"] = "bar.txt"
with builder.config.set_build_data(build_data):
assert [(f.path, f.distribution_path) for f in builder.recurse_included_files()] == [
(str(project_dir / "baz.txt"), "baz.txt"),
(str(project_dir / "foo.txt"), "foo.txt"),
(str(temp_dir / "external.txt"), f"new{path_sep}file.txt"),
(str(project_dir / "bar.txt"), "bar.txt"),
]
def test_no_duplication_force_include_with_sources(self, temp_dir):
project_dir = temp_dir / "project"
project_dir.ensure_dir_exists()
with project_dir.as_cwd():
config = {
"tool": {
"hatch": {
"build": {
"include": ["src"],
"sources": ["src"],
"force-include": {"../external.txt": "new/file.txt"},
}
}
}
}
builder = MockBuilder(str(project_dir), config=config)
src_dir = project_dir / "src"
src_dir.mkdir()
(src_dir / "foo.txt").touch()
(src_dir / "bar.txt").touch()
(src_dir / "baz.txt").touch()
(temp_dir / "external.txt").touch()
build_data = builder.get_default_build_data()
builder.set_build_data_defaults(build_data)
build_data["force_include"]["src/bar.txt"] = "bar.txt"
with builder.config.set_build_data(build_data):
assert [(f.path, f.distribution_path) for f in builder.recurse_included_files()] == [
(str(src_dir / "baz.txt"), "baz.txt"),
(str(src_dir / "foo.txt"), "foo.txt"),
(str(temp_dir / "external.txt"), f"new{path_sep}file.txt"),
(str(src_dir / "bar.txt"), "bar.txt"),
]
def test_exists(self, temp_dir):
project_dir = temp_dir / "project"
project_dir.ensure_dir_exists()
with project_dir.as_cwd():
config = {
"tool": {
"hatch": {
"build": {
"force-include": {
"../notfound": "target.txt",
},
}
}
}
}
builder = MockBuilder(str(project_dir), config=config)
build_data = builder.get_default_build_data()
builder.set_build_data_defaults(build_data)
with (
builder.config.set_build_data(build_data),
pytest.raises(FileNotFoundError, match="Forced include not found"),
):
list(builder.recurse_included_files())
def test_order(self, temp_dir):
project_dir = temp_dir / "project"
project_dir.ensure_dir_exists()
with project_dir.as_cwd():
config = {
"tool": {
"hatch": {
"build": {
"sources": ["src"],
"include": ["src/foo", "bar", "README.md", "tox.ini"],
"exclude": ["**/foo/baz.txt"],
"force-include": {
"../external1.txt": "nested/target2.txt",
"../external2.txt": "nested/target1.txt",
"../external": "nested",
},
}
}
}
}
builder = MockBuilder(str(project_dir), config=config)
foo = project_dir / "src" / "foo"
foo.ensure_dir_exists()
(foo / "bar.txt").touch()
(foo / "baz.txt").touch()
bar = project_dir / "bar"
bar.ensure_dir_exists()
(bar / "foo.txt").touch()
# Excluded
for name in EXCLUDED_DIRECTORIES:
excluded_dir = bar / name
excluded_dir.ensure_dir_exists()
(excluded_dir / "file.ext").touch()
for name in EXCLUDED_FILES:
excluded_file = bar / name
excluded_file.touch()
(project_dir / "README.md").touch()
(project_dir / "tox.ini").touch()
(temp_dir / "external1.txt").touch()
(temp_dir / "external2.txt").touch()
external = temp_dir / "external"
external.ensure_dir_exists()
(external / "external1.txt").touch()
(external / "external2.txt").touch()
# Excluded
for name in EXCLUDED_DIRECTORIES:
excluded_dir = external / name
excluded_dir.ensure_dir_exists()
(excluded_dir / "file.ext").touch()
for name in EXCLUDED_FILES:
excluded_file = external / name
excluded_file.touch()
assert [(f.path, f.distribution_path) for f in builder.recurse_included_files()] == [
(str(project_dir / "README.md"), "README.md"),
(str(project_dir / "tox.ini"), "tox.ini"),
(
str(project_dir / "bar" / "foo.txt"),
f"bar{path_sep}foo.txt",
),
(str(project_dir / "src" / "foo" / "bar.txt"), f"foo{path_sep}bar.txt"),
(str(temp_dir / "external" / "external1.txt"), f"nested{path_sep}external1.txt"),
(str(temp_dir / "external" / "external2.txt"), f"nested{path_sep}external2.txt"),
(str(temp_dir / "external2.txt"), f"nested{path_sep}target1.txt"),
(str(temp_dir / "external1.txt"), f"nested{path_sep}target2.txt"),
]
================================================
FILE: tests/backend/builders/test_binary.py
================================================
from __future__ import annotations
import os
import subprocess
import sys
from typing import Any
import pytest
from hatch.utils.fs import Path
from hatch.utils.structures import EnvVars
from hatchling.builders.app import AppBuilder
from hatchling.builders.binary import BinaryBuilder
from hatchling.builders.plugin.interface import BuilderInterface
pytestmark = [pytest.mark.requires_cargo, pytest.mark.requires_internet]
class ExpectedEnvVars:
def __init__(self, env_vars: dict):
self.env_vars = env_vars
def __eq__(self, other):
return all(not (key not in other or other[key] != value) for key, value in self.env_vars.items())
def __hash__(self): # no cov
return hash(self.env_vars)
def cargo_install(*args: Any, **_kwargs: Any) -> subprocess.CompletedProcess:
executable_name = "pyapp.exe" if sys.platform == "win32" else "pyapp"
install_command: list[str] = args[0]
repo_path = os.environ.get("PYAPP_REPO", "")
if repo_path:
temp_dir = install_command[install_command.index("--target-dir") + 1]
build_target = os.environ.get("CARGO_BUILD_TARGET", "")
if build_target:
executable = Path(temp_dir, build_target, "release", executable_name)
else:
executable = Path(temp_dir, "release", executable_name)
executable.parent.ensure_dir_exists()
executable.touch()
else:
temp_dir = install_command[install_command.index("--root") + 1]
executable = Path(temp_dir, "bin", executable_name)
executable.parent.ensure_dir_exists()
executable.touch()
return subprocess.CompletedProcess(install_command, returncode=0, stdout=None, stderr=None)
def test_class():
assert issubclass(BinaryBuilder, BuilderInterface)
def test_class_legacy():
assert issubclass(AppBuilder, BinaryBuilder)
def test_default_versions(isolation):
builder = BinaryBuilder(str(isolation))
assert builder.get_default_versions() == ["bootstrap"]
class TestScripts:
def test_unset(self, isolation):
config = {"project": {"name": "My.App", "version": "0.1.0"}}
builder = BinaryBuilder(str(isolation), config=config)
assert builder.config.scripts == builder.config.scripts == []
def test_default(self, isolation):
config = {
"project": {
"name": "My.App",
"version": "0.1.0",
"scripts": {"b": "foo.bar.baz:cli", "a": "baz.bar.foo:cli"},
}
}
builder = BinaryBuilder(str(isolation), config=config)
assert builder.config.scripts == ["a", "b"]
def test_specific(self, isolation):
config = {
"project": {
"name": "My.App",
"version": "0.1.0",
"scripts": {"b": "foo.bar.baz:cli", "a": "baz.bar.foo:cli"},
},
"tool": {"hatch": {"build": {"targets": {"binary": {"scripts": ["a", "a"]}}}}},
}
builder = BinaryBuilder(str(isolation), config=config)
assert builder.config.scripts == ["a"]
def test_not_array(self, isolation):
config = {
"project": {
"name": "My.App",
"version": "0.1.0",
"scripts": {"b": "foo.bar.baz:cli", "a": "baz.bar.foo:cli"},
},
"tool": {"hatch": {"build": {"targets": {"binary": {"scripts": 9000}}}}},
}
builder = BinaryBuilder(str(isolation), config=config)
with pytest.raises(TypeError, match="Field `tool.hatch.build.targets.binary.scripts` must be an array"):
_ = builder.config.scripts
def test_script_not_string(self, isolation):
config = {
"project": {
"name": "My.App",
"version": "0.1.0",
"scripts": {"b": "foo.bar.baz:cli", "a": "baz.bar.foo:cli"},
},
"tool": {"hatch": {"build": {"targets": {"binary": {"scripts": [9000]}}}}},
}
builder = BinaryBuilder(str(isolation), config=config)
with pytest.raises(
TypeError, match="Script #1 of field `tool.hatch.build.targets.binary.scripts` must be a string"
):
_ = builder.config.scripts
def test_unknown_script(self, isolation):
config = {
"project": {
"name": "My.App",
"version": "0.1.0",
"scripts": {"b": "foo.bar.baz:cli", "a": "baz.bar.foo:cli"},
},
"tool": {"hatch": {"build": {"targets": {"binary": {"scripts": ["c"]}}}}},
}
builder = BinaryBuilder(str(isolation), config=config)
with pytest.raises(ValueError, match="Unknown script in field `tool.hatch.build.targets.binary.scripts`: c"):
_ = builder.config.scripts
class TestPythonVersion:
def test_default_no_source(self, isolation):
config = {"project": {"name": "My.App", "version": "0.1.0"}}
builder = BinaryBuilder(str(isolation), config=config)
assert builder.config.python_version == builder.config.python_version == builder.config.SUPPORTED_VERSIONS[0]
def test_default_explicit_source(self, isolation):
config = {"project": {"name": "My.App", "version": "0.1.0"}}
builder = BinaryBuilder(str(isolation), config=config)
with EnvVars({"PYAPP_DISTRIBUTION_SOURCE": "url"}):
assert builder.config.python_version == builder.config.python_version == ""
def test_set(self, isolation):
config = {
"project": {
"name": "My.App",
"version": "0.1.0",
},
"tool": {"hatch": {"build": {"targets": {"binary": {"python-version": "4.0"}}}}},
}
builder = BinaryBuilder(str(isolation), config=config)
assert builder.config.python_version == "4.0"
def test_not_string(self, isolation):
config = {
"project": {
"name": "My.App",
"version": "0.1.0",
},
"tool": {"hatch": {"build": {"targets": {"binary": {"python-version": 9000}}}}},
}
builder = BinaryBuilder(str(isolation), config=config)
with pytest.raises(TypeError, match="Field `tool.hatch.build.targets.binary.python-version` must be a string"):
_ = builder.config.python_version
def test_compatibility(self, isolation):
config = {
"project": {
"name": "My.App",
"version": "0.1.0",
"requires-python": "<3.11",
},
}
builder = BinaryBuilder(str(isolation), config=config)
assert builder.config.python_version == "3.10"
def test_incompatible(self, isolation):
config = {
"project": {
"name": "My.App",
"version": "0.1.0",
"requires-python": ">9000",
},
}
builder = BinaryBuilder(str(isolation), config=config)
with pytest.raises(
ValueError, match="Field `project.requires-python` is incompatible with the known distributions"
):
_ = builder.config.python_version
class TestPyAppVersion:
def test_default(self, isolation):
config = {"project": {"name": "My.App", "version": "0.1.0"}}
builder = BinaryBuilder(str(isolation), config=config)
assert builder.config.pyapp_version == builder.config.pyapp_version == ""
def test_set(self, isolation):
config = {
"project": {
"name": "My.App",
"version": "0.1.0",
},
"tool": {"hatch": {"build": {"targets": {"binary": {"pyapp-version": "9000"}}}}},
}
builder = BinaryBuilder(str(isolation), config=config)
assert builder.config.pyapp_version == "9000"
def test_not_string(self, isolation):
config = {
"project": {
"name": "My.App",
"version": "0.1.0",
},
"tool": {"hatch": {"build": {"targets": {"binary": {"pyapp-version": 9000}}}}},
}
builder = BinaryBuilder(str(isolation), config=config)
with pytest.raises(TypeError, match="Field `tool.hatch.build.targets.binary.pyapp-version` must be a string"):
_ = builder.config.pyapp_version
class TestBuildBootstrap:
def test_default(self, hatch, temp_dir, mocker):
subprocess_run = mocker.patch("subprocess.run", side_effect=cargo_install)
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"
config = {
"project": {"name": project_name, "version": "0.1.0"},
"tool": {
"hatch": {
"build": {"targets": {"binary": {"versions": ["bootstrap"]}}},
},
},
}
builder = BinaryBuilder(str(project_path), config=config)
build_path = project_path / "dist"
with project_path.as_cwd():
artifacts = list(builder.build())
subprocess_run.assert_called_once_with(
["cargo", "install", "pyapp", "--force", "--root", mocker.ANY],
cwd=mocker.ANY,
env=ExpectedEnvVars({"PYAPP_PROJECT_NAME": "my-app", "PYAPP_PROJECT_VERSION": "0.1.0"}),
)
assert len(artifacts) == 1
expected_artifact = artifacts[0]
build_artifacts = list(build_path.iterdir())
assert len(build_artifacts) == 1
assert expected_artifact == str(build_artifacts[0])
assert (build_path / "binary" / ("my-app-0.1.0.exe" if sys.platform == "win32" else "my-app-0.1.0")).is_file()
def test_default_build_target(self, hatch, temp_dir, mocker):
subprocess_run = mocker.patch("subprocess.run", side_effect=cargo_install)
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"
config = {
"project": {"name": project_name, "version": "0.1.0"},
"tool": {
"hatch": {
"build": {"targets": {"binary": {"versions": ["bootstrap"]}}},
},
},
}
builder = BinaryBuilder(str(project_path), config=config)
build_path = project_path / "dist"
with project_path.as_cwd({"CARGO_BUILD_TARGET": "target"}):
artifacts = list(builder.build())
subprocess_run.assert_called_once_with(
["cargo", "install", "pyapp", "--force", "--root", mocker.ANY],
cwd=mocker.ANY,
env=ExpectedEnvVars({"PYAPP_PROJECT_NAME": "my-app", "PYAPP_PROJECT_VERSION": "0.1.0"}),
)
assert len(artifacts) == 1
expected_artifact = artifacts[0]
build_artifacts = list(build_path.iterdir())
assert len(build_artifacts) == 1
assert expected_artifact == str(build_artifacts[0])
assert (
build_path / "binary" / ("my-app-0.1.0-target.exe" if sys.platform == "win32" else "my-app-0.1.0-target")
).is_file()
def test_scripts(self, hatch, temp_dir, mocker):
subprocess_run = mocker.patch("subprocess.run", side_effect=cargo_install)
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"
config = {
"project": {"name": project_name, "version": "0.1.0", "scripts": {"foo": "bar.baz:cli"}},
"tool": {
"hatch": {
"build": {"targets": {"binary": {"versions": ["bootstrap"]}}},
},
},
}
builder = BinaryBuilder(str(project_path), config=config)
build_path = project_path / "dist"
with project_path.as_cwd():
artifacts = list(builder.build())
subprocess_run.assert_called_once_with(
["cargo", "install", "pyapp", "--force", "--root", mocker.ANY],
cwd=mocker.ANY,
env=ExpectedEnvVars({
"PYAPP_PROJECT_NAME": "my-app",
"PYAPP_PROJECT_VERSION": "0.1.0",
"PYAPP_EXEC_SPEC": "bar.baz:cli",
}),
)
assert len(artifacts) == 1
expected_artifact = artifacts[0]
build_artifacts = list(build_path.iterdir())
assert len(build_artifacts) == 1
assert expected_artifact == str(build_artifacts[0])
assert (build_path / "binary" / ("foo-0.1.0.exe" if sys.platform == "win32" else "foo-0.1.0")).is_file()
def test_scripts_build_target(self, hatch, temp_dir, mocker):
subprocess_run = mocker.patch("subprocess.run", side_effect=cargo_install)
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"
config = {
"project": {"name": project_name, "version": "0.1.0", "scripts": {"foo": "bar.baz:cli"}},
"tool": {
"hatch": {
"build": {"targets": {"binary": {"versions": ["bootstrap"]}}},
},
},
}
builder = BinaryBuilder(str(project_path), config=config)
build_path = project_path / "dist"
with project_path.as_cwd({"CARGO_BUILD_TARGET": "target"}):
artifacts = list(builder.build())
subprocess_run.assert_called_once_with(
["cargo", "install", "pyapp", "--force", "--root", mocker.ANY],
cwd=mocker.ANY,
env=ExpectedEnvVars({
"PYAPP_PROJECT_NAME": "my-app",
"PYAPP_PROJECT_VERSION": "0.1.0",
"PYAPP_EXEC_SPEC": "bar.baz:cli",
}),
)
assert len(artifacts) == 1
expected_artifact = artifacts[0]
build_artifacts = list(build_path.iterdir())
assert len(build_artifacts) == 1
assert expected_artifact == str(build_artifacts[0])
assert (
build_path / "binary" / ("foo-0.1.0-target.exe" if sys.platform == "win32" else "foo-0.1.0-target")
).is_file()
def test_custom_cargo(self, hatch, temp_dir, mocker):
subprocess_run = mocker.patch("subprocess.run", side_effect=cargo_install)
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"
config = {
"project": {"name": project_name, "version": "0.1.0"},
"tool": {
"hatch": {
"build": {"targets": {"binary": {"versions": ["bootstrap"]}}},
},
},
}
builder = BinaryBuilder(str(project_path), config=config)
build_path = project_path / "dist"
with project_path.as_cwd({"CARGO": "cross"}):
artifacts = list(builder.build())
subprocess_run.assert_called_once_with(
["cross", "install", "pyapp", "--force", "--root", mocker.ANY],
cwd=mocker.ANY,
env=ExpectedEnvVars({"PYAPP_PROJECT_NAME": "my-app", "PYAPP_PROJECT_VERSION": "0.1.0"}),
)
assert len(artifacts) == 1
expected_artifact = artifacts[0]
build_artifacts = list(build_path.iterdir())
assert len(build_artifacts) == 1
assert expected_artifact == str(build_artifacts[0])
assert (build_path / "binary" / ("my-app-0.1.0.exe" if sys.platform == "win32" else "my-app-0.1.0")).is_file()
def test_no_cargo(self, hatch, temp_dir, mocker):
mocker.patch("shutil.which", return_value=None)
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"
config = {
"project": {"name": project_name, "version": "0.1.0"},
"tool": {
"hatch": {
"build": {"targets": {"binary": {"versions": ["bootstrap"]}}},
},
},
}
builder = BinaryBuilder(str(project_path), config=config)
with pytest.raises(OSError, match="Executable `cargo` could not be found on PATH"), project_path.as_cwd():
next(builder.build())
def test_python_version(self, hatch, temp_dir, mocker):
subprocess_run = mocker.patch("subprocess.run", side_effect=cargo_install)
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"
config = {
"project": {"name": project_name, "version": "0.1.0"},
"tool": {
"hatch": {
"build": {"targets": {"binary": {"versions": ["bootstrap"], "python-version": "4.0"}}},
},
},
}
builder = BinaryBuilder(str(project_path), config=config)
build_path = project_path / "dist"
with project_path.as_cwd():
artifacts = list(builder.build())
subprocess_run.assert_called_once_with(
["cargo", "install", "pyapp", "--force", "--root", mocker.ANY],
cwd=mocker.ANY,
env=ExpectedEnvVars({
"PYAPP_PROJECT_NAME": "my-app",
"PYAPP_PROJECT_VERSION": "0.1.0",
"PYAPP_PYTHON_VERSION": "4.0",
}),
)
assert len(artifacts) == 1
expected_artifact = artifacts[0]
build_artifacts = list(build_path.iterdir())
assert len(build_artifacts) == 1
assert expected_artifact == str(build_artifacts[0])
assert (build_path / "binary" / ("my-app-0.1.0.exe" if sys.platform == "win32" else "my-app-0.1.0")).is_file()
def test_pyapp_version(self, hatch, temp_dir, mocker):
subprocess_run = mocker.patch("subprocess.run", side_effect=cargo_install)
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"
config = {
"project": {"name": project_name, "version": "0.1.0"},
"tool": {
"hatch": {
"build": {"targets": {"binary": {"versions": ["bootstrap"], "pyapp-version": "9000"}}},
},
},
}
builder = BinaryBuilder(str(project_path), config=config)
build_path = project_path / "dist"
with project_path.as_cwd():
artifacts = list(builder.build())
subprocess_run.assert_called_once_with(
["cargo", "install", "pyapp", "--force", "--root", mocker.ANY, "--version", "9000"],
cwd=mocker.ANY,
env=ExpectedEnvVars({"PYAPP_PROJECT_NAME": "my-app", "PYAPP_PROJECT_VERSION": "0.1.0"}),
)
assert len(artifacts) == 1
expected_artifact = artifacts[0]
build_artifacts = list(build_path.iterdir())
assert len(build_artifacts) == 1
assert expected_artifact == str(build_artifacts[0])
assert (build_path / "binary" / ("my-app-0.1.0.exe" if sys.platform == "win32" else "my-app-0.1.0")).is_file()
def test_verbosity(self, hatch, temp_dir, mocker):
subprocess_run = mocker.patch("subprocess.run", side_effect=cargo_install)
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"
config = {
"project": {"name": project_name, "version": "0.1.0"},
"tool": {
"hatch": {
"build": {"targets": {"binary": {"versions": ["bootstrap"]}}},
},
},
}
builder = BinaryBuilder(str(project_path), config=config)
build_path = project_path / "dist"
with project_path.as_cwd({"HATCH_QUIET": "1"}):
artifacts = list(builder.build())
subprocess_run.assert_called_once_with(
["cargo", "install", "pyapp", "--force", "--root", mocker.ANY],
cwd=mocker.ANY,
env=ExpectedEnvVars({"PYAPP_PROJECT_NAME": "my-app", "PYAPP_PROJECT_VERSION": "0.1.0"}),
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
)
assert len(artifacts) == 1
expected_artifact = artifacts[0]
build_artifacts = list(build_path.iterdir())
assert len(build_artifacts) == 1
assert expected_artifact == str(build_artifacts[0])
assert (build_path / "binary" / ("my-app-0.1.0.exe" if sys.platform == "win32" else "my-app-0.1.0")).is_file()
def test_local_build_with_build_target(self, hatch, temp_dir, mocker):
subprocess_run = mocker.patch("subprocess.run", side_effect=cargo_install)
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"
config = {
"project": {"name": project_name, "version": "0.1.0"},
"tool": {
"hatch": {
"build": {"targets": {"binary": {"versions": ["bootstrap"]}}},
},
},
}
builder = BinaryBuilder(str(project_path), config=config)
build_path = project_path / "dist"
with project_path.as_cwd({"PYAPP_REPO": "test-path", "CARGO_BUILD_TARGET": "target"}):
artifacts = list(builder.build())
subprocess_run.assert_called_once_with(
["cargo", "build", "--release", "--target-dir", mocker.ANY],
cwd="test-path",
env=ExpectedEnvVars({"PYAPP_PROJECT_NAME": "my-app", "PYAPP_PROJECT_VERSION": "0.1.0"}),
)
assert len(artifacts) == 1
expected_artifact = artifacts[0]
build_artifacts = list(build_path.iterdir())
assert len(build_artifacts) == 1
assert expected_artifact == str(build_artifacts[0])
assert (
build_path / "binary" / ("my-app-0.1.0-target.exe" if sys.platform == "win32" else "my-app-0.1.0-target")
).is_file()
def test_local_build_no_build_target(self, hatch, temp_dir, mocker):
subprocess_run = mocker.patch("subprocess.run", side_effect=cargo_install)
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"
config = {
"project": {"name": project_name, "version": "0.1.0"},
"tool": {
"hatch": {
"build": {"targets": {"binary": {"versions": ["bootstrap"]}}},
},
},
}
builder = BinaryBuilder(str(project_path), config=config)
build_path = project_path / "dist"
with project_path.as_cwd({"PYAPP_REPO": "test-path"}):
artifacts = list(builder.build())
subprocess_run.assert_called_once_with(
["cargo", "build", "--release", "--target-dir", mocker.ANY],
cwd="test-path",
env=ExpectedEnvVars({"PYAPP_PROJECT_NAME": "my-app", "PYAPP_PROJECT_VERSION": "0.1.0"}),
)
assert len(artifacts) == 1
expected_artifact = artifacts[0]
build_artifacts = list(build_path.iterdir())
assert len(build_artifacts) == 1
assert expected_artifact == str(build_artifacts[0])
assert (build_path / "binary" / ("my-app-0.1.0.exe" if sys.platform == "win32" else "my-app-0.1.0")).is_file()
def test_legacy(self, hatch, temp_dir, mocker):
subprocess_run = mocker.patch("subprocess.run", side_effect=cargo_install)
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"
config = {
"project": {"name": project_name, "version": "0.1.0"},
"tool": {
"hatch": {
"build": {"targets": {"app": {"versions": ["bootstrap"]}}},
},
},
}
builder = AppBuilder(str(project_path), config=config)
build_path = project_path / "dist"
with project_path.as_cwd():
artifacts = list(builder.build())
subprocess_run.assert_called_once_with(
["cargo", "install", "pyapp", "--force", "--root", mocker.ANY],
cwd=mocker.ANY,
env=ExpectedEnvVars({"PYAPP_PROJECT_NAME": "my-app", "PYAPP_PROJECT_VERSION": "0.1.0"}),
)
assert len(artifacts) == 1
expected_artifact = artifacts[0]
build_artifacts = list(build_path.iterdir())
assert len(build_artifacts) == 1
assert expected_artifact == str(build_artifacts[0])
assert (build_path / "app" / ("my-app-0.1.0.exe" if sys.platform == "win32" else "my-app-0.1.0")).is_file()
================================================
FILE: tests/backend/builders/test_config.py
================================================
import os
import re
from os.path import join as pjoin
import pathspec
import pytest
from hatch.utils.structures import EnvVars
from hatchling.builders.constants import BuildEnvVars
from .utils import MockBuilder
class TestDirectory:
def test_default(self, isolation):
builder = MockBuilder(str(isolation))
assert builder.config.directory == builder.config.directory == str(isolation / "dist")
def test_target(self, isolation):
config = {"tool": {"hatch": {"build": {"targets": {"foo": {"directory": "bar"}}}}}}
builder = MockBuilder(str(isolation), config=config)
builder.PLUGIN_NAME = "foo"
assert builder.config.directory == str(isolation / "bar")
def test_target_not_boolean(self, isolation):
config = {"tool": {"hatch": {"build": {"targets": {"foo": {"directory": 9000}}}}}}
builder = MockBuilder(str(isolation), config=config)
builder.PLUGIN_NAME = "foo"
with pytest.raises(TypeError, match="Field `tool.hatch.build.targets.foo.directory` must be a string"):
_ = builder.config.directory
def test_global(self, isolation):
config = {"tool": {"hatch": {"build": {"directory": "bar"}}}}
builder = MockBuilder(str(isolation), config=config)
builder.PLUGIN_NAME = "foo"
assert builder.config.directory == str(isolation / "bar")
def test_global_not_boolean(self, isolation):
config = {"tool": {"hatch": {"build": {"directory": 9000}}}}
builder = MockBuilder(str(isolation), config=config)
builder.PLUGIN_NAME = "foo"
with pytest.raises(TypeError, match="Field `tool.hatch.build.directory` must be a string"):
_ = builder.config.directory
def test_target_overrides_global(self, isolation):
config = {"tool": {"hatch": {"build": {"directory": "bar", "targets": {"foo": {"directory": "baz"}}}}}}
builder = MockBuilder(str(isolation), config=config)
builder.PLUGIN_NAME = "foo"
assert builder.config.directory == str(isolation / "baz")
def test_absolute_path(self, isolation):
absolute_path = str(isolation)
config = {"tool": {"hatch": {"build": {"targets": {"foo": {"directory": absolute_path}}}}}}
builder = MockBuilder(absolute_path, config=config)
builder.PLUGIN_NAME = "foo"
assert builder.config.directory == absolute_path
class TestSkipExcludedDirs:
def test_default(self, isolation):
builder = MockBuilder(str(isolation))
assert builder.config.skip_excluded_dirs is builder.config.skip_excluded_dirs is False
def test_target(self, isolation):
config = {"tool": {"hatch": {"build": {"targets": {"foo": {"skip-excluded-dirs": True}}}}}}
builder = MockBuilder(str(isolation), config=config)
builder.PLUGIN_NAME = "foo"
assert builder.config.skip_excluded_dirs is True
def test_target_not_boolean(self, isolation):
config = {"tool": {"hatch": {"build": {"targets": {"foo": {"skip-excluded-dirs": 9000}}}}}}
builder = MockBuilder(str(isolation), config=config)
builder.PLUGIN_NAME = "foo"
with pytest.raises(
TypeError, match="Field `tool.hatch.build.targets.foo.skip-excluded-dirs` must be a boolean"
):
_ = builder.config.skip_excluded_dirs
def test_global(self, isolation):
config = {"tool": {"hatch": {"build": {"skip-excluded-dirs": True}}}}
builder = MockBuilder(str(isolation), config=config)
builder.PLUGIN_NAME = "foo"
assert builder.config.skip_excluded_dirs is True
def test_global_not_boolean(self, isolation):
config = {"tool": {"hatch": {"build": {"skip-excluded-dirs": 9000}}}}
builder = MockBuilder(str(isolation), config=config)
builder.PLUGIN_NAME = "foo"
with pytest.raises(TypeError, match="Field `tool.hatch.build.skip-excluded-dirs` must be a boolean"):
_ = builder.config.skip_excluded_dirs
def test_target_overrides_global(self, isolation):
config = {
"tool": {
"hatch": {"build": {"skip-excluded-dirs": True, "targets": {"foo": {"skip-excluded-dirs": False}}}}
}
}
builder = MockBuilder(str(isolation), config=config)
builder.PLUGIN_NAME = "foo"
assert builder.config.skip_excluded_dirs is False
class TestIgnoreVCS:
def test_default(self, isolation):
builder = MockBuilder(str(isolation))
assert builder.config.ignore_vcs is builder.config.ignore_vcs is False
def test_target(self, isolation):
config = {"tool": {"hatch": {"build": {"targets": {"foo": {"ignore-vcs": True}}}}}}
builder = MockBuilder(str(isolation), config=config)
builder.PLUGIN_NAME = "foo"
assert builder.config.ignore_vcs is True
def test_target_not_boolean(self, isolation):
config = {"tool": {"hatch": {"build": {"targets": {"foo": {"ignore-vcs": 9000}}}}}}
builder = MockBuilder(str(isolation), config=config)
builder.PLUGIN_NAME = "foo"
with pytest.raises(TypeError, match="Field `tool.hatch.build.targets.foo.ignore-vcs` must be a boolean"):
_ = builder.config.ignore_vcs
def test_global(self, isolation):
config = {"tool": {"hatch": {"build": {"ignore-vcs": True}}}}
builder = MockBuilder(str(isolation), config=config)
builder.PLUGIN_NAME = "foo"
assert builder.config.ignore_vcs is True
def test_global_not_boolean(self, isolation):
config = {"tool": {"hatch": {"build": {"ignore-vcs": 9000}}}}
builder = MockBuilder(str(isolation), config=config)
builder.PLUGIN_NAME = "foo"
with pytest.raises(TypeError, match="Field `tool.hatch.build.ignore-vcs` must be a boolean"):
_ = builder.config.ignore_vcs
def test_target_overrides_global(self, isolation):
config = {"tool": {"hatch": {"build": {"ignore-vcs": True, "targets": {"foo": {"ignore-vcs": False}}}}}}
builder = MockBuilder(str(isolation), config=config)
builder.PLUGIN_NAME = "foo"
assert builder.config.ignore_vcs is False
class TestRequireRuntimeDependencies:
def test_default(self, isolation):
builder = MockBuilder(str(isolation))
assert builder.config.require_runtime_dependencies is builder.config.require_runtime_dependencies is False
def test_target(self, isolation):
config = {"tool": {"hatch": {"build": {"targets": {"foo": {"require-runtime-dependencies": True}}}}}}
builder = MockBuilder(str(isolation), config=config)
builder.PLUGIN_NAME = "foo"
assert builder.config.require_runtime_dependencies is True
def test_target_not_boolean(self, isolation):
config = {"tool": {"hatch": {"build": {"targets": {"foo": {"require-runtime-dependencies": 9000}}}}}}
builder = MockBuilder(str(isolation), config=config)
builder.PLUGIN_NAME = "foo"
with pytest.raises(
TypeError,
match="Field `tool.hatch.build.targets.foo.require-runtime-dependencies` must be a boolean",
):
_ = builder.config.require_runtime_dependencies
def test_global(self, isolation):
config = {"tool": {"hatch": {"build": {"require-runtime-dependencies": True}}}}
builder = MockBuilder(str(isolation), config=config)
builder.PLUGIN_NAME = "foo"
assert builder.config.require_runtime_dependencies is True
def test_global_not_boolean(self, isolation):
config = {"tool": {"hatch": {"build": {"require-runtime-dependencies": 9000}}}}
builder = MockBuilder(str(isolation), config=config)
builder.PLUGIN_NAME = "foo"
with pytest.raises(TypeError, match="Field `tool.hatch.build.require-runtime-dependencies` must be a boolean"):
_ = builder.config.require_runtime_dependencies
def test_target_overrides_global(self, isolation):
config = {
"tool": {
"hatch": {
"build": {
"require-runtime-dependencies": True,
"targets": {"foo": {"require-runtime-dependencies": False}},
}
}
}
}
builder = MockBuilder(str(isolation), config=config)
builder.PLUGIN_NAME = "foo"
assert builder.config.require_runtime_dependencies is False
class TestRequireRuntimeFeatures:
def test_default(self, isolation):
builder = MockBuilder(str(isolation))
assert builder.config.require_runtime_features == builder.config.require_runtime_features == []
def test_target(self, isolation):
config = {
"project": {"name": "my_app", "version": "0.0.1", "optional-dependencies": {"foo": [], "bar": []}},
"tool": {"hatch": {"build": {"targets": {"foo": {"require-runtime-features": ["foo", "bar"]}}}}},
}
builder = MockBuilder(str(isolation), config=config)
builder.PLUGIN_NAME = "foo"
assert builder.config.require_runtime_features == ["foo", "bar"]
def test_target_not_array(self, isolation):
config = {"tool": {"hatch": {"build": {"targets": {"foo": {"require-runtime-features": 9000}}}}}}
builder = MockBuilder(str(isolation), config=config)
builder.PLUGIN_NAME = "foo"
with pytest.raises(
TypeError, match="Field `tool.hatch.build.targets.foo.require-runtime-features` must be an array"
):
_ = builder.config.require_runtime_features
def test_target_feature_not_string(self, isolation):
config = {"tool": {"hatch": {"build": {"targets": {"foo": {"require-runtime-features": [9000]}}}}}}
builder = MockBuilder(str(isolation), config=config)
builder.PLUGIN_NAME = "foo"
with pytest.raises(
TypeError,
match="Feature #1 of field `tool.hatch.build.targets.foo.require-runtime-features` must be a string",
):
_ = builder.config.require_runtime_features
def test_target_feature_empty_string(self, isolation):
config = {"tool": {"hatch": {"build": {"targets": {"foo": {"require-runtime-features": [""]}}}}}}
builder = MockBuilder(str(isolation), config=config)
builder.PLUGIN_NAME = "foo"
with pytest.raises(
ValueError,
match=(
"Feature #1 of field `tool.hatch.build.targets.foo.require-runtime-features` cannot be an empty string"
),
):
_ = builder.config.require_runtime_features
def test_target_feature_unknown(self, isolation):
config = {
"project": {"name": "my_app", "version": "0.0.1"},
"tool": {"hatch": {"build": {"targets": {"foo": {"require-runtime-features": ["foo_bar"]}}}}},
}
builder = MockBuilder(str(isolation), config=config)
builder.PLUGIN_NAME = "foo"
with pytest.raises(
ValueError,
match=(
"Feature `foo-bar` of field `tool.hatch.build.targets.foo.require-runtime-features` is not defined in "
"field `project.optional-dependencies`"
),
):
_ = builder.config.require_runtime_features
def test_global(self, isolation):
config = {
"project": {"name": "my_app", "version": "0.0.1", "optional-dependencies": {"foo": [], "bar": []}},
"tool": {"hatch": {"build": {"require-runtime-features": ["foo", "bar"]}}},
}
builder = MockBuilder(str(isolation), config=config)
assert builder.config.require_runtime_features == ["foo", "bar"]
def test_global_not_array(self, isolation):
config = {"tool": {"hatch": {"build": {"require-runtime-features": 9000}}}}
builder = MockBuilder(str(isolation), config=config)
with pytest.raises(TypeError, match="Field `tool.hatch.build.require-runtime-features` must be an array"):
_ = builder.config.require_runtime_features
def test_global_feature_not_string(self, isolation):
config = {"tool": {"hatch": {"build": {"require-runtime-features": [9000]}}}}
builder = MockBuilder(str(isolation), config=config)
with pytest.raises(
TypeError, match="Feature #1 of field `tool.hatch.build.require-runtime-features` must be a string"
):
_ = builder.config.require_runtime_features
def test_global_feature_empty_string(self, isolation):
config = {"tool": {"hatch": {"build": {"require-runtime-features": [""]}}}}
builder = MockBuilder(str(isolation), config=config)
with pytest.raises(
ValueError,
match="Feature #1 of field `tool.hatch.build.require-runtime-features` cannot be an empty string",
):
_ = builder.config.require_runtime_features
def test_global_feature_unknown(self, isolation):
config = {
"project": {"name": "my_app", "version": "0.0.1"},
"tool": {"hatch": {"build": {"require-runtime-features": ["foo_bar"]}}},
}
builder = MockBuilder(str(isolation), config=config)
with pytest.raises(
ValueError,
match=(
"Feature `foo-bar` of field `tool.hatch.build.require-runtime-features` is not defined in "
"field `project.optional-dependencies`"
),
):
_ = builder.config.require_runtime_features
def test_target_overrides_global(self, isolation):
config = {
"project": {"name": "my_app", "version": "0.0.1", "optional-dependencies": {"foo_bar": [], "bar_baz": []}},
"tool": {
"hatch": {
"build": {
"require-runtime-features": ["bar_baz"],
"targets": {"foo": {"require-runtime-features": ["foo_bar"]}},
}
}
},
}
builder = MockBuilder(str(isolation), config=config)
builder.PLUGIN_NAME = "foo"
assert builder.config.require_runtime_features == ["foo-bar"]
class TestOnlyPackages:
def test_default(self, isolation):
builder = MockBuilder(str(isolation))
assert builder.config.only_packages is builder.config.only_packages is False
def test_target(self, isolation):
config = {"tool": {"hatch": {"build": {"targets": {"foo": {"only-packages": True}}}}}}
builder = MockBuilder(str(isolation), config=config)
builder.PLUGIN_NAME = "foo"
assert builder.config.only_packages is True
def test_target_not_boolean(self, isolation):
config = {"tool": {"hatch": {"build": {"targets": {"foo": {"only-packages": 9000}}}}}}
builder = MockBuilder(str(isolation), config=config)
builder.PLUGIN_NAME = "foo"
with pytest.raises(TypeError, match="Field `tool.hatch.build.targets.foo.only-packages` must be a boolean"):
_ = builder.config.only_packages
def test_global(self, isolation):
config = {"tool": {"hatch": {"build": {"only-packages": True}}}}
builder = MockBuilder(str(isolation), config=config)
builder.PLUGIN_NAME = "foo"
assert builder.config.only_packages is True
def test_global_not_boolean(self, isolation):
config = {"tool": {"hatch": {"build": {"only-packages": 9000}}}}
builder = MockBuilder(str(isolation), config=config)
builder.PLUGIN_NAME = "foo"
with pytest.raises(TypeError, match="Field `tool.hatch.build.only-packages` must be a boolean"):
_ = builder.config.only_packages
def test_target_overrides_global(self, isolation):
config = {"tool": {"hatch": {"build": {"only-packages": True, "targets": {"foo": {"only-packages": False}}}}}}
builder = MockBuilder(str(isolation), config=config)
builder.PLUGIN_NAME = "foo"
assert builder.config.only_packages is False
class TestReproducible:
def test_default(self, isolation):
builder = MockBuilder(str(isolation))
assert builder.config.reproducible is builder.config.reproducible is True
def test_target(self, isolation):
config = {"tool": {"hatch": {"build": {"targets": {"foo": {"reproducible": False}}}}}}
builder = MockBuilder(str(isolation), config=config)
builder.PLUGIN_NAME = "foo"
assert builder.config.reproducible is False
def test_target_not_boolean(self, isolation):
config = {"tool": {"hatch": {"build": {"targets": {"foo": {"reproducible": 9000}}}}}}
builder = MockBuilder(str(isolation), config=config)
builder.PLUGIN_NAME = "foo"
with pytest.raises(TypeError, match="Field `tool.hatch.build.targets.foo.reproducible` must be a boolean"):
_ = builder.config.reproducible
def test_global(self, isolation):
config = {"tool": {"hatch": {"build": {"reproducible": False}}}}
builder = MockBuilder(str(isolation), config=config)
builder.PLUGIN_NAME = "foo"
assert builder.config.reproducible is False
def test_global_not_boolean(self, isolation):
config = {"tool": {"hatch": {"build": {"reproducible": 9000}}}}
builder = MockBuilder(str(isolation), config=config)
builder.PLUGIN_NAME = "foo"
with pytest.raises(TypeError, match="Field `tool.hatch.build.reproducible` must be a boolean"):
_ = builder.config.reproducible
def test_target_overrides_global(self, isolation):
config = {"tool": {"hatch": {"build": {"reproducible": False, "targets": {"foo": {"reproducible": True}}}}}}
builder = MockBuilder(str(isolation), config=config)
builder.PLUGIN_NAME = "foo"
assert builder.config.reproducible is True
class TestDevModeDirs:
def test_default(self, isolation):
builder = MockBuilder(str(isolation))
assert builder.config.dev_mode_dirs == builder.config.dev_mode_dirs == []
def test_global_invalid_type(self, isolation):
config = {"tool": {"hatch": {"build": {"dev-mode-dirs": ""}}}}
builder = MockBuilder(str(isolation), config=config)
with pytest.raises(TypeError, match="Field `tool.hatch.build.dev-mode-dirs` must be an array of strings"):
_ = builder.config.dev_mode_dirs
def test_global(self, isolation):
config = {"tool": {"hatch": {"build": {"dev-mode-dirs": ["foo", "bar/baz"]}}}}
builder = MockBuilder(str(isolation), config=config)
assert builder.config.dev_mode_dirs == ["foo", "bar/baz"]
def test_global_pattern_not_string(self, isolation):
config = {"tool": {"hatch": {"build": {"dev-mode-dirs": [0]}}}}
builder = MockBuilder(str(isolation), config=config)
with pytest.raises(TypeError, match="Directory #1 in field `tool.hatch.build.dev-mode-dirs` must be a string"):
_ = builder.config.dev_mode_dirs
def test_global_pattern_empty_string(self, isolation):
config = {"tool": {"hatch": {"build": {"dev-mode-dirs": [""]}}}}
builder = MockBuilder(str(isolation), config=config)
with pytest.raises(
ValueError, match="Directory #1 in field `tool.hatch.build.dev-mode-dirs` cannot be an empty string"
):
_ = builder.config.dev_mode_dirs
def test_target(self, isolation):
config = {"tool": {"hatch": {"build": {"targets": {"foo": {"dev-mode-dirs": ["foo", "bar/baz"]}}}}}}
builder = MockBuilder(str(isolation), config=config)
builder.PLUGIN_NAME = "foo"
assert builder.config.dev_mode_dirs == ["foo", "bar/baz"]
def test_target_pattern_not_string(self, isolation):
config = {"tool": {"hatch": {"build": {"targets": {"foo": {"dev-mode-dirs": [0]}}}}}}
builder = MockBuilder(str(isolation), config=config)
builder.PLUGIN_NAME = "foo"
with pytest.raises(
TypeError, match="Directory #1 in field `tool.hatch.build.targets.foo.dev-mode-dirs` must be a string"
):
_ = builder.config.dev_mode_dirs
def test_target_pattern_empty_string(self, isolation):
config = {"tool": {"hatch": {"build": {"targets": {"foo": {"dev-mode-dirs": [""]}}}}}}
builder = MockBuilder(str(isolation), config=config)
builder.PLUGIN_NAME = "foo"
with pytest.raises(
ValueError,
match="Directory #1 in field `tool.hatch.build.targets.foo.dev-mode-dirs` cannot be an empty string",
):
_ = builder.config.dev_mode_dirs
def test_target_overrides_global(self, isolation):
config = {
"tool": {"hatch": {"build": {"dev-mode-dirs": ["foo"], "targets": {"foo": {"dev-mode-dirs": ["bar"]}}}}}
}
builder = MockBuilder(str(isolation), config=config)
builder.PLUGIN_NAME = "foo"
assert builder.config.dev_mode_dirs == ["bar"]
class TestDevModeExact:
def test_default(self, isolation):
builder = MockBuilder(str(isolation))
assert builder.config.dev_mode_exact is builder.config.dev_mode_exact is False
def test_target(self, isolation):
config = {"tool": {"hatch": {"build": {"targets": {"foo": {"dev-mode-exact": True}}}}}}
builder = MockBuilder(str(isolation), config=config)
builder.PLUGIN_NAME = "foo"
assert builder.config.dev_mode_exact is True
def test_target_not_boolean(self, isolation):
config = {"tool": {"hatch": {"build": {"targets": {"foo": {"dev-mode-exact": 9000}}}}}}
builder = MockBuilder(str(isolation), config=config)
builder.PLUGIN_NAME = "foo"
with pytest.raises(TypeError, match="Field `tool.hatch.build.targets.foo.dev-mode-exact` must be a boolean"):
_ = builder.config.dev_mode_exact
def test_global(self, isolation):
config = {"tool": {"hatch": {"build": {"dev-mode-exact": True}}}}
builder = MockBuilder(str(isolation), config=config)
builder.PLUGIN_NAME = "foo"
assert builder.config.dev_mode_exact is True
def test_global_not_boolean(self, isolation):
config = {"tool": {"hatch": {"build": {"dev-mode-exact": 9000}}}}
builder = MockBuilder(str(isolation), config=config)
builder.PLUGIN_NAME = "foo"
with pytest.raises(TypeError, match="Field `tool.hatch.build.dev-mode-exact` must be a boolean"):
_ = builder.config.dev_mode_exact
def test_target_overrides_global(self, isolation):
config = {"tool": {"hatch": {"build": {"dev-mode-exact": True, "targets": {"foo": {"dev-mode-exact": False}}}}}}
builder = MockBuilder(str(isolation), config=config)
builder.PLUGIN_NAME = "foo"
assert builder.config.dev_mode_exact is False
class TestPackages:
def test_default(self, isolation):
builder = MockBuilder(str(isolation))
assert builder.config.packages == builder.config.packages == []
def test_global_invalid_type(self, isolation):
config = {"tool": {"hatch": {"build": {"packages": ""}}}}
builder = MockBuilder(str(isolation), config=config)
with pytest.raises(TypeError, match="Field `tool.hatch.build.packages` must be an array of strings"):
_ = builder.config.packages
def test_global(self, isolation):
config = {"tool": {"hatch": {"build": {"packages": ["src/foo"]}}}}
builder = MockBuilder(str(isolation), config=config)
assert len(builder.config.packages) == 1
assert builder.config.packages[0] == pjoin("src", "foo")
def test_global_package_not_string(self, isolation):
config = {"tool": {"hatch": {"build": {"packages": [0]}}}}
builder = MockBuilder(str(isolation), config=config)
with pytest.raises(TypeError, match="Package #1 in field `tool.hatch.build.packages` must be a string"):
_ = builder.config.packages
def test_global_package_empty_string(self, isolation):
config = {"tool": {"hatch": {"build": {"packages": [""]}}}}
builder = MockBuilder(str(isolation), config=config)
with pytest.raises(
ValueError, match="Package #1 in field `tool.hatch.build.packages` cannot be an empty string"
):
_ = builder.config.packages
def test_target(self, isolation):
config = {"tool": {"hatch": {"build": {"targets": {"foo": {"packages": ["src/foo"]}}}}}}
builder = MockBuilder(str(isolation), config=config)
builder.PLUGIN_NAME = "foo"
assert len(builder.config.packages) == 1
assert builder.config.packages[0] == pjoin("src", "foo")
def test_target_package_not_string(self, isolation):
config = {"tool": {"hatch": {"build": {"targets": {"foo": {"packages": [0]}}}}}}
builder = MockBuilder(str(isolation), config=config)
builder.PLUGIN_NAME = "foo"
with pytest.raises(
TypeError, match="Package #1 in field `tool.hatch.build.targets.foo.packages` must be a string"
):
_ = builder.config.packages
def test_target_package_empty_string(self, isolation):
config = {"tool": {"hatch": {"build": {"targets": {"foo": {"packages": [""]}}}}}}
builder = MockBuilder(str(isolation), config=config)
builder.PLUGIN_NAME = "foo"
with pytest.raises(
ValueError, match="Package #1 in field `tool.hatch.build.targets.foo.packages` cannot be an empty string"
):
_ = builder.config.packages
def test_target_overrides_global(self, isolation):
config = {
"tool": {"hatch": {"build": {"packages": ["src/foo"], "targets": {"foo": {"packages": ["pkg/foo"]}}}}}
}
builder = MockBuilder(str(isolation), config=config)
builder.PLUGIN_NAME = "foo"
assert len(builder.config.packages) == 1
assert builder.config.packages[0] == pjoin("pkg", "foo")
def test_no_source(self, isolation):
config = {"tool": {"hatch": {"build": {"packages": ["foo"]}}}}
builder = MockBuilder(str(isolation), config=config)
assert len(builder.config.packages) == 1
assert builder.config.packages[0] == pjoin("foo")
class TestSources:
def test_default(self, isolation):
builder = MockBuilder(str(isolation))
assert builder.config.sources == builder.config.sources == {}
assert builder.config.get_distribution_path(pjoin("src", "foo", "bar.py")) == pjoin("src", "foo", "bar.py")
def test_global_invalid_type(self, isolation):
config = {"tool": {"hatch": {"build": {"sources": ""}}}}
builder = MockBuilder(str(isolation), config=config)
with pytest.raises(TypeError, match="Field `tool.hatch.build.sources` must be a mapping or array of strings"):
_ = builder.config.sources
def test_global_array(self, isolation):
config = {"tool": {"hatch": {"build": {"sources": ["src"]}}}}
builder = MockBuilder(str(isolation), config=config)
assert len(builder.config.sources) == 1
assert builder.config.sources[pjoin("src", "")] == ""
assert builder.config.get_distribution_path(pjoin("src", "foo", "bar.py")) == pjoin("foo", "bar.py")
def test_global_array_source_not_string(self, isolation):
config = {"tool": {"hatch": {"build": {"sources": [0]}}}}
builder = MockBuilder(str(isolation), config=config)
with pytest.raises(TypeError, match="Source #1 in field `tool.hatch.build.sources` must be a string"):
_ = builder.config.sources
def test_global_array_source_empty_string(self, isolation):
config = {"tool": {"hatch": {"build": {"sources": [""]}}}}
builder = MockBuilder(str(isolation), config=config)
with pytest.raises(ValueError, match="Source #1 in field `tool.hatch.build.sources` cannot be an empty string"):
_ = builder.config.sources
def test_global_mapping(self, isolation):
config = {"tool": {"hatch": {"build": {"sources": {"src/foo": "renamed"}}}}}
builder = MockBuilder(str(isolation), config=config)
assert len(builder.config.sources) == 1
assert builder.config.sources[pjoin("src", "foo", "")] == pjoin("renamed", "")
assert builder.config.get_distribution_path(pjoin("src", "foo", "bar.py")) == pjoin("renamed", "bar.py")
def test_global_mapping_source_empty_string(self, isolation):
config = {"tool": {"hatch": {"build": {"sources": {"": "renamed"}}}}}
builder = MockBuilder(str(isolation), config=config)
assert len(builder.config.sources) == 1
assert builder.config.sources[""] == pjoin("renamed", "")
assert builder.config.get_distribution_path("bar.py") == pjoin("renamed", "bar.py")
assert builder.config.get_distribution_path(pjoin("foo", "bar.py")) == pjoin("renamed", "foo", "bar.py")
def test_global_mapping_path_empty_string(self, isolation):
config = {"tool": {"hatch": {"build": {"sources": {"src/foo": ""}}}}}
builder = MockBuilder(str(isolation), config=config)
assert len(builder.config.sources) == 1
assert builder.config.sources[pjoin("src", "foo", "")] == ""
assert builder.config.get_distribution_path(pjoin("src", "foo", "bar.py")) == "bar.py"
def test_global_mapping_replacement_not_string(self, isolation):
config = {"tool": {"hatch": {"build": {"sources": {"src/foo": 0}}}}}
builder = MockBuilder(str(isolation), config=config)
with pytest.raises(
TypeError, match="Path for source `src/foo` in field `tool.hatch.build.sources` must be a string"
):
_ = builder.config.sources
def test_target_invalid_type(self, isolation):
config = {"tool": {"hatch": {"build": {"targets": {"foo": {"sources": ""}}}}}}
builder = MockBuilder(str(isolation), config=config)
builder.PLUGIN_NAME = "foo"
with pytest.raises(
TypeError, match="Field `tool.hatch.build.targets.foo.sources` must be a mapping or array of strings"
):
_ = builder.config.sources
def test_target_array(self, isolation):
config = {"tool": {"hatch": {"build": {"targets": {"foo": {"sources": ["src"]}}}}}}
builder = MockBuilder(str(isolation), config=config)
builder.PLUGIN_NAME = "foo"
assert len(builder.config.sources) == 1
assert builder.config.sources[pjoin("src", "")] == ""
assert builder.config.get_distribution_path(pjoin("src", "foo", "bar.py")) == pjoin("foo", "bar.py")
def test_target_array_source_not_string(self, isolation):
config = {"tool": {"hatch": {"build": {"targets": {"foo": {"sources": [0]}}}}}}
builder = MockBuilder(str(isolation), config=config)
builder.PLUGIN_NAME = "foo"
with pytest.raises(
TypeError, match="Source #1 in field `tool.hatch.build.targets.foo.sources` must be a string"
):
_ = builder.config.sources
def test_target_array_source_empty_string(self, isolation):
config = {"tool": {"hatch": {"build": {"targets": {"foo": {"sources": [""]}}}}}}
builder = MockBuilder(str(isolation), config=config)
builder.PLUGIN_NAME = "foo"
with pytest.raises(
ValueError, match="Source #1 in field `tool.hatch.build.targets.foo.sources` cannot be an empty string"
):
_ = builder.config.sources
def test_target_mapping(self, isolation):
config = {"tool": {"hatch": {"build": {"targets": {"foo": {"sources": {"src/foo": "renamed"}}}}}}}
builder = MockBuilder(str(isolation), config=config)
builder.PLUGIN_NAME = "foo"
assert len(builder.config.sources) == 1
assert builder.config.sources[pjoin("src", "foo", "")] == pjoin("renamed", "")
assert builder.config.get_distribution_path(pjoin("src", "foo", "bar.py")) == pjoin("renamed", "bar.py")
def test_target_mapping_source_empty_string(self, isolation):
config = {"tool": {"hatch": {"build": {"targets": {"foo": {"sources": {"": "renamed"}}}}}}}
builder = MockBuilder(str(isolation), config=config)
builder.PLUGIN_NAME = "foo"
assert len(builder.config.sources) == 1
assert builder.config.sources[""] == pjoin("renamed", "")
assert builder.config.get_distribution_path(pjoin("bar.py")) == pjoin("renamed", "bar.py")
def test_target_mapping_path_empty_string(self, isolation):
config = {"tool": {"hatch": {"build": {"targets": {"foo": {"sources": {"src/foo": ""}}}}}}}
builder = MockBuilder(str(isolation), config=config)
builder.PLUGIN_NAME = "foo"
assert len(builder.config.sources) == 1
assert builder.config.sources[pjoin("src", "foo", "")] == ""
assert builder.config.get_distribution_path(pjoin("src", "foo", "bar.py")) == "bar.py"
def test_target_mapping_replacement_not_string(self, isolation):
config = {"tool": {"hatch": {"build": {"targets": {"foo": {"sources": {"src/foo": 0}}}}}}}
builder = MockBuilder(str(isolation), config=config)
builder.PLUGIN_NAME = "foo"
with pytest.raises(
TypeError,
match="Path for source `src/foo` in field `tool.hatch.build.targets.foo.sources` must be a string",
):
_ = builder.config.sources
def test_target_overrides_global(self, isolation):
config = {"tool": {"hatch": {"build": {"sources": ["src"], "targets": {"foo": {"sources": ["pkg"]}}}}}}
builder = MockBuilder(str(isolation), config=config)
builder.PLUGIN_NAME = "foo"
assert len(builder.config.sources) == 1
assert builder.config.sources[pjoin("pkg", "")] == ""
assert builder.config.get_distribution_path(pjoin("pkg", "foo", "bar.py")) == pjoin("foo", "bar.py")
assert builder.config.get_distribution_path(pjoin("src", "foo", "bar.py")) == pjoin("src", "foo", "bar.py")
def test_no_source(self, isolation):
config = {"tool": {"hatch": {"build": {"sources": ["bar"]}}}}
builder = MockBuilder(str(isolation), config=config)
assert len(builder.config.sources) == 1
assert builder.config.sources[pjoin("bar", "")] == ""
assert builder.config.get_distribution_path(pjoin("foo", "bar.py")) == pjoin("foo", "bar.py")
def test_compatible_with_packages(self, isolation):
config = {"tool": {"hatch": {"build": {"sources": {"src/foo": "renamed"}, "packages": ["src/foo"]}}}}
builder = MockBuilder(str(isolation), config=config)
assert len(builder.config.sources) == 1
assert builder.config.sources[pjoin("src", "foo", "")] == pjoin("renamed", "")
assert builder.config.get_distribution_path(pjoin("src", "foo", "bar.py")) == pjoin("renamed", "bar.py")
class TestForceInclude:
def test_default(self, isolation):
builder = MockBuilder(str(isolation))
assert builder.config.force_include == builder.config.force_include == {}
def test_global_invalid_type(self, isolation):
config = {"tool": {"hatch": {"build": {"force-include": ""}}}}
builder = MockBuilder(str(isolation), config=config)
with pytest.raises(TypeError, match="Field `tool.hatch.build.force-include` must be a mapping"):
_ = builder.config.force_include
def test_global_absolute(self, isolation):
config = {"tool": {"hatch": {"build": {"force-include": {str(isolation / "source"): "/target/"}}}}}
builder = MockBuilder(str(isolation), config=config)
assert builder.config.force_include == {str(isolation / "source"): "target"}
def test_global_relative(self, isolation):
config = {"tool": {"hatch": {"build": {"force-include": {"../source": "/target/"}}}}}
builder = MockBuilder(str(isolation / "foo"), config=config)
assert builder.config.force_include == {str(isolation / "source"): "target"}
def test_global_source_empty_string(self, isolation):
config = {"tool": {"hatch": {"build": {"force-include": {"": "/target/"}}}}}
builder = MockBuilder(str(isolation), config=config)
with pytest.raises(
ValueError, match="Source #1 in field `tool.hatch.build.force-include` cannot be an empty string"
):
_ = builder.config.force_include
def test_global_relative_path_not_string(self, isolation):
config = {"tool": {"hatch": {"build": {"force-include": {"source": 0}}}}}
builder = MockBuilder(str(isolation), config=config)
with pytest.raises(
TypeError, match="Path for source `source` in field `tool.hatch.build.force-include` must be a string"
):
_ = builder.config.force_include
def test_global_relative_path_empty_string(self, isolation):
config = {"tool": {"hatch": {"build": {"force-include": {"source": ""}}}}}
builder = MockBuilder(str(isolation), config=config)
with pytest.raises(
ValueError,
match="Path for source `source` in field `tool.hatch.build.force-include` cannot be an empty string",
):
_ = builder.config.force_include
def test_target_invalid_type(self, isolation):
config = {"tool": {"hatch": {"build": {"targets": {"foo": {"force-include": ""}}}}}}
builder = MockBuilder(str(isolation), config=config)
builder.PLUGIN_NAME = "foo"
with pytest.raises(TypeError, match="Field `tool.hatch.build.targets.foo.force-include` must be a mapping"):
_ = builder.config.force_include
def test_target_absolute(self, isolation):
config = {
"tool": {
"hatch": {"build": {"targets": {"foo": {"force-include": {str(isolation / "source"): "/target/"}}}}}
}
}
builder = MockBuilder(str(isolation), config=config)
builder.PLUGIN_NAME = "foo"
assert builder.config.force_include == {str(isolation / "source"): "target"}
def test_target_relative(self, isolation):
config = {"tool": {"hatch": {"build": {"targets": {"foo": {"force-include": {"../source": "/target/"}}}}}}}
builder = MockBuilder(str(isolation / "foo"), config=config)
builder.PLUGIN_NAME = "foo"
assert builder.config.force_include == {str(isolation / "source"): "target"}
def test_target_source_empty_string(self, isolation):
config = {"tool": {"hatch": {"build": {"targets": {"foo": {"force-include": {"": "/target/"}}}}}}}
builder = MockBuilder(str(isolation), config=config)
builder.PLUGIN_NAME = "foo"
with pytest.raises(
ValueError,
match="Source #1 in field `tool.hatch.build.targets.foo.force-include` cannot be an empty string",
):
_ = builder.config.force_include
def test_target_relative_path_not_string(self, isolation):
config = {"tool": {"hatch": {"build": {"targets": {"foo": {"force-include": {"source": 0}}}}}}}
builder = MockBuilder(str(isolation), config=config)
builder.PLUGIN_NAME = "foo"
with pytest.raises(
TypeError,
match="Path for source `source` in field `tool.hatch.build.targets.foo.force-include` must be a string",
):
_ = builder.config.force_include
def test_target_relative_path_empty_string(self, isolation):
config = {"tool": {"hatch": {"build": {"targets": {"foo": {"force-include": {"source": ""}}}}}}}
builder = MockBuilder(str(isolation), config=config)
builder.PLUGIN_NAME = "foo"
with pytest.raises(
ValueError,
match=(
"Path for source `source` in field `tool.hatch.build.targets.foo.force-include` "
"cannot be an empty string"
),
):
_ = builder.config.force_include
def test_order(self, isolation):
config = {
"tool": {
"hatch": {
"build": {
"force-include": {
"../very-nested": "target1/embedded",
"../source1": "/target2/",
"../source2": "/target1/",
}
}
}
}
}
builder = MockBuilder(str(isolation / "foo"), config=config)
assert builder.config.force_include == {
str(isolation / "source2"): "target1",
str(isolation / "very-nested"): f"target1{os.sep}embedded",
str(isolation / "source1"): "target2",
}
class TestOnlyInclude:
def test_default(self, isolation):
builder = MockBuilder(str(isolation))
assert builder.config.only_include == builder.config.only_include == {}
def test_global_invalid_type(self, isolation):
config = {"tool": {"hatch": {"build": {"only-include": 9000}}}}
builder = MockBuilder(str(isolation), config=config)
with pytest.raises(TypeError, match="Field `tool.hatch.build.only-include` must be an array"):
_ = builder.config.only_include
def test_global_path_not_string(self, isolation):
config = {"tool": {"hatch": {"build": {"only-include": [9000]}}}}
builder = MockBuilder(str(isolation), config=config)
with pytest.raises(TypeError, match="Path #1 in field `tool.hatch.build.only-include` must be a string"):
_ = builder.config.only_include
@pytest.mark.parametrize("path", ["/", "~/foo", "../foo"])
def test_global_not_relative(self, isolation, path):
config = {"tool": {"hatch": {"build": {"only-include": [path]}}}}
builder = MockBuilder(str(isolation), config=config)
with pytest.raises(
ValueError, match=f"Path #1 in field `tool.hatch.build.only-include` must be relative: {path}"
):
_ = builder.config.only_include
def test_global_duplicate(self, isolation):
config = {"tool": {"hatch": {"build": {"only-include": ["/foo//bar", "foo//bar/"]}}}}
builder = MockBuilder(str(isolation), config=config)
with pytest.raises(
ValueError, match=re.escape(f"Duplicate path in field `tool.hatch.build.only-include`: foo{os.sep}bar")
):
_ = builder.config.only_include
def test_global_correct(self, isolation):
config = {"tool": {"hatch": {"build": {"only-include": ["/foo//bar/"]}}}}
builder = MockBuilder(str(isolation), config=config)
assert builder.config.only_include == {f"{isolation}{os.sep}foo{os.sep}bar": f"foo{os.sep}bar"}
def test_target_invalid_type(self, isolation):
config = {"tool": {"hatch": {"build": {"targets": {"foo": {"only-include": 9000}}}}}}
builder = MockBuilder(str(isolation), config=config)
builder.PLUGIN_NAME = "foo"
with pytest.raises(TypeError, match="Field `tool.hatch.build.targets.foo.only-include` must be an array"):
_ = builder.config.only_include
def test_target_path_not_string(self, isolation):
config = {"tool": {"hatch": {"build": {"targets": {"foo": {"only-include": [9000]}}}}}}
builder = MockBuilder(str(isolation), config=config)
builder.PLUGIN_NAME = "foo"
with pytest.raises(
TypeError, match="Path #1 in field `tool.hatch.build.targets.foo.only-include` must be a string"
):
_ = builder.config.only_include
@pytest.mark.parametrize("path", ["/", "~/foo", "../foo"])
def test_target_not_relative(self, isolation, path):
config = {"tool": {"hatch": {"build": {"targets": {"foo": {"only-include": [path]}}}}}}
builder = MockBuilder(str(isolation), config=config)
builder.PLUGIN_NAME = "foo"
with pytest.raises(
ValueError, match=f"Path #1 in field `tool.hatch.build.targets.foo.only-include` must be relative: {path}"
):
_ = builder.config.only_include
def test_target_duplicate(self, isolation):
config = {"tool": {"hatch": {"build": {"targets": {"foo": {"only-include": ["/foo//bar", "foo//bar/"]}}}}}}
builder = MockBuilder(str(isolation), config=config)
builder.PLUGIN_NAME = "foo"
with pytest.raises(
ValueError,
match=re.escape(f"Duplicate path in field `tool.hatch.build.targets.foo.only-include`: foo{os.sep}bar"),
):
_ = builder.config.only_include
def test_target_correct(self, isolation):
config = {"tool": {"hatch": {"build": {"targets": {"foo": {"only-include": ["/foo//bar/"]}}}}}}
builder = MockBuilder(str(isolation), config=config)
builder.PLUGIN_NAME = "foo"
assert builder.config.only_include == {f"{isolation}{os.sep}foo{os.sep}bar": f"foo{os.sep}bar"}
class TestVersions:
def test_default_known(self, isolation):
builder = MockBuilder(str(isolation))
builder.PLUGIN_NAME = "foo"
builder.get_version_api = lambda: {"2": str, "1": str}
assert builder.config.versions == builder.config.versions == ["2", "1"]
def test_default_override(self, isolation):
builder = MockBuilder(str(isolation))
builder.PLUGIN_NAME = "foo"
builder.get_default_versions = lambda: ["old", "new", "new"]
assert builder.config.versions == builder.config.versions == ["old", "new"]
def test_invalid_type(self, isolation):
config = {"tool": {"hatch": {"build": {"targets": {"foo": {"versions": ""}}}}}}
builder = MockBuilder(str(isolation), config=config)
builder.PLUGIN_NAME = "foo"
with pytest.raises(
TypeError, match="Field `tool.hatch.build.targets.foo.versions` must be an array of strings"
):
_ = builder.config.versions
def test_correct(self, isolation):
config = {"tool": {"hatch": {"build": {"targets": {"foo": {"versions": ["3.14", "1", "3.14"]}}}}}}
builder = MockBuilder(str(isolation), config=config)
builder.PLUGIN_NAME = "foo"
builder.get_version_api = lambda: {"3.14": str, "42": str, "1": str}
assert builder.config.versions == builder.config.versions == ["3.14", "1"]
def test_empty_default(self, isolation):
config = {"tool": {"hatch": {"build": {"targets": {"foo": {"versions": []}}}}}}
builder = MockBuilder(str(isolation), config=config)
builder.PLUGIN_NAME = "foo"
builder.get_default_versions = lambda: ["old", "new"]
assert builder.config.versions == builder.config.versions == ["old", "new"]
def test_version_not_string(self, isolation):
config = {"tool": {"hatch": {"build": {"targets": {"foo": {"versions": [1]}}}}}}
builder = MockBuilder(str(isolation), config=config)
builder.PLUGIN_NAME = "foo"
with pytest.raises(
TypeError, match="Version #1 in field `tool.hatch.build.targets.foo.versions` must be a string"
):
_ = builder.config.versions
def test_version_empty_string(self, isolation):
config = {"tool": {"hatch": {"build": {"targets": {"foo": {"versions": [""]}}}}}}
builder = MockBuilder(str(isolation), config=config)
builder.PLUGIN_NAME = "foo"
with pytest.raises(
ValueError, match="Version #1 in field `tool.hatch.build.targets.foo.versions` cannot be an empty string"
):
_ = builder.config.versions
def test_unknown_version(self, isolation):
config = {"tool": {"hatch": {"build": {"targets": {"foo": {"versions": ["9000", "1", "42"]}}}}}}
builder = MockBuilder(str(isolation), config=config)
builder.PLUGIN_NAME = "foo"
builder.get_version_api = lambda: {"1": str}
with pytest.raises(
ValueError, match="Unknown versions in field `tool.hatch.build.targets.foo.versions`: 42, 9000"
):
_ = builder.config.versions
class TestHookConfig:
def test_default(self, isolation):
builder = MockBuilder(str(isolation))
assert builder.config.hook_config == builder.config.hook_config == {}
def test_target_not_table(self, isolation):
config = {"tool": {"hatch": {"build": {"targets": {"foo": {"hooks": "bar"}}}}}}
builder = MockBuilder(str(isolation), config=config)
builder.PLUGIN_NAME = "foo"
with pytest.raises(TypeError, match="Field `tool.hatch.build.targets.foo.hooks` must be a table"):
_ = builder.config.hook_config
def test_target_hook_not_table(self, isolation):
config = {"tool": {"hatch": {"build": {"targets": {"foo": {"hooks": {"bar": "baz"}}}}}}}
builder = MockBuilder(str(isolation), config=config)
builder.PLUGIN_NAME = "foo"
with pytest.raises(TypeError, match="Field `tool.hatch.build.targets.foo.hooks.bar` must be a table"):
_ = builder.config.hook_config
def test_global_not_table(self, isolation):
config = {"tool": {"hatch": {"build": {"hooks": "foo"}}}}
builder = MockBuilder(str(isolation), config=config)
with pytest.raises(TypeError, match="Field `tool.hatch.build.hooks` must be a table"):
_ = builder.config.hook_config
def test_global_hook_not_table(self, isolation):
config = {"tool": {"hatch": {"build": {"hooks": {"foo": "bar"}}}}}
builder = MockBuilder(str(isolation), config=config)
with pytest.raises(TypeError, match="Field `tool.hatch.build.hooks.foo` must be a table"):
_ = builder.config.hook_config
def test_global(self, isolation):
config = {"tool": {"hatch": {"build": {"hooks": {"foo": {"bar": "baz"}}}}}}
builder = MockBuilder(str(isolation), config=config)
assert builder.config.hook_config["foo"]["bar"] == "baz"
def test_order(self, isolation):
config = {
"tool": {
"hatch": {
"build": {"hooks": {"foo": {"bar": "baz"}}, "targets": {"foo": {"hooks": {"baz": {"foo": "bar"}}}}}
}
},
}
builder = MockBuilder(str(isolation), config=config)
builder.PLUGIN_NAME = "foo"
assert builder.config.hook_config == {"foo": {"bar": "baz"}, "baz": {"foo": "bar"}}
def test_target_overrides_global(self, isolation):
config = {
"tool": {
"hatch": {
"build": {"hooks": {"foo": {"bar": "baz"}}, "targets": {"foo": {"hooks": {"foo": {"baz": "bar"}}}}}
}
},
}
builder = MockBuilder(str(isolation), config=config)
builder.PLUGIN_NAME = "foo"
assert builder.config.hook_config["foo"]["baz"] == "bar"
def test_env_var_no_hooks(self, isolation):
config = {"tool": {"hatch": {"build": {"hooks": {"foo": {"bar": "baz"}}}}}}
builder = MockBuilder(str(isolation), config=config)
with EnvVars({BuildEnvVars.NO_HOOKS: "true"}):
assert builder.config.hook_config == {}
def test_enable_by_default(self, isolation):
config = {
"tool": {
"hatch": {
"build": {
"hooks": {
"foo": {"bar": "baz", "enable-by-default": False},
"bar": {"foo": "baz", "enable-by-default": False},
"baz": {"foo": "bar"},
}
}
}
}
}
builder = MockBuilder(str(isolation), config=config)
assert builder.config.hook_config == {"baz": {"foo": "bar"}}
def test_env_var_all_override_enable_by_default(self, isolation):
config = {
"tool": {
"hatch": {
"build": {
"hooks": {
"foo": {"bar": "baz", "enable-by-default": False},
"bar": {"foo": "baz", "enable-by-default": False},
"baz": {"foo": "bar"},
}
}
}
}
}
builder = MockBuilder(str(isolation), config=config)
with EnvVars({BuildEnvVars.HOOKS_ENABLE: "true"}):
assert builder.config.hook_config == {
"foo": {"bar": "baz", "enable-by-default": False},
"bar": {"foo": "baz", "enable-by-default": False},
"baz": {"foo": "bar"},
}
def test_env_var_specific_override_enable_by_default(self, isolation):
config = {
"tool": {
"hatch": {
"build": {
"hooks": {
"foo": {"bar": "baz", "enable-by-default": False},
"bar": {"foo": "baz", "enable-by-default": False},
"baz": {"foo": "bar"},
}
}
}
}
}
builder = MockBuilder(str(isolation), config=config)
with EnvVars({f"{BuildEnvVars.HOOK_ENABLE_PREFIX}FOO": "true"}):
assert builder.config.hook_config == {
"foo": {"bar": "baz", "enable-by-default": False},
"baz": {"foo": "bar"},
}
class TestDependencies:
def test_default(self, isolation):
builder = MockBuilder(str(isolation))
assert builder.config.dependencies == builder.config.dependencies == []
def test_target_not_array(self, isolation):
config = {"tool": {"hatch": {"build": {"targets": {"foo": {"dependencies": 9000}}}}}}
builder = MockBuilder(str(isolation), config=config)
builder.PLUGIN_NAME = "foo"
with pytest.raises(TypeError, match="Field `tool.hatch.build.targets.foo.dependencies` must be an array"):
_ = builder.config.dependencies
def test_target_dependency_not_string(self, isolation):
config = {"tool": {"hatch": {"build": {"targets": {"foo": {"dependencies": [9000]}}}}}}
builder = MockBuilder(str(isolation), config=config)
builder.PLUGIN_NAME = "foo"
with pytest.raises(
TypeError, match="Dependency #1 of field `tool.hatch.build.targets.foo.dependencies` must be a string"
):
_ = builder.config.dependencies
def test_global_not_array(self, isolation):
config = {"tool": {"hatch": {"build": {"dependencies": 9000}}}}
builder = MockBuilder(str(isolation), config=config)
builder.PLUGIN_NAME = "foo"
with pytest.raises(TypeError, match="Field `tool.hatch.build.dependencies` must be an array"):
_ = builder.config.dependencies
def test_global_dependency_not_string(self, isolation):
config = {"tool": {"hatch": {"build": {"dependencies": [9000]}}}}
builder = MockBuilder(str(isolation), config=config)
builder.PLUGIN_NAME = "foo"
with pytest.raises(TypeError, match="Dependency #1 of field `tool.hatch.build.dependencies` must be a string"):
_ = builder.config.dependencies
def test_hook_require_runtime_dependencies_not_boolean(self, isolation):
config = {"tool": {"hatch": {"build": {"hooks": {"foo": {"require-runtime-dependencies": 9000}}}}}}
builder = MockBuilder(str(isolation), config=config)
builder.PLUGIN_NAME = "foo"
with pytest.raises(
TypeError, match="Option `require-runtime-dependencies` of build hook `foo` must be a boolean"
):
_ = builder.config.dependencies
def test_hook_require_runtime_features_not_array(self, isolation):
config = {"tool": {"hatch": {"build": {"hooks": {"foo": {"require-runtime-features": 9000}}}}}}
builder = MockBuilder(str(isolation), config=config)
builder.PLUGIN_NAME = "foo"
with pytest.raises(TypeError, match="Option `require-runtime-features` of build hook `foo` must be an array"):
_ = builder.config.dependencies
def test_hook_require_runtime_features_feature_not_string(self, isolation):
config = {"tool": {"hatch": {"build": {"hooks": {"foo": {"require-runtime-features": [9000]}}}}}}
builder = MockBuilder(str(isolation), config=config)
builder.PLUGIN_NAME = "foo"
with pytest.raises(
TypeError, match="Feature #1 of option `require-runtime-features` of build hook `foo` must be a string"
):
_ = builder.config.dependencies
def test_hook_require_runtime_features_feature_empty_string(self, isolation):
config = {"tool": {"hatch": {"build": {"hooks": {"foo": {"require-runtime-features": [""]}}}}}}
builder = MockBuilder(str(isolation), config=config)
builder.PLUGIN_NAME = "foo"
with pytest.raises(
ValueError,
match="Feature #1 of option `require-runtime-features` of build hook `foo` cannot be an empty string",
):
_ = builder.config.dependencies
def test_hook_require_runtime_features_feature_unknown(self, isolation):
config = {
"project": {"name": "my_app", "version": "0.0.1"},
"tool": {"hatch": {"build": {"hooks": {"foo": {"require-runtime-features": ["foo_bar"]}}}}},
}
builder = MockBuilder(str(isolation), config=config)
builder.PLUGIN_NAME = "foo"
with pytest.raises(
ValueError,
match=(
"Feature `foo-bar` of option `require-runtime-features` of build hook `foo` is not defined in "
"field `project.optional-dependencies`"
),
):
_ = builder.config.dependencies
def test_hook_dependencies_not_array(self, isolation):
config = {"tool": {"hatch": {"build": {"hooks": {"foo": {"dependencies": 9000}}}}}}
builder = MockBuilder(str(isolation), config=config)
builder.PLUGIN_NAME = "foo"
with pytest.raises(TypeError, match="Option `dependencies` of build hook `foo` must be an array"):
_ = builder.config.dependencies
def test_hook_dependency_not_string(self, isolation):
config = {"tool": {"hatch": {"build": {"hooks": {"foo": {"dependencies": [9000]}}}}}}
builder = MockBuilder(str(isolation), config=config)
builder.PLUGIN_NAME = "foo"
with pytest.raises(
TypeError, match="Dependency #1 of option `dependencies` of build hook `foo` must be a string"
):
_ = builder.config.dependencies
def test_correct(self, isolation):
config = {
"tool": {
"hatch": {
"build": {
"dependencies": ["bar"],
"hooks": {"foobar": {"dependencies": ["test1"]}},
"targets": {"foo": {"dependencies": ["baz"], "hooks": {"foobar": {"dependencies": ["test2"]}}}},
}
}
}
}
builder = MockBuilder(str(isolation), config=config)
builder.PLUGIN_NAME = "foo"
assert builder.config.dependencies == ["baz", "bar", "test2"]
def test_require_runtime_dependencies(self, isolation):
config = {
"project": {"name": "my-app", "version": "0.0.1", "dependencies": ["foo"]},
"tool": {
"hatch": {
"build": {
"require-runtime-dependencies": True,
"dependencies": ["bar"],
"hooks": {"foobar": {"dependencies": ["test1"]}},
"targets": {"foo": {"dependencies": ["baz"], "hooks": {"foobar": {"dependencies": ["test2"]}}}},
}
}
},
}
builder = MockBuilder(str(isolation), config=config)
builder.PLUGIN_NAME = "foo"
assert builder.config.dependencies == ["baz", "bar", "test2", "foo"]
def test_require_runtime_features(self, isolation):
config = {
"project": {"name": "my-app", "version": "0.0.1", "optional-dependencies": {"bar_baz": ["foo"]}},
"tool": {
"hatch": {
"build": {
"require-runtime-features": ["bar-baz"],
"dependencies": ["bar"],
"hooks": {"foobar": {"dependencies": ["test1"]}},
"targets": {"foo": {"dependencies": ["baz"], "hooks": {"foobar": {"dependencies": ["test2"]}}}},
}
}
},
}
builder = MockBuilder(str(isolation), config=config)
builder.PLUGIN_NAME = "foo"
assert builder.config.dependencies == ["baz", "bar", "test2", "foo"]
def test_env_var_no_hooks(self, isolation):
config = {
"tool": {
"hatch": {
"build": {
"hooks": {
"foo": {"dependencies": ["foo"]},
"bar": {"dependencies": ["bar"]},
"baz": {"dependencies": ["baz"]},
},
}
}
}
}
builder = MockBuilder(str(isolation), config=config)
builder.PLUGIN_NAME = "foo"
with EnvVars({BuildEnvVars.NO_HOOKS: "true"}):
assert builder.config.dependencies == []
def test_hooks_enable_by_default(self, isolation):
config = {
"tool": {
"hatch": {
"build": {
"hooks": {
"foo": {"dependencies": ["foo"], "enable-by-default": False},
"bar": {"dependencies": ["bar"], "enable-by-default": False},
"baz": {"dependencies": ["baz"]},
},
}
}
}
}
builder = MockBuilder(str(isolation), config=config)
builder.PLUGIN_NAME = "foo"
assert builder.config.dependencies == ["baz"]
def test_hooks_env_var_all_override_enable_by_default(self, isolation):
config = {
"tool": {
"hatch": {
"build": {
"hooks": {
"foo": {"dependencies": ["foo"], "enable-by-default": False},
"bar": {"dependencies": ["bar"], "enable-by-default": False},
"baz": {"dependencies": ["baz"]},
},
}
}
}
}
builder = MockBuilder(str(isolation), config=config)
builder.PLUGIN_NAME = "foo"
with EnvVars({BuildEnvVars.HOOKS_ENABLE: "true"}):
assert builder.config.dependencies == ["foo", "bar", "baz"]
def test_hooks_env_var_specific_override_enable_by_default(self, isolation):
config = {
"tool": {
"hatch": {
"build": {
"hooks": {
"foo": {"dependencies": ["foo"], "enable-by-default": False},
"bar": {"dependencies": ["bar"], "enable-by-default": False},
"baz": {"dependencies": ["baz"]},
},
}
}
}
}
builder = MockBuilder(str(isolation), config=config)
builder.PLUGIN_NAME = "foo"
with EnvVars({f"{BuildEnvVars.HOOK_ENABLE_PREFIX}FOO": "true"}):
assert builder.config.dependencies == ["foo", "baz"]
def test_hooks_require_runtime_dependencies(self, isolation):
config = {
"project": {"name": "my-app", "version": "0.0.1", "dependencies": ["baz"]},
"tool": {
"hatch": {
"build": {
"hooks": {
"foo": {"dependencies": ["foo"], "enable-by-default": False},
"bar": {"dependencies": ["bar"], "require-runtime-dependencies": True},
},
}
}
},
}
builder = MockBuilder(str(isolation), config=config)
builder.PLUGIN_NAME = "foo"
assert builder.config.dependencies == ["bar", "baz"]
def test_hooks_require_runtime_dependencies_disabled(self, isolation):
config = {
"project": {"name": "my-app", "version": "0.0.1", "dependencies": ["baz"]},
"tool": {
"hatch": {
"build": {
"hooks": {
"foo": {
"dependencies": ["foo"],
"enable-by-default": False,
"require-runtime-dependencies": True,
},
"bar": {"dependencies": ["bar"]},
},
}
}
},
}
builder = MockBuilder(str(isolation), config=config)
builder.PLUGIN_NAME = "foo"
assert builder.config.dependencies == ["bar"]
def test_hooks_require_runtime_features(self, isolation):
config = {
"project": {"name": "my-app", "version": "0.0.1", "optional-dependencies": {"foo_bar": ["baz"]}},
"tool": {
"hatch": {
"build": {
"hooks": {
"foo": {"dependencies": ["foo"], "enable-by-default": False},
"bar": {"dependencies": ["bar"], "require-runtime-features": ["foo-bar"]},
},
}
}
},
}
builder = MockBuilder(str(isolation), config=config)
builder.PLUGIN_NAME = "foo"
assert builder.config.dependencies == ["bar", "baz"]
def test_hooks_require_runtime_features_disabled(self, isolation):
config = {
"project": {"name": "my-app", "version": "0.0.1", "optional-dependencies": {"foo_bar": ["baz"]}},
"tool": {
"hatch": {
"build": {
"hooks": {
"foo": {
"dependencies": ["foo"],
"enable-by-default": False,
"require-runtime-features": ["foo-bar"],
},
"bar": {"dependencies": ["bar"]},
},
}
}
},
}
builder = MockBuilder(str(isolation), config=config)
builder.PLUGIN_NAME = "foo"
assert builder.config.dependencies == ["bar"]
class TestFileSelectionDefaults:
def test_include(self, isolation):
builder = MockBuilder(str(isolation))
assert builder.config.default_include() == []
def test_exclude(self, isolation):
builder = MockBuilder(str(isolation))
assert builder.config.default_exclude() == []
def test_packages(self, isolation):
builder = MockBuilder(str(isolation))
assert builder.config.default_packages() == []
def test_only_include(self, isolation):
builder = MockBuilder(str(isolation))
assert builder.config.default_only_include() == []
def test_global_exclude(self, isolation):
builder = MockBuilder(str(isolation))
assert builder.config.default_global_exclude() == ["*.py[cdo]", "/dist"]
class TestPatternInclude:
def test_default(self, isolation):
builder = MockBuilder(str(isolation))
assert builder.config.include_spec is None
def test_global_becomes_spec(self, isolation):
config = {"tool": {"hatch": {"build": {"include": ["foo"]}}}}
builder = MockBuilder(str(isolation), config=config)
assert isinstance(builder.config.include_spec, pathspec.GitIgnoreSpec)
def test_global_invalid_type(self, isolation):
config = {"tool": {"hatch": {"build": {"include": ""}}}}
builder = MockBuilder(str(isolation), config=config)
with pytest.raises(TypeError, match="Field `tool.hatch.build.include` must be an array of strings"):
_ = builder.config.include_spec
@pytest.mark.parametrize("separator", ["/", "\\"])
def test_global(self, isolation, separator, platform):
if separator == "\\" and not platform.windows:
pytest.skip("Not running on Windows")
config = {"tool": {"hatch": {"build": {"include": ["foo", "bar/baz"]}}}}
builder = MockBuilder(str(isolation), config=config)
assert builder.config.include_spec.match_file(f"foo{separator}file.py")
assert builder.config.include_spec.match_file(f"bar{separator}baz{separator}file.py")
assert not builder.config.include_spec.match_file(f"bar{separator}file.py")
def test_global_pattern_not_string(self, isolation):
config = {"tool": {"hatch": {"build": {"include": [0]}}}}
builder = MockBuilder(str(isolation), config=config)
with pytest.raises(TypeError, match="Pattern #1 in field `tool.hatch.build.include` must be a string"):
_ = builder.config.include_spec
def test_global_pattern_empty_string(self, isolation):
config = {"tool": {"hatch": {"build": {"include": [""]}}}}
builder = MockBuilder(str(isolation), config=config)
with pytest.raises(
ValueError, match="Pattern #1 in field `tool.hatch.build.include` cannot be an empty string"
):
_ = builder.config.include_spec
@pytest.mark.parametrize("separator", ["/", "\\"])
def test_global_packages_included(self, isolation, separator, platform):
if separator == "\\" and not platform.windows:
pytest.skip("Not running on Windows")
config = {"tool": {"hatch": {"build": {"packages": ["bar"], "include": ["foo"]}}}}
builder = MockBuilder(str(isolation), config=config)
assert builder.config.include_spec.match_file(f"foo{separator}file.py")
assert builder.config.include_spec.match_file(f"bar{separator}baz{separator}file.py")
assert not builder.config.include_spec.match_file(f"baz{separator}bar{separator}file.py")
@pytest.mark.parametrize("separator", ["/", "\\"])
def test_target(self, isolation, separator, platform):
if separator == "\\" and not platform.windows:
pytest.skip("Not running on Windows")
config = {"tool": {"hatch": {"build": {"targets": {"foo": {"include": ["foo", "bar/baz"]}}}}}}
builder = MockBuilder(str(isolation), config=config)
builder.PLUGIN_NAME = "foo"
assert builder.config.include_spec.match_file(f"foo{separator}file.py")
assert builder.config.include_spec.match_file(f"bar{separator}baz{separator}file.py")
assert not builder.config.include_spec.match_file(f"bar{separator}file.py")
def test_target_pattern_not_string(self, isolation):
config = {"tool": {"hatch": {"build": {"targets": {"foo": {"include": [0]}}}}}}
builder = MockBuilder(str(isolation), config=config)
builder.PLUGIN_NAME = "foo"
with pytest.raises(
TypeError, match="Pattern #1 in field `tool.hatch.build.targets.foo.include` must be a string"
):
_ = builder.config.include_spec
def test_target_pattern_empty_string(self, isolation):
config = {"tool": {"hatch": {"build": {"targets": {"foo": {"include": [""]}}}}}}
builder = MockBuilder(str(isolation), config=config)
builder.PLUGIN_NAME = "foo"
with pytest.raises(
ValueError, match="Pattern #1 in field `tool.hatch.build.targets.foo.include` cannot be an empty string"
):
_ = builder.config.include_spec
@pytest.mark.parametrize("separator", ["/", "\\"])
def test_target_overrides_global(self, isolation, separator, platform):
if separator == "\\" and not platform.windows:
pytest.skip("Not running on Windows")
config = {"tool": {"hatch": {"build": {"include": ["foo"], "targets": {"foo": {"include": ["bar"]}}}}}}
builder = MockBuilder(str(isolation), config=config)
builder.PLUGIN_NAME = "foo"
assert not builder.config.include_spec.match_file(f"foo{separator}file.py")
assert builder.config.include_spec.match_file(f"bar{separator}file.py")
@pytest.mark.parametrize("separator", ["/", "\\"])
def test_target_packages_included(self, isolation, separator, platform):
if separator == "\\" and not platform.windows:
pytest.skip("Not running on Windows")
config = {"tool": {"hatch": {"build": {"targets": {"foo": {"packages": ["bar"], "include": ["foo"]}}}}}}
builder = MockBuilder(str(isolation), config=config)
builder.PLUGIN_NAME = "foo"
assert builder.config.include_spec.match_file(f"foo{separator}file.py")
assert builder.config.include_spec.match_file(f"bar{separator}baz{separator}file.py")
assert not builder.config.include_spec.match_file(f"baz{separator}bar{separator}file.py")
class TestPatternExclude:
@pytest.mark.parametrize("separator", ["/", "\\"])
def test_default(self, isolation, separator, platform):
if separator == "\\" and not platform.windows:
pytest.skip("Not running on Windows")
builder = MockBuilder(str(isolation))
assert isinstance(builder.config.exclude_spec, pathspec.GitIgnoreSpec)
assert builder.config.exclude_spec.match_file(f"dist{separator}file.py")
def test_global_invalid_type(self, isolation):
config = {"tool": {"hatch": {"build": {"exclude": ""}}}}
builder = MockBuilder(str(isolation), config=config)
with pytest.raises(TypeError, match="Field `tool.hatch.build.exclude` must be an array of strings"):
_ = builder.config.exclude_spec
@pytest.mark.parametrize("separator", ["/", "\\"])
def test_global(self, isolation, separator, platform):
if separator == "\\" and not platform.windows:
pytest.skip("Not running on Windows")
config = {"tool": {"hatch": {"build": {"exclude": ["foo", "bar/baz"]}}}}
builder = MockBuilder(str(isolation), config=config)
assert builder.config.exclude_spec.match_file(f"foo{separator}file.py")
assert builder.config.exclude_spec.match_file(f"bar{separator}baz{separator}file.py")
assert not builder.config.exclude_spec.match_file(f"bar{separator}file.py")
def test_global_pattern_not_string(self, isolation):
config = {"tool": {"hatch": {"build": {"exclude": [0]}}}}
builder = MockBuilder(str(isolation), config=config)
with pytest.raises(TypeError, match="Pattern #1 in field `tool.hatch.build.exclude` must be a string"):
_ = builder.config.exclude_spec
def test_global_pattern_empty_string(self, isolation):
config = {"tool": {"hatch": {"build": {"exclude": [""]}}}}
builder = MockBuilder(str(isolation), config=config)
with pytest.raises(
ValueError, match="Pattern #1 in field `tool.hatch.build.exclude` cannot be an empty string"
):
_ = builder.config.exclude_spec
@pytest.mark.parametrize("separator", ["/", "\\"])
def test_target(self, isolation, separator, platform):
if separator == "\\" and not platform.windows:
pytest.skip("Not running on Windows")
config = {"tool": {"hatch": {"build": {"targets": {"foo": {"exclude": ["foo", "bar/baz"]}}}}}}
builder = MockBuilder(str(isolation), config=config)
builder.PLUGIN_NAME = "foo"
assert builder.config.exclude_spec.match_file(f"foo{separator}file.py")
assert builder.config.exclude_spec.match_file(f"bar{separator}baz{separator}file.py")
assert not builder.config.exclude_spec.match_file(f"bar{separator}file.py")
def test_target_pattern_not_string(self, isolation):
config = {"tool": {"hatch": {"build": {"targets": {"foo": {"exclude": [0]}}}}}}
builder = MockBuilder(str(isolation), config=config)
builder.PLUGIN_NAME = "foo"
with pytest.raises(
TypeError, match="Pattern #1 in field `tool.hatch.build.targets.foo.exclude` must be a string"
):
_ = builder.config.exclude_spec
def test_target_pattern_empty_string(self, isolation):
config = {"tool": {"hatch": {"build": {"targets": {"foo": {"exclude": [""]}}}}}}
builder = MockBuilder(str(isolation), config=config)
builder.PLUGIN_NAME = "foo"
with pytest.raises(
ValueError, match="Pattern #1 in field `tool.hatch.build.targets.foo.exclude` cannot be an empty string"
):
_ = builder.config.exclude_spec
@pytest.mark.parametrize("separator", ["/", "\\"])
def test_target_overrides_global(self, isolation, separator, platform):
if separator == "\\" and not platform.windows:
pytest.skip("Not running on Windows")
config = {"tool": {"hatch": {"build": {"exclude": ["foo"], "targets": {"foo": {"exclude": ["bar"]}}}}}}
builder = MockBuilder(str(isolation), config=config)
builder.PLUGIN_NAME = "foo"
assert not builder.config.exclude_spec.match_file(f"foo{separator}file.py")
assert builder.config.exclude_spec.match_file(f"bar{separator}file.py")
@pytest.mark.parametrize("separator", ["/", "\\"])
def test_vcs_git(self, temp_dir, separator, platform):
if separator == "\\" and not platform.windows:
pytest.skip("Not running on Windows")
with temp_dir.as_cwd():
config = {"tool": {"hatch": {"build": {"exclude": ["foo"]}}}}
builder = MockBuilder(str(temp_dir), config=config)
vcs_ignore_file = temp_dir / ".gitignore"
vcs_ignore_file.write_text("/bar\n*.pyc")
assert builder.config.exclude_spec.match_file(f"foo{separator}file.py")
assert builder.config.exclude_spec.match_file(f"bar{separator}file.py")
assert builder.config.exclude_spec.match_file(f"baz{separator}bar{separator}file.pyc")
assert not builder.config.exclude_spec.match_file(f"baz{separator}bar{separator}file.py")
@pytest.mark.parametrize("separator", ["/", "\\"])
def test_ignore_vcs_git(self, temp_dir, separator, platform):
if separator == "\\" and not platform.windows:
pytest.skip("Not running on Windows")
with temp_dir.as_cwd():
config = {"tool": {"hatch": {"build": {"ignore-vcs": True, "exclude": ["foo"]}}}}
builder = MockBuilder(str(temp_dir), config=config)
vcs_ignore_file = temp_dir / ".gitignore"
vcs_ignore_file.write_text("/bar\n*.pyc")
assert builder.config.exclude_spec.match_file(f"foo{separator}file.py")
assert not builder.config.exclude_spec.match_file(f"bar{separator}file.py")
@pytest.mark.parametrize("separator", ["/", "\\"])
def test_vcs_git_boundary(self, temp_dir, separator, platform):
if separator == "\\" and not platform.windows:
pytest.skip("Not running on Windows")
project_dir = temp_dir / "project"
project_dir.mkdir()
(project_dir / ".git").mkdir()
with project_dir.as_cwd():
config = {"tool": {"hatch": {"build": {"exclude": ["foo"]}}}}
builder = MockBuilder(str(project_dir), config=config)
vcs_ignore_file = temp_dir / ".gitignore"
vcs_ignore_file.write_text("/bar\n*.pyc")
assert builder.config.exclude_spec.match_file(f"foo{separator}file.py")
assert not builder.config.exclude_spec.match_file(f"bar{separator}file.py")
@pytest.mark.parametrize("separator", ["/", "\\"])
def test_vcs_git_exclude_whitelisted_file(self, temp_dir, separator, platform):
if separator == "\\" and not platform.windows:
pytest.skip("Not running on Windows")
with temp_dir.as_cwd():
config = {"tool": {"hatch": {"build": {"exclude": ["foo/bar"]}}}}
builder = MockBuilder(str(temp_dir), config=config)
vcs_ignore_file = temp_dir / ".gitignore"
vcs_ignore_file.write_text("foo/*\n!foo/bar")
assert builder.config.path_is_excluded(f"foo{separator}deb") is True
assert builder.config.path_is_excluded(f"foo{separator}bar") is True
@pytest.mark.parametrize("separator", ["/", "\\"])
def test_vcs_mercurial(self, temp_dir, separator, platform):
if separator == "\\" and not platform.windows:
pytest.skip("Not running on Windows")
with temp_dir.as_cwd():
config = {"tool": {"hatch": {"build": {"exclude": ["foo"]}}}}
builder = MockBuilder(str(temp_dir), config=config)
vcs_ignore_file = temp_dir / ".hgignore"
vcs_ignore_file.write_text("syntax: glob\n/bar\n*.pyc")
assert builder.config.exclude_spec.match_file(f"foo{separator}file.py")
assert builder.config.exclude_spec.match_file(f"bar{separator}file.py")
assert builder.config.exclude_spec.match_file(f"baz{separator}bar{separator}file.pyc")
assert not builder.config.exclude_spec.match_file(f"baz{separator}bar{separator}file.py")
@pytest.mark.parametrize("separator", ["/", "\\"])
def test_ignore_vcs_mercurial(self, temp_dir, separator, platform):
if separator == "\\" and not platform.windows:
pytest.skip("Not running on Windows")
with temp_dir.as_cwd():
config = {"tool": {"hatch": {"build": {"ignore-vcs": True, "exclude": ["foo"]}}}}
builder = MockBuilder(str(temp_dir), config=config)
vcs_ignore_file = temp_dir / ".hgignore"
vcs_ignore_file.write_text("syntax: glob\n/bar\n*.pyc")
assert builder.config.exclude_spec.match_file(f"foo{separator}file.py")
assert not builder.config.exclude_spec.match_file(f"bar{separator}file.py")
@pytest.mark.parametrize("separator", ["/", "\\"])
def test_vcs_mercurial_boundary(self, temp_dir, separator, platform):
if separator == "\\" and not platform.windows:
pytest.skip("Not running on Windows")
project_dir = temp_dir / "project"
project_dir.mkdir()
(project_dir / ".hg").mkdir()
with project_dir.as_cwd():
config = {"tool": {"hatch": {"build": {"exclude": ["foo"]}}}}
builder = MockBuilder(str(project_dir), config=config)
vcs_ignore_file = temp_dir / ".hgignore"
vcs_ignore_file.write_text("syntax: glob\n/bar\n*.pyc")
assert builder.config.exclude_spec.match_file(f"foo{separator}file.py")
assert not builder.config.exclude_spec.match_file(f"bar{separator}file.py")
def test_override_default_global_exclude_patterns(self, isolation):
builder = MockBuilder(str(isolation))
builder.config.default_global_exclude = list
assert builder.config.exclude_spec is None
assert not builder.config.path_is_excluded(".git/file")
class TestPatternArtifacts:
def test_default(self, isolation):
builder = MockBuilder(str(isolation))
assert builder.config.artifact_spec is None
def test_global_becomes_spec(self, isolation):
config = {"tool": {"hatch": {"build": {"artifacts": ["foo"]}}}}
builder = MockBuilder(str(isolation), config=config)
assert isinstance(builder.config.artifact_spec, pathspec.GitIgnoreSpec)
def test_global_invalid_type(self, isolation):
config = {"tool": {"hatch": {"build": {"artifacts": ""}}}}
builder = MockBuilder(str(isolation), config=config)
with pytest.raises(TypeError, match="Field `tool.hatch.build.artifacts` must be an array of strings"):
_ = builder.config.artifact_spec
@pytest.mark.parametrize("separator", ["/", "\\"])
def test_global(self, isolation, separator, platform):
if separator == "\\" and not platform.windows:
pytest.skip("Not running on Windows")
config = {"tool": {"hatch": {"build": {"artifacts": ["foo", "bar/baz"]}}}}
builder = MockBuilder(str(isolation), config=config)
assert builder.config.artifact_spec.match_file(f"foo{separator}file.py")
assert builder.config.artifact_spec.match_file(f"bar{separator}baz{separator}file.py")
assert not builder.config.artifact_spec.match_file(f"bar{separator}file.py")
def test_global_pattern_not_string(self, isolation):
config = {"tool": {"hatch": {"build": {"artifacts": [0]}}}}
builder = MockBuilder(str(isolation), config=config)
with pytest.raises(TypeError, match="Pattern #1 in field `tool.hatch.build.artifacts` must be a string"):
_ = builder.config.artifact_spec
def test_global_pattern_empty_string(self, isolation):
config = {"tool": {"hatch": {"build": {"artifacts": [""]}}}}
builder = MockBuilder(str(isolation), config=config)
with pytest.raises(
ValueError, match="Pattern #1 in field `tool.hatch.build.artifacts` cannot be an empty string"
):
_ = builder.config.artifact_spec
@pytest.mark.parametrize("separator", ["/", "\\"])
def test_target(self, isolation, separator, platform):
if separator == "\\" and not platform.windows:
pytest.skip("Not running on Windows")
config = {"tool": {"hatch": {"build": {"targets": {"foo": {"artifacts": ["foo", "bar/baz"]}}}}}}
builder = MockBuilder(str(isolation), config=config)
builder.PLUGIN_NAME = "foo"
assert builder.config.artifact_spec.match_file(f"foo{separator}file.py")
assert builder.config.artifact_spec.match_file(f"bar{separator}baz{separator}file.py")
assert not builder.config.artifact_spec.match_file(f"bar{separator}file.py")
def test_target_pattern_not_string(self, isolation):
config = {"tool": {"hatch": {"build": {"targets": {"foo": {"artifacts": [0]}}}}}}
builder = MockBuilder(str(isolation), config=config)
builder.PLUGIN_NAME = "foo"
with pytest.raises(
TypeError, match="Pattern #1 in field `tool.hatch.build.targets.foo.artifacts` must be a string"
):
_ = builder.config.artifact_spec
def test_target_pattern_empty_string(self, isolation):
config = {"tool": {"hatch": {"build": {"targets": {"foo": {"artifacts": [""]}}}}}}
builder = MockBuilder(str(isolation), config=config)
builder.PLUGIN_NAME = "foo"
with pytest.raises(
ValueError, match="Pattern #1 in field `tool.hatch.build.targets.foo.artifacts` cannot be an empty string"
):
_ = builder.config.artifact_spec
@pytest.mark.parametrize("separator", ["/", "\\"])
def test_target_overrides_global(self, isolation, separator, platform):
if separator == "\\" and not platform.windows:
pytest.skip("Not running on Windows")
config = {"tool": {"hatch": {"build": {"artifacts": ["foo"], "targets": {"foo": {"artifacts": ["bar"]}}}}}}
builder = MockBuilder(str(isolation), config=config)
builder.PLUGIN_NAME = "foo"
assert not builder.config.artifact_spec.match_file(f"foo{separator}file.py")
assert builder.config.artifact_spec.match_file(f"bar{separator}file.py")
class TestPatternMatching:
def test_include_explicit(self, isolation):
config = {"tool": {"hatch": {"build": {"include": ["foo"]}}}}
builder = MockBuilder(str(isolation), config=config)
assert builder.config.include_path("foo/file.py")
assert not builder.config.include_path("bar/file.py")
def test_no_include_greedy(self, isolation):
builder = MockBuilder(str(isolation))
assert builder.config.include_path("foo/file.py")
assert builder.config.include_path("bar/file.py")
def test_exclude_precedence(self, isolation):
config = {"tool": {"hatch": {"build": {"include": ["foo"], "exclude": ["foo"]}}}}
builder = MockBuilder(str(isolation), config=config)
assert not builder.config.include_path("foo/file.py")
assert not builder.config.include_path("bar/file.py")
def test_artifact_super_precedence(self, isolation):
config = {"tool": {"hatch": {"build": {"include": ["foo"], "exclude": ["foo"], "artifacts": ["foo"]}}}}
builder = MockBuilder(str(isolation), config=config)
assert builder.config.include_path("foo/file.py")
assert not builder.config.include_path("bar/file.py")
================================================
FILE: tests/backend/builders/test_custom.py
================================================
import re
import zipfile
import pytest
from hatchling.builders.custom import CustomBuilder
from hatchling.utils.constants import DEFAULT_BUILD_SCRIPT
def test_target_config_not_table(isolation):
config = {"tool": {"hatch": {"build": {"targets": {"custom": 9000}}}}}
with pytest.raises(TypeError, match="Field `tool.hatch.build.targets.custom` must be a table"):
CustomBuilder(str(isolation), config=config)
def test_no_path(isolation):
config = {
"tool": {
"hatch": {
"build": {"targets": {"custom": {"path": ""}}},
},
},
}
with pytest.raises(ValueError, match="Option `path` for builder `custom` must not be empty if defined"):
CustomBuilder(str(isolation), config=config)
def test_path_not_string(isolation):
config = {"tool": {"hatch": {"build": {"targets": {"custom": {"path": 3}}}}}}
with pytest.raises(TypeError, match="Option `path` for builder `custom` must be a string"):
CustomBuilder(str(isolation), config=config)
def test_nonexistent(isolation):
config = {"tool": {"hatch": {"build": {"targets": {"custom": {"path": "test.py"}}}}}}
with pytest.raises(OSError, match="Build script does not exist: test.py"):
CustomBuilder(str(isolation), config=config)
def test_default(hatch, helpers, temp_dir, config_file):
config_file.model.template.plugins["default"]["src-layout"] = False
config_file.save()
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"
config = {
"project": {"name": project_name, "dynamic": ["version"]},
"tool": {
"hatch": {
"version": {"path": "my_app/__about__.py"},
"build": {"targets": {"custom": {}}},
},
},
}
file_path = project_path / DEFAULT_BUILD_SCRIPT
file_path.write_text(
helpers.dedent(
"""
import os
from hatchling.builders.wheel import WheelBuilder
def get_builder():
return CustomWheelBuilder
class CustomWheelBuilder(WheelBuilder):
def build(self, **kwargs):
for i, artifact in enumerate(super().build(**kwargs)):
build_dir = os.path.dirname(artifact)
new_path = os.path.join(build_dir, f'{self.PLUGIN_NAME}-{i}.whl')
os.replace(artifact, new_path)
yield new_path
"""
)
)
builder = CustomBuilder(str(project_path), config=config)
build_path = project_path / "dist"
with project_path.as_cwd():
artifacts = list(builder.build())
assert len(artifacts) == 1
expected_artifact = artifacts[0]
build_artifacts = list(build_path.iterdir())
assert len(build_artifacts) == 1
assert expected_artifact == str(build_artifacts[0])
assert expected_artifact == str(build_path / "custom-0.whl")
extraction_directory = temp_dir / "_archive"
extraction_directory.mkdir()
with zipfile.ZipFile(str(expected_artifact), "r") as zip_archive:
zip_archive.extractall(str(extraction_directory))
metadata_directory = f"{builder.project_id}.dist-info"
expected_files = helpers.get_template_files(
"wheel.standard_default_license_single", project_name, metadata_directory=metadata_directory
)
helpers.assert_files(extraction_directory, expected_files)
# Inspect the archive rather than the extracted files because on Windows they lose their metadata
# https://stackoverflow.com/q/9813243
with zipfile.ZipFile(str(expected_artifact), "r") as zip_archive:
zip_info = zip_archive.getinfo(f"{metadata_directory}/WHEEL")
assert zip_info.date_time == (2020, 2, 2, 0, 0, 0)
def test_explicit_path(hatch, helpers, temp_dir, config_file):
config_file.model.template.plugins["default"]["src-layout"] = False
config_file.save()
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"
config = {
"project": {"name": project_name, "dynamic": ["version"]},
"tool": {
"hatch": {
"version": {"path": "my_app/__about__.py"},
"build": {"targets": {"custom": {"path": f"foo/{DEFAULT_BUILD_SCRIPT}"}}},
},
},
}
file_path = project_path / "foo" / DEFAULT_BUILD_SCRIPT
file_path.ensure_parent_dir_exists()
file_path.write_text(
helpers.dedent(
"""
import os
from hatchling.builders.wheel import WheelBuilder
def get_builder():
return CustomWheelBuilder
class CustomWheelBuilder(WheelBuilder):
def build(self, **kwargs):
for i, artifact in enumerate(super().build(**kwargs)):
build_dir = os.path.dirname(artifact)
new_path = os.path.join(build_dir, f'{self.PLUGIN_NAME}-{i}.whl')
os.replace(artifact, new_path)
yield new_path
"""
)
)
builder = CustomBuilder(str(project_path), config=config)
build_path = project_path / "dist"
with project_path.as_cwd():
artifacts = list(builder.build())
assert len(artifacts) == 1
expected_artifact = artifacts[0]
build_artifacts = list(build_path.iterdir())
assert len(build_artifacts) == 1
assert expected_artifact == str(build_artifacts[0])
assert expected_artifact == str(build_path / "custom-0.whl")
extraction_directory = temp_dir / "_archive"
extraction_directory.mkdir()
with zipfile.ZipFile(str(expected_artifact), "r") as zip_archive:
zip_archive.extractall(str(extraction_directory))
metadata_directory = f"{builder.project_id}.dist-info"
expected_files = helpers.get_template_files(
"wheel.standard_default_license_single", project_name, metadata_directory=metadata_directory
)
helpers.assert_files(extraction_directory, expected_files)
# Inspect the archive rather than the extracted files because on Windows they lose their metadata
# https://stackoverflow.com/q/9813243
with zipfile.ZipFile(str(expected_artifact), "r") as zip_archive:
zip_info = zip_archive.getinfo(f"{metadata_directory}/WHEEL")
assert zip_info.date_time == (2020, 2, 2, 0, 0, 0)
def test_no_subclass(hatch, helpers, temp_dir):
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"
config = {
"project": {"name": project_name, "dynamic": ["version"]},
"tool": {
"hatch": {
"version": {"path": "my_app/__about__.py"},
"build": {"targets": {"custom": {"path": f"foo/{DEFAULT_BUILD_SCRIPT}"}}},
},
},
}
file_path = project_path / "foo" / DEFAULT_BUILD_SCRIPT
file_path.ensure_parent_dir_exists()
file_path.write_text(
helpers.dedent(
"""
from hatchling.builders.plugin.interface import BuilderInterface
foo = None
bar = 'baz'
class CustomBuilder:
pass
"""
)
)
with (
pytest.raises(
ValueError,
match=re.escape(
f"Unable to find a subclass of `BuilderInterface` in `foo/{DEFAULT_BUILD_SCRIPT}`: {temp_dir}"
),
),
project_path.as_cwd(),
):
CustomBuilder(str(project_path), config=config)
def test_multiple_subclasses(hatch, helpers, temp_dir):
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"
config = {
"project": {"name": project_name, "dynamic": ["version"]},
"tool": {
"hatch": {
"version": {"path": "my_app/__about__.py"},
"build": {"targets": {"custom": {"path": f"foo/{DEFAULT_BUILD_SCRIPT}"}}},
},
},
}
file_path = project_path / "foo" / DEFAULT_BUILD_SCRIPT
file_path.ensure_parent_dir_exists()
file_path.write_text(
helpers.dedent(
"""
import os
from hatchling.builders.wheel import WheelBuilder
class CustomWheelBuilder(WheelBuilder):
pass
"""
)
)
with (
pytest.raises(
ValueError,
match=re.escape(
f"Multiple subclasses of `BuilderInterface` found in `foo/{DEFAULT_BUILD_SCRIPT}`, select "
f"one by defining a function named `get_builder`: {temp_dir}"
),
),
project_path.as_cwd(),
):
CustomBuilder(str(project_path), config=config)
def test_dynamic_dependencies(hatch, helpers, temp_dir, config_file):
config_file.model.template.plugins["default"]["src-layout"] = False
config_file.save()
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"
config = {
"project": {"name": project_name, "dynamic": ["version"]},
"tool": {
"hatch": {
"version": {"path": "my_app/__about__.py"},
"build": {
"targets": {"custom": {"dependencies": ["foo"], "hooks": {"custom": {"dependencies": ["bar"]}}}}
},
},
},
}
file_path = project_path / DEFAULT_BUILD_SCRIPT
file_path.write_text(
helpers.dedent(
"""
from hatchling.builders.hooks.plugin.interface import BuildHookInterface
from hatchling.builders.wheel import WheelBuilder
def get_builder():
return CustomWheelBuilder
class CustomWheelBuilder(WheelBuilder):
pass
class CustomHook(BuildHookInterface):
def dependencies(self):
return ['baz']
"""
)
)
builder = CustomBuilder(str(project_path), config=config)
assert builder.config.dependencies == ["foo", "bar", "baz"]
================================================
FILE: tests/backend/builders/test_sdist.py
================================================
import os
import tarfile
import pytest
from hatchling.builders.plugin.interface import BuilderInterface
from hatchling.builders.sdist import SdistBuilder
from hatchling.builders.utils import get_reproducible_timestamp
from hatchling.metadata.spec import DEFAULT_METADATA_VERSION, get_core_metadata_constructors
from hatchling.utils.constants import DEFAULT_BUILD_SCRIPT, DEFAULT_CONFIG_FILE
def test_class():
assert issubclass(SdistBuilder, BuilderInterface)
def test_default_versions(isolation):
builder = SdistBuilder(str(isolation))
assert builder.get_default_versions() == ["standard"]
class TestSupportLegacy:
def test_default(self, isolation):
builder = SdistBuilder(str(isolation))
assert builder.config.support_legacy is builder.config.support_legacy is False
def test_target(self, isolation):
config = {"tool": {"hatch": {"build": {"targets": {"sdist": {"support-legacy": True}}}}}}
builder = SdistBuilder(str(isolation), config=config)
assert builder.config.support_legacy is builder.config.support_legacy is True
class TestCoreMetadataConstructor:
def test_default(self, isolation):
builder = SdistBuilder(str(isolation))
assert builder.config.core_metadata_constructor is builder.config.core_metadata_constructor
assert builder.config.core_metadata_constructor is get_core_metadata_constructors()[DEFAULT_METADATA_VERSION]
def test_not_string(self, isolation):
config = {"tool": {"hatch": {"build": {"targets": {"sdist": {"core-metadata-version": 42}}}}}}
builder = SdistBuilder(str(isolation), config=config)
with pytest.raises(
TypeError, match="Field `tool.hatch.build.targets.sdist.core-metadata-version` must be a string"
):
_ = builder.config.core_metadata_constructor
def test_unknown(self, isolation):
config = {"tool": {"hatch": {"build": {"targets": {"sdist": {"core-metadata-version": "9000"}}}}}}
builder = SdistBuilder(str(isolation), config=config)
with pytest.raises(
ValueError,
match=(
f"Unknown metadata version `9000` for field `tool.hatch.build.targets.sdist.core-metadata-version`. "
f"Available: {', '.join(sorted(get_core_metadata_constructors()))}"
),
):
_ = builder.config.core_metadata_constructor
class TestStrictNaming:
def test_default(self, isolation):
builder = SdistBuilder(str(isolation))
assert builder.config.strict_naming is builder.config.strict_naming is True
def test_target(self, isolation):
config = {"tool": {"hatch": {"build": {"targets": {"sdist": {"strict-naming": False}}}}}}
builder = SdistBuilder(str(isolation), config=config)
assert builder.config.strict_naming is False
def test_target_not_boolean(self, isolation):
config = {"tool": {"hatch": {"build": {"targets": {"sdist": {"strict-naming": 9000}}}}}}
builder = SdistBuilder(str(isolation), config=config)
with pytest.raises(TypeError, match="Field `tool.hatch.build.targets.sdist.strict-naming` must be a boolean"):
_ = builder.config.strict_naming
def test_global(self, isolation):
config = {"tool": {"hatch": {"build": {"strict-naming": False}}}}
builder = SdistBuilder(str(isolation), config=config)
assert builder.config.strict_naming is False
def test_global_not_boolean(self, isolation):
config = {"tool": {"hatch": {"build": {"strict-naming": 9000}}}}
builder = SdistBuilder(str(isolation), config=config)
with pytest.raises(TypeError, match="Field `tool.hatch.build.strict-naming` must be a boolean"):
_ = builder.config.strict_naming
def test_target_overrides_global(self, isolation):
config = {"tool": {"hatch": {"build": {"strict-naming": False, "targets": {"sdist": {"strict-naming": True}}}}}}
builder = SdistBuilder(str(isolation), config=config)
assert builder.config.strict_naming is True
class TestConstructSetupPyFile:
def test_default(self, helpers, isolation):
config = {"project": {"name": "My.App", "version": "0.1.0"}}
builder = SdistBuilder(str(isolation), config=config)
assert builder.construct_setup_py_file([]) == helpers.dedent(
"""
from setuptools import setup
setup(
name='my-app',
version='0.1.0',
)
"""
)
def test_packages(self, helpers, isolation):
config = {"project": {"name": "My.App", "version": "0.1.0"}}
builder = SdistBuilder(str(isolation), config=config)
assert builder.construct_setup_py_file(["my_app", os.path.join("my_app", "pkg")]) == helpers.dedent(
"""
from setuptools import setup
setup(
name='my-app',
version='0.1.0',
packages=[
'my_app',
'my_app.pkg',
],
)
"""
)
def test_description(self, helpers, isolation):
config = {"project": {"name": "My.App", "version": "0.1.0", "description": "foo"}}
builder = SdistBuilder(str(isolation), config=config)
assert builder.construct_setup_py_file(["my_app", os.path.join("my_app", "pkg")]) == helpers.dedent(
"""
from setuptools import setup
setup(
name='my-app',
version='0.1.0',
description='foo',
packages=[
'my_app',
'my_app.pkg',
],
)
"""
)
def test_readme(self, helpers, isolation):
config = {
"project": {
"name": "My.App",
"version": "0.1.0",
"readme": {"content-type": "text/markdown", "text": "test content\n"},
}
}
builder = SdistBuilder(str(isolation), config=config)
assert builder.construct_setup_py_file(["my_app", os.path.join("my_app", "pkg")]) == helpers.dedent(
"""
from setuptools import setup
setup(
name='my-app',
version='0.1.0',
long_description='test content\\n',
packages=[
'my_app',
'my_app.pkg',
],
)
"""
)
def test_authors_name(self, helpers, isolation):
config = {"project": {"name": "My.App", "version": "0.1.0", "authors": [{"name": "foo"}]}}
builder = SdistBuilder(str(isolation), config=config)
assert builder.construct_setup_py_file(["my_app", os.path.join("my_app", "pkg")]) == helpers.dedent(
"""
from setuptools import setup
setup(
name='my-app',
version='0.1.0',
author='foo',
packages=[
'my_app',
'my_app.pkg',
],
)
"""
)
def test_authors_email(self, helpers, isolation):
config = {"project": {"name": "My.App", "version": "0.1.0", "authors": [{"email": "foo@domain"}]}}
builder = SdistBuilder(str(isolation), config=config)
assert builder.construct_setup_py_file(["my_app", os.path.join("my_app", "pkg")]) == helpers.dedent(
"""
from setuptools import setup
setup(
name='my-app',
version='0.1.0',
author_email='foo@domain',
packages=[
'my_app',
'my_app.pkg',
],
)
"""
)
def test_authors_name_and_email(self, helpers, isolation):
config = {
"project": {"name": "My.App", "version": "0.1.0", "authors": [{"email": "bar@domain", "name": "foo"}]}
}
builder = SdistBuilder(str(isolation), config=config)
assert builder.construct_setup_py_file(["my_app", os.path.join("my_app", "pkg")]) == helpers.dedent(
"""
from setuptools import setup
setup(
name='my-app',
version='0.1.0',
author_email='foo ',
packages=[
'my_app',
'my_app.pkg',
],
)
"""
)
def test_authors_multiple(self, helpers, isolation):
config = {"project": {"name": "My.App", "version": "0.1.0", "authors": [{"name": "foo"}, {"name": "bar"}]}}
builder = SdistBuilder(str(isolation), config=config)
assert builder.construct_setup_py_file(["my_app", os.path.join("my_app", "pkg")]) == helpers.dedent(
"""
from setuptools import setup
setup(
name='my-app',
version='0.1.0',
author='foo, bar',
packages=[
'my_app',
'my_app.pkg',
],
)
"""
)
def test_maintainers_name(self, helpers, isolation):
config = {"project": {"name": "My.App", "version": "0.1.0", "maintainers": [{"name": "foo"}]}}
builder = SdistBuilder(str(isolation), config=config)
assert builder.construct_setup_py_file(["my_app", os.path.join("my_app", "pkg")]) == helpers.dedent(
"""
from setuptools import setup
setup(
name='my-app',
version='0.1.0',
maintainer='foo',
packages=[
'my_app',
'my_app.pkg',
],
)
"""
)
def test_maintainers_email(self, helpers, isolation):
config = {"project": {"name": "My.App", "version": "0.1.0", "maintainers": [{"email": "foo@domain"}]}}
builder = SdistBuilder(str(isolation), config=config)
assert builder.construct_setup_py_file(["my_app", os.path.join("my_app", "pkg")]) == helpers.dedent(
"""
from setuptools import setup
setup(
name='my-app',
version='0.1.0',
maintainer_email='foo@domain',
packages=[
'my_app',
'my_app.pkg',
],
)
"""
)
def test_maintainers_name_and_email(self, helpers, isolation):
config = {
"project": {"name": "My.App", "version": "0.1.0", "maintainers": [{"email": "bar@domain", "name": "foo"}]}
}
builder = SdistBuilder(str(isolation), config=config)
assert builder.construct_setup_py_file(["my_app", os.path.join("my_app", "pkg")]) == helpers.dedent(
"""
from setuptools import setup
setup(
name='my-app',
version='0.1.0',
maintainer_email='foo ',
packages=[
'my_app',
'my_app.pkg',
],
)
"""
)
def test_maintainers_multiple(self, helpers, isolation):
config = {"project": {"name": "My.App", "version": "0.1.0", "maintainers": [{"name": "foo"}, {"name": "bar"}]}}
builder = SdistBuilder(str(isolation), config=config)
assert builder.construct_setup_py_file(["my_app", os.path.join("my_app", "pkg")]) == helpers.dedent(
"""
from setuptools import setup
setup(
name='my-app',
version='0.1.0',
maintainer='foo, bar',
packages=[
'my_app',
'my_app.pkg',
],
)
"""
)
def test_classifiers(self, helpers, isolation):
classifiers = [
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.9",
]
config = {"project": {"name": "My.App", "version": "0.1.0", "classifiers": classifiers}}
builder = SdistBuilder(str(isolation), config=config)
assert builder.construct_setup_py_file(["my_app", os.path.join("my_app", "pkg")]) == helpers.dedent(
"""
from setuptools import setup
setup(
name='my-app',
version='0.1.0',
classifiers=[
'Programming Language :: Python :: 3.9',
'Programming Language :: Python :: 3.11',
],
packages=[
'my_app',
'my_app.pkg',
],
)
"""
)
def test_dependencies(self, helpers, isolation):
config = {"project": {"name": "My.App", "version": "0.1.0", "dependencies": ["foo==1", "bar==5"]}}
builder = SdistBuilder(str(isolation), config=config)
assert builder.construct_setup_py_file(["my_app", os.path.join("my_app", "pkg")]) == helpers.dedent(
"""
from setuptools import setup
setup(
name='my-app',
version='0.1.0',
install_requires=[
'bar==5',
'foo==1',
],
packages=[
'my_app',
'my_app.pkg',
],
)
"""
)
def test_dependencies_extra(self, helpers, isolation):
config = {"project": {"name": "My.App", "version": "0.1.0", "dependencies": ["foo==1", "bar==5"]}}
builder = SdistBuilder(str(isolation), config=config)
assert builder.construct_setup_py_file(["my_app", os.path.join("my_app", "pkg")], ["baz==3"]) == helpers.dedent(
"""
from setuptools import setup
setup(
name='my-app',
version='0.1.0',
install_requires=[
'bar==5',
'foo==1',
'baz==3',
],
packages=[
'my_app',
'my_app.pkg',
],
)
"""
)
def test_optional_dependencies(self, helpers, isolation):
config = {
"project": {
"name": "My.App",
"version": "0.1.0",
"optional-dependencies": {
"feature2": ['foo==1; python_version < "3"', "bar==5"],
"feature1": ["foo==1", 'bar==5; python_version < "3"'],
},
}
}
builder = SdistBuilder(str(isolation), config=config)
assert builder.construct_setup_py_file(["my_app", os.path.join("my_app", "pkg")]) == helpers.dedent(
"""
from setuptools import setup
setup(
name='my-app',
version='0.1.0',
extras_require={
'feature1': [
'bar==5; python_version < "3"',
'foo==1',
],
'feature2': [
'bar==5',
'foo==1; python_version < "3"',
],
},
packages=[
'my_app',
'my_app.pkg',
],
)
"""
)
def test_scripts(self, helpers, isolation):
config = {"project": {"name": "My.App", "version": "0.1.0", "scripts": {"foo": "pkg:bar", "bar": "pkg:foo"}}}
builder = SdistBuilder(str(isolation), config=config)
assert builder.construct_setup_py_file(["my_app", os.path.join("my_app", "pkg")]) == helpers.dedent(
"""
from setuptools import setup
setup(
name='my-app',
version='0.1.0',
entry_points={
'console_scripts': [
'bar = pkg:foo',
'foo = pkg:bar',
],
},
packages=[
'my_app',
'my_app.pkg',
],
)
"""
)
def test_gui_scripts(self, helpers, isolation):
config = {
"project": {"name": "My.App", "version": "0.1.0", "gui-scripts": {"foo": "pkg:bar", "bar": "pkg:foo"}}
}
builder = SdistBuilder(str(isolation), config=config)
assert builder.construct_setup_py_file(["my_app", os.path.join("my_app", "pkg")]) == helpers.dedent(
"""
from setuptools import setup
setup(
name='my-app',
version='0.1.0',
entry_points={
'gui_scripts': [
'bar = pkg:foo',
'foo = pkg:bar',
],
},
packages=[
'my_app',
'my_app.pkg',
],
)
"""
)
def test_entry_points(self, helpers, isolation):
config = {
"project": {
"name": "My.App",
"version": "0.1.0",
"entry-points": {
"foo": {"bar": "pkg:foo", "foo": "pkg:bar"},
"bar": {"foo": "pkg:bar", "bar": "pkg:foo"},
},
}
}
builder = SdistBuilder(str(isolation), config=config)
assert builder.construct_setup_py_file(["my_app", os.path.join("my_app", "pkg")]) == helpers.dedent(
"""
from setuptools import setup
setup(
name='my-app',
version='0.1.0',
entry_points={
'bar': [
'bar = pkg:foo',
'foo = pkg:bar',
],
'foo': [
'bar = pkg:foo',
'foo = pkg:bar',
],
},
packages=[
'my_app',
'my_app.pkg',
],
)
"""
)
def test_all(self, helpers, isolation):
config = {
"project": {
"name": "My.App",
"version": "0.1.0",
"description": "foo",
"readme": {"content-type": "text/markdown", "text": "test content\n"},
"authors": [{"email": "bar@domain", "name": "foo"}],
"maintainers": [{"email": "bar@domain", "name": "foo"}],
"classifiers": [
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.9",
],
"dependencies": ["foo==1", "bar==5"],
"optional-dependencies": {
"feature2": ['foo==1; python_version < "3"', "bar==5"],
"feature1": ["foo==1", 'bar==5; python_version < "3"'],
"feature3": [],
},
"scripts": {"foo": "pkg:bar", "bar": "pkg:foo"},
"gui-scripts": {"foo": "pkg:bar", "bar": "pkg:foo"},
"entry-points": {
"foo": {"bar": "pkg:foo", "foo": "pkg:bar"},
"bar": {"foo": "pkg:bar", "bar": "pkg:foo"},
},
}
}
builder = SdistBuilder(str(isolation), config=config)
assert builder.construct_setup_py_file(["my_app", os.path.join("my_app", "pkg")]) == helpers.dedent(
"""
from setuptools import setup
setup(
name='my-app',
version='0.1.0',
description='foo',
long_description='test content\\n',
author_email='foo ',
maintainer_email='foo ',
classifiers=[
'Programming Language :: Python :: 3.9',
'Programming Language :: Python :: 3.11',
],
install_requires=[
'bar==5',
'foo==1',
],
extras_require={
'feature1': [
'bar==5; python_version < "3"',
'foo==1',
],
'feature2': [
'bar==5',
'foo==1; python_version < "3"',
],
},
entry_points={
'console_scripts': [
'bar = pkg:foo',
'foo = pkg:bar',
],
'gui_scripts': [
'bar = pkg:foo',
'foo = pkg:bar',
],
'bar': [
'bar = pkg:foo',
'foo = pkg:bar',
],
'foo': [
'bar = pkg:foo',
'foo = pkg:bar',
],
},
packages=[
'my_app',
'my_app.pkg',
],
)
"""
)
class TestBuildStandard:
def test_default(self, hatch, helpers, temp_dir, config_file):
config_file.model.template.plugins["default"]["src-layout"] = False
config_file.save()
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"
config = {
"project": {"name": project_name, "dynamic": ["version"]},
"tool": {
"hatch": {
"version": {"path": "my_app/__about__.py"},
"build": {"targets": {"sdist": {"versions": ["standard"]}}},
},
},
}
builder = SdistBuilder(str(project_path), config=config)
build_path = project_path / "dist"
with project_path.as_cwd():
artifacts = list(builder.build())
assert len(artifacts) == 1
expected_artifact = artifacts[0]
build_artifacts = list(build_path.iterdir())
assert len(build_artifacts) == 1
assert expected_artifact == str(build_artifacts[0])
assert expected_artifact == str(build_path / f"{builder.project_id}.tar.gz")
extraction_directory = temp_dir / "_archive"
extraction_directory.mkdir()
with tarfile.open(str(expected_artifact), "r:gz") as tar_archive:
tar_archive.extractall(str(extraction_directory), **helpers.tarfile_extraction_compat_options())
expected_files = helpers.get_template_files(
"sdist.standard_default", project_name, relative_root=builder.project_id
)
helpers.assert_files(extraction_directory, expected_files)
stat = os.stat(str(extraction_directory / builder.project_id / "PKG-INFO"))
assert stat.st_mtime == get_reproducible_timestamp()
def test_default_no_reproducible(self, hatch, helpers, temp_dir, config_file):
config_file.model.template.plugins["default"]["src-layout"] = False
config_file.save()
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"
config = {
"project": {"name": project_name, "dynamic": ["version"]},
"tool": {
"hatch": {
"version": {"path": "my_app/__about__.py"},
"build": {"targets": {"sdist": {"versions": ["standard"], "reproducible": False}}},
},
},
}
builder = SdistBuilder(str(project_path), config=config)
build_path = project_path / "dist"
build_path.mkdir()
with project_path.as_cwd():
artifacts = list(builder.build(directory=str(build_path)))
assert len(artifacts) == 1
expected_artifact = artifacts[0]
build_artifacts = list(build_path.iterdir())
assert len(build_artifacts) == 1
assert expected_artifact == str(build_artifacts[0])
assert expected_artifact == str(build_path / f"{builder.project_id}.tar.gz")
extraction_directory = temp_dir / "_archive"
extraction_directory.mkdir()
with tarfile.open(str(expected_artifact), "r:gz") as tar_archive:
tar_archive.extractall(str(extraction_directory), **helpers.tarfile_extraction_compat_options())
expected_files = helpers.get_template_files(
"sdist.standard_default", project_name, relative_root=builder.project_id
)
helpers.assert_files(extraction_directory, expected_files)
stat = os.stat(str(extraction_directory / builder.project_id / "PKG-INFO"))
assert stat.st_mtime != get_reproducible_timestamp()
def test_default_support_legacy(self, hatch, helpers, temp_dir, config_file):
config_file.model.template.plugins["default"]["src-layout"] = False
config_file.save()
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"
config = {
"project": {"name": project_name, "dynamic": ["version"]},
"tool": {
"hatch": {
"version": {"path": "my_app/__about__.py"},
"build": {"targets": {"sdist": {"versions": ["standard"], "support-legacy": True}}},
},
},
}
builder = SdistBuilder(str(project_path), config=config)
build_path = project_path / "dist"
build_path.mkdir()
with project_path.as_cwd():
artifacts = list(builder.build(directory=str(build_path)))
assert len(artifacts) == 1
expected_artifact = artifacts[0]
build_artifacts = list(build_path.iterdir())
assert len(build_artifacts) == 1
assert expected_artifact == str(build_artifacts[0])
assert expected_artifact == str(build_path / f"{builder.project_id}.tar.gz")
extraction_directory = temp_dir / "_archive"
extraction_directory.mkdir()
with tarfile.open(str(expected_artifact), "r:gz") as tar_archive:
tar_archive.extractall(str(extraction_directory), **helpers.tarfile_extraction_compat_options())
expected_files = helpers.get_template_files(
"sdist.standard_default_support_legacy", project_name, relative_root=builder.project_id
)
helpers.assert_files(extraction_directory, expected_files)
def test_default_build_script_artifacts(self, hatch, helpers, temp_dir, config_file):
config_file.model.template.plugins["default"]["src-layout"] = False
config_file.save()
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"
vcs_ignore_file = project_path / ".gitignore"
vcs_ignore_file.write_text("*.pyc\n*.so\n*.h\n")
build_script = project_path / DEFAULT_BUILD_SCRIPT
build_script.write_text(
helpers.dedent(
"""
import pathlib
from hatchling.builders.hooks.plugin.interface import BuildHookInterface
class CustomHook(BuildHookInterface):
def initialize(self, version, build_data):
pathlib.Path('my_app', 'lib.so').touch()
pathlib.Path('my_app', 'lib.h').touch()
"""
)
)
config = {
"project": {"name": project_name, "dynamic": ["version"]},
"tool": {
"hatch": {
"version": {"path": "my_app/__about__.py"},
"build": {
"targets": {
"sdist": {"versions": ["standard"], "exclude": [DEFAULT_BUILD_SCRIPT, ".gitignore"]}
},
"artifacts": ["my_app/lib.so"],
"hooks": {"custom": {"path": DEFAULT_BUILD_SCRIPT}},
},
},
},
}
builder = SdistBuilder(str(project_path), config=config)
build_path = project_path / "dist"
build_path.mkdir()
with project_path.as_cwd():
artifacts = list(builder.build(directory=str(build_path)))
assert len(artifacts) == 1
expected_artifact = artifacts[0]
build_artifacts = list(build_path.iterdir())
assert len(build_artifacts) == 1
assert expected_artifact == str(build_artifacts[0])
assert expected_artifact == str(build_path / f"{builder.project_id}.tar.gz")
extraction_directory = temp_dir / "_archive"
extraction_directory.mkdir()
with tarfile.open(str(expected_artifact), "r:gz") as tar_archive:
tar_archive.extractall(str(extraction_directory), **helpers.tarfile_extraction_compat_options())
expected_files = helpers.get_template_files(
"sdist.standard_default_build_script_artifacts", project_name, relative_root=builder.project_id
)
helpers.assert_files(extraction_directory, expected_files)
def test_default_build_script_extra_dependencies(self, hatch, helpers, temp_dir, config_file):
config_file.model.template.plugins["default"]["src-layout"] = False
config_file.save()
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"
vcs_ignore_file = project_path / ".gitignore"
vcs_ignore_file.write_text("*.pyc\n*.so\n*.h\n")
build_script = project_path / DEFAULT_BUILD_SCRIPT
build_script.write_text(
helpers.dedent(
"""
import pathlib
from hatchling.builders.hooks.plugin.interface import BuildHookInterface
class CustomHook(BuildHookInterface):
def initialize(self, version, build_data):
pathlib.Path('my_app', 'lib.so').touch()
pathlib.Path('my_app', 'lib.h').touch()
build_data['dependencies'].append('binary')
"""
)
)
config = {
"project": {"name": project_name, "dynamic": ["version"]},
"tool": {
"hatch": {
"version": {"path": "my_app/__about__.py"},
"build": {
"targets": {
"sdist": {"versions": ["standard"], "exclude": [DEFAULT_BUILD_SCRIPT, ".gitignore"]}
},
"artifacts": ["my_app/lib.so"],
"hooks": {"custom": {"path": DEFAULT_BUILD_SCRIPT}},
},
},
},
}
builder = SdistBuilder(str(project_path), config=config)
build_path = project_path / "dist"
build_path.mkdir()
with project_path.as_cwd():
artifacts = list(builder.build(directory=str(build_path)))
assert len(artifacts) == 1
expected_artifact = artifacts[0]
build_artifacts = list(build_path.iterdir())
assert len(build_artifacts) == 1
assert expected_artifact == str(build_artifacts[0])
assert expected_artifact == str(build_path / f"{builder.project_id}.tar.gz")
extraction_directory = temp_dir / "_archive"
extraction_directory.mkdir()
with tarfile.open(str(expected_artifact), "r:gz") as tar_archive:
tar_archive.extractall(str(extraction_directory), **helpers.tarfile_extraction_compat_options())
expected_files = helpers.get_template_files(
"sdist.standard_default_build_script_extra_dependencies", project_name, relative_root=builder.project_id
)
helpers.assert_files(extraction_directory, expected_files)
def test_include_project_file(self, hatch, helpers, temp_dir, config_file):
config_file.model.template.plugins["default"]["src-layout"] = False
config_file.save()
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"
config = {
"project": {"name": project_name, "dynamic": ["version"], "readme": "README.md"},
"tool": {
"hatch": {
"version": {"path": "my_app/__about__.py"},
"build": {
"targets": {"sdist": {"versions": ["standard"], "include": ["my_app/", "pyproject.toml"]}}
},
},
},
}
builder = SdistBuilder(str(project_path), config=config)
build_path = project_path / "dist"
with project_path.as_cwd():
artifacts = list(builder.build())
assert len(artifacts) == 1
expected_artifact = artifacts[0]
build_artifacts = list(build_path.iterdir())
assert len(build_artifacts) == 1
assert expected_artifact == str(build_artifacts[0])
assert expected_artifact == str(build_path / f"{builder.project_id}.tar.gz")
extraction_directory = temp_dir / "_archive"
extraction_directory.mkdir()
with tarfile.open(str(expected_artifact), "r:gz") as tar_archive:
tar_archive.extractall(str(extraction_directory), **helpers.tarfile_extraction_compat_options())
expected_files = helpers.get_template_files(
"sdist.standard_include", project_name, relative_root=builder.project_id
)
helpers.assert_files(extraction_directory, expected_files)
stat = os.stat(str(extraction_directory / builder.project_id / "PKG-INFO"))
assert stat.st_mtime == get_reproducible_timestamp()
def test_project_file_always_included(self, hatch, helpers, temp_dir, config_file):
config_file.model.template.plugins["default"]["src-layout"] = False
config_file.save()
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"
config = {
"project": {"name": project_name, "dynamic": ["version"], "readme": "README.md"},
"tool": {
"hatch": {
"version": {"path": "my_app/__about__.py"},
"build": {
"targets": {
"sdist": {
"versions": ["standard"],
"only-include": ["my_app"],
"exclude": ["pyproject.toml"],
},
},
},
},
},
}
builder = SdistBuilder(str(project_path), config=config)
# Ensure that only the root project file is forcibly included
(project_path / "my_app" / "pyproject.toml").touch()
build_path = project_path / "dist"
with project_path.as_cwd():
artifacts = list(builder.build())
assert len(artifacts) == 1
expected_artifact = artifacts[0]
build_artifacts = list(build_path.iterdir())
assert len(build_artifacts) == 1
assert expected_artifact == str(build_artifacts[0])
assert expected_artifact == str(build_path / f"{builder.project_id}.tar.gz")
extraction_directory = temp_dir / "_archive"
extraction_directory.mkdir()
with tarfile.open(str(expected_artifact), "r:gz") as tar_archive:
tar_archive.extractall(str(extraction_directory), **helpers.tarfile_extraction_compat_options())
expected_files = helpers.get_template_files(
"sdist.standard_include", project_name, relative_root=builder.project_id
)
helpers.assert_files(extraction_directory, expected_files)
stat = os.stat(str(extraction_directory / builder.project_id / "PKG-INFO"))
assert stat.st_mtime == get_reproducible_timestamp()
def test_config_file_always_included(self, hatch, helpers, temp_dir, config_file):
config_file.model.template.plugins["default"]["src-layout"] = False
config_file.save()
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"
config = {
"project": {"name": project_name, "dynamic": ["version"], "readme": "README.md"},
"tool": {
"hatch": {
"version": {"path": "my_app/__about__.py"},
"build": {
"targets": {
"sdist": {
"versions": ["standard"],
"only-include": ["my_app"],
"exclude": [DEFAULT_CONFIG_FILE],
},
},
},
},
},
}
builder = SdistBuilder(str(project_path), config=config)
(project_path / DEFAULT_CONFIG_FILE).touch()
# Ensure that only the root config file is forcibly included
(project_path / "my_app" / DEFAULT_CONFIG_FILE).touch()
build_path = project_path / "dist"
with project_path.as_cwd():
artifacts = list(builder.build())
assert len(artifacts) == 1
expected_artifact = artifacts[0]
build_artifacts = list(build_path.iterdir())
assert len(build_artifacts) == 1
assert expected_artifact == str(build_artifacts[0])
assert expected_artifact == str(build_path / f"{builder.project_id}.tar.gz")
extraction_directory = temp_dir / "_archive"
extraction_directory.mkdir()
with tarfile.open(str(expected_artifact), "r:gz") as tar_archive:
tar_archive.extractall(str(extraction_directory), **helpers.tarfile_extraction_compat_options())
expected_files = helpers.get_template_files(
"sdist.standard_include_config_file", project_name, relative_root=builder.project_id
)
helpers.assert_files(extraction_directory, expected_files)
stat = os.stat(str(extraction_directory / builder.project_id / "PKG-INFO"))
assert stat.st_mtime == get_reproducible_timestamp()
def test_include_readme(self, hatch, helpers, temp_dir, config_file):
config_file.model.template.plugins["default"]["src-layout"] = False
config_file.save()
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"
config = {
"project": {"name": project_name, "dynamic": ["version"], "readme": "README.md"},
"tool": {
"hatch": {
"version": {"path": "my_app/__about__.py"},
"build": {"targets": {"sdist": {"versions": ["standard"], "include": ["my_app/", "README.md"]}}},
},
},
}
builder = SdistBuilder(str(project_path), config=config)
build_path = project_path / "dist"
with project_path.as_cwd():
artifacts = list(builder.build())
assert len(artifacts) == 1
expected_artifact = artifacts[0]
build_artifacts = list(build_path.iterdir())
assert len(build_artifacts) == 1
assert expected_artifact == str(build_artifacts[0])
assert expected_artifact == str(build_path / f"{builder.project_id}.tar.gz")
extraction_directory = temp_dir / "_archive"
extraction_directory.mkdir()
with tarfile.open(str(expected_artifact), "r:gz") as tar_archive:
tar_archive.extractall(str(extraction_directory), **helpers.tarfile_extraction_compat_options())
expected_files = helpers.get_template_files(
"sdist.standard_include", project_name, relative_root=builder.project_id
)
helpers.assert_files(extraction_directory, expected_files)
stat = os.stat(str(extraction_directory / builder.project_id / "PKG-INFO"))
assert stat.st_mtime == get_reproducible_timestamp()
def test_readme_always_included(self, hatch, helpers, temp_dir, config_file):
config_file.model.template.plugins["default"]["src-layout"] = False
config_file.save()
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"
config = {
"project": {"name": project_name, "dynamic": ["version"], "readme": "README.md"},
"tool": {
"hatch": {
"version": {"path": "my_app/__about__.py"},
"build": {
"targets": {
"sdist": {"versions": ["standard"], "only-include": ["my_app"], "exclude": ["README.md"]},
},
},
},
},
}
builder = SdistBuilder(str(project_path), config=config)
# Ensure that only the desired readme is forcibly included
(project_path / "my_app" / "README.md").touch()
build_path = project_path / "dist"
with project_path.as_cwd():
artifacts = list(builder.build())
assert len(artifacts) == 1
expected_artifact = artifacts[0]
build_artifacts = list(build_path.iterdir())
assert len(build_artifacts) == 1
assert expected_artifact == str(build_artifacts[0])
assert expected_artifact == str(build_path / f"{builder.project_id}.tar.gz")
extraction_directory = temp_dir / "_archive"
extraction_directory.mkdir()
with tarfile.open(str(expected_artifact), "r:gz") as tar_archive:
tar_archive.extractall(str(extraction_directory), **helpers.tarfile_extraction_compat_options())
expected_files = helpers.get_template_files(
"sdist.standard_include", project_name, relative_root=builder.project_id
)
helpers.assert_files(extraction_directory, expected_files)
stat = os.stat(str(extraction_directory / builder.project_id / "PKG-INFO"))
assert stat.st_mtime == get_reproducible_timestamp()
def test_include_license_files(self, hatch, helpers, temp_dir, config_file):
config_file.model.template.plugins["default"]["src-layout"] = False
config_file.save()
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"
config = {
"project": {"name": project_name, "dynamic": ["version"], "readme": "README.md"},
"tool": {
"hatch": {
"version": {"path": "my_app/__about__.py"},
"build": {"targets": {"sdist": {"versions": ["standard"], "include": ["my_app/", "LICENSE.txt"]}}},
},
},
}
builder = SdistBuilder(str(project_path), config=config)
build_path = project_path / "dist"
with project_path.as_cwd():
artifacts = list(builder.build())
assert len(artifacts) == 1
expected_artifact = artifacts[0]
build_artifacts = list(build_path.iterdir())
assert len(build_artifacts) == 1
assert expected_artifact == str(build_artifacts[0])
assert expected_artifact == str(build_path / f"{builder.project_id}.tar.gz")
extraction_directory = temp_dir / "_archive"
extraction_directory.mkdir()
with tarfile.open(str(expected_artifact), "r:gz") as tar_archive:
tar_archive.extractall(str(extraction_directory), **helpers.tarfile_extraction_compat_options())
expected_files = helpers.get_template_files(
"sdist.standard_include", project_name, relative_root=builder.project_id
)
helpers.assert_files(extraction_directory, expected_files)
stat = os.stat(str(extraction_directory / builder.project_id / "PKG-INFO"))
assert stat.st_mtime == get_reproducible_timestamp()
def test_license_files_always_included(self, hatch, helpers, temp_dir, config_file):
config_file.model.template.plugins["default"]["src-layout"] = False
config_file.save()
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"
config = {
"project": {"name": project_name, "dynamic": ["version"], "readme": "README.md"},
"tool": {
"hatch": {
"version": {"path": "my_app/__about__.py"},
"build": {
"targets": {
"sdist": {"versions": ["standard"], "only-include": ["my_app"], "exclude": ["LICENSE.txt"]},
},
},
},
},
}
builder = SdistBuilder(str(project_path), config=config)
# Ensure that only the desired readme is forcibly included
(project_path / "my_app" / "LICENSE.txt").touch()
build_path = project_path / "dist"
with project_path.as_cwd():
artifacts = list(builder.build())
assert len(artifacts) == 1
expected_artifact = artifacts[0]
build_artifacts = list(build_path.iterdir())
assert len(build_artifacts) == 1
assert expected_artifact == str(build_artifacts[0])
assert expected_artifact == str(build_path / f"{builder.project_id}.tar.gz")
extraction_directory = temp_dir / "_archive"
extraction_directory.mkdir()
with tarfile.open(str(expected_artifact), "r:gz") as tar_archive:
tar_archive.extractall(str(extraction_directory), **helpers.tarfile_extraction_compat_options())
expected_files = helpers.get_template_files(
"sdist.standard_include", project_name, relative_root=builder.project_id
)
helpers.assert_files(extraction_directory, expected_files)
stat = os.stat(str(extraction_directory / builder.project_id / "PKG-INFO"))
assert stat.st_mtime == get_reproducible_timestamp()
def test_default_vcs_git_exclusion_files(self, hatch, helpers, temp_dir, config_file):
config_file.model.template.plugins["default"]["src-layout"] = False
config_file.save()
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"
vcs_ignore_file = temp_dir / ".gitignore"
vcs_ignore_file.write_text("*.pyc\n*.so\n*.h\n")
(project_path / "my_app" / "lib.so").touch()
(project_path / "my_app" / "lib.h").touch()
config = {
"project": {"name": project_name, "dynamic": ["version"]},
"tool": {
"hatch": {
"version": {"path": "my_app/__about__.py"},
"build": {
"targets": {"sdist": {"versions": ["standard"], "exclude": [".gitignore"]}},
"artifacts": ["my_app/lib.so"],
},
},
},
}
builder = SdistBuilder(str(project_path), config=config)
build_path = project_path / "dist"
build_path.mkdir()
with project_path.as_cwd():
artifacts = list(builder.build(directory=str(build_path)))
assert len(artifacts) == 1
expected_artifact = artifacts[0]
build_artifacts = list(build_path.iterdir())
assert len(build_artifacts) == 1
assert expected_artifact == str(build_artifacts[0])
assert expected_artifact == str(build_path / f"{builder.project_id}.tar.gz")
extraction_directory = temp_dir / "_archive"
extraction_directory.mkdir()
with tarfile.open(str(expected_artifact), "r:gz") as tar_archive:
tar_archive.extractall(str(extraction_directory), **helpers.tarfile_extraction_compat_options())
expected_files = helpers.get_template_files(
"sdist.standard_default_vcs_git_exclusion_files", project_name, relative_root=builder.project_id
)
helpers.assert_files(extraction_directory, expected_files)
def test_default_vcs_mercurial_exclusion_files(self, hatch, helpers, temp_dir, config_file):
config_file.model.template.plugins["default"]["src-layout"] = False
config_file.save()
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"
vcs_ignore_file = temp_dir / ".hgignore"
vcs_ignore_file.write_text(
helpers.dedent(
"""
syntax: glob
*.pyc
syntax: foo
README.md
syntax: glob
*.so
*.h
"""
)
)
(project_path / "my_app" / "lib.so").touch()
(project_path / "my_app" / "lib.h").touch()
config = {
"project": {"name": project_name, "dynamic": ["version"]},
"tool": {
"hatch": {
"version": {"path": "my_app/__about__.py"},
"build": {
"targets": {"sdist": {"versions": ["standard"], "exclude": [".hgignore"]}},
"artifacts": ["my_app/lib.so"],
},
},
},
}
builder = SdistBuilder(str(project_path), config=config)
build_path = project_path / "dist"
build_path.mkdir()
with project_path.as_cwd():
artifacts = list(builder.build(directory=str(build_path)))
assert len(artifacts) == 1
expected_artifact = artifacts[0]
build_artifacts = list(build_path.iterdir())
assert len(build_artifacts) == 1
assert expected_artifact == str(build_artifacts[0])
assert expected_artifact == str(build_path / f"{builder.project_id}.tar.gz")
extraction_directory = temp_dir / "_archive"
extraction_directory.mkdir()
with tarfile.open(str(expected_artifact), "r:gz") as tar_archive:
tar_archive.extractall(str(extraction_directory), **helpers.tarfile_extraction_compat_options())
expected_files = helpers.get_template_files(
"sdist.standard_default_vcs_mercurial_exclusion_files", project_name, relative_root=builder.project_id
)
helpers.assert_files(extraction_directory, expected_files)
def test_no_strict_naming(self, hatch, helpers, temp_dir, config_file):
config_file.model.template.plugins["default"]["src-layout"] = False
config_file.save()
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"
config = {
"project": {"name": project_name, "dynamic": ["version"]},
"tool": {
"hatch": {
"version": {"path": "my_app/__about__.py"},
"build": {"targets": {"sdist": {"versions": ["standard"], "strict-naming": False}}},
},
},
}
builder = SdistBuilder(str(project_path), config=config)
build_path = project_path / "dist"
with project_path.as_cwd():
artifacts = list(builder.build())
assert len(artifacts) == 1
expected_artifact = artifacts[0]
build_artifacts = list(build_path.iterdir())
assert len(build_artifacts) == 1
assert expected_artifact == str(build_artifacts[0])
assert expected_artifact == str(build_path / f"{builder.artifact_project_id}.tar.gz")
extraction_directory = temp_dir / "_archive"
extraction_directory.mkdir()
with tarfile.open(str(expected_artifact), "r:gz") as tar_archive:
tar_archive.extractall(str(extraction_directory), **helpers.tarfile_extraction_compat_options())
expected_files = helpers.get_template_files(
"sdist.standard_default", project_name, relative_root=builder.artifact_project_id
)
helpers.assert_files(extraction_directory, expected_files)
stat = os.stat(str(extraction_directory / builder.artifact_project_id / "PKG-INFO"))
assert stat.st_mtime == get_reproducible_timestamp()
def test_file_permissions_normalized(self, hatch, temp_dir, config_file):
config_file.model.template.plugins["default"]["src-layout"] = False
config_file.save()
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"
config = {
"project": {"name": project_name, "dynamic": ["version"]},
"tool": {
"hatch": {
"version": {"path": "my_app/__about__.py"},
"build": {"targets": {"sdist": {"versions": ["standard"]}}},
},
},
}
builder = SdistBuilder(str(project_path), config=config)
build_path = project_path / "dist"
with project_path.as_cwd():
artifacts = list(builder.build())
assert len(artifacts) == 1
expected_artifact = artifacts[0]
build_artifacts = list(build_path.iterdir())
assert len(build_artifacts) == 1
assert expected_artifact == str(build_artifacts[0])
assert expected_artifact == str(build_path / f"{builder.artifact_project_id}.tar.gz")
file_stat = os.stat(expected_artifact)
# we assert that at minimum 644 is set, based on the platform (e.g.)
# windows it may be higher
assert file_stat.st_mode & 0o644
================================================
FILE: tests/backend/builders/test_wheel.py
================================================
from __future__ import annotations
import os
import platform
import sys
import zipfile
from typing import TYPE_CHECKING
import packaging.tags
import pytest
from hatchling.builders.plugin.interface import BuilderInterface
from hatchling.builders.utils import get_known_python_major_versions
from hatchling.builders.wheel import WheelBuilder
from hatchling.metadata.spec import DEFAULT_METADATA_VERSION, get_core_metadata_constructors
from hatchling.utils.constants import DEFAULT_BUILD_SCRIPT
if TYPE_CHECKING:
from hatch.utils.fs import Path
def sys_tags():
return iter(
t for t in packaging.tags.sys_tags() if "manylinux" not in t.platform and "muslllinux" not in t.platform
)
# https://github.com/python/cpython/pull/26184
fixed_pathlib_resolution = pytest.mark.skipif(
sys.platform == "win32" and (sys.version_info < (3, 8) or sys.implementation.name == "pypy"),
reason="pathlib.Path.resolve has bug on Windows",
)
def get_python_versions_tag():
return ".".join(f"py{major_version}" for major_version in get_known_python_major_versions())
def extract_zip(zip_path: Path, target: Path) -> None:
with zipfile.ZipFile(zip_path, "r") as z:
for name in z.namelist():
member = z.getinfo(name)
path = z.extract(member, target)
os.chmod(path, member.external_attr >> 16)
def test_class():
assert issubclass(WheelBuilder, BuilderInterface)
def test_default_versions(isolation):
builder = WheelBuilder(str(isolation))
assert builder.get_default_versions() == ["standard"]
class TestDefaultFileSelection:
def test_already_defined(self, temp_dir):
config = {
"project": {"name": "my-app", "version": "0.0.1"},
"tool": {
"hatch": {
"build": {
"targets": {
"wheel": {
"include": ["foo"],
"exclude": ["bar"],
"packages": ["foo", "bar", "baz"],
"only-include": ["baz"],
}
}
}
}
},
}
builder = WheelBuilder(str(temp_dir), config=config)
assert builder.config.default_include() == ["foo"]
assert builder.config.default_exclude() == ["bar"]
assert builder.config.default_packages() == ["foo", "bar", "baz"]
assert builder.config.default_only_include() == ["baz"]
def test_flat_layout(self, temp_dir):
config = {
"project": {"name": "my-app", "version": "0.0.1"},
"tool": {"hatch": {"build": {"targets": {"wheel": {"exclude": ["foobarbaz"]}}}}},
}
builder = WheelBuilder(str(temp_dir), config=config)
flat_root = temp_dir / "my_app" / "__init__.py"
flat_root.ensure_parent_dir_exists()
flat_root.touch()
src_root = temp_dir / "src" / "my_app" / "__init__.py"
src_root.ensure_parent_dir_exists()
src_root.touch()
single_module_root = temp_dir / "my_app.py"
single_module_root.touch()
namespace_root = temp_dir / "ns" / "my_app" / "__init__.py"
namespace_root.ensure_parent_dir_exists()
namespace_root.touch()
assert builder.config.default_include() == []
assert builder.config.default_exclude() == ["foobarbaz"]
assert builder.config.default_packages() == ["my_app"]
assert builder.config.default_only_include() == []
def test_src_layout(self, temp_dir):
config = {
"project": {"name": "my-app", "version": "0.0.1"},
"tool": {"hatch": {"build": {"targets": {"wheel": {"exclude": ["foobarbaz"]}}}}},
}
builder = WheelBuilder(str(temp_dir), config=config)
src_root = temp_dir / "src" / "my_app" / "__init__.py"
src_root.ensure_parent_dir_exists()
src_root.touch()
single_module_root = temp_dir / "my_app.py"
single_module_root.touch()
namespace_root = temp_dir / "ns" / "my_app" / "__init__.py"
namespace_root.ensure_parent_dir_exists()
namespace_root.touch()
assert builder.config.default_include() == []
assert builder.config.default_exclude() == ["foobarbaz"]
assert builder.config.default_packages() == ["src/my_app"]
assert builder.config.default_only_include() == []
def test_single_module(self, temp_dir):
config = {
"project": {"name": "my-app", "version": "0.0.1"},
"tool": {"hatch": {"build": {"targets": {"wheel": {"exclude": ["foobarbaz"]}}}}},
}
builder = WheelBuilder(str(temp_dir), config=config)
single_module_root = temp_dir / "my_app.py"
single_module_root.touch()
namespace_root = temp_dir / "ns" / "my_app" / "__init__.py"
namespace_root.ensure_parent_dir_exists()
namespace_root.touch()
assert builder.config.default_include() == []
assert builder.config.default_exclude() == ["foobarbaz"]
assert builder.config.default_packages() == []
assert builder.config.default_only_include() == ["my_app.py"]
def test_namespace(self, temp_dir):
config = {
"project": {"name": "my-app", "version": "0.0.1"},
"tool": {"hatch": {"build": {"targets": {"wheel": {"exclude": ["foobarbaz"]}}}}},
}
builder = WheelBuilder(str(temp_dir), config=config)
namespace_root = temp_dir / "ns" / "my_app" / "__init__.py"
namespace_root.ensure_parent_dir_exists()
namespace_root.touch()
assert builder.config.default_include() == []
assert builder.config.default_exclude() == ["foobarbaz"]
assert builder.config.default_packages() == ["ns"]
assert builder.config.default_only_include() == []
def test_default_error(self, temp_dir):
config = {
"project": {"name": "MyApp", "version": "0.0.1"},
"tool": {"hatch": {"build": {"targets": {"wheel": {"exclude": ["foobarbaz"]}}}}},
}
builder = WheelBuilder(str(temp_dir), config=config)
for method in (
builder.config.default_include,
builder.config.default_exclude,
builder.config.default_packages,
builder.config.default_only_include,
):
with pytest.raises(
ValueError,
match=(
"Unable to determine which files to ship inside the wheel using the following heuristics: "
"https://hatch.pypa.io/latest/plugins/builder/wheel/#default-file-selection\n\n"
"The most likely cause of this is that there is no directory that matches the name of your "
"project \\(MyApp or myapp\\).\n\n"
"At least one file selection option must be defined in the `tool.hatch.build.targets.wheel` "
"table, see: https://hatch.pypa.io/latest/config/build/\n\n"
"As an example, if you intend to ship a directory named `foo` that resides within a `src` "
"directory located at the root of your project, you can define the following:\n\n"
"\\[tool.hatch.build.targets.wheel\\]\n"
'packages = \\["src/foo"\\]'
),
):
method()
def test_bypass_selection_option(self, temp_dir):
config = {
"project": {"name": "my-app", "version": "0.0.1"},
"tool": {"hatch": {"build": {"targets": {"wheel": {"bypass-selection": True}}}}},
}
builder = WheelBuilder(str(temp_dir), config=config)
assert builder.config.default_include() == []
assert builder.config.default_exclude() == []
assert builder.config.default_packages() == []
assert builder.config.default_only_include() == []
def test_force_include_option_considered_selection(self, temp_dir):
config = {
"project": {"name": "my-app", "version": "0.0.1"},
"tool": {"hatch": {"build": {"targets": {"wheel": {"force-include": {"foo": "bar"}}}}}},
}
builder = WheelBuilder(str(temp_dir), config=config)
assert builder.config.default_include() == []
assert builder.config.default_exclude() == []
assert builder.config.default_packages() == []
assert builder.config.default_only_include() == []
def test_force_include_build_data_considered_selection(self, temp_dir):
config = {"project": {"name": "my-app", "version": "0.0.1"}}
builder = WheelBuilder(str(temp_dir), config=config)
build_data = {"artifacts": [], "force_include": {"foo": "bar"}}
with builder.config.set_build_data(build_data):
assert builder.config.default_include() == []
assert builder.config.default_exclude() == []
assert builder.config.default_packages() == []
assert builder.config.default_only_include() == []
def test_artifacts_build_data_considered_selection(self, temp_dir):
config = {"project": {"name": "my-app", "version": "0.0.1"}}
builder = WheelBuilder(str(temp_dir), config=config)
build_data = {"artifacts": ["foo"], "force_include": {}}
with builder.config.set_build_data(build_data):
assert builder.config.default_include() == []
assert builder.config.default_exclude() == []
assert builder.config.default_packages() == []
assert builder.config.default_only_include() == []
def test_unnormalized_name_with_unnormalized_directory(self, temp_dir):
config = {"project": {"name": "MyApp", "version": "0.0.1"}}
builder = WheelBuilder(str(temp_dir), config=config)
src_root = temp_dir / "src" / "MyApp" / "__init__.py"
src_root.ensure_parent_dir_exists()
src_root.touch()
assert builder.config.default_packages() == ["src/MyApp"]
def test_unnormalized_name_with_normalized_directory(self, temp_dir):
config = {"project": {"name": "MyApp", "version": "0.0.1"}}
builder = WheelBuilder(str(temp_dir), config=config)
src_root = temp_dir / "src" / "myapp" / "__init__.py"
src_root.ensure_parent_dir_exists()
src_root.touch()
assert builder.config.default_packages() == ["src/myapp"]
class TestCoreMetadataConstructor:
def test_default(self, isolation):
builder = WheelBuilder(str(isolation))
assert builder.config.core_metadata_constructor is builder.config.core_metadata_constructor
assert builder.config.core_metadata_constructor is get_core_metadata_constructors()[DEFAULT_METADATA_VERSION]
def test_not_string(self, isolation):
config = {"tool": {"hatch": {"build": {"targets": {"wheel": {"core-metadata-version": 42}}}}}}
builder = WheelBuilder(str(isolation), config=config)
with pytest.raises(
TypeError, match="Field `tool.hatch.build.targets.wheel.core-metadata-version` must be a string"
):
_ = builder.config.core_metadata_constructor
def test_unknown(self, isolation):
config = {"tool": {"hatch": {"build": {"targets": {"wheel": {"core-metadata-version": "9000"}}}}}}
builder = WheelBuilder(str(isolation), config=config)
with pytest.raises(
ValueError,
match=(
f"Unknown metadata version `9000` for field `tool.hatch.build.targets.wheel.core-metadata-version`. "
f"Available: {', '.join(sorted(get_core_metadata_constructors()))}"
),
):
_ = builder.config.core_metadata_constructor
class TestSharedData:
def test_default(self, isolation):
builder = WheelBuilder(str(isolation))
assert builder.config.shared_data == builder.config.shared_data == {}
def test_invalid_type(self, isolation):
config = {"tool": {"hatch": {"build": {"targets": {"wheel": {"shared-data": 42}}}}}}
builder = WheelBuilder(str(isolation), config=config)
with pytest.raises(TypeError, match="Field `tool.hatch.build.targets.wheel.shared-data` must be a mapping"):
_ = builder.config.shared_data
def test_absolute(self, isolation):
config = {
"tool": {
"hatch": {"build": {"targets": {"wheel": {"shared-data": {str(isolation / "source"): "/target/"}}}}}
}
}
builder = WheelBuilder(str(isolation), config=config)
assert builder.config.shared_data == {str(isolation / "source"): "target"}
def test_relative(self, isolation):
config = {"tool": {"hatch": {"build": {"targets": {"wheel": {"shared-data": {"../source": "/target/"}}}}}}}
builder = WheelBuilder(str(isolation / "foo"), config=config)
assert builder.config.shared_data == {str(isolation / "source"): "target"}
def test_source_empty_string(self, isolation):
config = {"tool": {"hatch": {"build": {"targets": {"wheel": {"shared-data": {"": "/target/"}}}}}}}
builder = WheelBuilder(str(isolation), config=config)
with pytest.raises(
ValueError,
match="Source #1 in field `tool.hatch.build.targets.wheel.shared-data` cannot be an empty string",
):
_ = builder.config.shared_data
def test_relative_path_not_string(self, isolation):
config = {"tool": {"hatch": {"build": {"targets": {"wheel": {"shared-data": {"source": 0}}}}}}}
builder = WheelBuilder(str(isolation), config=config)
with pytest.raises(
TypeError,
match="Path for source `source` in field `tool.hatch.build.targets.wheel.shared-data` must be a string",
):
_ = builder.config.shared_data
def test_relative_path_empty_string(self, isolation):
config = {"tool": {"hatch": {"build": {"targets": {"wheel": {"shared-data": {"source": ""}}}}}}}
builder = WheelBuilder(str(isolation), config=config)
with pytest.raises(
ValueError,
match=(
"Path for source `source` in field `tool.hatch.build.targets.wheel.shared-data` "
"cannot be an empty string"
),
):
_ = builder.config.shared_data
def test_order(self, isolation):
config = {
"tool": {
"hatch": {
"build": {
"targets": {
"wheel": {
"shared-data": {
"../very-nested": "target1/embedded",
"../source1": "/target2/",
"../source2": "/target1/",
}
}
}
}
}
}
}
builder = WheelBuilder(str(isolation / "foo"), config=config)
assert builder.config.shared_data == {
str(isolation / "source2"): "target1",
str(isolation / "very-nested"): f"target1{os.sep}embedded",
str(isolation / "source1"): "target2",
}
class TestSharedScripts:
def test_default(self, isolation):
builder = WheelBuilder(str(isolation))
assert builder.config.shared_scripts == builder.config.shared_scripts == {}
def test_invalid_type(self, isolation):
config = {"tool": {"hatch": {"build": {"targets": {"wheel": {"shared-scripts": 42}}}}}}
builder = WheelBuilder(str(isolation), config=config)
with pytest.raises(TypeError, match="Field `tool.hatch.build.targets.wheel.shared-scripts` must be a mapping"):
_ = builder.config.shared_scripts
def test_absolute(self, isolation):
config = {
"tool": {
"hatch": {"build": {"targets": {"wheel": {"shared-scripts": {str(isolation / "source"): "/target/"}}}}}
}
}
builder = WheelBuilder(str(isolation), config=config)
assert builder.config.shared_scripts == {str(isolation / "source"): "target"}
def test_relative(self, isolation):
config = {"tool": {"hatch": {"build": {"targets": {"wheel": {"shared-scripts": {"../source": "/target/"}}}}}}}
builder = WheelBuilder(str(isolation / "foo"), config=config)
assert builder.config.shared_scripts == {str(isolation / "source"): "target"}
def test_source_empty_string(self, isolation):
config = {"tool": {"hatch": {"build": {"targets": {"wheel": {"shared-scripts": {"": "/target/"}}}}}}}
builder = WheelBuilder(str(isolation), config=config)
with pytest.raises(
ValueError,
match="Source #1 in field `tool.hatch.build.targets.wheel.shared-scripts` cannot be an empty string",
):
_ = builder.config.shared_scripts
def test_relative_path_not_string(self, isolation):
config = {"tool": {"hatch": {"build": {"targets": {"wheel": {"shared-scripts": {"source": 0}}}}}}}
builder = WheelBuilder(str(isolation), config=config)
with pytest.raises(
TypeError,
match="Path for source `source` in field `tool.hatch.build.targets.wheel.shared-scripts` must be a string",
):
_ = builder.config.shared_scripts
def test_relative_path_empty_string(self, isolation):
config = {"tool": {"hatch": {"build": {"targets": {"wheel": {"shared-scripts": {"source": ""}}}}}}}
builder = WheelBuilder(str(isolation), config=config)
with pytest.raises(
ValueError,
match=(
"Path for source `source` in field `tool.hatch.build.targets.wheel.shared-scripts` "
"cannot be an empty string"
),
):
_ = builder.config.shared_scripts
def test_order(self, isolation):
config = {
"tool": {
"hatch": {
"build": {
"targets": {
"wheel": {
"shared-scripts": {
"../very-nested": "target1/embedded",
"../source1": "/target2/",
"../source2": "/target1/",
}
}
}
}
}
}
}
builder = WheelBuilder(str(isolation / "foo"), config=config)
assert builder.config.shared_scripts == {
str(isolation / "source2"): "target1",
str(isolation / "very-nested"): f"target1{os.sep}embedded",
str(isolation / "source1"): "target2",
}
class TestExtraMetadata:
def test_default(self, isolation):
builder = WheelBuilder(str(isolation))
assert builder.config.extra_metadata == builder.config.extra_metadata == {}
def test_invalid_type(self, isolation):
config = {"tool": {"hatch": {"build": {"targets": {"wheel": {"extra-metadata": 42}}}}}}
builder = WheelBuilder(str(isolation), config=config)
with pytest.raises(TypeError, match="Field `tool.hatch.build.targets.wheel.extra-metadata` must be a mapping"):
_ = builder.config.extra_metadata
def test_absolute(self, isolation):
config = {
"tool": {
"hatch": {"build": {"targets": {"wheel": {"extra-metadata": {str(isolation / "source"): "/target/"}}}}}
}
}
builder = WheelBuilder(str(isolation), config=config)
assert builder.config.extra_metadata == {str(isolation / "source"): "target"}
def test_relative(self, isolation):
config = {"tool": {"hatch": {"build": {"targets": {"wheel": {"extra-metadata": {"../source": "/target/"}}}}}}}
builder = WheelBuilder(str(isolation / "foo"), config=config)
assert builder.config.extra_metadata == {str(isolation / "source"): "target"}
def test_source_empty_string(self, isolation):
config = {"tool": {"hatch": {"build": {"targets": {"wheel": {"extra-metadata": {"": "/target/"}}}}}}}
builder = WheelBuilder(str(isolation), config=config)
with pytest.raises(
ValueError,
match="Source #1 in field `tool.hatch.build.targets.wheel.extra-metadata` cannot be an empty string",
):
_ = builder.config.extra_metadata
def test_relative_path_not_string(self, isolation):
config = {"tool": {"hatch": {"build": {"targets": {"wheel": {"extra-metadata": {"source": 0}}}}}}}
builder = WheelBuilder(str(isolation), config=config)
with pytest.raises(
TypeError,
match="Path for source `source` in field `tool.hatch.build.targets.wheel.extra-metadata` must be a string",
):
_ = builder.config.extra_metadata
def test_relative_path_empty_string(self, isolation):
config = {"tool": {"hatch": {"build": {"targets": {"wheel": {"extra-metadata": {"source": ""}}}}}}}
builder = WheelBuilder(str(isolation), config=config)
with pytest.raises(
ValueError,
match=(
"Path for source `source` in field `tool.hatch.build.targets.wheel.extra-metadata` "
"cannot be an empty string"
),
):
_ = builder.config.extra_metadata
def test_order(self, isolation):
config = {
"tool": {
"hatch": {
"build": {
"targets": {
"wheel": {
"extra-metadata": {
"../very-nested": "target1/embedded",
"../source1": "/target2/",
"../source2": "/target1/",
}
}
}
}
}
}
}
builder = WheelBuilder(str(isolation / "foo"), config=config)
assert builder.config.extra_metadata == {
str(isolation / "source2"): "target1",
str(isolation / "very-nested"): f"target1{os.sep}embedded",
str(isolation / "source1"): "target2",
}
class TestStrictNaming:
def test_default(self, isolation):
builder = WheelBuilder(str(isolation))
assert builder.config.strict_naming is builder.config.strict_naming is True
def test_target(self, isolation):
config = {"tool": {"hatch": {"build": {"targets": {"wheel": {"strict-naming": False}}}}}}
builder = WheelBuilder(str(isolation), config=config)
assert builder.config.strict_naming is False
def test_target_not_boolean(self, isolation):
config = {"tool": {"hatch": {"build": {"targets": {"wheel": {"strict-naming": 9000}}}}}}
builder = WheelBuilder(str(isolation), config=config)
with pytest.raises(TypeError, match="Field `tool.hatch.build.targets.wheel.strict-naming` must be a boolean"):
_ = builder.config.strict_naming
def test_global(self, isolation):
config = {"tool": {"hatch": {"build": {"strict-naming": False}}}}
builder = WheelBuilder(str(isolation), config=config)
assert builder.config.strict_naming is False
def test_global_not_boolean(self, isolation):
config = {"tool": {"hatch": {"build": {"strict-naming": 9000}}}}
builder = WheelBuilder(str(isolation), config=config)
with pytest.raises(TypeError, match="Field `tool.hatch.build.strict-naming` must be a boolean"):
_ = builder.config.strict_naming
def test_target_overrides_global(self, isolation):
config = {"tool": {"hatch": {"build": {"strict-naming": False, "targets": {"wheel": {"strict-naming": True}}}}}}
builder = WheelBuilder(str(isolation), config=config)
assert builder.config.strict_naming is True
class TestMacOSMaxCompat:
def test_default(self, isolation):
builder = WheelBuilder(str(isolation))
assert builder.config.macos_max_compat is builder.config.macos_max_compat is False
def test_correct(self, isolation):
config = {"tool": {"hatch": {"build": {"targets": {"wheel": {"macos-max-compat": True}}}}}}
builder = WheelBuilder(str(isolation), config=config)
assert builder.config.macos_max_compat is True
def test_not_boolean(self, isolation):
config = {"tool": {"hatch": {"build": {"targets": {"wheel": {"macos-max-compat": 9000}}}}}}
builder = WheelBuilder(str(isolation), config=config)
with pytest.raises(
TypeError, match="Field `tool.hatch.build.targets.wheel.macos-max-compat` must be a boolean"
):
_ = builder.config.macos_max_compat
class TestBypassSelection:
def test_default(self, isolation):
builder = WheelBuilder(str(isolation))
assert builder.config.bypass_selection is False
def test_correct(self, isolation):
config = {"tool": {"hatch": {"build": {"targets": {"wheel": {"bypass-selection": True}}}}}}
builder = WheelBuilder(str(isolation), config=config)
assert builder.config.bypass_selection is True
def test_not_boolean(self, isolation):
config = {"tool": {"hatch": {"build": {"targets": {"wheel": {"bypass-selection": 9000}}}}}}
builder = WheelBuilder(str(isolation), config=config)
with pytest.raises(
TypeError, match="Field `tool.hatch.build.targets.wheel.bypass-selection` must be a boolean"
):
_ = builder.config.bypass_selection
class TestConstructEntryPointsFile:
def test_default(self, isolation):
config = {"project": {}}
builder = WheelBuilder(str(isolation), config=config)
assert builder.construct_entry_points_file() == ""
def test_scripts(self, isolation, helpers):
config = {"project": {"scripts": {"foo": "pkg:bar", "bar": "pkg:foo"}}}
builder = WheelBuilder(str(isolation), config=config)
assert builder.construct_entry_points_file() == helpers.dedent(
"""
[console_scripts]
bar = pkg:foo
foo = pkg:bar
"""
)
def test_gui_scripts(self, isolation, helpers):
config = {"project": {"gui-scripts": {"foo": "pkg:bar", "bar": "pkg:foo"}}}
builder = WheelBuilder(str(isolation), config=config)
assert builder.construct_entry_points_file() == helpers.dedent(
"""
[gui_scripts]
bar = pkg:foo
foo = pkg:bar
"""
)
def test_entry_points(self, isolation, helpers):
config = {
"project": {
"entry-points": {
"foo": {"bar": "pkg:foo", "foo": "pkg:bar"},
"bar": {"foo": "pkg:bar", "bar": "pkg:foo"},
}
}
}
builder = WheelBuilder(str(isolation), config=config)
assert builder.construct_entry_points_file() == helpers.dedent(
"""
[bar]
bar = pkg:foo
foo = pkg:bar
[foo]
bar = pkg:foo
foo = pkg:bar
"""
)
def test_all(self, isolation, helpers):
config = {
"project": {
"scripts": {"foo": "pkg:bar", "bar": "pkg:foo"},
"gui-scripts": {"foo": "pkg:bar", "bar": "pkg:foo"},
"entry-points": {
"foo": {"bar": "pkg:foo", "foo": "pkg:bar"},
"bar": {"foo": "pkg:bar", "bar": "pkg:foo"},
},
}
}
builder = WheelBuilder(str(isolation), config=config)
assert builder.construct_entry_points_file() == helpers.dedent(
"""
[console_scripts]
bar = pkg:foo
foo = pkg:bar
[gui_scripts]
bar = pkg:foo
foo = pkg:bar
[bar]
bar = pkg:foo
foo = pkg:bar
[foo]
bar = pkg:foo
foo = pkg:bar
"""
)
class TestBuildStandard:
def test_default_auto_detection(self, hatch, helpers, temp_dir, config_file):
config_file.model.template.plugins["default"]["src-layout"] = False
config_file.save()
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"
config = {
"project": {"name": project_name, "dynamic": ["version"]},
"tool": {
"hatch": {
"version": {"path": "my_app/__about__.py"},
"build": {"targets": {"wheel": {"versions": ["standard"]}}},
},
},
}
builder = WheelBuilder(str(project_path), config=config)
build_path = project_path / "dist"
with project_path.as_cwd():
artifacts = list(builder.build())
assert len(artifacts) == 1
expected_artifact = artifacts[0]
build_artifacts = list(build_path.iterdir())
assert len(build_artifacts) == 1
assert expected_artifact == str(build_artifacts[0])
assert expected_artifact == str(build_path / f"{builder.project_id}-{get_python_versions_tag()}-none-any.whl")
extraction_directory = temp_dir / "_archive"
extraction_directory.mkdir()
with zipfile.ZipFile(str(expected_artifact), "r") as zip_archive:
zip_archive.extractall(str(extraction_directory))
metadata_directory = f"{builder.project_id}.dist-info"
expected_files = helpers.get_template_files(
"wheel.standard_default_license_single", project_name, metadata_directory=metadata_directory
)
helpers.assert_files(extraction_directory, expected_files)
# Inspect the archive rather than the extracted files because on Windows they lose their metadata
# https://stackoverflow.com/q/9813243
with zipfile.ZipFile(str(expected_artifact), "r") as zip_archive:
zip_info = zip_archive.getinfo(f"{metadata_directory}/WHEEL")
assert zip_info.date_time == (2020, 2, 2, 0, 0, 0)
@pytest.mark.parametrize(
("epoch", "expected_date_time"),
[
("0", (1980, 1, 1, 0, 0, 0)),
("1580601700", (2020, 2, 2, 0, 1, 40)),
],
)
def test_default_reproducible_timestamp(self, hatch, helpers, temp_dir, config_file, epoch, expected_date_time):
config_file.model.template.plugins["default"]["src-layout"] = False
config_file.save()
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"
config = {
"project": {"name": project_name, "dynamic": ["version"]},
"tool": {
"hatch": {
"version": {"path": "my_app/__about__.py"},
"build": {"targets": {"wheel": {"versions": ["standard"]}}},
},
},
}
builder = WheelBuilder(str(project_path), config=config)
build_path = project_path / "dist"
build_path.mkdir()
with project_path.as_cwd(env_vars={"SOURCE_DATE_EPOCH": epoch}):
artifacts = list(builder.build(directory=str(build_path)))
assert len(artifacts) == 1
expected_artifact = artifacts[0]
build_artifacts = list(build_path.iterdir())
assert len(build_artifacts) == 1
assert expected_artifact == str(build_artifacts[0])
assert expected_artifact == str(build_path / f"{builder.project_id}-{get_python_versions_tag()}-none-any.whl")
extraction_directory = temp_dir / "_archive"
extraction_directory.mkdir()
with zipfile.ZipFile(str(expected_artifact), "r") as zip_archive:
zip_archive.extractall(str(extraction_directory))
metadata_directory = f"{builder.project_id}.dist-info"
expected_files = helpers.get_template_files(
"wheel.standard_default_license_single", project_name, metadata_directory=metadata_directory
)
helpers.assert_files(extraction_directory, expected_files)
with zipfile.ZipFile(str(expected_artifact), "r") as zip_archive:
zip_info = zip_archive.getinfo(f"{metadata_directory}/WHEEL")
assert zip_info.date_time == expected_date_time
def test_default_no_reproducible(self, hatch, helpers, temp_dir, config_file):
config_file.model.template.plugins["default"]["src-layout"] = False
config_file.save()
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"
config = {
"project": {"name": project_name, "dynamic": ["version"]},
"tool": {
"hatch": {
"version": {"path": "my_app/__about__.py"},
"build": {"targets": {"wheel": {"versions": ["standard"], "reproducible": False}}},
},
},
}
builder = WheelBuilder(str(project_path), config=config)
build_path = project_path / "dist"
build_path.mkdir()
with project_path.as_cwd(env_vars={"SOURCE_DATE_EPOCH": "1580601700"}):
artifacts = list(builder.build(directory=str(build_path)))
assert len(artifacts) == 1
expected_artifact = artifacts[0]
build_artifacts = list(build_path.iterdir())
assert len(build_artifacts) == 1
assert expected_artifact == str(build_artifacts[0])
assert expected_artifact == str(build_path / f"{builder.project_id}-{get_python_versions_tag()}-none-any.whl")
extraction_directory = temp_dir / "_archive"
extraction_directory.mkdir()
with zipfile.ZipFile(str(expected_artifact), "r") as zip_archive:
zip_archive.extractall(str(extraction_directory))
metadata_directory = f"{builder.project_id}.dist-info"
expected_files = helpers.get_template_files(
"wheel.standard_default_license_single", project_name, metadata_directory=metadata_directory
)
helpers.assert_files(extraction_directory, expected_files)
with zipfile.ZipFile(str(expected_artifact), "r") as zip_archive:
zip_info = zip_archive.getinfo(f"{metadata_directory}/WHEEL")
assert zip_info.date_time == (2020, 2, 2, 0, 0, 0)
def test_default_multiple_licenses(self, hatch, helpers, config_file, temp_dir):
project_name = "My.App"
config_file.model.template.plugins["default"]["src-layout"] = False
config_file.model.template.licenses.default = ["MIT", "Apache-2.0"]
config_file.save()
with temp_dir.as_cwd():
result = hatch("new", project_name)
assert result.exit_code == 0, result.output
project_path = temp_dir / "my-app"
# Ensure that we trigger the non-file case for code coverage
(project_path / "LICENSES" / "test").mkdir()
config = {
"project": {"name": project_name, "dynamic": ["version"], "license-files": ["LICENSES/*"]},
"tool": {
"hatch": {
"version": {"path": "my_app/__about__.py"},
"build": {"targets": {"wheel": {"versions": ["standard"]}}},
},
},
}
builder = WheelBuilder(str(project_path), config=config)
build_path = project_path / "dist"
build_path.mkdir()
with project_path.as_cwd():
artifacts = list(builder.build(directory=str(build_path)))
assert len(artifacts) == 1
expected_artifact = artifacts[0]
build_artifacts = list(build_path.iterdir())
assert len(build_artifacts) == 1
assert expected_artifact == str(build_artifacts[0])
assert expected_artifact == str(build_path / f"{builder.project_id}-{get_python_versions_tag()}-none-any.whl")
extraction_directory = temp_dir / "_archive"
extraction_directory.mkdir()
with zipfile.ZipFile(str(expected_artifact), "r") as zip_archive:
zip_archive.extractall(str(extraction_directory))
metadata_directory = f"{builder.project_id}.dist-info"
expected_files = helpers.get_template_files(
"wheel.standard_default_license_multiple", project_name, metadata_directory=metadata_directory
)
helpers.assert_files(extraction_directory, expected_files)
def test_default_include(self, hatch, helpers, temp_dir, config_file):
config_file.model.template.plugins["default"]["src-layout"] = False
config_file.save()
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"
config = {
"project": {"name": project_name, "dynamic": ["version"]},
"tool": {
"hatch": {
"version": {"path": "my_app/__about__.py"},
"build": {"targets": {"wheel": {"versions": ["standard"], "include": ["my_app", "tests"]}}},
},
},
}
builder = WheelBuilder(str(project_path), config=config)
build_path = project_path / "dist"
build_path.mkdir()
with project_path.as_cwd():
artifacts = list(builder.build(directory=str(build_path)))
assert len(artifacts) == 1
expected_artifact = artifacts[0]
build_artifacts = list(build_path.iterdir())
assert len(build_artifacts) == 1
assert expected_artifact == str(build_artifacts[0])
assert expected_artifact == str(build_path / f"{builder.project_id}-{get_python_versions_tag()}-none-any.whl")
extraction_directory = temp_dir / "_archive"
extraction_directory.mkdir()
with zipfile.ZipFile(str(expected_artifact), "r") as zip_archive:
zip_archive.extractall(str(extraction_directory))
metadata_directory = f"{builder.project_id}.dist-info"
expected_files = helpers.get_template_files(
"wheel.standard_tests", project_name, metadata_directory=metadata_directory
)
helpers.assert_files(extraction_directory, expected_files)
def test_default_only_packages(self, hatch, helpers, temp_dir, config_file):
config_file.model.template.plugins["default"]["src-layout"] = False
config_file.save()
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"
tests_path = project_path / "tests"
(tests_path / "__init__.py").replace(tests_path / "foo.py")
config = {
"project": {"name": project_name, "dynamic": ["version"]},
"tool": {
"hatch": {
"version": {"path": "my_app/__about__.py"},
"build": {
"targets": {
"wheel": {"versions": ["standard"], "include": ["my_app", "tests"], "only-packages": True}
},
},
},
},
}
builder = WheelBuilder(str(project_path), config=config)
build_path = project_path / "dist"
build_path.mkdir()
with project_path.as_cwd():
artifacts = list(builder.build(directory=str(build_path)))
assert len(artifacts) == 1
expected_artifact = artifacts[0]
build_artifacts = list(build_path.iterdir())
assert len(build_artifacts) == 1
assert expected_artifact == str(build_artifacts[0])
assert expected_artifact == str(build_path / f"{builder.project_id}-{get_python_versions_tag()}-none-any.whl")
extraction_directory = temp_dir / "_archive"
extraction_directory.mkdir()
with zipfile.ZipFile(str(expected_artifact), "r") as zip_archive:
zip_archive.extractall(str(extraction_directory))
metadata_directory = f"{builder.project_id}.dist-info"
expected_files = helpers.get_template_files(
"wheel.standard_default_license_single", project_name, metadata_directory=metadata_directory
)
helpers.assert_files(extraction_directory, expected_files)
def test_default_only_packages_artifact_override(self, hatch, helpers, temp_dir, config_file):
config_file.model.template.plugins["default"]["src-layout"] = False
config_file.save()
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"
tests_path = project_path / "tests"
(tests_path / "__init__.py").replace(tests_path / "foo.py")
config = {
"project": {"name": project_name, "dynamic": ["version"]},
"tool": {
"hatch": {
"version": {"path": "my_app/__about__.py"},
"build": {
"artifacts": ["foo.py"],
"targets": {
"wheel": {"versions": ["standard"], "include": ["my_app", "tests"], "only-packages": True}
},
},
},
},
}
builder = WheelBuilder(str(project_path), config=config)
build_path = project_path / "dist"
build_path.mkdir()
with project_path.as_cwd():
artifacts = list(builder.build(directory=str(build_path)))
assert len(artifacts) == 1
expected_artifact = artifacts[0]
build_artifacts = list(build_path.iterdir())
assert len(build_artifacts) == 1
assert expected_artifact == str(build_artifacts[0])
assert expected_artifact == str(build_path / f"{builder.project_id}-{get_python_versions_tag()}-none-any.whl")
extraction_directory = temp_dir / "_archive"
extraction_directory.mkdir()
with zipfile.ZipFile(str(expected_artifact), "r") as zip_archive:
zip_archive.extractall(str(extraction_directory))
metadata_directory = f"{builder.project_id}.dist-info"
expected_files = helpers.get_template_files(
"wheel.standard_only_packages_artifact_override", project_name, metadata_directory=metadata_directory
)
helpers.assert_files(extraction_directory, expected_files)
@pytest.mark.parametrize(
("python_constraint", "expected_template_file"),
[
pytest.param(">3", "wheel.standard_default_python_constraint", id=">3"),
pytest.param("==3.11.4", "wheel.standard_default_python_constraint_three_components", id="==3.11.4"),
],
)
def test_default_python_constraint(
self, hatch, helpers, temp_dir, config_file, python_constraint, expected_template_file
):
config_file.model.template.plugins["default"]["src-layout"] = False
config_file.save()
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"
config = {
"project": {"name": project_name, "requires-python": python_constraint, "dynamic": ["version"]},
"tool": {
"hatch": {
"version": {"path": "my_app/__about__.py"},
"build": {"targets": {"wheel": {"versions": ["standard"]}}},
},
},
}
builder = WheelBuilder(str(project_path), config=config)
build_path = project_path / "dist"
build_path.mkdir()
with project_path.as_cwd():
artifacts = list(builder.build(directory=str(build_path)))
assert len(artifacts) == 1
expected_artifact = artifacts[0]
build_artifacts = list(build_path.iterdir())
assert len(build_artifacts) == 1
assert expected_artifact == str(build_artifacts[0])
assert expected_artifact == str(build_path / f"{builder.project_id}-py3-none-any.whl")
extraction_directory = temp_dir / "_archive"
extraction_directory.mkdir()
with zipfile.ZipFile(str(expected_artifact), "r") as zip_archive:
zip_archive.extractall(str(extraction_directory))
metadata_directory = f"{builder.project_id}.dist-info"
expected_files = helpers.get_template_files(
expected_template_file, project_name, metadata_directory=metadata_directory
)
helpers.assert_files(extraction_directory, expected_files)
def test_default_build_script_default_tag(self, hatch, helpers, temp_dir, config_file):
config_file.model.template.plugins["default"]["src-layout"] = False
config_file.save()
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"
build_script = project_path / DEFAULT_BUILD_SCRIPT
build_script.write_text(
helpers.dedent(
"""
from hatchling.builders.hooks.plugin.interface import BuildHookInterface
class CustomHook(BuildHookInterface):
pass
"""
)
)
config = {
"project": {"name": project_name, "requires-python": ">3", "dynamic": ["version"]},
"tool": {
"hatch": {
"version": {"path": "my_app/__about__.py"},
"build": {
"targets": {"wheel": {"versions": ["standard"]}},
"hooks": {"custom": {"path": DEFAULT_BUILD_SCRIPT}},
},
},
},
}
builder = WheelBuilder(str(project_path), config=config)
build_path = project_path / "dist"
build_path.mkdir()
with project_path.as_cwd():
artifacts = list(builder.build(directory=str(build_path)))
assert len(artifacts) == 1
expected_artifact = artifacts[0]
build_artifacts = list(build_path.iterdir())
assert len(build_artifacts) == 1
assert expected_artifact == str(build_artifacts[0])
tag = "py3-none-any"
assert expected_artifact == str(build_path / f"{builder.project_id}-{tag}.whl")
extraction_directory = temp_dir / "_archive"
extraction_directory.mkdir()
with zipfile.ZipFile(str(expected_artifact), "r") as zip_archive:
zip_archive.extractall(str(extraction_directory))
metadata_directory = f"{builder.project_id}.dist-info"
expected_files = helpers.get_template_files(
"wheel.standard_default_build_script", project_name, metadata_directory=metadata_directory, tag=tag
)
helpers.assert_files(extraction_directory, expected_files)
def test_default_build_script_set_tag(self, hatch, helpers, temp_dir, config_file):
config_file.model.template.plugins["default"]["src-layout"] = False
config_file.save()
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"
build_script = project_path / DEFAULT_BUILD_SCRIPT
build_script.write_text(
helpers.dedent(
"""
from hatchling.builders.hooks.plugin.interface import BuildHookInterface
class CustomHook(BuildHookInterface):
def initialize(self, version, build_data):
build_data['tag'] = 'foo-bar-baz'
"""
)
)
config = {
"project": {"name": project_name, "requires-python": ">3", "dynamic": ["version"]},
"tool": {
"hatch": {
"version": {"path": "my_app/__about__.py"},
"build": {
"targets": {"wheel": {"versions": ["standard"]}},
"hooks": {"custom": {"path": DEFAULT_BUILD_SCRIPT}},
},
},
},
}
builder = WheelBuilder(str(project_path), config=config)
build_path = project_path / "dist"
build_path.mkdir()
with project_path.as_cwd():
artifacts = list(builder.build(directory=str(build_path)))
assert len(artifacts) == 1
expected_artifact = artifacts[0]
build_artifacts = list(build_path.iterdir())
assert len(build_artifacts) == 1
assert expected_artifact == str(build_artifacts[0])
tag = "foo-bar-baz"
assert expected_artifact == str(build_path / f"{builder.project_id}-{tag}.whl")
extraction_directory = temp_dir / "_archive"
extraction_directory.mkdir()
with zipfile.ZipFile(str(expected_artifact), "r") as zip_archive:
zip_archive.extractall(str(extraction_directory))
metadata_directory = f"{builder.project_id}.dist-info"
expected_files = helpers.get_template_files(
"wheel.standard_default_build_script", project_name, metadata_directory=metadata_directory, tag=tag
)
helpers.assert_files(extraction_directory, expected_files)
def test_default_build_script_known_artifacts(self, hatch, helpers, temp_dir, config_file):
config_file.model.template.plugins["default"]["src-layout"] = False
config_file.save()
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"
vcs_ignore_file = project_path / ".gitignore"
vcs_ignore_file.write_text("*.pyc\n*.so\n*.h")
build_script = project_path / DEFAULT_BUILD_SCRIPT
build_script.write_text(
helpers.dedent(
"""
import pathlib
from hatchling.builders.hooks.plugin.interface import BuildHookInterface
class CustomHook(BuildHookInterface):
def initialize(self, version, build_data):
build_data['pure_python'] = False
build_data['infer_tag'] = True
pathlib.Path('my_app', 'lib.so').touch()
pathlib.Path('my_app', 'lib.h').touch()
"""
)
)
config = {
"project": {"name": project_name, "requires-python": ">3", "dynamic": ["version"]},
"tool": {
"hatch": {
"version": {"path": "my_app/__about__.py"},
"build": {
"targets": {"wheel": {"versions": ["standard"]}},
"artifacts": ["my_app/lib.so"],
"hooks": {"custom": {"path": DEFAULT_BUILD_SCRIPT}},
},
},
},
}
builder = WheelBuilder(str(project_path), config=config)
build_path = project_path / "dist"
build_path.mkdir()
with project_path.as_cwd():
artifacts = list(builder.build(directory=str(build_path)))
assert len(artifacts) == 1
expected_artifact = artifacts[0]
build_artifacts = list(build_path.iterdir())
assert len(build_artifacts) == 1
assert expected_artifact == str(build_artifacts[0])
best_matching_tag = next(sys_tags())
tag = f"{best_matching_tag.interpreter}-{best_matching_tag.abi}-{best_matching_tag.platform}"
assert expected_artifact == str(build_path / f"{builder.project_id}-{tag}.whl")
extraction_directory = temp_dir / "_archive"
extraction_directory.mkdir()
with zipfile.ZipFile(str(expected_artifact), "r") as zip_archive:
zip_archive.extractall(str(extraction_directory))
metadata_directory = f"{builder.project_id}.dist-info"
expected_files = helpers.get_template_files(
"wheel.standard_default_build_script_artifacts",
project_name,
metadata_directory=metadata_directory,
tag=tag,
)
helpers.assert_files(extraction_directory, expected_files)
def test_default_build_script_configured_build_hooks(self, hatch, helpers, temp_dir, config_file):
config_file.model.template.plugins["default"]["src-layout"] = False
config_file.save()
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"
vcs_ignore_file = project_path / ".gitignore"
vcs_ignore_file.write_text("*.pyc\n*.so\n*.h")
build_script = project_path / DEFAULT_BUILD_SCRIPT
build_script.write_text(
helpers.dedent(
"""
import pathlib
from hatchling.builders.hooks.plugin.interface import BuildHookInterface
class CustomHook(BuildHookInterface):
def initialize(self, version, build_data):
build_data['pure_python'] = False
build_data['infer_tag'] = True
pathlib.Path('my_app', 'lib.so').write_text(','.join(build_data['build_hooks']))
pathlib.Path('my_app', 'lib.h').write_text(','.join(build_data['build_hooks']))
"""
)
)
config = {
"project": {"name": project_name, "requires-python": ">3", "dynamic": ["version"]},
"tool": {
"hatch": {
"version": {"path": "my_app/__about__.py"},
"build": {
"targets": {"wheel": {"versions": ["standard"]}},
"artifacts": ["my_app/lib.so"],
"hooks": {"custom": {"path": DEFAULT_BUILD_SCRIPT}},
},
},
},
}
builder = WheelBuilder(str(project_path), config=config)
build_path = project_path / "dist"
build_path.mkdir()
with project_path.as_cwd():
artifacts = list(builder.build(directory=str(build_path)))
assert len(artifacts) == 1
expected_artifact = artifacts[0]
build_artifacts = list(build_path.iterdir())
assert len(build_artifacts) == 1
assert expected_artifact == str(build_artifacts[0])
best_matching_tag = next(sys_tags())
tag = f"{best_matching_tag.interpreter}-{best_matching_tag.abi}-{best_matching_tag.platform}"
assert expected_artifact == str(build_path / f"{builder.project_id}-{tag}.whl")
extraction_directory = temp_dir / "_archive"
extraction_directory.mkdir()
with zipfile.ZipFile(str(expected_artifact), "r") as zip_archive:
zip_archive.extractall(str(extraction_directory))
metadata_directory = f"{builder.project_id}.dist-info"
expected_files = helpers.get_template_files(
"wheel.standard_default_build_script_configured_build_hooks",
project_name,
metadata_directory=metadata_directory,
tag=tag,
)
helpers.assert_files(extraction_directory, expected_files)
def test_default_build_script_extra_dependencies(self, hatch, helpers, temp_dir, config_file):
config_file.model.template.plugins["default"]["src-layout"] = False
config_file.save()
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"
vcs_ignore_file = project_path / ".gitignore"
vcs_ignore_file.write_text("*.pyc\n*.so\n*.h")
build_script = project_path / DEFAULT_BUILD_SCRIPT
build_script.write_text(
helpers.dedent(
"""
import pathlib
from hatchling.builders.hooks.plugin.interface import BuildHookInterface
class CustomHook(BuildHookInterface):
def initialize(self, version, build_data):
build_data['pure_python'] = False
build_data['infer_tag'] = True
build_data['dependencies'].append('binary')
pathlib.Path('my_app', 'lib.so').touch()
pathlib.Path('my_app', 'lib.h').touch()
"""
)
)
config = {
"project": {"name": project_name, "requires-python": ">3", "dynamic": ["version"]},
"tool": {
"hatch": {
"version": {"path": "my_app/__about__.py"},
"build": {
"targets": {"wheel": {"versions": ["standard"]}},
"artifacts": ["my_app/lib.so"],
"hooks": {"custom": {"path": DEFAULT_BUILD_SCRIPT}},
},
},
},
}
builder = WheelBuilder(str(project_path), config=config)
build_path = project_path / "dist"
build_path.mkdir()
with project_path.as_cwd():
artifacts = list(builder.build(directory=str(build_path)))
assert len(artifacts) == 1
expected_artifact = artifacts[0]
build_artifacts = list(build_path.iterdir())
assert len(build_artifacts) == 1
assert expected_artifact == str(build_artifacts[0])
best_matching_tag = next(sys_tags())
tag = f"{best_matching_tag.interpreter}-{best_matching_tag.abi}-{best_matching_tag.platform}"
assert expected_artifact == str(build_path / f"{builder.project_id}-{tag}.whl")
extraction_directory = temp_dir / "_archive"
extraction_directory.mkdir()
with zipfile.ZipFile(str(expected_artifact), "r") as zip_archive:
zip_archive.extractall(str(extraction_directory))
metadata_directory = f"{builder.project_id}.dist-info"
expected_files = helpers.get_template_files(
"wheel.standard_default_build_script_extra_dependencies",
project_name,
metadata_directory=metadata_directory,
tag=tag,
)
helpers.assert_files(extraction_directory, expected_files)
def test_default_build_script_dynamic_artifacts(self, hatch, helpers, temp_dir, config_file):
config_file.model.template.plugins["default"]["src-layout"] = False
config_file.save()
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"
vcs_ignore_file = project_path / ".gitignore"
vcs_ignore_file.write_text("*.pyc\n*.so\n*.h")
build_script = project_path / DEFAULT_BUILD_SCRIPT
build_script.write_text(
helpers.dedent(
"""
import pathlib
from hatchling.builders.hooks.plugin.interface import BuildHookInterface
class CustomHook(BuildHookInterface):
def initialize(self, version, build_data):
build_data['pure_python'] = False
build_data['infer_tag'] = True
build_data['artifacts'] = ['my_app/lib.so']
pathlib.Path('my_app', 'lib.so').touch()
pathlib.Path('my_app', 'lib.h').touch()
"""
)
)
config = {
"project": {"name": project_name, "requires-python": ">3", "dynamic": ["version"]},
"tool": {
"hatch": {
"version": {"path": "my_app/__about__.py"},
"build": {
"targets": {"wheel": {"versions": ["standard"]}},
"hooks": {"custom": {"path": DEFAULT_BUILD_SCRIPT}},
},
},
},
}
builder = WheelBuilder(str(project_path), config=config)
build_path = project_path / "dist"
build_path.mkdir()
with project_path.as_cwd():
artifacts = list(builder.build(directory=str(build_path)))
assert len(artifacts) == 1
expected_artifact = artifacts[0]
build_artifacts = list(build_path.iterdir())
assert len(build_artifacts) == 1
assert expected_artifact == str(build_artifacts[0])
best_matching_tag = next(sys_tags())
tag = f"{best_matching_tag.interpreter}-{best_matching_tag.abi}-{best_matching_tag.platform}"
assert expected_artifact == str(build_path / f"{builder.project_id}-{tag}.whl")
extraction_directory = temp_dir / "_archive"
extraction_directory.mkdir()
with zipfile.ZipFile(str(expected_artifact), "r") as zip_archive:
zip_archive.extractall(str(extraction_directory))
metadata_directory = f"{builder.project_id}.dist-info"
expected_files = helpers.get_template_files(
"wheel.standard_default_build_script_artifacts",
project_name,
metadata_directory=metadata_directory,
tag=tag,
)
helpers.assert_files(extraction_directory, expected_files)
def test_default_build_script_dynamic_force_include(self, hatch, helpers, temp_dir, config_file):
config_file.model.template.plugins["default"]["src-layout"] = False
config_file.save()
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"
vcs_ignore_file = project_path / ".gitignore"
vcs_ignore_file.write_text("*.pyc\n*.so\n*.h")
build_script = project_path / DEFAULT_BUILD_SCRIPT
build_script.write_text(
helpers.dedent(
"""
import pathlib
from hatchling.builders.hooks.plugin.interface import BuildHookInterface
class CustomHook(BuildHookInterface):
def initialize(self, version, build_data):
build_data['pure_python'] = False
build_data['infer_tag'] = True
build_data['artifacts'].extend(('lib.so', 'lib.h'))
build_data['force_include']['../artifacts'] = 'my_app'
artifact_path = pathlib.Path('..', 'artifacts')
artifact_path.mkdir()
(artifact_path / 'lib.so').touch()
(artifact_path / 'lib.h').touch()
"""
)
)
config = {
"project": {"name": project_name, "requires-python": ">3", "dynamic": ["version"]},
"tool": {
"hatch": {
"version": {"path": "my_app/__about__.py"},
"build": {
"targets": {"wheel": {"versions": ["standard"]}},
"hooks": {"custom": {"path": DEFAULT_BUILD_SCRIPT}},
},
},
},
}
builder = WheelBuilder(str(project_path), config=config)
build_path = project_path / "dist"
build_path.mkdir()
with project_path.as_cwd():
artifacts = list(builder.build(directory=str(build_path)))
assert len(artifacts) == 1
expected_artifact = artifacts[0]
build_artifacts = list(build_path.iterdir())
assert len(build_artifacts) == 1
assert expected_artifact == str(build_artifacts[0])
best_matching_tag = next(sys_tags())
tag = f"{best_matching_tag.interpreter}-{best_matching_tag.abi}-{best_matching_tag.platform}"
assert expected_artifact == str(build_path / f"{builder.project_id}-{tag}.whl")
extraction_directory = temp_dir / "_archive"
extraction_directory.mkdir()
with zipfile.ZipFile(str(expected_artifact), "r") as zip_archive:
zip_archive.extractall(str(extraction_directory))
metadata_directory = f"{builder.project_id}.dist-info"
expected_files = helpers.get_template_files(
"wheel.standard_default_build_script_force_include",
project_name,
metadata_directory=metadata_directory,
tag=tag,
)
helpers.assert_files(extraction_directory, expected_files)
def test_default_build_script_dynamic_force_include_duplicate(self, hatch, helpers, temp_dir, config_file):
config_file.model.template.plugins["default"]["src-layout"] = False
config_file.save()
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"
vcs_ignore_file = project_path / ".gitignore"
vcs_ignore_file.write_text("*.pyc\n*.so\n*.h")
target_file = project_path / "my_app" / "z.py"
target_file.write_text('print("hello world")')
build_script = project_path / DEFAULT_BUILD_SCRIPT
build_script.write_text(
helpers.dedent(
"""
import pathlib
from hatchling.builders.hooks.plugin.interface import BuildHookInterface
class CustomHook(BuildHookInterface):
def initialize(self, version, build_data):
build_data['pure_python'] = False
build_data['infer_tag'] = True
build_data['force_include']['../tmp/new_z.py'] = 'my_app/z.py'
tmp_path = pathlib.Path('..', 'tmp')
tmp_path.mkdir()
(tmp_path / 'new_z.py').write_bytes(pathlib.Path('my_app/z.py').read_bytes())
"""
)
)
config = {
"project": {"name": project_name, "requires-python": ">3", "dynamic": ["version"]},
"tool": {
"hatch": {
"version": {"path": "my_app/__about__.py"},
"build": {
"targets": {"wheel": {"versions": ["standard"]}},
"hooks": {"custom": {"path": DEFAULT_BUILD_SCRIPT}},
},
},
},
}
builder = WheelBuilder(str(project_path), config=config)
build_path = project_path / "dist"
build_path.mkdir()
with project_path.as_cwd():
artifacts = list(builder.build(directory=str(build_path)))
assert len(artifacts) == 1
expected_artifact = artifacts[0]
build_artifacts = list(build_path.iterdir())
assert len(build_artifacts) == 1
assert expected_artifact == str(build_artifacts[0])
best_matching_tag = next(sys_tags())
tag = f"{best_matching_tag.interpreter}-{best_matching_tag.abi}-{best_matching_tag.platform}"
assert expected_artifact == str(build_path / f"{builder.project_id}-{tag}.whl")
extraction_directory = temp_dir / "_archive"
extraction_directory.mkdir()
with zipfile.ZipFile(str(expected_artifact), "r") as zip_archive:
zip_archive.extractall(str(extraction_directory))
metadata_directory = f"{builder.project_id}.dist-info"
expected_files = helpers.get_template_files(
"wheel.standard_default_build_script_force_include_no_duplication",
project_name,
metadata_directory=metadata_directory,
tag=tag,
)
helpers.assert_files(extraction_directory, expected_files)
def test_default_build_script_dynamic_artifacts_with_src_layout(self, hatch, helpers, temp_dir):
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"
vcs_ignore_file = project_path / ".gitignore"
vcs_ignore_file.write_text("*.pyc\n*.so\n*.pyd\n*.h")
build_script = project_path / DEFAULT_BUILD_SCRIPT
build_script.write_text(
helpers.dedent(
"""
import pathlib
from hatchling.builders.hooks.plugin.interface import BuildHookInterface
class CustomHook(BuildHookInterface):
def initialize(self, version, build_data):
build_data['pure_python'] = False
build_data['infer_tag'] = True
build_data['artifacts'] = ['src/my_app/lib.so']
build_data['force_include']['src/zlib.pyd'] = 'src/zlib.pyd'
pathlib.Path('src', 'my_app', 'lib.so').touch()
pathlib.Path('src', 'lib.h').touch()
pathlib.Path('src', 'zlib.pyd').touch()
"""
)
)
config = {
"project": {"name": project_name, "requires-python": ">3", "dynamic": ["version"]},
"tool": {
"hatch": {
"version": {"path": "src/my_app/__about__.py"},
"build": {
"targets": {"wheel": {"versions": ["standard"]}},
"hooks": {"custom": {"path": DEFAULT_BUILD_SCRIPT}},
},
},
},
}
builder = WheelBuilder(str(project_path), config=config)
build_path = project_path / "dist"
build_path.mkdir()
with project_path.as_cwd():
artifacts = list(builder.build(directory=str(build_path)))
assert len(artifacts) == 1
expected_artifact = artifacts[0]
build_artifacts = list(build_path.iterdir())
assert len(build_artifacts) == 1
assert expected_artifact == str(build_artifacts[0])
best_matching_tag = next(sys_tags())
tag = f"{best_matching_tag.interpreter}-{best_matching_tag.abi}-{best_matching_tag.platform}"
assert expected_artifact == str(build_path / f"{builder.project_id}-{tag}.whl")
extraction_directory = temp_dir / "_archive"
extraction_directory.mkdir()
with zipfile.ZipFile(str(expected_artifact), "r") as zip_archive:
zip_archive.extractall(str(extraction_directory))
metadata_directory = f"{builder.project_id}.dist-info"
expected_files = helpers.get_template_files(
"wheel.standard_default_build_script_artifacts_with_src_layout",
project_name,
metadata_directory=metadata_directory,
tag=tag,
)
helpers.assert_files(extraction_directory, expected_files)
def test_default_shared_data(self, hatch, helpers, temp_dir, config_file):
config_file.model.template.plugins["default"]["src-layout"] = False
config_file.save()
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"
shared_data_path = temp_dir / "data"
shared_data_path.ensure_dir_exists()
(shared_data_path / "foo.txt").touch()
nested_data_path = shared_data_path / "nested"
nested_data_path.ensure_dir_exists()
(nested_data_path / "bar.txt").touch()
config = {
"project": {"name": project_name, "requires-python": ">3", "dynamic": ["version"]},
"tool": {
"hatch": {
"version": {"path": "my_app/__about__.py"},
"build": {"targets": {"wheel": {"versions": ["standard"], "shared-data": {"../data": "/"}}}},
},
},
}
builder = WheelBuilder(str(project_path), config=config)
build_path = project_path / "dist"
build_path.mkdir()
with project_path.as_cwd():
artifacts = list(builder.build(directory=str(build_path)))
assert len(artifacts) == 1
expected_artifact = artifacts[0]
build_artifacts = list(build_path.iterdir())
assert len(build_artifacts) == 1
assert expected_artifact == str(build_artifacts[0])
extraction_directory = temp_dir / "_archive"
extraction_directory.mkdir()
with zipfile.ZipFile(str(expected_artifact), "r") as zip_archive:
zip_archive.extractall(str(extraction_directory))
metadata_directory = f"{builder.project_id}.dist-info"
shared_data_directory = f"{builder.project_id}.data"
expected_files = helpers.get_template_files(
"wheel.standard_default_shared_data",
project_name,
metadata_directory=metadata_directory,
shared_data_directory=shared_data_directory,
)
helpers.assert_files(extraction_directory, expected_files)
def test_default_shared_data_from_build_data(self, hatch, helpers, temp_dir, config_file):
config_file.model.template.plugins["default"]["src-layout"] = False
config_file.save()
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"
shared_data_path = temp_dir / "data"
shared_data_path.ensure_dir_exists()
(shared_data_path / "foo.txt").touch()
nested_data_path = shared_data_path / "nested"
nested_data_path.ensure_dir_exists()
(nested_data_path / "bar.txt").touch()
build_script = project_path / DEFAULT_BUILD_SCRIPT
build_script.write_text(
helpers.dedent(
"""
import pathlib
from hatchling.builders.hooks.plugin.interface import BuildHookInterface
class CustomHook(BuildHookInterface):
def initialize(self, version, build_data):
build_data['shared_data']['../data'] = '/'
"""
)
)
config = {
"project": {"name": project_name, "requires-python": ">3", "dynamic": ["version"]},
"tool": {
"hatch": {
"version": {"path": "my_app/__about__.py"},
"build": {"targets": {"wheel": {"versions": ["standard"], "hooks": {"custom": {}}}}},
},
},
}
builder = WheelBuilder(str(project_path), config=config)
build_path = project_path / "dist"
build_path.mkdir()
with project_path.as_cwd():
artifacts = list(builder.build(directory=str(build_path)))
assert len(artifacts) == 1
expected_artifact = artifacts[0]
build_artifacts = list(build_path.iterdir())
assert len(build_artifacts) == 1
assert expected_artifact == str(build_artifacts[0])
extraction_directory = temp_dir / "_archive"
extraction_directory.mkdir()
with zipfile.ZipFile(str(expected_artifact), "r") as zip_archive:
zip_archive.extractall(str(extraction_directory))
metadata_directory = f"{builder.project_id}.dist-info"
shared_data_directory = f"{builder.project_id}.data"
expected_files = helpers.get_template_files(
"wheel.standard_default_shared_data",
project_name,
metadata_directory=metadata_directory,
shared_data_directory=shared_data_directory,
)
helpers.assert_files(extraction_directory, expected_files)
def test_default_shared_scripts(self, hatch, platform, helpers, temp_dir, config_file):
config_file.model.template.plugins["default"]["src-layout"] = False
config_file.save()
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"
shared_data_path = temp_dir / "data"
shared_data_path.ensure_dir_exists()
binary_contents = os.urandom(1024)
binary_file = shared_data_path / "binary"
binary_file.write_bytes(binary_contents)
if not platform.windows:
expected_mode = 0o755
binary_file.chmod(expected_mode)
(shared_data_path / "other_script.sh").write_text(
helpers.dedent(
"""
#!/bin/sh arg1 arg2
echo "Hello, World!"
"""
)
)
(shared_data_path / "python_script.sh").write_text(
helpers.dedent(
"""
#!/usr/bin/env python3.11 arg1 arg2
print("Hello, World!")
"""
)
)
(shared_data_path / "pythonw_script.sh").write_text(
helpers.dedent(
"""
#!/usr/bin/pythonw3.11 arg1 arg2
print("Hello, World!")
"""
)
)
(shared_data_path / "pypy_script.sh").write_text(
helpers.dedent(
"""
#!/usr/bin/env pypy
print("Hello, World!")
"""
)
)
(shared_data_path / "pypyw_script.sh").write_text(
helpers.dedent(
"""
#!pypyw3.11 arg1 arg2
print("Hello, World!")
"""
)
)
config = {
"project": {"name": project_name, "requires-python": ">3", "dynamic": ["version"]},
"tool": {
"hatch": {
"version": {"path": "my_app/__about__.py"},
"build": {"targets": {"wheel": {"versions": ["standard"], "shared-scripts": {"../data": "/"}}}},
},
},
}
builder = WheelBuilder(str(project_path), config=config)
build_path = project_path / "dist"
build_path.mkdir()
with project_path.as_cwd():
artifacts = list(builder.build(directory=str(build_path)))
assert len(artifacts) == 1
expected_artifact = artifacts[0]
build_artifacts = list(build_path.iterdir())
assert len(build_artifacts) == 1
assert expected_artifact == str(build_artifacts[0])
extraction_directory = temp_dir / "_archive"
extract_zip(expected_artifact, extraction_directory)
metadata_directory = f"{builder.project_id}.dist-info"
shared_data_directory = f"{builder.project_id}.data"
expected_files = helpers.get_template_files(
"wheel.standard_default_shared_scripts",
project_name,
metadata_directory=metadata_directory,
shared_data_directory=shared_data_directory,
binary_contents=binary_contents,
)
helpers.assert_files(extraction_directory, expected_files)
if not platform.windows:
extracted_binary = extraction_directory / shared_data_directory / "scripts" / "binary"
assert extracted_binary.stat().st_mode & 0o777 == expected_mode
def test_default_shared_scripts_from_build_data(self, hatch, platform, helpers, temp_dir, config_file):
config_file.model.template.plugins["default"]["src-layout"] = False
config_file.save()
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"
shared_data_path = temp_dir / "data"
shared_data_path.ensure_dir_exists()
binary_contents = os.urandom(1024)
binary_file = shared_data_path / "binary"
binary_file.write_bytes(binary_contents)
if not platform.windows:
expected_mode = 0o755
binary_file.chmod(expected_mode)
(shared_data_path / "other_script.sh").write_text(
helpers.dedent(
"""
#!/bin/sh arg1 arg2
echo "Hello, World!"
"""
)
)
(shared_data_path / "python_script.sh").write_text(
helpers.dedent(
"""
#!/usr/bin/env python3.11 arg1 arg2
print("Hello, World!")
"""
)
)
(shared_data_path / "pythonw_script.sh").write_text(
helpers.dedent(
"""
#!/usr/bin/pythonw3.11 arg1 arg2
print("Hello, World!")
"""
)
)
(shared_data_path / "pypy_script.sh").write_text(
helpers.dedent(
"""
#!/usr/bin/env pypy
print("Hello, World!")
"""
)
)
(shared_data_path / "pypyw_script.sh").write_text(
helpers.dedent(
"""
#!pypyw3.11 arg1 arg2
print("Hello, World!")
"""
)
)
build_script = project_path / DEFAULT_BUILD_SCRIPT
build_script.write_text(
helpers.dedent(
"""
import pathlib
from hatchling.builders.hooks.plugin.interface import BuildHookInterface
class CustomHook(BuildHookInterface):
def initialize(self, version, build_data):
build_data['shared_scripts']['../data'] = '/'
"""
)
)
config = {
"project": {"name": project_name, "requires-python": ">3", "dynamic": ["version"]},
"tool": {
"hatch": {
"version": {"path": "my_app/__about__.py"},
"build": {"targets": {"wheel": {"versions": ["standard"], "hooks": {"custom": {}}}}},
},
},
}
builder = WheelBuilder(str(project_path), config=config)
build_path = project_path / "dist"
build_path.mkdir()
with project_path.as_cwd():
artifacts = list(builder.build(directory=str(build_path)))
assert len(artifacts) == 1
expected_artifact = artifacts[0]
build_artifacts = list(build_path.iterdir())
assert len(build_artifacts) == 1
assert expected_artifact == str(build_artifacts[0])
extraction_directory = temp_dir / "_archive"
extract_zip(expected_artifact, extraction_directory)
metadata_directory = f"{builder.project_id}.dist-info"
shared_data_directory = f"{builder.project_id}.data"
expected_files = helpers.get_template_files(
"wheel.standard_default_shared_scripts",
project_name,
metadata_directory=metadata_directory,
shared_data_directory=shared_data_directory,
binary_contents=binary_contents,
)
helpers.assert_files(extraction_directory, expected_files)
if not platform.windows:
extracted_binary = extraction_directory / shared_data_directory / "scripts" / "binary"
assert extracted_binary.stat().st_mode & 0o777 == expected_mode
def test_default_extra_metadata(self, hatch, helpers, temp_dir, config_file):
config_file.model.template.plugins["default"]["src-layout"] = False
config_file.save()
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"
extra_metadata_path = temp_dir / "data"
extra_metadata_path.ensure_dir_exists()
(extra_metadata_path / "foo.txt").touch()
nested_data_path = extra_metadata_path / "nested"
nested_data_path.ensure_dir_exists()
(nested_data_path / "bar.txt").touch()
config = {
"project": {"name": project_name, "requires-python": ">3", "dynamic": ["version"]},
"tool": {
"hatch": {
"version": {"path": "my_app/__about__.py"},
"build": {"targets": {"wheel": {"versions": ["standard"], "extra-metadata": {"../data": "/"}}}},
},
},
}
builder = WheelBuilder(str(project_path), config=config)
build_path = project_path / "dist"
build_path.mkdir()
with project_path.as_cwd():
artifacts = list(builder.build(directory=str(build_path)))
assert len(artifacts) == 1
expected_artifact = artifacts[0]
build_artifacts = list(build_path.iterdir())
assert len(build_artifacts) == 1
assert expected_artifact == str(build_artifacts[0])
extraction_directory = temp_dir / "_archive"
extraction_directory.mkdir()
with zipfile.ZipFile(str(expected_artifact), "r") as zip_archive:
zip_archive.extractall(str(extraction_directory))
metadata_directory = f"{builder.project_id}.dist-info"
expected_files = helpers.get_template_files(
"wheel.standard_default_extra_metadata",
project_name,
metadata_directory=metadata_directory,
)
helpers.assert_files(extraction_directory, expected_files)
def test_default_extra_metadata_build_data(self, hatch, helpers, temp_dir, config_file):
config_file.model.template.plugins["default"]["src-layout"] = False
config_file.save()
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"
extra_metadata_path = temp_dir / "data"
extra_metadata_path.ensure_dir_exists()
(extra_metadata_path / "foo.txt").touch()
nested_data_path = extra_metadata_path / "nested"
nested_data_path.ensure_dir_exists()
(nested_data_path / "bar.txt").touch()
build_script = project_path / DEFAULT_BUILD_SCRIPT
build_script.write_text(
helpers.dedent(
"""
import pathlib
from hatchling.builders.hooks.plugin.interface import BuildHookInterface
class CustomHook(BuildHookInterface):
def initialize(self, version, build_data):
build_data['extra_metadata']['../data'] = '/'
"""
)
)
config = {
"project": {"name": project_name, "requires-python": ">3", "dynamic": ["version"]},
"tool": {
"hatch": {
"version": {"path": "my_app/__about__.py"},
"build": {"targets": {"wheel": {"versions": ["standard"], "hooks": {"custom": {}}}}},
},
},
}
builder = WheelBuilder(str(project_path), config=config)
build_path = project_path / "dist"
build_path.mkdir()
with project_path.as_cwd():
artifacts = list(builder.build(directory=str(build_path)))
assert len(artifacts) == 1
expected_artifact = artifacts[0]
build_artifacts = list(build_path.iterdir())
assert len(build_artifacts) == 1
assert expected_artifact == str(build_artifacts[0])
extraction_directory = temp_dir / "_archive"
extraction_directory.mkdir()
with zipfile.ZipFile(str(expected_artifact), "r") as zip_archive:
zip_archive.extractall(str(extraction_directory))
metadata_directory = f"{builder.project_id}.dist-info"
expected_files = helpers.get_template_files(
"wheel.standard_default_extra_metadata",
project_name,
metadata_directory=metadata_directory,
)
helpers.assert_files(extraction_directory, expected_files)
@pytest.mark.requires_unix
def test_default_symlink(self, hatch, helpers, temp_dir, config_file):
config_file.model.template.plugins["default"]["src-layout"] = False
config_file.save()
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"
vcs_ignore_file = project_path / ".gitignore"
vcs_ignore_file.write_text("*.pyc\n*.so\n*.h")
(temp_dir / "foo.so").write_bytes(b"data")
build_script = project_path / DEFAULT_BUILD_SCRIPT
build_script.write_text(
helpers.dedent(
"""
import os
import pathlib
from hatchling.builders.hooks.plugin.interface import BuildHookInterface
class CustomHook(BuildHookInterface):
def initialize(self, version, build_data):
build_data['pure_python'] = False
build_data['infer_tag'] = True
pathlib.Path('my_app', 'lib.so').symlink_to(os.path.abspath(os.path.join('..', 'foo.so')))
pathlib.Path('my_app', 'lib.h').touch()
"""
)
)
config = {
"project": {"name": project_name, "requires-python": ">3", "dynamic": ["version"]},
"tool": {
"hatch": {
"version": {"path": "my_app/__about__.py"},
"build": {
"targets": {"wheel": {"versions": ["standard"]}},
"artifacts": ["my_app/lib.so"],
"hooks": {"custom": {"path": DEFAULT_BUILD_SCRIPT}},
},
},
},
}
builder = WheelBuilder(str(project_path), config=config)
build_path = project_path / "dist"
build_path.mkdir()
with project_path.as_cwd():
artifacts = list(builder.build(directory=str(build_path)))
assert len(artifacts) == 1
expected_artifact = artifacts[0]
build_artifacts = list(build_path.iterdir())
assert len(build_artifacts) == 1
assert expected_artifact == str(build_artifacts[0])
best_matching_tag = next(sys_tags())
tag = f"{best_matching_tag.interpreter}-{best_matching_tag.abi}-{best_matching_tag.platform}"
assert expected_artifact == str(build_path / f"{builder.project_id}-{tag}.whl")
extraction_directory = temp_dir / "_archive"
extraction_directory.mkdir()
with zipfile.ZipFile(str(expected_artifact), "r") as zip_archive:
zip_archive.extractall(str(extraction_directory))
metadata_directory = f"{builder.project_id}.dist-info"
expected_files = helpers.get_template_files(
"wheel.standard_default_symlink",
project_name,
metadata_directory=metadata_directory,
tag=tag,
)
helpers.assert_files(extraction_directory, expected_files)
@fixed_pathlib_resolution
def test_editable_default(self, hatch, helpers, temp_dir):
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"
config = {
"project": {"name": project_name, "dynamic": ["version"]},
"tool": {
"hatch": {
"version": {"path": "src/my_app/__about__.py"},
"build": {"targets": {"wheel": {"versions": ["editable"]}}},
},
},
}
builder = WheelBuilder(str(project_path), config=config)
build_path = project_path / "dist"
build_path.mkdir()
with project_path.as_cwd():
artifacts = list(builder.build(directory=str(build_path)))
assert len(artifacts) == 1
expected_artifact = artifacts[0]
build_artifacts = list(build_path.iterdir())
assert len(build_artifacts) == 1
assert expected_artifact == str(build_artifacts[0])
assert expected_artifact == str(build_path / f"{builder.project_id}-{get_python_versions_tag()}-none-any.whl")
extraction_directory = temp_dir / "_archive"
extraction_directory.mkdir()
with zipfile.ZipFile(str(expected_artifact), "r") as zip_archive:
zip_archive.extractall(str(extraction_directory))
metadata_directory = f"{builder.project_id}.dist-info"
expected_files = helpers.get_template_files(
"wheel.standard_editable_pth",
project_name,
metadata_directory=metadata_directory,
package_paths=[str(project_path / "src")],
)
helpers.assert_files(extraction_directory, expected_files)
# Inspect the archive rather than the extracted files because on Windows they lose their metadata
# https://stackoverflow.com/q/9813243
with zipfile.ZipFile(str(expected_artifact), "r") as zip_archive:
zip_info = zip_archive.getinfo(f"{metadata_directory}/WHEEL")
assert zip_info.date_time == (2020, 2, 2, 0, 0, 0)
@fixed_pathlib_resolution
def test_editable_default_extra_dependencies(self, hatch, helpers, temp_dir):
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"
build_script = project_path / DEFAULT_BUILD_SCRIPT
build_script.write_text(
helpers.dedent(
"""
import pathlib
from hatchling.builders.hooks.plugin.interface import BuildHookInterface
class CustomHook(BuildHookInterface):
def initialize(self, version, build_data):
build_data['dependencies'].append('binary')
"""
)
)
config = {
"project": {"name": project_name, "dynamic": ["version"]},
"tool": {
"hatch": {
"version": {"path": "src/my_app/__about__.py"},
"build": {"targets": {"wheel": {"versions": ["editable"], "hooks": {"custom": {}}}}},
},
},
}
builder = WheelBuilder(str(project_path), config=config)
build_path = project_path / "dist"
build_path.mkdir()
with project_path.as_cwd():
artifacts = list(builder.build(directory=str(build_path)))
assert len(artifacts) == 1
expected_artifact = artifacts[0]
build_artifacts = list(build_path.iterdir())
assert len(build_artifacts) == 1
assert expected_artifact == str(build_artifacts[0])
assert expected_artifact == str(build_path / f"{builder.project_id}-{get_python_versions_tag()}-none-any.whl")
extraction_directory = temp_dir / "_archive"
extraction_directory.mkdir()
with zipfile.ZipFile(str(expected_artifact), "r") as zip_archive:
zip_archive.extractall(str(extraction_directory))
metadata_directory = f"{builder.project_id}.dist-info"
expected_files = helpers.get_template_files(
"wheel.standard_editable_pth_extra_dependencies",
project_name,
metadata_directory=metadata_directory,
package_paths=[str(project_path / "src")],
)
helpers.assert_files(extraction_directory, expected_files)
# Inspect the archive rather than the extracted files because on Windows they lose their metadata
# https://stackoverflow.com/q/9813243
with zipfile.ZipFile(str(expected_artifact), "r") as zip_archive:
zip_info = zip_archive.getinfo(f"{metadata_directory}/WHEEL")
assert zip_info.date_time == (2020, 2, 2, 0, 0, 0)
@fixed_pathlib_resolution
def test_editable_default_force_include(self, hatch, helpers, temp_dir):
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"
build_script = project_path / DEFAULT_BUILD_SCRIPT
build_script.write_text(
helpers.dedent(
"""
import pathlib
from hatchling.builders.hooks.plugin.interface import BuildHookInterface
class CustomHook(BuildHookInterface):
def initialize(self, version, build_data):
# Prefix z just to satisfy our ordering test assertion
build_data['force_include_editable']['src/my_app/__about__.py'] = 'zfoo.py'
"""
)
)
config = {
"project": {"name": project_name, "dynamic": ["version"]},
"tool": {
"hatch": {
"version": {"path": "src/my_app/__about__.py"},
"build": {"targets": {"wheel": {"versions": ["editable"], "hooks": {"custom": {}}}}},
},
},
}
builder = WheelBuilder(str(project_path), config=config)
build_path = project_path / "dist"
build_path.mkdir()
with project_path.as_cwd():
artifacts = list(builder.build(directory=str(build_path)))
assert len(artifacts) == 1
expected_artifact = artifacts[0]
build_artifacts = list(build_path.iterdir())
assert len(build_artifacts) == 1
assert expected_artifact == str(build_artifacts[0])
assert expected_artifact == str(build_path / f"{builder.project_id}-{get_python_versions_tag()}-none-any.whl")
extraction_directory = temp_dir / "_archive"
extraction_directory.mkdir()
with zipfile.ZipFile(str(expected_artifact), "r") as zip_archive:
zip_archive.extractall(str(extraction_directory))
metadata_directory = f"{builder.project_id}.dist-info"
expected_files = helpers.get_template_files(
"wheel.standard_editable_pth_force_include",
project_name,
metadata_directory=metadata_directory,
package_paths=[str(project_path / "src")],
)
helpers.assert_files(extraction_directory, expected_files)
# Inspect the archive rather than the extracted files because on Windows they lose their metadata
# https://stackoverflow.com/q/9813243
with zipfile.ZipFile(str(expected_artifact), "r") as zip_archive:
zip_info = zip_archive.getinfo(f"{metadata_directory}/WHEEL")
assert zip_info.date_time == (2020, 2, 2, 0, 0, 0)
@fixed_pathlib_resolution
def test_editable_default_force_include_option(self, hatch, helpers, temp_dir):
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"
config = {
"project": {"name": project_name, "dynamic": ["version"]},
"tool": {
"hatch": {
"version": {"path": "src/my_app/__about__.py"},
"build": {
"targets": {
"wheel": {
"versions": ["editable"],
"force-include": {"src/my_app/__about__.py": "zfoo.py"},
}
}
},
},
},
}
builder = WheelBuilder(str(project_path), config=config)
build_path = project_path / "dist"
build_path.mkdir()
with project_path.as_cwd():
artifacts = list(builder.build(directory=str(build_path)))
assert len(artifacts) == 1
expected_artifact = artifacts[0]
build_artifacts = list(build_path.iterdir())
assert len(build_artifacts) == 1
assert expected_artifact == str(build_artifacts[0])
assert expected_artifact == str(build_path / f"{builder.project_id}-{get_python_versions_tag()}-none-any.whl")
extraction_directory = temp_dir / "_archive"
extraction_directory.mkdir()
with zipfile.ZipFile(str(expected_artifact), "r") as zip_archive:
zip_archive.extractall(str(extraction_directory))
metadata_directory = f"{builder.project_id}.dist-info"
expected_files = helpers.get_template_files(
"wheel.standard_editable_pth_force_include",
project_name,
metadata_directory=metadata_directory,
package_paths=[str(project_path / "src")],
)
helpers.assert_files(extraction_directory, expected_files)
# Inspect the archive rather than the extracted files because on Windows they lose their metadata
# https://stackoverflow.com/q/9813243
with zipfile.ZipFile(str(expected_artifact), "r") as zip_archive:
zip_info = zip_archive.getinfo(f"{metadata_directory}/WHEEL")
assert zip_info.date_time == (2020, 2, 2, 0, 0, 0)
@pytest.mark.requires_unix
def test_editable_default_symlink(self, hatch, helpers, temp_dir):
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"
symlink = project_path / "_" / "my_app"
symlink.parent.ensure_dir_exists()
symlink.symlink_to(project_path / "src" / "my_app")
config = {
"project": {"name": project_name, "dynamic": ["version"]},
"tool": {
"hatch": {
"version": {"path": "src/my_app/__about__.py"},
"build": {"targets": {"wheel": {"versions": ["editable"]}}},
},
},
}
builder = WheelBuilder(str(project_path), config=config)
build_path = project_path / "dist"
build_path.mkdir()
with project_path.as_cwd():
artifacts = list(builder.build(directory=str(build_path)))
assert len(artifacts) == 1
expected_artifact = artifacts[0]
build_artifacts = list(build_path.iterdir())
assert len(build_artifacts) == 1
assert expected_artifact == str(build_artifacts[0])
assert expected_artifact == str(build_path / f"{builder.project_id}-{get_python_versions_tag()}-none-any.whl")
extraction_directory = temp_dir / "_archive"
extraction_directory.mkdir()
with zipfile.ZipFile(str(expected_artifact), "r") as zip_archive:
zip_archive.extractall(str(extraction_directory))
metadata_directory = f"{builder.project_id}.dist-info"
expected_files = helpers.get_template_files(
"wheel.standard_editable_pth",
project_name,
metadata_directory=metadata_directory,
package_paths=[str(project_path / "src")],
)
helpers.assert_files(extraction_directory, expected_files)
# Inspect the archive rather than the extracted files because on Windows they lose their metadata
# https://stackoverflow.com/q/9813243
with zipfile.ZipFile(str(expected_artifact), "r") as zip_archive:
zip_info = zip_archive.getinfo(f"{metadata_directory}/WHEEL")
assert zip_info.date_time == (2020, 2, 2, 0, 0, 0)
@fixed_pathlib_resolution
def test_editable_exact(self, hatch, helpers, temp_dir, config_file):
config_file.model.template.plugins["default"]["src-layout"] = False
config_file.save()
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"
config = {
"project": {"name": project_name, "dynamic": ["version"]},
"tool": {
"hatch": {
"version": {"path": "my_app/__about__.py"},
"build": {"targets": {"wheel": {"versions": ["editable"], "dev-mode-exact": True}}},
},
},
}
builder = WheelBuilder(str(project_path), config=config)
build_path = project_path / "dist"
build_path.mkdir()
with project_path.as_cwd():
artifacts = list(builder.build(directory=str(build_path)))
assert len(artifacts) == 1
expected_artifact = artifacts[0]
build_artifacts = list(build_path.iterdir())
assert len(build_artifacts) == 1
assert expected_artifact == str(build_artifacts[0])
assert expected_artifact == str(build_path / f"{builder.project_id}-{get_python_versions_tag()}-none-any.whl")
extraction_directory = temp_dir / "_archive"
extraction_directory.mkdir()
with zipfile.ZipFile(str(expected_artifact), "r") as zip_archive:
zip_archive.extractall(str(extraction_directory))
metadata_directory = f"{builder.project_id}.dist-info"
expected_files = helpers.get_template_files(
"wheel.standard_editable_exact",
project_name,
metadata_directory=metadata_directory,
package_root=str(project_path / "my_app" / "__init__.py"),
)
helpers.assert_files(extraction_directory, expected_files)
# Inspect the archive rather than the extracted files because on Windows they lose their metadata
# https://stackoverflow.com/q/9813243
with zipfile.ZipFile(str(expected_artifact), "r") as zip_archive:
zip_info = zip_archive.getinfo(f"{metadata_directory}/WHEEL")
assert zip_info.date_time == (2020, 2, 2, 0, 0, 0)
@fixed_pathlib_resolution
def test_editable_exact_extra_dependencies(self, hatch, helpers, temp_dir, config_file):
config_file.model.template.plugins["default"]["src-layout"] = False
config_file.save()
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"
build_script = project_path / DEFAULT_BUILD_SCRIPT
build_script.write_text(
helpers.dedent(
"""
import pathlib
from hatchling.builders.hooks.plugin.interface import BuildHookInterface
class CustomHook(BuildHookInterface):
def initialize(self, version, build_data):
build_data['dependencies'].append('binary')
"""
)
)
config = {
"project": {"name": project_name, "dynamic": ["version"]},
"tool": {
"hatch": {
"version": {"path": "my_app/__about__.py"},
"build": {
"targets": {
"wheel": {"versions": ["editable"], "dev-mode-exact": True, "hooks": {"custom": {}}}
}
},
},
},
}
builder = WheelBuilder(str(project_path), config=config)
build_path = project_path / "dist"
build_path.mkdir()
with project_path.as_cwd():
artifacts = list(builder.build(directory=str(build_path)))
assert len(artifacts) == 1
expected_artifact = artifacts[0]
build_artifacts = list(build_path.iterdir())
assert len(build_artifacts) == 1
assert expected_artifact == str(build_artifacts[0])
assert expected_artifact == str(build_path / f"{builder.project_id}-{get_python_versions_tag()}-none-any.whl")
extraction_directory = temp_dir / "_archive"
extraction_directory.mkdir()
with zipfile.ZipFile(str(expected_artifact), "r") as zip_archive:
zip_archive.extractall(str(extraction_directory))
metadata_directory = f"{builder.project_id}.dist-info"
expected_files = helpers.get_template_files(
"wheel.standard_editable_exact_extra_dependencies",
project_name,
metadata_directory=metadata_directory,
package_root=str(project_path / "my_app" / "__init__.py"),
)
helpers.assert_files(extraction_directory, expected_files)
# Inspect the archive rather than the extracted files because on Windows they lose their metadata
# https://stackoverflow.com/q/9813243
with zipfile.ZipFile(str(expected_artifact), "r") as zip_archive:
zip_info = zip_archive.getinfo(f"{metadata_directory}/WHEEL")
assert zip_info.date_time == (2020, 2, 2, 0, 0, 0)
@fixed_pathlib_resolution
def test_editable_exact_force_include(self, hatch, helpers, temp_dir, config_file):
config_file.model.template.plugins["default"]["src-layout"] = False
config_file.save()
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"
build_script = project_path / DEFAULT_BUILD_SCRIPT
build_script.write_text(
helpers.dedent(
"""
import pathlib
from hatchling.builders.hooks.plugin.interface import BuildHookInterface
class CustomHook(BuildHookInterface):
def initialize(self, version, build_data):
# Prefix z just to satisfy our ordering test assertion
build_data['force_include_editable']['my_app/__about__.py'] = 'zfoo.py'
"""
)
)
config = {
"project": {"name": project_name, "dynamic": ["version"]},
"tool": {
"hatch": {
"version": {"path": "my_app/__about__.py"},
"build": {
"targets": {
"wheel": {"versions": ["editable"], "dev-mode-exact": True, "hooks": {"custom": {}}}
}
},
},
},
}
builder = WheelBuilder(str(project_path), config=config)
build_path = project_path / "dist"
build_path.mkdir()
with project_path.as_cwd():
artifacts = list(builder.build(directory=str(build_path)))
assert len(artifacts) == 1
expected_artifact = artifacts[0]
build_artifacts = list(build_path.iterdir())
assert len(build_artifacts) == 1
assert expected_artifact == str(build_artifacts[0])
assert expected_artifact == str(build_path / f"{builder.project_id}-{get_python_versions_tag()}-none-any.whl")
extraction_directory = temp_dir / "_archive"
extraction_directory.mkdir()
with zipfile.ZipFile(str(expected_artifact), "r") as zip_archive:
zip_archive.extractall(str(extraction_directory))
metadata_directory = f"{builder.project_id}.dist-info"
expected_files = helpers.get_template_files(
"wheel.standard_editable_exact_force_include",
project_name,
metadata_directory=metadata_directory,
package_root=str(project_path / "my_app" / "__init__.py"),
)
helpers.assert_files(extraction_directory, expected_files)
# Inspect the archive rather than the extracted files because on Windows they lose their metadata
# https://stackoverflow.com/q/9813243
with zipfile.ZipFile(str(expected_artifact), "r") as zip_archive:
zip_info = zip_archive.getinfo(f"{metadata_directory}/WHEEL")
assert zip_info.date_time == (2020, 2, 2, 0, 0, 0)
@fixed_pathlib_resolution
def test_editable_exact_force_include_option(self, hatch, helpers, temp_dir, config_file):
config_file.model.template.plugins["default"]["src-layout"] = False
config_file.save()
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"
config = {
"project": {"name": project_name, "dynamic": ["version"]},
"tool": {
"hatch": {
"version": {"path": "my_app/__about__.py"},
"build": {
"targets": {
"wheel": {
"versions": ["editable"],
"dev-mode-exact": True,
"force-include": {"my_app/__about__.py": "zfoo.py"},
}
}
},
},
},
}
builder = WheelBuilder(str(project_path), config=config)
build_path = project_path / "dist"
build_path.mkdir()
with project_path.as_cwd():
artifacts = list(builder.build(directory=str(build_path)))
assert len(artifacts) == 1
expected_artifact = artifacts[0]
build_artifacts = list(build_path.iterdir())
assert len(build_artifacts) == 1
assert expected_artifact == str(build_artifacts[0])
assert expected_artifact == str(build_path / f"{builder.project_id}-{get_python_versions_tag()}-none-any.whl")
extraction_directory = temp_dir / "_archive"
extraction_directory.mkdir()
with zipfile.ZipFile(str(expected_artifact), "r") as zip_archive:
zip_archive.extractall(str(extraction_directory))
metadata_directory = f"{builder.project_id}.dist-info"
expected_files = helpers.get_template_files(
"wheel.standard_editable_exact_force_include",
project_name,
metadata_directory=metadata_directory,
package_root=str(project_path / "my_app" / "__init__.py"),
)
helpers.assert_files(extraction_directory, expected_files)
# Inspect the archive rather than the extracted files because on Windows they lose their metadata
# https://stackoverflow.com/q/9813243
with zipfile.ZipFile(str(expected_artifact), "r") as zip_archive:
zip_info = zip_archive.getinfo(f"{metadata_directory}/WHEEL")
assert zip_info.date_time == (2020, 2, 2, 0, 0, 0)
@fixed_pathlib_resolution
def test_editable_exact_force_include_build_data_precedence(self, hatch, helpers, temp_dir, config_file):
config_file.model.template.plugins["default"]["src-layout"] = False
config_file.save()
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"
build_script = project_path / DEFAULT_BUILD_SCRIPT
build_script.write_text(
helpers.dedent(
"""
import pathlib
from hatchling.builders.hooks.plugin.interface import BuildHookInterface
class CustomHook(BuildHookInterface):
def initialize(self, version, build_data):
# Prefix z just to satisfy our ordering test assertion
build_data['force_include_editable']['my_app/__about__.py'] = 'zfoo.py'
"""
)
)
config = {
"project": {"name": project_name, "dynamic": ["version"]},
"tool": {
"hatch": {
"version": {"path": "my_app/__about__.py"},
"build": {
"targets": {
"wheel": {
"versions": ["editable"],
"dev-mode-exact": True,
"force-include": {"my_app/__about__.py": "zbar.py"},
"hooks": {"custom": {}},
}
}
},
},
},
}
builder = WheelBuilder(str(project_path), config=config)
build_path = project_path / "dist"
build_path.mkdir()
with project_path.as_cwd():
artifacts = list(builder.build(directory=str(build_path)))
assert len(artifacts) == 1
expected_artifact = artifacts[0]
build_artifacts = list(build_path.iterdir())
assert len(build_artifacts) == 1
assert expected_artifact == str(build_artifacts[0])
assert expected_artifact == str(build_path / f"{builder.project_id}-{get_python_versions_tag()}-none-any.whl")
extraction_directory = temp_dir / "_archive"
extraction_directory.mkdir()
with zipfile.ZipFile(str(expected_artifact), "r") as zip_archive:
zip_archive.extractall(str(extraction_directory))
metadata_directory = f"{builder.project_id}.dist-info"
expected_files = helpers.get_template_files(
"wheel.standard_editable_exact_force_include",
project_name,
metadata_directory=metadata_directory,
package_root=str(project_path / "my_app" / "__init__.py"),
)
helpers.assert_files(extraction_directory, expected_files)
# Inspect the archive rather than the extracted files because on Windows they lose their metadata
# https://stackoverflow.com/q/9813243
with zipfile.ZipFile(str(expected_artifact), "r") as zip_archive:
zip_info = zip_archive.getinfo(f"{metadata_directory}/WHEEL")
assert zip_info.date_time == (2020, 2, 2, 0, 0, 0)
@fixed_pathlib_resolution
def test_editable_pth(self, hatch, helpers, temp_dir):
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"
config = {
"project": {"name": project_name, "dynamic": ["version"]},
"tool": {
"hatch": {
"version": {"path": "src/my_app/__about__.py"},
"build": {"targets": {"wheel": {"versions": ["editable"], "dev-mode-dirs": ["."]}}},
},
},
}
builder = WheelBuilder(str(project_path), config=config)
build_path = project_path / "dist"
build_path.mkdir()
with project_path.as_cwd():
artifacts = list(builder.build(directory=str(build_path)))
assert len(artifacts) == 1
expected_artifact = artifacts[0]
build_artifacts = list(build_path.iterdir())
assert len(build_artifacts) == 1
assert expected_artifact == str(build_artifacts[0])
assert expected_artifact == str(build_path / f"{builder.project_id}-{get_python_versions_tag()}-none-any.whl")
extraction_directory = temp_dir / "_archive"
extraction_directory.mkdir()
with zipfile.ZipFile(str(expected_artifact), "r") as zip_archive:
zip_archive.extractall(str(extraction_directory))
metadata_directory = f"{builder.project_id}.dist-info"
expected_files = helpers.get_template_files(
"wheel.standard_editable_pth",
project_name,
metadata_directory=metadata_directory,
package_paths=[str(project_path)],
)
helpers.assert_files(extraction_directory, expected_files)
# Inspect the archive rather than the extracted files because on Windows they lose their metadata
# https://stackoverflow.com/q/9813243
with zipfile.ZipFile(str(expected_artifact), "r") as zip_archive:
zip_info = zip_archive.getinfo(f"{metadata_directory}/WHEEL")
assert zip_info.date_time == (2020, 2, 2, 0, 0, 0)
def test_default_namespace_package(self, hatch, helpers, temp_dir, config_file):
config_file.model.template.plugins["default"]["src-layout"] = False
config_file.save()
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"
package_path = project_path / "my_app"
namespace_path = project_path / "namespace"
namespace_path.mkdir()
package_path.replace(namespace_path / "my_app")
config = {
"project": {"name": project_name, "dynamic": ["version"]},
"tool": {
"hatch": {
"version": {"path": "namespace/my_app/__about__.py"},
"build": {"targets": {"wheel": {"versions": ["standard"]}}},
},
},
}
builder = WheelBuilder(str(project_path), config=config)
build_path = project_path / "dist"
with project_path.as_cwd():
artifacts = list(builder.build())
assert len(artifacts) == 1
expected_artifact = artifacts[0]
build_artifacts = list(build_path.iterdir())
assert len(build_artifacts) == 1
assert expected_artifact == str(build_artifacts[0])
assert expected_artifact == str(build_path / f"{builder.project_id}-{get_python_versions_tag()}-none-any.whl")
extraction_directory = temp_dir / "_archive"
extraction_directory.mkdir()
with zipfile.ZipFile(str(expected_artifact), "r") as zip_archive:
zip_archive.extractall(str(extraction_directory))
metadata_directory = f"{builder.project_id}.dist-info"
expected_files = helpers.get_template_files(
"wheel.standard_default_namespace_package",
project_name,
metadata_directory=metadata_directory,
namespace="namespace",
)
helpers.assert_files(extraction_directory, expected_files)
def test_default_entry_points(self, hatch, helpers, temp_dir, config_file):
config_file.model.template.plugins["default"]["src-layout"] = False
config_file.save()
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"
config = {
"project": {"name": project_name, "dynamic": ["version"], "scripts": {"foo": "pkg:bar", "bar": "pkg:foo"}},
"tool": {
"hatch": {
"version": {"path": "my_app/__about__.py"},
"build": {"targets": {"wheel": {"versions": ["standard"]}}},
},
},
}
builder = WheelBuilder(str(project_path), config=config)
build_path = project_path / "dist"
with project_path.as_cwd():
artifacts = list(builder.build())
assert len(artifacts) == 1
expected_artifact = artifacts[0]
build_artifacts = list(build_path.iterdir())
assert len(build_artifacts) == 1
assert expected_artifact == str(build_artifacts[0])
assert expected_artifact == str(build_path / f"{builder.project_id}-{get_python_versions_tag()}-none-any.whl")
extraction_directory = temp_dir / "_archive"
extraction_directory.mkdir()
with zipfile.ZipFile(str(expected_artifact), "r") as zip_archive:
zip_archive.extractall(str(extraction_directory))
metadata_directory = f"{builder.project_id}.dist-info"
expected_files = helpers.get_template_files(
"wheel.standard_entry_points", project_name, metadata_directory=metadata_directory
)
helpers.assert_files(extraction_directory, expected_files)
def test_explicit_selection_with_src_layout(self, hatch, helpers, temp_dir):
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"
config = {
"project": {"name": project_name, "dynamic": ["version"]},
"tool": {
"hatch": {
"version": {"path": "src/my_app/__about__.py"},
"build": {
"targets": {
"wheel": {
"versions": ["standard"],
"artifacts": ["README.md"],
"only-include": ["src/my_app"],
"sources": ["src"],
}
},
},
},
},
}
builder = WheelBuilder(str(project_path), config=config)
build_path = project_path / "dist"
build_path.mkdir()
with project_path.as_cwd():
artifacts = list(builder.build(directory=str(build_path)))
assert len(artifacts) == 1
expected_artifact = artifacts[0]
build_artifacts = list(build_path.iterdir())
assert len(build_artifacts) == 1
assert expected_artifact == str(build_artifacts[0])
extraction_directory = temp_dir / "_archive"
extraction_directory.mkdir()
with zipfile.ZipFile(str(expected_artifact), "r") as zip_archive:
zip_archive.extractall(str(extraction_directory))
metadata_directory = f"{builder.project_id}.dist-info"
expected_files = helpers.get_template_files(
"wheel.standard_default_license_single",
project_name,
metadata_directory=metadata_directory,
)
helpers.assert_files(extraction_directory, expected_files)
def test_single_module(self, hatch, helpers, temp_dir, config_file):
config_file.model.template.plugins["default"]["src-layout"] = False
config_file.save()
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"
(project_path / "my_app").remove()
(project_path / "my_app.py").touch()
config = {"project": {"name": project_name, "version": "0.0.1"}}
builder = WheelBuilder(str(project_path), config=config)
build_path = project_path / "dist"
build_path.mkdir()
with project_path.as_cwd():
artifacts = list(builder.build(directory=str(build_path)))
assert len(artifacts) == 1
expected_artifact = artifacts[0]
build_artifacts = list(build_path.iterdir())
assert len(build_artifacts) == 1
assert expected_artifact == str(build_artifacts[0])
extraction_directory = temp_dir / "_archive"
extraction_directory.mkdir()
with zipfile.ZipFile(str(expected_artifact), "r") as zip_archive:
zip_archive.extractall(str(extraction_directory))
metadata_directory = f"{builder.project_id}.dist-info"
expected_files = helpers.get_template_files(
"wheel.standard_default_single_module",
project_name,
metadata_directory=metadata_directory,
)
helpers.assert_files(extraction_directory, expected_files)
def test_no_strict_naming(self, hatch, helpers, temp_dir, config_file):
config_file.model.template.plugins["default"]["src-layout"] = False
config_file.save()
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"
config = {
"project": {"name": project_name, "dynamic": ["version"]},
"tool": {
"hatch": {
"version": {"path": "my_app/__about__.py"},
"build": {"targets": {"wheel": {"versions": ["standard"], "strict-naming": False}}},
},
},
}
builder = WheelBuilder(str(project_path), config=config)
build_path = project_path / "dist"
with project_path.as_cwd():
artifacts = list(builder.build())
assert len(artifacts) == 1
expected_artifact = artifacts[0]
build_artifacts = list(build_path.iterdir())
assert len(build_artifacts) == 1
assert expected_artifact == str(build_artifacts[0])
assert expected_artifact == str(
build_path / f"{builder.artifact_project_id}-{get_python_versions_tag()}-none-any.whl"
)
extraction_directory = temp_dir / "_archive"
extraction_directory.mkdir()
with zipfile.ZipFile(str(expected_artifact), "r") as zip_archive:
zip_archive.extractall(str(extraction_directory))
metadata_directory = f"{builder.artifact_project_id}.dist-info"
expected_files = helpers.get_template_files(
"wheel.standard_no_strict_naming", project_name, metadata_directory=metadata_directory
)
helpers.assert_files(extraction_directory, expected_files)
def test_editable_sources_rewrite_error(self, hatch, temp_dir):
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"
config = {
"project": {"name": project_name, "dynamic": ["version"]},
"tool": {
"hatch": {
"version": {"path": "src/my_app/__about__.py"},
"build": {
"targets": {
"wheel": {
"versions": ["editable"],
"only-include": ["src/my_app"],
"sources": {"src/my_app": "namespace/plugins/my_app"},
}
},
},
},
},
}
builder = WheelBuilder(str(project_path), config=config)
build_path = project_path / "dist"
build_path.mkdir()
with (
project_path.as_cwd(),
pytest.raises(
ValueError,
match=(
"Dev mode installations are unsupported when any path rewrite in the `sources` option "
"changes a prefix rather than removes it, see: "
"https://github.com/pfmoore/editables/issues/20"
),
),
):
list(builder.build(directory=str(build_path)))
@pytest.mark.skipif(
sys.platform != "darwin" or sys.version_info < (3, 8),
reason="requires support for ARM on macOS",
)
@pytest.mark.parametrize(
("archflags", "expected_arch"),
[("-arch x86_64", "x86_64"), ("-arch arm64", "arm64"), ("-arch arm64 -arch x86_64", "universal2")],
)
def test_macos_archflags(self, hatch, helpers, temp_dir, config_file, archflags, expected_arch):
config_file.model.template.plugins["default"]["src-layout"] = False
config_file.save()
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"
vcs_ignore_file = project_path / ".gitignore"
vcs_ignore_file.write_text("*.pyc\n*.so\n*.h")
build_script = project_path / DEFAULT_BUILD_SCRIPT
build_script.write_text(
helpers.dedent(
"""
import pathlib
from hatchling.builders.hooks.plugin.interface import BuildHookInterface
class CustomHook(BuildHookInterface):
def initialize(self, version, build_data):
build_data['pure_python'] = False
build_data['infer_tag'] = True
pathlib.Path('my_app', 'lib.so').touch()
pathlib.Path('my_app', 'lib.h').touch()
"""
)
)
config = {
"project": {"name": project_name, "requires-python": ">3", "dynamic": ["version"]},
"tool": {
"hatch": {
"version": {"path": "my_app/__about__.py"},
"build": {
"targets": {"wheel": {"versions": ["standard"]}},
"artifacts": ["my_app/lib.so"],
"hooks": {"custom": {"path": DEFAULT_BUILD_SCRIPT}},
},
},
},
}
builder = WheelBuilder(str(project_path), config=config)
build_path = project_path / "dist"
build_path.mkdir()
with project_path.as_cwd({"ARCHFLAGS": archflags}):
artifacts = list(builder.build(directory=str(build_path)))
assert len(artifacts) == 1
expected_artifact = artifacts[0]
build_artifacts = list(build_path.iterdir())
assert len(build_artifacts) == 1
assert expected_artifact == str(build_artifacts[0])
tag = next(sys_tags())
tag_parts = [tag.interpreter, tag.abi, tag.platform]
tag_parts[2] = tag_parts[2].replace(platform.mac_ver()[2], expected_arch)
expected_tag = "-".join(tag_parts)
assert expected_artifact == str(build_path / f"{builder.project_id}-{expected_tag}.whl")
extraction_directory = temp_dir / "_archive"
extraction_directory.mkdir()
with zipfile.ZipFile(str(expected_artifact), "r") as zip_archive:
zip_archive.extractall(str(extraction_directory))
metadata_directory = f"{builder.project_id}.dist-info"
expected_files = helpers.get_template_files(
"wheel.standard_default_build_script_artifacts",
project_name,
metadata_directory=metadata_directory,
tag=expected_tag,
)
helpers.assert_files(extraction_directory, expected_files)
@pytest.mark.requires_macos
@pytest.mark.parametrize("macos_max_compat", [True, False])
def test_macos_max_compat(self, hatch, helpers, temp_dir, config_file, macos_max_compat):
config_file.model.template.plugins["default"]["src-layout"] = False
config_file.save()
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"
vcs_ignore_file = project_path / ".gitignore"
vcs_ignore_file.write_text("*.pyc\n*.so\n*.h")
build_script = project_path / DEFAULT_BUILD_SCRIPT
build_script.write_text(
helpers.dedent(
"""
import pathlib
from hatchling.builders.hooks.plugin.interface import BuildHookInterface
class CustomHook(BuildHookInterface):
def initialize(self, version, build_data):
build_data['pure_python'] = False
build_data['infer_tag'] = True
pathlib.Path('my_app', 'lib.so').touch()
pathlib.Path('my_app', 'lib.h').touch()
"""
)
)
config = {
"project": {"name": project_name, "requires-python": ">3", "dynamic": ["version"]},
"tool": {
"hatch": {
"version": {"path": "my_app/__about__.py"},
"build": {
"targets": {"wheel": {"versions": ["standard"], "macos-max-compat": macos_max_compat}},
"artifacts": ["my_app/lib.so"],
"hooks": {"custom": {"path": DEFAULT_BUILD_SCRIPT}},
},
},
},
}
builder = WheelBuilder(str(project_path), config=config)
build_path = project_path / "dist"
build_path.mkdir()
with project_path.as_cwd():
artifacts = list(builder.build(directory=str(build_path)))
assert len(artifacts) == 1
expected_artifact = artifacts[0]
build_artifacts = list(build_path.iterdir())
assert len(build_artifacts) == 1
assert expected_artifact == str(build_artifacts[0])
tag = next(sys_tags())
tag_parts = [tag.interpreter, tag.abi, tag.platform]
if macos_max_compat:
sdk_version_major, sdk_version_minor = tag_parts[2].split("_")[1:3]
if int(sdk_version_major) >= 11:
tag_parts[2] = tag_parts[2].replace(f"{sdk_version_major}_{sdk_version_minor}", "10_16", 1)
expected_tag = "-".join(tag_parts)
assert expected_artifact == str(build_path / f"{builder.project_id}-{expected_tag}.whl")
extraction_directory = temp_dir / "_archive"
extraction_directory.mkdir()
with zipfile.ZipFile(str(expected_artifact), "r") as zip_archive:
zip_archive.extractall(str(extraction_directory))
metadata_directory = f"{builder.project_id}.dist-info"
expected_files = helpers.get_template_files(
"wheel.standard_default_build_script_artifacts",
project_name,
metadata_directory=metadata_directory,
tag=expected_tag,
)
helpers.assert_files(extraction_directory, expected_files)
def test_file_permissions_normalized(self, hatch, temp_dir, config_file):
config_file.model.template.plugins["default"]["src-layout"] = False
config_file.save()
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"
config = {
"project": {"name": project_name, "dynamic": ["version"]},
"tool": {
"hatch": {
"version": {"path": "my_app/__about__.py"},
"build": {"targets": {"wheel": {"versions": ["standard"], "strict-naming": False}}},
},
},
}
builder = WheelBuilder(str(project_path), config=config)
build_path = project_path / "dist"
with project_path.as_cwd():
artifacts = list(builder.build())
assert len(artifacts) == 1
expected_artifact = artifacts[0]
build_artifacts = list(build_path.iterdir())
assert len(build_artifacts) == 1
assert expected_artifact == str(build_artifacts[0])
assert expected_artifact == str(
build_path / f"{builder.artifact_project_id}-{get_python_versions_tag()}-none-any.whl"
)
file_stat = os.stat(expected_artifact)
# we assert that at minimum 644 is set, based on the platform (e.g.)
# windows it may be higher
assert file_stat.st_mode & 0o644
class TestSBOMFiles:
def test_single_sbom_file(self, hatch, helpers, temp_dir, config_file):
config_file.model.template.plugins["default"]["tests"] = False
config_file.save()
with temp_dir.as_cwd():
result = hatch("new", "My.App")
assert result.exit_code == 0, result.output
project_path = temp_dir / "my-app"
sbom_file = project_path / "my-sbom.spdx.json"
sbom_file.write_text('{"spdxVersion": "SPDX-2.3"}')
config = {
"project": {"name": "My.App", "dynamic": ["version"]},
"tool": {
"hatch": {
"version": {"path": "src/my_app/__about__.py"},
"build": {"targets": {"wheel": {"sbom-files": ["my-sbom.spdx.json"]}}},
}
},
}
builder = WheelBuilder(str(project_path), config=config)
build_path = project_path / "dist"
build_path.mkdir()
with project_path.as_cwd():
artifacts = list(builder.build(directory=str(build_path)))
assert len(artifacts) == 1
extraction_directory = temp_dir / "_archive"
extraction_directory.mkdir()
with zipfile.ZipFile(str(artifacts[0]), "r") as zip_archive:
zip_archive.extractall(str(extraction_directory))
metadata_directory = f"{builder.project_id}.dist-info"
expected_files = helpers.get_template_files(
"wheel.standard_default_sbom",
"My.App",
metadata_directory=metadata_directory,
sbom_files=[("my-sbom.spdx.json", '{"spdxVersion": "SPDX-2.3"}')],
)
helpers.assert_files(extraction_directory, expected_files)
def test_multiple_sbom_files(self, hatch, helpers, temp_dir, config_file):
config_file.model.template.plugins["default"]["tests"] = False
config_file.save()
with temp_dir.as_cwd():
result = hatch("new", "My.App")
assert result.exit_code == 0, result.output
project_path = temp_dir / "my-app"
(project_path / "sbom1.spdx.json").write_text('{"spdxVersion": "SPDX-2.3"}')
(project_path / "sbom2.cyclonedx.json").write_text('{"bomFormat": "CycloneDX"}')
config = {
"project": {"name": "My.App", "dynamic": ["version"]},
"tool": {
"hatch": {
"version": {"path": "src/my_app/__about__.py"},
"build": {"targets": {"wheel": {"sbom-files": ["sbom1.spdx.json", "sbom2.cyclonedx.json"]}}},
}
},
}
builder = WheelBuilder(str(project_path), config=config)
build_path = project_path / "dist"
build_path.mkdir()
with project_path.as_cwd():
artifacts = list(builder.build(directory=str(build_path)))
assert len(artifacts) == 1
extraction_directory = temp_dir / "_archive"
extraction_directory.mkdir()
with zipfile.ZipFile(str(artifacts[0]), "r") as zip_archive:
zip_archive.extractall(str(extraction_directory))
metadata_directory = f"{builder.project_id}.dist-info"
expected_files = helpers.get_template_files(
"wheel.standard_default_sbom",
"My.App",
metadata_directory=metadata_directory,
sbom_files=[
("sbom1.spdx.json", '{"spdxVersion": "SPDX-2.3"}'),
("sbom2.cyclonedx.json", '{"bomFormat": "CycloneDX"}'),
],
)
helpers.assert_files(extraction_directory, expected_files)
def test_nested_sbom_file(self, hatch, helpers, temp_dir, config_file):
config_file.model.template.plugins["default"]["tests"] = False
config_file.save()
with temp_dir.as_cwd():
result = hatch("new", "My.App")
assert result.exit_code == 0, result.output
project_path = temp_dir / "my-app"
sbom_dir = project_path / "sboms"
sbom_dir.mkdir()
(sbom_dir / "vendor.spdx.json").write_text('{"spdxVersion": "SPDX-2.3"}')
config = {
"project": {"name": "My.App", "dynamic": ["version"]},
"tool": {
"hatch": {
"version": {"path": "src/my_app/__about__.py"},
"build": {"targets": {"wheel": {"sbom-files": ["sboms/vendor.spdx.json"]}}},
}
},
}
builder = WheelBuilder(str(project_path), config=config)
build_path = project_path / "dist"
build_path.mkdir()
with project_path.as_cwd():
artifacts = list(builder.build(directory=str(build_path)))
assert len(artifacts) == 1
extraction_directory = temp_dir / "_archive"
extraction_directory.mkdir()
with zipfile.ZipFile(str(artifacts[0]), "r") as zip_archive:
zip_archive.extractall(str(extraction_directory))
metadata_directory = f"{builder.project_id}.dist-info"
expected_files = helpers.get_template_files(
"wheel.standard_default_sbom",
"My.App",
metadata_directory=metadata_directory,
sbom_files=[("vendor.spdx.json", '{"spdxVersion": "SPDX-2.3"}')],
)
helpers.assert_files(extraction_directory, expected_files)
def test_sbom_files_invalid_type(self, isolation):
config = {
"project": {"name": "my-app", "version": "0.0.1"},
"tool": {"hatch": {"build": {"targets": {"wheel": {"sbom-files": "not-a-list"}}}}},
}
builder = WheelBuilder(str(isolation), config=config)
with pytest.raises(TypeError, match="Field `tool.hatch.build.targets.wheel.sbom-files` must be an array"):
_ = builder.config.sbom_files
def test_sbom_file_invalid_item(self, isolation):
config = {
"project": {"name": "my-app", "version": "0.0.1"},
"tool": {"hatch": {"build": {"targets": {"wheel": {"sbom-files": [123]}}}}},
}
builder = WheelBuilder(str(isolation), config=config)
with pytest.raises(
TypeError, match="SBOM file #1 in field `tool.hatch.build.targets.wheel.sbom-files` must be a string"
):
_ = builder.config.sbom_files
def test_sbom_from_build_data(self, hatch, helpers, temp_dir, config_file):
config_file.model.template.plugins["default"]["tests"] = False
config_file.save()
with temp_dir.as_cwd():
result = hatch("new", "My.App")
assert result.exit_code == 0, result.output
project_path = temp_dir / "my-app"
(project_path / "sbom1.cyclonedx.json").write_text('{"bomFormat": "CycloneDX"}')
(project_path / "sbom2.spdx.json").write_text('{"spdxVersion": "SPDX-2.3"}')
build_script = project_path / DEFAULT_BUILD_SCRIPT
build_script.write_text(
helpers.dedent(
"""
import pathlib
from hatchling.builders.hooks.plugin.interface import BuildHookInterface
class CustomHook(BuildHookInterface):
def initialize(self, version, build_data):
build_data["sbom_files"].append("sbom2.spdx.json")
"""
)
)
config = {
"project": {"name": "My.App", "dynamic": ["version"]},
"tool": {
"hatch": {
"version": {"path": "src/my_app/__about__.py"},
"build": {
"targets": {"wheel": {"sbom-files": ["sbom1.cyclonedx.json"]}},
"hooks": {"custom": {"path": DEFAULT_BUILD_SCRIPT}},
},
}
},
}
builder = WheelBuilder(str(project_path), config=config)
build_path = project_path / "dist"
build_path.mkdir()
with project_path.as_cwd():
artifacts = list(builder.build(directory=str(build_path)))
assert len(artifacts) == 1
extraction_directory = temp_dir / "_archive"
extraction_directory.mkdir()
with zipfile.ZipFile(str(artifacts[0]), "r") as zip_archive:
zip_archive.extractall(str(extraction_directory))
metadata_directory = f"{builder.project_id}.dist-info"
expected_files = helpers.get_template_files(
"wheel.standard_default_sbom",
"My.App",
metadata_directory=metadata_directory,
sbom_files=[
("sbom1.cyclonedx.json", '{"bomFormat": "CycloneDX"}'),
("sbom2.spdx.json", '{"spdxVersion": "SPDX-2.3"}'),
],
)
helpers.assert_files(extraction_directory, expected_files)
================================================
FILE: tests/backend/builders/utils.py
================================================
from hatchling.builders.plugin.interface import BuilderInterface
class MockBuilder(BuilderInterface): # no cov
def get_version_api(self):
return {}
================================================
FILE: tests/backend/metadata/__init__.py
================================================
================================================
FILE: tests/backend/metadata/test_build.py
================================================
import pytest
from packaging.requirements import Requirement
from hatchling.metadata.core import BuildMetadata
class TestRequires:
def test_default(self, isolation):
metadata = BuildMetadata(str(isolation), {})
assert metadata.requires == metadata.requires == []
def test_not_array(self, isolation):
metadata = BuildMetadata(str(isolation), {"requires": 10})
with pytest.raises(TypeError, match="Field `build-system.requires` must be an array"):
_ = metadata.requires
def test_entry_not_string(self, isolation):
metadata = BuildMetadata(str(isolation), {"requires": [10]})
with pytest.raises(TypeError, match="Dependency #1 of field `build-system.requires` must be a string"):
_ = metadata.requires
def test_invalid_specifier(self, isolation):
metadata = BuildMetadata(str(isolation), {"requires": ["foo^1"]})
with pytest.raises(ValueError, match="Dependency #1 of field `build-system.requires` is invalid: .+"):
_ = metadata.requires
def test_correct(self, isolation):
metadata = BuildMetadata(str(isolation), {"requires": ["foo", "bar", "Baz"]})
assert metadata.requires == metadata.requires == ["foo", "bar", "Baz"]
def test_correct_complex_type(self, isolation):
metadata = BuildMetadata(str(isolation), {"requires": ["foo"]})
assert isinstance(metadata.requires_complex, list)
assert isinstance(metadata.requires_complex[0], Requirement)
class TestBuildBackend:
def test_default(self, isolation):
metadata = BuildMetadata(str(isolation), {})
assert metadata.build_backend == metadata.build_backend == ""
def test_not_string(self, isolation):
metadata = BuildMetadata(str(isolation), {"build-backend": 10})
with pytest.raises(TypeError, match="Field `build-system.build-backend` must be a string"):
_ = metadata.build_backend
def test_correct(self, isolation):
metadata = BuildMetadata(str(isolation), {"build-backend": "foo"})
assert metadata.build_backend == metadata.build_backend == "foo"
class TestBackendPath:
def test_default(self, isolation):
metadata = BuildMetadata(str(isolation), {})
assert metadata.backend_path == metadata.backend_path == []
def test_not_array(self, isolation):
metadata = BuildMetadata(str(isolation), {"backend-path": 10})
with pytest.raises(TypeError, match="Field `build-system.backend-path` must be an array"):
_ = metadata.backend_path
def test_entry_not_string(self, isolation):
metadata = BuildMetadata(str(isolation), {"backend-path": [10]})
with pytest.raises(TypeError, match="Entry #1 of field `build-system.backend-path` must be a string"):
_ = metadata.backend_path
def test_correct(self, isolation):
metadata = BuildMetadata(str(isolation), {"backend-path": ["foo", "bar", "Baz"]})
assert metadata.backend_path == metadata.backend_path == ["foo", "bar", "Baz"]
================================================
FILE: tests/backend/metadata/test_core.py
================================================
import pytest
from hatchling.metadata.core import BuildMetadata, CoreMetadata, HatchMetadata, ProjectMetadata
from hatchling.metadata.spec import (
LATEST_METADATA_VERSION,
get_core_metadata_constructors,
project_metadata_from_core_metadata,
)
from hatchling.plugin.manager import PluginManager
from hatchling.utils.constants import DEFAULT_BUILD_SCRIPT
from hatchling.version.source.regex import RegexSource
@pytest.fixture(scope="module")
def latest_spec():
return get_core_metadata_constructors()[LATEST_METADATA_VERSION]
class TestConfig:
def test_default(self, isolation):
metadata = ProjectMetadata(str(isolation), None)
assert metadata.config == metadata.config == {}
def test_reuse(self, isolation):
config = {}
metadata = ProjectMetadata(str(isolation), None, config)
assert metadata.config is metadata.config is config
def test_read(self, temp_dir):
project_file = temp_dir / "pyproject.toml"
project_file.write_text("foo = 5")
with temp_dir.as_cwd():
metadata = ProjectMetadata(str(temp_dir), None)
assert metadata.config == metadata.config == {"foo": 5}
class TestInterface:
def test_types(self, isolation):
metadata = ProjectMetadata(str(isolation), None, {"project": {}})
assert isinstance(metadata.core, CoreMetadata)
assert isinstance(metadata.hatch, HatchMetadata)
assert isinstance(metadata.build, BuildMetadata)
def test_missing_core_metadata(self, isolation):
metadata = ProjectMetadata(str(isolation), None, {})
with pytest.raises(ValueError, match="Missing `project` metadata table in configuration"):
_ = metadata.core
def test_core_metadata_not_table(self, isolation):
metadata = ProjectMetadata(str(isolation), None, {"project": "foo"})
with pytest.raises(TypeError, match="The `project` configuration must be a table"):
_ = metadata.core
def test_tool_metadata_not_table(self, isolation):
metadata = ProjectMetadata(str(isolation), None, {"tool": "foo"})
with pytest.raises(TypeError, match="The `tool` configuration must be a table"):
_ = metadata.hatch
def test_hatch_metadata_not_table(self, isolation):
metadata = ProjectMetadata(str(isolation), None, {"tool": {"hatch": "foo"}})
with pytest.raises(TypeError, match="The `tool.hatch` configuration must be a table"):
_ = metadata.hatch
def test_build_metadata_not_table(self, isolation):
metadata = ProjectMetadata(str(isolation), None, {"build-system": "foo"})
with pytest.raises(TypeError, match="The `build-system` configuration must be a table"):
_ = metadata.build
class TestDynamic:
def test_not_array(self, isolation):
metadata = ProjectMetadata(str(isolation), None, {"project": {"dynamic": 10}})
with pytest.raises(TypeError, match="Field `project.dynamic` must be an array"):
_ = metadata.core.dynamic
def test_entry_not_string(self, isolation):
metadata = ProjectMetadata(str(isolation), None, {"project": {"dynamic": [10]}})
with pytest.raises(TypeError, match="Field #1 of field `project.dynamic` must be a string"):
_ = metadata.core.dynamic
def test_correct(self, isolation):
dynamic = ["version"]
metadata = ProjectMetadata(str(isolation), None, {"project": {"dynamic": dynamic}})
assert metadata.core.dynamic == ["version"]
def test_cache_not_array(self, isolation):
metadata = ProjectMetadata(str(isolation), None, {"project": {"dynamic": 10}})
with pytest.raises(TypeError, match="Field `project.dynamic` must be an array"):
_ = metadata.dynamic
def test_cache_entry_not_string(self, isolation):
metadata = ProjectMetadata(str(isolation), None, {"project": {"dynamic": [10]}})
with pytest.raises(TypeError, match="Field #1 of field `project.dynamic` must be a string"):
_ = metadata.dynamic
def test_cache_correct(self, temp_dir, helpers):
metadata = ProjectMetadata(
str(temp_dir),
PluginManager(),
{
"project": {"name": "foo", "dynamic": ["version", "description"]},
"tool": {"hatch": {"version": {"path": "a/b"}, "metadata": {"hooks": {"custom": {}}}}},
},
)
file_path = temp_dir / "a" / "b"
file_path.ensure_parent_dir_exists()
file_path.write_text('__version__ = "0.0.1"')
file_path = temp_dir / DEFAULT_BUILD_SCRIPT
file_path.write_text(
helpers.dedent(
"""
from hatchling.metadata.plugin.interface import MetadataHookInterface
class CustomHook(MetadataHookInterface):
def update(self, metadata):
metadata['description'] = metadata['name'] + 'bar'
"""
)
)
# Trigger hooks with `metadata.core` first
assert metadata.core.dynamic == []
assert metadata.dynamic == ["version", "description"]
class TestRawName:
def test_dynamic(self, isolation):
metadata = ProjectMetadata(str(isolation), None, {"project": {"name": 9000, "dynamic": ["name"]}})
with pytest.raises(
ValueError, match="Static metadata field `name` cannot be present in field `project.dynamic`"
):
_ = metadata.core.raw_name
def test_missing(self, isolation):
metadata = ProjectMetadata(str(isolation), None, {"project": {}})
with pytest.raises(ValueError, match="Missing required field `project.name`"):
_ = metadata.core.raw_name
def test_not_string(self, isolation):
metadata = ProjectMetadata(str(isolation), None, {"project": {"name": 9000}})
with pytest.raises(TypeError, match="Field `project.name` must be a string"):
_ = metadata.core.raw_name
def test_invalid(self, isolation):
metadata = ProjectMetadata(str(isolation), None, {"project": {"name": "my app"}})
with pytest.raises(
ValueError,
match=(
"Required field `project.name` must only contain ASCII letters/digits, underscores, "
"hyphens, and periods, and must begin and end with ASCII letters/digits."
),
):
_ = metadata.core.raw_name
def test_correct(self, isolation):
name = "My.App"
metadata = ProjectMetadata(str(isolation), None, {"project": {"name": name}})
assert metadata.core.raw_name is metadata.core.raw_name is name
class TestName:
@pytest.mark.parametrize("name", ["My--App", "My__App", "My..App"])
def test_normalization(self, isolation, name):
metadata = ProjectMetadata(str(isolation), None, {"project": {"name": name}})
assert metadata.core.name == metadata.core.name == "my-app"
class TestVersion:
def test_dynamic(self, isolation):
metadata = ProjectMetadata(str(isolation), None, {"project": {"version": 9000, "dynamic": ["version"]}})
with pytest.raises(
ValueError,
match="Metadata field `version` cannot be both statically defined and listed in field `project.dynamic`",
):
_ = metadata.core.version
def test_static_missing(self, isolation):
metadata = ProjectMetadata(str(isolation), None, {"project": {}})
with pytest.raises(
ValueError,
match="Field `project.version` can only be resolved dynamically if `version` is in field `project.dynamic`",
):
_ = metadata.version
def test_static_not_string(self, isolation):
metadata = ProjectMetadata(str(isolation), None, {"project": {"version": 9000}})
with pytest.raises(TypeError, match="Field `project.version` must be a string"):
_ = metadata.version
def test_static_invalid(self, isolation):
metadata = ProjectMetadata(str(isolation), None, {"project": {"version": "0..0"}})
with pytest.raises(
ValueError,
match="Invalid version `0..0` from field `project.version`, see https://peps.python.org/pep-0440/",
):
_ = metadata.version
def test_static_normalization(self, isolation):
metadata = ProjectMetadata(str(isolation), None, {"project": {"version": "0.1.0.0-rc.1"}})
assert metadata.version == metadata.version == "0.1.0.0rc1"
assert metadata.core.version == metadata.core.version == "0.1.0.0-rc.1"
def test_dynamic_missing(self, isolation):
metadata = ProjectMetadata(str(isolation), None, {"project": {"dynamic": ["version"]}, "tool": {"hatch": {}}})
with pytest.raises(ValueError, match="Missing `tool.hatch.version` configuration"):
_ = metadata.version
def test_dynamic_not_table(self, isolation):
metadata = ProjectMetadata(
str(isolation), None, {"project": {"dynamic": ["version"]}, "tool": {"hatch": {"version": "1.0"}}}
)
with pytest.raises(TypeError, match="Field `tool.hatch.version` must be a table"):
_ = metadata.version
def test_dynamic_source_empty(self, isolation):
metadata = ProjectMetadata(
str(isolation), None, {"project": {"dynamic": ["version"]}, "tool": {"hatch": {"version": {"source": ""}}}}
)
with pytest.raises(
ValueError, match="The `source` option under the `tool.hatch.version` table must not be empty if defined"
):
_ = metadata.version.cached
def test_dynamic_source_not_string(self, isolation):
metadata = ProjectMetadata(
str(isolation), None, {"project": {"dynamic": ["version"]}, "tool": {"hatch": {"version": {"source": 42}}}}
)
with pytest.raises(TypeError, match="Field `tool.hatch.version.source` must be a string"):
_ = metadata.version.cached
def test_dynamic_unknown_source(self, isolation):
metadata = ProjectMetadata(
str(isolation),
PluginManager(),
{"project": {"dynamic": ["version"]}, "tool": {"hatch": {"version": {"source": "foo"}}}},
)
with pytest.raises(ValueError, match="Unknown version source: foo"):
_ = metadata.version.cached
def test_dynamic_source_regex(self, temp_dir):
metadata = ProjectMetadata(
str(temp_dir),
PluginManager(),
{"project": {"dynamic": ["version"]}, "tool": {"hatch": {"version": {"source": "regex", "path": "a/b"}}}},
)
file_path = temp_dir / "a" / "b"
file_path.ensure_parent_dir_exists()
file_path.write_text('__version__ = "0.0.1"')
assert metadata.hatch.version.source is metadata.hatch.version.source
assert isinstance(metadata.hatch.version.source, RegexSource)
assert metadata.hatch.version.cached == metadata.hatch.version.cached == "0.0.1"
def test_dynamic_source_regex_invalid(self, temp_dir):
metadata = ProjectMetadata(
str(temp_dir),
PluginManager(),
{"project": {"dynamic": ["version"]}, "tool": {"hatch": {"version": {"source": "regex", "path": "a/b"}}}},
)
file_path = temp_dir / "a" / "b"
file_path.ensure_parent_dir_exists()
file_path.write_text('__version__ = "0..0"')
with pytest.raises(
ValueError, match="Invalid version `0..0` from source `regex`, see https://peps.python.org/pep-0440/"
):
_ = metadata.version
def test_dynamic_error(self, isolation):
metadata = ProjectMetadata(
str(isolation),
PluginManager(),
{"project": {"dynamic": ["version"]}, "tool": {"hatch": {"version": {"source": "regex"}}}},
)
with pytest.raises(
ValueError, match="Error getting the version from source `regex`: option `path` must be specified"
):
_ = metadata.version.cached
class TestDescription:
def test_dynamic(self, isolation):
metadata = ProjectMetadata(str(isolation), None, {"project": {"description": 9000, "dynamic": ["description"]}})
with pytest.raises(
ValueError,
match=(
"Metadata field `description` cannot be both statically defined and listed in field `project.dynamic`"
),
):
_ = metadata.core.description
def test_not_string(self, isolation):
metadata = ProjectMetadata(str(isolation), None, {"project": {"description": 9000}})
with pytest.raises(TypeError, match="Field `project.description` must be a string"):
_ = metadata.core.description
def test_default(self, isolation):
metadata = ProjectMetadata(str(isolation), None, {"project": {}})
assert metadata.core.description == metadata.core.description == ""
def test_custom(self, isolation):
metadata = ProjectMetadata(str(isolation), None, {"project": {"description": "foo"}})
assert metadata.core.description == metadata.core.description == "foo"
def test_normaliza(self, isolation):
metadata = ProjectMetadata(str(isolation), None, {"project": {"description": "\nfirst line.\r\nsecond line"}})
assert metadata.core.description == metadata.core.description == " first line. second line"
class TestReadme:
def test_dynamic(self, isolation):
metadata = ProjectMetadata(str(isolation), None, {"project": {"readme": 9000, "dynamic": ["readme"]}})
with pytest.raises(
ValueError,
match="Metadata field `readme` cannot be both statically defined and listed in field `project.dynamic`",
):
_ = metadata.core.readme
@pytest.mark.parametrize("attribute", ["readme", "readme_content_type"])
def test_unknown_type(self, isolation, attribute):
metadata = ProjectMetadata(str(isolation), None, {"project": {"readme": 9000}})
with pytest.raises(TypeError, match="Field `project.readme` must be a string or a table"):
_ = getattr(metadata.core, attribute)
def test_default(self, isolation):
metadata = ProjectMetadata(str(isolation), None, {"project": {}})
assert metadata.core.readme == metadata.core.readme == ""
assert metadata.core.readme_content_type == metadata.core.readme_content_type == "text/markdown"
assert metadata.core.readme_path == metadata.core.readme_path == ""
def test_string_path_unknown_content_type(self, isolation):
metadata = ProjectMetadata(str(isolation), None, {"project": {"readme": "foo"}})
with pytest.raises(
TypeError, match="Unable to determine the content-type based on the extension of readme file: foo"
):
_ = metadata.core.readme
def test_string_path_nonexistent(self, isolation):
metadata = ProjectMetadata(str(isolation), None, {"project": {"readme": "foo/bar.md"}})
with pytest.raises(OSError, match="Readme file does not exist: foo/bar\\.md"):
_ = metadata.core.readme
@pytest.mark.parametrize(
("extension", "content_type"), [(".md", "text/markdown"), (".rst", "text/x-rst"), (".txt", "text/plain")]
)
def test_string_correct(self, extension, content_type, temp_dir):
metadata = ProjectMetadata(str(temp_dir), None, {"project": {"readme": f"foo/bar{extension}"}})
file_path = temp_dir / "foo" / f"bar{extension}"
file_path.ensure_parent_dir_exists()
file_path.write_text("test content")
assert metadata.core.readme == metadata.core.readme == "test content"
assert metadata.core.readme_content_type == metadata.core.readme_content_type == content_type
assert metadata.core.readme_path == metadata.core.readme_path == f"foo/bar{extension}"
def test_table_content_type_missing(self, isolation):
metadata = ProjectMetadata(str(isolation), None, {"project": {"readme": {}}})
with pytest.raises(ValueError, match="Field `content-type` is required in the `project.readme` table"):
_ = metadata.core.readme
def test_table_content_type_not_string(self, isolation):
metadata = ProjectMetadata(str(isolation), None, {"project": {"readme": {"content-type": 5}}})
with pytest.raises(TypeError, match="Field `content-type` in the `project.readme` table must be a string"):
_ = metadata.core.readme
def test_table_content_type_not_unknown(self, isolation):
metadata = ProjectMetadata(str(isolation), None, {"project": {"readme": {"content-type": "foo"}}})
with pytest.raises(
ValueError,
match=(
"Field `content-type` in the `project.readme` table must be one of the following: "
"text/markdown, text/x-rst, text/plain"
),
):
_ = metadata.core.readme
def test_table_multiple_options(self, isolation):
metadata = ProjectMetadata(
str(isolation), None, {"project": {"readme": {"content-type": "text/markdown", "file": "", "text": ""}}}
)
with pytest.raises(ValueError, match="Cannot specify both `file` and `text` in the `project.readme` table"):
_ = metadata.core.readme
def test_table_no_option(self, isolation):
metadata = ProjectMetadata(str(isolation), None, {"project": {"readme": {"content-type": "text/markdown"}}})
with pytest.raises(ValueError, match="Must specify either `file` or `text` in the `project.readme` table"):
_ = metadata.core.readme
def test_table_file_not_string(self, isolation):
metadata = ProjectMetadata(
str(isolation), None, {"project": {"readme": {"content-type": "text/markdown", "file": 4}}}
)
with pytest.raises(TypeError, match="Field `file` in the `project.readme` table must be a string"):
_ = metadata.core.readme
def test_table_file_nonexistent(self, isolation):
metadata = ProjectMetadata(
str(isolation), None, {"project": {"readme": {"content-type": "text/markdown", "file": "foo/bar.md"}}}
)
with pytest.raises(OSError, match="Readme file does not exist: foo/bar\\.md"):
_ = metadata.core.readme
def test_table_file_correct(self, temp_dir):
metadata = ProjectMetadata(
str(temp_dir), None, {"project": {"readme": {"content-type": "text/markdown", "file": "foo/bar.markdown"}}}
)
file_path = temp_dir / "foo" / "bar.markdown"
file_path.ensure_parent_dir_exists()
file_path.write_text("test content")
assert metadata.core.readme == metadata.core.readme == "test content"
assert metadata.core.readme_content_type == metadata.core.readme_content_type == "text/markdown"
assert metadata.core.readme_path == metadata.core.readme_path == "foo/bar.markdown"
def test_table_text_not_string(self, isolation):
metadata = ProjectMetadata(
str(isolation), None, {"project": {"readme": {"content-type": "text/markdown", "text": 4}}}
)
with pytest.raises(TypeError, match="Field `text` in the `project.readme` table must be a string"):
_ = metadata.core.readme
def test_table_text_correct(self, isolation):
metadata = ProjectMetadata(
str(isolation), None, {"project": {"readme": {"content-type": "text/markdown", "text": "test content"}}}
)
assert metadata.core.readme == metadata.core.readme == "test content"
assert metadata.core.readme_content_type == metadata.core.readme_content_type == "text/markdown"
assert metadata.core.readme_path == metadata.core.readme_path == ""
class TestRequiresPython:
def test_dynamic(self, isolation):
metadata = ProjectMetadata(
str(isolation), None, {"project": {"requires-python": 9000, "dynamic": ["requires-python"]}}
)
with pytest.raises(
ValueError,
match=(
"Metadata field `requires-python` cannot be both statically defined and "
"listed in field `project.dynamic`"
),
):
_ = metadata.core.requires_python
@pytest.mark.parametrize("attribute", ["requires_python", "python_constraint"])
def test_not_string(self, isolation, attribute):
metadata = ProjectMetadata(str(isolation), None, {"project": {"requires-python": 9000}})
with pytest.raises(TypeError, match="Field `project.requires-python` must be a string"):
_ = getattr(metadata.core, attribute)
def test_invalid(self, isolation):
metadata = ProjectMetadata(str(isolation), None, {"project": {"requires-python": "^1"}})
with pytest.raises(ValueError, match="Field `project.requires-python` is invalid: .+"):
_ = metadata.core.requires_python
def test_default(self, isolation):
metadata = ProjectMetadata(str(isolation), None, {"project": {}})
assert metadata.core.requires_python == metadata.core.requires_python == ""
for major_version in map(str, range(10)):
assert metadata.core.python_constraint.contains(major_version)
def test_custom(self, isolation):
metadata = ProjectMetadata(str(isolation), None, {"project": {"requires-python": ">2"}})
assert metadata.core.requires_python == metadata.core.requires_python == ">2"
assert not metadata.core.python_constraint.contains("2")
assert metadata.core.python_constraint.contains("3")
class TestLicense:
def test_dynamic(self, isolation):
metadata = ProjectMetadata(str(isolation), None, {"project": {"license": 9000, "dynamic": ["license"]}})
with pytest.raises(
ValueError,
match="Metadata field `license` cannot be both statically defined and listed in field `project.dynamic`",
):
_ = metadata.core.license
def test_invalid_type(self, isolation):
metadata = ProjectMetadata(str(isolation), None, {"project": {"license": 9000}})
with pytest.raises(TypeError, match="Field `project.license` must be a string or a table"):
_ = metadata.core.license
def test_default(self, isolation):
metadata = ProjectMetadata(str(isolation), None, {"project": {}})
assert metadata.core.license == metadata.core.license == ""
assert metadata.core.license_expression == metadata.core.license_expression == ""
def test_normalization(self, isolation):
metadata = ProjectMetadata(str(isolation), None, {"project": {"license": "mit or apache-2.0"}})
assert metadata.core.license_expression == "MIT OR Apache-2.0"
def test_invalid_expression(self, isolation):
metadata = ProjectMetadata(str(isolation), None, {"project": {"license": "mit or foo"}})
with pytest.raises(ValueError, match="Error parsing field `project.license` - Unknown license: 'foo'"):
_ = metadata.core.license_expression
def test_multiple_options(self, isolation):
metadata = ProjectMetadata(str(isolation), None, {"project": {"license": {"file": "", "text": ""}}})
with pytest.raises(ValueError, match="Cannot specify both `file` and `text` in the `project.license` table"):
_ = metadata.core.license
def test_no_option(self, isolation):
metadata = ProjectMetadata(str(isolation), None, {"project": {"license": {}}})
with pytest.raises(ValueError, match="Must specify either `file` or `text` in the `project.license` table"):
_ = metadata.core.license
def test_file_not_string(self, isolation):
metadata = ProjectMetadata(str(isolation), None, {"project": {"license": {"file": 4}}})
with pytest.raises(TypeError, match="Field `file` in the `project.license` table must be a string"):
_ = metadata.core.license
def test_file_nonexistent(self, isolation):
metadata = ProjectMetadata(str(isolation), None, {"project": {"license": {"file": "foo/bar.md"}}})
with pytest.raises(OSError, match="License file does not exist: foo/bar\\.md"):
_ = metadata.core.license
def test_file_correct(self, temp_dir):
metadata = ProjectMetadata(str(temp_dir), None, {"project": {"license": {"file": "foo/bar.md"}}})
file_path = temp_dir / "foo" / "bar.md"
file_path.ensure_parent_dir_exists()
file_path.write_text("test content")
assert metadata.core.license == "test content"
def test_text_not_string(self, isolation):
metadata = ProjectMetadata(str(isolation), None, {"project": {"license": {"text": 4}}})
with pytest.raises(TypeError, match="Field `text` in the `project.license` table must be a string"):
_ = metadata.core.license
def test_text_correct(self, isolation):
metadata = ProjectMetadata(str(isolation), None, {"project": {"license": {"text": "test content"}}})
assert metadata.core.license == "test content"
class TestLicenseFiles:
def test_dynamic(self, isolation):
metadata = ProjectMetadata(
str(isolation), None, {"project": {"license-files": 9000, "dynamic": ["license-files"]}}
)
with pytest.raises(
ValueError,
match=(
"Metadata field `license-files` cannot be both statically defined and listed in field `project.dynamic`"
),
):
_ = metadata.core.license_files
def test_not_array(self, isolation):
metadata = ProjectMetadata(str(isolation), None, {"project": {"license-files": 9000}})
with pytest.raises(TypeError, match="Field `project.license-files` must be an array"):
_ = metadata.core.license_files
def test_entry_not_string(self, isolation):
metadata = ProjectMetadata(str(isolation), None, {"project": {"license-files": [9000]}})
with pytest.raises(TypeError, match="Entry #1 of field `project.license-files` must be a string"):
_ = metadata.core.license_files
def test_default_globs_no_licenses(self, isolation):
metadata = ProjectMetadata(str(isolation), None, {"project": {}})
assert metadata.core.license_files == metadata.core.license_files == []
def test_default_globs_with_licenses(self, temp_dir):
metadata = ProjectMetadata(str(temp_dir), None, {"project": {}})
expected = []
(temp_dir / "foo").touch()
for name in ("LICENSE", "LICENCE", "COPYING", "NOTICE", "AUTHORS"):
(temp_dir / name).touch()
expected.append(name)
name_with_extension = f"{name}.txt"
(temp_dir / f"{name}.txt").touch()
expected.append(name_with_extension)
assert metadata.core.license_files == sorted(expected)
def test_globs_with_licenses(self, temp_dir):
metadata = ProjectMetadata(str(temp_dir), None, {"project": {"license-files": ["LICENSES/*"]}})
licenses_dir = temp_dir / "LICENSES"
licenses_dir.mkdir()
(licenses_dir / "MIT.txt").touch()
(licenses_dir / "Apache-2.0.txt").touch()
for name in ("LICENSE", "LICENCE", "COPYING", "NOTICE", "AUTHORS"):
(temp_dir / name).touch()
assert metadata.core.license_files == ["LICENSES/Apache-2.0.txt", "LICENSES/MIT.txt"]
def test_paths_with_licenses(self, temp_dir):
metadata = ProjectMetadata(
str(temp_dir),
None,
{"project": {"license-files": ["LICENSES/Apache-2.0.txt", "LICENSES/MIT.txt", "COPYING"]}},
)
licenses_dir = temp_dir / "LICENSES"
licenses_dir.mkdir()
(licenses_dir / "MIT.txt").touch()
(licenses_dir / "Apache-2.0.txt").touch()
for name in ("LICENSE", "LICENCE", "COPYING", "NOTICE", "AUTHORS"):
(temp_dir / name).touch()
assert metadata.core.license_files == ["COPYING", "LICENSES/Apache-2.0.txt", "LICENSES/MIT.txt"]
class TestAuthors:
def test_dynamic(self, isolation):
metadata = ProjectMetadata(str(isolation), None, {"project": {"authors": 9000, "dynamic": ["authors"]}})
with pytest.raises(
ValueError,
match="Metadata field `authors` cannot be both statically defined and listed in field `project.dynamic`",
):
_ = metadata.core.authors
def test_not_array(self, isolation):
metadata = ProjectMetadata(str(isolation), None, {"project": {"authors": "foo"}})
with pytest.raises(TypeError, match="Field `project.authors` must be an array"):
_ = metadata.core.authors
def test_default(self, isolation):
metadata = ProjectMetadata(str(isolation), None, {"project": {}})
assert metadata.core.authors == metadata.core.authors == []
def test_not_table(self, isolation):
metadata = ProjectMetadata(str(isolation), None, {"project": {"authors": ["foo"]}})
with pytest.raises(TypeError, match="Author #1 of field `project.authors` must be an inline table"):
_ = metadata.core.authors
def test_no_data(self, isolation):
metadata = ProjectMetadata(str(isolation), None, {"project": {"authors": [{}]}})
with pytest.raises(
ValueError, match="Author #1 of field `project.authors` must specify either `name` or `email`"
):
_ = metadata.core.authors
def test_name_not_string(self, isolation):
metadata = ProjectMetadata(str(isolation), None, {"project": {"authors": [{"name": 9}]}})
with pytest.raises(TypeError, match="Name of author #1 of field `project.authors` must be a string"):
_ = metadata.core.authors
def test_name_only(self, isolation):
metadata = ProjectMetadata(str(isolation), None, {"project": {"authors": [{"name": "foo"}]}})
assert len(metadata.core.authors) == 1
assert metadata.core.authors[0] == {"name": "foo"}
assert metadata.core.authors_data == metadata.core.authors_data == {"name": ["foo"], "email": []}
def test_email_not_string(self, isolation):
metadata = ProjectMetadata(str(isolation), None, {"project": {"authors": [{"email": 9}]}})
with pytest.raises(TypeError, match="Email of author #1 of field `project.authors` must be a string"):
_ = metadata.core.authors
def test_email_only(self, isolation):
metadata = ProjectMetadata(str(isolation), None, {"project": {"authors": [{"email": "foo@bar.baz"}]}})
assert len(metadata.core.authors) == 1
assert metadata.core.authors[0] == {"email": "foo@bar.baz"}
assert metadata.core.authors_data == {"name": [], "email": ["foo@bar.baz"]}
def test_name_and_email(self, isolation):
metadata = ProjectMetadata(
str(isolation),
None,
{
"project": {
"authors": [{"name": "foo2", "email": "foo2@bar.baz"}, {"name": "foo1", "email": "foo1@bar.baz"}]
}
},
)
assert len(metadata.core.authors) == 2
assert metadata.core.authors[0] == {"name": "foo2", "email": "foo2@bar.baz"}
assert metadata.core.authors[1] == {"name": "foo1", "email": "foo1@bar.baz"}
assert metadata.core.authors_data == {"name": [], "email": ["foo2 ", "foo1 "]}
class TestMaintainers:
def test_dynamic(self, isolation):
metadata = ProjectMetadata(str(isolation), None, {"project": {"maintainers": 9000, "dynamic": ["maintainers"]}})
with pytest.raises(
ValueError,
match=(
"Metadata field `maintainers` cannot be both statically defined and listed in field `project.dynamic`"
),
):
_ = metadata.core.maintainers
def test_not_array(self, isolation):
metadata = ProjectMetadata(str(isolation), None, {"project": {"maintainers": "foo"}})
with pytest.raises(TypeError, match="Field `project.maintainers` must be an array"):
_ = metadata.core.maintainers
def test_default(self, isolation):
metadata = ProjectMetadata(str(isolation), None, {"project": {}})
assert metadata.core.maintainers == metadata.core.maintainers == []
def test_not_table(self, isolation):
metadata = ProjectMetadata(str(isolation), None, {"project": {"maintainers": ["foo"]}})
with pytest.raises(TypeError, match="Maintainer #1 of field `project.maintainers` must be an inline table"):
_ = metadata.core.maintainers
def test_no_data(self, isolation):
metadata = ProjectMetadata(str(isolation), None, {"project": {"maintainers": [{}]}})
with pytest.raises(
ValueError, match="Maintainer #1 of field `project.maintainers` must specify either `name` or `email`"
):
_ = metadata.core.maintainers
def test_name_not_string(self, isolation):
metadata = ProjectMetadata(str(isolation), None, {"project": {"maintainers": [{"name": 9}]}})
with pytest.raises(TypeError, match="Name of maintainer #1 of field `project.maintainers` must be a string"):
_ = metadata.core.maintainers
def test_name_only(self, isolation):
metadata = ProjectMetadata(str(isolation), None, {"project": {"maintainers": [{"name": "foo"}]}})
assert len(metadata.core.maintainers) == 1
assert metadata.core.maintainers[0] == {"name": "foo"}
assert metadata.core.maintainers_data == metadata.core.maintainers_data == {"name": ["foo"], "email": []}
def test_email_not_string(self, isolation):
metadata = ProjectMetadata(str(isolation), None, {"project": {"maintainers": [{"email": 9}]}})
with pytest.raises(TypeError, match="Email of maintainer #1 of field `project.maintainers` must be a string"):
_ = metadata.core.maintainers
def test_email_only(self, isolation):
metadata = ProjectMetadata(str(isolation), None, {"project": {"maintainers": [{"email": "foo@bar.baz"}]}})
assert len(metadata.core.maintainers) == 1
assert metadata.core.maintainers[0] == {"email": "foo@bar.baz"}
assert metadata.core.maintainers_data == {"name": [], "email": ["foo@bar.baz"]}
def test_name_and_email(self, isolation):
metadata = ProjectMetadata(
str(isolation),
None,
{
"project": {
"maintainers": [
{"name": "foo2", "email": "foo2@bar.baz"},
{"name": "foo1", "email": "foo1@bar.baz"},
]
}
},
)
assert len(metadata.core.maintainers) == 2
assert metadata.core.maintainers[0] == {"name": "foo2", "email": "foo2@bar.baz"}
assert metadata.core.maintainers[1] == {"name": "foo1", "email": "foo1@bar.baz"}
assert metadata.core.maintainers_data == {"name": [], "email": ["foo2 ", "foo1 "]}
class TestKeywords:
def test_dynamic(self, isolation):
metadata = ProjectMetadata(str(isolation), None, {"project": {"keywords": 9000, "dynamic": ["keywords"]}})
with pytest.raises(
ValueError,
match="Metadata field `keywords` cannot be both statically defined and listed in field `project.dynamic`",
):
_ = metadata.core.keywords
def test_not_array(self, isolation):
metadata = ProjectMetadata(str(isolation), None, {"project": {"keywords": 10}})
with pytest.raises(TypeError, match="Field `project.keywords` must be an array"):
_ = metadata.core.keywords
def test_entry_not_string(self, isolation):
metadata = ProjectMetadata(str(isolation), None, {"project": {"keywords": [10]}})
with pytest.raises(TypeError, match="Keyword #1 of field `project.keywords` must be a string"):
_ = metadata.core.keywords
def test_correct(self, isolation):
metadata = ProjectMetadata(str(isolation), None, {"project": {"keywords": ["foo", "foo", "bar"]}})
assert metadata.core.keywords == metadata.core.keywords == ["bar", "foo"]
class TestClassifiers:
def test_dynamic(self, isolation):
metadata = ProjectMetadata(str(isolation), None, {"project": {"classifiers": 9000, "dynamic": ["classifiers"]}})
with pytest.raises(
ValueError,
match=(
"Metadata field `classifiers` cannot be both statically defined and listed in field `project.dynamic`"
),
):
_ = metadata.core.classifiers
def test_not_array(self, isolation):
metadata = ProjectMetadata(str(isolation), None, {"project": {"classifiers": 10}})
with pytest.raises(TypeError, match="Field `project.classifiers` must be an array"):
_ = metadata.core.classifiers
def test_entry_not_string(self, isolation):
metadata = ProjectMetadata(str(isolation), None, {"project": {"classifiers": [10]}})
with pytest.raises(TypeError, match="Classifier #1 of field `project.classifiers` must be a string"):
_ = metadata.core.classifiers
def test_entry_unknown(self, isolation, monkeypatch):
monkeypatch.delenv("HATCH_METADATA_CLASSIFIERS_NO_VERIFY", False)
metadata = ProjectMetadata(str(isolation), None, {"project": {"classifiers": ["foo"]}})
with pytest.raises(ValueError, match="Unknown classifier in field `project.classifiers`: foo"):
_ = metadata.core.classifiers
def test_entry_unknown_no_verify(self, isolation, monkeypatch):
monkeypatch.setenv("HATCH_METADATA_CLASSIFIERS_NO_VERIFY", "1")
classifiers = [
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.9",
"Development Status :: 4 - Beta",
"Private :: Do Not Upload",
"Foo",
]
metadata = ProjectMetadata(str(isolation), None, {"project": {"classifiers": classifiers}})
assert (
metadata.core.classifiers
== metadata.core.classifiers
== [
"Private :: Do Not Upload",
"Development Status :: 4 - Beta",
"Foo",
"Programming Language :: Python :: 3.9",
"Programming Language :: Python :: 3.11",
]
)
def test_correct(self, isolation):
classifiers = [
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.9",
"Development Status :: 4 - Beta",
"Private :: Do Not Upload",
]
metadata = ProjectMetadata(str(isolation), None, {"project": {"classifiers": classifiers}})
assert (
metadata.core.classifiers
== metadata.core.classifiers
== [
"Private :: Do Not Upload",
"Development Status :: 4 - Beta",
"Programming Language :: Python :: 3.9",
"Programming Language :: Python :: 3.11",
]
)
class TestURLs:
def test_dynamic(self, isolation):
metadata = ProjectMetadata(str(isolation), None, {"project": {"urls": 9000, "dynamic": ["urls"]}})
with pytest.raises(
ValueError,
match="Metadata field `urls` cannot be both statically defined and listed in field `project.dynamic`",
):
_ = metadata.core.urls
def test_not_table(self, isolation):
metadata = ProjectMetadata(str(isolation), None, {"project": {"urls": 10}})
with pytest.raises(TypeError, match="Field `project.urls` must be a table"):
_ = metadata.core.urls
def test_entry_not_string(self, isolation):
metadata = ProjectMetadata(str(isolation), None, {"project": {"urls": {"foo": 7}}})
with pytest.raises(TypeError, match="URL `foo` of field `project.urls` must be a string"):
_ = metadata.core.urls
def test_correct(self, isolation):
metadata = ProjectMetadata(str(isolation), None, {"project": {"urls": {"foo": "bar", "bar": "baz"}}})
assert metadata.core.urls == metadata.core.urls == {"bar": "baz", "foo": "bar"}
class TestScripts:
def test_dynamic(self, isolation):
metadata = ProjectMetadata(str(isolation), None, {"project": {"scripts": 9000, "dynamic": ["scripts"]}})
with pytest.raises(
ValueError,
match="Metadata field `scripts` cannot be both statically defined and listed in field `project.dynamic`",
):
_ = metadata.core.scripts
def test_not_table(self, isolation):
metadata = ProjectMetadata(str(isolation), None, {"project": {"scripts": 10}})
with pytest.raises(TypeError, match="Field `project.scripts` must be a table"):
_ = metadata.core.scripts
def test_entry_not_string(self, isolation):
metadata = ProjectMetadata(str(isolation), None, {"project": {"scripts": {"foo": 7}}})
with pytest.raises(TypeError, match="Object reference `foo` of field `project.scripts` must be a string"):
_ = metadata.core.scripts
def test_correct(self, isolation):
metadata = ProjectMetadata(str(isolation), None, {"project": {"scripts": {"foo": "bar", "bar": "baz"}}})
assert metadata.core.scripts == metadata.core.scripts == {"bar": "baz", "foo": "bar"}
class TestGUIScripts:
def test_dynamic(self, isolation):
metadata = ProjectMetadata(str(isolation), None, {"project": {"gui-scripts": 9000, "dynamic": ["gui-scripts"]}})
with pytest.raises(
ValueError,
match=(
"Metadata field `gui-scripts` cannot be both statically defined and listed in field `project.dynamic`"
),
):
_ = metadata.core.gui_scripts
def test_not_table(self, isolation):
metadata = ProjectMetadata(str(isolation), None, {"project": {"gui-scripts": 10}})
with pytest.raises(TypeError, match="Field `project.gui-scripts` must be a table"):
_ = metadata.core.gui_scripts
def test_entry_not_string(self, isolation):
metadata = ProjectMetadata(str(isolation), None, {"project": {"gui-scripts": {"foo": 7}}})
with pytest.raises(TypeError, match="Object reference `foo` of field `project.gui-scripts` must be a string"):
_ = metadata.core.gui_scripts
def test_correct(self, isolation):
metadata = ProjectMetadata(str(isolation), None, {"project": {"gui-scripts": {"foo": "bar", "bar": "baz"}}})
assert metadata.core.gui_scripts == metadata.core.gui_scripts == {"bar": "baz", "foo": "bar"}
class TestEntryPoints:
def test_dynamic(self, isolation):
metadata = ProjectMetadata(
str(isolation), None, {"project": {"entry-points": 9000, "dynamic": ["entry-points"]}}
)
with pytest.raises(
ValueError,
match=(
"Metadata field `entry-points` cannot be both statically defined and listed in field `project.dynamic`"
),
):
_ = metadata.core.entry_points
def test_not_table(self, isolation):
metadata = ProjectMetadata(str(isolation), None, {"project": {"entry-points": 10}})
with pytest.raises(TypeError, match="Field `project.entry-points` must be a table"):
_ = metadata.core.entry_points
@pytest.mark.parametrize(("field", "expected"), [("console_scripts", "scripts"), ("gui-scripts", "gui-scripts")])
def test_forbidden_fields(self, isolation, field, expected):
metadata = ProjectMetadata(str(isolation), None, {"project": {"entry-points": {field: "foo"}}})
with pytest.raises(
ValueError,
match=(
f"Field `{field}` must be defined as `project.{expected}` instead of "
f"in the `project.entry-points` table"
),
):
_ = metadata.core.entry_points
def test_data_not_table(self, isolation):
metadata = ProjectMetadata(str(isolation), None, {"project": {"entry-points": {"foo": 7}}})
with pytest.raises(TypeError, match="Field `project.entry-points.foo` must be a table"):
_ = metadata.core.entry_points
def test_data_entry_not_string(self, isolation):
metadata = ProjectMetadata(str(isolation), None, {"project": {"entry-points": {"foo": {"bar": 4}}}})
with pytest.raises(
TypeError, match="Object reference `bar` of field `project.entry-points.foo` must be a string"
):
_ = metadata.core.entry_points
def test_data_empty(self, isolation):
metadata = ProjectMetadata(str(isolation), None, {"project": {"entry-points": {"foo": {}}}})
assert metadata.core.entry_points == metadata.core.entry_points == {}
def test_default(self, isolation):
metadata = ProjectMetadata(str(isolation), None, {"project": {}})
assert metadata.core.entry_points == metadata.core.entry_points == {}
def test_correct(self, isolation):
metadata = ProjectMetadata(
str(isolation),
None,
{"project": {"entry-points": {"foo": {"bar": "baz", "foo": "baz"}, "bar": {"foo": "baz", "bar": "baz"}}}},
)
assert (
metadata.core.entry_points
== metadata.core.entry_points
== {"bar": {"bar": "baz", "foo": "baz"}, "foo": {"bar": "baz", "foo": "baz"}}
)
class TestDependencies:
def test_dynamic(self, isolation):
metadata = ProjectMetadata(
str(isolation), None, {"project": {"dependencies": 9000, "dynamic": ["dependencies"]}}
)
with pytest.raises(
ValueError,
match=(
"Metadata field `dependencies` cannot be both statically defined and listed in field `project.dynamic`"
),
):
_ = metadata.core.dependencies
def test_not_array(self, isolation):
metadata = ProjectMetadata(str(isolation), None, {"project": {"dependencies": 10}})
with pytest.raises(TypeError, match="Field `project.dependencies` must be an array"):
_ = metadata.core.dependencies
def test_entry_not_string(self, isolation):
metadata = ProjectMetadata(str(isolation), None, {"project": {"dependencies": [10]}})
with pytest.raises(TypeError, match="Dependency #1 of field `project.dependencies` must be a string"):
_ = metadata.core.dependencies
def test_invalid(self, isolation):
metadata = ProjectMetadata(str(isolation), None, {"project": {"dependencies": ["foo^1"]}})
with pytest.raises(ValueError, match="Dependency #1 of field `project.dependencies` is invalid: .+"):
_ = metadata.core.dependencies
def test_direct_reference(self, isolation):
metadata = ProjectMetadata(
str(isolation), None, {"project": {"dependencies": ["proj @ git+https://github.com/org/proj.git@v1"]}}
)
with pytest.raises(
ValueError,
match=(
"Dependency #1 of field `project.dependencies` cannot be a direct reference unless "
"field `tool.hatch.metadata.allow-direct-references` is set to `true`"
),
):
_ = metadata.core.dependencies
def test_direct_reference_allowed(self, isolation):
metadata = ProjectMetadata(
str(isolation),
None,
{
"project": {"dependencies": ["proj @ git+https://github.com/org/proj.git@v1"]},
"tool": {"hatch": {"metadata": {"allow-direct-references": True}}},
},
)
assert metadata.core.dependencies == ["proj @ git+https://github.com/org/proj.git@v1"]
def test_context_formatting(self, isolation, uri_slash_prefix):
metadata = ProjectMetadata(
str(isolation),
None,
{
"project": {"dependencies": ["proj @ {root:uri}"]},
"tool": {"hatch": {"metadata": {"allow-direct-references": True}}},
},
)
normalized_path = str(isolation).replace("\\", "/")
assert metadata.core.dependencies == [f"proj @ file:{uri_slash_prefix}{normalized_path}"]
def test_correct(self, isolation):
metadata = ProjectMetadata(
str(isolation),
None,
{
"project": {
"dependencies": [
'python___dateutil;platform_python_implementation=="CPython"',
"bAr.Baz[TLS, Zu.Bat, EdDSA, Zu_Bat] >=1.2RC5 , <9000B1",
'Foo;python_version<"3.8"',
'fOO; python_version< "3.8"',
],
},
},
)
assert (
metadata.core.dependencies
== metadata.core.dependencies
== [
"bar-baz[eddsa,tls,zu-bat]<9000b1,>=1.2rc5",
"foo; python_version < '3.8'",
"python-dateutil; platform_python_implementation == 'CPython'",
]
)
assert metadata.core.dependencies_complex is metadata.core.dependencies_complex
class TestOptionalDependencies:
def test_dynamic(self, isolation):
metadata = ProjectMetadata(
str(isolation), None, {"project": {"optional-dependencies": 9000, "dynamic": ["optional-dependencies"]}}
)
with pytest.raises(
ValueError,
match=(
"Metadata field `optional-dependencies` cannot be both statically defined and "
"listed in field `project.dynamic`"
),
):
_ = metadata.core.optional_dependencies
def test_not_table(self, isolation):
metadata = ProjectMetadata(str(isolation), None, {"project": {"optional-dependencies": 10}})
with pytest.raises(TypeError, match="Field `project.optional-dependencies` must be a table"):
_ = metadata.core.optional_dependencies
def test_invalid_name(self, isolation):
metadata = ProjectMetadata(str(isolation), None, {"project": {"optional-dependencies": {"foo/bar": []}}})
with pytest.raises(
ValueError,
match=(
"Optional dependency group `foo/bar` of field `project.optional-dependencies` must only contain "
"ASCII letters/digits, underscores, hyphens, and periods, and must begin and end with "
"ASCII letters/digits."
),
):
_ = metadata.core.optional_dependencies
def test_definitions_not_array(self, isolation):
metadata = ProjectMetadata(str(isolation), None, {"project": {"optional-dependencies": {"foo": 5}}})
with pytest.raises(
TypeError, match="Dependencies for option `foo` of field `project.optional-dependencies` must be an array"
):
_ = metadata.core.optional_dependencies
def test_entry_not_string(self, isolation):
metadata = ProjectMetadata(str(isolation), None, {"project": {"optional-dependencies": {"foo": [5]}}})
with pytest.raises(
TypeError, match="Dependency #1 of option `foo` of field `project.optional-dependencies` must be a string"
):
_ = metadata.core.optional_dependencies
def test_invalid(self, isolation):
metadata = ProjectMetadata(str(isolation), None, {"project": {"optional-dependencies": {"foo": ["bar^1"]}}})
with pytest.raises(
ValueError, match="Dependency #1 of option `foo` of field `project.optional-dependencies` is invalid: .+"
):
_ = metadata.core.optional_dependencies
def test_conflict(self, isolation):
metadata = ProjectMetadata(
str(isolation), None, {"project": {"optional-dependencies": {"foo_bar": [], "foo.bar": []}}}
)
with pytest.raises(
ValueError,
match=(
"Optional dependency groups `foo_bar` and `foo.bar` of field `project.optional-dependencies` both "
"evaluate to `foo-bar`."
),
):
_ = metadata.core.optional_dependencies
def test_recursive_circular(self, isolation):
metadata = ProjectMetadata(
str(isolation),
None,
{"project": {"name": "my-app", "optional-dependencies": {"foo": ["my-app[bar]"], "bar": ["my-app[foo]"]}}},
)
with pytest.raises(
ValueError,
match="Field `project.optional-dependencies` defines a circular dependency group: foo",
):
_ = metadata.core.optional_dependencies
def test_recursive_unknown(self, isolation):
metadata = ProjectMetadata(
str(isolation),
None,
{"project": {"name": "my-app", "optional-dependencies": {"foo": ["my-app[bar]"]}}},
)
with pytest.raises(
ValueError,
match="Unknown recursive dependency group in field `project.optional-dependencies`: bar",
):
_ = metadata.core.optional_dependencies
def test_allow_ambiguity(self, isolation):
metadata = ProjectMetadata(
str(isolation),
None,
{
"project": {"optional-dependencies": {"foo_bar": [], "foo.bar": []}},
"tool": {"hatch": {"metadata": {"allow-ambiguous-features": True}}},
},
)
assert metadata.core.optional_dependencies == {"foo_bar": [], "foo.bar": []}
def test_direct_reference(self, isolation):
metadata = ProjectMetadata(
str(isolation),
None,
{"project": {"optional-dependencies": {"foo": ["proj @ git+https://github.com/org/proj.git@v1"]}}},
)
with pytest.raises(
ValueError,
match=(
"Dependency #1 of option `foo` of field `project.optional-dependencies` cannot be a direct reference "
"unless field `tool.hatch.metadata.allow-direct-references` is set to `true`"
),
):
_ = metadata.core.optional_dependencies
def test_context_formatting(self, isolation, uri_slash_prefix):
metadata = ProjectMetadata(
str(isolation),
None,
{
"project": {"name": "my-app", "optional-dependencies": {"foo": ["proj @ {root:uri}"]}},
"tool": {"hatch": {"metadata": {"allow-direct-references": True}}},
},
)
normalized_path = str(isolation).replace("\\", "/")
assert metadata.core.optional_dependencies == {"foo": [f"proj @ file:{uri_slash_prefix}{normalized_path}"]}
def test_direct_reference_allowed(self, isolation):
metadata = ProjectMetadata(
str(isolation),
None,
{
"project": {
"name": "my-app",
"optional-dependencies": {"foo": ["proj @ git+https://github.com/org/proj.git@v1"]},
},
"tool": {"hatch": {"metadata": {"allow-direct-references": True}}},
},
)
assert metadata.core.optional_dependencies == {"foo": ["proj @ git+https://github.com/org/proj.git@v1"]}
def test_correct(self, isolation):
metadata = ProjectMetadata(
str(isolation),
None,
{
"project": {
"name": "my-app",
"optional-dependencies": {
"foo": [
'python___dateutil;platform_python_implementation=="CPython"',
"bAr.Baz[TLS, Zu.Bat, EdDSA, Zu_Bat] >=1.2RC5 , <9000B1",
'Foo;python_version<"3.8"',
'fOO; python_version< "3.8"',
"MY-APP[zZz]",
],
"bar": ["foo", "bar", "Baz"],
"baz": ["my___app[XYZ]"],
"xyz": ["my...app[Bar]"],
"zzz": ["aaa"],
},
},
},
)
assert (
metadata.core.optional_dependencies
== metadata.core.optional_dependencies
== {
"bar": ["bar", "baz", "foo"],
"baz": ["bar", "baz", "foo"],
"foo": [
"aaa",
"bar-baz[eddsa,tls,zu-bat]<9000b1,>=1.2rc5",
"foo; python_version < '3.8'",
"python-dateutil; platform_python_implementation == 'CPython'",
],
"xyz": ["bar", "baz", "foo"],
"zzz": ["aaa"],
}
)
class TestHook:
def test_unknown(self, isolation):
metadata = ProjectMetadata(
str(isolation),
PluginManager(),
{"project": {"name": "foo"}, "tool": {"hatch": {"metadata": {"hooks": {"foo": {}}}}}},
)
with pytest.raises(ValueError, match="Unknown metadata hook: foo"):
_ = metadata.core
def test_custom(self, temp_dir, helpers):
classifiers = [
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.9",
"Framework :: Foo",
"Development Status :: 4 - Beta",
"Private :: Do Not Upload",
]
metadata = ProjectMetadata(
str(temp_dir),
PluginManager(),
{
"project": {"name": "foo", "classifiers": classifiers, "dynamic": ["version", "description"]},
"tool": {"hatch": {"version": {"path": "a/b"}, "metadata": {"hooks": {"custom": {}}}}},
},
)
file_path = temp_dir / "a" / "b"
file_path.ensure_parent_dir_exists()
file_path.write_text('__version__ = "0.0.1"')
file_path = temp_dir / DEFAULT_BUILD_SCRIPT
file_path.write_text(
helpers.dedent(
"""
from hatchling.metadata.plugin.interface import MetadataHookInterface
class CustomHook(MetadataHookInterface):
def update(self, metadata):
metadata['description'] = metadata['name'] + 'bar'
metadata['version'] = metadata['version'] + 'rc0'
def get_known_classifiers(self):
return ['Framework :: Foo']
"""
)
)
assert "custom" in metadata.hatch.metadata.hooks
assert metadata.core.name == "foo"
assert metadata.core.description == "foobar"
assert metadata.core.version == "0.0.1rc0"
assert metadata.core.classifiers == [
"Private :: Do Not Upload",
"Development Status :: 4 - Beta",
"Framework :: Foo",
"Programming Language :: Python :: 3.9",
"Programming Language :: Python :: 3.11",
]
def test_custom_missing_dynamic(self, temp_dir, helpers):
metadata = ProjectMetadata(
str(temp_dir),
PluginManager(),
{
"project": {"name": "foo", "dynamic": ["version"]},
"tool": {"hatch": {"version": {"path": "a/b"}, "metadata": {"hooks": {"custom": {}}}}},
},
)
file_path = temp_dir / "a" / "b"
file_path.ensure_parent_dir_exists()
file_path.write_text('__version__ = "0.0.1"')
file_path = temp_dir / DEFAULT_BUILD_SCRIPT
file_path.write_text(
helpers.dedent(
"""
from hatchling.metadata.plugin.interface import MetadataHookInterface
class CustomHook(MetadataHookInterface):
def update(self, metadata):
metadata['description'] = metadata['name'] + 'bar'
"""
)
)
with pytest.raises(
ValueError,
match="The field `description` was set dynamically and therefore must be listed in `project.dynamic`",
):
_ = metadata.core
class TestHatchPersonalProjectConfigFile:
def test_correct(self, temp_dir, helpers):
metadata = ProjectMetadata(
str(temp_dir),
PluginManager(),
{
"project": {"name": "foo", "dynamic": ["version"]},
"tool": {"hatch": {"build": {"reproducible": False}}},
},
)
file_path = temp_dir / "a" / "b"
file_path.ensure_parent_dir_exists()
file_path.write_text('__version__ = "0.0.1"')
(temp_dir / "pyproject.toml").touch()
file_path = temp_dir / "hatch.toml"
file_path.write_text(
helpers.dedent(
"""
[version]
path = 'a/b'
"""
)
)
assert metadata.version == "0.0.1"
assert metadata.hatch.build_config["reproducible"] is False
def test_precedence(self, temp_dir, helpers):
metadata = ProjectMetadata(
str(temp_dir),
PluginManager(),
{
"project": {"name": "foo", "dynamic": ["version"]},
"tool": {"hatch": {"version": {"path": "a/b"}, "build": {"reproducible": False}}},
},
)
file_path = temp_dir / "a" / "b"
file_path.ensure_parent_dir_exists()
file_path.write_text('__version__ = "0.0.1"')
file_path = temp_dir / "c" / "d"
file_path.ensure_parent_dir_exists()
file_path.write_text('__version__ = "0.0.2"')
(temp_dir / "pyproject.toml").touch()
file_path = temp_dir / "hatch.toml"
file_path.write_text(
helpers.dedent(
"""
[version]
path = 'c/d'
"""
)
)
assert metadata.version == "0.0.2"
assert metadata.hatch.build_config["reproducible"] is False
class TestMetadataConversion:
def test_required_only(self, isolation, latest_spec):
raw_metadata = {"name": "My.App", "version": "0.0.1"}
metadata = ProjectMetadata(str(isolation), None, {"project": raw_metadata})
core_metadata = latest_spec(metadata)
assert project_metadata_from_core_metadata(core_metadata) == raw_metadata
def test_dynamic(self, isolation, latest_spec):
raw_metadata = {"name": "My.App", "version": "0.0.1", "dynamic": ["authors", "classifiers"]}
metadata = ProjectMetadata(str(isolation), None, {"project": raw_metadata})
core_metadata = latest_spec(metadata)
assert project_metadata_from_core_metadata(core_metadata) == raw_metadata
def test_description(self, isolation, latest_spec):
raw_metadata = {"name": "My.App", "version": "0.0.1", "description": "foo bar"}
metadata = ProjectMetadata(str(isolation), None, {"project": raw_metadata})
core_metadata = latest_spec(metadata)
assert project_metadata_from_core_metadata(core_metadata) == raw_metadata
def test_urls(self, isolation, latest_spec):
raw_metadata = {"name": "My.App", "version": "0.0.1", "urls": {"foo": "bar", "bar": "baz"}}
metadata = ProjectMetadata(str(isolation), None, {"project": raw_metadata})
core_metadata = latest_spec(metadata)
assert project_metadata_from_core_metadata(core_metadata) == raw_metadata
def test_authors(self, isolation, latest_spec):
raw_metadata = {
"name": "My.App",
"version": "0.0.1",
"authors": [{"name": "foobar"}, {"email": "bar@domain", "name": "foo"}, {"email": "baz@domain"}],
}
metadata = ProjectMetadata(str(isolation), None, {"project": raw_metadata})
core_metadata = latest_spec(metadata)
assert project_metadata_from_core_metadata(core_metadata) == raw_metadata
def test_maintainers(self, isolation, latest_spec):
raw_metadata = {
"name": "My.App",
"version": "0.0.1",
"maintainers": [{"name": "foobar"}, {"email": "bar@domain", "name": "foo"}, {"email": "baz@domain"}],
}
metadata = ProjectMetadata(str(isolation), None, {"project": raw_metadata})
core_metadata = latest_spec(metadata)
assert project_metadata_from_core_metadata(core_metadata) == raw_metadata
def test_keywords(self, isolation, latest_spec):
raw_metadata = {"name": "My.App", "version": "0.0.1", "keywords": ["bar", "foo"]}
metadata = ProjectMetadata(str(isolation), None, {"project": raw_metadata})
core_metadata = latest_spec(metadata)
assert project_metadata_from_core_metadata(core_metadata) == raw_metadata
def test_classifiers(self, isolation, latest_spec):
raw_metadata = {
"name": "My.App",
"version": "0.0.1",
"classifiers": ["Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.11"],
}
metadata = ProjectMetadata(str(isolation), None, {"project": raw_metadata})
core_metadata = latest_spec(metadata)
assert project_metadata_from_core_metadata(core_metadata) == raw_metadata
def test_license_files(self, temp_dir, latest_spec):
raw_metadata = {
"name": "My.App",
"version": "0.0.1",
"license-files": ["LICENSES/Apache-2.0.txt", "LICENSES/MIT.txt"],
}
metadata = ProjectMetadata(str(temp_dir), None, {"project": raw_metadata})
licenses_path = temp_dir / "LICENSES"
licenses_path.mkdir()
licenses_path.joinpath("Apache-2.0.txt").touch()
licenses_path.joinpath("MIT.txt").touch()
core_metadata = latest_spec(metadata)
assert project_metadata_from_core_metadata(core_metadata) == raw_metadata
def test_license_expression(self, isolation, latest_spec):
raw_metadata = {"name": "My.App", "version": "0.0.1", "license": "MIT"}
metadata = ProjectMetadata(str(isolation), None, {"project": raw_metadata})
core_metadata = latest_spec(metadata)
assert project_metadata_from_core_metadata(core_metadata) == raw_metadata
def test_license_legacy(self, isolation, latest_spec):
raw_metadata = {"name": "My.App", "version": "0.0.1", "license": {"text": "foo"}}
metadata = ProjectMetadata(str(isolation), None, {"project": raw_metadata})
core_metadata = latest_spec(metadata)
assert project_metadata_from_core_metadata(core_metadata) == raw_metadata
def test_readme(self, isolation, latest_spec):
raw_metadata = {
"name": "My.App",
"version": "0.0.1",
"readme": {"content-type": "text/markdown", "text": "test content\n"},
}
metadata = ProjectMetadata(str(isolation), None, {"project": raw_metadata})
core_metadata = latest_spec(metadata)
assert project_metadata_from_core_metadata(core_metadata) == raw_metadata
def test_requires_python(self, isolation, latest_spec):
raw_metadata = {"name": "My.App", "version": "0.0.1", "requires-python": "<2,>=1"}
metadata = ProjectMetadata(str(isolation), None, {"project": raw_metadata})
core_metadata = latest_spec(metadata)
assert project_metadata_from_core_metadata(core_metadata) == raw_metadata
def test_dependencies(self, isolation, latest_spec):
raw_metadata = {
"name": "My.App",
"version": "0.0.1",
"dependencies": ["bar==5", "foo==1"],
"optional-dependencies": {
"feature1": ['bar==5; python_version < "3"', "foo==1"],
"feature2": ["bar==5", 'foo==1; python_version < "3"'],
},
}
metadata = ProjectMetadata(str(isolation), None, {"project": raw_metadata})
core_metadata = latest_spec(metadata)
assert project_metadata_from_core_metadata(core_metadata) == raw_metadata
class TestSourceDistributionMetadata:
def test_basic_persistence(self, temp_dir, helpers):
metadata = ProjectMetadata(
str(temp_dir),
None,
{
"project": {
"name": "My.App",
"dynamic": ["version", "keywords"],
"dependencies": ["foo==1"],
"scripts": {"foo": "bar"},
},
},
)
pkg_info = temp_dir / "PKG-INFO"
pkg_info.write_text(
helpers.dedent(
f"""
Metadata-Version: {LATEST_METADATA_VERSION}
Name: My.App
Version: 0.0.1
Keywords: foo,bar
Requires-Dist: bar==5
"""
)
)
with temp_dir.as_cwd():
assert metadata.core.config == {
"name": "My.App",
"version": "0.0.1",
"dependencies": ["foo==1"],
"keywords": ["foo", "bar"],
"scripts": {"foo": "bar"},
"dynamic": [],
}
def test_metadata_hooks(self, temp_dir, helpers):
metadata = ProjectMetadata(
str(temp_dir),
PluginManager(),
{
"project": {
"name": "My.App",
"dynamic": ["version", "keywords", "description", "scripts"],
"dependencies": ["foo==1"],
},
"tool": {"hatch": {"metadata": {"hooks": {"custom": {}}}}},
},
)
build_script = temp_dir / DEFAULT_BUILD_SCRIPT
build_script.write_text(
helpers.dedent(
"""
from hatchling.metadata.plugin.interface import MetadataHookInterface
class CustomHook(MetadataHookInterface):
def update(self, metadata):
metadata['description'] = metadata['name'] + ' bar'
metadata['scripts'] = {'foo': 'bar'}
"""
)
)
pkg_info = temp_dir / "PKG-INFO"
pkg_info.write_text(
helpers.dedent(
f"""
Metadata-Version: {LATEST_METADATA_VERSION}
Name: My.App
Version: 0.0.1
Summary: My.App bar
Keywords: foo,bar
Requires-Dist: bar==5
"""
)
)
with temp_dir.as_cwd():
assert metadata.core.config == {
"name": "My.App",
"version": "0.0.1",
"description": "My.App bar",
"dependencies": ["foo==1"],
"keywords": ["foo", "bar"],
"scripts": {"foo": "bar"},
"dynamic": ["scripts"],
}
================================================
FILE: tests/backend/metadata/test_custom_hook.py
================================================
import re
import pytest
from hatchling.metadata.custom import CustomMetadataHook
from hatchling.utils.constants import DEFAULT_BUILD_SCRIPT
def test_no_path(isolation):
config = {"path": ""}
with pytest.raises(ValueError, match="Option `path` for metadata hook `custom` must not be empty if defined"):
CustomMetadataHook(str(isolation), config)
def test_path_not_string(isolation):
config = {"path": 3}
with pytest.raises(TypeError, match="Option `path` for metadata hook `custom` must be a string"):
CustomMetadataHook(str(isolation), config)
def test_nonexistent(isolation):
config = {"path": "test.py"}
with pytest.raises(OSError, match="Build script does not exist: test.py"):
CustomMetadataHook(str(isolation), config)
def test_default(temp_dir, helpers):
config = {}
file_path = temp_dir / DEFAULT_BUILD_SCRIPT
file_path.write_text(
helpers.dedent(
"""
from hatchling.metadata.plugin.interface import MetadataHookInterface
class CustomHook(MetadataHookInterface):
def update(self, metadata):
pass
def foo(self):
return self.PLUGIN_NAME, self.root
"""
)
)
with temp_dir.as_cwd():
hook = CustomMetadataHook(str(temp_dir), config)
assert hook.foo() == ("custom", str(temp_dir))
def test_explicit_path(temp_dir, helpers):
config = {"path": f"foo/{DEFAULT_BUILD_SCRIPT}"}
file_path = temp_dir / "foo" / DEFAULT_BUILD_SCRIPT
file_path.ensure_parent_dir_exists()
file_path.write_text(
helpers.dedent(
"""
from hatchling.metadata.plugin.interface import MetadataHookInterface
class CustomHook(MetadataHookInterface):
def update(self, metadata):
pass
def foo(self):
return self.PLUGIN_NAME, self.root
"""
)
)
with temp_dir.as_cwd():
hook = CustomMetadataHook(str(temp_dir), config)
assert hook.foo() == ("custom", str(temp_dir))
def test_no_subclass(temp_dir, helpers):
config = {"path": f"foo/{DEFAULT_BUILD_SCRIPT}"}
file_path = temp_dir / "foo" / DEFAULT_BUILD_SCRIPT
file_path.ensure_parent_dir_exists()
file_path.write_text(
helpers.dedent(
"""
from hatchling.metadata.plugin.interface import MetadataHookInterface
foo = None
bar = 'baz'
class CustomHook:
pass
"""
)
)
with (
pytest.raises(
ValueError,
match=re.escape(
f"Unable to find a subclass of `MetadataHookInterface` in `foo/{DEFAULT_BUILD_SCRIPT}`: {temp_dir}"
),
),
temp_dir.as_cwd(),
):
CustomMetadataHook(str(temp_dir), config)
================================================
FILE: tests/backend/metadata/test_hatch.py
================================================
import pytest
from hatchling.metadata.core import HatchMetadata
from hatchling.plugin.manager import PluginManager
from hatchling.version.scheme.standard import StandardScheme
from hatchling.version.source.regex import RegexSource
class TestBuildConfig:
def test_default(self, isolation):
config = {}
metadata = HatchMetadata(str(isolation), config, None)
assert metadata.build_config == metadata.build_config == {}
def test_not_table(self, isolation):
config = {"build": 0}
metadata = HatchMetadata(str(isolation), config, None)
with pytest.raises(TypeError, match="Field `tool.hatch.build` must be a table"):
_ = metadata.build_config
def test_correct(self, isolation):
config = {"build": {"reproducible": True}}
metadata = HatchMetadata(str(isolation), config, None)
assert metadata.build_config == metadata.build_config == {"reproducible": True}
class TestBuildTargets:
def test_default(self, isolation):
config = {}
metadata = HatchMetadata(str(isolation), config, None)
assert metadata.build_targets == metadata.build_targets == {}
def test_not_table(self, isolation):
config = {"build": {"targets": 0}}
metadata = HatchMetadata(str(isolation), config, None)
with pytest.raises(TypeError, match="Field `tool.hatch.build.targets` must be a table"):
_ = metadata.build_targets
def test_correct(self, isolation):
config = {"build": {"targets": {"wheel": {"versions": ["standard"]}}}}
metadata = HatchMetadata(str(isolation), config, None)
assert metadata.build_targets == metadata.build_targets == {"wheel": {"versions": ["standard"]}}
class TestVersionSourceName:
def test_empty(self, isolation):
with pytest.raises(
ValueError, match="The `source` option under the `tool.hatch.version` table must not be empty if defined"
):
_ = HatchMetadata(isolation, {"version": {"source": ""}}, None).version.source_name
def test_not_table(self, isolation):
with pytest.raises(TypeError, match="Field `tool.hatch.version.source` must be a string"):
_ = HatchMetadata(isolation, {"version": {"source": 9000}}, None).version.source_name
def test_correct(self, isolation):
metadata = HatchMetadata(isolation, {"version": {"source": "foo"}}, None)
assert metadata.version.source_name == metadata.version.source_name == "foo"
def test_default(self, isolation):
metadata = HatchMetadata(isolation, {"version": {}}, None)
assert metadata.version.source_name == metadata.version.source_name == "regex"
class TestVersionSchemeName:
def test_missing(self, isolation):
with pytest.raises(
ValueError, match="The `scheme` option under the `tool.hatch.version` table must not be empty if defined"
):
_ = HatchMetadata(isolation, {"version": {"scheme": ""}}, None).version.scheme_name
def test_not_table(self, isolation):
with pytest.raises(TypeError, match="Field `tool.hatch.version.scheme` must be a string"):
_ = HatchMetadata(isolation, {"version": {"scheme": 9000}}, None).version.scheme_name
def test_correct(self, isolation):
metadata = HatchMetadata(isolation, {"version": {"scheme": "foo"}}, None)
assert metadata.version.scheme_name == metadata.version.scheme_name == "foo"
def test_default(self, isolation):
metadata = HatchMetadata(isolation, {"version": {}}, None)
assert metadata.version.scheme_name == metadata.version.scheme_name == "standard"
class TestVersionSource:
def test_unknown(self, isolation):
with pytest.raises(ValueError, match="Unknown version source: foo"):
_ = HatchMetadata(isolation, {"version": {"source": "foo"}}, PluginManager()).version.source
def test_cached(self, isolation):
metadata = HatchMetadata(isolation, {"version": {}}, PluginManager())
assert metadata.version.source is metadata.version.source
assert isinstance(metadata.version.source, RegexSource)
class TestVersionScheme:
def test_unknown(self, isolation):
with pytest.raises(ValueError, match="Unknown version scheme: foo"):
_ = HatchMetadata(isolation, {"version": {"scheme": "foo"}}, PluginManager()).version.scheme
def test_cached(self, isolation):
metadata = HatchMetadata(isolation, {"version": {}}, PluginManager())
assert metadata.version.scheme is metadata.version.scheme
assert isinstance(metadata.version.scheme, StandardScheme)
class TestMetadata:
def test_default(self, isolation):
config = {}
metadata = HatchMetadata(str(isolation), config, None)
assert metadata.metadata.config == metadata.metadata.config == {}
def test_not_table(self, isolation):
config = {"metadata": 0}
metadata = HatchMetadata(str(isolation), config, None)
with pytest.raises(TypeError, match="Field `tool.hatch.metadata` must be a table"):
_ = metadata.metadata.config
def test_correct(self, isolation):
config = {"metadata": {"option": True}}
metadata = HatchMetadata(str(isolation), config, None)
assert metadata.metadata.config == metadata.metadata.config == {"option": True}
class TestMetadataAllowDirectReferences:
def test_default(self, isolation):
config = {}
metadata = HatchMetadata(str(isolation), config, None)
assert metadata.metadata.allow_direct_references is metadata.metadata.allow_direct_references is False
def test_not_boolean(self, isolation):
config = {"metadata": {"allow-direct-references": 9000}}
metadata = HatchMetadata(str(isolation), config, None)
with pytest.raises(TypeError, match="Field `tool.hatch.metadata.allow-direct-references` must be a boolean"):
_ = metadata.metadata.allow_direct_references
def test_correct(self, isolation):
config = {"metadata": {"allow-direct-references": True}}
metadata = HatchMetadata(str(isolation), config, None)
assert metadata.metadata.allow_direct_references is True
class TestMetadataAllowAmbiguousFeatures:
def test_default(self, isolation):
config = {}
metadata = HatchMetadata(str(isolation), config, None)
assert metadata.metadata.allow_ambiguous_features is metadata.metadata.allow_ambiguous_features is False
def test_not_boolean(self, isolation):
config = {"metadata": {"allow-ambiguous-features": 9000}}
metadata = HatchMetadata(str(isolation), config, None)
with pytest.raises(TypeError, match="Field `tool.hatch.metadata.allow-ambiguous-features` must be a boolean"):
_ = metadata.metadata.allow_ambiguous_features
def test_correct(self, isolation):
config = {"metadata": {"allow-ambiguous-features": True}}
metadata = HatchMetadata(str(isolation), config, None)
assert metadata.metadata.allow_ambiguous_features is True
================================================
FILE: tests/backend/metadata/test_spec.py
================================================
import pytest
from hatchling.metadata.core import ProjectMetadata
from hatchling.metadata.spec import (
LATEST_METADATA_VERSION,
get_core_metadata_constructors,
project_metadata_from_core_metadata,
)
class TestProjectMetadataFromCoreMetadata:
def test_missing_name(self):
core_metadata = f"""\
Metadata-Version: {LATEST_METADATA_VERSION}
"""
with pytest.raises(ValueError, match="^Missing required core metadata: Name$"):
project_metadata_from_core_metadata(core_metadata)
def test_missing_version(self):
core_metadata = f"""\
Metadata-Version: {LATEST_METADATA_VERSION}
Name: My.App
"""
with pytest.raises(ValueError, match="^Missing required core metadata: Version$"):
project_metadata_from_core_metadata(core_metadata)
def test_dynamic(self):
core_metadata = f"""\
Metadata-Version: {LATEST_METADATA_VERSION}
Name: My.App
Version: 0.1.0
Dynamic: Classifier
Dynamic: Provides-Extra
"""
assert project_metadata_from_core_metadata(core_metadata) == {
"name": "My.App",
"version": "0.1.0",
"dynamic": ["classifiers", "dependencies", "optional-dependencies"],
}
def test_description(self):
core_metadata = f"""\
Metadata-Version: {LATEST_METADATA_VERSION}
Name: My.App
Version: 0.1.0
Summary: foo
"""
assert project_metadata_from_core_metadata(core_metadata) == {
"name": "My.App",
"version": "0.1.0",
"description": "foo",
}
def test_urls(self):
core_metadata = f"""\
Metadata-Version: {LATEST_METADATA_VERSION}
Name: My.App
Version: 0.1.0
Project-URL: foo, bar
Project-URL: bar, baz
"""
assert project_metadata_from_core_metadata(core_metadata) == {
"name": "My.App",
"version": "0.1.0",
"urls": {"foo": "bar", "bar": "baz"},
}
def test_authors(self):
core_metadata = f"""\
Metadata-Version: {LATEST_METADATA_VERSION}
Name: My.App
Version: 0.1.0
Author: foobar
Author-email: foo ,
"""
assert project_metadata_from_core_metadata(core_metadata) == {
"name": "My.App",
"version": "0.1.0",
"authors": [{"name": "foobar"}, {"email": "bar@domain", "name": "foo"}, {"email": "baz@domain"}],
}
def test_maintainers(self):
core_metadata = f"""\
Metadata-Version: {LATEST_METADATA_VERSION}
Name: My.App
Version: 0.1.0
Maintainer: foobar
Maintainer-email: foo ,
"""
assert project_metadata_from_core_metadata(core_metadata) == {
"name": "My.App",
"version": "0.1.0",
"maintainers": [{"name": "foobar"}, {"email": "bar@domain", "name": "foo"}, {"email": "baz@domain"}],
}
def test_keywords(self):
core_metadata = f"""\
Metadata-Version: {LATEST_METADATA_VERSION}
Name: My.App
Version: 0.1.0
Keywords: bar,foo
"""
assert project_metadata_from_core_metadata(core_metadata) == {
"name": "My.App",
"version": "0.1.0",
"keywords": ["bar", "foo"],
}
def test_classifiers(self):
core_metadata = f"""\
Metadata-Version: {LATEST_METADATA_VERSION}
Name: My.App
Version: 0.1.0
Classifier: Programming Language :: Python :: 3.9
Classifier: Programming Language :: Python :: 3.11
"""
assert project_metadata_from_core_metadata(core_metadata) == {
"name": "My.App",
"version": "0.1.0",
"classifiers": ["Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.11"],
}
def test_license_files(self):
core_metadata = f"""\
Metadata-Version: {LATEST_METADATA_VERSION}
Name: My.App
Version: 0.1.0
License-File: LICENSES/Apache-2.0.txt
License-File: LICENSES/MIT.txt
"""
assert project_metadata_from_core_metadata(core_metadata) == {
"name": "My.App",
"version": "0.1.0",
"license-files": ["LICENSES/Apache-2.0.txt", "LICENSES/MIT.txt"],
}
def test_license_expression(self):
core_metadata = f"""\
Metadata-Version: {LATEST_METADATA_VERSION}
Name: My.App
Version: 0.1.0
License-Expression: MIT
"""
assert project_metadata_from_core_metadata(core_metadata) == {
"name": "My.App",
"version": "0.1.0",
"license": "MIT",
}
def test_license_legacy(self):
core_metadata = f"""\
Metadata-Version: {LATEST_METADATA_VERSION}
Name: My.App
Version: 0.1.0
License: foo
"""
assert project_metadata_from_core_metadata(core_metadata) == {
"name": "My.App",
"version": "0.1.0",
"license": {"text": "foo"},
}
def test_readme(self):
core_metadata = f"""\
Metadata-Version: {LATEST_METADATA_VERSION}
Name: My.App
Version: 0.1.0
Description-Content-Type: text/markdown
test content
"""
assert project_metadata_from_core_metadata(core_metadata) == {
"name": "My.App",
"version": "0.1.0",
"readme": {"content-type": "text/markdown", "text": "test content\n"},
}
def test_readme_default_content_type(self):
core_metadata = f"""\
Metadata-Version: {LATEST_METADATA_VERSION}
Name: My.App
Version: 0.1.0
test content
"""
assert project_metadata_from_core_metadata(core_metadata) == {
"name": "My.App",
"version": "0.1.0",
"readme": {"content-type": "text/plain", "text": "test content\n"},
}
def test_requires_python(self):
core_metadata = f"""\
Metadata-Version: {LATEST_METADATA_VERSION}
Name: My.App
Version: 0.1.0
Requires-Python: <2,>=1
"""
assert project_metadata_from_core_metadata(core_metadata) == {
"name": "My.App",
"version": "0.1.0",
"requires-python": "<2,>=1",
}
def test_dependencies(self):
core_metadata = f"""\
Metadata-Version: {LATEST_METADATA_VERSION}
Name: My.App
Version: 0.1.0
Requires-Dist: bar==5
Requires-Dist: foo==1
Provides-Extra: feature1
Requires-Dist: bar==5; (python_version < '3') and extra == 'feature1'
Requires-Dist: foo==1; extra == 'feature1'
Provides-Extra: feature2
Requires-Dist: bar==5; extra == 'feature2'
Requires-Dist: foo==1; (python_version < '3') and extra == 'feature2'
Provides-Extra: feature3
Requires-Dist: baz @ file:///path/to/project ; extra == 'feature3'
"""
assert project_metadata_from_core_metadata(core_metadata) == {
"name": "My.App",
"version": "0.1.0",
"dependencies": ["bar==5", "foo==1"],
"optional-dependencies": {
"feature1": ['bar==5; python_version < "3"', "foo==1"],
"feature2": ["bar==5", 'foo==1; python_version < "3"'],
"feature3": ["baz @ file:///path/to/project"],
},
}
@pytest.mark.parametrize("constructor", [get_core_metadata_constructors()["1.2"]])
class TestCoreMetadataV12:
def test_default(self, constructor, isolation, helpers):
metadata = ProjectMetadata(str(isolation), None, {"project": {"name": "My.App", "version": "0.1.0"}})
assert constructor(metadata) == helpers.dedent(
"""
Metadata-Version: 1.2
Name: My.App
Version: 0.1.0
"""
)
def test_description(self, constructor, isolation, helpers):
metadata = ProjectMetadata(
str(isolation), None, {"project": {"name": "My.App", "version": "0.1.0", "description": "foo"}}
)
assert constructor(metadata) == helpers.dedent(
"""
Metadata-Version: 1.2
Name: My.App
Version: 0.1.0
Summary: foo
"""
)
def test_urls(self, constructor, isolation, helpers):
metadata = ProjectMetadata(
str(isolation),
None,
{"project": {"name": "My.App", "version": "0.1.0", "urls": {"foo": "bar", "bar": "baz"}}},
)
assert constructor(metadata) == helpers.dedent(
"""
Metadata-Version: 1.2
Name: My.App
Version: 0.1.0
Project-URL: foo, bar
Project-URL: bar, baz
"""
)
def test_authors_name(self, constructor, isolation, helpers):
metadata = ProjectMetadata(
str(isolation), None, {"project": {"name": "My.App", "version": "0.1.0", "authors": [{"name": "foo"}]}}
)
assert constructor(metadata) == helpers.dedent(
"""
Metadata-Version: 1.2
Name: My.App
Version: 0.1.0
Author: foo
"""
)
def test_authors_email(self, constructor, isolation, helpers):
metadata = ProjectMetadata(
str(isolation),
None,
{"project": {"name": "My.App", "version": "0.1.0", "authors": [{"email": "foo@domain"}]}},
)
assert constructor(metadata) == helpers.dedent(
"""
Metadata-Version: 1.2
Name: My.App
Version: 0.1.0
Author-email: foo@domain
"""
)
def test_authors_name_and_email(self, constructor, isolation, helpers):
metadata = ProjectMetadata(
str(isolation),
None,
{"project": {"name": "My.App", "version": "0.1.0", "authors": [{"email": "bar@domain", "name": "foo"}]}},
)
assert constructor(metadata) == helpers.dedent(
"""
Metadata-Version: 1.2
Name: My.App
Version: 0.1.0
Author-email: foo
"""
)
def test_authors_multiple(self, constructor, isolation, helpers):
metadata = ProjectMetadata(
str(isolation),
None,
{"project": {"name": "My.App", "version": "0.1.0", "authors": [{"name": "foo"}, {"name": "bar"}]}},
)
assert constructor(metadata) == helpers.dedent(
"""
Metadata-Version: 1.2
Name: My.App
Version: 0.1.0
Author: foo, bar
"""
)
def test_maintainers_name(self, constructor, isolation, helpers):
metadata = ProjectMetadata(
str(isolation), None, {"project": {"name": "My.App", "version": "0.1.0", "maintainers": [{"name": "foo"}]}}
)
assert constructor(metadata) == helpers.dedent(
"""
Metadata-Version: 1.2
Name: My.App
Version: 0.1.0
Maintainer: foo
"""
)
def test_maintainers_email(self, constructor, isolation, helpers):
metadata = ProjectMetadata(
str(isolation),
None,
{"project": {"name": "My.App", "version": "0.1.0", "maintainers": [{"email": "foo@domain"}]}},
)
assert constructor(metadata) == helpers.dedent(
"""
Metadata-Version: 1.2
Name: My.App
Version: 0.1.0
Maintainer-email: foo@domain
"""
)
def test_maintainers_name_and_email(self, constructor, isolation, helpers):
metadata = ProjectMetadata(
str(isolation),
None,
{
"project": {
"name": "My.App",
"version": "0.1.0",
"maintainers": [{"email": "bar@domain", "name": "foo"}],
}
},
)
assert constructor(metadata) == helpers.dedent(
"""
Metadata-Version: 1.2
Name: My.App
Version: 0.1.0
Maintainer-email: foo
"""
)
def test_maintainers_multiple(self, constructor, isolation, helpers):
metadata = ProjectMetadata(
str(isolation),
None,
{"project": {"name": "My.App", "version": "0.1.0", "maintainers": [{"name": "foo"}, {"name": "bar"}]}},
)
assert constructor(metadata) == helpers.dedent(
"""
Metadata-Version: 1.2
Name: My.App
Version: 0.1.0
Maintainer: foo, bar
"""
)
def test_license(self, constructor, isolation, helpers):
metadata = ProjectMetadata(
str(isolation), None, {"project": {"name": "My.App", "version": "0.1.0", "license": {"text": "foo\nbar"}}}
)
assert constructor(metadata) == helpers.dedent(
"""
Metadata-Version: 1.2
Name: My.App
Version: 0.1.0
License: foo
bar
"""
)
def test_license_expression(self, constructor, isolation, helpers):
metadata = ProjectMetadata(
str(isolation),
None,
{"project": {"name": "My.App", "version": "0.1.0", "license": "mit"}},
)
assert constructor(metadata) == helpers.dedent(
"""
Metadata-Version: 1.2
Name: My.App
Version: 0.1.0
License: MIT
"""
)
def test_keywords_single(self, constructor, isolation, helpers):
metadata = ProjectMetadata(
str(isolation), None, {"project": {"name": "My.App", "version": "0.1.0", "keywords": ["foo"]}}
)
assert constructor(metadata) == helpers.dedent(
"""
Metadata-Version: 1.2
Name: My.App
Version: 0.1.0
Keywords: foo
"""
)
def test_keywords_multiple(self, constructor, isolation, helpers):
metadata = ProjectMetadata(
str(isolation), None, {"project": {"name": "My.App", "version": "0.1.0", "keywords": ["foo", "bar"]}}
)
assert constructor(metadata) == helpers.dedent(
"""
Metadata-Version: 1.2
Name: My.App
Version: 0.1.0
Keywords: bar,foo
"""
)
def test_classifiers(self, constructor, isolation, helpers):
classifiers = [
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.9",
]
metadata = ProjectMetadata(
str(isolation), None, {"project": {"name": "My.App", "version": "0.1.0", "classifiers": classifiers}}
)
assert constructor(metadata) == helpers.dedent(
"""
Metadata-Version: 1.2
Name: My.App
Version: 0.1.0
Classifier: Programming Language :: Python :: 3.9
Classifier: Programming Language :: Python :: 3.11
"""
)
def test_requires_python(self, constructor, isolation, helpers):
metadata = ProjectMetadata(
str(isolation), None, {"project": {"name": "My.App", "version": "0.1.0", "requires-python": ">=1,<2"}}
)
assert constructor(metadata) == helpers.dedent(
"""
Metadata-Version: 1.2
Name: My.App
Version: 0.1.0
Requires-Python: <2,>=1
"""
)
def test_dependencies(self, constructor, isolation, helpers):
metadata = ProjectMetadata(
str(isolation),
None,
{"project": {"name": "My.App", "version": "0.1.0", "dependencies": ["foo==1", "bar==5"]}},
)
assert constructor(metadata) == helpers.dedent(
"""
Metadata-Version: 1.2
Name: My.App
Version: 0.1.0
Requires-Dist: bar==5
Requires-Dist: foo==1
"""
)
def test_extra_runtime_dependencies(self, constructor, isolation, helpers):
metadata = ProjectMetadata(
str(isolation),
None,
{"project": {"name": "My.App", "version": "0.1.0", "dependencies": ["foo==1", "bar==5"]}},
)
assert constructor(metadata, extra_dependencies=["baz==9"]) == helpers.dedent(
"""
Metadata-Version: 1.2
Name: My.App
Version: 0.1.0
Requires-Dist: bar==5
Requires-Dist: foo==1
Requires-Dist: baz==9
"""
)
def test_all(self, constructor, isolation, helpers):
metadata = ProjectMetadata(
str(isolation),
None,
{
"project": {
"name": "My.App",
"version": "0.1.0",
"description": "foo",
"urls": {"foo": "bar", "bar": "baz"},
"authors": [{"email": "bar@domain", "name": "foo"}],
"maintainers": [{"email": "bar@domain", "name": "foo"}],
"license": {"text": "foo\nbar"},
"keywords": ["foo", "bar"],
"classifiers": [
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.9",
],
"requires-python": ">=1,<2",
"dependencies": ["foo==1", "bar==5"],
}
},
)
assert constructor(metadata) == helpers.dedent(
"""
Metadata-Version: 1.2
Name: My.App
Version: 0.1.0
Summary: foo
Project-URL: foo, bar
Project-URL: bar, baz
Author-email: foo
Maintainer-email: foo
License: foo
bar
Keywords: bar,foo
Classifier: Programming Language :: Python :: 3.9
Classifier: Programming Language :: Python :: 3.11
Requires-Python: <2,>=1
Requires-Dist: bar==5
Requires-Dist: foo==1
"""
)
@pytest.mark.parametrize("constructor", [get_core_metadata_constructors()["2.1"]])
class TestCoreMetadataV21:
def test_default(self, constructor, isolation, helpers):
metadata = ProjectMetadata(str(isolation), None, {"project": {"name": "My.App", "version": "0.1.0"}})
assert constructor(metadata) == helpers.dedent(
"""
Metadata-Version: 2.1
Name: My.App
Version: 0.1.0
"""
)
def test_description(self, constructor, isolation, helpers):
metadata = ProjectMetadata(
str(isolation), None, {"project": {"name": "My.App", "version": "0.1.0", "description": "foo"}}
)
assert constructor(metadata) == helpers.dedent(
"""
Metadata-Version: 2.1
Name: My.App
Version: 0.1.0
Summary: foo
"""
)
def test_urls(self, constructor, isolation, helpers):
metadata = ProjectMetadata(
str(isolation),
None,
{"project": {"name": "My.App", "version": "0.1.0", "urls": {"foo": "bar", "bar": "baz"}}},
)
assert constructor(metadata) == helpers.dedent(
"""
Metadata-Version: 2.1
Name: My.App
Version: 0.1.0
Project-URL: foo, bar
Project-URL: bar, baz
"""
)
def test_authors_name(self, constructor, isolation, helpers):
metadata = ProjectMetadata(
str(isolation), None, {"project": {"name": "My.App", "version": "0.1.0", "authors": [{"name": "foo"}]}}
)
assert constructor(metadata) == helpers.dedent(
"""
Metadata-Version: 2.1
Name: My.App
Version: 0.1.0
Author: foo
"""
)
def test_authors_email(self, constructor, isolation, helpers):
metadata = ProjectMetadata(
str(isolation),
None,
{"project": {"name": "My.App", "version": "0.1.0", "authors": [{"email": "foo@domain"}]}},
)
assert constructor(metadata) == helpers.dedent(
"""
Metadata-Version: 2.1
Name: My.App
Version: 0.1.0
Author-email: foo@domain
"""
)
def test_authors_name_and_email(self, constructor, isolation, helpers):
metadata = ProjectMetadata(
str(isolation),
None,
{"project": {"name": "My.App", "version": "0.1.0", "authors": [{"email": "bar@domain", "name": "foo"}]}},
)
assert constructor(metadata) == helpers.dedent(
"""
Metadata-Version: 2.1
Name: My.App
Version: 0.1.0
Author-email: foo
"""
)
def test_authors_multiple(self, constructor, isolation, helpers):
metadata = ProjectMetadata(
str(isolation),
None,
{"project": {"name": "My.App", "version": "0.1.0", "authors": [{"name": "foo"}, {"name": "bar"}]}},
)
assert constructor(metadata) == helpers.dedent(
"""
Metadata-Version: 2.1
Name: My.App
Version: 0.1.0
Author: foo, bar
"""
)
def test_maintainers_name(self, constructor, isolation, helpers):
metadata = ProjectMetadata(
str(isolation), None, {"project": {"name": "My.App", "version": "0.1.0", "maintainers": [{"name": "foo"}]}}
)
assert constructor(metadata) == helpers.dedent(
"""
Metadata-Version: 2.1
Name: My.App
Version: 0.1.0
Maintainer: foo
"""
)
def test_maintainers_email(self, constructor, isolation, helpers):
metadata = ProjectMetadata(
str(isolation),
None,
{"project": {"name": "My.App", "version": "0.1.0", "maintainers": [{"email": "foo@domain"}]}},
)
assert constructor(metadata) == helpers.dedent(
"""
Metadata-Version: 2.1
Name: My.App
Version: 0.1.0
Maintainer-email: foo@domain
"""
)
def test_maintainers_name_and_email(self, constructor, isolation, helpers):
metadata = ProjectMetadata(
str(isolation),
None,
{
"project": {
"name": "My.App",
"version": "0.1.0",
"maintainers": [{"email": "bar@domain", "name": "foo"}],
}
},
)
assert constructor(metadata) == helpers.dedent(
"""
Metadata-Version: 2.1
Name: My.App
Version: 0.1.0
Maintainer-email: foo
"""
)
def test_maintainers_multiple(self, constructor, isolation, helpers):
metadata = ProjectMetadata(
str(isolation),
None,
{"project": {"name": "My.App", "version": "0.1.0", "maintainers": [{"name": "foo"}, {"name": "bar"}]}},
)
assert constructor(metadata) == helpers.dedent(
"""
Metadata-Version: 2.1
Name: My.App
Version: 0.1.0
Maintainer: foo, bar
"""
)
def test_license(self, constructor, isolation, helpers):
metadata = ProjectMetadata(
str(isolation), None, {"project": {"name": "My.App", "version": "0.1.0", "license": {"text": "foo\nbar"}}}
)
assert constructor(metadata) == helpers.dedent(
"""
Metadata-Version: 2.1
Name: My.App
Version: 0.1.0
License: foo
bar
"""
)
def test_license_expression(self, constructor, isolation, helpers):
metadata = ProjectMetadata(
str(isolation),
None,
{"project": {"name": "My.App", "version": "0.1.0", "license": "mit"}},
)
assert constructor(metadata) == helpers.dedent(
"""
Metadata-Version: 2.1
Name: My.App
Version: 0.1.0
License: MIT
"""
)
def test_keywords_single(self, constructor, isolation, helpers):
metadata = ProjectMetadata(
str(isolation), None, {"project": {"name": "My.App", "version": "0.1.0", "keywords": ["foo"]}}
)
assert constructor(metadata) == helpers.dedent(
"""
Metadata-Version: 2.1
Name: My.App
Version: 0.1.0
Keywords: foo
"""
)
def test_keywords_multiple(self, constructor, isolation, helpers):
metadata = ProjectMetadata(
str(isolation), None, {"project": {"name": "My.App", "version": "0.1.0", "keywords": ["foo", "bar"]}}
)
assert constructor(metadata) == helpers.dedent(
"""
Metadata-Version: 2.1
Name: My.App
Version: 0.1.0
Keywords: bar,foo
"""
)
def test_classifiers(self, constructor, isolation, helpers):
classifiers = [
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.9",
]
metadata = ProjectMetadata(
str(isolation), None, {"project": {"name": "My.App", "version": "0.1.0", "classifiers": classifiers}}
)
assert constructor(metadata) == helpers.dedent(
"""
Metadata-Version: 2.1
Name: My.App
Version: 0.1.0
Classifier: Programming Language :: Python :: 3.9
Classifier: Programming Language :: Python :: 3.11
"""
)
def test_requires_python(self, constructor, isolation, helpers):
metadata = ProjectMetadata(
str(isolation), None, {"project": {"name": "My.App", "version": "0.1.0", "requires-python": ">=1,<2"}}
)
assert constructor(metadata) == helpers.dedent(
"""
Metadata-Version: 2.1
Name: My.App
Version: 0.1.0
Requires-Python: <2,>=1
"""
)
def test_dependencies(self, constructor, isolation, helpers):
metadata = ProjectMetadata(
str(isolation),
None,
{"project": {"name": "My.App", "version": "0.1.0", "dependencies": ["foo==1", "bar==5"]}},
)
assert constructor(metadata) == helpers.dedent(
"""
Metadata-Version: 2.1
Name: My.App
Version: 0.1.0
Requires-Dist: bar==5
Requires-Dist: foo==1
"""
)
def test_optional_dependencies(self, constructor, isolation, helpers):
metadata = ProjectMetadata(
str(isolation),
None,
{
"project": {
"name": "My.App",
"version": "0.1.0",
"optional-dependencies": {
"feature2": ['foo==1; python_version < "3"', "bar==5"],
"feature1": ["foo==1", 'bar==5; python_version < "3"'],
},
}
},
)
assert constructor(metadata) == helpers.dedent(
"""
Metadata-Version: 2.1
Name: My.App
Version: 0.1.0
Provides-Extra: feature1
Requires-Dist: bar==5; (python_version < '3') and extra == 'feature1'
Requires-Dist: foo==1; extra == 'feature1'
Provides-Extra: feature2
Requires-Dist: bar==5; extra == 'feature2'
Requires-Dist: foo==1; (python_version < '3') and extra == 'feature2'
"""
)
def test_extra_runtime_dependencies(self, constructor, isolation, helpers):
metadata = ProjectMetadata(
str(isolation),
None,
{"project": {"name": "My.App", "version": "0.1.0", "dependencies": ["foo==1", "bar==5"]}},
)
assert constructor(metadata, extra_dependencies=["baz==9"]) == helpers.dedent(
"""
Metadata-Version: 2.1
Name: My.App
Version: 0.1.0
Requires-Dist: bar==5
Requires-Dist: foo==1
Requires-Dist: baz==9
"""
)
def test_readme(self, constructor, isolation, helpers):
metadata = ProjectMetadata(
str(isolation),
None,
{
"project": {
"name": "My.App",
"version": "0.1.0",
"readme": {"content-type": "text/markdown", "text": "test content\n"},
}
},
)
assert constructor(metadata) == helpers.dedent(
"""
Metadata-Version: 2.1
Name: My.App
Version: 0.1.0
Description-Content-Type: text/markdown
test content
"""
)
def test_all(self, constructor, helpers, temp_dir):
metadata = ProjectMetadata(
str(temp_dir),
None,
{
"project": {
"name": "My.App",
"version": "0.1.0",
"description": "foo",
"urls": {"foo": "bar", "bar": "baz"},
"authors": [{"email": "bar@domain", "name": "foo"}],
"maintainers": [{"email": "bar@domain", "name": "foo"}],
"license": {"text": "foo\nbar"},
"keywords": ["foo", "bar"],
"classifiers": [
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.9",
],
"requires-python": ">=1,<2",
"dependencies": ["foo==1", "bar==5"],
"optional-dependencies": {
"feature2": ['foo==1; python_version < "3"', "bar==5"],
"feature1": ["foo==1", 'bar==5; python_version < "3"'],
"feature3": ["baz @ file:///path/to/project"],
},
"readme": {"content-type": "text/markdown", "text": "test content\n"},
},
"tool": {"hatch": {"metadata": {"allow-direct-references": True}}},
},
)
(temp_dir / "LICENSE.txt").touch()
assert constructor(metadata) == helpers.dedent(
"""
Metadata-Version: 2.1
Name: My.App
Version: 0.1.0
Summary: foo
Project-URL: foo, bar
Project-URL: bar, baz
Author-email: foo
Maintainer-email: foo
License: foo
bar
Keywords: bar,foo
Classifier: Programming Language :: Python :: 3.9
Classifier: Programming Language :: Python :: 3.11
Requires-Python: <2,>=1
Requires-Dist: bar==5
Requires-Dist: foo==1
Provides-Extra: feature1
Requires-Dist: bar==5; (python_version < '3') and extra == 'feature1'
Requires-Dist: foo==1; extra == 'feature1'
Provides-Extra: feature2
Requires-Dist: bar==5; extra == 'feature2'
Requires-Dist: foo==1; (python_version < '3') and extra == 'feature2'
Provides-Extra: feature3
Requires-Dist: baz @ file:///path/to/project ; extra == 'feature3'
Description-Content-Type: text/markdown
test content
"""
)
@pytest.mark.parametrize("constructor", [get_core_metadata_constructors()["2.2"]])
class TestCoreMetadataV22:
def test_default(self, constructor, isolation, helpers):
metadata = ProjectMetadata(str(isolation), None, {"project": {"name": "My.App", "version": "0.1.0"}})
assert constructor(metadata) == helpers.dedent(
"""
Metadata-Version: 2.2
Name: My.App
Version: 0.1.0
"""
)
def test_dynamic(self, constructor, isolation, helpers):
metadata = ProjectMetadata(
str(isolation),
None,
{"project": {"name": "My.App", "version": "0.1.0", "dynamic": ["authors", "classifiers"]}},
)
assert constructor(metadata) == helpers.dedent(
"""
Metadata-Version: 2.2
Name: My.App
Version: 0.1.0
Dynamic: Author
Dynamic: Author-email
Dynamic: Classifier
"""
)
def test_description(self, constructor, isolation, helpers):
metadata = ProjectMetadata(
str(isolation), None, {"project": {"name": "My.App", "version": "0.1.0", "description": "foo"}}
)
assert constructor(metadata) == helpers.dedent(
"""
Metadata-Version: 2.2
Name: My.App
Version: 0.1.0
Summary: foo
"""
)
def test_urls(self, constructor, isolation, helpers):
metadata = ProjectMetadata(
str(isolation),
None,
{"project": {"name": "My.App", "version": "0.1.0", "urls": {"foo": "bar", "bar": "baz"}}},
)
assert constructor(metadata) == helpers.dedent(
"""
Metadata-Version: 2.2
Name: My.App
Version: 0.1.0
Project-URL: foo, bar
Project-URL: bar, baz
"""
)
def test_authors_name(self, constructor, isolation, helpers):
metadata = ProjectMetadata(
str(isolation), None, {"project": {"name": "My.App", "version": "0.1.0", "authors": [{"name": "foo"}]}}
)
assert constructor(metadata) == helpers.dedent(
"""
Metadata-Version: 2.2
Name: My.App
Version: 0.1.0
Author: foo
"""
)
def test_authors_email(self, constructor, isolation, helpers):
metadata = ProjectMetadata(
str(isolation),
None,
{"project": {"name": "My.App", "version": "0.1.0", "authors": [{"email": "foo@domain"}]}},
)
assert constructor(metadata) == helpers.dedent(
"""
Metadata-Version: 2.2
Name: My.App
Version: 0.1.0
Author-email: foo@domain
"""
)
def test_authors_name_and_email(self, constructor, isolation, helpers):
metadata = ProjectMetadata(
str(isolation),
None,
{"project": {"name": "My.App", "version": "0.1.0", "authors": [{"email": "bar@domain", "name": "foo"}]}},
)
assert constructor(metadata) == helpers.dedent(
"""
Metadata-Version: 2.2
Name: My.App
Version: 0.1.0
Author-email: foo
"""
)
def test_authors_multiple(self, constructor, isolation, helpers):
metadata = ProjectMetadata(
str(isolation),
None,
{"project": {"name": "My.App", "version": "0.1.0", "authors": [{"name": "foo"}, {"name": "bar"}]}},
)
assert constructor(metadata) == helpers.dedent(
"""
Metadata-Version: 2.2
Name: My.App
Version: 0.1.0
Author: foo, bar
"""
)
def test_maintainers_name(self, constructor, isolation, helpers):
metadata = ProjectMetadata(
str(isolation), None, {"project": {"name": "My.App", "version": "0.1.0", "maintainers": [{"name": "foo"}]}}
)
assert constructor(metadata) == helpers.dedent(
"""
Metadata-Version: 2.2
Name: My.App
Version: 0.1.0
Maintainer: foo
"""
)
def test_maintainers_email(self, constructor, isolation, helpers):
metadata = ProjectMetadata(
str(isolation),
None,
{"project": {"name": "My.App", "version": "0.1.0", "maintainers": [{"email": "foo@domain"}]}},
)
assert constructor(metadata) == helpers.dedent(
"""
Metadata-Version: 2.2
Name: My.App
Version: 0.1.0
Maintainer-email: foo@domain
"""
)
def test_maintainers_name_and_email(self, constructor, isolation, helpers):
metadata = ProjectMetadata(
str(isolation),
None,
{
"project": {
"name": "My.App",
"version": "0.1.0",
"maintainers": [{"email": "bar@domain", "name": "foo"}],
}
},
)
assert constructor(metadata) == helpers.dedent(
"""
Metadata-Version: 2.2
Name: My.App
Version: 0.1.0
Maintainer-email: foo
"""
)
def test_maintainers_multiple(self, constructor, isolation, helpers):
metadata = ProjectMetadata(
str(isolation),
None,
{"project": {"name": "My.App", "version": "0.1.0", "maintainers": [{"name": "foo"}, {"name": "bar"}]}},
)
assert constructor(metadata) == helpers.dedent(
"""
Metadata-Version: 2.2
Name: My.App
Version: 0.1.0
Maintainer: foo, bar
"""
)
def test_license(self, constructor, isolation, helpers):
metadata = ProjectMetadata(
str(isolation), None, {"project": {"name": "My.App", "version": "0.1.0", "license": {"text": "foo\nbar"}}}
)
assert constructor(metadata) == helpers.dedent(
"""
Metadata-Version: 2.2
Name: My.App
Version: 0.1.0
License: foo
bar
"""
)
def test_license_expression(self, constructor, isolation, helpers):
metadata = ProjectMetadata(
str(isolation),
None,
{"project": {"name": "My.App", "version": "0.1.0", "license": "mit"}},
)
assert constructor(metadata) == helpers.dedent(
"""
Metadata-Version: 2.2
Name: My.App
Version: 0.1.0
License: MIT
"""
)
def test_keywords_single(self, constructor, isolation, helpers):
metadata = ProjectMetadata(
str(isolation), None, {"project": {"name": "My.App", "version": "0.1.0", "keywords": ["foo"]}}
)
assert constructor(metadata) == helpers.dedent(
"""
Metadata-Version: 2.2
Name: My.App
Version: 0.1.0
Keywords: foo
"""
)
def test_keywords_multiple(self, constructor, isolation, helpers):
metadata = ProjectMetadata(
str(isolation), None, {"project": {"name": "My.App", "version": "0.1.0", "keywords": ["foo", "bar"]}}
)
assert constructor(metadata) == helpers.dedent(
"""
Metadata-Version: 2.2
Name: My.App
Version: 0.1.0
Keywords: bar,foo
"""
)
def test_classifiers(self, constructor, isolation, helpers):
classifiers = [
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.9",
]
metadata = ProjectMetadata(
str(isolation), None, {"project": {"name": "My.App", "version": "0.1.0", "classifiers": classifiers}}
)
assert constructor(metadata) == helpers.dedent(
"""
Metadata-Version: 2.2
Name: My.App
Version: 0.1.0
Classifier: Programming Language :: Python :: 3.9
Classifier: Programming Language :: Python :: 3.11
"""
)
def test_requires_python(self, constructor, isolation, helpers):
metadata = ProjectMetadata(
str(isolation), None, {"project": {"name": "My.App", "version": "0.1.0", "requires-python": ">=1,<2"}}
)
assert constructor(metadata) == helpers.dedent(
"""
Metadata-Version: 2.2
Name: My.App
Version: 0.1.0
Requires-Python: <2,>=1
"""
)
def test_dependencies(self, constructor, isolation, helpers):
metadata = ProjectMetadata(
str(isolation),
None,
{"project": {"name": "My.App", "version": "0.1.0", "dependencies": ["foo==1", "bar==5"]}},
)
assert constructor(metadata) == helpers.dedent(
"""
Metadata-Version: 2.2
Name: My.App
Version: 0.1.0
Requires-Dist: bar==5
Requires-Dist: foo==1
"""
)
def test_optional_dependencies(self, constructor, isolation, helpers):
metadata = ProjectMetadata(
str(isolation),
None,
{
"project": {
"name": "My.App",
"version": "0.1.0",
"optional-dependencies": {
"feature2": ['foo==1; python_version < "3"', "bar==5"],
"feature1": ["foo==1", 'bar==5; python_version < "3"'],
},
}
},
)
assert constructor(metadata) == helpers.dedent(
"""
Metadata-Version: 2.2
Name: My.App
Version: 0.1.0
Provides-Extra: feature1
Requires-Dist: bar==5; (python_version < '3') and extra == 'feature1'
Requires-Dist: foo==1; extra == 'feature1'
Provides-Extra: feature2
Requires-Dist: bar==5; extra == 'feature2'
Requires-Dist: foo==1; (python_version < '3') and extra == 'feature2'
"""
)
def test_optional_complex_dependencies(self, constructor, isolation, helpers):
metadata = ProjectMetadata(
str(isolation),
None,
{
"project": {
"name": "My.App",
"version": "0.1.0",
"optional-dependencies": {
"feature2": ['foo==1; sys_platform == "win32" or python_version < "3"', "bar==5"],
"feature1": ["foo==1", 'bar==5; python_version < "3"'],
},
}
},
)
assert constructor(metadata) == helpers.dedent(
"""
Metadata-Version: 2.2
Name: My.App
Version: 0.1.0
Provides-Extra: feature1
Requires-Dist: bar==5; (python_version < '3') and extra == 'feature1'
Requires-Dist: foo==1; extra == 'feature1'
Provides-Extra: feature2
Requires-Dist: bar==5; extra == 'feature2'
Requires-Dist: foo==1; (sys_platform == 'win32' or python_version < '3') and extra == 'feature2'
"""
)
def test_extra_runtime_dependencies(self, constructor, isolation, helpers):
metadata = ProjectMetadata(
str(isolation),
None,
{"project": {"name": "My.App", "version": "0.1.0", "dependencies": ["foo==1", "bar==5"]}},
)
assert constructor(metadata, extra_dependencies=["baz==9"]) == helpers.dedent(
"""
Metadata-Version: 2.2
Name: My.App
Version: 0.1.0
Requires-Dist: bar==5
Requires-Dist: foo==1
Requires-Dist: baz==9
"""
)
def test_readme(self, constructor, isolation, helpers):
metadata = ProjectMetadata(
str(isolation),
None,
{
"project": {
"name": "My.App",
"version": "0.1.0",
"readme": {"content-type": "text/markdown", "text": "test content\n"},
}
},
)
assert constructor(metadata) == helpers.dedent(
"""
Metadata-Version: 2.2
Name: My.App
Version: 0.1.0
Description-Content-Type: text/markdown
test content
"""
)
def test_all(self, constructor, helpers, temp_dir):
metadata = ProjectMetadata(
str(temp_dir),
None,
{
"project": {
"name": "My.App",
"version": "0.1.0",
"description": "foo",
"urls": {"foo": "bar", "bar": "baz"},
"authors": [{"email": "bar@domain", "name": "foo"}],
"maintainers": [{"email": "bar@domain", "name": "foo"}],
"license": {"text": "foo\nbar"},
"keywords": ["foo", "bar"],
"classifiers": [
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.9",
],
"requires-python": ">=1,<2",
"dependencies": ["foo==1", "bar==5"],
"optional-dependencies": {
"feature2": ['foo==1; python_version < "3"', "bar==5"],
"feature1": ["foo==1", 'bar==5; python_version < "3"'],
"feature3": ["baz @ file:///path/to/project"],
},
"readme": {"content-type": "text/markdown", "text": "test content\n"},
},
"tool": {"hatch": {"metadata": {"allow-direct-references": True}}},
},
)
(temp_dir / "LICENSE.txt").touch()
assert constructor(metadata) == helpers.dedent(
"""
Metadata-Version: 2.2
Name: My.App
Version: 0.1.0
Summary: foo
Project-URL: foo, bar
Project-URL: bar, baz
Author-email: foo
Maintainer-email: foo
License: foo
bar
Keywords: bar,foo
Classifier: Programming Language :: Python :: 3.9
Classifier: Programming Language :: Python :: 3.11
Requires-Python: <2,>=1
Requires-Dist: bar==5
Requires-Dist: foo==1
Provides-Extra: feature1
Requires-Dist: bar==5; (python_version < '3') and extra == 'feature1'
Requires-Dist: foo==1; extra == 'feature1'
Provides-Extra: feature2
Requires-Dist: bar==5; extra == 'feature2'
Requires-Dist: foo==1; (python_version < '3') and extra == 'feature2'
Provides-Extra: feature3
Requires-Dist: baz @ file:///path/to/project ; extra == 'feature3'
Description-Content-Type: text/markdown
test content
"""
)
@pytest.mark.parametrize("constructor", [get_core_metadata_constructors()["2.3"]])
class TestCoreMetadataV23:
def test_default(self, constructor, isolation, helpers):
metadata = ProjectMetadata(str(isolation), None, {"project": {"name": "My.App", "version": "0.1.0"}})
assert constructor(metadata) == helpers.dedent(
"""
Metadata-Version: 2.3
Name: My.App
Version: 0.1.0
"""
)
def test_description(self, constructor, isolation, helpers):
metadata = ProjectMetadata(
str(isolation), None, {"project": {"name": "My.App", "version": "0.1.0", "description": "foo"}}
)
assert constructor(metadata) == helpers.dedent(
"""
Metadata-Version: 2.3
Name: My.App
Version: 0.1.0
Summary: foo
"""
)
def test_dynamic(self, constructor, isolation, helpers):
metadata = ProjectMetadata(
str(isolation),
None,
{"project": {"name": "My.App", "version": "0.1.0", "dynamic": ["authors", "classifiers"]}},
)
assert constructor(metadata) == helpers.dedent(
"""
Metadata-Version: 2.3
Name: My.App
Version: 0.1.0
Dynamic: Author
Dynamic: Author-email
Dynamic: Classifier
"""
)
def test_urls(self, constructor, isolation, helpers):
metadata = ProjectMetadata(
str(isolation),
None,
{"project": {"name": "My.App", "version": "0.1.0", "urls": {"foo": "bar", "bar": "baz"}}},
)
assert constructor(metadata) == helpers.dedent(
"""
Metadata-Version: 2.3
Name: My.App
Version: 0.1.0
Project-URL: foo, bar
Project-URL: bar, baz
"""
)
def test_authors_name(self, constructor, isolation, helpers):
metadata = ProjectMetadata(
str(isolation), None, {"project": {"name": "My.App", "version": "0.1.0", "authors": [{"name": "foo"}]}}
)
assert constructor(metadata) == helpers.dedent(
"""
Metadata-Version: 2.3
Name: My.App
Version: 0.1.0
Author: foo
"""
)
def test_authors_email(self, constructor, isolation, helpers):
metadata = ProjectMetadata(
str(isolation),
None,
{"project": {"name": "My.App", "version": "0.1.0", "authors": [{"email": "foo@domain"}]}},
)
assert constructor(metadata) == helpers.dedent(
"""
Metadata-Version: 2.3
Name: My.App
Version: 0.1.0
Author-email: foo@domain
"""
)
def test_authors_name_and_email(self, constructor, isolation, helpers):
metadata = ProjectMetadata(
str(isolation),
None,
{"project": {"name": "My.App", "version": "0.1.0", "authors": [{"email": "bar@domain", "name": "foo"}]}},
)
assert constructor(metadata) == helpers.dedent(
"""
Metadata-Version: 2.3
Name: My.App
Version: 0.1.0
Author-email: foo
"""
)
def test_authors_multiple(self, constructor, isolation, helpers):
metadata = ProjectMetadata(
str(isolation),
None,
{"project": {"name": "My.App", "version": "0.1.0", "authors": [{"name": "foo"}, {"name": "bar"}]}},
)
assert constructor(metadata) == helpers.dedent(
"""
Metadata-Version: 2.3
Name: My.App
Version: 0.1.0
Author: foo, bar
"""
)
def test_maintainers_name(self, constructor, isolation, helpers):
metadata = ProjectMetadata(
str(isolation), None, {"project": {"name": "My.App", "version": "0.1.0", "maintainers": [{"name": "foo"}]}}
)
assert constructor(metadata) == helpers.dedent(
"""
Metadata-Version: 2.3
Name: My.App
Version: 0.1.0
Maintainer: foo
"""
)
def test_maintainers_email(self, constructor, isolation, helpers):
metadata = ProjectMetadata(
str(isolation),
None,
{"project": {"name": "My.App", "version": "0.1.0", "maintainers": [{"email": "foo@domain"}]}},
)
assert constructor(metadata) == helpers.dedent(
"""
Metadata-Version: 2.3
Name: My.App
Version: 0.1.0
Maintainer-email: foo@domain
"""
)
def test_maintainers_name_and_email(self, constructor, isolation, helpers):
metadata = ProjectMetadata(
str(isolation),
None,
{
"project": {
"name": "My.App",
"version": "0.1.0",
"maintainers": [{"email": "bar@domain", "name": "foo"}],
}
},
)
assert constructor(metadata) == helpers.dedent(
"""
Metadata-Version: 2.3
Name: My.App
Version: 0.1.0
Maintainer-email: foo
"""
)
def test_maintainers_multiple(self, constructor, isolation, helpers):
metadata = ProjectMetadata(
str(isolation),
None,
{"project": {"name": "My.App", "version": "0.1.0", "maintainers": [{"name": "foo"}, {"name": "bar"}]}},
)
assert constructor(metadata) == helpers.dedent(
"""
Metadata-Version: 2.3
Name: My.App
Version: 0.1.0
Maintainer: foo, bar
"""
)
def test_license(self, constructor, isolation, helpers):
metadata = ProjectMetadata(
str(isolation), None, {"project": {"name": "My.App", "version": "0.1.0", "license": {"text": "foo\nbar"}}}
)
assert constructor(metadata) == helpers.dedent(
"""
Metadata-Version: 2.3
Name: My.App
Version: 0.1.0
License: foo
bar
"""
)
def test_license_expression(self, constructor, isolation, helpers):
metadata = ProjectMetadata(
str(isolation),
None,
{"project": {"name": "My.App", "version": "0.1.0", "license": "mit"}},
)
assert constructor(metadata) == helpers.dedent(
"""
Metadata-Version: 2.3
Name: My.App
Version: 0.1.0
License: MIT
"""
)
def test_keywords_single(self, constructor, isolation, helpers):
metadata = ProjectMetadata(
str(isolation), None, {"project": {"name": "My.App", "version": "0.1.0", "keywords": ["foo"]}}
)
assert constructor(metadata) == helpers.dedent(
"""
Metadata-Version: 2.3
Name: My.App
Version: 0.1.0
Keywords: foo
"""
)
def test_keywords_multiple(self, constructor, isolation, helpers):
metadata = ProjectMetadata(
str(isolation), None, {"project": {"name": "My.App", "version": "0.1.0", "keywords": ["foo", "bar"]}}
)
assert constructor(metadata) == helpers.dedent(
"""
Metadata-Version: 2.3
Name: My.App
Version: 0.1.0
Keywords: bar,foo
"""
)
def test_classifiers(self, constructor, isolation, helpers):
classifiers = [
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.9",
]
metadata = ProjectMetadata(
str(isolation), None, {"project": {"name": "My.App", "version": "0.1.0", "classifiers": classifiers}}
)
assert constructor(metadata) == helpers.dedent(
"""
Metadata-Version: 2.3
Name: My.App
Version: 0.1.0
Classifier: Programming Language :: Python :: 3.9
Classifier: Programming Language :: Python :: 3.11
"""
)
def test_requires_python(self, constructor, isolation, helpers):
metadata = ProjectMetadata(
str(isolation), None, {"project": {"name": "My.App", "version": "0.1.0", "requires-python": ">=1,<2"}}
)
assert constructor(metadata) == helpers.dedent(
"""
Metadata-Version: 2.3
Name: My.App
Version: 0.1.0
Requires-Python: <2,>=1
"""
)
def test_dependencies(self, constructor, isolation, helpers):
metadata = ProjectMetadata(
str(isolation),
None,
{"project": {"name": "My.App", "version": "0.1.0", "dependencies": ["foo==1", "bar==5"]}},
)
assert constructor(metadata) == helpers.dedent(
"""
Metadata-Version: 2.3
Name: My.App
Version: 0.1.0
Requires-Dist: bar==5
Requires-Dist: foo==1
"""
)
def test_optional_dependencies(self, constructor, isolation, helpers):
metadata = ProjectMetadata(
str(isolation),
None,
{
"project": {
"name": "My.App",
"version": "0.1.0",
"optional-dependencies": {
"feature2": ['foo==1; python_version < "3"', "bar==5"],
"feature1": ["foo==1", 'bar==5; python_version < "3"'],
},
}
},
)
assert constructor(metadata) == helpers.dedent(
"""
Metadata-Version: 2.3
Name: My.App
Version: 0.1.0
Provides-Extra: feature1
Requires-Dist: bar==5; (python_version < '3') and extra == 'feature1'
Requires-Dist: foo==1; extra == 'feature1'
Provides-Extra: feature2
Requires-Dist: bar==5; extra == 'feature2'
Requires-Dist: foo==1; (python_version < '3') and extra == 'feature2'
"""
)
def test_extra_runtime_dependencies(self, constructor, isolation, helpers):
metadata = ProjectMetadata(
str(isolation),
None,
{"project": {"name": "My.App", "version": "0.1.0", "dependencies": ["foo==1", "bar==5"]}},
)
assert constructor(metadata, extra_dependencies=["baz==9"]) == helpers.dedent(
"""
Metadata-Version: 2.3
Name: My.App
Version: 0.1.0
Requires-Dist: bar==5
Requires-Dist: foo==1
Requires-Dist: baz==9
"""
)
def test_readme(self, constructor, isolation, helpers):
metadata = ProjectMetadata(
str(isolation),
None,
{
"project": {
"name": "My.App",
"version": "0.1.0",
"readme": {"content-type": "text/markdown", "text": "test content\n"},
}
},
)
assert constructor(metadata) == helpers.dedent(
"""
Metadata-Version: 2.3
Name: My.App
Version: 0.1.0
Description-Content-Type: text/markdown
test content
"""
)
def test_all(self, constructor, temp_dir, helpers):
metadata = ProjectMetadata(
str(temp_dir),
None,
{
"project": {
"name": "My.App",
"version": "0.1.0",
"description": "foo",
"urls": {"foo": "bar", "bar": "baz"},
"authors": [{"email": "bar@domain", "name": "foo"}],
"maintainers": [{"email": "bar@domain", "name": "foo"}],
"keywords": ["foo", "bar"],
"classifiers": [
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.9",
],
"requires-python": ">=1,<2",
"dependencies": ["foo==1", "bar==5"],
"optional-dependencies": {
"feature2": ['foo==1; python_version < "3"', "bar==5"],
"feature1": ["foo==1", 'bar==5; python_version < "3"'],
"feature3": ["baz @ file:///path/to/project"],
},
"readme": {"content-type": "text/markdown", "text": "test content\n"},
},
"tool": {"hatch": {"metadata": {"allow-direct-references": True}}},
},
)
licenses_dir = temp_dir / "LICENSES"
licenses_dir.mkdir()
(licenses_dir / "MIT.txt").touch()
(licenses_dir / "Apache-2.0.txt").touch()
assert constructor(metadata) == helpers.dedent(
"""
Metadata-Version: 2.3
Name: My.App
Version: 0.1.0
Summary: foo
Project-URL: foo, bar
Project-URL: bar, baz
Author-email: foo
Maintainer-email: foo
Keywords: bar,foo
Classifier: Programming Language :: Python :: 3.9
Classifier: Programming Language :: Python :: 3.11
Requires-Python: <2,>=1
Requires-Dist: bar==5
Requires-Dist: foo==1
Provides-Extra: feature1
Requires-Dist: bar==5; (python_version < '3') and extra == 'feature1'
Requires-Dist: foo==1; extra == 'feature1'
Provides-Extra: feature2
Requires-Dist: bar==5; extra == 'feature2'
Requires-Dist: foo==1; (python_version < '3') and extra == 'feature2'
Provides-Extra: feature3
Requires-Dist: baz @ file:///path/to/project ; extra == 'feature3'
Description-Content-Type: text/markdown
test content
"""
)
@pytest.mark.parametrize("constructor", [get_core_metadata_constructors()["2.4"]])
class TestCoreMetadataV24:
def test_default(self, constructor, isolation, helpers):
metadata = ProjectMetadata(str(isolation), None, {"project": {"name": "My.App", "version": "0.1.0"}})
assert constructor(metadata) == helpers.dedent(
"""
Metadata-Version: 2.4
Name: My.App
Version: 0.1.0
"""
)
def test_description(self, constructor, isolation, helpers):
metadata = ProjectMetadata(
str(isolation), None, {"project": {"name": "My.App", "version": "0.1.0", "description": "foo"}}
)
assert constructor(metadata) == helpers.dedent(
"""
Metadata-Version: 2.4
Name: My.App
Version: 0.1.0
Summary: foo
"""
)
def test_dynamic(self, constructor, isolation, helpers):
metadata = ProjectMetadata(
str(isolation),
None,
{"project": {"name": "My.App", "version": "0.1.0", "dynamic": ["authors", "classifiers"]}},
)
assert constructor(metadata) == helpers.dedent(
"""
Metadata-Version: 2.4
Name: My.App
Version: 0.1.0
Dynamic: Author
Dynamic: Author-email
Dynamic: Classifier
"""
)
def test_urls(self, constructor, isolation, helpers):
metadata = ProjectMetadata(
str(isolation),
None,
{"project": {"name": "My.App", "version": "0.1.0", "urls": {"foo": "bar", "bar": "baz"}}},
)
assert constructor(metadata) == helpers.dedent(
"""
Metadata-Version: 2.4
Name: My.App
Version: 0.1.0
Project-URL: foo, bar
Project-URL: bar, baz
"""
)
def test_authors_name(self, constructor, isolation, helpers):
metadata = ProjectMetadata(
str(isolation), None, {"project": {"name": "My.App", "version": "0.1.0", "authors": [{"name": "foo"}]}}
)
assert constructor(metadata) == helpers.dedent(
"""
Metadata-Version: 2.4
Name: My.App
Version: 0.1.0
Author: foo
"""
)
def test_authors_email(self, constructor, isolation, helpers):
metadata = ProjectMetadata(
str(isolation),
None,
{"project": {"name": "My.App", "version": "0.1.0", "authors": [{"email": "foo@domain"}]}},
)
assert constructor(metadata) == helpers.dedent(
"""
Metadata-Version: 2.4
Name: My.App
Version: 0.1.0
Author-email: foo@domain
"""
)
def test_authors_name_and_email(self, constructor, isolation, helpers):
metadata = ProjectMetadata(
str(isolation),
None,
{"project": {"name": "My.App", "version": "0.1.0", "authors": [{"email": "bar@domain", "name": "foo"}]}},
)
assert constructor(metadata) == helpers.dedent(
"""
Metadata-Version: 2.4
Name: My.App
Version: 0.1.0
Author-email: foo
"""
)
def test_authors_multiple(self, constructor, isolation, helpers):
metadata = ProjectMetadata(
str(isolation),
None,
{"project": {"name": "My.App", "version": "0.1.0", "authors": [{"name": "foo"}, {"name": "bar"}]}},
)
assert constructor(metadata) == helpers.dedent(
"""
Metadata-Version: 2.4
Name: My.App
Version: 0.1.0
Author: foo, bar
"""
)
def test_maintainers_name(self, constructor, isolation, helpers):
metadata = ProjectMetadata(
str(isolation), None, {"project": {"name": "My.App", "version": "0.1.0", "maintainers": [{"name": "foo"}]}}
)
assert constructor(metadata) == helpers.dedent(
"""
Metadata-Version: 2.4
Name: My.App
Version: 0.1.0
Maintainer: foo
"""
)
def test_maintainers_email(self, constructor, isolation, helpers):
metadata = ProjectMetadata(
str(isolation),
None,
{"project": {"name": "My.App", "version": "0.1.0", "maintainers": [{"email": "foo@domain"}]}},
)
assert constructor(metadata) == helpers.dedent(
"""
Metadata-Version: 2.4
Name: My.App
Version: 0.1.0
Maintainer-email: foo@domain
"""
)
def test_maintainers_name_and_email(self, constructor, isolation, helpers):
metadata = ProjectMetadata(
str(isolation),
None,
{
"project": {
"name": "My.App",
"version": "0.1.0",
"maintainers": [{"email": "bar@domain", "name": "foo"}],
}
},
)
assert constructor(metadata) == helpers.dedent(
"""
Metadata-Version: 2.4
Name: My.App
Version: 0.1.0
Maintainer-email: foo
"""
)
def test_maintainers_multiple(self, constructor, isolation, helpers):
metadata = ProjectMetadata(
str(isolation),
None,
{"project": {"name": "My.App", "version": "0.1.0", "maintainers": [{"name": "foo"}, {"name": "bar"}]}},
)
assert constructor(metadata) == helpers.dedent(
"""
Metadata-Version: 2.4
Name: My.App
Version: 0.1.0
Maintainer: foo, bar
"""
)
def test_license(self, constructor, isolation, helpers):
metadata = ProjectMetadata(
str(isolation), None, {"project": {"name": "My.App", "version": "0.1.0", "license": {"text": "foo\nbar"}}}
)
assert constructor(metadata) == helpers.dedent(
"""
Metadata-Version: 2.4
Name: My.App
Version: 0.1.0
License: foo
bar
"""
)
def test_license_expression(self, constructor, isolation, helpers):
metadata = ProjectMetadata(
str(isolation),
None,
{"project": {"name": "My.App", "version": "0.1.0", "license": "mit or apache-2.0"}},
)
assert constructor(metadata) == helpers.dedent(
"""
Metadata-Version: 2.4
Name: My.App
Version: 0.1.0
License-Expression: MIT OR Apache-2.0
"""
)
def test_license_files(self, constructor, temp_dir, helpers):
metadata = ProjectMetadata(
str(temp_dir),
None,
{"project": {"name": "My.App", "version": "0.1.0", "license-files": ["LICENSES/*"]}},
)
licenses_dir = temp_dir / "LICENSES"
licenses_dir.mkdir()
(licenses_dir / "MIT.txt").touch()
(licenses_dir / "Apache-2.0.txt").touch()
assert constructor(metadata) == helpers.dedent(
"""
Metadata-Version: 2.4
Name: My.App
Version: 0.1.0
License-File: LICENSES/Apache-2.0.txt
License-File: LICENSES/MIT.txt
"""
)
def test_keywords_single(self, constructor, isolation, helpers):
metadata = ProjectMetadata(
str(isolation), None, {"project": {"name": "My.App", "version": "0.1.0", "keywords": ["foo"]}}
)
assert constructor(metadata) == helpers.dedent(
"""
Metadata-Version: 2.4
Name: My.App
Version: 0.1.0
Keywords: foo
"""
)
def test_keywords_multiple(self, constructor, isolation, helpers):
metadata = ProjectMetadata(
str(isolation), None, {"project": {"name": "My.App", "version": "0.1.0", "keywords": ["foo", "bar"]}}
)
assert constructor(metadata) == helpers.dedent(
"""
Metadata-Version: 2.4
Name: My.App
Version: 0.1.0
Keywords: bar,foo
"""
)
def test_classifiers(self, constructor, isolation, helpers):
classifiers = [
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.9",
]
metadata = ProjectMetadata(
str(isolation), None, {"project": {"name": "My.App", "version": "0.1.0", "classifiers": classifiers}}
)
assert constructor(metadata) == helpers.dedent(
"""
Metadata-Version: 2.4
Name: My.App
Version: 0.1.0
Classifier: Programming Language :: Python :: 3.9
Classifier: Programming Language :: Python :: 3.11
"""
)
def test_requires_python(self, constructor, isolation, helpers):
metadata = ProjectMetadata(
str(isolation), None, {"project": {"name": "My.App", "version": "0.1.0", "requires-python": ">=1,<2"}}
)
assert constructor(metadata) == helpers.dedent(
"""
Metadata-Version: 2.4
Name: My.App
Version: 0.1.0
Requires-Python: <2,>=1
"""
)
def test_dependencies(self, constructor, isolation, helpers):
metadata = ProjectMetadata(
str(isolation),
None,
{"project": {"name": "My.App", "version": "0.1.0", "dependencies": ["foo==1", "bar==5"]}},
)
assert constructor(metadata) == helpers.dedent(
"""
Metadata-Version: 2.4
Name: My.App
Version: 0.1.0
Requires-Dist: bar==5
Requires-Dist: foo==1
"""
)
def test_optional_dependencies(self, constructor, isolation, helpers):
metadata = ProjectMetadata(
str(isolation),
None,
{
"project": {
"name": "My.App",
"version": "0.1.0",
"optional-dependencies": {
"feature2": ['foo==1; python_version < "3"', "bar==5"],
"feature1": ["foo==1", 'bar==5; python_version < "3"'],
},
}
},
)
assert constructor(metadata) == helpers.dedent(
"""
Metadata-Version: 2.4
Name: My.App
Version: 0.1.0
Provides-Extra: feature1
Requires-Dist: bar==5; (python_version < '3') and extra == 'feature1'
Requires-Dist: foo==1; extra == 'feature1'
Provides-Extra: feature2
Requires-Dist: bar==5; extra == 'feature2'
Requires-Dist: foo==1; (python_version < '3') and extra == 'feature2'
"""
)
def test_extra_runtime_dependencies(self, constructor, isolation, helpers):
metadata = ProjectMetadata(
str(isolation),
None,
{"project": {"name": "My.App", "version": "0.1.0", "dependencies": ["foo==1", "bar==5"]}},
)
assert constructor(metadata, extra_dependencies=["baz==9"]) == helpers.dedent(
"""
Metadata-Version: 2.4
Name: My.App
Version: 0.1.0
Requires-Dist: bar==5
Requires-Dist: foo==1
Requires-Dist: baz==9
"""
)
def test_readme(self, constructor, isolation, helpers):
metadata = ProjectMetadata(
str(isolation),
None,
{
"project": {
"name": "My.App",
"version": "0.1.0",
"readme": {"content-type": "text/markdown", "text": "test content\n"},
}
},
)
assert constructor(metadata) == helpers.dedent(
"""
Metadata-Version: 2.4
Name: My.App
Version: 0.1.0
Description-Content-Type: text/markdown
test content
"""
)
def test_all(self, constructor, temp_dir, helpers):
metadata = ProjectMetadata(
str(temp_dir),
None,
{
"project": {
"name": "My.App",
"version": "0.1.0",
"description": "foo",
"urls": {"foo": "bar", "bar": "baz"},
"authors": [{"email": "bar@domain", "name": "foo"}],
"maintainers": [{"email": "bar@domain", "name": "foo"}],
"license": "mit or apache-2.0",
"license-files": ["LICENSES/*"],
"keywords": ["foo", "bar"],
"classifiers": [
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.9",
],
"requires-python": ">=1,<2",
"dependencies": ["foo==1", "bar==5"],
"optional-dependencies": {
"feature2": ['foo==1; python_version < "3"', "bar==5"],
"feature1": ["foo==1", 'bar==5; python_version < "3"'],
"feature3": ["baz @ file:///path/to/project"],
},
"readme": {"content-type": "text/markdown", "text": "test content\n"},
},
"tool": {"hatch": {"metadata": {"allow-direct-references": True}}},
},
)
licenses_dir = temp_dir / "LICENSES"
licenses_dir.mkdir()
(licenses_dir / "MIT.txt").touch()
(licenses_dir / "Apache-2.0.txt").touch()
assert constructor(metadata) == helpers.dedent(
"""
Metadata-Version: 2.4
Name: My.App
Version: 0.1.0
Summary: foo
Project-URL: foo, bar
Project-URL: bar, baz
Author-email: foo
Maintainer-email: foo
License-Expression: MIT OR Apache-2.0
License-File: LICENSES/Apache-2.0.txt
License-File: LICENSES/MIT.txt
Keywords: bar,foo
Classifier: Programming Language :: Python :: 3.9
Classifier: Programming Language :: Python :: 3.11
Requires-Python: <2,>=1
Requires-Dist: bar==5
Requires-Dist: foo==1
Provides-Extra: feature1
Requires-Dist: bar==5; (python_version < '3') and extra == 'feature1'
Requires-Dist: foo==1; extra == 'feature1'
Provides-Extra: feature2
Requires-Dist: bar==5; extra == 'feature2'
Requires-Dist: foo==1; (python_version < '3') and extra == 'feature2'
Provides-Extra: feature3
Requires-Dist: baz @ file:///path/to/project ; extra == 'feature3'
Description-Content-Type: text/markdown
test content
"""
)
================================================
FILE: tests/backend/test_build.py
================================================
from hatchling.build import build_editable, build_sdist, build_wheel
def test_sdist(hatch, helpers, temp_dir, config_file):
config_file.model.template.plugins["default"]["src-layout"] = False
config_file.save()
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"
project_config = project_path / "pyproject.toml"
project_config.write_text(
helpers.dedent(
"""
[project]
name = 'my__app'
dynamic = [ 'version' ]
[tool.hatch.version]
path = 'my_app/__about__.py'
[tool.hatch.build.targets.sdist]
versions = '9000'
"""
)
)
build_path = project_path / "dist"
build_path.mkdir()
with project_path.as_cwd():
expected_artifact = build_sdist(str(build_path))
build_artifacts = list(build_path.iterdir())
assert len(build_artifacts) == 1
assert expected_artifact == str(build_artifacts[0].name)
assert expected_artifact.endswith(".tar.gz")
def test_wheel(hatch, helpers, temp_dir, config_file):
config_file.model.template.plugins["default"]["src-layout"] = False
config_file.save()
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"
project_config = project_path / "pyproject.toml"
project_config.write_text(
helpers.dedent(
"""
[project]
name = 'my__app'
dynamic = [ 'version' ]
[tool.hatch.version]
path = 'my_app/__about__.py'
[tool.hatch.build.targets.wheel]
versions = '9000'
"""
)
)
build_path = project_path / "dist"
build_path.mkdir()
with project_path.as_cwd():
expected_artifact = build_wheel(str(build_path))
build_artifacts = list(build_path.iterdir())
assert len(build_artifacts) == 1
assert expected_artifact == str(build_artifacts[0].name)
assert expected_artifact.endswith(".whl")
def test_editable(hatch, helpers, temp_dir, config_file):
config_file.model.template.plugins["default"]["src-layout"] = False
config_file.save()
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"
project_config = project_path / "pyproject.toml"
project_config.write_text(
helpers.dedent(
"""
[project]
name = 'my__app'
dynamic = [ 'version' ]
[tool.hatch.version]
path = 'my_app/__about__.py'
[tool.hatch.build.targets.wheel]
versions = '9000'
"""
)
)
build_path = project_path / "dist"
build_path.mkdir()
with project_path.as_cwd():
expected_artifact = build_editable(str(build_path), None)
build_artifacts = list(build_path.iterdir())
assert len(build_artifacts) == 1
assert expected_artifact == str(build_artifacts[0].name)
assert expected_artifact.endswith(".whl")
================================================
FILE: tests/backend/utils/__init__.py
================================================
================================================
FILE: tests/backend/utils/test_context.py
================================================
import os
import pytest
from hatch.utils.structures import EnvVars
from hatchling.utils.context import Context
def test_normal(isolation):
context = Context(isolation)
assert context.format("foo {0} {key}", "arg", key="value") == "foo arg value"
class TestStatic:
def test_directory_separator(self, isolation):
context = Context(isolation)
assert context.format("foo {/}") == f"foo {os.sep}"
def test_path_separator(self, isolation):
context = Context(isolation)
assert context.format("foo {;}") == f"foo {os.pathsep}"
class TestRoot:
def test_default(self, isolation):
context = Context(isolation)
assert context.format("foo {root}") == f"foo {isolation}"
def test_parent(self, isolation):
context = Context(isolation)
path = os.path.dirname(str(isolation))
assert context.format("foo {root:parent}") == f"foo {path}"
def test_parent_parent(self, isolation):
context = Context(isolation)
path = os.path.dirname(os.path.dirname(str(isolation)))
assert context.format("foo {root:parent:parent}") == f"foo {path}"
def test_uri(self, isolation, uri_slash_prefix):
context = Context(isolation)
normalized_path = str(isolation).replace(os.sep, "/")
assert context.format("foo {root:uri}") == f"foo file:{uri_slash_prefix}{normalized_path}"
def test_uri_parent(self, isolation, uri_slash_prefix):
context = Context(isolation)
normalized_path = os.path.dirname(str(isolation)).replace(os.sep, "/")
assert context.format("foo {root:parent:uri}") == f"foo file:{uri_slash_prefix}{normalized_path}"
def test_uri_parent_parent(self, isolation, uri_slash_prefix):
context = Context(isolation)
normalized_path = os.path.dirname(os.path.dirname(str(isolation))).replace(os.sep, "/")
assert context.format("foo {root:parent:parent:uri}") == f"foo file:{uri_slash_prefix}{normalized_path}"
def test_real(self, isolation):
context = Context(isolation)
real_path = os.path.realpath(isolation)
assert context.format("foo {root:real}") == f"foo {real_path}"
def test_real_parent(self, isolation):
context = Context(isolation)
real_path = os.path.dirname(os.path.realpath(isolation))
assert context.format("foo {root:parent:real}") == f"foo {real_path}"
def test_real_parent_parent(self, isolation):
context = Context(isolation)
real_path = os.path.dirname(os.path.dirname(os.path.realpath(isolation)))
assert context.format("foo {root:parent:parent:real}") == f"foo {real_path}"
def test_unknown_modifier(self, isolation):
context = Context(isolation)
with pytest.raises(ValueError, match="Unknown path modifier: bar"):
context.format("foo {root:bar}")
def test_too_many_modifiers_after_parent(self, isolation):
context = Context(isolation)
with pytest.raises(ValueError, match="Expected a single path modifier and instead got: foo, bar, baz"):
context.format("foo {root:parent:foo:bar:baz}")
class TestHome:
def test_default(self, isolation):
context = Context(isolation)
assert context.format("foo {home}") == f"foo {os.path.expanduser('~')}"
def test_uri(self, isolation, uri_slash_prefix):
context = Context(isolation)
normalized_path = os.path.expanduser("~").replace(os.sep, "/")
assert context.format("foo {home:uri}") == f"foo file:{uri_slash_prefix}{normalized_path}"
def test_real(self, isolation):
context = Context(isolation)
assert context.format("foo {home:real}") == f"foo {os.path.realpath(os.path.expanduser('~'))}"
def test_unknown_modifier(self, isolation):
context = Context(isolation)
with pytest.raises(ValueError, match="Unknown path modifier: bar"):
context.format("foo {home:bar}")
class TestEnvVars:
def test_set(self, isolation):
context = Context(isolation)
with EnvVars({"BAR": "foobarbaz"}):
assert context.format("foo {env:BAR}") == "foo foobarbaz"
def test_default(self, isolation):
context = Context(isolation)
assert context.format("foo {env:BAR:foobarbaz}") == "foo foobarbaz"
def test_default_empty_string(self, isolation):
context = Context(isolation)
assert context.format("foo {env:BAR:}") == "foo "
def test_default_nested_set(self, isolation):
context = Context(isolation)
with EnvVars({"BAZ": "foobarbaz"}):
assert context.format("foo {env:BAR:{env:BAZ}}") == "foo foobarbaz"
def test_default_nested_default(self, isolation):
context = Context(isolation)
assert context.format("foo {env:BAR:{env:BAZ:{home}}}") == f"foo {os.path.expanduser('~')}"
def test_no_selection(self, isolation):
context = Context(isolation)
with pytest.raises(ValueError, match="The `env` context formatting field requires a modifier"):
context.format("foo {env}")
def test_unset_without_default(self, isolation):
context = Context(isolation)
with pytest.raises(ValueError, match="Nonexistent environment variable must set a default: BAR"):
context.format("foo {env:BAR}")
================================================
FILE: tests/backend/utils/test_fs.py
================================================
import os
from hatchling.utils.fs import path_to_uri
class TestPathToURI:
def test_unix(self, isolation, uri_slash_prefix):
bad_path = f"{isolation}{os.sep}"
normalized_path = str(isolation).replace(os.sep, "/")
assert path_to_uri(bad_path) == f"file:{uri_slash_prefix}{normalized_path}"
def test_character_escaping(self, temp_dir, uri_slash_prefix):
path = temp_dir / "foo bar"
normalized_path = str(path).replace(os.sep, "/").replace(" ", "%20")
assert path_to_uri(path) == f"file:{uri_slash_prefix}{normalized_path}"
================================================
FILE: tests/backend/utils/test_macos.py
================================================
from __future__ import annotations
import platform
import pytest
from hatchling.builders.macos import normalize_macos_version, process_macos_plat_tag
@pytest.mark.parametrize(
("plat", "arch", "compat", "archflags", "deptarget", "expected"),
[
("macosx_10_9_x86_64", "x86_64", False, "", "", "macosx_10_9_x86_64"),
("macosx_11_9_x86_64", "x86_64", False, "", "", "macosx_11_0_x86_64"),
("macosx_12_0_x86_64", "x86_64", True, "", "", "macosx_10_16_x86_64"),
("macosx_10_9_arm64", "arm64", False, "", "", "macosx_11_0_arm64"),
("macosx_10_9_arm64", "arm64", False, "-arch x86_64 -arch arm64", "", "macosx_10_9_universal2"),
("macosx_10_9_x86_64", "x86_64", False, "-arch x86_64 -arch arm64", "", "macosx_10_9_universal2"),
("macosx_10_9_x86_64", "x86_64", False, "-arch x86_64 -arch arm64", "12", "macosx_12_0_universal2"),
("macosx_10_9_x86_64", "x86_64", False, "-arch arm64", "12.4", "macosx_12_0_arm64"),
("macosx_10_9_x86_64", "x86_64", False, "-arch arm64", "10.12", "macosx_11_0_arm64"),
("macosx_10_9_x86_64", "x86_64", True, "-arch arm64", "10.12", "macosx_10_16_arm64"),
],
)
def test_process_macos_plat_tag(
monkeypatch: pytest.MonkeyPatch,
*,
plat: str,
arch: str,
compat: bool,
archflags: str,
deptarget: str,
expected: str,
) -> None:
monkeypatch.setenv("ARCHFLAGS", archflags)
monkeypatch.setenv("MACOSX_DEPLOYMENT_TARGET", deptarget)
monkeypatch.setattr(platform, "machine", lambda: arch)
assert process_macos_plat_tag(plat, compat=compat) == expected
@pytest.mark.parametrize(
("version", "arm", "compat", "expected"),
[
("10_9", False, False, "10_9"),
("10_9", False, True, "10_9"),
("10_9", True, False, "11_0"),
("10_9", True, True, "10_9"),
("11_3", False, False, "11_0"),
("12_3", True, False, "12_0"),
("12_3", False, True, "10_16"),
("12_3", True, True, "10_16"),
],
)
def check_normalization(*, version: str, arm: bool, compat: bool, expected: str) -> None:
assert normalize_macos_version(version, arm=arm, compat=compat) == expected
================================================
FILE: tests/backend/version/__init__.py
================================================
================================================
FILE: tests/backend/version/scheme/__init__.py
================================================
================================================
FILE: tests/backend/version/scheme/test_standard.py
================================================
import pytest
from packaging.version import _parse_letter_version # noqa: PLC2701
from hatch.utils.structures import EnvVars
from hatchling.utils.constants import VersionEnvVars
from hatchling.version.scheme.standard import StandardScheme
def test_not_higher(isolation):
scheme = StandardScheme(str(isolation), {})
with pytest.raises(ValueError, match="Version `1.0.0` is not higher than the original version `1.0`"):
scheme.update("1.0.0", "1.0", {})
def test_specific(isolation):
scheme = StandardScheme(str(isolation), {})
assert scheme.update("9000.0.0-rc.1", "1.0", {}) == "9000.0.0rc1"
def test_specific_not_higher_allowed_config(isolation):
scheme = StandardScheme(str(isolation), {"validate-bump": False})
assert scheme.update("0.24.4", "1.0.0.dev0", {}) == "0.24.4"
def test_specific_not_higher_allowed_env_var(isolation):
scheme = StandardScheme(str(isolation), {})
with EnvVars({VersionEnvVars.VALIDATE_BUMP: "false"}):
assert scheme.update("0.24.4", "1.0.0.dev0", {}) == "0.24.4"
def test_release(isolation):
scheme = StandardScheme(str(isolation), {})
assert scheme.update("release", "9000.0.0-rc.1.post7.dev5", {}) == "9000.0.0"
def test_major(isolation):
scheme = StandardScheme(str(isolation), {})
assert scheme.update("major", "9000.0.0-rc.1", {}) == "9001.0.0"
def test_minor(isolation):
scheme = StandardScheme(str(isolation), {})
assert scheme.update("minor", "9000.0.0-rc.1", {}) == "9000.1.0"
@pytest.mark.parametrize("keyword", ["micro", "patch", "fix"])
def test_micro(isolation, keyword):
scheme = StandardScheme(str(isolation), {})
assert scheme.update(keyword, "9000.0.0-rc.1", {}) == "9000.0.1"
class TestPre:
@pytest.mark.parametrize("phase", ["a", "b", "c", "rc", "alpha", "beta", "pre", "preview"])
def test_begin(self, isolation, phase):
scheme = StandardScheme(str(isolation), {})
normalized_phase, _ = _parse_letter_version(phase, 0)
assert scheme.update(phase, "9000.0.0.post7.dev5", {}) == f"9000.0.0{normalized_phase}0"
@pytest.mark.parametrize("phase", ["a", "b", "c", "rc", "alpha", "beta", "pre", "preview"])
def test_continue(self, isolation, phase):
scheme = StandardScheme(str(isolation), {})
normalized_phase, _ = _parse_letter_version(phase, 0)
assert scheme.update(phase, f"9000.0.0{phase}0.post7.dev5", {}) == f"9000.0.0{normalized_phase}1"
@pytest.mark.parametrize("phase", ["a", "b", "c", "rc", "alpha", "beta", "pre", "preview"])
def test_restart(self, isolation, phase):
scheme = StandardScheme(str(isolation), {})
normalized_phase, _ = _parse_letter_version(phase, 0)
other_phase = "b" if normalized_phase == "a" else "a"
assert scheme.update(phase, f"9000.0.0-{other_phase}5.post7.dev5", {}) == f"9000.0.0{normalized_phase}0"
class TestPost:
@pytest.mark.parametrize("key", ["post", "rev", "r"])
def test_begin(self, isolation, key):
scheme = StandardScheme(str(isolation), {})
assert scheme.update(key, "9000.0.0-rc.3.dev5", {}) == "9000.0.0rc3.post0"
@pytest.mark.parametrize("key", ["post", "rev", "r"])
def test_continue(self, isolation, key):
scheme = StandardScheme(str(isolation), {})
assert scheme.update(key, f"9000.0.0-rc.3-{key}7.dev5", {}) == "9000.0.0rc3.post8"
class TestDev:
def test_begin(self, isolation):
scheme = StandardScheme(str(isolation), {})
assert scheme.update("dev", "9000.0.0-rc.3-7", {}) == "9000.0.0rc3.post7.dev0"
def test_continue(self, isolation):
scheme = StandardScheme(str(isolation), {})
assert scheme.update("dev", "9000.0.0-rc.3-7.dev5", {}) == "9000.0.0rc3.post7.dev6"
class TestMultiple:
def test_explicit_error(self, isolation):
scheme = StandardScheme(str(isolation), {})
with pytest.raises(ValueError, match="Cannot specify multiple update operations with an explicit version"):
scheme.update("5,rc", "3", {})
@pytest.mark.parametrize(
("operations", "expected"),
[
("fix,rc", "0.0.2rc0"),
("minor,dev", "0.1.0.dev0"),
("minor,preview", "0.1.0rc0"),
("major,beta", "1.0.0b0"),
("major,major,major", "3.0.0"),
],
)
def test_correct(self, isolation, operations, expected):
scheme = StandardScheme(str(isolation), {})
assert scheme.update(operations, "0.0.1", {}) == expected
class TestWithEpoch:
@pytest.mark.parametrize(
("operations", "expected"),
[
("patch,dev,release", "1!0.0.2"),
("fix,rc", "1!0.0.2rc0"),
("minor,dev", "1!0.1.0.dev0"),
("minor,preview", "1!0.1.0rc0"),
("major,beta", "1!1.0.0b0"),
("major,major,major", "1!3.0.0"),
],
)
def test_correct(self, isolation, operations, expected):
scheme = StandardScheme(str(isolation), {})
assert scheme.update(operations, "1!0.0.1", {}) == expected
================================================
FILE: tests/backend/version/source/__init__.py
================================================
================================================
FILE: tests/backend/version/source/test_code.py
================================================
import pytest
from hatchling.version.source.code import CodeSource
def test_no_path(isolation):
source = CodeSource(str(isolation), {})
with pytest.raises(ValueError, match="option `path` must be specified"):
source.get_version_data()
def test_path_not_string(isolation):
source = CodeSource(str(isolation), {"path": 1})
with pytest.raises(TypeError, match="option `path` must be a string"):
source.get_version_data()
def test_path_nonexistent(isolation):
source = CodeSource(str(isolation), {"path": "a/b.py"})
with pytest.raises(OSError, match="file does not exist: a/b.py"):
source.get_version_data()
def test_expression_not_string(temp_dir):
source = CodeSource(str(temp_dir), {"path": "a/b.py", "expression": 23})
file_path = temp_dir / "a" / "b.py"
file_path.ensure_parent_dir_exists()
file_path.touch()
with pytest.raises(TypeError, match="option `expression` must be a string"):
source.get_version_data()
def test_search_paths_not_array(temp_dir):
source = CodeSource(str(temp_dir), {"path": "a/b.py", "search-paths": 23})
file_path = temp_dir / "a" / "b.py"
file_path.ensure_parent_dir_exists()
file_path.touch()
with pytest.raises(TypeError, match="option `search-paths` must be an array"):
source.get_version_data()
def test_search_paths_entry_not_string(temp_dir):
source = CodeSource(str(temp_dir), {"path": "a/b.py", "search-paths": [23]})
file_path = temp_dir / "a" / "b.py"
file_path.ensure_parent_dir_exists()
file_path.touch()
with pytest.raises(TypeError, match="entry #1 of option `search-paths` must be a string"):
source.get_version_data()
def test_match_default_expression(temp_dir):
source = CodeSource(str(temp_dir), {"path": "a/b.py"})
file_path = temp_dir / "a" / "b.py"
file_path.ensure_parent_dir_exists()
file_path.write_text('__version__ = "0.0.1"')
with temp_dir.as_cwd():
assert source.get_version_data()["version"] == "0.0.1"
def test_match_custom_expression_basic(temp_dir):
source = CodeSource(str(temp_dir), {"path": "a/b.py", "expression": "VER"})
file_path = temp_dir / "a" / "b.py"
file_path.ensure_parent_dir_exists()
file_path.write_text('VER = "0.0.1"')
with temp_dir.as_cwd():
assert source.get_version_data()["version"] == "0.0.1"
def test_match_custom_expression_complex(temp_dir, helpers):
source = CodeSource(str(temp_dir), {"path": "a/b.py", "expression": "foo()"})
file_path = temp_dir / "a" / "b.py"
file_path.ensure_parent_dir_exists()
file_path.write_text(
helpers.dedent(
"""
__version_info__ = (1, 0, 0, 1, 'dev0')
def foo():
return '.'.join(str(part) for part in __version_info__)
"""
)
)
with temp_dir.as_cwd():
assert source.get_version_data()["version"] == "1.0.0.1.dev0"
def test_search_paths(temp_dir, helpers):
source = CodeSource(str(temp_dir), {"path": "a/b.py", "search-paths": ["."]})
parent_dir = temp_dir / "a"
parent_dir.mkdir()
(parent_dir / "__init__.py").touch()
(parent_dir / "b.py").write_text(
helpers.dedent(
"""
from a.c import foo
__version__ = foo((1, 0, 0, 1, 'dev0'))
"""
)
)
(parent_dir / "c.py").write_text(
helpers.dedent(
"""
def foo(version_info):
return '.'.join(str(part) for part in version_info)
"""
)
)
with temp_dir.as_cwd():
assert source.get_version_data()["version"] == "1.0.0.1.dev0"
================================================
FILE: tests/backend/version/source/test_env.py
================================================
import pytest
from hatch.utils.structures import EnvVars
from hatchling.version.source.env import EnvSource
def test_no_variable(isolation):
source = EnvSource(str(isolation), {})
with pytest.raises(ValueError, match="option `variable` must be specified"):
source.get_version_data()
def test_variable_not_string(isolation):
source = EnvSource(str(isolation), {"variable": 1})
with pytest.raises(TypeError, match="option `variable` must be a string"):
source.get_version_data()
def test_variable_not_available(isolation):
source = EnvSource(str(isolation), {"variable": "ENV_VERSION"})
with (
EnvVars(exclude=["ENV_VERSION"]),
pytest.raises(RuntimeError, match="environment variable `ENV_VERSION` is not set"),
):
source.get_version_data()
def test_variable_contains_version(isolation):
source = EnvSource(str(isolation), {"variable": "ENV_VERSION"})
with EnvVars({"ENV_VERSION": "0.0.1"}):
assert source.get_version_data()["version"] == "0.0.1"
================================================
FILE: tests/backend/version/source/test_regex.py
================================================
from itertools import product
import pytest
from hatchling.version.source.regex import RegexSource
DEFAULT_PATTERN_PRODUCTS = list(product(("__version__", "VERSION", "version"), ('"', "'"), ("", "v")))
def test_no_path(isolation):
source = RegexSource(str(isolation), {})
with pytest.raises(ValueError, match="option `path` must be specified"):
source.get_version_data()
def test_path_not_string(isolation):
source = RegexSource(str(isolation), {"path": 1})
with pytest.raises(TypeError, match="option `path` must be a string"):
source.get_version_data()
def test_path_nonexistent(isolation):
source = RegexSource(str(isolation), {"path": "a/b"})
with pytest.raises(OSError, match="file does not exist: a/b"):
source.get_version_data()
def test_pattern_not_string(temp_dir):
source = RegexSource(str(temp_dir), {"path": "a/b", "pattern": 23})
file_path = temp_dir / "a" / "b"
file_path.ensure_parent_dir_exists()
file_path.touch()
with pytest.raises(TypeError, match="option `pattern` must be a string"):
source.get_version_data()
def test_no_version(temp_dir):
source = RegexSource(str(temp_dir), {"path": "a/b"})
file_path = temp_dir / "a" / "b"
file_path.ensure_parent_dir_exists()
file_path.touch()
with temp_dir.as_cwd(), pytest.raises(ValueError, match="unable to parse the version from the file: a/b"):
source.get_version_data()
def test_pattern_no_version_group(temp_dir):
source = RegexSource(str(temp_dir), {"path": "a/b", "pattern": ".+"})
file_path = temp_dir / "a" / "b"
file_path.ensure_parent_dir_exists()
file_path.write_text("foo")
with temp_dir.as_cwd(), pytest.raises(ValueError, match="no group named `version` was defined in the pattern"):
source.get_version_data()
def test_match_custom_pattern(temp_dir):
source = RegexSource(str(temp_dir), {"path": "a/b", "pattern": 'VER = "(?P.+)"'})
file_path = temp_dir / "a" / "b"
file_path.ensure_parent_dir_exists()
file_path.write_text('VER = "0.0.1"')
with temp_dir.as_cwd():
assert source.get_version_data()["version"] == "0.0.1"
@pytest.mark.parametrize(("variable", "quote", "prefix"), DEFAULT_PATTERN_PRODUCTS)
def test_match_default_pattern(temp_dir, helpers, variable, quote, prefix):
source = RegexSource(str(temp_dir), {"path": "a/b"})
file_path = temp_dir / "a" / "b"
file_path.ensure_parent_dir_exists()
file_path.write_text(
helpers.dedent(
f"""
__all__ = [{quote}{variable}{quote}, {quote}foo{quote}]
{variable} = {quote}{prefix}0.0.1{quote}
def foo():
return {quote}bar{quote}
"""
)
)
with temp_dir.as_cwd():
assert source.get_version_data()["version"] == "0.0.1"
@pytest.mark.parametrize(("variable", "quote", "prefix"), DEFAULT_PATTERN_PRODUCTS)
def test_set_default_pattern(temp_dir, helpers, variable, quote, prefix):
source = RegexSource(str(temp_dir), {"path": "a/b"})
file_path = temp_dir / "a" / "b"
file_path.ensure_parent_dir_exists()
file_path.write_text(
helpers.dedent(
f"""
__all__ = [{quote}{variable}{quote}, {quote}foo{quote}]
{variable} = {quote}{prefix}0.0.1{quote}
def foo():
return {quote}bar{quote}
"""
)
)
with temp_dir.as_cwd():
source.set_version("foo", source.get_version_data())
assert source.get_version_data()["version"] == "foo"
================================================
FILE: tests/cli/__init__.py
================================================
================================================
FILE: tests/cli/build/__init__.py
================================================
================================================
FILE: tests/cli/build/test_build.py
================================================
import os
import re
import pytest
from hatch.config.constants import ConfigEnvVars
from hatch.project.constants import DEFAULT_BUILD_SCRIPT, DEFAULT_CONFIG_FILE, BuildEnvVars
from hatch.project.core import Project
pytestmark = [pytest.mark.usefixtures("mock_backend_process")]
@pytest.mark.requires_internet
class TestOtherBackend:
def test_standard(self, hatch, temp_dir, helpers):
project_name = "My.App"
with temp_dir.as_cwd():
result = hatch("new", project_name)
assert result.exit_code == 0, result.output
path = temp_dir / "my-app"
data_path = temp_dir / "data"
data_path.mkdir()
project = Project(path)
config = dict(project.raw_config)
config["build-system"]["requires"] = ["flit-core"]
config["build-system"]["build-backend"] = "flit_core.buildapi"
config["project"]["version"] = "0.0.1"
config["project"]["dynamic"] = []
del config["project"]["license"]
project.save_config(config)
build_directory = path / "dist"
assert not build_directory.is_dir()
with path.as_cwd(env_vars={ConfigEnvVars.DATA: str(data_path)}):
result = hatch("build")
assert result.exit_code == 0, result.output
assert build_directory.is_dir()
artifacts = list(build_directory.iterdir())
assert len(artifacts) == 2
wheel_path = build_directory / "my_app-0.0.1-py3-none-any.whl"
assert wheel_path.is_file()
sdist_path = build_directory / "my_app-0.0.1.tar.gz"
assert sdist_path.is_file()
assert result.exit_code == 0, result.output
assert result.output == helpers.dedent(
f"""
Creating environment: hatch-build
Checking dependencies
Syncing dependencies
Inspecting build dependencies
──────────────────────────────────── sdist ─────────────────────────────────────
{sdist_path.relative_to(path)}
──────────────────────────────────── wheel ─────────────────────────────────────
{wheel_path.relative_to(path)}
"""
)
build_directory.remove()
with path.as_cwd(env_vars={ConfigEnvVars.DATA: str(data_path)}):
result = hatch("build", "-t", "wheel")
assert result.exit_code == 0, result.output
assert result.output == helpers.dedent(
f"""
Inspecting build dependencies
──────────────────────────────────── wheel ─────────────────────────────────────
{wheel_path.relative_to(path)}
"""
)
assert build_directory.is_dir()
assert wheel_path.is_file()
assert not sdist_path.is_file()
build_directory.remove()
with path.as_cwd(env_vars={ConfigEnvVars.DATA: str(data_path)}):
result = hatch("build", "-t", "sdist")
assert result.exit_code == 0, result.output
assert result.output == helpers.dedent(
f"""
Inspecting build dependencies
──────────────────────────────────── sdist ─────────────────────────────────────
{sdist_path.relative_to(path)}
"""
)
assert build_directory.is_dir()
assert not wheel_path.is_file()
assert sdist_path.is_file()
def test_legacy(self, hatch, temp_dir, helpers):
path = temp_dir / "tmp"
path.mkdir()
data_path = temp_dir / "data"
data_path.mkdir()
(path / "pyproject.toml").write_text(
"""\
[build-system]
requires = ["setuptools"]
build-backend = "setuptools.build_meta"
"""
)
(path / "setup.py").write_text(
"""\
import setuptools
setuptools.setup(name="tmp", version="0.0.1")
"""
)
(path / "tmp.py").write_text(
"""\
print("Hello World!")
"""
)
(path / "README.md").touch()
build_directory = path / "dist"
assert not build_directory.is_dir()
with path.as_cwd(env_vars={ConfigEnvVars.DATA: str(data_path)}):
result = hatch("build")
assert result.exit_code == 0, result.output
assert build_directory.is_dir()
artifacts = list(build_directory.iterdir())
assert len(artifacts) == 2
wheel_path = build_directory / "tmp-0.0.1-py3-none-any.whl"
assert wheel_path.is_file()
sdist_path = build_directory / "tmp-0.0.1.tar.gz"
assert sdist_path.is_file()
assert result.exit_code == 0, result.output
assert result.output == helpers.dedent(
f"""
Creating environment: hatch-build
Checking dependencies
Syncing dependencies
Inspecting build dependencies
──────────────────────────────────── sdist ─────────────────────────────────────
{sdist_path.relative_to(path)}
──────────────────────────────────── wheel ─────────────────────────────────────
{wheel_path.relative_to(path)}
"""
)
@pytest.mark.allow_backend_process
def test_incompatible_environment(hatch, temp_dir, helpers, build_env_config):
project_name = "My.App"
with temp_dir.as_cwd():
result = hatch("new", project_name)
assert result.exit_code == 0, result.output
path = temp_dir / "my-app"
project = Project(path)
helpers.update_project_environment(project, "hatch-build", {"python": "9000", **build_env_config})
with path.as_cwd():
result = hatch("build")
assert result.exit_code == 1, result.output
assert result.output == helpers.dedent(
"""
Environment `hatch-build` is incompatible: cannot locate Python: 9000
"""
)
@pytest.mark.allow_backend_process
@pytest.mark.requires_internet
def test_no_compatibility_check_if_exists(hatch, temp_dir, helpers, mocker):
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()
with project_path.as_cwd(env_vars={ConfigEnvVars.DATA: str(data_path)}):
result = hatch("build")
assert result.exit_code == 0, result.output
build_directory = project_path / "dist"
assert build_directory.is_dir()
artifacts = list(build_directory.iterdir())
assert len(artifacts) == 2
assert result.exit_code == 0, result.output
assert result.output == helpers.dedent(
"""
Creating environment: hatch-build
Checking dependencies
Syncing dependencies
Inspecting build dependencies
──────────────────────────────────── sdist ─────────────────────────────────────
──────────────────────────────────── wheel ─────────────────────────────────────
"""
)
build_directory.remove()
mocker.patch("hatch.env.virtual.VirtualEnvironment.check_compatibility", side_effect=Exception("incompatible"))
with project_path.as_cwd(env_vars={ConfigEnvVars.DATA: str(data_path)}):
result = hatch("build")
assert result.exit_code == 0, result.output
artifacts = list(build_directory.iterdir())
assert len(artifacts) == 2
assert result.exit_code == 0, result.output
assert result.output == helpers.dedent(
"""
Inspecting build dependencies
──────────────────────────────────── sdist ─────────────────────────────────────
──────────────────────────────────── wheel ─────────────────────────────────────
"""
)
def test_unknown_targets(hatch, temp_dir, helpers):
project_name = "My.App"
with temp_dir.as_cwd():
result = hatch("new", project_name)
assert result.exit_code == 0, result.output
path = temp_dir / "my-app"
with path.as_cwd():
result = hatch("build", "-t", "foo")
assert result.exit_code == 1, result.output
assert result.output == helpers.dedent(
"""
Creating environment: hatch-build
Checking dependencies
Syncing dependencies
Inspecting build dependencies
───────────────────────────────────── foo ──────────────────────────────────────
Unknown build targets: foo
"""
)
def test_mutually_exclusive_hook_options(hatch, temp_dir, helpers):
project_name = "My.App"
with temp_dir.as_cwd():
result = hatch("new", project_name)
assert result.exit_code == 0, result.output
path = temp_dir / "my-app"
with path.as_cwd():
result = hatch("build", "--hooks-only", "--no-hooks")
assert result.exit_code == 1, result.output
assert result.output == helpers.dedent(
"""
Creating environment: hatch-build
Checking dependencies
Syncing dependencies
Inspecting build dependencies
──────────────────────────────────── sdist ─────────────────────────────────────
Cannot use both --hooks-only and --no-hooks together
"""
)
def test_default(hatch, temp_dir, helpers):
project_name = "My.App"
with temp_dir.as_cwd():
result = hatch("new", project_name)
assert result.exit_code == 0, result.output
path = temp_dir / "my-app"
with path.as_cwd():
result = hatch("build")
assert result.exit_code == 0, result.output
build_directory = path / "dist"
assert build_directory.is_dir()
artifacts = list(build_directory.iterdir())
assert len(artifacts) == 2
sdist_path = next(artifact for artifact in artifacts if artifact.name.endswith(".tar.gz"))
wheel_path = next(artifact for artifact in artifacts if artifact.name.endswith(".whl"))
assert result.output == helpers.dedent(
f"""
Creating environment: hatch-build
Checking dependencies
Syncing dependencies
Inspecting build dependencies
──────────────────────────────────── sdist ─────────────────────────────────────
{sdist_path.relative_to(path)}
──────────────────────────────────── wheel ─────────────────────────────────────
{wheel_path.relative_to(path)}
"""
)
def test_explicit_targets(hatch, temp_dir, helpers):
project_name = "My.App"
with temp_dir.as_cwd():
result = hatch("new", project_name)
assert result.exit_code == 0, result.output
path = temp_dir / "my-app"
with path.as_cwd():
result = hatch("build", "-t", "wheel")
assert result.exit_code == 0, result.output
build_directory = path / "dist"
assert build_directory.is_dir()
artifacts = list(build_directory.iterdir())
assert len(artifacts) == 1
wheel_path = next(artifact for artifact in artifacts if artifact.name.endswith(".whl"))
assert result.output == helpers.dedent(
f"""
Creating environment: hatch-build
Checking dependencies
Syncing dependencies
Inspecting build dependencies
──────────────────────────────────── wheel ─────────────────────────────────────
{wheel_path.relative_to(path)}
"""
)
def test_explicit_directory(hatch, temp_dir, helpers):
project_name = "My.App"
with temp_dir.as_cwd():
result = hatch("new", project_name)
assert result.exit_code == 0, result.output
path = temp_dir / "my-app"
build_directory = temp_dir / "dist"
with path.as_cwd():
result = hatch("build", str(build_directory))
assert result.exit_code == 0, result.output
assert build_directory.is_dir()
artifacts = list(build_directory.iterdir())
assert len(artifacts) == 2
sdist_path = next(artifact for artifact in artifacts if artifact.name.endswith(".tar.gz"))
wheel_path = next(artifact for artifact in artifacts if artifact.name.endswith(".whl"))
assert result.output == helpers.dedent(
f"""
Creating environment: hatch-build
Checking dependencies
Syncing dependencies
Inspecting build dependencies
──────────────────────────────────── sdist ─────────────────────────────────────
{sdist_path}
──────────────────────────────────── wheel ─────────────────────────────────────
{wheel_path}
"""
)
def test_explicit_directory_env_var(hatch, temp_dir, helpers):
project_name = "My.App"
with temp_dir.as_cwd():
result = hatch("new", project_name)
assert result.exit_code == 0, result.output
path = temp_dir / "my-app"
build_directory = temp_dir / "dist"
with path.as_cwd({BuildEnvVars.LOCATION: str(build_directory)}):
result = hatch("build")
assert result.exit_code == 0, result.output
assert build_directory.is_dir()
artifacts = list(build_directory.iterdir())
assert len(artifacts) == 2
sdist_path = next(artifact for artifact in artifacts if artifact.name.endswith(".tar.gz"))
wheel_path = next(artifact for artifact in artifacts if artifact.name.endswith(".whl"))
assert result.output == helpers.dedent(
f"""
Creating environment: hatch-build
Checking dependencies
Syncing dependencies
Inspecting build dependencies
──────────────────────────────────── sdist ─────────────────────────────────────
{sdist_path}
──────────────────────────────────── wheel ─────────────────────────────────────
{wheel_path}
"""
)
def test_clean(hatch, temp_dir, helpers, config_file):
config_file.model.template.plugins["default"]["src-layout"] = False
config_file.save()
project_name = "My.App"
with temp_dir.as_cwd():
result = hatch("new", project_name)
assert result.exit_code == 0, result.output
path = temp_dir / "my-app"
build_script = path / DEFAULT_BUILD_SCRIPT
build_script.write_text(
helpers.dedent(
"""
import pathlib
from hatchling.builders.hooks.plugin.interface import BuildHookInterface
class CustomHook(BuildHookInterface):
def clean(self, versions):
if self.target_name == 'wheel':
pathlib.Path('my_app', 'lib.so').unlink()
def initialize(self, version, build_data):
if self.target_name == 'wheel':
pathlib.Path('my_app', 'lib.so').touch()
"""
)
)
project = Project(path)
config = dict(project.raw_config)
config["tool"]["hatch"]["build"] = {"hooks": {"custom": {"path": build_script.name}}}
project.save_config(config)
with path.as_cwd():
result = hatch("build")
assert result.exit_code == 0, result.output
result = hatch("version", "minor")
assert result.exit_code == 0, result.output
result = hatch("build")
assert result.exit_code == 0, result.output
build_directory = path / "dist"
assert build_directory.is_dir()
assert (path / "my_app" / "lib.so").is_file()
artifacts = list(build_directory.iterdir())
assert len(artifacts) == 4
test_file = build_directory / "test.txt"
test_file.touch()
with path.as_cwd():
result = hatch("version", "9000")
assert result.exit_code == 0, result.output
result = hatch("build", "-c")
assert result.exit_code == 0, result.output
artifacts = list(build_directory.iterdir())
assert len(artifacts) == 3
assert test_file in artifacts
sdist_path = next(artifact for artifact in artifacts if artifact.name.endswith(".tar.gz"))
assert "9000" in str(sdist_path)
wheel_path = next(artifact for artifact in artifacts if artifact.name.endswith(".whl"))
assert "9000" in str(wheel_path)
assert result.output == helpers.dedent(
f"""
Inspecting build dependencies
──────────────────────────────────── sdist ─────────────────────────────────────
{sdist_path.relative_to(path)}
──────────────────────────────────── wheel ─────────────────────────────────────
{wheel_path.relative_to(path)}
"""
)
def test_clean_env_var(hatch, temp_dir, helpers):
project_name = "My.App"
with temp_dir.as_cwd():
result = hatch("new", project_name)
assert result.exit_code == 0, result.output
path = temp_dir / "my-app"
with path.as_cwd():
result = hatch("build")
assert result.exit_code == 0, result.output
result = hatch("version", "minor")
assert result.exit_code == 0, result.output
result = hatch("build")
assert result.exit_code == 0, result.output
build_directory = path / "dist"
assert build_directory.is_dir()
artifacts = list(build_directory.iterdir())
assert len(artifacts) == 4
test_file = build_directory / "test.txt"
test_file.touch()
with path.as_cwd({BuildEnvVars.CLEAN: "true"}):
result = hatch("version", "9000")
assert result.exit_code == 0, result.output
result = hatch("build")
assert result.exit_code == 0, result.output
artifacts = list(build_directory.iterdir())
assert len(artifacts) == 3
assert test_file in artifacts
sdist_path = next(artifact for artifact in artifacts if artifact.name.endswith(".tar.gz"))
assert "9000" in str(sdist_path)
wheel_path = next(artifact for artifact in artifacts if artifact.name.endswith(".whl"))
assert "9000" in str(wheel_path)
assert result.output == helpers.dedent(
f"""
Inspecting build dependencies
──────────────────────────────────── sdist ─────────────────────────────────────
{sdist_path.relative_to(path)}
──────────────────────────────────── wheel ─────────────────────────────────────
{wheel_path.relative_to(path)}
"""
)
def test_clean_only(hatch, temp_dir, helpers, config_file):
config_file.model.template.plugins["default"]["src-layout"] = False
config_file.save()
project_name = "My.App"
with temp_dir.as_cwd():
result = hatch("new", project_name)
assert result.exit_code == 0, result.output
path = temp_dir / "my-app"
build_script = path / DEFAULT_BUILD_SCRIPT
build_script.write_text(
helpers.dedent(
"""
import pathlib
from hatchling.builders.hooks.plugin.interface import BuildHookInterface
class CustomHook(BuildHookInterface):
def clean(self, versions):
if self.target_name == 'wheel':
pathlib.Path('my_app', 'lib.so').unlink()
def initialize(self, version, build_data):
if self.target_name == 'wheel':
pathlib.Path('my_app', 'lib.so').touch()
"""
)
)
project = Project(path)
config = dict(project.raw_config)
config["tool"]["hatch"]["build"] = {"hooks": {"custom": {"path": build_script.name}}}
project.save_config(config)
with path.as_cwd():
result = hatch("build")
assert result.exit_code == 0, result.output
build_directory = path / "dist"
assert build_directory.is_dir()
build_artifact = path / "my_app" / "lib.so"
assert build_artifact.is_file()
artifacts = list(build_directory.iterdir())
assert len(artifacts) == 2
with path.as_cwd():
result = hatch("version", "minor")
assert result.exit_code == 0, result.output
result = hatch("build", "--clean-only")
assert result.exit_code == 0, result.output
artifacts = list(build_directory.iterdir())
assert not artifacts
assert not build_artifact.exists()
assert result.output == helpers.dedent(
"""
Inspecting build dependencies
"""
)
def test_clean_only_hooks_only(hatch, temp_dir, helpers, config_file):
config_file.model.template.plugins["default"]["src-layout"] = False
config_file.save()
project_name = "My.App"
with temp_dir.as_cwd():
result = hatch("new", project_name)
assert result.exit_code == 0, result.output
path = temp_dir / "my-app"
build_script = path / DEFAULT_BUILD_SCRIPT
build_script.write_text(
helpers.dedent(
"""
import pathlib
from hatchling.builders.hooks.plugin.interface import BuildHookInterface
class CustomHook(BuildHookInterface):
def clean(self, versions):
if self.target_name == 'wheel':
pathlib.Path('my_app', 'lib.so').unlink()
def initialize(self, version, build_data):
if self.target_name == 'wheel':
pathlib.Path('my_app', 'lib.so').touch()
"""
)
)
project = Project(path)
config = dict(project.raw_config)
config["tool"]["hatch"]["build"] = {"hooks": {"custom": {"path": build_script.name}}}
project.save_config(config)
with path.as_cwd():
result = hatch("build")
assert result.exit_code == 0, result.output
build_directory = path / "dist"
assert build_directory.is_dir()
build_artifact = path / "my_app" / "lib.so"
assert build_artifact.is_file()
artifacts = list(build_directory.iterdir())
assert len(artifacts) == 2
with path.as_cwd():
result = hatch("version", "minor")
assert result.exit_code == 0, result.output
result = hatch("build", "--clean-only", "--hooks-only")
assert result.exit_code == 0, result.output
artifacts = list(build_directory.iterdir())
assert len(artifacts) == 2
assert not build_artifact.exists()
assert result.output == helpers.dedent(
"""
Inspecting build dependencies
"""
)
def test_clean_hooks_after(hatch, temp_dir, helpers, config_file):
config_file.model.template.plugins["default"]["src-layout"] = False
config_file.save()
project_name = "My.App"
with temp_dir.as_cwd():
result = hatch("new", project_name)
assert result.exit_code == 0, result.output
path = temp_dir / "my-app"
build_script = path / DEFAULT_BUILD_SCRIPT
build_script.write_text(
helpers.dedent(
"""
import pathlib
from hatchling.builders.hooks.plugin.interface import BuildHookInterface
class CustomHook(BuildHookInterface):
def clean(self, versions):
if self.target_name == 'wheel':
pathlib.Path('my_app', 'lib.so').unlink()
def initialize(self, version, build_data):
if self.target_name == 'wheel':
pathlib.Path('my_app', 'lib.so').touch()
"""
)
)
project = Project(path)
config = dict(project.raw_config)
config["tool"]["hatch"]["build"] = {"hooks": {"custom": {"path": build_script.name}}}
project.save_config(config)
with path.as_cwd():
result = hatch("build", "--clean-hooks-after")
assert result.exit_code == 0, result.output
build_directory = path / "dist"
assert build_directory.is_dir()
build_artifact = path / "my_app" / "lib.so"
assert not build_artifact.exists()
artifacts = list(build_directory.iterdir())
assert len(artifacts) == 2
sdist_path = next(artifact for artifact in artifacts if artifact.name.endswith(".tar.gz"))
wheel_path = next(artifact for artifact in artifacts if artifact.name.endswith(".whl"))
assert result.output == helpers.dedent(
f"""
Creating environment: hatch-build
Checking dependencies
Syncing dependencies
Inspecting build dependencies
──────────────────────────────────── sdist ─────────────────────────────────────
{sdist_path.relative_to(path)}
──────────────────────────────────── wheel ─────────────────────────────────────
{wheel_path.relative_to(path)}
"""
)
def test_clean_hooks_after_env_var(hatch, temp_dir, helpers, config_file):
config_file.model.template.plugins["default"]["src-layout"] = False
config_file.save()
project_name = "My.App"
with temp_dir.as_cwd():
result = hatch("new", project_name)
assert result.exit_code == 0, result.output
path = temp_dir / "my-app"
build_script = path / DEFAULT_BUILD_SCRIPT
build_script.write_text(
helpers.dedent(
"""
import pathlib
from hatchling.builders.hooks.plugin.interface import BuildHookInterface
class CustomHook(BuildHookInterface):
def clean(self, versions):
if self.target_name == 'wheel':
pathlib.Path('my_app', 'lib.so').unlink()
def initialize(self, version, build_data):
if self.target_name == 'wheel':
pathlib.Path('my_app', 'lib.so').touch()
"""
)
)
project = Project(path)
config = dict(project.raw_config)
config["tool"]["hatch"]["build"] = {"hooks": {"custom": {"path": build_script.name}}}
project.save_config(config)
with path.as_cwd({BuildEnvVars.CLEAN_HOOKS_AFTER: "true"}):
result = hatch("build")
assert result.exit_code == 0, result.output
build_directory = path / "dist"
assert build_directory.is_dir()
build_artifact = path / "my_app" / "lib.so"
assert not build_artifact.exists()
artifacts = list(build_directory.iterdir())
assert len(artifacts) == 2
sdist_path = next(artifact for artifact in artifacts if artifact.name.endswith(".tar.gz"))
wheel_path = next(artifact for artifact in artifacts if artifact.name.endswith(".whl"))
assert result.output == helpers.dedent(
f"""
Creating environment: hatch-build
Checking dependencies
Syncing dependencies
Inspecting build dependencies
──────────────────────────────────── sdist ─────────────────────────────────────
{sdist_path.relative_to(path)}
──────────────────────────────────── wheel ─────────────────────────────────────
{wheel_path.relative_to(path)}
"""
)
def test_clean_only_no_hooks(hatch, temp_dir, helpers, config_file):
config_file.model.template.plugins["default"]["src-layout"] = False
config_file.save()
project_name = "My.App"
with temp_dir.as_cwd():
result = hatch("new", project_name)
assert result.exit_code == 0, result.output
path = temp_dir / "my-app"
build_script = path / DEFAULT_BUILD_SCRIPT
build_script.write_text(
helpers.dedent(
"""
import pathlib
from hatchling.builders.hooks.plugin.interface import BuildHookInterface
class CustomHook(BuildHookInterface):
def clean(self, versions):
if self.target_name == 'wheel':
pathlib.Path('my_app', 'lib.so').unlink()
def initialize(self, version, build_data):
if self.target_name == 'wheel':
pathlib.Path('my_app', 'lib.so').touch()
"""
)
)
project = Project(path)
config = dict(project.raw_config)
config["tool"]["hatch"]["build"] = {"hooks": {"custom": {"path": build_script.name}}}
project.save_config(config)
with path.as_cwd():
result = hatch("build")
assert result.exit_code == 0, result.output
build_directory = path / "dist"
assert build_directory.is_dir()
build_artifact = path / "my_app" / "lib.so"
assert build_artifact.is_file()
artifacts = list(build_directory.iterdir())
assert len(artifacts) == 2
with path.as_cwd():
result = hatch("version", "minor")
assert result.exit_code == 0, result.output
result = hatch("build", "--clean-only", "--no-hooks")
assert result.exit_code == 0, result.output
artifacts = list(build_directory.iterdir())
assert not artifacts
assert build_artifact.is_file()
assert result.output == helpers.dedent(
"""
Inspecting build dependencies
"""
)
def test_hooks_only(hatch, temp_dir, helpers, config_file):
config_file.model.template.plugins["default"]["src-layout"] = False
config_file.save()
project_name = "My.App"
with temp_dir.as_cwd():
result = hatch("new", project_name)
assert result.exit_code == 0, result.output
path = temp_dir / "my-app"
build_script = path / DEFAULT_BUILD_SCRIPT
build_script.write_text(
helpers.dedent(
"""
import pathlib
from hatchling.builders.hooks.plugin.interface import BuildHookInterface
class CustomHook(BuildHookInterface):
def initialize(self, version, build_data):
if self.target_name == 'wheel':
pathlib.Path('my_app', 'lib.so').touch()
"""
)
)
project = Project(path)
config = dict(project.raw_config)
config["tool"]["hatch"]["build"] = {"hooks": {"custom": {"path": build_script.name}}}
project.save_config(config)
with path.as_cwd():
result = hatch("-v", "build", "-t", "wheel", "--hooks-only")
assert result.exit_code == 0, result.output
build_directory = path / "dist"
assert build_directory.is_dir()
artifacts = list(build_directory.iterdir())
assert len(artifacts) == 0
assert (path / "my_app" / "lib.so").is_file()
helpers.assert_output_match(
result.output,
r"""
Creating environment: hatch-build
Checking dependencies
Syncing dependencies
Inspecting build dependencies
cmd \[1\] \| python -u .+
──────────────────────────────────── wheel ─────────────────────────────────────
cmd \[1\] \| python -u -m hatchling build --target wheel --hooks-only
Building `wheel` version `standard`
Only ran build hooks for `wheel` version `standard`
""",
)
def test_hooks_only_env_var(hatch, temp_dir, helpers, config_file):
config_file.model.template.plugins["default"]["src-layout"] = False
config_file.save()
project_name = "My.App"
with temp_dir.as_cwd():
result = hatch("new", project_name)
assert result.exit_code == 0, result.output
path = temp_dir / "my-app"
build_script = path / DEFAULT_BUILD_SCRIPT
build_script.write_text(
helpers.dedent(
"""
import pathlib
from hatchling.builders.hooks.plugin.interface import BuildHookInterface
class CustomHook(BuildHookInterface):
def initialize(self, version, build_data):
if self.target_name == 'wheel':
pathlib.Path('my_app', 'lib.so').touch()
"""
)
)
project = Project(path)
config = dict(project.raw_config)
config["tool"]["hatch"]["build"] = {"hooks": {"custom": {"path": build_script.name}}}
project.save_config(config)
with path.as_cwd({BuildEnvVars.HOOKS_ONLY: "true"}):
result = hatch("-v", "build", "-t", "wheel")
assert result.exit_code == 0, result.output
build_directory = path / "dist"
assert build_directory.is_dir()
artifacts = list(build_directory.iterdir())
assert len(artifacts) == 0
assert (path / "my_app" / "lib.so").is_file()
helpers.assert_output_match(
result.output,
r"""
Creating environment: hatch-build
Checking dependencies
Syncing dependencies
Inspecting build dependencies
cmd \[1\] \| python -u .+
──────────────────────────────────── wheel ─────────────────────────────────────
cmd \[1\] \| python -u -m hatchling build --target wheel --hooks-only
Building `wheel` version `standard`
Only ran build hooks for `wheel` version `standard`
""",
)
def test_extensions_only(hatch, temp_dir, helpers, config_file):
config_file.model.template.plugins["default"]["src-layout"] = False
config_file.save()
project_name = "My.App"
with temp_dir.as_cwd():
result = hatch("new", project_name)
assert result.exit_code == 0, result.output
path = temp_dir / "my-app"
build_script = path / DEFAULT_BUILD_SCRIPT
build_script.write_text(
helpers.dedent(
"""
import pathlib
from hatchling.builders.hooks.plugin.interface import BuildHookInterface
class CustomHook(BuildHookInterface):
def initialize(self, version, build_data):
if self.target_name == 'wheel':
pathlib.Path('my_app', 'lib.so').touch()
"""
)
)
project = Project(path)
config = dict(project.raw_config)
config["tool"]["hatch"]["build"] = {"hooks": {"custom": {"path": build_script.name}}}
project.save_config(config)
with path.as_cwd():
result = hatch("-v", "build", "--ext")
assert result.exit_code == 0, result.output
build_directory = path / "dist"
assert build_directory.is_dir()
artifacts = list(build_directory.iterdir())
assert len(artifacts) == 0
assert (path / "my_app" / "lib.so").is_file()
helpers.assert_output_match(
result.output,
r"""
Creating environment: hatch-build
Checking dependencies
Syncing dependencies
Inspecting build dependencies
cmd \[1\] \| python -u .+
──────────────────────────────────── wheel ─────────────────────────────────────
cmd \[1\] \| python -u -m hatchling build --target wheel --hooks-only
Building `wheel` version `standard`
Only ran build hooks for `wheel` version `standard`
""",
)
def test_no_hooks(hatch, temp_dir, helpers):
project_name = "My.App"
with temp_dir.as_cwd():
result = hatch("new", project_name)
assert result.exit_code == 0, result.output
path = temp_dir / "my-app"
build_script = path / DEFAULT_BUILD_SCRIPT
build_script.write_text(
helpers.dedent(
"""
import pathlib
from hatchling.builders.hooks.plugin.interface import BuildHookInterface
class CustomHook(BuildHookInterface):
def initialize(self, version, build_data):
if self.target_name == 'wheel':
pathlib.Path('my_app', 'lib.so').touch()
"""
)
)
project = Project(path)
config = dict(project.raw_config)
config["tool"]["hatch"]["build"] = {"hooks": {"custom": {"path": build_script.name}}}
project.save_config(config)
with path.as_cwd():
result = hatch("build", "-t", "wheel", "--no-hooks")
assert result.exit_code == 0, result.output
build_directory = path / "dist"
assert build_directory.is_dir()
artifacts = list(build_directory.iterdir())
assert len(artifacts) == 1
assert not (path / "my_app" / "lib.so").exists()
wheel_path = next(artifact for artifact in artifacts if artifact.name.endswith(".whl"))
assert result.output == helpers.dedent(
f"""
Creating environment: hatch-build
Checking dependencies
Syncing dependencies
Inspecting build dependencies
──────────────────────────────────── wheel ─────────────────────────────────────
{wheel_path.relative_to(path)}
"""
)
def test_no_hooks_env_var(hatch, temp_dir, helpers):
project_name = "My.App"
with temp_dir.as_cwd():
result = hatch("new", project_name)
assert result.exit_code == 0, result.output
path = temp_dir / "my-app"
build_script = path / DEFAULT_BUILD_SCRIPT
build_script.write_text(
helpers.dedent(
"""
import pathlib
from hatchling.builders.hooks.plugin.interface import BuildHookInterface
class CustomHook(BuildHookInterface):
def initialize(self, version, build_data):
if self.target_name == 'wheel':
pathlib.Path('my_app', 'lib.so').touch()
"""
)
)
project = Project(path)
config = dict(project.raw_config)
config["tool"]["hatch"]["build"] = {"hooks": {"custom": {"path": build_script.name}}}
project.save_config(config)
with path.as_cwd({BuildEnvVars.NO_HOOKS: "true"}):
result = hatch("build", "-t", "wheel")
assert result.exit_code == 0, result.output
build_directory = path / "dist"
assert build_directory.is_dir()
artifacts = list(build_directory.iterdir())
assert len(artifacts) == 1
assert not (path / "my_app" / "lib.so").exists()
wheel_path = next(artifact for artifact in artifacts if artifact.name.endswith(".whl"))
assert result.output == helpers.dedent(
f"""
Creating environment: hatch-build
Checking dependencies
Syncing dependencies
Inspecting build dependencies
──────────────────────────────────── wheel ─────────────────────────────────────
{wheel_path.relative_to(path)}
"""
)
def test_debug_verbosity(hatch, temp_dir, helpers):
project_name = "My.App"
with temp_dir.as_cwd():
result = hatch("new", project_name)
assert result.exit_code == 0, result.output
path = temp_dir / "my-app"
with path.as_cwd():
result = hatch("-v", "build", "-t", "wheel:standard")
assert result.exit_code == 0, result.output
build_directory = path / "dist"
assert build_directory.is_dir()
artifacts = list(build_directory.iterdir())
assert len(artifacts) == 1
wheel_path = next(artifact for artifact in artifacts if artifact.name.endswith(".whl"))
helpers.assert_output_match(
result.output,
rf"""
Creating environment: hatch-build
Checking dependencies
Syncing dependencies
Inspecting build dependencies
──────────────────────────────────── wheel ─────────────────────────────────────
cmd \[1\] \| python -u -m hatchling build --target wheel:standard
Building `wheel` version `standard`
{re.escape(str(wheel_path.relative_to(path)))}
""",
)
@pytest.mark.allow_backend_process
@pytest.mark.requires_internet
def test_shipped(hatch, temp_dir, helpers):
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()
with project_path.as_cwd(env_vars={ConfigEnvVars.DATA: str(data_path)}):
result = hatch("build")
assert result.exit_code == 0, result.output
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 == "hatch-build"
build_directory = project_path / "dist"
assert build_directory.is_dir()
artifacts = list(build_directory.iterdir())
assert len(artifacts) == 2
assert result.output == helpers.dedent(
"""
Creating environment: hatch-build
Checking dependencies
Syncing dependencies
Inspecting build dependencies
──────────────────────────────────── sdist ─────────────────────────────────────
──────────────────────────────────── wheel ─────────────────────────────────────
"""
)
# Test removal while we're here
with project_path.as_cwd(env_vars={ConfigEnvVars.DATA: str(data_path)}):
result = hatch("env", "remove", "hatch-build")
assert result.exit_code == 0, result.output
assert result.output == helpers.dedent(
"""
Removing environment: hatch-build
"""
)
assert not storage_path.is_dir()
@pytest.mark.allow_backend_process
@pytest.mark.requires_internet
def test_build_dependencies(hatch, temp_dir, helpers):
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()
build_script = project_path / DEFAULT_BUILD_SCRIPT
build_script.write_text(
helpers.dedent(
"""
import pathlib
import binary
from hatchling.builders.wheel import WheelBuilder
def get_builder():
return CustomWheelBuilder
class CustomWheelBuilder(WheelBuilder):
def build(self, **kwargs):
pathlib.Path('test.txt').write_text(str(binary.convert_units(1024)))
yield from super().build(**kwargs)
"""
)
)
project = Project(project_path)
config = dict(project.raw_config)
config["tool"]["hatch"]["build"] = {
"targets": {"custom": {"dependencies": ["binary"], "path": DEFAULT_BUILD_SCRIPT}},
}
project.save_config(config)
with project_path.as_cwd(env_vars={ConfigEnvVars.DATA: str(data_path)}):
result = hatch("build", "-t", "custom")
assert result.exit_code == 0, result.output
build_directory = project_path / "dist"
assert build_directory.is_dir()
artifacts = list(build_directory.iterdir())
assert len(artifacts) == 1
output_file = project_path / "test.txt"
assert output_file.is_file()
assert str(output_file.read_text()) == "(1.0, 'KiB')"
assert result.output == helpers.dedent(
"""
Creating environment: hatch-build
Checking dependencies
Syncing dependencies
Inspecting build dependencies
Syncing dependencies
──────────────────────────────────── custom ────────────────────────────────────
"""
)
def test_plugin_dependencies_unmet(hatch, temp_dir, helpers, mock_plugin_installation):
project_name = "My.App"
with temp_dir.as_cwd():
result = hatch("new", project_name)
assert result.exit_code == 0, result.output
path = temp_dir / "my-app"
dependency = os.urandom(16).hex()
(path / DEFAULT_CONFIG_FILE).write_text(
helpers.dedent(
f"""
[env]
requires = ["{dependency}"]
"""
)
)
with path.as_cwd():
result = hatch("build")
assert result.exit_code == 0, result.output
build_directory = path / "dist"
assert build_directory.is_dir()
artifacts = list(build_directory.iterdir())
assert len(artifacts) == 2
sdist_path = next(artifact for artifact in artifacts if artifact.name.endswith(".tar.gz"))
wheel_path = next(artifact for artifact in artifacts if artifact.name.endswith(".whl"))
assert result.output == helpers.dedent(
f"""
Syncing environment plugin requirements
Creating environment: hatch-build
Checking dependencies
Syncing dependencies
Inspecting build dependencies
──────────────────────────────────── sdist ─────────────────────────────────────
{sdist_path.relative_to(path)}
──────────────────────────────────── wheel ─────────────────────────────────────
{wheel_path.relative_to(path)}
"""
)
helpers.assert_plugin_installation(mock_plugin_installation, [dependency])
================================================
FILE: tests/cli/clean/__init__.py
================================================
================================================
FILE: tests/cli/clean/test_clean.py
================================================
import os
import pytest
from hatch.project.core import Project
from hatchling.utils.constants import DEFAULT_BUILD_SCRIPT, DEFAULT_CONFIG_FILE
pytestmark = [pytest.mark.usefixtures("mock_backend_process")]
def test(hatch, temp_dir, helpers, config_file, mock_plugin_installation):
config_file.model.template.plugins["default"]["src-layout"] = False
config_file.save()
project_name = "My.App"
with temp_dir.as_cwd():
result = hatch("new", project_name)
assert result.exit_code == 0, result.output
path = temp_dir / "my-app"
build_script = path / DEFAULT_BUILD_SCRIPT
build_script.write_text(
helpers.dedent(
"""
import pathlib
from hatchling.builders.hooks.plugin.interface import BuildHookInterface
class CustomHook(BuildHookInterface):
def clean(self, versions):
if self.target_name == 'wheel':
pathlib.Path('my_app', 'lib.so').unlink()
def initialize(self, version, build_data):
if self.target_name == 'wheel':
pathlib.Path('my_app', 'lib.so').touch()
"""
)
)
project = Project(path)
config = dict(project.raw_config)
config["tool"]["hatch"]["build"] = {"hooks": {"custom": {"path": build_script.name}}}
project.save_config(config)
with path.as_cwd():
result = hatch("build")
assert result.exit_code == 0, result.output
build_directory = path / "dist"
assert build_directory.is_dir()
build_artifact = path / "my_app" / "lib.so"
assert build_artifact.is_file()
artifacts = list(build_directory.iterdir())
assert len(artifacts) == 2
dependency = os.urandom(16).hex()
(path / DEFAULT_CONFIG_FILE).write_text(
helpers.dedent(
f"""
[env]
requires = ["{dependency}"]
"""
)
)
with path.as_cwd():
result = hatch("version", "minor")
assert result.exit_code == 0, result.output
result = hatch("clean")
assert result.exit_code == 0, result.output
artifacts = list(build_directory.iterdir())
assert not artifacts
assert not build_artifact.exists()
assert result.output == helpers.dedent(
"""
Syncing environment plugin requirements
Inspecting build dependencies
"""
)
helpers.assert_plugin_installation(mock_plugin_installation, [dependency], count=2)
================================================
FILE: tests/cli/config/__init__.py
================================================
================================================
FILE: tests/cli/config/test_explore.py
================================================
def test_call(hatch, config_file, mocker):
mock = mocker.patch("click.launch")
result = hatch("config", "explore")
assert result.exit_code == 0, result.output
mock.assert_called_once_with(str(config_file.path), locate=True)
================================================
FILE: tests/cli/config/test_find.py
================================================
def test(hatch, config_file, helpers):
result = hatch("config", "find")
assert result.exit_code == 0, result.output
assert result.output == helpers.dedent(
f"""
{config_file.path}
"""
)
================================================
FILE: tests/cli/config/test_restore.py
================================================
def test_standard(hatch, config_file):
config_file.model.project = "foo"
config_file.save()
result = hatch("config", "restore")
assert result.exit_code == 0, result.output
assert result.output == "Settings were successfully restored.\n"
config_file.load()
assert config_file.model.project == ""
def test_allow_invalid_config(hatch, config_file, helpers):
config_file.model.project = ["foo"]
config_file.save()
result = hatch("config", "restore")
assert result.exit_code == 0, result.output
assert result.output == helpers.dedent(
"""
Settings were successfully restored.
"""
)
================================================
FILE: tests/cli/config/test_set.py
================================================
def test_standard(hatch, config_file, helpers):
result = hatch("config", "set", "project", "foo")
assert result.exit_code == 0, result.output
assert result.output == helpers.dedent(
"""
New setting:
project = "foo"
"""
)
config_file.load()
assert config_file.model.project == "foo"
def test_standard_deep(hatch, config_file, helpers):
result = hatch("config", "set", "template.name", "foo")
assert result.exit_code == 0, result.output
assert result.output == helpers.dedent(
"""
New setting:
[template]
name = "foo"
"""
)
config_file.load()
assert config_file.model.template.name == "foo"
def test_standard_complex_sequence(hatch, config_file, helpers):
result = hatch("config", "set", "dirs.project", "['/foo', '/bar']")
assert result.exit_code == 0, result.output
assert result.output == helpers.dedent(
"""
New setting:
[dirs]
project = ["/foo", "/bar"]
"""
)
config_file.load()
assert config_file.model.dirs.project == ["/foo", "/bar"]
def test_standard_complex_map(hatch, config_file, helpers):
result = hatch("config", "set", "projects", "{'a': '/foo', 'b': '/bar'}")
assert result.exit_code == 0, result.output
assert result.output == helpers.dedent(
"""
New setting:
[projects]
a = "/foo"
b = "/bar"
"""
)
config_file.load()
assert config_file.model.projects["a"].location == "/foo"
assert config_file.model.projects["b"].location == "/bar"
def test_standard_hidden(hatch, config_file, helpers):
result = hatch("config", "set", "publish.index.auth", "foo")
assert result.exit_code == 0, result.output
assert result.output == helpers.dedent(
"""
New setting:
[publish.index]
auth = "<...>"
"""
)
config_file.load()
assert config_file.model.publish["index"]["auth"] == "foo"
def test_prompt(hatch, config_file, helpers):
result = hatch("config", "set", "project", input="foo")
assert result.exit_code == 0, result.output
assert result.output == helpers.dedent(
"""
Value for `project`: foo
New setting:
project = "foo"
"""
)
config_file.load()
assert config_file.model.project == "foo"
def test_prompt_hidden(hatch, config_file, helpers):
result = hatch("config", "set", "publish.index.auth", input="foo")
assert result.exit_code == 0, result.output
assert result.output == helpers.dedent(
f"""
Value for `publish.index.auth`:{" "}
New setting:
[publish.index]
auth = "<...>"
"""
)
config_file.load()
assert config_file.model.publish["index"]["auth"] == "foo"
def test_prevent_invalid_config(hatch, config_file, helpers):
original_mode = config_file.model.mode
result = hatch("config", "set", "mode", "foo")
assert result.exit_code == 1
assert result.output == helpers.dedent(
"""
Error parsing config:
mode
must be one of: aware, local, project
"""
)
config_file.load()
assert config_file.model.mode == original_mode
def test_resolve_project_location_basic(hatch, config_file, helpers, temp_dir):
config_file.model.project = "foo"
config_file.save()
with temp_dir.as_cwd():
result = hatch("config", "set", "projects.foo", ".")
path = str(temp_dir).replace("\\", "\\\\")
assert result.exit_code == 0, result.output
assert result.output == helpers.dedent(
f"""
New setting:
[projects]
foo = "{path}"
"""
)
config_file.load()
assert config_file.model.projects["foo"].location == str(temp_dir)
def test_resolve_project_location_complex(hatch, config_file, helpers, temp_dir):
config_file.model.project = "foo"
config_file.save()
with temp_dir.as_cwd():
result = hatch("config", "set", "projects.foo.location", ".")
path = str(temp_dir).replace("\\", "\\\\")
assert result.exit_code == 0, result.output
assert result.output == helpers.dedent(
f"""
New setting:
[projects.foo]
location = "{path}"
"""
)
config_file.load()
assert config_file.model.projects["foo"].location == str(temp_dir)
def test_project_location_basic_set_first_project(hatch, config_file, helpers, temp_dir):
with temp_dir.as_cwd():
result = hatch("config", "set", "projects.foo", ".")
path = str(temp_dir).replace("\\", "\\\\")
assert result.exit_code == 0, result.output
assert result.output == helpers.dedent(
f"""
New setting:
project = "foo"
[projects]
foo = "{path}"
"""
)
config_file.load()
assert config_file.model.project == "foo"
assert config_file.model.projects["foo"].location == str(temp_dir)
def test_project_location_complex_set_first_project(hatch, config_file, helpers, temp_dir):
with temp_dir.as_cwd():
result = hatch("config", "set", "projects.foo.location", ".")
path = str(temp_dir).replace("\\", "\\\\")
assert result.exit_code == 0, result.output
assert result.output == helpers.dedent(
f"""
New setting:
project = "foo"
[projects.foo]
location = "{path}"
"""
)
config_file.load()
assert config_file.model.project == "foo"
assert config_file.model.projects["foo"].location == str(temp_dir)
def test_booleans(hatch, config_file, helpers, temp_dir):
assert config_file.model.template.licenses.headers is True
with temp_dir.as_cwd():
result = hatch("config", "set", "template.licenses.headers", "false")
assert result.exit_code == 0, result.output
assert result.output == helpers.dedent(
"""
New setting:
[template.licenses]
headers = false
"""
)
config_file.load()
assert config_file.model.template.licenses.headers is False
with temp_dir.as_cwd():
result = hatch("config", "set", "template.licenses.headers", "TruE")
assert result.exit_code == 0, result.output
assert result.output == helpers.dedent(
"""
New setting:
[template.licenses]
headers = true
"""
)
config_file.load()
assert config_file.model.template.licenses.headers is True
================================================
FILE: tests/cli/config/test_show.py
================================================
def test_default_scrubbed(hatch, config_file, helpers, default_cache_dir, default_data_dir):
config_file.model.project = "foo"
config_file.model.publish["index"]["auth"] = "bar"
config_file.save()
result = hatch("config", "show")
default_cache_directory = str(default_cache_dir).replace("\\", "\\\\")
default_data_directory = str(default_data_dir).replace("\\", "\\\\")
assert result.exit_code == 0, result.output
assert result.output == helpers.dedent(
f"""
mode = "local"
project = "foo"
shell = ""
[dirs]
project = []
python = "isolated"
data = "{default_data_directory}"
cache = "{default_cache_directory}"
[dirs.env]
[projects]
[template]
name = "Foo Bar"
email = "foo@bar.baz"
[template.licenses]
headers = true
default = [
"MIT",
]
[template.plugins.default]
tests = true
ci = false
src-layout = true
[terminal.styles]
info = "bold"
success = "bold cyan"
error = "bold red"
warning = "bold yellow"
waiting = "bold magenta"
debug = "bold"
spinner = "simpleDotsScrolling"
"""
)
def test_reveal(hatch, config_file, helpers, default_cache_dir, default_data_dir):
config_file.model.project = "foo"
config_file.model.publish["index"]["auth"] = "bar"
config_file.save()
result = hatch("config", "show", "-a")
default_cache_directory = str(default_cache_dir).replace("\\", "\\\\")
default_data_directory = str(default_data_dir).replace("\\", "\\\\")
assert result.exit_code == 0, result.output
assert result.output == helpers.dedent(
f"""
mode = "local"
project = "foo"
shell = ""
[dirs]
project = []
python = "isolated"
data = "{default_data_directory}"
cache = "{default_cache_directory}"
[dirs.env]
[projects]
[publish.index]
repo = "main"
auth = "bar"
[template]
name = "Foo Bar"
email = "foo@bar.baz"
[template.licenses]
headers = true
default = [
"MIT",
]
[template.plugins.default]
tests = true
ci = false
src-layout = true
[terminal.styles]
info = "bold"
success = "bold cyan"
error = "bold red"
warning = "bold yellow"
waiting = "bold magenta"
debug = "bold"
spinner = "simpleDotsScrolling"
"""
)
================================================
FILE: tests/cli/dep/__init__.py
================================================
================================================
FILE: tests/cli/dep/show/__init__.py
================================================
================================================
FILE: tests/cli/dep/show/test_requirements.py
================================================
import os
from hatch.config.constants import ConfigEnvVars
from hatch.project.core import Project
from hatchling.utils.constants import DEFAULT_CONFIG_FILE
def test_incompatible_environment(hatch, temp_dir, helpers, build_env_config):
project_name = "My.App"
with temp_dir.as_cwd():
result = hatch("new", project_name)
assert result.exit_code == 0, result.output
path = temp_dir / "my-app"
data_path = temp_dir / "data"
data_path.mkdir()
project = Project(path)
config = dict(project.raw_config)
config["build-system"]["requires"].append("foo")
config["project"]["dynamic"].append("dependencies")
project.save_config(config)
helpers.update_project_environment(project, "hatch-build", {"python": "9000", **build_env_config})
with path.as_cwd(env_vars={ConfigEnvVars.DATA: str(data_path)}):
result = hatch("dep", "show", "requirements")
assert result.exit_code == 1, result.output
assert result.output == helpers.dedent(
"""
Environment `hatch-build` is incompatible: cannot locate Python: 9000
"""
)
def test_project_only(hatch, helpers, temp_dir, config_file):
config_file.model.template.plugins["default"]["tests"] = False
config_file.save()
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"
project = Project(project_path)
config = dict(project.raw_config)
config["project"]["dependencies"] = ["foo-bar-baz"]
project.save_config(config)
with project_path.as_cwd():
result = hatch("dep", "show", "requirements", "-p")
assert result.exit_code == 0, result.output
assert result.output == helpers.dedent(
"""
foo-bar-baz
"""
)
def test_environment_only(hatch, helpers, temp_dir, config_file):
config_file.model.template.plugins["default"]["tests"] = False
config_file.save()
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"
project = Project(project_path)
helpers.update_project_environment(project, "default", {"dependencies": ["foo-bar-baz"]})
with project_path.as_cwd():
result = hatch("dep", "show", "requirements", "-e")
assert result.exit_code == 0, result.output
assert result.output == helpers.dedent(
"""
foo-bar-baz
"""
)
def test_default_both(hatch, helpers, temp_dir, config_file):
config_file.model.template.plugins["default"]["tests"] = False
config_file.save()
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"
project = Project(project_path)
config = dict(project.raw_config)
config["project"]["dependencies"] = ["foo-bar-baz"]
config["project"]["optional-dependencies"] = {
"feature1": ["bar-baz-foo"],
"feature2": ["bar-foo-baz"],
"feature3": ["foo-baz-bar"],
}
project.save_config(config)
helpers.update_project_environment(project, "default", {"dependencies": ["baz-bar-foo"]})
with project_path.as_cwd():
result = hatch("dep", "show", "requirements")
assert result.exit_code == 0, result.output
assert result.output == helpers.dedent(
"""
baz-bar-foo
foo-bar-baz
"""
)
def test_unknown_feature(hatch, helpers, temp_dir, config_file):
config_file.model.template.plugins["default"]["tests"] = False
config_file.save()
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"
with project_path.as_cwd():
result = hatch("dep", "show", "requirements", "-f", "foo")
assert result.exit_code == 1, result.output
assert result.output == helpers.dedent(
"""
Feature `foo` is not defined in field `project.optional-dependencies`
"""
)
def test_features_only(hatch, helpers, temp_dir, config_file):
config_file.model.template.plugins["default"]["tests"] = False
config_file.save()
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"
project = Project(project_path)
config = dict(project.raw_config)
config["project"]["dependencies"] = ["foo-bar-baz"]
config["project"]["optional-dependencies"] = {
"feature1": ["bar-baz-foo"],
"feature2": ["bar-foo-baz"],
"feature3": ["foo-baz-bar"],
"feature4": ["baz-foo-bar"],
}
project.save_config(config)
helpers.update_project_environment(project, "default", {"dependencies": ["baz-bar-foo"]})
with project_path.as_cwd():
result = hatch("dep", "show", "requirements", "-f", "feature2", "-f", "feature1")
assert result.exit_code == 0, result.output
assert result.output == helpers.dedent(
"""
bar-baz-foo
bar-foo-baz
"""
)
def test_include_features(hatch, helpers, temp_dir, config_file):
config_file.model.template.plugins["default"]["tests"] = False
config_file.save()
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"
project = Project(project_path)
config = dict(project.raw_config)
config["project"]["dependencies"] = ["foo-bar-baz"]
config["project"]["optional-dependencies"] = {
"feature1": ["bar-baz-foo"],
"feature2": ["bar-foo-baz"],
"feature3": ["foo-baz-bar"],
"feature4": ["baz-foo-bar"],
}
project.save_config(config)
helpers.update_project_environment(project, "default", {"dependencies": ["baz-bar-foo"]})
with project_path.as_cwd():
result = hatch("dep", "show", "requirements", "--all")
assert result.exit_code == 0, result.output
assert result.output == helpers.dedent(
"""
bar-baz-foo
bar-foo-baz
baz-bar-foo
baz-foo-bar
foo-bar-baz
foo-baz-bar
"""
)
def test_plugin_dependencies_unmet(hatch, helpers, temp_dir, config_file, mock_plugin_installation):
config_file.model.template.plugins["default"]["tests"] = False
config_file.save()
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"
dependency = os.urandom(16).hex()
(project_path / DEFAULT_CONFIG_FILE).write_text(
helpers.dedent(
f"""
[env]
requires = ["{dependency}"]
"""
)
)
project = Project(project_path)
config = dict(project.raw_config)
config["project"]["dependencies"] = ["foo-bar-baz"]
project.save_config(config)
with project_path.as_cwd():
result = hatch("dep", "show", "requirements", "-p")
assert result.exit_code == 0, result.output
assert result.output == helpers.dedent(
"""
Syncing environment plugin requirements
foo-bar-baz
"""
)
helpers.assert_plugin_installation(mock_plugin_installation, [dependency])
================================================
FILE: tests/cli/dep/show/test_table.py
================================================
import os
import pytest
from hatch.config.constants import ConfigEnvVars
from hatch.project.core import Project
from hatch.utils.structures import EnvVars
from hatchling.utils.constants import DEFAULT_CONFIG_FILE
@pytest.fixture(scope="module", autouse=True)
def _terminal_width():
with EnvVars({"COLUMNS": "200"}):
yield
def test_incompatible_environment(hatch, temp_dir, helpers, build_env_config):
project_name = "My.App"
with temp_dir.as_cwd():
result = hatch("new", project_name)
assert result.exit_code == 0, result.output
path = temp_dir / "my-app"
data_path = temp_dir / "data"
data_path.mkdir()
project = Project(path)
config = dict(project.raw_config)
config["build-system"]["requires"].append("foo")
config["project"]["dynamic"].append("dependencies")
project.save_config(config)
helpers.update_project_environment(project, "hatch-build", {"python": "9000", **build_env_config})
with path.as_cwd(env_vars={ConfigEnvVars.DATA: str(data_path)}):
result = hatch("dep", "show", "table")
assert result.exit_code == 1, result.output
assert result.output == helpers.dedent(
"""
Environment `hatch-build` is incompatible: cannot locate Python: 9000
"""
)
def test_project_only(hatch, helpers, temp_dir, config_file):
config_file.model.template.plugins["default"]["tests"] = False
config_file.save()
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()
project = Project(project_path)
config = dict(project.raw_config)
config["project"]["dependencies"] = ["foo-bar-baz"]
project.save_config(config)
with project_path.as_cwd():
result = hatch("dep", "show", "table", "--ascii", "-p")
assert result.exit_code == 0, result.output
assert helpers.remove_trailing_spaces(result.output) == helpers.dedent(
"""
Project
+-------------+
| Name |
+=============+
| foo-bar-baz |
+-------------+
"""
)
def test_environment_only(hatch, helpers, temp_dir, config_file):
config_file.model.template.plugins["default"]["tests"] = False
config_file.save()
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()
project = Project(project_path)
helpers.update_project_environment(project, "default", {"dependencies": ["foo-bar-baz"]})
with project_path.as_cwd():
result = hatch("dep", "show", "table", "--ascii", "-e")
assert result.exit_code == 0, result.output
assert helpers.remove_trailing_spaces(result.output) == helpers.dedent(
"""
Env: default
+-------------+
| Name |
+=============+
| foo-bar-baz |
+-------------+
"""
)
def test_default_both(hatch, helpers, temp_dir, config_file):
config_file.model.template.plugins["default"]["tests"] = False
config_file.save()
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()
project = Project(project_path)
config = dict(project.raw_config)
config["project"]["dependencies"] = ["foo-bar-baz"]
project.save_config(config)
helpers.update_project_environment(project, "default", {"dependencies": ["baz-bar-foo"]})
with project_path.as_cwd():
result = hatch("dep", "show", "table", "--ascii")
assert result.exit_code == 0, result.output
assert helpers.remove_trailing_spaces(result.output) == helpers.dedent(
"""
Project
+-------------+
| Name |
+=============+
| foo-bar-baz |
+-------------+
Env: default
+-------------+
| Name |
+=============+
| baz-bar-foo |
+-------------+
"""
)
def test_optional_columns(hatch, helpers, temp_dir, config_file):
config_file.model.template.plugins["default"]["tests"] = False
config_file.save()
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()
project = Project(project_path)
config = dict(project.raw_config)
config["project"]["dependencies"] = [
"python___dateutil",
"bAr.Baz[TLS, EdDSA] >=1.2RC5",
'Foo;python_version<"3.8"',
]
project.save_config(config)
helpers.update_project_environment(
project,
"default",
{
"dependencies": [
"proj @ git+https://github.com/org/proj.git@v1",
'bAr.Baz [TLS, EdDSA] >=1.2RC5;python_version<"3.8"',
],
},
)
with project_path.as_cwd():
result = hatch("dep", "show", "table", "--ascii")
assert result.exit_code == 0, result.output
assert helpers.remove_trailing_spaces(result.output) == helpers.dedent(
"""
Project
+-----------------+----------+------------------------+------------+
| Name | Versions | Markers | Features |
+=================+==========+========================+============+
| bar-baz | >=1.2rc5 | | eddsa, tls |
| foo | | python_version < '3.8' | |
| python-dateutil | | | |
+-----------------+----------+------------------------+------------+
Env: default
+---------+----------------------------------------+----------+------------------------+------------+
| Name | URL | Versions | Markers | Features |
+=========+========================================+==========+========================+============+
| bar-baz | | >=1.2rc5 | python_version < '3.8' | eddsa, tls |
| proj | git+https://github.com/org/proj.git@v1 | | | |
+---------+----------------------------------------+----------+------------------------+------------+
"""
)
def test_plugin_dependencies_unmet(hatch, helpers, temp_dir, config_file, mock_plugin_installation):
config_file.model.template.plugins["default"]["tests"] = False
config_file.save()
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()
dependency = os.urandom(16).hex()
(project_path / DEFAULT_CONFIG_FILE).write_text(
helpers.dedent(
f"""
[env]
requires = ["{dependency}"]
"""
)
)
project = Project(project_path)
config = dict(project.raw_config)
config["project"]["dependencies"] = ["foo-bar-baz"]
project.save_config(config)
with project_path.as_cwd():
result = hatch("dep", "show", "table", "--ascii", "-p")
assert result.exit_code == 0, result.output
assert helpers.remove_trailing_spaces(result.output) == helpers.dedent(
"""
Syncing environment plugin requirements
Project
+-------------+
| Name |
+=============+
| foo-bar-baz |
+-------------+
"""
)
helpers.assert_plugin_installation(mock_plugin_installation, [dependency])
================================================
FILE: tests/cli/dep/test_hash.py
================================================
import os
from hashlib import sha256
from hatch.config.constants import ConfigEnvVars
from hatch.project.core import Project
from hatchling.utils.constants import DEFAULT_CONFIG_FILE
def test_incompatible_environment(hatch, temp_dir, helpers, build_env_config):
project_name = "My.App"
with temp_dir.as_cwd():
result = hatch("new", project_name)
assert result.exit_code == 0, result.output
path = temp_dir / "my-app"
data_path = temp_dir / "data"
data_path.mkdir()
project = Project(path)
config = dict(project.raw_config)
config["build-system"]["requires"].append("foo")
config["project"]["dynamic"].append("dependencies")
project.save_config(config)
helpers.update_project_environment(project, "hatch-build", {"python": "9000", **build_env_config})
with path.as_cwd(env_vars={ConfigEnvVars.DATA: str(data_path)}):
result = hatch("dep", "hash")
assert result.exit_code == 1, result.output
assert result.output == helpers.dedent(
"""
Environment `hatch-build` is incompatible: cannot locate Python: 9000
"""
)
def test_all(hatch, helpers, temp_dir):
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"
project = Project(project_path)
config = dict(project.raw_config)
config["project"]["dependencies"] = ["Foo", "bar[ A, b]"]
project.save_config(config)
helpers.update_project_environment(project, "default", {"dependencies": ["bAZ >= 0"]})
expected_hash = sha256(b"bar[a,b]baz>=0foo").hexdigest()
with project_path.as_cwd():
result = hatch("dep", "hash")
assert result.exit_code == 0, result.output
assert result.output == helpers.dedent(
f"""
{expected_hash}
"""
)
def test_project_only(hatch, helpers, temp_dir):
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"
project = Project(project_path)
config = dict(project.raw_config)
config["project"]["dependencies"] = ["Foo", "bar[ A, b]"]
project.save_config(config)
helpers.update_project_environment(project, "default", {"dependencies": ["bAZ >= 0"]})
expected_hash = sha256(b"bar[a,b]foo").hexdigest()
with project_path.as_cwd():
result = hatch("dep", "hash", "-p")
assert result.exit_code == 0, result.output
assert result.output == helpers.dedent(
f"""
{expected_hash}
"""
)
def test_plugin_dependencies_unmet(hatch, helpers, temp_dir, mock_plugin_installation):
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"
dependency = os.urandom(16).hex()
(project_path / DEFAULT_CONFIG_FILE).write_text(
helpers.dedent(
f"""
[env]
requires = ["{dependency}"]
"""
)
)
project = Project(project_path)
config = dict(project.raw_config)
config["project"]["dependencies"] = ["Foo", "bar[ A, b]"]
project.save_config(config)
helpers.update_project_environment(project, "default", {"dependencies": ["bAZ >= 0"]})
expected_hash = sha256(b"bar[a,b]foo").hexdigest()
with project_path.as_cwd():
result = hatch("dep", "hash", "-p")
assert result.exit_code == 0, result.output
assert result.output == helpers.dedent(
f"""
Syncing environment plugin requirements
{expected_hash}
"""
)
helpers.assert_plugin_installation(mock_plugin_installation, [dependency])
================================================
FILE: tests/cli/env/__init__.py
================================================
================================================
FILE: tests/cli/env/test_create.py
================================================
import os
import sys
import pytest
from hatch.config.constants import AppEnvVars, ConfigEnvVars
from hatch.env.utils import get_env_var
from hatch.project.core import Project
from hatch.utils.structures import EnvVars
from hatch.venv.core import UVVirtualEnv, VirtualEnv
from hatchling.utils.constants import DEFAULT_BUILD_SCRIPT, DEFAULT_CONFIG_FILE
from hatchling.utils.fs import path_to_uri
def test_undefined(hatch, helpers, temp_dir, config_file):
config_file.model.template.plugins["default"]["tests"] = False
config_file.save()
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()
with project_path.as_cwd(env_vars={ConfigEnvVars.DATA: str(data_path)}):
result = hatch("env", "create", "test")
assert result.exit_code == 1
assert result.output == helpers.dedent(
"""
Environment `test` is not defined by project config
"""
)
def test_unknown_type(hatch, helpers, temp_dir, config_file):
config_file.model.template.plugins["default"]["tests"] = False
config_file.save()
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()
project = Project(project_path)
config = dict(project.config.envs["default"])
config["type"] = "foo"
helpers.update_project_environment(project, "default", config)
helpers.update_project_environment(project, "test", {})
with project_path.as_cwd(env_vars={ConfigEnvVars.DATA: str(data_path)}):
result = hatch("env", "create", "test")
assert result.exit_code == 1
assert result.output == helpers.dedent(
"""
Environment `test` has unknown type: foo
"""
)
def test_new(hatch, helpers, temp_dir, config_file):
config_file.model.template.plugins["default"]["tests"] = False
config_file.save()
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()
project = Project(project_path)
helpers.update_project_environment(project, "default", {"skip-install": True, **project.config.envs["default"]})
helpers.update_project_environment(project, "test", {})
with project_path.as_cwd(env_vars={ConfigEnvVars.DATA: str(data_path)}):
result = hatch("env", "create", "test")
assert result.exit_code == 0, result.output
assert result.output == helpers.dedent(
"""
Creating environment: test
Checking dependencies
"""
)
project = Project(project_path)
assert project.config.envs == {
"default": {"type": "virtual", "skip-install": True},
"test": {"type": "virtual", "skip-install": True},
}
assert project.raw_config["tool"]["hatch"]["envs"] == {
"default": {"type": "virtual", "skip-install": True},
"test": {},
}
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 == "test"
def test_uv_shipped(hatch, helpers, temp_dir, config_file):
config_file.model.template.plugins["default"]["tests"] = False
config_file.save()
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()
project = Project(project_path)
helpers.update_project_environment(
project,
"default",
{"skip-install": True, "installer": "uv", **project.config.envs["default"]},
)
helpers.update_project_environment(project, "test", {})
with (
project_path.as_cwd(),
EnvVars({ConfigEnvVars.DATA: str(data_path)}, exclude=[get_env_var(plugin_name="virtual", option="uv_path")]),
):
result = hatch("env", "create", "test")
assert result.exit_code == 0, result.output
assert result.output == helpers.dedent(
"""
Creating environment: test
Checking 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 == "test"
@pytest.mark.requires_internet
def test_uv_env(hatch, helpers, temp_dir, config_file):
config_file.model.template.plugins["default"]["tests"] = False
config_file.save()
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()
project = Project(project_path)
helpers.update_project_environment(
project,
"default",
{"skip-install": True, "installer": "uv", **project.config.envs["default"]},
)
helpers.update_project_environment(project, "hatch-uv", {"dependencies": ["uv>=0.1.31"]})
helpers.update_project_environment(project, "test", {})
with (
project_path.as_cwd(),
EnvVars({ConfigEnvVars.DATA: str(data_path)}, exclude=[get_env_var(plugin_name="virtual", option="uv_path")]),
):
result = hatch("env", "create", "test")
assert result.exit_code == 0, result.output
assert result.output == helpers.dedent(
"""
Creating environment: test
Creating environment: hatch-uv
Checking dependencies
Syncing dependencies
Checking 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) == 2
assert sorted(p.name for p in env_dirs) == ["hatch-uv", "test"]
def test_new_selected_python(hatch, helpers, temp_dir, config_file, python_on_path, mocker):
mocker.patch("sys.executable")
config_file.model.template.plugins["default"]["tests"] = False
config_file.save()
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()
project = Project(project_path)
helpers.update_project_environment(project, "default", {"skip-install": True, **project.config.envs["default"]})
helpers.update_project_environment(project, "test", {})
with project_path.as_cwd(env_vars={ConfigEnvVars.DATA: str(data_path), AppEnvVars.PYTHON: python_on_path}):
result = hatch("env", "create", "test")
assert result.exit_code == 0, result.output
assert result.output == helpers.dedent(
"""
Creating environment: test
Checking dependencies
"""
)
project = Project(project_path)
assert project.config.envs == {
"default": {"type": "virtual", "skip-install": True},
"test": {"type": "virtual", "skip-install": True},
}
assert project.raw_config["tool"]["hatch"]["envs"] == {
"default": {"type": "virtual", "skip-install": True},
"test": {},
}
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 == "test"
def test_selected_absolute_directory(hatch, helpers, temp_dir, config_file):
config_file.model.template.plugins["default"]["tests"] = False
config_file.model.dirs.env = {"virtual": "$VENVS_DIR"}
config_file.save()
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"
env_data_path = temp_dir / ".venvs"
project = Project(project_path)
assert project.config.envs == {"default": {"type": "virtual"}}
helpers.update_project_environment(project, "default", {"skip-install": True, **project.config.envs["default"]})
helpers.update_project_environment(project, "test", {})
with project_path.as_cwd({"VENVS_DIR": str(env_data_path)}):
result = hatch("env", "create", "test")
assert result.exit_code == 0, result.output
assert result.output == helpers.dedent(
"""
Creating environment: test
Checking dependencies
"""
)
project = Project(project_path)
assert project.config.envs == {
"default": {"type": "virtual", "skip-install": True},
"test": {"type": "virtual", "skip-install": True},
}
assert project.raw_config["tool"]["hatch"]["envs"] == {
"default": {"type": "virtual", "skip-install": True},
"test": {},
}
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 == "test"
def test_option_absolute_directory(hatch, helpers, temp_dir, config_file):
config_file.model.template.plugins["default"]["tests"] = False
config_file.model.dirs.env = {"virtual": "$VENVS_DIR"}
config_file.save()
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"
env_data_path = temp_dir / ".venvs"
env_path = temp_dir / "foo"
project = Project(project_path)
assert project.config.envs == {"default": {"type": "virtual"}}
helpers.update_project_environment(project, "default", {"skip-install": True, **project.config.envs["default"]})
helpers.update_project_environment(project, "test", {"path": str(env_path)})
with project_path.as_cwd({"VENVS_DIR": str(env_data_path)}):
result = hatch("env", "create", "test")
assert result.exit_code == 0, result.output
assert result.output == helpers.dedent(
"""
Creating environment: test
Checking dependencies
"""
)
project = Project(project_path)
assert project.config.envs == {
"default": {"type": "virtual", "skip-install": True},
"test": {"type": "virtual", "skip-install": True, "path": str(env_path)},
}
assert project.raw_config["tool"]["hatch"]["envs"] == {
"default": {"type": "virtual", "skip-install": True},
"test": {"path": str(env_path)},
}
assert not env_data_path.is_dir()
assert env_path.is_dir()
def test_env_var_absolute_directory(hatch, helpers, temp_dir, config_file):
config_file.model.template.plugins["default"]["tests"] = False
config_file.model.dirs.env = {"virtual": "$VENVS_DIR"}
config_file.save()
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"
env_data_path = temp_dir / ".venvs"
env_path = temp_dir / "foo"
env_path_overridden = temp_dir / "bar"
project = Project(project_path)
assert project.config.envs == {"default": {"type": "virtual"}}
helpers.update_project_environment(project, "default", {"skip-install": True, **project.config.envs["default"]})
helpers.update_project_environment(project, "test", {"path": str(env_path_overridden)})
with project_path.as_cwd({"VENVS_DIR": str(env_data_path), "HATCH_ENV_TYPE_VIRTUAL_PATH": str(env_path)}):
result = hatch("env", "create", "test")
assert result.exit_code == 0, result.output
assert result.output == helpers.dedent(
"""
Creating environment: test
Checking dependencies
"""
)
project = Project(project_path)
assert project.config.envs == {
"default": {"type": "virtual", "skip-install": True},
"test": {"type": "virtual", "skip-install": True, "path": str(env_path_overridden)},
}
assert project.raw_config["tool"]["hatch"]["envs"] == {
"default": {"type": "virtual", "skip-install": True},
"test": {"path": str(env_path_overridden)},
}
assert not env_data_path.is_dir()
assert env_path.is_dir()
def test_selected_local_directory(hatch, helpers, temp_dir, config_file):
config_file.model.template.plugins["default"]["tests"] = False
config_file.model.dirs.env = {"virtual": "$VENVS_DIR"}
config_file.save()
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"
project = Project(project_path)
helpers.update_project_environment(project, "default", {"skip-install": True, **project.config.envs["default"]})
helpers.update_project_environment(project, "test", {"matrix": [{"version": ["9000", "42"]}]})
with project_path.as_cwd({"VENVS_DIR": ".hatch"}):
result = hatch("env", "create")
assert result.exit_code == 0, result.output
assert result.output == helpers.dedent(
"""
Creating environment: default
Checking dependencies
"""
)
with project_path.as_cwd({"VENVS_DIR": ".hatch"}):
result = hatch("env", "create", "test")
assert result.exit_code == 0, result.output
assert result.output == helpers.dedent(
"""
Creating environment: test.9000
Checking dependencies
Creating environment: test.42
Checking dependencies
"""
)
project = Project(project_path)
assert project.config.envs == {
"default": {"type": "virtual", "skip-install": True},
"test.9000": {"type": "virtual", "skip-install": True},
"test.42": {"type": "virtual", "skip-install": True},
}
assert project.raw_config["tool"]["hatch"]["envs"] == {
"default": {"type": "virtual", "skip-install": True},
"test": {"matrix": [{"version": ["9000", "42"]}]},
}
env_data_path = project_path / ".hatch"
assert env_data_path.is_dir()
env_dirs = list(env_data_path.iterdir())
assert len(env_dirs) == 4
assert sorted(entry.name for entry in env_dirs) == [".gitignore", "my-app", "test.42", "test.9000"]
def test_option_local_directory(hatch, helpers, temp_dir, config_file):
config_file.model.template.plugins["default"]["tests"] = False
config_file.model.dirs.env = {"virtual": "$VENVS_DIR"}
config_file.save()
project_name = "My.App"
env_data_path = temp_dir / ".venvs"
with temp_dir.as_cwd():
result = hatch("new", project_name)
assert result.exit_code == 0, result.output
project_path = temp_dir / "my-app"
project = Project(project_path)
assert project.config.envs == {"default": {"type": "virtual"}}
helpers.update_project_environment(project, "default", {"skip-install": True, **project.config.envs["default"]})
helpers.update_project_environment(project, "test", {"path": ".venv"})
with project_path.as_cwd({"VENVS_DIR": str(env_data_path)}):
result = hatch("env", "create", "test")
assert result.exit_code == 0, result.output
assert result.output == helpers.dedent(
"""
Creating environment: test
Checking dependencies
"""
)
project = Project(project_path)
assert project.config.envs == {
"default": {"type": "virtual", "skip-install": True},
"test": {"type": "virtual", "skip-install": True, "path": ".venv"},
}
assert project.raw_config["tool"]["hatch"]["envs"] == {
"default": {"type": "virtual", "skip-install": True},
"test": {"path": ".venv"},
}
assert not env_data_path.is_dir()
assert (project_path / ".venv").is_dir()
def test_env_var_local_directory(hatch, helpers, temp_dir, config_file):
config_file.model.template.plugins["default"]["tests"] = False
config_file.model.dirs.env = {"virtual": "$VENVS_DIR"}
config_file.save()
project_name = "My.App"
env_data_path = temp_dir / ".venvs"
with temp_dir.as_cwd():
result = hatch("new", project_name)
assert result.exit_code == 0, result.output
project_path = temp_dir / "my-app"
project = Project(project_path)
assert project.config.envs == {"default": {"type": "virtual"}}
helpers.update_project_environment(project, "default", {"skip-install": True, **project.config.envs["default"]})
helpers.update_project_environment(project, "test", {"path": ".foo"})
with project_path.as_cwd({"VENVS_DIR": str(env_data_path), "HATCH_ENV_TYPE_VIRTUAL_PATH": ".venv"}):
result = hatch("env", "create", "test")
assert result.exit_code == 0, result.output
assert result.output == helpers.dedent(
"""
Creating environment: test
Checking dependencies
"""
)
project = Project(project_path)
assert project.config.envs == {
"default": {"type": "virtual", "skip-install": True},
"test": {"type": "virtual", "skip-install": True, "path": ".foo"},
}
assert project.raw_config["tool"]["hatch"]["envs"] == {
"default": {"type": "virtual", "skip-install": True},
"test": {"path": ".foo"},
}
assert not env_data_path.is_dir()
assert (project_path / ".venv").is_dir()
def test_enter_project_directory(hatch, config_file, helpers, temp_dir):
config_file.model.template.plugins["default"]["tests"] = False
config_file.save()
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()
project = "foo"
config_file.model.mode = "project"
config_file.model.project = project
config_file.model.projects = {project: str(project_path)}
config_file.save()
project = Project(project_path)
helpers.update_project_environment(project, "default", {"skip-install": True, **project.config.envs["default"]})
helpers.update_project_environment(project, "test", {})
with EnvVars({ConfigEnvVars.DATA: str(data_path)}):
result = hatch("env", "create", "test")
assert result.exit_code == 0, result.output
assert result.output == helpers.dedent(
"""
Creating environment: test
Checking 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 == "test"
def test_already_created(hatch, config_file, helpers, temp_dir):
config_file.model.template.plugins["default"]["tests"] = False
config_file.save()
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()
project = Project(project_path)
helpers.update_project_environment(project, "default", {"skip-install": True, **project.config.envs["default"]})
helpers.update_project_environment(project, "test", {})
with project_path.as_cwd(env_vars={ConfigEnvVars.DATA: str(data_path)}):
result = hatch("env", "create", "test")
assert result.exit_code == 0, result.output
assert result.output == helpers.dedent(
"""
Creating environment: test
Checking 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 == "test"
with project_path.as_cwd(env_vars={ConfigEnvVars.DATA: str(data_path)}):
result = hatch("env", "create", "test")
assert result.exit_code == 0, result.output
assert result.output == helpers.dedent(
"""
Environment `test` already exists
"""
)
def test_default(hatch, config_file, helpers, temp_dir):
config_file.model.template.plugins["default"]["tests"] = False
config_file.save()
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()
project = Project(project_path)
helpers.update_project_environment(project, "default", {"skip-install": True, **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
Checking 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
def test_matrix(hatch, helpers, temp_dir, config_file):
config_file.model.template.plugins["default"]["tests"] = False
config_file.save()
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()
project = Project(project_path)
helpers.update_project_environment(project, "default", {"skip-install": True, **project.config.envs["default"]})
helpers.update_project_environment(project, "test", {"matrix": [{"version": ["9000", "42"]}]})
with project_path.as_cwd(env_vars={ConfigEnvVars.DATA: str(data_path)}):
result = hatch("env", "create", "test")
assert result.exit_code == 0, result.output
assert result.output == helpers.dedent(
"""
Creating environment: test.9000
Checking dependencies
Creating environment: test.42
Checking dependencies
"""
)
project = Project(project_path)
assert project.config.envs == {
"default": {"type": "virtual", "skip-install": True},
"test.9000": {"type": "virtual", "skip-install": True},
"test.42": {"type": "virtual", "skip-install": True},
}
assert project.raw_config["tool"]["hatch"]["envs"] == {
"default": {"type": "virtual", "skip-install": True},
"test": {"matrix": [{"version": ["9000", "42"]}]},
}
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 = sorted(storage_path.iterdir(), key=lambda d: d.name)
assert len(env_dirs) == 2
assert env_dirs[0].name == "test.42"
assert env_dirs[1].name == "test.9000"
def test_incompatible_single(hatch, helpers, temp_dir, config_file):
config_file.model.template.plugins["default"]["tests"] = False
config_file.save()
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()
project = Project(project_path)
helpers.update_project_environment(
project, "default", {"skip-install": True, "platforms": ["foo"], **project.config.envs["default"]}
)
helpers.update_project_environment(project, "test", {})
with project_path.as_cwd(env_vars={ConfigEnvVars.DATA: str(data_path)}):
result = hatch("env", "create", "test")
assert result.exit_code == 1
assert result.output == helpers.dedent(
"""
Environment `test` is incompatible: unsupported platform
"""
)
project = Project(project_path)
assert project.config.envs == {
"default": {"type": "virtual", "skip-install": True, "platforms": ["foo"]},
"test": {"type": "virtual", "skip-install": True, "platforms": ["foo"]},
}
assert project.raw_config["tool"]["hatch"]["envs"] == {
"default": {"type": "virtual", "skip-install": True, "platforms": ["foo"]},
"test": {},
}
env_data_path = data_path / "env" / "virtual"
assert not env_data_path.is_dir()
def test_incompatible_matrix_full(hatch, helpers, temp_dir, config_file):
config_file.model.template.plugins["default"]["tests"] = False
config_file.save()
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()
project = Project(project_path)
helpers.update_project_environment(
project, "default", {"skip-install": True, "platforms": ["foo"], **project.config.envs["default"]}
)
helpers.update_project_environment(project, "test", {"matrix": [{"version": ["9000", "42"]}]})
with project_path.as_cwd(env_vars={ConfigEnvVars.DATA: str(data_path)}):
result = hatch("env", "create", "test")
assert result.exit_code == 0, result.output
assert result.output == helpers.dedent(
"""
Skipped 2 incompatible environments:
test.9000 -> unsupported platform
test.42 -> unsupported platform
"""
)
project = Project(project_path)
assert project.config.envs == {
"default": {"type": "virtual", "skip-install": True, "platforms": ["foo"]},
"test.9000": {"type": "virtual", "skip-install": True, "platforms": ["foo"]},
"test.42": {"type": "virtual", "skip-install": True, "platforms": ["foo"]},
}
assert project.raw_config["tool"]["hatch"]["envs"] == {
"default": {"type": "virtual", "skip-install": True, "platforms": ["foo"]},
"test": {"matrix": [{"version": ["9000", "42"]}]},
}
env_data_path = data_path / "env" / "virtual"
assert not env_data_path.is_dir()
def test_incompatible_matrix_partial(hatch, helpers, temp_dir, config_file):
config_file.model.template.plugins["default"]["tests"] = False
config_file.save()
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()
project = Project(project_path)
helpers.update_project_environment(project, "default", {"skip-install": True, **project.config.envs["default"]})
helpers.update_project_environment(
project,
"test",
{
"matrix": [{"version": ["9000", "42"]}],
"overrides": {"matrix": {"version": {"platforms": [{"value": "foo", "if": ["9000"]}]}}},
},
)
with project_path.as_cwd(env_vars={ConfigEnvVars.DATA: str(data_path)}):
result = hatch("env", "create", "test")
assert result.exit_code == 0, result.output
assert result.output == helpers.dedent(
"""
Creating environment: test.42
Checking dependencies
Skipped 1 incompatible environment:
test.9000 -> unsupported platform
"""
)
project = Project(project_path)
assert project.config.envs == {
"default": {"type": "virtual", "skip-install": True},
"test.9000": {"type": "virtual", "skip-install": True, "platforms": ["foo"]},
"test.42": {"type": "virtual", "skip-install": True},
}
assert project.raw_config["tool"]["hatch"]["envs"] == {
"default": {"type": "virtual", "skip-install": True},
"test": {
"matrix": [{"version": ["9000", "42"]}],
"overrides": {"matrix": {"version": {"platforms": [{"value": "foo", "if": ["9000"]}]}}},
},
}
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
assert env_dirs[0].name == "test.42"
@pytest.mark.requires_internet
def test_install_project_default_dev_mode(
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()
project = Project(project_path)
helpers.update_project_environment(project, "test", {})
with project_path.as_cwd(env_vars={ConfigEnvVars.DATA: str(data_path)}):
result = hatch("env", "create", "test")
assert result.exit_code == 0, result.output
assert result.output == helpers.dedent(
"""
Creating environment: test
Installing project in development mode
Checking 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 == "test"
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) == 1
assert requirements[0].lower() == f"-e {project_path.as_uri().lower()}"
@pytest.mark.requires_internet
def test_install_project_no_dev_mode(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()
project = Project(project_path)
helpers.update_project_environment(
project, "default", {"dev-mode": False, "extra-dependencies": ["binary"], **project.config.envs["default"]}
)
helpers.update_project_environment(project, "test", {})
with project_path.as_cwd(env_vars={ConfigEnvVars.DATA: str(data_path)}):
result = hatch("env", "create", "test")
assert result.exit_code == 0, result.output
assert result.output == helpers.dedent(
"""
Creating environment: test
Installing project
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 == "test"
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) == 2
assert f"my-app @ {project_path.as_uri().lower()}" in [req.lower() for req in requirements]
@pytest.mark.requires_internet
def test_pre_install_commands(hatch, helpers, temp_dir, config_file):
config_file.model.template.plugins["default"]["tests"] = False
config_file.save()
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()
project = Project(project_path)
helpers.update_project_environment(
project,
"default",
{
"pre-install-commands": ["python -c \"with open('test.txt', 'w') as f: f.write('content')\""],
**project.config.envs["default"],
},
)
helpers.update_project_environment(project, "test", {})
with project_path.as_cwd(env_vars={ConfigEnvVars.DATA: str(data_path)}):
result = hatch("env", "create", "test")
assert result.exit_code == 0, result.output
assert result.output == helpers.dedent(
"""
Creating environment: test
Running pre-installation commands
Installing project in development mode
Checking dependencies
"""
)
assert (project_path / "test.txt").is_file()
def test_pre_install_commands_error(hatch, helpers, temp_dir, config_file):
config_file.model.template.plugins["default"]["tests"] = False
config_file.save()
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()
project = Project(project_path)
helpers.update_project_environment(
project,
"default",
{"pre-install-commands": ['python -c "import sys;sys.exit(7)"'], **project.config.envs["default"]},
)
helpers.update_project_environment(project, "test", {})
with project_path.as_cwd(env_vars={ConfigEnvVars.DATA: str(data_path)}):
result = hatch("env", "create", "test")
assert result.exit_code == 7
assert result.output == helpers.dedent(
"""
Creating environment: test
Running pre-installation commands
Failed with exit code: 7
"""
)
@pytest.mark.requires_internet
def test_post_install_commands(hatch, helpers, temp_dir, config_file):
config_file.model.template.plugins["default"]["tests"] = False
config_file.save()
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()
project = Project(project_path)
helpers.update_project_environment(
project,
"default",
{
"post-install-commands": ["python -c \"with open('test.txt', 'w') as f: f.write('content')\""],
**project.config.envs["default"],
},
)
helpers.update_project_environment(project, "test", {})
with project_path.as_cwd(env_vars={ConfigEnvVars.DATA: str(data_path)}):
result = hatch("env", "create", "test")
assert result.exit_code == 0, result.output
assert result.output == helpers.dedent(
"""
Creating environment: test
Installing project in development mode
Running post-installation commands
Checking dependencies
"""
)
assert (project_path / "test.txt").is_file()
@pytest.mark.requires_internet
def test_post_install_commands_error(hatch, helpers, temp_dir, config_file):
config_file.model.template.plugins["default"]["tests"] = False
config_file.save()
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()
project = Project(project_path)
helpers.update_project_environment(
project,
"default",
{"post-install-commands": ['python -c "import sys;sys.exit(7)"'], **project.config.envs["default"]},
)
helpers.update_project_environment(project, "test", {})
with project_path.as_cwd(env_vars={ConfigEnvVars.DATA: str(data_path)}):
result = hatch("env", "create", "test")
assert result.exit_code == 7
assert result.output == helpers.dedent(
"""
Creating environment: test
Installing project in development mode
Running post-installation commands
Failed with exit code: 7
"""
)
@pytest.mark.requires_internet
def test_sync_dependencies_uv(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()
project = Project(project_path)
helpers.update_project_environment(
project,
"default",
{
"dependencies": ["binary"],
"post-install-commands": ["python -c \"with open('test.txt', 'w') as f: f.write('content')\""],
**project.config.envs["default"],
},
)
helpers.update_project_environment(project, "test", {})
with project_path.as_cwd(env_vars={ConfigEnvVars.DATA: str(data_path)}):
result = hatch("env", "create", "test")
assert result.exit_code == 0, result.output
assert result.output == helpers.dedent(
"""
Creating environment: test
Installing project in development mode
Running post-installation commands
Checking dependencies
Syncing dependencies
"""
)
assert (project_path / "test.txt").is_file()
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 == "test"
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) == 2
assert requirements[0].startswith("binary==")
assert requirements[1].lower() == f"-e {project_path.as_uri().lower()}"
@pytest.mark.requires_internet
def test_sync_dependencies_pip(hatch, helpers, temp_dir, platform, 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()
project = Project(project_path)
helpers.update_project_environment(
project,
"default",
{
"dependencies": ["binary"],
"post-install-commands": ["python -c \"with open('test.txt', 'w') as f: f.write('content')\""],
**project.config.envs["default"],
},
)
helpers.update_project_environment(project, "test", {})
with (
project_path.as_cwd(),
EnvVars({ConfigEnvVars.DATA: str(data_path)}, exclude=[get_env_var(plugin_name="virtual", option="uv_path")]),
):
result = hatch("env", "create", "test")
assert result.exit_code == 0, result.output
assert result.output == helpers.dedent(
"""
Creating environment: test
Installing project in development mode
Running post-installation commands
Checking dependencies
Syncing dependencies
"""
)
assert (project_path / "test.txt").is_file()
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 == "test"
with VirtualEnv(env_path, platform):
output = platform.run_command(["pip", "freeze"], check=True, capture_output=True).stdout.decode("utf-8")
requirements = extract_installed_requirements(output.splitlines())
assert len(requirements) == 2
assert requirements[0].startswith("binary==")
assert requirements[1].lower() == f"-e {str(project_path).lower()}"
@pytest.mark.requires_internet
def test_features(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()
project = Project(project_path)
config = dict(project.raw_config)
config["project"]["optional-dependencies"] = {"foo": ["binary"]}
project.save_config(config)
helpers.update_project_environment(project, "default", {"features": ["foo"], **project.config.envs["default"]})
helpers.update_project_environment(project, "test", {})
with project_path.as_cwd(env_vars={ConfigEnvVars.DATA: str(data_path)}):
result = hatch("env", "create", "test")
assert result.exit_code == 0, result.output
assert result.output == helpers.dedent(
"""
Creating environment: test
Installing project in development mode
Checking 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 == "test"
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) == 2
assert requirements[0].startswith("binary==")
assert requirements[1].lower() == f"-e {project_path.as_uri().lower()}"
@pytest.mark.requires_internet
def test_sync_dynamic_dependencies(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
for i in range(2):
with temp_dir.as_cwd():
result = hatch("new", f"{project_name}{i}")
assert result.exit_code == 0, result.output
project_path = temp_dir / "my-app"
data_path = temp_dir / "data"
data_path.mkdir()
project = Project(project_path)
config = dict(project.raw_config)
config["project"].pop("dependencies")
config["project"]["dynamic"].extend(("dependencies", "optional-dependencies"))
config["tool"]["hatch"]["metadata"] = {"allow-direct-references": True, "hooks": {"custom": {}}}
project.save_config(config)
helpers.update_project_environment(
project,
"default",
{
"dependencies": ["my-app1 @ {root:uri}/../my-app1"],
"features": ["foo"],
"post-install-commands": ["python -c \"with open('test.txt', 'w') as f: f.write('content')\""],
**project.config.envs["default"],
},
)
helpers.update_project_environment(project, "test", {})
build_script = project_path / DEFAULT_BUILD_SCRIPT
build_script.write_text(
helpers.dedent(
"""
from hatchling.metadata.plugin.interface import MetadataHookInterface
class CustomHook(MetadataHookInterface):
def update(self, metadata):
metadata['dependencies'] = ['my-app0 @ {root:uri}/../my-app0']
metadata['optional-dependencies'] = {'foo': ['binary']}
"""
)
)
with project_path.as_cwd(env_vars={ConfigEnvVars.DATA: str(data_path)}):
result = hatch("env", "create", "test")
assert result.exit_code == 0, result.output
assert result.output == helpers.dedent(
"""
Creating environment: test
Installing project in development mode
Running post-installation commands
Polling dependency state
Creating environment: hatch-build
Checking dependencies
Syncing dependencies
Inspecting build dependencies
Checking dependencies
Syncing dependencies
"""
)
assert (project_path / "test.txt").is_file()
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 = sorted(storage_path.iterdir())
assert [d.name for d in env_dirs] == ["hatch-build", "test"]
env_path = env_dirs[1]
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].startswith("binary==")
assert requirements[1].lower() == f"-e {project_path.as_uri().lower()}"
assert requirements[2].lower() == f"my-app0 @ {project_path.parent.as_uri().lower()}/my-app0"
assert requirements[3].lower() == f"my-app1 @ {project_path.parent.as_uri().lower()}/my-app1"
@pytest.mark.requires_internet
def test_unknown_dynamic_feature(hatch, helpers, temp_dir, config_file):
config_file.model.template.plugins["default"]["tests"] = False
config_file.save()
project_name = "My.App"
with temp_dir.as_cwd():
result = hatch("new", project_name)
assert result.exit_code == 0, result.output
with temp_dir.as_cwd():
result = hatch("new", f"{project_name}1")
assert result.exit_code == 0, result.output
project_path = temp_dir / "my-app"
data_path = temp_dir / "data"
data_path.mkdir()
project = Project(project_path)
config = dict(project.raw_config)
config["build-system"]["requires"].append(f"my-app1 @ {path_to_uri(project_path).lower()}/../my-app1")
config["project"]["dynamic"].append("optional-dependencies")
config["tool"]["hatch"]["metadata"] = {"hooks": {"custom": {}}}
project.save_config(config)
helpers.update_project_environment(
project,
"default",
{
"features": ["foo"],
"post-install-commands": ["python -c \"with open('test.txt', 'w') as f: f.write('content')\""],
**project.config.envs["default"],
},
)
helpers.update_project_environment(project, "test", {})
build_script = project_path / DEFAULT_BUILD_SCRIPT
build_script.write_text(
helpers.dedent(
"""
from hatchling.metadata.plugin.interface import MetadataHookInterface
class CustomHook(MetadataHookInterface):
def update(self, metadata):
metadata['optional-dependencies'] = {'bar': ['binary']}
"""
)
)
with (
project_path.as_cwd(env_vars={ConfigEnvVars.DATA: str(data_path)}),
pytest.raises(
ValueError,
match=(
"Feature `foo` of field `tool.hatch.envs.test.features` is not defined in the dynamic "
"field `project.optional-dependencies`"
),
),
):
hatch("env", "create", "test")
def test_no_project_file(hatch, helpers, temp_dir, config_file):
config_file.model.template.plugins["default"]["tests"] = False
config_file.save()
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()
(project_path / "pyproject.toml").remove()
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
Checking 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
def test_plugin_dependencies_unmet(hatch, config_file, helpers, temp_dir, mock_plugin_installation):
config_file.model.template.plugins["default"]["tests"] = False
config_file.save()
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()
dependency = os.urandom(16).hex()
(project_path / DEFAULT_CONFIG_FILE).write_text(
helpers.dedent(
f"""
[env]
requires = ["{dependency}"]
"""
)
)
project = Project(project_path)
helpers.update_project_environment(project, "default", {"skip-install": True, **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(
"""
Syncing environment plugin requirements
Creating environment: default
Checking dependencies
"""
)
helpers.assert_plugin_installation(mock_plugin_installation, [dependency])
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
@pytest.mark.usefixtures("mock_plugin_installation")
def test_plugin_dependencies_met(hatch, config_file, helpers, temp_dir):
config_file.model.template.plugins["default"]["tests"] = False
config_file.save()
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()
dependency = "hatch"
(project_path / DEFAULT_CONFIG_FILE).write_text(
helpers.dedent(
f"""
[env]
requires = ["{dependency}"]
"""
)
)
project = Project(project_path)
helpers.update_project_environment(project, "default", {"skip-install": True, **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
Checking 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
@pytest.mark.usefixtures("mock_plugin_installation")
def test_plugin_dependencies_met_as_app(hatch, config_file, helpers, temp_dir):
config_file.model.template.plugins["default"]["tests"] = False
config_file.save()
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()
dependency = "hatch"
(project_path / DEFAULT_CONFIG_FILE).write_text(
helpers.dedent(
f"""
[env]
requires = ["{dependency}"]
"""
)
)
project = Project(project_path)
helpers.update_project_environment(project, "default", {"skip-install": True, **project.config.envs["default"]})
with project_path.as_cwd(
env_vars={ConfigEnvVars.DATA: str(data_path), "PYAPP": sys.executable, "PYAPP_COMMAND_NAME": "self"}
):
result = hatch("env", "create")
assert result.exit_code == 0, result.output
assert result.output == helpers.dedent(
"""
Creating environment: default
Checking 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
@pytest.mark.requires_internet
def test_no_compatible_python(hatch, helpers, temp_dir, config_file):
config_file.model.template.plugins["default"]["tests"] = False
config_file.save()
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()
project = Project(project_path)
config = dict(project.raw_config)
config["project"]["requires-python"] = "==9000"
project.save_config(config)
with project_path.as_cwd(env_vars={ConfigEnvVars.DATA: str(data_path)}):
result = hatch("env", "create")
assert result.exit_code == 1, result.output
assert result.output == helpers.dedent(
"""
Environment `default` is incompatible: no compatible Python distribution available
"""
)
def test_no_compatible_python_ok_if_not_installed(hatch, helpers, temp_dir, config_file):
config_file.model.template.plugins["default"]["tests"] = False
config_file.save()
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()
project = Project(project_path)
config = dict(project.raw_config)
config["project"]["requires-python"] = "==9000"
project.save_config(config)
helpers.update_project_environment(project, "default", {"skip-install": True, **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
Checking 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
@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()}"
@pytest.mark.requires_internet
def test_workspace_members_always_editable_with_dev_mode_false(
hatch, helpers, temp_dir, platform, uv_on_path, extract_installed_requirements
):
"""Verify workspace members are always installed as editable even when dev-mode=false."""
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()
# Create workspace members
members = ["member-a", "member-b"]
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",
{
"dev-mode": False,
"workspace": {"members": members},
**project.config.envs["default"],
},
)
with project_path.as_cwd(env_vars={ConfigEnvVars.DATA: str(data_path)}):
result = hatch("env", "create", "default")
assert result.exit_code == 0, result.output
env_data_path = data_path / "env" / "virtual"
project_data_path = env_data_path / project_path.name
storage_dirs = list(project_data_path.iterdir())
storage_path = storage_dirs[0]
env_dirs = list(storage_path.iterdir())
env_path = env_dirs[0]
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())
# Find project and member requirements - be more precise
my_app_reqs = [
r
for r in requirements
if (r.lower().startswith("-e file:") and r.lower().endswith("/my-app"))
or (r.lower().startswith("my-app @") and "/member-" not in r.lower())
]
member_a_reqs = [r for r in requirements if "member-a" in r.lower() and "/member-a" in r.lower()]
member_b_reqs = [r for r in requirements if "member-b" in r.lower() and "/member-b" in r.lower()]
assert len(my_app_reqs) == 1, f"Expected 1 my-app requirement, got {len(my_app_reqs)}: {my_app_reqs}"
assert len(member_a_reqs) == 1, f"Expected 1 member-a requirement, got {len(member_a_reqs)}: {member_a_reqs}"
assert len(member_b_reqs) == 1, f"Expected 1 member-b requirement, got {len(member_b_reqs)}: {member_b_reqs}"
my_app_req = my_app_reqs[0]
member_a_req = member_a_reqs[0]
member_b_req = member_b_reqs[0]
# Project should NOT be editable (dev-mode=false)
assert not my_app_req.lower().startswith("-e"), f"Project should not be editable: {my_app_req}"
assert my_app_req.lower().startswith("my-app @"), f"Project should be non-editable install: {my_app_req}"
# Workspace members MUST be editable (always)
assert member_a_req.lower().startswith("-e"), f"Member A should be editable: {member_a_req}"
assert member_b_req.lower().startswith("-e"), f"Member B should be editable: {member_b_req}"
================================================
FILE: tests/cli/env/test_find.py
================================================
import os
import sys
import pytest
from hatch.project.core import Project
from hatchling.utils.constants import DEFAULT_CONFIG_FILE
def test_undefined(hatch, helpers, temp_dir, config_file):
config_file.model.template.plugins["default"]["tests"] = False
config_file.save()
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"
with project_path.as_cwd():
result = hatch("env", "find", "test")
assert result.exit_code == 1
assert result.output == helpers.dedent(
"""
Environment `test` is not defined by project config
"""
)
def test_single(hatch, helpers, temp_dir_data, config_file):
config_file.model.template.plugins["default"]["tests"] = False
config_file.save()
project_name = "My.App"
with temp_dir_data.as_cwd():
result = hatch("new", project_name)
assert result.exit_code == 0, result.output
project_path = temp_dir_data / "my-app"
project = Project(project_path)
helpers.update_project_environment(project, "default", {"skip-install": True, **project.config.envs["default"]})
with project_path.as_cwd():
result = hatch("env", "create")
assert result.exit_code == 0, result.output
assert result.output == helpers.dedent(
"""
Creating environment: default
Checking dependencies
"""
)
env_data_path = temp_dir_data / "data" / "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]
env_path = storage_path / "my-app"
with project_path.as_cwd():
result = hatch("env", "find")
assert result.exit_code == 0, result.output
assert result.output == helpers.dedent(
f"""
{env_path}
"""
)
def test_matrix(hatch, helpers, temp_dir_data, config_file):
config_file.model.template.plugins["default"]["tests"] = False
config_file.save()
project_name = "My.App"
with temp_dir_data.as_cwd():
result = hatch("new", project_name)
assert result.exit_code == 0, result.output
project_path = temp_dir_data / "my-app"
project = Project(project_path)
helpers.update_project_environment(project, "default", {"skip-install": True, **project.config.envs["default"]})
helpers.update_project_environment(
project,
"test",
{
"matrix": [{"version": ["9000", "42"]}],
"overrides": {"matrix": {"version": {"platforms": [{"value": "foo", "if": ["9000"]}]}}},
},
)
with project_path.as_cwd():
result = hatch("env", "create", "test")
assert result.exit_code == 0, result.output
assert result.output == helpers.dedent(
"""
Creating environment: test.42
Checking dependencies
Skipped 1 incompatible environment:
test.9000 -> unsupported platform
"""
)
env_data_path = temp_dir_data / "data" / "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]
with project_path.as_cwd():
result = hatch("env", "find", "test")
assert result.exit_code == 0, result.output
assert result.output == helpers.dedent(
f"""
{storage_path / "test.9000"}
{storage_path / "test.42"}
"""
)
def test_plugin_dependencies_unmet(hatch, helpers, temp_dir_data, config_file, mock_plugin_installation):
config_file.model.template.plugins["default"]["tests"] = False
config_file.save()
project_name = "My.App"
with temp_dir_data.as_cwd():
result = hatch("new", project_name)
assert result.exit_code == 0, result.output
project_path = temp_dir_data / "my-app"
project = Project(project_path)
helpers.update_project_environment(project, "default", {"skip-install": True, **project.config.envs["default"]})
with project_path.as_cwd():
result = hatch("env", "create")
assert result.exit_code == 0, result.output
assert result.output == helpers.dedent(
"""
Creating environment: default
Checking dependencies
"""
)
env_data_path = temp_dir_data / "data" / "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]
env_path = storage_path / "my-app"
dependency = os.urandom(16).hex()
(project_path / DEFAULT_CONFIG_FILE).write_text(
helpers.dedent(
f"""
[env]
requires = ["{dependency}"]
"""
)
)
with project_path.as_cwd():
result = hatch("env", "find")
assert result.exit_code == 0, result.output
assert result.output == helpers.dedent(
f"""
Syncing environment plugin requirements
{env_path}
"""
)
helpers.assert_plugin_installation(mock_plugin_installation, [dependency])
@pytest.mark.skipif(sys.platform not in {"win32", "darwin"}, reason="Case insensitive file system required")
def test_case_sensitivity(hatch, temp_dir_data):
from hatch.utils.fs import Path
project_name = "My.App"
with temp_dir_data.as_cwd():
result = hatch("new", project_name)
assert result.exit_code == 0, result.output
project_path = temp_dir_data / "my-app"
with project_path.as_cwd():
result = hatch("env", "find")
assert result.exit_code == 0, result.output
path_default = result.output.strip()
with Path(str(project_path).upper()).as_cwd():
result = hatch("env", "find")
assert result.exit_code == 0, result.output
path_upper = result.output.strip()
with Path(str(project_path).lower()).as_cwd():
result = hatch("env", "find")
assert result.exit_code == 0, result.output
path_lower = result.output.strip()
assert path_default == path_upper == path_lower
================================================
FILE: tests/cli/env/test_prune.py
================================================
import os
from hatch.config.constants import AppEnvVars
from hatch.project.core import Project
from hatchling.utils.constants import DEFAULT_CONFIG_FILE
def test_unknown_type(hatch, helpers, temp_dir_data, config_file):
config_file.model.template.plugins["default"]["tests"] = False
config_file.save()
project_name = "My.App"
with temp_dir_data.as_cwd():
result = hatch("new", project_name)
assert result.exit_code == 0, result.output
project_path = temp_dir_data / "my-app"
project = Project(project_path)
config = dict(project.config.envs["default"])
config["type"] = "foo"
helpers.update_project_environment(project, "default", config)
with project_path.as_cwd():
result = hatch("env", "prune")
assert result.exit_code == 1
assert result.output == helpers.dedent(
"""
Environment `default` has unknown type: foo
"""
)
def test_all(hatch, helpers, temp_dir_data, config_file):
config_file.model.template.plugins["default"]["tests"] = False
config_file.save()
project_name = "My.App"
with temp_dir_data.as_cwd():
result = hatch("new", project_name)
assert result.exit_code == 0, result.output
project_path = temp_dir_data / "my-app"
project = Project(project_path)
helpers.update_project_environment(project, "default", {"skip-install": True, **project.config.envs["default"]})
helpers.update_project_environment(project, "foo", {})
helpers.update_project_environment(project, "bar", {})
with project_path.as_cwd():
result = hatch("env", "create", "foo")
assert result.exit_code == 0, result.output
with project_path.as_cwd():
result = hatch("env", "create", "bar")
assert result.exit_code == 0, result.output
env_data_path = temp_dir_data / "data" / "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) == 2
with project_path.as_cwd():
result = hatch("env", "prune")
assert result.exit_code == 0, result.output
assert result.output == helpers.dedent(
"""
Removing environment: foo
Removing environment: bar
"""
)
assert not storage_path.is_dir()
def test_incompatible_ok(hatch, helpers, temp_dir_data, config_file):
config_file.model.template.plugins["default"]["tests"] = False
config_file.save()
project_name = "My.App"
with temp_dir_data.as_cwd():
result = hatch("new", project_name)
assert result.exit_code == 0, result.output
project_path = temp_dir_data / "my-app"
project = Project(project_path)
helpers.update_project_environment(
project, "default", {"skip-install": True, "platforms": ["foo"], **project.config.envs["default"]}
)
with project_path.as_cwd():
result = hatch("env", "prune")
assert result.exit_code == 0, result.output
assert not result.output
def test_active(hatch, temp_dir_data, helpers, config_file):
config_file.model.template.plugins["default"]["tests"] = False
config_file.save()
project_name = "My.App"
with temp_dir_data.as_cwd():
result = hatch("new", project_name)
assert result.exit_code == 0, result.output
project_path = temp_dir_data / "my-app"
with project_path.as_cwd(env_vars={AppEnvVars.ENV_ACTIVE: "default"}):
result = hatch("env", "prune")
assert result.exit_code == 1
assert result.output == helpers.dedent(
"""
Cannot remove active environment: default
"""
)
def test_plugin_dependencies_unmet(hatch, helpers, temp_dir_data, config_file, mock_plugin_installation):
config_file.model.template.plugins["default"]["tests"] = False
config_file.save()
project_name = "My.App"
with temp_dir_data.as_cwd():
result = hatch("new", project_name)
assert result.exit_code == 0, result.output
project_path = temp_dir_data / "my-app"
dependency = os.urandom(16).hex()
(project_path / DEFAULT_CONFIG_FILE).write_text(
helpers.dedent(
f"""
[env]
requires = ["{dependency}"]
"""
)
)
project = Project(project_path)
helpers.update_project_environment(
project, "default", {"skip-install": True, "platforms": ["foo"], **project.config.envs["default"]}
)
with project_path.as_cwd():
result = hatch("env", "prune")
assert result.exit_code == 0, result.output
assert result.output == helpers.dedent(
"""
Syncing environment plugin requirements
"""
)
helpers.assert_plugin_installation(mock_plugin_installation, [dependency])
================================================
FILE: tests/cli/env/test_remove.py
================================================
import os
from hatch.config.constants import AppEnvVars
from hatch.project.core import Project
from hatchling.utils.constants import DEFAULT_CONFIG_FILE
def test_unknown(hatch, temp_dir_data, helpers):
project_name = "My.App"
with temp_dir_data.as_cwd():
result = hatch("new", project_name)
assert result.exit_code == 0, result.output
project_path = temp_dir_data / "my-app"
with project_path.as_cwd():
result = hatch("env", "remove", "foo")
assert result.exit_code == 1, result.output
assert result.output == helpers.dedent(
"""
Environment `foo` is not defined by project config
"""
)
def test_nonexistent(hatch, temp_dir_data):
project_name = "My.App"
with temp_dir_data.as_cwd():
result = hatch("new", project_name)
assert result.exit_code == 0, result.output
project_path = temp_dir_data / "my-app"
with project_path.as_cwd():
result = hatch("env", "remove", "default")
assert result.exit_code == 0, result.output
assert not result.output
def test_single(hatch, helpers, temp_dir_data, config_file):
config_file.model.template.plugins["default"]["tests"] = False
config_file.save()
project_name = "My.App"
with temp_dir_data.as_cwd():
result = hatch("new", project_name)
assert result.exit_code == 0, result.output
project_path = temp_dir_data / "my-app"
project = Project(project_path)
helpers.update_project_environment(project, "default", {"skip-install": True, **project.config.envs["default"]})
helpers.update_project_environment(project, "foo", {})
helpers.update_project_environment(project, "bar", {})
with project_path.as_cwd():
result = hatch("env", "create", "foo")
assert result.exit_code == 0, result.output
with project_path.as_cwd():
result = hatch("env", "create", "bar")
assert result.exit_code == 0, result.output
env_data_path = temp_dir_data / "data" / "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) == 2
foo_env_path = storage_path / "foo"
bar_env_path = storage_path / "bar"
assert foo_env_path.is_dir()
assert bar_env_path.is_dir()
with project_path.as_cwd():
result = hatch("env", "remove", "bar")
assert result.exit_code == 0, result.output
assert result.output == helpers.dedent(
"""
Removing environment: bar
"""
)
assert foo_env_path.is_dir()
assert not bar_env_path.is_dir()
def test_all(hatch, helpers, temp_dir_data, config_file):
config_file.model.template.plugins["default"]["tests"] = False
config_file.save()
project_name = "My.App"
with temp_dir_data.as_cwd():
result = hatch("new", project_name)
assert result.exit_code == 0, result.output
project_path = temp_dir_data / "my-app"
project = Project(project_path)
helpers.update_project_environment(project, "default", {"skip-install": True, **project.config.envs["default"]})
helpers.update_project_environment(project, "foo", {})
helpers.update_project_environment(project, "bar", {})
with project_path.as_cwd():
result = hatch("env", "create", "foo")
assert result.exit_code == 0, result.output
with project_path.as_cwd():
result = hatch("env", "create", "bar")
assert result.exit_code == 0, result.output
env_data_path = temp_dir_data / "data" / "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) == 2
foo_env_path = storage_path / "foo"
bar_env_path = storage_path / "bar"
assert foo_env_path.is_dir()
assert bar_env_path.is_dir()
with project_path.as_cwd():
result = hatch("env", "remove", "foo")
assert result.exit_code == 0, result.output
assert result.output == helpers.dedent(
"""
Removing environment: foo
"""
)
with project_path.as_cwd():
result = hatch("env", "remove", "bar")
assert result.exit_code == 0, result.output
assert result.output == helpers.dedent(
"""
Removing environment: bar
"""
)
assert not storage_path.is_dir()
def test_matrix_all(hatch, helpers, temp_dir_data, config_file):
config_file.model.template.plugins["default"]["tests"] = False
config_file.save()
project_name = "My.App"
with temp_dir_data.as_cwd():
result = hatch("new", project_name)
assert result.exit_code == 0, result.output
project_path = temp_dir_data / "my-app"
project = Project(project_path)
helpers.update_project_environment(project, "default", {"skip-install": True, **project.config.envs["default"]})
helpers.update_project_environment(project, "foo", {"matrix": [{"version": ["9000", "42"]}]})
with project_path.as_cwd():
result = hatch("env", "create", "foo")
assert result.exit_code == 0, result.output
env_data_path = temp_dir_data / "data" / "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) == 2
foo_env_path = storage_path / "foo.42"
bar_env_path = storage_path / "foo.9000"
assert foo_env_path.is_dir()
assert bar_env_path.is_dir()
with project_path.as_cwd():
result = hatch("env", "remove", "foo")
assert result.exit_code == 0, result.output
assert result.output == helpers.dedent(
"""
Removing environment: foo.9000
Removing environment: foo.42
"""
)
assert not storage_path.is_dir()
def test_matrix_all_local_directory(hatch, helpers, temp_dir_data, config_file):
config_file.model.template.plugins["default"]["tests"] = False
config_file.model.dirs.env = {"virtual": ".hatch"}
config_file.save()
project_name = "My.App"
with temp_dir_data.as_cwd():
result = hatch("new", project_name)
assert result.exit_code == 0, result.output
project_path = temp_dir_data / "my-app"
project = Project(project_path)
helpers.update_project_environment(project, "default", {"skip-install": True, **project.config.envs["default"]})
helpers.update_project_environment(project, "foo", {"matrix": [{"version": ["9000", "42"]}]})
with project_path.as_cwd():
result = hatch("env", "create", "foo")
assert result.exit_code == 0, result.output
env_data_path = project_path / ".hatch"
assert env_data_path.is_dir()
env_dirs = list(env_data_path.iterdir())
assert len(env_dirs) == 3
assert sorted(entry.name for entry in env_dirs) == [".gitignore", "foo.42", "foo.9000"]
with project_path.as_cwd():
result = hatch("env", "remove", "foo")
assert result.exit_code == 0, result.output
assert result.output == helpers.dedent(
"""
Removing environment: foo.9000
Removing environment: foo.42
"""
)
assert not env_data_path.is_dir()
def test_incompatible_ok(hatch, helpers, temp_dir_data):
project_name = "My.App"
with temp_dir_data.as_cwd():
result = hatch("new", project_name)
assert result.exit_code == 0, result.output
project_path = temp_dir_data / "my-app"
project = Project(project_path)
helpers.update_project_environment(
project, "default", {"skip-install": True, "platforms": ["foo"], **project.config.envs["default"]}
)
with project_path.as_cwd():
result = hatch("env", "remove")
assert result.exit_code == 0, result.output
assert not result.output
def test_active(hatch, temp_dir_data, helpers):
project_name = "My.App"
with temp_dir_data.as_cwd():
result = hatch("new", project_name)
assert result.exit_code == 0, result.output
project_path = temp_dir_data / "my-app"
with project_path.as_cwd(env_vars={AppEnvVars.ENV_ACTIVE: "default"}):
result = hatch("env", "remove")
assert result.exit_code == 1
assert result.output == helpers.dedent(
"""
Cannot remove active environment: default
"""
)
def test_active_override(hatch, helpers, temp_dir_data, config_file):
config_file.model.template.plugins["default"]["tests"] = False
config_file.save()
project_name = "My.App"
with temp_dir_data.as_cwd():
result = hatch("new", project_name)
assert result.exit_code == 0, result.output
project_path = temp_dir_data / "my-app"
project = Project(project_path)
helpers.update_project_environment(project, "default", {"skip-install": True, **project.config.envs["default"]})
helpers.update_project_environment(project, "foo", {})
with project_path.as_cwd():
result = hatch("env", "create")
assert result.exit_code == 0, result.output
env_data_path = temp_dir_data / "data" / "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
(storage_path / "default").is_dir()
with project_path.as_cwd(env_vars={AppEnvVars.ENV_ACTIVE: "foo"}):
result = hatch("env", "remove", "default")
assert result.exit_code == 0, result.output
assert result.output == helpers.dedent(
"""
Removing environment: default
"""
)
assert not storage_path.is_dir()
def test_plugin_dependencies_unmet(hatch, helpers, temp_dir_data, config_file, mock_plugin_installation):
config_file.model.template.plugins["default"]["tests"] = False
config_file.save()
project_name = "My.App"
with temp_dir_data.as_cwd():
result = hatch("new", project_name)
assert result.exit_code == 0, result.output
project_path = temp_dir_data / "my-app"
project = Project(project_path)
helpers.update_project_environment(project, "default", {"skip-install": True, **project.config.envs["default"]})
helpers.update_project_environment(project, "foo", {})
helpers.update_project_environment(project, "bar", {})
with project_path.as_cwd():
result = hatch("env", "create", "foo")
assert result.exit_code == 0, result.output
with project_path.as_cwd():
result = hatch("env", "create", "bar")
assert result.exit_code == 0, result.output
env_data_path = temp_dir_data / "data" / "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) == 2
foo_env_path = storage_path / "foo"
bar_env_path = storage_path / "bar"
assert foo_env_path.is_dir()
assert bar_env_path.is_dir()
dependency = os.urandom(16).hex()
(project_path / DEFAULT_CONFIG_FILE).write_text(
helpers.dedent(
f"""
[env]
requires = ["{dependency}"]
"""
)
)
with project_path.as_cwd():
result = hatch("env", "remove", "bar")
assert result.exit_code == 0, result.output
assert result.output == helpers.dedent(
"""
Syncing environment plugin requirements
Removing environment: bar
"""
)
helpers.assert_plugin_installation(mock_plugin_installation, [dependency])
assert foo_env_path.is_dir()
assert not bar_env_path.is_dir()
================================================
FILE: tests/cli/env/test_run.py
================================================
import os
from hatch.config.constants import ConfigEnvVars
from hatch.project.core import Project
from hatchling.utils.constants import DEFAULT_CONFIG_FILE
def test_filter_not_mapping(hatch, helpers, temp_dir, config_file):
config_file.model.template.plugins["default"]["tests"] = False
config_file.save()
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()
project = Project(project_path)
helpers.update_project_environment(
project,
"default",
{
"skip-install": True,
"scripts": {
"error": [
'python -c "import sys;sys.exit(3)"',
"python -c \"import pathlib,sys;pathlib.Path('test.txt').write_text(sys.executable)\"",
],
},
**project.config.envs["default"],
},
)
with project_path.as_cwd(env_vars={ConfigEnvVars.DATA: str(data_path)}):
result = hatch("env", "run", "error", "--filter", "[]")
assert result.exit_code == 1
assert result.output == helpers.dedent(
"""
The --filter/-f option must be a JSON mapping
"""
)
def test_filter(hatch, helpers, temp_dir, config_file):
config_file.model.template.plugins["default"]["tests"] = False
config_file.save()
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()
project = Project(project_path)
helpers.update_project_environment(project, "default", {"skip-install": True, **project.config.envs["default"]})
helpers.update_project_environment(
project,
"test",
{
"matrix": [{"version": ["9000", "42"]}],
"overrides": {"matrix": {"version": {"foo-bar-option": {"value": True, "if": ["42"]}}}},
},
)
with project_path.as_cwd(env_vars={ConfigEnvVars.DATA: str(data_path)}):
result = hatch(
"env",
"run",
"--env",
"test",
"--filter",
'{"foo-bar-option":true}',
"--",
"python",
"-c",
"import os,sys;open('test.txt', 'a').write(sys.executable+os.linesep[-1])",
)
assert result.exit_code == 0, result.output
assert result.output == helpers.dedent(
"""
─────────────────────────────────── test.42 ────────────────────────────────────
Creating environment: test.42
Checking dependencies
"""
)
output_file = project_path / "test.txt"
assert output_file.is_file()
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 == "test.42"
python_path = str(output_file.read_text()).strip()
assert str(env_path) in python_path
def test_force_continue(hatch, helpers, temp_dir, config_file):
config_file.model.template.plugins["default"]["tests"] = False
config_file.save()
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()
project = Project(project_path)
helpers.update_project_environment(
project,
"default",
{
"skip-install": True,
"scripts": {
"error": [
'python -c "import sys;sys.exit(2)"',
'- python -c "import sys;sys.exit(3)"',
'python -c "import sys;sys.exit(1)"',
"python -c \"import pathlib,sys;pathlib.Path('test.txt').write_text(sys.executable)\"",
],
},
**project.config.envs["default"],
},
)
with project_path.as_cwd(env_vars={ConfigEnvVars.DATA: str(data_path)}):
result = hatch("env", "run", "--force-continue", "--", "error")
assert result.exit_code == 2
assert result.output == helpers.dedent(
"""
Creating environment: default
Checking dependencies
cmd [1] | python -c "import sys;sys.exit(2)"
cmd [2] | - python -c "import sys;sys.exit(3)"
cmd [3] | python -c "import sys;sys.exit(1)"
cmd [4] | python -c "import pathlib,sys;pathlib.Path('test.txt').write_text(sys.executable)"
"""
)
output_file = project_path / "test.txt"
assert output_file.is_file()
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
assert str(env_path) in str(output_file.read_text())
def test_ignore_compatibility(hatch, helpers, temp_dir, config_file):
config_file.model.template.plugins["default"]["tests"] = False
config_file.save()
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()
project = Project(project_path)
helpers.update_project_environment(
project, "default", {"skip-install": True, "platforms": ["foo"], **project.config.envs["default"]}
)
helpers.update_project_environment(project, "test", {})
with project_path.as_cwd(env_vars={ConfigEnvVars.DATA: str(data_path)}):
result = hatch(
"env",
"run",
"--ignore-compat",
"--env",
"test",
"--",
"python",
"-c",
"import os,sys;open('test.txt', 'a').write(sys.executable+os.linesep[-1])",
)
assert result.exit_code == 0
assert result.output == helpers.dedent(
"""
Skipped 1 incompatible environment:
test -> unsupported platform
"""
)
output_file = project_path / "test.txt"
assert not output_file.is_file()
env_data_path = data_path / "env" / "virtual"
assert not env_data_path.is_dir()
def test_plugin_dependencies_unmet(hatch, helpers, temp_dir, config_file, mock_plugin_installation):
config_file.model.template.plugins["default"]["tests"] = False
config_file.save()
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()
dependency = os.urandom(16).hex()
(project_path / DEFAULT_CONFIG_FILE).write_text(
helpers.dedent(
f"""
[env]
requires = ["{dependency}"]
"""
)
)
project = Project(project_path)
helpers.update_project_environment(project, "default", {"skip-install": True, **project.config.envs["default"]})
with project_path.as_cwd(env_vars={ConfigEnvVars.DATA: str(data_path)}):
result = hatch(
"env", "run", "--", "python", "-c", "import pathlib,sys;pathlib.Path('test.txt').write_text(sys.executable)"
)
assert result.exit_code == 0, result.output
assert result.output == helpers.dedent(
"""
Syncing environment plugin requirements
Creating environment: default
Checking dependencies
"""
)
helpers.assert_plugin_installation(mock_plugin_installation, [dependency])
output_file = project_path / "test.txt"
assert output_file.is_file()
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
assert str(env_path) in str(output_file.read_text())
================================================
FILE: tests/cli/env/test_show.py
================================================
import json
import os
import pytest
from hatch.env.utils import get_env_var
from hatch.project.core import Project
from hatch.utils.structures import EnvVars
from hatchling.utils.constants import DEFAULT_CONFIG_FILE
@pytest.fixture(scope="module", autouse=True)
def _terminal_width():
with EnvVars({"COLUMNS": "200"}, exclude=[get_env_var(plugin_name="virtual", option="uv_path")]):
yield
def test_default(hatch, helpers, temp_dir, config_file):
config_file.model.template.plugins["default"]["tests"] = False
config_file.save()
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()
with project_path.as_cwd():
result = hatch("env", "show", "--ascii")
assert result.exit_code == 0, result.output
assert helpers.remove_trailing_spaces(result.output) == helpers.dedent(
"""
Standalone
+---------+---------+
| Name | Type |
+=========+=========+
| default | virtual |
+---------+---------+
"""
)
def test_default_as_json(hatch, temp_dir, config_file):
config_file.model.template.plugins["default"]["tests"] = False
config_file.save()
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()
with project_path.as_cwd():
result = hatch("env", "show", "--json")
assert result.exit_code == 0, result.output
environments = json.loads(result.output)
assert list(environments) == [
"default",
"hatch-build",
"hatch-static-analysis",
"hatch-test.py3.14",
"hatch-test.py3.14t",
"hatch-test.py3.13",
"hatch-test.py3.12",
"hatch-test.py3.11",
"hatch-test.py3.10",
"hatch-uv",
]
assert environments["default"] == {"type": "virtual"}
def test_single_only(hatch, helpers, temp_dir, config_file):
config_file.model.template.plugins["default"]["tests"] = False
config_file.save()
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()
project = Project(project_path)
helpers.update_project_environment(project, "foo", {})
helpers.update_project_environment(project, "bar", {})
with project_path.as_cwd():
result = hatch("env", "show", "--ascii")
assert result.exit_code == 0, result.output
assert helpers.remove_trailing_spaces(result.output) == helpers.dedent(
"""
Standalone
+---------+---------+
| Name | Type |
+=========+=========+
| default | virtual |
+---------+---------+
| foo | virtual |
+---------+---------+
| bar | virtual |
+---------+---------+
"""
)
def test_single_and_matrix(hatch, helpers, temp_dir, config_file):
config_file.model.template.plugins["default"]["tests"] = False
config_file.save()
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()
project = Project(project_path)
helpers.update_project_environment(project, "foo", {"matrix": [{"version": ["9000", "3.14"], "py": ["39", "310"]}]})
with project_path.as_cwd():
result = hatch("env", "show", "--ascii")
assert result.exit_code == 0, result.output
assert helpers.remove_trailing_spaces(result.output) == helpers.dedent(
"""
Standalone
+---------+---------+
| Name | Type |
+=========+=========+
| default | virtual |
+---------+---------+
Matrices
+------+---------+----------------+
| Name | Type | Envs |
+======+=========+================+
| foo | virtual | foo.py39-9000 |
| | | foo.py39-3.14 |
| | | foo.py310-9000 |
| | | foo.py310-3.14 |
+------+---------+----------------+
"""
)
def test_default_matrix_only(hatch, helpers, temp_dir, config_file):
config_file.model.template.plugins["default"]["tests"] = False
config_file.save()
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()
project = Project(project_path)
helpers.update_project_environment(
project, "default", {"matrix": [{"version": ["9000", "3.14"], "py": ["39", "310"]}]}
)
with project_path.as_cwd():
result = hatch("env", "show", "--ascii")
assert result.exit_code == 0, result.output
assert helpers.remove_trailing_spaces(result.output) == helpers.dedent(
"""
Matrices
+---------+---------+------------+
| Name | Type | Envs |
+=========+=========+============+
| default | virtual | py39-9000 |
| | | py39-3.14 |
| | | py310-9000 |
| | | py310-3.14 |
+---------+---------+------------+
"""
)
def test_all_matrix_types_with_single(hatch, helpers, temp_dir, config_file):
config_file.model.template.plugins["default"]["tests"] = False
config_file.save()
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()
project = Project(project_path)
helpers.update_project_environment(
project, "default", {"matrix": [{"version": ["9000", "3.14"], "py": ["39", "310"]}]}
)
helpers.update_project_environment(project, "foo", {"matrix": [{"version": ["9000", "3.14"], "py": ["39", "310"]}]})
helpers.update_project_environment(project, "bar", {})
with project_path.as_cwd():
result = hatch("env", "show", "--ascii")
assert result.exit_code == 0, result.output
assert helpers.remove_trailing_spaces(result.output) == helpers.dedent(
"""
Standalone
+------+---------+
| Name | Type |
+======+=========+
| bar | virtual |
+------+---------+
Matrices
+---------+---------+----------------+
| Name | Type | Envs |
+=========+=========+================+
| default | virtual | py39-9000 |
| | | py39-3.14 |
| | | py310-9000 |
| | | py310-3.14 |
+---------+---------+----------------+
| foo | virtual | foo.py39-9000 |
| | | foo.py39-3.14 |
| | | foo.py310-9000 |
| | | foo.py310-3.14 |
+---------+---------+----------------+
"""
)
def test_specific(hatch, helpers, temp_dir, config_file):
config_file.model.template.plugins["default"]["tests"] = False
config_file.save()
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()
project = Project(project_path)
helpers.update_project_environment(project, "foo", {})
helpers.update_project_environment(project, "bar", {})
with project_path.as_cwd():
result = hatch("env", "show", "bar", "foo", "--ascii")
assert result.exit_code == 0, result.output
assert helpers.remove_trailing_spaces(result.output) == helpers.dedent(
"""
Standalone
+------+---------+
| Name | Type |
+======+=========+
| foo | virtual |
+------+---------+
| bar | virtual |
+------+---------+
"""
)
def test_specific_unknown(hatch, helpers, temp_dir, config_file):
config_file.model.template.plugins["default"]["tests"] = False
config_file.save()
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()
with project_path.as_cwd():
result = hatch("env", "show", "foo", "--ascii")
assert result.exit_code == 1, result.output
assert helpers.remove_trailing_spaces(result.output) == helpers.dedent(
"""
Environment `foo` is not defined by project config
"""
)
def test_optional_columns(hatch, helpers, temp_dir, config_file):
config_file.model.template.plugins["default"]["tests"] = False
config_file.save()
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()
dependencies = ["python___dateutil", "bAr.Baz[TLS] >=1.2RC5"]
extra_dependencies = ['Foo;python_version<"3.8"']
env_vars = {"FOO": "1", "BAR": "2"}
description = """
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna \
aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. \
Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint \
occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.
"""
project = Project(project_path)
config = dict(project.raw_config)
config["project"]["optional-dependencies"] = {"foo_bar": [], "baz": []}
project.save_config(config)
helpers.update_project_environment(
project,
"default",
{
"matrix": [{"version": ["9000", "3.14"], "py": ["39", "310"]}],
"description": description,
"dependencies": dependencies,
"extra-dependencies": extra_dependencies,
"env-vars": env_vars,
"features": ["Foo...Bar", "Baz", "baZ"],
"scripts": {"test": "pytest", "build": "python -m build", "_foo": "test"},
},
)
helpers.update_project_environment(
project,
"foo",
{
"description": description,
"dependencies": dependencies,
"extra-dependencies": extra_dependencies,
"env-vars": env_vars,
},
)
with project_path.as_cwd():
result = hatch("env", "show", "--ascii")
assert result.exit_code == 0, result.output
assert helpers.remove_trailing_spaces(result.output) == helpers.dedent(
"""
Standalone
+------+---------+----------+-----------------------------+-----------------------+---------+----------------------------------------------------------------------------------------------------------+
| Name | Type | Features | Dependencies | Environment variables | Scripts | Description |
+======+=========+==========+=============================+=======================+=========+==========================================================================================================+
| foo | virtual | baz | bar-baz[tls]>=1.2rc5 | BAR=2 | build | Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et |
| | | foo-bar | foo; python_version < '3.8' | FOO=1 | test | dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip |
| | | | python-dateutil | | | ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu |
| | | | | | | fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia |
| | | | | | | deserunt mollit anim id est laborum. |
+------+---------+----------+-----------------------------+-----------------------+---------+----------------------------------------------------------------------------------------------------------+
Matrices
+---------+---------+------------+----------+-----------------------------+-----------------------+---------+------------------------------------------------------------------------------------------+
| Name | Type | Envs | Features | Dependencies | Environment variables | Scripts | Description |
+=========+=========+============+==========+=============================+=======================+=========+==========================================================================================+
| default | virtual | py39-9000 | baz | bar-baz[tls]>=1.2rc5 | BAR=2 | build | Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor |
| | | py39-3.14 | foo-bar | foo; python_version < '3.8' | FOO=1 | test | incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud |
| | | py310-9000 | | python-dateutil | | | exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure |
| | | py310-3.14 | | | | | dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. |
| | | | | | | | Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt |
| | | | | | | | mollit anim id est laborum. |
+---------+---------+------------+----------+-----------------------------+-----------------------+---------+------------------------------------------------------------------------------------------+
"""
)
def test_context_formatting(hatch, helpers, temp_dir, config_file):
config_file.model.template.plugins["default"]["tests"] = False
config_file.save()
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()
project = Project(project_path)
# Without context formatting
helpers.update_project_environment(
project,
"default",
{
"matrix": [{"version": ["9000", "3.14"], "py": ["39", "310"]}],
"dependencies": ["foo@ {root:uri}/../foo"],
},
)
# With context formatting
helpers.update_project_environment(
project,
"foo",
{
"env-vars": {"BAR": "{env:FOO_BAZ}"},
"dependencies": ["pydantic"],
},
)
with project_path.as_cwd(env_vars={"FOO_BAZ": "FOO_BAR"}):
result = hatch("env", "show", "--ascii")
assert result.exit_code == 0, result.output
assert helpers.remove_trailing_spaces(result.output) == helpers.dedent(
"""
Standalone
+------+---------+--------------+-----------------------+
| Name | Type | Dependencies | Environment variables |
+======+=========+==============+=======================+
| foo | virtual | pydantic | BAR=FOO_BAR |
+------+---------+--------------+-----------------------+
Matrices
+---------+---------+------------+-------------------------+
| Name | Type | Envs | Dependencies |
+=========+=========+============+=========================+
| default | virtual | py39-9000 | foo @ {root:uri}/../foo |
| | | py39-3.14 | |
| | | py310-9000 | |
| | | py310-3.14 | |
+---------+---------+------------+-------------------------+
"""
)
def test_plugin_dependencies_unmet(hatch, helpers, temp_dir, config_file, mock_plugin_installation):
config_file.model.template.plugins["default"]["tests"] = False
config_file.save()
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()
dependency = os.urandom(16).hex()
(project_path / DEFAULT_CONFIG_FILE).write_text(
helpers.dedent(
f"""
[env]
requires = ["{dependency}"]
"""
)
)
with project_path.as_cwd():
result = hatch("env", "show", "--ascii")
assert result.exit_code == 0, result.output
assert helpers.remove_trailing_spaces(result.output) == helpers.dedent(
"""
Syncing environment plugin requirements
Standalone
+---------+---------+
| Name | Type |
+=========+=========+
| default | virtual |
+---------+---------+
"""
)
helpers.assert_plugin_installation(mock_plugin_installation, [dependency])
================================================
FILE: tests/cli/fmt/__init__.py
================================================
================================================
FILE: tests/cli/fmt/test_fmt.py
================================================
from __future__ import annotations
import pytest
from hatch.config.constants import ConfigEnvVars
from hatch.project.core import Project
def construct_ruff_defaults_file(rules: tuple[str, ...]) -> str:
from hatch.cli.fmt.core import PER_FILE_IGNORED_RULES
lines = [
"line-length = 120",
"",
"[format]",
"docstring-code-format = true",
"docstring-code-line-length = 80",
"",
"[lint]",
]
# Selected rules
lines.append("select = [")
lines.extend(f' "{rule}",' for rule in sorted(rules))
lines.extend(("]", ""))
# Ignored rules
lines.append("[lint.per-file-ignores]")
for glob, ignored_rules in PER_FILE_IGNORED_RULES.items():
lines.append(f'"{glob}" = [')
lines.extend(f' "{ignored_rule}",' for ignored_rule in ignored_rules)
lines.append("]")
# Default config
lines.extend((
"",
"[lint.flake8-tidy-imports]",
'ban-relative-imports = "all"',
"",
"[lint.isort]",
'known-first-party = ["my_app"]',
"",
"[lint.flake8-pytest-style]",
"fixture-parentheses = false",
"mark-parentheses = false",
))
# Ensure the file ends with a newline to satisfy other linters
lines.append("")
return "\n".join(lines)
@pytest.fixture(scope="module")
def defaults_file_stable() -> str:
from hatch.cli.fmt.core import STABLE_RULES
return construct_ruff_defaults_file(STABLE_RULES)
@pytest.fixture(scope="module")
def defaults_file_preview() -> str:
from hatch.cli.fmt.core import PREVIEW_RULES, STABLE_RULES
return construct_ruff_defaults_file(STABLE_RULES + PREVIEW_RULES)
class TestDefaults:
def test_fix(self, hatch, helpers, temp_dir, config_file, env_run, mocker, platform, defaults_file_stable):
config_file.model.template.plugins["default"]["tests"] = False
config_file.save()
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()
config_dir = data_path / "env" / ".internal" / "hatch-static-analysis" / ".config" / project_path.id
default_config = config_dir / "ruff_defaults.toml"
user_config = config_dir / "pyproject.toml"
user_config_path = platform.join_command_args([str(user_config)])
with project_path.as_cwd(env_vars={ConfigEnvVars.DATA: str(data_path)}):
result = hatch("fmt")
assert result.exit_code == 0, result.output
assert result.output == helpers.dedent(
f"""
cmd [1] | ruff check --config {user_config_path} --fix .
cmd [2] | ruff format --config {user_config_path} .
"""
)
assert env_run.call_args_list == [
mocker.call(f"ruff check --config {user_config_path} --fix .", shell=True),
mocker.call(f"ruff format --config {user_config_path} .", shell=True),
]
assert default_config.read_text() == defaults_file_stable
old_contents = (project_path / "pyproject.toml").read_text()
config_path = str(default_config).replace("\\", "\\\\")
assert (
user_config.read_text()
== f"""\
{old_contents}
[tool.ruff]
extend = "{config_path}\""""
)
def test_check(self, hatch, helpers, temp_dir, config_file, env_run, mocker, platform, defaults_file_stable):
config_file.model.template.plugins["default"]["tests"] = False
config_file.save()
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()
config_dir = data_path / "env" / ".internal" / "hatch-static-analysis" / ".config" / project_path.id
default_config = config_dir / "ruff_defaults.toml"
user_config = config_dir / "pyproject.toml"
user_config_path = platform.join_command_args([str(user_config)])
with project_path.as_cwd(env_vars={ConfigEnvVars.DATA: str(data_path)}):
result = hatch("fmt", "--check")
assert result.exit_code == 0, result.output
assert result.output == helpers.dedent(
f"""
cmd [1] | ruff check --config {user_config_path} .
cmd [2] | ruff format --config {user_config_path} --check --diff .
"""
)
assert env_run.call_args_list == [
mocker.call(f"ruff check --config {user_config_path} .", shell=True),
mocker.call(f"ruff format --config {user_config_path} --check --diff .", shell=True),
]
assert default_config.read_text() == defaults_file_stable
old_contents = (project_path / "pyproject.toml").read_text()
config_path = str(default_config).replace("\\", "\\\\")
assert (
user_config.read_text()
== f"""\
{old_contents}
[tool.ruff]
extend = "{config_path}\""""
)
def test_existing_config(
self, hatch, helpers, temp_dir, config_file, env_run, mocker, platform, defaults_file_stable
):
config_file.model.template.plugins["default"]["tests"] = False
config_file.save()
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()
config_dir = data_path / "env" / ".internal" / "hatch-static-analysis" / ".config" / project_path.id
default_config = config_dir / "ruff_defaults.toml"
user_config = config_dir / "pyproject.toml"
user_config_path = platform.join_command_args([str(user_config)])
project_file = project_path / "pyproject.toml"
old_contents = project_file.read_text()
project_file.write_text(f"[tool.ruff]\n{old_contents}")
with project_path.as_cwd(env_vars={ConfigEnvVars.DATA: str(data_path)}):
result = hatch("fmt", "--check")
assert result.exit_code == 0, result.output
assert result.output == helpers.dedent(
f"""
cmd [1] | ruff check --config {user_config_path} .
cmd [2] | ruff format --config {user_config_path} --check --diff .
"""
)
assert env_run.call_args_list == [
mocker.call(f"ruff check --config {user_config_path} .", shell=True),
mocker.call(f"ruff format --config {user_config_path} --check --diff .", shell=True),
]
assert default_config.read_text() == defaults_file_stable
config_path = str(default_config).replace("\\", "\\\\")
assert (
user_config.read_text()
== f"""\
[tool.ruff]
extend = "{config_path}\"
{old_contents.rstrip()}"""
)
class TestPreview:
def test_fix_flag(self, hatch, helpers, temp_dir, config_file, env_run, mocker, platform, defaults_file_preview):
config_file.model.template.plugins["default"]["tests"] = False
config_file.save()
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()
config_dir = data_path / "env" / ".internal" / "hatch-static-analysis" / ".config" / project_path.id
default_config = config_dir / "ruff_defaults.toml"
user_config = config_dir / "pyproject.toml"
user_config_path = platform.join_command_args([str(user_config)])
with project_path.as_cwd(env_vars={ConfigEnvVars.DATA: str(data_path)}):
result = hatch("fmt", "--preview")
assert result.exit_code == 0, result.output
assert result.output == helpers.dedent(
f"""
cmd [1] | ruff check --config {user_config_path} --preview --fix .
cmd [2] | ruff format --config {user_config_path} --preview .
"""
)
assert env_run.call_args_list == [
mocker.call(f"ruff check --config {user_config_path} --preview --fix .", shell=True),
mocker.call(f"ruff format --config {user_config_path} --preview .", shell=True),
]
assert default_config.read_text() == defaults_file_preview
old_contents = (project_path / "pyproject.toml").read_text()
config_path = str(default_config).replace("\\", "\\\\")
assert (
user_config.read_text()
== f"""\
{old_contents}
[tool.ruff]
extend = "{config_path}\""""
)
def test_check_flag(self, hatch, helpers, temp_dir, config_file, env_run, mocker, platform, defaults_file_preview):
config_file.model.template.plugins["default"]["tests"] = False
config_file.save()
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()
config_dir = data_path / "env" / ".internal" / "hatch-static-analysis" / ".config" / project_path.id
default_config = config_dir / "ruff_defaults.toml"
user_config = config_dir / "pyproject.toml"
user_config_path = platform.join_command_args([str(user_config)])
with project_path.as_cwd(env_vars={ConfigEnvVars.DATA: str(data_path)}):
result = hatch("fmt", "--check", "--preview")
assert result.exit_code == 0, result.output
assert result.output == helpers.dedent(
f"""
cmd [1] | ruff check --config {user_config_path} --preview .
cmd [2] | ruff format --config {user_config_path} --preview --check --diff .
"""
)
assert env_run.call_args_list == [
mocker.call(f"ruff check --config {user_config_path} --preview .", shell=True),
mocker.call(f"ruff format --config {user_config_path} --preview --check --diff .", shell=True),
]
assert default_config.read_text() == defaults_file_preview
old_contents = (project_path / "pyproject.toml").read_text()
config_path = str(default_config).replace("\\", "\\\\")
assert (
user_config.read_text()
== f"""\
{old_contents}
[tool.ruff]
extend = "{config_path}\""""
)
class TestComponents:
def test_only_linter(self, hatch, temp_dir, config_file, env_run, mocker, platform, defaults_file_stable):
config_file.model.template.plugins["default"]["tests"] = False
config_file.save()
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()
with project_path.as_cwd(env_vars={ConfigEnvVars.DATA: str(data_path)}):
result = hatch("fmt", "--linter")
assert result.exit_code == 0, result.output
assert not result.output
root_data_path = data_path / "env" / ".internal" / "hatch-static-analysis" / ".config"
config_dir = next(root_data_path.iterdir())
default_config = config_dir / "ruff_defaults.toml"
user_config = config_dir / "pyproject.toml"
user_config_path = platform.join_command_args([str(user_config)])
assert env_run.call_args_list == [
mocker.call(f"ruff check --config {user_config_path} --fix .", shell=True),
]
assert default_config.read_text() == defaults_file_stable
old_contents = (project_path / "pyproject.toml").read_text()
config_path = str(default_config).replace("\\", "\\\\")
assert (
user_config.read_text()
== f"""\
{old_contents}
[tool.ruff]
extend = "{config_path}\""""
)
def test_only_formatter(self, hatch, temp_dir, config_file, env_run, mocker, platform, defaults_file_stable):
config_file.model.template.plugins["default"]["tests"] = False
config_file.save()
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()
with project_path.as_cwd(env_vars={ConfigEnvVars.DATA: str(data_path)}):
result = hatch("fmt", "--formatter")
assert result.exit_code == 0, result.output
assert not result.output
root_data_path = data_path / "env" / ".internal" / "hatch-static-analysis" / ".config"
config_dir = next(root_data_path.iterdir())
default_config = config_dir / "ruff_defaults.toml"
user_config = config_dir / "pyproject.toml"
user_config_path = platform.join_command_args([str(user_config)])
assert env_run.call_args_list == [
mocker.call(f"ruff format --config {user_config_path} .", shell=True),
]
assert default_config.read_text() == defaults_file_stable
old_contents = (project_path / "pyproject.toml").read_text()
config_path = str(default_config).replace("\\", "\\\\")
assert (
user_config.read_text()
== f"""\
{old_contents}
[tool.ruff]
extend = "{config_path}\""""
)
@pytest.mark.usefixtures("env_run")
def test_select_multiple(self, hatch, helpers, temp_dir, config_file):
config_file.model.template.plugins["default"]["tests"] = False
config_file.save()
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()
with project_path.as_cwd(env_vars={ConfigEnvVars.DATA: str(data_path)}):
result = hatch("fmt", "--linter", "--formatter")
assert result.exit_code == 1, result.output
assert result.output == helpers.dedent(
"""
Cannot specify both --linter and --formatter
"""
)
class TestArguments:
def test_forwarding(self, hatch, helpers, temp_dir, config_file, env_run, mocker, platform, defaults_file_stable):
config_file.model.template.plugins["default"]["tests"] = False
config_file.save()
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()
config_dir = data_path / "env" / ".internal" / "hatch-static-analysis" / ".config" / project_path.id
default_config = config_dir / "ruff_defaults.toml"
user_config = config_dir / "pyproject.toml"
user_config_path = platform.join_command_args([str(user_config)])
with project_path.as_cwd(env_vars={ConfigEnvVars.DATA: str(data_path)}):
result = hatch("fmt", "--", "--foo", "bar")
assert result.exit_code == 0, result.output
assert result.output == helpers.dedent(
f"""
cmd [1] | ruff check --config {user_config_path} --fix --foo bar
cmd [2] | ruff format --config {user_config_path} --foo bar
"""
)
assert env_run.call_args_list == [
mocker.call(f"ruff check --config {user_config_path} --fix --foo bar", shell=True),
mocker.call(f"ruff format --config {user_config_path} --foo bar", shell=True),
]
assert default_config.read_text() == defaults_file_stable
old_contents = (project_path / "pyproject.toml").read_text()
config_path = str(default_config).replace("\\", "\\\\")
assert (
user_config.read_text()
== f"""\
{old_contents}
[tool.ruff]
extend = "{config_path}\""""
)
class TestConfigPath:
@pytest.mark.usefixtures("env_run")
def test_sync_without_config(self, hatch, helpers, temp_dir, config_file):
config_file.model.template.plugins["default"]["tests"] = False
config_file.save()
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()
with project_path.as_cwd(env_vars={ConfigEnvVars.DATA: str(data_path)}):
result = hatch("fmt", "--sync")
assert result.exit_code == 1, result.output
assert result.output == helpers.dedent(
"""
The --sync flag can only be used when the `tool.hatch.format.config-path` option is defined
"""
)
def test_sync(self, hatch, helpers, temp_dir, config_file, env_run, mocker, defaults_file_stable):
config_file.model.template.plugins["default"]["tests"] = False
config_file.save()
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()
default_config_file = project_path / "ruff_defaults.toml"
assert not default_config_file.is_file()
project = Project(project_path)
config = dict(project.raw_config)
config["tool"]["hatch"]["envs"] = {"hatch-static-analysis": {"config-path": "ruff_defaults.toml"}}
config["tool"]["ruff"] = {"extend": "ruff_defaults.toml"}
project.save_config(config)
with project_path.as_cwd(env_vars={ConfigEnvVars.DATA: str(data_path)}):
result = hatch("fmt", "--sync")
assert result.exit_code == 0, result.output
assert result.output == helpers.dedent(
"""
cmd [1] | ruff check --fix .
cmd [2] | ruff format .
"""
)
root_data_path = data_path / "env" / ".internal" / "hatch-static-analysis" / ".config"
assert not root_data_path.is_dir()
assert env_run.call_args_list == [
mocker.call("ruff check --fix .", shell=True),
mocker.call("ruff format .", shell=True),
]
assert default_config_file.read_text() == defaults_file_stable
def test_no_sync(self, hatch, helpers, temp_dir, config_file, env_run, mocker):
config_file.model.template.plugins["default"]["tests"] = False
config_file.save()
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()
default_config_file = project_path / "ruff_defaults.toml"
default_config_file.touch()
project = Project(project_path)
config = dict(project.raw_config)
config["tool"]["hatch"]["envs"] = {"hatch-static-analysis": {"config-path": "ruff_defaults.toml"}}
config["tool"]["ruff"] = {"extend": "ruff_defaults.toml"}
project.save_config(config)
with project_path.as_cwd(env_vars={ConfigEnvVars.DATA: str(data_path)}):
result = hatch("fmt")
assert result.exit_code == 0, result.output
assert result.output == helpers.dedent(
"""
cmd [1] | ruff check --fix .
cmd [2] | ruff format .
"""
)
root_data_path = data_path / "env" / ".internal" / "hatch-static-analysis" / ".config"
assert not root_data_path.is_dir()
assert env_run.call_args_list == [
mocker.call("ruff check --fix .", shell=True),
mocker.call("ruff format .", shell=True),
]
assert not default_config_file.read_text()
def test_sync_legacy_config(self, hatch, helpers, temp_dir, config_file, env_run, mocker, defaults_file_stable):
config_file.model.template.plugins["default"]["tests"] = False
config_file.save()
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()
default_config_file = project_path / "ruff_defaults.toml"
assert not default_config_file.is_file()
project = Project(project_path)
config = dict(project.raw_config)
config["tool"]["hatch"]["format"] = {"config-path": "ruff_defaults.toml"}
config["tool"]["ruff"] = {"extend": "ruff_defaults.toml"}
project.save_config(config)
with project_path.as_cwd(env_vars={ConfigEnvVars.DATA: str(data_path)}):
result = hatch("fmt", "--sync")
assert result.exit_code == 0, result.output
assert result.output == helpers.dedent(
"""
The `tool.hatch.format.config-path` option is deprecated and will be removed in a future release. Use `tool.hatch.envs.hatch-static-analysis.config-path` instead.
cmd [1] | ruff check --fix .
cmd [2] | ruff format .
"""
)
root_data_path = data_path / "env" / ".internal" / "hatch-static-analysis" / ".config"
assert not root_data_path.is_dir()
assert env_run.call_args_list == [
mocker.call("ruff check --fix .", shell=True),
mocker.call("ruff format .", shell=True),
]
assert default_config_file.read_text() == defaults_file_stable
class TestCustomScripts:
def test_only_linter_fix(self, hatch, temp_dir, config_file, env_run, mocker):
config_file.model.template.plugins["default"]["tests"] = False
config_file.save()
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()
project = Project(project_path)
config = dict(project.raw_config)
config["tool"]["hatch"]["envs"] = {
"hatch-static-analysis": {
"config-path": "none",
"dependencies": ["black", "flake8", "isort"],
"scripts": {
"format-check": [
"black --check --diff {args:.}",
"isort --check-only --diff {args:.}",
],
"format-fix": [
"isort {args:.}",
"black {args:.}",
],
"lint-check": "flake8 {args:.}",
"lint-fix": "lint-check",
},
}
}
project.save_config(config)
with project_path.as_cwd(env_vars={ConfigEnvVars.DATA: str(data_path)}):
result = hatch("fmt", "--linter")
assert result.exit_code == 0, result.output
assert not result.output
root_data_path = data_path / "env" / ".internal" / "hatch-static-analysis" / ".config"
assert not root_data_path.is_dir()
assert env_run.call_args_list == [
mocker.call("flake8 .", shell=True),
]
def test_only_linter_check(self, hatch, temp_dir, config_file, env_run, mocker):
config_file.model.template.plugins["default"]["tests"] = False
config_file.save()
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()
project = Project(project_path)
config = dict(project.raw_config)
config["tool"]["hatch"]["envs"] = {
"hatch-static-analysis": {
"config-path": "none",
"dependencies": ["black", "flake8", "isort"],
"scripts": {
"format-check": [
"black --check --diff {args:.}",
"isort --check-only --diff {args:.}",
],
"format-fix": [
"isort {args:.}",
"black {args:.}",
],
"lint-check": "flake8 {args:.}",
"lint-fix": "lint-check",
},
}
}
project.save_config(config)
with project_path.as_cwd(env_vars={ConfigEnvVars.DATA: str(data_path)}):
result = hatch("fmt", "--check", "--linter")
assert result.exit_code == 0, result.output
assert not result.output
root_data_path = data_path / "env" / ".internal" / "hatch-static-analysis" / ".config"
assert not root_data_path.is_dir()
assert env_run.call_args_list == [
mocker.call("flake8 .", shell=True),
]
def test_only_formatter_fix(self, hatch, helpers, temp_dir, config_file, env_run, mocker):
config_file.model.template.plugins["default"]["tests"] = False
config_file.save()
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()
project = Project(project_path)
config = dict(project.raw_config)
config["tool"]["hatch"]["envs"] = {
"hatch-static-analysis": {
"config-path": "none",
"dependencies": ["black", "flake8", "isort"],
"scripts": {
"format-check": [
"black --check --diff {args:.}",
"isort --check-only --diff {args:.}",
],
"format-fix": [
"isort {args:.}",
"black {args:.}",
],
"lint-check": "flake8 {args:.}",
"lint-fix": "lint-check",
},
}
}
project.save_config(config)
with project_path.as_cwd(env_vars={ConfigEnvVars.DATA: str(data_path)}):
result = hatch("fmt", "--formatter")
assert result.exit_code == 0, result.output
assert result.output == helpers.dedent(
"""
cmd [1] | isort .
cmd [2] | black .
"""
)
root_data_path = data_path / "env" / ".internal" / "hatch-static-analysis" / ".config"
assert not root_data_path.is_dir()
assert env_run.call_args_list == [
mocker.call("isort .", shell=True),
mocker.call("black .", shell=True),
]
def test_only_formatter_check(self, hatch, helpers, temp_dir, config_file, env_run, mocker):
config_file.model.template.plugins["default"]["tests"] = False
config_file.save()
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()
project = Project(project_path)
config = dict(project.raw_config)
config["tool"]["hatch"]["envs"] = {
"hatch-static-analysis": {
"config-path": "none",
"dependencies": ["black", "flake8", "isort"],
"scripts": {
"format-check": [
"black --check --diff {args:.}",
"isort --check-only --diff {args:.}",
],
"format-fix": [
"isort {args:.}",
"black {args:.}",
],
"lint-check": "flake8 {args:.}",
"lint-fix": "lint-check",
},
}
}
project.save_config(config)
with project_path.as_cwd(env_vars={ConfigEnvVars.DATA: str(data_path)}):
result = hatch("fmt", "--check", "--formatter")
assert result.exit_code == 0, result.output
assert result.output == helpers.dedent(
"""
cmd [1] | black --check --diff .
cmd [2] | isort --check-only --diff .
"""
)
root_data_path = data_path / "env" / ".internal" / "hatch-static-analysis" / ".config"
assert not root_data_path.is_dir()
assert env_run.call_args_list == [
mocker.call("black --check --diff .", shell=True),
mocker.call("isort --check-only --diff .", shell=True),
]
def test_fix(self, hatch, helpers, temp_dir, config_file, env_run, mocker):
config_file.model.template.plugins["default"]["tests"] = False
config_file.save()
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()
project = Project(project_path)
config = dict(project.raw_config)
config["tool"]["hatch"]["envs"] = {
"hatch-static-analysis": {
"config-path": "none",
"dependencies": ["black", "flake8", "isort"],
"scripts": {
"format-check": [
"black --check --diff {args:.}",
"isort --check-only --diff {args:.}",
],
"format-fix": [
"isort {args:.}",
"black {args:.}",
],
"lint-check": "flake8 {args:.}",
"lint-fix": "lint-check",
},
}
}
project.save_config(config)
with project_path.as_cwd(env_vars={ConfigEnvVars.DATA: str(data_path)}):
result = hatch("fmt")
assert result.exit_code == 0, result.output
assert result.output == helpers.dedent(
"""
cmd [1] | flake8 .
cmd [2] | isort .
cmd [3] | black .
"""
)
root_data_path = data_path / "env" / ".internal" / "hatch-static-analysis" / ".config"
assert not root_data_path.is_dir()
assert env_run.call_args_list == [
mocker.call("flake8 .", shell=True),
mocker.call("isort .", shell=True),
mocker.call("black .", shell=True),
]
def test_check(self, hatch, helpers, temp_dir, config_file, env_run, mocker):
config_file.model.template.plugins["default"]["tests"] = False
config_file.save()
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()
project = Project(project_path)
config = dict(project.raw_config)
config["tool"]["hatch"]["envs"] = {
"hatch-static-analysis": {
"config-path": "none",
"dependencies": ["black", "flake8", "isort"],
"scripts": {
"format-check": [
"black --check --diff {args:.}",
"isort --check-only --diff {args:.}",
],
"format-fix": [
"isort {args:.}",
"black {args:.}",
],
"lint-check": "flake8 {args:.}",
"lint-fix": "lint-check",
},
}
}
project.save_config(config)
with project_path.as_cwd(env_vars={ConfigEnvVars.DATA: str(data_path)}):
result = hatch("fmt", "--check")
assert result.exit_code == 0, result.output
assert result.output == helpers.dedent(
"""
cmd [1] | flake8 .
cmd [2] | black --check --diff .
cmd [3] | isort --check-only --diff .
"""
)
root_data_path = data_path / "env" / ".internal" / "hatch-static-analysis" / ".config"
assert not root_data_path.is_dir()
assert env_run.call_args_list == [
mocker.call("flake8 .", shell=True),
mocker.call("black --check --diff .", shell=True),
mocker.call("isort --check-only --diff .", shell=True),
]
================================================
FILE: tests/cli/new/__init__.py
================================================
================================================
FILE: tests/cli/new/test_new.py
================================================
import pytest
from hatch.config.constants import ConfigEnvVars
def remove_trailing_spaces(text):
return "".join(f"{line.rstrip()}\n" for line in text.splitlines(True))
class TestErrors:
def test_path_is_file(self, hatch, temp_dir):
with temp_dir.as_cwd():
path = temp_dir / "foo"
path.touch()
result = hatch("new", "foo")
assert result.exit_code == 1
assert result.output == f"Path `{path}` points to a file.\n"
def test_path_not_empty(self, hatch, temp_dir):
with temp_dir.as_cwd():
path = temp_dir / "foo"
(path / "bar").ensure_dir_exists()
result = hatch("new", "foo")
assert result.exit_code == 1
assert result.output == f"Directory `{path}` is not empty.\n"
def test_no_plugins_found(self, hatch, config_file, temp_dir):
project_name = "My.App"
config_file.model.template.plugins = {"foo": {}}
config_file.save()
with temp_dir.as_cwd():
result = hatch("new", project_name)
assert result.exit_code == 1
assert result.output == "None of the defined plugins were found: foo\n"
def test_some_not_plugins_found(self, hatch, config_file, temp_dir):
project_name = "My.App"
config_file.model.template.plugins["foo"] = {}
config_file.save()
with temp_dir.as_cwd():
result = hatch("new", project_name)
assert result.exit_code == 1
assert result.output == "Some of the defined plugins were not found: foo\n"
def test_default(hatch, helpers, temp_dir):
project_name = "My.App"
with temp_dir.as_cwd():
result = hatch("new", project_name)
path = temp_dir / "my-app"
expected_files = helpers.get_template_files("new.default", project_name)
helpers.assert_files(path, expected_files)
assert result.exit_code == 0, result.output
assert remove_trailing_spaces(result.output) == helpers.dedent(
"""
my-app
├── src
│ └── my_app
│ ├── __about__.py
│ └── __init__.py
├── tests
│ └── __init__.py
├── LICENSE.txt
├── README.md
└── pyproject.toml
"""
)
def test_default_explicit_path(hatch, helpers, temp_dir):
project_name = "My.App"
with temp_dir.as_cwd():
result = hatch("new", project_name, ".")
expected_files = helpers.get_template_files("new.default", project_name)
helpers.assert_files(temp_dir, expected_files)
assert result.exit_code == 0, result.output
assert remove_trailing_spaces(result.output) == helpers.dedent(
"""
src
└── my_app
├── __about__.py
└── __init__.py
tests
└── __init__.py
LICENSE.txt
README.md
pyproject.toml
"""
)
def test_default_empty_plugins_table(hatch, helpers, config_file, temp_dir):
project_name = "My.App"
config_file.model.template.plugins = {}
config_file.save()
with temp_dir.as_cwd():
result = hatch("new", project_name)
path = temp_dir / "my-app"
expected_files = helpers.get_template_files("new.default", project_name)
helpers.assert_files(path, expected_files)
assert result.exit_code == 0, result.output
assert remove_trailing_spaces(result.output) == helpers.dedent(
"""
my-app
├── src
│ └── my_app
│ ├── __about__.py
│ └── __init__.py
├── tests
│ └── __init__.py
├── LICENSE.txt
├── README.md
└── pyproject.toml
"""
)
@pytest.mark.requires_internet
def test_default_no_license_cache(hatch, helpers, temp_dir):
project_name = "My.App"
cache_dir = temp_dir / "cache"
cache_dir.mkdir()
with temp_dir.as_cwd({ConfigEnvVars.CACHE: str(cache_dir)}):
result = hatch("new", project_name)
path = temp_dir / "my-app"
expected_files = helpers.get_template_files("new.default", project_name)
helpers.assert_files(path, expected_files)
assert result.exit_code == 0, result.output
assert remove_trailing_spaces(result.output) == helpers.dedent(
"""
my-app
├── src
│ └── my_app
│ ├── __about__.py
│ └── __init__.py
├── tests
│ └── __init__.py
├── LICENSE.txt
├── README.md
└── pyproject.toml
"""
)
def test_licenses_multiple(hatch, helpers, config_file, temp_dir):
project_name = "My.App"
config_file.model.template.licenses.default = ["MIT", "Apache-2.0"]
config_file.save()
with temp_dir.as_cwd():
result = hatch("new", project_name)
path = temp_dir / "my-app"
expected_files = helpers.get_template_files("new.licenses_multiple", project_name)
helpers.assert_files(path, expected_files)
assert result.exit_code == 0, result.output
assert remove_trailing_spaces(result.output) == helpers.dedent(
"""
my-app
├── LICENSES
│ ├── Apache-2.0.txt
│ └── MIT.txt
├── src
│ └── my_app
│ ├── __about__.py
│ └── __init__.py
├── tests
│ └── __init__.py
├── README.md
└── pyproject.toml
"""
)
def test_licenses_empty(hatch, helpers, config_file, temp_dir):
project_name = "My.App"
config_file.model.template.licenses.default = []
config_file.save()
with temp_dir.as_cwd():
result = hatch("new", project_name)
path = temp_dir / "my-app"
expected_files = helpers.get_template_files("new.licenses_empty", project_name)
helpers.assert_files(path, expected_files)
assert result.exit_code == 0, result.output
assert remove_trailing_spaces(result.output) == helpers.dedent(
"""
my-app
├── src
│ └── my_app
│ ├── __about__.py
│ └── __init__.py
├── tests
│ └── __init__.py
├── README.md
└── pyproject.toml
"""
)
def test_projects_urls_space_in_label(hatch, helpers, config_file, temp_dir):
project_name = "My.App"
config_file.model.template.plugins["default"]["project_urls"] = {
"Documentation": "https://github.com/{name}/{project_name_normalized}#readme",
"Source": "https://github.com/{name}/{project_name_normalized}",
"Bug Tracker": "https://github.com/{name}/{project_name_normalized}/issues",
}
config_file.save()
with temp_dir.as_cwd():
result = hatch("new", project_name)
path = temp_dir / "my-app"
expected_files = helpers.get_template_files("new.projects_urls_space_in_label", project_name)
helpers.assert_files(path, expected_files)
assert result.exit_code == 0, result.output
assert remove_trailing_spaces(result.output) == helpers.dedent(
"""
my-app
├── src
│ └── my_app
│ ├── __about__.py
│ └── __init__.py
├── tests
│ └── __init__.py
├── LICENSE.txt
├── README.md
└── pyproject.toml
"""
)
def test_projects_urls_empty(hatch, helpers, config_file, temp_dir):
project_name = "My.App"
config_file.model.template.plugins["default"]["project_urls"] = {}
config_file.save()
with temp_dir.as_cwd():
result = hatch("new", project_name)
path = temp_dir / "my-app"
expected_files = helpers.get_template_files("new.projects_urls_empty", project_name)
helpers.assert_files(path, expected_files)
assert result.exit_code == 0, result.output
assert remove_trailing_spaces(result.output) == helpers.dedent(
"""
my-app
├── src
│ └── my_app
│ ├── __about__.py
│ └── __init__.py
├── tests
│ └── __init__.py
├── LICENSE.txt
├── README.md
└── pyproject.toml
"""
)
def test_feature_cli(hatch, helpers, temp_dir):
project_name = "My.App"
with temp_dir.as_cwd():
result = hatch("new", project_name, "--cli")
path = temp_dir / "my-app"
expected_files = helpers.get_template_files("new.feature_cli", project_name)
helpers.assert_files(path, expected_files)
assert result.exit_code == 0, result.output
assert remove_trailing_spaces(result.output) == helpers.dedent(
"""
my-app
├── src
│ └── my_app
│ ├── cli
│ │ └── __init__.py
│ ├── __about__.py
│ ├── __init__.py
│ └── __main__.py
├── tests
│ └── __init__.py
├── LICENSE.txt
├── README.md
└── pyproject.toml
"""
)
def test_feature_ci(hatch, helpers, config_file, temp_dir):
config_file.model.template.plugins["default"]["ci"] = True
config_file.save()
project_name = "My.App"
with temp_dir.as_cwd():
result = hatch("new", project_name)
path = temp_dir / "my-app"
expected_files = helpers.get_template_files("new.feature_ci", project_name)
helpers.assert_files(path, expected_files)
assert result.exit_code == 0, result.output
assert remove_trailing_spaces(result.output) == helpers.dedent(
"""
my-app
├── .github
│ └── workflows
│ └── test.yml
├── src
│ └── my_app
│ ├── __about__.py
│ └── __init__.py
├── tests
│ └── __init__.py
├── LICENSE.txt
├── README.md
└── pyproject.toml
"""
)
def test_feature_no_src_layout(hatch, helpers, config_file, temp_dir):
config_file.model.template.plugins["default"]["src-layout"] = False
config_file.save()
project_name = "My.App"
with temp_dir.as_cwd():
result = hatch("new", project_name)
path = temp_dir / "my-app"
expected_files = helpers.get_template_files("new.feature_no_src_layout", project_name)
helpers.assert_files(path, expected_files)
assert result.exit_code == 0, result.output
assert remove_trailing_spaces(result.output) == helpers.dedent(
"""
my-app
├── my_app
│ ├── __about__.py
│ └── __init__.py
├── tests
│ └── __init__.py
├── LICENSE.txt
├── README.md
└── pyproject.toml
"""
)
def test_feature_tests_disable(hatch, helpers, config_file, temp_dir):
config_file.model.template.plugins["default"]["tests"] = False
config_file.save()
project_name = "My.App"
with temp_dir.as_cwd():
result = hatch("new", project_name)
path = temp_dir / "my-app"
expected_files = helpers.get_template_files("new.basic", project_name)
helpers.assert_files(path, expected_files)
assert result.exit_code == 0, result.output
assert remove_trailing_spaces(result.output) == helpers.dedent(
"""
my-app
├── src
│ └── my_app
│ ├── __about__.py
│ └── __init__.py
├── LICENSE.txt
├── README.md
└── pyproject.toml
"""
)
def test_no_project_name_error(hatch, helpers, temp_dir):
with temp_dir.as_cwd():
result = hatch("new")
assert result.exit_code == 1
assert remove_trailing_spaces(result.output) == helpers.dedent(
"""
Missing required argument for the project name, use the -i/--interactive flag.
"""
)
def test_interactive(hatch, helpers, temp_dir):
project_name = "My.App"
description = "foo \u2764"
with temp_dir.as_cwd():
result = hatch("new", "-i", input=f"{project_name}\n{description}")
path = temp_dir / "my-app"
expected_files = helpers.get_template_files("new.default", project_name, description=description)
helpers.assert_files(path, expected_files)
assert result.exit_code == 0, result.output
assert remove_trailing_spaces(result.output) == helpers.dedent(
f"""
Project name: {project_name}
Description []: {description}
my-app
├── src
│ └── my_app
│ ├── __about__.py
│ └── __init__.py
├── tests
│ └── __init__.py
├── LICENSE.txt
├── README.md
└── pyproject.toml
"""
)
def test_no_project_name_enables_interactive(hatch, helpers, temp_dir):
project_name = "My.App"
description = "foo"
with temp_dir.as_cwd():
result = hatch("new", "-i", input=f"{project_name}\n{description}")
path = temp_dir / "my-app"
expected_files = helpers.get_template_files("new.default", project_name, description=description)
helpers.assert_files(path, expected_files)
assert result.exit_code == 0, result.output
assert remove_trailing_spaces(result.output) == helpers.dedent(
f"""
Project name: {project_name}
Description []: {description}
my-app
├── src
│ └── my_app
│ ├── __about__.py
│ └── __init__.py
├── tests
│ └── __init__.py
├── LICENSE.txt
├── README.md
└── pyproject.toml
"""
)
def test_initialize_fresh(hatch, helpers, temp_dir):
project_name = "My.App"
description = "foo"
with temp_dir.as_cwd():
result = hatch("new", project_name)
assert result.exit_code == 0, result.output
path = temp_dir / "my-app"
project_file = path / "pyproject.toml"
project_file.remove()
assert not project_file.is_file()
with path.as_cwd():
result = hatch("new", "--init", input=f"{project_name}\n{description}")
expected_files = helpers.get_template_files("new.default", project_name, description=description)
helpers.assert_files(path, expected_files)
assert result.exit_code == 0, result.output
assert remove_trailing_spaces(result.output) == helpers.dedent(
f"""
Project name: {project_name}
Description []: {description}
Wrote: pyproject.toml
"""
)
def test_initialize_update(hatch, helpers, temp_dir):
project_name = "My.App"
description = "foo"
project_file = temp_dir / "pyproject.toml"
project_file.write_text(
"""\
[build-system]
req = ["hatchling"]
build-backend = "build"
[project]
name = ""
description = ""
readme = "README.md"
requires-python = ">=3.8"
license = "MIT"
dynamic = ["version"]
[tool.hatch.version]
path = "o/__init__.py"
"""
)
with temp_dir.as_cwd():
result = hatch("new", "--init", input=f"{project_name}\n{description}")
assert result.exit_code == 0, result.output
assert remove_trailing_spaces(result.output) == helpers.dedent(
f"""
Project name: {project_name}
Description []: {description}
Updated: pyproject.toml
"""
)
assert len(list(temp_dir.iterdir())) == 1
assert project_file.read_text() == (
f"""\
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[project]
name = "my-app"
description = "{description}"
readme = "README.md"
requires-python = ">=3.8"
license = "MIT"
dynamic = ["version"]
[tool.hatch.version]
path = "my_app/__init__.py"
"""
)
def test_initialize_setup_cfg_only(hatch, helpers, temp_dir):
"""
Test initializing a project with a setup.cfg file only.
"""
setup_cfg_file = temp_dir / "setup.cfg"
setup_cfg_file.write_text(
"""\
[metadata]
name = testapp
version = attr:testapp.__version__
description = Foo
author = U.N. Owen
author_email = void@some.where
url = https://example.com
license = MIT
"""
)
with temp_dir.as_cwd():
result = hatch("new", "--init")
assert result.exit_code == 0, result.output
assert remove_trailing_spaces(result.output) == helpers.dedent(
"""
Migrating project metadata from setuptools
"""
)
project_file = temp_dir / "pyproject.toml"
assert project_file.read_text() == (
"""\
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[project]
name = "testapp"
dynamic = ["version"]
description = "Foo"
license = "MIT"
authors = [
{ name = "U.N. Owen", email = "void@some.where" },
]
[project.urls]
Homepage = "https://example.com"
[tool.hatch.version]
path = "testapp/__init__.py"
[tool.hatch.build.targets.sdist]
include = [
"/testapp",
]
"""
)
================================================
FILE: tests/cli/project/__init__.py
================================================
================================================
FILE: tests/cli/project/test_metadata.py
================================================
import os
import pytest
from hatch.config.constants import ConfigEnvVars
from hatch.project.core import Project
from hatchling.utils.constants import DEFAULT_CONFIG_FILE
pytestmark = [pytest.mark.usefixtures("mock_backend_process_output")]
def read_readme(project_dir):
return repr((project_dir / "README.txt").read_text())[1:-1]
def test_other_backend(hatch, temp_dir, helpers):
project_name = "My.App"
with temp_dir.as_cwd():
result = hatch("new", project_name)
assert result.exit_code == 0, result.output
path = temp_dir / "my-app"
data_path = temp_dir / "data"
data_path.mkdir()
(path / "README.md").replace(path / "README.txt")
project = Project(path)
config = dict(project.raw_config)
config["build-system"]["requires"] = ["flit-core==3.10.1"]
config["build-system"]["build-backend"] = "flit_core.buildapi"
config["project"]["version"] = "0.0.1"
config["project"]["dynamic"] = []
config["project"]["readme"] = "README.txt"
del config["project"]["license"]
project.save_config(config)
with path.as_cwd(env_vars={ConfigEnvVars.DATA: str(data_path)}):
result = hatch("project", "metadata")
assert result.exit_code == 0, result.output
assert result.output == helpers.dedent(
f"""
Creating environment: hatch-build
Checking dependencies
Syncing dependencies
Inspecting build dependencies
{{
"name": "my-app",
"version": "0.0.1",
"readme": {{
"content-type": "text/plain",
"text": "{read_readme(path)}\\n"
}},
"keywords": [
""
],
"classifiers": [
"Development Status :: 4 - Beta",
"Programming Language :: Python",
"Programming Language :: Python :: 3.8",
"Programming Language :: Python :: 3.9",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: Implementation :: CPython",
"Programming Language :: Python :: Implementation :: PyPy"
],
"urls": {{
"Documentation": "https://github.com/Foo Bar/my-app#readme",
"Issues": "https://github.com/Foo Bar/my-app/issues",
"Source": "https://github.com/Foo Bar/my-app"
}},
"authors": [
{{
"email": "foo@bar.baz",
"name": "Foo Bar"
}}
],
"requires-python": ">=3.8"
}}
"""
)
def test_default_all(hatch, temp_dir, helpers):
project_name = "My.App"
with temp_dir.as_cwd():
result = hatch("new", project_name)
assert result.exit_code == 0, result.output
path = temp_dir / "my-app"
data_path = temp_dir / "data"
data_path.mkdir()
(path / "README.md").replace(path / "README.txt")
project = Project(path)
config = dict(project.raw_config)
config["project"]["readme"] = "README.txt"
project.save_config(config)
with path.as_cwd(env_vars={ConfigEnvVars.DATA: str(data_path)}):
result = hatch("project", "metadata")
assert result.exit_code == 0, result.output
assert result.output == helpers.dedent(
f"""
Creating environment: hatch-build
Checking dependencies
Syncing dependencies
Inspecting build dependencies
{{
"name": "my-app",
"version": "0.0.1",
"readme": {{
"content-type": "text/plain",
"text": "{read_readme(path)}"
}},
"requires-python": ">=3.8",
"license": "MIT",
"authors": [
{{
"name": "Foo Bar",
"email": "foo@bar.baz"
}}
],
"classifiers": [
"Development Status :: 4 - Beta",
"Programming Language :: Python",
"Programming Language :: Python :: 3.8",
"Programming Language :: Python :: 3.9",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: Implementation :: CPython",
"Programming Language :: Python :: Implementation :: PyPy"
],
"urls": {{
"Documentation": "https://github.com/Foo Bar/my-app#readme",
"Issues": "https://github.com/Foo Bar/my-app/issues",
"Source": "https://github.com/Foo Bar/my-app"
}}
}}
"""
)
def test_field_readme(hatch, temp_dir):
project_name = "My.App"
with temp_dir.as_cwd():
result = hatch("new", project_name)
assert result.exit_code == 0, result.output
path = temp_dir / "my-app"
data_path = temp_dir / "data"
data_path.mkdir()
(path / "README.md").replace(path / "README.txt")
project = Project(path)
config = dict(project.raw_config)
config["project"]["readme"] = "README.txt"
project.save_config(config)
with path.as_cwd(env_vars={ConfigEnvVars.DATA: str(data_path)}):
result = hatch("project", "metadata", "readme")
assert result.exit_code == 0, result.output
assert result.output == (
f"""\
Creating environment: hatch-build
Checking dependencies
Syncing dependencies
Inspecting build dependencies
{(path / "README.txt").read_text()}
"""
)
def test_field_string(hatch, temp_dir, helpers):
project_name = "My.App"
with temp_dir.as_cwd():
result = hatch("new", project_name)
assert result.exit_code == 0, result.output
path = temp_dir / "my-app"
data_path = temp_dir / "data"
data_path.mkdir()
with path.as_cwd(env_vars={ConfigEnvVars.DATA: str(data_path)}):
result = hatch("project", "metadata", "license")
assert result.exit_code == 0, result.output
assert result.output == helpers.dedent(
"""
Creating environment: hatch-build
Checking dependencies
Syncing dependencies
Inspecting build dependencies
MIT
"""
)
def test_field_complex(hatch, temp_dir, helpers):
project_name = "My.App"
with temp_dir.as_cwd():
result = hatch("new", project_name)
assert result.exit_code == 0, result.output
path = temp_dir / "my-app"
data_path = temp_dir / "data"
data_path.mkdir()
with path.as_cwd(env_vars={ConfigEnvVars.DATA: str(data_path)}):
result = hatch("project", "metadata", "urls")
assert result.exit_code == 0, result.output
assert result.output == helpers.dedent(
"""
Creating environment: hatch-build
Checking dependencies
Syncing dependencies
Inspecting build dependencies
{
"Documentation": "https://github.com/Foo Bar/my-app#readme",
"Issues": "https://github.com/Foo Bar/my-app/issues",
"Source": "https://github.com/Foo Bar/my-app"
}
"""
)
@pytest.mark.allow_backend_process
def test_incompatible_environment(hatch, temp_dir, helpers, build_env_config):
project_name = "My.App"
with temp_dir.as_cwd():
result = hatch("new", project_name)
assert result.exit_code == 0, result.output
path = temp_dir / "my-app"
data_path = temp_dir / "data"
data_path.mkdir()
project = Project(path)
helpers.update_project_environment(project, "hatch-build", {"python": "9000", **build_env_config})
with path.as_cwd(env_vars={ConfigEnvVars.DATA: str(data_path)}):
result = hatch("project", "metadata")
assert result.exit_code == 1, result.output
assert result.output == helpers.dedent(
"""
Environment `hatch-build` is incompatible: cannot locate Python: 9000
"""
)
def test_plugin_dependencies_unmet(hatch, temp_dir, helpers, mock_plugin_installation):
project_name = "My.App"
with temp_dir.as_cwd():
result = hatch("new", project_name)
assert result.exit_code == 0, result.output
path = temp_dir / "my-app"
data_path = temp_dir / "data"
data_path.mkdir()
dependency = os.urandom(16).hex()
(path / DEFAULT_CONFIG_FILE).write_text(
helpers.dedent(
f"""
[env]
requires = ["{dependency}"]
"""
)
)
(path / "README.md").replace(path / "README.txt")
project = Project(path)
config = dict(project.raw_config)
config["project"]["readme"] = "README.txt"
project.save_config(config)
with path.as_cwd(env_vars={ConfigEnvVars.DATA: str(data_path)}):
result = hatch("project", "metadata")
assert result.exit_code == 0, result.output
assert result.output == helpers.dedent(
f"""
Syncing environment plugin requirements
Creating environment: hatch-build
Checking dependencies
Syncing dependencies
Inspecting build dependencies
{{
"name": "my-app",
"version": "0.0.1",
"readme": {{
"content-type": "text/plain",
"text": "{read_readme(path)}"
}},
"requires-python": ">=3.8",
"license": "MIT",
"authors": [
{{
"name": "Foo Bar",
"email": "foo@bar.baz"
}}
],
"classifiers": [
"Development Status :: 4 - Beta",
"Programming Language :: Python",
"Programming Language :: Python :: 3.8",
"Programming Language :: Python :: 3.9",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: Implementation :: CPython",
"Programming Language :: Python :: Implementation :: PyPy"
],
"urls": {{
"Documentation": "https://github.com/Foo Bar/my-app#readme",
"Issues": "https://github.com/Foo Bar/my-app/issues",
"Source": "https://github.com/Foo Bar/my-app"
}}
}}
"""
)
helpers.assert_plugin_installation(mock_plugin_installation, [dependency])
def test_build_dependencies_unmet(hatch, temp_dir, helpers):
project_name = "My.App"
with temp_dir.as_cwd():
result = hatch("new", project_name)
assert result.exit_code == 0, result.output
path = temp_dir / "my-app"
data_path = temp_dir / "data"
data_path.mkdir()
(path / "README.md").replace(path / "README.txt")
project = Project(path)
config = dict(project.raw_config)
config["project"]["readme"] = "README.txt"
config["tool"]["hatch"]["build"] = {"dependencies": ["binary"]}
project.save_config(config)
with path.as_cwd(env_vars={ConfigEnvVars.DATA: str(data_path)}):
result = hatch("project", "metadata", "license")
assert result.exit_code == 0, result.output
assert result.output == helpers.dedent(
"""
Creating environment: hatch-build
Checking dependencies
Syncing dependencies
Inspecting build dependencies
Syncing dependencies
MIT
"""
)
@pytest.mark.allow_backend_process
@pytest.mark.requires_internet
def test_no_compatibility_check_if_exists(hatch, temp_dir, helpers, mocker):
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()
project = Project(project_path)
config = dict(project.raw_config)
config["build-system"]["requires"].append("binary")
project.save_config(config)
with project_path.as_cwd(env_vars={ConfigEnvVars.DATA: str(data_path)}):
result = hatch("project", "metadata", "license")
assert result.exit_code == 0, result.output
assert result.output == helpers.dedent(
"""
Creating environment: hatch-build
Checking dependencies
Syncing dependencies
Inspecting build dependencies
MIT
"""
)
mocker.patch("hatch.env.virtual.VirtualEnvironment.check_compatibility", side_effect=Exception("incompatible"))
with project_path.as_cwd(env_vars={ConfigEnvVars.DATA: str(data_path)}):
result = hatch("project", "metadata", "license")
assert result.exit_code == 0, result.output
assert result.output == helpers.dedent(
"""
Inspecting build dependencies
MIT
"""
)
================================================
FILE: tests/cli/publish/__init__.py
================================================
================================================
FILE: tests/cli/publish/test_publish.py
================================================
import os
import secrets
import tarfile
import zipfile
from collections import defaultdict
import pytest
from hatch.config.constants import PublishEnvVars
pytestmark = [
pytest.mark.requires_docker,
pytest.mark.requires_internet,
pytest.mark.usefixtures("devpi"),
pytest.mark.usefixtures("mock_backend_process"),
]
@pytest.fixture(autouse=True)
def keyring_store(mocker):
mock_store = defaultdict(dict)
mocker.patch(
"keyring.get_password",
side_effect=lambda system, user: mock_store[system].get(user),
)
mocker.patch(
"keyring.set_password",
side_effect=lambda system, user, auth: mock_store[system].__setitem__(user, auth),
)
return mock_store
@pytest.fixture
def published_project_name():
return f"c4880cdbe05de9a28415fbad{secrets.choice(range(100))}"
def remove_metadata_field(field: str, metadata_file_contents: str):
lines = metadata_file_contents.splitlines(True)
field_marker = f"{field}: "
indices_to_remove = []
for i, line in enumerate(lines):
if line.lower().startswith(field_marker):
indices_to_remove.append(i)
for i, index in enumerate(indices_to_remove):
del lines[index - i]
return "".join(lines)
def timestamp_to_version(timestamp):
major, minor = str(timestamp).split(".")
if minor.startswith("0"):
normalized_minor = str(int(minor))
padding = ".".join("0" for _ in range(len(minor) - len(normalized_minor)))
return f"{major}.{padding}.{normalized_minor}"
return f"{major}.{minor}"
def test_timestamp_to_version():
assert timestamp_to_version(123.4) == "123.4"
assert timestamp_to_version(123.04) == "123.0.4"
assert timestamp_to_version(123.004) == "123.0.0.4"
def test_explicit_options(hatch, temp_dir):
project_name = "My.App"
with temp_dir.as_cwd():
result = hatch("new", project_name)
assert result.exit_code == 0, result.output
path = temp_dir / "my-app"
with path.as_cwd():
result = hatch("publish", "-o", "foo=bar")
assert result.exit_code == 1, result.output
assert result.output == (
"Use the standard CLI flags rather than passing explicit options when using the `index` plugin\n"
)
def test_unknown_publisher(hatch, temp_dir):
project_name = "My.App"
with temp_dir.as_cwd():
result = hatch("new", project_name)
assert result.exit_code == 0, result.output
path = temp_dir / "my-app"
with path.as_cwd():
result = hatch("publish", "-p", "foo")
assert result.exit_code == 1, result.output
assert result.output == "Unknown publisher: foo\n"
def test_disabled(hatch, temp_dir, config_file):
config_file.model.publish["index"]["disable"] = True
config_file.save()
project_name = "My.App"
with temp_dir.as_cwd():
result = hatch("new", project_name)
assert result.exit_code == 0, result.output
path = temp_dir / "my-app"
with path.as_cwd():
result = hatch("publish", "-n")
assert result.exit_code == 1, result.output
assert result.output == "Publisher is disabled: index\n"
def test_repo_invalid_type(hatch, temp_dir, config_file):
config_file.model.publish["index"]["repos"] = {"dev": 9000}
config_file.save()
project_name = "My.App"
with temp_dir.as_cwd():
result = hatch("new", project_name)
assert result.exit_code == 0, result.output
path = temp_dir / "my-app"
with path.as_cwd():
result = hatch("publish", "--user", "foo", "--auth", "bar")
assert result.exit_code == 1, result.output
assert result.output == "Hatch config field `publish.index.repos.dev` must be a string or a mapping\n"
def test_repo_missing_url(hatch, temp_dir, config_file):
config_file.model.publish["index"]["repos"] = {"dev": {}}
config_file.save()
project_name = "My.App"
with temp_dir.as_cwd():
result = hatch("new", project_name)
assert result.exit_code == 0, result.output
path = temp_dir / "my-app"
with path.as_cwd():
result = hatch("publish", "--user", "foo", "--auth", "bar")
assert result.exit_code == 1, result.output
assert result.output == "Hatch config field `publish.index.repos.dev` must define a `url` key\n"
def test_missing_user(hatch, temp_dir):
project_name = "My.App"
with temp_dir.as_cwd():
result = hatch("new", project_name)
assert result.exit_code == 0, result.output
path = temp_dir / "my-app"
with path.as_cwd():
result = hatch("publish", "-n")
assert result.exit_code == 1, result.output
assert result.output == "Missing required option: user\n"
def test_missing_auth(hatch, temp_dir):
project_name = "My.App"
with temp_dir.as_cwd():
result = hatch("new", project_name)
assert result.exit_code == 0, result.output
path = temp_dir / "my-app"
with path.as_cwd():
result = hatch("publish", "-n", "--user", "foo")
assert result.exit_code == 1, result.output
assert result.output == "Missing required option: auth\n"
def test_flags(hatch, devpi, temp_dir_cache, helpers, published_project_name):
with temp_dir_cache.as_cwd():
result = hatch("new", published_project_name)
assert result.exit_code == 0, result.output
path = temp_dir_cache / published_project_name
with path.as_cwd():
current_version = timestamp_to_version(helpers.get_current_timestamp())
result = hatch("version", current_version)
assert result.exit_code == 0, result.output
result = hatch("build")
assert result.exit_code == 0, result.output
build_directory = path / "dist"
artifacts = list(build_directory.iterdir())
result = hatch(
"publish", "--repo", devpi.repo, "--user", devpi.user, "--auth", devpi.auth, "--ca-cert", devpi.ca_cert
)
assert result.exit_code == 0, result.output
assert result.output == helpers.dedent(
f"""
{artifacts[0].relative_to(path)} ... success
{artifacts[1].relative_to(path)} ... success
[{published_project_name}]
{devpi.repo}{published_project_name}/{current_version}/
"""
)
def test_plugin_config(hatch, devpi, temp_dir_cache, helpers, published_project_name, config_file):
config_file.model.publish["index"]["user"] = devpi.user
config_file.model.publish["index"]["auth"] = devpi.auth
config_file.model.publish["index"]["ca-cert"] = devpi.ca_cert
config_file.model.publish["index"]["repo"] = "dev"
config_file.model.publish["index"]["repos"] = {"dev": devpi.repo}
config_file.save()
with temp_dir_cache.as_cwd():
result = hatch("new", published_project_name)
assert result.exit_code == 0, result.output
path = temp_dir_cache / published_project_name
with path.as_cwd():
del os.environ[PublishEnvVars.REPO]
current_version = timestamp_to_version(helpers.get_current_timestamp())
result = hatch("version", current_version)
assert result.exit_code == 0, result.output
result = hatch("build")
assert result.exit_code == 0, result.output
build_directory = path / "dist"
artifacts = list(build_directory.iterdir())
result = hatch("publish")
assert result.exit_code == 0, result.output
assert result.output == helpers.dedent(
f"""
{artifacts[0].relative_to(path)} ... success
{artifacts[1].relative_to(path)} ... success
[{published_project_name}]
{devpi.repo}{published_project_name}/{current_version}/
"""
)
def test_plugin_config_repo_override(hatch, devpi, temp_dir_cache, helpers, published_project_name, config_file):
config_file.model.publish["index"]["user"] = "foo"
config_file.model.publish["index"]["auth"] = "bar"
config_file.model.publish["index"]["ca-cert"] = "cert"
config_file.model.publish["index"]["repo"] = "dev"
config_file.model.publish["index"]["repos"] = {
"dev": {"url": devpi.repo, "user": devpi.user, "auth": devpi.auth, "ca-cert": devpi.ca_cert},
}
config_file.save()
with temp_dir_cache.as_cwd():
result = hatch("new", published_project_name)
assert result.exit_code == 0, result.output
path = temp_dir_cache / published_project_name
with path.as_cwd():
del os.environ[PublishEnvVars.REPO]
current_version = timestamp_to_version(helpers.get_current_timestamp())
result = hatch("version", current_version)
assert result.exit_code == 0, result.output
result = hatch("build")
assert result.exit_code == 0, result.output
build_directory = path / "dist"
artifacts = list(build_directory.iterdir())
result = hatch("publish")
assert result.exit_code == 0, result.output
assert result.output == helpers.dedent(
f"""
{artifacts[0].relative_to(path)} ... success
{artifacts[1].relative_to(path)} ... success
[{published_project_name}]
{devpi.repo}{published_project_name}/{current_version}/
"""
)
def test_prompt(hatch, devpi, temp_dir_cache, helpers, published_project_name, config_file):
config_file.model.publish["index"]["ca-cert"] = devpi.ca_cert
config_file.model.publish["index"]["repo"] = "dev"
config_file.model.publish["index"]["repos"] = {"dev": devpi.repo}
config_file.save()
with temp_dir_cache.as_cwd():
result = hatch("new", published_project_name)
assert result.exit_code == 0, result.output
path = temp_dir_cache / published_project_name
with path.as_cwd():
current_version = timestamp_to_version(helpers.get_current_timestamp())
result = hatch("version", current_version)
assert result.exit_code == 0, result.output
result = hatch("build")
assert result.exit_code == 0, result.output
build_directory = path / "dist"
artifacts = list(build_directory.iterdir())
result = hatch("publish", input=f"{devpi.user}\nfoo")
assert result.exit_code == 1, result.output
assert "401" in result.output
assert "Unauthorized" in result.output
# Ensure nothing is saved for errors
with path.as_cwd():
result = hatch("publish", "-n")
assert result.exit_code == 1, result.output
assert result.output == "Missing required option: user\n"
# Trigger save
with path.as_cwd():
result = hatch("publish", str(artifacts[0]), input=f"{devpi.user}\n{devpi.auth}")
assert result.exit_code == 0, result.output
assert result.output == helpers.dedent(
f"""
Username for '{devpi.repo}' [__token__]: {devpi.user}
Password / Token:{" "}
{artifacts[0].relative_to(path)} ... success
[{published_project_name}]
{devpi.repo}{published_project_name}/{current_version}/
"""
)
# Use saved results
with path.as_cwd():
result = hatch("publish", str(artifacts[1]))
assert result.exit_code == 0, result.output
assert result.output == helpers.dedent(
f"""
{artifacts[1].relative_to(path)} ... success
[{published_project_name}]
{devpi.repo}{published_project_name}/{current_version}/
"""
)
def test_initialize_auth(hatch, devpi, temp_dir_cache, helpers, published_project_name, config_file):
config_file.model.publish["index"]["ca-cert"] = devpi.ca_cert
config_file.model.publish["index"]["repo"] = "dev"
config_file.model.publish["index"]["repos"] = {"dev": devpi.repo}
config_file.save()
with temp_dir_cache.as_cwd():
result = hatch("new", published_project_name)
assert result.exit_code == 0, result.output
path = temp_dir_cache / published_project_name
# Trigger save
with path.as_cwd():
result = hatch("publish", "--initialize-auth", input=f"{devpi.user}\n{devpi.auth}")
assert result.exit_code == 0, result.output
assert result.output == helpers.dedent(
f"""
Username for '{devpi.repo}' [__token__]: {devpi.user}
Password / Token:{" "}
"""
)
with path.as_cwd():
current_version = timestamp_to_version(helpers.get_current_timestamp())
result = hatch("version", current_version)
assert result.exit_code == 0, result.output
result = hatch("build", "-t", "wheel")
assert result.exit_code == 0, result.output
build_directory = path / "dist"
artifacts = list(build_directory.iterdir())
# Use saved results
with path.as_cwd():
result = hatch("publish", str(artifacts[0]))
assert result.exit_code == 0, result.output
assert result.output == helpers.dedent(
f"""
{artifacts[0].relative_to(path)} ... success
[{published_project_name}]
{devpi.repo}{published_project_name}/{current_version}/
"""
)
def test_external_artifact_path(hatch, devpi, temp_dir_cache, helpers, published_project_name, config_file):
config_file.model.publish["index"]["ca-cert"] = devpi.ca_cert
config_file.model.publish["index"]["repo"] = "dev"
config_file.model.publish["index"]["repos"] = {"dev": devpi.repo}
config_file.save()
with temp_dir_cache.as_cwd():
result = hatch("new", published_project_name)
assert result.exit_code == 0, result.output
path = temp_dir_cache / published_project_name
external_build_directory = temp_dir_cache / "dist"
with path.as_cwd():
current_version = timestamp_to_version(helpers.get_current_timestamp())
result = hatch("version", current_version)
assert result.exit_code == 0, result.output
result = hatch("build", "-t", "sdist", str(external_build_directory))
assert result.exit_code == 0, result.output
external_artifacts = list(external_build_directory.iterdir())
result = hatch("build", "-t", "wheel")
assert result.exit_code == 0, result.output
internal_build_directory = path / "dist"
internal_artifacts = list(internal_build_directory.iterdir())
result = hatch("publish", "--user", devpi.user, "--auth", devpi.auth, "dist", str(external_build_directory))
assert result.exit_code == 0, result.output
assert result.output == helpers.dedent(
f"""
{internal_artifacts[0].relative_to(path)} ... success
{external_artifacts[0]} ... success
[{published_project_name}]
{devpi.repo}{published_project_name}/{current_version}/
"""
)
def test_already_exists(hatch, devpi, temp_dir_cache, helpers, published_project_name, config_file):
config_file.model.publish["index"]["ca-cert"] = devpi.ca_cert
config_file.model.publish["index"]["repo"] = "dev"
config_file.model.publish["index"]["repos"] = {"dev": devpi.repo}
config_file.save()
with temp_dir_cache.as_cwd():
result = hatch("new", published_project_name)
assert result.exit_code == 0, result.output
path = temp_dir_cache / published_project_name
with path.as_cwd():
current_version = timestamp_to_version(helpers.get_current_timestamp())
result = hatch("version", current_version)
assert result.exit_code == 0, result.output
result = hatch("build")
assert result.exit_code == 0, result.output
build_directory = path / "dist"
artifacts = list(build_directory.iterdir())
result = hatch("publish", "--user", devpi.user, "--auth", devpi.auth)
assert result.exit_code == 0, result.output
assert result.output == helpers.dedent(
f"""
{artifacts[0].relative_to(path)} ... success
{artifacts[1].relative_to(path)} ... success
[{published_project_name}]
{devpi.repo}{published_project_name}/{current_version}/
"""
)
with path.as_cwd():
result = hatch("publish", "--user", devpi.user, "--auth", devpi.auth)
assert result.exit_code == 0, result.output
assert result.output == helpers.dedent(
f"""
{artifacts[0].relative_to(path)} ... already exists
{artifacts[1].relative_to(path)} ... already exists
"""
)
def test_no_artifacts(hatch, temp_dir_cache, helpers, published_project_name):
with temp_dir_cache.as_cwd():
result = hatch("new", published_project_name)
assert result.exit_code == 0, result.output
path = temp_dir_cache / published_project_name
with path.as_cwd():
directory = path / "dir2"
directory.mkdir()
(directory / "test.txt").touch()
result = hatch("publish", "dir1", "dir2", "--user", "foo", "--auth", "bar")
assert result.exit_code == 1, result.output
assert result.output == helpers.dedent(
"""
No artifacts found
"""
)
def test_enable_with_flag(hatch, devpi, temp_dir_cache, helpers, published_project_name, config_file):
config_file.model.publish["index"]["user"] = devpi.user
config_file.model.publish["index"]["auth"] = devpi.auth
config_file.model.publish["index"]["ca-cert"] = devpi.ca_cert
config_file.model.publish["index"]["repo"] = "dev"
config_file.model.publish["index"]["repos"] = {"dev": devpi.repo}
config_file.model.publish["index"]["disable"] = True
config_file.save()
with temp_dir_cache.as_cwd():
result = hatch("new", published_project_name)
assert result.exit_code == 0, result.output
path = temp_dir_cache / published_project_name
with path.as_cwd():
del os.environ[PublishEnvVars.REPO]
current_version = timestamp_to_version(helpers.get_current_timestamp())
result = hatch("version", current_version)
assert result.exit_code == 0, result.output
result = hatch("build")
assert result.exit_code == 0, result.output
build_directory = path / "dist"
artifacts = list(build_directory.iterdir())
result = hatch("publish", "-y")
assert result.exit_code == 0, result.output
assert result.output == helpers.dedent(
f"""
{artifacts[0].relative_to(path)} ... success
{artifacts[1].relative_to(path)} ... success
[{published_project_name}]
{devpi.repo}{published_project_name}/{current_version}/
"""
)
def test_enable_with_prompt(hatch, devpi, temp_dir_cache, helpers, published_project_name, config_file):
config_file.model.publish["index"]["user"] = devpi.user
config_file.model.publish["index"]["auth"] = devpi.auth
config_file.model.publish["index"]["ca-cert"] = devpi.ca_cert
config_file.model.publish["index"]["repo"] = "dev"
config_file.model.publish["index"]["repos"] = {"dev": devpi.repo}
config_file.model.publish["index"]["disable"] = True
config_file.save()
with temp_dir_cache.as_cwd():
result = hatch("new", published_project_name)
assert result.exit_code == 0, result.output
path = temp_dir_cache / published_project_name
with path.as_cwd():
del os.environ[PublishEnvVars.REPO]
current_version = timestamp_to_version(helpers.get_current_timestamp())
result = hatch("version", current_version)
assert result.exit_code == 0, result.output
result = hatch("build")
assert result.exit_code == 0, result.output
build_directory = path / "dist"
artifacts = list(build_directory.iterdir())
result = hatch("publish", input="y\n")
assert result.exit_code == 0, result.output
assert result.output == helpers.dedent(
f"""
Confirm `index` publishing [y/N]: y
{artifacts[0].relative_to(path)} ... success
{artifacts[1].relative_to(path)} ... success
[{published_project_name}]
{devpi.repo}{published_project_name}/{current_version}/
"""
)
class TestWheel:
@pytest.mark.parametrize("field", ["name", "version"])
def test_missing_required_metadata_field(self, hatch, temp_dir_cache, helpers, published_project_name, field):
with temp_dir_cache.as_cwd():
result = hatch("new", published_project_name)
assert result.exit_code == 0, result.output
path = temp_dir_cache / published_project_name
with path.as_cwd():
current_version = timestamp_to_version(helpers.get_current_timestamp())
result = hatch("version", current_version)
assert result.exit_code == 0, result.output
result = hatch("build", "-t", "wheel")
assert result.exit_code == 0, result.output
build_directory = path / "dist"
artifacts = list(build_directory.iterdir())
artifact_path = str(artifacts[0])
metadata_file_path = f"{published_project_name}-{current_version}.dist-info/METADATA"
with zipfile.ZipFile(artifact_path, "r") as zip_archive, zip_archive.open(metadata_file_path) as metadata_file:
metadata_file_contents = metadata_file.read().decode("utf-8")
with (
zipfile.ZipFile(artifact_path, "w") as zip_archive,
zip_archive.open(metadata_file_path, "w") as metadata_file,
):
metadata_file.write(remove_metadata_field(field, metadata_file_contents).encode("utf-8"))
with path.as_cwd():
result = hatch("publish", "--user", "foo", "--auth", "bar")
assert result.exit_code == 1, result.output
assert result.output == helpers.dedent(
f"""
Missing required field `{field}` in artifact: {artifact_path}
"""
)
class TestSourceDistribution:
@pytest.mark.parametrize("field", ["name", "version"])
def test_missing_required_metadata_field(self, hatch, temp_dir_cache, helpers, published_project_name, field):
with temp_dir_cache.as_cwd():
result = hatch("new", published_project_name)
assert result.exit_code == 0, result.output
path = temp_dir_cache / published_project_name
with path.as_cwd():
current_version = timestamp_to_version(helpers.get_current_timestamp())
result = hatch("version", current_version)
assert result.exit_code == 0, result.output
result = hatch("build", "-t", "sdist")
assert result.exit_code == 0, result.output
build_directory = path / "dist"
artifacts = list(build_directory.iterdir())
artifact_path = str(artifacts[0])
extraction_directory = path / "extraction"
with tarfile.open(artifact_path, "r:gz") as tar_archive:
tar_archive.extractall(extraction_directory, **helpers.tarfile_extraction_compat_options())
metadata_file_path = extraction_directory / f"{published_project_name}-{current_version}" / "PKG-INFO"
metadata_file_path.write_text(remove_metadata_field(field, metadata_file_path.read_text()))
with tarfile.open(artifact_path, "w:gz") as tar_archive:
tar_archive.add(extraction_directory, arcname="")
with path.as_cwd():
result = hatch("publish", "--user", "foo", "--auth", "bar")
assert result.exit_code == 1, result.output
assert result.output == helpers.dedent(
f"""
Missing required field `{field}` in artifact: {artifact_path}
"""
)
================================================
FILE: tests/cli/python/__init__.py
================================================
================================================
FILE: tests/cli/python/conftest.py
================================================
import secrets
import pytest
from hatch.utils.shells import detect_shell
@pytest.fixture(autouse=True)
def default_shells(platform):
return [] if platform.windows else [detect_shell(platform)[0]]
@pytest.fixture(autouse=True)
def isolated_python_directory(config_file):
config_file.model.dirs.python = "isolated"
config_file.save()
@pytest.fixture(autouse=True)
def path_append(mocker):
return mocker.patch("userpath.append")
@pytest.fixture(autouse=True)
def disable_path_detectors(mocker):
mocker.patch("userpath.in_current_path", return_value=False)
mocker.patch("userpath.in_new_path", return_value=False)
@pytest.fixture
def dist_name(compatible_python_distributions):
return secrets.choice(compatible_python_distributions)
================================================
FILE: tests/cli/python/test_find.py
================================================
def test_not_installed(hatch, helpers):
name = "3.10"
result = hatch("python", "find", name)
assert result.exit_code == 1, result.output
assert result.output == helpers.dedent(
f"""
Distribution not installed: {name}
"""
)
def test_binary(hatch, helpers, temp_dir_data, dist_name):
install_dir = temp_dir_data / "data" / "pythons"
dist = helpers.write_distribution(install_dir, dist_name)
result = hatch("python", "find", dist_name)
assert result.exit_code == 0, result.output
assert result.output == helpers.dedent(
f"""
{dist.python_path}
"""
)
def test_parent(hatch, helpers, temp_dir_data, dist_name):
install_dir = temp_dir_data / "data" / "pythons"
dist = helpers.write_distribution(install_dir, dist_name)
result = hatch("python", "find", dist_name, "--parent")
assert result.exit_code == 0, result.output
assert result.output == helpers.dedent(
f"""
{dist.python_path.parent}
"""
)
================================================
FILE: tests/cli/python/test_install.py
================================================
import json
import secrets
import pytest
from hatch.errors import PythonDistributionResolutionError
from hatch.python.core import InstalledDistribution
from hatch.python.distributions import ORDERED_DISTRIBUTIONS
from hatch.python.resolve import get_distribution
def test_unknown(hatch, helpers, path_append, mocker):
install = mocker.patch("hatch.python.core.PythonManager.install")
result = hatch("python", "install", "foo", "bar")
assert result.exit_code == 1, result.output
assert result.output == helpers.dedent(
"""
Unknown distributions: foo, bar
"""
)
install.assert_not_called()
path_append.assert_not_called()
def test_incompatible_single(hatch, helpers, path_append, dist_name, mocker):
mocker.patch("hatch.python.resolve.get_distribution", side_effect=PythonDistributionResolutionError)
install = mocker.patch("hatch.python.core.PythonManager.install")
result = hatch("python", "install", dist_name)
assert result.exit_code == 1, result.output
assert result.output == helpers.dedent(
f"""
Incompatible distributions: {dist_name}
"""
)
install.assert_not_called()
path_append.assert_not_called()
def test_incompatible_all(hatch, helpers, path_append, mocker):
mocker.patch("hatch.python.resolve.get_distribution", side_effect=PythonDistributionResolutionError)
install = mocker.patch("hatch.python.core.PythonManager.install")
result = hatch("python", "install", "all")
assert result.exit_code == 1, result.output
assert result.output == helpers.dedent(
f"""
Incompatible distributions: {", ".join(ORDERED_DISTRIBUTIONS)}
"""
)
install.assert_not_called()
path_append.assert_not_called()
@pytest.mark.requires_internet
def test_installation(
hatch, helpers, temp_dir_data, platform, path_append, default_shells, compatible_python_distributions
):
selection = [name for name in compatible_python_distributions if not name.startswith("pypy")]
dist_name = secrets.choice(selection)
result = hatch("python", "install", dist_name)
install_dir = temp_dir_data / "data" / "pythons" / dist_name
metadata_file = install_dir / InstalledDistribution.metadata_filename()
python_path = install_dir / json.loads(metadata_file.read_text())["python_path"]
assert result.exit_code == 0, result.output
assert result.output == helpers.dedent(
f"""
Installing {dist_name}
Installed {dist_name} @ {install_dir}
The following directory has been added to your PATH (pending a shell restart):
{python_path.parent}
"""
)
assert python_path.is_file()
output = platform.check_command_output([python_path, "-c", "import sys;print(sys.executable)"]).strip()
assert output == str(python_path)
output = platform.check_command_output([python_path, "--version"]).strip()
assert output.startswith(f"Python {dist_name}.")
path_append.assert_called_once_with(str(python_path.parent), shells=default_shells)
def test_already_installed_latest(hatch, helpers, temp_dir_data, path_append, dist_name, mocker):
install = mocker.patch("hatch.python.core.PythonManager.install")
install_dir = temp_dir_data / "data" / "pythons"
installed_dist = helpers.write_distribution(install_dir, dist_name)
result = hatch("python", "install", dist_name)
assert result.exit_code == 0, result.output
assert result.output == helpers.dedent(
f"""
The latest version is already installed: {installed_dist.version}
"""
)
install.assert_not_called()
path_append.assert_not_called()
def test_already_installed_update_disabled(hatch, helpers, temp_dir_data, path_append, dist_name, mocker):
install = mocker.patch("hatch.python.core.PythonManager.install")
install_dir = temp_dir_data / "data" / "pythons"
helpers.write_distribution(install_dir, dist_name)
helpers.downgrade_distribution_metadata(install_dir / dist_name)
result = hatch("python", "install", dist_name, input="n\n")
assert result.exit_code == 1, result.output
assert result.output == helpers.dedent(
f"""
Update {dist_name}? [y/N]: n
Distribution is already installed: {dist_name}
"""
)
install.assert_not_called()
path_append.assert_not_called()
def test_already_installed_update_prompt(hatch, helpers, temp_dir_data, path_append, default_shells, dist_name, mocker):
install_dir = temp_dir_data / "data" / "pythons"
helpers.write_distribution(install_dir, dist_name)
dist_dir = install_dir / dist_name
metadata = helpers.downgrade_distribution_metadata(dist_dir)
python_path = dist_dir / metadata["python_path"]
install = mocker.patch(
"hatch.python.core.PythonManager.install", return_value=mocker.MagicMock(path=dist_dir, python_path=python_path)
)
result = hatch("python", "install", dist_name, input="y\n")
assert result.exit_code == 0, result.output
assert result.output == helpers.dedent(
f"""
Update {dist_name}? [y/N]: y
Updating {dist_name}
Updated {dist_name} @ {dist_dir}
The following directory has been added to your PATH (pending a shell restart):
{python_path.parent}
"""
)
install.assert_called_once_with(dist_name)
path_append.assert_called_once_with(str(python_path.parent), shells=default_shells)
def test_already_installed_update_flag(hatch, helpers, temp_dir_data, path_append, default_shells, dist_name, mocker):
install_dir = temp_dir_data / "data" / "pythons"
helpers.write_distribution(install_dir, dist_name)
dist_dir = install_dir / dist_name
metadata = helpers.downgrade_distribution_metadata(dist_dir)
python_path = dist_dir / metadata["python_path"]
install = mocker.patch(
"hatch.python.core.PythonManager.install", return_value=mocker.MagicMock(path=dist_dir, python_path=python_path)
)
result = hatch("python", "install", "--update", dist_name)
assert result.exit_code == 0, result.output
assert result.output == helpers.dedent(
f"""
Updating {dist_name}
Updated {dist_name} @ {dist_dir}
The following directory has been added to your PATH (pending a shell restart):
{python_path.parent}
"""
)
install.assert_called_once_with(dist_name)
path_append.assert_called_once_with(str(python_path.parent), shells=default_shells)
@pytest.mark.parametrize("detector", ["in_current_path", "in_new_path"])
def test_already_in_path(hatch, helpers, temp_dir_data, path_append, mocker, detector, dist_name):
mocker.patch(f"userpath.{detector}", return_value=True)
dist_dir = temp_dir_data / "data" / "pythons" / dist_name
python_path = dist_dir / get_distribution(dist_name).python_path
install = mocker.patch(
"hatch.python.core.PythonManager.install", return_value=mocker.MagicMock(path=dist_dir, python_path=python_path)
)
result = hatch("python", "install", dist_name)
assert result.exit_code == 0, result.output
assert result.output == helpers.dedent(
f"""
Installing {dist_name}
Installed {dist_name} @ {dist_dir}
"""
)
install.assert_called_once_with(dist_name)
path_append.assert_not_called()
def test_private(hatch, helpers, temp_dir_data, path_append, dist_name, mocker):
dist_dir = temp_dir_data / "data" / "pythons" / dist_name
python_path = dist_dir / get_distribution(dist_name).python_path
install = mocker.patch(
"hatch.python.core.PythonManager.install", return_value=mocker.MagicMock(path=dist_dir, python_path=python_path)
)
result = hatch("python", "install", "--private", dist_name)
assert result.exit_code == 0, result.output
assert result.output == helpers.dedent(
f"""
Installing {dist_name}
Installed {dist_name} @ {dist_dir}
"""
)
install.assert_called_once_with(dist_name)
path_append.assert_not_called()
def test_specific_location(hatch, helpers, temp_dir_data, path_append, dist_name, mocker):
install_dir = temp_dir_data / "foo" / "bar" / "baz"
dist_dir = install_dir / dist_name
python_path = dist_dir / get_distribution(dist_name).python_path
install = mocker.patch(
"hatch.python.core.PythonManager.install", return_value=mocker.MagicMock(path=dist_dir, python_path=python_path)
)
result = hatch("python", "install", "--private", "-d", str(install_dir), dist_name)
assert result.exit_code == 0, result.output
assert result.output == helpers.dedent(
f"""
Installing {dist_name}
Installed {dist_name} @ {dist_dir}
"""
)
install.assert_called_once_with(dist_name)
path_append.assert_not_called()
def test_all(hatch, temp_dir_data, path_append, default_shells, mocker, compatible_python_distributions):
mocked_dists = []
for name in compatible_python_distributions:
dist_dir = temp_dir_data / "data" / "pythons" / name
python_path = dist_dir / get_distribution(name).python_path
mocked_dists.append(mocker.MagicMock(path=dist_dir, python_path=python_path))
install = mocker.patch("hatch.python.core.PythonManager.install", side_effect=mocked_dists)
result = hatch("python", "install", "all")
assert result.exit_code == 0, result.output
expected_lines = []
for dist in mocked_dists:
expected_lines.extend((f"Installing {dist.path.name}", f"Installed {dist.path.name} @ {dist.path}"))
expected_lines.extend((
"",
"The following directories have been added to your PATH (pending a shell restart):",
"",
))
expected_lines.extend(str(dist.python_path.parent) for dist in mocked_dists)
expected_lines.append("")
assert result.output == "\n".join(expected_lines)
assert install.call_args_list == [mocker.call(name) for name in compatible_python_distributions]
assert path_append.call_args_list == [
mocker.call(str(dist.python_path.parent), shells=default_shells) for dist in mocked_dists
]
================================================
FILE: tests/cli/python/test_remove.py
================================================
def test_not_installed(hatch, helpers):
result = hatch("python", "remove", "3.9", "3.10")
assert result.exit_code == 0, result.output
assert result.output == helpers.dedent(
"""
Distribution is not installed: 3.9
Distribution is not installed: 3.10
"""
)
def test_basic(hatch, helpers, temp_dir_data):
install_dir = temp_dir_data / "data" / "pythons"
for name in ("3.9", "3.10"):
helpers.write_distribution(install_dir, name)
result = hatch("python", "remove", "3.9")
assert result.exit_code == 0, result.output
assert result.output == helpers.dedent(
"""
Removing 3.9
"""
)
assert not (install_dir / "3.9").exists()
assert (install_dir / "3.10").is_dir()
def test_specific_location(hatch, helpers, temp_dir_data, dist_name):
install_dir = temp_dir_data / "foo" / "bar" / "baz"
helpers.write_distribution(install_dir, dist_name)
result = hatch("python", "remove", "-d", str(install_dir), dist_name)
assert result.exit_code == 0, result.output
assert result.output == helpers.dedent(
f"""
Removing {dist_name}
"""
)
assert not any(install_dir.iterdir())
def test_all(hatch, helpers, temp_dir_data):
installed_distributions = ("3.9", "3.10", "3.11")
for name in installed_distributions:
install_dir = temp_dir_data / "data" / "pythons"
helpers.write_distribution(install_dir, name)
result = hatch("python", "remove", "all")
assert result.exit_code == 0, result.output
expected_lines = [f"Removing {name}" for name in installed_distributions]
expected_lines.append("")
assert result.output == "\n".join(expected_lines)
================================================
FILE: tests/cli/python/test_show.py
================================================
from rich.box import ASCII_DOUBLE_HEAD
from rich.console import Console
from rich.table import Table
from hatch.python.resolve import get_compatible_distributions
def render_table(title, rows):
console = Console(force_terminal=False, no_color=True, legacy_windows=False)
table = Table(title=title, show_lines=True, title_style="", box=ASCII_DOUBLE_HEAD, safe_box=True)
for column in rows[0]:
table.add_column(column, style="bold")
for row in rows[1:]:
table.add_row(*row)
with console.capture() as capture:
console.print(table, overflow="ignore", no_wrap=True, crop=False)
return capture.get()
def test_nothing_installed(hatch):
compatible_distributions = get_compatible_distributions()
available_table = render_table(
"Available",
[
["Name", "Version"],
*[[d.name, d.version.base_version] for d in compatible_distributions.values()],
],
)
result = hatch("python", "show", "--ascii")
assert result.exit_code == 0, result.output
assert result.output == available_table
def test_some_installed(hatch, helpers, temp_dir_data, dist_name):
install_dir = temp_dir_data / "data" / "pythons"
helpers.write_distribution(install_dir, dist_name)
compatible_distributions = get_compatible_distributions()
installed_distribution = compatible_distributions.pop(dist_name)
installed_table = render_table(
"Installed",
[
["Name", "Version"],
[dist_name, installed_distribution.version.base_version],
],
)
available_table = render_table(
"Available",
[
["Name", "Version"],
*[[d.name, d.version.base_version] for d in compatible_distributions.values()],
],
)
result = hatch("python", "show", "--ascii")
assert result.exit_code == 0, result.output
assert result.output == installed_table + available_table
def test_all_installed(hatch, helpers, temp_dir_data):
install_dir = temp_dir_data / "data" / "pythons"
compatible_distributions = get_compatible_distributions()
for dist_name in compatible_distributions:
helpers.write_distribution(install_dir, dist_name)
installed_table = render_table(
"Installed",
[
["Name", "Version"],
*[[d.name, d.version.base_version] for d in compatible_distributions.values()],
],
)
result = hatch("python", "show", "--ascii")
assert result.exit_code == 0, result.output
assert result.output == installed_table
def test_specific_location(hatch, helpers, temp_dir_data, dist_name):
install_dir = temp_dir_data / "foo" / "bar" / "baz"
helpers.write_distribution(install_dir, dist_name)
compatible_distributions = get_compatible_distributions()
installed_distribution = compatible_distributions.pop(dist_name)
installed_table = render_table(
"Installed",
[
["Name", "Version"],
[dist_name, installed_distribution.version.base_version],
],
)
available_table = render_table(
"Available",
[
["Name", "Version"],
*[[d.name, d.version.base_version] for d in compatible_distributions.values()],
],
)
result = hatch("python", "show", "--ascii", "-d", str(install_dir))
assert result.exit_code == 0, result.output
assert result.output == installed_table + available_table
def test_outdated(hatch, helpers, temp_dir_data, dist_name):
install_dir = temp_dir_data / "data" / "pythons"
helpers.write_distribution(install_dir, dist_name)
helpers.downgrade_distribution_metadata(install_dir / dist_name)
compatible_distributions = get_compatible_distributions()
installed_distribution = compatible_distributions.pop(dist_name)
installed_table = render_table(
"Installed",
[
["Name", "Version", "Status"],
[dist_name, helpers.downgrade_version(installed_distribution.version.base_version), "Update available"],
],
)
available_table = render_table(
"Available",
[
["Name", "Version"],
*[[d.name, d.version.base_version] for d in compatible_distributions.values()],
],
)
result = hatch("python", "show", "--ascii")
assert result.exit_code == 0, result.output
assert result.output == installed_table + available_table
================================================
FILE: tests/cli/python/test_update.py
================================================
def test_not_installed(hatch, helpers):
result = hatch("python", "update", "3.9", "3.10")
assert result.exit_code == 1, result.output
assert result.output == helpers.dedent(
"""
Distributions not installed: 3.9, 3.10
"""
)
def test_basic(hatch, helpers, temp_dir_data, path_append, dist_name, mocker):
install_dir = temp_dir_data / "data" / "pythons"
helpers.write_distribution(install_dir, dist_name)
dist_dir = install_dir / dist_name
metadata = helpers.downgrade_distribution_metadata(dist_dir)
python_path = dist_dir / metadata["python_path"]
install = mocker.patch(
"hatch.python.core.PythonManager.install", return_value=mocker.MagicMock(path=dist_dir, python_path=python_path)
)
result = hatch("python", "update", dist_name)
assert result.exit_code == 0, result.output
assert result.output == helpers.dedent(
f"""
Updating {dist_name}
Updated {dist_name} @ {dist_dir}
"""
)
install.assert_called_once_with(dist_name)
path_append.assert_not_called()
def test_specific_location(hatch, helpers, temp_dir_data, path_append, dist_name, mocker):
install = mocker.patch("hatch.python.core.PythonManager.install")
install_dir = temp_dir_data / "foo" / "bar" / "baz"
helpers.write_distribution(install_dir, dist_name)
dist_dir = install_dir / dist_name
metadata = helpers.downgrade_distribution_metadata(dist_dir)
python_path = dist_dir / metadata["python_path"]
install = mocker.patch(
"hatch.python.core.PythonManager.install", return_value=mocker.MagicMock(path=dist_dir, python_path=python_path)
)
result = hatch("python", "update", "-d", str(install_dir), dist_name)
assert result.exit_code == 0, result.output
assert result.output == helpers.dedent(
f"""
Updating {dist_name}
Updated {dist_name} @ {dist_dir}
"""
)
install.assert_called_once_with(dist_name)
path_append.assert_not_called()
def test_all(hatch, helpers, temp_dir_data, path_append, mocker):
installed_distributions = ("3.9", "3.10", "3.11")
mocked_dists = []
for name in installed_distributions:
install_dir = temp_dir_data / "data" / "pythons"
helpers.write_distribution(install_dir, name)
dist_dir = install_dir / name
metadata = helpers.downgrade_distribution_metadata(dist_dir)
python_path = dist_dir / metadata["python_path"]
mocked_dists.append(mocker.MagicMock(path=dist_dir, python_path=python_path))
install = mocker.patch("hatch.python.core.PythonManager.install", side_effect=mocked_dists)
result = hatch("python", "update", "all")
assert result.exit_code == 0, result.output
expected_lines = []
for dist in mocked_dists:
expected_lines.extend((f"Updating {dist.path.name}", f"Updated {dist.path.name} @ {dist.path}"))
expected_lines.append("")
assert result.output == "\n".join(expected_lines)
assert install.call_args_list == [mocker.call(name) for name in installed_distributions]
path_append.assert_not_called()
================================================
FILE: tests/cli/run/__init__.py
================================================
================================================
FILE: tests/cli/run/test_run.py
================================================
import os
import sys
import sysconfig
import pytest
from hatch.config.constants import AppEnvVars, ConfigEnvVars
from hatch.project.core import Project
from hatch.python.core import PythonManager
from hatch.python.resolve import get_compatible_distributions
from hatch.utils.fs import Path
from hatch.utils.structures import EnvVars
from hatchling.utils.constants import DEFAULT_BUILD_SCRIPT, DEFAULT_CONFIG_FILE
FREE_THREADED_BUILD = bool(sysconfig.get_config_var("Py_GIL_DISABLED"))
@pytest.fixture(scope="module")
def available_python_version():
compatible_distributions = get_compatible_distributions()
current_version = f"{sys.version_info.major}.{sys.version_info.minor}"
if current_version in compatible_distributions:
return current_version
versions = [d for d in get_compatible_distributions() if not d.startswith("pypy")]
return versions[-1]
def test_help(hatch):
short = hatch("run", "-h")
assert short.exit_code == 0, short.output
long = hatch("run", "--help")
assert long.exit_code == 0, long.output
assert short.output == long.output
def test_automatic_creation(hatch, helpers, temp_dir, config_file):
config_file.model.template.plugins["default"]["tests"] = False
config_file.save()
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()
project = Project(project_path)
helpers.update_project_environment(project, "default", {"skip-install": True, **project.config.envs["default"]})
with project_path.as_cwd(env_vars={ConfigEnvVars.DATA: str(data_path)}):
result = hatch("run", "python", "-c", "import pathlib,sys;pathlib.Path('test.txt').write_text(sys.executable)")
assert result.exit_code == 0, result.output
assert result.output == helpers.dedent(
"""
Creating environment: default
Checking dependencies
"""
)
output_file = project_path / "test.txt"
assert output_file.is_file()
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
assert str(env_path) in str(output_file.read_text())
def test_no_compatibility_check_if_exists(hatch, helpers, temp_dir, config_file, mocker):
config_file.model.template.plugins["default"]["tests"] = False
config_file.save()
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()
project = Project(project_path)
helpers.update_project_environment(project, "default", {"skip-install": True, **project.config.envs["default"]})
with project_path.as_cwd(env_vars={ConfigEnvVars.DATA: str(data_path)}):
result = hatch("run", "python", "-c", "import pathlib,sys;pathlib.Path('test.txt').write_text(sys.executable)")
assert result.exit_code == 0, result.output
assert result.output == helpers.dedent(
"""
Creating environment: default
Checking dependencies
"""
)
output_file = project_path / "test.txt"
assert output_file.is_file()
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
assert str(env_path) in str(output_file.read_text())
output_file.unlink()
mocker.patch("hatch.env.virtual.VirtualEnvironment.check_compatibility", side_effect=Exception("incompatible"))
with project_path.as_cwd(env_vars={ConfigEnvVars.DATA: str(data_path)}):
result = hatch("run", "python", "-c", "import pathlib,sys;pathlib.Path('test.txt').write_text(sys.executable)")
assert result.exit_code == 0, result.output
assert not result.output
assert str(env_path) in str(output_file.read_text())
def test_enter_project_directory(hatch, config_file, helpers, temp_dir):
config_file.model.template.plugins["default"]["tests"] = False
config_file.save()
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()
project = "foo"
config_file.model.mode = "project"
config_file.model.project = project
config_file.model.projects = {project: str(project_path)}
config_file.save()
project = Project(project_path)
helpers.update_project_environment(project, "default", {"skip-install": True, **project.config.envs["default"]})
with EnvVars({ConfigEnvVars.DATA: str(data_path)}):
result = hatch("run", "python", "-c", "import pathlib,sys;pathlib.Path('test.txt').write_text(sys.executable)")
assert result.exit_code == 0, result.output
assert result.output == helpers.dedent(
"""
Creating environment: default
Checking dependencies
"""
)
output_file = project_path / "test.txt"
assert output_file.is_file()
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
assert str(env_path) in str(output_file.read_text())
@pytest.mark.requires_internet
def test_sync_dependencies(hatch, helpers, temp_dir, config_file):
config_file.model.template.plugins["default"]["tests"] = False
config_file.save()
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()
project = Project(project_path)
helpers.update_project_environment(project, "default", {"skip-install": True, **project.config.envs["default"]})
with project_path.as_cwd(env_vars={ConfigEnvVars.DATA: str(data_path)}):
result = hatch("env", "create", "default")
assert result.exit_code == 0, result.output
assert result.output == helpers.dedent(
"""
Creating environment: default
Checking 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
project = Project(project_path)
helpers.update_project_environment(
project, "default", {"dependencies": ["binary"], **project.config.envs["default"]}
)
with project_path.as_cwd(env_vars={ConfigEnvVars.DATA: str(data_path)}):
result = hatch(
"run",
"python",
"-c",
"import binary,pathlib,sys;pathlib.Path('test.txt').write_text(str(binary.convert_units(1024)))",
)
assert result.exit_code == 0, result.output
assert result.output == helpers.dedent(
"""
Checking dependencies
Syncing dependencies
"""
)
output_file = project_path / "test.txt"
assert output_file.is_file()
assert str(output_file.read_text()) == "(1.0, 'KiB')"
@pytest.mark.requires_internet
def test_sync_project_dependencies(hatch, helpers, temp_dir, config_file):
config_file.model.template.plugins["default"]["tests"] = False
config_file.save()
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()
with project_path.as_cwd(env_vars={ConfigEnvVars.DATA: str(data_path)}):
result = hatch("env", "create", "default")
assert result.exit_code == 0, result.output
assert result.output == helpers.dedent(
"""
Creating environment: default
Installing project in development mode
Checking 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
project = Project(project_path)
config = dict(project.raw_config)
config["project"]["dependencies"] = ["binary"]
project.save_config(config)
with project_path.as_cwd(env_vars={ConfigEnvVars.DATA: str(data_path)}):
result = hatch(
"run",
"python",
"-c",
"import binary,pathlib,sys;pathlib.Path('test.txt').write_text(str(binary.convert_units(1024)))",
)
assert result.exit_code == 0, result.output
assert result.output == helpers.dedent(
"""
Checking dependencies
Syncing dependencies
"""
)
output_file = project_path / "test.txt"
assert output_file.is_file()
assert str(output_file.read_text()) == "(1.0, 'KiB')"
@pytest.mark.requires_internet
def test_sync_project_features(hatch, helpers, temp_dir, config_file):
config_file.model.template.plugins["default"]["tests"] = False
config_file.save()
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()
project = Project(project_path)
config = dict(project.raw_config)
config["project"]["optional-dependencies"] = {"foo": []}
project.save_config(config)
helpers.update_project_environment(project, "default", {"features": ["foo"], **project.config.envs["default"]})
with project_path.as_cwd(env_vars={ConfigEnvVars.DATA: str(data_path)}):
result = hatch("env", "create", "default")
assert result.exit_code == 0, result.output
assert result.output == helpers.dedent(
"""
Creating environment: default
Installing project in development mode
Checking 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
project = Project(project_path)
config = dict(project.raw_config)
config["project"]["optional-dependencies"]["foo"].append("binary")
project.save_config(config)
with project_path.as_cwd(env_vars={ConfigEnvVars.DATA: str(data_path)}):
result = hatch(
"run",
"python",
"-c",
"import binary,pathlib,sys;pathlib.Path('test.txt').write_text(str(binary.convert_units(1024)))",
)
assert result.exit_code == 0, result.output
assert result.output == helpers.dedent(
"""
Checking dependencies
Syncing dependencies
"""
)
output_file = project_path / "test.txt"
assert output_file.is_file()
assert str(output_file.read_text()) == "(1.0, 'KiB')"
@pytest.mark.requires_internet
def test_dependency_hash_checking(hatch, helpers, temp_dir, config_file, mocker):
config_file.model.template.plugins["default"]["tests"] = False
config_file.save()
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()
project = Project(project_path)
helpers.update_project_environment(project, "default", {"skip-install": True, **project.config.envs["default"]})
with project_path.as_cwd(env_vars={ConfigEnvVars.DATA: str(data_path)}):
result = hatch("env", "create", "default")
assert result.exit_code == 0, result.output
assert result.output == helpers.dedent(
"""
Creating environment: default
Checking 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
project = Project(project_path)
helpers.update_project_environment(
project, "default", {"dependencies": ["binary"], **project.config.envs["default"]}
)
with project_path.as_cwd(env_vars={ConfigEnvVars.DATA: str(data_path)}):
result = hatch(
"run",
"python",
"-c",
"import binary,pathlib,sys;pathlib.Path('test.txt').write_text(str(binary.convert_units(1024)))",
)
assert result.exit_code == 0, result.output
assert result.output == helpers.dedent(
"""
Checking dependencies
Syncing dependencies
"""
)
output_file = project_path / "test.txt"
assert output_file.is_file()
assert str(output_file.read_text()) == "(1.0, 'KiB')"
output_file.unlink()
# Now there should be no output because there is no dependency checking
with project_path.as_cwd(env_vars={ConfigEnvVars.DATA: str(data_path)}):
result = hatch(
"run",
"python",
"-c",
"import binary,pathlib,sys;pathlib.Path('test.txt').write_text(str(binary.convert_units(1024)))",
)
assert result.exit_code == 0, result.output
assert not result.output
output_file = project_path / "test.txt"
assert output_file.is_file()
assert str(output_file.read_text()) == "(1.0, 'KiB')"
output_file.unlink()
mocker.patch("hatch.env.virtual.VirtualEnvironment.dependencies_in_sync", return_value=False)
mocker.patch("hatch.env.virtual.VirtualEnvironment.dependency_hash", side_effect=["foo", "bar", "bar"])
# Ensure that the saved value is the second value
with project_path.as_cwd(env_vars={ConfigEnvVars.DATA: str(data_path)}):
result = hatch(
"run",
"python",
"-c",
"import binary,pathlib,sys;pathlib.Path('test.txt').write_text(str(binary.convert_units(1024)))",
)
assert result.exit_code == 0, result.output
assert result.output == helpers.dedent(
"""
Checking dependencies
Syncing dependencies
"""
)
output_file = project_path / "test.txt"
assert output_file.is_file()
assert str(output_file.read_text()) == "(1.0, 'KiB')"
output_file.unlink()
with project_path.as_cwd(env_vars={ConfigEnvVars.DATA: str(data_path)}):
result = hatch(
"run",
"python",
"-c",
"import binary,pathlib,sys;pathlib.Path('test.txt').write_text(str(binary.convert_units(1024)))",
)
assert result.exit_code == 0, result.output
assert not result.output
output_file = project_path / "test.txt"
assert output_file.is_file()
assert str(output_file.read_text()) == "(1.0, 'KiB')"
def test_scripts(hatch, helpers, temp_dir, config_file):
config_file.model.template.plugins["default"]["tests"] = False
config_file.save()
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()
project = Project(project_path)
helpers.update_project_environment(
project,
"default",
{"skip-install": True, "scripts": {"py": "python -c {args}"}, **project.config.envs["default"]},
)
with project_path.as_cwd(env_vars={ConfigEnvVars.DATA: str(data_path)}):
result = hatch("run", "py", "import pathlib,sys;pathlib.Path('test.txt').write_text(sys.executable)")
assert result.exit_code == 0, result.output
assert result.output == helpers.dedent(
"""
Creating environment: default
Checking dependencies
"""
)
output_file = project_path / "test.txt"
assert output_file.is_file()
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
assert str(env_path) in str(output_file.read_text())
def test_scripts_specific_environment(hatch, helpers, temp_dir, config_file):
config_file.model.template.plugins["default"]["tests"] = False
config_file.save()
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()
project = Project(project_path)
helpers.update_project_environment(
project,
"default",
{"skip-install": True, "scripts": {"py": "python -c {args}"}, **project.config.envs["default"]},
)
helpers.update_project_environment(project, "test", {"env-vars": {"foo": "bar"}})
with project_path.as_cwd(env_vars={ConfigEnvVars.DATA: str(data_path)}):
result = hatch(
"run",
"test:py",
"import os,pathlib,sys;pathlib.Path('test.txt').write_text("
"sys.executable+os.linesep[-1]+os.environ['foo'])",
)
assert result.exit_code == 0, result.output
assert result.output == helpers.dedent(
"""
Creating environment: test
Checking dependencies
"""
)
output_file = project_path / "test.txt"
assert output_file.is_file()
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 == "test"
python_executable_path, env_var_value = str(output_file.read_text()).splitlines()
assert str(env_path) in python_executable_path
assert env_var_value == "bar"
@pytest.mark.requires_internet
def test_scripts_no_environment(hatch, helpers, temp_dir, config_file):
config_file.model.template.plugins["default"]["tests"] = False
config_file.save()
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()
project = Project(project_path)
config = dict(project.raw_config)
config["tool"]["hatch"]["scripts"] = {"py": "python -c {args}"}
project.save_config(config)
with project_path.as_cwd(env_vars={ConfigEnvVars.DATA: str(data_path)}):
result = hatch("run", ":py", "import pathlib,sys;pathlib.Path('test.txt').write_text(sys.executable)")
assert result.exit_code == 0, result.output
assert result.output == helpers.dedent(
"""
Checking dependencies
"""
)
output_file = project_path / "test.txt"
assert output_file.is_file()
env_data_path = data_path / "env" / "virtual"
assert not env_data_path.exists()
assert os.path.realpath(output_file.read_text().strip()).lower() == os.path.realpath(sys.executable).lower()
def test_error(hatch, helpers, temp_dir, config_file):
config_file.model.template.plugins["default"]["tests"] = False
config_file.save()
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()
project = Project(project_path)
helpers.update_project_environment(
project,
"default",
{
"skip-install": True,
"scripts": {
"error": [
'python -c "import sys;sys.exit(3)"',
"python -c \"import pathlib,sys;pathlib.Path('test.txt').write_text(sys.executable)\"",
],
},
**project.config.envs["default"],
},
)
with project_path.as_cwd(env_vars={ConfigEnvVars.DATA: str(data_path)}):
result = hatch("run", "error")
assert result.exit_code == 3
assert result.output == helpers.dedent(
"""
Creating environment: default
Checking dependencies
cmd [1] | python -c "import sys;sys.exit(3)"
"""
)
output_file = project_path / "test.txt"
assert not output_file.is_file()
def test_ignore_error(hatch, helpers, temp_dir, config_file):
config_file.model.template.plugins["default"]["tests"] = False
config_file.save()
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()
project = Project(project_path)
helpers.update_project_environment(
project,
"default",
{
"skip-install": True,
"scripts": {
"error": [
'- python -c "import sys;sys.exit(3)"',
"python -c \"import pathlib,sys;pathlib.Path('test.txt').write_text(sys.executable)\"",
],
},
**project.config.envs["default"],
},
)
with project_path.as_cwd(env_vars={ConfigEnvVars.DATA: str(data_path)}):
result = hatch("run", "error")
assert result.exit_code == 0
assert result.output == helpers.dedent(
"""
Creating environment: default
Checking dependencies
cmd [1] | - python -c "import sys;sys.exit(3)"
cmd [2] | python -c "import pathlib,sys;pathlib.Path('test.txt').write_text(sys.executable)"
"""
)
output_file = project_path / "test.txt"
assert output_file.is_file()
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
assert str(env_path) in str(output_file.read_text())
def test_command_expansion_error(hatch, helpers, temp_dir, config_file):
config_file.model.template.plugins["default"]["tests"] = False
config_file.save()
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()
project = Project(project_path)
helpers.update_project_environment(
project,
"default",
{"skip-install": True, "scripts": {"error": ["echo {env:FOOBAR}"]}, **project.config.envs["default"]},
)
with project_path.as_cwd(env_vars={ConfigEnvVars.DATA: str(data_path)}):
result = hatch("run", "error")
assert result.exit_code == 1
assert result.output == helpers.dedent(
"""
Creating environment: default
Checking dependencies
Nonexistent environment variable must set a default: FOOBAR
"""
)
output_file = project_path / "test.txt"
assert not output_file.is_file()
def test_verbosity(hatch, helpers, temp_dir, config_file):
config_file.model.template.plugins["default"]["tests"] = False
config_file.save()
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()
project = Project(project_path)
helpers.update_project_environment(
project,
"default",
{
"skip-install": True,
"scripts": {
"write-exe": [
"python -c \"import pathlib,sys;pathlib.Path('{args}1.txt').write_text(sys.executable)\"",
"python -c \"import pathlib,sys;pathlib.Path('{args}2.txt').write_text(sys.executable)\"",
"python -c \"import pathlib,sys;pathlib.Path('{args}3.txt').write_text(sys.executable)\"",
],
},
**project.config.envs["default"],
},
)
with project_path.as_cwd(env_vars={ConfigEnvVars.DATA: str(data_path)}):
result = hatch("-v", "run", "write-exe", "test")
assert result.exit_code == 0, result.output
assert result.output == helpers.dedent(
"""
─────────────────────────────────── default ────────────────────────────────────
Creating environment: default
Checking dependencies
cmd [1] | python -c "import pathlib,sys;pathlib.Path('test1.txt').write_text(sys.executable)"
cmd [2] | python -c "import pathlib,sys;pathlib.Path('test2.txt').write_text(sys.executable)"
cmd [3] | python -c "import pathlib,sys;pathlib.Path('test3.txt').write_text(sys.executable)"
"""
)
output_files = []
for i in range(1, 4):
output_file = project_path / f"test{i}.txt"
assert output_file.is_file()
output_files.append(output_file)
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
for output_file in output_files:
assert str(env_path) in str(output_file.read_text())
def test_matrix_no_environments(hatch, helpers, temp_dir, config_file):
config_file.model.template.plugins["default"]["tests"] = False
config_file.save()
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()
project = Project(project_path)
helpers.update_project_environment(project, "default", {"skip-install": True, **project.config.envs["default"]})
helpers.update_project_environment(project, "test", {"matrix": []})
with project_path.as_cwd(env_vars={ConfigEnvVars.DATA: str(data_path)}):
result = hatch(
"run", "test:python", "-c", "import os,sys;open('test.txt', 'a').write(sys.executable+os.linesep[-1])"
)
assert result.exit_code == 1, result.output
assert result.output == helpers.dedent(
"""
No variables defined for matrix: test
"""
)
def test_matrix(hatch, helpers, temp_dir, config_file):
config_file.model.template.plugins["default"]["tests"] = False
config_file.save()
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()
project = Project(project_path)
helpers.update_project_environment(project, "default", {"skip-install": True, **project.config.envs["default"]})
helpers.update_project_environment(project, "test", {"matrix": [{"version": ["9000", "42"]}]})
with project_path.as_cwd(env_vars={ConfigEnvVars.DATA: str(data_path)}):
result = hatch(
"run", "test:python", "-c", "import os,sys;open('test.txt', 'a').write(sys.executable+os.linesep[-1])"
)
assert result.exit_code == 0, result.output
assert result.output == helpers.dedent(
"""
────────────────────────────────── test.9000 ───────────────────────────────────
Creating environment: test.9000
Checking dependencies
─────────────────────────────────── test.42 ────────────────────────────────────
Creating environment: test.42
Checking dependencies
"""
)
output_file = project_path / "test.txt"
assert output_file.is_file()
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 = sorted(storage_path.iterdir(), key=lambda d: d.name)[::-1]
assert len(env_dirs) == 2
python_path1, python_path2 = str(output_file.read_text()).splitlines()
for python_path, env_dir, env_name in zip(
(python_path1, python_path2), env_dirs, ("test.9000", "test.42"), strict=False
):
assert env_dir.name == env_name
assert str(env_dir) in python_path
def test_incompatible_single(hatch, helpers, temp_dir, config_file):
config_file.model.template.plugins["default"]["tests"] = False
config_file.save()
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()
project = Project(project_path)
helpers.update_project_environment(
project, "default", {"skip-install": True, "platforms": ["foo"], **project.config.envs["default"]}
)
helpers.update_project_environment(project, "test", {})
with project_path.as_cwd(env_vars={ConfigEnvVars.DATA: str(data_path)}):
result = hatch(
"run", "test:python", "-c", "import os,sys;open('test.txt', 'a').write(sys.executable+os.linesep[-1])"
)
assert result.exit_code == 1
assert result.output == helpers.dedent(
"""
Environment `test` is incompatible: unsupported platform
"""
)
output_file = project_path / "test.txt"
assert not output_file.is_file()
env_data_path = data_path / "env" / "virtual"
assert not env_data_path.is_dir()
def test_incompatible_matrix_full(hatch, helpers, temp_dir, config_file):
config_file.model.template.plugins["default"]["tests"] = False
config_file.save()
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()
project = Project(project_path)
helpers.update_project_environment(
project, "default", {"skip-install": True, "platforms": ["foo"], **project.config.envs["default"]}
)
helpers.update_project_environment(project, "test", {"matrix": [{"version": ["9000", "42"]}]})
with project_path.as_cwd(env_vars={ConfigEnvVars.DATA: str(data_path)}):
result = hatch(
"run", "test:python", "-c", "import os,sys;open('test.txt', 'a').write(sys.executable+os.linesep[-1])"
)
assert result.exit_code == 0, result.output
assert result.output == helpers.dedent(
"""
Skipped 2 incompatible environments:
test.9000 -> unsupported platform
test.42 -> unsupported platform
"""
)
output_file = project_path / "test.txt"
assert not output_file.is_file()
env_data_path = data_path / "env" / "virtual"
assert not env_data_path.is_dir()
def test_incompatible_matrix_partial(hatch, helpers, temp_dir, config_file):
config_file.model.template.plugins["default"]["tests"] = False
config_file.save()
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()
project = Project(project_path)
helpers.update_project_environment(project, "default", {"skip-install": True, **project.config.envs["default"]})
helpers.update_project_environment(
project,
"test",
{
"matrix": [{"version": ["9000", "42"]}],
"overrides": {"matrix": {"version": {"platforms": [{"value": "foo", "if": ["9000"]}]}}},
},
)
with project_path.as_cwd(env_vars={ConfigEnvVars.DATA: str(data_path)}):
result = hatch(
"run", "test:python", "-c", "import os,sys;open('test.txt', 'a').write(sys.executable+os.linesep[-1])"
)
assert result.exit_code == 0, result.output
assert result.output == helpers.dedent(
"""
─────────────────────────────────── test.42 ────────────────────────────────────
Creating environment: test.42
Checking dependencies
Skipped 1 incompatible environment:
test.9000 -> unsupported platform
"""
)
output_file = project_path / "test.txt"
assert output_file.is_file()
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 == "test.42"
python_path = str(output_file.read_text()).strip()
assert str(env_path) in python_path
def test_incompatible_missing_python(hatch, helpers, temp_dir, config_file):
config_file.model.template.plugins["default"]["tests"] = False
config_file.save()
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()
known_version = "".join(map(str, sys.version_info[:2]))
if FREE_THREADED_BUILD:
known_version += "t"
project = Project(project_path)
helpers.update_project_environment(project, "default", {"skip-install": True, **project.config.envs["default"]})
helpers.update_project_environment(project, "test", {"matrix": [{"python": [known_version, "9000"]}]})
with project_path.as_cwd(env_vars={ConfigEnvVars.DATA: str(data_path)}):
result = hatch(
"run", "test:python", "-c", "import os,sys;open('test.txt', 'a').write(sys.executable+os.linesep[-1])"
)
padding = "─"
if FREE_THREADED_BUILD:
pre_padding = ""
else:
pre_padding = "─"
if len(known_version) < 3:
padding += "─"
assert result.exit_code == 0, result.output
assert result.output == helpers.dedent(
f"""
─────────────────────────────────{pre_padding} test.py{known_version} ─────────────────────────────────{padding}
Creating environment: test.py{known_version}
Checking dependencies
Skipped 1 incompatible environment:
test.py9000 -> cannot locate Python: 9000
"""
)
output_file = project_path / "test.txt"
assert output_file.is_file()
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 == f"test.py{known_version}"
python_path = str(output_file.read_text()).strip()
assert str(env_path) in python_path
def test_env_detection(hatch, helpers, temp_dir, config_file):
config_file.model.template.plugins["default"]["tests"] = False
config_file.save()
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()
project = Project(project_path)
helpers.update_project_environment(project, "default", {"skip-install": True, **project.config.envs["default"]})
helpers.update_project_environment(project, "foo", {})
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
Checking dependencies
"""
)
with project_path.as_cwd(env_vars={ConfigEnvVars.DATA: str(data_path)}):
result = hatch("env", "create", "foo")
assert result.exit_code == 0, result.output
assert result.output == helpers.dedent(
"""
Creating environment: foo
Checking 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 = sorted(storage_path.iterdir(), key=lambda d: d.name)
assert len(env_dirs) == 2
assert env_dirs[0].name == "foo"
assert env_dirs[1].name == "my-app"
with project_path.as_cwd(env_vars={ConfigEnvVars.DATA: str(data_path), AppEnvVars.ENV_ACTIVE: "foo"}):
result = hatch("run", "python", "-c", "import pathlib,sys;pathlib.Path('test.txt').write_text(sys.executable)")
assert result.exit_code == 0, result.output
output_file = project_path / "test.txt"
assert output_file.is_file()
python_path = str(output_file.read_text()).strip()
assert str(env_dirs[0]) in python_path
def test_env_detection_override(hatch, helpers, temp_dir, config_file):
config_file.model.template.plugins["default"]["tests"] = False
config_file.save()
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()
project = Project(project_path)
helpers.update_project_environment(project, "default", {"skip-install": True, **project.config.envs["default"]})
helpers.update_project_environment(project, "foo", {})
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
Checking dependencies
"""
)
with project_path.as_cwd(env_vars={ConfigEnvVars.DATA: str(data_path)}):
result = hatch("env", "create", "foo")
assert result.exit_code == 0, result.output
assert result.output == helpers.dedent(
"""
Creating environment: foo
Checking 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 = sorted(storage_path.iterdir(), key=lambda d: d.name)
assert len(env_dirs) == 2
assert env_dirs[0].name == "foo"
assert env_dirs[1].name == "my-app"
with project_path.as_cwd(env_vars={ConfigEnvVars.DATA: str(data_path), AppEnvVars.ENV_ACTIVE: "foo"}):
result = hatch(
"run", "default:python", "-c", "import pathlib,sys;pathlib.Path('test.txt').write_text(sys.executable)"
)
assert result.exit_code == 0, result.output
output_file = project_path / "test.txt"
assert output_file.is_file()
python_path = str(output_file.read_text()).strip()
assert str(env_dirs[1]) in python_path
def test_matrix_variable_selection_no_command(hatch, helpers, temp_dir, config_file):
config_file.model.template.plugins["default"]["tests"] = False
config_file.save()
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()
project = Project(project_path)
helpers.update_project_environment(project, "default", {"skip-install": True, **project.config.envs["default"]})
helpers.update_project_environment(project, "test", {"matrix": [{"version": ["9000", "42"]}]})
with project_path.as_cwd(env_vars={ConfigEnvVars.DATA: str(data_path)}):
result = hatch("run", "+version=9000")
assert result.exit_code == 1, result.output
assert result.output == helpers.dedent(
"""
Missing argument `MATRIX:ARGS...`
"""
)
def test_matrix_variable_selection_duplicate_inclusion(hatch, helpers, temp_dir, config_file):
config_file.model.template.plugins["default"]["tests"] = False
config_file.save()
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()
project = Project(project_path)
helpers.update_project_environment(project, "default", {"skip-install": True, **project.config.envs["default"]})
helpers.update_project_environment(project, "test", {"matrix": [{"version": ["9000", "42"]}]})
with project_path.as_cwd(env_vars={ConfigEnvVars.DATA: str(data_path)}):
result = hatch(
"run",
"+version=9000",
"+version=42",
"python",
"-c",
"import pathlib,sys;pathlib.Path('test.txt').write_text(sys.executable)",
)
assert result.exit_code == 1, result.output
assert result.output == helpers.dedent(
"""
Duplicate included variable: version
"""
)
def test_matrix_variable_selection_duplicate_exclusion(hatch, helpers, temp_dir, config_file):
config_file.model.template.plugins["default"]["tests"] = False
config_file.save()
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()
project = Project(project_path)
helpers.update_project_environment(project, "default", {"skip-install": True, **project.config.envs["default"]})
helpers.update_project_environment(project, "test", {"matrix": [{"version": ["9000", "42"]}]})
with project_path.as_cwd(env_vars={ConfigEnvVars.DATA: str(data_path)}):
result = hatch(
"run",
"-version=9000",
"-version=42",
"python",
"-c",
"import pathlib,sys;pathlib.Path('test.txt').write_text(sys.executable)",
)
assert result.exit_code == 1, result.output
assert result.output == helpers.dedent(
"""
Duplicate excluded variable: version
"""
)
def test_matrix_variable_selection_python_alias(hatch, helpers, temp_dir, config_file):
config_file.model.template.plugins["default"]["tests"] = False
config_file.save()
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()
project = Project(project_path)
helpers.update_project_environment(project, "default", {"skip-install": True, **project.config.envs["default"]})
helpers.update_project_environment(project, "test", {"matrix": [{"python": ["9000", "42"]}]})
with project_path.as_cwd(env_vars={ConfigEnvVars.DATA: str(data_path)}):
result = hatch(
"run",
"+py=9000",
"+python=42",
"python",
"-c",
"import pathlib,sys;pathlib.Path('test.txt').write_text(sys.executable)",
)
assert result.exit_code == 1, result.output
assert result.output == helpers.dedent(
"""
Duplicate included variable: python
"""
)
def test_matrix_variable_selection_not_matrix(hatch, helpers, temp_dir, config_file):
config_file.model.template.plugins["default"]["tests"] = False
config_file.save()
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()
project = Project(project_path)
helpers.update_project_environment(project, "default", {"skip-install": True, **project.config.envs["default"]})
helpers.update_project_environment(project, "test", {"matrix": [{"version": ["9000", "42"]}]})
with project_path.as_cwd(env_vars={ConfigEnvVars.DATA: str(data_path)}):
result = hatch(
"run",
"+version=9000",
"python",
"-c",
"import os,sys;open('test.txt', 'a').write(sys.executable+os.linesep[-1])",
)
assert result.exit_code == 1, result.output
assert result.output == helpers.dedent(
"""
Variable selection is unsupported for non-matrix environments: default
"""
)
def test_matrix_variable_selection_inclusion(hatch, helpers, temp_dir, config_file):
config_file.model.template.plugins["default"]["tests"] = False
config_file.save()
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()
project = Project(project_path)
helpers.update_project_environment(project, "default", {"skip-install": True, **project.config.envs["default"]})
helpers.update_project_environment(project, "test", {"matrix": [{"version": ["9000", "42"]}]})
with project_path.as_cwd(env_vars={ConfigEnvVars.DATA: str(data_path)}):
result = hatch(
"run",
"+version=9000",
"test:python",
"-c",
"import os,sys;open('test.txt', 'a').write(sys.executable+os.linesep[-1])",
)
assert result.exit_code == 0, result.output
assert result.output == helpers.dedent(
"""
────────────────────────────────── test.9000 ───────────────────────────────────
Creating environment: test.9000
Checking dependencies
"""
)
output_file = project_path / "test.txt"
assert output_file.is_file()
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 == "test.9000"
python_path = str(output_file.read_text()).strip()
assert str(env_path) in python_path
def test_matrix_variable_selection_exclusion(hatch, helpers, temp_dir, config_file):
config_file.model.template.plugins["default"]["tests"] = False
config_file.save()
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()
project = Project(project_path)
helpers.update_project_environment(project, "default", {"skip-install": True, **project.config.envs["default"]})
helpers.update_project_environment(project, "test", {"matrix": [{"version": ["9000", "42"]}]})
with project_path.as_cwd(env_vars={ConfigEnvVars.DATA: str(data_path)}):
result = hatch(
"run",
"-version=9000",
"test:python",
"-c",
"import os,sys;open('test.txt', 'a').write(sys.executable+os.linesep[-1])",
)
assert result.exit_code == 0, result.output
assert result.output == helpers.dedent(
"""
─────────────────────────────────── test.42 ────────────────────────────────────
Creating environment: test.42
Checking dependencies
"""
)
output_file = project_path / "test.txt"
assert output_file.is_file()
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 == "test.42"
python_path = str(output_file.read_text()).strip()
assert str(env_path) in python_path
def test_matrix_variable_selection_exclude_all(hatch, helpers, temp_dir, config_file):
config_file.model.template.plugins["default"]["tests"] = False
config_file.save()
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()
project = Project(project_path)
helpers.update_project_environment(project, "default", {"skip-install": True, **project.config.envs["default"]})
helpers.update_project_environment(project, "test", {"matrix": [{"version": ["9000", "42"]}]})
with project_path.as_cwd(env_vars={ConfigEnvVars.DATA: str(data_path)}):
result = hatch(
"run",
"-version",
"test:python",
"-c",
"import os,sys;open('test.txt', 'a').write(sys.executable+os.linesep[-1])",
)
assert result.exit_code == 1, result.output
assert result.output == helpers.dedent(
"""
No environments were selected
"""
)
def test_matrix_variable_selection_include_none(hatch, helpers, temp_dir, config_file):
config_file.model.template.plugins["default"]["tests"] = False
config_file.save()
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()
project = Project(project_path)
helpers.update_project_environment(project, "default", {"skip-install": True, **project.config.envs["default"]})
helpers.update_project_environment(project, "test", {"matrix": [{"version": ["9000", "42"]}]})
with project_path.as_cwd(env_vars={ConfigEnvVars.DATA: str(data_path)}):
result = hatch(
"run",
"+version=3.14",
"test:python",
"-c",
"import os,sys;open('test.txt', 'a').write(sys.executable+os.linesep[-1])",
)
assert result.exit_code == 1, result.output
assert result.output == helpers.dedent(
"""
No environments were selected
"""
)
def test_matrix_variable_selection_inclusion_multiple_variables(hatch, helpers, temp_dir, config_file):
config_file.model.template.plugins["default"]["tests"] = False
config_file.save()
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()
project = Project(project_path)
helpers.update_project_environment(project, "default", {"skip-install": True, **project.config.envs["default"]})
helpers.update_project_environment(
project, "test", {"matrix": [{"version1": ["9000", "42"], "version2": ["3.14"]}]}
)
with project_path.as_cwd(env_vars={ConfigEnvVars.DATA: str(data_path)}):
result = hatch(
"run",
"+version1=9000",
"test:python",
"-c",
"import os,sys;open('test.txt', 'a').write(sys.executable+os.linesep[-1])",
)
assert result.exit_code == 0, result.output
assert result.output == helpers.dedent(
"""
──────────────────────────────── test.9000-3.14 ────────────────────────────────
Creating environment: test.9000-3.14
Checking dependencies
"""
)
output_file = project_path / "test.txt"
assert output_file.is_file()
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 == "test.9000-3.14"
python_path = str(output_file.read_text()).strip()
assert str(env_path) in python_path
def test_context_formatting_recursion(hatch, helpers, temp_dir, config_file):
config_file.model.template.plugins["default"]["tests"] = False
config_file.save()
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()
project = Project(project_path)
helpers.update_project_environment(
project,
"default",
{
"skip-install": True,
"scripts": {"py": 'python -c "{env:FOO:{env:BAR:{env:BAZ}}}"'},
**project.config.envs["default"],
},
)
with project_path.as_cwd(
env_vars={
ConfigEnvVars.DATA: str(data_path),
"BAZ": "import pathlib,sys;pathlib.Path('test.txt').write_text(sys.executable)",
}
):
result = hatch("run", "py")
assert result.exit_code == 0, result.output
assert result.output == helpers.dedent(
"""
Creating environment: default
Checking dependencies
"""
)
output_file = project_path / "test.txt"
assert output_file.is_file()
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
assert str(env_path) in str(output_file.read_text())
def test_plugin_dependencies_unmet(hatch, helpers, temp_dir, config_file, mock_plugin_installation):
config_file.model.template.plugins["default"]["tests"] = False
config_file.save()
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()
dependency = os.urandom(16).hex()
(project_path / DEFAULT_CONFIG_FILE).write_text(
helpers.dedent(
f"""
[env]
requires = ["{dependency}"]
"""
)
)
project = Project(project_path)
helpers.update_project_environment(project, "default", {"skip-install": True, **project.config.envs["default"]})
with project_path.as_cwd(env_vars={ConfigEnvVars.DATA: str(data_path)}):
result = hatch("run", "python", "-c", "import pathlib,sys;pathlib.Path('test.txt').write_text(sys.executable)")
assert result.exit_code == 0, result.output
assert result.output == helpers.dedent(
"""
Syncing environment plugin requirements
Creating environment: default
Checking dependencies
"""
)
helpers.assert_plugin_installation(mock_plugin_installation, [dependency])
output_file = project_path / "test.txt"
assert output_file.is_file()
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
assert str(env_path) in str(output_file.read_text())
@pytest.mark.requires_internet
def test_install_python_specific(hatch, helpers, temp_dir, config_file, mocker, available_python_version):
config_file.model.template.plugins["default"]["tests"] = False
config_file.save()
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()
project = Project(project_path)
helpers.update_project_environment(
project,
"default",
{"skip-install": True, "python": available_python_version, **project.config.envs["default"]},
)
mocker.patch("hatch.env.virtual.VirtualEnvironment._interpreter_is_compatible", return_value=False)
manager = PythonManager(data_path / "env" / "virtual" / ".pythons")
assert not manager.get_installed()
with project_path.as_cwd(env_vars={ConfigEnvVars.DATA: str(data_path)}):
result = hatch(
"run", "python", "-c", "import os,sys;open('test.txt', 'a').write(sys.executable+os.linesep[-1])"
)
assert result.exit_code == 0, result.output
assert result.output == helpers.dedent(
f"""
Creating environment: default
Installing Python distribution: {available_python_version}
Checking dependencies
"""
)
output_file = project_path / "test.txt"
assert output_file.is_file()
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
assert str(env_path) in str(output_file.read_text())
assert list(manager.get_installed()) == [available_python_version]
@pytest.mark.requires_internet
def test_update_python_specific(hatch, helpers, temp_dir, config_file, mocker, available_python_version):
config_file.model.template.plugins["default"]["tests"] = False
config_file.save()
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()
project = Project(project_path)
helpers.update_project_environment(
project,
"default",
{"skip-install": True, "python": available_python_version, **project.config.envs["default"]},
)
install_dir = data_path / "env" / "virtual" / ".pythons"
manager = PythonManager(install_dir)
dist = manager.install(available_python_version)
helpers.downgrade_distribution_metadata(install_dir / available_python_version)
mocker.patch(
"hatch.env.virtual.VirtualEnvironment._interpreter_is_compatible",
side_effect=lambda interpreter: Path(interpreter.executable) == dist.python_path,
)
with project_path.as_cwd(env_vars={ConfigEnvVars.DATA: str(data_path)}):
result = hatch(
"run", "python", "-c", "import os,sys;open('test.txt', 'a').write(sys.executable+os.linesep[-1])"
)
assert result.exit_code == 0, result.output
assert result.output == helpers.dedent(
f"""
Creating environment: default
Updating Python distribution: {available_python_version}
Checking dependencies
"""
)
output_file = project_path / "test.txt"
assert output_file.is_file()
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
assert str(env_path) in str(output_file.read_text())
@pytest.mark.requires_internet
def test_install_python_max_compatible(hatch, helpers, temp_dir, config_file, mocker, available_python_version):
config_file.model.template.plugins["default"]["tests"] = False
config_file.save()
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()
project = Project(project_path)
helpers.update_project_environment(project, "default", {"skip-install": True, **project.config.envs["default"]})
mocker.patch("hatch.env.virtual.VirtualEnvironment._interpreter_is_compatible", return_value=False)
manager = PythonManager(data_path / "env" / "virtual" / ".pythons")
assert not manager.get_installed()
with project_path.as_cwd(env_vars={ConfigEnvVars.DATA: str(data_path)}):
result = hatch(
"run", "python", "-c", "import os,sys;open('test.txt', 'a').write(sys.executable+os.linesep[-1])"
)
assert result.exit_code == 0, result.output
assert result.output == helpers.dedent(
f"""
Creating environment: default
Installing Python distribution: {available_python_version}
Checking dependencies
"""
)
output_file = project_path / "test.txt"
assert output_file.is_file()
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
assert str(env_path) in str(output_file.read_text())
assert list(manager.get_installed()) == [available_python_version]
@pytest.mark.requires_internet
def test_update_python_max_compatible(hatch, helpers, temp_dir, config_file, mocker, available_python_version):
config_file.model.template.plugins["default"]["tests"] = False
config_file.save()
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()
project = Project(project_path)
helpers.update_project_environment(project, "default", {"skip-install": True, **project.config.envs["default"]})
install_dir = data_path / "env" / "virtual" / ".pythons"
manager = PythonManager(install_dir)
dist = manager.install(available_python_version)
helpers.downgrade_distribution_metadata(install_dir / available_python_version)
mocker.patch(
"hatch.env.virtual.VirtualEnvironment._interpreter_is_compatible",
side_effect=lambda interpreter: Path(interpreter.executable) == dist.python_path,
)
with project_path.as_cwd(env_vars={ConfigEnvVars.DATA: str(data_path)}):
result = hatch(
"run", "python", "-c", "import os,sys;open('test.txt', 'a').write(sys.executable+os.linesep[-1])"
)
assert result.exit_code == 0, result.output
assert result.output == helpers.dedent(
f"""
Creating environment: default
Updating Python distribution: {available_python_version}
Checking dependencies
"""
)
output_file = project_path / "test.txt"
assert output_file.is_file()
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
assert str(env_path) in str(output_file.read_text())
@pytest.mark.requires_internet
def test_python_installation_with_metadata_hook(
hatch, helpers, temp_dir, config_file, mocker, available_python_version
):
config_file.model.template.plugins["default"]["tests"] = False
config_file.save()
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()
project = Project(project_path)
config = dict(project.raw_config)
config["build-system"]["requires"].append("foo")
config["tool"]["hatch"]["metadata"] = {"hooks": {"custom": {"dependencies": ["binary"]}}}
project.save_config(config)
helpers.update_project_environment(
project,
"default",
{"skip-install": True, "python": available_python_version, **project.config.envs["default"]},
)
build_script = project_path / DEFAULT_BUILD_SCRIPT
build_script.write_text(
helpers.dedent(
"""
from hatchling.metadata.plugin.interface import MetadataHookInterface
class CustomMetadataHook(MetadataHookInterface):
def update(self, metadata):
import binary
"""
)
)
mocker.patch("hatch.env.virtual.VirtualEnvironment._interpreter_is_compatible", return_value=False)
manager = PythonManager(data_path / "env" / "virtual" / ".pythons")
assert not manager.get_installed()
with project_path.as_cwd(env_vars={ConfigEnvVars.DATA: str(data_path)}):
result = hatch(
"run", "python", "-c", "import os,sys;open('test.txt', 'a').write(sys.executable+os.linesep[-1])"
)
assert result.exit_code == 0, result.output
assert result.output == helpers.dedent(
f"""
Creating environment: default
Installing Python distribution: {available_python_version}
Checking dependencies
"""
)
output_file = project_path / "test.txt"
assert output_file.is_file()
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
assert str(env_path) in str(output_file.read_text())
assert list(manager.get_installed()) == [available_python_version]
class TestScriptRunner:
@pytest.mark.requires_internet
def test_not_file(self, hatch, helpers, temp_dir):
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()
project = Project(project_path)
helpers.update_project_environment(
project,
"default",
{"skip-install": True, "scripts": {"script.py": "python -c {args}"}, **project.config.envs["default"]},
)
with project_path.as_cwd(env_vars={ConfigEnvVars.DATA: str(data_path)}):
result = hatch("run", "script.py", "import pathlib,sys;pathlib.Path('test.txt').write_text(sys.executable)")
assert result.exit_code == 0, result.output
assert result.output == helpers.dedent(
"""
Creating environment: default
Checking dependencies
"""
)
output_file = project_path / "test.txt"
assert output_file.is_file()
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
assert str(env_path) in str(output_file.read_text())
@pytest.mark.requires_internet
def test_dependencies(self, hatch, helpers, temp_dir):
data_path = temp_dir / "data"
data_path.mkdir()
script = (temp_dir / "script.py").resolve()
script.write_text(
helpers.dedent(
"""
# /// script
# dependencies = [
# "binary",
# ]
# ///
import pathlib
import sys
import binary
pathlib.Path('test.txt').write_text(
f'{sys.executable}\\n{str(binary.convert_units(1024))}'
)
"""
)
)
with temp_dir.as_cwd(env_vars={ConfigEnvVars.DATA: str(data_path)}):
result = hatch("run", "script.py")
assert result.exit_code == 0, result.output
assert result.output == helpers.dedent(
f"""
Creating environment: {script.id}
Checking dependencies
Syncing dependencies
"""
)
output_file = temp_dir / "test.txt"
assert output_file.is_file()
env_data_path = data_path / "env" / "virtual" / ".scripts"
assert env_data_path.is_dir()
env_path = env_data_path / script.id
assert env_path.is_dir()
assert env_path.name == script.id
executable_path, unit_conversion = output_file.read_text().splitlines()
executable = Path(executable_path)
assert executable.is_file()
assert data_path in executable.parents
assert unit_conversion == "(1.0, 'KiB')"
@pytest.mark.requires_internet
def test_dependencies_from_tool_config(self, hatch, helpers, temp_dir):
data_path = temp_dir / "data"
data_path.mkdir()
script = (temp_dir / "script.py").resolve()
script.write_text(
helpers.dedent(
"""
# /// script
# dependencies = []
#
# [tool.hatch]
# dependencies = [
# "binary",
# ]
# ///
import pathlib
import sys
import binary
pathlib.Path('test.txt').write_text(
f'{sys.executable}\\n{str(binary.convert_units(1024))}'
)
"""
)
)
with temp_dir.as_cwd(env_vars={ConfigEnvVars.DATA: str(data_path)}):
result = hatch("run", "script.py")
assert result.exit_code == 0, result.output
assert result.output == helpers.dedent(
f"""
Creating environment: {script.id}
Checking dependencies
Syncing dependencies
"""
)
output_file = temp_dir / "test.txt"
assert output_file.is_file()
env_data_path = data_path / "env" / "virtual" / ".scripts"
assert env_data_path.is_dir()
env_path = env_data_path / script.id
assert env_path.is_dir()
assert env_path.name == script.id
executable_path, unit_conversion = output_file.read_text().splitlines()
executable = Path(executable_path)
assert executable.is_file()
assert data_path in executable.parents
assert unit_conversion == "(1.0, 'KiB')"
def test_unsupported_python_version(self, hatch, helpers, temp_dir):
data_path = temp_dir / "data"
data_path.mkdir()
script = (temp_dir / "script.py").resolve()
script.write_text(
helpers.dedent(
"""
# /// script
# requires-python = ">9000"
# ///
import pathlib
import sys
pathlib.Path('test.txt').write_text(sys.executable)
"""
)
)
with temp_dir.as_cwd(env_vars={ConfigEnvVars.DATA: str(data_path)}):
result = hatch("run", "script.py")
assert result.exit_code == 1, result.output
assert result.output == helpers.dedent(
"""
Unable to satisfy Python version constraint: >9000
"""
)
@pytest.mark.requires_internet
def test_python_version_constraint(self, hatch, helpers, temp_dir):
data_path = temp_dir / "data"
data_path.mkdir()
script = (temp_dir / "script.py").resolve()
# Cap the range at the current minor version so that the current Python
# will be used and distributions don't have to be downloaded
major, minor = sys.version_info[:2]
minor += 1
script.write_text(
helpers.dedent(
f"""
# /// script
# requires-python = "<{major}.{minor}"
# ///
import pathlib
import sys
pathlib.Path('test.txt').write_text(sys.executable)
"""
)
)
with temp_dir.as_cwd(env_vars={ConfigEnvVars.DATA: str(data_path)}):
result = hatch("run", "script.py")
assert result.exit_code == 0, result.output
assert result.output == helpers.dedent(
f"""
Creating environment: {script.id}
Checking dependencies
"""
)
output_file = temp_dir / "test.txt"
assert output_file.is_file()
env_data_path = data_path / "env" / "virtual" / ".scripts"
assert env_data_path.is_dir()
env_path = env_data_path / script.id
assert env_path.is_dir()
assert env_path.name == script.id
executable = Path(output_file.read_text())
assert executable.is_file()
assert data_path in executable.parents
def test_python_version_constraint_from_tool_config(self, hatch, helpers, temp_dir):
data_path = temp_dir / "data"
data_path.mkdir()
script = (temp_dir / "script.py").resolve()
# Use the current minor version so that the current Python
# will be used and distributions don't have to be downloaded
major, minor = sys.version_info[:2]
python_version = f"{major}.{minor}"
if FREE_THREADED_BUILD:
python_version += "t"
script.write_text(
helpers.dedent(
f"""
# /// script
# requires-python = ">9000"
#
# [tool.hatch]
# python = "{python_version}"
# ///
import pathlib
import sys
pathlib.Path('test.txt').write_text(sys.executable)
"""
)
)
with temp_dir.as_cwd(env_vars={ConfigEnvVars.DATA: str(data_path)}):
result = hatch("run", "script.py")
assert result.exit_code == 0, result.output
assert result.output == helpers.dedent(
f"""
Creating environment: {script.id}
Checking dependencies
"""
)
output_file = temp_dir / "test.txt"
assert output_file.is_file()
env_data_path = data_path / "env" / "virtual" / ".scripts"
assert env_data_path.is_dir()
env_path = env_data_path / script.id
assert env_path.is_dir()
assert env_path.name == script.id
executable = Path(output_file.read_text())
assert executable.is_file()
assert data_path in executable.parents
================================================
FILE: tests/cli/self/__init__.py
================================================
================================================
FILE: tests/cli/self/test_report.py
================================================
import os
import sys
from textwrap import indent
from urllib.parse import unquote_plus
import pytest
from hatch._version import __version__
from hatch.utils.structures import EnvVars
URL = "https://github.com/pypa/hatch/issues/new?body="
STATIC_BODY = """\
## Current behavior
## Expected behavior
## Additional context
"""
def assert_call(open_new_tab, expected_body):
assert len(open_new_tab.mock_calls) == 1
assert len(open_new_tab.mock_calls[0].args) == 1
url = open_new_tab.mock_calls[0].args[0]
assert url.startswith(URL)
body = unquote_plus(url[len(URL) :])
assert body == expected_body
class TestDefault:
def test_open(self, hatch, mocker, platform):
open_new_tab = mocker.patch("webbrowser.open_new_tab")
result = hatch(os.environ["PYAPP_COMMAND_NAME"], "report")
assert result.exit_code == 0, result.output
assert not result.output
expected_body = f"""\
{STATIC_BODY}
## Debug
### Installation
- Source: pip
- Version: {__version__}
- Platform: {platform.display_name}
- Python version:
```
{indent(sys.version, " " * 4)}
```
### Configuration
```toml
mode = "local"
shell = ""
```
"""
assert_call(open_new_tab, expected_body)
def test_no_open(self, hatch, platform):
result = hatch(os.environ["PYAPP_COMMAND_NAME"], "report", "--no-open")
assert result.exit_code == 0, result.output
assert result.output.startswith(URL)
body = unquote_plus(result.output.rstrip()[len(URL) :])
assert (
body
== f"""\
{STATIC_BODY}
## Debug
### Installation
- Source: pip
- Version: {__version__}
- Platform: {platform.display_name}
- Python version:
```
{indent(sys.version, " " * 4)}
```
### Configuration
```toml
mode = "local"
shell = ""
```
"""
)
def test_binary(hatch, mocker, platform, temp_dir):
mock_executable = temp_dir / "exe"
mock_executable.touch()
mocker.patch("sys.executable", str(mock_executable))
mocker.patch("platformdirs.user_data_dir", return_value=str(temp_dir))
open_new_tab = mocker.patch("webbrowser.open_new_tab")
result = hatch(os.environ["PYAPP_COMMAND_NAME"], "report")
assert result.exit_code == 0, result.output
assert not result.output
expected_body = f"""\
{STATIC_BODY}
## Debug
### Installation
- Source: binary
- Version: {__version__}
- Platform: {platform.display_name}
- Python version:
```
{indent(sys.version, " " * 4)}
```
### Configuration
```toml
mode = "local"
shell = ""
```
"""
assert_call(open_new_tab, expected_body)
def test_pipx(hatch, mocker, platform, temp_dir):
mock_executable = temp_dir / ".local" / "pipx" / "venvs" / "exe"
mock_executable.parent.ensure_dir_exists()
mock_executable.touch()
mocker.patch("sys.executable", str(mock_executable))
mocker.patch("pathlib.Path.home", return_value=temp_dir)
open_new_tab = mocker.patch("webbrowser.open_new_tab")
result = hatch(os.environ["PYAPP_COMMAND_NAME"], "report")
assert result.exit_code == 0, result.output
assert not result.output
expected_body = f"""\
{STATIC_BODY}
## Debug
### Installation
- Source: pipx
- Version: {__version__}
- Platform: {platform.display_name}
- Python version:
```
{indent(sys.version, " " * 4)}
```
### Configuration
```toml
mode = "local"
shell = ""
```
"""
assert_call(open_new_tab, expected_body)
def test_system(hatch, mocker, platform, temp_dir):
indicator = temp_dir / "EXTERNALLY-MANAGED"
indicator.touch()
mocker.patch("sysconfig.get_path", return_value=str(temp_dir))
mocker.patch("sys.prefix", "foo")
mocker.patch("sys.base_prefix", "foo")
open_new_tab = mocker.patch("webbrowser.open_new_tab")
result = hatch(os.environ["PYAPP_COMMAND_NAME"], "report")
assert result.exit_code == 0, result.output
assert not result.output
expected_body = f"""\
{STATIC_BODY}
## Debug
### Installation
- Source: system
- Version: {__version__}
- Platform: {platform.display_name}
- Python version:
```
{indent(sys.version, " " * 4)}
```
### Configuration
```toml
mode = "local"
shell = ""
```
"""
assert_call(open_new_tab, expected_body)
@pytest.mark.requires_windows
def test_windows_store(hatch, mocker, platform, temp_dir):
mock_executable = temp_dir / "WindowsApps" / "python.exe"
mock_executable.parent.ensure_dir_exists()
mock_executable.touch()
mocker.patch("sys.executable", str(mock_executable))
open_new_tab = mocker.patch("webbrowser.open_new_tab")
result = hatch(os.environ["PYAPP_COMMAND_NAME"], "report")
assert result.exit_code == 0, result.output
assert not result.output
expected_body = f"""\
{STATIC_BODY}
## Debug
### Installation
- Source: Windows Store
- Version: {__version__}
- Platform: {platform.display_name}
- Python version:
```
{indent(sys.version, " " * 4)}
```
### Configuration
```toml
mode = "local"
shell = ""
```
"""
assert_call(open_new_tab, expected_body)
@pytest.mark.requires_unix
def test_pyenv(hatch, mocker, platform, temp_dir):
mock_executable = temp_dir / "exe"
mock_executable.parent.ensure_dir_exists()
mock_executable.touch()
mocker.patch("sys.executable", str(mock_executable))
open_new_tab = mocker.patch("webbrowser.open_new_tab")
with EnvVars({"PYENV_ROOT": str(temp_dir)}):
result = hatch(os.environ["PYAPP_COMMAND_NAME"], "report")
assert result.exit_code == 0, result.output
assert not result.output
expected_body = f"""\
{STATIC_BODY}
## Debug
### Installation
- Source: Pyenv
- Version: {__version__}
- Platform: {platform.display_name}
- Python version:
```
{indent(sys.version, " " * 4)}
```
### Configuration
```toml
mode = "local"
shell = ""
```
"""
assert_call(open_new_tab, expected_body)
================================================
FILE: tests/cli/self/test_self.py
================================================
import os
def test(hatch):
result = hatch(os.environ["PYAPP_COMMAND_NAME"], "-h")
assert result.exit_code == 0, result.output
================================================
FILE: tests/cli/status/__init__.py
================================================
================================================
FILE: tests/cli/status/test_status.py
================================================
import pytest
from hatch.config.constants import ConfigEnvVars
from hatch.utils.fs import temp_chdir
from hatch.utils.structures import EnvVars
class TestModeLocalDefault:
def test_no_project(self, hatch, isolation, config_file, helpers):
result = hatch("status")
assert result.exit_code == 0, result.output
assert result.output == helpers.dedent(
f"""
[Project] -
[Location] - {isolation}
[Config] - {config_file.path}
"""
)
def test_found_project(self, hatch, temp_dir, config_file, helpers):
project_file = temp_dir / "pyproject.toml"
project_file.touch()
with temp_dir.as_cwd():
result = hatch("status")
assert result.exit_code == 0, result.output
assert result.output == helpers.dedent(
f"""
[Project] - {temp_dir.name} (current directory)
[Location] - {temp_dir}
[Config] - {config_file.path}
"""
)
class TestProjectExplicit:
@pytest.mark.parametrize("file_name", ["pyproject.toml", "setup.py"])
def test_found_project_flag(self, hatch, temp_dir, config_file, helpers, file_name):
project_file = temp_dir / file_name
project_file.touch()
project = "foo"
config_file.model.projects = {project: str(temp_dir)}
config_file.save()
result = hatch("-p", project, "status")
assert result.exit_code == 0, result.output
assert result.output == helpers.dedent(
f"""
[Project] - {project}
[Location] - {temp_dir}
[Config] - {config_file.path}
"""
)
@pytest.mark.parametrize("file_name", ["pyproject.toml", "setup.py"])
def test_found_project_env(self, hatch, temp_dir, config_file, helpers, file_name):
project_file = temp_dir / file_name
project_file.touch()
project = "foo"
config_file.model.projects = {project: str(temp_dir)}
config_file.save()
with EnvVars({ConfigEnvVars.PROJECT: project}):
result = hatch("status")
assert result.exit_code == 0, result.output
assert result.output == helpers.dedent(
f"""
[Project] - {project}
[Location] - {temp_dir}
[Config] - {config_file.path}
"""
)
def test_unknown_project(self, hatch):
project = "foo"
result = hatch("-p", project, "status")
assert result.exit_code == 1
assert result.output == f"Unable to locate project {project}\n"
def test_not_a_project(self, hatch, temp_dir, config_file):
project = "foo"
config_file.model.project = project
config_file.model.projects = {project: str(temp_dir)}
config_file.save()
result = hatch("-p", project, "status")
assert result.exit_code == 1
assert result.output == f"Unable to locate project {project}\n"
class TestModeProject:
def test_no_project(self, hatch, isolation, config_file, helpers):
config_file.model.mode = "project"
config_file.save()
result = hatch("status")
assert result.exit_code == 0, result.output
assert result.output == helpers.dedent(
f"""
Mode is set to `project` but no project is set, defaulting to the current directory
[Project] -
[Location] - {isolation}
[Config] - {config_file.path}
"""
)
def test_unknown_project(self, hatch, isolation, config_file, helpers):
project = "foo"
config_file.model.mode = "project"
config_file.model.project = project
config_file.save()
result = hatch("status")
assert result.exit_code == 0, result.output
assert result.output == helpers.dedent(
f"""
Unable to locate project {project}, defaulting to the current directory
[Project] -
[Location] - {isolation}
[Config] - {config_file.path}
"""
)
def test_not_a_project(self, hatch, temp_dir, config_file, helpers):
project = "foo"
config_file.model.mode = "project"
config_file.model.project = project
config_file.model.projects = {project: str(temp_dir)}
config_file.save()
result = hatch("status")
assert result.exit_code == 0, result.output
assert result.output == helpers.dedent(
f"""
[Project] - {project} (not a project)
[Location] - {temp_dir}
[Config] - {config_file.path}
"""
)
@pytest.mark.parametrize("file_name", ["pyproject.toml", "setup.py"])
def test_found_project(self, hatch, temp_dir, config_file, helpers, file_name):
project_file = temp_dir / file_name
project_file.touch()
project = "foo"
config_file.model.mode = "project"
config_file.model.project = project
config_file.model.projects = {project: str(temp_dir)}
config_file.save()
result = hatch("status")
assert result.exit_code == 0, result.output
assert result.output == helpers.dedent(
f"""
[Project] - {project}
[Location] - {temp_dir}
[Config] - {config_file.path}
"""
)
class TestModeAware:
def test_no_detection_no_project(self, hatch, config_file, helpers, isolation):
config_file.model.mode = "aware"
config_file.save()
result = hatch("status")
assert result.exit_code == 0, result.output
assert result.output == helpers.dedent(
f"""
Mode is set to `aware` but no project is set, defaulting to the current directory
[Project] -
[Location] - {isolation}
[Config] - {config_file.path}
"""
)
def test_unknown_project(self, hatch, isolation, config_file, helpers):
project = "foo"
config_file.model.project = project
config_file.model.mode = "aware"
config_file.save()
result = hatch("status")
assert result.exit_code == 0, result.output
assert result.output == helpers.dedent(
f"""
Unable to locate project {project}, defaulting to the current directory
[Project] -
[Location] - {isolation}
[Config] - {config_file.path}
"""
)
def test_not_a_project(self, hatch, temp_dir, config_file, helpers):
project = "foo"
config_file.model.project = project
config_file.model.projects = {project: str(temp_dir)}
config_file.model.mode = "aware"
config_file.save()
result = hatch("status")
assert result.exit_code == 0, result.output
assert result.output == helpers.dedent(
f"""
[Project] - {project} (not a project)
[Location] - {temp_dir}
[Config] - {config_file.path}
"""
)
@pytest.mark.parametrize("file_name", ["pyproject.toml", "setup.py"])
def test_found_project(self, hatch, temp_dir, config_file, helpers, file_name):
project_file = temp_dir / file_name
project_file.touch()
project = "foo"
config_file.model.project = project
config_file.model.projects = {project: str(temp_dir)}
config_file.model.mode = "aware"
config_file.save()
result = hatch("status")
assert result.exit_code == 0, result.output
assert result.output == helpers.dedent(
f"""
[Project] - {project}
[Location] - {temp_dir}
[Config] - {config_file.path}
"""
)
def test_local_override(self, hatch, temp_dir, config_file, helpers):
project_file = temp_dir / "pyproject.toml"
project_file.touch()
project = "foo"
config_file.model.project = project
config_file.model.projects = {project: str(temp_dir)}
config_file.model.mode = "aware"
config_file.save()
with temp_chdir() as d:
d.joinpath("pyproject.toml").touch()
result = hatch("status")
assert result.exit_code == 0, result.output
assert result.output == helpers.dedent(
f"""
[Project] - {d.name} (current directory)
[Location] - {d}
[Config] - {config_file.path}
"""
)
================================================
FILE: tests/cli/test/__init__.py
================================================
================================================
FILE: tests/cli/test/test_test.py
================================================
from __future__ import annotations
import sys
import pytest
from hatch.config.constants import ConfigEnvVars
from hatch.env.utils import get_env_var
from hatch.project.core import Project
from hatch.utils.structures import EnvVars
@pytest.fixture(scope="module", autouse=True)
def _terminal_width():
with EnvVars({"COLUMNS": "100"}, exclude=[get_env_var(plugin_name="virtual", option="uv_path")]):
yield
class TestDefaults:
def test_basic(self, hatch, temp_dir, config_file, env_run, mocker):
config_file.model.template.plugins["default"]["tests"] = False
config_file.save()
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()
with project_path.as_cwd(env_vars={ConfigEnvVars.DATA: str(data_path)}):
result = hatch("test")
assert result.exit_code == 0, result.output
assert not result.output
assert env_run.call_args_list == [
mocker.call("pytest -p no:randomly tests", shell=True),
]
assert not (data_path / ".config" / "coverage").exists()
def test_arguments(self, hatch, temp_dir, config_file, env_run, mocker):
config_file.model.template.plugins["default"]["tests"] = False
config_file.save()
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()
with project_path.as_cwd(env_vars={ConfigEnvVars.DATA: str(data_path)}):
result = hatch("test", "--", "--flag", "--", "arg1", "arg2")
assert result.exit_code == 0, result.output
assert not result.output
assert env_run.call_args_list == [
mocker.call("pytest -p no:randomly --flag -- arg1 arg2", shell=True),
]
assert not (data_path / ".config" / "coverage").exists()
class TestArguments:
def test_default_args(self, hatch, temp_dir, platform, config_file, env_run, mocker):
config_file.model.template.plugins["default"]["tests"] = False
config_file.save()
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()
project = Project(project_path)
config = dict(project.raw_config)
config["tool"]["hatch"]["envs"] = {"hatch-test": {"default-args": ["tests1", "foo bar", "tests2"]}}
project.save_config(config)
with project_path.as_cwd(env_vars={ConfigEnvVars.DATA: str(data_path)}):
result = hatch("test")
assert result.exit_code == 0, result.output
assert not result.output
escape_char = '"' if platform.windows else "'"
assert env_run.call_args_list == [
mocker.call(f"pytest -p no:randomly tests1 {escape_char}foo bar{escape_char} tests2", shell=True),
]
assert not (data_path / ".config" / "coverage").exists()
def test_args_override(self, hatch, temp_dir, config_file, env_run, mocker):
config_file.model.template.plugins["default"]["tests"] = False
config_file.save()
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()
project = Project(project_path)
config = dict(project.raw_config)
config["tool"]["hatch"]["envs"] = {"hatch-test": {"default-args": ["tests1", "foo bar", "tests2"]}}
project.save_config(config)
with project_path.as_cwd(env_vars={ConfigEnvVars.DATA: str(data_path)}):
result = hatch("test", "--", "--flag", "--", "arg1", "arg2")
assert result.exit_code == 0, result.output
assert not result.output
assert env_run.call_args_list == [
mocker.call("pytest -p no:randomly --flag -- arg1 arg2", shell=True),
]
assert not (data_path / ".config" / "coverage").exists()
def test_extra_args(self, hatch, temp_dir, config_file, env_run, mocker):
config_file.model.template.plugins["default"]["tests"] = False
config_file.save()
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()
project = Project(project_path)
config = dict(project.raw_config)
config["tool"]["hatch"]["envs"] = {"hatch-test": {"extra-args": ["-vv", "--print"]}}
project.save_config(config)
with project_path.as_cwd(env_vars={ConfigEnvVars.DATA: str(data_path)}):
result = hatch("test")
assert result.exit_code == 0, result.output
assert not result.output
assert env_run.call_args_list == [
mocker.call("pytest -vv --print -p no:randomly tests", shell=True),
]
assert not (data_path / ".config" / "coverage").exists()
class TestCoverage:
def test_flag(self, hatch, temp_dir, config_file, env_run, mocker):
config_file.model.template.plugins["default"]["tests"] = False
config_file.save()
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()
with project_path.as_cwd(env_vars={ConfigEnvVars.DATA: str(data_path)}):
result = hatch("test", "--cover")
assert result.exit_code == 0, result.output
assert not result.output
assert env_run.call_args_list == [
mocker.call("coverage run -m pytest -p no:randomly tests", shell=True),
mocker.call("coverage combine", shell=True),
mocker.call("coverage report", shell=True),
]
root_config_path = data_path / ".config" / "coverage"
config_dir = next(root_config_path.iterdir())
coverage_config_file = next(config_dir.iterdir())
assert coverage_config_file.read_text().strip().splitlines()[-2:] == [
"[tool.coverage.run]",
"parallel = true",
]
def test_flag_with_arguments(self, hatch, temp_dir, config_file, env_run, mocker):
config_file.model.template.plugins["default"]["tests"] = False
config_file.save()
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()
with project_path.as_cwd(env_vars={ConfigEnvVars.DATA: str(data_path)}):
result = hatch("test", "--cover", "--", "--flag", "--", "arg1", "arg2")
assert result.exit_code == 0, result.output
assert not result.output
assert env_run.call_args_list == [
mocker.call("coverage run -m pytest -p no:randomly --flag -- arg1 arg2", shell=True),
mocker.call("coverage combine", shell=True),
mocker.call("coverage report", shell=True),
]
root_config_path = data_path / ".config" / "coverage"
config_dir = next(root_config_path.iterdir())
coverage_config_file = next(config_dir.iterdir())
assert coverage_config_file.read_text().strip().splitlines()[-2:] == [
"[tool.coverage.run]",
"parallel = true",
]
def test_quiet_implicitly_enables(self, hatch, temp_dir, config_file, env_run, mocker):
config_file.model.template.plugins["default"]["tests"] = False
config_file.save()
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()
with project_path.as_cwd(env_vars={ConfigEnvVars.DATA: str(data_path)}):
result = hatch("test", "--cover-quiet")
assert result.exit_code == 0, result.output
assert not result.output
assert env_run.call_args_list == [
mocker.call("coverage run -m pytest -p no:randomly tests", shell=True),
mocker.call("coverage combine", shell=True),
]
root_config_path = data_path / ".config" / "coverage"
config_dir = next(root_config_path.iterdir())
coverage_config_file = next(config_dir.iterdir())
assert coverage_config_file.read_text().strip().splitlines()[-2:] == [
"[tool.coverage.run]",
"parallel = true",
]
def test_legacy_config_define_section(self, hatch, temp_dir, config_file, env_run, mocker):
config_file.model.template.plugins["default"]["tests"] = False
config_file.save()
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()
(project_path / ".coveragerc").touch()
with project_path.as_cwd(env_vars={ConfigEnvVars.DATA: str(data_path)}):
result = hatch("test", "--cover")
assert result.exit_code == 0, result.output
assert not result.output
assert env_run.call_args_list == [
mocker.call("coverage run -m pytest -p no:randomly tests", shell=True),
mocker.call("coverage combine", shell=True),
mocker.call("coverage report", shell=True),
]
root_config_path = data_path / ".config" / "coverage"
config_dir = next(root_config_path.iterdir())
coverage_config_file = next(config_dir.iterdir())
assert coverage_config_file.read_text().strip().splitlines() == [
"[run]",
"parallel = true",
]
def test_legacy_config_enable_parallel(self, hatch, temp_dir, config_file, env_run, mocker):
config_file.model.template.plugins["default"]["tests"] = False
config_file.save()
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()
(project_path / ".coveragerc").write_text("[run]\nparallel = false\nbranch = true\n")
with project_path.as_cwd(env_vars={ConfigEnvVars.DATA: str(data_path)}):
result = hatch("test", "--cover")
assert result.exit_code == 0, result.output
assert not result.output
assert env_run.call_args_list == [
mocker.call("coverage run -m pytest -p no:randomly tests", shell=True),
mocker.call("coverage combine", shell=True),
mocker.call("coverage report", shell=True),
]
root_config_path = data_path / ".config" / "coverage"
config_dir = next(root_config_path.iterdir())
coverage_config_file = next(config_dir.iterdir())
assert coverage_config_file.read_text().strip().splitlines() == [
"[run]",
"parallel = true",
"branch = true",
]
class TestRandomize:
def test_flag(self, hatch, temp_dir, config_file, env_run, mocker):
config_file.model.template.plugins["default"]["tests"] = False
config_file.save()
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()
with project_path.as_cwd(env_vars={ConfigEnvVars.DATA: str(data_path)}):
result = hatch("test", "--randomize")
assert result.exit_code == 0, result.output
assert not result.output
assert env_run.call_args_list == [
mocker.call("pytest tests", shell=True),
]
assert not (data_path / ".config" / "coverage").exists()
def test_flag_with_arguments(self, hatch, temp_dir, config_file, env_run, mocker):
config_file.model.template.plugins["default"]["tests"] = False
config_file.save()
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()
with project_path.as_cwd(env_vars={ConfigEnvVars.DATA: str(data_path)}):
result = hatch("test", "--randomize", "--", "--flag", "--", "arg1", "arg2")
assert result.exit_code == 0, result.output
assert not result.output
assert env_run.call_args_list == [
mocker.call("pytest --flag -- arg1 arg2", shell=True),
]
assert not (data_path / ".config" / "coverage").exists()
def test_config(self, hatch, temp_dir, config_file, env_run, mocker):
config_file.model.template.plugins["default"]["tests"] = False
config_file.save()
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()
project = Project(project_path)
config = dict(project.raw_config)
config["tool"]["hatch"]["envs"] = {"hatch-test": {"randomize": True}}
project.save_config(config)
with project_path.as_cwd(env_vars={ConfigEnvVars.DATA: str(data_path)}):
result = hatch("test")
assert result.exit_code == 0, result.output
assert not result.output
assert env_run.call_args_list == [
mocker.call("pytest tests", shell=True),
]
assert not (data_path / ".config" / "coverage").exists()
class TestParallel:
def test_flag(self, hatch, temp_dir, config_file, env_run, mocker):
config_file.model.template.plugins["default"]["tests"] = False
config_file.save()
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()
with project_path.as_cwd(env_vars={ConfigEnvVars.DATA: str(data_path)}):
result = hatch("test", "--parallel")
assert result.exit_code == 0, result.output
assert not result.output
assert env_run.call_args_list == [
mocker.call("pytest -p no:randomly -n logical tests", shell=True),
]
assert not (data_path / ".config" / "coverage").exists()
def test_flag_with_arguments(self, hatch, temp_dir, config_file, env_run, mocker):
config_file.model.template.plugins["default"]["tests"] = False
config_file.save()
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()
with project_path.as_cwd(env_vars={ConfigEnvVars.DATA: str(data_path)}):
result = hatch("test", "--parallel", "--", "--flag", "--", "arg1", "arg2")
assert result.exit_code == 0, result.output
assert not result.output
assert env_run.call_args_list == [
mocker.call("pytest -p no:randomly -n logical --flag -- arg1 arg2", shell=True),
]
assert not (data_path / ".config" / "coverage").exists()
def test_config(self, hatch, temp_dir, config_file, env_run, mocker):
config_file.model.template.plugins["default"]["tests"] = False
config_file.save()
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()
project = Project(project_path)
config = dict(project.raw_config)
config["tool"]["hatch"]["envs"] = {"hatch-test": {"parallel": True}}
project.save_config(config)
with project_path.as_cwd(env_vars={ConfigEnvVars.DATA: str(data_path)}):
result = hatch("test")
assert result.exit_code == 0, result.output
assert not result.output
assert env_run.call_args_list == [
mocker.call("pytest -p no:randomly -n logical tests", shell=True),
]
assert not (data_path / ".config" / "coverage").exists()
class TestRetries:
def test_flag(self, hatch, temp_dir, config_file, env_run, mocker):
config_file.model.template.plugins["default"]["tests"] = False
config_file.save()
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()
with project_path.as_cwd(env_vars={ConfigEnvVars.DATA: str(data_path)}):
result = hatch("test", "--retries", "2")
assert result.exit_code == 0, result.output
assert not result.output
assert env_run.call_args_list == [
mocker.call("pytest -p no:randomly -r aR --reruns 2 tests", shell=True),
]
assert not (data_path / ".config" / "coverage").exists()
def test_flag_with_arguments(self, hatch, temp_dir, config_file, env_run, mocker):
config_file.model.template.plugins["default"]["tests"] = False
config_file.save()
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()
with project_path.as_cwd(env_vars={ConfigEnvVars.DATA: str(data_path)}):
result = hatch("test", "--retries", "2", "--", "--flag", "--", "arg1", "arg2")
assert result.exit_code == 0, result.output
assert not result.output
assert env_run.call_args_list == [
mocker.call("pytest -p no:randomly -r aR --reruns 2 --flag -- arg1 arg2", shell=True),
]
assert not (data_path / ".config" / "coverage").exists()
def test_config(self, hatch, temp_dir, config_file, env_run, mocker):
config_file.model.template.plugins["default"]["tests"] = False
config_file.save()
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()
project = Project(project_path)
config = dict(project.raw_config)
config["tool"]["hatch"]["envs"] = {"hatch-test": {"retries": 2}}
project.save_config(config)
with project_path.as_cwd(env_vars={ConfigEnvVars.DATA: str(data_path)}):
result = hatch("test")
assert result.exit_code == 0, result.output
assert not result.output
assert env_run.call_args_list == [
mocker.call("pytest -p no:randomly -r aR --reruns 2 tests", shell=True),
]
assert not (data_path / ".config" / "coverage").exists()
class TestRetryDelay:
@pytest.mark.usefixtures("env_run")
def test_no_retries(self, hatch, temp_dir, config_file):
config_file.model.template.plugins["default"]["tests"] = False
config_file.save()
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()
with project_path.as_cwd(env_vars={ConfigEnvVars.DATA: str(data_path)}):
result = hatch("test", "--retry-delay", "3.14")
assert result.exit_code == 1, result.output
assert result.output == "The --retry-delay option requires the --retries option to be set as well.\n"
def test_flag(self, hatch, temp_dir, config_file, env_run, mocker):
config_file.model.template.plugins["default"]["tests"] = False
config_file.save()
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()
with project_path.as_cwd(env_vars={ConfigEnvVars.DATA: str(data_path)}):
result = hatch("test", "--retries", "2", "--retry-delay", "3.14")
assert result.exit_code == 0, result.output
assert not result.output
assert env_run.call_args_list == [
mocker.call("pytest -p no:randomly -r aR --reruns 2 --reruns-delay 3.14 tests", shell=True),
]
assert not (data_path / ".config" / "coverage").exists()
def test_flag_with_arguments(self, hatch, temp_dir, config_file, env_run, mocker):
config_file.model.template.plugins["default"]["tests"] = False
config_file.save()
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()
with project_path.as_cwd(env_vars={ConfigEnvVars.DATA: str(data_path)}):
result = hatch("test", "--retries", "2", "--retry-delay", "3.14", "--", "--flag", "--", "arg1", "arg2")
assert result.exit_code == 0, result.output
assert not result.output
assert env_run.call_args_list == [
mocker.call("pytest -p no:randomly -r aR --reruns 2 --reruns-delay 3.14 --flag -- arg1 arg2", shell=True),
]
assert not (data_path / ".config" / "coverage").exists()
def test_config(self, hatch, temp_dir, config_file, env_run, mocker):
config_file.model.template.plugins["default"]["tests"] = False
config_file.save()
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()
project = Project(project_path)
config = dict(project.raw_config)
config["tool"]["hatch"]["envs"] = {"hatch-test": {"retry-delay": 1.23}}
project.save_config(config)
with project_path.as_cwd(env_vars={ConfigEnvVars.DATA: str(data_path)}):
result = hatch("test", "--retries", "2")
assert result.exit_code == 0, result.output
assert not result.output
assert env_run.call_args_list == [
mocker.call("pytest -p no:randomly -r aR --reruns 2 --reruns-delay 1.23 tests", shell=True),
]
assert not (data_path / ".config" / "coverage").exists()
class TestCustomScripts:
def test_basic(self, hatch, temp_dir, config_file, env_run, mocker):
config_file.model.template.plugins["default"]["tests"] = False
config_file.save()
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()
project = Project(project_path)
config = dict(project.raw_config)
config["tool"]["hatch"]["envs"] = {
"hatch-test": {
"scripts": {
"run": "test",
"run-cov": "test with coverage",
"cov-combine": "combine coverage",
"cov-report": "show coverage",
},
}
}
project.save_config(config)
with project_path.as_cwd(env_vars={ConfigEnvVars.DATA: str(data_path)}):
result = hatch("test")
assert result.exit_code == 0, result.output
assert not result.output
assert env_run.call_args_list == [
mocker.call("test", shell=True),
]
assert not (data_path / ".config" / "coverage").exists()
def test_coverage(self, hatch, temp_dir, config_file, env_run, mocker):
config_file.model.template.plugins["default"]["tests"] = False
config_file.save()
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()
project = Project(project_path)
config = dict(project.raw_config)
config["tool"]["hatch"]["envs"] = {
"hatch-test": {
"scripts": {
"run": "test",
"run-cov": "test with coverage",
"cov-combine": "combine coverage",
"cov-report": "show coverage",
},
}
}
project.save_config(config)
with project_path.as_cwd(env_vars={ConfigEnvVars.DATA: str(data_path)}):
result = hatch("test", "--cover")
assert result.exit_code == 0, result.output
assert not result.output
assert env_run.call_args_list == [
mocker.call("test with coverage", shell=True),
mocker.call("combine coverage", shell=True),
mocker.call("show coverage", shell=True),
]
root_config_path = data_path / ".config" / "coverage"
config_dir = next(root_config_path.iterdir())
coverage_config_file = next(config_dir.iterdir())
assert coverage_config_file.read_text().strip().splitlines()[-2:] == [
"[tool.coverage.run]",
"parallel = true",
]
def test_single(self, hatch, temp_dir, config_file, env_run, mocker):
config_file.model.template.plugins["default"]["tests"] = False
config_file.save()
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()
project = Project(project_path)
config = dict(project.raw_config)
config["tool"]["hatch"]["envs"] = {
"hatch-test": {
"scripts": {
"run": "test {env_name}",
"run-cov": "test with coverage",
"cov-combine": "combine coverage",
"cov-report": "show coverage",
},
}
}
project.save_config(config)
with project_path.as_cwd(env_vars={ConfigEnvVars.DATA: str(data_path)}):
result = hatch("test")
assert result.exit_code == 0, result.output
assert not result.output
assert env_run.call_args_list == [
mocker.call(f"test hatch-test.py{'.'.join(map(str, sys.version_info[:2]))}", shell=True),
]
assert not (data_path / ".config" / "coverage").exists()
def test_matrix(self, hatch, temp_dir, config_file, helpers, env_run, mocker):
config_file.model.template.plugins["default"]["tests"] = False
config_file.save()
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()
project = Project(project_path)
config = dict(project.raw_config)
config["tool"]["hatch"]["envs"] = {
"hatch-test": {
"matrix": [{"python": ["3.12", "3.10", "3.8"]}],
"scripts": {
"run": "test {env_name}",
"run-cov": "test with coverage",
"cov-combine": "combine coverage",
"cov-report": "show coverage",
},
}
}
project.save_config(config)
with project_path.as_cwd(env_vars={ConfigEnvVars.DATA: str(data_path)}):
result = hatch("test", "--all")
assert result.exit_code == 0, result.output
assert result.output == helpers.dedent(
"""
──────────────────────────────────────── hatch-test.py3.12 ─────────────────────────────────────────
──────────────────────────────────────── hatch-test.py3.10 ─────────────────────────────────────────
───────────────────────────────────────── hatch-test.py3.8 ─────────────────────────────────────────
"""
)
assert env_run.call_args_list == [
mocker.call("test hatch-test.py3.12", shell=True),
mocker.call("test hatch-test.py3.10", shell=True),
mocker.call("test hatch-test.py3.8", shell=True),
]
assert not (data_path / ".config" / "coverage").exists()
class TestFilters:
@pytest.mark.usefixtures("env_run")
@pytest.mark.parametrize("option", ["--include", "--exclude"])
def test_usage_with_all(self, hatch, temp_dir, config_file, helpers, option):
config_file.model.template.plugins["default"]["tests"] = False
config_file.save()
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()
with project_path.as_cwd(env_vars={ConfigEnvVars.DATA: str(data_path)}):
result = hatch("test", "--all", option, "py=3.10")
assert result.exit_code == 1, result.output
assert result.output == helpers.dedent(
"""
The --all option cannot be used with the --include or --exclude options.
"""
)
def test_include(self, hatch, temp_dir, config_file, helpers, env_run, mocker):
config_file.model.template.plugins["default"]["tests"] = False
config_file.save()
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()
project = Project(project_path)
config = dict(project.raw_config)
config["tool"]["hatch"]["envs"] = {
"hatch-test": {
"matrix": [{"python": ["3.12", "3.10", "3.8"]}],
"scripts": {
"run": "test {env_name}",
"run-cov": "test with coverage",
"cov-combine": "combine coverage",
"cov-report": "show coverage",
},
}
}
project.save_config(config)
with project_path.as_cwd(env_vars={ConfigEnvVars.DATA: str(data_path)}):
result = hatch("test", "-i", "py=3.10")
assert result.exit_code == 0, result.output
assert result.output == helpers.dedent(
"""
──────────────────────────────────────── hatch-test.py3.10 ─────────────────────────────────────────
"""
)
assert env_run.call_args_list == [
mocker.call("test hatch-test.py3.10", shell=True),
]
assert not (data_path / ".config" / "coverage").exists()
def test_exclude(self, hatch, temp_dir, config_file, helpers, env_run, mocker):
config_file.model.template.plugins["default"]["tests"] = False
config_file.save()
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()
project = Project(project_path)
config = dict(project.raw_config)
config["tool"]["hatch"]["envs"] = {
"hatch-test": {
"matrix": [{"python": ["3.12", "3.10", "3.8"]}],
"scripts": {
"run": "test {env_name}",
"run-cov": "test with coverage",
"cov-combine": "combine coverage",
"cov-report": "show coverage",
},
}
}
project.save_config(config)
with project_path.as_cwd(env_vars={ConfigEnvVars.DATA: str(data_path)}):
result = hatch("test", "-x", "py=3.10")
assert result.exit_code == 0, result.output
assert result.output == helpers.dedent(
"""
──────────────────────────────────────── hatch-test.py3.12 ─────────────────────────────────────────
───────────────────────────────────────── hatch-test.py3.8 ─────────────────────────────────────────
"""
)
assert env_run.call_args_list == [
mocker.call("test hatch-test.py3.12", shell=True),
mocker.call("test hatch-test.py3.8", shell=True),
]
assert not (data_path / ".config" / "coverage").exists()
def test_python(self, hatch, temp_dir, config_file, helpers, env_run, mocker):
config_file.model.template.plugins["default"]["tests"] = False
config_file.save()
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()
project = Project(project_path)
config = dict(project.raw_config)
config["tool"]["hatch"]["envs"] = {
"hatch-test": {
"matrix": [{"python": ["3.12", "3.10", "3.8"]}],
"scripts": {
"run": "test {env_name}",
"run-cov": "test with coverage",
"cov-combine": "combine coverage",
"cov-report": "show coverage",
},
}
}
project.save_config(config)
with project_path.as_cwd(env_vars={ConfigEnvVars.DATA: str(data_path)}):
result = hatch("test", "-py", "3.10")
assert result.exit_code == 0, result.output
assert result.output == helpers.dedent(
"""
──────────────────────────────────────── hatch-test.py3.10 ─────────────────────────────────────────
"""
)
assert env_run.call_args_list == [
mocker.call("test hatch-test.py3.10", shell=True),
]
assert not (data_path / ".config" / "coverage").exists()
class TestShow:
def test_default_compact(self, hatch, temp_dir, config_file, helpers):
config_file.model.template.plugins["default"]["tests"] = False
config_file.save()
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()
project = Project(project_path)
config = dict(project.raw_config)
config["tool"]["hatch"]["envs"] = {
"hatch-test": {
"matrix": [{"python": ["3.12", "3.10", "3.8"]}],
"dependencies": ["foo", "bar"],
"scripts": {
"run": "test {env_name}",
"run-cov": "test with coverage",
"cov-combine": "combine coverage",
"cov-report": "show coverage",
},
}
}
project.save_config(config)
with project_path.as_cwd(env_vars={ConfigEnvVars.DATA: str(data_path)}):
result = hatch("test", "--show")
assert result.exit_code == 0, result.output
assert helpers.remove_trailing_spaces(result.output) == helpers.dedent(
"""
+------------+---------+-------------------+--------------+-------------+
| Name | Type | Envs | Dependencies | Scripts |
+============+=========+===================+==============+=============+
| hatch-test | virtual | hatch-test.py3.12 | bar | cov-combine |
| | | hatch-test.py3.10 | foo | cov-report |
| | | hatch-test.py3.8 | | run |
| | | | | run-cov |
+------------+---------+-------------------+--------------+-------------+
"""
)
def test_verbose(self, hatch, temp_dir, config_file, helpers):
config_file.model.template.plugins["default"]["tests"] = False
config_file.save()
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()
project = Project(project_path)
config = dict(project.raw_config)
config["tool"]["hatch"]["envs"] = {
"hatch-test": {
"matrix": [{"python": ["3.12", "3.10", "3.8"]}],
"dependencies": ["foo", "bar"],
"scripts": {
"run": "test {env_name}",
"run-cov": "test with coverage",
"cov-combine": "combine coverage",
"cov-report": "show coverage",
},
"overrides": {"matrix": {"python": {"description": {"value": "test 3.10", "if": ["3.10"]}}}},
}
}
project.save_config(config)
with project_path.as_cwd(env_vars={ConfigEnvVars.DATA: str(data_path)}):
result = hatch("-v", "test", "--show")
assert result.exit_code == 0, result.output
assert helpers.remove_trailing_spaces(result.output) == helpers.dedent(
"""
+-------------------+---------+--------------+-------------+-------------+
| Name | Type | Dependencies | Scripts | Description |
+===================+=========+==============+=============+=============+
| hatch-test.py3.12 | virtual | bar | cov-combine | |
| | | foo | cov-report | |
| | | | run | |
| | | | run-cov | |
+-------------------+---------+--------------+-------------+-------------+
| hatch-test.py3.10 | virtual | bar | cov-combine | test 3.10 |
| | | foo | cov-report | |
| | | | run | |
| | | | run-cov | |
+-------------------+---------+--------------+-------------+-------------+
| hatch-test.py3.8 | virtual | bar | cov-combine | |
| | | foo | cov-report | |
| | | | run | |
| | | | run-cov | |
+-------------------+---------+--------------+-------------+-------------+
"""
)
================================================
FILE: tests/cli/test_root.py
================================================
import os
from hatch.config.constants import ConfigEnvVars
from hatch.config.user import ConfigFile
from hatch.utils.structures import EnvVars
class TestFreshInstallation:
INSTALL_MESSAGE = """\
No config file found, creating one with default settings now...
Success! Please see `hatch config`.
"""
def test_config_file_creation_default(self, hatch):
with EnvVars():
os.environ.pop(ConfigEnvVars.CONFIG, None)
with ConfigFile.get_default_location().temp_hide():
result = hatch()
assert self.INSTALL_MESSAGE not in result.output
def test_config_file_creation_verbose(self, hatch):
with EnvVars():
os.environ.pop(ConfigEnvVars.CONFIG, None)
with ConfigFile.get_default_location().temp_hide():
result = hatch("-v")
assert self.INSTALL_MESSAGE in result.output
result = hatch("-v")
assert self.INSTALL_MESSAGE not in result.output
def test_no_subcommand_shows_help(hatch):
assert hatch().output == hatch("--help").output
def test_no_config_file(hatch, config_file):
config_file.path.remove()
result = hatch()
assert result.exit_code == 1
assert result.output == f"The selected config file `{config_file.path}` does not exist.\n"
================================================
FILE: tests/cli/version/__init__.py
================================================
================================================
FILE: tests/cli/version/test_version.py
================================================
import os
import pytest
from hatch.config.constants import ConfigEnvVars
from hatch.project.core import Project
from hatchling.utils.constants import DEFAULT_CONFIG_FILE
class TestNoProject:
def test_random_directory(self, hatch, temp_dir, helpers):
with temp_dir.as_cwd():
result = hatch("version")
assert result.exit_code == 1, result.output
assert result.output == helpers.dedent(
"""
No project detected
"""
)
def test_configured_project(self, hatch, temp_dir, helpers, config_file):
project = "foo"
config_file.model.mode = "project"
config_file.model.project = project
config_file.model.projects = {project: str(temp_dir)}
config_file.save()
with temp_dir.as_cwd():
result = hatch("version")
assert result.exit_code == 1, result.output
assert result.output == helpers.dedent(
"""
Project foo (not a project)
"""
)
def test_other_backend_show(hatch, temp_dir, helpers):
project_name = "My.App"
with temp_dir.as_cwd():
result = hatch("new", project_name)
assert result.exit_code == 0, result.output
path = temp_dir / "my-app"
data_path = temp_dir / "data"
data_path.mkdir()
(path / "src" / "my_app" / "__init__.py").write_text('__version__ = "9000.42"')
project = Project(path)
config = dict(project.raw_config)
config["build-system"]["requires"] = ["flit-core"]
config["build-system"]["build-backend"] = "flit_core.buildapi"
del config["project"]["license"]
project.save_config(config)
with path.as_cwd(env_vars={ConfigEnvVars.DATA: str(data_path)}):
result = hatch("version")
assert result.exit_code == 0, result.output
assert result.output == helpers.dedent(
"""
Creating environment: hatch-build
Checking dependencies
Syncing dependencies
Inspecting build dependencies
9000.42
"""
)
def test_other_backend_set(hatch, temp_dir, helpers):
project_name = "My.App"
with temp_dir.as_cwd():
result = hatch("new", project_name)
assert result.exit_code == 0, result.output
path = temp_dir / "my-app"
data_path = temp_dir / "data"
data_path.mkdir()
project = Project(path)
config = dict(project.raw_config)
config["build-system"]["requires"] = ["flit-core"]
config["build-system"]["build-backend"] = "flit_core.buildapi"
del config["project"]["license"]
project.save_config(config)
with path.as_cwd(env_vars={ConfigEnvVars.DATA: str(data_path)}):
result = hatch("version", "1.0.0")
assert result.exit_code == 1, result.output
assert result.output == helpers.dedent(
"""
The version can only be set when Hatchling is the build backend
"""
)
def test_incompatible_environment(hatch, temp_dir, helpers, build_env_config):
project_name = "My.App"
with temp_dir.as_cwd():
result = hatch("new", project_name)
assert result.exit_code == 0, result.output
path = temp_dir / "my-app"
data_path = temp_dir / "data"
data_path.mkdir()
project = Project(path)
config = dict(project.raw_config)
config["build-system"]["requires"].append("foo")
project.save_config(config)
helpers.update_project_environment(project, "hatch-build", {"python": "9000", **build_env_config})
with path.as_cwd(env_vars={ConfigEnvVars.DATA: str(data_path)}):
result = hatch("version")
assert result.exit_code == 1, result.output
assert result.output == helpers.dedent(
"""
Environment `hatch-build` is incompatible: cannot locate Python: 9000
"""
)
@pytest.mark.usefixtures("mock_backend_process")
def test_show_dynamic(hatch, helpers, temp_dir):
project_name = "My.App"
with temp_dir.as_cwd():
hatch("new", project_name)
path = temp_dir / "my-app"
data_path = temp_dir / "data"
data_path.mkdir()
with path.as_cwd(env_vars={ConfigEnvVars.DATA: str(data_path)}):
result = hatch("version")
assert result.exit_code == 0, result.output
assert result.output == helpers.dedent(
"""
Creating environment: hatch-build
Checking dependencies
Syncing dependencies
Inspecting build dependencies
0.0.1
"""
)
@pytest.mark.usefixtures("mock_backend_process")
def test_plugin_dependencies_unmet(hatch, helpers, temp_dir, mock_plugin_installation):
project_name = "My.App"
with temp_dir.as_cwd():
hatch("new", project_name)
path = temp_dir / "my-app"
data_path = temp_dir / "data"
data_path.mkdir()
dependency = os.urandom(16).hex()
(path / DEFAULT_CONFIG_FILE).write_text(
helpers.dedent(
f"""
[env]
requires = ["{dependency}"]
"""
)
)
with path.as_cwd(env_vars={ConfigEnvVars.DATA: str(data_path)}):
result = hatch("version")
assert result.exit_code == 0, result.output
assert result.output == helpers.dedent(
"""
Syncing environment plugin requirements
Creating environment: hatch-build
Checking dependencies
Syncing dependencies
Inspecting build dependencies
0.0.1
"""
)
helpers.assert_plugin_installation(mock_plugin_installation, [dependency])
@pytest.mark.requires_internet
@pytest.mark.usefixtures("mock_backend_process")
def test_no_compatibility_check_if_exists(hatch, helpers, temp_dir, mocker):
project_name = "My.App"
with temp_dir.as_cwd():
hatch("new", project_name)
project_path = temp_dir / "my-app"
data_path = temp_dir / "data"
data_path.mkdir()
project = Project(project_path)
config = dict(project.raw_config)
config["build-system"]["requires"].append("binary")
project.save_config(config)
with project_path.as_cwd(env_vars={ConfigEnvVars.DATA: str(data_path)}):
result = hatch("version")
assert result.exit_code == 0, result.output
assert result.output == helpers.dedent(
"""
Creating environment: hatch-build
Checking dependencies
Syncing dependencies
Inspecting build dependencies
0.0.1
"""
)
mocker.patch("hatch.env.virtual.VirtualEnvironment.check_compatibility", side_effect=Exception("incompatible"))
with project_path.as_cwd(env_vars={ConfigEnvVars.DATA: str(data_path)}):
result = hatch("version")
assert result.exit_code == 0, result.output
assert result.output == helpers.dedent(
"""
Inspecting build dependencies
0.0.1
"""
)
@pytest.mark.usefixtures("mock_backend_process")
def test_set_dynamic(hatch, helpers, temp_dir):
project_name = "My.App"
with temp_dir.as_cwd():
hatch("new", project_name)
path = temp_dir / "my-app"
data_path = temp_dir / "data"
data_path.mkdir()
with path.as_cwd(env_vars={ConfigEnvVars.DATA: str(data_path)}):
result = hatch("version", "minor,rc")
assert result.exit_code == 0, result.output
assert result.output == helpers.dedent(
"""
Creating environment: hatch-build
Checking dependencies
Syncing dependencies
Inspecting build dependencies
Old: 0.0.1
New: 0.1.0rc0
"""
)
with path.as_cwd(env_vars={ConfigEnvVars.DATA: str(data_path)}):
result = hatch("version")
assert result.exit_code == 0, result.output
assert result.output == helpers.dedent(
"""
Inspecting build dependencies
0.1.0rc0
"""
)
@pytest.mark.usefixtures("mock_backend_process")
def test_set_dynamic_downgrade(hatch, helpers, temp_dir):
project_name = "My.App"
with temp_dir.as_cwd():
hatch("new", project_name)
path = temp_dir / "my-app"
data_path = temp_dir / "data"
data_path.mkdir()
(path / "src" / "my_app" / "__about__.py").write_text('__version__ = "21.1.2"')
# This one fails, because it's a downgrade without --force
with path.as_cwd(env_vars={ConfigEnvVars.DATA: str(data_path)}):
result = hatch("version", "21.1.0", catch_exceptions=True)
assert result.exit_code == 1, result.output
assert str(result.exception) == "Version `21.1.0` is not higher than the original version `21.1.2`"
# Try again, this time with --force
with path.as_cwd(env_vars={ConfigEnvVars.DATA: str(data_path)}):
result = hatch("version", "--force", "21.1.0")
assert result.exit_code == 0, result.output
assert result.output == helpers.dedent(
"""
Inspecting build dependencies
Old: 21.1.2
New: 21.1.0
"""
)
with path.as_cwd(env_vars={ConfigEnvVars.DATA: str(data_path)}):
result = hatch("version")
assert result.exit_code == 0, result.output
assert result.output == helpers.dedent(
"""
Inspecting build dependencies
21.1.0
"""
)
def test_show_static(hatch, temp_dir):
project_name = "My.App"
with temp_dir.as_cwd():
hatch("new", project_name)
path = temp_dir / "my-app"
project = Project(path)
config = dict(project.raw_config)
config["project"]["version"] = "1.2.3"
config["project"]["dynamic"].remove("version")
config["tool"]["hatch"]["metadata"] = {"hooks": {"foo": {}}}
project.save_config(config)
with path.as_cwd():
result = hatch("version")
assert result.exit_code == 0, result.output
assert result.output == "1.2.3\n"
def test_set_static(hatch, helpers, temp_dir):
project_name = "My.App"
with temp_dir.as_cwd():
hatch("new", project_name)
path = temp_dir / "my-app"
data_path = temp_dir / "data"
data_path.mkdir()
project = Project(path)
config = dict(project.raw_config)
config["project"]["version"] = "1.2.3"
config["project"]["dynamic"].remove("version")
project.save_config(config)
with path.as_cwd(env_vars={ConfigEnvVars.DATA: str(data_path)}):
result = hatch("version", "minor,rc")
assert result.exit_code == 1, result.output
assert result.output == helpers.dedent(
"""
Cannot set version when it is statically defined by the `project.version` field
"""
)
@pytest.mark.usefixtures("mock_backend_process")
def test_verbose_output_to_stderr(hatch, temp_dir):
"""Test that verbose output (command display and status messages) goes to stderr, not stdout."""
project_name = "My.App"
with temp_dir.as_cwd():
hatch("new", project_name)
path = temp_dir / "my-app"
data_path = temp_dir / "data"
data_path.mkdir()
# Run with verbose flag (-v) and separate stderr from stdout
with path.as_cwd(env_vars={ConfigEnvVars.DATA: str(data_path)}):
result = hatch("-v", "version")
assert result.exit_code == 0, result.output
# The actual version should be in stdout
assert result.stdout == "0.0.1\n"
# Verbose output should be in stderr
assert "Inspecting build dependencies" in result.stderr
assert "cmd [1] | python -u -m hatchling version" in result.stderr
# These should NOT be in stdout
assert "Inspecting build dependencies" not in result.stdout
assert "cmd [1]" not in result.stdout
================================================
FILE: tests/config/__init__.py
================================================
================================================
FILE: tests/config/test_model.py
================================================
import subprocess
import pytest
from hatch.config.model import ConfigurationError, RootConfig
def test_default(default_cache_dir, default_data_dir):
config = RootConfig({})
config.parse_fields()
assert config.raw_data == {
"mode": "local",
"project": "",
"shell": "",
"dirs": {
"project": [],
"env": {},
"python": "isolated",
"data": str(default_data_dir),
"cache": str(default_cache_dir),
},
"projects": {},
"publish": {"index": {"repo": "main"}},
"template": {
"name": "Foo Bar",
"email": "foo@bar.baz",
"licenses": {"default": ["MIT"], "headers": True},
"plugins": {"default": {"ci": False, "src-layout": True, "tests": True}},
},
"terminal": {
"styles": {
"info": "bold",
"success": "bold cyan",
"error": "bold red",
"warning": "bold yellow",
"waiting": "bold magenta",
"debug": "bold",
"spinner": "simpleDotsScrolling",
},
},
}
class TestMode:
def test_default(self):
config = RootConfig({})
assert config.mode == config.mode == "local"
assert config.raw_data == {"mode": "local"}
def test_defined(self):
config = RootConfig({"mode": "aware"})
assert config.mode == "aware"
assert config.raw_data == {"mode": "aware"}
def test_not_string(self, helpers):
config = RootConfig({"mode": 9000})
with pytest.raises(
ConfigurationError,
match=helpers.dedent(
"""
Error parsing config:
mode
must be a string"""
),
):
_ = config.mode
def test_unknown(self, helpers):
config = RootConfig({"mode": "foo"})
with pytest.raises(
ConfigurationError,
match=helpers.dedent(
"""
Error parsing config:
mode
must be one of: aware, local, project"""
),
):
_ = config.mode
def test_set_lazy_error(self, helpers):
config = RootConfig({})
config.mode = 9000
assert config.raw_data == {"mode": 9000}
with pytest.raises(
ConfigurationError,
match=helpers.dedent(
"""
Error parsing config:
mode
must be a string"""
),
):
_ = config.mode
class TestProject:
def test_default(self):
config = RootConfig({})
assert config.project == config.project == ""
assert config.raw_data == {"project": ""}
def test_defined(self):
config = RootConfig({"project": "foo"})
assert config.project == "foo"
assert config.raw_data == {"project": "foo"}
def test_not_string(self, helpers):
config = RootConfig({"project": 9000})
with pytest.raises(
ConfigurationError,
match=helpers.dedent(
"""
Error parsing config:
project
must be a string"""
),
):
_ = config.project
def test_set_lazy_error(self, helpers):
config = RootConfig({})
config.project = 9000
assert config.raw_data == {"project": 9000}
with pytest.raises(
ConfigurationError,
match=helpers.dedent(
"""
Error parsing config:
project
must be a string"""
),
):
_ = config.project
class TestShell:
def test_default(self):
config = RootConfig({})
assert config.shell.name == config.shell.name == ""
assert config.shell.path == config.shell.path == ""
assert config.shell.args == config.shell.args == []
assert config.raw_data == {"shell": ""}
def test_invalid_type(self, helpers):
config = RootConfig({"shell": 9000})
with pytest.raises(
ConfigurationError,
match=helpers.dedent(
"""
Error parsing config:
shell
must be a string or table"""
),
):
_ = config.shell
def test_string(self):
config = RootConfig({"shell": "foo"})
assert config.shell.name == "foo"
assert config.shell.path == "foo"
assert config.shell.args == []
assert config.raw_data == {"shell": "foo"}
def test_table(self):
config = RootConfig({"shell": {"name": "foo"}})
assert config.shell.name == "foo"
assert config.shell.path == "foo"
assert config.shell.args == []
assert config.raw_data == {"shell": {"name": "foo", "path": "foo", "args": []}}
def test_table_with_path(self):
config = RootConfig({"shell": {"name": "foo", "path": "bar"}})
assert config.shell.name == "foo"
assert config.shell.path == "bar"
assert config.shell.args == []
assert config.raw_data == {"shell": {"name": "foo", "path": "bar", "args": []}}
def test_table_with_path_and_args(self):
config = RootConfig({"shell": {"name": "foo", "path": "bar", "args": ["baz"]}})
assert config.shell.name == "foo"
assert config.shell.path == "bar"
assert config.shell.args == ["baz"]
assert config.raw_data == {"shell": {"name": "foo", "path": "bar", "args": ["baz"]}}
def test_table_no_name(self, helpers):
config = RootConfig({"shell": {}})
with pytest.raises(
ConfigurationError,
match=helpers.dedent(
"""
Error parsing config:
shell -> name
required field"""
),
):
_ = config.shell.name
def test_table_name_not_string(self, helpers):
config = RootConfig({"shell": {"name": 9000}})
with pytest.raises(
ConfigurationError,
match=helpers.dedent(
"""
Error parsing config:
shell -> name
must be a string"""
),
):
_ = config.shell.name
def test_table_path_not_string(self, helpers):
config = RootConfig({"shell": {"path": 9000}})
with pytest.raises(
ConfigurationError,
match=helpers.dedent(
"""
Error parsing config:
shell -> path
must be a string"""
),
):
_ = config.shell.path
def test_table_args_not_array(self, helpers):
config = RootConfig({"shell": {"args": 9000}})
with pytest.raises(
ConfigurationError,
match=helpers.dedent(
"""
Error parsing config:
shell -> args
must be an array"""
),
):
_ = config.shell.args
def test_table_args_entry_not_string(self, helpers):
config = RootConfig({"shell": {"args": [9000]}})
with pytest.raises(
ConfigurationError,
match=helpers.dedent(
"""
Error parsing config:
shell -> args -> 1
must be a string"""
),
):
_ = config.shell.args
def test_set_lazy_error(self, helpers):
config = RootConfig({})
config.shell = 9000
assert config.raw_data == {"shell": 9000}
with pytest.raises(
ConfigurationError,
match=helpers.dedent(
"""
Error parsing config:
shell
must be a string or table"""
),
):
_ = config.shell
def test_table_name_set_lazy_error(self, helpers):
config = RootConfig({"shell": {}})
config.shell.name = 9000
assert config.raw_data == {"shell": {"name": 9000}}
with pytest.raises(
ConfigurationError,
match=helpers.dedent(
"""
Error parsing config:
shell -> name
must be a string"""
),
):
_ = config.shell.name
def test_table_path_set_lazy_error(self, helpers):
config = RootConfig({"shell": {"name": "foo"}})
config.shell.path = 9000
assert config.raw_data == {"shell": {"name": "foo", "path": 9000}}
with pytest.raises(
ConfigurationError,
match=helpers.dedent(
"""
Error parsing config:
shell -> path
must be a string"""
),
):
_ = config.shell.path
def test_table_args_set_lazy_error(self, helpers):
config = RootConfig({"shell": {"name": "foo"}})
config.shell.args = 9000
assert config.raw_data == {"shell": {"name": "foo", "args": 9000}}
with pytest.raises(
ConfigurationError,
match=helpers.dedent(
"""
Error parsing config:
shell -> args
must be an array"""
),
):
_ = config.shell.args
class TestDirs:
def test_default(self, default_cache_dir, default_data_dir):
config = RootConfig({})
default_cache_directory = str(default_cache_dir)
default_data_directory = str(default_data_dir)
assert config.dirs.project == config.dirs.project == []
assert config.dirs.env == config.dirs.env == {}
assert config.dirs.python == config.dirs.python == "isolated"
assert config.dirs.cache == config.dirs.cache == default_cache_directory
assert config.dirs.data == config.dirs.data == default_data_directory
assert config.raw_data == {
"dirs": {
"project": [],
"env": {},
"python": "isolated",
"data": default_data_directory,
"cache": default_cache_directory,
},
}
def test_not_table(self, helpers):
config = RootConfig({"dirs": 9000})
with pytest.raises(
ConfigurationError,
match=helpers.dedent(
"""
Error parsing config:
dirs
must be a table"""
),
):
_ = config.dirs
def test_set_lazy_error(self, helpers):
config = RootConfig({})
config.dirs = 9000
assert config.raw_data == {"dirs": 9000}
with pytest.raises(
ConfigurationError,
match=helpers.dedent(
"""
Error parsing config:
dirs
must be a table"""
),
):
_ = config.dirs
def test_project(self):
config = RootConfig({"dirs": {"project": ["foo"]}})
assert config.dirs.project == ["foo"]
assert config.raw_data == {"dirs": {"project": ["foo"]}}
def test_project_not_array(self, helpers):
config = RootConfig({"dirs": {"project": 9000}})
with pytest.raises(
ConfigurationError,
match=helpers.dedent(
"""
Error parsing config:
dirs -> project
must be an array"""
),
):
_ = config.dirs.project
def test_project_entry_not_string(self, helpers):
config = RootConfig({"dirs": {"project": [9000]}})
with pytest.raises(
ConfigurationError,
match=helpers.dedent(
"""
Error parsing config:
dirs -> project -> 1
must be a string"""
),
):
_ = config.dirs.project
def test_project_set_lazy_error(self, helpers):
config = RootConfig({})
config.dirs.project = 9000
assert config.raw_data == {"dirs": {"project": 9000}}
with pytest.raises(
ConfigurationError,
match=helpers.dedent(
"""
Error parsing config:
dirs -> project
must be an array"""
),
):
_ = config.dirs.project
def test_env(self):
config = RootConfig({"dirs": {"env": {"foo": "bar"}}})
assert config.dirs.env == {"foo": "bar"}
assert config.raw_data == {"dirs": {"env": {"foo": "bar"}}}
def test_env_not_table(self, helpers):
config = RootConfig({"dirs": {"env": 9000}})
with pytest.raises(
ConfigurationError,
match=helpers.dedent(
"""
Error parsing config:
dirs -> env
must be a table"""
),
):
_ = config.dirs.env
def test_env_value_not_string(self, helpers):
config = RootConfig({"dirs": {"env": {"foo": 9000}}})
with pytest.raises(
ConfigurationError,
match=helpers.dedent(
"""
Error parsing config:
dirs -> env -> foo
must be a string"""
),
):
_ = config.dirs.env
def test_env_set_lazy_error(self, helpers):
config = RootConfig({})
config.dirs.env = 9000
assert config.raw_data == {"dirs": {"env": 9000}}
with pytest.raises(
ConfigurationError,
match=helpers.dedent(
"""
Error parsing config:
dirs -> env
must be a table"""
),
):
_ = config.dirs.env
def test_python(self):
config = RootConfig({"dirs": {"python": "foo"}})
assert config.dirs.python == "foo"
assert config.raw_data == {"dirs": {"python": "foo"}}
def test_python_not_string(self, helpers):
config = RootConfig({"dirs": {"python": 9000}})
with pytest.raises(
ConfigurationError,
match=helpers.dedent(
"""
Error parsing config:
dirs -> python
must be a string"""
),
):
_ = config.dirs.python
def test_python_set_lazy_error(self, helpers):
config = RootConfig({})
config.dirs.python = 9000
assert config.raw_data == {"dirs": {"python": 9000}}
with pytest.raises(
ConfigurationError,
match=helpers.dedent(
"""
Error parsing config:
dirs -> python
must be a string"""
),
):
_ = config.dirs.python
def test_data(self):
config = RootConfig({"dirs": {"data": "foo"}})
assert config.dirs.data == "foo"
assert config.raw_data == {"dirs": {"data": "foo"}}
def test_data_not_string(self, helpers):
config = RootConfig({"dirs": {"data": 9000}})
with pytest.raises(
ConfigurationError,
match=helpers.dedent(
"""
Error parsing config:
dirs -> data
must be a string"""
),
):
_ = config.dirs.data
def test_data_set_lazy_error(self, helpers):
config = RootConfig({})
config.dirs.data = 9000
assert config.raw_data == {"dirs": {"data": 9000}}
with pytest.raises(
ConfigurationError,
match=helpers.dedent(
"""
Error parsing config:
dirs -> data
must be a string"""
),
):
_ = config.dirs.data
def test_cache(self):
config = RootConfig({"dirs": {"cache": "foo"}})
assert config.dirs.cache == "foo"
assert config.raw_data == {"dirs": {"cache": "foo"}}
def test_cache_not_string(self, helpers):
config = RootConfig({"dirs": {"cache": 9000}})
with pytest.raises(
ConfigurationError,
match=helpers.dedent(
"""
Error parsing config:
dirs -> cache
must be a string"""
),
):
_ = config.dirs.cache
def test_cache_set_lazy_error(self, helpers):
config = RootConfig({})
config.dirs.cache = 9000
assert config.raw_data == {"dirs": {"cache": 9000}}
with pytest.raises(
ConfigurationError,
match=helpers.dedent(
"""
Error parsing config:
dirs -> cache
must be a string"""
),
):
_ = config.dirs.cache
class TestProjects:
def test_default(self):
config = RootConfig({})
assert config.projects == config.projects == {}
assert config.raw_data == {"projects": {}}
def test_not_table(self, helpers):
config = RootConfig({"projects": 9000})
with pytest.raises(
ConfigurationError,
match=helpers.dedent(
"""
Error parsing config:
projects
must be a table"""
),
):
_ = config.projects
def test_set_lazy_error(self, helpers):
config = RootConfig({})
config.projects = 9000
assert config.raw_data == {"projects": 9000}
with pytest.raises(
ConfigurationError,
match=helpers.dedent(
"""
Error parsing config:
projects
must be a table"""
),
):
_ = config.projects
def test_entry_invalid_type(self, helpers):
config = RootConfig({"projects": {"foo": 9000}})
with pytest.raises(
ConfigurationError,
match=helpers.dedent(
"""
Error parsing config:
projects -> foo
must be a string or table"""
),
):
_ = config.projects
def test_string(self):
config = RootConfig({"projects": {"foo": "bar"}})
project = config.projects["foo"]
assert project.location == project.location == "bar"
assert config.raw_data == {"projects": {"foo": "bar"}}
def test_table(self):
config = RootConfig({"projects": {"foo": {"location": "bar"}}})
project = config.projects["foo"]
assert project.location == project.location == "bar"
assert config.raw_data == {"projects": {"foo": {"location": "bar"}}}
def test_table_no_location(self, helpers):
config = RootConfig({"projects": {"foo": {}}})
with pytest.raises(
ConfigurationError,
match=helpers.dedent(
"""
Error parsing config:
projects -> foo -> location
required field"""
),
):
_ = config.projects["foo"].location
def test_location_not_string(self, helpers):
config = RootConfig({"projects": {"foo": {"location": 9000}}})
with pytest.raises(
ConfigurationError,
match=helpers.dedent(
"""
Error parsing config:
projects -> foo -> location
must be a string"""
),
):
_ = config.projects["foo"].location
def test_location_set_lazy_error(self, helpers):
config = RootConfig({"projects": {"foo": {}}})
project = config.projects["foo"]
project.location = 9000
with pytest.raises(
ConfigurationError,
match=helpers.dedent(
"""
Error parsing config:
projects -> foo -> location
must be a string"""
),
):
_ = project.location
class TestPublish:
def test_default(self):
config = RootConfig({})
assert config.publish == config.publish == {"index": {"repo": "main"}}
assert config.raw_data == {"publish": {"index": {"repo": "main"}}}
def test_defined(self):
config = RootConfig({"publish": {"foo": {"username": "", "password": ""}}})
assert config.publish == {"foo": {"username": "", "password": ""}}
assert config.raw_data == {"publish": {"foo": {"username": "", "password": ""}}}
def test_not_table(self, helpers):
config = RootConfig({"publish": 9000})
with pytest.raises(
ConfigurationError,
match=helpers.dedent(
"""
Error parsing config:
publish
must be a table"""
),
):
_ = config.publish
def test_data_not_table(self, helpers):
config = RootConfig({"publish": {"foo": 9000}})
with pytest.raises(
ConfigurationError,
match=helpers.dedent(
"""
Error parsing config:
publish -> foo
must be a table"""
),
):
_ = config.publish
def test_set_lazy_error(self, helpers):
config = RootConfig({})
config.publish = 9000
assert config.raw_data == {"publish": 9000}
with pytest.raises(
ConfigurationError,
match=helpers.dedent(
"""
Error parsing config:
publish
must be a table"""
),
):
_ = config.publish
class TestTemplate:
def test_not_table(self, helpers):
config = RootConfig({"template": 9000})
with pytest.raises(
ConfigurationError,
match=helpers.dedent(
"""
Error parsing config:
template
must be a table"""
),
):
_ = config.template
def test_set_lazy_error(self, helpers):
config = RootConfig({})
config.template = 9000
assert config.raw_data == {"template": 9000}
with pytest.raises(
ConfigurationError,
match=helpers.dedent(
"""
Error parsing config:
template
must be a table"""
),
):
_ = config.template
def test_name(self):
config = RootConfig({"template": {"name": "foo"}})
assert config.template.name == config.template.name == "foo"
assert config.raw_data == {"template": {"name": "foo"}}
def test_name_default_env_var(self):
config = RootConfig({})
assert config.template.name == "Foo Bar"
assert config.raw_data == {"template": {"name": "Foo Bar"}}
def test_name_default_git(self, temp_dir):
config = RootConfig({})
with temp_dir.as_cwd(exclude=["GIT_AUTHOR_NAME"]):
subprocess.check_output(["git", "init"])
subprocess.check_output(["git", "config", "--local", "user.name", "test"])
assert config.template.name == "test"
assert config.raw_data == {"template": {"name": "test"}}
def test_name_default_no_git(self, temp_dir):
config = RootConfig({})
with temp_dir.as_cwd(exclude=["*"]):
assert config.template.name == "U.N. Owen"
assert config.raw_data == {"template": {"name": "U.N. Owen"}}
def test_name_not_string(self, helpers):
config = RootConfig({"template": {"name": 9000}})
with pytest.raises(
ConfigurationError,
match=helpers.dedent(
"""
Error parsing config:
template -> name
must be a string"""
),
):
_ = config.template.name
def test_name_set_lazy_error(self, helpers):
config = RootConfig({})
config.template.name = 9000
assert config.raw_data == {"template": {"name": 9000}}
with pytest.raises(
ConfigurationError,
match=helpers.dedent(
"""
Error parsing config:
template -> name
must be a string"""
),
):
_ = config.template.name
def test_email(self):
config = RootConfig({"template": {"email": "foo"}})
assert config.template.email == config.template.email == "foo"
assert config.raw_data == {"template": {"email": "foo"}}
def test_email_default_env_var(self):
config = RootConfig({})
assert config.template.email == "foo@bar.baz"
assert config.raw_data == {"template": {"email": "foo@bar.baz"}}
def test_email_default_git(self, temp_dir):
config = RootConfig({})
with temp_dir.as_cwd(exclude=["GIT_AUTHOR_EMAIL"]):
subprocess.check_output(["git", "init"])
subprocess.check_output(["git", "config", "--local", "user.email", "test"])
assert config.template.email == "test"
assert config.raw_data == {"template": {"email": "test"}}
def test_email_default_no_git(self, temp_dir):
config = RootConfig({})
with temp_dir.as_cwd(exclude=["*"]):
assert config.template.email == "void@some.where"
assert config.raw_data == {"template": {"email": "void@some.where"}}
def test_email_not_string(self, helpers):
config = RootConfig({"template": {"email": 9000}})
with pytest.raises(
ConfigurationError,
match=helpers.dedent(
"""
Error parsing config:
template -> email
must be a string"""
),
):
_ = config.template.email
def test_email_set_lazy_error(self, helpers):
config = RootConfig({})
config.template.email = 9000
assert config.raw_data == {"template": {"email": 9000}}
with pytest.raises(
ConfigurationError,
match=helpers.dedent(
"""
Error parsing config:
template -> email
must be a string"""
),
):
_ = config.template.email
def test_licenses_not_table(self, helpers):
config = RootConfig({"template": {"licenses": 9000}})
with pytest.raises(
ConfigurationError,
match=helpers.dedent(
"""
Error parsing config:
template -> licenses
must be a table"""
),
):
_ = config.template.licenses
def test_licenses_set_lazy_error(self, helpers):
config = RootConfig({})
config.template.licenses = 9000
assert config.raw_data == {"template": {"licenses": 9000}}
with pytest.raises(
ConfigurationError,
match=helpers.dedent(
"""
Error parsing config:
template -> licenses
must be a table"""
),
):
_ = config.template.licenses
def test_licenses_headers(self):
config = RootConfig({"template": {"licenses": {"headers": False}}})
assert config.template.licenses.headers is config.template.licenses.headers is False
assert config.raw_data == {"template": {"licenses": {"headers": False}}}
def test_licenses_headers_default(self):
config = RootConfig({})
assert config.template.licenses.headers is config.template.licenses.headers is True
assert config.raw_data == {"template": {"licenses": {"headers": True}}}
def test_licenses_headers_not_boolean(self, helpers):
config = RootConfig({"template": {"licenses": {"headers": 9000}}})
with pytest.raises(
ConfigurationError,
match=helpers.dedent(
"""
Error parsing config:
template -> licenses -> headers
must be a boolean"""
),
):
_ = config.template.licenses.headers
def test_licenses_headers_set_lazy_error(self, helpers):
config = RootConfig({})
config.template.licenses.headers = 9000
assert config.raw_data == {"template": {"licenses": {"headers": 9000}}}
with pytest.raises(
ConfigurationError,
match=helpers.dedent(
"""
Error parsing config:
template -> licenses -> headers
must be a boolean"""
),
):
_ = config.template.licenses.headers
def test_licenses_default(self):
config = RootConfig({"template": {"licenses": {"default": ["Apache-2.0", "MIT"]}}})
assert config.template.licenses.default == config.template.licenses.default == ["Apache-2.0", "MIT"]
assert config.raw_data == {"template": {"licenses": {"default": ["Apache-2.0", "MIT"]}}}
def test_licenses_default_default(self):
config = RootConfig({})
assert config.template.licenses.default == ["MIT"]
assert config.raw_data == {"template": {"licenses": {"default": ["MIT"]}}}
def test_licenses_default_not_array(self, helpers):
config = RootConfig({"template": {"licenses": {"default": 9000}}})
with pytest.raises(
ConfigurationError,
match=helpers.dedent(
"""
Error parsing config:
template -> licenses -> default
must be an array"""
),
):
_ = config.template.licenses.default
def test_licenses_default_entry_not_string(self, helpers):
config = RootConfig({"template": {"licenses": {"default": [9000]}}})
with pytest.raises(
ConfigurationError,
match=helpers.dedent(
"""
Error parsing config:
template -> licenses -> default -> 1
must be a string"""
),
):
_ = config.template.licenses.default
def test_licenses_default_set_lazy_error(self, helpers):
config = RootConfig({})
config.template.licenses.default = 9000
assert config.raw_data == {"template": {"licenses": {"default": 9000}}}
with pytest.raises(
ConfigurationError,
match=helpers.dedent(
"""
Error parsing config:
template -> licenses -> default
must be an array"""
),
):
_ = config.template.licenses.default
def test_plugins(self):
config = RootConfig({"template": {"plugins": {"foo": {"bar": "baz"}}}})
assert config.template.plugins == config.template.plugins == {"foo": {"bar": "baz"}}
assert config.raw_data == {"template": {"plugins": {"foo": {"bar": "baz"}}}}
def test_plugins_default(self):
config = RootConfig({})
assert config.template.plugins == {"default": {"ci": False, "src-layout": True, "tests": True}}
assert config.raw_data == {
"template": {"plugins": {"default": {"ci": False, "src-layout": True, "tests": True}}}
}
def test_plugins_not_table(self, helpers):
config = RootConfig({"template": {"plugins": 9000}})
with pytest.raises(
ConfigurationError,
match=helpers.dedent(
"""
Error parsing config:
template -> plugins
must be a table"""
),
):
_ = config.template.plugins
def test_plugins_data_not_table(self, helpers):
config = RootConfig({"template": {"plugins": {"foo": 9000}}})
with pytest.raises(
ConfigurationError,
match=helpers.dedent(
"""
Error parsing config:
template -> plugins -> foo
must be a table"""
),
):
_ = config.template.plugins
def test_plugins_set_lazy_error(self, helpers):
config = RootConfig({})
config.template.plugins = 9000
assert config.raw_data == {"template": {"plugins": 9000}}
with pytest.raises(
ConfigurationError,
match=helpers.dedent(
"""
Error parsing config:
template -> plugins
must be a table"""
),
):
_ = config.template.plugins
class TestTerminal:
def test_default(self):
config = RootConfig({})
assert config.terminal.styles.info == config.terminal.styles.info == "bold"
assert config.terminal.styles.success == config.terminal.styles.success == "bold cyan"
assert config.terminal.styles.error == config.terminal.styles.error == "bold red"
assert config.terminal.styles.warning == config.terminal.styles.warning == "bold yellow"
assert config.terminal.styles.waiting == config.terminal.styles.waiting == "bold magenta"
assert config.terminal.styles.debug == config.terminal.styles.debug == "bold"
assert config.terminal.styles.spinner == config.terminal.styles.spinner == "simpleDotsScrolling"
assert config.raw_data == {
"terminal": {
"styles": {
"info": "bold",
"success": "bold cyan",
"error": "bold red",
"warning": "bold yellow",
"waiting": "bold magenta",
"debug": "bold",
"spinner": "simpleDotsScrolling",
},
},
}
def test_not_table(self, helpers):
config = RootConfig({"terminal": 9000})
with pytest.raises(
ConfigurationError,
match=helpers.dedent(
"""
Error parsing config:
terminal
must be a table"""
),
):
_ = config.terminal
def test_set_lazy_error(self, helpers):
config = RootConfig({})
config.terminal = 9000
assert config.raw_data == {"terminal": 9000}
with pytest.raises(
ConfigurationError,
match=helpers.dedent(
"""
Error parsing config:
terminal
must be a table"""
),
):
_ = config.terminal
def test_styles_not_table(self, helpers):
config = RootConfig({"terminal": {"styles": 9000}})
with pytest.raises(
ConfigurationError,
match=helpers.dedent(
"""
Error parsing config:
terminal -> styles
must be a table"""
),
):
_ = config.terminal.styles
def test_styles_set_lazy_error(self, helpers):
config = RootConfig({})
config.terminal.styles = 9000
assert config.raw_data == {"terminal": {"styles": 9000}}
with pytest.raises(
ConfigurationError,
match=helpers.dedent(
"""
Error parsing config:
terminal -> styles
must be a table"""
),
):
_ = config.terminal.styles
def test_styles_info(self):
config = RootConfig({"terminal": {"styles": {"info": "foo"}}})
assert config.terminal.styles.info == "foo"
assert config.raw_data == {"terminal": {"styles": {"info": "foo"}}}
def test_styles_info_not_string(self, helpers):
config = RootConfig({"terminal": {"styles": {"info": 9000}}})
with pytest.raises(
ConfigurationError,
match=helpers.dedent(
"""
Error parsing config:
terminal -> styles -> info
must be a string"""
),
):
_ = config.terminal.styles.info
def test_styles_info_set_lazy_error(self, helpers):
config = RootConfig({})
config.terminal.styles.info = 9000
assert config.raw_data == {"terminal": {"styles": {"info": 9000}}}
with pytest.raises(
ConfigurationError,
match=helpers.dedent(
"""
Error parsing config:
terminal -> styles -> info
must be a string"""
),
):
_ = config.terminal.styles.info
def test_styles_success(self):
config = RootConfig({"terminal": {"styles": {"success": "foo"}}})
assert config.terminal.styles.success == "foo"
assert config.raw_data == {"terminal": {"styles": {"success": "foo"}}}
def test_styles_success_not_string(self, helpers):
config = RootConfig({"terminal": {"styles": {"success": 9000}}})
with pytest.raises(
ConfigurationError,
match=helpers.dedent(
"""
Error parsing config:
terminal -> styles -> success
must be a string"""
),
):
_ = config.terminal.styles.success
def test_styles_success_set_lazy_error(self, helpers):
config = RootConfig({})
config.terminal.styles.success = 9000
assert config.raw_data == {"terminal": {"styles": {"success": 9000}}}
with pytest.raises(
ConfigurationError,
match=helpers.dedent(
"""
Error parsing config:
terminal -> styles -> success
must be a string"""
),
):
_ = config.terminal.styles.success
def test_styles_error(self):
config = RootConfig({"terminal": {"styles": {"error": "foo"}}})
assert config.terminal.styles.error == "foo"
assert config.raw_data == {"terminal": {"styles": {"error": "foo"}}}
def test_styles_error_not_string(self, helpers):
config = RootConfig({"terminal": {"styles": {"error": 9000}}})
with pytest.raises(
ConfigurationError,
match=helpers.dedent(
"""
Error parsing config:
terminal -> styles -> error
must be a string"""
),
):
_ = config.terminal.styles.error
def test_styles_error_set_lazy_error(self, helpers):
config = RootConfig({})
config.terminal.styles.error = 9000
assert config.raw_data == {"terminal": {"styles": {"error": 9000}}}
with pytest.raises(
ConfigurationError,
match=helpers.dedent(
"""
Error parsing config:
terminal -> styles -> error
must be a string"""
),
):
_ = config.terminal.styles.error
def test_styles_warning(self):
config = RootConfig({"terminal": {"styles": {"warning": "foo"}}})
assert config.terminal.styles.warning == "foo"
assert config.raw_data == {"terminal": {"styles": {"warning": "foo"}}}
def test_styles_warning_not_string(self, helpers):
config = RootConfig({"terminal": {"styles": {"warning": 9000}}})
with pytest.raises(
ConfigurationError,
match=helpers.dedent(
"""
Error parsing config:
terminal -> styles -> warning
must be a string"""
),
):
_ = config.terminal.styles.warning
def test_styles_warning_set_lazy_error(self, helpers):
config = RootConfig({})
config.terminal.styles.warning = 9000
assert config.raw_data == {"terminal": {"styles": {"warning": 9000}}}
with pytest.raises(
ConfigurationError,
match=helpers.dedent(
"""
Error parsing config:
terminal -> styles -> warning
must be a string"""
),
):
_ = config.terminal.styles.warning
def test_styles_waiting(self):
config = RootConfig({"terminal": {"styles": {"waiting": "foo"}}})
assert config.terminal.styles.waiting == "foo"
assert config.raw_data == {"terminal": {"styles": {"waiting": "foo"}}}
def test_styles_waiting_not_string(self, helpers):
config = RootConfig({"terminal": {"styles": {"waiting": 9000}}})
with pytest.raises(
ConfigurationError,
match=helpers.dedent(
"""
Error parsing config:
terminal -> styles -> waiting
must be a string"""
),
):
_ = config.terminal.styles.waiting
def test_styles_waiting_set_lazy_error(self, helpers):
config = RootConfig({})
config.terminal.styles.waiting = 9000
assert config.raw_data == {"terminal": {"styles": {"waiting": 9000}}}
with pytest.raises(
ConfigurationError,
match=helpers.dedent(
"""
Error parsing config:
terminal -> styles -> waiting
must be a string"""
),
):
_ = config.terminal.styles.waiting
def test_styles_debug(self):
config = RootConfig({"terminal": {"styles": {"debug": "foo"}}})
assert config.terminal.styles.debug == "foo"
assert config.raw_data == {"terminal": {"styles": {"debug": "foo"}}}
def test_styles_debug_not_string(self, helpers):
config = RootConfig({"terminal": {"styles": {"debug": 9000}}})
with pytest.raises(
ConfigurationError,
match=helpers.dedent(
"""
Error parsing config:
terminal -> styles -> debug
must be a string"""
),
):
_ = config.terminal.styles.debug
def test_styles_debug_set_lazy_error(self, helpers):
config = RootConfig({})
config.terminal.styles.debug = 9000
assert config.raw_data == {"terminal": {"styles": {"debug": 9000}}}
with pytest.raises(
ConfigurationError,
match=helpers.dedent(
"""
Error parsing config:
terminal -> styles -> debug
must be a string"""
),
):
_ = config.terminal.styles.debug
def test_styles_spinner(self):
config = RootConfig({"terminal": {"styles": {"spinner": "foo"}}})
assert config.terminal.styles.spinner == "foo"
assert config.raw_data == {"terminal": {"styles": {"spinner": "foo"}}}
def test_styles_spinner_not_string(self, helpers):
config = RootConfig({"terminal": {"styles": {"spinner": 9000}}})
with pytest.raises(
ConfigurationError,
match=helpers.dedent(
"""
Error parsing config:
terminal -> styles -> spinner
must be a string"""
),
):
_ = config.terminal.styles.spinner
def test_styles_spinner_set_lazy_error(self, helpers):
config = RootConfig({})
config.terminal.styles.spinner = 9000
assert config.raw_data == {"terminal": {"styles": {"spinner": 9000}}}
with pytest.raises(
ConfigurationError,
match=helpers.dedent(
"""
Error parsing config:
terminal -> styles -> spinner
must be a string"""
),
):
_ = config.terminal.styles.spinner
================================================
FILE: tests/conftest.py
================================================
from __future__ import annotations
import json
import os
import shutil
import subprocess
import sys
import time
from contextlib import suppress
from functools import lru_cache
from typing import TYPE_CHECKING, NamedTuple
import pytest
from click.testing import CliRunner as __CliRunner
from filelock import FileLock
from platformdirs import user_cache_dir, user_data_dir
from hatch.config.constants import AppEnvVars, ConfigEnvVars, PublishEnvVars
from hatch.config.user import ConfigFile
from hatch.env.internal import get_internal_env_config
from hatch.env.utils import get_env_var
from hatch.utils.ci import running_in_ci
from hatch.utils.fs import Path, temp_directory
from hatch.utils.platform import Platform
from hatch.utils.structures import EnvVars
from hatch.venv.core import TempVirtualEnv
from hatchling.cli import hatchling
from .helpers.templates.licenses import MIT, Apache_2_0
if TYPE_CHECKING:
from collections.abc import Generator
from unittest.mock import MagicMock
PLATFORM = Platform()
class Devpi(NamedTuple):
repo: str
index_name: str
user: str
auth: str
ca_cert: str
class CliRunner(__CliRunner):
def __init__(self, command):
super().__init__()
self._command = command
def __call__(self, *args, **kwargs):
# Exceptions should always be handled
kwargs.setdefault("catch_exceptions", False)
return self.invoke(self._command, args, **kwargs)
@pytest.fixture(scope="session")
def hatch(isolation): # noqa: ARG001
from hatch import cli
return CliRunner(cli.hatch)
@pytest.fixture(scope="session")
def helpers():
# https://docs.pytest.org/en/latest/writing_plugins.html#assertion-rewriting
pytest.register_assert_rewrite("tests.helpers.helpers")
from .helpers import helpers
return helpers
@pytest.fixture(scope="session", autouse=True)
def isolation(uv_on_path) -> Generator[Path, None, None]:
with temp_directory() as d:
data_dir = d / "data"
data_dir.mkdir()
cache_dir = d / "cache"
cache_dir.mkdir()
licenses_dir = cache_dir / "licenses"
licenses_dir.mkdir()
licenses_dir.joinpath("Apache-2.0.txt").write_text(Apache_2_0)
licenses_dir.joinpath("MIT.txt").write_text(MIT)
default_env_vars = {
AppEnvVars.NO_COLOR: "1",
ConfigEnvVars.DATA: str(data_dir),
ConfigEnvVars.CACHE: str(cache_dir),
PublishEnvVars.REPO: "dev",
"HATCH_SELF_TESTING": "true",
get_env_var(plugin_name="virtual", option="uv_path"): uv_on_path,
"PYAPP_COMMAND_NAME": os.urandom(4).hex(),
"GIT_AUTHOR_NAME": "Foo Bar",
"GIT_AUTHOR_EMAIL": "foo@bar.baz",
"COLUMNS": "80",
"LINES": "24",
}
if PLATFORM.windows:
default_env_vars["COMSPEC"] = "cmd.exe"
else:
default_env_vars["SHELL"] = "sh"
with d.as_cwd(default_env_vars):
os.environ.pop(AppEnvVars.ENV_ACTIVE, None)
os.environ.pop(AppEnvVars.FORCE_COLOR, None)
yield d
@pytest.fixture(scope="session")
def isolated_data_dir() -> Path:
return Path(os.environ[ConfigEnvVars.DATA])
@pytest.fixture(scope="session")
def default_data_dir() -> Path:
return Path(user_data_dir("hatch", appauthor=False))
@pytest.fixture(scope="session")
def default_cache_dir() -> Path:
return Path(user_cache_dir("hatch", appauthor=False))
@pytest.fixture(scope="session")
def platform():
return PLATFORM
@pytest.fixture(scope="session")
def current_platform():
return PLATFORM.name
@pytest.fixture(scope="session")
def current_arch():
import platform
return platform.machine().lower()
@pytest.fixture(scope="session")
def uri_slash_prefix():
return "//" if os.sep == "/" else "///"
@pytest.fixture
def temp_dir() -> Generator[Path, None, None]:
with temp_directory() as d:
yield d
@pytest.fixture
def temp_dir_data(temp_dir) -> Generator[Path, None, None]:
data_path = temp_dir / "data"
data_path.mkdir()
with EnvVars({ConfigEnvVars.DATA: str(data_path)}):
yield temp_dir
@pytest.fixture
def temp_dir_cache(temp_dir) -> Generator[Path, None, None]:
cache_path = temp_dir / "cache"
cache_path.mkdir()
with EnvVars({ConfigEnvVars.CACHE: str(cache_path)}):
yield temp_dir
@pytest.fixture(autouse=True)
def config_file(tmp_path) -> ConfigFile:
path = Path(tmp_path, "config.toml")
os.environ[ConfigEnvVars.CONFIG] = str(path)
config = ConfigFile(path)
config.restore()
return config
@pytest.fixture(scope="session")
def default_virtualenv_installed_requirements(helpers):
# PyPy installs extra packages by default
with TempVirtualEnv(sys.executable, PLATFORM):
output = PLATFORM.run_command(["pip", "freeze"], check=True, capture_output=True).stdout.decode("utf-8")
requirements = helpers.extract_requirements(output.splitlines())
return frozenset(requirements)
@pytest.fixture(scope="session")
def extract_installed_requirements(helpers, default_virtualenv_installed_requirements):
return lambda lines: [
requirement
for requirement in helpers.extract_requirements(lines)
if requirement not in default_virtualenv_installed_requirements
]
@pytest.fixture(scope="session", autouse=True)
def python_on_path():
return Path(sys.executable).stem
@pytest.fixture(scope="session", autouse=True)
def uv_on_path():
return shutil.which("uv")
@pytest.fixture(scope="session")
def compatible_python_distributions():
from hatch.python.resolve import get_compatible_distributions
return tuple(get_compatible_distributions())
@pytest.fixture(scope="session")
def global_application():
# This is only required for the EnvironmentInterface constructor and will never be used
from hatch.cli.application import Application
return Application(sys.exit, verbosity=0, enable_color=False, interactive=False)
@pytest.fixture
def temp_application():
# This is only required for the EnvironmentInterface constructor and will never be used
from hatch.cli.application import Application
return Application(sys.exit, verbosity=0, enable_color=False, interactive=False)
@pytest.fixture
def build_env_config():
return get_internal_env_config()["hatch-build"]
@pytest.fixture(scope="session")
def devpi(tmp_path_factory, worker_id):
import platform
if not shutil.which("docker") or (
running_in_ci() and (not PLATFORM.linux or platform.python_implementation() == "PyPy")
):
pytest.skip("Not testing publishing")
# This fixture is affected by https://github.com/pytest-dev/pytest-xdist/issues/271
root_tmp_dir = Path(tmp_path_factory.getbasetemp().parent)
devpi_data_file = root_tmp_dir / "devpi_data.json"
lock_file = f"{devpi_data_file}.lock"
devpi_started_sessions = root_tmp_dir / "devpi_started_sessions"
devpi_ended_sessions = root_tmp_dir / "devpi_ended_sessions"
devpi_data = root_tmp_dir / "devpi_data"
devpi_docker_data = devpi_data / "docker"
with FileLock(lock_file):
if devpi_data_file.is_file():
data = json.loads(devpi_data_file.read_text())
else:
import trustme
devpi_started_sessions.mkdir()
devpi_ended_sessions.mkdir()
devpi_data.mkdir()
shutil.copytree(Path(__file__).resolve().parent / "index" / "server", devpi_docker_data)
# https://github.com/python-trio/trustme/blob/master/trustme/_cli.py
# Generate the CA certificate
ca = trustme.CA()
cert = ca.issue_cert("localhost", "127.0.0.1", "::1")
# Write the certificate and private key the server should use
server_config_dir = devpi_docker_data / "nginx"
server_key = str(server_config_dir / "server.key")
server_cert = str(server_config_dir / "server.pem")
cert.private_key_pem.write_to_path(path=server_key)
with open(server_cert, mode="w", encoding="utf-8") as f:
f.truncate()
for blob in cert.cert_chain_pems:
blob.write_to_path(path=server_cert, append=True)
# Write the certificate the client should trust
client_cert = str(devpi_data / "client.pem")
ca.cert_pem.write_to_path(path=client_cert)
data = {"password": os.urandom(16).hex(), "ca_cert": client_cert}
devpi_data_file.write_atomic(json.dumps(data), "w", encoding="utf-8")
dp = Devpi("https://localhost:8443/hatch/testing/", "testing", "hatch", data["password"], data["ca_cert"])
env_vars = {"DEVPI_INDEX_NAME": dp.index_name, "DEVPI_USERNAME": dp.user, "DEVPI_PASSWORD": dp.auth}
compose_file = str(devpi_docker_data / "docker-compose.yaml")
with FileLock(lock_file):
if not any(devpi_started_sessions.iterdir()):
with EnvVars(env_vars):
result = subprocess.run(
["docker", "compose", "-f", compose_file, "up", "--build", "-d", "--wait"],
check=False,
capture_output=True,
)
if result.returncode != 0:
# Debugging info for if devpi fails to start
logs = subprocess.run(["docker", "logs", "hatch-devpi"], check=False, capture_output=True)
pytest.fail(
f"Failed to start devpi container, see logs:\n{logs.stdout.decode()}\n{logs.stderr.decode()}"
)
for _ in range(120):
output = subprocess.check_output(["docker", "logs", "hatch-devpi"]).decode("utf-8")
if f"Serving index {dp.user}/{dp.index_name}" in output:
time.sleep(15)
break
time.sleep(1)
else: # no cov
# Add logging here too for timeout case
import warnings
logs = subprocess.run(["docker", "logs", "hatch-devpi"], check=False, capture_output=True)
warnings.warn(
f"devpi container logs (timeout):\n{logs.stdout.decode()}\n{logs.stderr.decode()}", stacklevel=1
)
(devpi_started_sessions / worker_id).touch()
try:
yield dp
finally:
with FileLock(lock_file):
(devpi_ended_sessions / worker_id).touch()
if len(list(devpi_started_sessions.iterdir())) == len(list(devpi_ended_sessions.iterdir())):
devpi_data_file.unlink()
shutil.rmtree(devpi_started_sessions)
shutil.rmtree(devpi_ended_sessions)
with EnvVars(env_vars):
subprocess.run(["docker", "compose", "-f", compose_file, "down", "-t", "0"], capture_output=True) # noqa: PLW1510
shutil.rmtree(devpi_data)
@pytest.fixture
def env_run(mocker) -> Generator[MagicMock, None, None]:
run = mocker.patch("subprocess.run", return_value=subprocess.CompletedProcess([], 0, stdout=b""))
mocker.patch("hatch.env.virtual.VirtualEnvironment.exists", return_value=True)
mocker.patch("hatch.env.virtual.VirtualEnvironment.dependency_hash", return_value="")
mocker.patch("hatch.env.virtual.VirtualEnvironment.command_context")
return run
def is_hatchling_command(command: list[str] | str) -> bool:
if isinstance(command, str):
command = command.split()
if command[0] != "python":
return False
if "-m" not in command:
return False
return command[command.index("-m") + 1] == "hatchling"
@pytest.fixture
def mock_backend_process(request, mocker):
if "allow_backend_process" in request.keywords:
yield False
return
def mock_process_api(api):
def mock_process(command: list[str] | str, **kwargs):
if not is_hatchling_command(command): # no cov
return api(command, **kwargs)
if isinstance(command, str):
command = command.split()
original_args = sys.argv
try:
sys.argv = command[3:]
mock = mocker.MagicMock()
try:
# The builder sets process-wide environment variables
with EnvVars():
hatchling()
except SystemExit as e:
mock.returncode = e.code
else:
mock.returncode = 0
return mock
finally:
sys.argv = original_args
return mock_process
mocker.patch("hatch.utils.platform.Platform.run_command", side_effect=mock_process_api(PLATFORM.run_command))
yield True
@pytest.fixture
def mock_backend_process_output(request, mocker):
if "allow_backend_process" in request.keywords:
yield False
return
output_queue = []
def mock_process_api(api):
def mock_process(command, **kwargs):
if not is_hatchling_command(command): # no cov
return api(command, **kwargs)
if isinstance(command, str):
command = command.split()
output_queue.clear()
original_args = sys.argv
try:
sys.argv = command[3:]
mock = mocker.MagicMock()
try:
# The builder sets process-wide environment variables
with EnvVars():
hatchling()
except SystemExit as e:
mock.returncode = e.code
else:
mock.returncode = 0
mock.stdout = mock.stderr = "".join(output_queue).encode("utf-8")
return mock
finally:
sys.argv = original_args
return mock_process
mocker.patch("subprocess.run", side_effect=mock_process_api(subprocess.run))
mocker.patch("hatchling.bridge.app._display", side_effect=lambda cmd, **_: output_queue.append(f"{cmd}\n"))
yield True
@pytest.fixture
def mock_plugin_installation(mocker):
subprocess_run = subprocess.run
mocked_subprocess_run = mocker.MagicMock(returncode=0)
def _mock(command, **kwargs):
if isinstance(command, list):
if command[:5] == [sys.executable, "-u", "-m", "pip", "install"]:
mocked_subprocess_run(command, **kwargs)
return mocked_subprocess_run
if command[:3] == [sys.executable, "self", "python-path"]:
return mocker.MagicMock(returncode=0, stdout=sys.executable.encode())
return subprocess_run(command, **kwargs) # no cov
mocker.patch("subprocess.run", side_effect=_mock)
return mocked_subprocess_run
def pytest_runtest_setup(item):
for marker in item.iter_markers():
if marker.name == "requires_internet" and not network_connectivity(): # no cov
pytest.skip("No network connectivity")
if marker.name == "requires_ci" and not running_in_ci(): # no cov
pytest.skip("Not running in CI")
if marker.name == "requires_windows" and not PLATFORM.windows:
pytest.skip("Not running on Windows")
if marker.name == "requires_macos" and not PLATFORM.macos:
pytest.skip("Not running on macOS")
if marker.name == "requires_linux" and not PLATFORM.linux:
pytest.skip("Not running on Linux")
if marker.name == "requires_unix" and PLATFORM.windows:
pytest.skip("Not running on a Linux-based platform")
if marker.name == "requires_git" and not git_available(): # no cov
pytest.skip("Git not present in the environment")
if marker.name == "requires_docker" and not docker_available(): # no cov
pytest.skip("Docker not present in the environment")
if marker.name == "requires_cargo" and not cargo_available(): # no cov
pytest.skip("Cargo not present in the environment")
def pytest_configure(config):
config.addinivalue_line("markers", "requires_windows: Tests intended for Windows operating systems")
config.addinivalue_line("markers", "requires_macos: Tests intended for macOS operating systems")
config.addinivalue_line("markers", "requires_linux: Tests intended for Linux operating systems")
config.addinivalue_line("markers", "requires_unix: Tests intended for Linux-based operating systems")
config.addinivalue_line("markers", "requires_internet: Tests that require access to the internet")
config.addinivalue_line("markers", "requires_git: Tests that require the git command available in the environment")
config.addinivalue_line(
"markers", "requires_docker: Tests that require the docker command available in the environment"
)
config.addinivalue_line(
"markers", "requires_cargo: Tests that require the cargo command available in the environment"
)
config.addinivalue_line("markers", "allow_backend_process: Force the use of backend communication")
config.getini("norecursedirs").remove("build") # /tests/cli/build
config.getini("norecursedirs").remove("venv") # /tests/venv
@lru_cache
def network_connectivity(): # no cov
if running_in_ci():
return True
import socket
with suppress(Exception):
# Test availability of DNS first
host = socket.gethostbyname("www.google.com")
# Test connection
socket.create_connection((host, 80), 2)
return True
return False
@lru_cache
def git_available(): # no cov
if running_in_ci():
return True
return shutil.which("git") is not None
@lru_cache
def docker_available(): # no cov
if running_in_ci():
return True
return shutil.which("docker") is not None
@lru_cache
def cargo_available(): # no cov
if running_in_ci():
return True
return shutil.which("cargo") is not None
================================================
FILE: tests/dep/__init__.py
================================================
================================================
FILE: tests/dep/test_sync.py
================================================
import os
import sys
import pytest
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:
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:
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)
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)
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:
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:
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)
distributions = InstalledDistributions(sys_path=venv.sys_path)
assert not distributions.dependencies_in_sync([Dependency("binary[foo]")])
@pytest.mark.requires_internet
def test_unknown_extra(platform, uv_on_path):
with TempUVVirtualEnv(sys.executable, platform) as venv:
platform.run_command(
[uv_on_path, "pip", "install", "requests[security]==2.25.1"], check=True, capture_output=True
)
distributions = InstalledDistributions(sys_path=venv.sys_path)
assert not distributions.dependencies_in_sync([Dependency("requests[foo]")])
@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)
distributions = InstalledDistributions(sys_path=venv.sys_path)
assert not distributions.dependencies_in_sync([Dependency("requests[security]==2.25.1")])
@pytest.mark.requires_internet
def test_extra_met(platform, uv_on_path):
with TempUVVirtualEnv(sys.executable, platform) as venv:
platform.run_command(
[uv_on_path, "pip", "install", "requests[security]==2.25.1"], check=True, capture_output=True
)
distributions = InstalledDistributions(sys_path=venv.sys_path)
assert distributions.dependencies_in_sync([Dependency("requests[security]==2.25.1")])
@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
@pytest.mark.requires_git
def test_dependency_git_pip(platform):
with TempVirtualEnv(sys.executable, platform) as venv:
platform.run_command(
["pip", "install", "requests@git+https://github.com/psf/requests"], check=True, capture_output=True
)
distributions = InstalledDistributions(sys_path=venv.sys_path)
assert distributions.dependencies_in_sync([Dependency("requests@git+https://github.com/psf/requests")])
@pytest.mark.requires_internet
@pytest.mark.requires_git
def test_dependency_git_uv(platform, uv_on_path):
with TempUVVirtualEnv(sys.executable, platform) as venv:
platform.run_command(
[uv_on_path, "pip", "install", "requests@git+https://github.com/psf/requests"],
check=True,
capture_output=True,
)
distributions = InstalledDistributions(sys_path=venv.sys_path)
assert distributions.dependencies_in_sync([Dependency("requests@git+https://github.com/psf/requests")])
@pytest.mark.requires_internet
@pytest.mark.requires_git
def test_dependency_git_revision_pip(platform):
with TempVirtualEnv(sys.executable, platform) as venv:
platform.run_command(
["pip", "install", "requests@git+https://github.com/psf/requests@main"], check=True, capture_output=True
)
distributions = InstalledDistributions(sys_path=venv.sys_path)
assert distributions.dependencies_in_sync([Dependency("requests@git+https://github.com/psf/requests@main")])
@pytest.mark.requires_internet
@pytest.mark.requires_git
def test_dependency_git_revision_uv(platform, uv_on_path):
with TempUVVirtualEnv(sys.executable, platform) as venv:
platform.run_command(
[uv_on_path, "pip", "install", "requests@git+https://github.com/psf/requests@main"],
check=True,
capture_output=True,
)
distributions = InstalledDistributions(sys_path=venv.sys_path)
assert distributions.dependencies_in_sync([Dependency("requests@git+https://github.com/psf/requests@main")])
@pytest.mark.requires_internet
@pytest.mark.requires_git
def test_dependency_git_commit(platform, uv_on_path):
with TempUVVirtualEnv(sys.executable, platform) as venv:
platform.run_command(
[
uv_on_path,
"pip",
"install",
"requests@git+https://github.com/psf/requests@7f694b79e114c06fac5ec06019cada5a61e5570f",
],
check=True,
capture_output=True,
)
distributions = InstalledDistributions(sys_path=venv.sys_path)
assert distributions.dependencies_in_sync([
Dependency("requests@git+https://github.com/psf/requests@7f694b79e114c06fac5ec06019cada5a61e5570f")
])
def test_dependency_path_with_unresolved_context_variable():
"""
Regression test: Dependency.path should raise ValueError for unresolved context variables.
Context variables must be resolved before creating Dependency objects.
"""
unformatted_dep_string = "my-package @ {root:parent:uri}/my-package"
dep = Dependency(unformatted_dep_string)
with pytest.raises(ValueError, match="invalid scheme"):
_ = dep.path
def test_dependency_path_with_special_characters():
"""
Regression test: Dependency.path should handle URL-encoded special characters.
Paths with special characters like '+' get URL-encoded to '%2B' in URIs,
and should be decoded back when accessing the path property.
"""
# Create a dependency with a path containing URL-encoded special character
# Simulating what happens when a path with '+' is converted via .as_uri()
dep_string = "my-package @ file:///tmp/my%2Bproject"
dep = Dependency(dep_string)
# The path property should decode %2B back to +
assert dep.path is not None
assert "my+project" in str(dep.path)
================================================
FILE: tests/env/__init__.py
================================================
================================================
FILE: tests/env/collectors/__init__.py
================================================
================================================
FILE: tests/env/collectors/test_custom.py
================================================
import re
import pytest
from hatch.env.collectors.custom import CustomEnvironmentCollector
from hatch.plugin.constants import DEFAULT_CUSTOM_SCRIPT
def test_no_path(isolation):
config = {"path": ""}
with pytest.raises(
ValueError, match="Option `path` for environment collector `custom` must not be empty if defined"
):
CustomEnvironmentCollector(str(isolation), config)
def test_path_not_string(isolation):
config = {"path": 3}
with pytest.raises(TypeError, match="Option `path` for environment collector `custom` must be a string"):
CustomEnvironmentCollector(str(isolation), config)
def test_nonexistent(isolation):
config = {"path": "test.py"}
with pytest.raises(OSError, match="Plugin script does not exist: test.py"):
CustomEnvironmentCollector(str(isolation), config)
def test_default(temp_dir, helpers):
config = {}
file_path = temp_dir / DEFAULT_CUSTOM_SCRIPT
file_path.write_text(
helpers.dedent(
"""
from hatch.env.collectors.plugin.interface import EnvironmentCollectorInterface
class CustomHook(EnvironmentCollectorInterface):
def foo(self):
return self.PLUGIN_NAME, self.root
"""
)
)
with temp_dir.as_cwd():
hook = CustomEnvironmentCollector(str(temp_dir), config)
assert hook.foo() == ("custom", str(temp_dir))
def test_explicit_path(temp_dir, helpers):
config = {"path": f"foo/{DEFAULT_CUSTOM_SCRIPT}"}
file_path = temp_dir / "foo" / DEFAULT_CUSTOM_SCRIPT
file_path.ensure_parent_dir_exists()
file_path.write_text(
helpers.dedent(
"""
from hatch.env.collectors.plugin.interface import EnvironmentCollectorInterface
class CustomHook(EnvironmentCollectorInterface):
def foo(self):
return self.PLUGIN_NAME, self.root
"""
)
)
with temp_dir.as_cwd():
hook = CustomEnvironmentCollector(str(temp_dir), config)
assert hook.foo() == ("custom", str(temp_dir))
def test_no_subclass(temp_dir, helpers):
config = {"path": f"foo/{DEFAULT_CUSTOM_SCRIPT}"}
file_path = temp_dir / "foo" / DEFAULT_CUSTOM_SCRIPT
file_path.ensure_parent_dir_exists()
file_path.write_text(
helpers.dedent(
"""
from hatch.env.collectors.plugin.interface import EnvironmentCollectorInterface
foo = None
bar = 'baz'
class CustomHook:
pass
"""
)
)
with (
pytest.raises(
ValueError,
match=re.escape(
f"Unable to find a subclass of `EnvironmentCollectorInterface` in `foo/{DEFAULT_CUSTOM_SCRIPT}`: {temp_dir}"
),
),
temp_dir.as_cwd(),
):
CustomEnvironmentCollector(str(temp_dir), config)
================================================
FILE: tests/env/plugin/__init__.py
================================================
================================================
FILE: tests/env/plugin/test_interface.py
================================================
import re
import pytest
from hatch.config.constants import AppEnvVars
from hatch.env.plugin.interface import EnvironmentInterface
from hatch.project.core import Project
from hatch.utils.structures import EnvVars
class MockEnvironment(EnvironmentInterface): # no cov
PLUGIN_NAME = "mock"
def find(self):
pass
def create(self):
pass
def remove(self):
pass
def exists(self):
pass
def install_project(self):
pass
def install_project_dev_mode(self):
pass
def dependencies_in_sync(self):
pass
def sync_dependencies(self):
pass
class TestEnvVars:
def test_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.env_vars == environment.env_vars == {AppEnvVars.ENV_ACTIVE: "default"}
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": {"env-vars": 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.env-vars` must be a mapping"):
_ = environment.env_vars
def test_value_not_string(self, isolation, isolated_data_dir, platform, global_application):
config = {
"project": {"name": "my_app", "version": "0.0.1"},
"tool": {"hatch": {"envs": {"default": {"env-vars": {"foo": 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="Environment variable `foo` of field `tool.hatch.envs.default.env-vars` must be a string"
):
_ = environment.env_vars
def test_correct(self, isolation, isolated_data_dir, platform, global_application):
config = {
"project": {"name": "my_app", "version": "0.0.1"},
"tool": {"hatch": {"envs": {"default": {"env-vars": {"foo": "bar"}}}}},
}
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.env_vars == {AppEnvVars.ENV_ACTIVE: "default", "foo": "bar"}
def test_context_formatting(self, isolation, isolated_data_dir, platform, global_application):
config = {
"project": {"name": "my_app", "version": "0.0.1"},
"tool": {"hatch": {"envs": {"default": {"env-vars": {"foo": "{env:FOOBAZ}-{matrix:bar}"}}}}},
}
project = Project(isolation, config=config)
environment = MockEnvironment(
isolation,
project.metadata,
"default",
project.config.envs["default"],
{"bar": "42"},
isolated_data_dir,
isolated_data_dir,
platform,
0,
global_application,
)
with EnvVars({"FOOBAZ": "baz"}):
assert environment.env_vars == {AppEnvVars.ENV_ACTIVE: "default", "foo": "baz-42"}
class TestEnvInclude:
def test_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.env_include == environment.env_include == []
def test_not_array(self, isolation, isolated_data_dir, platform, global_application):
config = {
"project": {"name": "my_app", "version": "0.0.1"},
"tool": {"hatch": {"envs": {"default": {"env-include": 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.env-include` must be an array"):
_ = environment.env_include
def test_pattern_not_string(self, isolation, isolated_data_dir, platform, global_application):
config = {
"project": {"name": "my_app", "version": "0.0.1"},
"tool": {"hatch": {"envs": {"default": {"env-include": [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="Pattern #1 of field `tool.hatch.envs.default.env-include` must be a string"
):
_ = environment.env_include
def test_correct(self, isolation, isolated_data_dir, platform, global_application):
config = {
"project": {"name": "my_app", "version": "0.0.1"},
"tool": {"hatch": {"envs": {"default": {"env-include": ["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,
)
assert environment.env_include == ["HATCH_BUILD_*", "FOO*"]
class TestEnvExclude:
def test_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.env_exclude == environment.env_exclude == []
def test_not_array(self, isolation, isolated_data_dir, platform, global_application):
config = {
"project": {"name": "my_app", "version": "0.0.1"},
"tool": {"hatch": {"envs": {"default": {"env-exclude": 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.env-exclude` must be an array"):
_ = environment.env_exclude
def test_pattern_not_string(self, isolation, isolated_data_dir, platform, global_application):
config = {
"project": {"name": "my_app", "version": "0.0.1"},
"tool": {"hatch": {"envs": {"default": {"env-exclude": [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="Pattern #1 of field `tool.hatch.envs.default.env-exclude` must be a string"
):
_ = environment.env_exclude
def test_correct(self, isolation, isolated_data_dir, platform, global_application):
config = {
"project": {"name": "my_app", "version": "0.0.1"},
"tool": {"hatch": {"envs": {"default": {"env-exclude": ["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,
)
assert environment.env_exclude == ["FOO*"]
class TestPlatforms:
def test_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.platforms == environment.platforms == []
def test_not_array(self, isolation, isolated_data_dir, platform, global_application):
config = {
"project": {"name": "my_app", "version": "0.0.1"},
"tool": {"hatch": {"envs": {"default": {"platforms": 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.platforms` must be an array"):
_ = environment.platforms
def test_entry_not_string(self, isolation, isolated_data_dir, platform, global_application):
config = {
"project": {"name": "my_app", "version": "0.0.1"},
"tool": {"hatch": {"envs": {"default": {"platforms": [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="Platform #1 of field `tool.hatch.envs.default.platforms` must be a string"
):
_ = environment.platforms
def test_correct(self, isolation, isolated_data_dir, platform, global_application):
config = {
"project": {"name": "my_app", "version": "0.0.1"},
"tool": {"hatch": {"envs": {"default": {"platforms": ["macOS"]}}}},
}
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.platforms == ["macos"]
class TestSkipInstall:
def test_default_project(self, temp_dir, isolated_data_dir, platform, global_application):
config = {"project": {"name": "my_app", "version": "0.0.1"}}
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,
)
(temp_dir / "pyproject.toml").touch()
with temp_dir.as_cwd():
assert environment.skip_install is environment.skip_install is False
def test_default_no_project(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.skip_install is environment.skip_install is True
def test_not_boolean(self, isolation, isolated_data_dir, platform, global_application):
config = {
"project": {"name": "my_app", "version": "0.0.1"},
"tool": {"hatch": {"envs": {"default": {"skip-install": 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.skip-install` must be a boolean"):
_ = environment.skip_install
def test_enable(self, isolation, isolated_data_dir, platform, global_application):
config = {
"project": {"name": "my_app", "version": "0.0.1"},
"tool": {"hatch": {"envs": {"default": {"skip-install": True}}}},
}
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.skip_install is True
class TestDevMode:
def test_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.dev_mode is environment.dev_mode is True
def test_not_boolean(self, isolation, isolated_data_dir, platform, global_application):
config = {
"project": {"name": "my_app", "version": "0.0.1"},
"tool": {"hatch": {"envs": {"default": {"dev-mode": 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.dev-mode` must be a boolean"):
_ = environment.dev_mode
def test_disable(self, isolation, isolated_data_dir, platform, global_application):
config = {
"project": {"name": "my_app", "version": "0.0.1"},
"tool": {"hatch": {"envs": {"default": {"dev-mode": 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.dev_mode is False
class TestBuilder:
def test_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.builder is False
def test_not_boolean(self, isolation, isolated_data_dir, platform, global_application):
config = {
"project": {"name": "my_app", "version": "0.0.1"},
"tool": {"hatch": {"envs": {"default": {"builder": 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.builder` must be a boolean"):
_ = environment.builder
def test_enable(self, isolation, isolated_data_dir, platform, global_application):
config = {
"project": {"name": "my_app", "version": "0.0.1"},
"tool": {"hatch": {"envs": {"default": {"builder": True}}}},
}
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.builder is True
class TestFeatures:
def test_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.features == environment.features == []
def test_invalid_type(self, isolation, isolated_data_dir, platform, global_application):
config = {
"project": {"name": "my_app", "version": "0.0.1"},
"tool": {"hatch": {"envs": {"default": {"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="Field `tool.hatch.envs.default.features` must be an array of strings"):
_ = environment.features
def test_correct(self, isolation, isolated_data_dir, platform, global_application):
config = {
"project": {"name": "my_app", "version": "0.0.1", "optional-dependencies": {"foo-bar": [], "baz": []}},
"tool": {"hatch": {"envs": {"default": {"features": ["Foo...Bar", "Baz", "baZ"]}}}},
}
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.features == ["baz", "foo-bar"]
def test_feature_not_string(self, isolation, isolated_data_dir, platform, global_application):
config = {
"project": {"name": "my_app", "version": "0.0.1", "optional-dependencies": {"foo": [], "bar": []}},
"tool": {"hatch": {"envs": {"default": {"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 field `tool.hatch.envs.default.features` must be a string"):
_ = environment.features
def test_feature_empty_string(self, isolation, isolated_data_dir, platform, global_application):
config = {
"project": {"name": "my_app", "version": "0.0.1", "optional-dependencies": {"foo": [], "bar": []}},
"tool": {"hatch": {"envs": {"default": {"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 field `tool.hatch.envs.default.features` cannot be an empty string"
):
_ = environment.features
def test_feature_undefined(self, isolation, isolated_data_dir, platform, global_application):
config = {
"project": {"name": "my_app", "version": "0.0.1", "optional-dependencies": {"foo": []}},
"tool": {"hatch": {"envs": {"default": {"features": ["foo", "bar", ""]}}}},
}
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 `bar` of field `tool.hatch.envs.default.features` is not defined in "
"field `project.optional-dependencies`"
),
):
_ = environment.features
class TestDependencyGroups:
def test_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.dependency_groups == []
def test_not_array(self, isolation, isolated_data_dir, platform, global_application):
config = {
"project": {"name": "my_app", "version": "0.0.1"},
"tool": {"hatch": {"envs": {"default": {"dependency-groups": 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.dependency-groups` must be an array of strings"
):
_ = environment.dependency_groups
def test_correct(self, isolation, isolated_data_dir, platform, temp_application):
config = {
"project": {"name": "my_app", "version": "0.0.1"},
"dependency-groups": {"foo-bar": [], "baz": []},
"tool": {"hatch": {"envs": {"default": {"dependency-groups": ["Foo...Bar", "Baz", "baZ"]}}}},
}
project = Project(isolation, config=config)
project.set_app(temp_application)
temp_application.project = project
environment = MockEnvironment(
isolation,
project.metadata,
"default",
project.config.envs["default"],
{},
isolated_data_dir,
isolated_data_dir,
platform,
0,
temp_application,
)
assert environment.dependency_groups == ["baz", "foo-bar"]
def test_group_not_string(self, isolation, isolated_data_dir, platform, global_application):
config = {
"project": {"name": "my_app", "version": "0.0.1"},
"dependency-groups": {"foo": [], "bar": []},
"tool": {"hatch": {"envs": {"default": {"dependency-groups": [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="Group #1 of field `tool.hatch.envs.default.dependency-groups` must be a string"
):
_ = environment.dependency_groups
def test_group_empty_string(self, isolation, isolated_data_dir, platform, global_application):
config = {
"project": {"name": "my_app", "version": "0.0.1"},
"dependency-groups": {"foo": [], "bar": []},
"tool": {"hatch": {"envs": {"default": {"dependency-groups": [""]}}}},
}
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="Group #1 of field `tool.hatch.envs.default.dependency-groups` cannot be an empty string"
):
_ = environment.dependency_groups
def test_group_undefined(self, isolation, isolated_data_dir, platform, temp_application):
config = {
"project": {
"name": "my_app",
"version": "0.0.1",
},
"dependency-groups": {"foo": []},
"tool": {"hatch": {"envs": {"default": {"dependency-groups": ["foo", "bar", ""]}}}},
}
project = Project(isolation, config=config)
project.set_app(temp_application)
temp_application.project = project
environment = MockEnvironment(
isolation,
project.metadata,
"default",
project.config.envs["default"],
{},
isolated_data_dir,
isolated_data_dir,
platform,
0,
temp_application,
)
with pytest.raises(
ValueError,
match=(
"Group `bar` of field `tool.hatch.envs.default.dependency-groups` is not "
"defined in field `dependency-groups`"
),
):
_ = environment.dependency_groups
class TestDescription:
def test_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.description == environment.description == ""
def test_not_string(self, isolation, isolated_data_dir, platform, global_application):
config = {
"project": {"name": "my_app", "version": "0.0.1"},
"tool": {"hatch": {"envs": {"default": {"description": 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.description` must be a string"):
_ = environment.description
def test_correct(self, isolation, isolated_data_dir, platform, global_application):
description = "foo"
config = {
"project": {"name": "my_app", "version": "0.0.1"},
"tool": {"hatch": {"envs": {"default": {"description": description}}}},
}
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.description is description
class TestDependencies:
def test_default(self, isolation, isolated_data_dir, platform, temp_application):
config = {
"project": {"name": "my_app", "version": "0.0.1", "dependencies": ["dep1"]},
"tool": {"hatch": {"envs": {"default": {"skip-install": False}}}},
}
project = Project(isolation, config=config)
project.set_app(temp_application)
temp_application.project = project
environment = MockEnvironment(
isolation,
project.metadata,
"default",
project.config.envs["default"],
{},
isolated_data_dir,
isolated_data_dir,
platform,
0,
temp_application,
)
assert environment.dependencies == environment.dependencies == ["dep1"]
assert len(environment.dependencies) == len(environment.dependencies_complex)
def test_not_array(self, isolation, isolated_data_dir, platform, global_application):
config = {
"project": {"name": "my_app", "version": "0.0.1", "dependencies": ["dep1"]},
"tool": {"hatch": {"envs": {"default": {"dependencies": 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.dependencies` must be an array"):
_ = environment.dependencies
def test_entry_not_string(self, isolation, isolated_data_dir, platform, global_application):
config = {
"project": {"name": "my_app", "version": "0.0.1", "dependencies": ["dep1"]},
"tool": {"hatch": {"envs": {"default": {"dependencies": [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="Dependency #1 of field `tool.hatch.envs.default.dependencies` must be a string"
):
_ = environment.dependencies
def test_invalid(self, isolation, isolated_data_dir, platform, global_application):
config = {
"project": {"name": "my_app", "version": "0.0.1", "dependencies": ["dep1"]},
"tool": {"hatch": {"envs": {"default": {"dependencies": ["foo^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,
)
with pytest.raises(
ValueError, match="Dependency #1 of field `tool.hatch.envs.default.dependencies` is invalid: .+"
):
_ = environment.dependencies
def test_extra_not_array(self, isolation, isolated_data_dir, platform, global_application):
config = {
"project": {"name": "my_app", "version": "0.0.1", "dependencies": ["dep1"]},
"tool": {"hatch": {"envs": {"default": {"extra-dependencies": 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.extra-dependencies` must be an array"):
_ = environment.dependencies
def test_extra_entry_not_string(self, isolation, isolated_data_dir, platform, global_application):
config = {
"project": {"name": "my_app", "version": "0.0.1", "dependencies": ["dep1"]},
"tool": {"hatch": {"envs": {"default": {"extra-dependencies": [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="Dependency #1 of field `tool.hatch.envs.default.extra-dependencies` must be a string"
):
_ = environment.dependencies
def test_extra_invalid(self, isolation, isolated_data_dir, platform, global_application):
config = {
"project": {"name": "my_app", "version": "0.0.1", "dependencies": ["dep1"]},
"tool": {"hatch": {"envs": {"default": {"extra-dependencies": ["foo^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,
)
with pytest.raises(
ValueError, match="Dependency #1 of field `tool.hatch.envs.default.extra-dependencies` is invalid: .+"
):
_ = environment.dependencies
def test_full(self, isolation, isolated_data_dir, platform, temp_application):
config = {
"project": {"name": "my_app", "version": "0.0.1", "dependencies": ["dep1"]},
"tool": {
"hatch": {
"envs": {
"default": {"skip-install": False, "dependencies": ["dep2"], "extra-dependencies": ["dep3"]}
}
}
},
}
project = Project(isolation, config=config)
project.set_app(temp_application)
temp_application.project = project
environment = MockEnvironment(
isolation,
project.metadata,
"default",
project.config.envs["default"],
{},
isolated_data_dir,
isolated_data_dir,
platform,
0,
temp_application,
)
assert environment.dependencies == ["dep2", "dep3", "dep1"]
def test_context_formatting(self, isolation, isolated_data_dir, platform, temp_application, uri_slash_prefix):
config = {
"project": {"name": "my_app", "version": "0.0.1", "dependencies": ["dep1"]},
"tool": {
"hatch": {
"envs": {
"default": {
"skip-install": False,
"dependencies": ["dep2"],
"extra-dependencies": ["proj @ {root:uri}"],
}
}
}
},
}
project = Project(isolation, config=config)
project.set_app(temp_application)
temp_application.project = project
environment = MockEnvironment(
isolation,
project.metadata,
"default",
project.config.envs["default"],
{},
isolated_data_dir,
isolated_data_dir,
platform,
0,
temp_application,
)
normalized_path = str(isolation).replace("\\", "/")
assert environment.dependencies == ["dep2", f"proj @ file:{uri_slash_prefix}{normalized_path}", "dep1"]
def test_project_dependencies_context_formatting(
self, temp_dir, isolated_data_dir, platform, temp_application, uri_slash_prefix
):
"""
Regression test for context formatting in project dependencies.
Ensures that dependencies in [project] section with context variables
like {root:parent:uri} are properly formatted before creating Dependency objects.
"""
# Create a sibling project
sibling_project = temp_dir.parent / "sibling-project"
sibling_project.mkdir(exist_ok=True)
(sibling_project / "pyproject.toml").write_text(
"""\
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[project]
name = "sibling-project"
version = "0.0.1"
"""
)
config = {
"project": {
"name": "my_app",
"version": "0.0.1",
"dependencies": ["sibling-project @ {root:parent:uri}/sibling-project"],
},
"tool": {"hatch": {"envs": {"default": {"skip-install": False}}}},
}
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,
)
normalized_parent_path = str(temp_dir.parent).replace("\\", "/")
expected_dep = f"sibling-project @ file:{uri_slash_prefix}{normalized_parent_path}/sibling-project"
# Verify the dependency was formatted correctly
assert expected_dep in environment.dependencies
# Verify we can access the path property without errors
for dep in environment.project_dependencies_complex:
if dep.name == "sibling-project":
assert dep.path is not None
assert "sibling-project" in str(dep.path)
def test_full_skip_install(self, isolation, isolated_data_dir, platform, global_application):
config = {
"project": {"name": "my_app", "version": "0.0.1", "dependencies": ["dep1"]},
"tool": {
"hatch": {
"envs": {
"default": {"dependencies": ["dep2"], "extra-dependencies": ["dep3"], "skip-install": True}
}
}
},
}
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.dependencies == ["dep2", "dep3"]
def test_full_skip_install_and_features(self, isolation, isolated_data_dir, platform, temp_application):
config = {
"project": {
"name": "my_app",
"version": "0.0.1",
"dependencies": ["dep1"],
"optional-dependencies": {"feat": ["dep4"]},
},
"tool": {
"hatch": {
"envs": {
"default": {
"dependencies": ["dep2"],
"extra-dependencies": ["dep3"],
"skip-install": True,
"features": ["feat"],
}
}
}
},
}
project = Project(isolation, config=config)
project.set_app(temp_application)
temp_application.project = project
environment = MockEnvironment(
isolation,
project.metadata,
"default",
project.config.envs["default"],
{},
isolated_data_dir,
isolated_data_dir,
platform,
0,
temp_application,
)
assert environment.dependencies == ["dep2", "dep3", "dep4"]
def test_full_skip_install_and_dependency_groups(self, isolation, isolated_data_dir, platform, temp_application):
config = {
"project": {
"name": "my_app",
"version": "0.0.1",
"dependencies": ["dep1"],
},
"dependency-groups": {
"foo": ["dep5"],
"bar": ["dep4", {"include-group": "foo"}],
},
"tool": {
"hatch": {
"envs": {
"default": {
"dependencies": ["dep2"],
"extra-dependencies": ["dep3"],
"skip-install": True,
"dependency-groups": ["bar"],
}
}
}
},
}
project = Project(isolation, config=config)
project.set_app(temp_application)
temp_application.project = project
environment = MockEnvironment(
isolation,
project.metadata,
"default",
project.config.envs["default"],
{},
isolated_data_dir,
isolated_data_dir,
platform,
0,
temp_application,
)
assert environment.dependencies == ["dep2", "dep3", "dep4", "dep5"]
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": {
"hatch": {
"envs": {"default": {"dependencies": ["dep2"], "extra-dependencies": ["dep3"], "dev-mode": 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.dependencies == ["dep2", "dep3"]
def test_builder(self, isolation, isolated_data_dir, platform, global_application):
config = {
"build-system": {"requires": ["dep2"]},
"project": {"name": "my_app", "version": "0.0.1", "dependencies": ["dep1"]},
"tool": {
"hatch": {"envs": {"default": {"skip-install": False, "builder": True, "dependencies": ["dep3"]}}}
},
}
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.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",
]
def test_self_referencing_dependency_with_extras(self, temp_dir, isolated_data_dir, platform, global_application):
"""Test that self-referencing dependencies with extras include the extra's dependencies."""
project_dir = temp_dir / "my-app"
project_dir.mkdir()
(project_dir / "my_app").mkdir()
(project_dir / "my_app" / "__init__.py").write_text("")
(project_dir / "my_app" / "__about__.py").write_text('__version__ = "0.0.1"')
config = {
"project": {
"name": "my-app",
"version": "0.0.1",
"dependencies": [],
"optional-dependencies": {
"test": ["pytest>=7.0"],
},
},
"tool": {
"hatch": {
"envs": {
"dev": {
"skip-install": False,
"dependencies": ["my-app[test]"],
}
}
}
},
}
project = Project(project_dir, config=config)
global_application.project = project
environment = MockEnvironment(
project_dir,
project.metadata,
"dev",
project.config.envs["dev"],
{},
isolated_data_dir,
isolated_data_dir,
platform,
0,
global_application,
)
# Get all dependencies as strings (what would be passed to pip)
all_deps_str = [str(d) for d in environment.all_dependencies_complex]
# Should have the local installation
assert any("my-app" in dep and "file://" in dep for dep in all_deps_str)
# Should have my-app[test] which will cause pip to install pytest
assert any("pytest" in dep.lower() for dep in all_deps_str)
def test_dev_mode_true_returns_editable(self, temp_dir, isolated_data_dir, platform, temp_application):
"""Verify dev-mode=true creates editable local dependency."""
# Create a pyproject.toml file so skip_install defaults to False
pyproject = temp_dir / "pyproject.toml"
pyproject.write_text("""
[project]
name = "my-app"
version = "0.0.1"
[tool.hatch.envs.default]
dev-mode = true
""")
project = Project(temp_dir)
environment = MockEnvironment(
temp_dir,
project.metadata,
"default",
project.config.envs["default"],
{},
isolated_data_dir,
isolated_data_dir,
platform,
0,
temp_application,
)
local_deps = environment.local_dependencies_complex
assert len(local_deps) == 1
assert local_deps[0].editable is True
def test_dev_mode_false_returns_non_editable(self, temp_dir, isolated_data_dir, platform, temp_application):
"""Verify dev-mode=false creates non-editable local dependency."""
# Create a pyproject.toml file so skip_install defaults to False
pyproject = temp_dir / "pyproject.toml"
pyproject.write_text("""
[project]
name = "my-app"
version = "0.0.1"
[tool.hatch.envs.default]
dev-mode = false
""")
project = Project(temp_dir)
environment = MockEnvironment(
temp_dir,
project.metadata,
"default",
project.config.envs["default"],
{},
isolated_data_dir,
isolated_data_dir,
platform,
0,
temp_application,
)
local_deps = environment.local_dependencies_complex
assert len(local_deps) == 1
assert local_deps[0].editable is False
def test_skip_install_returns_empty(self, temp_dir, isolated_data_dir, platform, temp_application):
"""Verify skip-install=true returns empty local dependencies."""
pyproject = temp_dir / "pyproject.toml"
pyproject.write_text("""
[project]
name = "my-app"
version = "0.0.1"
[tool.hatch.envs.default]
skip-install = true
""")
project = Project(temp_dir)
environment = MockEnvironment(
temp_dir,
project.metadata,
"default",
project.config.envs["default"],
{},
isolated_data_dir,
isolated_data_dir,
platform,
0,
temp_application,
)
local_deps = environment.local_dependencies_complex
assert len(local_deps) == 0
def test_workspace_members_always_editable(self, temp_dir, isolated_data_dir, platform, temp_application):
"""Verify workspace members are always editable regardless of dev-mode."""
pyproject = temp_dir / "pyproject.toml"
pyproject.write_text("""
[project]
name = "my-app"
version = "0.0.1"
[tool.hatch.envs.default]
dev-mode = false
""")
project = Project(temp_dir)
environment = MockEnvironment(
temp_dir,
project.metadata,
"default",
project.config.envs["default"],
{},
isolated_data_dir,
isolated_data_dir,
platform,
0,
temp_application,
)
local_deps = environment.local_dependencies_complex
# Project itself should respect dev-mode=false
assert len(local_deps) == 1
assert local_deps[0].editable is False
def test_dependency_group_resolution(self, temp_dir, isolated_data_dir, platform, temp_application):
"""Test dependency group resolution."""
pyproject = temp_dir / "pyproject.toml"
pyproject.write_text("""
[project]
name = "my-app"
version = "0.0.1"
[dependency-groups]
test = ["pytest"]
[tool.hatch.envs.default]
dependency-groups = ["test"]
""")
project = Project(temp_dir)
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,
)
deps = environment.project_dependencies_complex
assert any("pytest" in str(d) for d in deps)
def test_dependency_group_resolution_builder_false_dev_mode_false(
self, temp_dir, isolated_data_dir, platform, temp_application
):
"""Test dependency group resolution in non-builder non-dev-mode environments."""
pyproject = temp_dir / "pyproject.toml"
pyproject.write_text("""
[project]
name = "my-app"
version = "0.0.1"
[dependency-groups]
test = ["pytest"]
[tool.hatch.envs.default]
builder = false
dev-mode = false
dependency-groups = ["test"]
""")
project = Project(temp_dir)
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 any("pytest" in str(d) for d in environment.project_dependencies_complex)
assert any("pytest" in str(d) for d in environment.dependencies_complex)
def test_dependency_group_resolution_builder_true_dev_mode_false(
self, temp_dir, isolated_data_dir, platform, temp_application
):
"""Test dependency group resolution in builder non-dev-mode environments."""
pyproject = temp_dir / "pyproject.toml"
pyproject.write_text("""
[project]
name = "my-app"
version = "0.0.1"
[dependency-groups]
test = ["pytest"]
[tool.hatch.envs.default]
builder = true
dev-mode = false
dependency-groups = ["test"]
""")
project = Project(temp_dir)
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 any("pytest" in str(d) for d in environment.project_dependencies_complex)
assert any("pytest" in str(d) for d in environment.dependencies_complex)
def test_dependency_group_resolution_builder_true_dev_mode_true(
self, temp_dir, isolated_data_dir, platform, temp_application
):
"""Test dependency group resolution in builder dev-mode environments."""
pyproject = temp_dir / "pyproject.toml"
pyproject.write_text("""
[project]
name = "my-app"
version = "0.0.1"
[dependency-groups]
test = ["pytest"]
[tool.hatch.envs.default]
builder = true
dev-mode = true
dependency-groups = ["test"]
""")
project = Project(temp_dir)
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 any("pytest" in str(d) for d in environment.project_dependencies_complex)
assert any("pytest" in str(d) for d in environment.dependencies_complex)
def test_additional_dependencies_as_strings(self, temp_dir, isolated_data_dir, platform, temp_application):
"""Test additional_dependencies with string values."""
pyproject = temp_dir / "pyproject.toml"
pyproject.write_text("""
[project]
name = "my-app"
version = "0.0.1"
""")
project = Project(temp_dir)
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,
)
environment.additional_dependencies = ["extra-dep"]
deps = environment.dependencies_complex
assert any("extra-dep" in str(d) for d in deps)
class TestScripts:
@pytest.mark.parametrize("field", ["scripts", "extra-scripts"])
def test_not_table(self, isolation, isolated_data_dir, platform, global_application, field):
config = {
"project": {"name": "my_app", "version": "0.0.1"},
"tool": {"hatch": {"envs": {"default": {field: 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=f"Field `tool.hatch.envs.default.{field}` must be a table"):
_ = environment.scripts
@pytest.mark.parametrize("field", ["scripts", "extra-scripts"])
def test_name_contains_spaces(self, isolation, isolated_data_dir, platform, global_application, field):
config = {
"project": {"name": "my_app", "version": "0.0.1"},
"tool": {"hatch": {"envs": {"default": {field: {"foo bar": []}}}}},
}
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=f"Script name `foo bar` in field `tool.hatch.envs.default.{field}` must not contain spaces",
):
_ = environment.scripts
def test_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.scripts == environment.scripts == {}
@pytest.mark.parametrize("field", ["scripts", "extra-scripts"])
def test_single_commands(self, isolation, isolated_data_dir, platform, global_application, field):
script_config = {"foo": "command1", "bar": "command2"}
config = {
"project": {"name": "my_app", "version": "0.0.1"},
"tool": {"hatch": {"envs": {"default": {field: script_config}}}},
}
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.scripts == {"foo": ["command1"], "bar": ["command2"]}
@pytest.mark.parametrize("field", ["scripts", "extra-scripts"])
def test_multiple_commands(self, isolation, isolated_data_dir, platform, global_application, field):
script_config = {"foo": "command1", "bar": ["command3", "command2"]}
config = {
"project": {"name": "my_app", "version": "0.0.1"},
"tool": {"hatch": {"envs": {"default": {field: script_config}}}},
}
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.scripts == {"foo": ["command1"], "bar": ["command3", "command2"]}
@pytest.mark.parametrize("field", ["scripts", "extra-scripts"])
def test_multiple_commands_not_string(self, isolation, isolated_data_dir, platform, global_application, field):
config = {
"project": {"name": "my_app", "version": "0.0.1"},
"tool": {"hatch": {"envs": {"default": {field: {"foo": [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=f"Command #1 in field `tool.hatch.envs.default.{field}.foo` must be a string"
):
_ = environment.scripts
@pytest.mark.parametrize("field", ["scripts", "extra-scripts"])
def test_config_invalid_type(self, isolation, isolated_data_dir, platform, global_application, field):
config = {
"project": {"name": "my_app", "version": "0.0.1"},
"tool": {"hatch": {"envs": {"default": {field: {"foo": 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=f"Field `tool.hatch.envs.default.{field}.foo` must be a string or an array of strings"
):
_ = environment.scripts
@pytest.mark.parametrize("field", ["scripts", "extra-scripts"])
def test_command_expansion_basic(self, isolation, isolated_data_dir, platform, global_application, field):
script_config = {"foo": "command1", "bar": ["command3", "foo"]}
config = {
"project": {"name": "my_app", "version": "0.0.1"},
"tool": {"hatch": {"envs": {"default": {field: script_config}}}},
}
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.scripts == {"foo": ["command1"], "bar": ["command3", "command1"]}
@pytest.mark.parametrize("field", ["scripts", "extra-scripts"])
def test_command_expansion_multiple_nested(self, isolation, isolated_data_dir, platform, global_application, field):
script_config = {
"foo": "command3",
"baz": ["command5", "bar", "foo", "command1"],
"bar": ["command4", "foo", "command2"],
}
config = {
"project": {"name": "my_app", "version": "0.0.1"},
"tool": {"hatch": {"envs": {"default": {field: script_config}}}},
}
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.scripts == {
"foo": ["command3"],
"baz": ["command5", "command4", "command3", "command2", "command3", "command1"],
"bar": ["command4", "command3", "command2"],
}
@pytest.mark.parametrize("field", ["scripts", "extra-scripts"])
def test_command_expansion_multiple_nested_ignore_exit_code(
self, isolation, isolated_data_dir, platform, global_application, field
):
script_config = {
"foo": "command3",
"baz": ["command5", "- bar", "foo", "command1"],
"bar": ["command4", "- foo", "command2"],
}
config = {
"project": {"name": "my_app", "version": "0.0.1"},
"tool": {"hatch": {"envs": {"default": {field: script_config}}}},
}
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.scripts == {
"foo": ["command3"],
"baz": ["command5", "- command4", "- command3", "- command2", "command3", "command1"],
"bar": ["command4", "- command3", "command2"],
}
@pytest.mark.parametrize("field", ["scripts", "extra-scripts"])
def test_command_expansion_modification(self, isolation, isolated_data_dir, platform, global_application, field):
script_config = {
"foo": "command3",
"baz": ["command5", "bar world", "foo", "command1"],
"bar": ["command4", "foo hello", "command2"],
}
config = {
"project": {"name": "my_app", "version": "0.0.1"},
"tool": {"hatch": {"envs": {"default": {field: script_config}}}},
}
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.scripts == {
"foo": ["command3"],
"baz": ["command5", "command4 world", "command3 hello world", "command2 world", "command3", "command1"],
"bar": ["command4", "command3 hello", "command2"],
}
def test_command_expansion_circular_inheritance(self, isolation, isolated_data_dir, platform, global_application):
config = {
"project": {"name": "my_app", "version": "0.0.1"},
"tool": {"hatch": {"envs": {"default": {"scripts": {"foo": "bar", "bar": "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="Circular expansion detected for field `tool.hatch.envs.default.scripts`: foo -> bar -> foo",
):
_ = environment.scripts
def test_extra_less_precedence(self, isolation, isolated_data_dir, platform, global_application):
config = {
"project": {"name": "my_app", "version": "0.0.1"},
"tool": {
"hatch": {
"envs": {
"default": {
"extra-scripts": {"foo": "command4", "baz": "command3"},
"scripts": {"foo": "command1", "bar": "command2"},
}
}
},
},
}
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.scripts == {"foo": ["command1"], "bar": ["command2"], "baz": ["command3"]}
class TestPreInstallCommands:
def test_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.pre_install_commands == environment.pre_install_commands == []
def test_not_array(self, isolation, isolated_data_dir, platform, global_application):
config = {
"project": {"name": "my_app", "version": "0.0.1"},
"tool": {"hatch": {"envs": {"default": {"pre-install-commands": 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.pre-install-commands` must be an array"):
_ = environment.pre_install_commands
def test_entry_not_string(self, isolation, isolated_data_dir, platform, global_application):
config = {
"project": {"name": "my_app", "version": "0.0.1"},
"tool": {"hatch": {"envs": {"default": {"pre-install-commands": [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="Command #1 of field `tool.hatch.envs.default.pre-install-commands` must be a string"
):
_ = environment.pre_install_commands
def test_correct(self, isolation, isolated_data_dir, platform, global_application):
config = {
"project": {"name": "my_app", "version": "0.0.1"},
"tool": {"hatch": {"envs": {"default": {"pre-install-commands": ["baz test"]}}}},
}
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.pre_install_commands == ["baz test"]
class TestPostInstallCommands:
def test_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.post_install_commands == environment.post_install_commands == []
def test_not_array(self, isolation, isolated_data_dir, platform, global_application):
config = {
"project": {"name": "my_app", "version": "0.0.1"},
"tool": {"hatch": {"envs": {"default": {"post-install-commands": 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.post-install-commands` must be an array"):
_ = environment.post_install_commands
def test_entry_not_string(self, isolation, isolated_data_dir, platform, global_application):
config = {
"project": {"name": "my_app", "version": "0.0.1"},
"tool": {"hatch": {"envs": {"default": {"post-install-commands": [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="Command #1 of field `tool.hatch.envs.default.post-install-commands` must be a string"
):
_ = environment.post_install_commands
def test_correct(self, isolation, isolated_data_dir, platform, global_application):
config = {
"project": {"name": "my_app", "version": "0.0.1"},
"tool": {"hatch": {"envs": {"default": {"post-install-commands": ["baz test"]}}}},
}
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.post_install_commands == ["baz test"]
class TestEnvVarOption:
def test_unset(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.get_env_var_option("foo") == ""
def test_set(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,
)
with EnvVars({"HATCH_ENV_TYPE_MOCK_FOO": "bar"}):
assert environment.get_env_var_option("foo") == "bar"
class TestContextFormatting:
def test_env_name(self, isolation, isolated_data_dir, platform, global_application):
config = {
"project": {"name": "my_app", "version": "0.0.1"},
"tool": {"hatch": {"envs": {"default": {"scripts": {"foo": "command {env_name}"}}}}},
}
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 list(environment.expand_command("foo")) == ["command default"]
def test_env_type(self, isolation, isolated_data_dir, platform, global_application):
config = {
"project": {"name": "my_app", "version": "0.0.1"},
"tool": {"hatch": {"envs": {"default": {"scripts": {"foo": "command {env_type}"}}}}},
}
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 list(environment.expand_command("foo")) == ["command mock"]
def test_verbosity_default(self, isolation, isolated_data_dir, platform, global_application):
config = {
"project": {"name": "my_app", "version": "0.0.1"},
"tool": {"hatch": {"envs": {"default": {"scripts": {"foo": "command -v={verbosity}"}}}}},
}
project = Project(isolation, config=config)
environment = MockEnvironment(
isolation,
project.metadata,
"default",
project.config.envs["default"],
{},
isolated_data_dir,
isolated_data_dir,
platform,
9000,
global_application,
)
assert list(environment.expand_command("foo")) == ["command -v=9000"]
def test_verbosity_unknown_modifier(self, isolation, isolated_data_dir, platform, global_application):
config = {
"project": {"name": "my_app", "version": "0.0.1"},
"tool": {"hatch": {"envs": {"default": {"scripts": {"foo": "command {verbosity:bar}"}}}}},
}
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="Unknown verbosity modifier: bar"):
next(environment.expand_command("foo"))
def test_verbosity_flag_adjustment_not_integer(self, isolation, isolated_data_dir, platform, global_application):
config = {
"project": {"name": "my_app", "version": "0.0.1"},
"tool": {"hatch": {"envs": {"default": {"scripts": {"foo": "command {verbosity:flag:-1.0}"}}}}},
}
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="Verbosity flag adjustment must be an integer: -1.0"):
next(environment.expand_command("foo"))
@pytest.mark.parametrize(
("verbosity", "command"),
[
(-9000, "command -qqq"),
(-3, "command -qqq"),
(-2, "command -qq"),
(-1, "command -q"),
(0, "command"),
(1, "command -v"),
(2, "command -vv"),
(3, "command -vvv"),
(9000, "command -vvv"),
],
)
def test_verbosity_flag_default(
self, isolation, isolated_data_dir, platform, global_application, verbosity, command
):
config = {
"project": {"name": "my_app", "version": "0.0.1"},
"tool": {"hatch": {"envs": {"default": {"scripts": {"foo": "command {verbosity:flag}"}}}}},
}
project = Project(isolation, config=config)
environment = MockEnvironment(
isolation,
project.metadata,
"default",
project.config.envs["default"],
{},
isolated_data_dir,
isolated_data_dir,
platform,
verbosity,
global_application,
)
assert list(environment.expand_command("foo")) == [command]
@pytest.mark.parametrize(
("adjustment", "command"),
[
(-9000, "command -qqq"),
(-3, "command -qqq"),
(-2, "command -qq"),
(-1, "command -q"),
(0, "command"),
(1, "command -v"),
(2, "command -vv"),
(3, "command -vvv"),
(9000, "command -vvv"),
],
)
def test_verbosity_flag_adjustment(
self, isolation, isolated_data_dir, platform, global_application, adjustment, command
):
config = {
"project": {"name": "my_app", "version": "0.0.1"},
"tool": {"hatch": {"envs": {"default": {"scripts": {"foo": f"command {{verbosity:flag:{adjustment}}}"}}}}},
}
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 list(environment.expand_command("foo")) == [command]
def test_verbosity_flag_adjustment_invalid(self, temp_dir, isolated_data_dir, platform, temp_application):
"""Test verbosity flag with invalid adjustment."""
pyproject = temp_dir / "pyproject.toml"
pyproject.write_text("""
[project]
name = "my-app"
version = "0.0.1"
[tool.hatch.envs.default]
scripts.test = "pytest {verbosity:flag:invalid}"
""")
project = Project(temp_dir)
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,
)
with pytest.raises(TypeError, match="Verbosity flag adjustment must be an integer"):
list(environment.expand_command("test"))
def test_args_undefined(self, isolation, isolated_data_dir, platform, global_application):
config = {
"project": {"name": "my_app", "version": "0.0.1"},
"tool": {"hatch": {"envs": {"default": {"scripts": {"foo": "command {args}"}}}}},
}
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 list(environment.expand_command("foo")) == ["command"]
def test_args_default(self, isolation, isolated_data_dir, platform, global_application):
config = {
"project": {"name": "my_app", "version": "0.0.1"},
"tool": {"hatch": {"envs": {"default": {"scripts": {"foo": "command {args: -bar > /dev/null}"}}}}},
}
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 list(environment.expand_command("foo")) == ["command -bar > /dev/null"]
def test_args_default_override(self, isolation, isolated_data_dir, platform, global_application):
config = {
"project": {"name": "my_app", "version": "0.0.1"},
"tool": {"hatch": {"envs": {"default": {"scripts": {"foo": "command {args: -bar > /dev/null}"}}}}},
}
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 list(environment.expand_command("foo baz")) == ["command baz"]
def test_matrix_no_selection(self, isolation, isolated_data_dir, platform, global_application):
config = {
"project": {"name": "my_app", "version": "0.0.1"},
"tool": {"hatch": {"envs": {"default": {"dependencies": ["pkg=={matrix}"]}}}},
}
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="The `matrix` context formatting field requires a modifier"):
_ = environment.dependencies
def test_matrix_no_default(self, isolation, isolated_data_dir, platform, global_application):
config = {
"project": {"name": "my_app", "version": "0.0.1"},
"tool": {"hatch": {"envs": {"default": {"dependencies": ["pkg=={matrix:bar}"]}}}},
}
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="Nonexistent matrix variable must set a default: bar"):
_ = environment.dependencies
def test_matrix_default(self, isolation, isolated_data_dir, platform, global_application):
config = {
"project": {"name": "my_app", "version": "0.0.1"},
"tool": {"hatch": {"envs": {"default": {"dependencies": ["pkg=={matrix:bar: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,
)
assert environment.dependencies == ["pkg==9000"]
def test_matrix_default_override(self, isolation, isolated_data_dir, platform, global_application):
config = {
"project": {"name": "my_app", "version": "0.0.1"},
"tool": {"hatch": {"envs": {"default": {"dependencies": ["pkg=={matrix:bar:baz}"]}}}},
}
project = Project(isolation, config=config)
environment = MockEnvironment(
isolation,
project.metadata,
"default",
project.config.envs["default"],
{"bar": "42"},
isolated_data_dir,
isolated_data_dir,
platform,
0,
global_application,
)
assert environment.dependencies == ["pkg==42"]
def test_env_vars_override(self, isolation, isolated_data_dir, platform, global_application):
config = {
"project": {"name": "my_app", "version": "0.0.1"},
"tool": {
"hatch": {
"envs": {
"default": {
"dependencies": ["pkg{env:DEP_PIN}"],
"env-vars": {"DEP_PIN": "==0.0.1"},
"overrides": {"env": {"DEP_ANY": {"env-vars": "DEP_PIN="}}},
},
},
},
},
}
with EnvVars({"DEP_ANY": "true"}):
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.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)
with pytest.raises(TypeError, match="Field workspace must be a table"):
MockEnvironment(
isolation,
project.metadata,
"default",
project.config.envs["default"], # Exception raised here
{},
isolated_data_dir,
isolated_data_dir,
platform,
0,
global_application,
)
def test_parallel_not_boolean(self, isolation, isolated_data_dir, platform, global_application):
config = {
"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)
with pytest.raises(TypeError, match="Field workspace.members must be an array"):
MockEnvironment(
isolation,
project.metadata,
"default",
project.config.envs["default"], # Exception raised here
{},
isolated_data_dir,
isolated_data_dir,
platform,
0,
global_application,
)
def test_member_invalid_type(self, isolation, isolated_data_dir, platform, global_application):
config = {
"project": {"name": "my_app", "version": "0.0.1"},
"tool": {"hatch": {"envs": {"default": {"workspace": {"members": [9000]}}}}},
}
project = Project(isolation, config=config)
with pytest.raises(TypeError, match="Member #1 must be a string or table"):
MockEnvironment(
isolation,
project.metadata,
"default",
project.config.envs["default"], # Exception raised here
{},
isolated_data_dir,
isolated_data_dir,
platform,
0,
global_application,
)
def test_member_no_path(self, isolation, isolated_data_dir, platform, global_application):
config = {
"project": {"name": "my_app", "version": "0.0.1"},
"tool": {"hatch": {"envs": {"default": {"workspace": {"members": [{}]}}}}},
}
project = Project(isolation, config=config)
with pytest.raises(TypeError, match="Member #1 must define a `path` key"):
MockEnvironment(
isolation,
project.metadata,
"default",
project.config.envs["default"], # Exception raised here
{},
isolated_data_dir,
isolated_data_dir,
platform,
0,
global_application,
)
def test_member_path_not_string(self, isolation, isolated_data_dir, platform, global_application):
config = {
"project": {"name": "my_app", "version": "0.0.1"},
"tool": {"hatch": {"envs": {"default": {"workspace": {"members": [{"path": 9000}]}}}}},
}
project = Project(isolation, config=config)
with pytest.raises(TypeError, match="Member #1 path must be a string"):
MockEnvironment(
isolation,
project.metadata,
"default",
project.config.envs["default"], # Exception raised here
{},
isolated_data_dir,
isolated_data_dir,
platform,
0,
global_application,
)
def test_member_path_empty_string(self, isolation, isolated_data_dir, platform, global_application):
config = {
"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
def test_member_outside_root_with_shared_prefix(self, temp_dir, isolated_data_dir, platform, global_application):
"""Verify correct workspace member discovery with shared path prefix.
os.path.commonprefix works character-by-character, so for paths that
share a partial directory name (e.g. 'local_app' and 'lib_member' both
start with 'l'), it would return an invalid path like '.../l' instead
of the true common ancestor directory. os.path.commonpath correctly
returns the nearest common directory, which is what we need as the
base for the member glob search.
Example of the mismatch:
os.path.commonprefix(['/usr/lib', '/usr/local/lib']) == '/usr/l'
os.path.commonpath(['/usr/lib', '/usr/local/lib']) == '/usr'
"""
project_root = temp_dir / "local_app"
project_root.mkdir()
member_path = temp_dir / "lib_member"
member_path.mkdir()
(member_path / "pyproject.toml").write_text(
"""\
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[project]
name = "lib-member"
version = "0.1.0"
"""
)
config = {
"project": {"name": "my_app", "version": "0.0.1"},
"tool": {"hatch": {"envs": {"default": {"workspace": {"members": [{"path": "../lib_member"}]}}}}},
}
project = Project(project_root, config=config)
environment = MockEnvironment(
project_root,
project.metadata,
"default",
project.config.envs["default"],
{},
isolated_data_dir,
isolated_data_dir,
platform,
0,
global_application,
)
members = environment.workspace.members
assert len(members) == 1
assert members[0].project.location == member_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",
]
class TestDependencyHash:
def test_hash_includes_local_dependencies(self, temp_dir, isolated_data_dir, platform, temp_application):
"""Verify dependency hash includes local dependencies."""
pyproject = temp_dir / "pyproject.toml"
pyproject.write_text("""
[project]
name = "my-app"
version = "0.0.1"
""")
config = {"project": {"name": "my_app", "version": "0.0.1"}}
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,
)
hash_value = environment.dependency_hash()
assert hash_value
assert len(hash_value) > 0
def test_hash_stable_when_dependencies_unchanged(self, temp_dir, isolated_data_dir, platform, temp_application):
"""Verify dependency hash is stable when dependencies don't change."""
pyproject = temp_dir / "pyproject.toml"
pyproject.write_text("""
[project]
name = "my-app"
version = "0.0.1"
""")
config = {"project": {"name": "my_app", "version": "0.0.1"}}
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,
)
hash1 = environment.dependency_hash()
hash2 = environment.dependency_hash()
assert hash1 == hash2
def test_hash_changes_with_extra_dependencies(self, temp_dir, isolated_data_dir, platform, temp_application):
"""Verify dependency hash changes when extra-dependencies are added."""
pyproject = temp_dir / "pyproject.toml"
pyproject.write_text("""
[project]
name = "my-app"
version = "0.0.1"
""")
config_no_deps = {"project": {"name": "my_app", "version": "0.0.1"}}
project_no_deps = Project(temp_dir, config=config_no_deps)
project_no_deps.set_app(temp_application)
temp_application.project = project_no_deps
env_no_deps = MockEnvironment(
temp_dir,
project_no_deps.metadata,
"default",
project_no_deps.config.envs["default"],
{},
isolated_data_dir,
isolated_data_dir,
platform,
0,
temp_application,
)
hash_no_deps = env_no_deps.dependency_hash()
config_with_deps = {
"project": {"name": "my_app", "version": "0.0.1"},
"tool": {"hatch": {"envs": {"default": {"extra-dependencies": ["pytest"]}}}},
}
project_with_deps = Project(temp_dir, config=config_with_deps)
project_with_deps.set_app(temp_application)
temp_application.project = project_with_deps
env_with_deps = MockEnvironment(
temp_dir,
project_with_deps.metadata,
"default",
project_with_deps.config.envs["default"],
{},
isolated_data_dir,
isolated_data_dir,
platform,
0,
temp_application,
)
hash_with_deps = env_with_deps.dependency_hash()
assert hash_no_deps != hash_with_deps
class TestLocalDependenciesComplex:
def test_dev_mode_true_returns_editable(self, temp_dir, isolated_data_dir, platform, temp_application):
"""Verify dev-mode=true creates editable local dependency."""
pyproject = temp_dir / "pyproject.toml"
pyproject.write_text("""
[project]
name = "my-app"
version = "0.0.1"
""")
project = Project(temp_dir)
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,
)
local_deps = environment.local_dependencies_complex
assert len(local_deps) == 1
assert local_deps[0].editable is True
def test_dev_mode_false_returns_non_editable(self, temp_dir, isolated_data_dir, platform, temp_application):
"""Verify dev-mode=false creates non-editable local dependency."""
pyproject = temp_dir / "pyproject.toml"
pyproject.write_text("""
[project]
name = "my-app"
version = "0.0.1"
[tool.hatch.envs.default]
dev-mode = false
""")
project = Project(temp_dir)
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,
)
local_deps = environment.local_dependencies_complex
assert len(local_deps) == 1
assert local_deps[0].editable is False
def test_workspace_members_always_editable(self, temp_dir, isolated_data_dir, platform, temp_application):
"""Verify workspace members are always editable regardless of dev-mode."""
pyproject = temp_dir / "pyproject.toml"
pyproject.write_text("""
[project]
name = "my-app"
version = "0.0.1"
[tool.hatch.envs.default]
dev-mode = false
workspace.members = ["member"]
""")
member_dir = temp_dir / "member"
member_dir.mkdir()
(member_dir / "pyproject.toml").write_text("""
[project]
name = "member"
version = "0.0.1"
""")
project = Project(temp_dir)
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,
)
local_deps = environment.local_dependencies_complex
assert len(local_deps) == 2
project_dep = next(d for d in local_deps if d.name == "my-app")
member_dep = next(d for d in local_deps if d.name == "member")
assert project_dep.editable is False
assert member_dep.editable is True
class TestDynamicDependencies:
def test_dynamic_dependencies_resolved(self, temp_dir, isolated_data_dir, platform, temp_application):
"""Verify dynamic dependencies are resolved correctly."""
pyproject = temp_dir / "pyproject.toml"
pyproject.write_text("""
[project]
name = "my-app"
version = "0.0.1"
dynamic = ["dependencies"]
[tool.hatch.metadata]
allow-direct-references = true
""")
project = Project(temp_dir)
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 "dependencies" in environment.metadata.dynamic
class TestBuildSystemIntegration:
def test_builder_includes_build_requirements(self, temp_dir, isolated_data_dir, platform, temp_application):
"""Verify builder environment includes build system requirements."""
pyproject = temp_dir / "pyproject.toml"
pyproject.write_text("""
[build-system]
requires = ["hatchling", "build-dep"]
[project]
name = "my-app"
version = "0.0.1"
[tool.hatch.envs.build]
builder = true
""")
project = Project(temp_dir)
project.set_app(temp_application)
temp_application.project = project
environment = MockEnvironment(
temp_dir,
project.metadata,
"build",
project.config.envs["build"],
{},
isolated_data_dir,
isolated_data_dir,
platform,
0,
temp_application,
)
deps = environment.dependencies
assert any("hatchling" in d for d in deps)
assert any("build-dep" in d for d in deps)
class TestEnvironmentLifecycle:
def test_app_status_contexts(self, temp_dir, isolated_data_dir, platform, temp_application):
"""Verify environment lifecycle status contexts work correctly."""
pyproject = temp_dir / "pyproject.toml"
pyproject.write_text("""
[project]
name = "my-app"
version = "0.0.1"
""")
project = Project(temp_dir)
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,
)
with environment.app_status_creation():
pass
with environment.app_status_pre_installation():
pass
with environment.app_status_post_installation():
pass
with environment.app_status_project_installation():
pass
with environment.app_status_dependency_state_check():
pass
with environment.app_status_dependency_installation_check():
pass
with environment.app_status_dependency_synchronization():
pass
class TestFileSystemContext:
def test_join_creates_new_context(self, temp_dir, isolated_data_dir, platform, temp_application):
"""Test FileSystemContext.join creates proper paths."""
from hatch.env.plugin.interface import FileSystemContext
pyproject = temp_dir / "pyproject.toml"
pyproject.write_text("""
[project]
name = "my-app"
version = "0.0.1"
""")
project = Project(temp_dir)
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,
)
ctx = FileSystemContext(environment, local_path=temp_dir, env_path="/env")
new_ctx = ctx.join("subdir")
assert "subdir" in str(new_ctx.local_path)
assert "subdir" in new_ctx.env_path
================================================
FILE: tests/helpers/__init__.py
================================================
================================================
FILE: tests/helpers/helpers.py
================================================
from __future__ import annotations
import importlib
import json
import os
import re
import sys
from datetime import datetime, timezone
from functools import lru_cache
from textwrap import dedent as _dedent
from typing import TYPE_CHECKING
from unittest.mock import call
import tomli_w
from hatch.config.user import RootConfig
from hatch.env.utils import add_verbosity_flag
from hatch.python.core import InstalledDistribution
from hatch.python.resolve import get_distribution
from hatch.utils.toml import load_toml_file
if TYPE_CHECKING:
from hatch.utils.fs import Path
def dedent(text):
return _dedent(text[1:])
@lru_cache
def tarfile_extraction_compat_options():
return {"filter": "data"} if sys.version_info >= (3, 12) else {}
def remove_trailing_spaces(text):
return "".join(f"{line.rstrip()}\n" for line in text.splitlines(True))
def extract_requirements(lines):
for raw_line in lines:
line = raw_line.rstrip()
if line and not line.startswith("#"):
yield line
def get_current_timestamp():
return datetime.now(timezone.utc).timestamp()
def assert_plugin_installation(subprocess_run, dependencies: list[str], *, verbosity=0, count=1):
command = [
sys.executable,
"-u",
"-m",
"pip",
"install",
"--disable-pip-version-check",
]
add_verbosity_flag(command, verbosity, adjustment=-1)
command.extend(dependencies)
assert subprocess_run.call_args_list == [call(command, shell=False)] * count
def assert_files(directory, expected_files, *, check_contents=True):
start = str(directory)
expected_relative_files = {str(f.path): f.contents for f in expected_files}
seen_relative_file_paths = set()
for root, _, files in os.walk(directory):
relative_path = os.path.relpath(root, start)
# First iteration
if relative_path == ".":
relative_path = ""
for file_name in files:
relative_file_path = os.path.join(relative_path, file_name)
seen_relative_file_paths.add(relative_file_path)
if check_contents and relative_file_path in expected_relative_files:
file_path = os.path.join(start, relative_file_path)
expected_contents = expected_relative_files[relative_file_path]
try:
with open(file_path, encoding="utf-8") as f:
assert f.read() == expected_contents, relative_file_path
except UnicodeDecodeError:
with open(file_path, "rb") as f:
assert f.read() == expected_contents, (relative_file_path, expected_contents)
else: # no cov
pass
expected_relative_file_paths = set(expected_relative_files)
missing_files = expected_relative_file_paths - seen_relative_file_paths
assert not missing_files, f"Missing files: {', '.join(sorted(missing_files))}"
extra_files = seen_relative_file_paths - expected_relative_file_paths
assert not extra_files, f"Extra files: {', '.join(sorted(extra_files))}"
def assert_output_match(output: str, pattern: str, *, exact: bool = True):
flags = re.MULTILINE if exact else re.MULTILINE | re.DOTALL
assert re.search(dedent(pattern), output, flags=flags) is not None, output
def get_template_files(template_name, project_name, **kwargs):
kwargs["project_name"] = project_name
kwargs["project_name_normalized"] = project_name.lower().replace(".", "-")
kwargs["package_name"] = kwargs["project_name_normalized"].replace("-", "_")
config = RootConfig({})
kwargs.setdefault("author", config.template.name)
kwargs.setdefault("email", config.template.email)
kwargs.setdefault("year", str(datetime.now(timezone.utc).year))
return __load_template_module(template_name)(**kwargs)
@lru_cache
def __load_template_module(template_name):
template = importlib.import_module(f"..templates.{template_name}", __name__)
return template.get_files
def update_project_environment(project, name, config):
project_file = project.root / "pyproject.toml"
raw_config = load_toml_file(str(project_file))
env_config = raw_config.setdefault("tool", {}).setdefault("hatch", {}).setdefault("envs", {}).setdefault(name, {})
env_config.update(config)
project.config.envs[name] = project.config.envs.get(name, project.config.envs["default"]).copy()
project.config.envs[name].update(env_config)
with open(str(project_file), "w", encoding="utf-8") as f:
f.write(tomli_w.dumps(raw_config))
def write_distribution(directory: Path, name: str):
dist = get_distribution(name)
path = directory / dist.name
path.ensure_dir_exists()
python_path = path / dist.python_path
python_path.parent.ensure_dir_exists()
python_path.touch()
metadata = {"source": dist.source, "python_path": dist.python_path}
metadata_file = path / InstalledDistribution.metadata_filename()
metadata_file.write_text(json.dumps(metadata))
return InstalledDistribution(path, dist, metadata)
def downgrade_distribution_metadata(dist_dir: Path):
metadata_file = dist_dir / InstalledDistribution.metadata_filename()
metadata = json.loads(metadata_file.read_text())
dist = InstalledDistribution(dist_dir, get_distribution(dist_dir.name), metadata)
source = metadata["source"]
python_path = metadata["python_path"]
version = dist.version
new_version = downgrade_version(version)
new_source = source.replace(version, new_version)
metadata["source"] = new_source
# We also modify the Python path because some directory structures are determined
# by the archive name which is itself determined by the source
metadata["python_path"] = python_path.replace(version, new_version)
if python_path != metadata["python_path"]:
new_python_path = dist_dir / metadata["python_path"]
new_python_path.parent.ensure_dir_exists()
(dist_dir / python_path).rename(new_python_path)
metadata_file.write_text(json.dumps(metadata))
return metadata
def downgrade_version(version: str) -> str:
major_version = version.split(".")[0]
return version.replace(major_version, str(int(major_version) - 1), 1)
================================================
FILE: tests/helpers/templates/__init__.py
================================================
================================================
FILE: tests/helpers/templates/licenses/__init__.py
================================================
Apache_2_0 = """\
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/ TERMS AND CONDITIONS FOR USE, REPRODUCTION,
AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction, and distribution
as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by the copyright
owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all other entities
that control, are controlled by, or are under common control with that entity.
For the purposes of this definition, "control" means (i) the power, direct
or indirect, to cause the direction or management of such entity, whether
by contract or otherwise, or (ii) ownership of fifty percent (50%) or more
of the outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity exercising permissions
granted by this License.
"Source" form shall mean the preferred form for making modifications, including
but not limited to software source code, documentation source, and configuration
files.
"Object" form shall mean any form resulting from mechanical transformation
or translation of a Source form, including but not limited to compiled object
code, generated documentation, and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or Object form,
made available under the License, as indicated by a copyright notice that
is included in or attached to the work (an example is provided in the Appendix
below).
"Derivative Works" shall mean any work, whether in Source or Object form,
that is based on (or derived from) the Work and for which the editorial revisions,
annotations, elaborations, or other modifications represent, as a whole, an
original work of authorship. For the purposes of this License, Derivative
Works shall not include works that remain separable from, or merely link (or
bind by name) to the interfaces of, the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including the original version
of the Work and any modifications or additions to that Work or Derivative
Works thereof, that is intentionally submitted to Licensor for inclusion in
the Work by the copyright owner or by an individual or Legal Entity authorized
to submit on behalf of the copyright owner. For the purposes of this definition,
"submitted" means any form of electronic, verbal, or written communication
sent to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems, and
issue tracking systems that are managed by, or on behalf of, the Licensor
for the purpose of discussing and improving the Work, but excluding communication
that is conspicuously marked or otherwise designated in writing by the copyright
owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity on behalf
of whom a Contribution has been received by Licensor and subsequently incorporated
within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of this
License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive,
no-charge, royalty-free, irrevocable copyright license to reproduce, prepare
Derivative Works of, publicly display, publicly perform, sublicense, and distribute
the Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of this License,
each Contributor hereby grants to You a perpetual, worldwide, non-exclusive,
no-charge, royalty-free, irrevocable (except as stated in this section) patent
license to make, have made, use, offer to sell, sell, import, and otherwise
transfer the Work, where such license applies only to those patent claims
licensable by such Contributor that are necessarily infringed by their Contribution(s)
alone or by combination of their Contribution(s) with the Work to which such
Contribution(s) was submitted. If You institute patent litigation against
any entity (including a cross-claim or counterclaim in a lawsuit) alleging
that the Work or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses granted to You
under this License for that Work shall terminate as of the date such litigation
is filed.
4. Redistribution. You may reproduce and distribute copies of the Work or
Derivative Works thereof in any medium, with or without modifications, and
in Source or Object form, provided that You meet the following conditions:
(a) You must give any other recipients of the Work or Derivative Works a copy
of this License; and
(b) You must cause any modified files to carry prominent notices stating that
You changed the files; and
(c) You must retain, in the Source form of any Derivative Works that You distribute,
all copyright, patent, trademark, and attribution notices from the Source
form of the Work, excluding those notices that do not pertain to any part
of the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its distribution,
then any Derivative Works that You distribute must include a readable copy
of the attribution notices contained within such NOTICE file, excluding those
notices that do not pertain to any part of the Derivative Works, in at least
one of the following places: within a NOTICE text file distributed as part
of the Derivative Works; within the Source form or documentation, if provided
along with the Derivative Works; or, within a display generated by the Derivative
Works, if and wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and do not modify the
License. You may add Your own attribution notices within Derivative Works
that You distribute, alongside or as an addendum to the NOTICE text from the
Work, provided that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and may provide
additional or different license terms and conditions for use, reproduction,
or distribution of Your modifications, or for any such Derivative Works as
a whole, provided Your use, reproduction, and distribution of the Work otherwise
complies with the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise, any
Contribution intentionally submitted for inclusion in the Work by You to the
Licensor shall be under the terms and conditions of this License, without
any additional terms or conditions. Notwithstanding the above, nothing herein
shall supersede or modify the terms of any separate license agreement you
may have executed with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade names,
trademarks, service marks, or product names of the Licensor, except as required
for reasonable and customary use in describing the origin of the Work and
reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or agreed to
in writing, Licensor provides the Work (and each Contributor provides its
Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
KIND, either express or implied, including, without limitation, any warranties
or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR
A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness
of using or redistributing the Work and assume any risks associated with Your
exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory, whether
in tort (including negligence), contract, or otherwise, unless required by
applicable law (such as deliberate and grossly negligent acts) or agreed to
in writing, shall any Contributor be liable to You for damages, including
any direct, indirect, special, incidental, or consequential damages of any
character arising as a result of this License or out of the use or inability
to use the Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all other commercial
damages or losses), even if such Contributor has been advised of the possibility
of such damages.
9. Accepting Warranty or Additional Liability. While redistributing the Work
or Derivative Works thereof, You may choose to offer, and charge a fee for,
acceptance of support, warranty, indemnity, or other liability obligations
and/or rights consistent with this License. However, in accepting such obligations,
You may act only on Your own behalf and on Your sole responsibility, not on
behalf of any other Contributor, and only if You agree to indemnify, defend,
and hold each Contributor harmless for any liability incurred by, or claims
asserted against, such Contributor by reason of your accepting any such warranty
or additional liability. END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following boilerplate
notice, with the fields enclosed by brackets "[]" replaced with your own identifying
information. (Don't include the brackets!) The text should be enclosed in
the appropriate comment syntax for the file format. We also recommend that
a file or class name and description of purpose be included on the same "printed
page" as the copyright notice for easier identification within third-party
archives.
Copyright [yyyy] [name of copyright owner]
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
"""
MIT = """\
MIT License
Copyright (c)
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and
associated documentation files (the "Software"), to deal in the Software without restriction, including
without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the
following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial
portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT
LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO
EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE
USE OR OTHER DEALINGS IN THE SOFTWARE.
"""
================================================
FILE: tests/helpers/templates/new/__init__.py
================================================
================================================
FILE: tests/helpers/templates/new/basic.py
================================================
from hatch.template import File
from hatch.utils.fs import Path
from ..licenses import MIT
def get_files(**kwargs):
return [
File(
Path("LICENSE.txt"),
MIT.replace("", f"{kwargs['year']}-present", 1).replace(
"", f"{kwargs['author']} <{kwargs['email']}>", 1
),
),
File(
Path("src", kwargs["package_name"], "__init__.py"),
f"""\
# SPDX-FileCopyrightText: {kwargs["year"]}-present {kwargs["author"]} <{kwargs["email"]}>
#
# SPDX-License-Identifier: MIT
""",
),
File(
Path("src", kwargs["package_name"], "__about__.py"),
f"""\
# SPDX-FileCopyrightText: {kwargs["year"]}-present {kwargs["author"]} <{kwargs["email"]}>
#
# SPDX-License-Identifier: MIT
__version__ = "0.0.1"
""",
),
File(
Path("README.md"),
f"""\
# {kwargs["project_name"]}
[](https://pypi.org/project/{kwargs["project_name_normalized"]})
[](https://pypi.org/project/{kwargs["project_name_normalized"]})
-----
## Table of Contents
- [Installation](#installation)
- [License](#license)
## Installation
```console
pip install {kwargs["project_name_normalized"]}
```
## License
`{kwargs["project_name_normalized"]}` is distributed under the terms of the [MIT](https://spdx.org/licenses/MIT.html) license.
""",
),
File(
Path("pyproject.toml"),
f"""\
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[project]
name = "{kwargs["project_name_normalized"]}"
dynamic = ["version"]
description = ''
readme = "README.md"
requires-python = ">=3.8"
license = "MIT"
keywords = []
authors = [
{{ name = "{kwargs["author"]}", email = "{kwargs["email"]}" }},
]
classifiers = [
"Development Status :: 4 - Beta",
"Programming Language :: Python",
"Programming Language :: Python :: 3.8",
"Programming Language :: Python :: 3.9",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: Implementation :: CPython",
"Programming Language :: Python :: Implementation :: PyPy",
]
dependencies = []
[project.urls]
Documentation = "https://github.com/{kwargs["author"]}/{kwargs["project_name_normalized"]}#readme"
Issues = "https://github.com/{kwargs["author"]}/{kwargs["project_name_normalized"]}/issues"
Source = "https://github.com/{kwargs["author"]}/{kwargs["project_name_normalized"]}"
[tool.hatch.version]
path = "src/{kwargs["package_name"]}/__about__.py"
""",
),
]
================================================
FILE: tests/helpers/templates/new/default.py
================================================
from hatch.template import File
from hatch.utils.fs import Path
from ..licenses import MIT
def get_files(**kwargs):
description = kwargs.get("description", "")
return [
File(
Path("LICENSE.txt"),
MIT.replace("", f"{kwargs['year']}-present", 1).replace(
"", f"{kwargs['author']} <{kwargs['email']}>", 1
),
),
File(
Path("src", kwargs["package_name"], "__init__.py"),
f"""\
# SPDX-FileCopyrightText: {kwargs["year"]}-present {kwargs["author"]} <{kwargs["email"]}>
#
# SPDX-License-Identifier: MIT
""",
),
File(
Path("src", kwargs["package_name"], "__about__.py"),
f"""\
# SPDX-FileCopyrightText: {kwargs["year"]}-present {kwargs["author"]} <{kwargs["email"]}>
#
# SPDX-License-Identifier: MIT
__version__ = "0.0.1"
""",
),
File(
Path("tests", "__init__.py"),
f"""\
# SPDX-FileCopyrightText: {kwargs["year"]}-present {kwargs["author"]} <{kwargs["email"]}>
#
# SPDX-License-Identifier: MIT
""",
),
File(
Path("README.md"),
f"""\
# {kwargs["project_name"]}
[](https://pypi.org/project/{kwargs["project_name_normalized"]})
[](https://pypi.org/project/{kwargs["project_name_normalized"]})
-----
## Table of Contents
- [Installation](#installation)
- [License](#license)
## Installation
```console
pip install {kwargs["project_name_normalized"]}
```
## License
`{kwargs["project_name_normalized"]}` is distributed under the terms of the [MIT](https://spdx.org/licenses/MIT.html) license.
""",
),
File(
Path("pyproject.toml"),
f"""\
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[project]
name = "{kwargs["project_name_normalized"]}"
dynamic = ["version"]
description = '{description}'
readme = "README.md"
requires-python = ">=3.8"
license = "MIT"
keywords = []
authors = [
{{ name = "{kwargs["author"]}", email = "{kwargs["email"]}" }},
]
classifiers = [
"Development Status :: 4 - Beta",
"Programming Language :: Python",
"Programming Language :: Python :: 3.8",
"Programming Language :: Python :: 3.9",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: Implementation :: CPython",
"Programming Language :: Python :: Implementation :: PyPy",
]
dependencies = []
[project.urls]
Documentation = "https://github.com/{kwargs["author"]}/{kwargs["project_name_normalized"]}#readme"
Issues = "https://github.com/{kwargs["author"]}/{kwargs["project_name_normalized"]}/issues"
Source = "https://github.com/{kwargs["author"]}/{kwargs["project_name_normalized"]}"
[tool.hatch.version]
path = "src/{kwargs["package_name"]}/__about__.py"
[tool.hatch.envs.types]
extra-dependencies = [
"mypy>=1.0.0",
]
[tool.hatch.envs.types.scripts]
check = "mypy --install-types --non-interactive {{args:src/{kwargs["package_name"]} tests}}"
[tool.coverage.run]
source_pkgs = ["{kwargs["package_name"]}", "tests"]
branch = true
parallel = true
omit = [
"src/{kwargs["package_name"]}/__about__.py",
]
[tool.coverage.paths]
{kwargs["package_name"]} = ["src/{kwargs["package_name"]}", "*/{kwargs["project_name_normalized"]}/src/{kwargs["package_name"]}"]
tests = ["tests", "*/{kwargs["project_name_normalized"]}/tests"]
[tool.coverage.report]
exclude_lines = [
"no cov",
"if __name__ == .__main__.:",
"if TYPE_CHECKING:",
]
""",
),
]
================================================
FILE: tests/helpers/templates/new/feature_ci.py
================================================
from hatch.template import File
from hatch.utils.fs import Path
from .default import get_files as get_template_files
def get_files(**kwargs):
files = [File(Path(f.path), f.contents) for f in get_template_files(**kwargs)]
files.append(
File(
Path(".github", "workflows", "test.yml"),
"""\
name: test
on:
push:
branches: [main, master]
pull_request:
branches: [main, master]
concurrency:
group: test-${{ github.head_ref }}
cancel-in-progress: true
env:
PYTHONUNBUFFERED: "1"
FORCE_COLOR: "1"
jobs:
run:
name: Python ${{ matrix.python-version }} on ${{ startsWith(matrix.os, 'macos-') && 'macOS' || startsWith(matrix.os, 'windows-') && 'Windows' || 'Linux' }}
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, windows-latest, macos-latest]
python-version: ['3.8', '3.9', '3.10', '3.11', '3.12', '3.13']
steps:
- uses: actions/checkout@v3
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v4
with:
python-version: ${{ matrix.python-version }}
- name: Install Hatch
run: pip install --upgrade hatch
- name: Run static analysis
run: hatch fmt --check
- name: Run tests
run: hatch test --python ${{ matrix.python-version }} --cover --randomize --parallel --retries 2 --retry-delay 1
""",
)
)
return files
================================================
FILE: tests/helpers/templates/new/feature_cli.py
================================================
from hatch.template import File
from hatch.utils.fs import Path
from ..licenses import MIT
def get_files(**kwargs):
return [
File(
Path("LICENSE.txt"),
MIT.replace("", f"{kwargs['year']}-present", 1).replace(
"", f"{kwargs['author']} <{kwargs['email']}>", 1
),
),
File(
Path("src", kwargs["package_name"], "__init__.py"),
f"""\
# SPDX-FileCopyrightText: {kwargs["year"]}-present {kwargs["author"]} <{kwargs["email"]}>
#
# SPDX-License-Identifier: MIT
""",
),
File(
Path("src", kwargs["package_name"], "__about__.py"),
f"""\
# SPDX-FileCopyrightText: {kwargs["year"]}-present {kwargs["author"]} <{kwargs["email"]}>
#
# SPDX-License-Identifier: MIT
__version__ = "0.0.1"
""",
),
File(
Path("src", kwargs["package_name"], "__main__.py"),
f"""\
# SPDX-FileCopyrightText: {kwargs["year"]}-present {kwargs["author"]} <{kwargs["email"]}>
#
# SPDX-License-Identifier: MIT
import sys
if __name__ == "__main__":
from {kwargs["package_name"]}.cli import {kwargs["package_name"]}
sys.exit({kwargs["package_name"]}())
""",
),
File(
Path("src", kwargs["package_name"], "cli", "__init__.py"),
f"""\
# SPDX-FileCopyrightText: {kwargs["year"]}-present {kwargs["author"]} <{kwargs["email"]}>
#
# SPDX-License-Identifier: MIT
import click
from {kwargs["package_name"]}.__about__ import __version__
@click.group(context_settings={{"help_option_names": ["-h", "--help"]}}, invoke_without_command=True)
@click.version_option(version=__version__, prog_name="{kwargs["project_name"]}")
def {kwargs["package_name"]}():
click.echo("Hello world!")
""",
),
File(
Path("tests", "__init__.py"),
f"""\
# SPDX-FileCopyrightText: {kwargs["year"]}-present {kwargs["author"]} <{kwargs["email"]}>
#
# SPDX-License-Identifier: MIT
""",
),
File(
Path("README.md"),
f"""\
# {kwargs["project_name"]}
[](https://pypi.org/project/{kwargs["project_name_normalized"]})
[](https://pypi.org/project/{kwargs["project_name_normalized"]})
-----
## Table of Contents
- [Installation](#installation)
- [License](#license)
## Installation
```console
pip install {kwargs["project_name_normalized"]}
```
## License
`{kwargs["project_name_normalized"]}` is distributed under the terms of the [MIT](https://spdx.org/licenses/MIT.html) license.
""",
),
File(
Path("pyproject.toml"),
f"""\
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[project]
name = "{kwargs["project_name_normalized"]}"
dynamic = ["version"]
description = ''
readme = "README.md"
requires-python = ">=3.8"
license = "MIT"
keywords = []
authors = [
{{ name = "{kwargs["author"]}", email = "{kwargs["email"]}" }},
]
classifiers = [
"Development Status :: 4 - Beta",
"Programming Language :: Python",
"Programming Language :: Python :: 3.8",
"Programming Language :: Python :: 3.9",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: Implementation :: CPython",
"Programming Language :: Python :: Implementation :: PyPy",
]
dependencies = [
"click",
]
[project.urls]
Documentation = "https://github.com/{kwargs["author"]}/{kwargs["project_name_normalized"]}#readme"
Issues = "https://github.com/{kwargs["author"]}/{kwargs["project_name_normalized"]}/issues"
Source = "https://github.com/{kwargs["author"]}/{kwargs["project_name_normalized"]}"
[project.scripts]
{kwargs["project_name_normalized"]} = "{kwargs["package_name"]}.cli:{kwargs["package_name"]}"
[tool.hatch.version]
path = "src/{kwargs["package_name"]}/__about__.py"
[tool.hatch.envs.types]
extra-dependencies = [
"mypy>=1.0.0",
]
[tool.hatch.envs.types.scripts]
check = "mypy --install-types --non-interactive {{args:src/{kwargs["package_name"]} tests}}"
[tool.coverage.run]
source_pkgs = ["{kwargs["package_name"]}", "tests"]
branch = true
parallel = true
omit = [
"src/{kwargs["package_name"]}/__about__.py",
]
[tool.coverage.paths]
{kwargs["package_name"]} = ["src/{kwargs["package_name"]}", "*/{kwargs["project_name_normalized"]}/src/{kwargs["package_name"]}"]
tests = ["tests", "*/{kwargs["project_name_normalized"]}/tests"]
[tool.coverage.report]
exclude_lines = [
"no cov",
"if __name__ == .__main__.:",
"if TYPE_CHECKING:",
]
""",
),
]
================================================
FILE: tests/helpers/templates/new/feature_no_src_layout.py
================================================
from hatch.template import File
from hatch.utils.fs import Path
from ..licenses import MIT
def get_files(**kwargs):
description = kwargs.get("description", "")
return [
File(
Path("LICENSE.txt"),
MIT.replace("", f"{kwargs['year']}-present", 1).replace(
"", f"{kwargs['author']} <{kwargs['email']}>", 1
),
),
File(
Path(kwargs["package_name"], "__init__.py"),
f"""\
# SPDX-FileCopyrightText: {kwargs["year"]}-present {kwargs["author"]} <{kwargs["email"]}>
#
# SPDX-License-Identifier: MIT
""",
),
File(
Path(kwargs["package_name"], "__about__.py"),
f"""\
# SPDX-FileCopyrightText: {kwargs["year"]}-present {kwargs["author"]} <{kwargs["email"]}>
#
# SPDX-License-Identifier: MIT
__version__ = "0.0.1"
""",
),
File(
Path("tests", "__init__.py"),
f"""\
# SPDX-FileCopyrightText: {kwargs["year"]}-present {kwargs["author"]} <{kwargs["email"]}>
#
# SPDX-License-Identifier: MIT
""",
),
File(
Path("README.md"),
f"""\
# {kwargs["project_name"]}
[](https://pypi.org/project/{kwargs["project_name_normalized"]})
[](https://pypi.org/project/{kwargs["project_name_normalized"]})
-----
## Table of Contents
- [Installation](#installation)
- [License](#license)
## Installation
```console
pip install {kwargs["project_name_normalized"]}
```
## License
`{kwargs["project_name_normalized"]}` is distributed under the terms of the [MIT](https://spdx.org/licenses/MIT.html) license.
""",
),
File(
Path("pyproject.toml"),
f"""\
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[project]
name = "{kwargs["project_name_normalized"]}"
dynamic = ["version"]
description = '{description}'
readme = "README.md"
requires-python = ">=3.8"
license = "MIT"
keywords = []
authors = [
{{ name = "{kwargs["author"]}", email = "{kwargs["email"]}" }},
]
classifiers = [
"Development Status :: 4 - Beta",
"Programming Language :: Python",
"Programming Language :: Python :: 3.8",
"Programming Language :: Python :: 3.9",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: Implementation :: CPython",
"Programming Language :: Python :: Implementation :: PyPy",
]
dependencies = []
[project.urls]
Documentation = "https://github.com/{kwargs["author"]}/{kwargs["project_name_normalized"]}#readme"
Issues = "https://github.com/{kwargs["author"]}/{kwargs["project_name_normalized"]}/issues"
Source = "https://github.com/{kwargs["author"]}/{kwargs["project_name_normalized"]}"
[tool.hatch.version]
path = "{kwargs["package_name"]}/__about__.py"
[tool.hatch.envs.types]
extra-dependencies = [
"mypy>=1.0.0",
]
[tool.hatch.envs.types.scripts]
check = "mypy --install-types --non-interactive {{args:{kwargs["package_name"]} tests}}"
[tool.coverage.run]
source_pkgs = ["{kwargs["package_name"]}", "tests"]
branch = true
parallel = true
omit = [
"{kwargs["package_name"]}/__about__.py",
]
[tool.coverage.paths]
{kwargs["package_name"]} = ["{kwargs["package_name"]}", "*/{kwargs["project_name_normalized"]}/{kwargs["package_name"]}"]
tests = ["tests", "*/{kwargs["project_name_normalized"]}/tests"]
[tool.coverage.report]
exclude_lines = [
"no cov",
"if __name__ == .__main__.:",
"if TYPE_CHECKING:",
]
""",
),
]
================================================
FILE: tests/helpers/templates/new/licenses_empty.py
================================================
from hatch.template import File
from hatch.utils.fs import Path
def get_files(**kwargs):
return [
File(Path("src", kwargs["package_name"], "__init__.py")),
File(Path("src", kwargs["package_name"], "__about__.py"), '__version__ = "0.0.1"\n'),
File(Path("tests", "__init__.py")),
File(
Path("README.md"),
f"""\
# {kwargs["project_name"]}
[](https://pypi.org/project/{kwargs["project_name_normalized"]})
[](https://pypi.org/project/{kwargs["project_name_normalized"]})
-----
## Table of Contents
- [Installation](#installation)
## Installation
```console
pip install {kwargs["project_name_normalized"]}
```
""",
),
File(
Path("pyproject.toml"),
f"""\
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[project]
name = "{kwargs["project_name_normalized"]}"
dynamic = ["version"]
description = ''
readme = "README.md"
requires-python = ">=3.8"
license = ""
keywords = []
authors = [
{{ name = "{kwargs["author"]}", email = "{kwargs["email"]}" }},
]
classifiers = [
"Development Status :: 4 - Beta",
"Programming Language :: Python",
"Programming Language :: Python :: 3.8",
"Programming Language :: Python :: 3.9",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: Implementation :: CPython",
"Programming Language :: Python :: Implementation :: PyPy",
]
dependencies = []
[project.urls]
Documentation = "https://github.com/{kwargs["author"]}/{kwargs["project_name_normalized"]}#readme"
Issues = "https://github.com/{kwargs["author"]}/{kwargs["project_name_normalized"]}/issues"
Source = "https://github.com/{kwargs["author"]}/{kwargs["project_name_normalized"]}"
[tool.hatch.version]
path = "src/{kwargs["package_name"]}/__about__.py"
[tool.hatch.envs.types]
extra-dependencies = [
"mypy>=1.0.0",
]
[tool.hatch.envs.types.scripts]
check = "mypy --install-types --non-interactive {{args:src/{kwargs["package_name"]} tests}}"
[tool.coverage.run]
source_pkgs = ["{kwargs["package_name"]}", "tests"]
branch = true
parallel = true
omit = [
"src/{kwargs["package_name"]}/__about__.py",
]
[tool.coverage.paths]
{kwargs["package_name"]} = ["src/{kwargs["package_name"]}", "*/{kwargs["project_name_normalized"]}/src/{kwargs["package_name"]}"]
tests = ["tests", "*/{kwargs["project_name_normalized"]}/tests"]
[tool.coverage.report]
exclude_lines = [
"no cov",
"if __name__ == .__main__.:",
"if TYPE_CHECKING:",
]
""",
),
]
================================================
FILE: tests/helpers/templates/new/licenses_multiple.py
================================================
from hatch.template import File
from hatch.utils.fs import Path
from ..licenses import MIT, Apache_2_0
def get_files(**kwargs):
return [
File(Path("LICENSES", "Apache-2.0.txt"), Apache_2_0),
File(
Path("LICENSES", "MIT.txt"),
MIT.replace("", f"{kwargs['year']}-present", 1).replace(
"", f"{kwargs['author']} <{kwargs['email']}>", 1
),
),
File(
Path("src", kwargs["package_name"], "__init__.py"),
f"""\
# SPDX-FileCopyrightText: {kwargs["year"]}-present {kwargs["author"]} <{kwargs["email"]}>
#
# SPDX-License-Identifier: Apache-2.0 OR MIT
""",
),
File(
Path("src", kwargs["package_name"], "__about__.py"),
f"""\
# SPDX-FileCopyrightText: {kwargs["year"]}-present {kwargs["author"]} <{kwargs["email"]}>
#
# SPDX-License-Identifier: Apache-2.0 OR MIT
__version__ = "0.0.1"
""",
),
File(
Path("tests", "__init__.py"),
f"""\
# SPDX-FileCopyrightText: {kwargs["year"]}-present {kwargs["author"]} <{kwargs["email"]}>
#
# SPDX-License-Identifier: Apache-2.0 OR MIT
""",
),
File(
Path("README.md"),
f"""\
# {kwargs["project_name"]}
[](https://pypi.org/project/{kwargs["project_name_normalized"]})
[](https://pypi.org/project/{kwargs["project_name_normalized"]})
-----
## Table of Contents
- [Installation](#installation)
- [License](#license)
## Installation
```console
pip install {kwargs["project_name_normalized"]}
```
## License
`{kwargs["project_name_normalized"]}` is distributed under the terms of any of the following licenses:
- [Apache-2.0](https://spdx.org/licenses/Apache-2.0.html)
- [MIT](https://spdx.org/licenses/MIT.html)
""",
),
File(
Path("pyproject.toml"),
f"""\
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[project]
name = "{kwargs["project_name_normalized"]}"
dynamic = ["version"]
description = ''
readme = "README.md"
requires-python = ">=3.8"
license = "Apache-2.0 OR MIT"
license-files = {{ globs = ["LICENSES/*"] }}
keywords = []
authors = [
{{ name = "{kwargs["author"]}", email = "{kwargs["email"]}" }},
]
classifiers = [
"Development Status :: 4 - Beta",
"Programming Language :: Python",
"Programming Language :: Python :: 3.8",
"Programming Language :: Python :: 3.9",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: Implementation :: CPython",
"Programming Language :: Python :: Implementation :: PyPy",
]
dependencies = []
[project.urls]
Documentation = "https://github.com/{kwargs["author"]}/{kwargs["project_name_normalized"]}#readme"
Issues = "https://github.com/{kwargs["author"]}/{kwargs["project_name_normalized"]}/issues"
Source = "https://github.com/{kwargs["author"]}/{kwargs["project_name_normalized"]}"
[tool.hatch.version]
path = "src/{kwargs["package_name"]}/__about__.py"
[tool.hatch.envs.types]
extra-dependencies = [
"mypy>=1.0.0",
]
[tool.hatch.envs.types.scripts]
check = "mypy --install-types --non-interactive {{args:src/{kwargs["package_name"]} tests}}"
[tool.coverage.run]
source_pkgs = ["{kwargs["package_name"]}", "tests"]
branch = true
parallel = true
omit = [
"src/{kwargs["package_name"]}/__about__.py",
]
[tool.coverage.paths]
{kwargs["package_name"]} = ["src/{kwargs["package_name"]}", "*/{kwargs["project_name_normalized"]}/src/{kwargs["package_name"]}"]
tests = ["tests", "*/{kwargs["project_name_normalized"]}/tests"]
[tool.coverage.report]
exclude_lines = [
"no cov",
"if __name__ == .__main__.:",
"if TYPE_CHECKING:",
]
""",
),
]
================================================
FILE: tests/helpers/templates/new/projects_urls_empty.py
================================================
from hatch.template import File
from hatch.utils.fs import Path
from ..licenses import MIT
def get_files(**kwargs):
return [
File(
Path("LICENSE.txt"),
MIT.replace("", f"{kwargs['year']}-present", 1).replace(
"", f"{kwargs['author']} <{kwargs['email']}>", 1
),
),
File(
Path("src", kwargs["package_name"], "__init__.py"),
f"""\
# SPDX-FileCopyrightText: {kwargs["year"]}-present {kwargs["author"]} <{kwargs["email"]}>
#
# SPDX-License-Identifier: MIT
""",
),
File(
Path("src", kwargs["package_name"], "__about__.py"),
f"""\
# SPDX-FileCopyrightText: {kwargs["year"]}-present {kwargs["author"]} <{kwargs["email"]}>
#
# SPDX-License-Identifier: MIT
__version__ = "0.0.1"
""",
),
File(
Path("tests", "__init__.py"),
f"""\
# SPDX-FileCopyrightText: {kwargs["year"]}-present {kwargs["author"]} <{kwargs["email"]}>
#
# SPDX-License-Identifier: MIT
""",
),
File(
Path("README.md"),
f"""\
# {kwargs["project_name"]}
[](https://pypi.org/project/{kwargs["project_name_normalized"]})
[](https://pypi.org/project/{kwargs["project_name_normalized"]})
-----
## Table of Contents
- [Installation](#installation)
- [License](#license)
## Installation
```console
pip install {kwargs["project_name_normalized"]}
```
## License
`{kwargs["project_name_normalized"]}` is distributed under the terms of the [MIT](https://spdx.org/licenses/MIT.html) license.
""",
),
File(
Path("pyproject.toml"),
f"""\
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[project]
name = "{kwargs["project_name_normalized"]}"
dynamic = ["version"]
description = ''
readme = "README.md"
requires-python = ">=3.8"
license = "MIT"
keywords = []
authors = [
{{ name = "{kwargs["author"]}", email = "{kwargs["email"]}" }},
]
classifiers = [
"Development Status :: 4 - Beta",
"Programming Language :: Python",
"Programming Language :: Python :: 3.8",
"Programming Language :: Python :: 3.9",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: Implementation :: CPython",
"Programming Language :: Python :: Implementation :: PyPy",
]
dependencies = []
[project.urls]
[tool.hatch.version]
path = "src/{kwargs["package_name"]}/__about__.py"
[tool.hatch.envs.types]
extra-dependencies = [
"mypy>=1.0.0",
]
[tool.hatch.envs.types.scripts]
check = "mypy --install-types --non-interactive {{args:src/{kwargs["package_name"]} tests}}"
[tool.coverage.run]
source_pkgs = ["{kwargs["package_name"]}", "tests"]
branch = true
parallel = true
omit = [
"src/{kwargs["package_name"]}/__about__.py",
]
[tool.coverage.paths]
{kwargs["package_name"]} = ["src/{kwargs["package_name"]}", "*/{kwargs["project_name_normalized"]}/src/{kwargs["package_name"]}"]
tests = ["tests", "*/{kwargs["project_name_normalized"]}/tests"]
[tool.coverage.report]
exclude_lines = [
"no cov",
"if __name__ == .__main__.:",
"if TYPE_CHECKING:",
]
""",
),
]
================================================
FILE: tests/helpers/templates/new/projects_urls_space_in_label.py
================================================
from hatch.template import File
from hatch.utils.fs import Path
from ..licenses import MIT
def get_files(**kwargs):
return [
File(
Path("LICENSE.txt"),
MIT.replace("", f"{kwargs['year']}-present", 1).replace(
"", f"{kwargs['author']} <{kwargs['email']}>", 1
),
),
File(
Path("src", kwargs["package_name"], "__init__.py"),
f"""\
# SPDX-FileCopyrightText: {kwargs["year"]}-present {kwargs["author"]} <{kwargs["email"]}>
#
# SPDX-License-Identifier: MIT
""",
),
File(
Path("src", kwargs["package_name"], "__about__.py"),
f"""\
# SPDX-FileCopyrightText: {kwargs["year"]}-present {kwargs["author"]} <{kwargs["email"]}>
#
# SPDX-License-Identifier: MIT
__version__ = "0.0.1"
""",
),
File(
Path("tests", "__init__.py"),
f"""\
# SPDX-FileCopyrightText: {kwargs["year"]}-present {kwargs["author"]} <{kwargs["email"]}>
#
# SPDX-License-Identifier: MIT
""",
),
File(
Path("README.md"),
f"""\
# {kwargs["project_name"]}
[](https://pypi.org/project/{kwargs["project_name_normalized"]})
[](https://pypi.org/project/{kwargs["project_name_normalized"]})
-----
## Table of Contents
- [Installation](#installation)
- [License](#license)
## Installation
```console
pip install {kwargs["project_name_normalized"]}
```
## License
`{kwargs["project_name_normalized"]}` is distributed under the terms of the [MIT](https://spdx.org/licenses/MIT.html) license.
""",
),
File(
Path("pyproject.toml"),
f"""\
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[project]
name = "{kwargs["project_name_normalized"]}"
dynamic = ["version"]
description = ''
readme = "README.md"
requires-python = ">=3.8"
license = "MIT"
keywords = []
authors = [
{{ name = "{kwargs["author"]}", email = "{kwargs["email"]}" }},
]
classifiers = [
"Development Status :: 4 - Beta",
"Programming Language :: Python",
"Programming Language :: Python :: 3.8",
"Programming Language :: Python :: 3.9",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: Implementation :: CPython",
"Programming Language :: Python :: Implementation :: PyPy",
]
dependencies = []
[project.urls]
Documentation = "https://github.com/{kwargs["author"]}/{kwargs["project_name_normalized"]}#readme"
Source = "https://github.com/{kwargs["author"]}/{kwargs["project_name_normalized"]}"
"Bug Tracker" = "https://github.com/{kwargs["author"]}/{kwargs["project_name_normalized"]}/issues"
[tool.hatch.version]
path = "src/{kwargs["package_name"]}/__about__.py"
[tool.hatch.envs.types]
extra-dependencies = [
"mypy>=1.0.0",
]
[tool.hatch.envs.types.scripts]
check = "mypy --install-types --non-interactive {{args:src/{kwargs["package_name"]} tests}}"
[tool.coverage.run]
source_pkgs = ["{kwargs["package_name"]}", "tests"]
branch = true
parallel = true
omit = [
"src/{kwargs["package_name"]}/__about__.py",
]
[tool.coverage.paths]
{kwargs["package_name"]} = ["src/{kwargs["package_name"]}", "*/{kwargs["project_name_normalized"]}/src/{kwargs["package_name"]}"]
tests = ["tests", "*/{kwargs["project_name_normalized"]}/tests"]
[tool.coverage.report]
exclude_lines = [
"no cov",
"if __name__ == .__main__.:",
"if TYPE_CHECKING:",
]
""",
),
]
================================================
FILE: tests/helpers/templates/sdist/__init__.py
================================================
================================================
FILE: tests/helpers/templates/sdist/standard_default.py
================================================
from hatch.template import File
from hatch.utils.fs import Path
from hatchling.metadata.spec import DEFAULT_METADATA_VERSION
from ..new.feature_no_src_layout import get_files as get_template_files
def get_files(**kwargs):
relative_root = kwargs.get("relative_root", "")
files = [File(Path(relative_root, f.path), f.contents) for f in get_template_files(**kwargs)]
files.append(
File(
Path(relative_root, "PKG-INFO"),
f"""\
Metadata-Version: {DEFAULT_METADATA_VERSION}
Name: {kwargs["project_name"]}
Version: 0.0.1
License-File: LICENSE.txt
""",
)
)
return files
================================================
FILE: tests/helpers/templates/sdist/standard_default_build_script_artifacts.py
================================================
from hatch.template import File
from hatch.utils.fs import Path
from hatchling.metadata.spec import DEFAULT_METADATA_VERSION
from hatchling.utils.constants import DEFAULT_BUILD_SCRIPT
from ..new.feature_no_src_layout import get_files as get_template_files
def get_files(**kwargs):
relative_root = kwargs.get("relative_root", "")
files = [File(Path(relative_root, f.path), f.contents) for f in get_template_files(**kwargs)]
files.extend((
File(Path(relative_root, kwargs["package_name"], "lib.so"), ""),
File(
Path(relative_root, ".gitignore"),
"""\
*.pyc
*.so
*.h
""",
),
File(
Path(relative_root, DEFAULT_BUILD_SCRIPT),
"""\
import pathlib
from hatchling.builders.hooks.plugin.interface import BuildHookInterface
class CustomHook(BuildHookInterface):
def initialize(self, version, build_data):
pathlib.Path('my_app', 'lib.so').touch()
pathlib.Path('my_app', 'lib.h').touch()
""",
),
File(
Path(relative_root, "PKG-INFO"),
f"""\
Metadata-Version: {DEFAULT_METADATA_VERSION}
Name: {kwargs["project_name"]}
Version: 0.0.1
License-File: LICENSE.txt
""",
),
))
return files
================================================
FILE: tests/helpers/templates/sdist/standard_default_build_script_extra_dependencies.py
================================================
from hatch.template import File
from hatch.utils.fs import Path
from hatchling.metadata.spec import DEFAULT_METADATA_VERSION
from hatchling.utils.constants import DEFAULT_BUILD_SCRIPT
from ..new.feature_no_src_layout import get_files as get_template_files
def get_files(**kwargs):
relative_root = kwargs.get("relative_root", "")
files = [File(Path(relative_root, f.path), f.contents) for f in get_template_files(**kwargs)]
files.extend((
File(Path(relative_root, kwargs["package_name"], "lib.so"), ""),
File(
Path(relative_root, ".gitignore"),
"""\
*.pyc
*.so
*.h
""",
),
File(
Path(relative_root, DEFAULT_BUILD_SCRIPT),
"""\
import pathlib
from hatchling.builders.hooks.plugin.interface import BuildHookInterface
class CustomHook(BuildHookInterface):
def initialize(self, version, build_data):
pathlib.Path('my_app', 'lib.so').touch()
pathlib.Path('my_app', 'lib.h').touch()
build_data['dependencies'].append('binary')
""",
),
File(
Path(relative_root, "PKG-INFO"),
f"""\
Metadata-Version: {DEFAULT_METADATA_VERSION}
Name: {kwargs["project_name"]}
Version: 0.0.1
License-File: LICENSE.txt
Requires-Dist: binary
""",
),
))
return files
================================================
FILE: tests/helpers/templates/sdist/standard_default_support_legacy.py
================================================
from hatch.template import File
from hatch.utils.fs import Path
from hatchling.metadata.spec import DEFAULT_METADATA_VERSION
from ..new.feature_no_src_layout import get_files as get_template_files
def get_files(**kwargs):
relative_root = kwargs.get("relative_root", "")
files = [File(Path(relative_root, f.path), f.contents) for f in get_template_files(**kwargs)]
files.extend((
File(
Path(relative_root, "PKG-INFO"),
f"""\
Metadata-Version: {DEFAULT_METADATA_VERSION}
Name: {kwargs["project_name"]}
Version: 0.0.1
License-File: LICENSE.txt
""",
),
File(
Path(relative_root, "setup.py"),
f"""\
from setuptools import setup
setup(
name='{kwargs["project_name_normalized"]}',
version='0.0.1',
packages=[
'{kwargs["package_name"]}',
'tests',
],
)
""",
),
))
return files
================================================
FILE: tests/helpers/templates/sdist/standard_default_vcs_git_exclusion_files.py
================================================
from hatch.template import File
from hatch.utils.fs import Path
from hatchling.metadata.spec import DEFAULT_METADATA_VERSION
from ..new.feature_no_src_layout import get_files as get_template_files
def get_files(**kwargs):
relative_root = kwargs.get("relative_root", "")
files = [File(Path(relative_root, f.path), f.contents) for f in get_template_files(**kwargs)]
files.extend((
File(Path(relative_root, kwargs["package_name"], "lib.so"), ""),
File(
Path(relative_root, ".gitignore"),
"""\
*.pyc
*.so
*.h
""",
),
File(
Path(relative_root, "PKG-INFO"),
f"""\
Metadata-Version: {DEFAULT_METADATA_VERSION}
Name: {kwargs["project_name"]}
Version: 0.0.1
License-File: LICENSE.txt
""",
),
))
return files
================================================
FILE: tests/helpers/templates/sdist/standard_default_vcs_mercurial_exclusion_files.py
================================================
from hatch.template import File
from hatch.utils.fs import Path
from hatchling.metadata.spec import DEFAULT_METADATA_VERSION
from ..new.feature_no_src_layout import get_files as get_template_files
def get_files(**kwargs):
relative_root = kwargs.get("relative_root", "")
files = [File(Path(relative_root, f.path), f.contents) for f in get_template_files(**kwargs)]
files.extend((
File(Path(relative_root, kwargs["package_name"], "lib.so"), ""),
File(
Path(relative_root, ".hgignore"),
"""\
syntax: glob
*.pyc
syntax: foo
README.md
syntax: glob
*.so
*.h
""",
),
File(
Path(relative_root, "PKG-INFO"),
f"""\
Metadata-Version: {DEFAULT_METADATA_VERSION}
Name: {kwargs["project_name"]}
Version: 0.0.1
License-File: LICENSE.txt
""",
),
))
return files
================================================
FILE: tests/helpers/templates/sdist/standard_include.py
================================================
from hatch.template import File
from hatch.utils.fs import Path
from hatchling.metadata.spec import DEFAULT_METADATA_VERSION
from ..new.feature_no_src_layout import get_files as get_template_files
def get_files(**kwargs):
relative_root = kwargs.get("relative_root", "")
files = []
for f in get_template_files(**kwargs):
part = f.path.parts[0]
if part in {"my_app", "pyproject.toml", "README.md", "LICENSE.txt"}:
files.append(File(Path(relative_root, f.path), f.contents))
files.append(
File(
Path(relative_root, "PKG-INFO"),
f"""\
Metadata-Version: {DEFAULT_METADATA_VERSION}
Name: {kwargs["project_name"]}
Version: 0.0.1
License-File: LICENSE.txt
Description-Content-Type: text/markdown
# My.App
[](https://pypi.org/project/my-app)
[](https://pypi.org/project/my-app)
-----
## Table of Contents
- [Installation](#installation)
- [License](#license)
## Installation
```console
pip install my-app
```
## License
`my-app` is distributed under the terms of the [MIT](https://spdx.org/licenses/MIT.html) license.
""",
)
)
return files
================================================
FILE: tests/helpers/templates/sdist/standard_include_config_file.py
================================================
from hatch.template import File
from hatch.utils.fs import Path
from hatchling.metadata.spec import DEFAULT_METADATA_VERSION
from ..new.feature_no_src_layout import get_files as get_template_files
def get_files(**kwargs):
relative_root = kwargs.get("relative_root", "")
files = []
for f in get_template_files(**kwargs):
part = f.path.parts[0]
if part in {"my_app", "pyproject.toml", "README.md", "LICENSE.txt"}:
files.append(File(Path(relative_root, f.path), f.contents))
files.extend((
File(Path(relative_root, "hatch.toml"), ""),
File(
Path(relative_root, "PKG-INFO"),
f"""\
Metadata-Version: {DEFAULT_METADATA_VERSION}
Name: {kwargs["project_name"]}
Version: 0.0.1
License-File: LICENSE.txt
Description-Content-Type: text/markdown
# My.App
[](https://pypi.org/project/my-app)
[](https://pypi.org/project/my-app)
-----
## Table of Contents
- [Installation](#installation)
- [License](#license)
## Installation
```console
pip install my-app
```
## License
`my-app` is distributed under the terms of the [MIT](https://spdx.org/licenses/MIT.html) license.
""",
),
))
return files
================================================
FILE: tests/helpers/templates/wheel/__init__.py
================================================
================================================
FILE: tests/helpers/templates/wheel/standard_default_build_script.py
================================================
from hatch.template import File
from hatch.utils.fs import Path
from hatchling.__about__ import __version__
from hatchling.metadata.spec import DEFAULT_METADATA_VERSION
from ..new.feature_no_src_layout import get_files as get_template_files
from .utils import update_record_file_contents
def get_files(**kwargs):
metadata_directory = kwargs.get("metadata_directory", "")
files = []
for f in get_template_files(**kwargs):
if str(f.path) == "LICENSE.txt":
files.append(File(Path(metadata_directory, "licenses", f.path), f.contents))
if f.path.parts[0] != kwargs["package_name"]:
continue
files.append(f)
files.extend((
File(
Path(metadata_directory, "WHEEL"),
f"""\
Wheel-Version: 1.0
Generator: hatchling {__version__}
Root-Is-Purelib: true
Tag: {kwargs.get("tag", "")}
""",
),
File(
Path(metadata_directory, "METADATA"),
f"""\
Metadata-Version: {DEFAULT_METADATA_VERSION}
Name: {kwargs["project_name"]}
Version: 0.0.1
License-File: LICENSE.txt
Requires-Python: >3
""",
),
))
record_file = File(Path(metadata_directory, "RECORD"), "")
update_record_file_contents(record_file, files)
files.append(record_file)
return files
================================================
FILE: tests/helpers/templates/wheel/standard_default_build_script_artifacts.py
================================================
from hatch.template import File
from hatch.utils.fs import Path
from hatchling.__about__ import __version__
from hatchling.metadata.spec import DEFAULT_METADATA_VERSION
from ..new.feature_no_src_layout import get_files as get_template_files
from .utils import update_record_file_contents
def get_files(**kwargs):
metadata_directory = kwargs.get("metadata_directory", "")
files = []
for f in get_template_files(**kwargs):
if str(f.path) == "LICENSE.txt":
files.append(File(Path(metadata_directory, "licenses", f.path), f.contents))
if f.path.parts[0] != kwargs["package_name"]:
continue
files.append(f)
files.extend((
File(Path(kwargs["package_name"], "lib.so"), ""),
File(
Path(metadata_directory, "WHEEL"),
f"""\
Wheel-Version: 1.0
Generator: hatchling {__version__}
Root-Is-Purelib: false
Tag: {kwargs.get("tag", "")}
""",
),
File(
Path(metadata_directory, "METADATA"),
f"""\
Metadata-Version: {DEFAULT_METADATA_VERSION}
Name: {kwargs["project_name"]}
Version: 0.0.1
License-File: LICENSE.txt
Requires-Python: >3
""",
),
))
record_file = File(Path(metadata_directory, "RECORD"), "")
update_record_file_contents(record_file, files)
files.append(record_file)
return files
================================================
FILE: tests/helpers/templates/wheel/standard_default_build_script_artifacts_with_src_layout.py
================================================
from hatch.template import File
from hatch.utils.fs import Path
from hatchling.__about__ import __version__
from hatchling.metadata.spec import DEFAULT_METADATA_VERSION
from ..new.default import get_files as get_template_files
from .utils import update_record_file_contents
def get_files(**kwargs):
metadata_directory = kwargs.get("metadata_directory", "")
files = []
for f in get_template_files(**kwargs):
if str(f.path) == "LICENSE.txt":
files.append(File(Path(metadata_directory, "licenses", f.path), f.contents))
if f.path.parts[0] != "src":
continue
files.append(File(Path(*f.path.parts[1:]), f.contents))
files.extend((
File(Path(kwargs["package_name"], "lib.so"), ""),
File(Path("zlib.pyd"), ""),
File(
Path(metadata_directory, "WHEEL"),
f"""\
Wheel-Version: 1.0
Generator: hatchling {__version__}
Root-Is-Purelib: false
Tag: {kwargs.get("tag", "")}
""",
),
File(
Path(metadata_directory, "METADATA"),
f"""\
Metadata-Version: {DEFAULT_METADATA_VERSION}
Name: {kwargs["project_name"]}
Version: 0.0.1
License-File: LICENSE.txt
Requires-Python: >3
""",
),
))
record_file = File(Path(metadata_directory, "RECORD"), "")
update_record_file_contents(record_file, files)
files.append(record_file)
return files
================================================
FILE: tests/helpers/templates/wheel/standard_default_build_script_configured_build_hooks.py
================================================
from hatch.template import File
from hatch.utils.fs import Path
from hatchling.__about__ import __version__
from hatchling.metadata.spec import DEFAULT_METADATA_VERSION
from ..new.feature_no_src_layout import get_files as get_template_files
from .utils import update_record_file_contents
def get_files(**kwargs):
metadata_directory = kwargs.get("metadata_directory", "")
files = []
for f in get_template_files(**kwargs):
if str(f.path) == "LICENSE.txt":
files.append(File(Path(metadata_directory, "licenses", f.path), f.contents))
if f.path.parts[0] != kwargs["package_name"]:
continue
files.append(f)
files.extend((
File(Path(kwargs["package_name"], "lib.so"), "custom"),
File(
Path(metadata_directory, "WHEEL"),
f"""\
Wheel-Version: 1.0
Generator: hatchling {__version__}
Root-Is-Purelib: false
Tag: {kwargs.get("tag", "")}
""",
),
File(
Path(metadata_directory, "METADATA"),
f"""\
Metadata-Version: {DEFAULT_METADATA_VERSION}
Name: {kwargs["project_name"]}
Version: 0.0.1
License-File: LICENSE.txt
Requires-Python: >3
""",
),
))
record_file = File(Path(metadata_directory, "RECORD"), "")
update_record_file_contents(record_file, files)
files.append(record_file)
return files
================================================
FILE: tests/helpers/templates/wheel/standard_default_build_script_extra_dependencies.py
================================================
from hatch.template import File
from hatch.utils.fs import Path
from hatchling.__about__ import __version__
from hatchling.metadata.spec import DEFAULT_METADATA_VERSION
from ..new.feature_no_src_layout import get_files as get_template_files
from .utils import update_record_file_contents
def get_files(**kwargs):
metadata_directory = kwargs.get("metadata_directory", "")
files = []
for f in get_template_files(**kwargs):
if str(f.path) == "LICENSE.txt":
files.append(File(Path(metadata_directory, "licenses", f.path), f.contents))
if f.path.parts[0] != kwargs["package_name"]:
continue
files.append(f)
files.extend((
File(Path(kwargs["package_name"], "lib.so"), ""),
File(
Path(metadata_directory, "WHEEL"),
f"""\
Wheel-Version: 1.0
Generator: hatchling {__version__}
Root-Is-Purelib: false
Tag: {kwargs.get("tag", "")}
""",
),
File(
Path(metadata_directory, "METADATA"),
f"""\
Metadata-Version: {DEFAULT_METADATA_VERSION}
Name: {kwargs["project_name"]}
Version: 0.0.1
License-File: LICENSE.txt
Requires-Python: >3
Requires-Dist: binary
""",
),
))
record_file = File(Path(metadata_directory, "RECORD"), "")
update_record_file_contents(record_file, files)
files.append(record_file)
return files
================================================
FILE: tests/helpers/templates/wheel/standard_default_build_script_force_include.py
================================================
from hatch.template import File
from hatch.utils.fs import Path
from hatchling.__about__ import __version__
from hatchling.metadata.spec import DEFAULT_METADATA_VERSION
from ..new.feature_no_src_layout import get_files as get_template_files
from .utils import update_record_file_contents
def get_files(**kwargs):
metadata_directory = kwargs.get("metadata_directory", "")
files = []
for f in get_template_files(**kwargs):
if str(f.path) == "LICENSE.txt":
files.append(File(Path(metadata_directory, "licenses", f.path), f.contents))
if f.path.parts[0] != kwargs["package_name"]:
continue
files.append(f)
files.extend((
File(Path(kwargs["package_name"], "lib.so"), ""),
File(Path(kwargs["package_name"], "lib.h"), ""),
File(
Path(metadata_directory, "WHEEL"),
f"""\
Wheel-Version: 1.0
Generator: hatchling {__version__}
Root-Is-Purelib: false
Tag: {kwargs.get("tag", "")}
""",
),
File(
Path(metadata_directory, "METADATA"),
f"""\
Metadata-Version: {DEFAULT_METADATA_VERSION}
Name: {kwargs["project_name"]}
Version: 0.0.1
License-File: LICENSE.txt
Requires-Python: >3
""",
),
))
record_file = File(Path(metadata_directory, "RECORD"), "")
update_record_file_contents(record_file, files)
files.append(record_file)
return files
================================================
FILE: tests/helpers/templates/wheel/standard_default_build_script_force_include_no_duplication.py
================================================
from hatch.template import File
from hatch.utils.fs import Path
from hatchling.__about__ import __version__
from hatchling.metadata.spec import DEFAULT_METADATA_VERSION
from ..new.feature_no_src_layout import get_files as get_template_files
from .utils import update_record_file_contents
def get_files(**kwargs):
metadata_directory = kwargs.get("metadata_directory", "")
files = []
for f in get_template_files(**kwargs):
if str(f.path) == "LICENSE.txt":
files.append(File(Path(metadata_directory, "licenses", f.path), f.contents))
if f.path.parts[0] != kwargs["package_name"]:
continue
files.append(f)
files.extend((
File(Path(kwargs["package_name"], "z.py"), 'print("hello world")'),
File(
Path(metadata_directory, "WHEEL"),
f"""\
Wheel-Version: 1.0
Generator: hatchling {__version__}
Root-Is-Purelib: false
Tag: {kwargs.get("tag", "")}
""",
),
File(
Path(metadata_directory, "METADATA"),
f"""\
Metadata-Version: {DEFAULT_METADATA_VERSION}
Name: {kwargs["project_name"]}
Version: 0.0.1
License-File: LICENSE.txt
Requires-Python: >3
""",
),
))
record_file = File(Path(metadata_directory, "RECORD"), "")
update_record_file_contents(record_file, files)
files.append(record_file)
return files
================================================
FILE: tests/helpers/templates/wheel/standard_default_extra_metadata.py
================================================
from hatch.template import File
from hatch.utils.fs import Path
from hatchling.__about__ import __version__
from hatchling.metadata.spec import DEFAULT_METADATA_VERSION
from ..new.feature_no_src_layout import get_files as get_template_files
from .utils import update_record_file_contents
def get_files(**kwargs):
metadata_directory = kwargs.get("metadata_directory", "")
files = []
for f in get_template_files(**kwargs):
if str(f.path) == "LICENSE.txt":
files.append(File(Path(metadata_directory, "licenses", f.path), f.contents))
if f.path.parts[0] != kwargs["package_name"]:
continue
files.append(f)
files.extend((
File(Path(metadata_directory, "extra_metadata", "foo.txt"), ""),
File(Path(metadata_directory, "extra_metadata", "nested", "bar.txt"), ""),
File(
Path(metadata_directory, "WHEEL"),
f"""\
Wheel-Version: 1.0
Generator: hatchling {__version__}
Root-Is-Purelib: true
Tag: py3-none-any
""",
),
File(
Path(metadata_directory, "METADATA"),
f"""\
Metadata-Version: {DEFAULT_METADATA_VERSION}
Name: {kwargs["project_name"]}
Version: 0.0.1
License-File: LICENSE.txt
Requires-Python: >3
""",
),
))
record_file = File(Path(metadata_directory, "RECORD"), "")
update_record_file_contents(record_file, files)
files.append(record_file)
return files
================================================
FILE: tests/helpers/templates/wheel/standard_default_license_multiple.py
================================================
from hatch.template import File
from hatch.utils.fs import Path
from hatchling.__about__ import __version__
from hatchling.metadata.spec import DEFAULT_METADATA_VERSION
from ..new.licenses_multiple import get_files as get_template_files
from .utils import update_record_file_contents
def get_files(**kwargs):
metadata_directory = kwargs.get("metadata_directory", "")
files = []
for f in get_template_files(**kwargs):
first_part = f.path.parts[0]
if first_part == "LICENSES":
files.append(File(Path(metadata_directory, "licenses", "LICENSES", f.path.parts[1]), f.contents))
if f.path.parts[0] != "src":
continue
files.append(File(Path(*f.path.parts[1:]), f.contents))
files.extend((
File(
Path(metadata_directory, "WHEEL"),
f"""\
Wheel-Version: 1.0
Generator: hatchling {__version__}
Root-Is-Purelib: true
Tag: py2-none-any
Tag: py3-none-any
""",
),
File(
Path(metadata_directory, "METADATA"),
f"""\
Metadata-Version: {DEFAULT_METADATA_VERSION}
Name: {kwargs["project_name"]}
Version: 0.0.1
License-File: LICENSES/Apache-2.0.txt
License-File: LICENSES/MIT.txt
""",
),
))
record_file = File(Path(metadata_directory, "RECORD"), "")
update_record_file_contents(record_file, files)
files.append(record_file)
return files
================================================
FILE: tests/helpers/templates/wheel/standard_default_license_single.py
================================================
from hatch.template import File
from hatch.utils.fs import Path
from hatchling.__about__ import __version__
from hatchling.metadata.spec import DEFAULT_METADATA_VERSION
from ..new.default import get_files as get_template_files
from .utils import update_record_file_contents
def get_files(**kwargs):
metadata_directory = kwargs.get("metadata_directory", "")
files = []
for f in get_template_files(**kwargs):
if str(f.path) == "LICENSE.txt":
files.append(File(Path(metadata_directory, "licenses", f.path), f.contents))
if f.path.parts[0] != "src":
continue
files.append(File(Path(*f.path.parts[1:]), f.contents))
files.extend((
File(
Path(metadata_directory, "WHEEL"),
f"""\
Wheel-Version: 1.0
Generator: hatchling {__version__}
Root-Is-Purelib: true
Tag: py2-none-any
Tag: py3-none-any
""",
),
File(
Path(metadata_directory, "METADATA"),
f"""\
Metadata-Version: {DEFAULT_METADATA_VERSION}
Name: {kwargs["project_name"]}
Version: 0.0.1
License-File: LICENSE.txt
""",
),
))
record_file = File(Path(metadata_directory, "RECORD"), "")
update_record_file_contents(record_file, files)
files.append(record_file)
return files
================================================
FILE: tests/helpers/templates/wheel/standard_default_namespace_package.py
================================================
from hatch.template import File
from hatch.utils.fs import Path
from hatchling.__about__ import __version__
from hatchling.metadata.spec import DEFAULT_METADATA_VERSION
from ..new.feature_no_src_layout import get_files as get_template_files
from .utils import update_record_file_contents
def get_files(**kwargs):
metadata_directory = kwargs.get("metadata_directory", "")
namespace_package = kwargs["namespace"]
files = []
for f in get_template_files(**kwargs):
if str(f.path) == "LICENSE.txt":
files.append(File(Path(metadata_directory, "licenses", f.path), f.contents))
if f.path.parts[0] != kwargs["package_name"]:
continue
f.path = Path(namespace_package, f.path)
files.append(f)
files.extend((
File(
Path(metadata_directory, "WHEEL"),
f"""\
Wheel-Version: 1.0
Generator: hatchling {__version__}
Root-Is-Purelib: true
Tag: py2-none-any
Tag: py3-none-any
""",
),
File(
Path(metadata_directory, "METADATA"),
f"""\
Metadata-Version: {DEFAULT_METADATA_VERSION}
Name: {kwargs["project_name"]}
Version: 0.0.1
License-File: LICENSE.txt
""",
),
))
record_file = File(Path(metadata_directory, "RECORD"), "")
update_record_file_contents(record_file, files)
files.append(record_file)
return files
================================================
FILE: tests/helpers/templates/wheel/standard_default_python_constraint.py
================================================
from hatch.template import File
from hatch.utils.fs import Path
from hatchling.__about__ import __version__
from hatchling.metadata.spec import DEFAULT_METADATA_VERSION
from ..new.feature_no_src_layout import get_files as get_template_files
from .utils import update_record_file_contents
def get_files(**kwargs):
metadata_directory = kwargs.get("metadata_directory", "")
files = []
for f in get_template_files(**kwargs):
if str(f.path) == "LICENSE.txt":
files.append(File(Path(metadata_directory, "licenses", f.path), f.contents))
if f.path.parts[0] != kwargs["package_name"]:
continue
files.append(f)
files.extend((
File(
Path(metadata_directory, "WHEEL"),
f"""\
Wheel-Version: 1.0
Generator: hatchling {__version__}
Root-Is-Purelib: true
Tag: py3-none-any
""",
),
File(
Path(metadata_directory, "METADATA"),
f"""\
Metadata-Version: {DEFAULT_METADATA_VERSION}
Name: {kwargs["project_name"]}
Version: 0.0.1
License-File: LICENSE.txt
Requires-Python: >3
""",
),
))
record_file = File(Path(metadata_directory, "RECORD"), "")
update_record_file_contents(record_file, files)
files.append(record_file)
return files
================================================
FILE: tests/helpers/templates/wheel/standard_default_python_constraint_three_components.py
================================================
from hatch.template import File
from hatch.utils.fs import Path
from hatchling.__about__ import __version__
from hatchling.metadata.spec import DEFAULT_METADATA_VERSION
from ..new.feature_no_src_layout import get_files as get_template_files
from .utils import update_record_file_contents
def get_files(**kwargs):
metadata_directory = kwargs.get("metadata_directory", "")
files = []
for f in get_template_files(**kwargs):
if str(f.path) == "LICENSE.txt":
files.append(File(Path(metadata_directory, "licenses", f.path), f.contents))
if f.path.parts[0] != kwargs["package_name"]:
continue
files.append(f)
files.extend((
File(
Path(metadata_directory, "WHEEL"),
f"""\
Wheel-Version: 1.0
Generator: hatchling {__version__}
Root-Is-Purelib: true
Tag: py3-none-any
""",
),
File(
Path(metadata_directory, "METADATA"),
f"""\
Metadata-Version: {DEFAULT_METADATA_VERSION}
Name: {kwargs["project_name"]}
Version: 0.0.1
License-File: LICENSE.txt
Requires-Python: ==3.11.4
""",
),
))
record_file = File(Path(metadata_directory, "RECORD"), "")
update_record_file_contents(record_file, files)
files.append(record_file)
return files
================================================
FILE: tests/helpers/templates/wheel/standard_default_sbom.py
================================================
from hatch.template import File
from hatch.utils.fs import Path
from hatchling.__about__ import __version__
from hatchling.metadata.spec import DEFAULT_METADATA_VERSION
from ..new.default import get_files as get_template_files
from .utils import update_record_file_contents
def get_files(**kwargs):
metadata_directory = kwargs.get("metadata_directory", "")
sbom_files = kwargs.get("sbom_files", [])
files = []
for f in get_template_files(**kwargs):
if str(f.path) == "LICENSE.txt":
files.append(File(Path(metadata_directory, "licenses", f.path), f.contents))
if f.path.parts[0] != "src":
continue
files.append(File(Path(*f.path.parts[1:]), f.contents))
# Add SBOM files
for sbom_path, sbom_content in sbom_files:
files.append(File(Path(metadata_directory, "sboms", sbom_path), sbom_content))
files.extend((
File(
Path(metadata_directory, "WHEEL"),
f"""\
Wheel-Version: 1.0
Generator: hatchling {__version__}
Root-Is-Purelib: true
Tag: py2-none-any
Tag: py3-none-any
""",
),
File(
Path(metadata_directory, "METADATA"),
f"""\
Metadata-Version: {DEFAULT_METADATA_VERSION}
Name: {kwargs["project_name"]}
Version: 0.0.1
License-File: LICENSE.txt
""",
),
))
record_file = File(Path(metadata_directory, "RECORD"), "")
update_record_file_contents(record_file, files)
files.append(record_file)
return files
================================================
FILE: tests/helpers/templates/wheel/standard_default_shared_data.py
================================================
from hatch.template import File
from hatch.utils.fs import Path
from hatchling.__about__ import __version__
from hatchling.metadata.spec import DEFAULT_METADATA_VERSION
from ..new.feature_no_src_layout import get_files as get_template_files
from .utils import update_record_file_contents
def get_files(**kwargs):
metadata_directory = kwargs.get("metadata_directory", "")
shared_data_directory = kwargs.get("shared_data_directory", "")
files = []
for f in get_template_files(**kwargs):
if str(f.path) == "LICENSE.txt":
files.append(File(Path(metadata_directory, "licenses", f.path), f.contents))
if f.path.parts[0] != kwargs["package_name"]:
continue
files.append(f)
files.extend((
File(Path(shared_data_directory, "data", "foo.txt"), ""),
File(Path(shared_data_directory, "data", "nested", "bar.txt"), ""),
File(
Path(metadata_directory, "WHEEL"),
f"""\
Wheel-Version: 1.0
Generator: hatchling {__version__}
Root-Is-Purelib: true
Tag: py3-none-any
""",
),
File(
Path(metadata_directory, "METADATA"),
f"""\
Metadata-Version: {DEFAULT_METADATA_VERSION}
Name: {kwargs["project_name"]}
Version: 0.0.1
License-File: LICENSE.txt
Requires-Python: >3
""",
),
))
record_file = File(Path(metadata_directory, "RECORD"), "")
update_record_file_contents(record_file, files)
files.append(record_file)
return files
================================================
FILE: tests/helpers/templates/wheel/standard_default_shared_scripts.py
================================================
from hatch.template import File
from hatch.utils.fs import Path
from hatchling.__about__ import __version__
from hatchling.metadata.spec import DEFAULT_METADATA_VERSION
from ..new.feature_no_src_layout import get_files as get_template_files
from .utils import update_record_file_contents
def get_files(**kwargs):
metadata_directory = kwargs.get("metadata_directory", "")
shared_data_directory = kwargs.get("shared_data_directory", "")
binary_contents = kwargs.get("binary_contents", b"")
files = []
for f in get_template_files(**kwargs):
if str(f.path) == "LICENSE.txt":
files.append(File(Path(metadata_directory, "licenses", f.path), f.contents))
if f.path.parts[0] != kwargs["package_name"]:
continue
files.append(f)
files.extend((
File(Path(shared_data_directory, "scripts", "binary"), binary_contents),
File(
Path(shared_data_directory, "scripts", "other_script.sh"),
"""\
#!/bin/sh arg1 arg2
echo "Hello, World!"
""",
),
File(
Path(shared_data_directory, "scripts", "python_script.sh"),
"""\
#!python arg1 arg2
print("Hello, World!")
""",
),
File(
Path(shared_data_directory, "scripts", "pythonw_script.sh"),
"""\
#!python arg1 arg2
print("Hello, World!")
""",
),
File(
Path(shared_data_directory, "scripts", "pypy_script.sh"),
"""\
#!python
print("Hello, World!")
""",
),
File(
Path(shared_data_directory, "scripts", "pypyw_script.sh"),
"""\
#!python arg1 arg2
print("Hello, World!")
""",
),
File(
Path(metadata_directory, "WHEEL"),
f"""\
Wheel-Version: 1.0
Generator: hatchling {__version__}
Root-Is-Purelib: true
Tag: py3-none-any
""",
),
File(
Path(metadata_directory, "METADATA"),
f"""\
Metadata-Version: {DEFAULT_METADATA_VERSION}
Name: {kwargs["project_name"]}
Version: 0.0.1
License-File: LICENSE.txt
Requires-Python: >3
""",
),
))
record_file = File(Path(metadata_directory, "RECORD"), "")
update_record_file_contents(record_file, files)
files.append(record_file)
return files
================================================
FILE: tests/helpers/templates/wheel/standard_default_single_module.py
================================================
from hatch.template import File
from hatch.utils.fs import Path
from hatchling.__about__ import __version__
from hatchling.metadata.spec import DEFAULT_METADATA_VERSION
from ..new.feature_no_src_layout import get_files as get_template_files
from .utils import update_record_file_contents
def get_files(**kwargs):
metadata_directory = kwargs.get("metadata_directory", "")
files = [
File(Path(metadata_directory, "licenses", f.path), f.contents)
for f in get_template_files(**kwargs)
if str(f.path) == "LICENSE.txt"
]
files.extend((
File(Path("my_app.py"), ""),
File(
Path(metadata_directory, "WHEEL"),
f"""\
Wheel-Version: 1.0
Generator: hatchling {__version__}
Root-Is-Purelib: true
Tag: py2-none-any
Tag: py3-none-any
""",
),
File(
Path(metadata_directory, "METADATA"),
f"""\
Metadata-Version: {DEFAULT_METADATA_VERSION}
Name: {kwargs["project_name"]}
Version: 0.0.1
License-File: LICENSE.txt
""",
),
))
record_file = File(Path(metadata_directory, "RECORD"), "")
update_record_file_contents(record_file, files)
files.append(record_file)
return files
================================================
FILE: tests/helpers/templates/wheel/standard_default_symlink.py
================================================
from hatch.template import File
from hatch.utils.fs import Path
from hatchling.__about__ import __version__
from hatchling.metadata.spec import DEFAULT_METADATA_VERSION
from ..new.feature_no_src_layout import get_files as get_template_files
from .utils import update_record_file_contents
def get_files(**kwargs):
metadata_directory = kwargs.get("metadata_directory", "")
files = []
for f in get_template_files(**kwargs):
if str(f.path) == "LICENSE.txt":
files.append(File(Path(metadata_directory, "licenses", f.path), f.contents))
if f.path.parts[0] != kwargs["package_name"]:
continue
files.append(f)
files.extend((
File(Path(kwargs["package_name"], "lib.so"), "data"),
File(
Path(metadata_directory, "WHEEL"),
f"""\
Wheel-Version: 1.0
Generator: hatchling {__version__}
Root-Is-Purelib: false
Tag: {kwargs.get("tag", "")}
""",
),
File(
Path(metadata_directory, "METADATA"),
f"""\
Metadata-Version: {DEFAULT_METADATA_VERSION}
Name: {kwargs["project_name"]}
Version: 0.0.1
License-File: LICENSE.txt
Requires-Python: >3
""",
),
))
record_file = File(Path(metadata_directory, "RECORD"), "")
update_record_file_contents(record_file, files)
files.append(record_file)
return files
================================================
FILE: tests/helpers/templates/wheel/standard_editable_exact.py
================================================
from hatch.template import File
from hatch.utils.fs import Path
from hatchling.__about__ import __version__
from hatchling.metadata.spec import DEFAULT_METADATA_VERSION
from ..new.feature_no_src_layout import get_files as get_template_files
from .utils import update_record_file_contents
def get_files(**kwargs):
metadata_directory = kwargs.get("metadata_directory", "")
package_root = kwargs.get("package_root", "")
files = [
File(Path(metadata_directory, "licenses", f.path), f.contents)
for f in get_template_files(**kwargs)
if str(f.path) == "LICENSE.txt"
]
pth_file_name = f"_{kwargs['package_name']}.pth"
loader_file_name = f"_editable_impl_{kwargs['package_name']}.py"
files.extend((
File(Path(pth_file_name), f"import _editable_impl_{kwargs['package_name']}"),
File(
Path(loader_file_name),
f"""\
from editables.redirector import RedirectingFinder as F
F.install()
F.map_module({kwargs["package_name"]!r}, {package_root!r})""",
),
File(
Path(metadata_directory, "WHEEL"),
f"""\
Wheel-Version: 1.0
Generator: hatchling {__version__}
Root-Is-Purelib: true
Tag: py2-none-any
Tag: py3-none-any
""",
),
File(
Path(metadata_directory, "METADATA"),
f"""\
Metadata-Version: {DEFAULT_METADATA_VERSION}
Name: {kwargs["project_name"]}
Version: 0.0.1
License-File: LICENSE.txt
Requires-Dist: editables~=0.3
""",
),
))
record_file = File(Path(metadata_directory, "RECORD"), "")
update_record_file_contents(record_file, files, generated_files={pth_file_name, loader_file_name})
files.append(record_file)
return files
================================================
FILE: tests/helpers/templates/wheel/standard_editable_exact_extra_dependencies.py
================================================
from hatch.template import File
from hatch.utils.fs import Path
from hatchling.__about__ import __version__
from hatchling.metadata.spec import DEFAULT_METADATA_VERSION
from ..new.feature_no_src_layout import get_files as get_template_files
from .utils import update_record_file_contents
def get_files(**kwargs):
metadata_directory = kwargs.get("metadata_directory", "")
package_root = kwargs.get("package_root", "")
files = [
File(Path(metadata_directory, "licenses", f.path), f.contents)
for f in get_template_files(**kwargs)
if str(f.path) == "LICENSE.txt"
]
pth_file_name = f"_{kwargs['package_name']}.pth"
loader_file_name = f"_editable_impl_{kwargs['package_name']}.py"
files.extend((
File(Path(pth_file_name), f"import _editable_impl_{kwargs['package_name']}"),
File(
Path(loader_file_name),
f"""\
from editables.redirector import RedirectingFinder as F
F.install()
F.map_module({kwargs["package_name"]!r}, {package_root!r})""",
),
File(
Path(metadata_directory, "WHEEL"),
f"""\
Wheel-Version: 1.0
Generator: hatchling {__version__}
Root-Is-Purelib: true
Tag: py2-none-any
Tag: py3-none-any
""",
),
File(
Path(metadata_directory, "METADATA"),
f"""\
Metadata-Version: {DEFAULT_METADATA_VERSION}
Name: {kwargs["project_name"]}
Version: 0.0.1
License-File: LICENSE.txt
Requires-Dist: binary
Requires-Dist: editables~=0.3
""",
),
))
record_file = File(Path(metadata_directory, "RECORD"), "")
update_record_file_contents(record_file, files, generated_files={pth_file_name, loader_file_name})
files.append(record_file)
return files
================================================
FILE: tests/helpers/templates/wheel/standard_editable_exact_force_include.py
================================================
from hatch.template import File
from hatch.utils.fs import Path
from hatchling.__about__ import __version__
from hatchling.metadata.spec import DEFAULT_METADATA_VERSION
from ..new.feature_no_src_layout import get_files as get_template_files
from .utils import update_record_file_contents
def get_files(**kwargs):
metadata_directory = kwargs.get("metadata_directory", "")
package_root = kwargs.get("package_root", "")
files = []
for f in get_template_files(**kwargs):
if str(f.path) == "LICENSE.txt":
files.append(File(Path(metadata_directory, "licenses", f.path), f.contents))
elif f.path.parts[-1] == "__about__.py":
files.append(File(Path("zfoo.py"), f.contents))
pth_file_name = f"_{kwargs['package_name']}.pth"
loader_file_name = f"_editable_impl_{kwargs['package_name']}.py"
files.extend((
File(Path(pth_file_name), f"import _editable_impl_{kwargs['package_name']}"),
File(
Path(loader_file_name),
f"""\
from editables.redirector import RedirectingFinder as F
F.install()
F.map_module({kwargs["package_name"]!r}, {package_root!r})""",
),
File(
Path(metadata_directory, "WHEEL"),
f"""\
Wheel-Version: 1.0
Generator: hatchling {__version__}
Root-Is-Purelib: true
Tag: py2-none-any
Tag: py3-none-any
""",
),
File(
Path(metadata_directory, "METADATA"),
f"""\
Metadata-Version: {DEFAULT_METADATA_VERSION}
Name: {kwargs["project_name"]}
Version: 0.0.1
License-File: LICENSE.txt
Requires-Dist: editables~=0.3
""",
),
))
record_file = File(Path(metadata_directory, "RECORD"), "")
update_record_file_contents(record_file, files, generated_files={pth_file_name, loader_file_name})
files.append(record_file)
return files
================================================
FILE: tests/helpers/templates/wheel/standard_editable_pth.py
================================================
from hatch.template import File
from hatch.utils.fs import Path
from hatchling.__about__ import __version__
from hatchling.metadata.spec import DEFAULT_METADATA_VERSION
from ..new.default import get_files as get_template_files
from .utils import update_record_file_contents
def get_files(**kwargs):
metadata_directory = kwargs.get("metadata_directory", "")
package_paths = kwargs.get("package_paths", [])
files = [
File(Path(metadata_directory, "licenses", f.path), f.contents)
for f in get_template_files(**kwargs)
if str(f.path) == "LICENSE.txt"
]
pth_file_name = f"_{kwargs['package_name']}.pth"
files.extend((
File(Path(pth_file_name), "\n".join(package_paths)),
File(
Path(metadata_directory, "WHEEL"),
f"""\
Wheel-Version: 1.0
Generator: hatchling {__version__}
Root-Is-Purelib: true
Tag: py2-none-any
Tag: py3-none-any
""",
),
File(
Path(metadata_directory, "METADATA"),
f"""\
Metadata-Version: {DEFAULT_METADATA_VERSION}
Name: {kwargs["project_name"]}
Version: 0.0.1
License-File: LICENSE.txt
""",
),
))
record_file = File(Path(metadata_directory, "RECORD"), "")
update_record_file_contents(record_file, files, generated_files={pth_file_name})
files.append(record_file)
return files
================================================
FILE: tests/helpers/templates/wheel/standard_editable_pth_extra_dependencies.py
================================================
from hatch.template import File
from hatch.utils.fs import Path
from hatchling.__about__ import __version__
from hatchling.metadata.spec import DEFAULT_METADATA_VERSION
from ..new.default import get_files as get_template_files
from .utils import update_record_file_contents
def get_files(**kwargs):
metadata_directory = kwargs.get("metadata_directory", "")
package_paths = kwargs.get("package_paths", [])
files = [
File(Path(metadata_directory, "licenses", f.path), f.contents)
for f in get_template_files(**kwargs)
if str(f.path) == "LICENSE.txt"
]
pth_file_name = f"_{kwargs['package_name']}.pth"
files.extend((
File(Path(pth_file_name), "\n".join(package_paths)),
File(
Path(metadata_directory, "WHEEL"),
f"""\
Wheel-Version: 1.0
Generator: hatchling {__version__}
Root-Is-Purelib: true
Tag: py2-none-any
Tag: py3-none-any
""",
),
File(
Path(metadata_directory, "METADATA"),
f"""\
Metadata-Version: {DEFAULT_METADATA_VERSION}
Name: {kwargs["project_name"]}
Version: 0.0.1
License-File: LICENSE.txt
Requires-Dist: binary
""",
),
))
record_file = File(Path(metadata_directory, "RECORD"), "")
update_record_file_contents(record_file, files, generated_files={pth_file_name})
files.append(record_file)
return files
================================================
FILE: tests/helpers/templates/wheel/standard_editable_pth_force_include.py
================================================
from hatch.template import File
from hatch.utils.fs import Path
from hatchling.__about__ import __version__
from hatchling.metadata.spec import DEFAULT_METADATA_VERSION
from ..new.default import get_files as get_template_files
from .utils import update_record_file_contents
def get_files(**kwargs):
metadata_directory = kwargs.get("metadata_directory", "")
package_paths = kwargs.get("package_paths", [])
files = []
for f in get_template_files(**kwargs):
if str(f.path) == "LICENSE.txt":
files.append(File(Path(metadata_directory, "licenses", f.path), f.contents))
elif f.path.parts[-1] == "__about__.py":
files.append(File(Path("zfoo.py"), f.contents))
pth_file_name = f"_{kwargs['package_name']}.pth"
files.extend((
File(Path(pth_file_name), "\n".join(package_paths)),
File(
Path(metadata_directory, "WHEEL"),
f"""\
Wheel-Version: 1.0
Generator: hatchling {__version__}
Root-Is-Purelib: true
Tag: py2-none-any
Tag: py3-none-any
""",
),
File(
Path(metadata_directory, "METADATA"),
f"""\
Metadata-Version: {DEFAULT_METADATA_VERSION}
Name: {kwargs["project_name"]}
Version: 0.0.1
License-File: LICENSE.txt
""",
),
))
record_file = File(Path(metadata_directory, "RECORD"), "")
update_record_file_contents(record_file, files, generated_files={pth_file_name})
files.append(record_file)
return files
================================================
FILE: tests/helpers/templates/wheel/standard_entry_points.py
================================================
from hatch.template import File
from hatch.utils.fs import Path
from hatchling.__about__ import __version__
from hatchling.metadata.spec import DEFAULT_METADATA_VERSION
from ..new.feature_no_src_layout import get_files as get_template_files
from .utils import update_record_file_contents
def get_files(**kwargs):
metadata_directory = kwargs.get("metadata_directory", "")
files = []
for f in get_template_files(**kwargs):
if str(f.path) == "LICENSE.txt":
files.append(File(Path(metadata_directory, "licenses", f.path), f.contents))
if f.path.parts[0] != kwargs["package_name"]:
continue
files.append(f)
files.extend((
File(
Path(metadata_directory, "entry_points.txt"),
"""\
[console_scripts]
bar = pkg:foo
foo = pkg:bar
""",
),
File(
Path(metadata_directory, "WHEEL"),
f"""\
Wheel-Version: 1.0
Generator: hatchling {__version__}
Root-Is-Purelib: true
Tag: py2-none-any
Tag: py3-none-any
""",
),
File(
Path(metadata_directory, "METADATA"),
f"""\
Metadata-Version: {DEFAULT_METADATA_VERSION}
Name: {kwargs["project_name"]}
Version: 0.0.1
License-File: LICENSE.txt
""",
),
))
record_file = File(Path(metadata_directory, "RECORD"), "")
update_record_file_contents(record_file, files)
files.append(record_file)
return files
================================================
FILE: tests/helpers/templates/wheel/standard_no_strict_naming.py
================================================
from hatch.template import File
from hatch.utils.fs import Path
from hatchling.__about__ import __version__
from hatchling.metadata.spec import DEFAULT_METADATA_VERSION
from ..new.feature_no_src_layout import get_files as get_template_files
from .utils import update_record_file_contents
def get_files(**kwargs):
metadata_directory = kwargs.get("metadata_directory", "")
files = []
for f in get_template_files(**kwargs):
if str(f.path) == "LICENSE.txt":
files.append(File(Path(metadata_directory, "licenses", f.path), f.contents))
if f.path.parts[0] != kwargs["package_name"]:
continue
files.append(f)
files.extend((
File(
Path(metadata_directory, "WHEEL"),
f"""\
Wheel-Version: 1.0
Generator: hatchling {__version__}
Root-Is-Purelib: true
Tag: py2-none-any
Tag: py3-none-any
""",
),
File(
Path(metadata_directory, "METADATA"),
f"""\
Metadata-Version: {DEFAULT_METADATA_VERSION}
Name: {kwargs["project_name"]}
Version: 0.0.1
License-File: LICENSE.txt
""",
),
))
record_file = File(Path(metadata_directory, "RECORD"), "")
update_record_file_contents(record_file, files)
files.append(record_file)
return files
================================================
FILE: tests/helpers/templates/wheel/standard_only_packages_artifact_override.py
================================================
from hatch.template import File
from hatch.utils.fs import Path
from hatchling.__about__ import __version__
from hatchling.metadata.spec import DEFAULT_METADATA_VERSION
from ..new.feature_no_src_layout import get_files as get_template_files
from .utils import update_record_file_contents
def get_files(**kwargs):
metadata_directory = kwargs.get("metadata_directory", "")
files = []
for f in get_template_files(**kwargs):
if str(f.path) == "LICENSE.txt":
files.append(File(Path(metadata_directory, "licenses", f.path), f.contents))
if f.path.parts[0] not in {kwargs["package_name"], "tests"}:
continue
if f.path == Path("tests", "__init__.py"):
f.path = Path("tests", "foo.py")
files.append(f)
files.extend((
File(
Path(metadata_directory, "WHEEL"),
f"""\
Wheel-Version: 1.0
Generator: hatchling {__version__}
Root-Is-Purelib: true
Tag: py2-none-any
Tag: py3-none-any
""",
),
File(
Path(metadata_directory, "METADATA"),
f"""\
Metadata-Version: {DEFAULT_METADATA_VERSION}
Name: {kwargs["project_name"]}
Version: 0.0.1
License-File: LICENSE.txt
""",
),
))
record_file = File(Path(metadata_directory, "RECORD"), "")
update_record_file_contents(record_file, files)
files.append(record_file)
return files
================================================
FILE: tests/helpers/templates/wheel/standard_tests.py
================================================
from hatch.template import File
from hatch.utils.fs import Path
from hatchling.__about__ import __version__
from hatchling.metadata.spec import DEFAULT_METADATA_VERSION
from ..new.feature_no_src_layout import get_files as get_template_files
from .utils import update_record_file_contents
def get_files(**kwargs):
metadata_directory = kwargs.get("metadata_directory", "")
files = []
for f in get_template_files(**kwargs):
if str(f.path) == "LICENSE.txt":
files.append(File(Path(metadata_directory, "licenses", f.path), f.contents))
if f.path.parts[0] not in {kwargs["package_name"], "tests"}:
continue
files.append(f)
files.extend((
File(
Path(metadata_directory, "WHEEL"),
f"""\
Wheel-Version: 1.0
Generator: hatchling {__version__}
Root-Is-Purelib: true
Tag: py2-none-any
Tag: py3-none-any
""",
),
File(
Path(metadata_directory, "METADATA"),
f"""\
Metadata-Version: {DEFAULT_METADATA_VERSION}
Name: {kwargs["project_name"]}
Version: 0.0.1
License-File: LICENSE.txt
""",
),
))
record_file = File(Path(metadata_directory, "RECORD"), "")
update_record_file_contents(record_file, files)
files.append(record_file)
return files
================================================
FILE: tests/helpers/templates/wheel/utils.py
================================================
import hashlib
import os
import tempfile
from hatchling.builders.utils import format_file_hash, normalize_artifact_permissions
def update_record_file_contents(record_file, files, generated_files=()):
for template_file in sorted(
files,
key=lambda f: (
f.path.parts[0].endswith(".dist-info"),
f.path.parts[0].endswith(".dist-info") and f.path.parts[1] == "extra_metadata",
f.path.parts[0].startswith("z"),
len(f.path.parts),
f.path.parts,
),
):
if isinstance(template_file.contents, bytes):
is_binary = True
raw_contents = template_file.contents
else:
is_binary = False
raw_contents = template_file.contents.encode("utf-8")
template_file_path = str(template_file.path)
if (
not is_binary
and os.linesep != "\n"
and (
"LICENSE" in template_file_path
or (
not template_file.path.parts[0].endswith(".dist-info")
and all(f not in template_file_path for f in generated_files)
)
)
):
raw_contents = raw_contents.replace(b"\n", b"\r\n")
hash_obj = hashlib.sha256()
hash_obj.update(raw_contents)
hash_digest = format_file_hash(hash_obj.digest())
record_file.contents += f"{template_file.path.as_posix()},sha256={hash_digest},{len(raw_contents)}\n"
record_file.contents += f"{record_file.path.as_posix()},,\n"
def test_normalize_artifact_permissions():
"""
assert that this func does what we expect on a tmpfile that that starts at 600
"""
_, path = tempfile.mkstemp()
file_stat = os.stat(path)
assert file_stat.st_mode == 0o100600
normalize_artifact_permissions(path)
file_stat = os.stat(path)
assert file_stat.st_mode == 0o100644
================================================
FILE: tests/index/__init__.py
================================================
================================================
FILE: tests/index/server/devpi/Dockerfile
================================================
FROM python:3.11-alpine
RUN apk add --update build-base && \
pip install -U devpi-server devpi-client devpi-web
EXPOSE 3141
COPY entrypoint.sh /
ENTRYPOINT ["/bin/ash", "/entrypoint.sh"]
================================================
FILE: tests/index/server/devpi/entrypoint.sh
================================================
#!/bin/ash
# http://redsymbol.net/articles/unofficial-bash-strict-mode/
IFS=$'\n\t'
set -euo pipefail
echo "==:> Initializing server"
devpi-init --no-root-pypi
echo "==:> Starting server"
devpi-server --host 0.0.0.0 --port 3141 &
echo "==:> Waiting on server"
for i in $(seq 1 30); do
if devpi use http://localhost:3141 2>/dev/null; then
break
fi
if [ "$i" -eq 30 ]; then
echo "Timed out waiting for devpi-server"
exit 1
fi
sleep 1
done
echo "==:> Setting up index"
devpi use http://localhost:3141
devpi user -c $DEVPI_USERNAME password=$DEVPI_PASSWORD
devpi login $DEVPI_USERNAME --password=$DEVPI_PASSWORD
devpi index -c $DEVPI_INDEX_NAME volatile=True mirror_whitelist="*"
devpi use $DEVPI_USERNAME/$DEVPI_INDEX_NAME
devpi logoff
echo "==:> Serving index $DEVPI_USERNAME/$DEVPI_INDEX_NAME"
sleep infinity
================================================
FILE: tests/index/server/docker-compose.yaml
================================================
services:
devpi:
container_name: hatch-devpi
build:
context: devpi
ports:
- "3141:3141"
environment:
- DEVPI_INDEX_NAME
- DEVPI_USERNAME
- DEVPI_PASSWORD
healthcheck:
test: [ "CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:3141')" ]
interval: 2s
timeout: 5s
retries: 30
start_period: 10s
nginx:
container_name: hatch-nginx
image: nginx:alpine
ports:
- "8080:80"
- "8443:443"
volumes:
- ./nginx:/etc/nginx
depends_on:
devpi:
condition: service_healthy
================================================
FILE: tests/index/server/nginx/nginx.conf
================================================
worker_processes 1;
events {
worker_connections 1024;
}
http {
# Redirect HTTP to HTTPS
server {
listen 80;
listen [::]:80;
server_name _;
return 301 https://$host$request_uri;
}
server {
listen 443 ssl;
server_name localhost;
gzip on;
gzip_min_length 2000;
gzip_proxied any;
proxy_read_timeout 60s;
client_max_body_size 64M;
ssl_certificate server.pem;
ssl_certificate_key server.key;
ssl_session_cache builtin:1000 shared:SSL:10m;
ssl_protocols TLSv1 TLSv1.1 TLSv1.2;
ssl_ciphers HIGH:!aNULL:!eNULL:!EXPORT:!CAMELLIA:!DES:!MD5:!PSK:!RC4;
ssl_prefer_server_ciphers on;
location / {
error_page 418 = @proxy_to_app;
return 418;
}
location @proxy_to_app {
proxy_pass http://devpi:3141;
proxy_pass_header Authorization;
proxy_redirect off;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Outside-URL $scheme://$host:$server_port;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Host $server_name;
}
}
}
================================================
FILE: tests/index/test_core.py
================================================
import platform
import sys
import httpx
import pytest
from hatch._version import __version__
from hatch.index.core import PackageIndex
class TestRepo:
def test_normalization(self):
index = PackageIndex("Https://Foo.Internal/z/../a/b/")
assert index.repo == "https://foo.internal/a/b/"
class TestURLs:
@pytest.mark.parametrize(
("repo_url", "expected_url"),
[
pytest.param("https://upload.pypi.org/legacy/", "https://pypi.org/simple/", id="PyPI main"),
pytest.param("https://test.pypi.org/legacy/", "https://test.pypi.org/simple/", id="PyPI test"),
pytest.param("https://foo.internal/a/b/", "https://foo.internal/a/b/%2Bsimple/", id="default"),
],
)
def test_simple(self, repo_url, expected_url):
index = PackageIndex(repo_url)
assert str(index.urls.simple) == expected_url
@pytest.mark.parametrize(
("repo_url", "expected_url"),
[
pytest.param("https://upload.pypi.org/legacy/", "https://pypi.org/project/", id="PyPI main"),
pytest.param("https://test.pypi.org/legacy/", "https://test.pypi.org/project/", id="PyPI test"),
pytest.param("https://foo.internal/a/b/", "https://foo.internal/a/b/", id="default"),
],
)
def test_project(self, repo_url, expected_url):
index = PackageIndex(repo_url)
assert str(index.urls.project) == expected_url
class TestTLS:
def test_default(self, mocker):
mock = mocker.patch("httpx._transports.default.create_ssl_context")
index = PackageIndex("https://foo.internal/a/b/")
_ = index.client
mock.assert_called_once_with(verify=True, cert=None, trust_env=True)
def test_ca_cert(self, mocker):
mock = mocker.patch("httpx._transports.default.create_ssl_context")
index = PackageIndex("https://foo.internal/a/b/", ca_cert="foo")
_ = index.client
mock.assert_called_once_with(verify="foo", cert=None, trust_env=True)
def test_client_cert(self, mocker):
mock = mocker.patch("httpx._transports.default.create_ssl_context")
index = PackageIndex("https://foo.internal/a/b/", client_cert="foo")
_ = index.client
mock.assert_called_once_with(verify=True, cert="foo", trust_env=True)
def test_client_cert_with_key(self, mocker):
mock = mocker.patch("httpx._transports.default.create_ssl_context")
index = PackageIndex("https://foo.internal/a/b/", client_cert="foo", client_key="bar")
_ = index.client
mock.assert_called_once_with(verify=True, cert=("foo", "bar"), trust_env=True)
class TestUserAgent:
def test_user_agent_header_format(self):
index = PackageIndex("https://foo.internal/a/b/")
client = index.client
user_agent = client.headers["User-Agent"]
expected = (
f"Hatch/{__version__} {sys.implementation.name}/{platform.python_version()} HTTPX/{httpx.__version__}"
)
assert user_agent == expected
================================================
FILE: tests/project/__init__.py
================================================
================================================
FILE: tests/project/test_config.py
================================================
from itertools import product
import pytest
from hatch.plugin.constants import DEFAULT_CUSTOM_SCRIPT
from hatch.plugin.manager import PluginManager
from hatch.project.config import ProjectConfig
from hatch.project.constants import DEFAULT_BUILD_DIRECTORY, BuildEnvVars
from hatch.project.env import RESERVED_OPTIONS
from hatch.utils.structures import EnvVars
ARRAY_OPTIONS = [o for o, t in RESERVED_OPTIONS.items() if t is list]
BOOLEAN_OPTIONS = [o for o, t in RESERVED_OPTIONS.items() if t is bool]
MAPPING_OPTIONS = [o for o, t in RESERVED_OPTIONS.items() if t is dict and o != "workspace"]
STRING_OPTIONS = [o for o, t in RESERVED_OPTIONS.items() if t is str and o != "matrix-name-format"]
WORKSPACE_OPTIONS = ["workspace"] # Workspace has nested structure, tested separately
def construct_matrix_data(env_name, config, overrides=None):
config = dict(config[env_name])
config.pop("overrides", None)
matrices = config.pop("matrix")
final_matrix_name_format = config.pop("matrix-name-format", "{value}")
# [{'version': ['9000']}, {'feature': ['bar']}]
envs = {}
for matrix_data in matrices:
matrix = dict(matrix_data)
variables = {}
python_selected = False
for variable in ("py", "python"):
if variable in matrix:
python_selected = True
variables[variable] = matrix.pop(variable)
break
variables.update(matrix)
for result in product(*variables.values()):
variable_values = dict(zip(variables, result, strict=False))
env_name_parts = []
for j, (variable, value) in enumerate(variable_values.items()):
if j == 0 and python_selected:
env_name_parts.append(value if value.startswith("py") else f"py{value}")
else:
env_name_parts.append(final_matrix_name_format.format(variable=variable, value=value))
new_env_name = "-".join(env_name_parts)
if env_name != "default":
new_env_name = f"{env_name}.{new_env_name}"
envs[new_env_name] = variable_values
if "py" in variable_values:
envs[new_env_name] = {"python": variable_values.pop("py"), **variable_values}
config.update(overrides or {})
config.setdefault("type", "virtual")
return {"config": config, "envs": envs}
class TestEnv:
def test_not_table(self, isolation):
with pytest.raises(TypeError, match="Field `tool.hatch.env` must be a table"):
_ = ProjectConfig(isolation, {"env": 9000}).env
def test_default(self, isolation):
project_config = ProjectConfig(isolation, {})
assert project_config.env == project_config.env == {}
class TestEnvRequires:
def test_not_array(self, isolation):
with pytest.raises(TypeError, match="Field `tool.hatch.env.requires` must be an array"):
_ = ProjectConfig(isolation, {"env": {"requires": 9000}}).env_requires
def test_requirement_not_string(self, isolation):
with pytest.raises(TypeError, match="Requirement #1 in `tool.hatch.env.requires` must be a string"):
_ = ProjectConfig(isolation, {"env": {"requires": [9000]}}).env_requires
def test_requirement_invalid(self, isolation):
with pytest.raises(ValueError, match="Requirement #1 in `tool.hatch.env.requires` is invalid: .+"):
_ = ProjectConfig(isolation, {"env": {"requires": ["foo^1"]}}).env_requires
def test_default(self, isolation):
project_config = ProjectConfig(isolation, {})
assert project_config.env_requires_complex == project_config.env_requires_complex == []
assert project_config.env_requires == project_config.env_requires == []
def test_defined(self, isolation):
project_config = ProjectConfig(isolation, {"env": {"requires": ["foo", "bar", "baz"]}})
assert project_config.env_requires == ["foo", "bar", "baz"]
class TestEnvCollectors:
def test_not_table(self, isolation):
with pytest.raises(TypeError, match="Field `tool.hatch.env.collectors` must be a table"):
_ = ProjectConfig(isolation, {"env": {"collectors": 9000}}).env_collectors
def test_collector_not_table(self, isolation):
with pytest.raises(TypeError, match="Field `tool.hatch.env.collectors.foo` must be a table"):
_ = ProjectConfig(isolation, {"env": {"collectors": {"foo": 9000}}}).env_collectors
def test_default(self, isolation):
project_config = ProjectConfig(isolation, {})
assert project_config.env_collectors == project_config.env_collectors == {"default": {}}
def test_defined(self, isolation):
project_config = ProjectConfig(isolation, {"env": {"collectors": {"foo": {"bar": {"baz": 9000}}}}})
assert project_config.env_collectors == {"default": {}, "foo": {"bar": {"baz": 9000}}}
assert list(project_config.env_collectors) == ["default", "foo"]
class TestEnvs:
def test_not_table(self, isolation):
with pytest.raises(TypeError, match="Field `tool.hatch.envs` must be a table"):
_ = ProjectConfig(isolation, {"envs": 9000}, PluginManager()).envs
def test_config_not_table(self, isolation):
with pytest.raises(TypeError, match="Field `tool.hatch.envs.foo` must be a table"):
_ = ProjectConfig(isolation, {"envs": {"foo": 9000}}, PluginManager()).envs
def test_unknown_collector(self, isolation):
with pytest.raises(ValueError, match="Unknown environment collector: foo"):
_ = ProjectConfig(isolation, {"env": {"collectors": {"foo": {}}}}, PluginManager()).envs
def test_unknown_template(self, isolation):
with pytest.raises(
ValueError, match="Field `tool.hatch.envs.foo.template` refers to an unknown environment `bar`"
):
_ = ProjectConfig(isolation, {"envs": {"foo": {"template": "bar"}}}, PluginManager()).envs
def test_default_undefined(self, isolation):
project_config = ProjectConfig(isolation, {}, PluginManager())
assert project_config.envs == project_config.envs == {"default": {"type": "virtual"}}
assert project_config.matrices == project_config.matrices == {}
def test_default_partially_defined(self, isolation):
env_config = {"default": {"option": True}}
project_config = ProjectConfig(isolation, {"envs": env_config}, PluginManager())
assert project_config.envs == {"default": {"option": True, "type": "virtual"}}
def test_default_defined(self, isolation):
env_config = {"default": {"type": "foo"}}
project_config = ProjectConfig(isolation, {"envs": env_config}, PluginManager())
assert project_config.envs == {"default": {"type": "foo"}}
def test_basic(self, isolation):
env_config = {"foo": {"option": True}}
project_config = ProjectConfig(isolation, {"envs": env_config}, PluginManager())
assert project_config.envs == {"default": {"type": "virtual"}, "foo": {"option": True, "type": "virtual"}}
def test_basic_override(self, isolation):
env_config = {"foo": {"type": "baz"}}
project_config = ProjectConfig(isolation, {"envs": env_config}, PluginManager())
assert project_config.envs == {"default": {"type": "virtual"}, "foo": {"type": "baz"}}
def test_multiple_inheritance(self, isolation):
env_config = {
"foo": {"option1": "foo"},
"bar": {"template": "foo", "option2": "bar"},
"baz": {"template": "bar", "option3": "baz"},
}
project_config = ProjectConfig(isolation, {"envs": env_config}, PluginManager())
assert project_config.envs == {
"default": {"type": "virtual"},
"foo": {"type": "virtual", "option1": "foo"},
"bar": {"type": "virtual", "option1": "foo", "option2": "bar"},
"baz": {"type": "virtual", "option1": "foo", "option2": "bar", "option3": "baz"},
}
def test_circular_inheritance(self, isolation):
with pytest.raises(
ValueError, match="Circular inheritance detected for field `tool.hatch.envs.*.template`: foo -> bar -> foo"
):
_ = ProjectConfig(
isolation, {"envs": {"foo": {"template": "bar"}, "bar": {"template": "foo"}}}, PluginManager()
).envs
def test_scripts_inheritance(self, isolation):
env_config = {
"default": {"scripts": {"cmd1": "bar", "cmd2": "baz"}},
"foo": {"scripts": {"cmd1": "foo"}},
"bar": {"template": "foo", "scripts": {"cmd3": "bar"}},
"baz": {},
}
project_config = ProjectConfig(isolation, {"envs": env_config}, PluginManager())
assert project_config.envs == {
"default": {"type": "virtual", "scripts": {"cmd1": "bar", "cmd2": "baz"}},
"foo": {"type": "virtual", "scripts": {"cmd1": "foo", "cmd2": "baz"}},
"bar": {"type": "virtual", "scripts": {"cmd1": "foo", "cmd2": "baz", "cmd3": "bar"}},
"baz": {"type": "virtual", "scripts": {"cmd1": "bar", "cmd2": "baz"}},
}
def test_self_referential(self, isolation):
env_config = {"default": {"option1": "foo"}, "bar": {"template": "bar"}}
project_config = ProjectConfig(isolation, {"envs": env_config}, PluginManager())
assert project_config.envs == {
"default": {"type": "virtual", "option1": "foo"},
"bar": {"type": "virtual"},
}
def test_detached(self, isolation):
env_config = {"default": {"option1": "foo"}, "bar": {"detached": True}}
project_config = ProjectConfig(isolation, {"envs": env_config}, PluginManager())
assert project_config.envs == {
"default": {"type": "virtual", "option1": "foo"},
"bar": {"type": "virtual", "skip-install": True},
}
def test_matrices_not_array(self, isolation):
with pytest.raises(TypeError, match="Field `tool.hatch.envs.foo.matrix` must be an array"):
_ = ProjectConfig(isolation, {"envs": {"foo": {"matrix": 9000}}}, PluginManager()).envs
def test_matrix_not_table(self, isolation):
with pytest.raises(TypeError, match="Entry #1 in field `tool.hatch.envs.foo.matrix` must be a table"):
_ = ProjectConfig(isolation, {"envs": {"foo": {"matrix": [9000]}}}, PluginManager()).envs
def test_matrix_empty(self, isolation):
with pytest.raises(ValueError, match="Matrix #1 in field `tool.hatch.envs.foo.matrix` cannot be empty"):
_ = ProjectConfig(isolation, {"envs": {"foo": {"matrix": [{}]}}}, PluginManager()).envs
def test_matrix_variable_empty_string(self, isolation):
with pytest.raises(
ValueError, match="Variable #1 in matrix #1 in field `tool.hatch.envs.foo.matrix` cannot be an empty string"
):
_ = ProjectConfig(isolation, {"envs": {"foo": {"matrix": [{"": []}]}}}, PluginManager()).envs
def test_matrix_variable_not_array(self, isolation):
with pytest.raises(
TypeError, match="Variable `bar` in matrix #1 in field `tool.hatch.envs.foo.matrix` must be an array"
):
_ = ProjectConfig(isolation, {"envs": {"foo": {"matrix": [{"bar": 9000}]}}}, PluginManager()).envs
def test_matrix_variable_array_empty(self, isolation):
with pytest.raises(
ValueError, match="Variable `bar` in matrix #1 in field `tool.hatch.envs.foo.matrix` cannot be empty"
):
_ = ProjectConfig(isolation, {"envs": {"foo": {"matrix": [{"bar": []}]}}}, PluginManager()).envs
def test_matrix_variable_entry_not_string(self, isolation):
with pytest.raises(
TypeError,
match="Value #1 of variable `bar` in matrix #1 in field `tool.hatch.envs.foo.matrix` must be a string",
):
_ = ProjectConfig(isolation, {"envs": {"foo": {"matrix": [{"bar": [9000]}]}}}, PluginManager()).envs
def test_matrix_variable_entry_empty_string(self, isolation):
with pytest.raises(
ValueError,
match=(
"Value #1 of variable `bar` in matrix #1 in field `tool.hatch.envs.foo.matrix` "
"cannot be an empty string"
),
):
_ = ProjectConfig(isolation, {"envs": {"foo": {"matrix": [{"bar": [""]}]}}}, PluginManager()).envs
def test_matrix_variable_entry_duplicate(self, isolation):
with pytest.raises(
ValueError,
match="Value #2 of variable `bar` in matrix #1 in field `tool.hatch.envs.foo.matrix` is a duplicate",
):
_ = ProjectConfig(isolation, {"envs": {"foo": {"matrix": [{"bar": ["1", "1"]}]}}}, PluginManager()).envs
def test_matrix_multiple_python_variables(self, isolation):
with pytest.raises(
ValueError,
match="Matrix #1 in field `tool.hatch.envs.foo.matrix` cannot contain both `py` and `python` variables",
):
_ = ProjectConfig(
isolation,
{"envs": {"foo": {"matrix": [{"py": ["39", "310"], "python": ["39", "311"]}]}}},
PluginManager(),
).envs
def test_matrix_name_format_not_string(self, isolation):
with pytest.raises(TypeError, match="Field `tool.hatch.envs.foo.matrix-name-format` must be a string"):
_ = ProjectConfig(isolation, {"envs": {"foo": {"matrix-name-format": 9000}}}, PluginManager()).envs
def test_matrix_name_format_invalid(self, isolation):
with pytest.raises(
ValueError,
match="Field `tool.hatch.envs.foo.matrix-name-format` must contain at least the `{value}` placeholder",
):
_ = ProjectConfig(isolation, {"envs": {"foo": {"matrix-name-format": "bar"}}}, PluginManager()).envs
def test_overrides_not_table(self, isolation):
with pytest.raises(TypeError, match="Field `tool.hatch.envs.foo.overrides` must be a table"):
_ = ProjectConfig(isolation, {"envs": {"foo": {"overrides": 9000}}}, PluginManager()).envs
def test_overrides_platform_not_table(self, isolation):
with pytest.raises(TypeError, match="Field `tool.hatch.envs.foo.overrides.platform` must be a table"):
_ = ProjectConfig(isolation, {"envs": {"foo": {"overrides": {"platform": 9000}}}}, PluginManager()).envs
def test_overrides_env_not_table(self, isolation):
with pytest.raises(TypeError, match="Field `tool.hatch.envs.foo.overrides.env` must be a table"):
_ = ProjectConfig(isolation, {"envs": {"foo": {"overrides": {"env": 9000}}}}, PluginManager()).envs
def test_overrides_matrix_not_table(self, isolation):
with pytest.raises(TypeError, match="Field `tool.hatch.envs.foo.overrides.matrix` must be a table"):
_ = ProjectConfig(
isolation,
{"envs": {"foo": {"matrix": [{"version": ["9000"]}], "overrides": {"matrix": 9000}}}},
PluginManager(),
).envs
def test_overrides_name_not_table(self, isolation):
with pytest.raises(TypeError, match="Field `tool.hatch.envs.foo.overrides.name` must be a table"):
_ = ProjectConfig(
isolation,
{"envs": {"foo": {"matrix": [{"version": ["9000"]}], "overrides": {"name": 9000}}}},
PluginManager(),
).envs
def test_overrides_platform_entry_not_table(self, isolation):
with pytest.raises(TypeError, match="Field `tool.hatch.envs.foo.overrides.platform.bar` must be a table"):
_ = ProjectConfig(
isolation, {"envs": {"foo": {"overrides": {"platform": {"bar": 9000}}}}}, PluginManager()
).envs
def test_overrides_env_entry_not_table(self, isolation):
with pytest.raises(TypeError, match="Field `tool.hatch.envs.foo.overrides.env.bar` must be a table"):
_ = ProjectConfig(isolation, {"envs": {"foo": {"overrides": {"env": {"bar": 9000}}}}}, PluginManager()).envs
def test_overrides_matrix_entry_not_table(self, isolation):
with pytest.raises(TypeError, match="Field `tool.hatch.envs.foo.overrides.matrix.bar` must be a table"):
_ = ProjectConfig(
isolation,
{"envs": {"foo": {"matrix": [{"version": ["9000"]}], "overrides": {"matrix": {"bar": 9000}}}}},
PluginManager(),
).envs
def test_overrides_name_entry_not_table(self, isolation):
with pytest.raises(TypeError, match="Field `tool.hatch.envs.foo.overrides.name.bar` must be a table"):
_ = ProjectConfig(
isolation,
{"envs": {"foo": {"matrix": [{"version": ["9000"]}], "overrides": {"name": {"bar": 9000}}}}},
PluginManager(),
).envs
def test_matrix_simple_no_python(self, isolation):
env_config = {"foo": {"option": True, "matrix": [{"version": ["9000", "3.14"]}]}}
project_config = ProjectConfig(isolation, {"envs": env_config}, PluginManager())
expected_envs = {
"default": {"type": "virtual"},
"foo.9000": {"type": "virtual", "option": True},
"foo.3.14": {"type": "virtual", "option": True},
}
assert project_config.envs == expected_envs
assert project_config.matrices["foo"] == construct_matrix_data("foo", env_config)
def test_matrix_simple_no_python_custom_name_format(self, isolation):
env_config = {
"foo": {
"option": True,
"matrix-name-format": "{variable}_{value}",
"matrix": [{"version": ["9000", "3.14"]}],
}
}
project_config = ProjectConfig(isolation, {"envs": env_config}, PluginManager())
expected_envs = {
"default": {"type": "virtual"},
"foo.version_9000": {"type": "virtual", "option": True},
"foo.version_3.14": {"type": "virtual", "option": True},
}
assert project_config.envs == expected_envs
assert project_config.matrices["foo"] == construct_matrix_data("foo", env_config)
@pytest.mark.parametrize("indicator", ["py", "python"])
def test_matrix_simple_only_python(self, isolation, indicator):
env_config = {"foo": {"option": True, "matrix": [{indicator: ["39", "310"]}]}}
project_config = ProjectConfig(isolation, {"envs": env_config}, PluginManager())
expected_envs = {
"default": {"type": "virtual"},
"foo.py39": {"type": "virtual", "option": True, "python": "39"},
"foo.py310": {"type": "virtual", "option": True, "python": "310"},
}
assert project_config.envs == expected_envs
assert project_config.matrices["foo"] == construct_matrix_data("foo", env_config)
@pytest.mark.parametrize("indicator", ["py", "python"])
def test_matrix_simple(self, isolation, indicator):
env_config = {"foo": {"option": True, "matrix": [{"version": ["9000", "3.14"], indicator: ["39", "310"]}]}}
project_config = ProjectConfig(isolation, {"envs": env_config}, PluginManager())
expected_envs = {
"default": {"type": "virtual"},
"foo.py39-9000": {"type": "virtual", "option": True, "python": "39"},
"foo.py39-3.14": {"type": "virtual", "option": True, "python": "39"},
"foo.py310-9000": {"type": "virtual", "option": True, "python": "310"},
"foo.py310-3.14": {"type": "virtual", "option": True, "python": "310"},
}
assert project_config.envs == expected_envs
assert project_config.matrices["foo"] == construct_matrix_data("foo", env_config)
@pytest.mark.parametrize("indicator", ["py", "python"])
def test_matrix_simple_custom_name_format(self, isolation, indicator):
env_config = {
"foo": {
"option": True,
"matrix-name-format": "{variable}_{value}",
"matrix": [{"version": ["9000", "3.14"], indicator: ["39", "310"]}],
}
}
project_config = ProjectConfig(isolation, {"envs": env_config}, PluginManager())
expected_envs = {
"default": {"type": "virtual"},
"foo.py39-version_9000": {"type": "virtual", "option": True, "python": "39"},
"foo.py39-version_3.14": {"type": "virtual", "option": True, "python": "39"},
"foo.py310-version_9000": {"type": "virtual", "option": True, "python": "310"},
"foo.py310-version_3.14": {"type": "virtual", "option": True, "python": "310"},
}
assert project_config.envs == expected_envs
assert project_config.matrices["foo"] == construct_matrix_data("foo", env_config)
def test_matrix_multiple_non_python(self, isolation):
env_config = {
"foo": {
"option": True,
"matrix": [{"version": ["9000", "3.14"], "py": ["39", "310"], "foo": ["baz", "bar"]}],
}
}
project_config = ProjectConfig(isolation, {"envs": env_config}, PluginManager())
expected_envs = {
"default": {"type": "virtual"},
"foo.py39-9000-baz": {"type": "virtual", "option": True, "python": "39"},
"foo.py39-9000-bar": {"type": "virtual", "option": True, "python": "39"},
"foo.py39-3.14-baz": {"type": "virtual", "option": True, "python": "39"},
"foo.py39-3.14-bar": {"type": "virtual", "option": True, "python": "39"},
"foo.py310-9000-baz": {"type": "virtual", "option": True, "python": "310"},
"foo.py310-9000-bar": {"type": "virtual", "option": True, "python": "310"},
"foo.py310-3.14-baz": {"type": "virtual", "option": True, "python": "310"},
"foo.py310-3.14-bar": {"type": "virtual", "option": True, "python": "310"},
}
assert project_config.envs == expected_envs
assert project_config.matrices["foo"] == construct_matrix_data("foo", env_config)
def test_matrix_series(self, isolation):
env_config = {
"foo": {
"option": True,
"matrix": [
{"version": ["9000", "3.14"], "py": ["39", "310"], "foo": ["baz", "bar"]},
{"version": ["9000"], "py": ["310"], "baz": ["foo", "test"], "bar": ["foobar"]},
],
}
}
project_config = ProjectConfig(isolation, {"envs": env_config}, PluginManager())
expected_envs = {
"default": {"type": "virtual"},
"foo.py39-9000-baz": {"type": "virtual", "option": True, "python": "39"},
"foo.py39-9000-bar": {"type": "virtual", "option": True, "python": "39"},
"foo.py39-3.14-baz": {"type": "virtual", "option": True, "python": "39"},
"foo.py39-3.14-bar": {"type": "virtual", "option": True, "python": "39"},
"foo.py310-9000-baz": {"type": "virtual", "option": True, "python": "310"},
"foo.py310-9000-bar": {"type": "virtual", "option": True, "python": "310"},
"foo.py310-3.14-baz": {"type": "virtual", "option": True, "python": "310"},
"foo.py310-3.14-bar": {"type": "virtual", "option": True, "python": "310"},
"foo.py310-9000-foo-foobar": {"type": "virtual", "option": True, "python": "310"},
"foo.py310-9000-test-foobar": {"type": "virtual", "option": True, "python": "310"},
}
assert project_config.envs == expected_envs
assert project_config.matrices["foo"] == construct_matrix_data("foo", env_config)
def test_matrices_not_inherited(self, isolation):
env_config = {
"foo": {"option1": True, "matrix": [{"py": ["39"]}]},
"bar": {"template": "foo", "option2": False},
}
project_config = ProjectConfig(isolation, {"envs": env_config}, PluginManager())
expected_envs = {
"default": {"type": "virtual"},
"foo.py39": {"type": "virtual", "option1": True, "python": "39"},
"bar": {"type": "virtual", "option1": True, "option2": False},
}
assert project_config.envs == expected_envs
assert project_config.matrices["foo"] == construct_matrix_data("foo", env_config)
def test_matrix_default_naming(self, isolation):
env_config = {"default": {"option": True, "matrix": [{"version": ["9000", "3.14"]}]}}
project_config = ProjectConfig(isolation, {"envs": env_config}, PluginManager())
expected_envs = {
"9000": {"type": "virtual", "option": True},
"3.14": {"type": "virtual", "option": True},
}
assert project_config.envs == expected_envs
assert project_config.matrices["default"] == construct_matrix_data("default", env_config)
def test_matrix_pypy_naming(self, isolation):
env_config = {"foo": {"option": True, "matrix": [{"py": ["python3.9", "pypy3"]}]}}
project_config = ProjectConfig(isolation, {"envs": env_config}, PluginManager())
expected_envs = {
"default": {"type": "virtual"},
"foo.python3.9": {"type": "virtual", "option": True, "python": "python3.9"},
"foo.pypy3": {"type": "virtual", "option": True, "python": "pypy3"},
}
assert project_config.envs == expected_envs
assert project_config.matrices["foo"] == construct_matrix_data("foo", env_config)
@pytest.mark.parametrize("option", MAPPING_OPTIONS)
def test_overrides_matrix_mapping_invalid_type(self, isolation, option):
with pytest.raises(
TypeError,
match=f"Field `tool.hatch.envs.foo.overrides.matrix.version.{option}` must be a string or an array",
):
_ = ProjectConfig(
isolation,
{
"envs": {
"foo": {"matrix": [{"version": ["9000"]}], "overrides": {"matrix": {"version": {option: 9000}}}}
}
},
PluginManager(),
).envs
@pytest.mark.parametrize("option", MAPPING_OPTIONS)
def test_overrides_matrix_mapping_array_entry_invalid_type(self, isolation, option):
with pytest.raises(
TypeError,
match=(
f"Entry #1 in field `tool.hatch.envs.foo.overrides.matrix.version.{option}` "
f"must be a string or an inline table"
),
):
_ = ProjectConfig(
isolation,
{
"envs": {
"foo": {
"matrix": [{"version": ["9000"]}],
"overrides": {"matrix": {"version": {option: [9000]}}},
}
}
},
PluginManager(),
).envs
@pytest.mark.parametrize("option", MAPPING_OPTIONS)
def test_overrides_matrix_mapping_table_entry_no_key(self, isolation, option):
with pytest.raises(
ValueError,
match=(
f"Entry #1 in field `tool.hatch.envs.foo.overrides.matrix.version.{option}` "
f"must have an option named `key`"
),
):
_ = ProjectConfig(
isolation,
{
"envs": {
"foo": {"matrix": [{"version": ["9000"]}], "overrides": {"matrix": {"version": {option: [{}]}}}}
}
},
PluginManager(),
).envs
@pytest.mark.parametrize("option", MAPPING_OPTIONS)
def test_overrides_matrix_mapping_table_entry_key_not_string(self, isolation, option):
with pytest.raises(
TypeError,
match=(
f"Option `key` in entry #1 in field `tool.hatch.envs.foo.overrides.matrix.version.{option}` "
f"must be a string"
),
):
_ = ProjectConfig(
isolation,
{
"envs": {
"foo": {
"matrix": [{"version": ["9000"]}],
"overrides": {"matrix": {"version": {option: [{"key": 9000}]}}},
}
}
},
PluginManager(),
).envs
@pytest.mark.parametrize("option", MAPPING_OPTIONS)
def test_overrides_matrix_mapping_table_entry_key_empty_string(self, isolation, option):
with pytest.raises(
ValueError,
match=(
f"Option `key` in entry #1 in field `tool.hatch.envs.foo.overrides.matrix.version.{option}` "
f"cannot be an empty string"
),
):
_ = ProjectConfig(
isolation,
{
"envs": {
"foo": {
"matrix": [{"version": ["9000"]}],
"overrides": {"matrix": {"version": {option: [{"key": ""}]}}},
}
}
},
PluginManager(),
).envs
@pytest.mark.parametrize("option", MAPPING_OPTIONS)
def test_overrides_matrix_mapping_table_entry_value_not_string(self, isolation, option):
with pytest.raises(
TypeError,
match=(
f"Option `value` in entry #1 in field `tool.hatch.envs.foo.overrides.matrix.version.{option}` "
f"must be a string"
),
):
_ = ProjectConfig(
isolation,
{
"envs": {
"foo": {
"matrix": [{"version": ["9000"]}],
"overrides": {"matrix": {"version": {option: [{"key": "foo", "value": 9000}]}}},
}
}
},
PluginManager(),
).envs
@pytest.mark.parametrize("option", MAPPING_OPTIONS)
def test_overrides_matrix_mapping_table_entry_if_not_array(self, isolation, option):
with pytest.raises(
TypeError,
match=(
f"Option `if` in entry #1 in field `tool.hatch.envs.foo.overrides.matrix.version.{option}` "
f"must be an array"
),
):
_ = ProjectConfig(
isolation,
{
"envs": {
"foo": {
"matrix": [{"version": ["9000"]}],
"overrides": {
"matrix": {"version": {option: [{"key": "foo", "value": "bar", "if": 9000}]}}
},
}
}
},
PluginManager(),
).envs
@pytest.mark.parametrize("option", ARRAY_OPTIONS)
def test_overrides_matrix_array_invalid_type(self, isolation, option):
with pytest.raises(
TypeError, match=f"Field `tool.hatch.envs.foo.overrides.matrix.version.{option}` must be an array"
):
_ = ProjectConfig(
isolation,
{
"envs": {
"foo": {"matrix": [{"version": ["9000"]}], "overrides": {"matrix": {"version": {option: 9000}}}}
}
},
PluginManager(),
).envs
@pytest.mark.parametrize("option", ARRAY_OPTIONS)
def test_overrides_matrix_array_table_entry_no_value(self, isolation, option):
with pytest.raises(
ValueError,
match=(
f"Entry #1 in field `tool.hatch.envs.foo.overrides.matrix.version.{option}` "
f"must have an option named `value`"
),
):
_ = ProjectConfig(
isolation,
{
"envs": {
"foo": {"matrix": [{"version": ["9000"]}], "overrides": {"matrix": {"version": {option: [{}]}}}}
}
},
PluginManager(),
).envs
@pytest.mark.parametrize("option", ARRAY_OPTIONS)
def test_overrides_matrix_array_table_entry_value_not_string(self, isolation, option):
with pytest.raises(
TypeError,
match=(
f"Option `value` in entry #1 in field `tool.hatch.envs.foo.overrides.matrix.version.{option}` "
f"must be a string"
),
):
_ = ProjectConfig(
isolation,
{
"envs": {
"foo": {
"matrix": [{"version": ["9000"]}],
"overrides": {"matrix": {"version": {option: [{"value": 9000}]}}},
}
}
},
PluginManager(),
).envs
@pytest.mark.parametrize("option", ARRAY_OPTIONS)
def test_overrides_matrix_array_table_entry_value_empty_string(self, isolation, option):
with pytest.raises(
ValueError,
match=(
f"Option `value` in entry #1 in field `tool.hatch.envs.foo.overrides.matrix.version.{option}` "
f"cannot be an empty string"
),
):
_ = ProjectConfig(
isolation,
{
"envs": {
"foo": {
"matrix": [{"version": ["9000"]}],
"overrides": {"matrix": {"version": {option: [{"value": ""}]}}},
}
}
},
PluginManager(),
).envs
@pytest.mark.parametrize("option", ARRAY_OPTIONS)
def test_overrides_matrix_array_table_entry_if_not_array(self, isolation, option):
with pytest.raises(
TypeError,
match=(
f"Option `if` in entry #1 in field `tool.hatch.envs.foo.overrides.matrix.version.{option}` "
f"must be an array"
),
):
_ = ProjectConfig(
isolation,
{
"envs": {
"foo": {
"matrix": [{"version": ["9000"]}],
"overrides": {"matrix": {"version": {option: [{"value": "foo", "if": 9000}]}}},
}
}
},
PluginManager(),
).envs
@pytest.mark.parametrize("option", ARRAY_OPTIONS)
def test_overrides_matrix_array_entry_invalid_type(self, isolation, option):
with pytest.raises(
TypeError,
match=(
f"Entry #1 in field `tool.hatch.envs.foo.overrides.matrix.version.{option}` "
f"must be a string or an inline table"
),
):
_ = ProjectConfig(
isolation,
{
"envs": {
"foo": {
"matrix": [{"version": ["9000"]}],
"overrides": {"matrix": {"version": {option: [9000]}}},
}
}
},
PluginManager(),
).envs
@pytest.mark.parametrize("option", STRING_OPTIONS)
def test_overrides_matrix_string_invalid_type(self, isolation, option):
with pytest.raises(
TypeError,
match=(
f"Field `tool.hatch.envs.foo.overrides.matrix.version.{option}` "
f"must be a string, inline table, or an array"
),
):
_ = ProjectConfig(
isolation,
{
"envs": {
"foo": {"matrix": [{"version": ["9000"]}], "overrides": {"matrix": {"version": {option: 9000}}}}
}
},
PluginManager(),
).envs
@pytest.mark.parametrize("option", STRING_OPTIONS)
def test_overrides_matrix_string_table_no_value(self, isolation, option):
with pytest.raises(
ValueError,
match=f"Field `tool.hatch.envs.foo.overrides.matrix.version.{option}` must have an option named `value`",
):
_ = ProjectConfig(
isolation,
{
"envs": {
"foo": {"matrix": [{"version": ["9000"]}], "overrides": {"matrix": {"version": {option: {}}}}}
}
},
PluginManager(),
).envs
@pytest.mark.parametrize("option", STRING_OPTIONS)
def test_overrides_matrix_string_table_value_not_string(self, isolation, option):
with pytest.raises(
TypeError,
match=f"Option `value` in field `tool.hatch.envs.foo.overrides.matrix.version.{option}` must be a string",
):
_ = ProjectConfig(
isolation,
{
"envs": {
"foo": {
"matrix": [{"version": ["9000"]}],
"overrides": {"matrix": {"version": {option: {"value": 9000}}}},
}
}
},
PluginManager(),
).envs
@pytest.mark.parametrize("option", STRING_OPTIONS)
def test_overrides_matrix_string_array_entry_invalid_type(self, isolation, option):
with pytest.raises(
TypeError,
match=(
f"Entry #1 in field `tool.hatch.envs.foo.overrides.matrix.version.{option}` "
f"must be a string or an inline table"
),
):
_ = ProjectConfig(
isolation,
{
"envs": {
"foo": {
"matrix": [{"version": ["9000"]}],
"overrides": {"matrix": {"version": {option: [9000]}}},
}
}
},
PluginManager(),
).envs
@pytest.mark.parametrize("option", STRING_OPTIONS)
def test_overrides_matrix_string_array_table_no_value(self, isolation, option):
with pytest.raises(
ValueError,
match=(
f"Entry #1 in field `tool.hatch.envs.foo.overrides.matrix.version.{option}` "
f"must have an option named `value`"
),
):
_ = ProjectConfig(
isolation,
{
"envs": {
"foo": {"matrix": [{"version": ["9000"]}], "overrides": {"matrix": {"version": {option: [{}]}}}}
}
},
PluginManager(),
).envs
@pytest.mark.parametrize("option", STRING_OPTIONS)
def test_overrides_matrix_string_array_table_value_not_string(self, isolation, option):
with pytest.raises(
TypeError,
match=(
f"Option `value` in entry #1 in field `tool.hatch.envs.foo.overrides.matrix.version.{option}` "
f"must be a string"
),
):
_ = ProjectConfig(
isolation,
{
"envs": {
"foo": {
"matrix": [{"version": ["9000"]}],
"overrides": {"matrix": {"version": {option: [{"value": 9000}]}}},
}
}
},
PluginManager(),
).envs
@pytest.mark.parametrize("option", STRING_OPTIONS)
def test_overrides_matrix_string_array_table_if_not_array(self, isolation, option):
with pytest.raises(
TypeError,
match=(
f"Option `if` in entry #1 in field `tool.hatch.envs.foo.overrides.matrix.version.{option}` "
f"must be an array"
),
):
_ = ProjectConfig(
isolation,
{
"envs": {
"foo": {
"matrix": [{"version": ["9000"]}],
"overrides": {"matrix": {"version": {option: [{"value": "foo", "if": 9000}]}}},
}
}
},
PluginManager(),
).envs
@pytest.mark.parametrize("option", BOOLEAN_OPTIONS)
def test_overrides_matrix_boolean_invalid_type(self, isolation, option):
with pytest.raises(
TypeError,
match=(
f"Field `tool.hatch.envs.foo.overrides.matrix.version.{option}` "
f"must be a boolean, inline table, or an array"
),
):
_ = ProjectConfig(
isolation,
{
"envs": {
"foo": {"matrix": [{"version": ["9000"]}], "overrides": {"matrix": {"version": {option: 9000}}}}
}
},
PluginManager(),
).envs
@pytest.mark.parametrize("option", BOOLEAN_OPTIONS)
def test_overrides_matrix_boolean_table_no_value(self, isolation, option):
with pytest.raises(
ValueError,
match=f"Field `tool.hatch.envs.foo.overrides.matrix.version.{option}` must have an option named `value`",
):
_ = ProjectConfig(
isolation,
{
"envs": {
"foo": {"matrix": [{"version": ["9000"]}], "overrides": {"matrix": {"version": {option: {}}}}}
}
},
PluginManager(),
).envs
@pytest.mark.parametrize("option", BOOLEAN_OPTIONS)
def test_overrides_matrix_boolean_table_value_not_boolean(self, isolation, option):
with pytest.raises(
TypeError,
match=f"Option `value` in field `tool.hatch.envs.foo.overrides.matrix.version.{option}` must be a boolean",
):
_ = ProjectConfig(
isolation,
{
"envs": {
"foo": {
"matrix": [{"version": ["9000"]}],
"overrides": {"matrix": {"version": {option: {"value": 9000}}}},
}
}
},
PluginManager(),
).envs
@pytest.mark.parametrize("option", BOOLEAN_OPTIONS)
def test_overrides_matrix_boolean_array_entry_invalid_type(self, isolation, option):
with pytest.raises(
TypeError,
match=(
f"Entry #1 in field `tool.hatch.envs.foo.overrides.matrix.version.{option}` "
f"must be a boolean or an inline table"
),
):
_ = ProjectConfig(
isolation,
{
"envs": {
"foo": {
"matrix": [{"version": ["9000"]}],
"overrides": {"matrix": {"version": {option: [9000]}}},
}
}
},
PluginManager(),
).envs
@pytest.mark.parametrize("option", BOOLEAN_OPTIONS)
def test_overrides_matrix_boolean_array_table_no_value(self, isolation, option):
with pytest.raises(
ValueError,
match=(
f"Entry #1 in field `tool.hatch.envs.foo.overrides.matrix.version.{option}` "
f"must have an option named `value`"
),
):
_ = ProjectConfig(
isolation,
{
"envs": {
"foo": {"matrix": [{"version": ["9000"]}], "overrides": {"matrix": {"version": {option: [{}]}}}}
}
},
PluginManager(),
).envs
@pytest.mark.parametrize("option", BOOLEAN_OPTIONS)
def test_overrides_matrix_boolean_array_table_value_not_boolean(self, isolation, option):
with pytest.raises(
TypeError,
match=(
f"Option `value` in entry #1 in field `tool.hatch.envs.foo.overrides.matrix.version.{option}` "
f"must be a boolean"
),
):
_ = ProjectConfig(
isolation,
{
"envs": {
"foo": {
"matrix": [{"version": ["9000"]}],
"overrides": {"matrix": {"version": {option: [{"value": 9000}]}}},
}
}
},
PluginManager(),
).envs
@pytest.mark.parametrize("option", BOOLEAN_OPTIONS)
def test_overrides_matrix_boolean_array_table_if_not_array(self, isolation, option):
with pytest.raises(
TypeError,
match=(
f"Option `if` in entry #1 in field `tool.hatch.envs.foo.overrides.matrix.version.{option}` "
f"must be an array"
),
):
_ = ProjectConfig(
isolation,
{
"envs": {
"foo": {
"matrix": [{"version": ["9000"]}],
"overrides": {"matrix": {"version": {option: [{"value": True, "if": 9000}]}}},
}
}
},
PluginManager(),
).envs
@pytest.mark.parametrize("option", BOOLEAN_OPTIONS)
def test_overrides_matrix_boolean_array_table_platform_not_array(self, isolation, option):
with pytest.raises(
TypeError,
match=(
f"Option `platform` in entry #1 in field `tool.hatch.envs.foo.overrides.matrix.version.{option}` "
f"must be an array"
),
):
_ = ProjectConfig(
isolation,
{
"envs": {
"foo": {
"matrix": [{"version": ["9000"]}],
"overrides": {"matrix": {"version": {option: [{"value": True, "platform": 9000}]}}},
}
}
},
PluginManager(),
).envs
@pytest.mark.parametrize("option", BOOLEAN_OPTIONS)
def test_overrides_matrix_boolean_array_table_platform_item_not_string(self, isolation, option):
with pytest.raises(
TypeError,
match=(
f"Item #1 in option `platform` in entry #1 in field "
f"`tool.hatch.envs.foo.overrides.matrix.version.{option}` must be a string"
),
):
_ = ProjectConfig(
isolation,
{
"envs": {
"foo": {
"matrix": [{"version": ["9000"]}],
"overrides": {"matrix": {"version": {option: [{"value": True, "platform": [9000]}]}}},
}
}
},
PluginManager(),
).envs
@pytest.mark.parametrize("option", BOOLEAN_OPTIONS)
def test_overrides_matrix_boolean_array_table_env_not_array(self, isolation, option):
with pytest.raises(
TypeError,
match=(
f"Option `env` in entry #1 in field `tool.hatch.envs.foo.overrides.matrix.version.{option}` "
f"must be an array"
),
):
_ = ProjectConfig(
isolation,
{
"envs": {
"foo": {
"matrix": [{"version": ["9000"]}],
"overrides": {"matrix": {"version": {option: [{"value": True, "env": 9000}]}}},
}
}
},
PluginManager(),
).envs
@pytest.mark.parametrize("option", BOOLEAN_OPTIONS)
def test_overrides_matrix_boolean_array_table_env_item_not_string(self, isolation, option):
with pytest.raises(
TypeError,
match=(
f"Item #1 in option `env` in entry #1 in field "
f"`tool.hatch.envs.foo.overrides.matrix.version.{option}` must be a string"
),
):
_ = ProjectConfig(
isolation,
{
"envs": {
"foo": {
"matrix": [{"version": ["9000"]}],
"overrides": {"matrix": {"version": {option: [{"value": True, "env": [9000]}]}}},
}
}
},
PluginManager(),
).envs
@pytest.mark.parametrize("option", MAPPING_OPTIONS)
def test_overrides_matrix_mapping_string_with_value(self, isolation, option):
env_config = {
"foo": {
"matrix": [{"version": ["9000"]}, {"feature": ["bar"]}],
"overrides": {"matrix": {"version": {option: "FOO=ok"}}},
}
}
project_config = ProjectConfig(isolation, {"envs": env_config}, PluginManager())
expected_envs = {
"default": {"type": "virtual"},
"foo.9000": {"type": "virtual", option: {"FOO": "ok"}},
"foo.bar": {"type": "virtual"},
}
assert project_config.envs == expected_envs
assert project_config.matrices["foo"] == construct_matrix_data("foo", env_config)
@pytest.mark.parametrize("option", MAPPING_OPTIONS)
def test_overrides_matrix_mapping_string_without_value(self, isolation, option):
env_config = {
"foo": {
"matrix": [{"version": ["9000"]}, {"feature": ["bar"]}],
"overrides": {"matrix": {"version": {option: "FOO"}}},
}
}
project_config = ProjectConfig(isolation, {"envs": env_config}, PluginManager())
expected_envs = {
"default": {"type": "virtual"},
"foo.9000": {"type": "virtual", option: {"FOO": "9000"}},
"foo.bar": {"type": "virtual"},
}
assert project_config.envs == expected_envs
assert project_config.matrices["foo"] == construct_matrix_data("foo", env_config)
@pytest.mark.parametrize("option", MAPPING_OPTIONS)
def test_overrides_matrix_mapping_string_override(self, isolation, option):
env_config = {
"foo": {
option: {"TEST": "baz"},
"matrix": [{"version": ["9000"]}, {"feature": ["bar"]}],
"overrides": {"matrix": {"version": {option: "TEST"}}},
}
}
project_config = ProjectConfig(isolation, {"envs": env_config}, PluginManager())
expected_envs = {
"default": {"type": "virtual"},
"foo.9000": {"type": "virtual", option: {"TEST": "9000"}},
"foo.bar": {"type": "virtual", option: {"TEST": "baz"}},
}
assert project_config.envs == expected_envs
assert project_config.matrices["foo"] == construct_matrix_data("foo", env_config)
@pytest.mark.parametrize("option", MAPPING_OPTIONS)
def test_overrides_matrix_mapping_array_string_with_value(self, isolation, option):
env_config = {
"foo": {
"matrix": [{"version": ["9000"]}, {"feature": ["bar"]}],
"overrides": {"matrix": {"version": {option: ["FOO=ok"]}}},
}
}
project_config = ProjectConfig(isolation, {"envs": env_config}, PluginManager())
expected_envs = {
"default": {"type": "virtual"},
"foo.9000": {"type": "virtual", option: {"FOO": "ok"}},
"foo.bar": {"type": "virtual"},
}
assert project_config.envs == expected_envs
assert project_config.matrices["foo"] == construct_matrix_data("foo", env_config)
@pytest.mark.parametrize("option", MAPPING_OPTIONS)
def test_overrides_matrix_mapping_array_string_without_value(self, isolation, option):
env_config = {
"foo": {
"matrix": [{"version": ["9000"]}, {"feature": ["bar"]}],
"overrides": {"matrix": {"version": {option: ["FOO"]}}},
}
}
project_config = ProjectConfig(isolation, {"envs": env_config}, PluginManager())
expected_envs = {
"default": {"type": "virtual"},
"foo.9000": {"type": "virtual", option: {"FOO": "9000"}},
"foo.bar": {"type": "virtual"},
}
assert project_config.envs == expected_envs
assert project_config.matrices["foo"] == construct_matrix_data("foo", env_config)
@pytest.mark.parametrize("option", MAPPING_OPTIONS)
def test_overrides_matrix_mapping_array_string_override(self, isolation, option):
env_config = {
"foo": {
option: {"TEST": "baz"},
"matrix": [{"version": ["9000"]}, {"feature": ["bar"]}],
"overrides": {"matrix": {"version": {option: ["TEST"]}}},
}
}
project_config = ProjectConfig(isolation, {"envs": env_config}, PluginManager())
expected_envs = {
"default": {"type": "virtual"},
"foo.9000": {"type": "virtual", option: {"TEST": "9000"}},
"foo.bar": {"type": "virtual", option: {"TEST": "baz"}},
}
assert project_config.envs == expected_envs
assert project_config.matrices["foo"] == construct_matrix_data("foo", env_config)
@pytest.mark.parametrize("option", MAPPING_OPTIONS)
def test_overrides_matrix_mapping_array_table_key_with_value(self, isolation, option):
env_config = {
"foo": {
"matrix": [{"version": ["9000"]}, {"feature": ["bar"]}],
"overrides": {"matrix": {"version": {option: [{"key": "FOO", "value": "ok"}]}}},
}
}
project_config = ProjectConfig(isolation, {"envs": env_config}, PluginManager())
expected_envs = {
"default": {"type": "virtual"},
"foo.9000": {"type": "virtual", option: {"FOO": "ok"}},
"foo.bar": {"type": "virtual"},
}
assert project_config.envs == expected_envs
assert project_config.matrices["foo"] == construct_matrix_data("foo", env_config)
@pytest.mark.parametrize("option", MAPPING_OPTIONS)
def test_overrides_matrix_mapping_array_table_key_without_value(self, isolation, option):
env_config = {
"foo": {
"matrix": [{"version": ["9000"]}, {"feature": ["bar"]}],
"overrides": {"matrix": {"version": {option: [{"key": "FOO"}]}}},
}
}
project_config = ProjectConfig(isolation, {"envs": env_config}, PluginManager())
expected_envs = {
"default": {"type": "virtual"},
"foo.9000": {"type": "virtual", option: {"FOO": "9000"}},
"foo.bar": {"type": "virtual"},
}
assert project_config.envs == expected_envs
assert project_config.matrices["foo"] == construct_matrix_data("foo", env_config)
@pytest.mark.parametrize("option", MAPPING_OPTIONS)
def test_overrides_matrix_mapping_array_table_override(self, isolation, option):
env_config = {
"foo": {
option: {"TEST": "baz"},
"matrix": [{"version": ["9000"]}, {"feature": ["bar"]}],
"overrides": {"matrix": {"version": {option: [{"key": "TEST"}]}}},
}
}
project_config = ProjectConfig(isolation, {"envs": env_config}, PluginManager())
expected_envs = {
"default": {"type": "virtual"},
"foo.9000": {"type": "virtual", option: {"TEST": "9000"}},
"foo.bar": {"type": "virtual", option: {"TEST": "baz"}},
}
assert project_config.envs == expected_envs
assert project_config.matrices["foo"] == construct_matrix_data("foo", env_config)
@pytest.mark.parametrize("option", MAPPING_OPTIONS)
def test_overrides_matrix_mapping_array_table_conditional(self, isolation, option):
env_config = {
"foo": {
option: {"TEST": "baz"},
"matrix": [{"version": ["9000", "42"]}, {"feature": ["bar"]}],
"overrides": {"matrix": {"version": {option: [{"key": "TEST", "if": ["42"]}]}}},
}
}
project_config = ProjectConfig(isolation, {"envs": env_config}, PluginManager())
expected_envs = {
"default": {"type": "virtual"},
"foo.9000": {"type": "virtual", option: {"TEST": "baz"}},
"foo.42": {"type": "virtual", option: {"TEST": "42"}},
"foo.bar": {"type": "virtual", option: {"TEST": "baz"}},
}
assert project_config.envs == expected_envs
assert project_config.matrices["foo"] == construct_matrix_data("foo", env_config)
@pytest.mark.parametrize("option", MAPPING_OPTIONS)
def test_overrides_matrix_mapping_overwrite(self, isolation, option):
env_config = {
"foo": {
option: {"TEST": "baz"},
"matrix": [{"version": ["9000"]}, {"feature": ["bar"]}],
"overrides": {"matrix": {"version": {f"set-{option}": ["FOO=bar", {"key": "BAZ"}]}}},
}
}
project_config = ProjectConfig(isolation, {"envs": env_config}, PluginManager())
expected_envs = {
"default": {"type": "virtual"},
"foo.9000": {"type": "virtual", option: {"FOO": "bar", "BAZ": "9000"}},
"foo.bar": {"type": "virtual", option: {"TEST": "baz"}},
}
assert project_config.envs == expected_envs
assert project_config.matrices["foo"] == construct_matrix_data("foo", env_config)
@pytest.mark.parametrize("option", ARRAY_OPTIONS)
def test_overrides_matrix_array_string(self, isolation, option):
env_config = {
"foo": {
"matrix": [{"version": ["9000"]}, {"feature": ["bar"]}],
"overrides": {"matrix": {"version": {option: ["run foo"]}}},
}
}
project_config = ProjectConfig(isolation, {"envs": env_config}, PluginManager())
expected_envs = {
"default": {"type": "virtual"},
"foo.9000": {"type": "virtual", option: ["run foo"]},
"foo.bar": {"type": "virtual"},
}
assert project_config.envs == expected_envs
assert project_config.matrices["foo"] == construct_matrix_data("foo", env_config)
@pytest.mark.parametrize("option", ARRAY_OPTIONS)
def test_overrides_matrix_array_string_existing_append(self, isolation, option):
env_config = {
"foo": {
option: ["run baz"],
"matrix": [{"version": ["9000"]}, {"feature": ["bar"]}],
"overrides": {"matrix": {"version": {option: ["run foo"]}}},
}
}
project_config = ProjectConfig(isolation, {"envs": env_config}, PluginManager())
expected_envs = {
"default": {"type": "virtual"},
"foo.9000": {"type": "virtual", option: ["run baz", "run foo"]},
"foo.bar": {"type": "virtual", option: ["run baz"]},
}
assert project_config.envs == expected_envs
assert project_config.matrices["foo"] == construct_matrix_data("foo", env_config)
@pytest.mark.parametrize("option", ARRAY_OPTIONS)
def test_overrides_matrix_array_table(self, isolation, option):
env_config = {
"foo": {
"matrix": [{"version": ["9000"]}, {"feature": ["bar"]}],
"overrides": {"matrix": {"version": {option: [{"value": "run foo"}]}}},
}
}
project_config = ProjectConfig(isolation, {"envs": env_config}, PluginManager())
expected_envs = {
"default": {"type": "virtual"},
"foo.9000": {"type": "virtual", option: ["run foo"]},
"foo.bar": {"type": "virtual"},
}
assert project_config.envs == expected_envs
assert project_config.matrices["foo"] == construct_matrix_data("foo", env_config)
@pytest.mark.parametrize("option", ARRAY_OPTIONS)
def test_overrides_matrix_array_table_existing_append(self, isolation, option):
env_config = {
"foo": {
option: ["run baz"],
"matrix": [{"version": ["9000"]}, {"feature": ["bar"]}],
"overrides": {"matrix": {"version": {option: [{"value": "run foo"}]}}},
}
}
project_config = ProjectConfig(isolation, {"envs": env_config}, PluginManager())
expected_envs = {
"default": {"type": "virtual"},
"foo.9000": {"type": "virtual", option: ["run baz", "run foo"]},
"foo.bar": {"type": "virtual", option: ["run baz"]},
}
assert project_config.envs == expected_envs
assert project_config.matrices["foo"] == construct_matrix_data("foo", env_config)
@pytest.mark.parametrize("option", ARRAY_OPTIONS)
def test_overrides_matrix_array_table_conditional(self, isolation, option):
env_config = {
"foo": {
option: ["run baz"],
"matrix": [{"version": ["9000", "42"]}, {"feature": ["bar"]}],
"overrides": {"matrix": {"version": {option: [{"value": "run foo", "if": ["42"]}]}}},
}
}
project_config = ProjectConfig(isolation, {"envs": env_config}, PluginManager())
expected_envs = {
"default": {"type": "virtual"},
"foo.9000": {"type": "virtual", option: ["run baz"]},
"foo.42": {"type": "virtual", option: ["run baz", "run foo"]},
"foo.bar": {"type": "virtual", option: ["run baz"]},
}
assert project_config.envs == expected_envs
assert project_config.matrices["foo"] == construct_matrix_data("foo", env_config)
@pytest.mark.parametrize("option", ARRAY_OPTIONS)
def test_overrides_matrix_array_table_conditional_with_platform(self, isolation, option, current_platform):
env_config = {
"foo": {
option: ["run baz"],
"matrix": [{"version": ["9000", "42"]}, {"feature": ["bar"]}],
"overrides": {
"matrix": {
"version": {option: [{"value": "run foo", "if": ["42"], "platform": [current_platform]}]}
},
},
}
}
project_config = ProjectConfig(isolation, {"envs": env_config}, PluginManager())
expected_envs = {
"default": {"type": "virtual"},
"foo.9000": {"type": "virtual", option: ["run baz"]},
"foo.42": {"type": "virtual", option: ["run baz", "run foo"]},
"foo.bar": {"type": "virtual", option: ["run baz"]},
}
assert project_config.envs == expected_envs
assert project_config.matrices["foo"] == construct_matrix_data("foo", env_config)
@pytest.mark.parametrize("option", ARRAY_OPTIONS)
def test_overrides_matrix_array_table_conditional_with_wrong_platform(self, isolation, option):
env_config = {
"foo": {
option: ["run baz"],
"matrix": [{"version": ["9000", "42"]}, {"feature": ["bar"]}],
"overrides": {
"matrix": {"version": {option: [{"value": "run foo", "if": ["42"], "platform": ["bar"]}]}},
},
}
}
project_config = ProjectConfig(isolation, {"envs": env_config}, PluginManager())
expected_envs = {
"default": {"type": "virtual"},
"foo.9000": {"type": "virtual", option: ["run baz"]},
"foo.42": {"type": "virtual", option: ["run baz"]},
"foo.bar": {"type": "virtual", option: ["run baz"]},
}
assert project_config.envs == expected_envs
assert project_config.matrices["foo"] == construct_matrix_data("foo", env_config)
@pytest.mark.parametrize("option", ARRAY_OPTIONS)
def test_overrides_matrix_array_table_conditional_with_env_var_match(self, isolation, option):
env_var = "OVERRIDES_ENV_FOO"
env_config = {
"foo": {
option: ["run baz"],
"matrix": [{"version": ["9000", "42"]}, {"feature": ["bar"]}],
"overrides": {
"matrix": {"version": {option: [{"value": "run foo", "if": ["42"], "env": [f"{env_var}=bar"]}]}}
},
}
}
project_config = ProjectConfig(isolation, {"envs": env_config}, PluginManager())
expected_envs = {
"default": {"type": "virtual"},
"foo.9000": {"type": "virtual", option: ["run baz"]},
"foo.42": {"type": "virtual", option: ["run baz", "run foo"]},
"foo.bar": {"type": "virtual", option: ["run baz"]},
}
with EnvVars({env_var: "bar"}):
assert project_config.envs == expected_envs
assert project_config.matrices["foo"] == construct_matrix_data("foo", env_config)
@pytest.mark.parametrize("option", ARRAY_OPTIONS)
def test_overrides_matrix_array_table_conditional_with_env_var_match_empty_string(self, isolation, option):
env_var = "OVERRIDES_ENV_FOO"
env_config = {
"foo": {
option: ["run baz"],
"matrix": [{"version": ["9000", "42"]}, {"feature": ["bar"]}],
"overrides": {
"matrix": {"version": {option: [{"value": "run foo", "if": ["42"], "env": [f"{env_var}="]}]}}
},
}
}
project_config = ProjectConfig(isolation, {"envs": env_config}, PluginManager())
expected_envs = {
"default": {"type": "virtual"},
"foo.9000": {"type": "virtual", option: ["run baz"]},
"foo.42": {"type": "virtual", option: ["run baz", "run foo"]},
"foo.bar": {"type": "virtual", option: ["run baz"]},
}
with EnvVars({env_var: ""}):
assert project_config.envs == expected_envs
assert project_config.matrices["foo"] == construct_matrix_data("foo", env_config)
@pytest.mark.parametrize("option", ARRAY_OPTIONS)
def test_overrides_matrix_array_table_conditional_with_env_var_present(self, isolation, option):
env_var = "OVERRIDES_ENV_FOO"
env_config = {
"foo": {
option: ["run baz"],
"matrix": [{"version": ["9000", "42"]}, {"feature": ["bar"]}],
"overrides": {"matrix": {"version": {option: [{"value": "run foo", "if": ["42"], "env": [env_var]}]}}},
}
}
project_config = ProjectConfig(isolation, {"envs": env_config}, PluginManager())
expected_envs = {
"default": {"type": "virtual"},
"foo.9000": {"type": "virtual", option: ["run baz"]},
"foo.42": {"type": "virtual", option: ["run baz", "run foo"]},
"foo.bar": {"type": "virtual", option: ["run baz"]},
}
with EnvVars({env_var: "any"}):
assert project_config.envs == expected_envs
assert project_config.matrices["foo"] == construct_matrix_data("foo", env_config)
@pytest.mark.parametrize("option", ARRAY_OPTIONS)
def test_overrides_matrix_array_table_conditional_with_env_var_no_match(self, isolation, option):
env_var = "OVERRIDES_ENV_FOO"
env_config = {
"foo": {
option: ["run baz"],
"matrix": [{"version": ["9000", "42"]}, {"feature": ["bar"]}],
"overrides": {
"matrix": {"version": {option: [{"value": "run foo", "if": ["42"], "env": [f"{env_var}=bar"]}]}}
},
}
}
project_config = ProjectConfig(isolation, {"envs": env_config}, PluginManager())
expected_envs = {
"default": {"type": "virtual"},
"foo.9000": {"type": "virtual", option: ["run baz"]},
"foo.42": {"type": "virtual", option: ["run baz"]},
"foo.bar": {"type": "virtual", option: ["run baz"]},
}
with EnvVars({env_var: "baz"}):
assert project_config.envs == expected_envs
assert project_config.matrices["foo"] == construct_matrix_data("foo", env_config)
@pytest.mark.parametrize("option", ARRAY_OPTIONS)
def test_overrides_matrix_array_table_conditional_with_env_var_missing(self, isolation, option):
env_var = "OVERRIDES_ENV_FOO"
env_config = {
"foo": {
option: ["run baz"],
"matrix": [{"version": ["9000", "42"]}, {"feature": ["bar"]}],
"overrides": {
"matrix": {"version": {option: [{"value": "run foo", "if": ["42"], "env": [f"{env_var}=bar"]}]}}
},
}
}
project_config = ProjectConfig(isolation, {"envs": env_config}, PluginManager())
expected_envs = {
"default": {"type": "virtual"},
"foo.9000": {"type": "virtual", option: ["run baz"]},
"foo.42": {"type": "virtual", option: ["run baz"]},
"foo.bar": {"type": "virtual", option: ["run baz"]},
}
assert project_config.envs == expected_envs
assert project_config.matrices["foo"] == construct_matrix_data("foo", env_config)
def test_overrides_matrix_set_with_no_type_information(self, isolation):
env_var = "OVERRIDES_ENV_FOO"
env_config = {
"foo": {
"matrix": [{"version": ["9000", "42"]}, {"feature": ["bar"]}],
"overrides": {
"matrix": {"version": {"bar": {"value": ["baz"], "if": ["42"], "env": [f"{env_var}=bar"]}}}
},
}
}
project_config = ProjectConfig(isolation, {"envs": env_config}, PluginManager())
expected_envs = {
"default": {"type": "virtual"},
"foo.9000": {"type": "virtual"},
"foo.42": {"type": "virtual", "bar": ["baz"]},
"foo.bar": {"type": "virtual"},
}
with EnvVars({env_var: "bar"}):
assert project_config.envs == expected_envs
assert project_config.matrices["foo"] == construct_matrix_data("foo", env_config)
def test_overrides_matrix_set_with_no_type_information_not_table(self, isolation):
project_config = ProjectConfig(
isolation,
{
"envs": {
"foo": {
"matrix": [{"version": ["9000", "42"]}, {"feature": ["bar"]}],
"overrides": {"matrix": {"version": {"bar": 9000}}},
}
}
},
PluginManager(),
)
_ = project_config.envs
with pytest.raises(
ValueError,
match=(
"Untyped option `tool.hatch.envs.foo.9000.overrides.matrix.version.bar` "
"must be defined as a table with a `value` key"
),
):
project_config.finalize_env_overrides({})
@pytest.mark.parametrize("option", ARRAY_OPTIONS)
def test_overrides_matrix_array_overwrite(self, isolation, option):
env_config = {
"foo": {
option: ["run baz"],
"matrix": [{"version": ["9000"]}, {"feature": ["bar"]}],
"overrides": {"matrix": {"version": {f"set-{option}": ["run foo", {"value": "run bar"}]}}},
}
}
project_config = ProjectConfig(isolation, {"envs": env_config}, PluginManager())
expected_envs = {
"default": {"type": "virtual"},
"foo.9000": {"type": "virtual", option: ["run foo", "run bar"]},
"foo.bar": {"type": "virtual", option: ["run baz"]},
}
assert project_config.envs == expected_envs
assert project_config.matrices["foo"] == construct_matrix_data("foo", env_config)
@pytest.mark.parametrize("option", STRING_OPTIONS)
def test_overrides_matrix_string_string_create(self, isolation, option):
env_config = {
"foo": {
"matrix": [{"version": ["9000"]}, {"feature": ["bar"]}],
"overrides": {"matrix": {"version": {option: "baz"}}},
}
}
project_config = ProjectConfig(isolation, {"envs": env_config}, PluginManager())
expected_envs = {
"default": {"type": "virtual"},
"foo.9000": {"type": "virtual", option: "baz"},
"foo.bar": {"type": "virtual"},
}
assert project_config.envs == expected_envs
assert project_config.matrices["foo"] == construct_matrix_data("foo", env_config)
@pytest.mark.parametrize("option", STRING_OPTIONS)
def test_overrides_matrix_string_string_overwrite(self, isolation, option):
env_config = {
"foo": {
option: "test",
"matrix": [{"version": ["9000"]}, {"feature": ["bar"]}],
"overrides": {"matrix": {"version": {option: "baz"}}},
}
}
project_config = ProjectConfig(isolation, {"envs": env_config}, PluginManager())
expected_envs = {
"default": {"type": "virtual"},
"foo.9000": {"type": "virtual", option: "baz"},
"foo.bar": {"type": "virtual", option: "test"},
}
assert project_config.envs == expected_envs
assert project_config.matrices["foo"] == construct_matrix_data("foo", env_config)
@pytest.mark.parametrize("option", STRING_OPTIONS)
def test_overrides_matrix_string_table_create(self, isolation, option):
env_config = {
"foo": {
"matrix": [{"version": ["9000"]}, {"feature": ["bar"]}],
"overrides": {"matrix": {"version": {option: {"value": "baz"}}}},
}
}
project_config = ProjectConfig(isolation, {"envs": env_config}, PluginManager())
expected_envs = {
"default": {"type": "virtual"},
"foo.9000": {"type": "virtual", option: "baz"},
"foo.bar": {"type": "virtual"},
}
assert project_config.envs == expected_envs
assert project_config.matrices["foo"] == construct_matrix_data("foo", env_config)
@pytest.mark.parametrize("option", STRING_OPTIONS)
def test_overrides_matrix_string_table_override(self, isolation, option):
env_config = {
"foo": {
option: "test",
"matrix": [{"version": ["9000"]}, {"feature": ["bar"]}],
"overrides": {"matrix": {"version": {option: {"value": "baz"}}}},
}
}
project_config = ProjectConfig(isolation, {"envs": env_config}, PluginManager())
expected_envs = {
"default": {"type": "virtual"},
"foo.9000": {"type": "virtual", option: "baz"},
"foo.bar": {"type": "virtual", option: "test"},
}
assert project_config.envs == expected_envs
assert project_config.matrices["foo"] == construct_matrix_data("foo", env_config)
@pytest.mark.parametrize("option", STRING_OPTIONS)
def test_overrides_matrix_string_table_conditional(self, isolation, option):
env_config = {
"foo": {
option: "test",
"matrix": [{"version": ["9000", "42"]}, {"feature": ["bar"]}],
"overrides": {"matrix": {"version": {option: {"value": "baz", "if": ["42"]}}}},
}
}
project_config = ProjectConfig(isolation, {"envs": env_config}, PluginManager())
expected_envs = {
"default": {"type": "virtual"},
"foo.9000": {"type": "virtual", option: "test"},
"foo.42": {"type": "virtual", option: "baz"},
"foo.bar": {"type": "virtual", option: "test"},
}
assert project_config.envs == expected_envs
assert project_config.matrices["foo"] == construct_matrix_data("foo", env_config)
@pytest.mark.parametrize("option", STRING_OPTIONS)
def test_overrides_matrix_string_array_table_create(self, isolation, option):
env_config = {
"foo": {
"matrix": [{"version": ["9000"]}, {"feature": ["bar"]}],
"overrides": {"matrix": {"version": {option: [{"value": "baz"}]}}},
}
}
project_config = ProjectConfig(isolation, {"envs": env_config}, PluginManager())
expected_envs = {
"default": {"type": "virtual"},
"foo.9000": {"type": "virtual", option: "baz"},
"foo.bar": {"type": "virtual"},
}
assert project_config.envs == expected_envs
assert project_config.matrices["foo"] == construct_matrix_data("foo", env_config)
@pytest.mark.parametrize("option", STRING_OPTIONS)
def test_overrides_matrix_string_array_table_override(self, isolation, option):
env_config = {
"foo": {
option: "test",
"matrix": [{"version": ["9000"]}, {"feature": ["bar"]}],
"overrides": {"matrix": {"version": {option: [{"value": "baz"}]}}},
}
}
project_config = ProjectConfig(isolation, {"envs": env_config}, PluginManager())
expected_envs = {
"default": {"type": "virtual"},
"foo.9000": {"type": "virtual", option: "baz"},
"foo.bar": {"type": "virtual", option: "test"},
}
assert project_config.envs == expected_envs
assert project_config.matrices["foo"] == construct_matrix_data("foo", env_config)
@pytest.mark.parametrize("option", STRING_OPTIONS)
def test_overrides_matrix_string_array_table_conditional(self, isolation, option):
env_config = {
"foo": {
option: "test",
"matrix": [{"version": ["9000", "42"]}, {"feature": ["bar"]}],
"overrides": {"matrix": {"version": {option: [{"value": "baz", "if": ["42"]}]}}},
}
}
project_config = ProjectConfig(isolation, {"envs": env_config}, PluginManager())
expected_envs = {
"default": {"type": "virtual"},
"foo.9000": {"type": "virtual", option: "test"},
"foo.42": {"type": "virtual", option: "baz"},
"foo.bar": {"type": "virtual", option: "test"},
}
assert project_config.envs == expected_envs
assert project_config.matrices["foo"] == construct_matrix_data("foo", env_config)
@pytest.mark.parametrize("option", STRING_OPTIONS)
def test_overrides_matrix_string_array_table_conditional_eager_string(self, isolation, option):
env_config = {
"foo": {
option: "test",
"matrix": [{"version": ["9000", "42"]}, {"feature": ["bar"]}],
"overrides": {"matrix": {"version": {option: ["baz", {"value": "foo", "if": ["42"]}]}}},
}
}
project_config = ProjectConfig(isolation, {"envs": env_config}, PluginManager())
expected_envs = {
"default": {"type": "virtual"},
"foo.9000": {"type": "virtual", option: "baz"},
"foo.42": {"type": "virtual", option: "baz"},
"foo.bar": {"type": "virtual", option: "test"},
}
assert project_config.envs == expected_envs
assert project_config.matrices["foo"] == construct_matrix_data("foo", env_config)
@pytest.mark.parametrize("option", STRING_OPTIONS)
def test_overrides_matrix_string_array_table_conditional_eager_table(self, isolation, option):
env_config = {
"foo": {
option: "test",
"matrix": [{"version": ["9000", "42"]}, {"feature": ["bar"]}],
"overrides": {"matrix": {"version": {option: [{"value": "baz", "if": ["42"]}, "foo"]}}},
}
}
project_config = ProjectConfig(isolation, {"envs": env_config}, PluginManager())
expected_envs = {
"default": {"type": "virtual"},
"foo.9000": {"type": "virtual", option: "foo"},
"foo.42": {"type": "virtual", option: "baz"},
"foo.bar": {"type": "virtual", option: "test"},
}
assert project_config.envs == expected_envs
assert project_config.matrices["foo"] == construct_matrix_data("foo", env_config)
@pytest.mark.parametrize("option", BOOLEAN_OPTIONS)
def test_overrides_matrix_boolean_boolean_create(self, isolation, option):
env_config = {
"foo": {
"matrix": [{"version": ["9000"]}, {"feature": ["bar"]}],
"overrides": {"matrix": {"version": {option: True}}},
}
}
project_config = ProjectConfig(isolation, {"envs": env_config}, PluginManager())
expected_envs = {
"default": {"type": "virtual"},
"foo.9000": {"type": "virtual", option: True},
"foo.bar": {"type": "virtual"},
}
assert project_config.envs == expected_envs
assert project_config.matrices["foo"] == construct_matrix_data("foo", env_config)
@pytest.mark.parametrize("option", BOOLEAN_OPTIONS)
def test_overrides_matrix_boolean_boolean_overwrite(self, isolation, option):
env_config = {
"foo": {
option: False,
"matrix": [{"version": ["9000"]}, {"feature": ["bar"]}],
"overrides": {"matrix": {"version": {option: True}}},
}
}
project_config = ProjectConfig(isolation, {"envs": env_config}, PluginManager())
expected_envs = {
"default": {"type": "virtual"},
"foo.9000": {"type": "virtual", option: True},
"foo.bar": {"type": "virtual", option: False},
}
assert project_config.envs == expected_envs
assert project_config.matrices["foo"] == construct_matrix_data("foo", env_config)
@pytest.mark.parametrize("option", BOOLEAN_OPTIONS)
def test_overrides_matrix_boolean_table_create(self, isolation, option):
env_config = {
"foo": {
"matrix": [{"version": ["9000"]}, {"feature": ["bar"]}],
"overrides": {"matrix": {"version": {option: {"value": True}}}},
}
}
project_config = ProjectConfig(isolation, {"envs": env_config}, PluginManager())
expected_envs = {
"default": {"type": "virtual"},
"foo.9000": {"type": "virtual", option: True},
"foo.bar": {"type": "virtual"},
}
assert project_config.envs == expected_envs
assert project_config.matrices["foo"] == construct_matrix_data("foo", env_config)
@pytest.mark.parametrize("option", BOOLEAN_OPTIONS)
def test_overrides_matrix_boolean_table_override(self, isolation, option):
env_config = {
"foo": {
option: False,
"matrix": [{"version": ["9000"]}, {"feature": ["bar"]}],
"overrides": {"matrix": {"version": {option: {"value": True}}}},
}
}
project_config = ProjectConfig(isolation, {"envs": env_config}, PluginManager())
expected_envs = {
"default": {"type": "virtual"},
"foo.9000": {"type": "virtual", option: True},
"foo.bar": {"type": "virtual", option: False},
}
assert project_config.envs == expected_envs
assert project_config.matrices["foo"] == construct_matrix_data("foo", env_config)
@pytest.mark.parametrize("option", BOOLEAN_OPTIONS)
def test_overrides_matrix_boolean_table_conditional(self, isolation, option):
env_config = {
"foo": {
option: False,
"matrix": [{"version": ["9000", "42"]}, {"feature": ["bar"]}],
"overrides": {"matrix": {"version": {option: {"value": True, "if": ["42"]}}}},
}
}
project_config = ProjectConfig(isolation, {"envs": env_config}, PluginManager())
expected_envs = {
"default": {"type": "virtual"},
"foo.9000": {"type": "virtual", option: False},
"foo.42": {"type": "virtual", option: True},
"foo.bar": {"type": "virtual", option: False},
}
assert project_config.envs == expected_envs
assert project_config.matrices["foo"] == construct_matrix_data("foo", env_config)
@pytest.mark.parametrize("option", BOOLEAN_OPTIONS)
def test_overrides_matrix_boolean_array_table_create(self, isolation, option):
env_config = {
"foo": {
"matrix": [{"version": ["9000"]}, {"feature": ["bar"]}],
"overrides": {"matrix": {"version": {option: [{"value": True}]}}},
}
}
project_config = ProjectConfig(isolation, {"envs": env_config}, PluginManager())
expected_envs = {
"default": {"type": "virtual"},
"foo.9000": {"type": "virtual", option: True},
"foo.bar": {"type": "virtual"},
}
assert project_config.envs == expected_envs
assert project_config.matrices["foo"] == construct_matrix_data("foo", env_config)
@pytest.mark.parametrize("option", BOOLEAN_OPTIONS)
def test_overrides_matrix_boolean_array_table_override(self, isolation, option):
env_config = {
"foo": {
option: False,
"matrix": [{"version": ["9000"]}, {"feature": ["bar"]}],
"overrides": {"matrix": {"version": {option: [{"value": True}]}}},
}
}
project_config = ProjectConfig(isolation, {"envs": env_config}, PluginManager())
expected_envs = {
"default": {"type": "virtual"},
"foo.9000": {"type": "virtual", option: True},
"foo.bar": {"type": "virtual", option: False},
}
assert project_config.envs == expected_envs
assert project_config.matrices["foo"] == construct_matrix_data("foo", env_config)
@pytest.mark.parametrize("option", BOOLEAN_OPTIONS)
def test_overrides_matrix_boolean_array_table_conditional(self, isolation, option):
env_config = {
"foo": {
option: False,
"matrix": [{"version": ["9000", "42"]}, {"feature": ["bar"]}],
"overrides": {"matrix": {"version": {option: [{"value": True, "if": ["42"]}]}}},
}
}
project_config = ProjectConfig(isolation, {"envs": env_config}, PluginManager())
expected_envs = {
"default": {"type": "virtual"},
"foo.9000": {"type": "virtual", option: False},
"foo.42": {"type": "virtual", option: True},
"foo.bar": {"type": "virtual", option: False},
}
assert project_config.envs == expected_envs
assert project_config.matrices["foo"] == construct_matrix_data("foo", env_config)
@pytest.mark.parametrize("option", BOOLEAN_OPTIONS)
def test_overrides_matrix_boolean_array_table_conditional_eager_boolean(self, isolation, option):
env_config = {
"foo": {
option: False,
"matrix": [{"version": ["9000", "42"]}, {"feature": ["bar"]}],
"overrides": {"matrix": {"version": {option: [True, {"value": False, "if": ["42"]}]}}},
}
}
project_config = ProjectConfig(isolation, {"envs": env_config}, PluginManager())
expected_envs = {
"default": {"type": "virtual"},
"foo.9000": {"type": "virtual", option: True},
"foo.42": {"type": "virtual", option: True},
"foo.bar": {"type": "virtual", option: False},
}
assert project_config.envs == expected_envs
assert project_config.matrices["foo"] == construct_matrix_data("foo", env_config)
@pytest.mark.parametrize("option", BOOLEAN_OPTIONS)
def test_overrides_matrix_boolean_array_table_conditional_eager_table(self, isolation, option):
env_config = {
"foo": {
option: False,
"matrix": [{"version": ["9000", "42"]}, {"feature": ["bar"]}],
"overrides": {"matrix": {"version": {option: [{"value": True, "if": ["42"]}, False]}}},
}
}
project_config = ProjectConfig(isolation, {"envs": env_config}, PluginManager())
expected_envs = {
"default": {"type": "virtual"},
"foo.9000": {"type": "virtual", option: False},
"foo.42": {"type": "virtual", option: True},
"foo.bar": {"type": "virtual", option: False},
}
assert project_config.envs == expected_envs
assert project_config.matrices["foo"] == construct_matrix_data("foo", env_config)
# We assert type coverage using matrix variable overrides, for the others just test one type
def test_overrides_platform_boolean_boolean_create(self, isolation, current_platform):
env_config = {
"foo": {
"overrides": {"platform": {"bar": {"dependencies": ["baz"]}, current_platform: {"skip-install": True}}}
}
}
project_config = ProjectConfig(isolation, {"envs": env_config}, PluginManager())
expected_envs = {
"default": {"type": "virtual"},
"foo": {"type": "virtual", "skip-install": True},
}
assert project_config.envs == expected_envs
def test_overrides_platform_boolean_boolean_overwrite(self, isolation, current_platform):
env_config = {
"foo": {
"skip-install": True,
"overrides": {
"platform": {"bar": {"dependencies": ["baz"]}, current_platform: {"skip-install": False}}
},
}
}
project_config = ProjectConfig(isolation, {"envs": env_config}, PluginManager())
expected_envs = {
"default": {"type": "virtual"},
"foo": {"type": "virtual", "skip-install": False},
}
assert project_config.envs == expected_envs
def test_overrides_platform_boolean_table_create(self, isolation, current_platform):
env_config = {
"foo": {
"overrides": {
"platform": {
"bar": {"dependencies": ["baz"]},
current_platform: {"skip-install": [{"value": True}]},
}
}
}
}
project_config = ProjectConfig(isolation, {"envs": env_config}, PluginManager())
expected_envs = {
"default": {"type": "virtual"},
"foo": {"type": "virtual", "skip-install": True},
}
assert project_config.envs == expected_envs
def test_overrides_platform_boolean_table_overwrite(self, isolation, current_platform):
env_config = {
"foo": {
"skip-install": True,
"overrides": {
"platform": {
"bar": {"dependencies": ["baz"]},
current_platform: {"skip-install": [{"value": False}]},
}
},
}
}
project_config = ProjectConfig(isolation, {"envs": env_config}, PluginManager())
expected_envs = {
"default": {"type": "virtual"},
"foo": {"type": "virtual", "skip-install": False},
}
assert project_config.envs == expected_envs
def test_overrides_env_boolean_boolean_create(self, isolation):
env_var_exists = "OVERRIDES_ENV_FOO"
env_var_missing = "OVERRIDES_ENV_BAR"
env_config = {
"foo": {
"overrides": {
"env": {env_var_missing: {"dependencies": ["baz"]}, env_var_exists: {"skip-install": True}}
}
}
}
project_config = ProjectConfig(isolation, {"envs": env_config}, PluginManager())
expected_envs = {
"default": {"type": "virtual"},
"foo": {"type": "virtual", "skip-install": True},
}
with EnvVars({env_var_exists: "any"}):
assert project_config.envs == expected_envs
def test_overrides_env_boolean_boolean_overwrite(self, isolation):
env_var_exists = "OVERRIDES_ENV_FOO"
env_var_missing = "OVERRIDES_ENV_BAR"
env_config = {
"foo": {
"skip-install": True,
"overrides": {
"env": {env_var_missing: {"dependencies": ["baz"]}, env_var_exists: {"skip-install": False}}
},
}
}
project_config = ProjectConfig(isolation, {"envs": env_config}, PluginManager())
expected_envs = {
"default": {"type": "virtual"},
"foo": {"type": "virtual", "skip-install": False},
}
with EnvVars({env_var_exists: "any"}):
assert project_config.envs == expected_envs
def test_overrides_env_boolean_table_create(self, isolation):
env_var_exists = "OVERRIDES_ENV_FOO"
env_var_missing = "OVERRIDES_ENV_BAR"
env_config = {
"foo": {
"overrides": {
"env": {
env_var_missing: {"dependencies": ["baz"]},
env_var_exists: {"skip-install": [{"value": True}]},
}
}
}
}
project_config = ProjectConfig(isolation, {"envs": env_config}, PluginManager())
expected_envs = {
"default": {"type": "virtual"},
"foo": {"type": "virtual", "skip-install": True},
}
with EnvVars({env_var_exists: "any"}):
assert project_config.envs == expected_envs
def test_overrides_env_boolean_table_overwrite(self, isolation):
env_var_exists = "OVERRIDES_ENV_FOO"
env_var_missing = "OVERRIDES_ENV_BAR"
env_config = {
"foo": {
"skip-install": True,
"overrides": {
"env": {
env_var_missing: {"dependencies": ["baz"]},
env_var_exists: {"skip-install": [{"value": False}]},
}
},
}
}
project_config = ProjectConfig(isolation, {"envs": env_config}, PluginManager())
expected_envs = {
"default": {"type": "virtual"},
"foo": {"type": "virtual", "skip-install": False},
}
with EnvVars({env_var_exists: "any"}):
assert project_config.envs == expected_envs
def test_overrides_env_boolean_conditional(self, isolation):
env_var_exists = "OVERRIDES_ENV_FOO"
env_var_missing = "OVERRIDES_ENV_BAR"
env_config = {
"foo": {
"overrides": {
"env": {
env_var_missing: {"dependencies": ["baz"]},
env_var_exists: {"skip-install": [{"value": True, "if": ["foo"]}]},
}
}
}
}
project_config = ProjectConfig(isolation, {"envs": env_config}, PluginManager())
expected_envs = {
"default": {"type": "virtual"},
"foo": {"type": "virtual", "skip-install": True},
}
with EnvVars({env_var_exists: "foo"}):
assert project_config.envs == expected_envs
def test_overrides_name_boolean_boolean_create(self, isolation):
env_config = {
"foo": {
"matrix": [{"version": ["9000"]}, {"feature": ["bar"]}],
"overrides": {"name": {"bar$": {"skip-install": True}}},
}
}
project_config = ProjectConfig(isolation, {"envs": env_config}, PluginManager())
expected_envs = {
"default": {"type": "virtual"},
"foo.9000": {"type": "virtual"},
"foo.bar": {"type": "virtual", "skip-install": True},
}
assert project_config.envs == expected_envs
def test_overrides_name_boolean_boolean_overwrite(self, isolation):
env_config = {
"foo": {
"skip-install": True,
"matrix": [{"version": ["9000"]}, {"feature": ["bar"]}],
"overrides": {"name": {"bar$": {"skip-install": False}}},
}
}
project_config = ProjectConfig(isolation, {"envs": env_config}, PluginManager())
expected_envs = {
"default": {"type": "virtual"},
"foo.9000": {"type": "virtual", "skip-install": True},
"foo.bar": {"type": "virtual", "skip-install": False},
}
assert project_config.envs == expected_envs
def test_overrides_name_boolean_table_create(self, isolation):
env_config = {
"foo": {
"matrix": [{"version": ["9000"]}, {"feature": ["bar"]}],
"overrides": {"name": {"bar$": {"skip-install": [{"value": True}]}}},
}
}
project_config = ProjectConfig(isolation, {"envs": env_config}, PluginManager())
expected_envs = {
"default": {"type": "virtual"},
"foo.9000": {"type": "virtual"},
"foo.bar": {"type": "virtual", "skip-install": True},
}
assert project_config.envs == expected_envs
def test_overrides_name_boolean_table_overwrite(self, isolation):
env_config = {
"foo": {
"skip-install": True,
"matrix": [{"version": ["9000"]}, {"feature": ["bar"]}],
"overrides": {"name": {"bar$": {"skip-install": [{"value": False}]}}},
}
}
project_config = ProjectConfig(isolation, {"envs": env_config}, PluginManager())
expected_envs = {
"default": {"type": "virtual"},
"foo.9000": {"type": "virtual", "skip-install": True},
"foo.bar": {"type": "virtual", "skip-install": False},
}
assert project_config.envs == expected_envs
# Tests for source precedence
def test_overrides_name_precedence_over_matrix(self, isolation):
env_config = {
"foo": {
"skip-install": False,
"matrix": [{"version": ["9000", "42"]}, {"feature": ["bar"]}],
"overrides": {
"name": {"42$": {"skip-install": False}},
"matrix": {"version": {"skip-install": [{"value": True, "if": ["42"]}]}},
},
}
}
project_config = ProjectConfig(isolation, {"envs": env_config}, PluginManager())
expected_envs = {
"default": {"type": "virtual"},
"foo.9000": {"type": "virtual", "skip-install": False},
"foo.42": {"type": "virtual", "skip-install": False},
"foo.bar": {"type": "virtual", "skip-install": False},
}
assert project_config.envs == expected_envs
assert project_config.matrices["foo"] == construct_matrix_data("foo", env_config, {"skip-install": False})
def test_overrides_matrix_precedence_over_platform(self, isolation, current_platform):
env_config = {
"foo": {
"skip-install": False,
"matrix": [{"version": ["9000", "42"]}, {"feature": ["bar"]}],
"overrides": {
"platform": {current_platform: {"skip-install": True}},
"matrix": {"version": {"skip-install": [{"value": False, "if": ["42"]}]}},
},
}
}
project_config = ProjectConfig(isolation, {"envs": env_config}, PluginManager())
expected_envs = {
"default": {"type": "virtual"},
"foo.9000": {"type": "virtual", "skip-install": True},
"foo.42": {"type": "virtual", "skip-install": False},
"foo.bar": {"type": "virtual", "skip-install": True},
}
assert project_config.envs == expected_envs
assert project_config.matrices["foo"] == construct_matrix_data("foo", env_config, {"skip-install": True})
def test_overrides_matrix_precedence_over_env(self, isolation):
env_var = "OVERRIDES_ENV_FOO"
env_config = {
"foo": {
"skip-install": False,
"matrix": [{"version": ["9000", "42"]}, {"feature": ["bar"]}],
"overrides": {
"env": {env_var: {"skip-install": True}},
"matrix": {"version": {"skip-install": [{"value": False, "if": ["42"]}]}},
},
}
}
project_config = ProjectConfig(isolation, {"envs": env_config}, PluginManager())
expected_envs = {
"default": {"type": "virtual"},
"foo.9000": {"type": "virtual", "skip-install": True},
"foo.42": {"type": "virtual", "skip-install": False},
"foo.bar": {"type": "virtual", "skip-install": True},
}
with EnvVars({env_var: "any"}):
assert project_config.envs == expected_envs
assert project_config.matrices["foo"] == construct_matrix_data("foo", env_config, {"skip-install": True})
def test_overrides_env_precedence_over_platform(self, isolation, current_platform):
env_var = "OVERRIDES_ENV_FOO"
env_config = {
"foo": {
"overrides": {
"platform": {current_platform: {"skip-install": True}},
"env": {env_var: {"skip-install": [{"value": False, "if": ["foo"]}]}},
},
}
}
project_config = ProjectConfig(isolation, {"envs": env_config}, PluginManager())
expected_envs = {
"default": {"type": "virtual"},
"foo": {"type": "virtual", "skip-install": False},
}
with EnvVars({env_var: "foo"}):
assert project_config.envs == expected_envs
# Test for options defined by environment plugins
def test_overrides_for_environment_plugins(self, isolation, current_platform):
env_var = "OVERRIDES_ENV_FOO"
env_config = {
"foo": {
"matrix": [{"version": ["9000"]}, {"feature": ["bar"]}],
"overrides": {
"platform": {current_platform: {"foo": True}},
"env": {env_var: {"bar": [{"value": "foobar", "if": ["foo"]}]}},
"matrix": {"version": {"baz": "BAR=ok"}},
},
}
}
project_config = ProjectConfig(isolation, {"envs": env_config}, PluginManager())
expected_envs = {
"default": {"type": "virtual"},
"foo.9000": {"type": "virtual"},
"foo.bar": {"type": "virtual"},
}
with EnvVars({env_var: "foo"}):
assert project_config.envs == expected_envs
assert project_config.matrices["foo"] == construct_matrix_data("foo", env_config)
project_config.finalize_env_overrides({"foo": bool, "bar": str, "baz": dict})
expected_envs = {
"default": {"type": "virtual"},
"foo.9000": {"type": "virtual", "foo": True, "bar": "foobar", "baz": {"BAR": "ok"}},
"foo.bar": {"type": "virtual", "foo": True, "bar": "foobar"},
}
assert project_config.envs == expected_envs
assert project_config.matrices["foo"] == construct_matrix_data("foo", env_config)
# Test environment collectors
def test_environment_collector_finalize_config(self, helpers, temp_dir):
file_path = temp_dir / DEFAULT_CUSTOM_SCRIPT
file_path.write_text(
helpers.dedent(
"""
from hatch.env.collectors.plugin.interface import EnvironmentCollectorInterface
class CustomHook(EnvironmentCollectorInterface):
def finalize_config(self, config):
config['default']['type'] = 'foo'
"""
)
)
env_config = {
"foo": {
"matrix": [{"version": ["9000", "42"]}, {"feature": ["bar"]}],
"overrides": {"matrix": {"version": {"type": {"value": "baz", "if": ["42"]}}}},
}
}
project_config = ProjectConfig(
temp_dir, {"envs": env_config, "env": {"collectors": {"custom": {}}}}, PluginManager()
)
expected_envs = {
"default": {"type": "foo"},
"foo.9000": {"type": "foo"},
"foo.42": {"type": "baz"},
"foo.bar": {"type": "foo"},
}
with temp_dir.as_cwd():
assert project_config.envs == expected_envs
assert project_config.matrices["foo"] == construct_matrix_data("foo", env_config, {"type": "foo"})
def test_environment_collector_finalize_environments(self, helpers, temp_dir):
file_path = temp_dir / DEFAULT_CUSTOM_SCRIPT
file_path.write_text(
helpers.dedent(
"""
from hatch.env.collectors.plugin.interface import EnvironmentCollectorInterface
class CustomHook(EnvironmentCollectorInterface):
def finalize_environments(self, config):
config['foo.42']['type'] = 'foo'
"""
)
)
env_config = {
"foo": {
"matrix": [{"version": ["9000", "42"]}, {"feature": ["bar"]}],
"overrides": {"matrix": {"version": {"type": {"value": "baz", "if": ["42"]}}}},
}
}
project_config = ProjectConfig(
temp_dir, {"envs": env_config, "env": {"collectors": {"custom": {}}}}, PluginManager()
)
expected_envs = {
"default": {"type": "virtual"},
"foo.9000": {"type": "virtual"},
"foo.42": {"type": "foo"},
"foo.bar": {"type": "virtual"},
}
with temp_dir.as_cwd():
assert project_config.envs == expected_envs
assert project_config.matrices["foo"] == construct_matrix_data("foo", env_config)
@pytest.mark.parametrize("option", WORKSPACE_OPTIONS)
def test_overrides_matrix_workspace_invalid_type(self, isolation, option):
with pytest.raises(
TypeError,
match=f"Field `tool.hatch.envs.foo.overrides.matrix.version.{option}` must be a table",
):
_ = ProjectConfig(
isolation,
{
"envs": {
"foo": {"matrix": [{"version": ["9000"]}], "overrides": {"matrix": {"version": {option: 9000}}}}
}
},
PluginManager(),
).envs
@pytest.mark.parametrize("option", WORKSPACE_OPTIONS)
def test_overrides_matrix_workspace_members_append(self, isolation, option):
env_config = {
"foo": {
option: {"members": ["packages/core"]},
"matrix": [{"version": ["9000"]}, {"feature": ["bar"]}],
"overrides": {"matrix": {"version": {option: {"members": ["packages/extra"]}}}},
}
}
project_config = ProjectConfig(isolation, {"envs": env_config}, PluginManager())
expected_envs = {
"default": {"type": "virtual"},
"foo.9000": {"type": "virtual", option: {"members": ["packages/core", "packages/extra"]}},
"foo.bar": {"type": "virtual", option: {"members": ["packages/core"]}},
}
assert project_config.envs == expected_envs
@pytest.mark.parametrize("option", WORKSPACE_OPTIONS)
def test_overrides_matrix_workspace_members_conditional(self, isolation, option):
env_config = {
"foo": {
option: {"members": ["packages/core"]},
"matrix": [{"version": ["9000", "42"]}],
"overrides": {
"matrix": {"version": {option: {"members": [{"value": "packages/special", "if": ["42"]}]}}}
},
}
}
project_config = ProjectConfig(isolation, {"envs": env_config}, PluginManager())
expected_envs = {
"default": {"type": "virtual"},
"foo.9000": {"type": "virtual", option: {"members": ["packages/core"]}},
"foo.42": {"type": "virtual", option: {"members": ["packages/core", "packages/special"]}},
}
assert project_config.envs == expected_envs
@pytest.mark.parametrize("option", WORKSPACE_OPTIONS)
def test_overrides_matrix_workspace_parallel(self, isolation, option):
env_config = {
"foo": {
option: {"members": ["packages/*"], "parallel": True},
"matrix": [{"version": ["9000", "42"]}],
"overrides": {"matrix": {"version": {option: {"parallel": {"value": False, "if": ["42"]}}}}},
}
}
project_config = ProjectConfig(isolation, {"envs": env_config}, PluginManager())
expected_envs = {
"default": {"type": "virtual"},
"foo.9000": {"type": "virtual", option: {"members": ["packages/*"], "parallel": True}},
"foo.42": {"type": "virtual", option: {"members": ["packages/*"], "parallel": False}},
}
assert project_config.envs == expected_envs
@pytest.mark.parametrize("option", WORKSPACE_OPTIONS)
def test_overrides_matrix_workspace_overwrite(self, isolation, option):
env_config = {
"foo": {
option: {"members": ["packages/core"], "parallel": True},
"matrix": [{"version": ["9000"]}],
"overrides": {"matrix": {"version": {f"set-{option}": {"members": ["packages/new"]}}}},
}
}
project_config = ProjectConfig(isolation, {"envs": env_config}, PluginManager())
expected_envs = {
"default": {"type": "virtual"},
"foo.9000": {"type": "virtual", option: {"members": ["packages/new"]}},
}
assert project_config.envs == expected_envs
class TestPublish:
def test_not_table(self, isolation):
with pytest.raises(TypeError, match="Field `tool.hatch.publish` must be a table"):
_ = ProjectConfig(isolation, {"publish": 9000}).publish
def test_config_not_table(self, isolation):
with pytest.raises(TypeError, match="Field `tool.hatch.publish.foo` must be a table"):
_ = ProjectConfig(isolation, {"publish": {"foo": 9000}}).publish
def test_default(self, isolation):
project_config = ProjectConfig(isolation, {})
assert project_config.publish == project_config.publish == {}
def test_defined(self, isolation):
project_config = ProjectConfig(isolation, {"publish": {"foo": {"bar": "baz"}}})
assert project_config.publish == {"foo": {"bar": "baz"}}
class TestScripts:
def test_not_table(self, isolation):
config = {"scripts": 9000}
project_config = ProjectConfig(isolation, config)
with pytest.raises(TypeError, match="Field `tool.hatch.scripts` must be a table"):
_ = project_config.scripts
def test_name_contains_spaces(self, isolation):
config = {"scripts": {"foo bar": []}}
project_config = ProjectConfig(isolation, config)
with pytest.raises(
ValueError, match="Script name `foo bar` in field `tool.hatch.scripts` must not contain spaces"
):
_ = project_config.scripts
def test_default(self, isolation):
project_config = ProjectConfig(isolation, {})
assert project_config.scripts == project_config.scripts == {}
def test_single_commands(self, isolation):
config = {"scripts": {"foo": "command1", "bar": "command2"}}
project_config = ProjectConfig(isolation, config)
assert project_config.scripts == {"foo": ["command1"], "bar": ["command2"]}
def test_multiple_commands(self, isolation):
config = {"scripts": {"foo": "command1", "bar": ["command3", "command2"]}}
project_config = ProjectConfig(isolation, config)
assert project_config.scripts == {"foo": ["command1"], "bar": ["command3", "command2"]}
def test_multiple_commands_not_string(self, isolation):
config = {"scripts": {"foo": [9000]}}
project_config = ProjectConfig(isolation, config)
with pytest.raises(TypeError, match="Command #1 in field `tool.hatch.scripts.foo` must be a string"):
_ = project_config.scripts
def test_config_invalid_type(self, isolation):
config = {"scripts": {"foo": 9000}}
project_config = ProjectConfig(isolation, config)
with pytest.raises(TypeError, match="Field `tool.hatch.scripts.foo` must be a string or an array of strings"):
_ = project_config.scripts
def test_command_expansion_basic(self, isolation):
config = {"scripts": {"foo": "command1", "bar": ["command3", "foo"]}}
project_config = ProjectConfig(isolation, config)
assert project_config.scripts == {"foo": ["command1"], "bar": ["command3", "command1"]}
def test_command_expansion_multiple_nested(self, isolation):
config = {
"scripts": {
"foo": "command3",
"baz": ["command5", "bar", "foo", "command1"],
"bar": ["command4", "foo", "command2"],
}
}
project_config = ProjectConfig(isolation, config)
assert project_config.scripts == {
"foo": ["command3"],
"baz": ["command5", "command4", "command3", "command2", "command3", "command1"],
"bar": ["command4", "command3", "command2"],
}
def test_command_expansion_multiple_nested_ignore_exit_code(self, isolation):
config = {
"scripts": {
"foo": "command3",
"baz": ["command5", "- bar", "foo", "command1"],
"bar": ["command4", "- foo", "command2"],
}
}
project_config = ProjectConfig(isolation, config)
assert project_config.scripts == {
"foo": ["command3"],
"baz": ["command5", "- command4", "- command3", "- command2", "command3", "command1"],
"bar": ["command4", "- command3", "command2"],
}
def test_command_expansion_modification(self, isolation):
config = {
"scripts": {
"foo": "command3",
"baz": ["command5", "bar world", "foo", "command1"],
"bar": ["command4", "foo hello", "command2"],
}
}
project_config = ProjectConfig(isolation, config)
assert project_config.scripts == {
"foo": ["command3"],
"baz": ["command5", "command4 world", "command3 hello world", "command2 world", "command3", "command1"],
"bar": ["command4", "command3 hello", "command2"],
}
def test_command_expansion_circular_inheritance(self, isolation):
config = {"scripts": {"foo": "bar", "bar": "foo"}}
project_config = ProjectConfig(isolation, config)
with pytest.raises(
ValueError, match="Circular expansion detected for field `tool.hatch.scripts`: foo -> bar -> foo"
):
_ = project_config.scripts
class TestBuild:
def test_not_table(self, isolation):
config = {"build": 9000}
project_config = ProjectConfig(isolation, config)
with pytest.raises(TypeError, match="Field `tool.hatch.build` must be a table"):
_ = project_config.build
def test_targets_not_table(self, isolation):
config = {"build": {"targets": 9000}}
project_config = ProjectConfig(isolation, config)
with pytest.raises(TypeError, match="Field `tool.hatch.build.targets` must be a table"):
_ = project_config.build.target("foo")
def test_target_not_table(self, isolation):
config = {"build": {"targets": {"foo": 9000}}}
project_config = ProjectConfig(isolation, config)
with pytest.raises(TypeError, match="Field `tool.hatch.build.targets.foo` must be a table"):
_ = project_config.build.target("foo")
def test_directory_global_not_table(self, isolation):
config = {"build": {"directory": 9000}}
project_config = ProjectConfig(isolation, config)
with pytest.raises(TypeError, match="Field `tool.hatch.build.directory` must be a string"):
_ = project_config.build.target("foo").directory
def test_directory_not_table(self, isolation):
config = {"build": {"targets": {"foo": {"directory": 9000}}}}
project_config = ProjectConfig(isolation, config)
with pytest.raises(TypeError, match="Field `tool.hatch.build.targets.foo.directory` must be a string"):
_ = project_config.build.target("foo").directory
def test_directory_default(self, isolation):
project_config = ProjectConfig(isolation, {})
assert project_config.build.target("foo").directory == DEFAULT_BUILD_DIRECTORY
def test_directory_global_correct(self, isolation):
config = {"build": {"directory": "bar"}}
project_config = ProjectConfig(isolation, config)
assert project_config.build.target("foo").directory == "bar"
def test_directory_target_override(self, isolation):
config = {"build": {"directory": "bar", "targets": {"foo": {"directory": "baz"}}}}
project_config = ProjectConfig(isolation, config)
assert project_config.build.target("foo").directory == "baz"
def test_dependencies_global_not_array(self, isolation):
config = {"build": {"dependencies": 9000}}
project_config = ProjectConfig(isolation, config)
with pytest.raises(TypeError, match="Field `tool.hatch.build.dependencies` must be an array"):
_ = project_config.build.target("foo").dependencies
def test_dependencies_global_entry_not_string(self, isolation):
config = {"build": {"dependencies": [9000]}}
project_config = ProjectConfig(isolation, config)
with pytest.raises(TypeError, match="Dependency #1 in field `tool.hatch.build.dependencies` must be a string"):
_ = project_config.build.target("foo").dependencies
def test_dependencies_not_array(self, isolation):
config = {"build": {"targets": {"foo": {"dependencies": 9000}}}}
project_config = ProjectConfig(isolation, config)
with pytest.raises(TypeError, match="Field `tool.hatch.build.targets.foo.dependencies` must be an array"):
_ = project_config.build.target("foo").dependencies
def test_dependencies_entry_not_string(self, isolation):
config = {"build": {"targets": {"foo": {"dependencies": [9000]}}}}
project_config = ProjectConfig(isolation, config)
with pytest.raises(
TypeError, match="Dependency #1 in field `tool.hatch.build.targets.foo.dependencies` must be a string"
):
_ = project_config.build.target("foo").dependencies
def test_dependencies_target_merge(self, isolation):
config = {"build": {"dependencies": ["baz"], "targets": {"foo": {"dependencies": ["bar"]}}}}
project_config = ProjectConfig(isolation, config)
assert project_config.build.target("foo").dependencies == ["baz", "bar"]
def test_hooks_global_not_table(self, isolation):
config = {"build": {"hooks": 9000}}
project_config = ProjectConfig(isolation, config)
with pytest.raises(TypeError, match="Field `tool.hatch.build.hooks` must be a table"):
_ = project_config.build.target("foo").hook_config
def test_hook_config_global_not_table(self, isolation):
config = {"build": {"hooks": {"hook1": 9000}}}
project_config = ProjectConfig(isolation, config)
with pytest.raises(TypeError, match="Field `tool.hatch.build.hooks.hook1` must be a table"):
_ = project_config.build.target("foo").hook_config
def test_hooks_not_table(self, isolation):
config = {"build": {"targets": {"foo": {"hooks": 9000}}}}
project_config = ProjectConfig(isolation, config)
with pytest.raises(TypeError, match="Field `tool.hatch.build.targets.foo.hooks` must be a table"):
_ = project_config.build.target("foo").hook_config
def test_hook_config_not_table(self, isolation):
config = {"build": {"targets": {"foo": {"hooks": {"hook1": 9000}}}}}
project_config = ProjectConfig(isolation, config)
with pytest.raises(TypeError, match="Field `tool.hatch.build.targets.foo.hooks.hook1` must be a table"):
_ = project_config.build.target("foo").hook_config
def test_hook_config_target_override(self, isolation):
config = {
"build": {
"hooks": {
"hook1": {"foo": "bar", "enable-by-default": False},
"hook2": {"foo": "bar"},
"hook3": {"foo": "bar"},
"hook4": {"foo": "bar"},
},
"targets": {
"foo": {
"hooks": {
"hook3": {"bar": "foo"},
"hook4": {"bar": "foo", "enable-by-default": False},
"hook5": {"bar": "foo"},
"hook6": {"bar": "foo", "enable-by-default": False},
},
},
},
}
}
project_config = ProjectConfig(isolation, config)
hook_config = project_config.build.target("foo").hook_config
assert hook_config == {
"hook2": {"foo": "bar"},
"hook3": {"bar": "foo"},
"hook5": {"bar": "foo"},
}
def test_hook_config_all_enabled(self, isolation):
config = {
"build": {
"hooks": {
"hook1": {"foo": "bar", "enable-by-default": False},
"hook2": {"foo": "bar"},
"hook3": {"foo": "bar"},
"hook4": {"foo": "bar"},
},
"targets": {
"foo": {
"hooks": {
"hook3": {"bar": "foo"},
"hook4": {"bar": "foo", "enable-by-default": False},
"hook5": {"bar": "foo"},
"hook6": {"bar": "foo", "enable-by-default": False},
},
},
},
}
}
project_config = ProjectConfig(isolation, config)
with EnvVars({BuildEnvVars.HOOKS_ENABLE: "true"}):
hook_config = project_config.build.target("foo").hook_config
assert hook_config == {
"hook1": {"foo": "bar", "enable-by-default": False},
"hook2": {"foo": "bar"},
"hook3": {"bar": "foo"},
"hook4": {"bar": "foo", "enable-by-default": False},
"hook5": {"bar": "foo"},
"hook6": {"bar": "foo", "enable-by-default": False},
}
def test_hook_config_all_disabled(self, isolation):
config = {
"build": {
"hooks": {
"hook1": {"foo": "bar", "enable-by-default": False},
"hook2": {"foo": "bar"},
"hook3": {"foo": "bar"},
"hook4": {"foo": "bar"},
},
"targets": {
"foo": {
"hooks": {
"hook3": {"bar": "foo"},
"hook4": {"bar": "foo", "enable-by-default": False},
"hook5": {"bar": "foo"},
"hook6": {"bar": "foo", "enable-by-default": False},
},
},
},
}
}
project_config = ProjectConfig(isolation, config)
with EnvVars({BuildEnvVars.NO_HOOKS: "true"}):
hook_config = project_config.build.target("foo").hook_config
assert not hook_config
def test_hook_config_specific_enabled(self, isolation):
config = {
"build": {
"hooks": {
"hook1": {"foo": "bar", "enable-by-default": False},
"hook2": {"foo": "bar"},
"hook3": {"foo": "bar"},
"hook4": {"foo": "bar"},
},
"targets": {
"foo": {
"hooks": {
"hook3": {"bar": "foo"},
"hook4": {"bar": "foo", "enable-by-default": False},
"hook5": {"bar": "foo"},
"hook6": {"bar": "foo", "enable-by-default": False},
},
},
},
}
}
project_config = ProjectConfig(isolation, config)
with EnvVars({f"{BuildEnvVars.HOOK_ENABLE_PREFIX}HOOK6": "true"}):
hook_config = project_config.build.target("foo").hook_config
assert hook_config == {
"hook2": {"foo": "bar"},
"hook3": {"bar": "foo"},
"hook5": {"bar": "foo"},
"hook6": {"bar": "foo", "enable-by-default": False},
}
================================================
FILE: tests/project/test_core.py
================================================
import pytest
from hatch.project.core import Project
class TestFindProjectRoot:
def test_no_project(self, temp_dir):
project = Project(temp_dir)
assert project.find_project_root() is None
@pytest.mark.parametrize("file_name", ["pyproject.toml", "setup.py"])
def test_direct(self, temp_dir, file_name):
project = Project(temp_dir)
project_file = temp_dir / file_name
project_file.touch()
assert project.find_project_root() == temp_dir
@pytest.mark.parametrize("file_name", ["pyproject.toml", "setup.py"])
def test_recurse(self, temp_dir, file_name):
project = Project(temp_dir)
project_file = temp_dir / file_name
project_file.touch()
path = temp_dir / "test"
path.mkdir()
assert project.find_project_root() == temp_dir
@pytest.mark.parametrize("file_name", ["pyproject.toml", "setup.py"])
def test_no_path(self, temp_dir, file_name):
project_file = temp_dir / file_name
project_file.touch()
path = temp_dir / "test"
project = Project(path)
assert project.find_project_root() == temp_dir
class TestLoadProjectFromConfig:
def test_no_project_no_project_dirs(self, config_file):
assert Project.from_config(config_file.model, "foo") is None
def test_project_empty_string(self, config_file, temp_dir):
config_file.model.projects[""] = str(temp_dir)
assert Project.from_config(config_file.model, "") is None
def test_project_basic_string(self, config_file, temp_dir):
config_file.model.projects = {"foo": str(temp_dir)}
project = Project.from_config(config_file.model, "foo")
assert project.chosen_name == "foo"
assert project.location == temp_dir
def test_project_complex(self, config_file, temp_dir):
config_file.model.projects = {"foo": {"location": str(temp_dir)}}
project = Project.from_config(config_file.model, "foo")
assert project.chosen_name == "foo"
assert project.location == temp_dir
def test_project_complex_null_location(self, config_file):
config_file.model.projects = {"foo": {"location": ""}}
assert Project.from_config(config_file.model, "foo") is None
def test_project_dirs(self, config_file, temp_dir):
path = temp_dir / "foo"
path.mkdir()
config_file.model.dirs.project = [str(temp_dir)]
project = Project.from_config(config_file.model, "foo")
assert project.chosen_name == "foo"
assert project.location == path
def test_project_dirs_null_dir(self, config_file):
config_file.model.dirs.project = [""]
assert Project.from_config(config_file.model, "foo") is None
def test_project_dirs_not_directory(self, config_file, temp_dir):
path = temp_dir / "foo"
path.touch()
config_file.model.dirs.project = [str(temp_dir)]
assert Project.from_config(config_file.model, "foo") is None
class TestChosenName:
def test_selected(self, temp_dir):
project = Project(temp_dir, name="foo")
assert project.chosen_name == "foo"
def test_cwd(self, temp_dir):
project = Project(temp_dir)
assert project.chosen_name is None
class TestLocation:
def test_no_project(self, temp_dir):
project = Project(temp_dir)
assert project.location == temp_dir
assert project.root is None
@pytest.mark.parametrize("file_name", ["pyproject.toml", "setup.py"])
def test_project(self, temp_dir, file_name):
project_file = temp_dir / file_name
project_file.touch()
project = Project(temp_dir)
assert project.location == temp_dir
assert project.root == temp_dir
class TestRawConfig:
def test_missing(self, temp_dir):
project = Project(temp_dir)
project.find_project_root()
assert project.raw_config == {"project": {"name": temp_dir.name}}
def test_exists(self, temp_dir):
project_file = temp_dir / "pyproject.toml"
project_file.touch()
project = Project(temp_dir)
project.find_project_root()
config = {"project": {"name": "foo"}, "bar": "baz"}
project.save_config(config)
assert project.raw_config == config
def test_exists_without_project_table(self, temp_dir):
project_file = temp_dir / "pyproject.toml"
project_file.touch()
project = Project(temp_dir)
project.find_project_root()
assert project.raw_config == {"project": {"name": temp_dir.name}}
class TestEnsureCWD:
def test_location_is_file(self, temp_dir, mocker):
script_path = temp_dir / "script.py"
script_path.touch()
project = Project(script_path)
project.find_project_root()
with temp_dir.as_cwd():
mocker.patch("hatch.utils.fs.Path.as_cwd", side_effect=Exception)
with project.ensure_cwd() as cwd:
assert cwd == temp_dir
def test_cwd_is_location(self, temp_dir, mocker):
project_file = temp_dir / "pyproject.toml"
project_file.touch()
project = Project(temp_dir)
project.find_project_root()
with temp_dir.as_cwd():
mocker.patch("hatch.utils.fs.Path.as_cwd", side_effect=Exception)
with project.ensure_cwd() as cwd:
assert cwd == temp_dir
def test_cwd_inside_location(self, temp_dir, mocker):
project_file = temp_dir / "pyproject.toml"
project_file.touch()
project = Project(temp_dir)
project.find_project_root()
subdir = temp_dir / "subdir"
subdir.mkdir()
with subdir.as_cwd():
mocker.patch("hatch.utils.fs.Path.as_cwd", side_effect=Exception)
with project.ensure_cwd() as cwd:
assert cwd == subdir
def test_cwd_outside_location(self, temp_dir):
subdir = temp_dir / "subdir"
subdir.mkdir()
project_file = subdir / "pyproject.toml"
project_file.touch()
project = Project(subdir)
project.find_project_root()
with temp_dir.as_cwd(), project.ensure_cwd() as cwd:
assert cwd == subdir
================================================
FILE: tests/project/test_frontend.py
================================================
import json
import sys
import pytest
from hatch.env.plugin.interface import EnvironmentInterface
from hatch.project.core import Project
from hatchling.builders.constants import EDITABLES_REQUIREMENT
from hatchling.metadata.spec import project_metadata_from_core_metadata
BACKENDS = [("hatchling", "hatchling.build"), ("flit-core", "flit_core.buildapi")]
class MockEnvironment(EnvironmentInterface): # no cov
PLUGIN_NAME = "mock"
def find(self):
pass
def create(self):
pass
def remove(self):
pass
def exists(self):
pass
def install_project(self):
pass
def install_project_dev_mode(self):
pass
def dependencies_in_sync(self):
pass
def sync_dependencies(self):
pass
class TestPrepareMetadata:
@pytest.mark.parametrize(
("backend_pkg", "backend_api"),
[pytest.param(backend_pkg, backend_api, id=backend_pkg) for backend_pkg, backend_api in BACKENDS],
)
def test_wheel(self, temp_dir, temp_dir_data, platform, global_application, backend_pkg, backend_api):
project_dir = temp_dir / "project"
project_dir.mkdir()
(project_dir / "pyproject.toml").write_text(
f"""\
[build-system]
requires = ["{backend_pkg}"]
build-backend = "{backend_api}"
[project]
name = "foo"
version = "9000.42"
description = "text"
"""
)
package_dir = project_dir / "foo"
package_dir.mkdir()
(package_dir / "__init__.py").touch()
project = Project(project_dir)
project.build_env = MockEnvironment(
temp_dir,
project.metadata,
"default",
project.config.envs["default"],
{},
temp_dir_data,
temp_dir_data,
platform,
0,
global_application,
)
output_dir = temp_dir / "output"
output_dir.mkdir()
script = project.build_frontend.scripts.prepare_metadata(
output_dir=str(output_dir), project_root=str(project_dir)
)
platform.check_command([sys.executable, "-c", script])
work_dir = output_dir / "work"
output = json.loads((output_dir / "output.json").read_text())
metadata_file = work_dir / output["return_val"] / "METADATA"
assert project_metadata_from_core_metadata(metadata_file.read_text()) == {
"name": "foo",
"version": "9000.42",
"description": "text",
}
@pytest.mark.parametrize(
("backend_pkg", "backend_api"),
[pytest.param(backend_pkg, backend_api, id=backend_pkg) for backend_pkg, backend_api in BACKENDS],
)
def test_editable(self, temp_dir, temp_dir_data, platform, global_application, backend_pkg, backend_api):
project_dir = temp_dir / "project"
project_dir.mkdir()
(project_dir / "pyproject.toml").write_text(
f"""\
[build-system]
requires = ["{backend_pkg}"]
build-backend = "{backend_api}"
[project]
name = "foo"
version = "9000.42"
description = "text"
"""
)
package_dir = project_dir / "foo"
package_dir.mkdir()
(package_dir / "__init__.py").touch()
project = Project(project_dir)
project.build_env = MockEnvironment(
temp_dir,
project.metadata,
"default",
project.config.envs["default"],
{},
temp_dir_data,
temp_dir_data,
platform,
0,
global_application,
)
output_dir = temp_dir / "output"
output_dir.mkdir()
script = project.build_frontend.scripts.prepare_metadata(
output_dir=str(output_dir), project_root=str(project_dir), editable=True
)
platform.check_command([sys.executable, "-c", script])
work_dir = output_dir / "work"
output = json.loads((output_dir / "output.json").read_text())
metadata_file = work_dir / output["return_val"] / "METADATA"
assert project_metadata_from_core_metadata(metadata_file.read_text()) == {
"name": "foo",
"version": "9000.42",
"description": "text",
}
class TestBuildWheel:
@pytest.mark.parametrize(
("backend_pkg", "backend_api"),
[pytest.param(backend_pkg, backend_api, id=backend_pkg) for backend_pkg, backend_api in BACKENDS],
)
def test_standard(self, temp_dir, temp_dir_data, platform, global_application, backend_pkg, backend_api):
project_dir = temp_dir / "project"
project_dir.mkdir()
(project_dir / "pyproject.toml").write_text(
f"""\
[build-system]
requires = ["{backend_pkg}"]
build-backend = "{backend_api}"
[project]
name = "foo"
version = "9000.42"
description = "text"
"""
)
package_dir = project_dir / "foo"
package_dir.mkdir()
(package_dir / "__init__.py").touch()
project = Project(project_dir)
project.build_env = MockEnvironment(
temp_dir,
project.metadata,
"default",
project.config.envs["default"],
{},
temp_dir_data,
temp_dir_data,
platform,
0,
global_application,
)
output_dir = temp_dir / "output"
output_dir.mkdir()
script = project.build_frontend.scripts.build_wheel(output_dir=str(output_dir), project_root=str(project_dir))
platform.check_command([sys.executable, "-c", script])
work_dir = output_dir / "work"
output = json.loads((output_dir / "output.json").read_text())
wheel_path = work_dir / output["return_val"]
assert wheel_path.is_file()
assert wheel_path.name.startswith("foo-9000.42-")
assert wheel_path.name.endswith(".whl")
@pytest.mark.parametrize(
("backend_pkg", "backend_api"),
[pytest.param(backend_pkg, backend_api, id=backend_pkg) for backend_pkg, backend_api in BACKENDS],
)
def test_editable(self, temp_dir, temp_dir_data, platform, global_application, backend_pkg, backend_api):
project_dir = temp_dir / "project"
project_dir.mkdir()
(project_dir / "pyproject.toml").write_text(
f"""\
[build-system]
requires = ["{backend_pkg}"]
build-backend = "{backend_api}"
[project]
name = "foo"
version = "9000.42"
description = "text"
"""
)
package_dir = project_dir / "foo"
package_dir.mkdir()
(package_dir / "__init__.py").touch()
project = Project(project_dir)
project.build_env = MockEnvironment(
temp_dir,
project.metadata,
"default",
project.config.envs["default"],
{},
temp_dir_data,
temp_dir_data,
platform,
0,
global_application,
)
output_dir = temp_dir / "output"
output_dir.mkdir()
script = project.build_frontend.scripts.build_wheel(
output_dir=str(output_dir), project_root=str(project_dir), editable=True
)
platform.check_command([sys.executable, "-c", script])
work_dir = output_dir / "work"
output = json.loads((output_dir / "output.json").read_text())
wheel_path = work_dir / output["return_val"]
assert wheel_path.is_file()
assert wheel_path.name.startswith("foo-9000.42-")
assert wheel_path.name.endswith(".whl")
class TestSourceDistribution:
@pytest.mark.parametrize(
("backend_pkg", "backend_api"),
[pytest.param(backend_pkg, backend_api, id=backend_pkg) for backend_pkg, backend_api in BACKENDS],
)
def test_standard(self, temp_dir, temp_dir_data, platform, global_application, backend_pkg, backend_api):
project_dir = temp_dir / "project"
project_dir.mkdir()
(project_dir / "pyproject.toml").write_text(
f"""\
[build-system]
requires = ["{backend_pkg}"]
build-backend = "{backend_api}"
[project]
name = "foo"
version = "9000.42"
description = "text"
"""
)
package_dir = project_dir / "foo"
package_dir.mkdir()
(package_dir / "__init__.py").touch()
project = Project(project_dir)
project.build_env = MockEnvironment(
temp_dir,
project.metadata,
"default",
project.config.envs["default"],
{},
temp_dir_data,
temp_dir_data,
platform,
0,
global_application,
)
output_dir = temp_dir / "output"
output_dir.mkdir()
script = project.build_frontend.scripts.build_sdist(output_dir=str(output_dir), project_root=str(project_dir))
platform.check_command([sys.executable, "-c", script])
work_dir = output_dir / "work"
output = json.loads((output_dir / "output.json").read_text())
sdist_path = work_dir / output["return_val"]
assert sdist_path.is_file()
assert sdist_path.name == "foo-9000.42.tar.gz"
class TestGetRequires:
@pytest.mark.parametrize(
("backend_pkg", "backend_api"),
[pytest.param(backend_pkg, backend_api, id=backend_pkg) for backend_pkg, backend_api in BACKENDS],
)
@pytest.mark.parametrize("build", ["sdist", "wheel", "editable"])
def test_default(self, temp_dir, temp_dir_data, platform, global_application, backend_pkg, backend_api, build):
project_dir = temp_dir / "project"
project_dir.mkdir()
(project_dir / "pyproject.toml").write_text(
f"""\
[build-system]
requires = ["{backend_pkg}"]
build-backend = "{backend_api}"
[project]
name = "foo"
version = "9000.42"
description = "text"
"""
)
package_dir = project_dir / "foo"
package_dir.mkdir()
(package_dir / "__init__.py").touch()
project = Project(project_dir)
project.build_env = MockEnvironment(
temp_dir,
project.metadata,
"default",
project.config.envs["default"],
{},
temp_dir_data,
temp_dir_data,
platform,
0,
global_application,
)
output_dir = temp_dir / "output"
output_dir.mkdir()
script = project.build_frontend.scripts.get_requires(
output_dir=str(output_dir), project_root=str(project_dir), build=build
)
platform.check_command([sys.executable, "-c", script])
output = json.loads((output_dir / "output.json").read_text())
assert output["return_val"] == (
[EDITABLES_REQUIREMENT] if backend_pkg == "hatchling" and build == "editable" else []
)
class TestHatchGetBuildDeps:
def test_default(self, temp_dir, temp_dir_data, platform, global_application):
project_dir = temp_dir / "project"
project_dir.mkdir()
(project_dir / "pyproject.toml").write_text(
"""\
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[project]
name = "foo"
version = "9000.42"
"""
)
package_dir = project_dir / "foo"
package_dir.mkdir()
(package_dir / "__init__.py").touch()
project = Project(project_dir)
project.build_env = MockEnvironment(
temp_dir,
project.metadata,
"default",
project.config.envs["default"],
{},
temp_dir_data,
temp_dir_data,
platform,
0,
global_application,
)
output_dir = temp_dir / "output"
output_dir.mkdir()
script = project.build_frontend.hatch.scripts.get_build_deps(
output_dir=str(output_dir), project_root=str(project_dir), targets=["sdist", "wheel"]
)
platform.check_command([sys.executable, "-c", script])
output = json.loads((output_dir / "output.json").read_text())
assert output == []
================================================
FILE: tests/project/test_utils.py
================================================
import pytest
from hatch.project.utils import parse_inline_script_metadata
class TestParseInlineScriptMetadata:
def test_no_metadata(self):
assert parse_inline_script_metadata("") is None
def test_too_many_blocks(self, helpers):
script = helpers.dedent(
"""
# /// script
# dependencies = ["foo"]
# ///
# /// script
# dependencies = ["foo"]
# ///
"""
)
with pytest.raises(ValueError, match="^Multiple inline metadata blocks found for type: script$"):
parse_inline_script_metadata(script)
def test_correct(self, helpers):
script = helpers.dedent(
"""
# /// script
# embedded-csharp = '''
# ///
# /// text
# ///
# ///
# public class MyClass { }
# '''
# ///
"""
)
assert parse_inline_script_metadata(script) == {
"embedded-csharp": helpers.dedent(
"""
///
/// text
///
///
public class MyClass { }
"""
),
}
================================================
FILE: tests/publish/__init__.py
================================================
================================================
FILE: tests/publish/plugin/__init__.py
================================================
================================================
FILE: tests/publish/plugin/test_interface.py
================================================
import pytest
from hatch.publish.plugin.interface import PublisherInterface
class MockPublisher(PublisherInterface): # no cov
PLUGIN_NAME = "mock"
def publish(self, artifacts, options):
pass
class TestDisable:
def test_default(self, isolation):
project_config = {}
plugin_config = {}
publisher = MockPublisher(None, isolation, None, project_config, plugin_config)
assert publisher.disable is publisher.disable is False
def test_project_config(self, isolation):
project_config = {"disable": True}
plugin_config = {}
publisher = MockPublisher(None, isolation, None, project_config, plugin_config)
assert publisher.disable is True
def test_project_config_not_boolean(self, isolation):
project_config = {"disable": 9000}
plugin_config = {}
publisher = MockPublisher(None, isolation, None, project_config, plugin_config)
with pytest.raises(TypeError, match="Field `tool.hatch.publish.mock.disable` must be a boolean"):
_ = publisher.disable
def test_plugin_config(self, isolation):
project_config = {}
plugin_config = {"disable": True}
publisher = MockPublisher(None, isolation, None, project_config, plugin_config)
assert publisher.disable is True
def test_plugin_config_not_boolean(self, isolation):
project_config = {}
plugin_config = {"disable": 9000}
publisher = MockPublisher(None, isolation, None, project_config, plugin_config)
with pytest.raises(TypeError, match="Global plugin configuration `publish.mock.disable` must be a boolean"):
_ = publisher.disable
def test_project_config_overrides_plugin_config(self, isolation):
project_config = {"disable": False}
plugin_config = {"disable": True}
publisher = MockPublisher(None, isolation, None, project_config, plugin_config)
assert publisher.disable is False
================================================
FILE: tests/python/__init__.py
================================================
================================================
FILE: tests/python/test_core.py
================================================
import json
import pytest
from hatch.config.constants import PythonEnvVars
from hatch.python.core import InstalledDistribution, PythonManager
from hatch.python.distributions import ORDERED_DISTRIBUTIONS
from hatch.python.resolve import custom_env_var, get_distribution
from hatch.utils.structures import EnvVars
@pytest.mark.parametrize("name", ORDERED_DISTRIBUTIONS)
def test_custom_source(platform, current_arch, name):
if platform.name == "macos" and current_arch == "arm64" and name == "3.7":
pytest.skip("No macOS 3.7 distribution for ARM")
dist = get_distribution(name)
with EnvVars({custom_env_var(PythonEnvVars.CUSTOM_SOURCE_PREFIX, name): "foo"}):
assert dist.source == "foo"
@pytest.mark.requires_internet
@pytest.mark.parametrize("name", ORDERED_DISTRIBUTIONS)
def test_installation(temp_dir, platform, current_arch, name):
if platform.name == "macos" and current_arch == "arm64" and name == "3.7":
pytest.skip("No macOS 3.7 distribution for ARM")
# Ensure the source and any parent directories get created
manager = PythonManager(temp_dir / "foo" / "bar")
dist = manager.install(name)
python_path = dist.python_path
assert python_path.is_file()
output = platform.check_command_output([python_path, "-c", "import sys;print(sys.executable)"]).strip()
assert output == str(python_path)
major_minor = name.replace("pypy", "")
output = platform.check_command_output([python_path, "--version"]).strip()
assert output.startswith(f"Python {major_minor}.")
if name.startswith("pypy"):
assert "PyPy" in output
class TestGetInstalled:
def test_source_does_not_exist(self, temp_dir):
manager = PythonManager(temp_dir / "foo")
assert manager.get_installed() == {}
def test_not_a_directory(self, temp_dir):
manager = PythonManager(temp_dir)
dist = get_distribution("3.10")
path = temp_dir / dist.name
path.touch()
assert manager.get_installed() == {}
def test_no_metadata_file(self, temp_dir):
manager = PythonManager(temp_dir)
dist = get_distribution("3.10")
path = temp_dir / dist.name
path.mkdir()
assert manager.get_installed() == {}
def test_no_python_path(self, temp_dir):
manager = PythonManager(temp_dir)
dist = get_distribution("3.10")
path = temp_dir / dist.name
path.mkdir()
metadata_file = path / InstalledDistribution.metadata_filename()
metadata_file.write_text(json.dumps({"source": dist.source}))
assert manager.get_installed() == {}
def test_order(self, temp_dir, compatible_python_distributions):
manager = PythonManager(temp_dir)
for name in compatible_python_distributions:
dist = get_distribution(name)
path = temp_dir / dist.name
path.mkdir()
metadata_file = path / InstalledDistribution.metadata_filename()
metadata_file.write_text(json.dumps({"source": dist.source}))
python_path = path / dist.python_path
python_path.parent.ensure_dir_exists()
python_path.touch()
assert tuple(manager.get_installed()) == compatible_python_distributions
================================================
FILE: tests/python/test_resolve.py
================================================
import sys
from platform import machine
import pytest
from hatch.config.constants import PythonEnvVars
from hatch.errors import PythonDistributionResolutionError, PythonDistributionUnknownError
from hatch.python.resolve import custom_env_var, get_distribution
from hatch.utils.structures import EnvVars
class TestErrors:
def test_unknown_distribution(self):
with pytest.raises(PythonDistributionUnknownError, match="Unknown distribution: foo"):
get_distribution("foo")
@pytest.mark.skipif(
not (sys.platform == "linux" and machine().lower() == "x86_64"),
reason="No variants for this platform and architecture combination",
)
def test_resolution_error(self, platform):
with (
EnvVars({"HATCH_PYTHON_VARIANT_CPU": "foo"}),
pytest.raises(
PythonDistributionResolutionError,
match=f"Could not find a default source for name='3.11' system='{platform.name}' arch=",
),
):
get_distribution("3.11")
class TestDistributionVersions:
def test_cpython_standalone(self):
url = "https://github.com/indygreg/python-build-standalone/releases/download/20230507/cpython-3.11.3%2B20230507-aarch64-unknown-linux-gnu-install_only.tar.gz"
dist = get_distribution("3.11", url)
version = dist.version
assert version.epoch == 0
assert version.base_version == "3.11.3"
def test_cpython_standalone_custom(self):
name = "3.11"
dist = get_distribution(name)
with EnvVars({custom_env_var(PythonEnvVars.CUSTOM_VERSION_PREFIX, name): "9000.42"}):
version = dist.version
assert version.epoch == 100
assert ".".join(map(str, version.release)) == "9000.42"
def test_pypy(self):
url = "https://downloads.python.org/pypy/pypy3.10-v7.3.12-aarch64.tar.bz2"
dist = get_distribution("pypy3.10", url)
version = dist.version
assert version.epoch == 0
assert version.base_version == "7.3.12"
def test_pypy_custom(self):
name = "pypy3.10"
dist = get_distribution(name)
with EnvVars({custom_env_var(PythonEnvVars.CUSTOM_VERSION_PREFIX, name): "9000.42"}):
version = dist.version
assert version.epoch == 100
assert ".".join(map(str, version.release)) == "9000.42"
class TestDistributionPaths:
def test_cpython_standalone_custom(self):
name = "3.11"
dist = get_distribution(name)
with EnvVars({custom_env_var(PythonEnvVars.CUSTOM_PATH_PREFIX, name): "foo/bar/python"}):
assert dist.python_path == "foo/bar/python"
def test_pypy_custom(self):
name = "pypy3.10"
dist = get_distribution(name)
with EnvVars({custom_env_var(PythonEnvVars.CUSTOM_PATH_PREFIX, name): "foo/bar/python"}):
assert dist.python_path == "foo/bar/python"
@pytest.mark.requires_linux
class TestVariantCPU:
def test_legacy_option(self, current_arch):
variant = "v4"
with EnvVars({"HATCH_PYTHON_VARIANT_LINUX": variant}):
dist = get_distribution("3.12")
if current_arch != "x86_64":
assert variant not in dist.source
else:
assert variant in dist.source
@pytest.mark.parametrize("variant", ["v1", "v2", "v3", "v4"])
def test_compatibility(self, variant, current_arch):
with EnvVars({"HATCH_PYTHON_VARIANT_CPU": variant}):
dist = get_distribution("3.12")
if current_arch != "x86_64" or variant == "v1":
assert variant not in dist.source
else:
assert variant in dist.source
@pytest.mark.skipif(
machine().lower() != "x86_64",
reason="No variants for this platform and architecture combination",
)
@pytest.mark.parametrize(
("variant", "flags"),
[
pytest.param(
"v1",
# Just guessing here...
"flags : fpu vme de pse tsc msr pae mce cx8 apic sep mtrr pge mca cmov pat pse36 clflush dts acpi mmx fxsr sse sse2 ht tm pbe syscall nx rdtscp lm constant_tsc arch_perfmon pebs bts rep_good nopl xtopology nonstop_tsc cpuid aperfmperf pni dtes64 monitor ds_cpl smx est tm2",
id="v1",
),
pytest.param(
"v2",
# Intel Core i7-860
"flags : fpu vme de pse tsc msr pae mce cx8 apic sep mtrr pge mca cmov pat pse36 clflush dts acpi mmx fxsr sse sse2 ht tm pbe syscall nx rdtscp lm constant_tsc arch_perfmon pebs bts rep_good nopl xtopology nonstop_tsc cpuid aperfmperf pni dtes64 monitor ds_cpl smx est tm2 ssse3 cx16 xtpr pdcm sse4_1 sse4_2 popcnt lahf_lm pti ssbd ibrs ibpb stibp dtherm ida flush_l1d",
id="v2",
),
pytest.param(
"v3",
# Intel Core i5-5300U
"flags : fpu vme de pse tsc msr pae mce cx8 apic sep mtrr pge mca cmov pat pse36 clflush dts acpi mmx fxsr sse sse2 ss ht tm pbe syscall nx pdpe1gb rdtscp lm constant_tsc arch_perfmon pebs bts rep_good nopl xtopology nonstop_tsc cpuid aperfmperf pni pclmulqdq dtes64 monitor ds_cpl vmx smx est tm2 ssse3 sdbg fma cx16 xtpr pdcm pcid sse4_1 sse4_2 x2apic movbe popcnt tsc_deadline_timer aes xsave avx f16c rdrand lahf_lm abm 3dnowprefetch cpuid_fault epb invpcid_single pti ssbd ibrs ibpb stibp tpr_shadow flexpriority ept vpid ept_ad fsgsbase tsc_adjust bmi1 hle avx2 smep bmi2 erms invpcid rtm rdseed adx smap intel_pt xsaveopt dtherm ida arat pln pts vnmi md_clear flush_l1d",
id="v3",
),
pytest.param(
"v4",
# Just guessing here...
"flags : fpu vme de pse tsc msr pae mce cx8 apic sep mtrr pge mca cmov pat pse36 clflush dts acpi mmx fxsr sse sse2 ss ht tm pbe syscall nx pdpe1gb rdtscp lm constant_tsc arch_perfmon pebs bts rep_good nopl xtopology nonstop_tsc cpuid aperfmperf pni pclmulqdq dtes64 monitor ds_cpl vmx smx est tm2 ssse3 sdbg fma cx16 xtpr pdcm pcid sse4_1 sse4_2 x2apic movbe popcnt tsc_deadline_timer aes xsave avx f16c rdrand lahf_lm abm 3dnowprefetch cpuid_fault epb invpcid_single pti ssbd ibrs ibpb stibp tpr_shadow flexpriority ept vpid ept_ad fsgsbase tsc_adjust bmi1 hle avx2 smep bmi2 erms invpcid rtm rdseed adx smap intel_pt xsaveopt dtherm ida arat pln pts vnmi md_clear flush_l1d avx512f avx512bw avx512cd avx512dq avx512vl",
id="v4",
),
],
)
def test_guess_variant(self, tmp_path, mocker, variant, flags):
cpuinfo = tmp_path / "cpuinfo"
cpuinfo.write_text(flags)
original_open = open
def mock_open(path, *args, **kwargs):
if str(path) == "/proc/cpuinfo":
return original_open(str(cpuinfo), *args, **kwargs)
return original_open(path, *args, **kwargs)
mocker.patch("builtins.open", side_effect=mock_open)
with EnvVars({"HATCH_PYTHON_VARIANT_CPU": ""}):
dist = get_distribution("3.12")
if variant == "v1":
for v in ("v1", "v2", "v3", "v4"):
assert v not in dist.source
else:
assert variant in dist.source
class TestVariantGIL:
def test_compatible(self):
with EnvVars({"HATCH_PYTHON_VARIANT_GIL": "freethreaded"}):
dist = get_distribution("3.13")
assert "freethreaded" in dist.source
def test_incompatible(self, platform):
with (
EnvVars({"HATCH_PYTHON_VARIANT_GIL": "freethreaded"}),
pytest.raises(
PythonDistributionResolutionError,
match=f"Could not find a default source for name='3.12' system='{platform.name}' arch=",
),
):
get_distribution("3.12")
================================================
FILE: tests/utils/__init__.py
================================================
================================================
FILE: tests/utils/test_auth.py
================================================
from hatch.publish.auth import AuthenticationCredentials
from hatch.utils.fs import Path
def test_pypirc(tmp_path, mocker):
# Create a fake home directory
fake_home = tmp_path / "home"
fake_home.mkdir()
# Create .pypirc in the fake home
pypirc = fake_home / ".pypirc"
pypirc.write_text("""\
[other]
username: guido
password: gat
repository: https://kaashandel.nl/
[pypi]
username: guido
password: sprscrt
""")
mocker.patch.object(Path, "home", return_value=fake_home)
credentials = AuthenticationCredentials(
app=None, cache_dir=Path("/none"), options={}, repo="", repo_config={"url": ""}
)
assert credentials.username == "guido"
assert credentials.password == "sprscrt"
credentials = AuthenticationCredentials(
app=None,
cache_dir=Path("/none"),
options={},
repo="other",
repo_config={"url": ""},
)
assert credentials.username == "guido"
assert credentials.password == "gat"
credentials = AuthenticationCredentials(
app=None,
cache_dir=Path("/none"),
options={},
repo="arbitrary",
repo_config={"url": "https://kaashandel.nl/"},
)
assert credentials.username == "guido"
assert credentials.password == "gat"
================================================
FILE: tests/utils/test_fs.py
================================================
import os
import pathlib
from hatch.utils.fs import Path, temp_chdir, temp_directory
class TestPath:
def test_type(self):
expected_type = type(pathlib.Path())
assert isinstance(Path(), expected_type)
assert issubclass(Path, expected_type)
def test_resolve_relative_non_existent(self, tmp_path):
origin = os.getcwd()
os.chdir(tmp_path)
try:
expected_representation = os.path.join(tmp_path, "foo")
assert str(Path("foo").resolve()) == expected_representation
assert str(Path(".", "foo").resolve()) == expected_representation
finally:
os.chdir(origin)
def test_ensure_dir_exists(self, tmp_path):
path = Path(tmp_path, "foo")
path.ensure_dir_exists()
assert path.is_dir()
def test_ensure_parent_dir_exists(self, tmp_path):
path = Path(tmp_path, "foo", "bar")
path.ensure_parent_dir_exists()
assert path.parent.is_dir()
assert not path.is_dir()
def test_as_cwd(self, tmp_path):
origin = os.getcwd()
with Path(tmp_path).as_cwd():
assert os.getcwd() == str(tmp_path)
assert os.getcwd() == origin
def test_as_cwd_env_vars(self, tmp_path):
env_var = str(self).encode().hex().upper()
origin = os.getcwd()
with Path(tmp_path).as_cwd(env_vars={env_var: "foo"}):
assert os.getcwd() == str(tmp_path)
assert os.environ.get(env_var) == "foo"
assert os.getcwd() == origin
assert env_var not in os.environ
def test_remove_file(self, tmp_path):
path = Path(tmp_path, "foo")
path.touch()
assert path.is_file()
path.remove()
assert not path.exists()
def test_remove_directory(self, tmp_path):
path = Path(tmp_path, "foo")
path.mkdir()
assert path.is_dir()
path.remove()
assert not path.exists()
def test_remove_non_existent(self, tmp_path):
path = Path(tmp_path, "foo")
assert not path.exists()
path.remove()
assert not path.exists()
def test_temp_hide_file(self, tmp_path):
path = Path(tmp_path, "foo")
path.touch()
with path.temp_hide() as temp_path:
assert not path.exists()
assert temp_path.is_file()
assert path.is_file()
assert not temp_path.exists()
def test_temp_hide_dir(self, tmp_path):
path = Path(tmp_path, "foo")
path.mkdir()
with path.temp_hide() as temp_path:
assert not path.exists()
assert temp_path.is_dir()
assert path.is_dir()
assert not temp_path.exists()
def test_temp_hide_non_existent(self, tmp_path):
path = Path(tmp_path, "foo")
with path.temp_hide() as temp_path:
assert not path.exists()
assert not temp_path.exists()
assert not path.exists()
assert not temp_path.exists()
def test_temp_directory():
with temp_directory() as temp_dir:
assert isinstance(temp_dir, Path)
assert temp_dir.is_dir()
assert not temp_dir.exists()
def test_temp_chdir():
origin = os.getcwd()
with temp_chdir() as temp_dir:
assert isinstance(temp_dir, Path)
assert temp_dir.is_dir()
assert os.getcwd() == str(temp_dir)
assert os.getcwd() == origin
assert not temp_dir.exists()
================================================
FILE: tests/utils/test_platform.py
================================================
import os
import stat
import pytest
from hatch.utils.fs import Path
from hatch.utils.platform import Platform
from hatch.utils.structures import EnvVars
@pytest.mark.requires_windows
class TestWindows:
def test_tag(self):
assert Platform().windows is True
def test_default_shell(self):
assert Platform().default_shell == os.environ.get("COMSPEC", "cmd")
def test_format_for_subprocess_list(self):
assert Platform().format_for_subprocess(["foo", "bar"], shell=False) == ["foo", "bar"]
def test_format_for_subprocess_list_shell(self):
assert Platform().format_for_subprocess(["foo", "bar"], shell=True) == ["foo", "bar"]
def test_format_for_subprocess_string(self):
assert Platform().format_for_subprocess("foo bar", shell=False) == "foo bar"
def test_format_for_subprocess_string_shell(self):
assert Platform().format_for_subprocess("foo bar", shell=True) == "foo bar"
def test_home(self):
platform = Platform()
assert platform.home == platform.home == Path(os.path.expanduser("~"))
def test_populate_default_popen_kwargs_executable(self):
platform = Platform()
kwargs = {}
platform.populate_default_popen_kwargs(kwargs, shell=True)
assert not kwargs
kwargs["executable"] = "foo"
platform.populate_default_popen_kwargs(kwargs, shell=True)
assert kwargs["executable"] == "foo"
@pytest.mark.requires_macos
class TestMacOS:
def test_tag(self):
assert Platform().macos is True
def test_default_shell(self):
assert Platform().default_shell == os.environ.get("SHELL", "bash")
def test_format_for_subprocess_list(self):
assert Platform().format_for_subprocess(["foo", "bar"], shell=False) == ["foo", "bar"]
def test_format_for_subprocess_list_shell(self):
assert Platform().format_for_subprocess(["foo", "bar"], shell=True) == ["foo", "bar"]
def test_format_for_subprocess_string(self):
assert Platform().format_for_subprocess("foo bar", shell=False) == ["foo", "bar"]
def test_format_for_subprocess_string_shell(self):
assert Platform().format_for_subprocess("foo bar", shell=True) == "foo bar"
def test_home(self):
platform = Platform()
assert platform.home == platform.home == Path(os.path.expanduser("~"))
def test_populate_default_popen_kwargs_executable(self, temp_dir):
new_path = f"{os.environ.get('PATH', '')}{os.pathsep}{temp_dir}".strip(os.pathsep)
executable = temp_dir / "sh"
executable.touch()
executable.chmod(executable.stat().st_mode | stat.S_IEXEC)
kwargs = {}
platform = Platform()
with EnvVars({"DYLD_FOO": "bar", "PATH": new_path}):
platform.populate_default_popen_kwargs(kwargs, shell=True)
assert kwargs["executable"] == str(executable)
@pytest.mark.requires_linux
class TestLinux:
def test_tag(self):
assert Platform().linux is True
def test_default_shell(self):
assert Platform().default_shell == os.environ.get("SHELL", "bash")
def test_format_for_subprocess_list(self):
assert Platform().format_for_subprocess(["foo", "bar"], shell=False) == ["foo", "bar"]
def test_format_for_subprocess_list_shell(self):
assert Platform().format_for_subprocess(["foo", "bar"], shell=True) == ["foo", "bar"]
def test_format_for_subprocess_string(self):
assert Platform().format_for_subprocess("foo bar", shell=False) == ["foo", "bar"]
def test_format_for_subprocess_string_shell(self):
assert Platform().format_for_subprocess("foo bar", shell=True) == "foo bar"
def test_home(self):
platform = Platform()
assert platform.home == platform.home == Path(os.path.expanduser("~"))
def test_populate_default_popen_kwargs_executable(self):
platform = Platform()
kwargs = {}
platform.populate_default_popen_kwargs(kwargs, shell=True)
assert not kwargs
kwargs["executable"] = "foo"
platform.populate_default_popen_kwargs(kwargs, shell=True)
assert kwargs["executable"] == "foo"
================================================
FILE: tests/utils/test_runner.py
================================================
from __future__ import annotations
import pytest
from hatch.utils.runner import parse_matrix_variables, select_environments
class TestParseMatrixVariables:
def test_empty(self):
assert parse_matrix_variables(()) == {}
def test_single(self):
assert parse_matrix_variables(("py=3.9",)) == {"python": {"3.9"}}
def test_multiple(self):
assert parse_matrix_variables(("py=3.9", "version=42")) == {"python": {"3.9"}, "version": {"42"}}
def test_no_values(self):
assert parse_matrix_variables(("py=3.9", "version")) == {"python": {"3.9"}, "version": set()}
def test_duplicate(self):
with pytest.raises(ValueError): # noqa: PT011
parse_matrix_variables(("py=3.9", "py=3.10"))
class TestSelectEnvironments:
def test_empty(self):
assert select_environments({}, {}, {}) == []
def test_no_filters(self):
environments = {
"a": {"python": "3.9", "feature": "foo"},
"b": {"python": "3.10", "feature": "bar"},
"c": {"python": "3.11", "feature": "baz"},
"d": {"python": "3.11", "feature": "foo", "version": "42"},
}
assert select_environments(environments, {}, {}) == ["a", "b", "c", "d"]
def test_include_any(self):
environments = {
"a": {"python": "3.9", "feature": "foo"},
"b": {"python": "3.10", "feature": "bar"},
"c": {"python": "3.11", "feature": "baz"},
"d": {"python": "3.11", "feature": "foo", "version": "42"},
}
assert select_environments(environments, {"version": set()}, {}) == ["d"]
def test_include_specific(self):
environments = {
"a": {"python": "3.9", "feature": "foo"},
"b": {"python": "3.10", "feature": "bar"},
"c": {"python": "3.11", "feature": "baz"},
"d": {"python": "3.11", "feature": "foo", "version": "42"},
}
assert select_environments(environments, {"python": {"3.11"}}, {}) == ["c", "d"]
def test_include_multiple(self):
environments = {
"a": {"python": "3.9", "feature": "foo"},
"b": {"python": "3.10", "feature": "bar"},
"c": {"python": "3.11", "feature": "baz"},
"d": {"python": "3.11", "feature": "foo", "version": "42"},
}
assert select_environments(environments, {"python": {"3.11"}, "feature": {"baz"}}, {}) == ["c"]
def test_exclude_any(self):
environments = {
"a": {"python": "3.9", "feature": "foo"},
"b": {"python": "3.10", "feature": "bar"},
"c": {"python": "3.11", "feature": "baz"},
"d": {"python": "3.11", "feature": "foo", "version": "42"},
}
assert select_environments(environments, {}, {"version": set()}) == ["a", "b", "c"]
def test_exclude_specific(self):
environments = {
"a": {"python": "3.9", "feature": "foo"},
"b": {"python": "3.10", "feature": "bar"},
"c": {"python": "3.11", "feature": "baz"},
"d": {"python": "3.11", "feature": "foo", "version": "42"},
}
assert select_environments(environments, {}, {"python": {"3.11"}}) == ["a", "b"]
def test_exclude_multiple(self):
environments = {
"a": {"python": "3.9", "feature": "foo"},
"b": {"python": "3.10", "feature": "bar"},
"c": {"python": "3.11", "feature": "baz"},
"d": {"python": "3.11", "feature": "foo", "version": "42"},
}
assert select_environments(environments, {}, {"python": {"3.11"}, "feature": {"baz"}}) == ["a", "b"]
def test_include_and_exclude(self):
environments = {
"a": {"python": "3.9", "feature": "foo"},
"b": {"python": "3.10", "feature": "bar"},
"c": {"python": "3.11", "feature": "baz"},
"d": {"python": "3.11", "feature": "foo", "version": "42"},
}
assert select_environments(environments, {"python": {"3.11"}}, {"feature": {"baz"}}) == ["d"]
================================================
FILE: tests/utils/test_structures.py
================================================
import os
from hatch.utils.structures import EnvVars
def get_random_name():
return os.urandom(16).hex().upper()
class TestEnvVars:
def test_restoration(self):
num_env_vars = len(os.environ)
with EnvVars():
os.environ.clear()
assert len(os.environ) == num_env_vars
def test_set(self):
env_var = get_random_name()
with EnvVars({env_var: "foo"}):
assert os.environ.get(env_var) == "foo"
assert env_var not in os.environ
def test_include(self):
env_var = get_random_name()
pattern = f"{env_var[:-2]}*"
with EnvVars({env_var: "foo"}):
num_env_vars = len(os.environ)
with EnvVars(include=[get_random_name(), pattern]):
assert len(os.environ) == 1
assert os.environ.get(env_var) == "foo"
assert len(os.environ) == num_env_vars
def test_exclude(self):
env_var = get_random_name()
pattern = f"{env_var[:-2]}*"
with EnvVars({env_var: "foo"}):
with EnvVars(exclude=[get_random_name(), pattern]):
assert env_var not in os.environ
assert os.environ.get(env_var) == "foo"
def test_precedence(self):
env_var1 = get_random_name()
env_var2 = get_random_name()
pattern = f"{env_var1[:-2]}*"
with EnvVars({env_var1: "foo"}):
num_env_vars = len(os.environ)
with EnvVars({env_var2: "bar"}, include=[pattern], exclude=[pattern, env_var2]):
assert len(os.environ) == 1
assert os.environ.get(env_var2) == "bar"
assert len(os.environ) == num_env_vars
================================================
FILE: tests/venv/__init__.py
================================================
================================================
FILE: tests/venv/test_core.py
================================================
import json
import os
import re
import sys
import pytest
from hatch.utils.structures import EnvVars
from hatch.venv.core import VirtualEnv
def test_initialization_does_not_create(temp_dir, platform):
venv_dir = temp_dir / "venv"
venv = VirtualEnv(venv_dir, platform)
assert not venv.exists()
with pytest.raises(OSError, match=f"Unable to locate executables directory within: {re.escape(str(venv_dir))}"):
_ = venv.executables_directory
def test_remove_non_existent_no_error(temp_dir, platform):
venv_dir = temp_dir / "venv"
venv = VirtualEnv(venv_dir, platform)
venv.remove()
def test_creation(temp_dir, platform):
venv_dir = temp_dir / "venv"
venv = VirtualEnv(venv_dir, platform)
venv.create(sys.executable)
assert venv_dir.is_dir()
assert venv.exists()
def test_executables_directory(temp_dir, platform):
venv_dir = temp_dir / "venv"
venv = VirtualEnv(venv_dir, platform)
venv.create(sys.executable)
assert venv.executables_directory.is_dir()
for entry in venv.executables_directory.iterdir():
if entry.name.startswith("py"):
break
else: # no cov
msg = "Unable to locate Python executable"
raise AssertionError(msg)
def test_activation(temp_dir, platform):
venv_dir = temp_dir / "venv"
venv = VirtualEnv(venv_dir, platform)
venv.create(sys.executable)
with EnvVars(exclude=VirtualEnv.IGNORED_ENV_VARS):
os.environ["PATH"] = str(temp_dir)
os.environ["VIRTUAL_ENV"] = "foo"
for env_var in VirtualEnv.IGNORED_ENV_VARS:
os.environ[env_var] = "foo"
venv.activate()
assert os.environ["PATH"] == f"{venv.executables_directory}{os.pathsep}{temp_dir}"
assert os.environ["VIRTUAL_ENV"] == str(venv_dir)
for env_var in VirtualEnv.IGNORED_ENV_VARS:
assert env_var not in os.environ
venv.deactivate()
assert os.environ["PATH"] == str(temp_dir)
assert os.environ["VIRTUAL_ENV"] == "foo"
for env_var in VirtualEnv.IGNORED_ENV_VARS:
assert os.environ[env_var] == "foo"
def test_activation_path_env_var_missing(temp_dir, platform):
venv_dir = temp_dir / "venv"
venv = VirtualEnv(venv_dir, platform)
venv.create(sys.executable)
with EnvVars(exclude=VirtualEnv.IGNORED_ENV_VARS):
os.environ.pop("PATH", None)
os.environ["VIRTUAL_ENV"] = "foo"
for env_var in VirtualEnv.IGNORED_ENV_VARS:
os.environ[env_var] = "foo"
venv.activate()
assert os.environ["PATH"] == f"{venv.executables_directory}{os.pathsep}{os.defpath}"
assert os.environ["VIRTUAL_ENV"] == str(venv_dir)
for env_var in VirtualEnv.IGNORED_ENV_VARS:
assert env_var not in os.environ
venv.deactivate()
assert "PATH" not in os.environ
assert os.environ["VIRTUAL_ENV"] == "foo"
for env_var in VirtualEnv.IGNORED_ENV_VARS:
assert os.environ[env_var] == "foo"
def test_context_manager(temp_dir, platform, extract_installed_requirements):
venv_dir = temp_dir / "venv"
venv = VirtualEnv(venv_dir, platform)
venv.create(sys.executable)
with EnvVars(exclude=VirtualEnv.IGNORED_ENV_VARS):
os.environ["PATH"] = str(temp_dir)
os.environ["VIRTUAL_ENV"] = "foo"
for env_var in VirtualEnv.IGNORED_ENV_VARS:
os.environ[env_var] = "foo"
with venv:
assert os.environ["PATH"] == f"{venv.executables_directory}{os.pathsep}{temp_dir}"
assert os.environ["VIRTUAL_ENV"] == str(venv_dir)
for env_var in VirtualEnv.IGNORED_ENV_VARS:
assert env_var not in os.environ
# Run here while we have cleanup
output = platform.run_command(["pip", "freeze"], check=True, capture_output=True).stdout.decode("utf-8")
assert not extract_installed_requirements(output.splitlines())
assert os.environ["PATH"] == str(temp_dir)
assert os.environ["VIRTUAL_ENV"] == "foo"
for env_var in VirtualEnv.IGNORED_ENV_VARS:
assert os.environ[env_var] == "foo"
def test_creation_allow_system_packages(temp_dir, platform, extract_installed_requirements):
venv_dir = temp_dir / "venv"
venv = VirtualEnv(venv_dir, platform)
venv.create(sys.executable, allow_system_packages=True)
with venv:
output = platform.run_command(["pip", "freeze"], check=True, capture_output=True).stdout.decode("utf-8")
assert len(extract_installed_requirements(output.splitlines())) > 0
def test_python_data(temp_dir, platform):
venv_dir = temp_dir / "venv"
venv = VirtualEnv(venv_dir, platform)
venv.create(sys.executable)
with venv:
output = platform.run_command(
["python", "-W", "ignore", "-"],
check=True,
capture_output=True,
input=b"import json,sys;print(json.dumps([path for path in sys.path if path]))",
).stdout.decode("utf-8")
assert venv.environment is venv.environment
assert venv.sys_path is venv.sys_path
assert venv.environment["sys_platform"] == sys.platform
assert venv.sys_path == json.loads(output)
================================================
FILE: tests/venv/test_utils.py
================================================
from hatch.venv.utils import get_random_venv_name
class TestGetRandomVenvName:
def test_length(self):
assert len(get_random_venv_name()) == 4
def test_different(self):
assert get_random_venv_name() != get_random_venv_name()
================================================
FILE: tests/workspaces/__init__.py
================================================
================================================
FILE: tests/workspaces/test_config.py
================================================
class TestWorkspaceConfiguration:
def test_workspace_members_editable_install(self, temp_dir, hatch):
"""Test that workspace members are installed as editable packages."""
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]
type = "virtual"
workspace.members = ["packages/*"]
""")
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"
version = "0.1.0"
dependencies = ["requests"]
""")
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():
result = hatch("env", "create")
assert result.exit_code == 0
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.envs.default]
workspace.members = ["packages/*"]
workspace.exclude = ["packages/excluded*"]
""")
packages_dir = workspace_root / "packages"
packages_dir.mkdir()
included_dir = packages_dir / "included"
included_dir.mkdir()
(included_dir / "pyproject.toml").write_text("""
[project]
name = "included"
version = "0.1.0"
""")
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.envs.default]
workspace.members = ["packages/*"]
workspace.parallel = true
""")
packages_dir = workspace_root / "packages"
packages_dir.mkdir()
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_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.envs.default]
workspace.members = ["packages/*"]
""")
packages_dir = workspace_root / "packages"
packages_dir.mkdir()
base_dir = packages_dir / "base"
base_dir.mkdir()
(base_dir / "pyproject.toml").write_text("""
[project]
name = "base"
version = "0.1.0"
dependencies = ["requests"]
""")
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
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()
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.envs.default]
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()
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}"]
""")
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
def test_environment_specific_workspace_slices(self, temp_dir, hatch):
"""Test different workspace slices per environment."""
workspace_root = temp_dir / "workspace"
workspace_root.mkdir()
workspace_config = workspace_root / "pyproject.toml"
workspace_config.write_text("""
[project]
name = "workspace-root"
version = "0.1.0"
[tool.hatch.envs.unit-tests]
workspace.members = ["packages/core", "packages/utils"]
scripts.test = "pytest tests/unit"
[tool.hatch.envs.integration-tests]
workspace.members = ["packages/*"]
scripts.test = "pytest tests/integration"
""")
packages_dir = workspace_root / "packages"
packages_dir.mkdir()
for pkg in ["core", "utils", "extras"]:
pkg_dir = packages_dir / pkg
pkg_dir.mkdir()
(pkg_dir / "pyproject.toml").write_text(f"""
[project]
name = "{pkg}"
version = "0.1.0"
""")
with workspace_root.as_cwd():
result = hatch("env", "create", "unit-tests")
assert result.exit_code == 0
result = hatch("env", "create", "integration-tests")
assert result.exit_code == 0
def test_workspace_test_matrices(self, temp_dir, hatch):
"""Test workspace configuration with test matrices."""
workspace_root = temp_dir / "workspace"
workspace_root.mkdir()
workspace_config = workspace_root / "pyproject.toml"
workspace_config.write_text("""
[project]
name = "workspace-root"
version = "0.1.0"
[[tool.hatch.envs.test.matrix]]
python = ["3.9", "3.10"]
[tool.hatch.envs.test]
workspace.members = ["packages/*"]
dependencies = ["pytest", "coverage"]
scripts.test = "pytest {args}"
[[tool.hatch.envs.test-core.matrix]]
python = ["3.9", "3.10"]
[tool.hatch.envs.test-core]
workspace.members = ["packages/core"]
dependencies = ["pytest", "coverage"]
scripts.test = "pytest packages/core/tests {args}"
""")
packages_dir = workspace_root / "packages"
packages_dir.mkdir()
for pkg in ["core", "utils"]:
pkg_dir = packages_dir / pkg
pkg_dir.mkdir()
(pkg_dir / "pyproject.toml").write_text(f"""
[project]
name = "{pkg}"
version = "0.1.0"
""")
with workspace_root.as_cwd():
result = hatch("env", "show", "test")
assert result.exit_code == 0
result = hatch("env", "show", "test-core")
assert result.exit_code == 0
def test_workspace_library_with_plugins(self, temp_dir, hatch):
"""Test library with plugins workspace configuration."""
workspace_root = temp_dir / "workspace"
workspace_root.mkdir()
workspace_config = workspace_root / "pyproject.toml"
workspace_config.write_text("""
[project]
name = "library-root"
version = "0.1.0"
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.hatch.build.targets.wheel]
packages = []
[tool.hatch.envs.default]
skip-install = true
workspace.members = ["core"]
dependencies = ["pytest"]
[tool.hatch.envs.full]
skip-install = true
workspace.members = [
"core",
"plugins/database",
"plugins/cache",
"plugins/auth"
]
dependencies = ["pytest", "pytest-asyncio"]
[tool.hatch.envs.database-only]
skip-install = true
workspace.members = [
"core",
{path = "plugins/database", features = ["postgresql", "mysql"]}
]
""")
# Create core package with source
core_dir = workspace_root / "core"
core_dir.mkdir()
(core_dir / "pyproject.toml").write_text("""
[project]
name = "core"
version = "0.1.0"
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.hatch.build.targets.wheel]
packages = ["core"]
""")
core_src = core_dir / "core"
core_src.mkdir()
(core_src / "__init__.py").write_text("")
# Create plugins with source
plugins_dir = workspace_root / "plugins"
plugins_dir.mkdir()
for plugin in ["database", "cache", "auth"]:
plugin_dir = plugins_dir / plugin
plugin_dir.mkdir()
(plugin_dir / "pyproject.toml").write_text(f"""
[project]
name = "{plugin}"
version = "0.1.0"
dependencies = ["core"]
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.hatch.build.targets.wheel]
packages = ["{plugin}"]
[project.optional-dependencies]
postgresql = ["requests"]
mysql = ["click"]
""")
plugin_src = plugin_dir / plugin
plugin_src.mkdir()
(plugin_src / "__init__.py").write_text("")
with workspace_root.as_cwd():
result = hatch("env", "create", "full")
assert result.exit_code == 0
result = hatch("env", "create", "database-only")
assert result.exit_code == 0
def test_workspace_multi_service_application(self, temp_dir, hatch):
"""Test multi-service application workspace configuration."""
workspace_root = temp_dir / "workspace"
workspace_root.mkdir()
workspace_config = workspace_root / "pyproject.toml"
workspace_config.write_text("""
[project]
name = "microservices-root"
version = "0.1.0"
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.hatch.build.targets.wheel]
packages = []
[tool.hatch.envs.default]
skip-install = true
workspace.members = ["shared"]
dependencies = ["pytest", "requests"]
[tool.hatch.envs.api]
skip-install = true
workspace.members = [
"shared",
{path = "services/api", features = ["dev"]}
]
dependencies = ["fastapi", "uvicorn"]
scripts.dev = "uvicorn services.api.main:app --reload"
[tool.hatch.envs.worker]
skip-install = true
workspace.members = [
"shared",
{path = "services/worker", features = ["dev"]}
]
dependencies = ["celery", "redis"]
scripts.dev = "celery -A services.worker.tasks worker --loglevel=info"
[tool.hatch.envs.integration]
skip-install = true
workspace.members = [
"shared",
"services/api",
"services/worker",
"services/frontend"
]
dependencies = ["pytest", "httpx", "docker"]
scripts.test = "pytest tests/integration {args}"
""")
# Create shared package with source
shared_dir = workspace_root / "shared"
shared_dir.mkdir()
(shared_dir / "pyproject.toml").write_text("""
[project]
name = "shared"
version = "0.1.0"
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.hatch.build.targets.wheel]
packages = ["shared"]
""")
shared_src = shared_dir / "shared"
shared_src.mkdir()
(shared_src / "__init__.py").write_text("")
# Create services with source
services_dir = workspace_root / "services"
services_dir.mkdir()
for service in ["api", "worker", "frontend"]:
service_dir = services_dir / service
service_dir.mkdir()
(service_dir / "pyproject.toml").write_text(f"""
[project]
name = "{service}"
version = "0.1.0"
dependencies = ["shared"]
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.hatch.build.targets.wheel]
packages = ["{service}"]
[project.optional-dependencies]
dev = ["black", "ruff"]
""")
service_src = service_dir / service
service_src.mkdir()
(service_src / "__init__.py").write_text("")
with workspace_root.as_cwd():
result = hatch("env", "create", "api")
assert result.exit_code == 0
result = hatch("env", "create", "worker")
assert result.exit_code == 0
result = hatch("env", "create", "integration")
assert result.exit_code == 0
def test_workspace_documentation_generation(self, temp_dir, hatch):
"""Test documentation generation workspace configuration."""
workspace_root = temp_dir / "workspace"
workspace_root.mkdir()
workspace_config = workspace_root / "pyproject.toml"
workspace_config.write_text("""
[project]
name = "docs-root"
version = "0.1.0"
[tool.hatch.envs.docs]
workspace.members = [
{path = "packages/core", features = ["docs"]},
{path = "packages/cli", features = ["docs"]},
{path = "packages/plugins", features = ["docs"]}
]
dependencies = [
"mkdocs",
"mkdocs-material",
"mkdocstrings[python]"
]
scripts.serve = "mkdocs serve"
scripts.build = "mkdocs build"
[tool.hatch.envs.docs-api-only]
workspace.members = [
{path = "packages/core", features = ["docs"]}
]
template = "docs"
scripts.serve = "mkdocs serve --config-file mkdocs-api.yml"
""")
packages_dir = workspace_root / "packages"
packages_dir.mkdir()
for pkg in ["core", "cli", "plugins"]:
pkg_dir = packages_dir / pkg
pkg_dir.mkdir()
(pkg_dir / "pyproject.toml").write_text(f"""
[project]
name = "{pkg}"
version = "0.1.0"
[project.optional-dependencies]
docs = ["sphinx", "sphinx-rtd-theme"]
""")
with workspace_root.as_cwd():
result = hatch("env", "create", "docs")
assert result.exit_code == 0
result = hatch("env", "create", "docs-api-only")
assert result.exit_code == 0
def test_workspace_development_workflow(self, temp_dir, hatch, monkeypatch):
"""Test development workflow workspace configuration."""
workspace_root = temp_dir / "workspace"
workspace_root.mkdir()
workspace_config = workspace_root / "pyproject.toml"
workspace_config.write_text("""
[project]
name = "dev-workflow-root"
version = "0.1.0"
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.hatch.build.targets.wheel]
packages = []
[tool.hatch.envs.dev]
skip-install = true
workspace.members = ["packages/*"]
workspace.parallel = true
dependencies = [
"pytest",
"black",
"ruff",
"mypy",
"pre-commit"
]
scripts.setup = "pre-commit install"
scripts.test = "pytest {args}"
scripts.lint = ["ruff check .", "black --check .", "mypy ."]
scripts.fmt = ["ruff check --fix .", "black ."]
[tool.hatch.envs.feature]
skip-install = true
template = "dev"
workspace.members = [
"packages/core",
"packages/{env:FEATURE_PACKAGE}"
]
scripts.test = "pytest packages/{env:FEATURE_PACKAGE}/tests {args}"
[[tool.hatch.envs.release.matrix]]
package = ["core", "utils", "cli"]
[tool.hatch.envs.release]
detached = true
skip-install = true
workspace.members = ["packages/{matrix:package}"]
dependencies = ["build", "twine"]
scripts.build = "python -m build packages/{matrix:package}"
scripts.publish = "twine upload packages/{matrix:package}/dist/*"
""")
packages_dir = workspace_root / "packages"
packages_dir.mkdir()
for pkg in ["core", "utils", "cli"]:
pkg_dir = packages_dir / pkg
pkg_dir.mkdir()
(pkg_dir / "pyproject.toml").write_text(f"""
[project]
name = "{pkg}"
version = "0.1.0"
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.hatch.build.targets.wheel]
packages = ["{pkg}"]
""")
pkg_src = pkg_dir / pkg
pkg_src.mkdir()
(pkg_src / "__init__.py").write_text("")
with workspace_root.as_cwd():
result = hatch("env", "create", "dev")
assert result.exit_code == 0
# Test feature environment with environment variable
monkeypatch.setenv("FEATURE_PACKAGE", "utils")
result = hatch("env", "create", "feature")
assert result.exit_code == 0
result = hatch("env", "create", "release")
assert result.exit_code == 0
def test_workspace_overrides_matrix_conditional_members(self, temp_dir, hatch):
"""Test workspace members added conditionally via matrix overrides."""
workspace_root = temp_dir / "workspace"
workspace_root.mkdir()
workspace_config = workspace_root / "pyproject.toml"
workspace_config.write_text("""
[project]
name = "workspace-root"
version = "0.1.0"
[[tool.hatch.envs.test.matrix]]
python = ["3.9", "3.11"]
[tool.hatch.envs.test]
workspace.members = ["packages/core"]
[tool.hatch.envs.test.overrides]
matrix.python.workspace.members = [
{ value = "packages/py311-only", if = ["3.11"] }
]
""")
packages_dir = workspace_root / "packages"
packages_dir.mkdir()
# Core package (always included)
core_dir = packages_dir / "core"
core_dir.mkdir()
(core_dir / "pyproject.toml").write_text("""
[project]
name = "core"
version = "0.1.0"
""")
# Python 3.11+ only package
py311_dir = packages_dir / "py311-only"
py311_dir.mkdir()
(py311_dir / "pyproject.toml").write_text("""
[project]
name = "py311-only"
version = "0.1.0"
""")
with workspace_root.as_cwd():
# Both environments should be created
result = hatch("env", "create", "test")
assert result.exit_code == 0
def test_workspace_overrides_platform_conditional_members(self, temp_dir, hatch):
"""Test workspace members added conditionally via platform overrides."""
workspace_root = temp_dir / "workspace"
workspace_root.mkdir()
workspace_config = workspace_root / "pyproject.toml"
workspace_config.write_text("""
[project]
name = "workspace-root"
version = "0.1.0"
[tool.hatch.envs.default]
workspace.members = ["packages/core"]
[tool.hatch.envs.default.overrides]
platform.linux.workspace.members = ["packages/linux-specific"]
platform.windows.workspace.members = ["packages/windows-specific"]
""")
packages_dir = workspace_root / "packages"
packages_dir.mkdir()
for pkg in ["core", "linux-specific", "windows-specific"]:
pkg_dir = packages_dir / pkg
pkg_dir.mkdir()
(pkg_dir / "pyproject.toml").write_text(f"""
[project]
name = "{pkg}"
version = "0.1.0"
""")
with workspace_root.as_cwd():
result = hatch("env", "create")
assert result.exit_code == 0
def test_workspace_overrides_combined_conditions(self, temp_dir, hatch):
"""Test workspace members with combined matrix and platform conditions."""
workspace_root = temp_dir / "workspace"
workspace_root.mkdir()
workspace_config = workspace_root / "pyproject.toml"
workspace_config.write_text("""
[project]
name = "workspace-root"
version = "0.1.0"
[[tool.hatch.envs.test.matrix]]
python = ["3.9", "3.11"]
[tool.hatch.envs.test]
workspace.members = ["packages/core"]
[tool.hatch.envs.test.overrides]
matrix.python.workspace.members = [
{ value = "packages/linux-py311", if = ["3.11"], platform = ["linux"] }
]
""")
packages_dir = workspace_root / "packages"
packages_dir.mkdir()
for pkg in ["core", "linux-py311"]:
pkg_dir = packages_dir / pkg
pkg_dir.mkdir()
(pkg_dir / "pyproject.toml").write_text(f"""
[project]
name = "{pkg}"
version = "0.1.0"
""")
with workspace_root.as_cwd():
result = hatch("env", "create", "test")
assert result.exit_code == 0