summaryrefslogtreecommitdiff
path: root/lib/debug_utils.py
blob: ab2a6ca9ef1d2f24c1c55c7b27531b0e44a676ce (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
# 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
from pathlib import Path  # to work with paths as objects
import configparser       # to read DEBUG.ini

# this file is without: import inkex
# - we need dump argv and sys.path as is on startup from inkscape
#   - later sys.path may be modified that influences importing inkex (see prefere_pip_inkex)


def write_offline_debug_script(debug_script_dir : Path, ini : configparser.ConfigParser):
    '''
    prepare Bash script for offline debugging from console
        arguments:
        - debug_script_dir - Path object, absolute path to directory of inkstitch.py
        - ini       - see DEBUG.ini
    '''

    # define names of files used by offline Bash script
    bash_file_base = ini.get("DEBUG","bash_file_base", fallback="debug_inkstitch")
    bash_name = Path(bash_file_base).with_suffix(".sh")  # Path object
    bash_svg  = Path(bash_file_base).with_suffix(".svg") # Path object

    # check if input svg file exists in arguments, take argument that not start with '-' as file name
    svgs = [arg for arg in sys.argv[1:] if not arg.startswith('-')]
    if len(svgs) != 1:
        print(f"WARN: {len(svgs)} svg files found, expected 1, [{svgs}]. No script created in write debug script.", file=sys.stderr)
        return

    svg_file = Path(svgs[0])
    if svg_file.exists() and bash_svg.exists() and bash_svg.samefile(svg_file):
        print(f"WARN: input svg file is same as output svg file. No script created in write debug script.", file=sys.stderr)
        return
    
    import shutil  # to copy svg file
    bash_file = debug_script_dir / bash_name

    with open(bash_file, 'w') as f:  # "w" text mode, automatic conversion of \n to os.linesep
        f.write(f'#!/usr/bin/env bash\n')

        # cmd line arguments for debugging and profiling
        f.write(bash_parser())  # parse cmd line arguments: -d -p

        f.write(f'# python 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

        # environment PATH
        f.write(f'# PATH:\n')
        f.write(f'#   {os.environ["PATH"]}\n')
        # for p in os.environ.get("PATH", '').split(os.pathsep): # PATH to list
        #     f.write(f'#   {p}\n')

        # python module path
        f.write(f'# python sys.path:\n')
        for p in sys.path:
            f.write(f'#   {p}\n')

        # see static void set_extensions_env() in inkscape/src/inkscape-main.cpp
        f.write(f'# PYTHONPATH:\n')
        for p in os.environ.get('PYTHONPATH', '').split(os.pathsep): # PYTHONPATH to list
            f.write(f'#   {p}\n')

        f.write(f'# copy {svg_file} to {bash_svg}\n')
        shutil.copy(svg_file, debug_script_dir / bash_svg)  # copy file to bash_svg
        myargs = myargs.replace(str(svg_file), str(bash_svg))   # replace file name with bash_svg

        # see void Extension::set_environment() in inkscape/src/extension/extension.cpp
        notexported = ['SELF_CALL'] # if an extension calls inkscape itself
        exported = ['INKEX_GETTEXT_DOMAIN', 'INKEX_GETTEXT_DIRECTORY', 
                    'INKSCAPE_PROFILE_DIR', 'DOCUMENT_PATH', 'PYTHONPATH']
        for k in notexported:
            if k in os.environ:
                f.write(f'# export {k}="{os.environ[k]}"\n')
        for k in exported:
            if k in os.environ:
                f.write(f'export {k}="{os.environ[k]}"\n')

        f.write('# signal inkstitch.py that we are running from offline script\n')
        f.write(f'export INKSTITCH_OFFLINE_SCRIPT="True"\n')

        f.write('# call inkstitch\n')
        f.write(f'python3 inkstitch.py {myargs}\n')
    bash_file.chmod(0o0755)  # make file executable, hopefully ignored on Windows


def bash_parser():
    return '''
set -e   #  exit on error

# parse cmd line arguments:
#   -d enable debugging
#   -p enable profiling
#             ":..." - silent error reporting
while getopts ":dp" opt; do
  case $opt in
    d)
        arg_d="true"
        ;;
    p)
        arg_p="true"
        ;;
    \?)
        echo "Invalid option: -$OPTARG" >&2
        exit 1
        ;;
    :)
        echo "Option -$OPTARG requires an argument." >&2
        exit 1
        ;;
  esac
done

#     -v: check if variable is set
if [[ -v arg_d ]]; then
    export INKSTITCH_DEBUG_ENABLE="True"
fi
if [[ -v arg_p ]]; then
    export INKSTITCH_PROFILE_ENABLE="True"
fi

'''


def reorder_sys_path():
    '''
    change sys.path to prefer pip installed inkex over inkscape bundled inkex
    '''

    # see static void set_extensions_env() in inkscape/src/inkscape-main.cpp
    # what we do:
    # - 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')]
    # remove nonexisting paths
    pythonpath = [p for p in pythonpath if os.path.exists(p)]
    # add pythonpath to the end of sys.path
    sys.path.extend(pythonpath)

# -----------------------------------------------------------------------------
# Profilers:
# currently supported profilers:
# - cProfile - standard python profiler
# - profile  - standard python profiler
# - pyinstrument - profiler with nice html output
    

def profile(profile_type, profile_dir : Path, ini : configparser.ConfigParser, extension, remaining_args):
    '''
    profile with cProfile, profile or pyinstrument
    '''
    profile_file_base = ini.get("PROFILE","profile_file_base", fallback="debug_profile")
    profile_file_path = profile_dir / profile_file_base  # Path object

    if profile_type == 'cprofile':
        with_cprofile(extension, remaining_args, profile_file_path)
    elif profile_type == 'profile':
        with_profile(extension, remaining_args, profile_file_path)
    elif profile_type == 'pyinstrument':
        with_pyinstrument(extension, remaining_args, profile_file_path)
    else:
        raise ValueError(f"unknown profiler type: '{profile_type}'")

def with_cprofile(extension, remaining_args, profile_file_path):
    '''
    profile with cProfile
    '''
    import cProfile
    import pstats
    profiler = cProfile.Profile()

    profiler.enable()
    extension.run(args=remaining_args)
    profiler.disable()

    profiler.dump_stats(profile_file_path.with_suffix(".prof"))  # can be read by 'snakeviz -s' or 'pyprof2calltree'
    with open(profile_file_path, 'w') as stats_file:
        stats = pstats.Stats(profiler, stream=stats_file)
        stats.sort_stats(pstats.SortKey.CUMULATIVE)
        stats.print_stats()
    print(f"Profiler: cprofile, stats written to '{profile_file_path.name}' and '{profile_file_path.name}.prof'. Use snakeviz to see it.", 
          file=sys.stderr)

def with_profile(extension, remaining_args, profile_file_path):
    '''
    profile with profile
    '''
    import profile
    import pstats
    profiler = profile.Profile()

    profiler.run('extension.run(args=remaining_args)')

    profiler.dump_stats(profile_file_path.with_suffix(".prof"))  # can be read by 'snakeviz' or 'pyprof2calltree' - seems broken
    with open(profile_file_path, 'w') as stats_file:
        stats = pstats.Stats(profiler, stream=stats_file)
        stats.sort_stats(pstats.SortKey.CUMULATIVE)
        stats.print_stats()
    print(f"'Profiler: profile, stats written to '{profile_file_path.name}' and '{profile_file_path.name}.prof'. Use of snakeviz is broken.", 
          file=sys.stderr)

def with_pyinstrument(extension, remaining_args, profile_file_path):
    '''
    profile with pyinstrument
    '''
    import pyinstrument
    profiler = pyinstrument.Profiler()

    profiler.start()
    extension.run(args=remaining_args)
    profiler.stop()

    profile_file_path = profile_file_path.with_suffix(".html")
    with open(profile_file_path, 'w') as stats_file:
        stats_file.write(profiler.output_html())
    print(f"Profiler: pyinstrument, stats written to '{profile_file_path.name}'. Use browser to see it.", file=sys.stderr)