<commit_before>.. _examples:

Learn by example
================

This page includes examples of varying length
demonstrating correct & type-checked **simfile** library usage.
You're free to use these recipes & scripts as-is,
modify them to suit your needs,
or simply use them as a learning aid.

Recipes
~~~~~~~

This section includes short snippets of code
that demonstrate basic library usage.
These recipes are in the public domain.

Get charts for one game mode
----------------------------

.. code:: python

    from typing import Iterator
    from simfile.types import Chart

    # Imperative version
    def charts_for_stepstype(charts, stepstype='dance-single') -> Iterator[Chart]:
        for chart in charts:
            if chart.stepstype == stepstype:
                yield chart
    
    # One-liner version
    def charts_for_stepstype(charts, stepstype='dance-single') -> Iterator[Chart]:
        yield from filter(lambda chart: chart.stepstype == stepstype, charts)


Get the hardest chart
---------------------

.. code:: python

    from typing import Optional, Sequence
    from simfile.types import Chart

    # Imperative version
    def get_hardest_chart(charts) -> Optional[Chart]:
        hardest_chart: Optional[Chart] = None
        hardest_meter: Optional[int] = None

        for chart in charts:
            # Remember to convert `meter` to an integer for comparisons
            meter = int(chart.meter or "1")
            if hardest_meter is None or meter > hardest_meter:
                hardest_chart = chart
                hardest_meter = meter

        return hardest_chart

    # One-liner version
    def get_hardest_chart(charts: Sequence[Chart]) -> Optional[Chart]:
        return max(
            charts,
            key=lambda chart: int(chart.meter or "1"),
            default=None,
        )


Mirror a chart's notes
----------------------

.. code:: python

    from typing import Iterator
    from simfile.types import Chart
    from simfile.notes import Note, NoteData

    def mirror_note(note: Note, columns: int) -> Note:
        # Make a new Note with all fields the same except for column
        return note._replace(
            # You could replace this expression with anything you want
            column=columns - note.column - 1
        )
    
    def mirror_notes(notedata: NoteData) -> Iterator[Note]:
        columns = notedata.columns
        for note in notedata:
            yield mirror_note(note, columns)

    def mirror_chart_in_place(chart: Chart) -> None:
        notedata = NoteData(chart)
        mirrored = NoteData.from_notes(
            mirror_notes(notedata),
            columns=notedata.columns,
        )
        # Assign str(NoteData) to Chart.notes to update the chart's notes
        chart.notes = str(mirrored)

Remove all but one chart from a simfile
---------------------------------------

.. code:: python

    from typing import Optional
    from simfile.types import Chart, Charts, Simfile

    # When you have multiple parameters of the same type (str in this case),
    # it's good practice to use a * pseudo-argument to require them to be named
    def find_chart(charts: Charts, *, stepstype: str, difficulty: str) -> Optional[Chart]:
        for chart in charts:
            if chart.stepstype == stepstype and chart.difficulty == difficulty:
                return chart

    def remove_other_charts(sf: Simfile, *, stepstype='dance-single', difficulty='Challenge'):
        the_chart = find_chart(sf.charts, stepstype=stepstype, difficulty=difficulty)
        if the_chart:
            # Replace the simfile's charts with a list of one
            sf.charts = [the_chart]  # type: ignore
        else:
            # You could alternatively raise an exception, pick a different chart,
            # set sf.charts to an empty list, etc.
            print(f"No {stepstype} {difficulty} chart found for {repr(sf)}")

Full scripts
~~~~~~~~~~~~

This section includes complete, ready-to-use scripts
that automate repetitive tasks on simfile packs.
These scripts are licensed under the MIT License,
the same license as the **simfile** library itself.

change_sync_bias.py
-------------------

