From 796ee43a074f1854f65c0fc24052356c5709dc23 Mon Sep 17 00:00:00 2001 From: Nhu Ngo Date: Thu, 23 Jul 2026 20:00:26 +0700 Subject: [PATCH 1/2] fix(structprop): improve parser error messages and fix token formatting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix format_error_token() to use token objects as mapping keys (previously showed raw tuple repr like ('OPEN',) in errors) - Clarify all ParserError messages with better context - Show source line in parse errors only when available Activitíe: CONVOY-1877 --- structprop/__init__.py | 68 ++++++++++++++++++++++++++++++++---------- 1 file changed, 52 insertions(+), 16 deletions(-) diff --git a/structprop/__init__.py b/structprop/__init__.py index f38d3b9..1ddab2f 100644 --- a/structprop/__init__.py +++ b/structprop/__init__.py @@ -118,6 +118,15 @@ def _parse(s, handler): # value: STRING # | '{' STRING* '}' # + def format_error_token(token): + mapping = { + CLOSE: '}', + OPEN: '{', + EQ: '=', + EOF: 'end of file', + } + return mapping.get(token, str(token)) + def stmts(obj, next, token): """Process statements until EOF.""" while token is not EOF: @@ -125,7 +134,10 @@ def stmts(obj, next, token): def assign(obj, next, token): if not isinstance(token, six.string_types): - raise ParserError("term expected, got '%s'" % token) + raise ParserError( + "expected a name at the start of an assignment, but got '%s'" + % format_error_token(token) + ) _key = token token = next() if _key.startswith('!') and token is not EQ \ @@ -143,14 +155,17 @@ def assign(obj, next, token): token = assignlist(subobj, next, token) obj[_key] = subobj else: - raise ParserError("expected '=' or '{' got '%s'" % token) + raise ParserError( + "expected '=' or '{' after key '%s', but got '%s'" + % (_key, format_error_token(token)) + ) return token def assignlist(obj, next, token): while True: assign(obj, next, token) token = next() - if type(token) != str: + if not isinstance(token, str): return token def value(obj, next, token): @@ -166,20 +181,25 @@ def value(obj, next, token): break assign(obj, next, token) _value.append(obj) - elif token.startswith('!'): + elif (isinstance(token, six.string_types) + and token.startswith('!')): key = token token = next() if token is CLOSE: raise ParserError( - "expected token, got '}'") - _value.extend(handler(key, token, - 'value')) + "expected a value, but the list closed with '%s'" + % format_error_token(token) + ) + _value.extend(handler(key, token, 'value')) else: _value.append(token) token = next() return _value if not isinstance(token, six.string_types): - raise ParserError("expected string token, got %r" % token) + raise ParserError( + "expected a string or number value, but got '%s'" + % format_error_token(token) + ) try: return json.loads(token) except ValueError: @@ -189,13 +209,29 @@ def value(obj, next, token): tokenizer = lexer.tokenize(s) def pop_token(): - return next(tokenizer) - - token = pop_token() - result = OrderedDict() + try: + return next(tokenizer) + except StopIteration: + raise ParserError( + "reached end of file unexpectedly " + "— check for a missing closing '}'" + ) - stmts(result, pop_token, token) - return result + try: + token = pop_token() + result = OrderedDict() + stmts(result, pop_token, token) + return result + except ParserError as e: + lines = s.splitlines() + offending_line = ( + lines[lexer.line - 1].strip() + if 0 < lexer.line <= len(lines) else "" + ) + context = "(context: '%s')" % offending_line if offending_line else "" + raise ParserError( + "parse error on line %d: %s %s" % (lexer.line, e, context) + ) from e def loads(data, handler=None): @@ -238,7 +274,7 @@ def _dump(d, indent=0): elif isinstance(value, list): yield '%s%s = {\n' % (' ' * indent, _escape(key)) for subvalue in value: - if type(subvalue) == dict: + if isinstance(subvalue, dict): yield '%s{\n' % (' ' * (indent + 2)) for subs in _dump(subvalue, indent + 4): yield subs @@ -248,7 +284,7 @@ def _dump(d, indent=0): _escape(subvalue)) yield '%s}\n' % (' ' * indent) - elif type(value) == bool: + elif isinstance(value, bool): yield '%s%s = %s\n' % (' ' * indent, _escape(key), _escape(str(value).lower())) else: From 08219e77ffaabf9dd4b18ad271de21fcdfe4a859 Mon Sep 17 00:00:00 2001 From: Nhu Ngo Date: Fri, 24 Jul 2026 11:01:44 +0700 Subject: [PATCH 2/2] ci: update GitHub Actions to use Python 3.9 and modern actions - Replace actions/checkout@v2 with actions/checkout@v4 - Replace actions/setup-python@v2 with actions/setup-python@v5 - Remove Python 2.7 and 3.6 (no longer available on GitHub Actions runners) - Use Python 3.9 as the only test target Activities: CONVOY-1877 --- .github/workflows/python.yml | 7 +++---- tox.ini | 5 ++--- 2 files changed, 5 insertions(+), 7 deletions(-) diff --git a/.github/workflows/python.yml b/.github/workflows/python.yml index 8cc4e83..8ca6b72 100644 --- a/.github/workflows/python.yml +++ b/.github/workflows/python.yml @@ -18,13 +18,12 @@ jobs: - ubuntu-latest - windows-latest python-version: - - '2.7' - - '3.6' + - '3.9' steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v4 - name: Setup Python - uses: actions/setup-python@v2 + uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} diff --git a/tox.ini b/tox.ini index 9a38444..866f89c 100644 --- a/tox.ini +++ b/tox.ini @@ -1,10 +1,9 @@ [tox] -envlist = py{27,36},flake8 +envlist = py39,flake8 [gh-actions] python = - 2.7: py27, flake8 - 3.6: py36, flake8 + 3.9: py39, flake8 [testenv:flake8] deps = flake8