diff --git a/CHANGELOG.txt b/CHANGELOG.txt index cb25f8345..3039eac86 100644 --- a/CHANGELOG.txt +++ b/CHANGELOG.txt @@ -1,3 +1,6 @@ +Version 2021.5.21 (1 may 2021) + - Allow users to force a specific communications scheme with FORCE_SCHEME_FROM_HOST (fixes #54) + Version 2021.4.20 (11 apr 2021) - Add support for ratio limit and seeding time limit when adding torrents diff --git a/qbittorrentapi/client.py b/qbittorrentapi/client.py index dc3049e11..df9e0bb1e 100644 --- a/qbittorrentapi/client.py +++ b/qbittorrentapi/client.py @@ -80,6 +80,10 @@ class Client( :param VERIFY_WEBUI_CERTIFICATE: Set to False to skip verify certificate for HTTPS connections; for instance, if the connection is using a self-signed certificate. Not setting this to False for self-signed certs will cause a APIConnectionError exception to be raised. + :param EXTRA_HEADERS: Dictionary of HTTP Headers to include in all requests made to qBittorrent. + :param FORCE_SCHEME_FROM_HOST: If a scheme (i.e. http or https) is specifed in host, it will be used regardless of + whether qBittorrent is configured for HTTP or HTTPS communication. Normally, this client will attempt to + determine which scheme qBittorrent is actually listening on...but this can cause problems in rare cases. :param RAISE_NOTIMPLEMENTEDERROR_FOR_UNIMPLEMENTED_API_ENDPOINTS: Some Endpoints may not be implemented in older versions of qBittorrent. Setting this to True will raise a NotImplementedError instead of just returning None. :param DISABLE_LOGGING_DEBUG_OUTPUT: Turn off debug output from logging for this package as well as Requests & urllib3. diff --git a/qbittorrentapi/request.py b/qbittorrentapi/request.py index 0981f41bd..2e6bb1231 100644 --- a/qbittorrentapi/request.py +++ b/qbittorrentapi/request.py @@ -144,6 +144,7 @@ def _initialize_lesser( self, EXTRA_HEADERS=None, VERIFY_WEBUI_CERTIFICATE=True, + FORCE_SCHEME_FROM_HOST=False, RAISE_UNIMPLEMENTEDERROR_FOR_UNIMPLEMENTED_API_ENDPOINTS=False, RAISE_NOTIMPLEMENTEDERROR_FOR_UNIMPLEMENTED_API_ENDPOINTS=False, VERBOSE_RESPONSE_LOGGING=False, @@ -160,6 +161,7 @@ def _initialize_lesser( self._VERBOSE_RESPONSE_LOGGING = bool(VERBOSE_RESPONSE_LOGGING) self._PRINT_STACK_FOR_EACH_REQUEST = bool(PRINT_STACK_FOR_EACH_REQUEST) self._SIMPLE_RESPONSES = bool(SIMPLE_RESPONSES) + self._FORCE_SCHEME_FROM_HOST = bool(FORCE_SCHEME_FROM_HOST) self._RAISE_UNIMPLEMENTEDERROR_FOR_UNIMPLEMENTED_API_ENDPOINTS = bool( RAISE_UNIMPLEMENTEDERROR_FOR_UNIMPLEMENTED_API_ENDPOINTS or RAISE_NOTIMPLEMENTEDERROR_FOR_UNIMPLEMENTED_API_ENDPOINTS @@ -248,7 +250,7 @@ def retry_backoff(retry_count): # then will sleep for 0s then .3s, then .6s, etc. between retries. backoff_time = _retry_backoff_factor * (2 ** ((retry_count + 1) - 1)) sleep(backoff_time if backoff_time <= 10 else 10) - logger.debug("Retry attempt %d" % (retry_count + 1)) + logger.debug("Retry attempt %d", (retry_count + 1)) max_retries = _retries if _retries > 1 else 2 for retry in range(0, (max_retries + 1)): @@ -257,7 +259,7 @@ def retry_backoff(retry_count): except HTTPError as e: # retry the request for HTTP 500 statuses; # raise immediately for other HTTP errors (e.g. 4XX statuses) - if not isinstance(e, HTTP5XXError) or retry >= max_retries: + if retry >= max_retries or not isinstance(e, HTTP5XXError): raise except Exception as e: if retry >= max_retries: @@ -316,6 +318,7 @@ def _build_url(self, api_namespace, api_method): base_url=self._API_BASE_URL, host=self.host, port=self.port, + force_user_scheme=self._FORCE_SCHEME_FROM_HOST, ) return self._build_url_path( base_url=self._API_BASE_URL, @@ -325,7 +328,7 @@ def _build_url(self, api_namespace, api_method): ) @staticmethod - def _build_base_url(base_url=None, host="", port=None): + def _build_base_url(base_url=None, host="", port=None, force_user_scheme=False): """ Determine the Base URL for the Web API endpoints. @@ -353,7 +356,7 @@ def _build_base_url(base_url=None, host="", port=None): if not host.lower().startswith(("http:", "https:", "//")): host = "//" + host base_url = urlparse(url=host) - logger.debug("Parsed user URL: %s" % repr(base_url)) + logger.debug("Parsed user URL: %r", base_url) # default to HTTP if user didn't specify user_scheme = base_url.scheme base_url = base_url._replace(scheme="http") if not user_scheme else base_url @@ -363,33 +366,35 @@ def _build_base_url(base_url=None, host="", port=None): base_url = base_url._replace(netloc="%s:%s" % (base_url.netloc, port)) # detect whether Web API is configured for HTTP or HTTPS - logger.debug("Detecting scheme for URL...") - try: - # skip verification here...if there's a problem, we'll catch it during the actual API call - r = requests_head(base_url.geturl(), allow_redirects=True, verify=False) - # if WebUI eventually supports sending a redirect from HTTP to HTTPS then - # Requests will automatically provide a URL using HTTPS. - # For instance, the URL returned below will use the HTTPS scheme. - # >>> requests.head('http://grc.com', allow_redirects=True).url - scheme = urlparse(r.url).scheme - except requests_exceptions.RequestException: - # assume alternative scheme will work...we'll fail later if neither are working - scheme = alt_scheme - - # use detected scheme - logger.debug("Using %s scheme" % scheme.upper()) - base_url = base_url._replace(scheme=scheme) - if user_scheme and user_scheme != scheme: - logger.warning( - "Using '%s' instead of requested '%s' to communicate with qBittorrent" - % (scheme, user_scheme) - ) + if not (user_scheme and force_user_scheme): + logger.debug("Detecting scheme for URL...") + try: + # skip verification here...if there's a problem, we'll catch it during the actual API call + r = requests_head(base_url.geturl(), allow_redirects=True, verify=False) + # if WebUI eventually supports sending a redirect from HTTP to HTTPS then + # Requests will automatically provide a URL using HTTPS. + # For instance, the URL returned below will use the HTTPS scheme. + # >>> requests.head('http://grc.com', allow_redirects=True).url + scheme = urlparse(r.url).scheme + except requests_exceptions.RequestException: + # assume alternative scheme will work...we'll fail later if neither are working + scheme = alt_scheme + + # use detected scheme + logger.debug("Using %s scheme", scheme.upper()) + base_url = base_url._replace(scheme=scheme) + if user_scheme and user_scheme != scheme: + logger.warning( + "Using '%s' instead of requested '%s' to communicate with qBittorrent", + scheme, + user_scheme, + ) # ensure URL always ends with a forward-slash base_url = base_url.geturl() if not base_url.endswith("/"): base_url = base_url + "/" - logger.debug("Base URL: %s" % base_url) + logger.debug("Base URL: %s", base_url) return base_url diff --git a/setup.py b/setup.py index ed2aa14db..947304870 100644 --- a/setup.py +++ b/setup.py @@ -5,7 +5,7 @@ setup( name="qbittorrent-api", - version="2021.4.20", + version="2021.5.21", packages=find_packages(exclude=["*.tests", "*.tests.*", "tests.*", "tests"]), include_package_data=True, install_requires=[ diff --git a/tests/test_request.py b/tests/test_request.py index 7e8c6c16b..070dee44c 100644 --- a/tests/test_request.py +++ b/tests/test_request.py @@ -102,6 +102,33 @@ def test_port_from_host(app_version): assert client.app.version == app_version +def test_force_user_scheme(app_version): + default_host = environ["PYTHON_QBITTORRENTAPI_HOST"] + + client = Client( + host="http://" + default_host, + VERIFY_WEBUI_CERTIFICATE=False, + FORCE_SCHEME_FROM_HOST=True, + ) + assert client.app.version == app_version + assert client._API_BASE_URL.startswith("http://") + + client = Client( + host=default_host, VERIFY_WEBUI_CERTIFICATE=False, FORCE_SCHEME_FROM_HOST=True + ) + assert client.app.version == app_version + assert client._API_BASE_URL.startswith("http://") + + client = Client( + host="https://" + default_host, + VERIFY_WEBUI_CERTIFICATE=False, + FORCE_SCHEME_FROM_HOST=True, + ) + with pytest.raises(APIConnectionError): + assert client.app.version == app_version + assert client._API_BASE_URL.startswith("https://") + + def test_log_out(client): client.auth_log_out() with pytest.raises(Forbidden403Error):