.. code:: python

    R"""
    Add or subtract the standard ITG sync bias (9 milliseconds)
    to all of the sync offsets in a pack.

    This script updates the offsets of both SM and SSC simfiles,
    including any SSC charts with their own timing data.

    If you actually intend to use this script in practice,
    you may want to keep track of which packs you've already adjusted
    using a text file in each pack directory or some other system.

    Usage examples:

        # Convert a pack from "null sync" to "ITG sync"
        python change_sync_bias.py +9 "C:\StepMania\Songs\My Pack"

        # Convert a pack from "ITG sync" to "null sync"
        python change_sync_bias.py -9 "C:\StepMania\Songs\My Pack"
    """
    import argparse
    from decimal import Decimal
    import sys
    from typing import Union

    import simfile
    import simfile.dir


    class ChangeSyncBiasArgs:
        """Stores the command-line arguments for this script."""

        pack: str
        itg_to_null: bool
        null_to_itg: bool


    def argparser():
        """Get an ArgumentParser instance for this command-line script."""
        parser = argparse.ArgumentParser(prefix_chars="-+")
        parser.add_argument("pack", type=str, help="path to the pack to modify")
        group = parser.add_mutually_exclusive_group(required=True)
        group.add_argument(
            "-9", "--itg-to-null", action="store_true", help="subtract 9ms from offsets"
        )
        group.add_argument(
            "+9", "--null-to-itg", action="store_true", help="add 9ms to offsets"
        )
        return parser


    def adjust_offset(
        obj: Union[simfile.types.Simfile, simfile.ssc.SSCChart],
        delta: Decimal,
    ):
        """Add the delta to the simfile or SSC chart's offset, if present."""
        if obj.offset is not None:
            obj.offset = str(Decimal(obj.offset) + delta)


    def change_sync_bias(simfile_path: str, args: ChangeSyncBiasArgs):
        """
        Add or subtract 9 milliseconds to the simfile's offset,
        as well as any SSC charts with their own timing data.

        This saves the updated simfile to its original location
        and writes a backup copy with a ~ appended to the filename.
        """
        # Map the +9 or -9 arg to the actual offset delta.
        #
        # We don't have to check both itg_to_null and null_to_itg
        # because the mutually exclusive & required argument group
        # ensures that exactly one of them will be True.
        delta = Decimal("-0.009" if args.itg_to_null else "+0.009")

        # You could specify output_filename here to write the updated file elsewhere
        with simfile.mutate(
            input_filename=f"{simfile_path}",
            backup_filename=f"{simfile_path}~",
        ) as sf:
            print(f"Processing {simfile_path}")

            # Always adjust the simfile's offset
            adjust_offset(sf, delta)

            # Additionally try to adjust SSC charts' offsets.
            # This won't do anything unless the chart has its own timing data.
            if isinstance(sf, simfile.ssc.SSCSimfile):
                for chart in sf.charts:
                    adjust_offset(chart, delta)


    def main(argv):
        # Parse command-line arguments
        args = argparser().parse_args(argv[1:], namespace=ChangeSyncBiasArgs())

        # Iterate over SimfileDirectory objects from the pack
        # so that we can easily get the .sm and/or .ssc paths
        for simfile_dir in simfile.dir.SimfilePack(args.pack).simfile_dirs():

            # Try to update whichever formats exist
            for simfile_path in [simfile_dir.sm_path, simfile_dir.ssc_path]:
                if simfile_path:
                    change_sync_bias(simfile_path, args)


    if __name__ == "__main__":
        main(sys.argv)


sort_by_difficulty.py
---------------------

