From 5c46c96b53a37516481b55f5134dabebaeb4b011 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alban=20F=C3=A9ron?= Date: Sun, 8 Nov 2020 15:39:09 +0100 Subject: [PATCH] Some fixes for Windows support (especially for tests) The main motive here isn't full Windows support per, but being able to run tests on Windows, as this is my main platform. Booting a VM just to run tests is cumbersome. --- supysonic/cache.py | 33 +++++----- supysonic/config.py | 7 +- tests/api/test_radio.py | 107 ++++++++++++++++++------------- tests/api/test_transcoding.py | 12 +++- tests/assets/formats/silence.mp3 | Bin 21 -> 85812 bytes tests/base/test_cli.py | 49 ++++++-------- tests/base/test_db.py | 2 +- tests/base/test_scanner.py | 26 +++++--- tests/base/test_secret.py | 5 +- tests/base/test_watcher.py | 12 ++-- tests/issue148.py | 6 +- tests/issue85.py | 4 ++ tests/testbase.py | 11 ++-- tests/with_net.py | 1 + 14 files changed, 154 insertions(+), 121 deletions(-) mode change 120000 => 100644 tests/assets/formats/silence.mp3 diff --git a/supysonic/cache.py b/supysonic/cache.py index 3b18727..7584a0d 100644 --- a/supysonic/cache.py +++ b/supysonic/cache.py @@ -143,25 +143,26 @@ class Cache(object): >>> with cache.set_fileobj(key) as fp: ... json.dump(some_data, fp) """ + f = tempfile.NamedTemporaryFile( + dir=self._cache_dir, suffix=".part", delete=False + ) try: - with tempfile.NamedTemporaryFile( - dir=self._cache_dir, suffix=".part", delete=True - ) as f: - yield f + yield f - # seek to end and get position to get filesize - f.seek(0, 2) - size = f.tell() + # seek to end and get position to get filesize + f.seek(0, 2) + size = f.tell() + f.close() - with self._lock: - if self._auto_prune: - self._make_space(size, key=key) - os.replace(f.name, self._filepath(key)) - self._record_file(key, size) - except OSError as e: - # Ignore error from trying to delete the renamed temp file - if e.errno != errno.ENOENT: - raise + with self._lock: + if self._auto_prune: + self._make_space(size, key=key) + os.replace(f.name, self._filepath(key)) + self._record_file(key, size) + except: + f.close() + os.remove(f.name) + raise def set(self, key, value): """Set a literal value into the cache and return its path""" diff --git a/supysonic/config.py b/supysonic/config.py index 203cf29..56c9acb 100644 --- a/supysonic/config.py +++ b/supysonic/config.py @@ -3,12 +3,13 @@ # This file is part of Supysonic. # Supysonic is a Python implementation of the Subsonic server API. # -# Copyright (C) 2013-2019 Alban 'spl0k' Féron +# Copyright (C) 2013-2020 Alban 'spl0k' Féron # 2017 Óscar García Amor # # Distributed under terms of the GNU AGPLv3 license. import os +import sys import tempfile from configparser import RawConfigParser @@ -39,7 +40,9 @@ class DefaultConfig(object): "mount_api": True, } DAEMON = { - "socket": os.path.join(tempdir, "supysonic.sock"), + "socket": r"\\.\pipe\supysonic" + if sys.platform == "win32" + else os.path.join(tempdir, "supysonic.sock"), "run_watcher": True, "wait_delay": 5, "jukebox_command": None, diff --git a/tests/api/test_radio.py b/tests/api/test_radio.py index 65bbc6c..f01ef29 100644 --- a/tests/api/test_radio.py +++ b/tests/api/test_radio.py @@ -35,22 +35,25 @@ class RadioStationTestCase(ApiTestBase): self._make_request( "createInternetRadioStation", {"u": "bob", "p": "B0b", "username": "alice"}, - error=50 + error=50, ) # check params self._make_request("createInternetRadioStation", error=10) - self._make_request("createInternetRadioStation", {"streamUrl": "missingName"}, error=10) - self._make_request("createInternetRadioStation", {"name": "missing stream"}, error=10) + self._make_request( + "createInternetRadioStation", {"streamUrl": "missingName"}, error=10 + ) + self._make_request( + "createInternetRadioStation", {"name": "missing stream"}, error=10 + ) # create w/ required fields stream_url = "http://example.com/radio/create" name = "radio station" - self._make_request("createInternetRadioStation", { - "streamUrl": stream_url, - "name": name, - }) + self._make_request( + "createInternetRadioStation", {"streamUrl": stream_url, "name": name} + ) # the correct value is 2 because _make_request uses GET then POST self.assertRadioStationCountEqual(2) @@ -66,11 +69,10 @@ class RadioStationTestCase(ApiTestBase): name = "radio station1" homepage_url = "http://example.com/home" - self._make_request("createInternetRadioStation", { - "streamUrl": stream_url, - "name": name, - "homepageUrl": homepage_url, - }) + self._make_request( + "createInternetRadioStation", + {"streamUrl": stream_url, "name": name, "homepageUrl": homepage_url}, + ) # the correct value is 2 because _make_request uses GET then POST self.assertRadioStationCountEqual(2) @@ -83,7 +85,7 @@ class RadioStationTestCase(ApiTestBase): self._make_request( "updateInternetRadioStation", {"u": "bob", "p": "B0b", "username": "alice"}, - error=50 + error=50, ) # test data @@ -107,67 +109,86 @@ class RadioStationTestCase(ApiTestBase): ) # check params - self._make_request("updateInternetRadioStation", { - "id": station.id, "homepageUrl": "missing required params", - }, error=10) - self._make_request("updateInternetRadioStation", { - "id": station.id, "name": "missing streamUrl", - }, error=10) - self._make_request("updateInternetRadioStation", { - "id": station.id, "streamUrl": "missing name", - }, error=10) + self._make_request( + "updateInternetRadioStation", + {"id": station.id, "homepageUrl": "missing required params"}, + error=10, + ) + self._make_request( + "updateInternetRadioStation", + {"id": station.id, "name": "missing streamUrl"}, + error=10, + ) + self._make_request( + "updateInternetRadioStation", + {"id": station.id, "streamUrl": "missing name"}, + error=10, + ) # update the record w/ required fields - self._make_request("updateInternetRadioStation", { - "id": station.id, - "streamUrl": update["stream_url"], - "name": update["name"], - }) + self._make_request( + "updateInternetRadioStation", + { + "id": station.id, + "streamUrl": update["stream_url"], + "name": update["name"], + }, + ) with db_session: rs_update = RadioStation[station.id] - self.assertRadioStationEquals(rs_update, update["stream_url"], update["name"], test["homepage_url"]) + self.assertRadioStationEquals( + rs_update, update["stream_url"], update["name"], test["homepage_url"] + ) # update the record w/ all fields - self._make_request("updateInternetRadioStation", { - "id": station.id, - "streamUrl": update["stream_url"], - "name": update["name"], - "homepageUrl": update["homepage_url"], - }) + self._make_request( + "updateInternetRadioStation", + { + "id": station.id, + "streamUrl": update["stream_url"], + "name": update["name"], + "homepageUrl": update["homepage_url"], + }, + ) with db_session: rs_update = RadioStation[station.id] - self.assertRadioStationEquals(rs_update, update["stream_url"], update["name"], update["homepage_url"]) + self.assertRadioStationEquals( + rs_update, update["stream_url"], update["name"], update["homepage_url"] + ) def test_delete_radio_station(self): # test for non-admin access self._make_request( "deleteInternetRadioStation", {"u": "bob", "p": "B0b", "username": "alice"}, - error=50 + error=50, ) # check params self._make_request("deleteInternetRadioStation", error=10) self._make_request("deleteInternetRadioStation", {"id": 1}, error=0) - self._make_request("deleteInternetRadioStation", {"id": str(uuid.uuid4())}, error=70) + self._make_request( + "deleteInternetRadioStation", {"id": str(uuid.uuid4())}, error=70 + ) # delete with db_session: station = RadioStation( stream_url="http://example.com/radio/delete", name="Radio Delete", - homepage_url="http://example.com/update" + homepage_url="http://example.com/update", ) - self._make_request("deleteInternetRadioStation", {"id": station.id}, skip_post=True) + self._make_request( + "deleteInternetRadioStation", {"id": station.id}, skip_post=True + ) self.assertRadioStationCountEqual(0) - def test_get_radio_stations(self): test_range = 3 with db_session: @@ -180,7 +201,9 @@ class RadioStationTestCase(ApiTestBase): # verify happy path is clean self.assertRadioStationCountEqual(test_range) - rv, child = self._make_request("getInternetRadioStations", tag="internetRadioStations") + rv, child = self._make_request( + "getInternetRadioStations", tag="internetRadioStations" + ) self.assertEqual(len(child), test_range) # This order is guaranteed to work because the api returns in order by name. # Test data is sequential by design. @@ -190,7 +213,6 @@ class RadioStationTestCase(ApiTestBase): self.assertTrue(station.get("name").endswith("Radio {}".format(x))) self.assertTrue(station.get("homePageUrl").endswith("update-{}".format(x))) - # test for non-admin access rv, child = self._make_request( "getInternetRadioStations", @@ -199,4 +221,3 @@ class RadioStationTestCase(ApiTestBase): ) self.assertEqual(len(child), test_range) - diff --git a/tests/api/test_transcoding.py b/tests/api/test_transcoding.py index afd8d2c..ad9fb00 100644 --- a/tests/api/test_transcoding.py +++ b/tests/api/test_transcoding.py @@ -1,14 +1,14 @@ #!/usr/bin/env python -# coding: utf-8 # # This file is part of Supysonic. # Supysonic is a Python implementation of the Subsonic server API. # -# Copyright (C) 2017-2018 Alban 'spl0k' Féron +# Copyright (C) 2017-2020 Alban 'spl0k' Féron # # Distributed under terms of the GNU AGPLv3 license. import unittest +import sys from pony.orm import db_session @@ -46,11 +46,19 @@ class TranscodingTestCase(ApiTestBase): def test_no_transcoding_available(self): self._make_request("stream", {"id": self.trackid, "format": "wat"}, error=0) + @unittest.skipIf( + sys.platform == "win32", + "Can't test transcoding on Windows because of a lack of simple commandline tools", + ) def test_direct_transcode(self): rv = self._stream(maxBitRate=96, estimateContentLength="true") self.assertIn("tests/assets/folder/silence.mp3", rv.data) self.assertTrue(rv.data.endswith("96")) + @unittest.skipIf( + sys.platform == "win32", + "Can't test transcoding on Windows because of a lack of simple commandline tools", + ) def test_decode_encode(self): rv = self._stream(format="cat") self.assertEqual(rv.data, "Pushing out some mp3 data...") diff --git a/tests/assets/formats/silence.mp3 b/tests/assets/formats/silence.mp3 deleted file mode 120000 index fa81f24..0000000 --- a/tests/assets/formats/silence.mp3 +++ /dev/null @@ -1 +0,0 @@ -../folder/silence.mp3 \ No newline at end of file diff --git a/tests/assets/formats/silence.mp3 b/tests/assets/formats/silence.mp3 new file mode 100644 index 0000000000000000000000000000000000000000..02c2c8b5f482a68199251bad6352e17d128746fd GIT binary patch literal 85812 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