"""Similarly to ``git rebase -i`` this extension allows users to interactively
choose which options apply to ``putup``, by editing a file filled with examples.
See :obj:`CONFIG` for more details on how to tweak the text generated in the interactive
mode.
.. versionadded:: 4.0
*"interactive mode"* introduced as an **experimental** extension.
Warning:
**NOTE FOR CONTRIBUTORS**:
Due to the way :mod:`argparse` is written, it is not very easy to obtain information
about which options and arguments a given parser is currently configured with.
There are no public methods that allow inspection/reflection, and therefore in order
to do so, one has to rely on a few non-public methods (according to Python's
convention, the ones starting with a ``_`` symbol).
Since :mod:`argparse` implementation is very stable and mature, these non-public
method are very unlikely to change and, therefore, it is relatively safe to use
these methods, however developers and maintainers have to be aware and pay attention
to eventual breaking changes.
The non-public functions are encapsulated in the functions :obj:`get_actions` and
:obj:`format_args` in this file, in order to centralise the usage of non-public API.
"""
import os
import shlex
import textwrap
from argparse import Action, ArgumentParser
from collections import abc
from functools import lru_cache, reduce
from itertools import chain
from typing import List, Optional, Set
from .. import api, cli, file_system, shell, templates
from ..actions import ScaffoldOpts as Opts
from ..actions import get_default_options
from . import Extension
from . import list_from_entry_points as list_all_extensions
INDENT_LEVEL = 4
HEADER = templates.get_template("header_interactive")
CONFIG_KEY = "interactive"
CONFIG = {
"ignore": ["--help", "--version"],
"comment": ["--verbose", "--very-verbose"],
}
"""Configuration for the options that are not associated with an extension class.
This dict value consist of a set of metadata organised as follows:
- Each value must be a list of "long" :obj:`argparse` option strings (e.g. `"--help"`
instead of `"-h"`).
- Each key implies on a different interpretation for the metadata:
- ``"ignore"``: Options that should be simply ignored when creating examples
- ``"comment"``: Options that should be commented when creating examples,
even if they appear in the original :obj:`sys.argv`.
Extension classes (or instances) can also provide configurations by defining a
``interactive`` attribute assigned to a similar :obj:`dict` object.
"""
[docs]@lru_cache(maxsize=2)
def get_config(kind: str) -> Set[str]:
"""Get configurations that will be used for generating examples
(from both :obj:`CONFIG` and the ``interactive`` attribute of each extension).
The ``kind`` argument can assume the same values as the :obj:`CONFIG` keys.
This function is cached to improve performance. Call ``get_config.__wrapped__`` to
bypass the cache (or ``get_config.cache_clear``, see :obj:`functools.lru_cache`).
"""
# TODO: when `python_requires >= 3.8` use Literal["ignore", "comment"] instead of
# str for type annotation of kind
configurable = CONFIG.keys()
assert kind in configurable
initial_value = set(CONFIG[kind]) # A set avoid repeated flags
empty_config: dict = {k: [] for k in configurable} # Same shape as CONFIG
def _merge_config(accumulated_config, extension):
# The main idea is to collect all the configuration dicts from the extensions
# (`interactive` attribute) and merge them with CONFIG, avoiding repetitions
extension_config_dict = getattr(extension, CONFIG_KEY, empty_config)
return accumulated_config.union(set(extension_config_dict.get(kind, [])))
return reduce(_merge_config, list_all_extensions(), initial_value)
[docs]class Interactive(Extension):
"""Interactively choose and configure PyScaffold's parameters"""
parser: ArgumentParser
def __init__(self, name: Optional[str] = None):
super().__init__(name)
setattr(self, CONFIG_KEY, {"ignore": [self.flag]})
self.flags = [f"-{self.name[0].lower()}", self.flag]
[docs] def augment_cli(self, parser: ArgumentParser):
"""See :obj:`~pyscaffold.extensions.Extension.augment_cli`."""
self.parser = parser
parser.add_argument(
*self.flags,
dest="command",
action="store_const",
const=self.command,
help=self.help_text,
)
return self
[docs] def command(self, opts: Opts):
"""This method replace the regular call to :obj:`cli.run_scaffold
<pyscaffold.cli.run_scaffold>` with an intermediate file to confirm the user's
choices in terms of arguments/options.
"""
opts = expand_computed_opts(opts)
examples = all_examples(self.parser, get_actions(self.parser), opts)
content = (os.linesep * 2).join([HEADER.template, examples])
with file_system.tmpfile(prefix="pyscaffold-", suffix=".args.sh") as file:
file.write_text(content, "utf-8")
content = shell.edit(file).read_text("utf-8")
cli.main(split_args(content)) # Call the CLI again with the new options
[docs]def expand_computed_opts(opts: Opts) -> Opts:
"""Pre-process the given PyScaffold options and add default/computed values
(including the ones derived from ``setup.cfg`` in case of ``--update`` or
PyScaffold's own configuration file in the user's home directory.
"""
_struct, opts = get_default_options({}, api.bootstrap_options(opts))
return opts
[docs]def wrap(text: Optional[str], width=70, **kwargs) -> str:
"""Wrap text to fit lines with a maximum number of characters"""
return os.linesep.join(textwrap.wrap(text or "", width, **kwargs))
[docs]def join_block(*parts: str, sep=os.linesep):
"""Join blocks of text using ``sep``, but ignoring the empty ones"""
return sep.join(p for p in parts if p)
[docs]def long_option(action: Action):
"""Get the long option corresponding to the given :obj:`argparse.Action`"""
return sorted(action.option_strings or [""], key=len)[-1]
[docs]def alternative_flags(action: Action):
"""Get the alternative flags (i.e. not the long one) of a :obj:`argparse.Action`"""
flags = sorted(action.option_strings, key=len)[:-1]
return f"(or alternatively: {' '.join(flags)})" if flags else ""
[docs]def has_active_extension(action: Action, opts: Opts) -> bool:
"""Returns :obj:`True` if the given :obj:`argparse.Action` corresponds to an
extension that was previously activated via CLI.
"""
ext_flags = [getattr(ext, "flag", None) for ext in opts.get("extensions", [])]
return any(f in ext_flags for f in action.option_strings)
[docs]def example_no_value(parser: ArgumentParser, action: Action, opts: Opts) -> str:
"""Generate a CLI example of option usage for a :obj:`argparse.Action` that do not
expect arguments (``nargs = 0``).
See :obj:`example`.
"""
long = long_option(action)
active_extension = has_active_extension(action, opts)
value = opts.get(action.dest)
stored_value = value == action.const and value != action.default
# ^ This function is only invoked when `nargs == 0` (store_*)
# When the option is activated the value should be stored
if long not in get_config("comment") and (active_extension or stored_value):
return f" {long}"
return comment(long)
[docs]def example_with_value(parser: ArgumentParser, action: Action, opts: Opts) -> str:
"""Generate a CLI example of option usage for a :obj:`argparse.Action` that expects
one or more arguments (``nargs`` is ``"?"``, ``"*"``, ``"+"`` or ``"N" > 0``).
See :obj:`example`.
"""
long = long_option(action)
arg = opts.get(action.dest)
if action.nargs in [None, 1, "?"]:
value = shlex.quote(f"{arg}")
elif isinstance(arg, (abc.Sequence, abc.Set)):
# We are expecting a sequence/set since nargs is *, + or N > 1
value = " ".join(shlex.quote(f"{a}") for a in arg).strip()
else:
value = ""
if arg is None or long in get_config("comment") or value == "":
return comment(f"{long} {format_args(parser, action)}".strip())
return f" {long} {value}"
[docs]def example(parser: ArgumentParser, action: Action, opts: Opts) -> str:
"""Generate a CLI example of option usage for the given :obj:`argparse.Action`.
The ``opts`` argument corresponds to options already processed by PyScaffold, and
interferes on the generated text (e.g., when the corresponding option is already
processed, the example will be adjusted accordingly; when the
corresponding option is not present, the example might be commented out; ...).
This function will comment options that are marked in the ``"comment"``
:obj:`configuration <CONFIG>`.
"""
fn = example_no_value if action.nargs == 0 else example_with_value
return fn(parser, action, opts)
[docs]def example_with_help(parser: ArgumentParser, action: Action, opts: Opts) -> str:
"""Generate a CLI example of option usage for the given :obj:`argparse.Action` that
includes a comment text block explaining its meaning (basically the same text
displayed when using ``--help``).
See :obj:`example`.
"""
return join_block(
example(parser, action, opts),
comment(alternative_flags(action), indent_level=INDENT_LEVEL),
comment(wrap(action.help), indent_level=INDENT_LEVEL),
)
[docs]def all_examples(parser: ArgumentParser, actions: List[Action], opts: Opts) -> str:
"""Generate a example of usage of the CLI options corresponding to the given
:obj:`actions <argparse.Action>` including the help text.
This function will skip options that are marked in the ``"ignore"``
:obj:`configuration <CONFIG>`.
See :obj:`example_with_help`.
"""
parts = (
example_with_help(parser, a, opts)
for a in actions
if long_option(a) not in get_config("ignore")
)
return join_block(*parts, sep=os.linesep * 3)
[docs]def split_args(text: str) -> List[str]:
"""Split the text from the interactive example into arguments that can be passed
directly to :mod:`argparse`, as if they were invoked directly from the CLI
(this includes removing comments).
"""
lines = (line.strip() for line in text.splitlines())
return list(chain.from_iterable(shlex.split(x) for x in lines if x and x[0] != "#"))
# -- Functions that encapsulate calls to argparse non-public API --
[docs]def get_actions(parser: ArgumentParser):
"""List actions related to options that were configured to the given
:obj:`ArgumentParser`.
Warning:
This function uses non-public API from Python's stdlib :mod:`argparse`.
"""
return parser._actions