Repository: planetarypy/pvl

Issue:
PVL Exception class round up?
In the 1.0.0-alpha.2 architecture there are now three exception classes:
```
pv.decoder.QuantityError(Exception)
pvl.lexer.LexerError(ValueError)
pvl.parser.ParseError(Exception)
```
As you can see, they're in the module which typically throw them.  Some just descend from Exception, and one from ValueError.

I think we should probably round them up.  Maybe into a pvl.exception module, and craft a PVLError exception class from which they all descend?  Thoughts?

Discussion:

Patch:
diff --git a/HISTORY.rst b/HISTORY.rst
index 6a3f189..de9ec10 100644
--- a/HISTORY.rst
+++ b/HISTORY.rst
@@ -3,6 +3,14 @@
 History
 -------
 
+1.0.0-alpha.7 (2020-07-29)
+~~~~~~~~~~~~~~~~~~~~~~~~~~
+* Created a new exceptions.py module and grouped all pvl Exceptions
+  there.  Addresses #58
+* Altered the message that LexerError emits to provide context
+  around the character that caused the error.
+* Added bump2version configuration file.
+
 1.0.0-alpha.6 (2020-07-27)
 ~~~~~~~~~~~~~~~~~~~~~~~~~~
 * Enforced that all datetime.time and datetime.datetime objects
diff --git a/docs/parsing.rst b/docs/parsing.rst
index 7eea39a..dc3217b 100644
--- a/docs/parsing.rst
+++ b/docs/parsing.rst
@@ -238,7 +238,8 @@ Stricter parsing can be accomplished by passing a different grammar object
   >>> pvl.loads(some_pvl, grammar=pvl.grammar.PVLGrammar())
   Traceback (most recent call last):
     ...
-  pvl.lexer.LexerError: (LexerError(...), 'Expecting an Aggregation Block, an Assignment Statement, or an End Statement, but found "#" : line 3 column 1 (char 67)')
+  pvl.exceptions.LexerError: (LexerError(...), 'Expecting an Aggregation Block, an Assignment Statement, or an End Statement, but found "#" : line 3 column 1 (char 67) near "like this */\n# but people"')
+
 
 ----------
 From a URL
