diff options
-rw-r--r-- | screenplain/parsers/fountain.py | 26 | ||||
-rw-r--r-- | screenplain/types.py | 30 | ||||
-rw-r--r-- | tests/fountain_test.py | 104 |
3 files changed, 103 insertions, 57 deletions
diff --git a/screenplain/parsers/fountain.py b/screenplain/parsers/fountain.py index df3d147..9b0c6c7 100644 --- a/screenplain/parsers/fountain.py +++ b/screenplain/parsers/fountain.py @@ -7,7 +7,8 @@ from itertools import takewhile import re from screenplain.types import ( - Slug, Action, Dialog, DualDialog, Transition, Section, PageBreak + Slug, Action, Dialog, DualDialog, Transition, Section, PageBreak, + Screenplay ) from screenplain.richstring import parse_emphasis, plain @@ -205,6 +206,11 @@ def _is_blank(line): def parse(stream): + """Parses Fountain source. + + Returns a Screenplay object. + + """ content = stream.read() content = boneyard_re.sub('', content) lines = linebreak_re.split(content) @@ -213,8 +219,11 @@ def parse(stream): def parse_lines(source): - """Reads raw text input and generates paragraph objects.""" + """Reads raw text input and generates paragraph objects. + + Returns a Screenplay object. + """ source = (_preprocess_line(line) for line in source) title_page_lines = list(takewhile(lambda line: line != '', source)) @@ -223,12 +232,14 @@ def parse_lines(source): if title_page: # The first lines were a title page. # Parse the rest of the source as screenplay body. - # TODO: Create a title page from the data in title_page - return parse_body(source) + return Screenplay(title_page, parse_body(source)) else: # The first lines were not a title page. # Parse them as part of the screenplay body. - return parse_body(itertools.chain(title_page_lines, [''], source)) + return Screenplay( + {}, + parse_body(itertools.chain(title_page_lines, [''], source)) + ) def parse_body(source): @@ -244,7 +255,12 @@ def parse_body(source): def parse_title_page(lines): + """Parse the title page. + Spec: http://fountain.io/syntax#section-titlepage + Returns None if the document does not have a title page section, + otherwise a dictionary with the data. + """ result = {} it = iter(lines) diff --git a/screenplain/types.py b/screenplain/types.py index 8464ea5..6acdce6 100644 --- a/screenplain/types.py +++ b/screenplain/types.py @@ -3,6 +3,36 @@ # http://www.opensource.org/licenses/mit-license.php +class Screenplay(object): + def __init__(self, title_page=None, paragraphs=None): + """ + Create a Screenplay object. + + `title_page` is a dictionary mapping string keys to strings. + `paragraphs` is a sequence of paragraph objects. + """ + + # Key/value pairs for title page + if title_page is None: + self.title_page = {} + else: + self.title_page = title_page + + # The paragraphs of the actual script + if paragraphs is None: + self.paragraphs = [] + else: + self.paragraphs = paragraphs + + def append(self, paragraph): + """Append a paragraph to this screenplay.""" + self.paragraphs.append(paragraph) + + def __iter__(self): + """Get an iterator over the paragraphs of this screenplay.""" + return iter(self.paragraphs) + + class Slug(object): def __init__(self, line, scene_number=None): diff --git a/tests/fountain_test.py b/tests/fountain_test.py index e265da9..9a5d308 100644 --- a/tests/fountain_test.py +++ b/tests/fountain_test.py @@ -14,25 +14,25 @@ from StringIO import StringIO def parse(lines): content = '\n'.join(lines) - return fountain.parse(StringIO(content)) + return list(fountain.parse(StringIO(content))) class SlugTests(TestCase): def test_slug_with_prefix(self): - paras = list(parse([ + paras = parse([ 'INT. SOMEWHERE - DAY', '', 'THIS IS JUST ACTION', - ])) + ]) self.assertEquals([Slug, Action], [type(p) for p in paras]) def test_slug_must_be_single_line(self): - paras = list(parse([ + paras = parse([ 'INT. SOMEWHERE - DAY', 'ANOTHER LINE', '', 'Some action', - ])) + ]) self.assertEquals([Dialog, Action], [type(p) for p in paras]) # What looks like a scene headingis parsed as a character name. # Unexpected perhaps, but that's how I interpreted the spec. @@ -40,10 +40,10 @@ class SlugTests(TestCase): self.assertEquals([plain('Some action')], paras[1].lines) def test_action_is_not_a_slug(self): - paras = list(parse([ + paras = parse([ '', 'THIS IS JUST ACTION', - ])) + ]) self.assertEquals([Action], [type(p) for p in paras]) def test_two_lines_creates_no_slug(self): @@ -151,25 +151,25 @@ class DialogTests(TestCase): # Fountain would not be able to support a character named "23". We # might need a syntax to force a character element. def test_nonalpha_character(self): - paras = list(parse([ + paras = parse([ '23', 'Hello', - ])) + ]) self.assertEquals([Action], [type(p) for p in paras]) def test_twospaced_line_is_not_character(self): - paras = list(parse([ + paras = parse([ 'SCANNING THE AISLES... ', 'Where is that pit boss?', - ])) + ]) self.assertEquals([Action], [type(p) for p in paras]) def test_simple_parenthetical(self): - paras = list(parse([ + paras = parse([ 'STEEL', '(starting the engine)', 'So much for retirement!', - ])) + ]) self.assertEquals(1, len(paras)) dialog = paras[0] self.assertEqual(2, len(dialog.blocks)) @@ -183,12 +183,12 @@ class DialogTests(TestCase): ) def test_twospace_keeps_dialog_together(self): - paras = list(parse([ + paras = parse([ 'SOMEONE', 'One', ' ', 'Two', - ])) + ]) self.assertEquals([Dialog], [type(p) for p in paras]) self.assertEquals([ (False, plain('One')), @@ -197,13 +197,13 @@ class DialogTests(TestCase): ], paras[0].blocks) def test_dual_dialog(self): - paras = list(parse([ + paras = parse([ 'BRICK', 'Fuck retirement.', '', 'STEEL ^', 'Fuck retirement!', - ])) + ]) self.assertEquals([DualDialog], [type(p) for p in paras]) dual = paras[0] self.assertEquals(plain('BRICK'), dual.left.character) @@ -218,12 +218,12 @@ class DialogTests(TestCase): ) def test_dual_dialog_without_previous_dialog_is_ignored(self): - paras = list(parse([ + paras = parse([ 'Brick strolls down the street.', '', 'BRICK ^', 'Nice retirement.', - ])) + ]) self.assertEquals([Action, Dialog], [type(p) for p in paras]) dialog = paras[1] self.assertEqual(plain('BRICK ^'), dialog.character) @@ -232,13 +232,13 @@ class DialogTests(TestCase): ], dialog.blocks) def test_leading_and_trailing_spaces_in_dialog(self): - paras = list(parse([ + paras = parse([ 'JULIET', 'O Romeo, Romeo! wherefore art thou Romeo?', ' Deny thy father and refuse thy name; ', 'Or, if thou wilt not, be but sworn my love,', " And I'll no longer be a Capulet.", - ])) + ]) self.assertEquals([Dialog], [type(p) for p in paras]) self.assertEquals([ (False, plain(u'O Romeo, Romeo! wherefore art thou Romeo?')), @@ -251,84 +251,84 @@ class DialogTests(TestCase): class TransitionTests(TestCase): def test_standard_transition(self): - paras = list(parse([ + paras = parse([ 'Jack begins to argue vociferously in Vietnamese (?)', '', 'CUT TO:', '', "EXT. BRICK'S POOL - DAY", - ])) + ]) self.assertEquals([Action, Transition, Slug], [type(p) for p in paras]) def test_transition_must_end_with_to(self): - paras = list(parse([ + paras = parse([ 'CUT TOO:', '', "EXT. BRICK'S POOL - DAY", - ])) + ]) self.assertEquals([Action, Slug], [type(p) for p in paras]) def test_transition_needs_to_be_upper_case(self): - paras = list(parse([ + paras = parse([ 'Jack begins to argue vociferously in Vietnamese (?)', '', 'cut to:', '', "EXT. BRICK'S POOL - DAY", - ])) + ]) self.assertEquals([Action, Action, Slug], [type(p) for p in paras]) def test_not_a_transition_on_trailing_whitespace(self): - paras = list(parse([ + paras = parse([ 'Jack begins to argue vociferously in Vietnamese (?)', '', 'CUT TO: ', '', "EXT. BRICK'S POOL - DAY", - ])) + ]) self.assertEquals([Action, Action, Slug], [type(p) for p in paras]) def test_transition_does_not_have_to_be_followed_by_slug(self): # The "followed by slug" requirement is gone from the Jan 2012 spec - paras = list(parse([ + paras = parse([ 'Bill lights a cigarette.', '', 'CUT TO:', '', 'SOME GUY mowing the lawn.', - ])) + ]) self.assertEquals( [Action, Transition, Action], [type(p) for p in paras] ) def test_greater_than_sign_means_transition(self): - paras = list(parse([ + paras = parse([ 'Bill blows out the match.', '', '> FADE OUT.', '', '.DARKNESS', - ])) + ]) self.assertEquals([Action, Transition, Slug], [type(p) for p in paras]) self.assertEquals(plain('FADE OUT.'), paras[1].line) def test_centered_text_is_not_parsed_as_transition(self): - paras = list(parse([ + paras = parse([ 'Bill blows out the match.', '', '> THE END. <', '', 'bye!' - ])) + ]) self.assertEquals([Action, Action, Action], [type(p) for p in paras]) def test_transition_at_end(self): - paras = list(parse([ + paras = parse([ 'They stroll hand in hand down the street.', '', '> FADE OUT.', - ])) + ]) self.assertEquals([Action, Transition], [type(p) for p in paras]) self.assertEquals(plain('FADE OUT.'), paras[1].line) @@ -336,12 +336,12 @@ class TransitionTests(TestCase): class ActionTests(TestCase): def test_action_preserves_leading_whitespace(self): - paras = list(parse([ + paras = parse([ 'hello', '', ' two spaces', ' three spaces ', - ])) + ]) self.assertEquals([Action, Action], [type(p) for p in paras]) self.assertEquals( [ @@ -351,7 +351,7 @@ class ActionTests(TestCase): ) def test_single_centered_line(self): - paras = list(parse(['> center me! <'])) + paras = parse(['> center me! <']) self.assertEquals([Action], [type(p) for p in paras]) self.assertTrue(paras[0].centered) @@ -361,7 +361,7 @@ class ActionTests(TestCase): ' > second! <', '> third!< ', ] - paras = list(parse(lines)) + paras = parse(lines) self.assertEquals([Action], [type(p) for p in paras]) self.assertTrue(paras[0].centered) self.assertEquals([ @@ -371,11 +371,11 @@ class ActionTests(TestCase): ], paras[0].lines) def test_upper_case_centered_not_parsed_as_dialog(self): - paras = list(parse([ + paras = parse([ '> FIRST! <', ' > SECOND! <', '> THIRD! <', - ])) + ]) self.assertEquals([Action], [type(p) for p in paras]) self.assertTrue(paras[0].centered) @@ -385,7 +385,7 @@ class ActionTests(TestCase): '> second! <', 'third!', ] - paras = list(parse(lines)) + paras = parse(lines) self.assertEquals([Action], [type(p) for p in paras]) self.assertFalse(paras[0].centered) self.assertEquals([plain(line) for line in lines], paras[0].lines) @@ -393,12 +393,12 @@ class ActionTests(TestCase): class SynopsisTests(TestCase): def test_synopsis_after_slug_adds_synopsis_to_scene(self): - paras = list(parse([ + paras = parse([ "EXT. BRICK'S PATIO - DAY", '', "= Set up Brick & Steel's new life." '', - ])) + ]) self.assertEquals([Slug], [type(p) for p in paras]) self.assertEquals( "Set up Brick & Steel's new life.", @@ -406,11 +406,11 @@ class SynopsisTests(TestCase): ) def test_synopsis_in_section(self): - paras = list(parse([ + paras = parse([ '# section one', '', '= In which we get to know our characters' - ])) + ]) self.assertEquals([Section], [type(p) for p in paras]) self.assertEquals( 'In which we get to know our characters', @@ -418,11 +418,11 @@ class SynopsisTests(TestCase): ) def test_synopsis_syntax_parsed_as_literal(self): - paras = list(parse([ + paras = parse([ 'Some action', '', '= A line that just happens to look like a synopsis' - ])) + ]) self.assertEquals([Action, Action], [type(p) for p in paras]) self.assertEquals( [plain('= A line that just happens to look like a synopsis')], @@ -485,9 +485,9 @@ class TitlePageTests(TestCase): class PageBreakTests(TestCase): def test_page_break_is_parsed(self): - paras = list(parse([ + paras = parse([ '====', '', 'So here we go' - ])) + ]) self.assertEquals([PageBreak, Action], [type(p) for p in paras]) |