Skip to content

Commit

Permalink
Make command parsing support operations on param expansions
Browse files Browse the repository at this point in the history
Specifically cmd tasks may now use the following operations like in bash
- `:-` fallback value, e.g. ${AWS_REGION:-us-east-1}
- `:+` value replacement, e.g. ${AWESOME:+--awesome-mode}

This was done by adding AST parser support for Param Expansion operators

Also:
- Fix bug in param expansion logic for pure whitespace param values
- Add env paramater to PoeThePoet class to override os.environ for testing
  • Loading branch information
nat-n committed Dec 27, 2024
1 parent eecbb96 commit 2d252f3
Show file tree
Hide file tree
Showing 12 changed files with 613 additions and 69 deletions.
14 changes: 7 additions & 7 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -118,14 +118,14 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Trigger update of homebrew formula
run: >
run: |
sleep 10 # some delay seems to be necessary
curl -L -X POST
-H "Accept: application/vnd.github+json"
-H "Authorization: Bearer ${{ secrets.homebrew_pat }}"
-H "X-GitHub-Api-Version: 2022-11-28"
https://api.github.com/repos/nat-n/homebrew-poethepoet/actions/workflows/71211730/dispatches
-d '{"ref":"main", "inputs":{}}'
curl -L -X POST \
-H "Accept: application/vnd.github+json" \
-H "Authorization: Bearer ${{ secrets.homebrew_pat }}" \
-H "X-GitHub-Api-Version: 2022-11-28" \
https://api.github.com/repos/nat-n/homebrew-poethepoet/actions/workflows/71211730/dispatches \
-d '{"ref":"main", "inputs":{}}'
github-release:
name: >-
Expand Down
26 changes: 25 additions & 1 deletion docs/tasks/task_types/cmd.rst
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ It is important to understand that ``cmd`` tasks are executed without a shell (t

.. _ref_env_vars:


Referencing environment variables
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

Expand All @@ -50,6 +51,29 @@ Parameter expansion can also can be disabled by escaping the $ with a backslash
greet = "echo Hello \\$USER" # the backslash itself needs escaping for the toml parser
Parameter expansion operators
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

When referencing an environment variable in a cmd task you can use the ``:-`` operator from bash to specify a *default value*, to be used in case the variable is unset. Similarly the ``:+`` operator can be used to specify an *alternate value* to use in place of the environment variable if it *is* set.

In the following example, if ``AWS_REGION`` has a value then it will be used, otherwise ``us-east-1`` will be used as a fallback.

.. code-block:: toml
[tool.poe.tasks]
tables = "aws dynamodb list-tables --region ${AWS_REGION:-us-east-1}"
The ``:+`` or *alternate value* operator is especially useful in cases such as the following where you might want to control whether some CLI options are passed to the command.

.. code-block:: toml
[tool.poe.tasks.aws-identity]
cmd = "aws sts get-caller-identity ${ARN_ONLY:+ --no-cli-pager --output text --query 'Arn'}"
args = [{ name = "ARN_ONLY", options = ["--arn-only"], type = "boolean" }]
In this example we declare a boolean argument with no default, so if the ``--arn-only`` flag is provided to the task then three additional CLI options will be included in the task content.


Glob expansion
~~~~~~~~~~~~~~

Expand Down Expand Up @@ -78,7 +102,7 @@ Here's an example of task using a recursive glob pattern:

.. seealso::

Much like in bash, the glob pattern can be escaped by wrapping it in quotes, or preceding it with a backslash.
Just like in bash, the glob pattern can be escaped by wrapping it in quotes, or preceding it with a backslash.


.. |glob_link| raw:: html
Expand Down
16 changes: 14 additions & 2 deletions poethepoet/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,28 +20,38 @@ class PoeThePoet:
this determines where to look for a pyproject.toml file, defaults to
``Path().resolve()``
:type cwd: Path, optional
:param config:
Either a dictionary with the same schema as a pyproject.toml file, or a
`PoeConfig <https://github.com/nat-n/poethepoet/blob/main/poethepoet/config/config.py>`_
object to use as an alternative to loading config from a file.
:type config: dict | PoeConfig, optional
:param output:
A stream for the application to write its own output to, defaults to sys.stdout
:type output: IO, optional
:param poetry_env_path:
The path to the poetry virtualenv. If provided then it is used by the
`PoetryExecutor <https://github.com/nat-n/poethepoet/blob/main/poethepoet/executor/poetry.py>`_,
instead of having to execute poetry in a subprocess to determine this.
:type poetry_env_path: str, optional
:param config_name:
The name of the file to load tasks and configuration from. If not set then poe
will search for config by the following file names: pyproject.toml
poe_tasks.toml poe_tasks.yaml poe_tasks.json
:type config_name: str, optional
:param program_name:
The name of the program that is being run. This is used primarily when
outputting help messages, defaults to "poe"
:type program_name: str, optional
:param env:
Optionally provide an alternative base environment for tasks to run with.
If no mapping is provided then ``os.environ`` is used.
:type env: dict, optional
"""

cwd: Path
Expand All @@ -58,6 +68,7 @@ def __init__(
poetry_env_path: Optional[str] = None,
config_name: Optional[str] = None,
program_name: str = "poe",
env: Optional[Mapping[str, str]] = None,
):
from .config import PoeConfig
from .ui import PoeUi
Expand All @@ -75,6 +86,7 @@ def __init__(
)
self.ui = PoeUi(output=output, program_name=program_name)
self._poetry_env_path = poetry_env_path
self._env = env if env is not None else os.environ

def __call__(self, cli_args: Sequence[str], internal: bool = False) -> int:
"""
Expand Down Expand Up @@ -212,9 +224,9 @@ def get_run_context(self, multistage: bool = False) -> "RunContext":
result = RunContext(
config=self.config,
ui=self.ui,
env=os.environ,
env=self._env,
dry=self.ui["dry_run"],
poe_active=os.environ.get("POE_ACTIVE"),
poe_active=self._env.get("POE_ACTIVE"),
multistage=multistage,
cwd=self.cwd,
)
Expand Down
2 changes: 1 addition & 1 deletion poethepoet/env/template.py
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ def apply_envvars_to_template(
content: str, env: Mapping[str, str], require_braces=False
) -> str:
"""
Template in ${environmental} $variables from env as if we were in a shell
Template in ${environment} $variables from env as if we were in a shell
Supports escaping of the $ if preceded by an odd number of backslashes, in which
case the backslash immediately preceding the $ is removed. This is an
Expand Down
16 changes: 13 additions & 3 deletions poethepoet/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,19 @@ class PoeException(RuntimeError):
cause: Optional[str]

def __init__(self, msg, *args):
super().__init__(msg, *args)
self.msg = msg
self.cause = args[0].args[0] if args else None
self.args = (msg, *args)

if args:
cause = args[0]
position_clause = (
f", near line {cause.line}, position {cause.position}."
if getattr(cause, "has_position", False)
else "."
)
self.cause = cause.args[0] + position_clause
else:
self.cause = None


class CyclicDependencyError(PoeException):
Expand All @@ -34,7 +44,7 @@ def __init__(
task_name: Optional[str] = None,
index: Optional[int] = None,
global_option: Optional[str] = None,
filename: Optional[str] = None
filename: Optional[str] = None,
):
super().__init__(msg, *args)
self.context = context
Expand Down
39 changes: 35 additions & 4 deletions poethepoet/helpers/command/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ def resolve_command_tokens(
patterns that are not escaped or quoted. In case there are glob patterns in the
token, any escaped glob characters will have been escaped with [].
"""
from .ast import Glob, ParamExpansion, ParseConfig, PythonGlob
from .ast import Glob, ParamArgument, ParamExpansion, ParseConfig, PythonGlob

if not config:
config = ParseConfig(substitute_nodes={Glob: PythonGlob})
Expand All @@ -56,23 +56,54 @@ def finalize_token(token_parts):
token_parts.clear()
return (token, includes_glob)

def resolve_param_argument(argument: ParamArgument, env: Mapping[str, str]):
token_parts = []
for segment in argument.segments:
for element in segment:
if isinstance(element, ParamExpansion):
token_parts.append(resolve_param_value(element, env))
else:
token_parts.append(element.content)

return "".join(token_parts)

def resolve_param_value(element: ParamExpansion, env: Mapping[str, str]):
param_value = env.get(element.param_name, "")

if element.operation:
if param_value:
if element.operation.operator == ":+":
# apply 'alternate value' operation
param_value = resolve_param_argument(
element.operation.argument, env
)

elif element.operation.operator == ":-":
# apply 'default value' operation
param_value = resolve_param_argument(element.operation.argument, env)

return param_value

for line in lines:
# Ignore line breaks, assuming they're only due to comments
for word in line:
if isinstance(word, Comment):
# strip out comments
continue

# For each token part indicate whether it is a glob
token_parts: list[tuple[str, bool]] = []
for segment in word:
for element in segment:
if isinstance(element, ParamExpansion):
param_value = env.get(element.param_name, "")
param_value = resolve_param_value(element, env)
if not param_value:
# Empty param value has no effect
continue
if segment.is_quoted:
token_parts.append((env.get(element.param_name, ""), False))
token_parts.append((param_value, False))
elif param_value.isspace():
# collapse whitespace value
token_parts.append((" ", False))
else:
# If the the param expansion it not quoted then:
# - Whitespace inside a substituted param value results in
Expand Down
Loading

0 comments on commit 2d252f3

Please sign in to comment.