songs2slides

A tool that automatically finds song lyrics and creates lyric slideshows
git clone https://git.ashermorgan.net/songs2slides/
Log | Files | Refs | README

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:
Asongs2slides/core.py | 181+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Dsongs2slides/utils.py | 149-------------------------------------------------------------------------------
Mtests/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')