Skip to content

Commit 820d21e

Browse files
authored
Merge pull request #1624 from microsoft/extra_argv_envvar
Add parsing args from environment
2 parents 6e8e5be + 7abb9cc commit 820d21e

3 files changed

Lines changed: 182 additions & 43 deletions

File tree

CONTRIBUTING.md

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -74,9 +74,24 @@ On Linux or macOS:
7474
.../debugpy$ python3 -m tox -e py27,py37 --develop
7575
```
7676

77+
You can run all tests in a single file using a specified python version, like this:
78+
```
79+
...\debugpy> py -m tox --develop -e py312 -- ".\tests\debugpy\server\test_cli.py"
80+
```
81+
82+
You can also specify a single test, like this:
83+
```
84+
...\debugpy> py -m tox --develop -e py312 -- ".\tests\debugpy\server\test_cli.py::test_duplicate_switch"
85+
```
86+
87+
The tests are run concurrently, and the default number of workers is 8. You can force a single worker by using the `-n0` flag, like this:
88+
```
89+
...\debugpy> py -m tox --develop -e py312 -- -n0 ".\tests\debugpy\server\test_cli.py"
90+
```
91+
7792
### Running tests without tox
7893

79-
While tox is the recommended way to run the test suite, pytest can also be invoked directly from the root of the repository. This requires packages in tests/test_requirements.txt to be installed first.
94+
While tox is the recommended way to run the test suite, pytest can also be invoked directly from the root of the repository. This requires packages in tests/requirements.txt to be installed first.
8095

8196
## Using modified debugpy in Visual Studio Code
8297
To test integration between debugpy and Visual Studio Code, the latter can be directed to use a custom version of debugpy in lieu of the one bundled with the Python extension. This is done by specifying `"debugAdapterPath"` in `launch.json` - it must point at the root directory of the *package*, which is `src/debugpy` inside the repository:

src/debugpy/server/cli.py

Lines changed: 73 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,7 @@
77
import re
88
import sys
99
from importlib.util import find_spec
10-
from typing import Any
11-
from typing import Union
12-
from typing import Tuple
13-
from typing import Dict
10+
from typing import Any, Union, Tuple, Dict
1411

1512
# debugpy.__main__ should have preloaded pydevd properly before importing this module.
1613
# Otherwise, some stdlib modules above might have had imported threading before pydevd
@@ -161,6 +158,7 @@ def do(arg, it):
161158
import locale
162159

163160
target = target.decode(locale.getpreferredencoding(False))
161+
164162
options.target = target
165163

166164
return do
@@ -193,23 +191,68 @@ def do(arg, it):
193191
]
194192
# fmt: on
195193

196-
194+
# Consume all the args from argv
197195
def consume_argv():
198196
while len(sys.argv) >= 2:
199197
value = sys.argv[1]
200198
del sys.argv[1]
201199
yield value
202200

201+
# Consume all the args from a given list
202+
def consume_args(args: list):
203+
if (args is sys.argv):
204+
yield from consume_argv()
205+
else:
206+
while args:
207+
value = args[0]
208+
del args[0]
209+
yield value
203210

204-
def parse_argv():
211+
# Parse the args from the command line, then from the environment.
212+
# Args from the environment are only used if they are not already set from the command line.
213+
def parse_args():
214+
215+
# keep track of the switches we've seen so far
205216
seen = set()
206-
it = consume_argv()
217+
218+
parse_args_from_command_line(seen)
219+
parse_args_from_environment(seen)
220+
221+
# if the target is not set, or is empty, this is an error
222+
if options.target is None or options.target == "":
223+
raise ValueError("missing target: " + TARGET)
224+
225+
if options.mode is None:
226+
raise ValueError("either --listen or --connect is required")
227+
if options.adapter_access_token is not None and options.mode != "connect":
228+
raise ValueError("--adapter-access-token requires --connect")
229+
if options.target_kind == "pid" and options.wait_for_client:
230+
raise ValueError("--pid does not support --wait-for-client")
231+
232+
assert options.target_kind is not None
233+
assert options.address is not None
234+
235+
def parse_args_from_command_line(seen: set):
236+
parse_args_helper(sys.argv, seen)
237+
238+
def parse_args_from_environment(seenFromCommandLine: set):
239+
args = os.environ.get("DEBUGPY_EXTRA_ARGV")
240+
if (not args):
241+
return
242+
243+
argsList = args.split()
244+
245+
seenFromEnvironment = set()
246+
parse_args_helper(argsList, seenFromCommandLine, seenFromEnvironment, True)
247+
248+
def parse_args_helper(args: list, seenFromCommandLine: set, seenFromEnvironment: set = set(), isFromEnvironment=False):
249+
iterator = consume_args(args)
207250

208251
while True:
209252
try:
210-
arg = next(it)
253+
arg = next(iterator)
211254
except StopIteration:
212-
raise ValueError("missing target: " + TARGET)
255+
break
213256

214257
switch = arg
215258
if not switch.startswith("-"):
@@ -220,34 +263,37 @@ def parse_argv():
220263
else:
221264
raise ValueError("unrecognized switch " + switch)
222265

223-
if switch in seen:
224-
raise ValueError("duplicate switch " + switch)
266+
# if we're parsing from the command line, and we've already seen the switch on the command line, this is an error
267+
if (not isFromEnvironment and switch in seenFromCommandLine):
268+
raise ValueError("duplicate switch on command line: " + switch)
269+
# if we're parsing from the environment, and we've already seen the switch in the environment, this is an error
270+
elif (isFromEnvironment and switch in seenFromEnvironment):
271+
raise ValueError("duplicate switch from environment: " + switch)
272+
# if we're parsing from the environment, and we've already seen the switch on the command line, skip it, since command line takes precedence
273+
elif (isFromEnvironment and switch in seenFromCommandLine):
274+
continue
275+
# otherwise, the switch is new, so add it to the appropriate set
225276
else:
226-
seen.add(switch)
277+
if (isFromEnvironment):
278+
seenFromEnvironment.add(switch)
279+
else:
280+
seenFromCommandLine.add(switch)
227281

282+
# process the switch, running the corresponding action
228283
try:
229-
action(arg, it)
284+
action(arg, iterator)
230285
except StopIteration:
231286
assert placeholder is not None
232287
raise ValueError("{0}: missing {1}".format(switch, placeholder))
233288
except Exception as exc:
234289
raise ValueError("invalid {0} {1}: {2}".format(switch, placeholder, exc))
235290

236-
if options.target is not None:
291+
# If we're parsing the command line, we're done after we've processed the target
292+
# Otherwise, we need to keep parsing until all args are consumed, since the target may be set from the command line
293+
# already, but there might be additional args in the environment that we want to process.
294+
if (not isFromEnvironment and options.target is not None):
237295
break
238296

239-
if options.mode is None:
240-
raise ValueError("either --listen or --connect is required")
241-
if options.adapter_access_token is not None and options.mode != "connect":
242-
raise ValueError("--adapter-access-token requires --connect")
243-
if options.target_kind == "pid" and options.wait_for_client:
244-
raise ValueError("--pid does not support --wait-for-client")
245-
246-
assert options.target is not None
247-
assert options.target_kind is not None
248-
assert options.address is not None
249-
250-
251297
def start_debugging(argv_0):
252298
# We need to set up sys.argv[0] before invoking either listen() or connect(),
253299
# because they use it to report the "process" event. Thus, we can't rely on
@@ -411,7 +457,7 @@ def attach_to_pid():
411457
def main():
412458
original_argv = list(sys.argv)
413459
try:
414-
parse_argv()
460+
parse_args()
415461
except Exception as exc:
416462
print(str(HELP) + str("\nError: ") + str(exc), file=sys.stderr)
417463
sys.exit(2)

tests/debugpy/server/test_cli.py

Lines changed: 93 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,16 @@
22
# Licensed under the MIT License. See LICENSE in the project root
33
# for license information.
44

5+
import os
56
import pickle
67
import pytest
78
import subprocess
89
import sys
910

11+
# This is used for mocking environment variables
12+
# See https://docs.python.org/3/library/unittest.mock-examples.html for more info
13+
from unittest import mock
14+
1015
from debugpy.common import log
1116
from tests.patterns import some
1217

@@ -21,7 +26,7 @@ def cli_parser():
2126
from debugpy.server import cli
2227

2328
try:
24-
cli.parse_argv()
29+
cli.parse_args()
2530
except Exception as exc:
2631
os.write(1, pickle.dumps(exc))
2732
sys.exit(1)
@@ -43,14 +48,19 @@ def cli_parser():
4348
"wait_for_client",
4449
]
4550
}
51+
52+
# Serialize the command line args and the options to stdout
4653
os.write(1, pickle.dumps([sys.argv[1:], options]))
4754

4855
def parse(args):
4956
log.debug("Parsing argv: {0!r}", args)
5057
try:
58+
# Run the CLI parser in a subprocess, and capture its output.
5159
output = subprocess.check_output(
5260
[sys.executable, "-u", cli_parser.strpath] + args
5361
)
62+
63+
# Deserialize the output and return the parsed argv and options.
5464
argv, options = pickle.loads(output)
5565
except subprocess.CalledProcessError as exc:
5666
raise pickle.loads(exc.output)
@@ -62,6 +72,7 @@ def parse(args):
6272
return parse
6373

6474

75+
# Test a combination of command line switches
6576
@pytest.mark.parametrize("target_kind", ["file", "module", "code"])
6677
@pytest.mark.parametrize("mode", ["listen", "connect"])
6778
@pytest.mark.parametrize("address", ["8888", "localhost:8888"])
@@ -71,7 +82,7 @@ def test_targets(cli, target_kind, mode, address, wait_for_client, script_args):
7182
expected_options = {
7283
"mode": mode,
7384
"target_kind": target_kind,
74-
"wait_for_client": bool(wait_for_client),
85+
"wait_for_client": False
7586
}
7687

7788
args = ["--" + mode, address]
@@ -84,6 +95,7 @@ def test_targets(cli, target_kind, mode, address, wait_for_client, script_args):
8495

8596
if wait_for_client:
8697
args += ["--wait-for-client"]
98+
expected_options["wait_for_client"] = True
8799

88100
if target_kind == "file":
89101
target = "spam.py"
@@ -119,32 +131,98 @@ def test_targets(cli, target_kind, mode, address, wait_for_client, script_args):
119131
assert argv == script_args
120132
assert options == some.dict.containing(expected_options)
121133

122-
123-
@pytest.mark.parametrize("value", ["", True, False])
134+
@pytest.mark.parametrize("value", [True, False])
124135
def test_configure_subProcess(cli, value):
125-
args = ["--listen", "8888"]
126-
127-
if value == "":
128-
value = True
129-
else:
130-
args += ["--configure-subProcess", str(value)]
131-
132-
args += ["spam.py"]
136+
args = ["--listen", "8888", "--configure-subProcess", str(value), "spam.py"]
133137
_, options = cli(args)
134138

135139
assert options["config"]["subProcess"] == value
136140

141+
@pytest.mark.parametrize("value", [True, False])
142+
def test_configure_subProcess_from_environment(cli, value):
143+
args = ["--listen", "8888", "spam.py"]
144+
with mock.patch.dict(os.environ, {"DEBUGPY_EXTRA_ARGV": "--configure-subProcess " + str(value)}):
145+
_, options = cli(args)
146+
147+
assert options["config"]["subProcess"] == value
137148

138149
def test_unsupported_switch(cli):
139-
with pytest.raises(Exception):
150+
with pytest.raises(ValueError) as ex:
140151
cli(["--listen", "8888", "--xyz", "123", "spam.py"])
152+
153+
assert "unrecognized switch --xyz" in str(ex.value)
141154

155+
def test_unsupported_switch_from_environment(cli):
156+
with pytest.raises(ValueError) as ex:
157+
with mock.patch.dict(os.environ, {"DEBUGPY_EXTRA_ARGV": "--xyz 123"}):
158+
cli(["--listen", "8888", "spam.py"])
159+
160+
assert "unrecognized switch --xyz" in str(ex.value)
142161

143162
def test_unsupported_configure(cli):
144-
with pytest.raises(Exception):
163+
with pytest.raises(ValueError) as ex:
145164
cli(["--connect", "127.0.0.1:8888", "--configure-xyz", "123", "spam.py"])
165+
166+
assert "unknown property 'xyz'" in str(ex.value)
167+
168+
def test_unsupported_configure_from_environment(cli):
169+
with pytest.raises(ValueError) as ex:
170+
with mock.patch.dict(os.environ, {"DEBUGPY_EXTRA_ARGV": "--configure-xyz 123"}):
171+
cli(["--connect", "127.0.0.1:8888", "spam.py"])
146172

173+
assert "unknown property 'xyz'" in str(ex.value)
147174

148175
def test_address_required(cli):
149-
with pytest.raises(Exception):
176+
with pytest.raises(ValueError) as ex:
150177
cli(["-m", "spam"])
178+
179+
assert "either --listen or --connect is required" in str(ex.value)
180+
181+
def test_missing_target(cli):
182+
with pytest.raises(ValueError) as ex:
183+
cli(["--listen", "8888"])
184+
185+
assert "missing target" in str(ex.value)
186+
187+
def test_duplicate_switch(cli):
188+
with pytest.raises(ValueError) as ex:
189+
cli(["--listen", "8888", "--listen", "9999", "spam.py"])
190+
191+
assert "duplicate switch on command line: --listen" in str(ex.value)
192+
193+
def test_duplicate_switch_from_environment(cli):
194+
with pytest.raises(ValueError) as ex:
195+
with mock.patch.dict(os.environ, {"DEBUGPY_EXTRA_ARGV": "--listen 8888 --listen 9999"}):
196+
cli(["spam.py"])
197+
198+
assert "duplicate switch from environment: --listen" in str(ex.value)
199+
200+
# Test that switches can be read from the environment
201+
def test_read_switches_from_environment(cli):
202+
args = ["spam.py"]
203+
204+
with mock.patch.dict(os.environ, {"DEBUGPY_EXTRA_ARGV": "--connect 5678"}):
205+
_, options = cli(args)
206+
207+
assert options["mode"] == "connect"
208+
assert options["address"] == ("127.0.0.1", 5678)
209+
assert options["target"] == "spam.py"
210+
211+
# Test that command line switches override environment variables
212+
def test_override_environment_switch(cli):
213+
args = ["--connect", "8888", "spam.py"]
214+
215+
with mock.patch.dict(os.environ, {"DEBUGPY_EXTRA_ARGV": "--connect 5678"}):
216+
_, options = cli(args)
217+
218+
assert options["mode"] == "connect"
219+
assert options["address"] == ("127.0.0.1", 8888)
220+
assert options["target"] == "spam.py"
221+
222+
# Test that script args (passed to target) are preserved
223+
def test_script_args(cli):
224+
args = ["--listen", "8888", "spam.py", "arg1", "arg2"]
225+
argv, options = cli(args)
226+
227+
assert argv == ["arg1", "arg2"]
228+
assert options["target"] == "spam.py"

0 commit comments

Comments
 (0)