├── .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 | [](https://travis-ci.com/edumco/pyan)
6 | [](https://app.fossa.io/projects/git%2Bgithub.com%2Fedumco%2Fpyan?ref=badge_shield)
7 | [](https://www.codacy.com/manual/edumco/pyan?utm_source=github.com&utm_medium=referral&utm_content=edumco/pyan&utm_campaign=Badge_Grade)
8 | 
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 | [")](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 |
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 |
--------------------------------------------------------------------------------