diff --git a/Pckgd/Pckgd.py b/Pckgd/Pckgd.py index a908dca..f944c22 100644 --- a/Pckgd/Pckgd.py +++ b/Pckgd/Pckgd.py @@ -29,7 +29,7 @@ def __init__(self, type_id, name, body): def __del__(self): Pckgd._save_meta_if_dirty() - do_not_edit_demarcation = "=========" + do_not_edit_demarcation = "PCKGD_MANAGED_SECTION" dependents = {} _saved_meta = None @@ -99,7 +99,7 @@ def get_action_buttons(self): if self.has_update_available(): buttons.append(""" - Update + Update """.format(self.filename_with_extension())) return "\n".join(buttons) @@ -167,11 +167,11 @@ def get_repo_link(self): if self.headers['Updates From'].lower().startswith("github"): path = self.headers['Updates From'].split('/', 3) if len(path) == 4: # some level of validation... - github_meta = Pckgd._get_github_repo_metadata(path[1] + "/" + path[2]) + source_meta = Pckgd._get_source_repo_metadata(path[1] + "/" + path[2]) default_branch = "main" - if 'default_branch' in github_meta: - default_branch = github_meta['default_branch'] + if 'default_branch' in source_meta: + default_branch = source_meta['default_branch'] return "https://github.com/{}/{}/blob/{}/{}".format(path[1], path[2], default_branch, path[3]) return None @@ -256,11 +256,11 @@ def get_update_source(self): if self.headers['Updates From'].lower().startswith("github"): path = self.headers['Updates From'].split('/', 3) if len(path) == 4: # some level of validation... - github_meta = Pckgd._get_github_repo_metadata(path[1] + "/" + path[2]) + source_meta = Pckgd._get_source_repo_metadata(path[1] + "/" + path[2]) default_branch = "main" - if 'default_branch' in github_meta: - default_branch = github_meta['default_branch'] + if 'default_branch' in source_meta: + default_branch = source_meta['default_branch'] return "https://raw.githubusercontent.com/{}/{}/refs/heads/{}/{}".format(path[1], path[2], default_branch, path[3]) @@ -279,7 +279,7 @@ def has_update_available(self): return self._has_update_available remote_content = model.RestGet(update_source, {}) - if remote_content == "404: Not Found": # How GitHub specifically handles these things. + if remote_content == "404: Not Found": # How source repositories (e.g., GitHub) handle 404s self._has_update_available = False raise Exception("Update source not found: {}".format(update_source)) @@ -301,24 +301,25 @@ def _get_saved_meta(): return Pckgd._saved_meta @staticmethod - def _get_github_repo_metadata(repo_path, bypass_cache=False): + def _get_source_repo_metadata(repo_path, bypass_cache=False): + """Get metadata for a source repository (e.g., GitHub)""" meta = Pckgd._get_saved_meta() - if not "github_repo_meta" in meta: - meta["github_repo_meta"] = {} + if not "source_repo_meta" in meta: + meta["source_repo_meta"] = {} - if not repo_path in meta["github_repo_meta"]: - meta["github_repo_meta"][repo_path] = {} + if not repo_path in meta["source_repo_meta"]: + meta["source_repo_meta"][repo_path] = {} - if '_expires' not in meta["github_repo_meta"][repo_path] or meta["github_repo_meta"][repo_path]['_expires'] < time.time() or bypass_cache: - # query GitHub api to get default branch and other such stuff + if '_expires' not in meta["source_repo_meta"][repo_path] or meta["source_repo_meta"][repo_path]['_expires'] < time.time() or bypass_cache: + # Query GitHub API to get default branch and other metadata url = "https://api.github.com/repos/{}".format(repo_path) response = model.RestGet(url, {"Accept": "application/vnd.github.v3+json"}) - meta["github_repo_meta"][repo_path]['data'] = json.loads(response) - meta["github_repo_meta"][repo_path]['_expires'] = time.time() + 86400 # cache for 1 day + meta["source_repo_meta"][repo_path]['data'] = json.loads(response) + meta["source_repo_meta"][repo_path]['_expires'] = time.time() + 86400 # cache for 1 day meta['_dirty'] = True - return meta["github_repo_meta"][repo_path]['data'] + return meta["source_repo_meta"][repo_path]['data'] @staticmethod def _save_meta_if_dirty(): @@ -328,27 +329,666 @@ def _save_meta_if_dirty(): model.WriteContentText("PckgdCache.json", json.dumps(meta, indent=2)) - def do_update(self, new_pckg): - # Update the content in the system - # If using demarcation, preserve anything above it (the "preamble"). + @staticmethod + def _merge_variables(local_preamble, source_preamble, type_id): + """ + Intelligently merge variable sections using difflib: + - Uses three-way merge logic + - Detects what changed between local and source + - Automatically resolves non-conflicting changes + - Preserves user customizations + - Adds new variables from source + """ + import difflib + import re + + comment_char = '#' if type_id == 5 else '--' + + # Helper to separate headers from variables + def split_headers_and_vars(preamble): + """Split preamble into headers and configuration variables""" + lines = preamble.split('\n') + headers = [] + config = [] + in_config = False + var_pattern = r'^([a-zA-Z_][a-zA-Z0-9_]*)\s*=\s*(.+)$' + + for line in lines: + stripped = line.strip() + + # Check if we're entering the config section + if not in_config and 'CONFIGURATION' in line and line.strip().startswith(comment_char): + # This is the config section header + in_config = True + config.append(line) + continue + + # Check if this line is a variable assignment (actual code, not comment) + is_variable = False + if stripped and not stripped.startswith(comment_char): + # Check if it matches variable pattern + if re.match(var_pattern, stripped): + is_variable = True + in_config = True + + if is_variable or in_config: + # We're in or starting the config section + config.append(line) + else: + # Still in headers + headers.append(line) + + return '\n'.join(headers), '\n'.join(config) + + # Parse variable assignments (simple but robust) + def parse_variables(code): + """Extract variable assignments as {var_name: full_assignment_text}""" + var_pattern = r'^([a-zA-Z_][a-zA-Z0-9_]*)\s*=\s*(.+)$' + variables = {} + lines = code.split('\n') + current_var = None + current_lines = [] + + for line in lines: + stripped = line.strip() + + # Skip empty lines and comments + if not stripped or stripped.startswith(comment_char): + # If we were tracking a multi-line, this ends it + if current_var: + variables[current_var] = '\n'.join(current_lines) + current_var = None + current_lines = [] + continue + + # Check if this is a variable assignment + match = re.match(var_pattern, stripped) + if match: + # Save previous variable if any + if current_var: + variables[current_var] = '\n'.join(current_lines) + + current_var = match.group(1) + current_lines = [line] + + # Check for unclosed brackets/parens (multi-line) + value = match.group(2) + open_count = value.count('(') + value.count('[') + value.count('{') + close_count = value.count(')') + value.count(']') + value.count('}') + + if open_count == close_count and not value.endswith('\\'): + # Single line assignment + variables[current_var] = line + current_var = None + current_lines = [] + elif current_var: + # Continuation of multi-line assignment + current_lines.append(line) + + # Check if we're closing the assignment + if ')' in line or ']' in line or '}' in line: + # Count brackets to see if balanced + full_text = '\n'.join(current_lines) + open_count = full_text.count('(') + full_text.count('[') + full_text.count('{') + close_count = full_text.count(')') + full_text.count(']') + full_text.count('}') + + if open_count == close_count: + variables[current_var] = full_text + current_var = None + current_lines = [] + + # Save last variable if any + if current_var: + variables[current_var] = '\n'.join(current_lines) + + return variables + + def get_structure(code): + """Get code structure preserving comments and order""" + lines = code.split('\n') + structure = [] + var_pattern = r'^([a-zA-Z_][a-zA-Z0-9_]*)\s*=\s*(.+)$' + in_var = None + + for line in lines: + stripped = line.strip() + + # Check if this is a variable assignment (not a comment) + match = re.match(var_pattern, stripped) if stripped and not stripped.startswith(comment_char) else None + + if match: + if in_var: + structure.append(('var_end', in_var)) + in_var = match.group(1) + structure.append(('var_start', in_var, line)) + elif in_var and stripped and not stripped.startswith(comment_char): + # Continuation of multi-line assignment + structure.append(('var_continue', in_var, line)) + if ')' in line or ']' in line or '}' in line: + # Might be end of multi-line + in_var = None + else: + # Comments, blank lines, etc. + if in_var: + structure.append(('var_end', in_var)) + in_var = None + structure.append(('text', None, line)) + + return structure + + # Separate headers from configuration variables + local_headers, local_config = split_headers_and_vars(local_preamble) + source_headers, source_config = split_headers_and_vars(source_preamble) + + # If local has config section but source doesn't, we need to be smarter + # Source might have variables but not the CONFIGURATION header yet + if local_config and not source_config: + # Check if source has any variables at all + source_has_vars = False + for line in source_preamble.split('\n'): + stripped = line.strip() + if stripped and not stripped.startswith(comment_char): + import re + if re.match(r'^[a-zA-Z_][a-zA-Z0-9_]*\s*=', stripped): + source_has_vars = True + break + + if source_has_vars: + # Source has variables but no CONFIGURATION section + # Split source differently - everything after Editable is config + lines = source_preamble.split('\n') + header_lines = [] + config_lines = [] + found_editable = False + + for line in lines: + if 'Editable:' in line: + header_lines.append(line) + found_editable = True + elif found_editable: + config_lines.append(line) + else: + header_lines.append(line) + + source_headers = '\n'.join(header_lines) + source_config = '\n'.join(config_lines) + + # DEBUG: Log the configs before parsing + try: + model.DebugPrint("=== CONFIG SECTIONS ===") + model.DebugPrint("Local config length: " + str(len(local_config))) + model.DebugPrint("Source config length: " + str(len(source_config))) + model.DebugPrint("Local config first 500 chars:\n" + local_config[:500]) + model.DebugPrint("Source config first 500 chars:\n" + source_config[:500]) + except Exception as ex: + model.DebugPrint("DEBUG ERROR: " + str(ex)) + + # Parse only the configuration sections + local_vars = parse_variables(local_config) + source_vars = parse_variables(source_config) + + # DEBUG: Log what we parsed + try: + model.DebugPrint("=== MERGE DEBUG ===") + model.DebugPrint("Local vars: " + str(local_vars.keys())) + model.DebugPrint("Source vars: " + str(source_vars.keys())) + if 'AZURE_ACCOUNT_KEY' in local_vars: + model.DebugPrint("Local AZURE_ACCOUNT_KEY: " + local_vars['AZURE_ACCOUNT_KEY'][:50]) + if 'AZURE_ACCOUNT_KEY' in source_vars: + model.DebugPrint("Source AZURE_ACCOUNT_KEY: " + source_vars['AZURE_ACCOUNT_KEY'][:50]) + except Exception as ex: + model.DebugPrint("DEBUG ERROR: " + str(ex)) + + # Compute differences using difflib + local_var_names = set(local_vars.keys()) + source_var_names = set(source_vars.keys()) + + # Variables only in local (user added) + local_only = local_var_names - source_var_names + + # Variables only in source (new from update) + source_only = source_var_names - local_var_names + + # Variables in both (potential conflicts or unchanged) + both = local_var_names & source_var_names + + # Check for conflicts (both changed same variable) + conflicts = [] + for var in both: + if local_vars[var] != source_vars[var]: + # Variable exists in both but values differ + # This is where user customized it - keep user's version + conflicts.append(var) + + # Build merged result + merged_lines = [] + processed = set() + + # Start with source headers (updated version, etc.) + if source_headers: + merged_lines.append(source_headers) + + # Add a blank line between headers and config + if source_headers and (local_config or source_config): + merged_lines.append('') + + # Use local config structure to preserve comments and formatting + # This includes the # ========== CONFIGURATION ========== header + if local_config: + local_structure = get_structure(local_config) + current_var_lines = [] + current_var = None + + # Track the closing marker position (# =========) + closing_marker_index = None + temp_merged_lines = [] + + for idx, item in enumerate(local_structure): + if item[0] == 'var_start': + var_name = item[1] + current_var = var_name + current_var_lines = [] + + # Always use local version if it exists + if var_name in local_vars: + temp_merged_lines.append(local_vars[var_name]) + processed.add(var_name) + current_var = None + else: + # Shouldn't happen since we're iterating local structure + current_var_lines.append(item[2]) + elif item[0] == 'var_continue' and current_var: + current_var_lines.append(item[2]) + elif item[0] == 'var_end' or (item[0] == 'text' and current_var): + if current_var and current_var_lines: + temp_merged_lines.append('\n'.join(current_var_lines)) + processed.add(current_var) + current_var = None + current_var_lines = [] + + if item[0] == 'text': + # Check if this is the closing marker line + line = item[2] + if line.strip().startswith(comment_char) and '========' in line and len(line.strip()) < 20: + closing_marker_index = len(temp_merged_lines) + # Preserve comments and blank lines from local + temp_merged_lines.append(line) + else: + # Preserve all text (comments, blank lines, etc.) from local + line = item[2] + if line.strip().startswith(comment_char) and '========' in line and len(line.strip()) < 20: + closing_marker_index = len(temp_merged_lines) + temp_merged_lines.append(line) + + # Add remaining current var if any + if current_var and current_var_lines: + temp_merged_lines.append('\n'.join(current_var_lines)) + processed.add(current_var) + + # Add new variables from source - insert before closing marker if found + source_only_vars = source_var_names - local_var_names + if source_only_vars: + new_var_lines = [] + for var in sorted(source_only_vars): + new_var_lines.append(source_vars[var]) + + # Insert new variables before the closing marker, or at the end + if closing_marker_index is not None: + # Insert before closing marker + merged_lines.extend(temp_merged_lines[:closing_marker_index]) + merged_lines.append('') # Blank line before new vars + merged_lines.extend(new_var_lines) + merged_lines.extend(temp_merged_lines[closing_marker_index:]) + else: + # No closing marker, add at end + merged_lines.extend(temp_merged_lines) + merged_lines.append('') + merged_lines.extend(new_var_lines) + else: + # No new variables, use temp lines as-is + merged_lines.extend(temp_merged_lines) + elif source_config: + # No local config exists, use source config entirely + merged_lines.append(source_config) + + # Add user-only variables at the end + if local_only: + merged_lines.append('') + merged_lines.append('{} User-added variables (not in source):'.format(comment_char)) + for var in sorted(local_only): + merged_lines.append(local_vars[var]) + + result = '\n'.join(merged_lines) + + # DEBUG: Log the result + try: + model.DebugPrint("=== MERGE RESULT ===") + model.DebugPrint("Result length: " + str(len(result))) + model.DebugPrint("First 500 chars: " + result[:500]) + except: + pass + + return result + + @staticmethod + def generate_diff_html(local_text, source_text, context_lines=3): + """Generate side-by-side HTML diff view using difflib""" + import difflib + + local_lines = local_text.splitlines() + source_lines = source_text.splitlines() + + # Use SequenceMatcher for side-by-side comparison + matcher = difflib.SequenceMatcher(None, local_lines, source_lines) + + html_lines = ['
| Line | ') + html_lines.append('Your Current Version | ') + html_lines.append('Line | ') + html_lines.append('After Update | ') + html_lines.append('||||
|---|---|---|---|---|---|---|---|
| {} | '.format(local_line_num)) + html_lines.append('{} | '.format( + local_lines[i].replace('<', '<').replace('>', '>') + )) + html_lines.append('{} | '.format(source_line_num)) + html_lines.append('{} | '.format( + source_lines[j].replace('<', '<').replace('>', '>') + )) + html_lines.append('||||
| {} | '.format(local_line_num)) + html_lines.append('{} | '.format( + local_lines[i].replace('<', '<').replace('>', '>') + )) + html_lines.append('{} | '.format(source_line_num)) + html_lines.append('{} | '.format( + source_lines[j].replace('<', '<').replace('>', '>') + )) + html_lines.append('||||
| ... | ') + html_lines.append('({} unchanged lines) | '.format(lines_skipped)) + html_lines.append('... | ') + html_lines.append('({} unchanged lines) | '.format(lines_skipped)) + html_lines.append('||||
| {} | '.format(local_line_num)) + html_lines.append('{} | '.format( + local_lines[i].replace('<', '<').replace('>', '>') + )) + html_lines.append('{} | '.format(source_line_num)) + html_lines.append('{} | '.format( + source_lines[j].replace('<', '<').replace('>', '>') + )) + html_lines.append('||||
| {} | '.format(local_line_num)) + html_lines.append('{} | '.format( + local_lines[i].replace('<', '<').replace('>', '>') + )) + html_lines.append('') + html_lines.append(' | ') + html_lines.append(' | ||||
| ') + html_lines.append(' | ') + html_lines.append(' | {} | '.format(source_line_num)) + html_lines.append('{} | '.format( + source_lines[j].replace('<', '<').replace('>', '>') + )) + html_lines.append('||||
| {} | '.format(local_line_num)) + html_lines.append('{} | '.format( + local_lines[i].replace('<', '<').replace('>', '>') + )) + local_line_num += 1 + else: + html_lines.append('') + html_lines.append(' | ') + + if j < j2: + html_lines.append(' | {} | '.format(source_line_num)) + html_lines.append('{} | '.format( + source_lines[j].replace('<', '<').replace('>', '>') + )) + source_line_num += 1 + else: + html_lines.append('') + html_lines.append(' | ') + + html_lines.append(' |
This package is already up to date.
\n") return - # Perform the update - try: - pkg.do_update(remote_pkg) - print("Package updated successfully to version {}.
\n".format(remote_pkg.version)) - except Exception as e: - print("Error updating package: {}
\n".format(str(e))) + # Show diff if requested + if show_diff: + print("Current version: {} → New version: {}
".format( + pkg.version, remote_pkg.version + )) + + # Action buttons at top + print('') + print('Confirm Update '.format(pkg_name)) + print('Cancel') + print('
') + + # Generate the merged result (what will actually be saved) + merged_body = pkg.generate_merged_body(remote_pkg) + + # Show diff of configuration section if it exists + if Pckgd.do_not_edit_demarcation in pkg.body: + local_preamble = pkg.body.split(Pckgd.do_not_edit_demarcation)[0] + if Pckgd.do_not_edit_demarcation in merged_body: + merged_preamble = merged_body.split(Pckgd.do_not_edit_demarcation)[0] + + print("Your custom values will be preserved. New settings will be added if available.
") + # Use large context to show all configuration lines + print(Pckgd.generate_diff_html(local_preamble, merged_preamble, context_lines=1000)) + + # Show full file diff in collapsible section + print('This shows all changes including code updates:
") + # Use moderate context with ellipsis for very long unchanged sections + print(Pckgd.generate_diff_html(pkg.body, merged_body, context_lines=5)) + print('Package updated successfully to version {}.
\n".format(remote_pkg.version)) + print('') + except Exception as e: + print("Error updating package: {}
\n".format(str(e))) + else: + # Default: show preview option + print("An update is available. Version {} → {}
".format( + pkg.version, remote_pkg.version + )) + print('Preview Changes'.format(pkg_name)) + print('Update Now'.format(pkg_name)) + print('Cancel
') if model.HttpMethod == "get" and Data.v == "": @@ -501,6 +1194,3 @@ def do_update_view(): elif model.HttpMethod == "get" and Data.v == "update" and Data.pkg != "": do_update_view() - - -