├── .editorconfig ├── .flake8 ├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md └── workflows │ ├── ci.yml │ └── release.yml ├── .gitignore ├── .pre-commit-config.yaml ├── .vscode ├── extensions.json └── settings.json ├── CHANGELOG.md ├── LICENSE ├── README.md ├── changelog-ci-config.json ├── pyproject.toml ├── requirements-dev.in ├── requirements-dev.txt ├── requirements.in ├── requirements.txt ├── src └── pytest_qgis │ ├── __init__.py │ ├── _version.py │ ├── mock_qgis_classes.py │ ├── py.typed │ ├── pytest_qgis.py │ ├── qgis_bot.py │ ├── qgis_interface.py │ └── utils.py └── tests ├── __init__.py ├── conftest.py ├── data ├── db.gpkg └── small_raster.tif ├── test_ini.py ├── test_layer_cleanup.py ├── test_pytest_qgis.py ├── test_qgis_bot.py ├── test_utils.py ├── utils.py └── visual ├── __init__.py ├── test_qgis_ui.py └── test_show_map.py /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | end_of_line = lf 5 | charset = utf-8 6 | trim_trailing_whitespace = true 7 | insert_final_newline = true 8 | 9 | [*.py] 10 | indent_style = space 11 | indent_size = 4 12 | 13 | [*.md] 14 | trim_trailing_whitespace = false 15 | -------------------------------------------------------------------------------- /.flake8: -------------------------------------------------------------------------------- 1 | [flake8] 2 | select = QGS 3 | per-file-ignores = 4 | src/pytest_qgis/pytest_qgis.py:QGS105 5 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: bug 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Environment (please complete the following information):** 24 | - OS: [e.g. Windows 10, Fedora 32] 25 | - Python: [e.g. 3.8] 26 | - Cookiecutter: [e.g. 1.7.2] 27 | 28 | 29 | **Additional context** 30 | Add any other context about the problem here. 31 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: enhancement 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Expected behaviour** 11 | A clear and concise description of what you'd like to happen if you do x. 12 | 13 | **Current behaviour** 14 | A clear and concise description of the current behaviour when you do x. If completely new feature, leave empty. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. If relevant please also provide version of the plugin and information on the system you are running it on. 21 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | pull_request: 5 | push: 6 | branches: [ main ] 7 | 8 | jobs: 9 | tests: 10 | runs-on: ubuntu-latest 11 | container: 12 | image: qgis/qgis:${{ matrix.qgis-tags }} 13 | strategy: 14 | matrix: 15 | qgis-tags: [ release-3_28, release-3_34, latest ] 16 | fail-fast: false 17 | 18 | steps: 19 | - uses: actions/checkout@v4 20 | - name: Install venv 21 | run: | 22 | apt update 23 | apt install -y python3-venv 24 | - name: Create virtualenv and install dependencies 25 | run: | 26 | python3 -m venv --system-site-packages .venv 27 | .venv/bin/pip install -U pip setuptools 28 | .venv/bin/pip install -qr requirements.txt pytest-cov 29 | .venv/bin/pip install -e . 30 | - name: Run tests 31 | env: 32 | QGIS_IN_CI: 1 33 | run: > 34 | xvfb-run -s '+extension GLX -screen 0 1024x768x24' 35 | .venv/bin/pytest -v --cov src/pytest_qgis --cov-report=xml -m 'not with_pytest_qt' -p no:pytest-qt tests 36 | 37 | # Upload coverage report. Will not work if the repo is private 38 | - name: Upload coverage to Codecov 39 | if: ${{ matrix.qgis-tags == 'latest' && !github.event.repository.private }} 40 | uses: codecov/codecov-action@v3 41 | with: 42 | token: ${{ secrets.CODECOV_TOKEN }} 43 | file: ./coverage.xml 44 | flags: pytest 45 | fail_ci_if_error: false # set to true when upload is working 46 | verbose: false 47 | 48 | 49 | code-style: 50 | runs-on: ubuntu-latest 51 | steps: 52 | - uses: actions/checkout@v4 53 | - uses: actions/setup-python@v4 54 | - uses: pre-commit/action@v3.0.0 55 | 56 | # changelog: 57 | # runs-on: ubuntu-latest 58 | # steps: 59 | # - uses: actions/checkout@v2 60 | # - name: Run Changelog CI 61 | # uses: saadmk11/changelog-ci@v1.0.0 62 | # with: 63 | # config_file: changelog-ci-config.json 64 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: release & publish workflow 2 | 3 | on: 4 | push: 5 | tags: 6 | - "v*" 7 | 8 | jobs: 9 | build: 10 | name: Build Release 11 | runs-on: ubuntu-latest 12 | 13 | steps: 14 | - uses: actions/checkout@v4 15 | - uses: actions/setup-python@v4 16 | with: 17 | python-version: 3.9 18 | cache: pip 19 | 20 | - name: Install dependencies 21 | run: | 22 | python -m pip install --upgrade build 23 | 24 | - name: Build wheels and source tarball 25 | run: >- 26 | python -m build 27 | 28 | - name: Archive Production Artifact 29 | uses: actions/upload-artifact@v3 30 | with: 31 | name: dist 32 | path: dist 33 | 34 | release: 35 | name: Deploy Release 36 | needs: build 37 | runs-on: ubuntu-latest 38 | env: 39 | name: pypi-release 40 | url: https://pypi.org/p/pytest-qgis 41 | permissions: 42 | id-token: write # IMPORTANT: this permission is mandatory for trusted publishing 43 | contents: write 44 | steps: 45 | - name: Checkout Repo 46 | uses: actions/checkout@v4 47 | - name: Download Artifact 48 | uses: actions/download-artifact@v3 49 | with: 50 | name: dist 51 | path: dist 52 | - name: Get version from tag 53 | id: tag_name 54 | run: | 55 | echo ::set-output name=current_version::${GITHUB_REF#refs/tags/v} 56 | shell: bash 57 | 58 | - name: create github release 59 | id: create_release 60 | uses: softprops/action-gh-release@v1 61 | env: 62 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 63 | with: 64 | files: dist/* 65 | draft: false 66 | prerelease: false 67 | 68 | - name: publish to PyPI 69 | uses: pypa/gh-action-pypi-publish@release/v1 70 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | env/ 12 | .venv/ 13 | build/ 14 | develop-eggs/ 15 | dist/ 16 | downloads/ 17 | eggs/ 18 | .eggs/ 19 | lib/ 20 | lib64/ 21 | parts/ 22 | sdist/ 23 | var/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | 28 | # PyInstaller 29 | # Usually these files are written by a python script from a template 30 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 31 | *.manifest 32 | *.spec 33 | 34 | # Installer logs 35 | pip-log.txt 36 | pip-delete-this-directory.txt 37 | 38 | # Unit test / coverage reports 39 | htmlcov/ 40 | .tox/ 41 | .coverage 42 | .coverage.* 43 | .cache 44 | nosetests.xml 45 | coverage.xml 46 | *,cover 47 | .hypothesis/ 48 | .pytest_cache 49 | 50 | # Translations 51 | *.mo 52 | *.pot 53 | 54 | # Django stuff: 55 | *.log 56 | local_settings.py 57 | 58 | # Flask instance folder 59 | instance/ 60 | 61 | # Sphinx documentation 62 | docs/_build/ 63 | 64 | # MkDocs documentation 65 | /site/ 66 | 67 | # PyBuilder 68 | target/ 69 | 70 | # IPython Notebook 71 | .ipynb_checkpoints 72 | 73 | # pyenv 74 | .python-version 75 | /tests/data/db.gpkg-shm 76 | /tests/data/db.gpkg-wal 77 | /tests/data/*.tif.aux.xml 78 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/pre-commit/pre-commit-hooks 3 | rev: v4.4.0 4 | hooks: 5 | - id: end-of-file-fixer 6 | - id: trailing-whitespace 7 | - repo: https://github.com/pre-commit/mirrors-mypy 8 | rev: v1.3.0 9 | hooks: 10 | - id: mypy 11 | args: [--ignore-missing-imports] 12 | - repo: https://github.com/astral-sh/ruff-pre-commit 13 | # Ruff version. 14 | rev: v0.1.6 15 | hooks: 16 | # Run the linter. 17 | - id: ruff 18 | args: [--fix, --exit-non-zero-on-fix, --extend-fixable=F401] 19 | # Run the formatter. 20 | - id: ruff-format 21 | 22 | - repo: https://github.com/PyCQA/flake8 23 | rev: 6.0.0 24 | hooks: 25 | - id: flake8 26 | additional_dependencies: 27 | - flake8-qgis==1.0.0 28 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "editorconfig.editorconfig", 4 | "ms-python.flake8", 5 | "charliermarsh.ruff", 6 | "ms-python.python" 7 | ] 8 | } 9 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "python.languageServer": "Pylance", 3 | "flake8.importStrategy": "fromEnvironment", 4 | "ruff.importStrategy": "fromEnvironment", 5 | "mypy-type-checker.importStrategy": "fromEnvironment", 6 | "[python]": { 7 | "editor.formatOnSave": true, 8 | "editor.codeActionsOnSave": { 9 | "source.organizeImports.ruff": true, 10 | "source.fixAll": true 11 | }, 12 | "editor.defaultFormatter": "charliermarsh.ruff" 13 | }, 14 | "python.testing.pytestArgs": [ 15 | "tests" 16 | ], 17 | "python.testing.unittestEnabled": false, 18 | "python.testing.pytestEnabled": true 19 | } 20 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Unreleased 2 | 3 | # Version 2.1.0 (14-06-2024) 4 | 5 | ## New Features 6 | 7 | * Add `clean_qgis_layer` decorator back alongside with automatic cleaning [#64](https://github.com/GispoCoding/pytest-qgis/pull/64) 8 | 9 | ## Fixes 10 | 11 | * [#53](https://github.com/GispoCoding/pytest-qgis/pull/53) Allow using MagicMocks to mock layers without problems 12 | * [#60](https://github.com/GispoCoding/pytest-qgis/pull/60) Allows using CRS properly again 13 | * [#62](https://github.com/GispoCoding/pytest-qgis/pull/62) Map does no longer zoom to the first added layer upon processing the events when using `qgis_show_map` marker 14 | 15 | # Version 2.0.0 (29-11-2023) 16 | 17 | ## New Features 18 | 19 | * [#45](https://github.com/GispoCoding/pytest-qgis/pull/45) Clean map layers automatically. 20 | * [#48](https://github.com/GispoCoding/pytest-qgis/pull/48) Add possibility to raise errors if there are warnings or errors in attribute form when adding feature. 21 | 22 | ## Fixes 23 | 24 | * [#45](https://github.com/GispoCoding/pytest-qgis/pull/45) Ensure that the projection is set when replacing layers with projected ones. 25 | 26 | ## Maintenance tasks 27 | 28 | * [#51](https://github.com/GispoCoding/pytest-qgis/pull/51) Change linting to use Ruff. 29 | * [#50](https://github.com/GispoCoding/pytest-qgis/pull/50) Migrate to pyproject.toml and upgrade development dependencies. 30 | 31 | ## API Breaks 32 | 33 | * [#47](https://github.com/GispoCoding/pytest-qgis/pull/48) Remove deprecated functionality: 34 | * `new_project()` fixture 35 | * `module_qgis_bot()` fixture 36 | * `clean_qgis_layer()` function 37 | * [#46](https://github.com/GispoCoding/pytest-qgis/pull/46) Use session scope in qgis_bot fixture 38 | * [#48](https://github.com/GispoCoding/pytest-qgis/pull/48) The `create_feature_with_attribute_dialog()` function now, by default, raises a ValueError when a created feature violates enforced attribute constraints. 39 | 40 | # Version 1.3.5 (30-06-2023) 41 | * [#34](https://github.com/GispoCoding/pytest-qgis/pull/34) Use tempfile instead of protected TempPathFactory in QGIS config path creation 42 | * [#39](https://github.com/GispoCoding/pytest-qgis/pull/39) Improve code style and CI 43 | * [#40](https://github.com/GispoCoding/pytest-qgis/pull/40) Improve showing map 44 | * [#42](https://github.com/GispoCoding/pytest-qgis/pull/42) Suppress errors when deleting temp dir 45 | 46 | # Version: 1.3.4 (31-05-2023) 47 | 48 | * [#34](https://github.com/GispoCoding/pytest-qgis/pull/34): Use tempfile instead of protected TempPathFactory in QGIS config path creation 49 | 50 | # Version: 1.3.3 (31-05-2023) 51 | 52 | * [#29](https://github.com/GispoCoding/pytest-qgis/pull/29): Release map canvas properly 53 | 54 | # Version: 1.3.2 (26-06-2022) 55 | 56 | * [#23](https://github.com/GispoCoding/pytest-qgis/pull/23): Support QToolBar as an arg in iface.addToolBar 57 | 58 | # Version: 1.3.1 (17-03-2022) 59 | 60 | * [#21](https://github.com/GispoCoding/pytest-qgis/pull/21): Add a newProjectCreated signal to QgisInterface mock class 61 | 62 | 63 | # Version: 1.3.0 (18-01-2022) 64 | 65 | * [#17](https://github.com/GispoCoding/pytest-qgis/pull/17): Add QgisBot to make it easier to access utility functions 66 | * [#14](https://github.com/GispoCoding/pytest-qgis/pull/14): Use QMainWindow with parent and store toolbars with iface.addToolBar 67 | 68 | # Version: 1.2.0 (17-01-2022) 69 | 70 | * [#10](https://github.com/GispoCoding/pytest-qgis/pull/10): Allow showing attribute dialog 71 | * [#9](https://github.com/GispoCoding/pytest-qgis/pull/9): Use QgsLayerTreeMapCanvasBridge to keep the layer order correct 72 | 73 | 74 | # Version: 1.1.2 (12-16-2021) 75 | 76 | * [#8](https://github.com/GispoCoding/pytest-qgis/pull/8): Add stub iface.activeLayer logic 77 | 78 | # Version: 1.1.1 (12-07-2021) 79 | 80 | * Reduce test time by getting basemap only when needed 81 | 82 | # Version: 1.1.0 (11-25-2021) 83 | 84 | * [#5](https://github.com/GispoCoding/pytest-qgis/pull/5): Add configurable options 85 | * [#5](https://github.com/GispoCoding/pytest-qgis/pull/5): Add clean_qgis_layer decorator 86 | * [#5](https://github.com/GispoCoding/pytest-qgis/pull/5): Deprecate new_project in favour of qgis_new_project 87 | * [#5](https://github.com/GispoCoding/pytest-qgis/pull/5): Add qgis_show_map marker and functionality 88 | 89 | # Version: 1.0.3 (11-22-2021) 90 | 91 | * [#4](https://github.com/GispoCoding/pytest-qgis/pull/4): Use hook to patch the imported iface 92 | 93 | # Version: 1.0.2 (10-08-2021) 94 | 95 | * Check if canvas is deleted before setting layers 96 | 97 | # Version: 1.0.1 (10-07-2021) 98 | 99 | * Include py.typed 100 | 101 | # Version: 1.0.0 (10-07-2021) 102 | 103 | * [#1](https://github.com/GispoCoding/pytest-qgis/pull/1) Add processing framework initialization fixture and test 104 | * [#3](https://github.com/GispoCoding/pytest-qgis/pull/3): Add type hints 105 | * [#10](https://github.com/GispoCoding/pytest-qgis/pull/2): Use empty configuration path in tests 106 | 107 | # Version: 0.1.0 108 | 109 | * Initial plugin 110 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | GNU GENERAL PUBLIC LICENSE 2 | Version 2, June 1991 3 | 4 | Copyright (C) 1989, 1991 Free Software Foundation, Inc., 5 | 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA 6 | Everyone is permitted to copy and distribute verbatim copies 7 | of this license document, but changing it is not allowed. 8 | 9 | Preamble 10 | 11 | The licenses for most software are designed to take away your 12 | freedom to share and change it. By contrast, the GNU General Public 13 | License is intended to guarantee your freedom to share and change free 14 | software--to make sure the software is free for all its users. This 15 | General Public License applies to most of the Free Software 16 | Foundation's software and to any other program whose authors commit to 17 | using it. (Some other Free Software Foundation software is covered by 18 | the GNU Lesser General Public License instead.) You can apply it to 19 | your programs, too. 20 | 21 | When we speak of free software, we are referring to freedom, not 22 | price. Our General Public Licenses are designed to make sure that you 23 | have the freedom to distribute copies of free software (and charge for 24 | this service if you wish), that you receive source code or can get it 25 | if you want it, that you can change the software or use pieces of it 26 | in new free programs; and that you know you can do these things. 27 | 28 | To protect your rights, we need to make restrictions that forbid 29 | anyone to deny you these rights or to ask you to surrender the rights. 30 | These restrictions translate to certain responsibilities for you if you 31 | distribute copies of the software, or if you modify it. 32 | 33 | For example, if you distribute copies of such a program, whether 34 | gratis or for a fee, you must give the recipients all the rights that 35 | you have. You must make sure that they, too, receive or can get the 36 | source code. And you must show them these terms so they know their 37 | rights. 38 | 39 | We protect your rights with two steps: (1) copyright the software, and 40 | (2) offer you this license which gives you legal permission to copy, 41 | distribute and/or modify the software. 42 | 43 | Also, for each author's protection and ours, we want to make certain 44 | that everyone understands that there is no warranty for this free 45 | software. If the software is modified by someone else and passed on, we 46 | want its recipients to know that what they have is not the original, so 47 | that any problems introduced by others will not reflect on the original 48 | authors' reputations. 49 | 50 | Finally, any free program is threatened constantly by software 51 | patents. We wish to avoid the danger that redistributors of a free 52 | program will individually obtain patent licenses, in effect making the 53 | program proprietary. To prevent this, we have made it clear that any 54 | patent must be licensed for everyone's free use or not licensed at all. 55 | 56 | The precise terms and conditions for copying, distribution and 57 | modification follow. 58 | 59 | GNU GENERAL PUBLIC LICENSE 60 | TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION 61 | 62 | 0. This License applies to any program or other work which contains 63 | a notice placed by the copyright holder saying it may be distributed 64 | under the terms of this General Public License. The "Program", below, 65 | refers to any such program or work, and a "work based on the Program" 66 | means either the Program or any derivative work under copyright law: 67 | that is to say, a work containing the Program or a portion of it, 68 | either verbatim or with modifications and/or translated into another 69 | language. (Hereinafter, translation is included without limitation in 70 | the term "modification".) Each licensee is addressed as "you". 71 | 72 | Activities other than copying, distribution and modification are not 73 | covered by this License; they are outside its scope. The act of 74 | running the Program is not restricted, and the output from the Program 75 | is covered only if its contents constitute a work based on the 76 | Program (independent of having been made by running the Program). 77 | Whether that is true depends on what the Program does. 78 | 79 | 1. You may copy and distribute verbatim copies of the Program's 80 | source code as you receive it, in any medium, provided that you 81 | conspicuously and appropriately publish on each copy an appropriate 82 | copyright notice and disclaimer of warranty; keep intact all the 83 | notices that refer to this License and to the absence of any warranty; 84 | and give any other recipients of the Program a copy of this License 85 | along with the Program. 86 | 87 | You may charge a fee for the physical act of transferring a copy, and 88 | you may at your option offer warranty protection in exchange for a fee. 89 | 90 | 2. You may modify your copy or copies of the Program or any portion 91 | of it, thus forming a work based on the Program, and copy and 92 | distribute such modifications or work under the terms of Section 1 93 | above, provided that you also meet all of these conditions: 94 | 95 | a) You must cause the modified files to carry prominent notices 96 | stating that you changed the files and the date of any change. 97 | 98 | b) You must cause any work that you distribute or publish, that in 99 | whole or in part contains or is derived from the Program or any 100 | part thereof, to be licensed as a whole at no charge to all third 101 | parties under the terms of this License. 102 | 103 | c) If the modified program normally reads commands interactively 104 | when run, you must cause it, when started running for such 105 | interactive use in the most ordinary way, to print or display an 106 | announcement including an appropriate copyright notice and a 107 | notice that there is no warranty (or else, saying that you provide 108 | a warranty) and that users may redistribute the program under 109 | these conditions, and telling the user how to view a copy of this 110 | License. (Exception: if the Program itself is interactive but 111 | does not normally print such an announcement, your work based on 112 | the Program is not required to print an announcement.) 113 | 114 | These requirements apply to the modified work as a whole. If 115 | identifiable sections of that work are not derived from the Program, 116 | and can be reasonably considered independent and separate works in 117 | themselves, then this License, and its terms, do not apply to those 118 | sections when you distribute them as separate works. But when you 119 | distribute the same sections as part of a whole which is a work based 120 | on the Program, the distribution of the whole must be on the terms of 121 | this License, whose permissions for other licensees extend to the 122 | entire whole, and thus to each and every part regardless of who wrote it. 123 | 124 | Thus, it is not the intent of this section to claim rights or contest 125 | your rights to work written entirely by you; rather, the intent is to 126 | exercise the right to control the distribution of derivative or 127 | collective works based on the Program. 128 | 129 | In addition, mere aggregation of another work not based on the Program 130 | with the Program (or with a work based on the Program) on a volume of 131 | a storage or distribution medium does not bring the other work under 132 | the scope of this License. 133 | 134 | 3. You may copy and distribute the Program (or a work based on it, 135 | under Section 2) in object code or executable form under the terms of 136 | Sections 1 and 2 above provided that you also do one of the following: 137 | 138 | a) Accompany it with the complete corresponding machine-readable 139 | source code, which must be distributed under the terms of Sections 140 | 1 and 2 above on a medium customarily used for software interchange; or, 141 | 142 | b) Accompany it with a written offer, valid for at least three 143 | years, to give any third party, for a charge no more than your 144 | cost of physically performing source distribution, a complete 145 | machine-readable copy of the corresponding source code, to be 146 | distributed under the terms of Sections 1 and 2 above on a medium 147 | customarily used for software interchange; or, 148 | 149 | c) Accompany it with the information you received as to the offer 150 | to distribute corresponding source code. (This alternative is 151 | allowed only for noncommercial distribution and only if you 152 | received the program in object code or executable form with such 153 | an offer, in accord with Subsection b above.) 154 | 155 | The source code for a work means the preferred form of the work for 156 | making modifications to it. For an executable work, complete source 157 | code means all the source code for all modules it contains, plus any 158 | associated interface definition files, plus the scripts used to 159 | control compilation and installation of the executable. However, as a 160 | special exception, the source code distributed need not include 161 | anything that is normally distributed (in either source or binary 162 | form) with the major components (compiler, kernel, and so on) of the 163 | operating system on which the executable runs, unless that component 164 | itself accompanies the executable. 165 | 166 | If distribution of executable or object code is made by offering 167 | access to copy from a designated place, then offering equivalent 168 | access to copy the source code from the same place counts as 169 | distribution of the source code, even though third parties are not 170 | compelled to copy the source along with the object code. 171 | 172 | 4. You may not copy, modify, sublicense, or distribute the Program 173 | except as expressly provided under this License. Any attempt 174 | otherwise to copy, modify, sublicense or distribute the Program is 175 | void, and will automatically terminate your rights under this License. 176 | However, parties who have received copies, or rights, from you under 177 | this License will not have their licenses terminated so long as such 178 | parties remain in full compliance. 179 | 180 | 5. You are not required to accept this License, since you have not 181 | signed it. However, nothing else grants you permission to modify or 182 | distribute the Program or its derivative works. These actions are 183 | prohibited by law if you do not accept this License. Therefore, by 184 | modifying or distributing the Program (or any work based on the 185 | Program), you indicate your acceptance of this License to do so, and 186 | all its terms and conditions for copying, distributing or modifying 187 | the Program or works based on it. 188 | 189 | 6. Each time you redistribute the Program (or any work based on the 190 | Program), the recipient automatically receives a license from the 191 | original licensor to copy, distribute or modify the Program subject to 192 | these terms and conditions. You may not impose any further 193 | restrictions on the recipients' exercise of the rights granted herein. 194 | You are not responsible for enforcing compliance by third parties to 195 | this License. 196 | 197 | 7. If, as a consequence of a court judgment or allegation of patent 198 | infringement or for any other reason (not limited to patent issues), 199 | conditions are imposed on you (whether by court order, agreement or 200 | otherwise) that contradict the conditions of this License, they do not 201 | excuse you from the conditions of this License. If you cannot 202 | distribute so as to satisfy simultaneously your obligations under this 203 | License and any other pertinent obligations, then as a consequence you 204 | may not distribute the Program at all. For example, if a patent 205 | license would not permit royalty-free redistribution of the Program by 206 | all those who receive copies directly or indirectly through you, then 207 | the only way you could satisfy both it and this License would be to 208 | refrain entirely from distribution of the Program. 209 | 210 | If any portion of this section is held invalid or unenforceable under 211 | any particular circumstance, the balance of the section is intended to 212 | apply and the section as a whole is intended to apply in other 213 | circumstances. 214 | 215 | It is not the purpose of this section to induce you to infringe any 216 | patents or other property right claims or to contest validity of any 217 | such claims; this section has the sole purpose of protecting the 218 | integrity of the free software distribution system, which is 219 | implemented by public license practices. Many people have made 220 | generous contributions to the wide range of software distributed 221 | through that system in reliance on consistent application of that 222 | system; it is up to the author/donor to decide if he or she is willing 223 | to distribute software through any other system and a licensee cannot 224 | impose that choice. 225 | 226 | This section is intended to make thoroughly clear what is believed to 227 | be a consequence of the rest of this License. 228 | 229 | 8. If the distribution and/or use of the Program is restricted in 230 | certain countries either by patents or by copyrighted interfaces, the 231 | original copyright holder who places the Program under this License 232 | may add an explicit geographical distribution limitation excluding 233 | those countries, so that distribution is permitted only in or among 234 | countries not thus excluded. In such case, this License incorporates 235 | the limitation as if written in the body of this License. 236 | 237 | 9. The Free Software Foundation may publish revised and/or new versions 238 | of the General Public License from time to time. Such new versions will 239 | be similar in spirit to the present version, but may differ in detail to 240 | address new problems or concerns. 241 | 242 | Each version is given a distinguishing version number. If the Program 243 | specifies a version number of this License which applies to it and "any 244 | later version", you have the option of following the terms and conditions 245 | either of that version or of any later version published by the Free 246 | Software Foundation. If the Program does not specify a version number of 247 | this License, you may choose any version ever published by the Free Software 248 | Foundation. 249 | 250 | 10. If you wish to incorporate parts of the Program into other free 251 | programs whose distribution conditions are different, write to the author 252 | to ask for permission. For software which is copyrighted by the Free 253 | Software Foundation, write to the Free Software Foundation; we sometimes 254 | make exceptions for this. Our decision will be guided by the two goals 255 | of preserving the free status of all derivatives of our free software and 256 | of promoting the sharing and reuse of software generally. 257 | 258 | NO WARRANTY 259 | 260 | 11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY 261 | FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN 262 | OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES 263 | PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED 264 | OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF 265 | MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS 266 | TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE 267 | PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, 268 | REPAIR OR CORRECTION. 269 | 270 | 12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING 271 | WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR 272 | REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, 273 | INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING 274 | OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED 275 | TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY 276 | YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER 277 | PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE 278 | POSSIBILITY OF SUCH DAMAGES. 279 | 280 | END OF TERMS AND CONDITIONS 281 | 282 | How to Apply These Terms to Your New Programs 283 | 284 | If you develop a new program, and you want it to be of the greatest 285 | possible use to the public, the best way to achieve this is to make it 286 | free software which everyone can redistribute and change under these terms. 287 | 288 | To do so, attach the following notices to the program. It is safest 289 | to attach them to the start of each source file to most effectively 290 | convey the exclusion of warranty; and each file should have at least 291 | the "copyright" line and a pointer to where the full notice is found. 292 | 293 | 294 | Copyright (C) 295 | 296 | This program is free software; you can redistribute it and/or modify 297 | it under the terms of the GNU General Public License as published by 298 | the Free Software Foundation; either version 2 of the License, or 299 | (at your option) any later version. 300 | 301 | This program is distributed in the hope that it will be useful, 302 | but WITHOUT ANY WARRANTY; without even the implied warranty of 303 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 304 | GNU General Public License for more details. 305 | 306 | You should have received a copy of the GNU General Public License along 307 | with this program; if not, write to the Free Software Foundation, Inc., 308 | 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. 309 | 310 | Also add information on how to contact you by electronic and paper mail. 311 | 312 | If the program is interactive, make it output a short notice like this 313 | when it starts in an interactive mode: 314 | 315 | Gnomovision version 69, Copyright (C) year name of author 316 | Gnomovision comes with ABSOLUTELY NO WARRANTY; for details type `show w'. 317 | This is free software, and you are welcome to redistribute it 318 | under certain conditions; type `show c' for details. 319 | 320 | The hypothetical commands `show w' and `show c' should show the appropriate 321 | parts of the General Public License. Of course, the commands you use may 322 | be called something other than `show w' and `show c'; they could even be 323 | mouse-clicks or menu items--whatever suits your program. 324 | 325 | You should also get your employer (if you work as a programmer) or your 326 | school, if any, to sign a "copyright disclaimer" for the program, if 327 | necessary. Here is a sample; alter the names: 328 | 329 | Yoyodyne, Inc., hereby disclaims all copyright interest in the program 330 | `Gnomovision' (which makes passes at compilers) written by James Hacker. 331 | 332 | , 1 April 1989 333 | Ty Coon, President of Vice 334 | 335 | This General Public License does not permit incorporating your program into 336 | proprietary programs. If your program is a subroutine library, you may 337 | consider it more useful to permit linking proprietary applications with the 338 | library. If this is what you want to do, use the GNU Lesser General 339 | Public License instead of this License. 340 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # pytest-qgis 2 | 3 | [![PyPI version](https://badge.fury.io/py/pytest-qgis.svg)](https://badge.fury.io/py/pytest-qgis) 4 | [![Downloads](https://img.shields.io/pypi/dm/pytest-qgis.svg)](https://pypistats.org/packages/pytest-qgis) 5 | ![CI](https://github.com/GispoCoding/pytest-qgis/workflows/CI/badge.svg) 6 | [![Code on Github](https://img.shields.io/badge/Code-GitHub-brightgreen)](https://github.com/GispoCoding/pytest-qgis) 7 | [![Ruff](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json)](https://github.com/astral-sh/ruff) 8 | [![pre-commit](https://img.shields.io/badge/pre--commit-enabled-brightgreen?logo=pre-commit&logoColor=white)](https://github.com/pre-commit/pre-commit) 9 | [![codecov.io](https://codecov.io/github/GispoCoding/pytest-qgis/coverage.svg?branch=main)](https://codecov.io/github/GispoCoding/pytest-qgis?branch=main) 10 | 11 | A [pytest](https://docs.pytest.org) plugin for testing QGIS python plugins. 12 | 13 | ## Features 14 | 15 | This plugin makes it easier to write QGIS plugin tests with the help of some fixtures and hooks: 16 | 17 | ### Fixtures 18 | 19 | * `qgis_app` returns and eventually exits fully 20 | configured [`QgsApplication`](https://qgis.org/pyqgis/master/core/QgsApplication.html). This fixture is called 21 | automatically on the start of pytest session. 22 | * `qgis_bot` returns a [`QgisBot`](#qgisbot), which holds common utility methods for interacting with QGIS. 23 | * `qgis_canvas` returns [`QgsMapCanvas`](https://qgis.org/pyqgis/master/gui/QgsMapCanvas.html). 24 | * `qgis_parent` returns the QWidget used as parent of the `qgis_canvas` 25 | * `qgis_iface` returns stubbed [`QgsInterface`](https://qgis.org/pyqgis/master/gui/QgisInterface.html) 26 | * `qgis_new_project` makes sure that all the map layers and configurations are removed. This should be used with tests 27 | that add stuff to [`QgsProject`](https://qgis.org/pyqgis/master/core/QgsProject.html). 28 | * `qgis_processing` initializes the processing framework. This can be used when testing code that 29 | calls `processing.run(...)`. 30 | * `qgis_version` returns QGIS version number as integer. 31 | * `qgis_world_map_geopackage` returns Path to the world_map.gpkg that ships with QGIS 32 | * `qgis_countries_layer` returns Natural Earth countries layer from world.map.gpkg as QgsVectorLayer 33 | 34 | ### Markers 35 | 36 | * `qgis_show_map` lets developer inspect the QGIS map visually during the test and also at the teardown of the test. Full signature of the marker 37 | is: 38 | ```python 39 | @pytest.mark.qgis_show_map(timeout: int = 30, add_basemap: bool = False, zoom_to_common_extent: bool = True, extent: QgsRectangle = None) 40 | ``` 41 | * `timeout` is the time in seconds until the map is closed. If timeout is zero, the map will be closed in teardown. 42 | * `add_basemap` when set to True, adds Natural Earth countries layer as the basemap for the map. 43 | * `zoom_to_common_extent` when set to True, centers the map around all layers in the project. 44 | * `extent` is alternative to `zoom_to_common_extent` and lets user specify the extent 45 | as [`QgsRectangle`](https://qgis.org/pyqgis/master/core/QgsRectangle.html) 46 | 47 | Check the marker api [documentation](https://docs.pytest.org/en/latest/mark.html) 48 | and [examples](https://docs.pytest.org/en/latest/example/markers.html#marking-whole-classes-or-modules) for the ways 49 | markers can be used. 50 | 51 | ### Hooks 52 | 53 | * `pytest_configure` hook is used to initialize and 54 | configure [`QgsApplication`](https://qgis.org/pyqgis/master/core/QgsApplication.html). With QGIS >= 3.18 it is also 55 | used to patch `qgis.utils.iface` with `qgis_iface` automatically. 56 | 57 | > Be careful not to import modules importing `qgis.utils.iface` in the root of conftest, because the `pytest_configure` hook has not yet patched `iface` in that point. See [this issue](https://github.com/GispoCoding/pytest-qgis/issues/35) for details. 58 | 59 | * `pytest_runtest_teardown` hook is used to ensure that all layer fixtures of any scope are cleaned properly without causing segmentation faults. The layer fixtures that are cleaned automatically must have some of the following keywords in their name: "layer", "lyr", "raster", "rast", "tif". 60 | 61 | 62 | ### Utility tools 63 | 64 | * `clean_qgis_layer` decorator found in `pytest_qgis.utils` can be used with `QgsMapLayer` fixtures to ensure that they 65 | are cleaned properly if they are used but not added to the `QgsProject`. This is only needed with layers with other than memory provider. 66 | 67 | This decorator works only with fixtures that **return** QgsMapLayer instances. 68 | There is no support for fixtures that use yield. 69 | 70 | This decorator is an alternative way of cleaning the layers, since `pytest_runtest_teardown` hook cleans layer fixtures automatically by the keyword. 71 | 72 | ```python 73 | # conftest.py or start of a test file 74 | import pytest 75 | from pytest_qgis.utils import clean_qgis_layer 76 | from qgis.core import QgsVectorLayer 77 | 78 | @pytest.fixture() 79 | @clean_qgis_layer 80 | def geojson() -> QgsVectorLayer: 81 | return QgsVectorLayer("layer_file.geojson", "some layer") 82 | 83 | # This will be cleaned automatically since it contains the keyword "layer" in its name 84 | @pytest.fixture() 85 | def geojson_layer() -> QgsVectorLayer: 86 | return QgsVectorLayer("layer_file2.geojson", "some layer") 87 | ``` 88 | 89 | 90 | ### Command line options 91 | 92 | * `--qgis_disable_gui` can be used to disable graphical user interface in tests. This speeds up the tests that use Qt 93 | widgets of the plugin. 94 | * `--qgis_disable_init` can be used to prevent QGIS (QgsApplication) from initializing. Mainly used in internal testing. 95 | 96 | ### ini-options 97 | 98 | * `qgis_qui_enabled` whether the QUI will be visible or not. Defaults to `True`. Command line 99 | option `--qgis_disable_gui` will override this. 100 | * `qgis_canvas_width` width of the QGIS canvas in pixels. Defaults to 600. 101 | * `qgis_canvas_height` height of the QGIS canvas in pixels. Defaults to 600. 102 | 103 | ## QgisBot 104 | 105 | Class to hold common utility methods for interacting with QGIS. Check [test_qgis_bot.py](tests%2Ftest_qgis_bot.py) for usage examples. Here are some of the methods: 106 | 107 | * `create_feature_with_attribute_dialog` method can be used to create a feature with default values using QgsAttributeDialog. This 108 | ensures that all the default values are honored and for example boolean fields are either true or false, not null. 109 | * `get_qgs_attribute_dialog_widgets_by_name` function can be used to get dictionary of the `QgsAttributeDialog` widgets. 110 | Check the test [test_qgis_ui.py::test_attribute_dialog_change](./tests/visual/test_qgis_ui.py) for a usage example. 111 | 112 | ## Requirements 113 | 114 | This pytest plugin requires QGIS >= 3.16 to work though it might work with older versions. 115 | 116 | ## Installation 117 | 118 | Install with `pip`: 119 | 120 | ```bash 121 | pip install pytest-qgis 122 | ``` 123 | 124 | ## Development environment 125 | 126 | ```shell 127 | # Create a virtual environment with qgis and dependencies available 128 | $ python -m venv .venv --system-site-packages 129 | # Activate the virtual environment 130 | $ source .venv/bin/activate 131 | # Update pip and setuptools 132 | $ python -m pip install -U pip setuptools 133 | $ pip install pip-tools 134 | # Install dependencies 135 | $ pip-sync requirements.txt requirements-dev.txt 136 | # Install pre-commit hooks 137 | $ pre-commit install 138 | ``` 139 | 140 | ### Updating dependencies 141 | 142 | 1. `pip-compile --upgrade` 143 | 2. `pip-compile --upgrade requirements-dev.in` 144 | 145 | ## Contributing 146 | 147 | Contributions are very welcome. 148 | 149 | ## License 150 | 151 | Distributed under the terms of the `GNU GPL v2.0` license, "pytest-qgis" is free and open source software. 152 | -------------------------------------------------------------------------------- /changelog-ci-config.json: -------------------------------------------------------------------------------- 1 | { 2 | "comment_changelog": true, 3 | "pull_request_title_regex": "(?i:release)" 4 | } 5 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["setuptools", "setuptools-scm"] 3 | build-backend = "setuptools.build_meta" 4 | 5 | [project] 6 | name = "pytest-qgis" 7 | authors = [{ name = "Gispo Ltd.", email = "info@gispo.fi" }] 8 | maintainers = [{ name = "Gispo Ltd.", email = "info@gispo.fi" }] 9 | 10 | description = "A pytest plugin for testing QGIS python plugins" 11 | readme = "README.md" 12 | 13 | dynamic = ["version"] 14 | keywords = ["pytest", "qgis", "QGIS", "PyQGIS"] 15 | 16 | requires-python = ">=3.7" 17 | license = { file = "LICENSE" } 18 | classifiers = [ 19 | "Development Status :: 5 - Production/Stable", 20 | "Framework :: Pytest", 21 | "Intended Audience :: Developers", 22 | "Topic :: Software Development :: Testing", 23 | "Programming Language :: Python", 24 | "Programming Language :: Python :: 3", 25 | "Programming Language :: Python :: 3.7", 26 | "Programming Language :: Python :: 3.8", 27 | "Programming Language :: Python :: 3.9", 28 | "Programming Language :: Python :: 3.10", 29 | "Programming Language :: Python :: 3.11", 30 | "Programming Language :: Python :: 3 :: Only", 31 | "Programming Language :: Python :: Implementation :: CPython", 32 | "Programming Language :: Python :: Implementation :: PyPy", 33 | "Operating System :: OS Independent", 34 | "License :: OSI Approved :: GNU General Public License v2 (GPLv2)", 35 | ] 36 | dependencies = ["pytest >= 6.0"] 37 | 38 | [project.urls] 39 | homepage = "https://github.com/GispoCoding/pytest-qgis" 40 | repository = "https://github.com/GispoCoding/pytest-qgis" 41 | changelog = "https://github.com/GispoCoding/pytest-qgis/CHANGELOG.md" 42 | 43 | [project.entry-points.pytest11] 44 | pytest_qgis = "pytest_qgis" 45 | 46 | 47 | [tool] 48 | 49 | [tool.setuptools.packages.find] 50 | where = ["src"] 51 | 52 | [tool.setuptools.dynamic] 53 | version = { attr = "pytest_qgis._version.__version__" } 54 | 55 | [tool.setuptools.package-data] 56 | pytest_qgis = ["py.typed"] 57 | 58 | 59 | [tool.pytest.ini_options] 60 | doctest_encoding = "utf-8" 61 | markers = [ 62 | "with_pytest_qt: these tests require pytest-qt (deselect with '-m \"with_pytest_qt\"')" 63 | ] 64 | 65 | [tool.isort] 66 | profile = "black" 67 | multi_line_output = 3 68 | 69 | 70 | [tool.mypy] 71 | disable_error_code = "misc" 72 | ignore_missing_imports = true 73 | follow_imports = "silent" 74 | show_column_numbers = true 75 | 76 | [tool.ruff] 77 | line-length = 88 78 | indent-width = 4 79 | 80 | target-version = "py37" 81 | 82 | external = ["QGS"] 83 | 84 | [tool.ruff.lint] 85 | select = [ 86 | "ANN", # flake8-annotations 87 | "ARG", # flake8-unused-arguments 88 | "B", # flake8-bugbear 89 | "C", # flake8-comprehensions 90 | "C90", # flake8, mccabe 91 | "E", # flake8, pycodestyle 92 | "F", # flake8, Pyflakes 93 | "I", # isort 94 | "INP", # flake8-no-pep420 95 | "N", # pep8-naming 96 | "PIE", # flake8-pie 97 | "PGH", # pygrep-hooks 98 | "PL", # pylint 99 | "PT", # flake8-pytest-style 100 | "RUF", # Ruff-specific rules 101 | "SIM", # flake8-simplify 102 | "T", # flake8-print 103 | "ICN", # flake8-import-conventions 104 | "TCH", # flake8-type-checking 105 | "TID", # flake8-tidy-imports 106 | "W", # flake8, pycodestyle 107 | "UP", # pyupgrade 108 | ] 109 | 110 | ignore = [ 111 | "ANN101", # Missing type annotation for `self` in method 112 | "PT004" # Fixture does not return anything, add leading underscore 113 | ] 114 | 115 | unfixable = [ 116 | "F401", # Unused imports 117 | "F841", # Unused variables 118 | ] 119 | 120 | [tool.ruff.flake8-tidy-imports] 121 | ban-relative-imports = "all" 122 | 123 | [tool.ruff.per-file-ignores] 124 | "src/pytest_qgis/pytest_qgis.py"=["PLR2004"] # TODO: Fix magic values. Remove this after. 125 | "src/pytest_qgis/qgis_interface.py" = ["N802", "N803"] 126 | "src/pytest_qgis/utils.py" = ["ANN401"] 127 | "tests/*" = [ 128 | "ANN001", 129 | "ANN201", 130 | "ARG001", # TODO: Unused function argument. These are mostly pytest fixtures. Find a way to allow these in tests. Remove this after. 131 | ] 132 | -------------------------------------------------------------------------------- /requirements-dev.in: -------------------------------------------------------------------------------- 1 | -r requirements.txt 2 | 3 | -e file:. 4 | 5 | # Managing dependencies 6 | pip-tools 7 | 8 | # testing 9 | pytest 10 | pytest-cov 11 | pytest-qt==3.3.0 12 | 13 | # typing 14 | PyQt5-stubs 15 | 16 | # for linting checks at commit time 17 | pre-commit 18 | 19 | # for ide linting 20 | ruff 21 | flake8 22 | flake8-qgis 23 | mypy 24 | -------------------------------------------------------------------------------- /requirements-dev.txt: -------------------------------------------------------------------------------- 1 | # 2 | # This file is autogenerated by pip-compile with Python 3.9 3 | # by the following command: 4 | # 5 | # pip-compile '.\requirements-dev.in' 6 | # 7 | -e file:. 8 | # via -r .\requirements-dev.in 9 | astor==0.8.1 10 | # via flake8-qgis 11 | atomicwrites==1.4.1 12 | # via 13 | # -r .\requirements.txt 14 | # pytest 15 | attrs==23.1.0 16 | # via 17 | # -r .\requirements.txt 18 | # pytest 19 | build==1.0.3 20 | # via pip-tools 21 | cfgv==3.4.0 22 | # via pre-commit 23 | click==8.1.7 24 | # via pip-tools 25 | colorama==0.4.6 26 | # via 27 | # -r .\requirements.txt 28 | # build 29 | # click 30 | # pytest 31 | coverage[toml]==7.3.2 32 | # via 33 | # coverage 34 | # pytest-cov 35 | distlib==0.3.7 36 | # via virtualenv 37 | filelock==3.13.1 38 | # via virtualenv 39 | flake8==6.1.0 40 | # via 41 | # -r .\requirements-dev.in 42 | # flake8-qgis 43 | flake8-qgis==1.0.0 44 | # via -r .\requirements-dev.in 45 | identify==2.5.32 46 | # via pre-commit 47 | importlib-metadata==6.8.0 48 | # via build 49 | iniconfig==2.0.0 50 | # via 51 | # -r .\requirements.txt 52 | # pytest 53 | mccabe==0.7.0 54 | # via flake8 55 | mypy==1.7.1 56 | # via -r .\requirements-dev.in 57 | mypy-extensions==1.0.0 58 | # via mypy 59 | nodeenv==1.8.0 60 | # via pre-commit 61 | packaging==23.2 62 | # via 63 | # -r .\requirements.txt 64 | # build 65 | # pytest 66 | pip-tools==7.3.0 67 | # via -r .\requirements-dev.in 68 | platformdirs==4.0.0 69 | # via virtualenv 70 | pluggy==1.3.0 71 | # via 72 | # -r .\requirements.txt 73 | # pytest 74 | pre-commit==3.5.0 75 | # via -r .\requirements-dev.in 76 | py==1.11.0 77 | # via 78 | # -r .\requirements.txt 79 | # pytest 80 | pycodestyle==2.11.1 81 | # via flake8 82 | pyflakes==3.1.0 83 | # via flake8 84 | pyproject-hooks==1.0.0 85 | # via build 86 | pyqt5-stubs==5.15.6.0 87 | # via -r .\requirements-dev.in 88 | pytest==6.2.5 89 | # via 90 | # -r .\requirements-dev.in 91 | # -r .\requirements.txt 92 | # pytest-cov 93 | # pytest-qgis 94 | # pytest-qt 95 | pytest-cov==4.1.0 96 | # via -r .\requirements-dev.in 97 | pytest-qt==3.3.0 98 | # via -r .\requirements-dev.in 99 | pyyaml==6.0.1 100 | # via pre-commit 101 | ruff==0.1.6 102 | # via -r .\requirements-dev.in 103 | toml==0.10.2 104 | # via 105 | # -r .\requirements.txt 106 | # pytest 107 | tomli==2.0.1 108 | # via 109 | # build 110 | # coverage 111 | # mypy 112 | # pip-tools 113 | # pyproject-hooks 114 | typing-extensions==4.8.0 115 | # via mypy 116 | virtualenv==20.24.7 117 | # via pre-commit 118 | wheel==0.42.0 119 | # via pip-tools 120 | zipp==3.17.0 121 | # via importlib-metadata 122 | 123 | # The following packages are considered to be unsafe in a requirements file: 124 | # pip 125 | # setuptools 126 | -------------------------------------------------------------------------------- /requirements.in: -------------------------------------------------------------------------------- 1 | # Pinned versions of the dependencies to support as many environments as possible 2 | pytest==6.* 3 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | # 2 | # This file is autogenerated by pip-compile with Python 3.9 3 | # by the following command: 4 | # 5 | # pip-compile 6 | # 7 | atomicwrites==1.4.1 8 | # via pytest 9 | attrs==23.1.0 10 | # via pytest 11 | colorama==0.4.6 12 | # via pytest 13 | iniconfig==2.0.0 14 | # via pytest 15 | packaging==23.2 16 | # via pytest 17 | pluggy==1.3.0 18 | # via pytest 19 | py==1.11.0 20 | # via pytest 21 | pytest==6.2.5 22 | # via -r requirements.in 23 | toml==0.10.2 24 | # via pytest 25 | -------------------------------------------------------------------------------- /src/pytest_qgis/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright (C) 2021 pytest-qgis Contributors. 2 | # 3 | # 4 | # This file is part of pytest-qgis. 5 | # 6 | # pytest-qgis is free software: you can redistribute it and/or modify 7 | # it under the terms of the GNU General Public License as published by 8 | # the Free Software Foundation, either version 2 of the License, or 9 | # (at your option) any later version. 10 | # 11 | # pytest-qgis is distributed in the hope that it will be useful, 12 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 13 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 14 | # GNU General Public License for more details. 15 | # 16 | # You should have received a copy of the GNU General Public License 17 | # along with pytest-qgis. If not, see . 18 | 19 | 20 | from pytest_qgis._version import __version__ # noqa: F401 21 | from pytest_qgis.pytest_qgis import * # noqa: F403 22 | -------------------------------------------------------------------------------- /src/pytest_qgis/_version.py: -------------------------------------------------------------------------------- 1 | # Copyright (C) 2021 pytest-qgis Contributors. 2 | # 3 | # 4 | # This file is part of pytest-qgis. 5 | # 6 | # pytest-qgis is free software: you can redistribute it and/or modify 7 | # it under the terms of the GNU General Public License as published by 8 | # the Free Software Foundation, either version 2 of the License, or 9 | # (at your option) any later version. 10 | # 11 | # pytest-qgis is distributed in the hope that it will be useful, 12 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 13 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 14 | # GNU General Public License for more details. 15 | # 16 | # You should have received a copy of the GNU General Public License 17 | # along with pytest-qgis. If not, see . 18 | 19 | 20 | __version__ = "2.1.0" 21 | -------------------------------------------------------------------------------- /src/pytest_qgis/mock_qgis_classes.py: -------------------------------------------------------------------------------- 1 | # Copyright (C) 2021-2023 pytest-qgis Contributors. 2 | # 3 | # 4 | # This file is part of pytest-qgis. 5 | # 6 | # pytest-qgis is free software: you can redistribute it and/or modify 7 | # it under the terms of the GNU General Public License as published by 8 | # the Free Software Foundation, either version 2 of the License, or 9 | # (at your option) any later version. 10 | # 11 | # pytest-qgis is distributed in the hope that it will be useful, 12 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 13 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 14 | # GNU General Public License for more details. 15 | # 16 | # You should have received a copy of the GNU General Public License 17 | # along with pytest-qgis. If not, see . 18 | 19 | 20 | from typing import Dict, List 21 | 22 | from qgis.core import Qgis 23 | from qgis.PyQt.QtCore import QObject 24 | 25 | 26 | class MockMessageBar(QObject): 27 | """Mocked message bar to hold the messages.""" 28 | 29 | def __init__(self) -> None: 30 | super().__init__() 31 | self.messages: Dict[int, List[str]] = { 32 | Qgis.Info: [], 33 | Qgis.Warning: [], 34 | Qgis.Critical: [], 35 | Qgis.Success: [], 36 | } 37 | 38 | def get_messages(self, level: int) -> List[str]: 39 | """Used to test which messages have been logged.""" 40 | return self.messages[level] 41 | 42 | def pushMessage( # noqa: N802 43 | self, 44 | title: str, 45 | text: str, 46 | level: int, 47 | duration: int, # noqa: ARG002 48 | ) -> None: 49 | """A mocked method for pushing a message to the bar.""" 50 | msg = f"{title}:{text}" 51 | self.messages[level].append(msg) 52 | -------------------------------------------------------------------------------- /src/pytest_qgis/py.typed: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GispoCoding/pytest-qgis/a316c0ae5c78ddbc2342e3ba55669eefbbd9eb6e/src/pytest_qgis/py.typed -------------------------------------------------------------------------------- /src/pytest_qgis/pytest_qgis.py: -------------------------------------------------------------------------------- 1 | # Copyright (C) 2021-2023 pytest-qgis Contributors. 2 | # 3 | # 4 | # This file is part of pytest-qgis. 5 | # 6 | # pytest-qgis is free software: you can redistribute it and/or modify 7 | # it under the terms of the GNU General Public License as published by 8 | # the Free Software Foundation, either version 2 of the License, or 9 | # (at your option) any later version. 10 | # 11 | # pytest-qgis is distributed in the hope that it will be useful, 12 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 13 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 14 | # GNU General Public License for more details. 15 | # 16 | # You should have received a copy of the GNU General Public License 17 | # along with pytest-qgis. If not, see . 18 | 19 | import contextlib 20 | import os.path 21 | import shutil 22 | import sys 23 | import tempfile 24 | import time 25 | import warnings 26 | from collections import namedtuple 27 | from pathlib import Path 28 | from typing import TYPE_CHECKING, Optional 29 | from unittest import mock 30 | 31 | import pytest 32 | from qgis.core import Qgis, QgsApplication, QgsProject, QgsRectangle, QgsVectorLayer 33 | from qgis.gui import QgisInterface as QgisInterfaceOrig 34 | from qgis.gui import QgsGui, QgsLayerTreeMapCanvasBridge, QgsMapCanvas 35 | from qgis.PyQt import QtCore, QtWidgets, sip 36 | from qgis.PyQt.QtCore import QCoreApplication 37 | from qgis.PyQt.QtWidgets import QMainWindow, QMessageBox, QWidget 38 | 39 | from pytest_qgis.mock_qgis_classes import MockMessageBar 40 | from pytest_qgis.qgis_bot import QgisBot 41 | from pytest_qgis.qgis_interface import QgisInterface 42 | from pytest_qgis.utils import ( 43 | ensure_qgis_layer_fixtures_are_cleaned, 44 | get_common_extent_from_all_layers, 45 | get_layers_with_different_crs, 46 | replace_layers_with_reprojected_clones, 47 | set_map_crs_based_on_layers, 48 | ) 49 | 50 | if TYPE_CHECKING: 51 | from _pytest.config import Config 52 | from _pytest.config.argparsing import Parser 53 | from _pytest.fixtures import SubRequest 54 | from _pytest.mark import Mark 55 | 56 | QGIS_3_18 = 31800 57 | 58 | Settings = namedtuple( 59 | "Settings", ["gui_enabled", "qgis_init_disabled", "canvas_width", "canvas_height"] 60 | ) 61 | ShowMapSettings = namedtuple( 62 | "ShowMapSettings", ["timeout", "add_basemap", "zoom_to_common_extent", "extent"] 63 | ) 64 | 65 | GUI_DISABLE_KEY = "qgis_disable_gui" 66 | GUI_ENABLED_KEY = "qgis_qui_enabled" 67 | GUI_DESCRIPTION = "Set whether the graphical user interface is wanted or not." 68 | GUI_ENABLED_DEFAULT = True 69 | 70 | CANVAS_HEIGHT_KEY = "qgis_canvas_height" 71 | CANVAS_WIDTH_KEY = "qgis_canvas_width" 72 | CANVAS_DESCRIPTION = "Set canvas height and width." 73 | CANVAS_SIZE_DEFAULT = (600, 600) 74 | 75 | DISABLE_QGIS_INIT_KEY = "qgis_disable_init" 76 | DISABLE_QGIS_INIT_DESCRIPTION = "Prevent QGIS (QgsApplication) from initializing." 77 | 78 | SHOW_MAP_MARKER = "qgis_show_map" 79 | SHOW_MAP_VISIBILITY_TIMEOUT_DEFAULT = 30 80 | SHOW_MAP_MARKER_DESCRIPTION = ( 81 | f"{SHOW_MAP_MARKER}(timeout={SHOW_MAP_VISIBILITY_TIMEOUT_DEFAULT}, add_basemap=False, zoom_to_common_extent=True, extent=None): " # noqa: E501 82 | f"Show QGIS map for a short amount of time. The first keyword, *timeout*, is the " 83 | f"timeout in seconds until the map closes. The second keyword *add_basemap*, " 84 | f"when set to True, adds Natural Earth countries layer as the basemap for the map. " 85 | f"The third keyword *zoom_to_common_extent*, when set to True, centers the map " 86 | f"around all layers in the project. Alternatively the fourth keyword *extent* " 87 | f"can be provided as QgsRectangle." 88 | ) 89 | 90 | _APP: Optional[QgsApplication] = None 91 | _CANVAS: Optional[QgsMapCanvas] = None 92 | _IFACE: Optional[QgisInterface] = None 93 | _PARENT: Optional[QtWidgets.QWidget] = None 94 | _AUTOUSE_QGIS: Optional[bool] = None 95 | _QGIS_CONFIG_PATH: Optional[Path] = None 96 | 97 | try: 98 | _QGIS_VERSION = Qgis.versionInt() 99 | except AttributeError: 100 | _QGIS_VERSION = Qgis.QGIS_VERSION_INT 101 | 102 | 103 | @pytest.hookimpl() 104 | def pytest_addoption(parser: "Parser") -> None: 105 | group = parser.getgroup( 106 | "qgis", 107 | "Utilities for testing QGIS plugins", 108 | ) 109 | group.addoption(f"--{GUI_DISABLE_KEY}", action="store_true", help=GUI_DESCRIPTION) 110 | group.addoption( 111 | f"--{DISABLE_QGIS_INIT_KEY}", 112 | action="store_true", 113 | help=DISABLE_QGIS_INIT_DESCRIPTION, 114 | ) 115 | 116 | parser.addini( 117 | GUI_ENABLED_KEY, GUI_DESCRIPTION, type="bool", default=GUI_ENABLED_DEFAULT 118 | ) 119 | 120 | parser.addini( 121 | CANVAS_WIDTH_KEY, 122 | CANVAS_DESCRIPTION, 123 | type="string", 124 | default=CANVAS_SIZE_DEFAULT[0], 125 | ) 126 | parser.addini( 127 | CANVAS_HEIGHT_KEY, 128 | CANVAS_DESCRIPTION, 129 | type="string", 130 | default=CANVAS_SIZE_DEFAULT[1], 131 | ) 132 | 133 | 134 | @pytest.hookimpl(tryfirst=True) 135 | def pytest_configure(config: "Config") -> None: 136 | """Configure and initialize qgis session for all tests.""" 137 | config.addinivalue_line("markers", SHOW_MAP_MARKER_DESCRIPTION) 138 | 139 | settings = _parse_settings(config) 140 | config._plugin_settings = settings 141 | 142 | if not settings.gui_enabled: 143 | os.environ["QT_QPA_PLATFORM"] = "offscreen" 144 | 145 | _start_and_configure_qgis_app(config) 146 | 147 | 148 | @pytest.hookimpl(tryfirst=True) 149 | def pytest_runtest_teardown(item: pytest.Item, nextitem: Optional[pytest.Item]) -> None: # noqa: ARG001 150 | request = item.funcargs.get("request") 151 | if request: 152 | ensure_qgis_layer_fixtures_are_cleaned(request) 153 | 154 | 155 | @pytest.fixture(autouse=True, scope="session") 156 | def qgis_app(request: "SubRequest") -> QgsApplication: 157 | yield _APP if not request.config._plugin_settings.qgis_init_disabled else None 158 | 159 | if not request.config._plugin_settings.qgis_init_disabled: 160 | assert _APP 161 | QgsProject.instance().legendLayersAdded.disconnect(_APP.processEvents) 162 | if not sip.isdeleted(_CANVAS) and _CANVAS is not None: 163 | _CANVAS.deleteLater() 164 | _APP.exitQgis() 165 | if _QGIS_CONFIG_PATH and _QGIS_CONFIG_PATH.exists(): 166 | # TODO: https://github.com/GispoCoding/pytest-qgis/issues/43 167 | with contextlib.suppress(PermissionError): 168 | shutil.rmtree(_QGIS_CONFIG_PATH) 169 | 170 | 171 | @pytest.fixture(scope="session") 172 | def qgis_parent(qgis_app: QgsApplication) -> QWidget: # noqa: ARG001 173 | return _PARENT 174 | 175 | 176 | @pytest.fixture(scope="session") 177 | def qgis_canvas() -> QgsMapCanvas: 178 | assert _CANVAS 179 | return _CANVAS 180 | 181 | 182 | @pytest.fixture(scope="session") 183 | def qgis_version() -> int: 184 | """QGIS version number as integer.""" 185 | return _QGIS_VERSION 186 | 187 | 188 | @pytest.fixture(scope="session") 189 | def qgis_iface() -> QgisInterfaceOrig: 190 | assert _IFACE 191 | return _IFACE 192 | 193 | 194 | @pytest.fixture(scope="session") 195 | def qgis_processing(qgis_app: QgsApplication) -> None: 196 | """ 197 | Initializes QGIS processing framework 198 | """ 199 | _initialize_processing(qgis_app) 200 | 201 | 202 | @pytest.fixture() 203 | def qgis_new_project(qgis_iface: QgisInterface) -> None: 204 | """ 205 | Initializes new QGIS project by removing layers and relations etc. 206 | """ 207 | qgis_iface.newProject() 208 | 209 | 210 | @pytest.fixture() 211 | def qgis_world_map_geopackage(tmp_path: Path) -> Path: 212 | """ 213 | Path to natural world map geopackage containing Natural Earth data. 214 | This geopackage can be modified in any way. 215 | 216 | Layers: 217 | * countries 218 | * disputed_borders 219 | * states_provinces 220 | """ 221 | return _get_world_map_geopackage(tmp_path) 222 | 223 | 224 | @pytest.fixture() 225 | def qgis_countries_layer(qgis_world_map_geopackage: Path) -> QgsVectorLayer: 226 | """ 227 | Natural Earth countries as a QgsVectorLayer. 228 | """ 229 | return _get_countries_layer(qgis_world_map_geopackage) 230 | 231 | 232 | @pytest.fixture(scope="session") 233 | def qgis_bot(qgis_iface: QgisInterface) -> QgisBot: 234 | """ 235 | Object that holds common utility methods for interacting with QGIS. 236 | """ 237 | return QgisBot(qgis_iface) 238 | 239 | 240 | @pytest.fixture(autouse=True) 241 | def qgis_show_map( 242 | qgis_app: QgsApplication, 243 | qgis_iface: QgisInterface, 244 | qgis_parent: QWidget, 245 | tmp_path: Path, 246 | request: "SubRequest", 247 | ) -> None: 248 | """ 249 | Shows QGIS map if qgis_show_map marker is used. 250 | """ 251 | show_map_marker = request.node.get_closest_marker(SHOW_MAP_MARKER) 252 | common_settings: Settings = request.config._plugin_settings 253 | 254 | if show_map_marker: 255 | # Assign the bridge to have correct layer order and visibilities 256 | bridge = QgsLayerTreeMapCanvasBridge( # noqa: F841, this needs to be assigned 257 | QgsProject.instance().layerTreeRoot(), qgis_iface.mapCanvas() 258 | ) 259 | _show_qgis_dlg(common_settings, qgis_parent) 260 | 261 | yield 262 | 263 | if ( 264 | show_map_marker 265 | and common_settings.gui_enabled 266 | and not common_settings.qgis_init_disabled 267 | ): 268 | _configure_qgis_map( 269 | qgis_app, 270 | qgis_iface, 271 | qgis_parent, 272 | _parse_show_map_marker(show_map_marker), 273 | tmp_path, 274 | ) 275 | 276 | 277 | def _start_and_configure_qgis_app(config: "Config") -> None: 278 | global _APP, _CANVAS, _IFACE, _PARENT, _QGIS_CONFIG_PATH # noqa: PLW0603 279 | settings: Settings = config._plugin_settings 280 | 281 | # Use temporary path for QGIS config 282 | _QGIS_CONFIG_PATH = Path(tempfile.mkdtemp(prefix="pytest-qgis")) 283 | os.environ["QGIS_CUSTOM_CONFIG_PATH"] = str(_QGIS_CONFIG_PATH) 284 | 285 | if not settings.qgis_init_disabled: 286 | _APP = QgsApplication([], GUIenabled=settings.gui_enabled) 287 | _APP.initQgis() 288 | QgsGui.editorWidgetRegistry().initEditors() 289 | _PARENT = QMainWindow() 290 | _CANVAS = QgsMapCanvas(_PARENT) 291 | _PARENT.resize(QtCore.QSize(settings.canvas_width, settings.canvas_height)) 292 | _CANVAS.resize(QtCore.QSize(settings.canvas_width, settings.canvas_height)) 293 | 294 | # QgisInterface is a stub implementation of the QGIS plugin interface 295 | _IFACE = QgisInterface(_CANVAS, MockMessageBar(), _PARENT) 296 | 297 | # Patching imported iface (evaluated as None in tests) with iface. 298 | # This only works with QGIS >= 3.18 since before that 299 | # importing qgis.utils causes RecursionErrors. See this issue for details 300 | # https://github.com/qgis/QGIS/issues/40564 301 | 302 | if _QGIS_VERSION >= QGIS_3_18: 303 | from qgis.utils import iface # noqa: F401 # This import is required 304 | 305 | mock.patch("qgis.utils.iface", _IFACE).start() 306 | 307 | if _APP is not None: 308 | # QGIS zooms to the layer's extent if it 309 | # is the first layer added to the map. 310 | # If the qgis_show_map marker is used, this zooming might occur 311 | # at some later time when events are processed (e.g. at qtbot.wait call) 312 | # and this might change the extent unexpectedly. 313 | # It is better to process events right after adding the 314 | # layer to avoid these kind of problems. 315 | QgsProject.instance().legendLayersAdded.connect(_APP.processEvents) 316 | 317 | 318 | def _initialize_processing(qgis_app: QgsApplication) -> None: 319 | python_plugins_path = os.path.join(qgis_app.pkgDataPath(), "python", "plugins") 320 | if python_plugins_path not in sys.path: 321 | sys.path.append(python_plugins_path) 322 | from processing.core.Processing import Processing 323 | 324 | Processing.initialize() 325 | 326 | 327 | def _show_qgis_dlg(common_settings: Settings, qgis_parent: QWidget) -> None: 328 | if not common_settings.qgis_init_disabled: 329 | qgis_parent.setWindowTitle("Test QGIS dialog opened by Pytest-qgis") 330 | qgis_parent.show() 331 | elif common_settings.qgis_init_disabled: 332 | warnings.warn( 333 | "Cannot show QGIS map because QGIS is not initialized. " 334 | "Run the tests without --qgis_disable_init to enable QGIS map.", 335 | stacklevel=1, 336 | ) 337 | if not common_settings.gui_enabled: 338 | warnings.warn( 339 | "QGIS map is not visible because the GUI is not enabled. " 340 | "Set qgis_qui_enabled=True in pytest.ini to see the window.", 341 | stacklevel=1, 342 | ) 343 | 344 | 345 | def _configure_qgis_map( 346 | qgis_app: QgsApplication, 347 | qgis_iface: QgisInterface, 348 | qgis_parent: QWidget, 349 | settings: ShowMapSettings, 350 | tmp_path: Path, 351 | ) -> None: 352 | if settings.timeout == 0: 353 | qgis_parent.close() 354 | return 355 | 356 | message_box = QMessageBox(qgis_parent) 357 | 358 | try: 359 | # Change project CRS to most common CRS if it is not set 360 | if not QgsProject.instance().crs().isValid(): 361 | set_map_crs_based_on_layers() 362 | 363 | extent = settings.extent 364 | if settings.zoom_to_common_extent and extent is None: 365 | extent = get_common_extent_from_all_layers() 366 | if extent is not None: 367 | qgis_iface.mapCanvas().setExtent(extent) 368 | 369 | # Replace layers with different CRS 370 | layers_with_different_crs = get_layers_with_different_crs() 371 | if layers_with_different_crs: 372 | _initialize_processing(qgis_app) 373 | replace_layers_with_reprojected_clones(layers_with_different_crs, tmp_path) 374 | 375 | if settings.add_basemap: 376 | # Add Natural Earth Countries 377 | countries_layer = _get_countries_layer(_get_world_map_geopackage(tmp_path)) 378 | QgsProject.instance().addMapLayer(countries_layer) 379 | if countries_layer.crs() != QgsProject.instance().crs(): 380 | _initialize_processing(qgis_app) 381 | replace_layers_with_reprojected_clones([countries_layer], tmp_path) 382 | 383 | QgsProject.instance().reloadAllLayers() 384 | qgis_iface.mapCanvas().refreshAllLayers() 385 | 386 | message_box.setWindowTitle("pytest-qgis") 387 | message_box.setText( 388 | "Click close to close the map and to end the test.\n" 389 | f"It will close automatically in {settings.timeout} seconds." 390 | ) 391 | message_box.addButton(QMessageBox.Close) 392 | message_box.move(QgsApplication.instance().primaryScreen().geometry().topLeft()) 393 | message_box.setWindowModality(QtCore.Qt.NonModal) 394 | message_box.show() 395 | 396 | t = time.time() 397 | while time.time() - t < settings.timeout and message_box.isVisible(): 398 | QCoreApplication.processEvents() 399 | finally: 400 | message_box.close() 401 | qgis_parent.close() 402 | 403 | 404 | def _parse_settings(config: "Config") -> Settings: 405 | gui_disabled = config.getoption(GUI_DISABLE_KEY) 406 | if not gui_disabled: 407 | gui_enabled = config.getini(GUI_ENABLED_KEY) 408 | else: 409 | gui_enabled = not gui_disabled 410 | 411 | qgis_init_disabled = config.getoption(DISABLE_QGIS_INIT_KEY) 412 | canvas_width = int(config.getini(CANVAS_WIDTH_KEY)) 413 | canvas_height = int(config.getini(CANVAS_HEIGHT_KEY)) 414 | 415 | return Settings(gui_enabled, qgis_init_disabled, canvas_width, canvas_height) 416 | 417 | 418 | def _parse_show_map_marker(marker: "Mark") -> ShowMapSettings: # noqa: C901, PLR0912 TODO: Fix complexity 419 | timeout = add_basemap = zoom_to_common_extent = extent = notset = object() 420 | 421 | for kwarg, value in marker.kwargs.items(): 422 | if kwarg == "timeout": 423 | timeout = value 424 | elif kwarg == "add_basemap": 425 | add_basemap = value 426 | elif kwarg == "zoom_to_common_extent": 427 | zoom_to_common_extent = value 428 | elif kwarg == "extent": 429 | extent = value 430 | else: 431 | raise TypeError( 432 | f"Invalid keyword argument for qgis_show_map marker: {kwarg}" 433 | ) 434 | 435 | if len(marker.args) >= 1 and timeout is not notset: 436 | raise TypeError("Multiple values for timeout argument of qgis_show_map marker") 437 | elif len(marker.args) >= 1: 438 | timeout = marker.args[0] 439 | if len(marker.args) >= 2 and add_basemap is not notset: 440 | raise TypeError( 441 | "Multiple values for add_basemap argument of qgis_show_map marker" 442 | ) 443 | elif len(marker.args) >= 2: 444 | add_basemap = marker.args[1] 445 | if len(marker.args) >= 3 and zoom_to_common_extent is not notset: 446 | raise TypeError( 447 | "Multiple values for zoom_to_common_extent argument of qgis_show_map marker" 448 | ) 449 | elif len(marker.args) >= 3: 450 | zoom_to_common_extent = marker.args[2] 451 | if len(marker.args) >= 4 and extent is not notset: 452 | raise TypeError("Multiple values for extent argument of qgis_show_map marker") 453 | elif len(marker.args) >= 4: 454 | extent = marker.args[3] 455 | if len(marker.args) > 4: 456 | raise TypeError("Too many arguments for qgis_show_map marker") 457 | 458 | if timeout is notset: 459 | timeout = SHOW_MAP_VISIBILITY_TIMEOUT_DEFAULT 460 | if add_basemap is notset: 461 | add_basemap = False 462 | if zoom_to_common_extent is notset: 463 | zoom_to_common_extent = True 464 | if extent is notset: 465 | extent = None 466 | elif not isinstance(extent, QgsRectangle): 467 | raise TypeError("Extent has to be of type QgsRectangle") 468 | return ShowMapSettings(timeout, add_basemap, zoom_to_common_extent, extent) 469 | 470 | 471 | def _get_world_map_geopackage(tmp_path: Path) -> Path: 472 | """Copy geopackage to the temporary directory and return the copy.""" 473 | world_map_gpkg = Path( 474 | QgsApplication.pkgDataPath(), "resources", "data", "world_map.gpkg" 475 | ) 476 | assert world_map_gpkg.exists(), world_map_gpkg 477 | 478 | # Copy the geopackage to allow modifications 479 | return Path(shutil.copy(world_map_gpkg, tmp_path)) 480 | 481 | 482 | def _get_countries_layer(geopackage: Path) -> QgsVectorLayer: 483 | countries_layer = QgsVectorLayer( 484 | f"{geopackage}|layername=countries", 485 | "Natural Earth Countries", 486 | "ogr", 487 | ) 488 | assert countries_layer.isValid(), geopackage 489 | return countries_layer 490 | -------------------------------------------------------------------------------- /src/pytest_qgis/qgis_bot.py: -------------------------------------------------------------------------------- 1 | # Copyright (C) 2021-2023 pytest-qgis Contributors. 2 | # 3 | # 4 | # This file is part of pytest-qgis. 5 | # 6 | # pytest-qgis is free software: you can redistribute it and/or modify 7 | # it under the terms of the GNU General Public License as published by 8 | # the Free Software Foundation, either version 2 of the License, or 9 | # (at your option) any later version. 10 | # 11 | # pytest-qgis is distributed in the hope that it will be useful, 12 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 13 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 14 | # GNU General Public License for more details. 15 | # 16 | # You should have received a copy of the GNU General Public License 17 | # along with pytest-qgis. If not, see . 18 | # 19 | from typing import Any, Dict, Optional, Union 20 | 21 | from qgis.core import ( 22 | QgsFeature, 23 | QgsFieldConstraints, 24 | QgsGeometry, 25 | QgsVectorDataProvider, 26 | QgsVectorLayer, 27 | QgsVectorLayerUtils, 28 | ) 29 | from qgis.gui import QgisInterface, QgsAttributeDialog, QgsAttributeEditorContext 30 | from qgis.PyQt.QtWidgets import QLabel, QWidget 31 | 32 | from pytest_qgis import utils 33 | 34 | 35 | class QgisBot: 36 | """ 37 | Class to hold common utility methods for interacting with QIGS. 38 | """ 39 | 40 | def __init__( # noqa: QGS105 # Iface has to be passed in order to 41 | # ensure compatibility with all QGIS versions >= 3.10 42 | self, 43 | iface: QgisInterface, 44 | ) -> None: 45 | self._iface = iface 46 | 47 | def create_feature_with_attribute_dialog( # noqa: PLR0913 48 | self, 49 | layer: QgsVectorLayer, 50 | geometry: QgsGeometry, 51 | attributes: Optional[Dict[str, Any]] = None, 52 | raise_from_warnings: bool = False, 53 | raise_from_errors: bool = True, 54 | show_dialog_timeout_milliseconds: int = 0, 55 | ) -> QgsFeature: 56 | """ 57 | Create test feature with default values using QgsAttributeDialog. 58 | This ensures that all the default values are honored and 59 | for example boolean fields are either true or false, not null. 60 | 61 | :param layer: QgsVectorLayer to create feature into 62 | :param geometry: QgsGeometry of the feature 63 | :param attributes: attributes as a dictionary 64 | :param raise_from_warnings: Whether to raise error if there are non-enforcing 65 | constraint warnings with attribute values. 66 | :param raise_from_errors: Whether to raise error if there are enforcing 67 | constraint errors with attribute values. 68 | :param show_dialog_timeout_milliseconds: Shows attribute dialog. Useful for 69 | debugging. 70 | :return: Created QgsFeature that can be added to the layer. 71 | """ 72 | 73 | initial_ids = set(layer.allFeatureIds()) 74 | 75 | capabilities = layer.dataProvider().capabilities() 76 | 77 | if not capabilities & QgsVectorDataProvider.AddFeatures: 78 | raise ValueError(f"Could not create feature for the layer {layer.name()}") 79 | 80 | new_feature = QgsVectorLayerUtils.createFeature( 81 | layer, context=layer.createExpressionContext() 82 | ) 83 | new_feature.setGeometry(geometry) 84 | 85 | if attributes is not None: 86 | if capabilities & QgsVectorDataProvider.ChangeAttributeValues: 87 | for field_name, value in attributes.items(): 88 | new_feature[field_name] = value 89 | else: 90 | raise ValueError( 91 | f"Could not change attributes for layer {layer.name()}" 92 | ) 93 | 94 | assert new_feature.isValid() 95 | 96 | warnings = {} 97 | errors = {} 98 | for field_index, field in enumerate(layer.fields()): 99 | no_warnings, warning_messages = QgsVectorLayerUtils.validateAttribute( 100 | layer, 101 | new_feature, 102 | field_index, 103 | QgsFieldConstraints.ConstraintStrengthSoft, 104 | ) 105 | no_errors, error_messages = QgsVectorLayerUtils.validateAttribute( 106 | layer, 107 | new_feature, 108 | field_index, 109 | QgsFieldConstraints.ConstraintStrengthHard, 110 | ) 111 | if not no_warnings: 112 | warnings[field.name()] = warning_messages 113 | if not no_errors: 114 | errors[field.name()] = error_messages 115 | 116 | if raise_from_warnings and warnings: 117 | raise ValueError( 118 | "There are non-enforcing constraint warnings in the attribute form: " 119 | f"{warnings!s}" 120 | ) 121 | if raise_from_errors and errors: 122 | raise ValueError( 123 | "There are enforcing constraint errors in the attribute form: " 124 | f"{errors!s}" 125 | ) 126 | 127 | context = QgsAttributeEditorContext() 128 | context.setMapCanvas(self._iface.mapCanvas()) 129 | 130 | dialog = QgsAttributeDialog( 131 | layer, new_feature, False, self._iface.mainWindow(), True, context 132 | ) 133 | dialog.show() 134 | dialog.setMode(QgsAttributeEditorContext.AddFeatureMode) 135 | 136 | utils.wait(show_dialog_timeout_milliseconds) 137 | 138 | # Two accepts to ignore warnings and errors 139 | dialog.accept() 140 | dialog.accept() 141 | 142 | feature_ids = set(layer.allFeatureIds()) 143 | feature_id = list(feature_ids.difference(initial_ids)) 144 | 145 | assert feature_id, "Creating new feature failed" 146 | return layer.getFeature(feature_id[0]) 147 | 148 | @staticmethod 149 | def get_qgs_attribute_dialog_widgets_by_name( 150 | widget: Union[QgsAttributeDialog, QWidget] 151 | ) -> Dict[str, QWidget]: 152 | """ 153 | Gets recursively all attribute dialog widgets by name. 154 | :param widget: QgsAttributeDialog for the first time, afterwards QWidget. 155 | :return: Dictionary with field names as keys and corresponding 156 | QWidgets as values. 157 | """ 158 | widgets_by_name = {} 159 | for child in widget.children(): 160 | if ( 161 | isinstance(child, QLabel) 162 | and child.text() != "" 163 | and child.toolTip() != "" 164 | and child.buddy() is not None 165 | ): 166 | widgets_by_name[child.text()] = child.buddy() 167 | if hasattr(child, "children"): 168 | widgets_by_name = { 169 | **widgets_by_name, 170 | **QgisBot.get_qgs_attribute_dialog_widgets_by_name(child), 171 | } 172 | 173 | return widgets_by_name 174 | -------------------------------------------------------------------------------- /src/pytest_qgis/qgis_interface.py: -------------------------------------------------------------------------------- 1 | """QGIS plugin implementation. 2 | 3 | .. note:: This program is free software; you can redistribute it and/or modify 4 | it under the terms of the GNU General Public License as published by 5 | the Free Software Foundation; either version 2 of the License, or 6 | (at your option) any later version. 7 | 8 | .. note:: This source code was copied from the 'postgis viewer' application 9 | with original authors: 10 | Copyright (c) 2010 by Ivan Mincik, ivan.mincik@gista.sk 11 | Copyright (c) 2011 German Carrillo, geotux_tuxman@linuxmail.org 12 | Copyright (c) 2014 Tim Sutton, tim@linfiniti.com 13 | Copyright (c) 2021 pytest-qgis Contributors 14 | 15 | """ 16 | 17 | __author__ = "tim@linfiniti.com" 18 | __revision__ = "$Format:%H$" 19 | __date__ = "10/01/2011" 20 | __copyright__ = ( 21 | "Copyright (c) 2010 by Ivan Mincik, ivan.mincik@gista.sk and " 22 | "Copyright (c) 2011 German Carrillo, geotux_tuxman@linuxmail.org" 23 | "Copyright (c) 2014 Tim Sutton, tim@linfiniti.com" 24 | "Copyright (c) 2021-2023 pytest-qgis Contributors" 25 | ) 26 | 27 | import logging 28 | from typing import Dict, List, Optional, Union 29 | 30 | from qgis.core import ( 31 | QgsLayerTree, 32 | QgsMapLayer, 33 | QgsProject, 34 | QgsRelationManager, 35 | QgsVectorLayer, 36 | ) 37 | from qgis.gui import QgsMapCanvas 38 | from qgis.PyQt import sip 39 | from qgis.PyQt.QtCore import QObject, pyqtSignal, pyqtSlot 40 | from qgis.PyQt.QtWidgets import ( 41 | QAction, 42 | QDockWidget, 43 | QMainWindow, 44 | QMenuBar, 45 | QToolBar, 46 | QWidget, 47 | ) 48 | 49 | from pytest_qgis.mock_qgis_classes import MockMessageBar 50 | 51 | LOGGER = logging.getLogger("QGIS") 52 | 53 | 54 | # noinspection PyMethodMayBeStatic,PyPep8Naming 55 | class QgisInterface(QObject): 56 | """Class to expose QGIS objects and functions to plugins. 57 | 58 | This class is here for enabling us to run unit tests only, 59 | so most methods are simply stubs. 60 | """ 61 | 62 | currentLayerChanged = pyqtSignal(QgsMapCanvas) # noqa: N815 63 | newProjectCreated = pyqtSignal() # noqa: N815 64 | 65 | def __init__( 66 | self, canvas: QgsMapCanvas, messageBar: MockMessageBar, mainWindow: QMainWindow 67 | ) -> None: 68 | """Constructor 69 | :param canvas: 70 | """ 71 | QObject.__init__(self) 72 | self.canvas = canvas 73 | self._messageBar = messageBar 74 | self._mainWindow = mainWindow 75 | self._active_layer_id: Optional[str] = None 76 | 77 | # Set up slots so we can mimic the behaviour of QGIS when layers 78 | # are added. 79 | LOGGER.debug("Initialising canvas...") 80 | # noinspection PyArgumentList 81 | QgsProject.instance().layersAdded.connect(self.addLayers) 82 | # noinspection PyArgumentList 83 | QgsProject.instance().removeAll.connect(self.removeAllLayers) 84 | 85 | # For processing module 86 | self.destCrs = None 87 | self._layers: List[QgsMapLayer] = [] 88 | 89 | # Add the MenuBar 90 | menu_bar = QMenuBar() 91 | self._mainWindow.setMenuBar(menu_bar) 92 | 93 | # Add the toolbar list 94 | self._toolbars: Dict[str, QToolBar] = {} 95 | 96 | @pyqtSlot("QList") 97 | def addLayers(self, layers: List[QgsMapLayer]) -> None: 98 | """Handle layers being added to the registry so they show up in canvas. 99 | 100 | :param layers: list list of map layers that were added 101 | 102 | .. note:: The QgsInterface api does not include this method, 103 | it is added here as a helper to facilitate testing. 104 | """ 105 | # LOGGER.debug('addLayers called on qgis_interface') 106 | # LOGGER.debug('Number of layers being added: %s' % len(layers)) 107 | # LOGGER.debug('Layer Count Before: %s' % len(self.canvas.layers())) 108 | current_layers = self.canvas.layers() 109 | final_layers = [] 110 | for layer in current_layers: 111 | final_layers.append(layer) 112 | for layer in layers: 113 | final_layers.append(layer) 114 | self._layers = final_layers 115 | 116 | self.canvas.setLayers(final_layers) 117 | # LOGGER.debug('Layer Count After: %s' % len(self.canvas.layers())) 118 | 119 | @pyqtSlot() 120 | def removeAllLayers(self) -> None: 121 | """Remove layers from the canvas before they get deleted.""" 122 | if not sip.isdeleted(self.canvas): 123 | self.canvas.setLayers([]) 124 | self._layers = [] 125 | 126 | def newProject(self) -> None: 127 | """Create new project.""" 128 | # noinspection PyArgumentList 129 | instance = QgsProject.instance() 130 | instance.removeAllMapLayers() 131 | root: QgsLayerTree = instance.layerTreeRoot() 132 | root.removeAllChildren() 133 | relation_manager: QgsRelationManager = instance.relationManager() 134 | for relation in relation_manager.relations(): 135 | relation_manager.removeRelation(relation) 136 | self._layers = [] 137 | self.newProjectCreated.emit() 138 | 139 | # ---------------- API Mock for QgsInterface follows ------------------- 140 | 141 | def zoomFull(self) -> None: 142 | """Zoom to the map full extent.""" 143 | 144 | def zoomToPrevious(self) -> None: 145 | """Zoom to previous view extent.""" 146 | 147 | def zoomToNext(self) -> None: 148 | """Zoom to next view extent.""" 149 | 150 | def zoomToActiveLayer(self) -> None: 151 | """Zoom to extent of active layer.""" 152 | 153 | def addVectorLayer( 154 | self, path: str, base_name: str, provider_key: str 155 | ) -> QgsVectorLayer: 156 | """Add a vector layer. 157 | 158 | :param path: Path to layer. 159 | :type path: str 160 | 161 | :param base_name: Base name for layer. 162 | :type base_name: str 163 | 164 | :param provider_key: Provider key e.g. 'ogr' 165 | :type provider_key: str 166 | """ 167 | layer = QgsVectorLayer(path, base_name, provider_key) 168 | self.addLayers([layer]) 169 | return layer 170 | 171 | def addRasterLayer(self, path: str, base_name: str) -> None: 172 | """Add a raster layer given a raster layer file name 173 | 174 | :param path: Path to layer. 175 | :type path: str 176 | 177 | :param base_name: Base name for layer. 178 | :type base_name: str 179 | """ 180 | 181 | def activeLayer(self) -> Optional[QgsMapLayer]: 182 | """Get pointer to the active layer (layer selected in the legend).""" 183 | return ( 184 | QgsProject.instance().mapLayer(self._active_layer_id) 185 | if self._active_layer_id 186 | else None 187 | ) 188 | 189 | def addPluginToMenu(self, name: str, action: QAction) -> None: 190 | """Add plugin item to menu. 191 | 192 | :param name: Name of the menu item 193 | :type name: str 194 | 195 | :param action: Action to add to menu. 196 | :type action: QAction 197 | """ 198 | 199 | def addToolBarIcon(self, action: QAction) -> None: 200 | """Add an icon to the plugins toolbar. 201 | 202 | :param action: Action to add to the toolbar. 203 | :type action: QAction 204 | """ 205 | 206 | def removeToolBarIcon(self, action: QAction) -> None: 207 | """Remove an action (icon) from the plugin toolbar. 208 | 209 | :param action: Action to add to the toolbar. 210 | :type action: QAction 211 | """ 212 | 213 | def addToolBar(self, toolbar: Union[str, QToolBar]) -> QToolBar: 214 | """Add toolbar with specified name. 215 | 216 | :param toolbar: Name for the toolbar or QToolBar object. 217 | """ 218 | if isinstance(toolbar, str): 219 | name = toolbar 220 | _toolbar = QToolBar(name, parent=self._mainWindow) 221 | else: 222 | name = toolbar.windowTitle() 223 | _toolbar = toolbar 224 | self._toolbars[name] = _toolbar 225 | return _toolbar 226 | 227 | def mapCanvas(self) -> QgsMapCanvas: 228 | """Return a pointer to the map canvas.""" 229 | return self.canvas 230 | 231 | def mainWindow(self) -> QWidget: 232 | """Return a pointer to the main window. 233 | 234 | In case of QGIS it returns an instance of QgisApp. 235 | """ 236 | return self._mainWindow 237 | 238 | def addDockWidget(self, area: int, dock_widget: QDockWidget) -> None: 239 | """Add a dock widget to the main window. 240 | 241 | :param area: Where in the ui the dock should be placed. 242 | :type area: Qt.DockWidgetArea 243 | 244 | :param dock_widget: A dock widget to add to the UI. 245 | :type dock_widget: QDockWidget 246 | """ 247 | 248 | def legendInterface(self) -> QgsMapCanvas: 249 | """Get the legend.""" 250 | return self.canvas 251 | 252 | def messageBar(self) -> MockMessageBar: 253 | """Get the messagebar""" 254 | return self._messageBar 255 | 256 | def getMockLayers(self) -> List[QgsMapLayer]: 257 | return self._layers 258 | 259 | def setActiveLayer(self, layer: QgsMapLayer) -> None: 260 | """ 261 | Set the active layer (layer gets selected in the legend) 262 | """ 263 | self._active_layer_id = layer.id() 264 | -------------------------------------------------------------------------------- /src/pytest_qgis/utils.py: -------------------------------------------------------------------------------- 1 | # Copyright (C) 2021-2023 pytest-qgis Contributors. 2 | # 3 | # 4 | # This file is part of pytest-qgis. 5 | # 6 | # pytest-qgis is free software: you can redistribute it and/or modify 7 | # it under the terms of the GNU General Public License as published by 8 | # the Free Software Foundation, either version 2 of the License, or 9 | # (at your option) any later version. 10 | # 11 | # pytest-qgis is distributed in the hope that it will be useful, 12 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 13 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 14 | # GNU General Public License for more details. 15 | # 16 | # You should have received a copy of the GNU General Public License 17 | # along with pytest-qgis. If not, see . 18 | # 19 | import time 20 | from collections import Counter 21 | from functools import wraps 22 | from pathlib import Path 23 | from typing import TYPE_CHECKING, Any, Callable, Generator, Optional 24 | from unittest.mock import MagicMock 25 | 26 | from osgeo import gdal 27 | from qgis.core import ( 28 | QgsCoordinateReferenceSystem, 29 | QgsCoordinateTransform, 30 | QgsLayerTree, 31 | QgsLayerTreeGroup, 32 | QgsLayerTreeLayer, 33 | QgsMapLayer, 34 | QgsProject, 35 | QgsRasterLayer, 36 | QgsRectangle, 37 | QgsVectorLayer, 38 | ) 39 | from qgis.PyQt import sip 40 | from qgis.PyQt.QtCore import QCoreApplication 41 | 42 | if TYPE_CHECKING: 43 | from _pytest.fixtures import FixtureRequest 44 | 45 | DEFAULT_RASTER_FORMAT = "tif" 46 | 47 | DEFAULT_EPSG = "EPSG:4326" 48 | LAYER_KEYWORDS = ("layer", "lyr", "raster", "rast", "tif") 49 | 50 | 51 | def get_common_extent_from_all_layers() -> Optional[QgsRectangle]: 52 | """Get common extent from all QGIS layers in the project.""" 53 | map_crs = QgsProject.instance().crs() 54 | layers = list(QgsProject.instance().mapLayers(validOnly=True).values()) 55 | 56 | if layers: 57 | extent = transform_rectangle(layers[0].extent(), layers[0].crs(), map_crs) 58 | for layer in layers[1:]: 59 | extent.combineExtentWith( 60 | transform_rectangle(layer.extent(), layer.crs(), map_crs) 61 | ) 62 | return extent 63 | return None 64 | 65 | 66 | def set_map_crs_based_on_layers() -> None: 67 | """Set map crs based on layers of the project.""" 68 | crs_counter = Counter( 69 | layer.crs().authid() 70 | for layer in QgsProject.instance().mapLayers().values() 71 | if layer.isSpatial() 72 | ) 73 | if crs_counter: 74 | crs_id, _ = crs_counter.most_common(1)[0] 75 | crs = QgsCoordinateReferenceSystem(crs_id) 76 | else: 77 | crs = QgsCoordinateReferenceSystem(DEFAULT_EPSG) 78 | QgsProject.instance().setCrs(crs) 79 | 80 | 81 | def transform_rectangle( 82 | rectangle: QgsRectangle, 83 | in_crs: QgsCoordinateReferenceSystem, 84 | out_crs: QgsCoordinateReferenceSystem, 85 | ) -> QgsRectangle: 86 | """ 87 | Transform rectangle from one crs to other. 88 | """ 89 | if in_crs == out_crs: 90 | return rectangle 91 | 92 | transform = QgsCoordinateTransform( 93 | QgsCoordinateReferenceSystem(in_crs), 94 | QgsCoordinateReferenceSystem(out_crs), 95 | QgsProject.instance(), 96 | ) 97 | return transform.transformBoundingBox(rectangle) 98 | 99 | 100 | def get_layers_with_different_crs() -> list[QgsMapLayer]: 101 | map_crs = QgsProject.instance().crs() 102 | return [ 103 | layer 104 | for layer in QgsProject.instance().mapLayers().values() 105 | if layer.crs() != map_crs 106 | ] 107 | 108 | 109 | def replace_layers_with_reprojected_clones( 110 | layers: list[QgsMapLayer], output_path: Path 111 | ) -> None: 112 | """ 113 | For some reason all layers having differing crs from the project are invisible. 114 | Hotfix is to replace those by reprojected layers with map crs. 115 | """ 116 | import processing 117 | 118 | vector_layers = [ 119 | layer 120 | for layer in layers 121 | if isinstance(layer, QgsVectorLayer) and layer.isSpatial() 122 | ] 123 | raster_layers = [ 124 | layer 125 | for layer in layers 126 | if isinstance(layer, QgsRasterLayer) and layer.isSpatial() 127 | ] 128 | 129 | map_crs = QgsProject.instance().crs() 130 | for input_layer in vector_layers: 131 | output_layer: QgsVectorLayer = processing.run( 132 | "native:reprojectlayer", 133 | {"INPUT": input_layer, "TARGET_CRS": map_crs, "OUTPUT": "TEMPORARY_OUTPUT"}, 134 | )["OUTPUT"] 135 | if not output_layer.crs().isValid(): 136 | output_layer.setCrs(map_crs) 137 | 138 | copy_layer_style_and_position(input_layer, output_layer, output_path) 139 | 140 | for input_layer in raster_layers: 141 | try: 142 | output_raster = str( 143 | Path(output_path, f"{input_layer.name()}.{DEFAULT_RASTER_FORMAT}") 144 | ) 145 | warp = gdal.Warp( 146 | output_raster, input_layer.source(), dstSRS=map_crs.authid() 147 | ) 148 | 149 | finally: 150 | warp = None # noqa: F841 151 | 152 | output_layer = QgsRasterLayer(output_raster) 153 | if not output_layer.crs().isValid(): 154 | output_layer.setCrs(map_crs) 155 | copy_layer_style_and_position(input_layer, output_layer, output_path) 156 | 157 | # Remove originals from project 158 | QgsProject.instance().removeMapLayers([layer.id() for layer in layers]) 159 | 160 | 161 | def copy_layer_style_and_position( 162 | layer1: QgsMapLayer, layer2: QgsMapLayer, tmp_path: Path 163 | ) -> None: 164 | """ 165 | Copy layer style and position to another layer. 166 | """ 167 | style_file = str(Path(tmp_path, f"{layer1.id()}.qml")) 168 | msg, succeeded = layer1.saveNamedStyle(style_file) 169 | if succeeded: 170 | layer2.loadNamedStyle(style_file) 171 | layer2.setMetadata(layer1.metadata()) 172 | layer2.setName(layer1.name()) 173 | if layer2.isValid(): 174 | QgsProject.instance().addMapLayer(layer2, False) 175 | 176 | root: QgsLayerTree = QgsProject.instance().layerTreeRoot() 177 | layer_tree_layer: QgsLayerTreeLayer = root.findLayer(layer1) 178 | group: QgsLayerTreeGroup = layer_tree_layer.parent() 179 | index = {child.name(): i for i, child in enumerate(group.children())}[ 180 | layer_tree_layer.name() 181 | ] 182 | 183 | group.insertLayer(index + 1, layer2) 184 | 185 | 186 | def clean_qgis_layer(fn: Callable[..., QgsMapLayer]) -> Callable[..., QgsMapLayer]: 187 | """ 188 | Decorator to ensure that a map layer created by a fixture is cleaned properly. 189 | 190 | Sometimes fixture non-memory layers that are used but not added 191 | to the project might cause segmentation fault errors. 192 | 193 | This decorator works only with fixtures that **return** QgsMapLayer instances. 194 | There is no support for fixtures that use yield. 195 | 196 | >>> @pytest.fixture() 197 | >>> @clean_qgis_layer 198 | >>> def geojson_layer() -> QgsVectorLayer: 199 | >>> layer = QgsVectorLayer("layer.json", "layer", "ogr") 200 | >>> return layer 201 | 202 | This decorator is the alternative way of cleaning the layers since layer fixtures 203 | are automatically cleaned if they contain one of the keywords listed in 204 | LAYER_KEYWORDS by pytest_runtest_teardown hook. 205 | """ 206 | 207 | @wraps(fn) 208 | def wrapper(*args: Any, **kwargs: Any) -> Generator[QgsMapLayer, None, None]: 209 | layer = fn(*args, **kwargs) 210 | yield layer 211 | _set_layer_owner_to_project(layer) 212 | 213 | return wrapper 214 | 215 | 216 | def ensure_qgis_layer_fixtures_are_cleaned(request: "FixtureRequest") -> None: 217 | """ 218 | Sometimes fixture non-memory layers that are used but not added 219 | to the project might cause segmentation fault errors. 220 | 221 | This function ensures that the layer fixtures will be cleaned by 222 | adding and removing those into the project. 223 | 224 | It does not matter what scoped the fixtures are since the 225 | layers are not actually deleted at any point. 226 | """ 227 | for fixture_name in request.fixturenames: 228 | if any( 229 | possible_layer_name in fixture_name.lower() 230 | for possible_layer_name in LAYER_KEYWORDS 231 | ): 232 | try: 233 | layer = request.getfixturevalue(fixture_name) 234 | except AssertionError: 235 | continue 236 | _set_layer_owner_to_project(layer) 237 | 238 | 239 | def _set_layer_owner_to_project(layer: Any) -> None: 240 | if ( 241 | isinstance(layer, QgsMapLayer) 242 | and not isinstance(layer, MagicMock) 243 | and not sip.isdeleted(layer) 244 | and layer.id() not in QgsProject.instance().mapLayers(True) 245 | ): 246 | QgsProject.instance().addMapLayer(layer) 247 | QgsProject.instance().removeMapLayer(layer) 248 | 249 | 250 | def wait(wait_time_milliseconds: int = 0) -> None: 251 | """Waits for wait_time ms.""" 252 | start = time.time() 253 | 254 | while (time.time() - start) * 1000 < wait_time_milliseconds: 255 | QCoreApplication.processEvents() 256 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright (C) 2021 pytest-qgis Contributors. 2 | # 3 | # 4 | # This file is part of pytest-qgis. 5 | # 6 | # pytest-qgis is free software: you can redistribute it and/or modify 7 | # it under the terms of the GNU General Public License as published by 8 | # the Free Software Foundation, either version 2 of the License, or 9 | # (at your option) any later version. 10 | # 11 | # pytest-qgis is distributed in the hope that it will be useful, 12 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 13 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 14 | # GNU General Public License for more details. 15 | # 16 | # You should have received a copy of the GNU General Public License 17 | # along with pytest-qgis. If not, see . 18 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | # Copyright (C) 2021 pytest-qgis Contributors. 2 | # 3 | # 4 | # This file is part of pytest-qgis. 5 | # 6 | # pytest-qgis is free software: you can redistribute it and/or modify 7 | # it under the terms of the GNU General Public License as published by 8 | # the Free Software Foundation, either version 2 of the License, or 9 | # (at your option) any later version. 10 | # 11 | # pytest-qgis is distributed in the hope that it will be useful, 12 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 13 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 14 | # GNU General Public License for more details. 15 | # 16 | # You should have received a copy of the GNU General Public License 17 | # along with pytest-qgis. If not, see . 18 | import shutil 19 | from pathlib import Path 20 | 21 | import pytest 22 | from qgis.core import QgsRasterLayer, QgsVectorLayer 23 | 24 | pytest_plugins = "pytester" 25 | 26 | 27 | @pytest.fixture() 28 | def gpkg(tmp_path: Path) -> Path: 29 | return get_copied_gpkg(tmp_path) 30 | 31 | 32 | @pytest.fixture(scope="module") 33 | def gpkg_module(tmpdir_factory) -> Path: 34 | tmp_path = Path(tmpdir_factory.mktemp("pytest_qgis_data")) 35 | return get_copied_gpkg(tmp_path) 36 | 37 | 38 | @pytest.fixture(scope="session") 39 | def gpkg_session(tmpdir_factory) -> Path: 40 | tmp_path = Path(tmpdir_factory.mktemp("pytest_qgis_data")) 41 | return get_copied_gpkg(tmp_path) 42 | 43 | 44 | @pytest.fixture() 45 | def layer_polygon(gpkg: Path): 46 | return get_gpkg_layer("polygon", gpkg) 47 | 48 | 49 | @pytest.fixture() 50 | def layer_polygon_function(gpkg: Path): 51 | return get_gpkg_layer("polygon", gpkg) 52 | 53 | 54 | @pytest.fixture() 55 | def lyr_polygon_module(gpkg_module: Path): 56 | return get_gpkg_layer("polygon", gpkg_module) 57 | 58 | 59 | @pytest.fixture() 60 | def layer_polygon_session(gpkg_session: Path): 61 | return get_gpkg_layer("polygon", gpkg_session) 62 | 63 | 64 | @pytest.fixture() 65 | def layer_polygon_3067(gpkg: Path): 66 | return get_gpkg_layer("polygon_3067", gpkg) 67 | 68 | 69 | @pytest.fixture() 70 | def raster_3067(): 71 | return get_raster_layer( 72 | "small raster 3067", 73 | Path(Path(__file__).parent, "data", "small_raster.tif"), 74 | ) 75 | 76 | 77 | @pytest.fixture() 78 | def layer_points(gpkg: Path): 79 | return get_gpkg_layer("points", gpkg) 80 | 81 | 82 | def get_copied_gpkg(tmp_path: Path) -> Path: 83 | db = Path(Path(__file__).parent, "data", "db.gpkg") 84 | new_db_path = tmp_path / "db.gpkg" 85 | shutil.copy(db, new_db_path) 86 | return new_db_path 87 | 88 | 89 | def get_gpkg_layer(name: str, gpkg: Path) -> QgsVectorLayer: 90 | layer = QgsVectorLayer(f"{gpkg!s}|layername={name}", name, "ogr") 91 | layer.setProviderEncoding("utf-8") 92 | assert layer.isValid() 93 | assert layer.crs().isValid() 94 | return layer 95 | 96 | 97 | def get_raster_layer(name: str, path: Path) -> QgsRasterLayer: 98 | layer = QgsRasterLayer(str(path), name) 99 | assert layer.isValid() 100 | return layer 101 | -------------------------------------------------------------------------------- /tests/data/db.gpkg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GispoCoding/pytest-qgis/a316c0ae5c78ddbc2342e3ba55669eefbbd9eb6e/tests/data/db.gpkg -------------------------------------------------------------------------------- /tests/data/small_raster.tif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GispoCoding/pytest-qgis/a316c0ae5c78ddbc2342e3ba55669eefbbd9eb6e/tests/data/small_raster.tif -------------------------------------------------------------------------------- /tests/test_ini.py: -------------------------------------------------------------------------------- 1 | # Copyright (C) 2021-2022 pytest-qgis Contributors. 2 | # 3 | # 4 | # This file is part of pytest-qgis. 5 | # 6 | # pytest-qgis is free software: you can redistribute it and/or modify 7 | # it under the terms of the GNU General Public License as published by 8 | # the Free Software Foundation, either version 2 of the License, or 9 | # (at your option) any later version. 10 | # 11 | # pytest-qgis is distributed in the hope that it will be useful, 12 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 13 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 14 | # GNU General Public License for more details. 15 | # 16 | # You should have received a copy of the GNU General Public License 17 | # along with pytest-qgis. If not, see . 18 | # 19 | 20 | from typing import TYPE_CHECKING 21 | 22 | import pytest 23 | 24 | if TYPE_CHECKING: 25 | from _pytest.pytester import Testdir 26 | 27 | 28 | def test_ini_canvas(testdir: "Testdir"): 29 | testdir.makeini( 30 | """ 31 | [pytest] 32 | qgis_canvas_height=1000 33 | qgis_canvas_width=1200 34 | """ 35 | ) 36 | testdir.makepyfile( 37 | """ 38 | def test_canvas(qgis_canvas): 39 | assert qgis_canvas.width() == 1200 40 | assert qgis_canvas.height() == 1000 41 | """ 42 | ) 43 | result = testdir.runpytest("--qgis_disable_init") 44 | result.assert_outcomes(passed=1) 45 | 46 | 47 | @pytest.mark.parametrize("gui_enabled", [True, False]) 48 | def test_ini_gui(gui_enabled: bool, testdir: "Testdir"): 49 | testdir.makeini( 50 | f""" 51 | [pytest] 52 | qgis_qui_enabled={gui_enabled} 53 | """ 54 | ) 55 | 56 | testdir.makepyfile( 57 | f""" 58 | import os 59 | 60 | def test_offscreen(qgis_new_project): 61 | assert (os.environ.get("QT_QPA_PLATFORM", "") == 62 | "{'offscreen' if not gui_enabled else ''}") 63 | """ 64 | ) 65 | result = testdir.runpytest("--qgis_disable_init") 66 | result.assert_outcomes(passed=1) 67 | 68 | result = testdir.runpytest("--qgis_disable_init", "--qgis_disable_gui") 69 | result.assert_outcomes( 70 | passed=1 if not gui_enabled else 0, failed=1 if gui_enabled else 0 71 | ) 72 | -------------------------------------------------------------------------------- /tests/test_layer_cleanup.py: -------------------------------------------------------------------------------- 1 | # Copyright (C) 2021-2023 pytest-qgis Contributors. 2 | # 3 | # 4 | # This file is part of pytest-qgis. 5 | # 6 | # pytest-qgis is free software: you can redistribute it and/or modify 7 | # it under the terms of the GNU General Public License as published by 8 | # the Free Software Foundation, either version 2 of the License, or 9 | # (at your option) any later version. 10 | # 11 | # pytest-qgis is distributed in the hope that it will be useful, 12 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 13 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 14 | # GNU General Public License for more details. 15 | # 16 | # You should have received a copy of the GNU General Public License 17 | # along with pytest-qgis. If not, see . 18 | from unittest.mock import MagicMock 19 | 20 | import pytest 21 | from qgis.core import QgsMapLayer, QgsProject, QgsVectorLayer 22 | 23 | """ 24 | Tests in this module will cause Segmentation fault error if 25 | non-memory layer is not cleaned properly. 26 | 27 | Tests are not parametrized since the problem cannot be observed with 28 | parametrized tests. 29 | """ 30 | 31 | 32 | @pytest.fixture() 33 | def stub_layer() -> MagicMock: 34 | return MagicMock( 35 | spec=QgsVectorLayer, 36 | autospec=True, 37 | ) 38 | 39 | 40 | def test_layer_fixture_should_be_cleaned(layer_polygon_function): 41 | _test(layer_polygon_function) 42 | 43 | 44 | def test_layer_fixture_should_be_cleaned_module(lyr_polygon_module): 45 | _test(lyr_polygon_module) 46 | 47 | 48 | def test_layer_fixture_should_be_cleaned_2(layer_polygon_function): 49 | _test(layer_polygon_function) 50 | 51 | 52 | def test_layer_fixture_should_be_cleaned_session(layer_polygon_session): 53 | _test(layer_polygon_session) 54 | 55 | 56 | def test_layer_fixture_should_be_cleaned_module_2(lyr_polygon_module): 57 | _test(lyr_polygon_module) 58 | 59 | 60 | def test_layer_fixture_should_be_cleaned_session_2(layer_polygon_session): 61 | _test(layer_polygon_session) 62 | 63 | 64 | def test_raster_layer_fixture_should_be_cleaned(raster_3067): 65 | _test(raster_3067) 66 | 67 | 68 | def _test(layer: QgsMapLayer) -> None: 69 | # Check that the layer is not in the project 70 | assert not QgsProject.instance().mapLayer(layer.id()) 71 | 72 | 73 | def test_mocked_layer_should_not_mess_with_cleaning_layers(stub_layer): 74 | assert isinstance(stub_layer, QgsVectorLayer) 75 | -------------------------------------------------------------------------------- /tests/test_pytest_qgis.py: -------------------------------------------------------------------------------- 1 | # Copyright (C) 2021-2023 pytest-qgis Contributors. 2 | # 3 | # 4 | # This file is part of pytest-qgis. 5 | # 6 | # pytest-qgis is free software: you can redistribute it and/or modify 7 | # it under the terms of the GNU General Public License as published by 8 | # the Free Software Foundation, either version 2 of the License, or 9 | # (at your option) any later version. 10 | # 11 | # pytest-qgis is distributed in the hope that it will be useful, 12 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 13 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 14 | # GNU General Public License for more details. 15 | # 16 | # You should have received a copy of the GNU General Public License 17 | # along with pytest-qgis. If not, see . 18 | 19 | import pytest 20 | from qgis.core import ( 21 | Qgis, 22 | QgsCoordinateReferenceSystem, 23 | QgsProcessing, 24 | QgsProject, 25 | QgsVectorLayer, 26 | ) 27 | from qgis.PyQt.QtWidgets import QToolBar 28 | from qgis.utils import iface 29 | 30 | from tests.utils import QGIS_VERSION 31 | 32 | QGIS_3_18 = 31800 33 | 34 | # DO not use this directly, this is only meant to be used with 35 | # replace_iface_with_qgis_iface fixture 36 | __iface = None 37 | 38 | 39 | @pytest.fixture() 40 | def replace_iface_with_qgis_iface(qgis_iface): 41 | global __iface # noqa: PLW0603 42 | __iface = qgis_iface 43 | 44 | 45 | @pytest.mark.usefixtures("replace_iface_with_qgis_iface") 46 | def test_a_teardown(): 47 | """ 48 | When replacing imported or passed QgisInterface inside a fixture, 49 | it might cause problems with pytest_qgis.qgis_interface.removeAllLayers 50 | when qgis_app is exiting. 51 | """ 52 | 53 | 54 | def test_add_layer(): 55 | layer = QgsVectorLayer("Polygon", "dummy_polygon_layer", "memory") 56 | QgsProject.instance().addMapLayer(layer) 57 | assert set(QgsProject.instance().mapLayers().values()) == {layer} 58 | 59 | 60 | def test_qgis_new_project(qgis_new_project): 61 | assert QgsProject.instance().mapLayers() == {} 62 | 63 | 64 | def test_msg_bar(qgis_iface): 65 | qgis_iface.messageBar().pushMessage("title", "text", Qgis.Info, 6) 66 | assert qgis_iface.messageBar().messages.get(Qgis.Info) == ["title:text"] 67 | 68 | 69 | def test_processing_providers(qgis_app, qgis_processing): 70 | assert "qgis" in [ 71 | provider.id() for provider in qgis_app.processingRegistry().providers() 72 | ] 73 | 74 | 75 | def test_processing_run(qgis_processing): 76 | from qgis import processing 77 | 78 | # Use any algo that is available on all test platforms 79 | result = processing.run( 80 | "qgis:regularpoints", 81 | { 82 | "EXTENT": "0,1,0,1", 83 | "CRS": "EPSG:4326", 84 | "OUTPUT": QgsProcessing.TEMPORARY_OUTPUT, 85 | }, 86 | ) 87 | 88 | assert "OUTPUT" in result 89 | assert isinstance(result["OUTPUT"], QgsVectorLayer) 90 | assert result["OUTPUT"].isValid() 91 | assert len(list(result["OUTPUT"].getFeatures())) > 0 92 | 93 | 94 | @pytest.mark.skipif( 95 | QGIS_VERSION < QGIS_3_18, reason="https://github.com/qgis/QGIS/issues/40564" 96 | ) 97 | def test_setup_qgis_iface(qgis_iface): 98 | assert iface == qgis_iface 99 | 100 | 101 | def test_iface_active_layer(qgis_iface, layer_polygon, layer_points): 102 | QgsProject.instance().addMapLayer(layer_polygon) 103 | QgsProject.instance().addMapLayer(layer_points) 104 | 105 | assert qgis_iface.activeLayer() is None 106 | qgis_iface.setActiveLayer(layer_polygon) 107 | assert qgis_iface.activeLayer() == layer_polygon 108 | qgis_iface.setActiveLayer(layer_points) 109 | assert qgis_iface.activeLayer() == layer_points 110 | 111 | 112 | def test_iface_toolbar_str(qgis_iface): 113 | name = "test_bar" 114 | toolbar: QToolBar = qgis_iface.addToolBar(name) 115 | assert toolbar.windowTitle() == name 116 | assert qgis_iface._toolbars == {name: toolbar} 117 | 118 | 119 | def test_iface_toolbar_qtoolbar(qgis_iface): 120 | name = "test_bar" 121 | toolbar: QToolBar = QToolBar(name) 122 | qgis_iface.addToolBar(toolbar) 123 | assert toolbar.windowTitle() == name 124 | assert qgis_iface._toolbars == {name: toolbar} 125 | 126 | 127 | def test_canvas_should_be_released(qgis_canvas, layer_polygon, layer_points): 128 | """ 129 | This test will not assert anything but calling zoom methods of qgis_canvas 130 | will cause segmentation faults after test session if 131 | the canvas is not released properly. 132 | """ 133 | QgsProject.instance().addMapLayer(layer_polygon) 134 | QgsProject.instance().addMapLayer(layer_points) 135 | qgis_canvas.zoomToFullExtent() 136 | 137 | 138 | def test_crs_is_not_constructed_before_application(): 139 | wkt_4326 = 'GEOGCS["WGS 84",DATUM["WGS_1984",SPHEROID["WGS 84",6378137,298.257223563,AUTHORITY["EPSG","7030"]],AUTHORITY["EPSG","6326"]],PRIMEM["Greenwich",0,AUTHORITY["EPSG","8901"]],UNIT["degree",0.0174532925199433,AUTHORITY["EPSG","9122"]],AUTHORITY["EPSG","4326"]]' # noqa: E501 140 | crs = QgsCoordinateReferenceSystem.fromWkt(wkt_4326) 141 | assert crs.isValid() 142 | -------------------------------------------------------------------------------- /tests/test_qgis_bot.py: -------------------------------------------------------------------------------- 1 | # Copyright (C) 2021-2022 pytest-qgis Contributors. 2 | # 3 | # 4 | # This file is part of pytest-qgis. 5 | # 6 | # pytest-qgis is free software: you can redistribute it and/or modify 7 | # it under the terms of the GNU General Public License as published by 8 | # the Free Software Foundation, either version 2 of the License, or 9 | # (at your option) any later version. 10 | # 11 | # pytest-qgis is distributed in the hope that it will be useful, 12 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 13 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 14 | # GNU General Public License for more details. 15 | # 16 | # You should have received a copy of the GNU General Public License 17 | # along with pytest-qgis. If not, see . 18 | # 19 | from typing import TYPE_CHECKING 20 | 21 | import pytest 22 | from qgis.core import QgsFieldConstraints, QgsGeometry 23 | from qgis.gui import QgsAttributeDialog 24 | 25 | if TYPE_CHECKING: 26 | from pytest_qgis.qgis_bot import QgisBot 27 | from qgis.core import QgsVectorLayer 28 | from qgis.gui import QgisInterface 29 | 30 | 31 | @pytest.fixture() 32 | def layer_with_soft_constraint(layer_points: "QgsVectorLayer") -> "QgsVectorLayer": 33 | """setup the layer""" 34 | # Set not-null constraint with SOFT strength 35 | fields = layer_points.fields() 36 | field_idx = fields.indexOf("text_field") 37 | layer_points.setFieldConstraint( 38 | field_idx, 39 | QgsFieldConstraints.Constraint.ConstraintNotNull, 40 | QgsFieldConstraints.ConstraintStrengthSoft, 41 | ) 42 | 43 | layer_points.startEditing() 44 | 45 | return layer_points 46 | 47 | 48 | @pytest.fixture() 49 | def layer_with_hard_constraint(layer_points: "QgsVectorLayer") -> "QgsVectorLayer": 50 | """setup the layer""" 51 | # Set not-null constraint with SOFT strength 52 | fields = layer_points.fields() 53 | field_idx = fields.indexOf("text_field") 54 | layer_points.setFieldConstraint( 55 | field_idx, 56 | QgsFieldConstraints.Constraint.ConstraintNotNull, 57 | QgsFieldConstraints.ConstraintStrengthHard, 58 | ) 59 | 60 | layer_points.startEditing() 61 | 62 | return layer_points 63 | 64 | 65 | def test_feature_gets_created_with_check_box_false_when_not_raising_from_warnings( 66 | layer_with_soft_constraint: "QgsVectorLayer", qgis_bot: "QgisBot" 67 | ): 68 | feature_count_before = layer_with_soft_constraint.featureCount() 69 | feature = qgis_bot.create_feature_with_attribute_dialog( 70 | layer_with_soft_constraint, 71 | QgsGeometry.fromWkt("POINT(0,0)"), 72 | raise_from_warnings=False, 73 | ) 74 | 75 | assert layer_with_soft_constraint.featureCount() == feature_count_before + 1 76 | 77 | # With normal way of creating, it would be NULL. 78 | assert feature["bool_field"] is False 79 | 80 | 81 | def test_should_raise_valueerror_on_soft_constraint_break_when_asked( 82 | layer_with_soft_constraint: "QgsVectorLayer", qgis_bot: "QgisBot" 83 | ): 84 | with pytest.raises(ValueError, match="value is NULL"): 85 | qgis_bot.create_feature_with_attribute_dialog( 86 | layer_with_soft_constraint, 87 | QgsGeometry.fromWkt("POINT(0,0)"), 88 | raise_from_warnings=True, 89 | ) 90 | 91 | 92 | def test_feature_gets_created_with_check_box_false_when_not_raising_from_errors( 93 | layer_with_hard_constraint: "QgsVectorLayer", qgis_bot: "QgisBot" 94 | ): 95 | feature_count_before = layer_with_hard_constraint.featureCount() 96 | feature = qgis_bot.create_feature_with_attribute_dialog( 97 | layer_with_hard_constraint, 98 | QgsGeometry.fromWkt("POINT(0,0)"), 99 | raise_from_errors=False, 100 | ) 101 | 102 | assert layer_with_hard_constraint.featureCount() == feature_count_before + 1 103 | 104 | # With normal way of creating, it would be NULL. 105 | assert feature["bool_field"] is False 106 | 107 | 108 | def test_should_raise_valueerror_on_hard_constraint_break_when_asked( 109 | layer_with_hard_constraint: "QgsVectorLayer", qgis_bot: "QgisBot" 110 | ): 111 | with pytest.raises(ValueError, match="value is NULL"): 112 | qgis_bot.create_feature_with_attribute_dialog( 113 | layer_with_hard_constraint, 114 | QgsGeometry.fromWkt("POINT(0,0)"), 115 | raise_from_errors=True, 116 | ) 117 | 118 | 119 | def test_create_simple_feature_with_attribute_dialog( 120 | layer_points: "QgsVectorLayer", qgis_bot: "QgisBot" 121 | ): 122 | layer = layer_points 123 | count = layer.featureCount() 124 | 125 | layer.startEditing() 126 | feat = qgis_bot.create_feature_with_attribute_dialog( 127 | layer, QgsGeometry.fromWkt("POINT(0,0)") 128 | ) 129 | assert layer.featureCount() == count + 1 130 | assert feat["bool_field"] is False 131 | 132 | 133 | def test_get_qgs_attribute_dialog_widgets_by_name( 134 | qgis_iface: "QgisInterface", layer_points: "QgsVectorLayer", qgis_bot: "QgisBot" 135 | ): 136 | dialog = QgsAttributeDialog( 137 | layer_points, 138 | layer_points.getFeature(1), 139 | False, 140 | qgis_iface.mainWindow(), 141 | True, 142 | ) 143 | widgets_by_name = qgis_bot.get_qgs_attribute_dialog_widgets_by_name(dialog) 144 | assert { 145 | name: widget.__class__.__name__ for name, widget in widgets_by_name.items() 146 | } == { 147 | "bool_field": "QCheckBox", 148 | "date_field": "QDateTimeEdit", 149 | "datetime_field": "QDateTimeEdit", 150 | "decimal_field": "QgsFilterLineEdit", 151 | "fid": "QgsFilterLineEdit", 152 | "text_field": "QgsFilterLineEdit", 153 | } 154 | -------------------------------------------------------------------------------- /tests/test_utils.py: -------------------------------------------------------------------------------- 1 | # Copyright (C) 2021-2023 pytest-qgis Contributors. 2 | # 3 | # 4 | # This file is part of pytest-qgis. 5 | # 6 | # pytest-qgis is free software: you can redistribute it and/or modify 7 | # it under the terms of the GNU General Public License as published by 8 | # the Free Software Foundation, either version 2 of the License, or 9 | # (at your option) any later version. 10 | # 11 | # pytest-qgis is distributed in the hope that it will be useful, 12 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 13 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 14 | # GNU General Public License for more details. 15 | # 16 | # You should have received a copy of the GNU General Public License 17 | # along with pytest-qgis. If not, see . 18 | 19 | import pytest 20 | from pytest_qgis.utils import ( 21 | clean_qgis_layer, 22 | get_common_extent_from_all_layers, 23 | get_layers_with_different_crs, 24 | replace_layers_with_reprojected_clones, 25 | set_map_crs_based_on_layers, 26 | ) 27 | from qgis.core import QgsCoordinateReferenceSystem, QgsProject, QgsVectorLayer 28 | from qgis.PyQt import sip 29 | 30 | from tests.utils import EPSG_3067, EPSG_4326, QGIS_VERSION 31 | 32 | QGIS_3_12 = 31200 33 | 34 | 35 | @pytest.fixture() 36 | def crs(): 37 | QgsProject.instance().setCrs(QgsCoordinateReferenceSystem(EPSG_4326)) 38 | 39 | 40 | @pytest.fixture() 41 | def layers_added(qgis_new_project, layer_polygon, layer_polygon_3067, raster_3067): 42 | QgsProject.instance().addMapLayers([raster_3067, layer_polygon_3067, layer_polygon]) 43 | 44 | 45 | @pytest.mark.skipif( 46 | QGIS_VERSION < QGIS_3_12, 47 | reason="QGIS 3.10 test image cannot find correct algorithms", 48 | ) 49 | def test_get_common_extent_from_all_layers( 50 | qgis_new_project, crs, layer_polygon, layer_polygon_3067 51 | ): 52 | QgsProject.instance().addMapLayers([layer_polygon, layer_polygon_3067]) 53 | assert get_common_extent_from_all_layers().toString(0) == "23,61 : 32,68" 54 | 55 | 56 | @pytest.mark.skipif( 57 | QGIS_VERSION < QGIS_3_12, 58 | reason="QGIS 3.10 test image cannot find correct algorithms", 59 | ) 60 | def test_set_map_crs_based_on_layers_should_set_4326(qgis_new_project, layer_polygon): 61 | layer_polygon2 = layer_polygon.clone() 62 | QgsProject.instance().addMapLayers([layer_polygon, layer_polygon2]) 63 | set_map_crs_based_on_layers() 64 | assert QgsProject.instance().crs().authid() == EPSG_4326 65 | 66 | 67 | def test_set_map_crs_based_on_layers_should_set_3067(layers_added): 68 | set_map_crs_based_on_layers() 69 | assert QgsProject.instance().crs().authid() == EPSG_3067 70 | 71 | 72 | def test_get_layers_with_different_crs( 73 | crs, layers_added, layer_polygon_3067, raster_3067 74 | ): 75 | assert set(get_layers_with_different_crs()) == {layer_polygon_3067, raster_3067} 76 | 77 | 78 | @pytest.mark.skipif( 79 | QGIS_VERSION < QGIS_3_12, 80 | reason="QGIS 3.10 test image cannot find correct algorithms", 81 | ) 82 | def test_replace_layers_with_reprojected_clones( # noqa: PLR0913 83 | crs, layers_added, qgis_processing, layer_polygon_3067, raster_3067, tmp_path 84 | ): 85 | vector_layer_id = layer_polygon_3067.id() 86 | raster_layer_id = raster_3067.id() 87 | vector_layer_name = layer_polygon_3067.name() 88 | raster_layer_name = raster_3067.name() 89 | 90 | replace_layers_with_reprojected_clones([layer_polygon_3067, raster_3067], tmp_path) 91 | 92 | layers = { 93 | layer.name(): layer for layer in QgsProject.instance().mapLayers().values() 94 | } 95 | 96 | assert {vector_layer_name, raster_layer_name}.issubset(set(layers.keys())) 97 | assert layers[vector_layer_name].id() != vector_layer_id 98 | assert layers[raster_layer_name].id() != raster_layer_id 99 | assert layers[vector_layer_name].crs().authid() == EPSG_4326 100 | assert layers[raster_layer_name].crs().authid() == EPSG_4326 101 | assert (tmp_path / f"{vector_layer_id}.qml").exists() 102 | assert (tmp_path / f"{raster_layer_id}.qml").exists() 103 | 104 | 105 | def test_clean_qgis_layer(layer_polygon): 106 | layer = QgsVectorLayer(layer_polygon.source(), "another layer") 107 | 108 | @clean_qgis_layer 109 | def layer_function() -> QgsVectorLayer: 110 | return layer 111 | 112 | # Using list to trigger yield and the code that runs after it 113 | list(layer_function()) 114 | 115 | assert sip.isdeleted(layer) 116 | -------------------------------------------------------------------------------- /tests/utils.py: -------------------------------------------------------------------------------- 1 | # Copyright (C) 2021 pytest-qgis Contributors. 2 | # 3 | # 4 | # This file is part of pytest-qgis. 5 | # 6 | # pytest-qgis is free software: you can redistribute it and/or modify 7 | # it under the terms of the GNU General Public License as published by 8 | # the Free Software Foundation, either version 2 of the License, or 9 | # (at your option) any later version. 10 | # 11 | # pytest-qgis is distributed in the hope that it will be useful, 12 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 13 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 14 | # GNU General Public License for more details. 15 | # 16 | # You should have received a copy of the GNU General Public License 17 | # along with pytest-qgis. If not, see . 18 | # 19 | import os 20 | 21 | from qgis.core import Qgis 22 | 23 | try: 24 | QGIS_VERSION = Qgis.versionInt() 25 | except AttributeError: 26 | QGIS_VERSION = Qgis.QGIS_VERSION_INT 27 | 28 | IN_CI = os.environ.get("QGIS_IN_CI") 29 | 30 | EPSG_4326 = "EPSG:4326" 31 | EPSG_3067 = "EPSG:3067" 32 | -------------------------------------------------------------------------------- /tests/visual/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright (C) 2021 pytest-qgis Contributors. 2 | # 3 | # 4 | # This file is part of pytest-qgis. 5 | # 6 | # pytest-qgis is free software: you can redistribute it and/or modify 7 | # it under the terms of the GNU General Public License as published by 8 | # the Free Software Foundation, either version 2 of the License, or 9 | # (at your option) any later version. 10 | # 11 | # pytest-qgis is distributed in the hope that it will be useful, 12 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 13 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 14 | # GNU General Public License for more details. 15 | # 16 | # You should have received a copy of the GNU General Public License 17 | # along with pytest-qgis. If not, see . 18 | -------------------------------------------------------------------------------- /tests/visual/test_qgis_ui.py: -------------------------------------------------------------------------------- 1 | # Copyright (C) 2021-2023 pytest-qgis Contributors. 2 | # 3 | # 4 | # This file is part of pytest-qgis. 5 | # 6 | # pytest-qgis is free software: you can redistribute it and/or modify 7 | # it under the terms of the GNU General Public License as published by 8 | # the Free Software Foundation, either version 2 of the License, or 9 | # (at your option) any later version. 10 | # 11 | # pytest-qgis is distributed in the hope that it will be useful, 12 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 13 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 14 | # GNU General Public License for more details. 15 | # 16 | # You should have received a copy of the GNU General Public License 17 | # along with pytest-qgis. If not, see . 18 | 19 | import pytest 20 | from qgis.gui import QgsAttributeDialog 21 | from qgis.PyQt import QtCore 22 | 23 | from tests.utils import IN_CI 24 | 25 | TIMEOUT = 10 if IN_CI else 1000 26 | 27 | 28 | @pytest.mark.with_pytest_qt() 29 | def test_attribute_dialog_change( 30 | qgis_iface, qgis_canvas, layer_points, qgis_bot, qtbot 31 | ): 32 | # The essential thing is QgsGui.editorWidgetRegistry().initEditors() 33 | layer = layer_points 34 | 35 | layer.startEditing() 36 | f = layer.getFeature(1) 37 | assert f 38 | 39 | dialog = QgsAttributeDialog( 40 | layer, 41 | f, 42 | False, 43 | qgis_iface.mainWindow(), 44 | True, 45 | ) 46 | qtbot.add_widget(dialog) 47 | dialog.show() 48 | 49 | widgets_by_name = qgis_bot.get_qgs_attribute_dialog_widgets_by_name(dialog) 50 | test_text = "New string" 51 | 52 | # Doubleclick and keys after that erase the old text 53 | qtbot.mouseDClick(widgets_by_name["text_field"], QtCore.Qt.LeftButton) 54 | qtbot.keyClicks(widgets_by_name["text_field"], test_text) 55 | 56 | qtbot.wait(TIMEOUT) 57 | dialog.accept() 58 | layer.commitChanges() 59 | 60 | assert layer.getFeature(1)["text_field"] == test_text 61 | -------------------------------------------------------------------------------- /tests/visual/test_show_map.py: -------------------------------------------------------------------------------- 1 | # Copyright (C) 2021-2023 pytest-qgis Contributors. 2 | # 3 | # 4 | # This file is part of pytest-qgis. 5 | # 6 | # pytest-qgis is free software: you can redistribute it and/or modify 7 | # it under the terms of the GNU General Public License as published by 8 | # the Free Software Foundation, either version 2 of the License, or 9 | # (at your option) any later version. 10 | # 11 | # pytest-qgis is distributed in the hope that it will be useful, 12 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 13 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 14 | # GNU General Public License for more details. 15 | # 16 | # You should have received a copy of the GNU General Public License 17 | # along with pytest-qgis. If not, see . 18 | # 19 | import pytest 20 | from qgis.core import QgsProject, QgsRectangle 21 | 22 | from tests.utils import IN_CI, QGIS_VERSION 23 | 24 | """ 25 | These tests are meant to be tested visually by the developer. 26 | 27 | NOTE: if you have pytest-qt installed, you might encounter some 28 | problems with tests in this module. 29 | 30 | In that case, run these tests with pytest-qt disabled: "pytest -p no:pytest-qt" 31 | """ 32 | 33 | DEFAULT_TIMEOUT = 0.01 if IN_CI else 1 34 | 35 | QGIS_3_12 = 31200 36 | QGIS_3_18 = 31800 37 | 38 | 39 | @pytest.fixture(autouse=True) 40 | def setup(qgis_new_project): 41 | pass 42 | 43 | 44 | @pytest.mark.qgis_show_map(timeout=DEFAULT_TIMEOUT) 45 | def test_show_map(layer_polygon, qgis_canvas, qgis_parent): 46 | QgsProject.instance().addMapLayers([layer_polygon]) 47 | assert qgis_parent.size() == qgis_canvas.size() 48 | 49 | 50 | @pytest.mark.qgis_show_map(timeout=0) 51 | def test_show_map_with_zero_timeout(layer_polygon): 52 | QgsProject.instance().addMapLayers([layer_polygon]) 53 | 54 | 55 | @pytest.mark.qgis_show_map(timeout=DEFAULT_TIMEOUT, extent=QgsRectangle(25, 65, 26, 66)) 56 | def test_show_map_custom_extent(layer_polygon): 57 | QgsProject.instance().addMapLayers([layer_polygon]) 58 | 59 | 60 | @pytest.mark.qgis_show_map(timeout=DEFAULT_TIMEOUT, add_basemap=True) 61 | def test_show_map_with_basemap(layer_polygon): 62 | QgsProject.instance().addMapLayers([layer_polygon]) 63 | 64 | 65 | @pytest.mark.qgis_show_map(timeout=DEFAULT_TIMEOUT) 66 | @pytest.mark.skipif( 67 | QGIS_VERSION < QGIS_3_12, 68 | reason="QGIS 3.10 test image cannot find correct algorithms", 69 | ) 70 | def test_show_map_crs_change_to_3067( 71 | layer_polygon, layer_polygon_3067, raster_3067, qgis_version 72 | ): 73 | layer_polygon_3067.setOpacity(0.3) 74 | if qgis_version > QGIS_3_18: 75 | raster_3067.setOpacity(0.9) 76 | QgsProject.instance().addMapLayers([layer_polygon, layer_polygon_3067, raster_3067]) 77 | 78 | 79 | @pytest.mark.qgis_show_map(timeout=DEFAULT_TIMEOUT) 80 | @pytest.mark.skipif( 81 | QGIS_VERSION < QGIS_3_12, 82 | reason="QGIS 3.10 test image cannot find correct algorithms", 83 | ) 84 | def test_show_map_crs_change_to_3067_with_different_layer_order( 85 | layer_polygon, layer_polygon_3067, raster_3067, qgis_version 86 | ): 87 | layer_polygon_3067.setOpacity(0.3) 88 | if qgis_version > QGIS_3_18: 89 | raster_3067.setOpacity(0.9) 90 | QgsProject.instance().addMapLayers([raster_3067, layer_polygon_3067, layer_polygon]) 91 | 92 | 93 | @pytest.mark.qgis_show_map(timeout=DEFAULT_TIMEOUT, add_basemap=True) 94 | @pytest.mark.skipif( 95 | QGIS_VERSION < QGIS_3_12, 96 | reason="QGIS 3.10 test image cannot find correct algorithms", 97 | ) 98 | def test_show_map_crs_change_to_3067_with_basemap( 99 | layer_polygon, layer_polygon_3067, raster_3067, qgis_version 100 | ): 101 | layer_polygon_3067.setOpacity(0.3) 102 | if qgis_version > QGIS_3_18: 103 | raster_3067.setOpacity(0.9) 104 | QgsProject.instance().addMapLayers([layer_polygon, layer_polygon_3067, raster_3067]) 105 | 106 | 107 | @pytest.mark.qgis_show_map(timeout=DEFAULT_TIMEOUT) 108 | @pytest.mark.skipif( 109 | QGIS_VERSION < QGIS_3_12, 110 | reason="QGIS 3.10 test image cannot find correct algorithms", 111 | ) 112 | def test_show_map_crs_change_to_4326( 113 | layer_polygon, raster_3067, layer_points, qgis_version 114 | ): 115 | if qgis_version > QGIS_3_18: 116 | raster_3067.setOpacity(0.9) 117 | QgsProject.instance().addMapLayers([layer_points, layer_polygon, raster_3067]) 118 | 119 | 120 | @pytest.mark.qgis_show_map(timeout=DEFAULT_TIMEOUT) 121 | @pytest.mark.skipif( 122 | QGIS_VERSION < QGIS_3_12, 123 | reason="QGIS 3.10 test image cannot find correct algorithms", 124 | ) 125 | def test_show_map_crs_change_to_4326_2(layer_polygon, layer_points, layer_polygon_3067): 126 | QgsProject.instance().addMapLayers( 127 | [layer_points, layer_polygon_3067, layer_polygon] 128 | ) 129 | 130 | 131 | @pytest.mark.qgis_show_map(timeout=DEFAULT_TIMEOUT, zoom_to_common_extent=False) 132 | def test_map_extent_should_not_change_to_layers_extent_when_processing_events( 133 | layer_polygon_3067, qgis_canvas, qgis_app 134 | ): 135 | extent_smaller_than_layer = QgsRectangle(475804, 7145949.5, 549226, 7219371.5) 136 | 137 | QgsProject.instance().addMapLayer(layer_polygon_3067) 138 | qgis_canvas.setExtent(extent_smaller_than_layer) 139 | 140 | # This triggers the map to set the extent based on the layer 141 | # if events are not processed after adding the layer 142 | qgis_app.processEvents() 143 | 144 | assert qgis_canvas.extent().height() == extent_smaller_than_layer.height() 145 | --------------------------------------------------------------------------------