formatting

This commit is contained in:
jrkb 2025-05-13 16:08:53 +02:00
parent a681245093
commit ff85c93551
5 changed files with 641 additions and 549 deletions

View file

@ -54,8 +54,8 @@ class SingletonUpdater:
needed throughout the addon. It implements all the interfaces for running
updates.
"""
def __init__(self):
def __init__(self):
self._engine = ForgejoEngine()
self._user = None
self._repo = None
@ -68,7 +68,7 @@ class SingletonUpdater:
self._latest_release = None
self._use_releases = False
self._include_branches = False
self._include_branch_list = ['master']
self._include_branch_list = ["master"]
self._include_branch_auto_check = False
self._manual_only = False
self._version_min_update = None
@ -110,7 +110,8 @@ class SingletonUpdater:
self._addon = __package__.lower()
self._addon_package = __package__ # Must not change.
self._updater_path = os.path.join(
os.path.dirname(__file__), self._addon + "_updater")
os.path.dirname(__file__), self._addon + "_updater"
)
self._addon_root = os.path.dirname(__file__)
self._json = dict()
self._error = None
@ -202,11 +203,13 @@ class SingletonUpdater:
@property
def check_interval(self):
return (self._check_interval_enabled,
self._check_interval_months,
self._check_interval_days,
self._check_interval_hours,
self._check_interval_minutes)
return (
self._check_interval_enabled,
self._check_interval_months,
self._check_interval_days,
self._check_interval_hours,
self._check_interval_minutes,
)
@property
def current_version(self):
@ -221,12 +224,10 @@ class SingletonUpdater:
try:
tuple(tuple_values)
except:
raise ValueError(
"current_version must be a tuple of integers")
raise ValueError("current_version must be a tuple of integers")
for i in tuple_values:
if type(i) is not int:
raise ValueError(
"current_version must be a tuple of integers")
raise ValueError("current_version must be a tuple of integers")
self._current_version = tuple(tuple_values)
@property
@ -285,15 +286,15 @@ class SingletonUpdater:
def include_branch_list(self, value):
try:
if value is None:
self._include_branch_list = ['master']
self._include_branch_list = ["master"]
elif not isinstance(value, list) or len(value) == 0:
raise ValueError(
"include_branch_list should be a list of valid branches")
"include_branch_list should be a list of valid branches"
)
else:
self._include_branch_list = value
except:
raise ValueError(
"include_branch_list should be a list of valid branches")
raise ValueError("include_branch_list should be a list of valid branches")
@property
def include_branches(self):
@ -362,8 +363,7 @@ class SingletonUpdater:
if value is None:
self._remove_pre_update_patterns = list()
elif not isinstance(value, list):
raise ValueError(
"remove_pre_update_patterns needs to be in a list format")
raise ValueError("remove_pre_update_patterns needs to be in a list format")
else:
self._remove_pre_update_patterns = value
@ -548,8 +548,7 @@ class SingletonUpdater:
tag_names.append(tag["name"])
return tag_names
def set_check_interval(self, enabled=False,
months=0, days=14, hours=0, minutes=0):
def set_check_interval(self, enabled=False, months=0, days=14, hours=0, minutes=0):
"""Set the time interval between automated checks, and if enabled.
Has enabled = False as default to not check against frequency,
@ -582,7 +581,8 @@ class SingletonUpdater:
def __str__(self):
return "Updater, with user: {a}, repository: {b}, url: {c}".format(
a=self._user, b=self._repo, c=self.form_repo_url())
a=self._user, b=self._repo, c=self.form_repo_url()
)
# -------------------------------------------------------------------------
# API-related functions
@ -621,10 +621,7 @@ class SingletonUpdater:
temp_branches.reverse()
for branch in temp_branches:
request = self.form_branch_url(branch)
include = {
"name": branch.title(),
"zipball_url": request
}
include = {"name": branch.title(), "zipball_url": request}
self._tags = [include] + self._tags # append to front
if self._tags is None:
@ -643,13 +640,18 @@ class SingletonUpdater:
if not self._error:
self._tag_latest = self._tags[0]
branch = self._include_branch_list[0]
self.print_verbose("{} branch found, no releases: {}".format(
branch, self._tags[0]))
self.print_verbose(
"{} branch found, no releases: {}".format(branch, self._tags[0])
)
elif ((len(self._tags) - len(self._include_branch_list) == 0
and self._include_branches)
or (len(self._tags) == 0 and not self._include_branches)
and self._prefiltered_tag_count > 0):
elif (
(
len(self._tags) - len(self._include_branch_list) == 0
and self._include_branches
)
or (len(self._tags) == 0 and not self._include_branches)
and self._prefiltered_tag_count > 0
):
self._tag_latest = None
self._error = "No releases available"
self._error_msg = "No versions found within compatible version range"
@ -659,13 +661,15 @@ class SingletonUpdater:
if not self._include_branches:
self._tag_latest = self._tags[0]
self.print_verbose(
"Most recent tag found:" + str(self._tags[0]['name']))
"Most recent tag found:" + str(self._tags[0]["name"])
)
else:
# Don't return branch if in list.
n = len(self._include_branch_list)
self._tag_latest = self._tags[n] # guaranteed at least len()=n+1
self.print_verbose(
"Most recent tag found:" + str(self._tags[n]['name']))
"Most recent tag found:" + str(self._tags[n]["name"])
)
def get_raw(self, url):
"""All API calls to base url."""
@ -680,13 +684,12 @@ class SingletonUpdater:
# Setup private request headers if appropriate.
if self._engine.token is not None:
if self._engine.name == "gitlab":
request.add_header('PRIVATE-TOKEN', self._engine.token)
request.add_header("PRIVATE-TOKEN", self._engine.token)
else:
self.print_verbose("Tokens not setup for engine yet")
# Always set user agent.
request.add_header(
'User-Agent', "Python/" + str(platform.python_version()))
request.add_header("User-Agent", "Python/" + str(platform.python_version()))
# Run the request.
try:
@ -747,8 +750,7 @@ class SingletonUpdater:
error = None
# Make/clear the staging folder, to ensure the folder is always clean.
self.print_verbose(
"Preparing staging folder for download:\n" + str(local))
self.print_verbose("Preparing staging folder for download:\n" + str(local))
if os.path.isdir(local):
try:
shutil.rmtree(local)
@ -782,17 +784,16 @@ class SingletonUpdater:
# Setup private token if appropriate.
if self._engine.token is not None:
if self._engine.name == "gitlab":
request.add_header('PRIVATE-TOKEN', self._engine.token)
request.add_header("PRIVATE-TOKEN", self._engine.token)
else:
self.print_verbose(
"Tokens not setup for selected engine yet")
self.print_verbose("Tokens not setup for selected engine yet")
# Always set user agent
request.add_header(
'User-Agent', "Python/" + str(platform.python_version()))
request.add_header("User-Agent", "Python/" + str(platform.python_version()))
self.url_retrieve(urllib.request.urlopen(request, context=context),
self._source_zip)
self.url_retrieve(
urllib.request.urlopen(request, context=context), self._source_zip
)
# Add additional checks on file size being non-zero.
self.print_verbose("Successfully downloaded update zip")
return True
@ -809,7 +810,8 @@ class SingletonUpdater:
self.print_verbose("Backing up current addon folder")
local = os.path.join(self._updater_path, "backup")
tempdest = os.path.join(
self._addon_root, os.pardir, self._addon + "_updater_backup_temp")
self._addon_root, os.pardir, self._addon + "_updater_backup_temp"
)
self.print_verbose("Backup destination path: " + str(local))
@ -818,7 +820,8 @@ class SingletonUpdater:
shutil.rmtree(local)
except:
self.print_verbose(
"Failed to removed previous backup folder, continuing")
"Failed to removed previous backup folder, continuing"
)
self.print_trace()
# Remove the temp folder.
@ -827,16 +830,17 @@ class SingletonUpdater:
try:
shutil.rmtree(tempdest)
except:
self.print_verbose(
"Failed to remove existing temp folder, continuing")
self.print_verbose("Failed to remove existing temp folder, continuing")
self.print_trace()
# Make a full addon copy, temporarily placed outside the addon folder.
if self._backup_ignore_patterns is not None:
try:
shutil.copytree(self._addon_root, tempdest,
ignore=shutil.ignore_patterns(
*self._backup_ignore_patterns))
shutil.copytree(
self._addon_root,
tempdest,
ignore=shutil.ignore_patterns(*self._backup_ignore_patterns),
)
except:
print("Failed to create backup, still attempting update.")
self.print_trace()
@ -853,7 +857,8 @@ class SingletonUpdater:
# Save the date for future reference.
now = datetime.now()
self._json["backup_date"] = "{m}-{d}-{yr}".format(
m=now.strftime("%B"), d=now.day, yr=now.year)
m=now.strftime("%B"), d=now.day, yr=now.year
)
self.save_updater_json()
def restore_backup(self):
@ -861,7 +866,8 @@ class SingletonUpdater:
self.print_verbose("Restoring backup, backing up current addon folder")
backuploc = os.path.join(self._updater_path, "backup")
tempdest = os.path.join(
self._addon_root, os.pardir, self._addon + "_updater_backup_temp")
self._addon_root, os.pardir, self._addon + "_updater_backup_temp"
)
tempdest = os.path.abspath(tempdest)
# Move instead contents back in place, instead of copy.
@ -910,10 +916,8 @@ class SingletonUpdater:
self._error_msg = "Failed to create extract directory"
return -1
self.print_verbose(
"Begin extracting source from zip:" + str(self._source_zip))
self.print_verbose("Begin extracting source from zip:" + str(self._source_zip))
with zipfile.ZipFile(self._source_zip, "r") as zfile:
if not zfile:
self._error = "Install failed"
self._error_msg = "Resulting file is not a zip, cannot extract"
@ -923,19 +927,20 @@ class SingletonUpdater:
# Now extract directly from the first subfolder (not root)
# this avoids adding the first subfolder to the path length,
# which can be too long if the download has the SHA in the name.
zsep = '/' # Not using os.sep, always the / value even on windows.
zsep = "/" # Not using os.sep, always the / value even on windows.
for name in zfile.namelist():
if zsep not in name:
continue
top_folder = name[:name.index(zsep) + 1]
top_folder = name[: name.index(zsep) + 1]
if name == top_folder + zsep:
continue # skip top level folder
sub_path = name[name.index(zsep) + 1:]
sub_path = name[name.index(zsep) + 1 :]
if name.endswith(zsep):
try:
os.mkdir(os.path.join(outdir, sub_path))
self.print_verbose(
"Extract - mkdir: " + os.path.join(outdir, sub_path))
"Extract - mkdir: " + os.path.join(outdir, sub_path)
)
except OSError as exc:
if exc.errno != errno.EEXIST:
self._error = "Install failed"
@ -947,7 +952,8 @@ class SingletonUpdater:
data = zfile.read(name)
outfile.write(data)
self.print_verbose(
"Extract - create: " + os.path.join(outdir, sub_path))
"Extract - create: " + os.path.join(outdir, sub_path)
)
self.print_verbose("Extracted source")
@ -959,8 +965,8 @@ class SingletonUpdater:
return -1
if self._subfolder_path:
self._subfolder_path.replace('/', os.path.sep)
self._subfolder_path.replace('\\', os.path.sep)
self._subfolder_path.replace("/", os.path.sep)
self._subfolder_path.replace("\\", os.path.sep)
# Either directly in root of zip/one subfolder, or use specified path.
if not os.path.isfile(os.path.join(unpath, "__init__.py")):
@ -1018,25 +1024,31 @@ class SingletonUpdater:
# Make sure that base is not a high level shared folder, but
# is dedicated just to the addon itself.
self.print_verbose(
"clean=True, clearing addon folder to fresh install state")
"clean=True, clearing addon folder to fresh install state"
)
# Remove root files and folders (except update folder).
files = [f for f in os.listdir(base)
if os.path.isfile(os.path.join(base, f))]
folders = [f for f in os.listdir(base)
if os.path.isdir(os.path.join(base, f))]
files = [
f for f in os.listdir(base) if os.path.isfile(os.path.join(base, f))
]
folders = [
f for f in os.listdir(base) if os.path.isdir(os.path.join(base, f))
]
for f in files:
os.remove(os.path.join(base, f))
self.print_verbose(
"Clean removing file {}".format(os.path.join(base, f)))
"Clean removing file {}".format(os.path.join(base, f))
)
for f in folders:
if os.path.join(base, f) is self._updater_path:
continue
shutil.rmtree(os.path.join(base, f))
self.print_verbose(
"Clean removing folder and contents {}".format(
os.path.join(base, f)))
os.path.join(base, f)
)
)
except Exception as err:
error = "failed to create clean existing addon folder"
@ -1047,8 +1059,9 @@ class SingletonUpdater:
# but avoid removing/altering backup and updater file.
for path, dirs, files in os.walk(base):
# Prune ie skip updater folder.
dirs[:] = [d for d in dirs
if os.path.join(path, d) not in [self._updater_path]]
dirs[:] = [
d for d in dirs if os.path.join(path, d) not in [self._updater_path]
]
for file in files:
for pattern in self.remove_pre_update_patterns:
if fnmatch.filter([file], pattern):
@ -1066,8 +1079,9 @@ class SingletonUpdater:
# actual file copying/replacements.
for path, dirs, files in os.walk(merger):
# Verify structure works to prune updater sub folder overwriting.
dirs[:] = [d for d in dirs
if os.path.join(path, d) not in [self._updater_path]]
dirs[:] = [
d for d in dirs if os.path.join(path, d) not in [self._updater_path]
]
rel_path = os.path.relpath(path, merger)
dest_path = os.path.join(base, rel_path)
if not os.path.exists(dest_path):
@ -1090,23 +1104,27 @@ class SingletonUpdater:
os.remove(dest_file)
os.rename(srcFile, dest_file)
self.print_verbose(
"Overwrote file " + os.path.basename(dest_file))
"Overwrote file " + os.path.basename(dest_file)
)
else:
self.print_verbose(
"Pattern not matched to {}, not overwritten".format(
os.path.basename(dest_file)))
os.path.basename(dest_file)
)
)
else:
# File did not previously exist, simply move it over.
os.rename(srcFile, dest_file)
self.print_verbose(
"New file " + os.path.basename(dest_file))
self.print_verbose("New file " + os.path.basename(dest_file))
# now remove the temp staging folder and downloaded zip
try:
shutil.rmtree(staging_path)
except:
error = ("Error: Failed to remove existing staging directory, "
"consider manually removing ") + staging_path
error = (
"Error: Failed to remove existing staging directory, "
"consider manually removing "
) + staging_path
self.print_verbose(error)
self.print_trace()
@ -1168,12 +1186,12 @@ class SingletonUpdater:
return ()
segments = list()
tmp = ''
tmp = ""
for char in str(text):
if not char.isdigit():
if len(tmp) > 0:
segments.append(int(tmp))
tmp = ''
tmp = ""
else:
tmp += char
if len(tmp) > 0:
@ -1184,7 +1202,7 @@ class SingletonUpdater:
if not self._include_branches:
return ()
else:
return (text)
return text
return tuple(segments)
def check_for_update_async(self, callback=None):
@ -1193,7 +1211,8 @@ class SingletonUpdater:
self._json is not None
and "update_ready" in self._json
and self._json["version_text"] != dict()
and self._json["update_ready"])
and self._json["update_ready"]
)
if is_ready:
self._update_ready = True
@ -1210,15 +1229,13 @@ class SingletonUpdater:
self.print_verbose("Skipping async check, already started")
# already running the bg thread
elif self._update_ready is None:
print("{} updater: Running background check for update".format(
self.addon))
print("{} updater: Running background check for update".format(self.addon))
self.start_async_check_update(False, callback)
def check_for_update_now(self, callback=None):
self._error = None
self._error_msg = None
self.print_verbose(
"Check update pressed, first getting current status")
self.print_verbose("Check update pressed, first getting current status")
if self._async_checking:
self.print_verbose("Skipping async check, already started")
return # already running the bg thread
@ -1243,9 +1260,7 @@ class SingletonUpdater:
# avoid running again in, just return past result if found
# but if force now check, then still do it
if self._update_ready is not None and not now:
return (self._update_ready,
self._update_version,
self._update_link)
return (self._update_ready, self._update_version, self._update_link)
if self._current_version is None:
raise ValueError("current_version not yet defined")
@ -1259,22 +1274,18 @@ class SingletonUpdater:
self.set_updater_json() # self._json
if not now and not self.past_interval_timestamp():
self.print_verbose(
"Aborting check for updated, check interval not reached")
self.print_verbose("Aborting check for updated, check interval not reached")
return (False, None, None)
# check if using tags or releases
# note that if called the first time, this will pull tags from online
if self._fake_install:
self.print_verbose(
"fake_install = True, setting fake version as ready")
self.print_verbose("fake_install = True, setting fake version as ready")
self._update_ready = True
self._update_version = "(999,999,999)"
self._update_link = "http://127.0.0.1"
return (self._update_ready,
self._update_version,
self._update_link)
return (self._update_ready, self._update_version, self._update_link)
# Primary internet call, sets self._tags and self._tag_latest.
self.get_tags()
@ -1327,7 +1338,6 @@ class SingletonUpdater:
else:
# Situation where branches not included.
if new_version > self._current_version:
self._update_ready = True
self._update_version = new_version
self._update_link = link
@ -1386,8 +1396,7 @@ class SingletonUpdater:
if self._fake_install:
# Change to True, to trigger the reload/"update installed" handler.
self.print_verbose("fake_install=True")
self.print_verbose(
"Just reloading and running any handler triggers")
self.print_verbose("Just reloading and running any handler triggers")
self._json["just_updated"] = True
self.save_updater_json()
if self._backup_current is True:
@ -1401,15 +1410,16 @@ class SingletonUpdater:
self.print_verbose("Update stopped, new version not ready")
if callback:
callback(
self._addon_package,
"Update stopped, new version not ready")
self._addon_package, "Update stopped, new version not ready"
)
return "Update stopped, new version not ready"
elif self._update_link is None:
# this shouldn't happen if update is ready
self.print_verbose("Update stopped, update link unavailable")
if callback:
callback(self._addon_package,
"Update stopped, update link unavailable")
callback(
self._addon_package, "Update stopped, update link unavailable"
)
return "Update stopped, update link unavailable"
if revert_tag is None:
@ -1461,12 +1471,12 @@ class SingletonUpdater:
return True
now = datetime.now()
last_check = datetime.strptime(
self._json["last_check"], "%Y-%m-%d %H:%M:%S.%f")
last_check = datetime.strptime(self._json["last_check"], "%Y-%m-%d %H:%M:%S.%f")
offset = timedelta(
days=self._check_interval_days + 30 * self._check_interval_months,
hours=self._check_interval_hours,
minutes=self._check_interval_minutes)
minutes=self._check_interval_minutes,
)
delta = (now - offset) - last_check
if delta.total_seconds() > 0:
@ -1482,8 +1492,8 @@ class SingletonUpdater:
Will also rename old file paths to addon-specific path if found.
"""
json_path = os.path.join(
self._updater_path,
"{}_updater_status.json".format(self._addon_package))
self._updater_path, "{}_updater_status.json".format(self._addon_package)
)
old_json_path = os.path.join(self._updater_path, "updater_status.json")
# Rename old file if it exists.
@ -1517,7 +1527,7 @@ class SingletonUpdater:
"ignore": False,
"just_restored": False,
"just_updated": False,
"version_text": dict()
"version_text": dict(),
}
self.save_updater_json()
@ -1537,11 +1547,13 @@ class SingletonUpdater:
jpath = self.get_json_path()
if not os.path.isdir(os.path.dirname(jpath)):
print("State error: Directory does not exist, cannot save json: ",
os.path.basename(jpath))
print(
"State error: Directory does not exist, cannot save json: ",
os.path.basename(jpath),
)
return
try:
with open(jpath, 'w') as outf:
with open(jpath, "w") as outf:
data_out = json.dumps(self._json, indent=4)
outf.write(data_out)
except:
@ -1575,8 +1587,13 @@ class SingletonUpdater:
if self._async_checking:
return
self.print_verbose("Starting background checking thread")
check_thread = threading.Thread(target=self.async_check_update,
args=(now, callback,))
check_thread = threading.Thread(
target=self.async_check_update,
args=(
now,
callback,
),
)
check_thread.daemon = True
self._check_thread = check_thread
check_thread.start()
@ -1630,17 +1647,19 @@ class SingletonUpdater:
# Updater Engines
# -----------------------------------------------------------------------------
class BitbucketEngine:
"""Integration to Bitbucket API for git-formatted repositories"""
def __init__(self):
self.api_url = 'https://api.bitbucket.org'
self.api_url = "https://api.bitbucket.org"
self.token = None
self.name = "bitbucket"
def form_repo_url(self, updater):
return "{}/2.0/repositories/{}/{}".format(
self.api_url, updater.user, updater.repo)
self.api_url, updater.user, updater.repo
)
def form_tags_url(self, updater):
return self.form_repo_url(updater) + "/refs/tags?sort=-name"
@ -1650,31 +1669,28 @@ class BitbucketEngine:
def get_zip_url(self, name, updater):
return "https://bitbucket.org/{user}/{repo}/get/{name}.zip".format(
user=updater.user,
repo=updater.repo,
name=name)
user=updater.user, repo=updater.repo, name=name
)
def parse_tags(self, response, updater):
if response is None:
return list()
return [
{
"name": tag["name"],
"zipball_url": self.get_zip_url(tag["name"], updater)
} for tag in response["values"]]
{"name": tag["name"], "zipball_url": self.get_zip_url(tag["name"], updater)}
for tag in response["values"]
]
class GithubEngine:
"""Integration to Github API"""
def __init__(self):
self.api_url = 'https://api.github.com'
self.api_url = "https://api.github.com"
self.token = None
self.name = "github"
def form_repo_url(self, updater):
return "{}/repos/{}/{}".format(
self.api_url, updater.user, updater.repo)
return "{}/repos/{}/{}".format(self.api_url, updater.user, updater.repo)
def form_tags_url(self, updater):
if updater.use_releases:
@ -1698,7 +1714,7 @@ class GitlabEngine:
"""Integration to GitLab API"""
def __init__(self):
self.api_url = 'https://gitlab.com'
self.api_url = "https://gitlab.com"
self.token = None
self.name = "gitlab"
@ -1710,19 +1726,19 @@ class GitlabEngine:
def form_branch_list_url(self, updater):
# does not validate branch name.
return "{}/repository/branches".format(
self.form_repo_url(updater))
return "{}/repository/branches".format(self.form_repo_url(updater))
def form_branch_url(self, branch, updater):
# Could clash with tag names and if it does, it will download TAG zip
# instead of branch zip to get direct path, would need.
return "{}/repository/archive.zip?sha={}".format(
self.form_repo_url(updater), branch)
self.form_repo_url(updater), branch
)
def get_zip_url(self, sha, updater):
return "{base}/repository/archive.zip?sha={sha}".format(
base=self.form_repo_url(updater),
sha=sha)
base=self.form_repo_url(updater), sha=sha
)
# def get_commit_zip(self, id, updater):
# return self.form_repo_url(updater)+"/repository/archive.zip?sha:"+id
@ -1733,8 +1749,11 @@ class GitlabEngine:
return [
{
"name": tag["name"],
"zipball_url": self.get_zip_url(tag["commit"]["id"], updater)
} for tag in response]
"zipball_url": self.get_zip_url(tag["commit"]["id"], updater),
}
for tag in response
]
class ForgejoEngine:
"""Integration to Forgejo/Gitea API"""
@ -1742,7 +1761,7 @@ class ForgejoEngine:
def __init__(self):
# the api_url may be overwritten by form_repo_url
# if updater.host is set
self.api_url = 'https://codeberg.org'
self.api_url = "https://codeberg.org"
self.token = None
self.name = "forgejo"
@ -1756,19 +1775,17 @@ class ForgejoEngine:
def form_branch_list_url(self, updater):
# does not validate branch name.
return "{}/branches".format(
self.form_repo_url(updater))
return "{}/branches".format(self.form_repo_url(updater))
def form_branch_url(self, branch, updater):
# Could clash with tag names and if it does, it will download TAG zip
# instead of branch zip to get direct path, would need.
return "{}/archive/{}.zip".format(
self.form_repo_url(updater), branch)
return "{}/archive/{}.zip".format(self.form_repo_url(updater), branch)
def get_zip_url(self, sha, updater):
return "{base}/archive/{sha}.zip".format(
base=self.form_repo_url(updater),
sha=sha)
base=self.form_repo_url(updater), sha=sha
)
# def get_commit_zip(self, id, updater):
# return self.form_repo_url(updater)+"/repository/archive.zip?sha:"+id
@ -1779,8 +1796,11 @@ class ForgejoEngine:
return [
{
"name": tag["name"],
"zipball_url": self.get_zip_url(tag["commit"]["sha"], updater)
} for tag in response]
"zipball_url": self.get_zip_url(tag["commit"]["sha"], updater),
}
for tag in response
]
# -----------------------------------------------------------------------------
# The module-shared class instance,