diff --git a/.gitignore b/.gitignore index a1d4a5e9d..2287a0562 100644 --- a/.gitignore +++ b/.gitignore @@ -21,8 +21,11 @@ locales/ .DS_Store /PROFILE /profile_stats +/profile_stats.html /profile_stats.prof /.vscode __pycache__ flaskserverport.json electron/yarn.lock +.ink.sh +.ink.svg diff --git a/inkstitch.py b/inkstitch.py index 91a0f18a6..e5ef3d7af 100644 --- a/inkstitch.py +++ b/inkstitch.py @@ -2,27 +2,82 @@ # # Copyright (c) 2010 Authors # Licensed under the GNU GPL version 3.0 or later. See the file LICENSE for details. -import cProfile -import pstats -import logging + import os import sys +import lib.debug_utils as debug_utils +from pathlib import Path + +SCRIPTDIR = Path(__file__).parent.absolute() + +if len(sys.argv) < 2: + exit(1) # no arguments - prevent uncidentally running this script + +running_as_frozen = getattr(sys, 'frozen', None) is not None # check if running from pyinstaller bundle +running_from_inkscape = '.ink.svg' not in sys.argv # inkscape never starts extension with .ink.svg file in args +# running_from_inkscape = True # for testing + +debug_active = bool((gettrace := getattr(sys, 'gettrace')) and gettrace()) # check if debugger is active on startup +debug_file = SCRIPTDIR / "DEBUG" +debug_type = 'none' + +profile_file = SCRIPTDIR / "PROFILE" +profile_type = 'none' + +# print(f"debug_type:'{debug_type}' profile_type:'{profile_type}'", file=sys.stderr) # for testing + +# if script was already started from debugger then don't read debug file +if not running_as_frozen and not debug_active and os.path.exists(debug_file): + debug_type = debug_utils.parse_file(debug_file) # read type of debugger from debug_file DEBUG + if debug_type == 'none': # for better backward compatibility + print(f"Debug file exists but no debugger type found in '{debug_file.name}'", file=sys.stderr) + +if os.path.exists(profile_file): + profile_type = debug_utils.parse_file(profile_file) # read type of profiler from profile_file PROFILE + if profile_type == 'none': # for better backward compatibility + print(f"Profile file exists but no profiler type found in '{profile_file.name}'", file=sys.stderr) + +if running_from_inkscape: + if debug_type.endswith('-script'): # if offline debugging just create script for later debugging + debug_utils.write_offline_debug_script(SCRIPTDIR) + debug_type = 'none' # do not start debugger when running from inkscape +else: # not running from inkscape + if debug_type.endswith('-script'): # remove '-script' to propely initialize debugger packages for each editor + debug_type = debug_type.replace('-script', '') + +if not running_as_frozen: + # When running in development mode, we prefer inkex installed by pip, not the one bundled with Inkscape. + # - move inkscape extensions path to the end of sys.path + # - we compare PYTHONPATH with sys.path and move PYTHONPATH to the end of sys.path + # - also user inkscape extensions path is moved to the end of sys.path - may cause problems? + # - path for deprecated-simple are removed from sys.path, will be added later by importing inkex + + # PYTHONPATH to list + pythonpath = os.environ.get('PYTHONPATH', '').split(os.pathsep) + # remove pythonpath from sys.path + sys.path = [p for p in sys.path if p not in pythonpath] + # remove deprecated-simple, it will be added later by importing inkex + pythonpath = [p for p in pythonpath if not p.endswith('deprecated-simple')] + # add pythonpath to the end of sys.path + sys.path.extend(pythonpath) + + # >> should be removed after previous code was tested << + # if sys.platform == "darwin": + # extensions_path = "/Applications/Inkscape.app/Contents/Resources/share/inkscape/extensions" # Mac + # else: + # extensions_path = "/usr/share/inkscape/extensions" # Linux + # # windows not solved + # move inkscape extensions path to the end of sys.path + # sys.path.remove(extensions_path) + # sys.path.append(extensions_path) + # >> ------------------------------------------------- << + +import logging from argparse import ArgumentParser from io import StringIO from lib.exceptions import InkstitchException, format_uncaught_exception -if getattr(sys, 'frozen', None) is None: - # When running in development mode, we want to use the inkex installed by - # pip install, not the one bundled with Inkscape which is not new enough. - if sys.platform == "darwin": - extensions_path = "/Applications/Inkscape.app/Contents/Resources/share/inkscape/extensions" - else: - extensions_path = "/usr/share/inkscape/extensions" - - sys.path.remove(extensions_path) - sys.path.append(extensions_path) - from inkex import errormsg from lxml.etree import XMLSyntaxError @@ -31,55 +86,95 @@ from lib import extensions from lib.i18n import _ from lib.utils import restore_stderr, save_stderr -# ignore warnings in releases -if getattr(sys, 'frozen', None): +# file DEBUG exists next to inkstitch.py - enabling debug mode depends on value of debug_type in DEBUG file +if debug_type != 'none': + debug.enable(debug_type) + # check if debugger is really activated + debug_active = bool((gettrace := getattr(sys, 'gettrace')) and gettrace()) + +# ignore warnings in releases - see warnings.warn() +if running_as_frozen or not debug_active: import warnings warnings.filterwarnings('ignore') -logger = logging.getLogger('shapely.geos') +# set logger for shapely +logger = logging.getLogger('shapely.geos') # attach logger of shapely, from ver 2.0.0 all logs are exceptions logger.setLevel(logging.DEBUG) -shapely_errors = StringIO() +shapely_errors = StringIO() # in memory file to store shapely errors ch = logging.StreamHandler(shapely_errors) ch.setLevel(logging.DEBUG) formatter = logging.Formatter('%(name)s - %(levelname)s - %(message)s') ch.setFormatter(formatter) logger.addHandler(ch) +# pop '--extension' from arguments and generate extension class name from extension name parser = ArgumentParser() parser.add_argument("--extension") my_args, remaining_args = parser.parse_known_args() -if os.path.exists(os.path.join(os.path.dirname(os.path.realpath(__file__)), "DEBUG")): - debug.enable() - -profiler = None -if os.path.exists(os.path.join(os.path.dirname(os.path.realpath(__file__)), "PROFILE")): - profiler = cProfile.Profile() - profiler.enable() - extension_name = my_args.extension # example: foo_bar_baz -> FooBarBaz extension_class_name = extension_name.title().replace("_", "") extension_class = getattr(extensions, extension_class_name) -extension = extension_class() +extension = extension_class() # create instance of extension class - call __init__ method -if (hasattr(sys, 'gettrace') and sys.gettrace()) or profiler is not None: - extension.run(args=remaining_args) - if profiler: - path = os.path.join(os.path.dirname(os.path.realpath(__file__)), "profile_stats") +# extension run(), but we differentiate between debug and normal mode +# - in debug or profile mode we run extension or profile extension +# - in normal mode we run extension in try/except block to catch all exceptions +if debug_active or profile_type != "none": # if debug or profile mode + print(f"Extension:'{extension_name}' Debug active:{debug_active} type:'{debug_type}' " + f"Profile type:'{profile_type}'", file=sys.stderr) + profile_path = SCRIPTDIR / "profile_stats" + + if profile_type == 'none': + extension.run(args=remaining_args) + elif profile_type == 'cprofile': + import cProfile + import pstats + profiler = cProfile.Profile() + + profiler.enable() + extension.run(args=remaining_args) profiler.disable() - profiler.dump_stats(path + ".prof") - with open(path, 'w') as stats_file: + profiler.dump_stats(profile_path.with_suffix(".prof")) # can be read by 'snakeviz -s' or 'pyprof2calltree' + with open(profile_path, 'w') as stats_file: stats = pstats.Stats(profiler, stream=stats_file) stats.sort_stats(pstats.SortKey.CUMULATIVE) stats.print_stats() + print(f"profiling stats written to '{profile_path.name}' and '{profile_path.name}.prof'", file=sys.stderr) - print(f"profiling stats written to {path} and {path}.prof", file=sys.stderr) -else: - save_stderr() + elif profile_type == 'profile': + import profile + import pstats + profiler = profile.Profile() + + profiler.run('extension.run(args=remaining_args)') + + profiler.dump_stats(profile_path.with_suffix(".prof")) # can be read by 'snakeviz' or 'pyprof2calltree' - seems broken + with open(profile_path, 'w') as stats_file: + stats = pstats.Stats(profiler, stream=stats_file) + stats.sort_stats(pstats.SortKey.CUMULATIVE) + stats.print_stats() + print(f"profiling stats written to '{profile_path.name}'", file=sys.stderr) + + elif profile_type == 'pyinstrument': + import pyinstrument + profiler = pyinstrument.Profiler() + + profiler.start() + extension.run(args=remaining_args) + profiler.stop() + + profile_path = SCRIPTDIR / "profile_stats.html" + with open(profile_path, 'w') as stats_file: + stats_file.write(profiler.output_html()) + print(f"profiling stats written to '{profile_path.name}'", file=sys.stderr) + +else: # if not debug nor profile mode + save_stderr() # hide GTK spam exception = None try: extension.run(args=remaining_args) diff --git a/lib/debug_utils.py b/lib/debug_utils.py new file mode 100644 index 000000000..6555b0830 --- /dev/null +++ b/lib/debug_utils.py @@ -0,0 +1,84 @@ +# Authors: see git history +# +# Copyright (c) 2010 Authors +# Licensed under the GNU GPL version 3.0 or later. See the file LICENSE for details. + +import os +import sys + +# DEBUG file format: +# - first non-comment line is debugger type +# - valid values are: +# "vscode" or "vscode-script" - for debugging with vscode +# "pycharm" or "pycharm-script" - for debugging with pycharm +# "pydev" or "pydev-script" - for debugging with pydev +# "none" or empty file - for no debugging +# - for offline debugging without inkscape, set debugger name to +# as "vscode-script" or "pycharm-script" or "pydev-script" +# - in that case running from inkscape will not start debugger +# but prepare script for offline debugging from console +# - backward compatibilty is broken due to confusion +# debug_type = 'pydev' # default debugger backwards compatibility +# if 'PYCHARM_REMOTE_DEBUG' in os.environ: # backwards compatibility +# debug_type = 'pycharm' + +# PROFILE file format: +# - first non-comment line is profiler type +# - valid values are: +# "cprofile" - for cProfile +# "pyinstrument" - for pyinstrument +# "profile" - for profile +# "none" - for no profiling + + +def parse_file(filename): + # parse DEBUG or PROFILE file for type + # - return first noncomment and nonempty line from file + value_type = 'none' + with open(filename, 'r') as f: + for line in f: + line = line.strip().lower() + if line.startswith("#") or line == "": # skip comments and empty lines + continue + value_type = line # first non-comment line is type + break + return value_type + +def write_offline_debug_script(SCRIPTDIR): + # prepare script for offline debugging from console + # - only tested on linux + import shutil + ink_file = os.path.join(SCRIPTDIR, ".ink.sh") + with open(ink_file, 'w') as f: + f.write(f"#!/usr/bin/env bash\n\n") + f.write(f"# version: {sys.version}\n") # python version + + myargs = " ".join(sys.argv[1:]) + f.write(f'# script: {sys.argv[0]} arguments: {myargs}\n') # script name and arguments + + # python module path + f.write(f"# python sys.path:\n") + for p in sys.path: + f.write(f"# {p}\n") + + # print PYTHONPATH one per line + f.write(f"# PYTHONPATH:\n") + for p in os.environ.get('PYTHONPATH', '').split(os.pathsep): + f.write(f"# {p}\n") + + # take argument that not start with '-' as file name + svg_file = " ".join([arg for arg in sys.argv[1:] if not arg.startswith('-')]) + f.write(f"# copy {svg_file} to .ink.svg\n") + # check if filer are not the same + if svg_file != '.ink.svg': + shutil.copy(svg_file, f'{SCRIPTDIR}/.ink.svg') # copy file to .ink.svg + myargs = myargs.replace(svg_file, '.ink.svg') # replace file name with .ink.svg + + # export INK*|PYTHON* environment variables + for k, v in sorted(os.environ.items()): + if k.startswith('INK') or k.startswith('PYTHON'): + f.write(f'export {k}="{v}"\n') + + # f.write(f"# python3 -m debugpy --listen 5678 --wait-for-client inkstitch.py {myargs}\n") + f.write(f"python3 inkstitch.py {myargs}\n") + os.chmod(ink_file, 0o0755) # make file executable