changeset 3281:a3639d6d9643

core: replaced `sat` shell script by a python script: the backend is now launched by `sat.core.launcher`, and the script is generated during installation with a new entry point in `setup.py`.
author Goffi <goffi@goffi.org>
date Mon, 25 May 2020 15:50:01 +0200
parents 96b9b65b4368
children e7e7be79fbcd
files bin/sat sat/core/launcher.py sat/memory/memory.py sat/tools/config.py setup.py twisted/plugins/sat_plugin.py
diffstat 6 files changed, 240 insertions(+), 149 deletions(-) [+]
line wrap: on
line diff
--- a/bin/sat	Mon May 25 15:46:21 2020 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,112 +0,0 @@
-#!/bin/sh
-
-DEBUG=""
-DAEMON=""
-PYTHON="python3"
-
-TWISTD="$(which twistd 2>/dev/null)"
-if [ $? -ne 0 ]; then
-	# for Python 3, "twistd" is named "twistd3" on some distros
-	TWISTD="$(which twistd3 2>/dev/null)"
-fi
-if [ $? -ne 0 ]; then
-  	printf "Can't find \"twistd\" script, are you sure that Twisted is installed?\n"	
-	exit 1
-fi
-
-kill_process() {
-    # $1 is the file containing the PID to kill, $2 is the process name
-    if [ -f $1 ]; then
-        PID=`cat $1`
-        if ps -p $PID > /dev/null; then
-            printf  "Terminating $2... "
-            kill $PID
-            while ps -p $PID > /dev/null; do
-                sleep 0.2
-            done
-            printf "OK\n"
-        else
-            echo "No running process of ID $PID... removing PID file"
-            rm -f $1
-        fi
-    else
-        echo "$2 is probably not running (PID file doesn't exist)"
-    fi
-}
-
-#We use python to parse config files
-eval `/usr/bin/env "$PYTHON" << PYTHONEND
-from sat.core.constants import Const as C
-from sat.tools.config import fixLocalDir
-from configparser import ConfigParser
-from os.path import expanduser, join
-import sys
-
-fixLocalDir()  # XXX: tmp update code, will be removed in the future
-
-config = ConfigParser(defaults=C.DEFAULT_CONFIG)
-try:
-    config.read(C.CONFIG_FILES)
-except:
-    print ("echo \"/!\\ Can't read main config ! Please check the syntax\";")
-    print ("exit 1")
-    sys.exit()
-
-env=[]
-env.append("PID_DIR='%s'" % join(expanduser(config.get('DEFAULT', 'pid_dir')),''))
-env.append("LOG_DIR='%s'" % join(expanduser(config.get('DEFAULT', 'log_dir')),''))
-env.append("APP_NAME='%s'" % C.APP_NAME)
-env.append("APP_NAME_FILE='%s'" % C.APP_NAME_FILE)
-print (";".join(env))
-PYTHONEND
-`
-APP_NAME="$APP_NAME"
-PID_FILE="$PID_DIR$APP_NAME_FILE.pid"
-LOG_FILE="$LOG_DIR$APP_NAME_FILE.log"
-RUNNING_MSG="$APP_NAME is running"
-NOT_RUNNING_MSG="$APP_NAME is *NOT* running"
-
-# if there is one argument which is "stop", then we kill SaT
-if [ $# -eq 1 ];then
-    if [ $1 = "stop" ];then
-        kill_process $PID_FILE "$APP_NAME"
-        exit 0
-    elif [ $1 = "debug" ];then
-        echo "Launching $APP_NAME in debug mode"
-        DEBUG="--debug"
-    elif [ $1 = "fg" ];then
-        echo "Launching $APP_NAME in foreground mode"
-        DAEMON="n"
-    elif [ $1 = "status" ];then
-		if [ -f $PID_FILE ]; then
-			PID=`cat $PID_FILE`
-			ps -p$PID 2>&1 > /dev/null
-			if [ $? = 0  ];then
-				echo "$RUNNING_MSG (pid: $PID)"
-				exit 0
-			else
-				echo "$NOT_RUNNING_MSG, but a pid file is present (bad exit ?): $PID_FILE"
-				exit 2
-			fi
-		else
-			echo "$NOT_RUNNING_MSG"
-			exit 1
-		fi
-	else
-		echo "bad argument, please use one of (stop, debug, fg, status) or no argument"
-		exit 1
-    fi
-fi
-
-MAIN_OPTIONS="-${DAEMON}o"
-
-#Don't change the next lines
-AUTO_OPTIONS=""
-ADDITIONAL_OPTIONS="--pidfile $PID_FILE --logfile $LOG_FILE $AUTO_OPTIONS $DEBUG"
-
-log_dir=`dirname "$LOG_FILE"`
-if [ ! -d $log_dir ] ; then
-    mkdir $log_dir
-fi
-
-exec /usr/bin/env $PYTHON $TWISTD $MAIN_OPTIONS $ADDITIONAL_OPTIONS $APP_NAME_FILE
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/sat/core/launcher.py	Mon May 25 15:50:01 2020 +0200
@@ -0,0 +1,228 @@
+#!/usr/bin/env python3
+
+# SàT: an XMPP client
+# Copyright (C) 2009-2020 Jérôme Poisson (goffi@goffi.org)
+
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 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 Affero General Public License for more details.
+
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+"""Script launching SàT backend"""
+
+import sys
+import os
+import argparse
+from pathlib import Path
+from configparser import ConfigParser
+from os.path import expanduser, join
+from twisted.application import app
+from twisted.python import usage
+from sat.core.constants import Const as C
+
+
+class SatLogger(app.AppLogger):
+
+    def start(self, application):
+        # logging is initialised by sat.core.log_config via the Twisted plugin, nothing
+        # to do here
+        self._initialLog()
+
+    def stop(self):
+        pass
+
+
+class Launcher:
+    APP_NAME=C.APP_NAME
+    APP_NAME_FILE=C.APP_NAME_FILE
+
+    @property
+    def NOT_RUNNING_MSG(self):
+        return f"{self.APP_NAME} is *NOT* running"
+
+    def cmd_background(self, args):
+        self.run_twistd(args)
+
+    def cmd_foreground(self, args):
+        self.run_twistd(args, twistd_opts=['--nodaemon'])
+
+    def cmd_debug(self, args):
+        self.run_twistd(args, twistd_opts=['--debug'])
+
+    def cmd_stop(self, args):
+        import signal
+        import time
+        config = self.get_config()
+        pid_file = self.get_pid_file(config)
+        if not pid_file.is_file():
+            print(self.NOT_RUNNING_MSG)
+            sys.exit(0)
+        try:
+            pid = int(pid_file.read_text())
+        except Exception as e:
+            print(f"Can't read PID file at {pid_file}: {e}")
+            # we use the same exit code as DATA_ERROR in jp
+            sys.exit(17)
+        print(f"Terminating {self.APP_NAME}…")
+        os.kill(pid, signal.SIGTERM)
+        kill_started = time.time()
+        state = "init"
+        import errno
+        while True:
+            try:
+                os.kill(pid, 0)
+            except OSError as e:
+                if e.errno == errno.ESRCH:
+                    break
+                elif e.errno == errno.EPERM:
+                    print(f"Can't kill {self.APP_NAME}, the process is owned by an other user", file=sys.stderr)
+                    sys.exit(18)
+                else:
+                    raise e
+            time.sleep(0.2)
+            now = time.time()
+            if state == 'init' and now - kill_started > 5:
+                if state == 'init':
+                    state = 'waiting'
+                    print(f"Still waiting for {self.APP_NAME} to be terminated…")
+            elif state == 'waiting' and now - kill_started > 10:
+                state == 'killing'
+                print(f"Waiting for too long, we kill the process")
+                os.kill(pid, signal.SIGKILL)
+                sys.exit(1)
+
+        sys.exit(0)
+
+    def cmd_status(self, args):
+        config = self.get_config()
+        pid_file = self.get_pid_file(config)
+        if pid_file.is_file():
+            import errno
+            try:
+                pid = int(pid_file.read_text())
+            except Exception as e:
+                print(f"Can't read PID file at {pid_file}: {e}")
+                # we use the same exit code as DATA_ERROR in jp
+                sys.exit(17)
+            # we check if there is a process
+            # inspired by https://stackoverflow.com/a/568285 and https://stackoverflow.com/a/6940314
+            try:
+                os.kill(pid, 0)
+            except OSError as e:
+                if e.errno == errno.ESRCH:
+                    running = False
+                elif e.errno == errno.EPERM:
+                    print("Process {pid} is run by an other user")
+                    running = True
+            else:
+                running = True
+
+            if running:
+                print(f"{self.APP_NAME} is running (pid: {pid})")
+                sys.exit(0)
+            else:
+                print(f"{self.NOT_RUNNING_MSG}, but a pid file is present (bad exit ?): {pid_file}")
+                sys.exit(2)
+        else:
+            print(self.NOT_RUNNING_MSG)
+            sys.exit(1)
+
+    def parse_args(self):
+        parser = argparse.ArgumentParser(description=f"Launch {self.APP_NAME} backend")
+        parser.set_defaults(cmd=self.cmd_background)
+        subparsers = parser.add_subparsers()
+
+        bg_parser = subparsers.add_parser(
+            'background',
+            aliases=['bg'],
+            help=f"run {self.APP_NAME} backend in background (as a daemon)")
+        bg_parser.set_defaults(cmd=self.cmd_background)
+
+        fg_parser = subparsers.add_parser(
+            'foreground',
+            aliases=['fg'],
+            help=f"run {self.APP_NAME} backend in foreground")
+        fg_parser.set_defaults(cmd=self.cmd_foreground)
+
+        dbg_parser = subparsers.add_parser(
+            'debug',
+            help=f"run {self.APP_NAME} backend in debug mode")
+        dbg_parser.set_defaults(cmd=self.cmd_debug)
+
+        stop_parser = subparsers.add_parser(
+            'stop',
+            help=f"stop running {self.APP_NAME} backend")
+        stop_parser.set_defaults(cmd=self.cmd_stop)
+
+        status_parser = subparsers.add_parser(
+            'status',
+            help=f"indicate if {self.APP_NAME} backend is running")
+        status_parser.set_defaults(cmd=self.cmd_status)
+
+        return parser.parse_args()
+
+    def get_config(self):
+        config = ConfigParser(defaults=C.DEFAULT_CONFIG)
+        try:
+            config.read(C.CONFIG_FILES)
+        except Exception as e:
+            print (rf"/!\ Can't read main config! {e}")
+            sys.exit(1)
+        return config
+
+    def get_pid_file(self, config):
+        pid_dir = Path(config.get('DEFAULT', 'pid_dir')).expanduser()
+        return pid_dir / f"{self.APP_NAME_FILE}.pid"
+
+    def run_twistd(self, args, twistd_opts=None):
+        """Run twistd settings options with args"""
+        from twisted.python.runtime import platformType
+        if platformType == "win32":
+            from twisted.scripts._twistw import (ServerOptions,
+                                                 WindowsApplicationRunner as app_runner)
+        else:
+            from twisted.scripts._twistd_unix import (ServerOptions,
+                                                      UnixApplicationRunner as app_runner)
+
+        app_runner.loggerFactory = SatLogger
+        server_options = ServerOptions()
+        config = self.get_config()
+        pid_file = self.get_pid_file(config)
+        log_dir = Path(config.get('DEFAULT', 'log_dir')).expanduser()
+        log_file = log_dir / f"{self.APP_NAME_FILE}.log"
+        server_opts = [
+            '--no_save',
+            '--pidfile', str(pid_file),
+            '--logfile', str(log_file),
+            ]
+        if twistd_opts is not None:
+            server_opts.extend(twistd_opts)
+        server_opts.append(self.APP_NAME_FILE)
+        try:
+            server_options.parseOptions(server_opts)
+        except usage.error as ue:
+            print(server_options)
+            print("%s: %s" % (sys.argv[0], ue))
+            sys.exit(1)
+        else:
+            runner = app_runner(server_options)
+            runner.run()
+            if runner._exitSignal is not None:
+                app._exitWithSignal(runner._exitSignal)
+
+    @classmethod
+    def run(cls):
+        args = cls().parse_args()
+        args.cmd(args)
+
+
+if __name__ == '__main__':
+    Launcher.run()
--- a/sat/memory/memory.py	Mon May 25 15:46:21 2020 +0200
+++ b/sat/memory/memory.py	Mon May 25 15:50:01 2020 +0200
@@ -235,8 +235,6 @@
         self.subscriptions = {}
         self.auth_sessions = PasswordSessions()  # remember the authenticated profiles
         self.disco = Discovery(host)
-        # XXX: tmp update code, will be removed in the future
-        tools_config.fixLocalDir(False)
         self.config = tools_config.parseMainConf(log_filenames=True)
         database_file = os.path.expanduser(
             os.path.join(self.getConfig("", "local_dir"), C.SAVEFILE_DATABASE)
--- a/sat/tools/config.py	Mon May 25 15:46:21 2020 +0200
+++ b/sat/tools/config.py	Mon May 25 15:50:01 2020 +0200
@@ -34,8 +34,10 @@
 
 
 def fixConfigOption(section, option, value, silent=True):
-    """Force a configuration option value, writing it in the first found user
-    config file, eventually creating a new user config file if none is found.
+    """Force a configuration option value
+
+    the option will be written in the first found user config file, a new user
+    config will be created if none is found.
 
     @param section (str): the config section
     @param option (str): the config option
@@ -70,35 +72,6 @@
                                                 config_file=target_file))
 
 
