diff --git a/addon_updater.py b/addon_updater.py index 3f35b51..1415aaf 100644 --- a/addon_updater.py +++ b/addon_updater.py @@ -53,1585 +53,1582 @@ # The main class # ----------------------------------------------------------------------------- class SingletonUpdater: - """ - This is the singleton class to reference a copy from, - it is the shared module level class - """ - def __init__(self): - - self._engine = GithubEngine() - self._user = None - self._repo = None - self._website = None - self._current_version = None - self._subfolder_path = None - self._tags = list() - self._tag_latest = None - self._tag_names = list() - self._latest_release = None - self._use_releases = False - self._include_branches = False - self._include_branch_list = ['master'] - self._include_branch_auto_check = False - self._manual_only = False - self._version_min_update = None - self._version_max_update = None - - # by default, backup current addon if new is being loaded - self._backup_current = True - self._backup_ignore_patterns = None - - # set patterns for what files to overwrite on update - self._overwrite_patterns = ["*.py", "*.pyc"] - self._remove_pre_update_patterns = list() - - # by default, don't auto enable/disable the addon on update - # as it is slightly less stable/won't always fully reload module - self._auto_reload_post_update = False - - # settings relating to frequency and whether to enable auto background check - self._check_interval_enable = False - self._check_interval_months = 0 - self._check_interval_days = 7 - self._check_interval_hours = 0 - self._check_interval_minutes = 0 - - # runtime variables, initial conditions - self._verbose = False - self._use_print_traces = True - self._fake_install = False - self._async_checking = False # only true when async daemon started - self._update_ready = None - self._update_link = None - self._update_version = None - self._source_zip = None - self._check_thread = None - self._select_link = None - self.skip_tag = None - - # get from module data - self._addon = __package__.lower() - self._addon_package = __package__ # must not change - self._updater_path = os.path.join(os.path.dirname(__file__), - self._addon + "_updater") - self._addon_root = os.path.dirname(__file__) - self._json = dict() - self._error = None - self._error_msg = None - self._prefiltered_tag_count = 0 - - # UI code only, ie not used within this module but still useful - # properties to have - - # to verify a valid import, in place of placeholder import - self.show_popups = True # used in UI to show or not show update popups - self.invalid_updater = False - - # pre-assign basic select-link function - def select_link_function(self, tag): - return tag["zipball_url"] - - self._select_link = select_link_function - - def print_trace(self): - """Print handled exception details when use_print_traces is set""" - if self._use_print_traces: - traceback.print_exc() - - # ------------------------------------------------------------------------- - # Getters and setters - # ------------------------------------------------------------------------- - @property - def addon(self): - return self._addon - - @addon.setter - def addon(self, value): - self._addon = str(value) - - @property - def api_url(self): - return self._engine.api_url - - @api_url.setter - def api_url(self, value): - if not self.check_is_url(value): - raise ValueError("Not a valid URL: " + value) - self._engine.api_url = value - - @property - def async_checking(self): - return self._async_checking - - @property - def auto_reload_post_update(self): - return self._auto_reload_post_update - - @auto_reload_post_update.setter - def auto_reload_post_update(self, value): - try: - self._auto_reload_post_update = bool(value) - except: - raise ValueError("auto_reload_post_update must be a boolean value") - - @property - def backup_current(self): - return self._backup_current - - @backup_current.setter - def backup_current(self, value): - if value is None: - self._backup_current = False - return - else: - self._backup_current = value - - @property - def backup_ignore_patterns(self): - return self._backup_ignore_patterns - - @backup_ignore_patterns.setter - def backup_ignore_patterns(self, value): - if value is None: - self._backup_ignore_patterns = None - return - elif not isinstance(value, list): - raise ValueError("Backup pattern must be in list format") - else: - self._backup_ignore_patterns = value - - @property - def check_interval(self): - return (self._check_interval_enable, - self._check_interval_months, - self._check_interval_days, - self._check_interval_hours, - self._check_interval_minutes) - - @property - def current_version(self): - return self._current_version - - @current_version.setter - def current_version(self, tuple_values): - if tuple_values is None: - self._current_version = None - return - elif type(tuple_values) is not tuple: - try: - tuple(tuple_values) - except: - raise ValueError( - "Not a tuple! current_version must be a tuple of integers") - for i in tuple_values: - if type(i) is not int: - raise ValueError( - "Not an integer! current_version must be a tuple of integers") - self._current_version = tuple(tuple_values) - - @property - def engine(self): - return self._engine.name - - @engine.setter - def engine(self, value): - if value.lower() is "github": - self._engine = GithubEngine() - elif value.lower() is "gitlab": - self._engine = GitlabEngine() - elif value.lower() is "bitbucket": - self._engine = BitbucketEngine() - else: - raise ValueError("Invalid engine selection") - - @property - def error(self): - return self._error - - @property - def error_msg(self): - return self._error_msg - - @property - def fake_install(self): - return self._fake_install - - @fake_install.setter - def fake_install(self, value): - if not isinstance(value, bool): - raise ValueError("fake_install must be a boolean value") - self._fake_install = bool(value) - - # not currently used - @property - def include_branch_auto_check(self): - return self._include_branch_auto_check - - @include_branch_auto_check.setter - def include_branch_auto_check(self, value): - try: - self._include_branch_auto_check = bool(value) - except: - raise ValueError("include_branch_autocheck must be a boolean value") - - @property - def include_branch_list(self): - return self._include_branch_list - - @include_branch_list.setter - def include_branch_list(self, value): - try: - if value is None: - 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") - else: - self._include_branch_list = value - except: - raise ValueError("include_branch_list should be a list of valid branches") - - @property - def include_branches(self): - return self._include_branches - - @include_branches.setter - def include_branches(self, value): - try: - self._include_branches = bool(value) - except: - raise ValueError("include_branches must be a boolean value") - - @property - def json(self): - if len(self._json) == 0: - self.set_updater_json() - return self._json - - @property - def latest_release(self): - if self._latest_release is None: - return None - return self._latest_release - - @property - def manual_only(self): - return self._manual_only - - @manual_only.setter - def manual_only(self, value): - try: - self._manual_only = bool(value) - except: - raise ValueError("manual_only must be a boolean value") - - @property - def overwrite_patterns(self): - return self._overwrite_patterns - - @overwrite_patterns.setter - def overwrite_patterns(self, value): - if value is None: - self._overwrite_patterns = ["*.py", "*.pyc"] - elif not isinstance(value, list): - raise ValueError("overwrite_patterns needs to be in a list format") - else: - self._overwrite_patterns = value - - @property - def private_token(self): - return self._engine.token - - @private_token.setter - def private_token(self, value): - if value is None: - self._engine.token = None - else: - self._engine.token = str(value) - - @property - def remove_pre_update_patterns(self): - return self._remove_pre_update_patterns - - @remove_pre_update_patterns.setter - def remove_pre_update_patterns(self, value): - 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") - else: - self._remove_pre_update_patterns = value - - @property - def repo(self): - return self._repo - - @repo.setter - def repo(self, value): - try: - self._repo = str(value) - except: - raise ValueError("repo must be a string value") - - @property - def select_link(self): - return self._select_link - - @select_link.setter - def select_link(self, value): - # ensure it is a function assignment, with signature: - # input self, tag; returns link name - if not hasattr(value, "__call__"): - raise ValueError("select_link must be a function") - self._select_link = value - - @property - def stage_path(self): - return self._updater_path - - @stage_path.setter - def stage_path(self, value): - if value is None: - if self._verbose: - print("Aborting assigning stage_path, it's null") - return - elif value is not None and not os.path.exists(value): - try: - os.makedirs(value) - except: - if self._verbose: - print("Error trying to staging path") - self.print_trace() - return - self._updater_path = value - - @property - def subfolder_path(self): - return self._subfolder_path - - @subfolder_path.setter - def subfolder_path(self, value): - self._subfolder_path = value - - @property - def tags(self): - if len(self._tags) == 0: - return list() - tag_names = list() - for tag in self._tags: - tag_names.append(tag["name"]) - return tag_names - - @property - def tag_latest(self): - if self._tag_latest is None: - return None - return self._tag_latest["name"] - - @property - def update_link(self): - return self._update_link - - @property - def update_ready(self): - return self._update_ready - - @property - def update_version(self): - return self._update_version - - @property - def use_releases(self): - return self._use_releases - - @use_releases.setter - def use_releases(self, value): - try: - self._use_releases = bool(value) - except: - raise ValueError("use_releases must be a boolean value") - - @property - def user(self): - return self._user - - @user.setter - def user(self, value): - try: - self._user = str(value) - except: - raise ValueError("User must be a string value") - - @property - def verbose(self): - return self._verbose - - @verbose.setter - def verbose(self, value): - try: - self._verbose = bool(value) - if self._verbose: - print(self._addon + " updater verbose is enabled") - except: - raise ValueError("Verbose must be a boolean value") - - @property - def use_print_traces(self): - return self._use_print_traces - - @use_print_traces.setter - def use_print_traces(self, value): - try: - self._use_print_traces = bool(value) - except: - raise ValueError("use_print_traces must be a boolean value") - - @property - def version_max_update(self): - return self._version_max_update - - @version_max_update.setter - def version_max_update(self, value): - if value is None: - self._version_max_update = None - return - if not isinstance(value, tuple): - raise ValueError("Version maximum must be a tuple") - for subvalue in value: - if type(subvalue) is not int: - raise ValueError("Version elements must be integers") - self._version_max_update = value - - @property - def version_min_update(self): - return self._version_min_update - - @version_min_update.setter - def version_min_update(self, value): - if value is None: - self._version_min_update = None - return - if not isinstance(value, tuple): - raise ValueError("Version minimum must be a tuple") - for subvalue in value: - if type(subvalue) != int: - raise ValueError("Version elements must be integers") - self._version_min_update = value - - @property - def website(self): - return self._website - - @website.setter - def website(self, value): - if not self.check_is_url(value): - raise ValueError("Not a valid URL: " + value) - self._website = value - - # ------------------------------------------------------------------------- - # Parameter validation related functions - # ------------------------------------------------------------------------- - @staticmethod - def check_is_url(url): - if not ("http://" in url or "https://" in url): - return False - if "." not in url: - return False - return True - - def _get_tag_names(self): - tag_names = list() - self.get_tags() - for tag in self._tags: - tag_names.append(tag["name"]) - return tag_names - - def set_check_interval(self, enabled=False, months=0, days=14, hours=0, minutes=0): - # enabled = False, default initially will not check against frequency - # if enabled, default is then 2 weeks - - if type(enabled) is not bool: - raise ValueError("Enable must be a boolean value") - if type(months) is not int: - raise ValueError("Months must be an integer value") - if type(days) is not int: - raise ValueError("Days must be an integer value") - if type(hours) is not int: - raise ValueError("Hours must be an integer value") - if type(minutes) is not int: - raise ValueError("Minutes must be an integer value") - - if not enabled: - self._check_interval_enable = False - else: - self._check_interval_enable = True - - self._check_interval_months = months - self._check_interval_days = days - self._check_interval_hours = hours - self._check_interval_minutes = minutes - - # declare how the class gets printed - - def __repr__(self): - return "".format(a=__file__) - - def __str__(self): - return "Updater, with user: {a}, repository: {b}, url: {c}".format( - a=self._user, - b=self._repo, c=self.form_repo_url()) - - # ------------------------------------------------------------------------- - # API-related functions - # ------------------------------------------------------------------------- - def form_repo_url(self): - return self._engine.form_repo_url(self) - - def form_tags_url(self): - return self._engine.form_tags_url(self) - - def form_branch_url(self, branch): - return self._engine.form_branch_url(branch, self) - - def get_tags(self): - request = self.form_tags_url() - if self._verbose: - print("Getting tags from server") - - # get all tags, internet call - all_tags = self._engine.parse_tags(self.get_api(request), self) - if all_tags is not None: - self._prefiltered_tag_count = len(all_tags) - else: - self._prefiltered_tag_count = 0 - all_tags = list() - - # pre-process to skip tags - if self.skip_tag is not None: - self._tags = [tg for tg in all_tags if not self.skip_tag(self, tg)] - else: - self._tags = all_tags - - # get additional branches too, if needed, and place in front - # Does NO checking here whether branch is valid - if self._include_branches: - temp_branches = self._include_branch_list.copy() - temp_branches.reverse() - for branch in temp_branches: - request = self.form_branch_url(branch) - include = { - "name": branch.title(), - "zipball_url": request - } - self._tags = [include] + self._tags # append to front - - if self._tags is None: - # some error occurred - self._tag_latest = None - self._tags = list() - return - elif self._prefiltered_tag_count == 0 and not self._include_branches: - self._tag_latest = None - if self._error is None: # if not None, could have had no internet - self._error = "No releases found" - self._error_msg = "No releases or tags found on this repository" - if self._verbose: - print("No releases or tags found on this repository") - elif self._prefiltered_tag_count == 0 and self._include_branches: - if not self._error: - self._tag_latest = self._tags[0] - if self._verbose: - branch = self._include_branch_list[0] - print("{} 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: - self._tag_latest = None - self._error = "No releases available" - self._error_msg = "No versions found within compatible version range" - if self._verbose: - print("No versions found within compatible version range") - else: - if not self._include_branches: - self._tag_latest = self._tags[0] - if self._verbose: - print("Most recent tag found:", 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 - if self._verbose: - print("Most recent tag found:", self._tags[n]['name']) - - # all API calls to base url - def get_raw(self, url): - # print("Raw request:", url) - request = urllib.request.Request(url) - try: - context = ssl._create_unverified_context() - except: - # some blender packaged python versions don't have this, largely - # useful for local network setups otherwise minimal impact - context = None - - # 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) - else: - if self._verbose: - print("Tokens not setup for engine yet") - - # Always set user agent - request.add_header('User-Agent', "Python/" + str(platform.python_version())) - - # run the request - try: - if context: - result = urllib.request.urlopen(request, context=context) - else: - result = urllib.request.urlopen(request) - except urllib.error.HTTPError as e: - if str(e.code) == "403": - self._error = "HTTP error (access denied)" - self._error_msg = str(e.code) + " - server error response" - print(self._error, self._error_msg) - else: - self._error = "HTTP error" - self._error_msg = str(e.code) - print(self._error, self._error_msg) - self.print_trace() - self._update_ready = None - except urllib.error.URLError as e: - reason = str(e.reason) - if "TLSV1_ALERT" in reason or "SSL" in reason.upper(): - self._error = "Connection rejected, download manually" - self._error_msg = reason - print(self._error, self._error_msg) - else: - self._error = "URL error, check internet connection" - self._error_msg = reason - print(self._error, self._error_msg) - self.print_trace() - self._update_ready = None - return None - else: - result_string = result.read() - result.close() - return result_string.decode() - - # result of all api calls, decoded into json format - def get_api(self, url): - # return the json version - get = None - get = self.get_raw(url) - if get is not None: - try: - return json.JSONDecoder().decode(get) - except Exception as e: - self._error = "API response has invalid JSON format" - self._error_msg = str(e.reason) - self._update_ready = None - print(self._error, self._error_msg) - self.print_trace() - return None - else: - return None - - # create a working directory and download the new files - def stage_repository(self, url): - - local = os.path.join(self._updater_path, "update_staging") - error = None - - # make/clear the staging folder - # ensure the folder is always "clean" - if self._verbose: - print("Preparing staging folder for download:\n", local) - if os.path.isdir(local): - try: - shutil.rmtree(local) - os.makedirs(local) - except: - error = "failed to remove existing staging directory" - self.print_trace() - else: - try: - os.makedirs(local) - except: - error = "failed to create staging directory" - self.print_trace() - - if error is not None: - if self._verbose: - print("Error: Aborting update, " + error) - self._error = "Update aborted, staging path error" - self._error_msg = "Error: {}".format(error) - return False - - if self._backup_current: - self.create_backup() - - if self._verbose: - print("Now retrieving the new source zip") - - self._source_zip = os.path.join(local, "source.zip") - - if self._verbose: - print("Starting download update zip") - try: - request = urllib.request.Request(url) - context = ssl._create_unverified_context() - - # 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) - else: - if self._verbose: - print("Tokens not setup for selected engine yet") - - # Always set user agent - request.add_header('User-Agent', "Python/" + str(platform.python_version())) - - self.url_retrieve(urllib.request.urlopen(request, context=context), self._source_zip) - # add additional checks on file size being non-zero - if self._verbose: - print("Successfully downloaded update zip") - return True - except Exception as e: - self._error = "Error retrieving download, bad link?" - self._error_msg = "Error: {}".format(e) - if self._verbose: - print("Error retrieving download, bad link?") - print("Error: {}".format(e)) - self.print_trace() - return False - - def create_backup(self): - if self._verbose: - print("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") - - if self._verbose: - print("Backup destination path: ", local) - - if os.path.isdir(local): - try: - shutil.rmtree(local) - except: - if self._verbose: - print("Failed to removed previous backup folder, continuing") - self.print_trace() - - # remove the temp folder; shouldn't exist but could if previously interrupted - if os.path.isdir(tempdest): - try: - shutil.rmtree(tempdest) - except: - if self._verbose: - print("Failed to remove existing temp folder, continuing") - self.print_trace() - - # make the full addon copy, which temporarily places outside the addon folder - if self._backup_ignore_patterns is not None: - shutil.copytree( - self._addon_root, tempdest, - ignore=shutil.ignore_patterns(*self._backup_ignore_patterns)) - else: - shutil.copytree(self._addon_root, tempdest) - shutil.move(tempdest, local) - - # save the date for future ref - now = datetime.now() - self._json["backup_date"] = "{m}-{d}-{yr}".format( - m=now.strftime("%B"), d=now.day, yr=now.year) - self.save_updater_json() - - def restore_backup(self): - if self._verbose: - print("Restoring backup") - - if self._verbose: - print("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") - tempdest = os.path.abspath(tempdest) - - # make the copy - shutil.move(backuploc, tempdest) - shutil.rmtree(self._addon_root) - os.rename(tempdest, self._addon_root) - - self._json["backup_date"] = "" - self._json["just_restored"] = True - self._json["just_updated"] = True - self.save_updater_json() - - self.reload_addon() - - def unpack_staged_zip(self, clean=False): - """Unzip the downloaded file, and validate contents""" - if not os.path.isfile(self._source_zip): - if self._verbose: - print("Error, update zip not found") - self._error = "Install failed" - self._error_msg = "Downloaded zip not found" - return -1 - - # clear the existing source folder in case previous files remain - outdir = os.path.join(self._updater_path, "source") - try: - shutil.rmtree(outdir) - if self._verbose: - print("Source folder cleared") - except: - self.print_trace() - - # Create parent directories if needed, would not be relevant unless - # installing addon into another location or via an addon manager - try: - os.mkdir(outdir) - except Exception as err: - print("Error occurred while making extract dir:") - print(str(err)) - self.print_trace() - self._error = "Install failed" - self._error_msg = "Failed to make extract directory" - return -1 - - if not os.path.isdir(outdir): - print("Failed to create source directory") - self._error = "Install failed" - self._error_msg = "Failed to create extract directory" - return -1 - - if self._verbose: - print("Begin extracting source from zip:", self._source_zip) - zfile = zipfile.ZipFile(self._source_zip, "r") - - if not zfile: - if self._verbose: - print("Resulting file is not a zip, cannot extract") - self._error = "Install failed" - self._error_msg = "Resulting file is not a zip, cannot extract" - return -1 - - # 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 = '/' # os.sep # might just always be / even on windows - for name in zfile.namelist(): - if zsep not in name: - continue - top_folder = name[:name.index(zsep) + 1] - if name == top_folder + zsep: - continue # skip top level folder - sub_path = name[name.index(zsep) + 1:] - if name.endswith(zsep): - try: - os.mkdir(os.path.join(outdir, sub_path)) - if self._verbose: - print("Extract - mkdir: ", os.path.join(outdir, sub_path)) - except OSError as exc: - if exc.errno != errno.EEXIST: - self._error = "Install failed" - self._error_msg = "Could not create folder from zip" - self.print_trace() - return -1 - else: - with open(os.path.join(outdir, sub_path), "wb") as outfile: - data = zfile.read(name) - outfile.write(data) - if self._verbose: - print("Extract - create:", os.path.join(outdir, sub_path)) - - if self._verbose: - print("Extracted source") - - unpath = os.path.join(self._updater_path, "source") - if not os.path.isdir(unpath): - self._error = "Install failed" - self._error_msg = "Extracted path does not exist" - print("Extracted path does not exist: ", unpath) - return -1 - - if self._subfolder_path: - 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")): - dirlist = os.listdir(unpath) - if len(dirlist) > 0: - if self._subfolder_path == "" or self._subfolder_path is None: - unpath = os.path.join(unpath, dirlist[0]) - else: - unpath = os.path.join(unpath, self._subfolder_path) - - # smarter check for additional sub folders for a single folder - # containing __init__.py - if not os.path.isfile(os.path.join(unpath, "__init__.py")): - if self._verbose: - print("not a valid addon found") - print("Paths:") - print(dirlist) - self._error = "Install failed" - self._error_msg = "No __init__ file found in new source" - return -1 - - # merge code with running addon directory, using blender default behavior - # plus any modifiers indicated by user (e.g. force remove/keep) - self.deep_merge_directory(self._addon_root, unpath, clean) - - # Now save the json state - # Change to True, to trigger the handler on other side - # if allowing reloading within same blender instance - self._json["just_updated"] = True - self.save_updater_json() - self.reload_addon() - self._update_ready = False - return 0 - - def deep_merge_directory(self, base, merger, clean=False): - """Merge folder 'merger' into folder 'base' without deleting existing""" - if not os.path.exists(base): - if self._verbose: - print("Base path does not exist:", base) - return -1 - elif not os.path.exists(merger): - if self._verbose: - print("Merger path does not exist") - return -1 - - # paths to be aware of and not overwrite/remove/etc - staging_path = os.path.join(self._updater_path, "update_staging") - backup_path = os.path.join(self._updater_path, "backup") - - # If clean install is enabled, clear existing files ahead of time - # note: will not delete the update.json, update folder, staging, or staging - # but will delete all other folders/files in addon directory - error = None - if clean: - try: - # implement clearing of all folders/files, except the - # updater folder and updater json - # Careful, this deletes entire subdirectories recursively... - # make sure that base is not a high level shared folder, but - # is dedicated just to the addon itself - if self._verbose: - print("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))] - - for f in files: - os.remove(os.path.join(base, f)) - print("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)) - print("Clean removing folder and contents {}".format(os.path.join(base, f))) - - except Exception as err: - error = "failed to create clean existing addon folder" - print(error, str(err)) - self.print_trace() - - # Walk through the base addon folder for rules on pre-removing - # 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]] - for file in files: - for pattern in self.remove_pre_update_patterns: - if fnmatch.filter([file], pattern): - try: - fl = os.path.join(path, file) - os.remove(fl) - if self._verbose: - print("Pre-removed file " + file) - except OSError: - print("Failed to pre-remove " + file) - self.print_trace() - - # Walk through the temp addon sub folder for replacements - # this implements the overwrite rules, which apply after - # the above pre-removal rules. This also performs the - # actual file copying/replacements - for path, dirs, files in os.walk(merger): - # verify this 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]] - rel_path = os.path.relpath(path, merger) - dest_path = os.path.join(base, rel_path) - if not os.path.exists(dest_path): - os.makedirs(dest_path) - for file in files: - # bring in additional logic around copying/replacing - # Blender default: overwrite .py's, don't overwrite the rest - dest_file = os.path.join(dest_path, file) - srcFile = os.path.join(path, file) - - # decide whether to replace if file already exists, and copy new over - if os.path.isfile(dest_file): - # otherwise, check each file to see if matches an overwrite pattern - replaced = False - for pattern in self._overwrite_patterns: - if fnmatch.filter([file], pattern): - replaced = True - break - if replaced: - os.remove(dest_file) - os.rename(srcFile, dest_file) - if self._verbose: - print("Overwrote file " + os.path.basename(dest_file)) - else: - if self._verbose: - print("Pattern not matched to " + os.path.basename(dest_file) + ", not overwritten") - else: - # file did not previously exist, simply move it over - os.rename(srcFile, dest_file) - if self._verbose: - print("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 - if self._verbose: - print(error) - self.print_trace() - - def reload_addon(self): - # if post_update false, skip this function - # else, unload/reload addon & trigger popup - if not self._auto_reload_post_update: - print("Restart blender to reload addon and complete update") - return - - if self._verbose: - print("Reloading addon...") - addon_utils.modules(refresh=True) - bpy.utils.refresh_script_paths() - - # not allowed in restricted context, such as register module - # toggle to refresh - if "addon_disable" in dir(bpy.ops.wm): # 2.7 - bpy.ops.wm.addon_disable(module=self._addon_package) - bpy.ops.wm.addon_refresh() - bpy.ops.wm.addon_enable(module=self._addon_package) - print("2.7 reload complete") - else: # 2.8 - bpy.ops.preferences.addon_disable(module=self._addon_package) - bpy.ops.preferences.addon_refresh() - bpy.ops.preferences.addon_enable(module=self._addon_package) - print("2.8 reload complete") - - # ------------------------------------------------------------------------- - # Other non-api functions and setups - # ------------------------------------------------------------------------- - def clear_state(self): - self._update_ready = None - self._update_link = None - self._update_version = None - self._source_zip = None - self._error = None - self._error_msg = None - - # custom urlretrieve implementation - def url_retrieve(self, url_file, filepath): - chunk = 1024 * 8 - f = open(filepath, "wb") - while 1: - data = url_file.read(chunk) - if not data: - # print("done.") - break - f.write(data) - # print("Read %s bytes" % len(data)) - f.close() - - def version_tuple_from_text(self, text): - if text is None: - return () - - # should go through string and remove all non-integers, - # and for any given break split into a different section - segments = list() - tmp = '' - for char in str(text): - if not char.isdigit(): - if len(tmp) > 0: - segments.append(int(tmp)) - tmp = '' - else: - tmp += char - if len(tmp) > 0: - segments.append(int(tmp)) - - if len(segments) == 0: - if self._verbose: - print("No version strings found text: ", text) - if not self._include_branches: - return () - else: - return (text) - return tuple(segments) - - # called for running check in a background thread - def check_for_update_async(self, callback=None): - - if self._json is not None and "update_ready" in self._json and self._json["version_text"] is not dict(): - if self._json["update_ready"]: - self._update_ready = True - self._update_link = self._json["version_text"]["link"] - self._update_version = str(self._json["version_text"]["version"]) - # cached update - callback(True) - return - - # do the check - if not self._check_interval_enable: - return - elif self._async_checking: - if self._verbose: - print("Skipping async check, already started") - return # already running the bg thread - elif self._update_ready is None: - self.start_async_check_update(False, callback) - - def check_for_update_now(self, callback=None): - - self._error = None - self._error_msg = None - - if self._verbose: - print("Check update pressed, first getting current status") - if self._async_checking: - if self._verbose: - print("Skipping async check, already started") - return # already running the bg thread - elif self._update_ready is None: - self.start_async_check_update(True, callback) - else: - self._update_ready = None - self.start_async_check_update(True, callback) - - # this function is not async, will always return in sequential fashion - # but should have a parent which calls it in another thread - def check_for_update(self, now=False): - if self._verbose: - print("Checking for update function") - - # clear the errors if any - self._error = None - self._error_msg = None - - # 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) - - if self._current_version is None: - raise ValueError("current_version not yet defined") - - if self._repo is None: - raise ValueError("repo not yet defined") - - if self._user is None: - raise ValueError("username not yet defined") - - self.set_updater_json() # self._json - - if not now and not self.past_interval_timestamp(): - if self._verbose: - print("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: - if self._verbose: - print("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) - - # primary internet call - self.get_tags() # sets self._tags and self._tag_latest - - self._json["last_check"] = str(datetime.now()) - self.save_updater_json() - - # can be () or ('master') in addition to branches, and version tag - new_version = self.version_tuple_from_text(self.tag_latest) - - if len(self._tags) == 0: - self._update_ready = False - self._update_version = None - self._update_link = None - return (False, None, None) - - if not self._include_branches: - link = self.select_link(self, self._tags[0]) - else: - n = len(self._include_branch_list) - if len(self._tags) == n: - # effectively means no tags found on repo - # so provide the first one as default - link = self.select_link(self, self._tags[0]) - else: - link = self.select_link(self, self._tags[n]) - - if new_version == (): - self._update_ready = False - self._update_version = None - self._update_link = None - return (False, None, None) - elif str(new_version).lower() in self._include_branch_list: - # handle situation where master/whichever branch is included - # however, this code effectively is not triggered now - # as new_version will only be tag names, not branch names - if not self._include_branch_auto_check: - # don't offer update as ready, - # but set the link for the default - # branch for installing - self._update_ready = False - self._update_version = new_version - self._update_link = link - self.save_updater_json() - return (True, new_version, link) - else: - raise ValueError("include_branch_autocheck: NOT YET DEVELOPED") - # bypass releases and look at timestamp of last update - # from a branch compared to now, see if commit values - # match or not. - - 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 - self.save_updater_json() - return (True, new_version, link) - - # elif new_version != self._current_version: - # self._update_ready = False - # self._update_version = new_version - # self._update_link = link - # self.save_updater_json() - # return (True, new_version, link) - - # if no update, set ready to False from None - self._update_ready = False - self._update_version = None - self._update_link = None - return (False, None, None) - - def set_tag(self, name): - """Assign the tag name and url to update to""" - tg = None - for tag in self._tags: - if name == tag["name"]: - tg = tag - break - if tg: - new_version = self.version_tuple_from_text(self.tag_latest) - self._update_version = new_version - self._update_link = self.select_link(self, tg) - elif self._include_branches and name in self._include_branch_list: - # scenario if reverting to a specific branch name instead of tag - tg = name - link = self.form_branch_url(tg) - self._update_version = name # this will break things - self._update_link = link - if not tg: - raise ValueError("Version tag not found: "+name) - - def run_update(self, force=False, revert_tag=None, clean=False, callback=None): - """Runs an install, update, or reversion of an addon from online source - - Arguments: - force: Install assigned link, even if self.update_ready is False - revert_tag: Version to install, if none uses detected update link - clean: not used, but in future could use to totally refresh addon - callback: used to run function on update completion - """ - self._json["update_ready"] = False - self._json["ignore"] = False # clear ignore flag - self._json["version_text"] = dict() - - if revert_tag is not None: - self.set_tag(revert_tag) - self._update_ready = True - - # clear the errors if any - self._error = None - self._error_msg = None - - if self._verbose: - print("Running update") - - if self._fake_install: - # change to True, to trigger the reload/"update installed" handler - if self._verbose: - print("fake_install=True") - print("Just reloading and running any handler triggers") - self._json["just_updated"] = True - self.save_updater_json() - if self._backup_current is True: - self.create_backup() - self.reload_addon() - self._update_ready = False - res = True # fake "success" zip download flag - - elif not force: - if not self._update_ready: - if self._verbose: - print("Update stopped, new version not ready") - if callback: - callback( - 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 - if self._verbose: - print("Update stopped, update link unavailable") - if callback: - callback(self._addon_package, - "Update stopped, update link unavailable") - return "Update stopped, update link unavailable" - - if self._verbose and revert_tag is None: - print("Staging update") - elif self._verbose: - print("Staging install") - - res = self.stage_repository(self._update_link) - if not res: - print("Error in staging repository: " + str(res)) - if callback is not None: - callback(self._addon_package, self._error_msg) - return self._error_msg - res = self.unpack_staged_zip(clean) - if res < 0: - if callback: - callback(self._addon_package, self._error_msg) - return res - - else: - if self._update_link is None: - if self._verbose: - print("Update stopped, could not get link") - return "Update stopped, could not get link" - if self._verbose: - print("Forcing update") - - res = self.stage_repository(self._update_link) - if not res: - print("Error in staging repository: " + str(res)) - if callback: - callback(self._addon_package, self._error_msg) - return self._error_msg - res = self.unpack_staged_zip(clean) - if res < 0: - return res - # would need to compare against other versions held in tags - - # run the front-end's callback if provided - if callback: - callback(self._addon_package) - - # return something meaningful, 0 means it worked - return 0 - - def past_interval_timestamp(self): - if not self._check_interval_enable: - return True # ie this exact feature is disabled - - if "last_check" not in self._json or self._json["last_check"] == "": - return True - - now = datetime.now() - last_check = datetime.strptime(self._json["last_check"], - "%Y-%m-%d %H:%M:%S.%f") - next_check = last_check - offset = timedelta( - days=self._check_interval_days + 30 * self._check_interval_months, - hours=self._check_interval_hours, - minutes=self._check_interval_minutes - ) - - delta = (now - offset) - last_check - if delta.total_seconds() > 0: - if self._verbose: - print("{} Updater: Time to check for updates!".format(self._addon)) - return True - - if self._verbose: - print("{} Updater: Determined it's not yet time to check for updates".format(self._addon)) - return False - - def get_json_path(self): - """Returns the full path to the JSON state file used by this updater. - - 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)) - old_json_path = os.path.join(self._updater_path, "updater_status.json") - - # rename old file if it exists - try: - os.rename(old_json_path, json_path) - except FileNotFoundError: - pass - except Exception as err: - print("Other OS error occurred while trying to rename old JSON") - print(err) - self.print_trace() - return json_path - - def set_updater_json(self): - """Load or initialize JSON dictionary data for updater state""" - if self._updater_path is None: - raise ValueError("updater_path is not defined") - elif not os.path.isdir(self._updater_path): - os.makedirs(self._updater_path) - - jpath = self.get_json_path() - if os.path.isfile(jpath): - with open(jpath) as data_file: - self._json = json.load(data_file) - if self._verbose: - print("{} Updater: Read in JSON settings from file".format( - self._addon)) - else: - # set data structure - self._json = { - "last_check": "", - "backup_date": "", - "update_ready": False, - "ignore": False, - "just_restored": False, - "just_updated": False, - "version_text": dict() - } - self.save_updater_json() - - def save_updater_json(self): - # first save the state - if self._update_ready: - if isinstance(self._update_version, tuple): - self._json["update_ready"] = True - self._json["version_text"]["link"] = self._update_link - self._json["version_text"]["version"] = self._update_version - else: - self._json["update_ready"] = False - self._json["version_text"] = dict() - else: - self._json["update_ready"] = False - self._json["version_text"] = dict() - - jpath = self.get_json_path() - outf = open(jpath, 'w') - data_out = json.dumps(self._json, indent=4) - outf.write(data_out) - outf.close() - if self._verbose: - print(self._addon+": Wrote out updater JSON settings to file, with the contents:") - print(self._json) - - def json_reset_postupdate(self): - self._json["just_updated"] = False - self._json["update_ready"] = False - self._json["version_text"] = dict() - self.save_updater_json() - - def json_reset_restore(self): - self._json["just_restored"] = False - self._json["update_ready"] = False - self._json["version_text"] = dict() - self.save_updater_json() - self._update_ready = None # reset so you could check update again - - def ignore_update(self): - self._json["ignore"] = True - self.save_updater_json() - - # ------------------------------------------------------------------------- - # ASYNC stuff - # ------------------------------------------------------------------------- - def start_async_check_update(self, now=False, callback=None): - """Start a background thread which will check for updates""" - if self._async_checking: - return - if self._verbose: - print("{} updater: Starting background checking thread".format( - self._addon)) - check_thread = threading.Thread(target=self.async_check_update, - args=(now, callback,)) - check_thread.daemon = True - self._check_thread = check_thread - check_thread.start() - - def async_check_update(self, now, callback=None): - """Perform update check, run as target of background thread""" - self._async_checking = True - if self._verbose: - print("{} BG thread: Checking for update now in background".format( - self._addon)) - - try: - self.check_for_update(now=now) - except Exception as exception: - print("Checking for update error:") - print(exception) - self.print_trace() - if not self._error: - self._update_ready = False - self._update_version = None - self._update_link = None - self._error = "Error occurred" - self._error_msg = "Encountered an error while checking for updates" - - self._async_checking = False - self._check_thread = None - - if self._verbose: - print("{} BG thread: Finished checking for update, doing callback".format(self._addon)) - if callback: - callback(self._update_ready) - - def stop_async_check_update(self): - """Method to give impression of stopping check for update. - - Currently does nothing but allows user to retry/stop blocking UI from - hitting a refresh button. This does not actually stop the thread, as it - will complete after the connection timeout regardless. If the thread - does complete with a successful response, this will be still displayed - on next UI refresh (ie no update, or update available). - """ - if self._check_thread is not None: - if self._verbose: - print("Thread will end in normal course.") - # however, "There is no direct kill method on a thread object." - # better to let it run its course - # self._check_thread.stop() - self._async_checking = False - self._error = None - self._error_msg = None + """ + This is the singleton class to reference a copy from, + it is the shared module level class + """ + def __init__(self): + + self._engine = GithubEngine() + self._user = None + self._repo = None + self._website = None + self._current_version = None + self._subfolder_path = None + self._tags = list() + self._tag_latest = None + self._tag_names = list() + self._latest_release = None + self._use_releases = False + self._include_branches = False + self._include_branch_list = ['master'] + self._include_branch_auto_check = False + self._manual_only = False + self._version_min_update = None + self._version_max_update = None + + # by default, backup current addon if new is being loaded + self._backup_current = True + self._backup_ignore_patterns = None + + # set patterns for what files to overwrite on update + self._overwrite_patterns = ["*.py", "*.pyc"] + self._remove_pre_update_patterns = list() + + # by default, don't auto enable/disable the addon on update + # as it is slightly less stable/won't always fully reload module + self._auto_reload_post_update = False + + # settings relating to frequency and whether to enable auto background check + self._check_interval_enable = False + self._check_interval_months = 0 + self._check_interval_days = 7 + self._check_interval_hours = 0 + self._check_interval_minutes = 0 + + # runtime variables, initial conditions + self._verbose = False + self._use_print_traces = True + self._fake_install = False + self._async_checking = False # only true when async daemon started + self._update_ready = None + self._update_link = None + self._update_version = None + self._source_zip = None + self._check_thread = None + self._select_link = None + self.skip_tag = None + + # get from module data + self._addon = __package__.lower() + self._addon_package = __package__ # must not change + self._updater_path = os.path.join(os.path.dirname(__file__), self._addon + "_updater") + self._addon_root = os.path.dirname(__file__) + self._json = dict() + self._error = None + self._error_msg = None + self._prefiltered_tag_count = 0 + + # UI code only, ie not used within this module but still useful + # properties to have + + # to verify a valid import, in place of placeholder import + self.show_popups = True # used in UI to show or not show update popups + self.invalid_updater = False + + # pre-assign basic select-link function + def select_link_function(self, tag): + return tag["zipball_url"] + + self._select_link = select_link_function + + def print_trace(self): + """Print handled exception details when use_print_traces is set""" + if self._use_print_traces: + traceback.print_exc() + + # ------------------------------------------------------------------------- + # Getters and setters + # ------------------------------------------------------------------------- + @property + def addon(self): + return self._addon + + @addon.setter + def addon(self, value): + self._addon = str(value) + + @property + def api_url(self): + return self._engine.api_url + + @api_url.setter + def api_url(self, value): + if not self.check_is_url(value): + raise ValueError("Not a valid URL: " + value) + self._engine.api_url = value + + @property + def async_checking(self): + return self._async_checking + + @property + def auto_reload_post_update(self): + return self._auto_reload_post_update + + @auto_reload_post_update.setter + def auto_reload_post_update(self, value): + try: + self._auto_reload_post_update = bool(value) + except: + raise ValueError("auto_reload_post_update must be a boolean value") + + @property + def backup_current(self): + return self._backup_current + + @backup_current.setter + def backup_current(self, value): + if value is None: + self._backup_current = False + return + else: + self._backup_current = value + + @property + def backup_ignore_patterns(self): + return self._backup_ignore_patterns + + @backup_ignore_patterns.setter + def backup_ignore_patterns(self, value): + if value is None: + self._backup_ignore_patterns = None + return + elif not isinstance(value, list): + raise ValueError("Backup pattern must be in list format") + else: + self._backup_ignore_patterns = value + + @property + def check_interval(self): + return (self._check_interval_enable, + self._check_interval_months, + self._check_interval_days, + self._check_interval_hours, + self._check_interval_minutes) + + @property + def current_version(self): + return self._current_version + + @current_version.setter + def current_version(self, tuple_values): + if tuple_values is None: + self._current_version = None + return + elif type(tuple_values) is not tuple: + try: + tuple(tuple_values) + except: + raise ValueError( + "Not a tuple! current_version must be a tuple of integers") + for i in tuple_values: + if type(i) is not int: + raise ValueError( + "Not an integer! current_version must be a tuple of integers") + self._current_version = tuple(tuple_values) + + @property + def engine(self): + return self._engine.name + + @engine.setter + def engine(self, value): + if value.lower() is "github": + self._engine = GithubEngine() + elif value.lower() is "gitlab": + self._engine = GitlabEngine() + elif value.lower() is "bitbucket": + self._engine = BitbucketEngine() + else: + raise ValueError("Invalid engine selection") + + @property + def error(self): + return self._error + + @property + def error_msg(self): + return self._error_msg + + @property + def fake_install(self): + return self._fake_install + + @fake_install.setter + def fake_install(self, value): + if not isinstance(value, bool): + raise ValueError("fake_install must be a boolean value") + self._fake_install = bool(value) + + # not currently used + @property + def include_branch_auto_check(self): + return self._include_branch_auto_check + + @include_branch_auto_check.setter + def include_branch_auto_check(self, value): + try: + self._include_branch_auto_check = bool(value) + except: + raise ValueError("include_branch_autocheck must be a boolean value") + + @property + def include_branch_list(self): + return self._include_branch_list + + @include_branch_list.setter + def include_branch_list(self, value): + try: + if value is None: + 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") + else: + self._include_branch_list = value + except: + raise ValueError("include_branch_list should be a list of valid branches") + + @property + def include_branches(self): + return self._include_branches + + @include_branches.setter + def include_branches(self, value): + try: + self._include_branches = bool(value) + except: + raise ValueError("include_branches must be a boolean value") + + @property + def json(self): + if len(self._json) == 0: + self.set_updater_json() + return self._json + + @property + def latest_release(self): + if self._latest_release is None: + return None + return self._latest_release + + @property + def manual_only(self): + return self._manual_only + + @manual_only.setter + def manual_only(self, value): + try: + self._manual_only = bool(value) + except: + raise ValueError("manual_only must be a boolean value") + + @property + def overwrite_patterns(self): + return self._overwrite_patterns + + @overwrite_patterns.setter + def overwrite_patterns(self, value): + if value is None: + self._overwrite_patterns = ["*.py", "*.pyc"] + elif not isinstance(value, list): + raise ValueError("overwrite_patterns needs to be in a list format") + else: + self._overwrite_patterns = value + + @property + def private_token(self): + return self._engine.token + + @private_token.setter + def private_token(self, value): + if value is None: + self._engine.token = None + else: + self._engine.token = str(value) + + @property + def remove_pre_update_patterns(self): + return self._remove_pre_update_patterns + + @remove_pre_update_patterns.setter + def remove_pre_update_patterns(self, value): + 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") + else: + self._remove_pre_update_patterns = value + + @property + def repo(self): + return self._repo + + @repo.setter + def repo(self, value): + try: + self._repo = str(value) + except: + raise ValueError("repo must be a string value") + + @property + def select_link(self): + return self._select_link + + @select_link.setter + def select_link(self, value): + # ensure it is a function assignment, with signature: + # input self, tag; returns link name + if not hasattr(value, "__call__"): + raise ValueError("select_link must be a function") + self._select_link = value + + @property + def stage_path(self): + return self._updater_path + + @stage_path.setter + def stage_path(self, value): + if value is None: + if self._verbose: + print("Aborting assigning stage_path, it's null") + return + elif value is not None and not os.path.exists(value): + try: + os.makedirs(value) + except: + if self._verbose: + print("Error trying to staging path") + self.print_trace() + return + self._updater_path = value + + @property + def subfolder_path(self): + return self._subfolder_path + + @subfolder_path.setter + def subfolder_path(self, value): + self._subfolder_path = value + + @property + def tags(self): + if len(self._tags) == 0: + return list() + tag_names = list() + for tag in self._tags: + tag_names.append(tag["name"]) + return tag_names + + @property + def tag_latest(self): + if self._tag_latest is None: + return None + return self._tag_latest["name"] + + @property + def update_link(self): + return self._update_link + + @property + def update_ready(self): + return self._update_ready + + @property + def update_version(self): + return self._update_version + + @property + def use_releases(self): + return self._use_releases + + @use_releases.setter + def use_releases(self, value): + try: + self._use_releases = bool(value) + except: + raise ValueError("use_releases must be a boolean value") + + @property + def user(self): + return self._user + + @user.setter + def user(self, value): + try: + self._user = str(value) + except: + raise ValueError("User must be a string value") + + @property + def verbose(self): + return self._verbose + + @verbose.setter + def verbose(self, value): + try: + self._verbose = bool(value) + if self._verbose: + print(self._addon + " updater verbose is enabled") + except: + raise ValueError("Verbose must be a boolean value") + + @property + def use_print_traces(self): + return self._use_print_traces + + @use_print_traces.setter + def use_print_traces(self, value): + try: + self._use_print_traces = bool(value) + except: + raise ValueError("use_print_traces must be a boolean value") + + @property + def version_max_update(self): + return self._version_max_update + + @version_max_update.setter + def version_max_update(self, value): + if value is None: + self._version_max_update = None + return + if not isinstance(value, tuple): + raise ValueError("Version maximum must be a tuple") + for subvalue in value: + if type(subvalue) is not int: + raise ValueError("Version elements must be integers") + self._version_max_update = value + + @property + def version_min_update(self): + return self._version_min_update + + @version_min_update.setter + def version_min_update(self, value): + if value is None: + self._version_min_update = None + return + if not isinstance(value, tuple): + raise ValueError("Version minimum must be a tuple") + for subvalue in value: + if type(subvalue) != int: + raise ValueError("Version elements must be integers") + self._version_min_update = value + + @property + def website(self): + return self._website + + @website.setter + def website(self, value): + if not self.check_is_url(value): + raise ValueError("Not a valid URL: " + value) + self._website = value + + # ------------------------------------------------------------------------- + # Parameter validation related functions + # ------------------------------------------------------------------------- + @staticmethod + def check_is_url(url): + if not ("http://" in url or "https://" in url): + return False + if "." not in url: + return False + return True + + def _get_tag_names(self): + tag_names = list() + self.get_tags() + for tag in self._tags: + tag_names.append(tag["name"]) + return tag_names + + def set_check_interval(self, enabled=False, months=0, days=14, hours=0, minutes=0): + # enabled = False, default initially will not check against frequency + # if enabled, default is then 2 weeks + + if type(enabled) is not bool: + raise ValueError("Enable must be a boolean value") + if type(months) is not int: + raise ValueError("Months must be an integer value") + if type(days) is not int: + raise ValueError("Days must be an integer value") + if type(hours) is not int: + raise ValueError("Hours must be an integer value") + if type(minutes) is not int: + raise ValueError("Minutes must be an integer value") + + if not enabled: + self._check_interval_enable = False + else: + self._check_interval_enable = True + + self._check_interval_months = months + self._check_interval_days = days + self._check_interval_hours = hours + self._check_interval_minutes = minutes + + # declare how the class gets printed + + def __repr__(self): + return "".format(a=__file__) + + def __str__(self): + return "Updater, with user: {a}, repository: {b}, url: {c}".format( + a=self._user, + b=self._repo, c=self.form_repo_url()) + + # ------------------------------------------------------------------------- + # API-related functions + # ------------------------------------------------------------------------- + def form_repo_url(self): + return self._engine.form_repo_url(self) + + def form_tags_url(self): + return self._engine.form_tags_url(self) + + def form_branch_url(self, branch): + return self._engine.form_branch_url(branch, self) + + def get_tags(self): + request = self.form_tags_url() + if self._verbose: + print("Getting tags from server") + + # get all tags, internet call + all_tags = self._engine.parse_tags(self.get_api(request), self) + if all_tags is not None: + self._prefiltered_tag_count = len(all_tags) + else: + self._prefiltered_tag_count = 0 + all_tags = list() + + # pre-process to skip tags + if self.skip_tag is not None: + self._tags = [tg for tg in all_tags if not self.skip_tag(self, tg)] + else: + self._tags = all_tags + + # get additional branches too, if needed, and place in front + # Does NO checking here whether branch is valid + if self._include_branches: + temp_branches = self._include_branch_list.copy() + temp_branches.reverse() + for branch in temp_branches: + request = self.form_branch_url(branch) + include = { + "name": branch.title(), + "zipball_url": request + } + self._tags = [include] + self._tags # append to front + + if self._tags is None: + # some error occurred + self._tag_latest = None + self._tags = list() + return + elif self._prefiltered_tag_count == 0 and not self._include_branches: + self._tag_latest = None + if self._error is None: # if not None, could have had no internet + self._error = "No releases found" + self._error_msg = "No releases or tags found on this repository" + if self._verbose: + print("No releases or tags found on this repository") + elif self._prefiltered_tag_count == 0 and self._include_branches: + if not self._error: + self._tag_latest = self._tags[0] + if self._verbose: + branch = self._include_branch_list[0] + print("{} 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: + self._tag_latest = None + self._error = "No releases available" + self._error_msg = "No versions found within compatible version range" + if self._verbose: + print("No versions found within compatible version range") + else: + if not self._include_branches: + self._tag_latest = self._tags[0] + if self._verbose: + print("Most recent tag found:", 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 + if self._verbose: + print("Most recent tag found:", self._tags[n]['name']) + + # all API calls to base url + def get_raw(self, url): + # print("Raw request:", url) + request = urllib.request.Request(url) + try: + context = ssl._create_unverified_context() + except: + # some blender packaged python versions don't have this, largely + # useful for local network setups otherwise minimal impact + context = None + + # 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) + else: + if self._verbose: + print("Tokens not setup for engine yet") + + # Always set user agent + request.add_header('User-Agent', "Python/" + str(platform.python_version())) + + # run the request + try: + if context: + result = urllib.request.urlopen(request, context=context) + else: + result = urllib.request.urlopen(request) + except urllib.error.HTTPError as e: + if str(e.code) == "403": + self._error = "HTTP error (access denied)" + self._error_msg = str(e.code) + " - server error response" + print(self._error, self._error_msg) + else: + self._error = "HTTP error" + self._error_msg = str(e.code) + print(self._error, self._error_msg) + self.print_trace() + self._update_ready = None + except urllib.error.URLError as e: + reason = str(e.reason) + if "TLSV1_ALERT" in reason or "SSL" in reason.upper(): + self._error = "Connection rejected, download manually" + self._error_msg = reason + print(self._error, self._error_msg) + else: + self._error = "URL error, check internet connection" + self._error_msg = reason + print(self._error, self._error_msg) + self.print_trace() + self._update_ready = None + return None + else: + result_string = result.read() + result.close() + return result_string.decode() + + # result of all api calls, decoded into json format + def get_api(self, url): + # return the json version + get = None + get = self.get_raw(url) + if get is not None: + try: + return json.JSONDecoder().decode(get) + except Exception as e: + self._error = "API response has invalid JSON format" + self._error_msg = str(e.reason) + self._update_ready = None + print(self._error, self._error_msg) + self.print_trace() + return None + else: + return None + + # create a working directory and download the new files + def stage_repository(self, url): + + local = os.path.join(self._updater_path, "update_staging") + error = None + + # make/clear the staging folder + # ensure the folder is always "clean" + if self._verbose: + print("Preparing staging folder for download:\n", local) + if os.path.isdir(local): + try: + shutil.rmtree(local) + os.makedirs(local) + except: + error = "failed to remove existing staging directory" + self.print_trace() + else: + try: + os.makedirs(local) + except: + error = "failed to create staging directory" + self.print_trace() + + if error is not None: + if self._verbose: + print("Error: Aborting update, " + error) + self._error = "Update aborted, staging path error" + self._error_msg = "Error: {}".format(error) + return False + + if self._backup_current: + self.create_backup() + + if self._verbose: + print("Now retrieving the new source zip") + + self._source_zip = os.path.join(local, "source.zip") + + if self._verbose: + print("Starting download update zip") + try: + request = urllib.request.Request(url) + context = ssl._create_unverified_context() + + # 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) + else: + if self._verbose: + print("Tokens not setup for selected engine yet") + + # Always set user agent + request.add_header('User-Agent', "Python/" + str(platform.python_version())) + + self.url_retrieve(urllib.request.urlopen(request, context=context), self._source_zip) + # add additional checks on file size being non-zero + if self._verbose: + print("Successfully downloaded update zip") + return True + except Exception as e: + self._error = "Error retrieving download, bad link?" + self._error_msg = "Error: {}".format(e) + if self._verbose: + print("Error retrieving download, bad link?") + print("Error: {}".format(e)) + self.print_trace() + return False + + def create_backup(self): + if self._verbose: + print("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") + + if self._verbose: + print("Backup destination path: ", local) + + if os.path.isdir(local): + try: + shutil.rmtree(local) + except: + if self._verbose: + print("Failed to removed previous backup folder, continuing") + self.print_trace() + + # remove the temp folder; shouldn't exist but could if previously interrupted + if os.path.isdir(tempdest): + try: + shutil.rmtree(tempdest) + except: + if self._verbose: + print("Failed to remove existing temp folder, continuing") + self.print_trace() + + # make the full addon copy, which temporarily places outside the addon folder + if self._backup_ignore_patterns is not None: + shutil.copytree( + self._addon_root, tempdest, + ignore=shutil.ignore_patterns(*self._backup_ignore_patterns)) + else: + shutil.copytree(self._addon_root, tempdest) + shutil.move(tempdest, local) + + # save the date for future ref + now = datetime.now() + self._json["backup_date"] = "{m}-{d}-{yr}".format( + m=now.strftime("%B"), d=now.day, yr=now.year) + self.save_updater_json() + + def restore_backup(self): + if self._verbose: + print("Restoring backup") + + if self._verbose: + print("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") + tempdest = os.path.abspath(tempdest) + + # make the copy + shutil.move(backuploc, tempdest) + shutil.rmtree(self._addon_root) + os.rename(tempdest, self._addon_root) + + self._json["backup_date"] = "" + self._json["just_restored"] = True + self._json["just_updated"] = True + self.save_updater_json() + + self.reload_addon() + + def unpack_staged_zip(self, clean=False): + """Unzip the downloaded file, and validate contents""" + if not os.path.isfile(self._source_zip): + if self._verbose: + print("Error, update zip not found") + self._error = "Install failed" + self._error_msg = "Downloaded zip not found" + return -1 + + # clear the existing source folder in case previous files remain + outdir = os.path.join(self._updater_path, "source") + try: + shutil.rmtree(outdir) + if self._verbose: + print("Source folder cleared") + except: + self.print_trace() + + # Create parent directories if needed, would not be relevant unless + # installing addon into another location or via an addon manager + try: + os.mkdir(outdir) + except Exception as err: + print("Error occurred while making extract dir:") + print(str(err)) + self.print_trace() + self._error = "Install failed" + self._error_msg = "Failed to make extract directory" + return -1 + + if not os.path.isdir(outdir): + print("Failed to create source directory") + self._error = "Install failed" + self._error_msg = "Failed to create extract directory" + return -1 + + if self._verbose: + print("Begin extracting source from zip:", self._source_zip) + zfile = zipfile.ZipFile(self._source_zip, "r") + + if not zfile: + if self._verbose: + print("Resulting file is not a zip, cannot extract") + self._error = "Install failed" + self._error_msg = "Resulting file is not a zip, cannot extract" + return -1 + + # 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 = '/' # os.sep # might just always be / even on windows + for name in zfile.namelist(): + if zsep not in name: + continue + top_folder = name[:name.index(zsep) + 1] + if name == top_folder + zsep: + continue # skip top level folder + sub_path = name[name.index(zsep) + 1:] + if name.endswith(zsep): + try: + os.mkdir(os.path.join(outdir, sub_path)) + if self._verbose: + print("Extract - mkdir: ", os.path.join(outdir, sub_path)) + except OSError as exc: + if exc.errno != errno.EEXIST: + self._error = "Install failed" + self._error_msg = "Could not create folder from zip" + self.print_trace() + return -1 + else: + with open(os.path.join(outdir, sub_path), "wb") as outfile: + data = zfile.read(name) + outfile.write(data) + if self._verbose: + print("Extract - create:", os.path.join(outdir, sub_path)) + + if self._verbose: + print("Extracted source") + + unpath = os.path.join(self._updater_path, "source") + if not os.path.isdir(unpath): + self._error = "Install failed" + self._error_msg = "Extracted path does not exist" + print("Extracted path does not exist: ", unpath) + return -1 + + if self._subfolder_path: + 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")): + dirlist = os.listdir(unpath) + if len(dirlist) > 0: + if self._subfolder_path == "" or self._subfolder_path is None: + unpath = os.path.join(unpath, dirlist[0]) + else: + unpath = os.path.join(unpath, self._subfolder_path) + + # smarter check for additional sub folders for a single folder + # containing __init__.py + if not os.path.isfile(os.path.join(unpath, "__init__.py")): + if self._verbose: + print("not a valid addon found") + print("Paths:") + print(dirlist) + self._error = "Install failed" + self._error_msg = "No __init__ file found in new source" + return -1 + + # merge code with running addon directory, using blender default behavior + # plus any modifiers indicated by user (e.g. force remove/keep) + self.deep_merge_directory(self._addon_root, unpath, clean) + + # Now save the json state + # Change to True, to trigger the handler on other side + # if allowing reloading within same blender instance + self._json["just_updated"] = True + self.save_updater_json() + self.reload_addon() + self._update_ready = False + return 0 + + def deep_merge_directory(self, base, merger, clean=False): + """Merge folder 'merger' into folder 'base' without deleting existing""" + if not os.path.exists(base): + if self._verbose: + print("Base path does not exist:", base) + return -1 + elif not os.path.exists(merger): + if self._verbose: + print("Merger path does not exist") + return -1 + + # paths to be aware of and not overwrite/remove/etc + staging_path = os.path.join(self._updater_path, "update_staging") + backup_path = os.path.join(self._updater_path, "backup") + + # If clean install is enabled, clear existing files ahead of time + # note: will not delete the update.json, update folder, staging, or staging + # but will delete all other folders/files in addon directory + error = None + if clean: + try: + # implement clearing of all folders/files, except the + # updater folder and updater json + # Careful, this deletes entire subdirectories recursively... + # make sure that base is not a high level shared folder, but + # is dedicated just to the addon itself + if self._verbose: + print("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))] + + for f in files: + os.remove(os.path.join(base, f)) + print("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)) + print("Clean removing folder and contents {}".format(os.path.join(base, f))) + + except Exception as err: + error = "failed to create clean existing addon folder" + print(error, str(err)) + self.print_trace() + + # Walk through the base addon folder for rules on pre-removing + # 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]] + for file in files: + for pattern in self.remove_pre_update_patterns: + if fnmatch.filter([file], pattern): + try: + fl = os.path.join(path, file) + os.remove(fl) + if self._verbose: + print("Pre-removed file " + file) + except OSError: + print("Failed to pre-remove " + file) + self.print_trace() + + # Walk through the temp addon sub folder for replacements + # this implements the overwrite rules, which apply after + # the above pre-removal rules. This also performs the + # actual file copying/replacements + for path, dirs, files in os.walk(merger): + # verify this 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]] + rel_path = os.path.relpath(path, merger) + dest_path = os.path.join(base, rel_path) + if not os.path.exists(dest_path): + os.makedirs(dest_path) + for file in files: + # bring in additional logic around copying/replacing + # Blender default: overwrite .py's, don't overwrite the rest + dest_file = os.path.join(dest_path, file) + srcFile = os.path.join(path, file) + + # decide whether to replace if file already exists, and copy new over + if os.path.isfile(dest_file): + # otherwise, check each file to see if matches an overwrite pattern + replaced = False + for pattern in self._overwrite_patterns: + if fnmatch.filter([file], pattern): + replaced = True + break + if replaced: + os.remove(dest_file) + os.rename(srcFile, dest_file) + if self._verbose: + print("Overwrote file " + os.path.basename(dest_file)) + else: + if self._verbose: + print("Pattern not matched to " + os.path.basename(dest_file) + ", not overwritten") + else: + # file did not previously exist, simply move it over + os.rename(srcFile, dest_file) + if self._verbose: + print("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 + if self._verbose: + print(error) + self.print_trace() + + def reload_addon(self): + # if post_update false, skip this function + # else, unload/reload addon & trigger popup + if not self._auto_reload_post_update: + print("Restart blender to reload addon and complete update") + return + + if self._verbose: + print("Reloading addon...") + addon_utils.modules(refresh=True) + bpy.utils.refresh_script_paths() + + # not allowed in restricted context, such as register module + # toggle to refresh + if "addon_disable" in dir(bpy.ops.wm): # 2.7 + bpy.ops.wm.addon_disable(module=self._addon_package) + bpy.ops.wm.addon_refresh() + bpy.ops.wm.addon_enable(module=self._addon_package) + print("2.7 reload complete") + else: # 2.8 + bpy.ops.preferences.addon_disable(module=self._addon_package) + bpy.ops.preferences.addon_refresh() + bpy.ops.preferences.addon_enable(module=self._addon_package) + print("2.8 reload complete") + + # ------------------------------------------------------------------------- + # Other non-api functions and setups + # ------------------------------------------------------------------------- + def clear_state(self): + self._update_ready = None + self._update_link = None + self._update_version = None + self._source_zip = None + self._error = None + self._error_msg = None + + # custom urlretrieve implementation + def url_retrieve(self, url_file, filepath): + chunk = 1024 * 8 + f = open(filepath, "wb") + while 1: + data = url_file.read(chunk) + if not data: + # print("done.") + break + f.write(data) + # print("Read %s bytes" % len(data)) + f.close() + + def version_tuple_from_text(self, text): + if text is None: + return () + + # should go through string and remove all non-integers, + # and for any given break split into a different section + segments = list() + tmp = '' + for char in str(text): + if not char.isdigit(): + if len(tmp) > 0: + segments.append(int(tmp)) + tmp = '' + else: + tmp += char + if len(tmp) > 0: + segments.append(int(tmp)) + + if len(segments) == 0: + if self._verbose: + print("No version strings found text: ", text) + if not self._include_branches: + return () + else: + return (text) + return tuple(segments) + + # called for running check in a background thread + def check_for_update_async(self, callback=None): + + if self._json is not None and "update_ready" in self._json and self._json["version_text"] is not dict(): + if self._json["update_ready"]: + self._update_ready = True + self._update_link = self._json["version_text"]["link"] + self._update_version = str(self._json["version_text"]["version"]) + # cached update + callback(True) + return + + # do the check + if not self._check_interval_enable: + return + elif self._async_checking: + if self._verbose: + print("Skipping async check, already started") + return # already running the bg thread + elif self._update_ready is None: + self.start_async_check_update(False, callback) + + def check_for_update_now(self, callback=None): + + self._error = None + self._error_msg = None + + if self._verbose: + print("Check update pressed, first getting current status") + if self._async_checking: + if self._verbose: + print("Skipping async check, already started") + return # already running the bg thread + elif self._update_ready is None: + self.start_async_check_update(True, callback) + else: + self._update_ready = None + self.start_async_check_update(True, callback) + + # this function is not async, will always return in sequential fashion + # but should have a parent which calls it in another thread + def check_for_update(self, now=False): + if self._verbose: + print("Checking for update function") + + # clear the errors if any + self._error = None + self._error_msg = None + + # 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) + + if self._current_version is None: + raise ValueError("current_version not yet defined") + + if self._repo is None: + raise ValueError("repo not yet defined") + + if self._user is None: + raise ValueError("username not yet defined") + + self.set_updater_json() # self._json + + if not now and not self.past_interval_timestamp(): + if self._verbose: + print("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: + if self._verbose: + print("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) + + # primary internet call + self.get_tags() # sets self._tags and self._tag_latest + + self._json["last_check"] = str(datetime.now()) + self.save_updater_json() + + # can be () or ('master') in addition to branches, and version tag + new_version = self.version_tuple_from_text(self.tag_latest) + + if len(self._tags) == 0: + self._update_ready = False + self._update_version = None + self._update_link = None + return (False, None, None) + + if not self._include_branches: + link = self.select_link(self, self._tags[0]) + else: + n = len(self._include_branch_list) + if len(self._tags) == n: + # effectively means no tags found on repo + # so provide the first one as default + link = self.select_link(self, self._tags[0]) + else: + link = self.select_link(self, self._tags[n]) + + if new_version == (): + self._update_ready = False + self._update_version = None + self._update_link = None + return (False, None, None) + elif str(new_version).lower() in self._include_branch_list: + # handle situation where master/whichever branch is included + # however, this code effectively is not triggered now + # as new_version will only be tag names, not branch names + if not self._include_branch_auto_check: + # don't offer update as ready, + # but set the link for the default + # branch for installing + self._update_ready = False + self._update_version = new_version + self._update_link = link + self.save_updater_json() + return (True, new_version, link) + else: + raise ValueError("include_branch_autocheck: NOT YET DEVELOPED") + # bypass releases and look at timestamp of last update + # from a branch compared to now, see if commit values + # match or not. + + 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 + self.save_updater_json() + return (True, new_version, link) + + # elif new_version != self._current_version: + # self._update_ready = False + # self._update_version = new_version + # self._update_link = link + # self.save_updater_json() + # return (True, new_version, link) + + # if no update, set ready to False from None + self._update_ready = False + self._update_version = None + self._update_link = None + return (False, None, None) + + def set_tag(self, name): + """Assign the tag name and url to update to""" + tg = None + for tag in self._tags: + if name == tag["name"]: + tg = tag + break + if tg: + new_version = self.version_tuple_from_text(self.tag_latest) + self._update_version = new_version + self._update_link = self.select_link(self, tg) + elif self._include_branches and name in self._include_branch_list: + # scenario if reverting to a specific branch name instead of tag + tg = name + link = self.form_branch_url(tg) + self._update_version = name # this will break things + self._update_link = link + if not tg: + raise ValueError("Version tag not found: "+name) + + def run_update(self, force=False, revert_tag=None, clean=False, callback=None): + """Runs an install, update, or reversion of an addon from online source + + Arguments: + force: Install assigned link, even if self.update_ready is False + revert_tag: Version to install, if none uses detected update link + clean: not used, but in future could use to totally refresh addon + callback: used to run function on update completion + """ + self._json["update_ready"] = False + self._json["ignore"] = False # clear ignore flag + self._json["version_text"] = dict() + + if revert_tag is not None: + self.set_tag(revert_tag) + self._update_ready = True + + # clear the errors if any + self._error = None + self._error_msg = None + + if self._verbose: + print("Running update") + + if self._fake_install: + # change to True, to trigger the reload/"update installed" handler + if self._verbose: + print("fake_install=True") + print("Just reloading and running any handler triggers") + self._json["just_updated"] = True + self.save_updater_json() + if self._backup_current is True: + self.create_backup() + self.reload_addon() + self._update_ready = False + res = True # fake "success" zip download flag + + elif not force: + if not self._update_ready: + if self._verbose: + print("Update stopped, new version not ready") + if callback: + callback( + 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 + if self._verbose: + print("Update stopped, update link unavailable") + if callback: + callback(self._addon_package, + "Update stopped, update link unavailable") + return "Update stopped, update link unavailable" + + if self._verbose and revert_tag is None: + print("Staging update") + elif self._verbose: + print("Staging install") + + res = self.stage_repository(self._update_link) + if not res: + print("Error in staging repository: " + str(res)) + if callback is not None: + callback(self._addon_package, self._error_msg) + return self._error_msg + res = self.unpack_staged_zip(clean) + if res < 0: + if callback: + callback(self._addon_package, self._error_msg) + return res + + else: + if self._update_link is None: + if self._verbose: + print("Update stopped, could not get link") + return "Update stopped, could not get link" + if self._verbose: + print("Forcing update") + + res = self.stage_repository(self._update_link) + if not res: + print("Error in staging repository: " + str(res)) + if callback: + callback(self._addon_package, self._error_msg) + return self._error_msg + res = self.unpack_staged_zip(clean) + if res < 0: + return res + # would need to compare against other versions held in tags + + # run the front-end's callback if provided + if callback: + callback(self._addon_package) + + # return something meaningful, 0 means it worked + return 0 + + def past_interval_timestamp(self): + if not self._check_interval_enable: + return True # ie this exact feature is disabled + + if "last_check" not in self._json or self._json["last_check"] == "": + return True + + now = datetime.now() + last_check = datetime.strptime(self._json["last_check"], "%Y-%m-%d %H:%M:%S.%f") + next_check = last_check + offset = timedelta( + days=self._check_interval_days + 30 * self._check_interval_months, + hours=self._check_interval_hours, + minutes=self._check_interval_minutes + ) + + delta = (now - offset) - last_check + if delta.total_seconds() > 0: + if self._verbose: + print("{} Updater: Time to check for updates!".format(self._addon)) + return True + + if self._verbose: + print("{} Updater: Determined it's not yet time to check for updates".format(self._addon)) + return False + + def get_json_path(self): + """Returns the full path to the JSON state file used by this updater. + + 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)) + old_json_path = os.path.join(self._updater_path, "updater_status.json") + + # rename old file if it exists + try: + os.rename(old_json_path, json_path) + except FileNotFoundError: + pass + except Exception as err: + print("Other OS error occurred while trying to rename old JSON") + print(err) + self.print_trace() + return json_path + + def set_updater_json(self): + """Load or initialize JSON dictionary data for updater state""" + if self._updater_path is None: + raise ValueError("updater_path is not defined") + elif not os.path.isdir(self._updater_path): + os.makedirs(self._updater_path) + + jpath = self.get_json_path() + if os.path.isfile(jpath): + with open(jpath) as data_file: + self._json = json.load(data_file) + if self._verbose: + print("{} Updater: Read in JSON settings from file".format( + self._addon)) + else: + # set data structure + self._json = { + "last_check": "", + "backup_date": "", + "update_ready": False, + "ignore": False, + "just_restored": False, + "just_updated": False, + "version_text": dict() + } + self.save_updater_json() + + def save_updater_json(self): + # first save the state + if self._update_ready: + if isinstance(self._update_version, tuple): + self._json["update_ready"] = True + self._json["version_text"]["link"] = self._update_link + self._json["version_text"]["version"] = self._update_version + else: + self._json["update_ready"] = False + self._json["version_text"] = dict() + else: + self._json["update_ready"] = False + self._json["version_text"] = dict() + + jpath = self.get_json_path() + outf = open(jpath, 'w') + data_out = json.dumps(self._json, indent=4) + outf.write(data_out) + outf.close() + if self._verbose: + print(self._addon+": Wrote out updater JSON settings to file, with the contents:") + print(self._json) + + def json_reset_postupdate(self): + self._json["just_updated"] = False + self._json["update_ready"] = False + self._json["version_text"] = dict() + self.save_updater_json() + + def json_reset_restore(self): + self._json["just_restored"] = False + self._json["update_ready"] = False + self._json["version_text"] = dict() + self.save_updater_json() + self._update_ready = None # reset so you could check update again + + def ignore_update(self): + self._json["ignore"] = True + self.save_updater_json() + + # ------------------------------------------------------------------------- + # ASYNC stuff + # ------------------------------------------------------------------------- + def start_async_check_update(self, now=False, callback=None): + """Start a background thread which will check for updates""" + if self._async_checking: + return + if self._verbose: + print("{} updater: Starting background checking thread".format( + self._addon)) + check_thread = threading.Thread(target=self.async_check_update, + args=(now, callback,)) + check_thread.daemon = True + self._check_thread = check_thread + check_thread.start() + + def async_check_update(self, now, callback=None): + """Perform update check, run as target of background thread""" + self._async_checking = True + if self._verbose: + print("{} BG thread: Checking for update now in background".format( + self._addon)) + + try: + self.check_for_update(now=now) + except Exception as exception: + print("Checking for update error:") + print(exception) + self.print_trace() + if not self._error: + self._update_ready = False + self._update_version = None + self._update_link = None + self._error = "Error occurred" + self._error_msg = "Encountered an error while checking for updates" + + self._async_checking = False + self._check_thread = None + + if self._verbose: + print("{} BG thread: Finished checking for update, doing callback".format(self._addon)) + if callback: + callback(self._update_ready) + + def stop_async_check_update(self): + """Method to give impression of stopping check for update. + + Currently does nothing but allows user to retry/stop blocking UI from + hitting a refresh button. This does not actually stop the thread, as it + will complete after the connection timeout regardless. If the thread + does complete with a successful response, this will be still displayed + on next UI refresh (ie no update, or update available). + """ + if self._check_thread is not None: + if self._verbose: + print("Thread will end in normal course.") + # however, "There is no direct kill method on a thread object." + # better to let it run its course + # self._check_thread.stop() + self._async_checking = False + self._error = None + self._error_msg = None # ----------------------------------------------------------------------------- @@ -1640,105 +1637,105 @@ def stop_async_check_update(self): class BitbucketEngine: - """Integration to Bitbucket API for git-formatted repositories""" + """Integration to Bitbucket API for git-formatted repositories""" - def __init__(self): - self.api_url = 'https://api.bitbucket.org' - self.token = None - self.name = "bitbucket" + def __init__(self): + self.api_url = 'https://api.bitbucket.org' + self.token = None + self.name = "bitbucket" - def form_repo_url(self, updater): - return self.api_url+"/2.0/repositories/"+updater.user+"/"+updater.repo + def form_repo_url(self, updater): + return self.api_url+"/2.0/repositories/"+updater.user+"/"+updater.repo - def form_tags_url(self, updater): - return self.form_repo_url(updater) + "/refs/tags?sort=-name" + def form_tags_url(self, updater): + return self.form_repo_url(updater) + "/refs/tags?sort=-name" - def form_branch_url(self, branch, updater): - return self.get_zip_url(branch, updater) + def form_branch_url(self, branch, updater): + return self.get_zip_url(branch, updater) - 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) + 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) - 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"]] + 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"]] class GithubEngine: - """Integration to Github API""" + """Integration to Github API""" - def __init__(self): - self.api_url = 'https://api.github.com' - self.token = None - self.name = "github" + def __init__(self): + self.api_url = 'https://api.github.com' + self.token = None + self.name = "github" - def form_repo_url(self, updater): - return "{}{}{}{}{}".format(self.api_url, "/repos/", updater.user, "/", - updater.repo) + def form_repo_url(self, updater): + return "{}{}{}{}{}".format(self.api_url, "/repos/", updater.user, "/", + updater.repo) - def form_tags_url(self, updater): - if updater.use_releases: - return "{}{}".format(self.form_repo_url(updater), "/releases") - else: - return "{}{}".format(self.form_repo_url(updater), "/tags") + def form_tags_url(self, updater): + if updater.use_releases: + return "{}{}".format(self.form_repo_url(updater), "/releases") + else: + return "{}{}".format(self.form_repo_url(updater), "/tags") - def form_branch_list_url(self, updater): - return "{}{}".format(self.form_repo_url(updater), "/branches") + def form_branch_list_url(self, updater): + return "{}{}".format(self.form_repo_url(updater), "/branches") - def form_branch_url(self, branch, updater): - return "{}{}{}".format(self.form_repo_url(updater), "/zipball/", - branch) + def form_branch_url(self, branch, updater): + return "{}{}{}".format(self.form_repo_url(updater), "/zipball/", + branch) - def parse_tags(self, response, updater): - if response is None: - return list() - return response + def parse_tags(self, response, updater): + if response is None: + return list() + return response class GitlabEngine: - """Integration to GitLab API""" - - def __init__(self): - self.api_url = 'https://gitlab.com' - self.token = None - self.name = "gitlab" - - def form_repo_url(self, updater): - return "{}{}{}".format(self.api_url, "/api/v4/projects/", updater.repo) - - def form_tags_url(self, updater): - return "{}{}".format(self.form_repo_url(updater), "/repository/tags") - - def form_branch_list_url(self, updater): - # does not validate branch name. - return "{}{}".format( - self.form_repo_url(updater), "/repository/branches") - - 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 "{}{}{}".format( - self.form_repo_url(updater), - "/repository/archive.zip?sha=", - branch) - - def get_zip_url(self, sha, updater): - return "{base}/repository/archive.zip?sha={sha}".format( - 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 - - def parse_tags(self, response, updater): - if response is None: - return list() - return [{"name": tag["name"], "zipball_url": self.get_zip_url(tag["commit"]["id"], updater)} for tag in response] + """Integration to GitLab API""" + + def __init__(self): + self.api_url = 'https://gitlab.com' + self.token = None + self.name = "gitlab" + + def form_repo_url(self, updater): + return "{}{}{}".format(self.api_url, "/api/v4/projects/", updater.repo) + + def form_tags_url(self, updater): + return "{}{}".format(self.form_repo_url(updater), "/repository/tags") + + def form_branch_list_url(self, updater): + # does not validate branch name. + return "{}{}".format( + self.form_repo_url(updater), "/repository/branches") + + 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 "{}{}{}".format( + self.form_repo_url(updater), + "/repository/archive.zip?sha=", + branch) + + def get_zip_url(self, sha, updater): + return "{base}/repository/archive.zip?sha={sha}".format( + 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 + + def parse_tags(self, response, updater): + if response is None: + return list() + return [{"name": tag["name"], "zipball_url": self.get_zip_url(tag["commit"]["id"], updater)} for tag in response] # ----------------------------------------------------------------------------- diff --git a/addon_updater_ops.py b/addon_updater_ops.py index 60fa369..2826e77 100644 --- a/addon_updater_ops.py +++ b/addon_updater_ops.py @@ -30,37 +30,37 @@ # updater import, import safely # Prevents popups for users with invalid python installs e.g. missing libraries try: - from .addon_updater import Updater as updater + from .addon_updater import Updater as updater except Exception as e: - print("ERROR INITIALIZING UPDATER") - print(str(e)) - traceback.print_exc() - - class SingletonUpdaterNone(object): - def __init__(self): - self.addon = None - self.verbose = False - self.use_print_traces = True - self.invalid_updater = True # used to distinguish bad install - self.error = None - self.error_msg = None - self.async_checking = None - - def clear_state(self): - self.addon = None - self.verbose = False - self.invalid_updater = True - self.error = None - self.error_msg = None - self.async_checking = None - - def run_update(self): pass - - def check_for_update(self): pass - - updater = SingletonUpdaterNone() - updater.error = "Error initializing updater module" - updater.error_msg = str(e) + print("ERROR INITIALIZING UPDATER") + print(str(e)) + traceback.print_exc() + + class SingletonUpdaterNone(object): + def __init__(self): + self.addon = None + self.verbose = False + self.use_print_traces = True + self.invalid_updater = True # used to distinguish bad install + self.error = None + self.error_msg = None + self.async_checking = None + + def clear_state(self): + self.addon = None + self.verbose = False + self.invalid_updater = True + self.error = None + self.error_msg = None + self.async_checking = None + + def run_update(self): pass + + def check_for_update(self): pass + + updater = SingletonUpdaterNone() + updater.error = "Error initializing updater module" + updater.error_msg = str(e) # Must declare this before classes are loaded # otherwise the bl_idname's will not match and have errors. @@ -72,41 +72,41 @@ def check_for_update(self): pass # Blender version utils # ----------------------------------------------------------------------------- def make_annotations(cls): - """Add annotation attribute to class fields to avoid Blender 2.8 warnings""" - if not hasattr(bpy.app, "version") or bpy.app.version < (2, 80): - return cls - bl_props = {k: v for k, v in cls.__dict__.items() if isinstance(v, tuple)} - if bl_props: - if '__annotations__' not in cls.__dict__: - setattr(cls, '__annotations__', {}) - annotations = cls.__dict__['__annotations__'] - for k, v in bl_props.items(): - annotations[k] = v - delattr(cls, k) - return cls + """Add annotation attribute to class fields to avoid Blender 2.8 warnings""" + if not hasattr(bpy.app, "version") or bpy.app.version < (2, 80): + return cls + bl_props = {k: v for k, v in cls.__dict__.items() if isinstance(v, tuple)} + if bl_props: + if '__annotations__' not in cls.__dict__: + setattr(cls, '__annotations__', {}) + annotations = cls.__dict__['__annotations__'] + for k, v in bl_props.items(): + annotations[k] = v + delattr(cls, k) + return cls def layout_split(layout, factor=0.0, align=False): - """Intermediate method for pre and post blender 2.8 split UI function""" - if not hasattr(bpy.app, "version") or bpy.app.version < (2, 80): - return layout.split(percentage=factor, align=align) - return layout.split(factor=factor, align=align) + """Intermediate method for pre and post blender 2.8 split UI function""" + if not hasattr(bpy.app, "version") or bpy.app.version < (2, 80): + return layout.split(percentage=factor, align=align) + return layout.split(factor=factor, align=align) def get_user_preferences(context=None): - """Intermediate method for pre and post blender 2.8 grabbing preferences""" - if not context: - context = bpy.context - prefs = None - if hasattr(context, "user_preferences"): - prefs = context.user_preferences.addons.get(__package__, None) - elif hasattr(context, "preferences"): - prefs = context.preferences.addons.get(__package__, None) - if prefs: - return prefs.preferences - # To make the addon stable and non-exception prone, return None - # raise Exception("Could not fetch user preferences") - return None + """Intermediate method for pre and post blender 2.8 grabbing preferences""" + if not context: + context = bpy.context + prefs = None + if hasattr(context, "user_preferences"): + prefs = context.user_preferences.addons.get(__package__, None) + elif hasattr(context, "preferences"): + prefs = context.preferences.addons.get(__package__, None) + if prefs: + return prefs.preferences + # To make the addon stable and non-exception prone, return None + # raise Exception("Could not fetch user preferences") + return None # ----------------------------------------------------------------------------- @@ -116,504 +116,504 @@ def get_user_preferences(context=None): # simple popup for prompting checking for update & allow to install if available class AddonUpdaterInstallPopup(bpy.types.Operator): - """Check and install update if available""" - bl_label = "Update {x} addon".format(x=updater.addon) - bl_idname = updater.addon+".updater_install_popup" - bl_description = "Popup menu to check and display current updates available" - bl_options = {'REGISTER', 'INTERNAL'} - - # if true, run clean install - ie remove all files before adding new - # equivalent to deleting the addon and reinstalling, except the - # updater folder/backup folder remains - clean_install = bpy.props.BoolProperty( - name="Clean install", - description="If enabled, completely clear the addon's folder before installing new update, creating a fresh install", - default=False, - options={'HIDDEN'} - ) - - ignore_enum = bpy.props.EnumProperty( - name="Process update", - description="Decide to install, ignore, or defer new addon update", - items=[ - ("install", "Update Now", "Install update now"), - ("ignore", "Ignore", "Ignore this update to prevent future popups"), - ("defer", "Defer", "Defer choice till next blender session") - ], - options={'HIDDEN'} - ) - - def check(self, context): - return True - - def invoke(self, context, event): - return context.window_manager.invoke_props_dialog(self) - - def draw(self, context): - layout = self.layout - if updater.invalid_updater: - layout.label(text="Updater module error") - return - elif updater.update_ready: - col = layout.column() - col.scale_y = 0.7 - col.label(text="Update {} ready!".format(str(updater.update_version)), - icon="LOOP_FORWARDS") - col.label(text="Choose 'Update Now' & press OK to install, ", icon="BLANK1") - col.label(text="or click outside window to defer", icon="BLANK1") - row = col.row() - row.prop(self, "ignore_enum", expand=True) - col.split() - elif not updater.update_ready: - col = layout.column() - col.scale_y = 0.7 - col.label(text="No updates available") - col.label(text="Press okay to dismiss dialog") - # add option to force install - else: - # case: updater.update_ready = None - # we have not yet checked for the update - layout.label(text="Check for update now?") - - # potentially in future, could have UI for 'check to select old version' - # to revert back to. - - def execute(self, context): - - # in case of error importing updater - if updater.invalid_updater: - return {'CANCELLED'} - - if updater.manual_only: - bpy.ops.wm.url_open(url=updater.website) - elif updater.update_ready: - - # action based on enum selection - if self.ignore_enum == 'defer': - return {'FINISHED'} - elif self.ignore_enum == 'ignore': - updater.ignore_update() - return {'FINISHED'} - # else: "install update now!" - - res = updater.run_update(force=False, - callback=post_update_callback, - clean=self.clean_install) - - # should return 0, if not something happened - if updater.verbose: - if res == 0: - print("Updater returned successful") - else: - print("Updater returned {}, error occurred".format(res)) - elif updater.update_ready is None: - _ = updater.check_for_update(now=True) - - # re-launch this dialog - atr = AddonUpdaterInstallPopup.bl_idname.split(".") - getattr(getattr(bpy.ops, atr[0]), atr[1])('INVOKE_DEFAULT') - else: - if updater.verbose: - print("Doing nothing, not ready for update") - return {'FINISHED'} + """Check and install update if available""" + bl_label = "Update {x} addon".format(x=updater.addon) + bl_idname = updater.addon+".updater_install_popup" + bl_description = "Popup menu to check and display current updates available" + bl_options = {'REGISTER', 'INTERNAL'} + + # if true, run clean install - ie remove all files before adding new + # equivalent to deleting the addon and reinstalling, except the + # updater folder/backup folder remains + clean_install = bpy.props.BoolProperty( + name="Clean install", + description="If enabled, completely clear the addon's folder before installing new update, creating a fresh install", + default=False, + options={'HIDDEN'} + ) + + ignore_enum = bpy.props.EnumProperty( + name="Process update", + description="Decide to install, ignore, or defer new addon update", + items=[ + ("install", "Update Now", "Install update now"), + ("ignore", "Ignore", "Ignore this update to prevent future popups"), + ("defer", "Defer", "Defer choice till next blender session") + ], + options={'HIDDEN'} + ) + + def check(self, context): + return True + + def invoke(self, context, event): + return context.window_manager.invoke_props_dialog(self) + + def draw(self, context): + layout = self.layout + if updater.invalid_updater: + layout.label(text="Updater module error") + return + elif updater.update_ready: + col = layout.column() + col.scale_y = 0.7 + col.label(text="Update {} ready!".format(str(updater.update_version)), + icon="LOOP_FORWARDS") + col.label(text="Choose 'Update Now' & press OK to install, ", icon="BLANK1") + col.label(text="or click outside window to defer", icon="BLANK1") + row = col.row() + row.prop(self, "ignore_enum", expand=True) + col.split() + elif not updater.update_ready: + col = layout.column() + col.scale_y = 0.7 + col.label(text="No updates available") + col.label(text="Press okay to dismiss dialog") + # add option to force install + else: + # case: updater.update_ready = None + # we have not yet checked for the update + layout.label(text="Check for update now?") + + # potentially in future, could have UI for 'check to select old version' + # to revert back to. + + def execute(self, context): + + # in case of error importing updater + if updater.invalid_updater: + return {'CANCELLED'} + + if updater.manual_only: + bpy.ops.wm.url_open(url=updater.website) + elif updater.update_ready: + + # action based on enum selection + if self.ignore_enum == 'defer': + return {'FINISHED'} + elif self.ignore_enum == 'ignore': + updater.ignore_update() + return {'FINISHED'} + # else: "install update now!" + + res = updater.run_update(force=False, + callback=post_update_callback, + clean=self.clean_install) + + # should return 0, if not something happened + if updater.verbose: + if res == 0: + print("Updater returned successful") + else: + print("Updater returned {}, error occurred".format(res)) + elif updater.update_ready is None: + _ = updater.check_for_update(now=True) + + # re-launch this dialog + atr = AddonUpdaterInstallPopup.bl_idname.split(".") + getattr(getattr(bpy.ops, atr[0]), atr[1])('INVOKE_DEFAULT') + else: + if updater.verbose: + print("Doing nothing, not ready for update") + return {'FINISHED'} # User preference check-now operator class AddonUpdaterCheckNow(bpy.types.Operator): - bl_label = "Check now for " + updater.addon + " update" - bl_idname = updater.addon + ".updater_check_now" - bl_description = "Check now for an update to the {x} addon".format( - x=updater.addon) - bl_options = {'REGISTER', 'INTERNAL'} - - def execute(self, context): - if updater.invalid_updater: - return {'CANCELLED'} - - if updater.async_checking and updater.error is None: - # Check already happened - # Used here to just avoid constant applying settings below - # Ignoring if error, to prevent being stuck on the error screen - return {'CANCELLED'} - - # apply the UI settings - settings = get_user_preferences(context) - if not settings: - if updater.verbose: - print("Could not get {} preferences, update check skipped".format( - __package__)) - return {'CANCELLED'} - - updater.set_check_interval( - enable=settings.auto_check_update, - months=settings.updater_interval_months, - days=settings.updater_interval_days, - hours=settings.updater_interval_hours, - minutes=settings.updater_interval_minutes) # optional, if auto_check_update - - # input is an optional callback function - # this function should take a bool input, if true: update ready - # if false, no update ready - updater.check_for_update_now(ui_refresh) - - return {'FINISHED'} + bl_label = "Check now for " + updater.addon + " update" + bl_idname = updater.addon + ".updater_check_now" + bl_description = "Check now for an update to the {x} addon".format( + x=updater.addon) + bl_options = {'REGISTER', 'INTERNAL'} + + def execute(self, context): + if updater.invalid_updater: + return {'CANCELLED'} + + if updater.async_checking and updater.error is None: + # Check already happened + # Used here to just avoid constant applying settings below + # Ignoring if error, to prevent being stuck on the error screen + return {'CANCELLED'} + + # apply the UI settings + settings = get_user_preferences(context) + if not settings: + if updater.verbose: + print("Could not get {} preferences, update check skipped".format( + __package__)) + return {'CANCELLED'} + + updater.set_check_interval( + enable=settings.auto_check_update, + months=settings.updater_interval_months, + days=settings.updater_interval_days, + hours=settings.updater_interval_hours, + minutes=settings.updater_interval_minutes) # optional, if auto_check_update + + # input is an optional callback function + # this function should take a bool input, if true: update ready + # if false, no update ready + updater.check_for_update_now(ui_refresh) + + return {'FINISHED'} class AddonUpdaterUpdateNow(bpy.types.Operator): - bl_label = "Update " + updater.addon + " addon now" - bl_idname = updater.addon + ".updater_update_now" - bl_description = "Update to the latest version of the {x} addon".format( - x=updater.addon) - bl_options = {'REGISTER', 'INTERNAL'} - - # if true, run clean install - ie remove all files before adding new - # equivalent to deleting the addon and reinstalling, except the - # updater folder/backup folder remains - clean_install = bpy.props.BoolProperty( - name="Clean install", - description="If enabled, completely clear the addon's folder before installing new update, creating a fresh install", - default=False, - options={'HIDDEN'} - ) - - def execute(self, context): - - # in case of error importing updater - if updater.invalid_updater: - return {'CANCELLED'} - - if updater.manual_only: - bpy.ops.wm.url_open(url=updater.website) - if updater.update_ready: - # if it fails, offer to open the website instead - try: - res = updater.run_update( - force=False, - callback=post_update_callback, - clean=self.clean_install) - - # should return 0, if not something happened - if updater.verbose: - if res == 0: - print("Updater returned successful") - else: - print("Updater returned " + str(res) + ", error occurred") - except Exception as expt: - updater._error = "Error trying to run update" - updater._error_msg = str(expt) - updater.print_trace() - atr = AddonUpdaterInstallManually.bl_idname.split(".") - getattr(getattr(bpy.ops, atr[0]), atr[1])('INVOKE_DEFAULT') - elif updater.update_ready is None: - (update_ready, version, link) = updater.check_for_update(now=True) - # re-launch this dialog - atr = AddonUpdaterInstallPopup.bl_idname.split(".") - getattr(getattr(bpy.ops, atr[0]), atr[1])('INVOKE_DEFAULT') - - elif not updater.update_ready: - self.report({'INFO'}, "Nothing to update") - return {'CANCELLED'} - else: - self.report({'ERROR'}, "Encountered problem while trying to update") - return {'CANCELLED'} - - return {'FINISHED'} + bl_label = "Update " + updater.addon + " addon now" + bl_idname = updater.addon + ".updater_update_now" + bl_description = "Update to the latest version of the {x} addon".format( + x=updater.addon) + bl_options = {'REGISTER', 'INTERNAL'} + + # if true, run clean install - ie remove all files before adding new + # equivalent to deleting the addon and reinstalling, except the + # updater folder/backup folder remains + clean_install = bpy.props.BoolProperty( + name="Clean install", + description="If enabled, completely clear the addon's folder before installing new update, creating a fresh install", + default=False, + options={'HIDDEN'} + ) + + def execute(self, context): + + # in case of error importing updater + if updater.invalid_updater: + return {'CANCELLED'} + + if updater.manual_only: + bpy.ops.wm.url_open(url=updater.website) + if updater.update_ready: + # if it fails, offer to open the website instead + try: + res = updater.run_update( + force=False, + callback=post_update_callback, + clean=self.clean_install) + + # should return 0, if not something happened + if updater.verbose: + if res == 0: + print("Updater returned successful") + else: + print("Updater returned " + str(res) + ", error occurred") + except Exception as expt: + updater._error = "Error trying to run update" + updater._error_msg = str(expt) + updater.print_trace() + atr = AddonUpdaterInstallManually.bl_idname.split(".") + getattr(getattr(bpy.ops, atr[0]), atr[1])('INVOKE_DEFAULT') + elif updater.update_ready is None: + (update_ready, version, link) = updater.check_for_update(now=True) + # re-launch this dialog + atr = AddonUpdaterInstallPopup.bl_idname.split(".") + getattr(getattr(bpy.ops, atr[0]), atr[1])('INVOKE_DEFAULT') + + elif not updater.update_ready: + self.report({'INFO'}, "Nothing to update") + return {'CANCELLED'} + else: + self.report({'ERROR'}, "Encountered problem while trying to update") + return {'CANCELLED'} + + return {'FINISHED'} class AddonUpdaterUpdateTarget(bpy.types.Operator): - bl_label = updater.addon + " version target" - bl_idname = updater.addon + ".updater_update_target" - bl_description = "Install a targeted version of the {x} addon".format( - x=updater.addon) - bl_options = {'REGISTER', 'INTERNAL'} - - def target_version(self, context): - # in case of error importing updater - if updater.invalid_updater: - ret = [] - - ret = [] - i = 0 - for tag in updater.tags: - ret.append((tag, tag, "Select to install " + tag)) - i += 1 - return ret - - target = bpy.props.EnumProperty( - name="Target version to install", - description="Select the version to install", - items=target_version - ) - - # if true, run clean install - ie remove all files before adding new - # equivalent to deleting the addon and reinstalling, except the - # updater folder/backup folder remains - clean_install = bpy.props.BoolProperty( - name="Clean install", - description="If enabled, completely clear the addon's folder before installing new update, creating a fresh install", - default=False, - options={'HIDDEN'} - ) - - @classmethod - def poll(cls, context): - if updater.invalid_updater: - return False - return updater.update_ready is not None and len(updater.tags) > 0 - - def invoke(self, context, event): - return context.window_manager.invoke_props_dialog(self) - - def draw(self, context): - layout = self.layout - if updater.invalid_updater: - layout.label(text="Updater error") - return - split = layout_split(layout, factor=0.5) - sub_col = split.column() - sub_col.label(text="Select install version") - sub_col = split.column() - sub_col.prop(self, "target", text="") - - def execute(self, context): - - # in case of error importing updater - if updater.invalid_updater: - return {'CANCELLED'} - - res = updater.run_update( - force=False, - revert_tag=self.target, - callback=post_update_callback, - clean=self.clean_install) - - # should return 0, if not something happened - if res == 0: - if updater.verbose: - print("Updater returned successful") - else: - if updater.verbose: - print("Updater returned " + str(res) + ", error occurred") - return {'CANCELLED'} - - return {'FINISHED'} + bl_label = updater.addon + " version target" + bl_idname = updater.addon + ".updater_update_target" + bl_description = "Install a targeted version of the {x} addon".format( + x=updater.addon) + bl_options = {'REGISTER', 'INTERNAL'} + + def target_version(self, context): + # in case of error importing updater + if updater.invalid_updater: + ret = [] + + ret = [] + i = 0 + for tag in updater.tags: + ret.append((tag, tag, "Select to install " + tag)) + i += 1 + return ret + + target = bpy.props.EnumProperty( + name="Target version to install", + description="Select the version to install", + items=target_version + ) + + # if true, run clean install - ie remove all files before adding new + # equivalent to deleting the addon and reinstalling, except the + # updater folder/backup folder remains + clean_install = bpy.props.BoolProperty( + name="Clean install", + description="If enabled, completely clear the addon's folder before installing new update, creating a fresh install", + default=False, + options={'HIDDEN'} + ) + + @classmethod + def poll(cls, context): + if updater.invalid_updater: + return False + return updater.update_ready is not None and len(updater.tags) > 0 + + def invoke(self, context, event): + return context.window_manager.invoke_props_dialog(self) + + def draw(self, context): + layout = self.layout + if updater.invalid_updater: + layout.label(text="Updater error") + return + split = layout_split(layout, factor=0.5) + sub_col = split.column() + sub_col.label(text="Select install version") + sub_col = split.column() + sub_col.prop(self, "target", text="") + + def execute(self, context): + + # in case of error importing updater + if updater.invalid_updater: + return {'CANCELLED'} + + res = updater.run_update( + force=False, + revert_tag=self.target, + callback=post_update_callback, + clean=self.clean_install) + + # should return 0, if not something happened + if res == 0: + if updater.verbose: + print("Updater returned successful") + else: + if updater.verbose: + print("Updater returned " + str(res) + ", error occurred") + return {'CANCELLED'} + + return {'FINISHED'} class AddonUpdaterInstallManually(bpy.types.Operator): - """As a fallback, direct the user to download the addon manually""" - bl_label = "Install update manually" - bl_idname = updater.addon + ".updater_install_manually" - bl_description = "Proceed to manually install update" - bl_options = {'REGISTER', 'INTERNAL'} - - error = bpy.props.StringProperty( - name="Error Occurred", - default="", - options={'HIDDEN'} - ) - - def invoke(self, context, event): - return context.window_manager.invoke_popup(self) - - def draw(self, context): - layout = self.layout - - if updater.invalid_updater: - layout.label(text="Updater error") - return - - # use a "failed flag"? it shows this label if the case failed. - if self.error is not "": - col = layout.column() - col.scale_y = 0.7 - col.label(text="There was an issue trying to auto-install", icon="ERROR") - col.label(text="Press the download button below and install", icon="BLANK1") - col.label(text="the zip file like a normal addon.", icon="BLANK1") - else: - col = layout.column() - col.scale_y = 0.7 - col.label(text="Install the addon manually") - col.label(text="Press the download button below and install") - col.label(text="the zip file like a normal addon.") - - # if check hasn't happened, i.e. accidentally called this menu - # allow to check here - - row = layout.row() - - if updater.update_link is not None: - row.operator( - "wm.url_open", - text="Direct download").url = updater.update_link - else: - row.operator( - "wm.url_open", - text="(failed to retrieve direct download)") - row.enabled = False - - if updater.website is not None: - row = layout.row() - row.operator("wm.url_open", - text="Open website").url = updater.website - else: - row = layout.row() - row.label(text="See source website to download the update") - - def execute(self, context): - return {'FINISHED'} + """As a fallback, direct the user to download the addon manually""" + bl_label = "Install update manually" + bl_idname = updater.addon + ".updater_install_manually" + bl_description = "Proceed to manually install update" + bl_options = {'REGISTER', 'INTERNAL'} + + error = bpy.props.StringProperty( + name="Error Occurred", + default="", + options={'HIDDEN'} + ) + + def invoke(self, context, event): + return context.window_manager.invoke_popup(self) + + def draw(self, context): + layout = self.layout + + if updater.invalid_updater: + layout.label(text="Updater error") + return + + # use a "failed flag"? it shows this label if the case failed. + if self.error is not "": + col = layout.column() + col.scale_y = 0.7 + col.label(text="There was an issue trying to auto-install", icon="ERROR") + col.label(text="Press the download button below and install", icon="BLANK1") + col.label(text="the zip file like a normal addon.", icon="BLANK1") + else: + col = layout.column() + col.scale_y = 0.7 + col.label(text="Install the addon manually") + col.label(text="Press the download button below and install") + col.label(text="the zip file like a normal addon.") + + # if check hasn't happened, i.e. accidentally called this menu + # allow to check here + + row = layout.row() + + if updater.update_link is not None: + row.operator( + "wm.url_open", + text="Direct download").url = updater.update_link + else: + row.operator( + "wm.url_open", + text="(failed to retrieve direct download)") + row.enabled = False + + if updater.website is not None: + row = layout.row() + row.operator("wm.url_open", + text="Open website").url = updater.website + else: + row = layout.row() + row.label(text="See source website to download the update") + + def execute(self, context): + return {'FINISHED'} class AddonUpdaterUpdatedSuccessful(bpy.types.Operator): - """Addon in place, popup telling user it completed or what went wrong""" - bl_label = "Installation Report" - bl_idname = updater.addon + ".updater_update_successful" - bl_description = "Update installation response" - bl_options = {'REGISTER', 'INTERNAL', 'UNDO'} - - error = bpy.props.StringProperty( - name="Error Occurred", - default="", - options={'HIDDEN'} - ) - - def invoke(self, context, event): - return context.window_manager.invoke_props_popup(self, event) - - def draw(self, context): - layout = self.layout - - if updater.invalid_updater: - layout.label(text="Updater error") - return - - saved = updater.json - if self.error != "": - col = layout.column() - col.scale_y = 0.7 - col.label(text="Error occurred, did not install", icon="ERROR") - if updater.error_msg: - msg = updater.error_msg - else: - msg = self.error - col.label(text=str(msg), icon="BLANK1") - rw = col.row() - rw.scale_y = 2 - rw.operator( - "wm.url_open", - text="Click for manual download.", - icon="BLANK1").url = updater.website - # manual download button here - elif not updater.auto_reload_post_update: - # tell user to restart blender - if "just_restored" in saved and saved["just_restored"]: - col = layout.column() - col.label(text="Addon restored", icon="RECOVER_LAST") - alert_row = col.row() - alert_row.alert = True - alert_row.operator( - "wm.quit_blender", - text="Restart blender to reload", - icon="BLANK1") - updater.json_reset_restore() - else: - col = layout.column() - col.label(text="Addon successfully installed", icon="FILE_TICK") - alert_row = col.row() - alert_row.alert = True - alert_row.operator( - "wm.quit_blender", - text="Restart blender to reload", - icon="BLANK1") - - else: - # reload addon, but still recommend they restart blender - if "just_restored" in saved and saved["just_restored"]: - col = layout.column() - col.scale_y = 0.7 - col.label(text="Addon restored", icon="RECOVER_LAST") - col.label( - text="Consider restarting blender to fully reload.", - icon="BLANK1") - updater.json_reset_restore() - else: - col = layout.column() - col.scale_y = 0.7 - col.label(text="Addon successfully installed", icon="FILE_TICK") - col.label( - text="Consider restarting blender to fully reload.", - icon="BLANK1") - - def execute(self, context): - return {'FINISHED'} + """Addon in place, popup telling user it completed or what went wrong""" + bl_label = "Installation Report" + bl_idname = updater.addon + ".updater_update_successful" + bl_description = "Update installation response" + bl_options = {'REGISTER', 'INTERNAL', 'UNDO'} + + error = bpy.props.StringProperty( + name="Error Occurred", + default="", + options={'HIDDEN'} + ) + + def invoke(self, context, event): + return context.window_manager.invoke_props_popup(self, event) + + def draw(self, context): + layout = self.layout + + if updater.invalid_updater: + layout.label(text="Updater error") + return + + saved = updater.json + if self.error != "": + col = layout.column() + col.scale_y = 0.7 + col.label(text="Error occurred, did not install", icon="ERROR") + if updater.error_msg: + msg = updater.error_msg + else: + msg = self.error + col.label(text=str(msg), icon="BLANK1") + rw = col.row() + rw.scale_y = 2 + rw.operator( + "wm.url_open", + text="Click for manual download.", + icon="BLANK1").url = updater.website + # manual download button here + elif not updater.auto_reload_post_update: + # tell user to restart blender + if "just_restored" in saved and saved["just_restored"]: + col = layout.column() + col.label(text="Addon restored", icon="RECOVER_LAST") + alert_row = col.row() + alert_row.alert = True + alert_row.operator( + "wm.quit_blender", + text="Restart blender to reload", + icon="BLANK1") + updater.json_reset_restore() + else: + col = layout.column() + col.label(text="Addon successfully installed", icon="FILE_TICK") + alert_row = col.row() + alert_row.alert = True + alert_row.operator( + "wm.quit_blender", + text="Restart blender to reload", + icon="BLANK1") + + else: + # reload addon, but still recommend they restart blender + if "just_restored" in saved and saved["just_restored"]: + col = layout.column() + col.scale_y = 0.7 + col.label(text="Addon restored", icon="RECOVER_LAST") + col.label( + text="Consider restarting blender to fully reload.", + icon="BLANK1") + updater.json_reset_restore() + else: + col = layout.column() + col.scale_y = 0.7 + col.label(text="Addon successfully installed", icon="FILE_TICK") + col.label( + text="Consider restarting blender to fully reload.", + icon="BLANK1") + + def execute(self, context): + return {'FINISHED'} class AddonUpdaterRestoreBackup(bpy.types.Operator): - """Restore addon from backup""" - bl_label = "Restore backup" - bl_idname = updater.addon + ".updater_restore_backup" - bl_description = "Restore addon from backup" - bl_options = {'REGISTER', 'INTERNAL'} - - @classmethod - def poll(cls, context): - try: - return os.path.isdir(os.path.join(updater.stage_path, "backup")) - except: - return False - - def execute(self, context): - # in case of error importing updater - if updater.invalid_updater: - return {'CANCELLED'} - updater.restore_backup() - return {'FINISHED'} + """Restore addon from backup""" + bl_label = "Restore backup" + bl_idname = updater.addon + ".updater_restore_backup" + bl_description = "Restore addon from backup" + bl_options = {'REGISTER', 'INTERNAL'} + + @classmethod + def poll(cls, context): + try: + return os.path.isdir(os.path.join(updater.stage_path, "backup")) + except: + return False + + def execute(self, context): + # in case of error importing updater + if updater.invalid_updater: + return {'CANCELLED'} + updater.restore_backup() + return {'FINISHED'} class AddonUpdaterIgnore(bpy.types.Operator): - """Prevent future update notice popups""" - bl_label = "Ignore update" - bl_idname = updater.addon + ".updater_ignore" - bl_description = "Ignore update to prevent future popups" - bl_options = {'REGISTER', 'INTERNAL'} - - @classmethod - def poll(cls, context): - if updater.invalid_updater: - return False - elif updater.update_ready: - return True - else: - return False - - def execute(self, context): - # in case of error importing updater - if updater.invalid_updater: - return {'CANCELLED'} - updater.ignore_update() - self.report({"INFO"}, "Open addon preferences for updater options") - return {'FINISHED'} + """Prevent future update notice popups""" + bl_label = "Ignore update" + bl_idname = updater.addon + ".updater_ignore" + bl_description = "Ignore update to prevent future popups" + bl_options = {'REGISTER', 'INTERNAL'} + + @classmethod + def poll(cls, context): + if updater.invalid_updater: + return False + elif updater.update_ready: + return True + else: + return False + + def execute(self, context): + # in case of error importing updater + if updater.invalid_updater: + return {'CANCELLED'} + updater.ignore_update() + self.report({"INFO"}, "Open addon preferences for updater options") + return {'FINISHED'} class AddonUpdaterEndBackground(bpy.types.Operator): - """Stop checking for update in the background""" - bl_label = "End background check" - bl_idname = updater.addon + ".end_background_check" - bl_description = "Stop checking for update in the background" - bl_options = {'REGISTER', 'INTERNAL'} - - # @classmethod - # def poll(cls, context): - # if updater.async_checking == True: - # return True - # else: - # return False - - def execute(self, context): - # in case of error importing updater - if updater.invalid_updater: - return {'CANCELLED'} - updater.stop_async_check_update() - return {'FINISHED'} + """Stop checking for update in the background""" + bl_label = "End background check" + bl_idname = updater.addon + ".end_background_check" + bl_description = "Stop checking for update in the background" + bl_options = {'REGISTER', 'INTERNAL'} + + # @classmethod + # def poll(cls, context): + # if updater.async_checking == True: + # return True + # else: + # return False + + def execute(self, context): + # in case of error importing updater + if updater.invalid_updater: + return {'CANCELLED'} + updater.stop_async_check_update() + return {'FINISHED'} # ----------------------------------------------------------------------------- @@ -631,890 +631,890 @@ def execute(self, context): @persistent def updater_run_success_popup_handler(scene): - global ran_update_success_popup - ran_update_success_popup = True + global ran_update_success_popup + ran_update_success_popup = True - # in case of error importing updater - if updater.invalid_updater: - return + # in case of error importing updater + if updater.invalid_updater: + return - try: - if "scene_update_post" in dir(bpy.app.handlers): - bpy.app.handlers.scene_update_post.remove( - updater_run_success_popup_handler) - else: - bpy.app.handlers.depsgraph_update_post.remove( - updater_run_success_popup_handler) - except: - pass + try: + if "scene_update_post" in dir(bpy.app.handlers): + bpy.app.handlers.scene_update_post.remove( + updater_run_success_popup_handler) + else: + bpy.app.handlers.depsgraph_update_post.remove( + updater_run_success_popup_handler) + except: + pass - atr = AddonUpdaterUpdatedSuccessful.bl_idname.split(".") - getattr(getattr(bpy.ops, atr[0]), atr[1])('INVOKE_DEFAULT') + atr = AddonUpdaterUpdatedSuccessful.bl_idname.split(".") + getattr(getattr(bpy.ops, atr[0]), atr[1])('INVOKE_DEFAULT') @persistent def updater_run_install_popup_handler(scene): - global ran_auto_check_install_popup - ran_auto_check_install_popup = True - - # in case of error importing updater - if updater.invalid_updater: - return - - try: - if "scene_update_post" in dir(bpy.app.handlers): - bpy.app.handlers.scene_update_post.remove( - updater_run_install_popup_handler) - else: - bpy.app.handlers.depsgraph_update_post.remove( - updater_run_install_popup_handler) - except: - pass - - if "ignore" in updater.json and updater.json["ignore"]: - return # don't do popup if ignore pressed - # elif type(updater.update_version) != type((0,0,0)): - # # likely was from master or another branch, shouldn't trigger popup - # updater.json_reset_restore() - # return - elif "version_text" in updater.json and "version" in updater.json["version_text"]: - version = updater.json["version_text"]["version"] - ver_tuple = updater.version_tuple_from_text(version) - - if ver_tuple < updater.current_version: - # user probably manually installed to get the up to date addon - # in here. Clear out the update flag using this function - if updater.verbose: - print("{} updater: appears user updated, clearing flag".format( - updater.addon)) - updater.json_reset_restore() - return - atr = AddonUpdaterInstallPopup.bl_idname.split(".") - getattr(getattr(bpy.ops, atr[0]), atr[1])('INVOKE_DEFAULT') + global ran_auto_check_install_popup + ran_auto_check_install_popup = True + + # in case of error importing updater + if updater.invalid_updater: + return + + try: + if "scene_update_post" in dir(bpy.app.handlers): + bpy.app.handlers.scene_update_post.remove( + updater_run_install_popup_handler) + else: + bpy.app.handlers.depsgraph_update_post.remove( + updater_run_install_popup_handler) + except: + pass + + if "ignore" in updater.json and updater.json["ignore"]: + return # don't do popup if ignore pressed + # elif type(updater.update_version) != type((0,0,0)): + # # likely was from master or another branch, shouldn't trigger popup + # updater.json_reset_restore() + # return + elif "version_text" in updater.json and "version" in updater.json["version_text"]: + version = updater.json["version_text"]["version"] + ver_tuple = updater.version_tuple_from_text(version) + + if ver_tuple < updater.current_version: + # user probably manually installed to get the up to date addon + # in here. Clear out the update flag using this function + if updater.verbose: + print("{} updater: appears user updated, clearing flag".format( + updater.addon)) + updater.json_reset_restore() + return + atr = AddonUpdaterInstallPopup.bl_idname.split(".") + getattr(getattr(bpy.ops, atr[0]), atr[1])('INVOKE_DEFAULT') def background_update_callback(update_ready): - """Passed into the updater, background thread updater""" - global ran_auto_check_install_popup - - # in case of error importing updater - if updater.invalid_updater: - return - if not updater.showpopups: - return - if not update_ready: - return - - # see if we need add to the update handler to trigger the popup - handlers = [] - if "scene_update_post" in dir(bpy.app.handlers): # 2.7x - handlers = bpy.app.handlers.scene_update_post - else: # 2.8x - handlers = bpy.app.handlers.depsgraph_update_post - in_handles = updater_run_install_popup_handler in handlers - - if in_handles or ran_auto_check_install_popup: - return - - if "scene_update_post" in dir(bpy.app.handlers): # 2.7x - bpy.app.handlers.scene_update_post.append( - updater_run_install_popup_handler) - else: # 2.8x - bpy.app.handlers.depsgraph_update_post.append( - updater_run_install_popup_handler) - ran_auto_check_install_popup = True + """Passed into the updater, background thread updater""" + global ran_auto_check_install_popup + + # in case of error importing updater + if updater.invalid_updater: + return + if not updater.showpopups: + return + if not update_ready: + return + + # see if we need add to the update handler to trigger the popup + handlers = [] + if "scene_update_post" in dir(bpy.app.handlers): # 2.7x + handlers = bpy.app.handlers.scene_update_post + else: # 2.8x + handlers = bpy.app.handlers.depsgraph_update_post + in_handles = updater_run_install_popup_handler in handlers + + if in_handles or ran_auto_check_install_popup: + return + + if "scene_update_post" in dir(bpy.app.handlers): # 2.7x + bpy.app.handlers.scene_update_post.append( + updater_run_install_popup_handler) + else: # 2.8x + bpy.app.handlers.depsgraph_update_post.append( + updater_run_install_popup_handler) + ran_auto_check_install_popup = True def post_update_callback(module_name, res=None): - """Callback for once the run_update function has completed - - Only makes sense to use this if "auto_reload_post_update" == False, - i.e. don't auto-restart the addon - - Arguments: - module_name: returns the module name from updater, but unused here - res: If an error occurred, this is the detail string - """ - - # in case of error importing updater - if updater.invalid_updater: - return - - if res is None: - # this is the same code as in conditional at the end of the register function - # ie if "auto_reload_post_update" == True, comment out this code - if updater.verbose: - print("{} updater: Running post update callback".format(updater.addon)) - - atr = AddonUpdaterUpdatedSuccessful.bl_idname.split(".") - getattr(getattr(bpy.ops, atr[0]), atr[1])('INVOKE_DEFAULT') - global ran_update_success_popup - ran_update_success_popup = True - else: - # some kind of error occurred and it was unable to install, - # offer manual download instead - atr = AddonUpdaterUpdatedSuccessful.bl_idname.split(".") - getattr(getattr(bpy.ops, atr[0]), atr[1])('INVOKE_DEFAULT', error=res) - return + """Callback for once the run_update function has completed + + Only makes sense to use this if "auto_reload_post_update" == False, + i.e. don't auto-restart the addon + + Arguments: + module_name: returns the module name from updater, but unused here + res: If an error occurred, this is the detail string + """ + + # in case of error importing updater + if updater.invalid_updater: + return + + if res is None: + # this is the same code as in conditional at the end of the register function + # ie if "auto_reload_post_update" == True, comment out this code + if updater.verbose: + print("{} updater: Running post update callback".format(updater.addon)) + + atr = AddonUpdaterUpdatedSuccessful.bl_idname.split(".") + getattr(getattr(bpy.ops, atr[0]), atr[1])('INVOKE_DEFAULT') + global ran_update_success_popup + ran_update_success_popup = True + else: + # some kind of error occurred and it was unable to install, + # offer manual download instead + atr = AddonUpdaterUpdatedSuccessful.bl_idname.split(".") + getattr(getattr(bpy.ops, atr[0]), atr[1])('INVOKE_DEFAULT', error=res) + return def ui_refresh(update_status): - # find a way to just re-draw self? - # callback intended for trigger by async thread - for windowManager in bpy.data.window_managers: - for window in windowManager.windows: - for area in window.screen.areas: - area.tag_redraw() + # find a way to just re-draw self? + # callback intended for trigger by async thread + for windowManager in bpy.data.window_managers: + for window in windowManager.windows: + for area in window.screen.areas: + area.tag_redraw() def check_for_update_background(): - """Function for asynchronous background check. - - *Could* be called on register, but would be bad practice. - """ - if updater.invalid_updater: - return - global ran_background_check - if ran_background_check: - # Global var ensures check only happens once - return - elif updater.update_ready is not None or updater.async_checking: - # Check already happened - # Used here to just avoid constant applying settings below - return - - # apply the UI settings - settings = get_user_preferences(bpy.context) - if not settings: - return - updater.set_check_interval(enable=settings.auto_check_update, - months=settings.updater_intrval_months, - days=settings.updater_intrval_days, - hours=settings.updater_intrval_hours, - minutes=settings.updater_intrval_minutes - ) # optional, if auto_check_update - - # input is an optional callback function - # this function should take a bool input, if true: update ready - # if false, no update ready - if updater.verbose: - print("{} updater: Running background check for update".format( - updater.addon)) - updater.check_for_update_async(background_update_callback) - ran_background_check = True + """Function for asynchronous background check. + + *Could* be called on register, but would be bad practice. + """ + if updater.invalid_updater: + return + global ran_background_check + if ran_background_check: + # Global var ensures check only happens once + return + elif updater.update_ready is not None or updater.async_checking: + # Check already happened + # Used here to just avoid constant applying settings below + return + + # apply the UI settings + settings = get_user_preferences(bpy.context) + if not settings: + return + updater.set_check_interval(enable=settings.auto_check_update, + months=settings.updater_intrval_months, + days=settings.updater_intrval_days, + hours=settings.updater_intrval_hours, + minutes=settings.updater_intrval_minutes + ) # optional, if auto_check_update + + # input is an optional callback function + # this function should take a bool input, if true: update ready + # if false, no update ready + if updater.verbose: + print("{} updater: Running background check for update".format( + updater.addon)) + updater.check_for_update_async(background_update_callback) + ran_background_check = True def check_for_update_nonthreaded(self, context): - """Can be placed in front of other operators to launch when pressed""" - if updater.invalid_updater: - return - - # only check if it's ready, ie after the time interval specified - # should be the async wrapper call here - settings = get_user_preferences(bpy.context) - if not settings: - if updater.verbose: - print("Could not get {} preferences, update check skipped".format( - __package__)) - return - updater.set_check_interval(enable=settings.auto_check_update, - months=settings.updater_intrval_months, - days=settings.updater_intrval_days, - hours=settings.updater_intrval_hours, - minutes=settings.updater_intrval_minutes) # optional, if auto_check_update - - (update_ready, version, link) = updater.check_for_update(now=False) - if update_ready: - atr = AddonUpdaterInstallPopup.bl_idname.split(".") - getattr(getattr(bpy.ops, atr[0]), atr[1])('INVOKE_DEFAULT') - else: - if updater.verbose: - print("No update ready") - self.report({'INFO'}, "No update ready") + """Can be placed in front of other operators to launch when pressed""" + if updater.invalid_updater: + return + + # only check if it's ready, ie after the time interval specified + # should be the async wrapper call here + settings = get_user_preferences(bpy.context) + if not settings: + if updater.verbose: + print("Could not get {} preferences, update check skipped".format( + __package__)) + return + updater.set_check_interval(enable=settings.auto_check_update, + months=settings.updater_intrval_months, + days=settings.updater_intrval_days, + hours=settings.updater_intrval_hours, + minutes=settings.updater_intrval_minutes) # optional, if auto_check_update + + (update_ready, version, link) = updater.check_for_update(now=False) + if update_ready: + atr = AddonUpdaterInstallPopup.bl_idname.split(".") + getattr(getattr(bpy.ops, atr[0]), atr[1])('INVOKE_DEFAULT') + else: + if updater.verbose: + print("No update ready") + self.report({'INFO'}, "No update ready") def show_reload_popup(): - """For use in register only, to show popup after re-enabling the addon + """For use in register only, to show popup after re-enabling the addon - Must be enabled by developer - """ - if updater.invalid_updater: - return - saved_state = updater.json - global ran_update_success_popup + Must be enabled by developer + """ + if updater.invalid_updater: + return + saved_state = updater.json + global ran_update_success_popup - has_state = saved_state is not None - just_updated = "just_updated" in saved_state - updated_info = saved_state["just_updated"] + has_state = saved_state is not None + just_updated = "just_updated" in saved_state + updated_info = saved_state["just_updated"] - if not (has_state and just_updated and updated_info): - return + if not (has_state and just_updated and updated_info): + return - updater.json_reset_postupdate() # so this only runs once + updater.json_reset_postupdate() # so this only runs once - # no handlers in this case - if not updater.auto_reload_post_update: - return + # no handlers in this case + if not updater.auto_reload_post_update: + return - # see if we need add to the update handler to trigger the popup - handlers = [] - if "scene_update_post" in dir(bpy.app.handlers): # 2.7x - handlers = bpy.app.handlers.scene_update_post - else: # 2.8x - handlers = bpy.app.handlers.depsgraph_update_post - in_handles = updater_run_success_popup_handler in handlers + # see if we need add to the update handler to trigger the popup + handlers = [] + if "scene_update_post" in dir(bpy.app.handlers): # 2.7x + handlers = bpy.app.handlers.scene_update_post + else: # 2.8x + handlers = bpy.app.handlers.depsgraph_update_post + in_handles = updater_run_success_popup_handler in handlers - if in_handles or ran_update_success_popup: - return + if in_handles or ran_update_success_popup: + return - if "scene_update_post" in dir(bpy.app.handlers): # 2.7x - bpy.app.handlers.scene_update_post.append( - updater_run_success_popup_handler) - else: # 2.8x - bpy.app.handlers.depsgraph_update_post.append( - updater_run_success_popup_handler) - ran_update_success_popup = True + if "scene_update_post" in dir(bpy.app.handlers): # 2.7x + bpy.app.handlers.scene_update_post.append( + updater_run_success_popup_handler) + else: # 2.8x + bpy.app.handlers.depsgraph_update_post.append( + updater_run_success_popup_handler) + ran_update_success_popup = True # ----------------------------------------------------------------------------- # Example UI integrations # ----------------------------------------------------------------------------- def update_notice_box_ui(self, context): - """ Panel - Update Available for placement at end/beginning of panel - - After a check for update has occurred, this function will draw a box - saying an update is ready, and give a button for: update now, open website, - or ignore popup. Ideal to be placed at the end / beginning of a panel - """ - - if updater.invalid_updater: - return - - saved_state = updater.json - if not updater.auto_reload_post_update: - if "just_updated" in saved_state and saved_state["just_updated"]: - layout = self.layout - box = layout.box() - col = box.column() - alert_row = col.row() - alert_row.alert = True - alert_row.operator( - "wm.quit_blender", - text="Restart blender", - icon="ERROR") - col.label(text="to complete update") - - return - - # if user pressed ignore, don't draw the box - if "ignore" in updater.json and updater.json["ignore"]: - return - if not updater.update_ready: - return - - layout = self.layout - box = layout.box() - col = box.column(align=True) - col.label(text="Update ready!", icon="ERROR") - col.separator() - row = col.row(align=True) - split = row.split(align=True) - colL = split.column(align=True) - colL.scale_y = 1.5 - colL.operator(AddonUpdaterIgnore.bl_idname, icon="X", text="Ignore") - colR = split.column(align=True) - colR.scale_y = 1.5 - if not updater.manual_only: - colR.operator(AddonUpdaterUpdateNow.bl_idname, - text="Update", icon="LOOP_FORWARDS") - col.operator("wm.url_open", text="Open website").url = updater.website - # col.operator("wm.url_open",text="Direct download").url=updater.update_link - col.operator(AddonUpdaterInstallManually.bl_idname, - text="Install manually") - else: - # col.operator("wm.url_open",text="Direct download").url=updater.update_link - col.operator("wm.url_open", text="Get it now").url = updater.website + """ Panel - Update Available for placement at end/beginning of panel + + After a check for update has occurred, this function will draw a box + saying an update is ready, and give a button for: update now, open website, + or ignore popup. Ideal to be placed at the end / beginning of a panel + """ + + if updater.invalid_updater: + return + + saved_state = updater.json + if not updater.auto_reload_post_update: + if "just_updated" in saved_state and saved_state["just_updated"]: + layout = self.layout + box = layout.box() + col = box.column() + alert_row = col.row() + alert_row.alert = True + alert_row.operator( + "wm.quit_blender", + text="Restart blender", + icon="ERROR") + col.label(text="to complete update") + + return + + # if user pressed ignore, don't draw the box + if "ignore" in updater.json and updater.json["ignore"]: + return + if not updater.update_ready: + return + + layout = self.layout + box = layout.box() + col = box.column(align=True) + col.label(text="Update ready!", icon="ERROR") + col.separator() + row = col.row(align=True) + split = row.split(align=True) + colL = split.column(align=True) + colL.scale_y = 1.5 + colL.operator(AddonUpdaterIgnore.bl_idname, icon="X", text="Ignore") + colR = split.column(align=True) + colR.scale_y = 1.5 + if not updater.manual_only: + colR.operator(AddonUpdaterUpdateNow.bl_idname, + text="Update", icon="LOOP_FORWARDS") + col.operator("wm.url_open", text="Open website").url = updater.website + # col.operator("wm.url_open",text="Direct download").url=updater.update_link + col.operator(AddonUpdaterInstallManually.bl_idname, + text="Install manually") + else: + # col.operator("wm.url_open",text="Direct download").url=updater.update_link + col.operator("wm.url_open", text="Get it now").url = updater.website def update_settings_ui(self, context, element=None): - """Preferences - for drawing with full width inside user preferences - - Create a function that can be run inside user preferences panel for prefs UI - Place inside UI draw using: addon_updater_ops.updaterSettingsUI(self, context) - or by: addon_updater_ops.updaterSettingsUI(context) - """ - - # element is a UI element, such as layout, a row, column, or box - if element is None: - element = self.layout - box = element.box() - - # in case of error importing updater - if updater.invalid_updater: - box.label(text="Error initializing updater code:") - box.label(text=updater.error_msg) - return - settings = get_user_preferences(context) - if not settings: - box.label(text="Error getting updater preferences", icon='ERROR') - return - - # auto-update settings - box.label(text="Updater Settings") - row = box.row() - - # special case to tell user to restart blender, if set that way - if not updater.auto_reload_post_update: - saved_state = updater.json - if "just_updated" in saved_state and saved_state["just_updated"]: - row.alert = True - row.operator( - "wm.quit_blender", - text="Restart blender to complete update", - icon="ERROR") - return - - split = layout_split(row, factor=0.4) - sub_col = split.column() - sub_col.prop(settings, "auto_check_update") - sub_col = split.column() - - if not settings.auto_check_update: - sub_col.enabled = False - sub_row = sub_col.row() - sub_row.label(text="Interval between checks") - sub_row = sub_col.row(align=True) - check_col = sub_row.column(align=True) - check_col.prop(settings, "updater_interval_months") - check_col = sub_row.column(align=True) - check_col.prop(settings, "updater_interval_days") - check_col = sub_row.column(align=True) - - # Consider un-commenting for local dev (e.g. to set shorter intervals) - # check_col.prop(settings,"updater_interval_hours") - # check_col = sub_row.column(align=True) - # check_col.prop(settings,"updater_interval_minutes") - - # checking / managing updates - row = box.row() - col = row.column() - if updater.error is not None: - sub_col = col.row(align=True) - sub_col.scale_y = 1 - split = sub_col.split(align=True) - split.scale_y = 2 - if "ssl" in updater.error_msg.lower(): - split.enabled = True - split.operator(AddonUpdaterInstallManually.bl_idname, - text=updater.error) - else: - split.enabled = False - split.operator(AddonUpdaterCheckNow.bl_idname, - text=updater.error) - split = sub_col.split(align=True) - split.scale_y = 2 - split.operator(AddonUpdaterCheckNow.bl_idname, - text="", icon="FILE_REFRESH") - - elif updater.update_ready is None and not updater.async_checking: - col.scale_y = 2 - col.operator(AddonUpdaterCheckNow.bl_idname) - elif updater.update_ready is None: # async is running - sub_col = col.row(align=True) - sub_col.scale_y = 1 - split = sub_col.split(align=True) - split.enabled = False - split.scale_y = 2 - split.operator(AddonUpdaterCheckNow.bl_idname, - text="Checking...") - split = sub_col.split(align=True) - split.scale_y = 2 - split.operator(AddonUpdaterEndBackground.bl_idname, - text="", icon="X") - - elif updater.include_branches and \ - len(updater.tags) == len(updater.include_branch_list) and not \ - updater.manual_only: - # no releases found, but still show the appropriate branch - sub_col = col.row(align=True) - sub_col.scale_y = 1 - split = sub_col.split(align=True) - split.scale_y = 2 - split.operator(AddonUpdaterUpdateNow.bl_idname, - text="Update directly to " + str(updater.include_branch_list[0])) - split = sub_col.split(align=True) - split.scale_y = 2 - split.operator(AddonUpdaterCheckNow.bl_idname, - text="", icon="FILE_REFRESH") - - elif updater.update_read and not updater.manual_only: - sub_col = col.row(align=True) - sub_col.scale_y = 1 - split = sub_col.split(align=True) - split.scale_y = 2 - split.operator(AddonUpdaterUpdateNow.bl_idname, - text="Update now to " + str(updater.update_version)) - split = sub_col.split(align=True) - split.scale_y = 2 - split.operator(AddonUpdaterCheckNow.bl_idname, - text="", icon="FILE_REFRESH") - - elif updater.update_ready and updater.manual_only: - col.scale_y = 2 - col.operator("wm.url_open", - text="Download " + str(updater.update_version)).url = updater.website - else: # i.e. that updater.update_ready == False - sub_col = col.row(align=True) - sub_col.scale_y = 1 - split = sub_col.split(align=True) - split.enabled = False - split.scale_y = 2 - split.operator(AddonUpdaterCheckNow.bl_idname, - text="Addon is up to date") - split = sub_col.split(align=True) - split.scale_y = 2 - split.operator(AddonUpdaterCheckNow.bl_idname, - text="", icon="FILE_REFRESH") - - if not updater.manual_only: - col = row.column(align=True) - # col.operator(AddonUpdaterUpdateTarget.bl_idname, - if updater.include_branches and len(updater.include_branch_list) > 0: - branch = updater.include_branch_list[0] - col.operator(AddonUpdaterUpdateTarget.bl_idname, - text="Install latest {} / old version".format(branch)) - else: - col.operator(AddonUpdaterUpdateTarget.bl_idname, - text="Reinstall / install old version") - last_date = "none found" - backup_path = os.path.join(updater.stage_path, "backup") - if "backup_date" in updater.json and os.path.isdir(backup_path): - if updater.json["backup_date"] == "": - last_date = "Date not found" - else: - last_date = updater.json["backup_date"] - backup_text = "Restore addon backup ({})".format(last_date) - col.operator(AddonUpdaterRestoreBackup.bl_idname, text=backup_text) - - row = box.row() - row.scale_y = 0.7 - last_check = updater.json["last_check"] - if updater.error is not None and updater.error_msg is not None: - row.label(text=updater.error_msg) - elif last_check is not "" and last_check is not None: - last_check = last_check[0: last_check.index(".")] - row.label(text="Last update check: " + last_check) - else: - row.label(text="Last update check: Never") + """Preferences - for drawing with full width inside user preferences + + Create a function that can be run inside user preferences panel for prefs UI + Place inside UI draw using: addon_updater_ops.updaterSettingsUI(self, context) + or by: addon_updater_ops.updaterSettingsUI(context) + """ + + # element is a UI element, such as layout, a row, column, or box + if element is None: + element = self.layout + box = element.box() + + # in case of error importing updater + if updater.invalid_updater: + box.label(text="Error initializing updater code:") + box.label(text=updater.error_msg) + return + settings = get_user_preferences(context) + if not settings: + box.label(text="Error getting updater preferences", icon='ERROR') + return + + # auto-update settings + box.label(text="Updater Settings") + row = box.row() + + # special case to tell user to restart blender, if set that way + if not updater.auto_reload_post_update: + saved_state = updater.json + if "just_updated" in saved_state and saved_state["just_updated"]: + row.alert = True + row.operator( + "wm.quit_blender", + text="Restart blender to complete update", + icon="ERROR") + return + + split = layout_split(row, factor=0.4) + sub_col = split.column() + sub_col.prop(settings, "auto_check_update") + sub_col = split.column() + + if not settings.auto_check_update: + sub_col.enabled = False + sub_row = sub_col.row() + sub_row.label(text="Interval between checks") + sub_row = sub_col.row(align=True) + check_col = sub_row.column(align=True) + check_col.prop(settings, "updater_interval_months") + check_col = sub_row.column(align=True) + check_col.prop(settings, "updater_interval_days") + check_col = sub_row.column(align=True) + + # Consider un-commenting for local dev (e.g. to set shorter intervals) + # check_col.prop(settings,"updater_interval_hours") + # check_col = sub_row.column(align=True) + # check_col.prop(settings,"updater_interval_minutes") + + # checking / managing updates + row = box.row() + col = row.column() + if updater.error is not None: + sub_col = col.row(align=True) + sub_col.scale_y = 1 + split = sub_col.split(align=True) + split.scale_y = 2 + if "ssl" in updater.error_msg.lower(): + split.enabled = True + split.operator(AddonUpdaterInstallManually.bl_idname, + text=updater.error) + else: + split.enabled = False + split.operator(AddonUpdaterCheckNow.bl_idname, + text=updater.error) + split = sub_col.split(align=True) + split.scale_y = 2 + split.operator(AddonUpdaterCheckNow.bl_idname, + text="", icon="FILE_REFRESH") + + elif updater.update_ready is None and not updater.async_checking: + col.scale_y = 2 + col.operator(AddonUpdaterCheckNow.bl_idname) + elif updater.update_ready is None: # async is running + sub_col = col.row(align=True) + sub_col.scale_y = 1 + split = sub_col.split(align=True) + split.enabled = False + split.scale_y = 2 + split.operator(AddonUpdaterCheckNow.bl_idname, + text="Checking...") + split = sub_col.split(align=True) + split.scale_y = 2 + split.operator(AddonUpdaterEndBackground.bl_idname, + text="", icon="X") + + elif updater.include_branches and \ + len(updater.tags) == len(updater.include_branch_list) and not \ + updater.manual_only: + # no releases found, but still show the appropriate branch + sub_col = col.row(align=True) + sub_col.scale_y = 1 + split = sub_col.split(align=True) + split.scale_y = 2 + split.operator(AddonUpdaterUpdateNow.bl_idname, + text="Update directly to " + str(updater.include_branch_list[0])) + split = sub_col.split(align=True) + split.scale_y = 2 + split.operator(AddonUpdaterCheckNow.bl_idname, + text="", icon="FILE_REFRESH") + + elif updater.update_read and not updater.manual_only: + sub_col = col.row(align=True) + sub_col.scale_y = 1 + split = sub_col.split(align=True) + split.scale_y = 2 + split.operator(AddonUpdaterUpdateNow.bl_idname, + text="Update now to " + str(updater.update_version)) + split = sub_col.split(align=True) + split.scale_y = 2 + split.operator(AddonUpdaterCheckNow.bl_idname, + text="", icon="FILE_REFRESH") + + elif updater.update_ready and updater.manual_only: + col.scale_y = 2 + col.operator("wm.url_open", + text="Download " + str(updater.update_version)).url = updater.website + else: # i.e. that updater.update_ready == False + sub_col = col.row(align=True) + sub_col.scale_y = 1 + split = sub_col.split(align=True) + split.enabled = False + split.scale_y = 2 + split.operator(AddonUpdaterCheckNow.bl_idname, + text="Addon is up to date") + split = sub_col.split(align=True) + split.scale_y = 2 + split.operator(AddonUpdaterCheckNow.bl_idname, + text="", icon="FILE_REFRESH") + + if not updater.manual_only: + col = row.column(align=True) + # col.operator(AddonUpdaterUpdateTarget.bl_idname, + if updater.include_branches and len(updater.include_branch_list) > 0: + branch = updater.include_branch_list[0] + col.operator(AddonUpdaterUpdateTarget.bl_idname, + text="Install latest {} / old version".format(branch)) + else: + col.operator(AddonUpdaterUpdateTarget.bl_idname, + text="Reinstall / install old version") + last_date = "none found" + backup_path = os.path.join(updater.stage_path, "backup") + if "backup_date" in updater.json and os.path.isdir(backup_path): + if updater.json["backup_date"] == "": + last_date = "Date not found" + else: + last_date = updater.json["backup_date"] + backup_text = "Restore addon backup ({})".format(last_date) + col.operator(AddonUpdaterRestoreBackup.bl_idname, text=backup_text) + + row = box.row() + row.scale_y = 0.7 + last_check = updater.json["last_check"] + if updater.error is not None and updater.error_msg is not None: + row.label(text=updater.error_msg) + elif last_check is not "" and last_check is not None: + last_check = last_check[0: last_check.index(".")] + row.label(text="Last update check: " + last_check) + else: + row.label(text="Last update check: Never") def update_settings_ui_condensed(self, context, element=None): - """Preferences - Condensed drawing within preferences - - Alternate draw for user preferences or other places, does not draw a box - """ - - # element is a UI element, such as layout, a row, column, or box - if element is None: - element = self.layout - row = element.row() - - # in case of error importing updater - if updater.invalid_updater: - row.label(text="Error initializing updater code:") - row.label(text=updater.error_msg) - return - settings = get_user_preferences(context) - if not settings: - row.label(text="Error getting updater preferences", icon='ERROR') - return - - # special case to tell user to restart blender, if set that way - if not updater.auto_reload_post_update: - saved_state = updater.json - if "just_updated" in saved_state and saved_state["just_updated"]: - row.alert = True # mark red - row.operator( - "wm.quit_blender", - text="Restart blender to complete update", - icon="ERROR") - return - - col = row.column() - if updater.error is not None: - sub_col = col.row(align=True) - sub_col.scale_y = 1 - split = sub_col.split(align=True) - split.scale_y = 2 - if "ssl" in updater.error_msg.lower(): - split.enabled = True - split.operator(AddonUpdaterInstallManually.bl_idname, - text=updater.error) - else: - split.enabled = False - split.operator(AddonUpdaterCheckNow.bl_idname, - text=updater.error) - split = sub_col.split(align=True) - split.scale_y = 2 - split.operator(AddonUpdaterCheckNow.bl_idname, - text="", icon="FILE_REFRESH") - - elif updater.update_ready is None and not updater.async_checking: - col.scale_y = 2 - col.operator(AddonUpdaterCheckNow.bl_idname) - elif updater.update_ready is None: # async is running - sub_col = col.row(align=True) - sub_col.scale_y = 1 - split = sub_col.split(align=True) - split.enabled = False - split.scale_y = 2 - split.operator(AddonUpdaterCheckNow.bl_idname, text="Checking...") - split = sub_col.split(align=True) - split.scale_y = 2 - split.operator(AddonUpdaterEndBackground.bl_idname, text="", icon="X") - - elif updater.include_branches and \ - len(updater.tags) == len(updater.include_branch_list) and not \ - updater.manual_only: - # no releases found, but still show the appropriate branch - sub_col = col.row(align=True) - sub_col.scale_y = 1 - split = sub_col.split(align=True) - split.scale_y = 2 - split.operator(AddonUpdaterUpdateNow.bl_idname, - text="Update directly to " + str(updater.include_branch_list[0])) - split = sub_col.split(align=True) - split.scale_y = 2 - split.operator(AddonUpdaterCheckNow.bl_idname, - text="", icon="FILE_REFRESH") - - elif updater.update_ready and not updater.manual_only: - sub_col = col.row(align=True) - sub_col.scale_y = 1 - split = sub_col.split(align=True) - split.scale_y = 2 - split.operator(AddonUpdaterUpdateNow.bl_idname, - text="Update now to " + str(updater.update_version)) - split = sub_col.split(align=True) - split.scale_y = 2 - split.operator(AddonUpdaterCheckNow.bl_idname, - text="", icon="FILE_REFRESH") - - elif updater.update_ready and updater.manual_only: - col.scale_y = 2 - col.operator("wm.url_open", - text="Download " + str(updater.update_version)).url = updater.website - else: # i.e. that updater.update_ready == False - sub_col = col.row(align=True) - sub_col.scale_y = 1 - split = sub_col.split(align=True) - split.enabled = False - split.scale_y = 2 - split.operator(AddonUpdaterCheckNow.bl_idname, - text="Addon is up to date") - split = sub_col.split(align=True) - split.scale_y = 2 - split.operator(AddonUpdaterCheckNow.bl_idname, - text="", icon="FILE_REFRESH") - - row = element.row() - row.prop(settings, "auto_check_update") - - row = element.row() - row.scale_y = 0.7 - last_check = updater.json["last_check"] - if updater.error is not None and updater.error_msg is not None: - row.label(text=updater.error_msg) - elif last_check != "" and last_check is not None: - last_check = last_check[0: last_check.index(".")] - row.label(text="Last check: " + last_check) - else: - row.label(text="Last check: Never") + """Preferences - Condensed drawing within preferences + + Alternate draw for user preferences or other places, does not draw a box + """ + + # element is a UI element, such as layout, a row, column, or box + if element is None: + element = self.layout + row = element.row() + + # in case of error importing updater + if updater.invalid_updater: + row.label(text="Error initializing updater code:") + row.label(text=updater.error_msg) + return + settings = get_user_preferences(context) + if not settings: + row.label(text="Error getting updater preferences", icon='ERROR') + return + + # special case to tell user to restart blender, if set that way + if not updater.auto_reload_post_update: + saved_state = updater.json + if "just_updated" in saved_state and saved_state["just_updated"]: + row.alert = True # mark red + row.operator( + "wm.quit_blender", + text="Restart blender to complete update", + icon="ERROR") + return + + col = row.column() + if updater.error is not None: + sub_col = col.row(align=True) + sub_col.scale_y = 1 + split = sub_col.split(align=True) + split.scale_y = 2 + if "ssl" in updater.error_msg.lower(): + split.enabled = True + split.operator(AddonUpdaterInstallManually.bl_idname, + text=updater.error) + else: + split.enabled = False + split.operator(AddonUpdaterCheckNow.bl_idname, + text=updater.error) + split = sub_col.split(align=True) + split.scale_y = 2 + split.operator(AddonUpdaterCheckNow.bl_idname, + text="", icon="FILE_REFRESH") + + elif updater.update_ready is None and not updater.async_checking: + col.scale_y = 2 + col.operator(AddonUpdaterCheckNow.bl_idname) + elif updater.update_ready is None: # async is running + sub_col = col.row(align=True) + sub_col.scale_y = 1 + split = sub_col.split(align=True) + split.enabled = False + split.scale_y = 2 + split.operator(AddonUpdaterCheckNow.bl_idname, text="Checking...") + split = sub_col.split(align=True) + split.scale_y = 2 + split.operator(AddonUpdaterEndBackground.bl_idname, text="", icon="X") + + elif updater.include_branches and \ + len(updater.tags) == len(updater.include_branch_list) and not \ + updater.manual_only: + # no releases found, but still show the appropriate branch + sub_col = col.row(align=True) + sub_col.scale_y = 1 + split = sub_col.split(align=True) + split.scale_y = 2 + split.operator(AddonUpdaterUpdateNow.bl_idname, + text="Update directly to " + str(updater.include_branch_list[0])) + split = sub_col.split(align=True) + split.scale_y = 2 + split.operator(AddonUpdaterCheckNow.bl_idname, + text="", icon="FILE_REFRESH") + + elif updater.update_ready and not updater.manual_only: + sub_col = col.row(align=True) + sub_col.scale_y = 1 + split = sub_col.split(align=True) + split.scale_y = 2 + split.operator(AddonUpdaterUpdateNow.bl_idname, + text="Update now to " + str(updater.update_version)) + split = sub_col.split(align=True) + split.scale_y = 2 + split.operator(AddonUpdaterCheckNow.bl_idname, + text="", icon="FILE_REFRESH") + + elif updater.update_ready and updater.manual_only: + col.scale_y = 2 + col.operator("wm.url_open", + text="Download " + str(updater.update_version)).url = updater.website + else: # i.e. that updater.update_ready == False + sub_col = col.row(align=True) + sub_col.scale_y = 1 + split = sub_col.split(align=True) + split.enabled = False + split.scale_y = 2 + split.operator(AddonUpdaterCheckNow.bl_idname, + text="Addon is up to date") + split = sub_col.split(align=True) + split.scale_y = 2 + split.operator(AddonUpdaterCheckNow.bl_idname, + text="", icon="FILE_REFRESH") + + row = element.row() + row.prop(settings, "auto_check_update") + + row = element.row() + row.scale_y = 0.7 + last_check = updater.json["last_check"] + if updater.error is not None and updater.error_msg is not None: + row.label(text=updater.error_msg) + elif last_check != "" and last_check is not None: + last_check = last_check[0: last_check.index(".")] + row.label(text="Last check: " + last_check) + else: + row.label(text="Last check: Never") def skip_tag_function(self, tag): - """A global function for tag skipping - - A way to filter which tags are displayed, - e.g. to limit downgrading too far - input is a tag text, e.g. "v1.2.3" - output is True for skipping this tag number, - False if the tag is allowed (default for all) - Note: here, "self" is the acting updater shared class instance - """ - - # in case of error importing updater - if self.invalid_updater: - return False - - # ---- write any custom code here, return true to disallow version ---- # - # - # # Filter out e.g. if 'beta' is in name of release - # if 'beta' in tag.lower(): - # return True - # ---- write any custom code above, return true to disallow version --- # - - if self.include_branches: - for branch in self.include_branch_list: - if tag["name"].lower() == branch: return False - - # function converting string to tuple, ignoring e.g. leading 'v' - tupled = self.version_tuple_from_text(tag["name"]) - if not isinstance(tupled, tuple): - return True - - # select the min tag version - change tuple accordingly - if self.version_min_update is not None: - if tupled < self.version_min_update: - return True # skip if current version below this - - # select the max tag version - if self.version_max_update is not None: - if tupled >= self.version_max_update: - return True # skip if current version at or above this - - # in all other cases, allow showing the tag for updating/reverting - return False + """A global function for tag skipping + + A way to filter which tags are displayed, + e.g. to limit downgrading too far + input is a tag text, e.g. "v1.2.3" + output is True for skipping this tag number, + False if the tag is allowed (default for all) + Note: here, "self" is the acting updater shared class instance + """ + + # in case of error importing updater + if self.invalid_updater: + return False + + # ---- write any custom code here, return true to disallow version ---- # + # + # # Filter out e.g. if 'beta' is in name of release + # if 'beta' in tag.lower(): + # return True + # ---- write any custom code above, return true to disallow version --- # + + if self.include_branches: + for branch in self.include_branch_list: + if tag["name"].lower() == branch: return False + + # function converting string to tuple, ignoring e.g. leading 'v' + tupled = self.version_tuple_from_text(tag["name"]) + if not isinstance(tupled, tuple): + return True + + # select the min tag version - change tuple accordingly + if self.version_min_update is not None: + if tupled < self.version_min_update: + return True # skip if current version below this + + # select the max tag version + if self.version_max_update is not None: + if tupled >= self.version_max_update: + return True # skip if current version at or above this + + # in all other cases, allow showing the tag for updating/reverting + return False def select_link_function(self, tag): - """Only customize if trying to leverage "attachments" in *GitHub* releases + """Only customize if trying to leverage "attachments" in *GitHub* releases - A way to select from one or multiple attached downloadable files from the - server, instead of downloading the default release/tag source code - """ + A way to select from one or multiple attached downloadable files from the + server, instead of downloading the default release/tag source code + """ - # -- Default, universal case (and is the only option for GitLab/Bitbucket) - link = tag["zipball_url"] + # -- Default, universal case (and is the only option for GitLab/Bitbucket) + link = tag["zipball_url"] - # -- Example: select the first (or only) asset instead source code -- - # if "assets" in tag and "browser_download_url" in tag["assets"][0]: - # link = tag["assets"][0]["browser_download_url"] + # -- Example: select the first (or only) asset instead source code -- + # if "assets" in tag and "browser_download_url" in tag["assets"][0]: + # link = tag["assets"][0]["browser_download_url"] - # -- Example: select asset based on OS, where multiple builds exist -- - # # not tested/no error checking, modify to fit your own needs! - # # assume each release has three attached builds: - # # release_windows.zip, release_OSX.zip, release_linux.zip - # # This also would logically not be used with "branches" enabled - # if platform.system() == "Darwin": # ie OSX - # link = [asset for asset in tag["assets"] if 'OSX' in asset][0] - # elif platform.system() == "Windows": - # link = [asset for asset in tag["assets"] if 'windows' in asset][0] - # elif platform.system() == "Linux": - # link = [asset for asset in tag["assets"] if 'linux' in asset][0] + # -- Example: select asset based on OS, where multiple builds exist -- + # # not tested/no error checking, modify to fit your own needs! + # # assume each release has three attached builds: + # # release_windows.zip, release_OSX.zip, release_linux.zip + # # This also would logically not be used with "branches" enabled + # if platform.system() == "Darwin": # ie OSX + # link = [asset for asset in tag["assets"] if 'OSX' in asset][0] + # elif platform.system() == "Windows": + # link = [asset for asset in tag["assets"] if 'windows' in asset][0] + # elif platform.system() == "Linux": + # link = [asset for asset in tag["assets"] if 'linux' in asset][0] - return link + return link # ----------------------------------------------------------------------------- # Register, should be run in the register module itself # ----------------------------------------------------------------------------- classes = ( - AddonUpdaterInstallPopup, - AddonUpdaterCheckNow, - AddonUpdaterUpdateNow, - AddonUpdaterUpdateTarget, - AddonUpdaterInstallManually, - AddonUpdaterUpdatedSuccessful, - AddonUpdaterRestoreBackup, - AddonUpdaterIgnore, - AddonUpdaterEndBackground + AddonUpdaterInstallPopup, + AddonUpdaterCheckNow, + AddonUpdaterUpdateNow, + AddonUpdaterUpdateTarget, + AddonUpdaterInstallManually, + AddonUpdaterUpdatedSuccessful, + AddonUpdaterRestoreBackup, + AddonUpdaterIgnore, + AddonUpdaterEndBackground ) def register(bl_info): - """Registering the operators in this module""" - # safer failure in case of issue loading module - if updater.error: - print("Exiting updater registration, " + updater.error) - return - updater.clear_state() # clear internal vars, avoids reloading oddities - - # confirm your updater "engine" (Github is default if not specified) - updater.engine = "Github" - # updater.engine = "GitLab" - # updater.engine = "Bitbucket" - - # If using private repository, indicate the token here - # Must be set after assigning the engine. - # **WARNING** Depending on the engine, this token can act like a password!! - # Only provide a token if the project is *non-public*, see readme for - # other considerations and suggestions from a security standpoint - updater.private_token = None # "tokenstring" - - # choose your own username, must match website (not needed for GitLab) - updater.user = "cgcookie" - - # choose your own repository, must match git name for GitHUb and Bitbucket, - # for GitLab use project ID (numbers only) - updater.repo = "blender-addon-updater" - - # updater.addon = # define at top of module, MUST be done first - - # Website for manual addon download, optional but recommended to set - updater.website = "https://github.com/CGCookie/blender-addon-updater/" - - # Addon subfolder path - # "sample/path/to/addon" - # default is "" or None, meaning root - updater.subfolder_path = "" - - # used to check/compare versions - updater.current_version = bl_info["version"] - - # Optional, to hard-set update frequency, use this here - however, - # this demo has this set via UI properties. - # updater.set_check_interval( - # enable=False,months=0,days=0,hours=0,minutes=2) - - # Optional, consider turning off for production or allow as an option - # This will print out additional debugging info to the console - updater.verbose = True # make False for production default - - # Optional, customize where the addon updater processing subfolder is, - # essentially a staging folder used by the updater on its own - # Needs to be within the same folder as the addon itself - # Need to supply a full, absolute path to folder - # updater.updater_path = # set path of updater folder, by default: - # /addons/{__package__}/{__package__}_updater - - # auto create a backup of the addon when installing other versions - updater.backup_current = True # True by default - - # Sample ignore patterns for when creating backup of current during update - updater.backup_ignore_patterns = ["__pycache__"] - # Alternate example patterns - # updater.backup_ignore_patterns = [".git", "__pycache__", "*.bat", ".gitignore", "*.exe"] - - # Patterns for files to actively overwrite if found in new update - # file and are also found in the currently installed addon. Note that - - # by default (ie if set to []), updates are installed in the same way as blender: - # .py files are replaced, but other file types (e.g. json, txt, blend) - # will NOT be overwritten if already present in current install. Thus - # if you want to automatically update resources/non py files, add them - # as a part of the pattern list below so they will always be overwritten by an - # update. If a pattern file is not found in new update, no action is taken - # This does NOT delete anything, only defines what is allowed to be overwritten - updater.overwrite_patterns = ["*.png", "*.jpg", "README.md", "LICENSE.txt"] - # updater.overwrite_patterns = [] - # other examples: - # ["*"] means ALL files/folders will be overwritten by update, was the behavior pre updater v1.0.4 - # [] or ["*.py","*.pyc"] matches default blender behavior, ie same effect if user installs update manually without deleting the existing addon first - # e.g. if existing install and update both have a resource.blend file, the existing installed one will remain - # ["some.py"] means if some.py is found in addon update, it will overwrite any existing some.py in current addon install, if any - # ["*.json"] means all json files found in addon update will overwrite those of same name in current install - # ["*.png","README.md","LICENSE.txt"] means the readme, license, and all pngs will be overwritten by update - - # Patterns for files to actively remove prior to running update - # Useful if wanting to remove old code due to changes in filenames - # that otherwise would accumulate. Note: this runs after taking - # a backup (if enabled) but before placing in new update. If the same - # file name removed exists in the update, then it acts as if pattern - # is placed in the overwrite_patterns property. Note this is effectively - # ignored if clean=True in the run_update method - updater.remove_pre_update_patterns = ["*.py", "*.pyc"] - # Note setting ["*"] here is equivalent to always running updates with - # clean = True in the run_update method, ie the equivalent of a fresh, - # new install. This would also delete any resources or user-made/modified - # files setting ["__pycache__"] ensures the pycache folder is always removed - # The configuration of ["*.py","*.pyc"] is a safe option as this - # will ensure no old python files/caches remain in event different addon - # versions have different filenames or structures - - # Allow branches like 'master' as an option to update to, regardless - # of release or version. - # Default behavior: releases will still be used for auto check (popup), - # but the user has the option from user preferences to directly - # update to the master branch or any other branches specified using - # the "install {branch}/older version" operator. - updater.include_branches = True - - # (GitHub only) This options allows the user to use releases over tags for data, - # which enables pulling down release logs/notes, as well as specify installs from - # release-attached zips (instead of just the auto-packaged code generated with - # a release/tag). Setting has no impact on BitBucket or GitLab repos - updater.use_releases = False - # note: Releases always have a tag, but a tag may not always be a release - # Therefore, setting True above will filter out any non-annotated tags - # note 2: Using this option will also display the release name instead of - # just the tag name, bear this in mind given the skip_tag_function filtering above - - # if using "include_branches", - # updater.include_branch_list defaults to ['master'] branch if set to none - # example targeting another multiple branches allowed to pull from - # updater.include_branch_list = ['master', 'dev'] # example with two branches - updater.include_branch_list = None # None is the equivalent to setting ['master'] - - # Only allow manual install, thus prompting the user to open - # the addon's web page to download, specifically: updater.website - # Useful if only wanting to get notification of updates but not - # directly install. - updater.manual_only = False - - # Used for development only, "pretend" to install an update to test - # reloading conditions - updater.fake_install = False # Set to true to test callback/reloading - - # Show popups, ie if auto-check for update is enabled or a previous - # check for update in user preferences found a new version, show a popup - # (at most once per blender session, and it provides an option to ignore - # for future sessions); default behavior is set to True - updater.show_popups = True - # note: if set to false, there will still be an "update ready" box drawn - # using the `update_notice_box_ui` panel function. - - # Override with a custom function on what tags - # to skip showing for updater; see code for function above. - # Set the min and max versions allowed to install. - # Optional, default None - # min install (>=) will install this and higher - updater.version_min_update = (0, 0, 0) - # updater.version_min_update = None # if not wanting to define a min - - # max install (<) will install strictly anything lower - # updater.version_max_update = (9,9,9) - updater.version_max_update = None # set to None if not wanting to set max - - # Function defined above, customize as appropriate per repository - updater.skip_tag = skip_tag_function # min and max used in this function - - # Function defined above, customize as appropriate per repository; not required - updater.select_link = select_link_function - - # Recommended false to encourage blender restarts on update completion - # Setting this option to True is NOT as stable as false (could cause - # blender crashes) - updater.auto_reload_post_update = False - - # The register line items for all operators/panels - # If using bpy.utils.register_module(__name__) to register elsewhere - # in the addon, delete these lines (also from unregister) - for cls in classes: - # apply annotations to remove Blender 2.8 warnings, no effect on 2.7 - make_annotations(cls) - # comment out this line if using bpy.utils.register_module(__name__) - bpy.utils.register_class(cls) - - # special situation: we just updated the addon, show a popup - # to tell the user it worked - # should be enclosed in try/catch in case other issues arise - show_reload_popup() + """Registering the operators in this module""" + # safer failure in case of issue loading module + if updater.error: + print("Exiting updater registration, " + updater.error) + return + updater.clear_state() # clear internal vars, avoids reloading oddities + + # confirm your updater "engine" (Github is default if not specified) + updater.engine = "Github" + # updater.engine = "GitLab" + # updater.engine = "Bitbucket" + + # If using private repository, indicate the token here + # Must be set after assigning the engine. + # **WARNING** Depending on the engine, this token can act like a password!! + # Only provide a token if the project is *non-public*, see readme for + # other considerations and suggestions from a security standpoint + updater.private_token = None # "tokenstring" + + # choose your own username, must match website (not needed for GitLab) + updater.user = "cgcookie" + + # choose your own repository, must match git name for GitHUb and Bitbucket, + # for GitLab use project ID (numbers only) + updater.repo = "blender-addon-updater" + + # updater.addon = # define at top of module, MUST be done first + + # Website for manual addon download, optional but recommended to set + updater.website = "https://github.com/CGCookie/blender-addon-updater/" + + # Addon subfolder path + # "sample/path/to/addon" + # default is "" or None, meaning root + updater.subfolder_path = "" + + # used to check/compare versions + updater.current_version = bl_info["version"] + + # Optional, to hard-set update frequency, use this here - however, + # this demo has this set via UI properties. + # updater.set_check_interval( + # enable=False,months=0,days=0,hours=0,minutes=2) + + # Optional, consider turning off for production or allow as an option + # This will print out additional debugging info to the console + updater.verbose = True # make False for production default + + # Optional, customize where the addon updater processing subfolder is, + # essentially a staging folder used by the updater on its own + # Needs to be within the same folder as the addon itself + # Need to supply a full, absolute path to folder + # updater.updater_path = # set path of updater folder, by default: + # /addons/{__package__}/{__package__}_updater + + # auto create a backup of the addon when installing other versions + updater.backup_current = True # True by default + + # Sample ignore patterns for when creating backup of current during update + updater.backup_ignore_patterns = ["__pycache__"] + # Alternate example patterns + # updater.backup_ignore_patterns = [".git", "__pycache__", "*.bat", ".gitignore", "*.exe"] + + # Patterns for files to actively overwrite if found in new update + # file and are also found in the currently installed addon. Note that + + # by default (ie if set to []), updates are installed in the same way as blender: + # .py files are replaced, but other file types (e.g. json, txt, blend) + # will NOT be overwritten if already present in current install. Thus + # if you want to automatically update resources/non py files, add them + # as a part of the pattern list below so they will always be overwritten by an + # update. If a pattern file is not found in new update, no action is taken + # This does NOT delete anything, only defines what is allowed to be overwritten + updater.overwrite_patterns = ["*.png", "*.jpg", "README.md", "LICENSE.txt"] + # updater.overwrite_patterns = [] + # other examples: + # ["*"] means ALL files/folders will be overwritten by update, was the behavior pre updater v1.0.4 + # [] or ["*.py","*.pyc"] matches default blender behavior, ie same effect if user installs update manually without deleting the existing addon first + # e.g. if existing install and update both have a resource.blend file, the existing installed one will remain + # ["some.py"] means if some.py is found in addon update, it will overwrite any existing some.py in current addon install, if any + # ["*.json"] means all json files found in addon update will overwrite those of same name in current install + # ["*.png","README.md","LICENSE.txt"] means the readme, license, and all pngs will be overwritten by update + + # Patterns for files to actively remove prior to running update + # Useful if wanting to remove old code due to changes in filenames + # that otherwise would accumulate. Note: this runs after taking + # a backup (if enabled) but before placing in new update. If the same + # file name removed exists in the update, then it acts as if pattern + # is placed in the overwrite_patterns property. Note this is effectively + # ignored if clean=True in the run_update method + updater.remove_pre_update_patterns = ["*.py", "*.pyc"] + # Note setting ["*"] here is equivalent to always running updates with + # clean = True in the run_update method, ie the equivalent of a fresh, + # new install. This would also delete any resources or user-made/modified + # files setting ["__pycache__"] ensures the pycache folder is always removed + # The configuration of ["*.py","*.pyc"] is a safe option as this + # will ensure no old python files/caches remain in event different addon + # versions have different filenames or structures + + # Allow branches like 'master' as an option to update to, regardless + # of release or version. + # Default behavior: releases will still be used for auto check (popup), + # but the user has the option from user preferences to directly + # update to the master branch or any other branches specified using + # the "install {branch}/older version" operator. + updater.include_branches = True + + # (GitHub only) This options allows the user to use releases over tags for data, + # which enables pulling down release logs/notes, as well as specify installs from + # release-attached zips (instead of just the auto-packaged code generated with + # a release/tag). Setting has no impact on BitBucket or GitLab repos + updater.use_releases = False + # note: Releases always have a tag, but a tag may not always be a release + # Therefore, setting True above will filter out any non-annotated tags + # note 2: Using this option will also display the release name instead of + # just the tag name, bear this in mind given the skip_tag_function filtering above + + # if using "include_branches", + # updater.include_branch_list defaults to ['master'] branch if set to none + # example targeting another multiple branches allowed to pull from + # updater.include_branch_list = ['master', 'dev'] # example with two branches + updater.include_branch_list = None # None is the equivalent to setting ['master'] + + # Only allow manual install, thus prompting the user to open + # the addon's web page to download, specifically: updater.website + # Useful if only wanting to get notification of updates but not + # directly install. + updater.manual_only = False + + # Used for development only, "pretend" to install an update to test + # reloading conditions + updater.fake_install = False # Set to true to test callback/reloading + + # Show popups, ie if auto-check for update is enabled or a previous + # check for update in user preferences found a new version, show a popup + # (at most once per blender session, and it provides an option to ignore + # for future sessions); default behavior is set to True + updater.show_popups = True + # note: if set to false, there will still be an "update ready" box drawn + # using the `update_notice_box_ui` panel function. + + # Override with a custom function on what tags + # to skip showing for updater; see code for function above. + # Set the min and max versions allowed to install. + # Optional, default None + # min install (>=) will install this and higher + updater.version_min_update = (0, 0, 0) + # updater.version_min_update = None # if not wanting to define a min + + # max install (<) will install strictly anything lower + # updater.version_max_update = (9,9,9) + updater.version_max_update = None # set to None if not wanting to set max + + # Function defined above, customize as appropriate per repository + updater.skip_tag = skip_tag_function # min and max used in this function + + # Function defined above, customize as appropriate per repository; not required + updater.select_link = select_link_function + + # Recommended false to encourage blender restarts on update completion + # Setting this option to True is NOT as stable as false (could cause + # blender crashes) + updater.auto_reload_post_update = False + + # The register line items for all operators/panels + # If using bpy.utils.register_module(__name__) to register elsewhere + # in the addon, delete these lines (also from unregister) + for cls in classes: + # apply annotations to remove Blender 2.8 warnings, no effect on 2.7 + make_annotations(cls) + # comment out this line if using bpy.utils.register_module(__name__) + bpy.utils.register_class(cls) + + # special situation: we just updated the addon, show a popup + # to tell the user it worked + # should be enclosed in try/catch in case other issues arise + show_reload_popup() def unregister(): - for cls in reversed(classes): - # comment out this line if using bpy.utils.unregister_module(__name__) - bpy.utils.unregister_class(cls) + for cls in reversed(classes): + # comment out this line if using bpy.utils.unregister_module(__name__) + bpy.utils.unregister_class(cls) - # clear global vars since they may persist if not restarting blender - updater.clear_state() # clear internal vars, avoids reloading oddities + # clear global vars since they may persist if not restarting blender + updater.clear_state() # clear internal vars, avoids reloading oddities - global ran_auto_check_install_popup - ran_auto_check_install_popup = False + global ran_auto_check_install_popup + ran_auto_check_install_popup = False - global ran_update_success_popup - ran_update_success_popup = False + global ran_update_success_popup + ran_update_success_popup = False - global ran_background_check - ran_background_check = False + global ran_background_check + ran_background_check = False