Skip to content

Commit 3538bb1

Browse files
authored
fix: support directory paths in save_lyrics() filename arg (closes #251) (#333)
The `filename` parameter of save_lyrics() now accepts a full or relative path (e.g. "output/my_song"). The parent directory is created automatically if it does not exist, using pathlib.Path.mkdir(parents=True, exist_ok=True). Sanitization is applied only to the final filename stem, so caller-supplied directory path components are preserved as-is. Also fixes a latent double-sanitize: the base implementation now passes sanitize=False when delegating to to_json()/to_text(), since the filename is already sanitized at that point. Refactored base.py to use pathlib.Path throughout, removing the os module.
1 parent 8db5fe1 commit 3538bb1

4 files changed

Lines changed: 74 additions & 19 deletions

File tree

lyricsgenius/types/base.py

Lines changed: 24 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import json
2-
import os
32
from abc import ABC, abstractmethod
3+
from pathlib import Path
44
from typing import Any
55

66
from ..utils import safe_unicode, sanitize_filename
@@ -26,7 +26,10 @@ def save_lyrics(
2626
2727
Args:
2828
filename (:obj:`str`, optional): Output filename, a string.
29-
If not specified, the result is returned as a string.
29+
May include a full or relative directory path (e.g.
30+
``"output/my_song"``). The directory will be created
31+
automatically if it does not exist.
32+
If not specified, a default name is used.
3033
extension (:obj:`str`, optional): Format of the file (`json` or `txt`).
3134
overwrite (:obj:`bool`, optional): Overwrites preexisting file if `True`.
3235
Otherwise prompts user for input.
@@ -45,17 +48,21 @@ def save_lyrics(
4548
msg = "extension must be JSON or TXT"
4649
assert (extension == "json") or (extension == "txt"), msg
4750

48-
# Standardize the extension
49-
filename, _ = os.path.splitext(filename)
50-
filename += "." + extension
51-
filename = sanitize_filename(filename) if sanitize else filename
51+
# Separate parent directory from stem so we sanitize only the filename
52+
# portion, then reconstruct the full path.
53+
p = Path(filename)
54+
stem = sanitize_filename(p.stem) if sanitize else p.stem
55+
p = p.with_name(stem + "." + extension)
56+
57+
# Create parent directory if needed (no-op when parent is cwd)
58+
p.parent.mkdir(parents=True, exist_ok=True)
5259

5360
# Check if file already exists
5461
write_file = False
55-
if overwrite or not os.path.isfile(filename):
62+
if overwrite or not p.is_file():
5663
write_file = True
5764
elif verbose:
58-
msg = f"{filename} already exists. Overwrite?\n(y/n): "
65+
msg = f"{p} already exists. Overwrite?\n(y/n): "
5966
if input(msg).lower() == "y":
6067
write_file = True
6168

@@ -67,12 +74,12 @@ def save_lyrics(
6774

6875
# Save the lyrics to a file
6976
if extension == "json":
70-
self.to_json(filename, ensure_ascii=ensure_ascii, sanitize=sanitize)
77+
self.to_json(str(p), ensure_ascii=ensure_ascii, sanitize=False)
7178
else:
72-
self.to_text(filename, sanitize=sanitize)
79+
self.to_text(str(p), sanitize=False)
7380

7481
if verbose:
75-
print(f"Wrote {safe_unicode(filename)}.")
82+
print(f"Wrote {safe_unicode(str(p))}.")
7683

7784
return None
7885

@@ -116,9 +123,10 @@ def to_json(
116123
return json.dumps(data, indent=1, ensure_ascii=ensure_ascii)
117124

118125
# Save Song object to a json file
119-
filename = sanitize_filename(filename) if sanitize else filename
120-
with open(filename, "w", encoding="utf-8") as ff:
121-
json.dump(data, ff, indent=4, ensure_ascii=ensure_ascii)
126+
p = Path(sanitize_filename(filename) if sanitize else filename)
127+
p.write_text(
128+
json.dumps(data, indent=4, ensure_ascii=ensure_ascii), encoding="utf-8"
129+
)
122130
return None
123131

124132
@property
@@ -156,9 +164,8 @@ def to_text(self, filename: str | None = None, sanitize: bool = True) -> str | N
156164
return self._text_data
157165

158166
# Save song lyrics to a text file
159-
filename = sanitize_filename(filename) if sanitize else filename
160-
with open(filename, "w", encoding="utf-8") as ff:
161-
ff.write(self._text_data)
167+
p = Path(sanitize_filename(filename) if sanitize else filename)
168+
p.write_text(self._text_data, encoding="utf-8")
162169
return None
163170

164171
def __repr__(self) -> str:

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
44

55
[project]
66
name = "lyricsgenius"
7-
version = "3.8.0"
7+
version = "3.9.0"
88
dependencies = ["beautifulsoup4>=4.12.3", "requests>=2.27.1"]
99
requires-python = ">=3.11"
1010
authors = [{ name = "John W. R. Miller", email = "john.w.millr+lg@gmail.com" }]

tests/test_song.py

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -135,3 +135,51 @@ def test_save_lyrics_txt(song_object: Song, tmp_path: Path) -> None:
135135
# Check that the file was written correctly
136136
assert filename.is_file(), filename
137137
assert filename.read_text() == song_object.lyrics, filename
138+
139+
140+
def test_save_lyrics_with_path_json(song_object: Song, tmp_path: Path) -> None:
141+
"""Test that save_lyrics creates the directory when a path is included in filename."""
142+
output_dir = tmp_path / "output" / "nested"
143+
song_object.save_lyrics(
144+
filename=str(output_dir / "test_song"),
145+
extension="json",
146+
overwrite=True,
147+
verbose=False,
148+
)
149+
150+
saved_file = output_dir / "test_song.json"
151+
assert saved_file.is_file(), f"Expected file at {saved_file}"
152+
content = saved_file.read_text()
153+
assert '"title": "Mocking the Tests"' in content, content
154+
assert '"artist": "Py Testerson"' in content, content
155+
156+
157+
def test_save_lyrics_with_path_txt(song_object: Song, tmp_path: Path) -> None:
158+
"""Test that save_lyrics creates the directory when a path is included in filename."""
159+
output_dir = tmp_path / "lyrics_output"
160+
song_object.save_lyrics(
161+
filename=str(output_dir / "test_song"),
162+
extension="txt",
163+
overwrite=True,
164+
verbose=False,
165+
)
166+
167+
saved_file = output_dir / "test_song.txt"
168+
assert saved_file.is_file(), f"Expected file at {saved_file}"
169+
assert saved_file.read_text() == song_object.lyrics
170+
171+
172+
def test_save_lyrics_path_creates_directory(song_object: Song, tmp_path: Path) -> None:
173+
"""Test that save_lyrics creates the parent directory if it doesn't exist."""
174+
new_dir = tmp_path / "brand_new_dir"
175+
assert not new_dir.exists()
176+
177+
song_object.save_lyrics(
178+
filename=str(new_dir / "out"),
179+
extension="json",
180+
overwrite=True,
181+
verbose=False,
182+
)
183+
184+
assert new_dir.is_dir()
185+
assert (new_dir / "out.json").is_file()

uv.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)