Test layers
Every test in packages/qgis-sdk-python/tests belongs to exactly one layer, and each layer is a pytest marker you can select on its own (TASK-7). The markers are registered under --strict-markers by tests/conftest.py from tests/layers.py:
| Marker | Needs | Selected by |
|---|---|---|
pure |
Python only - no QGIS, Qt, display or network | derived: every test that declares no native layer |
qt |
a QApplication (offscreen) |
pytestmark = pytest.mark.qt |
qgis |
an initialised QgsApplication |
pytestmark = pytest.mark.qgis |
webengine |
Qt WebEngine and WebChannel under the application | pytestmark = pytest.mark.webengine |
desktop |
a real QGIS Desktop or headless native qualification host | @pytest.mark.desktop |
server |
a QGIS built with server bindings (qgis.server), which conda-forge does not provide |
pytestmark = pytest.mark.server, skipped by importorskip elsewhere; run by the QGIS 3.44 LTS row |
native |
umbrella for qt, qgis, webengine, desktop, server |
derived |
smoke |
explicit real-QGIS qualification probes, excluded by default | @pytest.mark.smoke |
# the pure half, the way the macOS/Windows matrix runs it (QGIS is not installed there)pixi run -e default pytest -c packages/qgis-sdk-python/pyproject.toml packages/qgis-sdk-python/tests -m pure# one native layer, or all of them, offscreenQT_QPA_PLATFORM=offscreen pixi run -e default pytest -c packages/qgis-sdk-python/pyproject.toml packages/qgis-sdk-python/tests -m webengineQT_QPA_PLATFORM=offscreen QGIS_SDK_NATIVE_SERVICES=1 pixi run -e default pytest -c packages/qgis-sdk-python/pyproject.toml packages/qgis-sdk-python/tests -m 'native and not smoke'How the layers stay honest:
- A native module imports its bindings in one guarded
tryat the top and callspytest.skip(..., allow_module_level=True)with the reason, so a host without Qt reports what it lacks instead of failing.tests/test_layers.pyruns a pure module next to the native ones in a child interpreter that refusesqgisandPyQt6imports and checks exactly that. - The one application per process is owned by the session fixtures
qt_appandqgis_appintests/conftest.py(qt_appis already aQgsApplicationwhenever the QGIS bindings import, so the two layers never disagree about the application a process holds;qgis_appskips when only Qt is there) and stopped once at the end of the session;webengine_profileis the oneQWebEngineProfile, because Chromium tears a profile down once per process. Native modules takeappfrom these fixtures rather than building their own, so module order no longer decides which application a test sees.tests/layers.pyprepares the headless environment (QT_QPA_PLATFORM,QGIS_PREFIX_PATH, the Chromium flags, an auth database directory) before any native module is collected, without importing Qt. - The service backends in
tests/services/run every contract against the in-memory fakes in the pure layer, and against real QGIS as a secondqgis-marked parameter whenQGIS_SDK_NATIVE_SERVICES=1is set;tests/native_support.pycarries the loopback HTTP origin and thepump/settleloop helpers the network, WebEngine and WebChannel modules share. pytest-bddfeatures undertests/features/are ordinary pytest tests: the steps are functions in the same modules, andtests/bridge_steps.py- loaded as a plugin bytests/conftest.py- shows the shared form, where thecontract_hostfixture and the step definitions serve bothbridge_contract.featureandtest_bridge_contract_fixtures.py. A scenario can carry a native marker like any other test.- The bridge contract vectors in
packages/qgis-sdk-typescript/tests/contracts/bridge/cases.jsonare generated byscripts/generate_bridge_cases.pyfrom the real dispatcher and consumed by the TypeScript client, the pure replay test and the feature above;tests/test_webchannel_native.pyis the separate integration evidence that a real page overQWebChannelis bound to the host-minted identity and loses its subscriptions when the host disposes the view.
The server layer and the QGIS 3.44 LTS row
Section titled “The server layer and the QGIS 3.44 LTS row”qgis.server - QgsServer, QgsServerInterface, QgsServerFilter - exists
only in a QGIS configured with WITH_SERVER=ON and its Python bindings.
conda-forge passes -D WITH_SERVER=FALSE on every platform, so the Pixi
environment these harnesses run in has no such module (decision-52
implementation note). The server layer is therefore the one layer the default
environment cannot run.
The QGIS a Linux user can actually install from a repository is the 3.44 LTS,
and that build is also the one with qgis.server in it - and it is a Qt 5 /
PyQt5 build, while the locked environment is Qt 6 / PyQt6. One CI job covers
both facts (decision-70):
- it installs
qgis,qgis-serverandpython3-qgisfrom the qgis.org Debian repository onubuntu-24.04, creates a virtual environment over the system interpreter (--system-site-packages, which is what makes the apt bindings importable next to pytest), and callsscripts/ci.py lts; - that helper probes the build first and prints
::notice::LTS host: qgis 3.44.x | qgisserver QgsServer | qt 5.15.x | binding PyQt5, then runstests/test_server_native.py(the server layer),tests/test_lts_native.py(the Desktop host on this binding, offscreen) andtests/test_server_devtools_native.py(thedev --host serverloop, opted in withQGIS_SDK_SERVER_DEV=1), each in its own pytest process with its own log, so no half can hide behind another’s summary line; - a missing
qgis.server, aqgis.corethat does not import, a run that collected nothing, or a module that reportsskippedtest count is a failure with an annotation, never a pass - a suite that skips everything must not read as evidence, andimportorskipis precisely the way a native module says “this host cannot run me”, which on this row is the thing being disproved; - the job is part of the
checksaggregate, so it gates.
First green run: 2026-10-30, run 41115792940 job 124269563846, probe
qgis 3.44.7-Solothurn | qgisserver QgsServer | qt 5.15.13 | binding PyQt5,
7 passed for the Desktop module and 3 passed for the server module. Getting
there took two defects the row itself exposed, both of which are the reason it
is worth a job rather than a note: the adapters imported PyQt6 by name (so a
PyQt5 QGIS crashed on the first import), and the session fixture that starts the
application did too and pointed QGIS_PREFIX_PATH at sys.prefix - a virtual
environment built with --system-site-packages owns no QGIS, and a
QgsApplication with the wrong prefix initialises without providers. The
fixtures now import Qt through qgis.PyQt like the adapters, and
tests/layers.py finds the installation by walking up from the bindings to the
directory that carries share/qgis (/usr on Debian and Ubuntu), falling back
to sys.prefix where there is no QGIS at all.
The development loop for a server plugin is a third layer: qgis-sdk dev --host server starts a real QgsServer in the process and answers HTTP on loopback,
so the harness drives the command a user types (spawn the CLI, wait for the
port, request the plugin’s own SERVICE, save an edit to the @server.filter,
request again). What it proves is the reload, not a restart: QGIS Server cannot
withdraw a registration, so the reload hands the objects QGIS holds the new hook
set (QgsServerHost.rebind) and the second body is the one the edited filter
stamped.
The server host has two layers of its own, because a deployed QGIS Server calls three objects at three moments of one request and none of them exists on the default environment:
tests/qgis_server_support.pyholds stand-ins forQgsServerFilter,QgsService,QgsAccessControlFilter,QgsServerInterface,QgsRequestHandler,QgsServerRequestandQgsServerResponse- QGIS’s own spellings, no behaviour beyond what the adapter reads.tests/test_server_wire.pypins the mapping between those objects and the plainServerRequest/ServerResponsemodels, andtests/test_server_adapter.pypins the wiring: which hook runs in which slot, what a rewrite leaves in the request QGIS’s services read, what an answer leaves in the handler, and that a refused layer clears all four permissions. Both are pure, so a wrongparameterMap/setResponseHeadercosts a second locally rather than a CI round trip.tests/test_server_native.pydrives the same adapter through a realQgsServeron theQGIS 3.44 LTSrow: aQgsBufferServerRequestwhoseSERVICE=WMSa@server.filterrewrites to the plugin’s own@server.service, a body that proves the service read the rewritten parameters, an access hook answering QGIS’s per-layer question on realQgsVectorLayers, and the registration landing inserverInterface().filters(). No FCGI, no network.tests/test_server_adapter_native.pyruns in the Pixi environment, where QGIS is real andqgis.serveris not: the adapter imports there and refuses a host by name, namingqgis-server/WITH_SERVER=ONand warning off conda-forge.
tests/test_lts_native.py is small on purpose: it reports which binding this
QGIS has and proves the three places Qt 5 and Qt 6 disagree - a dock area, a
QAction, an item flag - through qgis_sdk.adapters._qt, then runs one job on
a QgsTask. Everything else about the hosts is proven in the native suite on
the locked Qt 6 environment, and about the server dispatcher in the pure layer
against qgis_sdk.testing.FakeServerInterface. The row does not run the
WebEngine layer, so the matrix row leaves its Chromium cell empty and says why
(decision-70 point 4).