diff --git a/pvl/__init__.py b/pvl/__init__.py
index f1c6184..a4ec452 100755
--- a/pvl/__init__.py
+++ b/pvl/__init__.py
@@ -23,7 +23,7 @@ from ._collections import (
 
 __author__ = 'The pvl Developers'
 __email__ = 'trevor@heytrevor.com'
-__version__ = '1.0.0-alpha.6'
+__version__ = '1.0.0-alpha.7'
 __all__ = [
     'load',
     'loads',
diff --git a/pvl/decoder.py b/pvl/decoder.py
index d172312..118a182 100644
--- a/pvl/decoder.py
+++ b/pvl/decoder.py
@@ -22,6 +22,7 @@ from warnings import warn
 
 from .grammar import PVLGrammar, ODLGrammar
 from ._collections import Units
+from .exceptions import QuantityError
 
 
 def for_try_except(exception, function, *iterable):
@@ -45,11 +46,6 @@ def for_try_except(exception, function, *iterable):
     raise exception
 
 
-class QuantityError(Exception):
-    """A simple exception to distinguish errors from Quantity classes."""
-    pass
-
-
 class PVLDecoder(object):
     """A decoder based on the rules in the CCSDS-641.0-B-2 'Blue Book'
     which defines the PVL language.
diff --git a/pvl/exceptions.py b/pvl/exceptions.py
new file mode 100644
index 0000000..0c61a62
--- /dev/null
+++ b/pvl/exceptions.py
@@ -0,0 +1,78 @@
+# -*- coding: utf-8 -*-
+"""
+Exceptions for the Parameter Value Library.
+"""
+
+# Copyright 2019-2020, ``pvl`` library authors.
+#
+# Reuse is permitted under the terms of the license.
+# The AUTHORS file and the LICENSE file are at the
+# top level of this library.
+
+
+def firstpos(sub: str, pos: int):
+    """On the assumption that *sub* is a substring contained in a longer
+    string, and *pos* is the index in that longer string of the final
+    character in sub, returns the position of the first character of
+    sub in that longer string.
+
+    This is useful in the PVL library when we know the position of the
+    final character of a token, but want the position of the first
+    character.
+    """
+    return pos - len(sub) + 1
+
+
+def linecount(doc: str, end: int, start: int = 0):
+    """Returns the number of lines (by counting the
+    number of newline characters \\n, with the first line
+    being line number one) in the string *doc* between the
+    positions *start* and *end*.
+    """
+    return doc.count('\n', start, end) + 1
+
+
+class LexerError(ValueError):
+    """Subclass of ValueError with the following additional properties:
+
+    msg: The unformatted error message
+    doc: The PVL text being parsed
+    pos: The start index in doc where parsing failed
+    lineno: The line corresponding to pos
+    colno: The column corresponding to pos
+    """
+
+    def __init__(self, msg, doc, pos, lexeme):
+        self.pos = firstpos(lexeme, pos)
+        lineno = linecount(doc, self.pos)
+        colno = self.pos - doc.rfind('\n', 0, self.pos)
+        # Assemble a context string that consists of whole
+        # words, using fragments is hard to read.
+        context_tokens = doc[self.pos - 15: self.pos + 15].split(" ")
+        context = " ".join(context_tokens[1:-1])
+        errmsg = (
+            f'{msg}: line {lineno} column {colno} (char {pos}) '
+            f'near "{context}"'
+        )
+        super().__init__(self, errmsg)
+        self.msg = msg
+        self.doc = doc
+        self.lineno = lineno
+        self.colno = colno
+        self.lexeme = lexeme
+
+    def __reduce__(self):
+        return self.__class__, (self.msg, self.doc, self.pos, self.lexeme)
+
+
+class ParseError(Exception):
+    """An exception to signal errors in the pvl parser."""
+
+    def __init__(self, msg, token=None):
+        super().__init__(self, msg)
+        self.token = token
+
+
+class QuantityError(Exception):
+    """A simple exception to distinguish errors from Quantity classes."""
+    pass
diff --git a/pvl/lexer.py b/pvl/lexer.py
index 457d8a7..c20c337 100644
--- a/pvl/lexer.py
+++ b/pvl/lexer.py
@@ -14,33 +14,7 @@ from enum import Enum, auto
 from .grammar import PVLGrammar
 from .token import Token
 from .decoder import PVLDecoder
-
-
-class LexerError(ValueError):
-    """Subclass of ValueError with the following additional properties:
-
-    msg: The unformatted error message
-    doc: The PVL document being parsed
-    pos: The start index in doc where parsing failed
-    lineno: The line corresponding to pos
-    colno: The column corresponding to pos
-    """
-
-    def __init__(self, msg, doc, pos, lexeme):
-        self.pos = firstpos(lexeme, pos)
-        # lineno = doc.count('\n', 0, self.pos) + 1
-        lineno = linecount(doc, self.pos)
-        colno = self.pos - doc.rfind('\n', 0, self.pos)
-        errmsg = f'{msg}: line {lineno} column {colno} (char {pos})'
-        super().__init__(self, errmsg)
-        self.msg = msg
-        self.doc = doc
-        self.lineno = lineno
-        self.colno = colno
-        self.lexeme = lexeme
-
-    def __reduce__(self):
-        return self.__class__, (self.msg, self.doc, self.pos, self.lexeme)
+from .exceptions import LexerError, firstpos
 
 
 class Preserve(Enum):
@@ -51,28 +25,6 @@ class Preserve(Enum):
     NONDECIMAL = auto()
 
 
-def linecount(doc: str, end: int, start: int = 0):
-    """Returns the number of lines (by counting the
-    number of newline characters \\n, with the first line
-    being line number one) in the string *doc* between the
-    positions *start* and *end*.
-    """
-    return (doc.count('\n', start, end) + 1)
-
-
-def firstpos(sub: str, pos: int):
-    """On the assumption that *sub* is a substring contained in a longer
-    string, and *pos* is the index in that longer string of the final
-    character in sub, returns the position of the first character of
-    sub in that longer string.
-
-    This is useful in the PVL library when we know the position of the
-    final character of a token, but want the position of the first
-    character.
-    """
-    return (pos - len(sub) + 1)
-
-
 def lex_preserve(char: str, lexeme: str, preserve: dict) -> tuple((str, dict)):
     """Returns a modified *lexeme* string and a modified *preserve*
     dict in a two-tuple.  The modified *lexeme* will always be
@@ -89,9 +41,9 @@ def lex_preserve(char: str, lexeme: str, preserve: dict) -> tuple((str, dict)):
     """
     # print(f'in preserve: char "{char}", lexeme "{lexeme}, p {preserve}"')
     if char == preserve['end']:
-        return (lexeme + char, dict(state=Preserve.FALSE, end=None))
+        return lexeme + char, dict(state=Preserve.FALSE, end=None)
     else:
-        return (lexeme + char, preserve)
+        return lexeme + char, preserve
 
 
 def lex_singlechar_comments(char: str, lexeme: str, preserve: dict,
@@ -121,7 +73,7 @@ def lex_singlechar_comments(char: str, lexeme: str, preserve: dict,
         return (lexeme + char, dict(state=Preserve.COMMENT,
                                     end=comments[char]))
 
-    return (lexeme, preserve)
+    return lexeme, preserve
 
 
 def lex_multichar_comments(char: str, prev_char: str, next_char: str,
@@ -160,23 +112,22 @@ def lex_multichar_comments(char: str, prev_char: str, next_char: str,
     if ('/*', '*/') in comments:
         if char == '*':
             if prev_char == '/':
-                return (lexeme + '/*', dict(state=Preserve.COMMENT, end='*/'))
+                return lexeme + '/*', dict(state=Preserve.COMMENT, end='*/')
             elif next_char == '/':
-                return (lexeme + '*/', dict(state=Preserve.FALSE, end=None))
+                return lexeme + '*/', dict(state=Preserve.FALSE, end=None)
             else:
-                return (lexeme + '*', preserve)
+                return lexeme + '*', preserve
         elif char == '/':
             # If part of a comment ignore, and let the char == '*' handler
             # above deal with it, otherwise add it to the lexeme.
             if prev_char != '*' and next_char != '*':
-                return (lexeme + '/', preserve)
+                return lexeme + '/', preserve
 
-    return (lexeme, preserve)
+    return lexeme, preserve
 
 
 def lex_comment(char: str, prev_char: str, next_char: str,
                 lexeme: str, preserve: dict,
-                comments: tuple(tuple((str, str))),
                 c_info: dict) -> tuple((str, dict)):
     """Returns a modified *lexeme* string and a modified *preserve*
     dict in a two-tuple.
@@ -274,7 +225,7 @@ def lex_char(char: str, prev_char: str, next_char: str,
         if preserve['state'] == Preserve.COMMENT:
             (lexeme,
              preserve) = lex_comment(char, prev_char, next_char,
-                                     lexeme, preserve, g.comments, c_info)
+                                     lexeme, preserve, c_info)
         elif preserve['state'] in (Preserve.UNIT, Preserve.QUOTE,
                                    Preserve.NONDECIMAL):
             (lexeme,
@@ -289,8 +240,7 @@ def lex_char(char: str, prev_char: str, next_char: str,
     elif char in c_info['chars']:
         (lexeme,
          preserve) = lex_comment(char, prev_char, next_char,
-                                 lexeme, preserve,
-                                 g.comments, c_info)
+                                 lexeme, preserve, c_info)
     elif char in g.units_delimiters[0]:
         lexeme += char
         preserve = dict(state=Preserve.UNIT, end=g.units_delimiters[1])
@@ -302,7 +252,7 @@ def lex_char(char: str, prev_char: str, next_char: str,
             lexeme += char  # adding a char each time
 
     # print(f'lex_char end: char "{char}", lexeme "{lexeme}", "{preserve}"')
-    return (lexeme, preserve)
+    return lexeme, preserve
 
 
 def lex_continue(char: str, next_char: str, lexeme: str,
@@ -343,7 +293,7 @@ def lex_continue(char: str, next_char: str, lexeme: str,
     # Some datetimes can have trailing numeric tz offsets,
     # if the decoder allows it, this means there could be
     # a '+' that splits the lexeme that we don't want.
-    if (next_char in g.numeric_start_chars and token.is_datetime()):
+    if next_char in g.numeric_start_chars and token.is_datetime():
         return True
 
     return False
@@ -424,10 +374,10 @@ def lexer(s: str, g=PVLGrammar(), d=PVLDecoder()):
                  or lexeme in g.reserved_characters
                  or tok.is_quoted_string()):
                 # print(f'yielding {tok}')
-                t = yield(tok)
+                t = yield tok
                 while t is not None:
                     yield None
-                    t = yield(t)
+                    t = yield t
                 lexeme = ''
             else:
                 continue
diff --git a/pvl/parser.py b/pvl/parser.py
index 6006c58..d770a3f 100644
--- a/pvl/parser.py
+++ b/pvl/parser.py
@@ -61,15 +61,7 @@ from .token import Token
 from .grammar import PVLGrammar, OmniGrammar
 from .decoder import PVLDecoder, OmniDecoder
 from .lexer import lexer as Lexer
-from .lexer import LexerError, linecount
-
-
-class ParseError(Exception):
-    """A simple parser exception."""
-
-    def __init__(self, msg, token=None):
-        super().__init__(self, msg)
-        self.token = token
+from .exceptions import LexerError, ParseError, linecount
 
 
 class EmptyValueAtLine(str):
diff --git a/setup.cfg b/setup.cfg
index 256c3e6..7caeffc 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -3,3 +3,23 @@ universal = 1
 
 [tool:pytest]
 doctest_optionflags = NORMALIZE_WHITESPACE
+
+
+[bumpversion]
+current_version = 1.0.0-alpha.7
+commit = False
+tag = False
+parse = (?P<major>\d+)\.(?P<minor>\d+)\.(?P<patch>\d+)(\-(?P<prerelease>[a-z]+)\.((?P<serial>\d+)))?
+serialize = 
+	{major}.{minor}.{patch}-{prerelease}.{serial}
+	{major}.{minor}.{patch}
+
+[bumpversion:part:prerelease]
+optional_value = beta
+values = 
+	alpha
+	beta
+
+[bumpversion:file:setup.py]
+
+[bumpversion:file:pvl/__init__.py]
diff --git a/setup.py b/setup.py
index 3a47230..c2b854a 100755
--- a/setup.py
+++ b/setup.py
@@ -12,7 +12,7 @@ history = open('HISTORY.rst').read().replace('.. :changelog:', '')
 
 setup(
     name='pvl',
-    version='1.0.0-alpha.6',
+    version='1.0.0-alpha.7',
     description='Python implementation of PVL (Parameter Value Language)',
     long_description=readme + '\n\n' + history,
     author='The PlanetaryPy Developers',


Test patch:
diff --git a/tests/test_lexer.py b/tests/test_lexer.py
index f38cbc2..5c131b9 100644
--- a/tests/test_lexer.py
+++ b/tests/test_lexer.py
@@ -100,7 +100,6 @@ class TestLexComments(unittest.TestCase):
         self.assertEqual(('', pfn),
                          Lexer.lex_comment('a', 'b', 'c',
                                            '', pfn,
-                                           (('/*', '*/'),),
                                            dict(single_comments={'k': 'v'},
                                                 multi_comments=(('/*', '*/'),),
                                                 multi_chars=set(('/', '*')))))
@@ -108,7 +107,6 @@ class TestLexComments(unittest.TestCase):
         self.assertEqual(('/*', pcom),
                          Lexer.lex_comment('*', '/', 'c',
                                            '', pfn,
-                                           (('/*', '*/'),),
                                            dict(single_comments={'k': 'v'},
                                                 multi_comments=(('/*', '*/'),),
                                                 multi_chars=set(('/', '*')))))