You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
350 lines
14 KiB
350 lines
14 KiB
#! /usr/bin/env python3
|
|
# -*- coding: utf-8 -*-
|
|
"""
|
|
Copyright 2022, Nils Hilbricht, Germany ( https://www.hilbricht.net )
|
|
|
|
This file is part of the Laborejo Software Suite ( https://www.laborejo.org ),
|
|
|
|
This application 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 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 General Public License for more details.
|
|
|
|
You should have received a copy of the GNU General Public License
|
|
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
|
"""
|
|
|
|
#This is the first file in the program to be actually executed, after the executable which uses this as first instruction.
|
|
|
|
"""
|
|
We use a 'wrong' scheme of importing modules here because there are multiple exit conditions, good and bad.
|
|
We don't want to load all the libraries, including the big Qt one, only to end up displaying the --version and exit.
|
|
Same with the tests if jack or nsm are running.
|
|
"""
|
|
|
|
#Give at least some feedback when C libs crash.
|
|
#Will still not work for the common case that PyQt crashes and ends Python.
|
|
#But every bit helps when hunting bugs.
|
|
import faulthandler; faulthandler.enable()
|
|
|
|
from engine.config import METADATA #includes METADATA only. No other environmental setup is executed.
|
|
from template.qtgui.chooseSessionDirectory import ChooseSessionDirectory
|
|
from template.qtgui.helper import setPaletteAndFont #our error boxes shall look like the rest of the program
|
|
|
|
"""
|
|
Check parameters first. It is possible that we will just --help or --version and exit. In this case
|
|
nothing gets loaded.
|
|
"""
|
|
import argparse
|
|
parser = argparse.ArgumentParser(description=f"""{METADATA["name"]} - Version {METADATA["version"]} - Copyright {METADATA["year"]} by {METADATA["author"]} - {METADATA["url"]}""")
|
|
parser.add_argument("-v", "--version", action='version', version="{} {}".format(METADATA["name"], METADATA["version"]))
|
|
parser.add_argument("-s", "--save", action='store', dest="directory", help="Use this directory to save. Will be created or loaded from if already present. Deactivates Agordejo/New-Session-Manager support.")
|
|
parser.add_argument("-p", "--profiler", action='store_true', help="(Development) Run the python profiler and produce a .cprof file at quit. The name will appear in your STDOUT.")
|
|
parser.add_argument("-m", "--mute", action='store_true', help="(Development) Use a fake cbox module, effectively deactivating midi and audio.")
|
|
parser.add_argument("-V", "--verbose", action='store_true', help="(Development) Switch the logger to INFO and print out all kinds of information to get a high-level idea of what the program is doing. You can also set the environment variable LSS_DEBUG=1")
|
|
args = parser.parse_args()
|
|
|
|
#Check for an alternative way to enable the logger.
|
|
import os
|
|
if args.verbose:
|
|
os.environ["LSS_DEBUG"] = "1" #for children programs.
|
|
elif not args.verbose and os.getenv("LSS_DEBUG"):
|
|
args.verbose = True
|
|
|
|
|
|
|
|
import logging
|
|
if args.verbose: #development
|
|
logging.basicConfig(level=logging.INFO, format='[' + METADATA["shortName"] + '] %(levelname)s %(asctime)s %(name)s: %(message)s',)
|
|
#logging.getLogger().setLevel(logging.INFO) #development
|
|
else: #production
|
|
logging.basicConfig(level=logging.ERROR, format='[' + METADATA["shortName"] + '] %(levelname)s %(asctime)s %(name)s: %(message)s',)
|
|
#logging.getLogger().setLevel(logging.ERROR) #production
|
|
|
|
logger = logging.getLogger(__name__)
|
|
logger.info("import")
|
|
|
|
|
|
"""set up python search path before the program starts and cbox gets imported.
|
|
We need to be earliest, so let's put it here.
|
|
This is influence during compiling by creating a temporary file "compiledprefix.py".
|
|
pyzipapp archives that in, when make is finished we delete it.
|
|
|
|
#Default mode is a self-contained directory relative to the uncompiled patroneo python start script
|
|
"""
|
|
|
|
import sys
|
|
import os
|
|
import os.path
|
|
from PyQt5.QtWidgets import QApplication, QStyleFactory
|
|
from PyQt5 import QtGui
|
|
|
|
|
|
import inspect
|
|
def get_script_dir(follow_symlinks=True):
|
|
if getattr(sys, 'frozen', False): # py2exe, PyInstaller, cx_Freeze
|
|
path = os.path.abspath(sys.executable)
|
|
else:
|
|
path = inspect.getabsfile(get_script_dir)
|
|
if follow_symlinks:
|
|
path = os.path.realpath(path)
|
|
return os.path.dirname(path)
|
|
|
|
|
|
logger.info(f"Script dir: {get_script_dir()}")
|
|
|
|
logger.info(f"Python Version {sys.version}")
|
|
|
|
try:
|
|
from compiledprefix import prefix as prefix_import
|
|
prefix:str = prefix_import
|
|
compiledVersion = True
|
|
logger.info("Compiled prefix found: {}".format(prefix))
|
|
except ModuleNotFoundError as e:
|
|
compiledVersion = False
|
|
|
|
logger.info("Compiled version: {}".format(compiledVersion))
|
|
|
|
|
|
#ZippApp with compiledprefix.py
|
|
if compiledVersion:
|
|
PATHS={ #this gets imported
|
|
"root": "",
|
|
"bin": os.path.join(prefix, "bin"), # type: ignore
|
|
"doc": os.path.join(prefix, "share", "doc", METADATA["shortName"]), # type: ignore
|
|
"desktopfile": os.path.join(prefix, "share", "applications", METADATA["shortName"] + ".desktop"), # type: ignore #not ~/Desktop but our desktop file
|
|
"share": os.path.join(prefix, "share", METADATA["shortName"]), # type: ignore
|
|
"templateShare": os.path.join(prefix, "share", METADATA["shortName"], "template"), # type: ignore
|
|
}
|
|
|
|
_root = os.path.dirname(__file__)
|
|
_root = os.path.abspath(os.path.join(_root, ".."))
|
|
|
|
#Not compiled, not installed. Running pure python directly in the source tree.
|
|
else:
|
|
_root = os.path.dirname(__file__)
|
|
_root = os.path.abspath(os.path.join(_root, ".."))
|
|
PATHS={ #this gets imported
|
|
"root": _root,
|
|
"bin": _root,
|
|
"doc": os.path.join(_root, "documentation", "out"),
|
|
"desktopfile": os.path.join(_root, "desktop", "desktop.desktop"), #not ~/Desktop but our desktop file
|
|
"share": os.path.join(_root, "engine", "resources"),
|
|
"templateShare": os.path.join(_root, "template", "engine", "resources"),
|
|
}
|
|
|
|
logger.info("PATHS: {}".format(PATHS))
|
|
|
|
#Construct QAppliction before constantsAndCOnfigs, which has the fontDB
|
|
#QtGui.QGuiApplication.setDesktopSettingsAware(False) #This will crash with new Qt!
|
|
qtApp = QApplication(sys.argv)
|
|
setPaletteAndFont(qtApp)
|
|
QApplication.setStyle(QStyleFactory.create("Fusion"))
|
|
setPaletteAndFont(qtApp)
|
|
|
|
def exitWithMessage(message:str):
|
|
title = f"""{METADATA["name"]} Error"""
|
|
if sys.stdout.isatty():
|
|
sys.exit(title + ": " + message)
|
|
else:
|
|
from PyQt5.QtWidgets import QMessageBox
|
|
#This is the start file for the Qt client so we know at least that Qt is installed and use that for a warning.
|
|
QMessageBox.critical(qtApp.desktop(), title, message)
|
|
sys.exit(title + ": " + message)
|
|
|
|
def setProcessName(executableName):
|
|
"""From
|
|
https://stackoverflow.com/questions/31132342/change-process-name-while-executing-a-python-script
|
|
"""
|
|
import ctypes, ctypes.util
|
|
lib = ctypes.cdll.LoadLibrary(None)
|
|
prctl = lib.prctl
|
|
prctl.restype = ctypes.c_int
|
|
prctl.argtypes = [ctypes.c_int, ctypes.c_char_p, ctypes.c_ulong,
|
|
ctypes.c_ulong, ctypes.c_ulong]
|
|
def set_proctitle(new_title):
|
|
result = prctl(15, new_title, 0, 0, 0)
|
|
if result != 0:
|
|
raise OSError("prctl result: %d" % result)
|
|
set_proctitle(executableName.encode())
|
|
|
|
|
|
libpthread_path = ctypes.util.find_library("pthread")
|
|
if not libpthread_path:
|
|
return
|
|
|
|
libpthread = ctypes.CDLL(libpthread_path)
|
|
if hasattr(libpthread, "pthread_setname_np"):
|
|
_pthread_setname_np = libpthread.pthread_setname_np
|
|
|
|
_pthread_self = libpthread.pthread_self
|
|
_pthread_self.argtypes = []
|
|
_pthread_self.restype = ctypes.c_void_p
|
|
|
|
_pthread_setname_np.argtypes = [ctypes.c_void_p, ctypes.c_char_p]
|
|
_pthread_setname_np.restype = ctypes.c_int
|
|
|
|
if _pthread_setname_np is None:
|
|
return
|
|
|
|
_pthread_setname_np(_pthread_self(), executableName.encode())
|
|
|
|
def checkNsmOrExit(prettyName):
|
|
"""Check for NSM"""
|
|
#NSM changes our cwd to whereever we started new-session-manager from.
|
|
#print (os.getcwd())
|
|
import sys
|
|
from os import getenv
|
|
if not getenv("NSM_URL"): #NSMClient checks for this itself but we can anticipate an error and inform the user.
|
|
|
|
path = ChooseSessionDirectory(qtApp).path #ChooseSessionDirectory is calling exec. We can't call qtapp.exec_ because that blocks forever, even after quitting the window.
|
|
#qSessionDirApp.quit()
|
|
#del qSessionDirApp
|
|
#path = "/tmp"
|
|
|
|
if path:
|
|
startPseudoNSMServer(path)
|
|
else:
|
|
sys.exit()
|
|
|
|
#message = f"""Please start {prettyName} only through the New Session Manager (NSM) or use the --save command line parameter."""
|
|
#exitWithMessage(message)
|
|
|
|
def _is_jack_running():
|
|
"""Check for JACK"""
|
|
import ctypes
|
|
import os
|
|
|
|
if not args.verbose:
|
|
silent = os.open(os.devnull, os.O_WRONLY)
|
|
stdout = os.dup(1)
|
|
stderr = os.dup(2)
|
|
os.dup2(silent, 1) #stdout
|
|
os.dup2(silent, 2) #stderr
|
|
cjack = ctypes.cdll.LoadLibrary("libjack.so.0")
|
|
class jack_client_t(ctypes.Structure):
|
|
_fields_ = []
|
|
cjack.jack_client_open.argtypes = [ctypes.c_char_p, ctypes.c_int, ctypes.POINTER(ctypes.c_int)] #the two ints are enum and pointer to enum. #http://jackaudio.org/files/docs/html/group__ClientFunctions.html#gab8b16ee616207532d0585d04a0bd1d60
|
|
cjack.jack_client_open.restype = ctypes.POINTER(jack_client_t)
|
|
ctypesJackClient = cjack.jack_client_open("probe".encode("ascii"), 0x01, None) #0x01 is the bit set for do not autostart JackNoStartServer
|
|
try:
|
|
ret = bool(ctypesJackClient.contents)
|
|
except ValueError: #NULL pointer access
|
|
ret = False
|
|
cjack.jack_client_close(ctypesJackClient)
|
|
if not args.verbose:
|
|
os.dup2(stdout, 1) #stdout
|
|
os.dup2(stderr, 2) #stderr
|
|
return ret
|
|
|
|
|
|
def checkJackOrExit(mute, prettyName):
|
|
import sys
|
|
if not mute and (not _is_jack_running()):
|
|
exitWithMessage("JACK Audio Connection Kit is not running. Please start it.")
|
|
|
|
from contextlib import contextmanager
|
|
@contextmanager
|
|
def profiler(*pargs, **kwds):
|
|
"""Eventhough this is a context manager we never get past the yield statement because our
|
|
program quits the moment we receive NSM quit signal. The only chance is to register a atexit
|
|
handler which will be called no matter what"""
|
|
if args.profiler:
|
|
import tracemalloc
|
|
import cProfile
|
|
import atexit
|
|
def profilerExit(pr):
|
|
snapshot = tracemalloc.take_snapshot()
|
|
top_stats = snapshot.statistics('lineno')
|
|
print("[ Top 10 ]")
|
|
for stat in top_stats[:10]:
|
|
print(stat)
|
|
|
|
from tempfile import NamedTemporaryFile
|
|
cprofPath = NamedTemporaryFile().name + ".cprof"
|
|
pr.dump_stats(cprofPath)
|
|
logger.info("{}: write profiling data to {}".format(METADATA["name"], cprofPath))
|
|
print (f"pyprof2calltree -k -i {cprofPath}")
|
|
|
|
pr = cProfile.Profile()
|
|
pr.enable()
|
|
#Closing and file writing happens in guiMainWindow._nsmQuit
|
|
tracemalloc.start()
|
|
atexit.register(lambda: profilerExit(pr))
|
|
|
|
#Program execution
|
|
yield
|
|
|
|
#Catch Exceptions even if PyQt crashes.
|
|
import sys
|
|
sys._excepthook = sys.excepthook # type: ignore #mypy thinks that doesn't exist, eventhough we set it ourselves right here.
|
|
def exception_hook(exctype, value, traceback):
|
|
"""This hook purely exists to call sys.exit(1) even on a Qt crash
|
|
so that atexit gets triggered"""
|
|
#print(exctype, value, traceback)
|
|
logger.error("Caught crash in execpthook. Trying too execute atexit anyway")
|
|
sys._excepthook(exctype, value, traceback)
|
|
sys.exit(1)
|
|
sys.excepthook = exception_hook
|
|
|
|
|
|
def startPseudoNSMServer(path):
|
|
from os import getenv
|
|
assert not getenv("NSM_URL")
|
|
from .qtgui.nsmsingleserver import startSingleNSMServer
|
|
startSingleNSMServer(path) #provides NSM_URL environment variable and a limited drop-in replacement for NSM that will only answer to our application
|
|
assert getenv("NSM_URL")
|
|
sys.path.append("sitepackages") # If you compiled but did not install you can still run with the local build of cbox in our temp dir sitepackages. Add path to the last place, in case there is an installed or bundled version
|
|
|
|
if args.directory:
|
|
#Switch to the mode without NSM.
|
|
startPseudoNSMServer(args.directory)
|
|
|
|
checkNsmOrExit(METADATA["name"])
|
|
checkJackOrExit(args.mute, METADATA["name"])
|
|
try:
|
|
#Only cosmetics
|
|
setProcessName(METADATA["shortName"])
|
|
except:
|
|
pass
|
|
|
|
if args.mute:
|
|
"""This uses the fact that imports are global and only triggered once.
|
|
Inside nullbox it will overwrite the actual cbox functions,
|
|
so that the rest of the program can naively import cbox directly.
|
|
We only need this one line to change between the two cbox modes.
|
|
|
|
There are four possible states:
|
|
1) run program locally, system wide calfbox
|
|
2) run program installed, calfbox bundled/installed
|
|
both work fine with a simple import calfbox.nullbox because
|
|
bundled has already changed the cbox/python search path
|
|
|
|
3) run program locally, calfbox in local tree
|
|
Needs adding of the local tree into python search path.
|
|
|
|
4) impossible: run program installed, calfbox in local tree
|
|
"""
|
|
import template.calfbox.nullbox
|
|
logger.info(f"Using {sys.modules['template.calfbox']} as calfbox/nullbox")
|
|
else:
|
|
import ctypes.util
|
|
libname = ctypes.util.find_library("calfbox-lss") #returns something like 'libcalfbox-lss.so.1' without a path, but with "lib"
|
|
if libname:
|
|
logger.info(f"libcalfbox-lss name is {libname}")
|
|
os.environ["CALFBOXLIBFILENAME"] = "calfbox-lss" #let _cbox2 use ctypes to find the name and path again. We just need to tell it that we are not the standard cbox file name.
|
|
else:
|
|
logger.error("libcalfbox-lss not installed on system")
|
|
raise RuntimeError("libcalfbox-lss not installed on system")
|
|
|
|
#Capture Ctlr+C / SIGINT and let @atexit handle the rest.
|
|
import signal
|
|
import sys
|
|
def signal_handler(sig, frame):
|
|
sys.exit(0) #atexit will trigger
|
|
signal.signal(signal.SIGINT, signal_handler)
|
|
|