3 Commits cdce51467d ... b160dfc4a4

Author SHA1 Message Date
  Fabian Peter Hammerle b160dfc4a4 format code using black 3 weeks ago
  Fabian Peter Hammerle 2e456e7196 pipenv: installed black (code formatter) 3 weeks ago
  Fabian Peter Hammerle dd065ab546 mp4 interface: added set_free_int_ratio() 3 weeks ago

+ 1 - 0
.githooks/pre-commit

@@ -5,4 +5,5 @@ set -ex
 pipenv clean
 pipenv sync --dev
 pipenv run pytest --doctest-modules
+pipenv run pipenv run black --check .
 pipenv run pylint --disable=fixme symuid tests

+ 2 - 1
.pylintrc

@@ -1,3 +1,4 @@
 [MESSAGES CONTROL]
 
-disable=missing-docstring
+disable=bad-continuation,
+        missing-docstring

+ 2 - 1
Pipfile

@@ -7,8 +7,9 @@ name = "pypi"
 symuid = {editable = true, path = "."}
 
 [dev-packages]
-pytest = "*"
+black = "==19.10b0"
 pylint = "*"
+pytest = "*"
 
 [requires]
 python_version = "3"

+ 92 - 36
Pipfile.lock

@@ -1,7 +1,7 @@
 {
     "_meta": {
         "hash": {
-            "sha256": "d5ddd801c0ea8f079b0bac0187b7ffdea2d8d4ea02e3f472d72d8d1176879739"
+            "sha256": "b7d460f42a3d7b333f360a364ccbaf8184d8737a94b24a51469ec4aab85261f5"
         },
         "pipfile-spec": 6,
         "requires": {
@@ -24,10 +24,10 @@
         },
         "python-dateutil": {
             "hashes": [
-                "sha256:7e6584c74aeed623791615e26efd690f29817a27c73085b78e4bad02493df2fb",
-                "sha256:c89805f6f4d64db21ed966fda138f8a5ed7a4fdbc1a8ee329ce1b74e3c74da9e"
+                "sha256:73ebfe9dbf22e832286dafa60473e4cd239f8592f699aa5adaf10050e6e1823c",
+                "sha256:75bb3f31ea686f1197762692a9ee6a7550b59fc6ca3a1f4b5d7e32fb98e2da2a"
             ],
-            "version": "==2.8.0"
+            "version": "==2.8.1"
         },
         "pytz": {
             "hashes": [
@@ -38,10 +38,10 @@
         },
         "six": {
             "hashes": [
-                "sha256:3350809f0555b11f552448330d0b52d5f24c91a322ea4a15ef22629740f3761c",
-                "sha256:d16a0141ec1a18405cd4ce8b4613101da75da0e9a7aec5bdd4fa804d0e0eba73"
+                "sha256:1f1b7d42e254082a9db6279deae68afb421ceba6158efa6131de7b3003ee93fd",
+                "sha256:30f610279e8b2578cab6db20741130331735c781b56053c59c4076da27f06b66"
             ],
-            "version": "==1.12.0"
+            "version": "==1.13.0"
         },
         "symuid": {
             "editable": true,
@@ -49,12 +49,19 @@
         }
     },
     "develop": {
+        "appdirs": {
+            "hashes": [
+                "sha256:9e5896d1372858f8dd3344faf4e5014d21849c756c8d5701f78f8a103b372d92",
+                "sha256:d8b24664561d0d34ddfaec54636d502d7cea6e29c3eaf68f3df6180863e2166e"
+            ],
+            "version": "==1.4.3"
+        },
         "astroid": {
             "hashes": [
-                "sha256:09a3fba616519311f1af8a461f804b68f0370e100c9264a035aa7846d7852e33",
-                "sha256:5a79c9b4bd6c4be777424593f957c996e20beb5f74e0bc332f47713c6f675efe"
+                "sha256:71ea07f44df9568a75d0f354c49143a4575d90645e9fead6dfb52c26a85ed13a",
+                "sha256:840947ebfa8b58f318d42301cf8c0a20fd794a33b61cc4638e28e9e61ba32f42"
             ],
-            "version": "==2.3.2"
+            "version": "==2.3.3"
         },
         "atomicwrites": {
             "hashes": [
@@ -70,6 +77,21 @@
             ],
             "version": "==19.3.0"
         },
+        "black": {
+            "hashes": [
+                "sha256:1b30e59be925fafc1ee4565e5e08abef6b03fe455102883820fe5ee2e4734e0b",
+                "sha256:c2edb73a08e9e0e6f65a0e6af18b059b8b1cdd5bef997d7a0b181df93dc81539"
+            ],
+            "index": "pypi",
+            "version": "==19.10b0"
+        },
+        "click": {
+            "hashes": [
+                "sha256:2335065e6395b9e67ca716de5f7526736bfa6ceead690adf616d925bdc622b13",
+                "sha256:5b94b49521f6456670fdb30cd82a4eca9412788a93fa6dd6df72c94d5a8ff2d7"
+            ],
+            "version": "==7.0"
+        },
         "importlib-metadata": {
             "hashes": [
                 "sha256:aa18d7378b00b40847790e7c27e11673d7fed219354109d0e7b9e5b25dc3ad26",
@@ -87,26 +109,29 @@
         },
         "lazy-object-proxy": {
             "hashes": [
-                "sha256:02b260c8deb80db09325b99edf62ae344ce9bc64d68b7a634410b8e9a568edbf",
-                "sha256:18f9c401083a4ba6e162355873f906315332ea7035803d0fd8166051e3d402e3",
-                "sha256:1f2c6209a8917c525c1e2b55a716135ca4658a3042b5122d4e3413a4030c26ce",
-                "sha256:2f06d97f0ca0f414f6b707c974aaf8829c2292c1c497642f63824119d770226f",
-                "sha256:616c94f8176808f4018b39f9638080ed86f96b55370b5a9463b2ee5c926f6c5f",
-                "sha256:63b91e30ef47ef68a30f0c3c278fbfe9822319c15f34b7538a829515b84ca2a0",
-                "sha256:77b454f03860b844f758c5d5c6e5f18d27de899a3db367f4af06bec2e6013a8e",
-                "sha256:83fe27ba321e4cfac466178606147d3c0aa18e8087507caec78ed5a966a64905",
-                "sha256:84742532d39f72df959d237912344d8a1764c2d03fe58beba96a87bfa11a76d8",
-                "sha256:874ebf3caaf55a020aeb08acead813baf5a305927a71ce88c9377970fe7ad3c2",
-                "sha256:9f5caf2c7436d44f3cec97c2fa7791f8a675170badbfa86e1992ca1b84c37009",
-                "sha256:a0c8758d01fcdfe7ae8e4b4017b13552efa7f1197dd7358dc9da0576f9d0328a",
-                "sha256:a4def978d9d28cda2d960c279318d46b327632686d82b4917516c36d4c274512",
-                "sha256:ad4f4be843dace866af5fc142509e9b9817ca0c59342fdb176ab6ad552c927f5",
-                "sha256:ae33dd198f772f714420c5ab698ff05ff900150486c648d29951e9c70694338e",
-                "sha256:b4a2b782b8a8c5522ad35c93e04d60e2ba7f7dcb9271ec8e8c3e08239be6c7b4",
-                "sha256:c462eb33f6abca3b34cdedbe84d761f31a60b814e173b98ede3c81bb48967c4f",
-                "sha256:fd135b8d35dfdcdb984828c84d695937e58cc5f49e1c854eb311c4d6aa03f4f1"
-            ],
-            "version": "==1.4.2"
+                "sha256:0c4b206227a8097f05c4dbdd323c50edf81f15db3b8dc064d08c62d37e1a504d",
+                "sha256:194d092e6f246b906e8f70884e620e459fc54db3259e60cf69a4d66c3fda3449",
+                "sha256:1be7e4c9f96948003609aa6c974ae59830a6baecc5376c25c92d7d697e684c08",
+                "sha256:4677f594e474c91da97f489fea5b7daa17b5517190899cf213697e48d3902f5a",
+                "sha256:48dab84ebd4831077b150572aec802f303117c8cc5c871e182447281ebf3ac50",
+                "sha256:5541cada25cd173702dbd99f8e22434105456314462326f06dba3e180f203dfd",
+                "sha256:59f79fef100b09564bc2df42ea2d8d21a64fdcda64979c0fa3db7bdaabaf6239",
+                "sha256:8d859b89baf8ef7f8bc6b00aa20316483d67f0b1cbf422f5b4dc56701c8f2ffb",
+                "sha256:9254f4358b9b541e3441b007a0ea0764b9d056afdeafc1a5569eee1cc6c1b9ea",
+                "sha256:9651375199045a358eb6741df3e02a651e0330be090b3bc79f6d0de31a80ec3e",
+                "sha256:97bb5884f6f1cdce0099f86b907aa41c970c3c672ac8b9c8352789e103cf3156",
+                "sha256:9b15f3f4c0f35727d3a0fba4b770b3c4ebbb1fa907dbcc046a1d2799f3edd142",
+                "sha256:a2238e9d1bb71a56cd710611a1614d1194dc10a175c1e08d75e1a7bcc250d442",
+                "sha256:a6ae12d08c0bf9909ce12385803a543bfe99b95fe01e752536a60af2b7797c62",
+                "sha256:ca0a928a3ddbc5725be2dd1cf895ec0a254798915fb3a36af0964a0a4149e3db",
+                "sha256:cb2c7c57005a6804ab66f106ceb8482da55f5314b7fcb06551db1edae4ad1531",
+                "sha256:d74bb8693bf9cf75ac3b47a54d716bbb1a92648d5f781fc799347cfc95952383",
+                "sha256:d945239a5639b3ff35b70a88c5f2f491913eb94871780ebfabb2568bd58afc5a",
+                "sha256:eba7011090323c1dadf18b3b689845fd96a61ba0a1dfbd7f24b921398affc357",
+                "sha256:efa1909120ce98bbb3777e8b6f92237f5d5c8ea6758efea36a473e1d38f7d3e4",
+                "sha256:f3900e8a5de27447acbf900b4750b0ddfd7ec1ea7fbaf11dfa911141bc522af0"
+            ],
+            "version": "==1.4.3"
         },
         "mccabe": {
             "hashes": [
@@ -129,6 +154,12 @@
             ],
             "version": "==19.2"
         },
+        "pathspec": {
+            "hashes": [
+                "sha256:e285ccc8b0785beadd4c18e5708b12bb8fcf529a1e61215b3feff1d1e559ea5c"
+            ],
+            "version": "==0.6.0"
+        },
         "pluggy": {
             "hashes": [
                 "sha256:0db4b7601aae1d35b4a033282da476845aa19185c1e6964b25cf324b5e4ec3e6",
@@ -153,10 +184,10 @@
         },
         "pyparsing": {
             "hashes": [
-                "sha256:6f98a7b9397e206d78cc01df10131398f1c8b8510a2f4d97d9abd82e1aacdd80",
-                "sha256:d9338df12903bbf5d65a0e4e87c2161968b10d2e489652bb47001d82a9b028b4"
+                "sha256:20f995ecd72f2a1f4bf6b072b63b22e2eb457836601e76d6e5dfcd75436acc1f",
+                "sha256:4ca62001be367f01bd3e92ecbb79070272a9d4964dce6a48a82ff0b8bc7e683a"
             ],
-            "version": "==2.4.2"
+            "version": "==2.4.5"
         },
         "pytest": {
             "hashes": [
@@ -166,12 +197,37 @@
             "index": "pypi",
             "version": "==5.2.2"
         },
+        "regex": {
+            "hashes": [
+                "sha256:15454b37c5a278f46f7aa2d9339bda450c300617ca2fca6558d05d870245edc7",
+                "sha256:1ad40708c255943a227e778b022c6497c129ad614bb7a2a2f916e12e8a359ee7",
+                "sha256:5e00f65cc507d13ab4dfa92c1232d004fa202c1d43a32a13940ab8a5afe2fb96",
+                "sha256:604dc563a02a74d70ae1f55208ddc9bfb6d9f470f6d1a5054c4bd5ae58744ab1",
+                "sha256:720e34a539a76a1fedcebe4397290604cc2bdf6f81eca44adb9fb2ea071c0c69",
+                "sha256:7caf47e4a9ac6ef08cabd3442cc4ca3386db141fb3c8b2a7e202d0470028e910",
+                "sha256:7faf534c1841c09d8fefa60ccde7b9903c9b528853ecf41628689793290ca143",
+                "sha256:b4e0406d822aa4993ac45072a584d57aa4931cf8288b5455bbf30c1d59dbad59",
+                "sha256:c31eaf28c6fe75ea329add0022efeed249e37861c19681960f99bbc7db981fb2",
+                "sha256:c7393597191fc2043c744db021643549061e12abe0b3ff5c429d806de7b93b66",
+                "sha256:d2b302f8cdd82c8f48e9de749d1d17f85ce9a0f082880b9a4859f66b07037dc6",
+                "sha256:e3d8dd0ec0ea280cf89026b0898971f5750a7bd92cb62c51af5a52abd020054a",
+                "sha256:ec032cbfed59bd5a4b8eab943c310acfaaa81394e14f44454ad5c9eba4f24a74"
+            ],
+            "version": "==2019.11.1"
+        },
         "six": {
             "hashes": [
-                "sha256:3350809f0555b11f552448330d0b52d5f24c91a322ea4a15ef22629740f3761c",
-                "sha256:d16a0141ec1a18405cd4ce8b4613101da75da0e9a7aec5bdd4fa804d0e0eba73"
+                "sha256:1f1b7d42e254082a9db6279deae68afb421ceba6158efa6131de7b3003ee93fd",
+                "sha256:30f610279e8b2578cab6db20741130331735c781b56053c59c4076da27f06b66"
+            ],
+            "version": "==1.13.0"
+        },
+        "toml": {
+            "hashes": [
+                "sha256:229f81c57791a41d65e399fc06bf0848bab550a9dfd5ed66df18ce5f05e73d5c",
+                "sha256:235682dd292d5899d361a811df37e04a8828a5b1da3115886b73cf81ebc9100e"
             ],
-            "version": "==1.12.0"
+            "version": "==0.10.0"
         },
         "typed-ast": {
             "hashes": [

+ 2 - 0
README.md

@@ -1,3 +1,5 @@
+[![Code style: black](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/psf/black)
+
 ## develop
 
 ```sh

+ 8 - 14
setup.py

@@ -1,23 +1,17 @@
 import setuptools
 
 setuptools.setup(
-    name='symuid',
+    name="symuid",
     use_scm_version=True,
     packages=setuptools.find_packages(),
     entry_points={
-        'console_scripts': [
-            'symuid-import-cmus = symuid.import_library.cmus_cache:_main',
-            'symuid-import-itunes = symuid.import_library.itunes_xml:_main',
-            'symuid-list = symuid.list:_main',
-            'symuid-sync = symuid.sync:_main',
+        "console_scripts": [
+            "symuid-import-cmus = symuid.import_library.cmus_cache:_main",
+            "symuid-import-itunes = symuid.import_library.itunes_xml:_main",
+            "symuid-list = symuid.list:_main",
+            "symuid-sync = symuid.sync:_main",
         ],
     },
-    install_requires=[
-        'mutagen < 2',
-        'python-dateutil < 3',
-        'pytz',
-    ],
-    setup_requires=[
-        'setuptools_scm',
-    ],
+    install_requires=["mutagen < 2", "python-dateutil < 3", "pytz",],
+    setup_requires=["setuptools_scm",],
 )

+ 21 - 18
symuid/__init__.py

@@ -10,7 +10,6 @@ from symuid._datetime import datetime_utc_now, unix_epoch_time_to_datetime_utc
 
 
 class PlayCount:
-
     def __init__(self, player, library_id, register_dt, count):
         self.player = player
         self.library_id = library_id
@@ -29,14 +28,14 @@ class PlayCount:
         return hash(tuple(v for k, v in attrs_sorted))
 
     def __repr__(self):
-        return 'PlayCount({})'.format(', '.join(
-            '{}={!r}'.format(k, v) for k, v in vars(self).items()
-        ))
+        return "PlayCount({})".format(
+            ", ".join("{}={!r}".format(k, v) for k, v in vars(self).items())
+        )
 
 
 class Track:
 
-    PATH_DEFAULT_IGNORE_REGEX = r'\.(itdb|itc2|itl|jpg|midi?|plist|xml|zip)$'
+    PATH_DEFAULT_IGNORE_REGEX = r"\.(itdb|itc2|itl|jpg|midi?|plist|xml|zip)$"
 
     def __init__(self, path):
         self._iface = self._select_tag_interface(path)
@@ -50,12 +49,13 @@ class Track:
             return _tag_interface.ID3(mutagen_file)
         if isinstance(mutagen_file.tags, mutagen.mp4.MP4Tags):
             return _tag_interface.MP4(mutagen_file)
-        if isinstance(mutagen_file, (mutagen.oggopus.OggOpus,
-                                     mutagen.oggvorbis.OggVorbis)):
+        if isinstance(
+            mutagen_file, (mutagen.oggopus.OggOpus, mutagen.oggvorbis.OggVorbis)
+        ):
             return _tag_interface.Ogg(mutagen_file)
         raise NotImplementedError((track_path, type(mutagen_file)))
 
-    def __eq__(self, other: 'Track') -> bool:
+    def __eq__(self, other: "Track") -> bool:
         return self.path == other.path
 
     def __hash__(self) -> int:
@@ -84,21 +84,20 @@ class Track:
         self._iface.save()
 
     def _get_play_counts(self, player=None, library_id=None):
-        label = 'symuid:pcnt'
+        label = "symuid:pcnt"
         assert library_id is None or player is not None
         if player:
-            label += ':' + player
+            label += ":" + player
             if library_id:
-                label += ':' + library_id
+                label += ":" + library_id
         elif library_id:
             raise Exception((player, library_id))
         for k, count in self._iface.get_free_ints(label):
-            player, library_id, register_ts_dec = k.split(':')[2:]
+            player, library_id, register_ts_dec = k.split(":")[2:]
             yield PlayCount(
                 player=player,
                 library_id=library_id,
-                register_dt=unix_epoch_time_to_datetime_utc(
-                    int(register_ts_dec)),
+                register_dt=unix_epoch_time_to_datetime_utc(int(register_ts_dec)),
                 count=count,
             )
 
@@ -110,8 +109,11 @@ class Track:
         for count in self._get_play_counts(player, library_id):
             if not count.player in latest:
                 latest[count.player] = {}
-            if not count.library_id in latest[count.player] \
-                    or latest[count.player][count.library_id].register_dt < count.register_dt:
+            if (
+                not count.library_id in latest[count.player]
+                or latest[count.player][count.library_id].register_dt
+                < count.register_dt
+            ):
                 latest[count.player][count.library_id] = count
         return [c for p in latest.values() for c in p.values()]
 
@@ -129,8 +131,9 @@ class Track:
 
     def register_play_count(self, play_count, tag_set_cb=None):
         assert isinstance(play_count, PlayCount), play_count
-        tag_label = 'symuid:pcnt:{}:{}:{:.0f}'.format(
-            play_count.player, play_count.library_id,
+        tag_label = "symuid:pcnt:{}:{}:{:.0f}".format(
+            play_count.player,
+            play_count.library_id,
             play_count.register_dt.timestamp(),
         )
         current_count = self._iface.get_free_int(tag_label)

+ 4 - 2
symuid/_datetime.py

@@ -2,8 +2,10 @@ import datetime
 
 
 def unix_epoch_time_to_datetime_utc(ts_sec: int) -> datetime.datetime:
-    return datetime.datetime.utcfromtimestamp(ts_sec) \
-        .replace(tzinfo=datetime.timezone.utc)
+    return datetime.datetime.utcfromtimestamp(ts_sec).replace(
+        tzinfo=datetime.timezone.utc
+    )
+
 
 def datetime_utc_now() -> datetime.datetime:
     return datetime.datetime.utcnow().replace(tzinfo=datetime.timezone.utc)

+ 52 - 40
symuid/_tag_interface.py

@@ -9,7 +9,6 @@ from symuid._uuid import uuid_bytes_to_str, uuid_str_to_bytes
 
 
 class TagInterface(abc.ABC):
-
     @abc.abstractproperty
     def track_path(self):
         pass
@@ -65,11 +64,10 @@ class _MutagenTagInterface(TagInterface):
 class ID3(_MutagenTagInterface):
 
     # http://id3.org/id3v2.4.0-frames#4.1.
-    _UFID_OWNER_ID = 'symuid'
+    _UFID_OWNER_ID = "symuid"
 
     def __init__(self, mutagen_file):
-        assert isinstance(mutagen_file.tags, mutagen.id3.ID3), \
-            mutagen_file.tags
+        assert isinstance(mutagen_file.tags, mutagen.id3.ID3), mutagen_file.tags
         super().__init__(mutagen_file)
 
     def _get_str(self, tag_label) -> str:
@@ -81,10 +79,10 @@ class ID3(_MutagenTagInterface):
         raise ValueError(tag)
 
     def _get_free_str(self, tag_label) -> str:
-        return self._get_str('TXXX:' + tag_label)
+        return self._get_str("TXXX:" + tag_label)
 
     def get_free_ints(self, tag_label_prefix):
-        for tag in self._mutagen_file.tags.getall('TXXX:' + tag_label_prefix):
+        for tag in self._mutagen_file.tags.getall("TXXX:" + tag_label_prefix):
             assert len(tag.text) == 1, tag
             yield (tag.desc, int(tag.text[0]))
 
@@ -95,15 +93,13 @@ class ID3(_MutagenTagInterface):
             return None
 
     def get_free_int_ratio(self, tag_label) -> typing.Tuple[int, int]:
-        nominator, denominator = map(int, self._get_free_str(tag_label).split('/'))
+        nominator, denominator = map(int, self._get_free_str(tag_label).split("/"))
         return (nominator, denominator)
 
     def _set_free_str(self, tag_label: str, string: str) -> mutagen.id3.TXXX:
         # mutagen.id3._specs.EncodedTextSpec.write encodes 'desc' and 'text'
         tag = mutagen.id3.TXXX(
-            encoding=mutagen.id3.Encoding.LATIN1,
-            desc=tag_label,
-            text=[string],
+            encoding=mutagen.id3.Encoding.LATIN1, desc=tag_label, text=[string],
         )
         # TODO overwrite instead of add() ?
         self._mutagen_file.tags.add(tag)
@@ -112,27 +108,28 @@ class ID3(_MutagenTagInterface):
     def set_free_int(self, tag_label: str, data: str) -> mutagen.id3.TXXX:
         return self._set_free_str(tag_label=tag_label, string=str(data))
 
-    def set_free_int_ratio(self, tag_label, numerator: int, denominator: int) -> mutagen.id3.TXXX:
-        return self._set_free_str(tag_label=tag_label,
-                                  string='{}/{}'.format(numerator, denominator))
+    def set_free_int_ratio(
+        self, tag_label, numerator: int, denominator: int
+    ) -> mutagen.id3.TXXX:
+        return self._set_free_str(
+            tag_label=tag_label, string="{}/{}".format(numerator, denominator)
+        )
 
     def get_comment(self) -> typing.Optional[str]:
         try:
-            return self._get_str('COMM::eng')
+            return self._get_str("COMM::eng")
         except KeyError:
             return None
 
     def set_comment(self, comment) -> mutagen.id3.COMM:
         tag = mutagen.id3.COMM(
-            encoding=mutagen.id3.Encoding.UTF8,
-            lang='eng',
-            text=[comment],
+            encoding=mutagen.id3.Encoding.UTF8, lang="eng", text=[comment],
         )
         self._mutagen_file.tags.add(tag)
         return tag
 
     def get_track_uuid(self) -> typing.Optional:
-        for ufid in self._mutagen_file.tags.getall('UFID'):
+        for ufid in self._mutagen_file.tags.getall("UFID"):
             if ufid.owner == self._UFID_OWNER_ID:
                 return ufid.data
         return None
@@ -146,36 +143,35 @@ class ID3(_MutagenTagInterface):
 
 class MP4(_MutagenTagInterface):
 
-    _UUID_TAG_KEY = 'symuid:uuid'
-    _COMMENT_TAG_KEY = '\xa9cmt'
+    _UUID_TAG_KEY = "symuid:uuid"
+    _COMMENT_TAG_KEY = "\xa9cmt"
 
     def __init__(self, mutagen_file):
         assert mutagen_file.tags is not None, mutagen_file
-        assert isinstance(mutagen_file.tags, mutagen.mp4.MP4Tags), \
-            mutagen_file.tags
+        assert isinstance(mutagen_file.tags, mutagen.mp4.MP4Tags), mutagen_file.tags
         super().__init__(mutagen_file)
 
     @staticmethod
     def _freeform_to_int(freeform):
         # "a signed big-endian integer with length one of { 1,2,3,4,8 } bytes"
         assert freeform.dataformat == mutagen.mp4.AtomDataType.INTEGER, freeform
-        return int.from_bytes(freeform, byteorder='big', signed=True)
+        return int.from_bytes(freeform, byteorder="big", signed=True)
 
     def get_free_ints(self, tag_label_prefix):
-        label_pattern = re.compile(r'^----:{}(:|$)'.format(
-            re.escape(tag_label_prefix),
-        ))
+        label_pattern = re.compile(
+            r"^----:{}(:|$)".format(re.escape(tag_label_prefix),)
+        )
         for label, values in self._mutagen_file.tags.items():
             # TODO overwrite instead of add() ?
             if label_pattern.match(label):
                 assert len(values) == 1, (label, values)
                 value = MP4._freeform_to_int(values[0])
-                yield (re.sub(r'^----:', '', label), value)
+                yield (re.sub(r"^----:", "", label), value)
 
     def _get_free(self, tag_label) -> typing.List[mutagen.mp4.MP4FreeForm]:
         # freeform keys start with '----'
         # http://mutagen.readthedocs.io/en/latest/api/mp4.html
-        tag = self._mutagen_file.tags.get('----:' + tag_label, None)
+        tag = self._mutagen_file.tags.get("----:" + tag_label, None)
         if not tag:
             raise KeyError(tag_label)
         return tag
@@ -199,7 +195,7 @@ class MP4(_MutagenTagInterface):
         return ints
 
     def _get_free_uuid(self, tag_label) -> mutagen.mp4.MP4FreeForm:
-        tag, = self._get_free(tag_label)
+        (tag,) = self._get_free(tag_label)
         if tag.dataformat != mutagen.mp4.AtomDataType.UUID:
             raise ValueError(tag)
         return tag
@@ -207,7 +203,7 @@ class MP4(_MutagenTagInterface):
     def _set_free(self, tag_label, dataformat, data):
         assert isinstance(data, bytes)
         tag = mutagen.mp4.MP4FreeForm(dataformat=dataformat, data=data)
-        self._mutagen_file.tags['----:' + tag_label] = [tag]
+        self._mutagen_file.tags["----:" + tag_label] = [tag]
         return tag
 
     @staticmethod
@@ -215,7 +211,7 @@ class MP4(_MutagenTagInterface):
         # "a signed big-endian integer with length one of { 1,2,3,4,8 } bytes"
         # TODO exclude 5-7, 9-
         byte_len = max((integer.bit_length() + 8) // 8, 1)
-        return integer.to_bytes(byte_len, byteorder='big', signed=True)
+        return integer.to_bytes(byte_len, byteorder="big", signed=True)
 
     def set_free_int(self, tag_label, data):
         assert isinstance(data, int)
@@ -225,6 +221,19 @@ class MP4(_MutagenTagInterface):
             data=self._int_to_freeform_data(data),
         )
 
+    def set_free_int_ratio(
+        self, tag_label, numerator: int, denominator: int
+    ) -> typing.List[mutagen.mp4.MP4FreeForm]:
+        tag = [
+            mutagen.mp4.MP4FreeForm(
+                dataformat=mutagen.mp4.AtomDataType.INTEGER,
+                data=self._int_to_freeform_data(i),
+            )
+            for i in (numerator, denominator)
+        ]
+        self._mutagen_file.tags["----:" + tag_label] = tag
+        return tag
+
     def _set_free_uuid(self, tag_label, data):
         return self._set_free(
             tag_label=tag_label,
@@ -259,13 +268,14 @@ class Ogg(_MutagenTagInterface):
     # https://github.com/cmus/cmus/blob/9a0723f7a90dc7de0898be87963d5105a999aa6c/ip/opus.c#L229
     # https://github.com/cmus/cmus/blob/9a0723f7a90dc7de0898be87963d5105a999aa6c/ip/vorbis.c#L319
     # https://github.com/cmus/cmus/blob/17bf542c6b120d9dcf6642b259d78badfc1143eb/comment.c#L224
-    _COMMENT_TAG_KEY = 'comment'
-    _UUID_TAG_KEY = 'symuid:uuid'
+    _COMMENT_TAG_KEY = "comment"
+    _UUID_TAG_KEY = "symuid:uuid"
 
     def __init__(self, mutagen_file):
-        assert isinstance(mutagen_file.tags, (mutagen.oggopus.OggOpusVComment,
-                                              mutagen.oggvorbis.OggVCommentDict)), \
-            (type(mutagen_file), type(mutagen_file.tags))
+        assert isinstance(
+            mutagen_file.tags,
+            (mutagen.oggopus.OggOpusVComment, mutagen.oggvorbis.OggVCommentDict),
+        ), (type(mutagen_file), type(mutagen_file.tags))
         super().__init__(mutagen_file)
 
     def _get_single_text(self, tag_label) -> typing.Optional[str]:
@@ -295,11 +305,13 @@ class Ogg(_MutagenTagInterface):
         dec = self._get_single_text(tag_label)
         return int(dec) if dec else None
 
-    def get_free_ints(self, tag_label_prefix: str) \
-            -> typing.Iterator[typing.Tuple[str, int]]:
+    def get_free_ints(
+        self, tag_label_prefix: str
+    ) -> typing.Iterator[typing.Tuple[str, int]]:
         for tag_key, tag_value in self._mutagen_file.items():
-            if tag_key == tag_label_prefix \
-                    or tag_key.startswith(tag_label_prefix + ':'):
+            if tag_key == tag_label_prefix or tag_key.startswith(
+                tag_label_prefix + ":"
+            ):
                 if len(tag_value) > 1:
                     raise ValueError((self.track_path, tag_key, tag_value))
                 yield (tag_key, int(tag_value[0]))

+ 7 - 6
symuid/_uuid.py

@@ -8,7 +8,7 @@ def uuid_str_to_int(uuid_str: str) -> int:
     >>> uuid_str_to_int('c064bf40-4fd1-50e1-a2f7-2aefa4593f67')
     255734883912096391966117250963728842599
     """
-    return int(uuid_str.replace('-', ''), 16)
+    return int(uuid_str.replace("-", ""), 16)
 
 
 def uuid_int_to_str(uuid_int: str) -> str:
@@ -19,8 +19,9 @@ def uuid_int_to_str(uuid_int: str) -> str:
     'c064bf40-4fd1-50e1-a2f7-2aefa4593f67'
     """
     uuid_hex = hex(uuid_int)[2:]
-    return '-'.join((uuid_hex[0:8], uuid_hex[8:12], uuid_hex[12:16],
-                     uuid_hex[16:20], uuid_hex[20:]))
+    return "-".join(
+        (uuid_hex[0:8], uuid_hex[8:12], uuid_hex[12:16], uuid_hex[16:20], uuid_hex[20:])
+    )
 
 
 def uuid_int_to_bytes(uuid_siv: int) -> bytes:
@@ -30,7 +31,7 @@ def uuid_int_to_bytes(uuid_siv: int) -> bytes:
     >>> uuid_int_to_bytes(255734883912096391966117250963728842599)
     b'\\xc0d\\xbf@O\\xd1P\\xe1\\xa2\\xf7*\\xef\\xa4Y?g'
     """
-    return uuid_siv.to_bytes(16, byteorder='big')
+    return uuid_siv.to_bytes(16, byteorder="big")
 
 
 def uuid_bytes_to_int(uuid_bytes: bytes) -> int:
@@ -40,7 +41,7 @@ def uuid_bytes_to_int(uuid_bytes: bytes) -> int:
     >>> uuid_bytes_to_int(b'\\xc0d\\xbf@O\\xd1P\\xe1\\xa2\\xf7*\\xef\\xa4Y?g')
     255734883912096391966117250963728842599
     """
-    return int.from_bytes(uuid_bytes, byteorder='big', signed=False)
+    return int.from_bytes(uuid_bytes, byteorder="big", signed=False)
 
 
 def uuid_str_to_bytes(uuid_str: str) -> bytes:
@@ -64,4 +65,4 @@ def uuid_bytes_to_str(uuid_bytes: bytes) -> str:
 
 
 def generate_uuid4_bytes() -> bytes:
-    return subprocess.check_output(['uuid', '-v', '4', '-F', 'BIN']).strip()
+    return subprocess.check_output(["uuid", "-v", "4", "-F", "BIN"]).strip()

+ 23 - 15
symuid/import_library/cmus_cache.py

@@ -9,23 +9,28 @@ from symuid._datetime import datetime_utc_now
 # TODO rename module to cmus.py
 # after ImportMismatchError on `pytest --doctest-modules` was fixed
 
-_PLAYER_NAME = 'cmus'
+_PLAYER_NAME = "cmus"
 _LIBRARY_ID_LENGTH_MIN = 8
 
+
 def symuid_import_cmus(library_id, cache_path):
     assert len(library_id) >= _LIBRARY_ID_LENGTH_MIN, library_id
     lib = symuid.library.cmus.Cache(cache_path)
     for cmus_track in lib.get_tracks():
         if not os.path.exists(cmus_track.path):
-            sys.stderr.write('{!r}: not found\n'.format(cmus_track.path))
-        elif cmus_track.play_count > 0:  # check before symuid.Track to improve performance
+            sys.stderr.write("{!r}: not found\n".format(cmus_track.path))
+        elif (
+            cmus_track.play_count > 0
+        ):  # check before symuid.Track to improve performance
             symuid_track = symuid.Track(path=cmus_track.path.decode())
             last_count = symuid_track.get_latest_play_count(
-                player=_PLAYER_NAME,
-                library_id=library_id,
+                player=_PLAYER_NAME, library_id=library_id,
+            )
+            assert last_count is None or last_count.count <= cmus_track.play_count, (
+                symuid_track.path,
+                last_count.count,
+                cmus_track.play_count,
             )
-            assert last_count is None or last_count.count <= cmus_track.play_count, \
-                (symuid_track.path, last_count.count, cmus_track.play_count)
             if last_count is None or last_count.count != cmus_track.play_count:
                 symuid_track.register_play_count(
                     symuid.PlayCount(
@@ -34,19 +39,22 @@ def symuid_import_cmus(library_id, cache_path):
                         register_dt=datetime_utc_now(),
                         count=cmus_track.play_count,
                     ),
-                    tag_set_cb=lambda track, tag:
-                    print('{!r}: set tag {!r}'.format(track.path, tag)),
+                    tag_set_cb=lambda track, tag: print(
+                        "{!r}: set tag {!r}".format(track.path, tag)
+                    ),
                 )
 
 
 def _main():
-    argparser = argparse.ArgumentParser(description="Import play counts from cmus' cache")
-    argparser.add_argument('library_id')
+    argparser = argparse.ArgumentParser(
+        description="Import play counts from cmus' cache"
+    )
+    argparser.add_argument("library_id")
     argparser.add_argument(
-        'cache_path',
-        nargs='?',
-        default=os.path.expanduser('~/.config/cmus/cache'),
-        help='(default: %(default)r)',
+        "cache_path",
+        nargs="?",
+        default=os.path.expanduser("~/.config/cmus/cache"),
+        help="(default: %(default)r)",
     )
     args = argparser.parse_args()
     symuid_import_cmus(**vars(args))

+ 14 - 10
symuid/import_library/itunes_xml.py

@@ -9,6 +9,7 @@ import symuid.library.itunes
 # TODO rename module to itunes.py
 # after ImportMismatchError on `pytest --doctest-modules` was fixed
 
+
 def import_itunes_xml_library(xml_library_path, path_regex_sub):
     lib = symuid.library.itunes.XmlLibrary(xml_library_path)
     for itunes_track in lib.tracks:
@@ -18,32 +19,35 @@ def import_itunes_xml_library(xml_library_path, path_regex_sub):
             for pattern, repl in path_regex_sub:
                 track_path = re.sub(pattern, repl, track_path)
             if not os.path.exists(track_path):
-                sys.stderr.write('{!r}: not found\n'.format(track_path))
+                sys.stderr.write("{!r}: not found\n".format(track_path))
             else:
                 symuid_track = symuid.Track(path=track_path)
                 # TODO dt=dt.datetime.now()
                 symuid_track.register_play_count(
                     symuid.PlayCount(
-                        player='itunes',
+                        player="itunes",
                         library_id=lib.library_id,
                         register_dt=itunes_track.last_play_dt,
                         count=itunes_track.play_count,
                     ),
-                    tag_set_cb=lambda tr, tag:
-                    print('{!r}: set tag {!r}'.format(tr.path, tag)),
+                    tag_set_cb=lambda tr, tag: print(
+                        "{!r}: set tag {!r}".format(tr.path, tag)
+                    ),
                 )
 
 
 def _main():
-    argparser = argparse.ArgumentParser(description='Import play counts from iTunes XML Library')
-    argparser.add_argument('xml_library_path')
+    argparser = argparse.ArgumentParser(
+        description="Import play counts from iTunes XML Library"
+    )
+    argparser.add_argument("xml_library_path")
     argparser.add_argument(
-        '--path-regex-sub',
+        "--path-regex-sub",
         nargs=2,
-        action='append',
-        metavar=('regex', 'replacement'),
+        action="append",
+        metavar=("regex", "replacement"),
         default=[],
-        help='(default: no substitution)',
+        help="(default: no substitution)",
     )
     args = argparser.parse_args()
     import_itunes_xml_library(**vars(args))

+ 10 - 9
symuid/library/cmus.py

@@ -8,8 +8,8 @@ def _int_from_bytes_sys(data_bytes):
 
 class Track:
 
-    RESERVED_PAD_REGEX = rb'\xff{16,}'
-    STRING_TERMINATOR = b'\x00'
+    RESERVED_PAD_REGEX = rb"\xff{16,}"
+    STRING_TERMINATOR = b"\x00"
 
     def __init__(self, cache_size, cache_bytes):
         """
@@ -32,8 +32,9 @@ class Track:
         # self._duration_seconds = _int_from_bytes_sys(cache_bytes[12:16])
         # self._bitrate = _int_from_bytes_sys(cache_bytes[16:20])
         # self._bpm = _int_from_bytes_sys(cache_bytes[20:24])
-        strings = re.split(self.RESERVED_PAD_REGEX, cache_bytes)[1] \
-            .split(self.STRING_TERMINATOR)
+        strings = re.split(self.RESERVED_PAD_REGEX, cache_bytes)[1].split(
+            self.STRING_TERMINATOR
+        )
         self._path = strings[0]
 
     @property
@@ -49,11 +50,11 @@ class Cache:
 
     # pylint: disable=too-few-public-methods
 
-    FILE_PREFIX = b'CTC'
+    FILE_PREFIX = b"CTC"
     VERSION_LENGTH = 1
-    SUPPORTED_VERSIONS = [b'\x0c', b'\x0d']
+    SUPPORTED_VERSIONS = [b"\x0c", b"\x0d"]
     # always big endian, see cache_init()
-    FLAGS_BYTEORDER = 'big'
+    FLAGS_BYTEORDER = "big"
     FLAGS_LENGTH = 4
     FLAG_64_BIT = 0x01
 
@@ -61,7 +62,7 @@ class Cache:
 
     def __init__(self, path):
         self._path = path
-        with open(self._path, 'rb') as stream:
+        with open(self._path, "rb") as stream:
             # see cache.c cache_init()
             assert stream.read(len(self.FILE_PREFIX)) == self.FILE_PREFIX
             cache_version = stream.read(self.VERSION_LENGTH)
@@ -75,7 +76,7 @@ class Cache:
             assert flags & ~self.FLAG_64_BIT == 0, flags
 
     def get_tracks(self):
-        with open(self._path, 'rb') as stream:
+        with open(self._path, "rb") as stream:
             stream.seek(self.HEADER_LENGTH)
             # size includes size itself
             while True:

+ 19 - 20
symuid/library/itunes.py

@@ -6,28 +6,27 @@ import dateutil.parser
 
 
 class XmlDict:
-
     def __init__(self, node):
         assert isinstance(node, xml.etree.ElementTree.Element), node
-        assert node.tag == 'dict', node.tag
+        assert node.tag == "dict", node.tag
         self._node = node
 
     def _get_value_node(self, key):
         # WORKAROUND method getnext() is sadly not available
         for child_idx, child_node in enumerate(self._node):
-            if child_node.tag == 'key' and child_node.text == key:
+            if child_node.tag == "key" and child_node.text == key:
                 return self._node[child_idx + 1]
         raise KeyError(key)
 
     @staticmethod
     def _parse_value_node(value_node):
-        if value_node.tag == 'string':
+        if value_node.tag == "string":
             return value_node.text
-        if value_node.tag == 'integer':
+        if value_node.tag == "integer":
             return int(value_node.text)
-        if value_node.tag == 'date':
+        if value_node.tag == "date":
             return dateutil.parser.parse(value_node.text)
-        if value_node.tag == 'dict':
+        if value_node.tag == "dict":
             return XmlDict(value_node)
         raise ValueError(value_node.tag)
 
@@ -43,7 +42,7 @@ class XmlDict:
 
     def items(self):
         for key_node, value_node in zip(*[iter(self._node)] * 2):
-            assert key_node.tag == 'key'
+            assert key_node.tag == "key"
             key = key_node.text
             value = XmlDict._parse_value_node(value_node)
             yield (key, value)
@@ -55,17 +54,17 @@ class XmlDict:
 
 class Track:
 
-    LOCAL_LOCATION_URL_PREFIX = 'file://localhost/'
+    LOCAL_LOCATION_URL_PREFIX = "file://localhost/"
 
     def __init__(self, track_dict):
         assert isinstance(track_dict, XmlDict)
         self._dict = track_dict
-        self._id = self._dict['Track ID']
+        self._id = self._dict["Track ID"]
         assert isinstance(self._id, int)
-        self._location_url = self._dict.get('Location', None)
-        self._play_count = self._dict.get('Play Count', 0)
+        self._location_url = self._dict.get("Location", None)
+        self._play_count = self._dict.get("Play Count", 0)
         assert isinstance(self._play_count, int)
-        self._last_play_dt = self._dict.get('Play Date UTC', None)
+        self._last_play_dt = self._dict.get("Play Date UTC", None)
 
     @property
     def track_id(self):
@@ -77,14 +76,15 @@ class Track:
 
     @property
     def local(self):
-        return self.location_url is not None \
-            and self.location_url.startswith(self.LOCAL_LOCATION_URL_PREFIX)
+        return self.location_url is not None and self.location_url.startswith(
+            self.LOCAL_LOCATION_URL_PREFIX
+        )
 
     @property
     def local_path(self):
         if self.local:
             return os.path.sep + urllib.parse.unquote(
-                self.location_url[len(self.LOCAL_LOCATION_URL_PREFIX):],
+                self.location_url[len(self.LOCAL_LOCATION_URL_PREFIX) :],
             )
         raise ValueError(self.location_url)
 
@@ -98,11 +98,10 @@ class Track:
 
 
 class XmlLibrary:
-
     def __init__(self, path):
         self._tree = xml.etree.ElementTree.parse(path)
-        self._root_dict = XmlDict(self._tree.find('./dict'))
-        self._id = self._root_dict['Library Persistent ID']
+        self._root_dict = XmlDict(self._tree.find("./dict"))
+        self._id = self._root_dict["Library Persistent ID"]
         assert isinstance(self._id, str), self._id
         assert len(self._id) > 4
 
@@ -112,5 +111,5 @@ class XmlLibrary:
 
     @property
     def tracks(self):
-        for track_dict in self._root_dict['Tracks'].values():
+        for track_dict in self._root_dict["Tracks"].values():
             yield Track(track_dict)

+ 29 - 27
symuid/list.py

@@ -7,50 +7,50 @@ import symuid
 
 def _walk_track_attrs(path, path_ignore_regex) -> typing.Iterator[dict]:
     for track in symuid.Track.walk(path, path_ignore_regex):
-        yield {'path': track.path,
-               'comment': track.comment,
-               'play_count': track.get_play_count_sum()}
+        yield {
+            "path": track.path,
+            "comment": track.comment,
+            "play_count": track.get_play_count_sum(),
+        }
+
 
 def _init_argparser():
-    argparser = argparse.ArgumentParser(description='filter & sort tracks')
-    argparser.add_argument('path')
+    argparser = argparse.ArgumentParser(description="filter & sort tracks")
+    argparser.add_argument("path")
     argparser.add_argument(
-        '--path-ignore-regex',
+        "--path-ignore-regex",
         default=symuid.Track.PATH_DEFAULT_IGNORE_REGEX,
-        dest='path_ignore_regex',
+        dest="path_ignore_regex",
         nargs=1,
         type=re.compile,
-        metavar='pattern',
-        help='(default: %(default)s)',
+        metavar="pattern",
+        help="(default: %(default)s)",
     )
     argparser.add_argument(
-        '--filter',
-        metavar='expression',
-        dest='filter_expression',
-        help='(example: {!r} or {!r})'.format(
+        "--filter",
+        metavar="expression",
+        dest="filter_expression",
+        help="(example: {!r} or {!r})".format(
             "play_count > 16 and path.endswith('.mp3')",
             "comment is None or len(comment) < 16",
         ),
     )
     argparser.add_argument(
-        '--sort',
-        metavar='expression',
-        dest='sort_expression',
-        help='(example: {!r} or {!r})'.format(
-            "play_count * -1",
-            "(play_count, len(path))",
+        "--sort",
+        metavar="expression",
+        dest="sort_expression",
+        help="(example: {!r} or {!r})".format(
+            "play_count * -1", "(play_count, len(path))",
         ),
     )
     argparser.add_argument(
-        '--limit',
-        type=int,
-        help='(default: none)',
+        "--limit", type=int, help="(default: none)",
     )
     argparser.add_argument(
-        '--prefix',
+        "--prefix",
         type=str,
-        default='',
-        help='add prefix to each resulting path (default: %(default)r)',
+        default="",
+        help="add prefix to each resulting path (default: %(default)r)",
     )
     return argparser
 
@@ -58,7 +58,9 @@ def _init_argparser():
 def _main():
     args = _init_argparser().parse_args()
     # use generators until sort is required
-    track_attrs = _walk_track_attrs(path=args.path, path_ignore_regex=args.path_ignore_regex)
+    track_attrs = _walk_track_attrs(
+        path=args.path, path_ignore_regex=args.path_ignore_regex
+    )
     # pylint: disable=eval-used
     if args.filter_expression:
         track_attrs = filter(lambda a: eval(args.filter_expression, a), track_attrs)
@@ -67,4 +69,4 @@ def _main():
     for track_index, attr in enumerate(track_attrs):
         if args.limit and track_index == args.limit:
             break
-        print(args.prefix + attr['path'])
+        print(args.prefix + attr["path"])

+ 32 - 30
symuid/sync.py

@@ -10,7 +10,7 @@ from symuid._uuid import generate_uuid4_bytes
 
 
 def _log_path(track_path, msg, stream=None):
-    if not stream: # pytest capsys
+    if not stream:  # pytest capsys
         stream = sys.stdout
     stream.write("{!r}: {}\n".format(track_path, msg))
 
@@ -20,10 +20,9 @@ def _log_path_error(track_path, msg):
 
 
 class _SyncPosition:
-
     def __init__(self):
-        self._tracks = set() # type: typing.Set[symuid.Track]
-        self._play_counts = set() # type: typing.Set[symuid.PlayCount]
+        self._tracks = set()  # type: typing.Set[symuid.Track]
+        self._play_counts = set()  # type: typing.Set[symuid.PlayCount]
 
     def add_track(self, track: symuid.Track) -> None:
         self._tracks.add(track)
@@ -35,33 +34,38 @@ class _SyncPosition:
             for play_count in self._play_counts:
                 if play_count not in track_play_counts:
                     track.register_play_count(
-                        play_count, tag_set_cb=play_count_added_cb)
+                        play_count, tag_set_cb=play_count_added_cb
+                    )
 
     def __repr__(self) -> str:
         return repr(vars(self))
 
+
 def sync(tracks: typing.Iterator[symuid.Track], play_count_added_cb=None):
     sync_positions = collections.defaultdict(_SyncPosition)
     for track in tracks:
         if track.get_uuid() is None:
             track.assign_uuid(generate_uuid4_bytes())
-            _log_path(track.path, 'assigned uuid {!r}'.format(
-                track.get_uuid()))
+            _log_path(track.path, "assigned uuid {!r}".format(track.get_uuid()))
         sync_positions[track.get_uuid()].add_track(track)
     for sync_position in sync_positions.values():
         sync_position.sync(play_count_added_cb=play_count_added_cb)
 
 
-def _walk_tracks(paths: typing.List[str], path_ignore_regex=None,
-                 ignored_cb=None, unsupported_cb=None) \
-        -> typing.Iterator[symuid.Track]:
+def _walk_tracks(
+    paths: typing.List[str],
+    path_ignore_regex=None,
+    ignored_cb=None,
+    unsupported_cb=None,
+) -> typing.Iterator[symuid.Track]:
     for path in paths:
         if os.path.isdir(path):
             for track in symuid.Track.walk(
-                    root_path=path,
-                    path_ignore_regex=path_ignore_regex,
-                    ignored_cb=ignored_cb,
-                    unsupported_cb=unsupported_cb):
+                root_path=path,
+                path_ignore_regex=path_ignore_regex,
+                ignored_cb=ignored_cb,
+                unsupported_cb=unsupported_cb,
+            ):
                 yield track
         else:
             yield symuid.Track(path)
@@ -70,32 +74,30 @@ def _walk_tracks(paths: typing.List[str], path_ignore_regex=None,
 def _main():
     argparser = argparse.ArgumentParser(description=None)
     argparser.add_argument(
-        'paths',
-        metavar='path',
-        nargs='+',
-        help='track or folder containing tracks',
+        "paths", metavar="path", nargs="+", help="track or folder containing tracks",
     )
     argparser.add_argument(
-        '--path-ignore-regex',
+        "--path-ignore-regex",
         default=symuid.Track.PATH_DEFAULT_IGNORE_REGEX,
         nargs=1,
-        metavar='pattern',
-        dest='path_ignore_regex',
+        metavar="pattern",
+        dest="path_ignore_regex",
         type=re.compile,
-        help='(default: %(default)s)',
+        help="(default: %(default)s)",
     )
     argparser.add_argument(
-        '--show-ignored',
-        action='store_true',
+        "--show-ignored", action="store_true",
     )
     args = argparser.parse_args()
     tracks = _walk_tracks(
         paths=args.paths,
         path_ignore_regex=args.path_ignore_regex,
-        ignored_cb=lambda p: args.show_ignored and _log_path(p, 'ignored'),
-        unsupported_cb=lambda p, e:
-        _log_path_error(p, 'unsupported type, skipped'),
+        ignored_cb=lambda p: args.show_ignored and _log_path(p, "ignored"),
+        unsupported_cb=lambda p, e: _log_path_error(p, "unsupported type, skipped"),
+    )
+    sync(
+        tracks=tracks,
+        play_count_added_cb=lambda track, tag: _log_path(
+            track.path, "added play count tag {!r}".format(tag)
+        ),
     )
-    sync(tracks=tracks,
-         play_count_added_cb=lambda track, tag:
-         _log_path(track.path, 'added play count tag {!r}'.format(tag)))

+ 9 - 13
tests/conftest.py

@@ -8,44 +8,40 @@ import pytest
 
 @pytest.fixture
 def tracks_dir_path():
-    return os.path.join(os.path.dirname(__file__), 'tracks')
+    return os.path.join(os.path.dirname(__file__), "tracks")
 
 
 @pytest.fixture
 def empty_id3_path(tmpdir, tracks_dir_path):
-    path = tmpdir.join('empty.mp3').strpath
+    path = tmpdir.join("empty.mp3").strpath
     shutil.copyfile(
-        src=os.path.join(tracks_dir_path, 'id3v2.4-empty.mp3'),
-        dst=path,
+        src=os.path.join(tracks_dir_path, "id3v2.4-empty.mp3"), dst=path,
     )
     return path
 
 
 @pytest.fixture
 def empty_mp4_path(tmpdir, tracks_dir_path):
-    path = tmpdir.join('empty.m4a').strpath
+    path = tmpdir.join("empty.m4a").strpath
     shutil.copyfile(
-        src=os.path.join(tracks_dir_path, 'mp4-aac-empty.m4a'),
-        dst=path,
+        src=os.path.join(tracks_dir_path, "mp4-aac-empty.m4a"), dst=path,
     )
     return path
 
 
 @pytest.fixture
 def empty_ogg_opus_path(tmpdir, tracks_dir_path):
-    path = tmpdir.join('empty.opus').strpath
+    path = tmpdir.join("empty.opus").strpath
     shutil.copyfile(
-        src=os.path.join(tracks_dir_path, 'ogg-opus-empty.opus'),
-        dst=path,
+        src=os.path.join(tracks_dir_path, "ogg-opus-empty.opus"), dst=path,
     )
     return path
 
 
 @pytest.fixture
 def empty_ogg_vorbis_path(tmpdir, tracks_dir_path):
-    path = tmpdir.join('empty.ogg').strpath
+    path = tmpdir.join("empty.ogg").strpath
     shutil.copyfile(
-        src=os.path.join(tracks_dir_path, 'ogg-vorbis-empty.ogg'),
-        dst=path,
+        src=os.path.join(tracks_dir_path, "ogg-vorbis-empty.ogg"), dst=path,
     )
     return path

+ 45 - 42
tests/tag_interface/test_id3.py

@@ -9,26 +9,29 @@ from symuid._tag_interface import ID3
 # pylint: disable=protected-access
 
 
-@pytest.mark.parametrize('track_name', ['id3v2.4-empty.mp3'])
+@pytest.mark.parametrize("track_name", ["id3v2.4-empty.mp3"])
 def test_get_track_path(tracks_dir_path, track_name):
     track_path = os.path.join(tracks_dir_path, track_name)
     iface = ID3(mutagen.File(track_path))
     assert track_path == iface.track_path
 
 
-@pytest.mark.parametrize(('track_name', 'tag_label', 'expected_text'), [
-    ('id3v2.4-typical.mp3', 'TPE1', 'some artist'),
-    ('id3v2.4-typical.mp3', 'COMM::eng', 'some comment'),
-])
+@pytest.mark.parametrize(
+    ("track_name", "tag_label", "expected_text"),
+    [
+        ("id3v2.4-typical.mp3", "TPE1", "some artist"),
+        ("id3v2.4-typical.mp3", "COMM::eng", "some comment"),
+    ],
+)
 def test__get_str(tracks_dir_path, track_name, tag_label, expected_text):
     iface = ID3(mutagen.File(os.path.join(tracks_dir_path, track_name)))
     assert expected_text == iface._get_str(tag_label)
 
 
-@pytest.mark.parametrize(('track_name', 'tag_label'), [
-    ('id3v2.4-empty.mp3', 'TPE1'),
-    ('id3v2.4-typical.mp3', 'COMM'),
-])
+@pytest.mark.parametrize(
+    ("track_name", "tag_label"),
+    [("id3v2.4-empty.mp3", "TPE1"), ("id3v2.4-typical.mp3", "COMM"),],
+)
 def test__get_str_missing(tracks_dir_path, track_name, tag_label):
     iface = ID3(mutagen.File(os.path.join(tracks_dir_path, track_name)))
     with pytest.raises(KeyError, match=re.escape(tag_label)):
@@ -37,25 +40,22 @@ def test__get_str_missing(tracks_dir_path, track_name, tag_label):
 
 def test__get_free_str(empty_id3_path):
     mutagen_file = mutagen.File(empty_id3_path)
-    mutagen_file.tags.add(mutagen.id3.TXXX(
-        desc='foo',
-        text='bar',
-    ))
+    mutagen_file.tags.add(mutagen.id3.TXXX(desc="foo", text="bar",))
     mutagen_file.save()
     id3_iface = ID3(mutagen.File(empty_id3_path))
-    assert id3_iface._get_free_str('foo') == 'bar'
+    assert id3_iface._get_free_str("foo") == "bar"
 
 
 def test__get_free_str_missing(empty_id3_path):
     iface = ID3(mutagen.File(empty_id3_path))
-    with pytest.raises(KeyError, match=r'TXXX:foo'):
-        iface._get_free_str('foo')
+    with pytest.raises(KeyError, match=r"TXXX:foo"):
+        iface._get_free_str("foo")
 
 
-@pytest.mark.parametrize(('track_name', 'expected_comment'), [
-    ('id3v2.4-empty.mp3', None),
-    ('id3v2.4-typical.mp3', 'some comment'),
-])
+@pytest.mark.parametrize(
+    ("track_name", "expected_comment"),
+    [("id3v2.4-empty.mp3", None), ("id3v2.4-typical.mp3", "some comment"),],
+)
 def test_get_comment(tracks_dir_path, track_name, expected_comment):
     iface = ID3(mutagen.File(os.path.join(tracks_dir_path, track_name)))
     assert expected_comment == iface.get_comment()
@@ -64,46 +64,49 @@ def test_get_comment(tracks_dir_path, track_name, expected_comment):
 def test_set_comment(empty_id3_path):
     empty_id3_iface = ID3(mutagen.File(empty_id3_path))
     assert empty_id3_iface.get_comment() is None
-    empty_id3_iface.set_comment('latin')
-    assert empty_id3_iface.get_comment() == 'latin'
-    empty_id3_iface.set_comment('你好')
-    assert empty_id3_iface.get_comment() == '你好'
+    empty_id3_iface.set_comment("latin")
+    assert empty_id3_iface.get_comment() == "latin"
+    empty_id3_iface.set_comment("你好")
+    assert empty_id3_iface.get_comment() == "你好"
     empty_id3_iface.save()
     tags = mutagen.File(empty_id3_iface.track_path).tags
     assert len(tags) == 1
     tag = tags.values()[0]
     assert isinstance(tag, mutagen.id3.COMM)
-    assert tag.lang == 'eng'
-    assert tag.text == ['你好']
+    assert tag.lang == "eng"
+    assert tag.text == ["你好"]
 
 
-@pytest.mark.parametrize(('tag_label', 'tag_value', 'expected_value'), [
-    ('foo', '21/42', (21, 42)),
-    ('bar', '-1/4', (-1, 4)),
-    ('ham:egg', '0/-3', (0, -3)),
-])
+@pytest.mark.parametrize(
+    ("tag_label", "tag_value", "expected_value"),
+    [
+        ("foo", "21/42", (21, 42)),
+        ("bar", "-1/4", (-1, 4)),
+        ("ham:egg", "0/-3", (0, -3)),
+    ],
+)
 def test_get_free_int_ratio(empty_id3_path, tag_label, tag_value, expected_value):
     mutagen_file = mutagen.File(empty_id3_path)
-    mutagen_file.tags.add(mutagen.id3.TXXX(
-        encoding=mutagen.id3.Encoding.LATIN1,
-        desc=tag_label,
-        text=[tag_value],
-    ))
+    mutagen_file.tags.add(
+        mutagen.id3.TXXX(
+            encoding=mutagen.id3.Encoding.LATIN1, desc=tag_label, text=[tag_value],
+        )
+    )
     mutagen_file.save()
     id3_iface = ID3(mutagen.File(empty_id3_path))
     assert id3_iface.get_free_int_ratio(tag_label) == expected_value
 
 
 @pytest.mark.parametrize(
-    ('tag_label', 'nominator', 'denominator', 'expected_tag_value'),
-    [('foo', 21, 42, '21/42'),
-     ('bar', -1, 4, '-1/4'),
-     ('ham:egg', 0, -3, '0/-3')],
+    ("tag_label", "nominator", "denominator", "expected_tag_value"),
+    [("foo", 21, 42, "21/42"), ("bar", -1, 4, "-1/4"), ("ham:egg", 0, -3, "0/-3")],
 )
-def test_set_free_int_ratio(empty_id3_path, tag_label, nominator, denominator, expected_tag_value):
+def test_set_free_int_ratio(
+    empty_id3_path, tag_label, nominator, denominator, expected_tag_value
+):
     id3_iface = ID3(mutagen.File(empty_id3_path))
     assert repr(id3_iface.set_free_int_ratio(tag_label, nominator, denominator))
     id3_iface.save()
     mutagen_file = mutagen.File(empty_id3_path)
     assert len(mutagen_file.tags) == 1
-    assert mutagen_file.tags['TXXX:' + tag_label].text == [expected_tag_value]
+    assert mutagen_file.tags["TXXX:" + tag_label].text == [expected_tag_value]

+ 65 - 39
tests/tag_interface/test_mp4.py

@@ -9,75 +9,101 @@ from symuid._tag_interface import MP4
 def test_set_comment(empty_mp4_path):
     iface = MP4(mutagen.File(empty_mp4_path))
     assert iface.get_comment() is None
-    iface.set_comment('latin')
-    assert iface.get_comment() == 'latin'
-    iface.set_comment('mp4 你好')
-    assert iface.get_comment() == 'mp4 你好'
+    iface.set_comment("latin")
+    assert iface.get_comment() == "latin"
+    iface.set_comment("mp4 你好")
+    assert iface.get_comment() == "mp4 你好"
     iface.save()
     iface_reread = MP4(mutagen.File(empty_mp4_path))
-    assert iface_reread.get_comment() == 'mp4 你好'
+    assert iface_reread.get_comment() == "mp4 你好"
     tags = mutagen.File(iface.track_path).tags
     assert len(tags) == 1
-    assert tags.items()[0] == ('©cmt', ['mp4 你好'])
+    assert tags.items()[0] == ("©cmt", ["mp4 你好"])
 
 
 def test__get_free_uuid(empty_mp4_path):
-    uuid = b'h\x97\x8c_1?B\t\x9d\xa3$\xdf\xd0Y\xa1\xc2'
+    uuid = b"h\x97\x8c_1?B\t\x9d\xa3$\xdf\xd0Y\xa1\xc2"
     mutagen_file = mutagen.File(empty_mp4_path)
-    mutagen_file['----:foo:bar'] = mutagen.mp4.MP4FreeForm(
-        dataformat=mutagen.mp4.AtomDataType.UUID, data=uuid)
+    mutagen_file["----:foo:bar"] = mutagen.mp4.MP4FreeForm(
+        dataformat=mutagen.mp4.AtomDataType.UUID, data=uuid
+    )
     mutagen_file.save()
     mp4_iface = MP4(mutagen.File(empty_mp4_path))
-    assert mp4_iface._get_free_uuid('foo:bar') == uuid
+    assert mp4_iface._get_free_uuid("foo:bar") == uuid
 
 
 def test_get_track_uuid(empty_mp4_path):
-    uuid = b'h\x97\x8c_1?B\t\x9d\xa3$\xdf\xd0Y\xa1\xc2'
+    uuid = b"h\x97\x8c_1?B\t\x9d\xa3$\xdf\xd0Y\xa1\xc2"
     mutagen_file = mutagen.File(empty_mp4_path)
-    mutagen_file['----:symuid:uuid'] = mutagen.mp4.MP4FreeForm(
-        dataformat=mutagen.mp4.AtomDataType.UUID, data=uuid)
+    mutagen_file["----:symuid:uuid"] = mutagen.mp4.MP4FreeForm(
+        dataformat=mutagen.mp4.AtomDataType.UUID, data=uuid
+    )
     mutagen_file.save()
     mp4_iface = MP4(mutagen.File(empty_mp4_path))
     assert mp4_iface.get_track_uuid() == uuid
 
 
-@pytest.mark.parametrize(('nominator', 'denominator'), [
-    (21, 42),
-    (-21, 42),
-    (21, -42),
-    (-21, -42),
-    (0, 42),
-])
+@pytest.mark.parametrize(
+    ("nominator", "denominator"), [(21, 42), (-21, 42), (21, -42), (-21, -42), (0, 42),]
+)
 def test_get_free_int_ratio(empty_mp4_path, nominator, denominator):
     mutagen_file = mutagen.File(empty_mp4_path)
-    mutagen_file.tags['----:foo:bar'] = [
-        mutagen.mp4.MP4FreeForm(dataformat=mutagen.mp4.AtomDataType.INTEGER,
-                                data=i.to_bytes(4, byteorder='big', signed=True))
-        for i in (nominator, denominator)]
+    mutagen_file.tags["----:foo:bar"] = [
+        mutagen.mp4.MP4FreeForm(
+            dataformat=mutagen.mp4.AtomDataType.INTEGER,
+            data=i.to_bytes(4, byteorder="big", signed=True),
+        )
+        for i in (nominator, denominator)
+    ]
     mutagen_file.save()
     mp4_iface = MP4(mutagen.File(empty_mp4_path))
-    assert mp4_iface.get_free_int_ratio('foo:bar') == (nominator, denominator)
+    assert mp4_iface.get_free_int_ratio("foo:bar") == (nominator, denominator)
 
 
-@pytest.mark.parametrize(('integer', 'expected_tag_data'), [
-    (0, b'\x00'),
-    (4, b'\x04'),
-    (-1, b'\xff'),
-    (-2, b'\xfe'),
-    (2**6, b'\x40'),
-    (2**7-1, b'\x7f'),
-    (2**7, b'\x00\x80'),
-    (2**8, b'\x01\x00'),
-    (2**24, b'\x01\x00\x00\x00'),
-    (2**31-1, b'\x7f\xff\xff\xff'),
-])
+@pytest.mark.parametrize(
+    ("nominator", "denominator", "expected_tag_data"),
+    [
+        (21, 42, [b"\x15", b"\x2a"]),
+        (-21, 42, [b"\xeb", b"\x2a"]),
+        (21, -42, [b"\x15", b"\xd6"]),
+        (-21, -42, [b"\xeb", b"\xd6"]),
+        (0, 42, [b"\x00", b"\x2a"]),
+    ],
+)
+def test_set_free_int_ratio(empty_mp4_path, nominator, denominator, expected_tag_data):
+    mp4_iface = MP4(mutagen.File(empty_mp4_path))
+    mp4_iface.set_free_int_ratio("test:some-ratio", nominator, denominator)
+    mp4_iface.save()
+    mutagen_file = mutagen.File(empty_mp4_path)
+    assert len(mutagen_file.tags) == 1
+    tag = mutagen_file.tags["----:test:some-ratio"]
+    assert len(tag) == 2
+    assert all(f.dataformat == mutagen.mp4.AtomDataType.INTEGER for f in tag)
+    assert [bytes(f) for f in tag] == expected_tag_data
+
+
+@pytest.mark.parametrize(
+    ("integer", "expected_tag_data"),
+    [
+        (0, b"\x00"),
+        (4, b"\x04"),
+        (-1, b"\xff"),
+        (-2, b"\xfe"),
+        (2 ** 6, b"\x40"),
+        (2 ** 7 - 1, b"\x7f"),
+        (2 ** 7, b"\x00\x80"),
+        (2 ** 8, b"\x01\x00"),
+        (2 ** 24, b"\x01\x00\x00\x00"),
+        (2 ** 31 - 1, b"\x7f\xff\xff\xff"),
+    ],
+)
 def test_set_free_int(empty_mp4_path, integer, expected_tag_data):
     mp4_iface = MP4(mutagen.File(empty_mp4_path))
-    mp4_iface.set_free_int('foo:bar', integer)
+    mp4_iface.set_free_int("foo:bar", integer)
     mp4_iface.save()
     mutagen_file = mutagen.File(empty_mp4_path)
     assert len(mutagen_file.tags) == 1
-    tag, = mutagen_file.get('----:foo:bar')
+    (tag,) = mutagen_file.get("----:foo:bar")
     assert tag.dataformat == mutagen.mp4.AtomDataType.INTEGER
     assert bytes(tag) == expected_tag_data
     assert MP4._freeform_to_int(tag) == integer

+ 72 - 56
tests/tag_interface/test_ogg_opus.py

@@ -7,30 +7,36 @@ from symuid._tag_interface import Ogg
 # pylint: disable=protected-access
 
 
-@pytest.mark.parametrize('track_name', ['ogg-opus-empty.opus'])
+@pytest.mark.parametrize("track_name", ["ogg-opus-empty.opus"])
 def test_get_track_path(tracks_dir_path, track_name):
     track_path = os.path.join(tracks_dir_path, track_name)
     iface = Ogg(mutagen.File(track_path))
     assert track_path == iface.track_path
 
 
-@pytest.mark.parametrize(('track_name', 'tag_label', 'expected_text'), [
-    ('ogg-opus-empty.opus', 'artist', None),
-    ('ogg-opus-typical.opus', 'artist', 'some artist'),
-    ('ogg-opus-typical.opus', 'comment', 'some comment'),
-    ('ogg-opus-typical.opus', 'com', None),
-    ('ogg-opus-typical.opus', 'symuid:uuid',
-     '613ea4ac-a4cf-4026-8e99-1904b2bb5cd0'),
-])
+@pytest.mark.parametrize(
+    ("track_name", "tag_label", "expected_text"),
+    [
+        ("ogg-opus-empty.opus", "artist", None),
+        ("ogg-opus-typical.opus", "artist", "some artist"),
+        ("ogg-opus-typical.opus", "comment", "some comment"),
+        ("ogg-opus-typical.opus", "com", None),
+        (
+            "ogg-opus-typical.opus",
+            "symuid:uuid",
+            "613ea4ac-a4cf-4026-8e99-1904b2bb5cd0",
+        ),
+    ],
+)
 def test__get_single_text(tracks_dir_path, track_name, tag_label, expected_text):
     iface = Ogg(mutagen.File(os.path.join(tracks_dir_path, track_name)))
     assert expected_text == iface._get_single_text(tag_label)
 
 
-@pytest.mark.parametrize(('track_name', 'expected_comment'), [
-    ('ogg-opus-empty.opus', None),
-    ('ogg-opus-typical.opus', 'some comment'),
-])
+@pytest.mark.parametrize(
+    ("track_name", "expected_comment"),
+    [("ogg-opus-empty.opus", None), ("ogg-opus-typical.opus", "some comment"),],
+)
 def test_get_comment(tracks_dir_path, track_name, expected_comment):
     iface = Ogg(mutagen.File(os.path.join(tracks_dir_path, track_name)))
     assert expected_comment == iface.get_comment()
@@ -39,22 +45,28 @@ def test_get_comment(tracks_dir_path, track_name, expected_comment):
 def test_set_comment(empty_ogg_opus_path):
     iface = Ogg(mutagen.File(empty_ogg_opus_path))
     assert iface.get_comment() is None
-    iface.set_comment('latin')
-    assert iface.get_comment() == 'latin'
-    iface.set_comment('你好')
-    assert iface.get_comment() == '你好'
+    iface.set_comment("latin")
+    assert iface.get_comment() == "latin"
+    iface.set_comment("你好")
+    assert iface.get_comment() == "你好"
     iface.save()
     iface_reread = Ogg(mutagen.File(empty_ogg_opus_path))
-    assert iface_reread.get_comment() == '你好'
+    assert iface_reread.get_comment() == "你好"
     tags = mutagen.File(iface.track_path).tags
     assert len(tags) == 1
-    assert tags.items()[0] == ('comment', ['你好'])
-
-
-@pytest.mark.parametrize(('track_name', 'expected_uuid'), [
-    ('ogg-opus-empty.opus', None),
-    ('ogg-opus-typical.opus', b'a>\xa4\xac\xa4\xcf@&\x8e\x99\x19\x04\xb2\xbb\\\xd0'),
-])
+    assert tags.items()[0] == ("comment", ["你好"])
+
+
+@pytest.mark.parametrize(
+    ("track_name", "expected_uuid"),
+    [
+        ("ogg-opus-empty.opus", None),
+        (
+            "ogg-opus-typical.opus",
+            b"a>\xa4\xac\xa4\xcf@&\x8e\x99\x19\x04\xb2\xbb\\\xd0",
+        ),
+    ],
+)
 def test_get_track_uuid(tracks_dir_path, track_name, expected_uuid):
     iface = Ogg(mutagen.File(os.path.join(tracks_dir_path, track_name)))
     assert expected_uuid == iface.get_track_uuid()
@@ -63,10 +75,10 @@ def test_get_track_uuid(tracks_dir_path, track_name, expected_uuid):
 def test_set_track_uuid(empty_ogg_opus_path):
     iface = Ogg(mutagen.File(empty_ogg_opus_path))
     assert iface.get_track_uuid() is None
-    uuid_a = b'\x9e\xa7\xc4\xf0\xda\xecE\xb7\xab\x9a\xba\x9f\xc6\xaa\xc0S'
+    uuid_a = b"\x9e\xa7\xc4\xf0\xda\xecE\xb7\xab\x9a\xba\x9f\xc6\xaa\xc0S"
     iface.set_track_uuid(uuid_a)
     assert iface.get_track_uuid() == uuid_a
-    uuid_b = b'_\xa0\xd7\xc0\xf3\x15F\x14\xbe\xe4idM=\x80\xb3'
+    uuid_b = b"_\xa0\xd7\xc0\xf3\x15F\x14\xbe\xe4idM=\x80\xb3"
     iface.set_track_uuid(uuid_b)
     assert iface.get_track_uuid() == uuid_b
     iface.save()
@@ -74,46 +86,50 @@ def test_set_track_uuid(empty_ogg_opus_path):
     assert iface_reread.get_track_uuid() == uuid_b
     tags = mutagen.File(iface.track_path).tags
     assert len(tags) == 1
-    assert tags.items()[0] == (
-        'symuid:uuid', ['5fa0d7c0-f315-4614-bee4-69644d3d80b3'])
-
-
-@pytest.mark.parametrize(('track_name', 'tag_label', 'expected_int'), [
-    ('ogg-opus-empty.opus', 'tracknumber', None),
-    ('ogg-opus-typical.opus', 'tracknumber', 21),
-    ('ogg-opus-typical.opus', 'tracknumberr', None),
-    ('ogg-opus-typical.opus', 'symuid:pcnt:player:library:1572098177', 43),
-])
+    assert tags.items()[0] == ("symuid:uuid", ["5fa0d7c0-f315-4614-bee4-69644d3d80b3"])
+
+
+@pytest.mark.parametrize(
+    ("track_name", "tag_label", "expected_int"),
+    [
+        ("ogg-opus-empty.opus", "tracknumber", None),
+        ("ogg-opus-typical.opus", "tracknumber", 21),
+        ("ogg-opus-typical.opus", "tracknumberr", None),
+        ("ogg-opus-typical.opus", "symuid:pcnt:player:library:1572098177", 43),
+    ],
+)
 def test_get_free_int(tracks_dir_path, track_name, tag_label, expected_int):
     iface = Ogg(mutagen.File(os.path.join(tracks_dir_path, track_name)))
     assert expected_int == iface.get_free_int(tag_label)
 
 
 def test_get_free_ints(tracks_dir_path):
-    iface = Ogg(mutagen.File(os.path.join(
-        tracks_dir_path, 'ogg-opus-typical.opus')))
-    assert not list(iface.get_free_ints('symuid:none'))
-    assert list(iface.get_free_ints('tracknumber')) == [('tracknumber', 21)]
-    assert not list(iface.get_free_ints('tracknum'))
-    pcnt_tags = {('symuid:pcnt:player:library:1572098158', 42),
-                 ('symuid:pcnt:player:library:1572098177', 43)}
-    assert set(iface.get_free_ints('symuid:pcnt:player:library')) == pcnt_tags
-    assert set(iface.get_free_ints('symuid:pcnt:player')) == pcnt_tags
-    assert set(iface.get_free_ints('symuid:pcnt')) == pcnt_tags
-    assert set(iface.get_free_ints('symuid:pcnt:player:library:1572098158')) \
-        == {('symuid:pcnt:player:library:1572098158', 42)}
+    iface = Ogg(mutagen.File(os.path.join(tracks_dir_path, "ogg-opus-typical.opus")))
+    assert not list(iface.get_free_ints("symuid:none"))
+    assert list(iface.get_free_ints("tracknumber")) == [("tracknumber", 21)]
+    assert not list(iface.get_free_ints("tracknum"))
+    pcnt_tags = {
+        ("symuid:pcnt:player:library:1572098158", 42),
+        ("symuid:pcnt:player:library:1572098177", 43),
+    }
+    assert set(iface.get_free_ints("symuid:pcnt:player:library")) == pcnt_tags
+    assert set(iface.get_free_ints("symuid:pcnt:player")) == pcnt_tags
+    assert set(iface.get_free_ints("symuid:pcnt")) == pcnt_tags
+    assert set(iface.get_free_ints("symuid:pcnt:player:library:1572098158")) == {
+        ("symuid:pcnt:player:library:1572098158", 42)
+    }
 
 
 def test_set_free_int(empty_ogg_opus_path):
     iface = Ogg(mutagen.File(empty_ogg_opus_path))
-    assert iface.get_free_int('tracknumber') is None
-    iface.set_free_int('tracknumber', 7)
-    assert iface.get_free_int('tracknumber') == 7
-    iface.set_free_int('tracknumber', 14)
-    assert iface.get_free_int('tracknumber') == 14
+    assert iface.get_free_int("tracknumber") is None
+    iface.set_free_int("tracknumber", 7)
+    assert iface.get_free_int("tracknumber") == 7
+    iface.set_free_int("tracknumber", 14)
+    assert iface.get_free_int("tracknumber") == 14
     iface.save()
     iface_reread = Ogg(mutagen.File(empty_ogg_opus_path))
-    assert iface_reread.get_free_int('tracknumber') == 14
+    assert iface_reread.get_free_int("tracknumber") == 14
     tags = mutagen.File(iface.track_path).tags
     assert len(tags) == 1
-    assert tags.items()[0] == ('tracknumber', ['14'])
+    assert tags.items()[0] == ("tracknumber", ["14"])

+ 70 - 54
tests/tag_interface/test_ogg_vorbis.py

@@ -7,30 +7,39 @@ from symuid._tag_interface import Ogg
 # pylint: disable=protected-access
 
 
-@pytest.mark.parametrize('track_name', ['ogg-vorbis-empty.ogg'])
+@pytest.mark.parametrize("track_name", ["ogg-vorbis-empty.ogg"])
 def test_get_track_path(tracks_dir_path, track_name):
     track_path = os.path.join(tracks_dir_path, track_name)
     ogg_iface = Ogg(mutagen.File(track_path))
     assert track_path == ogg_iface.track_path
 
 
-@pytest.mark.parametrize(('track_name', 'tag_label', 'expected_text'), [
-    ('ogg-vorbis-empty.ogg', 'artist', None),
-    ('ogg-vorbis-typical.ogg', 'artist', 'libvorbis encoder'),
-    ('ogg-vorbis-typical.ogg', 'comment', 'some further information'),
-    ('ogg-vorbis-typical.ogg', 'com', None),
-    ('ogg-vorbis-typical.ogg', 'symuid:uuid',
-     '4cfe3a2a-6354-40f5-bd9b-5ccf1c6f48ba'),
-])
+@pytest.mark.parametrize(
+    ("track_name", "tag_label", "expected_text"),
+    [
+        ("ogg-vorbis-empty.ogg", "artist", None),
+        ("ogg-vorbis-typical.ogg", "artist", "libvorbis encoder"),
+        ("ogg-vorbis-typical.ogg", "comment", "some further information"),
+        ("ogg-vorbis-typical.ogg", "com", None),
+        (
+            "ogg-vorbis-typical.ogg",
+            "symuid:uuid",
+            "4cfe3a2a-6354-40f5-bd9b-5ccf1c6f48ba",
+        ),
+    ],
+)
 def test__get_single_text(tracks_dir_path, track_name, tag_label, expected_text):
     ogg_iface = Ogg(mutagen.File(os.path.join(tracks_dir_path, track_name)))
     assert expected_text == ogg_iface._get_single_text(tag_label)
 
 
-@pytest.mark.parametrize(('track_name', 'expected_comment'), [
-    ('ogg-vorbis-empty.ogg', None),
-    ('ogg-vorbis-typical.ogg', 'some further information'),
-])
+@pytest.mark.parametrize(
+    ("track_name", "expected_comment"),
+    [
+        ("ogg-vorbis-empty.ogg", None),
+        ("ogg-vorbis-typical.ogg", "some further information"),
+    ],
+)
 def test_get_comment(tracks_dir_path, track_name, expected_comment):
     iface = Ogg(mutagen.File(os.path.join(tracks_dir_path, track_name)))
     assert expected_comment == iface.get_comment()
@@ -39,22 +48,25 @@ def test_get_comment(tracks_dir_path, track_name, expected_comment):
 def test_set_comment(empty_ogg_vorbis_path):
     iface = Ogg(mutagen.File(empty_ogg_vorbis_path))
     assert iface.get_comment() is None
-    iface.set_comment('latin chars')
-    assert iface.get_comment() == 'latin chars'
-    iface.set_comment('你好!')
-    assert iface.get_comment() == '你好!'
+    iface.set_comment("latin chars")
+    assert iface.get_comment() == "latin chars"
+    iface.set_comment("你好!")
+    assert iface.get_comment() == "你好!"
     iface.save()
     iface_reread = Ogg(mutagen.File(empty_ogg_vorbis_path))
-    assert iface_reread.get_comment() == '你好!'
+    assert iface_reread.get_comment() == "你好!"
     tags = mutagen.File(iface.track_path).tags
     assert len(tags) == 1
-    assert tags.items()[0] == ('comment', ['你好!'])
+    assert tags.items()[0] == ("comment", ["你好!"])
 
 
-@pytest.mark.parametrize(('track_name', 'expected_uuid'), [
-    ('ogg-vorbis-empty.ogg', None),
-    ('ogg-vorbis-typical.ogg', b'L\xfe:*cT@\xf5\xbd\x9b\\\xcf\x1coH\xba'),
-])
+@pytest.mark.parametrize(
+    ("track_name", "expected_uuid"),
+    [
+        ("ogg-vorbis-empty.ogg", None),
+        ("ogg-vorbis-typical.ogg", b"L\xfe:*cT@\xf5\xbd\x9b\\\xcf\x1coH\xba"),
+    ],
+)
 def test_get_track_uuid(tracks_dir_path, track_name, expected_uuid):
     iface = Ogg(mutagen.File(os.path.join(tracks_dir_path, track_name)))
     assert expected_uuid == iface.get_track_uuid()
@@ -63,10 +75,10 @@ def test_get_track_uuid(tracks_dir_path, track_name, expected_uuid):
 def test_set_track_uuid(empty_ogg_vorbis_path):
     iface = Ogg(mutagen.File(empty_ogg_vorbis_path))
     assert iface.get_track_uuid() is None
-    uuid_a = b's\x84@\xd8\xfe\xe8K\x80\xa7J\x13\x89\\%\xech'
+    uuid_a = b"s\x84@\xd8\xfe\xe8K\x80\xa7J\x13\x89\\%\xech"
     iface.set_track_uuid(uuid_a)
     assert iface.get_track_uuid() == uuid_a
-    uuid_b = b'\xfaQ\x9a\x0eo\xa3Cp\xa1\x98c\x01\x93\xfb\xf7\xd0'
+    uuid_b = b"\xfaQ\x9a\x0eo\xa3Cp\xa1\x98c\x01\x93\xfb\xf7\xd0"
     iface.set_track_uuid(uuid_b)
     assert iface.get_track_uuid() == uuid_b
     iface.save()
@@ -74,46 +86,50 @@ def test_set_track_uuid(empty_ogg_vorbis_path):
     assert iface_reread.get_track_uuid() == uuid_b
     tags = mutagen.File(iface.track_path).tags
     assert len(tags) == 1
-    assert tags.items()[0] == (
-        'symuid:uuid', ['fa519a0e-6fa3-4370-a198-630193fbf7d0'])
-
-
-@pytest.mark.parametrize(('track_name', 'tag_label', 'expected_int'), [
-    ('ogg-vorbis-empty.ogg', 'tracknumber', None),
-    ('ogg-vorbis-typical.ogg', 'tracknumber', 3),
-    ('ogg-vorbis-typical.ogg', 'tracknumberr', None),
-    ('ogg-vorbis-typical.ogg', 'symuid:pcnt:player:library:1572098177', 22),
-])
+    assert tags.items()[0] == ("symuid:uuid", ["fa519a0e-6fa3-4370-a198-630193fbf7d0"])
+
+
+@pytest.mark.parametrize(
+    ("track_name", "tag_label", "expected_int"),
+    [
+        ("ogg-vorbis-empty.ogg", "tracknumber", None),
+        ("ogg-vorbis-typical.ogg", "tracknumber", 3),
+        ("ogg-vorbis-typical.ogg", "tracknumberr", None),
+        ("ogg-vorbis-typical.ogg", "symuid:pcnt:player:library:1572098177", 22),
+    ],
+)
 def test_get_free_int(tracks_dir_path, track_name, tag_label, expected_int):
     ogg_iface = Ogg(mutagen.File(os.path.join(tracks_dir_path, track_name)))
     assert expected_int == ogg_iface.get_free_int(tag_label)
 
 
 def test_get_free_ints(tracks_dir_path):
-    iface = Ogg(mutagen.File(os.path.join(
-        tracks_dir_path, 'ogg-vorbis-typical.ogg')))
-    assert not list(iface.get_free_ints('symuid:none'))
-    assert list(iface.get_free_ints('tracknumber')) == [('tracknumber', 3)]
-    assert not list(iface.get_free_ints('tracknum'))
-    pcnt_tags = {('symuid:pcnt:player:library:1572098158', 21),
-                 ('symuid:pcnt:player:library:1572098177', 22)}
-    assert set(iface.get_free_ints('symuid:pcnt:player:library')) == pcnt_tags
-    assert set(iface.get_free_ints('symuid:pcnt:player')) == pcnt_tags
-    assert set(iface.get_free_ints('symuid:pcnt')) == pcnt_tags
-    assert set(iface.get_free_ints('symuid:pcnt:player:library:1572098158')) \
-        == {('symuid:pcnt:player:library:1572098158', 21)}
+    iface = Ogg(mutagen.File(os.path.join(tracks_dir_path, "ogg-vorbis-typical.ogg")))
+    assert not list(iface.get_free_ints("symuid:none"))
+    assert list(iface.get_free_ints("tracknumber")) == [("tracknumber", 3)]
+    assert not list(iface.get_free_ints("tracknum"))
+    pcnt_tags = {
+        ("symuid:pcnt:player:library:1572098177", 22),
+        ("symuid:pcnt:player:library:1572098158", 21),
+    }
+    assert set(iface.get_free_ints("symuid:pcnt:player:library:1572098158")) == {
+        ("symuid:pcnt:player:library:1572098158", 21)
+    }
+    assert set(iface.get_free_ints("symuid:pcnt:player:library")) == pcnt_tags
+    assert set(iface.get_free_ints("symuid:pcnt:player")) == pcnt_tags
+    assert set(iface.get_free_ints("symuid:pcnt")) == pcnt_tags
 
 
 def test_set_free_int(empty_ogg_vorbis_path):
     iface = Ogg(mutagen.File(empty_ogg_vorbis_path))
-    assert iface.get_free_int('tracknumber') is None
-    iface.set_free_int('tracknumber', 9)
-    assert iface.get_free_int('tracknumber') == 9
-    iface.set_free_int('tracknumber', 5)
-    assert iface.get_free_int('tracknumber') == 5
+    assert iface.get_free_int("tracknumber") is None
+    iface.set_free_int("tracknumber", 9)
+    assert iface.get_free_int("tracknumber") == 9
+    iface.set_free_int("tracknumber", 5)
+    assert iface.get_free_int("tracknumber") == 5
     iface.save()
     iface_reread = Ogg(mutagen.File(empty_ogg_vorbis_path))
-    assert iface_reread.get_free_int('tracknumber') == 5
+    assert iface_reread.get_free_int("tracknumber") == 5
     tags = mutagen.File(iface.track_path).tags
     assert len(tags) == 1
-    assert tags.items()[0] == ('tracknumber', ['5'])
+    assert tags.items()[0] == ("tracknumber", ["5"])

+ 10 - 5
tests/test_datetime.py

@@ -7,13 +7,18 @@ from symuid._datetime import datetime_utc_now, unix_epoch_time_to_datetime_utc
 
 
 def test_datetime_utc_now():
-    pytz_dt = datetime.datetime.now(pytz.timezone('UTC'))
+    pytz_dt = datetime.datetime.now(pytz.timezone("UTC"))
     assert abs((datetime_utc_now() - pytz_dt).total_seconds()) < 5
 
 
-@pytest.mark.parametrize(('ts_sec', 'expected_dt'), [
-    (1528795204,
-     datetime.datetime(2018, 6, 12, 9, 20, 4, tzinfo=datetime.timezone.utc)),
-])
+@pytest.mark.parametrize(
+    ("ts_sec", "expected_dt"),
+    [
+        (
+            1528795204,
+            datetime.datetime(2018, 6, 12, 9, 20, 4, tzinfo=datetime.timezone.utc),
+        ),
+    ],
+)
 def test_unix_epoch_time_to_datetime_utc(ts_sec, expected_dt):
     assert expected_dt == unix_epoch_time_to_datetime_utc(ts_sec)

+ 103 - 83
tests/test_sync.py

@@ -24,13 +24,15 @@ def test_add_uuid_idempotent(empty_ogg_opus_path):
 
 def test_add_uuid_main_file(empty_ogg_opus_path):
     assert Track(empty_ogg_opus_path).get_uuid() is None
-    with unittest.mock.patch('sys.argv', ['', empty_ogg_opus_path]):
+    with unittest.mock.patch("sys.argv", ["", empty_ogg_opus_path]):
         _main()
     assert Track(empty_ogg_opus_path).get_uuid() is not None
 
 
 def test_add_uuid_main_multiple_files(empty_ogg_opus_path, empty_ogg_vorbis_path):
-    with unittest.mock.patch('sys.argv', ['', empty_ogg_opus_path, empty_ogg_vorbis_path]):
+    with unittest.mock.patch(
+        "sys.argv", ["", empty_ogg_opus_path, empty_ogg_vorbis_path]
+    ):
         _main()
     assert Track(empty_ogg_opus_path).get_uuid() is not None
     assert Track(empty_ogg_vorbis_path).get_uuid() is not None
@@ -38,133 +40,151 @@ def test_add_uuid_main_multiple_files(empty_ogg_opus_path, empty_ogg_vorbis_path
 
 def test_add_uuid_main_dir(empty_ogg_opus_path):
     assert Track(empty_ogg_opus_path).get_uuid() is None
-    with unittest.mock.patch('sys.argv', ['', os.path.dirname(empty_ogg_opus_path)]):
+    with unittest.mock.patch("sys.argv", ["", os.path.dirname(empty_ogg_opus_path)]):
         _main()
     assert Track(empty_ogg_opus_path).get_uuid() is not None
 
 
 def test_sync_play_count(tmpdir, tracks_dir_path):
     shutil.copyfile(
-        src=os.path.join(tracks_dir_path, 'id3v2.4-empty.mp3'),
-        dst=tmpdir.join('a1.mp3'),
+        src=os.path.join(tracks_dir_path, "id3v2.4-empty.mp3"),
+        dst=tmpdir.join("a1.mp3"),
     )
     shutil.copyfile(
-        src=os.path.join(tracks_dir_path, 'ogg-vorbis-empty.ogg'),
-        dst=tmpdir.join('a2.ogg'),
+        src=os.path.join(tracks_dir_path, "ogg-vorbis-empty.ogg"),
+        dst=tmpdir.join("a2.ogg"),
     )
     shutil.copyfile(
-        src=os.path.join(tracks_dir_path, 'ogg-opus-empty.opus'),
-        dst=tmpdir.join('a3.opus'),
+        src=os.path.join(tracks_dir_path, "ogg-opus-empty.opus"),
+        dst=tmpdir.join("a3.opus"),
     )
     shutil.copyfile(
-        src=os.path.join(tracks_dir_path, 'id3v2.4-empty.mp3'),
-        dst=tmpdir.join('b.mp3'),
+        src=os.path.join(tracks_dir_path, "id3v2.4-empty.mp3"),
+        dst=tmpdir.join("b.mp3"),
     )
     uuid_a = generate_uuid4_bytes()
     uuid_b = generate_uuid4_bytes()
-    track_a1 = Track(tmpdir.join('a1.mp3'))
+    track_a1 = Track(tmpdir.join("a1.mp3"))
     track_a1.assign_uuid(uuid_a)
-    track_a2 = Track(tmpdir.join('a2.ogg'))
+    track_a2 = Track(tmpdir.join("a2.ogg"))
     track_a2.assign_uuid(uuid_a)
-    track_a3 = Track(tmpdir.join('a3.opus'))
+    track_a3 = Track(tmpdir.join("a3.opus"))
     track_a3.assign_uuid(uuid_a)
-    track_b = Track(tmpdir.join('b.mp3'))
+    track_b = Track(tmpdir.join("b.mp3"))
     track_b.assign_uuid(uuid_b)
-    track_a1.register_play_count(PlayCount(
-        player='cmus',
-        library_id='lib1',
-        register_dt=unix_epoch_time_to_datetime_utc(0),
-        count=21,
-    ))
-    track_a1.register_play_count(PlayCount(
-        player='cmus',
-        library_id='lib1',
-        register_dt=unix_epoch_time_to_datetime_utc(1),
-        count=22,
-    ))
-    track_a2.register_play_count(PlayCount(
-        player='cmus',
-        library_id='lib1',
-        register_dt=unix_epoch_time_to_datetime_utc(0),
-        count=21,
-    ))
-    track_a2.register_play_count(PlayCount(
-        player='cmus',
-        library_id='lib2',
-        register_dt=unix_epoch_time_to_datetime_utc(2),
-        count=7,
-    ))
-    track_b.register_play_count(PlayCount(
-        player='cmus',
-        library_id='lib2',
-        register_dt=unix_epoch_time_to_datetime_utc(3),
-        count=4,
-    ))
+    track_a1.register_play_count(
+        PlayCount(
+            player="cmus",
+            library_id="lib1",
+            register_dt=unix_epoch_time_to_datetime_utc(0),
+            count=21,
+        )
+    )
+    track_a1.register_play_count(
+        PlayCount(
+            player="cmus",
+            library_id="lib1",
+            register_dt=unix_epoch_time_to_datetime_utc(1),
+            count=22,
+        )
+    )
+    track_a2.register_play_count(
+        PlayCount(
+            player="cmus",
+            library_id="lib1",
+            register_dt=unix_epoch_time_to_datetime_utc(0),
+            count=21,
+        )
+    )
+    track_a2.register_play_count(
+        PlayCount(
+            player="cmus",
+            library_id="lib2",
+            register_dt=unix_epoch_time_to_datetime_utc(2),
+            count=7,
+        )
+    )
+    track_b.register_play_count(
+        PlayCount(
+            player="cmus",
+            library_id="lib2",
+            register_dt=unix_epoch_time_to_datetime_utc(3),
+            count=4,
+        )
+    )
     sync((track_a1, track_a2, track_a3, track_b))
-    play_counts_a1 = set(Track(tmpdir.join('a1.mp3')).get_play_counts())
-    play_counts_a2 = set(Track(tmpdir.join('a2.ogg')).get_play_counts())
-    play_counts_a3 = set(Track(tmpdir.join('a3.opus')).get_play_counts())
-    play_counts_b = set(Track(tmpdir.join('b.mp3')).get_play_counts())
+    play_counts_a1 = set(Track(tmpdir.join("a1.mp3")).get_play_counts())
+    play_counts_a2 = set(Track(tmpdir.join("a2.ogg")).get_play_counts())
+    play_counts_a3 = set(Track(tmpdir.join("a3.opus")).get_play_counts())
+    play_counts_b = set(Track(tmpdir.join("b.mp3")).get_play_counts())
     assert len(play_counts_a1) == 3
     assert len(play_counts_a1) == len(play_counts_a2)
     assert len(play_counts_a1) == len(play_counts_a3)
     assert play_counts_a1 == play_counts_a2
-    assert {pc.register_dt.timestamp() for pc in play_counts_a1} \
-        == pytest.approx({0, 1, 2})
+    assert {pc.register_dt.timestamp() for pc in play_counts_a1} == pytest.approx(
+        {0, 1, 2}
+    )
     assert len(play_counts_b) == 1
-    assert {pc.register_dt.timestamp() for pc in play_counts_b} \
-        == pytest.approx({3})
+    assert {pc.register_dt.timestamp() for pc in play_counts_b} == pytest.approx({3})
 
 
 def test_sync_play_count_callback(tmpdir, tracks_dir_path):
     shutil.copyfile(
-        src=os.path.join(tracks_dir_path, 'id3v2.4-empty.mp3'),
-        dst=tmpdir.join('a1.mp3'),
+        src=os.path.join(tracks_dir_path, "id3v2.4-empty.mp3"),
+        dst=tmpdir.join("a1.mp3"),
     )
     shutil.copyfile(
-        src=os.path.join(tracks_dir_path, 'ogg-vorbis-empty.ogg'),
-        dst=tmpdir.join('a2.ogg'),
+        src=os.path.join(tracks_dir_path, "ogg-vorbis-empty.ogg"),
+        dst=tmpdir.join("a2.ogg"),
     )
     uuid_a = generate_uuid4_bytes()
-    track_a1 = Track(tmpdir.join('a1.mp3'))
+    track_a1 = Track(tmpdir.join("a1.mp3"))
     track_a1.assign_uuid(uuid_a)
-    track_a2 = Track(tmpdir.join('a2.ogg'))
+    track_a2 = Track(tmpdir.join("a2.ogg"))
     track_a2.assign_uuid(uuid_a)
-    track_a1.register_play_count(PlayCount(
-        player='cmus',
-        library_id='lib1',
-        register_dt=unix_epoch_time_to_datetime_utc(0),
-        count=21,
-    ))
+    track_a1.register_play_count(
+        PlayCount(
+            player="cmus",
+            library_id="lib1",
+            register_dt=unix_epoch_time_to_datetime_utc(0),
+            count=21,
+        )
+    )
     play_count_added_cb = unittest.mock.MagicMock()
-    sync(_walk_tracks([tmpdir]),
-         play_count_added_cb=play_count_added_cb)
+    sync(_walk_tracks([tmpdir]), play_count_added_cb=play_count_added_cb)
     play_count_added_cb.assert_called_once_with(
-        track_a2, ('symuid:pcnt:cmus:lib1:0', ['21']))
+        track_a2, ("symuid:pcnt:cmus:lib1:0", ["21"])
+    )
 
 
 def test_sync_play_count_callback_main(capsys, tmpdir, tracks_dir_path):
     shutil.copyfile(
-        src=os.path.join(tracks_dir_path, 'id3v2.4-empty.mp3'),
-        dst=tmpdir.join('a1.mp3'),
+        src=os.path.join(tracks_dir_path, "id3v2.4-empty.mp3"),
+        dst=tmpdir.join("a1.mp3"),
     )
     shutil.copyfile(
-        src=os.path.join(tracks_dir_path, 'ogg-vorbis-empty.ogg'),
-        dst=tmpdir.join('a2.ogg'),
+        src=os.path.join(tracks_dir_path, "ogg-vorbis-empty.ogg"),
+        dst=tmpdir.join("a2.ogg"),
     )
     uuid_a = generate_uuid4_bytes()
-    track_a1 = Track(tmpdir.join('a1.mp3'))
+    track_a1 = Track(tmpdir.join("a1.mp3"))
     track_a1.assign_uuid(uuid_a)
-    track_a2 = Track(tmpdir.join('a2.ogg'))
+    track_a2 = Track(tmpdir.join("a2.ogg"))
     track_a2.assign_uuid(uuid_a)
-    track_a1.register_play_count(PlayCount(
-        player='cmus',
-        library_id='lib1',
-        register_dt=unix_epoch_time_to_datetime_utc(0),
-        count=21,
-    ))
-    with unittest.mock.patch('sys.argv', ['', tmpdir.strpath]):
+    track_a1.register_play_count(
+        PlayCount(
+            player="cmus",
+            library_id="lib1",
+            register_dt=unix_epoch_time_to_datetime_utc(0),
+            count=21,
+        )
+    )
+    with unittest.mock.patch("sys.argv", ["", tmpdir.strpath]):
         _main()
     out, _ = capsys.readouterr()
-    assert out == "{!r}: added play count tag ('symuid:pcnt:cmus:lib1:0', ['21'])\n" \
-        .format(track_a2.path)
+    assert (
+        out
+        == "{!r}: added play count tag ('symuid:pcnt:cmus:lib1:0', ['21'])\n".format(
+            track_a2.path
+        )
+    )

+ 117 - 99
tests/test_track.py

@@ -12,7 +12,7 @@ from symuid._datetime import datetime_utc_now
 
 # TODO test aac / m4a itunes tags
 
-TRACKS_DIR_PATH = os.path.join(os.path.dirname(__file__), 'tracks')
+TRACKS_DIR_PATH = os.path.join(os.path.dirname(__file__), "tracks")
 
 
 def utc_dt(hour=0):
@@ -24,22 +24,28 @@ def empty_id3_track(empty_id3_path):
     return symuid.Track(empty_id3_path)
 
 
-@pytest.mark.parametrize('track_name', [
-    'id3v2.4-typical.mp3',
-    'id3v2.4-empty.mp3',
-    'ogg-opus-typical.opus',
-    'ogg-vorbis-typical.ogg',
-])
+@pytest.mark.parametrize(
+    "track_name",
+    [
+        "id3v2.4-typical.mp3",
+        "id3v2.4-empty.mp3",
+        "ogg-opus-typical.opus",
+        "ogg-vorbis-typical.ogg",
+    ],
+)
 def test_init(tracks_dir_path, track_name):
     symuid.Track(os.path.join(tracks_dir_path, track_name))
 
 
-@pytest.mark.parametrize(('track_name', 'expected_comment'), [
-    ('id3v2.4-typical.mp3', 'some comment'),
-    ('id3v2.4-empty.mp3', None),
-    ('ogg-opus-typical.opus', 'some comment'),
-    ('ogg-vorbis-typical.ogg', 'some further information'),
-])
+@pytest.mark.parametrize(
+    ("track_name", "expected_comment"),
+    [
+        ("id3v2.4-typical.mp3", "some comment"),
+        ("id3v2.4-empty.mp3", None),
+        ("ogg-opus-typical.opus", "some comment"),
+        ("ogg-vorbis-typical.ogg", "some further information"),
+    ],
+)
 def test_get_comment(tracks_dir_path, track_name, expected_comment):
     track = symuid.Track(os.path.join(tracks_dir_path, track_name))
     assert expected_comment == track.comment
@@ -47,53 +53,62 @@ def test_get_comment(tracks_dir_path, track_name, expected_comment):
 
 def test_set_comment(empty_id3_track):
     assert empty_id3_track.comment is None
-    empty_id3_track.comment = 'note'
-    assert empty_id3_track.comment == 'note'
-    empty_id3_track.comment += 's'
-    assert empty_id3_track.comment == 'notes'
-    assert symuid.Track(empty_id3_track.path).comment == 'notes'
+    empty_id3_track.comment = "note"
+    assert empty_id3_track.comment == "note"
+    empty_id3_track.comment += "s"
+    assert empty_id3_track.comment == "notes"
+    assert symuid.Track(empty_id3_track.path).comment == "notes"
 
 
-@pytest.mark.parametrize(('play_count'), [
-    symuid.PlayCount('pytest', 'lib', utc_dt(), 7),
-])
+@pytest.mark.parametrize(
+    ("play_count"), [symuid.PlayCount("pytest", "lib", utc_dt(), 7),]
+)
 def test_register_play_count_id3(empty_id3_track, play_count):
     empty_id3_track.register_play_count(play_count)
     tags = mutagen.File(empty_id3_track.path).tags
     assert len(tags) == 1
-    expected_desc = 'symuid:pcnt:{}:{}:{}'.format(
-        play_count.player, play_count.library_id, int(
-            play_count.register_dt.timestamp()),
+    expected_desc = "symuid:pcnt:{}:{}:{}".format(
+        play_count.player,
+        play_count.library_id,
+        int(play_count.register_dt.timestamp()),
     )
-    tag = tags['TXXX:' + expected_desc]
+    tag = tags["TXXX:" + expected_desc]
     assert tag.desc == expected_desc
     assert tag.text == [str(play_count.count)]
 
 
-@pytest.mark.parametrize(('play_count'), [
-    symuid.PlayCount('pytest', 'lib', utc_dt(), 7),
-])
+@pytest.mark.parametrize(
+    ("play_count"), [symuid.PlayCount("pytest", "lib", utc_dt(), 7),]
+)
 def test_register_play_count_opus(empty_ogg_opus_path, play_count):
     track = symuid.Track(empty_ogg_opus_path)
     track.register_play_count(play_count)
     tags = mutagen.File(track.path).tags
     assert len(tags) == 1
-    expected_desc = 'symuid:pcnt:{}:{}:{}'.format(
-        play_count.player, play_count.library_id, int(
-            play_count.register_dt.timestamp()),
+    expected_desc = "symuid:pcnt:{}:{}:{}".format(
+        play_count.player,
+        play_count.library_id,
+        int(play_count.register_dt.timestamp()),
     )
     assert tags[expected_desc] == [str(play_count.count)]
 
 
-@pytest.mark.parametrize(('expected_counts'), [
-    [],
-    [symuid.PlayCount('player', 'lib', utc_dt(), 3)],
-    [symuid.PlayCount('a', '0', utc_dt(0), 1),
-     symuid.PlayCount('b', '1', utc_dt(1), 2)],
-    [symuid.PlayCount('a', '0', utc_dt(0), 1),
-     symuid.PlayCount('a', '2', utc_dt(1), 2),
-     symuid.PlayCount('b', '3', utc_dt(3), 3)],
-])
+@pytest.mark.parametrize(
+    ("expected_counts"),
+    [
+        [],
+        [symuid.PlayCount("player", "lib", utc_dt(), 3)],
+        [
+            symuid.PlayCount("a", "0", utc_dt(0), 1),
+            symuid.PlayCount("b", "1", utc_dt(1), 2),
+        ],
+        [
+            symuid.PlayCount("a", "0", utc_dt(0), 1),
+            symuid.PlayCount("a", "2", utc_dt(1), 2),
+            symuid.PlayCount("b", "3", utc_dt(3), 3),
+        ],
+    ],
+)
 def test__get_play_counts_all(empty_id3_track, expected_counts):
     for play_count in expected_counts:
         empty_id3_track.register_play_count(play_count)
@@ -101,103 +116,106 @@ def test__get_play_counts_all(empty_id3_track, expected_counts):
 
 
 def test__get_play_counts_filtered(empty_id3_track):
-    counts = [symuid.PlayCount('a', '0', utc_dt(0), 1),
-              symuid.PlayCount('a', '0', utc_dt(1), 2),
-              symuid.PlayCount('a', '1', utc_dt(0), 3),
-              symuid.PlayCount('b', '2', utc_dt(1), 4)]
+    counts = [
+        symuid.PlayCount("a", "0", utc_dt(0), 1),
+        symuid.PlayCount("a", "0", utc_dt(1), 2),
+        symuid.PlayCount("a", "1", utc_dt(0), 3),
+        symuid.PlayCount("b", "2", utc_dt(1), 4),
+    ]
     for play_count in counts:
         empty_id3_track.register_play_count(play_count)
-    assert set(empty_id3_track._get_play_counts(player='a')) \
-        == set(filter(lambda pc: pc.player == 'a', counts))
-    assert set(empty_id3_track._get_play_counts(player='b')) \
-        == set(filter(lambda pc: pc.player == 'b', counts))
-    assert set(empty_id3_track._get_play_counts(player='a', library_id='0')) \
-        == set(filter(lambda pc: pc.library_id == '0', counts))
-    assert set(empty_id3_track._get_play_counts(player='a', library_id='2')) \
-        == set()
+    assert set(empty_id3_track._get_play_counts(player="a")) == set(
+        filter(lambda pc: pc.player == "a", counts)
+    )
+    assert set(empty_id3_track._get_play_counts(player="b")) == set(
+        filter(lambda pc: pc.player == "b", counts)
+    )
+    assert set(empty_id3_track._get_play_counts(player="a", library_id="0")) == set(
+        filter(lambda pc: pc.library_id == "0", counts)
+    )
+    assert set(empty_id3_track._get_play_counts(player="a", library_id="2")) == set()
 
 
 def test__get_latest_play_counts(empty_id3_track):
-    counts = [symuid.PlayCount('a', '0', utc_dt(0), 1),
-              symuid.PlayCount('a', '0', utc_dt(1), 2),
-              symuid.PlayCount('a', '1', utc_dt(0), 3),
-              symuid.PlayCount('a', '1', utc_dt(2), 4),
-              symuid.PlayCount('b', '2', utc_dt(3), 5)]
+    counts = [
+        symuid.PlayCount("a", "0", utc_dt(0), 1),
+        symuid.PlayCount("a", "0", utc_dt(1), 2),
+        symuid.PlayCount("a", "1", utc_dt(0), 3),
+        symuid.PlayCount("a", "1", utc_dt(2), 4),
+        symuid.PlayCount("b", "2", utc_dt(3), 5),
+    ]
     for play_count in counts:
         empty_id3_track.register_play_count(play_count)
-    assert set(empty_id3_track._get_latest_play_counts()) \
-        == set([counts[1], counts[3], counts[4]])
-    assert set(empty_id3_track._get_latest_play_counts(player='a')) \
-        == set([counts[1], counts[3]])
-    assert set(empty_id3_track._get_latest_play_counts(player='a', library_id='0')) \
-        == set([counts[1]])
+    assert set(empty_id3_track._get_latest_play_counts()) == set(
+        [counts[1], counts[3], counts[4]]
+    )
+    assert set(empty_id3_track._get_latest_play_counts(player="a")) == set(
+        [counts[1], counts[3]]
+    )
+    assert set(
+        empty_id3_track._get_latest_play_counts(player="a", library_id="0")
+    ) == set([counts[1]])
 
 
 def test_get_play_count_sum(empty_id3_track):
-    counts = [symuid.PlayCount('a', '0', utc_dt(0), 1),
-              symuid.PlayCount('a', '0', utc_dt(1), 2),
-              symuid.PlayCount('a', '1', utc_dt(0), 3),
-              symuid.PlayCount('a', '1', utc_dt(2), 4),
-              symuid.PlayCount('b', '2', utc_dt(3), 5)]
+    counts = [
+        symuid.PlayCount("a", "0", utc_dt(0), 1),
+        symuid.PlayCount("a", "0", utc_dt(1), 2),
+        symuid.PlayCount("a", "1", utc_dt(0), 3),
+        symuid.PlayCount("a", "1", utc_dt(2), 4),
+        symuid.PlayCount("b", "2", utc_dt(3), 5),
+    ]
     for play_count in counts:
         empty_id3_track.register_play_count(play_count)
     assert 2 + 4 + 5 == empty_id3_track.get_play_count_sum()
 
 
 def test_increase_play_count(empty_id3_track):
-    init_count = symuid.PlayCount('a', '0', utc_dt(0), 3)
+    init_count = symuid.PlayCount("a", "0", utc_dt(0), 3)
     empty_id3_track.register_play_count(init_count)
     assert empty_id3_track.get_play_count_sum() == 3
-    empty_id3_track.increase_play_count('a', '0')
+    empty_id3_track.increase_play_count("a", "0")
     assert empty_id3_track.get_play_count_sum() == 4
     counts = set(empty_id3_track._get_play_counts())
     assert len(counts) == 2
     counts.remove(init_count)
     new_count = counts.pop()
-    assert new_count.player == 'a'
-    assert new_count.library_id == '0'
+    assert new_count.player == "a"
+    assert new_count.library_id == "0"
     assert abs(new_count.register_dt - datetime_utc_now()).total_seconds() < 5
     assert new_count.count == 4
 
 
 def test_increase_play_count_init(empty_id3_track):
-    empty_id3_track.increase_play_count('a', '0')
+    empty_id3_track.increase_play_count("a", "0")
     assert empty_id3_track.get_play_count_sum() == 1
-    count, = empty_id3_track._get_play_counts()
-    assert count.player == 'a'
-    assert count.library_id == '0'
+    (count,) = empty_id3_track._get_play_counts()
+    assert count.player == "a"
+    assert count.library_id == "0"
     assert abs(count.register_dt - datetime_utc_now()).total_seconds() < 5
     assert count.count == 1
 
 
 def test_increase_play_count_others(empty_id3_track):
-    empty_id3_track.register_play_count(
-        symuid.PlayCount('a', '0', utc_dt(0), 1),
-    )
-    empty_id3_track.register_play_count(
-        symuid.PlayCount('a', '1', utc_dt(0), 2),
-    )
-    empty_id3_track.register_play_count(
-        symuid.PlayCount('b', '0', utc_dt(0), 3),
-    )
+    empty_id3_track.register_play_count(symuid.PlayCount("a", "0", utc_dt(0), 1),)
+    empty_id3_track.register_play_count(symuid.PlayCount("a", "1", utc_dt(0), 2),)
+    empty_id3_track.register_play_count(symuid.PlayCount("b", "0", utc_dt(0), 3),)
     assert empty_id3_track.get_play_count_sum() == 6
-    empty_id3_track.increase_play_count('a', '1')
+    empty_id3_track.increase_play_count("a", "1")
     assert empty_id3_track.get_play_count_sum() == 7
-    assert len(list(empty_id3_track._get_play_counts(player='b'))) == 1
-    assert len(list(
-        empty_id3_track._get_play_counts(player='a', library_id='0')
-    )) == 1
-    assert len(list(
-        empty_id3_track._get_play_counts(player='a', library_id='1')
-    )) == 2
+    assert len(list(empty_id3_track._get_play_counts(player="b"))) == 1
+    assert len(list(empty_id3_track._get_play_counts(player="a", library_id="0"))) == 1
+    assert len(list(empty_id3_track._get_play_counts(player="a", library_id="1"))) == 2
 
 
 def test_walk(tracks_dir_path):
     tracks = symuid.Track.walk(
-        tracks_dir_path,
-        path_ignore_regex=re.compile(r'typical'))
+        tracks_dir_path, path_ignore_regex=re.compile(r"typical")
+    )
     track_names = set(os.path.basename(t.path) for t in tracks)
-    assert track_names == {'id3v2.4-empty.mp3',
-                           'mp4-aac-empty.m4a',
-                           'ogg-opus-empty.opus',
-                           'ogg-vorbis-empty.ogg'}
+    assert track_names == {
+        "id3v2.4-empty.mp3",
+        "mp4-aac-empty.m4a",
+        "ogg-opus-empty.opus",
+        "ogg-vorbis-empty.ogg",
+    }