├── .bash_aliases ├── .dockerignore ├── .github ├── dependabot.yml └── workflows │ ├── close-stalled-issues.yml │ ├── publish-doc.yml │ └── release.yml ├── .gitignore ├── .gitlab-ci.yml ├── .readthedocs.yml ├── CHANGELOG.md ├── DEPRECATION_NOTICE.md ├── MANIFEST.in ├── Makefile ├── README.md ├── config.mk ├── doc ├── Makefile ├── cache.rst ├── conf.py ├── configuration.rst ├── configuration.rst.j2 ├── index.rst ├── intro.rst ├── jtpl.py ├── management.rst ├── qgisapi.rst ├── requirements.txt └── schemes.rst ├── docker ├── .dockerignore ├── .gitlab-ci.yml ├── Dockerfile ├── Makefile ├── README.md ├── docker-compose.yml ├── docker-entrypoint.sh └── scripts │ └── qgis-version.sh ├── examples └── test-lizmap-server-plugin │ └── docker-compose.yml ├── license.txt ├── mypy.ini ├── pyproject.toml ├── pyqgisserver ├── __init__.py ├── config.py ├── config.yml ├── handlers │ ├── __init__.py │ ├── asynchandler.py │ ├── basehandler.py │ ├── landingpage.py │ ├── oapihandler.py │ ├── owshandler.py │ └── statushandler.py ├── logger.py ├── management │ ├── __init__.py │ ├── apis │ │ ├── __init__.py │ │ ├── cache.py │ │ ├── handler.py │ │ └── plugins.py │ └── server.py ├── middleware.py ├── monitor.py ├── monitors │ ├── amqp.py │ ├── base.py │ └── test.py ├── plugins.py ├── qgscache │ ├── __init__.py │ ├── cachemanager.py │ ├── handlers │ │ ├── __init__.py │ │ ├── file_handler.py │ │ ├── postgres_handler.py │ │ └── proto.py │ ├── observer.py │ ├── observers │ │ ├── ban.py │ │ └── test.py │ └── types.py ├── qgspool.py ├── qgsworker.py ├── runtime.py ├── server.py ├── stats.py ├── tests.py ├── utils │ ├── __init__.py │ ├── decorators.py │ ├── lru.py │ ├── qgis.py │ └── stats.py ├── version.py └── zeromq │ ├── __init__.py │ ├── broker.py │ ├── client.py │ ├── messages.py │ ├── pool.py │ ├── supervisor.py │ ├── utils.py │ └── worker.py ├── pyqgisservercontrib ├── core │ ├── __init__.py │ ├── componentmanager.py │ ├── filters.py │ └── watchfiles.py └── middlewares │ ├── __init__.py │ └── request_logger.py ├── requests.log ├── ruff.toml └── tests ├── .gitignore ├── .pg_service.conf ├── Makefile ├── certs ├── README.md ├── localhost.crt └── localhost.key ├── check_tag.sh ├── client ├── conftest.py ├── pytest.ini ├── test_base.py ├── test_getcapabilities.py ├── test_getmap.py ├── test_profile.py └── test_regression.py ├── data ├── bad_layer.qgs ├── france_parts.qgs ├── france_parts │ ├── france_parts.dbf │ ├── france_parts.prj │ ├── france_parts.qpj │ ├── france_parts.shp │ └── france_parts.shx ├── france_parts_qgz.qgz ├── france_parts_wmsurl.qgs ├── lines.geojson ├── points.geojson ├── points3.geojson.gz ├── points3.geojson.gz.properties ├── preloads.list ├── project_simple.qgs ├── project_simple_with_excluded.qgs ├── project_simple_with_invalid.qgs ├── project_simple_with_invalid_excluded.qgs └── raster_layer.qgs ├── docker-compose.amqp.yml ├── docker-compose.postgres.yml ├── docker-compose.proxy.yml ├── docker-compose.varnish.yml ├── docker-compose.yml ├── plugins ├── badplugin │ ├── .update-manifest │ ├── __init__.py │ └── metadata.txt ├── foo │ ├── .update-manifest │ ├── __init__.py │ └── metadata.txt └── headers │ ├── .update-manifest │ ├── __init__.py │ └── metadata.txt ├── preload.list ├── qgis └── QGIS │ └── QGIS3.ini ├── requirements.txt ├── run_proxy.sh ├── run_server.sh ├── run_tests.sh ├── run_worker.sh ├── server.conf ├── setup_env.sh ├── tests.env ├── unittests ├── conftest.py ├── pytest.ini ├── test.conf ├── test_a_lru.py ├── test_api.py ├── test_badlayers.py ├── test_base.py ├── test_cache.py ├── test_config.py ├── test_getcapabilities.py ├── test_getfeatures.py ├── test_getlegendgraphic.py ├── test_getmap.py ├── test_monitor.py ├── test_regression.py ├── test_wfs.py └── test_wfs3.py ├── varnish.secret └── varnish.vcl /.bash_aliases: -------------------------------------------------------------------------------- 1 | # Activate environment 2 | workon dev 3 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | * 2 | 3 | !/pyqgisserver 4 | !/pyqgisservercontrib 5 | !/pyproject.toml 6 | !/setup.py 7 | !/docker/docker-entrypoint.sh 8 | !/VERSION 9 | 10 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "github-actions" 4 | directory: "/" 5 | schedule: 6 | interval: "cron" 7 | cronjob: "0 4 2 */4 *" 8 | assignees: 9 | - "Gustry" 10 | 11 | - package-ecosystem: "pip" 12 | directory: "/requirements/" 13 | schedule: 14 | interval: "cron" 15 | cronjob: "0 4 2 */4 *" 16 | assignees: 17 | - "Gustry" 18 | -------------------------------------------------------------------------------- /.github/workflows/close-stalled-issues.yml: -------------------------------------------------------------------------------- 1 | name: Close inactive issues 2 | on: 3 | schedule: 4 | - cron: "30 1 * * *" 5 | 6 | jobs: 7 | close-issues: 8 | runs-on: ubuntu-latest 9 | permissions: 10 | issues: write 11 | pull-requests: write 12 | steps: 13 | - uses: actions/stale@v9 14 | with: 15 | days-before-issue-stale: 30 16 | days-before-issue-close: 14 17 | stale-issue-label: "stale" 18 | stale-issue-message: "This issue is stale because it has been open for 30 days with no activity." 19 | close-issue-message: "This issue was closed because it has been inactive for 14 days since being marked as stale." 20 | days-before-pr-stale: -1 21 | days-before-pr-close: -1 22 | repo-token: ${{ secrets.GITHUB_TOKEN }} 23 | -------------------------------------------------------------------------------- /.github/workflows/publish-doc.yml: -------------------------------------------------------------------------------- 1 | name: 📖 Documentation 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | paths: 8 | - 'doc/**' 9 | - '.github/workflows/publish-doc.yml' 10 | 11 | jobs: 12 | deploy: 13 | runs-on: ubuntu-latest 14 | steps: 15 | 16 | - name: Get source code 17 | uses: actions/checkout@v4 18 | with: 19 | fetch-depth: 0 20 | 21 | - name: Set up Python 3.13 22 | uses: actions/setup-python@v5.6.0 23 | with: 24 | python-version: '3.13' 25 | 26 | - name: Set up NodeJS (for search index prebuilding) 27 | uses: actions/setup-node@v4.4.0 28 | with: 29 | node-version: '18' 30 | 31 | - name: Cache project dependencies (pip) 32 | uses: actions/cache@v4.2.2 33 | with: 34 | path: ~/.cache/pip 35 | key: ${{ runner.os }}-pip-${{ hashFiles('doc/requirements.txt') }} 36 | restore-keys: | 37 | ${{ runner.os }}-pip- 38 | ${{ runner.os }}- 39 | 40 | - name: Install dependencies 41 | run: | 42 | python -m pip install --upgrade pip setuptools wheel 43 | python -m pip install -r doc/requirements.txt 44 | 45 | - name: Build HTML 46 | run: | 47 | make -C doc html 48 | 49 | - name: Bypass underscore 50 | run: touch doc/build/html/.nojekyll 51 | 52 | - name: Deploy to GitHub Pages 53 | uses: JamesIves/github-pages-deploy-action@v4.7.3 54 | with: 55 | branch: gh-pages 56 | folder: doc/build/html 57 | token: ${{ secrets.BOT_HUB_TOKEN }} 58 | git-config-name: ${{ secrets.BOT_NAME }} 59 | git-config-email: ${{ secrets.BOT_MAIL }} 60 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: 🚀 Release 2 | 3 | on: 4 | push: 5 | tags: 6 | - "*" 7 | 8 | jobs: 9 | release: 10 | name: "🚀 Release" 11 | runs-on: ubuntu-latest 12 | if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/') 13 | 14 | steps: 15 | 16 | - name: Set env 17 | # CI_COMMIT_TAG is used in Makefile to not have the "RC" flag 18 | run: echo "CI_COMMIT_TAG=${GITHUB_REF#refs/*/}" >> $GITHUB_ENV 19 | 20 | - name: Get source code 21 | uses: actions/checkout@v4 22 | 23 | - name: Changelog 24 | id: changelog 25 | uses: 3liz/changelog-release@0.4.0 26 | 27 | - name: Set up Python 3.13 28 | uses: actions/setup-python@v5.6.0 29 | with: 30 | python-version: '3.13' 31 | 32 | # - name: Check tag 33 | # run: tests/check_tag.sh ${{ env.CI_COMMIT_TAG }} 34 | 35 | - name: Setup 36 | run: | 37 | make manifest 38 | 39 | - name: Install Python requirements 40 | run: pip3 install --upgrade setuptools qgis-plugin-ci build 41 | 42 | - name : Get current changelog 43 | run: qgis-plugin-ci changelog ${{ env.CI_COMMIT_TAG }} >> release.md 44 | 45 | - name: Build package 46 | run: | 47 | make dist 48 | 49 | - name: Create release on GitHub 50 | uses: ncipollo/release-action@v1.16.0 51 | with: 52 | body: ${{ steps.changelog.outputs.markdown }} 53 | token: ${{ secrets.BOT_HUB_TOKEN }} 54 | allowUpdates: true 55 | artifacts: "dist/*.tar.gz" 56 | 57 | - name: Deploy to PyPI 58 | uses: pypa/gh-action-pypi-publish@release/v1 59 | with: 60 | user: __token__ 61 | password: ${{ secrets.PYPI_API_TOKEN }} 62 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | *.pyo 3 | *.egg-info 4 | dist 5 | build 6 | tmp 7 | .tox 8 | docs/_build 9 | 10 | build.manifest 11 | factory.manifest 12 | factory-*.manifest 13 | 14 | venv/ 15 | .venv/ 16 | __pycache__ 17 | 18 | # Test outputs 19 | .cache 20 | .pipcache 21 | .pytest_cache 22 | __output__ 23 | 24 | # vim, mac os 25 | *.sw* 26 | .DS_Store 27 | .*.un~ 28 | 29 | *.qgs~ 30 | 31 | # git 32 | 33 | *.orig 34 | .coverage 35 | 36 | # Test log 37 | docker-test.log 38 | 39 | .local 40 | .qgis-restart 41 | .ipython 42 | 43 | .idea/ 44 | 45 | /tests/data/liz 46 | /tests/data/montpellier 47 | /tests/unittests/qgis.db 48 | /tests/data/private 49 | /tests/qgis 50 | /tests/unittests/symbology-style.db 51 | 52 | *.bak 53 | *.bak~ 54 | 55 | qgis.db 56 | 57 | VERSION 58 | .env 59 | .activate 60 | 61 | -------------------------------------------------------------------------------- /.gitlab-ci.yml: -------------------------------------------------------------------------------- 1 | variables: 2 | FACTORY_PRODUCT_NAME: pyqgis-server 3 | FACTORY_PACKAGE_TYPE: application 4 | 5 | stages: 6 | - test 7 | - build 8 | - docker 9 | - deploy 10 | - release 11 | - post_release 12 | 13 | #----------------- 14 | # Tests 15 | #----------------- 16 | 17 | .tests: 18 | image: ${REGISTRY_URL}/factory-ci-runner:qgis-${QGIS_FLAVOR} 19 | stage: test 20 | script: 21 | - source ~/.bashrc 22 | - make install install-tests 23 | - pip list -l 24 | - make test 25 | tags: 26 | - factory-plain 27 | variables: 28 | ASYNC_TEST_TIMEOUT: "20" 29 | 30 | tests: 31 | extends: .tests 32 | resource_group: py_qgis_server_tests 33 | parallel: 34 | matrix: 35 | - QGIS_FLAVOR: ['ltr', 'release', '3.34', '3.28'] 36 | 37 | #--------------- 38 | # Build 39 | #--------------- 40 | 41 | build: 42 | image: ${REGISTRY_URL}/factory-ci-runner:factory-ci 43 | stage: build 44 | script: 45 | - source ~/.bashrc 46 | - make dist deliver 47 | environment: 48 | name: snap 49 | tags: 50 | - infrav3-plain 51 | only: 52 | refs: 53 | - tags 54 | - master 55 | except: 56 | - schedules 57 | - triggers 58 | 59 | 60 | # Docker build 61 | include: '/docker/.gitlab-ci.yml' 62 | 63 | tickets: 64 | stage: post_release 65 | only: 66 | - tags 67 | needs: 68 | - "release:release" 69 | - "release:ltr" 70 | image: 71 | name: $REGISTRY_URI/infra/ci-tools:latest 72 | script: 73 | - create_ticket.py 74 | tags: 75 | - factory-plain 76 | 77 | gitlab_release: 78 | stage: release 79 | rules: 80 | - if: '$CI_COMMIT_TAG =~ /^\d+\.\d+\.\d+$/' 81 | # Running only when the tag is like X.Y.Z 82 | when: always 83 | - when: never 84 | image: 85 | name: $REGISTRY_URI/infra/ci-tools:latest 86 | script: 87 | - gitlab_release 88 | tags: 89 | - factory-plain 90 | 91 | 92 | monthly-release: 93 | stage: test 94 | rules: 95 | - if: $CI_PIPELINE_SOURCE == "schedule" 96 | when: always 97 | allow_failure: false 98 | - when: manual 99 | allow_failure: true 100 | script: 101 | - > 102 | curl -v -i 103 | --header 'Content-Type:application/json' 104 | --header "PRIVATE-TOKEN: ${BOT_TOKEN}" 105 | --data '{ 106 | "id":"'"${CI_PROJECT_ID}"'", 107 | "title":"New monthly release", 108 | "description":"New monthly bugfix release of QGIS", 109 | "labels":"qgis", 110 | "assignee_ids":[5] 111 | }' 112 | https://projects.3liz.org/api/v4/projects/${CI_PROJECT_ID}/issues 113 | tags: 114 | - factory-plain 115 | -------------------------------------------------------------------------------- /.readthedocs.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | 3 | sphinx: 4 | configuration: doc/conf.py 5 | 6 | python: 7 | install: 8 | - requirements: doc/requirements.txt 9 | 10 | -------------------------------------------------------------------------------- /DEPRECATION_NOTICE.md: -------------------------------------------------------------------------------- 1 | ## DEPRECATION NOTICE 2 | 3 | # Since 1.8.4: 4 | 5 | * The url endpoint `/ows/wfs3/` is deprecated and will be removed in version 1.9: 6 | the new endpoint for WFS3 is `/wfs3/`. 7 | 8 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include MANIFEST.in README.md VERSION 2 | include pyqgisserver/build.manifest 3 | 4 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | # qgis server makefile 2 | # 3 | DEPTH=. 4 | 5 | include $(DEPTH)/config.mk 6 | 7 | DIST:=dist 8 | 9 | MANIFEST=pyqgisserver/build.manifest 10 | 11 | PYTHON_PKG=pyqgisserver pyqgisservercontrib 12 | 13 | TESTDIR=tests/unittests 14 | 15 | PYPISERVER:=storage 16 | 17 | # This is necessary with pytest as long it is not fixed 18 | # see also https://github.com/qgis/QGIS/pull/5337 19 | export QGIS_DISABLE_MESSAGE_HOOKS := 1 20 | export QGIS_NO_OVERRIDE_IMPORT := 1 21 | 22 | dirs: 23 | mkdir -p $(DIST) 24 | 25 | version: 26 | echo $(VERSION_TAG) > VERSION 27 | 28 | configure: manifest 29 | 30 | manifest: version 31 | echo name=$(PROJECT_NAME) > $(MANIFEST) && \ 32 | echo version=$(VERSION_TAG) >> $(MANIFEST) && \ 33 | echo buildid=$(BUILDID) >> $(MANIFEST) && \ 34 | echo commitid=$(COMMITID) >> $(MANIFEST) 35 | @echo "=== Written manifest ===" 36 | @cat $(MANIFEST) 37 | 38 | deliver: 39 | twine upload $(TWINE_OPTIONS) -r $(PYPISERVER) $(DIST)/* 40 | 41 | dist: dirs configure 42 | rm -rf *.egg-info 43 | $(PYTHON) -m build --no-isolation --sdist --outdir=$(DIST) 44 | 45 | clean: 46 | rm -rf $(DIST) 47 | 48 | test: manifest lint 49 | make -C tests test PYTEST_ADDOPTS=$(PYTEST_ADDOPTS) 50 | 51 | install: manifest 52 | pip install -U --upgrade-strategy=eager -e . 53 | 54 | install-tests: 55 | pip install -U --upgrade-strategy=eager -r tests/requirements.txt 56 | 57 | install-doc: 58 | pip install -U --upgrade-strategy=eager -r doc/requirements.txt 59 | 60 | install-dev: install-tests install-doc 61 | 62 | lint: 63 | @ruff check --output-format=concise $(PYTHON_PKG) $(TESTDIR) 64 | 65 | lint-preview: 66 | @ruff check --preview $(PYTHON_PKG) $(TESTDIR) 67 | 68 | lint-fix: 69 | @ruff check --preview --fix $(PYTHON_PKG) $(TESTDIR) 70 | 71 | typing: 72 | mypy --config-file=$(topsrcdir)/mypy.ini -p pyqgisserver -p pyqgisservercontrib 73 | 74 | -------------------------------------------------------------------------------- /config.mk: -------------------------------------------------------------------------------- 1 | 2 | # Name to be set in the manifest 3 | PROJECT_NAME:=py-qgis-server 4 | 5 | # Project version 6 | VERSION:=1.9.6 7 | 8 | ifndef CI_COMMIT_TAG 9 | VERSION_TAG=$(VERSION)rc0 10 | else 11 | VERSION_TAG=$(VERSION) 12 | endif 13 | 14 | BUILDID=$(shell date +"%Y%m%d%H%M") 15 | COMMITID=$(shell git rev-parse --short HEAD) 16 | 17 | PYTHON=python3 18 | 19 | topsrcdir:=$(shell realpath $(DEPTH)) 20 | 21 | -------------------------------------------------------------------------------- /doc/Makefile: -------------------------------------------------------------------------------- 1 | 2 | DEPTH=.. 3 | 4 | include $(DEPTH)/config.mk 5 | 6 | PROJECT_NAME=py-qgis-server 7 | 8 | BUILDDIR=${shell pwd}/build 9 | 10 | main: html 11 | 12 | html: build 13 | mkdir -p ${BUILDDIR}/html 14 | sphinx-build -b html ./ ${BUILDDIR}/html 15 | 16 | pdf: build 17 | mkdir -p ${BUILDDIR}/latex 18 | sphinx-build -b latex ./ ${BUILDDIR}/latex 19 | cd ${BUILDDIR}/latex && pdflatex ${PROJECT_NAME}.tex 20 | 21 | md: build 22 | mkdir -p ${BUILDDIR}/md 23 | sphinx-build -b markdown ./ ${BUILDDIR}/md 24 | 25 | build: templates 26 | templates: configuration.rst 27 | 28 | configuration.rst: ../pyqgisserver/config.yml configuration.rst.j2 29 | ./jtpl.py -c $^ 30 | 31 | clean: 32 | rm -r ${BUILDDIR} 33 | 34 | -------------------------------------------------------------------------------- /doc/cache.rst: -------------------------------------------------------------------------------- 1 | .. _cache: 2 | 3 | Cache 4 | ===== 5 | 6 | Py-qgis-server has two cache for Qgis projects: a LRU cache and a Static cache. 7 | 8 | .. _lru_cache: 9 | 10 | LRU cache 11 | --------- 12 | 13 | The lru cache store projects and use a lru (last-recent-use) eviction scheme. This cache has a fixed size controlled by the :ref:`CACHE_SIZE` configuration setting. 14 | 15 | The lru cache will prevent to bloat the memory with too many projects (remember that projects are loaded in memory for each worker). 16 | 17 | If you have many project that are accessed frequently then you may experience many eviction/reloading. This may be not desirable with big projects that may take long loading time, in this situation you may consider using the static cache. 18 | 19 | .. _static_cache: 20 | 21 | Static cache 22 | ------------ 23 | 24 | The static cache is not subject to LRU eviction and has no limitation in the number of project you may store. 25 | 26 | Projects stored in the static cache are preloaded at startup from a configuration file. 27 | The file must have one project uri per line. Each uri is similar to the project uri passed in the 'MAP' query parameter of OWS requests. 28 | 29 | The path of the cache configuration file is given in the :ref:`CACHE_PRELOAD_CONFIG` configuration setting. 30 | 31 | .. _async_cache: 32 | 33 | Asynchronous check 34 | ------------------ 35 | 36 | Cache may be checked for invalidation/refresh synchronously or asynchronously depending on the value of the :ref:`CACHE_CHECK_INTERVAL` configuration setting. 37 | 38 | If the refresh interval value is set to a strict positive value (>0) then the cache is checked for invalidation/refresh asynchronously every seconds set by the option's value. 39 | 40 | If the refresh interval is set to a negative or null value (<=0) then the cache is invalidated/refreshed synchronously at each requests. 41 | 42 | Depending of the backend storage and the loading time of your projects you may choose one or another invalidation strategy. 43 | 44 | With slow loading projects it is recommended to use asynchronous check in conjunction with static_cache. 45 | -------------------------------------------------------------------------------- /doc/conf.py: -------------------------------------------------------------------------------- 1 | # Configuration file for the Sphinx documentation builder. 2 | # 3 | # This file only contains a selection of the most common options. For a full 4 | # list see the documentation: 5 | # https://www.sphinx-doc.org/en/master/usage/configuration.html 6 | 7 | # -- Path setup -------------------------------------------------------------- 8 | 9 | # If extensions (or modules to document with autodoc) are in another directory, 10 | # add these directories to sys.path here. If the directory is relative to the 11 | # documentation root, use os.path.abspath to make it absolute, like shown here. 12 | # 13 | # import os 14 | # import sys 15 | # sys.path.insert(0, os.path.abspath('.')) 16 | 17 | import sphinx_rtd_theme 18 | 19 | # -- Project information ----------------------------------------------------- 20 | 21 | project = 'py-qgis-server' 22 | copyright = '2021, David Marteau - 3liz' 23 | author = 'David Marteau' 24 | 25 | # The full version, including alpha/beta/rc tags 26 | release = '1.8' 27 | 28 | 29 | # -- General configuration --------------------------------------------------- 30 | 31 | # Add any Sphinx extension module names here, as strings. They can be 32 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom 33 | # ones. 34 | extensions = [ 35 | "sphinx_rtd_theme", 36 | "sphinxcontrib.httpdomain", 37 | ] 38 | 39 | # Add any paths that contain templates here, relative to this directory. 40 | templates_path = ['_templates'] 41 | 42 | # List of patterns, relative to source directory, that match files and 43 | # directories to ignore when looking for source files. 44 | # This pattern also affects html_static_path and html_extra_path. 45 | exclude_patterns = [] 46 | 47 | 48 | # -- Options for HTML output ------------------------------------------------- 49 | 50 | # The theme to use for HTML and HTML Help pages. See the documentation for 51 | # a list of builtin themes. 52 | # 53 | #html_theme = 'alabaster' 54 | html_theme ='sphinx_rtd_theme' 55 | 56 | # Add any paths that contain custom static files (such as style sheets) here, 57 | # relative to this directory. They are copied after the builtin static files, 58 | # so a file named "default.css" will overwrite the builtin "default.css". 59 | html_static_path = ['_static'] 60 | 61 | # Fix for read the doc 62 | # See https://github.com/readthedocs/readthedocs.org/issues/2569 63 | master_doc = 'index' 64 | -------------------------------------------------------------------------------- /doc/configuration.rst.j2: -------------------------------------------------------------------------------- 1 | .. _configuration_settings: 2 | 3 | Configuration Settings 4 | ====================== 5 | 6 | Configuration can be done either by using a configuration file or with environnement variable. 7 | 8 | Except stated otherwise, the rule for environnement variable names is ``QGSRV_
_`` all in uppercase. 9 | 10 | 11 | Common Configuration Options 12 | ============================= 13 | 14 | {% if config_options %} 15 | 16 | {% for config in config_options %} 17 | {% set config_len = config.name|length -%} 18 | {% set config_option = config.name %} 19 | .. _{{config_option}}: 20 | 21 | {{config_option}} 22 | {{ '-' * config_len }} 23 | {% if config['description'] and config['description'] != [''] %} 24 | {{config['description']}} 25 | {{space}} 26 | {% endif %} 27 | 28 | {%- if config['type'] -%} 29 | :Type: {{config['type']}} 30 | {% else %} 31 | :Type: string 32 | {% endif %} 33 | {%- if config['default'] -%} 34 | :Default: {{config['default']}} 35 | {% endif %} 36 | {%- if config['version_added'] -%} 37 | :Version Added: {{config['version_added']}} 38 | {{space}} 39 | {%- endif -%} 40 | :Section: {{config['section']}} 41 | :Key: {{config['key']}} 42 | {%- if config['deprecated'] -%} 43 | :Deprecated in: {{config['deprecated']['version']}} 44 | :Deprecated detail: {{config['deprecated']['why']}} 45 | :Deprecated alternatives: {{config['deprecated']['alternatives']}} 46 | {% endif %} 47 | :Env: QGSRV_{{config_option}} 48 | {% if config['alt_env'] -%} 49 | :Alternate name: {{ config['alt_env'] }} 50 | {% endif %} 51 | 52 | {% endfor %} 53 | {% endif %} 54 | -------------------------------------------------------------------------------- /doc/index.rst: -------------------------------------------------------------------------------- 1 | .. py-qgis-server documentation master file, created by 2 | sphinx-quickstart on Tue Jul 7 13:32:12 2020. 3 | You can adapt this file completely to your liking, but it should at least 4 | contain the root `toctree` directive. 5 | 6 | Welcome to py-qgis-server's documentation! 7 | ========================================== 8 | 9 | .. toctree:: 10 | :maxdepth: 2 11 | :caption: Contents: 12 | 13 | intro 14 | configuration 15 | schemes 16 | cache 17 | qgisapi 18 | management 19 | 20 | Indices and tables 21 | ================== 22 | 23 | * :ref:`genindex` 24 | * :ref:`modindex` 25 | * :ref:`search` 26 | -------------------------------------------------------------------------------- /doc/intro.rst: -------------------------------------------------------------------------------- 1 | .. highlight:: python 2 | .. highlight:: sh 3 | 4 | .. _server_description: 5 | 6 | Description 7 | =========== 8 | 9 | Py-qgis-server is a asynchronous HTTP Qgis server written in python on top of the `tornado `_ framework and the 0MQ messaging framework for distributing requests workers. 10 | 11 | It is based on the new Qgis 3 server API for efficiently passing requests/responses using 0MQ messaging framework to workers. 12 | 13 | The server may be run as a self-contained single service or as a proxy server with an arbitrary number of workers running 14 | remotely or locally. Independent workers connect automatically to the front-end proxy with no need of special configuration 15 | on the proxy side. Thus, this is ideal for auto-scaling configuration for use with container orchestrator as Rancher, Swarm or Kubernetes. 16 | 17 | The server is aimed at solving some real situations encountered in production environment: zero conf scalability, handle long-running request situation, auto restart... 18 | 19 | 20 | .. _server_features: 21 | 22 | Features 23 | -------- 24 | 25 | - Multiples workers 26 | - Fair queuing request dispatching 27 | - Timeout for long running/stalled requests 28 | - Full support of qgis server plugins 29 | - Auto-restart trigger for workers 30 | - Support streamed/chunked responses 31 | - SSL support 32 | 33 | 34 | .. _server_requirements: 35 | 36 | Requirements 37 | ------------ 38 | 39 | - OS: Unix/Posix variants (Linux or OSX) (Windows not officially supported) 40 | - Python >= 3.8 41 | - QGIS >= 3.22 installed 42 | - libzmq >= 4.0.1 and pyzmq >= 17 43 | 44 | 45 | .. _server_installation: 46 | 47 | Installation 48 | ============ 49 | 50 | 51 | .. _server_source_install: 52 | 53 | Install from source 54 | ------------------- 55 | 56 | * Install from pypi.org:: 57 | 58 | pip install py-qgis-server 59 | 60 | * Install from sources:: 61 | 62 | pip install -e . 63 | 64 | * Install from build version X.Y.Z:: 65 | 66 | make dist 67 | pip install py-qgis-server-X.Y.Z.tar.gz 68 | 69 | 70 | .. _server_running: 71 | 72 | Running the server 73 | ================== 74 | 75 | The server does not run as a daemon by itself, there is several way to run a command as a daemon. 76 | 77 | For example: 78 | 79 | * Use `Supervisor `_. Will gives you full control over logs and server status notifications. See :ref:`Running with Supervisor `. 80 | * Use the ``daemon`` command. 81 | * Use systemd 82 | * ... 83 | 84 | Synopsis 85 | -------- 86 | 87 | **qgisserver** [*options*] 88 | 89 | 90 | Options 91 | ------- 92 | 93 | .. program: qgisserver 94 | 95 | .. option:: -d, --debug 96 | 97 | Force debug mode. This is the same as setting the :ref:`LOGGING_LEVEL ` option to ``DEBUG`` 98 | 99 | .. option:: -c, --config path 100 | 101 | Use the configuration file located at ``path`` 102 | 103 | .. option:: --proxy 104 | 105 | Run only as proxy. 106 | 107 | 108 | Running proxy and workers separately 109 | ------------------------------------ 110 | 111 | If the ``--proxy`` option is set the server will act as a proxy server to talk to independent qgis workers. 112 | 113 | QGIS workers can be run using the command: 114 | 115 | **qgisserver-worker** [*options*] 116 | 117 | The options are the same as 118 | 119 | 120 | .. _server_docker_running: 121 | 122 | Running with Docker 123 | ------------------- 124 | 125 | Docker image is available on `docker-hub `_. 126 | 127 | All options are passed with environment variables. See the :ref:`Configuration settings ` 128 | for a description of the options. 129 | 130 | 131 | .. _install_plugin: 132 | 133 | Install server plugins with the Docker container 134 | ------------------------------------------------ 135 | 136 | The docker image is shipped with the `qgis-plugin-manager `_. 137 | 138 | To install or manage your server plugins, use the docker `exec` command into your container, the plugins will install in the folder defined by the :ref:`SERVER_PLUGINPATH ` option. 139 | 140 | Example:: 141 | 142 | docker exec myserver -it qgis-plugin-manager install "Lizmap server" 143 | 144 | 145 | .. _server_supervisor_running: 146 | 147 | Running with Supervisor 148 | ----------------------- 149 | 150 | Example of Supervisor configuration file for py-qgis-server :file:`/etc/supervisor/conf.d/py-qgis-server.conf`: 151 | 152 | .. code-block:: ini 153 | 154 | [program:py-qgis-server] 155 | command=/path/to/qgisserver -c /path/to/py-qgis-server-config-file.conf 156 | process_name=%(program_name)s 157 | user=www-data 158 | redirect_stderr=true 159 | stdout_logfile=/var/log/supervisor/%(program_name)s-stdout.log 160 | stdout_logfile_maxbytes=10MB 161 | environment= 162 | QGIS_OPTIONS_PATH=/path/to/qgis-server-profile-folder, 163 | QGIS_SERVER_PARALLEL_RENDERING=1, 164 | QGIS_SERVER_MAX_THREADS=8, 165 | QGIS_SERVER_LIZMAP_REVEAL_SETTINGS=True 166 | 167 | Feel free to adapt environment variables depending on your setup and needs. 168 | 169 | Once supervisor configuration file for py-qgis-server is created, py-qgis-server can be started using following commands:: 170 | 171 | sudo supervisorctl reread && sudo supervisorctl start py-qgis-server 172 | 173 | -------------------------------------------------------------------------------- /doc/jtpl.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | """ 4 | Command line utility for applying jinja 2 template from 5 | yaml, json or toml 6 | """ 7 | import sys 8 | import os 9 | import argparse 10 | 11 | from pathlib import Path 12 | 13 | try: 14 | import jinja2 15 | except ImportError: 16 | print('jinja2 is required',file=sys.stderr) 17 | sys.exit(1) 18 | 19 | 20 | def _load_json( path: Path ): 21 | import json 22 | with path.open() as f: 23 | return json.loads(f.read()) 24 | 25 | 26 | def _load_ini( path: Path ): 27 | import configparser 28 | parser = configparser.ConfigParser(interpolation=configparser.ExtendedInterpolation()) 29 | parser.read_file( path.as_posix() ) 30 | return { s: dict(p.items()) for s,p in parser.items() } 31 | 32 | 33 | def _load_yaml( path: Path ): 34 | try: 35 | import yaml 36 | except ImportError: 37 | print('PyYaml no installed',file=sys.stderr) 38 | sys.exit(1) 39 | with path.open() as f: 40 | return yaml.safe_load(f) 41 | 42 | 43 | def _load_properties( path: Path ): 44 | from configparser import SafeConfigParser 45 | from io import StringIO 46 | with path.open() as f: 47 | parser = configparser.ConfigParser() 48 | parser.readfp(StringIO("[ROOT]\n"+fp.read())) 49 | return { k:v for k,v in parser.items("ROOT") } 50 | 51 | 52 | def _load_toml( path: Path ): 53 | try: 54 | import toml 55 | except ImportError: 56 | print('Toml no installed',file=sys.stderr) 57 | sys.exit(1) 58 | with path.open() as f: 59 | return toml.loads(f.read()) 60 | 61 | 62 | 63 | # Global list of available format parsers on your system 64 | _FORMATS = { 65 | "json": _load_json, 66 | "ini": _load_ini, 67 | "yaml": _load_yaml, 68 | "yml": _load_yaml, 69 | "toml": _load_toml, 70 | "properties": _load_properties, 71 | } 72 | 73 | 74 | def _render(template: Path, config): 75 | with template.open() as f: 76 | tpl = jinja2.Template(f.read()) 77 | return tpl.render(config) 78 | 79 | 80 | def _get_loader_for( path: Path ): 81 | """ Guess format from filename 82 | """ 83 | return _FORMATS.get(path.suffix.lstrip('.')) 84 | 85 | 86 | def _get_output( path: Path ): 87 | """ Guess output file 88 | """ 89 | if path.suffix == '.j2': 90 | return path.with_suffix('') 91 | else: 92 | return path.with_suffix(path.suffix + '.out') 93 | 94 | 95 | def main(): 96 | 97 | import argparse 98 | 99 | p = argparse.ArgumentParser(description='Apply template to file') 100 | p.add_argument('inputs', nargs='+',help='Input files') 101 | p.add_argument('-c','--config', help="Config file", required=True) 102 | p.add_argument('-f','--format' , help="Format", choices=list(_FORMATS.keys())) 103 | 104 | args = p.parse_args() 105 | input_files = args.inputs 106 | 107 | config = Path(args.config) 108 | loader = _FORMATS[args.format] if args.format else _get_loader_for(config) 109 | if not loader: 110 | print('Cannot guess format for %s' % confif, file=sys.stderr) 111 | sys.exit(1) 112 | 113 | # Load configuration 114 | config = loader(config) 115 | 116 | for inp in args.inputs: 117 | templ = Path(inp) 118 | outp = _get_output( templ ) 119 | with outp.open('w') as f: 120 | f.write(_render(templ,config)) 121 | 122 | main() 123 | -------------------------------------------------------------------------------- /doc/management.rst: -------------------------------------------------------------------------------- 1 | .. _management_api: 2 | 3 | Management API (experimental) 4 | ============================= 5 | 6 | The management API is a REST api for inspecting the server state and controlling some of the server 7 | behavior. 8 | 9 | Starting from version 1.7.0, this is an **experimental** feature and it will be subject to change. 10 | The api will be considered as *stable* from the 1.8.0 version. 11 | 12 | .. _plugins_api: 13 | 14 | Plugins API 15 | ----------- 16 | 17 | The *plugins* api enable inspecting and managing Qgis server plugins. 18 | 19 | * :http:get:`/plugins/(name)` 20 | 21 | 22 | .. http:get:: /plugins/(name) 23 | 24 | Return plugin status from its name `name` 25 | 26 | If `name` is not given, return a summary of plugins that the worker has attempted to load 27 | 28 | :statuscode 200: no error 29 | :statuscode 404: the plugin does not exists 30 | 31 | **example**: 32 | 33 | .. sourcecode:: http 34 | 35 | GET /plugins/myplugin HTTP/1.1 36 | Host: example.com 37 | Accept: application/json 38 | 39 | **response**: 40 | 41 | .. sourcecode:: http 42 | 43 | HTTP/1.1 200 OK 44 | Vary: * 45 | Content-Type: application/json 46 | 47 | { 48 | "metadata": { 49 | "general": { 50 | "author": "3liz", 51 | "description": "Test plugin", 52 | "email": "", 53 | "name": "myplugin", 54 | "qgisminimumversion": "3.0", 55 | "server": "True", 56 | "version": "1.0.0", 57 | }, 58 | "path" : "/plugins/myplugin/__init__.py" 59 | }, 60 | "name": "myplugin", 61 | "status": "loaded" 62 | } 63 | 64 | 65 | .. _cache_api: 66 | 67 | Cache API 68 | --------- 69 | 70 | The *cache* api allow qgis project introspection 71 | 72 | * :http:get:`/cache/(project_uri)` 73 | 74 | 75 | .. http:get:: /cache/(project_uri) 76 | 77 | Return the project informations 78 | 79 | :statuscode 200: no error 80 | :statuscode 404: the project does not exists 81 | 82 | **example**: 83 | 84 | .. sourcecode:: http 85 | 86 | GET /cache/myproject HTTP/1.1 87 | Host: example.com 88 | Accept: application/json 89 | 90 | **response**: 91 | 92 | .. sourcecode:: http 93 | 94 | HTTP/1.1 200 OK 95 | Vary: * 96 | Content-Type: application/json 97 | 98 | { 99 | "bad_layers_count": 0, 100 | "cache_key": "myproject", 101 | "crs": "EPSG:4326 - WGS 84", 102 | "last_modified": "2021-06-24T08:04:26", 103 | "layers" : [ 104 | { 105 | "crs": "EPSG:4326 - WGS 84", 106 | "id": "myproject_8d8d649f_7748_43cc_8bde_b013e17ede29", 107 | "name": "my project layer", 108 | "source": "/src/tests/data/myproject/myproject.shp" 109 | } 110 | ] 111 | } 112 | 113 | 114 | .. _pool_api: 115 | 116 | Pool API 117 | -------- 118 | 119 | The *pool* api allow inspecting workers and projects cache associated to them. 120 | 121 | * :http:get:`/pool/` 122 | * :http:post:`/pool/restart` 123 | 124 | 125 | .. http:get:: /pool/ 126 | 127 | Return the list of worker state and the cache for each of them. 128 | 129 | :statuscode 200: no error 130 | 131 | **example**: 132 | 133 | .. sourcecode:: http 134 | 135 | GET /cache/pool/ HTTP/1.1 136 | Host: example.com 137 | Accept: application/json 138 | 139 | **response**: 140 | 141 | .. sourcecode:: http 142 | 143 | HTTP/1.1 200 OK 144 | Vary: * 145 | Content-Type: application/json 146 | 147 | { 148 | "num_workers": 2, 149 | "workers": [ 150 | { 151 | "cache": [ 152 | { 153 | "filename": 154 | "/tests/data/myporject.qgs", 155 | "key": "myproject", 156 | "last_modified": "2021-06-24T08:04:26", 157 | "link": "http://localhost:19876/cache/myproject", 158 | "num_layers": 1 159 | } 160 | ], 161 | "mem_percent": 0.4297396825334563, 162 | "mem_usage": 143597568, 163 | "pid": 34 164 | }, 165 | { 166 | "cache": [], 167 | "mem_percent": 0.41044564806749534, 168 | "mem_usage": 137150464, 169 | "pid": 31 170 | } 171 | ] 172 | } 173 | 174 | .. http:post:: /pool/restart 175 | 176 | Restart workers gracefully. 177 | 178 | :statuscode 200: no error 179 | 180 | 181 | .. _qgis_api: 182 | 183 | Qgis API 184 | --------- 185 | 186 | The `/qgis/` api endpoint is a passthrough for accessing directly the Qgis server api and services. Some services may install some management api that will be accessible from this endpoint. 187 | 188 | -------------------------------------------------------------------------------- /doc/qgisapi.rst: -------------------------------------------------------------------------------- 1 | .. _expose_qgis_api: 2 | 3 | Expose Qgis Api 4 | =============== 5 | 6 | Qgis server enable defining `custom api `_. 7 | 8 | Py-qgis-server allow you to control public access to the qgis services apis while still allowing access 9 | through the management api. 10 | 11 | This may be useful if you plan to have some api doing some backoffice management and do not want these api being accessed 12 | publicly. 13 | 14 | .. _api_endpoints: 15 | 16 | Api endpoint 17 | ----------------- 18 | 19 | Apis may be accessed by the management api by configuring the api endpoint:: 20 | 21 | [api.endpoints] 22 | =/endpoint 23 | 24 | The endpoint should match the root path of the api as defined in the `qgis server api entrypoint `_ 25 | 26 | 27 | .. _enabling_api: 28 | 29 | Enabling api public access 30 | ------------------------------- 31 | 32 | Apis are publicly enabled with the following configuration:: 33 | 34 | [api.enabled] 35 | =yes 36 | -------------------------------------------------------------------------------- /doc/requirements.txt: -------------------------------------------------------------------------------- 1 | sphinx-rtd-theme 2 | sphinxcontrib-httpdomain 3 | PyYAML 4 | jinja2 5 | -------------------------------------------------------------------------------- /doc/schemes.rst: -------------------------------------------------------------------------------- 1 | .. _loader_schemes: 2 | 3 | Loader schemes 4 | ============== 5 | 6 | Py-qgis-server support custom loaders schemes definitions 7 | 8 | Qgis projects path are passed in the ``MAP`` query parameter of the request. By default this parameter 9 | is interpreted as an url. 10 | 11 | If no scheme is specified, the ``MAP`` parameter is interpreted as a file path relative to the :ref:`CACHE_ROOTDIR` setting and is 12 | handled with the ``file`` protocol handler. 13 | 14 | .. _default handlers: 15 | 16 | Default handlers 17 | ================ 18 | 19 | Py-qgis-server supports natively the following default schemes: 20 | 21 | .. _file_protocol: 22 | 23 | File protocol 24 | ------------- 25 | 26 | Handle projects file stored on local media. The ``file:`` protocol is aliased by default for using 27 | the :ref:`CACHE_ROOTDIR` as base path for when looking for projects. 28 | 29 | :Secure mode: No special limitation 30 | 31 | 32 | .. _postgres_protocol: 33 | 34 | Postgres protocol 35 | ----------------- 36 | 37 | The `postgres` protocol handle file stored in postgres database as supported by Qgis. 38 | 39 | :Secure mode: Only ``dbname``, ``schema``, ``authcfg`` and ``service`` query params are allowed. 40 | Only the ``user@`` in the netloc part is allowed. 41 | 42 | 43 | .. _scheme_aliases: 44 | 45 | Scheme aliases 46 | =============== 47 | 48 | Py-qgis-server allow defining custom scheme as scheme aliases. Scheme aliases defines base urls 49 | for existing schemes with overrides rules on path and query params: 50 | 51 | Schemes aliases are defined by adding the scheme definition in the ``projects.schemes`` section:: 52 | 53 | [projects.schemes] 54 | my_relative_scheme=file:relative/path/ 55 | my_absolute_scheme=file:/absolute/path/ 56 | 57 | .. note:: 58 | 59 | The trailing ``/`` is important for the substitution rules. Otherwise 60 | the path will interpreted as a base name which is not what you usually want. 61 | 62 | 63 | In the previous exemple, the ``MAP=my_relative_scheme:myproject`` will be substituted with ``file:relative/path/myproject`` 64 | and searched relatively to the ``CACHE_ROOTDIR`` option. 65 | 66 | On the other hand, the ``MAP=my_absolute_scheme:myproject`` will be substituted with ``file:/absolute/path/myproject`` 67 | and the file will be searched at ``/absolute/path/myproject.qgs`` 68 | 69 | Important notes: 70 | 71 | * For security reason, only file path defined from alias may be absolute path: all paths used 72 | in the request parameters will be interpreted as *relative* path. 73 | 74 | * Query parameters defined in the alias scheme take precedence over the query parameters from the ``MAP`` parameter. 75 | 76 | There is a special exception when using the ``{path}`` expression in the target of alias: only the path of the alias URL 77 | will be used in the target url. 78 | 79 | i.e, with the following definition:: 80 | 81 | [projects.schemes] 82 | postgres=postgres:///?service=myservice&project={path} 83 | 84 | Enable you to protect the `postgres` scheme from any parameter injection, only the path of the original url will be used. 85 | 86 | * Target of alias cannot be alias 87 | -------------------------------------------------------------------------------- /docker/.dockerignore: -------------------------------------------------------------------------------- 1 | .git 2 | .cache 3 | .local 4 | 5 | Makefile 6 | tests 7 | 8 | 9 | -------------------------------------------------------------------------------- /docker/.gitlab-ci.yml: -------------------------------------------------------------------------------- 1 | #------------- 2 | # Build 3 | #------------- 4 | 5 | .build-docker: 6 | image: ${REGISTRY_URL}/factory-ci-runner:factory-ci 7 | stage: docker 8 | script: 9 | - make manifest 10 | - make -C docker build deliver pushrc clean FLAVOR=$QGIS_FLAVOR BUILD_TARGET=amqp 11 | environment: 12 | name: snap 13 | artifacts: 14 | paths: 15 | - "docker/factory-${QGIS_FLAVOR}.manifest" 16 | only: 17 | refs: 18 | - tags 19 | - master 20 | tags: 21 | - factory 22 | 23 | 24 | build-docker:release: 25 | extends: .build-docker 26 | variables: 27 | QGIS_FLAVOR: release 28 | 29 | build-docker:ltr: 30 | extends: .build-docker 31 | variables: 32 | QGIS_FLAVOR: ltr 33 | 34 | # 35 | # Compatibility with previous ltr 36 | # 37 | 38 | build-docker:3.34: 39 | extends: .build-docker 40 | variables: 41 | QGIS_FLAVOR: "3.34" 42 | 43 | build-docker:3.28: 44 | extends: .build-docker 45 | variables: 46 | QGIS_FLAVOR: "3.28" 47 | 48 | 49 | #------------- 50 | # deploy 51 | #------------- 52 | 53 | deploy_snap: 54 | image: ${REGISTRY_URL}/factory-ci-runner:factory-ci 55 | stage: deploy 56 | script: 57 | - update-service map 58 | environment: 59 | name: snap 60 | only: 61 | refs: 62 | - tags 63 | - master 64 | tags: 65 | - factory-plain 66 | 67 | #------------- 68 | # Release 69 | #------------- 70 | 71 | .release: 72 | image: ${REGISTRY_URL}/factory-ci-runner:factory-ci 73 | stage: release 74 | script: 75 | - release-image qgis-map-server-$QGIS_FLAVOR 76 | - push-to-docker-hub --clean 77 | environment: 78 | name: production 79 | when: manual 80 | # See https://about.gitlab.com/blog/2021/05/20/dag-manual-fix/#what-if-i-dont-want-this-new-behavior 81 | allow_failure: false 82 | only: 83 | refs: 84 | - tags 85 | tags: 86 | - factory-dind 87 | variables: 88 | FACTORY_MANIFEST: "docker/factory-${QGIS_FLAVOR}.manifest" 89 | 90 | release:release: 91 | extends: .release 92 | variables: 93 | QGIS_FLAVOR: release 94 | dependencies: 95 | - build-docker:release 96 | 97 | release:ltr: 98 | extends: .release 99 | variables: 100 | QGIS_FLAVOR: ltr 101 | dependencies: 102 | - build-docker:ltr 103 | 104 | # 105 | # Compatibility with previous ltr 106 | # 107 | 108 | release:3.34: 109 | extends: .release 110 | variables: 111 | QGIS_FLAVOR: "3.34" 112 | dependencies: 113 | - build-docker:3.34 114 | 115 | release:3.28: 116 | extends: .release 117 | variables: 118 | QGIS_FLAVOR: "3.28" 119 | dependencies: 120 | - build-docker:3.28 121 | -------------------------------------------------------------------------------- /docker/Dockerfile: -------------------------------------------------------------------------------- 1 | # syntax=docker/dockerfile:1 2 | # vim: ft=dockerfile 3 | ARG REGISTRY_PREFIX='' 4 | ARG QGIS_VERSION=release 5 | 6 | FROM ${REGISTRY_PREFIX}qgis-platform:${QGIS_VERSION} AS base 7 | 8 | LABEL Description="QGIS3 Python Server" Vendor="3liz.org" 9 | LABEL Maintainer="David Marteau " 10 | 11 | ARG PIP_OPTIONS 12 | 13 | # Setup will use this variable for copying manifest 14 | ENV QGSRV_DATA_PATH=/usr/local/share/qgis-server 15 | 16 | 17 | # Install qgis-plugin-manager 18 | RUN --mount=type=cache,target=/.python-cache mkdir -p /opt/local/ \ 19 | && python3 -m venv --system-site-packages /opt/local/plugin-manager \ 20 | && cd /usr/local/bin \ 21 | && /opt/local/plugin-manager/bin/pip install -U --upgrade-strategy=eager \ 22 | --cache-dir=/.python-cache pip setuptools wheel packaging>=22.0 \ 23 | && /opt/local/plugin-manager/bin/pip install -U --upgrade-strategy=eager \ 24 | --cache-dir=/.python-cache qgis-plugin-manager \ 25 | && cd /usr/local/bin \ 26 | && echo "#!/bin/bash" > qgis-plugin-manager \ 27 | && echo "export QGIS_PLUGINPATH=\${QGIS_PLUGINPATH:-\$QGSRV_SERVER_PLUGINPATH}" >> qgis-plugin-manager \ 28 | && echo "echo \"QGIS_PLUGINPATH set to \$QGIS_PLUGINPATH\"" >> qgis-plugin-manager \ 29 | && echo "/opt/local/plugin-manager/bin/qgis-plugin-manager \"\$@\"" >> qgis-plugin-manager \ 30 | && chmod 755 qgis-plugin-manager \ 31 | ${NULL} 32 | 33 | 34 | COPY pyqgisserver py-qgis-server/pyqgisserver 35 | COPY pyqgisservercontrib py-qgis-server/pyqgisservercontrib 36 | COPY pyproject.toml VERSION py-qgis-server/ 37 | 38 | # Create virtualenv for installing server 39 | RUN --mount=type=cache,target=/.python-cache mkdir -p /opt/local/ \ 40 | && python3 -m venv --system-site-packages /opt/local/pyqgisserver \ 41 | && cd /usr/local/bin \ 42 | && /opt/local/pyqgisserver/bin/pip install -U \ 43 | --cache-dir=/.python-cache pip setuptools wheel \ 44 | --upgrade-strategy=eager \ 45 | && /opt/local/pyqgisserver/bin/pip install -U --cache-dir=/.python-cache \ 46 | --upgrade-strategy=eager \ 47 | -e /py-qgis-server \ 48 | && ln -s /opt/local/pyqgisserver/bin/qgisserver \ 49 | && ln -s /opt/local/pyqgisserver/bin/qgisserver-worker \ 50 | ${NULL} 51 | 52 | COPY docker/docker-entrypoint.sh / 53 | RUN chmod 0755 /docker-entrypoint.sh && mkdir -p /home/qgis && chmod 777 /home/qgis 54 | 55 | # Set uid root on Xvfb 56 | # Allow us to run Xvfb when the container runs with '-u' option 57 | RUN chmod u+s /usr/bin/Xvfb 58 | 59 | EXPOSE 8080 60 | 61 | ENTRYPOINT ["/docker-entrypoint.sh"] 62 | 63 | CMD ["qgisserver"] 64 | 65 | # ============ 66 | # AMQP support 67 | # ============ 68 | FROM base AS amqp 69 | 70 | ARG PIP_OPTIONS 71 | 72 | RUN /opt/local/pyqgisserver/bin/pip install --no-cache-dir $PIP_OPTIONS "py-amqp-client<=2.0.0" 73 | -------------------------------------------------------------------------------- /docker/Makefile: -------------------------------------------------------------------------------- 1 | SHELL:=bash 2 | # 3 | # Build docker image 4 | # 5 | # 6 | 7 | DEPTH=.. 8 | 9 | include $(DEPTH)/config.mk 10 | 11 | NAME=qgis-map-server 12 | 13 | # QGIS platform version 14 | FLAVOR:=release 15 | 16 | ifdef PYPISERVER 17 | PYPISERVER_SCHEME ?= https 18 | PIP_OPTIONS="--extra-index-url=$(PYPISERVER_SCHEME)://$(PYPISERVER) --trusted-host=$(PYPISERVER)" 19 | BUILD_ARGS=--build-arg PIP_OPTIONS=$(PIP_OPTIONS) 20 | endif 21 | 22 | BUILD_ARGS += --build-arg QGIS_VERSION=$(FLAVOR) 23 | 24 | REGISTRY_URL ?= 3liz 25 | REGISTRY_PREFIX=$(REGISTRY_URL)/ 26 | BUILD_ARGS += --build-arg REGISTRY_PREFIX=$(REGISTRY_PREFIX) 27 | 28 | BUILDIMAGE:=$(NAME):$(FLAVOR)-$(COMMITID) 29 | 30 | MANIFEST:=factory-$(FLAVOR).manifest 31 | 32 | # Use buildkit 33 | export DOCKER_BUILDKIT:=1 34 | 35 | all: 36 | @echo "Usage: make [build|test|deliver|clean]" 37 | 38 | BUILD_TARGET:=base 39 | 40 | build: manifest 41 | docker build --rm --force-rm --no-cache $(DOCKER_BUILD_ARGS) --target $(BUILD_TARGET) \ 42 | $(BUILD_ARGS) -t $(BUILDIMAGE) -f Dockerfile .. 43 | 44 | 45 | QGIS_IMAGE=$(REGISTRY_PREFIX)qgis-platform:$(FLAVOR) 46 | 47 | manifest: 48 | { \ 49 | set -e; \ 50 | version=`docker run --rm -v $$(pwd)/scripts:/scripts $(QGIS_IMAGE) /scripts/qgis-version.sh`; \ 51 | echo name=$(NAME) > $(MANIFEST) && \ 52 | echo version=$$version-$(VERSION) >> $(MANIFEST) && \ 53 | echo version_short=$$version >> $(MANIFEST) && \ 54 | echo release_tag=`echo $$version | cut -d- -f1 |cut -d. -f1-2` >> $(MANIFEST) && \ 55 | echo buildid=$(BUILDID) >> $(MANIFEST) && \ 56 | echo commitid=$(COMMITID) >> $(MANIFEST); } 57 | 58 | deliver: tag push 59 | 60 | ifndef CI_COMMIT_TAG 61 | 62 | GIT_BRANCH=$(shell git branch --show-current) 63 | ifeq ($(GIT_BRANCH),) 64 | GIT_BRANCH=$(CI_COMMIT_BRANCH) 65 | endif 66 | 67 | ifeq ($(GIT_BRANCH),master) 68 | TAG_DEV=dev 69 | else 70 | TAG_DEV=$(GIT_BRANCH)-dev 71 | endif 72 | 73 | tag: 74 | { set -e; source $(MANIFEST); \ 75 | docker tag $(BUILDIMAGE) $(REGISTRY_PREFIX)$(NAME):$${release_tag}-$(TAG_DEV); \ 76 | docker tag $(BUILDIMAGE) $(REGISTRY_PREFIX)$(NAME):$(FLAVOR)-$(TAG_DEV); \ 77 | } 78 | 79 | push: 80 | { set -e; source $(MANIFEST); \ 81 | docker push $(REGISTRY_URL)/$(NAME):$${release_tag}-$(TAG_DEV); \ 82 | docker push $(REGISTRY_URL)/$(NAME):$(FLAVOR)-$(TAG_DEV); \ 83 | } 84 | else 85 | tag: 86 | { set -e; source $(MANIFEST); \ 87 | docker tag $(BUILDIMAGE) $(REGISTRY_PREFIX)$(NAME):$$version; \ 88 | docker tag $(BUILDIMAGE) $(REGISTRY_PREFIX)$(NAME):$$version_short; \ 89 | docker tag $(BUILDIMAGE) $(REGISTRY_PREFIX)$(NAME):$$release_tag; \ 90 | docker tag $(BUILDIMAGE) $(REGISTRY_PREFIX)$(NAME):$(FLAVOR); \ 91 | } 92 | 93 | push: 94 | { set -e; source $(MANIFEST); \ 95 | docker push $(REGISTRY_URL)/$(NAME):$$version; \ 96 | docker push $(REGISTRY_URL)/$(NAME):$$version_short; \ 97 | docker push $(REGISTRY_URL)/$(NAME):$$release_tag; \ 98 | docker push $(REGISTRY_URL)/$(NAME):$(FLAVOR); \ 99 | } 100 | endif 101 | 102 | clean-all: 103 | docker rmi -f $(shell docker images $(BUILDIMAGE) -q) 104 | 105 | clean: 106 | docker rmi $(BUILDIMAGE) 107 | 108 | SRCDIR=$(shell realpath ..) 109 | 110 | TEST_HTTP_PORT:=8888 111 | QGSRV_USER:=$(shell id -u):$(shell id -g) 112 | 113 | run: 114 | docker run -it --rm -p $(TEST_HTTP_PORT):8080 --name py-qgis-server \ 115 | -v $(SRCDIR):/src \ 116 | -v $(SRCDIR)/tests/data:/projects \ 117 | -e QGSRV_PROJECTS_SCHEMES_TEST=/src/tests/data/ \ 118 | -e QGSRV_PROJECTS_SCHEMES_FOO=file:foobar/ \ 119 | -e QGSRV_PROJECTS_SCHEMES_BAR=file:foobar?data={path} \ 120 | -e QGSRV_SERVER_HTTP_PROXY=yes \ 121 | -e QGSRV_CACHE_ROOTDIR=/projects \ 122 | -e QGSRV_USER=$(QGSRV_USER) \ 123 | -e QGSRV_LOGGING_LEVEL=DEBUG \ 124 | -e QGSRV_MANAGEMENT_ENABLED=yes \ 125 | -e QGSRV_MANAGEMENT_INTERFACES=127.0.0.1 \ 126 | -e QGSRV_SERVER_PLUGINPATH=/src/tests/plugins \ 127 | -e QGSRV_API_ENABLED_LANDING_PAGE=yes \ 128 | -e DEBUG_ENTRYPOINT=yes \ 129 | $(BUILDIMAGE) 130 | 131 | run-proxy: 132 | docker run --rm -p $(TEST_HTTP_PORT):8080 --net mynet --name map-proxy-$(COMMITID) \ 133 | -e QGSRV_USER=$(QGSRV_USER) \ 134 | -e QGSRV_LOGGING_LEVEL=DEBUG \ 135 | $(BUILDIMAGE) qgisserver-proxy 136 | 137 | run-worker: 138 | docker run --rm --net mynet -v $(SRCDIR)/tests/data:/projects \ 139 | --name qgis3-worker-$(COMMITID) \ 140 | -e QGSRV_CACHE_ROOTDIR=/projects \ 141 | -e QGSRV_USER=$(QGSRV_USER) \ 142 | -e QGSRV_LOGGING_LEVEL=DEBUG \ 143 | -e ROUTER_HOST=map-proxy-$(COMMITID) \ 144 | $(BUILDIMAGE) qgisserver-worker 145 | 146 | 147 | # Push to docker hub as rc version 148 | pushrc: 149 | ifdef CI_COMMIT_TAG 150 | @echo "This is a TAG commit" 151 | else 152 | cat $(DOCKERPASS) | docker login -u 3liz --password-stdin 153 | docker tag $(BUILDIMAGE) 3liz/$(NAME):$(FLAVOR)-rc 154 | docker push 3liz/$(NAME):$(FLAVOR)-rc 155 | endif 156 | -------------------------------------------------------------------------------- /docker/README.md: -------------------------------------------------------------------------------- 1 | # QGIS map server 2 | 3 | [![Docker Pulls](https://img.shields.io/docker/pulls/3liz/qgis-map-server)](https://hub.docker.com/r/3liz/qgis-map-server/tags) 4 | 5 | Set up a OGC WWS/WFS/WCS service. 6 | 7 | Run the python QGIS server from https://github.com/3liz/py-qgis-server in a docker container. 8 | 9 | Versions are published on Docker Hub https://hub.docker.com/r/3liz/qgis-map-server 10 | 11 | To know the QGIS version used in an image: 12 | ```bash 13 | docker run -it 3liz/qgis-map-server:3.10 version 14 | ``` 15 | will return the full QGIS version used. 16 | 17 | ## Quick start 18 | 19 | ``` 20 | docker run -p 8080:8080 [--user [:]] -v /path/to/qgis/projects:/qgis-data 3liz/qgis-map-server 21 | ``` 22 | 23 | ## Run example with config 24 | 25 | ```bash 26 | docker run -p 8080:8080 \ 27 | -v /path/to/qgis/projects:/projects \ 28 | -e QGSRV_SERVER_WORKERS=2 \ 29 | -e QGSRV_LOGGING_LEVEL=DEBUG \ 30 | -e QGSRV_CACHE_ROOTDIR=/projects \ 31 | -e QGSRV_CACHE_SIZE=10 \ 32 | 3liz/qgis-map-server 33 | ``` 34 | 35 | ## Requests to OWS services 36 | 37 | The OWS requests use the following format: `/ows/?` 38 | 39 | Example: 40 | 41 | ``` 42 | http://myserver:8080/ows/?SERVICE=WFS&VERSION=1.1.0&REQUEST=GetCapabilities 43 | ``` 44 | 45 | ### Passing MAP arguments 46 | 47 | MAP arguments are treated as relative to the location given by `QYWPS_CACHE_ROOTDIR` 48 | 49 | ## Configuration 50 | 51 | The server is configured via environment variables or configuration file as 52 | described [here](https://github.com/3liz/py-qgis-server/blob/master/README.md#configuration) 53 | 54 | ### Running the server as specific user 55 | 56 | By default, the server ren as user and group id 9001. The user id may be customized by setting 57 | the `QGSRV_USER` environment variable to the - numerical - user ID of your choice 58 | 59 | 60 | ### QGIS project Cache configuration 61 | 62 | - QGSRV\_CACHE\_ROOTDIR: Absolute path to the qgis projects root directory 63 | - QGSRV\_CACHE\_SIZE: Qgis projects cache size 64 | - QGSRV\_LOGGING\_LEVEL: Logging level (DEBUG,INFO) 65 | - QGSRV\_SERVER\_WORKERS: Number of QGIS server instances 66 | 67 | The cache hold projects, if the project timestamp change on disk then the project will be reloaded. 68 | 69 | ### Xvfb and Display support 70 | 71 | Xvfb display support can be activated with `QGSRV_DISPLAY_XVFB=ON` which is the default behavior. 72 | 73 | ### Plugin path 74 | 75 | Plugins can be used from a host mounted volume; use the `QGSRV_SERVER_PLUGINPATH` environment 76 | variables to set the path inside the container. 77 | 78 | ### Pass QGIS environment variables 79 | 80 | You may pass QGIS server environment variables by defining them as docker environment variables. 81 | 82 | Useful variables are: 83 | 84 | ``` 85 | QGIS_OPTIONS_PATH # Path to look for qgis settings ini file 86 | QGIS_SERVER_PARALLEL_RENDERING # Enable/Disable QGIS_SERVER_PARALLEL_RENDERING (default to false) 87 | QGIS_SERVER_MAX_THREADS # Max num rendering threads (per processes) - default unlimited 88 | QGIS_SERVER_WMS_MAX_HEIGHT # Maximum height for a WMS request - default not set 89 | QGIS_SERVER_WMS_MAX_WIDTH # Maximum width for a WMS request - default not set 90 | ``` 91 | 92 | ## Using with Lizmap 93 | 94 | In order to use the server with Lizmap, you must set the following configuration 95 | in your `lizmapConfig.ini.php`: 96 | 97 | ```ini 98 | [services] 99 | wmsServerURL="http://my.domain:/ows/" 100 | ... 101 | 102 | ; Use relative path 103 | relativeWMSPath=true 104 | ``` 105 | 106 | ## Notes 107 | 108 | * GeoPackages is not multiprocessing friendly and are not working well with read-only volumes. 109 | Avoid them if you intend to use your data with read-only volumes. 110 | * Qgis requires Qt5 minimum, there is [known issues](https://askubuntu.com/questions/1034313/ubuntu-18-4-libqt5core-so-5-cannot-open-shared-object-file-no-such-file-or-dir) with too old kernel. As a matter of fact, you should use a kernel 4.19 or superior. 111 | -------------------------------------------------------------------------------- /docker/docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3.9' 2 | services: 3 | qgis-server: 4 | user: 1001:1001 5 | image: 3liz/qgis-map-server:ltr-dev 6 | environment: 7 | QGIS_OPTIONS_PATH: /test/qgis 8 | QGSRV_CACHE_ROOTDIR: /test/data 9 | QGSRV_SERVER_WORKERS: "1" 10 | QGSRV_LOGGING_LEVEL: DEBUG 11 | volumes: 12 | - type: bind 13 | source: "../tests/" 14 | target: /test 15 | read_only: true 16 | 17 | -------------------------------------------------------------------------------- /docker/docker-entrypoint.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -e 4 | 5 | copy_qgis_configuration() { 6 | QGIS_CUSTOM_CONFIG_PATH=${QGIS_CUSTOM_CONFIG_PATH:-$QGIS_OPTIONS_PATH} 7 | if [[ -n $QGIS_CUSTOM_CONFIG_PATH ]]; then 8 | echo "Copying Qgis configuration: $QGIS_CUSTOM_CONFIG_PATH" 9 | cp -RL $QGIS_CUSTOM_CONFIG_PATH/* $HOME/ 10 | fi 11 | export QGIS_CUSTOM_CONFIG_PATH=$HOME 12 | export QGIS_OPTIONS_PATH=$HOME 13 | } 14 | 15 | [[ "$DEBUG_ENTRYPOINT" == "yes" ]] && set -x 16 | 17 | if [[ "$1" == "version" ]]; then 18 | version=`/opt/local/pyqgisserver/bin/pip list | grep py-qgis-server | tr -s [:blank:] | cut -d ' ' -f 2` 19 | qgis_version=`python3 -c "from qgis.core import Qgis; print(Qgis.QGIS_VERSION.split('-')[0])"` 20 | # Strip the 'rc' from the version 21 | # An 'rc' version is not released so as a docker image the rc is not relevant 22 | # here 23 | echo "$qgis_version-${version%rc0}" 24 | exit 0 25 | fi 26 | 27 | # Check for uid (running with --user) 28 | if [[ "$UID" != "0" ]]; then 29 | QGSRV_USER=$UID:$(id -g) 30 | else 31 | QGSRV_USER=${QGSRV_USER:-"9001:9001"} 32 | fi 33 | 34 | if [[ "$QGSRV_USER" =~ ^root:? ]] || [[ "$QGSRV_USER" =~ ^0:? ]]; then 35 | echo "QGSRV_USER must no be root !" 36 | exit 1 37 | fi 38 | 39 | if [[ "$1" = "qgisserver-proxy" ]]; then 40 | shift 41 | echo "Running Qgis server proxy" 42 | 43 | REUID=`echo $QGSRV_USER|cut -d: -f1` 44 | REGID=`echo $QGSRV_USER|cut -d: -f2` 45 | exec setpriv --clear-groups --reuid=$REUID --regid=$REGID qgisserver --proxy $@ 46 | fi 47 | 48 | # Qgis need a HOME 49 | export HOME=/home/qgis 50 | 51 | # Set the default QGSRV_CACHE_ROOTDIR 52 | if [[ -z $QGSRV_CACHE_ROOTDIR ]]; then 53 | export QGSRV_CACHE_ROOTDIR=/qgis-data 54 | echo "QGSRV_CACHE_ROOTDIR set to $QGSRV_CACHE_ROOTDIR" 55 | fi 56 | 57 | if [ "$(id -u)" = '0' ]; then 58 | # Delete any actual Xvfb lock file 59 | # Because it can only be removed as root 60 | rm -rf /tmp/.X99-lock 61 | 62 | if [[ "$(stat -c '%u' $HOME)" == "0" ]] ; then 63 | chown $QGSRV_USER $HOME 64 | chmod 755 $HOME 65 | fi 66 | if [[ ! -e $QGSRV_CACHE_ROOTDIR ]]; then 67 | mkdir -p $QGSRV_CACHE_ROOTDIR 68 | chown $QGSRV_USER $QGSRV_CACHE_ROOTDIR 69 | fi 70 | 71 | REUID=`echo $QGSRV_USER|cut -d: -f1` 72 | REGID=`echo $QGSRV_USER|cut -d: -f2` 73 | exec setpriv --clear-groups --reuid=$REUID --regid=$REGID "$BASH_SOURCE" "$@" 74 | fi 75 | 76 | echo "Running as $QGSRV_USER" 77 | 78 | if [[ "$(id -g)" == "0" ]]; then 79 | echo "SECURITY WARNING: running as group 'root'" 80 | fi 81 | 82 | # Check if HOME is available 83 | if [[ ! -d $HOME ]]; then 84 | echo "ERROR: Qgis require a HOME directory (default to $HOME)" 85 | echo "ERROR: You must mount the corresponding volume directory" 86 | exit 1 87 | fi 88 | # Check if HOME is writable 89 | if [[ ! -w $HOME ]]; then 90 | echo "ERROR: $HOME must be writable for user:group $QGSRV_USER" 91 | echo "ERROR: You should consider the '--user' Docker option" 92 | exit 1 93 | fi 94 | 95 | # Check that QGSRV_CACHE_ROOTDIR exists and is readable 96 | if [[ ! -r $QGSRV_CACHE_ROOTDIR ]]; then 97 | echo "ERROR: $QGSRV_CACHE_ROOTDIR do not exists or is not readable" 98 | exit 1 99 | fi 100 | 101 | # Check that QGSRV_CACHE_ROOTDIR is writable 102 | if [[ ! -w $QGSRV_CACHE_ROOTDIR ]]; then 103 | echo "WARNING: $QGSRV_CACHE_ROOTDIR is not writable" 104 | echo "WARNING: this may lead to potential problems with gpkg datasets" 105 | fi 106 | 107 | QGSRV_DISPLAY_XVFB=${QGSRV_DISPLAY_XVFB:-ON} 108 | # 109 | # Set up xvfb 110 | # https://www.x.org/archive/X11R7.6/doc/man/man1/Xvfb.1.xhtml 111 | # see https://www.x.org/archive/X11R7.6/doc/man/man1/Xserver.1.xhtml 112 | # 113 | XVFB_DEFAULT_ARGS="-screen 0 1024x768x24 -ac +extension GLX +render -noreset" 114 | XVFB_ARGS=${QGSRV_XVFB_ARGS:-":99 $XVFB_DEFAULT_ARGS"} 115 | 116 | if [[ "$QGSRV_DISPLAY_XVFB" == "ON" ]]; then 117 | if [ -f /tmp/.X99-lock ]; then 118 | echo "ERROR: An existing lock file will prevent Xvfb to start" 119 | echo "If you expect restarting the container with '--user' option" 120 | echo "consider mounting /tmp with option '--tmpfs /tmp'" 121 | exit 1 122 | fi 123 | 124 | # RUN Xvfb in the background 125 | echo "Running Xvfb" 126 | nohup /usr/bin/Xvfb $XVFB_ARGS >/tmp/xvfb.log 2>&1 & 127 | export DISPLAY=":99" 128 | fi 129 | 130 | copy_qgis_configuration 131 | 132 | # See https://github.com/qgis/QGIS/pull/5337 133 | export QGIS_DISABLE_MESSAGE_HOOKS=1 134 | export QGIS_NO_OVERRIDE_IMPORT=1 135 | 136 | # Make sure that QGSRV_SERVER_PLUGINPATH takes precedence over 137 | # QGIS_PLUGINPATH 138 | if [[ -n ${QGSRV_SERVER_PLUGINPATH} ]]; then 139 | export QGIS_PLUGINPATH=$QGSRV_SERVER_PLUGINPATH 140 | fi 141 | 142 | if [[ "$1" == "qgisserver-worker" ]]; then 143 | shift 144 | echo "Running Qgis server worker" 145 | exec qgisserver-worker --host=$ROUTER_HOST $@ 146 | else 147 | exec $@ 148 | fi 149 | 150 | -------------------------------------------------------------------------------- /docker/scripts/qgis-version.sh: -------------------------------------------------------------------------------- 1 | #!/bin/env python3 2 | 3 | from qgis.core import Qgis 4 | 5 | print(Qgis.QGIS_VERSION.split('-')[0]) 6 | 7 | -------------------------------------------------------------------------------- /examples/test-lizmap-server-plugin/docker-compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | map: 3 | image: 3liz/qgis-map-server:release-dev 4 | environment: 5 | QGSRV_LOGGING_LEVEL: debug 6 | QGSRV_SERVER_PLUGINPATH: /srv/lizmap-plugin-server 7 | QGIS_OPTIONS_PATH: "/src/tests/qgis" 8 | QGIS_SERVER_TRUST_LAYER_METADATA: "yes" 9 | QGIS_SERVER_DISABLE_GETPRINT: "yes" 10 | QGIS_SERVER_LIZMAP_REVEAL_SETTINGS: "yes" 11 | QGIS_SERVER_PROJECT_CACHE_STRATEGY: "off" 12 | QGSRV_CACHE_ROOTDIR: /src/tests/data 13 | QGSRV_SERVER_WORKERS: "1" 14 | QGSRV_PROJECTS_SCHEMES_TEST: /src/tests/data/ 15 | QGSRV_SERVER_STATUS_PAGE: "yes" 16 | QGSRV_MANAGEMENT_ENABLED: "yes" 17 | QGSRV_MANAGEMENT_INTERFACES: "0.0.0.0" 18 | QGSRV_API_ENABLED_LIZMAP: "yes" 19 | QGSRV_API_ENABLED_LANDING_PAGE: "yes" 20 | QGSRV_API_ENDPOINTS_LANDING_PAGE: /ows/catalog 21 | QGSRV_SERVER_TIMEOUT: "20" 22 | QGSRV_CACHE_STRICT_CHECK: "yes" 23 | volumes: 24 | - type: bind 25 | source: "../../../lizmap/lizmap-plugin-server" 26 | target: /srv/lizmap-plugin-server 27 | - { type: bind, source: "../../", target: "/src" } 28 | ports: 29 | - "127.0.0.1:9080:8080" 30 | -------------------------------------------------------------------------------- /mypy.ini: -------------------------------------------------------------------------------- 1 | 2 | [mypy] 3 | python_version = 3.10 4 | mypy_path = 5 | $MYPY_CONFIG_FILE_DIR/pyqgiscontrib: 6 | allow_redefinition = true 7 | 8 | # The following packages do not 9 | # define proper type annotations 10 | 11 | [mypy-qgis.*] 12 | ignore_missing_imports = true 13 | 14 | [mypy-processing.*] 15 | ignore_missing_imports = true 16 | 17 | [mypy-osgeo.*] 18 | ignore_missing_imports = true 19 | 20 | [mypy-psycopg.*] 21 | ignore_missing_imports = true 22 | 23 | [mypy-pika.*] 24 | ignore_missing_imports = true 25 | 26 | [mypy-amqpclient.*] 27 | ignore_missing_imports = true 28 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["setuptools>=61.0"] 3 | build-backend = "setuptools.build_meta" 4 | 5 | [project] 6 | name = "py-qgis-server" 7 | description = "Py-Qgis-Server is an OWS/OGC server built on top of QGIS Server implementation" 8 | readme = "README.md" 9 | requires-python = ">= 3.8" 10 | classifiers=[ 11 | "License :: OSI Approved :: Mozilla Public License 2.0 (MPL 2.0)", 12 | "Environment :: Web Environment", 13 | "Intended Audience :: Developers", 14 | "Intended Audience :: Science/Research", 15 | "Programming Language :: Python :: 3 :: Only", 16 | "Programming Language :: Python :: 3.8", 17 | "Programming Language :: Python :: 3.9", 18 | "Programming Language :: Python :: 3.10", 19 | "Programming Language :: Python :: 3.11", 20 | "Programming Language :: Python :: 3.12", 21 | "Operating System :: POSIX :: Linux", 22 | "Topic :: Scientific/Engineering :: GIS", 23 | ] 24 | dependencies = [ 25 | "tornado >= 5", 26 | "pyzmq >= 17", 27 | "PyYAML", 28 | "typing-extensions", 29 | "psutil", 30 | ] 31 | dynamic = ["version"] 32 | 33 | [project.optional-dependencies] 34 | amqp = ["py-amqp-client<2.0.0"] 35 | 36 | [[project.authors]] 37 | name = "3Liz" 38 | email = "david.marteau@3liz.com" 39 | 40 | [[project.maintainers]] 41 | name = "David Marteau" 42 | email = "david.marteau@3liz.com" 43 | 44 | [project.urls] 45 | Homepage = "https://github.com/3liz/py-qgis-server" 46 | Repository = "https://github.com/3liz/py-qgis-server.git" 47 | Documentation = "https://docs.3liz.org/py-qgis-server" 48 | 49 | [project.scripts] 50 | qgisserver = "pyqgisserver.server:main" 51 | qgisserver-worker = "pyqgisserver.qgsworker:main" 52 | 53 | [project.entry-points."py_qgis_server.monitors"] 54 | amqp = "pyqgisserver.monitors.amqp:Monitor" 55 | test = "pyqgisserver.monitors.test" 56 | 57 | [project.entry-points."py_qgis_server.cache.observers"] 58 | test = "pyqgisserver.qgscache.observers.test" 59 | ban = "pyqgisserver.qgscache.observers.ban" 60 | 61 | [project.entry-points."py_qgis_server.access_policy"] 62 | request_logger = "pyqgisservercontrib.middlewares.request_logger:register_filters" 63 | 64 | [tool.setuptools.dynamic] 65 | version = { file = ["VERSION"] } 66 | 67 | [tool.setuptools.packages.find] 68 | include = [ 69 | "pyqgisserver", 70 | "pyqgisserver.*", 71 | "pyqgisservercontrib.*", 72 | ] 73 | -------------------------------------------------------------------------------- /pyqgisserver/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/3liz/py-qgis-server/ffdf4a3d8075c619adad768d42b1aef10d1257ee/pyqgisserver/__init__.py -------------------------------------------------------------------------------- /pyqgisserver/handlers/__init__.py: -------------------------------------------------------------------------------- 1 | from .basehandler import ( # noqa F401 2 | BaseHandler, 3 | ErrorHandler, 4 | NotFoundHandler, 5 | ) 6 | from .landingpage import LandingPage # noqa F401 7 | from .oapihandler import OAPIHandler # noqa F401 8 | from .owshandler import OwsHandler # noqa F401 9 | from .statushandler import PingHandler, StatusHandler # noqa F401 10 | -------------------------------------------------------------------------------- /pyqgisserver/handlers/landingpage.py: -------------------------------------------------------------------------------- 1 | # 2 | # Copyright 2018 3liz 3 | # Author: David Marteau 4 | # 5 | # This Source Code Form is subject to the terms of the Mozilla Public 6 | # License, v. 2.0. If a copy of the MPL was not distributed with this 7 | # file, You can obtain one at http://mozilla.org/MPL/2.0/. 8 | 9 | import logging 10 | 11 | from ..config import confservice 12 | from ..version import __version__ 13 | from .basehandler import BaseHandler 14 | 15 | LOGGER = logging.getLogger('SRVLOG') 16 | 17 | 18 | class LandingPage(BaseHandler): 19 | 20 | def initialize(self): 21 | super().initialize() 22 | 23 | self._metadata = confservice['metadata'] 24 | 25 | def get(self): 26 | """ 27 | """ 28 | req = self.request 29 | self._server = f"{req.protocol}://{req.host}" 30 | doc = {} 31 | doc.update( 32 | servers=[{ 33 | 'url': self._server, 34 | }], 35 | info=self.service_infos(), 36 | externalDocs=self.external_doc(), 37 | paths={}, 38 | ) 39 | self.write_json(doc) 40 | 41 | def service_infos(self): 42 | m = self._metadata.get 43 | doc = { 44 | 'title': m('title'), 45 | 'description': m('description'), 46 | 'termsOfService': m('terms_of_service'), 47 | 'contact': { 48 | 'name': m('contact_name'), 49 | 'url': m('contact_url'), 50 | 'email': m('contact_email'), 51 | }, 52 | 'licence': { 53 | 'name': m('licence_name'), 54 | 'url': m('licence_url'), 55 | }, 56 | 'version': __version__, 57 | } 58 | 59 | doc.update(self.qgis_version_info()) 60 | return doc 61 | 62 | def qgis_version_info(self): 63 | try: 64 | from qgis.core import Qgis 65 | qgis_version = Qgis.QGIS_VERSION_INT 66 | qgis_release = Qgis.QGIS_RELEASE_NAME 67 | except ImportError: 68 | LOGGER.critical("Failed to import Qgis module !") 69 | qgis_version = qgis_release = 'n/a' 70 | 71 | return ( 72 | ('x-qgis-version', qgis_version), 73 | ('x-qgis-release', qgis_release), 74 | ) 75 | 76 | def external_doc(self): 77 | return { 78 | 'description': self._metadata['external_doc_description'], 79 | 'url': self._metadata['external_doc_url'], 80 | } 81 | -------------------------------------------------------------------------------- /pyqgisserver/handlers/oapihandler.py: -------------------------------------------------------------------------------- 1 | # 2 | # Copyright 2018-2019 3liz 3 | # Author: David Marteau 4 | # 5 | # This Source Code Form is subject to the terms of the Mozilla Public 6 | # License, v. 2.0. If a copy of the MPL was not distributed with this 7 | # file, You can obtain one at http://mozilla.org/MPL/2.0/. 8 | 9 | """ Qgis server handler 10 | """ 11 | import logging 12 | 13 | from .asynchandler import AsyncClientHandler 14 | 15 | LOGGER = logging.getLogger('SRVLOG') 16 | 17 | 18 | class OAPIHandler(AsyncClientHandler): 19 | """ Handle Qgis api 20 | """ 21 | def initialize(self, service: str, **kwargs): # type: ignore [override] 22 | super().initialize(**kwargs) 23 | self.ogc_scheme = 'OAF' 24 | self._service_name = service.upper() 25 | 26 | def prepare(self): 27 | super().prepare() 28 | # Replace MAP key with uppercase 29 | args = self.request.arguments 30 | if 'MAP' in args: 31 | return 32 | for k in args: 33 | if k.upper() == 'MAP': 34 | key = k 35 | val = args[k] 36 | break 37 | else: 38 | return 39 | del args[key] 40 | args['MAP'] = val 41 | 42 | async def delete(self): 43 | await self.handle_request('DELETE') 44 | 45 | async def put(self): 46 | await self.handle_request('PUT') 47 | 48 | async def patch(self): 49 | await self.handle_request('PATCH') 50 | 51 | async def options(self): 52 | await self.handle_request('OPTIONS') 53 | 54 | def get_monitor_params(self): 55 | """ Override 56 | """ 57 | params = dict( 58 | MAP=self.request.arguments.get('MAP', '__unknown__'), 59 | SERVICE=self._service_name, 60 | REQUEST=self.request.path, 61 | ) 62 | return params 63 | -------------------------------------------------------------------------------- /pyqgisserver/handlers/owshandler.py: -------------------------------------------------------------------------------- 1 | # 2 | # Copyright 2018-2019 3liz 3 | # Author: David Marteau 4 | # 5 | # This Source Code Form is subject to the terms of the Mozilla Public 6 | # License, v. 2.0. If a copy of the MPL was not distributed with this 7 | # file, You can obtain one at http://mozilla.org/MPL/2.0/. 8 | 9 | """ Qgis server handler 10 | """ 11 | import logging 12 | 13 | from typing import Any, Dict, Union 14 | from urllib.parse import urlencode 15 | 16 | from .asynchandler import AsyncClientHandler 17 | 18 | LOGGER = logging.getLogger('SRVLOG') 19 | 20 | 21 | def _decode(b: Union[str, bytes]) -> str: 22 | if not isinstance(b, str): 23 | return b.decode('utf-8') 24 | return b 25 | 26 | 27 | class OwsHandler(AsyncClientHandler): 28 | 29 | def initialize(self, *args, getfeaturelimit: int = -1, **kwargs) -> None: 30 | super().initialize(*args, **kwargs) 31 | self.getfeaturelimit = getfeaturelimit 32 | self.ogc_scheme = 'OWS' 33 | 34 | MONITOR_ARGUMENTS = ( 35 | 'MAP', 36 | 'SERVICE', 37 | 'REQUEST', 38 | ) 39 | 40 | def get_monitor_params(self) -> Dict[str, Any]: 41 | """ Override 42 | """ 43 | args = self.request.arguments 44 | params = {k: _decode(args.get(k, ["__unknown__"])[0]) for k in self.MONITOR_ARGUMENTS} 45 | return params 46 | 47 | def prepare(self) -> None: 48 | super().prepare() 49 | # Replace query arguments to upper case: (it's ok for OWS) 50 | self.request.arguments = {k.upper(): v for (k, v) in self.request.arguments.items()} 51 | 52 | def fix_getfeature(self, arguments: Dict) -> Dict: 53 | """ Take care of WFS/GetFeature limit 54 | 55 | Qgis does not set a default limit and unlimited 56 | request may cause issues 57 | """ 58 | if self.getfeaturelimit > 0 \ 59 | and arguments.get('SERVICE', b'').upper() == b'WFS' \ 60 | and arguments.get('REQUEST', b'').lower() == b'getfeature': 61 | 62 | if arguments.get('VERSION', b'').startswith(b'2.'): 63 | key = 'COUNT' 64 | else: 65 | key = 'MAXFEATURES' 66 | 67 | limit = self.getfeaturelimit 68 | try: 69 | actual_limit = int(arguments.get(key, 0)) 70 | if actual_limit > 0: 71 | limit = min(limit, actual_limit) 72 | except ValueError: 73 | pass 74 | arguments[key] = str(limit).encode() 75 | 76 | return arguments 77 | 78 | def encode_arguments(self) -> str: 79 | arguments = {k: v[0] for k, v in self.request.arguments.items()} 80 | return '?' + urlencode(self.fix_getfeature(arguments)) 81 | -------------------------------------------------------------------------------- /pyqgisserver/handlers/statushandler.py: -------------------------------------------------------------------------------- 1 | # 2 | # Copyright 2018 3liz 3 | # Author: David Marteau 4 | # 5 | # This Source Code Form is subject to the terms of the Mozilla Public 6 | # License, v. 2.0. If a copy of the MPL was not distributed with this 7 | # file, You can obtain one at http://mozilla.org/MPL/2.0/. 8 | 9 | import logging 10 | import os 11 | 12 | from typing import Dict 13 | 14 | import tornado 15 | 16 | from ..config import config_to_dict 17 | from ..version import __version__ 18 | from .basehandler import BaseHandler 19 | 20 | LOGGER = logging.getLogger('SRVLOG') 21 | 22 | 23 | class PingHandler(BaseHandler): 24 | 25 | def set_default_headers(self) -> None: 26 | super().set_default_headers() 27 | # Disable cache because this is healtcheck requests 28 | self.set_header("Cache-control", "no-store") 29 | self.set_header("X-Server-Status", "ok") 30 | 31 | def get(self): 32 | self.write_json({'status': 'ok'}) 33 | 34 | def head(self): 35 | pass 36 | 37 | 38 | class StatusHandler(BaseHandler): 39 | 40 | def get(self): 41 | 42 | path = self.request.path.rstrip('/') 43 | 44 | if path == '/status/config': 45 | response = config_to_dict() 46 | elif path == '/status/env': 47 | response = dict(os.environ) 48 | elif path == '/status/stats': 49 | response = self.application.stats 50 | elif path == '/status/versions': 51 | response = self.get_versions() 52 | else: 53 | response = self.get_versions() 54 | req = self.request 55 | 56 | def _link(path: str, title: str, rel: str) -> Dict[str, str]: 57 | return { 58 | 'href': f"{req.protocol}://{req.host}{path}", 59 | 'rel': rel, 60 | 'title': title, 61 | 'type': "application/json", 62 | } 63 | 64 | response.update(links=[ 65 | _link("/status/config", "Server configuration", "status"), 66 | _link("/status/env", "Execution environment", "status"), 67 | _link("/status/versions", "Versions", "status"), 68 | _link("/status/", "Server status", "self"), 69 | ]) 70 | 71 | self.write_json(response) 72 | 73 | def get_versions(self) -> Dict[str, str]: 74 | try: 75 | from qgis.core import Qgis 76 | QGIS_VERSION = str(Qgis.QGIS_VERSION_INT) 77 | QGIS_RELEASE = Qgis.QGIS_RELEASE_NAME 78 | except Exception as e: 79 | LOGGER.error("Cannot get Qgis version %s", e) 80 | QGIS_VERSION = "n/a" 81 | QGIS_RELEASE = "n/a" 82 | 83 | return dict( 84 | tornado_ver=tornado.version, 85 | version=__version__, 86 | qgis_version=QGIS_VERSION, 87 | qgis_release=QGIS_RELEASE, 88 | ) 89 | -------------------------------------------------------------------------------- /pyqgisserver/logger.py: -------------------------------------------------------------------------------- 1 | # 2 | # Copyright 2018 3liz 3 | # 4 | # This Source Code Form is subject to the terms of the Mozilla Public 5 | # License, v. 2.0. If a copy of the MPL was not distributed with this 6 | # file, You can obtain one at http://mozilla.org/MPL/2.0/. 7 | """ Logging handlers 8 | """ 9 | import logging 10 | 11 | REQ_LOG_TEMPLATE = "{ip}\t{code}\t{method}\t{url}\t{time}\t{length}\t" 12 | REQ_FORMAT = REQ_LOG_TEMPLATE + '{agent}\t{referer}{extra}' 13 | RREQ_FORMAT = REQ_LOG_TEMPLATE + '{extra}' 14 | 15 | # Lies between info and warning 16 | REQ = 21 17 | RREQ = 22 18 | 19 | LOGGER = logging.getLogger('SRVLOG') 20 | 21 | 22 | def setup_log_handler(log_level, formatstr='%(asctime)s\t%(levelname)s\t[%(process)d]\t%(message)s', 23 | stream=None): 24 | """ Initialize log handler with the given log level 25 | """ 26 | logging.addLevelName(REQ, "REQ") 27 | logging.addLevelName(RREQ, "RREQ") 28 | 29 | logger = LOGGER 30 | logger.setLevel(getattr(logging, log_level.upper())) 31 | # Init the root logger 32 | if not logger.handlers: 33 | channel = logging.StreamHandler(stream=stream) 34 | formatter = logging.Formatter(formatstr) 35 | channel.setFormatter(formatter) 36 | logger.addHandler(channel) 37 | return True 38 | return False 39 | 40 | 41 | def format_log_request(handler): 42 | """ Format current request from the given tornado request handler 43 | 44 | :return a tuple (fmt,code,reqtime,length) where: 45 | fmt: the log string 46 | code: the http return code 47 | reqtime: the request time 48 | length: the size of the payload 49 | """ 50 | request = handler.request 51 | code = handler.get_status() 52 | reqtime = request.request_time() 53 | 54 | length = handler._headers.get('Content-Length') or -1 55 | agent = request.headers.get('User-Agent') or "" 56 | referer = request.headers.get('Referer') or "" 57 | 58 | request_id = request.headers.get('X-Request-Id') 59 | 60 | fmt = REQ_FORMAT.format( 61 | ip=request.remote_ip, 62 | method=request.method, 63 | url=request.uri, 64 | code=code, 65 | time=int(1000.0 * reqtime), 66 | length=length, 67 | referer=referer, 68 | agent=agent, 69 | extra=f"\tREQ_ID:{request_id}" if request_id else '', 70 | ) 71 | 72 | return fmt, code, reqtime, length 73 | 74 | 75 | def log_request(handler): 76 | """ Log the current request from the given tornado request handler 77 | 78 | :param handler: The request handler 79 | :param logger: an optional logger 80 | 81 | :return A tuple (code,reqtime,length) where: 82 | code: the http retudn code 83 | reqtime: the request time 84 | length: the size of the payload 85 | """ 86 | fmt, code, reqtime, length = format_log_request(handler) 87 | LOGGER.log(REQ, fmt) 88 | return code, reqtime, length 89 | 90 | 91 | def format_log_rrequest(path, code, method, query, reqtime, headers, addr=''): 92 | """ Format current r-request from the given response 93 | 94 | :param response: The response returned from the request 95 | :return A tuple (fmt,code,reqtime,length) where: 96 | fmt: the log string 97 | code: the http retudn code 98 | reqtime: the request time 99 | length: the size of the payload 100 | """ 101 | length = -1 102 | try: 103 | length = headers['Content-Length'] 104 | except KeyError: 105 | pass 106 | 107 | request_id = headers.get('X-Request-Id') 108 | 109 | fmt = RREQ_FORMAT.format( 110 | ip=addr, 111 | method=method, 112 | url=f"{path.rstrip('/')}/{query}", 113 | code=code, 114 | time=int(1000.0 * reqtime), 115 | length=length, 116 | extra=f"REQ_ID:{request_id}" if request_id else '', 117 | ) 118 | 119 | return fmt 120 | 121 | 122 | def log_rrequest(*args, **kwargs): 123 | """ Log the current response request from the given response 124 | 125 | :return A tuple (code,reqtime,length) where: 126 | code: the http retudn code 127 | reqtime: the request time 128 | length: the size of the payload 129 | """ 130 | fmt = format_log_rrequest(*args, **kwargs) 131 | LOGGER.log(RREQ, fmt) 132 | -------------------------------------------------------------------------------- /pyqgisserver/management/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/3liz/py-qgis-server/ffdf4a3d8075c619adad768d42b1aef10d1257ee/pyqgisserver/management/__init__.py -------------------------------------------------------------------------------- /pyqgisserver/management/apis/__init__.py: -------------------------------------------------------------------------------- 1 | # 2 | # Copyright 2021 3liz 3 | # Author David Marteau 4 | # 5 | # This Source Code Form is subject to the terms of the Mozilla Public 6 | # License, v. 2.0. If a copy of the MPL was not distributed with this 7 | # file, You can obtain one at http://mozilla.org/MPL/2.0/. 8 | 9 | from pyqgisserver.management.apis import cache, plugins 10 | 11 | 12 | def register_management_apis(serverIface): 13 | """ Return management Qgis server apis 14 | """ 15 | plugins.register(serverIface) 16 | cache.register(serverIface) 17 | -------------------------------------------------------------------------------- /pyqgisserver/management/apis/cache.py: -------------------------------------------------------------------------------- 1 | # 2 | # Copyright 2021 3liz 3 | # Author David Marteau 4 | # 5 | # This Source Code Form is subject to the terms of the Mozilla Public 6 | # License, v. 2.0. If a copy of the MPL was not distributed with this 7 | # file, You can obtain one at http://mozilla.org/MPL/2.0/. 8 | 9 | """ 10 | Wrapper around more tornado-like request handler for 11 | implementing Qgis server API 12 | """ 13 | import logging 14 | 15 | from typing import Optional 16 | 17 | from tornado.web import HTTPError # F401 18 | 19 | from pyqgisserver.qgscache.cachemanager import ( 20 | get_cacheservice, 21 | get_project_summary, 22 | ) 23 | 24 | from .handler import RequestHandler, register_handlers 25 | 26 | LOGGER = logging.getLogger('SRVLOG') 27 | 28 | 29 | class CacheCollection(RequestHandler): 30 | 31 | def get(self, key: Optional[str] = None): # type: ignore [override] 32 | """ Return project cache info 33 | """ 34 | if not key: 35 | # Try to get key from param 36 | key = self.request.parameter('MAP') 37 | 38 | if not key: 39 | raise HTTPError(400, reason="Missing project specification") 40 | 41 | cache = get_cacheservice() 42 | project, _ = cache.lookup(key, refresh=False) 43 | 44 | self.write(get_project_summary(key, project)) 45 | 46 | 47 | def register(serverIface): 48 | """ Register plugins api handlers 49 | """ 50 | register_handlers(serverIface, "/cache", "CacheManagment", 51 | [ 52 | (r'/content/(?P.+)$', CacheCollection), 53 | (r'/', CacheCollection), 54 | ]) 55 | -------------------------------------------------------------------------------- /pyqgisserver/management/apis/plugins.py: -------------------------------------------------------------------------------- 1 | # 2 | # Copyright 2021 3liz 3 | # Author David Marteau 4 | # 5 | # This Source Code Form is subject to the terms of the Mozilla Public 6 | # License, v. 2.0. If a copy of the MPL was not distributed with this 7 | # file, You can obtain one at http://mozilla.org/MPL/2.0/. 8 | 9 | """ 10 | Wrapper around more tornado-like request handler for 11 | implementing Qgis server API 12 | """ 13 | import logging 14 | 15 | from typing import Optional 16 | 17 | from tornado.web import HTTPError # F401 18 | 19 | from pyqgisserver.plugins import failed_plugins, plugin_list, plugin_metadata 20 | 21 | from .handler import RequestHandler, register_handlers 22 | 23 | LOGGER = logging.getLogger('SRVLOG') 24 | 25 | 26 | class PluginCollection(RequestHandler): 27 | 28 | def get(self, name: Optional[str] = None): # type: ignore [override] 29 | """ Return plugin info 30 | """ 31 | if name: 32 | if name in failed_plugins: 33 | self.write({'name': name, 'error_log': failed_plugins[name], 'status': 'failed'}) 34 | return 35 | # Return plugin informations 36 | metadata = plugin_metadata(name) 37 | if not metadata: 38 | raise HTTPError(404) 39 | self.write({'name': name, 'status': 'loaded', 'metadata': metadata}) 40 | else: 41 | def _link(name, status): 42 | return { 43 | 'href': self.public_url(f"/{name}"), 44 | 'status': status, 45 | 'name': name, 46 | 'type': 'application/json', 47 | 'title': f'Details for plugin {name}', 48 | } 49 | # List all loaded plugins 50 | plugins = [_link(n, 'loaded') for n in plugin_list()] 51 | plugins.extend([_link(n, 'failed') for n in failed_plugins]) 52 | self.write({'links': plugins}) 53 | 54 | 55 | def register(serverIface): 56 | """ Register plugins api handlers 57 | """ 58 | register_handlers(serverIface, "/plugins", "PluginsManagment", 59 | [ 60 | (r'/(?P[^\/]+)/?$', PluginCollection), 61 | (r'/', PluginCollection), 62 | ]) 63 | -------------------------------------------------------------------------------- /pyqgisserver/middleware.py: -------------------------------------------------------------------------------- 1 | # 2 | # Copyright 2018 3liz 3 | # Author David Marteau 4 | # 5 | # This Source Code Form is subject to the terms of the Mozilla Public 6 | # License, v. 2.0. If a copy of the MPL was not distributed with this 7 | # file, You can obtain one at http://mozilla.org/MPL/2.0/. 8 | 9 | import logging 10 | import traceback 11 | 12 | from typing import List, NamedTuple, cast 13 | 14 | import tornado.web 15 | 16 | from tornado.httpserver import HTTPRequest 17 | from tornado.routing import Router 18 | from tornado.web import HTTPError 19 | 20 | from pyqgisservercontrib.core.filters import _FilterBase 21 | 22 | from .handlers import ErrorHandler 23 | 24 | HandlerDelegate = tornado.web._HandlerDelegate 25 | 26 | LOGGER = logging.getLogger('SRVLOG') 27 | 28 | 29 | class _Policy(NamedTuple): 30 | pri: int 31 | filters: List[_FilterBase] 32 | 33 | 34 | def load_access_policies() -> List[_Policy]: 35 | """ Create filter list 36 | """ 37 | collection = [] 38 | 39 | class policy_service: 40 | @staticmethod 41 | def add_filters(filters, pri=0): 42 | if LOGGER.isEnabledFor(logging.DEBUG): 43 | LOGGER.debug("Adding policy filter(s):\n%s", '\n'.join(str(f) for f in filters)) 44 | collection.append(_Policy(pri, [p for p in filters if isinstance(p, _FilterBase)])) 45 | 46 | import pyqgisservercontrib.core.componentmanager as cm 47 | cm.register_entrypoints('py_qgis_server.access_policy', policy_service) 48 | 49 | # Sort filters 50 | collection.sort(key=lambda p: p.pri, reverse=True) 51 | return collection 52 | 53 | 54 | class MiddleWareRouter(Router): 55 | 56 | def __init__(self, app: tornado.web.Application) -> None: 57 | """ Initialize middleware filters 58 | """ 59 | LOGGER.info("Initializing middleware filters") 60 | self.app = app 61 | self.policies = load_access_policies() 62 | 63 | def find_handler(self, request: HTTPRequest, **kwargs) -> HandlerDelegate: 64 | """ Define middleware prerocessing 65 | """ 66 | # Find matching paths 67 | for policy in self.policies: 68 | for filt in policy.filters: 69 | # Find the first filter that match the path 70 | match, path = filt.match(request.path) 71 | if match: 72 | LOGGER.debug("Found matching filter for %s -> %s:\n%s", request.path, path, filt) 73 | request.path = cast(str, path) 74 | try: 75 | filt.apply(request) 76 | break 77 | except HTTPError as err: 78 | kwargs = {'status_code': err.status_code, 'reason': err.reason} 79 | return self.app.get_handler_delegate(request, ErrorHandler, kwargs) 80 | except Exception: 81 | LOGGER.critical(traceback.format_exc()) 82 | return self.app.get_handler_delegate(request, ErrorHandler, {'status_code': 500}) 83 | 84 | return self.app.find_handler(request, **kwargs) 85 | -------------------------------------------------------------------------------- /pyqgisserver/monitor.py: -------------------------------------------------------------------------------- 1 | """ AMQP monitor for qgis requests 2 | """ 3 | import logging 4 | 5 | from typing import Optional 6 | 7 | from .config import confservice 8 | from .monitors.base import MonitorABC 9 | 10 | LOGGER = logging.getLogger('SRVLOG') 11 | 12 | 13 | class Monitor: 14 | 15 | @classmethod 16 | def instance(cls) -> Optional[MonitorABC]: 17 | 18 | if hasattr(cls, '_instance'): 19 | return cls._instance 20 | 21 | name = confservice.get('server', 'monitor', fallback=None) 22 | service = None 23 | if name: 24 | from pyqgisservercontrib.core import componentmanager as cm 25 | try: 26 | service = cm.load_entrypoint('py_qgis_server.monitors', name).initialize() 27 | setattr(cls, '_instance', service) 28 | LOGGER.info("Using '%s' monitor service", name) 29 | except cm.EntryPointNotFoundError: 30 | LOGGER.error("Failed to load monitor component: %s", name) 31 | 32 | return service 33 | -------------------------------------------------------------------------------- /pyqgisserver/monitors/amqp.py: -------------------------------------------------------------------------------- 1 | """ AMQP monitor for qgis requests 2 | """ 3 | import asyncio 4 | import json 5 | import logging 6 | import os 7 | import traceback 8 | 9 | from typing import Any, Dict, Optional 10 | 11 | from amqpclient.concurrent import AsyncPublisher 12 | from pika import PlainCredentials 13 | 14 | from ..config import confservice 15 | from .base import MonitorABC 16 | 17 | LOGGER = logging.getLogger('SRVLOG') 18 | 19 | 20 | def _read_credentials(vhost: str, user: str) -> Optional[PlainCredentials]: 21 | """ Read credentials from passfile 22 | """ 23 | credential_file = os.getenv("AMQPPASSFILE") 24 | if not (credential_file and os.path.exists(credential_file)): 25 | return None 26 | 27 | LOGGER.debug("Using passfile %s", credential_file) 28 | with open(credential_file) as fp: 29 | for line in fp.readlines(): 30 | credentials = line.strip() 31 | if credentials and not credentials.startswith('#'): 32 | cr_vhost, cr_user, passwd = credentials.split(':') 33 | if cr_vhost in ('*', vhost) and cr_user in ('*', user): 34 | LOGGER.info("Using credentials for user '%s' on vhost '%s'", user, vhost) 35 | return PlainCredentials(user, passwd) 36 | return None 37 | 38 | 39 | class Monitor(MonitorABC): 40 | 41 | def __init__(self, amqp_client: 'AsyncPublisher', 42 | routing_key: str, 43 | default_routing: Optional[str] = None) -> None: 44 | """ Init AMQP monitor 45 | """ 46 | super().__init__() 47 | 48 | self._client = amqp_client 49 | 50 | self._dynamic_routing = routing_key.startswith('@') 51 | if self._dynamic_routing: 52 | self._routing_key = routing_key[1:] 53 | self._default_routing = default_routing 54 | if not self._default_routing: 55 | LOGGER.warning("No default routing defined as fallback for dynamic routing") 56 | else: 57 | self._routing_key = routing_key 58 | 59 | def emit(self, params: Dict[str, Any], meta: Dict[str, str]) -> None: 60 | """ Publish monitor data 61 | """ 62 | if self._dynamic_routing: 63 | try: 64 | routing_key = self._routing_key.format(META=meta) 65 | except KeyError: 66 | # FALLBACK to default routing key 67 | if self._default_routing: 68 | routing_key = self._default_routing 69 | else: 70 | routing_key = self._routing_key 71 | 72 | # Send all params to our logger 73 | data = dict(self.global_tags, ROUTING_KEY=routing_key) 74 | data.update(params) 75 | log_msg = json.dumps(data) 76 | self._client.publish( 77 | log_msg, 78 | routing_key=routing_key, 79 | expiration=3000, 80 | content_type='application/json', 81 | content_encoding='utf-8', 82 | ) 83 | 84 | @classmethod 85 | def initialize(cls) -> Optional[MonitorABC]: 86 | """ Register an instance of Monitor client 87 | """ 88 | if hasattr(cls, '_instance'): 89 | return cls._instance 90 | 91 | conf = confservice['monitor:amqp'] 92 | routing_key = conf.get('routing_key') 93 | if not routing_key: 94 | return None 95 | 96 | hosts = conf['host'] 97 | user = conf['user'] 98 | vhost = conf['vhost'] 99 | port = conf['port'] 100 | 101 | reconnect_delay = conf.getint('reconnect_delay') 102 | 103 | kwargs = {} 104 | 105 | if user: 106 | credentials = _read_credentials(vhost, user) 107 | if credentials: 108 | kwargs['credentials'] = credentials 109 | 110 | exchange = conf['exchange'] 111 | 112 | client = AsyncPublisher(host=hosts, port=int(port), virtual_host=vhost, 113 | reconnect_delay=reconnect_delay, 114 | logger=LOGGER, **kwargs) 115 | 116 | # Catch exception in connection 117 | async def connect(): 118 | try: 119 | await client.connect(exchange=exchange, exchange_type='topic') 120 | LOGGER.info("AMQP logger initialized.") 121 | except Exception: 122 | LOGGER.error("Failed to initialize AMQP logger: %s", traceback.format_exc()) 123 | 124 | bckgnd_task = set() 125 | connect_task = asyncio.create_task(connect()) 126 | bckgnd_task.add(connect_task) 127 | connect_task.add_done_callback(bckgnd_task.discard) 128 | 129 | # Get default routing key in case as fallback in case 130 | # We fail to get dynamic key 131 | default_routing_key = conf.get('default_routing_key', fallback=None) 132 | 133 | inst = cls(client, routing_key, default_routing=default_routing_key) 134 | setattr(cls, '_instance', inst) 135 | return inst 136 | -------------------------------------------------------------------------------- /pyqgisserver/monitors/base.py: -------------------------------------------------------------------------------- 1 | """ Monitor utilities 2 | """ 3 | import os 4 | 5 | from abc import ABC, abstractmethod 6 | from typing import ( 7 | Any, 8 | Dict, 9 | Iterator, 10 | Tuple, 11 | ) 12 | 13 | TAG_PREFIX_LEGACY = 'AMQP_GLOBAL_TAG_' 14 | TAG_PREFIX = 'QGSRV_MONITOR_TAG_' 15 | 16 | 17 | def _get_tags(prefix: str) -> Iterator[Tuple[str, str]]: 18 | return ((e.partition(prefix)[2], os.environ[e]) for e in os.environ if e.startswith(prefix)) 19 | 20 | 21 | class MonitorABC(ABC): 22 | 23 | def __init__(self): 24 | """ Return tags defined in environment 25 | """ 26 | # Get global tags 27 | tags = {t: v for (t, v) in _get_tags(TAG_PREFIX) if t} 28 | tags.update((t, v) for (t, v) in _get_tags(TAG_PREFIX_LEGACY) if t) 29 | self.global_tags = tags 30 | 31 | @abstractmethod 32 | def emit(self, params: Dict[str, Any], meta: Dict[str, str]) -> None: 33 | raise NotImplementedError("Subclasses must implement this") 34 | -------------------------------------------------------------------------------- /pyqgisserver/monitors/test.py: -------------------------------------------------------------------------------- 1 | # 2 | # Copyright 2022 3liz 3 | # Author David Marteau 4 | # 5 | # This Source Code Form is subject to the terms of the Mozilla Public 6 | # License, v. 2.0. If a copy of the MPL was not distributed with this 7 | # file, You can obtain one at http://mozilla.org/MPL/2.0/. 8 | # 9 | """ Store monitoring data in memory table 10 | 11 | Used for testing purposes 12 | DO NOT USE IN PRODUCTION 13 | """ 14 | import logging 15 | 16 | from typing import Dict 17 | 18 | from .base import MonitorABC 19 | 20 | LOGGER = logging.getLogger('SRVLOG') 21 | 22 | 23 | class Monitor(MonitorABC): 24 | 25 | def __init__(self): 26 | super().__init__() 27 | 28 | self.messages = [] 29 | 30 | def emit(self, params: Dict[str, str], meta: Dict[str, str]) -> None: 31 | """ Publish monitor data 32 | """ 33 | data = dict(self.global_tags) 34 | data.update(params) 35 | self.messages.append((data, meta)) 36 | 37 | 38 | _instance = Monitor() 39 | 40 | # Entrypoint 41 | 42 | 43 | def initialize() -> Monitor: 44 | return _instance 45 | -------------------------------------------------------------------------------- /pyqgisserver/plugins.py: -------------------------------------------------------------------------------- 1 | # 2 | # Copyright 2018 3liz 3 | # Author David Marteau 4 | # 5 | # This Source Code Form is subject to the terms of the Mozilla Public 6 | # License, v. 2.0. If a copy of the MPL was not distributed with this 7 | # file, You can obtain one at http://mozilla.org/MPL/2.0/. 8 | 9 | """ QGIS server plugin management 10 | 11 | """ 12 | import configparser 13 | import logging 14 | import sys 15 | import traceback 16 | 17 | from pathlib import Path 18 | from typing import Dict, Generator, Optional, TypeVar, Union 19 | 20 | from .config import confservice 21 | 22 | LOGGER = logging.getLogger('SRVLOG') 23 | 24 | server_plugins = {} 25 | failed_plugins = {} 26 | 27 | 28 | def checkQgisVersion(minver: Optional[str], maxver: Optional[str]) -> bool: 29 | from qgis.core import Qgis 30 | 31 | def to_int(ver): 32 | major, *ver = ver.split('.') 33 | major = int(major) 34 | minor = int(ver[0]) if len(ver) > 0 else 0 35 | rev = int(ver[1]) if len(ver) > 1 else 0 36 | if minor >= 99: 37 | minor = rev = 0 38 | major += 1 39 | if rev > 99: 40 | rev = 99 41 | return int(f"{major:d}{minor:02d}{rev:02d}") 42 | 43 | version = to_int(Qgis.QGIS_VERSION.split('-')[0]) 44 | minver = to_int(minver) if minver else version 45 | maxver = to_int(maxver) if maxver else version 46 | 47 | return minver <= version <= maxver 48 | 49 | 50 | def find_plugins(path: str) -> Generator[str, None, None]: 51 | """ return list of plugins in given path 52 | """ 53 | path = Path(path) 54 | for plugin in path.glob("*"): 55 | LOGGER.debug(f"Looking for plugin in '{plugin}'") 56 | 57 | if not plugin.is_dir(): 58 | # Warn about dangling symlink 59 | # This occurs when running in docker container 60 | # and symlink target path are not visible from the 61 | # container - give some hint for debugging 62 | if plugin.is_symlink(): 63 | LOGGER.warning(f"*** The symbolic link '{plugin}' is not resolved." 64 | " If you are running in docker container please consider" 65 | "mounting the target path in the container.") 66 | continue 67 | 68 | metadata_file = plugin / 'metadata.txt' 69 | if not metadata_file.exists(): 70 | # Do not log here 71 | continue 72 | 73 | if not (plugin / '__init__.py').exists(): 74 | LOGGER.warning(f"'{plugin}' : Found metadata file but no Python entry point !") 75 | continue 76 | 77 | cp = configparser.ConfigParser() 78 | 79 | try: 80 | with metadata_file.open(mode='rt') as f: 81 | cp.read_file(f) 82 | 83 | if not cp['general'].getboolean('server'): 84 | LOGGER.warning(f"'{plugin}' is not a server plugin") 85 | continue 86 | 87 | min_ver = cp['general'].get('qgisMinimumVersion') 88 | max_ver = cp['general'].get('qgisMaximumVersion') 89 | 90 | except Exception as exc: 91 | LOGGER.error(f"'{plugin}' : Error reading plugin metadata '{metadata_file}': {exc}") 92 | continue 93 | 94 | if not checkQgisVersion(min_ver, max_ver): 95 | LOGGER.warning(f"Unsupported version for {plugin}. Discarding") 96 | continue 97 | 98 | yield plugin.name 99 | 100 | 101 | QgsServerInterface = TypeVar('QgsServerInterface') 102 | 103 | 104 | def load_plugins(serverIface: QgsServerInterface): 105 | """ Start all plugins """ 106 | 107 | plugin_path = confservice.get('server', 'pluginpath') 108 | if not plugin_path: 109 | return 110 | 111 | LOGGER.info(f"Initializing plugins from {plugin_path}") 112 | sys.path.append(plugin_path) 113 | 114 | success = 0 115 | error = 0 116 | for plugin in find_plugins(plugin_path): 117 | # noinspection PyBroadException 118 | try: 119 | __import__(plugin) 120 | 121 | package = sys.modules[plugin] 122 | 123 | # Mark package as loaded by py_qgis_server 124 | # allow plugins to check if it has been loaded by py-qgis-server 125 | package._is_py_qgis_server = True # type: ignore [attr-defined] 126 | 127 | # Initialize the plugin 128 | server_plugins[plugin] = package.serverClassFactory(serverIface) 129 | 130 | LOGGER.info(f"Loaded plugin {plugin}") 131 | success += 1 132 | except Exception: 133 | strace = traceback.format_exc() 134 | LOGGER.error(f"Error loading plugin '{plugin}'\n{strace}") 135 | failed_plugins[plugin] = strace 136 | error += 1 137 | 138 | LOGGER.info(f"Loaded {success} plugin(s) successfully") 139 | if error: 140 | LOGGER.warning(f"{error} plugin(s) having an issue") 141 | 142 | 143 | def plugin_metadata(plugin: str) -> Dict: 144 | """ Return plugin metadata 145 | """ 146 | if plugin not in server_plugins: 147 | return {} 148 | 149 | # Read metadata 150 | path = Path(sys.modules[plugin].__file__ or '/i_dot_not_exists') 151 | metadatafile = path.parent / 'metadata.txt' 152 | if not metadatafile.exists(): 153 | return {} 154 | 155 | with metadatafile.open(mode='rt') as f: 156 | cp = configparser.ConfigParser() 157 | cp.read_file(f) 158 | 159 | metadata: Dict[str, Union[str, Dict[str, str]]] = {s: dict(p.items()) for s, p in cp.items()} 160 | metadata.pop('DEFAULT', None) 161 | metadata.update(path=str(path)) 162 | return metadata 163 | 164 | 165 | def plugin_list(): 166 | """ Iterate over loaded plugins 167 | """ 168 | return (k for k in server_plugins.keys()) 169 | -------------------------------------------------------------------------------- /pyqgisserver/qgscache/__init__.py: -------------------------------------------------------------------------------- 1 | from .types import UpdateState # noqa 2 | -------------------------------------------------------------------------------- /pyqgisserver/qgscache/handlers/__init__.py: -------------------------------------------------------------------------------- 1 | # 2 | # Copyright 2020 3liz 3 | # Author David Marteau 4 | # 5 | # This Source Code Form is subject to the terms of the Mozilla Public 6 | # License, v. 2.0. If a copy of the MPL was not distributed with this 7 | # file, You can obtain one at http://mozilla.org/MPL/2.0/. 8 | 9 | from .proto import ProtocolHandler # noqa F401 10 | from .file_handler import * # noqa: F403 11 | from .postgres_handler import * # noqa: F403 12 | 13 | __all__ = [] # type: ignore [var-annotated] 14 | -------------------------------------------------------------------------------- /pyqgisserver/qgscache/handlers/file_handler.py: -------------------------------------------------------------------------------- 1 | # 2 | # Copyright 2020 3liz 3 | # Author David Marteau 4 | # 5 | # This Source Code Form is subject to the terms of the Mozilla Public 6 | # License, v. 2.0. If a copy of the MPL was not distributed with this 7 | # file, You can obtain one at http://mozilla.org/MPL/2.0/. 8 | 9 | """ File protocol handler 10 | """ 11 | import logging 12 | import urllib.parse 13 | 14 | from datetime import datetime 15 | from pathlib import Path 16 | from typing import Optional, Tuple 17 | 18 | from qgis.core import QgsProject 19 | 20 | from pyqgisservercontrib.core import componentmanager 21 | 22 | LOGGER = logging.getLogger('SRVLOG') 23 | 24 | ALLOWED_SFX = ('.qgs', '.qgz') 25 | 26 | __all__ = [] # type: ignore [var-annotated] 27 | 28 | 29 | @componentmanager.register_factory('@3liz.org/cache/protocol-handler;1?scheme=file') 30 | class FileProtocolHandler: 31 | """ Handle file protocol 32 | """ 33 | 34 | def __init__(self): 35 | pass 36 | 37 | def _check_file(self, path: Path) -> Optional[Path]: 38 | """ 39 | """ 40 | if not path.is_absolute(): 41 | raise ValueError(f"file path must be absolute not {path}") 42 | 43 | exists = False 44 | if path.suffix not in ALLOWED_SFX: 45 | for sfx in ALLOWED_SFX: 46 | path = path.with_suffix(sfx) 47 | exists = path.is_file() 48 | if exists: 49 | break 50 | else: 51 | exists = path.is_file() 52 | 53 | return path if exists else None 54 | 55 | def get_modified_time(self, url: urllib.parse.ParseResult) -> datetime: 56 | """ Return the modified date time of the project referenced by its url 57 | """ 58 | path = self._check_file(Path(url.path)) 59 | if not path: 60 | raise FileNotFoundError(url.path) 61 | return datetime.fromtimestamp(path.stat().st_mtime) 62 | 63 | def get_project( 64 | self, 65 | url: Optional[urllib.parse.ParseResult], 66 | project: Optional[QgsProject] = None, 67 | timestamp: Optional[datetime] = None, 68 | ) -> Tuple[QgsProject, datetime]: 69 | """ Create or return a proect 70 | """ 71 | if url: 72 | path = self._check_file(Path(url.path)) 73 | elif project: 74 | path = self._check_file(project.fileName()) 75 | else: 76 | raise ValueError('Cannot get path from arguments') 77 | 78 | if not path: 79 | LOGGER.error("File protocol handler: File not found: %s", str(path)) 80 | raise FileNotFoundError(str(path)) 81 | 82 | modified_time = datetime.fromtimestamp(path.stat().st_mtime) 83 | if timestamp is None or timestamp < modified_time: 84 | cachmngr = componentmanager.get_service('@3liz.org/cache-manager;1') 85 | project = cachmngr.read_project(str(path)) 86 | timestamp = modified_time 87 | 88 | return project, timestamp 89 | -------------------------------------------------------------------------------- /pyqgisserver/qgscache/handlers/proto.py: -------------------------------------------------------------------------------- 1 | 2 | import urllib.parse 3 | 4 | from datetime import datetime 5 | from typing import Optional, Protocol, Tuple 6 | 7 | from qgis.core import QgsProject 8 | 9 | 10 | class ProtocolHandler(Protocol): 11 | """ Protocol for Handle file protocol 12 | """ 13 | 14 | def get_modified_time(self, url: urllib.parse.ParseResult) -> datetime: 15 | """ Return the modified date time of the project referenced by its url 16 | """ 17 | ... 18 | 19 | def get_project( 20 | self, 21 | url: Optional[urllib.parse.ParseResult], 22 | project: Optional[QgsProject] = None, 23 | timestamp: Optional[datetime] = None, 24 | ) -> Tuple[QgsProject, datetime]: 25 | """ Create or return a proect 26 | """ 27 | ... 28 | -------------------------------------------------------------------------------- /pyqgisserver/qgscache/observers/ban.py: -------------------------------------------------------------------------------- 1 | # Copyright 2022 3liz 2 | # Author David Marteau 3 | # 4 | # This Source Code Form is subject to the terms of the Mozilla Public 5 | # License, v. 2.0. If a copy of the MPL was not distributed with this 6 | # file, You can obtain one at http://mozilla.org/MPL/2.0/. 7 | 8 | 9 | """ Cache Observer that send a BAN request 10 | """ 11 | import asyncio 12 | import logging 13 | 14 | from datetime import datetime 15 | from typing import Union, cast 16 | 17 | from tornado.httpclient import AsyncHTTPClient, HTTPRequest 18 | 19 | from pyqgisserver.config import confservice 20 | 21 | LOGGER = logging.getLogger('SRVLOG') 22 | 23 | server_address: str 24 | http_client: Union[AsyncHTTPClient, None] = None 25 | 26 | 27 | def init() -> None: 28 | """ 29 | """ 30 | LOGGER.debug("*** Initializing ban observer") 31 | confservice.add_section('cache.observers:ban') 32 | 33 | global server_address, http_client 34 | server_address = confservice.get('cache.observers:ban', 'server_address') 35 | http_client = AsyncHTTPClient() 36 | 37 | LOGGER.debug("*** Ban observer: sending_request to %s", server_address) 38 | 39 | 40 | async def ban(key: str) -> None: 41 | """ Ban key 42 | """ 43 | LOGGER.info("Sending BAN request to %s", server_address) 44 | 45 | request = HTTPRequest( 46 | cast(str, server_address), 47 | method='BAN', 48 | headers={'X-Map-Id': key}, 49 | user_agent="py-qgis-server; ban observer", 50 | allow_nonstandard_methods=True, 51 | ) 52 | 53 | response = await cast(AsyncHTTPClient, http_client).fetch(request, raise_error=False) 54 | if response.code != 200: 55 | LOGGER.error("Ban server returned status code %s", response.code) 56 | 57 | 58 | def observe(key: str, datetime: datetime, inserted: bool) -> None: 59 | background_tasks = set() 60 | task = asyncio.create_task(ban(key)) 61 | background_tasks.add(task) 62 | task.add_done_callback(background_tasks.discard) 63 | -------------------------------------------------------------------------------- /pyqgisserver/qgscache/observers/test.py: -------------------------------------------------------------------------------- 1 | # Copyright 2022 3liz 2 | # Author David Marteau 3 | # 4 | # This Source Code Form is subject to the terms of the Mozilla Public 5 | # License, v. 2.0. If a copy of the MPL was not distributed with this 6 | # file, You can obtain one at http://mozilla.org/MPL/2.0/. 7 | 8 | 9 | """ Cache Observer used for testing 10 | """ 11 | import logging 12 | 13 | from datetime import datetime 14 | 15 | LOGGER = logging.getLogger('SRVLOG') 16 | 17 | notify_data = {} 18 | 19 | 20 | def init() -> None: 21 | pass 22 | 23 | 24 | def observe(key: str, datetime: datetime, insert: bool) -> None: 25 | LOGGER.debug("*** TEST CACHE OBSERVER: Received update notification" 26 | "for %s %s [Inserted: %s]", key, datetime, insert) 27 | notify_data[key] = (key, datetime, insert) 28 | -------------------------------------------------------------------------------- /pyqgisserver/qgscache/types.py: -------------------------------------------------------------------------------- 1 | # 2 | # Copyright 2020 3liz 3 | # Author David Marteau 4 | # 5 | # This Source Code Form is subject to the terms of the Mozilla Public 6 | # License, v. 2.0. If a copy of the MPL was not distributed with this 7 | # file, You can obtain one at http://mozilla.org/MPL/2.0/. 8 | 9 | """ Common cache types 10 | """ 11 | 12 | from enum import IntEnum 13 | 14 | 15 | class UpdateState(IntEnum): 16 | UNCHANGED = 0 17 | INSERTED = 1 18 | UPDATED = 2 19 | -------------------------------------------------------------------------------- /pyqgisserver/server.py: -------------------------------------------------------------------------------- 1 | # 2 | # Copyright 2018 3liz 3 | # Author David Marteau 4 | # 5 | # This Source Code Form is subject to the terms of the Mozilla Public 6 | # License, v. 2.0. If a copy of the MPL was not distributed with this 7 | # file, You can obtain one at http://mozilla.org/MPL/2.0/. 8 | 9 | import argparse 10 | import logging 11 | import os 12 | import sys 13 | 14 | from typing import ( 15 | List, 16 | Optional, 17 | ) 18 | 19 | from .config import ( 20 | confservice, 21 | load_configuration, 22 | read_config_file, 23 | validate_config_path, 24 | ) 25 | from .logger import setup_log_handler 26 | from .runtime import run_server 27 | from .utils.qgis import print_qgis_version 28 | from .version import __description__, __manifest__ 29 | 30 | LOGGER = logging.getLogger('SRVLOG') 31 | 32 | 33 | def print_version(verbose: bool = False): 34 | """ Display version infos 35 | """ 36 | m = __manifest__ 37 | program = os.path.basename(sys.argv[0]) 38 | print( # noqa: T201 39 | f"{program} {m['version']} " 40 | f"(build {m['buildid']}, commit {m['commitid']})", 41 | ) 42 | print_qgis_version(verbose=verbose) 43 | 44 | 45 | def read_configuration(argv: Optional[List[str]] = None) -> argparse.Namespace: 46 | """ Parse command line and read configuration file 47 | """ 48 | if argv is None: 49 | argv = sys.argv[1:] 50 | 51 | cli_parser = argparse.ArgumentParser(description=__description__) 52 | 53 | cli_parser.add_argument( 54 | '-d', 55 | '--debug', 56 | action='store_true', 57 | default=False, 58 | help="debug mode", 59 | ) 60 | cli_parser.add_argument( 61 | '-c', 62 | '--config', 63 | metavar='PATH', 64 | nargs='?', 65 | dest='config', 66 | default=None, 67 | help="Configuration file", 68 | ) 69 | cli_parser.add_argument( 70 | '--version', 71 | action='store_true', 72 | default=False, 73 | help="Return version infos and exit", 74 | ) 75 | cli_parser.add_argument( 76 | '-p', 77 | '--port', 78 | type=int, 79 | help="http port", 80 | dest='port', 81 | default=argparse.SUPPRESS, 82 | ) 83 | cli_parser.add_argument( 84 | '-b', 85 | '--bind', 86 | metavar='IP', 87 | default=argparse.SUPPRESS, 88 | help="interface to bind to", dest='interfaces', 89 | ) 90 | cli_parser.add_argument( 91 | '-w', 92 | '--workers', 93 | metavar='NUM', 94 | type=int, 95 | default=argparse.SUPPRESS, 96 | help="num workers", 97 | dest='workers', 98 | ) 99 | cli_parser.add_argument( 100 | '-u', 101 | '--setuid', 102 | default='', 103 | help="uid to switch to", 104 | dest='setuid', 105 | ) 106 | cli_parser.add_argument( 107 | '--rootdir', 108 | default=argparse.SUPPRESS, 109 | metavar='PATH', 110 | help='path to qgis projects', 111 | ) 112 | cli_parser.add_argument( 113 | '--proxy', 114 | action='store_true', 115 | default=False, 116 | help='run only as proxy', 117 | ) 118 | 119 | args = cli_parser.parse_args(argv) 120 | 121 | if args.version: 122 | print_version(verbose=args.debug) 123 | sys.exit(1) 124 | else: 125 | print_version() 126 | 127 | load_configuration() 128 | 129 | if args.config: 130 | with open(args.config) as config_file: 131 | read_config_file(config_file) 132 | 133 | # Override config 134 | def set_arg(section: str, name: str) -> None: 135 | if name in args: 136 | confservice.set(section, name, str(getattr(args, name))) 137 | 138 | set_arg('projects.cache', 'rootdir') 139 | set_arg('server', 'interfaces') 140 | set_arg('server', 'port') 141 | set_arg('server', 'workers') 142 | 143 | if args.debug: 144 | # Force debug mode 145 | confservice.set('logging', 'level', 'DEBUG') 146 | 147 | # set log level 148 | setup_log_handler(confservice.get('logging', 'level')) 149 | print(f"Log level set to {logging.getLevelName(LOGGER.level)}\n", file=sys.stderr) # noqa: T201 150 | 151 | conf = confservice['server'] 152 | args.port = conf.getint('port') 153 | args.workers = conf.getint('workers') 154 | args.interfaces = conf.get('interfaces') 155 | 156 | return args 157 | 158 | 159 | def main() -> None: 160 | """ Run the server as cli command 161 | """ 162 | args = read_configuration() 163 | 164 | workers = args.workers 165 | if not args.proxy: 166 | validate_config_path('projects.cache', 'rootdir') 167 | else: 168 | # Do not run any qgis workers 169 | workers = 0 170 | 171 | run_server(port=args.port, address=args.interfaces, user=args.setuid, workers=workers) 172 | -------------------------------------------------------------------------------- /pyqgisserver/stats.py: -------------------------------------------------------------------------------- 1 | # 2 | # Copyright 2021 3liz 3 | # Author David Marteau 4 | # 5 | # This Source Code Form is subject to the terms of the Mozilla Public 6 | # License, v. 2.0. If a copy of the MPL was not distributed with this 7 | # file, You can obtain one at http://mozilla.org/MPL/2.0/. 8 | 9 | """ 10 | Collect global stats 11 | """ 12 | from datetime import datetime, timedelta 13 | from time import time 14 | 15 | 16 | class Stats: 17 | 18 | def __init__(self) -> None: 19 | self.reset() 20 | 21 | def reset(self) -> None: 22 | self.num_requests = 0 23 | self.num_errors = 0 24 | self.start_time = time() 25 | 26 | def json(self): 27 | """ Return a json payload 28 | """ 29 | return dict( 30 | num_request=self.num_requests, 31 | num_errors=self.num_errors, 32 | start_date=datetime.fromtimestamp(self.start_time).isoformat(), 33 | uptime=timedelta(seconds=time() - self.start_time).total_seconds(), 34 | ) 35 | -------------------------------------------------------------------------------- /pyqgisserver/utils/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/3liz/py-qgis-server/ffdf4a3d8075c619adad768d42b1aef10d1257ee/pyqgisserver/utils/__init__.py -------------------------------------------------------------------------------- /pyqgisserver/utils/decorators.py: -------------------------------------------------------------------------------- 1 | # 2 | # Copyright 2018 3liz 3 | # Author David Marteau 4 | # 5 | # This Source Code Form is subject to the terms of the Mozilla Public 6 | # License, v. 2.0. If a copy of the MPL was not distributed with this 7 | # file, You can obtain one at http://mozilla.org/MPL/2.0/. 8 | 9 | """ 10 | Define various decarators for: 11 | 12 | singleton, 13 | lazy properties, 14 | cached function evaluation 15 | """ 16 | 17 | _missing = object() 18 | 19 | __all__ = ['lazy_property', 'once', 'singleton'] 20 | 21 | 22 | class singleton: 23 | """ Decorator for defining a singleton-like object instanciated at the first call """ 24 | 25 | def __init__(self, decorated): 26 | self._decorated = decorated 27 | 28 | def __call__(self, *args, **kwargs): 29 | try: 30 | return self._instance 31 | except AttributeError: 32 | self._instance = self._decorated(*args, **kwargs) 33 | return self._instance 34 | 35 | 36 | class once: 37 | """ Evaluate the function only once """ 38 | 39 | def __init__(self, f): 40 | self.f = f 41 | 42 | def __call__(self, *args, **kwargs): 43 | if not hasattr(self, "value"): 44 | self.value = self.f(*args, **kwargs) 45 | return self.value 46 | 47 | 48 | class lazy_property: 49 | """ A decorator that converts a function into a lazy property. The 50 | function wrapped is called the first time to retrieve the result 51 | and then that calculated result is used the next time you access 52 | the value:: 53 | 54 | class Foo(object): 55 | 56 | @lazy_property 57 | def foo(self): 58 | # calculate something important here 59 | return 42 60 | 61 | The class has to have a `__dict__` in order for this property to 62 | work. 63 | """ 64 | 65 | # implementation detail: this property is implemented as non-data 66 | # descriptor. non-data descriptors are only invoked if there is 67 | # no entry with the same name in the instance's __dict__. 68 | # this allows us to completely get rid of the access function call 69 | # overhead. If one choses to invoke __get__ by hand the property 70 | # will still work as expected because the lookup logic is replicated 71 | # in __get__ for manual invocation. 72 | 73 | def __init__(self, func, name=None, doc=None): 74 | self.__name__ = name or func.__name__ 75 | self.__module__ = func.__module__ 76 | self.__doc__ = doc or func.__doc__ 77 | self.func = func 78 | 79 | def __get__(self, obj, klass=None): 80 | if obj is None: 81 | return self 82 | value = obj.__dict__.get(self.__name__, _missing) 83 | if value is _missing: 84 | value = self.func(obj) 85 | obj.__dict__[self.__name__] = value 86 | return value 87 | -------------------------------------------------------------------------------- /pyqgisserver/utils/lru.py: -------------------------------------------------------------------------------- 1 | # 2 | # Copyright 2018 3liz 3 | # 4 | # This Source Code Form is subject to the terms of the Mozilla Public 5 | # License, v. 2.0. If a copy of the MPL was not distributed with this 6 | # file, You can obtain one at http://mozilla.org/MPL/2.0/. 7 | 8 | """ Simple LRU cache implementation based on Ordered dict 9 | """ 10 | from collections import OrderedDict 11 | from typing import ( 12 | Generic, 13 | Hashable, 14 | Iterator, 15 | Optional, 16 | Tuple, 17 | TypeVar, 18 | ) 19 | 20 | V = TypeVar('V') 21 | K = TypeVar('K', bound=Hashable) 22 | 23 | 24 | class lrucache(Generic[K, V]): 25 | 26 | def __init__(self, size: int) -> None: 27 | self._table = OrderedDict[K, V]() 28 | self._capacity = size 29 | 30 | # Adjust the size 31 | self.size(size) 32 | 33 | def __len__(self) -> int: 34 | return len(self._table) 35 | 36 | def clear(self) -> None: 37 | self._table.clear() 38 | 39 | def __contains__(self, key: K) -> bool: 40 | return key in self._table 41 | 42 | def peek(self, key: K) -> Optional[V]: 43 | """ Looks up a value in the cache without affecting cache order 44 | 45 | Return None if the key doesn't exists 46 | """ 47 | # Look up the node 48 | return self._table.get(key) 49 | 50 | def __getitem__(self, key: K) -> V: 51 | """ Look up the node 52 | """ 53 | # Update the list ordering 54 | self._table.move_to_end(key) 55 | return self._table[key] 56 | 57 | def __setitem__(self, key: K, value: V) -> None: 58 | """ Define a dict like setter 59 | """ 60 | # First, see if any value is stored under 'key' in the cache already. 61 | # If so we are going to replace that value with the new one. 62 | if key in self._table: 63 | del self._table[key] 64 | 65 | # Keep size 66 | while len(self._table) >= self._capacity: 67 | self._table.popitem(last=False) 68 | 69 | self._table.__setitem__(key, value) 70 | 71 | def __delitem__(self, key: K) -> None: 72 | """ Remove from _ 73 | """ 74 | del self._table[key] 75 | 76 | def __iter__(self) -> Iterator[K]: 77 | """ Return an iterator that returns the keys in the cache. 78 | 79 | Values are returned in order from the most recently to least recently used. 80 | Does not modify the cache order. 81 | 82 | Make the cache behaves like a dictionary 83 | """ 84 | return reversed(self._table.keys()) 85 | 86 | def items(self) -> Iterator[Tuple[K, V]]: 87 | """ Return an iterator that returns the (key, value) pairs in the cache. 88 | 89 | Items are returned in order from the most recently to least recently used. 90 | Does not modify the cache order. 91 | """ 92 | return reversed(self._table.items()) 93 | 94 | def keys(self) -> Iterator[K]: 95 | """ Return an iterator that returns the keys in the cache. 96 | 97 | Keys are returned in order from the most recently to least recently used. 98 | Does not modify the cache order. 99 | """ 100 | return reversed(self._table.keys()) 101 | 102 | def values(self) -> Iterator[V]: 103 | """ Return an iterator that returns the values in the cache. 104 | 105 | Values are returned in order from the most recently to least recently used. 106 | Does not modify the cache order. 107 | """ 108 | return reversed(self._table.values()) 109 | 110 | def size(self, size: Optional[int] = None) -> int: 111 | """ Set the size of the cache 112 | 113 | :param int size: maximum number of elements in the cache 114 | """ 115 | if size is not None: 116 | assert size > 0 117 | if size < self._capacity: 118 | d = self._table 119 | # Remove extra items 120 | while len(d) > size: 121 | d.popitem(last=False) 122 | self._capacity = size 123 | 124 | return self._capacity 125 | -------------------------------------------------------------------------------- /pyqgisserver/utils/stats.py: -------------------------------------------------------------------------------- 1 | # 2 | # Copyright 2021 3liz 3 | # Author David Marteau 4 | # 5 | # This Source Code Form is subject to the terms of the Mozilla Public 6 | # License, v. 2.0. If a copy of the MPL was not distributed with this 7 | # file, You can obtain one at http://mozilla.org/MPL/2.0/. 8 | 9 | from typing import Dict, Optional 10 | 11 | try: 12 | import psutil 13 | HAVE_PSUTIL = True 14 | except Exception: 15 | print("WARNING: Failed to load 'PSutil', system metrics will not be collected") # noqa T201 16 | HAVE_PSUTIL = False 17 | 18 | 19 | def stats(pid: Optional[int] = None) -> Dict: 20 | """ Collect stats about process 21 | see https://psutil.readthedocs.io/en/latest/#processes 22 | """ 23 | if not HAVE_PSUTIL: 24 | return {} 25 | 26 | proc = psutil.Process(pid) 27 | return dict( 28 | mem_usage=proc.memory_info().rss, 29 | mem_percent=proc.memory_percent(), 30 | ) 31 | -------------------------------------------------------------------------------- /pyqgisserver/version.py: -------------------------------------------------------------------------------- 1 | # 2 | # Copyright 2018 3liz 3 | # 4 | # This Source Code Form is subject to the terms of the Mozilla Public 5 | # License, v. 2.0. If a copy of the MPL was not distributed with this 6 | # file, You can obtain one at http://mozilla.org/MPL/2.0/. 7 | 8 | import sys 9 | 10 | from typing import Dict 11 | 12 | 13 | def read_manifest() -> Dict: 14 | from pkg_resources import resource_stream 15 | 16 | # Read build manifest 17 | manifest = {'commitid': 'n/a', 'buildid': 'n/a', 'version': 'n/a'} 18 | try: 19 | with resource_stream('pyqgisserver', 'build.manifest') as stream: 20 | manifest.update(line.decode().strip().split('=')[:2] for line in stream.readlines()) 21 | except Exception as e: 22 | print("WARNING: Failed to read manifest ! %s " % e, file=sys.stderr) # noqa: T201 23 | return manifest 24 | 25 | 26 | __manifest__ = read_manifest() 27 | 28 | __version__ = __manifest__['version'] 29 | __description__ = "Qgis/HTTP/0MQ scalable server" 30 | -------------------------------------------------------------------------------- /pyqgisserver/zeromq/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/3liz/py-qgis-server/ffdf4a3d8075c619adad768d42b1aef10d1257ee/pyqgisserver/zeromq/__init__.py -------------------------------------------------------------------------------- /pyqgisserver/zeromq/messages.py: -------------------------------------------------------------------------------- 1 | # 2 | # Copyright 2018 3liz 3 | # Author David Marteau 4 | # 5 | # This Source Code Form is subject to the terms of the Mozilla Public 6 | # License, v. 2.0. If a copy of the MPL was not distributed with this 7 | # file, You can obtain one at http://mozilla.org/MPL/2.0/. 8 | 9 | from typing import Dict, Mapping, NamedTuple, Optional 10 | 11 | WORKER_READY = b"ready" 12 | 13 | # Message structure 14 | 15 | 16 | class RequestMessage(NamedTuple): 17 | query: str 18 | headers: Mapping[str, str] 19 | method: str 20 | data: Optional[bytes] 21 | 22 | 23 | class ReplyMessage(NamedTuple): 24 | status: int 25 | headers: Dict[str, str] 26 | data: bytes 27 | meta: Optional[Dict[str, str]] 28 | -------------------------------------------------------------------------------- /pyqgisserver/zeromq/pool.py: -------------------------------------------------------------------------------- 1 | # 2 | # Copyright 2020 3liz 3 | # Author David Marteau 4 | # 5 | # This Source Code Form is subject to the terms of the Mozilla Public 6 | # License, v. 2.0. If a copy of the MPL was not distributed with this 7 | # file, You can obtain one at http://mozilla.org/MPL/2.0/. 8 | 9 | """ Pool server 10 | """ 11 | import logging 12 | import os 13 | import signal 14 | import time 15 | 16 | from multiprocessing import Process 17 | from multiprocessing.util import Finalize 18 | 19 | from typing_extensions import Callable, Dict, List, Sequence 20 | 21 | # Early failure min delay 22 | # If any process fail before that starting delay 23 | # we abort the whole process 24 | EARLY_FAILURE_DELAY = 5 25 | 26 | LOGGER = logging.getLogger('SRVLOG') 27 | 28 | 29 | class Pool: 30 | 31 | def __init__( 32 | self, 33 | num_workers: int, 34 | target: Callable, 35 | args: Sequence = (), 36 | kwargs: Dict = {}, 37 | ): 38 | 39 | self.critical_failure = False 40 | 41 | self._num_workers = num_workers 42 | self._pool: List[Process] = [] 43 | self._args = args 44 | self._kwargs = kwargs 45 | self._target = target 46 | self._start_time = time.time() 47 | 48 | # Ensure that pool is terminated is called 49 | # at process exit 50 | self._terminate = Finalize( 51 | self, self._terminate_pool, 52 | args=(self._pool,), 53 | exitpriority=15, 54 | ) 55 | 56 | self._repopulate_pool() 57 | 58 | def _join_exited_workers(self) -> bool: 59 | """Cleanup after any worker processes which have exited due to reaching 60 | their specified lifetime. 61 | 62 | Returns True if any workers were cleaned up. 63 | """ 64 | cleaned = False 65 | for i in reversed(range(len(self._pool))): 66 | worker = self._pool[i] 67 | if worker.exitcode is not None: 68 | # Handle special case when 69 | # Return is not zero 70 | if worker.exitcode != 0: 71 | # Handle early failure by killing current process 72 | LOGGER.warning("Worker %s exited with code %s", worker.pid, worker.exitcode) 73 | if time.time() - self._start_time < EARLY_FAILURE_DELAY: 74 | # Critical exit 75 | LOGGER.critical("Critical worker failure. Aborting...") 76 | self.critical_failure = True 77 | os.kill(os.getpid(), signal.SIGABRT) 78 | # worker exited 79 | worker.join() 80 | cleaned = True 81 | del self._pool[i] 82 | return cleaned 83 | 84 | def _repopulate_pool(self): 85 | """Bring the number of pool processes up to the specified number, 86 | for use after reaping workers which have exited. 87 | """ 88 | for _ in range(self._num_workers - len(self._pool)): 89 | w = Process(target=self._target, args=self._args, kwargs=self._kwargs) 90 | self._pool.append(w) 91 | w.name = w.name.replace('Process', 'PoolWorker') 92 | w.start() 93 | 94 | def maintain_pool(self): 95 | """Clean up any exited workers and start replacements for them. 96 | """ 97 | if self._join_exited_workers(): 98 | self._repopulate_pool() 99 | 100 | @classmethod 101 | def _terminate_pool(cls, pool: List[Process]): 102 | 103 | # Send terminate to workers 104 | if pool and hasattr(pool[0], 'terminate'): 105 | for p in pool: 106 | if p.exitcode is None: 107 | p.terminate() 108 | 109 | # Join pool workers 110 | if pool and hasattr(pool[0], 'terminate'): 111 | for p in pool: 112 | if p.is_alive(): 113 | # worker has not yet exited 114 | p.join() 115 | 116 | def __reduce__(self): 117 | raise NotImplementedError( 118 | 'Pool objects cannot be passed between processes or pickled', 119 | ) 120 | 121 | def terminate(self): 122 | self._terminate() 123 | 124 | def kill(self, pid: int) -> bool: 125 | """ Kill a running process 126 | """ 127 | try: 128 | p = next(filter(lambda w: w.pid == pid, self._pool)) 129 | p.kill() 130 | return True 131 | except StopIteration: 132 | pass 133 | return False 134 | -------------------------------------------------------------------------------- /pyqgisserver/zeromq/supervisor.py: -------------------------------------------------------------------------------- 1 | # 2 | # Copyright 2018 3liz 3 | # Author David Marteau 4 | # 5 | # This Source Code Form is subject to the terms of the Mozilla Public 6 | # License, v. 2.0. If a copy of the MPL was not distributed with this 7 | # file, You can obtain one at http://mozilla.org/MPL/2.0/. 8 | # 9 | """ Supervisor implementation for controlling lifetime and restart of workers 10 | 11 | This supervisor will not work for distributed workers because it needs to know 12 | the pid of the worker processes to send abort signal to. 13 | """ 14 | 15 | # 16 | # Client 17 | # 18 | 19 | import asyncio 20 | import logging 21 | import os 22 | import signal 23 | import traceback 24 | 25 | from typing import ( 26 | Any, 27 | Dict, 28 | NamedTuple, 29 | Optional, 30 | Union, 31 | ) 32 | 33 | import zmq 34 | import zmq.asyncio 35 | 36 | from .utils import _get_ipc 37 | 38 | LOGGER = logging.getLogger('SRVLOG') 39 | 40 | 41 | class _Report(NamedTuple): 42 | data: Any 43 | 44 | 45 | class Client: 46 | 47 | def __init__(self): 48 | """ Supervised client notifier 49 | """ 50 | try: 51 | address = _get_ipc('supervisor') 52 | except KeyError: 53 | self._sock = None 54 | LOGGER.warning("Supervisor disabled") 55 | else: 56 | ctx = zmq.Context.instance() 57 | self._sock = ctx.socket(zmq.PUSH) 58 | self._sock.setsockopt(zmq.IMMEDIATE, 1) # Do no queue if no connection 59 | self._sock.connect(address) 60 | 61 | self._pid = os.getpid() 62 | self._busy = False 63 | 64 | def _send(self, data: Union[bytes, _Report]): 65 | if not self._sock: 66 | return 67 | try: 68 | self._sock.send_pyobj((self._pid, data), flags=zmq.DONTWAIT) 69 | except zmq.ZMQError as err: 70 | if err.errno != zmq.EAGAIN: 71 | LOGGER.error("%s (%s)", zmq.strerror(err.errno), err.errno) 72 | 73 | def notify_done(self): 74 | """ Send 'ready' notification 75 | """ 76 | if self._busy: 77 | self._busy = False 78 | self._send(b'DONE') 79 | 80 | def notify_busy(self): 81 | """ send 'busy' notification 82 | """ 83 | if not self._busy: 84 | self._busy = True 85 | self._send(b'BUSY') 86 | 87 | def close(self): 88 | if self._sock: 89 | self._sock.close() 90 | 91 | def send_report(self, data: Any): # noqa: ANN401 92 | self._send(_Report(data=data)) 93 | 94 | 95 | class Supervisor: 96 | 97 | def __init__(self, timeout: int): 98 | """ Run supervisor 99 | 100 | :param timeout: timeout delay in seconds 101 | """ 102 | address = _get_ipc('supervisor') 103 | 104 | ctx = zmq.asyncio.Context.instance() 105 | self._sock = ctx.socket(zmq.PULL) 106 | self._sock.setsockopt(zmq.RCVTIMEO, 1000) 107 | self._sock.bind(address) 108 | 109 | self._timeout = timeout 110 | self._busy: Dict[int, asyncio.Task] = {} 111 | self._stopped = True 112 | self._task: Optional[asyncio.Task] = None 113 | self._reports: Dict[int, Any] = {} 114 | 115 | def run(self): 116 | self._task = asyncio.create_task(self._run_async()) 117 | 118 | async def _run_async(self): 119 | """ Run supervisor 120 | """ 121 | loop = asyncio.get_running_loop() 122 | 123 | def kill(pid: int): 124 | del self._busy[pid] 125 | try: 126 | os.kill(pid, signal.SIGKILL) 127 | LOGGER.critical("Killed stalled process %s", pid) 128 | except ProcessLookupError: 129 | # Process was already terminated/crashed 130 | pass 131 | 132 | self._stopped = False 133 | 134 | while not self._stopped: 135 | try: 136 | pid, msg = await self._sock.recv_pyobj() 137 | if msg == b'BUSY': 138 | self._busy[pid] = loop.call_later(self._timeout, kill, pid) 139 | elif msg == b'DONE': 140 | try: 141 | self._busy.pop(pid).cancel() 142 | except KeyError: 143 | pass 144 | elif isinstance(msg, _Report): 145 | self._reports[pid] = msg.data 146 | except zmq.ZMQError as err: 147 | if err.errno != zmq.EAGAIN: 148 | LOGGER.error("%s\n%s", zmq.strerror(err.errno), traceback.format_exc()) 149 | except asyncio.CancelledError: 150 | raise 151 | except Exception: 152 | LOGGER.critical("%s", traceback.format_exc()) 153 | raise 154 | 155 | @property 156 | def reports(self): 157 | return list(self._reports.values()) 158 | 159 | def num_reports(self) -> int: 160 | return len(self._reports) 161 | 162 | def clear_reports(self): 163 | self._reports = {} 164 | 165 | def stop(self): 166 | """ Stop the supervisor 167 | """ 168 | LOGGER.info("Stopping supervisor") 169 | self._stopped = True 170 | if self._task and not self._task.cancelled(): 171 | self._task.cancel() 172 | for th in self._busy.values(): 173 | th.cancel() 174 | self._busy.clear() 175 | -------------------------------------------------------------------------------- /pyqgisserver/zeromq/utils.py: -------------------------------------------------------------------------------- 1 | from ..config import confservice 2 | 3 | 4 | def _get_ipc(name: str) -> str: 5 | ipc_path = confservice.get('zmq', 'ipcpath', fallback=None) 6 | if ipc_path: 7 | return f"ipc://{ipc_path}/{name}" 8 | else: 9 | # Get alternate tcp configuration 10 | return confservice.get('zmq', f'{name}_addr') 11 | -------------------------------------------------------------------------------- /pyqgisservercontrib/core/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/3liz/py-qgis-server/ffdf4a3d8075c619adad768d42b1aef10d1257ee/pyqgisservercontrib/core/__init__.py -------------------------------------------------------------------------------- /pyqgisservercontrib/core/componentmanager.py: -------------------------------------------------------------------------------- 1 | # 2 | # Copyright 2020 3liz 3 | # Author David Marteau 4 | # 5 | # This Source Code Form is subject to the terms of the Mozilla Public 6 | # License, v. 2.0. If a copy of the MPL was not distributed with this 7 | # file, You can obtain one at http://mozilla.org/MPL/2.0/. 8 | 9 | """ Components are a way to register objects using 10 | contract ids. A contract id is attached to an interface or a set 11 | of interface. 12 | 13 | It is designed to have a general way about passing objects or behaviors to plugins 14 | or extension or other modules. It then enables for these modules or extensions to rely on the calling 15 | module behaviors without the need for these to do explicit imports 16 | """ 17 | 18 | import logging 19 | import sys 20 | 21 | from collections import namedtuple 22 | from importlib import metadata 23 | from typing import Any, Callable, Optional, Sequence 24 | 25 | 26 | class ComponentManagerError(Exception): 27 | pass 28 | 29 | 30 | class FactoryNotFoundError(ComponentManagerError): 31 | pass 32 | 33 | 34 | class NoRegisteredFactoryError(ComponentManagerError): 35 | pass 36 | 37 | 38 | class EntryPointNotFoundError(ComponentManagerError): 39 | pass 40 | 41 | 42 | LOGGER = logging.getLogger('SRVLOG') 43 | 44 | FactoryEntry = namedtuple('FactoryEntry', ('create_instance', 'service')) 45 | 46 | 47 | def _entry_points(group: str, name: Optional[str] = None) -> Sequence[metadata.EntryPoint]: 48 | """ Return entry points 49 | """ 50 | ver = sys.version_info[:2] 51 | if ver >= (3, 10): 52 | # See https://docs.python.org/3.10/library/importlib.metadata.html 53 | entry_points = metadata.entry_points() 54 | if name: 55 | return entry_points.select(group=group, name=name) 56 | else: 57 | return entry_points.select(group=group) 58 | else: 59 | # Return a dict 60 | # see https://docs.python.org/3.8/library/importlib.metadata.html 61 | eps = metadata.entry_points().get(group, []) # type: ignore [var-annotated] 62 | if name: 63 | eps = [ep for ep in eps if ep.name == name] 64 | return eps 65 | 66 | 67 | class ComponentManager: 68 | 69 | def __init__(self): 70 | """ Component Manager 71 | """ 72 | self._contractIDs = {} 73 | 74 | def register_entrypoints(self, category, *args, **kwargs): 75 | """ Load extension modules 76 | 77 | Loaded modules will do self-registration 78 | """ 79 | for ep in _entry_points(category): 80 | LOGGER.info("Loading module: %s:%s", category, ep.name) 81 | ep.load()(*args, **kwargs) 82 | 83 | def load_entrypoint(self, category: str, name: str) -> Any: 84 | for ep in _entry_points(category, name): 85 | return ep.load() 86 | raise EntryPointNotFoundError(name) 87 | 88 | def register_factory(self, contractID: str, factory: Callable[[], None]): 89 | """ Register a factory for the given contract ID 90 | """ 91 | if not callable(factory): 92 | raise ValueError('factory must be a callable object') 93 | 94 | LOGGER.debug("Registering factory: %s", contractID) 95 | self._contractIDs[contractID] = FactoryEntry(factory, None) 96 | 97 | def register_service(self, contractID: str, service: Any): 98 | """ Register an instance object as singleton service 99 | """ 100 | def nullFactory(): 101 | raise NoRegisteredFactoryError(contractID) 102 | 103 | LOGGER.debug("Registering service: %s", contractID) 104 | self._contractIDs[contractID] = FactoryEntry(nullFactory, service) 105 | 106 | def create_instance(self, contractID: str) -> Any: 107 | """ Create an instance of the object referenced by its 108 | contract id. 109 | """ 110 | fe = self._contractIDs.get(contractID) 111 | if fe: 112 | return fe.create_instance() 113 | else: 114 | raise FactoryNotFoundError(contractID) 115 | 116 | def get_service(self, contractID: str) -> Any: 117 | """ Return instance object as singleton 118 | """ 119 | fe = self._contractIDs.get(contractID) 120 | if fe is None: 121 | raise FactoryNotFoundError(contractID) 122 | if fe.service is None: 123 | fe = fe._replace(service=fe.create_instance()) 124 | self._contractIDs[contractID] = fe 125 | return fe.service 126 | 127 | 128 | gComponentManager = ComponentManager() 129 | 130 | 131 | def get_service(contractID: str) -> Any: 132 | """ Alias to component_manager.get_service 133 | """ 134 | return gComponentManager.get_service(contractID) 135 | 136 | 137 | def create_instance(contractID: str) -> Any: 138 | """ Alias to component_manager.create_instance 139 | """ 140 | return gComponentManager.create_instance(contractID) 141 | 142 | 143 | def register_entrypoints(category: str, *args, **kwargs): 144 | """ Alias to component_manager.register_components 145 | """ 146 | gComponentManager.register_entrypoints(category, *args, **kwargs) 147 | 148 | 149 | def load_entrypoint(category: str, name: str) -> Any: 150 | """ Alias to component_manager.load_entrypoint 151 | """ 152 | return gComponentManager.load_entrypoint(category, name) 153 | 154 | # 155 | # Declare factories or services with decorators 156 | # 157 | 158 | 159 | def register_service(contractID: str) -> Callable: 160 | def wrapper(obj): 161 | gComponentManager.register_service(contractID, obj) 162 | return obj 163 | return wrapper 164 | 165 | 166 | def register_factory(contractID: str) -> Callable: 167 | def wrapper(obj): 168 | gComponentManager.register_factory(contractID, obj) 169 | return obj 170 | return wrapper 171 | -------------------------------------------------------------------------------- /pyqgisservercontrib/core/filters.py: -------------------------------------------------------------------------------- 1 | # 2 | # Copyright 2019 3liz 3 | # Author: David Marteau 4 | # 5 | # This Source Code Form is subject to the terms of the Mozilla Public 6 | # License, v. 2.0. If a copy of the MPL was not distributed with this 7 | # file, You can obtain one at http://mozilla.org/MPL/2.0/. 8 | 9 | 10 | """ Define middleware filters 11 | 12 | Declare filters as 13 | 14 | @blockingfilter(uri="/ows/") 15 | def myfilter1( handller: tornado.web.RequestHandler, *args ) -> None: 16 | ... 17 | 18 | @asyncfilter(pri=100, uri="/ows/") 19 | async def myfilter2( handller: tornado.web.RequestHandler, *args) -> None: 20 | ... 21 | 22 | 23 | Filters are applied before sending the request, a filter can return response or 24 | raise exception based on authorization rules. 25 | 26 | Registering filters: 27 | 28 | Modules implementing filters must register their filters using setup.py entry_points with the 29 | key 'pyqgisserver_filters' 30 | 31 | policy_service.add_policy([myfilter1, myfilter2], pri=1000) 32 | """ 33 | 34 | import logging 35 | import re 36 | 37 | from tornado.httputil import HTTPServerRequest 38 | from typing_extensions import ( 39 | Callable, 40 | Dict, 41 | Optional, 42 | Self, 43 | Tuple, 44 | Union, 45 | ) 46 | 47 | LOGGER = logging.getLogger('SRVLOG') 48 | 49 | 50 | class _FilterBase: 51 | 52 | def __init__(self, match: Optional[Union[str, re.Pattern]] = None, repl: Optional[str] = None): 53 | if isinstance(match, str): 54 | match = re.compile(match, re.IGNORECASE) 55 | self.pattern = match 56 | self.repl = repl 57 | self.match_args: Tuple = () 58 | self.match_kwargs: Dict = {} 59 | 60 | def __str__(self) -> str: 61 | return f"_FilterBase<{hex(id(self))}>(match={self.pattern}, repl={self.repl})" 62 | 63 | def match(self, path: str) -> Tuple[bool, Optional[str]]: 64 | """ Check uri against pattern 65 | """ 66 | if self.pattern: 67 | match = self.pattern.match(path) 68 | if match: 69 | # match.groups() includes both named and 70 | # unnamed groups, we want to use either groups 71 | # or groupdict but not both. 72 | if self.pattern.groupindex: 73 | self.match_kwargs = match.groupdict() 74 | else: 75 | self.match_args = match.groups() 76 | if self.repl: 77 | path = self.pattern.sub(self.repl, path, count=1) 78 | return True, path 79 | else: 80 | # Match everything 81 | return True, path 82 | 83 | return False, None 84 | 85 | def __call__(self, fn: Callable) -> Self: 86 | self.fn = fn 87 | return self 88 | 89 | def apply(self, request: HTTPServerRequest): 90 | self.fn(request, *self.match_args, **self.match_kwargs) 91 | 92 | 93 | class policy_filter(_FilterBase): 94 | """ Decorator for synchronous request filter 95 | """ 96 | pass 97 | -------------------------------------------------------------------------------- /pyqgisservercontrib/core/watchfiles.py: -------------------------------------------------------------------------------- 1 | """ Watch file change 2 | """ 3 | import functools 4 | import logging 5 | import os 6 | import traceback 7 | 8 | from typing import Callable, Dict, List, Optional 9 | 10 | from tornado.ioloop import PeriodicCallback as Scheduler 11 | 12 | LOGGER = logging.getLogger('SRVLOG') 13 | 14 | UpdateFunc = Callable[[List[str]], None] 15 | 16 | 17 | def watchfiles(watched_files: List[str], updatefunc: UpdateFunc, check_time: int = 500) -> Scheduler: 18 | """Begins watching source files for changes. 19 | """ 20 | modify_times: Dict[str, float] = {} 21 | callback = functools.partial(_update_callback, updatefunc, watched_files, modify_times) 22 | scheduler = Scheduler(callback, check_time) 23 | return scheduler 24 | 25 | 26 | def _update_callback( 27 | updatefunc: UpdateFunc, 28 | watched_files: List[str], 29 | modify_times: Dict[str, float], 30 | ): 31 | """ Call update funcs when modified files 32 | """ 33 | modified_files = [path for path in watched_files if _check_file(modify_times, path) is not None] 34 | if len(modified_files) > 0: 35 | LOGGER.debug("running update hook for %s", modified_files) 36 | updatefunc(modified_files) 37 | 38 | 39 | def _check_file(modify_times: Dict[str, float], path: str) -> Optional[str]: 40 | try: 41 | modified = os.stat(path).st_mtime 42 | 43 | if path not in modify_times: 44 | modify_times[path] = modified 45 | return None 46 | if modify_times[path] != modified: 47 | modify_times[path] = modified 48 | return path 49 | except FileNotFoundError: 50 | # Do not care if file do not exists 51 | pass 52 | except Exception: 53 | traceback.print_exc() 54 | LOGGER.error("Error while checking file %s") 55 | 56 | return None 57 | -------------------------------------------------------------------------------- /pyqgisservercontrib/middlewares/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/3liz/py-qgis-server/ffdf4a3d8075c619adad768d42b1aef10d1257ee/pyqgisservercontrib/middlewares/__init__.py -------------------------------------------------------------------------------- /pyqgisservercontrib/middlewares/request_logger.py: -------------------------------------------------------------------------------- 1 | """ Request logger 2 | 3 | Detailled log of all *incoming* requests as: 4 | ``` 5 | timestamp: int 6 | method: str 7 | uri: str 8 | body: bytes 9 | headers: Dict[str, str] 10 | ``` 11 | """ 12 | import json 13 | import logging 14 | import os 15 | 16 | from base64 import b64encode 17 | from dataclasses import asdict, dataclass, is_dataclass 18 | from pathlib import Path 19 | from time import time 20 | from typing import List, Optional, Tuple 21 | 22 | from tornado.httputil import HTTPServerRequest 23 | 24 | from pyqgisservercontrib.core.filters import policy_filter 25 | 26 | logger = logging.getLogger('SRVLOG.request_logger') 27 | 28 | 29 | class DataclassEncoder(json.JSONEncoder): 30 | def default(self, o): 31 | if isinstance(o, bytes): 32 | return b64encode(o).decode() 33 | if is_dataclass(o): 34 | return asdict(o) 35 | return super().default(o) 36 | 37 | 38 | @dataclass 39 | class RequestData: 40 | timestamp: float 41 | method: Optional[str] 42 | uri: Optional[str] 43 | body: Optional[bytes] 44 | headers: List[Tuple[str, str]] 45 | 46 | 47 | def register_filters(policy_service, *args, **kwargs): 48 | 49 | env = os.getenv("PY_QGIS_SERVER_REQUEST_LOG") 50 | if env: 51 | fp = Path(env).open('a') 52 | else: 53 | return 54 | 55 | @policy_filter() 56 | def request_logger(request: HTTPServerRequest) -> None: 57 | data = RequestData( 58 | timestamp=time(), 59 | method=request.method, 60 | uri=request.uri, 61 | body=request.body, 62 | headers=list(request.headers.get_all()), 63 | ) 64 | print(json.dumps(data, cls=DataclassEncoder), file=fp) 65 | 66 | policy_service.add_filters([request_logger], pri=10000) 67 | -------------------------------------------------------------------------------- /ruff.toml: -------------------------------------------------------------------------------- 1 | # Ruff configuration 2 | # See https://docs.astral.sh/ruff/configuration/ 3 | line-length = 120 4 | target-version = "py310" 5 | 6 | [lint] 7 | extend-select = ["E", "F", "I", "ANN", "W", "T", "COM", "RUF"] 8 | ignore = ["ANN002", "ANN003"] 9 | 10 | [format] 11 | indent-style = "space" 12 | 13 | [lint.per-file-ignores] 14 | "tests/*" = ["T201"] 15 | "pyqgisservercontrib/core/componentmanager.py" = ["ANN401"] 16 | 17 | [lint.isort] 18 | lines-between-types = 1 19 | known-first-party = [ 20 | "pyqgisserver", 21 | "pyqgisservercontrib", 22 | ] 23 | section-order = [ 24 | "future", 25 | "standard-library", 26 | "third-party", 27 | "qgis", 28 | "first-party", 29 | "local-folder", 30 | ] 31 | 32 | [lint.isort.sections] 33 | qgis = ["qgis"] 34 | 35 | [lint.flake8-annotations] 36 | ignore-fully-untyped = true 37 | suppress-none-returning = true 38 | suppress-dummy-args = true 39 | 40 | -------------------------------------------------------------------------------- /tests/.gitignore: -------------------------------------------------------------------------------- 1 | /data/.local 2 | symbology-style.db 3 | .env 4 | 5 | *.request 6 | legend.json 7 | /plugins/lizmap_server 8 | 9 | -------------------------------------------------------------------------------- /tests/.pg_service.conf: -------------------------------------------------------------------------------- 1 | 2 | [local] 3 | host=/var/run/postgresql 4 | 5 | [not_working] 6 | host=/do_not_exists 7 | 8 | -------------------------------------------------------------------------------- /tests/Makefile: -------------------------------------------------------------------------------- 1 | SHELL:=bash 2 | .ONESHELL: 3 | .PHONY: env 4 | 5 | DEPTH=.. 6 | 7 | include $(DEPTH)/config.mk 8 | 9 | # 10 | # Makefile for running server in docker containers 11 | # 12 | FLAVOR:=release 13 | 14 | REGISTRY_URL ?= 3liz 15 | REGISTRY_PREFIX=$(REGISTRY_URL)/ 16 | 17 | QGIS_IMAGE:=$(REGISTRY_PREFIX)qgis-platform:$(FLAVOR) 18 | 19 | WITH_SSL:=no 20 | 21 | SERVER_HTTP_PORT:=127.0.0.1:8888 22 | MANAGEMENT_HTTP_PORT:=127.0.0.1:19876 23 | 24 | BECOME_USER:=$(shell id -u) 25 | 26 | SRCDIR=$(topsrcdir) 27 | 28 | PLUGINPATH:=$(SRCDIR)/tests/plugins 29 | 30 | PGUSER ?= $(USER) 31 | PGDATABASE ?= $(USER) 32 | PGAPPNAME ?= py-qgis-server-tests 33 | 34 | TESTS_CPU_LIMITS:=2.0 35 | TESTS_MEMORY_LIMITS:=2g 36 | 37 | REQUEST_TIMEOUT:=20 38 | STRICT_CHECK:=yes 39 | WORKERS:=1 40 | PROJECTPATH:=$(SRCDIR)/tests/data 41 | 42 | env: 43 | @echo "Creating environment file for docker-compose" 44 | @cat <<-EOF > .env 45 | SRCDIR=$(SRCDIR) 46 | WORKERS=$(WORKERS) 47 | BECOME_USER=$(BECOME_USER) 48 | QGIS_IMAGE=$(QGIS_IMAGE) 49 | USER=$(USER) 50 | PWD=$(shell pwd) 51 | PLUGINPATH=$(PLUGINPATH) 52 | PROJECTPATH=$(PROJECTPATH) 53 | SERVER_HTTP_PORT=$(SERVER_HTTP_PORT) 54 | MANAGEMENT_HTTP_PORT=$(MANAGEMENT_HTTP_PORT) 55 | CPU_LIMITS=$(TESTS_CPU_LIMITS) 56 | MEMORY_LIMITS=$(TESTS_MEMORY_LIMITS) 57 | WITH_SSL=$(WITH_SSL) 58 | PGPASSFILE=${PGPASSFILE} 59 | PGAPPNAME=$(PGAPPNAME) 60 | PGUSER=$(PGAPPNAME) 61 | PGDATABASE=$(PGDATABASE) 62 | PG_RUN=$(PG_RUN) 63 | REQUEST_TIMEOUT=$(REQUEST_TIMEOUT) 64 | STRICT_CHECK=$(STRICT_CHECK) 65 | PYTEST_ADDOPTS=$(PYTEST_ADDOPTS) 66 | COMPOSE_PROJECT_NAME=test-py-qgis-server 67 | EOF 68 | 69 | test: 70 | SRCDIR=$(SRCDIR) source tests.env \ 71 | && export $$(cut -d= -f1 tests.env) \ 72 | && cd unittests \ 73 | && pytest -v $(ADDOPTS) 74 | 75 | %-test: export RUN_COMMAND=./tests/run_tests.sh 76 | %-test: env 77 | docker compose \ 78 | -f docker-compose.yml \ 79 | -f docker-compose.$*.yml up -V --quiet-pull --remove-orphans \ 80 | --force-recreate --exit-code-from qgis-server 81 | docker compose down -v --remove-orphans 82 | 83 | run: 84 | SRCDIR=$(SRCDIR) source tests.env \ 85 | && export $$(cut -d= -f1 tests.env) \ 86 | && qgisserver 87 | 88 | 89 | up: env 90 | docker compose up -V --quiet-pull --remove-orphans \ 91 | --force-recreate --exit-code-from qgis-server 92 | 93 | stop: 94 | docker compose down -v --remove-orphans 95 | 96 | # 97 | # Run server with extra services 98 | # 99 | %-run: export RUN_COMMAND=./tests/run_server.sh 100 | %-run: env 101 | docker compose \ 102 | -f docker-compose.yml \ 103 | -f docker-compose.$*.yml up -V --quiet-pull --remove-orphans --force-recreate -d 104 | 105 | run-proxy: env 106 | docker compose -f docker-compose.proxy.yml \ 107 | up -V --quiet-pull --remove-orphans --force-recreate -d 108 | 109 | stop-proxy: 110 | docker compose -f docker-compose.proxy.yml down -v --remove-orphans 111 | 112 | 113 | -------------------------------------------------------------------------------- /tests/certs/README.md: -------------------------------------------------------------------------------- 1 | 2 | These certificates are self-signed certificates used for 3 | testing purpose only. 4 | 5 | **Do not use them in real production environment** 6 | 7 | -------------------------------------------------------------------------------- /tests/certs/localhost.crt: -------------------------------------------------------------------------------- 1 | -----BEGIN CERTIFICATE----- 2 | MIIDrjCCApagAwIBAgIJAO7KlWboP6p/MA0GCSqGSIb3DQEBCwUAMGExCzAJBgNV 3 | BAYTAkZSMQ8wDQYDVQQIDAZGcmFuY2UxDjAMBgNVBAcMBUxvY2FsMQ0wCwYDVQQK 4 | DAQzTElaMQ4wDAYDVQQLDAVJTkZSQTESMBAGA1UEAwwJbG9jYWxob3N0MB4XDTE5 5 | MDQxNzEyMDUyNFoXDTI5MDQxNDEyMDUyNFowYTELMAkGA1UEBhMCRlIxDzANBgNV 6 | BAgMBkZyYW5jZTEOMAwGA1UEBwwFTG9jYWwxDTALBgNVBAoMBDNMSVoxDjAMBgNV 7 | BAsMBUlORlJBMRIwEAYDVQQDDAlsb2NhbGhvc3QwggEiMA0GCSqGSIb3DQEBAQUA 8 | A4IBDwAwggEKAoIBAQC8WhkGSTzWythKxVDNQ9Q1gPa/tcjIyk/XXGZvEgT+4xg4 9 | dL38ADbAcTt3BEH0YrtI4RUtDZ+qa6jL07r1RwEe+Z3+w/3XmcbhqZyF3ealab4e 10 | fZ+Wt0SkQa/t/wQbh3ugf03dQbLqy//xhyG8f+LkeBRtAE4BM6d3SVpark57/YMy 11 | hDr6jF4sjcbp4zUEX1oBOrIb3BTGQrrdAFu4JTQTlVlZMqUDE98vq7Tzwcv+FQjV 12 | aRVi8Gt5gkvQFAQnQwhqwqO8vjTMyUusS5RMvycwNVDJwBCznb9G/sd6pkDCv2PV 13 | KkO3Bhxyqf4ijZ1uE3fqH357bIDSTZcYBGOfMv19AgMBAAGjaTBnMB0GA1UdDgQW 14 | BBSKZjrXRVBFTSG3uCm7QwLJ/+RbyzAfBgNVHSMEGDAWgBSKZjrXRVBFTSG3uCm7 15 | QwLJ/+RbyzAPBgNVHRMBAf8EBTADAQH/MBQGA1UdEQQNMAuCCWxvY2FsaG9zdDAN 16 | BgkqhkiG9w0BAQsFAAOCAQEATek53tYpjTxykWhqJexbIjAfbaC3nrXQPoM6ogSu 17 | r9wS9Op6rKEtDzM3PhYu5JiVcjaADevbtCGlOunXVlisfpv6Ffs+XOAv/M82Gix5 18 | tnkQK0CeDsVTOMwQe4H/Bjs9euU2Ow322H266rB4pMux96EtEtpilA3FpirFuRFM 19 | 6z5ynoY8ZSr1OhZkGGkQEkoUH4DC9UbDPnecKhQrjDZ8l0rtu75AWV1Gyv68hDBT 20 | SxuFP4TQuJNir3OZwhDTCgFUgW+FJLqzp8y+Vvg/06tzsZDGgtNnkEHIlFRS9weg 21 | Q/zGIN5xYVKh7rHIjpY/SpZmwHIvcvmxXLV459nJjakqwA== 22 | -----END CERTIFICATE----- 23 | -------------------------------------------------------------------------------- /tests/certs/localhost.key: -------------------------------------------------------------------------------- 1 | -----BEGIN PRIVATE KEY----- 2 | MIIEvwIBADANBgkqhkiG9w0BAQEFAASCBKkwggSlAgEAAoIBAQC8WhkGSTzWythK 3 | xVDNQ9Q1gPa/tcjIyk/XXGZvEgT+4xg4dL38ADbAcTt3BEH0YrtI4RUtDZ+qa6jL 4 | 07r1RwEe+Z3+w/3XmcbhqZyF3ealab4efZ+Wt0SkQa/t/wQbh3ugf03dQbLqy//x 5 | hyG8f+LkeBRtAE4BM6d3SVpark57/YMyhDr6jF4sjcbp4zUEX1oBOrIb3BTGQrrd 6 | AFu4JTQTlVlZMqUDE98vq7Tzwcv+FQjVaRVi8Gt5gkvQFAQnQwhqwqO8vjTMyUus 7 | S5RMvycwNVDJwBCznb9G/sd6pkDCv2PVKkO3Bhxyqf4ijZ1uE3fqH357bIDSTZcY 8 | BGOfMv19AgMBAAECggEBAKv26qAVn8lPu7hQfFxcUFRlIWOZGe9Q3yJ2R0OjRRJd 9 | zpBE7ew1WcfL2gnoxjrVJb79WbMfnCYhUyR9dFVadYiNSJYA5TLCQJVpoPGLzng5 10 | mg4Gjf0x6Ca7nl+BlIN56AJ6GGIpCKxcgppG0SNj84i6pZN7UrzAJ2a+fxMJz7hd 11 | AhgZuA2VzdWCinimNsTu5VTPxk3zbG58lnhb/mAG1/l4RF9NRkPwLhYinpeODFW2 12 | 9xzvYBazgEUe5+OS+peuuKFR0ZBKqQuDCk3VBH2gOOiEb5z75WFLMGQQe/zsrnym 13 | fFewZ3pH/wgs71Ws8QqtO26A4xLNcyKmAouNLe3o/+ECgYEA+PkxbXFtSuL0YPHt 14 | sF8SKYd4JeC5L+SI9RAVF9Z95YT6ulVHIaPBAk0eXtjTRq6Am7KVj6MhIkcwXowP 15 | 7TQqPGcbokJa1Bikqtkxig2BlZFDQ9d/0xYrVW0PFSXEkQNjSjwUSHDuD0rhFPrl 16 | TZTcoYTqxWUjN/lW5J5eAFqn3EkCgYEAwarrwHwwNFL/UJ9nyOPuPttP4Yoy2MHN 17 | 4I/x8M6a4vYxPnltuqcvxWHXS2QptGvz/F/FO34RrtumrIkE45WNR7qvobASsHls 18 | 7iQFyfB/R2vP/3ry7qA0PrPF8ot4tYoA6NYA1jlTOIQ/A8OfTGfu2AWzkC6qtGlB 19 | 6CrIUn3bj5UCgYAUjW2ZHT3qaqXFwvLeFlsHdplidxSScYkkwbkcCKbH5ZxU+Wkv 20 | y7bJG9if98IpGYqgT7Os8chwbfP+Og2uhRnfdpt/X37j90zQlbFTNh42XJFy7j0t 21 | Da1yFdii5EU/u0nc/OyntjrQpFvEQZngN6Om6bP/q4OmwTx9Dt2vpcwpkQKBgQCN 22 | jHta6GnjFHZ/WsLkVZOgZXLxCa04OA5df1f6BUe3cvFzFBVbQy7rOSO+eqrwr6ZY 23 | Yzco0G/kOa6MlGj6XigwsQYFS0edrGItfC6u9hRRAz+3HwnH11fYLFUVfVLwfLlF 24 | dISQDr4ApMfZ3HTlx2EOirw/OZyS2AvPwNVBVbPQMQKBgQDOFU/czZr0U6z3Q64A 25 | SneIMYF/EIYjH2Gg+uJDQNdTQkE5syOLIMQASY3Vhwx5/JZ+u+PdCbfBU8mg9BT0 26 | 9p1Vbn5xW0oYe70JIkRhjQmWK7q4vr8oMxQoQeeSOmhFEGOc0GUmvZBmPkScQ2IE 27 | 4ec/jvE9MxKKYlYqaBBP3zdBzA== 28 | -----END PRIVATE KEY----- 29 | -------------------------------------------------------------------------------- /tests/check_tag.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | VERSION=$1 4 | METADATA=$(cat config.mk | grep "VERSION:=" | cut -d '=' -f2) 5 | 6 | if [ "$METADATA" != "$VERSION" ]; 7 | then 8 | echo "The Makefile file has ${METADATA} while the requested tag is ${VERSION}." 9 | echo "Aborting" 10 | exit 1 11 | fi 12 | 13 | echo "The Makefile is synced with ${VERSION}" 14 | exit 0 15 | -------------------------------------------------------------------------------- /tests/client/conftest.py: -------------------------------------------------------------------------------- 1 | import os 2 | import pytest 3 | 4 | server_host = "localhost:8080" 5 | 6 | 7 | def pytest_addoption(parser): 8 | parser.addoption("--host", metavar="HOST[:PORT]", default=server_host, help="server host name (and port)") 9 | parser.addoption("--with-postgres", action="store_true", help="Run postgres tests", 10 | default=False) 11 | parser.addoption("--with-profiles", action="store_true", help="Run profiles tests", 12 | default=False) 13 | 14 | 15 | 16 | def pytest_configure(config): 17 | global server_host 18 | server_host = config.getoption('host') 19 | 20 | # Postgres 21 | config.with_postgres = config.getoption('with_postgres') 22 | config.addinivalue_line("markers", "with_postgres: mark test as postgres run") 23 | 24 | # Profiles 25 | config.with_profiles = config.getoption('with_profiles') 26 | config.addinivalue_line("markers", "with_profiles: mark test as profiles test") 27 | 28 | 29 | def pytest_collection_modifyitems(config, items): 30 | if config.with_postgres: 31 | # postgres enabled: do not skip tests 32 | return 33 | if config.with_profiles: 34 | # postgres enabled: do not skip tests 35 | return 36 | skip_postgres = pytest.mark.skip(reason="Postgres tests disabled") 37 | skip_profiles = pytest.mark.skip(reason="Profiles tests disabled") 38 | for item in items: 39 | if "with_postgres" in item.keywords: 40 | item.add_marker(skip_postgres) 41 | if "with_profiles" in item.keywords: 42 | item.add_marker(skip_profiles) 43 | 44 | @pytest.fixture(scope='session') 45 | def host(): 46 | return server_host 47 | 48 | 49 | 50 | -------------------------------------------------------------------------------- /tests/client/pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | addopts= --junit-xml=__output__/junit.xml 3 | junit_family=xunit2 4 | 5 | -------------------------------------------------------------------------------- /tests/client/test_base.py: -------------------------------------------------------------------------------- 1 | """ 2 | Test server disponibility 3 | """ 4 | import requests 5 | 6 | 7 | def test_root_request( host ): 8 | """ Test response from root path 9 | """ 10 | rv = requests.get(f"http://{host}/") 11 | assert rv.status_code == 200 12 | 13 | 14 | def test_wms_getcaps( host ): 15 | """ Test 16 | """ 17 | rv = requests.get(f"http://{host}/ows/?MAP=france_parts.qgs&SERVICE=WMS&request=GetCapabilities") 18 | assert rv.status_code == 200 19 | assert rv.headers['Content-Type'] == 'text/xml; charset=utf-8' 20 | 21 | 22 | def test_wfs_getcaps( host ): 23 | """ Test 24 | """ 25 | rv = requests.get(f"http://{host}/ows/?MAP=france_parts.qgs&SERVICE=WFS&request=GetCapabilities") 26 | assert rv.status_code == 200 27 | assert rv.headers['Content-Type'] == 'text/xml; charset=utf-8' 28 | 29 | 30 | def test_wcs_getcaps( host ): 31 | """ Test 32 | """ 33 | rv = requests.get(f"http://{host}/ows/?MAP=france_parts.qgs&SERVICE=WCS&request=GetCapabilities") 34 | assert rv.status_code == 200 35 | assert rv.headers['Content-Type'] == 'text/xml; charset=utf-8' 36 | 37 | def test_map_not_found_return_404( host ): 38 | """ Test that non existent map return 404 39 | """ 40 | rv = requests.get(f"http://{host}/ows/?MAP=i_do_not_exists.qgs&SERVICE=WFS&request=GetCapabilities") 41 | assert rv.status_code == 404 42 | 43 | def test_protocol_resolution( host ): 44 | """ Test that custom protocol is correctly resolved 45 | """ 46 | rv = requests.get(f"http://{host}/ows/?MAP=test:france_parts.qgs&SERVICE=WFS&request=GetCapabilities") 47 | assert rv.status_code == 200 48 | 49 | def test_unknown_protocol_is_404( host ): 50 | """ Test that custom protocol is correctly resolved 51 | """ 52 | rv = requests.get(f"http://{host}/ows/?MAP=fail:france_parts.qgs&SERVICE=WFS&request=GetCapabilities") 53 | assert rv.status_code == 404 54 | 55 | 56 | -------------------------------------------------------------------------------- /tests/client/test_getcapabilities.py: -------------------------------------------------------------------------------- 1 | """ 2 | Test server disponibility 3 | """ 4 | import requests 5 | import lxml.etree as etree 6 | 7 | from urllib.parse import urlparse 8 | 9 | ns = { "wms": "http://www.opengis.net/wms" } 10 | 11 | xlink = "{http://www.w3.org/1999/xlink}" 12 | 13 | def test_wms_getcapabilities_hrefs( host ): 14 | """ Test getcapabilities hrefs 15 | """ 16 | urlref = urlparse( f"http://{host}/ows/?MAP=france_parts.qgs&SERVICE=WMS&request=GetCapabilities" ) 17 | rv = requests.get( urlref.geturl() ) 18 | assert rv.status_code == 200 19 | assert rv.headers['Content-Type'] == 'text/xml; charset=utf-8' 20 | 21 | urlref = urlparse(f"http://{host}/ows/") 22 | 23 | xml = etree.fromstring(rv.content) 24 | 25 | elem = xml.findall(".//wms:OnlineResource", ns) 26 | assert len(elem) > 0 27 | 28 | href = urlparse(elem[0].get(xlink+'href')) 29 | assert href.scheme == urlref.scheme 30 | assert href.hostname == urlref.hostname 31 | assert href.path == urlref.path 32 | 33 | 34 | def test_forwarded_url( host ): 35 | """ Test proxy location 36 | """ 37 | urlref = urlparse('https://my.proxy.loc:9999/anywhere') 38 | rv = requests.get(f"http://{host}/ows/?MAP=france_parts.qgs&SERVICE=WMS&request=GetCapabilities" , 39 | headers={ 'X-Forwarded-Url': urlref.geturl() } ) 40 | 41 | assert rv.status_code == 200 42 | assert rv.headers['Content-Type'] == 'text/xml; charset=utf-8' 43 | 44 | xml = etree.fromstring(rv.content) 45 | 46 | elem = xml.findall(".//wms:OnlineResource", ns) 47 | assert len(elem) > 0 48 | 49 | href = urlparse(elem[0].get(xlink+'href')) 50 | assert href.scheme == urlref.scheme 51 | assert href.hostname == urlref.hostname 52 | assert href.path == urlref.path 53 | 54 | def test_lower_case_query_params( host ): 55 | """ Test that we support lower case query param 56 | """ 57 | urlref = f"http://{host}/ows/?map=france_parts.qgs&SERVICE=WMS&request=GetCapabilities" 58 | rv = requests.get( urlref ) 59 | assert rv.status_code == 200 60 | -------------------------------------------------------------------------------- /tests/client/test_getmap.py: -------------------------------------------------------------------------------- 1 | """ 2 | Test server disponibility 3 | """ 4 | import requests 5 | 6 | 7 | def test_getmap_request( host ): 8 | """ Test response from root path 9 | """ 10 | url = ('/ows/?bbox=-621646.696284,5795001.359349,205707.697759,6354520.406319&crs=EPSG:3857' 11 | '&dpi=96&exceptions=application/vnd.ogc.se_inimage&format=image/png&height=915' 12 | '&layers=france_parts&map=france_parts.qgs&request=GetMap' 13 | '&service=WMS&styles=default&transparent=TRUE&version=1.3.0&width=1353') 14 | 15 | rv = requests.get(f"http://{host}{url}") 16 | assert rv.status_code == 200 17 | 18 | def test_getmap_post_request( host ): 19 | """ Test response from root path 20 | """ 21 | arguments = { 22 | 'bbox':'-621646.696284,5795001.359349,205707.697759,6354520.406319', 23 | 'crs':'EPSG:3857', 24 | 'dpi':'96', 25 | 'exceptions':'application/vnd.ogc.se_inimage', 26 | 'format':'image/png', 27 | 'height':'915', 28 | 'layers':'france_parts', 29 | 'map':'france_parts.qgs', 30 | 'request':'GetMap', 31 | 'service':'WMS', 32 | 'styles':'default', 33 | 'transparent':'TRUE', 34 | 'version':'1.3.0', 35 | 'width':'1353' } 36 | 37 | rv = requests.post(f"http://{host}/ows/", data=arguments) 38 | assert rv.status_code == 200 39 | 40 | 41 | -------------------------------------------------------------------------------- /tests/client/test_profile.py: -------------------------------------------------------------------------------- 1 | """ 2 | Test server disponibility 3 | """ 4 | import pytest 5 | import requests 6 | 7 | @pytest.mark.with_profiles 8 | def test_profile_request( host ): 9 | """ Test response from root path 10 | """ 11 | url = ('/ows/p/wmsonly/?bbox=-621646.696284,5795001.359349,205707.697759,6354520.406319&crs=EPSG:3857' 12 | '&dpi=96&exceptions=application/vnd.ogc.se_inimage&format=image/png&height=915' 13 | '&layers=france_parts&request=GetMap' 14 | '&service=WMS&styles=default&transparent=TRUE&version=1.3.0&width=1353') 15 | 16 | rv = requests.get(f"http://{host}{url}") 17 | assert rv.status_code == 200 18 | 19 | 20 | @pytest.mark.with_profiles 21 | def test_profile_return_403( host ): 22 | """ Test unauthorized WFS return a 403 response 23 | """ 24 | url = ('/ows/p/wmsonly/?exceptions=application/vnd.ogc.se_inimage' 25 | '&service=WFS&request=GetCapabilities') 26 | 27 | rv = requests.get(f"http://{host}{url}") 28 | assert rv.status_code == 403 29 | 30 | 31 | @pytest.mark.with_profiles 32 | def test_ip_ok( host ): 33 | """ Test authorized ip return a 200 response 34 | """ 35 | url = ('/ows/p/rejectips/?service=WMS&request=GetCapabilities') 36 | 37 | rv = requests.get(f"http://{host}{url}", headers={ 'X-Forwarded-For': '192.168.2.1' }) 38 | assert rv.status_code == 200 39 | 40 | 41 | @pytest.mark.with_profiles 42 | def test_ip_rejected_return_403( host ): 43 | """ Test unauthorized WFS return a 403 response 44 | """ 45 | url = ('/ows/p/rejectips/?service=WMS&request=GetCapabilities') 46 | 47 | rv = requests.get(f"http://{host}{url}", headers={ 'X-Forwarded-For': '192.168.3.1' }) 48 | assert rv.status_code == 403 49 | 50 | 51 | @pytest.mark.with_profiles 52 | def test_profile_with_path( host ): 53 | """ Test response from root path 54 | """ 55 | url = ('/ows/p/wms/testpath?bbox=-621646.696284,5795001.359349,205707.697759,6354520.406319&crs=EPSG:3857' 56 | '&dpi=96&exceptions=application/vnd.ogc.se_inimage&format=image/png&height=915' 57 | '&layers=france_parts&request=GetMap' 58 | '&service=WMS&styles=default&transparent=TRUE&version=1.3.0&width=1353') 59 | 60 | rv = requests.get(f"http://{host}{url}") 61 | assert rv.status_code == 200 62 | 63 | 64 | 65 | -------------------------------------------------------------------------------- /tests/client/test_regression.py: -------------------------------------------------------------------------------- 1 | """ 2 | Test server disponibility 3 | """ 4 | import pytest 5 | import requests 6 | import lxml.etree as etree 7 | 8 | from urllib.parse import urlparse 9 | 10 | ns = { "wms": "http://www.opengis.net/wms" } 11 | 12 | xlink = "{http://www.w3.org/1999/xlink}" 13 | 14 | @pytest.mark.skip(reason="This test randomly fail, need to investigate") 15 | def test_wfs_segfault( host ): 16 | """ Test that wfs request return a result 17 | see https://projects.3liz.org/infra-v3/py-qgis-server/issues/3 18 | """ 19 | urlref = ("http://{}/ows/?map=Hot_Spot_Deforestation_Patch_analysis.qgs&request=GetFeature&service=WFS" 20 | "&typename=Near_real_time_deforestation&version=1.0.0").format( host ) 21 | rv = requests.get( urlref ) 22 | assert rv.status_code < 500 23 | 24 | -------------------------------------------------------------------------------- /tests/data/france_parts/france_parts.dbf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/3liz/py-qgis-server/ffdf4a3d8075c619adad768d42b1aef10d1257ee/tests/data/france_parts/france_parts.dbf -------------------------------------------------------------------------------- /tests/data/france_parts/france_parts.prj: -------------------------------------------------------------------------------- 1 | GEOGCS["GCS_WGS_1984",DATUM["D_WGS_1984",SPHEROID["WGS_1984",6378137,298.257223563]],PRIMEM["Greenwich",0],UNIT["Degree",0.017453292519943295]] -------------------------------------------------------------------------------- /tests/data/france_parts/france_parts.qpj: -------------------------------------------------------------------------------- 1 | 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.01745329251994328,AUTHORITY["EPSG","9122"]],AUTHORITY["EPSG","4326"]] 2 | -------------------------------------------------------------------------------- /tests/data/france_parts/france_parts.shp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/3liz/py-qgis-server/ffdf4a3d8075c619adad768d42b1aef10d1257ee/tests/data/france_parts/france_parts.shp -------------------------------------------------------------------------------- /tests/data/france_parts/france_parts.shx: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/3liz/py-qgis-server/ffdf4a3d8075c619adad768d42b1aef10d1257ee/tests/data/france_parts/france_parts.shx -------------------------------------------------------------------------------- /tests/data/france_parts_qgz.qgz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/3liz/py-qgis-server/ffdf4a3d8075c619adad768d42b1aef10d1257ee/tests/data/france_parts_qgz.qgz -------------------------------------------------------------------------------- /tests/data/lines.geojson: -------------------------------------------------------------------------------- 1 | { 2 | "type": "FeatureCollection", 3 | "name": "test_lines", 4 | "features": [ 5 | { "type": "Feature", "properties": { "id": 1, "name": "Line 1" }, "geometry": { "type": "LineString", "coordinates": [ [ 3.80, 43.50 ], [ 3.80, 43.60 ] ] } }, 6 | { "type": "Feature", "properties": { "id": 2, "name": "Line 2" }, "geometry": { "type": "LineString", "coordinates": [ [ 3.80, 43.60 ], [ 3.90, 43.60 ] ] } }, 7 | { "type": "Feature", "properties": { "id": 3, "name": "Line 3" }, "geometry": { "type": "LineString", "coordinates": [ [ 3.90, 43.60 ], [ 3.90, 43.50 ] ] } }, 8 | { "type": "Feature", "properties": { "id": 4, "name": "Line 4" }, "geometry": { "type": "LineString", "coordinates": [ [ 3.90, 43.50 ], [ 3.80, 43.50 ] ] } } 9 | ] 10 | } -------------------------------------------------------------------------------- /tests/data/points.geojson: -------------------------------------------------------------------------------- 1 | { 2 | "type": "FeatureCollection", 3 | "name": "test_points", 4 | "features": [ 5 | { "type": "Feature", "properties": { "id": 1, "name": "Point 1" }, "geometry": { "type": "Point", "coordinates": [ 3.80, 43.50 ] } }, 6 | { "type": "Feature", "properties": { "id": 2, "name": "Point 2" }, "geometry": { "type": "Point", "coordinates": [ 3.80, 43.60 ] } }, 7 | { "type": "Feature", "properties": { "id": 3, "name": "Point 3" }, "geometry": { "type": "Point", "coordinates": [ 3.90, 43.60 ] } }, 8 | { "type": "Feature", "properties": { "id": 4, "name": "Point 4" }, "geometry": { "type": "Point", "coordinates": [ 3.90, 43.50 ] } } 9 | ] 10 | } -------------------------------------------------------------------------------- /tests/data/points3.geojson.gz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/3liz/py-qgis-server/ffdf4a3d8075c619adad768d42b1aef10d1257ee/tests/data/points3.geojson.gz -------------------------------------------------------------------------------- /tests/data/points3.geojson.gz.properties: -------------------------------------------------------------------------------- 1 | compressed_size=660 2 | uncompressed_size=3328 3 | -------------------------------------------------------------------------------- /tests/data/preloads.list: -------------------------------------------------------------------------------- 1 | # 2 | # Test preloading projects 3 | # 4 | file:france_parts.qgs 5 | project_simple.qgs # Comments may goes here 6 | #raster_layer.qgs # Invalid layer 7 | 8 | -------------------------------------------------------------------------------- /tests/docker-compose.amqp.yml: -------------------------------------------------------------------------------- 1 | version: '3.8' 2 | services: 3 | qgis-server: 4 | environment: 5 | QGSRV_SERVER_MONITOR: amqp 6 | AMQP_ROUTING: "test.local" 7 | volumes: 8 | - ${AMQP_SRC}:/amqp_src 9 | amqp: 10 | image: rabbitmq:3-management 11 | ports: 12 | - 127.0.0.1:5672:5672 13 | - 127.0.0.1:15672:15672 14 | networks: 15 | - backend 16 | -------------------------------------------------------------------------------- /tests/docker-compose.postgres.yml: -------------------------------------------------------------------------------- 1 | version: '3.8' 2 | services: 3 | qgis-server: 4 | environment: 5 | PGSERVICEFILE: /src/tests/.pg_service.conf 6 | PGPASSFILE: /.pgpass 7 | PGAPPNAME: ${PGAPPNAME} 8 | PGUSER: ${USER} 9 | PGDATABASE: ${PGDATABASE} 10 | volumes: 11 | - ${PG_RUN}:/var/run/postgresql 12 | - ${PGPASSFILE}:/.pgpass 13 | networks: 14 | - backend 15 | -------------------------------------------------------------------------------- /tests/docker-compose.proxy.yml: -------------------------------------------------------------------------------- 1 | version: '3.8' 2 | services: 3 | map-server: 4 | image: ${QGIS_IMAGE} 5 | working_dir: /src 6 | command: ./tests/run_proxy.sh 7 | environment: 8 | PYTHONWARNINGS: 'd' 9 | PIP_CACHE_DIR: /.cache 10 | USER: ${USER} 11 | QGSRV_SERVER_RESTARTMON: /src/.qgis-restart 12 | QGSRV_SERVER_HTTP_PROXY: 'no' 13 | QGSRV_LOGGING_LEVEL: DEBUG 14 | QGSRV_SERVER_STATUS_PAGE: 'yes' 15 | QGSRV_MANAGEMENT_ENABLED: 'yes' 16 | QGSRV_MANAGEMENT_INTERFACES: '0.0.0.0' 17 | QGSRV_API_ENABLED_LANDING_PAGE: 'yes' 18 | QGSRV_DATA_PATH: /.local/share/qgis-server 19 | QGSRV_SERVER_SSL_CERT: /src/tests/certs/localhost.crt 20 | QGSRV_SERVER_SSL_KEY: /src/tests/certs/localhost.key 21 | QGSRV_SERVER_SSL: ${WITH_SSL} 22 | QGSRV_SERVER_MONITOR: test 23 | QGSRV_CACHE_OBSERVERS: test 24 | QGSRV_MONITOR_TAG_EXTRA_DATA: "monitor.test" 25 | user: ${BECOME_USER} 26 | volumes: 27 | - ${SRCDIR}:/src 28 | - ${PWD}/.local:/.local 29 | - ${PWD}/server.conf:/server.conf 30 | - ${PWD}/.cache:/.cache 31 | - ${PLUGINPATH}:/plugins 32 | - ${PROJECTPATH}:/src/tests/data 33 | ports: 34 | - ${SERVER_HTTP_PORT}:8080 35 | - ${MANAGEMENT_HTTP_PORT}:19876 36 | expose: 37 | - "18080" 38 | - "18090" 39 | - "18091" 40 | networks: 41 | - backend 42 | qgis-worker: 43 | image: ${QGIS_IMAGE} 44 | working_dir: /src 45 | command: ./tests/run_worker.sh 46 | environment: 47 | PYTHONWARNINGS: 'd' 48 | PIP_CACHE_DIR: /.cache 49 | USER: ${USER} 50 | PGSERVICEFILE: /src/tests/.pg_service.conf 51 | QGIS_OPTIONS_PATH: /src/tests/qgis 52 | QGIS_SERVER_TRUST_LAYER_METADATA: 'yes' 53 | QGSRV_ZMQ_HOSTADDR: map-server 54 | QGSRV_CACHE_ROOTDIR: /src/tests/data 55 | QGSRV_SERVER_PLUGINPATH: /plugins 56 | QGSRV_PROJECTS_SCHEMES_TEST: /src/tests/data/ 57 | QGSRV_PROJECTS_SCHEMES_FOO: file:foobar/ 58 | QGSRV_PROJECTS_SCHEMES_BAR: file:foobar?data={path} 59 | QGSRV_SERVER_RESTARTMON: /src/.qgis-restart 60 | QGSRV_SERVER_HTTP_PROXY: 'no' 61 | QGSRV_LOGGING_LEVEL: DEBUG 62 | QGSRV_DATA_PATH: /.local/share/qgis-server 63 | QGSRV_SERVER_TIMEOUT: ${REQUEST_TIMEOUT} 64 | QGSRV_CACHE_STRICT_CHECK: ${STRICT_CHECK} 65 | user: ${BECOME_USER} 66 | volumes: 67 | - ${SRCDIR}:/src 68 | - ${PWD}/.local:/.local 69 | - ${PWD}/server.conf:/server.conf 70 | - ${PWD}/.cache:/.cache 71 | - ${PLUGINPATH}:/plugins 72 | - ${PROJECTPATH}:/src/tests/data 73 | networks: 74 | - backend 75 | deploy: 76 | resources: 77 | limits: 78 | cpus: ${CPU_LIMITS} 79 | memory: ${MEMORY_LIMITS} 80 | networks: 81 | backend: 82 | ipam: 83 | driver: default 84 | config: 85 | - subnet: 172.199.0.0/16 86 | 87 | -------------------------------------------------------------------------------- /tests/docker-compose.varnish.yml: -------------------------------------------------------------------------------- 1 | # 2 | # See https://www.varnish-software.com/developers/tutorials/running-varnish-docker/ 3 | # 4 | # References: 5 | # - https://book.varnish-software.com/4.0/chapters/VCL_Basics.html 6 | # 7 | version: '3.8' 8 | services: 9 | qgis-server: 10 | environment: 11 | QGSRV_CACHE_OBSERVERS: ban 12 | QGSRV_CACHE_OBSERVERS_BAN_SERVER_ADDRESS: "http://varnish:80" 13 | QGSRV_CACHE_CHECK_INTERVAL: 10 14 | varnish: 15 | image: varnish:7.2-alpine 16 | environment: 17 | VARNISH_SIZE: 500M 18 | volumes: 19 | - ${PWD}/varnish.vcl:/etc/varnish/default.vcl:ro 20 | - ${PWD}/varnish.secret:/etc/varnish/secret:ro 21 | command: ["-S", "/etc/varnish/secret"] 22 | tmpfs: 23 | - /var/lib/varnish/varnishd:exec 24 | ports: 25 | - 127.0.0.1:8889:80 26 | networks: 27 | - backend 28 | depends_on: 29 | - qgis-server 30 | 31 | -------------------------------------------------------------------------------- /tests/docker-compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | qgis-server: 3 | image: ${QGIS_IMAGE} 4 | working_dir: /src 5 | command: ${RUN_COMMAND} 6 | environment: 7 | PYTHONWARNINGS: 'd' 8 | PIP_CACHE_DIR: /.cache 9 | USER: ${USER} 10 | QGIS_OPTIONS_PATH: /src/tests/qgis 11 | QGIS_CUSTOM_CONFIG_PATH: /src/tests/qgis 12 | QGIS_SERVER_TRUST_LAYER_METADATA: 'yes' 13 | QGIS_SERVER_DISABLE_GETPRINT: 'yes' 14 | QGSRV_SERVER_WORKERS: ${WORKERS} 15 | QGSRV_CACHE_ROOTDIR: /src/tests/data 16 | QGSRV_SERVER_PLUGINPATH: /plugins 17 | QGSRV_PROJECTS_SCHEMES_TEST: /src/tests/data/ 18 | QGSRV_PROJECTS_SCHEMES_FOO: file:foobar/ 19 | QGSRV_PROJECTS_SCHEMES_BAR: file:foobar?data={path} 20 | QGSRV_SERVER_RESTARTMON: /src/.qgis-restart 21 | QGSRV_SERVER_HTTP_PROXY: 'no' 22 | QGSRV_LOGGING_LEVEL: DEBUG 23 | QGSRV_DATA_PATH: /.local/share/qgis-server 24 | QGSRV_SERVER_STATUS_PAGE: 'yes' 25 | QGSRV_MANAGEMENT_ENABLED: 'yes' 26 | QGSRV_MANAGEMENT_INTERFACES: '0.0.0.0' 27 | QGSRV_API_ENABLED_LANDING_PAGE: 'yes' 28 | QGSRV_API_ENDPOINTS_LANDING_PAGE: '/ows/catalog' 29 | QGSRV_SERVER_SSL: ${WITH_SSL} 30 | QGSRV_SERVER_SSL_CERT: /src/tests/certs/localhost.crt 31 | QGSRV_SERVER_SSL_KEY: /src/tests/certs/localhost.key 32 | QGSRV_SERVER_TIMEOUT: ${REQUEST_TIMEOUT} 33 | QGSRV_CACHE_STRICT_CHECK: ${STRICT_CHECK} 34 | QGSRV_SERVER_MONITOR: test 35 | QGSRV_MONITOR_TAG_EXTRA_DATA: "monitor.test" 36 | QGSRV_CACHE_OBSERVERS: test 37 | QGSRV_CACHE_ADVANCED_REPORT: 'yes' 38 | PYTEST_ADDOPTS: ${PYTEST_ADDOPTS} 39 | ASYNC_TEST_TIMEOUT: '20' 40 | user: ${BECOME_USER} 41 | volumes: 42 | - ${SRCDIR}:/src 43 | - ${PWD}/.local:/.local 44 | - ${PWD}/server.conf:/server.conf 45 | - ${PWD}/.cache:/.cache 46 | - ${PLUGINPATH}:/plugins 47 | - ${PROJECTPATH}:/src/tests/data 48 | ports: 49 | - ${SERVER_HTTP_PORT}:8080 50 | - ${MANAGEMENT_HTTP_PORT}:19876 51 | networks: 52 | backend: 53 | ipv4_address: 172.199.0.2 54 | #deploy: 55 | # resources: 56 | # limits: 57 | # cpus: ${CPU_LIMITS} 58 | # memory: ${MEMORY_LIMITS} 59 | 60 | networks: 61 | backend: 62 | ipam: 63 | driver: default 64 | config: 65 | - subnet: 172.199.0.0/16 66 | 67 | -------------------------------------------------------------------------------- /tests/plugins/badplugin/.update-manifest: -------------------------------------------------------------------------------- 1 | # For test only 2 | -------------------------------------------------------------------------------- /tests/plugins/badplugin/__init__.py: -------------------------------------------------------------------------------- 1 | from qgis.core import Qgis, QgsMessageLog 2 | 3 | def serverClassFactory(serverIface): # pylint: disable=invalid-name 4 | """Load wfsOutputExtensionServer class from file wfsOutputExtension. 5 | 6 | :param iface: A QGIS Server interface instance. 7 | :type iface: QgsServerInterface 8 | """ 9 | # 10 | return Foo(serverIface) 11 | 12 | class Foo: 13 | def __init__(self, iface): 14 | raise RuntimeError("I'am a BAD plugin !") 15 | 16 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /tests/plugins/badplugin/metadata.txt: -------------------------------------------------------------------------------- 1 | [general] 2 | name=badplugin 3 | qgisMinimumVersion=3.0 4 | ;qgisMaximumVersion= 5 | description=Test bad plugin 6 | version=1.0.0 7 | author= 8 | email= 9 | ; if True it's a server plugin 10 | server=True 11 | 12 | 13 | -------------------------------------------------------------------------------- /tests/plugins/foo/.update-manifest: -------------------------------------------------------------------------------- 1 | # For test only 2 | -------------------------------------------------------------------------------- /tests/plugins/foo/__init__.py: -------------------------------------------------------------------------------- 1 | from qgis.core import Qgis, QgsMessageLog 2 | 3 | def serverClassFactory(serverIface): # pylint: disable=invalid-name 4 | """Load wfsOutputExtensionServer class from file wfsOutputExtension. 5 | 6 | :param iface: A QGIS Server interface instance. 7 | :type iface: QgsServerInterface 8 | """ 9 | # 10 | return Foo(serverIface) 11 | 12 | class Foo: 13 | def __init__(self, iface): 14 | QgsMessageLog.logMessage("SUCCESS - plugin foo initialized") 15 | 16 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /tests/plugins/foo/metadata.txt: -------------------------------------------------------------------------------- 1 | [general] 2 | name=foo 3 | qgisMinimumVersion=3.0 4 | ;qgisMaximumVersion= 5 | description=Test plugin 6 | version=1.0.0 7 | author= 8 | email= 9 | ; if True it's a server plugin 10 | server=True 11 | 12 | 13 | -------------------------------------------------------------------------------- /tests/plugins/headers/.update-manifest: -------------------------------------------------------------------------------- 1 | # For test only 2 | -------------------------------------------------------------------------------- /tests/plugins/headers/__init__.py: -------------------------------------------------------------------------------- 1 | from qgis.core import Qgis, QgsMessageLog 2 | from qgis.server import QgsServerFilter 3 | 4 | def serverClassFactory(serverIface): # pylint: disable=invalid-name 5 | """Load wfsOutputExtensionServer class from file wfsOutputExtension. 6 | 7 | :param iface: A QGIS Server interface instance. 8 | :type iface: QgsServerInterface 9 | """ 10 | # 11 | return Headers(serverIface) 12 | 13 | class Headers: 14 | def __init__(self, iface): 15 | QgsMessageLog.logMessage("SUCCESS - plugin Headers initialized") 16 | self.iface = iface 17 | 18 | iface.registerFilter(HeaderFilter(iface), 10) 19 | 20 | 21 | class HeaderFilter(QgsServerFilter): 22 | def __init__(self, iface): 23 | QgsMessageLog.logMessage("Plugin Initialized", "header_plugin") 24 | super().__init__(iface) 25 | 26 | def responseComplete(self): 27 | QgsMessageLog.logMessage("Response Complete", "header_plugin") 28 | # Check for headers 29 | handler = self.serverInterface().requestHandler() 30 | 31 | qgis_header = handler.requestHeader('X-Qgis-Test') 32 | handler.setResponseHeader('X-Qgis-Header',qgis_header) 33 | 34 | lizmap_header = handler.requestHeader('X-Lizmap-Test') 35 | handler.setResponseHeader('X-Lizmap-Header',lizmap_header) 36 | 37 | 38 | 39 | 40 | -------------------------------------------------------------------------------- /tests/plugins/headers/metadata.txt: -------------------------------------------------------------------------------- 1 | [general] 2 | name=headers 3 | qgisMinimumVersion=3.0 4 | ;qgisMaximumVersion= 5 | description=Test plugin headers 6 | version=1.0.0 7 | author= 8 | email= 9 | ; if True it's a server plugin 10 | server=True 11 | 12 | 13 | -------------------------------------------------------------------------------- /tests/preload.list: -------------------------------------------------------------------------------- 1 | /src/tests/data/france_parts 2 | -------------------------------------------------------------------------------- /tests/qgis/QGIS/QGIS3.ini: -------------------------------------------------------------------------------- 1 | 2 | [Qgis] 3 | compileExpressions=true 4 | 5 | [qgis] 6 | simplifyLocal=false ; true 7 | warnOldProjectVersion=false ; true 8 | networkAndProxy\networkTimeout=10000 ;instead of 60000 9 | defaultTileExpiry=168 ;24 10 | defaultTileMaxRetry=2 ;3 11 | defaultCapabilitiesExpiry=24; 24 12 | compileExpressions=true 13 | 14 | [proxy] 15 | authcfg= 16 | proxyEnabled=true 17 | proxyExcludedUrls= 18 | proxyHost=proxy.whatever.com 19 | proxyPassword= 20 | proxyPort=3128 21 | 22 | -------------------------------------------------------------------------------- /tests/requirements.txt: -------------------------------------------------------------------------------- 1 | requests 2 | pytest >= 5.3 3 | packaging >= 22.0 4 | ruff 5 | mypy 6 | mypy-extensions 7 | types-psutil 8 | types-PyYAML 9 | types-requests 10 | types-psycopg2 11 | pipdeptree 12 | lxml 13 | lxml-stubs 14 | build >= 1.2.2 15 | -------------------------------------------------------------------------------- /tests/run_proxy.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -e 4 | 5 | VENV_PATH=/.local/venv 6 | 7 | PIP="$VENV_PATH/bin/pip" 8 | PIP_INSTALL="$VENV_PATH/bin/pip install -U" 9 | 10 | echo "-- Creating virtualenv" 11 | python3 -m venv --system-site-packages $VENV_PATH 12 | 13 | echo "-- Installing required packages..." 14 | $PIP_INSTALL -q pip setuptools wheel 15 | $PIP install -e ./ 16 | 17 | export QGIS_DISABLE_MESSAGE_HOOKS=1 18 | export QGIS_NO_OVERRIDE_IMPORT=1 19 | 20 | if [ -e /amqp_src ]; then 21 | $PIP install -e /amqp_src/ 22 | fi 23 | 24 | # Run the server locally 25 | echo "Running server..." 26 | exec $VENV_PATH/bin/qgisserver -b 0.0.0.0 -p 8080 --proxy 27 | 28 | 29 | 30 | -------------------------------------------------------------------------------- /tests/run_server.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -e 4 | 5 | VENV_PATH=/.local/venv 6 | 7 | PIP="$VENV_PATH/bin/pip" 8 | PIP_INSTALL="$VENV_PATH/bin/pip install -U" 9 | 10 | echo "-- Creating virtualenv" 11 | python3 -m venv --system-site-packages $VENV_PATH 12 | 13 | echo "-- Installing packages..." 14 | $PIP_INSTALL -q pip setuptools wheel 15 | $PIP install -e ./ 16 | 17 | export QGIS_DISABLE_MESSAGE_HOOKS=1 18 | export QGIS_NO_OVERRIDE_IMPORT=1 19 | 20 | if [ -e /amqp_src ]; then 21 | $PIP install -e /amqp_src/ 22 | fi 23 | 24 | # Run the server locally 25 | echo "Running server..." 26 | exec $VENV_PATH/bin/qgisserver -b 0.0.0.0 -p 8080 -c /server.conf 27 | 28 | 29 | -------------------------------------------------------------------------------- /tests/run_tests.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -e 4 | 5 | echo "-- HOME is $HOME" 6 | 7 | VENV_PATH=/.local/venv 8 | 9 | PIP="$VENV_PATH/bin/pip" 10 | PIP_INSTALL="$VENV_PATH/bin/pip install -U" 11 | 12 | echo "-- Creating virtualenv" 13 | python3 -m venv --system-site-packages $VENV_PATH 14 | 15 | echo "-- Installing required packages..." 16 | $PIP_INSTALL -q pip setuptools wheel 17 | $PIP install -e ./ 18 | 19 | if [ -e /amqp_src ]; then 20 | $PIP install -e /amqp_src/ 21 | fi 22 | 23 | export QGIS_DISABLE_MESSAGE_HOOKS=1 24 | export QGIS_NO_OVERRIDE_IMPORT=1 25 | 26 | # Disable qDebug stuff that bloats test outputs 27 | export QT_LOGGING_RULES="*.debug=false;*.warning=false" 28 | 29 | export QGSRV_SERVER_HTTP_PROXY=yes 30 | 31 | # Run new tests 32 | cd tests/unittests && exec $VENV_PATH/bin/pytest -v $@ 33 | 34 | -------------------------------------------------------------------------------- /tests/run_worker.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | set -e 4 | 5 | VENV_PATH=/.local/venv 6 | 7 | PIP="$VENV_PATH/bin/pip" 8 | PIP_INSTALL="$VENV_PATH/bin/pip install -U" 9 | 10 | echo "-- Creating virtualenv" 11 | python3 -m venv --system-site-packages $VENV_PATH 12 | 13 | echo "-- Installing required packages..." 14 | $PIP_INSTALL -q pip setuptools wheel 15 | $PIP install -e ./ 16 | 17 | export QGIS_DISABLE_MESSAGE_HOOKS=1 18 | export QGIS_NO_OVERRIDE_IMPORT=1 19 | 20 | if [ -e /amqp_src ]; then 21 | $PIP install -e /amqp_src/ 22 | fi 23 | 24 | # Run the server locally 25 | echo "Running worker..." 26 | exec $VENV_PATH/bin/qgisserver-worker --proxy-host=$QGSRV_ZMQ_HOSTADDR 27 | 28 | 29 | 30 | -------------------------------------------------------------------------------- /tests/server.conf: -------------------------------------------------------------------------------- 1 | # Empty on purpose 2 | 3 | -------------------------------------------------------------------------------- /tests/setup_env.sh: -------------------------------------------------------------------------------- 1 | SRCDIR=$(pwd) source tests/tests.env 2 | export $(cut -d= -f1 tests/tests.env) 3 | qgisserver 4 | -------------------------------------------------------------------------------- /tests/tests.env: -------------------------------------------------------------------------------- 1 | QGIS_SERVER_TRUST_LAYER_METADATA=yes 2 | QGIS_OPTIONS_PATH=${SRCDIR}/tests/qgis 3 | QGIS_SERVER_DISABLE_GETPRINT=yes 4 | QGIS_SERVER_LIZMAP_REVEAL_SETTINGS=yes 5 | QGSRV_CACHE_ROOTDIR=${SRCDIR}/tests/data 6 | QGSRV_SERVER_WORKERS=1 7 | QGSRV_SERVER_PLUGINPATH=${SRCDIR}/tests/plugins 8 | QGSRV_PROJECTS_SCHEMES_TEST=${SRCDIR}/tests/data/ 9 | QGSRV_PROJECTS_SCHEMES_FOO=file:foobar/ 10 | QGSRV_PROJECTS_SCHEMES_BAR=file:foobar?data={path} 11 | QGSRV_SERVER_HTTP_PROXY=yes 12 | QGSRV_LOGGING_LEVEL=DEBUG 13 | QGSRV_SERVER_STATUS_PAGE=yes 14 | QGSRV_MANAGEMENT_ENABLED=yes 15 | QGSRV_MANAGEMENT_INTERFACES=0.0.0.0 16 | QGSRV_API_ENABLED_LANDING_PAGE=yes 17 | QGSRV_API_ENDPOINTS_LANDING_PAGE=/ows/catalog 18 | QGSRV_SERVER_TIMEOUT=20 19 | QGSRV_CACHE_STRICT_CHECK=yes 20 | QGSRV_SERVER_MONITOR=test 21 | QGSRV_MONITOR_TAG_EXTRA_DATA=monitor.test 22 | QGSRV_CACHE_OBSERVERS=test 23 | QGSRV_CACHE_ADVANCED_REPORT=yes 24 | QGSRV_SERVER_ENABLE_FILTERS=yes 25 | -------------------------------------------------------------------------------- /tests/unittests/conftest.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | from time import sleep 3 | 4 | import pytest 5 | 6 | from pyqgisserver.tests import TestRuntime 7 | 8 | 9 | def pytest_addoption(parser): 10 | parser.addoption("--with-postgres", action="store_true", help="Run postgres tests", 11 | default=False) 12 | parser.addoption("--with-profiles", action="store_true", help="Run profiles tests", 13 | default=False) 14 | 15 | 16 | def pytest_configure(config): 17 | 18 | # Debug mode 19 | global postgres_user 20 | 21 | # Postgres 22 | config.with_postgres = config.getoption('with_postgres') 23 | config.addinivalue_line("markers", "with_postgres: mark test as postgres run") 24 | 25 | # Profiles 26 | config.with_profiles = config.getoption('with_profiles') 27 | config.addinivalue_line("markers", "with_profiles: mark test as profiles test") 28 | 29 | 30 | @pytest.fixture(scope='session') 31 | def data(request): 32 | return Path(request.config.rootdir.strpath).parent / 'data' 33 | 34 | 35 | def pytest_collection_modifyitems(config, items): 36 | if config.with_postgres: 37 | # postgres enabled: do not skip tests 38 | return 39 | skip_postgres = pytest.mark.skip(reason="Postgres tests disabled") 40 | skip_profiles = pytest.mark.skip(reason="Profiles tests disabled") 41 | for item in items: 42 | if "with_postgres" in item.keywords: 43 | item.add_marker(skip_postgres) 44 | if "with_profiles" in item.keywords: 45 | item.add_marker(skip_profiles) 46 | 47 | 48 | def pytest_sessionstart(session): 49 | """ Start subprocesses 50 | """ 51 | rt = TestRuntime.instance() 52 | rt.start() 53 | print("Waiting for server to initialize...") 54 | sleep(2) 55 | 56 | 57 | def pytest_sessionfinish(session, exitstatus): 58 | """ End subprocesses 59 | """ 60 | rt = TestRuntime.instance() 61 | rt.stop() 62 | -------------------------------------------------------------------------------- /tests/unittests/pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | addopts= --junit-xml=__output__/junit.xml 3 | junit_family=xunit2 4 | log_cli=1 5 | log_cli_level=critical 6 | norecursedirs = 7 | scripts 8 | data 9 | algorithms 10 | .* 11 | __* 12 | 13 | -------------------------------------------------------------------------------- /tests/unittests/test.conf: -------------------------------------------------------------------------------- 1 | 2 | 3 | [projects.cache] 4 | rootdir=/tmp/ 5 | 6 | -------------------------------------------------------------------------------- /tests/unittests/test_a_lru.py: -------------------------------------------------------------------------------- 1 | from pyqgisserver.utils.lru import lrucache 2 | 3 | 4 | def test_lru_ordering(): 5 | """ Test lru cache 6 | """ 7 | c = lrucache(3) 8 | 9 | c['k1'] = 'foo' 10 | c['k2'] = 'bar' 11 | c['k3'] = 'baz' 12 | 13 | assert tuple(c.keys()) == ('k3', 'k2', 'k1') 14 | 15 | # Access key and test reordering 16 | _k = c['k1'] 17 | assert tuple(c.keys()) == ('k1', 'k3', 'k2') 18 | 19 | # Test size keeping 20 | c['k4'] = 'foo' 21 | 22 | assert len(c) == 3 23 | assert tuple(c.keys()) == ('k4', 'k1', 'k3') 24 | -------------------------------------------------------------------------------- /tests/unittests/test_api.py: -------------------------------------------------------------------------------- 1 | """ 2 | Test server disponibility 3 | """ 4 | 5 | 6 | import pytest 7 | 8 | from pyqgisserver.tests import HTTPTestCase 9 | 10 | 11 | class Tests(HTTPTestCase): 12 | 13 | # XXX This test takes an insane amount with QGIS >= 3.26+ 14 | # of time on gitlab CI, this need to be investigated 15 | # See https://github.com/qgis/QGIS/pull/49476 16 | @pytest.mark.skip(reason="Wait to fix project loading in landing page") 17 | def test_landing_page(self): 18 | """ Test landing_page 19 | """ 20 | rv = self.client.get('', path="/ows/catalog/") 21 | assert rv.status_code == 200 22 | -------------------------------------------------------------------------------- /tests/unittests/test_badlayers.py: -------------------------------------------------------------------------------- 1 | """ 2 | Test server disponibility 3 | """ 4 | 5 | from pyqgisserver.tests import HTTPTestCase 6 | 7 | ns = {"wms": "http://www.opengis.net/wms"} 8 | xlink = "{http://www.w3.org/1999/xlink}" 9 | 10 | 11 | class Tests(HTTPTestCase): 12 | 13 | def test_project_ok(self): 14 | """ Test getcapabilities hrefs 15 | """ 16 | rv = self.client.get("?MAP=project_simple&SERVICE=WMS&request=GetCapabilities") 17 | assert rv.status_code == 200 18 | assert rv.headers['Content-Type'] == 'text/xml; charset=utf-8' 19 | 20 | elem = rv.xml.findall(".//wms:Layer/wms:Layer", ns) 21 | assert len(elem) == 2 22 | 23 | def test_project_with_excluded(self): 24 | """ Test getcapabilities hrefs 25 | """ 26 | rv = self.client.get("?MAP=project_simple_with_excluded&SERVICE=WMS&request=GetCapabilities") 27 | assert rv.status_code == 200 28 | assert rv.headers['Content-Type'] == 'text/xml; charset=utf-8' 29 | 30 | elem = rv.xml.findall(".//wms:Layer/wms:Layer", ns) 31 | assert len(elem) == 1 32 | 33 | def test_project_with_invalid(self): 34 | """ Test getcapabilities hrefs 35 | """ 36 | rv = self.client.get("?MAP=project_simple_with_invalid&SERVICE=WMS&request=GetCapabilities") 37 | assert rv.status_code == 422 38 | 39 | def test_project_with_invalid_excluded(self): 40 | """ Test getcapabilities hrefs 41 | """ 42 | rv = self.client.get("?MAP=project_simple_with_invalid_excluded&SERVICE=WMS&request=GetCapabilities") 43 | assert rv.status_code == 200 44 | assert rv.headers['Content-Type'] == 'text/xml; charset=utf-8' 45 | 46 | elem = rv.xml.findall(".//wms:Layer/wms:Layer", ns) 47 | assert len(elem) == 1 48 | -------------------------------------------------------------------------------- /tests/unittests/test_base.py: -------------------------------------------------------------------------------- 1 | """ 2 | Test server disponibility 3 | """ 4 | from pyqgisserver.tests import HTTPTestCase 5 | 6 | 7 | class Tests(HTTPTestCase): 8 | 9 | def test_status_request(self): 10 | """ Test response from root path 11 | """ 12 | rv = self.client.get('', path='/status/') 13 | assert rv.status_code == 200 14 | 15 | def test_wms_getcapabilitiesatlas(self): 16 | """ 17 | """ 18 | rv = self.client.get("?MAP=france_parts.qgs&SERVICE=WMS&request=GetCapabilitiesAtlas") 19 | assert rv.status_code == 501 20 | assert rv.headers['Content-Type'] == 'text/xml; charset=utf-8' 21 | 22 | def test_wms_getcaps(self): 23 | """ 24 | """ 25 | rv = self.client.get("?MAP=france_parts.qgs&SERVICE=WMS&request=GetCapabilities") 26 | assert rv.status_code == 200 27 | assert rv.headers['Content-Type'] == 'text/xml; charset=utf-8' 28 | 29 | def test_wfs_getcaps(self): 30 | """ 31 | """ 32 | rv = self.client.get("?MAP=france_parts.qgs&SERVICE=WFS&request=GetCapabilities") 33 | assert rv.status_code == 200 34 | assert rv.headers['Content-Type'] == 'text/xml; charset=utf-8' 35 | 36 | def test_wcs_getcaps(self): 37 | """ 38 | """ 39 | rv = self.client.get("?MAP=france_parts.qgs&SERVICE=WCS&request=GetCapabilities") 40 | assert rv.status_code == 200 41 | assert rv.headers['Content-Type'] == 'text/xml; charset=utf-8' 42 | 43 | def test_map_not_found_return_404(self): 44 | """ Test that non existent map return 404 45 | """ 46 | rv = self.client.get("?MAP=i_do_not_exists.qgs&SERVICE=WFS&request=GetCapabilities") 47 | assert rv.status_code == 404 48 | 49 | def test_protocol_resolution(self): 50 | """ Test that custom protocol is correctly resolved 51 | """ 52 | rv = self.client.get("?MAP=test:france_parts.qgs&SERVICE=WFS&request=GetCapabilities") 53 | assert rv.status_code == 200 54 | 55 | def test_unknown_protocol_is_404(self): 56 | """ Test that custom protocol is correctly resolved 57 | """ 58 | rv = self.client.get("?MAP=fail:france_parts.qgs&SERVICE=WFS&request=GetCapabilities") 59 | assert rv.status_code == 404 60 | 61 | def test_open_with_basename(self): 62 | """ Test that custom protocol is correctly resolved 63 | """ 64 | rv = self.client.get("?MAP=france_parts&SERVICE=WFS&request=GetCapabilities") 65 | assert rv.status_code == 200 66 | 67 | def test_open_qgz(self): 68 | """ Test that custom protocol is correctly resolved 69 | """ 70 | rv = self.client.get("?MAP=france_parts_qgz&SERVICE=WFS&request=GetCapabilities") 71 | assert rv.status_code == 200 72 | 73 | def test_ows_service_nomap_return_400(self): 74 | """ Test that a ows request without Map return 400 75 | """ 76 | rv = self.client.get("?Service=WMS&request=GetCapabilities") 77 | assert rv.status_code == 400 78 | 79 | def test_allowed_headers(self): 80 | """ Test allowed headers as defined in configuration 81 | and the X-Request-Id header 82 | 83 | Use the 'headers' test plugin 84 | """ 85 | headers = { 86 | 'X-Qgis-Test': 'This is Qgis header', 87 | 'X-Lizmap-Test': 'This is Lizmap header', 88 | 'X-Request-Id': 'foobar', 89 | } 90 | 91 | rv = self.client.get("?MAP=france_parts&SERVICE=WFS&request=GetCapabilities", 92 | headers=headers) 93 | assert rv.status_code == 200 94 | assert rv.headers['X-Qgis-Header'] == headers['X-Qgis-Test'] 95 | assert rv.headers['X-Lizmap-Header'] == headers['X-Lizmap-Test'] 96 | 97 | # Check that X-Request-Id is correctly formarded 98 | assert rv.headers['X-Request-Id'] == headers['X-Request-Id'] 99 | -------------------------------------------------------------------------------- /tests/unittests/test_config.py: -------------------------------------------------------------------------------- 1 | from pyqgisserver.config import confservice 2 | from pyqgisserver.server import read_configuration 3 | 4 | 5 | def test_argument_precedence(): 6 | """ Test argument precedences 7 | 8 | From lowest to highest: 9 | - default 10 | - environment 11 | - config file 12 | - command line 13 | """ 14 | args = read_configuration([ 15 | '--workers', '3', 16 | '--port', '9090', 17 | '--config', 'test.conf', 18 | ]) 19 | 20 | conf = confservice['server'] 21 | 22 | # Workers must be 3 23 | assert args.workers == 3 24 | assert conf.getint('workers') == 3 25 | 26 | # Port must be 9090 27 | assert args.port == 9090 28 | assert conf.getint('port') == 9090 29 | 30 | # rootdir must be '/tmp/' defined in config file 31 | assert confservice.get('projects.cache', 'rootdir') == '/tmp/' 32 | -------------------------------------------------------------------------------- /tests/unittests/test_getcapabilities.py: -------------------------------------------------------------------------------- 1 | """ 2 | Test server disponibility 3 | """ 4 | from urllib.parse import urlparse 5 | 6 | import pytest 7 | 8 | from qgis.core import Qgis 9 | 10 | from pyqgisserver.tests import HTTPTestCase 11 | 12 | ns = {"wms": "http://www.opengis.net/wms"} 13 | 14 | xlink = "{http://www.w3.org/1999/xlink}" 15 | 16 | 17 | class Tests(HTTPTestCase): 18 | 19 | def test_wms_getcapabilities_hrefs(self): 20 | """ Test getcapabilities hrefs 21 | """ 22 | rv = self.client.get("?MAP=france_parts.qgs&SERVICE=WMS&request=GetCapabilities") 23 | assert rv.status_code == 200 24 | assert rv.headers['Content-Type'] == 'text/xml; charset=utf-8' 25 | 26 | elem = rv.xml.findall(".//wms:OnlineResource", ns) 27 | assert len(elem) > 0 28 | 29 | href = urlparse(elem[0].get(xlink + 'href')) 30 | self.logger.info(href.geturl()) 31 | 32 | def test_forwarded_url(self): 33 | """ Test proxy location 34 | """ 35 | urlref = urlparse('https://my.proxy.loc:9999/anywhere/') 36 | rv = self.client.get("/ows/?MAP=france_parts.qgs&SERVICE=WMS&request=GetCapabilities", 37 | headers={'X-Forwarded-Url': urlref.geturl()}, path='') 38 | 39 | assert rv.status_code == 200 40 | assert rv.headers['Content-Type'] == 'text/xml; charset=utf-8' 41 | 42 | elem = rv.xml.findall(".//wms:OnlineResource", ns) 43 | assert len(elem) > 0 44 | 45 | href = urlparse(elem[0].get(xlink + 'href')) 46 | assert href.scheme == urlref.scheme 47 | assert href.hostname == urlref.hostname 48 | assert href.path == f"{urlref.path}ows/" 49 | 50 | def test_wmsurl(self): 51 | """ Test proxy location is overrided by WMSUrl 52 | """ 53 | from pyqgisserver.config import confservice 54 | 55 | proxy_url = 'https://my.proxy.loc:9999/anywhere' 56 | rv = self.client.get("?MAP=france_parts_wmsurl.qgs&SERVICE=WMS&request=GetCapabilities", 57 | headers={'X-Forwarded-Url': proxy_url}) 58 | 59 | assert rv.status_code == 200 60 | assert rv.headers['Content-Type'] == 'text/xml; charset=utf-8' 61 | 62 | elem = rv.xml.findall(".//wms:OnlineResource", ns) 63 | assert len(elem) > 0 64 | 65 | if confservice.getboolean('projects.cache', 'disable_owsurls'): 66 | urlref = urlparse(proxy_url) 67 | else: 68 | urlref = urlparse("http://test.proxy.loc/whatever/") 69 | 70 | href = urlparse(elem[0].get(xlink + 'href')) 71 | assert href.scheme == urlref.scheme 72 | assert href.hostname == urlref.hostname 73 | assert href.path == urlref.path 74 | 75 | def test_cors_options(self): 76 | """ Test CORS options 77 | """ 78 | rv = self.client.options(headers={'Origin': 'my.home'}) 79 | 80 | assert rv.status_code == 200 81 | assert 'Allow' in rv.headers 82 | assert 'Access-Control-Allow-Methods' in rv.headers 83 | assert 'Access-Control-Allow-Origin' in rv.headers 84 | 85 | def test_ows_request_with_cors(self): 86 | """ Test getcapabilities hrefs 87 | """ 88 | rv = self.client.get("?MAP=france_parts.qgs&SERVICE=WMS&request=GetCapabilities", 89 | headers={'Origin': 'my.home'}) 90 | 91 | assert rv.status_code == 200 92 | assert rv.headers['Content-Type'] == 'text/xml; charset=utf-8' 93 | assert 'Access-Control-Allow-Origin' in rv.headers 94 | 95 | def test_lower_case_query_params(self): 96 | """ Test that we support lower case query param 97 | """ 98 | rv = self.client.get("?map=france_parts.qgs&SERVICE=WMS&request=GetCapabilities") 99 | assert rv.status_code == 200 100 | 101 | @pytest.mark.skipif(Qgis.QGIS_VERSION_INT <= 32000, reason="Requires qgis >= 3.20") 102 | def test_qgis_urls(self): 103 | """ Test X-Qgis-* urls 104 | see https://github.com/qgis/QGIS/pull/41333 105 | """ 106 | urlref = urlparse('https://my.proxy.loc:9999/anywhere/') 107 | rv = self.client.get("?MAP=france_parts.qgs&SERVICE=WMS&request=GetCapabilities", 108 | headers={'X-Qgis-Service-Url': urlref.geturl()}) 109 | 110 | assert rv.status_code == 200 111 | assert rv.headers['Content-Type'] == 'text/xml; charset=utf-8' 112 | 113 | elem = rv.xml.findall(".//wms:OnlineResource", ns) 114 | assert len(elem) > 0 115 | 116 | href = urlparse(elem[0].get(xlink + 'href')) 117 | assert href.scheme == urlref.scheme 118 | assert href.hostname == urlref.hostname 119 | assert href.path == urlref.path 120 | 121 | def test_getcapabilities_etag(self): 122 | """ Test getcapabilities etag 123 | """ 124 | rv = self.client.get("?MAP=france_parts.qgs&SERVICE=WFS&request=GetCapabilities") 125 | assert rv.status_code == 200 126 | assert rv.headers['Content-Type'] == 'text/xml; charset=utf-8' 127 | 128 | etag = rv.headers.get('Etag') 129 | assert etag is not None 130 | 131 | # Redo request with etag 132 | rv = self.client.get("?MAP=france_parts.qgs&SERVICE=WFS&request=GetCapabilities", 133 | headers={'If-None-Match': etag}) 134 | assert rv.status_code == 304 135 | 136 | def test_head_getcapabilities_request(self): 137 | """ Test HEAD request returning Etag 138 | """ 139 | rv = self.client.head("?MAP=france_parts.qgs&SERVICE=WFS&request=GetCapabilities") 140 | assert rv.status_code == 200 141 | 142 | etag = rv.headers.get('Etag') 143 | assert etag is not None 144 | 145 | # Redo GET request 146 | rv = self.client.get("?MAP=france_parts.qgs&SERVICE=WFS&request=GetCapabilities") 147 | assert rv.status_code == 200 148 | assert rv.headers.get('Etag') == etag 149 | -------------------------------------------------------------------------------- /tests/unittests/test_getfeatures.py: -------------------------------------------------------------------------------- 1 | """ 2 | Test Getfeature requests 3 | """ 4 | 5 | 6 | from pyqgisserver.config import confservice 7 | from pyqgisserver.tests import NAMESPACES, HTTPTestCase 8 | 9 | xlink = "{http://www.w3.org/1999/xlink}" 10 | 11 | 12 | class Tests1(HTTPTestCase): 13 | 14 | def get_app(self) -> None: 15 | confservice.set('server', 'getfeaturelimit', "-1") 16 | return super().get_app() 17 | 18 | def test_getfeature_nolimit(self): 19 | """ Test getcapabilities hrefs 20 | """ 21 | rv = self.client.get( 22 | "?MAP=france_parts.qgs&SERVICE=WFS&REQUEST=GetFeature&VERSION=1.0.0" 23 | "&TYPENAME=france_parts_bordure", 24 | ) 25 | assert rv.status_code == 200 26 | assert rv.headers['Content-Type'].startswith('text/xml;') 27 | 28 | features = rv.xml.findall(".//gml:featureMember", NAMESPACES) 29 | assert len(features) == 4 30 | 31 | def test_getfeature_nolimit_geojson(self): 32 | """ Test getcapabilities hrefs 33 | """ 34 | rv = self.client.get( 35 | "?MAP=france_parts.qgs&SERVICE=WFS&REQUEST=GetFeature&VERSION=1.0.0" 36 | "&TYPENAME=france_parts_bordure" 37 | "&OUTPUTFORMAT=GeoJSON", 38 | ) 39 | assert rv.status_code == 200 40 | assert rv.headers['Content-Type'].startswith('application/vnd.geo+json;') 41 | 42 | content = rv.json() 43 | 44 | # print("\ntest_getfeature_nolimit_geojson", content) 45 | 46 | assert content.get('type') == "FeatureCollection" 47 | assert len(content["features"]) == 4 48 | 49 | 50 | class Tests2(HTTPTestCase): 51 | 52 | def get_app(self) -> None: 53 | confservice.set('server', 'getfeaturelimit', "2") 54 | return super().get_app() 55 | 56 | def test_getfeature_limit(self): 57 | """ Test getcapabilities hrefs 58 | """ 59 | 60 | assert confservice.getint('server', 'getfeaturelimit') == 2 61 | 62 | rv = self.client.get( 63 | "?MAP=france_parts.qgs&SERVICE=WFS&REQUEST=GetFeature&VERSION=1.0.0" 64 | "&TYPENAME=france_parts_bordure", 65 | ) 66 | assert rv.status_code == 200 67 | assert rv.headers['Content-Type'].startswith('text/xml;') 68 | 69 | features = rv.xml.findall(".//gml:featureMember", NAMESPACES) 70 | assert len(features) == 2 71 | 72 | def test_getfeature_limit_ok(self): 73 | """ Test getcapabilities hrefs 74 | """ 75 | 76 | assert confservice.getint('server', 'getfeaturelimit') == 2 77 | 78 | rv = self.client.get( 79 | "?MAP=france_parts.qgs&SERVICE=WFS&REQUEST=GetFeature&VERSION=1.0.0" 80 | "&TYPENAME=france_parts_bordure&MAXFEATURES=1", 81 | ) 82 | assert rv.status_code == 200 83 | assert rv.headers['Content-Type'].startswith('text/xml;') 84 | 85 | features = rv.xml.findall(".//gml:featureMember", NAMESPACES) 86 | assert len(features) == 1 87 | 88 | def test_getfeature_limit_not_ok(self): 89 | """ Test getcapabilities hrefs 90 | """ 91 | assert confservice.getint('server', 'getfeaturelimit') == 2 92 | 93 | rv = self.client.get( 94 | "?MAP=france_parts.qgs&SERVICE=WFS&REQUEST=GetFeature&VERSION=1.0.0" 95 | "&TYPENAME=france_parts_bordure&MAXFEATURES=3", 96 | ) 97 | assert rv.status_code == 200 98 | assert rv.headers['Content-Type'].startswith('text/xml;') 99 | 100 | features = rv.xml.findall(".//gml:featureMember", NAMESPACES) 101 | assert len(features) == 2 102 | -------------------------------------------------------------------------------- /tests/unittests/test_getlegendgraphic.py: -------------------------------------------------------------------------------- 1 | """ 2 | Test Getfeature requests 3 | """ 4 | import pytest 5 | 6 | from pyqgisserver.tests import HTTPTestCase 7 | 8 | xlink = "{http://www.w3.org/1999/xlink}" 9 | 10 | 11 | class Tests(HTTPTestCase): 12 | 13 | @pytest.mark.skip(reason="Wait for proper datasource") 14 | def test_getlegendgraphic_xml(self): 15 | """ Test getlegendgraphic 16 | """ 17 | rv = self.client.get( 18 | "?MAP=france_parts.qgs&SERVICE=WFS&REQUEST=GetLegendGraphic&VERSION=1.0.0" 19 | "&FORMAT=image/png&WIDTH=20&HEIGHT=20&LAYER=france_parts_bordure", 20 | ) 21 | assert rv.status_code == 200 22 | assert rv.headers['Content-Type'].startswith('text/xml;') 23 | 24 | @pytest.mark.skip(reason="Wait for proper datasource") 25 | def test_getlegendgraphic_json(self): 26 | """ Test getlegendgraphic in json format 27 | """ 28 | rv = self.client.get( 29 | "?MAP=france_parts.qgs&SERVICE=WFS&REQUEST=GetLegendGraphic&VERSION=1.0.0" 30 | "&FORMAT=json&WIDTH=20&HEIGHT=20&LAYER=france_parts_bordure", 31 | ) 32 | assert rv.status_code == 200 33 | assert rv.headers['Content-Type'].startswith('text/xml;') 34 | -------------------------------------------------------------------------------- /tests/unittests/test_getmap.py: -------------------------------------------------------------------------------- 1 | """ 2 | Test server disponibility 3 | """ 4 | from urllib.parse import urlencode 5 | 6 | from pyqgisserver.tests import HTTPTestCase 7 | 8 | 9 | class Tests(HTTPTestCase): 10 | 11 | def test_getmap_request(self): 12 | """ Test response from root path 13 | """ 14 | query = ('?bbox=-621646.696284,5795001.359349,205707.697759,6354520.406319&crs=EPSG:3857' 15 | '&dpi=96&exceptions=application/vnd.ogc.se_inimage&format=image/png&height=915' 16 | '&layers=france_parts&map=france_parts.qgs&request=GetMap' 17 | '&service=WMS&styles=default&transparent=TRUE&version=1.3.0&width=1353') 18 | 19 | rv = self.client.get(query) 20 | assert rv.status_code == 200 21 | 22 | def test_getmap_post_request(self): 23 | """ Test response from root path 24 | """ 25 | arguments = { 26 | 'bbox': '-621646.696284,5795001.359349,205707.697759,6354520.406319', 27 | 'crs': 'EPSG:3857', 28 | 'dpi': '96', 29 | 'exceptions': 'application/vnd.ogc.se_inimage', 30 | 'format': 'image/png', 31 | 'height': '915', 32 | 'layers': 'france_parts', 33 | 'map': 'france_parts.qgs', 34 | 'request': 'GetMap', 35 | 'service': 'WMS', 36 | 'styles': 'default', 37 | 'transparent': 'TRUE', 38 | 'version': '1.3.0', 39 | 'width': '1353'} 40 | 41 | rv = self.client.post(urlencode(arguments)) 42 | assert rv.status_code == 200 43 | -------------------------------------------------------------------------------- /tests/unittests/test_monitor.py: -------------------------------------------------------------------------------- 1 | """ Test monitoring data 2 | """ 3 | import os 4 | 5 | from pyqgisserver.monitor import Monitor 6 | from pyqgisserver.tests import HTTPTestCase 7 | 8 | 9 | class Tests(HTTPTestCase): 10 | 11 | def test_monitor_data(self): 12 | """ Test getcapabilities hrefs 13 | """ 14 | monitor = Monitor.instance() 15 | assert monitor is not None 16 | 17 | monitor.messages.clear() 18 | 19 | # Check invironment 20 | assert 'QGSRV_MONITOR_TAG_EXTRA_DATA' in os.environ 21 | assert 'EXTRA_DATA' in monitor.global_tags 22 | 23 | # Send request 24 | rv = self.client.get("?MAP=france_parts.qgs&SERVICE=WMS&request=GetCapabilities") 25 | assert rv.status_code == 200 26 | 27 | # check monitor data 28 | assert len(monitor.messages) == 1 29 | 30 | params, _meta = monitor.messages[0] 31 | assert params['MAP'] == 'france_parts.qgs' 32 | assert params['SERVICE'] == 'WMS' 33 | assert params['REQUEST'] == 'GetCapabilities' 34 | assert params['EXTRA_DATA'] == 'monitor.test' 35 | assert params['RESPONSE_STATUS'] == 200 36 | assert 'RESPONSE_TIME' in params 37 | -------------------------------------------------------------------------------- /tests/unittests/test_regression.py: -------------------------------------------------------------------------------- 1 | """ 2 | Test server disponibility 3 | """ 4 | import pytest 5 | 6 | from pyqgisserver.tests import HTTPTestCase 7 | 8 | ns = {"wms": "http://www.opengis.net/wms"} 9 | 10 | xlink = "{http://www.w3.org/1999/xlink}" 11 | 12 | 13 | class Tests(HTTPTestCase): 14 | 15 | @pytest.mark.skip(reason="This test randomly fail, need to investigate") 16 | def test_wfs_segfault(self): 17 | """ Test that wfs request return a result 18 | see https://projects.3liz.org/infra-v3/py-qgis-server/issues/3 19 | """ 20 | urlref = ("?map=Hot_Spot_Deforestation_Patch_analysis.qgs&request=GetFeature&service=WFS" 21 | "&typename=Near_real_time_deforestation&version=1.0.0") 22 | rv = self.client.get(urlref) 23 | assert rv.status_code < 500 24 | -------------------------------------------------------------------------------- /tests/unittests/test_wfs.py: -------------------------------------------------------------------------------- 1 | """ 2 | Test server disponibility 3 | """ 4 | 5 | 6 | from pyqgisserver.tests import HTTPTestCase 7 | 8 | ns = { 9 | "wfs": "http://www.opengis.net/wfs", 10 | "qgs": "http://www.qgis.org/gml", 11 | "xsd": "http://www.w3.org/2001/XMLSchema", 12 | } 13 | 14 | xlink = "{http://www.w3.org/1999/xlink}" 15 | 16 | 17 | class Tests(HTTPTestCase): 18 | 19 | def test_wfs_describe_feature_type(self): 20 | """ Test DescribeFeatureType 21 | """ 22 | rv = self.client.get("?MAP=france_parts.qgs&SERVICE=WFS&request=DescribeFeatureType&VERSION=1.0.0" 23 | "&TypeName=france_parts_bordure") 24 | 25 | assert rv.status_code == 200 26 | assert rv.headers['Content-Type'] == 'text/xml; charset=utf-8' 27 | 28 | elem = rv.xml.findall(".//xsd:complexContent", ns) 29 | assert len(elem) > 0 30 | 31 | def test_wfs_getfeature(self): 32 | """ Test DescribeFeatureType 33 | """ 34 | rv = self.client.get("?MAP=france_parts.qgs&SERVICE=WFS&request=GetFeature&VERSION=1.0.0" 35 | "&TypeName=france_parts_bordure") 36 | 37 | assert rv.status_code == 200 38 | 39 | content_type = rv.headers['Content-Type'] 40 | assert "text/xml" in content_type 41 | assert "subtype=gml" in content_type 42 | 43 | elem = rv.xml.findall(".//qgs:france_parts_bordure", ns) 44 | assert len(elem) > 0 45 | -------------------------------------------------------------------------------- /tests/unittests/test_wfs3.py: -------------------------------------------------------------------------------- 1 | """ 2 | Test server disponibility 3 | """ 4 | 5 | 6 | from pyqgisserver.tests import HTTPTestCase 7 | 8 | ns = {"wms": "http://www.opengis.net/wms"} 9 | 10 | xlink = "{http://www.w3.org/1999/xlink}" 11 | 12 | 13 | class Tests(HTTPTestCase): 14 | 15 | def test_wfs3(self): 16 | """ Test wfs3 17 | """ 18 | rv = self.client.get('', path="/wfs3/?MAP=france_parts.qgs") 19 | assert rv.status_code == 200 20 | assert rv.headers['Content-Type'].find('application/json') >= 0 21 | 22 | rv = self.client.get('', path="/wfs3.json?MAP=france_parts.qgs") 23 | assert rv.status_code == 200 24 | assert rv.headers['Content-Type'].find('application/json') >= 0 25 | 26 | data = rv.json() 27 | assert data['links'][0]['href'].find("/wfs3.json?MAP=france_parts") 28 | 29 | rv = self.client.get('', path="/wfs3.html?MAP=france_parts.qgs") 30 | assert rv.status_code == 200 31 | assert rv.headers['Content-Type'].find('text/html') >= 0 32 | 33 | def test_wfs3_collections(self): 34 | """ Test wfs3 35 | """ 36 | rv = self.client.get('', path="/wfs3/collections/?MAP=france_parts.qgs") 37 | assert rv.status_code == 200 38 | assert rv.headers['Content-Type'].find('application/json') >= 0 39 | 40 | rv = self.client.get('', path="/wfs3/collections.json?MAP=france_parts.qgs") 41 | assert rv.status_code == 200 42 | assert rv.headers['Content-Type'].find('application/json') >= 0 43 | 44 | rv = self.client.get('', path="/wfs3/collections.html?MAP=france_parts.qgs") 45 | assert rv.status_code == 200 46 | assert rv.headers['Content-Type'].find('text/html') >= 0 47 | 48 | def test_wfs3_should_return_404(self): 49 | """ Test wfs3 50 | """ 51 | rv = self.client.get('', path="/wfs3/foobar/?MAP=france_parts.qgs") 52 | assert rv.status_code == 404 53 | 54 | def test_wfs3_limit_parameter(self): 55 | """ Test parameters in wfs3 56 | """ 57 | rv = self.client.get('', path="/wfs3/collections/france_parts/items.json?MAP=france_parts&limit=1") 58 | assert rv.status_code == 200 59 | data = rv.json() 60 | assert len(data['features']) == 1 61 | 62 | rv = self.client.get('', path="/wfs3/collections/france_parts/items.json?MAP=france_parts&limit=2") 63 | assert rv.status_code == 200 64 | data = rv.json() 65 | assert len(data['features']) == 2 66 | -------------------------------------------------------------------------------- /tests/varnish.secret: -------------------------------------------------------------------------------- 1 | varnishsecretwhateveritcanbe 2 | -------------------------------------------------------------------------------- /tests/varnish.vcl: -------------------------------------------------------------------------------- 1 | # 2 | # This is an example VCL file for Varnish. 3 | # 4 | # It does not do anything by default, delegating control to the 5 | # builtin VCL. The builtin VCL is called when there is no explicit 6 | # return statement. 7 | # 8 | # See the VCL chapters in the Users Guide for a comprehensive documentation 9 | # at https://www.varnish-cache.org/docs/. 10 | 11 | # Marker to tell the VCL compiler that this VCL has been written with the 12 | # 4.0 or 4.1 syntax. 13 | vcl 4.1; 14 | 15 | import std; 16 | 17 | # Cf https://www.getpagespeed.com/server-setup/varnish/varnish-5-2-grace-mode 18 | import xkey; 19 | 20 | # acl for administrative requests (i.e BAN) 21 | # Set this to the configured network between admin backend 22 | # and varnish 23 | acl purge_acl { 24 | "172.199.0.2"; // Our backend network 25 | } 26 | 27 | # Default probe 28 | probe default { 29 | .request = 30 | "HEAD /ping HTTP/1.1" 31 | "Connection: close" 32 | "Host: qgis.server" 33 | "User-Agent: Varnish Health Probe"; 34 | .interval = 10s; 35 | .timeout = 2s; 36 | } 37 | 38 | # Default backend definition. Set this to point to your content server. 39 | backend default { 40 | .host = "qgis-server"; 41 | .port = "8080"; 42 | } 43 | 44 | 45 | sub vcl_recv { 46 | # Happens before we check if we have this in cache already. 47 | # 48 | # Typically you clean up the request here, removing cookies you don't need, 49 | # rewriting the request, etc. 50 | 51 | # Handle BAN request 52 | if (req.method == "BAN") { 53 | if (!client.ip ~ purge_acl) { 54 | return(synth(405,"Not Allowed")); 55 | } 56 | set req.http.n-gone = xkey.softpurge(req.http.X-Map-Id); 57 | return(synth(200,"Ban Added for "+req.http.n-gone+" objects")); 58 | } 59 | 60 | # Do not cache other than WMTS or GetCapabilities 61 | if(req.url !~ "(?i)(&|\?)service=wmts" && req.url !~ "(?i)(&|\?)request=getcapabilities") { 62 | return(pass); 63 | } 64 | } 65 | 66 | sub vcl_backend_response { 67 | # Happens after we have read the response headers from the backend. 68 | # 69 | # Here you clean the response headers, removing silly Set-Cookie headers 70 | # and other mistakes your backend does. 71 | 72 | # Set grace period long enough to get 73 | # the response from long loading projects 74 | set beresp.grace = 10m; 75 | 76 | # Keep the response in cache for 24 hours if the response has 77 | # validating headers. 78 | if (beresp.http.ETag || beresp.http.Last-Modified) { 79 | set beresp.keep = 24h; 80 | } 81 | 82 | # Set the xkey tag so that we may use it in softpurge 83 | set beresp.http.xkey = beresp.http.X-Map-Id; 84 | 85 | return (deliver); 86 | } 87 | 88 | sub vcl_deliver { 89 | # Happens when we have all the pieces we need, and are about to send the 90 | # response to the client. 91 | # 92 | # You can do accounting or modifying the final object here. 93 | } 94 | 95 | --------------------------------------------------------------------------------