From 3714ef4ba97f6490540de3ff2be8709c2234b8bc Mon Sep 17 00:00:00 2001 From: Pierre-Louis Veyrenc Date: Tue, 30 Sep 2025 16:59:19 +0200 Subject: [PATCH 01/12] feat(go): Add a custom `Go.build` method for easier invocations of `go build` Also restructures a bit the tests directory for `go` --- src/dda/tools/go.py | 64 +++++++++++ tests/tools/go/__init__.py | 3 + tests/tools/go/conftest.py | 19 ++++ .../go/fixtures/small_go_project/debug.go | 9 ++ .../tools/go/fixtures/small_go_project/go.mod | 3 + .../go/fixtures/small_go_project/main.go | 21 ++++ .../go/fixtures/small_go_project/prod.go | 9 ++ tests/tools/go/test_go.py | 105 ++++++++++++++++++ tests/tools/test_go.py | 29 ----- 9 files changed, 233 insertions(+), 29 deletions(-) create mode 100644 tests/tools/go/__init__.py create mode 100644 tests/tools/go/conftest.py create mode 100644 tests/tools/go/fixtures/small_go_project/debug.go create mode 100644 tests/tools/go/fixtures/small_go_project/go.mod create mode 100644 tests/tools/go/fixtures/small_go_project/main.go create mode 100644 tests/tools/go/fixtures/small_go_project/prod.go create mode 100644 tests/tools/go/test_go.py delete mode 100644 tests/tools/test_go.py diff --git a/src/dda/tools/go.py b/src/dda/tools/go.py index 311de4a8..4b51cf41 100644 --- a/src/dda/tools/go.py +++ b/src/dda/tools/go.py @@ -12,6 +12,8 @@ if TYPE_CHECKING: from collections.abc import Generator + from os import PathLike + from typing import Any class Go(Tool): @@ -62,3 +64,65 @@ def version(self) -> str | None: return match.group(1) return None + + def _build(self, args: list[str], **kwargs: Any) -> str: + """Run a raw go build command.""" + return self.capture(["build", *args], check=True, **kwargs) + + def build( + self, + entrypoint: str | PathLike, + output: str | PathLike, + *args: str, + build_tags: set[str] | None = None, + go_mod: str | PathLike | None = None, + gcflags: str | None = None, + ldflags: str | None = None, + **kwargs: dict[str, Any], + ) -> str: + """ + Run an instrumented Go build command. + + Args: + entrypoint: The go file / directory to build. + output: The path to the output binary. + *args: Extra positional arguments to pass to the go build command. + go_mod: Path to a go.mod file to use. By default will not be specified to the build command. + gcflags: The gcflags (go compiler flags) to use. Empty by default. + ldflags: The ldflags (go linker flags) to use. Empty by default. + **kwargs: Additional arguments to pass to the go build command. + """ + from platform import machine as architecture + + from dda.config.constants import Verbosity + from dda.utils.platform import PLATFORM_ID + + command_parts = [ + "-a", # Always rebuild the package for consistent behavior + "-trimpath", # Always use trimmed paths instead of absolute file system paths # NOTE: This might not work with delve + f"-o={output}", + ] + + # Enable data race detection on platforms that support it (all execpt windows arm64) + if not (PLATFORM_ID == "windows" and architecture() == "arm64"): + command_parts.append("-race") + + if self.app.config.terminal.verbosity >= Verbosity.VERBOSE: + command_parts.append("-v") + if self.app.config.terminal.verbosity >= Verbosity.DEBUG: + command_parts.append("-x") + + if go_mod: + command_parts.append(f"-mod={go_mod}") + if gcflags: + command_parts.append(f"-gcflags={gcflags}") + if ldflags: + command_parts.append(f"-ldflags={ldflags}") + + if build_tags: + command_parts.append(f"-tags={' '.join(sorted(build_tags))}") + + command_parts.extend(args) + command_parts.append(str(entrypoint)) + + return self._build(command_parts, **kwargs) diff --git a/tests/tools/go/__init__.py b/tests/tools/go/__init__.py new file mode 100644 index 00000000..79ca6026 --- /dev/null +++ b/tests/tools/go/__init__.py @@ -0,0 +1,3 @@ +# SPDX-FileCopyrightText: 2025-present Datadog, Inc. +# +# SPDX-License-Identifier: MIT diff --git a/tests/tools/go/conftest.py b/tests/tools/go/conftest.py new file mode 100644 index 00000000..1ca68128 --- /dev/null +++ b/tests/tools/go/conftest.py @@ -0,0 +1,19 @@ +import random +import string + +import pytest + +from dda.utils.fs import Path + +AVAILABLE_CHARS = string.ascii_letters + string.digits + "-_+.!@#$%^&*()[]{}:;,. " + + +@pytest.fixture +def get_random_filename(): + def _get_random_filename(k: int = 10, root: Path | None = None) -> Path: + name = "".join(random.choices(AVAILABLE_CHARS, k=k)) + if root: + return root / name + return Path(name) + + return _get_random_filename diff --git a/tests/tools/go/fixtures/small_go_project/debug.go b/tests/tools/go/fixtures/small_go_project/debug.go new file mode 100644 index 00000000..5fefc6ee --- /dev/null +++ b/tests/tools/go/fixtures/small_go_project/debug.go @@ -0,0 +1,9 @@ +//go:build debug +// +build debug + +package main + +// Also defined in prod.go to test build tags +func TagInfo() string { + return "DEBUG" +} diff --git a/tests/tools/go/fixtures/small_go_project/go.mod b/tests/tools/go/fixtures/small_go_project/go.mod new file mode 100644 index 00000000..94195dd5 --- /dev/null +++ b/tests/tools/go/fixtures/small_go_project/go.mod @@ -0,0 +1,3 @@ +module github.com/DataDog/datadog-agent-dev/tests/tools/go/fixtures/small_go_project + +go 1.21.0 diff --git a/tests/tools/go/fixtures/small_go_project/main.go b/tests/tools/go/fixtures/small_go_project/main.go new file mode 100644 index 00000000..df5140a4 --- /dev/null +++ b/tests/tools/go/fixtures/small_go_project/main.go @@ -0,0 +1,21 @@ +package main + +import ( + "fmt" + "os" + "runtime" +) + +func main() { + fmt.Printf("Hello from small go project!\n") + fmt.Printf("Go version: %s\n", runtime.Version()) + fmt.Printf("OS/Arch: %s/%s\n", runtime.GOOS, runtime.GOARCH) + + // Test command line args + if len(os.Args) > 1 { + fmt.Printf("Arguments: %v\n", os.Args[1:]) + } + + // Call build-specific function to test build tags + fmt.Printf("Tag: %s\n", TagInfo()) +} diff --git a/tests/tools/go/fixtures/small_go_project/prod.go b/tests/tools/go/fixtures/small_go_project/prod.go new file mode 100644 index 00000000..983d3457 --- /dev/null +++ b/tests/tools/go/fixtures/small_go_project/prod.go @@ -0,0 +1,9 @@ +//go:build !debug +// +build !debug + +package main + +// Also defined in debug.go to test build tags +func TagInfo() string { + return "PRODUCTION" +} diff --git a/tests/tools/go/test_go.py b/tests/tools/go/test_go.py new file mode 100644 index 00000000..5ee7cb13 --- /dev/null +++ b/tests/tools/go/test_go.py @@ -0,0 +1,105 @@ +# SPDX-FileCopyrightText: 2025-present Datadog, Inc. +# +# SPDX-License-Identifier: MIT +from __future__ import annotations + +import platform + +import pytest + +from dda.utils.fs import Path + + +def test_default(app): + with app.tools.go.execution_context([]) as context: + assert context.env_vars == {} + + +class TestPrecedence: + def test_workspace_file(self, app, temp_dir): + (temp_dir / "go.work").write_text("stuff\ngo X.Y.Z\nstuff") + with temp_dir.as_cwd(), app.tools.go.execution_context([]) as context: + assert context.env_vars == {"GOTOOLCHAIN": "goX.Y.Z"} + + def test_module_file(self, app, temp_dir): + (temp_dir / "go.work").write_text("stuff\ngo X.Y.Z\nstuff") + (temp_dir / "go.mod").write_text("stuff\ngo X.Y.Zrc1\nstuff") + with temp_dir.as_cwd(), app.tools.go.execution_context([]) as context: + assert context.env_vars == {"GOTOOLCHAIN": "goX.Y.Zrc1"} + + def test_version_file(self, app, temp_dir): + (temp_dir / "go.work").write_text("stuff\ngo X.Y.Z\nstuff") + (temp_dir / "go.mod").write_text("stuff\ngo X.Y.Zrc1\nstuff") + (temp_dir / ".go-version").write_text("X.Y.Zrc2") + with temp_dir.as_cwd(), app.tools.go.execution_context([]) as context: + assert context.env_vars == {"GOTOOLCHAIN": "goX.Y.Zrc2"} + + +class TestBuild: + @pytest.mark.parametrize( + "call_args", + [ + {}, + {"build_tags": ["debug"]}, + {"build_tags": ["prod"], "gcflags": "-gcflags=all=-N -l", "ldflags": "-ldflags=all=-s -w"}, + { + "build_tags": ["prod"], + "gcflags": "-gcflags=all=-N -l", + "ldflags": "-ldflags=all=-s -w", + "go_mod": "../go.mod", + }, + ], + ) + def test_command_formation(self, app, mocker, call_args, get_random_filename): + # Patch the raw _build method to avoid running anything + mocker.patch("dda.tools.go.Go._build", return_value="output") + + # Generate dummy entrypoint and output paths + entrypoint: Path = get_random_filename() + output: Path = get_random_filename() + app.tools.go.build( + entrypoint=entrypoint, + output=output, + **call_args, + ) + + # Assert the command is formed correctly + expected_command_flags = { + "-a", + "-trimpath", + f"-o={output}", + # "-v", # By default verbosity is INFO + # "-x", + } + if not (platform.machine() == "windows" and platform.machine() == "arm64"): + expected_command_flags.add("-race") + + if call_args.get("build_tags"): + expected_command_flags.add(f"-tags={' '.join(sorted(call_args.get('build_tags', [])))}") + if call_args.get("gcflags"): + expected_command_flags.add(f"-gcflags={call_args.get('gcflags')}") + if call_args.get("ldflags"): + expected_command_flags.add(f"-ldflags={call_args.get('ldflags')}") + if call_args.get("go_mod"): + expected_command_flags.add(f"-mod={call_args.get('go_mod')}") + + seen_command = app.tools.go._build.call_args[0][0] # noqa: SLF001 + seen_command_flags = {x for x in seen_command if x.startswith("-")} + assert seen_command_flags == expected_command_flags + assert seen_command[len(seen_command_flags)] == str(entrypoint) + + # This tests is quite slow, we'll only run it in CI + @pytest.mark.requires_ci + def test_build_project(self, app, temp_dir): + for tag, output_mark in [("prod", "PRODUCTION"), ("debug", "DEBUG")]: + with (Path(__file__).parent / "fixtures" / "small_go_project").as_cwd(): + app.tools.go.build( + entrypoint=".", + output=(temp_dir / "testbinary").absolute(), + build_tags=[tag], + ) + + assert (temp_dir / "testbinary").is_file() + output = app.subprocess.capture(str(temp_dir / "testbinary")) + assert output_mark in output + # Note: doing both builds in the same test with the same name also allows us to test the force rebuild diff --git a/tests/tools/test_go.py b/tests/tools/test_go.py deleted file mode 100644 index 49d68dfb..00000000 --- a/tests/tools/test_go.py +++ /dev/null @@ -1,29 +0,0 @@ -# SPDX-FileCopyrightText: 2025-present Datadog, Inc. -# -# SPDX-License-Identifier: MIT -from __future__ import annotations - - -def test_default(app): - with app.tools.go.execution_context([]) as context: - assert context.env_vars == {} - - -class TestPrecedence: - def test_workspace_file(self, app, temp_dir): - (temp_dir / "go.work").write_text("stuff\ngo X.Y.Z\nstuff") - with temp_dir.as_cwd(), app.tools.go.execution_context([]) as context: - assert context.env_vars == {"GOTOOLCHAIN": "goX.Y.Z"} - - def test_module_file(self, app, temp_dir): - (temp_dir / "go.work").write_text("stuff\ngo X.Y.Z\nstuff") - (temp_dir / "go.mod").write_text("stuff\ngo X.Y.Zrc1\nstuff") - with temp_dir.as_cwd(), app.tools.go.execution_context([]) as context: - assert context.env_vars == {"GOTOOLCHAIN": "goX.Y.Zrc1"} - - def test_version_file(self, app, temp_dir): - (temp_dir / "go.work").write_text("stuff\ngo X.Y.Z\nstuff") - (temp_dir / "go.mod").write_text("stuff\ngo X.Y.Zrc1\nstuff") - (temp_dir / ".go-version").write_text("X.Y.Zrc2") - with temp_dir.as_cwd(), app.tools.go.execution_context([]) as context: - assert context.env_vars == {"GOTOOLCHAIN": "goX.Y.Zrc2"} From e2434caece6da0e42981c09781f414077754e4ab Mon Sep 17 00:00:00 2001 From: Pierre-Louis Veyrenc Date: Wed, 1 Oct 2025 15:19:44 +0200 Subject: [PATCH 02/12] fix(go): Make the `-a` argument controllable This way we can use the build cache if needed, which is useful for example in the `test_build_project` test which rebuilds more or less the same project twice. --- src/dda/tools/go.py | 6 +++++- tests/tools/go/test_go.py | 4 +++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/src/dda/tools/go.py b/src/dda/tools/go.py index 4b51cf41..17abdeb0 100644 --- a/src/dda/tools/go.py +++ b/src/dda/tools/go.py @@ -78,6 +78,7 @@ def build( go_mod: str | PathLike | None = None, gcflags: str | None = None, ldflags: str | None = None, + force_rebuild: bool = False, **kwargs: dict[str, Any], ) -> str: """ @@ -90,6 +91,7 @@ def build( go_mod: Path to a go.mod file to use. By default will not be specified to the build command. gcflags: The gcflags (go compiler flags) to use. Empty by default. ldflags: The ldflags (go linker flags) to use. Empty by default. + force_rebuild: Whether to force a rebuild of the package and bypass the build cache. **kwargs: Additional arguments to pass to the go build command. """ from platform import machine as architecture @@ -98,11 +100,13 @@ def build( from dda.utils.platform import PLATFORM_ID command_parts = [ - "-a", # Always rebuild the package for consistent behavior "-trimpath", # Always use trimmed paths instead of absolute file system paths # NOTE: This might not work with delve f"-o={output}", ] + if force_rebuild: + command_parts.append("-a") + # Enable data race detection on platforms that support it (all execpt windows arm64) if not (PLATFORM_ID == "windows" and architecture() == "arm64"): command_parts.append("-race") diff --git a/tests/tools/go/test_go.py b/tests/tools/go/test_go.py index 5ee7cb13..0501f5f9 100644 --- a/tests/tools/go/test_go.py +++ b/tests/tools/go/test_go.py @@ -48,6 +48,7 @@ class TestBuild: "ldflags": "-ldflags=all=-s -w", "go_mod": "../go.mod", }, + {"force_rebuild": True}, ], ) def test_command_formation(self, app, mocker, call_args, get_random_filename): @@ -65,7 +66,6 @@ def test_command_formation(self, app, mocker, call_args, get_random_filename): # Assert the command is formed correctly expected_command_flags = { - "-a", "-trimpath", f"-o={output}", # "-v", # By default verbosity is INFO @@ -82,6 +82,8 @@ def test_command_formation(self, app, mocker, call_args, get_random_filename): expected_command_flags.add(f"-ldflags={call_args.get('ldflags')}") if call_args.get("go_mod"): expected_command_flags.add(f"-mod={call_args.get('go_mod')}") + if call_args.get("force_rebuild"): + expected_command_flags.add("-a") seen_command = app.tools.go._build.call_args[0][0] # noqa: SLF001 seen_command_flags = {x for x in seen_command if x.startswith("-")} From fea5456398802a2a1bbb574030b27dc4bf6354a7 Mon Sep 17 00:00:00 2001 From: Pierre-Louis Veyrenc Date: Mon, 3 Nov 2025 13:35:29 +0100 Subject: [PATCH 03/12] fix(build): Prevent `get_random_filename` from creating filenames starting with `-` This would cause a failure in `test_command_formation` if the entrypoint starts with `-`, as it would then be considered a flag instead of a parameter --- tests/tools/go/conftest.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/tests/tools/go/conftest.py b/tests/tools/go/conftest.py index 1ca68128..235c46c7 100644 --- a/tests/tools/go/conftest.py +++ b/tests/tools/go/conftest.py @@ -12,8 +12,10 @@ def get_random_filename(): def _get_random_filename(k: int = 10, root: Path | None = None) -> Path: name = "".join(random.choices(AVAILABLE_CHARS, k=k)) + # Remove the leading `-` to avoid considering it as a command flag + path = Path(name.removeprefix("-")) if root: - return root / name - return Path(name) + return root / path + return path return _get_random_filename From ac5fde07470ec35bbfd90c6e8053de0335304934 Mon Sep 17 00:00:00 2001 From: Pierre-Louis Veyrenc Date: Wed, 5 Nov 2025 14:08:04 +0100 Subject: [PATCH 04/12] feat(tests): Add `skip_${OS}` pytest markers for skipping tests on specific operating systems --- tests/conftest.py | 20 ++++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index 6864cc57..0ac1920b 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -232,21 +232,37 @@ def pytest_runtest_setup(item): if marker.name == "requires_windows" and PLATFORM_ID != "windows": pytest.skip("Not running on Windows") + if marker.name == "skip_windows" and PLATFORM_ID == "windows": + pytest.skip("Test should be skipped on Windows") + if marker.name == "requires_macos" and PLATFORM_ID != "macos": pytest.skip("Not running on macOS") + if marker.name == "skip_macos" and PLATFORM_ID == "macos": + pytest.skip("Test should be skipped on macOS") + if marker.name == "requires_linux" and PLATFORM_ID != "linux": pytest.skip("Not running on Linux") - if marker.name == "requires_unix" and PLATFORM_ID == "windows": - pytest.skip("Not running on a Linux-based platform") + if marker.name == "skip_linux" and PLATFORM_ID == "linux": + pytest.skip("Test should be skipped on Linux") + + if marker.name == "requires_unix" and PLATFORM_ID not in {"linux", "macos"}: + pytest.skip("Not running on a Unix-based platform") + + if marker.name == "skip_unix" and PLATFORM_ID in {"linux", "macos"}: + pytest.skip("Test should be skipped on Unix-based platforms") def pytest_configure(config): config.addinivalue_line("markers", "requires_ci: Tests intended for CI environments") config.addinivalue_line("markers", "requires_windows: Tests intended for Windows operating systems") + config.addinivalue_line("markers", "skip_windows: Tests should be skipped on Windows operating systems") config.addinivalue_line("markers", "requires_macos: Tests intended for macOS operating systems") + config.addinivalue_line("markers", "skip_macos: Tests should be skipped on macOS operating systems") config.addinivalue_line("markers", "requires_linux: Tests intended for Linux operating systems") + config.addinivalue_line("markers", "skip_linux: Tests should be skipped on Linux operating systems") config.addinivalue_line("markers", "requires_unix: Tests intended for Linux-based operating systems") + config.addinivalue_line("markers", "skip_unix: Tests should be skipped on Unix-based operating systems") config.getini("norecursedirs").remove("build") # /tests/cli/build From 37748c865b0aabcefe8f3c5e5a6bf3fa2e8ba39d Mon Sep 17 00:00:00 2001 From: Pierre-Louis Veyrenc Date: Wed, 5 Nov 2025 14:08:54 +0100 Subject: [PATCH 05/12] fix(tests): Skip `TestBuild::test_build_project` on macOS as `go` is not installed in CI runners --- tests/tools/go/test_go.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/tools/go/test_go.py b/tests/tools/go/test_go.py index 0501f5f9..7d9a1b08 100644 --- a/tests/tools/go/test_go.py +++ b/tests/tools/go/test_go.py @@ -92,6 +92,7 @@ def test_command_formation(self, app, mocker, call_args, get_random_filename): # This tests is quite slow, we'll only run it in CI @pytest.mark.requires_ci + @pytest.mark.skip_macos # Go binary is not installed on macOS CI runners def test_build_project(self, app, temp_dir): for tag, output_mark in [("prod", "PRODUCTION"), ("debug", "DEBUG")]: with (Path(__file__).parent / "fixtures" / "small_go_project").as_cwd(): From 23cc2515a246ededa03d53435266f31a011321b6 Mon Sep 17 00:00:00 2001 From: Pierre-Louis Veyrenc Date: Fri, 7 Nov 2025 12:48:10 +0100 Subject: [PATCH 06/12] fix(go): Tweak `Go.build` method interface after testing - Additionnal quoting around some arguments - Pass `tags` as a comma-separated list - Pass custom env vars to the build command - `-mod` argument has a different purpose than previously thought --- src/dda/tools/go.py | 26 ++++++++++---------- tests/tools/go/test_go.py | 52 +++++++++++++++++++++------------------ 2 files changed, 41 insertions(+), 37 deletions(-) diff --git a/src/dda/tools/go.py b/src/dda/tools/go.py index 17abdeb0..ebcfd370 100644 --- a/src/dda/tools/go.py +++ b/src/dda/tools/go.py @@ -11,7 +11,7 @@ from dda.utils.fs import Path if TYPE_CHECKING: - from collections.abc import Generator + from collections.abc import Generator, Iterable from os import PathLike from typing import Any @@ -75,9 +75,9 @@ def build( output: str | PathLike, *args: str, build_tags: set[str] | None = None, - go_mod: str | PathLike | None = None, - gcflags: str | None = None, - ldflags: str | None = None, + gcflags: Iterable[str] | None = None, + ldflags: Iterable[str] | None = None, + env_vars: dict[str, str] | None = None, force_rebuild: bool = False, **kwargs: dict[str, Any], ) -> str: @@ -88,9 +88,9 @@ def build( entrypoint: The go file / directory to build. output: The path to the output binary. *args: Extra positional arguments to pass to the go build command. - go_mod: Path to a go.mod file to use. By default will not be specified to the build command. - gcflags: The gcflags (go compiler flags) to use. Empty by default. - ldflags: The ldflags (go linker flags) to use. Empty by default. + gcflags: The gcflags (go compiler flags) to use, passed as a list of strings. Empty by default. + ldflags: The ldflags (go linker flags) to use, passed as a list of strings. Empty by default. + env_vars: Extra environment variables to set for the build command. Empty by default. force_rebuild: Whether to force a rebuild of the package and bypass the build cache. **kwargs: Additional arguments to pass to the go build command. """ @@ -101,6 +101,7 @@ def build( command_parts = [ "-trimpath", # Always use trimmed paths instead of absolute file system paths # NOTE: This might not work with delve + "-mod=readonly", # Always use readonly mode, we never use anything else f"-o={output}", ] @@ -116,17 +117,16 @@ def build( if self.app.config.terminal.verbosity >= Verbosity.DEBUG: command_parts.append("-x") - if go_mod: - command_parts.append(f"-mod={go_mod}") if gcflags: - command_parts.append(f"-gcflags={gcflags}") + command_parts.append(f"-gcflags={' '.join(gcflags)}") if ldflags: - command_parts.append(f"-ldflags={ldflags}") + command_parts.append(f"-ldflags={' '.join(ldflags)}") if build_tags: - command_parts.append(f"-tags={' '.join(sorted(build_tags))}") + command_parts.extend(("-tags", f"{','.join(sorted(build_tags))}")) command_parts.extend(args) command_parts.append(str(entrypoint)) - return self._build(command_parts, **kwargs) + # TODO: Debug log the command parts ? + return self._build(command_parts, env=env_vars, **kwargs) diff --git a/tests/tools/go/test_go.py b/tests/tools/go/test_go.py index 7d9a1b08..5a26fd74 100644 --- a/tests/tools/go/test_go.py +++ b/tests/tools/go/test_go.py @@ -41,13 +41,7 @@ class TestBuild: [ {}, {"build_tags": ["debug"]}, - {"build_tags": ["prod"], "gcflags": "-gcflags=all=-N -l", "ldflags": "-ldflags=all=-s -w"}, - { - "build_tags": ["prod"], - "gcflags": "-gcflags=all=-N -l", - "ldflags": "-ldflags=all=-s -w", - "go_mod": "../go.mod", - }, + {"build_tags": ["prod"], "gcflags": ["all=-N -l"], "ldflags": ["all=-s -w", "-dumpdep"]}, {"force_rebuild": True}, ], ) @@ -64,31 +58,40 @@ def test_command_formation(self, app, mocker, call_args, get_random_filename): **call_args, ) - # Assert the command is formed correctly - expected_command_flags = { - "-trimpath", - f"-o={output}", - # "-v", # By default verbosity is INFO - # "-x", + flags = { + ("-trimpath",), + ("-mod=readonly",), + (f"-o={output}",), + # ("-v",), # By default verbosity is INFO + # ("-x",), } + if not (platform.machine() == "windows" and platform.machine() == "arm64"): - expected_command_flags.add("-race") + flags.add(("-race",)) if call_args.get("build_tags"): - expected_command_flags.add(f"-tags={' '.join(sorted(call_args.get('build_tags', [])))}") + flags.add(("-tags", ",".join(sorted(call_args.get("build_tags", []))))) if call_args.get("gcflags"): - expected_command_flags.add(f"-gcflags={call_args.get('gcflags')}") + flags.add((f"-gcflags={' '.join(call_args.get('gcflags'))}",)) if call_args.get("ldflags"): - expected_command_flags.add(f"-ldflags={call_args.get('ldflags')}") - if call_args.get("go_mod"): - expected_command_flags.add(f"-mod={call_args.get('go_mod')}") + flags.add((f"-ldflags={' '.join(call_args.get('ldflags'))}",)) if call_args.get("force_rebuild"): - expected_command_flags.add("-a") + flags.add(("-a",)) + + entrypoint = [str(entrypoint)] + + seen_command_parts = app.tools.go._build.call_args[0][0] # noqa: SLF001 + + flags_len = len(flags) + seen_flags: list[str] = seen_command_parts[: flags_len + 1] + for flag_tuple in flags: + assert flag_tuple[0] in seen_flags + + if len(flag_tuple) > 1: + flag_index = seen_flags.index(flag_tuple[0]) + assert seen_flags[flag_index + 1] == flag_tuple[1] - seen_command = app.tools.go._build.call_args[0][0] # noqa: SLF001 - seen_command_flags = {x for x in seen_command if x.startswith("-")} - assert seen_command_flags == expected_command_flags - assert seen_command[len(seen_command_flags)] == str(entrypoint) + assert seen_command_parts[-len(entrypoint) :] == entrypoint # This tests is quite slow, we'll only run it in CI @pytest.mark.requires_ci @@ -100,6 +103,7 @@ def test_build_project(self, app, temp_dir): entrypoint=".", output=(temp_dir / "testbinary").absolute(), build_tags=[tag], + force_rebuild=True, ) assert (temp_dir / "testbinary").is_file() From 49829e2eb18fb5ba0c6165af387f0aac4c700a2d Mon Sep 17 00:00:00 2001 From: Pierre-Louis Veyrenc Date: Wed, 10 Dec 2025 14:53:27 +0100 Subject: [PATCH 07/12] refactor: Minor Copilot-suggested tweaks --- src/dda/tools/go.py | 5 +++-- tests/tools/go/test_go.py | 4 ++-- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/src/dda/tools/go.py b/src/dda/tools/go.py index ebcfd370..26b8ca64 100644 --- a/src/dda/tools/go.py +++ b/src/dda/tools/go.py @@ -79,7 +79,7 @@ def build( ldflags: Iterable[str] | None = None, env_vars: dict[str, str] | None = None, force_rebuild: bool = False, - **kwargs: dict[str, Any], + **kwargs: Any, ) -> str: """ Run an instrumented Go build command. @@ -88,6 +88,7 @@ def build( entrypoint: The go file / directory to build. output: The path to the output binary. *args: Extra positional arguments to pass to the go build command. + build_tags: Build tags to include when compiling. Empty by default. gcflags: The gcflags (go compiler flags) to use, passed as a list of strings. Empty by default. ldflags: The ldflags (go linker flags) to use, passed as a list of strings. Empty by default. env_vars: Extra environment variables to set for the build command. Empty by default. @@ -108,7 +109,7 @@ def build( if force_rebuild: command_parts.append("-a") - # Enable data race detection on platforms that support it (all execpt windows arm64) + # Enable data race detection on platforms that support it (all except windows arm64) if not (PLATFORM_ID == "windows" and architecture() == "arm64"): command_parts.append("-race") diff --git a/tests/tools/go/test_go.py b/tests/tools/go/test_go.py index 5a26fd74..c2e941a0 100644 --- a/tests/tools/go/test_go.py +++ b/tests/tools/go/test_go.py @@ -66,7 +66,7 @@ def test_command_formation(self, app, mocker, call_args, get_random_filename): # ("-x",), } - if not (platform.machine() == "windows" and platform.machine() == "arm64"): + if not (platform.system() == "Windows" and platform.machine() == "arm64"): flags.add(("-race",)) if call_args.get("build_tags"): @@ -93,7 +93,7 @@ def test_command_formation(self, app, mocker, call_args, get_random_filename): assert seen_command_parts[-len(entrypoint) :] == entrypoint - # This tests is quite slow, we'll only run it in CI + # This test is quite slow, we'll only run it in CI @pytest.mark.requires_ci @pytest.mark.skip_macos # Go binary is not installed on macOS CI runners def test_build_project(self, app, temp_dir): From a70eed8bcdd4c8ef7f6088957f9b48f77664cb97 Mon Sep 17 00:00:00 2001 From: Pierre-Louis Veyrenc Date: Wed, 10 Dec 2025 18:19:45 +0100 Subject: [PATCH 08/12] refactor: Simplify `get_random_filename` test fixture --- tests/tools/go/conftest.py | 13 +++---------- 1 file changed, 3 insertions(+), 10 deletions(-) diff --git a/tests/tools/go/conftest.py b/tests/tools/go/conftest.py index 235c46c7..ab4b6311 100644 --- a/tests/tools/go/conftest.py +++ b/tests/tools/go/conftest.py @@ -1,21 +1,14 @@ -import random -import string +from base64 import urlsafe_b64encode +from os import urandom import pytest from dda.utils.fs import Path -AVAILABLE_CHARS = string.ascii_letters + string.digits + "-_+.!@#$%^&*()[]{}:;,. " - @pytest.fixture def get_random_filename(): def _get_random_filename(k: int = 10, root: Path | None = None) -> Path: - name = "".join(random.choices(AVAILABLE_CHARS, k=k)) - # Remove the leading `-` to avoid considering it as a command flag - path = Path(name.removeprefix("-")) - if root: - return root / path - return path + return (root or Path()).joinpath(urlsafe_b64encode(urandom(k)).decode("utf-8")) return _get_random_filename From 946a62f8ee4090c5762846ac6d1b81f8480659e2 Mon Sep 17 00:00:00 2001 From: Pierre-Louis Veyrenc Date: Wed, 10 Dec 2025 18:54:00 +0100 Subject: [PATCH 09/12] refactor(go): Pass `*packages` instead of a single `entrypoint` --- src/dda/tools/go.py | 12 ++++++------ tests/tools/go/test_go.py | 13 +++++-------- 2 files changed, 11 insertions(+), 14 deletions(-) diff --git a/src/dda/tools/go.py b/src/dda/tools/go.py index 26b8ca64..4b8b22c0 100644 --- a/src/dda/tools/go.py +++ b/src/dda/tools/go.py @@ -71,9 +71,8 @@ def _build(self, args: list[str], **kwargs: Any) -> str: def build( self, - entrypoint: str | PathLike, + *packages: str | PathLike, output: str | PathLike, - *args: str, build_tags: set[str] | None = None, gcflags: Iterable[str] | None = None, ldflags: Iterable[str] | None = None, @@ -85,9 +84,8 @@ def build( Run an instrumented Go build command. Args: - entrypoint: The go file / directory to build. + packages: The go packages to build, passed as a list of strings or Paths. Needs at least one item. output: The path to the output binary. - *args: Extra positional arguments to pass to the go build command. build_tags: Build tags to include when compiling. Empty by default. gcflags: The gcflags (go compiler flags) to use, passed as a list of strings. Empty by default. ldflags: The ldflags (go linker flags) to use, passed as a list of strings. Empty by default. @@ -126,8 +124,10 @@ def build( if build_tags: command_parts.extend(("-tags", f"{','.join(sorted(build_tags))}")) - command_parts.extend(args) - command_parts.append(str(entrypoint)) + if len(packages) < 1: + msg = "At least one package is required to build" + raise ValueError(msg) + command_parts.extend(str(package) for package in packages) # TODO: Debug log the command parts ? return self._build(command_parts, env=env_vars, **kwargs) diff --git a/tests/tools/go/test_go.py b/tests/tools/go/test_go.py index c2e941a0..53f5da1d 100644 --- a/tests/tools/go/test_go.py +++ b/tests/tools/go/test_go.py @@ -49,11 +49,11 @@ def test_command_formation(self, app, mocker, call_args, get_random_filename): # Patch the raw _build method to avoid running anything mocker.patch("dda.tools.go.Go._build", return_value="output") - # Generate dummy entrypoint and output paths - entrypoint: Path = get_random_filename() + # Generate dummy package and output paths + packages: tuple[Path, ...] = (get_random_filename(), get_random_filename()) output: Path = get_random_filename() app.tools.go.build( - entrypoint=entrypoint, + *packages, output=output, **call_args, ) @@ -78,8 +78,6 @@ def test_command_formation(self, app, mocker, call_args, get_random_filename): if call_args.get("force_rebuild"): flags.add(("-a",)) - entrypoint = [str(entrypoint)] - seen_command_parts = app.tools.go._build.call_args[0][0] # noqa: SLF001 flags_len = len(flags) @@ -90,8 +88,7 @@ def test_command_formation(self, app, mocker, call_args, get_random_filename): if len(flag_tuple) > 1: flag_index = seen_flags.index(flag_tuple[0]) assert seen_flags[flag_index + 1] == flag_tuple[1] - - assert seen_command_parts[-len(entrypoint) :] == entrypoint + assert seen_command_parts[-len(packages) :] == [str(package) for package in packages] # This test is quite slow, we'll only run it in CI @pytest.mark.requires_ci @@ -100,7 +97,7 @@ def test_build_project(self, app, temp_dir): for tag, output_mark in [("prod", "PRODUCTION"), ("debug", "DEBUG")]: with (Path(__file__).parent / "fixtures" / "small_go_project").as_cwd(): app.tools.go.build( - entrypoint=".", + ".", output=(temp_dir / "testbinary").absolute(), build_tags=[tag], force_rebuild=True, From 86f06c8393505dc1f583fbb44d247df53e9ce566 Mon Sep 17 00:00:00 2001 From: Pierre-Louis Veyrenc Date: Wed, 10 Dec 2025 18:54:45 +0100 Subject: [PATCH 10/12] refactor(tests): Use sets of build tags everywhere --- tests/tools/go/test_go.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/tools/go/test_go.py b/tests/tools/go/test_go.py index 53f5da1d..3aabe7ec 100644 --- a/tests/tools/go/test_go.py +++ b/tests/tools/go/test_go.py @@ -40,8 +40,8 @@ class TestBuild: "call_args", [ {}, - {"build_tags": ["debug"]}, - {"build_tags": ["prod"], "gcflags": ["all=-N -l"], "ldflags": ["all=-s -w", "-dumpdep"]}, + {"build_tags": {"debug"}}, + {"build_tags": {"prod"}, "gcflags": ["all=-N -l"], "ldflags": ["all=-s -w", "-dumpdep"]}, {"force_rebuild": True}, ], ) @@ -99,7 +99,7 @@ def test_build_project(self, app, temp_dir): app.tools.go.build( ".", output=(temp_dir / "testbinary").absolute(), - build_tags=[tag], + build_tags={tag}, force_rebuild=True, ) From 8ba515b38e6a96cc4fe6ad26fcf0769a4fb31802 Mon Sep 17 00:00:00 2001 From: Pierre-Louis Veyrenc Date: Fri, 12 Dec 2025 09:45:55 +0100 Subject: [PATCH 11/12] feat(go): Allow passing 0 packages to `build` method --- src/dda/tools/go.py | 6 ++---- tests/tools/go/test_go.py | 16 ++++++++++------ 2 files changed, 12 insertions(+), 10 deletions(-) diff --git a/src/dda/tools/go.py b/src/dda/tools/go.py index 4b8b22c0..168ee24d 100644 --- a/src/dda/tools/go.py +++ b/src/dda/tools/go.py @@ -84,7 +84,8 @@ def build( Run an instrumented Go build command. Args: - packages: The go packages to build, passed as a list of strings or Paths. Needs at least one item. + packages: The go packages to build, passed as a list of strings or Paths. + Empty by default, which is equivalent to building the current directory. output: The path to the output binary. build_tags: Build tags to include when compiling. Empty by default. gcflags: The gcflags (go compiler flags) to use, passed as a list of strings. Empty by default. @@ -124,9 +125,6 @@ def build( if build_tags: command_parts.extend(("-tags", f"{','.join(sorted(build_tags))}")) - if len(packages) < 1: - msg = "At least one package is required to build" - raise ValueError(msg) command_parts.extend(str(package) for package in packages) # TODO: Debug log the command parts ? diff --git a/tests/tools/go/test_go.py b/tests/tools/go/test_go.py index 3aabe7ec..a0109b44 100644 --- a/tests/tools/go/test_go.py +++ b/tests/tools/go/test_go.py @@ -39,10 +39,11 @@ class TestBuild: @pytest.mark.parametrize( "call_args", [ - {}, - {"build_tags": {"debug"}}, - {"build_tags": {"prod"}, "gcflags": ["all=-N -l"], "ldflags": ["all=-s -w", "-dumpdep"]}, - {"force_rebuild": True}, + {"n_packages": 2}, + {"n_packages": 1, "build_tags": {"debug"}}, + {"n_packages": 0, "build_tags": {"prod"}, "gcflags": ["all=-N -l"], "ldflags": ["all=-s -w", "-dumpdep"]}, + {"n_packages": 2, "build_tags": {"prod"}, "gcflags": ["all=-N -l"], "ldflags": ["all=-s -w", "-dumpdep"]}, + {"n_packages": 0, "force_rebuild": True}, ], ) def test_command_formation(self, app, mocker, call_args, get_random_filename): @@ -50,7 +51,8 @@ def test_command_formation(self, app, mocker, call_args, get_random_filename): mocker.patch("dda.tools.go.Go._build", return_value="output") # Generate dummy package and output paths - packages: tuple[Path, ...] = (get_random_filename(), get_random_filename()) + n_packages = call_args.pop("n_packages", 0) + packages: tuple[Path, ...] = tuple(get_random_filename() for _ in range(n_packages)) output: Path = get_random_filename() app.tools.go.build( *packages, @@ -88,7 +90,9 @@ def test_command_formation(self, app, mocker, call_args, get_random_filename): if len(flag_tuple) > 1: flag_index = seen_flags.index(flag_tuple[0]) assert seen_flags[flag_index + 1] == flag_tuple[1] - assert seen_command_parts[-len(packages) :] == [str(package) for package in packages] + + if n_packages > 0: + assert seen_command_parts[-len(packages) :] == [str(package) for package in packages] # This test is quite slow, we'll only run it in CI @pytest.mark.requires_ci From f16be019d3a853de1a76db9e680015fd19728f9c Mon Sep 17 00:00:00 2001 From: Pierre-Louis Veyrenc Date: Fri, 12 Dec 2025 09:48:27 +0100 Subject: [PATCH 12/12] refactor(tests): `get_random_filename` returns a simple `str` --- tests/tools/go/conftest.py | 6 ++---- tests/tools/go/test_go.py | 2 +- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/tests/tools/go/conftest.py b/tests/tools/go/conftest.py index ab4b6311..4ffd83f5 100644 --- a/tests/tools/go/conftest.py +++ b/tests/tools/go/conftest.py @@ -3,12 +3,10 @@ import pytest -from dda.utils.fs import Path - @pytest.fixture def get_random_filename(): - def _get_random_filename(k: int = 10, root: Path | None = None) -> Path: - return (root or Path()).joinpath(urlsafe_b64encode(urandom(k)).decode("utf-8")) + def _get_random_filename(k: int = 10) -> str: + return urlsafe_b64encode(urandom(k)).decode("utf-8") return _get_random_filename diff --git a/tests/tools/go/test_go.py b/tests/tools/go/test_go.py index a0109b44..fa643818 100644 --- a/tests/tools/go/test_go.py +++ b/tests/tools/go/test_go.py @@ -52,7 +52,7 @@ def test_command_formation(self, app, mocker, call_args, get_random_filename): # Generate dummy package and output paths n_packages = call_args.pop("n_packages", 0) - packages: tuple[Path, ...] = tuple(get_random_filename() for _ in range(n_packages)) + packages: tuple[str, ...] = tuple(get_random_filename() for _ in range(n_packages)) output: Path = get_random_filename() app.tools.go.build( *packages,