# -*- coding: utf-8 -*-
"""Back-port compiler for Python 3.8 positional-only parameter syntax."""
import argparse
import os
import pathlib
import re
import sys
import traceback
from typing import Generator, List, Optional, Union
import f2format
import parso.python.tree
import parso.tree
import tbtrim
from bpc_utils import (BaseContext, BPCSyntaxError, Config, TaskLock, archive_files,
detect_encoding, detect_files, detect_indentation, detect_linesep,
first_non_none, get_parso_grammar_versions, map_tasks, parse_boolean_state,
parse_indentation, parse_linesep, parse_positive_integer, parso_parse,
recover_files)
from bpc_utils.typing import Linesep
from typing_extensions import Literal, final
__all__ = ['main', 'poseur', 'convert', 'decorator'] # pylint: disable=undefined-all-variable
# version string
__version__ = '0.4.3'
###############################################################################
# Typings
class PoseurConfig(Config):
indentation = '' # type: str
linesep = '\n' # type: Literal[Linesep]
pep8 = True # type: bool
filename = None # Optional[str]
source_version = None # Optional[str]
decorator = 'decorator' # type: str
dismiss = False # type: bool
##############################################################################
# Auxiliaries
#: Get supported source versions.
#:
#: .. seealso:: :func:`bpc_utils.get_parso_grammar_versions`
POSEUR_SOURCE_VERSIONS = get_parso_grammar_versions(minimum='3.8')
# option default values
#: Default value for the ``quiet`` option.
_default_quiet = False
#: Default value for the ``concurrency`` option.
_default_concurrency = None # auto detect
#: Default value for the ``do_archive`` option.
_default_do_archive = True
#: Default value for the ``archive_path`` option.
_default_archive_path = 'archive'
#: Default value for the ``source_version`` option.
_default_source_version = POSEUR_SOURCE_VERSIONS[-1]
#: Default value for the ``linesep`` option.
_default_linesep = None # auto detect
#: Default value for the ``indentation`` option.
_default_indentation = None # auto detect
#: Default value for the ``pep8`` option.
_default_pep8 = True
#: Default value for the ``dismiss-runtime`` option.
_default_dismiss = False
#: Default value for the ``decorator-name`` option.
_default_decorator = '_poseur_decorator'
# option getter utility functions
# option value precedence is: explicit value (CLI/API arguments) > environment variable > default value
[docs]def _get_quiet_option(explicit: Optional[bool] = None) -> Optional[bool]:
"""Get the value for the ``quiet`` option.
Args:
explicit (Optional[bool]): the value explicitly specified by user,
:data:`None` if not specified
Returns:
bool: the value for the ``quiet`` option
:Environment Variables:
:envvar:`POSEUR_QUIET` -- the value in environment variable
See Also:
:data:`_default_quiet`
"""
# We need short circuit evaluation, so first_non_none(a, b, c) does not work here
# with PEP 505 we can simply write a ?? b ?? c
def _option_layers() -> Generator[Optional[bool], None, None]:
yield explicit
yield parse_boolean_state(os.getenv('POSEUR_QUIET'))
yield _default_quiet
return first_non_none(_option_layers())
[docs]def _get_concurrency_option(explicit: Optional[int] = None) -> Optional[int]:
"""Get the value for the ``concurrency`` option.
Args:
explicit (Optional[int]): the value explicitly specified by user,
:data:`None` if not specified
Returns:
Optional[int]: the value for the ``concurrency`` option;
:data:`None` means *auto detection* at runtime
:Environment Variables:
:envvar:`POSEUR_CONCURRENCY` -- the value in environment variable
See Also:
:data:`_default_concurrency`
"""
return parse_positive_integer(explicit or os.getenv('POSEUR_CONCURRENCY') or _default_concurrency)
[docs]def _get_do_archive_option(explicit: Optional[bool] = None) -> Optional[bool]:
"""Get the value for the ``do_archive`` option.
Args:
explicit (Optional[bool]): the value explicitly specified by user,
:data:`None` if not specified
Returns:
bool: the value for the ``do_archive`` option
:Environment Variables:
:envvar:`POSEUR_DO_ARCHIVE` -- the value in environment variable
See Also:
:data:`_default_do_archive`
"""
def _option_layers() -> Generator[Optional[bool], None, None]:
yield explicit
yield parse_boolean_state(os.getenv('POSEUR_DO_ARCHIVE'))
yield _default_do_archive
return first_non_none(_option_layers())
[docs]def _get_archive_path_option(explicit: Optional[str] = None) -> str:
"""Get the value for the ``archive_path`` option.
Args:
explicit (Optional[str]): the value explicitly specified by user,
:data:`None` if not specified
Returns:
str: the value for the ``archive_path`` option
:Environment Variables:
:envvar:`POSEUR_ARCHIVE_PATH` -- the value in environment variable
See Also:
:data:`_default_archive_path`
"""
return explicit or os.getenv('POSEUR_ARCHIVE_PATH') or _default_archive_path
[docs]def _get_source_version_option(explicit: Optional[str] = None) -> Optional[str]:
"""Get the value for the ``source_version`` option.
Args:
explicit (Optional[str]): the value explicitly specified by user,
:data:`None` if not specified
Returns:
str: the value for the ``source_version`` option
:Environment Variables:
:envvar:`POSEUR_SOURCE_VERSION` -- the value in environment variable
See Also:
:data:`_default_source_version`
"""
return explicit or os.getenv('POSEUR_SOURCE_VERSION') or _default_source_version
[docs]def _get_linesep_option(explicit: Optional[str] = None) -> Optional[Linesep]:
r"""Get the value for the ``linesep`` option.
Args:
explicit (Optional[str]): the value explicitly specified by user,
:data:`None` if not specified
Returns:
Optional[Literal['\\n', '\\r\\n', '\\r']]: the value for the ``linesep`` option;
:data:`None` means *auto detection* at runtime
:Environment Variables:
:envvar:`POSEUR_LINESEP` -- the value in environment variable
See Also:
:data:`_default_linesep`
"""
return parse_linesep(explicit or os.getenv('POSEUR_LINESEP') or _default_linesep)
[docs]def _get_indentation_option(explicit: Optional[Union[str, int]] = None) -> Optional[str]:
"""Get the value for the ``indentation`` option.
Args:
explicit (Optional[Union[str, int]]): the value explicitly specified by user,
:data:`None` if not specified
Returns:
Optional[str]: the value for the ``indentation`` option;
:data:`None` means *auto detection* at runtime
:Environment Variables:
:envvar:`POSEUR_INDENTATION` -- the value in environment variable
See Also:
:data:`_default_indentation`
"""
return parse_indentation(explicit or os.getenv('POSEUR_INDENTATION') or _default_indentation)
[docs]def _get_pep8_option(explicit: Optional[bool] = None) -> Optional[bool]:
"""Get the value for the ``pep8`` option.
Args:
explicit (Optional[bool]): the value explicitly specified by user,
:data:`None` if not specified
Returns:
bool: the value for the ``pep8`` option
:Environment Variables:
:envvar:`POSEUR_PEP8` -- the value in environment variable
See Also:
:data:`_default_pep8`
"""
def _option_layers() -> Generator[Optional[bool], None, None]:
yield explicit
yield parse_boolean_state(os.getenv('POSEUR_PEP8'))
yield _default_pep8
return first_non_none(_option_layers())
def _get_dismiss_option(explicit: Optional[bool] = None) -> Optional[bool]:
"""Get the value for the ``dismiss-runtime`` option.
Args:
explicit (Optional[bool]): the value explicitly specified by user,
:data:`None` if not specified
Returns:
bool: the value for the ``dismiss-runtime`` option
:Environment Variables:
:envvar:`POSEUR_DISMISS` -- the value in environment variable
See Also:
:data:`_default_dismiss`
"""
def _option_layers() -> Generator[Optional[bool], None, None]:
yield explicit
yield parse_boolean_state(os.getenv('POSEUR_DISMISS'))
yield _default_dismiss
return first_non_none(_option_layers())
def _get_decorator_option(explicit: Optional[str] = None) -> Optional[str]:
"""Get the value for the ``decorator`` option.
Args:
explicit (Optional[str]): the value explicitly specified by user,
:data:`None` if not specified
Returns:
str: the value for the ``decorator`` option
:Environment Variables:
:envvar:`POSEUR_DECORATOR` -- the value in environment variable
See Also:
:data:`_default_decorator`
"""
return explicit or os.getenv('POSEUR_DECORATOR') or _default_decorator
###############################################################################
# Traceback Trimming (tbtrim)
# root path
ROOT = pathlib.Path(__file__).resolve().parent
def predicate(filename: str) -> bool:
return pathlib.Path(filename).parent == ROOT
tbtrim.set_trim_rule(predicate, strict=True, target=BPCSyntaxError)
###############################################################################
# Main convertion implementations
# cf. https://mail.python.org/pipermail/python-ideas/2017-February/044888.html
DECORATOR_TEMPLATE = '''\
def %(decorator)s(*poseur):
%(indentation)s"""Positional-only parameters runtime checker.
%(indentation)s
%(indentation)s Args:
%(indentation)s *poseur: Name list of positional-only parameters.
%(indentation)s
%(indentation)s Raises:
%(indentation)s TypeError: If any position-only parameters were passed as
%(indentation)s keyword parameters.
%(indentation)s
%(indentation)s The decorator function may decorate regular :term:`function` and/or
%(indentation)s :term:`lambda` function to provide runtime checks on the original
%(indentation)s positional-only parameters.
%(indentation)s
%(indentation)s"""
%(indentation)simport functools
%(indentation)sdef caller(func):
%(indentation)s%(indentation)s@functools.wraps(func)
%(indentation)s%(indentation)sdef wrapper(*args, **kwargs):
%(indentation)s%(indentation)s%(indentation)sposeur_args = set(poseur).intersection(kwargs)
%(indentation)s%(indentation)s%(indentation)sif poseur_args:
%(indentation)s%(indentation)s%(indentation)s%(indentation)sraise TypeError('%%s() got some positional-only arguments passed as keyword arguments: %%r' %% (func.__name__, ', '.join(poseur_args)))
%(indentation)s%(indentation)s%(indentation)sreturn func(*args, **kwargs)
%(indentation)s%(indentation)sreturn wrapper
%(indentation)sreturn caller
'''.splitlines() # `str.splitlines` will remove trailing newline
[docs]class Context(BaseContext):
"""General conversion context.
Args:
node (parso.tree.NodeOrLeaf): parso AST
config (Config): conversion configurations
Keyword Args:
clx_ctx (Optional[str]): class context name
indent_level (int): current indentation level
raw (bool): raw processing flag
Important:
``raw`` should be :data:`True` only if the ``node`` is in the clause of another *context*,
where the converted wrapper functions should be inserted.
For the :class:`Context` class of :mod:`poseur` module,
it will process nodes with following methods:
* :token:`suite`
- :meth:`Context._process_suite_node`
* :token:`funcdef`
- :meth:`Context._process_funcdef`
* :token:`lambdef`
- :meth:`Context._process_lambdef`
* :token:`async_funcdef`
- :meth:`Context._process_async_funcdef`
* :token:`async_stmt`
- :meth:`Context._process_async_stmt`
* :token:`classdef`
- :meth:`Context._process_classdef`
* :token:`if_stmt`
- :meth:`Context._process_if_stmt`
* :token:`while_stmt`
- :meth:`Context._process_while_stmt`
* :token:`for_stmt`
- :meth:`Context._process_for_stmt`
* :token:`with_stmt`
- :meth:`Context._process_with_stmt`
* :token:`try_stmt`
- :meth:`Context._process_try_stmt`
* :token:`stringliteral`
* :meth:`Context._process_strings`
* :meth:`Context._process_string_context`
* :token:`f_string`
* :meth:`Context._process_fstring`
"""
#: re.Pattern: Pattern to find the function definition line.
pattern_funcdef = re.compile(r'^\s*(async\s+)?def\s', re.ASCII)
@final
@property
def decorator(self) -> str:
"""Name of the ``poseur`` decorator.
:rtype: str
"""
return self._decorator
def __init__(self, node: parso.tree.NodeOrLeaf, config: PoseurConfig, *,
clx_ctx: Optional[str] = None, indent_level: int = 0, raw: bool = False):
#: bool: Dismiss runtime checks for positional-only parameters.
self._dismiss = config.dismiss # type: bool
#: str: Decorator name.
self._decorator = config.decorator # type: str
#: Optional[str]: Class context name. This is rather useful as
#: we might need to *mangle* and/or *normalize* variable names
#: in certain scenario.
self._cls_ctx = clx_ctx # type: Optional[str]
super().__init__(node, config, indent_level=indent_level, raw=raw)
[docs] def _process_suite_node(self, node: parso.tree.NodeOrLeaf, *, cls_ctx: Optional[str] = None) -> None:
"""Process indented suite (:token:`suite` or others).
Args:
node (parso.tree.NodeOrLeaf): suite node
Keyword Args:
cls_ctx (Optional[str]): class context name
This method first checks if ``node`` contains positional-only parameters.
If not, it will not perform any processing, rather just append the
original source code to context buffer.
If ``node`` contains positional-only parameters, then it will initiate
another Context` instance to perform the conversion process on such
``node``.
"""
if not self.has_expr(node):
self += node.get_code()
return
indent = self._indent_level + 1
self += self._linesep + self._indentation * indent
if cls_ctx is None:
cls_ctx = self._cls_ctx
# initialize new context
ctx = Context(node=node, config=self.config, clx_ctx=cls_ctx, # type: ignore[arg-type]
indent_level=indent, raw=True)
self += ctx.string.lstrip()
[docs] def _process_funcdef(self, node: parso.python.tree.Function, *,
async_ctx: Optional[parso.python.tree.Keyword] = None) -> None:
"""Process function definition (:token:`funcdef`).
Args:
node (parso.python.tree.Function): function node
Keyword Args:
async_ctx (Optional[parso.python.tree.Keyword]): ``async`` keyword AST node
"""
if not self.has_expr(node):
self += node.get_code()
return
posonly = [] # positional-only parameters
funcdef = '' if async_ctx is None else async_ctx.get_code()
# 'def' NAME '(' PARAM ')' [ '->' NAME ] ':' SUITE
for child in node.children[:-1]:
if child.type == 'parameters':
# <Operator: (>
funcdef += child.children[0].get_code()
parameters = ''
param_list = [] # type: List[parso.python.tree.Param]
for grandchild in child.children[1:-1]:
# <Operator: />
if grandchild.type == 'operator' and grandchild.value == '/':
parameters += grandchild.get_code().replace('/', '')
posonly.extend(param_list)
continue
# <Param: ...>
if grandchild.type == 'param':
param_list.append(grandchild)
if grandchild.default is not None:
# initiate new context
ctx = Context(grandchild, self.config, raw=True, # type: ignore[arg-type]
indent_level=self._indent_level)
parameters += ctx.string
continue
# <Param: ...> / <Operator: *> / <Operator: ,>
parameters += grandchild.get_code()
if self._pep8:
funcdef += ', '.join(filter(None, map(lambda s: s.strip(), parameters.split(','))))
else:
funcdef += ','.join(filter(lambda s: s.strip(), parameters.split(',')))
# <Operator: )>
funcdef += child.children[-1].get_code()
continue
funcdef += child.get_code()
# decorate the function
if not self._dismiss and posonly:
prefix = ''
suffix = ''
deflag = False # function definition line
for line in funcdef.splitlines(True):
if self.pattern_funcdef.match(line) is not None:
deflag = True
if deflag:
suffix += line
else:
prefix += line
posonly_args = ', '.join(map(lambda param: repr(self.normalizer(param.name.value)), posonly))
indentation = self._indentation * self._indent_level
self += ('%(prefix)s'
'%(indentation)s@%(decorator)s(%(posonly)s)%(linesep)s'
'%(suffix)s') % dict(
prefix=prefix, suffix=suffix,
linesep=self._linesep, indentation=indentation,
decorator=self._decorator, posonly=posonly_args,
)
else:
self += funcdef
# SUITE
self._process_suite_node(node.children[-1])
[docs] def _process_async_stmt(self, node: parso.python.tree.PythonNode) -> None:
"""Process ``async`` statement (:token:`async_stmt`).
Args:
node (parso.python.tree.PythonNode): ``async`` statement node
This method processes an ``async`` statement node. If such statement is an
*async* :term:`function`, then it will pass on the processing to
:meth:`self._process_funcdef <Context._process_funcdef>`.
"""
child_1st = node.children[0]
child_2nd = node.children[1]
flag_1st = child_1st.type == 'keyword' and child_1st.value == 'async'
flag_2nd = child_2nd.type == 'funcdef'
if flag_1st and flag_2nd:
self._process_funcdef(child_2nd, async_ctx=child_1st)
return
self._process(child_1st)
self._process(child_2nd)
[docs] def _process_async_funcdef(self, node: parso.python.tree.PythonNode) -> None:
"""Process ``async`` function definition (:token:`async_funcdef`).
Args:
node (parso.python.tree.PythonNode): ``async`` function node
This method processes an ``async`` function node. It will extract
the ``async`` keyword node (:class:`parso.python.tree.Keyword`)
and the :term:`function` node (:class:`parso.python.tree.Function`)
then pass on the processing to
:meth:`self._process_funcdef <Context._process_funcdef>`.
"""
async_ctx, funcdef = node.children
self._process_funcdef(funcdef, async_ctx=async_ctx)
[docs] def _process_lambdef(self, node: parso.python.tree.Lambda) -> None:
"""Process lambda definition (:token:`lambdef`).
Args:
node (parso.python.tree.Lambda): lambda node
"""
if not self.has_expr(node):
self += node.get_code()
return
pos_only = []
children = iter(node.children)
# string buffers
params = ''
prefix = ''
suffix = ''
# <Keyword: lambda>
prefix += next(children).get_code()
# vararglist
param_list = [] # type: List[parso.python.tree.Param]
for child in children:
if child.type == 'operator':
# <Operator: />
if child.value == '/':
params += child.get_code().replace('/', '')
pos_only.extend(param_list)
continue
# <Operator: :>
if child.value == ':':
suffix += child.get_code()
break
# <Param: ...>
if child.type == 'param':
param_list.append(child)
if child.default is not None:
# initialize new context
ctx = Context(node=child, config=self.config, # type: ignore[arg-type]
indent_level=self._indent_level, raw=True)
params += ctx.string
continue
# <Param: ...> / <Operator: *> / <Operator: ,>
params += child.get_code()
# test_nocond | test
ctx = Context(node=next(children), config=self.config, # type: ignore[arg-type]
indent_level=self._indent_level, raw=True)
suffix += ctx.string
whitespace_prefix, whitespace_suffix = self.extract_whitespaces(params)
if self._pep8:
params = ', '.join(filter(None, map(lambda s: s.strip(), params.split(','))))
else:
params = ','.join(filter(lambda s: s.strip(), params.split(',')))
lambdef = prefix + whitespace_prefix + params.strip() + suffix.lstrip()
if self._dismiss or not pos_only:
self += lambdef
return
# decorate lambda definition
whitespace_prefix, whitespace_suffix = self.extract_whitespaces(lambdef)
posonly_args = ', '.join(map(lambda param: repr(self.normalizer(param.name.value).strip()), pos_only))
self += ('%(prefix)s'
'%(decorator)s(%(posonly)s)'
'(%(lambdef)s)'
'%(suffix)s') % dict(
prefix=whitespace_prefix, suffix=whitespace_suffix,
lambdef=lambdef.strip(),
decorator=self._decorator, posonly=posonly_args,
)
[docs] def _process_string_context(self, node: parso.python.tree.PythonNode) -> None:
"""Process string contexts (:token:`stringliteral`).
Args:
node (parso.python.tree.PythonNode): string literals node
This method first checks if ``node`` contains position-only parameters.
If not, it will not perform any processing, rather just append the
original source code to context buffer. Later it will check if
``node`` contains *debug f-string*. If not, it will process the
*regular* processing on each child of such ``node``.
See Also:
The method calls :meth:`f2format.Context.has_debug_fstring`
to detect *debug f-strings*.
Otherwise, it will initiate a new :class:`StringContext` instance
to perform the conversion process on such ``node``, which will first
use :mod:`f2format` to convert those formatted string literals.
Important:
When initialisation, ``raw`` parameter **must** be set to :data:`True`;
as the converted wrapper functions should be inserted in the *outer*
context, rather than the new :class:`StringContext` instance.
"""
if not self.has_expr(node):
self += node.get_code()
return
if not f2format.Context.has_debug_fstring(node):
for child in node.children:
self._process(child)
return
# initiate new context
ctx = StringContext(node=node, config=self.config, # type: ignore[arg-type]
indent_level=self._indent_level, raw=True)
self += ctx.string
[docs] def _process_classdef(self, node: parso.python.tree.Class) -> None:
"""Process class definition (:token:`classdef`).
Args:
node (parso.python.tree.Class): class node
This method converts the whole class suite context with
:meth:`~Context._process_suite_node` through :class:`Context`
respectively.
"""
# <Name: ...>
name = node.name
# <Keyword: class>
# <Name: ...>
# [<Operator: (>, PythonNode(arglist, [...]]), <Operator: )>]
# <Operator: :>
for child in node.children[:-1]:
self._process(child)
# PythonNode(suite, [...]) / PythonNode(simple_stmt, [...])
suite = node.children[-1]
self._process_suite_node(suite, cls_ctx=name.name)
[docs] def _process_if_stmt(self, node: parso.python.tree.IfStmt) -> None:
"""Process if statement (:token:`if_stmt`).
Args:
node (parso.python.tree.IfStmt): if node
This method processes each indented suite under the *if*, *elif*,
and *else* statements.
"""
children = iter(node.children)
# <Keyword: if>
self._process(next(children))
# namedexpr_test
self._process(next(children))
# <Operator: :>
self._process(next(children))
# suite
self._process_suite_node(next(children))
while True:
try:
# <Keyword: elif | else>
key = next(children)
except StopIteration:
break
self._process(key)
if key.value == 'elif':
# namedexpr_test
self._process(next(children))
# <Operator: :>
self._process(next(children))
# suite
self._process_suite_node(next(children))
continue
if key.value == 'else':
# <Operator: :>
self._process(next(children))
# suite
self._process_suite_node(next(children))
continue
[docs] def _process_while_stmt(self, node: parso.python.tree.WhileStmt) -> None:
"""Process while statement (:token:`while_stmt`).
Args:
node (parso.python.tree.WhileStmt): while node
This method processes the indented suite under the *while* and optional
*else* statements.
"""
children = iter(node.children)
# <Keyword: while>
self._process(next(children))
# namedexpr_test
self._process(next(children))
# <Operator: :>
self._process(next(children))
# suite
self._process_suite_node(next(children))
try:
key = next(children)
except StopIteration:
return
# <Keyword: else>
self._process(key)
# <Operator: :>
self._process(next(children))
# suite
self._process_suite_node(next(children))
[docs] def _process_for_stmt(self, node: parso.python.tree.ForStmt) -> None:
"""Process for statement (:token:`for_stmt`).
Args:
node (parso.python.tree.ForStmt): for node
This method processes the indented suite under the *for* and optional
*else* statements.
"""
children = iter(node.children)
# <Keyword: for>
self._process(next(children))
# exprlist
self._process(next(children))
# <Keyword: in>
self._process(next(children))
# testlist
self._process(next(children))
# <Operator: :>
self._process(next(children))
# suite
self._process_suite_node(next(children))
try:
key = next(children)
except StopIteration:
return
# <Keyword: else>
self._process(key)
# <Operator: :>
self._process(next(children))
# suite
self._process_suite_node(next(children))
[docs] def _process_with_stmt(self, node: parso.python.tree.WithStmt) -> None:
"""Process with statement (:token:`with_stmt`).
Args:
node (parso.python.tree.WithStmt): with node
This method processes the indented suite under the *with* statement.
"""
children = iter(node.children)
# <Keyword: with>
self._process(next(children))
while True:
# with_item | <Operator: ,>
item = next(children)
self._process(item)
# <Operator: :>
if item.type == 'operator' and item.value == ':':
break
# suite
self._process_suite_node(next(children))
[docs] def _process_try_stmt(self, node: parso.python.tree.TryStmt) -> None:
"""Process try statement (:token:`try_stmt`).
Args:
node (parso.python.tree.TryStmt): try node
This method processes the indented suite under the *try*, *except*,
*else*, and *finally* statements.
"""
children = iter(node.children)
while True:
try:
key = next(children)
except StopIteration:
break
# <Keyword: try | else | finally> | PythonNode(except_clause, [...]
self._process(key)
# <Operator: :>
self._process(next(children))
# suite
self._process_suite_node(next(children))
[docs] def _process_strings(self, node: parso.python.tree.PythonNode) -> None:
"""Process concatenable strings (:token:`stringliteral`).
Args:
node (parso.python.tree.PythonNode): concatentable strings node
As in Python, adjacent string literals can be concatenated in certain
cases, as described in the `documentation`_. Such concatenable strings
may contain formatted string literals (:term:`f-string`) within its scope.
.. _documentation: https://docs.python.org/3/reference/lexical_analysis.html#string-literal-concatenation
"""
self._process_string_context(node)
[docs] def _process_fstring(self, node: parso.python.tree.PythonNode) -> None:
"""Process formatted strings (:token:`f_string`).
Args:
node (parso.python.tree.PythonNode): formatted strings node
"""
self._process_string_context(node)
[docs] def _concat(self) -> None:
"""Concatenate final string.
This method tries to inserted the runtime check decorator function
at the very location where starts to contain positional-only parameters, i.e.
between the converted code as :attr:`self._prefix <Context._prefix>` and
:attr:`self._suffix <Context._suffix>`.
The inserted code is rendered from :data:`DECORATOR_TEMPLATE`. If
:attr:`self._pep8 <Context._pep8>` is :data:`True`, it will insert the code
in compliance with :pep:`8`.
"""
if self._dismiss or not self.has_expr(self._root):
self._buffer += self._prefix + self._suffix
return
# strip suffix comments
prefix, suffix = self.split_comments(self._suffix, self._linesep)
match = re.match(r'^(?P<linesep>(%s)*)' % self._linesep, suffix, flags=re.ASCII)
suffix_linesep = match.group('linesep') if match is not None else ''
# first, the prefix code
self._buffer += self._prefix + prefix + suffix_linesep
if self._pep8 and self._buffer:
if (self._node_before_expr is not None
and self._node_before_expr.type in ('funcdef', 'classdef')
and self._indent_level == 0):
blank = 2
else:
blank = 1
self._buffer += self._linesep * self.missing_newlines(prefix=self._buffer, suffix='',
expected=blank, linesep=self._linesep)
# then, the decorator function
self._buffer += self._linesep.join(DECORATOR_TEMPLATE) % dict(
decorator=self._decorator,
indentation=self._indentation,
) + self._linesep
# finally, the suffix code
if self._pep8:
self._buffer += self._linesep * self.missing_newlines(prefix=self._buffer, suffix='',
expected=2, linesep=self._linesep)
self._buffer += suffix.lstrip(self._linesep)
[docs] @final
@classmethod
def has_expr(cls, node: parso.tree.NodeOrLeaf) -> bool:
"""Check if node has positional-only parameters.
Args:
node (parso.tree.NodeOrLeaf): parso AST
Returns:
bool: if ``node`` has positional-only parameters
"""
if node.type == 'funcdef':
return cls._check_funcdef(node) # type: ignore[arg-type]
if node.type == 'lambdef':
return cls._check_lambdef(node) # type: ignore[arg-type]
if hasattr(node, 'children'):
return any(map(cls.has_expr, node.children)) # type: ignore[attr-defined]
return False
# backward compatibility and auxiliary alias
has_poseur = has_expr
[docs] @final
@classmethod
def _check_funcdef(cls, node: parso.python.tree.Function) -> bool:
"""Check if :term:`function` definition contains positional-only parameters.
Args:
node (parso.python.tree.Function): function definition
Returns:
bool: if :term:`function` definition contains positional-only parameters
"""
for child in node.children:
if child.type == 'parameters':
for param in child.children[1:-1]:
if param.type == 'operator':
if param.value == '/':
return True
continue
if param.default is not None and cls.has_expr(param.default):
return True
elif cls.has_expr(child): # suite / ...
return True
return False
[docs] @final
@classmethod
def _check_lambdef(cls, node: parso.python.tree.Lambda) -> bool:
"""Check if :term:`lambda` definition contains positional-only parameters.
Args:
node (parso.python.tree.Lambda): lambda definition
Returns:
bool: if :term:`lambda` definition contains positional-only parameters
"""
param = False
suite = False
for child in node.children:
if child.type == 'param':
if child.default is not None:
if cls.has_expr(child.default):
return True
param = True
elif child.type == 'operator' and child.value == ':':
param = False
suite = True
elif param and child.type == 'operator' and child.value == '/':
return True
elif suite and cls.has_expr(child):
return True
return False
[docs] @final
def normalizer(self, name: str) -> str:
"""Variable name normalizer.
If :attr:`self._cls_ctx <poseur.Context._cls_ctx>` is :data:`None`,
the methold will simply class :meth:`~bpc_utils.context.BaseContext.normalize`
on the variable name; otherwise, it will call
:meth:`~bpc_utils.context.BaseContext.mangle` on the variable name.
Args:
name (str): variable name
Returns:
str: *normalized* variable name
"""
if self._cls_ctx is None:
return self.normalize(name)
return self.mangle(self._cls_ctx, name)
[docs]class StringContext(Context):
"""String (f-string) conversion context.
This class is mainly used for converting **formatted strings**.
Args:
node (parso.python.tree.PythonNode): parso AST
config (Config): conversion configurations
Keyword Args:
clx_ctx (Optional[str]): class context name
indent_level (int): current indentation level
raw (Literal[True]): raw processing flag
Note:
* ``raw`` should always be :data:`True`.
As the conversion in :class:`Context` changes the original expression,
which may change the content of *debug f-string*.
"""
def __init__(self, node: parso.python.tree.PythonNode, config: PoseurConfig, *,
clx_ctx: Optional[str] = None, indent_level: int = 0, raw: Literal[True] = True):
# convert using f2format first
prefix, suffix = self.extract_whitespaces(node.get_code())
code = f2format.convert(node.get_code().strip())
node = parso_parse(code, filename=config.filename, version=config.source_version)
# call super init
super().__init__(node=node, config=config, clx_ctx=clx_ctx,
indent_level=indent_level, raw=raw)
self._buffer = prefix + self._buffer + suffix
###############################################################################
# Public Interface
exec(os.linesep.join(DECORATOR_TEMPLATE) % dict(decorator='decorator', indentation=' ')) # nosec: B102; pylint: disable=exec-used
[docs]def convert(code: Union[str, bytes], filename: Optional[str] = None, *,
source_version: Optional[str] = None, linesep: Optional[Linesep] = None,
indentation: Optional[Union[int, str]] = None, pep8: Optional[bool] = None,
dismiss: Optional[bool] = None, decorator: Optional[str] = None) -> str:
"""Convert the given Python source code string.
Args:
code (Union[str, bytes]): the source code to be converted
filename (Optional[str]): an optional source file name to provide a context in case of error
Keyword Args:
source_version (Optional[str]): parse the code as this Python version (uses the latest version by default)
linesep (Optional[str]): line separator of code (``LF``, ``CRLF``, ``CR``) (auto detect by default)
indentation (Optional[Union[int, str]]): code indentation style, specify an integer for the number of spaces,
or ``'t'``/``'tab'`` for tabs (auto detect by default)
pep8 (Optional[bool]): whether to make code insertion :pep:`8` compliant
:Environment Variables:
- :envvar:`POSEUR_SOURCE_VERSION` -- same as the ``source_version`` argument and the ``--source-version`` option
in CLI
- :envvar:`POSEUR_LINESEP` -- same as the `linesep` `argument` and the ``--linesep`` option in CLI
- :envvar:`POSEUR_INDENTATION` -- same as the ``indentation`` argument and the ``--indentation`` option in CLI
- :envvar:`POSEUR_PEP8` -- same as the ``pep8`` argument and the ``--no-pep8`` option in CLI (logical negation)
- :envvar:`POSEUR_DISMISS` -- same as the ``--dismiss-runtime`` option in CLI
- :envvar:`POSEUR_DECORATOR` -- same as the ``--decorator-name`` option in CLI
Returns:
str: converted source code
Raises:
ValueError: if ``decorator`` is not a valid identifier name or starts with double underscore
"""
# parse source string
source_version = _get_source_version_option(source_version)
module = parso_parse(code, filename=filename, version=source_version)
# get linesep, indentation and pep8 options
linesep = _get_linesep_option(linesep)
indentation = _get_indentation_option(indentation)
if linesep is None:
linesep = detect_linesep(code)
if indentation is None:
indentation = detect_indentation(code)
pep8 = _get_pep8_option(pep8)
dismiss = _get_dismiss_option(dismiss)
decorator = _get_decorator_option(decorator)
# validate that decorator name is valid identifier
if not decorator.isidentifier():
raise ValueError('name of decorator for runtime checks is not a valid identifier name: %r' % decorator)
# prevent using class-private names and dunder names
if decorator.startswith('__'):
raise ValueError('name of decorator for runtime checks should not start with double underscore')
# pack conversion configuration
config = Config(linesep=linesep, indentation=indentation, pep8=pep8,
filename=filename, source_version=source_version,
dismiss=dismiss, decorator=decorator)
# convert source string
result = Context(module, config).string # type: ignore[arg-type]
# return conversion result
return result
[docs]def poseur(filename: str, *, source_version: Optional[str] = None, linesep: Optional[Linesep] = None,
indentation: Optional[Union[int, str]] = None, pep8: Optional[bool] = None,
dismiss: Optional[bool] = None, decorator: Optional[str] = None,
quiet: Optional[bool] = None, dry_run: bool = False) -> None:
"""Convert the given Python source code file. The file will be overwritten.
Args:
filename (str): the file to convert
Keyword Args:
source_version (Optional[str]): parse the code as this Python version (uses the latest version by default)
linesep (Optional[str]): line separator of code (``LF``, ``CRLF``, ``CR``) (auto detect by default)
indentation (Optional[Union[int, str]]): code indentation style, specify an integer for the number of spaces,
or ``'t'``/``'tab'`` for tabs (auto detect by default)
pep8 (Optional[bool]): whether to make code insertion :pep:`8` compliant
quiet (Optional[bool]): whether to run in quiet mode
dry_run (bool): if :data:`True`, only print the name of the file to convert but do not perform any conversion
:Environment Variables:
- :envvar:`POSEUR_SOURCE_VERSION` -- same as the ``source-version`` argument and the ``--source-version`` option
in CLI
- :envvar:`POSEUR_LINESEP` -- same as the ``linesep`` argument and the ``--linesep`` option in CLI
- :envvar:`POSEUR_INDENTATION` -- same as the ``indentation`` argument and the ``--indentation`` option in CLI
- :envvar:`POSEUR_PEP8` -- same as the ``pep8`` argument and the ``--no-pep8`` option in CLI (logical negation)
- :envvar:`POSEUR_QUIET` -- same as the ``quiet`` argument and the ``--quiet`` option in CLI
- :envvar:`POSEUR_DISMISS` -- same as the ``--dismiss-runtime`` option in CLI
- :envvar:`POSEUR_DECORATOR` -- same as the ``--decorator-name`` option in CLI
"""
quiet = _get_quiet_option(quiet)
if not quiet:
with TaskLock():
print('Now converting: %r' % filename, file=sys.stderr)
if dry_run:
return
# read file content
with open(filename, 'rb') as file:
content = file.read()
# detect source code encoding
encoding = detect_encoding(content)
# get linesep and indentation
linesep = _get_linesep_option(linesep)
indentation = _get_indentation_option(indentation)
if linesep is None or indentation is None:
with open(filename, 'r', encoding=encoding) as file:
if linesep is None:
linesep = detect_linesep(file)
if indentation is None:
indentation = detect_indentation(file)
# do the dirty things
result = convert(content, filename=filename, source_version=source_version,
linesep=linesep, indentation=indentation, pep8=pep8,
dismiss=dismiss, decorator=decorator)
# overwrite the file with conversion result
with open(filename, 'w', encoding=encoding, newline='') as file:
file.write(result)
###############################################################################
# CLI & Entry Point
# option values display
# these values are only intended for argparse help messages
# this shows default values by default, environment variables may override them
__cwd__ = os.getcwd()
__poseur_quiet__ = 'quiet mode' if _get_quiet_option() else 'non-quiet mode'
__poseur_concurrency__ = _get_concurrency_option() or 'auto detect'
__poseur_do_archive__ = 'will do archive' if _get_do_archive_option() else 'will not do archive'
__poseur_archive_path__ = os.path.join(__cwd__, _get_archive_path_option())
__poseur_source_version__ = _get_source_version_option()
__poseur_linesep__ = {
'\n': 'LF',
'\r\n': 'CRLF',
'\r': 'CR',
None: 'auto detect'
}[_get_linesep_option()]
__poseur_indentation__ = _get_indentation_option()
if __poseur_indentation__ is None:
__poseur_indentation__ = 'auto detect'
elif __poseur_indentation__ == '\t':
__poseur_indentation__ = 'tab'
else:
__poseur_indentation__ = '%d spaces' % len(__poseur_indentation__)
__poseur_pep8__ = 'will conform to PEP 8' if _get_pep8_option() else 'will not conform to PEP 8'
__poseur_dismiss__ = 'will dismiss runtime checks' if _get_dismiss_option() else 'will not dismiss runtime checks'
__poseur_decorator__ = _get_decorator_option() or '_poseur_decorator'
[docs]def get_parser() -> argparse.ArgumentParser:
"""Generate CLI parser.
Returns:
argparse.ArgumentParser: CLI parser for poseur
"""
parser = argparse.ArgumentParser(prog='poseur',
usage='poseur [options] <Python source files and directories...>',
description='Back-port compiler for Python 3.8 position-only parameters.')
parser.add_argument('-V', '--version', action='version', version=__version__)
parser.add_argument('-q', '--quiet', action='store_true', default=None,
help='run in quiet mode (current: %s)' % __poseur_quiet__)
parser.add_argument('-C', '--concurrency', action='store', type=int, metavar='N',
help='the number of concurrent processes for conversion (current: %s)' % __poseur_concurrency__)
parser.add_argument('--dry-run', action='store_true',
help='list the files to be converted without actually performing conversion and archiving')
parser.add_argument('-s', '--simple', action='store', nargs='?', dest='simple_args', const='', metavar='FILE',
help='this option tells the program to operate in "simple mode"; '
'if a file name is provided, the program will convert the file but print conversion '
'result to standard output instead of overwriting the file; '
'if no file names are provided, read code for conversion from standard input and print '
'conversion result to standard output; '
'in "simple mode", no file names shall be provided via positional arguments')
archive_group = parser.add_argument_group(title='archive options',
description="backup original files in case there're any issues")
archive_group.add_argument('-na', '--no-archive', action='store_false', dest='do_archive', default=None,
help='do not archive original files (current: %s)' % __poseur_do_archive__)
archive_group.add_argument('-k', '--archive-path', action='store', default=__poseur_archive_path__, metavar='PATH',
help='path to archive original files (current: %(default)s)')
archive_group.add_argument('-r', '--recover', action='store', dest='recover_file', metavar='ARCHIVE_FILE',
help='recover files from a given archive file')
archive_group.add_argument('-r2', action='store_true', help='remove the archive file after recovery')
archive_group.add_argument('-r3', action='store_true', help='remove the archive file after recovery, '
'and remove the archive directory if it becomes empty')
# TODO: revise ``--dismiss-runtime`` & ``--decorator-name`` options
convert_group = parser.add_argument_group(title='convert options', description='conversion configuration')
convert_group.add_argument('-vs', '-vf', '--source-version', '--from-version', action='store', metavar='VERSION',
default=__poseur_source_version__, choices=POSEUR_SOURCE_VERSIONS,
help='parse source code as this Python version (current: %(default)s)')
convert_group.add_argument('-l', '--linesep', action='store',
help='line separator (LF, CRLF, CR) to read '
'source files (current: %s)' % __poseur_linesep__)
convert_group.add_argument('-t', '--indentation', action='store', metavar='INDENT',
help='code indentation style, specify an integer for the number of spaces, '
"or 't'/'tab' for tabs (current: %s)" % __poseur_indentation__)
convert_group.add_argument('-n8', '--no-pep8', action='store_false', dest='pep8', default=None,
help='do not make code insertion PEP 8 compliant (current: %s)' % __poseur_pep8__)
convert_group.add_argument('-nr', '--dismiss-runtime', action='store_true', dest='dismiss', default=None,
help='dismiss runtime checks for positional-only parameters (current: %s)' % __poseur_dismiss__) # pylint: disable=line-too-long
convert_group.add_argument('-d', '--decorator-name', action='store', dest='decorator', metavar='NAME',
default=__poseur_decorator__, help='name of decorator for runtime checks (current: %s)' % __poseur_decorator__) # pylint: disable=line-too-long
parser.add_argument('files', action='store', nargs='*', metavar='<Python source files and directories...>',
help='Python source files and directories to be converted')
return parser
def do_poseur(filename: str, **kwargs: object) -> None:
"""Wrapper function to catch exceptions."""
try:
poseur(filename, **kwargs) # type: ignore[arg-type]
except Exception: # pylint: disable=broad-except
with TaskLock():
print('Failed to convert file: %r' % filename, file=sys.stderr)
traceback.print_exc()
[docs]def main(argv: Optional[List[str]] = None) -> int:
"""Entry point for poseur.
Args:
argv (Optional[List[str]]): CLI arguments
:Environment Variables:
- :envvar:`POSEUR_QUIET` -- same as the ``--quiet`` option in CLI
- :envvar:`POSEUR_CONCURRENCY` -- same as the ``--concurrency`` option in CLI
- :envvar:`POSEUR_DO_ARCHIVE` -- same as the ``--no-archive`` option in CLI (logical negation)
- :envvar:`POSEUR_ARCHIVE_PATH` -- same as the ``--archive-path`` option in CLI
- :envvar:`POSEUR_SOURCE_VERSION` -- same as the ``--source-version`` option in CLI
- :envvar:`POSEUR_LINESEP` -- same as the ``--linesep`` option in CLI
- :envvar:`POSEUR_INDENTATION` -- same as the ``--indentation`` option in CLI
- :envvar:`POSEUR_PEP8` -- same as the ``--no-pep8`` option in CLI (logical negation)
- :envvar:`POSEUR_DISMISS` -- same as the ``--dismiss-runtime`` option in CLI
- :envvar:`POSEUR_DECORATOR` -- same as the ``--decorator-name`` option in CLI
"""
parser = get_parser()
args = parser.parse_args(argv)
options = {
'source_version': args.source_version,
'linesep': args.linesep,
'indentation': args.indentation,
'pep8': args.pep8,
'dismiss': args.dismiss,
'decorator': args.decorator,
}
# check if running in simple mode
if args.simple_args is not None:
if args.files:
parser.error('no Python source files or directories shall be given as positional arguments in simple mode')
if not args.simple_args: # read from stdin
code = sys.stdin.read()
else: # read from file
filename = args.simple_args
options['filename'] = filename
with open(filename, 'rb') as file:
code = file.read()
sys.stdout.write(convert(code, **options)) # print conversion result to stdout
return 0
# get options
quiet = _get_quiet_option(args.quiet)
processes = _get_concurrency_option(args.concurrency)
do_archive = _get_do_archive_option(args.do_archive)
archive_path = _get_archive_path_option(args.archive_path)
# check if doing recovery
if args.recover_file:
recover_files(args.recover_file)
if not args.quiet:
print('Recovered files from archive: %r' % args.recover_file, file=sys.stderr)
# TODO: maybe implement deletion in bpc-utils?
if args.r2 or args.r3:
os.remove(args.recover_file)
if args.r3:
archive_dir = os.path.dirname(os.path.realpath(args.recover_file))
if not os.listdir(archive_dir):
os.rmdir(archive_dir)
return 0
# fetch file list
if not args.files:
parser.error('no Python source files or directories are given')
filelist = sorted(detect_files(args.files))
# terminate if no valid Python source files detected
if not filelist:
if not args.quiet:
# TODO: maybe use parser.error?
print('Warning: no valid Python source files found in %r' % args.files, file=sys.stderr)
return 1
# make archive
if do_archive and not args.dry_run:
archive_files(filelist, archive_path)
# process files
options.update({
'quiet': quiet,
'dry_run': args.dry_run,
})
map_tasks(do_poseur, filelist, kwargs=options, processes=processes)
return 0
if __name__ == '__main__':
sys.exit(main())