Skip to content

Commit 84a23aa

Browse files
Anchor in-core editable installs in hatch env collector to project root (DataDog#23765)
* Use absolute paths for in-core editable installs in hatch env collector The post-install command emitted by the ddev environment collector referenced datadog_checks_base/dev with relative paths (`../...`), which only resolved correctly when hatch happened to run the command with CWD == integration root. Hatch <=1.9 silently cd'd into the project root before running scripts; hatch 1.10+ preserves the invocation CWD, so invoking hatch from a subdirectory (e.g. `tests/lab/`) caused the install to look for the dependencies under that subdirectory and fail with "Distribution not found". Anchor both installs to `self.root.parent` (the integrations-core checkout directory, determined by hatch via pyproject.toml lookup), so the install commands work regardless of the hatch version or the CWD hatch was invoked from. * Add changelog entry for DataDog#23765 * Anchor ruff config and mypy config paths to the project root Both `ruff_settings_dir` and the typing-script's `mypy --config-file` relied on relative paths (`./pyproject.toml`, `..`, `../pyproject.toml`) resolved from the invocation CWD. They had the same CWD-sensitivity class of bug as the editable-install paths fixed earlier in this PR and break the same way on hatch 1.10+ when invoked from a subdirectory. Anchor them to `self.root` (the integration root, resolved by hatch via pyproject.toml walk-up) so the emitted commands are stable. Also add a regression suite for `format_base_package`, `base_package_install_command`, `test_package_install_command`, and `ruff_settings_dir` covering both the in-core (absolute path) and non-core (PyPI package name) branches. * Mark hatch env collector tests as a package and harden the install test - Add __init__.py to the new ddev/tests/plugin/external/hatch/ subtree to match the convention used by every other ddev/tests/ subdir and avoid future filename collisions under pytest's default import mode. - Clear DDEV_TEST_BASE_PACKAGE_VERSION in the in-core install test so a leaked env var can't short-circuit into the version branch and fail the assertion for a reason unrelated to path anchoring. - Use pytest.param(id=...) on the format_base_package parametrize for self-describing test IDs. * Derive local-path test expectations from the input for Windows portability The two local_path parametrize cases hardcoded POSIX-style expected strings, but format_base_package returns str(local_path), which renders with backslashes on Windows. The ddev test target runs on windows-2022 in CI, so the equality assertion would fail there. Build the expected string with an f-string over the same Path input. * Quote absolute editable/config paths cross-platform in generated hatch scripts Hatch runs post-install and lint/typing scripts with shell=True, so an absolute checkout path containing whitespace (e.g. /Users/Jane Doe or C:\Users\Jane Doe) was word-split before uv/ruff/mypy received it. shlex.quote only produces POSIX quoting, which cmd.exe ignores, so add a shell_quote helper that double-quotes on Windows and uses shlex.quote elsewhere, applied at each path interpolation site. * Extract a fake_checkout fixture to dedupe whitespace-checkout test setup Merges the plain/whitespace variants of the base and test package install command tests into single parametrized tests, and reuses the fixture for the ruff settings dir and lint command tests. * Fix Windows CI failures in environment collector tests Pin sys.platform and use PurePosixPath in test_format_base_package so the raw-string assertion isn't broken by shlex.quote wrapping backslash-laden WindowsPath strings in quotes on a Windows host. Also fix the expected lint config path to match the separator each underlying code path actually produces (forced '/' for ruff, native join for mypy). --------- Co-authored-by: Alexey Pilyugin <alexey.pilyugin@datadoghq.com>
1 parent c0e205c commit 84a23aa

6 files changed

Lines changed: 224 additions & 13 deletions

File tree

ddev/changelog.d/23765.fixed

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Anchor in-core editable installs in the hatch environment collector to the project root so the post-install command works on hatch 1.10+ when invoked from a subdirectory of the integration. The generated install, lint, and typing commands now shell-quote those absolute paths so checkouts under a path containing whitespace work on both POSIX and Windows.

ddev/src/ddev/plugin/external/hatch/environment_collector.py

Lines changed: 27 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,21 @@
11
import os
22
import re
3+
import shlex
4+
import sys
35
from functools import cached_property
4-
from pathlib import Path
56
from typing import Any
67

78
from hatch.env.collectors.plugin.interface import EnvironmentCollectorInterface
89

910

11+
def shell_quote(token: str) -> str:
12+
"""Quote a token for the shell Hatch runs commands through: cmd.exe on Windows, POSIX sh elsewhere."""
13+
# Mypy has special recognition for sys.platform, not os.name.
14+
if sys.platform == 'win32':
15+
return f'"{token}"'
16+
return shlex.quote(token)
17+
18+
1019
class DatadogChecksEnvironmentCollector(EnvironmentCollectorInterface):
1120
PLUGIN_NAME = 'datadog-checks'
1221

@@ -80,7 +89,7 @@ def test_package_install_command(self):
8089
if not self.in_core_repo:
8190
return self.uv_install_command('datadog-checks-dev')
8291
elif not (self.is_test_package or self.is_dev_package):
83-
return self.uv_install_command('-e', '../datadog_checks_dev')
92+
return self.uv_install_command('-e', shell_quote(str(self.root.parent / 'datadog_checks_dev')))
8493

8594
def base_package_install_command(self, features):
8695
from ddev.testing.constants import TestEnvVars
@@ -90,14 +99,16 @@ def base_package_install_command(self, features):
9099
elif not self.in_core_repo:
91100
return self.uv_install_command(self.format_base_package(features))
92101
elif not (self.is_base_package or self.is_dev_package):
93-
return self.uv_install_command('-e', self.format_base_package(features, local=True))
102+
return self.uv_install_command(
103+
'-e', self.format_base_package(features, local_path=self.root.parent / 'datadog_checks_base')
104+
)
94105

95106
@staticmethod
96-
def format_base_package(features, version='', local=False):
107+
def format_base_package(features, version='', local_path=None):
97108
if not features:
98109
features = ['deps']
99110

100-
base_package = '../datadog_checks_base' if local else 'datadog-checks-base'
111+
base_package = shell_quote(str(local_path)) if local_path is not None else 'datadog-checks-base'
101112
formatted = f'{base_package}[{",".join(sorted(features))}]'
102113
if version:
103114
formatted += f'=={version}'
@@ -161,17 +172,19 @@ def finalize_config(self, config):
161172
scripts.setdefault('benchmark', '_dd-benchmark')
162173

163174
def lint_command(self, options: str, settings_dir: str) -> str:
175+
config = shell_quote(f'{settings_dir}/pyproject.toml')
164176
return self.on_config(
165177
'disable-linter',
166178
"echo 'Linter is disabled for this environment'",
167-
f'ruff check {options} --config {settings_dir}/pyproject.toml .',
179+
f'ruff check {options} --config {config} .',
168180
)
169181

170182
def formatter_command(self, options: str, settings_dir: str) -> str:
183+
config = shell_quote(f'{settings_dir}/pyproject.toml')
171184
return self.on_config(
172185
'disable-formatter',
173186
"echo 'Formatter is disabled for this environment'",
174-
f'ruff format {options} --config {settings_dir}/pyproject.toml .',
187+
f'ruff format {options} --config {config} .',
175188
)
176189

177190
def inject_ddtrace_dependency(self, env_config):
@@ -189,12 +202,12 @@ def inject_ddtrace_dependency(self, env_config):
189202

190203
def ruff_settings_dir(self):
191204
# If the local pyproject.toml exists and has ruff configuration, use it
192-
local_pyproject = Path("./pyproject.toml")
205+
local_pyproject = self.root / 'pyproject.toml'
193206
if local_pyproject.exists():
194207
pyproject_text = local_pyproject.read_text()
195208
if re.search(r'\[tool\.ruff\]', pyproject_text):
196-
return "."
197-
return ".."
209+
return str(self.root)
210+
return str(self.root.parent)
198211

199212
def get_initial_config(self):
200213
settings_dir = self.ruff_settings_dir()
@@ -234,9 +247,10 @@ def get_initial_config(self):
234247
config = {'lint': lint_env}
235248

236249
if self.check_types:
237-
lint_env['scripts']['typing'] = [
238-
f'mypy --config-file=../pyproject.toml {" ".join(self.mypy_args)} {" ".join(self.mypy_files)}'.rstrip()
239-
]
250+
mypy_config = shell_quote(f'--config-file={self.root.parent / "pyproject.toml"}')
251+
mypy_args = ' '.join(self.mypy_args)
252+
mypy_files = ' '.join(self.mypy_files)
253+
lint_env['scripts']['typing'] = [f'mypy {mypy_config} {mypy_args} {mypy_files}'.rstrip()]
240254
lint_env['scripts']['all'].append('typing')
241255
lint_env['dependencies'].extend(self.mypy_deps)
242256

ddev/tests/plugin/__init__.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
# (C) Datadog, Inc. 2026-present
2+
# All rights reserved
3+
# Licensed under a 3-clause BSD style license (see LICENSE)
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
# (C) Datadog, Inc. 2026-present
2+
# All rights reserved
3+
# Licensed under a 3-clause BSD style license (see LICENSE)
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
# (C) Datadog, Inc. 2026-present
2+
# All rights reserved
3+
# Licensed under a 3-clause BSD style license (see LICENSE)
Lines changed: 187 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,187 @@
1+
# (C) Datadog, Inc. 2026-present
2+
# All rights reserved
3+
# Licensed under a 3-clause BSD style license (see LICENSE)
4+
import shlex
5+
import sys
6+
from pathlib import Path, PurePosixPath
7+
8+
import pytest
9+
10+
from ddev.plugin.external.hatch.environment_collector import DatadogChecksEnvironmentCollector, shell_quote
11+
12+
13+
def tokenize(command):
14+
"""Tokenize a generated command the way a POSIX shell does (Hatch runs scripts with ``shell=True``)."""
15+
return shlex.split(command.replace('{verbosity:flag:-1}', '-q'))
16+
17+
18+
@pytest.fixture
19+
def fake_checkout(tmp_path):
20+
"""Build a fake in-core checkout, returning `(integrations_core, integration_root)`."""
21+
22+
def _make(*extra_dirs, whitespace=False):
23+
base = tmp_path / 'Jane Doe' if whitespace else tmp_path
24+
integrations_core = base / 'integrations-core'
25+
(integrations_core / 'datadog_checks_base').mkdir(parents=True)
26+
for extra_dir in extra_dirs:
27+
(integrations_core / extra_dir).mkdir()
28+
integration_root = integrations_core / 'fake_integration'
29+
integration_root.mkdir()
30+
return integrations_core, integration_root
31+
32+
return _make
33+
34+
35+
@pytest.mark.parametrize(
36+
'features, version, local_path, expected',
37+
[
38+
pytest.param(['deps'], '', None, 'datadog-checks-base[deps]', id='pypi_with_deps'),
39+
pytest.param([], '', None, 'datadog-checks-base[deps]', id='pypi_empty_features_defaults_to_deps'),
40+
pytest.param(['deps', 'http'], '', None, 'datadog-checks-base[deps,http]', id='pypi_multi_features'),
41+
pytest.param(['deps'], '1.2.3', None, 'datadog-checks-base[deps]==1.2.3', id='pypi_with_version'),
42+
pytest.param(
43+
['deps'],
44+
'',
45+
PurePosixPath('/repo') / 'integrations-core' / 'datadog_checks_base',
46+
f"{PurePosixPath('/repo') / 'integrations-core' / 'datadog_checks_base'}[deps]",
47+
id='local_path_with_deps',
48+
),
49+
pytest.param(
50+
['deps', 'http'],
51+
'',
52+
PurePosixPath('/repo') / 'integrations-core' / 'datadog_checks_base',
53+
f"{PurePosixPath('/repo') / 'integrations-core' / 'datadog_checks_base'}[deps,http]",
54+
id='local_path_multi_features',
55+
),
56+
],
57+
)
58+
def test_format_base_package(monkeypatch, features, version, local_path, expected):
59+
# Pin to a POSIX target: a native `Path` would render backslashes on a Windows host,
60+
# which `shlex.quote` treats as unsafe and wraps in quotes, breaking the raw string comparison.
61+
monkeypatch.setattr(sys, 'platform', 'linux')
62+
63+
assert DatadogChecksEnvironmentCollector.format_base_package(features, version=version, local_path=local_path) == (
64+
expected
65+
)
66+
67+
68+
@pytest.mark.parametrize('whitespace', [False, True], ids=['plain_checkout', 'whitespace_checkout'])
69+
def test_base_package_install_command_uses_absolute_path_in_core(fake_checkout, monkeypatch, tmp_path, whitespace):
70+
monkeypatch.delenv('DDEV_TEST_BASE_PACKAGE_VERSION', raising=False)
71+
72+
integrations_core, integration_root = fake_checkout(whitespace=whitespace)
73+
monkeypatch.chdir(tmp_path)
74+
75+
collector = DatadogChecksEnvironmentCollector(integration_root, {})
76+
command = collector.base_package_install_command(features=None)
77+
78+
assert tokenize(command)[-1] == f"{integrations_core / 'datadog_checks_base'}[deps]"
79+
80+
81+
@pytest.mark.parametrize('whitespace', [False, True], ids=['plain_checkout', 'whitespace_checkout'])
82+
def test_test_package_install_command_uses_absolute_path_in_core(fake_checkout, whitespace):
83+
integrations_core, integration_root = fake_checkout('datadog_checks_dev', whitespace=whitespace)
84+
85+
collector = DatadogChecksEnvironmentCollector(integration_root, {})
86+
command = collector.test_package_install_command
87+
88+
assert tokenize(command)[-1] == str(integrations_core / 'datadog_checks_dev')
89+
90+
91+
@pytest.mark.parametrize(
92+
'has_local_ruff_config, expected_relative_to',
93+
[
94+
pytest.param(True, 'self', id='local_ruff_config_returns_integration_root'),
95+
pytest.param(False, 'parent', id='no_local_ruff_config_returns_repo_root'),
96+
],
97+
)
98+
def test_ruff_settings_dir_uses_absolute_path(
99+
fake_checkout, monkeypatch, tmp_path, has_local_ruff_config, expected_relative_to
100+
):
101+
integrations_core, integration_root = fake_checkout()
102+
103+
pyproject = '[project]\nname = "fake"\n'
104+
if has_local_ruff_config:
105+
pyproject += '\n[tool.ruff]\nline-length = 120\n'
106+
(integration_root / 'pyproject.toml').write_text(pyproject)
107+
108+
monkeypatch.chdir(tmp_path)
109+
110+
collector = DatadogChecksEnvironmentCollector(integration_root, {})
111+
settings_dir = collector.ruff_settings_dir()
112+
113+
expected = str(integration_root) if expected_relative_to == 'self' else str(integrations_core)
114+
assert settings_dir == expected
115+
assert settings_dir not in ('.', '..')
116+
117+
118+
def test_format_base_package_quotes_local_path_with_whitespace():
119+
local_path = Path('/Users/Jane Doe/integrations-core/datadog_checks_base')
120+
121+
token = DatadogChecksEnvironmentCollector.format_base_package(['deps'], local_path=local_path)
122+
command = DatadogChecksEnvironmentCollector.uv_install_command('-e', token)
123+
124+
assert tokenize(command)[-1] == f'{local_path}[deps]'
125+
126+
127+
@pytest.mark.parametrize(
128+
'platform, path, expected',
129+
[
130+
pytest.param('linux', '/Users/Jane Doe/integrations-core', "'/Users/Jane Doe/integrations-core'", id='linux'),
131+
pytest.param('darwin', '/Users/Jane Doe/integrations-core', "'/Users/Jane Doe/integrations-core'", id='macos'),
132+
pytest.param(
133+
'win32', r'C:\Users\Jane Doe\integrations-core', r'"C:\Users\Jane Doe\integrations-core"', id='win'
134+
),
135+
],
136+
)
137+
def test_shell_quote_matches_target_shell(monkeypatch, platform, path, expected):
138+
monkeypatch.setattr(sys, 'platform', platform)
139+
140+
assert shell_quote(path) == expected
141+
142+
143+
def test_shell_quote_windows_groups_whitespace_path_into_one_token(monkeypatch):
144+
monkeypatch.setattr(sys, 'platform', 'win32')
145+
path = r'C:\Users\Jane Doe\integrations-core\datadog_checks_base'
146+
147+
quoted = shell_quote(path)
148+
149+
# cmd.exe treats a double-quoted span as a single argument; the path is recovered intact, spaces and all.
150+
assert quoted == f'"{path}"'
151+
assert quoted[1:-1] == path
152+
153+
154+
def test_format_base_package_double_quotes_local_path_on_windows(monkeypatch):
155+
monkeypatch.setattr(sys, 'platform', 'win32')
156+
local_path = Path('/Users/Jane Doe/integrations-core/datadog_checks_base')
157+
158+
token = DatadogChecksEnvironmentCollector.format_base_package(['deps'], local_path=local_path)
159+
160+
assert token == f'"{local_path}"[deps]'
161+
162+
163+
@pytest.mark.parametrize(
164+
'script_name, config_flag',
165+
[
166+
pytest.param('style', '--config', id='ruff_style'),
167+
pytest.param('fmt', '--config', id='ruff_fmt'),
168+
pytest.param('typing', '--config-file', id='mypy_typing'),
169+
],
170+
)
171+
def test_lint_commands_survive_whitespace_checkout(fake_checkout, monkeypatch, tmp_path, script_name, config_flag):
172+
integrations_core, integration_root = fake_checkout(whitespace=True)
173+
monkeypatch.chdir(tmp_path)
174+
175+
collector = DatadogChecksEnvironmentCollector(integration_root, {'check-types': True})
176+
scripts = collector.get_initial_config()['lint']['scripts']
177+
178+
for command in scripts[script_name]:
179+
tokens = tokenize(command)
180+
if config_flag == '--config-file':
181+
# mypy's config path is built with `Path.__truediv__`, which uses the native separator.
182+
config_path = str(integrations_core / 'pyproject.toml')
183+
assert f'--config-file={config_path}' in tokens
184+
else:
185+
# ruff's config path is always joined with `/`, regardless of platform.
186+
config_path = f'{integrations_core}/pyproject.toml'
187+
assert config_path == tokens[tokens.index('--config') + 1]

0 commit comments

Comments
 (0)