How to run a script from outside Blender? [Script Live-Link Addon]

I made a little add-on that sync a script.py filepath to a text target inside of Blender, it’s quite handy for me because I like Sublime text editor more than the Blender’s native text editor.

bl_info = {
    "name" : "Script-LiveLink [BD3D]",
    "author" : "BD3D",
    "description" : "LiveLink for script",
    "blender" : (2, 80, 0),
    "location" : "Operator",
    "warning" : "",
    "category" : "Generic"
}

#pep8compliant..ect..

import bpy, os,functools
from bpy.types import Menu, Panel, Operator, PropertyGroup, Operator, AddonPreferences, PropertyGroup
from bpy.props import StringProperty, IntProperty, BoolProperty, FloatProperty, EnumProperty, PointerProperty
C = bpy.context

#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-

def execute_check(G_path,G_name): #TIMER
    if bpy.context.scene[G_name+" check"] == False:
        print(G_name + ' checking aborted')
        return None
    if bpy.context.scene[G_name] != os.path.getmtime(G_path):
        F = open(G_path)
        will_exec = False
        if "EXECUTE" in F.readline():#EXECEXEC
            will_exec = True#EXEC
        F.close()#i just lose the first line so i need to restart
        F = open(G_path)
        bpy.data.texts[G_name].clear()         #clear all text
        bpy.data.texts[G_name].write(F.read()) #paste text from G_path
        if will_exec == True:#EXEC
            #exec(bpy.data.texts[G_name].as_string()) #wont work on addon reg... i tried a lot of other exec method, i think we need bpy.ops.text.run_script()
            exec(compile(open(G_path).read(), G_path, 'exec'))
        F.close()
        bpy.context.scene[G_name] = os.path.getmtime(G_path)
    #print('check')
    return 0.5

      
class SCR_OT_link(bpy.types.Operator):
    bl_idname = "scr.link"
    bl_label = ""
    bl_description = ""
    index : bpy.props.IntProperty() 
    def execute(self, context):
        index = self.index
        G = bpy.context.scene.SCR_OT_group
        if index == 1:
            G_path = G.path_01
            G_targ = G.target_01
        elif index ==2:
            G_path = G.path_02
            G_targ = G.target_02
        elif index ==3:
            G_path = G.path_03
            G_targ = G.target_03

        G_name = os.path.basename(G_path)
        G_targ.name = G_name
        bpy.context.scene[G_name]          = os.path.getmtime(G_path)
        bpy.context.scene[G_name+" check"] = True
        print("starting timer")
        bpy.app.timers.register(functools.partial(execute_check,G_path,G_name), first_interval=0.5)
        return {'FINISHED'}
    
    
class SCR_OT_stop_link(bpy.types.Operator):
    bl_idname = "scr.stop_link"
    bl_label = ""
    bl_description = ""
    
    index : bpy.props.IntProperty() 
    def execute(self, context):
        index = self.index
        G = bpy.context.scene.SCR_OT_group
        if index == 1:
            G_path = G.path_01
        elif index ==2:
            G_path = G.path_02
        elif index ==3:
            G_path = G.path_03

        G_name = os.path.basename(G_path)
        bpy.context.scene[G_name+" check"] = False
        return {'FINISHED'}

#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-


class SCR_OT_group(bpy.types.PropertyGroup): #not needed, could do with one storage 
    path_01   : StringProperty(name=" ",subtype='FILE_PATH',default=r"‪")
    target_01 : PointerProperty(type=bpy.types.Text)
    
    path_02   : StringProperty(name=" ",subtype='FILE_PATH',default=r"‪")
    target_02 : PointerProperty(type=bpy.types.Text)
    
    path_03   : StringProperty(name=" ",subtype='FILE_PATH',default=r"‪")
    target_03 : PointerProperty(type=bpy.types.Text)

