Skip to content

Commit

Permalink
Improve compatibility checking for environments (#1152)
Browse files Browse the repository at this point in the history
  • Loading branch information
ofek authored Dec 15, 2023
1 parent 2dd8ca1 commit 98e6238
Show file tree
Hide file tree
Showing 16 changed files with 229 additions and 52 deletions.
2 changes: 2 additions & 0 deletions docs/history/hatch.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,14 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
***Added:***

- Enable docstring formatting by default for static analysis
- Concretely state the expected API contract for the environment interface methods `find` and `check_compatibility`
- Upgrade Ruff to 0.1.8

***Fixed:***

- Ignore a project's Python requirement for environments where the project is not installed
- When not persisting config for static analysis, properly manage internal settings when Ruff's top level table already exists
- Ignore compatibility checks when environments have already been created, significantly improving performance of environment usage

## [1.8.1](https://github.com/pypa/hatch/releases/tag/hatch-v1.8.1) - 2023-12-14 ## {: #hatch-v1.8.1 }

Expand Down
9 changes: 5 additions & 4 deletions src/hatch/cli/application.py
Original file line number Diff line number Diff line change
Expand Up @@ -87,10 +87,11 @@ def prepare_internal_environment(self, env_name: str, config: dict[str, Any] | N
self.verbosity,
self.get_safe_application(),
)
try:
environment.check_compatibility()
except Exception as e: # noqa: BLE001
self.abort(f'Internal environment `{env_name}` is incompatible: {e}')
if not environment.exists():
try:
environment.check_compatibility()
except Exception as e: # noqa: BLE001
self.abort(f'Internal environment `{env_name}` is incompatible: {e}')

self.prepare_environment(environment)
return environment
Expand Down
9 changes: 5 additions & 4 deletions src/hatch/cli/build/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -94,10 +94,11 @@ def get_version_api(self): # noqa: PLR6301

with app.project.location.as_cwd(env_vars):
environment = app.get_environment()
try:
environment.check_compatibility()
except Exception as e: # noqa: BLE001
app.abort(f'Environment `{environment.name}` is incompatible: {e}')
if not environment.build_environment_exists():
try:
environment.check_compatibility()
except Exception as e: # noqa: BLE001
app.abort(f'Environment `{environment.name}` is incompatible: {e}')

for target in targets:
target_name, _, _ = target.partition(':')
Expand Down
7 changes: 3 additions & 4 deletions src/hatch/cli/env/create.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,9 @@ def create(app, env_name):
incompatible = {}
for env in environments:
environment = app.get_environment(env)
if environment.exists():
app.display_warning(f'Environment `{env}` already exists')
continue

try:
environment.check_compatibility()
Expand All @@ -32,10 +35,6 @@ def create(app, env_name):

app.abort(f'Environment `{env}` is incompatible: {e}')

if environment.exists():
app.display_warning(f'Environment `{env}` already exists')
continue

app.prepare_environment(environment)

if incompatible:
Expand Down
6 changes: 0 additions & 6 deletions src/hatch/cli/env/find.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,10 +21,4 @@ def find(app, env_name):

for env in environments:
environment = app.get_environment(env)

try:
environment.check_compatibility()
except Exception: # noqa: BLE001, S112
continue

app.display(environment.find())
6 changes: 0 additions & 6 deletions src/hatch/cli/env/prune.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,12 +30,6 @@ def prune(app):
app.platform,
app.verbosity,
)

try:
environment.check_compatibility()
except Exception: # noqa: BLE001, S112
continue

if environment.exists() or environment.build_environment_exists():
with app.status(f'Removing environment: {env_name}'):
environment.remove()
6 changes: 0 additions & 6 deletions src/hatch/cli/env/remove.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,12 +22,6 @@ def remove(ctx, env_name):

for env_name in environments:
environment = app.get_environment(env_name)

try:
environment.check_compatibility()
except Exception: # noqa: BLE001, S112
continue

