Merge branch 'v3' into master

This commit is contained in:
Peter Schorn 2021-04-14 12:15:47 -05:00 committed by GitHub
commit aa9652f1fd
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 313 additions and 62 deletions

View File

@ -8,7 +8,7 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
strategy: strategy:
matrix: matrix:
python-version: [2.7, 3.5, 3.6, 3.7, 3.8] python-version: [3.5, 3.6, 3.7, 3.8, 3.9]
steps: steps:
- uses: actions/checkout@v2 - uses: actions/checkout@v2

View File

@ -5,7 +5,24 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## Unreleased ## Unreleased [3.0.0-alpha]
While this is unreleased, please only add v3 features here. Rebasing master onto v3 doesn't require a changelog update.
### Added
* `Scope` - An enum which contains all of the authorization scopes (see [here](https://github.com/plamere/spotipy/issues/652#issuecomment-797461311)).
### Changed
* Made `CacheHandler` an abstract base class
* Modified the return structure of the `audio_features` function (wrapping the [Get Audio Features for Several Tracks](https://developer.spotify.com/documentation/web-api/reference/#endpoint-get-several-audio-features) API) to conform to the return structure of the similar methods listed below. The functions wrapping these APIs do not unwrap the single key JSON response, and this is currently the only function that does this.
* [Get Several Tracks](https://developer.spotify.com/documentation/web-api/reference/#endpoint-get-several-tracks)
* [Get Multiple Artists](https://developer.spotify.com/documentation/web-api/reference/#endpoint-get-multiple-artists)
* [Get Multiple Albums](https://developer.spotify.com/documentation/web-api/reference/#endpoint-get-multiple-albums)
## Unreleased [2.x.x]
### Added ### Added
@ -138,6 +155,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- `playlist_tracks` in favor of `playlist_items` - `playlist_tracks` in favor of `playlist_items`
### Fixed ### Fixed
- fixed issue where episode URIs were being converted to track URIs in playlist calls - fixed issue where episode URIs were being converted to track URIs in playlist calls
## [2.13.0] - 2020-06-25 ## [2.13.0] - 2020-06-25
@ -246,6 +264,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Optional `show_dialog` parameter to be passed to `SpotifyOAuth` - Optional `show_dialog` parameter to be passed to `SpotifyOAuth`
### Changed ### Changed
- Both `SpotifyClientCredentials` and `SpotifyOAuth` inherit from a common `SpotifyAuthBase` which handles common parameters and logics. - Both `SpotifyClientCredentials` and `SpotifyOAuth` inherit from a common `SpotifyAuthBase` which handles common parameters and logics.
## [2.7.1] - 2020-01-20 ## [2.7.1] - 2020-01-20
@ -289,16 +308,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [2.6.1] - 2020-01-13 ## [2.6.1] - 2020-01-13
### Fixed ### Fixed
- Fixed inconsistent behaviour with some API methods when - Fixed inconsistent behaviour with some API methods when
a full HTTP URL is passed. a full HTTP URL is passed.
- Fixed invalid calls to logging warn method - Fixed invalid calls to logging warn method
### Removed ### Removed
- `mock` no longer needed for install. Only used in `tox`. - `mock` no longer needed for install. Only used in `tox`.
## [2.6.0] - 2020-01-12 ## [2.6.0] - 2020-01-12
### Added ### Added
- Support for `playlist` to get a playlist without specifying a user - Support for `playlist` to get a playlist without specifying a user
- Support for `current_user_saved_albums_delete` - Support for `current_user_saved_albums_delete`
- Support for `current_user_saved_albums_contains` - Support for `current_user_saved_albums_contains`
@ -307,95 +329,126 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Lint with flake8 using Github action - Lint with flake8 using Github action
### Changed ### Changed
- Fix typos in doc - Fix typos in doc
- Start following [SemVer](https://semver.org) properly - Start following [SemVer](https://semver.org) properly
## [2.5.0] - 2020-01-11 ## [2.5.0] - 2020-01-11
Added follow and player endpoints Added follow and player endpoints
## [2.4.4] - 2017-01-04 ## [2.4.4] - 2017-01-04
Python 3 fix Python 3 fix
## [2.4.3] - 2017-01-02 ## [2.4.3] - 2017-01-02
Fixed proxy issue in standard auth flow Fixed proxy issue in standard auth flow
## [2.4.2] - 2017-01-02 ## [2.4.2] - 2017-01-02
Support getting audio features for a single track Support getting audio features for a single track
## [2.4.1] - 2017-01-02 ## [2.4.1] - 2017-01-02
Incorporated proxy support Incorporated proxy support
## [2.4.0] - 2016-12-31 ## [2.4.0] - 2016-12-31
Incorporated a number of PRs Incorporated a number of PRs
## [2.3.8] - 2016-03-31 ## [2.3.8] - 2016-03-31
Added recs, audio features, user top lists Added recs, audio features, user top lists
## [2.3.7] - 2015-08-10 ## [2.3.7] - 2015-08-10
Added current_user_followed_artists Added current_user_followed_artists
## [2.3.6] - 2015-06-03 ## [2.3.6] - 2015-06-03
Support for offset/limit with album_tracks API Support for offset/limit with album_tracks API
## [2.3.5] - 2015-04-28 ## [2.3.5] - 2015-04-28
Fixed bug in auto retry logic Fixed bug in auto retry logic
## [2.3.3] - 2015-04-01 ## [2.3.3] - 2015-04-01
Aadded client credential flow Aadded client credential flow
## [2.3.2] - 2015-03-31 ## [2.3.2] - 2015-03-31
Added auto retry logic Added auto retry logic
## [2.3.0] - 2015-01-05 ## [2.3.0] - 2015-01-05
Added session support added by akx. Added session support added by akx.
## [2.2.0] - 2014-11-15 ## [2.2.0] - 2014-11-15
Added support for user_playlist_tracks Added support for user_playlist_tracks
## [2.1.0] - 2014-10-25 ## [2.1.0] - 2014-10-25
Added support for new_releases and featured_playlists Added support for new_releases and featured_playlists
## [2.0.2] - 2014-08-25 ## [2.0.2] - 2014-08-25
Moved to spotipy at pypi Moved to spotipy at pypi
## [1.2.0] - 2014-08-22 ## [1.2.0] - 2014-08-22
Upgraded APIs and docs to make it be a real library Upgraded APIs and docs to make it be a real library
## [1.310.0] - 2014-08-20 ## [1.310.0] - 2014-08-20
Added playlist replace and remove methods. Added auth tests. Improved API docs Added playlist replace and remove methods. Added auth tests. Improved API docs
## [1.301.0] - 2014-08-19 ## [1.301.0] - 2014-08-19
Upgraded version number to take precedence over previously botched release (sigh) Upgraded version number to take precedence over previously botched release (sigh)
## [1.50.0] - 2014-08-14 ## [1.50.0] - 2014-08-14
Refactored util out of examples and into the main package Refactored util out of examples and into the main package
## [1.49.0] - 2014-07-23 ## [1.49.0] - 2014-07-23
Support for "Your Music" tracks (add, delete, get), with examples Support for "Your Music" tracks (add, delete, get), with examples
## [1.45.0] - 2014-07-07 ## [1.45.0] - 2014-07-07
Support for related artists endpoint. Don't use cache auth codes when scope changes Support for related artists endpoint. Don't use cache auth codes when scope changes
## [1.44.0] - 2014-07-03 ## [1.44.0] - 2014-07-03
Added show tracks.py example Added show tracks.py example
## [1.43.0] - 2014-06-27 ## [1.43.0] - 2014-06-27
Fixed JSON handling issue Fixed JSON handling issue
## [1.42.0] - 2014-06-19 ## [1.42.0] - 2014-06-19
Removed dependency on simplejson Removed dependency on simplejson
## [1.40.0] - 2014-06-12 ## [1.40.0] - 2014-06-12
Initial public release. Initial public release.
## [1.4.2] - 2014-06-21 ## [1.4.2] - 2014-06-21
Added support for retrieving starred playlists Added support for retrieving starred playlists
## [1.1.0] - 2014-06-17 ## [1.1.0] - 2014-06-17
Updates to match released API Updates to match released API
## [1.1.0] - 2014-05-18 ## [1.1.0] - 2014-05-18
Repackaged for saner imports Repackaged for saner imports
## [1.0.0] - 2017-04-05 ## [1.0.0] - 2017-04-05
Initial release Initial release

View File

@ -18,7 +18,7 @@ extra_reqs = {
setup( setup(
name='spotipy', name='spotipy',
version='2.18.0', version='3.0.0-alpha',
description='A light weight Python library for the Spotify Web API', description='A light weight Python library for the Spotify Web API',
long_description=long_description, long_description=long_description,
long_description_content_type="text/markdown", long_description_content_type="text/markdown",

View File

@ -3,3 +3,4 @@ from .client import * # noqa
from .exceptions import * # noqa from .exceptions import * # noqa
from .oauth2 import * # noqa from .oauth2 import * # noqa
from .util import * # noqa from .util import * # noqa
from .scope import * # noqa

View File

@ -1,3 +1,5 @@
# -*- coding: utf-8 -*-
__all__ = ['CacheHandler', 'CacheFileHandler', 'MemoryCacheHandler'] __all__ = ['CacheHandler', 'CacheFileHandler', 'MemoryCacheHandler']
import errno import errno
@ -5,33 +7,32 @@ import json
import logging import logging
import os import os
from spotipy.util import CLIENT_CREDS_ENV_VARS from spotipy.util import CLIENT_CREDS_ENV_VARS
from abc import ABC, abstractmethod
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
class CacheHandler(): class CacheHandler(ABC):
""" """
An abstraction layer for handling the caching and retrieval of An abstraction layer for handling the caching and retrieval of
authorization tokens. authorization tokens.
Custom extensions of this class must implement get_cached_token Clients are expected to subclass this class and override the
and save_token_to_cache methods with the same input and output get_cached_token and save_token_to_cache methods with the same
structure as the CacheHandler class. type signatures of this class.
""" """
@abstractmethod
def get_cached_token(self): def get_cached_token(self):
""" """
Get and return a token_info dictionary object. Get and return a token_info dictionary object.
""" """
# return token_info
raise NotImplementedError()
@abstractmethod
def save_token_to_cache(self, token_info): def save_token_to_cache(self, token_info):
""" """
Save a token_info dictionary object to the cache and return None. Save a token_info dictionary object to the cache and return None.
""" """
raise NotImplementedError()
return None
class CacheFileHandler(CacheHandler): class CacheFileHandler(CacheHandler):

View File

@ -1221,7 +1221,6 @@ class Spotify(object):
- limit - the number of tracks to return - limit - the number of tracks to return
- offset - the index of the first track to return - offset - the index of the first track to return
- market - an ISO 3166-1 alpha-2 country code - market - an ISO 3166-1 alpha-2 country code
""" """
return self._get("me/tracks", limit=limit, offset=offset, market=market) return self._get("me/tracks", limit=limit, offset=offset, market=market)
@ -1347,6 +1346,7 @@ class Spotify(object):
Parameters: Parameters:
- shows - a list of show URIs, URLs or IDs - shows - a list of show URIs, URLs or IDs
""" """
slist = [self._get_id("show", s) for s in shows] slist = [self._get_id("show", s) for s in shows]
return self._get("me/shows/contains?ids=" + ",".join(slist)) return self._get("me/shows/contains?ids=" + ",".join(slist))
@ -1675,11 +1675,6 @@ class Spotify(object):
else: else:
tlist = [self._get_id("track", t) for t in tracks] tlist = [self._get_id("track", t) for t in tracks]
results = self._get("audio-features/?ids=" + ",".join(tlist)) results = self._get("audio-features/?ids=" + ",".join(tlist))
# the response has changed, look for the new style first, and if
# its not there, fallback on the old style
if "audio_features" in results:
return results["audio_features"]
else:
return results return results
def devices(self): def devices(self):

View File

@ -24,7 +24,10 @@ from six.moves.BaseHTTPServer import BaseHTTPRequestHandler, HTTPServer
from six.moves.urllib_parse import parse_qsl, urlparse from six.moves.urllib_parse import parse_qsl, urlparse
from spotipy.cache_handler import CacheFileHandler, CacheHandler from spotipy.cache_handler import CacheFileHandler, CacheHandler
from spotipy.util import CLIENT_CREDS_ENV_VARS, get_host_port, normalize_scope from spotipy.util import CLIENT_CREDS_ENV_VARS, get_host_port
from spotipy.scope import Scope
from typing import Iterable
import re
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -72,6 +75,7 @@ def _ensure_value(value, env_key):
class SpotifyAuthBase(object): class SpotifyAuthBase(object):
def __init__(self, requests_session): def __init__(self, requests_session):
if isinstance(requests_session, requests.Session): if isinstance(requests_session, requests.Session):
self._session = requests_session self._session = requests_session
@ -83,7 +87,40 @@ class SpotifyAuthBase(object):
self._session = api self._session = api
def _normalize_scope(self, scope): def _normalize_scope(self, scope):
return normalize_scope(scope) """
Accepts a string of scopes, or an iterable with elements of type
`Scope` or `str` and returns a space-separated string of scopes.
Returns `None` if the argument is `None`.
"""
# TODO: do we need to sort the scopes?
if isinstance(scope, str):
# allow for any separator(s) between the scopes other than a word
# character or a hyphen
scopes = re.split(pattern=r"[^\w-]+", string=scope)
return " ".join(sorted(scopes))
if isinstance(scope, Iterable):
# Assume all of the iterable's elements are of the same type.
# If the iterable is empty, then return None.
first_element = next(iter(scope), None)
if isinstance(first_element, str):
return " ".join(sorted(scope))
if isinstance(first_element, Scope):
return Scope.make_string(scope)
if first_element is None:
return ""
elif scope is None:
return None
raise TypeError(
"Unsupported type for scopes: %s. Expected either a string of scopes, or "
"an Iterable with elements of type `Scope` or `str`." % type(scope)
)
@property @property
def client_id(self): def client_id(self):
@ -289,7 +326,11 @@ class SpotifyOAuth(SpotifyAuthBase):
* client_secret: Must be supplied or set as environment variable * client_secret: Must be supplied or set as environment variable
* redirect_uri: Must be supplied or set as environment variable * redirect_uri: Must be supplied or set as environment variable
* state: Optional, no verification is performed * state: Optional, no verification is performed
* scope: Optional, either a list of scopes or comma separated string of scopes. * scope: Optional, either a string of scopes, or an iterable with elements of type
`Scope` or `str`. E.g.,
{Scope.user_modify_playback_state, Scope.user_library_read}
iterable of scopes or comma separated string of scopes.
e.g, "playlist-read-private,playlist-read-collaborative" e.g, "playlist-read-private,playlist-read-collaborative"
* cache_path: (deprecated) Optional, will otherwise be generated * cache_path: (deprecated) Optional, will otherwise be generated
(takes precedence over `username`) (takes precedence over `username`)
@ -650,8 +691,9 @@ class SpotifyPKCE(SpotifyAuthBase):
* client_id: Must be supplied or set as environment variable * client_id: Must be supplied or set as environment variable
* redirect_uri: Must be supplied or set as environment variable * redirect_uri: Must be supplied or set as environment variable
* state: Optional, no verification is performed * state: Optional, no verification is performed
* scope: Optional, either a list of scopes or comma separated string of scopes. * scope: Optional, either a string of scopes, or an iterable with elements of type
e.g, "playlist-read-private,playlist-read-collaborative" `Scope` or `str`. E.g.,
{Scope.user_modify_playback_state, Scope.user_library_read}
* cache_path: (deprecated) Optional, will otherwise be generated * cache_path: (deprecated) Optional, will otherwise be generated
(takes precedence over `username`) (takes precedence over `username`)
* username: (deprecated) Optional or set as environment variable * username: (deprecated) Optional or set as environment variable
@ -1050,8 +1092,9 @@ class SpotifyImplicitGrant(SpotifyAuthBase):
* client_id: Must be supplied or set as environment variable * client_id: Must be supplied or set as environment variable
* redirect_uri: Must be supplied or set as environment variable * redirect_uri: Must be supplied or set as environment variable
* state: May be supplied, no verification is performed * state: May be supplied, no verification is performed
* scope: Optional, either a list of scopes or comma separated string of scopes. * scope: Optional, either a string of scopes, or an iterable with elements of type
e.g, "playlist-read-private,playlist-read-collaborative" `Scope` or `str`. E.g.,
{Scope.user_modify_playback_state, Scope.user_library_read}
* cache_handler: An instance of the `CacheHandler` class to handle * cache_handler: An instance of the `CacheHandler` class to handle
getting and saving cached authorization tokens. getting and saving cached authorization tokens.
May be supplied, will otherwise use `CacheFileHandler`. May be supplied, will otherwise use `CacheFileHandler`.

85
spotipy/scope.py Normal file
View File

@ -0,0 +1,85 @@
# -*- coding: utf-8 -*-
__all__ = ["Scope"]
from enum import Enum
import re
from typing import Iterable, Set
class Scope(Enum):
"""
The Spotify authorization scopes
Create a Scope from a string:
scope = Scope("playlist-modify-private")
Create a set of scopes:
scopes = {
Scope.user_read_currently_playing,
Scope.playlist_read_collaborative,
Scope.playlist_modify_public
}
"""
user_read_currently_playing = "user-read-currently-playing"
playlist_read_collaborative = "playlist-read-collaborative"
playlist_modify_private = "playlist-modify-private"
user_read_playback_position = "user-read-playback-position"
user_library_modify = "user-library-modify"
user_top_read = "user-top-read"
user_read_playback_state = "user-read-playback-state"
user_read_email = "user-read-email"
ugc_image_upload = "ugc-image-upload"
user_read_private = "user-read-private"
playlist_modify_public = "playlist-modify-public"
user_library_read = "user-library-read"
streaming = "streaming"
user_read_recently_played = "user-read-recently-played"
user_follow_read = "user-follow-read"
user_follow_modify = "user-follow-modify"
app_remote_control = "app-remote-control"
playlist_read_private = "playlist-read-private"
user_modify_playback_state = "user-modify-playback-state"
@staticmethod
def all() -> Set['Scope']:
"""Returns all of the authorization scopes"""
return set(Scope)
@staticmethod
def make_string(scopes: Iterable['Scope']) -> str:
"""
Converts an iterable of scopes to a space-separated string.
* scopes: An iterable of scopes.
returns: a space-separated string of scopes
"""
return " ".join([scope.value for scope in scopes])
@staticmethod
def from_string(scope_string: str) -> Set['Scope']:
"""
Converts a string of (usuallly space-separated) scopes into a
set of scopes
Any scope-strings that do not match any of the known scopes are
ignored.
* scope_string: a string of scopes
returns: a set of scopes.
"""
scope_string_list = re.split(pattern=r"[^\w-]+", string=scope_string)
scopes = set()
for scope_string in sorted(scope_string_list):
try:
scope = Scope(scope_string)
scopes.add(scope)
except ValueError:
pass
return scopes

View File

@ -7,7 +7,6 @@ __all__ = ["CLIENT_CREDS_ENV_VARS", "prompt_for_user_token"]
import logging import logging
import os import os
import warnings import warnings
import spotipy import spotipy
LOGGER = logging.getLogger(__name__) LOGGER = logging.getLogger(__name__)
@ -117,19 +116,3 @@ def get_host_port(netloc):
port = None port = None
return host, port return host, port
def normalize_scope(scope):
if scope:
if isinstance(scope, str):
scopes = scope.split(',')
elif isinstance(scope, list) or isinstance(scope, tuple):
scopes = scope
else:
raise Exception(
"Unsupported scope value, please either provide a list of scopes, "
"or a string of scopes separated by commas"
)
return " ".join(sorted(scopes))
else:
return None

View File

@ -67,19 +67,19 @@ class AuthTestSpotipy(unittest.TestCase):
def test_audio_features(self): def test_audio_features(self):
results = self.spotify.audio_features(self.four_tracks) results = self.spotify.audio_features(self.four_tracks)
self.assertTrue(len(results) == len(self.four_tracks)) self.assertTrue(len(results['audio_features']) == len(self.four_tracks))
for track in results: for track in results['audio_features']:
assert('speechiness' in track) assert('speechiness' in track)
def test_audio_features_with_bad_track(self): def test_audio_features_with_bad_track(self):
bad_tracks = ['spotify:track:bad'] bad_tracks = ['spotify:track:bad']
input = self.four_tracks + bad_tracks input = self.four_tracks + bad_tracks
results = self.spotify.audio_features(input) results = self.spotify.audio_features(input)
self.assertTrue(len(results) == len(input)) self.assertTrue(len(results['audio_features']) == len(input))
for track in results[:-1]: for track in results['audio_features'][:-1]:
if track is not None: if track is not None:
assert('speechiness' in track) assert('speechiness' in track)
self.assertTrue(results[-1] is None) self.assertTrue(results['audio_features'][-1] is None)
def test_recommendations(self): def test_recommendations(self):
results = self.spotify.recommendations( results = self.spotify.recommendations(

90
tests/unit/test_scopes.py Normal file
View File

@ -0,0 +1,90 @@
from unittest import TestCase
from spotipy.scope import Scope
from spotipy.oauth2 import SpotifyAuthBase
class SpotipyScopeTest(TestCase):
@classmethod
def setUpClass(cls):
cls.auth_manager = SpotifyAuthBase(requests_session=True)
def normalize_scope(self, scope):
return self.auth_manager._normalize_scope(scope)
def test_empty_scope(self):
scopes = set()
scope_string = Scope.make_string(scopes)
normalized_scope_string = self.normalize_scope(scopes)
normalized_scope_string_2 = self.normalize_scope(scope_string)
self.assertEqual(scope_string, "")
self.assertEqual(normalized_scope_string, "")
self.assertEqual(normalized_scope_string_2, "")
converted_scopes = Scope.from_string(scope_string)
self.assertEqual(converted_scopes, set())
def test_scopes(self):
scopes = {
Scope.playlist_modify_public,
Scope.playlist_read_collaborative,
Scope.user_read_playback_state,
Scope.ugc_image_upload
}
normalized_scope_string = self.normalize_scope(scopes)
scope_string = Scope.make_string(scopes)
self.assertEqual(scope_string, normalized_scope_string)
normalized_scope_string_2 = self.normalize_scope(scope_string)
converted_scopes = Scope.from_string(scope_string)
normalized_converted_scope = Scope.from_string(normalized_scope_string)
normalized_converted_scope_2 = Scope.from_string(normalized_scope_string_2)
self.assertEqual(scopes, converted_scopes)
self.assertEqual(scopes, normalized_converted_scope)
self.assertEqual(scopes, normalized_converted_scope_2)
def test_single_scope(self):
scope_string = "user-modify-playback-state"
scope = Scope(scope_string)
self.assertEqual(scope, Scope.user_modify_playback_state)
self.assertEqual(scope_string, scope.value)
def test_scope_string(self):
scope_string = (
"user-read-currently-playing playlist-read-collaborative,user-library-read "
"playlist-read-private user-read-email"
)
expected_scopes = {
Scope.user_read_currently_playing,
Scope.playlist_read_collaborative,
Scope.user_library_read,
Scope.playlist_read_private,
Scope.user_read_email
}
parsed_scopes = Scope.from_string(scope_string)
normalized_scope_string = self.normalize_scope(scope_string)
normalized_parsed_scopes = Scope.from_string(normalized_scope_string)
self.assertEqual(parsed_scopes, expected_scopes)
self.assertEqual(normalized_parsed_scopes, expected_scopes)
def test_invalid_types(self):
numbers = [1, 2, 3]
with self.assertRaises(TypeError):
self.normalize_scope(numbers)
with self.assertRaises(TypeError):
self.normalize_scope(True)
def test_normalize_scope(self):
normalized_scope_string = self.normalize_scope([])
self.assertEqual(normalized_scope_string, "")
normalized_scope_string_2 = self.normalize_scope(())
self.assertEqual(normalized_scope_string_2, "")
self.assertIsNone(self.normalize_scope(None))