commit c9fecc50380af0d1bc1842817edc37fbf70f1288
parent 0c9b8b113cbd3d934c4d5107aa48be3c32fe8a82
Author: ashermorgan <59518073+ashermorgan@users.noreply.github.com>
Date: Wed, 21 Feb 2024 11:33:05 -0800
Implement assemble_slides function
Diffstat:
| A | songs2slides/core.py | | | 181 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ |
| D | songs2slides/utils.py | | | 149 | ------------------------------------------------------------------------------- |
| M | tests/test_core.py | | | 158 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------------- |
3 files changed, 312 insertions(+), 176 deletions(-)
diff --git a/songs2slides/core.py b/songs2slides/core.py
@@ -0,0 +1,181 @@
+from dataclasses import dataclass
+from dotenv import load_dotenv
+import pptx
+from pptx.dml.color import RGBColor
+from pptx.enum.text import MSO_ANCHOR, PP_ALIGN
+from pptx.util import Inches, Pt
+import os
+import requests
+
+@dataclass
+class SongData:
+ """
+ Represents data about a song
+
+ Attributes
+ ----------
+ title : str
+ The title of the song
+ artist : str
+ The artist of the song
+ lyrics : str
+ The song's lyrics, with double newlines separating stanzas
+ """
+
+ title: str
+ artist: str
+ lyrics: str
+
+def get_song_data(title: str, artist:str):
+ """
+ Get song data from an external API
+
+ Parameters
+ ----------
+ title : str
+ The title of the song
+ artist : str
+ The artist of the song
+
+ Returns
+ -------
+ SongData
+ The song data
+ """
+
+ url = os.getenv('API_URL')
+ if url is None:
+ raise Exception()
+ url = url.replace('{title}', title, 1)
+ url = url.replace('{artist}', artist, 1)
+ data = requests.get(url).json()
+
+ if 'lyrics' in data.keys():
+ return SongData(data['title'], data['artist'], data['lyrics'])
+ else:
+ raise Exception()
+
+def parse_song_lyrics(lyrics: str, lines_per_slide: int = 4):
+ """
+ Parse slide contents from the raw lyrics of a song
+
+ Used by assemble_slides
+
+ Parameters
+ ----------
+ lyrics : str
+ The song lyrics
+ lines_per_slide : int
+ The maximum number of lines per slide (the default is 4)
+
+ Returns
+ -------
+ list of str
+ The list of slide contents
+ """
+
+ slides = ['']
+ line_count = 0
+
+ for line in lyrics.split('\n'):
+ line = line.strip()
+
+ if line == '':
+ # Empty line represents new slide
+ slides += ['']
+ line_count = 0
+
+ elif line_count < lines_per_slide:
+ # Add line to current slide
+ if line_count != 0: slides[-1] += '\n'
+ slides[-1] += line
+ line_count += 1
+
+ else:
+ # Overflow to new slide
+ slides += [line]
+ line_count = 1
+
+ # Remove first/last slide if empty
+ # len(slides) is always greater than 1 or single slide is not empty
+ if slides[0] == '': slides = slides[1:]
+ if slides[-1] == '': slides = slides[:-1]
+
+ return slides
+
+def assemble_slides(songs, title_slides = True, blank_slides = True):
+ """
+ Assemble slides from a list of songs
+
+ Paramters
+ ---------
+ songs : list of SongData
+ The songs
+ title_slides : bool
+ Whether to include title slides before songs (default: True)
+ blank_slides : bool
+ Whether to include blank slides between songs (default: True)
+
+ Returns
+ -------
+ list of str
+ The list of slide contents
+ """
+
+ slides = []
+ for song in songs:
+ # Add slides for song
+ if title_slides: slides += [f'{song.title}']
+ slides += parse_song_lyrics(song.lyrics.upper())
+ if blank_slides: slides += ['']
+
+ # Remove trailing blank slides
+ if len(slides) and blank_slides: slides = slides[:-1]
+
+ return slides
+
+def create_pptx(slide_contents: list[str], filepath: str):
+ """
+ Create a PowerPoint from a list of slide contents
+
+ Parameters
+ ----------
+ slide_contents : list of str
+ The list of slide contents
+ filepath : str
+ The file to save the PowerPoint to
+ """
+
+ # Create presentation
+ prs = pptx.Presentation()
+
+ # Get blank slide template
+ blank_slide_layout = prs.slide_layouts[6]
+
+ # Get textbox size parameters
+ margin = Inches(1)
+ width = prs.slide_width - Inches(2)
+ height = prs.slide_height - Inches(2)
+
+ for slide_content in slide_contents:
+ # Create and format slide
+ slide = prs.slides.add_slide(blank_slide_layout)
+ slide.background.fill.solid()
+ slide.background.fill.fore_color.rgb = RGBColor.from_string('000000')
+
+ # Create and format textbox
+ textbox = slide.shapes.add_textbox(margin, margin, width, height)
+ textbox.text_frame.vertical_anchor = MSO_ANCHOR.MIDDLE
+ textbox.text_frame.word_wrap = True
+
+ # Format paragraph
+ paragraph = textbox.text_frame.paragraphs[0]
+ paragraph.font.color.rgb = RGBColor.from_string('ffffff')
+ paragraph.font.size = Pt(48)
+ paragraph.alignment = PP_ALIGN.CENTER
+
+ # Add slide content
+ paragraph.text = slide_content
+
+ # Save to file
+ prs.save(filepath)
diff --git a/songs2slides/utils.py b/songs2slides/utils.py
@@ -1,149 +0,0 @@
-from dotenv import load_dotenv
-
-from dataclasses import dataclass
-import pptx
-from pptx.dml.color import RGBColor
-from pptx.enum.text import MSO_ANCHOR, PP_ALIGN
-from pptx.util import Inches, Pt
-import os
-import requests
-
-@dataclass
-class SongData:
- """
- Represents data about a song
-
- Attributes
- ----------
- title : str
- The title of the song
- artist : str
- The artist of the song
- lyrics : str
- The song's lyrics, with double newlines separating stanzas
- """
-
- title: str
- artist: str
- lyrics: str
-
-def get_song_data(title: str, artist:str):
- """
- Get song data from an external API
-
- Parameters
- ----------
- title : str
- The title of the song
- artist : str
- The artist of the song
-
- Returns
- -------
- SongData
- The song data
- """
-
- url = os.getenv('API_URL')
- if url is None:
- raise Exception()
- url = url.replace('{title}', title, 1)
- url = url.replace('{artist}', artist, 1)
- data = requests.get(url).json()
-
- if 'lyrics' in data.keys():
- return SongData(data['title'], data['artist'], data['lyrics'])
- else:
- raise Exception()
-
-def get_slide_contents(lyrics: str, lines_per_slide: int = 4):
- """
- Generate slide contents from song lyrics
-
- Parameters
- ----------
- lyrics : str
- The song lyrics
- lines_per_slide : int
- The maximum number of lines per slide (the default is 4)
-
- Returns
- -------
- list of str
- The list of slide contents
- """
-
- slides = ['']
- line_count = 0
-
- for line in lyrics.split('\n'):
- line = line.strip()
-
- if line == '':
- # Empty line represents new slide
- slides += ['']
- line_count = 0
-
- elif line_count < lines_per_slide:
- # Add line to current slide
- if line_count != 0: slides[-1] += '\n'
- slides[-1] += line
- line_count += 1
-
- else:
- # Overflow to new slide
- slides += [line]
- line_count = 1
-
- # Remove first/last slide if empty
- # len(slides) is always greater than 1 or single slide is not empty
- if slides[0] == '': slides = slides[1:]
- if slides[-1] == '': slides = slides[:-1]
-
- return slides
-
-def create_pptx(slide_contents: list[str], filepath: str):
- """
- Create a PowerPoint from a list of slide contents
-
- Parameters
- ----------
- slide_contents : list of str
- The list of slide contents
- filepath : str
- The file to save the PowerPoint to
- """
-
- # Create presentation
- prs = pptx.Presentation()
-
- # Get blank slide template
- blank_slide_layout = prs.slide_layouts[6]
-
- # Get textbox size parameters
- margin = Inches(1)
- width = prs.slide_width - Inches(2)
- height = prs.slide_height - Inches(2)
-
- for slide_content in slide_contents:
- # Create and format slide
- slide = prs.slides.add_slide(blank_slide_layout)
- slide.background.fill.solid()
- slide.background.fill.fore_color.rgb = RGBColor.from_string('000000')
-
- # Create and format textbox
- textbox = slide.shapes.add_textbox(margin, margin, width, height)
- textbox.text_frame.vertical_anchor = MSO_ANCHOR.MIDDLE
- textbox.text_frame.word_wrap = True
-
- # Format paragraph
- paragraph = textbox.text_frame.paragraphs[0]
- paragraph.font.color.rgb = RGBColor.from_string('ffffff')
- paragraph.font.size = Pt(48)
- paragraph.alignment = PP_ALIGN.CENTER
-
- # Add slide content
- paragraph.text = slide_content
-
- # Save to file
- prs.save(filepath)
diff --git a/tests/test_core.py b/tests/test_core.py
@@ -1,12 +1,12 @@
import unittest
from unittest.mock import patch
-from songs2slides import utils
+from songs2slides import core
-class TestUtils(unittest.TestCase):
+class TestCore(unittest.TestCase):
def test_get_song_data_success(self):
- with patch('songs2slides.utils.os.getenv') as mocked_env, \
- patch('songs2slides.utils.requests.get') as mocked_get:
+ with patch('songs2slides.core.os.getenv') as mocked_env, \
+ patch('songs2slides.core.requests.get') as mocked_get:
# Mock os.getenv and requests.get
mocked_env.return_value = 'api://lyrics/{artist}/{title}'
@@ -19,7 +19,7 @@ class TestUtils(unittest.TestCase):
mocked_get.return_value.status_code = 200
# Get song data
- song_data = utils.get_song_data('foo', 'bar')
+ song_data = core.get_song_data('foo', 'bar')
# Assert song data is correct
mocked_get.assert_called_with('api://lyrics/bar/foo')
@@ -28,8 +28,8 @@ class TestUtils(unittest.TestCase):
self.assertEqual(song_data.lyrics, 'A\nB\nC\nD')
def test_get_song_data_no_url(self):
- with patch('songs2slides.utils.os.getenv') as mocked_env, \
- patch('songs2slides.utils.requests.get') as mocked_get:
+ with patch('songs2slides.core.os.getenv') as mocked_env, \
+ patch('songs2slides.core.requests.get') as mocked_get:
# Mock os.getenv and requests.get
mocked_env.return_value = None
@@ -38,14 +38,14 @@ class TestUtils(unittest.TestCase):
# Try to get song data
with self.assertRaises(Exception):
- song_data = utils.get_song_data('foo', 'bar')
+ song_data = core.get_song_data('foo', 'bar')
# Assert request was not called
mocked_get.assert_not_called()
def test_get_song_data_not_found(self):
- with patch('songs2slides.utils.os.getenv') as mocked_env, \
- patch('songs2slides.utils.requests.get') as mocked_get:
+ with patch('songs2slides.core.os.getenv') as mocked_env, \
+ patch('songs2slides.core.requests.get') as mocked_get:
# Mock os.getenv and requests.get
mocked_env.return_value = 'api://lyrics/{artist}/{title}'
@@ -54,85 +54,189 @@ class TestUtils(unittest.TestCase):
# Try to get song data
with self.assertRaises(Exception):
- song_data = utils.get_song_data('foo', 'bar')
+ song_data = core.get_song_data('foo', 'bar')
# Assert request was called
mocked_get.assert_called_with('api://lyrics/bar/foo')
- def test_get_slide_contents_default(self):
+ def test_parse_song_lyrics_default(self):
# Declare song data and expected slides
lyrics = 'A\nB\nC\nD\nE\nF\n\nG\nH'
expected = ['A\nB\nC\nD', 'E\nF', 'G\nH']
# Get slide content
- result = utils.get_slide_contents(lyrics)
+ result = core.parse_song_lyrics(lyrics)
+ # Assert slides are correct
self.assertEqual(result, expected)
- def test_get_slide_contents_3_lines_per_slide(self):
+ def test_parse_song_lyrics_3_lines_per_slide(self):
# Declare song data and expected slides
lyrics = 'A\nB\nC\nD\nE\nF\n\nG\nH'
expected = ['A\nB\nC', 'D\nE\nF', 'G\nH']
# Get slide content
- result = utils.get_slide_contents(lyrics, lines_per_slide = 3)
+ result = core.parse_song_lyrics(lyrics, lines_per_slide = 3)
+ # Assert slides are correct
self.assertEqual(result, expected)
- def test_get_slide_contents_empty_string(self):
+ def test_parse_song_lyrics_empty_string(self):
# Declare song data and expected slides
lyrics = ''
expected = []
# Get slide content
- result = utils.get_slide_contents(lyrics, lines_per_slide = 3)
+ result = core.parse_song_lyrics(lyrics, lines_per_slide = 3)
+ # Assert slides are correct
self.assertEqual(result, expected)
- def test_get_slide_contents_one_line(self):
+ def test_parse_song_lyrics_one_line(self):
# Declare song data and expected slides
lyrics = 'A'
expected = ['A']
# Get slide content
- result = utils.get_slide_contents(lyrics)
+ result = core.parse_song_lyrics(lyrics)
+ # Assert slides are correct
self.assertEqual(result, expected)
- def test_get_slide_contents_one_slide(self):
+ def test_parse_song_lyrics_one_slide(self):
# Declare song data and expected slides
lyrics = 'A\nB\nC\nD'
expected = ['A\nB\nC\nD']
# Get slide content
- result = utils.get_slide_contents(lyrics)
+ result = core.parse_song_lyrics(lyrics)
+ # Assert slides are correct
self.assertEqual(result, expected)
- def test_get_slide_contents_tripple_newlines(self):
+ def test_parse_song_lyrics_tripple_newlines(self):
# Declare song data and expected slides
lyrics = 'A\nB\n\n\nC\nD'
expected = ['A\nB', '', 'C\nD']
# Get slide content
- result = utils.get_slide_contents(lyrics)
+ result = core.parse_song_lyrics(lyrics)
+ # Assert slides are correct
self.assertEqual(result, expected)
- def test_get_slide_contents_extra_whitespace(self):
+ def test_parse_song_lyrics_extra_whitespace(self):
# Declare song data and expected slides
lyrics = ' A\n B \nC D\nE '
expected = ['A\nB\nC D\nE']
# Get slide content
- result = utils.get_slide_contents(lyrics)
+ result = core.parse_song_lyrics(lyrics)
+ # Assert slides are correct
self.assertEqual(result, expected)
+ def test_assemble_slides_calls_parse_song_lyrics(self):
+ with patch('songs2slides.core.parse_song_lyrics') as mocked_parse:
+
+ # Mock parse_song_lyrics
+ mocked_parse.side_effect = [['aaa'], ['b1', 'b2']]
+
+ # Declare song data and expected slides
+ songs = [
+ core.SongData('T1', 'A1', 'l1'),
+ core.SongData('T2', 'A3', 'l2'),
+ ]
+ expected = ['T1', 'aaa', '', 'T2', 'b1', 'b2']
+
+ # Get slides
+ slides = core.assemble_slides(songs)
+
+ # Assert slides are correct
+ self.assertEqual(slides, expected)
+
+ def test_assemble_slides_default(self):
+ # Declare song data and expected slides
+ songs = [
+ core.SongData('T1', 'A1', 'l1\nl2\nl3\nl4\nl5'),
+ core.SongData('T2', 'A3', 'L6\nL7\n\nL8\n\n\nL9'),
+ ]
+ expected = [
+ 'T1', 'L1\nL2\nL3\nL4', 'L5', '',
+ 'T2', 'L6\nL7', 'L8', '', 'L9',
+ ]
+
+ # Get slides
+ slides = core.assemble_slides(songs)
+
+ # Assert slides are correct
+ self.assertEqual(slides, expected)
+
+ def test_assemble_slides_no_title_slides(self):
+ # Declare song data and expected slides
+ songs = [
+ core.SongData('T1', 'A1', 'l1\nl2\nl3\nl4\nl5'),
+ core.SongData('T2', 'A3', 'L6\nL7\n\nL8\n\n\nL9'),
+ ]
+ expected = [
+ 'L1\nL2\nL3\nL4', 'L5', '',
+ 'L6\nL7', 'L8', '', 'L9',
+ ]
+
+ # Get slides
+ slides = core.assemble_slides(songs, title_slides = False)
+
+ # Assert slides are correct
+ self.assertEqual(slides, expected)
+
+ def test_assemble_slides_no_blank_slides(self):
+ # Declare song data and expected slides
+ songs = [
+ core.SongData('T1', 'A1', 'l1\nl2\nl3\nl4\nl5'),
+ core.SongData('T2', 'A3', 'L6\nL7\n\nL8\n\n\nL9'),
+ ]
+ expected = [
+ 'T1', 'L1\nL2\nL3\nL4', 'L5',
+ 'T2', 'L6\nL7', 'L8', '', 'L9',
+ ]
+
+ # Get slides
+ slides = core.assemble_slides(songs, blank_slides = False)
+
+ # Assert slides are correct
+ self.assertEqual(slides, expected)
+
+ def test_assemble_slides_no_extra_slides(self):
+ # Declare song data and expected slides
+ songs = [
+ core.SongData('T1', 'A1', 'l1\nl2\nl3\nl4\nl5'),
+ core.SongData('T2', 'A3', 'L6\nL7\n\nL8\n\n\nL9'),
+ ]
+ expected = [
+ 'L1\nL2\nL3\nL4', 'L5',
+ 'L6\nL7', 'L8', '', 'L9',
+ ]
+
+ # Get slides
+ slides = core.assemble_slides(songs, title_slides = False, blank_slides = False)
+
+ # Assert slides are correct
+ self.assertEqual(slides, expected)
+
+ def test_assemble_slides_no_songs(self):
+ # Declare expected slides
+ expected = []
+
+ # Get slides
+ slides = core.assemble_slides([])
+
+ # Assert slides are correct
+ self.assertEqual(slides, expected)
+
def test_create_pptx(self):
- with patch('songs2slides.utils.pptx.presentation.Presentation.save') as mocked_save:
+ with patch('songs2slides.core.pptx.presentation.Presentation.save') as mocked_save:
# Create PowerPoint
- utils.create_pptx(['A', 'B\nC', 'D'], 'test.pptx')
+ core.create_pptx(['A', 'B\nC', 'D'], 'test.pptx')
# Assert PowerPoint was saved
mocked_save.assert_called_with('test.pptx')