Added preliminary CLI. Supports building a project, transfering and running on PLC. wxPython4
authorEdouard Tisserant <edouard.tisserant@gmail.com>
Mon, 13 Jun 2022 18:05:12 +0200
branchwxPython4
changeset 3501 fa291393aac7
parent 3491 88c4b18453d5
child 3502 a35bf9c585cf
Added preliminary CLI. Supports building a project, transfering and running on PLC.
BeremizIDE.py
Beremiz_cli.py
CLIController.py
LocalRuntimeMixin.py
ProjectController.py
connectors/__init__.py
fake_wx.py
util/paths.py
--- a/BeremizIDE.py	Mon May 30 15:30:51 2022 +0200
+++ b/BeremizIDE.py	Mon Jun 13 18:05:12 2022 +0200
@@ -28,9 +28,7 @@
 from __future__ import print_function
 import os
 import sys
-import tempfile
 import shutil
-import random
 import time
 from time import time as gettime
 from threading import Lock, Timer, currentThread
@@ -47,9 +45,8 @@
 from editors.TextViewer import TextViewer
 from editors.ResourceEditor import ConfigurationEditor, ResourceEditor
 from editors.DataTypeEditor import DataTypeEditor
-from util import paths as paths
+from util.paths import Bpath
 from util.MiniTextControler import MiniTextControler
-from util.ProcessLogger import ProcessLogger
 from util.BitmapLibrary import GetBitmap
 from controls.LogViewer import LogViewer
 from controls.CustomStyledTextCtrl import CustomStyledTextCtrl
@@ -84,12 +81,8 @@
     EncodeFileSystemPath, \
     DecodeFileSystemPath
 
-
-beremiz_dir = paths.AbsDir(__file__)
-
-
-def Bpath(*args):
-    return os.path.join(beremiz_dir, *args)
+from LocalRuntimeMixin import LocalRuntimeMixin
+
 
 def AppendMenu(parent, help, id, kind, text):
     return parent.Append(wx.MenuItem(helpString=help, id=id, kind=kind, text=text))
@@ -240,7 +233,7 @@
 ID_FILEMENURECENTPROJECTS = wx.NewId()
 
 
-class Beremiz(IDEFrame):
+class Beremiz(IDEFrame, LocalRuntimeMixin):
 
     def _init_utils(self):
         self.ConfNodeMenu = wx.Menu(title='')
@@ -451,6 +444,8 @@
             os.environ["PATH"] = os.getcwd()+';'+os.environ["PATH"]
 
     def __init__(self, parent, projectOpen=None, buildpath=None, ctr=None, debug=True, logf=None):
+        LocalRuntimeMixin.__init__(self)
+
         # Add beremiz's icon in top left corner of the frame
         self.icon = wx.Icon(Bpath("images", "brz.ico"), wx.BITMAP_TYPE_ICO)
         self.__init_execute_path()
@@ -458,10 +453,6 @@
         IDEFrame.__init__(self, parent, debug)
         self.Log = LogPseudoFile(self.LogConsole, self.SelectTab, logf)
 
-        self.local_runtime = None
-        self.runtime_port = None
-        self.local_runtime_tmpdir = None
-
         self.LastPanelSelected = None
 
         # Define Tree item icon list
@@ -525,37 +516,6 @@
         else:
             self.SetTitle(name)
 
