diff --git a/.coveragerc b/.coveragerc index 44aedb03..c6341b7a 100644 --- a/.coveragerc +++ b/.coveragerc @@ -3,7 +3,12 @@ exclude_lines = pragma: no cover raise NotImplementedError raise Unsupported + raise Exception except ImportError + except BadRequest def __repr__ def __bool__ - if __name__ == .__main__.: \ No newline at end of file + def __iter__ + def __hash__ + def __len__ + if __name__ == .__main__.: diff --git a/plexapi/__init__.py b/plexapi/__init__.py index fd517488..cd49c7fa 100644 --- a/plexapi/__init__.py +++ b/plexapi/__init__.py @@ -35,10 +35,12 @@ logfile = CONFIG.get('log.path') logformat = CONFIG.get('log.format', '%(asctime)s %(module)12s:%(lineno)-4s %(levelname)-9s %(message)s') loglevel = CONFIG.get('log.level', 'INFO').upper() loghandler = logging.NullHandler() -if logfile: + +if logfile: # pragma: no cover logbackups = CONFIG.get('log.backup_count', 3, int) logbytes = CONFIG.get('log.rotate_bytes', 512000, int) loghandler = RotatingFileHandler(os.path.expanduser(logfile), 'a', logbytes, logbackups) + loghandler.setFormatter(logging.Formatter(logformat)) log.addHandler(loghandler) log.setLevel(loglevel) diff --git a/plexapi/alert.py b/plexapi/alert.py index 2e26e550..510af906 100644 --- a/plexapi/alert.py +++ b/plexapi/alert.py @@ -32,7 +32,7 @@ class AlertListener(threading.Thread): url = self._server.url(self.key).replace('http', 'ws') log.info('Starting AlertListener: %s', url) self._ws = websocket.WebSocketApp(url, on_message=self._onMessage, - on_error=self._onError) + on_error=self._onError) self._ws.run_forever() def stop(self): @@ -47,12 +47,12 @@ class AlertListener(threading.Thread): """ Called when websocket message is recieved. """ try: data = json.loads(message)['NotificationContainer'] - log.debug('Alert: %s', data) + log.debug('Alert: %s %s %s', *data) if self._callback: self._callback(data) - except Exception as err: + except Exception as err: # pragma: no cover log.error('AlertListener Msg Error: %s', err) - def _onError(self, ws, err): + def _onError(self, ws, err): # pragma: no cover """ Called when websocket error is recieved. """ log.error('AlertListener Error: %s' % err) diff --git a/plexapi/client.py b/plexapi/client.py index c07eda5e..9dc7acee 100644 --- a/plexapi/client.py +++ b/plexapi/client.py @@ -444,7 +444,7 @@ class PlexClient(PlexObject): if not self.product != 'OpenPHT': try: self.sendCommand('timeline/subscribe', port=server_url[1].strip('/'), protocol='http') - except: + except: # noqa: E722 # some clients dont need or like this and raises http 400. # We want to include the exception in the log, # but it might still work so we swallow it. diff --git a/plexapi/config.py b/plexapi/config.py index 3b1ccfd2..20f9a96e 100644 --- a/plexapi/config.py +++ b/plexapi/config.py @@ -35,7 +35,7 @@ class PlexConfig(ConfigParser): section, name = key.lower().split('.') value = self.data.get(section, {}).get(name, default) return cast(value) if cast else value - except: + except: # noqa: E722 return default def _asDict(self): diff --git a/plexapi/media.py b/plexapi/media.py index b5e969c9..4910fc58 100644 --- a/plexapi/media.py +++ b/plexapi/media.py @@ -148,7 +148,7 @@ class MediaPartStream(PlexObject): self.type = cast(int, data.attrib.get('streamType')) @staticmethod - def parse(server, data, initpath): + def parse(server, data, initpath): # pragma: no cover seems to be dead code. """ Factory method returns a new MediaPartStream from xml data. """ STREAMCLS = {1: VideoStream, 2: AudioStream, 3: SubtitleStream} stype = cast(int, data.attrib.get('streamType')) diff --git a/plexapi/myplex.py b/plexapi/myplex.py index 0735613a..832bc330 100644 --- a/plexapi/myplex.py +++ b/plexapi/myplex.py @@ -150,10 +150,10 @@ class MyPlexAccount(PlexObject): log.debug('%s %s %s', method.__name__.upper(), url, kwargs.get('json', '')) headers = self._headers(**headers or {}) response = method(url, headers=headers, timeout=timeout, **kwargs) - if response.status_code not in (200, 201, 204): + if response.status_code not in (200, 201, 204): # pragma: no cover codename = codes.get(response.status_code)[0] errtext = response.text.replace('\n', ' ') - log.warn('BadRequest (%s) %s %s; %s' % (response.status_code, codename, response.url, errtext)) + log.warning('BadRequest (%s) %s %s; %s' % (response.status_code, codename, response.url, errtext)) raise BadRequest('(%s) %s %s; %s' % (response.status_code, codename, response.url, errtext)) data = response.text.encode('utf8') return ElementTree.fromstring(data) if data.strip() else None @@ -428,8 +428,8 @@ class MyPlexUser(PlexObject): self.recommendationsPlaylistId = data.attrib.get('recommendationsPlaylistId') self.restricted = data.attrib.get('restricted') self.thumb = data.attrib.get('thumb') - self.title = data.attrib.get('title') - self.username = data.attrib.get('username') + self.title = data.attrib.get('title', '') + self.username = data.attrib.get('username', '') self.servers = self.findItems(data, MyPlexServerShare) def get_token(self, machineIdentifier): diff --git a/plexapi/photo.py b/plexapi/photo.py index 06f43c62..50db79f5 100644 --- a/plexapi/photo.py +++ b/plexapi/photo.py @@ -54,7 +54,7 @@ class Photoalbum(PlexPartialObject): def album(self, title): """ Returns the :class:`~plexapi.photo.Photoalbum` that matches the specified title. """ for album in self.albums(): - if album.attrib.get('title').lower() == title.lower(): + if album.title.lower() == title.lower(): return album raise NotFound('Unable to find album: %s' % title) @@ -66,17 +66,10 @@ class Photoalbum(PlexPartialObject): def photo(self, title): """ Returns the :class:`~plexapi.photo.Photo` that matches the specified title. """ for photo in self.photos(): - if photo.attrib.get('title').lower() == title.lower(): + if photo.title.lower() == title.lower(): return photo raise NotFound('Unable to find photo: %s' % title) - def reload(self): - """ Reload the data for this object from self.key. """ - self._initpath = self.key - data = self._server.query(self.key) - self._loadData(data) - return self - @utils.registerPlexObject class Photo(PlexPartialObject): diff --git a/plexapi/playlist.py b/plexapi/playlist.py index 5877e073..06e18ffa 100644 --- a/plexapi/playlist.py +++ b/plexapi/playlist.py @@ -34,13 +34,13 @@ class Playlist(PlexPartialObject, Playable): self.updatedAt = toDatetime(data.attrib.get('updatedAt')) self._items = None # cache for self.items - def __len__(self): + def __len__(self): # pragma: no cover return len(self.items()) - def __contains__(self, other): + def __contains__(self, other): # pragma: no cover return any(i.key == other.key for i in self.items()) - def __getitem__(self, key): + def __getitem__(self, key): # pragma: no cover return self.items()[key] def items(self): @@ -57,7 +57,7 @@ class Playlist(PlexPartialObject, Playable): items = [items] ratingKeys = [] for item in items: - if item.listType != self.playlistType: + if item.listType != self.playlistType: # pragma: no cover raise BadRequest('Can not mix media types when building a playlist: %s and %s' % (self.playlistType, item.listType)) ratingKeys.append(str(item.ratingKey)) @@ -108,7 +108,7 @@ class Playlist(PlexPartialObject, Playable): items = [items] ratingKeys = [] for item in items: - if item.listType != items[0].listType: + if item.listType != items[0].listType: # pragma: no cover raise BadRequest('Can not mix media types when building a playlist') ratingKeys.append(str(item.ratingKey)) ratingKeys = ','.join(ratingKeys) diff --git a/plexapi/utils.py b/plexapi/utils.py index 7e332f21..f7add415 100644 --- a/plexapi/utils.py +++ b/plexapi/utils.py @@ -74,14 +74,6 @@ def cast(func, value): return value -def getattributeOrNone(obj, self, attr): - """ Returns result from __getattribute__ or None if not found. """ - try: - return super(obj, self).__getattribute__(attr) - except AttributeError: - return None - - def joinArgs(args): """ Returns a query string (uses for HTTP URLs) where only the value is URL encoded. Example return value: '?genre=action&type=1337'. @@ -129,7 +121,7 @@ def rget(obj, attrstr, default=None, delim='.'): # pragma: no cover if attrstr: return rget(value, attrstr, default, delim) return value - except: + except: # noqa: E722 return default @@ -198,7 +190,8 @@ def toList(value, itemcast=None, delim=','): return [itemcast(item) for item in value.split(delim) if item != ''] -def downloadSessionImages(server, filename=None, height=150, width=150, opacity=100, saturation=100): +def downloadSessionImages(server, filename=None, height=150, width=150, + opacity=100, saturation=100): # pragma: no cover """ Helper to download a bif image or thumb.url from plex.server.sessions. Parameters: @@ -243,7 +236,7 @@ def download(url, filename=None, savepath=None, session=None, chunksize=4024, mocked (bool): Helper to do evertything except write the file. unpack (bool): Unpack the zip file. showstatus(bool): Display a progressbar. - + Example: >>> download(a_episode.getStreamURL(), a_episode.location) /path/to/file @@ -278,7 +271,7 @@ def download(url, filename=None, savepath=None, session=None, chunksize=4024, # save the file to disk log.info('Downloading: %s', fullpath) - if showstatus: + if showstatus: # pragma: no cover total = int(response.headers.get('content-length', 0)) bar = tqdm(unit='B', unit_scale=True, total=total, desc=filename) @@ -288,7 +281,7 @@ def download(url, filename=None, savepath=None, session=None, chunksize=4024, if showstatus: bar.update(len(chunk)) - if showstatus: + if showstatus: # pragma: no cover bar.close() # check we want to unzip the contents if fullpath.endswith('zip') and unpack: @@ -314,7 +307,7 @@ def tag_helper(tag, items, locked=True, remove=False): return data -def getMyPlexAccount(opts=None): +def getMyPlexAccount(opts=None): # pragma: no cover """ Helper function tries to get a MyPlex Account instance by checking the the following locations for a username and password. This is useful to create user-friendly command line tools. @@ -341,7 +334,7 @@ def getMyPlexAccount(opts=None): return MyPlexAccount(username, password) -def choose(msg, items, attr): +def choose(msg, items, attr): # pragma: no cover """ Command line helper to display a list of choices, asking the user to choose one of the options. """ diff --git a/tests/conftest.py b/tests/conftest.py index e1135c03..ba147280 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -15,12 +15,24 @@ # 3. A Photos section containing the photoalbums: # Cats (with cute cat photos inside) # 4. A TV Shows section containing at least two seasons of The 100. -import plexapi, pytest, requests +from datetime import datetime +from functools import partial + +import pytest +import requests + +try: + from unittest.mock import patch, MagicMock +except ImportError: + from mock import patch, MagicMock + + +import plexapi from plexapi import compat from plexapi.client import PlexClient -from datetime import datetime + from plexapi.server import PlexServer -from functools import partial + SERVER_BASEURL = plexapi.CONFIG.get('auth.server_baseurl') SERVER_TOKEN = plexapi.CONFIG.get('auth.server_token') @@ -150,10 +162,29 @@ def monkeydownload(request, monkeypatch): monkeypatch.undo() +def callable_http_patch(): + return patch('plexapi.server.requests.sessions.Session.send', + return_value=MagicMock(status_code=200, + text='')) + + +@pytest.fixture() +def empty_response(mocker): + response = mocker.MagicMock(status_code=200, text='') + return response + + +@pytest.fixture() +def patched_http_call(mocker): + return mocker.patch('plexapi.server.requests.sessions.Session.send', + return_value=MagicMock(status_code=200, + text='') + ) + + # --------------------------------- # Utility Functions # --------------------------------- - def is_datetime(value): return value > MIN_DATETIME diff --git a/tests/test_library.py b/tests/test_library.py index 7ce556e2..083e0f8a 100644 --- a/tests/test_library.py +++ b/tests/test_library.py @@ -45,13 +45,8 @@ def test_library_section_get_movie(plex): assert plex.library.section('Movies').get('Sita Sings the Blues') -def test_library_section_delete(monkeypatch, movies): - monkeypatch.delattr("requests.sessions.Session.request") - try: - movies.delete() - except AttributeError: - # will always raise because there is no request - pass +def test_library_section_delete(movies, patched_http_call): + movies.delete() def test_library_fetchItem(plex, movie): @@ -69,11 +64,6 @@ def test_library_recentlyAdded(plex): assert len(list(plex.library.recentlyAdded())) -def test_library_search(plex): - item = plex.library.search('Elephants Dream')[0] - assert item.title == 'Elephants Dream' - - def test_library_add_edit_delete(plex): # Dont add a location to prevent scanning scanning section_name = 'plexapi_test_section' @@ -115,14 +105,33 @@ def test_library_Library_deleteMediaPreviews(plex): plex.library.deleteMediaPreviews() -def _test_library_MovieSection_refresh(movies): - movies.refresh() +def test_library_Library_all(plex): + assert len(plex.library.all(title__iexact='The 100')) + + +def test_library_Library_search(plex): + item = plex.library.search('Elephants Dream')[0] + assert item.title == 'Elephants Dream' + assert len(plex.library.search(libtype='episode')) def test_library_MovieSection_update(movies): movies.update() +def test_library_ShowSection_all(tvshows): + assert len(tvshows.all(title__iexact='The 100')) + + +def test_library_MovieSection_refresh(movies, patched_http_call): + movies.refresh() + + +def test_library_MovieSection_search_genre(movie, movies): + # assert len(movie.genres[0].items()) # TODO + assert len(movies.search(genre=movie.genres[0])) > 1 + + def test_library_MovieSection_cancelUpdate(movies): movies.cancelUpdate() diff --git a/tests/test_myplex.py b/tests/test_myplex.py index 978d0f37..a5750bac 100644 --- a/tests/test_myplex.py +++ b/tests/test_myplex.py @@ -1,4 +1,7 @@ # -*- coding: utf-8 -*- +import pytest +from plexapi.exceptions import BadRequest, NotFound +from . import conftest as utils def test_myplex_accounts(account, plex): @@ -16,7 +19,7 @@ def test_myplex_accounts(account, plex): account = plex.account() print('Local PlexServer.account():') print('username: %s' % account.username) - print('authToken: %s' % account.authToken) + #print('authToken: %s' % account.authToken) print('signInState: %s' % account.signInState) assert account.username, 'Account has no username' assert account.authToken, 'Account has no authToken' @@ -51,6 +54,10 @@ def test_myplex_devices(account): assert devices, 'No devices found for account: %s' % account.name +def test_myplex_device(account): + assert account.device('pkkid-plexapi') + + def _test_myplex_connect_to_device(account): devices = account.devices() for device in devices: @@ -67,3 +74,81 @@ def test_myplex_users(account): user = account.user(users[0].title) print('Found user: %s' % user) assert user, 'Could not find user %s' % users[0].title + + assert len(users[0].servers[0].sections()) == 7, "Could'nt info about the shared libraries" + + +def test_myplex_resource(account): + assert account.resource('pkkid-plexapi') + + +def test_myplex_webhooks(account): + # Webhooks are a plex pass feature to this will fail + with pytest.raises(BadRequest): + account.webhooks() + + +def test_myplex_addwebhooks(account): + with pytest.raises(BadRequest): + account.addWebhook('http://site.com') + + +def test_myplex_deletewebhooks(account): + with pytest.raises(BadRequest): + account.deleteWebhook('http://site.com') + + +def test_myplex_optout(account): + def enabled(): + ele = account.query('https://plex.tv/api/v2/user/privacy') + lib = ele.attrib.get('optOutLibraryStats') + play = ele.attrib.get('optOutPlayback') + return bool(int(lib)), bool(int(play)) + + # This should be False False + library_enabled, playback_enabled = enabled() + + account.optOut(library=True, playback=True) + + assert all(enabled()) + + account.optOut(library=False, playback=False) + + assert not all(enabled()) + + +def test_myplex_inviteFriend_remove(account, plex, mocker): + inv_user = 'hellowlol' + vid_filter = {'contentRating': ['G'], 'label': ['foo']} + secs = plex.library.sections() + + ids = account._getSectionIds(plex.machineIdentifier, secs) + with mocker.patch.object(account, '_getSectionIds', return_value=ids): + with utils.callable_http_patch(): + + account.inviteFriend(inv_user, plex, secs, allowSync=True, allowCameraUpload=True, + allowChannels=False, filterMovies=vid_filter, filterTelevision=vid_filter, + filterMusic={'label': ['foo']}) + + assert inv_user not in [u.title for u in account.users()] + + with pytest.raises(NotFound): + with utils.callable_http_patch(): + account.removeFriend(inv_user) + + +def test_myplex_updateFriend(account, plex, mocker): + edit_user = 'PKKid' + vid_filter = {'contentRating': ['G'], 'label': ['foo']} + secs = plex.library.sections() + user = account.user(edit_user) + + ids = account._getSectionIds(plex.machineIdentifier, secs) + with mocker.patch.object(account, '_getSectionIds', return_value=ids): + with mocker.patch.object(account, 'user', return_value=user): + with utils.callable_http_patch(): + + account.updateFriend(edit_user, plex, secs, allowSync=True, removeSections=True, + allowCameraUpload=True, allowChannels=False, filterMovies=vid_filter, + filterTelevision=vid_filter, filterMusic={'label': ['foo']}) + diff --git a/tests/test_photo.py b/tests/test_photo.py new file mode 100644 index 00000000..0a7a6c3f --- /dev/null +++ b/tests/test_photo.py @@ -0,0 +1,9 @@ + + +def test_photo_Photoalbum(photoalbum): + assert len(photoalbum.albums()) == 3 + assert len(photoalbum.photos()) == 3 + cats_in_bed = photoalbum.album('Cats in bed') + assert len(cats_in_bed.photos()) == 7 + a_pic = cats_in_bed.photo('maxresdefault') + assert a_pic diff --git a/tests/test_playlist.py b/tests/test_playlist.py index 33c8ecd6..c52c73c4 100644 --- a/tests/test_playlist.py +++ b/tests/test_playlist.py @@ -1,5 +1,6 @@ # -*- coding: utf-8 -*- -import pytest, time +import time +import pytest def test_create_playlist(plex, show): @@ -99,14 +100,11 @@ def test_playqueues(plex): def test_copyToUser(plex, show, fresh_plex): - # Skip out if we do not have plexpass - if not plex.myPlexSubscription: - pytest.skip('PlexPass subscription required for test.') episodes = show.episodes() playlist = plex.createPlaylist('shared_from_test_plexapi', episodes) try: - playlist.copyToUser('plexapi2') - user = plex.myPlexAccount().user('plexapi2') + playlist.copyToUser('PKKid') + user = plex.myPlexAccount().user('PKKid') user_plex = fresh_plex(plex._baseurl, user.get_token(plex.machineIdentifier)) assert playlist.title in [p.title for p in user_plex.playlists()] finally: diff --git a/tests/test_server.py b/tests/test_server.py index c35ff34d..5103e0c8 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -155,12 +155,6 @@ def test_server_createPlayQueue(plex, movie): assert playqueue.playQueueShuffled is True -def _test_server_createPlaylist(): - # TODO: Implement _test_server_createPlaylist() - # see test_playlists.py - pass - - def test_server_client_not_found(plex): with pytest.raises(NotFound): plex.client('') @@ -171,9 +165,14 @@ def test_server_sessions(plex): def test_server_isLatest(plex, mocker): - # I really need to update the testservers pms.. TODO - with mocker.patch('plexapi.server.PlexServer.isLatest', return_value=True): - assert plex.isLatest() is True + plex.isLatest() + + +def test_server_installUpdate(plex, mocker): + m = mocker.MagicMock(release='aa') + mocker.patch('plexapi.server.PlexServer.check_for_update', return_value=m) + with utils.callable_http_patch(): + plex.installUpdate() def test_server_check_for_update(plex, mocker): diff --git a/tests/test_settings.py b/tests/test_settings.py new file mode 100644 index 00000000..0ed38e17 --- /dev/null +++ b/tests/test_settings.py @@ -0,0 +1,17 @@ + +def test_settings_group(plex): + assert plex.settings.group('general') + + +def test_settings_get(plex): + # This is the value since it we havnt set any friendlyname + # plex just default to computer name but it NOT in the settings. + assert plex.settings.get('FriendlyName').value == '' + + +def test_settings_get(plex): + cd = plex.settings.get('collectUsageData') + cd.set(False) + # Save works but since we reload asap the data isnt changed. + # or it might be our caching that does this. ## TODO + plex.settings.save() diff --git a/tests/test_video.py b/tests/test_video.py index 3cc493cd..ae6673e6 100644 --- a/tests/test_video.py +++ b/tests/test_video.py @@ -17,10 +17,9 @@ def test_video_ne(movies): assert len(movies.fetchItems('/library/sections/7/all', title__ne='Sintel')) == 3 -def test_video_Movie_delete(monkeypatch, movie): - monkeypatch.delattr('requests.sessions.Session.request') - with pytest.raises(AttributeError): - movie.delete() +def test_video_Movie_delete(movie, patched_http_call): + movie.delete() + def test_video_Movie_addCollection(movie): labelname = 'Random_label' @@ -61,6 +60,15 @@ def test_video_Movie_isPartialObject(movie): assert movie.isPartialObject() +def test_video_Movie_delete_part(movie, mocker): + # we need to reload this as there is a bug in part.delete + # See https://github.com/pkkid/python-plexapi/issues/201 + m = movie.reload() + for part in m.iterParts(): + with utils.callable_http_patch(): + part.delete() + + def test_video_Movie_iterParts(movie): assert len(list(movie.iterParts())) >= 1 @@ -72,6 +80,10 @@ def test_video_Movie_download(monkeydownload, tmpdir, movie): assert len(filepaths2) >= 1 +def test_video_Movie_subtitlestreams(movie): + assert not movie.subtitleStreams() + + def test_video_Movie_attrs(movies): movie = movies.get('Sita Sings the Blues') assert len(movie.locations[0]) >= 10 @@ -259,6 +271,19 @@ def test_video_Show(show): assert show.title == 'Game of Thrones' +def test_video_Episode_split(episode, patched_http_call): + episode.split() + + +def test_video_Episode_unmatch(episode, patched_http_call): + episode.unmatch() + + +def test_video_Episode_stop(episode, mocker, patched_http_call): + mocker.patch.object(episode, 'session', return_value=list(mocker.MagicMock(id='hello'))) + episode.stop(reason="It's past bedtime!") + + def test_video_Show_attrs(show): assert utils.is_datetime(show.addedAt) assert utils.is_metadata(show.art, contains='/art/')