changeset 2:59fe8cb6190d

Add lots of tests, fix lots of bugs.
author Ludovic Chabant <ludovic@chabant.com>
date Tue, 03 Jan 2017 09:05:28 -0800
parents 74b83e3d921e
children 6019eee799bf
files fontaine/document.py fontaine/parser.py tests/conftest.py tests/test_action.yaml tests/test_character.yaml tests/test_pagebreak.yaml tests/test_sceneheadings.yaml tests/test_scripts.yaml tests/test_titlepage.yaml
diffstat 9 files changed, 196 insertions(+), 98 deletions(-) [+]
line wrap: on
line diff
--- a/fontaine/document.py	Mon Jan 02 21:54:59 2017 -0800
+++ b/fontaine/document.py	Tue Jan 03 09:05:28 2017 -0800
@@ -85,6 +85,7 @@
 TYPE_TRANSITION = 5
 TYPE_LYRICS = 6
 TYPE_PAGEBREAK = 7
+TYPE_EMPTYLINES = 8
 
 
 def _scene_element_type_str(t):
@@ -104,6 +105,8 @@
         return 'LYRICS'
     if t == TYPE_PAGEBREAK:
         return 'PAGEBREAK'
+    if t == TYPE_EMPTYLINES:
+        return 'EMPTYLINES'
     raise NotImplementedError()
 
 
--- a/fontaine/parser.py	Mon Jan 02 21:54:59 2017 -0800
+++ b/fontaine/parser.py	Tue Jan 03 09:05:28 2017 -0800
@@ -1,16 +1,14 @@
 import re
 import logging
+from .document import TYPE_ACTION
 
 
 logger = logging.getLogger(__name__)
 
 
 class FontaineState:
-    can_merge = False
-    needs_pending_empty_lines = True
-
     def __init__(self):
-        self.has_pending_empty_line = False
+        pass
 
     def match(self, fp, ctx):
         return False
@@ -18,10 +16,7 @@
     def consume(self, fp, ctx):
         raise NotImplementedError()
 
-    def merge(self):
-        pass
-
-    def exit(self, ctx):
+    def exit(self, ctx, next_state):
         pass
 
 
@@ -42,7 +37,7 @@
 RE_EMPTY_LINE = re.compile(r"^$", re.M)
 RE_BLANK_LINE = re.compile(r"^\s*$", re.M)
 
-RE_TITLE_KEY_VALUE = re.compile(r"^(?P<key>[\w\s\-]+)\s*:")
+RE_TITLE_KEY_VALUE = re.compile(r"^(?P<key>[\w\s\-]+)\s*:\s*")
 
 
 class _TitlePageState(FontaineState):
@@ -65,27 +60,27 @@
             if m:
                 # Commit current value, start new one.
                 self._commit(ctx)
-                self._cur_key = m.group('key')
-                self._cur_val = line[m.end():].strip()
+                self._cur_key = m.group('key').lower()
+                self._cur_val = line[m.end():]
             else:
                 # Keep accumulating the value of one of the title page's
                 # values.
-                self._cur_val += line.strip()
+                self._cur_val += line.lstrip()
 
             if RE_EMPTY_LINE.match(fp.peekline()):
                 self._commit(ctx)
                 # Finished with the page title, now move on to the first scene.
-                self.has_pending_empty_line = True
                 break
 
         return ANY_STATE
 
-    def exit(self, ctx):
+    def exit(self, ctx, next_state):
         self._commit(ctx)
 
     def _commit(self, ctx):
         if self._cur_key is not None:
-            ctx.document.title_values[self._cur_key] = self._cur_val
+            val = self._cur_val.rstrip('\r\n')
+            ctx.document.title_values[self._cur_key] = val
             self._cur_key = None
             self._cur_val = None
 
@@ -107,19 +102,13 @@
         line = fp.readline().rstrip('\r\n')
         line = line.lstrip('.')  # In case it was forced.
         ctx.document.addScene(line)
-        self.has_pending_empty_line = True
         return ANY_STATE
 
 
 class _ActionState(FontaineState):
