diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index de326a4..d1b0a8e 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -14,7 +14,7 @@ jobs: strategy: fail-fast: false matrix: - python-version: ["3.8", "3.9", "3.10", "3.11", "3.12"] + python-version: ["3.8", "3.9", "3.10", "3.11", "3.12", "3.13"] steps: diff --git a/LICENSE.txt b/LICENSE.txt index e7bbee5..13b501a 100644 --- a/LICENSE.txt +++ b/LICENSE.txt @@ -1,6 +1,6 @@ New BSD License -Copyright (c) 2015-2023, Kevin D. Wurster +Copyright (c) 2015-2024, Kevin D. Wurster All rights reserved. Redistribution and use in source and binary forms, with or without diff --git a/docs.rst b/docs.rst index 9eff15a..9e79386 100644 --- a/docs.rst +++ b/docs.rst @@ -160,7 +160,7 @@ A more complex example mixing directives, expressions, etc.: 'i[::2]' \ %stream '[" ".join(i) for i in s]' New License - Copyright 2015-2023, D. + Copyright 2015-2024, D. All reserved. is equivalent to the Python code: @@ -180,7 +180,7 @@ is equivalent to the Python code: ... i = i[::2] ... print(" ".join(i)) New License - Copyright 2015-2023, D. + Copyright 2015-2024, D. All reserved. Scope @@ -207,6 +207,19 @@ but can be investigated: This is admittedly very hard to read, but rebuilding the command one expression at a time should reveal what is happening. +Environment Variables +--------------------- + +``PYIN_FULL_TRACEBACK`` +^^^^^^^^^^^^^^^^^^^^^^^ + +``$ pyin`` carefully manages how exceptions are raised and presented to the +user to differentiate between problems with expressions, and problems with +``$ pyin`` itself. In some cases it is helpful to get a full traceback. + +The presence of this variable in the environment enables the feature regardless +of its value. + Directives ========== diff --git a/pyin.py b/pyin.py index b09f181..73a55ea 100644 --- a/pyin.py +++ b/pyin.py @@ -26,7 +26,7 @@ __license__ = ''' New BSD License -Copyright (c) 2015-2023, Kevin D. Wurster +Copyright (c) 2015-2024, Kevin D. Wurster All rights reserved. Redistribution and use in source and binary forms, with or without @@ -148,18 +148,16 @@ def compile( tokens = list(expressions) del expressions - # Check for empty strings in expressions. A check with 'all(tokens)' may - # be sufficient, but could be confused by '__bool__()'. - if not all(len(t) for t in tokens): - raise SyntaxError( - f"one or more expression is an empty string:" - f" {' '.join(map(repr, tokens))}") - while tokens: # Get a directive directive = tokens.pop(0) + if directive == '' or directive.isspace(): + raise SyntaxError( + f'expression is white space or empty: {repr(directive)}' + ) + # If it is not actually a directive just assume it is a Python # expression that should be evaluated. Stick the token back in the # queue so that it can be evaluated as an argument - makes the rest @@ -526,13 +524,7 @@ def compiled_expression(self, mode): """Compile a Python expression using the builtin ``compile()``.""" - try: - return builtins.compile(self.expression, '', mode) - except SyntaxError as e: - raise SyntaxError( - f"expression {repr(self.expression)} contains a syntax error:" - f" {e.text}" - ) + return builtins.compile(self.expression, '', mode) class OpEval(OpBaseExpression, directives=('%eval', '%stream', '%exec')): @@ -1055,6 +1047,26 @@ def _type_gen(value): raise argparse.ArgumentTypeError( 'cannot combine with piping data to stdin') + return _type_expression(value) + + +def _type_expression(value): + + """Validate a Python expression argument. + + Not comprehensive. Ultimately compiling the expression to a code object + is the only method for ensuring compliance. + """ + + if value.isspace(): + raise argparse.ArgumentTypeError( + 'expression is entirely white space' + ) + elif value == '': + raise argparse.ArgumentTypeError( + 'empty expression' + ) + return value @@ -1079,7 +1091,7 @@ def argparse_parser(): input_group = aparser.add_mutually_exclusive_group() input_group.add_argument( '--gen', - metavar='expression', + metavar='EXPR', dest='generate_expr', type=_type_gen, help="Execute this Python expression and feed results into other" @@ -1087,7 +1099,7 @@ def argparse_parser(): ) input_group.add_argument( '-i', '--infile', - metavar='path', + metavar='PATH', type=argparse.FileType('r'), default='-', help="Read input from this file. Use '-' for stdin (the default)." @@ -1095,25 +1107,26 @@ def argparse_parser(): aparser.add_argument( '-o', '--outfile', - metavar='path', + metavar='PATH', type=argparse.FileType('w'), default='-', help="Write to this file. Use '-' for stdout (the default)." ) aparser.add_argument( '--linesep', - metavar='string', + metavar='STR', default=os.linesep, help=f"Write this after every line. Defaults to: {repr(os.linesep)}." ) aparser.add_argument( - '-s', '--setup', action='append', + '-s', '--setup', action='append', metavar='EXPR', + type=_type_expression, help="Execute one or more Python statements to pre-initialize objects," " import objects with new names, etc." ) aparser.add_argument( '--variable', - metavar='string', + metavar='STR', type=_type_variable, default=_DEFAULT_VARIABLE, help="Place each input item in this variable when evaluating" @@ -1121,7 +1134,7 @@ def argparse_parser(): ) aparser.add_argument( '--stream-variable', - metavar='string', + metavar='STR', type=_type_variable, default=_DEFAULT_STREAM_VARIABLE, help="Place the stream in this variable when evaluating expressions" @@ -1130,7 +1143,8 @@ def argparse_parser(): aparser.add_argument( 'expressions', - metavar='expressions', + metavar='EXPR', + type=_type_expression, nargs='*', help='Python expression.' ) @@ -1226,14 +1240,8 @@ def main( # Probably possible to use 'OpEval(%exec)' here, but not immediately # clear how to manifest the scope changes. for statement in setup: - try: - code = builtins.compile(statement, '', 'exec') - except SyntaxError as e: - raise SyntaxError( - f"setup statement contains a syntax error:" - f" {e.text.strip()}" - ) - exec(code, scope, local_scope) + code_object = builtins.compile(statement, '', 'exec') + exec(code_object, scope, local_scope) scope.update(local_scope) del local_scope @@ -1319,6 +1327,20 @@ def _cli_entrypoint(rawargs=None): try: exit_code = main(**vars(args)) + except SyntaxError as e: + + exit_code = 1 + + # Reformat the exception information to provide clarity that this is + # something the user did wrong, and not something 'pyin' did wrong. + lines = [ + f'ERROR: expression contains a syntax error: {e.msg}', + '', + f' {e.text}', + f' {" " * (e.offset - 1)}^', + ] + print(os.linesep.join(lines), file=sys.stderr) + # User interrupted with '^C' most likely, but technically this is just # a SIGINT. Somehow this shows up in the coverage report generated by # '$ pytest --cov'. No idea how that works!! @@ -1326,17 +1348,19 @@ def _cli_entrypoint(rawargs=None): print() # Don't get a trailing newline otherwise exit_code = 128 + signal.SIGINT - # A 'RuntimeError()' indicates a problem that should have been caught - # during testing. We want a full traceback in these cases. - except RuntimeError: # pragma no cover - print(''.join(traceback.format_exc()).rstrip(), file=sys.stderr) - exit_code = 1 - - # Generic error reporting except Exception as e: - print("ERROR:", str(e), file=sys.stderr) + exit_code = 1 + # A 'RuntimeError()' indicates a problem that should have been caught + # during testing. We want a full traceback in these cases. + if 'PYIN_FULL_TRACEBACK' in os.environ or isinstance(e, RuntimeError): + message = ''.join(traceback.format_exc()).rstrip() + else: + message = f"ERROR: {str(e)}" + + print(message, file=sys.stderr) + # If the input and/or file points to a file descriptor and is not 'stdin', # close it. Avoids a Python warning about an unclosed resource. for attr in ('infile', 'outfile'): diff --git a/pyproject.toml b/pyproject.toml index 274113a..169a268 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -50,7 +50,7 @@ license-files = ["LICENSE.txt"] legacy_tox_ini = """ [tox] min_version = 4.0 - env_list = py{38,39,310,311,312} + env_list = py{38,39,310,311,312,313} [testenv] deps = diff --git a/tests/test_operations.py b/tests/test_operations.py index f362293..3cbd828 100644 --- a/tests/test_operations.py +++ b/tests/test_operations.py @@ -150,7 +150,7 @@ def test_simple_stream(directive, args, stream, expected): assert list(pyin.eval(expressions, [])) == [] -def test_Eval_syntax_error(): +def test_eval_syntax_error(): """Produce a helpful error when encountering :obj:`SyntaxError`. @@ -163,8 +163,8 @@ def test_Eval_syntax_error(): with pytest.raises(SyntaxError) as e: list(pyin.eval(expr, range(1))) - assert 'contains a syntax error' in str(e.value) - assert expr in str(e.value) + assert 'invalid syntax' in str(e.value) + assert expr == e.value.text def test_OpCSVDict(csv_with_header): diff --git a/tests/test_pyin.py b/tests/test_pyin.py index 34738c2..b293cdc 100644 --- a/tests/test_pyin.py +++ b/tests/test_pyin.py @@ -11,6 +11,7 @@ import sys import textwrap import time +from unittest import mock import pytest @@ -313,7 +314,6 @@ def test_setup_syntax_error(runner): """``SyntaxError`` in a setup statement.""" statement = '1 invalid syntax' - expected = f'ERROR: setup statement contains a syntax error: {statement}' result = runner.invoke(_cli_entrypoint, [ '--gen', 'range(1)', @@ -322,4 +322,52 @@ def test_setup_syntax_error(runner): assert result.exit_code == 1 assert not result.output - assert result.err == expected + os.linesep + assert 'expression contains a syntax error: invalid syntax' in result.err + assert statement in result.err + + +@mock.patch.dict(os.environ, {'PYIN_FULL_TRACEBACK': ''}) +def test_PYIN_FULL_TRACEBACK(runner): + + """Test the ``PYIN_FULL_TRACEBACK`` environment variable.""" + + result = runner.invoke(_cli_entrypoint, [ + '--gen', 'range(3)', + 'i + "TypeError"' + ]) + + assert result.exit_code == 1 + assert not result.output + assert len(result.err.splitlines()) > 10 + assert 'supported operand' in result.err + + +@pytest.mark.parametrize('expr, message', [ + ('', '{tag}: empty expression'), + (' ', '{tag}: expression is entirely white space') +]) +def test_expressions_white_space(expr, message, runner): + + """Ensure empty expressions cannot be passed to ``$ pyin``.""" + + ########################################################################### + # Test expressions + + result = runner.invoke(_cli_entrypoint, [ + '--gen', 'range(3)', expr, 'i' + ]) + + assert result.exit_code == 2 + assert not result.output + assert message.format(tag='EXPR') in result.err + + ########################################################################### + # Test --gen expressions + + result = runner.invoke(_cli_entrypoint, [ + '--gen', expr + ]) + + assert result.exit_code == 2 + assert not result.output + assert message.format(tag='--gen') in result.err