# This Source Code Form is subject to the terms of the Mozilla Public
# License, v. 2.0. If a copy of the MPL was not distributed with this
# file, You can obtain one at https://mozilla.org/MPL/2.0/.
r"""
quotish is a combinator library for building command lines and shell snippets using PEP
750 template strings.
>>> import quotish as qsh
>>> name = 'arcana shadow'
>>> echo = qsh.cmd(t'echo "Hello, {name}!"')
>>> echo.render()
"echo 'Hello, arcana shadow!'"
>>> echo.argv()
['echo', 'Hello, arcana shadow!']
>>> qsh.cmd(t'sh -c {echo}').argv()
['sh', '-c', "echo 'Hello, arcana shadow!'"]
There are three primary functions that take template strings:
- :func:`cmd`: Parses the template as shell syntax into individual arguments, which are then
re-joined when calling :meth:`Renderable.render`. Alternatively, you can get a list of
arguments with :meth:`Command.argv`, which can then be passed to ``subprocess.run`` or
other similar functions.
**This is the function you usually want to use.**
As a bonus, you can concatenate :class:`Command` instances with ``+`` or ``+=`` to
combine their argument lists:
>>> (qsh.cmd(t'echo') + t'hello' + qsh.cmd('world!')).argv()
['echo', 'hello', 'world!']
If you find yourself only calling :meth:`Command.argv` on the result, without using the
:class:`Command` for anything else, you can use the :func:`argv` shorthand:
>>> qsh.argv(t'echo "Hello, {name}!"')
['echo', 'Hello, arcana shadow!']
- :func:`shell`:
Parses the template as shell syntax but does not split or modify it in any way other
than expanding the template variables and added the needed quotes. This is useful if
you want to use actual shell syntax, which would normally be re-quoted by
:func:`cmd` when it re-joins the arguments.
- :func:`plain`:
Interpolates the template variables but does not perform any quoting or interpret
the template string in any way. This solely exists as a way to get normal f-string
behavior while working with :class:`Renderable`\ s, but **you should avoid using
this to build actual command lines**.
These all return instances of :class:`Renderable` subclasses, so you can use
:meth:`Renderable.render` on any of them (but of course, :meth:`Command.argv` is
specific to :func:`cmd`'s return value).
Lists given to :func:`cmd` or :func:`shell` can be expanded by using :func:`unpack` for
simple expansion, aliased as the ``*`` format specifier (if you don't care about type
safety, since format specifiers are not type-checked):
>>> qsh.cmd(t'echo +{qsh.unpack([1, 2])}+{[3,4]:*}+').argv()
['echo', '+1', '2+3', '4+']
or :func:`product` / ``^`` for a brace-expansion-style cartesian product:
>>> qsh.cmd(t'echo +{qsh.product([1, 2])}+{[3, 4]:^}+').argv()
['echo', '+1+3+', '+1+4+', '+2+3+', '+2+4+']
Passing a :class:`Command` to either of these will unpack its arguments:
>>> cmd = qsh.cmd(t'echo a b c')
>>> qsh.shell(t'exec {qsh.unpack(cmd)}').render()
'exec echo a b c'
(These functions are only intended to be used **directly** in the t-string, i.e. their
values should not be saved anywhere, for readability purposes—it's confusing if what
appears to be a single value is interpolated and somehow expands into multiple, without
this being clear at the call site.)
To mask out secret values when debugging, use :func:`mask` with
:meth:`Renderable.debug`:
>>> password = qsh.mask('password123')
>>> cmd = qsh.cmd(t'echo {password}')
>>> str(cmd)
"<<echo '[masked]'>>"
>>> cmd.debug()
"echo '[masked]'"
>>> cmd.render()
'echo password123'
"""
from collections.abc import Sequence
from dataclasses import dataclass
from string import templatelib
from typing import (
Callable,
Generator,
Iterable,
Literal,
assert_never,
override,
)
import abc
import contextlib
import dataclasses
import itertools
import shlex
from . import _lexer
@dataclass
class _RenderParams:
unmask: bool
[docs]
class Renderable(abc.ABC):
r"""
The type returned by the various combinators in this module, offering rendering with
or without unmasking. (See :func:`mask` for information on masking values.)
Additionally, any :class:`Renderable`\ s can be concatenated with :class:`Part`\ s
(i.e. another :class:`Renderable` or a plain ``str``) using the ``&`` operator:
>>> import quotish as qsh
>>> (qsh.plain('abc') & qsh.plain('def') & 'ghi').render()
'abcdefghi'
This is as an alias for :func:`join`.
"""
[docs]
def render(self) -> str:
r"""
Render this to a string, unmasking any :func:`mask`\ 'd values in the process.
To keep the values masked, use :func:`debug` instead.
"""
return self._render(_RenderParams(unmask=True))
[docs]
def debug(self) -> str:
r"""
Render this to a string, *without* unmasking any :func:`mask`\ 'd values.
"""
return self._render(_RenderParams(unmask=False))
def __and__(self, other: Part) -> Renderable:
return join([self, other], by='')
def __rand__(self, other: Part) -> Renderable:
return join([other, self], by='')
def __iand__(self, other: Part) -> Renderable:
return self & other
@abc.abstractmethod
def _render(self, params: _RenderParams) -> str:
_ = params
raise NotImplementedError
@override
def __str__(self) -> str:
# <<...>> is to make it clear that this is not just a normal string.
return f'<<{self.debug()}>>'
type Part = Renderable | str
r"""
A part of a string that can be passed to various combinators.
While :class:`Renderable` is what's usually returned from functions like :func:`cmd`,
:class:`Part`\ 's inclusion of ``str`` allows you to pass plain strings to functions
like :func:`mask`.
"""
def _render(part: Part, params: _RenderParams) -> str:
if isinstance(part, Renderable):
return part._render(params)
else:
return part
def _render_parts(parts: list[Part], params: _RenderParams) -> list[str]:
return [_render(part, params) for part in parts]
@dataclass
class _Format(Renderable):
_inner: object
_conversion: Literal['a', 'r', 's'] | None
_spec: str
@staticmethod
def wrap(
inner: object,
*,
options_from: templatelib.Interpolation[object] | None,
) -> Part:
conversion = options_from.conversion if options_from is not None else None
spec = options_from.format_spec if options_from is not None else ''
if not isinstance(inner, (str, Renderable)) or (
options_from is not None and (conversion is not None or spec)
):
return _Format(
_inner=inner,
_conversion=conversion,
_spec=spec,
)
else:
return inner
@staticmethod
def wrap_many(
inner: Iterable[object],
*,
options_from: templatelib.Interpolation[object] | None,
) -> list[Part]:
return [_Format.wrap(part, options_from=options_from) for part in inner]
@override
def _render(self, params: _RenderParams) -> str:
value = (
self._inner._render(params)
if isinstance(self._inner, Renderable)
else self._inner
)
if self._conversion is not None:
value = templatelib.convert(value, self._conversion)
return format(value, self._spec)
@dataclass
class _Unpack:
_inner: list[Part]
_unsafe_by: str
@staticmethod
def from_interpolation(item: templatelib.Interpolation[object]) -> _Unpack | None:
if isinstance(item.value, _Unpack):
if item.format_spec or item.conversion is not None:
return _Unpack(
_inner=_Format.wrap_many(item.value._inner, options_from=item),
_unsafe_by=item.value._unsafe_by,
)
return item.value
if isinstance(item.value, (Sequence, Command)) and item.format_spec.startswith(
'*'
):
return _Unpack(
_inner=_Format.wrap_many(
item.value._argv if isinstance(item.value, Command) else item.value,
options_from=templatelib.Interpolation(
value=item.value,
expression=item.expression,
format_spec=item.format_spec.removeprefix('*'),
conversion=item.conversion,
),
),
_unsafe_by=' ',
)
[docs]
def unpack(parts: Iterable[object] | Command, *, unsafe_by: str = ' ') -> _Unpack:
"""
Unpacks the given iterable or command into a :func:`cmd` or :func:`shell`.
>>> import quotish as qsh
>>> names = ['mystique', 'answer']
>>> (echo := qsh.cmd(t'echo {qsh.unpack(names)}')).argv()
['echo', 'mystique', 'answer']
>>> qsh.shell(t'exec {qsh.unpack(echo)}').render()
'exec echo mystique answer'
If ``unsafe_by`` is given, it will be used to concatenate the items instead of
spaces, but **without it being treated as being quoted**. Thus, any spaces or other
special characters will behave as they would if placed directly in the t-string:
>>> qsh.cmd(t'echo {qsh.unpack(names, unsafe_by=' & ')}').render()
"echo mystique '&' answer"
>>> qsh.cmd(t'echo {names[0]} & {names[1]}').render() # same as this
"echo mystique '&' answer"
>>> qsh.shell(t'echo {qsh.unpack(names, unsafe_by=' & ')}').render()
'echo mystique & answer'
>>> qsh.shell(t'echo {names[0]} & {names[1]}').render() # same as this
'echo mystique & answer'
If you want the separator *and* the items to be quoted, use :func:`join`.
When used with :func:`shell`, if you'd like to ensure *neither* of them are quoted,
use :func:`unsafe`:
>>> dangerous = ['mystique!', 'answer!']
>>> qsh.shell(t'echo {qsh.unpack(dangerous, unsafe_by=' & ')}').render()
"echo 'mystique!' & 'answer!'"
>>> qsh.shell(t'echo {qsh.unsafe(qsh.unpack(dangerous, unsafe_by=' & '))}').render()
'echo mystique! & answer!'
Unlike regular shell brace expansion, this does *not* perform a cartesian product;
the first and last item will simply be placed next to any other text part of the
same shell argument:
>>> qsh.cmd(t'echo ({qsh.unpack(names)})').argv()
['echo', '(mystique', 'answer)']
If you want a cartesian product, use :func:`product`.
"""
if isinstance(parts, Command):
parts = parts._argv
return _Unpack(
_inner=_Format.wrap_many(parts, options_from=None), _unsafe_by=unsafe_by
)
@dataclass
class _Product:
_inner: list[Part]
@staticmethod
def from_interpolation(item: templatelib.Interpolation[object]) -> _Product | None:
if isinstance(item.value, _Product):
if item.format_spec or item.conversion is not None:
return _Product(
_inner=_Format.wrap_many(
item.value._inner,
options_from=item,
)
)
return item.value
if isinstance(item.value, (Sequence, Command)) and item.format_spec.startswith(
'^'
):
return _Product(
_inner=_Format.wrap_many(
item.value._argv if isinstance(item.value, Command) else item.value,
options_from=templatelib.Interpolation(
value=item.value,
expression=item.expression,
format_spec=item.format_spec.removeprefix('^'),
conversion=item.conversion,
),
)
)
[docs]
def product(parts: Iterable[object] | Command) -> _Product:
"""
Unpacks the given iterable or command into a :func:`cmd` or :func:`shell`, using a
cartesian product.
>>> import quotish as qsh
>>> names = ['mystique', 'answer']
>>> (echo := qsh.cmd(t'echo {qsh.product(names)},{qsh.product(names)}')).argv()
['echo', 'mystique,mystique', 'mystique,answer', 'answer,mystique', 'answer,answer']
>>> qsh.shell(t'exec {qsh.unpack(echo)}').render()
'exec echo mystique,mystique mystique,answer answer,mystique answer,answer'
This is similar to shell brace expansion. If instead, you want to simply expand the
items side-by-side, use :func:`unpack`.
"""
if isinstance(parts, Command):
parts = parts._argv
return _Product(_inner=_Format.wrap_many(parts, options_from=None))
@dataclass
class _Unsafe:
_inner: Part | _Unpack | _Product
[docs]
def unsafe(inner: Part | _Unpack | _Product) -> _Unsafe:
"""
Renders the inner part without any quoting in :func:`shell`.
>>> import quotish as qsh
>>> qsh.shell(t'echo {'hello!'}').render() # quoted
"echo 'hello!'"
>>> qsh.shell(t'echo {qsh.unsafe('hello!')}').render() # NOT quoted
'echo hello!'
Can also be used to wrap :func:`unpack` or :func:`product`:
>>> dangerous = ['mystique!', 'answer!']
>>> qsh.shell(t'echo {qsh.unsafe(qsh.unpack(dangerous))}').render()
'echo mystique! answer!'
>>> qsh.shell(t'echo {qsh.unsafe(qsh.product(dangerous))}').render()
'echo mystique! answer!'
"""
return _Unsafe(_inner=inner)
@dataclass
class _Join(Renderable):
_inner: list[Part]
_by: str
@override
def _render(self, params: _RenderParams) -> str:
return self._by.join(_render_parts(self._inner, params))
[docs]
def join(parts: Iterable[Part], *, by: str) -> _Join:
r"""
Joins the given parts with the given separator.
When later expanded into :func:`cmd` or :func:`shell`, the entire, joined result is
treated as a single argument and thus will be escaped as such.
>>> import quotish as qsh
>>> joined = qsh.join(['hello', 'world'], by=' ')
>>> joined.render()
'hello world'
>>> qsh.cmd(t'echo {joined}').render()
"echo 'hello world'"
You can also use ``&`` as an alias for joining two :class:`Part`\ s by an empty
separator:
>>> (qsh.plain('hello') & 'world').render()
'helloworld'
"""
return _Join(_inner=list(parts), _by=by)
@dataclass
class _Masked(Renderable):
_inner: Part
@override
def _render(self, params: _RenderParams) -> str:
if not params.unmask:
return '[masked]'
return _render(self._inner, params)
[docs]
def mask(inner: Part) -> _Masked:
"""
Masks the given part when rendered with :meth:`Renderable.debug`.
>>> import quotish as qsh
>>> secret = 'my super secret password'
>>> echo = qsh.cmd(t'echo {qsh.mask(secret)}')
>>> echo.debug()
"echo '[masked]'"
>>> echo.render()
"echo 'my super secret password'"
>>> str(echo) # uses debug(), not render()
"<<echo '[masked]'>>"
Generally, you should try to `avoid passing secrets through the command line
<https://security.stackexchange.com/questions/190071/when-running-shell-scripts-is-it-safer-to-pass-sensitive-information-using-stdi>`_,
but if absolutely necessary, this should help make logging safer.
"""
return _Masked(_inner=inner)
@dataclass
class _Unmasked(Renderable):
_inner: Part
@override
def _render(self, params: _RenderParams) -> str:
return _render(self._inner, dataclasses.replace(params, unmask=True))
[docs]
def unmask(inner: Part) -> _Unmasked:
"""
Unmasks any masked values in the given part.
>>> import quotish as qsh
>>> m = qsh.mask('my secret')
>>> m.debug()
'[masked]'
>>> qsh.unmask(m).debug()
'my secret'
"""
return _Unmasked(_inner=inner)
@dataclass
class _Quoted(Renderable):
_inner: Part
@override
def _render(self, params: _RenderParams) -> str:
return shlex.quote(_render(self._inner, params))
[docs]
def quote(inner: Part) -> _Quoted:
"""
Shell quotes the given part.
>>> import quotish as qsh
>>> qsh.quote('hello world!').render()
"'hello world!'"
You should prefer using :func:`cmd` or :func:`shell` when possible for more
exhaustive functionality. (They both use :func:`quote` internally.)
"""
return _Quoted(_inner=inner)
[docs]
@dataclass
class Command(Renderable):
r"""
A command line, internally split following shell quoting rules.
Returned by :func:`cmd`, with extra rendering methods specifically for conviently
dealing with argument lists.
Unlike other :class:`Renderable`\ s, :class:`Command`\ s can be added to each other
or to other t-strings, which will concatenate their argument lists:
>>> import quotish as qsh
>>> echo = qsh.cmd('echo')
>>> echo += t'some arguments'
>>> echo += qsh.cmd(t'more arguments')
>>> echo.argv()
['echo', 'some', 'arguments', 'more', 'arguments']
"""
_argv: list[Part]
[docs]
def argv(self, *, debug: bool = False) -> list[str]:
"""
Renders the individual command line arguments into a list of strings.
Set ``debug=True`` to mask secrets given to :func:`mask`.
>>> import quotish as qsh
>>> secret = qsh.mask('super secret')
>>> echo = qsh.cmd(t'echo "Your password is: {secret}"')
>>> echo.argv()
['echo', 'Your password is: super secret']
>>> echo.argv(debug=True)
['echo', 'Your password is: [masked]']
"""
return _render_parts(self._argv, _RenderParams(unmask=not debug))
@override
def _render(self, params: _RenderParams) -> str:
return ' '.join(map(shlex.quote, _render_parts(self._argv, params)))
@staticmethod
def _extract_argv_for_add(other: Command | templatelib.Template) -> list[Part]:
if isinstance(other, Command):
return other._argv
elif isinstance(other, templatelib.Template): # pyright: ignore[reportUnnecessaryIsInstance]
return cmd(other)._argv
else:
raise ValueError('Can only add Command to another Command or a Template')
def __add__(self, other: Command | templatelib.Template) -> Command:
return Command(self._argv + self._extract_argv_for_add(other))
def __radd__(self, other: Command | templatelib.Template) -> Command:
return Command(self._extract_argv_for_add(other) + self._argv)
def __iadd__(self, other: Command | templatelib.Template) -> Command:
self._argv += self._extract_argv_for_add(other)
return self
class UnsupportedInterpolationModifier(Exception):
"""
Raised when :func:`unsafe`, :func:`unpack`, or :func:`product` are used in
unsupported places.
"""
def __init__(self, *, modifier: str, me: str) -> None:
super().__init__(f'{modifier} is not supported in {me}')
@staticmethod
def _unsafe(*, me: str) -> UnsupportedInterpolationModifier:
return UnsupportedInterpolationModifier(modifier='unsafe()', me=me)
@staticmethod
def _unpack(*, me: str) -> UnsupportedInterpolationModifier:
return UnsupportedInterpolationModifier(modifier='unpack()/*', me=me)
@staticmethod
def _product(*, me: str) -> UnsupportedInterpolationModifier:
return UnsupportedInterpolationModifier(modifier='product()/^', me=me)
@dataclass
class _ArgLinear:
parts: list[Part]
@property
def empty(self) -> bool:
return not self.parts
def add(self, part: Part) -> None:
self.parts.append(part)
def pop(self) -> None:
self.parts.pop()
def into_argv(self) -> list[Sequence[Part]]:
if not self.parts:
return []
return [self.parts]
@dataclass
class _ArgProduct:
part_lists: list[list[Part]]
@staticmethod
def from_linear(linear: _ArgLinear) -> '_ArgProduct':
return _ArgProduct([[arg] for arg in linear.parts])
@property
def empty(self) -> bool:
return not self.part_lists
def add(self, part: Part) -> None:
self.part_lists.append([part])
def pop(self) -> None:
self.part_lists.pop()
def into_argv(self) -> list[Sequence[Part]]:
return list(itertools.product(*self.part_lists))
[docs]
def cmd(tmpl: templatelib.Template) -> Command:
r"""
Parses the given template into an argument list using shell quoting rules.
>>> import quotish as qsh
>>> qsh.cmd(t'echo hello "a name"').argv()
['echo', 'hello', 'a name']
>>> qsh.cmd(t'echo hello "a name"').render()
"echo hello 'a name'"
Variables substituted into the template will be treated as if they were quoted:
>>> name = 'arcana shadow'
>>> qsh.cmd(t'echo hello {name}').argv()
['echo', 'hello', 'arcana shadow']
>>> qsh.cmd(t'echo hello {name}').render()
"echo hello 'arcana shadow'"
You can use :func:`unpack` or :func:`product` to unpack sequences as individual
arguments, or their less-type-safe format specifiers ``*`` and ``^``:
>>> names = ['mystique', 'answer']
>>> qsh.cmd(t'echo "some names: " ({qsh.unpack(names)})').argv()
['echo', 'some names: ', '(mystique', 'answer)']
>>> qsh.cmd(t'echo "some names: " ({names:*})').render()
"echo 'some names: ' '(mystique' 'answer)'"
>>> qsh.cmd(t'ls users/{qsh.product(names)}').argv()
['ls', 'users/mystique', 'users/answer']
>>> qsh.cmd(t'ls users/{names:^}').render()
'ls users/mystique users/answer'
Because this splits and re-joins the string, special characters will be quoted when
rendered, quotes will be rewritten to a preferred form, and backslashes will be
evaluated:
>>> qsh.cmd(rt"special & 'characters' \"").render()
'special \'&\' characters \'"\''
If you want to substitute values into a template without affecting the literal
parts, use :func:`shell`, which doesn't perform the splitting.
"""
argv: list[Part] = []
current_arg: _ArgLinear | _ArgProduct = _ArgLinear([])
def flush_arg() -> None:
nonlocal current_arg
argv.extend(join(arg, by='') for arg in current_arg.into_argv())
current_arg = _ArgLinear([])
def add_unpack(values: Sequence[Part], *, unsafe_by: str) -> None:
if not values:
return
current_arg.add(values[0])
for part in values[1:]:
lexer.feed(unsafe_by)
current_arg.add(part)
def add_product(values: Sequence[Part]) -> None:
nonlocal current_arg
if not values:
return
if not isinstance(current_arg, _ArgProduct):
current_arg = _ArgProduct.from_linear(current_arg)
current_arg.part_lists.append(list(values))
def on_token(token: _lexer.Token) -> None:
match token.kind:
case _lexer.Token.Kind.WS:
flush_arg()
case _lexer.Token.Kind.SLASH | _lexer.Token.Kind.QUOTE:
# Although we don't keep control characters, make sure the current
# argument will not be treated as being empty.
if current_arg.empty:
current_arg.add('')
case _lexer.Token.Kind.TEXT:
current_arg.add(token.content)
case _:
assert_never(token.kind)
lexer = _lexer.ShellLexer(on_token)
for item in tmpl:
if isinstance(item, str):
lexer.feed(item)
continue
if isinstance(item.value, _Unsafe):
raise UnsupportedInterpolationModifier._unsafe(me='cmd()')
if (unpack := _Unpack.from_interpolation(item)) is not None:
add_unpack(unpack._inner, unsafe_by=unpack._unsafe_by)
elif (product := _Product.from_interpolation(item)) is not None:
add_product(product._inner)
else:
current_arg.add(_Format.wrap(item.value, options_from=item))
lexer.finish()
flush_arg()
return Command(argv)
[docs]
def argv(tmpl: templatelib.Template) -> list[str]:
"""
An alias for calling :func:`cmd` and then :meth:`argv` on the result.
>>> import quotish as qsh
>>> qsh.argv(t'echo 123')
['echo', '123']
>>> qsh.cmd(t'echo 123').argv()
['echo', '123']
"""
return cmd(tmpl).argv()
[docs]
def shell(tmpl: templatelib.Template) -> Renderable:
r"""
Parses the given template into a shell string using shell quoting rules.
>>> import quotish as qsh
>>> qsh.shell(t'echo hello "a name"').render()
'echo hello "a name"'
Variables substituted into the template will be treated as if they were quoted:
>>> name = 'arcana shadow'
>>> qsh.shell(t'echo hello {name}').render()
"echo hello 'arcana shadow'"
You can use :func:`unpack` or :func:`product` to unpack sequences as individual
arguments, or their less-type-safe format specifiers ``*`` and ``^``:
>>> names = ['mystique!', 'answer!']
>>> qsh.shell(rt"echo 'some names: ' \({qsh.unpack(names)}\)").render()
"echo 'some names: ' \\('mystique!' 'answer!'\\)"
>>> qsh.shell(t'ls users/{qsh.product(names)}').render()
"ls users/'mystique!' users/'answer!'"
Unlike :func:`cmd`, :func:`shell` does not touch any characters outside of the
substituted values:
>>> qsh.shell(rt"special & 'characters' \"").render()
'special & \'characters\' \\"'
However, in order to substitute in values, strings may be closed and reopened via
intermediate quotes:
>>> name1 = 'mystique'
>>> name2 = 'arcana shadow'
>>> qsh.shell(t'echo "Hello, {name1}!" "and {name2}" too').render()
'echo "Hello, "mystique"!" "and "\'arcana shadow\' too'
Because it ignores the meanings of any characters other than quotes, you may have
surprising behavior when mixing special characters with cartesian products:
>>> qsh.shell(t'echo {names:^}; echo 123').render()
"echo 'mystique!'; 'answer!'; echo 123"
In an actual shell, the semicolon is a syntactic element and thus is not part of the
arguments to ``echo``, but here it gets treated as a regular character.
If you want an argument list for use with functions like ``subprocess.run``, use
:func:`cmd` instead.
"""
parts: list[Part] = []
current_arg: _ArgLinear | _ArgProduct = _ArgLinear([])
# We may end up receiving an interpolated value *inside* of quotes, e.g.:
#
# t'echo "Hello, {name}!"' # noqa: ERA001
# In that case, we can't just blindly quote `name`, because that might result in
# something like
#
# echo "Hello, 'the user'!"
#
# which is nonsense—the single quotes should not be inside the double ones. Thus,
# the initial quoted string needs to be re-closed before the interpolation and
# re-opened afterwards:
#
# echo "Hello"'the user'"!"
#
# Additionally, it would be nice to avoid unneeded quotes at edges, e.g.:
#
# t'echo "{name}, hello!"' # noqa: ERA001
# t'echo "you are: {name}"' # noqa: ERA001
#
# should not result in
#
# echo ""'the user'", hello!" # <- unneeded "" at the start
# echo "you are: "'the user'"" # <- unneeded "" at the end
# There are two parts to this:
# - last_token_was_quote and can_omit_closing_quote track the state needed.
# - unquoted() will adjust the currently added parts such that anything added within
# the `yield` can assume it is in an unquoted context.
last_token_was_quote: bool = True
can_omit_closing_quote: bool = False
# unquoted() makes sure that the parts added within the block are *not* quoted.
@contextlib.contextmanager
def unquoted() -> Generator[None]:
nonlocal can_omit_closing_quote
quote = lexer.in_quote
if quote is None:
# Not actually in a quoted sequence, so nothing special to do.
yield
return
# If the last token was an opening quote, then we can just remove it, instead of
# having to close an empty quoted sequence.
pop_quote = last_token_was_quote
if pop_quote:
current_arg.pop()
else:
current_arg.add(quote.value)
try:
yield
finally:
current_arg.add(quote.value)
can_omit_closing_quote = True
def flush_arg() -> None:
nonlocal current_arg
for i, arg in enumerate(current_arg.into_argv()):
# None of these should have leading/trailing whitespace, so in the case of
# >= arg list (i.e. a product), make sure that there's some form of
# separation.
if i:
parts.append(' ')
parts.extend(arg)
current_arg = _ArgLinear([])
def add_unpack(
values: Sequence[Part],
*,
unsafe_by: str,
quote_fn: Callable[[Part], Part],
) -> None:
if not values:
return
with unquoted():
current_arg.add(quote_fn(values[0]))
for value in values[1:]:
lexer.feed(unsafe_by)
current_arg.add(quote_fn(value))
def add_product(values: Sequence[Part]) -> None:
nonlocal current_arg
if not values:
return
with unquoted():
if not isinstance(current_arg, _ArgProduct):
current_arg = _ArgProduct.from_linear(current_arg)
current_arg.part_lists.append([quote_fn(value) for value in values])
def on_token(token: _lexer.Token) -> None:
nonlocal last_token_was_quote, can_omit_closing_quote
if can_omit_closing_quote:
can_omit_closing_quote = False
# If we know we can omit a closing quote, and *this* is the closing quote,
# then...omit it.
if token.kind == _lexer.Token.Kind.QUOTE:
current_arg.pop()
# Also avoid setting last_token_was_quote here, because that's used to
# determine whether we can pop a quote off, which makes no sense because
# we didn't add it in the first place.
return
# Make sure whitespace is always part of its "own" arguments, so that it won't
# get attached inconsistently to a product.
if token.kind == _lexer.Token.Kind.WS:
flush_arg()
last_token_was_quote = token.kind == _lexer.Token.Kind.QUOTE
current_arg.add(token.content)
if token.kind == _lexer.Token.Kind.WS:
flush_arg()
lexer = _lexer.ShellLexer(on_token)
for item in tmpl:
if isinstance(item, str):
lexer.feed(item)
continue
quote_fn: Callable[[Part], Part] = quote
if isinstance(item.value, _Unsafe):
item = templatelib.Interpolation[object](
value=item.value._inner,
expression=item.expression,
conversion=item.conversion,
format_spec=item.format_spec,
)
quote_fn = lambda x: x # noqa: E731
if (unpack := _Unpack.from_interpolation(item)) is not None:
add_unpack(unpack._inner, unsafe_by=unpack._unsafe_by, quote_fn=quote_fn)
elif (product := _Product.from_interpolation(item)) is not None:
add_product(product._inner)
else:
with unquoted():
current_arg.add(quote_fn(_Format.wrap(item.value, options_from=item)))
flush_arg()
return join(parts, by='')
[docs]
def plain(tmpl: templatelib.Template) -> Renderable:
r"""
Formats the given template as if a plain string, with no special quoting.
>>> import quotish as qsh
>>> name = 'arcana shadow'
>>> qsh.plain(t'hello, {name}').render()
'hello, arcana shadow'
"""
parts: list[Part] = []
for item in tmpl:
if isinstance(item, str):
parts.append(item)
continue
if isinstance(item.value, _Unsafe):
raise UnsupportedInterpolationModifier._unsafe(me='plain()')
if _Unpack.from_interpolation(item) is not None:
raise UnsupportedInterpolationModifier._unpack(me='plain()')
if _Product.from_interpolation(item) is not None:
raise UnsupportedInterpolationModifier._product(me='plain()')
parts.append(_Format.wrap(item.value, options_from=item))
return join(parts, by='')