-    can_merge = True
-    needs_pending_empty_lines = False
-
     def __init__(self):
         super().__init__()
         self.text = ''
-        self._to_merge = None
-        self._was_merged = False
 
     def match(self, fp, ctx):
         return True
@@ -132,6 +121,10 @@
                 return EOF_STATE
 
             if is_first_line:
+                # Ignore the fake blank line at 0 if it's threre.
+                if fp.line_no == 0:
+                    continue
+
                 line = line.lstrip('!')  # In case it was forced.
                 is_first_line = False
 
@@ -139,23 +132,19 @@
             # the line we just got because it's probably gonna be the
             # last one.
             if RE_EMPTY_LINE.match(fp.peekline()):
-                stripped_line = line.rstrip("\r\n")
-                self.text += stripped_line
-                self._to_merge = line[len(stripped_line):]
+                self.text += line.rstrip("\r\n")
                 break
             # ...otherwise, add the line with in full.
             self.text += line
 
         return ANY_STATE
 
-    def merge(self):
-        # Put back the stuff we stripped from what we thought was the
-        # last line.
-        self.text += self._to_merge
-        self._was_merged = True
-
-    def exit(self, ctx):
-        ctx.document.lastScene().addAction(self.text)
+    def exit(self, ctx, next_state):
+        last_para = ctx.document.lastParagraph()
+        if last_para and last_para.type == TYPE_ACTION:
+            last_para.text += '\n' + self.text
+        else:
+            ctx.document.lastScene().addAction(self.text)
 
 
 RE_CENTERED_LINE = re.compile(r"^\s*>\s*.*\s*<\s*$", re.M)
@@ -190,7 +179,6 @@
                 # if we detect a line that doesn't have them, we make this
                 # paragraph be a normal action instead.
                 fp.restore(snapshot)
-                self.has_pending_empty_line = True
                 self._aborted = True
                 return _ActionState()
             else:
@@ -199,18 +187,17 @@
 
             if RE_EMPTY_LINE.match(fp.peekline()):
                 self.text += clean_line
-                self.has_pending_empty_line = True
                 break
             self.text += clean_line + eol
 
         return ANY_STATE
 
-    def exit(self, ctx):
+    def exit(self, ctx, next_state):
         if not self._aborted:
             ctx.document.lastScene().addCenteredAction(self.text)
 
 
-RE_CHARACTER_LINE = re.compile(r"^\s*[A-Z\-]+\s*(\(.*\))?$", re.M)
+RE_CHARACTER_LINE = re.compile(r"^\s*[A-Z][A-Z\-\._\s]+\s*(\(.*\))?$", re.M)
 
 
 class _CharacterState(FontaineState):
@@ -247,7 +234,6 @@
         if not RE_EMPTY_LINE.match(next_line):
             return _DialogState()
 
-        self.has_pending_empty_line = True
         return ANY_STATE
 
 
@@ -280,13 +266,12 @@
 
             if RE_EMPTY_LINE.match(next_line):
                 self.text += line.rstrip('\r\n')
-                self.has_pending_empty_line = True
                 break
             self.text += line
 
         return ANY_STATE
 
-    def exit(self, ctx):
+    def exit(self, ctx, next_state):
         ctx.document.lastScene().addDialog(self.text.rstrip('\r\n'))
 
 
@@ -312,19 +297,17 @@
             else:
                 logger.debug("Rolling back lyrics into action paragraph.")
                 fp.restore(snapshot)
-                self.has_pending_empty_line = True
                 self._aborted = True
                 return _ActionState()
 
             if RE_EMPTY_LINE.match(fp.peekline()):
                 self.text += line.rstrip('\r\n')
-                self.has_pending_empty_line = True
                 break
             self.text += line
 
         return ANY_STATE
 
-    def exit(self, ctx):
+    def exit(self, ctx, next_state):
         if not self._aborted:
             ctx.document.lastScene().addLyrics(self.text)
 
@@ -345,7 +328,7 @@
         line = fp.readline().lstrip().rstrip('\r\n')
         line = line.lstrip('>')  # In case it was forced.
         ctx.document.lastScene().addTransition(line)
