Conventions¶
The rules of this code base. The architecture and invariants pages say what the pieces are. This page says how to work on them, and links to the page that teaches each practice.
python-geoservercloud first, always. Before you write a GeoServer call, look for the library method in
geoservercloud/geoservercloud.pyof the bundled wheel. Use it, even when a raw request would be shorter. When the method does not exist, or cannot do what is needed, do three things.Update issue #1 first. Add a row with the call site, the REST verb and path, and the library API you would want. That issue is the work list for the library; a gap that is not in it is never fixed upstream.
Then, and only then, work around it here through
self._raw_rest(...), never a barerest_clientcall, with aTODO(#1)comment at the call site.When the library gains the method and the bundled wheel is bumped, replace the workaround, drop the
TODO(#1), and tick the row.
The same applies to behaviour the plugin papers over:
_check,_resource_exists, the datastore merge. Those are library gaps too, and they are listed in #1. We depend on this library; the fastest way to make the plugin better is to make the library better. ATODO(#1)stays in place until the library has the method and the workaround is gone.The global-or-workspace scope lives in
gui/scope.py(theGLOBALlabel andscope()), because styles, layer groups and the dialog’s own workspace-link helper all need it._layer_uri()is still reached fromtab_layers.pythrough the shared dialog class; lift it the same way when a third caller appears.One
_open_workspace_from_row()on the dialog serves every tab’s Workspace column (column 1) and skips the global label. A tab points its_extra_click_callbacksat it instead of writing its own.A form’s primary button says what it does.
ResourceFormDialog(..., ok_label="Create" | "Publish" | "Upload" | "Apply" | "Set style"); only an edit keeps the default “Save”. An Add button’s label starts with a verb and never says “New” (Add a Workspace,Publish a Layer,Upload a Style). The form it opens has the same words in its title.tests/qgis/test_ux.pychecks every tab for both rules.An empty table says why.
_empty_state_text()tells apart a fruitless filter (“Nothing matches ‘x’. Esc clears the filter.”), an empty resource type (“Nothing here yet. Start with ‘Add a Workspace’ above.”) and the plain fallback. It readsbtn_add.isHidden(), notisVisible(); see invariant 8.Colours come from the palette, never from a literal.
gui/theme.pymaps “ok”, “error” and “busy”, and the hint and invalid-field colours, onto the widget’s own palette, with a light or a dark variant.tests/qgis/test_ux.pyasserts that each one clears WCAG’s 3:1 contrast floor against the window colour in both themes. A prettier colour that cannot be read fails the suite.Every hook into QGIS is undone in
unload().LayerTreeMenuconnectsQgsLayerTreeView.contextMenuAboutToShowininitGuiand disconnects it inunload(), before the dialog is destroyed. This repository is developed with plugin_reloader, and a hook left behind fires into the dead plugin on the next reload.tests/qgis/test_layer_tree.pydrivesinitGuithenunloadon a fakeifaceand checks that the menu stops appearing. The menu never acts silently: a push confirms the target and the style name, a pull asks which style when there are several. Without a connection it disables its entries and says why, instead of opening a form that complains.Keyboard: F5, Ctrl+F, Esc and Del live in
GeoServerMainDialog.keyPressEvent, not inQShortcut, because each one has to know where the focus is. Del deletes only while the table has the focus; in the search box the same key erases a character. Esc clears the search only when there is one, so it still closes the dialog otherwise.Nothing the dialog shows comes from a cache. A list is fetched on every tab switch and every Refresh. An edit form fetches the object when it opens. A picker fetches its options when the form opens. A workspace-name cache once survived a Refresh and left the datastore form’s combo stale; it was removed, not given a TTL. One extra GET is always cheaper than a stale view.
The smallest change that removes demonstrated friction. No abstraction with one implementation, no setting for a value that never changes. Add a service layer only when a non-GUI caller needs the API.
A string in a tab mixin uses
translate("<MixinClass>", "…"), neverself.tr(). In a mixin,self.trcarries the instance’s context, so every lookup missed. The translations page says which call each module uses, why, and whattest_i18n.pychecks. Runpython scripts/update_translations.pyafter you change a user-visible string.Every plugin icon goes through the registry. Use the IDs of
resources/icons/catalog.jsonthroughgui/icons.py,TABSand_row_actions. Read the icon style guide before you add artwork. Register pending artwork asneeds-customwith a QGISfallbackandnotes. Runpython scripts/build_icon_catalog.py --checkto validate usage and SVGs. The optional local preview goes to the ignoredbuild/icon-catalog.html.The icon and the screenshots are generated, not edited by hand. Every brand asset is rendered from
resources/images/geoserver_manager.svg. That includes theresources/images/default_icon.pngthatmetadata.txtpoints at, and the website’s logo and favicon;docs/branding.mdis the guide.scripts/capture_screenshot.pytakes every screenshot of the README and the user guide from the real dialog, against the docker sandbox, off screen. That is each tab, the main forms, the preview and the settings page. Run it again when a tab or a form changes, or the screenshots show an interface that no longer exists.Messages. A user-facing outcome goes to the dialog’s message bar (
show_*_message); the details go to the QGIS log (self.log(..., log_level=Qgis.MessageLevel.Critical))._run_actiondoes both. An error or a warning stays until closed (duration 0): it says what to do next, and used to be gone in 5 s. A success fades. A response body reaches a banner only throughtoolbelt.rest.summarise_body(first line, 300 characters, markup reduced to its title). A Tomcat error page or a proxy login page is not an explanation.Reopening after Close must work.
closeEventsets_closing, so a late task finish stays away from dying widgets.showEventresets it, whatever reopened the dialog (the layer tree’s Publish shows it without reconnecting).refresh_ui()stops the running load before it dropsself.gs, and a finished task frees its slot even while closing. Without that, the dialog worked once per QGIS session;test_cancel_and_threads.pycloses and reopens.A tab label gets a tooltip from
_tab_help(), in GeoServer’s words (“WMS and WMTS stores that proxy another server’s layers”). The label itself stays a logic key (invariant 11).Qt6-compatible enums only:
Qt.CursorShape.WaitCursor,QDialog.DialogCode.Accepted,QMessageBox.StandardButton.Yes; never the unscoped PyQt5 spellings. CI runs a PyQt6 checker.Every fix ships with a test that fails without it. Run the test against the old code once to prove that it does. The testing page says how the suites are built, how a load is tested, and which fakes to reuse.
Commit messages have a conventional prefix (
fix:,feat:,refactor:,chore:,ci:,docs:) and a body that says why. Pre-commit runs ruff, ruff-format, black, isort, flake8 with flake8-qgis, and the hygiene hooks on every commit. If black rewrites a file, the commit aborts: add the file again and commit again.Documentation ships with the change, in the same commit. The docs are not a follow-up task. A change that reaches the user and leaves the docs stale is unfinished. The documentation page lists what to update for each kind of change.
No em dashes, and short sentences. This holds in code, comments, docstrings, docs, commit messages and every user-facing string. Use a comma, a period, a colon, a semicolon or parentheses instead of an em dash. Split a long sentence in two instead of joining two clauses with a dash.
Before you push¶
python -m pip install -r requirements/development.txt -r requirements/testing.txt
pre-commit install
# lint + format exactly as CI does
pre-commit run -a
# tests (unit needs no QGIS; qgis needs the QGIS python, headless is fine)
python -m pytest tests/unit
QT_QPA_PLATFORM=offscreen python -m pytest tests/qgis
# after changing any user-visible string (needs pip install PyQt6); test_i18n fails otherwise
python scripts/update_translations.py
# build the zip qgis-plugin-ci would release
qgis-plugin-ci package 0.1.0 --allow-uncommitted-changes && rm geoserver_manager.0.1.0.zip
Test anything that touches the library contract against a real server: the
docker sandbox (docker compose up -d). The crypt1:
password encoding, the datastore edit merge and the enabled handling were
all confirmed against it, and a fake server cannot show those. The same page
says how to load the plugin into QGIS from the working tree.
CI, in .github/workflows/, has four workflows:
The linter runs flake8 and the PyQt6 check.
The tester runs the unit tests on Python 3.12, and the QGIS suite in the
qgis/qgis:3.40andqgis/qgis:4.0containers.The documentation job builds the site with Sphinx and deploys it to GitHub Pages.
Package and release builds a zip on each push to main, and a release on a tag.