Skip to content

Commit 8ca9fa2

Browse files
authored
Migrate test command (#15762)
* Migrate test command * address
1 parent aafc898 commit 8ca9fa2

File tree

16 files changed

+1346
-87
lines changed

16 files changed

+1346
-87
lines changed

.coveragerc

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ omit =
4141
../datadog_checks_dev/datadog_checks/dev/tooling/utils.py
4242

4343
[report]
44-
show_missing = ${DDEV_COV_MISSING}
44+
show_missing = True
4545
ignore_errors = True
4646

4747
exclude_lines =

.github/workflows/test-target.yml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,7 @@ jobs:
8787

8888
env:
8989
PYTHON_VERSION: "${{ inputs.python-version || '3.9' }}"
90+
PYTHON_FILTER: "${{ (inputs.test-py2 && !inputs.test-py3) && '2.7' || (!inputs.test-py2 && inputs.test-py3) && '3.9' || '' }}"
9091
SKIP_ENV_NAME: "${{ (inputs.test-py2 && !inputs.test-py3) && 'py3.*' || (!inputs.test-py2 && inputs.test-py3) && 'py2.*' || '' }}"
9192
# Windows E2E requires Windows containers
9293
DDEV_E2E_AGENT: "${{ inputs.platform == 'windows' && (inputs.agent-image-windows || 'datadog/agent-dev:master-py3-win-servercore') || inputs.agent-image }}"
@@ -164,6 +165,10 @@ jobs:
164165
ddev config set repos.${{ inputs.repo }} .
165166
ddev config set repo ${{ inputs.repo }}
166167
168+
- name: Lint
169+
# TODO: use the more descriptive `--lint` variant when ddev is released
170+
run: ddev test -s ${{ inputs.target }}
171+
167172
- name: Prepare for testing
168173
env: >-
169174
${{ fromJson(inputs.setup-env-vars || format(

ddev/CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,10 @@
66

77
* Remove `release agent requirements` subcommand ([#15621](https://github.com/DataDog/integrations-core/pull/15621))
88

9+
***Added***:
10+
11+
* Migrate test command ([#15762](https://github.com/DataDog/integrations-core/pull/15762))
12+
913
***Fixed***:
1014

1115
* Bump datadog-checks-dev version to ~=24.0 ([#15683](https://github.com/DataDog/integrations-core/pull/15683))

ddev/pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ classifiers = [
2727
]
2828
dependencies = [
2929
"click~=8.1.6",
30+
"coverage",
3031
"datadog-checks-dev[cli]~=24.0",
3132
"hatch>=1.6.3",
3233
"httpx",

ddev/src/ddev/cli/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,6 @@
88
from datadog_checks.dev.tooling.commands.create import create
99
from datadog_checks.dev.tooling.commands.dep import dep
1010
from datadog_checks.dev.tooling.commands.run import run
11-
from datadog_checks.dev.tooling.commands.test import test
1211

1312
from ddev._version import __version__
1413
from ddev.cli.application import Application
@@ -20,6 +19,7 @@
2019
from ddev.cli.meta import meta
2120
from ddev.cli.release import release
2221
from ddev.cli.status import status
22+
from ddev.cli.test import test
2323
from ddev.cli.validate import validate
2424
from ddev.config.constants import AppEnvVars, ConfigEnvVars
2525
from ddev.plugin import specs

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

Lines changed: 234 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,234 @@
1+
# (C) Datadog, Inc. 2023-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
7+
8+
import click
9+
10+
if TYPE_CHECKING:
11+
from ddev.cli.application import Application
12+
from ddev.integration.core import Integration
13+
from ddev.utils.fs import Path
14+
15+
16+
def fix_coverage_report(report_file: Path):
17+
target_dir = report_file.parent.name
18+
report = report_file.read_bytes()
19+
20+
# Make every target's `tests` directory path unique so they don't get combined in UI
21+
report = report.replace(b'"tests/', f'"{target_dir}/tests/'.encode('utf-8'))
22+
23+
report_file.write_bytes(report)
24+
25+
26+
@click.command(short_help='Run tests')
27+
@click.argument('target_spec', required=False)
28+
@click.argument('args', nargs=-1)
29+
@click.option('--lint', '-s', is_flag=True, help='Run only lint & style checks')
30+
@click.option('--fmt', '-fs', is_flag=True, help='Run only the code formatter')
31+
@click.option('--bench', '-b', is_flag=True, help='Run only benchmarks')
32+
@click.option('--latest', is_flag=True, help='Only verify support of new product versions')
33+
@click.option('--cov', '-c', 'coverage', is_flag=True, help='Measure code coverage')
34+
@click.option('--compat', is_flag=True, help='Check compatibility with the minimum allowed Agent version')
35+
@click.option('--ddtrace', is_flag=True, envvar='DDEV_TEST_ENABLE_TRACING', help='Enable tracing during test execution')
36+
@click.option('--memray', is_flag=True, help='Measure memory usage during test execution')
37+
@click.option('--recreate', '-r', is_flag=True, help='Recreate environments from scratch')
38+
@click.option('--list', '-l', 'list_envs', is_flag=True, help='Show available test environments')
39+
@click.option('--python-filter', envvar='PYTHON_FILTER', hidden=True)
40+
@click.option('--junit', is_flag=True, hidden=True)
41+
@click.option('--e2e', is_flag=True, hidden=True)
42+
@click.pass_obj
43+
def test(
44+
app: Application,
45+
target_spec: str | None,
46+
args: tuple[str, ...],
47+
lint: bool,
48+
fmt: bool,
49+
bench: bool,
50+
latest: bool,
51+
coverage: bool,
52+
compat: bool,
53+
ddtrace: bool,
54+
memray: bool,
55+
recreate: bool,
56+
list_envs: bool,
57+
python_filter: str | None,
58+
junit: bool,
59+
e2e: bool,
60+
):
61+
"""
62+
Run tests.
63+
"""
64+
import json
65+
import os
66+
import sys
67+
68+
from ddev.repo.constants import PYTHON_VERSION
69+
from ddev.testing.constants import EndToEndEnvVars, TestEnvVars
70+
from ddev.utils.ci import running_in_ci
71+
72+
if target_spec is None:
73+
target_spec = 'changed'
74+
75+
target_name, _, environments = target_spec.partition(':')
76+
77+
# target name -> target
78+
targets: dict[str, Integration] = {}
79+
if target_name == 'changed':
80+
for integration in app.repo.integrations.iter_changed():
81+
if integration.is_testable:
82+
targets[integration.name] = integration
83+
else:
84+
try:
85+
integration = app.repo.integrations.get(target_name)
86+
except OSError:
87+
app.abort(f'Unknown target: {target_name}')
88+
89+
if integration.is_testable:
90+
targets[integration.name] = integration
91+
92+
if not targets:
93+
app.abort('No testable targets found')
94+
95+
if list_envs:
96+
multiple_targets = len(targets) > 1
97+
for target in targets.values():
98+
with target.path.as_cwd():
99+
if multiple_targets:
100+
app.display_header(target.display_name)
101+
102+
app.platform.check_command([sys.executable, '-m', 'hatch', 'env', 'show'])
103+
104+
return
105+
106+
global_env_vars: dict[str, str] = {}
107+
108+
hatch_verbosity = app.verbosity + 1
109+
if hatch_verbosity > 0:
110+
global_env_vars['HATCH_VERBOSE'] = str(hatch_verbosity)
111+
elif hatch_verbosity < 0:
112+
global_env_vars['HATCH_QUIET'] = str(abs(hatch_verbosity))
113+
114+
api_key = app.config.org.config.get('api_key')
115+
if api_key and not (lint or fmt):
116+
global_env_vars['DD_API_KEY'] = api_key
117+
118+
# Only enable certain functionality when running standard tests
119+
standard_tests = not (lint or fmt or bench or latest)
120+
121+
# Keep track of environments so that they can first be removed if requested
122+
chosen_environments = []
123+
124+
base_command = [sys.executable, '-m', 'hatch', 'env', 'run']
125+
if environments and not standard_tests:
126+
app.abort('Cannot specify environments when using specific functionality like linting')
127+
elif lint:
128+
chosen_environments.append('lint')
129+
base_command.extend(('--env', 'lint', '--', 'all'))
130+
elif fmt:
131+
chosen_environments.append('lint')
132+
base_command.extend(('--env', 'lint', '--', 'fmt'))
133+
elif bench:
134+
filter_data = json.dumps({'benchmark-env': True})
135+
base_command.extend(('--filter', filter_data, '--', 'benchmark'))
136+
elif latest:
137+
filter_data = json.dumps({'latest-env': True})
138+
base_command.extend(('--filter', filter_data, '--', 'test', '--run-latest-metrics'))
139+
else:
140+
if environments:
141+
for env_name in environments.split(','):
142+
chosen_environments.append(env_name)
143+
base_command.extend(('--env', env_name))
144+
else:
145+
chosen_environments.append('default')
146+
base_command.append('--ignore-compat')
147+
148+
if python_filter:
149+
filter_data = json.dumps({'python': python_filter})
150+
base_command.extend(('--filter', filter_data))
151+
152+
base_command.extend(('--', 'test-cov' if coverage else 'test'))
153+
154+
if app.verbosity <= 0:
155+
base_command.extend(('--tb', 'short'))
156+
157+
if memray:
158+
if app.platform.windows:
159+
app.abort('Memory profiling with `memray` is not supported on Windows')
160+
161+
base_command.append('--memray')
162+
163+
if e2e:
164+
base_command.extend(('-m', 'e2e'))
165+
global_env_vars[EndToEndEnvVars.PARENT_PYTHON] = sys.executable
166+
167+
app.display_debug(f'Targets: {", ".join(targets)}')
168+
for target in targets.values():
169+
app.display_header(target.display_name)
170+
171+
command = base_command.copy()
172+
env_vars = global_env_vars.copy()
173+
174+
if standard_tests:
175+
if ddtrace and (target.is_integration or target.name == 'datadog_checks_base'):
176+
# TODO: remove this once we drop Python 2
177+
if app.platform.windows and (
178+
(python_filter and python_filter != PYTHON_VERSION)
179+
or not all(env_name.startswith('py3') for env_name in chosen_environments)
180+
):
181+
app.display_warning('Tracing is only supported on Python 3 on Windows')
182+
else:
183+
command.append('--ddtrace')
184+
env_vars['DDEV_TRACE_ENABLED'] = 'true'
185+
env_vars['DD_PROFILING_ENABLED'] = 'true'
186+
env_vars['DD_SERVICE'] = os.environ.get('DD_SERVICE', 'ddev-integrations')
187+
env_vars['DD_ENV'] = os.environ.get('DD_ENV', 'ddev-integrations')
188+
189+
if junit:
190+
# In order to handle multiple environments the report files must contain the environment name.
191+
# Hatch injects the `HATCH_ENV_ACTIVE` environment variable, see:
192+
# https://hatch.pypa.io/latest/plugins/environment/reference/#hatch.env.plugin.interface.EnvironmentInterface.get_env_vars
193+
command.extend(('--junit-xml', f'.junit/test-{"e2e" if e2e else "unit"}-$HATCH_ENV_ACTIVE.xml'))
194+
# Test results class prefix
195+
command.extend(('--junit-prefix', target.name))
196+
197+
if (
198+
compat
199+
and target.is_package
200+
and target.is_integration
201+
and target.minimum_base_package_version is not None
202+
):
203+
env_vars[TestEnvVars.BASE_PACKAGE_VERSION] = target.minimum_base_package_version
204+
205+
command.extend(args)
206+
207+
with target.path.as_cwd(env_vars=env_vars):
208+
app.display_debug(f'Command: {command}')
209+
210+
if recreate:
211+
if bench or latest:
212+
variable = 'benchmark-env' if bench else 'latest-env'
213+
env_data = json.loads(
214+
app.platform.check_command_output([sys.executable, '-m', 'hatch', 'env', 'show', '--json'])
215+
)
216+
for env_name, env_config in env_data.items():
217+
if env_config.get(variable, False):
218+
app.platform.check_command([sys.executable, '-m', 'hatch', 'env', 'remove', env_name])
219+
else:
220+
for env_name in chosen_environments:
221+
app.platform.check_command([sys.executable, '-m', 'hatch', 'env', 'remove', env_name])
222+
223+
app.platform.check_command(command)
224+
if standard_tests and coverage:
225+
app.display_header('Coverage report')
226+
app.platform.check_command([sys.executable, '-m', 'coverage', 'report', '--rcfile=../.coveragerc'])
227+
228+
if running_in_ci():
229+
app.platform.check_command(
230+
[sys.executable, '-m', 'coverage', 'xml', '-i', '--rcfile=../.coveragerc']
231+
)
232+
fix_coverage_report(target.path / 'coverage.xml')
233+
else:
234+
(target.path / '.coverage').remove()

ddev/src/ddev/integration/core.py

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,10 @@ def normalized_display_name(self) -> str:
9999
normalized_integration = normalized_integration.strip("_")
100100
return normalized_integration.lower()
101101

102+
@cached_property
103+
def project_file(self) -> Path:
104+
return self.path / 'pyproject.toml'
105+
102106
@cached_property
103107
def metrics_file(self) -> Path:
104108
relative_path = self.manifest.get('/assets/integration/metrics/metadata_path', 'metadata.csv')
@@ -109,6 +113,32 @@ def config_spec(self) -> Path:
109113
relative_path = self.manifest.get('/assets/integration/configuration/spec', 'assets/configuration/spec.yaml')
110114
return self.path / relative_path
111115

116+
@cached_property
117+
def minimum_base_package_version(self) -> str | None:
118+
from packaging.requirements import Requirement
119+
from packaging.utils import canonicalize_name
120+
121+
from ddev.utils.toml import load_toml_data
122+
123+
data = load_toml_data(self.project_file.read_text())
124+
for entry in data['project'].get('dependencies', []):
125+
dep = Requirement(entry)
126+
if canonicalize_name(dep.name) == 'datadog-checks-base':
127+
if dep.specifier:
128+
specifier = str(sorted(dep.specifier, key=str)[-1])
129+
130+
version_index = 0
131+
for i, c in enumerate(specifier):
132+
if c.isdigit():
133+
version_index = i
134+
break
135+
136+
return specifier[version_index:]
137+
else:
138+
return None
139+
140+
return None
141+
112142
@cached_property
113143
def is_valid(self) -> bool:
114144
return self.is_integration or self.is_package
@@ -123,7 +153,7 @@ def has_metrics(self) -> bool:
123153

124154
@cached_property
125155
def is_package(self) -> bool:
126-
return (self.path / 'pyproject.toml').is_file()
156+
return self.project_file.is_file()
127157

128158
@cached_property
129159
def is_tile(self) -> bool:

0 commit comments

Comments
 (0)