.. code:: python

    R"""
    Change the title of every simfile in a pack
    so that they are sorted by difficulty in StepMania.

    This script finds the hardest chart of a given stepstype (dance-single by default)
    and puts its meter (difficulty number) between brackets at the start of the title.

    Usage examples:

        # Sort a pack by difficulty
        python sort_by_difficulty.py "C:\StepMania\Songs\My Pack"

        # Unsort by difficulty (remove the title prefixes)
        python sort_by_difficulty.py -r "C:\StepMania\Songs\My Pack"

        # Customize stepstype and digits
        python sort_by_difficulty.py -s dance-double -d 3 "C:\StepMania\My Pack"
    """
    import argparse
    import sys
    from typing import Optional, Sequence

    import simfile
    import simfile.dir


    class SortByDifficultyArgs:
        """Stores the command-line arguments for this script."""

        pack: str
        stepstype: str
        digits: int
        remove: bool


    def argparser():
        """Get an ArgumentParser instance for this command-line script."""
        parser = argparse.ArgumentParser()
        parser.add_argument("pack", type=str, help="path to the pack to modify")
        parser.add_argument("-s", "--stepstype", type=str, default="dance-single")
        parser.add_argument(
            "-d",
            "--digits",
            type=int,
            default=2,
            help="minimum digits (will add leading zeroes)",
        )
        parser.add_argument(
            "-r",
            "--remove",
            action=argparse.BooleanOptionalAction,
            help="remove meter prefix",
        )
        return parser


    def hardest_chart(
        charts: Sequence[simfile.types.Chart], stepstype: str
    ) -> Optional[simfile.types.Chart]:
        """
        Find & return the hardest chart (numerically) of a given stepstype.

        Returns None if there are no charts matching the stepstype.
        """
        return max(
            [c for c in charts if c.stepstype == stepstype],
            key=lambda c: int(c.meter or "1"),
            default=None,
        )


    def prefix_title_with_meter(simfile_path: str, args: SortByDifficultyArgs):
        """
        Add (or remove) a numeric prefix to the simfile's title.

        This saves the updated simfile to its original location
        and writes a backup copy with a ~ appended to the filename.
        """
        # You could specify output_filename here to write the updated file elsewhere
        with simfile.mutate(
            input_filename=f"{simfile_path}",
            backup_filename=f"{simfile_path}~",
        ) as sf:
            print(f"Processing {simfile_path}")

            # It's very unlikely for the title property to be blank or missing.
            # This is mostly to satisfy type-checkers.
            current_title = sf.title or ""

            if args.remove:
                # Look for a number in brackets at the start of the title
                if current_title.startswith("["):
                    open_bracket_index = current_title.find("[")
                    close_bracket_index = current_title.find("]")
                    bracketed_text = current_title[
                        open_bracket_index + 1 : close_bracket_index
                    ]
                    if bracketed_text.isnumeric():
                        # Remove the bracketed number from the title
                        sf.title = current_title[close_bracket_index + 1 :].lstrip(" ")
            else:
                # Find the hardest chart (numerically) within a stepstype
                # and use it to prefix the title
                chart = hardest_chart(sf.charts, args.stepstype)

                # Skip this simfile if there were no charts for the stepstype.
                # Nothing will be written to disk in this case.
                if not chart:
                    raise simfile.CancelMutation

                # It's very unlikely for the meter property to be blank or missing.
                # This is mostly to satisfy type-checkers.
                meter = chart.meter or "1"

                # Put the meter at the start of the title,
                # filling in leading zeros per arguments
                sf.title = f"[{meter.zfill(args.digits)}] {current_title}"


    def main(argv):
        # Parse command-line arguments
        args = argparser().parse_args(argv[1:], namespace=SortByDifficultyArgs())

        # Iterate over SimfileDirectory objects from the pack
        # so that we can easily get the .sm and/or .ssc paths
        for simfile_dir in simfile.dir.SimfilePack(args.pack).simfile_dirs():

            # Try to update whichever formats exist
            for simfile_path in [simfile_dir.sm_path, simfile_dir.ssc_path]:
                if simfile_path:
                    prefix_title_with_meter(simfile_path, args)


    if __name__ == "__main__":
        main(sys.argv)
