From c47b84b098a7c65103dd104ccbc05e075accf944 Mon Sep 17 00:00:00 2001 From: Olezhich Date: Fri, 9 Jan 2026 23:36:31 +0300 Subject: [PATCH 1/2] =?UTF-8?q?=D0=B0=20common=20base=20class=20for=20rem?= =?UTF-8?q?=20was=20added,=20from=20which=20rems=20for=20tracks=20and=20al?= =?UTF-8?q?bums=20are=20inherited?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- cuetools/models.py | 46 ++++++++++++++++++++++--------------- cuetools/parser/handlers.py | 2 +- cuetools/parser/parser.py | 22 +++++++++++++----- tests/conftest.py | 12 +++++----- tests/test_parser.py | 4 ++-- tests/test_types.py | 32 +++++++++++--------------- 6 files changed, 67 insertions(+), 51 deletions(-) diff --git a/cuetools/models.py b/cuetools/models.py index 67c0a32..9d1f6d8 100644 --- a/cuetools/models.py +++ b/cuetools/models.py @@ -7,6 +7,29 @@ from cuetools.types.title_case import TitleCase +class BaseRemData(BaseModel): + model_config = ConfigDict(validate_assignment=True) + + replaygain_gain: ReplayGainGain | None = Field( + default=None, description='Album replay gain, value format [-]a.bb dB' + ) + replaygain_peak: ReplayGainPeak | None = Field( + default=None, description='Album peak, value format c.dddddd' + ) + + +class AlbumRemData(BaseRemData): + genre: str | None = Field(default=None, description='Album genre') + date: int | None = Field(default=None, description='Album release date') + + def set_genre(self, genre: TitleCase) -> None: + """Set album genre with a Title Case validation using `TitleCase` class consructor for string""" + self.genre = genre + + +class TrackRemData(BaseRemData): ... + + class TrackData(BaseModel): """Represents a single track within a CUE sheet. @@ -22,6 +45,9 @@ class TrackData(BaseModel): ) title: str | None = Field(default=None, description='Track title') performer: str | None = Field(default=None, description='Track performer') + rem: TrackRemData = Field( + default_factory=TrackRemData, description='Track additional rem meta' + ) index00: FrameTime | None = Field( default=None, description="The index 00 (the end of the prev track), corresponds to the line like *'INDEX 00 00:00:00'*", @@ -44,22 +70,6 @@ def set_title(self, title: TitleCase) -> None: self.title = title -class RemData(BaseModel): - model_config = ConfigDict(validate_assignment=True) - genre: str | None = Field(default=None, description='Album genre') - date: int | None = Field(default=None, description='Album release date') - replaygain_album_gain: ReplayGainGain | None = Field( - default=None, description='Album replay gain, value format [-]a.bb dB' - ) - replaygain_album_peak: ReplayGainPeak | None = Field( - default=None, description='Album peak, value format c.dddddd' - ) - - def set_genre(self, genre: TitleCase) -> None: - """Set album genre with a Title Case validation using `TitleCase` class consructor for string""" - self.genre = genre - - class AlbumData(BaseModel): """Represents a parsed CUE sheet at the album level. @@ -69,8 +79,8 @@ class AlbumData(BaseModel): model_config = ConfigDict(validate_assignment=True) performer: str | None = Field(default=None, description='Album performer') title: str | None = Field(default=None, description='Album title') - rem: RemData = Field( - default_factory=RemData, description='Album additional rem meta' + rem: AlbumRemData = Field( + default_factory=AlbumRemData, description='Album additional rem meta' ) tracks: list[TrackData] = Field( default_factory=list[TrackData], description='Track list of this album' diff --git a/cuetools/parser/handlers.py b/cuetools/parser/handlers.py index 8d41750..5fba627 100644 --- a/cuetools/parser/handlers.py +++ b/cuetools/parser/handlers.py @@ -30,4 +30,4 @@ def title_case_handler( err_expected_comment, value.lexeme, value.pos, - ) + ) \ No newline at end of file diff --git a/cuetools/parser/parser.py b/cuetools/parser/parser.py index 6b3619f..f4029d4 100644 --- a/cuetools/parser/parser.py +++ b/cuetools/parser/parser.py @@ -130,14 +130,28 @@ def load_f_iter(cue: Iterator[str], strict_title_case: bool = False) -> AlbumDat ) case Token.REPLAYGAIN_ALBUM_GAIN: try: - album.rem.replaygain_album_gain = value.lexeme # type: ignore[assignment] + album.rem.replaygain_gain = value.lexeme # type: ignore[assignment] except ValueError as e: raise CueValidationError( current_line, line, value.pos, value.lexeme, e ) case Token.REPLAYGAIN_ALBUM_PEAK: try: - album.rem.replaygain_album_peak = value.lexeme # type: ignore[assignment] + album.rem.replaygain_peak = value.lexeme # type: ignore[assignment] + except ValueError as e: + raise CueValidationError( + current_line, line, value.pos, value.lexeme, e + ) + case Token.REPLAYGAIN_TRACK_GAIN: + try: + current_track.rem.replaygain_gain = value.lexeme # type: ignore[assignment] + except ValueError as e: + raise CueValidationError( + current_line, line, value.pos, value.lexeme, e + ) + case Token.REPLAYGAIN_TRACK_PEAK: + try: + album.rem.replaygain_peak = value.lexeme # type: ignore[assignment] except ValueError as e: raise CueValidationError( current_line, line, value.pos, value.lexeme, e @@ -146,10 +160,6 @@ def load_f_iter(cue: Iterator[str], strict_title_case: bool = False) -> AlbumDat ... case Token.COMMENT: ... - case Token.REPLAYGAIN_TRACK_GAIN: - ... - case Token.REPLAYGAIN_TRACK_PEAK: - ... case _: raise CueParseError( current_line, diff --git a/tests/conftest.py b/tests/conftest.py index 014e640..b607425 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -4,7 +4,7 @@ import pytest from cuetools import AlbumData, TrackData -from cuetools.models import RemData +from cuetools.models import AlbumRemData TAB = 2 @@ -152,7 +152,7 @@ def obj_sample_one_file_one_track_default() -> AlbumData: album = AlbumData( performer='the performer', title='the title of album', - rem=RemData(genre='rock', date=1969), + rem=AlbumRemData(genre='rock', date=1969), ) for i in range(1, 8): album.add_track( @@ -166,7 +166,7 @@ def obj_sample_one_file_one_track_strict() -> AlbumData: album = AlbumData( performer='The Performer', title='The Title Of Album', - rem=RemData(genre='Rock', date=1969), + rem=AlbumRemData(genre='Rock', date=1969), ) for i in range(1, 8): album.add_track( @@ -183,7 +183,7 @@ def obj_sample_one_file_many_tracks_default() -> AlbumData: album = AlbumData( performer='the performer', title='the title of album', - rem=RemData(genre='rock', date=1969), + rem=AlbumRemData(genre='rock', date=1969), ) for i in range(1, 8): album.add_track( @@ -199,7 +199,7 @@ def obj_sample_one_file_many_tracks_strict() -> AlbumData: album = AlbumData( performer='The Performer', title='The Title Of Album', - rem=RemData(genre='Rock', date=1969), + rem=AlbumRemData(genre='Rock', date=1969), ) for i in range(1, 8): album.add_track( @@ -222,7 +222,7 @@ def obj_sample_one_file_many_tracks() -> AlbumData: album = AlbumData( performer='The Performer', title='The Title Of Album', - rem=RemData(genre='Rock', date=1969), + rem=AlbumRemData(genre='Rock', date=1969), ) for i in range(1, 8): album.add_track( diff --git a/tests/test_parser.py b/tests/test_parser.py index 19c4a49..45a1bd6 100644 --- a/tests/test_parser.py +++ b/tests/test_parser.py @@ -3,7 +3,7 @@ import logging -from cuetools.models import AlbumData, RemData +from cuetools.models import AlbumData, AlbumRemData from cuetools.parser.errors import CueParseError, CueValidationError logger = logging.getLogger(__name__) @@ -114,7 +114,7 @@ def test_line_parsing(): cue = cuetools.loads(cue_sheet) logger.debug(cue) - assert cue == AlbumData(rem=RemData(genre='Blues', date=1969)) + assert cue == AlbumData(rem=AlbumRemData(genre='Blues', date=1969)) cue_sheet = """REM REPLAYGAIN_ALBUM_GAIN 12.5""" with pytest.raises(cuetools.CueValidationError) as e: diff --git a/tests/test_types.py b/tests/test_types.py index 5df1e72..f83da9b 100644 --- a/tests/test_types.py +++ b/tests/test_types.py @@ -2,7 +2,7 @@ from pydantic import ValidationError import pytest from cuetools import TrackData -from cuetools.models import AlbumData, RemData +from cuetools.models import AlbumData, AlbumRemData from cuetools.types.title_case import TitleCase @@ -22,41 +22,37 @@ def test_FrameTime(): def test_ReplayGain_gain(): - rem = RemData(replaygain_album_gain='17.84 dB') # type: ignore - assert rem.replaygain_album_gain == 17.84, ( - 'using string to ReplayGain gain cast, >0 case' - ) - rem = RemData(replaygain_album_gain='-17.84 dB') # type: ignore - assert rem.replaygain_album_gain == -17.84, ( + rem = AlbumRemData(replaygain_gain='17.84 dB') # type: ignore + assert rem.replaygain_gain == 17.84, 'using string to ReplayGain gain cast, >0 case' + rem = AlbumRemData(replaygain_gain='-17.84 dB') # type: ignore + assert rem.replaygain_gain == -17.84, ( 'using string to ReplayGain gain cast, <0 case' ) - rem = RemData(replaygain_album_gain=17.84) # type: ignore - assert rem.replaygain_album_gain == 17.84, ( - 'using float to ReplayGain gain cast, >0 case' - ) + rem = AlbumRemData(replaygain_gain=17.84) # type: ignore + assert rem.replaygain_gain == 17.84, 'using float to ReplayGain gain cast, >0 case' with pytest.raises(ValidationError): - RemData(replaygain_album_gain='7.8 dB') # type: ignore + AlbumRemData(replaygain_gain='7.8 dB') # type: ignore with pytest.raises(ValidationError): - RemData(replaygain_album_gain='0.824654') # type: ignore + AlbumRemData(replaygain_gain='0.824654') # type: ignore def test_ReplayGain_peak(): - rem = RemData(replaygain_album_peak='0.987654') # type: ignore - assert rem.replaygain_album_peak == 0.987654, ( + rem = AlbumRemData(replaygain_peak='0.987654') # type: ignore + assert rem.replaygain_peak == 0.987654, ( 'using string to ReplayGain peak cast, >0 case' ) with pytest.raises(ValidationError): - RemData(replaygain_album_peak='0.0023') # type: ignore + AlbumRemData(replaygain_peak='0.0023') # type: ignore with pytest.raises(ValidationError): - RemData(replaygain_album_peak='0.0001112') # type: ignore + AlbumRemData(replaygain_peak='0.0001112') # type: ignore with pytest.raises(ValidationError): - RemData(replaygain_album_peak='-0.001122') # type: ignore + AlbumRemData(replaygain_peak='-0.001122') # type: ignore def test_TitleCase(): From ab94b4c30cded27fa2becb1fbc6f2a7f220334e2 Mon Sep 17 00:00:00 2001 From: Olezhich Date: Sat, 10 Jan 2026 00:24:28 +0300 Subject: [PATCH 2/2] full parsing of track replaygain fields has been added --- CHANGELOG.md | 8 ++++++ cuetools/parser/handlers.py | 2 +- cuetools/parser/parser.py | 18 +++++++++++- pyproject.toml | 2 +- tests/conftest.py | 56 +++++++++++++++++++++++++++++-------- tests/test_parser.py | 2 ++ 6 files changed, 73 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0165779..24922f5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,14 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ## [Unreleased] +## Added +- `BaseRemData` class from which the corresponding classes for albums and tracks are inherited. +- Full parsing of track replaygain fields. + +### Changed +- Type of album rem data: `AlbumRemData` instead `RemData`. +- Field names of the album replaygain on: `replaygain_gain` and `replaygain_peak` instead `replaygain_album_gain` and `replaygain_album_peak`. + ### Fixed - Parsing of title case fields with the *strict_title_case* flag; now, when an error occurs, an `CueValidationError` is thrown instead of an `ValueError`. - `CHANGELOG.md` markup. diff --git a/cuetools/parser/handlers.py b/cuetools/parser/handlers.py index 5fba627..8d41750 100644 --- a/cuetools/parser/handlers.py +++ b/cuetools/parser/handlers.py @@ -30,4 +30,4 @@ def title_case_handler( err_expected_comment, value.lexeme, value.pos, - ) \ No newline at end of file + ) diff --git a/cuetools/parser/parser.py b/cuetools/parser/parser.py index f4029d4..946f057 100644 --- a/cuetools/parser/parser.py +++ b/cuetools/parser/parser.py @@ -143,6 +143,14 @@ def load_f_iter(cue: Iterator[str], strict_title_case: bool = False) -> AlbumDat current_line, line, value.pos, value.lexeme, e ) case Token.REPLAYGAIN_TRACK_GAIN: + if not current_track: + raise CueParseError( + current_line, + line, + 'any TRACK tag before', + tokens[0].lexeme, + tokens[1].pos, + ) try: current_track.rem.replaygain_gain = value.lexeme # type: ignore[assignment] except ValueError as e: @@ -150,8 +158,16 @@ def load_f_iter(cue: Iterator[str], strict_title_case: bool = False) -> AlbumDat current_line, line, value.pos, value.lexeme, e ) case Token.REPLAYGAIN_TRACK_PEAK: + if not current_track: + raise CueParseError( + current_line, + line, + 'any TRACK tag before', + tokens[0].lexeme, + tokens[1].pos, + ) try: - album.rem.replaygain_peak = value.lexeme # type: ignore[assignment] + current_track.rem.replaygain_peak = value.lexeme # type: ignore[assignment] except ValueError as e: raise CueValidationError( current_line, line, value.pos, value.lexeme, e diff --git a/pyproject.toml b/pyproject.toml index 194bd32..6497688 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "cuetools" -version = "1.0.3" +version = "1.1.0" description = "Lightweight CUE sheet toolkit for Python" authors = [ {name = "Olezhich"} diff --git a/tests/conftest.py b/tests/conftest.py index b607425..cc28ccd 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -4,13 +4,18 @@ import pytest from cuetools import AlbumData, TrackData -from cuetools.models import AlbumRemData +from cuetools.models import AlbumRemData, TrackRemData TAB = 2 def track_gen( - filename: str, songname: str, tracks_count: int = 1, tab: int = TAB + filename: str, + songname: str, + gain: float, + peak: float, + tracks_count: int = 1, + tab: int = TAB, ) -> str: out = f'''FILE "{filename}" {'WAVE' if filename.endswith('.flac') else ''}\n''' filename = filename.split('.')[0] @@ -23,6 +28,8 @@ def track_gen( s = '0' + s if len(s) < 2 else s out += f'''{' ' * tab}TRACK {s} AUDIO {' ' * 2 * tab}TITLE "{songname}" +{' ' * 2 * tab}REM REPLAYGAIN_TRACK_GAIN {gain:.2f} dB +{' ' * 2 * tab}REM REPLAYGAIN_TRACK_PEAK {peak:.6f} {' ' * 2 * tab}INDEX 01 00:00:00\n''' suf += 1 if out.endswith('\n'): @@ -88,12 +95,16 @@ def album_rem_strict(request: Any) -> list[str]: @pytest.fixture() def album_tracks_default() -> list[str]: - return [track_gen(f'0{i} - Song 0{i}.flac', f'song {i}') for i in range(1, 8)] + return [ + track_gen(f'0{i} - Song 0{i}.flac', f'song {i}', 10.0, 0.9) for i in range(1, 8) + ] @pytest.fixture() def album_tracks_strict() -> list[str]: - return [track_gen(f'0{i} - Song 0{i}.flac', f'Song {i}') for i in range(1, 8)] + return [ + track_gen(f'0{i} - Song 0{i}.flac', f'Song {i}', 10.0, 0.9) for i in range(1, 8) + ] # Cues @@ -129,7 +140,7 @@ def cue_sample_one_file_many_tracks_default( album_meta_default: list[str], album_rem_default: list[str] ) -> str: cuesheet = album_meta_default + album_rem_default - cuesheet += [track_gen('The Title Of Album.flac', 'track title', 7)] + cuesheet += [track_gen('The Title Of Album.flac', 'track title', 10.0, 0.9, 7)] return '\n'.join(cuesheet) @@ -138,7 +149,7 @@ def cue_sample_one_file_many_tracks_strict( album_meta_strict: list[str], album_rem_strict: list[str] ) -> str: cuesheet = album_meta_strict + album_rem_strict - cuesheet += [track_gen('The Title Of Album.flac', 'Track Title', 7)] + cuesheet += [track_gen('The Title Of Album.flac', 'Track Title', 10.0, 0.9, 7)] return '\n'.join(cuesheet) @@ -156,7 +167,12 @@ def obj_sample_one_file_one_track_default() -> AlbumData: ) for i in range(1, 8): album.add_track( - TrackData(track=i, title=f'song {i}', file=Path(f'0{i} - Song 0{i}.flac')) + TrackData( + track=i, + title=f'song {i}', + file=Path(f'0{i} - Song 0{i}.flac'), + rem=TrackRemData(replaygain_gain=10.0, replaygain_peak=0.9), + ) ) return album @@ -170,7 +186,12 @@ def obj_sample_one_file_one_track_strict() -> AlbumData: ) for i in range(1, 8): album.add_track( - TrackData(track=i, title=f'Song {i}', file=Path(f'0{i} - Song 0{i}.flac')) + TrackData( + track=i, + title=f'Song {i}', + file=Path(f'0{i} - Song 0{i}.flac'), + rem=TrackRemData(replaygain_gain=10.0, replaygain_peak=0.9), + ) ) return album @@ -188,7 +209,10 @@ def obj_sample_one_file_many_tracks_default() -> AlbumData: for i in range(1, 8): album.add_track( TrackData( - track=i, title='track title', file=Path('The Title Of Album.flac') + track=i, + title='track title', + file=Path('The Title Of Album.flac'), + rem=TrackRemData(replaygain_gain=10.0, replaygain_peak=0.9), ) ) return album @@ -204,7 +228,10 @@ def obj_sample_one_file_many_tracks_strict() -> AlbumData: for i in range(1, 8): album.add_track( TrackData( - track=i, title='Track Title', file=Path('The Title Of Album.flac') + track=i, + title='Track Title', + file=Path('The Title Of Album.flac'), + rem=TrackRemData(replaygain_gain=10.0, replaygain_peak=0.9), ) ) return album @@ -213,7 +240,7 @@ def obj_sample_one_file_many_tracks_strict() -> AlbumData: @pytest.fixture() def cue_sample_one_file_many_tracks() -> str: cuesheet = album_rem_default() + album_meta_default() - cuesheet += [track_gen('The Title Of Album.flac', 'song_name', 7)] + cuesheet += [track_gen('The Title Of Album.flac', 'song_name', 10.0, 0.9, 7)] return '\n'.join(cuesheet) @@ -226,7 +253,12 @@ def obj_sample_one_file_many_tracks() -> AlbumData: ) for i in range(1, 8): album.add_track( - TrackData(track=i, title='song_name', file=Path('The Title Of Album.flac')) + TrackData( + track=i, + title='song_name', + file=Path('The Title Of Album.flac'), + rem=TrackRemData(replaygain_gain=10.0, replaygain_peak=0.9), + ) ) return album diff --git a/tests/test_parser.py b/tests/test_parser.py index 45a1bd6..831840d 100644 --- a/tests/test_parser.py +++ b/tests/test_parser.py @@ -61,6 +61,8 @@ def test_real_cue(self, cue_sample_real: str): assert res.performer == 'Scorpions', ( 'Thats correct if it can parse the entire file without throwing an errors' ) + assert res.tracks[3].rem.replaygain_gain == -6.73 + assert res.tracks[3].rem.replaygain_peak == 1.035224 def test_line_parsing():