From 47234b0b13540b6ac15f6b2b65a277e1241f302e Mon Sep 17 00:00:00 2001 From: Leon Date: Tue, 8 Aug 2023 00:55:40 +0200 Subject: [PATCH 01/15] update patcher, so it does not use older chromedriver binary. --- undetected_chromedriver/patcher.py | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/undetected_chromedriver/patcher.py b/undetected_chromedriver/patcher.py index d083dc3..a84bc7d 100644 --- a/undetected_chromedriver/patcher.py +++ b/undetected_chromedriver/patcher.py @@ -116,12 +116,13 @@ class Patcher(object): # # -1 being a skip value used later in this block # p = pathlib.Path(self.data_path) - with Lock(): - files = list(p.rglob("*chromedriver*?")) - for file in files: - if self.is_binary_patched(file): - self.executable_path = str(file) - return True + if self.user_multiprocs: + with Lock(): + files = list(p.rglob("*chromedriver*?")) + for file in files: + if self.is_binary_patched(file): + self.executable_path = str(file) + return True if executable_path: self.executable_path = executable_path @@ -202,7 +203,11 @@ class Patcher(object): def cleanup_unused_files(self): p = pathlib.Path(self.data_path) items = list(p.glob("*undetected*")) - print(items) + for item in items: + try: + item.unlink() + except: + pass def patch(self): self.patch_exe() From 8049384e5a67277419cc75ae2a19eb67c0e46038 Mon Sep 17 00:00:00 2001 From: Leon Date: Tue, 8 Aug 2023 00:56:49 +0200 Subject: [PATCH 02/15] Update __init__.py --- undetected_chromedriver/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/undetected_chromedriver/__init__.py b/undetected_chromedriver/__init__.py index 2139c58..0ecaa82 100644 --- a/undetected_chromedriver/__init__.py +++ b/undetected_chromedriver/__init__.py @@ -17,7 +17,7 @@ by UltrafunkAmsterdam (https://github.com/ultrafunkamsterdam) from __future__ import annotations -__version__ = "3.5.0" +__version__ = "3.5.1" import json import logging From f3a8a62908c4b0f7dbf4c25bac33c8eebae3f98a Mon Sep 17 00:00:00 2001 From: Leon Date: Tue, 8 Aug 2023 01:00:07 +0200 Subject: [PATCH 03/15] Update patcher.py --- undetected_chromedriver/patcher.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/undetected_chromedriver/patcher.py b/undetected_chromedriver/patcher.py index a84bc7d..d7d62b0 100644 --- a/undetected_chromedriver/patcher.py +++ b/undetected_chromedriver/patcher.py @@ -116,7 +116,7 @@ class Patcher(object): # # -1 being a skip value used later in this block # p = pathlib.Path(self.data_path) - if self.user_multiprocs: + if self.user_multi_procs: with Lock(): files = list(p.rglob("*chromedriver*?")) for file in files: From a0712f87cb88a25a4cb5176e2870fbbe242350e4 Mon Sep 17 00:00:00 2001 From: Leon Date: Tue, 8 Aug 2023 01:01:22 +0200 Subject: [PATCH 04/15] Update __init__.py --- undetected_chromedriver/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/undetected_chromedriver/__init__.py b/undetected_chromedriver/__init__.py index 0ecaa82..87ba76a 100644 --- a/undetected_chromedriver/__init__.py +++ b/undetected_chromedriver/__init__.py @@ -17,7 +17,7 @@ by UltrafunkAmsterdam (https://github.com/ultrafunkamsterdam) from __future__ import annotations -__version__ = "3.5.1" +__version__ = "3.5.1a" import json import logging From 44c5ea712793eff892026df49ec64597726c78e0 Mon Sep 17 00:00:00 2001 From: unknown Date: Wed, 9 Aug 2023 19:42:11 +0200 Subject: [PATCH 05/15] 3.5.2 - Minor changes and fixes * removed search paths for Chrome Canary and Chrome Beta from find_chrome_executable() since chromedriver is always behind schedule so that means a driver for newer versions than current main could not be found and raises Exception. * Changed/Fixed wrong binary version caused by patcher. Due to multi-threading people and a mistake fromy my side, the driver binary currently on disk was always used instead of getting new ones. even if you did not use multithreading. so even outdated binaries where kept! for multithreading people, it now only keeps the most recent binary and throws away others. for normal people, you will get the binary you deserve ;) * Added more descriptive exceptions when Chrome binary could not be found origin no connection could be made to Chrome. * some stuff i forgot --- undetected_chromedriver/__init__.py | 17 ++++++++++++++--- undetected_chromedriver/patcher.py | 16 +++++++--------- 2 files changed, 21 insertions(+), 12 deletions(-) diff --git a/undetected_chromedriver/__init__.py b/undetected_chromedriver/__init__.py index 87ba76a..96f108a 100644 --- a/undetected_chromedriver/__init__.py +++ b/undetected_chromedriver/__init__.py @@ -17,11 +17,12 @@ by UltrafunkAmsterdam (https://github.com/ultrafunkamsterdam) from __future__ import annotations -__version__ = "3.5.1a" +__version__ = "3.5.2" import json import logging import os +import pathlib import re import shutil import subprocess @@ -372,6 +373,18 @@ class Chrome(selenium.webdriver.chrome.webdriver.WebDriver): browser_executable_path or find_chrome_executable() ) + if not options.binary_location or not \ + pathlib.Path(options.binary_location).exists(): + raise FileNotFoundError( + "\n---------------------\n" + "Could not determine browser executable." + "\n---------------------\n" + "Make sure your browser is installed in the default location (path).\n" + "If you are sure about the browser executable, you can specify it using\n" + "the `browser_executable_path='{}` parameter.\n\n" + .format("/path/to/browser/executable" if IS_POSIX else "c:/path/to/your/browser.exe") + ) + self._delay = 3 self.user_data_dir = user_data_dir @@ -877,8 +890,6 @@ def find_chrome_executable(): if item is not None: for subitem in ( "Google/Chrome/Application", - "Google/Chrome Beta/Application", - "Google/Chrome Canary/Application", ): candidates.add(os.sep.join((item, subitem, "chrome.exe"))) for candidate in candidates: diff --git a/undetected_chromedriver/patcher.py b/undetected_chromedriver/patcher.py index d7d62b0..4c062f6 100644 --- a/undetected_chromedriver/patcher.py +++ b/undetected_chromedriver/patcher.py @@ -111,18 +111,16 @@ class Patcher(object): Returns: """ - # if self.user_multi_procs and \ - # self.user_multi_procs != -1: - # # -1 being a skip value used later in this block - # p = pathlib.Path(self.data_path) if self.user_multi_procs: with Lock(): - files = list(p.rglob("*chromedriver*?")) - for file in files: - if self.is_binary_patched(file): - self.executable_path = str(file) - return True + files = list(p.rglob("*chromedriver*")) + most_recent = max(files, key=lambda f: f.stat().st_mtime) + files.remove(most_recent) + list(map(lambda f: f.unlink(), files)) + if self.is_binary_patched(most_recent): + self.executable_path = str(most_recent) + return True if executable_path: self.executable_path = executable_path From 25877fd95a145fef8248e37ae9dbe7b6364caab2 Mon Sep 17 00:00:00 2001 From: jdholtz Date: Wed, 16 Aug 2023 07:42:07 -0500 Subject: [PATCH 06/15] Add support for Chromedriver versions 115+ --- undetected_chromedriver/patcher.py | 114 +++++++++++++++++++++++------ 1 file changed, 90 insertions(+), 24 deletions(-) diff --git a/undetected_chromedriver/patcher.py b/undetected_chromedriver/patcher.py index 4c062f6..08c6434 100644 --- a/undetected_chromedriver/patcher.py +++ b/undetected_chromedriver/patcher.py @@ -3,9 +3,11 @@ from distutils.version import LooseVersion import io +import json import logging import os import pathlib +import platform import random import re import shutil @@ -24,21 +26,9 @@ IS_POSIX = sys.platform.startswith(("darwin", "cygwin", "linux", "linux2")) class Patcher(object): lock = Lock() - url_repo = "https://chromedriver.storage.googleapis.com" - zip_name = "chromedriver_%s.zip" exe_name = "chromedriver%s" platform = sys.platform - if platform.endswith("win32"): - zip_name %= "win32" - exe_name %= ".exe" - if platform.endswith(("linux", "linux2")): - zip_name %= "linux64" - exe_name %= "" - if platform.endswith("darwin"): - zip_name %= "mac64" - exe_name %= "" - if platform.endswith("win32"): d = "~/appdata/roaming/undetected_chromedriver" elif "LAMBDA_TASK_ROOT" in os.environ: @@ -97,9 +87,53 @@ class Patcher(object): self._custom_exe_path = True self.executable_path = executable_path + # Set the correct repository to download the Chromedriver from + self.is_old_chromedriver = version_main and version_main <= 114 + if self.is_old_chromedriver: + self.url_repo = "https://chromedriver.storage.googleapis.com" + else: + self.url_repo = "https://googlechromelabs.github.io/chrome-for-testing" + self.version_main = version_main self.version_full = None + self._set_platform_name() + + def _set_platform_name(self): + """ + Set the platform and exe name based on the platform undetected_chromedriver is running on + in order to download the correct chromedriver. + """ + if self.platform.endswith("win32"): + self.platform_name = "win32" + self.exe_name %= ".exe" + if self.platform.endswith(("linux", "linux2")): + self.platform_name = "linux64" + self.exe_name %= "" + if self.platform.endswith("darwin"): + self.platform_name = self._get_mac_platform_name() + self.exe_name %= "" + + def _get_mac_platform_name(self): + """ + The Mac platform name changes based on the architecture and Chromedriver version desired + """ + platform_name = "mac" + is_arm_arch = any(["aarch64", "arm"] in platform.machine()) + + if self.is_old_chromedriver: + if is_arm_arch: + platform_name += "_arm64" + else: + platform_name += "64" + else: + if is_arm_arch: + platform_name += "-arm64" + else: + platform_name += "-x64" + + return platform_name + def auto(self, executable_path=None, force=False, version_main=None, _=None): """ @@ -217,12 +251,32 @@ class Patcher(object): :return: version string :rtype: LooseVersion """ - path = "/latest_release" - if self.version_main: - path += f"_{self.version_main}" - path = path.upper() + # Endpoint for old versions of Chromedriver (114 and below) + if self.is_old_chromedriver: + path = f"/latest_release_{self.version_main}" + path = path.upper() + logger.debug("getting release number from %s" % path) + return LooseVersion(urlopen(self.url_repo + path).read().decode()) + + # Endpoint for new versions of Chromedriver (115+) + if not self.version_main: + # Fetch the latest version + path = "/last-known-good-versions-with-downloads.json" + logger.debug("getting release number from %s" % path) + with urlopen(self.url_repo + path) as conn: + response = conn.read().decode() + + last_versions = json.loads(response) + return LooseVersion(last_versions["channels"]["Stable"]["version"]) + + # Fetch the latest minor version of the major version provided + path = "/latest-versions-per-milestone-with-downloads.json" logger.debug("getting release number from %s" % path) - return LooseVersion(urlopen(self.url_repo + path).read().decode()) + with urlopen(self.url_repo + path) as conn: + response = conn.read().decode() + + major_versions = json.loads(response) + return LooseVersion(major_versions["milestones"][str(self.version_main)]["version"]) def parse_exe_version(self): with io.open(self.executable_path, "rb") as f: @@ -237,10 +291,16 @@ class Patcher(object): :return: path to downloaded file """ - u = "%s/%s/%s" % (self.url_repo, self.version_full.vstring, self.zip_name) - logger.debug("downloading from %s" % u) - # return urlretrieve(u, filename=self.data_path)[0] - return urlretrieve(u)[0] + zip_name = f"chromedriver_{self.platform_name}.zip" + if self.is_old_chromedriver: + download_url = "%s/%s/%s" % (self.url_repo, self.version_full.vstring, zip_name) + else: + zip_name = zip_name.replace("_", "-", 1) + download_url = "https://edgedl.me.gvt1.com/edgedl/chrome/chrome-for-testing/%s/%s/%s" + download_url %= (self.version_full.vstring, self.platform_name, zip_name) + + logger.debug("downloading from %s" % download_url) + return urlretrieve(download_url)[0] def unzip_package(self, fp): """ @@ -248,6 +308,12 @@ class Patcher(object): :return: path to unpacked executable """ + exe_path = self.exe_name + if not self.is_old_chromedriver: + # The new chromedriver unzips into its own folder + zip_name = f"chromedriver-{self.platform_name}" + exe_path = os.path.join(zip_name, self.exe_name) + logger.debug("unzipping %s" % fp) try: os.unlink(self.zip_path) @@ -256,10 +322,10 @@ class Patcher(object): os.makedirs(self.zip_path, mode=0o755, exist_ok=True) with zipfile.ZipFile(fp, mode="r") as zf: - zf.extract(self.exe_name, self.zip_path) - os.rename(os.path.join(self.zip_path, self.exe_name), self.executable_path) + zf.extract(exe_path, self.zip_path) + os.rename(os.path.join(self.zip_path, exe_path), self.executable_path) os.remove(fp) - os.rmdir(self.zip_path) + shutil.rmtree(self.zip_path) os.chmod(self.executable_path, 0o755) return self.executable_path From 9102a51636a58cc0b71498ea971513c94bcd8ffb Mon Sep 17 00:00:00 2001 From: jdholtz Date: Wed, 16 Aug 2023 08:09:01 -0500 Subject: [PATCH 07/15] Set exe name before using it --- undetected_chromedriver/patcher.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/undetected_chromedriver/patcher.py b/undetected_chromedriver/patcher.py index 08c6434..bb2383a 100644 --- a/undetected_chromedriver/patcher.py +++ b/undetected_chromedriver/patcher.py @@ -62,6 +62,9 @@ class Patcher(object): prefix = "undetected" self.user_multi_procs = user_multi_procs + # Needs to be called before self.exe_name is accessed + self._set_platform_name() + if not os.path.exists(self.data_path): os.makedirs(self.data_path, exist_ok=True) @@ -97,8 +100,6 @@ class Patcher(object): self.version_main = version_main self.version_full = None - self._set_platform_name() - def _set_platform_name(self): """ Set the platform and exe name based on the platform undetected_chromedriver is running on From f91b7d86bc257d4cb9bfc848266e82509868e2c6 Mon Sep 17 00:00:00 2001 From: jdholtz Date: Wed, 16 Aug 2023 11:05:49 -0500 Subject: [PATCH 08/15] Extract all contents from zip file when downloading Chromedriver Extracting just one file that was in a directory caused an error on Windows. Extracting all fixes this issue. --- undetected_chromedriver/patcher.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/undetected_chromedriver/patcher.py b/undetected_chromedriver/patcher.py index bb2383a..2d9d97f 100644 --- a/undetected_chromedriver/patcher.py +++ b/undetected_chromedriver/patcher.py @@ -323,7 +323,7 @@ class Patcher(object): os.makedirs(self.zip_path, mode=0o755, exist_ok=True) with zipfile.ZipFile(fp, mode="r") as zf: - zf.extract(exe_path, self.zip_path) + zf.extractall(self.zip_path) os.rename(os.path.join(self.zip_path, exe_path), self.executable_path) os.remove(fp) shutil.rmtree(self.zip_path) From 0ae871fed3d3d6bfbaf669aee8c873341d50e6be Mon Sep 17 00:00:00 2001 From: jdholtz Date: Wed, 16 Aug 2023 20:43:16 -0500 Subject: [PATCH 09/15] Fix architecture checks for Mac devices --- undetected_chromedriver/patcher.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/undetected_chromedriver/patcher.py b/undetected_chromedriver/patcher.py index 2d9d97f..6ff765e 100644 --- a/undetected_chromedriver/patcher.py +++ b/undetected_chromedriver/patcher.py @@ -120,7 +120,8 @@ class Patcher(object): The Mac platform name changes based on the architecture and Chromedriver version desired """ platform_name = "mac" - is_arm_arch = any(["aarch64", "arm"] in platform.machine()) + # Matches the platform as a substring so values like 'arm64' and 'armv7l' work + is_arm_arch = any(p in platform.machine() for p in ["aarch64", "arm"]) if self.is_old_chromedriver: if is_arm_arch: From 1ef8e8cead3f24314c16914c627d0e3de8d2f684 Mon Sep 17 00:00:00 2001 From: jdholtz Date: Wed, 16 Aug 2023 21:18:21 -0500 Subject: [PATCH 10/15] Fix reference to is_old_chromedriver when setting platform name --- undetected_chromedriver/patcher.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/undetected_chromedriver/patcher.py b/undetected_chromedriver/patcher.py index 6ff765e..7a023d4 100644 --- a/undetected_chromedriver/patcher.py +++ b/undetected_chromedriver/patcher.py @@ -62,6 +62,7 @@ class Patcher(object): prefix = "undetected" self.user_multi_procs = user_multi_procs + self.is_old_chromedriver = version_main and version_main <= 114 # Needs to be called before self.exe_name is accessed self._set_platform_name() @@ -91,7 +92,6 @@ class Patcher(object): self.executable_path = executable_path # Set the correct repository to download the Chromedriver from - self.is_old_chromedriver = version_main and version_main <= 114 if self.is_old_chromedriver: self.url_repo = "https://chromedriver.storage.googleapis.com" else: From 29551bd27954dacaf09864cf77935524db642c1b Mon Sep 17 00:00:00 2001 From: jdholtz Date: Wed, 16 Aug 2023 22:14:46 -0500 Subject: [PATCH 11/15] Remove ARM specification for Macs Apparently the x64 version works on ARM devices with Mac, so the platform now just chooses the correct platform name based on the Chromedriver version requested --- undetected_chromedriver/patcher.py | 26 ++++---------------------- 1 file changed, 4 insertions(+), 22 deletions(-) diff --git a/undetected_chromedriver/patcher.py b/undetected_chromedriver/patcher.py index 7a023d4..1b5409d 100644 --- a/undetected_chromedriver/patcher.py +++ b/undetected_chromedriver/patcher.py @@ -112,30 +112,12 @@ class Patcher(object): self.platform_name = "linux64" self.exe_name %= "" if self.platform.endswith("darwin"): - self.platform_name = self._get_mac_platform_name() + if self.is_old_chromedriver: + self.platform_name = "mac64" + else: + self.platform_name = "mac-x64" self.exe_name %= "" - def _get_mac_platform_name(self): - """ - The Mac platform name changes based on the architecture and Chromedriver version desired - """ - platform_name = "mac" - # Matches the platform as a substring so values like 'arm64' and 'armv7l' work - is_arm_arch = any(p in platform.machine() for p in ["aarch64", "arm"]) - - if self.is_old_chromedriver: - if is_arm_arch: - platform_name += "_arm64" - else: - platform_name += "64" - else: - if is_arm_arch: - platform_name += "-arm64" - else: - platform_name += "-x64" - - return platform_name - def auto(self, executable_path=None, force=False, version_main=None, _=None): """ From cea80717c5a3d95ccf5c40e6e38081d5454ec7a5 Mon Sep 17 00:00:00 2001 From: Leon Date: Fri, 25 Aug 2023 11:57:31 +0200 Subject: [PATCH 12/15] 3.5.3 Sorry for not getting earlier at this, my pc had a complete meltdown, m2, and gpu both dead. picking up a new one this afternoon. thanks to @jdholtz : This PR adds support for downloading Chromedriver versions 115+. This is necessary due to the Chromium team's change to Chromedriver's release process (see here). If the version_main is 114 or older, the Chromedriver will still be downloaded using LATEST_RELEASE_{version}. If the version_main is specified and is 115+, the /latest-versions-per-milestone-with-downloads.json from the new JSON endpoint is used and the version is selected from the corresponding milestone. Last, if the version_main is not specified, the /last-known-good-versions-with-downloads.json endpoint is used to fetch the latest stable version. In contrast with #1427, this PR uses the new JSON endpoints instead of reverting back to old versions if the LATEST_RELEASE endpoint isn't found (causing version discrepancy errors). I also added compatibility for installing x86 and arm64 for Mac separately since the platform names changed for the new endpoints. However, I have only tested on Linux and Windows so it would be great if someone could test on Mac (x86 and ARM) It has been tested on Linux, Windows, and Mac with success. The Chromedriver doesn't work on ARM devices when downloading the ARM chromedriver, but it seems to work fine with the x64 version (possibly with Rosetta installed). This also allows for users to download the Dev and Beta versions (currently 117 and 118) if they specify it using version_main. --- undetected_chromedriver/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/undetected_chromedriver/__init__.py b/undetected_chromedriver/__init__.py index 96f108a..d31055a 100644 --- a/undetected_chromedriver/__init__.py +++ b/undetected_chromedriver/__init__.py @@ -17,7 +17,7 @@ by UltrafunkAmsterdam (https://github.com/ultrafunkamsterdam) from __future__ import annotations -__version__ = "3.5.2" +__version__ = "3.5.3" import json import logging From 783b8393157b578e19e85b04d300fe06efeef653 Mon Sep 17 00:00:00 2001 From: Leon Date: Wed, 15 Nov 2023 09:27:59 +0100 Subject: [PATCH 13/15] bugfix --- undetected_chromedriver/__init__.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/undetected_chromedriver/__init__.py b/undetected_chromedriver/__init__.py index d31055a..2d78589 100644 --- a/undetected_chromedriver/__init__.py +++ b/undetected_chromedriver/__init__.py @@ -17,7 +17,7 @@ by UltrafunkAmsterdam (https://github.com/ultrafunkamsterdam) from __future__ import annotations -__version__ = "3.5.3" +__version__ = "3.5.4" import json import logging @@ -395,7 +395,7 @@ class Chrome(selenium.webdriver.chrome.webdriver.WebDriver): if no_sandbox: options.arguments.extend(["--no-sandbox", "--test-type"]) - if headless or options.headless: + if headless or getattr(options, 'headless', None): #workaround until a better checking is found try: if self.patcher.version_main < 108: @@ -485,7 +485,7 @@ class Chrome(selenium.webdriver.chrome.webdriver.WebDriver): else: self._web_element_cls = WebElement - if options.headless: + if headless or getattr(options, 'headless', None): self._configure_headless() def _configure_headless(self): From 702ff57e4aef88749b13cd792f497544977d520a Mon Sep 17 00:00:00 2001 From: Om Morendha Date: Thu, 15 Feb 2024 18:34:03 +0530 Subject: [PATCH 14/15] Update patcher.py Change the download url since the old one presents a 404 Error --- undetected_chromedriver/patcher.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/undetected_chromedriver/patcher.py b/undetected_chromedriver/patcher.py index 1b5409d..e72c022 100644 --- a/undetected_chromedriver/patcher.py +++ b/undetected_chromedriver/patcher.py @@ -280,7 +280,7 @@ class Patcher(object): download_url = "%s/%s/%s" % (self.url_repo, self.version_full.vstring, zip_name) else: zip_name = zip_name.replace("_", "-", 1) - download_url = "https://edgedl.me.gvt1.com/edgedl/chrome/chrome-for-testing/%s/%s/%s" + download_url = "https://storage.googleapis.com/chrome-for-testing-public/%s/%s/%s" download_url %= (self.version_full.vstring, self.platform_name, zip_name) logger.debug("downloading from %s" % download_url) From 0aa5fbe252370b4cb2b95526add445392cad27ba Mon Sep 17 00:00:00 2001 From: ultrafunkamsterdam Date: Sat, 17 Feb 2024 18:13:50 +0100 Subject: [PATCH 15/15] 3.5.5 --- undetected_chromedriver/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/undetected_chromedriver/__init__.py b/undetected_chromedriver/__init__.py index 2d78589..c419100 100644 --- a/undetected_chromedriver/__init__.py +++ b/undetected_chromedriver/__init__.py @@ -17,7 +17,7 @@ by UltrafunkAmsterdam (https://github.com/ultrafunkamsterdam) from __future__ import annotations -__version__ = "3.5.4" +__version__ = "3.5.5" import json import logging