spotifywebapiPython 1.0.242__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (138) hide show
  1. spotifywebapipython/__init__.py +35 -0
  2. spotifywebapipython/const.py +114 -0
  3. spotifywebapipython/docs/__init__.html +2156 -0
  4. spotifywebapipython/docs/codecopy.js +70 -0
  5. spotifywebapipython/docs/index.html +514 -0
  6. spotifywebapipython/docs/search.js +46 -0
  7. spotifywebapipython/docs/spotifywebapipython/const.html +636 -0
  8. spotifywebapipython/docs/spotifywebapipython/saappmessages.html +540 -0
  9. spotifywebapipython/docs/spotifywebapipython/sautils.html +620 -0
  10. spotifywebapipython/docs/spotifywebapipython/spotifyapierror.html +341 -0
  11. spotifywebapipython/docs/spotifywebapipython/spotifyapimessage.html +598 -0
  12. spotifywebapipython/docs/spotifywebapipython/spotifyauthtoken.html +612 -0
  13. spotifywebapipython/docs/spotifywebapipython/spotifyclient.html +16557 -0
  14. spotifywebapipython/docs/spotifywebapipython/spotifydiscovery.html +518 -0
  15. spotifywebapipython/docs/spotifywebapipython/spotifywebapiauthenticationerror.html +421 -0
  16. spotifywebapipython/docs/spotifywebapipython/spotifywebapierror.html +400 -0
  17. spotifywebapipython/docs/spotifywebapipython/spotifywebplayertoken.html +635 -0
  18. spotifywebapipython/docs/spotifywebapipython.ico +0 -0
  19. spotifywebapipython/docs/spotifywebapipython_OLD.ico +0 -0
  20. spotifywebapipython/models/__init__.py +121 -0
  21. spotifywebapipython/models/album.py +170 -0
  22. spotifywebapipython/models/albumpagesaved.py +85 -0
  23. spotifywebapipython/models/albumpagesimplified.py +88 -0
  24. spotifywebapipython/models/albumsaved.py +102 -0
  25. spotifywebapipython/models/albumsimplified.py +308 -0
  26. spotifywebapipython/models/artist.py +146 -0
  27. spotifywebapipython/models/artistinfo.py +282 -0
  28. spotifywebapipython/models/artistinfotourevent.py +127 -0
  29. spotifywebapipython/models/artistpage.py +70 -0
  30. spotifywebapipython/models/artistsimplified.py +163 -0
  31. spotifywebapipython/models/audiobook.py +87 -0
  32. spotifywebapipython/models/audiobookpagesimplified.py +88 -0
  33. spotifywebapipython/models/audiobooksimplified.py +362 -0
  34. spotifywebapipython/models/audiofeatures.py +333 -0
  35. spotifywebapipython/models/author.py +67 -0
  36. spotifywebapipython/models/category.py +168 -0
  37. spotifywebapipython/models/categorypage.py +70 -0
  38. spotifywebapipython/models/chapter.py +84 -0
  39. spotifywebapipython/models/chapterpagesimplified.py +88 -0
  40. spotifywebapipython/models/chaptersimplified.py +387 -0
  41. spotifywebapipython/models/context.py +113 -0
  42. spotifywebapipython/models/copyright.py +79 -0
  43. spotifywebapipython/models/device.py +301 -0
  44. spotifywebapipython/models/episode.py +84 -0
  45. spotifywebapipython/models/episodepagesaved.py +85 -0
  46. spotifywebapipython/models/episodepagesimplified.py +88 -0
  47. spotifywebapipython/models/episodesaved.py +99 -0
  48. spotifywebapipython/models/episodesimplified.py +370 -0
  49. spotifywebapipython/models/explicitcontent.py +80 -0
  50. spotifywebapipython/models/externalids.py +91 -0
  51. spotifywebapipython/models/externalurls.py +104 -0
  52. spotifywebapipython/models/followers.py +81 -0
  53. spotifywebapipython/models/imageobject.py +193 -0
  54. spotifywebapipython/models/imagevibrantcolors.py +182 -0
  55. spotifywebapipython/models/linkedfrom.py +165 -0
  56. spotifywebapipython/models/narrator.py +67 -0
  57. spotifywebapipython/models/owner.py +162 -0
  58. spotifywebapipython/models/pageobject.py +343 -0
  59. spotifywebapipython/models/playeractions.py +196 -0
  60. spotifywebapipython/models/playerlastplayedinfo.py +334 -0
  61. spotifywebapipython/models/playerplaystate.py +467 -0
  62. spotifywebapipython/models/playerqueueinfo.py +207 -0
  63. spotifywebapipython/models/playhistory.py +136 -0
  64. spotifywebapipython/models/playhistorypage.py +85 -0
  65. spotifywebapipython/models/playlist.py +125 -0
  66. spotifywebapipython/models/playlistpage.py +85 -0
  67. spotifywebapipython/models/playlistpagesimplified.py +107 -0
  68. spotifywebapipython/models/playlistsimplified.py +334 -0
  69. spotifywebapipython/models/playlisttrack.py +143 -0
  70. spotifywebapipython/models/playlisttracksummary.py +80 -0
  71. spotifywebapipython/models/recommendationseed.py +135 -0
  72. spotifywebapipython/models/restrictions.py +86 -0
  73. spotifywebapipython/models/resumepoint.py +79 -0
  74. spotifywebapipython/models/searchresponse.py +391 -0
  75. spotifywebapipython/models/searchresultbase.py +299 -0
  76. spotifywebapipython/models/show.py +87 -0
  77. spotifywebapipython/models/showpagesaved.py +85 -0
  78. spotifywebapipython/models/showpagesimplified.py +88 -0
  79. spotifywebapipython/models/showsaved.py +99 -0
  80. spotifywebapipython/models/showsimplified.py +320 -0
  81. spotifywebapipython/models/spotifyconnectdevice.py +321 -0
  82. spotifywebapipython/models/spotifyconnectdevices.py +441 -0
  83. spotifywebapipython/models/track.py +142 -0
  84. spotifywebapipython/models/trackpage.py +110 -0
  85. spotifywebapipython/models/trackpagesaved.py +85 -0
  86. spotifywebapipython/models/trackpagesimplified.py +88 -0
  87. spotifywebapipython/models/trackrecommendations.py +88 -0
  88. spotifywebapipython/models/tracksaved.py +99 -0
  89. spotifywebapipython/models/tracksimplified.py +398 -0
  90. spotifywebapipython/models/userprofile.py +184 -0
  91. spotifywebapipython/models/userprofilesimplified.py +206 -0
  92. spotifywebapipython/models/zeroconfdiscoveryresult.py +592 -0
  93. spotifywebapipython/models/zeroconfproperty.py +93 -0
  94. spotifywebapipython/oauthcli/__init__.py +7 -0
  95. spotifywebapipython/oauthcli/authclient.py +1229 -0
  96. spotifywebapipython/saappmessages.py +86 -0
  97. spotifywebapipython/sautils.py +285 -0
  98. spotifywebapipython/spotifyapierror.py +66 -0
  99. spotifywebapipython/spotifyapimessage.py +269 -0
  100. spotifywebapipython/spotifyauthtoken.py +264 -0
  101. spotifywebapipython/spotifyclient.py +17885 -0
  102. spotifywebapipython/spotifyconnect/__init__.py +19 -0
  103. spotifywebapipython/spotifyconnect/spotifyconnectdeviceeventargs.py +67 -0
  104. spotifywebapipython/spotifyconnect/spotifyconnectdirectorytask.py +2564 -0
  105. spotifywebapipython/spotifyconnect/spotifyconnectzeroconfcastapptask.py +467 -0
  106. spotifywebapipython/spotifyconnect/spotifyconnectzeroconfcastcontroller.py +701 -0
  107. spotifywebapipython/spotifyconnect/spotifyconnectzeroconfcastlistener.py +359 -0
  108. spotifywebapipython/spotifyconnect/spotifyconnectzeroconfcastmultizonelistener.py +212 -0
  109. spotifywebapipython/spotifyconnect/spotifyconnectzeroconfexceptions.py +20 -0
  110. spotifywebapipython/spotifyconnect/spotifyconnectzeroconflistener.py +335 -0
  111. spotifywebapipython/spotifydiscovery.py +473 -0
  112. spotifywebapipython/spotifymediatypes.py +53 -0
  113. spotifywebapipython/spotifywebapiauthenticationerror.py +122 -0
  114. spotifywebapipython/spotifywebapierror.py +106 -0
  115. spotifywebapipython/spotifywebplayertoken.py +743 -0
  116. spotifywebapipython/vibrant/__init__.py +17 -0
  117. spotifywebapipython/vibrant/generator.py +261 -0
  118. spotifywebapipython/vibrant/image.py +100 -0
  119. spotifywebapipython/vibrant/main.py +34 -0
  120. spotifywebapipython/vibrant/models.py +93 -0
  121. spotifywebapipython/vibrant/utils.py +56 -0
  122. spotifywebapipython/zeroconfapi/__init__.py +23 -0
  123. spotifywebapipython/zeroconfapi/authenticationtypes.py +38 -0
  124. spotifywebapipython/zeroconfapi/blobbuilder.py +370 -0
  125. spotifywebapipython/zeroconfapi/credentials.py +37 -0
  126. spotifywebapipython/zeroconfapi/cryptodiffiehellman.py +89 -0
  127. spotifywebapipython/zeroconfapi/helpers.py +64 -0
  128. spotifywebapipython/zeroconfapi/spotifyzeroconfapierror.py +108 -0
  129. spotifywebapipython/zeroconfapi/zeroconfconnect.py +1327 -0
  130. spotifywebapipython/zeroconfapi/zeroconfgetinfo.py +572 -0
  131. spotifywebapipython/zeroconfapi/zeroconfgetinfoalias.py +109 -0
  132. spotifywebapipython/zeroconfapi/zeroconfgetinfodrmmediaformat.py +90 -0
  133. spotifywebapipython/zeroconfapi/zeroconfresponse.py +195 -0
  134. spotifywebapipython-1.0.242.dist-info/LICENSE +22 -0
  135. spotifywebapipython-1.0.242.dist-info/METADATA +219 -0
  136. spotifywebapipython-1.0.242.dist-info/RECORD +138 -0
  137. spotifywebapipython-1.0.242.dist-info/WHEEL +5 -0
  138. spotifywebapipython-1.0.242.dist-info/top_level.txt +1 -0