class SCR_PT_panel(Panel):
    bl_space_type = 'TEXT_EDITOR'
    bl_region_type = 'UI'
    bl_category = "Text"
    bl_label = "Live-Link"

    def draw(self, context):
        layout = self.layout

        row = layout.column(align=True)
        row.prop(bpy.context.scene.SCR_OT_group, "path_01",text="")
        row.prop(bpy.context.scene.SCR_OT_group, "target_01",text="")
        rowrow = row.row(align=True)
        rowrow.operator(SCR_OT_link.bl_idname, text="Start Live-Link",icon="PASTEDOWN").index       = 1
        rowrow.operator(SCR_OT_stop_link.bl_idname, text="Stop Live-Link",icon="PANEL_CLOSE").index = 1

        layout.separator()

        row = layout.column(align=True)
        row.prop(bpy.context.scene.SCR_OT_group, "path_02",text="")
        row.prop(bpy.context.scene.SCR_OT_group, "target_02",text="")
        rowrow = row.row(align=True)
        rowrow.operator(SCR_OT_link.bl_idname, text="Start Live-Link",icon="PASTEDOWN").index       = 2
        rowrow.operator(SCR_OT_stop_link.bl_idname, text="Stop Live-Link",icon="PANEL_CLOSE").index = 2

        layout.separator()

        row = layout.column(align=True)
        row.prop(bpy.context.scene.SCR_OT_group, "path_03",text="")
        row.prop(bpy.context.scene.SCR_OT_group, "target_03",text="")
        rowrow = row.row(align=True)
        rowrow.operator(SCR_OT_link.bl_idname, text="Start Live-Link",icon="PASTEDOWN").index       = 3
        rowrow.operator(SCR_OT_stop_link.bl_idname, text="Stop Live-Link",icon="PANEL_CLOSE").index = 3

        layout.separator()

        text = layout.box().column(align=True)
        text.label(text='if "EXECUTE" is in the first line of your script', icon='INFO')
        text.label(text='it will Run the script after the Sync')


#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-

sc_classes = {
    SCR_OT_group,
    SCR_PT_panel,
    SCR_OT_link,
    SCR_OT_stop_link,
}

def register():
    for cls in sc_classes:
        bpy.utils.register_class(cls)
    bpy.types.Scene.SCR_OT_group = bpy.props.PointerProperty(type=SCR_OT_group)

def unregister():
    for cls in sc_classes:
        bpy.utils.unregister_class(cls)
    del bpy.types.Scene.C_Slots_settings

if __name__ == "__main__":
    register()

I also made a function that detects if the there’s “EXECUTE” in the first line of the script. if it is so, the script will automatically be executed inside of Blender. so I can execute a script from within my text editor easily.

enter image description here

The only problem is: scripts that require a context will not work at all.

Somehow there’s no context? So here’s my question:

How to run a script from outside of Blender?

Or more precisely:

How to create an artificial context?

I tried multiple script execution methods like exec(compile(open(G_path).read(), G_path, 'exec')) or bpy.ops.script.python_file_run() but it never worked… Maybe using bpy.ops.text.run_script() can resolve the problem? But again, it needs a context, and an automatic change editor to the correct text? I don’t know - I’m lost…

1 Like

@jacqueslucke can you help here, please? Thank you.

1 Like

I worked quite a lot on this issue for my VS Code extension. Unfortunately, I’m not aware of any solution that works in the current version of Blender. This functionality is broken in my extension as well.

Originally, I planned that timers could be used for that (the way you describe it). That’s why I implemented them in the first place. That did work in the beginning, but later Blender (correctly) became a bit more strict with what you can do in them.

I believe that we need additional functionality in Blenders event system, to make this work reliably.
A possible API could look like this:

import bpy
wm = bpy.data.window_managers[0]
context_dict = {...}
props = wm.schedule_operator_call("operator.name", context_dict)
props.prop1 = ...
props.prop2 = ... 

This method should be made thread-safe, so that Python code can listen for e.g. an incoming network packet in a separate thread, and run an operator when it arrived.
Furthermore, this does not execute the operator immediately. Instead, it adds a new event to Blender’s event queue. When the event is handled later on, the operator will be executed in the correct context.

It feels very doable, but I don’t know the event system good enough to make a good guess on how complicated it is to implement this. Maybe @ideasman42 or @julianeisel can give some more information on the topic.

3 Likes

Glad to see that this issue interest the blender devs. It could be really useful for making other softwares interact with blender. ( ex automatic object/image update when saved on disk ) :slightly_smiling_face:

i tried to set the blender window as active just before executing the script but it doesn’t even work.

Active_W = storing blender window handle before..ect
ctypes.windll.user32.SetActiveWindow(Active_W)

result =nothing is executed from outside.
if executed within blender it is working fine.

i really though that could resolve the problem.
Maybe other technique for setting the blender window as active will work ? this is the only one i know so far.

You should be able to establish a context during addon registration, so just add a draw handler to the text editor. This binds a context to the callback. Then you can just use a timer to check if the text timestamp has changed on disk, and signal a tag_redraw which fires the draw function.

In the draw function you hide bpy.ops.text.run_script behind a bool to control when it’s allowed to execute.