-    def StartLocalRuntime(self, taskbaricon=True):
-        if (self.local_runtime is None) or (self.local_runtime.exitcode is not None):
-            # create temporary directory for runtime working directory
-            self.local_runtime_tmpdir = tempfile.mkdtemp()
-            # choose an arbitrary random port for runtime
-            self.runtime_port = int(random.random() * 1000) + 61131
-            self.Log.write(_("Starting local runtime...\n"))
-            # launch local runtime
-            self.local_runtime = ProcessLogger(
-                self.Log,
-                "\"%s\" \"%s\" -p %s -i localhost %s %s" % (
-                    sys.executable,
-                    Bpath("Beremiz_service.py"),
-                    self.runtime_port,
-                    {False: "-x 0", True: "-x 1"}[taskbaricon],
-                    self.local_runtime_tmpdir),
-                no_gui=False,
-                timeout=500, keyword=self.local_runtime_tmpdir,
-                cwd=self.local_runtime_tmpdir)
-            self.local_runtime.spin()
-        return self.runtime_port
-
-    def KillLocalRuntime(self):
-        if self.local_runtime is not None:
-            # shutdown local runtime
-            self.local_runtime.kill(gently=False)
-            # clear temp dir
-            shutil.rmtree(self.local_runtime_tmpdir)
-
-            self.local_runtime = None
-
     def OnOpenWidgetInspector(self, evt):
         # Activate the widget inspection tool
         from wx.lib.inspection import InspectionTool
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/Beremiz_cli.py	Mon Jun 13 18:05:12 2022 +0200
@@ -0,0 +1,112 @@
+#!/usr/bin/env python
+# -*- coding: utf-8 -*-
+
+import os
+import posixpath
+import sys
+from functools import wraps
+
+import click
+from importlib import import_module
+
+
+class CLISession(object):
+    def __init__(self, **kwargs):
+        self.__dict__.update(kwargs)
+        self.controller = None
+
+pass_session = click.make_pass_decorator(CLISession)
+
+
+@click.group(chain=True)
+@click.option(
+    "--project-home",
+    envvar="PROJECT_HOME",
+    default=".",
+    metavar="PATH",
+    help="Changes the project folder location.",
+)
+@click.option(
+    "--config",
+    nargs=2,
+    multiple=True,
+    metavar="KEY VALUE",
+    help="Overrides a config key/value pair.",
+)
+@click.option(
+    "--keep", "-k", is_flag=True,
+    help="Keep local runtime, do not kill it after executing commands.",
+)
+@click.option("--verbose", "-v", is_flag=True, help="Enables verbose mode.")
+@click.option(
+    "--buildpath", "-b", help="Where to store files created during build."
+)
+@click.option(
+    "--uri", "-u", help="URI to reach remote PLC."
+)
+@click.version_option("0.1")
+@click.pass_context
+def cli(ctx, **kwargs):
+    """Beremiz CLI manipulates beremiz projects and runtimes. """
+
+    ctx.obj = CLISession(**kwargs)
+
+def ensure_controller(func):
+    @wraps(func)
+    def func_wrapper(session, *args, **kwargs):
+        if session.controller is None:
+            session.controller = import_module("CLIController").CLIController(session)
+        ret = func(session, *args, **kwargs)
+        return ret
+
+    return func_wrapper
+
+@cli.command()
+@click.option(
+    "--target", "-t", help="Target system triplet."
+)
+@pass_session
+@ensure_controller
+def build(session, target):
+    """Builds project. """
+    def processor():
+        return session.controller.build_project(target)
+    return processor
+
+@cli.command()
+@pass_session
+@ensure_controller
+def transfer(session):
+    """Transfer program to PLC runtim."""
+    def processor():
+        return session.controller.transfer_project()
+    return processor
+
+@cli.command()
+@pass_session
+@ensure_controller
+def run(session):
+    """Run program already present in PLC. """
+    def processor():
+        return session.controller.run_project()
+    return processor
+
+
+@cli.resultcallback()
+@pass_session
+def process_pipeline(session, processors, **kwargs):
+    ret = 0
+    for processor in processors:
+        ret = processor()
+        if ret != 0:
+            if len(processors) > 1 :
+                click.echo("Command sequence aborted")
+            break
+
+    session.controller.finish()
+
+    return ret
+
+if __name__ == '__main__':
+    cli()
+
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/CLIController.py	Mon Jun 13 18:05:12 2022 +0200
@@ -0,0 +1,120 @@
+#!/usr/bin/env python
+# -*- coding: utf-8 -*-
+
+import os
+import sys
+from functools import wraps
+
+import click
+
+import fake_wx
+
+from ProjectController import ProjectController
+from LocalRuntimeMixin import LocalRuntimeMixin
+
+class Log:
+
+    def __init__(self):
+        self.crlfpending = False
+
+    def write(self, s):
+        if s:
+            if self.crlfpending:
+                sys.stdout.write("\n")
+            sys.stdout.write(s)
+            self.crlfpending = 0
+
+    def write_error(self, s):
+        if s:
+            self.write("Error: "+s)
+
+    def write_warning(self, s):
+        if s:
+            self.write("Warning: "+s)
+
+    def flush(self):
+        sys.stdout.flush()
+        
+    def isatty(self):
+        return False
+
+    def progress(self, s):
+        if s:
+            sys.stdout.write(s+"\r")
+            self.crlfpending = True
+
+
+def with_project_loaded(func):
+    @wraps(func)
+    def func_wrapper(self, *args, **kwargs):
+        if not self.HasOpenedProject():
+            if self.check_and_load_project():
+                return 1 
+            self.apply_config()
+        return func(self, *args, **kwargs)
+
+    return func_wrapper
+
+def connected(func):
+    @wraps(func)
+    def func_wrapper(self, *args, **kwargs):
+        if self._connector is None:
+            if self.session.uri:
+                self.BeremizRoot.setURI_location(self.session.uri)
+            if not self._Connect():
+                return 1
+        return func(self, *args, **kwargs)
+
+    return func_wrapper
+
+class CLIController(LocalRuntimeMixin, ProjectController):
+    def __init__(self, session):
+        self.session = session
+        log = Log()
+        LocalRuntimeMixin.__init__(self, log)
+        ProjectController.__init__(self, None, log)
+
+    def check_and_load_project(self):
+        if not os.path.isdir(self.session.project_home):
+            self.logger.write_error(
+                _("\"%s\" is not a valid Beremiz project\n") % self.session.project_home)
+            return True
+
+        errmsg, error = self.LoadProject(self.session.project_home, self.session.buildpath)
+        if error:
+            self.logger.write_error(errmsg)
+            return True
+
+    def apply_config(self):
+        for k,v in self.session.config:
+            self.SetParamsAttribute("BeremizRoot."+k, v)
+
+    @with_project_loaded
+    def build_project(self, target):
+
+        if target:
+            self.SetParamsAttribute("BeremizRoot.TargetType", target)
+            
+        return 0 if self._Build() else 1
+
+    @with_project_loaded
+    @connected
+    def transfer_project(self):
+
+        return 0 if self._Transfer() else 1
+
+    @with_project_loaded
+    @connected
+    def run_project(self):
+
+        return 0 if self._Run() else 1
+        
+
+    def finish(self):
+
+        self._Disconnect()
+
+        if not self.session.keep:
+            self.KillLocalRuntime()
+
+
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/LocalRuntimeMixin.py	Mon Jun 13 18:05:12 2022 +0200
@@ -0,0 +1,48 @@
+#!/usr/bin/env python
+# -*- coding: utf-8 -*-
+import sys
+import tempfile
+import random
+import shutil
+from util.ProcessLogger import ProcessLogger
+from util.paths import Bpath
+
+class LocalRuntimeMixin():
+
+    def __init__(self, log):
+        self.local_runtime_log = log
+        self.local_runtime = None
+        self.runtime_port = None
+        self.local_runtime_tmpdir = None
+
+    def StartLocalRuntime(self, taskbaricon=True):
+        if (self.local_runtime is None) or (self.local_runtime.exitcode is not None):
+            # create temporary directory for runtime working directory
+            self.local_runtime_tmpdir = tempfile.mkdtemp()
+            # choose an arbitrary random port for runtime
+            self.runtime_port = int(random.random() * 1000) + 61131
+            self.local_runtime_log.write(_("Starting local runtime...\n"))
+            # launch local runtime
+            self.local_runtime = ProcessLogger(
+                self.local_runtime_log,
+                "\"%s\" \"%s\" -p %s -i localhost %s %s" % (
+                    sys.executable,
+                    Bpath("Beremiz_service.py"),
+                    self.runtime_port,
+                    {False: "-x 0", True: "-x 1"}[taskbaricon],
+                    self.local_runtime_tmpdir),
+                no_gui=False,
+                timeout=500, keyword=self.local_runtime_tmpdir,
+                cwd=self.local_runtime_tmpdir)
+            self.local_runtime.spin()
+        return self.runtime_port
+
+    def KillLocalRuntime(self):
+        if self.local_runtime is not None:
+            # shutdown local runtime
+            self.local_runtime.kill(gently=False)
+            # clear temp dir
+            shutil.rmtree(self.local_runtime_tmpdir)
+
+            self.local_runtime = None
+
--- a/ProjectController.py	Mon May 30 15:30:51 2022 +0200
+++ b/ProjectController.py	Mon Jun 13 18:05:12 2022 +0200
@@ -1526,7 +1526,8 @@
         # clear previous_plcstate to restore status
         # in UpdateMethodsFromPLCStatus()
         self.previous_plcstate = ""
