diff --git a/.flake8 b/.flake8 index 5123bc0..4295e73 100644 --- a/.flake8 +++ b/.flake8 @@ -32,6 +32,6 @@ per-file-ignores = example*.py: F405, F403 # Silence weird false positive on inline comments ... jubeatools/formats/jubeat_analyser/symbols.py: E262 - # there's a field named "l" in a marshmallow schema - jubeatools/formats/memon/memon.py: E741 + # there's a field named "l" in a marshmallow schema and I don't want te rename it + jubeatools/formats/memon/v0.py: E741 max-line-length = 120 \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index d985645..1595cfb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,7 @@ +# v1.3.0 +## Added +- [memon] 🎉 v0.3.0 support + # v1.2.3 ## Fixed - Loaders and Dumpers would recieve options with unwanted default values when diff --git a/README.md b/README.md index ddbfd43..37ed3a2 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,8 @@ jubeatools ${source} ${destination} -f ${output format} (... format specific opt ## Which formats are supported | | | input | output | |-----------------|----------------------|:-----:|:------:| -| memon | v0.2.0 | ✔️ | ✔️ | +| memon | v0.3.0 | ✔️ | ✔️ | +| | v0.2.0 | ✔️ | ✔️ | | | v0.1.0 | ✔️ | ✔️ | | | legacy | ✔️ | ✔️ | | jubeat analyser | #memo2 | ✔️ | ✔️ | diff --git a/docs/repo maintenance.md b/docs/repo maintenance.md index 0bd6c29..5584871 100644 --- a/docs/repo maintenance.md +++ b/docs/repo maintenance.md @@ -18,7 +18,8 @@ Sanity checks before anything serious happens, from the repo's root : Now that this is done you can move on to actually making a new version, while still being in the repo's root : 1. Update `CHANGELOG.md` -1. Commit everything you want in the new release, including the changelog +1. Update `README.md` if you've just added support for a new format +1. Commit everything you want in the new release 1. Run the script
`$ poetry run python utils/bump_version.py {rule}` `{rule}` will usually be one of `patch`, `minor` or `major`. But it can be anything `poetry version` handles. diff --git a/jubeatools/formats/dump_tools.py b/jubeatools/formats/dump_tools.py index 17baaa4..6b409c9 100644 --- a/jubeatools/formats/dump_tools.py +++ b/jubeatools/formats/dump_tools.py @@ -1,9 +1,10 @@ import string +from functools import singledispatch from itertools import count from pathlib import Path -from typing import AbstractSet, Any, Dict, Iterator, TypedDict +from typing import AbstractSet, Any, Dict, Iterator, Optional, TypedDict -from jubeatools.formats.filetypes import ChartFile +from jubeatools.formats.filetypes import ChartFile, JubeatFile, SongFile from jubeatools.formats.typing import ChartFileDumper, Dumper from jubeatools.song import Difficulty, Song from jubeatools.utils import none_or @@ -31,17 +32,12 @@ def make_dumper_from_chart_file_dumper( def dumper(song: Song, path: Path, **kwargs: Any) -> Dict[Path, bytes]: res: Dict[Path, bytes] = {} - if path.is_dir(): - file_path = file_name_template - parent = path - else: - file_path = path - parent = path.parent - - name_format = f"{file_path.stem}{{dedup}}{file_path.suffix}" + name_format = FileNameFormat(file_name_template, suggestion=path) files = internal_dumper(song, **kwargs) for chartfile in files: - filepath = choose_file_path(chartfile, name_format, parent, res.keys()) + filepath = name_format.available_filename_for( + chartfile, already_chosen=res.keys() + ) res[filepath] = chartfile.contents return res @@ -49,28 +45,6 @@ def make_dumper_from_chart_file_dumper( return dumper -def choose_file_path( - chart_file: ChartFile, - name_format: str, - parent: Path, - already_chosen: AbstractSet[Path], -) -> Path: - all_paths = iter_possible_paths(chart_file, name_format, parent) - not_on_filesystem = filter(lambda p: not p.exists(), all_paths) - not_already_chosen = filter(lambda p: p not in already_chosen, not_on_filesystem) - return next(not_already_chosen) - - -def iter_possible_paths( - chart_file: ChartFile, name_format: str, parent: Path -) -> Iterator[Path]: - for dedup_index in count(start=0): - params = extract_format_params(chart_file, dedup_index) - formatter = BetterStringFormatter() - filename = formatter.format(name_format, **params).strip() - yield parent / filename - - class FormatParameters(TypedDict, total=False): title: str # uppercase BSC ADV EXT @@ -82,13 +56,64 @@ class FormatParameters(TypedDict, total=False): dedup: str -def extract_format_params(chartfile: ChartFile, dedup_index: int) -> FormatParameters: +class FileNameFormat: + def __init__(self, file_name_template: Path, suggestion: Path): + if suggestion.is_dir(): + file_path = file_name_template + self.parent = suggestion + else: + file_path = suggestion + self.parent = suggestion.parent + + self.name_format = f"{file_path.stem}{{dedup}}{file_path.suffix}" + + def available_filename_for( + self, file: JubeatFile, already_chosen: Optional[AbstractSet[Path]] = None + ) -> Path: + fixed_params = extract_format_params(file) + return next(self.iter_possible_paths(fixed_params, already_chosen)) + + def iter_possible_paths( + self, + fixed_params: FormatParameters, + already_chosen: Optional[AbstractSet[Path]] = None, + ) -> Iterator[Path]: + all_paths = self.iter_deduped_paths(fixed_params) + not_on_filesystem = (p for p in all_paths if not p.exists()) + if already_chosen is not None: + yield from (p for p in not_on_filesystem if p not in already_chosen) + else: + yield from not_on_filesystem + + def iter_deduped_paths(self, params: FormatParameters) -> Iterator[Path]: + for dedup_index in count(start=0): + # TODO Remove the type ignore once this issue is fixed + # https://github.com/python/mypy/issues/6019 + params.update( # type: ignore[call-arg] + dedup="" if dedup_index == 0 else f"-{dedup_index}" + ) + formatter = BetterStringFormatter() + filename = formatter.format(self.name_format, **params).strip() + yield self.parent / filename + + +@singledispatch +def extract_format_params(file: JubeatFile) -> FormatParameters: + ... + + +@extract_format_params.register +def extract_song_format_params(songfile: SongFile) -> FormatParameters: + return FormatParameters(title=none_or(slugify, songfile.song.metadata.title) or "") + + +@extract_format_params.register +def extract_chart_format_params(chartfile: ChartFile) -> FormatParameters: return FormatParameters( title=none_or(slugify, chartfile.song.metadata.title) or "", difficulty=slugify(chartfile.difficulty), difficulty_index=str(DIFFICULTY_INDEX.get(chartfile.difficulty, 2)), difficulty_number=str(DIFFICULTY_NUMBER.get(chartfile.difficulty, 3)), - dedup="" if dedup_index == 0 else f"-{dedup_index}", ) diff --git a/jubeatools/formats/enum.py b/jubeatools/formats/enum.py index 2681ad1..96fb76a 100644 --- a/jubeatools/formats/enum.py +++ b/jubeatools/formats/enum.py @@ -8,6 +8,7 @@ class Format(str, Enum): MEMON_LEGACY = "memon:legacy" MEMON_0_1_0 = "memon:v0.1.0" MEMON_0_2_0 = "memon:v0.2.0" + MEMON_0_3_0 = "memon:v0.3.0" MONO_COLUMN = "mono-column" MEMO = "memo" MEMO_1 = "memo1" diff --git a/jubeatools/formats/formats.py b/jubeatools/formats/formats.py index 3efcd53..b43a487 100644 --- a/jubeatools/formats/formats.py +++ b/jubeatools/formats/formats.py @@ -1,50 +1,33 @@ from typing import Dict +from . import jubeat_analyser, konami, malody, memon from .enum import Format -from .jubeat_analyser import ( - dump_memo, - dump_memo1, - dump_memo2, - dump_mono_column, - load_memo, - load_memo1, - load_memo2, - load_mono_column, -) -from .konami import dump_eve, dump_jbsq, load_eve, load_jbsq -from .malody import dump_malody, load_malody -from .memon import ( - dump_memon_0_1_0, - dump_memon_0_2_0, - dump_memon_legacy, - load_memon_0_1_0, - load_memon_0_2_0, - load_memon_legacy, -) from .typing import Dumper, Loader LOADERS: Dict[Format, Loader] = { - Format.EVE: load_eve, - Format.JBSQ: load_jbsq, - Format.MALODY: load_malody, - Format.MEMON_LEGACY: load_memon_legacy, - Format.MEMON_0_1_0: load_memon_0_1_0, - Format.MEMON_0_2_0: load_memon_0_2_0, - Format.MONO_COLUMN: load_mono_column, - Format.MEMO: load_memo, - Format.MEMO_1: load_memo1, - Format.MEMO_2: load_memo2, + Format.EVE: konami.load_eve, + Format.JBSQ: konami.load_jbsq, + Format.MALODY: malody.load_malody, + Format.MEMON_LEGACY: memon.load_memon_legacy, + Format.MEMON_0_1_0: memon.load_memon_0_1_0, + Format.MEMON_0_2_0: memon.load_memon_0_2_0, + Format.MEMON_0_3_0: memon.load_memon_0_3_0, + Format.MONO_COLUMN: jubeat_analyser.load_mono_column, + Format.MEMO: jubeat_analyser.load_memo, + Format.MEMO_1: jubeat_analyser.load_memo1, + Format.MEMO_2: jubeat_analyser.load_memo2, } DUMPERS: Dict[Format, Dumper] = { - Format.EVE: dump_eve, - Format.JBSQ: dump_jbsq, - Format.MALODY: dump_malody, - Format.MEMON_LEGACY: dump_memon_legacy, - Format.MEMON_0_1_0: dump_memon_0_1_0, - Format.MEMON_0_2_0: dump_memon_0_2_0, - Format.MONO_COLUMN: dump_mono_column, - Format.MEMO: dump_memo, - Format.MEMO_1: dump_memo1, - Format.MEMO_2: dump_memo2, + Format.EVE: konami.dump_eve, + Format.JBSQ: konami.dump_jbsq, + Format.MALODY: malody.dump_malody, + Format.MEMON_LEGACY: memon.dump_memon_legacy, + Format.MEMON_0_1_0: memon.dump_memon_0_1_0, + Format.MEMON_0_2_0: memon.dump_memon_0_2_0, + Format.MEMON_0_3_0: memon.dump_memon_0_3_0, + Format.MONO_COLUMN: jubeat_analyser.dump_mono_column, + Format.MEMO: jubeat_analyser.dump_memo, + Format.MEMO_1: jubeat_analyser.dump_memo1, + Format.MEMO_2: jubeat_analyser.dump_memo2, } diff --git a/jubeatools/formats/guess.py b/jubeatools/formats/guess.py index 6b1542a..689400c 100644 --- a/jubeatools/formats/guess.py +++ b/jubeatools/formats/guess.py @@ -53,6 +53,8 @@ def recognize_memon_version(obj: dict) -> Format: return Format.MEMON_0_1_0 elif version == "0.2.0": return Format.MEMON_0_2_0 + elif version == "0.3.0": + return Format.MEMON_0_3_0 else: raise ValueError(f"Unsupported memon version : {version}") diff --git a/jubeatools/formats/jubeat_analyser/dump_tools.py b/jubeatools/formats/jubeat_analyser/dump_tools.py index 48e9d5d..cdfcc16 100644 --- a/jubeatools/formats/jubeat_analyser/dump_tools.py +++ b/jubeatools/formats/jubeat_analyser/dump_tools.py @@ -210,7 +210,8 @@ def create_sections_from_chart( header = sections[BeatsTime(0)].commands header["o"] = int(timing.beat_zero_offset * 1000) - header["lev"] = Decimal(chart.level) + if chart.level is not None: + header["lev"] = Decimal(chart.level) header["dif"] = DIFFICULTY_NUMBER.get(difficulty, 3) if metadata.audio is not None: header["m"] = metadata.audio diff --git a/jubeatools/formats/jubeat_analyser/memo2/dump.py b/jubeatools/formats/jubeat_analyser/memo2/dump.py index d28730c..03eef67 100644 --- a/jubeatools/formats/jubeat_analyser/memo2/dump.py +++ b/jubeatools/formats/jubeat_analyser/memo2/dump.py @@ -299,7 +299,8 @@ def _dump_memo2_chart( file.write(f"// https://github.com/Stepland/jubeatools\n\n") # Header - file.write(dump_command("lev", Decimal(chart.level)) + "\n") + if chart.level is not None: + file.write(dump_command("lev", Decimal(chart.level)) + "\n") file.write(dump_command("dif", DIFFICULTY_NUMBER.get(difficulty, 1)) + "\n") if metadata.audio is not None: file.write(dump_command("m", metadata.audio) + "\n") diff --git a/jubeatools/formats/load_tools.py b/jubeatools/formats/load_tools.py index 991fdb8..148eabb 100644 --- a/jubeatools/formats/load_tools.py +++ b/jubeatools/formats/load_tools.py @@ -27,7 +27,9 @@ class FolderLoader(Protocol[T]): ... -def make_folder_loader(glob_pattern: str, file_loader: FileLoader) -> FolderLoader: +def make_folder_loader( + glob_pattern: str, file_loader: FileLoader[T] +) -> FolderLoader[T]: def folder_loader(path: Path) -> Dict[Path, T]: files: Dict[Path, T] = {} if path.is_dir(): diff --git a/jubeatools/formats/malody/load.py b/jubeatools/formats/malody/load.py index ca7fe15..2ddf8d3 100644 --- a/jubeatools/formats/malody/load.py +++ b/jubeatools/formats/malody/load.py @@ -9,7 +9,7 @@ import simplejson as json from jubeatools import song from jubeatools.formats import timemap -from jubeatools.formats.load_tools import make_folder_loader +from jubeatools.formats.load_tools import FolderLoader, make_folder_loader from jubeatools.utils import none_or from . import schema as malody @@ -26,7 +26,7 @@ def load_file(path: Path) -> Any: return json.load(f, use_decimal=True) -load_folder = make_folder_loader("*.mc", load_file) +load_folder: FolderLoader[Any] = make_folder_loader("*.mc", load_file) def load_malody_file(raw_dict: dict) -> song.Song: diff --git a/jubeatools/formats/memon/__init__.py b/jubeatools/formats/memon/__init__.py index aa2da9f..2b4782f 100644 --- a/jubeatools/formats/memon/__init__.py +++ b/jubeatools/formats/memon/__init__.py @@ -8,11 +8,13 @@ parse than existing "memo-like" formats (memo, youbeat, etc ...). https://github.com/Stepland/memon """ -from .memon import ( +from .v0 import ( dump_memon_0_1_0, dump_memon_0_2_0, + dump_memon_0_3_0, dump_memon_legacy, load_memon_0_1_0, load_memon_0_2_0, + load_memon_0_3_0, load_memon_legacy, ) diff --git a/jubeatools/formats/memon/test_memon.py b/jubeatools/formats/memon/test_memon.py index 2fe3b0a..6a9b1b9 100644 --- a/jubeatools/formats/memon/test_memon.py +++ b/jubeatools/formats/memon/test_memon.py @@ -1,36 +1,12 @@ -import tempfile -from pathlib import Path from typing import Set import hypothesis.strategies as st from hypothesis import given from jubeatools import song -from jubeatools.formats.typing import Dumper, Loader +from jubeatools.formats.enum import Format from jubeatools.testutils import strategies as jbst - -from . import ( - dump_memon_0_1_0, - dump_memon_0_2_0, - dump_memon_legacy, - load_memon_0_1_0, - load_memon_0_2_0, - load_memon_legacy, -) - - -def dump_and_load( - expected_song: song.Song, dump_function: Dumper, load_function: Loader -) -> None: - with tempfile.NamedTemporaryFile(mode="wb") as file: - files = dump_function(expected_song, Path(file.name)) - assert len(files) == 1 - filename, contents = list(files.items())[0] - file.write(contents) - file.seek(0) - actual_song = load_function(Path(file.name)) - - assert actual_song == expected_song +from jubeatools.testutils.test_patterns import dump_and_load_then_compare @st.composite @@ -56,6 +32,11 @@ def memon_legacy_compatible_song(draw: st.DrawFn) -> song.Song: diffs_strat=memon_diffs(), chart_strat=jbst.chart(timing_strat=st.none()), common_timing_strat=jbst.timing_info(with_bpm_changes=False), + metadata_strat=jbst.metadata( + text_strat=st.text( + alphabet=st.characters(blacklist_categories=("Cc", "Cs")), + ), + ), ) ) random_song.metadata.preview = None @@ -65,7 +46,7 @@ def memon_legacy_compatible_song(draw: st.DrawFn) -> song.Song: @given(memon_legacy_compatible_song()) def test_memon_legacy(song: song.Song) -> None: - dump_and_load(song, dump_memon_legacy, load_memon_legacy) + dump_and_load_then_compare(Format.MEMON_LEGACY, song) memon_0_1_0_compatible_song = memon_legacy_compatible_song @@ -73,7 +54,7 @@ memon_0_1_0_compatible_song = memon_legacy_compatible_song @given(memon_0_1_0_compatible_song()) def test_memon_0_1_0(song: song.Song) -> None: - dump_and_load(song, dump_memon_0_1_0, load_memon_0_1_0) + dump_and_load_then_compare(Format.MEMON_0_1_0, song) @st.composite @@ -84,6 +65,11 @@ def memon_0_2_0_compatible_song(draw: st.DrawFn) -> song.Song: diffs_strat=memon_diffs(), chart_strat=jbst.chart(timing_strat=st.none()), common_timing_strat=jbst.timing_info(with_bpm_changes=False), + metadata_strat=jbst.metadata( + text_strat=st.text( + alphabet=st.characters(blacklist_categories=("Cc", "Cs")), + ), + ), ) ) random_song.metadata.preview_file = None @@ -92,4 +78,25 @@ def memon_0_2_0_compatible_song(draw: st.DrawFn) -> song.Song: @given(memon_0_2_0_compatible_song()) def test_memon_0_2_0(song: song.Song) -> None: - dump_and_load(song, dump_memon_0_2_0, load_memon_0_2_0) + dump_and_load_then_compare(Format.MEMON_0_2_0, song) + + +@st.composite +def memon_0_3_0_compatible_song(draw: st.DrawFn) -> song.Song: + return draw( + jbst.song( + diffs_strat=memon_diffs(), + chart_strat=jbst.chart(timing_strat=st.none()), + common_timing_strat=jbst.timing_info(with_bpm_changes=False), + metadata_strat=jbst.metadata( + text_strat=st.text( + alphabet=st.characters(blacklist_categories=("Cc", "Cs")), + ), + ), + ) + ) + + +@given(memon_0_3_0_compatible_song()) +def test_memon_0_3_0(song: song.Song) -> None: + dump_and_load_then_compare(Format.MEMON_0_3_0, song) diff --git a/jubeatools/formats/memon/memon.py b/jubeatools/formats/memon/v0.py similarity index 69% rename from jubeatools/formats/memon/memon.py rename to jubeatools/formats/memon/v0.py index b5b266e..c050acd 100644 --- a/jubeatools/formats/memon/memon.py +++ b/jubeatools/formats/memon/v0.py @@ -1,7 +1,8 @@ +from functools import reduce from io import StringIO from itertools import chain from pathlib import Path -from typing import Any, Dict, List, Union +from typing import Any, Callable, Dict, List, Union import simplejson as json from marshmallow import ( @@ -15,7 +16,11 @@ from marshmallow import ( from multidict import MultiDict from jubeatools import song as jbt -from jubeatools.utils import lcm +from jubeatools.formats.dump_tools import FileNameFormat +from jubeatools.formats.filetypes import SongFile +from jubeatools.formats.load_tools import FolderLoader, make_folder_loader +from jubeatools.formats.typing import Dumper, Loader +from jubeatools.utils import lcm, none_or # v0.x.x long note value : # @@ -102,6 +107,12 @@ class MemonMetadata_0_2_0(MemonMetadata_0_1_0): preview = fields.Nested(MemonPreview) +class MemonMetadata_0_3_0(MemonMetadata_0_2_0): + audio = fields.String(required=False, data_key="music path") + cover = fields.String(required=False, data_key="album cover path") + preview_path = fields.String(data_key="preview path") + + class Memon_legacy(StrictSchema): metadata = fields.Nested(MemonMetadata_legacy, required=True) data = fields.Nested(MemonChart_legacy, required=True, many=True) @@ -123,15 +134,39 @@ class Memon_0_2_0(StrictSchema): ) -def _load_raw_memon(file: Path) -> Dict[str, Any]: - with open(file) as f: - res = json.load(f, use_decimal=True) - if not isinstance(res, dict): +class Memon_0_3_0(StrictSchema): + version = fields.String(required=True, validate=validate.OneOf(["0.3.0"])) + metadata = fields.Nested(MemonMetadata_0_3_0, required=True) + data = fields.Dict( + keys=fields.String(), values=fields.Nested(MemonChart_0_1_0), required=True + ) + + +def _load_raw_memon(path: Path) -> Any: + with path.open() as f: + return json.load(f, use_decimal=True) + + +load_folder: FolderLoader[Any] = make_folder_loader("*.memon", _load_raw_memon) + + +def make_folder_loader_with_optional_merge( + memon_loader: Callable[[Any], jbt.Song] +) -> Loader: + def load(path: Path, merge: bool = False, **kwargs: Any) -> jbt.Song: + files = load_folder(path) + if not merge and len(files) > 1: raise ValueError( - "JSON file does not represent a valid memon file : " - "The top level of a memon file should be a JSON Object" + "Multiple .memon files were found in the given folder, " + "use the --merge option if you want to make a single memon file " + "out of several that each containt a different chart (or set of " + "charts) for the same song" ) - return res + + charts = [memon_loader(d) for d in files.values()] + return reduce(jbt.Song.merge, charts) + + return load def _load_memon_note_v0( @@ -149,8 +184,7 @@ def _load_memon_note_v0( return jbt.TapNote(time, position) -def load_memon_legacy(path: Path, **kwargs: Any) -> jbt.Song: - raw_memon = _load_raw_memon(path) +def _load_memon_legacy(raw_memon: Any) -> jbt.Song: schema = Memon_legacy() memon = schema.load(raw_memon) metadata = jbt.Metadata( @@ -179,8 +213,10 @@ def load_memon_legacy(path: Path, **kwargs: Any) -> jbt.Song: return jbt.Song(metadata=metadata, charts=charts, common_timing=common_timing) -def load_memon_0_1_0(path: Path, **kwargs: Any) -> jbt.Song: - raw_memon = _load_raw_memon(path) +load_memon_legacy = make_folder_loader_with_optional_merge(_load_memon_legacy) + + +def _load_memon_0_1_0(raw_memon: Any) -> jbt.Song: schema = Memon_0_1_0() memon = schema.load(raw_memon) metadata = jbt.Metadata( @@ -209,8 +245,10 @@ def load_memon_0_1_0(path: Path, **kwargs: Any) -> jbt.Song: return jbt.Song(metadata=metadata, charts=charts, common_timing=common_timing) -def load_memon_0_2_0(path: Path, **kwargs: Any) -> jbt.Song: - raw_memon = _load_raw_memon(path) +load_memon_0_1_0 = make_folder_loader_with_optional_merge(_load_memon_0_1_0) + + +def _load_memon_0_2_0(raw_memon: Any) -> jbt.Song: schema = Memon_0_2_0() memon = schema.load(raw_memon) preview = None @@ -246,6 +284,49 @@ def load_memon_0_2_0(path: Path, **kwargs: Any) -> jbt.Song: return jbt.Song(metadata=metadata, charts=charts, common_timing=common_timing) +load_memon_0_2_0 = make_folder_loader_with_optional_merge(_load_memon_0_2_0) + + +def _load_memon_0_3_0(raw_memon: Any) -> jbt.Song: + schema = Memon_0_3_0() + memon = schema.load(raw_memon) + preview = None + if "preview" in memon["metadata"]: + start = memon["metadata"]["preview"]["position"] + length = memon["metadata"]["preview"]["length"] + preview = jbt.Preview(start, length) + + metadata = jbt.Metadata( + title=memon["metadata"]["title"], + artist=memon["metadata"]["artist"], + audio=none_or(Path, memon["metadata"].get("audio")), + cover=none_or(Path, memon["metadata"].get("cover")), + preview=preview, + preview_file=none_or(Path, memon["metadata"].get("preview_path")), + ) + common_timing = jbt.Timing( + events=[jbt.BPMEvent(time=jbt.BeatsTime(0), BPM=memon["metadata"]["BPM"])], + beat_zero_offset=jbt.SecondsTime(-memon["metadata"]["offset"]), + ) + charts: MultiDict[jbt.Chart] = MultiDict() + for difficulty, memon_chart in memon["data"].items(): + charts.add( + difficulty, + jbt.Chart( + level=memon_chart["level"], + notes=[ + _load_memon_note_v0(note, memon_chart["resolution"]) + for note in memon_chart["notes"] + ], + ), + ) + + return jbt.Song(metadata=metadata, charts=charts, common_timing=common_timing) + + +load_memon_0_3_0 = make_folder_loader_with_optional_merge(_load_memon_0_3_0) + + def _long_note_tail_value_v0(note: jbt.LongNote) -> int: dx = note.tail_tip.x - note.position.x dy = note.tail_tip.y - note.position.y @@ -267,7 +348,6 @@ def _get_timing(song: jbt.Song) -> jbt.Timing: def _raise_if_unfit_for_v0(song: jbt.Song, version: str) -> None: - """Raises an exception if the Song object is ill-formed or contains information that cannot be represented in a memon v0.x.y file (includes legacy)""" @@ -347,8 +427,7 @@ def _dump_memon_note_v0( return memon_note -def dump_memon_legacy(song: jbt.Song, path: Path, **kwargs: dict) -> Dict[Path, bytes]: - +def _dump_memon_legacy(song: jbt.Song) -> SongFile: _raise_if_unfit_for_v0(song, "legacy") timing = _get_timing(song) @@ -379,16 +458,23 @@ def dump_memon_legacy(song: jbt.Song, path: Path, **kwargs: dict) -> Dict[Path, } ) - if path.is_dir(): - filepath = path / f"{song.metadata.title}.memon" - else: - filepath = path - - return {filepath: _dump_to_json(memon)} + return SongFile(contents=_dump_to_json(memon), song=song) -def dump_memon_0_1_0(song: jbt.Song, path: Path, **kwargs: dict) -> Dict[Path, bytes]: +def make_memon_dumper(internal_dumper: Callable[[jbt.Song], SongFile]) -> Dumper: + def dump(song: jbt.Song, path: Path, **kwargs: dict) -> Dict[Path, bytes]: + name_format = FileNameFormat(Path("{title}.memon"), suggestion=path) + songfile = internal_dumper(song) + filepath = name_format.available_filename_for(songfile) + return {filepath: songfile.contents} + return dump + + +dump_memon_legacy = make_memon_dumper(_dump_memon_legacy) + + +def _dump_memon_0_1_0(song: jbt.Song) -> SongFile: _raise_if_unfit_for_v0(song, "v0.1.0") timing = _get_timing(song) @@ -415,15 +501,13 @@ def dump_memon_0_1_0(song: jbt.Song, path: Path, **kwargs: dict) -> Dict[Path, b ], } - if path.is_dir(): - filepath = path / f"{song.metadata.title}.memon" - else: - filepath = path - - return {filepath: _dump_to_json(memon)} + return SongFile(contents=_dump_to_json(memon), song=song) -def dump_memon_0_2_0(song: jbt.Song, path: Path, **kwargs: dict) -> Dict[Path, bytes]: +dump_memon_0_1_0 = make_memon_dumper(_dump_memon_0_1_0) + + +def _dump_memon_0_2_0(song: jbt.Song) -> SongFile: _raise_if_unfit_for_v0(song, "v0.2.0") timing = _get_timing(song) @@ -458,9 +542,55 @@ def dump_memon_0_2_0(song: jbt.Song, path: Path, **kwargs: dict) -> Dict[Path, b ], } - if path.is_dir(): - filepath = path / f"{song.metadata.title}.memon" - else: - filepath = path + return SongFile(contents=_dump_to_json(memon), song=song) - return {filepath: _dump_to_json(memon)} + +dump_memon_0_2_0 = make_memon_dumper(_dump_memon_0_2_0) + + +def _dump_memon_0_3_0(song: jbt.Song) -> SongFile: + + _raise_if_unfit_for_v0(song, "v0.3.0") + timing = _get_timing(song) + + memon: Dict[str, Any] = { + "version": "0.3.0", + "metadata": { + "song title": song.metadata.title, + "artist": song.metadata.artist, + "BPM": timing.events[0].BPM, + "offset": -timing.beat_zero_offset, + }, + "data": {}, + } + + if song.metadata.audio is not None: + memon["metadata"]["music path"] = str(song.metadata.audio) + + if song.metadata.cover is not None: + memon["metadata"]["album cover path"] = str(song.metadata.cover) + + if song.metadata.preview is not None: + memon["metadata"]["preview"] = { + "position": song.metadata.preview.start, + "length": song.metadata.preview.length, + } + + if song.metadata.preview_file is not None: + memon["metadata"]["preview path"] = str(song.metadata.preview_file) + + for difficulty, chart in song.charts.items(): + resolution = _compute_resolution(chart.notes) + memon["data"][difficulty] = { + "level": chart.level, + "resolution": resolution, + "notes": [ + _dump_memon_note_v0(note, resolution) + for note in sorted(set(chart.notes), key=lambda n: (n.time, n.position)) + ], + } + + return SongFile(contents=_dump_to_json(memon), song=song) + + +dump_memon_0_3_0 = make_memon_dumper(_dump_memon_0_3_0) diff --git a/jubeatools/formats/tests/test_decimal_to_beats.py b/jubeatools/formats/tests/test_decimal_to_beats.py index 2d96a4a..c7733f5 100644 --- a/jubeatools/formats/tests/test_decimal_to_beats.py +++ b/jubeatools/formats/tests/test_decimal_to_beats.py @@ -1,17 +1,15 @@ from decimal import Decimal from fractions import Fraction -import pytest - from ..load_tools import round_beats -@pytest.mark.parametrize("numerator", range(240)) -def test_fraction_recovery_after_rounding_to_three_decimals(numerator: int) -> None: - fraction = Fraction(numerator, 240) - decimal = numerator / Decimal(240) - rounded = round(decimal, 3) - text_form = str(rounded) - re_parsed_decimal = Decimal(text_form) - result = round_beats(re_parsed_decimal) - assert fraction == result +def test_fraction_recovery_after_rounding_to_three_decimals() -> None: + for numerator in range(240): + fraction = Fraction(numerator, 240) + decimal = numerator / Decimal(240) + rounded = round(decimal, 3) + text_form = str(rounded) + re_parsed_decimal = Decimal(text_form) + result = round_beats(re_parsed_decimal) + assert fraction == result diff --git a/jubeatools/song.py b/jubeatools/song.py index e442635..fdfcb1e 100644 --- a/jubeatools/song.py +++ b/jubeatools/song.py @@ -180,7 +180,7 @@ class Timing: @dataclass class Chart: - level: Decimal + level: Optional[Decimal] timing: Optional[Timing] = None notes: List[Union[TapNote, LongNote]] = field(default_factory=list) diff --git a/jubeatools/testutils/test_patterns.py b/jubeatools/testutils/test_patterns.py index 176265b..4c73d7c 100644 --- a/jubeatools/testutils/test_patterns.py +++ b/jubeatools/testutils/test_patterns.py @@ -20,7 +20,7 @@ def open_temp_dir() -> Iterator[Path]: def dump_and_load_then_compare( format_: Format, song: song.Song, - bytes_decoder: Callable[[bytes], str], + bytes_decoder: Callable[[bytes], str] = lambda b: b.decode("utf-8"), temp_path: Callable[[], ContextManager[Path]] = open_temp_dir, load_options: Optional[dict] = None, dump_options: Optional[dict] = None, diff --git a/poetry.lock b/poetry.lock index bf26bfa..04c7311 100644 --- a/poetry.lock +++ b/poetry.lock @@ -498,6 +498,14 @@ category = "dev" optional = false python-versions = "*" +[[package]] +name = "types-toml" +version = "0.10.1" +description = "Typing stubs for toml" +category = "dev" +optional = false +python-versions = "*" + [[package]] name = "typing-extensions" version = "3.10.0.2" @@ -521,7 +529,7 @@ typing-extensions = ">=3.7.4" [metadata] lock-version = "1.1" python-versions = "^3.8" -content-hash = "667e5000f1e328f2d07a86946ad1423912c875c0abcc7efb97a72aec38fdb916" +content-hash = "025ff93ec3b033c89bdaaeab7905dc10ff450ed70695bcbd863a10e6af6fa9ea" [metadata.files] atomicwrites = [ @@ -864,6 +872,10 @@ types-simplejson = [ {file = "types-simplejson-3.17.1.tar.gz", hash = "sha256:310ef5addfe97c20b9f2cac85079cbab95fd123c2c9a5c6b99856075d6b48211"}, {file = "types_simplejson-3.17.1-py3-none-any.whl", hash = "sha256:7b5ac5549f5269f25e5e932d6f4e96ea8397369d6031300161cd1fda484e2534"}, ] +types-toml = [ + {file = "types-toml-0.10.1.tar.gz", hash = "sha256:5c1f8f8d57692397c8f902bf6b4d913a0952235db7db17d2908cc110e70610cb"}, + {file = "types_toml-0.10.1-py3-none-any.whl", hash = "sha256:8cdfd2b7c89bed703158b042dd5cf04255dae77096db66f4a12ca0a93ccb07a5"}, +] typing-extensions = [ {file = "typing_extensions-3.10.0.2-py2-none-any.whl", hash = "sha256:d8226d10bc02a29bcc81df19a26e56a9647f8b0a6d4a83924139f4a8b01f17b7"}, {file = "typing_extensions-3.10.0.2-py3-none-any.whl", hash = "sha256:f1d25edafde516b146ecd0613dabcc61409817af4766fbbcfb8d1ad4ec441a34"}, diff --git a/pyproject.toml b/pyproject.toml index 6a71b39..d56d959 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -32,6 +32,7 @@ toml = "^0.10.2" flake8 = "^3.9.1" autoimport = "^0.7.0" types-simplejson = "^3.17.1" +types-toml = "^0.10.1" [tool.poetry.scripts] jubeatools = 'jubeatools.cli.cli:convert'