@@ -0,0 +1,1229 @@
1
+ # external package imports.
2
+ import base64
3
+ import contextlib
4
+ import hashlib
5
+ import json
6
+ import logging
7
+ import os.path
8
+ import platformdirs
9
+ import secrets
10
+ import socket
11
+ import threading
12
+ import webbrowser
13
+
14
+ from oauthlib.oauth2 import InvalidGrantError, Client, WebApplicationClient, TokenExpiredError, AccessDeniedError
15
+ from requests_oauthlib import OAuth2Session
16
+ from typing import Optional, Union, Callable, Iterable
17
+ from wsgiref.simple_server import WSGIServer, WSGIRequestHandler, make_server as WSGIMakeServer
18
+ from wsgiref.util import request_uri as WSGIRequestUri
19
+
20
+ # get smartinspect logger reference; create a new session for this module name.
21
+ from smartinspectpython.siauto import SIAuto, SILevel, SISession, SIMethodParmListContext, SIColors
22
+ import logging
23
+ _logsi:SISession = SIAuto.Si.GetSession(__name__)
24
+ if (_logsi == None):
25
+ _logsi = SIAuto.Si.AddSession(__name__, True)
26
+ _logsi.SystemLogger = logging.getLogger(__name__)
27
+
28
+
29
+ class AuthClient:
30
+ """
31
+ OAuth2 Authorization client class.
32
+ """
33
+
34
+ _DEFAULT_AUTH_CODE_MESSAGE = "Enter the authorization code: "
35
+ """
36
+ Enter the authorization code:
37
+ """
38
+
39
+ _DEFAULT_AUTH_PROMPT_MESSAGE = "Please visit this URL to authorize this application: {url}"
40
+ """
41
+ Please visit this URL to authorize this application: {url}
42
+ """
43
+
44
+ _DEFAULT_WEB_SUCCESS_MESSAGE = "The authentication flow has completed; you may close this window (or tab)." #</br>Auth Token:</br>%s"
45
+ """
46
+ The authentication flow has completed; you may close this window (or tab).
47
+ """
48
+
49
+ def __init__(self,
50
+ authorizationType:str,
51
+ authorizationUrl:str=None,
52
+ tokenUrl:str=None,
53
+ scope:str = None,
54
+ clientId:str=None,
55
+ clientSecret:str=None,
56
+ oauth2Client:Client=None,
57
+ oauth2Session:OAuth2Session=None,
58
+ tokenProviderId:str=None,
59
+ tokenProfileId:str=None,
60
+ tokenStorageDir:str=None,
61
+ tokenStorageFile:str=None,
62
+ tokenUpdater:Callable=None,
63
+ ) -> None:
64
+ """
65
+ Initializes a new instance of the class.
66
+
67
+ Args:
68
+ authorizationType (str):
69
+ OAuth2 authorization type title (e.g. "Client Credentials", etc).
70
+ authorizationUrl (str):
71
+ URL used to authorize the requested access.
72
+ tokenUrl (str):
73
+ URL used to request an access token.
74
+ scope (str | list[str]):
75
+ A space-delimited list of scopes you wish to request access to.
76
+ If no scopes are specified, authorization will be granted only to access publicly
77
+ available information.
78
+ clientId (str):
79
+ The unique identifier of the application.
80
+ clientSecret (str):
81
+ The application's secret key, used to authorize your Web API or SDK calls.
82
+ oauth2Client (Client):
83
+ OAuth2 Client instance to use for this request.
84
+ If null, a new WebApplicationClient will be created with the specified clientId and scope argument values.
85
+ oauth2Session (requests_oauthlib.oauth2_session.OAuth2Session):
86
+ A `OAuth2Session` instance to use for this request.
87
+ If null, a new 'requests_oauthlib.oauth2_session.OAuth2Session' will be created with the specified
88
+ clientId and scope argument values.
89
+ tokenProviderId (str):
90
+ Provider identifier used when storing the token to disk.
91
+ A null value will default to `Shared`.
92
+ Default: `Shared`
93
+ tokenProfileId (str):
94
+ Profile identifier used when storing the token to disk.
95
+ A null value will default to `Shared`.
96
+ Default: `Shared`
97
+ tokenStorageDir (str):
98
+ The directory path that will contain the Token Cache file.
99
+ A null value will default to the platform specific storage location:
100
+ Example for Windows OS = `C:\\ProgramData\\SpotifyWebApiPython`
101
+ tokenStorageFile (str):
102
+ The filename and extension of the Token Cache file.
103
+ Default is `tokens.json`.
104
+ tokenUpdater (Callable):
105
+ A method to call when a token needs to be refreshed by an external provider.
106
+ The defined method is called with no parameters, and should return a token dictionary.
107
+ Default is null.
108
+ """
109
+ # if scope is a list then convert it to space-delimited string.
110
+ if isinstance(scope, list):
111
+ scope = ' '.join(scope)
112
+
113
+ # verify token storage directory exists.
114
+ if tokenStorageDir is None:
115
+ tokenStorageDir = platformdirs.site_config_dir('SpotifyWebApiPython', ensure_exists=True, appauthor=False)
116
+ os.makedirs(tokenStorageDir, exist_ok=True) # succeeds even if directory exists.
117
+
118
+ # verify token storage filename was specified.
119
+ if tokenStorageFile is None:
120
+ tokenStorageFile = 'tokens.json'
121
+
122
+ # if token providerId not set, then default to shared.
123
+ if tokenProviderId is None:
124
+ tokenProviderId = 'Shared'
125
+
126
+ # if token profileId not set, then default to shared.
127
+ if tokenProfileId is None:
128
+ tokenProfileId = 'Shared'
129
+
130
+ # initialize storage.
131
+ self._AuthorizationType:str = authorizationType
132
+ self._AuthorizationUrl:str = authorizationUrl
133
+ self._ClientId:str = clientId
134
+ self._ClientSecret:str = clientSecret
135
+ self._CodeVerifier:str = None
136
+ self._DefaultLocalHost:str = 'localhost'
137
+ self._Session:OAuth2Session = oauth2Session
138
+ self._TokenProviderId:str = tokenProviderId
139
+ self._TokenStorageDir:str = tokenStorageDir
140
+ self._TokenStorageFile:str = tokenStorageFile
141
+ self._TokenStoragePath:str = os.path.join(tokenStorageDir, tokenStorageFile)
142
+ self._TokenUpdater:Callable = tokenUpdater
143
+ self._TokenUpdater_Lock = threading.Lock() # non re-entrant lock to sync access to token updates.
144
+ self._TokenUrl:str = tokenUrl
145
+ self._TokenProfileId:str = tokenProfileId
146
+
147
+ # create OAuth2 Session instance if necessary.
148
+ if self._Session is None:
149
+
150
+ if oauth2Client is None:
151
+ oauth2Client = WebApplicationClient(client_id=clientId)
152
+
153
+ # create the OAuth2 session with the specified clientId and scope.
154
+ # note that this will not load the 'token' property!
155
+ self._Session:OAuth2Session = OAuth2Session(clientId, scope=scope, client=oauth2Client)
156
+
157
+ # load the token from storage, if session does not currently have a token assigned.
158
+ token:dict = self._Session.token
159
+ if self._Session.token is None or len(self._Session.token) == 0:
160
+ token:dict = self._LoadToken()
161
+
162
+ # was a token loaded?
163
+ if token is not None:
164
+
165
+ # check for scope change between the existing token and the newly requested session.
166
+ _logsi.LogVerbose('Verifying OAuth2 authorization access scope has not changed')
167
+ hasScopeChanged:bool = self.HasScopeChanged(token, scope)
168
+ if hasScopeChanged == True:
169
+ # if scope change detected, then destroy the token as we need to force an auth refresh.
170
+ _logsi.LogVerbose('OAuth2 authorization access scope has changed; token will be destroyed', colorValue=SIColors.Gold)
171
+ self._SaveToken(None)
172
+ else:
173
+ # set session token reference to the loaded token.
174
+ self._Session.token = token
175
+
176
+
177
+ @property
178
+ def AuthorizationType(self) -> str:
179
+ """
180
+ OAuth2 authorization type title (e.g. "Client Credentials", etc).
181
+ """
182
+ return self._AuthorizationType
183
+
184
+
185
+ @property
186
+ def AuthorizationUrl(self) -> str:
187
+ """
188
+ Url used to request user authorization permission for an authorization token.
189
+ """
190
+ return self._AuthorizationUrl
191
+
192
+
193
+ @property
194
+ def ClientId(self) -> str:
195
+ """
196
+ The unique identifier of the application.
197
+ """
198
+ return self._ClientId
199
+
200
+
201
+ @property
202
+ def CodeVerifier(self) -> str:
203
+ """
204
+ The code verifier string used as part of the token request authorization process.
205
+
206
+ According to the PKCE standard, a code verifier is a high-entropy cryptographic
207
+ random string with a length between 43 and 128 characters (the longer the better).
208
+ It can contain letters, digits, underscores, periods, hyphens, or tildes.
209
+ """
210
+ return self._CodeVerifier
211
+
212
+
213
+ @property
214
+ def IsAuthorized(self) -> bool:
215
+ """
216
+ Indicates whether this session has an OAuth token 'access_token' value or not.
217
+
218
+ If True, you can reasonably expect OAuth-protected requests to the resource to succeed.
219
+
220
+ If False, you need the user to go through the OAuth authentication dance before
221
+ OAuth-protected requests to the resource will succeed.
222
+ """
223
+ # mimic behavior of self._Session.authorized, in case we are using a Home Assistant OAuth2Session
224
+ # instance, which does not have an 'authorized' method.
225
+ result:bool = False
226
+ if self._Session is None:
227
+ return result
228
+ if self._Session.token is None:
229
+ return result
230
+ if self._Session.token.get('access_token') is None:
231
+ return result
232
+ return True
233
+
234
+
235
+ @property
236
+ def Session(self) -> OAuth2Session:
237
+ """
238
+ OAuth 2 extension to the `requests.Session` class.
239
+
240
+ Supports any grant type adhering to :class:`oauthlib.oauth2.Client` spec
241
+ including the four core OAuth 2 grants.
242
+
243
+ Can be used to create authorization urls, fetch tokens and access protected
244
+ resources using the `requests.Session` class interface you are used to.
245
+ """
246
+ return self._Session
247
+
248
+
249
+ @property
250
+ def TokenProfileId(self) -> str:
251
+ """
252
+ Profile identifier used when loading / storing the token from / to disk.
253
+ """
254
+ return self._TokenProfileId
255
+
256
+
257
+ @property
258
+ def TokenUrl(self) -> str:
259
+ """
260
+ Url used to request or renew an authorization token.
261
+ """
262
+ return self._TokenUrl
263
+
264
+
265
+ def Logout(self):
266
+ """
267
+ Removes a stored token, but does not clear the current session.
268
+
269
+ Warning: a request with the current session can refresh and save
270
+ the token, making this call ineffective.
271
+ """
272
+ _logsi.LogVerbose('OAuth2 logout called; token will be destroyed', colorValue=SIColors.Gold)
273
+ self._SaveToken(None)
274
+
275
+
276
+ def process_url(self, api:str) -> str:
277
+ return api
278
+
279
+
280
+ def request(self, method:str, api:str, **kwargs) -> object:
281
+ return self._Session.request(method, self.process_url(api), **kwargs)
282
+
283
+
284
+ def get(self, api:str, **kwargs):
285
+ return self._Session.get(self.process_url(api), **kwargs)
286
+
287
+
288
+ def post(self, api:str, **kwargs):
289
+ return self._Session.post(self.process_url(api), **kwargs)
290
+
291
+
292
+ def put(self, api:str, **kwargs):
293
+ return self._Session.put(self.process_url(api), **kwargs)
294
+
295
+
296
+ def patch(self, api:str, **kwargs):
297
+ return self._Session.patch(self.process_url(api), **kwargs)
298
+
299
+
300
+ def delete(self, api:str, **kwargs):
301
+ return self._Session.delete(self.process_url(api), **kwargs)
302
+
303
+
304
+ def head(self, api:str, **kwargs):
305
+ return self._Session.head(self.process_url(api), **kwargs)
306
+
307
+
308
+ def options(self, api:str, **kwargs):
309
+ return self._Session.options(self.process_url(api), **kwargs)
310
+
311
+
312
+ def _CheckAuthorization(self, force:bool, token_test:Optional[Callable]=None) -> bool:
313
+ """
314
+ Checks if the access token is authorized or not.
315
+
316
+ Args:
317
+ force (bool):
318
+ True to force the access token to be refreshed regardless of
319
+ authorized status.
320
+ tokenTest (Callable):
321
+ Function that receives this object for a param, makes a call, and
322
+ returns the response.
323
+ Default is null.
324
+
325
+ Returns:
326
+ True if the access token is authorized; otherwise, False if it is not
327
+ authorized or the force argument is true.
328
+ """
329
+ # force the user to authorize the application access if we do not have an authorized
330
+ # access token, if the scope has changed, or if the caller requested us to (by force).
331
+ if (not self.IsAuthorized) or (force == True):
332
+ return False
333
+
334
+ result:bool = True
335
+ if token_test is not None:
336
+ try:
337
+ resp = token_test(self)
338
+ if resp.status_code % 100 == 4:
339
+ result = False
340
+ except: # noqa: E722
341
+ result = False
342
+
343
+ return result
344
+
345
+
346
+ def _FindOpenPortOnLocalhost(self, portRange:list[int]):
347
+ """
348
+ Find a port to open for the redirect web server.
349
+
350
+ Args:
351
+ portRange (list[int]):
352
+ A list that contains a starting port number (index 0) and the ending
353
+ port number (index 1) to check.
354
+ Default port range is 8080 - 8180.
355
+
356
+ If portRange argument is null, then the default range will be used.
357
+ If only one port number is passed, then only that port will be checked.
358
+ """
359
+ _logsi.LogVerbose('Finding an available local port for the temporary server; port range to check: "%s"' % (str(portRange)))
360
+
361
+ # default starting and ending ports.
362
+ portStart:int = 8080
363
+ portStop = portStart + 100
364
+ localhostIp:str = '127.0.0.1'
365
+
366
+ # validations.
367
+ if portRange is None:
368
+ pass
369
+ elif len(portRange) == 1:
370
+ portStart = int(portRange[0])
371
+ portStop = portStart
372
+ elif len(portRange) == 2:
373
+ portStart = int(portRange[0])
374
+ portStop = int(portRange[1])
375
+
376
+ # find an available port within the specified range of ports.
377
+ for port in range(portStart, portStop):
378
+
379
+ with contextlib.closing(socket.socket(socket.AF_INET, socket.SOCK_STREAM)) as sock:
380
+ try:
381
+ sock.bind((localhostIp, port))
382
+ sock.listen(1)
383
+ except socket.error:
384
+ is_open = False
385
+ else:
386
+ is_open = True
387
+
388
+ # if port is open for listening, then use it.
389
+ if is_open:
390
+ return port
391
+
392
+ # if we could not find a port, then it's a problem!
393
+ raise ConnectionError('Could not find an open port on localhost address "%s", in the range of "%s" to "%s"' % (localhostIp, portStart, portStop))
394
+
395
+
396
+ @staticmethod
397
+ def HasTokenForKey(
398
+ clientId:str=None,
399
+ tokenProviderId:str=None,
400
+ tokenProfileId:str=None,
401
+ tokenStorageDir:str=None,
402
+ tokenStorageFile:str=None,
403
+ ) -> bool:
404
+ """
405
+ Checks if a token exists in the token storage file for the ProviderId / ClientId key.
406
+
407
+ Args:
408
+ clientId (str):
409
+ The unique identifier of the application.
410
+ tokenProviderId (str):
411
+ Provider identifier used when storing the token to disk.
412
+ tokenProfileId (str):
413
+ Profile identifier used when storing the token to disk.
414
+ tokenStorageDir (str):
415
+ The directory path that will contain the `tokens.json` file.
416
+ A null value will default to the platform specific storage location:
417
+ Example for Windows OS = `C:\\ProgramData\\SpotifyWebApiPython`
418
+ tokenStorageFile (str):
419
+ The filename and extension of the Token Cache file.
420
+ Default is `tokens.json`.
421
+
422
+ Returns:
423
+ True if the token was found in the token storage file for the
424
+ specified ProviderId and ClientId key; otherwise, null.
425
+ """
426
+ apiMethodName:str = 'HasTokenForKey'
427
+ apiMethodParms:SIMethodParmListContext = None
428
+
429
+ try:
430
+
431
+ # trace.
432
+ apiMethodParms = _logsi.EnterMethodParmList(SILevel.Debug, apiMethodName)
433
+ _logsi.LogMethodParmList(SILevel.Verbose, "Checking if token exists in token storage file", apiMethodParms)
434
+
435
+ # verify token storage directory exists.
436
+ if tokenStorageDir is None:
437
+ tokenStorageDir = platformdirs.site_config_dir('SpotifyWebApiPython', ensure_exists=True, appauthor=False)
438
+
439
+ # verify token storage filename was specified.
440
+ if tokenStorageFile is None:
441
+ tokenStorageFile = 'tokens.json'
442
+
443
+ # if token clientId not set, then default to shared.
444
+ if clientId is None:
445
+ clientId = 'Shared'
446
+
447
+ # if token providerId not set, then default to shared.
448
+ if tokenProviderId is None:
449
+ tokenProviderId = 'Shared'
450
+
451
+ # if token profileId not set, then default to shared.
452
+ if tokenProfileId is None:
453
+ tokenProfileId = 'Shared'
454
+
455
+ # formulate token storage path.
456
+ tokenStoragePath:str = os.path.join(tokenStorageDir, tokenStorageFile)
457
+
458
+ # formulate token storage key.
459
+ tokenKey:str = f'{tokenProviderId}/{clientId}/{tokenProfileId}'
460
+ _logsi.LogVerbose('Token storage key: "%s"' % (tokenKey))
461
+ _logsi.LogVerbose('Token storage file path: "%s"' % (tokenStoragePath))
462
+
463
+ # does the token storage file exist?
464
+ if os.path.exists(tokenStoragePath):
465
+
466
+ # open the token storage file, and load it's contents.
467
+ _logsi.LogVerbose('Opening token storage file')
468
+ with open(tokenStoragePath, 'r') as f:
469
+
470
+ tokens = json.load(f)
471
+
472
+ # return if token key exists or not.
473
+ if tokenKey in tokens:
474
+ _logsi.LogDictionary(SILevel.Verbose, 'Token was found in token storage file for key: "%s"' % (tokenKey), tokens[tokenKey], prettyPrint=True)
475
+ return True
476
+
477
+ # indicate token was not found.
478
+ _logsi.LogVerbose('Token was not found in token storage file for key: "%s"' % (tokenKey))
479
+ return False
480
+
481
+ except Exception as ex:
482
+
483
+ # trace.
484
+ _logsi.LogException('Could not verify OAuth2 Token from token storage file: "%s"' % (tokenStoragePath), ex)
485
+ raise
486
+
487
+ finally:
488
+
489
+ # trace.
490
+ _logsi.LeaveMethod(SILevel.Debug, apiMethodName)
491
+
492
+
493
+ def _LoadToken(self) -> dict:
494
+ """
495
+ Loads a token from the token storage file for the ProviderId / ClientId key.
496
+
497
+ Returns:
498
+ A token dictionary, if one was found in the token storage file for the
499
+ specified ProviderId and ClientId key; otherwise, null.
500
+ """
501
+ apiMethodName:str = '_LoadToken'
502
+ apiMethodParms:SIMethodParmListContext = None
503
+
504
+ try:
505
+
506
+ # trace.
507
+ apiMethodParms = _logsi.EnterMethodParmList(SILevel.Debug, apiMethodName)
508
+ _logsi.LogMethodParmList(SILevel.Verbose, "Loading Token from token storage", apiMethodParms)
509
+
510
+ # if we don't have a clientId then don't bother.
511
+ if not self._Session.client_id:
512
+ _logsi.LogVerbose('Client ID is not set - nothing to do')
513
+ return
514
+
515
+ # formulate token storage key.
516
+ tokenKey:str = f'{self._TokenProviderId}/{self._Session.client_id}/{self._TokenProfileId}'
517
+ _logsi.LogVerbose('Token storage key: "%s"' % (tokenKey))
518
+ _logsi.LogVerbose('Token storage file path: "%s"' % (self._TokenStoragePath))
519
+
520
+ # does the token storage file exist?
521
+ if os.path.exists(self._TokenStoragePath):
522
+
523
+ # open the token storage file, and load it's contents.
524
+ _logsi.LogVerbose('Opening token storage file')
525
+ with open(self._TokenStoragePath, 'r') as f:
526
+
527
+ tokens = json.load(f)
528
+
529
+ # if token key exists then load the token.
530
+ if tokenKey in tokens:
531
+ _logsi.LogDictionary(SILevel.Verbose, 'Token was loaded from token storage file for key: "%s"' % (tokenKey), tokens[tokenKey], prettyPrint=True)
532
+ return tokens[tokenKey]
533
+ else:
534
+ _logsi.LogVerbose('Token was not found in token storage file for key: "%s"' % (tokenKey))
535
+
536
+ except Exception as ex:
537
+
538
+ # trace.
539
+ _logsi.LogException('Could not load OAuth2 Token from token storage file: "%s"' % (self._TokenStoragePath), ex)
540
+ raise
541
+
542
+ finally:
543
+
544
+ # trace.
545
+ _logsi.LeaveMethod(SILevel.Debug, apiMethodName)
546
+
547
+
548
+ def _SaveToken(self, token:dict=None) -> None:
549
+ """
550
+ Saves a token to the token storage file (e.g. disk) for the ProviderId / ClientId key.
551
+
552
+ Args:
553
+ token (dict):
554
+ The token dictionary object to save.
555
+ Specify null to remove the token for the specified ProviderId / ClientId.
556
+
557
+ Raises:
558
+ IOError:
559
+ If an error occurs saving the tokens file.
560
+ """
561
+ apiMethodName:str = '_SaveToken'
562
+ apiMethodParms:SIMethodParmListContext = None
563
+
564
+ try:
565
+
566
+ # trace.
567
+ apiMethodParms = _logsi.EnterMethodParmList(SILevel.Debug, apiMethodName)
568
+ apiMethodParms.AppendKeyValue("token", token)
569
+ _logsi.LogMethodParmList(SILevel.Verbose, "Saving token to token storage", apiMethodParms)
570
+ _logsi.LogDictionary(SILevel.Verbose, "Token to save (pretty print)", token, prettyPrint=True)
571
+
572
+ # formulate token storage key.
573
+ tokenKey:str = f'{self._TokenProviderId}/{self._Session.client_id}/{self._TokenProfileId}'
574
+ _logsi.LogVerbose('Token storage key: "%s"' % (tokenKey))
575
+ _logsi.LogVerbose('Token storage file path: "%s"' % (self._TokenStoragePath))
576
+
577
+ tokens:dict = {}
578
+
579
+ # does the token storage file exist?
580
+ if os.path.exists(self._TokenStoragePath):
581
+
582
+ # open the token storage file, and load it's contents.
583
+ _logsi.LogVerbose('Loading token storage file contents')
584
+ with open(self._TokenStoragePath, 'r') as f:
585
+ tokens = json.load(f)
586
+
587
+ if token is None:
588
+
589
+ # if token not specified, then remove the existing token for the providerId / clientId.
590
+ _logsi.LogVerbose('Removing token from token storage file')
591
+ if tokenKey in tokens:
592
+ del tokens[tokenKey]
593
+
594
+ else:
595
+
596
+ # store the token for the providerId / clientId.
597
+ _logsi.LogVerbose('Storing token in token storage file')
598
+ tokens[tokenKey] = token
599
+
600
+ # save the token storage file changes.
601
+ _logsi.LogVerbose('Saving token storage file updates')
602
+ with open(self._TokenStoragePath, 'w') as f:
603
+ json.dump(tokens, f, indent=4, sort_keys=True)
604
+
605
+ except Exception as ex:
606
+
607
+ # trace.
608
+ _logsi.LogException('Could not store Token in the token storage file', ex)
609
+ raise
610
+
611
+ finally:
612
+
613
+ # trace.
614
+ _logsi.LeaveMethod(SILevel.Debug, apiMethodName)
615
+
616
+
617
+ def authorization_url(self, **kwargs):
618
+ """
619
+ Constructs the authorization url, complete with all querystring parameters.
620
+
621
+ Args:
622
+ **kwargs
623
+ Keyword arguments for the authorization type.
624
+
625
+ Returns:
626
+ The authorization url querystring and a generated state value.
627
+ """
628
+ _logsi.LogVerbose('Creating OAuth2 authorization url')
629
+
630
+ # according to the PKCE standard, a code verifier is a high-entropy cryptographic
631
+ # random string with a length between 43 and 128 characters (the longer the better).
632
+ # it can contain letters, digits, underscores, periods, hyphens, or tildes.
633
+ codeVerifierLength:int = 128
634
+ codeVerifier:str = secrets.token_urlsafe(codeVerifierLength)[0:codeVerifierLength]
635
+ _logsi.LogText(SILevel.Verbose, 'OAuth2 code verifier (urlsafe, %d bytes): %s' % (codeVerifierLength, codeVerifier), codeVerifier)
636
+ self._CodeVerifier = codeVerifier
637
+
638
+ # once the code verifier has been generated, we must transform (hash) it using the SHA256 algorithm.
639
+ codeHash = hashlib.sha256(codeVerifier.encode('utf-8'))
640
+ _logsi.LogText(SILevel.Verbose, 'OAuth2 code verifier SHA256 hashed bytes: %s' % (codeHash.hexdigest()), codeHash.hexdigest())
641
+
642
+ # we will then convert the hash value to a base64 encoded string.
643
+ # this is the value that will be sent within the user authorization request.
644
+ codeChallengeBytes = base64.urlsafe_b64encode(codeHash.digest())
645
+ codeChallenge = codeChallengeBytes.decode('utf-8').rstrip('=') # drop '=' padding characters
646
+ _logsi.LogText(SILevel.Verbose, 'OAuth2 code challenge url-safe BASE64 encoded string: %s' % (codeChallenge), codeChallenge)
647
+ kwargs.setdefault("code_challenge_method", "S256")
648
+ kwargs.setdefault("code_challenge", codeChallenge)
649
+
650
+ # state is an opaque value that is used to prevent cross-site request forgery.
651
+ stateLength:int = 16
652
+ state:str = secrets.token_urlsafe(stateLength)[0:stateLength]
653
+ _logsi.LogText(SILevel.Verbose, 'OAuth2 state value (urlsafe, %d bytes): %s' % (stateLength, state), state)
654
+
655
+ # create the authorization URL.
656
+ url, state = self._Session.authorization_url(self._AuthorizationUrl, state, **kwargs)
657
+
658
+ # trace.
659
+ _logsi.LogText(SILevel.Verbose, 'OAuth2 authorization url: "%s"' % (url), url)
660
+ return url, state
661
+
662
+
663
+ def FetchToken(self, **kwargs) -> dict:
664
+ """
665
+ Fetch an access token from the token endpoint.
666
+
667
+ Args:
668
+ **kwargs:
669
+ Additional keyword arguments to add to the
670
+
671
+ Returns:
672
+ A token dictionary.
673
+ """
674
+ apiMethodName:str = 'FetchToken'
675
+ apiMethodParms:SIMethodParmListContext = None
676
+
677
+ try:
678
+
679
+ # trace.
680
+ apiMethodParms = _logsi.EnterMethodParmList(SILevel.Debug, apiMethodName)
681
+ apiMethodParms.AppendKeyValue("self._TokenUrl", self._TokenUrl)
682
+ apiMethodParms.AppendKeyValue("self._ClientId", self._ClientId)
683
+ apiMethodParms.AppendKeyValue("self._ClientSecret", self._ClientSecret)
684
+ apiMethodParms.AppendKeyValue("self._CodeVerifier", self._CodeVerifier)
685
+ apiMethodParms.AppendKeyValue("**kwargs", kwargs)
686
+ _logsi.LogMethodParmList(SILevel.Verbose, 'Fetching access token for authorization type "%s"' % self._AuthorizationType, apiMethodParms)
687
+
688
+ # if client secret specified then include it.
689
+ includeClientId:bool = False
690
+ if self._ClientId is not None:
691
+ includeClientId = True
692
+
693
+ # if code verifier specified then include it so that the code challenge is verified.
694
+ if self._CodeVerifier is not None:
695
+ kwargs.setdefault("code_verifier", self._CodeVerifier)
696
+
697
+ # fetch the authorization token.
698
+ # this will also automatically set the 'self._Session.token' instance.
699
+ token = self._Session.fetch_token(self._TokenUrl,
700
+ include_client_id=includeClientId,
701
+ client_secret=self._ClientSecret,
702
+ **kwargs)
703
+
704
+ # trace.
705
+ _logsi.LogDictionary(SILevel.Verbose, 'Access token was successfully fetched', token, prettyPrint=True)
706
+
707
+ # save the new token to disk.
708
+ self._SaveToken(token)
709
+
710
+ # return new token to the caller.
711
+ return token
712
+
713
+ except AccessDeniedError as ex:
714
+
715
+ _logsi.LogException('AccessDenied Error: normally, this indicates the user cancelled the login request', ex)
716
+ raise
717
+
718
+ except Exception as ex:
719
+
720
+ # trace.
721
+ _logsi.LogException('Could not fetch Token', ex)
722
+ raise
723
+
724
+ finally:
725
+
726
+ # trace.
727
+ _logsi.LeaveMethod(SILevel.Debug, apiMethodName)
728
+
729
+
730
+ def HasScopeChanged(self, token:dict, scope:str=None) -> bool:
731
+ """
732
+ Indicates whether the scope has changed between this session and the authorization
733
+ access token scope values.
734
+
735
+ Args:
736
+ token (dict):
737
+ The authorization access token dictionary to check the scope of.
738
+ scope (str | list[str]):
739
+ A space-delimited list of scope identifiers you wish to request access to.
740
+
741
+ If True, you need the user to go through the OAuth authentication dance before
742
+ OAuth-protected requests to the resource will succeed.
743
+
744
+ If False, then you can reasonably expect OAuth-protected requests to the resource to succeed.
745
+ """
746
+ # if token not set then don't bother - indicate scope has changed.
747
+ if token is None:
748
+ return True
749
+
750
+ arrScopeSession:list[str] = []
751
+ arrScopeToken:list[str] = []
752
+
753
+ # get scope values for session and token.
754
+ # we will convert them to array and sort them before comparing for differences.
755
+ if scope is not None:
756
+ arrScopeSession = scope
757
+ if isinstance(arrScopeSession, str):
758
+ arrScopeSession = arrScopeSession.split(' ')
759
+ arrScopeSession.sort()
760
+
761
+ if token is not None and isinstance(token, dict):
762
+ arrScopeToken = token.get('scope', None) or []
763
+ if isinstance(arrScopeToken, str):
764
+ arrScopeToken = arrScopeSession.split(' ')
765
+ arrScopeToken.sort()
766
+
767
+ # get string values for both scope's for comparison.
768
+ strScopeToken = ' '.join(arrScopeToken)
769
+ strScopeSession = ' '.join(arrScopeSession)
770
+
771
+ # does the token scope match the requested session scope?
772
+ if strScopeToken != strScopeSession:
773
+
774
+ _logsi.LogVerbose('Token scope change detected, forcing authorization access; Token scope="%s", Session scope="%s"' % (strScopeToken, strScopeSession))
775
+ return True
776
+
777
+ # indicate scope has not changed.
778
+ return False
779
+
780
+
781
+ def AuthorizeWithConsole(
782
+ self,
783
+ authorization_prompt_message=_DEFAULT_AUTH_PROMPT_MESSAGE,
784
+ open_browser=True,
785
+ code_message=_DEFAULT_AUTH_CODE_MESSAGE,
786
+ token_audience=None,
787
+ force: bool = False,
788
+ token_test: Optional[Callable] = None,
789
+ **kwargs
790
+ ):
791
+ """
792
+ Executes the authorization flow without starting a web server.
793
+
794
+ Note that you must have 'urn:ietf:wg:oauth:2.0:oob' as a redirect URI value
795
+ in the provider app settings for this to work.
796
+
797
+ Args:
798
+ authorization_prompt_message (str | None):
799
+ The message to display that will inform the user to navigate to the
800
+ authorization URL. If null, then no message is displayed.
801
+ open_browser (bool):
802
+ True to open the authorization URL in the user's browser; otherwise, False
803
+ to not open a browser.
804
+ code_message (str):
805
+ The message to display in the console prompting the user to enter the authorization code.
806
+ token_audience (str):
807
+ Passed along with the request for an access token.
808
+ It determines the endpoints with which the token can be used.
809
+ Default is null.
810
+ force (bool):
811
+ True to authorize, even if we already have a token; otherwise, False
812
+ to only authorize if the token is not authorized, has expired, or the
813
+ scope has changed.
814
+ token_test (Callable):
815
+ Function that receives this object for a param, makes a call, and returns
816
+ the response.
817
+ kwargs:
818
+ Additional keyword arguments passed through to `authorization_url`.
819
+
820
+ Returns:
821
+ The OAuth 2.0 credentials for the user.
822
+ """
823
+ apiMethodName:str = 'AuthorizeWithServer'
824
+ apiMethodParms:SIMethodParmListContext = None
825
+
826
+ wsgiServer:WSGIServer = None
827
+
828
+ try:
829
+
830
+ # trace.
831
+ apiMethodParms = _logsi.EnterMethodParmList(SILevel.Debug, apiMethodName)
832
+ apiMethodParms.AppendKeyValue("authorization_prompt_message", authorization_prompt_message)
833
+ apiMethodParms.AppendKeyValue("open_browser", open_browser)
834
+ apiMethodParms.AppendKeyValue("code_message", code_message)
835
+ apiMethodParms.AppendKeyValue("token_audience", token_audience)
836
+ apiMethodParms.AppendKeyValue("force", force)
837
+ apiMethodParms.AppendKeyValue("token_test", token_test)
838
+ apiMethodParms.AppendKeyValue("**kwargs", kwargs)
839
+ _logsi.LogMethodParmList(SILevel.Verbose, 'Executing authorization flow using local console for authorization type "%s"' % self._AuthorizationType, apiMethodParms)
840
+
841
+ # is the access token authorized? if so, then we are done.
842
+ if self._CheckAuthorization(force, token_test):
843
+ _logsi.LogVerbose('Token is authorized; nothing else to do')
844
+ return self
845
+
846
+ self._Session.redirect_uri = 'urn:ietf:wg:oauth:2.0:oob'
847
+ auth_url, _ = self.authorization_url(**kwargs)
848
+
849
+ if open_browser:
850
+ _logsi.LogVerbose('Opening a browser window (or tab) to the authorization request url')
851
+ webbrowser.open(auth_url, new=2, autoraise=True)
852
+
853
+ if authorization_prompt_message:
854
+ message:str = authorization_prompt_message.format(url=auth_url)
855
+ _logsi.LogWarning(message, logToSystemLogger=True)
856
+
857
+ # prompt the user (in the console) to enter the authorization code response.
858
+ _logsi.LogVerbose('Waiting for a console response from the user for the authorization request response code')
859
+ while True:
860
+ auth_code = input(code_message).strip()
861
+ if auth_code:
862
+ break
863
+
864
+ # fetch the newly issued authorization token.
865
+ self.FetchToken(code=auth_code, audience=token_audience)
866
+
867
+ # return to caller.
868
+ return self
869
+
870
+ except Exception as ex:
871
+
872
+ # trace.
873
+ _logsi.LogException('Authorize with console exception', ex)
874
+ raise
875
+
876
+ finally:
877
+
878
+ # trace.
879
+ _logsi.LeaveMethod(SILevel.Debug, apiMethodName)
880
+
881
+
882
+ def AuthorizeWithServer(
883
+ self,
884
+ host: Optional[str] = None,
885
+ bind_addr: Optional[int] = None,
886
+ port: Union[int, list[int]] = 8080,
887
+ authorization_prompt_message: Optional[str] = _DEFAULT_AUTH_PROMPT_MESSAGE,
888
+ success_message: str = _DEFAULT_WEB_SUCCESS_MESSAGE,
889
+ open_browser: bool = True,
890
+ redirect_uri_path: str = '/',
891
+ timeout_seconds: Optional[int] = 120,
892
+ token_audience: Optional[str] = None,
893
+ force: bool = False,
894
+ token_test: Optional[Callable] = None,
895
+ **kwargs
896
+ ):
897
+ """
898
+ Executes the authorization flow by starting a temporary local web server
899
+ to listen for the authorization response.
900
+
901
+ Note that you must have 'http://localhost:8080/' as a redirect URI value
902
+ in the provider app settings for this to work.
903
+
904
+ The server strategy instructs the user to open the authorization URL in
905
+ their browser and will attempt to automatically open the URL for them.
906
+ It will start a local web server to listen for the authorization
907
+ response. Once authorization is complete the authorization server will
908
+ redirect the user's browser to the local web server. The web server
909
+ will get the authorization code from the response and shutdown. The
910
+ code is then exchanged for a token.
911
+
912
+ Args:
913
+ host (str):
914
+ The hostname for the local redirect server. This will be served
915
+ over http, not https.
916
+ bind_addr (str):
917
+ Optionally provide an ip address for the redirect server to listen
918
+ on when it is not the same as host (e.g. in a container).
919
+ Default value is None, which means that the redirect server will listen
920
+ on the ip address specified in the host parameter.
921
+ port (int / list[int]):
922
+ The port for the local redirect server.
923
+ If a list, it would find the first open port in the range.
924
+ authorization_prompt_message (str | None):
925
+ The message to display that will inform the user to navigate to the
926
+ authorization URL. If null, then no message is displayed.
927
+ success_message (str):
928
+ The message to display in the web browser that the authorization flow
929
+ is complete.
930
+ open_browser (bool):
931
+ True to open the authorization URL in the user's browser; otherwise, False
932
+ to not open a browser.
933
+ redirect_uri_path (str):
934
+ Path value to add when constructing the redirect_uri; otherwise,
935
+ None to not add a path value.
936
+ Default value is '/'.
937
+ timeout_seconds (int):
938
+ If set, an error will be raised after the timeout value if the user did not
939
+ respond to the authorization request. The value is in seconds.
940
+ When set to None there is no timeout.
941
+ Default value is 120 (2 minutes).
942
+ token_audience (str):
943
+ Passed along with the request for an access token.
944
+ It determines the endpoints with which the token can be used.
945
+ Default is null.
946
+ force (bool):
947
+ True to authorize, even if we already have a token; otherwise, False
948
+ to only authorize if the token is not authorized, has expired, or the
949
+ scope has changed.
950
+ token_test (Callable):
951
+ Function that receives this object for a param, makes a call, and returns
952
+ the response.
953
+ kwargs:
954
+ Additional keyword arguments passed through to `authorization_url`.
955
+
956
+ Returns:
957
+ The OAuth 2.0 credentials for the user.
958
+ """
959
+ apiMethodName:str = 'AuthorizeWithServer'
960
+ apiMethodParms:SIMethodParmListContext = None
961
+
962
+ wsgiServer:WSGIServer = None
963
+
964
+ try:
965
+
966
+ # trace.
967
+ apiMethodParms = _logsi.EnterMethodParmList(SILevel.Debug, apiMethodName)
968
+ apiMethodParms.AppendKeyValue("host", host)
969
+ apiMethodParms.AppendKeyValue("bind_addr", bind_addr)
970
+ apiMethodParms.AppendKeyValue("port", port)
971
+ apiMethodParms.AppendKeyValue("authorization_prompt_message", authorization_prompt_message)
972
+ apiMethodParms.AppendKeyValue("success_message", success_message)
973
+ apiMethodParms.AppendKeyValue("open_browser", open_browser)
974
+ apiMethodParms.AppendKeyValue("redirect_uri_path", redirect_uri_path)
975
+ apiMethodParms.AppendKeyValue("timeout_seconds", timeout_seconds)
976
+ apiMethodParms.AppendKeyValue("token_audience", token_audience)
977
+ apiMethodParms.AppendKeyValue("force", force)
978
+ apiMethodParms.AppendKeyValue("token_test", token_test)
979
+ apiMethodParms.AppendKeyValue("**kwargs", kwargs)
980
+ _logsi.LogMethodParmList(SILevel.Verbose, 'Executing authorization flow using local web server for authorization type "%s"' % self._AuthorizationType, apiMethodParms)
981
+
982
+ # is the access token authorized? if so, then we are done.
983
+ if self._CheckAuthorization(force, token_test):
984
+ _logsi.LogVerbose('Token is authorized; nothing else to do')
985
+ return self
986
+
987
+ # if a port range was passed, then find an available port in that range.
988
+ if isinstance(port, list):
989
+ port = self._FindOpenPortOnLocalhost(port)
990
+
991
+ # default the local redirect server host if one was not specified.
992
+ if not host:
993
+ host = self._DefaultLocalHost
994
+
995
+ # initialize and create a local Web Server Gateway Interface (WSGI) server.
996
+ _logsi.LogVerbose('Creating temporary WSGI local web server to handle the OAuth2 authorization request response')
997
+ wsgiApp:_WSGIAppRedirectHandler = _WSGIAppRedirectHandler(success_message)
998
+ WSGIServer.allow_reuse_address = False # Fail fast if the address is occupied
999
+ wsgiServer:WSGIServer = WSGIMakeServer(bind_addr or host,
1000
+ port,
1001
+ wsgiApp,
1002
+ handler_class=_WSGIRequestHandler
1003
+ )
1004
+
1005
+ # construct the redirect uri, which is where the user is redirected after
1006
+ # authentication success or failure.
1007
+ redirectUri:str = 'http://{}:{}'.format(host, wsgiServer.server_port)
1008
+ if redirect_uri_path is not None:
1009
+ redirectUri = redirectUri + redirect_uri_path
1010
+ self._Session.redirect_uri = redirectUri
1011
+
1012
+ # construct the authorization url and state values.
1013
+ auth_url, _ = self.authorization_url(**kwargs)
1014
+
1015
+ # open a new tab page using the local default browser.
1016
+ if open_browser:
1017
+ _logsi.LogVerbose('Opening a browser window (or tab) to the authorization request url')
1018
+ webbrowser.open(auth_url, new=2, autoraise=True)
1019
+
1020
+ if authorization_prompt_message:
1021
+ message:str = authorization_prompt_message.format(url=auth_url)
1022
+ _logsi.LogWarning(message, logToSystemLogger=True)
1023
+
1024
+ # set the timeout value, and handle the user authorization request response.
1025
+ # the 'handle_request' call will block until a response is received, or a timeout occurs.
1026
+ _logsi.LogVerbose('WSGI server will now wait for a response from the user for the authorization request url (timeout=%s seconds)' % timeout_seconds)
1027
+ wsgiServer.timeout = timeout_seconds
1028
+ wsgiServer.handle_request()
1029
+
1030
+ # get the user authorization request response value.
1031
+ authResponse:str = wsgiApp.LastRequestUri
1032
+ if authResponse is None:
1033
+ raise AccessDeniedError('An authorization response was not returned, which usually indicates the server timed out waiting for a response from the user to authorize the request')
1034
+
1035
+ # force https in case it needs it, as OAuth 2.0 can only occur over https.
1036
+ authResponse = authResponse.replace("http", "https")
1037
+ _logsi.LogText(SILevel.Verbose, 'WSGI server intercepted the user authorization response: "%s"' % (authResponse), authResponse)
1038
+
1039
+ # fetch the newly issued authorization token.
1040
+ self.FetchToken(authorization_response=authResponse,
1041
+ audience=token_audience
1042
+ )
1043
+
1044
+ # return to caller.
1045
+ return self
1046
+
1047
+ except Exception as ex:
1048
+
1049
+ # trace.
1050
+ _logsi.LogException('Authorize with server exception', ex)
1051
+ raise
1052
+
1053
+ finally:
1054
+
1055
+ # ensure wsgi server is shutdown.
1056
+ if wsgiServer is not None:
1057
+ _logsi.LogVerbose('WSGI server is shutting down and freeing resources.')
1058
+ wsgiServer.server_close()
1059
+ wsgiServer = None
1060
+
1061
+ # trace.
1062
+ _logsi.LeaveMethod(SILevel.Debug, apiMethodName)
1063
+
1064
+
1065
+ def RefreshToken(self, **kwargs) -> dict:
1066
+ """
1067
+ Refreshes the current session token.
1068
+
1069
+ If a TokenUpdater callable was specified, then the token is refreshed externally by the
1070
+ callable function.
1071
+
1072
+ Otherwise, the session `refresh_token` method is invoked using the refresh token value.
1073
+
1074
+ Args:
1075
+ **kwargs:
1076
+ Additional keyword arguments to include in the token request.
1077
+
1078
+ Returns:
1079
+ A token dictionary.
1080
+ """
1081
+ token:dict = None
1082
+
1083
+ try:
1084
+
1085
+ # trace.
1086
+ _logsi.LogVerbose("Refreshing OAuth2 authorization token for the \"%s\" authorization type" % self._AuthorizationType, colorValue=SIColors.Gold)
1087
+
1088
+ # was a token updater supplied?
1089
+ if self._TokenUpdater is not None:
1090
+
1091
+ # log trace message if we are waiting on another token update to complete!
1092
+ _logsi.LogVerbose("Preparing to set the lock for external token refresh", colorValue=SIColors.Gold)
1093
+ if self._TokenUpdater_Lock.locked():
1094
+ _logsi.LogVerbose("Waiting on a previous external token refresh to complete", colorValue=SIColors.Red)
1095
+
1096
+ # only allow one thread to update authorization token at a time.
1097
+ with self._TokenUpdater_Lock:
1098
+
1099
+ _logsi.LogVerbose('Calling External Token Updater to refresh the token', colorValue=SIColors.Gold)
1100
+ token = self._TokenUpdater()
1101
+
1102
+ _logsi.LogDictionary(SILevel.Verbose, 'External Token Updater has refreshed the token', token, colorValue=SIColors.Gold)
1103
+
1104
+ # if nothing returned then it's an error.
1105
+ if token is None:
1106
+ raise TokenExpiredError('External Token Updater did not return a token.')
1107
+
1108
+ else:
1109
+
1110
+ # get refresh token from session token.
1111
+ refreshToken:str = None
1112
+ if self._Session is not None and self._Session.token is not None:
1113
+ _logsi.LogDictionary(SILevel.Verbose, 'OAuth2 Session token', self._Session.token)
1114
+ refreshToken = self._Session.token.get('refresh_token')
1115
+
1116
+ # if refresh token is not present, then it's an error.
1117
+ if refreshToken is None:
1118
+ raise TokenExpiredError('Token cannot be refreshed as there is no "refresh_token" key in the session token.')
1119
+
1120
+ # add the clientId if the session has been established.
1121
+ if self._Session.client_id is not None:
1122
+ kwargs.setdefault("client_id", self._Session.client_id)
1123
+
1124
+ _logsi.LogDictionary(SILevel.Verbose, 'Refreshing the OAuth2 session token via url "%s" - kwargs' % self._TokenUrl, kwargs)
1125
+
1126
+ # refresh the authorization token, using it's refresh token value.
1127
+ token = self._Session.refresh_token(self._TokenUrl, refreshToken, **kwargs)
1128
+
1129
+ # store the refresh token to the token storage file.
1130
+ self._SaveToken(token)
1131
+
1132
+ # trace.
1133
+ _logsi.LogVerbose('OAuth2 authorization token was successfully refreshed for the "%s" authorization type' % self._AuthorizationType)
1134
+
1135
+ # return the refreshed authorization token to the caller.
1136
+ return token
1137
+
1138
+ except Exception as ex:
1139
+
1140
+ # trace.
1141
+ #_logsi.LogException('OAuth2 token refresh error: %s' % str(ex), ex)
1142
+
1143
+ # if refresh token was revoked, then remove the token from the token storage file
1144
+ # so that it will force an authorization refresh next time around.
1145
+ if isinstance(ex, InvalidGrantError):
1146
+ if ex.description == 'Refresh token revoked':
1147
+ if self.AuthorizationType in ['OAuth2Token']:
1148
+ _logsi.LogWarning('Refresh token was revoked for authorization type "%s". Ensure that you are saving the refreshed token in the host provider session to avoid this problem; see the "tokenUpdater" argument for more details' % self.AuthorizationType)
1149
+
1150
+ # if internal storage provider, then remove the token from the storage file.
1151
+ if self._TokenUpdater is None:
1152
+ _logsi.LogVerbose('Refresh token error detected for internal storage provider; token will be destroyed', colorValue=SIColors.Gold)
1153
+ self._SaveToken(None)
1154
+
1155
+ # pass exception on thru.
1156
+ raise
1157
+
1158
+
1159
+ class _WSGIRequestHandler(WSGIRequestHandler):
1160
+ """
1161
+ WSGI Request Handler.
1162
+
1163
+ Uses a named logger instead of printing to stderr.
1164
+ """
1165
+
1166
+ def log_message(self, format, *args) -> None:
1167
+ # pylint: disable=redefined-builtin
1168
+ # (format is the argument name defined in the superclass.)
1169
+
1170
+ # trace.
1171
+ _logsi.LogVerbose(format, *args, logToSystemLogger=False)
1172
+
1173
+
1174
+ class _WSGIAppRedirectHandler(object):
1175
+ """
1176
+ WSGI App that will handle the authorization redirect.
1177
+
1178
+ Stores the request URI and displays the given success message.
1179
+ """
1180
+
1181
+ def __init__(self, successMessage) -> None:
1182
+ """
1183
+ Args:
1184
+ successMessage (str):
1185
+ The message to display in the web browser that the authorization flow is complete.
1186
+ """
1187
+ self._LastRequestUri:str = None
1188
+ self._SuccessMessage = successMessage
1189
+
1190
+
1191
+ def __call__(self, environ, start_response) -> Iterable[bytes]:
1192
+ """
1193
+ WSGI Callable.
1194
+
1195
+ Args:
1196
+ environ (Mapping[str, Any]):
1197
+ The WSGI environment.
1198
+ start_response (Callable[str, list]):
1199
+ The WSGI start_response callable.
1200
+
1201
+ Returns:
1202
+ The response body.
1203
+ """
1204
+ # respond to the authorization server with response code 200 (ok) message,
1205
+ # which indicates that we received their response.
1206
+ start_response("200 OK", [("Content-type", "text/plain; charset=utf-8")])
1207
+
1208
+ # store the full request URI (including the query string) so that it can
1209
+ # be accessed by the caller.
1210
+ self._LastRequestUri = WSGIRequestUri(environ)
1211
+
1212
+ # return authorization flow was completed successfully message.
1213
+ return [self._SuccessMessage.encode("utf-8")]
1214
+
1215
+
1216
+ @property
1217
+ def LastRequestUri(self) -> str:
1218
+ """
1219
+ The full request URI, optionally including the query string.
1220
+ """
1221
+ return self._LastRequestUri
1222
+
1223
+
1224
+ @property
1225
+ def SuccessMessage(self) -> str:
1226
+ """
1227
+ The message to display in the web browser that the authorization flow is complete.
1228
+ """
1229
+ return self._SuccessMessage