Skip to content

Commit

Permalink
Support documenting commands and tasks.
Browse files Browse the repository at this point in the history
Documentation can be viewed by passing `dev-cmd` the new `-l` / `--list`
option to list commands and tasks.
  • Loading branch information
jsirois committed Jan 26, 2025
1 parent 42a589a commit 930e395
Show file tree
Hide file tree
Showing 9 changed files with 335 additions and 38 deletions.
5 changes: 5 additions & 0 deletions CHANGES.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
# Release Notes

## 0.14.0

Add support for documenting commands and tasks and listing them with their documentation via
`-l` / `--list`.

## 0.13.0

Support an optional leading `:` in factor argument values to parallel factor parameter default
Expand Down
71 changes: 71 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,37 @@ as the value for the `-py` factor parameter. The colon-prefix helps distinguish
factor value, paralleling the default value syntax that can be used at factor parameter declaration
sites.

#### Documentation

You can document a command by providing a `description`. If the command has factors, you can
document these using a `factors` sub-table whose keys are the factor names and whose values are
strings that describe the factor.

For example:
```toml
[tool.dev-cmd.commands.type-check.factors]
py = "The Python version to type check in <major>.<minor> form; i.e.: 3.13."

[tool.dev-cmd.commands.type-check]
args = [
"mypy",
"--python-version", "{-py:{markers.python_version}}",
"--cache-dir", ".mypy_cache_{markers.python_version}",
"setup.py",
"dev_cmd",
"tests",
]
```

You can view this documentation by passing `dev-cmd` either `-l` or `--list`. For example:
```console
uv run dev-cmd --list
Commands:
type-check
-py: The Python version to type check in <major>.<minor> form; i.e.: 3.13.
[default: {markers.python_version} (currently 3.12)]
```

### Tasks

Tasks are defined in their own table and compose two or more commands to implement some larger task.
Expand Down Expand Up @@ -184,6 +215,46 @@ your shell does not do parameter expansion of this sort:
uv run dev-cmd -p 'type-check-py3.{8,9}'
```

#### Documentation

You can document a task by defining it in a table instead of as a list of steps. To do so, supply
the list of steps with the `steps` key and the documentation with the `description` key:
```toml
[tool.dev-cmd.commands]
fmt = ["ruff", "format"]
lint = ["ruff", "check", "--fix"]
type-check = ["mypy", "--python", "{-py:{markers.python_version}}"]

[tool.dev-cmd.commands.test]
args = ["pytest"]
cwd = "tests"
accepts-extra-args = true

[tool.dev-cmd.tasks.checks]
description = "Runs all development checks, including auto-formatting code."
steps = [
"fmt",
"lint",
# Parallelizing the type checks and test is safe (they don't modify files), and it nets a ~3x
# speedup over running them all serially.
["type-check-py3.{8..13}", "test"],
]
```

You can view this documentation by passing `dev-cmd` either `-l` or `--list`. For example:
```console
uv run dev-cmd --list
Commands:
fmt
lint
type-check
-py: [default: {markers.python_version} (currently 3.12)]
test

