def search(text, category='', page=0, token=None): if text != '': text = '&q=' + text searchUrl = "{}/search/?version={}".format(pluginSetting("connectEndpoint"), pluginSetting("apiVersion")) else: searchUrl = "{}/search/matchAll?version={}".format(pluginSetting("connectEndpoint"), pluginSetting("apiVersion")) headers = {} headers["Authorization"] = "Bearer {}".format(token) nam = NetworkAccessManager() if category == '': res, content = nam.request("{}{}&si={}&c={}".format(searchUrl, text, int(page), RESULTS_PER_PAGE), headers=headers) else: res, content = nam.request("{}{}&cat={}&si={}&c={}".format(searchUrl, text, category, int(page), RESULTS_PER_PAGE), headers=headers) j = json.loads(re.sub(r'[^\x00-\x7f]',r'', content)) results = [] for element in j["features"]: props = element["properties"] roles = props["role"].split(",") category = props["category"] if category != "PLUG": title = props["title"] or props["description"].split(".")[0] if category in categories: results.append(categories[category][0](props["url"].replace("\n", ""), title, props["description"], roles)) else: plugin = _plugins.get(props["title"], None) if plugin: results.append(ConnectPlugin(plugin, roles)) return results
def test_AsyncNAM_abort(self): """Test ANAM if it can manages abort during connection""" from threading import Timer # connection redirection self.checkEx = None def finishedListener(): try: httpResult = nam.httpResult() self.assertIn('Operation canceled', str(httpResult.exception)) self.assertIsInstance(httpResult.exception, RequestsExceptionUserAbort) except Exception as ex: self.checkEx = ex loop = QtCore.QEventLoop() nam = NetworkAccessManager(debug=True) nam.request(self.serverUrl + '/delay/5', blocking=False) nam.reply.finished.connect(finishedListener) nam.reply.finished.connect(loop.exit, QtCore.Qt.QueuedConnection) # abort after 1sec t = Timer(1, nam.abort) t.start() loop.exec_(flags=QtCore.QEventLoop.ExcludeUserInputEvents) if self.checkEx: raise self.checkEx
def appSDKification(folder, progress): ''' zip app folder and send to WAB compiler to apply SDK compilation. The returned zip will be the official webapp ''' progress.oscillate() progress.setText("Get Authorization token") try: global __appSDKification_doAgain if __appSDKification_doAgain: QgsMessageLog.logMessage("Renew token in case of it is expired and retry", level=QgsMessageLog.WARNING) utils.resetCachedToken() token = utils.getToken() except Exception as e: pub.sendMessage(utils.topics.endAppSDKification, success=False, reason=str(e)) return # zip folder to send for compiling progress.setText("Preparing data to compile") zipFileName = tempFilenameInTempFolder("webapp.zip", "webappbuilder") try: with zipfile.ZipFile(zipFileName, "w") as zf: relativeFrom = os.path.dirname(folder) for dirname, subdirs, files in os.walk(folder): # exclude data folder if 'data' in subdirs: subdirs.remove('data') if relativeFrom in dirname: zf.write(dirname, dirname[len(relativeFrom):]) for filename in files: fiename = os.path.join(dirname, filename) zf.write(fiename, fiename[len(relativeFrom):]) except: msg = "Could not zip webapp folder: {}".format(folder) pub.sendMessage(utils.topics.endAppSDKification, success=False, reason=msg) return # prepare data for WAB compiling request with open(zipFileName, 'rb') as f: fileContent = f.read() fields = { 'file': (os.path.basename(zipFileName), fileContent) } payload, content_type = encode_multipart_formdata(fields) headers = {} headers["authorization"] = "Bearer {}".format(token) headers["Content-Type"] = content_type # prepare request (as in NetworkAccessManager) but without blocking request # do http post progress.setText("Wait compilation") global __anam if __anam: del __anam __anam = None __anam = NetworkAccessManager(debug=pluginSetting("logresponse")) __anam.request(utils.wabCompilerUrl(), method='POST', body=payload, headers=headers, blocking=False) __anam.reply.finished.connect( lambda: manageFinished(__anam, zipFileName, folder, progress) )
def test_syncNAM_success(self): """Test NAM in sync mode.""" # test success nam = NetworkAccessManager(debug=True) (response, content) = nam.request(self.serverUrl + '/get') self.assertTrue(response.ok) self.assertEqual(response.status_code, 200)
def getToken(login, password): global token if token: return token token = None payload = {"username": login, "password": password} headers = {} headers["Content-Type"] = "application/json" url = "{}/token?version={}".format(pluginSetting("connectEndpoint"), pluginSetting("apiVersion")) nam = NetworkAccessManager() try: res, data = nam.request(url, method="POST", body=json.dumps(payload), headers=headers) except Exception as e: return token try: responce = json.loads(str(data)) token = responce["token"] except: pass return token
def searchBasemaps(text, token): searchUrl = "{}/basemaps?version={}".format(pluginSetting("connectEndpoint"), pluginSetting("apiVersion")) headers = {} headers["Authorization"] = "Bearer {}".format(token) nam = NetworkAccessManager() res, content = nam.request(searchUrl, headers=headers) try: j = json.loads(content) except: raise Exception("Unable to parse server reply.") maps = [l for l in j if basemaputils.isSupported(l)] results = [] if text == '': for item in maps: results.append( ConnectBasemap(item["endpoint"], item["name"], item["description"], item, item["accessList"])) else: for item in maps: if text.lower() in item["name"].lower() or text.lower() in item["description"].lower(): results.append( ConnectBasemap(item["endpoint"], item["name"], item["description"], item, item["accessList"])) return results
def test_AsyncNAM_tiff_success(self): """Test NAM in async mode with binary files to check for bytearray conversion.""" # test success self.checkEx = None def finishedListener(): try: httpResult = nam.httpResult() self.assertTrue(httpResult.ok) # No status code in offline #self.assertEqual(httpResult.status_code, 200) self.assertEqual(httpResult.content, open(tiff_file, 'rb').read(), "Image differs") except Exception as ex: self.checkEx = ex loop = QtCore.QEventLoop() tiff_file = os.path.join(os.path.dirname(os.path.realpath(__file__)), 'data', '1.1.01.tiff') nam = NetworkAccessManager(debug=True) (response, content) = nam.request("file://%s" % tiff_file, blocking=False) nam.reply.finished.connect(finishedListener) nam.reply.finished.connect(loop.exit, QtCore.Qt.QueuedConnection) loop.exec_(flags=QtCore.QEventLoop.ExcludeUserInputEvents) if self.checkEx: raise self.checkEx
def test_syncNAM_local_timeout(self): # test url timeout by client timout self.timeoutOriginal = self.settings.value(self.timeoutEntry) self.settings.setValue(self.timeoutEntry, 1000) nam = NetworkAccessManager(debug=True) with self.assertRaises(RequestsExceptionTimeout): (response, content) = nam.request(self.serverUrl + '/delay/60') self.settings.setValue(self.timeoutEntry, self.timeoutOriginal)
def test_syncNAM_forbidden(self): # connection refused http 403 try: nam = NetworkAccessManager(debug=True) (response, content) = nam.request(self.serverUrl + '/status/403') except RequestsException as ex: self.assertTrue('server replied: FORBIDDEN' in str(ex)) except Exception as ex: raise ex
def test_syncNAM_unathorised(self): # connection refused http 401 try: nam = NetworkAccessManager(debug=True) (response, content) = nam.request(self.serverUrl + '/status/401') except RequestsException as ex: self.assertTrue('Host requires authentication' in str(ex)) except Exception as ex: raise ex
def test_syncNAM_url_not_found(self): # test Url not found try: nam = NetworkAccessManager(debug=True) (response, content) = nam.request(self.serverUrl + '/somethingwrong') except RequestsException as ex: self.assertTrue('server replied: NOT FOUND' in str(ex)) except Exception as ex: raise ex
def test_syncNAM_customException(self): """Test NAM raise custom exception.""" # test success class customEx(Exception): pass with self.assertRaises(customEx): nam = NetworkAccessManager(debug=True, exception_class=customEx) (response, content) = nam.request(self.serverUrl + '/somethingwrong')
def test_AsyncNAM_success(self): """Test ANAM if it can manages success.""" # test success self.checkEx = None def finishedListener(): try: httpResult = nam.httpResult() self.assertTrue(httpResult.ok) self.assertEqual(httpResult.status_code, 200) except Exception as ex: self.checkEx = ex loop = QtCore.QEventLoop() nam = NetworkAccessManager(debug=True) nam.request(self.serverUrl + '/get', blocking=False) nam.reply.finished.connect(finishedListener) nam.reply.finished.connect(loop.exit, QtCore.Qt.QueuedConnection) loop.exec_(flags=QtCore.QEventLoop.ExcludeUserInputEvents) if self.checkEx: raise self.checkEx
def test_AsyncNAM_forbidden(self): """Test ANAM if it can manages 403 (forbidden)""" # connection refused http 403 self.checkEx = None def finishedListener(): try: httpResult = nam.httpResult() self.assertIn('server replied: FORBIDDEN', str(httpResult.exception)) self.assertIsInstance(httpResult.exception, RequestsException) except Exception as ex: self.checkEx = ex loop = QtCore.QEventLoop() nam = NetworkAccessManager(debug=True) nam.request(self.serverUrl + '/status/403', blocking=False) nam.reply.finished.connect(finishedListener) nam.reply.finished.connect(loop.exit, QtCore.Qt.QueuedConnection) loop.exec_(flags=QtCore.QEventLoop.ExcludeUserInputEvents) if self.checkEx: raise self.checkEx
def test_AsyncNAM_url_not_found(self): """Test ANAM if it can manages 404""" # test Url not found self.checkEx = None def finishedListener(): try: httpResult = nam.httpResult() self.assertEqual(httpResult.status_code, 404) self.assertIn('server replied: NOT FOUND', str(httpResult.exception)) except Exception as ex: self.checkEx = ex loop = QtCore.QEventLoop() nam = NetworkAccessManager(debug=True) nam.request(self.serverUrl + '/somethingwrong', blocking=False) nam.reply.finished.connect(finishedListener) nam.reply.finished.connect(loop.exit, QtCore.Qt.QueuedConnection) loop.exec_(flags=QtCore.QEventLoop.ExcludeUserInputEvents) if self.checkEx: raise self.checkEx
def checkSDKServerVersion(): localVersion = utils.sdkVersion() token = utils.getToken() headers = {} headers["authorization"] = "Bearer {}".format(token) nam = NetworkAccessManager(debug=pluginSetting("logresponse")) try: resp, text = nam.request(wabVersionUrl(), headers=headers) except Exception as e: # check if 401/403 => probably token expired permissionDenied = utils.isPermissionDenied(str(e)) if not permissionDenied: raise e else: # renew token and try again utils.resetCachedToken() token = utils.getToken() # retry call headers["authorization"] = "Bearer {}".format(token) try: resp, text = nam.request(wabVersionUrl(), headers=headers) except Exception as e: # check if 401/403 => probably token expired permissionDenied = utils.isPermissionDenied(str(e)) if not permissionDenied: raise e else: raise Exception( "Permission denied with current Connect credentials") remoteVersion = json.loads(text)["boundless-sdk"] if localVersion != remoteVersion: raise VersionMismatchError( "The server SDK version (%s) is different from the expected version (%s)" % (remoteVersion, localVersion))
def test_AsyncNAM_local_timeout(self): """Test ANAM if it can manages operation canceled by client.""" """!!!Note!!! that finishedListener is emitted before than timeoutListener. When timeoutListener is called the httpResult has been changed to notify timout.""" # test url timeout by client timout self.checkEx = None def finishedListener(): try: httpResult = nam.httpResult() self.assertIn('Operation canceled', str(httpResult.exception)) self.assertIsInstance(httpResult.exception, RequestsException) except Exception as ex: self.checkEx = ex def timeoutListener(): try: httpResult = nam.httpResult() self.assertIn('Timeout error', str(httpResult.exception)) self.assertIsInstance(httpResult.exception, RequestsExceptionTimeout) except Exception as ex: self.checkEx = ex self.timeoutOriginal = self.settings.value(self.timeoutEntry) self.settings.setValue(self.timeoutEntry, 1000) loop = QtCore.QEventLoop() nam = NetworkAccessManager(debug=True) nam.request(self.serverUrl + '/delay/60', blocking=False) QgsNetworkAccessManager.instance().requestTimedOut.connect( timeoutListener) nam.reply.finished.connect(finishedListener) nam.reply.finished.connect(loop.exit, QtCore.Qt.QueuedConnection) loop.exec_(flags=QtCore.QEventLoop.ExcludeUserInputEvents) self.settings.setValue(self.timeoutEntry, self.timeoutOriginal) if self.checkEx: raise self.checkEx
def test_AsyncNAM_redirect(self): """Test ANAM if it can manages url redirect""" # connection redirection self.checkEx = None def finishedListener(): try: httpResult = nam.httpResult() self.assertIn('Host requires authentication', str(httpResult.exception)) self.assertIsInstance(httpResult.exception, RequestsException) except Exception as ex: self.checkEx = ex loop = QtCore.QEventLoop() nam = NetworkAccessManager(debug=True) nam.request(self.serverUrl + '/redirect-to?url=./status/401', blocking=False) nam.reply.finished.connect(finishedListener) nam.reply.finished.connect(loop.exit, QtCore.Qt.QueuedConnection) loop.exec_(flags=QtCore.QEventLoop.ExcludeUserInputEvents) if self.checkEx: raise self.checkEx
def test_AsyncNAM_customException(self): """Test ANAM raise curstom exception.""" # test success class customEx(Exception): pass self.checkEx = None def finishedListener(): try: httpResult = nam.httpResult() self.assertIsInstance(httpResult.exception, customEx) except Exception as ex: self.checkEx = ex loop = QtCore.QEventLoop() nam = NetworkAccessManager(debug=True, exception_class=customEx) nam.request(self.serverUrl + '/somethingwrong', blocking=False) nam.reply.finished.connect(finishedListener) nam.reply.finished.connect(loop.exit, QtCore.Qt.QueuedConnection) loop.exec_(flags=QtCore.QEventLoop.ExcludeUserInputEvents) if self.checkEx: raise self.checkEx
def test_syncNAM_tiff_success(self): """Test NAM in sync mode with binary files to check for bytearray conversion.""" # test success nam = NetworkAccessManager(debug=True) tiff_file = os.path.join(os.path.dirname(os.path.realpath(__file__)), 'data', '1.1.01.tiff') # Online #(response, content) = nam.request("http://sipi.usc.edu/database/download.php?vol=textures&img=1.1.01") # Offline (I tested and they give the same result) (response, content) = nam.request("file://%s" % tiff_file) self.assertTrue(response.ok) # Offline has no status code #self.assertEqual(response.status_code, 200) self.assertEqual(content, open(tiff_file, 'rb').read(), "Image differs")
def getLegendSymbols(self, layer, ilayer, legendFolder): size = self._parameters["size"] qsize = QSize(size, size) symbols = [] def appendSymbol(title, href): symbols.append({'title': title, 'href':href}) if layer.type() == layer.VectorLayer: renderer = layer.rendererV2() if isinstance(renderer, QgsSingleSymbolRendererV2): img = renderer.symbol().asImage(qsize) symbolPath = os.path.join(legendFolder, "%i_0.png" % (ilayer)) img.save(symbolPath) appendSymbol("", os.path.basename(symbolPath)) elif isinstance(renderer, QgsCategorizedSymbolRendererV2): for isymbol, cat in enumerate(renderer.categories()): img = cat.symbol().asImage(qsize) symbolPath = os.path.join(legendFolder, "%i_%i.png" % (ilayer, isymbol)) img.save(symbolPath) appendSymbol(cat.label(), os.path.basename(symbolPath)) elif isinstance(renderer, QgsGraduatedSymbolRendererV2): for isymbol, ran in enumerate(renderer.ranges()): img = ran.symbol().asImage(qsize) symbolPath = os.path.join(legendFolder, "%i_%i.png" % (ilayer, isymbol)) img.save(symbolPath) appendSymbol("%s-%s" % (ran.lowerValue(), ran.upperValue()), os.path.basename(symbolPath)) elif isinstance(renderer, QgsRuleBasedRendererV2): for isymbol, rule in enumerate(renderer.rootRule().children()): img = rule.symbol().asImage(qsize) symbolPath = os.path.join(legendFolder, "%i_%i.png" % (ilayer, isymbol)) img.save(symbolPath) appendSymbol(rule.label(), os.path.basename(symbolPath)) elif layer.type() == layer.RasterLayer: if layer.providerType() == "wms": source = layer.source() layerName = re.search(r"layers=(.*?)(?:&|$)", source).groups(0)[0] url = re.search(r"url=(.*?)(?:&|$)", source).groups(0)[0] styles = re.search(r"styles=(.*?)(?:&|$)", source).groups(0)[0] fullUrl = ("%s?LAYER=%s&STYLES=%s&REQUEST=GetLegendGraphic&VERSION=1.0.0&FORMAT=image/png&WIDTH=%i&HEIGHT=%i" % (url, layerName, styles, size, size)) nam = NetworkAccessManager(debug=pluginSetting("logresponse")) response, content = nam.request(fullUrl) symbolPath = os.path.join(legendFolder, "%i_0.png" % ilayer) with open(symbolPath, 'wb') as f: f.write(content) appendSymbol("", os.path.basename(symbolPath)) return symbols
def availableMaps(maps_uri, token): """Fetch the list of available maps from BCS endpoint, apparently this API method does not require auth""" # For testing purposes, we can also access to a json file directly if not maps_uri.startswith('http') or token is None: j = json.load(open(maps_uri)) else: headers = {} headers["Authorization"] = "Bearer {}".format(token) nam = NetworkAccessManager() res, content = nam.request(maps_uri, headers=headers) try: j = json.loads(content) except: raise Exception("Unable to parse server reply.") return [l for l in j if isSupported(l)]
class AuthCatalog(BaseCatalog): def __init__(self, service_url, authid, cache_time): # Do not call parent constructor, this is a patching class self.authid = authid self.cache_time = cache_time self.service_url = service_url.strip("/") self._cache = dict() self._version = None self.nam = NetworkAccessManager(self.authid, exception_class=FailedRequestError, debug=False) self.username = '' self.password = '' def http_request(self, url, data=None, method='get', headers={}): resp, content = self.nam.request(url, method, data, headers) return resp def setup_connection(self): pass
class what3words(object): """what3words API""" def __init__(self, host='api.what3words.com', apikey=''): self.host = 'https://' + host self.apikey = apikey self.nam = NetworkAccessManager() def forwardGeocode(self, words='index.home.raft', lang='en'): if isinstance(words, list): words = "%s.%s.%s" % (words[0], words[1], words[2]) params = { 'addr': words, 'display': 'full', 'format': 'json', 'lang': lang } return self.postRequest(self.host + '/v2/forward', params) def reverseGeocode(self, lat='', lng='', corners='false', lang='en'): coords = "%s,%s" % (lat, lng) params = { 'coords': coords, 'display': 'full', 'format': 'json', 'lang': lang } return self.postRequest(self.host + '/v2/reverse', params) def getLanguages(self): return self.postRequest(self.host + '/v2/languages', dict()) def postRequest(self, url, params): params.update({'key': self.apikey}) encparams = urllib.parse.urlencode(params) url = url + '?' + encparams r, data = self.nam.request(url) return json.loads(data)
def checkAppCanBeCreated(appdef, forPreview=False): ##viewCrs = appdef["Settings"]["App view CRS"] jsonp = appdef["Settings"]["Use JSONP for WFS connections"] problems = [] layers = appdef["Layers"] widgets = appdef["Widgets"].values() for w in widgets: w.checkProblems(appdef, problems, forPreview) themeModule = importlib.import_module("webappbuilder.themes." + appdef["Settings"]["Theme"]) themeModule.checkProblems(appdef, problems) def getSize(lyr): ptsInFeature = 1 if lyr.geometryType( ) == QGis.Point else 10 #quick estimate... return lyr.featureCount() * (ptsInFeature + lyr.pendingFields().size()) MAXSIZE = 30000 for applayer in layers: if applayer.layer.type( ) == applayer.layer.VectorLayer and applayer.layer.providerType( ).lower() != "wfs": if getSize(applayer.layer) > MAXSIZE: problems.append( "Layer %s might be too big for being loaded directly from a file." % applayer.layer.name()) nam = NetworkAccessManager(debug=pluginSetting("logresponse")) for applayer in layers: layer = applayer.layer if layer.providerType().lower() == "wms": try: source = layer.source() url = re.search( r"url=(.*?)(?:&|$)", source).groups(0)[0] + "?REQUEST=GetCapabilities" r, content = run( lambda: nam.request(url, headers={"origin": "null"})) cors = r.headers.get("Access-Control-Allow-Origin", "").lower() if cors not in ["null", "*"]: problems.append( "Server for layer %s is not allowed to accept cross-origin requests." " Popups and printing might not work correctly for that layer." % layer.name()) except: QgsMessageLog.logMessage( "Warning: cannot verify cross-origin configuration for layer '%s'." % layer.name(), level=QgsMessageLog.WARNING) for applayer in layers: layer = applayer.layer if layer.providerType().lower() == "wfs": datasourceUri = QgsDataSourceURI(layer.source()) url = datasourceUri.param("url") or layer.source().split("?")[0] url = url + "?service=WFS&version=1.1.0&REQUEST=GetCapabilities" try: if jsonp: r, content = run(lambda: nam.request(url)) if "text/javascript" not in r.headers.values(): problems.append( "Server for layer %s does not support JSONP. WFS layer won't be correctly loaded in Web App." % layer.name()) else: r, content = run( lambda: nam.request(url, headers={"origin": "null"})) cors = r.headers.get("Access-Control-Allow-Origin", "").lower() if cors not in ["null", "*"]: problems.append( "Server for layer %s is not allowed to accept cross-origin requests." % layer.name()) except: QgsMessageLog.logMessage( "Warning: cannot verify if WFS layer server has the required configuration. Layer: '%s'." % layer.name(), level=QgsMessageLog.WARNING) if layer.type() != layer.VectorLayer: continue renderer = applayer.layer.rendererV2() allowed = [ QgsSingleSymbolRendererV2, QgsCategorizedSymbolRendererV2, QgsGraduatedSymbolRendererV2, QgsHeatmapRenderer, QgsRuleBasedRendererV2 ] try: allowed.append(QgsNullSymbolRenderer) except: pass if not isinstance(renderer, tuple(allowed)): problems.append( "Symbology used by layer %s includes unsupported elements." "Only single symbol, categorized, graduated, heatmap and rule-based renderers are supported." "This layer will not be correctly styled in the web app." % layer.name()) if isinstance(renderer, QgsRuleBasedRendererV2): rules = renderer.rootRule().children() for rule in rules: expr = rule.filterExpression() unsupported = is_expression_supported(expr) if unsupported: problems.append( "The expression '%s' has unsupported functions: %s" % (expr, ", ".join(unsupported))) if layer.hasLabelsEnabled(): problems.append( "Layer %s uses old-style labeling. Labels might not be correctly rendered in the web app." % layer.name()) if str(layer.customProperty("labeling/enabled")).lower() == "true": if unicode(layer.customProperty( "labeling/isExpression")).lower() == "true": expr = layer.customProperty("labeling/fieldName") unsupported = is_expression_supported(expr) if unsupported: problems.append( "The expression '%s' has unsupported functions: %s" % (expr, ", ".join(unsupported))) #TODO: check that layers using time attributes are not published using WMS hasTimeInfo = False for applayer in layers: if applayer.timeInfo is not None: hasTimeInfo = True break if hasTimeInfo and "timeline" not in appdef["Widgets"]: problems.append( "There are layers with time information, but timeline widget is not used." ) if "timeline" in appdef["Widgets"]: for applayer in layers: layer = applayer.layer if layer.providerType().lower() == "wms": try: source = layer.source() url = re.search(r"url=(.*?)(?:&|$)", source).groups(0)[0] layernames = re.search(r"layers=(.*?)(?:&|$)", source).groups(0)[0] r, content = nam.request( url + "?service=WMS&request=GetCapabilities") root = ET.fromstring( re.sub('\\sxmlns="[^"]+"', '', r.content)) for layerElement in root.iter('Layer'): name = layerElement.find("Name").text if name == layernames: # look for discrete values time = layerElement.find('Extent') if time is not None: applayer.timeInfo = time hasTimeInfo = True # look for interval values time = layerElement.find('Dimension') if time is not None and time.attrib[ 'name'] == 'time': applayer.timeInfo = '"{}"'.format(time.text) hasTimeInfo = True except: #we swallow error, since this is not a vital info to add, so the app can still be created. pass if not hasTimeInfo: problems.append( "Timeline widget is used but there are no layers with time information" ) return problems
def test_AsyncNAM_emptyreturn(self): """Test ANAM return value.""" loop = QtCore.QEventLoop() nam = NetworkAccessManager(debug=True) ret = nam.request('anyurl', blocking=False) self.assertEqual(ret, (None, None))