├── .github ├── CODEOWNERS └── workflows │ └── ci.yml ├── .gitignore ├── .testr.conf ├── ADOPTERS.md ├── CHANGES.rst ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── LICENSE ├── README.rst ├── doc └── source │ ├── changelog.rst │ ├── conf.py │ ├── git_stacktrace.rst │ ├── index.rst │ └── python_api.rst ├── git_stacktrace ├── __init__.py ├── api.py ├── cmd.py ├── git.py ├── parse_trace.py ├── result.py ├── server.py ├── templates │ ├── messages.html │ └── page.html └── tests │ ├── __init__.py │ ├── base.py │ ├── examples │ ├── java1.trace │ ├── java2.trace │ ├── java3.trace │ ├── java4.trace │ ├── javascript1.trace │ ├── javascript2.trace │ ├── javascript3.trace │ ├── javascript4.trace │ ├── javascript5.trace │ ├── python1.trace │ ├── python2.trace │ ├── python3.trace │ ├── python4.trace │ ├── python5.trace │ ├── python6.trace │ ├── python7.trace │ └── python8.trace │ ├── test_api.py │ ├── test_git.py │ ├── test_parse_trace.py │ ├── test_result.py │ └── test_server.py ├── pyproject.toml ├── requirements.txt ├── setup.cfg ├── setup.py ├── test-requirements.txt └── tox.ini /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @jogo 2 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | pull_request: 8 | branches: 9 | - master 10 | 11 | jobs: 12 | build: 13 | runs-on: ubuntu-latest 14 | strategy: 15 | matrix: 16 | python-version: ['3.9', '3.10', '3.11', '3.12'] 17 | 18 | steps: 19 | - uses: actions/checkout@v2 20 | - name: Set up Python ${{ matrix.python-version }} 21 | uses: actions/setup-python@v2 22 | with: 23 | python-version: ${{ matrix.python-version }} 24 | - name: Upgrade pip 25 | run: pip install --upgrade pip 26 | - name: Install Tox and any other packages 27 | run: pip install tox 28 | - name: Lint 29 | if: matrix.python-version == '3.12' 30 | run: | 31 | pip install flake8 black 32 | flake8 git_stacktrace 33 | black --check . 34 | - name: Run Tests via Tox 35 | # Run tox using the version of Python in `PATH` 36 | run: tox -e py 37 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.egg 2 | *.egg-info 3 | .eggs/ 4 | .tox 5 | .testrepository 6 | *.py[cod] 7 | AUTHORS 8 | ChangeLog 9 | TEST 10 | build/ 11 | dist/ 12 | -------------------------------------------------------------------------------- /.testr.conf: -------------------------------------------------------------------------------- 1 | [DEFAULT] 2 | test_command= ${PYTHON:-python} -m subunit.run discover -t ./ . $LISTOPT $IDOPTION 3 | test_id_option=--load-list $IDFILE 4 | test_list_option=--list 5 | -------------------------------------------------------------------------------- /ADOPTERS.md: -------------------------------------------------------------------------------- 1 | # Adopters 2 | 3 | This is an alphabetical list of people and organizations who are using this 4 | project. If you'd like to be included here, please send a Pull Request that 5 | adds your information to this file. 6 | 7 | - [Pinterest](https://www.pinterest.com/) 8 | -------------------------------------------------------------------------------- /CHANGES.rst: -------------------------------------------------------------------------------- 1 | Change Log 2 | ========== 3 | 4 | 1.0.0 5 | ----- 6 | 7 | * Add Python 3.8 support drop Python 2.7 support (https://github.com/pinterest/git-stacktrace/pull/33) 8 | 9 | 0.9.0 10 | ----- 11 | 12 | * Add support for javascript (https://github.com/pinterest/git-stacktrace/pull/24) 13 | 14 | 0.8.1 15 | ----- 16 | 17 | * Add several missing rare git file states (T, U, X) 18 | 19 | 0.8.0 20 | ----- 21 | 22 | * Fix pickaxe support for python3 23 | * Improve java traceback support 24 | * Improve the python api 25 | 26 | 0.7.2 27 | ----- 28 | 29 | * Fix python traceback parsing with no code on last line (https://github.com/pinterest/git-stacktrace/pull/13) 30 | 31 | 0.7.1 32 | ----- 33 | 34 | * Fix python traceback parsing where code is missing (https://github.com/pinterest/git-stacktrace/issues/10) 35 | * Add --debug flag 36 | 37 | 0.7.0 38 | ----- 39 | 40 | * Add python 3 support 41 | 42 | 0.6.0 43 | ----- 44 | 45 | * Support arbitrary sized abbreviated hashes 46 | * Clarify CLI help message 47 | 48 | 0.5.0 49 | ----- 50 | 51 | * Match file line numbers in stacktrace to lines changed in commits 52 | * Differentiate files added, deleted and modified 53 | * print stacktrace headers and footers 54 | * Fix git pickaxe error (Use '--' to separate paths from revisions) 55 | * Add initial java stacktrace support. Begin supporting basic java stacktraces, some more complex formats are not supported yet. 56 | 57 | 0.4.1 58 | ----- 59 | 60 | * Get ready for pypi 61 | 62 | 0.4.0 63 | ----- 64 | 65 | * Initial open source commit 66 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Code of Conduct 2 | 3 | At Pinterest, we work hard to ensure that our work environment is welcoming 4 | and inclusive to as many people as possible. We are committed to creating this 5 | environment for everyone involved in our open source projects as well. We 6 | welcome all participants regardless of ability, age, ethnicity, identified 7 | gender, religion (or lack there of), sexual orientation and socioeconomic 8 | status. 9 | 10 | This code of conduct details our expectations for upholding these values. 11 | 12 | ## Good behavior 13 | 14 | We expect members of our community to exhibit good behavior including (but of 15 | course not limited to): 16 | 17 | - Using intentional and empathetic language. 18 | - Focusing on resolving instead of escalating conflict. 19 | - Providing constructive feedback. 20 | 21 | ## Unacceptable behavior 22 | 23 | Some examples of unacceptable behavior (again, this is not an exhaustive 24 | list): 25 | 26 | - Harassment, publicly or in private. 27 | - Trolling. 28 | - Sexual advances (this isn’t the place for it). 29 | - Publishing other’s personal information. 30 | - Any behavior which would be deemed unacceptable in a professional environment. 31 | 32 | ## Recourse 33 | 34 | If you are witness to or the target of unacceptable behavior, it should be 35 | reported to Pinterest at opensource-policy@pinterest.com. All reporters will 36 | be kept confidential and an appropriate response for each incident will be 37 | evaluated. 38 | 39 | If the git-stacktrace maintainers do not uphold and enforce this code of 40 | conduct in good faith, community leadership will hold them accountable. 41 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | First off, thanks for taking the time to contribute! This guide will answer 4 | some common questions about how this project works. 5 | 6 | While this is a Pinterest open source project, we welcome contributions from 7 | everyone. Regular outside contributors can become project maintainers. 8 | 9 | ## Code of Conduct 10 | 11 | Please read and understand our [`CODE_OF_CONDUCT.md`](CODE_OF_CONDUCT.md). We 12 | work hard to ensure that our projects are welcoming and inclusive to as many 13 | people as possible. 14 | 15 | ## Making Changes 16 | 17 | 1. Fork this repository to your own account 18 | 2. Make your changes and verify that tests pass 19 | 3. Commit your work and push to a new branch on your fork 20 | 4. Submit a pull request 21 | 5. Participate in the code review process by responding to feedback 22 | 23 | Once there is agreement that the code is in good shape, one of the project's 24 | maintainers will merge your contribution. 25 | 26 | To increase the chances that your pull request will be accepted: 27 | 28 | - Follow the coding style 29 | - Write tests for your changes 30 | - Write a good commit message 31 | 32 | ## Coding Style 33 | 34 | This project follows [PEP 8](https://www.python.org/dev/peps/pep-0008/) 35 | conventions and is linted using [flake8](http://flake8.pycqa.org/). 36 | 37 | ## Testing 38 | 39 | The tests use [pytest](https://docs.pytest.org/) and can be run using `tox`. 40 | 41 | ## License 42 | 43 | By contributing to this project, you agree that your contributions will be 44 | licensed under its [Apache 2 license](LICENSE). 45 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | 2 | Apache License 3 | Version 2.0, January 2004 4 | http://www.apache.org/licenses/ 5 | 6 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 7 | 8 | 1. Definitions. 9 | 10 | "License" shall mean the terms and conditions for use, reproduction, 11 | and distribution as defined by Sections 1 through 9 of this document. 12 | 13 | "Licensor" shall mean the copyright owner or entity authorized by 14 | the copyright owner that is granting the License. 15 | 16 | "Legal Entity" shall mean the union of the acting entity and all 17 | other entities that control, are controlled by, or are under common 18 | control with that entity. For the purposes of this definition, 19 | "control" means (i) the power, direct or indirect, to cause the 20 | direction or management of such entity, whether by contract or 21 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 22 | outstanding shares, or (iii) beneficial ownership of such entity. 23 | 24 | "You" (or "Your") shall mean an individual or Legal Entity 25 | exercising permissions granted by this License. 26 | 27 | "Source" form shall mean the preferred form for making modifications, 28 | including but not limited to software source code, documentation 29 | source, and configuration files. 30 | 31 | "Object" form shall mean any form resulting from mechanical 32 | transformation or translation of a Source form, including but 33 | not limited to compiled object code, generated documentation, 34 | and conversions to other media types. 35 | 36 | "Work" shall mean the work of authorship, whether in Source or 37 | Object form, made available under the License, as indicated by a 38 | copyright notice that is included in or attached to the work 39 | (an example is provided in the Appendix below). 40 | 41 | "Derivative Works" shall mean any work, whether in Source or Object 42 | form, that is based on (or derived from) the Work and for which the 43 | editorial revisions, annotations, elaborations, or other modifications 44 | represent, as a whole, an original work of authorship. For the purposes 45 | of this License, Derivative Works shall not include works that remain 46 | separable from, or merely link (or bind by name) to the interfaces of, 47 | the Work and Derivative Works thereof. 48 | 49 | "Contribution" shall mean any work of authorship, including 50 | the original version of the Work and any modifications or additions 51 | to that Work or Derivative Works thereof, that is intentionally 52 | submitted to Licensor for inclusion in the Work by the copyright owner 53 | or by an individual or Legal Entity authorized to submit on behalf of 54 | the copyright owner. For the purposes of this definition, "submitted" 55 | means any form of electronic, verbal, or written communication sent 56 | to the Licensor or its representatives, including but not limited to 57 | communication on electronic mailing lists, source code control systems, 58 | and issue tracking systems that are managed by, or on behalf of, the 59 | Licensor for the purpose of discussing and improving the Work, but 60 | excluding communication that is conspicuously marked or otherwise 61 | designated in writing by the copyright owner as "Not a Contribution." 62 | 63 | "Contributor" shall mean Licensor and any individual or Legal Entity 64 | on behalf of whom a Contribution has been received by Licensor and 65 | subsequently incorporated within the Work. 66 | 67 | 2. Grant of Copyright License. Subject to the terms and conditions of 68 | this License, each Contributor hereby grants to You a perpetual, 69 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 70 | copyright license to reproduce, prepare Derivative Works of, 71 | publicly display, publicly perform, sublicense, and distribute the 72 | Work and such Derivative Works in Source or Object form. 73 | 74 | 3. Grant of Patent License. Subject to the terms and conditions of 75 | this License, each Contributor hereby grants to You a perpetual, 76 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 77 | (except as stated in this section) patent license to make, have made, 78 | use, offer to sell, sell, import, and otherwise transfer the Work, 79 | where such license applies only to those patent claims licensable 80 | by such Contributor that are necessarily infringed by their 81 | Contribution(s) alone or by combination of their Contribution(s) 82 | with the Work to which such Contribution(s) was submitted. If You 83 | institute patent litigation against any entity (including a 84 | cross-claim or counterclaim in a lawsuit) alleging that the Work 85 | or a Contribution incorporated within the Work constitutes direct 86 | or contributory patent infringement, then any patent licenses 87 | granted to You under this License for that Work shall terminate 88 | as of the date such litigation is filed. 89 | 90 | 4. Redistribution. You may reproduce and distribute copies of the 91 | Work or Derivative Works thereof in any medium, with or without 92 | modifications, and in Source or Object form, provided that You 93 | meet the following conditions: 94 | 95 | (a) You must give any other recipients of the Work or 96 | Derivative Works a copy of this License; and 97 | 98 | (b) You must cause any modified files to carry prominent notices 99 | stating that You changed the files; and 100 | 101 | (c) You must retain, in the Source form of any Derivative Works 102 | that You distribute, all copyright, patent, trademark, and 103 | attribution notices from the Source form of the Work, 104 | excluding those notices that do not pertain to any part of 105 | the Derivative Works; and 106 | 107 | (d) If the Work includes a "NOTICE" text file as part of its 108 | distribution, then any Derivative Works that You distribute must 109 | include a readable copy of the attribution notices contained 110 | within such NOTICE file, excluding those notices that do not 111 | pertain to any part of the Derivative Works, in at least one 112 | of the following places: within a NOTICE text file distributed 113 | as part of the Derivative Works; within the Source form or 114 | documentation, if provided along with the Derivative Works; or, 115 | within a display generated by the Derivative Works, if and 116 | wherever such third-party notices normally appear. The contents 117 | of the NOTICE file are for informational purposes only and 118 | do not modify the License. You may add Your own attribution 119 | notices within Derivative Works that You distribute, alongside 120 | or as an addendum to the NOTICE text from the Work, provided 121 | that such additional attribution notices cannot be construed 122 | as modifying the License. 123 | 124 | You may add Your own copyright statement to Your modifications and 125 | may provide additional or different license terms and conditions 126 | for use, reproduction, or distribution of Your modifications, or 127 | for any such Derivative Works as a whole, provided Your use, 128 | reproduction, and distribution of the Work otherwise complies with 129 | the conditions stated in this License. 130 | 131 | 5. Submission of Contributions. Unless You explicitly state otherwise, 132 | any Contribution intentionally submitted for inclusion in the Work 133 | by You to the Licensor shall be under the terms and conditions of 134 | this License, without any additional terms or conditions. 135 | Notwithstanding the above, nothing herein shall supersede or modify 136 | the terms of any separate license agreement you may have executed 137 | with Licensor regarding such Contributions. 138 | 139 | 6. Trademarks. This License does not grant permission to use the trade 140 | names, trademarks, service marks, or product names of the Licensor, 141 | except as required for reasonable and customary use in describing the 142 | origin of the Work and reproducing the content of the NOTICE file. 143 | 144 | 7. Disclaimer of Warranty. Unless required by applicable law or 145 | agreed to in writing, Licensor provides the Work (and each 146 | Contributor provides its Contributions) on an "AS IS" BASIS, 147 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 148 | implied, including, without limitation, any warranties or conditions 149 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 150 | PARTICULAR PURPOSE. You are solely responsible for determining the 151 | appropriateness of using or redistributing the Work and assume any 152 | risks associated with Your exercise of permissions under this License. 153 | 154 | 8. Limitation of Liability. In no event and under no legal theory, 155 | whether in tort (including negligence), contract, or otherwise, 156 | unless required by applicable law (such as deliberate and grossly 157 | negligent acts) or agreed to in writing, shall any Contributor be 158 | liable to You for damages, including any direct, indirect, special, 159 | incidental, or consequential damages of any character arising as a 160 | result of this License or out of the use or inability to use the 161 | Work (including but not limited to damages for loss of goodwill, 162 | work stoppage, computer failure or malfunction, or any and all 163 | other commercial damages or losses), even if such Contributor 164 | has been advised of the possibility of such damages. 165 | 166 | 9. Accepting Warranty or Additional Liability. While redistributing 167 | the Work or Derivative Works thereof, You may choose to offer, 168 | and charge a fee for, acceptance of support, warranty, indemnity, 169 | or other liability obligations and/or rights consistent with this 170 | License. However, in accepting such obligations, You may act only 171 | on Your own behalf and on Your sole responsibility, not on behalf 172 | of any other Contributor, and only if You agree to indemnify, 173 | defend, and hold each Contributor harmless for any liability 174 | incurred by, or claims asserted against, such Contributor by reason 175 | of your accepting any such warranty or additional liability. 176 | 177 | END OF TERMS AND CONDITIONS 178 | 179 | APPENDIX: How to apply the Apache License to your work. 180 | 181 | To apply the Apache License to your work, attach the following 182 | boilerplate notice, with the fields enclosed by brackets "[]" 183 | replaced with your own identifying information. (Don't include 184 | the brackets!) The text should be enclosed in the appropriate 185 | comment syntax for the file format. We also recommend that a 186 | file or class name and description of purpose be included on the 187 | same "printed page" as the copyright notice for easier 188 | identification within third-party archives. 189 | 190 | Copyright [yyyy] [name of copyright owner] 191 | 192 | Licensed under the Apache License, Version 2.0 (the "License"); 193 | you may not use this file except in compliance with the License. 194 | You may obtain a copy of the License at 195 | 196 | http://www.apache.org/licenses/LICENSE-2.0 197 | 198 | Unless required by applicable law or agreed to in writing, software 199 | distributed under the License is distributed on an "AS IS" BASIS, 200 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 201 | See the License for the specific language governing permissions and 202 | limitations under the License. 203 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | git-stacktrace 2 | ============== 3 | 4 | git-stacktrace is a tool to help you associate git commits with stacktraces. 5 | 6 | It helps you identify related commits by looking at: 7 | 8 | * commits in given range that touched files present in the stacktrace 9 | * commits in given range that added/removed code present in the stacktrace 10 | 11 | Supported Languages 12 | ------------------- 13 | 14 | * Python 15 | * Java 16 | * `JavaScript `_ 17 | 18 | Development 19 | ------------ 20 | 21 | Run tests with: ``tox`` 22 | 23 | Installation 24 | ------------ 25 | 26 | .. code-block:: sh 27 | 28 | $ pip install git_stacktrace 29 | 30 | Usage 31 | ----- 32 | 33 | Run ``git stacktrace`` within a git initialized directory. 34 | 35 | .. code-block:: sh 36 | 37 | usage: git stacktrace [] [] < stacktrace from stdin 38 | 39 | Lookup commits related to a given stacktrace. 40 | 41 | positional arguments: 42 | range git commit range to use 43 | 44 | optional arguments: 45 | -h, --help show this help message and exit 46 | --since show commits more recent than a specific date (from 47 | git-log) 48 | --server start a webserver to visually interact with git- 49 | stacktrace 50 | --port PORT Server port 51 | -f, --fast Speed things up by not running pickaxe if the file for 52 | a line of code cannot be found 53 | -b [BRANCH], --branch [BRANCH] 54 | Git branch. If using --since, use this to specify 55 | which branch to run since on. Runs on current branch 56 | by default 57 | --version show program's version number and exit 58 | -d, --debug Enable debug logging 59 | 60 | 61 | For the Python API see: ``git_stacktrace/api.py`` 62 | 63 | To run as a web server: ``git stacktrace --server --port=8080`` 64 | or ``GIT_STACKTRACE_PORT=8080 git stacktrace --server`` 65 | 66 | Use the web server as an API: 67 | 68 | .. code-block:: sh 69 | 70 | $ curl \ 71 | -d '{"option-type":"by-date", "since":"1.day", "trace":"..."}' \ 72 | -H "Content-Type: application/json" \ 73 | -X POST http://localhost:8080/ 74 | 75 | 76 | Examples 77 | -------- 78 | 79 | Example output:: 80 | 81 | 82 | $ git stacktrace --since=1.day < trace 83 | Traceback (most recent call last): 84 | File "webapp/framework/resource.py", line 72, in _call 85 | result = getattr(self, method_name)() 86 | File "webapp/resources/interests_resource.py", line 232, in get 87 | if self.options['from_navigate'] == "true": 88 | KeyError 89 | 90 | 91 | commit da39a3ee5e6b4b0d3255bfef95601890afd80709 92 | Commit Date: Tue, 19 Jul 2016 14:18:08 -0700 93 | Author: John Doe 94 | Subject: break interest resource 95 | Link: https://example.com/D1000 96 | Files Modified: 97 | - webapp/resources/interests_resource.py:232 98 | Lines Added: 99 | - "if self.options['from_navigate'] == "true":" 100 | -------------------------------------------------------------------------------- /doc/source/changelog.rst: -------------------------------------------------------------------------------- 1 | .. include:: ../../CHANGES.rst 2 | -------------------------------------------------------------------------------- /doc/source/conf.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # git-stacktrace documentation build configuration file, created by 4 | # sphinx-quickstart on Wed Aug 30 11:15:56 2017. 5 | # 6 | # This file is execfile()d with the current directory set to its 7 | # containing dir. 8 | # 9 | # Note that not all possible configuration values are present in this 10 | # autogenerated file. 11 | # 12 | # All configuration values have a default; values that are commented out 13 | # serve to show the default. 14 | 15 | # If extensions (or modules to document with autodoc) are in another directory, 16 | # add these directories to sys.path here. If the directory is relative to the 17 | # documentation root, use os.path.abspath to make it absolute, like shown here. 18 | # 19 | import os 20 | import sys 21 | 22 | import git_stacktrace 23 | 24 | sys.path.insert(0, os.path.abspath(".")) 25 | 26 | 27 | # -- General configuration ------------------------------------------------ 28 | 29 | # If your documentation needs a minimal Sphinx version, state it here. 30 | # 31 | # needs_sphinx = '1.0' 32 | 33 | # Add any Sphinx extension module names here, as strings. They can be 34 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom 35 | # ones. 36 | extensions = ["sphinx.ext.autodoc", "sphinx.ext.viewcode", "sphinx.ext.intersphinx"] 37 | 38 | # Add any paths that contain templates here, relative to this directory. 39 | templates_path = ["_templates"] 40 | 41 | # The suffix(es) of source filenames. 42 | # You can specify multiple suffix as a list of string: 43 | # 44 | # source_suffix = ['.rst', '.md'] 45 | source_suffix = ".rst" 46 | 47 | # The master toctree document. 48 | master_doc = "index" 49 | 50 | # General information about the project. 51 | project = "git-stacktrace" 52 | copyright = "2017 Pinterest, Inc" 53 | author = "Joe Gordon" 54 | 55 | 56 | # The version info for the project you're documenting, acts as replacement for 57 | # |version| and |release|, also used in various other places throughout the 58 | # built documents. 59 | # 60 | # The short X.Y version. 61 | version = git_stacktrace.__version__ 62 | # The full version, including alpha/beta/rc tags. 63 | release = git_stacktrace.__version__ 64 | 65 | # The language for content autogenerated by Sphinx. Refer to documentation 66 | # for a list of supported languages. 67 | # 68 | # This is also used if you do content translation via gettext catalogs. 69 | # Usually you set "language" from the command line for these cases. 70 | language = None 71 | 72 | # List of patterns, relative to source directory, that match files and 73 | # directories to ignore when looking for source files. 74 | # This patterns also effect to html_static_path and html_extra_path 75 | exclude_patterns = [] 76 | 77 | # The name of the Pygments (syntax highlighting) style to use. 78 | pygments_style = "sphinx" 79 | 80 | # If true, `todo` and `todoList` produce output, else they produce nothing. 81 | todo_include_todos = False 82 | 83 | 84 | # -- Options for HTML output ---------------------------------------------- 85 | 86 | # The theme to use for HTML and HTML Help pages. See the documentation for 87 | # a list of builtin themes. 88 | # 89 | html_theme = "sphinx_rtd_theme" 90 | 91 | # Theme options are theme-specific and customize the look and feel of a theme 92 | # further. For a list of options available for each theme, see the 93 | # documentation. 94 | # 95 | # html_theme_options = {} 96 | 97 | # Add any paths that contain custom static files (such as style sheets) here, 98 | # relative to this directory. They are copied after the builtin static files, 99 | # so a file named "default.css" will overwrite the builtin "default.css". 100 | html_static_path = ["_static"] 101 | 102 | # Custom sidebar templates, must be a dictionary that maps document names 103 | # to template names. 104 | # 105 | # This is required for the alabaster theme 106 | # refs: http://alabaster.readthedocs.io/en/latest/installation.html#sidebars 107 | html_sidebars = { 108 | "**": [ 109 | "about.html", 110 | "navigation.html", 111 | "relations.html", # needs 'show_related': True theme option to display 112 | "searchbox.html", 113 | "donate.html", 114 | ] 115 | } 116 | 117 | 118 | # -- Options for HTMLHelp output ------------------------------------------ 119 | 120 | # Output file base name for HTML help builder. 121 | htmlhelp_basename = "git-stacktracedoc" 122 | 123 | 124 | # -- Options for LaTeX output --------------------------------------------- 125 | 126 | latex_elements = { 127 | # The paper size ('letterpaper' or 'a4paper'). 128 | # 129 | # 'papersize': 'letterpaper', 130 | # The font size ('10pt', '11pt' or '12pt'). 131 | # 132 | # 'pointsize': '10pt', 133 | # Additional stuff for the LaTeX preamble. 134 | # 135 | # 'preamble': '', 136 | # Latex figure (float) alignment 137 | # 138 | # 'figure_align': 'htbp', 139 | } 140 | 141 | # Grouping the document tree into LaTeX files. List of tuples 142 | # (source start file, target name, title, 143 | # author, documentclass [howto, manual, or own class]). 144 | latex_documents = [ 145 | (master_doc, "git-stacktrace.tex", "git-stacktrace Documentation", "Joe Gordon", "manual"), 146 | ] 147 | 148 | 149 | # -- Options for manual page output --------------------------------------- 150 | 151 | # One entry per manual page. List of tuples 152 | # (source start file, name, description, authors, manual section). 153 | man_pages = [(master_doc, "git-stacktrace", "git-stacktrace Documentation", [author], 1)] 154 | 155 | 156 | # -- Options for Texinfo output ------------------------------------------- 157 | 158 | # Grouping the document tree into Texinfo files. List of tuples 159 | # (source start file, target name, title, author, 160 | # dir menu entry, description, category) 161 | texinfo_documents = [ 162 | ( 163 | master_doc, 164 | "git-stacktrace", 165 | "git-stacktrace Documentation", 166 | author, 167 | "git-stacktrace", 168 | "One line description of project.", 169 | "Miscellaneous", 170 | ), 171 | ] 172 | 173 | 174 | # Example configuration for intersphinx: refer to the Python standard library. 175 | intersphinx_mapping = {"https://docs.python.org/": None} 176 | -------------------------------------------------------------------------------- /doc/source/git_stacktrace.rst: -------------------------------------------------------------------------------- 1 | git\_stacktrace package 2 | ======================= 3 | 4 | Submodules 5 | ---------- 6 | 7 | git\_stacktrace\.api module 8 | --------------------------- 9 | 10 | .. automodule:: git_stacktrace.api 11 | :members: 12 | :undoc-members: 13 | :show-inheritance: 14 | 15 | git\_stacktrace\.cmd module 16 | --------------------------- 17 | 18 | .. automodule:: git_stacktrace.cmd 19 | :members: 20 | :undoc-members: 21 | :show-inheritance: 22 | 23 | git\_stacktrace\.git module 24 | --------------------------- 25 | 26 | .. automodule:: git_stacktrace.git 27 | :members: 28 | :undoc-members: 29 | :show-inheritance: 30 | 31 | git\_stacktrace\.parse\_trace module 32 | ------------------------------------ 33 | 34 | .. automodule:: git_stacktrace.parse_trace 35 | :members: 36 | :undoc-members: 37 | :show-inheritance: 38 | 39 | git\_stacktrace\.result module 40 | ------------------------------ 41 | 42 | .. automodule:: git_stacktrace.result 43 | :members: 44 | :undoc-members: 45 | :show-inheritance: 46 | 47 | 48 | Module contents 49 | --------------- 50 | 51 | .. automodule:: git_stacktrace 52 | :members: 53 | :undoc-members: 54 | :show-inheritance: 55 | -------------------------------------------------------------------------------- /doc/source/index.rst: -------------------------------------------------------------------------------- 1 | .. include:: ../../README.rst 2 | 3 | 4 | Additional Resources 5 | ===================== 6 | 7 | .. toctree:: 8 | :maxdepth: 2 9 | 10 | python_api 11 | changelog 12 | 13 | 14 | 15 | Indices and tables 16 | ================== 17 | 18 | * :ref:`genindex` 19 | * :ref:`modindex` 20 | -------------------------------------------------------------------------------- /doc/source/python_api.rst: -------------------------------------------------------------------------------- 1 | ========== 2 | Python API 3 | ========== 4 | 5 | .. automodule:: git_stacktrace.api 6 | :members: 7 | :undoc-members: 8 | :show-inheritance: 9 | 10 | 11 | -------------------------------------------------------------------------------- /git_stacktrace/__init__.py: -------------------------------------------------------------------------------- 1 | import pkg_resources 2 | 3 | __version__ = pkg_resources.get_provider(pkg_resources.Requirement.parse("git-stacktrace")).version 4 | -------------------------------------------------------------------------------- /git_stacktrace/api.py: -------------------------------------------------------------------------------- 1 | """ 2 | python API to call git stacktrace. 3 | 4 | 5 | Example usage:: 6 | 7 | from git_stacktrace import api 8 | 9 | traceback = api.parse_trace(traceback_string) 10 | git_range = api.convert_since('1.day') 11 | results = api.lookup_stacktrace(traceback, git_range) 12 | for r in results.get_sorted_results(): 13 | if "Smith" in r.author 14 | print("") 15 | print(r) 16 | """ 17 | 18 | import logging 19 | 20 | from git_stacktrace import git 21 | from git_stacktrace import result 22 | from git_stacktrace import parse_trace 23 | 24 | log = logging.getLogger(__name__) 25 | 26 | # So we can call api.parse_trace 27 | parse_trace = parse_trace.parse_trace 28 | 29 | 30 | def _longest_filename(matches): 31 | """find longest match by number of '/'.""" 32 | return max(matches, key=lambda filename: len(filename.split("/"))) 33 | 34 | 35 | def _lookup_files(commit_files, git_files, traceback, results): 36 | """Populate results and line.git_filename.""" 37 | for line in traceback.lines: 38 | matches = traceback.file_match(line.trace_filename, git_files) 39 | if matches: 40 | git_file = _longest_filename(matches) 41 | for commit, file_list in commit_files.items(): 42 | if git_file in file_list: 43 | git_file = file_list[file_list.index(git_file)] 44 | line.git_filename = git_file.filename 45 | line_number = None 46 | if git.line_match(commit, line): 47 | line_number = line.line_number 48 | results.get_result(commit).add_file(git_file, line_number) 49 | if line.git_filename is None: 50 | line.git_filename = _longest_filename(matches) 51 | 52 | 53 | def convert_since(since, branch=None): 54 | """Convert the git since format into a git range 55 | 56 | :param since: git formatted since value such as '1,day' 57 | :type since: str 58 | :param branch: git branch, such as 'origin/master' 59 | :type branch: str 60 | :returns: commit range, such as 'ab9f71a..c04140' 61 | :rtype: str 62 | """ 63 | return git.convert_since(since, branch=branch) 64 | 65 | 66 | def valid_range(git_range): 67 | """Make sure there are commits in the range 68 | 69 | Generate a dictionary of files modified by the commits in range 70 | 71 | :param git_range: range of commits, such as 'ab9f71a..c04140' 72 | :type git_range: str 73 | :rtype: bool 74 | """ 75 | return git.valid_range(git_range) 76 | 77 | 78 | def lookup_stacktrace(traceback, git_range, fast=False): 79 | """Lookup to see what commits in git_range could have caused the stacktrace. 80 | 81 | Pass in a stacktrace object and returns a results object. 82 | 83 | :param traceback: Traceback object 84 | :type traceback: git_stacktrace.parse_trace.Traceback 85 | :param git_range: git commit range 86 | :type git_range: str 87 | :param fast: If True, don't run pickaxe if cannot find the file in git. 88 | :type fast: bool 89 | :returns: results 90 | :rtype: git_stacktrace.result.Results 91 | """ 92 | results = result.Results() 93 | 94 | commit_files = git.files_touched(git_range) 95 | git_files = git.files(git_range) 96 | _lookup_files(commit_files, git_files, traceback, results) 97 | 98 | for line in traceback.lines: 99 | commits = [] 100 | if line.code and not (line.git_filename is None and fast is True): 101 | try: 102 | commits = git.pickaxe(line.code, git_range, line.git_filename) 103 | except Exception: 104 | # If this fails, move on 105 | if log.isEnabledFor(logging.DEBUG): 106 | log.exception("pickaxe failed") 107 | continue 108 | for commit, line_removed in commits: 109 | if line_removed is True: 110 | results.get_result(commit).lines_removed.add(line.code) 111 | if line_removed is False: 112 | results.get_result(commit).lines_added.add(line.code) 113 | return results 114 | -------------------------------------------------------------------------------- /git_stacktrace/cmd.py: -------------------------------------------------------------------------------- 1 | from __future__ import print_function 2 | 3 | import argparse 4 | import logging 5 | import os 6 | import select 7 | import sys 8 | 9 | import git_stacktrace 10 | from git_stacktrace import api 11 | from git_stacktrace import server 12 | from wsgiref.simple_server import make_server 13 | 14 | 15 | def main(): 16 | usage = "git stacktrace [] [] < stacktrace from stdin" 17 | description = "Lookup commits related to a given stacktrace." 18 | parser = argparse.ArgumentParser(usage=usage, description=description) 19 | range_group = parser.add_mutually_exclusive_group() 20 | range_group.add_argument( 21 | "--since", metavar="", help="show commits " "more recent than a specific date (from git-log)" 22 | ) 23 | range_group.add_argument("range", nargs="?", help="git commit range to use") 24 | range_group.add_argument( 25 | "--server", action="store_true", help="start a " "webserver to visually interact with git-stacktrace" 26 | ) 27 | parser.add_argument("--port", default=os.environ.get("GIT_STACKTRACE_PORT", 8080), type=int, help="Server port") 28 | parser.add_argument( 29 | "-f", 30 | "--fast", 31 | action="store_true", 32 | help="Speed things up by not running " "pickaxe if the file for a line of code cannot be found", 33 | ) 34 | parser.add_argument( 35 | "-b", 36 | "--branch", 37 | nargs="?", 38 | help="Git branch. If using --since, use this to " 39 | "specify which branch to run since on. Runs on current branch by default", 40 | ) 41 | parser.add_argument( 42 | "--version", 43 | action="version", 44 | version="%s version %s" % (os.path.split(sys.argv[0])[-1], git_stacktrace.__version__), 45 | ) 46 | parser.add_argument("-d", "--debug", action="store_true", help="Enable debug logging") 47 | args = parser.parse_args() 48 | 49 | logging.basicConfig(format="%(name)s:%(funcName)s:%(lineno)s: %(message)s") 50 | if args.debug: 51 | logging.getLogger().setLevel(logging.DEBUG) 52 | 53 | if args.server: 54 | print("Starting httpd on port %s..." % args.port) 55 | httpd = make_server("", args.port, server.application) 56 | try: 57 | httpd.serve_forever() 58 | except KeyboardInterrupt: 59 | sys.exit(0) 60 | 61 | if args.since: 62 | git_range = api.convert_since(args.since, branch=args.branch) 63 | print("commit range: %s" % git_range, file=sys.stderr) 64 | else: 65 | if args.range is None: 66 | print("Error: Missing range and since, must use one\n") 67 | parser.print_help() 68 | sys.exit(1) 69 | git_range = args.range 70 | 71 | if not api.valid_range(git_range): 72 | print("Found no commits in '%s'" % git_range) 73 | sys.exit(1) 74 | 75 | if not select.select([sys.stdin], [], [], 0.0)[0]: 76 | raise Exception("No input found in stdin") 77 | blob = sys.stdin.readlines() 78 | traceback = api.parse_trace(blob) 79 | 80 | print(traceback) 81 | 82 | results = api.lookup_stacktrace(traceback, git_range, fast=args.fast) 83 | 84 | for r in results.get_sorted_results(): 85 | print("") 86 | print(r) 87 | 88 | if len(results.get_sorted_results()) == 0: 89 | print("No matches found") 90 | 91 | 92 | if __name__ == "__main__": 93 | main() 94 | -------------------------------------------------------------------------------- /git_stacktrace/git.py: -------------------------------------------------------------------------------- 1 | from __future__ import print_function 2 | 3 | import collections 4 | import datetime 5 | import re 6 | import subprocess 7 | import sys 8 | import shlex 9 | import os 10 | 11 | import whatthepatch 12 | 13 | SHA1_REGEX = re.compile(r"\b[0-9a-f]{40}\b") 14 | 15 | CommitInfo = collections.namedtuple("CommitInfo", ["summary", "subject", "body", "url", "author", "date"]) 16 | 17 | 18 | class GitFile(object): 19 | """Track filename and if file was added/removed or modified.""" 20 | 21 | ADDED = "A" 22 | COPY_EDIT = "C" 23 | DELETED = "D" 24 | MODIFIED = "M" 25 | RENAME_EDIT = "R" 26 | TYPE = "T" 27 | UNMERGED = "U" 28 | UNKNOWN = "X" 29 | VALID = frozenset([ADDED, DELETED, MODIFIED, COPY_EDIT, RENAME_EDIT, TYPE, UNMERGED, UNKNOWN]) 30 | 31 | def __init__(self, filename, state=None): 32 | self.filename = filename 33 | if state not in GitFile.VALID: 34 | raise Exception("Invalid git file state: %s" % state) 35 | self.state = state 36 | 37 | def __repr__(self): 38 | return self.filename 39 | 40 | def __eq__(self, other): 41 | if isinstance(other, str): 42 | other_filename = other 43 | else: 44 | other_filename = other.filename 45 | return self.filename == other_filename 46 | 47 | 48 | def run_command_status(*argv, **kwargs): 49 | if len(argv) == 1: 50 | argv = shlex.split(str(argv[0])) 51 | stdin = kwargs.pop("stdin", None) 52 | newenv = os.environ.copy() 53 | newenv["LANG"] = "C" 54 | newenv["LANGUAGE"] = "C" 55 | newenv.update(kwargs) 56 | p = subprocess.Popen( 57 | argv, stdin=subprocess.PIPE if stdin else None, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, env=newenv 58 | ) 59 | (out, nothing) = p.communicate(stdin) 60 | out = out.decode("utf-8", "replace") 61 | return (p.returncode, out.strip()) 62 | 63 | 64 | def run_command(*argv, **kwargs): 65 | (rc, output) = run_command_status(*argv, **kwargs) 66 | if rc != 0: 67 | print(argv, rc, output) 68 | raise Exception("Something went wrong running the command %s %s" % (argv, kwargs)) 69 | return output 70 | 71 | 72 | def files_touched(git_range): 73 | """Run git log --pretty="%H" --raw git_range. 74 | 75 | Generate a dictionary of files modified by the commits in range 76 | """ 77 | cmd = "git", "log", "--pretty=%H", "--raw", git_range 78 | data = run_command(*cmd) 79 | commits = collections.defaultdict(list) 80 | commit = None 81 | for line in data.splitlines(): 82 | if SHA1_REGEX.match(line): 83 | commit = line 84 | elif line.strip(): 85 | split_line = line.split("\t") 86 | filename = split_line[-1] 87 | state = split_line[0].split(" ")[-1][0] 88 | commits[commit].append(GitFile(filename, state)) 89 | return commits 90 | 91 | 92 | def pickaxe(snippet, git_range, filename=None): 93 | """Run git log -S 94 | 95 | Use git pickaxe to 'Look for differences that change the number of occurrences of the 96 | specified string' 97 | 98 | If filename is passed in only look in that file 99 | 100 | Return list of commits that modified that snippet 101 | """ 102 | cmd = "git", "log", "-b", "--pretty=%H", "-S", str(snippet), git_range 103 | if filename: 104 | cmd = cmd + ( 105 | "--", 106 | filename, 107 | ) 108 | commits = run_command(*cmd).splitlines() 109 | commits = [(commit, line_removed(snippet, commit)) for commit in commits] 110 | # Couldn't find a good way to POSIX regex escape the code and use regex 111 | # pickaxe to match full lines, so filter out partial results here. 112 | # Filter out results that aren't a full line 113 | commits = [commit for commit in commits if commit[1] is not None] 114 | return commits 115 | 116 | 117 | def line_removed(target_line, commit): 118 | """Given a commit tell if target_line was added or removed. 119 | 120 | True if line was removed 121 | False if added 122 | None if target_line wasn't found at all (because not a full line etc.) 123 | """ 124 | cmd = "git", "log", "-1", "--format=", "-p", str(commit) 125 | diff = run_command(*cmd) 126 | for diff in whatthepatch.parse_patch(diff): 127 | for line in diff.changes: 128 | if target_line in line[2]: 129 | if line[0] is None: 130 | # Line added 131 | return False 132 | elif line[1] is None: 133 | # Line removed 134 | return True 135 | # target_line matched part of a line instead of a full line 136 | return None 137 | 138 | 139 | def line_match(commit, traceback_line): 140 | """Return true if line_number was added to filename in commit""" 141 | 142 | cmd = "git", "log", "-1", "--format=", "-p", str(commit) 143 | diff = run_command(*cmd) 144 | for diff in whatthepatch.parse_patch(diff): 145 | if diff.header.new_path == traceback_line.git_filename: 146 | for line in diff.changes: 147 | if line[0] is None: 148 | if line[1] == traceback_line.line_number: 149 | return True 150 | return False 151 | 152 | 153 | def format_one_commit(commit): 154 | result = [] 155 | info = get_commit_info(commit) 156 | result.append(info.summary) 157 | if info.url: 158 | result.append("Link: " + info.url) 159 | return "\n".join(result) 160 | 161 | 162 | def get_commit_info(commit, color=True): 163 | # Only use color if output is a terminal 164 | if sys.stdout.isatty() and color: 165 | cmd_prefix = "git", "log", "--color", "-1" 166 | else: 167 | cmd_prefix = "git", "log", "-1" 168 | git_format = ( 169 | "--format=%C(auto,yellow)commit %H%C(auto,reset)%n" "Commit Date: %cD%nAuthor: %aN <%aE>%nSubject: %s" 170 | ) 171 | summary = run_command(*(cmd_prefix + (git_format, commit))) 172 | 173 | # Find phabricator URL 174 | cmd = "git", "log", "-1", "--pretty=%b", commit 175 | body = run_command(*cmd) 176 | url = None 177 | for line in body.splitlines(): 178 | if line.startswith("Differential Revision:"): 179 | url = line.split(" ")[2] 180 | 181 | cmd = "git", "log", "-1", "--format=%ct|%aN <%aE>", str(commit) 182 | date, author = run_command(*cmd).split("|", 1) 183 | date = datetime.datetime.fromtimestamp(int(date)) 184 | 185 | cmd = "git", "log", "-1", "--format=%s", str(commit) 186 | subject = run_command(*cmd) 187 | 188 | return CommitInfo(summary=summary, subject=subject, body=body, url=url, author=author, date=date) 189 | 190 | 191 | def valid_range(git_range): 192 | """Make sure there are commits in the range 193 | 194 | Returns True or False 195 | """ 196 | cmd = "git", "log", "--oneline", git_range 197 | data = run_command(*cmd) 198 | lines = data.splitlines() 199 | return len(lines) > 0 200 | 201 | 202 | def convert_since(since, branch=None): 203 | cmd = "git", "log", "--pretty=%H", "--since=%s" % since 204 | if branch: 205 | cmd = cmd + (branch,) 206 | data = run_command(*cmd) 207 | lines = data.splitlines() 208 | if len(lines) == 0: 209 | raise Exception("Didn't find any commits in 'since' range, try updating your git repo") 210 | return "%s..%s" % (lines[-1], lines[0]) 211 | 212 | 213 | def files(git_range): 214 | commit = git_range.split(".")[-1] 215 | cmd = "git", "ls-tree", "-r", "--name-only", commit 216 | data = run_command(*cmd) 217 | files = data.splitlines() 218 | return files 219 | -------------------------------------------------------------------------------- /git_stacktrace/parse_trace.py: -------------------------------------------------------------------------------- 1 | """Extract important filenames, lines, functions and code from stacktrace 2 | 3 | Currently only supports python stacktraces 4 | """ 5 | 6 | from __future__ import print_function 7 | 8 | import abc 9 | import logging 10 | import re 11 | import traceback 12 | 13 | log = logging.getLogger(__name__) 14 | 15 | 16 | class ParseException(Exception): 17 | pass 18 | 19 | 20 | class Line(object): 21 | """Track data for each line in stacktrace""" 22 | 23 | def __init__( 24 | self, filename, line_number, function_name, code, class_name=None, native_method=False, unknown_source=False 25 | ): 26 | self.trace_filename = filename 27 | self.line_number = line_number 28 | self.function_name = function_name 29 | self.code = code 30 | self.class_name = class_name # Java specific 31 | self.native_method = native_method # Java specific 32 | self.unknown_source = unknown_source # Java specific 33 | self.git_filename = None 34 | 35 | def traceback_format(self): 36 | return (self.trace_filename, self.line_number, self.function_name, self.code) 37 | 38 | 39 | class Traceback(object, metaclass=abc.ABCMeta): 40 | def __init__(self, blob): 41 | self.header = "" 42 | self.footer = "" 43 | self.lines = None 44 | self.extract_traceback(self.prep_blob(blob)) 45 | 46 | def prep_blob(self, blob): 47 | """Cleanup input.""" 48 | # remove empty lines 49 | if isinstance(blob, list): 50 | blob = [line for line in blob if line.strip() != ""] 51 | if len(blob) == 1: 52 | blob = blob[0].replace("\\n", "\n").split("\n") 53 | # Split by line 54 | if isinstance(blob, str): 55 | lines = blob.split("\n") 56 | elif isinstance(blob, list): 57 | if len(blob) == 1: 58 | lines = blob[0].split("\n") 59 | else: 60 | lines = [line.rstrip() for line in blob] 61 | else: 62 | message = "Unknown input format" 63 | log.debug("%s - '%s", message, blob) 64 | raise ParseException(message) 65 | return lines 66 | 67 | @abc.abstractmethod 68 | def extract_traceback(self, lines): 69 | """Extract language specific traceback""" 70 | return 71 | 72 | @abc.abstractmethod 73 | def format_lines(self): 74 | """format extracted traceback in same way as traceback.""" 75 | return 76 | 77 | def __str__(self): 78 | return self.header + self.format_lines() + self.footer 79 | 80 | @abc.abstractmethod 81 | def file_match(self, trace_filename, git_files): 82 | """How to match a trace_filename to git_files. 83 | 84 | Generally this varies depending on which is a substring of the other 85 | """ 86 | return 87 | 88 | 89 | class PythonTraceback(Traceback): 90 | """Parse Traceback string.""" 91 | 92 | FILE_LINE_START = ' File "' 93 | 94 | def extract_traceback(self, lines): 95 | """Convert traceback string into a traceback.extract_tb format""" 96 | # filter out traceback lines 97 | self.header = lines[0] + "\n" 98 | if lines[-1] and not lines[-1].startswith(" "): 99 | self.footer = lines[-1] + "\n" 100 | lines = [line.rstrip() for line in lines if line.startswith(" ")] 101 | # extract 102 | extracted = [] 103 | code_line = False 104 | for i, line in enumerate(lines): 105 | if code_line: 106 | code_line = False 107 | continue 108 | words = line.split(", ") 109 | if words[0].startswith(self.FILE_LINE_START): 110 | if not (words[0].startswith(' File "') and words[1].startswith("line ") and words[2].startswith("in")): 111 | message = "Something went wrong parsing stacktrace input." 112 | log.debug("%s - '%s'", message, line) 113 | raise ParseException(message) 114 | f = words[0].split('"')[1].strip() 115 | line_number = int(words[1].split(" ")[1]) 116 | function_name = " ".join(words[2].split(" ")[1:]).strip() 117 | if len(lines) == i + 1 or lines[i + 1].startswith(self.FILE_LINE_START): 118 | # Not all lines have code in the traceback 119 | code = None 120 | else: 121 | code_line = True 122 | code = str(lines[i + 1].strip()) 123 | 124 | try: 125 | extracted.append(Line(filename=f, line_number=line_number, function_name=function_name, code=code)) 126 | except IndexError: 127 | raise ParseException("Incorrectly extracted traceback information") 128 | self.lines = extracted 129 | # Sanity check 130 | new_lines = traceback.format_list(self.traceback_format()) 131 | new_lines = "\n".join([line.rstrip() for line in new_lines]) 132 | lines = "\n".join(lines) 133 | if lines != new_lines or not self.lines: 134 | message = "Incorrectly extracted traceback information" 135 | logging.debug("%s: original != parsed\noriginal:\n%s\nparsed:\n%s", message, lines, new_lines) 136 | raise ParseException(message) 137 | 138 | def traceback_format(self): 139 | return [line.traceback_format() for line in self.lines] 140 | 141 | def format_lines(self): 142 | lines = self.traceback_format() 143 | return "".join(traceback.format_list(lines)) 144 | 145 | def file_match(self, trace_filename, git_files): 146 | # trace_filename is substring of git_filename 147 | return [f for f in git_files if trace_filename.endswith(f)] 148 | 149 | 150 | class JavaTraceback(Traceback): 151 | def extract_traceback(self, lines): 152 | if not lines[0].startswith("\t"): 153 | self.header = lines[0] + "\n" 154 | lines = [line for line in lines if line.startswith("\t")] 155 | extracted = [] 156 | for line in lines: 157 | extracted.append(self._extract_line(line)) 158 | self.lines = extracted 159 | if not self.lines: 160 | raise ParseException("Failed to parse stacktrace") 161 | 162 | def _extract_line(self, line_string): 163 | if not line_string.startswith("\t"): 164 | raise ParseException("Missing tab at beginning of line") 165 | 166 | native_method = False 167 | unknown_source = False 168 | if line_string.endswith("(Native Method)"): 169 | # "at java.io.FileInputStream.open(Native Method)" 170 | native_method = True 171 | if line_string.endswith("(Unknown Source)"): 172 | # $Lambda$5/1034627183.run(Unknown Source) 173 | unknown_source = True 174 | 175 | # split on ' ', '(', ')', ':' 176 | tokens = re.split(r" |\(|\)|:", line_string.strip()) 177 | 178 | if tokens[0] != "at" or len(tokens) != 5: 179 | raise ParseException("Invalid Java Exception") 180 | 181 | path = tokens[1].split(".") 182 | filename = "/".join(path[:-2] + [tokens[2]]) 183 | function_name = path[-1] 184 | if not native_method and not unknown_source: 185 | line_number = int(tokens[3]) 186 | else: 187 | line_number = None 188 | class_name = path[-2] 189 | return Line(filename, line_number, function_name, None, class_name, native_method, unknown_source) 190 | 191 | def _format_line(self, line): 192 | split = line.trace_filename.split("/") 193 | path = ".".join(split[:-1]) 194 | filename = split[-1] 195 | if line.native_method: 196 | return "\tat %s.%s.%s(Native Method)\n" % (path, line.class_name, line.function_name) 197 | if line.unknown_source: 198 | return "\tat %s.%s.%s(Unknown Source)\n" % (path, line.class_name, line.function_name) 199 | return "\tat %s.%s.%s(%s:%d)\n" % (path, line.class_name, line.function_name, filename, line.line_number) 200 | 201 | def format_lines(self): 202 | return "".join(map(self._format_line, self.lines)) 203 | 204 | def file_match(self, trace_filename, git_files): 205 | # git_filename is substring of trace_filename 206 | return [f for f in git_files if f.endswith(trace_filename)] 207 | 208 | 209 | class JavaScriptTraceback(Traceback): 210 | # This class matches a stacktrace that looks similar to https://v8.dev/docs/stack-trace-api 211 | 212 | def extract_traceback(self, lines): 213 | if not lines[0].startswith("\t"): 214 | self.header = lines[0] + "\n" 215 | lines = [line for line in lines if line.startswith("\t")] 216 | extracted = [] 217 | for line in lines: 218 | extracted.append(self._extract_line(line)) 219 | self.lines = extracted 220 | if not self.lines: 221 | raise ParseException("Failed to parse stacktrace") 222 | 223 | def _extract_line(self, line_string): 224 | pattern = r"\tat\s(?P[^\s(]*)?(?:\s*)?\(?(?P[^\s\?]+)(?:\S*)?\:(?P\d+):(?:\d+)\)?" 225 | result = re.match(pattern, line_string) 226 | 227 | if result: 228 | frame = result.groupdict() 229 | else: 230 | log.debug("Failed to parse frame %s", line_string) 231 | raise ParseException 232 | 233 | return Line(frame.get("path"), frame.get("line"), frame.get("symbol"), None) 234 | 235 | def traceback_format(self): 236 | return [line.traceback_format() for line in self.lines] 237 | 238 | def format_lines(self): 239 | lines = self.traceback_format() 240 | return "".join(traceback.format_list(lines)) 241 | 242 | def file_match(self, trace_filename, git_files): 243 | return [f for f in git_files if trace_filename.endswith(f)] 244 | 245 | 246 | def parse_trace(traceback_string): 247 | languages = [PythonTraceback, JavaTraceback, JavaScriptTraceback] 248 | for language in languages: 249 | try: 250 | return language(traceback_string) 251 | except ParseException: 252 | log.debug("Failed to parse as %s", language) 253 | # Try next language 254 | continue 255 | raise ParseException("Unable to parse traceback") 256 | -------------------------------------------------------------------------------- /git_stacktrace/result.py: -------------------------------------------------------------------------------- 1 | from git_stacktrace import git 2 | 3 | 4 | class Result(object): 5 | """Track matches to stacktrace in a given commit.""" 6 | 7 | def __init__(self, commit): 8 | self.__commit = commit 9 | self.files_modified = set() 10 | self.files_deleted = set() 11 | self.files_added = set() 12 | self.lines_added = set() 13 | self.lines_removed = set() 14 | self._line_numbers_matched = 0 15 | self.__commit_info_fetched = False 16 | 17 | def _lazy_fetch(self): 18 | if not self.__commit_info_fetched: 19 | self.__info = git.get_commit_info(self.commit, color=False) 20 | self.__commit_info_fetched = True 21 | 22 | @property 23 | def commit(self): 24 | """git commit hash""" 25 | return self.__commit 26 | 27 | @property 28 | def summary(self): 29 | """pre-formatted summary of git commit information""" 30 | self._lazy_fetch() 31 | return self.__info.summary 32 | 33 | @property 34 | def subject(self): 35 | """commit subject""" 36 | self._lazy_fetch() 37 | return self.__info.subject 38 | 39 | @property 40 | def body(self): 41 | """body of commit message""" 42 | self._lazy_fetch() 43 | return self.__info.body 44 | 45 | @property 46 | def url(self): 47 | """url found in commit body""" 48 | self._lazy_fetch() 49 | return self.__info.url 50 | 51 | @property 52 | def author(self): 53 | """commit author""" 54 | self._lazy_fetch() 55 | return self.__info.author 56 | 57 | @property 58 | def date(self): 59 | """commit date (not author date)""" 60 | self._lazy_fetch() 61 | return self.__info.date 62 | 63 | def add_file(self, git_file, line_number=None): 64 | if line_number: 65 | self._line_numbers_matched += 1 66 | filename = git_file.filename + ":" + str(line_number) 67 | else: 68 | filename = git_file.filename 69 | if git_file.state in [git.GitFile.ADDED, git.GitFile.COPY_EDIT]: 70 | self.files_added.add(filename) 71 | elif git_file.state in [git.GitFile.DELETED]: 72 | self.files_deleted.add(filename) 73 | else: 74 | self.files_modified.add(filename) 75 | 76 | def __hash__(self): 77 | return hash(self.commit) 78 | 79 | def __str__(self): 80 | result = "" 81 | result += git.format_one_commit(self.commit) + "\n" 82 | if len(self.files_added) > 0: 83 | result += "Files Added:\n" 84 | for f in sorted(self.files_added): 85 | result += " - %s\n" % f 86 | 87 | if len(self.files_modified) > 0: 88 | result += "Files Modified:\n" 89 | for f in sorted(self.files_modified): 90 | result += " - %s\n" % f 91 | 92 | if len(self.files_deleted) > 0: 93 | result += "Files Deleted:\n" 94 | for f in sorted(self.files_deleted): 95 | result += " - %s\n" % f 96 | 97 | if len(self.lines_added) > 0: 98 | result += "Lines Added:\n" 99 | for line in sorted(self.lines_added): 100 | result += ' - "%s"\n' % line 101 | 102 | if len(self.lines_removed) > 0: 103 | result += "Lines Removed:\n" 104 | for line in sorted(self.lines_removed): 105 | result += ' - "%s"\n' % line 106 | return result 107 | 108 | def __iter__(self): 109 | self._lazy_fetch() 110 | yield "commit", self.commit 111 | yield "summary", self.summary 112 | yield "subject", self.subject 113 | yield "body", self.body 114 | yield "url", self.url 115 | yield "author", self.author 116 | yield "date", self.date 117 | yield "files_added", list(self.files_added) 118 | yield "files_modified", list(self.files_modified) 119 | yield "files_deleted", list(self.files_deleted) 120 | yield "lines_added", list(self.lines_added) 121 | yield "lines_removed", list(self.lines_removed) 122 | 123 | def rank(self): 124 | return ( 125 | len(self.files_modified) 126 | + len(self.files_deleted) * 2 127 | + len(self.files_added) * 3 128 | + len(self.lines_added) * 3 129 | + len(self.lines_removed) * 2 130 | + self._line_numbers_matched * 4 131 | ) 132 | 133 | def __eq__(self, other): 134 | return self.commit == other.commit 135 | 136 | def __lt__(self, other): 137 | if self.rank() == other.rank(): 138 | # Make sorted order deterministic (but random) if rank is same 139 | # TODO fall back to sorting chronologically 140 | return self.commit < other.commit 141 | return self.rank() < other.rank() 142 | 143 | 144 | class Results(object): 145 | """List of results.""" 146 | 147 | def __init__(self): 148 | self.results = {} 149 | 150 | def get_result(self, commit): 151 | if commit not in self.results: 152 | self.results[commit] = Result(commit) 153 | return self.results[commit] 154 | 155 | def get_sorted_results(self): 156 | """Return list of results sorted by rank""" 157 | results = self.results.values() 158 | return sorted(results, reverse=True) 159 | 160 | def get_sorted_results_by_dict(self): 161 | """Return a list of dictionaries of the results sorted by rank""" 162 | results = self.get_sorted_results() 163 | return [dict(r) for r in results] 164 | -------------------------------------------------------------------------------- /git_stacktrace/server.py: -------------------------------------------------------------------------------- 1 | from __future__ import print_function 2 | 3 | import json 4 | import logging 5 | import os 6 | 7 | from html import escape, unescape 8 | from git_stacktrace import api 9 | from urllib.parse import parse_qs 10 | from string import Template 11 | from datetime import date, datetime 12 | 13 | log = logging.getLogger(__name__) 14 | dir_path = os.path.dirname(os.path.realpath(__file__)) 15 | 16 | 17 | def json_serial(obj): 18 | """JSON serializer for objects not serializable by default json code""" 19 | 20 | if isinstance(obj, (datetime, date)): 21 | return obj.isoformat() 22 | raise TypeError("Type %s not serializable" % type(obj)) 23 | 24 | 25 | class Args(object): 26 | @staticmethod 27 | def from_json_body(body): 28 | return Args(json.loads(body)) 29 | 30 | @staticmethod 31 | def from_qs(query_string): 32 | return Args(parse_qs(query_string.lstrip("?"))) 33 | 34 | def __init__(self, params): 35 | self.params = params 36 | 37 | def _get_field(self, field, default=""): 38 | val = self.params.get(field, [default]) 39 | val = val[0] if isinstance(val, list) else val 40 | return unescape(val) 41 | 42 | @property 43 | def type(self): 44 | return self._get_field("option-type") 45 | 46 | @property 47 | def range(self): 48 | return self._get_field("range") 49 | 50 | @property 51 | def branch(self): 52 | return self._get_field("branch") 53 | 54 | @property 55 | def since(self): 56 | return self._get_field("since") 57 | 58 | @property 59 | def trace(self): 60 | return self._get_field("trace") 61 | 62 | @property 63 | def fast(self): 64 | return self._get_field("fast") == "on" 65 | 66 | def validate(self): 67 | if not self.type: 68 | return None 69 | 70 | if self.type == "by-date": 71 | if not self.since: 72 | return "Missing `since` value. Plese specify a date." 73 | self.git_range = api.convert_since(self.since, branch=self.branch) 74 | if not api.valid_range(self.git_range): 75 | return "Found no commits in '%s'" % self.git_range 76 | elif self.type == "by-range": 77 | self.git_range = self.range 78 | if not api.valid_range(self.git_range): 79 | return "Found no commits in '%s'" % self.git_range 80 | else: 81 | return "Invalid `type` value. Expected `by-date` or `by-range`." 82 | return None 83 | 84 | def get_results(self): 85 | if self.trace: 86 | traceback = api.parse_trace(self.trace) 87 | return api.lookup_stacktrace(traceback, self.git_range, fast=self.fast) 88 | else: 89 | return None 90 | 91 | 92 | class ResultsOutput(object): 93 | def __init__(self, args): 94 | self.cwd = os.getcwd() 95 | self.args = args 96 | try: 97 | self.messages = args.validate() 98 | self.results = args.get_results() 99 | except Exception as e: 100 | self.messages = str(e) 101 | self.results = None 102 | 103 | def results_as_json(self): 104 | if self.results is None: 105 | return json.dumps( 106 | { 107 | "errors": self.messages, 108 | "commits": [], 109 | } 110 | ).encode() 111 | elif len(self.results.results) == 0: 112 | return json.dumps( 113 | { 114 | "errors": "No matches found", 115 | "commits": [], 116 | } 117 | ).encode() 118 | else: 119 | return json.dumps( 120 | { 121 | "errors": None, 122 | "commits": self.results.get_sorted_results_by_dict(), 123 | }, 124 | default=json_serial, 125 | ).encode() 126 | 127 | def results_as_html(self): 128 | if self.results and self.results.results: 129 | sorted_results = self.results.get_sorted_results() 130 | return "\n
\n".join( 131 | ["
" + escape(str(result)) + "
" for result in sorted_results] 132 | ) 133 | else: 134 | return "\n
\n
No results found.
\n" 135 | 136 | def messages_as_html(self): 137 | if self.messages is None: 138 | return "" 139 | with open(os.path.join(dir_path, "templates", "messages.html")) as f: 140 | return Template(f.read()).substitute(messages=escape(self.messages)) 141 | 142 | def render_page(self): 143 | optionType = "by-date" if not self.args.type else self.args.type 144 | with open(os.path.join(dir_path, "templates", "page.html")) as f: 145 | return ( 146 | Template(f.read()) 147 | .substitute( 148 | pwd=escape(self.cwd), 149 | messages=self.messages_as_html(), 150 | range=escape(self.args.range), 151 | branch=escape(self.args.branch), 152 | since=escape(self.args.since), 153 | trace=escape(self.args.trace), 154 | fast="checked" if self.args.fast else "", 155 | optionType=escape(optionType), 156 | isByDate="true" if optionType == "by-date" else "false", 157 | isByRange="true" if optionType == "by-range" else "false", 158 | byDateClass="active" if optionType == "by-date" else "", 159 | byRangeClass="active" if optionType == "by-range" else "", 160 | results=self.results_as_html(), 161 | ) 162 | .encode("utf-8") 163 | ) 164 | 165 | 166 | class GitStacktraceApplication(object): 167 | def __init__(self, environ, start_response): 168 | self.environ = environ 169 | self.start_response = start_response 170 | self.path = environ["PATH_INFO"] 171 | 172 | def __iter__(self): 173 | method = self.environ["REQUEST_METHOD"] 174 | if method == "GET": 175 | yield self.do_GET() or b"" 176 | elif method == "POST": 177 | yield self.do_POST() or b"" 178 | elif method == "HEAD": 179 | self._set_headers() 180 | yield b"" 181 | else: 182 | self._set_headers(500) 183 | yield b"" 184 | 185 | def _set_headers(self, code=200, content_type="text/html"): 186 | codes = { 187 | 200: "200 OK", 188 | 404: "404 Not Found", 189 | } 190 | self.start_response(codes.get(code, "500 Internal Server Error"), [("Content-type", content_type)]) 191 | 192 | def _request_body(self): 193 | content_length = int(self.environ["CONTENT_LENGTH"], 0) 194 | return self.environ["wsgi.input"].read(content_length) 195 | 196 | def do_GET(self): 197 | if self.path == "/favicon.ico": 198 | self._set_headers() 199 | elif self.path == "/": 200 | try: 201 | args = Args.from_qs(self.environ["QUERY_STRING"]) 202 | out = ResultsOutput(args).render_page() 203 | self._set_headers() 204 | return out 205 | except Exception: 206 | log.exception("Unable to render trace page as html") 207 | self._set_headers(500) 208 | else: 209 | self._set_headers(404) 210 | 211 | def do_POST(self): 212 | if self.path == "/": 213 | try: 214 | args = Args.from_json_body(self._request_body()) 215 | out = ResultsOutput(args).results_as_json() 216 | self._set_headers(200, "application/json") 217 | return out 218 | except Exception as e: 219 | log.exception("Unable to load trace results as json") 220 | self._set_headers(500, "application/json") 221 | return json.dumps({"error": str(e)}).encode() 222 | else: 223 | self._set_headers(404, "application/json") 224 | 225 | 226 | application = GitStacktraceApplication 227 | -------------------------------------------------------------------------------- /git_stacktrace/templates/messages.html: -------------------------------------------------------------------------------- 1 | 9 | -------------------------------------------------------------------------------- /git_stacktrace/templates/page.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | Git Stacktrace 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 20 | 23 | ${messages} 24 | 25 |
26 |
27 |
28 |
29 |
30 | 67 |
68 |
69 |
74 | 75 |
76 | 84 |
85 | 91 |
92 | 93 | 101 |
102 | 108 |
109 |
110 |
111 |
116 |
117 | 125 | 126 |
127 | 133 |
134 |
135 |
136 |
137 |
138 |
139 | 140 |
141 | 142 |
143 | 144 |
145 | 146 |
147 |
148 | 149 |
150 |
151 | 152 | 160 |
161 | 162 |
163 | 164 |
165 |
166 | 167 |
168 |
169 |
Results
170 | ${results} 171 |
172 |
173 |
174 |
175 | 187 | 188 | 189 | -------------------------------------------------------------------------------- /git_stacktrace/tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pinterest/git-stacktrace/e662f103eb8c95b75def3dcbc47b0c883345a3b5/git_stacktrace/tests/__init__.py -------------------------------------------------------------------------------- /git_stacktrace/tests/base.py: -------------------------------------------------------------------------------- 1 | import fixtures 2 | import testtools 3 | 4 | 5 | class TestCase(testtools.TestCase): 6 | def setUp(self): 7 | super(TestCase, self).setUp() 8 | stdout = self.useFixture(fixtures.StringStream("stdout")).stream 9 | self.useFixture(fixtures.MonkeyPatch("sys.stdout", stdout)) 10 | stderr = self.useFixture(fixtures.StringStream("stderr")).stream 11 | self.useFixture(fixtures.MonkeyPatch("sys.stderr", stderr)) 12 | -------------------------------------------------------------------------------- /git_stacktrace/tests/examples/java1.trace: -------------------------------------------------------------------------------- 1 | java.io.FileNotFoundException: foo (The system cannot find the file specified) 2 | at java.io.FileInputStream.open(Native Method) 3 | at java.io.FileInputStream.(FileInputStream.java:106) 4 | at com.devdaily.tests.ExceptionTest.(ExceptionTest.java:15) 5 | at com.devdaily.tests.ExceptionTest.main(ExceptionTest.java:36) 6 | -------------------------------------------------------------------------------- /git_stacktrace/tests/examples/java2.trace: -------------------------------------------------------------------------------- 1 | com.google.common.base.VerifyException: start time: 1470440989775 is greater than end time: 0 2 | at com.google.common.base.Verify.verify(Verify.java:123) 3 | at com.pinterest.metatron.util.SearchTimer.addParallelDuration(SearchTimer.java:72) 4 | at com.pinterest.metatron.search.MetatronSearcher.getTopDocsParallel(MetatronSearcher.java:293) 5 | at com.pinterest.metatron.search.MetatronSearcher.search(MetatronSearcher.java:122) 6 | at com.pinterest.metatron.backend.SearchBackend.search(SearchBackend.java:103) 7 | at com.pinterest.metatron.server.MetatronServiceImpl$2.applyE(MetatronServiceImpl.java:93) 8 | at com.pinterest.metatron.server.MetatronServiceImpl$2.applyE(MetatronServiceImpl.java:90) 9 | at com.twitter.util.ExceptionalFunction0.apply(Function.scala:12) 10 | at com.twitter.util.Try$.apply(Try.scala:13) 11 | at com.twitter.util.ExecutorServiceFuturePool$$anon$2.run(FuturePool.scala:115) 12 | at java.util.concurrent.Executors$RunnableAdapter.call(Executors.java:511) 13 | at java.util.concurrent.FutureTask.run(FutureTask.java:266) 14 | at java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1142) 15 | at java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:617) 16 | at java.lang.Thread.run(Thread.java:745) 17 | 18 | 19 | -------------------------------------------------------------------------------- /git_stacktrace/tests/examples/java3.trace: -------------------------------------------------------------------------------- 1 | java.lang.RuntimeException: java.lang.NullPointerException 2 | at com.pinterest.metatron.jbender.MetatronRequestExecutor.execute(MetatronRequestExecutor.java:38) 3 | at com.pinterest.metatron.jbender.MetatronRequestExecutor.execute(MetatronRequestExecutor.java:16) 4 | at com.pinterest.jbender.JBender.executeRequest(JBender.java:387) 5 | at com.pinterest.jbender.JBender.lambda$loadTestThroughput$a515b4bc$1(JBender.java:298) 6 | at com.pinterest.jbender.JBender$$Lambda$5/1034627183.run(Unknown Source) 7 | at co.paralleluniverse.fibers.Fiber.run(Fiber.java:1019) 8 | at co.paralleluniverse.fibers.Fiber.run1(Fiber.java:1014) 9 | at co.paralleluniverse.fibers.Fiber.exec(Fiber.java:729) 10 | at co.paralleluniverse.fibers.FiberForkJoinScheduler$FiberForkJoinTask.exec1(FiberForkJoinScheduler.java:257) 11 | at co.paralleluniverse.concurrent.forkjoin.ParkableForkJoinTask.doExec(ParkableForkJoinTask.java:116) 12 | at co.paralleluniverse.concurrent.forkjoin.ParkableForkJoinTask.exec(ParkableForkJoinTask.java:73) 13 | at java.util.concurrent.ForkJoinTask.doExec(ForkJoinTask.java:289) 14 | at java.util.concurrent.ForkJoinPool$WorkQueue.pollAndExecAll(ForkJoinPool.java:892) 15 | at java.util.concurrent.ForkJoinPool$WorkQueue.runTask(ForkJoinPool.java:908) 16 | at java.util.concurrent.ForkJoinPool.scan(ForkJoinPool.java:1689) 17 | at java.util.concurrent.ForkJoinPool.runWorker(ForkJoinPool.java:1644) 18 | at java.util.concurrent.ForkJoinWorkerThread.run(ForkJoinWorkerThread.java:157) 19 | -------------------------------------------------------------------------------- /git_stacktrace/tests/examples/java4.trace: -------------------------------------------------------------------------------- 1 | java.lang.IllegalArgumentException: Comparison method violates its general contract! 2 | at java.util.TimSort.mergeLo(TimSort.java:777) 3 | at java.util.TimSort.mergeAt(TimSort.java:514) 4 | at java.util.TimSort.mergeCollapse(TimSort.java:441) 5 | at java.util.TimSort.sort(TimSort.java:245) 6 | at java.util.Arrays.sort(Arrays.java:1438) 7 | at com.pinterest.metatron.search.MetatronSearcher.fullScore(MetatronSearcher.java:470) 8 | at com.pinterest.metatron.backend.SearchBackend.search(SearchBackend.java:112) 9 | at com.pinterest.metatron.server.MetatronServiceImpl$2.applyE(MetatronServiceImpl.java:93) 10 | at com.pinterest.metatron.server.MetatronServiceImpl$2.applyE(MetatronServiceImpl.java:90) 11 | at com.twitter.util.ExceptionalFunction0.apply(Function.scala:12) 12 | at com.twitter.util.Try$.apply(Try.scala:13) 13 | at com.twitter.util.ExecutorServiceFuturePool$$anon$2.run(FuturePool.scala:115) 14 | at java.util.concurrent.Executors$RunnableAdapter.call(Executors.java:511) 15 | at java.util.concurrent.FutureTask.run(FutureTask.java:266) 16 | at java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1142) 17 | at java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:617) 18 | at java.lang.Thread.run(Thread.java:745) 19 | -------------------------------------------------------------------------------- /git_stacktrace/tests/examples/javascript1.trace: -------------------------------------------------------------------------------- 1 | TypeError: Cannot read property 'routes' of undefined 2 | at routes (webpack:///mnt/pinboard/webapp/app/www-unauth/Main.js:170:58) 3 | at callback (webpack:///mnt/pinboard/webapp/node_modules/react-router/es/match.js:51:0) 4 | at callback (webpack:///mnt/pinboard/webapp/node_modules/react-router/es/createTransitionManager.js:79:0) -------------------------------------------------------------------------------- /git_stacktrace/tests/examples/javascript2.trace: -------------------------------------------------------------------------------- 1 | at routes (webpack:///mnt/pinboard/webapp/app/www-unauth/Main.js:170:58) 2 | at callback (webpack:///mnt/pinboard/webapp/node_modules/react-router/es/match.js:51:0) 3 | at callback (webpack:///mnt/pinboard/webapp/node_modules/react-router/es/createTransitionManager.js:79:0) -------------------------------------------------------------------------------- /git_stacktrace/tests/examples/javascript3.trace: -------------------------------------------------------------------------------- 1 | TypeError: Cannot read property 'routes' of undefined 2 | at (webpack:///mnt/pinboard/webapp/app/www-unauth/Main.js:170:58) 3 | at (webpack:///mnt/pinboard/webapp/node_modules/react-router/es/match.js:51:0) 4 | at (webpack:///mnt/pinboard/webapp/node_modules/react-router/es/createTransitionManager.js:79:0) -------------------------------------------------------------------------------- /git_stacktrace/tests/examples/javascript4.trace: -------------------------------------------------------------------------------- 1 | TypeError: Cannot read property 'routes' of undefined 2 | at routes (https://s.pinimg.com/mobile/js/entryChunk-mobile-16e4a90610ff33cbf2f6.js:12:307245) 3 | at callback (webpack:///mnt/pinboard/webapp/node_modules/react-router/es/match.js:51:0) 4 | at callback (webpack:///mnt/pinboard/webapp/node_modules/react-router/es/createTransitionManager.js:79:0) -------------------------------------------------------------------------------- /git_stacktrace/tests/examples/javascript5.trace: -------------------------------------------------------------------------------- 1 | TypeError: Cannot read property 'routes' of undefined 2 | at ha\nUndefinedError: \'None\' has no attribute \'get\'\n 2 | 3 | 4 | -------------------------------------------------------------------------------- /git_stacktrace/tests/examples/python2.trace: -------------------------------------------------------------------------------- 1 | Traceback (most recent call last): 2 | File "../core/managers/user_manager.py", line 2672, in _get_pins_from_pintersection_helper 3 | user_id, imgsigs, rpc_timeout_ms=rpc_timeout_ms) 4 | File "/mnt/virtualenv/local/lib/python2.7/site-packages/thrift_utils/thrift_client_mixin.py", line 210, in wrap_with_pool 5 | return method(client, *args, **kwargs) 6 | File "/mnt/virtualenv/local/lib/python2.7/site-packages/thrift_utils/thrift_client_mixin.py", line 481, in method_wrapper 7 | self.connect(conn_timeout_ms, req_timeout_ms) 8 | File "/mnt/virtualenv/local/lib/python2.7/site-packages/thrift_utils/thrift_client_mixin.py", line 721, in connect 9 | self._transport.open() 10 | File "/mnt/virtualenv/local/lib/python2.7/site-packages/thrift/transport/TTransport.py", line 266, in open 11 | return self.__trans.open() 12 | File "/mnt/virtualenv/local/lib/python2.7/site-packages/thrift_utils/TNoDelaySocket.py", line 41, in open 13 | self.handle.connect(res[4]) 14 | File "/mnt/virtualenv/local/lib/python2.7/site-packages/gevent/socket.py", line 392, in connect 15 | wait_readwrite(sock.fileno(), timeout=timeleft, event=self._rw_event) 16 | File "/mnt/virtualenv/local/lib/python2.7/site-packages/gevent/socket.py", line 215, in wait_readwrite 17 | switch_result = get_hub().switch() 18 | File "../common/utils/patched_greenlet.py", line 187, in switch 19 | return gevent.hub.Hub.switch(self) 20 | File "/mnt/virtualenv/local/lib/python2.7/site-packages/gevent/hub.py", line 164, in switch 21 | return greenlet.switch(self) 22 | Timeout 23 | -------------------------------------------------------------------------------- /git_stacktrace/tests/examples/python3.trace: -------------------------------------------------------------------------------- 1 | Traceback (most recent call last): 2 | File "../common/utils/geo_utils.py", line 68, in get_ip_geo 3 | return get_geo_db().record_by_addr(ip_address) 4 | File "/mnt/virtualenv_A/local/lib/python2.7/site-packages/pygeoip/__init__.py", line 563, in record_by_addr 5 | ipnum = util.ip2long(addr) 6 | File "/mnt/virtualenv_A/local/lib/python2.7/site-packages/pygeoip/util.py", line 36, in ip2long 7 | return int(binascii.hexlify(socket.inet_pton(socket.AF_INET6, ip)), 16) 8 | error 9 | -------------------------------------------------------------------------------- /git_stacktrace/tests/examples/python4.trace: -------------------------------------------------------------------------------- 1 | Traceback (most recent call last): 2 | File "../core/managers/user_manager.py", line 2835, in add_typed_search_query 3 | query) 4 | File "/mnt/virtualenv_A/local/lib/python2.7/site-packages/thrift_utils/thrift_client_mixin.py", line 210, in wrap_with_pool 5 | return method(client, *args, **kwargs) 6 | File "/mnt/virtualenv_A/local/lib/python2.7/site-packages/thrift_utils/thrift_client_mixin.py", line 484, in method_wrapper 7 | result = method(self._client, *args, **kwargs) 8 | File "../services/dataservices/thrift_libs/DataServices.py", line 1772, in createOrUpdateTypedSearchQuery 9 | self.send_createOrUpdateTypedSearchQuery(context, userId, queryType, query) 10 | File "../services/dataservices/thrift_libs/DataServices.py", line 1782, in send_createOrUpdateTypedSearchQuery 11 | args.write(self._oprot) 12 | File "../services/dataservices/thrift_libs/DataServices.py", line 8975, in write 13 | oprot.writeString(self.query.encode('utf-8')) 14 | UnicodeDecodeError 15 | -------------------------------------------------------------------------------- /git_stacktrace/tests/examples/python5.trace: -------------------------------------------------------------------------------- 1 | Traceback (most recent call last): 2 | File "../api/extensions/pin_extensions.py", line 260, in _map_usm_upsell_info 3 | upsell_info_list) 4 | File "/mnt/virtualenv_A/local/lib/python2.7/site-packages/pinstatsd/statsd.py", line 98, in decorated_function 5 | return_value = func(*args, **kwargs) 6 | File "../core/utils/usm_utils.py", line 568, in get_usm_upsell 7 | gk) 8 | File "../core/utils/usm_utils.py", line 462, in _get_upsell_dict 9 | experiment) 10 | UnboundLocalError 11 | -------------------------------------------------------------------------------- /git_stacktrace/tests/examples/python6.trace: -------------------------------------------------------------------------------- 1 | Traceback (most recent call last): 2 | File "/mnt/builds/VXmUJO4bRX26CecfwlI_hw_9f779bf/webapp/framework/resource.py", line 72, in _call 3 | result = getattr(self, method_name)() 4 | File "/mnt/builds/VXmUJO4bRX26CecfwlI_hw_9f779bf/webapp/resources/interests_resource.py", line 232, in get 5 | if self.options['from_navigate'] == "true": 6 | KeyError 7 | -------------------------------------------------------------------------------- /git_stacktrace/tests/examples/python7.trace: -------------------------------------------------------------------------------- 1 | ValueError: invalid literal for int() with base 10: 'None' 2 | File "flask/app.py", line 1982, in wsgi_app 3 | response = self.full_dispatch_request() 4 | File "flask/app.py", line 1614, in full_dispatch_request 5 | rv = self.handle_user_exception(e) 6 | File "flask_cors/extension.py", line 161, in wrapped_function 7 | return cors_after_request(app.make_response(f(*args, **kwargs))) 8 | File "flask/app.py", line 1517, in handle_user_exception 9 | reraise(exc_type, exc_value, tb) 10 | File "flask/_compat.py", line 33, in reraise 11 | raise value 12 | File "flask/app.py", line 1612, in full_dispatch_request 13 | rv = self.dispatch_request() 14 | File "flask/app.py", line 1598, in dispatch_request 15 | return self.view_functions[rule.endpoint](**req.view_args) 16 | File "", line 2, in ci 17 | File "geetest_client.py", line 209, in captcha_counter_decorator 18 | return func(*args, **kwargs) 19 | File "company.py", line 305, in ci 20 | company = get_company_info(company_id) 21 | File "company.py", line 129, in get_company_info 22 | return get_company_info_from_es(company_id) or get_company_info_from_element(company_id) 23 | File "company.py", line 144, in get_company_info_from_es 24 | annuals = choose_and_prettify_annual(company_id, result.get('extra', {}).get('类型', '')) 25 | File "company.py", line 174, in choose_and_prettify_annual 26 | result = prettify(annuals, is_llc=is_llc, show_all=show_all) 27 | File "prettify/annual.py", line 45, in prettify 28 | year = int(annual.get('year') or 0) 29 | -------------------------------------------------------------------------------- /git_stacktrace/tests/examples/python8.trace: -------------------------------------------------------------------------------- 1 | Traceback (most recent call last): 2 | File "foo.py", line 2, in 3 | x() 4 | File "", line 1, in 5 | TypeError: int() argument must be a string or a number, not 'NoneType' 6 | -------------------------------------------------------------------------------- /git_stacktrace/tests/test_api.py: -------------------------------------------------------------------------------- 1 | import mock 2 | 3 | from git_stacktrace.tests import base 4 | from git_stacktrace import api 5 | from git_stacktrace import git 6 | 7 | 8 | class TestApi(base.TestCase): 9 | @mock.patch("git_stacktrace.git.convert_since") 10 | def test_convert_since(self, mocked_command): 11 | expected = "HASH1..HASH2" 12 | mocked_command.return_value = expected 13 | self.assertEqual(expected, api.convert_since("1.day")) 14 | 15 | @mock.patch("git_stacktrace.git.valid_range") 16 | def test_valid_range(self, mocked_command): 17 | expected = True 18 | mocked_command.return_value = expected 19 | self.assertEqual(expected, api.valid_range("hash1..hash2")) 20 | 21 | expected = False 22 | mocked_command.return_value = expected 23 | self.assertEqual(expected, api.valid_range("hash1..hash2")) 24 | 25 | def get_traceback(self, java=False): 26 | if java: 27 | with open("git_stacktrace/tests/examples/java1.trace") as f: 28 | traceback = api.parse_trace(f.readlines()) 29 | else: 30 | with open("git_stacktrace/tests/examples/python3.trace") as f: 31 | traceback = api.parse_trace(f.readlines()) 32 | return traceback 33 | 34 | def setup_mocks(self, mock_files, mock_files_touched): 35 | mock_files_touched.return_value = {"hash2": [git.GitFile("common/utils/geo_utils.py", "M")]} 36 | mock_files.return_value = ["common/utils/geo_utils.py"] 37 | 38 | @mock.patch("git_stacktrace.git.pickaxe") 39 | @mock.patch("git_stacktrace.git.files_touched") 40 | @mock.patch("git_stacktrace.git.files") 41 | @mock.patch("git_stacktrace.git.line_match") 42 | def test_lookup_stacktrace_python(self, mock_line_match, mock_files, mock_files_touched, mock_pickaxe): 43 | mock_files_touched.return_value = True 44 | mock_line_match.return_value = False 45 | traceback = self.get_traceback() 46 | self.setup_mocks(mock_files, mock_files_touched) 47 | self.assertEqual( 48 | 0, 49 | api.lookup_stacktrace(traceback, "hash1..hash3", fast=False).get_sorted_results()[0]._line_numbers_matched, 50 | ) 51 | self.assertEqual(3, mock_pickaxe.call_count) 52 | 53 | @mock.patch("git_stacktrace.git.pickaxe") 54 | @mock.patch("git_stacktrace.git.files_touched") 55 | @mock.patch("git_stacktrace.git.files") 56 | @mock.patch("git_stacktrace.git.line_match") 57 | def test_lookup_stacktrace_java(self, mock_line_match, mock_files, mock_files_touched, mock_pickaxe): 58 | mock_files_touched.return_value = True 59 | mock_line_match.return_value = True 60 | traceback = self.get_traceback(java=True) 61 | mock_files.return_value = ["devdaily/src/main/java/com/devdaily/tests/ExceptionTest.java"] 62 | mock_files_touched.return_value = { 63 | "hash2": [git.GitFile("devdaily/src/main/java/com/devdaily/tests/ExceptionTest.java", "M")] 64 | } 65 | self.assertEqual( 66 | 2, 67 | api.lookup_stacktrace(traceback, "hash1..hash3", fast=False).get_sorted_results()[0]._line_numbers_matched, 68 | ) 69 | self.assertEqual(0, mock_pickaxe.call_count) 70 | 71 | @mock.patch("git_stacktrace.git.pickaxe") 72 | @mock.patch("git_stacktrace.git.files_touched") 73 | @mock.patch("git_stacktrace.git.files") 74 | @mock.patch("git_stacktrace.git.line_match") 75 | def test_lookup_stacktrace_fast(self, mock_line_match, mock_files, mock_files_touched, mock_pickaxe): 76 | mock_files_touched.return_value = True 77 | traceback = self.get_traceback() 78 | self.setup_mocks(mock_files, mock_files_touched) 79 | api.lookup_stacktrace(traceback, "hash1..hash3", fast=True) 80 | self.assertEqual(1, mock_pickaxe.call_count) 81 | 82 | @mock.patch("git_stacktrace.git.pickaxe") 83 | @mock.patch("git_stacktrace.git.files_touched") 84 | @mock.patch("git_stacktrace.git.files") 85 | @mock.patch("git_stacktrace.git.line_match") 86 | def test_lookup_stacktrace_line_match(self, mock_line_match, mock_files, mock_files_touched, mock_pickaxe): 87 | mock_files_touched.return_value = True 88 | mock_line_match.return_value = True 89 | traceback = self.get_traceback() 90 | self.setup_mocks(mock_files, mock_files_touched) 91 | self.assertEqual( 92 | 1, 93 | api.lookup_stacktrace(traceback, "hash1..hash3", fast=False).get_sorted_results()[0]._line_numbers_matched, 94 | ) 95 | self.assertEqual(3, mock_pickaxe.call_count) 96 | -------------------------------------------------------------------------------- /git_stacktrace/tests/test_git.py: -------------------------------------------------------------------------------- 1 | import mock 2 | 3 | from git_stacktrace.tests import base 4 | from git_stacktrace import git 5 | from git_stacktrace import parse_trace 6 | 7 | 8 | class TestGitFile(base.TestCase): 9 | def test_git_file(self): 10 | git_file = git.GitFile("file1", "M") 11 | self.assertEqual(git_file.filename, "file1") 12 | self.assertEqual(git_file.state, git.GitFile.MODIFIED) 13 | git_file = git.GitFile("file2", "A") 14 | self.assertEqual(git_file.filename, "file2") 15 | self.assertEqual(git_file.state, git.GitFile.ADDED) 16 | 17 | def test_git_file_invalid(self): 18 | self.assertRaises(Exception, git.GitFile, "file1", "x") 19 | 20 | def test_git_file_cmp(self): 21 | git_file1 = git.GitFile("file1", "M") 22 | git_file = git.GitFile("file1", "A") 23 | self.assertEqual(git_file, git_file1) 24 | self.assertEqual(git_file, "file1") 25 | self.assertEqual(git_file, "file1") 26 | 27 | 28 | class TestGit(base.TestCase): 29 | @mock.patch("git_stacktrace.git.run_command") 30 | def test_convert_since_fail(self, mocked_command): 31 | mocked_command.return_value = "" 32 | self.assertRaises(Exception, git.convert_since, "1.day") 33 | 34 | @mock.patch("git_stacktrace.git.run_command") 35 | def test_convert_since(self, mocked_command): 36 | mocked_command.return_value = "\n".join( 37 | [ 38 | "de75c8dd27af30daef012a9902af4c39c4728710", 39 | "04a13ace3a3e490a5e1a74aae740f45fee6562c3", 40 | "c0497475799306eebcfd014657150daa9af9c488", 41 | "f301b355050f64ebc2e83ebffc583713113aee9b", 42 | "32eba9e2c389c427c5b7b2288353eaf0903d52c0", 43 | ] 44 | ) 45 | expected = "32eba9e2c389c427c5b7b2288353eaf0903d52c0..de75c8dd27af30daef012a9902af4c39c4728710" 46 | self.assertEqual(expected, git.convert_since("1.day")) 47 | 48 | @mock.patch("git_stacktrace.git.run_command") 49 | def test_files_touched(self, mocked_command): 50 | mocked_command.return_value = "\n".join( 51 | [ 52 | "1ca8dd2b178ef8f308849bac2b0eaecaf91abc70", 53 | "", 54 | ":100644 100644 bcd1234... 0123456... M file0", 55 | ":100644 100644 abcd123... 1234567... C68 file1 file2", 56 | ":100644 100644 abcd123... 1234567... R86 file1 file3", 57 | ":000000 100644 0000000... 1234567... A file4 space/log", 58 | ":100644 100644 f9731ae1d4... 6dc2860... M test/file" 59 | ":100644 000000 1234567... 0000000... D file5", 60 | ] 61 | ) 62 | expected = {"1ca8dd2b178ef8f308849bac2b0eaecaf91abc70": ["file0", "file2", "file3", "file4 space/log", "file5"]} 63 | self.assertEqual(expected, git.files_touched("A..B")) 64 | 65 | @mock.patch("git_stacktrace.git.run_command") 66 | def test_line_match(self, mocked_command): 67 | mocked_command.return_value = "\n".join( 68 | [ 69 | "diff --git a/test_api.py b/test_api.py", 70 | "index 73e79d1..884b953 100644", 71 | "--- a/test_api.py", 72 | "+++ b/test_api.py", 73 | "@@ -35,7 +35,9 @@ class TestApi(base.TestCase):", 74 | " @mock.patch('git_stacktrace.git.pickaxe')", 75 | " @mock.patch('git_stacktrace.git.files_touched')", 76 | " @mock.patch('git_stacktrace.git.files')", 77 | "- def test_lookup_stacktrace(self, mock_files, mock_files_touched, mock_pickaxe):", 78 | "+ @mock.patch('git_stacktrace.git.line_match')", 79 | "+ def test_lookup_stacktrace(self, mock_line_match, mock_files, mock_files_touched, mock_pickaxe):", 80 | "+ mock_files_touched.return_value = True", 81 | " traceback = self.get_traceback()", 82 | " self.setup_mocks(mock_files, mock_files_touched)", 83 | ] 84 | ) 85 | filename = "test_api.py" 86 | line = parse_trace.Line(filename, 38, None, None) 87 | line.git_filename = filename 88 | self.assertTrue(git.line_match("hash1", line)) 89 | line.line_number = 5 90 | self.assertFalse(git.line_match("hash1", line)) 91 | 92 | @mock.patch("git_stacktrace.git.run_command") 93 | def test_pickaxe(self, mocked_command): 94 | mocked_command.return_value = "" 95 | 96 | git.pickaxe("for f in sorted(self.files_added):", "hash1..hash2") 97 | expected = ("git", "log", "-b", "--pretty=%H", "-S", "for f in sorted(self.files_added):", "hash1..hash2") 98 | mocked_command.assert_called_with(*expected) 99 | 100 | git.pickaxe("if 'a/b' in z", "hash1..hash2") 101 | expected = ("git", "log", "-b", "--pretty=%H", "-S", "if 'a/b' in z", "hash1..hash2") 102 | mocked_command.assert_called_with(*expected) 103 | 104 | git.pickaxe("for f in sorted(self.files_added):", "hash1..hash2", "filename") 105 | expected = ( 106 | "git", 107 | "log", 108 | "-b", 109 | "--pretty=%H", 110 | "-S", 111 | "for f in sorted(self.files_added):", 112 | "hash1..hash2", 113 | "--", 114 | "filename", 115 | ) 116 | mocked_command.assert_called_with(*expected) 117 | -------------------------------------------------------------------------------- /git_stacktrace/tests/test_parse_trace.py: -------------------------------------------------------------------------------- 1 | import glob 2 | 3 | from git_stacktrace.tests import base 4 | from git_stacktrace import parse_trace 5 | 6 | 7 | class TestParsePythonStacktrace(base.TestCase): 8 | trace3_expected = [ 9 | ("../common/utils/geo_utils.py", 68, "get_ip_geo", "return get_geo_db().record_by_addr(ip_address)"), 10 | ( 11 | "/mnt/virtualenv_A/local/lib/python2.7/site-packages/pygeoip/__init__.py", 12 | 563, 13 | "record_by_addr", 14 | "ipnum = util.ip2long(addr)", 15 | ), 16 | ( 17 | "/mnt/virtualenv_A/local/lib/python2.7/site-packages/pygeoip/util.py", 18 | 36, 19 | "ip2long", 20 | "return int(binascii.hexlify(socket.inet_pton(socket.AF_INET6, ip)), 16)", 21 | ), 22 | ] 23 | 24 | def get_trace(self, number=3): 25 | with open("git_stacktrace/tests/examples/python%d.trace" % number) as f: 26 | trace = parse_trace.PythonTraceback(f.readlines()) 27 | return trace 28 | 29 | def test_extract_traceback_from_file(self): 30 | # extract_python_traceback_from_file will raise an exception if it incorrectly parses a file 31 | for filename in glob.glob("git_stacktrace/tests/examples/python*.trace"): 32 | with open(filename) as f: 33 | traceback = parse_trace.PythonTraceback(f.readlines()) 34 | if filename == "git_stacktrace/tests/examples/python3.trace": 35 | self.assertEqual(self.trace3_expected, traceback.traceback_format()) 36 | 37 | def test_str(self): 38 | with open("git_stacktrace/tests/examples/python3.trace") as f: 39 | expected = f.read() 40 | trace = self.get_trace() 41 | self.assertEqual(expected, str(trace)) 42 | with open("git_stacktrace/tests/examples/python5.trace") as f: 43 | expected = f.read() 44 | trace = self.get_trace(number=5) 45 | self.assertEqual(expected, str(trace)) 46 | with open("git_stacktrace/tests/examples/python6.trace") as f: 47 | expected = f.read() 48 | trace = self.get_trace(number=6) 49 | self.assertEqual(expected, str(trace)) 50 | 51 | def test_exception(self): 52 | self.assertRaises(parse_trace.ParseException, parse_trace.PythonTraceback, "NOT A TRACEBACK") 53 | with open("git_stacktrace/tests/examples/java1.trace") as f: 54 | self.assertRaises(parse_trace.ParseException, parse_trace.PythonTraceback, f.readlines()) 55 | 56 | def test_file_match(self): 57 | trace = self.get_trace() 58 | self.assertTrue(trace.file_match(trace.lines[0].trace_filename, ["common/utils/geo_utils.py"])) 59 | self.assertFalse(trace.file_match(trace.lines[0].trace_filename, ["common/utils/fake.py"])) 60 | 61 | 62 | class TestParseJavaStacktrace(base.TestCase): 63 | def get_trace(self): 64 | with open("git_stacktrace/tests/examples/java1.trace") as f: 65 | trace = parse_trace.JavaTraceback(f.readlines()) 66 | return trace 67 | 68 | def test_str(self): 69 | with open("git_stacktrace/tests/examples/java1.trace") as f: 70 | expected = f.read() 71 | trace = self.get_trace() 72 | self.assertEqual(expected, str(trace)) 73 | 74 | def test_extract_traceback(self): 75 | trace = self.get_trace() 76 | line = trace.lines[1] 77 | self.assertEqual(106, line.line_number) 78 | self.assertEqual("java/io/FileInputStream.java", line.trace_filename) 79 | self.assertEqual("FileInputStream", line.class_name) 80 | line = trace.lines[2] 81 | self.assertEqual(15, line.line_number) 82 | self.assertEqual("com/devdaily/tests/ExceptionTest.java", line.trace_filename) 83 | self.assertEqual("ExceptionTest", line.class_name) 84 | 85 | def test_exception(self): 86 | self.assertRaises(parse_trace.ParseException, parse_trace.JavaTraceback, "NOT A TRACEBACK") 87 | for filename in glob.glob("git_stacktrace/tests/examples/python*.trace"): 88 | with open(filename) as f: 89 | self.assertRaises(parse_trace.ParseException, parse_trace.JavaTraceback, f.readlines()) 90 | 91 | def test_file_match(self): 92 | trace = self.get_trace() 93 | self.assertTrue( 94 | trace.file_match(trace.lines[2].trace_filename, ["src/main/java/com/devdaily/tests/ExceptionTest.java"]) 95 | ) 96 | self.assertFalse( 97 | trace.file_match(trace.lines[2].trace_filename, ["src/main/java/com/devdaily/tests/Fake.java"]) 98 | ) 99 | 100 | 101 | class TestLine(base.TestCase): 102 | def test_line(self): 103 | line_data = ("./file", 1, "foo", "pass") 104 | line = parse_trace.Line(*line_data) 105 | line.git_filename = "file" 106 | self.assertEqual(line_data, line.traceback_format()) 107 | 108 | 109 | class TestParseTrace(base.TestCase): 110 | def test_parse_trace(self): 111 | for filename in glob.glob("git_stacktrace/tests/examples/*.trace"): 112 | with open(filename) as f: 113 | try: 114 | parse_trace.parse_trace(f.readlines()) 115 | except Exception: 116 | self.fail("Failed to parse '%s'" % filename) 117 | -------------------------------------------------------------------------------- /git_stacktrace/tests/test_result.py: -------------------------------------------------------------------------------- 1 | import mock 2 | import testtools 3 | 4 | from git_stacktrace.tests import base 5 | from git_stacktrace import git 6 | from git_stacktrace import result 7 | 8 | fake_commit_info = git.CommitInfo( 9 | summary="summary", subject="subject", body="body", url="url", author="author", date=None 10 | ) 11 | 12 | 13 | class TestResult(base.TestCase): 14 | commit_hash = "hash1" 15 | 16 | def test_init_result(self): 17 | commit1 = result.Result(self.commit_hash) 18 | self.assertEqual(self.commit_hash, commit1.commit) 19 | with testtools.ExpectedException(AttributeError): 20 | commit1.commit = "test" 21 | 22 | def test_files(self): 23 | commit1 = result.Result(self.commit_hash) 24 | expected_files_modified = set(["file1:10", "file1"]) 25 | expected_files_added = set(["file2", "file3"]) 26 | expected_files_deleted = set(["file4"]) 27 | commit1.add_file(git.GitFile("file1", "M"), line_number=10) 28 | commit1.add_file(git.GitFile("file1", "M")) 29 | commit1.add_file(git.GitFile("file2", "A")) 30 | commit1.add_file(git.GitFile("file3", "C")) 31 | commit1.add_file(git.GitFile("file4", "D")) 32 | self.assertEqual(expected_files_modified, commit1.files_modified) 33 | self.assertEqual(expected_files_added, commit1.files_added) 34 | self.assertEqual(expected_files_deleted, commit1.files_deleted) 35 | 36 | def test_lines(self): 37 | commit1 = result.Result(self.commit_hash) 38 | expected_lines_added = set(["pass", "1+1"]) 39 | expected_lines_removed = set(["1+2"]) 40 | commit1.lines_added.add("pass") 41 | commit1.lines_added.add("pass") 42 | commit1.lines_added.add("1+1") 43 | self.assertEqual(expected_lines_added, commit1.lines_added) 44 | commit1.lines_removed.add("1+2") 45 | commit1.lines_removed.add("1+2") 46 | self.assertEqual(expected_lines_removed, commit1.lines_removed) 47 | 48 | @mock.patch("git_stacktrace.git.get_commit_info") 49 | def test_str(self, mocked_git_info): 50 | mocked_git_info.return_value = fake_commit_info 51 | commit1 = result.Result(self.commit_hash) 52 | commit1.add_file(git.GitFile("file1", "M")) 53 | expected = "summary\nLink: url\nFiles Modified:\n - file1\n" 54 | commit1.add_file(git.GitFile("file2", "A")) 55 | expected = "summary\nLink: url\nFiles Added:\n - file2\nFiles Modified:\n - file1\n" 56 | self.assertEqual(expected, str(commit1)) 57 | commit1.lines_added.add("pass") 58 | expected = ( 59 | "summary\nLink: url\nFiles Added:\n - file2\nFiles Modified:\n - " 60 | 'file1\nLines Added:\n - "pass"\n' 61 | ) 62 | commit1.lines_removed.add("True") 63 | expected = ( 64 | "summary\nLink: url\nFiles Added:\n - file2\nFiles Modified:\n - " 65 | 'file1\nLines Added:\n - "pass"\nLines Removed:\n - "True"\n' 66 | ) 67 | commit1.add_file(git.GitFile("file3", "D"), line_number=11) 68 | expected = ( 69 | "summary\nLink: url\nFiles Added:\n - file2\nFiles Modified:\n - " 70 | "file1\nFiles Deleted:\n - file3:11\nLines Added:\n - " 71 | '"pass"\nLines Removed:\n - "True"\n' 72 | ) 73 | self.assertEqual(expected, str(commit1)) 74 | 75 | @mock.patch("git_stacktrace.git.get_commit_info") 76 | def test_str_no_url(self, mocked_git_info): 77 | mocked_git_info.return_value = git.CommitInfo("summary", "subject", "body", None, "author", None) 78 | commit1 = result.Result(self.commit_hash) 79 | commit1.add_file(git.GitFile("file1", "M")) 80 | expected = "summary\nFiles Modified:\n - file1\n" 81 | self.assertEqual(expected, str(commit1)) 82 | commit1.lines_added.add("pass") 83 | expected = 'summary\nFiles Modified:\n - file1\nLines Added:\n - "pass"\n' 84 | commit1.lines_removed.add("True") 85 | expected = ( 86 | 'summary\nFiles Modified:\n - file1\nLines Added:\n - "pass"\nLines ' 'Removed:\n - "True"\n' 87 | ) 88 | self.assertEqual(expected, str(commit1)) 89 | 90 | def test_rank(self): 91 | commit1 = result.Result(self.commit_hash) 92 | self.assertEqual(0, commit1.rank()) 93 | commit1.add_file(git.GitFile("file1", "M")) 94 | self.assertEqual(1, commit1.rank()) 95 | commit1.add_file(git.GitFile("file2", "A")) 96 | self.assertEqual(4, commit1.rank()) 97 | commit1.lines_added.add("pass") 98 | self.assertEqual(7, commit1.rank()) 99 | commit1.add_file(git.GitFile("file3", "M"), line_number=12) 100 | self.assertEqual(12, commit1.rank()) 101 | 102 | @mock.patch("git_stacktrace.git.get_commit_info") 103 | def test_dict(self, mocked_git_info): 104 | mocked_git_info.return_value = fake_commit_info 105 | commit1 = result.Result(self.commit_hash) 106 | commit1.add_file(git.GitFile("file1", "M")) 107 | commit1.add_file(git.GitFile("file2", "A"), line_number=12) 108 | commit1.add_file(git.GitFile("file3", "D")) 109 | commit1.lines_removed.add("True") 110 | commit1.lines_added.add("pass") 111 | expected = { 112 | "commit": "hash1", 113 | "files_added": ["file2:12"], 114 | "files_modified": ["file1"], 115 | "files_deleted": ["file3"], 116 | "body": "body", 117 | "date": None, 118 | "author": "author", 119 | "subject": "subject", 120 | "lines_added": ["pass"], 121 | "lines_removed": ["True"], 122 | "summary": "summary", 123 | "url": "url", 124 | } 125 | self.assertEqual(expected, dict(commit1)) 126 | 127 | @mock.patch("git_stacktrace.git.get_commit_info") 128 | def test_git_info(self, mocked_git_info): 129 | mocked_git_info.return_value = fake_commit_info 130 | commit1 = result.Result(self.commit_hash) 131 | self.assertEqual(commit1.summary, "summary") 132 | self.assertEqual(commit1.subject, "subject") 133 | self.assertEqual(commit1.body, "body") 134 | self.assertEqual(commit1.url, "url") 135 | self.assertEqual(commit1.author, "author") 136 | self.assertIsNone(commit1.date) 137 | self.assertEqual(mocked_git_info.call_count, 1) 138 | 139 | 140 | class TestResults(base.TestCase): 141 | def test_results(self): 142 | results = result.Results() 143 | commit1 = results.get_result("hash1") 144 | commit1.add_file(git.GitFile("file1", "A")) 145 | commit2 = results.get_result("hash1") 146 | self.assertEqual(commit1, commit2) 147 | 148 | def test_sorted_results(self): 149 | results = result.Results() 150 | commit2 = results.get_result("hash2") 151 | commit1 = results.get_result("hash1") 152 | commit1.add_file(git.GitFile("file1", "M")) 153 | expected = [commit1, commit2] 154 | self.assertEqual(expected, results.get_sorted_results()) 155 | 156 | def test_sorted_results_inverse(self): 157 | results = result.Results() 158 | commit1 = results.get_result("hash1") 159 | commit1.add_file(git.GitFile("file1", "M")) 160 | commit2 = results.get_result("hash2") 161 | expected = [commit1, commit2] 162 | self.assertEqual(expected, results.get_sorted_results()) 163 | 164 | @mock.patch("git_stacktrace.git.get_commit_info") 165 | def test_get_sorted_results_by_dict(self, mocked_git_info): 166 | mocked_git_info.return_value = fake_commit_info 167 | results = result.Results() 168 | commit2 = results.get_result("hash2") 169 | commit1 = results.get_result("hash1") 170 | commit1.add_file(git.GitFile("file1", "M")) 171 | expected = [dict(commit1), dict(commit2)] 172 | self.assertEqual(expected, results.get_sorted_results_by_dict()) 173 | -------------------------------------------------------------------------------- /git_stacktrace/tests/test_server.py: -------------------------------------------------------------------------------- 1 | import mock 2 | 3 | from git_stacktrace.tests import base 4 | from git_stacktrace.server import Args 5 | 6 | 7 | class TestApi(base.TestCase): 8 | def test_default_args(self): 9 | args = Args({}) 10 | self.assertEqual("", args.trace) 11 | self.assertEqual("", args.branch) 12 | self.assertFalse(args.fast) 13 | 14 | def test_args_from_json(self): 15 | json = '{"branch": "master", "fast": "on"}' 16 | args = Args.from_json_body(json) 17 | self.assertEqual("", args.trace) 18 | self.assertEqual("master", args.branch) 19 | self.assertTrue(args.fast) 20 | 21 | def test_args_from_json_as_array_vals(self): 22 | json = '{"branch": ["master"], "fast": ["on"]}' 23 | args = Args.from_json_body(json) 24 | self.assertEqual("master", args.branch) 25 | self.assertTrue(args.fast) 26 | 27 | def test_args_from_querystring(self): 28 | qs = "?branch=master&fast=on" 29 | args = Args.from_qs(qs) 30 | self.assertEqual("", args.trace) 31 | self.assertEqual("master", args.branch) 32 | self.assertTrue(args.fast) 33 | 34 | def test_args_from_querystring_without_qmark(self): 35 | qs = "branch=master&fast=on" 36 | args = Args.from_qs(qs) 37 | self.assertEqual("master", args.branch) 38 | 39 | def test_args_from_querystring_multiple_vals(self): 40 | qs = "?branch=master&branch=feature" 41 | args = Args.from_qs(qs) 42 | self.assertEqual("master", args.branch) 43 | 44 | def test_unescape_args(self): 45 | qs = "?branch=origin%2Fmaster&fast=on" 46 | args = Args.from_qs(qs) 47 | self.assertEqual("origin/master", args.branch) 48 | 49 | def test_args_ok_when_empty(self): 50 | args = Args({}) 51 | self.assertIsNone(args.validate()) 52 | 53 | def test_args_message_for_invalid_optionType(self): 54 | args = Args({"option-type": "foo"}) 55 | self.assertIn("Invalid `type` value", args.validate()) 56 | 57 | def test_args_byDate_requires_since(self): 58 | args = Args({"option-type": "by-date"}) 59 | self.assertIn("Missing `since` value", args.validate()) 60 | 61 | @mock.patch("git_stacktrace.server.api.convert_since") 62 | @mock.patch("git_stacktrace.server.api.valid_range") 63 | def test_args_byDate_errors_on_invalid_range(self, mock_valid_range, mock_convert_since): 64 | mock_valid_range.return_value = False 65 | args = Args({"option-type": "by-date", "since": "1.day"}) 66 | self.assertIn("Found no commits in", args.validate()) 67 | 68 | @mock.patch("git_stacktrace.server.api.convert_since") 69 | @mock.patch("git_stacktrace.server.api.valid_range") 70 | def test_args_byDate_returns_none_for_good_range(self, mock_valid_range, mock_convert_since): 71 | mock_valid_range.return_value = True 72 | args = Args({"option-type": "by-date", "since": "1.day"}) 73 | self.assertIsNone(args.validate()) 74 | 75 | @mock.patch("git_stacktrace.server.api.valid_range") 76 | def test_args_byRange_errors_on_invalid_range(self, mock_valid_range): 77 | mock_valid_range.return_value = False 78 | args = Args({"option-type": "by-range"}) 79 | self.assertIn("Found no commits in", args.validate()) 80 | 81 | @mock.patch("git_stacktrace.server.api.valid_range") 82 | def test_args_byRange_returns_none_for_good_range(self, mock_valid_range): 83 | mock_valid_range.return_value = True 84 | args = Args({"option-type": "by-range"}) 85 | self.assertIsNone(args.validate()) 86 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.black] 2 | line-length = 120 3 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | whatthepatch 2 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [metadata] 2 | name = git-stacktrace 3 | author = Joe Gordon 4 | license = Apache License (2.0) 5 | classifier = 6 | Programming Language :: Python :: 3 7 | Programming Language :: Python :: 3.9 8 | Programming Language :: Python :: 3.10 9 | Programming Language :: Python :: 3.11 10 | Programming Language :: Python :: 3.12 11 | Intended Audience :: Developers 12 | Operating System :: OS Independent 13 | License :: OSI Approved :: Apache Software License 14 | summary = git-blame for stacktraces 15 | home-page = https://github.com/pinterest/git-stacktrace 16 | 17 | description-file = 18 | README.rst 19 | CHANGES.rst 20 | 21 | [files] 22 | packages = 23 | git_stacktrace 24 | 25 | [pbr] 26 | skip_changelog = True 27 | 28 | [entry_points] 29 | console_scripts = 30 | git-stacktrace = git_stacktrace.cmd:main 31 | 32 | [build_sphinx] 33 | source-dir = doc/source 34 | build-dir = doc/build 35 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import setuptools 2 | 3 | # In python < 2.7.4, a lazy loading of package `pbr` will break 4 | # setuptools if some other modules registered functions in `atexit`. 5 | # solution from: http://bugs.python.org/issue15881#msg170215 6 | try: 7 | import multiprocessing # noqa 8 | except ImportError: 9 | pass 10 | 11 | setuptools.setup(setup_requires=["pbr"], pbr=True) 12 | -------------------------------------------------------------------------------- /test-requirements.txt: -------------------------------------------------------------------------------- 1 | black 2 | testtools 3 | flake8 4 | pytest 5 | python-subunit 6 | fixtures 7 | mock 8 | sphinx 9 | sphinx_rtd_theme 10 | setuptools>=78.1.1 # not directly required, pinned by Snyk to avoid a vulnerability 11 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | minversion = 2.0 3 | envlist=py39,py310,py311,py312,flake8,black,docs 4 | skipsdist=False 5 | 6 | [testenv] 7 | usedevelop = True 8 | install_command = pip install -U {opts} {packages} 9 | deps = -r{toxinidir}/test-requirements.txt 10 | commands = pytest {posargs} 11 | 12 | [testenv:flake8] 13 | commands = flake8 {posargs} 14 | 15 | [testenv:black] 16 | commands = black --check {posargs} . 17 | 18 | [testenv:docs] 19 | commands = python setup.py build_sphinx 20 | 21 | [testenv:venv] 22 | basepython = python3 23 | commands = {posargs} 24 | 25 | [testenv:sdist] 26 | commands = python setup.py sdist {posargs} 27 | 28 | [flake8] 29 | exclude=.venv,.git,dist,build,.tox,.eggs 30 | max-line-length = 120 31 | ignore = E203, E501, W503, E231 32 | --------------------------------------------------------------------------------