<commit_msg>In difficulty sort example script, also apply changes to titletranslit (#27)<commit_after>.. _examples:

Learn by example
================

This page includes examples of varying length
demonstrating correct & type-checked **simfile** library usage.
You're free to use these recipes & scripts as-is,
modify them to suit your needs,
or simply use them as a learning aid.

Recipes
~~~~~~~

This section includes short snippets of code
that demonstrate basic library usage.
These recipes are in the public domain.

Get charts for one game mode
----------------------------

.. code:: python

    from typing import Iterator
    from simfile.types import Chart

    # Imperative version
    def charts_for_stepstype(charts, stepstype='dance-single') -> Iterator[Chart]:
        for chart in charts:
            if chart.stepstype == stepstype:
                yield chart
    
    # One-liner version
    def charts_for_stepstype(charts, stepstype='dance-single') -> Iterator[Chart]:
        yield from filter(lambda chart: chart.stepstype == stepstype, charts)


Get the hardest chart
---------------------

.. code:: python

    from typing import Optional, Sequence
    from simfile.types import Chart

    # Imperative version
    def get_hardest_chart(charts) -> Optional[Chart]:
        hardest_chart: Optional[Chart] = None
        hardest_meter: Optional[int] = None

        for chart in charts:
            # Remember to convert `meter` to an integer for comparisons
            meter = int(chart.meter or "1")
            if hardest_meter is None or meter > hardest_meter:
                hardest_chart = chart
                hardest_meter = meter

        return hardest_chart

    # One-liner version
    def get_hardest_chart(charts: Sequence[Chart]) -> Optional[Chart]:
        return max(
            charts,
            key=lambda chart: int(chart.meter or "1"),
            default=None,
        )


Mirror a chart's notes
----------------------

.. code:: python

    from typing import Iterator
    from simfile.types import Chart
    from simfile.notes import Note, NoteData

    def mirror_note(note: Note, columns: int) -> Note:
        # Make a new Note with all fields the same except for column
        return note._replace(
            # You could replace this expression with anything you want
            column=columns - note.column - 1
        )
    
    def mirror_notes(notedata: NoteData) -> Iterator[Note]:
        columns = notedata.columns
        for note in notedata:
            yield mirror_note(note, columns)

    def mirror_chart_in_place(chart: Chart) -> None:
        notedata = NoteData(chart)
        mirrored = NoteData.from_notes(
            mirror_notes(notedata),
            columns=notedata.columns,
        )
        # Assign str(NoteData) to Chart.notes to update the chart's notes
        chart.notes = str(mirrored)

Remove all but one chart from a simfile
---------------------------------------

.. code:: python

    from typing import Optional
    from simfile.types import Chart, Charts, Simfile

    # When you have multiple parameters of the same type (str in this case),
    # it's good practice to use a * pseudo-argument to require them to be named
    def find_chart(charts: Charts, *, stepstype: str, difficulty: str) -> Optional[Chart]:
        for chart in charts:
            if chart.stepstype == stepstype and chart.difficulty == difficulty:
                return chart

    def remove_other_charts(sf: Simfile, *, stepstype='dance-single', difficulty='Challenge'):
        the_chart = find_chart(sf.charts, stepstype=stepstype, difficulty=difficulty)
        if the_chart:
            # Replace the simfile's charts with a list of one
            sf.charts = [the_chart]  # type: ignore
        else:
            # You could alternatively raise an exception, pick a different chart,
            # set sf.charts to an empty list, etc.
            print(f"No {stepstype} {difficulty} chart found for {repr(sf)}")

Full scripts
~~~~~~~~~~~~

This section includes complete, ready-to-use scripts
that automate repetitive tasks on simfile packs.
These scripts are licensed under the MIT License,
the same license as the **simfile** library itself.

change_sync_bias.py
-------------------

.. code:: python

    R"""
    Add or subtract the standard ITG sync bias (9 milliseconds)
    to all of the sync offsets in a pack.

    This script updates the offsets of both SM and SSC simfiles,
    including any SSC charts with their own timing data.

    If you actually intend to use this script in practice,
    you may want to keep track of which packs you've already adjusted
    using a text file in each pack directory or some other system.

    Usage examples:

        # Convert a pack from "null sync" to "ITG sync"
        python change_sync_bias.py +9 "C:\StepMania\Songs\My Pack"

        # Convert a pack from "ITG sync" to "null sync"
        python change_sync_bias.py -9 "C:\StepMania\Songs\My Pack"
    """
    import argparse
    from decimal import Decimal
    import sys
    from typing import Union

    import simfile
    import simfile.dir


    class ChangeSyncBiasArgs:
        """Stores the command-line arguments for this script."""

        pack: str
        itg_to_null: bool
        null_to_itg: bool


    def argparser():
        """Get an ArgumentParser instance for this command-line script."""
        parser = argparse.ArgumentParser(prefix_chars="-+")
        parser.add_argument("pack", type=str, help="path to the pack to modify")
        group = parser.add_mutually_exclusive_group(required=True)
        group.add_argument(
            "-9", "--itg-to-null", action="store_true", help="subtract 9ms from offsets"
        )
        group.add_argument(
            "+9", "--null-to-itg", action="store_true", help="add 9ms to offsets"
        )
        return parser


    def adjust_offset(
        obj: Union[simfile.types.Simfile, simfile.ssc.SSCChart],
        delta: Decimal,
    ):
        """Add the delta to the simfile or SSC chart's offset, if present."""
        if obj.offset is not None:
            obj.offset = str(Decimal(obj.offset) + delta)


    def change_sync_bias(simfile_path: str, args: ChangeSyncBiasArgs):
        """
        Add or subtract 9 milliseconds to the simfile's offset,
        as well as any SSC charts with their own timing data.

        This saves the updated simfile to its original location
        and writes a backup copy with a ~ appended to the filename.
        """
        # Map the +9 or -9 arg to the actual offset delta.
        #
        # We don't have to check both itg_to_null and null_to_itg
        # because the mutually exclusive & required argument group
        # ensures that exactly one of them will be True.
        delta = Decimal("-0.009" if args.itg_to_null else "+0.009")

        # You could specify output_filename here to write the updated file elsewhere
        with simfile.mutate(
            input_filename=f"{simfile_path}",
            backup_filename=f"{simfile_path}~",
        ) as sf:
            print(f"Processing {simfile_path}")

            # Always adjust the simfile's offset
            adjust_offset(sf, delta)

            # Additionally try to adjust SSC charts' offsets.
            # This won't do anything unless the chart has its own timing data.
            if isinstance(sf, simfile.ssc.SSCSimfile):
                for chart in sf.charts:
                    adjust_offset(chart, delta)


    def main(argv):
        # Parse command-line arguments
        args = argparser().parse_args(argv[1:], namespace=ChangeSyncBiasArgs())

        # Iterate over SimfileDirectory objects from the pack
        # so that we can easily get the .sm and/or .ssc paths
        for simfile_dir in simfile.dir.SimfilePack(args.pack).simfile_dirs():

            # Try to update whichever formats exist
            for simfile_path in [simfile_dir.sm_path, simfile_dir.ssc_path]:
                if simfile_path:
                    change_sync_bias(simfile_path, args)


    if __name__ == "__main__":
        main(sys.argv)


sort_by_difficulty.py
---------------------

.. code:: python

    R"""
    Change the title of every simfile in a pack
    so that they are sorted by difficulty in StepMania.

    This script finds the hardest chart of a given stepstype (dance-single by default)
    and puts its meter (difficulty number) between brackets at the start of the title
    and titletranslit.

    Usage examples:

        # Sort a pack by difficulty
        python sort_by_difficulty.py "C:\StepMania\Songs\My Pack"

        # Unsort by difficulty (remove the title prefixes)
        python sort_by_difficulty.py -r "C:\StepMania\Songs\My Pack"

        # Customize stepstype and digits
        python sort_by_difficulty.py -s dance-double -d 3 "C:\StepMania\My Pack"
    """
    import argparse
    import sys
    from typing import Optional, Sequence

    import simfile
    import simfile.dir


    class SortByDifficultyArgs:
        """Stores the command-line arguments for this script."""

        pack: str
        stepstype: str
        digits: int
        remove: bool


    def argparser():
        """Get an ArgumentParser instance for this command-line script."""
        parser = argparse.ArgumentParser()
        parser.add_argument("pack", type=str, help="path to the pack to modify")
        parser.add_argument("-s", "--stepstype", type=str, default="dance-single")
        parser.add_argument(
            "-d",
            "--digits",
            type=int,
            default=2,
            help="minimum digits (will add leading zeroes)",
        )
        parser.add_argument(
            "-r",
            "--remove",
            action=argparse.BooleanOptionalAction,
            help="remove meter prefix",
        )
        return parser


    def hardest_chart(
        charts: Sequence[simfile.types.Chart], stepstype: str
    ) -> Optional[simfile.types.Chart]:
        """
        Find & return the hardest chart (numerically) of a given stepstype.

        Returns None if there are no charts matching the stepstype.
        """
        return max(
            [c for c in charts if c.stepstype == stepstype],
            key=lambda c: int(c.meter or "1"),
            default=None,
        )


    def prefix_title_with_meter(simfile_path: str, args: SortByDifficultyArgs):
        """
        Add (or remove) a numeric prefix to the simfile's title and titletranslit.

        This saves the updated simfile to its original location
        and writes a backup copy with a ~ appended to the filename.
        """
        # You could specify output_filename here to write the updated file elsewhere
        with simfile.mutate(
            input_filename=f"{simfile_path}",
            backup_filename=f"{simfile_path}~",
        ) as sf:
            print(f"Processing {simfile_path}")

            # It's very unlikely for the title property to be blank or missing.
            # This is mostly to satisfy type-checkers.
            current_title = sf.title or ""
            current_titletranslit = sf.titletranslit or ""

            if args.remove:
                def remove_starting_brackets(current_text: str) -> str:
                    """
                    If current_text has a bracketed number at the start of the text, remove it and return it
                    Otherwise, return current_text unchanged. 
                    """
                    # Look for a number in brackets at the start of the text
                    if current_text.startswith("["):
                        open_bracket_index = current_text.find("[")
                        close_bracket_index = current_text.find("]")
                        bracketed_text = current_text[
                            open_bracket_index + 1 : close_bracket_index
                        ]
                        if bracketed_text.isnumeric():
                            # Remove the bracketed number from the text
                            return current_title[close_bracket_index + 1 :].lstrip(" ")
                    return current_title
                sf.title = remove_starting_brackets(sf.title)
                sf.titletranslit = remove_starting_brackets(sf.titletranslit)
            else:
                # Find the hardest chart (numerically) within a stepstype
                # and use it to prefix the title
                chart = hardest_chart(sf.charts, args.stepstype)

                # Skip this simfile if there were no charts for the stepstype.
                # Nothing will be written to disk in this case.
                if not chart:
                    raise simfile.CancelMutation

                # It's very unlikely for the meter property to be blank or missing.
                # This is mostly to satisfy type-checkers.
                meter = chart.meter or "1"

                # Put the meter at the start of the title,
                # filling in leading zeros per arguments
                sf.title = f"[{meter.zfill(args.digits)}] {current_title}"
                sf.titletranslit = f"[{meter.zfill(args.digits)}] {current_titletranslit}"


    def main(argv):
        # Parse command-line arguments
        args = argparser().parse_args(argv[1:], namespace=SortByDifficultyArgs())

        # Iterate over SimfileDirectory objects from the pack
        # so that we can easily get the .sm and/or .ssc paths
        for simfile_dir in simfile.dir.SimfilePack(args.pack).simfile_dirs():

            # Try to update whichever formats exist
            for simfile_path in [simfile_dir.sm_path, simfile_dir.ssc_path]:
                if simfile_path:
                    prefix_title_with_meter(simfile_path, args)


    if __name__ == "__main__":
        main(sys.argv)

{'fraction_non_alphanumeric': 0.08864667807454803, 'fraction_numerical': 0.002231976787441411, 'mean_word_length': 2.102481246393537, 'pattern_counts': {'":': 4, '<': 3, '<?xml version=': 0, '>': 24, 'https://': 0, 'lorem ipsum': 0, 'www.': 0, 'xml': 0}, 'pii_count': 0, 'substrings_counts': 0, 'word_list_counts': {'cursed_substrings.json': 0, 'profanity_word_list.json': 0, 'sexual_word_list.json': 0, 'zh_pornsignals.json': 0}}
{'dir': 'git-commits-cleaned', 'id': '737929', 'n_tokens_mistral': 7455, 'n_tokens_neox': 6995, 'n_words': 2547}