if environment.exists() or environment.build_environment_exists():
with app.status(f'Removing environment: {env_name}'):
environment.remove()
18 changes: 9 additions & 9 deletions src/hatch/cli/env/run.py
Original file line number Diff line number Diff line change
Expand Up @@ -160,15 +160,15 @@ def run(
with project.location.as_cwd():
for env_name in environments:
environment = app.get_environment(env_name)

try:
environment.check_compatibility()
except Exception as e: # noqa: BLE001
if ignore_compat or matrix_selected:
incompatible[environment.name] = str(e)
continue

app.abort(f'Environment `{env_name}` is incompatible: {e}')
if not environment.exists():
try:
environment.check_compatibility()
except Exception as e: # noqa: BLE001
if ignore_compat or matrix_selected:
incompatible[environment.name] = str(e)
continue

app.abort(f'Environment `{env_name}` is incompatible: {e}')

any_compatible = True
if should_display_header:
Expand Down
12 changes: 7 additions & 5 deletions src/hatch/cli/project/metadata.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,14 +30,16 @@ def metadata(app, field):

with app.project.location.as_cwd():
environment = app.get_environment()
try:
environment.check_compatibility()
except Exception as e: # noqa: BLE001
app.abort(f'Environment `{environment.name}` is incompatible: {e}')
build_environment_exists = environment.build_environment_exists()
if not build_environment_exists:
try:
environment.check_compatibility()
except Exception as e: # noqa: BLE001
app.abort(f'Environment `{environment.name}` is incompatible: {e}')

with app.status_if(
'Setting up build environment for missing dependencies',
condition=not environment.build_environment_exists(),
condition=not build_environment_exists,
) as status, environment.build_environment(app.project.metadata.build.requires):
status.stop()

Expand Down
12 changes: 7 additions & 5 deletions src/hatch/cli/version/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,14 +52,16 @@ def version(app: Application, desired_version: str | None):
app.ensure_environment_plugin_dependencies()

environment = app.get_environment()
try:
environment.check_compatibility()
except Exception as e: # noqa: BLE001
app.abort(f'Environment `{environment.name}` is incompatible: {e}')
build_environment_exists = environment.build_environment_exists()
if not build_environment_exists:
try:
environment.check_compatibility()
except Exception as e: # noqa: BLE001
app.abort(f'Environment `{environment.name}` is incompatible: {e}')

with app.status_if(
'Setting up build environment for missing dependencies',
condition=not environment.build_environment_exists(),
condition=not build_environment_exists,
) as status, environment.build_environment(app.project.metadata.build.requires):
status.stop()

Expand Down
7 changes: 6 additions & 1 deletion src/hatch/env/plugin/interface.py
Original file line number Diff line number Diff line change
Expand Up @@ -574,7 +574,9 @@ def find(self):
"""
:material-align-horizontal-left: **REQUIRED** :material-align-horizontal-right:
This should return information about how to locate the environment.
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
Expand Down Expand Up @@ -849,6 +851,9 @@ 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'
Expand Down
47 changes: 47 additions & 0 deletions tests/cli/build/test_build.py
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,53 @@ def test_incompatible_environment(hatch, temp_dir, helpers):
)


@pytest.mark.allow_backend_process
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')

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(
"""
──────────────────────────────────── sdist ─────────────────────────────────────
Setting up build environment
──────────────────────────────────── 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')

artifacts = list(build_directory.iterdir())
assert len(artifacts) == 2

assert result.exit_code == 0, result.output
assert result.output == helpers.dedent(
"""
──────────────────────────────────── sdist ─────────────────────────────────────
──────────────────────────────────── wheel ─────────────────────────────────────
"""
)


def test_unknown_targets(hatch, temp_dir, helpers):
project_name = 'My.App'

Expand Down
4 changes: 2 additions & 2 deletions tests/cli/env/test_find.py
Original file line number Diff line number Diff line change
Expand Up @@ -125,15 +125,15 @@ def test_matrix(hatch, helpers, temp_dir_data, config_file):
assert len(storage_dirs) == 1

storage_path = storage_dirs[0]
env_path = storage_path / 'test.42'

with project_path.as_cwd():
result = hatch('env', 'find', 'test')

assert result.exit_code == 0, result.output
assert result.output == helpers.dedent(
f"""
{env_path}
{storage_path / 'test.9000'}
{storage_path / 'test.42'}
"""
)

Expand Down
40 changes: 40 additions & 0 deletions tests/cli/project/test_metadata.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import pytest

from hatch.config.constants import ConfigEnvVars
from hatch.project.core import Project
from hatchling.utils.constants import DEFAULT_CONFIG_FILE

Expand Down Expand Up @@ -379,3 +380,42 @@ def test_plugin_dependencies_unmet(self, hatch, temp_dir, helpers, mock_plugin_i
"""
)
helpers.assert_plugin_installation(mock_plugin_installation, [dependency])

@pytest.mark.allow_backend_process
def test_no_compatibility_check_if_exists(self, 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(
"""
Setting up build environment for missing 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(
"""
MIT
"""
)
62 changes: 62 additions & 0 deletions tests/cli/run/test_run.py
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,68 @@ def test_automatic_creation(hatch, helpers, temp_dir, config_file):
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()
Expand Down
Loading

0 comments on commit 98e6238

Please sign in to comment.