Skip to content

Instantly share code, notes, and snippets.

@ranjian0
Created June 10, 2020 12:08
Show Gist options
  • Save ranjian0/439500ef3a4298c1d8cf843b68c2752e to your computer and use it in GitHub Desktop.
Save ranjian0/439500ef3a4298c1d8cf843b68c2752e to your computer and use it in GitHub Desktop.
ScriptWatcher used with building tools addon.
"""
script_watcher.py: Reload watched script upon changes.
Copyright (C) 2015 Isaac Weaver
Author: Isaac Weaver <[email protected]>
This program is free software; you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation; either version 2 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License along
with this program; if not, write to the Free Software Foundation, Inc.,
51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
"""
bl_info = {
"name": "Script Watcher",
"author": "Isaac Weaver",
"version": (0, 6),
"blender": (2, 80, 0),
"location": "Properties > Scene > Script Watcher",
"description": "Reloads an external script on edits.",
"warning": "Still in beta stage.",
"wiki_url": "http://wiki.blender.org/index.php/Extensions:2.6/Py/Scripts/Development/Script_Watcher",
"tracker_url": "https://github.com/wisaac407/blender-script-watcher/issues/new",
"category": "Development",
}
import os, sys
import io
import traceback
import types
import console_python # Blender module giving us access to the blender python console.
import bpy
from bpy.app.handlers import persistent
@persistent
def load_handler(dummy):
running = bpy.context.scene.sw_settings.running
# First of all, make sure script watcher is off on all the scenes.
for scene in bpy.data.scenes:
bpy.ops.wm.sw_watch_end({'scene': scene})
# Startup script watcher on the current scene if needed.
if running and bpy.context.scene.sw_settings.auto_watch_on_startup:
bpy.ops.wm.sw_watch_start()
# Reset the consoles list to remove all the consoles that don't exist anymore.
for screen in bpy.data.screens:
screen.sw_consoles.clear()
def add_scrollback(ctx, text, text_type):
for line in text:
bpy.ops.console.scrollback_append(ctx, text=line.replace('\t', ' '),
type=text_type)
def get_console_id(area):
"""Return the console id of the given region."""
if area.type == 'CONSOLE': # Only continue if we have a console area.
for region in area.regions:
if region.type == 'WINDOW':
return hash(region) # The id is the hash of the window region.
return False
def isnum(s):
return s[1:].isnumeric() and s[0] in '-+1234567890'
class SplitIO(io.StringIO):
"""Feed the input stream into another stream."""
PREFIX = '[Script Watcher]: '
_can_prefix = True
def __init__(self, stream):
io.StringIO.__init__(self)
self.stream = stream
def write(self, s):
# Make sure we prefix our string before we do anything else with it.
if self._can_prefix:
s = self.PREFIX + s
# only add the prefix if the last stream ended with a newline.
self._can_prefix = s.endswith('\n')
# Make sure to call the super classes write method.
io.StringIO.write(self, s)
# When we are written to, we also write to the secondary stream.
self.stream.write(s)
# Define the script watching operator.
class WatchScriptOperator(bpy.types.Operator):
"""Watches the script for changes, reloads the script if any changes occur."""
bl_idname = "wm.sw_watch_start"
bl_label = "Watch Script"
_timer = None
_running = False
_times = None
filepath = None
def get_paths(self):
"""Find all the python paths surrounding the given filepath."""
dirname = os.path.dirname(self.filepath)
paths = []
filepaths = []
for root, dirs, files in os.walk(dirname, topdown=True):
if '__init__.py' in files:
paths.append(root)
for f in files:
filepaths.append(os.path.join(root, f))
else:
dirs[:] = [] # No __init__ so we stop walking this dir.
# If we just have one (non __init__) file then return just that file.
return paths, filepaths or [self.filepath]
def get_mod_name(self):
"""Return the module name and the root path of the givin python file path."""
dir, mod = os.path.split(self.filepath)
# Module is a package.
if mod == '__init__.py':
mod = os.path.basename(dir)
dir = os.path.dirname(dir)
# Module is a single file.
else:
mod = os.path.splitext(mod)[0]
return mod, dir
def remove_cached_mods(self):
"""Remove all the script modules from the system cache."""
paths, files = self.get_paths()
for mod_name, mod in list(sys.modules.items()):
try:
if hasattr(mod, '__file__') and os.path.dirname(mod.__file__) in paths:
del sys.modules[mod_name]
except TypeError:
pass
def _reload_script_module(self):
print('Reloading script:', self.filepath)
self.remove_cached_mods()
try:
f = open(self.filepath)
paths, files = self.get_paths()
# Get the module name and the root module path.
mod_name, mod_root = self.get_mod_name()
# Create the module and setup the basic properties.
mod = types.ModuleType('__main__')
mod.__file__ = self.filepath
mod.__path__ = paths
mod.__package__ = mod_name
# Add the module to the system module cache.
sys.modules[mod_name] = mod
# Fianally, execute the module.
exec(compile(f.read(), self.filepath, 'exec'), mod.__dict__)
except IOError:
print('Could not open script file.')
except:
sys.stderr.write("There was an error when running the script:\n" + traceback.format_exc())
else:
f.close()
def reload_script(self, context):
"""Reload this script while printing the output to blenders python console."""
# Setup stdout and stderr.
stdout = SplitIO(sys.stdout)
stderr = SplitIO(sys.stderr)
sys.stdout = stdout
sys.stderr = stderr
# Run the script.
self._reload_script_module()
# Go back to the begining so we can read the streams.
stdout.seek(0)
stderr.seek(0)
# Don't use readlines because that leaves trailing new lines.
output = stdout.read().split('\n')
output_err = stderr.read().split('\n')
for console in context.screen.sw_consoles:
if console.active and isnum(console.name): # Make sure it's not some random string.
console, _, _ = console_python.get_console(int(console.name))
# Set the locals to the modules dict.
console.locals = sys.modules[self.get_mod_name()[0]].__dict__
if self.use_py_console:
# Print the output to the consoles.
for area in context.screen.areas:
if area.type == "CONSOLE":
ctx = context.copy()
ctx.update({"area": area})
# Actually print the output.
if output:
add_scrollback(ctx, output, 'OUTPUT')
if output_err:
add_scrollback(ctx, output_err, 'ERROR')
# Cleanup
sys.stdout = sys.__stdout__
sys.stderr = sys.__stderr__
def modal(self, context, event):
if not context.scene.sw_settings.running:
self.cancel(context)
return {'CANCELLED'}
if context.scene.sw_settings.reload:
context.scene.sw_settings.reload = False
self.reload_script(context)
return {'PASS_THROUGH'}
if event.type == 'TIMER':
for path in self._times:
cur_time = os.stat(path).st_mtime
if cur_time != self._times[path]:
self._times[path] = cur_time
self.reload_script(context)
return {'PASS_THROUGH'}
def execute(self, context):
if context.scene.sw_settings.running:
return {'CANCELLED'}
# Grab the settings and store them as local variables.
self.filepath = bpy.path.abspath(context.scene.sw_settings.filepath)
self.use_py_console = context.scene.sw_settings.use_py_console
# If it's not a file, doesn't exist or permistion is denied we don't preceed.
if not os.path.isfile(self.filepath):
self.report({'ERROR'}, 'Unable to open script.')
return {'CANCELLED'}
# Setup the times dict to keep track of when all the files where last edited.
dirs, files = self.get_paths()
self._times = dict((path, os.stat(path).st_mtime) for path in files) # Where we store the times of all the paths.
self._times[files[0]] = 0 # We set one of the times to 0 so the script will be loaded on startup.
# Setup the event timer.
wm = context.window_manager
self._timer = wm.event_timer_add(0.1, window=context.window)
wm.modal_handler_add(self)
context.scene.sw_settings.running = True
return {'RUNNING_MODAL'}
def cancel(self, context):
wm = context.window_manager
wm.event_timer_remove(self._timer)
self.remove_cached_mods()
context.scene.sw_settings.running = False
class CancelScriptWatcher(bpy.types.Operator):
"""Stop watching the current script."""
bl_idname = "wm.sw_watch_end"
bl_label = "Stop Watching"
def execute(self, context):
# Setting the running flag to false will cause the modal to cancel itself.
context.scene.sw_settings.running = False
return {'FINISHED'}
class ReloadScriptWatcher(bpy.types.Operator):
"""Reload the current script."""
bl_idname = "wm.sw_reload"
bl_label = "Reload Script"
def execute(self, context):
# Setting the reload flag to true will cause the modal to cancel itself.
context.scene.sw_settings.reload = True
return {'FINISHED'}
# Create the UI for the operator. NEEDS FINISHING!!
class ScriptWatcherPanel(bpy.types.Panel):
"""UI for the script watcher."""
bl_label = "Script Watcher"
bl_idname = "SCENE_PT_script_watcher"
bl_space_type = 'PROPERTIES'
bl_region_type = 'WINDOW'
bl_context = "scene"
def draw(self, context):
layout = self.layout
running = context.scene.sw_settings.running
col = layout.column()
col.prop(context.scene.sw_settings, 'filepath')
col.prop(context.scene.sw_settings, 'use_py_console')
col.prop(context.scene.sw_settings, 'auto_watch_on_startup')
if bpy.app.version < (2, 80, 0):
col.operator('wm.sw_watch_start', icon='VISIBLE_IPO_ON')
else:
col.operator('wm.sw_watch_start', icon='HIDE_OFF')
col.enabled = not running
if running:
row = layout.row(align=True)
row.operator('wm.sw_watch_end', icon='CANCEL')
row.operator('wm.sw_reload', icon='FILE_REFRESH')
class ScriptWatcherSettings(bpy.types.PropertyGroup):
"""All the script watcher settings."""
running : bpy.props.BoolProperty(default=False)
reload : bpy.props.BoolProperty(default=False)
filepath : bpy.props.StringProperty(
name = 'Script',
description = 'Script file to watch for changes.',
subtype = 'FILE_PATH'
)
use_py_console : bpy.props.BoolProperty(
name = 'Use py console',
description = 'Use blenders built-in python console for program output (e.g. print statments and error messages)',
default = False
)
auto_watch_on_startup : bpy.props.BoolProperty(
name = 'Watch on startup',
description = 'Watch script automatically on new .blend load',
default = False
)
def update_debug(self, context):
console_id = get_console_id(context.area)
console, _, _ = console_python.get_console(console_id)
if self.active:
console.globals = console.locals
if context.scene.sw_settings.running:
dir, mod = os.path.split(bpy.path.abspath(context.scene.sw_settings.filepath))
# XXX This is almost the same as get_mod_name so it should become a global function.
if mod == '__init__.py':
mod = os.path.basename(dir)
else:
mod = os.path.splitext(mod)[0]
console.locals = sys.modules[mod].__dict__
else:
console.locals = console.globals
#ctx = context.copy() # Operators only take dicts.
#bpy.ops.console.update_console(ctx, debug_mode=self.active, script='test-script.py')
class SWConsoleSettings(bpy.types.PropertyGroup):
active : bpy.props.BoolProperty(
name = "Debug Mode",
update = update_debug,
description = "Enter Script Watcher debugging mode (when in debug mode you can access the script variables).",
default = False
)
class SWConsoleHeader(bpy.types.Header):
bl_space_type = 'CONSOLE'
def draw(self, context):
layout = self.layout
cs = context.screen.sw_consoles
console_id = str(get_console_id(context.area))
# Make sure this console is in the consoles collection.
if console_id not in cs:
console = cs.add()
console.name = console_id
row = layout.row()
row.scale_x = 1.8
row.prop(cs[console_id], 'active', toggle=True)
classes = (
WatchScriptOperator,
CancelScriptWatcher,
ReloadScriptWatcher,
ScriptWatcherPanel,
ScriptWatcherSettings,
SWConsoleSettings,
SWConsoleHeader
)
def register():
for cls in classes:
bpy.utils.register_class(cls)
bpy.types.Scene.sw_settings = \
bpy.props.PointerProperty(type=ScriptWatcherSettings)
bpy.app.handlers.load_post.append(load_handler)
bpy.types.Screen.sw_consoles = bpy.props.CollectionProperty(
type = SWConsoleSettings
)
def unregister():
for cls in classes:
bpy.utils.unregister_class(cls)
bpy.app.handlers.load_post.remove(load_handler)
del bpy.types.Scene.sw_settings
del bpy.types.Screen.sw_consoles
if __name__ == "__main__":
register()
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment