Skip to content

Commit 6040d99

Browse files
authored
feat(plugins): add upgrade_mode option to delete plugin folder before upgrade (#783)
Fixes #776 Claude has been used to write the tests, especially `test_job_plugins_synchronizer.py`.
2 parents e743be0 + 1498fdb commit 6040d99

File tree

8 files changed

+255
-7
lines changed

8 files changed

+255
-7
lines changed

docs/jobs/plugins_synchronizer.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,4 +58,6 @@ Default: `~/.cache/qgis-deployment-toolbelt/plugins`
5858
1. List plugins archives into the source folder. Default: `~/.cache/qgis-deployment-toolbelt/plugins`
5959
1. Parse profiles installed
6060
1. Compare plugin versions between referenced in profile.json and the one installed
61-
1. If version plugin in installed profile is inferior, unzip the download plugin in installed profiles
61+
1. If version plugin in installed profile is inferior, unzip the download plugin in installed profiles. Upgrade process can be controlled per plugin in `profile.json` using [`upgrade_mode`](../reference/qdt_profile.md#plugin-upgrade-mode):
62+
- `keep` (default): existing plugin folder is kept, newer version is unpacked on top of it,
63+
- `delete`: existing plugin folder is deleted before unpack.

docs/reference/qdt_profile.md

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,36 @@ qdt export-rules-context -o qdt_rules_context.json
104104

105105
----
106106

107+
## Plugin upgrade mode
108+
109+
> Added in version 0.41
110+
111+
By default, when upgrading a plugin, the new version is unpacked on top of the existing folder (`keep` mode). This works well in most cases but can cause issues when a plugin removes or renames files between versions: leftover files from the old version may remain and cause conflicts or unexpected behavior.
112+
113+
Setting `upgrade_mode` to `delete` on a plugin ensures a clean installation by removing the existing plugin folder before unpacking the new version. This is recommended for plugins that are known to have breaking changes between versions or that do not handle leftover files gracefully.
114+
115+
Example in `profile.json`:
116+
117+
```json
118+
{
119+
"plugins": [
120+
{
121+
"name": "my_plugin",
122+
"version": "2.0.0",
123+
"official_repository": true,
124+
"upgrade_mode": "delete"
125+
}
126+
]
127+
}
128+
```
129+
130+
Possible values:
131+
132+
- `keep` (default): existing plugin folder is kept, newer version is unpacked on top of it.
133+
- `delete`: existing plugin folder is deleted before unpacking the new version.
134+
135+
----
136+
107137
## Model definition
108138

109139
The project comes with a [JSON schema](https://raw.githubusercontent.com/qgis-deployment/qgis-deployment-toolbelt-cli/main/docs/schemas/profile/qgis_profile.json) describing the model of a profile:

docs/schemas/profile/qgis_plugin.json

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,10 @@
1010
},
1111
"location": {
1212
"description": "Indicates if the plugin is located on a remote server or on local drive/network.",
13-
"enum": ["local", "remote"],
13+
"enum": [
14+
"local",
15+
"remote"
16+
],
1417
"type": "string"
1518
},
1619
"name": {
@@ -42,7 +45,9 @@
4245
"repository_url_xml": {
4346
"description": "URL to the plugin repository file (XML).",
4447
"type": "string",
45-
"examples": ["https://oslandia.gitlab.io/qgis/ngp-connect/plugins.xml"]
48+
"examples": [
49+
"https://oslandia.gitlab.io/qgis/ngp-connect/plugins.xml"
50+
]
4651
},
4752
"url": {
4853
"description": "Direct URI (URL or local path) to download the plugin archive (.zip).",
@@ -56,6 +61,15 @@
5661
"version": {
5762
"description": "Version of the plugin to be installed.",
5863
"type": "string"
64+
},
65+
"upgrade_mode": {
66+
"description": "Indicates whether the existing plugin folder should be deleted or not when upgrading.",
67+
"default": "keep",
68+
"enum": [
69+
"delete",
70+
"keep"
71+
],
72+
"type": "string"
5973
}
6074
}
6175
}

qgis_deployment_toolbelt/jobs/job_plugins_synchronizer.py

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
# Standard library
1515
import logging
1616
from pathlib import Path
17-
from shutil import ReadError, unpack_archive
17+
from shutil import ReadError, rmtree, unpack_archive
1818

1919
# package
2020
from qgis_deployment_toolbelt.jobs.generic_job import GenericJob
@@ -222,6 +222,25 @@ def install_plugin_into_profile(
222222
# make sure destination folder exists
223223
profile_plugins_folder.mkdir(parents=True, exist_ok=True)
224224

225+
if plugin.upgrade_mode == "delete":
226+
# if the plugin is already present into the profile, delete it before installing the new version
227+
plugin_installed_folder = Path(
228+
profile_plugins_folder, plugin.installation_folder_name
229+
)
230+
if plugin_installed_folder.is_dir():
231+
logger.debug(
232+
f"Profile {profile.name} - "
233+
f"Plugin {plugin.name} is already installed. It will be deleted before installing the new version."
234+
)
235+
try:
236+
rmtree(plugin_installed_folder)
237+
except OSError as err:
238+
logger.error(
239+
f"Profile {profile.name} - "
240+
f"Plugin {plugin.name} could not be deleted before installing the new version. Trace: {err}"
241+
)
242+
continue
243+
225244
# in some cases related to proxies issues, the plugin archive download
226245
# returns a success but in fact it's just some HTML error from the proxy
227246
# (but with wrong HTTP error code...) so the ZIP file is not really a zip...

qgis_deployment_toolbelt/plugins/plugin.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,7 @@ class QgisPlugin:
7474
repository_url_xml: str = None
7575
url: str = None
7676
version: str = "latest"
77+
upgrade_mode: str = "keep"
7778

7879
@classmethod
7980
def from_dict(cls, input_dict: dict) -> QgisPlugin:
@@ -228,7 +229,7 @@ def id_with_version(self) -> str:
228229
@property
229230
def installation_folder_name(self) -> str:
230231
"""Name of the folder when the plugin is installed into QGIS/profile/python/plugins/. \
231-
If not clearly specified intot the profile.json, it tries to extract it from \
232+
If not clearly specified into the profile.json, it tries to extract it from \
232233
the download URL. As final fallback, it returns the slufigied plugin name.
233234
234235
Returns:

tests/fixtures/profiles/good_profile_complete.json

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,13 +16,15 @@
1616
"version": "1.0.4",
1717
"url": "https://plugins.qgis.org/plugins/french_locator_filter/version/1.0.4/download/",
1818
"location": "remote",
19-
"plugin_id": 1846
19+
"plugin_id": 1846,
20+
"upgrade_mode": "delete"
2021
},
2122
{
2223
"name": "pg_metadata",
2324
"version": "1.2.1",
2425
"url": "https://plugins.qgis.org/plugins/pg_metadata/version/1.2.1/download/",
25-
"location": "remote"
26+
"location": "remote",
27+
"upgrade_mode": "keep"
2628
},
2729
{
2830
"name": "Layers menu from project",
Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
#! python3 # noqa E265
2+
3+
"""Usage from the repo root folder:
4+
5+
.. code-block:: python
6+
7+
# for whole test
8+
python -m unittest tests.test_job_plugins_synchronizer
9+
# for specific
10+
python -m unittest tests.test_job_plugins_synchronizer.TestJobPluginsSynchronizer.test_install_plugin_upgrade_mode_delete
11+
"""
12+
13+
# #############################################################################
14+
# ########## Libraries #############
15+
# ##################################
16+
17+
# Standard library
18+
import tempfile
19+
import unittest
20+
import zipfile
21+
from pathlib import Path
22+
23+
# package
24+
from qgis_deployment_toolbelt.jobs.job_plugins_synchronizer import (
25+
JobPluginsSynchronizer,
26+
)
27+
from qgis_deployment_toolbelt.plugins.plugin import QgisPlugin
28+
from qgis_deployment_toolbelt.profiles.qdt_profile import QdtProfile
29+
30+
31+
# #############################################################################
32+
# ########## Classes ###############
33+
# ##################################
34+
35+
36+
class TestJobPluginsSynchronizer(unittest.TestCase):
37+
"""Test plugins synchronizer job."""
38+
39+
# -- Standard methods --------------------------------------------------------
40+
@classmethod
41+
def setUpClass(cls):
42+
"""Executed when module is loaded before any test."""
43+
pass
44+
45+
@classmethod
46+
def tearDownClass(cls):
47+
"""Executed when module is unloaded after all tests."""
48+
pass
49+
50+
# -- Helpers -----------------------------------------------------------------
51+
@staticmethod
52+
def _create_fake_plugin_zip(zip_path: Path, folder_name: str) -> None:
53+
"""Create a minimal plugin zip archive with a metadata.txt."""
54+
with zipfile.ZipFile(zip_path, "w") as zf:
55+
zf.writestr(
56+
f"{folder_name}/metadata.txt",
57+
"[general]\nname=Test Plugin\nversion=2.0.0\n",
58+
)
59+
zf.writestr(f"{folder_name}/__init__.py", "")
60+
61+
@staticmethod
62+
def _make_profile_in_tmpdir(tmp_dir: Path, name: str) -> QdtProfile:
63+
"""Create a QdtProfile whose path_in_qgis points into the temp dir."""
64+
profile = QdtProfile(name=name)
65+
profile.os_config.qgis_profiles_path = tmp_dir / "qgis_profiles"
66+
return profile
67+
68+
# -- Tests -------------------------------------------------------------------
69+
def test_install_plugin_upgrade_mode_delete(self):
70+
"""Test that upgrade_mode=delete removes the plugin folder before unzip."""
71+
with tempfile.TemporaryDirectory(
72+
prefix="QDT_test_plugins_sync_upgrade_delete_"
73+
) as tmp_dir:
74+
options = {"action": "create_or_restore"}
75+
job = JobPluginsSynchronizer(options=options)
76+
77+
profile = self._make_profile_in_tmpdir(Path(tmp_dir), "test_delete")
78+
plugins_folder = profile.path_in_qgis / "python/plugins"
79+
plugin_folder = plugins_folder / "test_plugin"
80+
plugin_folder.mkdir(parents=True, exist_ok=True)
81+
82+
# simulate an old installed plugin with a leftover file
83+
leftover_file = plugin_folder / "old_leftover.py"
84+
leftover_file.write_text("# this file should be removed")
85+
86+
# create a fake plugin zip
87+
zip_path = Path(tmp_dir) / "test_plugin_delete.zip"
88+
self._create_fake_plugin_zip(zip_path, "test_plugin")
89+
90+
# plugin object with upgrade_mode=delete
91+
plugin = QgisPlugin.from_dict(
92+
{
93+
"name": "Test Plugin",
94+
"folder_name": "test_plugin",
95+
"version": "2.0.0",
96+
"upgrade_mode": "delete",
97+
}
98+
)
99+
100+
# run install
101+
job.install_plugin_into_profile([(profile, plugin, zip_path)])
102+
103+
# the leftover file should be gone
104+
self.assertFalse(leftover_file.exists())
105+
# the plugin folder should exist with new files
106+
self.assertTrue(plugin_folder.is_dir())
107+
self.assertTrue((plugin_folder / "metadata.txt").exists())
108+
self.assertTrue((plugin_folder / "__init__.py").exists())
109+
110+
def test_install_plugin_upgrade_mode_keep(self):
111+
"""Test that upgrade_mode=keep preserves existing files in the plugin folder."""
112+
with tempfile.TemporaryDirectory(
113+
prefix="QDT_test_plugins_sync_upgrade_keep_"
114+
) as tmp_dir:
115+
options = {"action": "create_or_restore"}
116+
job = JobPluginsSynchronizer(options=options)
117+
118+
profile = self._make_profile_in_tmpdir(Path(tmp_dir), "test_keep")
119+
plugins_folder = profile.path_in_qgis / "python/plugins"
120+
plugin_folder = plugins_folder / "test_plugin"
121+
plugin_folder.mkdir(parents=True, exist_ok=True)
122+
123+
# simulate an old installed plugin with a leftover file
124+
leftover_file = plugin_folder / "old_leftover.py"
125+
leftover_file.write_text("# this file should remain")
126+
127+
# create a fake plugin zip
128+
zip_path = Path(tmp_dir) / "test_plugin_keep.zip"
129+
self._create_fake_plugin_zip(zip_path, "test_plugin")
130+
131+
# plugin object with upgrade_mode=keep (default)
132+
plugin = QgisPlugin.from_dict(
133+
{
134+
"name": "Test Plugin",
135+
"folder_name": "test_plugin",
136+
"version": "2.0.0",
137+
"upgrade_mode": "keep",
138+
}
139+
)
140+
141+
# run install
142+
job.install_plugin_into_profile([(profile, plugin, zip_path)])
143+
144+
# the leftover file should still be there
145+
self.assertTrue(leftover_file.exists())
146+
# and the new files should also be present
147+
self.assertTrue((plugin_folder / "metadata.txt").exists())
148+
self.assertTrue((plugin_folder / "__init__.py").exists())
149+
150+
151+
# #############################################################################
152+
# ####### Stand-alone run ########
153+
# ################################
154+
if __name__ == "__main__":
155+
unittest.main()

tests/test_qplugin_object.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -265,6 +265,31 @@ def test_qplugin_versions_comparison_calver(self):
265265
self.assertTrue(plugin_v2.is_older_than(plugin_v3))
266266
self.assertFalse(plugin_v2.is_older_than(plugin_v1))
267267

268+
def test_qplugin_upgrade_mode_default(self):
269+
"""Test plugin upgrade_mode defaults to 'keep'."""
270+
plugin = QgisPlugin.from_dict({"name": "Sample plugin", "version": "1.0.0"})
271+
self.assertEqual(plugin.upgrade_mode, "keep")
272+
273+
def test_qplugin_upgrade_mode_from_dict(self):
274+
"""Test plugin upgrade_mode is correctly loaded from dict."""
275+
plugin_delete = QgisPlugin.from_dict(
276+
{
277+
"name": "Sample plugin",
278+
"version": "1.0.0",
279+
"upgrade_mode": "delete",
280+
}
281+
)
282+
self.assertEqual(plugin_delete.upgrade_mode, "delete")
283+
284+
plugin_keep = QgisPlugin.from_dict(
285+
{
286+
"name": "Sample plugin",
287+
"version": "1.0.0",
288+
"upgrade_mode": "keep",
289+
}
290+
)
291+
self.assertEqual(plugin_keep.upgrade_mode, "keep")
292+
268293
def test_qplugin_versions_comparison_bad(self):
269294
"""Test plugin compare versions issues"""
270295
plugin_v1: QgisPlugin = QgisPlugin.from_dict(

0 commit comments

Comments
 (0)