From 924f65fdddcc8cb164bdd8ef8f281edfe2d3418a Mon Sep 17 00:00:00 2001 From: Anna Schumaker Date: Tue, 30 Jan 2024 16:34:00 -0500 Subject: [PATCH] listenbrainz: Set the user API token I added a "user-token" property to the ListenBrainz object, and watch for changes to query the liblistenbrainz client. I also set up an idle callback so we can set the "valid-token" property so the user can know if there is a problem. Implements: #69 ("Add ListenBrainz support") Signed-off-by: Anna Schumaker --- emmental/listenbrainz/__init__.py | 45 +++++++++++++++++ emmental/listenbrainz/task.py | 18 +++++++ emmental/listenbrainz/thread.py | 38 +++++++++++++++ tests/listenbrainz/test_listenbrainz.py | 64 +++++++++++++++++++++++-- tests/listenbrainz/test_task.py | 16 +++++++ tests/listenbrainz/test_thread.py | 39 ++++++++++++++- 6 files changed, 216 insertions(+), 4 deletions(-) diff --git a/emmental/listenbrainz/__init__.py b/emmental/listenbrainz/__init__.py index 63a0898..3d2863e 100644 --- a/emmental/listenbrainz/__init__.py +++ b/emmental/listenbrainz/__init__.py @@ -1,6 +1,7 @@ # Copyright 2024 (c) Anna Schumaker. """Our ListenBrainz custom GObject.""" from gi.repository import GObject +from gi.repository import GLib from . import thread from . import task @@ -8,12 +9,56 @@ from . import task class ListenBrainz(GObject.GObject): """Our main ListenBrainz GObject.""" + user_token = GObject.Property(type=str) + valid_token = GObject.Property(type=bool, default=True) + def __init__(self): """Initialize the ListenBrainz GObject.""" super().__init__() self._queue = task.Queue() self._thread = thread.Thread() + self._idle_id = None + + self.connect("notify::user-token", self.__notify_user_token) + + def __check_result(self) -> None: + if (res := self._thread.get_result()) is not None: + self.valid_token = res.valid + + def __parse_task(self, op: str, *args) -> bool: + match op: + case "set-token": + self._thread.set_user_token(*args) + + return GLib.SOURCE_CONTINUE + + def __idle_work(self) -> bool: + if self._thread.ready.is_set(): + self.__check_result() + + if (task := self._queue.pop()) is None: + self._idle_id = None + return GLib.SOURCE_REMOVE + + return self.__parse_task(*task) + return GLib.SOURCE_CONTINUE + + def __idle_start(self) -> None: + if self._idle_id is None: + self._idle_id = GLib.idle_add(self.__idle_work) + + def __notify_user_token(self, listenbrainz: GObject.GObject, + param: GObject.ParamSpec) -> None: + self._queue.push("set-token", self.user_token) + self.__idle_start() + + def __source_stop(self, srcid: str) -> None: + if (id := getattr(self, srcid)) is not None: + GLib.source_remove(id) + setattr(self, srcid, None) + def stop(self) -> None: """Stop the ListenBrainz thread.""" + self.__source_stop("_idle_id") self._thread.stop() diff --git a/emmental/listenbrainz/task.py b/emmental/listenbrainz/task.py index b4e88bd..29f5c40 100644 --- a/emmental/listenbrainz/task.py +++ b/emmental/listenbrainz/task.py @@ -4,3 +4,21 @@ class Queue: """A queue for prioritizing ListenBrainz operations.""" + + def __init__(self): + """Initialize the task Queue.""" + self._set_token = None + + def clear(self, op: str) -> None: + """Clear a pending operation.""" + self._set_token = None + + def push(self, op: str, *args) -> None: + """Push an operation onto the queue.""" + self._set_token = (op, *args) + + def pop(self) -> tuple: + """Pop an operation off the queue.""" + res = self._set_token + self._set_token = None + return res diff --git a/emmental/listenbrainz/thread.py b/emmental/listenbrainz/thread.py index 2e2745d..3ee69d8 100644 --- a/emmental/listenbrainz/thread.py +++ b/emmental/listenbrainz/thread.py @@ -1,7 +1,45 @@ # Copyright 2024 (c) Anna Schumaker. """Our ListenBrainz client thread.""" +import liblistenbrainz from .. import thread class Thread(thread.Thread): """Thread for submitting listens to ListenBrainz.""" + + def __init__(self): + """Initialize the ListenBrainz Thread object.""" + super().__init__() + self._client = liblistenbrainz.client.ListenBrainz() + + def __print(self, text: str) -> None: + print(f"listenbrainz: {text}") + + def __set_user_token(self, token: str) -> None: + try: + self._client.set_auth_token(token) + self.set_result("set-token", token=token) + except liblistenbrainz.errors.InvalidAuthTokenException: + self.set_result("set-token", token=token, valid=False) + + def do_run_task(self, task: thread.Data) -> None: + """Call a specific listenbrainz operation.""" + match task.op: + case "set-token": + self.__set_user_token(task.token) + + def get_result(self, **kwargs) -> thread.Data: + """Get the result of a listenbrainz task.""" + if (res := super().get_result(**kwargs)) is not None: + if not res.valid: + self.__print("user token is invalid") + return res + + def set_result(self, op: str, *, valid: bool = True, **kwargs) -> None: + """Set the Thread result with a standard format for all ops.""" + super().set_result(op=op, valid=valid, **kwargs) + + def set_user_token(self, token: str) -> None: + """Schedule setting the user token.""" + self.__print("setting user token") + self.set_task(op="set-token", token=token) diff --git a/tests/listenbrainz/test_listenbrainz.py b/tests/listenbrainz/test_listenbrainz.py index 42d6def..b7a031b 100644 --- a/tests/listenbrainz/test_listenbrainz.py +++ b/tests/listenbrainz/test_listenbrainz.py @@ -1,10 +1,15 @@ # Copyright 2024 (c) Anna Schumaker. """Tests our custom ListenBrainz GObject.""" import emmental.listenbrainz +import io import unittest from gi.repository import GObject +from gi.repository import GLib +@unittest.mock.patch("sys.stdout", new_callable=io.StringIO) +@unittest.mock.patch("gi.repository.GLib.source_remove") +@unittest.mock.patch("gi.repository.GLib.idle_add", return_value=42) class TestListenBrainz(unittest.TestCase): """ListenBrainz GObject test case.""" @@ -12,19 +17,72 @@ class TestListenBrainz(unittest.TestCase): """Set up common variables.""" self.listenbrainz = emmental.listenbrainz.ListenBrainz() - def tearDown(self): + @unittest.mock.patch("gi.repository.GLib.source_remove") + def tearDown(self, mock_source_remove: unittest.mock.Mock): """Clean up.""" self.listenbrainz.stop() - def test_init(self): + def test_init(self, mock_idle_add: unittest.mock.Mock, + mock_source_remove: unittest.mock.Mock, + mock_stdout: io.StringIO): """Test that the ListenBrainz GObject was set up properly.""" self.assertIsInstance(self.listenbrainz, GObject.GObject) self.assertIsInstance(self.listenbrainz._queue, emmental.listenbrainz.task.Queue) self.assertIsInstance(self.listenbrainz._thread, emmental.listenbrainz.thread.Thread) + self.assertIsNone(self.listenbrainz._idle_id) - def test_stop(self): + def test_stop(self, mock_idle_add: unittest.mock.Mock, + mock_source_remove: unittest.mock.Mock, + mock_stdout: io.StringIO): """Test stopping the thread during shutdown.""" + self.listenbrainz._idle_id = 12345 + self.listenbrainz.stop() self.assertFalse(self.listenbrainz._thread.is_alive()) + self.assertIsNone(self.listenbrainz._idle_id) + mock_source_remove.assert_called_with(12345) + + def test_set_user_token(self, mock_idle_add: unittest.mock.Mock, + mock_source_remove: unittest.mock.Mock, + mock_stdout: io.StringIO): + """Test setting the user-token property.""" + self.assertEqual(self.listenbrainz.user_token, "") + self.assertTrue(self.listenbrainz.valid_token) + + idle_work = self.listenbrainz._ListenBrainz__idle_work + with unittest.mock.patch.object(self.listenbrainz._thread, + "set_user_token") as mock_set_token: + self.listenbrainz.user_token = "abc" + self.assertEqual(self.listenbrainz._queue._set_token, + ("set-token", "abc")) + self.assertEqual(self.listenbrainz._idle_id, 42) + mock_idle_add.assert_called_with(idle_work) + + self.assertEqual(idle_work(), GLib.SOURCE_CONTINUE) + mock_set_token.assert_called_with("abc") + + mock_idle_add.reset_mock() + self.listenbrainz.user_token = "abcde" + self.assertEqual(self.listenbrainz._queue._set_token, + ("set-token", "abcde")) + self.assertEqual(self.listenbrainz._idle_id, 42) + mock_idle_add.assert_not_called() + + self.listenbrainz._thread.set_result(op="set-token", token="abc", + valid=True) + self.assertEqual(idle_work(), GLib.SOURCE_CONTINUE) + mock_set_token.assert_called_with("abcde") + + self.listenbrainz._thread.ready.clear() + self.assertEqual(idle_work(), GLib.SOURCE_CONTINUE) + + for valid in [False, True]: + with self.subTest(valid=valid): + self.listenbrainz._thread.set_result(op="set-token", + token="abcde", + valid=valid) + self.assertEqual(idle_work(), GLib.SOURCE_REMOVE) + self.assertEqual(self.listenbrainz.valid_token, valid) + self.assertIsNone(self.listenbrainz._idle_id) diff --git a/tests/listenbrainz/test_task.py b/tests/listenbrainz/test_task.py index 8bcd106..8383082 100644 --- a/tests/listenbrainz/test_task.py +++ b/tests/listenbrainz/test_task.py @@ -14,3 +14,19 @@ class TestTaskQueue(unittest.TestCase): def test_init(self): """Test that the queue was set up properly.""" self.assertIsNotNone(self.queue) + + def test_push_set_token(self): + """Test calling push() with the 'set-token' operation.""" + self.assertIsNone(self.queue._set_token) + + self.queue.push("set-token", "abcde") + self.assertTupleEqual(self.queue._set_token, ("set-token", "abcde")) + self.queue.push("set-token", "fghij") + self.assertTupleEqual(self.queue._set_token, ("set-token", "fghij")) + + self.assertTupleEqual(self.queue.pop(), ("set-token", "fghij")) + self.assertIsNone(self.queue._set_token) + + self.queue.push("set-token", "abcde") + self.queue.clear("set-token") + self.assertIsNone(self.queue._set_token) diff --git a/tests/listenbrainz/test_thread.py b/tests/listenbrainz/test_thread.py index 446055b..c59dfd6 100644 --- a/tests/listenbrainz/test_thread.py +++ b/tests/listenbrainz/test_thread.py @@ -1,9 +1,12 @@ # Copyright 2024 (c) Anna Schumaker. """Tests our ListenBrainz client thread.""" import emmental.listenbrainz.thread +import io +import liblistenbrainz import unittest +@unittest.mock.patch("sys.stdout", new_callable=io.StringIO) class TestThread(unittest.TestCase): """ListenBrainz Thread test case.""" @@ -15,6 +18,40 @@ class TestThread(unittest.TestCase): """Clean up.""" self.thread.stop() - def test_init(self): + def test_init(self, mock_stdout: io.StringIO): """Test that the ListenBrainz thread was initialized properly.""" self.assertIsInstance(self.thread, emmental.thread.Thread) + self.assertIsInstance(self.thread._client, + liblistenbrainz.client.ListenBrainz) + + def test_set_user_token(self, mock_stdout: io.StringIO): + """Test setting the user auth token.""" + with unittest.mock.patch.object(self.thread._client, + "set_auth_token") as mock_set_auth: + self.thread.set_user_token("abcde") + self.assertFalse(self.thread.ready.is_set()) + self.assertEqual(self.thread._task, + {"op": "set-token", "token": "abcde"}) + self.assertEqual(mock_stdout.getvalue(), + "listenbrainz: setting user token\n") + + self.thread.ready.wait() + mock_set_auth.assert_called_with("abcde") + self.assertEqual(self.thread.get_result(), + {"op": "set-token", "token": "abcde", + "valid": True}) + + def test_set_user_token_exceptions(self, mock_stdout: io.StringIO): + """Test exception handling when setting the user auth token.""" + with unittest.mock.patch.object(self.thread._client, + "set_auth_token") as mock_set_auth: + mock_set_auth.side_effect = \ + liblistenbrainz.errors.InvalidAuthTokenException() + self.thread.set_user_token("abcde") + self.thread.ready.wait() + self.assertEqual(self.thread.get_result(), + {"op": "set-token", "token": "abcde", + "valid": False}) + self.assertEqual(mock_stdout.getvalue(), + "listenbrainz: setting user token\n" + + "listenbrainz: user token is invalid\n")