Skip to content

Commit 87cab28

Browse files
authored
Avoid arg limits for internal uses of Bazel (#190)
* Avoid arg limits for internal uses of Bazel * address review
1 parent 4aec335 commit 87cab28

File tree

9 files changed

+203
-23
lines changed

9 files changed

+203
-23
lines changed

CHANGELOG.md

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,19 +8,27 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
88

99
## Unreleased
1010

11+
***Changed:***
12+
13+
- Rename `build bazel` command to `bzl`
14+
15+
***Added:***
16+
17+
- Methods on the `app.tools.bazel` tool now prevent exceeding platform-specific command line length limits
18+
1119
## 0.27.0 - 2025-09-22
1220

1321
***Changed:***
1422

15-
- Rename the `[git.user]` section to `[tools.git.author]`
23+
- Rename the `[git.user]` config section to `[tools.git.author]`
1624
- The `Tool` interface now uses a single execution context instead of the dedicated methods `format_command` and `env_vars`
17-
- Updated Atlassian API to 4.0.7
1825

1926
***Added:***
2027

2128
- Add `git` tool
2229
- Add `[user]` section to the configuration
2330
- Add abstract context manager `execution_context` method to the `Tool` interface
31+
- Updated Atlassian API to 4.0.7
2432

2533
## 0.26.0 - 2025-09-09
2634

docs/reference/api/tools.md

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,23 +5,28 @@
55
::: dda.tools.Tools
66
options:
77
members:
8+
- bazel
89
- docker
10+
- git
911
- go
1012
- uv
11-
- git
13+
14+
::: dda.tools.bazel.Bazel
15+
options:
16+
members: []
1217

1318
::: dda.tools.docker.Docker
1419
options:
1520
members: []
1621

17-
::: dda.tools.go.Go
22+
::: dda.tools.git.Git
1823
options:
1924
members: []
2025

21-
::: dda.tools.uv.UV
26+
::: dda.tools.go.Go
2227
options:
2328
members: []
2429

25-
::: dda.tools.git.Git
30+
::: dda.tools.uv.UV
2631
options:
2732
members: []

src/dda/cli/base.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -502,6 +502,16 @@ def ensure_features_installed(
502502
app.tools.uv.wait(command, message="Synchronizing dependencies", cwd=str(temp_dir), env=env_vars)
503503

504504

505+
def get_raw_args(ctx: click.Context) -> list[str]:
506+
level = len(ctx.command_path.split())
507+
return _get_argv()[level:]
508+
509+
510+
def _get_argv() -> list[str]:
511+
# This is used for test assertions only
512+
return sys.argv
513+
514+
505515
def _get_root_ctx(ctx: click.Context) -> DynamicContext:
506516
while ctx.parent is not None:
507517
ctx = ctx.parent

src/dda/cli/build/bazel/__init__.py renamed to src/dda/cli/bzl/__init__.py

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,18 +7,20 @@
77

88
import click
99

10-
from dda.cli.base import dynamic_command, pass_app
10+
from dda.cli.base import dynamic_command, get_raw_args
1111

1212
if TYPE_CHECKING:
1313
from dda.cli.application import Application
1414

1515

1616
@dynamic_command(
1717
short_help="Run Bazel commands",
18-
context_settings={"help_option_names": [], "ignore_unknown_options": True},
18+
context_settings={"help_option_names": [], "ignore_unknown_options": True, "allow_extra_args": True},
1919
)
20-
@click.argument("args", nargs=-1)
21-
@pass_app
22-
def cmd(app: Application, *, args: tuple[str, ...]) -> None:
23-
process = app.tools.bazel.attach(list(args), check=False)
20+
@click.pass_context
21+
def cmd(ctx: click.Context) -> None:
22+
app: Application = ctx.obj
23+
with app.tools.bazel.ignore_arg_limits():
24+
process = app.tools.bazel.attach(get_raw_args(ctx), check=False)
25+
2426
app.abort(code=process.returncode)

src/dda/tools/bazel.py

Lines changed: 72 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,8 @@
44
from __future__ import annotations
55

66
from contextlib import contextmanager
7-
from functools import cached_property
8-
from typing import TYPE_CHECKING
7+
from functools import cache, cached_property
8+
from typing import TYPE_CHECKING, Any
99

1010
from dda.tools.base import ExecutionContext, Tool
1111
from dda.utils.platform import PLATFORM_ID, which
@@ -28,9 +28,52 @@ class Bazel(Tool):
2828
to an internal location if `bazel` nor `bazelisk` are already on PATH.
2929
"""
3030

31+
def __init__(self, *args: Any, **kwargs: Any) -> None:
32+
super().__init__(*args, **kwargs)
33+
34+
# Avoid platform-specific command line length limits by default
35+
self.__ignore_arg_limits = False
36+
3137
@contextmanager
3238
def execution_context(self, command: list[str]) -> Generator[ExecutionContext, None, None]:
33-
yield ExecutionContext(command=[self.path, *command], env_vars={})
39+
first_arg: str | None = None
40+
for arg in command:
41+
if not arg.startswith("-"):
42+
first_arg = arg
43+
break
44+
45+
if first_arg is None:
46+
yield ExecutionContext(command=[self.path, *command], env_vars={})
47+
return
48+
49+
try:
50+
sep_index = command.index("--")
51+
except ValueError:
52+
if self.__ignore_arg_limits:
53+
yield ExecutionContext(command=[self.path, *command], env_vars={})
54+
return
55+
56+
msg = "Bazel arguments must come after the `--` separator"
57+
raise ValueError(msg) from None
58+
59+
if first_arg in self.target_accepting_commands:
60+
arg_file_flag = "--target_pattern_file"
61+
elif first_arg in self.query_accepting_commands:
62+
arg_file_flag = "--query_file"
63+
else:
64+
yield ExecutionContext(command=[self.path, *command], env_vars={})
65+
return
66+
67+
from tempfile import NamedTemporaryFile
68+
69+
with NamedTemporaryFile(mode="w", encoding="utf-8") as f:
70+
f.write("\n".join(command[sep_index + 1 :]))
71+
f.flush()
72+
73+
yield ExecutionContext(
74+
command=[self.path, *command[:sep_index], arg_file_flag, f.name],
75+
env_vars={},
76+
)
3477

3578
@property
3679
def managed(self) -> bool:
@@ -67,6 +110,22 @@ def __external_path(self) -> str | None:
67110

68111
return None
69112

113+
@property
114+
def target_accepting_commands(self) -> frozenset[str]:
115+
return target_accepting_commands()
116+
117+
@property
118+
def query_accepting_commands(self) -> frozenset[str]:
119+
return query_accepting_commands()
120+
121+
@contextmanager
122+
def ignore_arg_limits(self) -> Generator[None, None, None]:
123+
self.__ignore_arg_limits = True
124+
try:
125+
yield
126+
finally:
127+
self.__ignore_arg_limits = False
128+
70129

71130
def get_download_url() -> str:
72131
import platform
@@ -80,3 +139,13 @@ def get_download_url() -> str:
80139

81140
url = f"https://github.com/bazelbuild/bazelisk/releases/latest/download/bazelisk-{system}-{arch}"
82141
return f"{url}.exe" if PLATFORM_ID == "windows" else url
142+
143+
144+
@cache
145+
def target_accepting_commands() -> frozenset[str]:
146+
return frozenset({"build", "coverage", "fetch", "run", "test"})
147+
148+
149+
@cache
150+
def query_accepting_commands() -> frozenset[str]:
151+
return frozenset({"aquery", "cquery", "query"})
File renamed without changes.

tests/cli/build/test_bazel.py renamed to tests/cli/bzl/test_bzl.py

Lines changed: 32 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -10,11 +10,13 @@
1010

1111

1212
def test_default_download(dda, helpers, isolation, mocker):
13+
args = ["build", "//..."]
14+
mocker.patch("dda.cli.base._get_argv", return_value=["dda", "bzl", *args])
1315
downloader = mocker.patch("dda.utils.network.http.manager.HTTPClientManager.download")
1416
subprocess_runner = mocker.patch("subprocess.run", return_value=subprocess.CompletedProcess(args=[], returncode=0))
1517

1618
with EnvVars(exclude=["PATH"]):
17-
result = dda("build", "bazel", "build", "//...")
19+
result = dda("bzl", *args)
1820

1921
assert result.exit_code == 0, result.output
2022
assert result.output == helpers.dedent(
@@ -28,24 +30,26 @@ def test_default_download(dda, helpers, isolation, mocker):
2830
get_download_url(),
2931
path=internal_bazel_path,
3032
)
31-
subprocess_runner.assert_called_once_with([str(internal_bazel_path), "build", "//..."])
33+
subprocess_runner.assert_called_once_with([str(internal_bazel_path), *args])
3234

3335

3436
def test_default_exists(dda, helpers, temp_dir, mocker):
3537
external_bazel_path = temp_dir.joinpath("bazel").as_exe()
3638
helpers.create_binary(external_bazel_path)
3739

40+
args = ["build", "//..."]
41+
mocker.patch("dda.cli.base._get_argv", return_value=["dda", "bzl", *args])
3842
downloader = mocker.patch("dda.utils.network.http.manager.HTTPClientManager.download")
3943
subprocess_runner = mocker.patch("subprocess.run", return_value=subprocess.CompletedProcess(args=[], returncode=0))
4044

4145
with EnvVars({"PATH": str(temp_dir)}):
42-
result = dda("build", "bazel", "build", "//...")
46+
result = dda("bzl", *args)
4347

4448
assert result.exit_code == 0, result.output
4549
assert not result.output
4650

4751
downloader.assert_not_called()
48-
subprocess_runner.assert_called_once_with([str(external_bazel_path), "build", "//..."])
52+
subprocess_runner.assert_called_once_with([str(external_bazel_path), *args])
4953

5054

5155
def test_config_force_managed(dda, helpers, isolation, config_file, temp_dir, mocker):
@@ -55,11 +59,13 @@ def test_config_force_managed(dda, helpers, isolation, config_file, temp_dir, mo
5559
external_bazel_path = temp_dir.joinpath("bazel").as_exe()
5660
helpers.create_binary(external_bazel_path)
5761

62+
args = ["build", "//..."]
63+
mocker.patch("dda.cli.base._get_argv", return_value=["dda", "bzl", *args])
5864
downloader = mocker.patch("dda.utils.network.http.manager.HTTPClientManager.download")
5965
subprocess_runner = mocker.patch("subprocess.run", return_value=subprocess.CompletedProcess(args=[], returncode=0))
6066

6167
with EnvVars({"PATH": str(temp_dir)}):
62-
result = dda("build", "bazel", "build", "//...")
68+
result = dda("bzl", *args)
6369

6470
assert result.exit_code == 0, result.output
6571
assert result.output == helpers.dedent(
@@ -73,17 +79,19 @@ def test_config_force_managed(dda, helpers, isolation, config_file, temp_dir, mo
7379
get_download_url(),
7480
path=internal_bazel_path,
7581
)
76-
subprocess_runner.assert_called_once_with([str(internal_bazel_path), "build", "//..."])
82+
subprocess_runner.assert_called_once_with([str(internal_bazel_path), *args])
7783

7884

7985
def test_config_force_unmanaged(dda, helpers, config_file, mocker):
8086
config_file.data["tools"]["bazel"]["managed"] = False
8187
config_file.save()
8288

89+
args = ["build", "//..."]
90+
mocker.patch("dda.cli.base._get_argv", return_value=["dda", "bzl", *args])
8391
downloader = mocker.patch("dda.utils.network.http.manager.HTTPClientManager.download")
8492

8593
with EnvVars(exclude=["PATH"]):
86-
result = dda("build", "bazel", "build", "//...")
94+
result = dda("bzl", *args)
8795

8896
assert result.exit_code == 1, result.output
8997
assert result.output == helpers.dedent(
@@ -93,3 +101,20 @@ def test_config_force_unmanaged(dda, helpers, config_file, mocker):
93101
)
94102

95103
downloader.assert_not_called()
104+
105+
106+
def test_arg_interception(dda, config_file, mocker):
107+
config_file.data["tools"]["bazel"]["managed"] = False
108+
config_file.save()
109+
110+
args = ["build", "--", "//..."]
111+
mocker.patch("dda.cli.base._get_argv", return_value=["dda", "bzl", *args])
112+
downloader = mocker.patch("dda.utils.network.http.manager.HTTPClientManager.download")
113+
114+
with EnvVars(exclude=["PATH"]):
115+
result = dda("bzl", *args)
116+
117+
assert result.exit_code == 1, result.output
118+
assert result.output.startswith("Executable `bazel` not found: ['bazel', 'build', '--target_pattern_file', '")
119+
120+
downloader.assert_not_called()

tests/conftest.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@
2020
from dda.utils.ci import running_in_ci
2121
from dda.utils.fs import Path, temp_directory
2222
from dda.utils.git.constants import GitEnvVars
23-
from dda.utils.platform import PLATFORM_ID
23+
from dda.utils.platform import PLATFORM_ID, which
2424
from dda.utils.process import EnvVars
2525

2626
if TYPE_CHECKING:
@@ -160,6 +160,11 @@ def uv_on_path() -> Path:
160160
return Path(shutil.which("uv"))
161161

162162

163+
@pytest.fixture(scope="session")
164+
def bazel_on_path() -> Path:
165+
return Path(which("bazel"))
166+
167+
163168
def pytest_runtest_setup(item):
164169
for marker in item.iter_markers():
165170
if marker.name == "requires_ci" and not running_in_ci(): # no cov

tests/tools/test_bzl.py

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
# SPDX-FileCopyrightText: 2025-present Datadog, Inc. <dev@datadoghq.com>
2+
#
3+
# SPDX-License-Identifier: MIT
4+
from __future__ import annotations
5+
6+
from typing import TYPE_CHECKING
7+
8+
import pytest
9+
10+
from dda.tools.bazel import query_accepting_commands, target_accepting_commands
11+
12+
if TYPE_CHECKING:
13+
from pytest_mock import MockerFixture
14+
15+
from dda.cli.application import Application
16+
from dda.utils.fs import Path
17+
18+
19+
class TestArgLengthLimits:
20+
@pytest.mark.parametrize("command", sorted(target_accepting_commands()))
21+
def test_ambiguous_targets(self, app: Application, command: str) -> None:
22+
with (
23+
pytest.raises(ValueError, match="Bazel arguments must come after the `--` separator"),
24+
app.tools.bazel.execution_context([command, "//..."]),
25+
):
26+
pass
27+
28+
@pytest.mark.parametrize("command", sorted(query_accepting_commands()))
29+
def test_ambiguous_query(self, app: Application, command: str) -> None:
30+
with (
31+
pytest.raises(ValueError, match="Bazel arguments must come after the `--` separator"),
32+
app.tools.bazel.execution_context([command, "deps(//foo)"]),
33+
):
34+
pass
35+
36+
def test_targets_arg_file(self, app: Application, bazel_on_path: Path, mocker: MockerFixture) -> None:
37+
writer = mocker.MagicMock()
38+
writer.name = "targets.txt"
39+
mocker.patch(
40+
"tempfile.NamedTemporaryFile",
41+
return_value=mocker.MagicMock(__enter__=lambda *_, **__: writer, __exit__=lambda *_, **__: None),
42+
)
43+
with app.tools.bazel.execution_context(["build", "--foo", "--", "//foo", "//bar"]) as context:
44+
assert context.command == [str(bazel_on_path), "build", "--foo", "--target_pattern_file", "targets.txt"]
45+
writer.write.assert_called_once_with("//foo\n//bar")
46+
47+
def test_query_arg_file(self, app: Application, bazel_on_path: Path, mocker: MockerFixture) -> None:
48+
writer = mocker.MagicMock()
49+
writer.name = "query.txt"
50+
mocker.patch(
51+
"tempfile.NamedTemporaryFile",
52+
return_value=mocker.MagicMock(__enter__=lambda *_, **__: writer, __exit__=lambda *_, **__: None),
53+
)
54+
with app.tools.bazel.execution_context(["query", "--foo", "--", "deps(//foo)"]) as context:
55+
assert context.command == [str(bazel_on_path), "query", "--foo", "--query_file", "query.txt"]
56+
writer.write.assert_called_once_with("deps(//foo)")

0 commit comments

Comments
 (0)