-        self.AppFrame.ProgressStatusBar.Hide()
+        if self.AppFrame is not None:
+            self.AppFrame.ProgressStatusBar.Hide()
         self.UpdateMethodsFromPLCStatus()
 
     def PullPLCStatusProc(self, event):
@@ -1785,13 +1786,16 @@
         """
         Start PLC
         """
+        success = False
         if self.GetIECProgramsAndVariables():
             self._connector.StartPLC()
             self.logger.write(_("Starting PLC\n"))
             self._connect_debug()
+            success = True
         else:
             self.logger.write_error(_("Couldn't start PLC !\n"))
         wx.CallAfter(self.UpdateMethodsFromPLCStatus)
+        return success
 
     def _Stop(self):
         """
@@ -1805,6 +1809,10 @@
 
         wx.CallAfter(self.UpdateMethodsFromPLCStatus)
 
+    def StartLocalRuntime(self):
+        if self.AppFrame:
+            return self.AppFrame.StartLocalRuntime()
+
     def _SetConnector(self, connector, update_status=True):
         self._connector = connector
         if self.AppFrame is not None:
@@ -1821,6 +1829,7 @@
                 wx.CallAfter(self.UpdateMethodsFromPLCStatus)
 
     def _Connect(self):
+        success = False
         # don't accept re-connetion if already connected
         if self._connector is not None:
             self.logger.write_error(
@@ -1882,6 +1891,8 @@
                 else:
                     self.logger.write_warning(
                         _("Debug does not match PLC - stop/transfert/start to re-enable\n"))
+            success = True
+        return success
 
     def CompareLocalAndRemotePLC(self):
         if self._connector is None:
@@ -1905,6 +1916,7 @@
         self._SetConnector(None)
 
     def _Transfer(self):
+        success = False
         if self.IsPLCStarted():
             dialog = wx.MessageDialog(
                 self.AppFrame,
@@ -1967,16 +1979,19 @@
                 if self.GetIECProgramsAndVariables():
                     self.UnsubscribeAllDebugIECVariable()
                     self.ProgramTransferred()
-                    self.AppFrame.CloseObsoleteDebugTabs()
-                    self.AppFrame.RefreshPouInstanceVariablesPanel()
-                    self.AppFrame.LogViewer.ResetLogCounters()
+                    if self.AppFrame is not None:
+                        self.AppFrame.CloseObsoleteDebugTabs()
+                        self.AppFrame.RefreshPouInstanceVariablesPanel()
+                        self.AppFrame.LogViewer.ResetLogCounters()
                     self.logger.write(_("PLC installed successfully.\n"))
+                    success = True
                 else:
                     self.logger.write_error(_("Missing debug data\n"))
             else:
                 self.logger.write_error(_("PLC couldn't be installed\n"))
 
         wx.CallAfter(self.UpdateMethodsFromPLCStatus)
+        return success
 
     def _Repair(self):
         dialog = wx.MessageDialog(
--- a/connectors/__init__.py	Mon May 30 15:30:51 2022 +0200
+++ b/connectors/__init__.py	Mon Jun 13 18:05:12 2022 +0200
@@ -76,8 +76,7 @@
         # pyro connection to local runtime
         # started on demand, listening on random port
         scheme = "PYRO"
-        runtime_port = confnodesroot.AppFrame.StartLocalRuntime(
-            taskbaricon=True)
+        runtime_port = confnodesroot.StartLocalRuntime()
         uri = "PYROLOC://127.0.0.1:" + str(runtime_port)
 
     # commented code to enable for MDNS:// support
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/fake_wx.py	Mon Jun 13 18:05:12 2022 +0200
@@ -0,0 +1,109 @@
+#!/usr/bin/env python
+# -*- coding: utf-8 -*-
+
+import sys
+import new
+from types import ModuleType
+
+# TODO use gettext instead
+def get_translation(txt):
+    return txt
+
+
+class FakeObject:
+    def __init__(self, *args, **kwargs):
+        self.__classname__ = kwargs["__classname__"]
+
+    def __getattr__(self,name):
+        if name.startswith('__'):
+            raise AttributeError, name
+        return FakeObject(__classname__=self.__classname__+"."+name)
+
+    def __call__(self, *args, **kwargs):
+        return FakeObject(__classname__=self.__classname__+"()")
+
+    def __getitem__(self, key):
+        raise IndexError, key
+
+    def __str__(self):
+        return self.__classname__
+
+    def __or__(self, other):
+        return FakeObject(__classname__=self.__classname__+"|"+other.__classname__)
+
+
+class FakeClass:
+    def __init__(self, *args, **kwargs):
+        print("DUMMY Class __init__ !",self.__name__,args,kwargs)
+
+
+class FakeModule(ModuleType):
+    def __init__(self, name, classes):
+        self.__modname__ = name
+        self.__objects__ = dict(map(lambda desc: 
+            (desc, new.classobj(desc, (FakeClass,), {}))
+            if type(desc)==str else desc, classes))
+        ModuleType(name)
+
+    def __getattr__(self,name):
+        if name.startswith('__'):
+            raise AttributeError, name
+  
+        if self.__objects__.has_key(name):
+            return self.__objects__[name]
+  
+        obj = FakeObject(__classname__=self.__modname__+"."+name)
+        self.__objects__[name] = obj
+        return obj
+
+
+# Keep track of already faked modules to catch those
+# that are already present in sys.modules from start
+# (i.e. mpl_toolkits for exemple)
+already_patched = {}
+
+for name, classes in [
+    # list given for each module name contains name string for a FakeClass,
+    # otherwise a tuple (name, object) for arbitrary object/function/class
+    ('wx',[
+        'Panel', 'PyCommandEvent', 'Dialog', 'PopupWindow', 'TextEntryDialog',
+        'Notebook', 'ListCtrl', 'TextDropTarget', 'PyControl', 'TextCtrl', 
+        'SplitterWindow', 'Frame', 'Printout', 'StaticBitmap',
+        ('GetTranslation', get_translation)]),
+    ('wx.lib.agw.advancedsplash',[]),
+    ('wx.lib.buttons',['GenBitmapTextButton']),
+    ('wx.adv',['EditableListBox']),
+    ('wx.grid',[
+        'Grid', 'PyGridTableBase', 'GridCellEditor', 'GridCellTextEditor',
+        'GridCellChoiceEditor']),
+    ('wx.lib.agw.customtreectrl',['CustomTreeCtrl']),
+    ('wx.lib.intctrl',['IntCtrl']),
+    ('matplotlib.pyplot',[]),
+    ('matplotlib.backends.backend_wxagg',['FigureCanvasWxAgg']),
+    ('wx.stc',['StyledTextCtrl']),
+    ('wx.lib.scrolledpanel',[]),
+    ('wx.lib.mixins.listctrl',['ColumnSorterMixin', 'ListCtrlAutoWidthMixin']),
+    ('wx.dataview',['PyDataViewIndexListModel']),
+    ('matplotlib.backends.backend_agg',[]),
+    ('wx.aui',[]),
+    ('mpl_toolkits.mplot3d',[])]:
+    modpath = None
+    parentmod = None
+    for identifier in name.split("."):
+        modpath = (modpath + "." + identifier) if modpath else identifier
+        mod = sys.modules.get(modpath, None)
+
+        if mod is None or modpath not in already_patched:
+            mod = FakeModule(modpath, classes)
+            sys.modules[modpath] = mod
+            already_patched[modpath] = True
+
+        if parentmod is not None:
+            parentmod.__objects__[identifier] = mod
+
+        parentmod = mod
+
+from six.moves import builtins
+
+builtins.__dict__['_'] = get_translation
+
--- a/util/paths.py	Mon May 30 15:30:51 2022 +0200
+++ b/util/paths.py	Mon Jun 13 18:05:12 2022 +0200
@@ -55,3 +55,8 @@
     """
     return os.path.join(AbsParentDir(__file__, 2), name)
 
+def Bpath(name):
+    """
+    Return path of files in Beremiz project
+    """
+    return os.path.join(AbsParentDir(__file__, 1), name)