From 25dda79a4561542e14b57e881ed0a7976c9de87f Mon Sep 17 00:00:00 2001 From: David Arnold Date: Tue, 11 Aug 2026 23:20:04 +1000 Subject: [PATCH 1/2] Parse final line without line ending char(s). In some cases, the vobject text supplied does not have a line terminator on the final line of the string/file. This was handled using the regex "$" syntax, but that meant every line was reporting an extra line number consumed, because "$" matches either the end of the string, or immediately before a final CR/LF. So, logical lines still match either a CRLF, CR, LF, or "$", but when removing folded lines, there's now an explicit check for a training terminator, and one is added if it's missing. That ensures every line increments the counter by at least one, and the returned line numbers are now correct for both folded and unfolded lines. --- vobject/base.py | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/vobject/base.py b/vobject/base.py index 77010ac..1d03736 100644 --- a/vobject/base.py +++ b/vobject/base.py @@ -831,12 +831,12 @@ def parseLine(line, lineNumber=None): # logical line regular expressions -patterns['lineend'] = r'(?:\r\n|\r|\n|$)' +patterns['lineend'] = r'(?:\r\n|\r|\n)' patterns['wrap'] = r'{lineend!s} [\t ]'.format(**patterns) patterns['logicallines'] = r""" ( (?: [^\r\n] | {wrap!s} )* - {lineend!s} + (?: {lineend!s} | $ ) ) """.format(**patterns) @@ -883,7 +883,13 @@ def getLogicalLines(fp, allowQP=True): lineNumber = 1 for match in logical_lines_re.finditer(val): - line, n = wrap_re.subn('', match.group()) + log_line = match.group() + # It's possible that the final line of the vobject doesn't + # have a line ending. For ease of parsing, just add it. + # This should really be in an 'if not strict' branch. + if log_line[-1:] not in "\r\n": + log_line += "\r\n" + line, n = wrap_re.subn('', log_line) if line != '': yield line, lineNumber lineNumber += n From d3415ff9e60841eee3fb8d6276517c9cefee713c Mon Sep 17 00:00:00 2001 From: David Arnold Date: Wed, 12 Aug 2026 17:17:09 +1000 Subject: [PATCH 2/2] Add tests for parse error and line terminators. --- tests.py | 44 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/tests.py b/tests.py index 81d2678..7b05835 100644 --- a/tests.py +++ b/tests.py @@ -155,6 +155,50 @@ def test_unicode_multiline(): # json tries to encode as utf-8 and it would break if some chars could not be encoded json.dumps(cal.serialize()) + def test_missing_object_terminator(self): + """ + Test parsing of vObject without line terminator on final line. + """ + # Proper CRLF. + raw = "BEGIN:VCARD\r\n" \ + + "END:VCARD" + card = base.readOne(raw) + self.assertIsNotNone(card) + + # Check with folded line too. + raw = "BEGIN:VCARD\r\n" \ + + "END:\r\n" \ + + " VCARD" + card = base.readOne(raw) + self.assertIsNotNone(card) + + # LF-only (Unix-style). + raw = "BEGIN:VCARD\n" \ + + "END:VCARD" + card = base.readOne(raw) + self.assertIsNotNone(card) + + # CR-only (old MacOS-style). + raw = "BEGIN:VCARD\r" \ + + "END:VCARD" + card = base.readOne(raw) + self.assertIsNotNone(card) + + def test_parsing_error_line_number(self): + """ + Check that the line number reported for a parsing error is correct. + """ + # Mismatched item names, with folded line. + raw = "BEGIN:\r\n" \ + + " AAA\r\n" \ + + "END:BBB" + with self.assertRaises(base.ParseError) as context: + card = base.readOne(raw) + + # Check line number of parsing error. + e = context.exception + self.assertEqual(e.args[1], 3) + @staticmethod def test_ical_to_hcal(): """