Even tried to make cursor click to blender window + sleep(), still no context.
@kaio any example of that ? never did a tag_redraw before.

Here’s a working example.
Adds a filepath to bpy.context.scene.live_text. Just point it to a py-file.

import bpy
import os

bl_info = {
    "name" : "live text",
    "author" : "kaio",
    "description" : "yep",
    "blender" : (2, 81, 0),
    "location" : "Text Editor",
    "warning" : "",
    "category" : "Text Editor"
}

def execute_text(context):
    st = getattr(context, 'space_data', None)
    if not execute_text.ok or not st:
        return
    execute_text.ok = False
    try:
        bpy.ops.text.run_script()
    except Exception as err:
        print(err)

def call_redraw():
    wm = bpy.data.window_managers[0]
    texeds = [a for w in wm.windows
             for a in w.screen.areas
             if a.type == 'TEXT_EDITOR' and
             a.spaces.active.text]

    if texeds:
        path = bpy.data.scenes[0].live_text
        name = os.path.split(path)[-1]
        for ed in texeds:
            if name == ed.spaces.active.text.name:
                execute_text.ok = True
                ed.tag_redraw()
                return
        else:
            text = bpy.data.texts.get(name)
            if not text:
                text = bpy.data.texts.new(name)
            texeds[0].spaces.active.text = text

def modify_internal_text():
    path = bpy.data.scenes[0].live_text
    name = os.path.split(path)[-1]
    text = bpy.data.texts.get(name)
    if not text:
        text = bpy.data.texts.new(name)
    with open(path) as file:
        text.from_string(file.read())

def poll_text():
    live_text = bpy.data.scenes[0].live_text
    if os.path.exists(live_text):
        mtime = os.path.getmtime(live_text)
        if mtime != poll_text.mtime_prev:
            modify_internal_text()
            poll_text.mtime_prev = mtime
            call_redraw()
    return 1

def register():
    poll_text.mtime_prev = -1
    execute_text.ok = False
    add = bpy.types.SpaceTextEditor.draw_handler_add
    bpy.types.Scene.live_text = bpy.props.StringProperty(
        name="Live Text", subtype='FILE_PATH', default="‪")
    bpy.app.timers.register(lambda: setattr(register, 'execute_text', add(execute_text, (getattr(bpy, 'context'),), 'WINDOW', 'POST_PIXEL')), first_interval=0.1)
    
    bpy.app.timers.register(poll_text)

def unregister():
    bpy.app.timers.unregister(poll_text)
    bpy.types.SpaceTextEditor.draw_handler_remove(
        register.execute_text, 'WINDOW')
    del bpy.types.Scene.live_text

live_text

7 Likes

cannot wait to play arount with this.

@kaio dear kaio, your technique worked really well!!! i completely integrated it in the addon and its wonderful. i did mention your name in the addon author credit.

1 Like

Here is the Script-Link addon finished. it is working sublimly fine ! @kaio

@jesterKing is this good enough to be officially incorporated inside of blender? i know that @jacqueslucke did the VS Code extension, my method is quite hacky but well it work really fine (it don’t “officially” install the addon tho, but i added an “Addon reloader” operator for that, i could also try to add an automatic folder (of scripts) to .zip creation + install as an option for this operator?)

i used the os for reading paths and creating a .py file, so it may not work on a mac ? i think that some security OS options will block this kind of behavior?

4 Likes

I never even tried to run an operator within drawing code. I’m fairly certain that this is not supported in general, even though it might work fine in your context.

I see that this can be useful nonetheless. I don’t see the need for adding it to the official Blender release with this hack though. Anyone that needs it can just install it as any other addon.

2 Likes

@BD3D I get a question, why in the link operator are you using a timer?
about the run, to have the context, I’m using:

def run_script(self, path, dirpath, name):

    if dirpath not in sys.path:
        sys.path.append(dirpath)
    # Change current working directory to scripts folder
    os.chdir(dirpath)

    # exec(compile(open(path).read(), path, 'exec'),{}) #not enough
    global_namespace = {
        "__file__": path, "__name__": "__main__"}
    try:
        with open(path, 'r') as file:
            exec(compile(file.read(), path, 'exec'),
                 global_namespace)
    except:
        self.report(
            {'WARNING'}, f'WRONG PATH {path}, check your selection')
        show_message_box(
            message=f'WRONG PATH {path}, check your selection', title="WARNING", icon='ERROR')
        return

    self.report({'INFO'}, f'RUN SCRIPT: "{name}"')