Skip to content

Commit adb6a43

Browse files
committed
Add support for - in factor values.
You now escape a `-` with `-`; i.e.: `--` maps to `-` in a factor value instead of breaking to the next factor. Also fix `dev-cmd` `pyproject.toml` discovery when installed in editable mode in another project as well as fixing over-eager command deactivation in some cases where commands had multiple implementations selected amongst via `when` markers.
1 parent 653baa2 commit adb6a43

File tree

7 files changed

+74
-47
lines changed

7 files changed

+74
-47
lines changed

CHANGES.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,17 @@
11
# Release Notes
22

3+
## 0.29.0
4+
5+
Add support for `-` in factor values by escaping with a leading `-`; i.e. to pass the value of
6+
`bar-baz` for factor `foo` of command `test` you can now say: `test-foo:bar--baz` (or
7+
`test-foobar--baz`).
8+
9+
Also fix an issue with `pyproject.toml` discovery when `dev-cmd` is installed in another repo in
10+
editable mode.
11+
12+
Finally, fix over-eager command deactivation in some cases where commands had multiple
13+
implementations selected amongst via `when` markers.
14+
315
## 0.28.0
416

517
As long as there is at least one command left to execute, tasks and any groups they contain now

README.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -149,7 +149,8 @@ to ambiguity in which factor argument should be applied. An optional leading `:`
149149
factor argument value, and it will be stripped. So both `test-py:3.12` and `test-py3.12` pass `3.12`
150150
as the value for the `-py` factor parameter. The colon-prefix helps distinguish factor name from
151151
factor value, paralleling the default value syntax that can be used at factor parameter declaration
152-
sites.
152+
sites. If your factor value contains a `-`, just escape it with a `-`; i.e.: `--` will map to a
153+
single `-` in a factor value instead of indicating a new factor starts there.
153154

154155
#### Documentation
155156

dev_cmd/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
11
# Copyright 2024 John Sirois.
22
# Licensed under the Apache License, Version 2.0 (see LICENSE).
33

4-
__version__ = "0.28.0"
4+
__version__ = "0.29.0"

dev_cmd/parse.py

