├── .codecov.yml ├── .coveragerc ├── .flake8 ├── .gitattributes ├── .github └── workflows │ ├── codeql.yml │ ├── docs.yml │ └── testing.yml ├── .gitignore ├── AUTHORS.rst ├── CONTRIBUTING.rst ├── LICENSE ├── MANIFEST.in ├── README.rst ├── UAT.py ├── docs ├── Makefile ├── make.bat └── source │ ├── _static │ └── .placeholder │ ├── api.rst │ ├── conf.py │ ├── custom_roles.py │ ├── index.rst │ ├── min_versions.rst │ └── release_history.rst ├── mpl_gui ├── __init__.py ├── _creation.py ├── _figure.py ├── _manage_backend.py ├── _manage_interactive.py ├── _patched_backends │ ├── __init__.py │ └── tkagg.py ├── _promotion.py ├── _version.py ├── global_figures.py └── tests │ ├── __init__.py │ ├── conftest.py │ └── test_examples.py ├── requirements-dev.txt ├── requirements-doc.txt ├── requirements.txt ├── setup.cfg ├── setup.py └── versioneer.py /.codecov.yml: -------------------------------------------------------------------------------- 1 | # show coverage in CI status, not as a comment. 2 | comment: off 3 | coverage: 4 | status: 5 | project: 6 | default: 7 | target: auto 8 | patch: 9 | default: 10 | target: auto 11 | -------------------------------------------------------------------------------- /.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | source = 3 | mpl_gui 4 | [report] 5 | omit = 6 | */python?.?/* 7 | */site-packages/nose/* 8 | # ignore _version.py and versioneer.py 9 | .*version.* 10 | *_version.py 11 | 12 | exclude_lines = 13 | if __name__ == '__main__': 14 | -------------------------------------------------------------------------------- /.flake8: -------------------------------------------------------------------------------- 1 | [flake8] 2 | exclude = 3 | .git, 4 | __pycache__, 5 | build, 6 | dist, 7 | versioneer.py, 8 | mpl_gui/_version.py, 9 | docs/source/conf.py 10 | max-line-length = 115 11 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | mpl_gui/_version.py export-subst 2 | -------------------------------------------------------------------------------- /.github/workflows/codeql.yml: -------------------------------------------------------------------------------- 1 | name: "CodeQL" 2 | 3 | on: 4 | push: 5 | branches: [ "main" ] 6 | pull_request: 7 | branches: [ "main" ] 8 | schedule: 9 | - cron: "39 10 * * 5" 10 | 11 | jobs: 12 | analyze: 13 | name: Analyze 14 | runs-on: ubuntu-latest 15 | permissions: 16 | actions: read 17 | contents: read 18 | security-events: write 19 | 20 | strategy: 21 | fail-fast: false 22 | matrix: 23 | language: [ python ] 24 | 25 | steps: 26 | - name: Checkout 27 | uses: actions/checkout@v3 28 | 29 | - name: Initialize CodeQL 30 | uses: github/codeql-action/init@v2 31 | with: 32 | languages: ${{ matrix.language }} 33 | queries: +security-and-quality 34 | 35 | - name: Autobuild 36 | uses: github/codeql-action/autobuild@v2 37 | 38 | - name: Perform CodeQL Analysis 39 | uses: github/codeql-action/analyze@v2 40 | with: 41 | category: "/language:${{ matrix.language }}" 42 | -------------------------------------------------------------------------------- /.github/workflows/docs.yml: -------------------------------------------------------------------------------- 1 | name: Docs 2 | 3 | on: [push, pull_request] 4 | 5 | env: 6 | IS_RELEASE: | 7 | ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' }} 8 | 9 | jobs: 10 | build: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v2 14 | - name: Install Python dependencies 15 | run: pip install -r requirements-doc.txt 16 | 17 | - name: Install mpl-gui 18 | run: python -m pip install -v . 19 | - name: Build 20 | run: | 21 | if [ "x${IS_RELEASE}" == "xtrue" ]; then 22 | O="-t release" 23 | fi 24 | make -Cdocs html O="$O" 25 | 26 | - name: Publish 27 | if: ${{ env.IS_RELEASE == 'true' }} 28 | uses: peaceiris/actions-gh-pages@v3 29 | with: 30 | github_token: ${{ secrets.GITHUB_TOKEN }} 31 | publish_dir: ./docs/build/html 32 | force_orphan: true 33 | -------------------------------------------------------------------------------- /.github/workflows/testing.yml: -------------------------------------------------------------------------------- 1 | name: Unit Tests 2 | 3 | on: 4 | push: 5 | pull_request: 6 | schedule: 7 | - cron: '00 4 * * *' # daily at 4AM 8 | 9 | jobs: 10 | build: 11 | 12 | runs-on: ubuntu-latest 13 | strategy: 14 | matrix: 15 | python-version: ['3.9', '3.10', '3.11', '3.12'] 16 | fail-fast: false 17 | steps: 18 | 19 | - uses: actions/checkout@v2 20 | with: 21 | fetch-depth: 0 22 | 23 | - name: Set up Python ${{ matrix.python-version }} 24 | uses: actions/setup-python@v2 25 | with: 26 | python-version: ${{ matrix.python-version }} 27 | 28 | - name: Install test requirements 29 | shell: bash -l {0} 30 | run: | 31 | set -vxeuo pipefail 32 | python -m pip install -r requirements-dev.txt 33 | python -m pip install -v . 34 | python -m pip list 35 | 36 | - name: Test with pytest 37 | shell: bash -l {0} 38 | run: | 39 | set -vxeuo pipefail 40 | coverage run -m pytest -v 41 | coverage report 42 | 43 | - name: Upload code coverage 44 | uses: codecov/codecov-action@v1 45 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | ######################################### 2 | # OS-specific temporary and backup files 3 | .DS_Store 4 | 5 | ######################################### 6 | # Editor temporary/working/backup files # 7 | .#* 8 | [#]*# 9 | *~ 10 | *$ 11 | *.bak 12 | *.kdev4 13 | .project 14 | .pydevproject 15 | *.swp 16 | .idea 17 | .vscode/ 18 | 19 | # Compiled source # 20 | ################### 21 | *.a 22 | *.com 23 | *.class 24 | *.dll 25 | *.exe 26 | *.o 27 | *.py[ocd] 28 | *.so 29 | 30 | # Python files # 31 | ################ 32 | # setup.py working directory 33 | build 34 | 35 | # setup.py dist directory 36 | dist 37 | # Egg metadata 38 | *.egg-info 39 | .eggs 40 | # wheel metadata 41 | pip-wheel-metadata/* 42 | # tox testing tool 43 | .tox 44 | setup.cfg 45 | # generated by setuptools_scm 46 | lib/matplotlib/_version.py 47 | 48 | # OS generated files # 49 | ###################### 50 | .directory 51 | .gdb_history 52 | .DS_Store? 53 | ehthumbs.db 54 | Icon? 55 | Thumbs.db 56 | 57 | # Things specific to this project # 58 | ################################### 59 | tutorials/intermediate/CL01.png 60 | tutorials/intermediate/CL02.png 61 | 62 | # Documentation generated files # 63 | ################################# 64 | # sphinx build directory 65 | doc/_build 66 | doc/api/_as_gen 67 | # autogenerated by sphinx-gallery 68 | doc/examples 69 | doc/gallery 70 | doc/modules 71 | doc/plot_types 72 | doc/pyplots/tex_demo.png 73 | doc/tutorials 74 | lib/dateutil 75 | examples/*/*.eps 76 | examples/*/*.pdf 77 | examples/*/*.png 78 | examples/*/*.svg 79 | examples/*/*.svgz 80 | examples/tests/* 81 | !examples/tests/backend_driver_sgskip.py 82 | result_images 83 | doc/_static/constrained_layout*.png 84 | 85 | # Nose/Pytest generated files # 86 | ############################### 87 | .pytest_cache/ 88 | .cache/ 89 | .coverage 90 | .coverage.* 91 | *.py,cover 92 | cover/ 93 | .noseids 94 | 95 | # Conda files # 96 | ############### 97 | __conda_version__.txt 98 | lib/png.lib 99 | lib/z.lib 100 | 101 | # Jupyter files # 102 | ################# 103 | 104 | .ipynb_checkpoints/ 105 | 106 | # Vendored dependencies # 107 | ######################### 108 | lib/matplotlib/backends/web_backend/node_modules/ 109 | lib/matplotlib/backends/web_backend/package-lock.json 110 | 111 | LICENSE/LICENSE_QHULL 112 | 113 | docs/source/_as_gen/* -------------------------------------------------------------------------------- /AUTHORS.rst: -------------------------------------------------------------------------------- 1 | ======= 2 | Credits 3 | ======= 4 | 5 | Maintainer 6 | ---------- 7 | 8 | * Thomas A Caswell 9 | 10 | Contributors 11 | ------------ 12 | 13 | None yet. Why not be the first? See: CONTRIBUTING.rst 14 | -------------------------------------------------------------------------------- /CONTRIBUTING.rst: -------------------------------------------------------------------------------- 1 | ============ 2 | Contributing 3 | ============ 4 | 5 | Contributions are welcome, and they are greatly appreciated! Every 6 | little bit helps, and credit will always be given. 7 | 8 | You can contribute in many ways: 9 | 10 | Types of Contributions 11 | ---------------------- 12 | 13 | Report Bugs 14 | ~~~~~~~~~~~ 15 | 16 | Report bugs at https://github.com/matplotlib/mpl-gui/issues. 17 | 18 | If you are reporting a bug, please include: 19 | 20 | * Any details about your local setup that might be helpful in troubleshooting. 21 | * Detailed steps to reproduce the bug. 22 | 23 | Fix Bugs 24 | ~~~~~~~~ 25 | 26 | Look through the GitHub issues for bugs. Anything tagged with "bug" 27 | is open to whoever wants to implement it. 28 | 29 | Implement Features 30 | ~~~~~~~~~~~~~~~~~~ 31 | 32 | Look through the GitHub issues for features. Anything tagged with "feature" 33 | is open to whoever wants to implement it. 34 | 35 | Write Documentation 36 | ~~~~~~~~~~~~~~~~~~~ 37 | 38 | mpl-gui could always use more documentation, whether 39 | as part of the official mpl-gui docs, in docstrings, 40 | or even on the web in blog posts, articles, and such. 41 | 42 | Submit Feedback 43 | ~~~~~~~~~~~~~~~ 44 | 45 | The best way to send feedback is to file an issue at https://github.com/matplotlib/mpl-gui/issues. 46 | 47 | If you are proposing a feature: 48 | 49 | * Explain in detail how it would work. 50 | * Keep the scope as narrow as possible, to make it easier to implement. 51 | * Remember that this is a volunteer-driven project, and that contributions 52 | are welcome :) 53 | 54 | Get Started! 55 | ------------ 56 | 57 | Ready to contribute? Here's how to set up `mpl-gui` for local development. 58 | 59 | 1. Fork the `mpl-gui` repo on GitHub. 60 | 2. Clone your fork locally:: 61 | 62 | $ git clone git@github.com:your_name_here/mpl-gui.git 63 | 64 | 3. Install your local copy into a virtualenv. Using the standard-libary `venv`, see 65 | [the Python documentation](https://docs.python.org/3/tutorial/venv.html#creating-virtual-environments) for details :: 66 | 67 | $ python -m venv mpl-gui-dev 68 | $ source mpl-gui-dev/activate # unix 69 | $ mpl-gui-dev\Scripts\activate.bat # windows 70 | 71 | or other virtual environment tool (e.g. conda, canopy) of choice. 72 | 73 | 4. Install the development dependencies :: 74 | 75 | $ cd mpl-gui/ 76 | $ pip install -r requirements-dev.txt 77 | $ pip install -v --no-build-isolation . 78 | 79 | 5. Create a branch for local development:: 80 | 81 | $ git checkout -b name-of-your-bugfix-or-feature 82 | 83 | Now you can make your changes locally. 84 | 85 | 6. When you're done making changes, check that your changes pass flake8 and the tests:: 86 | 87 | $ flake8 mpl_gui tests 88 | $ pytest 89 | 90 | To get flake8 and pytest, pip install them into your virtualenv. 91 | 92 | 7. Commit your changes and push your branch to GitHub:: 93 | 94 | $ git add . 95 | $ git commit -m "Your detailed description of your changes." 96 | $ git push origin name-of-your-bugfix-or-feature 97 | 98 | 8. Submit a pull request through the GitHub website. 99 | 100 | Pull Request Guidelines 101 | ----------------------- 102 | 103 | Before you submit a pull request, check that it meets these guidelines: 104 | 105 | 1. The pull request should include tests. 106 | 2. If the pull request adds functionality, the docs should be updated. Put 107 | your new functionality into a function with a docstring, and add the 108 | feature to the list in README.rst. 109 | 3. The pull request should work for Python 3.7 and up. 110 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | BSD 3-Clause License 2 | 3 | Copyright (c) 2021, Thomas A Caswell 4 | All rights reserved. 5 | 6 | Redistribution and use in source and binary forms, with or without 7 | modification, are permitted provided that the following conditions are met: 8 | 9 | 1. Redistributions of source code must retain the above copyright notice, this 10 | list of conditions and the following disclaimer. 11 | 12 | 2. Redistributions in binary form must reproduce the above copyright notice, 13 | this list of conditions and the following disclaimer in the documentation 14 | and/or other materials provided with the distribution. 15 | 16 | 3. Neither the name of the copyright holder nor the names of its contributors 17 | may be used to endorse or promote products derived from this software 18 | without specific prior written permission. 19 | 20 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 21 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 22 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 23 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 24 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 25 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 26 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 27 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 28 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 29 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 30 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include AUTHORS.rst 2 | include CONTRIBUTING.rst 3 | include LICENSE 4 | include README.rst 5 | include requirements.txt 6 | 7 | recursive-exclude * __pycache__ 8 | recursive-exclude * *.py[co] 9 | 10 | recursive-include docs *.rst conf.py Makefile make.bat 11 | 12 | include versioneer.py 13 | include mpl_gui/_version.py 14 | 15 | # If including data files in the package, add them like: 16 | # include path/to/data_file 17 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | ======= 2 | mpl-gui 3 | ======= 4 | 5 | .. image:: https://img.shields.io/pypi/v/mpl-gui.svg 6 | :target: https://pypi.python.org/pypi/mpl-gui 7 | 8 | 9 | Prototype project for splitting pyplot in half 10 | 11 | * Free software: 3-clause BSD license 12 | * Documentation: https://matplotlib.github.io/mpl-gui. 13 | 14 | Motivation 15 | ---------- 16 | 17 | This project is a prototype space for overhauling the GUI event loop management 18 | tools that Matplotlib provides in pyplot. 19 | 20 | The pyplot module current serves two critical, but unrelated functions: 21 | 22 | 1. provide a state-full implicit API that rhymes / was inspired by MATLAB 23 | 2. provide the management of interaction between Matplotlib and the GUI event 24 | loop 25 | 26 | While it can be very convenient when working at the prompt, the state-full API 27 | can lead to brittle code that depends on the global state in confusing ways, 28 | particularly when used in library code. On the other hand, 29 | ``matplotlib.pyplot`` does a very good job of hiding from the user the fact 30 | that they are developing a GUI application and handling, along with IPython, 31 | many of the details involved in running a GUI application in parallel with 32 | Python. 33 | -------------------------------------------------------------------------------- /UAT.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | import sys 3 | 4 | import matplotlib as mpl 5 | import mpl_gui as mg 6 | from matplotlib.figure import Figure 7 | 8 | # ensure no pyplot! 9 | assert sys.modules.get("matplotlib.pyplot", None) is None 10 | sys.modules["matplotlib.pyplot"] = None 11 | 12 | parser = argparse.ArgumentParser( 13 | description="User Acceptance Tests for mpl-gui.", 14 | epilog=( 15 | "This script runs through several scenarios and prints out prompts explaining the expected behavior. " 16 | "The figures will need to be closed to continue the script. " 17 | ), 18 | ) 19 | parser.add_argument( 20 | "backend", 21 | type=str, 22 | help="The backend to use. Can be anything that `mpl.use` accepts", 23 | ) 24 | 25 | args = parser.parse_args() 26 | target = args.backend 27 | 28 | 29 | # this is how to force the right Qt binding 30 | if target.lower().startswith("qt5"): 31 | import PyQt5.QtWidgets # noqa 32 | elif target.lower().startswith("qt"): 33 | import PyQt6.QtWidgets # noqa 34 | 35 | mpl.use(target, force=True) 36 | 37 | fig1 = Figure(label="A Label!") 38 | 39 | fig2 = Figure() 40 | 41 | print( 42 | """ 43 | You should see two figures with window titles of 44 | 45 | - "A Label!" 46 | - "Figure 0" 47 | 48 | both should be empty and the process should block until both 49 | are closed (the keybinding 'q' should work). 50 | """ 51 | ) 52 | mg.show([fig1, fig2]) 53 | 54 | mg.ion() 55 | fig = Figure() 56 | print(f"Interactive mode is on: {mg.is_interactive()}") 57 | mg.show([fig]) # will not block 58 | print("A (implicitly) non-blocking show was just called.") 59 | mg.ioff() 60 | print(mg.is_interactive()) 61 | print(f"Interactive mode is on: {mg.is_interactive()}") 62 | print( 63 | """ 64 | You should see one open figure with the title 65 | 66 | - Figure 2 67 | 68 | and the process should again block until it is closed. 69 | """ 70 | ) 71 | 72 | mg.show([fig]) # will block! 73 | 74 | fig = Figure(label="control blocking") 75 | 76 | mg.show([fig], block=False) # will never block 77 | print("A (implicitly) non-blocking show was just called.") 78 | print( 79 | """ 80 | You should see one open figure with the title 81 | 82 | - control blocking 83 | 84 | and the process should again block until it is closed. 85 | """ 86 | ) 87 | mg.show([fig], block=True) # will always block 88 | 89 | 90 | fig1 = mg.figure() 91 | fig2, axs = mg.subplots(2, 2) 92 | fig3, axd = mg.subplot_mosaic("AA\nBC") 93 | 94 | print( 95 | """ 96 | You should see three open figure with the titles 97 | 98 | - Figure 4 99 | - Figure 5 100 | - Figure 6 101 | 102 | and the process should again block until it is closed. One will 103 | be empty, one will have a 2x2 grid, One will have `AA;BC` layout. 104 | """ 105 | ) 106 | 107 | mg.show([fig1, fig2, fig3]) 108 | 109 | 110 | fr = mg.FigureRegistry() 111 | 112 | fr.figure() 113 | fr.subplots(2, 2) 114 | fr.subplot_mosaic("AA\nBC") 115 | 116 | print( 117 | """ 118 | You should see three open figure with the titles 119 | 120 | - Figure 0 121 | - Figure 1 122 | - Figure 2 123 | 124 | and the process should again block until it is closed. One will 125 | be empty, one will have a 2x2 grid, One will have `AA;BC` layout. 126 | """ 127 | ) 128 | 129 | fr.show_all() # will show all three figures 130 | # fr.show() # alias for pyplot compatibility 131 | 132 | # fr.close_all() # will close all three figures 133 | # fr.close("all") # alias for pyplot compatibility 134 | 135 | 136 | plt = mg.FigureRegistry() 137 | 138 | 139 | with mg.FigureContext() as fc: 140 | fc.subplot_mosaic("AA\nBC") 141 | fc.figure() 142 | fc.subplots(2, 2) 143 | 144 | print( 145 | """ 146 | You should see three open figure with the titles 147 | 148 | - Figure 0 149 | - Figure 1 150 | - Figure 2 151 | 152 | and the process should again block until it is closed. One will 153 | be empty, one will have a 2x2 grid, One will have `AA;BC` layout. 154 | """ 155 | ) 156 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Minimal makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line. 5 | SPHINXOPTS = -W --keep-going 6 | SPHINXBUILD = sphinx-build 7 | SPHINXPROJ = mpl-gui 8 | SOURCEDIR = source 9 | BUILDDIR = build 10 | 11 | # Put it first so that "make" without argument is like "make help". 12 | help: 13 | @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 14 | 15 | 16 | show: 17 | @python -c "import webbrowser; webbrowser.open_new_tab('file://$(shell pwd)/build/html/index.html')" 18 | 19 | 20 | .PHONY: help Makefile 21 | 22 | # Catch-all target: route all unknown targets to Sphinx using the new 23 | # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). 24 | %: Makefile 25 | @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 26 | -------------------------------------------------------------------------------- /docs/make.bat: -------------------------------------------------------------------------------- 1 | @ECHO OFF 2 | 3 | pushd %~dp0 4 | 5 | REM Command file for Sphinx documentation 6 | 7 | if "%SPHINXBUILD%" == "" ( 8 | set SPHINXBUILD=sphinx-build 9 | ) 10 | set SOURCEDIR=source 11 | set BUILDDIR=build 12 | set SPHINXPROJ=PackagingScientificPython 13 | 14 | if "%1" == "" goto help 15 | 16 | %SPHINXBUILD% >NUL 2>NUL 17 | if errorlevel 9009 ( 18 | echo. 19 | echo.The 'sphinx-build' command was not found. Make sure you have Sphinx 20 | echo.installed, then set the SPHINXBUILD environment variable to point 21 | echo.to the full path of the 'sphinx-build' executable. Alternatively you 22 | echo.may add the Sphinx directory to PATH. 23 | echo. 24 | echo.If you don't have Sphinx installed, grab it from 25 | echo.http://sphinx-doc.org/ 26 | exit /b 1 27 | ) 28 | 29 | %SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% 30 | goto end 31 | 32 | :help 33 | %SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% 34 | 35 | :end 36 | popd 37 | -------------------------------------------------------------------------------- /docs/source/_static/.placeholder: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/matplotlib/mpl-gui/465a7cb16f1068a2efecfc6dac53ab5aae9228c2/docs/source/_static/.placeholder -------------------------------------------------------------------------------- /docs/source/api.rst: -------------------------------------------------------------------------------- 1 | mpl gui API Reference 2 | ===================== 3 | 4 | .. automodule:: mpl_gui 5 | :no-undoc-members: 6 | 7 | 8 | 9 | Select the backend 10 | ------------------ 11 | .. autosummary:: 12 | :toctree: _as_gen 13 | 14 | 15 | select_gui_toolkit 16 | 17 | 18 | Interactivity 19 | ------------- 20 | 21 | .. autosummary:: 22 | :toctree: _as_gen 23 | 24 | 25 | ion 26 | ioff 27 | is_interactive 28 | 29 | 30 | Unmanaged Figures 31 | ----------------- 32 | 33 | Figure Creation 34 | +++++++++++++++ 35 | 36 | These are not strictly necessary as they are only thin wrappers around creating 37 | a `matplotlib.figure.Figure` instance and creating children in one line. 38 | 39 | .. autosummary:: 40 | :toctree: _as_gen 41 | 42 | 43 | 44 | figure 45 | subplots 46 | subplot_mosaic 47 | 48 | 49 | 50 | Display 51 | +++++++ 52 | 53 | .. autosummary:: 54 | :toctree: _as_gen 55 | 56 | 57 | 58 | display 59 | demote_figure 60 | 61 | 62 | 63 | Locally Managed Figures 64 | ----------------------- 65 | 66 | 67 | .. autoclass:: FigureRegistry 68 | :no-undoc-members: 69 | :show-inheritance: 70 | 71 | 72 | .. autoclass:: FigureContext 73 | :no-undoc-members: 74 | :show-inheritance: 75 | 76 | Create Figures and Axes 77 | +++++++++++++++++++++++ 78 | 79 | .. autosummary:: 80 | :toctree: _as_gen 81 | 82 | 83 | FigureRegistry.figure 84 | FigureRegistry.subplots 85 | FigureRegistry.subplot_mosaic 86 | 87 | 88 | Access managed figures 89 | ++++++++++++++++++++++ 90 | 91 | .. autosummary:: 92 | :toctree: _as_gen 93 | 94 | 95 | FigureRegistry.by_label 96 | FigureRegistry.by_number 97 | FigureRegistry.figures 98 | 99 | 100 | 101 | Show and close managed Figures 102 | ++++++++++++++++++++++++++++++ 103 | 104 | 105 | .. autosummary:: 106 | :toctree: _as_gen 107 | 108 | 109 | FigureRegistry.show_all 110 | FigureRegistry.close_all 111 | FigureRegistry.show 112 | FigureRegistry.close 113 | 114 | 115 | 116 | 117 | Globally managed 118 | ---------------- 119 | 120 | 121 | .. automodule:: mpl_gui.global_figures 122 | :no-undoc-members: 123 | 124 | 125 | 126 | Create Figures and Axes 127 | +++++++++++++++++++++++ 128 | 129 | .. autosummary:: 130 | :toctree: _as_gen 131 | 132 | 133 | figure 134 | subplots 135 | subplot_mosaic 136 | 137 | 138 | Access managed figures 139 | ++++++++++++++++++++++ 140 | 141 | 142 | .. autosummary:: 143 | :toctree: _as_gen 144 | 145 | 146 | by_label 147 | 148 | 149 | Show and close managed Figures 150 | ++++++++++++++++++++++++++++++ 151 | 152 | 153 | .. autosummary:: 154 | :toctree: _as_gen 155 | 156 | 157 | 158 | 159 | show 160 | show_all 161 | close_all 162 | close 163 | 164 | 165 | Interactivity 166 | +++++++++++++ 167 | 168 | .. autosummary:: 169 | :toctree: _as_gen 170 | 171 | 172 | 173 | ion 174 | ioff 175 | is_interactive 176 | -------------------------------------------------------------------------------- /docs/source/conf.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | # 4 | # mpl-gui documentation build configuration file, created by 5 | # sphinx-quickstart on Thu Jun 28 12:35:56 2018. 6 | # 7 | # This file is execfile()d with the current directory set to its 8 | # containing dir. 9 | # 10 | # Note that not all possible configuration values are present in this 11 | # autogenerated file. 12 | # 13 | # All configuration values have a default; values that are commented out 14 | # serve to show the default. 15 | 16 | # If extensions (or modules to document with autodoc) are in another directory, 17 | # add these directories to sys.path here. If the directory is relative to the 18 | # documentation root, use os.path.abspath to make it absolute, like shown here. 19 | # 20 | # import os 21 | # import sys 22 | # sys.path.insert(0, os.path.abspath('.')) 23 | 24 | 25 | # -- General configuration ------------------------------------------------ 26 | 27 | # If your documentation needs a minimal Sphinx version, state it here. 28 | # 29 | # needs_sphinx = '1.0' 30 | import sys 31 | 32 | sys.path.append(".") 33 | 34 | 35 | # Release mode enables optimizations and other related options. 36 | is_release_build = tags.has("release") # noqa 37 | 38 | # Add any Sphinx extension module names here, as strings. They can be 39 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom 40 | # ones. 41 | extensions = [ 42 | "sphinx.ext.autodoc", 43 | "sphinx.ext.autosummary", 44 | "sphinx.ext.githubpages", 45 | "sphinx.ext.intersphinx", 46 | "sphinx.ext.mathjax", 47 | "sphinx.ext.viewcode", 48 | "custom_roles", 49 | "IPython.sphinxext.ipython_directive", 50 | "IPython.sphinxext.ipython_console_highlighting", 51 | "matplotlib.sphinxext.plot_directive", 52 | "numpydoc", 53 | "sphinx_copybutton", 54 | ] 55 | 56 | # Configuration options for plot_directive. See: 57 | # https://github.com/matplotlib/matplotlib/blob/f3ed922d935751e08494e5fb5311d3050a3b637b/lib/matplotlib/sphinxext/plot_directive.py#L81 58 | plot_html_show_source_link = False 59 | plot_html_show_formats = False 60 | 61 | # Generate the API documentation when building 62 | autosummary_generate = True 63 | numpydoc_show_class_members = False 64 | 65 | # Add any paths that contain templates here, relative to this directory. 66 | templates_path = ["_templates"] 67 | 68 | # The suffix(es) of source filenames. 69 | # You can specify multiple suffix as a list of string: 70 | # 71 | # source_suffix = ['.rst', '.md'] 72 | source_suffix = ".rst" 73 | 74 | # The master toctree document. 75 | master_doc = "index" 76 | 77 | # General information about the project. 78 | project = "mpl-gui" 79 | copyright = "2021, Thomas A Caswell" 80 | author = "Thomas A Caswell" 81 | 82 | # The version info for the project you're documenting, acts as replacement for 83 | # |version| and |release|, also used in various other places throughout the 84 | # built documents. 85 | # 86 | import mpl_gui 87 | 88 | # The short X.Y version. 89 | version = mpl_gui.__version__ 90 | # The full version, including alpha/beta/rc tags. 91 | release = mpl_gui.__version__ 92 | 93 | # The language for content autogenerated by Sphinx. Refer to documentation 94 | # for a list of supported languages. 95 | # 96 | # This is also used if you do content translation via gettext catalogs. 97 | # Usually you set "language" from the command line for these cases. 98 | # language = None 99 | 100 | # List of patterns, relative to source directory, that match files and 101 | # directories to ignore when looking for source files. 102 | # This patterns also effect to html_static_path and html_extra_path 103 | exclude_patterns = [] 104 | 105 | # The name of the Pygments (syntax highlighting) style to use. 106 | pygments_style = "sphinx" 107 | 108 | # If true, `todo` and `todoList` produce output, else they produce nothing. 109 | todo_include_todos = False 110 | 111 | default_role = "obj" 112 | 113 | nitpicky = True 114 | 115 | # -- Options for HTML output ---------------------------------------------- 116 | 117 | # The theme to use for HTML and HTML Help pages. See the documentation for 118 | # a list of builtin themes. 119 | # 120 | html_theme = "mpl_sphinx_theme" 121 | 122 | html_theme_options = { 123 | "navbar_links": ("absolute", "server-stable"), 124 | # collapse_navigation in pydata-sphinx-theme is slow, so skipped for local 125 | # and CI builds https://github.com/pydata/pydata-sphinx-theme/pull/386 126 | "collapse_navigation": not is_release_build, 127 | "show_prev_next": False, 128 | "navigation_with_keys": False, 129 | # "secondary_sidebar_items": "page-toc.html", 130 | "footer_start": ["copyright", "sphinx-version", "doc_version"], 131 | } 132 | include_analytics = is_release_build 133 | if include_analytics: 134 | html_theme_options["google_analytics_id"] = "UA-55954603-1" 135 | 136 | native_site = False 137 | 138 | # Theme options are theme-specific and customize the look and feel of a theme 139 | # further. For a list of options available for each theme, see the 140 | # documentation. 141 | # 142 | # html_theme_options = {} 143 | 144 | # Add any paths that contain custom static files (such as style sheets) here, 145 | # relative to this directory. They are copied after the builtin static files, 146 | # so a file named "default.css" will overwrite the builtin "default.css". 147 | html_static_path = ["_static"] 148 | 149 | # Custom sidebar templates, must be a dictionary that maps document names 150 | # to template names. 151 | # 152 | # This is required for the alabaster theme 153 | # refs: http://alabaster.readthedocs.io/en/latest/installation.html#sidebars 154 | html_sidebars = { 155 | } 156 | 157 | 158 | # -- Options for HTMLHelp output ------------------------------------------ 159 | 160 | # Output file base name for HTML help builder. 161 | htmlhelp_basename = "mpl-gui" 162 | 163 | 164 | # -- Options for LaTeX output --------------------------------------------- 165 | 166 | latex_elements = { 167 | # The paper size ('letterpaper' or 'a4paper'). 168 | # 169 | # 'papersize': 'letterpaper', 170 | # The font size ('10pt', '11pt' or '12pt'). 171 | # 172 | # 'pointsize': '10pt', 173 | # Additional stuff for the LaTeX preamble. 174 | # 175 | # 'preamble': '', 176 | # Latex figure (float) alignment 177 | # 178 | # 'figure_align': 'htbp', 179 | } 180 | 181 | # Grouping the document tree into LaTeX files. List of tuples 182 | # (source start file, target name, title, 183 | # author, documentclass [howto, manual, or own class]). 184 | latex_documents = [ 185 | (master_doc, "mpl-gui.tex", "mpl-gui Documentation", "Contributors", "manual"), 186 | ] 187 | 188 | 189 | # -- Options for manual page output --------------------------------------- 190 | 191 | # One entry per manual page. List of tuples 192 | # (source start file, name, description, authors, manual section). 193 | man_pages = [(master_doc, "mpl-gui", "mpl-gui Documentation", [author], 1)] 194 | 195 | 196 | # -- Options for Texinfo output ------------------------------------------- 197 | 198 | # Grouping the document tree into Texinfo files. List of tuples 199 | # (source start file, target name, title, author, 200 | # dir menu entry, description, category) 201 | texinfo_documents = [ 202 | ( 203 | master_doc, 204 | "mpl-gui", 205 | "mpl-gui Documentation", 206 | author, 207 | "mpl-gui", 208 | "Prototype project for splitting pyplot in half", 209 | "Miscellaneous", 210 | ), 211 | ] 212 | 213 | 214 | # Example configuration for intersphinx: refer to the Python standard library. 215 | intersphinx_mapping = { 216 | "python": ("https://docs.python.org/3/", None), 217 | "matplotlib": ("https://matplotlib.org/stable", None), 218 | } 219 | -------------------------------------------------------------------------------- /docs/source/custom_roles.py: -------------------------------------------------------------------------------- 1 | from docutils import nodes 2 | from os.path import sep 3 | from matplotlib import rcParamsDefault 4 | 5 | 6 | def rcparam_role(name, rawtext, text, lineno, inliner, options={}, content=[]): 7 | rendered = nodes.Text(f'rcParams["{text}"]') 8 | 9 | source = inliner.document.attributes["source"].replace(sep, "/") 10 | rel_source = source.split("/docs/", 1)[1] 11 | 12 | levels = rel_source.count("/") 13 | refuri = ( 14 | "../" * levels 15 | + "tutorials/introductory/customizing.html" 16 | + f"?highlight={text}#a-sample-matplotlibrc-file" 17 | ) 18 | 19 | ref = nodes.reference(rawtext, rendered, refuri=refuri) 20 | node_list = [nodes.literal("", "", ref)] 21 | # The default backend would be printed as "agg", but that's not correct (as 22 | # the default is actually determined by fallback). 23 | if text in rcParamsDefault and text != "backend": 24 | node_list.extend( 25 | [ 26 | nodes.Text(" (default: "), 27 | nodes.literal("", repr(rcParamsDefault[text])), 28 | nodes.Text(")"), 29 | ] 30 | ) 31 | return node_list, [] 32 | 33 | 34 | def setup(app): 35 | app.add_role("rc", rcparam_role) 36 | return {"parallel_read_safe": True, "parallel_write_safe": True} 37 | -------------------------------------------------------------------------------- /docs/source/index.rst: -------------------------------------------------------------------------------- 1 | .. Packaging Scientific Python documentation master file, created by 2 | sphinx-quickstart on Thu Jun 28 12:35:56 2018. 3 | You can adapt this file completely to your liking, but it should at least 4 | contain the root `toctree` directive. 5 | 6 | ======================= 7 | mpl-gui Documentation 8 | ======================= 9 | .. highlight:: python 10 | 11 | .. toctree:: 12 | :maxdepth: 1 13 | 14 | api 15 | release_history 16 | min_versions 17 | 18 | 19 | Motivation 20 | ========== 21 | 22 | This project is a prototype space for overhauling the GUI event loop management 23 | tools that Matplotlib provides in pyplot. 24 | 25 | The pyplot module current serves two critical, but unrelated functions: 26 | 27 | 1. provide a state-full implicit API that rhymes / was inspired by MATLAB 28 | 2. provide the management of interaction between Matplotlib and the GUI event 29 | loop, including keeping Figures alive 30 | 31 | While it can be very convenient when working at the prompt, the state-full API 32 | can lead to brittle code that depends on the global state in confusing ways, 33 | particularly when used in library code. On the other hand, 34 | `matplotlib.pyplot` does a very good job of hiding from the user the fact 35 | that they are developing a GUI application and handling, along with IPython, 36 | many of the details involved in running a GUI application in parallel with 37 | Python. 38 | 39 | 40 | If you want to be sure that this code does not secretly depend on pyplot run :: 41 | 42 | import sys 43 | sys.modules['matplotlib.pyplot'] = None 44 | 45 | 46 | which will prevent pyplot from being imported! 47 | 48 | 49 | 50 | Selecting the GUI toolkit 51 | ========================= 52 | 53 | `mpl_gui` makes use of `Matplotlib backends 54 | `_ for actually 55 | providing the GUI bindings. Analagous to `matplotlib.use` and 56 | `matplotlib.pyplot.switch_backend` `mpl_gui` provides 57 | `mpl_gui.select_gui_toolkit` to select which GUI toolkit is used. 58 | `~mpl_gui.select_gui_toolkit` has the same fall-back behavior as 59 | `~matplotlib.pyplot` and stores its state in :rc:`backend`. 60 | 61 | `mpl_gui` will 62 | consistently co-exist with `matplotlib.pyplot` managed Figures in the same 63 | process. 64 | 65 | 66 | 67 | User Managed Figures 68 | ==================== 69 | 70 | There are cases where having such a registry may be too much implicit state. 71 | For such cases the underlying tools that `.FigureRegistry` are built on are 72 | explicitly available :: 73 | 74 | import mpl_gui as mg 75 | from matplotlib.figure import Figure 76 | 77 | fig1 = Figure(label='A Label!') 78 | 79 | fig2 = Figure() 80 | 81 | mg.display(fig1, fig2) 82 | 83 | 84 | which will show both figures and block until they are closed. As part of the 85 | "showing" process, the correct GUI objects will be created, put on the 86 | screen, and the event loop for the host GUI framework is run. 87 | 88 | Similar to `plt.ion` and 89 | `plt.ioff`, we provide `mg.ion()` and 90 | `mg.ioff()` which have identical semantics. Thus :: 91 | 92 | import mpl_gui as mg 93 | from matplotlib.figure import Figure 94 | 95 | mg.ion() 96 | print(mg.is_interactive()) 97 | fig = Figure() 98 | 99 | mg.display([fig]) # will not block 100 | 101 | mg.ioff() 102 | print(mg.is_interactive()) 103 | mg.display(fig) # will block! 104 | 105 | 106 | As with `plt.show`, you can explicitly control the 107 | blocking behavior of `mg.display` via the *block* keyword argument :: 108 | 109 | import mpl_gui as mg 110 | from matplotlib.figure import Figure 111 | 112 | fig = Figure(label='control blocking') 113 | 114 | mg.display(fig, block=False) # will never block 115 | mg.display(fig, block=True) # will always block 116 | 117 | 118 | The interactive state is shared Matplotlib and can also be controlled with 119 | `matplotlib.interactive` and queried via `matplotlib.is_interactive`. 120 | 121 | 122 | 123 | Locally Managed Figures 124 | ======================= 125 | 126 | To avoid the issues with global state the objects you can create a local `.FigureRegistry`. 127 | It keeps much of the convenience of the ``pyplot`` API but without the risk of global state :: 128 | 129 | import mpl_gui as mg 130 | 131 | fr = mg.FigureRegistry() 132 | 133 | fr.figure() 134 | fr.subplots(2, 2) 135 | fr.subplot_mosaic('AA\nBC') 136 | 137 | fr.show_all() # will show all three figures 138 | fr.show() # alias for pyplot compatibility 139 | 140 | fr.close_all() # will close all three figures 141 | fr.close('all') # alias for pyplot compatibility 142 | 143 | 144 | Additionally, there are the `.FigureRegistry.by_label`, `.FigureRegistry.by_number`, 145 | `.FigureRegistry.figures` accessors that returns a dictionary mapping the 146 | Figures' labels to each Figure, the figures number to Figure, and a tuple of known Figures:: 147 | 148 | import mpl_gui as mg 149 | 150 | fr = mg.FigureRegistry() 151 | 152 | figA = fr.figure(label='A') 153 | figB, axs = fr.subplots(2, 2, label='B') 154 | 155 | fr.by_label['A'] is figA 156 | fr.by_label['B'] is figB 157 | 158 | fr.by_number[0] is figA 159 | fr.by_number[1] is figB 160 | 161 | fr.figures == (figA, figB) 162 | 163 | fr.show() 164 | 165 | The `.FigureRegistry` is local state so that if the user drops all references 166 | to it it will be eligible for garbage collection. If there are no other 167 | references to the ``Figure`` objects it is likely that they may be closed when 168 | the garbage collector runs! 169 | 170 | 171 | A very common use case is to make several figures and then show them all 172 | together at the end. To facilitate this we provide a `.FigureContext` that is 173 | a `.FigureRegistry` that can be used as a context manager that (locally) keeps 174 | track of the created figures and shows them on exit :: 175 | 176 | import mpl_gui as mg 177 | 178 | with mg.FigureContext(block=None) as fc: 179 | fc.subplot_mosaic('AA\nBC') 180 | fc.figure() 181 | fc.subplots(2, 2) 182 | 183 | 184 | This will create 3 figures and block on ``__exit__``. The blocking 185 | behavior depends on `~mpl_gui.is_interactive()` (and follow the behavior of 186 | `.display` and `.FigureRegistry.show` can explicitly controlled via the *block* keyword argument). 187 | 188 | The `.global_figures` module is implemented by having a singleton `.FigureRegistry` 189 | at the module level. 190 | 191 | 192 | 193 | 194 | Globally Managed Figures 195 | ======================== 196 | 197 | 198 | The `mpl_gui.global_figures` module provides a direct analogy to the 199 | `matplotlib.pyplot` behavior of having a global registry of figures. Thus, any 200 | figures created via the functions in `.global_figures` will remain alive until they 201 | have been cleared from the registry (and the user has dropped all other 202 | references). While it can be convenient, it carries with it the risk inherent 203 | in any use of global state. 204 | 205 | The `matplotlib.pyplot` API related to figure creation, showing, and closing is a drop-in replacement: 206 | 207 | :: 208 | 209 | import mpl_gui.global_figures as gfigs 210 | 211 | fig = gfigs.figure() 212 | fig, ax = gfigs.subplots() 213 | fig, axd = gfigs.subplot_mosaic('AA\nCD') 214 | 215 | gfigs.show(block=True) # blocks until all figures are closed 216 | gfigs.show(block=True, timeout=1000) # blocks for up to 1s or all figures are closed 217 | gfigs.show(block=False) # does not block 218 | gfigs.show() # depends on if in "interacitve mode" 219 | 220 | gfigs.ion() # turn on interactive mode 221 | gfigs.ioff() # turn off interactive mode 222 | gfigs.is_interactive() # query interactive state 223 | 224 | gfigs.close('all') # close all open figures 225 | gfigs.close(fig) # close a particular figure 226 | -------------------------------------------------------------------------------- /docs/source/min_versions.rst: -------------------------------------------------------------------------------- 1 | =================================== 2 | Minimum Version of Python and NumPy 3 | =================================== 4 | 5 | 6 | - This project supports at least the minor versions of Python 7 | initially released 42 months prior to a planned project release 8 | date. 9 | - The project will always support at least the 2 latest minor 10 | versions of Python. 11 | - The project will support minor versions of ``numpy`` initially 12 | released in the 24 months prior to a planned project release date or 13 | the oldest version that supports the minimum Python version 14 | (whichever is higher). 15 | - The project will always support at least the 3 latest minor 16 | versions of NumPy. 17 | 18 | The minimum supported version of Python will be set to 19 | ``python_requires`` in ``setup``. All supported minor versions of 20 | Python will be in the test matrix and have binary artifacts built 21 | for releases. 22 | 23 | The project should adjust upward the minimum Python and NumPy 24 | version support on every minor and major release, but never on a 25 | patch release. 26 | 27 | This is consistent with NumPy `NEP 29 28 | `__. 29 | -------------------------------------------------------------------------------- /docs/source/release_history.rst: -------------------------------------------------------------------------------- 1 | =============== 2 | Release History 3 | =============== 4 | 5 | v0.0.1 (2022-01-29) 6 | ------------------- 7 | 8 | Initial release. 9 | 10 | All expected functionality is implemented, but all APIs should be considered 11 | provisional. 12 | -------------------------------------------------------------------------------- /mpl_gui/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | Prototype project for new Matplotlib GUI management. 3 | 4 | The pyplot module current serves two critical, but unrelated functions: 5 | 6 | 1. provide a state-full implicit API that rhymes / was inspired by MATLAB 7 | 2. provide the management of interaction between Matplotlib and the GUI event 8 | loop 9 | 10 | This project is prototype for separating the second function from the first. 11 | This will enable users to both only use the explicit API (nee OO interface) and 12 | to have smooth integration with the GUI event loop as with pyplot. 13 | 14 | """ 15 | from collections import Counter 16 | import functools 17 | import logging 18 | import warnings 19 | import weakref 20 | 21 | from matplotlib.backend_bases import FigureCanvasBase as _FigureCanvasBase 22 | 23 | from ._figure import Figure # noqa: F401 24 | 25 | from ._manage_interactive import ( # noqa: F401 26 | ion as ion, 27 | ioff as ioff, 28 | is_interactive as is_interactive, 29 | ) 30 | from ._manage_backend import select_gui_toolkit as select_gui_toolkit # noqa: F401 31 | from ._manage_backend import current_backend_module as _cbm 32 | from ._promotion import ( 33 | promote_figure as _promote_figure, 34 | demote_figure as demote_figure, 35 | ) 36 | from ._creation import ( 37 | figure as figure, 38 | subplots as subplots, 39 | subplot_mosaic as subplot_mosaic, 40 | ) 41 | 42 | 43 | from ._version import get_versions 44 | 45 | __version__ = get_versions()["version"] 46 | del get_versions 47 | 48 | 49 | _log = logging.getLogger(__name__) 50 | 51 | 52 | def display(*figs, block=None, timeout=0): 53 | """ 54 | Show the figures and maybe block. 55 | 56 | Parameters 57 | ---------- 58 | *figs : Figure 59 | The figures to show. If they do not currently have a GUI aware 60 | canvas + manager attached they will be promoted. 61 | 62 | block : bool, optional 63 | Whether to wait for all figures to be closed before returning. 64 | 65 | If `True` block and run the GUI main loop until all figure windows 66 | are closed. 67 | 68 | If `False` ensure that all figure windows are displayed and return 69 | immediately. In this case, you are responsible for ensuring 70 | that the event loop is running to have responsive figures. 71 | 72 | Defaults to True in non-interactive mode and to False in interactive 73 | mode (see `.is_interactive`). 74 | 75 | timeout : float, optional 76 | How long to run the event loop in msec if blocking. 77 | 78 | """ 79 | # TODO handle single figure 80 | 81 | # call this to ensure a backend is indeed selected 82 | backend = _cbm() 83 | managers = [] 84 | for fig in figs: 85 | if fig.canvas.manager is not None: 86 | managers.append(fig.canvas.manager) 87 | else: 88 | managers.append(_promote_figure(fig, num=None)) 89 | 90 | if block is None: 91 | block = not is_interactive() 92 | 93 | if block and len(managers): 94 | if timeout == 0: 95 | backend.show_managers(managers=managers, block=block) 96 | elif len(managers): 97 | manager, *_ = managers 98 | manager.canvas.start_event_loop(timeout=timeout) 99 | 100 | 101 | class FigureRegistry: 102 | """ 103 | A registry to wrap the creation of figures and track them. 104 | 105 | This instance will keep a hard reference to created Figures to ensure 106 | that they do not get garbage collected. 107 | 108 | Parameters 109 | ---------- 110 | block : bool, optional 111 | Whether to wait for all figures to be closed before returning from 112 | show_all. 113 | 114 | If `True` block and run the GUI main loop until all figure windows 115 | are closed. 116 | 117 | If `False` ensure that all figure windows are displayed and return 118 | immediately. In this case, you are responsible for ensuring 119 | that the event loop is running to have responsive figures. 120 | 121 | Defaults to True in non-interactive mode and to False in interactive 122 | mode (see `.is_interactive`). 123 | 124 | timeout : float, optional 125 | Default time to wait for all of the Figures to be closed if blocking. 126 | 127 | If 0 block forever. 128 | 129 | """ 130 | 131 | def __init__(self, *, block=None, timeout=0, prefix="Figure "): 132 | # settings stashed to set defaults on show 133 | self._timeout = timeout 134 | self._block = block 135 | # the canonical location for storing the Figures this registry owns. 136 | # any additional views must never include a figure that is not a key but 137 | # may omit figures 138 | self._fig_to_number = dict() 139 | # Settings / state to control the default figure label 140 | self._prefix = prefix 141 | 142 | @property 143 | def figures(self): 144 | return tuple(self._fig_to_number) 145 | 146 | def _register_fig(self, fig): 147 | # if the user closes the figure by any other mechanism, drop our 148 | # reference to it. This is important for getting a "pyplot" like user 149 | # experience 150 | def registry_cleanup(fig_wr): 151 | fig = fig_wr() 152 | if fig is not None: 153 | if fig.canvas is not None: 154 | fig.canvas.mpl_disconnect(cid) 155 | self.close(fig) 156 | 157 | fig_wr = weakref.ref(fig) 158 | cid = fig.canvas.mpl_connect("close_event", lambda e: registry_cleanup(fig_wr)) 159 | # Make sure we give the figure a quasi-unique label. We will never set 160 | # the same label twice, but will not over-ride any user label (but 161 | # empty string) on a Figure so if they provide duplicate labels, change 162 | # the labels under us, or provide a label that will be shadowed in the 163 | # future it will be what it is. 164 | fignum = max(self._fig_to_number.values(), default=-1) + 1 165 | if fig.get_label() == "": 166 | fig.set_label(f"{self._prefix}{fignum:d}") 167 | self._fig_to_number[fig] = fignum 168 | if is_interactive(): 169 | _promote_figure(fig, num=fignum) 170 | return fig 171 | 172 | @property 173 | def by_label(self): 174 | """ 175 | Return a dictionary of the current mapping labels -> figures. 176 | 177 | If there are duplicate labels, newer figures will take precedence. 178 | """ 179 | mapping = {fig.get_label(): fig for fig in self.figures} 180 | if len(mapping) != len(self.figures): 181 | counts = Counter(fig.get_label() for fig in self.figures) 182 | multiples = {k: v for k, v in counts.items() if v > 1} 183 | warnings.warn( 184 | ( 185 | f"There are repeated labels ({multiples!r}), but only the newest figure with that label can " 186 | "be returned. " 187 | ), 188 | stacklevel=2, 189 | ) 190 | return mapping 191 | 192 | @property 193 | def by_number(self): 194 | """ 195 | Return a dictionary of the current mapping number -> figures. 196 | 197 | """ 198 | self._ensure_all_figures_promoted() 199 | return {fig.canvas.manager.num: fig for fig in self.figures} 200 | 201 | @functools.wraps(figure) 202 | def figure(self, *args, **kwargs): 203 | fig = figure(*args, **kwargs) 204 | return self._register_fig(fig) 205 | 206 | @functools.wraps(subplots) 207 | def subplots(self, *args, **kwargs): 208 | fig, axs = subplots(*args, **kwargs) 209 | return self._register_fig(fig), axs 210 | 211 | @functools.wraps(subplot_mosaic) 212 | def subplot_mosaic(self, *args, **kwargs): 213 | fig, axd = subplot_mosaic(*args, **kwargs) 214 | return self._register_fig(fig), axd 215 | 216 | def _ensure_all_figures_promoted(self): 217 | for f in self.figures: 218 | if f.canvas.manager is None: 219 | _promote_figure(f, num=self._fig_to_number[f]) 220 | 221 | def show_all(self, *, block=None, timeout=None): 222 | """ 223 | Show all of the Figures that the FigureRegistry knows about. 224 | 225 | Parameters 226 | ---------- 227 | block : bool, optional 228 | Whether to wait for all figures to be closed before returning from 229 | show_all. 230 | 231 | If `True` block and run the GUI main loop until all figure windows 232 | are closed. 233 | 234 | If `False` ensure that all figure windows are displayed and return 235 | immediately. In this case, you are responsible for ensuring 236 | that the event loop is running to have responsive figures. 237 | 238 | Defaults to the value set on the Registry at init 239 | 240 | timeout : float, optional 241 | time to wait for all of the Figures to be closed if blocking. 242 | 243 | If 0 block forever. 244 | 245 | Defaults to the timeout set on the Registry at init 246 | """ 247 | if block is None: 248 | block = self._block 249 | 250 | if timeout is None: 251 | timeout = self._timeout 252 | self._ensure_all_figures_promoted() 253 | display(*self.figures, block=self._block, timeout=timeout) 254 | 255 | # alias to easy pyplot compatibility 256 | show = show_all 257 | 258 | def close_all(self): 259 | """ 260 | Close all Figures know to this Registry. 261 | 262 | This will do four things: 263 | 264 | 1. call the ``.destory()`` method on the manager 265 | 2. clears the Figure on the canvas instance 266 | 3. replace the canvas on each Figure with a new `~matplotlib.backend_bases.FigureCanvasBase` instance 267 | 4. drops its hard reference to the Figure 268 | 269 | If the user still holds a reference to the Figure it can be revived by 270 | passing it to `mpl_gui.display`. 271 | 272 | """ 273 | for fig in list(self.figures): 274 | self.close(fig) 275 | 276 | def close(self, val): 277 | """ 278 | Close (meaning destroy the UI) and forget a managed Figure. 279 | 280 | This will do two things: 281 | 282 | - start the destruction process of an UI (the event loop may need to 283 | run to complete this process and if the user is holding hard 284 | references to any of the UI elements they may remain alive). 285 | - Remove the `~matplotlib.figure.Figure` from this Registry. 286 | 287 | We will no longer have any hard references to the Figure, but if 288 | the user does the `~matplotlib.figure.Figure` (and its components) will not be garbage 289 | collected. Due to the circular references in Matplotlib these 290 | objects may not be collected until the full cyclic garbage collection 291 | runs. 292 | 293 | If the user still has a reference to the `~matplotlib.figure.Figure` they can re-show the 294 | figure via `show`, but the `.FigureRegistry` will not be aware of it. 295 | 296 | Parameters 297 | ---------- 298 | val : 'all' or int or str or Figure 299 | 300 | - The special case of 'all' closes all open Figures 301 | - If any other string is passed, it is interpreted as a key in 302 | `by_label` and that Figure is closed 303 | - If an integer it is interpreted as a key in `.FigureRegistry.by_number` and that 304 | Figure is closed 305 | - If it is a `~matplotlib.figure.Figure` instance, then that figure is closed 306 | 307 | """ 308 | if val == "all": 309 | self.close_all() 310 | return 311 | # or do we want to close _all_ of the figures with a given label / number? 312 | if isinstance(val, str): 313 | fig = self.by_label[val] 314 | elif isinstance(val, int): 315 | fig = self.by_number[val] 316 | else: 317 | fig = val 318 | if fig not in self.figures: 319 | raise ValueError( 320 | "Trying to close a figure not associated with this Registry." 321 | ) 322 | if fig.canvas.manager is not None: 323 | fig.canvas.manager.destroy() 324 | # disconnect figure from canvas 325 | fig.canvas.figure = None 326 | # disconnect canvas from figure 327 | _FigureCanvasBase(figure=fig) 328 | assert fig.canvas.manager is None 329 | self._fig_to_number.pop(fig, None) 330 | return 331 | 332 | 333 | class FigureContext(FigureRegistry): 334 | """ 335 | Extends FigureRegistry to be used as a context manger. 336 | 337 | All figures known to the Registry will be shown on exiting the context. 338 | 339 | Parameters 340 | ---------- 341 | block : bool, optional 342 | Whether to wait for all figures to be closed before returning from 343 | show_all. 344 | 345 | If `True` block and run the GUI main loop until all figure windows 346 | are closed. 347 | 348 | If `False` ensure that all figure windows are displayed and return 349 | immediately. In this case, you are responsible for ensuring 350 | that the event loop is running to have responsive figures. 351 | 352 | Defaults to True in non-interactive mode and to False in interactive 353 | mode (see `.is_interactive`). 354 | 355 | timeout : float, optional 356 | Default time to wait for all of the Figures to be closed if blocking. 357 | 358 | If 0 block forever. 359 | 360 | forgive_failure : bool, optional 361 | If True, block to show the figure before letting the exception 362 | propagate 363 | 364 | """ 365 | 366 | def __init__(self, *, forgive_failure=False, **kwargs): 367 | super().__init__(**kwargs) 368 | self._forgive_failure = forgive_failure 369 | 370 | def __enter__(self): 371 | return self 372 | 373 | def __exit__(self, exc_type, exc_value, traceback): 374 | if exc_value is not None and not self._forgive_failure: 375 | return 376 | display(*self.figures, block=self._block, timeout=self._timeout) 377 | 378 | 379 | # from mpl_gui import * # is a langauge miss-feature 380 | __all__ = [] 381 | -------------------------------------------------------------------------------- /mpl_gui/_creation.py: -------------------------------------------------------------------------------- 1 | """Helpers to create new Figures.""" 2 | 3 | from ._figure import Figure 4 | 5 | 6 | def figure( 7 | *, 8 | label=None, # autoincrement if None, else integer from 1-N 9 | figsize=None, # defaults to rc figure.figsize 10 | dpi=None, # defaults to rc figure.dpi 11 | facecolor=None, # defaults to rc figure.facecolor 12 | edgecolor=None, # defaults to rc figure.edgecolor 13 | frameon=True, 14 | FigureClass=Figure, 15 | **kwargs, 16 | ): 17 | """ 18 | Create a new figure 19 | 20 | Parameters 21 | ---------- 22 | label : str, optional 23 | Label for the figure. Will be used as the window title 24 | 25 | figsize : (float, float), default: :rc:`figure.figsize` 26 | Width, height in inches. 27 | 28 | dpi : float, default: :rc:`figure.dpi` 29 | The resolution of the figure in dots-per-inch. 30 | 31 | facecolor : color, default: :rc:`figure.facecolor` 32 | The background color. 33 | 34 | edgecolor : color, default: :rc:`figure.edgecolor` 35 | The border color. 36 | 37 | frameon : bool, default: True 38 | If False, suppress drawing the figure frame. 39 | 40 | FigureClass : subclass of `~matplotlib.figure.Figure` 41 | Optionally use a custom `~matplotlib.figure.Figure` instance. 42 | 43 | tight_layout : bool or dict, default: :rc:`figure.autolayout` 44 | If ``False`` use *subplotpars*. If ``True`` adjust subplot parameters 45 | using `~matplotlib.figure.Figure.tight_layout` with default padding. 46 | When providing a dict containing the keys ``pad``, ``w_pad``, 47 | ``h_pad``, and ``rect``, the default 48 | `~matplotlib.figure.Figure.tight_layout` paddings will be overridden. 49 | 50 | **kwargs : optional 51 | See `~.matplotlib.figure.Figure` for other possible arguments. 52 | 53 | Returns 54 | ------- 55 | `~matplotlib.figure.Figure` 56 | The `~matplotlib.figure.Figure` instance returned will also be passed 57 | to new_figure_manager in the backends, which allows to hook custom 58 | `~matplotlib.figure.Figure` classes into the pyplot 59 | interface. Additional kwargs will be passed to the 60 | `~matplotlib.figure.Figure` init function. 61 | 62 | """ 63 | 64 | fig = FigureClass( 65 | label=label, 66 | figsize=figsize, 67 | dpi=dpi, 68 | facecolor=facecolor, 69 | edgecolor=edgecolor, 70 | frameon=frameon, 71 | **kwargs, 72 | ) 73 | return fig 74 | 75 | 76 | def subplots( 77 | nrows=1, 78 | ncols=1, 79 | *, 80 | sharex=False, 81 | sharey=False, 82 | squeeze=True, 83 | subplot_kw=None, 84 | gridspec_kw=None, 85 | **fig_kw, 86 | ): 87 | """ 88 | Create a figure and a set of subplots. 89 | 90 | This utility wrapper makes it convenient to create common layouts of 91 | subplots, including the enclosing figure object, in a single call. 92 | 93 | Parameters 94 | ---------- 95 | nrows, ncols : int, default: 1 96 | Number of rows/columns of the subplot grid. 97 | 98 | sharex, sharey : bool or {'none', 'all', 'row', 'col'}, default: False 99 | Controls sharing of properties among x (*sharex*) or y (*sharey*) 100 | axes: 101 | 102 | - True or 'all': x- or y-axis will be shared among all subplots. 103 | - False or 'none': each subplot x- or y-axis will be independent. 104 | - 'row': each subplot row will share an x- or y-axis. 105 | - 'col': each subplot column will share an x- or y-axis. 106 | 107 | When subplots have a shared x-axis along a column, only the x tick 108 | labels of the bottom subplot are created. Similarly, when subplots 109 | have a shared y-axis along a row, only the y tick labels of the first 110 | column subplot are created. To later turn other subplots' ticklabels 111 | on, use `~matplotlib.axes.Axes.tick_params`. 112 | 113 | When subplots have a shared axis that has units, calling 114 | `~matplotlib.axis.Axis.set_units` will update each axis with the 115 | new units. 116 | 117 | squeeze : bool, default: True 118 | - If True, extra dimensions are squeezed out from the returned 119 | array of `~matplotlib.axes.Axes`: 120 | 121 | - if only one subplot is constructed (nrows=ncols=1), the 122 | resulting single Axes object is returned as a scalar. 123 | - for Nx1 or 1xM subplots, the returned object is a 1D numpy 124 | object array of Axes objects. 125 | - for NxM, subplots with N>1 and M>1 are returned as a 2D array. 126 | 127 | - If False, no squeezing at all is done: the returned Axes object is 128 | always a 2D array containing Axes instances, even if it ends up 129 | being 1x1. 130 | 131 | subplot_kw : dict, optional 132 | Dict with keywords passed to the 133 | `~matplotlib.figure.Figure.add_subplot` call used to create each 134 | subplot. 135 | 136 | gridspec_kw : dict, optional 137 | Dict with keywords passed to the `~matplotlib.gridspec.GridSpec` 138 | constructor used to create the grid the subplots are placed on. 139 | 140 | **fig_kw 141 | All additional keyword arguments are passed to the 142 | `.figure` call. 143 | 144 | Returns 145 | ------- 146 | fig : `~matplotlib.figure.Figure` 147 | 148 | ax : `~matplotlib.axes.Axes` or array of Axes 149 | *ax* can be either a single `~matplotlib.axes.Axes` object or an 150 | array of Axes objects if more than one subplot was created. The 151 | dimensions of the resulting array can be controlled with the squeeze 152 | keyword, see above. 153 | 154 | Typical idioms for handling the return value are:: 155 | 156 | # using the variable ax for single a Axes 157 | fig, ax = plt.subplots() 158 | 159 | # using the variable axs for multiple Axes 160 | fig, axs = plt.subplots(2, 2) 161 | 162 | # using tuple unpacking for multiple Axes 163 | fig, (ax1, ax2) = plt.subplots(1, 2) 164 | fig, ((ax1, ax2), (ax3, ax4)) = plt.subplots(2, 2) 165 | 166 | The names ``ax`` and pluralized ``axs`` are preferred over ``axes`` 167 | because for the latter it's not clear if it refers to a single 168 | `~matplotlib.axes.Axes` instance or a collection of these. 169 | 170 | See Also 171 | -------- 172 | 173 | matplotlib.figure.Figure.subplots 174 | matplotlib.figure.Figure.add_subplot 175 | 176 | Examples 177 | -------- 178 | :: 179 | 180 | # First create some toy data: 181 | x = np.linspace(0, 2*np.pi, 400) 182 | y = np.sin(x**2) 183 | 184 | # Create just a figure and only one subplot 185 | fig, ax = plt.subplots() 186 | ax.plot(x, y) 187 | ax.set_title('Simple plot') 188 | 189 | # Create two subplots and unpack the output array immediately 190 | f, (ax1, ax2) = plt.subplots(1, 2, sharey=True) 191 | ax1.plot(x, y) 192 | ax1.set_title('Sharing Y axis') 193 | ax2.scatter(x, y) 194 | 195 | # Create four polar axes and access them through the returned array 196 | fig, axs = plt.subplots(2, 2, subplot_kw=dict(projection="polar")) 197 | axs[0, 0].plot(x, y) 198 | axs[1, 1].scatter(x, y) 199 | 200 | # Share a X axis with each column of subplots 201 | plt.subplots(2, 2, sharex='col') 202 | 203 | # Share a Y axis with each row of subplots 204 | plt.subplots(2, 2, sharey='row') 205 | 206 | # Share both X and Y axes with all subplots 207 | plt.subplots(2, 2, sharex='all', sharey='all') 208 | 209 | # Note that this is the same as 210 | plt.subplots(2, 2, sharex=True, sharey=True) 211 | 212 | """ 213 | fig = figure(**fig_kw) 214 | axs = fig.subplots( 215 | nrows=nrows, 216 | ncols=ncols, 217 | sharex=sharex, 218 | sharey=sharey, 219 | squeeze=squeeze, 220 | subplot_kw=subplot_kw, 221 | gridspec_kw=gridspec_kw, 222 | ) 223 | return fig, axs 224 | 225 | 226 | def subplot_mosaic( 227 | layout, *, subplot_kw=None, gridspec_kw=None, empty_sentinel=".", **fig_kw 228 | ): 229 | """ 230 | Build a layout of Axes based on ASCII art or nested lists. 231 | 232 | This is a helper function to build complex `~matplotlib.gridspec.GridSpec` 233 | layouts visually. 234 | 235 | .. note :: 236 | 237 | This API is provisional and may be revised in the future based on 238 | early user feedback. 239 | 240 | 241 | Parameters 242 | ---------- 243 | layout : list of list of {hashable or nested} or str 244 | 245 | A visual layout of how you want your Axes to be arranged 246 | labeled as strings. For example :: 247 | 248 | x = [['A panel', 'A panel', 'edge'], 249 | ['C panel', '.', 'edge']] 250 | 251 | Produces 4 axes: 252 | 253 | - 'A panel' which is 1 row high and spans the first two columns 254 | - 'edge' which is 2 rows high and is on the right edge 255 | - 'C panel' which in 1 row and 1 column wide in the bottom left 256 | - a blank space 1 row and 1 column wide in the bottom center 257 | 258 | Any of the entries in the layout can be a list of lists 259 | of the same form to create nested layouts. 260 | 261 | If input is a str, then it must be of the form :: 262 | 263 | ''' 264 | AAE 265 | C.E 266 | ''' 267 | 268 | where each character is a column and each line is a row. 269 | This only allows only single character Axes labels and does 270 | not allow nesting but is very terse. 271 | 272 | subplot_kw : dict, optional 273 | Dictionary with keywords passed to the `~matplotlib.figure.Figure.add_subplot` call 274 | used to create each subplot. 275 | 276 | gridspec_kw : dict, optional 277 | Dictionary with keywords passed to the `~matplotlib.gridspec.GridSpec` constructor used 278 | to create the grid the subplots are placed on. 279 | 280 | empty_sentinel : object, optional 281 | Entry in the layout to mean "leave this space empty". Defaults 282 | to ``'.'``. Note, if *layout* is a string, it is processed via 283 | `inspect.cleandoc` to remove leading white space, which may 284 | interfere with using white-space as the empty sentinel. 285 | 286 | **fig_kw 287 | All additional keyword arguments are passed to the 288 | `.figure` call. 289 | 290 | Returns 291 | ------- 292 | fig : `~matplotlib.figure.Figure` 293 | The new figure 294 | 295 | dict[label, Axes] 296 | A dictionary mapping the labels to the Axes objects. The order of 297 | the axes is left-to-right and top-to-bottom of their position in the 298 | total layout. 299 | 300 | """ 301 | fig = figure(**fig_kw) 302 | ax_dict = fig.subplot_mosaic( 303 | layout, 304 | subplot_kw=subplot_kw, 305 | gridspec_kw=gridspec_kw, 306 | empty_sentinel=empty_sentinel, 307 | ) 308 | return fig, ax_dict 309 | -------------------------------------------------------------------------------- /mpl_gui/_figure.py: -------------------------------------------------------------------------------- 1 | """Locally patch Figure to accept a label kwarg.""" 2 | 3 | 4 | from matplotlib.figure import Figure as _Figure 5 | 6 | 7 | class Figure(_Figure): 8 | """Thin sub-class of Figure to accept a label on init.""" 9 | 10 | def __init__(self, *args, label=None, **kwargs): 11 | # docstring inherited 12 | super().__init__(*args, **kwargs) 13 | if label is not None: 14 | self.set_label(label) 15 | -------------------------------------------------------------------------------- /mpl_gui/_manage_backend.py: -------------------------------------------------------------------------------- 1 | import importlib 2 | import sys 3 | import logging 4 | import types 5 | 6 | from matplotlib import cbook, rcsetup 7 | from matplotlib import rcParams, rcParamsDefault 8 | from matplotlib.backends.registry import backend_registry 9 | import matplotlib.backend_bases 10 | 11 | 12 | _backend_mod = None 13 | 14 | _log = logging.getLogger(__name__) 15 | 16 | 17 | def current_backend_module(): 18 | """ 19 | Get the currently active backend module, selecting one if needed. 20 | 21 | Returns 22 | ------- 23 | matplotlib.backend_bases._Backend 24 | """ 25 | if _backend_mod is None: 26 | select_gui_toolkit() 27 | return _backend_mod 28 | 29 | 30 | def select_gui_toolkit(newbackend=None): 31 | """ 32 | Select the GUI toolkit to use. 33 | 34 | The argument is case-insensitive. Switching between GUI toolkits is 35 | possible only if no event loop for another interactive backend has started. 36 | Switching to and from non-interactive backends is always possible. 37 | 38 | Parameters 39 | ---------- 40 | newbackend : Union[str, _Backend] 41 | The name of the backend to use or a _Backend class to use. 42 | 43 | Returns 44 | ------- 45 | _Backend 46 | The backend selected. 47 | 48 | """ 49 | global _backend_mod 50 | 51 | # work-around the sentinel resolution in Matplotlib 😱 52 | if newbackend is None: 53 | newbackend = dict.__getitem__(rcParams, "backend") 54 | 55 | if newbackend is rcsetup._auto_backend_sentinel: 56 | current_framework = cbook._get_running_interactive_framework() 57 | mapping = { 58 | "qt": "qtagg", 59 | "gtk3": "gtk3agg", 60 | "gtk4": "gtk4agg", 61 | "wx": "wxagg", 62 | "tk": "tkagg", 63 | "macosx": "macosx", 64 | "headless": "agg", 65 | } 66 | 67 | best_guess = mapping.get(current_framework, None) 68 | if best_guess is not None: 69 | candidates = [best_guess] 70 | else: 71 | candidates = [] 72 | candidates += ["macosx", "qtagg", "gtk3agg", "tkagg", "wxagg"] 73 | 74 | # Don't try to fallback on the cairo-based backends as they each have 75 | # an additional dependency (pycairo) over the agg-based backend, and 76 | # are of worse quality. 77 | for candidate in candidates: 78 | try: 79 | return select_gui_toolkit(candidate) 80 | except ImportError: 81 | continue 82 | 83 | # Switching to Agg should always succeed; if it doesn't, let the 84 | # exception propagate out. 85 | return select_gui_toolkit("agg") 86 | 87 | if isinstance(newbackend, str): 88 | # Backends are implemented as modules, but "inherit" default method 89 | # implementations from backend_bases._Backend. This is achieved by 90 | # creating a "class" that inherits from backend_bases._Backend and whose 91 | # body is filled with the module's globals. 92 | 93 | if newbackend.lower() == "tkagg": 94 | backend_name = f"mpl_gui._patched_backends.{newbackend.lower()}" 95 | else: 96 | backend_name = backend_registry._backend_module_name(newbackend) 97 | 98 | mod = importlib.import_module(backend_name) 99 | if hasattr(mod, "Backend"): 100 | orig_class = mod.Backend 101 | 102 | else: 103 | 104 | class orig_class(matplotlib.backend_bases._Backend): 105 | locals().update(vars(mod)) 106 | 107 | @classmethod 108 | def mainloop(cls): 109 | return mod.Show().mainloop() 110 | 111 | class BackendClass(orig_class): 112 | @classmethod 113 | def show_managers(cls, *, managers, block): 114 | if not managers: 115 | return 116 | for manager in managers: 117 | manager.show() # Emits a warning for non-interactive backend 118 | manager.canvas.draw_idle() 119 | if cls.mainloop is None: 120 | return 121 | if block: 122 | try: 123 | cls.FigureManager._active_managers = managers 124 | cls.mainloop() 125 | finally: 126 | cls.FigureManager._active_managers = None 127 | 128 | if not hasattr(BackendClass.FigureManager, "_active_managers"): 129 | BackendClass.FigureManager._active_managers = None 130 | rc_params_string = newbackend 131 | 132 | else: 133 | BackendClass = newbackend 134 | mod_name = f"_backend_mod_{id(BackendClass)}" 135 | rc_params_string = f"module://{mod_name}" 136 | mod = types.ModuleType(mod_name) 137 | mod.Backend = BackendClass 138 | sys.modules[mod_name] = mod 139 | 140 | required_framework = getattr( 141 | BackendClass.FigureCanvas, "required_interactive_framework", None 142 | ) 143 | if required_framework is not None: 144 | current_framework = cbook._get_running_interactive_framework() 145 | if ( 146 | current_framework 147 | and required_framework 148 | and current_framework != required_framework 149 | ): 150 | raise ImportError( 151 | "Cannot load backend {!r} which requires the {!r} interactive " 152 | "framework, as {!r} is currently running".format( 153 | newbackend, required_framework, current_framework 154 | ) 155 | ) 156 | 157 | _log.debug( 158 | "Loaded backend %s version %s.", newbackend, BackendClass.backend_version 159 | ) 160 | 161 | rcParams["backend"] = rcParamsDefault["backend"] = rc_params_string 162 | 163 | # is IPython imported? 164 | mod_ipython = sys.modules.get("IPython") 165 | if mod_ipython: 166 | # if so are we in an IPython session 167 | ip = mod_ipython.get_ipython() 168 | if ip: 169 | # macosx -> osx mapping for the osx backend in ipython 170 | if required_framework == "macosx": 171 | required_framework = "osx" 172 | ip.enable_gui(required_framework) 173 | 174 | # remember to set the global variable 175 | _backend_mod = BackendClass 176 | return BackendClass 177 | -------------------------------------------------------------------------------- /mpl_gui/_manage_interactive.py: -------------------------------------------------------------------------------- 1 | """Module for managing if we are "interactive" or not.""" 2 | from matplotlib import is_interactive as _is_interact, interactive as _interactive 3 | 4 | 5 | def is_interactive(): 6 | """ 7 | Return whether plots are updated after every plotting command. 8 | 9 | The interactive mode is mainly useful if you build plots from the command 10 | line and want to see the effect of each command while you are building the 11 | figure. 12 | 13 | In interactive mode: 14 | 15 | - newly created figures will be shown immediately; 16 | - figures will automatically redraw on change; 17 | - `.display` will not block by default. 18 | - `mpl_gui.FigureContext` will not block on ``__exit__`` by default. 19 | 20 | In non-interactive mode: 21 | 22 | - newly created figures and changes to figures will not be reflected until 23 | explicitly asked to be; 24 | - `.display` will block by default. 25 | - `mpl_gui.FigureContext` will block on ``__exit__`` by default. 26 | 27 | See Also 28 | -------- 29 | ion : Enable interactive mode. 30 | ioff : Disable interactive mode. 31 | mpl_gui.display : Show all figures (and maybe block). 32 | """ 33 | return _is_interact() 34 | 35 | 36 | class _IoffContext: 37 | """ 38 | Context manager for `.ioff`. 39 | 40 | The state is changed in ``__init__()`` instead of ``__enter__()``. The 41 | latter is a no-op. This allows using `.ioff` both as a function and 42 | as a context. 43 | """ 44 | 45 | def __init__(self): 46 | self.wasinteractive = is_interactive() 47 | _interactive(False) 48 | 49 | def __enter__(self): 50 | pass 51 | 52 | def __exit__(self, exc_type, exc_value, traceback): 53 | if self.wasinteractive: 54 | _interactive(True) 55 | else: 56 | _interactive(False) 57 | 58 | 59 | class _IonContext: 60 | """ 61 | Context manager for `.ion`. 62 | 63 | The state is changed in ``__init__()`` instead of ``__enter__()``. The 64 | latter is a no-op. This allows using `.ion` both as a function and 65 | as a context. 66 | """ 67 | 68 | def __init__(self): 69 | self.wasinteractive = is_interactive() 70 | _interactive(True) 71 | 72 | def __enter__(self): 73 | pass 74 | 75 | def __exit__(self, exc_type, exc_value, traceback): 76 | if not self.wasinteractive: 77 | _interactive(False) 78 | else: 79 | _interactive(True) 80 | 81 | 82 | def ioff(): 83 | """ 84 | Disable interactive mode. 85 | 86 | See `.is_interactive` for more details. 87 | 88 | See Also 89 | -------- 90 | ion : Enable interactive mode. 91 | is_interactive : Whether interactive mode is enabled. 92 | mpl_gui.display : Show all figures (and maybe block). 93 | 94 | Notes 95 | ----- 96 | For a temporary change, this can be used as a context manager:: 97 | 98 | # if interactive mode is on 99 | # then figures will be shown on creation 100 | mg.ion() 101 | # This figure will be shown immediately 102 | fig = mg.figure() 103 | 104 | with mg.ioff(): 105 | # interactive mode will be off 106 | # figures will not automatically be shown 107 | fig2 = mg.figure() 108 | # ... 109 | 110 | To enable usage as a context manager, this function returns an 111 | ``_IoffContext`` object. The return value is not intended to be stored 112 | or accessed by the user. 113 | """ 114 | return _IoffContext() 115 | 116 | 117 | def ion(): 118 | """ 119 | Enable interactive mode. 120 | 121 | See `.is_interactive` for more details. 122 | 123 | See Also 124 | -------- 125 | ioff : Disable interactive mode. 126 | is_interactive : Whether interactive mode is enabled. 127 | mpl_gui.display : Show all figures (and maybe block). 128 | 129 | Notes 130 | ----- 131 | For a temporary change, this can be used as a context manager:: 132 | 133 | # if interactive mode is off 134 | # then figures will not be shown on creation 135 | mg.ioff() 136 | # This figure will not be shown immediately 137 | fig = mg.figure() 138 | 139 | with mg.ion(): 140 | # interactive mode will be on 141 | # figures will automatically be shown 142 | fig2 = mg.figure() 143 | # ... 144 | 145 | To enable usage as a context manager, this function returns an 146 | ``_IonContext`` object. The return value is not intended to be stored 147 | or accessed by the user. 148 | """ 149 | return _IonContext() 150 | -------------------------------------------------------------------------------- /mpl_gui/_patched_backends/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/matplotlib/mpl-gui/465a7cb16f1068a2efecfc6dac53ab5aae9228c2/mpl_gui/_patched_backends/__init__.py -------------------------------------------------------------------------------- /mpl_gui/_patched_backends/tkagg.py: -------------------------------------------------------------------------------- 1 | from contextlib import contextmanager 2 | 3 | import matplotlib as mpl 4 | from matplotlib import _c_internal_utils 5 | from matplotlib.backends.backend_tkagg import ( 6 | _BackendTkAgg, 7 | FigureManagerTk as _FigureManagerTk, 8 | ) 9 | 10 | 11 | @contextmanager 12 | def _restore_foreground_window_at_end(): 13 | foreground = _c_internal_utils.Win32_GetForegroundWindow() 14 | try: 15 | yield 16 | finally: 17 | if mpl.rcParams["tk.window_focus"]: 18 | _c_internal_utils.Win32_SetForegroundWindow(foreground) 19 | 20 | 21 | class FigureManagerTk(_FigureManagerTk): 22 | _active_managers = None 23 | 24 | def show(self): 25 | with _restore_foreground_window_at_end(): 26 | if not self._shown: 27 | self.window.protocol("WM_DELETE_WINDOW", self.destroy) 28 | self.window.deiconify() 29 | self.canvas._tkcanvas.focus_set() 30 | else: 31 | self.canvas.draw_idle() 32 | if mpl.rcParams["figure.raise_window"]: 33 | self.canvas.manager.window.attributes("-topmost", 1) 34 | self.canvas.manager.window.attributes("-topmost", 0) 35 | self._shown = True 36 | 37 | def destroy(self, *args): 38 | if self.canvas._idle_draw_id: 39 | self.canvas._tkcanvas.after_cancel(self.canvas._idle_draw_id) 40 | if self.canvas._event_loop_id: 41 | self.canvas._tkcanvas.after_cancel(self.canvas._event_loop_id) 42 | 43 | # NOTE: events need to be flushed before issuing destroy (GH #9956), 44 | # however, self.window.update() can break user code. This is the 45 | # safest way to achieve a complete draining of the event queue, 46 | # but it may require users to update() on their own to execute the 47 | # completion in obscure corner cases. 48 | def delayed_destroy(): 49 | self.window.destroy() 50 | 51 | if self._owns_mainloop and not self._active_managers: 52 | self.window.quit() 53 | 54 | # "after idle after 0" avoids Tcl error/race (GH #19940) 55 | self.window.after_idle(self.window.after, 0, delayed_destroy) 56 | 57 | 58 | @_BackendTkAgg.export 59 | class _PatchedBackendTkAgg(_BackendTkAgg): 60 | @classmethod 61 | def mainloop(cls): 62 | managers = cls.FigureManager._active_managers 63 | if managers: 64 | first_manager = managers[0] 65 | manager_class = type(first_manager) 66 | if manager_class._owns_mainloop: 67 | return 68 | manager_class._owns_mainloop = True 69 | try: 70 | first_manager.window.mainloop() 71 | finally: 72 | manager_class._owns_mainloop = False 73 | 74 | FigureManager = FigureManagerTk 75 | 76 | 77 | Backend = _PatchedBackendTkAgg 78 | -------------------------------------------------------------------------------- /mpl_gui/_promotion.py: -------------------------------------------------------------------------------- 1 | """State and logic to promote a Figure -> a GUI window.""" 2 | 3 | import threading 4 | import itertools 5 | 6 | import matplotlib as mpl 7 | from matplotlib import is_interactive 8 | from matplotlib.cbook import _api 9 | from matplotlib.backend_bases import FigureCanvasBase 10 | from ._manage_backend import current_backend_module 11 | 12 | 13 | _figure_count = itertools.count() 14 | 15 | 16 | def _auto_draw_if_interactive(fig, val): 17 | """ 18 | An internal helper function for making sure that auto-redrawing 19 | works as intended in the plain python repl. 20 | 21 | Parameters 22 | ---------- 23 | fig : Figure 24 | A figure object which is assumed to be associated with a canvas 25 | """ 26 | if ( 27 | val 28 | and is_interactive() 29 | and not fig.canvas.is_saving() 30 | and not fig.canvas._is_idle_drawing 31 | ): 32 | # Some artists can mark themselves as stale in the middle of drawing 33 | # (e.g. axes position & tick labels being computed at draw time), but 34 | # this shouldn't trigger a redraw because the current redraw will 35 | # already take them into account. 36 | with fig.canvas._idle_draw_cntx(): 37 | fig.canvas.draw_idle() 38 | 39 | 40 | def promote_figure(fig, *, auto_draw=True, num): 41 | """Create a new figure manager instance.""" 42 | _backend_mod = current_backend_module() 43 | if ( 44 | getattr(_backend_mod.FigureCanvas, "required_interactive_framework", None) 45 | and threading.current_thread() is not threading.main_thread() 46 | ): 47 | _api.warn_external( 48 | "Starting a Matplotlib GUI outside of the main thread will likely fail." 49 | ) 50 | 51 | if fig.canvas.manager is not None: 52 | if not isinstance(fig.canvas.manager, _backend_mod.FigureManager): 53 | raise Exception("Figure already has a manager an it is the wrong type!") 54 | else: 55 | # TODO is this the right behavior? 56 | return fig.canvas.manager 57 | # TODO: do we want to make sure we poison / destroy / decouple the existing 58 | # canavs? 59 | next_num = next(_figure_count) 60 | manager = _backend_mod.new_figure_manager_given_figure( 61 | num if num is not None else next_num, fig 62 | ) 63 | if fig.get_label(): 64 | manager.set_window_title(fig.get_label()) 65 | 66 | if auto_draw: 67 | fig.stale_callback = _auto_draw_if_interactive 68 | 69 | if is_interactive(): 70 | manager.show() 71 | fig.canvas.draw_idle() 72 | 73 | # HACK: the callback in backend_bases uses GCF.destroy which misses these 74 | # figures by design! 75 | def _destroy_on_hotkey(event): 76 | if event.key in mpl.rcParams["keymap.quit"]: 77 | # grab the manager off the event 78 | mgr = event.canvas.manager 79 | if mgr is None: 80 | raise RuntimeError("Should never be here, please report a bug.") 81 | # close the window 82 | mgr.destroy() 83 | 84 | # remove this callback. Callbacks live on the Figure so survive the canvas 85 | # being replaced. 86 | fig._destroy_cid = fig.canvas.mpl_connect("key_press_event", _destroy_on_hotkey) 87 | 88 | return manager 89 | 90 | 91 | def demote_figure(fig): 92 | """Fully clear all GUI elements from the `~matplotlib.figure.Figure`. 93 | 94 | The opposite of what is done during `mpl_gui.display`. 95 | 96 | Parameters 97 | ---------- 98 | fig : matplotlib.figure.Figure 99 | 100 | """ 101 | fig.canvas.destroy() 102 | fig.canvas.manager = None 103 | original_dpi = getattr(fig, "_original_dpi", fig.dpi) 104 | if (cid := getattr(fig, '_destroy_cid', None)) is not None: 105 | fig.canvas.mpl_disconnect(cid) 106 | FigureCanvasBase(fig) 107 | fig.dpi = original_dpi 108 | 109 | return fig 110 | -------------------------------------------------------------------------------- /mpl_gui/_version.py: -------------------------------------------------------------------------------- 1 | 2 | # This file helps to compute a version number in source trees obtained from 3 | # git-archive tarball (such as those provided by githubs download-from-tag 4 | # feature). Distribution tarballs (built by setup.py sdist) and build 5 | # directories (produced by setup.py build) will contain a much shorter file 6 | # that just contains the computed version number. 7 | 8 | # This file is released into the public domain. Generated by 9 | # versioneer-0.18 (https://github.com/warner/python-versioneer) 10 | 11 | """Git implementation of _version.py.""" 12 | 13 | import errno 14 | import os 15 | import re 16 | import subprocess 17 | import sys 18 | 19 | 20 | def get_keywords(): 21 | """Get the keywords needed to look up the version information.""" 22 | # these strings will be replaced by git during git-archive. 23 | # setup.py/versioneer.py will grep for the variable names, so they must 24 | # each be defined on a line of their own. _version.py will just call 25 | # get_keywords(). 26 | git_refnames = " (HEAD -> main)" 27 | git_full = "465a7cb16f1068a2efecfc6dac53ab5aae9228c2" 28 | git_date = "2024-12-28 21:17:27 -0500" 29 | keywords = {"refnames": git_refnames, "full": git_full, "date": git_date} 30 | return keywords 31 | 32 | 33 | class VersioneerConfig: 34 | """Container for Versioneer configuration parameters.""" 35 | 36 | 37 | def get_config(): 38 | """Create, populate and return the VersioneerConfig() object.""" 39 | # these strings are filled in when 'setup.py versioneer' creates 40 | # _version.py 41 | cfg = VersioneerConfig() 42 | cfg.VCS = "git" 43 | cfg.style = "pep440-post" 44 | cfg.tag_prefix = "v" 45 | cfg.parentdir_prefix = "None" 46 | cfg.versionfile_source = "mpl_gui/_version.py" 47 | cfg.verbose = False 48 | return cfg 49 | 50 | 51 | class NotThisMethod(Exception): 52 | """Exception raised if a method is not valid for the current scenario.""" 53 | 54 | 55 | LONG_VERSION_PY = {} 56 | HANDLERS = {} 57 | 58 | 59 | def register_vcs_handler(vcs, method): # decorator 60 | """Decorator to mark a method as the handler for a particular VCS.""" 61 | def decorate(f): 62 | """Store f in HANDLERS[vcs][method].""" 63 | if vcs not in HANDLERS: 64 | HANDLERS[vcs] = {} 65 | HANDLERS[vcs][method] = f 66 | return f 67 | return decorate 68 | 69 | 70 | def run_command(commands, args, cwd=None, verbose=False, hide_stderr=False, 71 | env=None): 72 | """Call the given command(s).""" 73 | assert isinstance(commands, list) 74 | p = None 75 | for c in commands: 76 | try: 77 | dispcmd = str([c] + args) 78 | # remember shell=False, so use git.cmd on windows, not just git 79 | p = subprocess.Popen([c] + args, cwd=cwd, env=env, 80 | stdout=subprocess.PIPE, 81 | stderr=(subprocess.PIPE if hide_stderr 82 | else None)) 83 | break 84 | except EnvironmentError: 85 | e = sys.exc_info()[1] 86 | if e.errno == errno.ENOENT: 87 | continue 88 | if verbose: 89 | print("unable to run %s" % dispcmd) 90 | print(e) 91 | return None, None 92 | else: 93 | if verbose: 94 | print("unable to find command, tried %s" % (commands,)) 95 | return None, None 96 | stdout = p.communicate()[0].strip() 97 | if sys.version_info[0] >= 3: 98 | stdout = stdout.decode() 99 | if p.returncode != 0: 100 | if verbose: 101 | print("unable to run %s (error)" % dispcmd) 102 | print("stdout was %s" % stdout) 103 | return None, p.returncode 104 | return stdout, p.returncode 105 | 106 | 107 | def versions_from_parentdir(parentdir_prefix, root, verbose): 108 | """Try to determine the version from the parent directory name. 109 | 110 | Source tarballs conventionally unpack into a directory that includes both 111 | the project name and a version string. We will also support searching up 112 | two directory levels for an appropriately named parent directory 113 | """ 114 | rootdirs = [] 115 | 116 | for i in range(3): 117 | dirname = os.path.basename(root) 118 | if dirname.startswith(parentdir_prefix): 119 | return {"version": dirname[len(parentdir_prefix):], 120 | "full-revisionid": None, 121 | "dirty": False, "error": None, "date": None} 122 | else: 123 | rootdirs.append(root) 124 | root = os.path.dirname(root) # up a level 125 | 126 | if verbose: 127 | print("Tried directories %s but none started with prefix %s" % 128 | (str(rootdirs), parentdir_prefix)) 129 | raise NotThisMethod("rootdir doesn't start with parentdir_prefix") 130 | 131 | 132 | @register_vcs_handler("git", "get_keywords") 133 | def git_get_keywords(versionfile_abs): 134 | """Extract version information from the given file.""" 135 | # the code embedded in _version.py can just fetch the value of these 136 | # keywords. When used from setup.py, we don't want to import _version.py, 137 | # so we do it with a regexp instead. This function is not used from 138 | # _version.py. 139 | keywords = {} 140 | try: 141 | f = open(versionfile_abs, "r") 142 | for line in f.readlines(): 143 | if line.strip().startswith("git_refnames ="): 144 | mo = re.search(r'=\s*"(.*)"', line) 145 | if mo: 146 | keywords["refnames"] = mo.group(1) 147 | if line.strip().startswith("git_full ="): 148 | mo = re.search(r'=\s*"(.*)"', line) 149 | if mo: 150 | keywords["full"] = mo.group(1) 151 | if line.strip().startswith("git_date ="): 152 | mo = re.search(r'=\s*"(.*)"', line) 153 | if mo: 154 | keywords["date"] = mo.group(1) 155 | f.close() 156 | except EnvironmentError: 157 | pass 158 | return keywords 159 | 160 | 161 | @register_vcs_handler("git", "keywords") 162 | def git_versions_from_keywords(keywords, tag_prefix, verbose): 163 | """Get version information from git keywords.""" 164 | if not keywords: 165 | raise NotThisMethod("no keywords at all, weird") 166 | date = keywords.get("date") 167 | if date is not None: 168 | # git-2.2.0 added "%cI", which expands to an ISO-8601 -compliant 169 | # datestamp. However we prefer "%ci" (which expands to an "ISO-8601 170 | # -like" string, which we must then edit to make compliant), because 171 | # it's been around since git-1.5.3, and it's too difficult to 172 | # discover which version we're using, or to work around using an 173 | # older one. 174 | date = date.strip().replace(" ", "T", 1).replace(" ", "", 1) 175 | refnames = keywords["refnames"].strip() 176 | if refnames.startswith("$Format"): 177 | if verbose: 178 | print("keywords are unexpanded, not using") 179 | raise NotThisMethod("unexpanded keywords, not a git-archive tarball") 180 | refs = set([r.strip() for r in refnames.strip("()").split(",")]) 181 | # starting in git-1.8.3, tags are listed as "tag: foo-1.0" instead of 182 | # just "foo-1.0". If we see a "tag: " prefix, prefer those. 183 | TAG = "tag: " 184 | tags = set([r[len(TAG):] for r in refs if r.startswith(TAG)]) 185 | if not tags: 186 | # Either we're using git < 1.8.3, or there really are no tags. We use 187 | # a heuristic: assume all version tags have a digit. The old git %d 188 | # expansion behaves like git log --decorate=short and strips out the 189 | # refs/heads/ and refs/tags/ prefixes that would let us distinguish 190 | # between branches and tags. By ignoring refnames without digits, we 191 | # filter out many common branch names like "release" and 192 | # "stabilization", as well as "HEAD" and "master". 193 | tags = set([r for r in refs if re.search(r'\d', r)]) 194 | if verbose: 195 | print("discarding '%s', no digits" % ",".join(refs - tags)) 196 | if verbose: 197 | print("likely tags: %s" % ",".join(sorted(tags))) 198 | for ref in sorted(tags): 199 | # sorting will prefer e.g. "2.0" over "2.0rc1" 200 | if ref.startswith(tag_prefix): 201 | r = ref[len(tag_prefix):] 202 | if verbose: 203 | print("picking %s" % r) 204 | return {"version": r, 205 | "full-revisionid": keywords["full"].strip(), 206 | "dirty": False, "error": None, 207 | "date": date} 208 | # no suitable tags, so version is "0+unknown", but full hex is still there 209 | if verbose: 210 | print("no suitable tags, using unknown + full revision id") 211 | return {"version": "0+unknown", 212 | "full-revisionid": keywords["full"].strip(), 213 | "dirty": False, "error": "no suitable tags", "date": None} 214 | 215 | 216 | @register_vcs_handler("git", "pieces_from_vcs") 217 | def git_pieces_from_vcs(tag_prefix, root, verbose, run_command=run_command): 218 | """Get version from 'git describe' in the root of the source tree. 219 | 220 | This only gets called if the git-archive 'subst' keywords were *not* 221 | expanded, and _version.py hasn't already been rewritten with a short 222 | version string, meaning we're inside a checked out source tree. 223 | """ 224 | GITS = ["git"] 225 | if sys.platform == "win32": 226 | GITS = ["git.cmd", "git.exe"] 227 | 228 | out, rc = run_command(GITS, ["rev-parse", "--git-dir"], cwd=root, 229 | hide_stderr=True) 230 | if rc != 0: 231 | if verbose: 232 | print("Directory %s not under git control" % root) 233 | raise NotThisMethod("'git rev-parse --git-dir' returned error") 234 | 235 | # if there is a tag matching tag_prefix, this yields TAG-NUM-gHEX[-dirty] 236 | # if there isn't one, this yields HEX[-dirty] (no NUM) 237 | describe_out, rc = run_command(GITS, ["describe", "--tags", "--dirty", 238 | "--always", "--long", 239 | "--match", "%s*" % tag_prefix], 240 | cwd=root) 241 | # --long was added in git-1.5.5 242 | if describe_out is None: 243 | raise NotThisMethod("'git describe' failed") 244 | describe_out = describe_out.strip() 245 | full_out, rc = run_command(GITS, ["rev-parse", "HEAD"], cwd=root) 246 | if full_out is None: 247 | raise NotThisMethod("'git rev-parse' failed") 248 | full_out = full_out.strip() 249 | 250 | pieces = {} 251 | pieces["long"] = full_out 252 | pieces["short"] = full_out[:7] # maybe improved later 253 | pieces["error"] = None 254 | 255 | # parse describe_out. It will be like TAG-NUM-gHEX[-dirty] or HEX[-dirty] 256 | # TAG might have hyphens. 257 | git_describe = describe_out 258 | 259 | # look for -dirty suffix 260 | dirty = git_describe.endswith("-dirty") 261 | pieces["dirty"] = dirty 262 | if dirty: 263 | git_describe = git_describe[:git_describe.rindex("-dirty")] 264 | 265 | # now we have TAG-NUM-gHEX or HEX 266 | 267 | if "-" in git_describe: 268 | # TAG-NUM-gHEX 269 | mo = re.search(r'^(.+)-(\d+)-g([0-9a-f]+)$', git_describe) 270 | if not mo: 271 | # unparseable. Maybe git-describe is misbehaving? 272 | pieces["error"] = ("unable to parse git-describe output: '%s'" 273 | % describe_out) 274 | return pieces 275 | 276 | # tag 277 | full_tag = mo.group(1) 278 | if not full_tag.startswith(tag_prefix): 279 | if verbose: 280 | fmt = "tag '%s' doesn't start with prefix '%s'" 281 | print(fmt % (full_tag, tag_prefix)) 282 | pieces["error"] = ("tag '%s' doesn't start with prefix '%s'" 283 | % (full_tag, tag_prefix)) 284 | return pieces 285 | pieces["closest-tag"] = full_tag[len(tag_prefix):] 286 | 287 | # distance: number of commits since tag 288 | pieces["distance"] = int(mo.group(2)) 289 | 290 | # commit: short hex revision ID 291 | pieces["short"] = mo.group(3) 292 | 293 | else: 294 | # HEX: no tags 295 | pieces["closest-tag"] = None 296 | count_out, rc = run_command(GITS, ["rev-list", "HEAD", "--count"], 297 | cwd=root) 298 | pieces["distance"] = int(count_out) # total number of commits 299 | 300 | # commit date: see ISO-8601 comment in git_versions_from_keywords() 301 | date = run_command(GITS, ["show", "-s", "--format=%ci", "HEAD"], 302 | cwd=root)[0].strip() 303 | pieces["date"] = date.strip().replace(" ", "T", 1).replace(" ", "", 1) 304 | 305 | return pieces 306 | 307 | 308 | def plus_or_dot(pieces): 309 | """Return a + if we don't already have one, else return a .""" 310 | if "+" in pieces.get("closest-tag", ""): 311 | return "." 312 | return "+" 313 | 314 | 315 | def render_pep440(pieces): 316 | """Build up version string, with post-release "local version identifier". 317 | 318 | Our goal: TAG[+DISTANCE.gHEX[.dirty]] . Note that if you 319 | get a tagged build and then dirty it, you'll get TAG+0.gHEX.dirty 320 | 321 | Exceptions: 322 | 1: no tags. git_describe was just HEX. 0+untagged.DISTANCE.gHEX[.dirty] 323 | """ 324 | if pieces["closest-tag"]: 325 | rendered = pieces["closest-tag"] 326 | if pieces["distance"] or pieces["dirty"]: 327 | rendered += plus_or_dot(pieces) 328 | rendered += "%d.g%s" % (pieces["distance"], pieces["short"]) 329 | if pieces["dirty"]: 330 | rendered += ".dirty" 331 | else: 332 | # exception #1 333 | rendered = "0+untagged.%d.g%s" % (pieces["distance"], 334 | pieces["short"]) 335 | if pieces["dirty"]: 336 | rendered += ".dirty" 337 | return rendered 338 | 339 | 340 | def render_pep440_pre(pieces): 341 | """TAG[.post.devDISTANCE] -- No -dirty. 342 | 343 | Exceptions: 344 | 1: no tags. 0.post.devDISTANCE 345 | """ 346 | if pieces["closest-tag"]: 347 | rendered = pieces["closest-tag"] 348 | if pieces["distance"]: 349 | rendered += ".post.dev%d" % pieces["distance"] 350 | else: 351 | # exception #1 352 | rendered = "0.post.dev%d" % pieces["distance"] 353 | return rendered 354 | 355 | 356 | def render_pep440_post(pieces): 357 | """TAG[.postDISTANCE[.dev0]+gHEX] . 358 | 359 | The ".dev0" means dirty. Note that .dev0 sorts backwards 360 | (a dirty tree will appear "older" than the corresponding clean one), 361 | but you shouldn't be releasing software with -dirty anyways. 362 | 363 | Exceptions: 364 | 1: no tags. 0.postDISTANCE[.dev0] 365 | """ 366 | if pieces["closest-tag"]: 367 | rendered = pieces["closest-tag"] 368 | if pieces["distance"] or pieces["dirty"]: 369 | rendered += ".post%d" % pieces["distance"] 370 | if pieces["dirty"]: 371 | rendered += ".dev0" 372 | rendered += plus_or_dot(pieces) 373 | rendered += "g%s" % pieces["short"] 374 | else: 375 | # exception #1 376 | rendered = "0.post%d" % pieces["distance"] 377 | if pieces["dirty"]: 378 | rendered += ".dev0" 379 | rendered += "+g%s" % pieces["short"] 380 | return rendered 381 | 382 | 383 | def render_pep440_old(pieces): 384 | """TAG[.postDISTANCE[.dev0]] . 385 | 386 | The ".dev0" means dirty. 387 | 388 | Eexceptions: 389 | 1: no tags. 0.postDISTANCE[.dev0] 390 | """ 391 | if pieces["closest-tag"]: 392 | rendered = pieces["closest-tag"] 393 | if pieces["distance"] or pieces["dirty"]: 394 | rendered += ".post%d" % pieces["distance"] 395 | if pieces["dirty"]: 396 | rendered += ".dev0" 397 | else: 398 | # exception #1 399 | rendered = "0.post%d" % pieces["distance"] 400 | if pieces["dirty"]: 401 | rendered += ".dev0" 402 | return rendered 403 | 404 | 405 | def render_git_describe(pieces): 406 | """TAG[-DISTANCE-gHEX][-dirty]. 407 | 408 | Like 'git describe --tags --dirty --always'. 409 | 410 | Exceptions: 411 | 1: no tags. HEX[-dirty] (note: no 'g' prefix) 412 | """ 413 | if pieces["closest-tag"]: 414 | rendered = pieces["closest-tag"] 415 | if pieces["distance"]: 416 | rendered += "-%d-g%s" % (pieces["distance"], pieces["short"]) 417 | else: 418 | # exception #1 419 | rendered = pieces["short"] 420 | if pieces["dirty"]: 421 | rendered += "-dirty" 422 | return rendered 423 | 424 | 425 | def render_git_describe_long(pieces): 426 | """TAG-DISTANCE-gHEX[-dirty]. 427 | 428 | Like 'git describe --tags --dirty --always -long'. 429 | The distance/hash is unconditional. 430 | 431 | Exceptions: 432 | 1: no tags. HEX[-dirty] (note: no 'g' prefix) 433 | """ 434 | if pieces["closest-tag"]: 435 | rendered = pieces["closest-tag"] 436 | rendered += "-%d-g%s" % (pieces["distance"], pieces["short"]) 437 | else: 438 | # exception #1 439 | rendered = pieces["short"] 440 | if pieces["dirty"]: 441 | rendered += "-dirty" 442 | return rendered 443 | 444 | 445 | def render(pieces, style): 446 | """Render the given version pieces into the requested style.""" 447 | if pieces["error"]: 448 | return {"version": "unknown", 449 | "full-revisionid": pieces.get("long"), 450 | "dirty": None, 451 | "error": pieces["error"], 452 | "date": None} 453 | 454 | if not style or style == "default": 455 | style = "pep440" # the default 456 | 457 | if style == "pep440": 458 | rendered = render_pep440(pieces) 459 | elif style == "pep440-pre": 460 | rendered = render_pep440_pre(pieces) 461 | elif style == "pep440-post": 462 | rendered = render_pep440_post(pieces) 463 | elif style == "pep440-old": 464 | rendered = render_pep440_old(pieces) 465 | elif style == "git-describe": 466 | rendered = render_git_describe(pieces) 467 | elif style == "git-describe-long": 468 | rendered = render_git_describe_long(pieces) 469 | else: 470 | raise ValueError("unknown style '%s'" % style) 471 | 472 | return {"version": rendered, "full-revisionid": pieces["long"], 473 | "dirty": pieces["dirty"], "error": None, 474 | "date": pieces.get("date")} 475 | 476 | 477 | def get_versions(): 478 | """Get version information or return default if unable to do so.""" 479 | # I am in _version.py, which lives at ROOT/VERSIONFILE_SOURCE. If we have 480 | # __file__, we can work backwards from there to the root. Some 481 | # py2exe/bbfreeze/non-CPython implementations don't do __file__, in which 482 | # case we can only use expanded keywords. 483 | 484 | cfg = get_config() 485 | verbose = cfg.verbose 486 | 487 | try: 488 | return git_versions_from_keywords(get_keywords(), cfg.tag_prefix, 489 | verbose) 490 | except NotThisMethod: 491 | pass 492 | 493 | try: 494 | root = os.path.realpath(__file__) 495 | # versionfile_source is the relative path from the top of the source 496 | # tree (where the .git directory might live) to this file. Invert 497 | # this to find the root from __file__. 498 | for i in cfg.versionfile_source.split('/'): 499 | root = os.path.dirname(root) 500 | except NameError: 501 | return {"version": "0+unknown", "full-revisionid": None, 502 | "dirty": None, 503 | "error": "unable to find root of source tree", 504 | "date": None} 505 | 506 | try: 507 | pieces = git_pieces_from_vcs(cfg.tag_prefix, root, verbose) 508 | return render(pieces, cfg.style) 509 | except NotThisMethod: 510 | pass 511 | 512 | try: 513 | if cfg.parentdir_prefix: 514 | return versions_from_parentdir(cfg.parentdir_prefix, root, verbose) 515 | except NotThisMethod: 516 | pass 517 | 518 | return {"version": "0+unknown", "full-revisionid": None, 519 | "dirty": None, 520 | "error": "unable to compute version", "date": None} 521 | -------------------------------------------------------------------------------- /mpl_gui/global_figures.py: -------------------------------------------------------------------------------- 1 | """Reproduces the module-level pyplot UX for Figure management.""" 2 | 3 | from . import FigureRegistry as _FigureRegistry 4 | from ._manage_interactive import ( 5 | ion as ion, 6 | ioff as ioff, 7 | is_interactive as is_interactive, 8 | ) 9 | 10 | _fr = _FigureRegistry() 11 | 12 | _fr_exports = [ 13 | "figure", 14 | "subplots", 15 | "subplot_mosaic", 16 | "by_label", 17 | "show", 18 | "show_all", 19 | "close", 20 | "close_all", 21 | ] 22 | 23 | for k in _fr_exports: 24 | locals()[k] = getattr(_fr, k) 25 | 26 | 27 | def get_figlabels(): 28 | return list(_fr.by_label) 29 | 30 | 31 | def get_fignums(): 32 | return sorted(_fr.by_number) 33 | 34 | 35 | # if one must. `from foo import *` is a language miss-feature, but provide 36 | # sensible behavior anyway. 37 | __all__ = _fr_exports + [ 38 | "ion", 39 | "ioff", 40 | "is_interactive", 41 | ] 42 | -------------------------------------------------------------------------------- /mpl_gui/tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/matplotlib/mpl-gui/465a7cb16f1068a2efecfc6dac53ab5aae9228c2/mpl_gui/tests/__init__.py -------------------------------------------------------------------------------- /mpl_gui/tests/conftest.py: -------------------------------------------------------------------------------- 1 | from matplotlib.backend_bases import ( 2 | _Backend, 3 | FigureCanvasBase, 4 | FigureManagerBase, 5 | ShowBase, 6 | ) 7 | import mpl_gui 8 | import sys 9 | 10 | 11 | def pytest_configure(config): 12 | # config is initialized here rather than in pytest.ini so that `pytest 13 | # --pyargs matplotlib` (which would not find pytest.ini) works. The only 14 | # entries in pytest.ini set minversion (which is checked earlier), 15 | # testpaths/python_files, as they are required to properly find the tests 16 | for key, value in [ 17 | ("filterwarnings", "error"), 18 | ]: 19 | config.addinivalue_line(key, value) 20 | 21 | # make sure we do not sneakily get pyplot 22 | assert sys.modules.get("matplotlib.pyplot") is None 23 | sys.modules["matplotlib.pyplot"] = None 24 | 25 | 26 | class TestManger(FigureManagerBase): 27 | _active_managers = None 28 | 29 | def __init__(self, *args, **kwargs): 30 | super().__init__(*args, **kwargs) 31 | self.call_info = {} 32 | 33 | def show(self): 34 | self.call_info["show"] = {} 35 | 36 | def destroy(self): 37 | self.call_info["destroy"] = {} 38 | 39 | 40 | class TestCanvas(FigureCanvasBase): 41 | manager_class = TestManger 42 | 43 | def __init__(self, *args, **kwargs): 44 | super().__init__(*args, **kwargs) 45 | self.call_info = {} 46 | 47 | def start_event_loop(self, timeout=0): 48 | self.call_info["start_event_loop"] = {"timeout": timeout} 49 | 50 | 51 | class TestShow(ShowBase): 52 | def mainloop(self): 53 | ... 54 | 55 | 56 | class TestingBackend(_Backend): 57 | FigureCanvas = TestCanvas 58 | FigureManager = TestManger 59 | Show = TestShow 60 | 61 | @classmethod 62 | def mainloop(cls): 63 | ... 64 | 65 | @classmethod 66 | def show_managers(cls, *, managers, block): 67 | for m in managers: 68 | m.show() 69 | 70 | 71 | mpl_gui.select_gui_toolkit(TestingBackend) 72 | -------------------------------------------------------------------------------- /mpl_gui/tests/test_examples.py: -------------------------------------------------------------------------------- 1 | import sys 2 | 3 | import pytest 4 | 5 | from matplotlib.backend_bases import FigureCanvasBase 6 | 7 | import mpl_gui as mg 8 | 9 | 10 | def test_no_pyplot(): 11 | assert sys.modules.get("matplotlib.pyplot", None) is None 12 | 13 | 14 | def test_promotion(): 15 | fig = mg.Figure(label="test") 16 | assert fig.canvas.manager is None 17 | mg.display(*[fig], block=False) 18 | assert fig.canvas.manager is not None 19 | 20 | 21 | def test_smoke_test_context(): 22 | with mg.FigureContext(block=False) as fc: 23 | fc.figure() 24 | fc.subplots() 25 | fc.subplot_mosaic("A\nB") 26 | 27 | 28 | def test_ion(): 29 | with mg.ion(): 30 | assert mg.is_interactive() 31 | fig = mg.Figure() 32 | ax = fig.subplots() 33 | (ln,) = ax.plot(range(5)) 34 | ln.set_color("k") 35 | mg.display(*[fig], timeout=1) 36 | assert "start_event_loop" not in fig.canvas.call_info 37 | 38 | 39 | def test_ioff(): 40 | with mg.ioff(): 41 | assert not mg.is_interactive() 42 | 43 | 44 | def test_timeout(): 45 | fig = mg.Figure() 46 | mg.display(*[fig], block=True, timeout=1) 47 | assert "start_event_loop" in fig.canvas.call_info 48 | 49 | 50 | def test_test_context_timeout(): 51 | with mg.FigureContext(block=True, timeout=1) as fc: 52 | fig = fc.figure() 53 | fc.subplots() 54 | fc.subplot_mosaic("A\nB") 55 | assert "start_event_loop" in fig.canvas.call_info 56 | assert fig.canvas.call_info["start_event_loop"]["timeout"] == 1 57 | 58 | 59 | @pytest.mark.parametrize("forgiving", [True, False]) 60 | def test_context_exceptions(forgiving): 61 | class TestException(Exception): 62 | ... 63 | 64 | with pytest.raises(TestException): 65 | with mg.FigureContext(block=True, forgive_failure=forgiving, timeout=1) as fc: 66 | fig = fc.figure() 67 | raise TestException 68 | 69 | if forgiving: 70 | assert "start_event_loop" in fig.canvas.call_info 71 | else: 72 | assert isinstance(fig.canvas, FigureCanvasBase) 73 | 74 | 75 | def test_close_all(): 76 | fr = mg.FigureRegistry(block=False) 77 | fig = fr.figure() 78 | assert fig in fr.figures 79 | assert isinstance(fig.canvas, FigureCanvasBase) 80 | old_canvas = fig.canvas 81 | fr.show_all() 82 | assert fig.canvas is not old_canvas 83 | new_canvas = fig.canvas 84 | fr.close_all() 85 | assert len(fr.figures) == 0 86 | assert "destroy" in new_canvas.manager.call_info 87 | assert fig.canvas is not new_canvas 88 | assert new_canvas.figure is None 89 | 90 | # test revive 91 | old_canvas = fig.canvas 92 | mg.display(fig) 93 | assert fig.canvas is not old_canvas 94 | 95 | 96 | def test_labels_prefix(): 97 | fr = mg.FigureRegistry(block=False, prefix="Aardvark ") 98 | for j in range(5): 99 | fr.figure() 100 | assert list(fr.by_label) == [f"Aardvark {k}" for k in range(j + 1)] 101 | fr.close_all() 102 | assert len(fr.by_label) == 0 103 | 104 | 105 | def test_labels_collision(): 106 | fr = mg.FigureRegistry(block=False) 107 | for j in range(5): 108 | fr.figure(label="aardvark") 109 | with pytest.warns(UserWarning, match="{'aardvark': 5}"): 110 | assert list(fr.by_label) == ["aardvark"] 111 | assert len(fr.figures) == 5 112 | assert len(set(fr.figures)) == 5 113 | with pytest.warns(UserWarning, match="{'aardvark': 5}"): 114 | assert fr.figures[-1] is fr.by_label["aardvark"] 115 | fr.close_all() 116 | assert len(fr.by_label) == 0 117 | 118 | 119 | def test_by_label_new_dict(): 120 | fr = mg.FigureRegistry(block=False) 121 | for j in range(5): 122 | fr.figure() 123 | # test we get a new dict each time! 124 | assert fr.by_label is not fr.by_label 125 | 126 | 127 | def test_change_labels(): 128 | fr = mg.FigureRegistry(block=False) 129 | for j in range(5): 130 | fr.figure() 131 | assert list(fr.by_label) == [f"Figure {j}" for j in range(5)] 132 | 133 | for j, f in enumerate(fr.by_label.values()): 134 | f.set_label(f"aardvark {j}") 135 | assert list(fr.by_label) == [f"aardvark {j}" for j in range(5)] 136 | 137 | 138 | def test_close_one_at_a_time(): 139 | fr = mg.FigureRegistry(block=False) 140 | fig1 = fr.figure(label="a") 141 | fig2 = fr.figure(label="b") 142 | fig3 = fr.figure(label="c") 143 | fr.figure(label="d") 144 | assert len(fr.figures) == 4 145 | 146 | fr.close(fig1) 147 | assert len(fr.figures) == 3 148 | assert fig1 not in fr.figures 149 | 150 | fr.close(fig2.get_label()) 151 | assert len(fr.figures) == 2 152 | assert fig2 not in fr.figures 153 | 154 | fr.show() 155 | 156 | fr.close(fig3.canvas.manager.num) 157 | assert len(fr.figures) == 1 158 | assert fig3 not in fr.figures 159 | 160 | fr.close("all") 161 | assert len(fr.figures) == 0 162 | -------------------------------------------------------------------------------- /requirements-dev.txt: -------------------------------------------------------------------------------- 1 | # These are required for developing the package (running the tests, building 2 | # the documentation) but not necessarily required for _using_ it. 3 | codecov 4 | coverage 5 | flake8 6 | pytest 7 | sphinx 8 | twine 9 | black 10 | # These are dependencies of various sphinx extensions for documentation. 11 | -r requirements-doc.txt 12 | -------------------------------------------------------------------------------- /requirements-doc.txt: -------------------------------------------------------------------------------- 1 | # List required packages in this file, one per line. 2 | ipython 3 | jinja2>3 4 | matplotlib 5 | mpl-sphinx-theme~=3.9.0 6 | numpydoc 7 | sphinx 8 | sphinx-copybutton 9 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | # List required packages in this file, one per line. 2 | matplotlib>3.9 3 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [versioneer] 2 | VCS = git 3 | style = pep440-post 4 | versionfile_source = mpl_gui/_version.py 5 | versionfile_build = mpl_gui/_version.py 6 | tag_prefix = v 7 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from os import path 2 | from setuptools import setup, find_packages 3 | import sys 4 | import versioneer 5 | 6 | 7 | # NOTE: This file must remain Python 2 compatible for the foreseeable future, 8 | # to ensure that we error out properly for people with outdated setuptools 9 | # and/or pip. 10 | min_version = (3, 7) 11 | if sys.version_info < min_version: 12 | error = """ 13 | mpl-gui does not support Python {0}.{1}. 14 | Python {2}.{3} and above is required. Check your Python version like so: 15 | 16 | python3 --version 17 | 18 | This may be due to an out-of-date pip. Make sure you have pip >= 9.0.1. 19 | Upgrade pip like so: 20 | 21 | pip install --upgrade pip 22 | """.format(*(sys.version_info[:2] + min_version)) 23 | sys.exit(error) 24 | 25 | here = path.abspath(path.dirname(__file__)) 26 | 27 | with open(path.join(here, 'README.rst'), encoding='utf-8') as readme_file: 28 | readme = readme_file.read() 29 | 30 | with open(path.join(here, 'requirements.txt')) as requirements_file: 31 | # Parse requirements.txt, ignoring any commented-out lines. 32 | requirements = [line for line in requirements_file.read().splitlines() 33 | if not line.startswith('#')] 34 | 35 | 36 | setup( 37 | name='mpl-gui', 38 | version=versioneer.get_version(), 39 | cmdclass=versioneer.get_cmdclass(), 40 | description="Prototype project for splitting pyplot in half", 41 | long_description=readme, 42 | author="Thomas A Caswell", 43 | author_email='tcaswell@gmail.com', 44 | url='https://github.com/matplotlib/mpl-gui', 45 | python_requires='>={}'.format('.'.join(str(n) for n in min_version)), 46 | packages=find_packages(exclude=['docs', 'tests']), 47 | entry_points={ 48 | 'console_scripts': [ 49 | # 'command = some.module:some_function', 50 | ], 51 | }, 52 | include_package_data=True, 53 | package_data={ 54 | 'mpl_gui': [ 55 | # When adding files here, remember to update MANIFEST.in as well, 56 | # or else they will not be included in the distribution on PyPI! 57 | # 'path/to/data_file', 58 | ] 59 | }, 60 | install_requires=requirements, 61 | license="BSD (3-clause)", 62 | classifiers=[ 63 | 'Development Status :: 2 - Pre-Alpha', 64 | 'Natural Language :: English', 65 | 'Programming Language :: Python :: 3', 66 | 'Framework :: Matplotlib', 67 | ], 68 | ) 69 | -------------------------------------------------------------------------------- /versioneer.py: -------------------------------------------------------------------------------- 1 | 2 | # Version: 0.18 3 | 4 | """The Versioneer - like a rocketeer, but for versions. 5 | 6 | The Versioneer 7 | ============== 8 | 9 | * like a rocketeer, but for versions! 10 | * https://github.com/warner/python-versioneer 11 | * Brian Warner 12 | * License: Public Domain 13 | * Compatible With: python2.6, 2.7, 3.2, 3.3, 3.4, 3.5, 3.6, and pypy 14 | * [![Latest Version] 15 | (https://pypip.in/version/versioneer/badge.svg?style=flat) 16 | ](https://pypi.python.org/pypi/versioneer/) 17 | * [![Build Status] 18 | (https://travis-ci.org/warner/python-versioneer.png?branch=master) 19 | ](https://travis-ci.org/warner/python-versioneer) 20 | 21 | This is a tool for managing a recorded version number in distutils-based 22 | python projects. The goal is to remove the tedious and error-prone "update 23 | the embedded version string" step from your release process. Making a new 24 | release should be as easy as recording a new tag in your version-control 25 | system, and maybe making new tarballs. 26 | 27 | 28 | ## Quick Install 29 | 30 | * `pip install versioneer` to somewhere to your $PATH 31 | * add a `[versioneer]` section to your setup.cfg (see below) 32 | * run `versioneer install` in your source tree, commit the results 33 | 34 | ## Version Identifiers 35 | 36 | Source trees come from a variety of places: 37 | 38 | * a version-control system checkout (mostly used by developers) 39 | * a nightly tarball, produced by build automation 40 | * a snapshot tarball, produced by a web-based VCS browser, like github's 41 | "tarball from tag" feature 42 | * a release tarball, produced by "setup.py sdist", distributed through PyPI 43 | 44 | Within each source tree, the version identifier (either a string or a number, 45 | this tool is format-agnostic) can come from a variety of places: 46 | 47 | * ask the VCS tool itself, e.g. "git describe" (for checkouts), which knows 48 | about recent "tags" and an absolute revision-id 49 | * the name of the directory into which the tarball was unpacked 50 | * an expanded VCS keyword ($Id$, etc) 51 | * a `_version.py` created by some earlier build step 52 | 53 | For released software, the version identifier is closely related to a VCS 54 | tag. Some projects use tag names that include more than just the version 55 | string (e.g. "myproject-1.2" instead of just "1.2"), in which case the tool 56 | needs to strip the tag prefix to extract the version identifier. For 57 | unreleased software (between tags), the version identifier should provide 58 | enough information to help developers recreate the same tree, while also 59 | giving them an idea of roughly how old the tree is (after version 1.2, before 60 | version 1.3). Many VCS systems can report a description that captures this, 61 | for example `git describe --tags --dirty --always` reports things like 62 | "0.7-1-g574ab98-dirty" to indicate that the checkout is one revision past the 63 | 0.7 tag, has a unique revision id of "574ab98", and is "dirty" (it has 64 | uncommitted changes. 65 | 66 | The version identifier is used for multiple purposes: 67 | 68 | * to allow the module to self-identify its version: `myproject.__version__` 69 | * to choose a name and prefix for a 'setup.py sdist' tarball 70 | 71 | ## Theory of Operation 72 | 73 | Versioneer works by adding a special `_version.py` file into your source 74 | tree, where your `__init__.py` can import it. This `_version.py` knows how to 75 | dynamically ask the VCS tool for version information at import time. 76 | 77 | `_version.py` also contains `$Revision$` markers, and the installation 78 | process marks `_version.py` to have this marker rewritten with a tag name 79 | during the `git archive` command. As a result, generated tarballs will 80 | contain enough information to get the proper version. 81 | 82 | To allow `setup.py` to compute a version too, a `versioneer.py` is added to 83 | the top level of your source tree, next to `setup.py` and the `setup.cfg` 84 | that configures it. This overrides several distutils/setuptools commands to 85 | compute the version when invoked, and changes `setup.py build` and `setup.py 86 | sdist` to replace `_version.py` with a small static file that contains just 87 | the generated version data. 88 | 89 | ## Installation 90 | 91 | See [INSTALL.md](./INSTALL.md) for detailed installation instructions. 92 | 93 | ## Version-String Flavors 94 | 95 | Code which uses Versioneer can learn about its version string at runtime by 96 | importing `_version` from your main `__init__.py` file and running the 97 | `get_versions()` function. From the "outside" (e.g. in `setup.py`), you can 98 | import the top-level `versioneer.py` and run `get_versions()`. 99 | 100 | Both functions return a dictionary with different flavors of version 101 | information: 102 | 103 | * `['version']`: A condensed version string, rendered using the selected 104 | style. This is the most commonly used value for the project's version 105 | string. The default "pep440" style yields strings like `0.11`, 106 | `0.11+2.g1076c97`, or `0.11+2.g1076c97.dirty`. See the "Styles" section 107 | below for alternative styles. 108 | 109 | * `['full-revisionid']`: detailed revision identifier. For Git, this is the 110 | full SHA1 commit id, e.g. "1076c978a8d3cfc70f408fe5974aa6c092c949ac". 111 | 112 | * `['date']`: Date and time of the latest `HEAD` commit. For Git, it is the 113 | commit date in ISO 8601 format. This will be None if the date is not 114 | available. 115 | 116 | * `['dirty']`: a boolean, True if the tree has uncommitted changes. Note that 117 | this is only accurate if run in a VCS checkout, otherwise it is likely to 118 | be False or None 119 | 120 | * `['error']`: if the version string could not be computed, this will be set 121 | to a string describing the problem, otherwise it will be None. It may be 122 | useful to throw an exception in setup.py if this is set, to avoid e.g. 123 | creating tarballs with a version string of "unknown". 124 | 125 | Some variants are more useful than others. Including `full-revisionid` in a 126 | bug report should allow developers to reconstruct the exact code being tested 127 | (or indicate the presence of local changes that should be shared with the 128 | developers). `version` is suitable for display in an "about" box or a CLI 129 | `--version` output: it can be easily compared against release notes and lists 130 | of bugs fixed in various releases. 131 | 132 | The installer adds the following text to your `__init__.py` to place a basic 133 | version in `YOURPROJECT.__version__`: 134 | 135 | from ._version import get_versions 136 | __version__ = get_versions()['version'] 137 | del get_versions 138 | 139 | ## Styles 140 | 141 | The setup.cfg `style=` configuration controls how the VCS information is 142 | rendered into a version string. 143 | 144 | The default style, "pep440", produces a PEP440-compliant string, equal to the 145 | un-prefixed tag name for actual releases, and containing an additional "local 146 | version" section with more detail for in-between builds. For Git, this is 147 | TAG[+DISTANCE.gHEX[.dirty]] , using information from `git describe --tags 148 | --dirty --always`. For example "0.11+2.g1076c97.dirty" indicates that the 149 | tree is like the "1076c97" commit but has uncommitted changes (".dirty"), and 150 | that this commit is two revisions ("+2") beyond the "0.11" tag. For released 151 | software (exactly equal to a known tag), the identifier will only contain the 152 | stripped tag, e.g. "0.11". 153 | 154 | Other styles are available. See [details.md](details.md) in the Versioneer 155 | source tree for descriptions. 156 | 157 | ## Debugging 158 | 159 | Versioneer tries to avoid fatal errors: if something goes wrong, it will tend 160 | to return a version of "0+unknown". To investigate the problem, run `setup.py 161 | version`, which will run the version-lookup code in a verbose mode, and will 162 | display the full contents of `get_versions()` (including the `error` string, 163 | which may help identify what went wrong). 164 | 165 | ## Known Limitations 166 | 167 | Some situations are known to cause problems for Versioneer. This details the 168 | most significant ones. More can be found on Github 169 | [issues page](https://github.com/warner/python-versioneer/issues). 170 | 171 | ### Subprojects 172 | 173 | Versioneer has limited support for source trees in which `setup.py` is not in 174 | the root directory (e.g. `setup.py` and `.git/` are *not* siblings). The are 175 | two common reasons why `setup.py` might not be in the root: 176 | 177 | * Source trees which contain multiple subprojects, such as 178 | [Buildbot](https://github.com/buildbot/buildbot), which contains both 179 | "master" and "slave" subprojects, each with their own `setup.py`, 180 | `setup.cfg`, and `tox.ini`. Projects like these produce multiple PyPI 181 | distributions (and upload multiple independently-installable tarballs). 182 | * Source trees whose main purpose is to contain a C library, but which also 183 | provide bindings to Python (and perhaps other langauges) in subdirectories. 184 | 185 | Versioneer will look for `.git` in parent directories, and most operations 186 | should get the right version string. However `pip` and `setuptools` have bugs 187 | and implementation details which frequently cause `pip install .` from a 188 | subproject directory to fail to find a correct version string (so it usually 189 | defaults to `0+unknown`). 190 | 191 | `pip install --editable .` should work correctly. `setup.py install` might 192 | work too. 193 | 194 | Pip-8.1.1 is known to have this problem, but hopefully it will get fixed in 195 | some later version. 196 | 197 | [Bug #38](https://github.com/warner/python-versioneer/issues/38) is tracking 198 | this issue. The discussion in 199 | [PR #61](https://github.com/warner/python-versioneer/pull/61) describes the 200 | issue from the Versioneer side in more detail. 201 | [pip PR#3176](https://github.com/pypa/pip/pull/3176) and 202 | [pip PR#3615](https://github.com/pypa/pip/pull/3615) contain work to improve 203 | pip to let Versioneer work correctly. 204 | 205 | Versioneer-0.16 and earlier only looked for a `.git` directory next to the 206 | `setup.cfg`, so subprojects were completely unsupported with those releases. 207 | 208 | ### Editable installs with setuptools <= 18.5 209 | 210 | `setup.py develop` and `pip install --editable .` allow you to install a 211 | project into a virtualenv once, then continue editing the source code (and 212 | test) without re-installing after every change. 213 | 214 | "Entry-point scripts" (`setup(entry_points={"console_scripts": ..})`) are a 215 | convenient way to specify executable scripts that should be installed along 216 | with the python package. 217 | 218 | These both work as expected when using modern setuptools. When using 219 | setuptools-18.5 or earlier, however, certain operations will cause 220 | `pkg_resources.DistributionNotFound` errors when running the entrypoint 221 | script, which must be resolved by re-installing the package. This happens 222 | when the install happens with one version, then the egg_info data is 223 | regenerated while a different version is checked out. Many setup.py commands 224 | cause egg_info to be rebuilt (including `sdist`, `wheel`, and installing into 225 | a different virtualenv), so this can be surprising. 226 | 227 | [Bug #83](https://github.com/warner/python-versioneer/issues/83) describes 228 | this one, but upgrading to a newer version of setuptools should probably 229 | resolve it. 230 | 231 | ### Unicode version strings 232 | 233 | While Versioneer works (and is continually tested) with both Python 2 and 234 | Python 3, it is not entirely consistent with bytes-vs-unicode distinctions. 235 | Newer releases probably generate unicode version strings on py2. It's not 236 | clear that this is wrong, but it may be surprising for applications when then 237 | write these strings to a network connection or include them in bytes-oriented 238 | APIs like cryptographic checksums. 239 | 240 | [Bug #71](https://github.com/warner/python-versioneer/issues/71) investigates 241 | this question. 242 | 243 | 244 | ## Updating Versioneer 245 | 246 | To upgrade your project to a new release of Versioneer, do the following: 247 | 248 | * install the new Versioneer (`pip install -U versioneer` or equivalent) 249 | * edit `setup.cfg`, if necessary, to include any new configuration settings 250 | indicated by the release notes. See [UPGRADING](./UPGRADING.md) for details. 251 | * re-run `versioneer install` in your source tree, to replace 252 | `SRC/_version.py` 253 | * commit any changed files 254 | 255 | ## Future Directions 256 | 257 | This tool is designed to make it easily extended to other version-control 258 | systems: all VCS-specific components are in separate directories like 259 | src/git/ . The top-level `versioneer.py` script is assembled from these 260 | components by running make-versioneer.py . In the future, make-versioneer.py 261 | will take a VCS name as an argument, and will construct a version of 262 | `versioneer.py` that is specific to the given VCS. It might also take the 263 | configuration arguments that are currently provided manually during 264 | installation by editing setup.py . Alternatively, it might go the other 265 | direction and include code from all supported VCS systems, reducing the 266 | number of intermediate scripts. 267 | 268 | 269 | ## License 270 | 271 | To make Versioneer easier to embed, all its code is dedicated to the public 272 | domain. The `_version.py` that it creates is also in the public domain. 273 | Specifically, both are released under the Creative Commons "Public Domain 274 | Dedication" license (CC0-1.0), as described in 275 | https://creativecommons.org/publicdomain/zero/1.0/ . 276 | 277 | """ 278 | 279 | from __future__ import print_function 280 | try: 281 | import configparser 282 | except ImportError: 283 | import ConfigParser as configparser 284 | import errno 285 | import json 286 | import os 287 | import re 288 | import subprocess 289 | import sys 290 | 291 | 292 | class VersioneerConfig: 293 | """Container for Versioneer configuration parameters.""" 294 | 295 | 296 | def get_root(): 297 | """Get the project root directory. 298 | 299 | We require that all commands are run from the project root, i.e. the 300 | directory that contains setup.py, setup.cfg, and versioneer.py . 301 | """ 302 | root = os.path.realpath(os.path.abspath(os.getcwd())) 303 | setup_py = os.path.join(root, "setup.py") 304 | versioneer_py = os.path.join(root, "versioneer.py") 305 | if not (os.path.exists(setup_py) or os.path.exists(versioneer_py)): 306 | # allow 'python path/to/setup.py COMMAND' 307 | root = os.path.dirname(os.path.realpath(os.path.abspath(sys.argv[0]))) 308 | setup_py = os.path.join(root, "setup.py") 309 | versioneer_py = os.path.join(root, "versioneer.py") 310 | if not (os.path.exists(setup_py) or os.path.exists(versioneer_py)): 311 | err = ("Versioneer was unable to run the project root directory. " 312 | "Versioneer requires setup.py to be executed from " 313 | "its immediate directory (like 'python setup.py COMMAND'), " 314 | "or in a way that lets it use sys.argv[0] to find the root " 315 | "(like 'python path/to/setup.py COMMAND').") 316 | raise VersioneerBadRootError(err) 317 | try: 318 | # Certain runtime workflows (setup.py install/develop in a setuptools 319 | # tree) execute all dependencies in a single python process, so 320 | # "versioneer" may be imported multiple times, and python's shared 321 | # module-import table will cache the first one. So we can't use 322 | # os.path.dirname(__file__), as that will find whichever 323 | # versioneer.py was first imported, even in later projects. 324 | me = os.path.realpath(os.path.abspath(__file__)) 325 | me_dir = os.path.normcase(os.path.splitext(me)[0]) 326 | vsr_dir = os.path.normcase(os.path.splitext(versioneer_py)[0]) 327 | if me_dir != vsr_dir: 328 | print("Warning: build in %s is using versioneer.py from %s" 329 | % (os.path.dirname(me), versioneer_py)) 330 | except NameError: 331 | pass 332 | return root 333 | 334 | 335 | def get_config_from_root(root): 336 | """Read the project setup.cfg file to determine Versioneer config.""" 337 | # This might raise EnvironmentError (if setup.cfg is missing), or 338 | # configparser.NoSectionError (if it lacks a [versioneer] section), or 339 | # configparser.NoOptionError (if it lacks "VCS="). See the docstring at 340 | # the top of versioneer.py for instructions on writing your setup.cfg . 341 | setup_cfg = os.path.join(root, "setup.cfg") 342 | parser = configparser.ConfigParser() 343 | parser.read(setup_cfg) 344 | VCS = parser.get("versioneer", "VCS") # mandatory 345 | 346 | def get(parser, name): 347 | if parser.has_option("versioneer", name): 348 | return parser.get("versioneer", name) 349 | return None 350 | cfg = VersioneerConfig() 351 | cfg.VCS = VCS 352 | cfg.style = get(parser, "style") or "" 353 | cfg.versionfile_source = get(parser, "versionfile_source") 354 | cfg.versionfile_build = get(parser, "versionfile_build") 355 | cfg.tag_prefix = get(parser, "tag_prefix") 356 | if cfg.tag_prefix in ("''", '""'): 357 | cfg.tag_prefix = "" 358 | cfg.parentdir_prefix = get(parser, "parentdir_prefix") 359 | cfg.verbose = get(parser, "verbose") 360 | return cfg 361 | 362 | 363 | class NotThisMethod(Exception): 364 | """Exception raised if a method is not valid for the current scenario.""" 365 | 366 | 367 | # these dictionaries contain VCS-specific tools 368 | LONG_VERSION_PY = {} 369 | HANDLERS = {} 370 | 371 | 372 | def register_vcs_handler(vcs, method): # decorator 373 | """Decorator to mark a method as the handler for a particular VCS.""" 374 | def decorate(f): 375 | """Store f in HANDLERS[vcs][method].""" 376 | if vcs not in HANDLERS: 377 | HANDLERS[vcs] = {} 378 | HANDLERS[vcs][method] = f 379 | return f 380 | return decorate 381 | 382 | 383 | def run_command(commands, args, cwd=None, verbose=False, hide_stderr=False, 384 | env=None): 385 | """Call the given command(s).""" 386 | assert isinstance(commands, list) 387 | p = None 388 | for c in commands: 389 | try: 390 | dispcmd = str([c] + args) 391 | # remember shell=False, so use git.cmd on windows, not just git 392 | p = subprocess.Popen([c] + args, cwd=cwd, env=env, 393 | stdout=subprocess.PIPE, 394 | stderr=(subprocess.PIPE if hide_stderr 395 | else None)) 396 | break 397 | except EnvironmentError: 398 | e = sys.exc_info()[1] 399 | if e.errno == errno.ENOENT: 400 | continue 401 | if verbose: 402 | print("unable to run %s" % dispcmd) 403 | print(e) 404 | return None, None 405 | else: 406 | if verbose: 407 | print("unable to find command, tried %s" % (commands,)) 408 | return None, None 409 | stdout = p.communicate()[0].strip() 410 | if sys.version_info[0] >= 3: 411 | stdout = stdout.decode() 412 | if p.returncode != 0: 413 | if verbose: 414 | print("unable to run %s (error)" % dispcmd) 415 | print("stdout was %s" % stdout) 416 | return None, p.returncode 417 | return stdout, p.returncode 418 | 419 | 420 | LONG_VERSION_PY['git'] = ''' 421 | # This file helps to compute a version number in source trees obtained from 422 | # git-archive tarball (such as those provided by githubs download-from-tag 423 | # feature). Distribution tarballs (built by setup.py sdist) and build 424 | # directories (produced by setup.py build) will contain a much shorter file 425 | # that just contains the computed version number. 426 | 427 | # This file is released into the public domain. Generated by 428 | # versioneer-0.18 (https://github.com/warner/python-versioneer) 429 | 430 | """Git implementation of _version.py.""" 431 | 432 | import errno 433 | import os 434 | import re 435 | import subprocess 436 | import sys 437 | 438 | 439 | def get_keywords(): 440 | """Get the keywords needed to look up the version information.""" 441 | # these strings will be replaced by git during git-archive. 442 | # setup.py/versioneer.py will grep for the variable names, so they must 443 | # each be defined on a line of their own. _version.py will just call 444 | # get_keywords(). 445 | git_refnames = "%(DOLLAR)sFormat:%%d%(DOLLAR)s" 446 | git_full = "%(DOLLAR)sFormat:%%H%(DOLLAR)s" 447 | git_date = "%(DOLLAR)sFormat:%%ci%(DOLLAR)s" 448 | keywords = {"refnames": git_refnames, "full": git_full, "date": git_date} 449 | return keywords 450 | 451 | 452 | class VersioneerConfig: 453 | """Container for Versioneer configuration parameters.""" 454 | 455 | 456 | def get_config(): 457 | """Create, populate and return the VersioneerConfig() object.""" 458 | # these strings are filled in when 'setup.py versioneer' creates 459 | # _version.py 460 | cfg = VersioneerConfig() 461 | cfg.VCS = "git" 462 | cfg.style = "%(STYLE)s" 463 | cfg.tag_prefix = "%(TAG_PREFIX)s" 464 | cfg.parentdir_prefix = "%(PARENTDIR_PREFIX)s" 465 | cfg.versionfile_source = "%(VERSIONFILE_SOURCE)s" 466 | cfg.verbose = False 467 | return cfg 468 | 469 | 470 | class NotThisMethod(Exception): 471 | """Exception raised if a method is not valid for the current scenario.""" 472 | 473 | 474 | LONG_VERSION_PY = {} 475 | HANDLERS = {} 476 | 477 | 478 | def register_vcs_handler(vcs, method): # decorator 479 | """Decorator to mark a method as the handler for a particular VCS.""" 480 | def decorate(f): 481 | """Store f in HANDLERS[vcs][method].""" 482 | if vcs not in HANDLERS: 483 | HANDLERS[vcs] = {} 484 | HANDLERS[vcs][method] = f 485 | return f 486 | return decorate 487 | 488 | 489 | def run_command(commands, args, cwd=None, verbose=False, hide_stderr=False, 490 | env=None): 491 | """Call the given command(s).""" 492 | assert isinstance(commands, list) 493 | p = None 494 | for c in commands: 495 | try: 496 | dispcmd = str([c] + args) 497 | # remember shell=False, so use git.cmd on windows, not just git 498 | p = subprocess.Popen([c] + args, cwd=cwd, env=env, 499 | stdout=subprocess.PIPE, 500 | stderr=(subprocess.PIPE if hide_stderr 501 | else None)) 502 | break 503 | except EnvironmentError: 504 | e = sys.exc_info()[1] 505 | if e.errno == errno.ENOENT: 506 | continue 507 | if verbose: 508 | print("unable to run %%s" %% dispcmd) 509 | print(e) 510 | return None, None 511 | else: 512 | if verbose: 513 | print("unable to find command, tried %%s" %% (commands,)) 514 | return None, None 515 | stdout = p.communicate()[0].strip() 516 | if sys.version_info[0] >= 3: 517 | stdout = stdout.decode() 518 | if p.returncode != 0: 519 | if verbose: 520 | print("unable to run %%s (error)" %% dispcmd) 521 | print("stdout was %%s" %% stdout) 522 | return None, p.returncode 523 | return stdout, p.returncode 524 | 525 | 526 | def versions_from_parentdir(parentdir_prefix, root, verbose): 527 | """Try to determine the version from the parent directory name. 528 | 529 | Source tarballs conventionally unpack into a directory that includes both 530 | the project name and a version string. We will also support searching up 531 | two directory levels for an appropriately named parent directory 532 | """ 533 | rootdirs = [] 534 | 535 | for i in range(3): 536 | dirname = os.path.basename(root) 537 | if dirname.startswith(parentdir_prefix): 538 | return {"version": dirname[len(parentdir_prefix):], 539 | "full-revisionid": None, 540 | "dirty": False, "error": None, "date": None} 541 | else: 542 | rootdirs.append(root) 543 | root = os.path.dirname(root) # up a level 544 | 545 | if verbose: 546 | print("Tried directories %%s but none started with prefix %%s" %% 547 | (str(rootdirs), parentdir_prefix)) 548 | raise NotThisMethod("rootdir doesn't start with parentdir_prefix") 549 | 550 | 551 | @register_vcs_handler("git", "get_keywords") 552 | def git_get_keywords(versionfile_abs): 553 | """Extract version information from the given file.""" 554 | # the code embedded in _version.py can just fetch the value of these 555 | # keywords. When used from setup.py, we don't want to import _version.py, 556 | # so we do it with a regexp instead. This function is not used from 557 | # _version.py. 558 | keywords = {} 559 | try: 560 | f = open(versionfile_abs, "r") 561 | for line in f.readlines(): 562 | if line.strip().startswith("git_refnames ="): 563 | mo = re.search(r'=\s*"(.*)"', line) 564 | if mo: 565 | keywords["refnames"] = mo.group(1) 566 | if line.strip().startswith("git_full ="): 567 | mo = re.search(r'=\s*"(.*)"', line) 568 | if mo: 569 | keywords["full"] = mo.group(1) 570 | if line.strip().startswith("git_date ="): 571 | mo = re.search(r'=\s*"(.*)"', line) 572 | if mo: 573 | keywords["date"] = mo.group(1) 574 | f.close() 575 | except EnvironmentError: 576 | pass 577 | return keywords 578 | 579 | 580 | @register_vcs_handler("git", "keywords") 581 | def git_versions_from_keywords(keywords, tag_prefix, verbose): 582 | """Get version information from git keywords.""" 583 | if not keywords: 584 | raise NotThisMethod("no keywords at all, weird") 585 | date = keywords.get("date") 586 | if date is not None: 587 | # git-2.2.0 added "%%cI", which expands to an ISO-8601 -compliant 588 | # datestamp. However we prefer "%%ci" (which expands to an "ISO-8601 589 | # -like" string, which we must then edit to make compliant), because 590 | # it's been around since git-1.5.3, and it's too difficult to 591 | # discover which version we're using, or to work around using an 592 | # older one. 593 | date = date.strip().replace(" ", "T", 1).replace(" ", "", 1) 594 | refnames = keywords["refnames"].strip() 595 | if refnames.startswith("$Format"): 596 | if verbose: 597 | print("keywords are unexpanded, not using") 598 | raise NotThisMethod("unexpanded keywords, not a git-archive tarball") 599 | refs = set([r.strip() for r in refnames.strip("()").split(",")]) 600 | # starting in git-1.8.3, tags are listed as "tag: foo-1.0" instead of 601 | # just "foo-1.0". If we see a "tag: " prefix, prefer those. 602 | TAG = "tag: " 603 | tags = set([r[len(TAG):] for r in refs if r.startswith(TAG)]) 604 | if not tags: 605 | # Either we're using git < 1.8.3, or there really are no tags. We use 606 | # a heuristic: assume all version tags have a digit. The old git %%d 607 | # expansion behaves like git log --decorate=short and strips out the 608 | # refs/heads/ and refs/tags/ prefixes that would let us distinguish 609 | # between branches and tags. By ignoring refnames without digits, we 610 | # filter out many common branch names like "release" and 611 | # "stabilization", as well as "HEAD" and "master". 612 | tags = set([r for r in refs if re.search(r'\d', r)]) 613 | if verbose: 614 | print("discarding '%%s', no digits" %% ",".join(refs - tags)) 615 | if verbose: 616 | print("likely tags: %%s" %% ",".join(sorted(tags))) 617 | for ref in sorted(tags): 618 | # sorting will prefer e.g. "2.0" over "2.0rc1" 619 | if ref.startswith(tag_prefix): 620 | r = ref[len(tag_prefix):] 621 | if verbose: 622 | print("picking %%s" %% r) 623 | return {"version": r, 624 | "full-revisionid": keywords["full"].strip(), 625 | "dirty": False, "error": None, 626 | "date": date} 627 | # no suitable tags, so version is "0+unknown", but full hex is still there 628 | if verbose: 629 | print("no suitable tags, using unknown + full revision id") 630 | return {"version": "0+unknown", 631 | "full-revisionid": keywords["full"].strip(), 632 | "dirty": False, "error": "no suitable tags", "date": None} 633 | 634 | 635 | @register_vcs_handler("git", "pieces_from_vcs") 636 | def git_pieces_from_vcs(tag_prefix, root, verbose, run_command=run_command): 637 | """Get version from 'git describe' in the root of the source tree. 638 | 639 | This only gets called if the git-archive 'subst' keywords were *not* 640 | expanded, and _version.py hasn't already been rewritten with a short 641 | version string, meaning we're inside a checked out source tree. 642 | """ 643 | GITS = ["git"] 644 | if sys.platform == "win32": 645 | GITS = ["git.cmd", "git.exe"] 646 | 647 | out, rc = run_command(GITS, ["rev-parse", "--git-dir"], cwd=root, 648 | hide_stderr=True) 649 | if rc != 0: 650 | if verbose: 651 | print("Directory %%s not under git control" %% root) 652 | raise NotThisMethod("'git rev-parse --git-dir' returned error") 653 | 654 | # if there is a tag matching tag_prefix, this yields TAG-NUM-gHEX[-dirty] 655 | # if there isn't one, this yields HEX[-dirty] (no NUM) 656 | describe_out, rc = run_command(GITS, ["describe", "--tags", "--dirty", 657 | "--always", "--long", 658 | "--match", "%%s*" %% tag_prefix], 659 | cwd=root) 660 | # --long was added in git-1.5.5 661 | if describe_out is None: 662 | raise NotThisMethod("'git describe' failed") 663 | describe_out = describe_out.strip() 664 | full_out, rc = run_command(GITS, ["rev-parse", "HEAD"], cwd=root) 665 | if full_out is None: 666 | raise NotThisMethod("'git rev-parse' failed") 667 | full_out = full_out.strip() 668 | 669 | pieces = {} 670 | pieces["long"] = full_out 671 | pieces["short"] = full_out[:7] # maybe improved later 672 | pieces["error"] = None 673 | 674 | # parse describe_out. It will be like TAG-NUM-gHEX[-dirty] or HEX[-dirty] 675 | # TAG might have hyphens. 676 | git_describe = describe_out 677 | 678 | # look for -dirty suffix 679 | dirty = git_describe.endswith("-dirty") 680 | pieces["dirty"] = dirty 681 | if dirty: 682 | git_describe = git_describe[:git_describe.rindex("-dirty")] 683 | 684 | # now we have TAG-NUM-gHEX or HEX 685 | 686 | if "-" in git_describe: 687 | # TAG-NUM-gHEX 688 | mo = re.search(r'^(.+)-(\d+)-g([0-9a-f]+)$', git_describe) 689 | if not mo: 690 | # unparseable. Maybe git-describe is misbehaving? 691 | pieces["error"] = ("unable to parse git-describe output: '%%s'" 692 | %% describe_out) 693 | return pieces 694 | 695 | # tag 696 | full_tag = mo.group(1) 697 | if not full_tag.startswith(tag_prefix): 698 | if verbose: 699 | fmt = "tag '%%s' doesn't start with prefix '%%s'" 700 | print(fmt %% (full_tag, tag_prefix)) 701 | pieces["error"] = ("tag '%%s' doesn't start with prefix '%%s'" 702 | %% (full_tag, tag_prefix)) 703 | return pieces 704 | pieces["closest-tag"] = full_tag[len(tag_prefix):] 705 | 706 | # distance: number of commits since tag 707 | pieces["distance"] = int(mo.group(2)) 708 | 709 | # commit: short hex revision ID 710 | pieces["short"] = mo.group(3) 711 | 712 | else: 713 | # HEX: no tags 714 | pieces["closest-tag"] = None 715 | count_out, rc = run_command(GITS, ["rev-list", "HEAD", "--count"], 716 | cwd=root) 717 | pieces["distance"] = int(count_out) # total number of commits 718 | 719 | # commit date: see ISO-8601 comment in git_versions_from_keywords() 720 | date = run_command(GITS, ["show", "-s", "--format=%%ci", "HEAD"], 721 | cwd=root)[0].strip() 722 | pieces["date"] = date.strip().replace(" ", "T", 1).replace(" ", "", 1) 723 | 724 | return pieces 725 | 726 | 727 | def plus_or_dot(pieces): 728 | """Return a + if we don't already have one, else return a .""" 729 | if "+" in pieces.get("closest-tag", ""): 730 | return "." 731 | return "+" 732 | 733 | 734 | def render_pep440(pieces): 735 | """Build up version string, with post-release "local version identifier". 736 | 737 | Our goal: TAG[+DISTANCE.gHEX[.dirty]] . Note that if you 738 | get a tagged build and then dirty it, you'll get TAG+0.gHEX.dirty 739 | 740 | Exceptions: 741 | 1: no tags. git_describe was just HEX. 0+untagged.DISTANCE.gHEX[.dirty] 742 | """ 743 | if pieces["closest-tag"]: 744 | rendered = pieces["closest-tag"] 745 | if pieces["distance"] or pieces["dirty"]: 746 | rendered += plus_or_dot(pieces) 747 | rendered += "%%d.g%%s" %% (pieces["distance"], pieces["short"]) 748 | if pieces["dirty"]: 749 | rendered += ".dirty" 750 | else: 751 | # exception #1 752 | rendered = "0+untagged.%%d.g%%s" %% (pieces["distance"], 753 | pieces["short"]) 754 | if pieces["dirty"]: 755 | rendered += ".dirty" 756 | return rendered 757 | 758 | 759 | def render_pep440_pre(pieces): 760 | """TAG[.post.devDISTANCE] -- No -dirty. 761 | 762 | Exceptions: 763 | 1: no tags. 0.post.devDISTANCE 764 | """ 765 | if pieces["closest-tag"]: 766 | rendered = pieces["closest-tag"] 767 | if pieces["distance"]: 768 | rendered += ".post.dev%%d" %% pieces["distance"] 769 | else: 770 | # exception #1 771 | rendered = "0.post.dev%%d" %% pieces["distance"] 772 | return rendered 773 | 774 | 775 | def render_pep440_post(pieces): 776 | """TAG[.postDISTANCE[.dev0]+gHEX] . 777 | 778 | The ".dev0" means dirty. Note that .dev0 sorts backwards 779 | (a dirty tree will appear "older" than the corresponding clean one), 780 | but you shouldn't be releasing software with -dirty anyways. 781 | 782 | Exceptions: 783 | 1: no tags. 0.postDISTANCE[.dev0] 784 | """ 785 | if pieces["closest-tag"]: 786 | rendered = pieces["closest-tag"] 787 | if pieces["distance"] or pieces["dirty"]: 788 | rendered += ".post%%d" %% pieces["distance"] 789 | if pieces["dirty"]: 790 | rendered += ".dev0" 791 | rendered += plus_or_dot(pieces) 792 | rendered += "g%%s" %% pieces["short"] 793 | else: 794 | # exception #1 795 | rendered = "0.post%%d" %% pieces["distance"] 796 | if pieces["dirty"]: 797 | rendered += ".dev0" 798 | rendered += "+g%%s" %% pieces["short"] 799 | return rendered 800 | 801 | 802 | def render_pep440_old(pieces): 803 | """TAG[.postDISTANCE[.dev0]] . 804 | 805 | The ".dev0" means dirty. 806 | 807 | Eexceptions: 808 | 1: no tags. 0.postDISTANCE[.dev0] 809 | """ 810 | if pieces["closest-tag"]: 811 | rendered = pieces["closest-tag"] 812 | if pieces["distance"] or pieces["dirty"]: 813 | rendered += ".post%%d" %% pieces["distance"] 814 | if pieces["dirty"]: 815 | rendered += ".dev0" 816 | else: 817 | # exception #1 818 | rendered = "0.post%%d" %% pieces["distance"] 819 | if pieces["dirty"]: 820 | rendered += ".dev0" 821 | return rendered 822 | 823 | 824 | def render_git_describe(pieces): 825 | """TAG[-DISTANCE-gHEX][-dirty]. 826 | 827 | Like 'git describe --tags --dirty --always'. 828 | 829 | Exceptions: 830 | 1: no tags. HEX[-dirty] (note: no 'g' prefix) 831 | """ 832 | if pieces["closest-tag"]: 833 | rendered = pieces["closest-tag"] 834 | if pieces["distance"]: 835 | rendered += "-%%d-g%%s" %% (pieces["distance"], pieces["short"]) 836 | else: 837 | # exception #1 838 | rendered = pieces["short"] 839 | if pieces["dirty"]: 840 | rendered += "-dirty" 841 | return rendered 842 | 843 | 844 | def render_git_describe_long(pieces): 845 | """TAG-DISTANCE-gHEX[-dirty]. 846 | 847 | Like 'git describe --tags --dirty --always -long'. 848 | The distance/hash is unconditional. 849 | 850 | Exceptions: 851 | 1: no tags. HEX[-dirty] (note: no 'g' prefix) 852 | """ 853 | if pieces["closest-tag"]: 854 | rendered = pieces["closest-tag"] 855 | rendered += "-%%d-g%%s" %% (pieces["distance"], pieces["short"]) 856 | else: 857 | # exception #1 858 | rendered = pieces["short"] 859 | if pieces["dirty"]: 860 | rendered += "-dirty" 861 | return rendered 862 | 863 | 864 | def render(pieces, style): 865 | """Render the given version pieces into the requested style.""" 866 | if pieces["error"]: 867 | return {"version": "unknown", 868 | "full-revisionid": pieces.get("long"), 869 | "dirty": None, 870 | "error": pieces["error"], 871 | "date": None} 872 | 873 | if not style or style == "default": 874 | style = "pep440" # the default 875 | 876 | if style == "pep440": 877 | rendered = render_pep440(pieces) 878 | elif style == "pep440-pre": 879 | rendered = render_pep440_pre(pieces) 880 | elif style == "pep440-post": 881 | rendered = render_pep440_post(pieces) 882 | elif style == "pep440-old": 883 | rendered = render_pep440_old(pieces) 884 | elif style == "git-describe": 885 | rendered = render_git_describe(pieces) 886 | elif style == "git-describe-long": 887 | rendered = render_git_describe_long(pieces) 888 | else: 889 | raise ValueError("unknown style '%%s'" %% style) 890 | 891 | return {"version": rendered, "full-revisionid": pieces["long"], 892 | "dirty": pieces["dirty"], "error": None, 893 | "date": pieces.get("date")} 894 | 895 | 896 | def get_versions(): 897 | """Get version information or return default if unable to do so.""" 898 | # I am in _version.py, which lives at ROOT/VERSIONFILE_SOURCE. If we have 899 | # __file__, we can work backwards from there to the root. Some 900 | # py2exe/bbfreeze/non-CPython implementations don't do __file__, in which 901 | # case we can only use expanded keywords. 902 | 903 | cfg = get_config() 904 | verbose = cfg.verbose 905 | 906 | try: 907 | return git_versions_from_keywords(get_keywords(), cfg.tag_prefix, 908 | verbose) 909 | except NotThisMethod: 910 | pass 911 | 912 | try: 913 | root = os.path.realpath(__file__) 914 | # versionfile_source is the relative path from the top of the source 915 | # tree (where the .git directory might live) to this file. Invert 916 | # this to find the root from __file__. 917 | for i in cfg.versionfile_source.split('/'): 918 | root = os.path.dirname(root) 919 | except NameError: 920 | return {"version": "0+unknown", "full-revisionid": None, 921 | "dirty": None, 922 | "error": "unable to find root of source tree", 923 | "date": None} 924 | 925 | try: 926 | pieces = git_pieces_from_vcs(cfg.tag_prefix, root, verbose) 927 | return render(pieces, cfg.style) 928 | except NotThisMethod: 929 | pass 930 | 931 | try: 932 | if cfg.parentdir_prefix: 933 | return versions_from_parentdir(cfg.parentdir_prefix, root, verbose) 934 | except NotThisMethod: 935 | pass 936 | 937 | return {"version": "0+unknown", "full-revisionid": None, 938 | "dirty": None, 939 | "error": "unable to compute version", "date": None} 940 | ''' 941 | 942 | 943 | @register_vcs_handler("git", "get_keywords") 944 | def git_get_keywords(versionfile_abs): 945 | """Extract version information from the given file.""" 946 | # the code embedded in _version.py can just fetch the value of these 947 | # keywords. When used from setup.py, we don't want to import _version.py, 948 | # so we do it with a regexp instead. This function is not used from 949 | # _version.py. 950 | keywords = {} 951 | try: 952 | f = open(versionfile_abs, "r") 953 | for line in f.readlines(): 954 | if line.strip().startswith("git_refnames ="): 955 | mo = re.search(r'=\s*"(.*)"', line) 956 | if mo: 957 | keywords["refnames"] = mo.group(1) 958 | if line.strip().startswith("git_full ="): 959 | mo = re.search(r'=\s*"(.*)"', line) 960 | if mo: 961 | keywords["full"] = mo.group(1) 962 | if line.strip().startswith("git_date ="): 963 | mo = re.search(r'=\s*"(.*)"', line) 964 | if mo: 965 | keywords["date"] = mo.group(1) 966 | f.close() 967 | except EnvironmentError: 968 | pass 969 | return keywords 970 | 971 | 972 | @register_vcs_handler("git", "keywords") 973 | def git_versions_from_keywords(keywords, tag_prefix, verbose): 974 | """Get version information from git keywords.""" 975 | if not keywords: 976 | raise NotThisMethod("no keywords at all, weird") 977 | date = keywords.get("date") 978 | if date is not None: 979 | # git-2.2.0 added "%cI", which expands to an ISO-8601 -compliant 980 | # datestamp. However we prefer "%ci" (which expands to an "ISO-8601 981 | # -like" string, which we must then edit to make compliant), because 982 | # it's been around since git-1.5.3, and it's too difficult to 983 | # discover which version we're using, or to work around using an 984 | # older one. 985 | date = date.strip().replace(" ", "T", 1).replace(" ", "", 1) 986 | refnames = keywords["refnames"].strip() 987 | if refnames.startswith("$Format"): 988 | if verbose: 989 | print("keywords are unexpanded, not using") 990 | raise NotThisMethod("unexpanded keywords, not a git-archive tarball") 991 | refs = set([r.strip() for r in refnames.strip("()").split(",")]) 992 | # starting in git-1.8.3, tags are listed as "tag: foo-1.0" instead of 993 | # just "foo-1.0". If we see a "tag: " prefix, prefer those. 994 | TAG = "tag: " 995 | tags = set([r[len(TAG):] for r in refs if r.startswith(TAG)]) 996 | if not tags: 997 | # Either we're using git < 1.8.3, or there really are no tags. We use 998 | # a heuristic: assume all version tags have a digit. The old git %d 999 | # expansion behaves like git log --decorate=short and strips out the 1000 | # refs/heads/ and refs/tags/ prefixes that would let us distinguish 1001 | # between branches and tags. By ignoring refnames without digits, we 1002 | # filter out many common branch names like "release" and 1003 | # "stabilization", as well as "HEAD" and "master". 1004 | tags = set([r for r in refs if re.search(r'\d', r)]) 1005 | if verbose: 1006 | print("discarding '%s', no digits" % ",".join(refs - tags)) 1007 | if verbose: 1008 | print("likely tags: %s" % ",".join(sorted(tags))) 1009 | for ref in sorted(tags): 1010 | # sorting will prefer e.g. "2.0" over "2.0rc1" 1011 | if ref.startswith(tag_prefix): 1012 | r = ref[len(tag_prefix):] 1013 | if verbose: 1014 | print("picking %s" % r) 1015 | return {"version": r, 1016 | "full-revisionid": keywords["full"].strip(), 1017 | "dirty": False, "error": None, 1018 | "date": date} 1019 | # no suitable tags, so version is "0+unknown", but full hex is still there 1020 | if verbose: 1021 | print("no suitable tags, using unknown + full revision id") 1022 | return {"version": "0+unknown", 1023 | "full-revisionid": keywords["full"].strip(), 1024 | "dirty": False, "error": "no suitable tags", "date": None} 1025 | 1026 | 1027 | @register_vcs_handler("git", "pieces_from_vcs") 1028 | def git_pieces_from_vcs(tag_prefix, root, verbose, run_command=run_command): 1029 | """Get version from 'git describe' in the root of the source tree. 1030 | 1031 | This only gets called if the git-archive 'subst' keywords were *not* 1032 | expanded, and _version.py hasn't already been rewritten with a short 1033 | version string, meaning we're inside a checked out source tree. 1034 | """ 1035 | GITS = ["git"] 1036 | if sys.platform == "win32": 1037 | GITS = ["git.cmd", "git.exe"] 1038 | 1039 | out, rc = run_command(GITS, ["rev-parse", "--git-dir"], cwd=root, 1040 | hide_stderr=True) 1041 | if rc != 0: 1042 | if verbose: 1043 | print("Directory %s not under git control" % root) 1044 | raise NotThisMethod("'git rev-parse --git-dir' returned error") 1045 | 1046 | # if there is a tag matching tag_prefix, this yields TAG-NUM-gHEX[-dirty] 1047 | # if there isn't one, this yields HEX[-dirty] (no NUM) 1048 | describe_out, rc = run_command(GITS, ["describe", "--tags", "--dirty", 1049 | "--always", "--long", 1050 | "--match", "%s*" % tag_prefix], 1051 | cwd=root) 1052 | # --long was added in git-1.5.5 1053 | if describe_out is None: 1054 | raise NotThisMethod("'git describe' failed") 1055 | describe_out = describe_out.strip() 1056 | full_out, rc = run_command(GITS, ["rev-parse", "HEAD"], cwd=root) 1057 | if full_out is None: 1058 | raise NotThisMethod("'git rev-parse' failed") 1059 | full_out = full_out.strip() 1060 | 1061 | pieces = {} 1062 | pieces["long"] = full_out 1063 | pieces["short"] = full_out[:7] # maybe improved later 1064 | pieces["error"] = None 1065 | 1066 | # parse describe_out. It will be like TAG-NUM-gHEX[-dirty] or HEX[-dirty] 1067 | # TAG might have hyphens. 1068 | git_describe = describe_out 1069 | 1070 | # look for -dirty suffix 1071 | dirty = git_describe.endswith("-dirty") 1072 | pieces["dirty"] = dirty 1073 | if dirty: 1074 | git_describe = git_describe[:git_describe.rindex("-dirty")] 1075 | 1076 | # now we have TAG-NUM-gHEX or HEX 1077 | 1078 | if "-" in git_describe: 1079 | # TAG-NUM-gHEX 1080 | mo = re.search(r'^(.+)-(\d+)-g([0-9a-f]+)$', git_describe) 1081 | if not mo: 1082 | # unparseable. Maybe git-describe is misbehaving? 1083 | pieces["error"] = ("unable to parse git-describe output: '%s'" 1084 | % describe_out) 1085 | return pieces 1086 | 1087 | # tag 1088 | full_tag = mo.group(1) 1089 | if not full_tag.startswith(tag_prefix): 1090 | if verbose: 1091 | fmt = "tag '%s' doesn't start with prefix '%s'" 1092 | print(fmt % (full_tag, tag_prefix)) 1093 | pieces["error"] = ("tag '%s' doesn't start with prefix '%s'" 1094 | % (full_tag, tag_prefix)) 1095 | return pieces 1096 | pieces["closest-tag"] = full_tag[len(tag_prefix):] 1097 | 1098 | # distance: number of commits since tag 1099 | pieces["distance"] = int(mo.group(2)) 1100 | 1101 | # commit: short hex revision ID 1102 | pieces["short"] = mo.group(3) 1103 | 1104 | else: 1105 | # HEX: no tags 1106 | pieces["closest-tag"] = None 1107 | count_out, rc = run_command(GITS, ["rev-list", "HEAD", "--count"], 1108 | cwd=root) 1109 | pieces["distance"] = int(count_out) # total number of commits 1110 | 1111 | # commit date: see ISO-8601 comment in git_versions_from_keywords() 1112 | date = run_command(GITS, ["show", "-s", "--format=%ci", "HEAD"], 1113 | cwd=root)[0].strip() 1114 | pieces["date"] = date.strip().replace(" ", "T", 1).replace(" ", "", 1) 1115 | 1116 | return pieces 1117 | 1118 | 1119 | def do_vcs_install(manifest_in, versionfile_source, ipy): 1120 | """Git-specific installation logic for Versioneer. 1121 | 1122 | For Git, this means creating/changing .gitattributes to mark _version.py 1123 | for export-subst keyword substitution. 1124 | """ 1125 | GITS = ["git"] 1126 | if sys.platform == "win32": 1127 | GITS = ["git.cmd", "git.exe"] 1128 | files = [manifest_in, versionfile_source] 1129 | if ipy: 1130 | files.append(ipy) 1131 | try: 1132 | me = __file__ 1133 | if me.endswith(".pyc") or me.endswith(".pyo"): 1134 | me = os.path.splitext(me)[0] + ".py" 1135 | versioneer_file = os.path.relpath(me) 1136 | except NameError: 1137 | versioneer_file = "versioneer.py" 1138 | files.append(versioneer_file) 1139 | present = False 1140 | try: 1141 | f = open(".gitattributes", "r") 1142 | for line in f.readlines(): 1143 | if line.strip().startswith(versionfile_source): 1144 | if "export-subst" in line.strip().split()[1:]: 1145 | present = True 1146 | f.close() 1147 | except EnvironmentError: 1148 | pass 1149 | if not present: 1150 | f = open(".gitattributes", "a+") 1151 | f.write("%s export-subst\n" % versionfile_source) 1152 | f.close() 1153 | files.append(".gitattributes") 1154 | run_command(GITS, ["add", "--"] + files) 1155 | 1156 | 1157 | def versions_from_parentdir(parentdir_prefix, root, verbose): 1158 | """Try to determine the version from the parent directory name. 1159 | 1160 | Source tarballs conventionally unpack into a directory that includes both 1161 | the project name and a version string. We will also support searching up 1162 | two directory levels for an appropriately named parent directory 1163 | """ 1164 | rootdirs = [] 1165 | 1166 | for i in range(3): 1167 | dirname = os.path.basename(root) 1168 | if dirname.startswith(parentdir_prefix): 1169 | return {"version": dirname[len(parentdir_prefix):], 1170 | "full-revisionid": None, 1171 | "dirty": False, "error": None, "date": None} 1172 | else: 1173 | rootdirs.append(root) 1174 | root = os.path.dirname(root) # up a level 1175 | 1176 | if verbose: 1177 | print("Tried directories %s but none started with prefix %s" % 1178 | (str(rootdirs), parentdir_prefix)) 1179 | raise NotThisMethod("rootdir doesn't start with parentdir_prefix") 1180 | 1181 | 1182 | SHORT_VERSION_PY = """ 1183 | # This file was generated by 'versioneer.py' (0.18) from 1184 | # revision-control system data, or from the parent directory name of an 1185 | # unpacked source archive. Distribution tarballs contain a pre-generated copy 1186 | # of this file. 1187 | 1188 | import json 1189 | 1190 | version_json = ''' 1191 | %s 1192 | ''' # END VERSION_JSON 1193 | 1194 | 1195 | def get_versions(): 1196 | return json.loads(version_json) 1197 | """ 1198 | 1199 | 1200 | def versions_from_file(filename): 1201 | """Try to determine the version from _version.py if present.""" 1202 | try: 1203 | with open(filename) as f: 1204 | contents = f.read() 1205 | except EnvironmentError: 1206 | raise NotThisMethod("unable to read _version.py") 1207 | mo = re.search(r"version_json = '''\n(.*)''' # END VERSION_JSON", 1208 | contents, re.M | re.S) 1209 | if not mo: 1210 | mo = re.search(r"version_json = '''\r\n(.*)''' # END VERSION_JSON", 1211 | contents, re.M | re.S) 1212 | if not mo: 1213 | raise NotThisMethod("no version_json in _version.py") 1214 | return json.loads(mo.group(1)) 1215 | 1216 | 1217 | def write_to_version_file(filename, versions): 1218 | """Write the given version number to the given _version.py file.""" 1219 | os.unlink(filename) 1220 | contents = json.dumps(versions, sort_keys=True, 1221 | indent=1, separators=(",", ": ")) 1222 | with open(filename, "w") as f: 1223 | f.write(SHORT_VERSION_PY % contents) 1224 | 1225 | print("set %s to '%s'" % (filename, versions["version"])) 1226 | 1227 | 1228 | def plus_or_dot(pieces): 1229 | """Return a + if we don't already have one, else return a .""" 1230 | if "+" in pieces.get("closest-tag", ""): 1231 | return "." 1232 | return "+" 1233 | 1234 | 1235 | def render_pep440(pieces): 1236 | """Build up version string, with post-release "local version identifier". 1237 | 1238 | Our goal: TAG[+DISTANCE.gHEX[.dirty]] . Note that if you 1239 | get a tagged build and then dirty it, you'll get TAG+0.gHEX.dirty 1240 | 1241 | Exceptions: 1242 | 1: no tags. git_describe was just HEX. 0+untagged.DISTANCE.gHEX[.dirty] 1243 | """ 1244 | if pieces["closest-tag"]: 1245 | rendered = pieces["closest-tag"] 1246 | if pieces["distance"] or pieces["dirty"]: 1247 | rendered += plus_or_dot(pieces) 1248 | rendered += "%d.g%s" % (pieces["distance"], pieces["short"]) 1249 | if pieces["dirty"]: 1250 | rendered += ".dirty" 1251 | else: 1252 | # exception #1 1253 | rendered = "0+untagged.%d.g%s" % (pieces["distance"], 1254 | pieces["short"]) 1255 | if pieces["dirty"]: 1256 | rendered += ".dirty" 1257 | return rendered 1258 | 1259 | 1260 | def render_pep440_pre(pieces): 1261 | """TAG[.post.devDISTANCE] -- No -dirty. 1262 | 1263 | Exceptions: 1264 | 1: no tags. 0.post.devDISTANCE 1265 | """ 1266 | if pieces["closest-tag"]: 1267 | rendered = pieces["closest-tag"] 1268 | if pieces["distance"]: 1269 | rendered += ".post.dev%d" % pieces["distance"] 1270 | else: 1271 | # exception #1 1272 | rendered = "0.post.dev%d" % pieces["distance"] 1273 | return rendered 1274 | 1275 | 1276 | def render_pep440_post(pieces): 1277 | """TAG[.postDISTANCE[.dev0]+gHEX] . 1278 | 1279 | The ".dev0" means dirty. Note that .dev0 sorts backwards 1280 | (a dirty tree will appear "older" than the corresponding clean one), 1281 | but you shouldn't be releasing software with -dirty anyways. 1282 | 1283 | Exceptions: 1284 | 1: no tags. 0.postDISTANCE[.dev0] 1285 | """ 1286 | if pieces["closest-tag"]: 1287 | rendered = pieces["closest-tag"] 1288 | if pieces["distance"] or pieces["dirty"]: 1289 | rendered += ".post%d" % pieces["distance"] 1290 | if pieces["dirty"]: 1291 | rendered += ".dev0" 1292 | rendered += plus_or_dot(pieces) 1293 | rendered += "g%s" % pieces["short"] 1294 | else: 1295 | # exception #1 1296 | rendered = "0.post%d" % pieces["distance"] 1297 | if pieces["dirty"]: 1298 | rendered += ".dev0" 1299 | rendered += "+g%s" % pieces["short"] 1300 | return rendered 1301 | 1302 | 1303 | def render_pep440_old(pieces): 1304 | """TAG[.postDISTANCE[.dev0]] . 1305 | 1306 | The ".dev0" means dirty. 1307 | 1308 | Eexceptions: 1309 | 1: no tags. 0.postDISTANCE[.dev0] 1310 | """ 1311 | if pieces["closest-tag"]: 1312 | rendered = pieces["closest-tag"] 1313 | if pieces["distance"] or pieces["dirty"]: 1314 | rendered += ".post%d" % pieces["distance"] 1315 | if pieces["dirty"]: 1316 | rendered += ".dev0" 1317 | else: 1318 | # exception #1 1319 | rendered = "0.post%d" % pieces["distance"] 1320 | if pieces["dirty"]: 1321 | rendered += ".dev0" 1322 | return rendered 1323 | 1324 | 1325 | def render_git_describe(pieces): 1326 | """TAG[-DISTANCE-gHEX][-dirty]. 1327 | 1328 | Like 'git describe --tags --dirty --always'. 1329 | 1330 | Exceptions: 1331 | 1: no tags. HEX[-dirty] (note: no 'g' prefix) 1332 | """ 1333 | if pieces["closest-tag"]: 1334 | rendered = pieces["closest-tag"] 1335 | if pieces["distance"]: 1336 | rendered += "-%d-g%s" % (pieces["distance"], pieces["short"]) 1337 | else: 1338 | # exception #1 1339 | rendered = pieces["short"] 1340 | if pieces["dirty"]: 1341 | rendered += "-dirty" 1342 | return rendered 1343 | 1344 | 1345 | def render_git_describe_long(pieces): 1346 | """TAG-DISTANCE-gHEX[-dirty]. 1347 | 1348 | Like 'git describe --tags --dirty --always -long'. 1349 | The distance/hash is unconditional. 1350 | 1351 | Exceptions: 1352 | 1: no tags. HEX[-dirty] (note: no 'g' prefix) 1353 | """ 1354 | if pieces["closest-tag"]: 1355 | rendered = pieces["closest-tag"] 1356 | rendered += "-%d-g%s" % (pieces["distance"], pieces["short"]) 1357 | else: 1358 | # exception #1 1359 | rendered = pieces["short"] 1360 | if pieces["dirty"]: 1361 | rendered += "-dirty" 1362 | return rendered 1363 | 1364 | 1365 | def render(pieces, style): 1366 | """Render the given version pieces into the requested style.""" 1367 | if pieces["error"]: 1368 | return {"version": "unknown", 1369 | "full-revisionid": pieces.get("long"), 1370 | "dirty": None, 1371 | "error": pieces["error"], 1372 | "date": None} 1373 | 1374 | if not style or style == "default": 1375 | style = "pep440" # the default 1376 | 1377 | if style == "pep440": 1378 | rendered = render_pep440(pieces) 1379 | elif style == "pep440-pre": 1380 | rendered = render_pep440_pre(pieces) 1381 | elif style == "pep440-post": 1382 | rendered = render_pep440_post(pieces) 1383 | elif style == "pep440-old": 1384 | rendered = render_pep440_old(pieces) 1385 | elif style == "git-describe": 1386 | rendered = render_git_describe(pieces) 1387 | elif style == "git-describe-long": 1388 | rendered = render_git_describe_long(pieces) 1389 | else: 1390 | raise ValueError("unknown style '%s'" % style) 1391 | 1392 | return {"version": rendered, "full-revisionid": pieces["long"], 1393 | "dirty": pieces["dirty"], "error": None, 1394 | "date": pieces.get("date")} 1395 | 1396 | 1397 | class VersioneerBadRootError(Exception): 1398 | """The project root directory is unknown or missing key files.""" 1399 | 1400 | 1401 | def get_versions(verbose=False): 1402 | """Get the project version from whatever source is available. 1403 | 1404 | Returns dict with two keys: 'version' and 'full'. 1405 | """ 1406 | if "versioneer" in sys.modules: 1407 | # see the discussion in cmdclass.py:get_cmdclass() 1408 | del sys.modules["versioneer"] 1409 | 1410 | root = get_root() 1411 | cfg = get_config_from_root(root) 1412 | 1413 | assert cfg.VCS is not None, "please set [versioneer]VCS= in setup.cfg" 1414 | handlers = HANDLERS.get(cfg.VCS) 1415 | assert handlers, "unrecognized VCS '%s'" % cfg.VCS 1416 | verbose = verbose or cfg.verbose 1417 | assert cfg.versionfile_source is not None, \ 1418 | "please set versioneer.versionfile_source" 1419 | assert cfg.tag_prefix is not None, "please set versioneer.tag_prefix" 1420 | 1421 | versionfile_abs = os.path.join(root, cfg.versionfile_source) 1422 | 1423 | # extract version from first of: _version.py, VCS command (e.g. 'git 1424 | # describe'), parentdir. This is meant to work for developers using a 1425 | # source checkout, for users of a tarball created by 'setup.py sdist', 1426 | # and for users of a tarball/zipball created by 'git archive' or github's 1427 | # download-from-tag feature or the equivalent in other VCSes. 1428 | 1429 | get_keywords_f = handlers.get("get_keywords") 1430 | from_keywords_f = handlers.get("keywords") 1431 | if get_keywords_f and from_keywords_f: 1432 | try: 1433 | keywords = get_keywords_f(versionfile_abs) 1434 | ver = from_keywords_f(keywords, cfg.tag_prefix, verbose) 1435 | if verbose: 1436 | print("got version from expanded keyword %s" % ver) 1437 | return ver 1438 | except NotThisMethod: 1439 | pass 1440 | 1441 | try: 1442 | ver = versions_from_file(versionfile_abs) 1443 | if verbose: 1444 | print("got version from file %s %s" % (versionfile_abs, ver)) 1445 | return ver 1446 | except NotThisMethod: 1447 | pass 1448 | 1449 | from_vcs_f = handlers.get("pieces_from_vcs") 1450 | if from_vcs_f: 1451 | try: 1452 | pieces = from_vcs_f(cfg.tag_prefix, root, verbose) 1453 | ver = render(pieces, cfg.style) 1454 | if verbose: 1455 | print("got version from VCS %s" % ver) 1456 | return ver 1457 | except NotThisMethod: 1458 | pass 1459 | 1460 | try: 1461 | if cfg.parentdir_prefix: 1462 | ver = versions_from_parentdir(cfg.parentdir_prefix, root, verbose) 1463 | if verbose: 1464 | print("got version from parentdir %s" % ver) 1465 | return ver 1466 | except NotThisMethod: 1467 | pass 1468 | 1469 | if verbose: 1470 | print("unable to compute version") 1471 | 1472 | return {"version": "0+unknown", "full-revisionid": None, 1473 | "dirty": None, "error": "unable to compute version", 1474 | "date": None} 1475 | 1476 | 1477 | def get_version(): 1478 | """Get the short version string for this project.""" 1479 | return get_versions()["version"] 1480 | 1481 | 1482 | def get_cmdclass(): 1483 | """Get the custom setuptools/distutils subclasses used by Versioneer.""" 1484 | if "versioneer" in sys.modules: 1485 | del sys.modules["versioneer"] 1486 | # this fixes the "python setup.py develop" case (also 'install' and 1487 | # 'easy_install .'), in which subdependencies of the main project are 1488 | # built (using setup.py bdist_egg) in the same python process. Assume 1489 | # a main project A and a dependency B, which use different versions 1490 | # of Versioneer. A's setup.py imports A's Versioneer, leaving it in 1491 | # sys.modules by the time B's setup.py is executed, causing B to run 1492 | # with the wrong versioneer. Setuptools wraps the sub-dep builds in a 1493 | # sandbox that restores sys.modules to it's pre-build state, so the 1494 | # parent is protected against the child's "import versioneer". By 1495 | # removing ourselves from sys.modules here, before the child build 1496 | # happens, we protect the child from the parent's versioneer too. 1497 | # Also see https://github.com/warner/python-versioneer/issues/52 1498 | 1499 | cmds = {} 1500 | 1501 | # we add "version" to both distutils and setuptools 1502 | from distutils.core import Command 1503 | 1504 | class cmd_version(Command): 1505 | description = "report generated version string" 1506 | user_options = [] 1507 | boolean_options = [] 1508 | 1509 | def initialize_options(self): 1510 | pass 1511 | 1512 | def finalize_options(self): 1513 | pass 1514 | 1515 | def run(self): 1516 | vers = get_versions(verbose=True) 1517 | print("Version: %s" % vers["version"]) 1518 | print(" full-revisionid: %s" % vers.get("full-revisionid")) 1519 | print(" dirty: %s" % vers.get("dirty")) 1520 | print(" date: %s" % vers.get("date")) 1521 | if vers["error"]: 1522 | print(" error: %s" % vers["error"]) 1523 | cmds["version"] = cmd_version 1524 | 1525 | # we override "build_py" in both distutils and setuptools 1526 | # 1527 | # most invocation pathways end up running build_py: 1528 | # distutils/build -> build_py 1529 | # distutils/install -> distutils/build ->.. 1530 | # setuptools/bdist_wheel -> distutils/install ->.. 1531 | # setuptools/bdist_egg -> distutils/install_lib -> build_py 1532 | # setuptools/install -> bdist_egg ->.. 1533 | # setuptools/develop -> ? 1534 | # pip install: 1535 | # copies source tree to a tempdir before running egg_info/etc 1536 | # if .git isn't copied too, 'git describe' will fail 1537 | # then does setup.py bdist_wheel, or sometimes setup.py install 1538 | # setup.py egg_info -> ? 1539 | 1540 | # we override different "build_py" commands for both environments 1541 | if "setuptools" in sys.modules: 1542 | from setuptools.command.build_py import build_py as _build_py 1543 | else: 1544 | from distutils.command.build_py import build_py as _build_py 1545 | 1546 | class cmd_build_py(_build_py): 1547 | def run(self): 1548 | root = get_root() 1549 | cfg = get_config_from_root(root) 1550 | versions = get_versions() 1551 | _build_py.run(self) 1552 | # now locate _version.py in the new build/ directory and replace 1553 | # it with an updated value 1554 | if cfg.versionfile_build: 1555 | target_versionfile = os.path.join(self.build_lib, 1556 | cfg.versionfile_build) 1557 | print("UPDATING %s" % target_versionfile) 1558 | write_to_version_file(target_versionfile, versions) 1559 | cmds["build_py"] = cmd_build_py 1560 | 1561 | if "cx_Freeze" in sys.modules: # cx_freeze enabled? 1562 | from cx_Freeze.dist import build_exe as _build_exe 1563 | # nczeczulin reports that py2exe won't like the pep440-style string 1564 | # as FILEVERSION, but it can be used for PRODUCTVERSION, e.g. 1565 | # setup(console=[{ 1566 | # "version": versioneer.get_version().split("+", 1)[0], # FILEVERSION 1567 | # "product_version": versioneer.get_version(), 1568 | # ... 1569 | 1570 | class cmd_build_exe(_build_exe): 1571 | def run(self): 1572 | root = get_root() 1573 | cfg = get_config_from_root(root) 1574 | versions = get_versions() 1575 | target_versionfile = cfg.versionfile_source 1576 | print("UPDATING %s" % target_versionfile) 1577 | write_to_version_file(target_versionfile, versions) 1578 | 1579 | _build_exe.run(self) 1580 | os.unlink(target_versionfile) 1581 | with open(cfg.versionfile_source, "w") as f: 1582 | LONG = LONG_VERSION_PY[cfg.VCS] 1583 | f.write(LONG % 1584 | {"DOLLAR": "$", 1585 | "STYLE": cfg.style, 1586 | "TAG_PREFIX": cfg.tag_prefix, 1587 | "PARENTDIR_PREFIX": cfg.parentdir_prefix, 1588 | "VERSIONFILE_SOURCE": cfg.versionfile_source, 1589 | }) 1590 | cmds["build_exe"] = cmd_build_exe 1591 | del cmds["build_py"] 1592 | 1593 | if 'py2exe' in sys.modules: # py2exe enabled? 1594 | try: 1595 | from py2exe.distutils_buildexe import py2exe as _py2exe # py3 1596 | except ImportError: 1597 | from py2exe.build_exe import py2exe as _py2exe # py2 1598 | 1599 | class cmd_py2exe(_py2exe): 1600 | def run(self): 1601 | root = get_root() 1602 | cfg = get_config_from_root(root) 1603 | versions = get_versions() 1604 | target_versionfile = cfg.versionfile_source 1605 | print("UPDATING %s" % target_versionfile) 1606 | write_to_version_file(target_versionfile, versions) 1607 | 1608 | _py2exe.run(self) 1609 | os.unlink(target_versionfile) 1610 | with open(cfg.versionfile_source, "w") as f: 1611 | LONG = LONG_VERSION_PY[cfg.VCS] 1612 | f.write(LONG % 1613 | {"DOLLAR": "$", 1614 | "STYLE": cfg.style, 1615 | "TAG_PREFIX": cfg.tag_prefix, 1616 | "PARENTDIR_PREFIX": cfg.parentdir_prefix, 1617 | "VERSIONFILE_SOURCE": cfg.versionfile_source, 1618 | }) 1619 | cmds["py2exe"] = cmd_py2exe 1620 | 1621 | # we override different "sdist" commands for both environments 1622 | if "setuptools" in sys.modules: 1623 | from setuptools.command.sdist import sdist as _sdist 1624 | else: 1625 | from distutils.command.sdist import sdist as _sdist 1626 | 1627 | class cmd_sdist(_sdist): 1628 | def run(self): 1629 | versions = get_versions() 1630 | self._versioneer_generated_versions = versions 1631 | # unless we update this, the command will keep using the old 1632 | # version 1633 | self.distribution.metadata.version = versions["version"] 1634 | return _sdist.run(self) 1635 | 1636 | def make_release_tree(self, base_dir, files): 1637 | root = get_root() 1638 | cfg = get_config_from_root(root) 1639 | _sdist.make_release_tree(self, base_dir, files) 1640 | # now locate _version.py in the new base_dir directory 1641 | # (remembering that it may be a hardlink) and replace it with an 1642 | # updated value 1643 | target_versionfile = os.path.join(base_dir, cfg.versionfile_source) 1644 | print("UPDATING %s" % target_versionfile) 1645 | write_to_version_file(target_versionfile, 1646 | self._versioneer_generated_versions) 1647 | cmds["sdist"] = cmd_sdist 1648 | 1649 | return cmds 1650 | 1651 | 1652 | CONFIG_ERROR = """ 1653 | setup.cfg is missing the necessary Versioneer configuration. You need 1654 | a section like: 1655 | 1656 | [versioneer] 1657 | VCS = git 1658 | style = pep440 1659 | versionfile_source = src/myproject/_version.py 1660 | versionfile_build = myproject/_version.py 1661 | tag_prefix = 1662 | parentdir_prefix = myproject- 1663 | 1664 | You will also need to edit your setup.py to use the results: 1665 | 1666 | import versioneer 1667 | setup(version=versioneer.get_version(), 1668 | cmdclass=versioneer.get_cmdclass(), ...) 1669 | 1670 | Please read the docstring in ./versioneer.py for configuration instructions, 1671 | edit setup.cfg, and re-run the installer or 'python versioneer.py setup'. 1672 | """ 1673 | 1674 | SAMPLE_CONFIG = """ 1675 | # See the docstring in versioneer.py for instructions. Note that you must 1676 | # re-run 'versioneer.py setup' after changing this section, and commit the 1677 | # resulting files. 1678 | 1679 | [versioneer] 1680 | #VCS = git 1681 | #style = pep440 1682 | #versionfile_source = 1683 | #versionfile_build = 1684 | #tag_prefix = 1685 | #parentdir_prefix = 1686 | 1687 | """ 1688 | 1689 | INIT_PY_SNIPPET = """ 1690 | from ._version import get_versions 1691 | __version__ = get_versions()['version'] 1692 | del get_versions 1693 | """ 1694 | 1695 | 1696 | def do_setup(): 1697 | """Main VCS-independent setup function for installing Versioneer.""" 1698 | root = get_root() 1699 | try: 1700 | cfg = get_config_from_root(root) 1701 | except (EnvironmentError, configparser.NoSectionError, 1702 | configparser.NoOptionError) as e: 1703 | if isinstance(e, (EnvironmentError, configparser.NoSectionError)): 1704 | print("Adding sample versioneer config to setup.cfg", 1705 | file=sys.stderr) 1706 | with open(os.path.join(root, "setup.cfg"), "a") as f: 1707 | f.write(SAMPLE_CONFIG) 1708 | print(CONFIG_ERROR, file=sys.stderr) 1709 | return 1 1710 | 1711 | print(" creating %s" % cfg.versionfile_source) 1712 | with open(cfg.versionfile_source, "w") as f: 1713 | LONG = LONG_VERSION_PY[cfg.VCS] 1714 | f.write(LONG % {"DOLLAR": "$", 1715 | "STYLE": cfg.style, 1716 | "TAG_PREFIX": cfg.tag_prefix, 1717 | "PARENTDIR_PREFIX": cfg.parentdir_prefix, 1718 | "VERSIONFILE_SOURCE": cfg.versionfile_source, 1719 | }) 1720 | 1721 | ipy = os.path.join(os.path.dirname(cfg.versionfile_source), 1722 | "__init__.py") 1723 | if os.path.exists(ipy): 1724 | try: 1725 | with open(ipy, "r") as f: 1726 | old = f.read() 1727 | except EnvironmentError: 1728 | old = "" 1729 | if INIT_PY_SNIPPET not in old: 1730 | print(" appending to %s" % ipy) 1731 | with open(ipy, "a") as f: 1732 | f.write(INIT_PY_SNIPPET) 1733 | else: 1734 | print(" %s unmodified" % ipy) 1735 | else: 1736 | print(" %s doesn't exist, ok" % ipy) 1737 | ipy = None 1738 | 1739 | # Make sure both the top-level "versioneer.py" and versionfile_source 1740 | # (PKG/_version.py, used by runtime code) are in MANIFEST.in, so 1741 | # they'll be copied into source distributions. Pip won't be able to 1742 | # install the package without this. 1743 | manifest_in = os.path.join(root, "MANIFEST.in") 1744 | simple_includes = set() 1745 | try: 1746 | with open(manifest_in, "r") as f: 1747 | for line in f: 1748 | if line.startswith("include "): 1749 | for include in line.split()[1:]: 1750 | simple_includes.add(include) 1751 | except EnvironmentError: 1752 | pass 1753 | # That doesn't cover everything MANIFEST.in can do 1754 | # (http://docs.python.org/2/distutils/sourcedist.html#commands), so 1755 | # it might give some false negatives. Appending redundant 'include' 1756 | # lines is safe, though. 1757 | if "versioneer.py" not in simple_includes: 1758 | print(" appending 'versioneer.py' to MANIFEST.in") 1759 | with open(manifest_in, "a") as f: 1760 | f.write("include versioneer.py\n") 1761 | else: 1762 | print(" 'versioneer.py' already in MANIFEST.in") 1763 | if cfg.versionfile_source not in simple_includes: 1764 | print(" appending versionfile_source ('%s') to MANIFEST.in" % 1765 | cfg.versionfile_source) 1766 | with open(manifest_in, "a") as f: 1767 | f.write("include %s\n" % cfg.versionfile_source) 1768 | else: 1769 | print(" versionfile_source already in MANIFEST.in") 1770 | 1771 | # Make VCS-specific changes. For git, this means creating/changing 1772 | # .gitattributes to mark _version.py for export-subst keyword 1773 | # substitution. 1774 | do_vcs_install(manifest_in, cfg.versionfile_source, ipy) 1775 | return 0 1776 | 1777 | 1778 | def scan_setup_py(): 1779 | """Validate the contents of setup.py against Versioneer's expectations.""" 1780 | found = set() 1781 | setters = False 1782 | errors = 0 1783 | with open("setup.py", "r") as f: 1784 | for line in f.readlines(): 1785 | if "import versioneer" in line: 1786 | found.add("import") 1787 | if "versioneer.get_cmdclass()" in line: 1788 | found.add("cmdclass") 1789 | if "versioneer.get_version()" in line: 1790 | found.add("get_version") 1791 | if "versioneer.VCS" in line: 1792 | setters = True 1793 | if "versioneer.versionfile_source" in line: 1794 | setters = True 1795 | if len(found) != 3: 1796 | print("") 1797 | print("Your setup.py appears to be missing some important items") 1798 | print("(but I might be wrong). Please make sure it has something") 1799 | print("roughly like the following:") 1800 | print("") 1801 | print(" import versioneer") 1802 | print(" setup( version=versioneer.get_version(),") 1803 | print(" cmdclass=versioneer.get_cmdclass(), ...)") 1804 | print("") 1805 | errors += 1 1806 | if setters: 1807 | print("You should remove lines like 'versioneer.VCS = ' and") 1808 | print("'versioneer.versionfile_source = ' . This configuration") 1809 | print("now lives in setup.cfg, and should be removed from setup.py") 1810 | print("") 1811 | errors += 1 1812 | return errors 1813 | 1814 | 1815 | if __name__ == "__main__": 1816 | cmd = sys.argv[1] 1817 | if cmd == "setup": 1818 | errors = do_setup() 1819 | errors += scan_setup_py() 1820 | if errors: 1821 | sys.exit(1) 1822 | --------------------------------------------------------------------------------