├── .gitignore ├── .pre-commit-config.yaml ├── AUTHORS.md ├── LICENSE.md ├── README.md ├── graph0.png ├── graph0.svg ├── makedist.sh ├── modvis.py ├── pyan ├── __init__.py ├── __main__.py ├── analyzer.py ├── anutils.py ├── callgraph.html ├── main.py ├── node.py ├── sphinx.py ├── visgraph.py └── writers.py ├── pyproject.toml ├── pytest.ini ├── requirements.txt ├── setup.cfg ├── setup.py ├── tests ├── old_tests │ ├── issue2 │ │ ├── pyan_err.py │ │ └── run.sh │ ├── issue3 │ │ └── testi.py │ └── issue5 │ │ ├── meas_xrd.py │ │ ├── plot_xrd.py │ │ ├── relimport.py │ │ └── run.sh ├── test_analyzer.py └── test_code │ ├── __init__.py │ ├── submodule1.py │ ├── submodule2.py │ ├── subpackage1 │ ├── __init__.py │ └── submodule1.py │ └── subpackage2 │ └── submodule_hidden1.py ├── uploaddist.sh └── visualize_pyan_architecture.sh /.gitignore: -------------------------------------------------------------------------------- 1 | # based on https://github.com/github/gitignore/blob/master/Python.gitignore 2 | *.csv 3 | *.pkl 4 | *.joblib 5 | *.msgpack 6 | .DS_Store 7 | .ipynb_checkpoints 8 | .venv/ 9 | Endpoint_test/ 10 | run_simulator.py 11 | __pycache__/ 12 | 13 | 14 | # Byte-compiled / optimized / DLL files 15 | __pycache__/ 16 | *.py[cod] 17 | *$py.class 18 | 19 | # C extensions 20 | *.so 21 | 22 | # Distribution / packaging 23 | .Python 24 | build/ 25 | develop-eggs/ 26 | dist/ 27 | downloads/ 28 | eggs/ 29 | .eggs/ 30 | lib/ 31 | lib64/ 32 | parts/ 33 | sdist/ 34 | var/ 35 | wheels/ 36 | share/python-wheels/ 37 | *.egg-info/ 38 | .installed.cfg 39 | *.egg 40 | MANIFEST 41 | 42 | # PyInstaller 43 | # Usually these files are written by a python script from a template 44 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 45 | *.manifest 46 | *.spec 47 | 48 | # Installer logs 49 | pip-log.txt 50 | pip-delete-this-directory.txt 51 | 52 | # Unit test / coverage reports 53 | htmlcov/ 54 | .tox/ 55 | .nox/ 56 | .coverage 57 | .coverage.* 58 | .cache 59 | nosetests.xml 60 | coverage.xml 61 | *.cover 62 | *.py,cover 63 | .hypothesis/ 64 | .pytest_cache/ 65 | cover/ 66 | 67 | # Translations 68 | *.mo 69 | *.pot 70 | 71 | # Django stuff: 72 | *.log 73 | local_settings.py 74 | db.sqlite3 75 | db.sqlite3-journal 76 | 77 | # Flask stuff: 78 | instance/ 79 | .webassets-cache 80 | 81 | # Scrapy stuff: 82 | .scrapy 83 | 84 | # Sphinx documentation 85 | docs/_build/ 86 | docs/source/api 87 | 88 | # PyBuilder 89 | .pybuilder/ 90 | target/ 91 | 92 | # Jupyter Notebook 93 | .ipynb_checkpoints 94 | 95 | # IPython 96 | profile_default/ 97 | ipython_config.py 98 | 99 | # pyenv 100 | # For a library or package, you might want to ignore these files since the code is 101 | # intended to run in multiple environments; otherwise, check them in: 102 | # .python-version 103 | 104 | # pipenv 105 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 106 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 107 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 108 | # install all needed dependencies. 109 | #Pipfile.lock 110 | 111 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 112 | __pypackages__/ 113 | 114 | # Celery stuff 115 | celerybeat-schedule 116 | celerybeat.pid 117 | 118 | # SageMath parsed files 119 | *.sage.py 120 | 121 | # Environments 122 | .env 123 | .venv 124 | env/ 125 | venv/ 126 | ENV/ 127 | env.bak/ 128 | venv.bak/ 129 | 130 | # Spyder project settings 131 | .spyderproject 132 | .spyproject 133 | 134 | # Rope project settings 135 | .ropeproject 136 | 137 | # mkdocs documentation 138 | /site 139 | 140 | # mypy 141 | .mypy_cache/ 142 | .dmypy.json 143 | dmypy.json 144 | 145 | # Pyre type checker 146 | .pyre/ 147 | 148 | # pytype static type analyzer 149 | .pytype/ 150 | 151 | # Cython debug symbols 152 | cython_debug/ 153 | 154 | 155 | # others 156 | VERSION 157 | coverage.xml 158 | junit.xml 159 | htmlcov 160 | 161 | # editors 162 | .idea/ 163 | .history/ 164 | .vscode/ 165 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | # See https://pre-commit.com for more information 2 | # See https://pre-commit.com/hooks.html for more hooks 3 | repos: 4 | - repo: https://github.com/pre-commit/pre-commit-hooks 5 | rev: v3.3.0 6 | hooks: 7 | - id: trailing-whitespace 8 | - id: end-of-file-fixer 9 | - repo: https://gitlab.com/pycqa/flake8 10 | rev: "" 11 | hooks: 12 | - id: flake8 13 | - repo: https://github.com/pre-commit/mirrors-isort 14 | rev: v5.6.4 15 | hooks: 16 | - id: isort 17 | - repo: https://github.com/psf/black 18 | rev: 20.8b1 19 | hooks: 20 | - id: black 21 | -------------------------------------------------------------------------------- /AUTHORS.md: -------------------------------------------------------------------------------- 1 | Original [pyan.py](https://github.com/ejrh/ejrh/blob/master/utils/pyan.py) for Python 2 by Edmund Horner, 2012. [Original blog post with explanation](http://ejrh.wordpress.com/2012/01/31/call-graphs-in-python-part-2/). 2 | 3 | [Coloring and grouping](https://ejrh.wordpress.com/2012/08/18/coloured-call-graphs/) for GraphViz output by Juha Jeronen. 4 | 5 | [Git repository cleanup](https://github.com/davidfraser/pyan/) and maintenance by David Fraser. 6 | 7 | [yEd GraphML output, and framework for easily adding new output formats](https://github.com/davidfraser/pyan/pull/1) by Patrick Massot. 8 | 9 | A bugfix [[2]](https://github.com/davidfraser/pyan/pull/2) and the option `--dot-rankdir` [[3]](https://github.com/davidfraser/pyan/pull/3) contributed by GitHub user ch41rmn. 10 | 11 | A bug in `.tgf` output [[4]](https://github.com/davidfraser/pyan/pull/4) pointed out and fix suggested by Adam Eijdenberg. 12 | 13 | This Python 3 port, analyzer expansion, and additional refactoring by Juha Jeronen. 14 | 15 | HTML and SVG export by Jan Beitner. 16 | 17 | Support for relative imports by Jan Beitner and Rakan Alanazi. 18 | 19 | Further contributions by Ioannis Filippidis, Jan Malek, José Eduardo Montenegro Cavalcanti de Oliveira, Mantas Zimnickas, Sam Basak, Brady Deetz, and GitHub user dmfreemon. 20 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | ### GNU GENERAL PUBLIC LICENSE 2 | 3 | Version 2, June 1991 4 | 5 | Copyright (C) 1989, 1991 Free Software Foundation, Inc. 6 | 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA 7 | 8 | Everyone is permitted to copy and distribute verbatim copies 9 | of this license document, but changing it is not allowed. 10 | 11 | ### Preamble 12 | 13 | The licenses for most software are designed to take away your freedom 14 | to share and change it. By contrast, the GNU General Public License is 15 | intended to guarantee your freedom to share and change free 16 | software--to make sure the software is free for all its users. This 17 | General Public License applies to most of the Free Software 18 | Foundation's software and to any other program whose authors commit to 19 | using it. (Some other Free Software Foundation software is covered by 20 | the GNU Lesser General Public License instead.) You can apply it to 21 | your programs, too. 22 | 23 | When we speak of free software, we are referring to freedom, not 24 | price. Our General Public Licenses are designed to make sure that you 25 | have the freedom to distribute copies of free software (and charge for 26 | this service if you wish), that you receive source code or can get it 27 | if you want it, that you can change the software or use pieces of it 28 | in new free programs; and that you know you can do these things. 29 | 30 | To protect your rights, we need to make restrictions that forbid 31 | anyone to deny you these rights or to ask you to surrender the rights. 32 | These restrictions translate to certain responsibilities for you if 33 | you distribute copies of the software, or if you modify it. 34 | 35 | For example, if you distribute copies of such a program, whether 36 | gratis or for a fee, you must give the recipients all the rights that 37 | you have. You must make sure that they, too, receive or can get the 38 | source code. And you must show them these terms so they know their 39 | rights. 40 | 41 | We protect your rights with two steps: (1) copyright the software, and 42 | (2) offer you this license which gives you legal permission to copy, 43 | distribute and/or modify the software. 44 | 45 | Also, for each author's protection and ours, we want to make certain 46 | that everyone understands that there is no warranty for this free 47 | software. If the software is modified by someone else and passed on, 48 | we want its recipients to know that what they have is not the 49 | original, so that any problems introduced by others will not reflect 50 | on the original authors' reputations. 51 | 52 | Finally, any free program is threatened constantly by software 53 | patents. We wish to avoid the danger that redistributors of a free 54 | program will individually obtain patent licenses, in effect making the 55 | program proprietary. To prevent this, we have made it clear that any 56 | patent must be licensed for everyone's free use or not licensed at 57 | all. 58 | 59 | The precise terms and conditions for copying, distribution and 60 | modification follow. 61 | 62 | ### TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION 63 | 64 | **0.** This License applies to any program or other work which 65 | contains a notice placed by the copyright holder saying it may be 66 | distributed under the terms of this General Public License. The 67 | "Program", below, refers to any such program or work, and a "work 68 | based on the Program" means either the Program or any derivative work 69 | under copyright law: that is to say, a work containing the Program or 70 | a portion of it, either verbatim or with modifications and/or 71 | translated into another language. (Hereinafter, translation is 72 | included without limitation in the term "modification".) Each licensee 73 | is addressed as "you". 74 | 75 | Activities other than copying, distribution and modification are not 76 | covered by this License; they are outside its scope. The act of 77 | running the Program is not restricted, and the output from the Program 78 | is covered only if its contents constitute a work based on the Program 79 | (independent of having been made by running the Program). Whether that 80 | is true depends on what the Program does. 81 | 82 | **1.** You may copy and distribute verbatim copies of the Program's 83 | source code as you receive it, in any medium, provided that you 84 | conspicuously and appropriately publish on each copy an appropriate 85 | copyright notice and disclaimer of warranty; keep intact all the 86 | notices that refer to this License and to the absence of any warranty; 87 | and give any other recipients of the Program a copy of this License 88 | along with the Program. 89 | 90 | You may charge a fee for the physical act of transferring a copy, and 91 | you may at your option offer warranty protection in exchange for a 92 | fee. 93 | 94 | **2.** You may modify your copy or copies of the Program or any 95 | portion of it, thus forming a work based on the Program, and copy and 96 | distribute such modifications or work under the terms of Section 1 97 | above, provided that you also meet all of these conditions: 98 | 99 | 100 | **a)** You must cause the modified files to carry prominent notices 101 | stating that you changed the files and the date of any change. 102 | 103 | 104 | **b)** You must cause any work that you distribute or publish, that in 105 | whole or in part contains or is derived from the Program or any part 106 | thereof, to be licensed as a whole at no charge to all third parties 107 | under the terms of this License. 108 | 109 | 110 | **c)** If the modified program normally reads commands interactively 111 | when run, you must cause it, when started running for such interactive 112 | use in the most ordinary way, to print or display an announcement 113 | including an appropriate copyright notice and a notice that there is 114 | no warranty (or else, saying that you provide a warranty) and that 115 | users may redistribute the program under these conditions, and telling 116 | the user how to view a copy of this License. (Exception: if the 117 | Program itself is interactive but does not normally print such an 118 | announcement, your work based on the Program is not required to print 119 | an announcement.) 120 | 121 | These requirements apply to the modified work as a whole. If 122 | identifiable sections of that work are not derived from the Program, 123 | and can be reasonably considered independent and separate works in 124 | themselves, then this License, and its terms, do not apply to those 125 | sections when you distribute them as separate works. But when you 126 | distribute the same sections as part of a whole which is a work based 127 | on the Program, the distribution of the whole must be on the terms of 128 | this License, whose permissions for other licensees extend to the 129 | entire whole, and thus to each and every part regardless of who wrote 130 | it. 131 | 132 | Thus, it is not the intent of this section to claim rights or contest 133 | your rights to work written entirely by you; rather, the intent is to 134 | exercise the right to control the distribution of derivative or 135 | collective works based on the Program. 136 | 137 | In addition, mere aggregation of another work not based on the Program 138 | with the Program (or with a work based on the Program) on a volume of 139 | a storage or distribution medium does not bring the other work under 140 | the scope of this License. 141 | 142 | **3.** You may copy and distribute the Program (or a work based on it, 143 | under Section 2) in object code or executable form under the terms of 144 | Sections 1 and 2 above provided that you also do one of the following: 145 | 146 | 147 | **a)** Accompany it with the complete corresponding machine-readable 148 | source code, which must be distributed under the terms of Sections 1 149 | and 2 above on a medium customarily used for software interchange; or, 150 | 151 | 152 | **b)** Accompany it with a written offer, valid for at least three 153 | years, to give any third party, for a charge no more than your cost of 154 | physically performing source distribution, a complete machine-readable 155 | copy of the corresponding source code, to be distributed under the 156 | terms of Sections 1 and 2 above on a medium customarily used for 157 | software interchange; or, 158 | 159 | 160 | **c)** Accompany it with the information you received as to the offer 161 | to distribute corresponding source code. (This alternative is allowed 162 | only for noncommercial distribution and only if you received the 163 | program in object code or executable form with such an offer, in 164 | accord with Subsection b above.) 165 | 166 | The source code for a work means the preferred form of the work for 167 | making modifications to it. For an executable work, complete source 168 | code means all the source code for all modules it contains, plus any 169 | associated interface definition files, plus the scripts used to 170 | control compilation and installation of the executable. However, as a 171 | special exception, the source code distributed need not include 172 | anything that is normally distributed (in either source or binary 173 | form) with the major components (compiler, kernel, and so on) of the 174 | operating system on which the executable runs, unless that component 175 | itself accompanies the executable. 176 | 177 | If distribution of executable or object code is made by offering 178 | access to copy from a designated place, then offering equivalent 179 | access to copy the source code from the same place counts as 180 | distribution of the source code, even though third parties are not 181 | compelled to copy the source along with the object code. 182 | 183 | **4.** You may not copy, modify, sublicense, or distribute the Program 184 | except as expressly provided under this License. Any attempt otherwise 185 | to copy, modify, sublicense or distribute the Program is void, and 186 | will automatically terminate your rights under this License. However, 187 | parties who have received copies, or rights, from you under this 188 | License will not have their licenses terminated so long as such 189 | parties remain in full compliance. 190 | 191 | **5.** You are not required to accept this License, since you have not 192 | signed it. However, nothing else grants you permission to modify or 193 | distribute the Program or its derivative works. These actions are 194 | prohibited by law if you do not accept this License. Therefore, by 195 | modifying or distributing the Program (or any work based on the 196 | Program), you indicate your acceptance of this License to do so, and 197 | all its terms and conditions for copying, distributing or modifying 198 | the Program or works based on it. 199 | 200 | **6.** Each time you redistribute the Program (or any work based on 201 | the Program), the recipient automatically receives a license from the 202 | original licensor to copy, distribute or modify the Program subject to 203 | these terms and conditions. You may not impose any further 204 | restrictions on the recipients' exercise of the rights granted herein. 205 | You are not responsible for enforcing compliance by third parties to 206 | this License. 207 | 208 | **7.** If, as a consequence of a court judgment or allegation of 209 | patent infringement or for any other reason (not limited to patent 210 | issues), conditions are imposed on you (whether by court order, 211 | agreement or otherwise) that contradict the conditions of this 212 | License, they do not excuse you from the conditions of this License. 213 | If you cannot distribute so as to satisfy simultaneously your 214 | obligations under this License and any other pertinent obligations, 215 | then as a consequence you may not distribute the Program at all. For 216 | example, if a patent license would not permit royalty-free 217 | redistribution of the Program by all those who receive copies directly 218 | or indirectly through you, then the only way you could satisfy both it 219 | and this License would be to refrain entirely from distribution of the 220 | Program. 221 | 222 | If any portion of this section is held invalid or unenforceable under 223 | any particular circumstance, the balance of the section is intended to 224 | apply and the section as a whole is intended to apply in other 225 | circumstances. 226 | 227 | It is not the purpose of this section to induce you to infringe any 228 | patents or other property right claims or to contest validity of any 229 | such claims; this section has the sole purpose of protecting the 230 | integrity of the free software distribution system, which is 231 | implemented by public license practices. Many people have made 232 | generous contributions to the wide range of software distributed 233 | through that system in reliance on consistent application of that 234 | system; it is up to the author/donor to decide if he or she is willing 235 | to distribute software through any other system and a licensee cannot 236 | impose that choice. 237 | 238 | This section is intended to make thoroughly clear what is believed to 239 | be a consequence of the rest of this License. 240 | 241 | **8.** If the distribution and/or use of the Program is restricted in 242 | certain countries either by patents or by copyrighted interfaces, the 243 | original copyright holder who places the Program under this License 244 | may add an explicit geographical distribution limitation excluding 245 | those countries, so that distribution is permitted only in or among 246 | countries not thus excluded. In such case, this License incorporates 247 | the limitation as if written in the body of this License. 248 | 249 | **9.** The Free Software Foundation may publish revised and/or new 250 | versions of the General Public License from time to time. Such new 251 | versions will be similar in spirit to the present version, but may 252 | differ in detail to address new problems or concerns. 253 | 254 | Each version is given a distinguishing version number. If the Program 255 | specifies a version number of this License which applies to it and 256 | "any later version", you have the option of following the terms and 257 | conditions either of that version or of any later version published by 258 | the Free Software Foundation. If the Program does not specify a 259 | version number of this License, you may choose any version ever 260 | published by the Free Software Foundation. 261 | 262 | **10.** If you wish to incorporate parts of the Program into other 263 | free programs whose distribution conditions are different, write to 264 | the author to ask for permission. For software which is copyrighted by 265 | the Free Software Foundation, write to the Free Software Foundation; 266 | we sometimes make exceptions for this. Our decision will be guided by 267 | the two goals of preserving the free status of all derivatives of our 268 | free software and of promoting the sharing and reuse of software 269 | generally. 270 | 271 | **NO WARRANTY** 272 | 273 | **11.** BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO 274 | WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. 275 | EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR 276 | OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY 277 | KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE 278 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR 279 | PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE 280 | PROGRAM IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME 281 | THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION. 282 | 283 | **12.** IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN 284 | WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY 285 | AND/OR REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU 286 | FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR 287 | CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE 288 | PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING 289 | RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A 290 | FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), EVEN IF 291 | SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH 292 | DAMAGES. 293 | 294 | ### END OF TERMS AND CONDITIONS 295 | 296 | ### How to Apply These Terms to Your New Programs 297 | 298 | If you develop a new program, and you want it to be of the greatest 299 | possible use to the public, the best way to achieve this is to make it 300 | free software which everyone can redistribute and change under these 301 | terms. 302 | 303 | To do so, attach the following notices to the program. It is safest to 304 | attach them to the start of each source file to most effectively 305 | convey the exclusion of warranty; and each file should have at least 306 | the "copyright" line and a pointer to where the full notice is found. 307 | 308 | one line to give the program's name and an idea of what it does. 309 | Copyright (C) yyyy name of author 310 | 311 | This program is free software; you can redistribute it and/or 312 | modify it under the terms of the GNU General Public License 313 | as published by the Free Software Foundation; either version 2 314 | of the License, or (at your option) any later version. 315 | 316 | This program is distributed in the hope that it will be useful, 317 | but WITHOUT ANY WARRANTY; without even the implied warranty of 318 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 319 | GNU General Public License for more details. 320 | 321 | You should have received a copy of the GNU General Public License 322 | along with this program; if not, write to the Free Software 323 | Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. 324 | 325 | Also add information on how to contact you by electronic and paper 326 | mail. 327 | 328 | If the program is interactive, make it output a short notice like this 329 | when it starts in an interactive mode: 330 | 331 | Gnomovision version 69, Copyright (C) year name of author 332 | Gnomovision comes with ABSOLUTELY NO WARRANTY; for details 333 | type `show w'. This is free software, and you are welcome 334 | to redistribute it under certain conditions; type `show c' 335 | for details. 336 | 337 | The hypothetical commands \`show w' and \`show c' should show the 338 | appropriate parts of the General Public License. Of course, the 339 | commands you use may be called something other than \`show w' and 340 | \`show c'; they could even be mouse-clicks or menu items--whatever 341 | suits your program. 342 | 343 | You should also get your employer (if you work as a programmer) or 344 | your school, if any, to sign a "copyright disclaimer" for the program, 345 | if necessary. Here is a sample; alter the names: 346 | 347 | Yoyodyne, Inc., hereby disclaims all copyright 348 | interest in the program `Gnomovision' 349 | (which makes passes at compilers) written 350 | by James Hacker. 351 | 352 | signature of Ty Coon, 1 April 1989 353 | Ty Coon, President of Vice 354 | 355 | This General Public License does not permit incorporating your program 356 | into proprietary programs. If your program is a subroutine library, 357 | you may consider it more useful to permit linking proprietary 358 | applications with the library. If this is what you want to do, use the 359 | [GNU Lesser General Public 360 | License](http://www.gnu.org/licenses/lgpl.html) instead of this 361 | License. 362 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Pyan3 2 | 3 | Offline call graph generator for Python 3 4 | 5 | [![Build Status](https://travis-ci.com/edumco/pyan.svg?branch=master)](https://travis-ci.com/edumco/pyan) 6 | [![FOSSA Status](https://app.fossa.io/api/projects/git%2Bgithub.com%2Fedumco%2Fpyan.svg?type=shield)](https://app.fossa.io/projects/git%2Bgithub.com%2Fedumco%2Fpyan?ref=badge_shield) 7 | [![Codacy Badge](https://api.codacy.com/project/badge/Grade/7cba5ba5d3694a42a1252243e3634b5e)](https://www.codacy.com/manual/edumco/pyan?utm_source=github.com&utm_medium=referral&utm_content=edumco/pyan&utm_campaign=Badge_Grade) 8 | ![PyPI - Python Version](https://img.shields.io/pypi/pyversions/pyan3) 9 | 10 | Pyan takes one or more Python source files, performs a (rather superficial) static analysis, and constructs a directed graph of the objects in the combined source, and how they define or use each other. The graph can be output for rendering by GraphViz or yEd. 11 | 12 | This project has 2 official repositories: 13 | 14 | - The original stable [davidfraser/pyan](https://github.com/davidfraser/pyan). 15 | - The development repository [Technologicat/pyan](https://github.com/Technologicat/pyan) 16 | 17 | > The PyPI package [pyan3](https://pypi.org/project/pyan3/) is built from development 18 | 19 | ## About 20 | 21 | [![Example output](graph0.png "Example: GraphViz rendering of Pyan output (click for .svg)")](graph0.svg) 22 | 23 | **Defines** relations are drawn with _dotted gray arrows_. 24 | 25 | **Uses** relations are drawn with _black solid arrows_. Recursion is indicated by an arrow from a node to itself. [Mutual recursion](https://en.wikipedia.org/wiki/Mutual_recursion#Basic_examples) between nodes X and Y is indicated by a pair of arrows, one pointing from X to Y, and the other from Y to X. 26 | 27 | **Nodes** are always filled, and made translucent to clearly show any arrows passing underneath them. This is especially useful for large graphs with GraphViz's `fdp` filter. If colored output is not enabled, the fill is white. 28 | 29 | In **node coloring**, the [HSL](https://en.wikipedia.org/wiki/HSL_and_HSV) color model is used. The **hue** is determined by the _filename_ the node comes from. The **lightness** is determined by _depth of namespace nesting_, with darker meaning more deeply nested. Saturation is constant. The spacing between different hues depends on the number of files analyzed; better results are obtained for fewer files. 30 | 31 | **Groups** are filled with translucent gray to avoid clashes with any node color. 32 | 33 | The nodes can be **annotated** by _filename and source line number_ information. 34 | 35 | ## Note 36 | 37 | The static analysis approach Pyan takes is different from running the code and seeing which functions are called and how often. There are various tools that will generate a call graph that way, usually using a debugger or profiling trace hooks, such as [Python Call Graph](https://pycallgraph.readthedocs.org/). 38 | 39 | In Pyan3, the analyzer was ported from `compiler` ([good riddance](https://stackoverflow.com/a/909172)) to a combination of `ast` and `symtable`, and slightly extended. 40 | 41 | # Install 42 | 43 | pip install pyan3 44 | 45 | # Usage 46 | 47 | See `pyan3 --help`. 48 | 49 | Example: 50 | 51 | `pyan *.py --uses --no-defines --colored --grouped --annotated --dot >myuses.dot` 52 | 53 | Then render using your favorite GraphViz filter, mainly `dot` or `fdp`: 54 | 55 | `dot -Tsvg myuses.dot >myuses.svg` 56 | 57 | Or use directly 58 | 59 | `pyan *.py --uses --no-defines --colored --grouped --annotated --svg >myuses.svg` 60 | 61 | You can also export as an interactive HTML 62 | 63 | `pyan *.py --uses --no-defines --colored --grouped --annotated --html > myuses.html` 64 | 65 | Alternatively, you can call `pyan` from a script 66 | 67 | ```shell script 68 | import pyan 69 | from IPython.display import HTML 70 | HTML(pyan.create_callgraph(filenames="**/*.py", format="html")) 71 | ``` 72 | 73 | #### Sphinx integration 74 | 75 | You can integrate callgraphs into Sphinx. 76 | Install graphviz (e.g. via `sudo apt-get install graphviz`) and modify `source/conf.py` so that 77 | 78 | ``` 79 | # modify extensions 80 | extensions = [ 81 | ... 82 | "sphinx.ext.graphviz" 83 | "pyan.sphinx", 84 | ] 85 | 86 | # add graphviz options 87 | graphviz_output_format = "svg" 88 | ``` 89 | 90 | Now, there is a callgraph directive which has all the options of the [graphviz directive](https://www.sphinx-doc.org/en/master/usage/extensions/graphviz.html) 91 | and in addition: 92 | 93 | - **:no-groups:** (boolean flag): do not group 94 | - **:no-defines:** (boolean flag): if to not draw edges that show which functions, methods and classes are defined by a class or module 95 | - **:no-uses:** (boolean flag): if to not draw edges that show how a function uses other functions 96 | - **:no-colors:** (boolean flag): if to not color in callgraph (default is coloring) 97 | - **:nested-grops:** (boolean flag): if to group by modules and submodules 98 | - **:annotated:** (boolean flag): annotate callgraph with file names 99 | - **:direction:** (string): "horizontal" or "vertical" callgraph 100 | - **:toctree:** (string): path to toctree (as used with autosummary) to link elements of callgraph to documentation (makes all nodes clickable) 101 | - **:zoomable:** (boolean flag): enables users to zoom and pan callgraph 102 | 103 | Example to create a callgraph for the function `pyan.create_callgraph` that is 104 | zoomable, is defined from left to right and links each node to the API documentation that 105 | was created at the toctree path `api`. 106 | 107 | ``` 108 | .. callgraph:: pyan.create_callgraph 109 | :toctree: api 110 | :zoomable: 111 | :direction: horizontal 112 | ``` 113 | 114 | #### Troubleshooting 115 | 116 | If GraphViz says _trouble in init_rank_, try adding `-Gnewrank=true`, as in: 117 | 118 | `dot -Gnewrank=true -Tsvg myuses.dot >myuses.svg` 119 | 120 | Usually either old or new rank (but often not both) works; this is a long-standing GraphViz issue with complex graphs. 121 | 122 | ## Too much detail? 123 | 124 | If the graph is visually unreadable due to too much detail, consider visualizing only a subset of the files in your project. Any references to files outside the analyzed set will be considered as undefined, and will not be drawn. 125 | 126 | Currently Pyan always operates at the level of individual functions and methods; an option to visualize only relations between namespaces may (or may not) be added in a future version. 127 | 128 | # Features 129 | 130 | _Items tagged with ☆ are new in Pyan3._ 131 | 132 | **Graph creation**: 133 | 134 | - Nodes for functions and classes 135 | - Edges for defines 136 | - Edges for uses 137 | - This includes recursive calls ☆ 138 | - Grouping to represent defines, with or without nesting 139 | - Coloring of nodes by filename 140 | - Unlimited number of hues ☆ 141 | 142 | **Analysis**: 143 | 144 | - Name lookup across the given set of files 145 | - Nested function definitions 146 | - Nested class definitions ☆ 147 | - Nested attribute accesses like `self.a.b` ☆ 148 | - Inherited attributes ☆ 149 | - Pyan3 looks up also in base classes when resolving attributes. In the old Pyan, calls to inherited methods used to be picked up by `contract_nonexistents()` followed by `expand_unknowns()`, but that often generated spurious uses edges (because the wildcard to `*.name` expands to `X.name` _for all_ `X` that have an attribute called `name`.). 150 | - Resolution of `super()` based on the static type at the call site ☆ 151 | - MRO is (statically) respected in looking up inherited attributes and `super()` ☆ 152 | - Assignment tracking with lexical scoping 153 | - E.g. if `self.a = MyFancyClass()`, the analyzer knows that any references to `self.a` point to `MyFancyClass` 154 | - All binding forms are supported (assign, augassign, for, comprehensions, generator expressions, with) ☆ 155 | - Name clashes between `for` loop counter variables and functions or classes defined elsewhere no longer confuse Pyan. 156 | - `self` is defined by capturing the name of the first argument of a method definition, like Python does. ☆ 157 | - Simple item-by-item tuple assignments like `x,y,z = a,b,c` ☆ 158 | - Chained assignments `a = b = c` ☆ 159 | - Local scope for lambda, listcomp, setcomp, dictcomp, genexpr ☆ 160 | - Keep in mind that list comprehensions gained a local scope (being treated like a function) only in Python 3. Thus, Pyan3, when applied to legacy Python 2 code, will give subtly wrong results if the code uses list comprehensions. 161 | - Source filename and line number annotation ☆ 162 | - The annotation is appended to the node label. If grouping is off, namespace is included in the annotation. If grouping is on, only source filename and line number information is included, because the group title already shows the namespace. 163 | 164 | ## TODO 165 | 166 | - Determine confidence of detected edges (probability that the edge is correct). Start with a binary system, with only values 1.0 and 0.0. 167 | - A fully resolved reference to a name, based on lexical scoping, has confidence 1.0. 168 | - A reference to an unknown name has confidence 0.0. 169 | - Attributes: 170 | - A fully resolved reference to a known attribute of a known object has confidence 1.0. 171 | - A reference to an unknown attribute of a known object has confidence 1.0. These are mainly generated by imports, when the imported file is not in the analyzed set. (Does this need a third value, such as 0.5?) 172 | - A reference to an attribute of an unknown object has confidence 0.0. 173 | - A wildcard and its expansions have confidence 0.0. 174 | - Effects of binding analysis? The system should not claim full confidence in a bound value, unless it fully understands both the binding syntax and the value. (Note that this is very restrictive. A function call or a list in the expression for the value will currently spoil the full analysis.) 175 | - Confidence values may need updating in pass 2. 176 | - Make the analyzer understand `del name` (probably seen as `isinstance(node.ctx, ast.Del)` in `visit_Name()`, `visit_Attribute()`) 177 | - Prefix methods by class name in the graph; create a legend for annotations. See the discussion [here](https://github.com/johnyf/pyan/issues/4). 178 | - Improve the wildcard resolution mechanism, see discussion [here](https://github.com/johnyf/pyan/issues/5). 179 | - Could record the namespace of the use site upon creating the wildcard, and check any possible resolutions against that (requiring that the resolved name is in scope at the use site)? 180 | - Add an option to visualize relations only between namespaces, useful for large projects. 181 | - Scan the nodes and edges, basically generate a new graph and visualize that. 182 | - Publish test cases. 183 | - Get rid of `self.last_value`? 184 | - Consider each specific kind of expression or statement being handled; get the relevant info directly (or by a more controlled kind of recursion) instead of `self.visit()`. 185 | - At some point, may need a second visitor class that is just a catch-all that extracts names, which is then applied to only relevant branches of the AST. 186 | - On the other hand, maybe `self.last_value` is the simplest implementation that extracts a value from an expression, and it only needs to be used in a controlled manner (as `analyze_binding()` currently does); i.e. reset before visiting, and reset immediately when done. 187 | 188 | The analyzer **does not currently support**: 189 | 190 | - Tuples/lists as first-class values (currently ignores any assignment of a tuple/list to a single name). 191 | - Support empty lists, too (for resolving method calls to `.append()` and similar). 192 | - Starred assignment `a,*b,c = d,e,f,g,h` 193 | - Slicing and indexing in assignment (`ast.Subscript`) 194 | - Additional unpacking generalizations ([PEP 448](https://www.python.org/dev/peps/pep-0448/), Python 3.5+). 195 | - Any **uses** on the RHS _at the binding site_ in all of the above are already detected by the name and attribute analyzers, but the binding information from assignments of these forms will not be recorded (at least not correctly). 196 | - Enums; need to mark the use of any of their attributes as use of the Enum. Need to detect `Enum` in `bases` during analysis of ClassDef; then tag the class as an enum and handle differently. 197 | - Resolving results of function calls, except for a very limited special case for `super()`. 198 | - Any binding of a name to a result of a function (or method) call - provided that the binding itself is understood by Pyan - will instead show in the output as binding the name to that function (or method). (This may generate some unintuitive uses edges in the graph.) 199 | - Distinguishing between different Lambdas in the same namespace (to report uses of a particular `lambda` that has been stored in `self.something`). 200 | - Type hints ([PEP 484](https://www.python.org/dev/peps/pep-0484/), Python 3.5+). 201 | - Type inference for function arguments 202 | - Either of these two could be used to bind function argument names to the appropriate object types, avoiding the need for wildcard references (especially for attribute accesses on objects passed in as function arguments). 203 | - Type inference could run as pass 3, using additional information from the state of the graph after pass 2 to connect call sites to function definitions. Alternatively, no additional pass; store the AST nodes in the earlier pass. Type inference would allow resolving some wildcards by finding the method of the actual object instance passed in. 204 | - Must understand, at the call site, whether the first positional argument in the function def is handled implicitly or not. This is found by looking at the flavor of the Node representing the call target. 205 | - Async definitions are detected, but passed through to the corresponding non-async analyzers; could be annotated. 206 | - Cython; could strip or comment out Cython-specific code as a preprocess step, then treat as Python (will need to be careful to get line numbers right). 207 | 208 | # How it works 209 | 210 | From the viewpoint of graphing the defines and uses relations, the interesting parts of the [AST](https://en.wikipedia.org/wiki/Abstract_syntax_tree) are bindings (defining new names, or assigning new values to existing names), and any name that appears in an `ast.Load` context (i.e. a use). The latter includes function calls; the function's name then appears in a load context inside the `ast.Call` node that represents the call site. 211 | 212 | Bindings are tracked, with lexical scoping, to determine which type of object, or which function, each name points to at any given point in the source code being analyzed. This allows tracking things like: 213 | 214 | ```python 215 | def some_func(): 216 | pass 217 | 218 | class MyClass: 219 | def __init__(self): 220 | self.f = some_func 221 | 222 | def dostuff(self) 223 | self.f() 224 | ``` 225 | 226 | By tracking the name `self.f`, the analyzer will see that `MyClass.dostuff()` uses `some_func()`. 227 | 228 | The analyzer also needs to keep track of what type of object `self` currently points to. In a method definition, the literal name representing `self` is captured from the argument list, as Python does; then in the lexical scope of that method, that name points to the current class (since Pyan cares only about object types, not instances). 229 | 230 | Of course, this simple approach cannot correctly track cases where the current binding of `self.f` depends on the order in which the methods of the class are executed. To keep things simple, Pyan decides to ignore this complication, just reads through the code in a linear fashion (twice so that any forward-references are picked up), and uses the most recent binding that is currently in scope. 231 | 232 | When a binding statement is encountered, the current namespace determines in which scope to store the new value for the name. Similarly, when encountering a use, the current namespace determines which object type or function to tag as the user. 233 | 234 | # Authors 235 | 236 | See [AUTHORS.md](AUTHORS.md). 237 | 238 | # License 239 | 240 | [GPL v2](LICENSE.md), as per [comments here](https://ejrh.wordpress.com/2012/08/18/coloured-call-graphs/). 241 | -------------------------------------------------------------------------------- /graph0.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/davidfraser/pyan/1df66cefd71ad57f2c22003fd1eee193ee31b666/graph0.png -------------------------------------------------------------------------------- /graph0.svg: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 9 | 10 | G 11 | 12 | cluster_main 13 | 14 | main 15 | 16 | cluster_model 17 | 18 | model 19 | 20 | cluster_model__Model 21 | 22 | model.Model 23 | 24 | cluster_modelbase 25 | 26 | modelbase 27 | 28 | cluster_modelbase__ModelBase 29 | 30 | modelbase.ModelBase 31 | 32 | cluster_stage1 33 | 34 | stage1 35 | 36 | cluster_stage1__CodeGenerator 37 | 38 | stage1.CodeGenerator 39 | 40 | cluster_stage1__CodeGenerator__run 41 | 42 | stage1.CodeGenerator.run 43 | 44 | cluster_stage2 45 | 46 | stage2 47 | 48 | cluster_stage2__CodeGenerator 49 | 50 | stage2.CodeGenerator 51 | 52 | cluster_stage2__CodeGenerator__analyze_interface 53 | 54 | stage2.CodeGenerator.analyze_interface 55 | 56 | cluster_stage2__CodeGenerator__run 57 | 58 | stage2.CodeGenerator.run 59 | 60 | cluster_stage2__CodeGenerator__validate_bound_args 61 | 62 | stage2.CodeGenerator.validate_bound_args 63 | 64 | cluster_stage2__main 65 | 66 | stage2.main 67 | 68 | 69 | main 70 | 71 | main 72 | 73 | 74 | stage1 75 | 76 | stage1 77 | 78 | 79 | main->stage1 80 | 81 | 82 | 83 | 84 | stage2 85 | 86 | stage2 87 | 88 | 89 | main->stage2 90 | 91 | 92 | 93 | 94 | main__main 95 | 96 | main 97 | (main.py:47) 98 | 99 | 100 | main->main__main 101 | 102 | 103 | 104 | 105 | main->main__main 106 | 107 | 108 | 109 | 110 | model 111 | 112 | model 113 | 114 | 115 | modelbase 116 | 117 | modelbase 118 | 119 | 120 | model->modelbase 121 | 122 | 123 | 124 | 125 | model__Model 126 | 127 | Model 128 | (model.py:32) 129 | 130 | 131 | model->model__Model 132 | 133 | 134 | 135 | 136 | model__main 137 | 138 | main 139 | (model.py:368) 140 | 141 | 142 | model->model__main 143 | 144 | 145 | 146 | 147 | model->model__main 148 | 149 | 150 | 151 | 152 | modelbase__ModelBase 153 | 154 | ModelBase 155 | (modelbase.py:16) 156 | 157 | 158 | modelbase->modelbase__ModelBase 159 | 160 | 161 | 162 | 163 | stage1->model 164 | 165 | 166 | 167 | 168 | stage1__CodeGenerator 169 | 170 | CodeGenerator 171 | (stage1.py:27) 172 | 173 | 174 | stage1->stage1__CodeGenerator 175 | 176 | 177 | 178 | 179 | stage1__main 180 | 181 | main 182 | (stage1.py:152) 183 | 184 | 185 | stage1->stage1__main 186 | 187 | 188 | 189 | 190 | stage1->stage1__main 191 | 192 | 193 | 194 | 195 | stage2__CodeGenerator 196 | 197 | CodeGenerator 198 | (stage2.py:42) 199 | 200 | 201 | stage2->stage2__CodeGenerator 202 | 203 | 204 | 205 | 206 | stage2__main 207 | 208 | main 209 | (stage2.py:499) 210 | 211 | 212 | stage2->stage2__main 213 | 214 | 215 | 216 | 217 | stage2->stage2__main 218 | 219 | 220 | 221 | 222 | main__main->stage1__CodeGenerator 223 | 224 | 225 | 226 | 227 | stage1__CodeGenerator__run 228 | 229 | run 230 | (stage1.py:32) 231 | 232 | 233 | main__main->stage1__CodeGenerator__run 234 | 235 | 236 | 237 | 238 | main__main->stage2__CodeGenerator 239 | 240 | 241 | 242 | 243 | stage2__CodeGenerator__run 244 | 245 | run 246 | (stage2.py:322) 247 | 248 | 249 | main__main->stage2__CodeGenerator__run 250 | 251 | 252 | 253 | 254 | model__Model____init__ 255 | 256 | __init__ 257 | (model.py:35) 258 | 259 | 260 | model__Model->model__Model____init__ 261 | 262 | 263 | 264 | 265 | model__Model__build_φ 266 | 267 | build_φ 268 | (model.py:85) 269 | 270 | 271 | model__Model->model__Model__build_φ 272 | 273 | 274 | 275 | 276 | model__Model__define_api 277 | 278 | define_api 279 | (model.py:142) 280 | 281 | 282 | model__Model->model__Model__define_api 283 | 284 | 285 | 286 | 287 | model__Model__define_helpers 288 | 289 | define_helpers 290 | (model.py:258) 291 | 292 | 293 | model__Model->model__Model__define_helpers 294 | 295 | 296 | 297 | 298 | model__Model__dφdq 299 | 300 | dφdq 301 | (model.py:197) 302 | 303 | 304 | model__Model->model__Model__dφdq 305 | 306 | 307 | 308 | 309 | model__Model__simplify 310 | 311 | simplify 312 | (model.py:333) 313 | 314 | 315 | model__Model->model__Model__simplify 316 | 317 | 318 | 319 | 320 | model__Model->modelbase__ModelBase 321 | 322 | 323 | 324 | 325 | model__main->model__Model 326 | 327 | 328 | 329 | 330 | modelbase__ModelBase__define_api 331 | 332 | define_api 333 | (modelbase.py:24) 334 | 335 | 336 | model__main->modelbase__ModelBase__define_api 337 | 338 | 339 | 340 | 341 | modelbase__ModelBase__define_helpers 342 | 343 | define_helpers 344 | (modelbase.py:36) 345 | 346 | 347 | model__main->modelbase__ModelBase__define_helpers 348 | 349 | 350 | 351 | 352 | model__Model__define_api->model__Model__dφdq 353 | 354 | 355 | 356 | 357 | modelbase__ModelBase__simplify 358 | 359 | simplify 360 | (modelbase.py:53) 361 | 362 | 363 | model__Model__define_helpers->modelbase__ModelBase__simplify 364 | 365 | 366 | 367 | 368 | model__Model__dφdq->model__Model__build_φ 369 | 370 | 371 | 372 | 373 | modelbase__ModelBase____init__ 374 | 375 | __init__ 376 | (modelbase.py:20) 377 | 378 | 379 | modelbase__ModelBase->modelbase__ModelBase____init__ 380 | 381 | 382 | 383 | 384 | modelbase__ModelBase->modelbase__ModelBase__define_api 385 | 386 | 387 | 388 | 389 | modelbase__ModelBase->modelbase__ModelBase__define_helpers 390 | 391 | 392 | 393 | 394 | modelbase__ModelBase->modelbase__ModelBase__simplify 395 | 396 | 397 | 398 | 399 | stage1__CodeGenerator____init__ 400 | 401 | __init__ 402 | (stage1.py:28) 403 | 404 | 405 | stage1__CodeGenerator->stage1__CodeGenerator____init__ 406 | 407 | 408 | 409 | 410 | stage1__CodeGenerator->stage1__CodeGenerator__run 411 | 412 | 413 | 414 | 415 | stage1__main->stage1__CodeGenerator 416 | 417 | 418 | 419 | 420 | stage1__main->stage1__CodeGenerator__run 421 | 422 | 423 | 424 | 425 | stage1__main->stage2__CodeGenerator__run 426 | 427 | 428 | 429 | 430 | stage1__CodeGenerator__run->model__Model 431 | 432 | 433 | 434 | 435 | stage1__CodeGenerator__run->modelbase__ModelBase__define_api 436 | 437 | 438 | 439 | 440 | stage1__CodeGenerator__run->modelbase__ModelBase__define_helpers 441 | 442 | 443 | 444 | 445 | stage1__CodeGenerator__run->modelbase__ModelBase__simplify 446 | 447 | 448 | 449 | 450 | stage1__CodeGenerator__run__kill_zero 451 | 452 | kill_zero 453 | (stage1.py:82) 454 | 455 | 456 | stage1__CodeGenerator__run->stage1__CodeGenerator__run__kill_zero 457 | 458 | 459 | 460 | 461 | stage1__CodeGenerator__run->stage1__CodeGenerator__run__kill_zero 462 | 463 | 464 | 465 | 466 | stage2__CodeGenerator____init__ 467 | 468 | __init__ 469 | (stage2.py:45) 470 | 471 | 472 | stage2__CodeGenerator->stage2__CodeGenerator____init__ 473 | 474 | 475 | 476 | 477 | stage2__CodeGenerator___analyze_args_internal 478 | 479 | _analyze_args_internal 480 | (stage2.py:190) 481 | 482 | 483 | stage2__CodeGenerator->stage2__CodeGenerator___analyze_args_internal 484 | 485 | 486 | 487 | 488 | stage2__CodeGenerator__analyze_args 489 | 490 | analyze_args 491 | (stage2.py:148) 492 | 493 | 494 | stage2__CodeGenerator->stage2__CodeGenerator__analyze_args 495 | 496 | 497 | 498 | 499 | stage2__CodeGenerator__analyze_interface 500 | 501 | analyze_interface 502 | (stage2.py:56) 503 | 504 | 505 | stage2__CodeGenerator->stage2__CodeGenerator__analyze_interface 506 | 507 | 508 | 509 | 510 | stage2__CodeGenerator__make_sortkey 511 | 512 | make_sortkey 513 | (stage2.py:205) 514 | 515 | 516 | stage2__CodeGenerator->stage2__CodeGenerator__make_sortkey 517 | 518 | 519 | 520 | 521 | stage2__CodeGenerator->stage2__CodeGenerator__run 522 | 523 | 524 | 525 | 526 | stage2__CodeGenerator__strip_levels 527 | 528 | strip_levels 529 | (stage2.py:225) 530 | 531 | 532 | stage2__CodeGenerator->stage2__CodeGenerator__strip_levels 533 | 534 | 535 | 536 | 537 | stage2__CodeGenerator__validate_bound_args 538 | 539 | validate_bound_args 540 | (stage2.py:230) 541 | 542 | 543 | stage2__CodeGenerator->stage2__CodeGenerator__validate_bound_args 544 | 545 | 546 | 547 | 548 | stage2__main->stage1__CodeGenerator__run 549 | 550 | 551 | 552 | 553 | stage2__main->stage2__CodeGenerator 554 | 555 | 556 | 557 | 558 | stage2__main->stage2__CodeGenerator__run 559 | 560 | 561 | 562 | 563 | stage2__main__npar 564 | 565 | npar 566 | (stage2.py:509) 567 | 568 | 569 | stage2__main->stage2__main__npar 570 | 571 | 572 | 573 | 574 | stage2__main->stage2__main__npar 575 | 576 | 577 | 578 | 579 | stage2__main__relevant 580 | 581 | relevant 582 | (stage2.py:507) 583 | 584 | 585 | stage2__main->stage2__main__relevant 586 | 587 | 588 | 589 | 590 | stage2__main->stage2__main__relevant 591 | 592 | 593 | 594 | 595 | stage2__CodeGenerator___analyze_args_internal->stage2__CodeGenerator__analyze_args 596 | 597 | 598 | 599 | 600 | stage2__CodeGenerator__analyze_args->stage2__CodeGenerator___analyze_args_internal 601 | 602 | 603 | 604 | 605 | stage2__CodeGenerator__analyze_interface__ReaderState 606 | 607 | ReaderState 608 | (stage2.py:81) 609 | 610 | 611 | stage2__CodeGenerator__analyze_interface->stage2__CodeGenerator__analyze_interface__ReaderState 612 | 613 | 614 | 615 | 616 | stage2__CodeGenerator__analyze_interface__commit 617 | 618 | commit 619 | (stage2.py:85) 620 | 621 | 622 | stage2__CodeGenerator__analyze_interface->stage2__CodeGenerator__analyze_interface__commit 623 | 624 | 625 | 626 | 627 | stage2__CodeGenerator__analyze_interface->stage2__CodeGenerator__analyze_interface__commit 628 | 629 | 630 | 631 | 632 | stage2__CodeGenerator__analyze_interface__function_header_ends 633 | 634 | function_header_ends 635 | (stage2.py:88) 636 | 637 | 638 | stage2__CodeGenerator__analyze_interface->stage2__CodeGenerator__analyze_interface__function_header_ends 639 | 640 | 641 | 642 | 643 | stage2__CodeGenerator__analyze_interface->stage2__CodeGenerator__analyze_interface__function_header_ends 644 | 645 | 646 | 647 | 648 | stage2__CodeGenerator__run->stage2__CodeGenerator__analyze_args 649 | 650 | 651 | 652 | 653 | stage2__CodeGenerator__run->stage2__CodeGenerator__analyze_interface 654 | 655 | 656 | 657 | 658 | stage2__CodeGenerator__run->stage2__CodeGenerator__make_sortkey 659 | 660 | 661 | 662 | 663 | stage2__CodeGenerator__run->stage2__CodeGenerator__strip_levels 664 | 665 | 666 | 667 | 668 | stage2__CodeGenerator__run->stage2__CodeGenerator__validate_bound_args 669 | 670 | 671 | 672 | 673 | stage2__CodeGenerator__run__bind_local 674 | 675 | bind_local 676 | (stage2.py:428) 677 | 678 | 679 | stage2__CodeGenerator__run->stage2__CodeGenerator__run__bind_local 680 | 681 | 682 | 683 | 684 | stage2__CodeGenerator__run->stage2__CodeGenerator__run__bind_local 685 | 686 | 687 | 688 | 689 | stage2__CodeGenerator__run__make_sorted_by 690 | 691 | make_sorted_by 692 | (stage2.py:325) 693 | 694 | 695 | stage2__CodeGenerator__run->stage2__CodeGenerator__run__make_sorted_by 696 | 697 | 698 | 699 | 700 | stage2__CodeGenerator__run->stage2__CodeGenerator__run__make_sorted_by 701 | 702 | 703 | 704 | 705 | stage2__CodeGenerator__validate_bound_args->stage2__CodeGenerator__strip_levels 706 | 707 | 708 | 709 | 710 | stage2__CodeGenerator__validate_bound_args__process 711 | 712 | process 713 | (stage2.py:292) 714 | 715 | 716 | stage2__CodeGenerator__validate_bound_args->stage2__CodeGenerator__validate_bound_args__process 717 | 718 | 719 | 720 | 721 | stage2__CodeGenerator__validate_bound_args->stage2__CodeGenerator__validate_bound_args__process 722 | 723 | 724 | 725 | 726 | stage2__CodeGenerator__validate_bound_args__update_callers_of 727 | 728 | update_callers_of 729 | (stage2.py:284) 730 | 731 | 732 | stage2__CodeGenerator__validate_bound_args->stage2__CodeGenerator__validate_bound_args__update_callers_of 733 | 734 | 735 | 736 | 737 | stage2__CodeGenerator__validate_bound_args__process->stage2__CodeGenerator__strip_levels 738 | 739 | 740 | 741 | 742 | stage2__CodeGenerator__validate_bound_args__process->stage2__CodeGenerator__validate_bound_args__update_callers_of 743 | 744 | 745 | 746 | 747 | 748 | -------------------------------------------------------------------------------- /makedist.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | python3 setup.py sdist bdist_wheel 3 | -------------------------------------------------------------------------------- /modvis.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8; -*- 3 | """A simple import analyzer. Visualize dependencies between modules.""" 4 | 5 | import ast 6 | from glob import glob 7 | import logging 8 | from optparse import OptionParser # TODO: migrate to argparse 9 | import os 10 | 11 | import pyan.node 12 | import pyan.visgraph 13 | import pyan.writers 14 | 15 | # from pyan.anutils import get_module_name 16 | 17 | 18 | def filename_to_module_name(fullpath): # we need to see __init__, hence we don't use anutils.get_module_name. 19 | """'some/path/module.py' -> 'some.path.module'""" 20 | if not fullpath.endswith(".py"): 21 | raise ValueError("Expected a .py filename, got '{}'".format(fullpath)) 22 | rel = ".{}".format(os.path.sep) # ./ 23 | if fullpath.startswith(rel): 24 | fullpath = fullpath[len(rel) :] 25 | fullpath = fullpath[:-3] # remove .py 26 | return fullpath.replace(os.path.sep, ".") 27 | 28 | 29 | def split_module_name(m): 30 | """'fully.qualified.name' -> ('fully.qualified', 'name')""" 31 | k = m.rfind(".") 32 | if k == -1: 33 | return ("", m) 34 | return (m[:k], m[(k + 1) :]) 35 | 36 | 37 | # blacklist = (".git", "build", "dist", "test") 38 | # def find_py_files(basedir): 39 | # py_files = [] 40 | # for root, dirs, files in os.walk(basedir): 41 | # for x in blacklist: # don't visit blacklisted dirs 42 | # if x in dirs: 43 | # dirs.remove(x) 44 | # for filename in files: 45 | # if filename.endswith(".py"): 46 | # fullpath = os.path.join(root, filename) 47 | # py_files.append(fullpath) 48 | # return py_files 49 | 50 | 51 | def resolve(current_module, target_module, level): 52 | """Return fully qualified name of the target_module in an import. 53 | 54 | If level == 0, the import is absolute, hence target_module is already the 55 | fully qualified name (and will be returned as-is). 56 | 57 | Relative imports (level > 0) are resolved using current_module as the 58 | starting point. Usually this is good enough (especially if you analyze your 59 | project by invoking modvis in its top-level directory). 60 | 61 | For the exact implications, see the section "Import sibling packages" in: 62 | https://alex.dzyoba.com/blog/python-import/ 63 | and this SO discussion: 64 | https://stackoverflow.com/questions/14132789/relative-imports-for-the-billionth-time 65 | """ 66 | if level < 0: 67 | raise ValueError("Relative import level must be >= 0, got {}".format(level)) 68 | if level == 0: # absolute import 69 | return target_module 70 | # level > 0 (let's have some simplistic support for relative imports) 71 | if level > current_module.count(".") + 1: # foo.bar.baz -> max level 3, pointing to top level 72 | raise ValueError("Relative import level {} too large for module name {}".format(level, current_module)) 73 | base = current_module 74 | for _ in range(level): 75 | k = base.rfind(".") 76 | if k == -1: 77 | base = "" 78 | break 79 | base = base[:k] 80 | return ".".join((base, target_module)) 81 | 82 | 83 | class ImportVisitor(ast.NodeVisitor): 84 | def __init__(self, filenames, logger): 85 | self.modules = {} # modname: {dep0, dep1, ...} 86 | self.fullpaths = {} # modname: fullpath 87 | self.logger = logger 88 | self.analyze(filenames) 89 | 90 | def analyze(self, filenames): 91 | for fullpath in filenames: 92 | with open(fullpath, "rt", encoding="utf-8") as f: 93 | content = f.read() 94 | m = filename_to_module_name(fullpath) 95 | self.current_module = m 96 | self.fullpaths[m] = fullpath 97 | self.visit(ast.parse(content, fullpath)) 98 | 99 | def add_dependency(self, target_module): # source module is always self.current_module 100 | m = self.current_module 101 | if m not in self.modules: 102 | self.modules[m] = set() 103 | self.modules[m].add(target_module) 104 | # Just in case the target (or one or more of its parents) is a package 105 | # (we don't know that), add a dependency on the relevant __init__ module. 106 | # 107 | # If there's no matching __init__ (either no __init__.py provided, or 108 | # the target is just a module), this is harmless - we just generate a 109 | # spurious dependency on a module that doesn't even exist. 110 | # 111 | # Since nonexistent modules are not in the analyzed set (i.e. do not 112 | # appear as keys of self.modules), prepare_graph will ignore them. 113 | # 114 | # TODO: This would be a problem for a simple plain-text output that doesn't use the graph. 115 | modpath = target_module.split(".") 116 | for k in range(1, len(modpath) + 1): 117 | base = ".".join(modpath[:k]) 118 | possible_init = base + ".__init__" 119 | if possible_init != m: # will happen when current_module is somepackage.__init__ itself 120 | self.modules[m].add(possible_init) 121 | self.logger.debug(" added possible implicit use of '{}'".format(possible_init)) 122 | 123 | def visit_Import(self, node): 124 | self.logger.debug( 125 | "{}:{}: Import {}".format(self.current_module, node.lineno, [alias.name for alias in node.names]) 126 | ) 127 | for alias in node.names: 128 | self.add_dependency(alias.name) # alias.asname not relevant for our purposes 129 | 130 | def visit_ImportFrom(self, node): 131 | # from foo import some_symbol 132 | if node.module: 133 | self.logger.debug( 134 | "{}:{}: ImportFrom '{}', relative import level {}".format( 135 | self.current_module, node.lineno, node.module, node.level 136 | ) 137 | ) 138 | absname = resolve(self.current_module, node.module, node.level) 139 | if node.level > 0: 140 | self.logger.debug(" resolved relative import to '{}'".format(absname)) 141 | self.add_dependency(absname) 142 | 143 | # from . import foo --> module = None; now the **names** refer to modules 144 | else: 145 | for alias in node.names: 146 | self.logger.debug( 147 | "{}:{}: ImportFrom '{}', target module '{}', relative import level {}".format( 148 | self.current_module, node.lineno, "." * node.level, alias.name, node.level 149 | ) 150 | ) 151 | absname = resolve(self.current_module, alias.name, node.level) 152 | if node.level > 0: 153 | self.logger.debug(" resolved relative import to '{}'".format(absname)) 154 | self.add_dependency(absname) 155 | 156 | # -------------------------------------------------------------------------------- 157 | 158 | def detect_cycles(self): 159 | """Postprocessing. Detect import cycles. 160 | 161 | Return format is `[(prefix, cycle), ...]` where `prefix` is the 162 | non-cyclic prefix of the import chain, and `cycle` contains only 163 | the cyclic part (where the first and last elements are the same). 164 | """ 165 | cycles = [] 166 | 167 | def walk(m, seen=None, trace=None): 168 | trace = (trace or []) + [m] 169 | seen = seen or set() 170 | if m in seen: 171 | cycles.append(trace) 172 | return 173 | seen = seen | {m} 174 | deps = self.modules[m] 175 | for d in sorted(deps): 176 | if d in self.modules: 177 | walk(d, seen, trace) 178 | 179 | for root in sorted(self.modules): 180 | walk(root) 181 | 182 | # For each detected cycle, report the non-cyclic prefix and the cycle separately 183 | out = [] 184 | for cycle in cycles: 185 | offender = cycle[-1] 186 | k = cycle.index(offender) 187 | out.append((cycle[:k], cycle[k:])) 188 | return out 189 | 190 | def prepare_graph(self): # same format as in pyan.analyzer 191 | """Postprocessing. Prepare data for pyan.visgraph for graph file generation.""" 192 | self.nodes = {} # Node name: list of Node objects (in possibly different namespaces) 193 | self.uses_edges = {} 194 | # we have no defines_edges, which doesn't matter as long as we don't enable that option in visgraph. 195 | 196 | # TODO: Right now we care only about modules whose files we read. 197 | # TODO: If we want to include in the graph also targets that are not in the analyzed set, 198 | # TODO: then we could create nodes also for the modules listed in the *values* of self.modules. 199 | for m in self.modules: 200 | ns, mod = split_module_name(m) 201 | package = os.path.dirname(self.fullpaths[m]) 202 | # print("{}: ns={}, mod={}, fn={}".format(m, ns, mod, fn)) 203 | # HACK: The `filename` attribute of the node determines the visual color. 204 | # HACK: We are visualizing at module level, so color by package. 205 | # TODO: If we are analyzing files from several projects in the same run, 206 | # TODO: it could be useful to decide the hue by the top-level directory name 207 | # TODO: (after the './' if any), and lightness by the depth in each tree. 208 | # TODO: This would be most similar to how Pyan does it for functions/classes. 209 | n = pyan.node.Node(namespace=ns, name=mod, ast_node=None, filename=package, flavor=pyan.node.Flavor.MODULE) 210 | n.defined = True 211 | # Pyan's analyzer.py allows several nodes to share the same short name, 212 | # which is used as the key to self.nodes; but we use the fully qualified 213 | # name as the key. Nevertheless, visgraph expects a format where the 214 | # values in the visitor's `nodes` attribute are lists. 215 | self.nodes[m] = [n] 216 | 217 | def add_uses_edge(from_node, to_node): 218 | if from_node not in self.uses_edges: 219 | self.uses_edges[from_node] = set() 220 | self.uses_edges[from_node].add(to_node) 221 | 222 | for m, deps in self.modules.items(): 223 | for d in deps: 224 | n_from = self.nodes.get(m) 225 | n_to = self.nodes.get(d) 226 | if n_from and n_to: 227 | add_uses_edge(n_from[0], n_to[0]) 228 | 229 | # sanity check output 230 | for m, deps in self.uses_edges.items(): 231 | assert m.get_name() in self.nodes 232 | for d in deps: 233 | assert d.get_name() in self.nodes 234 | 235 | 236 | def main(): 237 | usage = """usage: %prog FILENAME... [--dot|--tgf|--yed]""" 238 | desc = "Analyse one or more Python source files and generate an approximate module dependency graph." 239 | parser = OptionParser(usage=usage, description=desc) 240 | parser.add_option("--dot", action="store_true", default=False, help="output in GraphViz dot format") 241 | parser.add_option("--tgf", action="store_true", default=False, help="output in Trivial Graph Format") 242 | parser.add_option("--yed", action="store_true", default=False, help="output in yEd GraphML Format") 243 | parser.add_option("-f", "--file", dest="filename", help="write graph to FILE", metavar="FILE", default=None) 244 | parser.add_option("-l", "--log", dest="logname", help="write log to LOG", metavar="LOG") 245 | parser.add_option("-v", "--verbose", action="store_true", default=False, dest="verbose", help="verbose output") 246 | parser.add_option( 247 | "-V", 248 | "--very-verbose", 249 | action="store_true", 250 | default=False, 251 | dest="very_verbose", 252 | help="even more verbose output (mainly for debug)", 253 | ) 254 | parser.add_option( 255 | "-c", 256 | "--colored", 257 | action="store_true", 258 | default=False, 259 | dest="colored", 260 | help="color nodes according to namespace [dot only]", 261 | ) 262 | parser.add_option( 263 | "-g", 264 | "--grouped", 265 | action="store_true", 266 | default=False, 267 | dest="grouped", 268 | help="group nodes (create subgraphs) according to namespace [dot only]", 269 | ) 270 | parser.add_option( 271 | "-e", 272 | "--nested-groups", 273 | action="store_true", 274 | default=False, 275 | dest="nested_groups", 276 | help="create nested groups (subgraphs) for nested namespaces (implies -g) [dot only]", 277 | ) 278 | parser.add_option( 279 | "-C", 280 | "--cycles", 281 | action="store_true", 282 | default=False, 283 | dest="cycles", 284 | help="detect import cycles and print report to stdout", 285 | ) 286 | parser.add_option( 287 | "--dot-rankdir", 288 | default="TB", 289 | dest="rankdir", 290 | help=( 291 | "specifies the dot graph 'rankdir' property for " 292 | "controlling the direction of the graph. " 293 | "Allowed values: ['TB', 'LR', 'BT', 'RL']. " 294 | "[dot only]" 295 | ), 296 | ) 297 | parser.add_option( 298 | "-a", "--annotated", action="store_true", default=False, dest="annotated", help="annotate with module location" 299 | ) 300 | 301 | options, args = parser.parse_args() 302 | filenames = [fn2 for fn in args for fn2 in glob(fn, recursive=True)] 303 | if len(args) == 0: 304 | parser.error("Need one or more filenames to process") 305 | 306 | if options.nested_groups: 307 | options.grouped = True 308 | 309 | graph_options = { 310 | "draw_defines": False, # we have no defines edges 311 | "draw_uses": True, 312 | "colored": options.colored, 313 | "grouped_alt": False, 314 | "grouped": options.grouped, 315 | "nested_groups": options.nested_groups, 316 | "annotated": options.annotated, 317 | } 318 | 319 | # TODO: use an int argument for verbosity 320 | logger = logging.getLogger(__name__) 321 | if options.very_verbose: 322 | logger.setLevel(logging.DEBUG) 323 | elif options.verbose: 324 | logger.setLevel(logging.INFO) 325 | else: 326 | logger.setLevel(logging.WARN) 327 | logger.addHandler(logging.StreamHandler()) 328 | if options.logname: 329 | handler = logging.FileHandler(options.logname) 330 | logger.addHandler(handler) 331 | 332 | # run the analysis 333 | v = ImportVisitor(filenames, logger) 334 | 335 | # Postprocessing: detect import cycles 336 | # 337 | # NOTE: Because this is a static analysis, it doesn't care about the order 338 | # the code runs in any particular invocation of the software. Every 339 | # analyzed module is considered as a possible entry point to the program, 340 | # and all cycles (considering *all* possible branches *at any step* of 341 | # *each* import chain) will be mapped recursively. 342 | # 343 | # Obviously, this easily leads to a combinatoric explosion. In a mid-size 344 | # project (~20k SLOC), the analysis may find thousands of unique import 345 | # cycles, most of which are harmless. 346 | # 347 | # Many cycles appear due to package A importing something from package B 348 | # (possibly from one of its submodules) and vice versa, when both packages 349 | # have an __init__ module. If they don't actually try to import any names 350 | # that only become defined after the init has finished running, it's 351 | # usually fine. 352 | # 353 | # (Init modules often import names from their submodules to the package's 354 | # top-level namespace; those names can be reliably accessed only after the 355 | # init module has finished running. But importing names directly from the 356 | # submodule where they are defined is fine also during the init.) 357 | # 358 | # But if your program is crashing due to a cyclic import, you already know 359 | # in any case *which* import cycle is causing it, just by looking at the 360 | # stack trace. So this analysis is just extra information that says what 361 | # other cycles exist, if any. 362 | if options.cycles: 363 | cycles = v.detect_cycles() 364 | if not cycles: 365 | print("No import cycles detected.") 366 | else: 367 | unique_cycles = set() 368 | for prefix, cycle in cycles: 369 | unique_cycles.add(tuple(cycle)) 370 | print("Detected the following import cycles (n_results={}).".format(len(unique_cycles))) 371 | 372 | def stats(): 373 | lengths = [len(x) - 1 for x in unique_cycles] # number of modules in the cycle 374 | 375 | def mean(lst): 376 | return sum(lst) / len(lst) 377 | 378 | def median(lst): 379 | tmp = list(sorted(lst)) 380 | n = len(lst) 381 | if n % 2 == 1: 382 | return tmp[n // 2] # e.g. tmp[5] if n = 11 383 | else: 384 | return (tmp[n // 2 - 1] + tmp[n // 2]) / 2 # e.g. avg of tmp[4] and tmp[5] if n = 10 385 | 386 | return min(lengths), mean(lengths), median(lengths), max(lengths) 387 | 388 | print( 389 | "Number of modules in a cycle: min = {}, average = {:0.2g}, median = {:0.2g}, max = {}".format(*stats()) 390 | ) 391 | for c in sorted(unique_cycles): 392 | print(" {}".format(c)) 393 | 394 | # # we could generate a plaintext report like this (with caveats; see TODO above) 395 | # ms = v.modules 396 | # for m in sorted(ms): 397 | # print(m) 398 | # for d in sorted(ms[m]): 399 | # print(" {}".format(d)) 400 | 401 | # Postprocessing: format graph report 402 | make_graph = options.dot or options.tgf or options.yed 403 | if make_graph: 404 | v.prepare_graph() 405 | # print(v.nodes, v.uses_edges) 406 | graph = pyan.visgraph.VisualGraph.from_visitor(v, options=graph_options, logger=logger) 407 | 408 | if options.dot: 409 | writer = pyan.writers.DotWriter( 410 | graph, options=["rankdir=" + options.rankdir], output=options.filename, logger=logger 411 | ) 412 | if options.tgf: 413 | writer = pyan.writers.TgfWriter(graph, output=options.filename, logger=logger) 414 | if options.yed: 415 | writer = pyan.writers.YedWriter(graph, output=options.filename, logger=logger) 416 | if make_graph: 417 | writer.run() 418 | 419 | 420 | if __name__ == "__main__": 421 | main() 422 | -------------------------------------------------------------------------------- /pyan/__init__.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | 4 | from glob import glob 5 | import io 6 | from typing import List, Union 7 | 8 | from .analyzer import CallGraphVisitor 9 | from .main import main # noqa: F401, for export only. 10 | from .visgraph import VisualGraph 11 | from .writers import DotWriter, HTMLWriter, SVGWriter 12 | 13 | __version__ = "1.2.1" 14 | 15 | 16 | # TODO: fix code duplication with main.py, should have just one implementation. 17 | def create_callgraph( 18 | filenames: Union[List[str], str] = "**/*.py", 19 | root: str = None, 20 | function: Union[str, None] = None, 21 | namespace: Union[str, None] = None, 22 | format: str = "dot", 23 | rankdir: str = "LR", 24 | nested_groups: bool = True, 25 | draw_defines: bool = True, 26 | draw_uses: bool = True, 27 | colored: bool = True, 28 | grouped_alt: bool = False, 29 | annotated: bool = False, 30 | grouped: bool = True, 31 | max_iter: int = 1000, 32 | ) -> str: 33 | """ 34 | create callgraph based on static code analysis 35 | 36 | Args: 37 | filenames: glob pattern or list of glob patterns 38 | to identify filenames to parse (`**` for multiple directories) 39 | example: **/*.py for all python files 40 | root: path to known root directory at which package root sits. Defaults to None, i.e. it will be inferred. 41 | function: if defined, function name to filter for, e.g. "my_module.my_function" 42 | to only include calls that are related to `my_function` 43 | namespace: if defined, namespace to filter for, e.g. "my_module", it is highly 44 | recommended to define this filter 45 | format: format to write callgraph to, of of "dot", "svg", "html". you need to have graphviz 46 | installed for svg or html output 47 | rankdir: direction of graph, e.g. "LR" for horizontal or "TB" for vertical 48 | nested_groups: if to group by modules and submodules 49 | draw_defines: if to draw defines edges (functions that are defines) 50 | draw_uses: if to draw uses edges (functions that are used) 51 | colored: if to color graph 52 | grouped_alt: if to use alternative grouping 53 | annotated: if to annotate graph with filenames 54 | grouped: if to group by modules 55 | max_iter: maximum number of iterations for filtering. Defaults to 1000. 56 | 57 | Returns: 58 | str: callgraph 59 | """ 60 | if isinstance(filenames, str): 61 | filenames = [filenames] 62 | filenames = [fn2 for fn in filenames for fn2 in glob(fn, recursive=True)] 63 | 64 | if nested_groups: 65 | grouped = True 66 | graph_options = { 67 | "draw_defines": draw_defines, 68 | "draw_uses": draw_uses, 69 | "colored": colored, 70 | "grouped_alt": grouped_alt, 71 | "grouped": grouped, 72 | "nested_groups": nested_groups, 73 | "annotated": annotated, 74 | } 75 | 76 | v = CallGraphVisitor(filenames, root=root) 77 | if function or namespace: 78 | if function: 79 | function_name = function.split(".")[-1] 80 | function_namespace = ".".join(function.split(".")[:-1]) 81 | node = v.get_node(function_namespace, function_name) 82 | else: 83 | node = None 84 | v.filter(node=node, namespace=namespace, max_iter=max_iter) 85 | graph = VisualGraph.from_visitor(v, options=graph_options) 86 | 87 | stream = io.StringIO() 88 | if format == "dot": 89 | writer = DotWriter(graph, options=["rankdir=" + rankdir], output=stream) 90 | writer.run() 91 | 92 | elif format == "html": 93 | writer = HTMLWriter(graph, options=["rankdir=" + rankdir], output=stream) 94 | writer.run() 95 | 96 | elif format == "svg": 97 | writer = SVGWriter(graph, options=["rankdir=" + rankdir], output=stream) 98 | writer.run() 99 | else: 100 | raise ValueError(f"format {format} is unknown") 101 | 102 | return stream.getvalue() 103 | -------------------------------------------------------------------------------- /pyan/__main__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | import pyan 4 | 5 | if __name__ == "__main__": 6 | pyan.main() 7 | -------------------------------------------------------------------------------- /pyan/anutils.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | """Utilities for analyzer.""" 4 | 5 | import ast 6 | import os.path 7 | 8 | from .node import Flavor 9 | 10 | 11 | def head(lst): 12 | if len(lst): 13 | return lst[0] 14 | 15 | 16 | def tail(lst): 17 | if len(lst) > 1: 18 | return lst[1:] 19 | else: 20 | return [] 21 | 22 | 23 | def get_module_name(filename, root: str = None): 24 | """Try to determine the full module name of a source file, by figuring out 25 | if its directory looks like a package (i.e. has an __init__.py file or 26 | there is a .py file in it ).""" 27 | 28 | if os.path.basename(filename) == "__init__.py": 29 | # init file means module name is directory name 30 | module_path = os.path.dirname(filename) 31 | else: 32 | # otherwise it is the filename without extension 33 | module_path = filename.replace(".py", "") 34 | 35 | # find the module root - walk up the tree and check if it contains .py files - if yes. it is the new root 36 | directories = [(module_path, True)] 37 | if root is None: 38 | while directories[0][0] != os.path.dirname(directories[0][0]): 39 | potential_root = os.path.dirname(directories[0][0]) 40 | is_root = any([f == "__init__.py" for f in os.listdir(potential_root)]) 41 | directories.insert(0, (potential_root, is_root)) 42 | 43 | # keep directories where itself of parent is root 44 | while not directories[0][1]: 45 | directories.pop(0) 46 | 47 | else: # root is already known - just walk up until it is matched 48 | while directories[0][0] != root: 49 | potential_root = os.path.dirname(directories[0][0]) 50 | directories.insert(0, (potential_root, True)) 51 | 52 | mod_name = ".".join([os.path.basename(f[0]) for f in directories]) 53 | return mod_name 54 | 55 | 56 | def format_alias(x): 57 | """Return human-readable description of an ast.alias (used in Import and ImportFrom nodes).""" 58 | if not isinstance(x, ast.alias): 59 | raise TypeError("Can only format an ast.alias; got %s" % type(x)) 60 | 61 | if x.asname is not None: 62 | return "%s as %s" % (x.name, x.asname) 63 | else: 64 | return "%s" % (x.name) 65 | 66 | 67 | def get_ast_node_name(x): 68 | """Return human-readable name of ast.Attribute or ast.Name. Pass through anything else.""" 69 | if isinstance(x, ast.Attribute): 70 | # x.value might also be an ast.Attribute (think "x.y.z") 71 | return "%s.%s" % (get_ast_node_name(x.value), x.attr) 72 | elif isinstance(x, ast.Name): 73 | return x.id 74 | else: 75 | return x 76 | 77 | 78 | # Helper for handling binding forms. 79 | def sanitize_exprs(exprs): 80 | """Convert ast.Tuples in exprs to Python tuples; wrap result in a Python tuple.""" 81 | 82 | def process(expr): 83 | if isinstance(expr, (ast.Tuple, ast.List)): 84 | return expr.elts # .elts is a Python tuple 85 | else: 86 | return [expr] 87 | 88 | if isinstance(exprs, (tuple, list)): 89 | return [process(expr) for expr in exprs] 90 | else: 91 | return process(exprs) 92 | 93 | 94 | def resolve_method_resolution_order(class_base_nodes, logger): 95 | """Compute the method resolution order (MRO) for each of the analyzed classes. 96 | 97 | class_base_nodes: dict cls: [base1, base2, ..., baseN] 98 | where dict and basej are all Node objects. 99 | """ 100 | 101 | # https://en.wikipedia.org/wiki/C3_linearization#Description 102 | 103 | class LinearizationImpossible(Exception): 104 | pass 105 | 106 | from functools import reduce 107 | from operator import add 108 | 109 | def C3_find_good_head(heads, tails): # find an element of heads which is not in any of the tails 110 | flat_tails = reduce(add, tails, []) # flatten the outer level 111 | for hd in heads: 112 | if hd not in flat_tails: 113 | break 114 | else: # no break only if there are cyclic dependencies. 115 | raise LinearizationImpossible( 116 | "MRO linearization impossible; cyclic dependency detected. heads: %s, tails: %s" % (heads, tails) 117 | ) 118 | return hd 119 | 120 | def remove_all(elt, lst): # remove all occurrences of elt from lst, return a copy 121 | return [x for x in lst if x != elt] 122 | 123 | def remove_all_in(elt, lists): # remove elt from all lists, return a copy 124 | return [remove_all(elt, lst) for lst in lists] 125 | 126 | def C3_merge(lists): 127 | out = [] 128 | while True: 129 | logger.debug("MRO: C3 merge: out: %s, lists: %s" % (out, lists)) 130 | heads = [head(lst) for lst in lists if head(lst) is not None] 131 | if not len(heads): 132 | break 133 | tails = [tail(lst) for lst in lists] 134 | logger.debug("MRO: C3 merge: heads: %s, tails: %s" % (heads, tails)) 135 | hd = C3_find_good_head(heads, tails) 136 | logger.debug("MRO: C3 merge: chose head %s" % (hd)) 137 | out.append(hd) 138 | lists = remove_all_in(hd, lists) 139 | return out 140 | 141 | mro = {} # result 142 | try: 143 | memo = {} # caching/memoization 144 | 145 | def C3_linearize(node): 146 | logger.debug("MRO: C3 linearizing %s" % (node)) 147 | seen.add(node) 148 | if node not in memo: 149 | # unknown class or no ancestors 150 | if node not in class_base_nodes or not len(class_base_nodes[node]): 151 | memo[node] = [node] 152 | else: # known and has ancestors 153 | lists = [] 154 | # linearization of parents... 155 | for baseclass_node in class_base_nodes[node]: 156 | if baseclass_node not in seen: 157 | lists.append(C3_linearize(baseclass_node)) 158 | # ...and the parents themselves (in the order they appear in the ClassDef) 159 | logger.debug("MRO: parents of %s: %s" % (node, class_base_nodes[node])) 160 | lists.append(class_base_nodes[node]) 161 | logger.debug("MRO: C3 merging %s" % (lists)) 162 | memo[node] = [node] + C3_merge(lists) 163 | logger.debug("MRO: C3 linearized %s, result %s" % (node, memo[node])) 164 | return memo[node] 165 | 166 | for node in class_base_nodes: 167 | logger.debug("MRO: analyzing class %s" % (node)) 168 | seen = set() # break cycles (separately for each class we start from) 169 | mro[node] = C3_linearize(node) 170 | except LinearizationImpossible as e: 171 | logger.error(e) 172 | 173 | # generic fallback: depth-first search of lists of ancestors 174 | # 175 | # (so that we can try to draw *something* if the code to be 176 | # analyzed is so badly formed that the MRO algorithm fails) 177 | 178 | memo = {} # caching/memoization 179 | 180 | def lookup_bases_recursive(node): 181 | seen.add(node) 182 | if node not in memo: 183 | out = [node] # first look up in obj itself... 184 | if node in class_base_nodes: # known class? 185 | for baseclass_node in class_base_nodes[node]: # ...then in its bases 186 | if baseclass_node not in seen: 187 | out.append(baseclass_node) 188 | out.extend(lookup_bases_recursive(baseclass_node)) 189 | memo[node] = out 190 | return memo[node] 191 | 192 | mro = {} 193 | for node in class_base_nodes: 194 | logger.debug("MRO: generic fallback: analyzing class %s" % (node)) 195 | seen = set() # break cycles (separately for each class we start from) 196 | mro[node] = lookup_bases_recursive(node) 197 | 198 | return mro 199 | 200 | 201 | class UnresolvedSuperCallError(Exception): 202 | """For specifically signaling an unresolved super().""" 203 | 204 | pass 205 | 206 | 207 | class Scope: 208 | """Adaptor that makes scopes look somewhat like those from the Python 2 209 | compiler module, as far as Pyan's CallGraphVisitor is concerned.""" 210 | 211 | def __init__(self, table): 212 | """table: SymTable instance from symtable.symtable()""" 213 | name = table.get_name() 214 | if name == "top": 215 | name = "" # Pyan defines the top level as anonymous 216 | self.name = name 217 | self.type = table.get_type() # useful for __repr__() 218 | self.defs = {iden: None for iden in table.get_identifiers()} # name:assigned_value 219 | 220 | def __repr__(self): 221 | return "" % (self.type, self.name) 222 | 223 | 224 | # A context manager, sort of a friend of CallGraphVisitor (depends on implementation details) 225 | class ExecuteInInnerScope: 226 | """Execute a code block with the scope stack augmented with an inner scope. 227 | 228 | Used to analyze lambda, listcomp et al. The scope must still be present in 229 | analyzer.scopes. 230 | 231 | !!! 232 | Will add a defines edge from the current namespace to the inner scope, 233 | marking both nodes as defined. 234 | !!! 235 | """ 236 | 237 | def __init__(self, analyzer, scopename): 238 | """analyzer: CallGraphVisitor instance 239 | scopename: name of the inner scope""" 240 | self.analyzer = analyzer 241 | self.scopename = scopename 242 | 243 | def __enter__(self): 244 | # The inner scopes pollute the graph too much; we will need to collapse 245 | # them in postprocessing. However, we must use them during analysis to 246 | # follow the Python 3 scoping rules correctly. 247 | 248 | analyzer = self.analyzer 249 | scopename = self.scopename 250 | 251 | analyzer.name_stack.append(scopename) 252 | inner_ns = analyzer.get_node_of_current_namespace().get_name() 253 | if inner_ns not in analyzer.scopes: 254 | analyzer.name_stack.pop() 255 | raise ValueError("Unknown scope '%s'" % (inner_ns)) 256 | analyzer.scope_stack.append(analyzer.scopes[inner_ns]) 257 | analyzer.context_stack.append(scopename) 258 | 259 | return self 260 | 261 | def __exit__(self, errtype, errvalue, traceback): 262 | # TODO: do we need some error handling here? 263 | analyzer = self.analyzer 264 | scopename = self.scopename 265 | 266 | analyzer.context_stack.pop() 267 | analyzer.scope_stack.pop() 268 | analyzer.name_stack.pop() 269 | 270 | # Add a defines edge, which will mark the inner scope as defined, 271 | # allowing any uses to other objects from inside the lambda/listcomp/etc. 272 | # body to be visualized. 273 | # 274 | # All inner scopes of the same scopename (lambda, listcomp, ...) in the 275 | # current ns will be grouped into a single node, as they have no name. 276 | # We create a namespace-like node that has no associated AST node, 277 | # as it does not represent any unique AST node. 278 | from_node = analyzer.get_node_of_current_namespace() 279 | ns = from_node.get_name() 280 | to_node = analyzer.get_node(ns, scopename, None, flavor=Flavor.NAMESPACE) 281 | if analyzer.add_defines_edge(from_node, to_node): 282 | analyzer.logger.info("Def from %s to %s %s" % (from_node, scopename, to_node)) 283 | analyzer.last_value = to_node # Make this inner scope node assignable to track its uses. 284 | -------------------------------------------------------------------------------- /pyan/callgraph.html: -------------------------------------------------------------------------------- 1 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 39 | 40 |

