├── .gitignore ├── AUTHORS.txt ├── CHANGELOG ├── CONTRIBUTING.rst ├── INSTALL.rst ├── LICENSE.txt ├── README.rst ├── call_map ├── __init__.py ├── __main__.py ├── cache.py ├── config.py ├── core.py ├── custom_typing.py ├── errors.py ├── gui.py ├── icons │ ├── cruciform-by-andylee.png │ └── statement-of-permission.txt ├── jedi_alt │ ├── AUTHORS.txt │ ├── JEDI_LICENSE.txt │ ├── __init__.py │ ├── api_usages.py │ ├── monkey_patch_evaluate_compiled.py │ ├── stop_signal.py │ └── usages.py ├── jedi_ast_tools.py ├── jedi_dump.py ├── package_info.json ├── project_settings_module.py ├── qt_compatibility.py ├── serialize.py └── wheel_fix.py ├── dev_helper_tools └── shell_tools.zsh ├── docs └── UI-clicked-on-function.png ├── gpl-3.0.txt ├── optional-requirements.txt ├── setup.py └── tests ├── load_test_modules.py ├── test_cache.py ├── test_jedi_ast_tools.py ├── test_jedi_dump.py ├── test_modules ├── simple_test_package │ ├── __init__.py │ ├── aa.py │ └── bb.py ├── test_package_sibling_usage │ ├── __init__.py │ ├── subpackage │ │ ├── __init__.py │ │ └── test_sibling_usage.py │ └── test_sibling_used.py ├── use_comprehension.py └── use_decorators.py ├── test_project.py └── test_ui.py /.gitignore: -------------------------------------------------------------------------------- 1 | *.egg-info 2 | __pycache__ 3 | .cm_proj 4 | local-meta/ 5 | -------------------------------------------------------------------------------- /AUTHORS.txt: -------------------------------------------------------------------------------- 1 | # Call Map authors 2 | 3 | Andrew J. Lee @andylee-ncc, @andylee-work, @ajylee 4 | 5 | 6 | # Jedi authors 7 | 8 | See call_map/jedi_alt/AUTHORS.txt 9 | -------------------------------------------------------------------------------- /CHANGELOG: -------------------------------------------------------------------------------- 1 | # v0.6.0 -- 2017-05-04 2 | 3 | Make Call Map compatible with official Jedi v0.10.2 release. 4 | 5 | # v0.5.0 6 | 7 | Make Call Map compatible with official Jedi v0.10.0 release. Previously it 8 | depended on a custom fork. 9 | -------------------------------------------------------------------------------- /CONTRIBUTING.rst: -------------------------------------------------------------------------------- 1 | Licensing/copyright issues 2 | ========================== 3 | 4 | Note that I (Andy Lee) am planning to make a release of Call Map compatible 5 | with PySide2, licensed under BSD 2-clause license. To contribute code to the 6 | Call Map program, you must agree to allow your contribution to be licensed 7 | under GPL-v3, and also BSD 2-clause license as part of any future release the 8 | Call Map project that is PySide2-compatible. 9 | 10 | Call Map is copyrighted to its developers, so that will include you if you 11 | contribute. 12 | -------------------------------------------------------------------------------- /INSTALL.rst: -------------------------------------------------------------------------------- 1 | ============ 2 | Installation 3 | ============ 4 | 5 | .. default-role:: code 6 | 7 | 8 | Standard Installation with Pip 9 | ------------------------------- 10 | 11 | Call Map is distributed as a Python package. It requires `Python 3.5`, 12 | `pip>=8.1.2`, and `jedi==0.10.0` [*]_. Other requirements can be installed 13 | automatically once you have the correct versions of `pip` and `jedi`. 14 | (If you don't have `Python 3.5`, see `Getting Python 3.5`_.) 15 | 16 | Before installing for the first time, consider `Using Virtualenv`_. 17 | To install `call_map`, from the top level directory of `call_map`, run:: 18 | 19 | pip3 install --upgrade pip # make sure pip is >=8.1.2 20 | pip3 install --no-cache-dir -e . # caching sometimes breaks 21 | 22 | Note that some Python 3 distributions call the package manager `pip`, and some 23 | call it `pip3`. 24 | 25 | I have included instructions using `conda`, `apt`, and `brew` if you prefer 26 | obtaining as many depencencies as possible using those package managers. I also 27 | include instructions for obtaining `Python 3.5` with `apt` and `brew`. 28 | 29 | More details about `PyQt5` specifically can be found at `the Riverbank download 30 | page`__ (Riverbank is the developer of `PyQt5`). 31 | 32 | __ https://www.riverbankcomputing.com/software/pyqt/download5 33 | 34 | .. [*] A small part of `call_map` relies on internal structures of `jedi`, which 35 | may change in future releases. I plan to make `call_map` rely less on 36 | `jedi` internals in future `call_map` releases. 37 | 38 | 39 | Using Virtualenv 40 | ----------------- 41 | 42 | Setting up virtual environment is optional but recommended. Virtualenv makes it 43 | easy to start over if things break, by deleting the virtualenv directory. To 44 | set it up, run:: 45 | 46 | python -m venv --system-site-packages $HOME/py3env 47 | export PATH=$HOME/py3env/bin:$PATH 48 | 49 | # update command hash table in bash/zsh 50 | hash pip3 51 | hash python 52 | 53 | 54 | Getting Python 3.5 55 | ------------------- 56 | 57 | You can obtain the `official release`__ or use a package manager. Specific 58 | instructions for Mac OS and Ubuntu are included below. 59 | 60 | __ https://www.python.org/downloads/release/python-352/ 61 | 62 | Mac OS 63 | ~~~~~~~ 64 | 65 | You can use `brew`:: 66 | 67 | brew install python3 68 | 69 | You can also use `brew` to compile PyQt5:: 70 | 71 | brew install --with-python3 pyqt5 72 | 73 | 74 | Ubuntu 75 | ~~~~~~~ 76 | 77 | You can use `apt` to install `Python 3.5` and `PyQt5`:: 78 | 79 | sudo apt install python3-pyqt5 virtualenv 80 | 81 | 82 | Using Conda 83 | ------------ 84 | 85 | You can use `conda` to install `pyqt` and `qtconsole`, then `pip` to install 86 | `jedi` and `call_map`:: 87 | 88 | pip install conda 89 | hash conda 90 | conda install pygments 91 | conda install pyqt 92 | conda install qtconsole 93 | conda install toolz 94 | 95 | Now follow the `pip` installation instructions in `Standard Installation with 96 | Pip`_. `pip` will not reinstall the packages you installed with conda. 97 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | # Jedi License Terms 2 | 3 | Some code from the Jedi project is included in modified form in 4 | 5 | - call_map/jedi_alt/api_usages.py 6 | - call_map/jedi_alt/usages.py 7 | 8 | This code is dual licensed under the terms of the original Jedi license and the 9 | Call Map Program License Terms. 10 | 11 | Jedi is Copyright (C) David Halter and others, see call_map/jedi_alt/AUTHORS.txt. 12 | Also see call_map/jedi_alt/JEDI_LICENSE.txt for the original Jedi license. 13 | 14 | 15 | # Call Map Program License Terms 16 | 17 | Copyright (C) 2016-2017 Call Map developers 18 | 19 | In the following, "this program" refers to the source code and associated 20 | documentation files. "This program" does not include the icons. 21 | 22 | --- 23 | 24 | 25 | This program is free software: you can redistribute it and/or modify 26 | it under the terms of the GNU General Public License as published by 27 | the Free Software Foundation, either version 3 of the License, or 28 | (at your option) any later version. 29 | 30 | This program is distributed in the hope that it will be useful, 31 | but WITHOUT ANY WARRANTY; without even the implied warranty of 32 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 33 | GNU General Public License for more details. 34 | 35 | You should have received a copy of the GNU General Public License 36 | along with this program. If not, see . 37 | 38 | --- 39 | 40 | # Image Assets License 41 | 42 | The image assets included in this repository are copyright Andrew J. Lee 43 | (C) and separately licensed, as documented in 44 | call_map/icons/statement-of-permission.txt. 45 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | ========== 2 | Call Map 3 | ========== 4 | 5 | 6 | .. default-role:: code 7 | 8 | About 9 | ====== 10 | 11 | Call Map is a tool for navigating call graphs in Python, with plans to support 12 | other languages. Below is a screen shot after running `call_map -m toolz`, then 13 | clicking on some functions. 14 | 15 | .. figure:: docs/UI-clicked-on-function.png 16 | 17 | While Call Map is intended to help in gaining a general 18 | understanding of a codebase, it is also a natural fit for tracing code paths, 19 | which constitutes an important security concern. Many security issues are 20 | revealed by finding a code path that connects user input to dangerous coding 21 | patterns. 22 | 23 | See the `blog post`__ for more. 24 | 25 | __ https://www.nccgroup.trust/us/about-us/newsroom-and-events/blog/2017/july/call-map-a-tool-for-navigating-call-graphs-in-python/ 26 | 27 | 28 | Installation 29 | ============= 30 | 31 | Call Map is distributed as a python package for `Python 3.5`. To install with 32 | `pip3`, run:: 33 | 34 | # From top level directory of call_map 35 | pip3 install -e . 36 | 37 | Note that some Python 3 distributions call the package manager `pip`, and some 38 | call it `pip3`, so you may need substitute `pip3` with `pip`. 39 | 40 | The above commands should install the executable `call_map` to the same path 41 | that the Python interpreter is in. 42 | 43 | For details and alternative methods see `INSTALL.rst`. 44 | 45 | 46 | Quick Start 47 | ============ 48 | 49 | Open files to start exploring:: 50 | 51 | call_map -f example.py 52 | 53 | # you can also open multiple files 54 | call_map -f *.py 55 | 56 | 57 | You can also add to the module search path (`sys.path`):: 58 | 59 | call_map -f *.py -p . 60 | 61 | Call Map will try to resolve the files as modules whenever they can be found in 62 | the module search path. For more documentation on command line 63 | arguments, `call_map -h`. 64 | 65 | 66 | Configuration 67 | ============= 68 | 69 | To configure Call Map, set the environment variable CALL_MAP_RC_DIRECTORY. 70 | The path to the Call Map configuration file will be:: 71 | 72 | $CALL_MAP_RC_DIRECTORY/call_map_rc.py 73 | 74 | At this time the configuration options are: 75 | 76 | - `open_in_editor(path: pathlib.Path, line: int)`: if you define this function 77 | it will be called whenever you open a file. For example, the following can be 78 | used to open files in an Emacs server if have you called `server-mode` in a 79 | running Emacs session:: 80 | 81 | def open_in_editor(path, line): 82 | import subprocess as sbp 83 | sbp.call(['emacsclient', '+{}'.format(line), str(path)]) 84 | 85 | The following can be used to open files in the GVim server if you started a GVim 86 | server with `gvim --servername my_vim_server`:: 87 | 88 | def open_in_editor(path, line): 89 | import subprocess as sbp 90 | sbp.call(['gvim', '--servername', 'my_vim_server', '--remote', '+{}'.format(line), str(path)]) 91 | 92 | 93 | - `MULTITHREADING`: Whether to use a separate thread for the GUI and searching 94 | the call graph. Defaults to `True`. Turning it off is for debug purposes. 95 | 96 | 97 | Quirks 98 | ======= 99 | 100 | There are a couple of quirks in the UI design, due to the fact that I haven't 101 | arrived at a better solution or the tradeoff of additional complexity is 102 | unfavorable. 103 | 104 | - Usages can appear to show up more than once, but actually they are different 105 | usages in the same scope. 106 | 107 | - You may notice some bulitins such as `help`, `id`, and `filter` are ignored. 108 | See `call_map/config.py` in the source code for the full list of ignored 109 | functions. 110 | 111 | - If the position to be highlighted is at the start of a file, it won't be 112 | highlighted. This is because typically only modules and scripts are positioned 113 | at the start of their respective files. 114 | 115 | 116 | Quirks Inherited from Jedi 117 | --------------------------- 118 | 119 | Some quirks are inherited from the `jedi` Python analysis backend. 120 | 121 | - The search scope for usages is the set of modules that have been loaded by the 122 | `jedi` backend. That means that the scope will change as you explore new 123 | modules. If you want to explicitly include a module in the search scope, add 124 | it to the initial list of modules to be inspected. 125 | 126 | - `jedi` always searches the interpreter's `sys.path`, even when it is not 127 | explicitly included. However, it will prioritize the user-defined `sys_path`. 128 | 129 | 130 | Caveats 131 | ======== 132 | 133 | Any caveats that exist for static analysis backends also apply to Call Map. (At 134 | this time only the `jedi` backend is integrated into Call Map.) 135 | 136 | 137 | Jedi 138 | ----- 139 | 140 | The `jedi` backend for Python analysis does not always find all usages. The 141 | dynamic nature of Python makes it impossible to always determine the definition 142 | of a function with static analysis. Sometimes `jedi` resolves a call to multiple 143 | possible functions. For example `os.path.abspath` depends on the platform. In 144 | this case `call_map` lists both possibilities as `abspath` and `abspath (2)`. 145 | `jedi` also does not resolve all calls and usages. Jedi's own documentation also 146 | has a `list of caveats`__. 147 | 148 | Sometimes when Jedi throws an error when analyzing one item, the error affects 149 | other items. For example, when finding usages, if Jedi raises an error on one 150 | usage, the other usages it has found may be unrecoverable (as of Jedi v0.10.0). 151 | 152 | __ https://jedi.readthedocs.io/en/latest/docs/features.html#caveats 153 | -------------------------------------------------------------------------------- /call_map/__init__.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import json as _json 3 | from pathlib import Path as _Path 4 | 5 | logging.getLogger(__name__).addHandler(logging.NullHandler()) 6 | 7 | _package_info = ( 8 | _json.loads(_Path(__file__).parent.joinpath('package_info.json').read_text())) 9 | 10 | version = _package_info['version'] 11 | -------------------------------------------------------------------------------- /call_map/__main__.py: -------------------------------------------------------------------------------- 1 | 2 | from .gui import main 3 | 4 | if __name__ == '__main__': 5 | main() 6 | 7 | try: 8 | __IPYTHON__ 9 | except NameError: 10 | pass 11 | else: 12 | from .gui import Debug 13 | from sys import modules 14 | ui_toplevel = modules['call_map_ui_toplevel'] 15 | -------------------------------------------------------------------------------- /call_map/cache.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | TEXT_CACHE_SIZE = 100 4 | _text_cache = {} 5 | _text_cache_rankings = {} 6 | 7 | def read_text_cached(path: Path) -> str: 8 | try: 9 | text = _text_cache[path] 10 | hole = _text_cache_rankings[path] 11 | eliminate = True 12 | except KeyError: 13 | text = path.read_text() 14 | _text_cache[path] = text 15 | hole = TEXT_CACHE_SIZE 16 | eliminate = False 17 | 18 | _text_cache_rankings[path] = 0 19 | 20 | for key, ranking in list(_text_cache_rankings.items()): 21 | if key == path: 22 | continue 23 | 24 | if ranking < hole: 25 | new_ranking = ranking + 1 26 | if new_ranking < TEXT_CACHE_SIZE: 27 | _text_cache_rankings[key] = new_ranking 28 | else: 29 | _text_cache.pop(key) 30 | _text_cache_rankings.pop(key) 31 | 32 | #print('-'*10, list(_text_cache_rankings.values()), len(_text_cache), len(_text_cache_rankings)) 33 | 34 | return text -------------------------------------------------------------------------------- /call_map/config.py: -------------------------------------------------------------------------------- 1 | import os 2 | from pathlib import Path 3 | from .qt_compatibility import QtCore 4 | from typing import Tuple, Optional 5 | import toolz as tz 6 | 7 | # ignore 8 | 9 | py_ignore = {'builtins': frozenset(['ArithmeticError', 10 | 'AssertionError', 11 | 'AttributeError', 12 | 'BaseException', 13 | 'BlockingIOError', 14 | 'BrokenPipeError', 15 | 'BufferError', 16 | 'BytesWarning', 17 | 'ChildProcessError', 18 | 'ConnectionAbortedError', 19 | 'ConnectionError', 20 | 'ConnectionRefusedError', 21 | 'ConnectionResetError', 22 | 'DeprecationWarning', 23 | 'EOFError', 24 | 'Ellipsis', 25 | 'EnvironmentError', 26 | 'Exception', 27 | 'False', 28 | 'FileExistsError', 29 | 'FileNotFoundError', 30 | 'FloatingPointError', 31 | 'FutureWarning', 32 | 'GeneratorExit', 33 | 'IOError', 34 | 'ImportError', 35 | 'ImportWarning', 36 | 'IndentationError', 37 | 'IndexError', 38 | 'InterruptedError', 39 | 'IsADirectoryError', 40 | 'KeyError', 41 | 'KeyboardInterrupt', 42 | 'LookupError', 43 | 'MemoryError', 44 | 'NameError', 45 | 'None', 46 | 'NotADirectoryError', 47 | 'NotImplemented', 48 | 'NotImplementedError', 49 | 'OSError', 50 | 'OverflowError', 51 | 'PendingDeprecationWarning', 52 | 'PermissionError', 53 | 'ProcessLookupError', 54 | 'RecursionError', 55 | 'ReferenceError', 56 | 'ResourceWarning', 57 | 'RuntimeError', 58 | 'RuntimeWarning', 59 | 'StopAsyncIteration', 60 | 'StopIteration', 61 | 'SyntaxError', 62 | 'SyntaxWarning', 63 | 'SystemError', 64 | 'SystemExit', 65 | 'TabError', 66 | 'TimeoutError', 67 | 'True', 68 | 'TypeError', 69 | 'UnboundLocalError', 70 | 'UnicodeDecodeError', 71 | 'UnicodeEncodeError', 72 | 'UnicodeError', 73 | 'UnicodeTranslateError', 74 | 'UnicodeWarning', 75 | 'UserWarning', 76 | 'ValueError', 77 | 'Warning', 78 | 'ZeroDivisionError', 79 | '__IPYTHON__', 80 | '__build_class__', 81 | '__debug__', 82 | '__doc__', 83 | '__import__', 84 | '__loader__', 85 | '__name__', 86 | '__package__', 87 | '__spec__', 88 | 'abs', 89 | 'all', 90 | 'any', 91 | 'ascii', 92 | 'bin', 93 | 'bool', 94 | #'bytearray', 95 | #'bytes', 96 | 'callable', 97 | 'chr', 98 | 'classmethod', 99 | #'compile', 100 | 'complex', 101 | 'copyright', 102 | 'credits', 103 | #'delattr', 104 | 'dict', 105 | 'dir', 106 | #'divmod', 107 | #'dreload', 108 | 'enumerate', 109 | #'eval', 110 | #'exec', 111 | 'filter', 112 | 'float', 113 | #'format', 114 | 'frozenset', 115 | #'get_ipython', 116 | #'getattr', 117 | #'globals', 118 | #'hasattr', 119 | 'hash', 120 | 'help', 121 | 'hex', 122 | 'id', 123 | 'input', 124 | 'int', 125 | 'isinstance', 126 | 'issubclass', 127 | 'iter', 128 | 'len', 129 | 'license', 130 | 'list', 131 | #'locals', 132 | 'map', 133 | 'max', 134 | #'memoryview', 135 | 'min', 136 | 'next', 137 | 'object', 138 | 'oct', 139 | #'open', 140 | 'ord', 141 | 'pow', 142 | 'print', 143 | 'property', 144 | 'range', 145 | 'repr', 146 | 'reversed', 147 | 'round', 148 | 'set', 149 | 'setattr', 150 | 'slice', 151 | 'sorted', 152 | 'staticmethod', 153 | 'str', 154 | 'sum', 155 | 'super', 156 | 'tuple', 157 | 'type', 158 | #'vars', 159 | 'zip' 160 | ])} 161 | 162 | 163 | 164 | def default_open_in_editor(path, line): 165 | import sys 166 | import logging 167 | import subprocess as sbp 168 | 169 | if sys.platform == 'darwin': 170 | sbp.call(['open', '--', path]) 171 | else: 172 | logging.getLogger(__name__).warning( 173 | ' Need to define open_in_editor in call_map_rc.py') 174 | 175 | 176 | class UserConfig: 177 | """ 178 | Manages user config 179 | 180 | The config file at `$CALL_MAP_RC_DIRECTORY/call_map_rc.py` is watched. If 181 | the config file is changed, it will be re-read the next time `get_config` is 182 | called. 183 | 184 | There are three priority levels for settings: defaults, settings from the 185 | config file, and the override settings. The overrides are for testing. The 186 | user also may be allowed to change the overrides mid-session in the future. 187 | 188 | The main reason UserConfig is a class, and not just a collection of 189 | variables and functions, is for testability. 190 | 191 | """ 192 | 193 | default_user_config = {'open_in_editor': default_open_in_editor, 194 | 'MULTITHREADING': True, 195 | 'UNICODE_ROLE_MARKERS': True, 196 | 'EXC_INFO': False, 197 | 'EXPERIMENTAL_MODE': False, 198 | 'LOG_LEVEL': None, # needs restart to take effect 199 | 'PROFILING': False} # needs restart to take effect 200 | 201 | def __init__(self, rc_dir: Optional[str]): 202 | self.rc_dir = rc_dir 203 | 204 | self._cache = None # caches the settings 205 | 206 | self.watcher = QtCore.QFileSystemWatcher() 207 | self.watcher.fileChanged.connect(self.clear_cache) 208 | self.start_watcher() 209 | 210 | # top priority configuration settings. Used for debugging. 211 | self.session_overrides = {} 212 | 213 | def clear_cache(self, file_names: Tuple[str]): 214 | self._cache = None 215 | 216 | def start_watcher(self): 217 | if self.rc_dir and os.path.isdir(self.rc_dir): 218 | self.watcher.addPath(os.path.join(self.rc_dir, 'call_map_rc.py')) 219 | 220 | def read_user_config(self): 221 | """Read the configuration from file""" 222 | from sys import path as sys_path 223 | 224 | if self.rc_dir and os.path.isdir(self.rc_dir): 225 | text = Path(self.rc_dir).joinpath('call_map_rc.py').read_text() 226 | 227 | _namespace = {} 228 | 229 | exec(text, _namespace) 230 | 231 | result = {} 232 | 233 | for key in self.default_user_config: 234 | try: 235 | result[key] = _namespace[key] 236 | except KeyError: 237 | continue 238 | 239 | return result 240 | else: 241 | return {} 242 | 243 | def get_config(self): 244 | if self._cache is None: 245 | read_result = self.read_user_config() 246 | return tz.merge(self.default_user_config, 247 | read_result, 248 | self.session_overrides) 249 | else: 250 | return self._cache 251 | 252 | 253 | user_config = UserConfig(rc_dir=os.getenv('CALL_MAP_RC_DIRECTORY')) 254 | get_user_config = user_config.get_config 255 | -------------------------------------------------------------------------------- /call_map/core.py: -------------------------------------------------------------------------------- 1 | import typing 2 | import collections 3 | import abc 4 | 5 | from typing import List, Tuple, Optional 6 | from pathlib import Path 7 | 8 | 9 | LocationType = Tuple[Optional[int], Optional[int]] 10 | CallPosType = Tuple[Optional[str], LocationType, LocationType] 11 | 12 | CodeElement = typing.NamedTuple( 13 | 'CodeElement', [('name', str), 14 | ('type', str), # not sure 15 | ('module', str), 16 | ('role', str), 17 | ('path', Optional[str]), 18 | ('call_pos', CallPosType), 19 | ('start_pos', LocationType), 20 | ('end_pos', LocationType)]) 21 | 22 | 23 | class Node(metaclass=abc.ABCMeta): 24 | """Every node must have attributes `name` and `role` 25 | 26 | The Node is responsible for catching all exceptions arising from the 27 | analysis backend when searching for connected nodes. 28 | 29 | """ 30 | 31 | @property 32 | @abc.abstractmethod 33 | def parents(self): 34 | pass 35 | 36 | @property 37 | @abc.abstractmethod 38 | def children(self): 39 | pass 40 | 41 | @property 42 | @abc.abstractmethod 43 | def cancel_search(self): 44 | '''Cancel pending search for related nodes''' 45 | pass 46 | 47 | 48 | class OrganizerNode(Node): 49 | def __init__(self, name, parents=None, children=None): 50 | """The parents call the node, children are called by the node. 51 | 52 | If not callable, the Node has no children 53 | """ 54 | self._parents = parents if parents is not None else [] 55 | self._children = children if children is not None else [] 56 | self.code_element = CodeElement(name=name, 57 | type='organizer', 58 | module='[None]', 59 | role='definition', 60 | path=None, 61 | call_pos=(None, (None,None), (None,None)), 62 | start_pos = (None, None), 63 | end_pos = (None, None)) 64 | 65 | @property 66 | def parents(self): 67 | return self._parents 68 | 69 | @property 70 | def children(self): 71 | return self._children 72 | 73 | def cancel_search(self): 74 | pass 75 | 76 | 77 | UserScopeSettings = typing.NamedTuple('UserScopeSettings', [('module_names', List[str]), 78 | ('file_names', List[Path]), 79 | ('include_runtime_sys_path', bool), 80 | ('add_to_sys_path', List[Path])]) 81 | 82 | ScopeSettings = typing.NamedTuple('ScopeSettings', [('module_names', List[str]), 83 | ('scripts', List[Path]), 84 | ('effective_sys_path', List[Path])]) 85 | -------------------------------------------------------------------------------- /call_map/custom_typing.py: -------------------------------------------------------------------------------- 1 | import toolz as tz 2 | import typing 3 | import abc 4 | 5 | 6 | class TypeSpec(metaclass=abc.ABCMeta): 7 | '''Used to classify objects, but is not an actual Python type 8 | 9 | Instead of `isinstance`, use `matches_spec` with `TypeSpec`s. Does not implement any 10 | other interface. 11 | 12 | For example, this is useful for specifying a list of strings. `TypeSpec`s 13 | differ from type hints, in that they can easily be checked. 14 | 15 | ''' 16 | 17 | @abc.abstractmethod 18 | def __matches_spec__(self, obj): 19 | pass 20 | 21 | 22 | def matches_spec(obj: typing.Any, type_spec: typing.Union[type, TypeSpec, typing.Iterable]): 23 | if isinstance(type_spec, type): 24 | return isinstance(obj, type_spec) 25 | elif isinstance(type_spec, TypeSpec): 26 | return type_spec.__matches_spec__(obj) 27 | elif tz.isiterable(type_spec): 28 | return any(matches_spec(obj, elt) for elt in type_spec) 29 | 30 | 31 | class CheckableOptional(TypeSpec): 32 | def __init__(self, arg): 33 | self.nontrivial_type = arg 34 | 35 | def __repr__(self): 36 | return ''.format(repr(self.nontrivial_type)) 37 | 38 | def __matches_spec__(self, obj): 39 | return matches_spec(obj, (type(None), self.nontrivial_type)) 40 | 41 | 42 | class CheckableDict(TypeSpec): 43 | def __init__(self, types: dict): 44 | self.value_types = types 45 | 46 | def __repr__(self): 47 | return ''.format(repr(self.value_types)) 48 | 49 | def __matches_spec__(self, obj): 50 | return ( 51 | isinstance(obj, dict) 52 | and set(self.value_types.keys()) == set(obj.keys()) 53 | and all(matches_spec(obj[key], val_type) 54 | for key, val_type in self.value_types.items())) 55 | 56 | def new_empty_instance(self): 57 | return {key: None for key in self.value_types} 58 | 59 | 60 | class CheckableList(TypeSpec): 61 | def __init__(self, value_type): 62 | self.value_type = value_type 63 | 64 | def __repr__(self): 65 | return ''.format(repr(self.value_type)) 66 | 67 | def __matches_spec__(self, obj): 68 | return (isinstance(obj, list) 69 | and all(matches_spec(elt, self.value_type) for elt in obj)) 70 | 71 | def new_empty_instance(self): 72 | return list() 73 | 74 | 75 | class CheckableTuple(TypeSpec): 76 | def __init__(self, *value_types): 77 | self.value_types = value_types 78 | 79 | def __repr__(self): 80 | return ''.format(repr(self.value_types)) 81 | 82 | def __matches_spec__(self, obj): 83 | return (isinstance(obj, tuple) 84 | and all(matches_spec(elt, self.value_type) for elt in zip(obj, self.value_types))) 85 | -------------------------------------------------------------------------------- /call_map/errors.py: -------------------------------------------------------------------------------- 1 | 2 | class BadArgsError(Exception): 3 | pass 4 | 5 | class ModuleResolutionError(Exception): 6 | pass 7 | 8 | class ScriptResolutionError(Exception): 9 | pass 10 | -------------------------------------------------------------------------------- /call_map/gui.py: -------------------------------------------------------------------------------- 1 | from .qt_compatibility import QtCore, QtGui, QtWidgets, Qt 2 | import re 3 | import threading 4 | import json 5 | import logging 6 | from typing import List, Tuple, Optional, Iterable 7 | from concurrent.futures import Executor, ThreadPoolExecutor, wait, Future 8 | from sys import modules as runtime_sys_modules, argv as sys_argv, platform as sys_platform, version_info as sys_version_info 9 | 10 | import toolz as tz 11 | 12 | import qtconsole.pygments_highlighter 13 | from pygments.lexers import PythonLexer 14 | 15 | from types import ModuleType 16 | from pathlib import Path 17 | 18 | #from . import wheel_fix 19 | from .core import OrganizerNode as ONode, Node 20 | from .config import get_user_config 21 | from .cache import read_text_cached 22 | 23 | from .core import UserScopeSettings, ScopeSettings, OrganizerNode, CodeElement 24 | from .errors import BadArgsError, ModuleResolutionError, ScriptResolutionError 25 | from .project_settings_module import Project 26 | 27 | from . import serialize 28 | from . import project_settings_module 29 | 30 | logger = logging.getLogger(__name__) 31 | 32 | COLUMN_WIDTH = 200 33 | 34 | def make_module(name): 35 | module = ModuleType(name) 36 | runtime_sys_modules[name] = module 37 | return module 38 | 39 | executors = make_module('call_map_executors') 40 | Debug = make_module('call_map_debug') 41 | 42 | 43 | def code_font(): 44 | _code_font = QtGui.QFont('Monaco') 45 | _code_font.setStyleHint(QtGui.QFont.Monospace) 46 | _code_font.setPointSize(11) 47 | return _code_font 48 | 49 | 50 | def maybe_first(iterable): 51 | for ii in iterable: 52 | return ii 53 | else: 54 | return None 55 | 56 | 57 | def findParent(qobj, type_): 58 | while type(qobj) is not type_: 59 | qobj = qobj.parent() 60 | 61 | return qobj 62 | 63 | 64 | class CallListItem(QtWidgets.QListWidgetItem): 65 | default_role_markers = {'child': ' ', 'definition': '.', 'parent': '<', 'signature': '-'} 66 | role_markers = default_role_markers.copy() 67 | type_markers = {'module': 'm:', 'class': 'c:', 'script': 's:'} 68 | type_background_colors = { 69 | #'class': QtGui.QColor(240, 220, 240), # light purple 70 | #'module': QtGui.QColor(200, 240, 240), # light blue 71 | } 72 | 73 | type_foreground_colors = { 74 | 'class': QtGui.QColor(100, 20, 180), # dark purple 75 | 'module': QtGui.QColor(20, 100, 180), # dark blue 76 | 'script': QtGui.QColor(20, 100, 180), # dark blue 77 | } 78 | 79 | role_foreground_colors = { 80 | 'signature': QtGui.QColor('gray'), # gray 81 | } 82 | 83 | def __init__(self, node: Node): 84 | """The parents call the node, children are called by the node. 85 | 86 | If not callable, the Node has no children 87 | 88 | :param relation: relation to the CallListItem on the left 89 | 90 | """ 91 | super().__init__() 92 | 93 | self.node = node 94 | 95 | name = self.node.code_element.name 96 | 97 | self.setFont(code_font()) 98 | 99 | fontMetrics = QtGui.QFontMetrics(self.font()) 100 | 101 | icon = self.role_markers[self.node.code_element.role] 102 | 103 | try: 104 | color = self.type_background_colors[node.code_element.type] 105 | except KeyError: 106 | pass 107 | else: 108 | self.setBackground(QtGui.QBrush(color)) 109 | 110 | try: 111 | color = self.type_foreground_colors[node.code_element.type] 112 | except KeyError: 113 | pass 114 | else: 115 | self.setForeground(QtGui.QBrush(color)) 116 | 117 | try: 118 | color = self.role_foreground_colors[self.node.code_element.role] 119 | except KeyError: 120 | pass 121 | else: 122 | self.setForeground(QtGui.QBrush(color)) 123 | 124 | if isinstance(icon, str): 125 | if self.node.code_element.role == 'signature': 126 | fullText = icon + ' ' + '[sig]' 127 | else: 128 | fullText = icon + ' ' + self.type_markers.get(node.code_element.type, '') + name 129 | elidedText = fontMetrics.elidedText(fullText, Qt.Qt.ElideRight, COLUMN_WIDTH - 25) 130 | self.setText(elidedText) 131 | else: 132 | raise NotImplementedError 133 | self.setText(name) 134 | self.setIcon(icon) 135 | 136 | @classmethod 137 | def configure_role_markers(cls, want_unicode_role_markers): 138 | if want_unicode_role_markers: 139 | cls.role_markers = tz.merge(cls.default_role_markers, {'definition': '・', 'parent': '⊲'}) 140 | else: 141 | cls.role_markers = cls.default_role_markers 142 | 143 | def walk_left(self): 144 | for ll in self.listWidget().walk_left(): 145 | yield ll.currentItem() 146 | 147 | def walk_right(self): 148 | for ll in self.listWidget().walk_right(): 149 | yield ll.currentItem() 150 | 151 | def showSource(self, path) -> Tuple[bool, bool, str]: 152 | # returns success, reuse, text 153 | 154 | if path: 155 | _path = Path(path) 156 | 157 | main_widget = findParent(self.listWidget(), MainWidget) 158 | 159 | text_edit = main_widget.text_edit 160 | if _path == text_edit.current_path: 161 | return (True, True, '') 162 | 163 | text = read_text_cached(_path) 164 | 165 | # see http://www.qtcentre.org/archive/index.php/t-52574.html 166 | 167 | text_edit.current_path = _path 168 | 169 | return (True, False, text) 170 | else: 171 | return (False, True, '') 172 | 173 | #line = node.code_element.start_pos[0] - 1 174 | #scrollToLine(text_edit, line - 8) 175 | 176 | def highlight(self, cancel_event): 177 | if cancel_event.is_set(): 178 | return 179 | 180 | text_edit = findParent(self.listWidget(), MainWidget).text_edit 181 | sig = Signaler() 182 | sig.setPlainText_highlight_and_scroll.connect(text_edit.setPlainText_highlight_and_scroll) 183 | 184 | #if (self.node.code_element.role == 'definition' 185 | # and self.node.code_element.type == 'module' 186 | # and self.listWidget().index > 0): 187 | # text_edit.highlighter.reset() 188 | #else: 189 | success, reuse, text = self.showSource(self.node.code_element.call_pos[0]) 190 | 191 | if cancel_event.is_set() or not success: 192 | text_edit.highlighter.reset() 193 | return 194 | else: 195 | call_pos = self.node.code_element.call_pos 196 | sig.setPlainText_highlight_and_scroll.emit(reuse, text, call_pos) 197 | 198 | 199 | def next_nodes(node: Node, cancel_event: threading.Event) -> List[Node]: 200 | if node.code_element.role == 'signature': 201 | return [] 202 | 203 | if (type(node) != ONode 204 | and node.code_element.path != None 205 | and node.code_element.type != 'module'): 206 | signatures = [node.with_new_role('signature')] 207 | else: 208 | signatures = [] 209 | 210 | if cancel_event.is_set(): return () 211 | 212 | try: 213 | children = list(node.children) 214 | except Exception as exc: 215 | children = [] 216 | logger.error('{}; while finding outbound connections of {}.'.format(exc, node), exc_info=get_user_config()['EXC_INFO']) 217 | if cancel_event.is_set(): return () 218 | 219 | try: 220 | parents = list(node.parents) 221 | except Exception as exc: 222 | parents = [] 223 | logger.error('{}; while finding inbound connections of {}.'.format(exc, node), exc_info=get_user_config()['EXC_INFO']) 224 | if cancel_event.is_set(): return () 225 | 226 | return signatures + children + parents 227 | 228 | 229 | class UnthreadedExecutor: 230 | def submit(self, fn, *args, **kwargs) -> Future: 231 | result = fn(*args, **kwargs) 232 | future = Future() 233 | future.set_result(result) 234 | return future 235 | 236 | 237 | class MuxExecutor(Executor): 238 | def __init__(self, max_workers=None, thread_name_prefix=''): 239 | self.unthreaded_executor = UnthreadedExecutor() 240 | if sys_version_info >= (3, 6): 241 | self.thread_pool_executor = ThreadPoolExecutor(max_workers=max_workers, 242 | thread_name_prefix=thread_name_prefix) 243 | else: 244 | self.thread_pool_executor = ThreadPoolExecutor(max_workers=max_workers) 245 | 246 | def submit(self, fn, *args, **kwargs): 247 | if get_user_config()['MULTITHREADING']: 248 | executor = self.thread_pool_executor 249 | else: 250 | executor = self.unthreaded_executor 251 | 252 | return executor.submit(fn, *args, **kwargs) 253 | 254 | 255 | executors.highlight_executor = MuxExecutor(max_workers=1, thread_name_prefix='highlight') 256 | executors.main_executor = MuxExecutor(max_workers=1, thread_name_prefix='main') 257 | 258 | 259 | class Signaler(QtCore.QObject): 260 | """Handles Qt Signals for multithreading 261 | 262 | Qt requires that objects in different threads pass information only through 263 | signals. Various signals are gathered in the `Signaler` class to make passing 264 | arguments more like using plain functions. 265 | 266 | """ 267 | add_next = QtCore.Signal(int, Node, list) 268 | insertItem = QtCore.Signal(int, CallListItem) 269 | insertCallListItem = QtCore.Signal(int, object) 270 | focus = QtCore.Signal(CallListItem) 271 | setPlainText_highlight_and_scroll = QtCore.Signal(bool, str, tuple) 272 | progress = QtCore.Signal(int) 273 | setNode = QtCore.Signal(Node, list) 274 | 275 | 276 | class CallList(QtWidgets.QListWidget): 277 | 278 | def __init__(self, map_widget, info_widget, index: int): 279 | super().__init__() 280 | 281 | self.map_widget = map_widget 282 | self.info_widget = info_widget 283 | self.index = index 284 | 285 | self.node = ONode('') 286 | self.nodes_to_items = {} 287 | 288 | self.currentItemChanged.connect(self.itemChangedSlot) 289 | 290 | self.setSelectionMode(QtWidgets.QAbstractItemView.SingleSelection) 291 | 292 | # appearance 293 | self.focused_palette = QtGui.QPalette() 294 | 295 | self.unfocused_palette = QtGui.QPalette() 296 | self.unfocused_palette.setColor(QtGui.QPalette.HighlightedText, 297 | QtGui.QColor('black')) 298 | self.unfocused_palette.setColor(QtGui.QPalette.Highlight, QtGui.QColor('lightgray')) 299 | 300 | self.setPalette(self.unfocused_palette) 301 | 302 | self.setFocusPolicy(QtCore.Qt.StrongFocus) 303 | 304 | self.setMinimumWidth(COLUMN_WIDTH) 305 | self.setMaximumWidth(COLUMN_WIDTH) 306 | 307 | sizePolicy = QtWidgets.QSizePolicy() 308 | sizePolicy.setVerticalPolicy(Qt.QSizePolicy.Expanding) 309 | 310 | self.setSizePolicy(sizePolicy) 311 | self.setAttribute(QtCore.Qt.WA_MacShowFocusRect, False) 312 | 313 | #self.setAlternatingRowColors(True) 314 | 315 | self.populate_futures = [] 316 | self.add_next_futures = [] 317 | self.highlight_futures = [] 318 | 319 | self.strict = False 320 | 321 | def setNode(self, node: Node, items: List[Node]): 322 | self.node = node 323 | self.clear() 324 | self.nodes_to_items.clear() 325 | 326 | for ii, item in enumerate(filter(tz.identity, items)): # filter null items -- TODO: find better place for filter 327 | self.insertCallListItem(ii, item) 328 | 329 | # NOTE: previously `setNode` involved submitting a job to an Executor 330 | # and appending the future to self.populate_futures. Currently 331 | # self.populate_futures is always empty. 332 | 333 | def focus(self, current): 334 | self.info_widget.showInfo(current.node) 335 | if not self.map_widget.auto_highlight: 336 | return 337 | 338 | text = ', '.join(('< ' if node.code_element.role == 'parent' else '') + node.code_element.name 339 | for node in self.map_widget.node_path()) 340 | 341 | self.info_widget.setCallPath(text) 342 | 343 | cancel_event = threading.Event() 344 | fut = executors.highlight_executor.submit(current.highlight, cancel_event) 345 | fut.cancel_event = cancel_event 346 | self.highlight_futures.append(fut) 347 | 348 | def prepareToFocus(self): 349 | while self.highlight_futures: 350 | fut = self.highlight_futures.pop() 351 | fut.cancel() 352 | fut.cancel_event.set() 353 | 354 | def itemChangedSlot(self, current, previous): 355 | while self.add_next_futures: 356 | future = self.add_next_futures.pop() 357 | future.cancel() 358 | future.node.cancel_search() 359 | 360 | if not future.done() and not future.cancel_event.is_set(): 361 | future.cancel_event.set() 362 | 363 | if current: 364 | try: 365 | node = current.node 366 | except AttributeError: 367 | return 368 | 369 | # clear immediately so user cannot issue commands on stale items 370 | self.map_widget.prepareToSetCallList(self.index + 1) 371 | 372 | self.prepareToFocus() 373 | 374 | wait(self.populate_futures + self.highlight_futures) 375 | 376 | self.focus(current) 377 | 378 | if self.strict: 379 | cancel_event = threading.Event() 380 | self.makeNextCallList(node, cancel_event) 381 | else: 382 | cancel_event = threading.Event() 383 | add_next_future = executors.main_executor.submit(self.makeNextCallList, node, cancel_event) 384 | add_next_future.node = node 385 | add_next_future.cancel_event = cancel_event 386 | self.add_next_futures.append(add_next_future) 387 | 388 | def _showCallPath(self): 389 | path = list(tz.cons(self.currentItem().node, (ll.currentItem().node for ll in self.walk_left()))) 390 | path.reverse() 391 | 392 | text = ', '.join(('< ' if node.code_element.role == 'parent' else '') 393 | + node.code_element.name 394 | for node in path) 395 | 396 | self.info_widget.setCallPath(text) 397 | 398 | def remove_nodes(self, nodes: Iterable[Node]): 399 | for node in nodes: 400 | item = self.nodes_to_items.pop(node) 401 | self.takeItem(self.row(item)) 402 | self.removeItemWidget(item) 403 | #assert 0 404 | #print(self.items()) 405 | 406 | def add_nodes(self, nodes: Iterable[Node]): 407 | start = self.count() 408 | for ii, node in enumerate(nodes): 409 | self.insertCallListItem(start + ii, node) 410 | 411 | @QtCore.Slot(int, object) 412 | def insertCallListItem(self, ii, node: Node): 413 | self.nodes_to_items[node] = CallListItem(node) 414 | self.insertItem(ii, CallListItem(node)) 415 | 416 | def makeNextCallList(self, node: Node, cancel_event: threading.Event): 417 | items = next_nodes(node, cancel_event) 418 | if not cancel_event.is_set(): 419 | next_call_list = self.map_widget.callLists[self.index + 1] 420 | if self.strict: 421 | next_call_list.strict = True 422 | next_call_list.setNode(node, items) 423 | next_call_list.strict = False 424 | else: 425 | signaler = Signaler() 426 | signaler.setNode.connect(next_call_list.setNode) 427 | signaler.setNode.emit(node, items) 428 | 429 | def walk_right(self): 430 | return self.map_widget.callLists[self.index + 1:] 431 | 432 | def walk_left(self): 433 | if self.index > 0: 434 | return self.map_widget.callLists[self.index - 1: :-1] 435 | else: 436 | return () 437 | 438 | def next_call_list(self): 439 | try: 440 | return self.map_widget.callLists[self.index + 1] 441 | except IndexError: 442 | return None 443 | 444 | def prev_call_list(self): 445 | if self.index > 0: 446 | return self.map_widget.callLists[self.index - 1] 447 | else: 448 | return None 449 | 450 | def focusInEvent(self, event): 451 | current = self.currentItem() 452 | if current: 453 | self.prepareToFocus() 454 | wait(self.populate_futures + self.highlight_futures) 455 | self.focus(current) 456 | self.setPalette(self.focused_palette) 457 | 458 | def focusOutEvent(self, event): 459 | if event.reason() != QtCore.Qt.ActiveWindowFocusReason: 460 | self.setPalette(self.unfocused_palette) 461 | 462 | def keyPressEvent(self, event): 463 | super().keyPressEvent(event) #ll.setFocus() 464 | 465 | if not event.isAccepted(): 466 | if event.key() == Qt.Qt.Key_Right: 467 | _next = self.next_call_list() 468 | if _next and _next.count() > 0: 469 | if all(future.done() for future in 470 | tz.concatv(self.populate_futures, self.add_next_futures)): 471 | _next.setFocus() 472 | self.map_widget.ensureWidgetVisible(_next, 0, 0) 473 | if self.next_call_list().currentItem() is None: 474 | self.next_call_list().setCurrentRow(0) 475 | else: 476 | wait_item = _next.item(0) 477 | wait_item.poked += 1 478 | if wait_item.poked == 3: 479 | wait_item.setText('QUIT POKING ME') 480 | event.accept() 481 | 482 | if event.key() == Qt.Qt.Key_Left: 483 | _prev = self.prev_call_list() 484 | if _prev: 485 | _prev.setFocus() 486 | (self.map_widget.ensureWidgetVisible(_prev, 0, 0)) 487 | event.accept() 488 | 489 | if event.key() == Qt.Qt.Key_Space: 490 | pass 491 | 492 | class MapLayout(QtWidgets.QHBoxLayout): 493 | def __init__(self, parent): 494 | super().__init__(parent) 495 | #self.addStretch(1) 496 | 497 | def sizeHint(self): 498 | size = super().sizeHint() 499 | width = self.count() * COLUMN_WIDTH 500 | return QtCore.QSize(width, self.parent().maximumHeight()) 501 | 502 | def maximumSize(self): 503 | width = self.count() * COLUMN_WIDTH 504 | return QtCore.QSize(width, super().maximumSize().height()) 505 | 506 | def minimumSize(self): 507 | width = self.count() * COLUMN_WIDTH 508 | return QtCore.QSize(width, super().minimumSize().height()) 509 | 510 | 511 | class MapWidget(QtWidgets.QScrollArea): 512 | def __init__(self, parent, info_widget, status_bar, node: Node): 513 | super().__init__(parent) 514 | 515 | self.info_widget = info_widget 516 | self.status_bar = status_bar 517 | 518 | self.callLists = [] 519 | 520 | self.setFocusPolicy(QtCore.Qt.StrongFocus) 521 | self.setWidget(QtWidgets.QWidget(self)) 522 | 523 | layout = MapLayout(self.widget()) 524 | #layout.setAlignment(QtCore.Qt.AlignRight) 525 | layout.setContentsMargins(0,0,0,0) 526 | layout.setSpacing(0) 527 | 528 | layout.setSizeConstraint(Qt.QLayout.SetMinAndMaxSize) 529 | 530 | self.currentIndex = -1 531 | self.prepareToSetCallList(0) 532 | 533 | self.root_list_items = {} # type: Dict[Node, CallListItem] 534 | 535 | self.callLists[0].setNode(node, node.children) 536 | wait(self.callLists[0].populate_futures) 537 | 538 | for ii in range(self.callLists[0].count()): 539 | item = self.callLists[0].item(ii) 540 | child_node = item.node 541 | self.root_list_items[child_node] = item 542 | 543 | self.auto_highlight = True 544 | 545 | def prepareToSetCallList(self, index): 546 | oldIndex = self.currentIndex 547 | self.currentIndex = index 548 | 549 | try: 550 | ll = self.callLists[index] 551 | except IndexError: 552 | ll = CallList(self, self.info_widget, len(self.callLists)) 553 | self.callLists.append(ll) 554 | 555 | self.widget().layout().insertWidget(ll.index, ll) 556 | 557 | while ll.populate_futures: 558 | fut = ll.populate_futures.pop() 559 | fut.cancel() 560 | fut.cancel_event.set() 561 | 562 | ll.clear() 563 | ll.nodes_to_items.clear() 564 | item = QtWidgets.QListWidgetItem('wait . . .') 565 | item.setFlags(QtCore.Qt.ItemNeverHasChildren) 566 | item.poked = 0 567 | ll.addItem(item) 568 | ll.show() 569 | self.ensureWidgetVisible(ll) 570 | 571 | if oldIndex != index: 572 | for ll in self.callLists[index+1:]: 573 | ll.hide() 574 | self.widget().layout().removeWidget(ll) 575 | 576 | def resizeEvent(self, event): 577 | # Note: must do super().resizeEvent before resizing the self.widget, 578 | # otherwise there will be minor graphical glitches. 579 | super().resizeEvent(event) 580 | self.widget().resize(self.widget().size().width(), event.size().height()) 581 | 582 | def node_path(self): 583 | for ll in self.callLists: 584 | if ll.currentItem(): 585 | yield ll.currentItem().node 586 | else: 587 | break 588 | 589 | def open_bookmark(self, bookmark_code_element_path: Iterable[CodeElement]): 590 | def same_except_location(aa: CodeElement, bb: CodeElement): 591 | return (aa.name == bb.name and 592 | aa.module == bb.module and 593 | aa.type == bb.type and 594 | aa.role == bb.role) 595 | 596 | def same_except_name(aa: CodeElement, bb: CodeElement): 597 | return (aa.module == bb.module and 598 | aa.type == bb.type and 599 | aa.role == bb.role and 600 | aa.call_pos == bb.call_pos) 601 | 602 | for ii, bookmark_code_element in enumerate(bookmark_code_element_path): 603 | ll = self.callLists[ii] # type: CallList 604 | 605 | # TODO: use a with block to set strictness 606 | ll.strict = True 607 | 608 | wait(ll.populate_futures + ll.highlight_futures + ll.add_next_futures) 609 | 610 | near_misses = [] 611 | 612 | for jj in range(ll.count()): 613 | ll_item = ll.item(jj) 614 | ll_code_element = ll_item.node.code_element 615 | if ll_code_element == bookmark_code_element: 616 | ll.setCurrentItem(ll_item) 617 | ll.setFocus() 618 | wait(ll.add_next_futures) 619 | break 620 | elif same_except_location(ll_code_element, bookmark_code_element): 621 | near_misses.append((jj, 'Same except location.')) 622 | 623 | elif same_except_name(ll_code_element, bookmark_code_element): 624 | near_misses.append((jj, 'Same except name.')) 625 | else: 626 | if near_misses: 627 | idx, note = near_misses[0] 628 | self.status_bar.showMessage( 629 | 'Exact bookmark not found. Nearest match followed. ({})'.format(note), 630 | msecs=10000) 631 | ll.setCurrentRow(idx) 632 | ll.setFocus() 633 | else: 634 | ll.setCurrentRow(0) 635 | ll.setFocus() 636 | ll.strict = False 637 | return 638 | 639 | ll.strict = False 640 | 641 | def toggle_auto_highlight(self): 642 | self.auto_highlight = not self.auto_highlight 643 | self.status_bar.showMessage( 644 | 'Auto highlight toggled {}'.format( 645 | 'on' if self.auto_highlight else 'off'), msecs=3000) 646 | 647 | self.status_bar.update() 648 | 649 | if self.auto_highlight: 650 | current_callList = tz.first(cl for cl in self.callLists if cl.hasFocus()) 651 | current = current_callList.currentItem() 652 | if current: 653 | current_callList.focus(current) 654 | 655 | 656 | class InfoWidget(QtWidgets.QWidget): 657 | """Displays information about a node""" 658 | labels = ['code_path', 'name', 'type', 'module', 'position', 'defined at'] 659 | auto_labels = ['name', 'type', 'module'] 660 | 661 | def __init__(self, status_bar, parent=None): 662 | super().__init__(parent) 663 | 664 | self.status_bar = status_bar 665 | self.setLayout(QtWidgets.QGridLayout(self)) 666 | self.current_code_element = None 667 | 668 | self.label_to_row = {label: ii for ii, label in enumerate(self.labels)} 669 | 670 | for ii, label in enumerate(self.labels): 671 | lineEdit = QtWidgets.QLineEdit(self) 672 | lineEdit.setFont(code_font()) 673 | lineEdit.setReadOnly(True) 674 | qlabel = QtWidgets.QLabel('&' + label + ':', self) 675 | qlabel.setBuddy(lineEdit) 676 | qlabel.setAlignment(Qt.Qt.AlignLeft | Qt.Qt.AlignVCenter) 677 | 678 | self.layout().addWidget(qlabel, ii, 0) 679 | self.layout().addWidget(lineEdit, ii, 1) 680 | 681 | open_position_button = QtWidgets.QPushButton('Open') 682 | open_def_button = QtWidgets.QPushButton('Open') 683 | 684 | self.layout().addWidget(open_position_button, self.label_to_row['position'], 2) 685 | self.layout().addWidget(open_def_button, self.label_to_row['defined at'], 2) 686 | 687 | 688 | @open_position_button.clicked.connect 689 | def onclick(): 690 | if self.current_code_element: 691 | self.open_in_editor_with_user_defined_function( 692 | self.current_code_element.call_pos[0], 693 | self.current_code_element.call_pos[1][0]) 694 | 695 | 696 | @open_def_button.clicked.connect 697 | def onclick(): 698 | if self.current_code_element and self.current_code_element.path: 699 | self.open_in_editor_with_user_defined_function( 700 | self.current_code_element.path, 701 | self.current_code_element.start_pos[0]) 702 | 703 | 704 | self.layout().addItem(QtWidgets.QSpacerItem(0,0)) 705 | self.layout().setRowStretch(self.layout().rowCount(), 1) 706 | 707 | 708 | def showInfo(self, node: Node): 709 | try: 710 | code_element = node.code_element 711 | except AttributeError: 712 | pass 713 | else: 714 | Debug.code_element = code_element 715 | Debug.node = node 716 | 717 | self.current_code_element = code_element 718 | 719 | for ii, label in enumerate(self.labels): 720 | lineEdit = self.layout().itemAtPosition(ii, 1).widget() 721 | 722 | if label == 'code_path': 723 | continue 724 | elif label in self.auto_labels: 725 | value = getattr(code_element, label) 726 | elif label == 'defined at': 727 | if code_element.start_pos: 728 | value = '{} : {}'.format(code_element.path, code_element.start_pos[0]) 729 | elif code_element.path: 730 | value = code_element.path 731 | else: 732 | value = '[Unknown]' 733 | elif label == 'position': 734 | value = '{} : {}'.format(code_element.call_pos[0], code_element.call_pos[1][0]) 735 | 736 | lineEdit.setText(value if isinstance(value, str) 737 | else repr(value)) 738 | 739 | def setCallPath(self, call_path): 740 | lineEdit = self.layout().itemAtPosition(0, 1).widget() 741 | lineEdit.setText(call_path) 742 | 743 | def open_in_editor_with_user_defined_function(self, path, line): 744 | user_defined_function = (get_user_config()['open_in_editor']) 745 | try: 746 | user_defined_function(path, line) 747 | except: 748 | message = 'Cannot open {} at line {}'.format(path, line) 749 | logger.exception(message) 750 | self.status_bar.showMessage(message, 10000) 751 | 752 | 753 | class TargetHighlighter: 754 | def __init__(self, text_edit): 755 | self.text_edit = text_edit 756 | self.current_highlight_cursor = None 757 | 758 | def highlight(self, call_pos): 759 | self.reset() 760 | 761 | if call_pos[0] != None and call_pos[1] != (None, None): 762 | document = self.text_edit.document() 763 | self.call_pos = call_pos 764 | cursor = QtGui.QTextCursor( 765 | document.findBlockByLineNumber(call_pos[1][0] - 1)) 766 | cursor.setPosition(cursor.block().position() + call_pos[1][1]) 767 | 768 | anchor = cursor.position() 769 | cursor.movePosition(QtGui.QTextCursor.NextWord, anchor) 770 | 771 | self.current_highlight_cursor = cursor 772 | cursor.orig_format = cursor.charFormat() 773 | 774 | # WARNING: To be safe, make a new QTextCharFormat every time. If you 775 | # use the same one for more than one document, it will cause many 776 | # glitches. I do not know exactly when it is safe to share a format. 777 | # -- Andy Lee 778 | _fmt = QtGui.QTextCharFormat() 779 | _fmt.setBackground(QtGui.QColor('lightskyblue')) 780 | cursor.setCharFormat(_fmt) 781 | 782 | def reset(self): 783 | cc = self.current_highlight_cursor 784 | if cc: 785 | cc.setCharFormat(cc.orig_format) 786 | self.current_highlight_cursor = None 787 | 788 | 789 | class PlainTextEdit(QtWidgets.QTextEdit): 790 | def setPlainText_highlight_and_scroll(self, reuse: bool, text: str, call_pos: tuple): 791 | if not reuse: 792 | self.setPlainText(text) 793 | 794 | self.highlighter.highlight(call_pos) 795 | if call_pos[1][0]: 796 | self.scrollToLine(call_pos[1][0] - 1 - 8) 797 | 798 | def scrollToLine(self, line): 799 | cursor = self.textCursor() 800 | block = self.document().findBlockByLineNumber(line) 801 | cursor.setPosition(block.position()) 802 | 803 | sb = self.verticalScrollBar() 804 | sb.setValue(sb.maximum()) 805 | 806 | self.setTextCursor(cursor) 807 | 808 | #Widgets.text_edit.scrollBar().scrollContentsBy(0, 10) 809 | #Widgets.text_edit.horizontalScrollBar().setValue(10,10) 810 | 811 | 812 | class SettingsWidget(QtWidgets.QTabWidget): 813 | def __init__(self, project: Project, map_widget: MapWidget, status_bar: QtWidgets.QStatusBar, parent=None): 814 | super().__init__(parent) 815 | 816 | self.project = project 817 | self.map_widget = map_widget 818 | self.status_bar = status_bar 819 | 820 | self.path_settings_widget = TextSettingsWidget( 821 | project, map_widget, status_bar, project_settings_module.sys_path, parent=self) 822 | 823 | self.module_settings_widget = TextSettingsWidget( 824 | project, map_widget, status_bar, project_settings_module.modules, parent=self) 825 | 826 | self.script_settings_widget = TextSettingsWidget( 827 | project, map_widget, status_bar, project_settings_module.scripts, parent=self) 828 | 829 | self.project_settings_widget = TextSettingsWidget( 830 | project, map_widget, status_bar, project_settings_module.project_settings, parent=self) 831 | 832 | self.bookmarks_json_widget = TextSettingsWidget( 833 | project, map_widget, status_bar, project_settings_module.bookmarks, parent=self) 834 | 835 | self.bookmarks_widget = BookmarksWidget(project, map_widget, parent=self) 836 | 837 | self.bookmarks_widget.bookmarks_changed.connect(self.bookmarks_json_widget.load_project_settings) 838 | self.bookmarks_json_widget.settings_changed.connect(self.bookmarks_widget.load_from_project_settings) 839 | 840 | self.addTab(self.bookmarks_widget, "bookmarks") 841 | self.addTab(self.path_settings_widget, "sys path") 842 | self.addTab(self.module_settings_widget, "modules") 843 | self.addTab(self.script_settings_widget, "scripts") 844 | self.addTab(self.project_settings_widget, "project settings") 845 | self.addTab(self.bookmarks_json_widget, "bookmarks (json)") 846 | 847 | if sys_platform == 'darwin': 848 | self.setStyleSheet(''' 849 | QTabWidget::tab-bar { 850 | left: 3px; 851 | } 852 | ''') 853 | 854 | #self.minimumSize = QtCore.QSize(200, 200) 855 | 856 | 857 | class TextSettingsWidget(QtWidgets.QWidget): 858 | settings_changed = QtCore.Signal(list, list) 859 | 860 | def __init__(self, project: Project, map_widget: MapWidget, status_bar: QtWidgets.QStatusBar, 861 | category: str, parent=None): 862 | super().__init__(parent) 863 | 864 | self.__init_gui_elements__() 865 | 866 | self.project = project 867 | self.map_widget = map_widget 868 | self.status_bar = status_bar 869 | self.category = category 870 | self.load_project_settings() 871 | self.textEdit.textChanged.connect(self.enableButtons) 872 | 873 | def __init_gui_elements__(self): 874 | self.setLayout(QtWidgets.QVBoxLayout(self)) 875 | self.layout().setContentsMargins(3,3,3,3) 876 | self.layout().setSpacing(3) 877 | 878 | self.textEdit = QtWidgets.QTextEdit() 879 | self.textEdit.setFont(code_font()) 880 | self.saveButton = QtWidgets.QPushButton('Commit') 881 | self.cancelButton = QtWidgets.QPushButton('Cancel') 882 | 883 | #self.buttonGroup = QtWidgets.QButtonGroup(self) 884 | #self.buttonGroup.addButton(self.saveButton) 885 | #self.buttonGroup.addButton(self.cancelButton) 886 | self.buttonLayout = QtWidgets.QHBoxLayout() 887 | self.buttonLayout.addStretch(-1) 888 | self.buttonLayout.setContentsMargins(1,1,1,1) 889 | self.buttonLayout.setSpacing(15) 890 | 891 | self.saveButton.setMaximumWidth(200) 892 | self.saveButton.setMinimumWidth(100) 893 | self.cancelButton.setMaximumWidth(200) 894 | self.cancelButton.setMinimumWidth(100) 895 | 896 | self.layout().addWidget(self.textEdit) 897 | self.layout().addLayout(self.buttonLayout) 898 | 899 | self.buttonLayout.addWidget(self.saveButton) 900 | self.buttonLayout.addWidget(self.cancelButton) 901 | 902 | self.saveButton.pressed.connect(self.commit) 903 | self.cancelButton.pressed.connect(self.load_project_settings) 904 | 905 | def enableButtons(self): 906 | self.saveButton.setEnabled(True) 907 | self.cancelButton.setEnabled(True) 908 | 909 | def load_project_settings(self): 910 | text = json.dumps(self.project.encode(self.category, for_persistence=False), indent=True, sort_keys=True) 911 | self.textEdit.setPlainText(text) 912 | self.saveButton.setDisabled(True) 913 | self.cancelButton.setDisabled(True) 914 | 915 | def commit(self): 916 | type_ = project_settings_module.category_type[self.category] 917 | try: 918 | decoded = serialize.decode(type_, json.loads(self.textEdit.toPlainText())) 919 | except json.JSONDecodeError as err: 920 | message = 'Cannot decode JSON; {}'.format(err.args[0]) 921 | logger.error(message) 922 | self.status_bar.showMessage(message, 10000) 923 | self.saveButton.setDisabled(True) 924 | return 925 | except serialize.DecodeError as err: 926 | message = '{}'.format(err.args[0]) 927 | logger.error(message) 928 | self.status_bar.showMessage(message, 10000) 929 | self.saveButton.setDisabled(True) 930 | return 931 | 932 | try: 933 | has_changed, stale, additional = self.project.set_settings(self.category, decoded) 934 | except (ValueError, TypeError) as err: 935 | # TypeError -- raised by initial type validation in `set_settings`. 936 | # ModuleResolutionError -- raised by `module_nodes` 937 | # ScriptResolutionError -- raised by `script_nodes` 938 | 939 | message = 'Invalid settings; {}: {}'.format(type(err).__name__, err.args[0]) 940 | logger.error(message) 941 | self.status_bar.showMessage(message, 10000) 942 | self.saveButton.setDisabled(True) 943 | return 944 | 945 | status_messages = [] 946 | for key, err in tz.concatv(self.project.failures['python'][project_settings_module.modules].items(), 947 | self.project.failures['python'][project_settings_module.scripts].items()): 948 | message = '{}: {}'.format(type(err).__name__, err.args[0]) 949 | logger.error(message) 950 | status_messages.append((logging.ERROR, message)) 951 | 952 | if has_changed: 953 | if self.category in (project_settings_module.modules, 954 | project_settings_module.scripts): 955 | self.map_widget.callLists[0].remove_nodes(stale) 956 | self.map_widget.callLists[0].add_nodes(additional) 957 | 958 | # TODO: add/remove stale nodes in other callLists 959 | 960 | elif self.category == project_settings_module.files: 961 | print('TODO: implement settings update for {}'.format(self.category)) 962 | elif self.category == project_settings_module.sys_path: 963 | # 1. Record code path 964 | # 2. regenerate root nodes and root list 965 | # 3. Follow previous code path 966 | 967 | old_code_element_path = list(node.code_element for node in self.map_widget.node_path()) 968 | 969 | node = OrganizerNode('Root', [], 970 | list(tz.concatv(self.project.module_nodes['python'].values(), 971 | self.project.script_nodes['python'].values()))) 972 | 973 | try: 974 | children = node.children 975 | except Exception as exc: 976 | children = [] 977 | logger.error('{}; while finding inbound connections of {}.'.format(exc, node), 978 | exc_info=get_user_config()['EXC_INFO']) 979 | 980 | self.map_widget.callLists[0].setNode(node, children) 981 | 982 | self.map_widget.callLists[0].setFocus() 983 | 984 | self.map_widget.open_bookmark(old_code_element_path) 985 | 986 | elif self.category == project_settings_module.bookmarks: 987 | self.settings_changed.emit(stale, additional) 988 | 989 | elif self.category == 'project_settings': 990 | self.settings_changed.emit(stale, additional) 991 | 992 | else: 993 | print('TODO: implement settings update for {}'.format(self.category)) 994 | 995 | try: 996 | self.project.update_persistent_storage() 997 | except Exception as err: 998 | message = 'Cannot save settings; {}'.format(err.args[0]) 999 | logger.error(err, exc_info=get_user_config()['EXC_INFO']) 1000 | status_messages.append((logging.ERROR, err.args[0])) 1001 | self.load_project_settings() # reload to reformat text 1002 | else: 1003 | status_messages.append((logging.INFO, 'Saved.')) 1004 | self.load_project_settings() # reload to reformat text 1005 | 1006 | else: 1007 | status_messages.append((logging.INFO, 'Not saved (no change).')) 1008 | self.load_project_settings() # reload to reformat text 1009 | 1010 | self.status_bar.showMessage('. '.join(message for level, message in status_messages), 10000) 1011 | 1012 | 1013 | def bookmark_to_str(bookmark): 1014 | return ', '.join(('< ' if code_element.role == 'parent' else '') + code_element.name 1015 | for code_element in bookmark) 1016 | 1017 | 1018 | class BookmarksWidget(QtWidgets.QWidget): 1019 | bookmarks_changed = QtCore.Signal() 1020 | 1021 | def __init__(self, project: Project, map_widget: MapWidget, parent=None): 1022 | super().__init__(parent) 1023 | self.project = project 1024 | self.map_widget = map_widget 1025 | 1026 | self.setLayout(QtWidgets.QVBoxLayout(self)) 1027 | self.layout().setContentsMargins(3,3,3,3) 1028 | self.layout().setSpacing(3) 1029 | 1030 | self.buttonLayout = QtWidgets.QHBoxLayout() 1031 | self.buttonLayout.setContentsMargins(1,1,1,1) 1032 | self.buttonLayout.setSpacing(4) 1033 | self.buttonLayout.addStretch(-1) 1034 | 1035 | self.visitButton = QtWidgets.QPushButton('Visit') 1036 | self.visitButton.setMaximumWidth(200) 1037 | self.visitButton.setMinimumWidth(100) 1038 | self.addButton = QtWidgets.QPushButton('Add') 1039 | self.addButton.setMaximumWidth(200) 1040 | self.addButton.setMinimumWidth(100) 1041 | self.deleteButton = QtWidgets.QPushButton('Delete') 1042 | self.deleteButton.setMaximumWidth(200) 1043 | self.deleteButton.setMinimumWidth(100) 1044 | self.buttonLayout.addWidget(self.visitButton) 1045 | self.buttonLayout.addWidget(self.addButton) 1046 | self.buttonLayout.addWidget(self.deleteButton) 1047 | self.buttonLayout.addStretch(-1) 1048 | 1049 | self.visitButton.pressed.connect(self.visitBookmark) 1050 | self.addButton.pressed.connect(self.addBookmark) 1051 | self.deleteButton.pressed.connect(self.deleteBookmark) 1052 | 1053 | self.listWidget = QtWidgets.QListWidget() 1054 | 1055 | self.layout().addWidget(self.listWidget) 1056 | self.layout().addLayout(self.buttonLayout) 1057 | 1058 | self.load_from_project_settings() 1059 | 1060 | def load_from_project_settings(self): 1061 | self.listWidget.clear() 1062 | 1063 | for ii, bookmark in enumerate(self.project.settings[project_settings_module.bookmarks]): 1064 | item = QtWidgets.QListWidgetItem(bookmark_to_str(bookmark)) 1065 | item.setFont(code_font()) 1066 | item.setData(1, bookmark) 1067 | self.listWidget.insertItem(ii, item) 1068 | 1069 | def visitBookmark(self): 1070 | item = self.listWidget.currentItem() 1071 | if item: 1072 | bookmark = item.data(1) 1073 | self.map_widget.open_bookmark(bookmark) 1074 | 1075 | def addBookmark(self): 1076 | bookmark = list(node.code_element for node in self.map_widget.node_path()) 1077 | if bookmark: 1078 | self.project.settings[project_settings_module.bookmarks].append(bookmark) 1079 | item = QtWidgets.QListWidgetItem(bookmark_to_str(bookmark)) 1080 | item.setFont(code_font()) 1081 | item.setData(1, bookmark) 1082 | self.listWidget.addItem(item) 1083 | self.project.update_persistent_storage() 1084 | 1085 | self.bookmarks_changed.emit() 1086 | else: 1087 | logger.info('Empty bookmark ignored.') 1088 | 1089 | def deleteBookmark(self): 1090 | item = self.listWidget.currentItem() 1091 | if item: 1092 | bookmark = item.data(1) 1093 | self.listWidget.removeItemWidget(item) 1094 | self.project.settings[project_settings_module.bookmarks].remove(bookmark) 1095 | 1096 | self.project.update_persistent_storage() 1097 | self.load_from_project_settings() 1098 | 1099 | self.bookmarks_changed.emit() 1100 | 1101 | 1102 | def make_test_node(): 1103 | test_node = ONode('AAA', 1104 | parents=[ONode('BBB'), ONode('CCC')], 1105 | children=[ONode('DDD'), ONode('EEE')]) 1106 | 1107 | for ii in range(100): 1108 | test_node.children.append(ONode('X_' + repr(ii))) 1109 | 1110 | for child in test_node.children: 1111 | child.children.append(ONode('Y' + child.code_element.name, [], 1112 | [ONode('Z1' + child.code_element.name), 1113 | ONode('Z2' + child.code_element.name)])) 1114 | 1115 | root_node = ONode('Root', [], [test_node]) 1116 | 1117 | return root_node 1118 | 1119 | 1120 | class MyQApplication(QtWidgets.QApplication): 1121 | 1122 | def legacy_notify(self, obj: QtCore.QObject, event: QtCore.QEvent): 1123 | """Fixes scrolling weirdness 1124 | 1125 | This is a legacy method for `notify` from before PyQt5. It doesn't work 1126 | with PyQt5. 1127 | 1128 | """ 1129 | if (event.type() == QtCore.QEvent.Wheel): 1130 | return wheel_fix.notify_wheel_event(self, obj, event) 1131 | else: 1132 | return super().notify(obj, event) 1133 | 1134 | 1135 | class MainWidget(QtWidgets.QSplitter): 1136 | def __init__(self, parent=None): 1137 | super().__init__(parent) 1138 | 1139 | self.setOrientation(QtCore.Qt.Horizontal) 1140 | self.left_widget = QtWidgets.QWidget(self) 1141 | self.right_widget = QtWidgets.QWidget(self) 1142 | self.right_splitter = QtWidgets.QSplitter(self.right_widget) 1143 | self.right_splitter.setOrientation(QtCore.Qt.Vertical) 1144 | 1145 | self.left_layout = QtWidgets.QVBoxLayout(self.left_widget) 1146 | self.right_layout = QtWidgets.QVBoxLayout(self.right_widget) 1147 | 1148 | self.addWidget(self.left_widget) 1149 | self.addWidget(self.right_widget) 1150 | self.right_layout.addWidget(self.right_splitter) 1151 | 1152 | self.left_layout.setContentsMargins(0,0,0,0) 1153 | self.right_layout.setContentsMargins(0,0,0,0) 1154 | self.right_splitter.setContentsMargins(0,0,0,0) 1155 | 1156 | self.left_layout.setSpacing(0.0) 1157 | self.right_layout.setSpacing(0.0) 1158 | 1159 | 1160 | def finalize(self): 1161 | self.setStretchFactor(0, 2) 1162 | self.setStretchFactor(1, 1) 1163 | self.right_splitter.setStretchFactor(0, 2) 1164 | self.right_splitter.setStretchFactor(1, 1) 1165 | 1166 | self.setHandleWidth(1) 1167 | self.setCollapsible(0, False) 1168 | self.setCollapsible(1, False) 1169 | 1170 | self.right_splitter.setCollapsible(0, False) 1171 | self.right_splitter.setCollapsible(1, True) 1172 | 1173 | 1174 | def make_app(user_scope_settings: UserScopeSettings, project_directory: Optional[str], 1175 | enable_ipython_support: bool = False, show_gui: bool = True): 1176 | from .jedi_dump import make_scope_settings 1177 | 1178 | ui_toplevel = ModuleType('call_map_ui_toplevel') 1179 | 1180 | project = project_settings_module.Project(project_directory) 1181 | ui_toplevel.project = project 1182 | 1183 | stored_settings = project.load_from_persistent_storage() 1184 | is_new_project = bool(stored_settings) 1185 | project.update_settings(stored_settings) 1186 | 1187 | scope_settings = make_scope_settings(is_new_project, project.scope_settings, user_scope_settings) 1188 | 1189 | project.settings.update( 1190 | {project_settings_module.modules: scope_settings.module_names, 1191 | project_settings_module.scripts: scope_settings.scripts, 1192 | project_settings_module.sys_path: scope_settings.effective_sys_path}) 1193 | 1194 | project.update_module_resolution_path('python') 1195 | project.make_platform_specific_nodes('python') 1196 | 1197 | errors = list(tz.concatv(project.failures['python'][project_settings_module.modules].values(), 1198 | project.failures['python'][project_settings_module.scripts].values())) 1199 | # will also put in status bar later 1200 | for error in errors: 1201 | logger.error(error) 1202 | 1203 | try: 1204 | ui_toplevel.project.update_persistent_storage() 1205 | except FileNotFoundError as err: 1206 | raise BadArgsError(err) 1207 | 1208 | node = OrganizerNode('Root', [], 1209 | list(tz.concatv(project.module_nodes['python'].values(), 1210 | project.script_nodes['python'].values()))) 1211 | 1212 | CallListItem.configure_role_markers( 1213 | get_user_config()['UNICODE_ROLE_MARKERS']) 1214 | 1215 | app = QtCore.QCoreApplication.instance() 1216 | if app is None: 1217 | app = MyQApplication(sys_argv) 1218 | ui_toplevel.app = app 1219 | 1220 | 1221 | app.setQuitOnLastWindowClosed(True) 1222 | 1223 | main_window = QtWidgets.QMainWindow() 1224 | main_window.layout().setSpacing(0) 1225 | main_window.layout().setContentsMargins(0, 0, 0, 0) 1226 | 1227 | main_widget = MainWidget(main_window) 1228 | main_window.setCentralWidget(main_widget) 1229 | 1230 | status_bar = main_window.statusBar() 1231 | ui_toplevel.status_bar = status_bar 1232 | status_bar.setSizeGripEnabled(False) 1233 | 1234 | info_widget = InfoWidget(main_widget, status_bar) 1235 | ui_toplevel.info_widget = info_widget 1236 | 1237 | map_widget = MapWidget(main_widget, info_widget, status_bar, node) 1238 | ui_toplevel.map_widget = map_widget 1239 | 1240 | text_edit_0 = PlainTextEdit() 1241 | text_edit_0.current_path = None 1242 | text_edit_0.highlighter = TargetHighlighter(text_edit_0) 1243 | 1244 | text_edit_0.setReadOnly(True) 1245 | text_edit_0.setFont(code_font()) 1246 | 1247 | ui_toplevel.pygments_highlighter = qtconsole.pygments_highlighter.PygmentsHighlighter( 1248 | text_edit_0.document(), lexer=PythonLexer()) 1249 | 1250 | 1251 | text_edit_1 = QtWidgets.QTextEdit() 1252 | text_edit_1.setFont(code_font()) 1253 | 1254 | main_widget.text_edit = text_edit_0 1255 | 1256 | 1257 | toggle_action = QtWidgets.QAction('&Turn Auto Highlight Off', main_window) 1258 | view_menu = main_window.menuBar().addMenu('&View') 1259 | view_menu.addAction(toggle_action) 1260 | 1261 | def _update_auto_highlight_menu_text(): 1262 | action_on_or_off = 'Off' if map_widget.auto_highlight else 'On' 1263 | toggle_action.setText('&Turn Auto Highlight {}'.format(action_on_or_off)) 1264 | 1265 | toggle_action.triggered.connect(map_widget.toggle_auto_highlight) 1266 | toggle_action.triggered.connect(_update_auto_highlight_menu_text) 1267 | 1268 | ui_toplevel.settings_widget = SettingsWidget(ui_toplevel.project, map_widget, status_bar) 1269 | 1270 | def configure_sizes(): 1271 | main_widget.setSizes([4, 3]) 1272 | 1273 | main_widget.right_splitter.setSizes([2,1]) 1274 | 1275 | map_widget.setMinimumWidth(500) 1276 | map_widget.setSizePolicy(QtWidgets.QSizePolicy(Qt.QSizePolicy.Expanding, Qt.QSizePolicy.Expanding)) 1277 | 1278 | info_widget.setSizePolicy(QtWidgets.QSizePolicy(Qt.QSizePolicy.Expanding, Qt.QSizePolicy.Maximum)) 1279 | 1280 | text_edit_0.setMinimumWidth(400) 1281 | text_edit_0.setMinimumHeight(300) 1282 | text_edit_0.setSizePolicy(QtWidgets.QSizePolicy(Qt.QSizePolicy.Expanding, Qt.QSizePolicy.Expanding)) 1283 | 1284 | settings_widget = ui_toplevel.settings_widget 1285 | settings_widget.setMinimumWidth(400) 1286 | settings_widget.setSizePolicy(QtWidgets.QSizePolicy(Qt.QSizePolicy.Expanding, Qt.QSizePolicy.Minimum)) 1287 | 1288 | configure_sizes() 1289 | 1290 | 1291 | def fix_status_bar_shading(mesg): 1292 | status_bar.show() 1293 | 1294 | status_bar.messageChanged.connect(fix_status_bar_shading) 1295 | status_bar.showMessage('Starting', 1) 1296 | status_bar.hide() 1297 | if errors: 1298 | status_bar.showMessage('. '.join(e.args[0] for e in errors), 10000) 1299 | 1300 | #ui_toplevel.right_layout = QtWidgets.QStackedLayout(main_widget.layout()) 1301 | main_widget.left_layout.addWidget(map_widget) 1302 | main_widget.left_layout.addWidget(info_widget) 1303 | #main_widget.left_layout.addWidget(status_bar) 1304 | #main_widget.layout().addWidget(ui_toplevel.settings_widget, 2, 0) 1305 | main_widget.right_splitter.addWidget(text_edit_0) 1306 | main_widget.right_splitter.addWidget(ui_toplevel.settings_widget) 1307 | main_widget.right_layout.addWidget(status_bar) 1308 | #main_widget.layout().addWidget(text_edit_1, 1, 1) 1309 | 1310 | main_widget.resize(COLUMN_WIDTH * 4, 600) 1311 | 1312 | main_widget.finalize() 1313 | 1314 | ui_toplevel.main_widget = main_widget 1315 | 1316 | ui_toplevel.main_window = main_window 1317 | if node.code_element.name != 'Root': 1318 | ui_toplevel.main_window.setWindowTitle(node.code_element.name) 1319 | else: 1320 | ui_toplevel.main_window.setWindowTitle('Call Map') 1321 | 1322 | QtWidgets.qApp.setWindowIcon(QtGui.QIcon( 1323 | str(Path(__file__).parent.joinpath('icons/cruciform-by-andylee.png')))) 1324 | 1325 | original_window_flags = ui_toplevel.main_window.windowFlags() 1326 | 1327 | if show_gui: 1328 | MAXIMIZE_ON_START = True 1329 | # the following is a workaround to fix window size when maximizing 1330 | if MAXIMIZE_ON_START: 1331 | ui_toplevel.main_window.setWindowFlags(Qt.Qt.FramelessWindowHint) 1332 | ui_toplevel.main_window.setWindowState(Qt.Qt.WindowFullScreen) 1333 | ui_toplevel.main_window.setVisible(True) 1334 | 1335 | ui_toplevel.main_window.setWindowFlags(original_window_flags) 1336 | ui_toplevel.main_window.setWindowState(Qt.Qt.WindowMaximized) 1337 | ui_toplevel.main_window.setVisible(True) 1338 | else: 1339 | ui_toplevel.main_window.resize(1800, 1000) 1340 | ui_toplevel.main_window.show() 1341 | 1342 | map_widget.callLists[0].setFocus() 1343 | 1344 | def customFullScreen(self): 1345 | # experimental 1346 | self.setWindowFlags(Qt.Qt.FramelessWindowHint) 1347 | self.setWindowState(Qt.Qt.WindowFullScreen) 1348 | self.show() 1349 | 1350 | def cancelFullScreen(self): 1351 | self.setWindowFlags(original_window_flags) 1352 | self.show() 1353 | 1354 | ui_toplevel.main_window.customFullScreen = customFullScreen.__get__(ui_toplevel.main_window) 1355 | ui_toplevel.main_window.cancelFullScreen = cancelFullScreen.__get__(ui_toplevel.main_window) 1356 | 1357 | return ui_toplevel 1358 | 1359 | 1360 | def _resolve_robustly(str_paths: List[str]): 1361 | import os.path 1362 | paths = [] 1363 | for ff in str_paths: 1364 | try: 1365 | pp = Path(ff).resolve() 1366 | except FileNotFoundError as err: 1367 | logger.error('Could not resolve {} from {}'.format(ff, os.path.realpath('.'))) 1368 | else: 1369 | paths.append(pp) 1370 | 1371 | return paths 1372 | 1373 | 1374 | def main(): 1375 | import argparse 1376 | 1377 | parser = argparse.ArgumentParser( 1378 | description='Create root node from filename contents') 1379 | parser.add_argument('-m', '--modules', metavar='M', type=str, nargs='+', 1380 | help='Modules (e.g. "os.path").', 1381 | default=[]) 1382 | parser.add_argument('-f', '--files', metavar='F', type=str, nargs='+', 1383 | help='Script or module file names.', 1384 | default=[]) 1385 | parser.add_argument('-p', '--add-to-sys-path', metavar='P', type=str, 1386 | nargs='+', help='''Directories to add to the analysis 1387 | Python module search path ("sys_path"), where modules 1388 | will be found during analysis. By default the Python 1389 | interpreter's `sys.path` is included. Note that the 1390 | module resolution order in Python is first match; earlier items in 1391 | `sys_path` have higher priority.''', default=[]) 1392 | parser.add_argument('-d', '--project-directory', metavar='PROJ_DIR', action='store', type=Path, 1393 | default=None, 1394 | help=('''Where to store `call_map` bookmarks, modules, sys_path, etc. 1395 | If not set, these will not be saved.''')) 1396 | parser.add_argument('--no-interpreter-sys-path', action='store_true', 1397 | help='''Tells `call_map` not explicitly include the 1398 | `sys.path` from the interpreter in the Python module 1399 | search path. (Note that the analysis backend `jedi` as 1400 | of v0.10.0 will still fall back to the interpreter's 1401 | `sys.path` if it cannot resolve modules using the 1402 | `sys_path` that `call_map` passes to it.) 1403 | ''') 1404 | parser.add_argument('--ipython', action='store_true', help='''Enables 1405 | IPython integration. See 1406 | `dev_helper_tools/shell_tools.zsh` in the `call_map` 1407 | source tree.''') 1408 | parser.add_argument('-v', '--verbose', action='store_true', help='''Increase 1409 | logging verbosity.''') 1410 | parser.add_argument('--version', action='store_true', help='''Print version and exit.''') 1411 | 1412 | args = parser.parse_args() 1413 | 1414 | if args.version: 1415 | from . import version 1416 | print(version) 1417 | exit() 1418 | 1419 | if args.verbose: 1420 | logging.basicConfig(level=logging.INFO) 1421 | else: 1422 | logging.basicConfig(level=get_user_config()['LOG_LEVEL']) 1423 | 1424 | module_names = args.modules 1425 | 1426 | file_names = _resolve_robustly(args.files) # type: List[Path] 1427 | 1428 | additional_paths = _resolve_robustly(args.add_to_sys_path) # type: List[Path] 1429 | 1430 | user_scope_settings = UserScopeSettings( 1431 | module_names=module_names, 1432 | file_names=file_names, 1433 | include_runtime_sys_path=(not args.no_interpreter_sys_path), 1434 | add_to_sys_path=additional_paths) 1435 | 1436 | try: 1437 | ui_toplevel = make_app(user_scope_settings, project_directory=args.project_directory) 1438 | runtime_sys_modules[ui_toplevel.__name__] = ui_toplevel 1439 | enable_ipython_support = args.ipython 1440 | 1441 | if enable_ipython_support: 1442 | # Previously this code was to stop segfault on exit. 1443 | # It was tentatively removed around v0.2.0 due to improvements in ipython apparently obviating it. 1444 | #import atexit 1445 | #atexit.register(ui_toplevel.main_window.deleteLater) 1446 | pass 1447 | else: 1448 | exit(ui_toplevel.app.exec_()) 1449 | 1450 | except (BadArgsError, ModuleResolutionError) as err: 1451 | if not args.ipython: 1452 | logger.error(err) 1453 | exit(1) 1454 | else: 1455 | raise 1456 | -------------------------------------------------------------------------------- /call_map/icons/cruciform-by-andylee.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nccgroup/call_map/8f9b41260f654595e7520fade107d018ea011c0d/call_map/icons/cruciform-by-andylee.png -------------------------------------------------------------------------------- /call_map/icons/statement-of-permission.txt: -------------------------------------------------------------------------------- 1 | In this document, "Call Map" refers to the program that was authored by Andrew 2 | J. Lee under employment of NCC Group PLC during the years 2016 and 2017, and 3 | bears the name "Call Map". The associated artifacts of "Call Map" are its 4 | source code, package specification code, user documentation, and developer 5 | documentation. 6 | 7 | Andrew J. Lee, henceforth referred to as THE LICENSOR, is the creator and 8 | copyright owner of the artwork contained in the file by the name of 9 | "cruciform-by-andylee.png" and having the SHA-256 value of 10 | 616283e2683bbb52e9ef18ed83d7844630c143a7006a99942cb81795db274d28, henceforth 11 | referred to as THE ARTWORK. 12 | 13 | THE LICENSOR grants nonexclusive permission to NCC Group PLC to display THE 14 | ARTWORK in association with the program "Call Map" and to include THE ARTWORK 15 | while distributing the program "Call Map" and the associated artifacts of the 16 | program "Call Map". 17 | -------------------------------------------------------------------------------- /call_map/jedi_alt/JEDI_LICENSE.txt: -------------------------------------------------------------------------------- 1 | All contributions towards Jedi are MIT licensed. 2 | 3 | Some Python files have been taken from the standard library and are therefore 4 | PSF licensed. Modifications on these files are dual licensed (both MIT and 5 | PSF). These files are: 6 | 7 | - jedi/parser/pgen2 8 | - jedi/parser/tokenize.py 9 | - jedi/parser/token.py 10 | - test/test_parser/test_pgen2.py 11 | 12 | ------------------------------------------------------------------------------- 13 | The MIT License (MIT) 14 | 15 | Copyright (c) <2013> 16 | 17 | Permission is hereby granted, free of charge, to any person obtaining a copy 18 | of this software and associated documentation files (the "Software"), to deal 19 | in the Software without restriction, including without limitation the rights 20 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 21 | copies of the Software, and to permit persons to whom the Software is 22 | furnished to do so, subject to the following conditions: 23 | 24 | The above copyright notice and this permission notice shall be included in 25 | all copies or substantial portions of the Software. 26 | 27 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 28 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 29 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 30 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 31 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 32 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 33 | THE SOFTWARE. 34 | ------------------------------------------------------------------------------- 35 | 36 | PYTHON SOFTWARE FOUNDATION LICENSE VERSION 2 37 | -------------------------------------------- 38 | 39 | 1. This LICENSE AGREEMENT is between the Python Software Foundation 40 | ("PSF"), and the Individual or Organization ("Licensee") accessing and 41 | otherwise using this software ("Python") in source or binary form and 42 | its associated documentation. 43 | 44 | 2. Subject to the terms and conditions of this License Agreement, PSF hereby 45 | grants Licensee a nonexclusive, royalty-free, world-wide license to reproduce, 46 | analyze, test, perform and/or display publicly, prepare derivative works, 47 | distribute, and otherwise use Python alone or in any derivative version, 48 | provided, however, that PSF's License Agreement and PSF's notice of copyright, 49 | i.e., "Copyright (c) 2001, 2002, 2003, 2004, 2005, 2006, 2007, 2008, 2009, 2010, 50 | 2011, 2012, 2013, 2014, 2015 Python Software Foundation; All Rights Reserved" 51 | are retained in Python alone or in any derivative version prepared by Licensee. 52 | 53 | 3. In the event Licensee prepares a derivative work that is based on 54 | or incorporates Python or any part thereof, and wants to make 55 | the derivative work available to others as provided herein, then 56 | Licensee hereby agrees to include in any such work a brief summary of 57 | the changes made to Python. 58 | 59 | 4. PSF is making Python available to Licensee on an "AS IS" 60 | basis. PSF MAKES NO REPRESENTATIONS OR WARRANTIES, EXPRESS OR 61 | IMPLIED. BY WAY OF EXAMPLE, BUT NOT LIMITATION, PSF MAKES NO AND 62 | DISCLAIMS ANY REPRESENTATION OR WARRANTY OF MERCHANTABILITY OR FITNESS 63 | FOR ANY PARTICULAR PURPOSE OR THAT THE USE OF PYTHON WILL NOT 64 | INFRINGE ANY THIRD PARTY RIGHTS. 65 | 66 | 5. PSF SHALL NOT BE LIABLE TO LICENSEE OR ANY OTHER USERS OF PYTHON 67 | FOR ANY INCIDENTAL, SPECIAL, OR CONSEQUENTIAL DAMAGES OR LOSS AS 68 | A RESULT OF MODIFYING, DISTRIBUTING, OR OTHERWISE USING PYTHON, 69 | OR ANY DERIVATIVE THEREOF, EVEN IF ADVISED OF THE POSSIBILITY THEREOF. 70 | 71 | 6. This License Agreement will automatically terminate upon a material 72 | breach of its terms and conditions. 73 | 74 | 7. Nothing in this License Agreement shall be deemed to create any 75 | relationship of agency, partnership, or joint venture between PSF and 76 | Licensee. This License Agreement does not grant permission to use PSF 77 | trademarks or trade name in a trademark sense to endorse or promote 78 | products or services of Licensee, or any third party. 79 | 80 | 8. By copying, installing or otherwise using Python, Licensee 81 | agrees to be bound by the terms and conditions of this License 82 | Agreement. 83 | -------------------------------------------------------------------------------- /call_map/jedi_alt/__init__.py: -------------------------------------------------------------------------------- 1 | from . import usages 2 | -------------------------------------------------------------------------------- /call_map/jedi_alt/api_usages.py: -------------------------------------------------------------------------------- 1 | """ 2 | Modified version of `jedi.api.usages` from `jedi` v0.10.0. 3 | 4 | - Removed :func:`resolve_potential_imports`. 5 | - Added `compare_contexts` and used it in `usages`. (This was merged upstream.) 6 | - Caught and logged errors in loop over usage items. This makes it so that if 7 | one item raises an error, the user can still see other usages. (This will not 8 | be merged upstream. Catching non-top-level errors is frowned upon in Jedi.) 9 | 10 | """ 11 | 12 | from jedi.api import classes 13 | from jedi.parser import tree 14 | from jedi.evaluate import imports 15 | from jedi.evaluate.filters import TreeNameDefinition 16 | from jedi.evaluate.representation import ModuleContext 17 | import logging 18 | 19 | logger = logging.getLogger(__name__) 20 | 21 | 22 | def compare_contexts(c1, c2): 23 | return c1 == c2 or (c1[1] == c2[1] and c1[0].tree_node == c2[0].tree_node) 24 | 25 | 26 | def usages(evaluator, definition_names, mods): 27 | """ 28 | :param definitions: list of Name 29 | """ 30 | def resolve_names(definition_names): 31 | for name in definition_names: 32 | if name.api_type == 'module': 33 | found = False 34 | for context in name.infer(): 35 | found = True 36 | yield context.name 37 | if not found: 38 | yield name 39 | else: 40 | yield name 41 | 42 | def compare_array(definition_names): 43 | """ `definitions` are being compared by module/start_pos, because 44 | sometimes the id's of the objects change (e.g. executions). 45 | """ 46 | return [ 47 | (name.get_root_context(), name.start_pos) 48 | for name in resolve_names(definition_names) 49 | ] 50 | 51 | search_name = list(definition_names)[0].string_name 52 | compare_definitions = compare_array(definition_names) 53 | mods = mods | set([d.get_root_context() for d in definition_names]) 54 | definition_names = set(resolve_names(definition_names)) 55 | for m in imports.get_modules_containing_name(evaluator, mods, search_name): 56 | if isinstance(m, ModuleContext): 57 | for name_node in m.tree_node.used_names.get(search_name, []): 58 | context = evaluator.create_context(m, name_node) 59 | try: 60 | result = evaluator.goto(context, name_node) 61 | except (NotImplementedError, RecursionError) as err: 62 | logger.error(err) 63 | continue 64 | if any(compare_contexts(c1, c2) 65 | for c1 in compare_array(result) 66 | for c2 in compare_definitions): 67 | name = TreeNameDefinition(context, name_node) 68 | definition_names.add(name) 69 | # Previous definitions might be imports, so include them 70 | # (because goto might return that import name). 71 | compare_definitions += compare_array([name]) 72 | else: 73 | # compiled objects 74 | definition_names.add(m.name) 75 | 76 | return [classes.Definition(evaluator, n) for n in definition_names] 77 | -------------------------------------------------------------------------------- /call_map/jedi_alt/monkey_patch_evaluate_compiled.py: -------------------------------------------------------------------------------- 1 | """ 2 | Sometimes the unimplemented method `jedi.evaluate.CompiledObject.dict_values` is called. 3 | TODO: make tests and push upstream 4 | """ 5 | 6 | import logging 7 | from jedi.evaluate import CompiledObject 8 | 9 | def do_monkey_patch(): 10 | def _dict_values(self): 11 | logging.getLogger(__name__).warning( 12 | "Monkey patched function `jedi.evaluate.CompiledObject.dict_values` called.") 13 | return set([create(self.evaluator, val) for val in self.obj.values()]) 14 | 15 | CompiledObject.dict_values = _dict_values 16 | -------------------------------------------------------------------------------- /call_map/jedi_alt/stop_signal.py: -------------------------------------------------------------------------------- 1 | from jedi.evaluate.recursion import ExecutionRecursionDetector 2 | 3 | try: 4 | from queue import Queue 5 | except ImportError: 6 | # Python 2 shim 7 | from Queue import Queue 8 | 9 | stop_execution_signal_queue = Queue(maxsize=1) 10 | """ 11 | Allows a controller thread to cause Jedi to abort execution. 12 | `ExecutionRecursionDetector.push_execution` will raise `StopExecutionException` 13 | if the `stop_execution_signal_queue` is not empty. 14 | """ 15 | 16 | class StopExecutionException(Exception): 17 | """Raised when Jedi aborts execution""" 18 | pass 19 | 20 | 21 | def poll_and_handle_stop_execution_signal(): 22 | if not stop_execution_signal_queue.empty(): 23 | stop_execution_signal_queue.get() 24 | raise StopExecutionException('Received signal to stop execution.') 25 | 26 | def poll_and_handle_stop_execution_signal_at_start(function): 27 | def wrapper(obj, *args, **kwargs): 28 | poll_and_handle_stop_execution_signal() 29 | return function(obj, *args, **kwargs) 30 | return wrapper 31 | 32 | 33 | # Monkey patch jedi. 34 | # The pull request to push this functionality upstream was rejected. 35 | # See https://github.com/davidhalter/jedi/pull/898 36 | ExecutionRecursionDetector.push_execution = poll_and_handle_stop_execution_signal_at_start(ExecutionRecursionDetector.push_execution) 37 | -------------------------------------------------------------------------------- /call_map/jedi_alt/usages.py: -------------------------------------------------------------------------------- 1 | from typing import List, Tuple 2 | import logging 3 | 4 | import jedi 5 | import jedi.api 6 | 7 | from jedi._compatibility import unicode 8 | from jedi.api import classes 9 | 10 | from jedi.evaluate import imports 11 | from jedi.evaluate.filters import TreeNameDefinition 12 | from jedi.evaluate.representation import ModuleContext 13 | 14 | from ..config import get_user_config 15 | 16 | if tuple(map(int, jedi.__version__.split('.'))) >= (0,10,1): 17 | from jedi.parser.python.tree import Import as tree_Import 18 | else: 19 | from jedi.parser.tree import Import as tree_Import 20 | 21 | 22 | PROFILING = get_user_config()['PROFILING'] 23 | 24 | 25 | def usages_with_additional_modules(script: jedi.api.Script, 26 | additional_module_contexts: Tuple[ModuleContext] = ()): 27 | """ 28 | Based on `jedi.api.Script.usages`, except `additional_modules` are also searched 29 | 30 | Forked from `jedi.api.Script.usages` on 2017-02-02. 31 | 32 | Return :class:`classes.Definition` objects, which contain all 33 | names that point to the definition of the name under the cursor. This 34 | is very useful for refactoring (renaming), or to show all usages of a 35 | variable. 36 | 37 | .. todo:: Implement additional_module_paths 38 | 39 | :rtype: list of :class:`classes.Definition` 40 | """ 41 | from jedi import settings 42 | from jedi.api import usages 43 | from jedi.api import helpers 44 | from . import api_usages as alt_api_usages 45 | 46 | self = script 47 | 48 | temp, settings.dynamic_flow_information = \ 49 | settings.dynamic_flow_information, False 50 | try: 51 | module_node = self._get_module_node() 52 | user_stmt = module_node.get_statement_for_position(self._pos) 53 | definition_names = self._goto() 54 | 55 | #assert not definition_names 56 | if not definition_names and isinstance(user_stmt, tree_Import): 57 | # For not defined imports (goto doesn't find something, we take 58 | # the name as a definition. This is enough, because every name 59 | # points to it. 60 | name = user_stmt.name_for_position(self._pos) 61 | if name is None: 62 | # Must be syntax 63 | return [] 64 | definition_names = [TreeNameDefinition(self._get_module(), name)] 65 | 66 | if not definition_names: 67 | # Without a definition for a name we cannot find references. 68 | return [] 69 | 70 | definition_names = usages.resolve_potential_imports(self._evaluator, 71 | definition_names) 72 | 73 | modules = set([d.get_root_context() for d in definition_names]) 74 | modules.add(self._get_module()) 75 | for additional_module_context in additional_module_contexts: 76 | modules.add(additional_module_context) 77 | definitions = alt_api_usages.usages(self._evaluator, definition_names, modules) 78 | finally: 79 | settings.dynamic_flow_information = temp 80 | 81 | return helpers.sorted_definitions(set(definitions)) 82 | 83 | 84 | if PROFILING: 85 | try: 86 | from profilehooks import profile 87 | except ImportError: 88 | logging.getLogger(__name__).error('Failed to start with profiler; please install `profilehooks`.') 89 | 90 | usages_with_additional_modules = profile(usages_with_additional_modules, dirs=True, immediate=True) 91 | -------------------------------------------------------------------------------- /call_map/jedi_ast_tools.py: -------------------------------------------------------------------------------- 1 | import jedi 2 | from typing import Tuple 3 | 4 | 5 | if tuple(map(int, jedi.__version__.split('.'))) >= (0,10,1): 6 | jedi_python_tree = jedi.parser.python.tree 7 | else: 8 | jedi_python_tree = jedi.parser.tree 9 | jedi.parser.tree.Node.get_first_leaf = jedi.parser.tree.Node.first_leaf 10 | jedi.parser.tree.Node.get_last_leaf = jedi.parser.tree.Node.last_leaf 11 | 12 | 13 | def leaves(node: jedi.parser.tree.Node): 14 | current_leaf = node.get_first_leaf() 15 | last_leaf = node.get_last_leaf() 16 | 17 | while True: 18 | yield current_leaf 19 | 20 | if current_leaf is last_leaf: 21 | break 22 | else: 23 | current_leaf = current_leaf.get_next_leaf() 24 | 25 | 26 | def walk_nodes(node: jedi.parser.tree.BaseNode): 27 | node = getattr(node, 'base', node) 28 | 29 | for elt in node.children: 30 | if isinstance(elt, jedi.parser.tree.BaseNode): 31 | yield elt 32 | yield from walk_nodes(elt) 33 | 34 | 35 | def walk_nodes_while_staying_in_scope(node: jedi.parser.tree.BaseNode): 36 | node = getattr(node, 'base', node) 37 | 38 | 39 | for elt in node.children: 40 | if isinstance(elt, jedi.parser.tree.BaseNode): 41 | yield elt 42 | if not isinstance(elt, jedi_python_tree.ClassOrFunc): 43 | yield from walk_nodes_while_staying_in_scope(elt) 44 | 45 | 46 | def is_call_trailer(node: jedi.parser.tree.Node): 47 | """Whether the node is a trailer bounded by parens""" 48 | 49 | if node.type == 'trailer': 50 | fl = node.get_first_leaf() 51 | ll = node.get_last_leaf() 52 | 53 | return (type(fl) is jedi_python_tree.Operator 54 | and fl.value == '(' 55 | and type(ll) is jedi_python_tree.Operator 56 | and ll.value == ')') 57 | 58 | else: 59 | return False 60 | 61 | 62 | def _maybe_getattr_chain(obj, *attrs): 63 | for attr in attrs: 64 | obj = getattr(obj, attr, obj) 65 | 66 | return obj 67 | 68 | 69 | def decorator_name(node: jedi.parser.tree.BaseNode): 70 | child_1 = node.children[1] 71 | if isinstance(child_1, jedi_python_tree.Name): 72 | return child_1 73 | else: 74 | name = child_1.children[-1] 75 | assert isinstance(name, jedi_python_tree.Name) 76 | return name 77 | 78 | 79 | def vec_add(pos0: Tuple[int, int], pos1: Tuple[int, int]): 80 | return (pos0[0] + pos1[0], pos0[1] + pos1[1]) 81 | 82 | 83 | def get_called_functions(node: jedi.parser.tree.BaseNode): 84 | """Yield AST leaves that represent called functions.""" 85 | 86 | _abort = (type(_maybe_getattr_chain(node, 'base', 'var')) 87 | is jedi.evaluate.compiled.CompiledObject) 88 | 89 | if not _abort: 90 | for child in walk_nodes_while_staying_in_scope(node): 91 | if isinstance(child, jedi_python_tree.ClassOrFunc): 92 | name = child.name 93 | 94 | if isinstance(child, jedi_python_tree.Lambda): 95 | loc = (vec_add((-0,0), child.start_pos), 96 | vec_add((-0,0), child.end_pos)) 97 | else: 98 | loc = ((child.start_pos[0], name.start_pos[1]), name.end_pos) 99 | 100 | yield ('definition', name, child) + loc 101 | elif is_call_trailer(child): 102 | name = child.get_previous_leaf() 103 | yield 'child', name, child, name.start_pos, name.end_pos 104 | elif isinstance(child, jedi_python_tree.Decorator): 105 | name = decorator_name(child) 106 | yield 'child', name, child, name.start_pos, name.end_pos 107 | 108 | 109 | def parent_scope_of_usage(usage: jedi.api.classes.Definition) -> jedi.api.classes.Definition: 110 | # Like Definition.parent() but skips nameless parents. 111 | # Test case: jeid.evaluate.docstrings._evaluate_for_statement_string. 112 | try: 113 | return usage.parent() 114 | except AttributeError: 115 | _name = usage._name 116 | while not hasattr(_name, 'name'): 117 | _name = _name.get_parent_scope() 118 | return jedi.api.classes.Definition(usage._evaluator, usage._evaluator.wrap(_name).name) 119 | 120 | 121 | def _convert_FunctionExecutionContext_to_FunctionContext(_evaluator, context): 122 | '''Helper copied from `jedi.api.classes.Definition.parent` for `parent_definition`''' 123 | 124 | from jedi.evaluate import representation as er 125 | 126 | if isinstance(context, er.FunctionExecutionContext): 127 | # TODO the function context should be a part of the function 128 | # execution context. 129 | context = er.FunctionContext( 130 | _evaluator, context.parent_context, context.tree_node) 131 | 132 | return context 133 | 134 | 135 | def parent_definition(definition: jedi.api.classes.Definition) -> jedi.api.classes.Definition: 136 | '''Robust version of `jedi.api.classes.Denition.parent`''' 137 | context = _convert_FunctionExecutionContext_to_FunctionContext(definition._evaluator, 138 | definition._name.parent_context) 139 | if context is None: 140 | return definition 141 | 142 | while not hasattr(context, 'name'): 143 | new_context = _convert_FunctionExecutionContext_to_FunctionContext(definition._evaluator, 144 | context.parent_context) 145 | if context is not None: 146 | context = new_context 147 | else: 148 | break 149 | 150 | return jedi.api.classes.Definition(definition._evaluator, context.name) 151 | -------------------------------------------------------------------------------- /call_map/jedi_dump.py: -------------------------------------------------------------------------------- 1 | """ 2 | *Warning* this module uses jedi internals and is highly dependent on the jedi 3 | version. Please refer to jedi-requirements.txt in the toplevel of the repo for 4 | the version this commit is compatible with. 5 | 6 | """ 7 | 8 | import logging 9 | import pprint, textwrap 10 | from sys import path as actual_sys_path 11 | 12 | from typing import List, Dict, Tuple, Callable, Any, Optional 13 | from pathlib import Path 14 | 15 | from .core import CodeElement, Node, OrganizerNode, UserScopeSettings, ScopeSettings 16 | from . import config 17 | from . import jedi_alt 18 | 19 | import toolz as tz 20 | 21 | import jedi 22 | from .jedi_ast_tools import get_called_functions, parent_definition 23 | from .jedi_alt.stop_signal import stop_execution_signal_queue, StopExecutionException 24 | 25 | logger = logging.getLogger(__name__) 26 | 27 | 28 | def catch_errors(thunk: Callable, default: Any, post_message: str): 29 | try: 30 | return thunk() 31 | except StopExecutionException as exc: 32 | logger.info('{}; {}'.format(exc, post_message)) 33 | return default 34 | except Exception as exc: 35 | logger.error('{}; {}'.format(exc, post_message), exc_info=config.get_user_config()['EXC_INFO']) 36 | #import traceback 37 | #logger.error(traceback.format_exc()) 38 | return default 39 | 40 | 41 | def filter_nodes(nodes): 42 | for node in nodes: 43 | try: 44 | skip = (node.code_element.name in 45 | config.py_ignore.get(node.code_element.module, ())) 46 | except AttributeError: 47 | skip = False 48 | 49 | if skip: 50 | continue 51 | else: 52 | yield node 53 | 54 | 55 | def _cleanup_signal_queue(): 56 | '''Cleans signal queue in case reference resolution finished successfully 57 | 58 | Call after finishing reference resolution searches. This cleans up any 59 | leftover signals, in case a "stop" signal was issued, but the resolution 60 | search actually completed successfully. 61 | 62 | ''' 63 | 64 | while not stop_execution_signal_queue.empty(): 65 | stop_execution_signal_queue.get() 66 | 67 | 68 | class UsageFilter(jedi.evaluate.filters.ParserTreeFilter): 69 | def _is_name_reachable(self, name): 70 | #print(name, name.parent.type) 71 | parent = name.parent 72 | if parent.type in ('decorator', 'atom_expr', 'trailer'): 73 | return True 74 | 75 | if not name.is_definition(): 76 | return False 77 | 78 | if parent.type == 'trailer': 79 | return True 80 | base_node = parent if parent.type in ('classdef', 'funcdef') else name 81 | return base_node.get_parent_scope() == self._parser_scope 82 | 83 | 84 | class JediCodeElementNode(Node): 85 | usage_resolution_modules = frozenset() # where to search for usages 86 | sys_path = [] 87 | 88 | def __init__(self, code_element: CodeElement, definition: jedi.api.classes.Definition): 89 | """The parents call the node, children are called by the node. 90 | 91 | If not callable, the Node has no children 92 | """ 93 | self.code_element = code_element 94 | self.definition = definition 95 | 96 | @property 97 | def module_context(self): 98 | if self.definition: 99 | return self.definition._name.get_root_context() 100 | else: 101 | return None 102 | 103 | def __repr__(self): 104 | return '<{}({}, call_pos={})>'.format(self.__class__.__name__, self.code_element.name, self.code_element.call_pos) 105 | 106 | @property 107 | def parents(self): 108 | # Note: the last created Script object appears to bork the older ones. Must keep making new Script objects! 109 | 110 | # Note: jedi appears to already do enough caching. It does not significantly 111 | # improve performance to cache the parents. 112 | 113 | #acceptable_name_types = (jedi.parser.tree.Name, 114 | # jedi.evaluate.representation.InstanceElement) 115 | 116 | if self.definition and self.definition.module_path: 117 | script = jedi.api.Script(source_path=self.definition.module_path, 118 | sys_path=self.definition._evaluator.sys_path, 119 | line=self.definition.line, 120 | column=self.definition.column) 121 | 122 | usages = catch_errors(tz.partial(jedi_alt.usages.usages_with_additional_modules, 123 | script, 124 | self.usage_resolution_modules), 125 | [], 126 | 'while finding usages of {}'.format(self.code_element.name)) 127 | 128 | elif self.code_element.call_pos[0]: 129 | call_pos_script = jedi.api.Script(source_path=self.code_element.call_pos[0], 130 | sys_path=self.definition._evaluator.sys_path if self.definition else self.sys_path, 131 | line=self.code_element.call_pos[1][0], 132 | column=self.code_element.call_pos[1][1]) 133 | 134 | usages = catch_errors(tz.partial(jedi_alt.usages.usages_with_additional_modules, 135 | call_pos_script, 136 | self.usage_resolution_modules), 137 | [], 138 | 'while finding usages of {}'.format(self.code_element.name)) 139 | 140 | elif self.definition: 141 | script = create_import_script(self.definition._evaluator.sys_path if self.definition else self.sys_path, 142 | self.code_element.name) 143 | 144 | usages = [ 145 | usage for usage in 146 | catch_errors(tz.partial(jedi_alt.usages.usages_with_additional_modules, 147 | script, 148 | self.usage_resolution_modules), 149 | [], 150 | 'while finding usages of {}'.format(self.code_element.name)) 151 | if usage.module_name] 152 | 153 | else: 154 | return () 155 | 156 | _unfiltered_parents = [] 157 | positions = set() 158 | 159 | for usage in usages: 160 | tree_name = usage._name.tree_name 161 | if tree_name: 162 | position = (usage.module_path, tree_name.start_pos, tree_name.end_pos) 163 | else: 164 | position = (None, (None, None), (None, None)) 165 | 166 | if position not in positions or position == (None, (None, None), (None, None)): 167 | _usage_parent = parent_definition(usage) 168 | 169 | if _usage_parent.module_path: 170 | JediCodeElementNode.usage_resolution_modules |= frozenset((_usage_parent._name.get_root_context(),)) 171 | 172 | usage_node = JediCodeElementNode.from_definition( 173 | 'parent', position, _usage_parent) 174 | 175 | # check if this usage is actually the definition of the 176 | # current node, and is therefore already covered by the 177 | # "- [sig]" node. 178 | if (usage_node.code_element.call_pos[0] == self.code_element.path 179 | and usage_node.code_element.call_pos[1] == self.code_element.start_pos 180 | and usage_node.code_element.type == 'module'): 181 | 182 | logger.info('Usages: Skipped definition of {} at {}:{}.' 183 | .format(self.code_element.name, 184 | usage_node.code_element.name, 185 | usage_node.code_element.call_pos[1][0])) 186 | continue 187 | else: 188 | _unfiltered_parents.append(usage_node) 189 | positions.add(position) 190 | 191 | _cleanup_signal_queue() 192 | 193 | return _unfiltered_parents 194 | 195 | 196 | @property 197 | def children(self): 198 | if self.definition: 199 | 200 | ## If self is a package, yield submodules/subpackages 201 | if self.code_element.type == 'module' and self.code_element.path: 202 | _pp = Path(self.code_element.path) 203 | 204 | sys_path = self.definition._evaluator.sys_path 205 | 206 | is_pkg = (self.code_element.name != _pp.stem 207 | and self.code_element.name == _pp.parent.stem 208 | and _pp.stem == '__init__') 209 | 210 | if is_pkg: 211 | 212 | submodules = [path_to_module_name(sys_path, str(_m)) for _m in _pp.parent.glob('*.py') 213 | if _m != _pp] 214 | 215 | subdirs = [_dd for _dd in _pp.parent.iterdir() 216 | if _dd.is_dir() and _dd.name != '__pycache__'] 217 | 218 | subpkgs = [_dd for _dd in subdirs if _dd.joinpath('__init__.py').exists()] 219 | 220 | if len(subpkgs) < len(subdirs): 221 | logger.warning('Package `{}` contains subdirectories that are not packages:\n{}'.format( 222 | self.code_element.name, 223 | textwrap.indent( 224 | pprint.pformat([str(_dd.stem) for _dd in set(subdirs) - set(subpkgs)]), 225 | ' ' * 4))) 226 | 227 | names = submodules + [path_to_module_name(sys_path, str(_dd)) for _dd in subpkgs] 228 | 229 | for _name in names: 230 | _node, _err = get_module_node(sys_path, _name) 231 | try: 232 | yield _node 233 | except KeyError: 234 | continue 235 | 236 | if config.get_user_config()['EXPERIMENTAL_MODE']: 237 | yield from experimental_definitions_of_called_objects(self.definition) 238 | else: 239 | tree_name = self.definition._name.tree_name 240 | if not tree_name: 241 | for inferred in self.definition._name.infer(): 242 | tree_name = inferred.tree_node 243 | if tree_name: 244 | break 245 | 246 | if tree_name: 247 | tree_definition = tree_name.get_definition() 248 | 249 | path = self.definition.module_path 250 | _unfiltered = definitions_of_called_objects(self.definition._evaluator, tree_definition, path) 251 | 252 | yield from filter_nodes(_unfiltered) 253 | 254 | _cleanup_signal_queue() 255 | 256 | @staticmethod 257 | def cancel_search(): 258 | if stop_execution_signal_queue.empty(): 259 | stop_execution_signal_queue.put(1) 260 | 261 | @classmethod 262 | def from_definition(cls, role, call_pos, definition): 263 | #if not isinstance(definition._name, jedi.parser.tree.Name): 264 | # name = definition._name.name 265 | #else: 266 | # name = definition._name 267 | 268 | start_pos = (definition.line, definition.column) 269 | if definition._name.tree_name: 270 | end_pos = definition._name.tree_name.end_pos or (None, None) 271 | else: 272 | end_pos = (None, None) 273 | 274 | code_element = CodeElement( 275 | name=definition.name, 276 | type=definition.type, 277 | module=definition.module_name, 278 | role=role, 279 | path=definition.module_path, 280 | call_pos=call_pos, 281 | start_pos=start_pos, 282 | end_pos=end_pos, 283 | ) 284 | 285 | return cls(code_element, definition) 286 | 287 | def with_new_role(self, role): 288 | if role == 'signature': 289 | new_call_pos = (self.code_element.path, self.code_element.start_pos, self.code_element.end_pos) 290 | new_code_element = self.code_element._replace(role=role, call_pos=new_call_pos) 291 | return __class__(new_code_element, self.definition) 292 | else: 293 | raise NotImplementedError 294 | 295 | 296 | def definitions_of_called_objects(evaluator: jedi.evaluate.Evaluator, 297 | definition: jedi.parser.tree.BaseNode, 298 | path: str): 299 | 300 | for role, fn, ast_node, call_start_pos, call_end_pos in get_called_functions(definition): 301 | #try: 302 | # defs = list(jedi.api.helpers.evaluate_goto_definition(evaluator, fn)) 303 | #except AttributeError: 304 | # defs = list() 305 | 306 | code = ast_node.get_root_node().get_code() 307 | script = jedi.api.Script(source=code, source_path=path, 308 | sys_path=evaluator.sys_path, 309 | line=call_start_pos[0], 310 | column=call_start_pos[1]) 311 | 312 | defs = (catch_errors(script.goto_definitions, [], 'while finding definitions of {}'.format(fn)) 313 | or catch_errors(script.goto_assignments, [], 'while finding assignments of {}'.format(fn))) 314 | 315 | found = set() 316 | 317 | call_pos = (path, call_start_pos, call_end_pos) 318 | 319 | for ii, def_ in enumerate(defs): 320 | #def_ = defs[-1] # should be jedi.evaluate.representation.Function 321 | 322 | assert isinstance(def_, jedi.api.classes.Definition) 323 | 324 | module = def_._module 325 | called_fn_def = def_ 326 | base_name = def_.name 327 | try: 328 | start_pos, end_pos = (def_._name.tree_name.start_pos, 329 | def_._name.tree_name.end_pos) 330 | except AttributeError: 331 | start_pos, end_pos = ((None, None), (None, None)) 332 | 333 | defined_at = (def_.module_path, start_pos, end_pos) 334 | 335 | if (base_name, defined_at) in found: 336 | continue 337 | else: 338 | found.add((base_name, defined_at)) 339 | 340 | if ii > 0: 341 | name = base_name + ' ({})'.format(ii + 1) 342 | else: 343 | name = base_name 344 | 345 | code_element = CodeElement( 346 | name=name, 347 | type=called_fn_def.type, 348 | module=module.name.string_name, 349 | role = role, 350 | path = defined_at[0], 351 | call_pos = call_pos, 352 | start_pos=defined_at[1], 353 | end_pos=defined_at[2], 354 | ) 355 | 356 | yield JediCodeElementNode(code_element, called_fn_def) 357 | 358 | if len(defs) == 0: 359 | logging.getLogger(__name__).debug( 360 | ' Cannot get def for {}'.format(fn)) 361 | 362 | name = fn.value if fn.value not in (']', ')') else '[unknown]' 363 | 364 | if role == 'definition': 365 | start_pos = ast_node.start_pos 366 | end_pos = ast_node.end_pos 367 | else: 368 | start_pos = fn.start_pos 369 | end_pos = fn.end_pos 370 | 371 | code_element = CodeElement( 372 | name=name, 373 | type='[Unknown]', 374 | module='[Unknown]', 375 | role = role, 376 | path = None, 377 | call_pos = call_pos, 378 | start_pos=(None,None), 379 | end_pos=(None,None), 380 | ) 381 | 382 | jn = JediCodeElementNode(code_element, None) 383 | 384 | yield jn 385 | 386 | 387 | def experimental_definitions_of_called_objects(definition: jedi.api.classes.Definition): 388 | _unfiltered_nodes = [] 389 | 390 | for context in definition._name.infer(): 391 | _filter = UsageFilter(definition._evaluator, context, node_context=None, 392 | until_position=None, origin_scope=None) 393 | values = jedi.api.classes._sort_names_by_start_pos(_filter.values()) 394 | 395 | for tree_name_definition in values: 396 | name = jedi.api.classes.Definition(definition._evaluator, tree_name_definition) 397 | call_pos = (name.module_path, tree_name_definition.start_pos, tree_name_definition.tree_name.end_pos) 398 | 399 | assignments = name.goto_assignments() 400 | 401 | if assignments: 402 | assignment = assignments[0] 403 | else: 404 | assignment = name 405 | 406 | role = {'atom_expr': 'child', 407 | 'trailer': 'child', 408 | 'funcdef': 'definition', 409 | 'classdef': 'definition', 410 | }.get(tree_name_definition.tree_name.parent.type, 'definition') 411 | 412 | code_element = CodeElement( 413 | name=name.name, 414 | type=name.type, 415 | module=assignment.module_name, 416 | role = role, 417 | path = assignment.module_path, 418 | call_pos = call_pos, 419 | start_pos=name._name.start_pos, 420 | end_pos=name._name.tree_name.end_pos, 421 | ) 422 | 423 | node = JediCodeElementNode(code_element, name) 424 | _unfiltered_nodes.append(node) 425 | 426 | yield from filter_nodes(_unfiltered_nodes) 427 | 428 | 429 | def _is_submodule(path: Path, parent: Path): 430 | for _parent in path.parents: 431 | if _parent == parent: 432 | return True 433 | elif _parent.joinpath('__init__.py').exists(): 434 | continue 435 | else: 436 | return False 437 | else: 438 | return False 439 | 440 | def path_to_module_name(sys_path, path: str): 441 | path = Path(path).resolve() 442 | for _sp in reversed(sys_path): 443 | try: 444 | sp = Path(_sp).resolve() 445 | except FileNotFoundError: 446 | continue 447 | 448 | if _is_submodule(path, sp): 449 | module_name = ('.'.join(path.relative_to(sp).parts)) 450 | if module_name.endswith('.py'): 451 | module_name = module_name[:-3] 452 | return module_name 453 | else: 454 | return None 455 | 456 | 457 | def create_import_script(effective_sys_path: List[Path], module_name: str) -> jedi.api.Script: 458 | import_script_text = 'import {}'.format(module_name) 459 | import_script = jedi.api.Script(source=import_script_text, sys_path=list(map(str, effective_sys_path)), 460 | line=1, column=len(import_script_text)-1) # TODO: double check column 461 | return import_script 462 | 463 | 464 | def get_module_node(effective_sys_path: List[Path], module_name: str) -> Tuple[Optional[Node], Optional[Exception]]: 465 | from .errors import ModuleResolutionError 466 | 467 | import_script = create_import_script(effective_sys_path, module_name) 468 | definitions = import_script.goto_definitions() 469 | 470 | if definitions: 471 | mod = tz.first(definitions) 472 | 473 | if tuple(map(int, jedi.__version__.split('.'))) >= (0,10,1): 474 | # duck punch to avoid mod._name.api_type error, which uses parent_context. 475 | mod._name.parent_context = mod._name.get_root_context() 476 | 477 | if mod.module_path: 478 | JediCodeElementNode.usage_resolution_modules |= frozenset((mod._name.get_root_context(),)) 479 | 480 | node = JediCodeElementNode.from_definition( 481 | role='definition', 482 | call_pos=(mod.module_path, (1,0), (None,None)), 483 | definition=mod) 484 | 485 | err = None 486 | else: 487 | node = None 488 | err = ModuleResolutionError( 489 | 'Could not resolve module {} (did you mean to use "-f"?)'.format(module_name)) 490 | 491 | return node, err 492 | 493 | 494 | def dump_module_nodes(effective_sys_path: List[Path], module_names: List[str]) \ 495 | -> Tuple[Dict[str, Node], Dict[str, Node]]: 496 | 497 | _nodes = {} 498 | failures = {} 499 | 500 | for _name in module_names: 501 | node, err = get_module_node(effective_sys_path, _name) 502 | 503 | if node: 504 | _nodes[_name] = node 505 | else: 506 | failures[_name] = err 507 | 508 | return _nodes, failures 509 | 510 | 511 | def remove_dupes(ll: list): 512 | seen = set() 513 | out = [] 514 | 515 | for elt in ll: 516 | if elt not in seen: 517 | seen.add(elt) 518 | out.append(elt) 519 | else: 520 | continue 521 | 522 | return out 523 | 524 | 525 | def make_scope_settings(is_new_project: bool, 526 | saved_scope_settings: ScopeSettings, 527 | user_scope_settings: UserScopeSettings) -> ScopeSettings: 528 | 529 | additional_sys_path = list(map(str, user_scope_settings.add_to_sys_path)) # type: List[str] 530 | 531 | if is_new_project: 532 | effective_sys_path = saved_scope_settings.effective_sys_path + additional_sys_path 533 | elif user_scope_settings.include_runtime_sys_path: 534 | effective_sys_path = actual_sys_path + additional_sys_path 535 | else: 536 | effective_sys_path = additional_sys_path 537 | 538 | if '' in effective_sys_path: 539 | del effective_sys_path[effective_sys_path.index('')] 540 | 541 | module_names = remove_dupes(saved_scope_settings.module_names + user_scope_settings.module_names) 542 | 543 | scripts = saved_scope_settings.scripts.copy() # type: List[Path] 544 | 545 | for path in user_scope_settings.file_names: 546 | module_name = path_to_module_name(effective_sys_path, path) 547 | if module_name is not None: 548 | module_names.append(module_name) 549 | elif path.is_file(): 550 | scripts.append(path) 551 | else: 552 | logger.warning('Could not resolve module for {}'.format(path)) 553 | 554 | return ScopeSettings(module_names=module_names, 555 | scripts=scripts, 556 | effective_sys_path=list(map(Path, effective_sys_path))) 557 | 558 | 559 | def dump_script_nodes(effective_sys_path: List[Path], scripts: List[Path]) -> Dict[Path, Node]: 560 | from .errors import ScriptResolutionError 561 | 562 | _sys_path_str = list(map(str, effective_sys_path)) # type: List[str] 563 | 564 | failures = {} 565 | _nodes = {} # type: Dict[Path, Node] 566 | for path in scripts: 567 | fname = str(path) 568 | try: 569 | script = jedi.api.Script(path=fname, sys_path=_sys_path_str) 570 | except FileNotFoundError: 571 | failures[path] = ScriptResolutionError('Cannot resolve path to script "{}"'.format(fname)) 572 | continue 573 | except UnicodeDecodeError: 574 | failures[path] = ScriptResolutionError('Cannot decode script "{}"'.format(fname)) 575 | continue 576 | 577 | module = script._get_module() 578 | 579 | children = list( 580 | filter_nodes( 581 | definitions_of_called_objects(script._evaluator, module.tree_node, path=fname))) 582 | 583 | node = OrganizerNode(fname, [], children) 584 | 585 | node.code_element = CodeElement( 586 | name=module.name.string_name, 587 | type='script', 588 | module=module.name.string_name, 589 | role='definition', 590 | path=str(path.resolve()), 591 | call_pos=(fname, (None,None), (None,None)), 592 | start_pos=(1,0), 593 | end_pos=(None, None), 594 | ) 595 | 596 | node.module_context = module 597 | 598 | _nodes[path] = node 599 | 600 | return _nodes, failures 601 | -------------------------------------------------------------------------------- /call_map/package_info.json: -------------------------------------------------------------------------------- 1 | {"version": "1.0.2"} 2 | -------------------------------------------------------------------------------- /call_map/project_settings_module.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | import json 3 | import logging 4 | from typing import List, GenericMeta, Any, Tuple, Optional, Dict, Iterable 5 | import toolz as tz 6 | 7 | from .core import UserScopeSettings, ScopeSettings, CodeElement, CallPosType, Node 8 | from . import serialize 9 | from .custom_typing import CheckableOptional, CheckableDict, CheckableTuple, CheckableList, matches_spec 10 | 11 | 12 | project_settings = 'project_settings' 13 | sys_path = 'sys_path' 14 | bookmarks = 'bookmarks' 15 | 16 | # `files` can only changed manually 17 | # `modules` and `scripts` can be affected by `files` but does affect `files` 18 | files = 'files' 19 | modules = 'modules' 20 | scripts = 'scripts' 21 | 22 | categories = [project_settings, sys_path, bookmarks, modules, files, scripts] 23 | 24 | logger = logging.getLogger(__name__) 25 | 26 | category_type = { 27 | project_settings: CheckableDict({'project_directory': CheckableOptional(Path)}), 28 | modules: CheckableList(str), 29 | files: CheckableList(Path), 30 | sys_path: CheckableList(Path), 31 | bookmarks: CheckableList(CheckableList(CodeElement)), 32 | scripts: CheckableList(Path), 33 | } 34 | 35 | 36 | def diff(type_spec, oo_1, oo_2): 37 | if isinstance(type_spec, CheckableList): 38 | return ([elt_1 for elt_1 in oo_1 if elt_1 not in oo_2], [elt_2 for elt_2 in oo_2 if elt_2 not in oo_1]) 39 | elif isinstance(type_spec, CheckableTuple): 40 | return [(elt_1, elt_2) for elt_1, elt_2 in zip(oo_1, oo_2) if elt_1 != elt_2] 41 | elif isinstance(type_spec, CheckableDict): 42 | return {key: (oo_1.get(key), oo_2.get(key)) for key in set(oo_1.keys()) + set(oo_2.keys())} 43 | 44 | 45 | class Project: 46 | def __init__(self, project_directory: Optional[Path]): 47 | 48 | self.project_files = {} 49 | 50 | self.settings = {category: _type.new_empty_instance() for category, _type in category_type.items()} 51 | #self.settings[project_settings] = {} 52 | 53 | self.project_directory_is_set_up = False # only matters for nontrivial project_directory 54 | self.project_directory = project_directory 55 | 56 | # each Node has attributes `code_element` and `definition`. 57 | self.failures = {} 58 | self.script_nodes = {} # type: Dict[Path, Node]; maps script path to node 59 | self.module_nodes = {} # type: Dict[str, Node]; maps module name to node 60 | 61 | @property 62 | def project_directory(self) -> Optional[Path]: 63 | try: 64 | return self.settings[project_settings]['project_directory'] 65 | except KeyError: 66 | return None 67 | 68 | @project_directory.setter 69 | def project_directory(self, new_project_directory: Optional[Path]): 70 | 71 | if new_project_directory != self.project_directory: 72 | self.project_directory_is_set_up = False 73 | self.settings[project_settings]['project_directory'] = new_project_directory 74 | 75 | if new_project_directory: 76 | self.project_files.update({rest_name: new_project_directory.joinpath(rest_name + '.json') 77 | for rest_name in categories}) 78 | 79 | def load_scope_settings(self, scope_settings: ScopeSettings): 80 | self.settings[modules] = scope_settings.module_names 81 | self.settings[scripts] = scope_settings.scripts 82 | self.settings[sys_path] = scope_settings.effective_sys_path 83 | 84 | @property 85 | def scope_settings(self): 86 | return ScopeSettings(module_names=self.settings[modules], 87 | scripts=self.settings[scripts], 88 | effective_sys_path=self.settings[sys_path]) 89 | 90 | def update_scope_settings(self, scope_settings: ScopeSettings): 91 | self.update_settings({modules: scope_settings.module_names, 92 | scripts: scope_settings.scripts, 93 | sys_path: scope_settings.effective_sys_path}) 94 | 95 | def encode(self, category: str, for_persistence: bool): 96 | '''Encode settings in JSON serializable format 97 | 98 | :param str category: category of settings to encode. 99 | :param bool for_persistence: whether to filter out settings that don't need to persist 100 | 101 | ''' 102 | if for_persistence and category == project_settings: 103 | _settings = {k:v for k,v in self.settings[category].items() 104 | if k != 'project_directory'} 105 | else: 106 | _settings = self.settings[category] 107 | 108 | _type = category_type[category] 109 | 110 | return serialize.encode(_type, _settings) 111 | 112 | def update_persistent_storage(self): 113 | '''Write out to persistent storage 114 | 115 | Raises `FileNotFoundError` if the project directory cannot be created. 116 | 117 | ''' 118 | if self.project_directory: 119 | if not self.project_directory_is_set_up: 120 | self._setup_project_directory() 121 | self.project_directory_is_set_up = True 122 | 123 | self._write_to_project_directory() 124 | 125 | def _setup_project_directory(self): 126 | # only used in update_persistent_storage 127 | try: 128 | self.project_directory.mkdir(mode=0o744, exist_ok=True) 129 | except FileNotFoundError: 130 | raise FileNotFoundError('Could not create project directory: {}'.format(self.project_directory)) 131 | 132 | backup_directory = self.project_directory.joinpath('backup') 133 | try: 134 | backup_directory.mkdir(mode=0o744, exist_ok=True) 135 | except FileNotFoundError: 136 | raise FileNotFoundError('Could not create project backup directory: {}'.format(backup_directory)) 137 | 138 | for ff in self.project_files.values(): 139 | ff.touch(mode=0o644) 140 | 141 | def _write_to_project_directory(self): 142 | # only used in update_persistent_storage 143 | for key, project_file in self.project_files.items(): 144 | if project_file.exists(): 145 | backup = self.project_directory.joinpath('backup').joinpath(project_file.name) 146 | project_file.rename(backup) 147 | 148 | project_file = self.project_files[key] 149 | text = json.dumps(self.encode(key, for_persistence=True), indent=True, sort_keys=True) 150 | project_file.write_text(text + '\n') 151 | 152 | def load_from_persistent_storage(self): 153 | # precedence: files > (modules, scripts) 154 | 155 | # Note that `files` will only be changed manually 156 | # It is not updated by changing `modules` or `scripts`. 157 | # However, `files` will affect `modules` and `scripts`. 158 | 159 | decoded = {} 160 | if self.project_directory and self.project_directory.exists(): 161 | for key, project_file in self.project_files.items(): 162 | try: 163 | decoded[key] = serialize.decode(category_type[key], json.loads(project_file.read_text())) 164 | except json.JSONDecodeError as err: 165 | logger.error(err) 166 | except FileNotFoundError as err: 167 | #logger.error(err) 168 | pass 169 | 170 | return decoded 171 | 172 | def update_settings(self, new_settings: Dict[str, Any]): 173 | try: 174 | new_project_directory = new_settings[project_settings]['project_directory'] 175 | except KeyError: 176 | pass 177 | else: 178 | self.project_directory = new_project_directory 179 | 180 | for category, value in new_settings.items(): 181 | type_spec = category_type[category] 182 | settings = self.settings[category] 183 | 184 | if isinstance(type_spec, CheckableDict): 185 | settings.update(value) 186 | elif isinstance(type_spec, CheckableList): 187 | for elt in value: 188 | if elt not in settings: 189 | settings.append(elt) 190 | else: 191 | raise TypeError('Invalid category type {}'.format(type_spec)) 192 | 193 | 194 | def set_settings(self, category: str, decoded: Any) -> Tuple[bool, Iterable[Node], Iterable[Node]]: 195 | from . import jedi_dump 196 | 197 | type_spec = category_type[category] 198 | 199 | if not matches_spec(decoded, type_spec): 200 | raise TypeError('Settings for {} should have type `{}`'.format(category, type_spec)) 201 | 202 | if category == project_settings: 203 | self.project_directory = decoded['project_directory'] 204 | return (True, [], []) 205 | 206 | old = self.settings[category] 207 | 208 | if isinstance(type_spec, CheckableList): 209 | to_delete = [elt for elt in old if elt not in decoded] # type: List[str] 210 | to_add = [elt for elt in decoded if elt not in old] # type: List[str] 211 | 212 | if category == modules: 213 | stale = [] 214 | additional, failures = jedi_dump.dump_module_nodes(self.settings[sys_path], to_add) 215 | 216 | # Execute destructive operations now that we are done with 217 | # failure-prone operations 218 | self.settings[category] = decoded 219 | 220 | for name in to_delete: 221 | stale_node = self.module_nodes['python'].pop(name) 222 | stale.append(stale_node) 223 | 224 | self.module_nodes['python'].update(additional) 225 | self.failures['python'][category].update(failures) 226 | 227 | self.update_usage_search_locations('python') 228 | 229 | return (stale or additional, stale, additional.values()) 230 | 231 | elif category == scripts: 232 | stale = [] 233 | additional, failures = jedi_dump.dump_script_nodes(self.settings[sys_path], to_add) 234 | 235 | # Execute destructive operations now that we are done with 236 | # failure-prone operations 237 | self.settings[category] = decoded 238 | 239 | for name in to_delete: 240 | stale_node = self.script_nodes['python'].pop(name) 241 | stale.append(stale_node) 242 | 243 | self.script_nodes['python'].update(additional) 244 | self.failures['python'][category].update(failures) 245 | 246 | self.update_usage_search_locations('python') 247 | 248 | return (stale or additional, stale, additional.values()) 249 | 250 | elif category == sys_path: 251 | self.settings[sys_path] = decoded 252 | 253 | platform = 'python' 254 | self.update_module_resolution_path(platform) 255 | self.make_platform_specific_nodes(platform) # depends on self.settings[sys_path] 256 | 257 | return (old != decoded, to_delete, to_add) 258 | 259 | elif category == bookmarks: 260 | self.settings[bookmarks] = decoded 261 | return (old != decoded, to_delete, to_add) 262 | else: 263 | raise NotImplementedError 264 | 265 | raise NotImplementedError 266 | 267 | 268 | def make_platform_specific_nodes(self, platform: str): 269 | if platform.lower().startswith('python'): 270 | from . import jedi_dump 271 | 272 | self.failures[platform] = {} 273 | 274 | self.module_nodes[platform], self.failures[platform][modules] = ( 275 | jedi_dump.dump_module_nodes(self.settings[sys_path], self.settings[modules])) 276 | 277 | self.script_nodes[platform], self.failures[platform][scripts] = ( 278 | jedi_dump.dump_script_nodes(self.settings[sys_path], self.settings[scripts])) 279 | 280 | self.update_usage_search_locations(platform) 281 | 282 | 283 | def update_usage_search_locations(self, platform: str): 284 | '''Update the places where usages are found 285 | 286 | Call this whenever you load new modules or scripts. 287 | 288 | ''' 289 | 290 | if platform.lower().startswith('python'): 291 | from . import jedi_dump 292 | 293 | jedi_dump.JediCodeElementNode.usage_resolution_modules = ( 294 | frozenset((nn.module_context for nn in 295 | tz.concatv(self.module_nodes[platform].values(), 296 | self.script_nodes[platform].values()) 297 | if nn.code_element.path))) 298 | 299 | def update_module_resolution_path(self, platform: str): 300 | if platform.lower().startswith('python'): 301 | from . import jedi_dump 302 | jedi_dump.JediCodeElementNode.sys_path = [str(pp) for pp in self.settings[sys_path]] 303 | -------------------------------------------------------------------------------- /call_map/qt_compatibility.py: -------------------------------------------------------------------------------- 1 | ''' 2 | The qt_compatibility module is to make it easy to switch between different Qt/Python bridge implementations. 3 | 4 | ''' 5 | 6 | 7 | from PyQt5 import QtCore, QtGui, QtWidgets, Qt 8 | 9 | # The following monkey patching already occurs when importing 10 | # qtconsole.pygments_highlighter, however that should not be relied upon. 11 | QtCore.Signal = QtCore.pyqtSignal 12 | -------------------------------------------------------------------------------- /call_map/serialize.py: -------------------------------------------------------------------------------- 1 | from typing import Union, Dict, List, Any, Callable, Tuple 2 | from pathlib import Path 3 | import toolz 4 | from .core import CodeElement 5 | from .custom_typing import CheckableOptional, CheckableDict, CheckableList, CheckableTuple 6 | 7 | NoneType = type(None) 8 | 9 | py_type = 'py_type' 10 | value = 'value' 11 | 12 | encoding_fns = {} 13 | decoding_fns = {} 14 | 15 | class DecodeError(Exception): 16 | pass 17 | 18 | 19 | def register(py_type: Any, encoder: Callable, decoder: Callable): 20 | """Register JSON encoding name and JSON encoding/decoding functions""" 21 | encoding_fns[py_type] = encoder 22 | decoding_fns[py_type] = decoder 23 | 24 | 25 | def encode(type_spec, oo: Any): 26 | """Encode a Python object to JSON serializable form""" 27 | 28 | if isinstance(type_spec, CheckableDict): 29 | return {k: encode(type_spec.value_types[k], v) for k, v in oo.items()} 30 | 31 | elif isinstance(type_spec, CheckableOptional): 32 | if oo is not None: 33 | return encode(type_spec.nontrivial_type, oo) 34 | else: 35 | return None 36 | 37 | elif isinstance(type_spec, CheckableList): 38 | return [encode(type_spec.value_type, elt) for elt in oo] 39 | 40 | elif isinstance(type_spec, CheckableTuple): 41 | return tuple(encode(elt_type, elt) for elt_type, elt in zip(type_spec.value_types, oo)) 42 | 43 | else: 44 | try: 45 | return encoding_fns[type_spec](oo) 46 | except KeyError: 47 | raise TypeError('Cannot encode {}'.format(repr(oo))) 48 | 49 | 50 | def decode(type_spec, oo: Union[Dict[str, Any], List, str, int, type(None)]): 51 | """Decode a Python object encoded by `encode`""" 52 | try: 53 | if isinstance(type_spec, CheckableDict): 54 | return {k: decode(type_spec.value_types[k], v) for k, v in oo.items()} 55 | 56 | elif isinstance(type_spec, CheckableOptional): 57 | if oo is not None: 58 | return decode(type_spec.nontrivial_type, oo) 59 | else: 60 | return None 61 | 62 | elif isinstance(type_spec, CheckableList): 63 | return [decode(type_spec.value_type, elt) for elt in oo] 64 | 65 | elif isinstance(type_spec, CheckableTuple): 66 | return tuple(decode(elt_type, elt) for elt_type, elt in zip(type_spec.value_types, oo)) 67 | 68 | elif type_spec in decoding_fns: 69 | return decoding_fns[type_spec](oo) 70 | 71 | else: 72 | raise TypeError 73 | 74 | except (TypeError, AttributeError, KeyError) as err: 75 | raise DecodeError('Cannot decode `{}` as type {}; {}'.format(oo, type_spec, err)) 76 | 77 | 78 | 79 | def encode_code_element(ce): 80 | return ce._asdict() 81 | 82 | def decode_code_element(encoded): 83 | ce1 = CodeElement(**encoded) 84 | path, line, column = ce1.call_pos 85 | return ce1._replace(call_pos=(path, tuple(line), tuple(column)), 86 | start_pos=tuple(ce1.start_pos), 87 | end_pos=tuple(ce1.end_pos)) 88 | 89 | 90 | register(Path, encoder=str, decoder=Path) 91 | register(str, encoder=toolz.identity, decoder=toolz.identity) 92 | register(int, encoder=toolz.identity, decoder=toolz.identity) 93 | register(float, encoder=toolz.identity, decoder=toolz.identity) 94 | register(NoneType, encoder=toolz.identity, decoder=toolz.identity) 95 | register(CodeElement, encoder=encode_code_element, decoder=decode_code_element) 96 | -------------------------------------------------------------------------------- /call_map/wheel_fix.py: -------------------------------------------------------------------------------- 1 | """ 2 | Utilities for locking scroll orientation 3 | 4 | Normally you can scroll in any direction you want, but sometimes that is 5 | not desirable. 6 | 7 | The only function you should be using from this module is 8 | :func:`notify_wheel_event`. Use it from your custom notify method, 9 | for example:: 10 | 11 | class MyQApplication(QtGui.QApplication): 12 | def notify(self, obj: QtCore.QObject, event: QtCore.QEvent): 13 | if (event.type() == QtCore.QEvent.Wheel): 14 | return wheel_fix.notify_wheel_event(self, obj, event) 15 | else: 16 | return super().notify(obj, event) 17 | 18 | """ 19 | 20 | from PyQt5 import QtCore, QtGui, Qt, QtWidgets 21 | 22 | HORIZONTAL = 1 23 | VERTICAL = 2 24 | 25 | LOCK_INTERVAL = 200 26 | 27 | locked_orientation = None 28 | 29 | timer = QtCore.QTimer() 30 | timer.setInterval(LOCK_INTERVAL) 31 | 32 | 33 | def unlock(): 34 | global locked_orientation 35 | locked_orientation = None 36 | timer.stop() 37 | 38 | timer.timeout.connect(unlock) 39 | 40 | 41 | def update_lock_orientation(event): 42 | global locked_orientation 43 | 44 | if locked_orientation is None: 45 | locked_orientation = event.orientation() 46 | else: 47 | timer.stop() 48 | 49 | timer.start() 50 | 51 | 52 | def notify_wheel_event(app: QtWidgets.QApplication, 53 | obj: QtCore.QObject, 54 | event: QtCore.QEvent): 55 | """Notify obj or obj.parent() based on orientation lock state""" 56 | 57 | update_lock_orientation(event) 58 | 59 | if (event.orientation() == locked_orientation 60 | and (type(obj) is not QtGui.QScrollBar 61 | or obj.orientation() == locked_orientation)): 62 | 63 | return super(type(app), app).notify(obj, event) 64 | else: 65 | return super(type(app), app).notify(obj.parent(), event) 66 | -------------------------------------------------------------------------------- /dev_helper_tools/shell_tools.zsh: -------------------------------------------------------------------------------- 1 | 2 | function ipy_call_map { 3 | QT_API=pyqt5 ipython --gui=qt -im call_map -- --ipython $@ 4 | } 5 | -------------------------------------------------------------------------------- /docs/UI-clicked-on-function.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nccgroup/call_map/8f9b41260f654595e7520fade107d018ea011c0d/docs/UI-clicked-on-function.png -------------------------------------------------------------------------------- /gpl-3.0.txt: -------------------------------------------------------------------------------- 1 | GNU GENERAL PUBLIC LICENSE 2 | Version 3, 29 June 2007 3 | 4 | Copyright (C) 2007 Free Software Foundation, Inc. 5 | Everyone is permitted to copy and distribute verbatim copies 6 | of this license document, but changing it is not allowed. 7 | 8 | Preamble 9 | 10 | The GNU General Public License is a free, copyleft license for 11 | software and other kinds of works. 12 | 13 | The licenses for most software and other practical works are designed 14 | to take away your freedom to share and change the works. By contrast, 15 | the GNU General Public License is intended to guarantee your freedom to 16 | share and change all versions of a program--to make sure it remains free 17 | software for all its users. We, the Free Software Foundation, use the 18 | GNU General Public License for most of our software; it applies also to 19 | any other work released this way by its authors. You can apply it to 20 | your programs, too. 21 | 22 | When we speak of free software, we are referring to freedom, not 23 | price. Our General Public Licenses are designed to make sure that you 24 | have the freedom to distribute copies of free software (and charge for 25 | them if you wish), that you receive source code or can get it if you 26 | want it, that you can change the software or use pieces of it in new 27 | free programs, and that you know you can do these things. 28 | 29 | To protect your rights, we need to prevent others from denying you 30 | these rights or asking you to surrender the rights. Therefore, you have 31 | certain responsibilities if you distribute copies of the software, or if 32 | you modify it: responsibilities to respect the freedom of others. 33 | 34 | For example, if you distribute copies of such a program, whether 35 | gratis or for a fee, you must pass on to the recipients the same 36 | freedoms that you received. You must make sure that they, too, receive 37 | or can get the source code. And you must show them these terms so they 38 | know their rights. 39 | 40 | Developers that use the GNU GPL protect your rights with two steps: 41 | (1) assert copyright on the software, and (2) offer you this License 42 | giving you legal permission to copy, distribute and/or modify it. 43 | 44 | For the developers' and authors' protection, the GPL clearly explains 45 | that there is no warranty for this free software. For both users' and 46 | authors' sake, the GPL requires that modified versions be marked as 47 | changed, so that their problems will not be attributed erroneously to 48 | authors of previous versions. 49 | 50 | Some devices are designed to deny users access to install or run 51 | modified versions of the software inside them, although the manufacturer 52 | can do so. This is fundamentally incompatible with the aim of 53 | protecting users' freedom to change the software. The systematic 54 | pattern of such abuse occurs in the area of products for individuals to 55 | use, which is precisely where it is most unacceptable. Therefore, we 56 | have designed this version of the GPL to prohibit the practice for those 57 | products. If such problems arise substantially in other domains, we 58 | stand ready to extend this provision to those domains in future versions 59 | of the GPL, as needed to protect the freedom of users. 60 | 61 | Finally, every program is threatened constantly by software patents. 62 | States should not allow patents to restrict development and use of 63 | software on general-purpose computers, but in those that do, we wish to 64 | avoid the special danger that patents applied to a free program could 65 | make it effectively proprietary. To prevent this, the GPL assures that 66 | patents cannot be used to render the program non-free. 67 | 68 | The precise terms and conditions for copying, distribution and 69 | modification follow. 70 | 71 | TERMS AND CONDITIONS 72 | 73 | 0. Definitions. 74 | 75 | "This License" refers to version 3 of the GNU General Public License. 76 | 77 | "Copyright" also means copyright-like laws that apply to other kinds of 78 | works, such as semiconductor masks. 79 | 80 | "The Program" refers to any copyrightable work licensed under this 81 | License. Each licensee is addressed as "you". "Licensees" and 82 | "recipients" may be individuals or organizations. 83 | 84 | To "modify" a work means to copy from or adapt all or part of the work 85 | in a fashion requiring copyright permission, other than the making of an 86 | exact copy. The resulting work is called a "modified version" of the 87 | earlier work or a work "based on" the earlier work. 88 | 89 | A "covered work" means either the unmodified Program or a work based 90 | on the Program. 91 | 92 | To "propagate" a work means to do anything with it that, without 93 | permission, would make you directly or secondarily liable for 94 | infringement under applicable copyright law, except executing it on a 95 | computer or modifying a private copy. Propagation includes copying, 96 | distribution (with or without modification), making available to the 97 | public, and in some countries other activities as well. 98 | 99 | To "convey" a work means any kind of propagation that enables other 100 | parties to make or receive copies. Mere interaction with a user through 101 | a computer network, with no transfer of a copy, is not conveying. 102 | 103 | An interactive user interface displays "Appropriate Legal Notices" 104 | to the extent that it includes a convenient and prominently visible 105 | feature that (1) displays an appropriate copyright notice, and (2) 106 | tells the user that there is no warranty for the work (except to the 107 | extent that warranties are provided), that licensees may convey the 108 | work under this License, and how to view a copy of this License. If 109 | the interface presents a list of user commands or options, such as a 110 | menu, a prominent item in the list meets this criterion. 111 | 112 | 1. Source Code. 113 | 114 | The "source code" for a work means the preferred form of the work 115 | for making modifications to it. "Object code" means any non-source 116 | form of a work. 117 | 118 | A "Standard Interface" means an interface that either is an official 119 | standard defined by a recognized standards body, or, in the case of 120 | interfaces specified for a particular programming language, one that 121 | is widely used among developers working in that language. 122 | 123 | The "System Libraries" of an executable work include anything, other 124 | than the work as a whole, that (a) is included in the normal form of 125 | packaging a Major Component, but which is not part of that Major 126 | Component, and (b) serves only to enable use of the work with that 127 | Major Component, or to implement a Standard Interface for which an 128 | implementation is available to the public in source code form. A 129 | "Major Component", in this context, means a major essential component 130 | (kernel, window system, and so on) of the specific operating system 131 | (if any) on which the executable work runs, or a compiler used to 132 | produce the work, or an object code interpreter used to run it. 133 | 134 | The "Corresponding Source" for a work in object code form means all 135 | the source code needed to generate, install, and (for an executable 136 | work) run the object code and to modify the work, including scripts to 137 | control those activities. However, it does not include the work's 138 | System Libraries, or general-purpose tools or generally available free 139 | programs which are used unmodified in performing those activities but 140 | which are not part of the work. For example, Corresponding Source 141 | includes interface definition files associated with source files for 142 | the work, and the source code for shared libraries and dynamically 143 | linked subprograms that the work is specifically designed to require, 144 | such as by intimate data communication or control flow between those 145 | subprograms and other parts of the work. 146 | 147 | The Corresponding Source need not include anything that users 148 | can regenerate automatically from other parts of the Corresponding 149 | Source. 150 | 151 | The Corresponding Source for a work in source code form is that 152 | same work. 153 | 154 | 2. Basic Permissions. 155 | 156 | All rights granted under this License are granted for the term of 157 | copyright on the Program, and are irrevocable provided the stated 158 | conditions are met. This License explicitly affirms your unlimited 159 | permission to run the unmodified Program. The output from running a 160 | covered work is covered by this License only if the output, given its 161 | content, constitutes a covered work. This License acknowledges your 162 | rights of fair use or other equivalent, as provided by copyright law. 163 | 164 | You may make, run and propagate covered works that you do not 165 | convey, without conditions so long as your license otherwise remains 166 | in force. You may convey covered works to others for the sole purpose 167 | of having them make modifications exclusively for you, or provide you 168 | with facilities for running those works, provided that you comply with 169 | the terms of this License in conveying all material for which you do 170 | not control copyright. Those thus making or running the covered works 171 | for you must do so exclusively on your behalf, under your direction 172 | and control, on terms that prohibit them from making any copies of 173 | your copyrighted material outside their relationship with you. 174 | 175 | Conveying under any other circumstances is permitted solely under 176 | the conditions stated below. Sublicensing is not allowed; section 10 177 | makes it unnecessary. 178 | 179 | 3. Protecting Users' Legal Rights From Anti-Circumvention Law. 180 | 181 | No covered work shall be deemed part of an effective technological 182 | measure under any applicable law fulfilling obligations under article 183 | 11 of the WIPO copyright treaty adopted on 20 December 1996, or 184 | similar laws prohibiting or restricting circumvention of such 185 | measures. 186 | 187 | When you convey a covered work, you waive any legal power to forbid 188 | circumvention of technological measures to the extent such circumvention 189 | is effected by exercising rights under this License with respect to 190 | the covered work, and you disclaim any intention to limit operation or 191 | modification of the work as a means of enforcing, against the work's 192 | users, your or third parties' legal rights to forbid circumvention of 193 | technological measures. 194 | 195 | 4. Conveying Verbatim Copies. 196 | 197 | You may convey verbatim copies of the Program's source code as you 198 | receive it, in any medium, provided that you conspicuously and 199 | appropriately publish on each copy an appropriate copyright notice; 200 | keep intact all notices stating that this License and any 201 | non-permissive terms added in accord with section 7 apply to the code; 202 | keep intact all notices of the absence of any warranty; and give all 203 | recipients a copy of this License along with the Program. 204 | 205 | You may charge any price or no price for each copy that you convey, 206 | and you may offer support or warranty protection for a fee. 207 | 208 | 5. Conveying Modified Source Versions. 209 | 210 | You may convey a work based on the Program, or the modifications to 211 | produce it from the Program, in the form of source code under the 212 | terms of section 4, provided that you also meet all of these conditions: 213 | 214 | a) The work must carry prominent notices stating that you modified 215 | it, and giving a relevant date. 216 | 217 | b) The work must carry prominent notices stating that it is 218 | released under this License and any conditions added under section 219 | 7. This requirement modifies the requirement in section 4 to 220 | "keep intact all notices". 221 | 222 | c) You must license the entire work, as a whole, under this 223 | License to anyone who comes into possession of a copy. This 224 | License will therefore apply, along with any applicable section 7 225 | additional terms, to the whole of the work, and all its parts, 226 | regardless of how they are packaged. This License gives no 227 | permission to license the work in any other way, but it does not 228 | invalidate such permission if you have separately received it. 229 | 230 | d) If the work has interactive user interfaces, each must display 231 | Appropriate Legal Notices; however, if the Program has interactive 232 | interfaces that do not display Appropriate Legal Notices, your 233 | work need not make them do so. 234 | 235 | A compilation of a covered work with other separate and independent 236 | works, which are not by their nature extensions of the covered work, 237 | and which are not combined with it such as to form a larger program, 238 | in or on a volume of a storage or distribution medium, is called an 239 | "aggregate" if the compilation and its resulting copyright are not 240 | used to limit the access or legal rights of the compilation's users 241 | beyond what the individual works permit. Inclusion of a covered work 242 | in an aggregate does not cause this License to apply to the other 243 | parts of the aggregate. 244 | 245 | 6. Conveying Non-Source Forms. 246 | 247 | You may convey a covered work in object code form under the terms 248 | of sections 4 and 5, provided that you also convey the 249 | machine-readable Corresponding Source under the terms of this License, 250 | in one of these ways: 251 | 252 | a) Convey the object code in, or embodied in, a physical product 253 | (including a physical distribution medium), accompanied by the 254 | Corresponding Source fixed on a durable physical medium 255 | customarily used for software interchange. 256 | 257 | b) Convey the object code in, or embodied in, a physical product 258 | (including a physical distribution medium), accompanied by a 259 | written offer, valid for at least three years and valid for as 260 | long as you offer spare parts or customer support for that product 261 | model, to give anyone who possesses the object code either (1) a 262 | copy of the Corresponding Source for all the software in the 263 | product that is covered by this License, on a durable physical 264 | medium customarily used for software interchange, for a price no 265 | more than your reasonable cost of physically performing this 266 | conveying of source, or (2) access to copy the 267 | Corresponding Source from a network server at no charge. 268 | 269 | c) Convey individual copies of the object code with a copy of the 270 | written offer to provide the Corresponding Source. This 271 | alternative is allowed only occasionally and noncommercially, and 272 | only if you received the object code with such an offer, in accord 273 | with subsection 6b. 274 | 275 | d) Convey the object code by offering access from a designated 276 | place (gratis or for a charge), and offer equivalent access to the 277 | Corresponding Source in the same way through the same place at no 278 | further charge. You need not require recipients to copy the 279 | Corresponding Source along with the object code. If the place to 280 | copy the object code is a network server, the Corresponding Source 281 | may be on a different server (operated by you or a third party) 282 | that supports equivalent copying facilities, provided you maintain 283 | clear directions next to the object code saying where to find the 284 | Corresponding Source. Regardless of what server hosts the 285 | Corresponding Source, you remain obligated to ensure that it is 286 | available for as long as needed to satisfy these requirements. 287 | 288 | e) Convey the object code using peer-to-peer transmission, provided 289 | you inform other peers where the object code and Corresponding 290 | Source of the work are being offered to the general public at no 291 | charge under subsection 6d. 292 | 293 | A separable portion of the object code, whose source code is excluded 294 | from the Corresponding Source as a System Library, need not be 295 | included in conveying the object code work. 296 | 297 | A "User Product" is either (1) a "consumer product", which means any 298 | tangible personal property which is normally used for personal, family, 299 | or household purposes, or (2) anything designed or sold for incorporation 300 | into a dwelling. In determining whether a product is a consumer product, 301 | doubtful cases shall be resolved in favor of coverage. For a particular 302 | product received by a particular user, "normally used" refers to a 303 | typical or common use of that class of product, regardless of the status 304 | of the particular user or of the way in which the particular user 305 | actually uses, or expects or is expected to use, the product. A product 306 | is a consumer product regardless of whether the product has substantial 307 | commercial, industrial or non-consumer uses, unless such uses represent 308 | the only significant mode of use of the product. 309 | 310 | "Installation Information" for a User Product means any methods, 311 | procedures, authorization keys, or other information required to install 312 | and execute modified versions of a covered work in that User Product from 313 | a modified version of its Corresponding Source. The information must 314 | suffice to ensure that the continued functioning of the modified object 315 | code is in no case prevented or interfered with solely because 316 | modification has been made. 317 | 318 | If you convey an object code work under this section in, or with, or 319 | specifically for use in, a User Product, and the conveying occurs as 320 | part of a transaction in which the right of possession and use of the 321 | User Product is transferred to the recipient in perpetuity or for a 322 | fixed term (regardless of how the transaction is characterized), the 323 | Corresponding Source conveyed under this section must be accompanied 324 | by the Installation Information. But this requirement does not apply 325 | if neither you nor any third party retains the ability to install 326 | modified object code on the User Product (for example, the work has 327 | been installed in ROM). 328 | 329 | The requirement to provide Installation Information does not include a 330 | requirement to continue to provide support service, warranty, or updates 331 | for a work that has been modified or installed by the recipient, or for 332 | the User Product in which it has been modified or installed. Access to a 333 | network may be denied when the modification itself materially and 334 | adversely affects the operation of the network or violates the rules and 335 | protocols for communication across the network. 336 | 337 | Corresponding Source conveyed, and Installation Information provided, 338 | in accord with this section must be in a format that is publicly 339 | documented (and with an implementation available to the public in 340 | source code form), and must require no special password or key for 341 | unpacking, reading or copying. 342 | 343 | 7. Additional Terms. 344 | 345 | "Additional permissions" are terms that supplement the terms of this 346 | License by making exceptions from one or more of its conditions. 347 | Additional permissions that are applicable to the entire Program shall 348 | be treated as though they were included in this License, to the extent 349 | that they are valid under applicable law. If additional permissions 350 | apply only to part of the Program, that part may be used separately 351 | under those permissions, but the entire Program remains governed by 352 | this License without regard to the additional permissions. 353 | 354 | When you convey a copy of a covered work, you may at your option 355 | remove any additional permissions from that copy, or from any part of 356 | it. (Additional permissions may be written to require their own 357 | removal in certain cases when you modify the work.) You may place 358 | additional permissions on material, added by you to a covered work, 359 | for which you have or can give appropriate copyright permission. 360 | 361 | Notwithstanding any other provision of this License, for material you 362 | add to a covered work, you may (if authorized by the copyright holders of 363 | that material) supplement the terms of this License with terms: 364 | 365 | a) Disclaiming warranty or limiting liability differently from the 366 | terms of sections 15 and 16 of this License; or 367 | 368 | b) Requiring preservation of specified reasonable legal notices or 369 | author attributions in that material or in the Appropriate Legal 370 | Notices displayed by works containing it; or 371 | 372 | c) Prohibiting misrepresentation of the origin of that material, or 373 | requiring that modified versions of such material be marked in 374 | reasonable ways as different from the original version; or 375 | 376 | d) Limiting the use for publicity purposes of names of licensors or 377 | authors of the material; or 378 | 379 | e) Declining to grant rights under trademark law for use of some 380 | trade names, trademarks, or service marks; or 381 | 382 | f) Requiring indemnification of licensors and authors of that 383 | material by anyone who conveys the material (or modified versions of 384 | it) with contractual assumptions of liability to the recipient, for 385 | any liability that these contractual assumptions directly impose on 386 | those licensors and authors. 387 | 388 | All other non-permissive additional terms are considered "further 389 | restrictions" within the meaning of section 10. If the Program as you 390 | received it, or any part of it, contains a notice stating that it is 391 | governed by this License along with a term that is a further 392 | restriction, you may remove that term. If a license document contains 393 | a further restriction but permits relicensing or conveying under this 394 | License, you may add to a covered work material governed by the terms 395 | of that license document, provided that the further restriction does 396 | not survive such relicensing or conveying. 397 | 398 | If you add terms to a covered work in accord with this section, you 399 | must place, in the relevant source files, a statement of the 400 | additional terms that apply to those files, or a notice indicating 401 | where to find the applicable terms. 402 | 403 | Additional terms, permissive or non-permissive, may be stated in the 404 | form of a separately written license, or stated as exceptions; 405 | the above requirements apply either way. 406 | 407 | 8. Termination. 408 | 409 | You may not propagate or modify a covered work except as expressly 410 | provided under this License. Any attempt otherwise to propagate or 411 | modify it is void, and will automatically terminate your rights under 412 | this License (including any patent licenses granted under the third 413 | paragraph of section 11). 414 | 415 | However, if you cease all violation of this License, then your 416 | license from a particular copyright holder is reinstated (a) 417 | provisionally, unless and until the copyright holder explicitly and 418 | finally terminates your license, and (b) permanently, if the copyright 419 | holder fails to notify you of the violation by some reasonable means 420 | prior to 60 days after the cessation. 421 | 422 | Moreover, your license from a particular copyright holder is 423 | reinstated permanently if the copyright holder notifies you of the 424 | violation by some reasonable means, this is the first time you have 425 | received notice of violation of this License (for any work) from that 426 | copyright holder, and you cure the violation prior to 30 days after 427 | your receipt of the notice. 428 | 429 | Termination of your rights under this section does not terminate the 430 | licenses of parties who have received copies or rights from you under 431 | this License. If your rights have been terminated and not permanently 432 | reinstated, you do not qualify to receive new licenses for the same 433 | material under section 10. 434 | 435 | 9. Acceptance Not Required for Having Copies. 436 | 437 | You are not required to accept this License in order to receive or 438 | run a copy of the Program. Ancillary propagation of a covered work 439 | occurring solely as a consequence of using peer-to-peer transmission 440 | to receive a copy likewise does not require acceptance. However, 441 | nothing other than this License grants you permission to propagate or 442 | modify any covered work. These actions infringe copyright if you do 443 | not accept this License. Therefore, by modifying or propagating a 444 | covered work, you indicate your acceptance of this License to do so. 445 | 446 | 10. Automatic Licensing of Downstream Recipients. 447 | 448 | Each time you convey a covered work, the recipient automatically 449 | receives a license from the original licensors, to run, modify and 450 | propagate that work, subject to this License. You are not responsible 451 | for enforcing compliance by third parties with this License. 452 | 453 | An "entity transaction" is a transaction transferring control of an 454 | organization, or substantially all assets of one, or subdividing an 455 | organization, or merging organizations. If propagation of a covered 456 | work results from an entity transaction, each party to that 457 | transaction who receives a copy of the work also receives whatever 458 | licenses to the work the party's predecessor in interest had or could 459 | give under the previous paragraph, plus a right to possession of the 460 | Corresponding Source of the work from the predecessor in interest, if 461 | the predecessor has it or can get it with reasonable efforts. 462 | 463 | You may not impose any further restrictions on the exercise of the 464 | rights granted or affirmed under this License. For example, you may 465 | not impose a license fee, royalty, or other charge for exercise of 466 | rights granted under this License, and you may not initiate litigation 467 | (including a cross-claim or counterclaim in a lawsuit) alleging that 468 | any patent claim is infringed by making, using, selling, offering for 469 | sale, or importing the Program or any portion of it. 470 | 471 | 11. Patents. 472 | 473 | A "contributor" is a copyright holder who authorizes use under this 474 | License of the Program or a work on which the Program is based. The 475 | work thus licensed is called the contributor's "contributor version". 476 | 477 | A contributor's "essential patent claims" are all patent claims 478 | owned or controlled by the contributor, whether already acquired or 479 | hereafter acquired, that would be infringed by some manner, permitted 480 | by this License, of making, using, or selling its contributor version, 481 | but do not include claims that would be infringed only as a 482 | consequence of further modification of the contributor version. For 483 | purposes of this definition, "control" includes the right to grant 484 | patent sublicenses in a manner consistent with the requirements of 485 | this License. 486 | 487 | Each contributor grants you a non-exclusive, worldwide, royalty-free 488 | patent license under the contributor's essential patent claims, to 489 | make, use, sell, offer for sale, import and otherwise run, modify and 490 | propagate the contents of its contributor version. 491 | 492 | In the following three paragraphs, a "patent license" is any express 493 | agreement or commitment, however denominated, not to enforce a patent 494 | (such as an express permission to practice a patent or covenant not to 495 | sue for patent infringement). To "grant" such a patent license to a 496 | party means to make such an agreement or commitment not to enforce a 497 | patent against the party. 498 | 499 | If you convey a covered work, knowingly relying on a patent license, 500 | and the Corresponding Source of the work is not available for anyone 501 | to copy, free of charge and under the terms of this License, through a 502 | publicly available network server or other readily accessible means, 503 | then you must either (1) cause the Corresponding Source to be so 504 | available, or (2) arrange to deprive yourself of the benefit of the 505 | patent license for this particular work, or (3) arrange, in a manner 506 | consistent with the requirements of this License, to extend the patent 507 | license to downstream recipients. "Knowingly relying" means you have 508 | actual knowledge that, but for the patent license, your conveying the 509 | covered work in a country, or your recipient's use of the covered work 510 | in a country, would infringe one or more identifiable patents in that 511 | country that you have reason to believe are valid. 512 | 513 | If, pursuant to or in connection with a single transaction or 514 | arrangement, you convey, or propagate by procuring conveyance of, a 515 | covered work, and grant a patent license to some of the parties 516 | receiving the covered work authorizing them to use, propagate, modify 517 | or convey a specific copy of the covered work, then the patent license 518 | you grant is automatically extended to all recipients of the covered 519 | work and works based on it. 520 | 521 | A patent license is "discriminatory" if it does not include within 522 | the scope of its coverage, prohibits the exercise of, or is 523 | conditioned on the non-exercise of one or more of the rights that are 524 | specifically granted under this License. You may not convey a covered 525 | work if you are a party to an arrangement with a third party that is 526 | in the business of distributing software, under which you make payment 527 | to the third party based on the extent of your activity of conveying 528 | the work, and under which the third party grants, to any of the 529 | parties who would receive the covered work from you, a discriminatory 530 | patent license (a) in connection with copies of the covered work 531 | conveyed by you (or copies made from those copies), or (b) primarily 532 | for and in connection with specific products or compilations that 533 | contain the covered work, unless you entered into that arrangement, 534 | or that patent license was granted, prior to 28 March 2007. 535 | 536 | Nothing in this License shall be construed as excluding or limiting 537 | any implied license or other defenses to infringement that may 538 | otherwise be available to you under applicable patent law. 539 | 540 | 12. No Surrender of Others' Freedom. 541 | 542 | If conditions are imposed on you (whether by court order, agreement or 543 | otherwise) that contradict the conditions of this License, they do not 544 | excuse you from the conditions of this License. If you cannot convey a 545 | covered work so as to satisfy simultaneously your obligations under this 546 | License and any other pertinent obligations, then as a consequence you may 547 | not convey it at all. For example, if you agree to terms that obligate you 548 | to collect a royalty for further conveying from those to whom you convey 549 | the Program, the only way you could satisfy both those terms and this 550 | License would be to refrain entirely from conveying the Program. 551 | 552 | 13. Use with the GNU Affero General Public License. 553 | 554 | Notwithstanding any other provision of this License, you have 555 | permission to link or combine any covered work with a work licensed 556 | under version 3 of the GNU Affero General Public License into a single 557 | combined work, and to convey the resulting work. The terms of this 558 | License will continue to apply to the part which is the covered work, 559 | but the special requirements of the GNU Affero General Public License, 560 | section 13, concerning interaction through a network will apply to the 561 | combination as such. 562 | 563 | 14. Revised Versions of this License. 564 | 565 | The Free Software Foundation may publish revised and/or new versions of 566 | the GNU General Public License from time to time. Such new versions will 567 | be similar in spirit to the present version, but may differ in detail to 568 | address new problems or concerns. 569 | 570 | Each version is given a distinguishing version number. If the 571 | Program specifies that a certain numbered version of the GNU General 572 | Public License "or any later version" applies to it, you have the 573 | option of following the terms and conditions either of that numbered 574 | version or of any later version published by the Free Software 575 | Foundation. If the Program does not specify a version number of the 576 | GNU General Public License, you may choose any version ever published 577 | by the Free Software Foundation. 578 | 579 | If the Program specifies that a proxy can decide which future 580 | versions of the GNU General Public License can be used, that proxy's 581 | public statement of acceptance of a version permanently authorizes you 582 | to choose that version for the Program. 583 | 584 | Later license versions may give you additional or different 585 | permissions. However, no additional obligations are imposed on any 586 | author or copyright holder as a result of your choosing to follow a 587 | later version. 588 | 589 | 15. Disclaimer of Warranty. 590 | 591 | THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY 592 | APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT 593 | HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY 594 | OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, 595 | THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR 596 | PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM 597 | IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF 598 | ALL NECESSARY SERVICING, REPAIR OR CORRECTION. 599 | 600 | 16. Limitation of Liability. 601 | 602 | IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING 603 | WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS 604 | THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY 605 | GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE 606 | USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF 607 | DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD 608 | PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), 609 | EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF 610 | SUCH DAMAGES. 611 | 612 | 17. Interpretation of Sections 15 and 16. 613 | 614 | If the disclaimer of warranty and limitation of liability provided 615 | above cannot be given local legal effect according to their terms, 616 | reviewing courts shall apply local law that most closely approximates 617 | an absolute waiver of all civil liability in connection with the 618 | Program, unless a warranty or assumption of liability accompanies a 619 | copy of the Program in return for a fee. 620 | 621 | END OF TERMS AND CONDITIONS 622 | 623 | How to Apply These Terms to Your New Programs 624 | 625 | If you develop a new program, and you want it to be of the greatest 626 | possible use to the public, the best way to achieve this is to make it 627 | free software which everyone can redistribute and change under these terms. 628 | 629 | To do so, attach the following notices to the program. It is safest 630 | to attach them to the start of each source file to most effectively 631 | state the exclusion of warranty; and each file should have at least 632 | the "copyright" line and a pointer to where the full notice is found. 633 | 634 | 635 | Copyright (C) 636 | 637 | This program is free software: you can redistribute it and/or modify 638 | it under the terms of the GNU General Public License as published by 639 | the Free Software Foundation, either version 3 of the License, or 640 | (at your option) any later version. 641 | 642 | This program is distributed in the hope that it will be useful, 643 | but WITHOUT ANY WARRANTY; without even the implied warranty of 644 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 645 | GNU General Public License for more details. 646 | 647 | You should have received a copy of the GNU General Public License 648 | along with this program. If not, see . 649 | 650 | Also add information on how to contact you by electronic and paper mail. 651 | 652 | If the program does terminal interaction, make it output a short 653 | notice like this when it starts in an interactive mode: 654 | 655 | Copyright (C) 656 | This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. 657 | This is free software, and you are welcome to redistribute it 658 | under certain conditions; type `show c' for details. 659 | 660 | The hypothetical commands `show w' and `show c' should show the appropriate 661 | parts of the General Public License. Of course, your program's commands 662 | might be different; for a GUI interface, you would use an "about box". 663 | 664 | You should also get your employer (if you work as a programmer) or school, 665 | if any, to sign a "copyright disclaimer" for the program, if necessary. 666 | For more information on this, and how to apply and follow the GNU GPL, see 667 | . 668 | 669 | The GNU General Public License does not permit incorporating your program 670 | into proprietary programs. If your program is a subroutine library, you 671 | may consider it more useful to permit linking proprietary applications with 672 | the library. If this is what you want to do, use the GNU Lesser General 673 | Public License instead of this License. But first, please read 674 | . 675 | -------------------------------------------------------------------------------- /optional-requirements.txt: -------------------------------------------------------------------------------- 1 | # for highlighting 2 | qtconsole 3 | 4 | # for profiling 5 | profilehooks 6 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import setuptools 2 | import glob 3 | import os.path as osp 4 | import json as _json 5 | from pathlib import Path as _Path 6 | 7 | requirements = [ 8 | #'jedi>=0.10.0, <=0.10.2', 9 | 'jedi==0.10.2', 10 | 'toolz', 'pygments', 'qtconsole'] 11 | 12 | # If PyQt5 was installed using conda, pip will not recognize it 13 | # Therefore import it to see if it is installed. 14 | try: 15 | import PyQt5 16 | except ImportError: 17 | requirements += ['PyQt5'] 18 | 19 | _package_info = ( 20 | _json.loads(_Path(__file__).parent.joinpath('call_map', 'package_info.json').read_text())) 21 | 22 | setuptools.setup( 23 | name='call_map', 24 | version=_package_info['version'], 25 | description='A GUI for viewing call graphs in Python', 26 | author='Andy Lee', 27 | license='MIT', 28 | classifiers=[ 29 | 'Programming Language :: Python :: 3.5', 30 | ], 31 | 32 | install_requires=requirements, 33 | 34 | entry_points={ 35 | 'console_scripts': ['call_map=call_map.gui:main'], 36 | }, 37 | 38 | package_data={ 39 | '': ['*.png'] 40 | }, 41 | 42 | packages=setuptools.find_packages(exclude=['contrib', 'docs', 'tests*', 'scratch']) 43 | ) 44 | -------------------------------------------------------------------------------- /tests/load_test_modules.py: -------------------------------------------------------------------------------- 1 | import toolz as tz 2 | 3 | from call_map.core import UserScopeSettings, ScopeSettings, OrganizerNode 4 | from call_map.jedi_dump import make_scope_settings 5 | from call_map import project_settings_module 6 | from call_map.project_settings_module import Project 7 | from pathlib import Path 8 | from sys import path as runtime_sys_path 9 | 10 | test_modules_dir = Path(__file__).parent.joinpath('test_modules') 11 | 12 | user_scope_settings = UserScopeSettings( 13 | module_names=[], 14 | file_names=test_modules_dir.glob('*.py'), 15 | include_runtime_sys_path=True, 16 | add_to_sys_path=([str(test_modules_dir)] + runtime_sys_path), 17 | ) 18 | 19 | scope_settings = make_scope_settings(is_new_project=True, 20 | saved_scope_settings=ScopeSettings([], [], []), 21 | user_scope_settings=user_scope_settings) # type: ScopeSettings 22 | 23 | project = Project(None) 24 | 25 | project.settings.update( 26 | {project_settings_module.modules: scope_settings.module_names, 27 | project_settings_module.scripts: scope_settings.scripts, 28 | project_settings_module.sys_path: scope_settings.effective_sys_path}) 29 | 30 | project.make_platform_specific_nodes('python') 31 | 32 | root_node = OrganizerNode('Root', [], 33 | list(tz.concatv(project.module_nodes['python'].values(), 34 | project.script_nodes['python'].values()))) 35 | -------------------------------------------------------------------------------- /tests/test_cache.py: -------------------------------------------------------------------------------- 1 | 2 | from pathlib import Path 3 | from call_map.cache import read_text_cached 4 | import call_map.cache 5 | import random 6 | 7 | call_map.cache.TEXT_CACHE_SIZE = 10 8 | 9 | class FakePath: 10 | def __init__(self, name, text): 11 | self.name = name 12 | self.text = text 13 | 14 | def __hash__(self): 15 | return hash(self.name) 16 | 17 | def read_text(self): 18 | return self.text 19 | 20 | 21 | def test_read_text_cached(): 22 | 23 | paths = [FakePath(str(ii), str(ii)) for ii in range(100)] 24 | 25 | for path in paths[:call_map.cache.TEXT_CACHE_SIZE]: 26 | assert read_text_cached(path) == path.text 27 | 28 | assert len(call_map.cache._text_cache) == call_map.cache.TEXT_CACHE_SIZE 29 | 30 | for ii in range(1000): 31 | path = random.choice(paths) 32 | assert read_text_cached(path) == path.text 33 | assert len(call_map.cache._text_cache) == call_map.cache.TEXT_CACHE_SIZE 34 | -------------------------------------------------------------------------------- /tests/test_jedi_ast_tools.py: -------------------------------------------------------------------------------- 1 | import jedi 2 | import call_map.jedi_ast_tools as jat 3 | import toolz as tz 4 | import textwrap 5 | 6 | 7 | def test_get_called_functions(): 8 | 9 | test_script = """ 10 | 11 | import call_map.jedi_ast_tools as jat 12 | 13 | 14 | def thunk(): 15 | print('hi') 16 | 17 | 18 | def ff(node): 19 | aa = jat.get_called_functions(node) 20 | thunk() 21 | 22 | """ 23 | 24 | text_script = textwrap.dedent(test_script) 25 | 26 | definitions = jedi.api.names(source=test_script) 27 | 28 | def_ff = tz.first(filter(lambda x: x.name == 'ff', definitions)) 29 | called_by_ff = list(jat.get_called_functions(def_ff._name.tree_name.get_definition().children[-1])) 30 | 31 | assert len(called_by_ff) == 2 32 | assert {name.value for role, name, ast_node, start_pos, end_pos in called_by_ff} == {'thunk', 'get_called_functions'} 33 | -------------------------------------------------------------------------------- /tests/test_jedi_dump.py: -------------------------------------------------------------------------------- 1 | import toolz as tz 2 | 3 | from load_test_modules import root_node, test_modules_dir 4 | from call_map.config import user_config 5 | 6 | use_decorators_node = tz.first(node for node in root_node.children 7 | if node.code_element.name == 'use_decorators') 8 | 9 | use_comprehension_node = tz.first(node for node in root_node.children 10 | if node.code_element.name == 'use_comprehension') 11 | 12 | def test_decorator_child(): 13 | user_config.session_overrides['EXPERIMENTAL_MODE'] = False 14 | 15 | children = list(use_decorators_node.children) 16 | 17 | assert children 18 | 19 | assert any( 20 | node.code_element.name == 'dec' 21 | and node.code_element.call_pos == (str(test_modules_dir.joinpath('use_decorators.py')), (7, 1), (7, 4)) 22 | for node in children 23 | ) 24 | 25 | 26 | def test_comprehension(): 27 | user_config.session_overrides['EXPERIMENTAL_MODE'] = False 28 | 29 | nodes = [node for node in use_comprehension_node.children 30 | if node.code_element.name == 'ff'] 31 | 32 | assert nodes 33 | 34 | ff_node = tz.first(nodes) 35 | 36 | fn_with_comprehension_node = tz.first( 37 | node for node in use_comprehension_node.children 38 | if node.code_element.name == 'fn_with_comprehension' 39 | ) 40 | 41 | assert any(node.code_element.name == 'fn_with_comprehension' for node in ff_node.parents) 42 | assert any(node.code_element.name == 'ff' for node in fn_with_comprehension_node.children) 43 | -------------------------------------------------------------------------------- /tests/test_modules/simple_test_package/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | A simple import tree for testing 3 | """ 4 | 5 | -------------------------------------------------------------------------------- /tests/test_modules/simple_test_package/aa.py: -------------------------------------------------------------------------------- 1 | from . import bb 2 | 3 | def foo(): 4 | bb.bar() 5 | -------------------------------------------------------------------------------- /tests/test_modules/simple_test_package/bb.py: -------------------------------------------------------------------------------- 1 | def bar(): 2 | pass 3 | -------------------------------------------------------------------------------- /tests/test_modules/test_package_sibling_usage/__init__.py: -------------------------------------------------------------------------------- 1 | ''' 2 | Tests whether jedi finds a usage of a function in another module in the same package 3 | on startup. 4 | 5 | The test is currently not automated. 6 | ''' 7 | -------------------------------------------------------------------------------- /tests/test_modules/test_package_sibling_usage/subpackage/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nccgroup/call_map/8f9b41260f654595e7520fade107d018ea011c0d/tests/test_modules/test_package_sibling_usage/subpackage/__init__.py -------------------------------------------------------------------------------- /tests/test_modules/test_package_sibling_usage/subpackage/test_sibling_usage.py: -------------------------------------------------------------------------------- 1 | from .. import test_sibling_used 2 | 3 | def uses_sibling(): 4 | return test_sibling_used.used_function() 5 | -------------------------------------------------------------------------------- /tests/test_modules/test_package_sibling_usage/test_sibling_used.py: -------------------------------------------------------------------------------- 1 | 2 | def used_function(): 3 | return 1 4 | -------------------------------------------------------------------------------- /tests/test_modules/use_comprehension.py: -------------------------------------------------------------------------------- 1 | 2 | 3 | def ff(x): 4 | pass 5 | 6 | def fn_with_comprehension(): 7 | return [ff(ii) for ii in range(10)] 8 | -------------------------------------------------------------------------------- /tests/test_modules/use_decorators.py: -------------------------------------------------------------------------------- 1 | 2 | import functools 3 | 4 | def dec(f): 5 | pass 6 | 7 | @dec 8 | def ff(): 9 | def gg(): 10 | pass 11 | pass 12 | -------------------------------------------------------------------------------- /tests/test_project.py: -------------------------------------------------------------------------------- 1 | 2 | from pathlib import Path 3 | from typing import Optional 4 | import toolz as tz 5 | import pytest 6 | import json 7 | 8 | import call_map.project_settings_module 9 | from call_map.gui import make_app 10 | from call_map.core import UserScopeSettings 11 | from call_map.config import user_config 12 | 13 | 14 | test_modules_dir = Path(__file__).parent.joinpath("test_modules") 15 | 16 | 17 | @pytest.fixture(scope='module') 18 | def testing_project_directory(tmpdir_factory): 19 | return Path(str(tmpdir_factory.mktemp('project_directory'))) 20 | 21 | 22 | @pytest.fixture(scope='module') 23 | def second_project_directory(tmpdir_factory): 24 | return Path(str(tmpdir_factory.mktemp('project_directory'))) 25 | 26 | 27 | @pytest.fixture(scope='module') 28 | def testing_ui(testing_project_directory): 29 | '''Returns ui_toplevel module for the app''' 30 | 31 | module_names = ['simple_test_package'] 32 | #module_names = ['toolz'] 33 | user_scope_settings = UserScopeSettings( 34 | module_names=module_names, 35 | file_names=[], 36 | include_runtime_sys_path=True, 37 | add_to_sys_path=[test_modules_dir]) 38 | ui_toplevel = make_app(user_scope_settings, 39 | project_directory=testing_project_directory, 40 | enable_ipython_support=True, 41 | show_gui=False) 42 | 43 | return ui_toplevel 44 | 45 | 46 | def iterListWidget(ll): 47 | for ii in range(ll.count()): 48 | yield ll.item(ii) 49 | 50 | 51 | def append_to_list_settings_widget(list_settings_widget, item_name): 52 | textEdit = list_settings_widget.textEdit 53 | jj = json.loads(textEdit.toPlainText()) 54 | jj.append(item_name) 55 | textEdit.setPlainText(json.dumps(jj)) 56 | list_settings_widget.commit() 57 | 58 | 59 | def test_add_module(testing_ui): 60 | append_to_list_settings_widget(testing_ui.settings_widget.module_settings_widget, 61 | 'json') 62 | assert 'json' in testing_ui.project.settings[call_map.project_settings_module.modules] 63 | 64 | 65 | def test_add_bookmark(testing_ui): 66 | testing_ui.map_widget.callLists 67 | 68 | map_widget = testing_ui.map_widget 69 | 70 | user_config.session_overrides['MULTITHREADING'] = False 71 | user_config.session_overrides['EXPERIMENTAL_MODE'] = False 72 | 73 | # Step 1: Choose a path and bookmark it. 74 | for ii, target_name in enumerate(['simple_test_package', 'bb', 'bar', 'foo']): 75 | for item in iterListWidget(map_widget.callLists[ii]): 76 | if item.node.code_element.name == target_name: 77 | map_widget.callLists[ii].setCurrentItem(item) 78 | 79 | bookmarked_node_path = list(map_widget.node_path()) 80 | bookmarks_widget = testing_ui.settings_widget.bookmarks_widget 81 | bookmarks_widget.addBookmark() 82 | 83 | # Step 2: Go down a different path. 84 | for ii, target_name in enumerate(['simple_test_package', 'aa']): 85 | for item in iterListWidget(map_widget.callLists[ii]): 86 | if item.node.code_element.name == target_name: 87 | map_widget.callLists[ii].setCurrentItem(item) 88 | 89 | assert not all(aa.code_element == bb.code_element 90 | for aa, bb in 91 | zip(map_widget.node_path(), bookmarked_node_path)) 92 | 93 | # Step 3: Revisit bookmark 94 | assert bookmarks_widget.listWidget.count() == 1 95 | bookmarks_widget.listWidget.setCurrentRow(0) 96 | bookmarks_widget.visitBookmark() 97 | 98 | assert all(aa.code_element == bb.code_element 99 | for aa, bb in 100 | zip(map_widget.node_path(), bookmarked_node_path)) 101 | 102 | 103 | 104 | def test_change_project_directory(testing_ui, testing_project_directory, 105 | second_project_directory): 106 | 107 | def change_project_directory(project_directory): 108 | textEdit = testing_ui.settings_widget.project_settings_widget.textEdit 109 | jj = json.loads(textEdit.toPlainText()) 110 | jj['project_directory'] = str(project_directory) 111 | textEdit.setPlainText(json.dumps(jj)) 112 | testing_ui.settings_widget.project_settings_widget.commit() 113 | 114 | original_project = call_map.project_settings_module.Project(testing_project_directory) 115 | 116 | original_project.update_settings(original_project.load_from_persistent_storage()) 117 | 118 | assert original_project.settings[call_map.project_settings_module.modules] 119 | 120 | change_project_directory(second_project_directory) 121 | second_project = call_map.project_settings_module.Project(second_project_directory) 122 | second_project.update_settings(second_project.load_from_persistent_storage()) 123 | 124 | assert (testing_ui.settings_widget.project_settings_widget.project.project_directory 125 | == second_project_directory) 126 | 127 | assert tz.assoc_in( 128 | original_project.settings, 129 | [call_map.project_settings_module.project_settings, 'project_directory'], 130 | second_project.project_directory 131 | ) == second_project.settings 132 | 133 | change_project_directory(testing_project_directory) 134 | 135 | 136 | def test_change_sys_path(): 137 | pass 138 | 139 | 140 | def test_change_scripts(testing_ui): 141 | append_to_list_settings_widget(testing_ui.settings_widget.script_settings_widget, 142 | __file__) 143 | assert Path(__file__) in testing_ui.project.settings[call_map.project_settings_module.scripts] -------------------------------------------------------------------------------- /tests/test_ui.py: -------------------------------------------------------------------------------- 1 | from typing import Optional 2 | from pathlib import Path 3 | from concurrent.futures import wait 4 | 5 | from call_map.gui import make_app 6 | from call_map.core import UserScopeSettings, ScopeSettings, OrganizerNode, CodeElement 7 | from call_map.config import user_config 8 | 9 | test_modules_dir = Path(__file__).parent.joinpath("test_modules") 10 | 11 | 12 | def create_testing_app(project_directory: Optional[str]): 13 | module_names = ['simple_test_package'] 14 | #module_names = ['toolz'] 15 | user_scope_settings = UserScopeSettings( 16 | module_names=module_names, 17 | file_names=[], 18 | include_runtime_sys_path=True, 19 | add_to_sys_path=[test_modules_dir]) 20 | ui_toplevel = make_app(user_scope_settings, project_directory=project_directory, enable_ipython_support=True) 21 | 22 | return ui_toplevel 23 | 24 | 25 | def iterListWidget(ll): 26 | for ii in range(ll.count()): 27 | yield ll.item(ii) 28 | 29 | 30 | def test_definition_resolution(): 31 | # create application, open on 32 | ui_toplevel = create_testing_app(project_directory=None) 33 | 34 | map_widget = ui_toplevel.map_widget 35 | 36 | user_config.session_overrides['MULTITHREADING'] = False 37 | user_config.session_overrides['EXPERIMENTAL_MODE'] = False 38 | 39 | for ii, target_name in enumerate(['simple_test_package', 'aa', 'foo', 'bar']): 40 | for item in iterListWidget(map_widget.callLists[ii]): 41 | if item.node.code_element.name == target_name: 42 | map_widget.callLists[ii].setCurrentItem(item) 43 | break 44 | else: 45 | raise ValueError('Could not find {}'.format(target_name)) 46 | 47 | # test that the 'bar' node has the correct properties. TODO: test more properties 48 | assert map_widget.callLists[3].currentItem().node.code_element.module == 'bb' 49 | 50 | #ll = map_widget.callLists[0] 51 | #print(ll.node) 52 | #print(ll.node.children) 53 | 54 | #print(list(iterListWidget(ui_toplevel.map_widget.callLists[0]))[0]) 55 | #print(list(ui_toplevel.map_widget.node_path())) 56 | 57 | 58 | def test_usages_resolution(): 59 | # create application, open on 60 | ui_toplevel = create_testing_app(project_directory=None) 61 | 62 | map_widget = ui_toplevel.map_widget 63 | 64 | user_config.session_overrides['MULTITHREADING'] = False 65 | user_config.session_overrides['EXPERIMENTAL_MODE'] = False 66 | 67 | for ii, target_name in enumerate(['simple_test_package', 'bb', 'bar', 'foo']): 68 | for item in iterListWidget(map_widget.callLists[ii]): 69 | if item.node.code_element.name == target_name: 70 | map_widget.callLists[ii].setCurrentItem(item) 71 | 72 | # test that we found the usage of bar in the function `aa.foo` 73 | assert map_widget.callLists[3].currentItem().node.code_element.role == 'parent' 74 | assert map_widget.callLists[3].currentItem().node.code_element.type == 'function' 75 | assert map_widget.callLists[3].currentItem().node.code_element.module == 'aa' 76 | 77 | 78 | def test_definition_resolution_with_script(): 79 | # TODO: add a script node, test definition resolution. 80 | pass 81 | 82 | 83 | def test_usages_resolution_with_script(): 84 | # TODO: add a script node, test usages resolution. 85 | pass 86 | --------------------------------------------------------------------------------