Skip to content

Commit

Permalink
Add experimental PoeTaskArgs for processing named task args
Browse files Browse the repository at this point in the history
task definitions may now include an args subtable to define arguments
which will be parsed with argparse and made available to the task.

script, cmd and shell tasks now support using named arguments.

Also:
- tweak types for handling extra_args
- minor refactor of task validation code
- tweak ui code to facilitate printing help for task args

Still to do:
- improve error message for task argument omission or misuse
- add extensive feature tests for the new functionality
- support defining args for sequence and ref task types
- provide more complete documentation
- maybe support positional arugments
- maybe support nargs or typed args
  • Loading branch information
nat-n committed Feb 14, 2021
1 parent c5ac6cf commit 7e89600
Show file tree
Hide file tree
Showing 10 changed files with 370 additions and 73 deletions.
64 changes: 62 additions & 2 deletions README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -271,6 +271,68 @@ You can specify arbitrary environment variables to be set for a task by providin
Notice this example uses deep keys which can be more convenient but aren't as well supported by some toml implementations.

Declaring CLI options (experimental)
------------------------------------

By default extra CLI arguments are appended to the end of a cmd task, or exposed as
sys.argv in a script task. Alternatively it is possible to define CLI options that a
task should accept, which will be documented in the help for that task, and exposed to
the task in a way the makes the most sense for that task type.

Arguments for cmd and shell tasks
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

For cmd and shell tasks the values are exposed to the task as environment variables. For example given the following configuration:

.. code-block:: toml
[tool.poe.tasks.passby]
shell = """
echo "hello $planet";
echo "goodbye $planet";
"""
help = "Pass by a planet!"
[tool.poe.tasks.passby.args.planet] # the key of the arg is used as the name of the variable that the given value will be exposed as
help = "Name of the planet to pass"
default = "earth"
required = false # by default all args are optional and default to ""
options = ["-p", "--planet"] # options are passed to ArgumentParser.add_argument as *args, if not given the the name value, i.e. [f"--{name}"]
the resulting task can be run like:

.. code-block:: bash
poe passby --planet mars
Arguments for script tasks
~~~~~~~~~~~~~~~~~~~~~~~~~~

Arguments can be defined for script tasks in the same way, but how they are exposed to
the underlying python function depends on how the script is defined.

In the following example, since not parenthesis are included for the referenced function,
all provided args will be passed to the function as kwargs:

.. code-block:: toml
[tool.poe.tasks]
build = { script = "project.util:build", args = ["dest", "version"]
Here the build method will be passed the two argument values (if provided) from the
command lines as kwargs.

Note that in this example, args are given as a list of strings. This abbreviated
form is equivalent to just providing a name for each argument and keeping the default
values for all other configuration (including empty string for the help message).

If there's a need to take control of how values are passed to the function, then this
is also possible as demonstrated in the following example:

.. code-block:: toml
[tool.poe.tasks]
build = { script = "project.util:build(dest, build_version=version)", args = ["dest", "version"]
Project-wide configuration options
==================================

Expand Down Expand Up @@ -358,8 +420,6 @@ There's plenty to do, come say hi in the issues! 👋
TODO
====

☐ support declaring specific arguments for a task `#6 <https://github.com/nat-n/poethepoet/issues/6>`_

☐ support conditional execution (a bit like make targets) `#12 <https://github.com/nat-n/poethepoet/issues/12>`_

☐ support verbose mode for documentation that shows task definitions
Expand Down
16 changes: 12 additions & 4 deletions poethepoet/app.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
import os
from pathlib import Path
import sys
from typing import Any, IO, MutableMapping, Optional, Sequence, Union
from typing import Any, Dict, IO, MutableMapping, Optional, Sequence, Tuple, Union
from .config import PoeConfig
from .context import RunContext
from .exceptions import ExecutionError, PoeException
from .task import PoeTask
from .task.args import PoeTaskArgs
from .ui import PoeUi


Expand Down Expand Up @@ -100,8 +101,15 @@ def print_help(
):
if isinstance(error, str):
error == PoeException(error)
tasks_help = {
task: (content.get("help", "") if isinstance(content, dict) else "")
for task, content in self.config.tasks.items()
tasks_help: Dict[str, Tuple[str, Sequence[Tuple[Tuple[str, ...], str]]]] = {
task_name: (
(
content.get("help", ""),
PoeTaskArgs.get_help_content(content.get("args")),
)
if isinstance(content, dict)
else ("", tuple())
)
for task_name, content in self.config.tasks.items()
}
self.ui.print_help(tasks=tasks_help, info=info, error=error) # type: ignore
154 changes: 154 additions & 0 deletions poethepoet/task/args.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
import argparse
from typing import (
Any,
Dict,
List,
Optional,
Sequence,
Set,
Tuple,
Type,
Union,
TYPE_CHECKING,
)

if TYPE_CHECKING:
from ..config import PoeConfig

ArgParams = Dict[str, Any]
ArgsDef = Union[List[str], List[ArgParams], Dict[str, ArgParams]]
arg_param_schema: Dict[str, Union[Type, Tuple[Type, ...]]] = {
"default": (str, int, float, bool),
"help": str,
"name": str,
"options": (list, tuple),
"required": bool,
}


class PoeTaskArgs:
_args: Tuple[ArgParams, ...]

def __init__(self, args_def: ArgsDef):
self._args = self._normalize_args_def(args_def)

@staticmethod
def _normalize_args_def(args_def: ArgsDef):
"""
args_def can be defined as a dictionary of ArgParams, or a list of strings, or
ArgParams. Here we normalize it to a list of ArgParams, assuming that it has
already been validated.
"""
result = []
if isinstance(args_def, list):
for item in args_def:
if isinstance(item, str):
result.append({"name": item, "options": (f"--{item}",)})
else:
result.append(
dict(
item,
options=tuple(
item.get("options", (f"--{item.get('name')}",))
),
)
)
else:
for name, params in args_def.items():
result.append(
dict(
params,
name=name,
options=tuple(params.get("options", (f"--{name}",))),
)
)
return result

@classmethod
def get_help_content(
cls, args_def: Optional[ArgsDef]
) -> List[Tuple[Tuple[str, ...], str]]:
if args_def is None:
return []
args = cls._normalize_args_def(args_def)
return [(arg["options"], arg.get("help", "")) for arg in args]

@classmethod
def validate_def(cls, task_name: str, args_def: ArgsDef) -> Optional[str]:
arg_names: Set[str] = set()
if isinstance(args_def, list):
for item in args_def:
# can be a list of strings (just arg name) or ArgConfig dictionaries
if isinstance(item, str):
arg_name = item
elif isinstance(item, dict):
arg_name = item.get("name", "")
error = cls._validate_params(item, arg_name, task_name)
if error:
return error
else:
return f"Arg {item!r} of task {task_name!r} has invlaid type"
error = cls._validate_name(arg_name, task_name, arg_names)
if error:
return error
elif isinstance(args_def, dict):
for arg_name, params in args_def.items():
error = cls._validate_name(arg_name, task_name, arg_names)
if error:
return error
if "name" in params:
return (
f"Unexpected 'name' option for arg {arg_name!r} of task "
f"{task_name!r}"
)
error = cls._validate_params(params, arg_name, task_name)
if error:
return error
return None

@classmethod
def _validate_params(
cls, params: ArgParams, arg_name: str, task_name: str
) -> Optional[str]:
for param, value in params.items():
if param not in arg_param_schema:
return (
f"Invalid option {param!r} for arg {arg_name!r} of task "
f"{task_name!r}"
)
if not isinstance(value, arg_param_schema[param]):
return (
f"Invalid value for option {param!r} of arg {arg_name!r} of"
f" task {task_name!r}"
)
return None

@classmethod
def _validate_name(
cls, name: Any, task_name: str, arg_names: Set[str]
) -> Optional[str]:
if not isinstance(name, str):
return f"Arg name {name!r} of task {task_name!r} should be a string"
if not name.isidentifier():
return (
f"Arg name {name!r} of task {task_name!r} is not a valid " "identifier"
)
if name in arg_names:
return f"Duplicate arg name {name!r} for task {task_name!r}"
arg_names.add(name)
return None

def build_parser(self) -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(add_help=False, allow_abbrev=False)
for arg in self._args:
parser.add_argument(
*arg["options"],
default=arg.get("default", ""),
dest=arg["name"],
required=arg.get("required", False),
help=arg.get("help", ""),
)
return parser

def parse(self, extra_args: Sequence[str]):
return vars(self.build_parser().parse_args(extra_args))
Loading

0 comments on commit 7e89600

Please sign in to comment.