Skip to content

Commit 7286fd7

Browse files
authored
Create hatch command wrappers (#21135)
* Create wrapper around hatch commands and refactor `ddev env test` to use it. This simplifies the logic for environment selection. * Fix changelog number
1 parent 1a9c7b0 commit 7286fd7

File tree

9 files changed

+346
-55
lines changed

9 files changed

+346
-55
lines changed

ddev/changelog.d/21135.added

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Add a utils.hatch module to centralize hatch operations

ddev/src/ddev/cli/env/test.py

Lines changed: 32 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99

1010
if TYPE_CHECKING:
1111
from ddev.cli.application import Application
12+
from ddev.utils.hatch import Environment
1213

1314

1415
@click.command('test')
@@ -72,13 +73,16 @@ def test_command(
7273
\b
7374
https://datadoghq.dev/integrations-core/testing/
7475
"""
76+
from functools import partial
77+
7578
from ddev.cli.env.start import start
7679
from ddev.cli.env.stop import stop
7780
from ddev.cli.test import test
7881
from ddev.config.constants import AppEnvVars
7982
from ddev.e2e.config import EnvDataStorage
8083
from ddev.e2e.constants import E2EMetadata
8184
from ddev.utils.ci import running_in_ci
85+
from ddev.utils.hatch import HatchCommandError, list_environment_names
8286
from ddev.utils.structures import EnvVars
8387

8488
app: Application = ctx.obj
@@ -93,29 +97,19 @@ def test_command(
9397
if environment == 'active':
9498
env_names = active_envs
9599
else:
96-
import json
97-
import sys
98-
99-
with integration.path.as_cwd():
100-
env_data_output = app.platform.check_command_output(
101-
[sys.executable, '-m', 'hatch', '--no-color', '--no-interactive', 'env', 'show', '--json']
100+
try:
101+
env_names = list_environment_names(
102+
app.platform,
103+
integration,
104+
filters=[
105+
is_e2e_environment,
106+
partial(uses_python_version, python_filter=python_filter),
107+
partial(uses_platform, platform=app.platform.name),
108+
partial(is_selected_environment, environment_name=environment),
109+
],
102110
)
103-
try:
104-
environments = json.loads(env_data_output)
105-
except json.JSONDecodeError:
106-
app.abort(f'Failed to parse environments for `{integration.name}`:\n{repr(env_data_output)}')
107-
108-
no_python_filter = python_filter is None
109-
all_environments = environment == 'all'
110-
111-
env_names = [
112-
name
113-
for name, data in environments.items()
114-
if data.get('e2e-env', False)
115-
and (not data.get('platforms') or app.platform.name in data['platforms'])
116-
and (no_python_filter or data.get('python') == python_filter)
117-
and (name == environment or all_environments)
118-
]
111+
except HatchCommandError as error:
112+
app.abort(f'Failed to list environments for `{integration.name}`:\n{error}')
119113

120114
if not env_names:
121115
app.display_info(f"Selected target {integration.name!r} disabled by e2e-env option.")
@@ -155,3 +149,19 @@ def test_command(
155149
)
156150
finally:
157151
ctx.invoke(stop, intg_name=intg_name, environment=env_name, ignore_state=env_active)
152+
153+
154+
def is_e2e_environment(environment: Environment) -> bool:
155+
return environment.e2e_env
156+
157+
158+
def uses_python_version(environment: Environment, python_filter: str | None) -> bool:
159+
return python_filter is None or environment.python == python_filter
160+
161+
162+
def uses_platform(environment: Environment, platform: str) -> bool:
163+
return not environment.platforms or platform in environment.platforms
164+
165+
166+
def is_selected_environment(environment: Environment, environment_name: str) -> bool:
167+
return environment.name == environment_name or environment_name == 'all'

ddev/src/ddev/cli/test/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -130,8 +130,8 @@ def test(
130130
import sys
131131

132132
from ddev.testing.constants import EndToEndEnvVars, TestEnvVars
133-
from ddev.testing.hatch import get_hatch_env_vars
134133
from ddev.utils.ci import running_in_ci
134+
from ddev.utils.hatch import get_hatch_env_vars
135135

136136
if target_spec is None:
137137
target_spec = 'changed'

ddev/src/ddev/e2e/run.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
from typing import Generator
99

1010
from ddev.e2e.constants import E2EEnvVars
11-
from ddev.testing.hatch import get_hatch_env_vars
11+
from ddev.utils.hatch import get_hatch_env_vars
1212
from ddev.utils.structures import EnvVars
1313

1414

ddev/src/ddev/testing/hatch.py

Lines changed: 0 additions & 12 deletions
This file was deleted.

ddev/src/ddev/utils/hatch.py

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
# (C) Datadog, Inc. 2025-present
2+
# All rights reserved
3+
# Licensed under a 3-clause BSD style license (see LICENSE)
4+
from __future__ import annotations
5+
6+
from typing import TYPE_CHECKING, Protocol, overload
7+
8+
from pydantic import BaseModel, Field, RootModel, model_validator
9+
10+
if TYPE_CHECKING:
11+
from collections.abc import Collection
12+
from typing import Any, Literal
13+
14+
from ddev.integration.core import Integration
15+
16+
from .platform import Platform
17+
18+
19+
class HatchCommandError(Exception):
20+
pass
21+
22+
23+
class Environment(BaseModel):
24+
"""Represents a single environment configuration."""
25+
26+
name: str
27+
type: str
28+
dependencies: list[str] = Field(default_factory=list)
29+
test_env: bool = Field(alias='test-env')
30+
e2e_env: bool = Field(alias='e2e-env')
31+
benchmark_env: bool = Field(alias='benchmark-env')
32+
latest_env: bool = Field(alias='latest-env')
33+
python: str | None = None
34+
scripts: dict[str, list[str]] = Field(default_factory=dict)
35+
platforms: list[str] = Field(default_factory=list)
36+
pre_install_commands: list[str] = Field(default_factory=list, alias='pre-install-commands')
37+
post_install_commands: list[str] = Field(default_factory=list, alias='post-install-commands')
38+
skip_install: bool = Field(False, alias='skip-install')
39+
40+
41+
class HatchEnvironmentConfiguration(RootModel[list[Environment]]):
42+
"""
43+
A root model that parses the top-level dictionary returned by the `hatch env show --json` command
44+
into a list of Environment models.
45+
"""
46+
47+
@model_validator(mode='before')
48+
@classmethod
49+
def transform_dict_to_list_with_name(cls, data: object) -> object:
50+
if isinstance(data, list):
51+
return data
52+
53+
if isinstance(data, dict):
54+
return [Environment(**value, name=key) for key, value in data.items()]
55+
56+
raise ValueError(f'Invalid data type: {type(data)}. Expected a list or a dictionary.')
57+
58+
59+
class EnvironmentFilter(Protocol):
60+
def __call__(self, environment: Environment) -> bool: ...
61+
62+
63+
@overload
64+
def env_show(platform: Platform, integration: Integration, as_json: Literal[True]) -> dict[str, Any]: ...
65+
66+
67+
@overload
68+
def env_show(platform: Platform, integration: Integration) -> dict[str, Any]: ...
69+
70+
71+
@overload
72+
def env_show(platform: Platform, integration: Integration, as_json: Literal[False]) -> str: ...
73+
74+
75+
def env_show(platform: Platform, integration: Integration, as_json: bool = True) -> dict[str, Any] | str:
76+
import json
77+
import sys
78+
79+
with integration.path.as_cwd():
80+
command = [sys.executable, '-m', 'hatch', '--no-color', '--no-interactive', 'env', 'show']
81+
if as_json:
82+
command.append('--json')
83+
84+
env_data_output = platform.check_command_output(command)
85+
86+
try:
87+
if as_json:
88+
return json.loads(env_data_output)
89+
return env_data_output
90+
except json.JSONDecodeError as error:
91+
raise HatchCommandError(
92+
f'Failed to parse environments for {integration.name!r}: {env_data_output!r}'
93+
) from error
94+
95+
96+
def list_environment_names(
97+
platform: Platform, integration: Integration, filters: Collection[EnvironmentFilter], match_all: bool = True
98+
) -> list[str]:
99+
"""
100+
List the names of the environments that match the given filters.
101+
102+
If `match_all` is True, all filters must match. If False, any filter can match.
103+
"""
104+
hatch_output = env_show(platform, integration)
105+
matching_rule = all if match_all else any
106+
107+
return [
108+
env.name
109+
for env in HatchEnvironmentConfiguration.model_validate(hatch_output).root
110+
if matching_rule(filter(env) for filter in filters)
111+
]
112+
113+
114+
def get_hatch_env_vars(*, verbosity: int) -> dict[str, str]:
115+
env_vars = {}
116+
117+
if verbosity > 0:
118+
env_vars['HATCH_VERBOSE'] = str(verbosity)
119+
elif verbosity < 0:
120+
env_vars['HATCH_QUIET'] = str(abs(verbosity))
121+
122+
return env_vars

ddev/tests/cli/env/test_test.py

Lines changed: 23 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,21 @@
1616
from tests.helpers.mocks import MockPopen
1717
from tests.helpers.runner import CliRunner
1818

19+
BASE_ENV_CONFIG = {
20+
'type': 'virtual',
21+
'dependencies': [],
22+
'test-env': True,
23+
'e2e-env': True,
24+
'benchmark-env': True,
25+
'latest-env': True,
26+
'python': '3.12',
27+
'scripts': {},
28+
'platforms': [],
29+
'pre-install-commands': [],
30+
'post-install-commands': [],
31+
'skip-install': False,
32+
}
33+
1934

2035
def setup(
2136
mocker: MockerFixture,
@@ -96,7 +111,7 @@ def test_env_vars_repo(
96111
predicate: Callable[[Result], bool],
97112
mock_commands: tuple[MockType, MockType, MockType],
98113
):
99-
setup(mocker, write_result_file, hatch_json_output={'py3.12': {'e2e-env': e2e_env}})
114+
setup(mocker, write_result_file, hatch_json_output={'py3.12': {**BASE_ENV_CONFIG, 'e2e-env': e2e_env}})
100115
mocker.patch.object(EnvData, 'read_metadata', return_value={})
101116

102117
result = ddev('env', 'test', 'postgres', 'py3.12')
@@ -119,7 +134,11 @@ def test_environment_runs_for_enabled_environments(
119134
setup(
120135
mocker,
121136
write_result_file,
122-
hatch_json_output={'py3.12': {'e2e-env': True}, 'py3.13': {'e2e-env': False}, 'py3.13-v1': {'e2e-env': True}},
137+
hatch_json_output={
138+
'py3.12': BASE_ENV_CONFIG,
139+
'py3.13': {**BASE_ENV_CONFIG, 'e2e-env': False},
140+
'py3.13-v1': BASE_ENV_CONFIG,
141+
},
123142
)
124143
with mocker.patch.object(EnvData, 'read_metadata', return_value={}):
125144
result = ddev('env', 'test', 'postgres', environment)
@@ -145,7 +164,7 @@ def test_runningin_ci_triggers_all_environments_when_not_supplied(
145164
mocker: MockerFixture,
146165
mock_commands: tuple[MockType, MockType, MockType],
147166
):
148-
setup(mocker, write_result_file, hatch_json_output={'py3.12': {'e2e-env': True}, 'py3.13': {'e2e-env': True}})
167+
setup(mocker, write_result_file, hatch_json_output={'py3.12': BASE_ENV_CONFIG, 'py3.13': BASE_ENV_CONFIG})
149168
mocker.patch('ddev.utils.ci.running_in_ci', return_value=True)
150169

151170
with mocker.patch.object(EnvData, 'read_metadata', return_value={}):
@@ -161,7 +180,7 @@ def test_run_only_active_environments_when_not_running_in_ci_and_active_environm
161180
mocker: MockerFixture,
162181
mock_commands: tuple[MockType, MockType, MockType],
163182
):
164-
setup(mocker, write_result_file, hatch_json_output={'py3.12': {'e2e-env': True}, 'py3.13': {'e2e-env': True}})
183+
setup(mocker, write_result_file, hatch_json_output={'py3.12': BASE_ENV_CONFIG, 'py3.13': BASE_ENV_CONFIG})
165184
mocker.patch('ddev.utils.ci.running_in_ci', return_value=False)
166185

167186
with (

ddev/tests/testing/test_hatch.py

Lines changed: 0 additions & 15 deletions
This file was deleted.

0 commit comments

Comments
 (0)