Files
blender-portable-repo/scripts/addons/blender_svn/threaded/background_process.py
T
2026-03-17 14:58:51 -06:00

278 lines
9.4 KiB
Python

# 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()