Skip to content

Commit

Permalink
Merge pull request #76 from geowurster/quality-of-life
Browse files Browse the repository at this point in the history
Quality of life improvements
  • Loading branch information
geowurster authored Nov 5, 2024
2 parents 7993674 + 0416146 commit 51701af
Show file tree
Hide file tree
Showing 7 changed files with 134 additions and 49 deletions.
2 changes: 1 addition & 1 deletion .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down
2 changes: 1 addition & 1 deletion LICENSE.txt
Original file line number Diff line number Diff line change
@@ -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
Expand Down
17 changes: 15 additions & 2 deletions docs.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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
Expand All @@ -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
==========

Expand Down
102 changes: 63 additions & 39 deletions pyin.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -526,13 +524,7 @@ def compiled_expression(self, mode):

"""Compile a Python expression using the builtin ``compile()``."""

try:
return builtins.compile(self.expression, '<string>', mode)
except SyntaxError as e:
raise SyntaxError(
f"expression {repr(self.expression)} contains a syntax error:"
f" {e.text}"
)
return builtins.compile(self.expression, '<string>', mode)


class OpEval(OpBaseExpression, directives=('%eval', '%stream', '%exec')):
Expand Down Expand Up @@ -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


Expand All @@ -1079,49 +1091,50 @@ 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"
" expressions."
)
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)."
)

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"
" expressions."
)
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"
Expand All @@ -1130,7 +1143,8 @@ def argparse_parser():

aparser.add_argument(
'expressions',
metavar='expressions',
metavar='EXPR',
type=_type_expression,
nargs='*',
help='Python expression.'
)
Expand Down Expand Up @@ -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, '<string>', '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, '<string>', 'exec')
exec(code_object, scope, local_scope)
scope.update(local_scope)

del local_scope
Expand Down Expand Up @@ -1319,24 +1327,40 @@ 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!!
except KeyboardInterrupt:
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'):
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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 =
Expand Down
6 changes: 3 additions & 3 deletions tests/test_operations.py
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand All @@ -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):
Expand Down
52 changes: 50 additions & 2 deletions tests/test_pyin.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
import sys
import textwrap
import time
from unittest import mock

import pytest

Expand Down Expand Up @@ -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)',
Expand All @@ -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

0 comments on commit 51701af

Please sign in to comment.