-# XXX: tmp update code, will be removed in the future
-# When you remove this, please add the default value for
-# 'local_dir' in sat.core.constants.Const.DEFAULT_CONFIG
-def fixLocalDir(silent=True):
-    """Retro-compatibility with the previous local_dir default value.
-
-    @param silent (boolean): toggle logging output (must be True when called from sat.sh)
-    """
-    user_config = ConfigParser()
-    try:
-        user_config.read(C.CONFIG_FILES)
-    except:
-        pass  # file is readable but its structure if wrong
-    try:
-        current_value = user_config.get("DEFAULT", "local_dir")
-    except (NoOptionError, NoSectionError):
-        current_value = ""
-    if current_value:
-        return  # nothing to do
-    old_default = "~/.sat"
-    if os.path.isfile(os.path.expanduser(old_default) + "/" + C.SAVEFILE_DATABASE):
-        if not silent:
-            log.warning(_(
-                "A database has been found in the default local_dir for previous "
-                "versions (< 0.5)"
-            ))
-        fixConfigOption("", "local_dir", old_default, silent)
-
-
 def parseMainConf(log_filenames=False):
     """Look for main .ini configuration file, and parse it
 
--- a/setup.py	Mon May 25 15:46:21 2020 +0200
+++ b/setup.py	Mon May 25 15:50:01 2020 +0200
@@ -110,7 +110,12 @@
                  ["CHANGELOG", "COPYING", "INSTALL", "README", "README4TRANSLATORS"]),
                 (os.path.join("share", DBUS_DIR), [DBUS_FILE]),
                 ],
-    scripts=["sat_frontends/jp/jp", "sat_frontends/primitivus/primitivus", "bin/sat"],
+    entry_points={
+        "console_scripts": [
+            "sat = sat.core.launcher:Launcher.run",
+            ],
+        },
+    scripts=["sat_frontends/jp/jp", "sat_frontends/primitivus/primitivus"],
     zip_safe=False,
     setup_requires=["setuptools_scm"] if is_dev_version else [],
     use_scm_version=sat_dev_version if is_dev_version else False,
--- a/twisted/plugins/sat_plugin.py	Mon May 25 15:46:21 2020 +0200
+++ b/twisted/plugins/sat_plugin.py	Mon May 25 15:50:01 2020 +0200
@@ -1,8 +1,7 @@
 #!/usr/bin/env python3
 
-
-# SAT: a jabber client
-# Copyright (C) 2009-2018 Jérôme Poisson (goffi@goffi.org)
+# SàT: an XMPP client
+# Copyright (C) 2009-2020 Jérôme Poisson (goffi@goffi.org)
 
 # This program is free software: you can redistribute it and/or modify
 # it under the terms of the GNU Affero General Public License as published by