Lines changed: 43 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
import dataclasses
77
import itertools
88
import os
9-
from collections import defaultdict
9+
from collections import defaultdict, deque
1010
from dataclasses import dataclass
1111
from pathlib import Path
1212
from typing import Any, Container, Dict, Iterable, Iterator, List, Mapping, Set, cast
@@ -838,7 +838,7 @@ def _gather_all_required_step_names(
838838

839839
def parse_dev_config(
840840
pyproject_toml: PyProjectToml, *requested_steps: str, requested_python: Python | None = None
841-
) -> Configuration:
841+
) -> tuple[Configuration, tuple[str, ...]]:
842842
pyproject_data = pyproject_toml.parse()
843843
try:
844844
dev_cmd_data = _assert_dict_str_keys(
@@ -875,6 +875,7 @@ def pop_dict(key: str, *, path: str) -> dict[str, Any] | None:
875875
required_step_names = (
876876
_gather_all_required_step_names(requested_steps, tasks_data) or known_names
877877
)
878+
requested_step_names: dict[str, str] = {step: step for step in requested_steps}
878879
for required_step_name in required_step_names:
879880
if required_step_name in known_names:
880881
required_steps[required_step_name].append(())
@@ -883,23 +884,46 @@ def pop_dict(key: str, *, path: str) -> dict[str, Any] | None:
883884
if not required_step_name.startswith(f"{known_name}-"):
884885
continue
885886

886-
required_steps[known_name].append(
887-
tuple(
888-
Factor(factor)
889-
for factor in required_step_name[len(known_name) + 1 :].split("-")
890-
)
887+
factors = [] # type: List[Factor]
888+
factor_chars = [] # type: List[str]
889+
chars = deque(required_step_name[len(known_name) + 1 :])
890+
while chars:
891+
while chars:
892+
char = chars.popleft()
893+
894+
if char != "-":
895+
factor_chars.append(char)
896+
continue
897+
898+
# Escaped - (--)
899+
if chars and chars[0] == "-":
900+
factor_chars.append(char)
901+
chars.popleft()
902+
continue
903+
904+
if not chars:
905+
factor_chars.append(char)
906+
907+
break
908+
factors.append(Factor("".join(factor_chars)))
909+
factor_chars.clear()
910+
911+
required_steps[known_name].append(tuple(factors))
912+
requested_step_names[required_step_name] = (
913+
f"{known_name}-{'-'.join(factors)}" if factors else known_name
891914
)
892915
break
893916

894-
commands: dict[str, Command | DeactivatedCommand] = {
895-
cmd.name: cmd
896-
for cmd in _parse_commands(
897-
commands_data,
898-
required_steps,
899-
project_dir=pyproject_toml.path.parent,
900-
marker_environment=marker_environment,
901-
)
902-
}
917+
commands: dict[str, Command | DeactivatedCommand] = {}
918+
for cmd in _parse_commands(
919+
commands_data,
920+
required_steps,
921+
project_dir=pyproject_toml.path.parent,
922+
marker_environment=marker_environment,
923+
):
924+
existing = commands.setdefault(cmd.name, cmd)
925+
if isinstance(existing, DeactivatedCommand) and isinstance(cmd, Command):
926+
commands[cmd.name] = cmd
903927
if not commands:
904928
raise InvalidModelError(
905929
"No commands are defined in the [tool.dev-cmd.commands] table. At least one must be "
@@ -919,7 +943,7 @@ def pop_dict(key: str, *, path: str) -> dict[str, Any] | None:
919943
f"Unexpected configuration keys in the [tool.dev-cmd] table: {' '.join(dev_cmd_data)}"
920944
)
921945

922-
return Configuration(
946+
configuration = Configuration(
923947
commands=tuple(cmd for cmd in commands.values() if isinstance(cmd, Command)),
924948
tasks=tuple(tasks.values()),
925949
default=default,
@@ -929,3 +953,5 @@ def pop_dict(key: str, *, path: str) -> dict[str, Any] | None:
929953
pythons=pythons,
930954
source=pyproject_toml.path,
931955
)
956+
957+
return configuration, tuple(requested_step_names[step] for step in requested_steps)

dev_cmd/project.py

Lines changed: 1 addition & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,6 @@
55

66
import os
77
from dataclasses import dataclass
8-
from importlib import metadata
9-
from importlib.metadata import PackageNotFoundError
108
from pathlib import Path
119
from typing import Any
1210

@@ -35,18 +33,7 @@ def parse(self) -> dict[str, Any]:
3533

3634

3735
def find_pyproject_toml() -> PyProjectToml:
38-
module = Path(__file__)
39-
start = module.parent
40-
try:
41-
dist_files = metadata.files("dev-cmd")
42-
if dist_files and any(module == dist_file.locate() for dist_file in dist_files):
43-
# N.B.: We're running from an installed package; so use the PWD as the search start.
44-
start = Path()
45-
except PackageNotFoundError:
46-
# N.B.: We're being run directly from sources that are not installed or are installed in
47-
# editable mode.
48-
pass
49-
36+
start = Path()
5037
candidate = start.resolve()
5138
while True:
5239
pyproject_toml = candidate / "pyproject.toml"

dev_cmd/run.py

Lines changed: 14 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -99,7 +99,7 @@ def ensure_venv(python: Python, *, quiet: bool) -> Venv:
9999

100100
def _run(
101101
config: Configuration,
102-
*names: str,
102+
*steps: str,
103103
skips: Collection[str] = (),
104104
console: Console = Console(),
105105
parallel: bool = False,
@@ -126,21 +126,22 @@ def _run(
126126
f"configured command or task names."
127127
)
128128

129-
if names:
129+
if steps:
130130
try:
131131
invocation = Invocation.create(
132-
*(available_tasks.get(name) or available_cmds[name] for name in names),
132+
*(available_tasks.get(step) or available_cmds[step] for step in steps),
133133
skips=skips,
134134
console=console,
135135
grace_period=grace_period,
136136
timings=timings,
137137
venv=config.venv,
138138
)
139139
except KeyError as e:
140+
print(e, file=sys.stderr)
140141
raise InvalidArgumentError(
141142
os.linesep.join(
142143
(
143-
f"A requested task is not defined in {config.source}: {e}",
144+
f"A requested step is not defined in {config.source}: {e}",
144145
"",
145146
f"Available tasks: {' '.join(sorted(available_tasks))}",
146147
f"Available commands: {' '.join(sorted(available_cmds))}",
@@ -193,7 +194,7 @@ def _run(
193194

194195
@dataclass(frozen=True)
195196
class Options:
196-
tasks: tuple[str, ...]
197+
steps: tuple[str, ...]
197198
skips: frozenset[str]
198199
list: bool
199200
quiet: bool
@@ -322,7 +323,7 @@ def _parse_args() -> Options:
322323
),
323324
)
324325
parser.add_argument(
325-
"tasks",
326+
"steps",
326327
nargs="*",
327328
metavar="cmd|task",
328329
help=(
@@ -346,10 +347,10 @@ def _parse_args() -> Options:
346347
options = parser.parse_args(args)
347348
color.set_color(ColorChoice(options.color))
348349

349-
tasks = tuple(itertools.chain.from_iterable(expand(task) for task in options.tasks))
350-
parallel = options.parallel and len(tasks) > 1
350+
steps = tuple(itertools.chain.from_iterable(expand(step) for step in options.steps))
351+
parallel = options.parallel and len(steps) > 1
351352
if options.parallel and not parallel and not options.quiet:
352-
single_task = repr(tasks[0]) if tasks else "the default"
353+
single_task = repr(steps[0]) if steps else "the default"
353354
print(
354355
color.yellow(
355356
f"A parallel run of top-level tasks was requested but only one was requested, "
@@ -358,7 +359,7 @@ def _parse_args() -> Options:
358359
)
359360

360361
return Options(
361-
tasks=tasks,
362+
steps=steps,
362363
skips=frozenset(options.skips),
363364
list=options.list,
364365
quiet=options.quiet,
@@ -407,7 +408,7 @@ def _list(
407408
factor_desc_header = f" -{factor_description.factor}"
408409
rendered_factor = color.magenta(factor_desc_header)
409410
default = factor_description.default
410-
if default:
411+
if default is not None:
411412
substituted_default = DEFAULT_ENVIRONMENT.substitute(default).value
412413
if substituted_default != default:
413414
extra_info = f"[default: {default} (currently {substituted_default})]"
@@ -459,7 +460,7 @@ def main() -> Any:
459460
python = Python(options.python) if options.python else None
460461
try:
461462
pyproject_toml = find_pyproject_toml()
462-
config = parse_dev_config(pyproject_toml, *options.tasks, requested_python=python)
463+
config, steps = parse_dev_config(pyproject_toml, *options.steps, requested_python=python)
463464
except DevCmdError as e:
464465
return 1 if console.quiet else f"{color.red('Configuration error')}: {color.yellow(str(e))}"
465466

@@ -470,7 +471,7 @@ def main() -> Any:
470471
try:
471472
_run(
472473
config,
473-
*options.tasks,
474+
*steps,
474475
skips=options.skips,
475476
console=console,
476477
parallel=options.parallel,

tests/test_parse.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ def parse_config(tmp_path: Path) -> Iterator[ConfigurationParser]:
2626
def parse(content: str) -> Configuration:
2727
pyproject_toml = tmp_path / "pyproject.toml"
2828
pyproject_toml.write_text(content)
29-
return parse_dev_config(PyProjectToml(pyproject_toml))
29+
return parse_dev_config(PyProjectToml(pyproject_toml))[0]
3030

3131
yield parse
3232

0 commit comments

Comments
 (0)