2025-12-01

This commit is contained in:
2026-03-17 14:58:51 -06:00
parent 183e865f8b
commit 4b82b57113
6846 changed files with 954887 additions and 162606 deletions
@@ -0,0 +1,25 @@
# SPDX-FileCopyrightText: 2025 Blender Studio Tools Authors
#
# SPDX-License-Identifier: GPL-3.0-or-later
from . import (
background_process,
execute_subprocess,
svn_log,
svn_status,
filebrowser_activate_file,
update,
commit,
redraw_viewport
)
modules = [
background_process,
execute_subprocess,
svn_log,
svn_status,
filebrowser_activate_file,
update,
commit,
redraw_viewport
]
@@ -0,0 +1,277 @@
# SPDX-FileCopyrightText: 2022 Blender Studio Tools Authors
#
# SPDX-License-Identifier: GPL-3.0-or-later
import bpy
import threading
import subprocess
import random
from typing import List
from ..util import get_addon_prefs, redraw_viewport
from bpy.app.handlers import persistent
class BackgroundProcess:
"""
Base class that uses bpy.app.timers and threading to execute SVN commands
without freezing the interface.
The class should be extended and the process_output and acquire_output functions
implemented for each SVN command, then a single instance of that subclass should
be created, which can from that point on be used to manage that SVN process.
"""
name = "Unnamed Process"
# If the acquire_output() function doesn't write anything into
# self.output/self.error after this long, we will write a timeout
# error into self.error.
timeout = 10
# After a successful execution of process_output(), wait this many seconds
# before trying to acquire_output() again.
# If 0, repeated execution will stop.
repeat_delay = 15
# How many seconds to wait between checks for whether output has been acquired yet.
tick_delay = 1.0
# Time in seconds to delay the first execution by.
first_interval = 0
needs_authentication = False
# Displayed in the tooltip on mouse-hover in the error message when an error occurs.
error_description = "SVN Error:"
debug = False
def debug_print(self, msg: str):
if self.debug:
print(f"{self.name} (#{self.id}): {msg}")
def __init__(self):
self.thread = None
self.thread_start_time = 0
self.is_running = False
self.output = ""
self.error = ""
self.id = int(random.random() * 10000)
self.start()
def acquire_output_safe(self, context, prefs):
"""
Executed from a thread to avoid UI freezing during execute_svn_command().
Should save data into self.output and self.error.
Reading Blender data from this function is safe, but writing isn't!
"""
repo = context.scene.svn.get_repo(context)
if not repo.is_valid_svn:
self.stop()
try:
self.acquire_output(context, prefs)
except subprocess.CalledProcessError as error:
self.handle_error(context, error)
def acquire_output(self, context, prefs):
raise NotImplementedError
def handle_error(self, context, error):
self.output = ""
self.error = error.stderr.decode()
self.stop()
def process_output(self, context, prefs):
"""
Executed on main thread when there is any value in self.output or self.error.
It is safe to read and write Blender data from this function.
"""
raise NotImplementedError
def tick(self, context, prefs):
"""
Executed repeatedly while the timer is running, with tick_delay seconds between
each call.
Doesn't have to be used for anything. Can be useful for redrawing the UI.
Just be careful with this though.
"""
return
@persistent
def timer_function(self):
"""This is the actual function registered to bpy.app.timers."""
context = bpy.context
if not hasattr(context.scene, 'svn'):
# With some bad luck, this can happen when in the middle of opening a .blend file.
return 1
repo = context.scene.svn.get_repo(context)
if not repo:
self.debug_print("Shutdown: Not in repo.")
self.stop()
return
prefs = get_addon_prefs(context)
self.tick(context, prefs)
if not self.is_running:
# Since unregistering timers seems to be broken, let's allow setting
# is_running to False in order to shut down this process.
self.debug_print("Shutdown: is_running was set to False.")
return
if self.needs_authentication and not repo.authenticated:
self.debug_print("Shutdown: Authentication needed.")
self.stop()
return
if not self.thread or not self.thread.is_alive() and not self.output and not self.error:
self.thread = threading.Thread(
target=self.acquire_output_safe, args=(context, prefs))
self.thread.start()
self.debug_print("Started thread")
return self.tick_delay
elif self.error:
self.debug_print("Shutdown: There was an error.")
return
elif self.output:
self.debug_print("Processing output")
# self.debug_print("Processing output: \n" + str(self.output))
self.process_output(context, prefs)
self.output = ""
redraw_viewport()
if self.repeat_delay == 0:
self.debug_print("Shutdown: Output was processed, repeat_delay==0.")
self.stop()
return
self.debug_print(f"Processed output. Waiting {self.repeat_delay}")
return self.repeat_delay
elif not self.thread and not self.thread.is_alive() and self.repeat_delay == 0:
self.debug_print("Shutdown: Finished.\n")
self.stop()
return
self.debug_print(f"Tick delay: {self.tick_delay}")
return self.tick_delay
def get_ui_message(self, context) -> str:
"""Return a string that should be drawn in the UI for user feedback,
depending on the state of the process."""
if self.is_running:
return "Running..."
return ""
def restart(self):
self.stop()
self.start()
def start(self, persistent=True):
"""Start the process if it isn't running already, by registering its timer function."""
self.is_running = True
self.error = ""
self.output = ""
if not bpy.app.timers.is_registered(self.timer_function):
self.debug_print("Register timer")
bpy.app.timers.register(
self.timer_function,
first_interval=self.first_interval,
persistent=persistent
)
def stop(self):
"""Stop the process if it isn't running, by unregistering its timer function"""
self.debug_print("stop() function was called.")
self.is_running = False
if bpy.app.timers.is_registered(self.timer_function):
# This won't work if the timer has returned None at any point, as that
# will have already unregistered it.
# Actually, it doesn't seem to work anyways...
bpy.app.timers.unregister(self.timer_function)
self.debug_print("Force-unregistered.")
def get_recursive_subclasses(typ) -> List[type]:
ret = []
for subcl in typ.__subclasses__():
ret.append(subcl)
ret.extend(get_recursive_subclasses(subcl))
return ret
processes = {}
class ProcessManager:
@property
def processes(self):
# I tried to implement this thing as a Singleton that inherits from the `dict` class,
# I tried having the `processes` dict on the class level,
# I tried having it on the instance level,
# and it just refuses to work properly. I add an instance to the dictionary,
# I print it, I can see that it's there, I make sure it absolutely doesn't get removed,
# but when I try to access it from anywhere, it's just empty. My mind is boggled.
# Global dict works. :shrug:
global processes
return processes
@property
def running_processes(self) -> List[BackgroundProcess]:
return [p for p in self.processes.values() if p.is_running]
def is_running(self, *args: List[str]):
for proc_name in args:
if proc_name in self.processes:
return self.processes[proc_name].is_running
def get(self, proc_name: str):
return self.processes.get(proc_name)
def start(self, proc_name: str, **kwargs):
"""Start a process if it's stopped, or create it if it hasn't yet been instantiated."""
process = self.processes.get(proc_name, None)
if process:
process.start()
for key, value in kwargs.items():
setattr(process, key, value)
return
else:
for subcl in get_recursive_subclasses(BackgroundProcess):
if subcl.name == proc_name:
self.processes[subcl.name] = subcl(**kwargs)
return
raise Exception("SVN: Process name not found: ", proc_name)
def stop(self, proc_name: str):
"""Stop a process if it exists, otherwise do nothing."""
process = self.processes.get(proc_name, None)
if process:
process.stop()
def kill(self, proc_name: str):
"""Destroy a process entirely, such that it cannot be started again
without initializing a new instance."""
process = self.processes.get(proc_name, None)
if process:
process.stop()
del self.processes[proc_name]
def restart(self, proc_name: str):
"""Destroy a process, then start it again.
Useful to skip the repeat_delay timer of infinite processes like Status or Log."""
self.kill(proc_name)
self.start(proc_name)
# I named this variable with title-case, to indicate that it's a Singleton.
# There should only be one.
Processes = ProcessManager()
@@ -0,0 +1,70 @@
# SPDX-FileCopyrightText: 2023 Blender Studio Tools Authors
#
# SPDX-License-Identifier: GPL-3.0-or-later
from typing import List
from .background_process import Processes, BackgroundProcess
from .execute_subprocess import execute_svn_command
class BGP_SVN_Commit(BackgroundProcess):
name = "Commit"
needs_authentication = True
timeout = 5*60
repeat_delay = 0
debug = False
def __init__(self, commit_msg: str, file_list: List[str]):
super().__init__()
self.commit_msg = commit_msg
self.file_list = file_list
def acquire_output(self, context, prefs):
"""This function should be executed from a separate thread to avoid freezing
Blender's UI during execute_svn_command().
"""
if not self.commit_msg:
self.stop()
return
Processes.kill('Status')
sanitized_commit_msg = self.commit_msg.replace('"', "'")
command = ["svn", "commit", "-m",
f"{sanitized_commit_msg}"] + self.file_list
self.output = execute_svn_command(
context,
command,
use_cred=True
)
def handle_error(self, context, error):
print("Commit failed.")
Processes.start('Status')
super().handle_error(context, error)
def process_output(self, context, prefs):
print(self.output)
repo = context.scene.svn.get_repo(context)
for f in repo.external_files:
if f == repo.current_blend_file:
context.scene.svn.file_is_outdated = False
if f.status_prediction_type == 'SVN_COMMIT':
f.status_prediction_type = 'SKIP_ONCE'
Processes.start('Log')
Processes.start('Status')
repo.commit_message = ""
Processes.kill('Commit')
def get_ui_message(self, context) -> str:
"""Return a string that should be drawn in the UI for user feedback,
depending on the state of the process."""
if self.is_running:
plural = "s" if len(self.file_list) > 1 else ""
return f"Committing {len(self.file_list)} file{plural}..."
return ""
def stop(self):
super().stop()
@@ -0,0 +1,72 @@
# SPDX-FileCopyrightText: 2022 Blender Studio Tools Authors
#
# SPDX-License-Identifier: GPL-3.0-or-later
import subprocess
from typing import List
from ..util import get_addon_prefs
def get_credential_commands(context) -> List[str]:
repo = context.scene.svn.get_repo(context)
assert (
repo.is_cred_entered
), "No username or password entered for this repository. The UI shouldn't have allowed you to get into a state where you can press an SVN operation button without having your credentials entered, so this is a bug!"
return ["--username", f"{repo.username}", "--password", f"{repo.password}"]
def execute_command(path: str, command: str) -> str:
output_bytes = subprocess.check_output(
command,
shell=False,
cwd=path + "/",
stderr=subprocess.PIPE,
start_new_session=True,
)
return output_bytes.decode(encoding='utf-8', errors='replace')
def execute_svn_command(
context,
command: List[str],
*,
ignore_errors=False,
print_errors=True,
use_cred=False,
) -> str:
"""Execute an svn command in the root of the current svn repository.
So any file paths that are part of the command should be relative to the
SVN root.
"""
repo = context.scene.svn.get_repo(context)
if "svn" not in command:
command.insert(0, "svn")
if use_cred:
command += get_credential_commands(context)
command.append("--non-interactive")
command.append("--trust-server-cert")
prefs = get_addon_prefs(context)
if prefs.debug_mode:
print(" ".join(command))
try:
if repo.is_valid_svn:
return execute_command(repo.directory, command)
except subprocess.CalledProcessError as error:
if ignore_errors:
return ""
else:
err_msg = error.stderr.decode()
if print_errors:
print(f"Command returned error: {command}")
print(err_msg)
raise error
def check_svn_installed():
code, message = subprocess.getstatusoutput('svn')
return code != 127
@@ -0,0 +1,37 @@
# SPDX-FileCopyrightText: 2025 Blender Studio Tools Authors
#
# SPDX-License-Identifier: GPL-3.0-or-later
from .background_process import BackgroundProcess
class BGP_SVN_Activate_File(BackgroundProcess):
"""This crazy hacky method of activating the file with some delay is necessary
because Blender won't let us select the file immediately when changing the
directory - some time needs to pass before the files actually appear.
(This is visible with the naked eye as the file browser is empty for a
brief moment whenever params.dictionary is changed.)
"""
name = "Activate File"
needs_authentication = False
tick_delay = 0.1
debug = False
def acquire_output(self, context, prefs):
self.output = "dummy"
def process_output(self, context, prefs):
if not hasattr(context.scene, 'svn'):
return
repo = context.scene.svn.get_repo(context)
for area in context.screen.areas:
if area.type == 'FILE_BROWSER':
area.spaces.active.activate_file_by_relative_path(
relative_path=repo.active_file.file_name)
self.stop()
def get_ui_message(self, context):
return ""
@@ -0,0 +1,27 @@
# SPDX-FileCopyrightText: 2025 Blender Studio Tools Authors
#
# SPDX-License-Identifier: GPL-3.0-or-later
from .background_process import BackgroundProcess, Processes
from ..util import redraw_viewport
class BGP_SVN_Redraw_Viewport(BackgroundProcess):
name = "Redraw Viewport"
repeat_delay = 1
debug = False
tick_delay = 1
needs_authentication = False
def tick(self, context, prefs):
redraw_viewport()
def acquire_output(self, context, prefs):
return ""
def process_output(self, context, prefs):
return ""
def register():
Processes.start("Redraw Viewport")
@@ -0,0 +1,190 @@
# SPDX-FileCopyrightText: 2022 Blender Studio Tools Authors
#
# SPDX-License-Identifier: GPL-3.0-or-later
from datetime import datetime
from pathlib import Path
import subprocess
from ..util import redraw_viewport
from .. import constants
from .execute_subprocess import execute_svn_command
from .background_process import BackgroundProcess
def reload_svn_log(self, context):
"""Read the svn.log file (written by this addon) into the log entry list."""
repo = self
repo.log.clear()
# Read file into lists of lines where each list is one log entry
filepath = self.log_file_path
if not filepath.exists():
# Nothing to read!
return
chunks = []
with open(filepath, 'r') as f:
next(f) # Skip the first line of dashes.
chunk = []
for line in f:
line = line.replace("\n", "")
if line == "-" * 72:
# Line of dashes indicates the log entry is over.
chunks.append(chunk)
chunk = []
continue
chunk.append(line)
previous_rev_number = 0
for chunk in chunks:
if not chunk[0]:
chunk.pop(0)
# Read the first line of the svn log containing revision number, author,
# date and commit message length.
r_number, r_author, r_date, r_msg_length = chunk[0].split(" | ")
r_number = int(r_number[1:])
if r_number != previous_rev_number+1:
# print(f"SVN: Warning: Revision order seems wrong at r{r_number}")
# TODO: Currently this can happen when multiple Blender instances are running and end up writing the same log entry to the .log file multiple times.
# This is not very ideal!
continue
previous_rev_number = r_number
r_msg_length = int(r_msg_length.split(" ")[0])
log_entry = repo.log.add()
log_entry.revision_number = r_number
log_entry.revision_author = r_author
log_entry.revision_date = r_date
log_entry.revision_date_simple = svn_date_simple(r_date).split(" ")[
0][5:]
# File change set is on line 3 until the commit message begins...
file_change_lines = chunk[2:-(r_msg_length+1)]
for line in file_change_lines:
line = line.strip()
status_char = line[0]
file_path = line[2:]
if ' (from ' in file_path:
# If the file was moved, let's just ignore that information for now.
# TODO: This can be improved later if neccessary.
file_path = file_path.split(" (from ")[0]
file_path = Path(file_path)
log_file_entry = log_entry.changed_files.add()
log_file_entry.svn_path = str(file_path.as_posix())
log_file_entry.revision = r_number
log_file_entry.status = constants.SVN_STATUS_CHAR_TO_NAME[status_char]
log_entry['commit_message'] = "\n".join(chunk[-r_msg_length:])
log_entry.set_search_string()
def write_to_svn_log_file_and_storage(context, data_str: str) -> int:
"""
Get all SVN Log entries from the remote repo in the background,
without freezing up the UI, by calling this function every 3 seconds.
These are then stored in a file, so each log entry only needs to be fetched
once per computer that runs the addon.
Return how many SVN log entries were contained in data_str.
"""
repo = context.scene.svn.get_repo(context)
log_file_path = repo.log_file_path
file_existed = False
if log_file_path.exists():
file_existed = True
repo.reload_svn_log(context)
num_entries = len(repo.log)
with open(log_file_path, 'a+') as f:
# Append to the file, create it if necessary.
if file_existed:
# We want to skip the first line of the svn log when continuing,
# to avoid duplicate dashed lines, which would also mess up our
# parsing logic.
data_str = data_str[73:] # 72 dashes and a newline
data_str = "\n" + data_str # TODO: This is untested on windows.
# On Windows, the `svn log` command outputs lines with all sorts of \r and \n shennanigans.
# TODO: For this reason, this should be implemented with the --xml arg.
data_str = data_str.replace("\r", "")
if data_str.endswith("\n"):
data_str = data_str[:-1]
f.write(data_str)
repo.reload_svn_log(context)
print(f"SVN Log now at r{repo.log[-1].revision_number}")
return len(repo.log) - num_entries
class BGP_SVN_Log(BackgroundProcess):
name = "Log"
needs_authentication = True
timeout = 10
repeat_delay = 3
debug = False
def acquire_output(self, context, prefs):
"""This function should be executed from a separate thread to avoid freezing
Blender's UI during execute_svn_command().
"""
repo = context.scene.svn.get_repo(context)
latest_log_rev = 0
if len(repo.log) > 0:
latest_log_rev = repo.log[-1].revision_number
self.debug_print("Acquire output...")
# We have no way to know if latest_log_rev+1 will exist or not, but we
# must check, and there is no safe way to check it, so let's just
# catch and handle the potential error.
try:
self.output = execute_svn_command(
context,
["svn", "log", "--verbose",
f"-r{latest_log_rev+1}:HEAD", "--limit", "10"],
print_errors=False,
use_cred=True
)
self.debug_print("Output: \n" + str(self.output))
except subprocess.CalledProcessError as error:
error_msg = error.stderr.decode()
if "No such revision" in error_msg:
print("SVN: Log is now fully up to date.")
self.stop()
else:
self.error = error_msg
def process_output(self, context, prefs):
num_logs = write_to_svn_log_file_and_storage(context, self.output)
if num_logs < 10:
self.stop()
def get_ui_message(self, context):
repo = context.scene.svn.get_repo(context)
if len(repo.log) > 0:
rev_no = repo.log[-1].revision_number
return f"Updating log. Current: r{rev_no}..."
def svn_date_to_datetime(datetime_str: str) -> datetime:
"""Convert a string from SVN's datetime format to a datetime object."""
date, time, _timezone, _day, _n_day, _mo, _y = datetime_str.split(" ")
return datetime.strptime(f"{date} {time}", '%Y-%m-%d %H:%M:%S')
def svn_date_simple(datetime_str: str) -> str:
"""Convert a string form SVN's datetime format to a simpler format."""
dt = svn_date_to_datetime(datetime_str)
month_name = dt.strftime("%b")
date_str = f"{dt.year}-{month_name}-{dt.day}"
time_str = f"{str(dt.hour).zfill(2)}:{str(dt.minute).zfill(2)}"
return date_str + " " + time_str
@@ -0,0 +1,395 @@
# SPDX-FileCopyrightText: 2022 Blender Studio Tools Authors
#
# SPDX-License-Identifier: GPL-3.0-or-later
from ..svn_info import get_svn_info
from ..util import get_addon_prefs
from .. import constants
from .execute_subprocess import execute_svn_command, check_svn_installed
from .background_process import BackgroundProcess, Processes
from bpy.types import Operator
from bpy.props import StringProperty
import bpy
import xmltodict
import time
from pathlib import Path
from typing import List, Dict, Union, Any, Set, Optional, Tuple
from .. import wheels
# This will load the xmltodict wheel file.
wheels.preload_dependencies()
class SVN_OT_explain_status(Operator):
bl_idname = "svn.explain_status"
bl_label = "" # Don't want the first line of the tooltip on mouse hover.
bl_description = "Show an explanation of this status, using a dynamic tooltip"
bl_options = {'INTERNAL'}
status: StringProperty(
description="Identifier of the status to show an explanation for"
)
file_rel_path: StringProperty(
description="Path of the file to select in the list when clicking this explanation, to act as if it was click-through-able"
)
@staticmethod
def get_explanation(status: str):
return constants.SVN_STATUS_DATA[status][1]
@classmethod
def description(cls, context, properties):
return cls.get_explanation(properties.status)
def draw(self, context):
self.layout.label(text=self.get_explanation(self.status))
def execute(self, context):
"""Set the index on click, to act as if this operator button was
click-through in the UIList."""
if not self.file_rel_path:
return {'FINISHED'}
repo = context.scene.svn.get_repo(context)
file_entry = repo.external_files.get(self.file_rel_path)
file_entry_idx = repo.get_index_of_file(file_entry)
if file_entry_idx:
repo.external_files_active_index = file_entry_idx
return {'FINISHED'}
@bpy.app.handlers.persistent
def ensure_svn_of_current_file(_scene=None):
"""When opening or saving a .blend file, it's possible that the new .blend
is part of an SVN repository. If this is the case, do the following:
- Check if this file's repository is already in our database
- If not, create it
- Switch to that repo
"""
context = bpy.context
prefs = get_addon_prefs(context)
prefs.is_svn_installed = check_svn_installed()
if not prefs.is_svn_installed:
return
scene_svn = context.scene.svn
old_active_repo = prefs.active_repo
prefs.init_repo_list()
# If the file is unsaved, nothing more to do.
if not bpy.data.filepath:
scene_svn.svn_url = ""
return
# If file is not in a repo, nothing more to do.
is_in_repo = set_scene_svn_info(context)
if not is_in_repo:
return
# If file is in an existing repo, we should switch over to that repo.
for i, existing_repo in enumerate(prefs.repositories):
if (
existing_repo.url == scene_svn.svn_url
and existing_repo.directory == scene_svn.svn_directory
and existing_repo != old_active_repo
):
prefs.active_repo_idx = i
else:
# If file is in a non-existing repo, initialize that repo.
prefs.init_repo(context, scene_svn.svn_directory)
context.scene.svn.get_repo(context).force_update_ui_caches(context)
def set_scene_svn_info(context) -> bool:
"""Check if the current .blend file is in an SVN repository.
If it is, use `svn info` to grab the SVN URL and directory and store them in the Scene.
The rest of the add-on will use this stored URL & Dir to find the corresponding
SVN repository data stored in the user preferences.
Returns whether initialization was successful or not.
"""
scene_svn = context.scene.svn
scene_svn.svn_directory = ""
scene_svn.svn_url = ""
blend_path = Path(bpy.data.filepath).parent
root_dir, base_url = get_svn_info(blend_path)
if not root_dir:
return False
scene_svn.svn_directory = root_dir
scene_svn.svn_url = base_url
return True
################################################################################
############## AUTOMATICALLY KEEPING FILE STATUSES UP TO DATE ##################
################################################################################
class BGP_SVN_Status(BackgroundProcess):
name = "Status"
needs_authentication = True
timeout = 10
repeat_delay = 15
debug = False
def __init__(self):
self.timestamp_last_update = 0
self.list_command_output = ""
super().__init__()
def acquire_output(self, context, prefs):
self.output = execute_svn_command(
context,
["svn", "status", "--show-updates", "--verbose", "--xml"],
use_cred=True,
)
# The list command includes file size info and also files of directories
# which have their Depth set to Empty, which is used for a partial check-out,
# which we also use for updating files and folders one-by-one instead of
# all-at-once, so we can provide more live feedback in the UI.
# NOTE: This one-by-one updating functionality conflicts with a potential
# future support for partial check-outs, so that would require storing user-intended
# partially checked out folders separately somewhere.
self.list_command_output = execute_svn_command(
context,
["svn", "list", "--recursive", "--xml"],
use_cred=True,
)
def process_output(self, context, prefs):
repo = context.scene.svn.get_repo(context)
update_file_list_svn_status(context, svn_status_xml_to_dict(self.output))
update_file_list_svn_list(context, self.list_command_output)
repo.refresh_ui_lists(context)
self.timestamp_last_update = time.time()
def get_ui_message(self, context):
time_since_last_update = time.time() - self.timestamp_last_update
time_delta = self.repeat_delay - time_since_last_update
if self.repeat_delay > time_delta > 0:
return f"Status update in {int(time_delta)}s."
return f"Updating repo status..."
class BGP_SVN_Authenticate(BGP_SVN_Status):
name = "Authenticate"
needs_authentication = False
timeout = 10
repeat_delay = 0
debug = False
def get_ui_message(self, context):
return "Authenticating..."
def acquire_output(self, context, prefs):
repo = context.scene.svn.get_repo(context)
if (
not repo
or not repo.is_valid_svn
or not repo.is_cred_entered
or repo.authenticated
):
return
super().acquire_output(context, prefs)
def handle_error(self, context, error):
super().handle_error(context, error)
repo = context.scene.svn.get_repo(context)
repo.authenticated = False
repo.auth_failed = True
def process_output(self, context, prefs):
repo = context.scene.svn.get_repo(context)
if not repo or not repo.is_cred_entered or repo.authenticated:
return
assert self.output
super().process_output(context, prefs)
repo.authenticated = True
repo.auth_failed = False
Processes.start('Status')
Processes.start('Log')
def update_file_list_svn_status(context, file_statuses: Dict[str, Tuple[str, str, int]]):
"""Update the file list based on data from get_svn_file_statuses().
(See timer_update_svn_status)"""
repo = context.scene.svn.get_repo(context)
svn_paths = []
new_files_on_repo = set()
for filepath_str, status_info in file_statuses.items():
svn_path = Path(filepath_str)
svn_path_str = str(filepath_str)
suffix = svn_path.suffix
if (
(suffix.startswith(".r") and suffix[2:].isdecimal())
or (suffix.startswith(".blend") and suffix[6:].isdecimal())
or suffix.endswith("blend@")
):
# Do not add certain file extensions, ever:
# .r### files are from SVN conflicts waiting to be resolved.
# .blend@ is the Blender filesave temp file.
# .blend### are Blender backup files.
continue
svn_paths.append(svn_path_str)
wc_status, repos_status, revision = status_info
file_entry = repo.external_files.get(svn_path_str)
entry_existed = True
if not file_entry:
entry_existed = False
file_entry = repo.external_files.add()
file_entry.svn_path = svn_path_str
if not file_entry.exists:
new_files_on_repo.add((file_entry.svn_path, repos_status))
if entry_existed and (
file_entry.repos_status == 'none' and repos_status != 'none'
):
new_files_on_repo.add((file_entry.svn_path, repos_status))
file_entry.revision = revision
file_entry.status = wc_status
file_entry.repos_status = repos_status
file_entry.status_prediction_type = 'NONE'
file_entry.absolute_path = str(repo.svn_to_absolute_path(svn_path))
if new_files_on_repo:
# File entry status has changed between local and repo.
file_strings = []
for svn_path, repos_status in new_files_on_repo:
status_char = constants.SVN_STATUS_NAME_TO_CHAR.get(repos_status, " ")
file_strings.append(f"{status_char} {svn_path}")
print(
"SVN: Detected file changes on remote:\n",
"\n".join(file_strings),
"\nUpdating log...\n",
)
Processes.start('Log')
# Remove file entries who no longer seem to have an SVN status.
# This can happen if an unversioned file was removed from the filesystem,
# Or sub-folders whose parent was Un-Added to the SVN.
for file_entry in repo.external_files[:]:
if file_entry.svn_path not in svn_paths:
repo.remove_file_entry(file_entry)
def svn_status_xml_to_dict(svn_status_str: str) -> Dict[str, Tuple[str, str, int]]:
svn_status_xml = xmltodict.parse(svn_status_str)
file_infos = svn_status_xml['status']['target']['entry']
# print(json.dumps(file_infos, indent=4))
file_statuses = {}
for file_info in file_infos:
filepath = file_info.get('@path')
assert filepath, f"Filepath was not found in an SVN status entry:\n{file_info}"
# Remote Repository status.
repos_status = "none"
if 'repos-status' in file_info:
repos_status_block = file_info.get('repos-status')
if repos_status_block:
repos_status = repos_status_block.get('@item', "none")
# _repo_props = repos_status_block.get('@props')
# Working Copy status.
wc_status_block = file_info.get('wc-status')
wc_status = wc_status_block.get('@item', 'normal')
# _revision = int(wc_status_block.get('@revision', 0))
# _props = wc_status_block['@props']
commit_revision = 0
if 'commit' in wc_status_block:
commit_block = wc_status_block['commit']
if commit_block:
commit_revision = int(commit_block.get('@revision', 0))
# _commit_author = commit_block.get('author')
# _commit_date = commit_block.get('date')
file_statuses[filepath] = (wc_status, repos_status, commit_revision)
return file_statuses
def update_file_list_svn_list(context, svn_list_str: str) -> Dict:
repo = context.scene.svn.get_repo(context)
try:
svn_list_xml = xmltodict.parse(svn_list_str)
except:
# This seems to fail with an "ExpatError" on Windows...?
return
file_infos = svn_list_xml['lists']['list']['entry']
for file_info in file_infos:
svn_path = file_info['name']
kind = file_info['@kind']
file_entry = repo.external_files.get(svn_path)
if not file_entry:
file_entry = repo.external_files.add()
file_entry.svn_path = svn_path
file_entry.absolute_path = str(repo.svn_to_absolute_path(svn_path))
if not file_entry.exists:
file_entry.status = 'none'
file_entry.repos_status = 'added'
file_entry.status_prediction_type = 'NONE'
if kind == 'file':
file_entry.file_size_KiB = float(file_info['size']) / 1024.0
@bpy.app.handlers.persistent
def mark_current_file_as_modified(_dummy1=None, _dummy2=None):
context = bpy.context
scene_svn = context.scene.svn
if not scene_svn.svn_directory:
return
repo = scene_svn.get_repo(context)
if not repo:
return
current_blend = repo.current_blend_file
if current_blend:
current_blend.status = 'modified'
current_blend.status_prediction_type = 'SKIP_ONCE'
################################################################################
############################# REGISTER #########################################
################################################################################
def delayed_init_svn(delay=1):
bpy.app.timers.register(ensure_svn_of_current_file, first_interval=delay)
def register():
bpy.app.handlers.load_post.append(ensure_svn_of_current_file)
bpy.app.handlers.save_post.append(ensure_svn_of_current_file)
bpy.app.handlers.save_post.append(mark_current_file_as_modified)
delayed_init_svn()
def unregister():
bpy.app.handlers.load_post.remove(ensure_svn_of_current_file)
bpy.app.handlers.save_post.remove(ensure_svn_of_current_file)
bpy.app.handlers.save_post.remove(mark_current_file_as_modified)
Processes.kill('Status')
registry = [SVN_OT_explain_status]
@@ -0,0 +1,93 @@
# SPDX-FileCopyrightText: 2023 Blender Studio Tools Authors
#
# SPDX-License-Identifier: GPL-3.0-or-later
from typing import List, Dict, Union, Any, Set, Optional, Tuple
from .execute_subprocess import execute_svn_command
from .background_process import BackgroundProcess, Processes
class BGP_SVN_Update(BackgroundProcess):
name = "Update"
needs_authentication = True
timeout = 5*60
repeat_delay = 0.01 # 0 means don't repeat
debug = False
def __init__(self, revision=0):
super().__init__()
self.revision = revision
self.message = "Updating..."
def set_predicted_file_status(self, file):
if self.revision != 0:
# File status prediction is not supported for reverting the entire
# repository. It would be complicated to implement, and not really useful.
return
file.status_prediction_type = "SVN_UP"
if file.repos_status == 'modified' and file.status == 'normal':
# Modified on remote, exists on local.
file.repos_status = 'none'
elif file.repos_status == 'added' and file.status == 'none':
# Added on remote, doesn't exist on local.
file.status = 'normal'
elif file.repos_status == 'deleted' and file.status == 'normal':
# Deleted on remote, exists on local.
# NOTE: File entry should just be deleted.
file.status = 'none'
file.repos_status = 'none'
else:
file.status = 'conflicted'
def acquire_output(self, context, prefs):
Processes.kill('Status')
repo = context.scene.svn.get_repo(context)
files = [
f
for f in repo.external_files
if f.repos_status != 'none' or f.status_prediction_type == 'SVN_UP'
]
dirs = [f for f in files if f.is_dir]
if dirs:
dirs.sort(key=lambda d: len(d.svn_path))
file = dirs[0]
self.message = "Creating folder: " + file.svn_path
elif files:
file = files[0]
self.message = f"Updating file: {file.svn_path} ({file.file_size})"
else:
print("SVN Update complete.")
self.stop()
return
print(self.message)
self.set_predicted_file_status(file)
command = ["svn", "up", file.svn_path, "--accept", "postpone", "--depth", "empty"]
if self.revision > 0:
command.insert(2, f"-r{self.revision}")
self.output = execute_svn_command(context, command, use_cred=True)
file.status_prediction_type = 'SKIP_ONCE'
file.repos_status = 'none' # Without this, it would keep updating the same file.
def handle_error(self, context, error):
Processes.start('Status')
super().handle_error(context, error)
def process_output(self, context, prefs):
return
def get_ui_message(self, context) -> str:
"""Return a string that should be drawn in the UI for user feedback,
depending on the state of the process."""
if self.is_running:
return self.message + "..."
return ""
def stop(self):
super().stop()
Processes.start('Log')
Processes.start('Status')