2025-12-01
This commit is contained in:
@@ -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')
|
||||
Reference in New Issue
Block a user