1
0
mirror of synced 2024-12-04 19:17:55 +01:00

Add support for memon 0.3.0

This commit is contained in:
Stepland 2021-12-22 01:40:53 +01:00
parent 93ada9fd31
commit 2e4b5978bd
20 changed files with 336 additions and 165 deletions

View File

@ -32,6 +32,6 @@ per-file-ignores =
example*.py: F405, F403 example*.py: F405, F403
# Silence weird false positive on inline comments ... # Silence weird false positive on inline comments ...
jubeatools/formats/jubeat_analyser/symbols.py: E262 jubeatools/formats/jubeat_analyser/symbols.py: E262
# there's a field named "l" in a marshmallow schema # there's a field named "l" in a marshmallow schema and I don't want te rename it
jubeatools/formats/memon/memon.py: E741 jubeatools/formats/memon/v0.py: E741
max-line-length = 120 max-line-length = 120

View File

@ -1,3 +1,7 @@
# v1.3.0
## Added
- [memon] 🎉 v0.3.0 support
# v1.2.3 # v1.2.3
## Fixed ## Fixed
- Loaders and Dumpers would recieve options with unwanted default values when - Loaders and Dumpers would recieve options with unwanted default values when

View File

@ -16,7 +16,8 @@ jubeatools ${source} ${destination} -f ${output format} (... format specific opt
## Which formats are supported ## Which formats are supported
| | | input | output | | | | input | output |
|-----------------|----------------------|:-----:|:------:| |-----------------|----------------------|:-----:|:------:|
| memon | v0.2.0 | ✔️ | ✔️ | | memon | v0.3.0 | ✔️ | ✔️ |
| | v0.2.0 | ✔️ | ✔️ |
| | v0.1.0 | ✔️ | ✔️ | | | v0.1.0 | ✔️ | ✔️ |
| | legacy | ✔️ | ✔️ | | | legacy | ✔️ | ✔️ |
| jubeat analyser | #memo2 | ✔️ | ✔️ | | jubeat analyser | #memo2 | ✔️ | ✔️ |

View File

@ -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, Now that this is done you can move on to actually making a new version,
while still being in the repo's root : while still being in the repo's root :
1. Update `CHANGELOG.md` 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 <br> `$ poetry run python utils/bump_version.py {rule}` 1. Run the script <br> `$ 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. `{rule}` will usually be one of `patch`, `minor` or `major`. But it can be anything `poetry version` handles.

View File

@ -1,9 +1,10 @@
import string import string
from functools import singledispatch
from itertools import count from itertools import count
from pathlib import Path 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.formats.typing import ChartFileDumper, Dumper
from jubeatools.song import Difficulty, Song from jubeatools.song import Difficulty, Song
from jubeatools.utils import none_or 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]: def dumper(song: Song, path: Path, **kwargs: Any) -> Dict[Path, bytes]:
res: Dict[Path, bytes] = {} res: Dict[Path, bytes] = {}
if path.is_dir(): name_format = FileNameFormat(file_name_template, suggestion=path)
file_path = file_name_template
parent = path
else:
file_path = path
parent = path.parent
name_format = f"{file_path.stem}{{dedup}}{file_path.suffix}"
files = internal_dumper(song, **kwargs) files = internal_dumper(song, **kwargs)
for chartfile in files: 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 res[filepath] = chartfile.contents
return res return res
@ -49,28 +45,6 @@ def make_dumper_from_chart_file_dumper(
return 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): class FormatParameters(TypedDict, total=False):
title: str title: str
# uppercase BSC ADV EXT # uppercase BSC ADV EXT
@ -82,13 +56,64 @@ class FormatParameters(TypedDict, total=False):
dedup: str 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( return FormatParameters(
title=none_or(slugify, chartfile.song.metadata.title) or "", title=none_or(slugify, chartfile.song.metadata.title) or "",
difficulty=slugify(chartfile.difficulty), difficulty=slugify(chartfile.difficulty),
difficulty_index=str(DIFFICULTY_INDEX.get(chartfile.difficulty, 2)), difficulty_index=str(DIFFICULTY_INDEX.get(chartfile.difficulty, 2)),
difficulty_number=str(DIFFICULTY_NUMBER.get(chartfile.difficulty, 3)), difficulty_number=str(DIFFICULTY_NUMBER.get(chartfile.difficulty, 3)),
dedup="" if dedup_index == 0 else f"-{dedup_index}",
) )

View File

@ -8,6 +8,7 @@ class Format(str, Enum):
MEMON_LEGACY = "memon:legacy" MEMON_LEGACY = "memon:legacy"
MEMON_0_1_0 = "memon:v0.1.0" MEMON_0_1_0 = "memon:v0.1.0"
MEMON_0_2_0 = "memon:v0.2.0" MEMON_0_2_0 = "memon:v0.2.0"
MEMON_0_3_0 = "memon:v0.3.0"
MONO_COLUMN = "mono-column" MONO_COLUMN = "mono-column"
MEMO = "memo" MEMO = "memo"
MEMO_1 = "memo1" MEMO_1 = "memo1"

View File

@ -1,50 +1,33 @@
from typing import Dict from typing import Dict
from . import jubeat_analyser, konami, malody, memon
from .enum import Format 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 from .typing import Dumper, Loader
LOADERS: Dict[Format, Loader] = { LOADERS: Dict[Format, Loader] = {
Format.EVE: load_eve, Format.EVE: konami.load_eve,
Format.JBSQ: load_jbsq, Format.JBSQ: konami.load_jbsq,
Format.MALODY: load_malody, Format.MALODY: malody.load_malody,
Format.MEMON_LEGACY: load_memon_legacy, Format.MEMON_LEGACY: memon.load_memon_legacy,
Format.MEMON_0_1_0: load_memon_0_1_0, Format.MEMON_0_1_0: memon.load_memon_0_1_0,
Format.MEMON_0_2_0: load_memon_0_2_0, Format.MEMON_0_2_0: memon.load_memon_0_2_0,
Format.MONO_COLUMN: load_mono_column, Format.MEMON_0_3_0: memon.load_memon_0_3_0,
Format.MEMO: load_memo, Format.MONO_COLUMN: jubeat_analyser.load_mono_column,
Format.MEMO_1: load_memo1, Format.MEMO: jubeat_analyser.load_memo,
Format.MEMO_2: load_memo2, Format.MEMO_1: jubeat_analyser.load_memo1,
Format.MEMO_2: jubeat_analyser.load_memo2,
} }
DUMPERS: Dict[Format, Dumper] = { DUMPERS: Dict[Format, Dumper] = {
Format.EVE: dump_eve, Format.EVE: konami.dump_eve,
Format.JBSQ: dump_jbsq, Format.JBSQ: konami.dump_jbsq,
Format.MALODY: dump_malody, Format.MALODY: malody.dump_malody,
Format.MEMON_LEGACY: dump_memon_legacy, Format.MEMON_LEGACY: memon.dump_memon_legacy,
Format.MEMON_0_1_0: dump_memon_0_1_0, Format.MEMON_0_1_0: memon.dump_memon_0_1_0,
Format.MEMON_0_2_0: dump_memon_0_2_0, Format.MEMON_0_2_0: memon.dump_memon_0_2_0,
Format.MONO_COLUMN: dump_mono_column, Format.MEMON_0_3_0: memon.dump_memon_0_3_0,
Format.MEMO: dump_memo, Format.MONO_COLUMN: jubeat_analyser.dump_mono_column,
Format.MEMO_1: dump_memo1, Format.MEMO: jubeat_analyser.dump_memo,
Format.MEMO_2: dump_memo2, Format.MEMO_1: jubeat_analyser.dump_memo1,
Format.MEMO_2: jubeat_analyser.dump_memo2,
} }

View File

@ -53,6 +53,8 @@ def recognize_memon_version(obj: dict) -> Format:
return Format.MEMON_0_1_0 return Format.MEMON_0_1_0
elif version == "0.2.0": elif version == "0.2.0":
return Format.MEMON_0_2_0 return Format.MEMON_0_2_0
elif version == "0.3.0":
return Format.MEMON_0_3_0
else: else:
raise ValueError(f"Unsupported memon version : {version}") raise ValueError(f"Unsupported memon version : {version}")

View File

@ -210,7 +210,8 @@ def create_sections_from_chart(
header = sections[BeatsTime(0)].commands header = sections[BeatsTime(0)].commands
header["o"] = int(timing.beat_zero_offset * 1000) 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) header["dif"] = DIFFICULTY_NUMBER.get(difficulty, 3)
if metadata.audio is not None: if metadata.audio is not None:
header["m"] = metadata.audio header["m"] = metadata.audio

View File

@ -299,7 +299,8 @@ def _dump_memo2_chart(
file.write(f"// https://github.com/Stepland/jubeatools\n\n") file.write(f"// https://github.com/Stepland/jubeatools\n\n")
# Header # 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") file.write(dump_command("dif", DIFFICULTY_NUMBER.get(difficulty, 1)) + "\n")
if metadata.audio is not None: if metadata.audio is not None:
file.write(dump_command("m", metadata.audio) + "\n") file.write(dump_command("m", metadata.audio) + "\n")

View File

@ -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]: def folder_loader(path: Path) -> Dict[Path, T]:
files: Dict[Path, T] = {} files: Dict[Path, T] = {}
if path.is_dir(): if path.is_dir():

View File

@ -9,7 +9,7 @@ import simplejson as json
from jubeatools import song from jubeatools import song
from jubeatools.formats import timemap 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 jubeatools.utils import none_or
from . import schema as malody from . import schema as malody
@ -26,7 +26,7 @@ def load_file(path: Path) -> Any:
return json.load(f, use_decimal=True) 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: def load_malody_file(raw_dict: dict) -> song.Song:

View File

@ -8,11 +8,13 @@ parse than existing "memo-like" formats (memo, youbeat, etc ...).
https://github.com/Stepland/memon https://github.com/Stepland/memon
""" """
from .memon import ( from .v0 import (
dump_memon_0_1_0, dump_memon_0_1_0,
dump_memon_0_2_0, dump_memon_0_2_0,
dump_memon_0_3_0,
dump_memon_legacy, dump_memon_legacy,
load_memon_0_1_0, load_memon_0_1_0,
load_memon_0_2_0, load_memon_0_2_0,
load_memon_0_3_0,
load_memon_legacy, load_memon_legacy,
) )

View File

@ -1,36 +1,12 @@
import tempfile
from pathlib import Path
from typing import Set from typing import Set
import hypothesis.strategies as st import hypothesis.strategies as st
from hypothesis import given from hypothesis import given
from jubeatools import song 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 jubeatools.testutils import strategies as jbst
from jubeatools.testutils.test_patterns import dump_and_load_then_compare
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
@st.composite @st.composite
@ -56,6 +32,11 @@ def memon_legacy_compatible_song(draw: st.DrawFn) -> song.Song:
diffs_strat=memon_diffs(), diffs_strat=memon_diffs(),
chart_strat=jbst.chart(timing_strat=st.none()), chart_strat=jbst.chart(timing_strat=st.none()),
common_timing_strat=jbst.timing_info(with_bpm_changes=False), 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 random_song.metadata.preview = None
@ -65,7 +46,7 @@ def memon_legacy_compatible_song(draw: st.DrawFn) -> song.Song:
@given(memon_legacy_compatible_song()) @given(memon_legacy_compatible_song())
def test_memon_legacy(song: song.Song) -> None: 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 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()) @given(memon_0_1_0_compatible_song())
def test_memon_0_1_0(song: song.Song) -> None: 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 @st.composite
@ -84,6 +65,11 @@ def memon_0_2_0_compatible_song(draw: st.DrawFn) -> song.Song:
diffs_strat=memon_diffs(), diffs_strat=memon_diffs(),
chart_strat=jbst.chart(timing_strat=st.none()), chart_strat=jbst.chart(timing_strat=st.none()),
common_timing_strat=jbst.timing_info(with_bpm_changes=False), 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 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()) @given(memon_0_2_0_compatible_song())
def test_memon_0_2_0(song: song.Song) -> None: 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)

View File

@ -1,7 +1,8 @@
from functools import reduce
from io import StringIO from io import StringIO
from itertools import chain from itertools import chain
from pathlib import Path from pathlib import Path
from typing import Any, Dict, List, Union from typing import Any, Callable, Dict, List, Union
import simplejson as json import simplejson as json
from marshmallow import ( from marshmallow import (
@ -15,7 +16,11 @@ from marshmallow import (
from multidict import MultiDict from multidict import MultiDict
from jubeatools import song as jbt 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 : # v0.x.x long note value :
# #
@ -102,6 +107,12 @@ class MemonMetadata_0_2_0(MemonMetadata_0_1_0):
preview = fields.Nested(MemonPreview) 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): class Memon_legacy(StrictSchema):
metadata = fields.Nested(MemonMetadata_legacy, required=True) metadata = fields.Nested(MemonMetadata_legacy, required=True)
data = fields.Nested(MemonChart_legacy, required=True, many=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]: class Memon_0_3_0(StrictSchema):
with open(file) as f: version = fields.String(required=True, validate=validate.OneOf(["0.3.0"]))
res = json.load(f, use_decimal=True) metadata = fields.Nested(MemonMetadata_0_3_0, required=True)
if not isinstance(res, dict): 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( raise ValueError(
"JSON file does not represent a valid memon file : " "Multiple .memon files were found in the given folder, "
"The top level of a memon file should be a JSON Object" "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( def _load_memon_note_v0(
@ -149,8 +184,7 @@ def _load_memon_note_v0(
return jbt.TapNote(time, position) return jbt.TapNote(time, position)
def load_memon_legacy(path: Path, **kwargs: Any) -> jbt.Song: def _load_memon_legacy(raw_memon: Any) -> jbt.Song:
raw_memon = _load_raw_memon(path)
schema = Memon_legacy() schema = Memon_legacy()
memon = schema.load(raw_memon) memon = schema.load(raw_memon)
metadata = jbt.Metadata( 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) return jbt.Song(metadata=metadata, charts=charts, common_timing=common_timing)
def load_memon_0_1_0(path: Path, **kwargs: Any) -> jbt.Song: load_memon_legacy = make_folder_loader_with_optional_merge(_load_memon_legacy)
raw_memon = _load_raw_memon(path)
def _load_memon_0_1_0(raw_memon: Any) -> jbt.Song:
schema = Memon_0_1_0() schema = Memon_0_1_0()
memon = schema.load(raw_memon) memon = schema.load(raw_memon)
metadata = jbt.Metadata( 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) return jbt.Song(metadata=metadata, charts=charts, common_timing=common_timing)
def load_memon_0_2_0(path: Path, **kwargs: Any) -> jbt.Song: load_memon_0_1_0 = make_folder_loader_with_optional_merge(_load_memon_0_1_0)
raw_memon = _load_raw_memon(path)
def _load_memon_0_2_0(raw_memon: Any) -> jbt.Song:
schema = Memon_0_2_0() schema = Memon_0_2_0()
memon = schema.load(raw_memon) memon = schema.load(raw_memon)
preview = None 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) 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: def _long_note_tail_value_v0(note: jbt.LongNote) -> int:
dx = note.tail_tip.x - note.position.x dx = note.tail_tip.x - note.position.x
dy = note.tail_tip.y - note.position.y 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: 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 """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)""" 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 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") _raise_if_unfit_for_v0(song, "legacy")
timing = _get_timing(song) 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(): return SongFile(contents=_dump_to_json(memon), song=song)
filepath = path / f"{song.metadata.title}.memon"
else:
filepath = path
return {filepath: _dump_to_json(memon)}
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") _raise_if_unfit_for_v0(song, "v0.1.0")
timing = _get_timing(song) 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(): return SongFile(contents=_dump_to_json(memon), song=song)
filepath = path / f"{song.metadata.title}.memon"
else:
filepath = path
return {filepath: _dump_to_json(memon)}
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") _raise_if_unfit_for_v0(song, "v0.2.0")
timing = _get_timing(song) 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(): return SongFile(contents=_dump_to_json(memon), song=song)
filepath = path / f"{song.metadata.title}.memon"
else:
filepath = path
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)

View File

@ -1,17 +1,15 @@
from decimal import Decimal from decimal import Decimal
from fractions import Fraction from fractions import Fraction
import pytest
from ..load_tools import round_beats from ..load_tools import round_beats
@pytest.mark.parametrize("numerator", range(240)) def test_fraction_recovery_after_rounding_to_three_decimals() -> None:
def test_fraction_recovery_after_rounding_to_three_decimals(numerator: int) -> None: for numerator in range(240):
fraction = Fraction(numerator, 240) fraction = Fraction(numerator, 240)
decimal = numerator / Decimal(240) decimal = numerator / Decimal(240)
rounded = round(decimal, 3) rounded = round(decimal, 3)
text_form = str(rounded) text_form = str(rounded)
re_parsed_decimal = Decimal(text_form) re_parsed_decimal = Decimal(text_form)
result = round_beats(re_parsed_decimal) result = round_beats(re_parsed_decimal)
assert fraction == result assert fraction == result

View File

@ -180,7 +180,7 @@ class Timing:
@dataclass @dataclass
class Chart: class Chart:
level: Decimal level: Optional[Decimal]
timing: Optional[Timing] = None timing: Optional[Timing] = None
notes: List[Union[TapNote, LongNote]] = field(default_factory=list) notes: List[Union[TapNote, LongNote]] = field(default_factory=list)

View File

@ -20,7 +20,7 @@ def open_temp_dir() -> Iterator[Path]:
def dump_and_load_then_compare( def dump_and_load_then_compare(
format_: Format, format_: Format,
song: song.Song, 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, temp_path: Callable[[], ContextManager[Path]] = open_temp_dir,
load_options: Optional[dict] = None, load_options: Optional[dict] = None,
dump_options: Optional[dict] = None, dump_options: Optional[dict] = None,

14
poetry.lock generated
View File

@ -498,6 +498,14 @@ category = "dev"
optional = false optional = false
python-versions = "*" python-versions = "*"
[[package]]
name = "types-toml"
version = "0.10.1"
description = "Typing stubs for toml"
category = "dev"
optional = false
python-versions = "*"
[[package]] [[package]]
name = "typing-extensions" name = "typing-extensions"
version = "3.10.0.2" version = "3.10.0.2"
@ -521,7 +529,7 @@ typing-extensions = ">=3.7.4"
[metadata] [metadata]
lock-version = "1.1" lock-version = "1.1"
python-versions = "^3.8" python-versions = "^3.8"
content-hash = "667e5000f1e328f2d07a86946ad1423912c875c0abcc7efb97a72aec38fdb916" content-hash = "025ff93ec3b033c89bdaaeab7905dc10ff450ed70695bcbd863a10e6af6fa9ea"
[metadata.files] [metadata.files]
atomicwrites = [ atomicwrites = [
@ -864,6 +872,10 @@ types-simplejson = [
{file = "types-simplejson-3.17.1.tar.gz", hash = "sha256:310ef5addfe97c20b9f2cac85079cbab95fd123c2c9a5c6b99856075d6b48211"}, {file = "types-simplejson-3.17.1.tar.gz", hash = "sha256:310ef5addfe97c20b9f2cac85079cbab95fd123c2c9a5c6b99856075d6b48211"},
{file = "types_simplejson-3.17.1-py3-none-any.whl", hash = "sha256:7b5ac5549f5269f25e5e932d6f4e96ea8397369d6031300161cd1fda484e2534"}, {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 = [ typing-extensions = [
{file = "typing_extensions-3.10.0.2-py2-none-any.whl", hash = "sha256:d8226d10bc02a29bcc81df19a26e56a9647f8b0a6d4a83924139f4a8b01f17b7"}, {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"}, {file = "typing_extensions-3.10.0.2-py3-none-any.whl", hash = "sha256:f1d25edafde516b146ecd0613dabcc61409817af4766fbbcfb8d1ad4ec441a34"},

View File

@ -32,6 +32,7 @@ toml = "^0.10.2"
flake8 = "^3.9.1" flake8 = "^3.9.1"
autoimport = "^0.7.0" autoimport = "^0.7.0"
types-simplejson = "^3.17.1" types-simplejson = "^3.17.1"
types-toml = "^0.10.1"
[tool.poetry.scripts] [tool.poetry.scripts]
jubeatools = 'jubeatools.cli.cli:convert' jubeatools = 'jubeatools.cli.cli:convert'