Source code for quotish

# 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='')