-        self.has_pending_empty_line = True
+        return ANY_STATE
 
 
 RE_PAGE_BREAK_LINE = re.compile(r"^\=\=\=+$", re.M)
@@ -363,7 +346,6 @@
         fp.readline()
         fp.readline()
         ctx.document.lastScene().addPageBreak()
-        self.has_pending_empty_line = True
         return ANY_STATE
 
 
@@ -407,6 +389,30 @@
         return self._state_cls()
 
 
+class _EmptyLineState(FontaineState):
+    def __init__(self):
+        super().__init__()
+        self.line_count = 0
+
+    def match(self, fp, ctx):
+        return RE_EMPTY_LINE.match(fp.peekline())
+
+    def consume(self, fp, ctx):
+        fp.readline()
+        if fp.line_no > 1:  # Don't take into account the fake blank at 0
+            self.line_count += 1
+        return ANY_STATE
+
+    def exit(self, ctx, next_state):
+        if self.line_count > 0:
+            text = self.line_count * '\n'
+            last_para = ctx.document.lastParagraph()
+            if last_para and last_para.type == TYPE_ACTION:
+                last_para.text += text
+            else:
+                ctx.document.lastScene().addAction(text[1:])
+
+
 ROOT_STATES = [
     _ForcedParagraphStates,  # Must be first.
     _SceneHeaderState,
@@ -414,6 +420,7 @@
     _TransitionState,
     _PageBreakState,
     _CenteredActionState,
+    _EmptyLineState,   # Must be second to last.
     _ActionState,  # Must be last.
 ]
 
@@ -424,12 +431,13 @@
         self._fp = fp
         self._blankAt0 = False
 
-    def readline(self, size=-1):
+    def readline(self):
         if self._blankAt0:
             self._blankAt0 = False
+            self.line_no = 0
             return '\n'
 
-        data = self._fp.readline(size)
+        data = self._fp.readline()
         self.line_no += 1
         return data
 
@@ -466,12 +474,7 @@
             raise Exception(
                 "Can't add blank line at 0 if reading has started.")
         self._blankAt0 = True
-
-    def _read(self, size, advance_line_no):
-        data = self._fp.read(size)
-        if advance_line_no:
-            self.line_no += data.count('\n')
-        return data
+        self.line_no = 0
 
 
 class _FontaineStateMachine:
@@ -497,9 +500,6 @@
                 # Add a fake empty line at the beginning of the text if
                 # there's not one already. This makes state matching easier.
                 self.fp._addBlankAt0()
-                # Make this added empty line "pending" so if the first line
-                # is an action paragraph, it doesn't include it.
-                self.state.has_pending_empty_line = True
 
         # Start parsing! Here we try to do a mostly-forward-only parser with
         # non overlapping regexes to make it decently fast.
@@ -516,10 +516,12 @@
             # Figure out what to do next...
 
             if res is None:
-                raise Exception(
+                raise FontaineParserError(
+                    self.line_no,
+                    "State '%s' returned a `None` result. "
                     "States need to return `ANY_STATE`, one or more specific "
                     "states, or `EOF_STATE` if they reached the end of the "
-                    "file.")
+                    "file." % self.state.__class__.__name__)
 
             elif res is ANY_STATE or isinstance(res, list):
                 # State wants to exit, we need to figure out what is the
@@ -544,36 +546,19 @@
 
                 # Handle the current state before we move on to the new one.
                 if self.state:
-                    if type(self.state) == type(res) and self.state.can_merge:
-                        # Don't switch states if the next state is the same
-                        # type and that type supports merging.
-                        self.state.merge()
-                        continue
-
-                    self.state.exit(self)
-                    if (self.state.has_pending_empty_line and
-                            not res.needs_pending_empty_lines):
-                        logger.debug("Skipping pending blank line from %s" %
-                                     self.state.__class__.__name__)
-                        self.fp.readline()
-
+                    self.state.exit(self, res)
                 self.state = res
 
             elif isinstance(res, FontaineState):
                 # State wants to exit, wants a specific state to be next.
                 if self.state:
-                    self.state.exit(self)
-                    if (self.state.has_pending_empty_line and
-                            not res.needs_pending_empty_lines):
-                        logger.debug("Skipping pending blank line from %s" %
-                                     self.state.__class__.__name__)
-                        self.fp.readline()
+                    self.state.exit(self, res)
                 self.state = res
 
             elif res is EOF_STATE:
                 # Reached end of file.
                 if self.state:
-                    self.state.exit(self)
+                    self.state.exit(self, res)
                 break
 
             else:
--- a/tests/conftest.py	Mon Jan 02 21:54:59 2017 -0800
+++ b/tests/conftest.py	Tue Jan 03 09:05:28 2017 -0800
@@ -1,3 +1,4 @@
+import re
 import sys
 import logging
 import yaml
@@ -6,9 +7,9 @@
     FontaineSceneElement,
     TYPE_ACTION, TYPE_CENTEREDACTION, TYPE_CHARACTER, TYPE_DIALOG,
     TYPE_PARENTHETICAL, TYPE_TRANSITION, TYPE_LYRICS, TYPE_PAGEBREAK,
+    TYPE_EMPTYLINES,
     _scene_element_type_str)
 from fontaine.parser import FontaineParser, FontaineParserError
-from fontaine.renderer import BaseRenderer
 
 
 def pytest_addoption(parser):
@@ -153,6 +154,9 @@
                                       p.text)
 
 
+RE_BLANK_LINE = re.compile(r"^\s+$")
+
+
 def make_scenes(spec):
     if not isinstance(spec, list):
         raise Exception("Script specs must be lists.")
@@ -166,6 +170,11 @@
             cur_paras.append(FontaineSceneElement(TYPE_PAGEBREAK, None))
             continue
 
+        if RE_BLANK_LINE.match(item):
+            text = len(item) * '\n'
+            cur_paras.append(FontaineSceneElement(TYPE_EMPTYLINES, text))
+            continue
+
         token = item[:1]
         if token == '.':
             if cur_header or cur_paras:
@@ -193,14 +202,3 @@
     if cur_header or cur_paras:
         out.append([cur_header] + cur_paras)
     return out
-
-
-class TestRenderer(BaseRenderer):
-    def write_bold(self, text):
-        pass
-
-    def write_italics(self, text):
-        pass
-
-    def write_underline(self, text):
-        pass
--- a/tests/test_action.yaml	Mon Jan 02 21:54:59 2017 -0800
+++ b/tests/test_action.yaml	Tue Jan 03 09:05:28 2017 -0800
@@ -14,3 +14,44 @@
 in: "!EXT. ACTION. FORCED."
 out: 
     - "!EXT. ACTION. FORCED."
+---
+in: |
+    JOHN
+    This is my dialogue.
+
+    JAKE
+    There were blanks before mine.
+out:
+    - "@JOHN"
+    - "=This is my dialogue."
+    - "@JAKE"
+    - "=There were blanks before mine."
+---
+in: |
+    JOHN
+    This is my dialogue.
+
+
+    JAKE
+    There were blanks before mine.
+out:
+    - "@JOHN"
+    - "=This is my dialogue."
+    - "!"
+    - "@JAKE"
+    - "=There were blanks before mine."
+---
+in: |
+    JOHN
+    This is my dialogue.
+
+
+
+    JAKE
+    There were blanks before mine.
+out:
+    - "@JOHN"
+    - "=This is my dialogue."
+    - "!\n"
+    - "@JAKE"
+    - "=There were blanks before mine."
--- a/tests/test_character.yaml	Mon Jan 02 21:54:59 2017 -0800
+++ b/tests/test_character.yaml	Tue Jan 03 09:05:28 2017 -0800
@@ -18,3 +18,13 @@
 out:
     - '@McCLANE'
     - "=Yippie ki-yay!"