Click node to highlight; Shift-scroll to zoom; Esc to unhighlight

41 |
42 | 43 | 44 | 45 | 46 | 47 | 48 | 71 | 72 | 73 | -------------------------------------------------------------------------------- /pyan/main.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | """ 4 | pyan.py - Generate approximate call graphs for Python programs. 5 | 6 | This program takes one or more Python source files, does a superficial 7 | analysis, and constructs a directed graph of the objects in the combined 8 | source, and how they define or use each other. The graph can be output 9 | for rendering by e.g. GraphViz or yEd. 10 | """ 11 | 12 | from argparse import ArgumentParser 13 | from glob import glob 14 | import logging 15 | import os 16 | 17 | from .analyzer import CallGraphVisitor 18 | from .visgraph import VisualGraph 19 | from .writers import DotWriter, HTMLWriter, SVGWriter, TgfWriter, YedWriter 20 | 21 | 22 | def main(cli_args=None): 23 | usage = """%(prog)s FILENAME... [--dot|--tgf|--yed|--svg|--html]""" 24 | desc = ( 25 | "Analyse one or more Python source files and generate an" 26 | "approximate call graph of the modules, classes and functions" 27 | " within them." 28 | ) 29 | 30 | parser = ArgumentParser(usage=usage, description=desc) 31 | 32 | parser.add_argument("--dot", action="store_true", default=False, help="output in GraphViz dot format") 33 | 34 | parser.add_argument("--tgf", action="store_true", default=False, help="output in Trivial Graph Format") 35 | 36 | parser.add_argument("--svg", action="store_true", default=False, help="output in SVG Format") 37 | 38 | parser.add_argument("--html", action="store_true", default=False, help="output in HTML Format") 39 | 40 | parser.add_argument("--yed", action="store_true", default=False, help="output in yEd GraphML Format") 41 | 42 | parser.add_argument("--file", dest="filename", help="write graph to FILE", metavar="FILE", default=None) 43 | 44 | parser.add_argument("--namespace", dest="namespace", help="filter for NAMESPACE", metavar="NAMESPACE", default=None) 45 | 46 | parser.add_argument("--function", dest="function", help="filter for FUNCTION", metavar="FUNCTION", default=None) 47 | 48 | parser.add_argument("-l", "--log", dest="logname", help="write log to LOG", metavar="LOG") 49 | 50 | parser.add_argument("-v", "--verbose", action="store_true", default=False, dest="verbose", help="verbose output") 51 | 52 | parser.add_argument( 53 | "-V", 54 | "--very-verbose", 55 | action="store_true", 56 | default=False, 57 | dest="very_verbose", 58 | help="even more verbose output (mainly for debug)", 59 | ) 60 | 61 | parser.add_argument( 62 | "-d", 63 | "--defines", 64 | action="store_true", 65 | dest="draw_defines", 66 | help="add edges for 'defines' relationships [default]", 67 | ) 68 | 69 | parser.add_argument( 70 | "-n", 71 | "--no-defines", 72 | action="store_false", 73 | default=True, 74 | dest="draw_defines", 75 | help="do not add edges for 'defines' relationships", 76 | ) 77 | 78 | parser.add_argument( 79 | "-u", 80 | "--uses", 81 | action="store_true", 82 | default=True, 83 | dest="draw_uses", 84 | help="add edges for 'uses' relationships [default]", 85 | ) 86 | 87 | parser.add_argument( 88 | "-N", 89 | "--no-uses", 90 | action="store_false", 91 | default=True, 92 | dest="draw_uses", 93 | help="do not add edges for 'uses' relationships", 94 | ) 95 | 96 | parser.add_argument( 97 | "-c", 98 | "--colored", 99 | action="store_true", 100 | default=False, 101 | dest="colored", 102 | help="color nodes according to namespace [dot only]", 103 | ) 104 | 105 | parser.add_argument( 106 | "-G", 107 | "--grouped-alt", 108 | action="store_true", 109 | default=False, 110 | dest="grouped_alt", 111 | help="suggest grouping by adding invisible defines edges [only useful with --no-defines]", 112 | ) 113 | 114 | parser.add_argument( 115 | "-g", 116 | "--grouped", 117 | action="store_true", 118 | default=False, 119 | dest="grouped", 120 | help="group nodes (create subgraphs) according to namespace [dot only]", 121 | ) 122 | 123 | parser.add_argument( 124 | "-e", 125 | "--nested-groups", 126 | action="store_true", 127 | default=False, 128 | dest="nested_groups", 129 | help="create nested groups (subgraphs) for nested namespaces (implies -g) [dot only]", 130 | ) 131 | 132 | parser.add_argument( 133 | "--dot-rankdir", 134 | default="TB", 135 | dest="rankdir", 136 | help=( 137 | "specifies the dot graph 'rankdir' property for " 138 | "controlling the direction of the graph. " 139 | "Allowed values: ['TB', 'LR', 'BT', 'RL']. " 140 | "[dot only]" 141 | ), 142 | ) 143 | 144 | parser.add_argument( 145 | "-a", 146 | "--annotated", 147 | action="store_true", 148 | default=False, 149 | dest="annotated", 150 | help="annotate with module and source line number", 151 | ) 152 | 153 | parser.add_argument( 154 | "--root", 155 | default=None, 156 | dest="root", 157 | help="Package root directory. Is inferred by default.", 158 | ) 159 | 160 | known_args, unknown_args = parser.parse_known_args(cli_args) 161 | 162 | filenames = [fn2 for fn in unknown_args for fn2 in glob(fn, recursive=True)] 163 | 164 | # determine root 165 | if known_args.root is not None: 166 | root = os.path.abspath(known_args.root) 167 | else: 168 | root = None 169 | 170 | if len(unknown_args) == 0: 171 | parser.error("Need one or more filenames to process") 172 | elif len(filenames) == 0: 173 | parser.error("No files found matching given glob: %s" % " ".join(unknown_args)) 174 | 175 | if known_args.nested_groups: 176 | known_args.grouped = True 177 | 178 | graph_options = { 179 | "draw_defines": known_args.draw_defines, 180 | "draw_uses": known_args.draw_uses, 181 | "colored": known_args.colored, 182 | "grouped_alt": known_args.grouped_alt, 183 | "grouped": known_args.grouped, 184 | "nested_groups": known_args.nested_groups, 185 | "annotated": known_args.annotated, 186 | } 187 | 188 | # TODO: use an int argument for verbosity 189 | logger = logging.getLogger(__name__) 190 | 191 | if known_args.very_verbose: 192 | logger.setLevel(logging.DEBUG) 193 | 194 | elif known_args.verbose: 195 | logger.setLevel(logging.INFO) 196 | 197 | else: 198 | logger.setLevel(logging.WARN) 199 | 200 | logger.addHandler(logging.StreamHandler()) 201 | 202 | if known_args.logname: 203 | handler = logging.FileHandler(known_args.logname) 204 | logger.addHandler(handler) 205 | 206 | v = CallGraphVisitor(filenames, logger, root=root) 207 | 208 | if known_args.function or known_args.namespace: 209 | 210 | if known_args.function: 211 | function_name = known_args.function.split(".")[-1] 212 | namespace = ".".join(known_args.function.split(".")[:-1]) 213 | node = v.get_node(namespace, function_name) 214 | 215 | else: 216 | node = None 217 | 218 | v.filter(node=node, namespace=known_args.namespace) 219 | 220 | graph = VisualGraph.from_visitor(v, options=graph_options, logger=logger) 221 | 222 | writer = None 223 | 224 | if known_args.dot: 225 | writer = DotWriter(graph, options=["rankdir=" + known_args.rankdir], output=known_args.filename, logger=logger) 226 | 227 | if known_args.html: 228 | writer = HTMLWriter(graph, options=["rankdir=" + known_args.rankdir], output=known_args.filename, logger=logger) 229 | 230 | if known_args.svg: 231 | writer = SVGWriter(graph, options=["rankdir=" + known_args.rankdir], output=known_args.filename, logger=logger) 232 | 233 | if known_args.tgf: 234 | writer = TgfWriter(graph, output=known_args.filename, logger=logger) 235 | 236 | if known_args.yed: 237 | writer = YedWriter(graph, output=known_args.filename, logger=logger) 238 | 239 | if writer: 240 | writer.run() 241 | 242 | 243 | if __name__ == "__main__": 244 | main() 245 | -------------------------------------------------------------------------------- /pyan/node.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | 4 | """Abstract node representing data gathered from the analysis.""" 5 | 6 | from enum import Enum 7 | 8 | 9 | def make_safe_label(label): 10 | """Avoid name clashes with GraphViz reserved words such as 'graph'.""" 11 | unsafe_words = ("digraph", "graph", "cluster", "subgraph", "node") 12 | out = label 13 | for word in unsafe_words: 14 | out = out.replace(word, "%sX" % word) 15 | return out.replace(".", "__").replace("*", "") 16 | 17 | 18 | class Flavor(Enum): 19 | """Flavor describes the kind of object a node represents.""" 20 | 21 | UNSPECIFIED = "---" # as it says on the tin 22 | UNKNOWN = "???" # not determined by analysis (wildcard) 23 | 24 | NAMESPACE = "namespace" # node representing a namespace 25 | ATTRIBUTE = "attribute" # attr of something, but not known if class or func. 26 | 27 | IMPORTEDITEM = "import" # imported item of unanalyzed type 28 | 29 | MODULE = "module" 30 | CLASS = "class" 31 | FUNCTION = "function" 32 | METHOD = "method" # instance method 33 | STATICMETHOD = "staticmethod" 34 | CLASSMETHOD = "classmethod" 35 | NAME = "name" # Python name (e.g. "x" in "x = 42") 36 | 37 | # Flavors have a partial ordering in specificness of the information. 38 | # 39 | # This sort key scores higher on flavors that are more specific, 40 | # allowing selective overwriting (while defining the override rules 41 | # here, where that information belongs). 42 | # 43 | @staticmethod 44 | def specificity(flavor): 45 | if flavor in (Flavor.UNSPECIFIED, Flavor.UNKNOWN): 46 | return 0 47 | elif flavor in (Flavor.NAMESPACE, Flavor.ATTRIBUTE): 48 | return 1 49 | elif flavor == Flavor.IMPORTEDITEM: 50 | return 2 51 | else: 52 | return 3 53 | 54 | def __repr__(self): 55 | return self.value 56 | 57 | 58 | class Node: 59 | """A node is an object in the call graph. 60 | 61 | Nodes have names, and reside in namespaces. 62 | 63 | The namespace is a dot-delimited string of names. It can be blank, '', 64 | denoting the top level. 65 | 66 | The fully qualified name of a node is its namespace, a dot, and its name; 67 | except at the top level, where the leading dot is omitted. 68 | 69 | If the namespace has the special value None, it is rendered as *, and the 70 | node is considered as an unknown node. A uses edge to an unknown node is 71 | created when the analysis cannot determine which actual node is being used. 72 | 73 | A graph node can be associated with an AST node from the analysis. 74 | This identifies the syntax object the node represents, and as a bonus, 75 | provides the line number at which the syntax object appears in the 76 | analyzed code. The filename, however, must be given manually. 77 | 78 | Nodes can also represent namespaces. These namespace nodes do not have an 79 | associated AST node. For a namespace node, the "namespace" argument is the 80 | **parent** namespace, and the "name" argument is the (last component of 81 | the) name of the namespace itself. For example, 82 | 83 | Node("mymodule", "main", None) 84 | 85 | represents the namespace "mymodule.main". 86 | 87 | Flavor describes the kind of object the node represents. 88 | See the Flavor enum for currently supported values. 89 | """ 90 | 91 | def __init__(self, namespace, name, ast_node, filename, flavor): 92 | self.namespace = namespace 93 | self.name = name 94 | self.ast_node = ast_node 95 | self.filename = filename 96 | self.flavor = flavor 97 | self.defined = namespace is None # assume that unknown nodes are defined 98 | 99 | def get_short_name(self): 100 | """Return the short name (i.e. excluding the namespace), of this Node. 101 | Names of unknown nodes will include the *. prefix.""" 102 | 103 | if self.namespace is None: 104 | return "*." + self.name 105 | else: 106 | return self.name 107 | 108 | def get_annotated_name(self): 109 | """Return the short name, plus module and line number of definition site, if available. 110 | Names of unknown nodes will include the *. prefix.""" 111 | if self.namespace is None: 112 | return "*." + self.name 113 | else: 114 | if self.get_level() >= 1 and self.ast_node is not None: 115 | return "%s\\n(%s:%d)" % (self.name, self.filename, self.ast_node.lineno) 116 | else: 117 | return self.name 118 | 119 | def get_long_annotated_name(self): 120 | """Return the short name, plus namespace, and module and line number of definition site, if available. 121 | Names of unknown nodes will include the *. prefix.""" 122 | if self.namespace is None: 123 | return "*." + self.name 124 | else: 125 | if self.get_level() >= 1: 126 | if self.ast_node is not None: 127 | return "%s\\n\\n(%s:%d,\\n%s in %s)" % ( 128 | self.name, 129 | self.filename, 130 | self.ast_node.lineno, 131 | repr(self.flavor), 132 | self.namespace, 133 | ) 134 | else: 135 | return "%s\\n\\n(%s in %s)" % (self.name, repr(self.flavor), self.namespace) 136 | else: 137 | return self.name 138 | 139 | def get_name(self): 140 | """Return the full name of this node.""" 141 | 142 | if self.namespace == "": 143 | return self.name 144 | elif self.namespace is None: 145 | return "*." + self.name 146 | else: 147 | return self.namespace + "." + self.name 148 | 149 | def get_level(self): 150 | """Return the level of this node (in terms of nested namespaces). 151 | 152 | The level is defined as the number of '.' in the namespace, plus one. 153 | Top level is level 0. 154 | 155 | """ 156 | if self.namespace == "": 157 | return 0 158 | else: 159 | return 1 + self.namespace.count(".") 160 | 161 | def get_toplevel_namespace(self): 162 | """Return the name of the top-level namespace of this node, or "" if none.""" 163 | if self.namespace == "": 164 | return "" 165 | if self.namespace is None: # group all unknowns in one namespace, "*" 166 | return "*" 167 | 168 | idx = self.namespace.find(".") 169 | if idx > -1: 170 | return self.namespace[0:idx] 171 | else: 172 | return self.namespace 173 | 174 | def get_label(self): 175 | """Return a label for this node, suitable for use in graph formats. 176 | Unique nodes should have unique labels; and labels should not contain 177 | problematic characters like dots or asterisks.""" 178 | 179 | return make_safe_label(self.get_name()) 180 | 181 | def get_namespace_label(self): 182 | """Return a label for the namespace of this node, suitable for use 183 | in graph formats. Unique nodes should have unique labels; and labels 184 | should not contain problematic characters like dots or asterisks.""" 185 | 186 | return make_safe_label(self.namespace) 187 | 188 | def __repr__(self): 189 | return "" % (repr(self.flavor), self.get_name()) 190 | -------------------------------------------------------------------------------- /pyan/sphinx.py: -------------------------------------------------------------------------------- 1 | """ 2 | Simple sphinx extension that allows including callgraphs in documentation. 3 | 4 | Example usage: 5 | 6 | ``` 7 | .. callgraph:: 8 | 9 | 10 | Options are 11 | 12 | - **:no-groups:** (boolean flag): do not group 13 | - **:no-defines:** (boolean flag): if to not draw edges that show which 14 | functions, methods and classes are defined by a class or module 15 | - **:no-uses:** (boolean flag): if to not draw edges that show how a function 16 | uses other functions 17 | - **:no-colors:** (boolean flag): if to not color in callgraph (default is 18 | coloring) 19 | - **:nested-grops:** (boolean flag): if to group by modules and submodules 20 | - **:annotated:** (boolean flag): annotate callgraph with file names 21 | - **:direction:** (string): "horizontal" or "vertical" callgraph 22 | - **:toctree:** (string): path to toctree (as used with autosummary) to link 23 | elements of callgraph to documentation (makes all nodes clickable) 24 | - **:zoomable:** (boolean flag): enables users to zoom and pan callgraph 25 | ``` 26 | """ 27 | import re 28 | from typing import Any 29 | 30 | from docutils.parsers.rst import directives 31 | from sphinx.ext.graphviz import align_spec, figure_wrapper, graphviz 32 | from sphinx.util.docutils import SphinxDirective 33 | 34 | from pyan import create_callgraph 35 | 36 | 37 | def direction_spec(argument: Any) -> str: 38 | return directives.choice(argument, ("vertical", "horizontal")) 39 | 40 | 41 | class CallgraphDirective(SphinxDirective): 42 | 43 | # this enables content in the directive 44 | has_content = True 45 | 46 | option_spec = { 47 | # graphviz 48 | "alt": directives.unchanged, 49 | "align": align_spec, 50 | "caption": directives.unchanged, 51 | "name": directives.unchanged, 52 | "class": directives.class_option, 53 | # pyan 54 | "no-groups": directives.unchanged, 55 | "no-defines": directives.unchanged, 56 | "no-uses": directives.unchanged, 57 | "no-colors": directives.unchanged, 58 | "nested-groups": directives.unchanged, 59 | "annotated": directives.unchanged, 60 | "direction": direction_spec, 61 | "toctree": directives.unchanged, 62 | "zoomable": directives.unchanged, 63 | } 64 | 65 | def run(self): 66 | func_name = self.content[0] 67 | base_name = func_name.split(".")[0] 68 | if len(func_name.split(".")) == 1: 69 | func_name = None 70 | base_path = __import__(base_name).__path__[0] 71 | 72 | direction = "vertical" 73 | if "direction" in self.options: 74 | direction = self.options["direction"] 75 | dotcode = create_callgraph( 76 | filenames=f"{base_path}/**/*.py", 77 | root=base_path, 78 | function=func_name, 79 | namespace=base_name, 80 | format="dot", 81 | grouped="no-groups" not in self.options, 82 | draw_uses="no-uses" not in self.options, 83 | draw_defines="no-defines" not in self.options, 84 | nested_groups="nested-groups" in self.options, 85 | colored="no-colors" not in self.options, 86 | annotated="annotated" in self.options, 87 | rankdir={"horizontal": "LR", "vertical": "TB"}[direction], 88 | ) 89 | node = graphviz() 90 | 91 | # insert link targets into groups: first insert link, then reformat link 92 | if "toctree" in self.options: 93 | path = self.options["toctree"].strip("/") 94 | # create raw link 95 | dotcode = re.sub( 96 | r'([\w\d]+)(\s.+), (style="filled")', 97 | r'\1\2, href="../' + path + r'/\1.html", target="_blank", \3', 98 | dotcode, 99 | ) 100 | 101 | def create_link(dot_name): 102 | raw_link = re.sub(r"__(\w)", r".\1", dot_name) 103 | # determine if name this is a class by checking if its first letter is capital 104 | # (heuristic but should work almost always) 105 | splits = raw_link.rsplit(".", 2) 106 | if len(splits) > 1 and splits[-2][0].capitalize() == splits[-2][0]: 107 | # is class 108 | link = ".".join(splits[:-1]) + ".html#" + raw_link + '"' 109 | else: 110 | link = raw_link + '.html"' 111 | return link 112 | 113 | dotcode = re.sub( 114 | r'(href="../' + path + r'/)(\w+)(\.html")', 115 | lambda m: m.groups()[0] + create_link(m.groups()[1]), 116 | dotcode, 117 | ) 118 | 119 | node["code"] = dotcode 120 | node["options"] = {"docname": self.env.docname} 121 | if "graphviz_dot" in self.options: 122 | node["options"]["graphviz_dot"] = self.options["graphviz_dot"] 123 | if "layout" in self.options: 124 | node["options"]["graphviz_dot"] = self.options["layout"] 125 | if "alt" in self.options: 126 | node["alt"] = self.options["alt"] 127 | if "align" in self.options: 128 | node["align"] = self.options["align"] 129 | 130 | if "class" in self.options: 131 | classes = self.options["class"] 132 | else: 133 | classes = [] 134 | if "zoomable" in self.options: 135 | if len(classes) == 0: 136 | classes = ["zoomable-callgraph"] 137 | else: 138 | classes.append("zoomable-callgraph") 139 | if len(classes) > 0: 140 | node["classes"] = classes 141 | 142 | if "caption" not in self.options: 143 | self.add_name(node) 144 | return [node] 145 | else: 146 | figure = figure_wrapper(self, node, self.options["caption"]) 147 | self.add_name(figure) 148 | return [figure] 149 | 150 | 151 | def setup(app): 152 | 153 | app.add_directive("callgraph", CallgraphDirective) 154 | app.add_js_file("https://cdn.jsdelivr.net/npm/svg-pan-zoom@3.6.1/dist/svg-pan-zoom.min.js") 155 | 156 | # script to find zoomable svgs 157 | script = """ 158 | window.addEventListener('load', () => { 159 | Array.from(document.getElementsByClassName('zoomable-callgraph')).forEach(function(element) { 160 | svgPanZoom(element); 161 | }); 162 | }) 163 | """ 164 | 165 | app.add_js_file(None, body=script) 166 | 167 | return { 168 | "version": "0.1", 169 | "parallel_read_safe": True, 170 | "parallel_write_safe": True, 171 | } 172 | -------------------------------------------------------------------------------- /pyan/visgraph.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | """Format-agnostic representation of the output graph.""" 4 | 5 | import colorsys 6 | import logging 7 | import re 8 | 9 | 10 | class Colorizer: 11 | """Output graph color manager. 12 | 13 | We set node color by filename. 14 | 15 | HSL: hue = top-level namespace, lightness = nesting level, saturation constant. 16 | 17 | The "" namespace (for *.py files) gets the first color. Since its 18 | level is 0, its lightness will be 1.0, i.e. pure white regardless 19 | of the hue. 20 | """ 21 | 22 | def __init__(self, num_colors, colored=True, logger=None): 23 | self.logger = logger or logging.getLogger(__name__) 24 | self.colored = colored 25 | 26 | self._hues = [j / num_colors for j in range(num_colors)] 27 | self._idx_of = {} # top-level namespace: hue index 28 | self._idx = 0 29 | 30 | def _next_idx(self): 31 | result = self._idx 32 | self._idx += 1 33 | if self._idx >= len(self._hues): 34 | self.logger.warn("WARNING: colors wrapped") 35 | self._idx = 0 36 | return result 37 | 38 | def _node_to_idx(self, node): 39 | ns = node.filename 40 | self.logger.info("Coloring %s from file '%s'" % (node.get_short_name(), ns)) 41 | if ns not in self._idx_of: 42 | self._idx_of[ns] = self._next_idx() 43 | return self._idx_of[ns] 44 | 45 | def get(self, node): # return (group number, hue index) 46 | idx = self._node_to_idx(node) 47 | return (idx, self._hues[idx]) 48 | 49 | def make_colors(self, node): # return (group number, fill color, text color) 50 | if self.colored: 51 | idx, H = self.get(node) 52 | L = max([1.0 - 0.1 * node.get_level(), 0.1]) 53 | S = 1.0 54 | A = 0.7 # make nodes translucent (to handle possible overlaps) 55 | fill_RGBA = self.htmlize_rgb(*colorsys.hls_to_rgb(H, L, S), A=A) 56 | 57 | # black text on light nodes, white text on (very) dark nodes. 58 | text_RGB = "#000000" if L >= 0.5 else "#ffffff" 59 | else: 60 | idx, _ = self.get(node) 61 | fill_RGBA = self.htmlize_rgb(1.0, 1.0, 1.0, 0.7) 62 | text_RGB = "#000000" 63 | return idx, fill_RGBA, text_RGB 64 | 65 | @staticmethod 66 | def htmlize_rgb(R, G, B, A=None): 67 | if A is not None: 68 | R, G, B, A = [int(255.0 * x) for x in (R, G, B, A)] 69 | return "#%02x%02x%02x%02x" % (R, G, B, A) 70 | else: 71 | R, G, B = [int(255.0 * x) for x in (R, G, B)] 72 | return "#%02x%02x%02x" % (R, G, B) 73 | 74 | 75 | class VisualNode(object): 76 | """ 77 | A node in the output graph: colors, internal ID, human-readable label, ... 78 | """ 79 | 80 | def __init__(self, id, label="", flavor="", fill_color="", text_color="", group=""): 81 | self.id = id # graphing software friendly label (no special chars) 82 | self.label = label # human-friendly label 83 | self.flavor = flavor 84 | self.fill_color = fill_color 85 | self.text_color = text_color 86 | self.group = group 87 | 88 | def __repr__(self): 89 | optionals = [repr(s) for s in [self.label, self.flavor, self.fill_color, self.text_color, self.group] if s] 90 | if optionals: 91 | return "VisualNode(" + repr(self.id) + ", " + ", ".join(optionals) + ")" 92 | else: 93 | return "VisualNode(" + repr(self.id) + ")" 94 | 95 | 96 | class VisualEdge(object): 97 | """ 98 | An edge in the output graph. 99 | 100 | flavor is meant to be 'uses' or 'defines' 101 | """ 102 | 103 | def __init__(self, source, target, flavor, color): 104 | self.source = source 105 | self.target = target 106 | self.flavor = flavor 107 | self.color = color 108 | 109 | def __repr__(self): 110 | return "Edge(" + self.source.label + " " + self.flavor + " " + self.target.label + ")" 111 | 112 | 113 | class VisualGraph(object): 114 | def __init__(self, id, label, nodes=None, edges=None, subgraphs=None, grouped=False): 115 | self.id = id 116 | self.label = label 117 | self.nodes = nodes or [] 118 | self.edges = edges or [] 119 | self.subgraphs = subgraphs or [] 120 | self.grouped = grouped 121 | 122 | @classmethod 123 | def from_visitor(cls, visitor, options=None, logger=None): 124 | colored = options.get("colored", False) 125 | nested = options.get("nested_groups", False) 126 | grouped_alt = options.get("grouped_alt", False) 127 | grouped = nested or options.get("grouped", False) # nested -> grouped 128 | annotated = options.get("annotated", False) 129 | draw_defines = options.get("draw_defines", False) 130 | draw_uses = options.get("draw_uses", False) 131 | 132 | # Terminology: 133 | # - what Node calls "label" is a computer-friendly unique identifier 134 | # for use in graphing tools 135 | # - the "label" property of a GraphViz node is a **human-readable** name 136 | # 137 | # The annotation determines the human-readable name. 138 | # 139 | if annotated: 140 | if grouped: 141 | # group label includes namespace already 142 | def labeler(n): 143 | return n.get_annotated_name() 144 | 145 | else: 146 | # the node label is the only place to put the namespace info 147 | def labeler(n): 148 | return n.get_long_annotated_name() 149 | 150 | else: 151 | 152 | def labeler(n): 153 | return n.get_short_name() 154 | 155 | logger = logger or logging.getLogger(__name__) 156 | 157 | # collect and sort defined nodes 158 | visited_nodes = [] 159 | for name in visitor.nodes: 160 | for node in visitor.nodes[name]: 161 | if node.defined: 162 | visited_nodes.append(node) 163 | visited_nodes.sort(key=lambda x: (x.namespace, x.name)) 164 | 165 | def find_filenames(): 166 | filenames = set() 167 | for node in visited_nodes: 168 | filenames.add(node.filename) 169 | return filenames 170 | 171 | colorizer = Colorizer(num_colors=len(find_filenames()) + 1, colored=colored, logger=logger) 172 | 173 | nodes_dict = dict() 174 | root_graph = cls("G", label="", grouped=grouped) 175 | subgraph = root_graph 176 | namespace_stack = [] 177 | prev_namespace = "" # The namespace '' is first in visited_nodes. 178 | for node in visited_nodes: 179 | logger.info("Looking at %s" % node.name) 180 | 181 | # Create the node itself and add it to nodes_dict 182 | idx, fill_RGBA, text_RGB = colorizer.make_colors(node) 183 | visual_node = VisualNode( 184 | id=node.get_label(), 185 | label=labeler(node), 186 | flavor=repr(node.flavor), 187 | fill_color=fill_RGBA, 188 | text_color=text_RGB, 189 | group=idx, 190 | ) 191 | nodes_dict[node] = visual_node 192 | 193 | # next namespace? 194 | if grouped and node.namespace != prev_namespace: 195 | if not prev_namespace: 196 | logger.info("New namespace %s" % (node.namespace)) 197 | else: 198 | logger.info("New namespace %s, old was %s" % (node.namespace, prev_namespace)) 199 | prev_namespace = node.namespace 200 | 201 | label = node.get_namespace_label() 202 | subgraph = cls(label, node.namespace) 203 | 204 | if nested: 205 | # Pop the stack until the newly found namespace is within 206 | # one of the parent namespaces, or until the stack runs out 207 | # (i.e. this is a sibling). 208 | if len(namespace_stack): 209 | m = re.match(namespace_stack[-1].label, node.namespace) 210 | # The '.' check catches siblings in cases like 211 | # MeshGenerator vs. Mesh. 212 | while m is None or m.end() == len(node.namespace) or node.namespace[m.end()] != ".": 213 | namespace_stack.pop() 214 | if not len(namespace_stack): 215 | break 216 | m = re.match(namespace_stack[-1].label, node.namespace) 217 | parentgraph = namespace_stack[-1] if len(namespace_stack) else root_graph 218 | parentgraph.subgraphs.append(subgraph) 219 | 220 | namespace_stack.append(subgraph) 221 | else: 222 | root_graph.subgraphs.append(subgraph) 223 | 224 | subgraph.nodes.append(visual_node) 225 | 226 | # Now add edges 227 | if draw_defines or grouped_alt: 228 | # If grouped, use gray lines so they won't visually obstruct 229 | # the "uses" lines. 230 | # 231 | # If not grouped, create lines for defines, but make them 232 | # fully transparent. This helps GraphViz's layout algorithms 233 | # place closer together those nodes that are linked by a 234 | # defines relationship. 235 | # 236 | color = "#838b8b" if draw_defines else "#ffffff00" 237 | for n in visitor.defines_edges: 238 | if n.defined: 239 | for n2 in visitor.defines_edges[n]: 240 | if n2.defined: 241 | root_graph.edges.append(VisualEdge(nodes_dict[n], nodes_dict[n2], "defines", color)) 242 | 243 | if draw_uses: 244 | color = "#000000" 245 | for n in visitor.uses_edges: 246 | if n.defined: 247 | for n2 in visitor.uses_edges[n]: 248 | if n2.defined: 249 | root_graph.edges.append(VisualEdge(nodes_dict[n], nodes_dict[n2], "uses", color)) 250 | 251 | return root_graph 252 | -------------------------------------------------------------------------------- /pyan/writers.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | 4 | """Graph markup writers.""" 5 | 6 | import io 7 | import logging 8 | import os 9 | import subprocess 10 | import sys 11 | 12 | from jinja2 import Template 13 | 14 | 15 | class Writer(object): 16 | def __init__(self, graph, output=None, logger=None, tabstop=4): 17 | self.graph = graph 18 | self.output = output 19 | self.logger = logger or logging.getLogger(__name__) 20 | self.indent_level = 0 21 | self.tabstop = tabstop * " " 22 | 23 | def log(self, msg): 24 | self.logger.info(msg) 25 | 26 | def indent(self, level=1): 27 | self.indent_level += level 28 | 29 | def dedent(self, level=1): 30 | self.indent_level -= level 31 | 32 | def write(self, line): 33 | self.outstream.write(self.tabstop * self.indent_level + line + "\n") 34 | 35 | def run(self): 36 | self.log("%s running" % type(self)) 37 | try: 38 | if isinstance(self.output, io.StringIO): # write to stream 39 | self.outstream = self.output 40 | else: 41 | self.outstream = open(self.output, "w") # write to file 42 | except TypeError: 43 | self.outstream = sys.stdout 44 | self.start_graph() 45 | self.write_subgraph(self.graph) 46 | self.write_edges() 47 | self.finish_graph() 48 | if self.output and not isinstance(self.output, io.StringIO): 49 | self.outstream.close() 50 | 51 | def write_subgraph(self, graph): 52 | self.start_subgraph(graph) 53 | for node in graph.nodes: 54 | self.write_node(node) 55 | for subgraph in graph.subgraphs: 56 | self.write_subgraph(subgraph) 57 | self.finish_subgraph(graph) 58 | 59 | def write_edges(self): 60 | self.start_edges() 61 | for edge in self.graph.edges: 62 | self.write_edge(edge) 63 | self.finish_edges() 64 | 65 | def start_graph(self): 66 | pass 67 | 68 | def start_subgraph(self, graph): 69 | pass 70 | 71 | def write_node(self, node): 72 | pass 73 | 74 | def start_edges(self): 75 | pass 76 | 77 | def write_edge(self, edge): 78 | pass 79 | 80 | def finish_edges(self): 81 | pass 82 | 83 | def finish_subgraph(self, graph): 84 | pass 85 | 86 | def finish_graph(self): 87 | pass 88 | 89 | 90 | class TgfWriter(Writer): 91 | def __init__(self, graph, output=None, logger=None): 92 | Writer.__init__(self, graph, output=output, logger=logger) 93 | self.i = 1 94 | self.id_map = {} 95 | 96 | def write_node(self, node): 97 | self.write("%d %s" % (self.i, node.label)) 98 | self.id_map[node] = self.i 99 | self.i += 1 100 | 101 | def start_edges(self): 102 | self.write("#") 103 | 104 | def write_edge(self, edge): 105 | flavor = "U" if edge.flavor == "uses" else "D" 106 | self.write("%s %s %s" % (self.id_map[edge.source], self.id_map[edge.target], flavor)) 107 | 108 | 109 | class DotWriter(Writer): 110 | def __init__(self, graph, options=None, output=None, logger=None, tabstop=4): 111 | Writer.__init__(self, graph, output=output, logger=logger, tabstop=tabstop) 112 | options = options or [] 113 | if graph.grouped: 114 | options += ['clusterrank="local"'] 115 | self.options = ", ".join(options) 116 | self.grouped = graph.grouped 117 | 118 | def start_graph(self): 119 | self.write("digraph G {") 120 | self.write(" graph [" + self.options + "];") 121 | self.indent() 122 | 123 | def start_subgraph(self, graph): 124 | self.log("Start subgraph %s" % graph.label) 125 | # Name must begin with "cluster" to be recognized as a cluster by GraphViz. 126 | self.write("subgraph cluster_%s {\n" % graph.id) 127 | self.indent() 128 | 129 | # translucent gray (no hue to avoid visual confusion with any 130 | # group of colored nodes) 131 | self.write('graph [style="filled,rounded", fillcolor="#80808018", label="%s"];' % graph.label) 132 | 133 | def finish_subgraph(self, graph): 134 | self.log("Finish subgraph %s" % graph.label) 135 | # terminate previous subgraph 136 | self.dedent() 137 | self.write("}") 138 | 139 | def write_node(self, node): 140 | self.log("Write node %s" % node.label) 141 | self.write( 142 | '%s [label="%s", style="filled", fillcolor="%s",' 143 | ' fontcolor="%s", group="%s"];' % (node.id, node.label, node.fill_color, node.text_color, node.group) 144 | ) 145 | 146 | def write_edge(self, edge): 147 | source = edge.source 148 | target = edge.target 149 | color = edge.color 150 | if edge.flavor == "defines": 151 | self.write(' %s -> %s [style="dashed", color="%s"];' % (source.id, target.id, color)) 152 | else: # edge.flavor == 'uses': 153 | self.write(' %s -> %s [style="solid", color="%s"];' % (source.id, target.id, color)) 154 | 155 | def finish_graph(self): 156 | self.write("}") # terminate "digraph G {" 157 | 158 | 159 | class SVGWriter(DotWriter): 160 | def run(self): 161 | # write dot file 162 | self.log("%s running" % type(self)) 163 | self.outstream = io.StringIO() 164 | self.start_graph() 165 | self.write_subgraph(self.graph) 166 | self.write_edges() 167 | self.finish_graph() 168 | 169 | # convert to svg 170 | svg = subprocess.run( 171 | "dot -Tsvg", shell=True, stdout=subprocess.PIPE, input=self.outstream.getvalue().encode() 172 | ).stdout.decode() 173 | 174 | if self.output: 175 | if isinstance(self.output, io.StringIO): 176 | self.output.write(svg) 177 | else: 178 | with open(self.output, "w") as f: 179 | f.write(svg) 180 | else: 181 | print(svg) 182 | 183 | 184 | class HTMLWriter(SVGWriter): 185 | def run(self): 186 | with io.StringIO() as svg_stream: 187 | # run SVGWriter with stream as output 188 | output = self.output 189 | self.output = svg_stream 190 | super().run() 191 | svg = svg_stream.getvalue() 192 | self.output = output 193 | 194 | # insert svg into html 195 | with open(os.path.join(os.path.dirname(__file__), "callgraph.html"), "r") as f: 196 | template = Template(f.read()) 197 | 198 | html = template.render(svg=svg) 199 | if self.output: 200 | if isinstance(self.output, io.StringIO): 201 | self.output.write(html) 202 | else: 203 | with open(self.output, "w") as f: 204 | f.write(html) 205 | else: 206 | print(html) 207 | 208 | 209 | class YedWriter(Writer): 210 | def __init__(self, graph, output=None, logger=None, tabstop=2): 211 | Writer.__init__(self, graph, output=output, logger=logger, tabstop=tabstop) 212 | self.grouped = graph.grouped 213 | self.indent_level = 0 214 | self.edge_id = 0 215 | 216 | def start_graph(self): 217 | self.write('') 218 | self.write( 219 | '' 231 | ) 232 | self.indent() 233 | self.write('') 234 | self.write('') 235 | self.write('') 236 | self.indent() 237 | 238 | def start_subgraph(self, graph): 239 | self.log("Start subgraph %s" % graph.label) 240 | 241 | self.write('' % graph.id) 242 | self.indent() 243 | self.write('') 244 | self.indent() 245 | self.write("") 246 | self.indent() 247 | self.write('') 248 | self.indent() 249 | self.write("") 250 | self.indent() 251 | self.write('') 252 | self.write( 253 | '%s' % graph.label 254 | ) 255 | self.write('') 256 | self.dedent() 257 | self.write("") 258 | self.dedent() 259 | self.write("") 260 | self.dedent() 261 | self.write("") 262 | self.dedent() 263 | self.write("") 264 | self.write('' % graph.id) 265 | self.indent() 266 | 267 | def finish_subgraph(self, graph): 268 | self.log("Finish subgraph %s" % graph.label) 269 | self.dedent() 270 | self.write("") 271 | self.dedent() 272 | self.write("") 273 | 274 | def write_node(self, node): 275 | self.log("Write node %s" % node.label) 276 | width = 20 + 10 * len(node.label) 277 | self.write('' % node.id) 278 | self.indent() 279 | self.write('') 280 | self.indent() 281 | self.write("") 282 | self.indent() 283 | self.write('' % ("30", width)) 284 | self.write('' % node.fill_color) 285 | self.write('') 286 | self.write("%s" % node.label) 287 | self.write('') 288 | self.dedent() 289 | self.write("") 290 | self.dedent() 291 | self.write("") 292 | self.dedent() 293 | self.write("") 294 | 295 | def write_edge(self, edge): 296 | self.edge_id += 1 297 | source = edge.source 298 | target = edge.target 299 | self.write('' % (self.edge_id, source.id, target.id)) 300 | self.indent() 301 | self.write('') 302 | self.indent() 303 | self.write("") 304 | self.indent() 305 | if edge.flavor == "defines": 306 | self.write('' % edge.color) 307 | else: 308 | self.write('' % edge.color) 309 | self.write('') 310 | self.write('') 311 | self.dedent() 312 | self.write("") 313 | self.dedent() 314 | self.write("") 315 | self.dedent() 316 | self.write("") 317 | 318 | def finish_graph(self): 319 | self.dedent(2) 320 | self.write(" ") 321 | self.dedent() 322 | self.write("") 323 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.isort] 2 | profile = "black" 3 | honor_noqa = true 4 | line_length = 120 5 | combine_as_imports = true 6 | force_sort_within_sections = true 7 | known_first_party = "pyan" 8 | 9 | [tool.black] 10 | line-length = 120 11 | include = '\.pyi?$' 12 | exclude = ''' 13 | /( 14 | \.git 15 | | \.hg 16 | | \.mypy_cache 17 | | \.tox 18 | | \.venv 19 | | _build 20 | | egg-info 21 | | buck-out 22 | | build 23 | | dist 24 | | env 25 | )/ 26 | ''' 27 | -------------------------------------------------------------------------------- /pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | addopts = 3 | -rsxX 4 | -vv 5 | 6 | --cov-config=.coveragerc 7 | --cov=pyan 8 | --cov-report=html 9 | --cov-report=term-missing:skip-covered 10 | --no-cov-on-fail 11 | testpaths = tests/ 12 | log_cli_level = ERROR 13 | log_format = %(asctime)s %(levelname)s %(message)s 14 | log_date_format = %Y-%m-%d %H:%M:%S 15 | cache_dir = .cache 16 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | coverage>=5.3 2 | pytest>=6.1.2 3 | pytest-cov>=2.10.1 4 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [flake8] 2 | max-line-length = 120 3 | show-source = true 4 | ignore = 5 | E203, # space before : (needed for how black formats slicing) 6 | W503, # line break before binary operator 7 | W504, # line break after binary operator 8 | E402, # module level import not at top of file 9 | E731, # do not assign a lambda expression, use a def 10 | E741, # ignore not easy to read variables like i l I etc. 11 | C406, # Unnecessary list literal - rewrite as a dict literal. 12 | C408, # Unnecessary dict call - rewrite as a literal. 13 | C409, # Unnecessary list passed to tuple() - rewrite as a tuple literal. 14 | S001, # found modulo formatter (incorrect picks up mod operations) 15 | F401 # unused imports 16 | W605 # invalid escape sequence (e.g. for LaTeX) 17 | exclude = docs/build/*.py, 18 | node_modules/*.py, 19 | .eggs/*.py, 20 | versioneer.py, 21 | venv/*, 22 | .venv/*, 23 | .git/* 24 | .history/* 25 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """setuptools-based setup.py for pyan3. 3 | 4 | Tested on Python 3.6. 5 | 6 | Usage as usual with setuptools: 7 | python3 setup.py build 8 | python3 setup.py sdist 9 | python3 setup.py bdist_wheel --universal 10 | python3 setup.py install 11 | 12 | For details, see 13 | http://setuptools.readthedocs.io/en/latest/setuptools.html#command-reference 14 | or 15 | python3 setup.py --help 16 | python3 setup.py --help-commands 17 | python3 setup.py --help bdist_wheel # or any command 18 | """ 19 | 20 | import ast 21 | import os 22 | 23 | from setuptools import setup 24 | 25 | ######################################################### 26 | # General config 27 | ######################################################### 28 | 29 | # Short description for package list on PyPI 30 | # 31 | SHORTDESC = "Offline call graph generator for Python 3" 32 | 33 | # Long description for package homepage on PyPI 34 | # 35 | DESC = ( 36 | "Generate approximate call graphs for Python programs.\n" 37 | "\n" 38 | "Pyan takes one or more Python source files, performs a " 39 | "(rather superficial) static analysis, and constructs a directed graph of " 40 | "the objects in the combined source, and how they define or " 41 | "use each other. The graph can be output for rendering by GraphViz or yEd." 42 | ) 43 | 44 | ######################################################### 45 | # Init 46 | ######################################################### 47 | 48 | # Extract __version__ from the package __init__.py 49 | # (since it's not a good idea to actually run __init__.py during the 50 | # build process). 51 | # 52 | # https://stackoverflow.com/q/2058802/1959808 53 | # 54 | init_py_path = os.path.join("pyan", "__init__.py") 55 | version = None 56 | try: 57 | with open(init_py_path) as f: 58 | for line in f: 59 | if line.startswith("__version__"): 60 | module = ast.parse(line) 61 | expr = module.body[0] 62 | v = expr.value 63 | if type(v) is ast.Constant: 64 | version = v.value 65 | elif type(v) is ast.Str: # TODO: Python 3.8: remove ast.Str 66 | version = v.s 67 | break 68 | except FileNotFoundError: 69 | pass 70 | if not version: 71 | raise RuntimeError(f"Version information not found in {init_py_path}") 72 | 73 | ######################################################### 74 | # Call setup() 75 | ######################################################### 76 | 77 | setup( 78 | name="pyan3", 79 | version=version, 80 | author="Juha Jeronen", 81 | author_email="juha.m.jeronen@gmail.com", 82 | url="https://github.com/Technologicat/pyan", 83 | description=SHORTDESC, 84 | long_description=DESC, 85 | license="GPL 2.0", 86 | # free-form text field; 87 | # https://stackoverflow.com/q/34994130/1959808 88 | platforms=["Linux"], 89 | # See 90 | # https://pypi.python.org/pypi?%3Aaction=list_classifiers 91 | # 92 | # for the standard classifiers. 93 | # 94 | classifiers=[ 95 | "Development Status :: 4 - Beta", 96 | "Environment :: Console", 97 | "Intended Audience :: Developers", 98 | "License :: OSI Approved :: GNU General Public License v2 (GPLv2)", 99 | "Operating System :: POSIX :: Linux", 100 | "Programming Language :: Python", 101 | "Programming Language :: Python :: 3", 102 | "Programming Language :: Python :: 3.6", 103 | "Programming Language :: Python :: 3.7", 104 | "Topic :: Software Development", 105 | ], 106 | # See 107 | # http://setuptools.readthedocs.io/en/latest/setuptools.html 108 | # 109 | setup_requires=["wheel"], 110 | install_requires=["jinja2"], 111 | provides=["pyan"], 112 | # keywords for PyPI (in case you upload your project) 113 | # 114 | # e.g. the keywords your project uses as topics on GitHub, 115 | # minus "python" (if there) 116 | # 117 | keywords=["call-graph", "static-code-analysis"], 118 | # Declare packages so that python -m setup build will copy .py files 119 | # (especially __init__.py). 120 | # 121 | # This **does not** automatically recurse into subpackages, 122 | # so they must also be declared. 123 | # 124 | packages=["pyan"], 125 | zip_safe=True, 126 | package_data={"pyan": ["callgraph.html"]}, 127 | include_package_data=True, 128 | entry_points={ 129 | "console_scripts": [ 130 | "pyan3 = pyan.main:main", 131 | ] 132 | }, 133 | ) 134 | -------------------------------------------------------------------------------- /tests/old_tests/issue2/pyan_err.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8; -*- 2 | # See issue #2 3 | 4 | """ 5 | This works fine 6 | a = 3 7 | b = 4 8 | print(a + b) 9 | """ 10 | 11 | # But this did not (#2) 12 | a: int = 3 13 | b = 4 14 | print(a + b) 15 | -------------------------------------------------------------------------------- /tests/old_tests/issue2/run.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | pyan pyan_err.py -V >out.dot 3 | -------------------------------------------------------------------------------- /tests/old_tests/issue3/testi.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8; -*- 2 | # See issue #3 3 | 4 | 5 | def f(): 6 | return [x for x in range(10)] 7 | 8 | 9 | def g(): 10 | return [(x, y) for x in range(10) for y in range(10)] 11 | 12 | 13 | def h(results): 14 | return [ 15 | ( 16 | [(name, allargs) for name, _, _, allargs, _ in recs], 17 | {name: inargs for name, inargs, _, _, _ in recs}, 18 | {name: meta for name, _, _, _, meta in recs}, 19 | ) 20 | for recs in (results[key] for key in sorted(results.keys())) 21 | ] 22 | -------------------------------------------------------------------------------- /tests/old_tests/issue5/meas_xrd.py: -------------------------------------------------------------------------------- 1 | import os.path 2 | 3 | import numpy as np 4 | import pandas.io.parsers 5 | 6 | 7 | class MeasXRD: 8 | def __init__(self, path: str): 9 | if not os.path.isfile(path): 10 | raise FileNotFoundError("Invalid XRD file path:", path) 11 | 12 | row_ind = 2 13 | self.params = {} 14 | with open(path, "r") as file: 15 | line = file.readline() 16 | if line != "[Measurement conditions]\n": 17 | raise ValueError("XRD measurement file does not contain a valid header") 18 | 19 | line = file.readline() 20 | while line not in ["[Scan points]\n", ""]: 21 | row_ind += 1 22 | columns = line.rstrip("\n").split(",", 1) 23 | self.params[columns[0]] = columns[1] 24 | line = file.readline() 25 | 26 | self.data = pandas.io.parsers.read_csv( 27 | path, skiprows=row_ind, dtype={"Angle": np.float_, "Intensity": np.int_}, engine="c" 28 | ) 29 | -------------------------------------------------------------------------------- /tests/old_tests/issue5/plot_xrd.py: -------------------------------------------------------------------------------- 1 | import plotly.graph_objs as go 2 | import plotly.offline as py 3 | 4 | from . import meas_xrd 5 | 6 | 7 | def plot_xrd(meas: meas_xrd.MeasXRD): 8 | trace = go.Scatter(x=meas.data["Angle"], y=meas.data["Intensity"]) 9 | 10 | layout = go.Layout(title="XRD data", xaxis=dict(title="Angle"), yaxis=dict(title="Intensity", type="log")) 11 | 12 | data = [trace] 13 | fig = go.Figure(data=data, layout=layout) 14 | return py.plot(fig, output_type="div", include_plotlyjs=False) 15 | -------------------------------------------------------------------------------- /tests/old_tests/issue5/relimport.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8; -*- 2 | # See issue #5 3 | 4 | from . import mod1 # noqa 5 | from . import mod1 as moo # noqa 6 | from ..mod3 import bar 7 | from .mod2 import foo 8 | -------------------------------------------------------------------------------- /tests/old_tests/issue5/run.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | pyan plot_xrd.py --uses --colored --grouped --annotated --dot > test.dot 3 | -------------------------------------------------------------------------------- /tests/test_analyzer.py: -------------------------------------------------------------------------------- 1 | from glob import glob 2 | import logging 3 | import os 4 | 5 | import pytest 6 | 7 | from pyan.analyzer import CallGraphVisitor 8 | 9 | 10 | @pytest.fixture 11 | def callgraph(): 12 | filenames = glob(os.path.join(os.path.dirname(__file__), "test_code/**/*.py"), recursive=True) 13 | v = CallGraphVisitor(filenames, logger=logging.getLogger()) 14 | return v 15 | 16 | 17 | def get_node(nodes, name): 18 | filtered_nodes = [node for node in nodes if node.get_name() == name] 19 | assert len(filtered_nodes) == 1, f"Node with name {name} should exist" 20 | return filtered_nodes[0] 21 | 22 | 23 | def get_in_dict(node_dict, name): 24 | return node_dict[get_node(node_dict.keys(), name)] 25 | 26 | 27 | def test_resolve_import_as(callgraph): 28 | imports = get_in_dict(callgraph.uses_edges, "test_code.submodule2") 29 | get_node(imports, "test_code.submodule1") 30 | assert len(imports) == 1, "only one effective import" 31 | 32 | imports = get_in_dict(callgraph.uses_edges, "test_code.submodule1") 33 | get_node(imports, "test_code.subpackage1.submodule1.A") 34 | get_node(imports, "test_code.subpackage1") 35 | 36 | 37 | def test_import_relative(callgraph): 38 | imports = get_in_dict(callgraph.uses_edges, "test_code.subpackage1.submodule1") 39 | get_node(imports, "test_code.submodule2.test_2") 40 | 41 | 42 | def test_resolve_use_in_class(callgraph): 43 | uses = get_in_dict(callgraph.uses_edges, "test_code.subpackage1.submodule1.A.__init__") 44 | get_node(uses, "test_code.submodule2.test_2") 45 | 46 | 47 | def test_resolve_use_in_function(callgraph): 48 | uses = get_in_dict(callgraph.uses_edges, "test_code.submodule2.test_2") 49 | get_node(uses, "test_code.submodule1.test_func1") 50 | get_node(uses, "test_code.submodule1.test_func2") 51 | 52 | 53 | def test_resolve_package_without___init__(callgraph): 54 | defines = get_in_dict(callgraph.defines_edges, "test_code.subpackage2.submodule_hidden1") 55 | get_node(defines, "test_code.subpackage2.submodule_hidden1.test_func1") 56 | 57 | 58 | def test_resolve_package_with_known_root(): 59 | dirname = os.path.dirname(__file__) 60 | filenames = glob(os.path.join(dirname, "test_code/**/*.py"), recursive=True) 61 | callgraph = CallGraphVisitor(filenames, logger=logging.getLogger(), root=dirname) 62 | dirname_base = os.path.basename(dirname) 63 | defines = get_in_dict(callgraph.defines_edges, f"{dirname_base}.test_code.subpackage2.submodule_hidden1") 64 | get_node(defines, f"{dirname_base}.test_code.subpackage2.submodule_hidden1.test_func1") 65 | -------------------------------------------------------------------------------- /tests/test_code/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/davidfraser/pyan/1df66cefd71ad57f2c22003fd1eee193ee31b666/tests/test_code/__init__.py -------------------------------------------------------------------------------- /tests/test_code/submodule1.py: -------------------------------------------------------------------------------- 1 | from test_code import subpackage1 as subpackage 2 | from test_code.subpackage1 import A 3 | 4 | 5 | def test_func1(a): 6 | return a 7 | 8 | 9 | def test_func2(a): 10 | return a 11 | 12 | 13 | class B: 14 | def __init__(self, k): 15 | self.a = 1 16 | 17 | def to_A(self): 18 | return A(self) 19 | 20 | def get_a_via_A(self): 21 | return test_func1(self.to_A().b.a) 22 | -------------------------------------------------------------------------------- /tests/test_code/submodule2.py: -------------------------------------------------------------------------------- 1 | import test_code.submodule1 as b 2 | 3 | from . import submodule1 4 | 5 | A = 32 6 | 7 | 8 | def test_2(a): 9 | return submodule1.test_func2(a) + A + b.test_func1(a) 10 | -------------------------------------------------------------------------------- /tests/test_code/subpackage1/__init__.py: -------------------------------------------------------------------------------- 1 | from test_code.subpackage1.submodule1 import A 2 | 3 | __all__ = ["A"] 4 | -------------------------------------------------------------------------------- /tests/test_code/subpackage1/submodule1.py: -------------------------------------------------------------------------------- 1 | from ..submodule2 import test_2 2 | 3 | 4 | class A: 5 | def __init__(self, b): 6 | self.b = test_2(b) 7 | -------------------------------------------------------------------------------- /tests/test_code/subpackage2/submodule_hidden1.py: -------------------------------------------------------------------------------- 1 | def test_func1(): 2 | pass 3 | -------------------------------------------------------------------------------- /uploaddist.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | VERSION="$1" 3 | twine upload dist/pyan3-${VERSION}.tar.gz dist/pyan3-${VERSION}-py3-none-any.whl 4 | -------------------------------------------------------------------------------- /visualize_pyan_architecture.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | echo -ne "Pyan architecture: generating architecture.{dot,svg}\n" 3 | python3 -m pyan pyan/*.py --no-defines --uses --colored --annotate --dot -V >architecture.dot 2>architecture.log 4 | dot -Tsvg architecture.dot >architecture.svg 5 | --------------------------------------------------------------------------------