Tasks:
checks: Runs all development checks, including auto-formatting code.
```

### Global Options

You can set a default command or task to run when `dev-cmd` is passed no positional arguments like
Expand Down
2 changes: 1 addition & 1 deletion dev_cmd/__init__.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# Copyright 2024 John Sirois.
# Licensed under the Apache License, Version 2.0 (see LICENSE).

__version__ = "0.13.0"
__version__ = "0.14.0"
11 changes: 11 additions & 0 deletions dev_cmd/model.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,13 +13,23 @@ class Factor(str):
pass


@dataclass(frozen=True)
class FactorDescription:
factor: Factor
default: str | None = None
description: str | None = None


@dataclass(frozen=True)
class Command:
name: str
args: tuple[str, ...]
extra_env: tuple[tuple[str, str], ...] = ()
cwd: PurePath | None = None
accepts_extra_args: bool = False
base: Command | None = None
description: str | None = None
factor_descriptions: tuple[FactorDescription, ...] = ()


@dataclass(frozen=True)
Expand All @@ -40,6 +50,7 @@ def accepts_extra_args(self, skips: Container[str]) -> Command | None:
class Task:
name: str
steps: Group
description: str | None = None

def accepts_extra_args(self, skips: Container[str]) -> Command | None:
if self.name in skips:
Expand Down
121 changes: 108 additions & 13 deletions dev_cmd/parse.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@

from __future__ import annotations

import dataclasses
import itertools
import os
from collections import defaultdict
Expand All @@ -11,8 +12,8 @@

from dev_cmd.errors import InvalidModelError
from dev_cmd.expansion import expand
from dev_cmd.model import Command, Configuration, ExitStyle, Factor, Group, Task
from dev_cmd.placeholder import Environment
from dev_cmd.model import Command, Configuration, ExitStyle, Factor, FactorDescription, Group, Task
from dev_cmd.placeholder import DEFAULT_ENVIRONMENT
from dev_cmd.project import PyProjectToml


Expand Down Expand Up @@ -47,10 +48,12 @@ def _parse_commands(

for name, data in commands.items():
extra_env: list[tuple[str, str]] = []
factor_descriptions: dict[Factor, str | None] = {}
if isinstance(data, list):
args = tuple(_assert_list_str(data, path=f"[tool.dev-cmd.commands] `{name}`"))
cwd = project_dir
accepts_extra_args = False
description = None
else:
command = _assert_dict_str_keys(data, path=f"[tool.dev-cmd.commands.{name}]")

Expand Down Expand Up @@ -92,22 +95,45 @@ def _parse_commands(
f"`true` or `false`, given: {accepts_extra_args} of type "
f"{type(accepts_extra_args)}."
)

description = command.pop("description", None)
if description and not isinstance(description, str):
raise InvalidModelError(
f"The [tool.dev-cmd.commands.{name}] `description` value must be a string, "
f"given: {description} of type {type(description)}."
)

raw_factor_descriptions = _assert_dict_str_keys(
command.pop("factors", {}), path=f"[tool.dev-cmd.commands.{name}] `factors`"
)
for factor_name, factor_desc in raw_factor_descriptions.items():
if not isinstance(factor_desc, str):
raise InvalidModelError(
f"The [tool.dev-cmd.commands.{name}.factors] `{factor_name}` value must be "
f"a string, given: {factor_desc} of type {type(factor_desc)}."
)
factor_descriptions[Factor(factor_name)] = factor_desc

if data:
raise InvalidModelError(
f"Unexpected configuration keys in the [tool.dev-cmd.commands.{name}] table: "
f"{' '.join(data)}"
)

env = Environment()
for factors in required_steps[name]:
factors_suffix = f"-{'-'.join(factors)}" if factors else ""

seen_factors: dict[Factor, FactorDescription] = {}
used_factors: set[Factor] = set()

def substitute(text: str) -> str:
substituted, consumed_factors = env.substitute(text, *factors)
used_factors.update(consumed_factors)
return substituted
substitution = DEFAULT_ENVIRONMENT.substitute(text, *factors)
seen_factors.update(
(seen_factor, FactorDescription(seen_factor, default=default))
for seen_factor, default in substitution.seen_factors
)
used_factors.update(substitution.used_factors)
return substitution.value

substituted_args = [substitute(arg) for arg in args]
substituted_extra_env = [(key, substitute(value)) for key, value in extra_env]
Expand All @@ -127,12 +153,53 @@ def substitute(text: str) -> str:
f"{head} and {tail}."
)

mismatched_factors_descriptions: list[str] = []
for factor, desc in factor_descriptions.items():
factor_desc = seen_factors.get(factor)
if not factor_desc:
mismatched_factors_descriptions.append(factor)
else:
seen_factors[factor] = dataclasses.replace(factor_desc, description=desc)
if mismatched_factors_descriptions:
count = len(mismatched_factors_descriptions)
factor_plural = "factors" if count > 1 else "factor"
raise InvalidModelError(
os.linesep.join(
(
f"Descriptions were given for {count} {factor_plural} that do not "
f"appear in [dev-cmd.commands.{name}] `args` or `env`:",
*(
f"{index}. {name}"
for index, name in enumerate(
mismatched_factors_descriptions, start=1
)
),
)
)
)

base: Command | None = None
if factors:
base = Command(
name=name,
args=tuple(args),
extra_env=tuple(extra_env),
cwd=cwd,
accepts_extra_args=accepts_extra_args,
base=None,
description=description,
factor_descriptions=tuple(seen_factors.values()),
)

yield Command(
f"{name}{factors_suffix}",
tuple(substituted_args),
name=f"{name}{factors_suffix}",
args=tuple(substituted_args),
extra_env=tuple(substituted_extra_env),
cwd=cwd,
accepts_extra_args=accepts_extra_args,
base=base,
description=description,
factor_descriptions=tuple(seen_factors.values()),
)


Expand Down Expand Up @@ -194,17 +261,40 @@ def _parse_tasks(tasks: dict[str, Any] | None, commands: Mapping[str, Command])
return

tasks_by_name: dict[str, Task] = {}
for name, group in tasks.items():
for name, data in tasks.items():
if name in commands:
raise InvalidModelError(
f"The task {name!r} collides with command {name!r}. Tasks and commands share the "
f"same namespace and the names must be unique."
)
if not isinstance(group, list):
if isinstance(data, dict):
group = data.pop("steps", [])
if not group or not isinstance(group, list):
raise InvalidModelError(
f"Expected the [tool.dev-cmd.tasks.{name}] table to define a `steps` list "
f"containing at least one step."
)
description = data.pop("description", None)
if description and not isinstance(description, str):
raise InvalidModelError(
f"The [tool.dev-cmd.tasks.{name}] `description` value must be a string, "
f"given: {description} of type {type(description)}."
)
if data:
raise InvalidModelError(
f"Unexpected configuration keys in the [tool.dev-cmd.tasks.{name}] table: "
f"{' '.join(data)}"
)
elif isinstance(data, list):
group = data
description = None
else:
raise InvalidModelError(
f"Expected value at [tool.dev-cmd.tasks] `{name}` to be a list containing "
f"strings or lists of strings, but given: {group} of type {type(group)}."
f"Expected value at [tool.dev-cmd.tasks] `{name}` to be a list containing strings "
f"or lists of strings or else a table defining a `steps` list, but given: {data} "
f"of type {type(data)}."
)

task = Task(
name=name,
steps=_parse_group(
Expand All @@ -214,6 +304,7 @@ def _parse_tasks(tasks: dict[str, Any] | None, commands: Mapping[str, Command])
tasks_defined_so_far=tasks_by_name,
commands=commands,
),
description=description,
)
tasks_by_name[name] = task
yield task
Expand Down Expand Up @@ -294,6 +385,8 @@ def _iter_all_required_step_names(
elif isinstance(value, list):
for item in value:
yield from _iter_all_required_step_names(item, tasks_data, seen)
elif isinstance(value, dict):
yield from _iter_all_required_step_names(value.get("steps", []), tasks_data, seen)


def _gather_all_required_step_names(
Expand Down Expand Up @@ -329,8 +422,10 @@ def pop_dict(key: str, *, path: str) -> dict[str, Any] | None:
default_step_name = dev_cmd_data.pop("default", None)

required_steps: defaultdict[str, list[tuple[Factor, ...]]] = defaultdict(list)
required_step_names = _gather_all_required_step_names(requested_steps, tasks_data)
known_names = tuple(itertools.chain(commands_data, tasks_data))
required_step_names = (
_gather_all_required_step_names(requested_steps, tasks_data) or known_names
)
for required_step_name in required_step_names:
if required_step_name in known_names:
required_steps[required_step_name].append(())
Expand Down
Loading

0 comments on commit 930e395

Please sign in to comment.