+---
+in: "DR. BENNET\nHere's your medication."
+out:
+    - '@DR. BENNET'
+    - "=Here's your medication."
+---
+in: "CROWD (VARIOUS)\nWe want free t-shirts!"
+out:
+    - "@CROWD (VARIOUS)"
+    - "=We want free t-shirts!"
--- a/tests/test_pagebreak.yaml	Mon Jan 02 21:54:59 2017 -0800
+++ b/tests/test_pagebreak.yaml	Tue Jan 03 09:05:28 2017 -0800
@@ -13,4 +13,4 @@
     - "@NARRATOR"
     - "=A page break is coming."
     - "<pagebreak>"
-    - "!There it was."
+    - "!\nThere it was."
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/tests/test_sceneheadings.yaml	Tue Jan 03 09:05:28 2017 -0800
@@ -0,0 +1,36 @@
+---
+in: |
+    EXT. ANOTHER PLACE
+
+    This is an action I think.
+out:
+    - ".EXT. ANOTHER PLACE"
+    - "!\nThis is an action I think."
+---
+in: |
+    An action.
+
+
+    EXT. ANOTHER PLACE
+
+    This is an action I think.
+out:
+    - "!An action.\n"
+    - ".EXT. ANOTHER PLACE"
+    - "!\nThis is an action I think."
+---
+in: |
+    title: Something for Testing
+    author: Moi
+
+
+    EXT. ANOTHER PLACE
+
+    This is an action I think.
+title:
+    title: Something for Testing
+    author: Moi
+out:
+    - "!"
+    - ".EXT. ANOTHER PLACE"
+    - "!\nThis is an action I think."
--- a/tests/test_scripts.yaml	Mon Jan 02 21:54:59 2017 -0800
+++ b/tests/test_scripts.yaml	Tue Jan 03 09:05:28 2017 -0800
@@ -19,7 +19,7 @@
     - "!><**End of Act One**"
     - "<pagebreak>"
     - "!><**Act Two**"
-    - "!The General Lee hangs in the air, right where we left it."
+    - "!\nThe General Lee hangs in the air, right where we left it."
 ---
 in: |
     EXT. BRICK'S POOL - DAY
@@ -38,7 +38,7 @@
     From what seems like only INCHES AWAY.  _Steel's face FILLS the *Leupold Mark 4* scope_.
 out:
     - ".EXT. BRICK'S POOL - DAY"
-    - "!Steel, in the middle of a heated phone call:"
+    - "!\nSteel, in the middle of a heated phone call:"
     - "@STEEL"
     - "=They're coming out of the woodwork!"
     - "_(pause)"
@@ -46,4 +46,18 @@
     - "_(pause)"
     - "=Point Blank Sniper?"
     - ".SNIPER SCOPE POV"
-    - "!From what seems like only INCHES AWAY.  _Steel's face FILLS the *Leupold Mark 4* scope_."
+    - "!\nFrom what seems like only INCHES AWAY.  _Steel's face FILLS the *Leupold Mark 4* scope_."
+---
+in: |
+    Title: Something for Testing
+    Author: Moi
+
+    EXT. ANOTHER PLACE
+
+    This is an action I think.
+title:
+    title: Something for Testing
+    author: Moi
+out:
+    - ".EXT. ANOTHER PLACE"
+    - "!\nThis is an action I think."
--- a/tests/test_titlepage.yaml	Mon Jan 02 21:54:59 2017 -0800
+++ b/tests/test_titlepage.yaml	Tue Jan 03 09:05:28 2017 -0800
@@ -22,8 +22,8 @@
     Title: This simple test
     Author: Ludovic
 title:
-    Title: "This simple test"
-    Author: "Ludovic"
+    title: "This simple test"
+    author: "Ludovic"
 out: []
 ---
 in: |
@@ -31,7 +31,18 @@
 
     It doesn't have much.
 title:
-    Title: "This simple test"
+    title: "This simple test"
 out:
-    - "!It doesn't have much."
-
+    - "!\nIt doesn't have much."
+---
+in: |
+    Title:
+        _**BRICK & STEEL**_
+        _**FULL RETIRED**_
+    Credit: Written by
+    Authors: Stu Maschwitz
+title:
+    title: "_**BRICK & STEEL**_\n_**FULL RETIRED**_"
+    credit: Written by
+    authors: Stu Maschwitz
+out: []