Skip to content

Commit d7ac3aa

Browse files
committed
feat: enforce default profile in QGIS Project file association in Windows registry
1 parent 87ee129 commit d7ac3aa

File tree

6 files changed

+240
-23
lines changed

6 files changed

+240
-23
lines changed

docs/jobs/default_profile_setter.md

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,18 @@ Sample job configuration in your scenario file:
1616
force_profile_selection_policy: true
1717
```
1818
19+
On Windows, if you want to ensure that double-clicking on QGIS Project files will launch QGIS with the chosen default profile and disable version checking:
20+
21+
```yaml
22+
- name: Set default profile to conf_qgis_fr
23+
uses: default-profile-setter
24+
with:
25+
profile: conf_qgis_fr
26+
force_profile_selection_policy: true
27+
force_profile_file_association: true
28+
profile_file_association_arguments: "--noversioncheck"
29+
```
30+
1931
----
2032
2133
## Options
@@ -29,6 +41,22 @@ Name of the profile to set as default profile.
2941
Force the key `selectionPolicy` to 1, which will always open the profile defined in the `defaultProfile` key in `profiles.ini` file. In this context, this job will force QGIS to always start with the profile specified in this job.
3042
It's the same behavior as [the option _Always use profile_ in QGIS user profiles preferences](https://docs.qgis.org/latest/en/docs/user_manual/introduction/qgis_configuration.html#setting-user-profile).
3143

44+
### force_profile_file_association
45+
46+
:::{note}
47+
Windows-only feature
48+
:::
49+
50+
Modify the Windows registry to ensure that QGIS Project files always open with the default profile when launched.
51+
52+
### profile_file_association_arguments
53+
54+
:::{note}
55+
Windows-only feature
56+
:::
57+
58+
Arguments to pass to QGIS executable.
59+
3260
----
3361

3462
## Schema

docs/schemas/scenario/jobs/default-profile-setter.json

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,19 @@
1313
"description": "Force the profile selection policy to 1 (always open default profile).",
1414
"type": "boolean",
1515
"default": false
16+
},
17+
"force_profile_file_association": {
18+
"description": "Force the QGIS project file association to starts QGIS with the default profile.",
19+
"type": "boolean",
20+
"default": false
21+
},
22+
"profile_file_association_arguments": {
23+
"description": "Arguments to pass to QGIS executable when force_profile_file_association is true.",
24+
"type": "string",
25+
"example": "--noversioncheck"
1626
}
17-
}
27+
},
28+
"required": [
29+
"profile"
30+
]
1831
}

qgis_deployment_toolbelt/jobs/job_default_profile_setter.py

Lines changed: 48 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -13,10 +13,12 @@
1313

1414
# Standard library
1515
import logging
16+
from sys import platform as opersys
1617

1718
# package
1819
from qgis_deployment_toolbelt.jobs.generic_job import GenericJob
1920
from qgis_deployment_toolbelt.utils.ini_parser_with_path import CustomConfigParser
21+
from qgis_deployment_toolbelt.utils.win32utils import set_qgis_command
2022

2123
# #############################################################################
2224
# ########## Globals ###############
@@ -52,6 +54,21 @@ class JobDefaultProfileSetter(GenericJob):
5254
"possible_values": None,
5355
"condition": None,
5456
},
57+
"force_profile_file_association": {
58+
"type": bool,
59+
"required": False,
60+
"default": False,
61+
"possible_values": None,
62+
"condition": None,
63+
},
64+
"profile_file_association_arguments": {
65+
"type": str,
66+
"required": False,
67+
"default": None,
68+
"possible_values": None,
69+
"condition": lambda options: options.get("force_profile_file_association")
70+
is True,
71+
},
5572
}
5673

5774
def __init__(self, options: dict) -> None:
@@ -81,32 +98,52 @@ def run(self) -> None:
8198
logger.error("No QGIS profile matching the provided profile name.")
8299
return
83100

84-
ini_profiles_path = self.qgis_profiles_path / "profiles.ini"
101+
self.ini_profiles_path = self.qgis_profiles_path / "profiles.ini"
85102

86103
# check if the profiles.ini file exists and create it with default profile set
87104
# if not
88-
if not ini_profiles_path.exists():
89-
logger.warning(
90-
"Configuration file profiles.ini doesn't exist. "
91-
"It will be created but maybe it was not the expected behavior."
105+
if not self.ini_profiles_path.exists():
106+
self._create_ini_profiles()
107+
else:
108+
self._alter_ini_profiles()
109+
110+
if self.options.get("force_profile_file_association"):
111+
qgis_cmd = (
112+
f'"{self.os_config.get_qgis_bin_path}" --profile "{qdt_profile.name}"'
92113
)
93-
ini_profiles_path.touch(exist_ok=True)
114+
if self.options.get("profile_file_association_arguments"):
115+
qgis_cmd += f' {self.options.get("profile_file_association_arguments")}'
116+
qgis_cmd += ' "%1"'
117+
logger.debug(f"Command to set file association: {qgis_cmd}")
118+
if opersys == "win32":
119+
set_qgis_command(qgis_cmd=qgis_cmd)
120+
else:
121+
logger.warning(
122+
"File association is only supported on Windows. "
123+
"Skipping file association setting."
124+
)
125+
126+
logger.debug(f"Job {self.ID} ran successfully.")
127+
128+
def _create_ini_profiles(self) -> None:
129+
"""Create the profiles.ini file with the default profile."""
130+
if not self.ini_profiles_path.exists():
131+
self.ini_profiles_path.touch(exist_ok=True)
94132
data = f"[core]\ndefaultProfile={self.options.get('profile')}"
95133
if self.options.get("force_profile_selection_policy"):
96134
data += "\nselectionPolicy=1"
97-
ini_profiles_path.write_text(
135+
self.ini_profiles_path.write_text(
98136
data=data,
99137
encoding="UTF8",
100138
)
101139
logger.info(f"Default profile set to {self.options.get('profile')}")
102-
logger.debug(f"Job {self.ID} ran successfully.")
103-
return
104140

141+
def _alter_ini_profiles(self) -> None:
142+
"""Alter the ini profiles with the default profile."""
105143
ini_profiles = CustomConfigParser()
106144
ini_profiles.optionxform = str
107145
ini_profiles.read(self.qgis_profiles_path / "profiles.ini", encoding="UTF8")
108146

109-
# set the default profile
110147
if not ini_profiles.has_section("core"):
111148
ini_profiles.add_section("core")
112149

@@ -122,8 +159,6 @@ def run(self) -> None:
122159
)
123160
ini_profiles.set("core", "selectionPolicy", "1")
124161

125-
with ini_profiles_path.open("w", encoding="UTF8") as wf:
162+
with self.ini_profiles_path.open("w", encoding="UTF8") as wf:
126163
ini_profiles.write(wf, space_around_delimiters=False)
127164
logger.info(f"Default profile set to {self.options.get('profile')}")
128-
129-
logger.debug(f"Job {self.ID} ran successfully.")

qgis_deployment_toolbelt/utils/win32utils.py

Lines changed: 68 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -39,11 +39,19 @@
3939

4040
if opersys == "win32":
4141
"""windows"""
42-
system_hkey = (
42+
env_system_hkey = (
4343
winreg.HKEY_LOCAL_MACHINE,
4444
r"SYSTEM\CurrentControlSet\Control\Session Manager\Environment",
4545
)
46-
user_hkey = (winreg.HKEY_CURRENT_USER, r"Environment")
46+
env_user_hkey = (winreg.HKEY_CURRENT_USER, r"Environment")
47+
qgis_command_system_hkey = (
48+
winreg.HKEY_LOCAL_MACHINE,
49+
r"QGIS Project\Shell\open\command",
50+
)
51+
qgis_command_user_hkey = (
52+
winreg.HKEY_CURRENT_USER,
53+
r"Software\Classes\QGIS Project\Shell\open\command",
54+
)
4755

4856

4957
# #############################################################################
@@ -115,9 +123,9 @@ def delete_environment_variable(envvar_name: str, scope: str = "user") -> bool:
115123
"""
116124
# user or system
117125
if scope == "user":
118-
hkey = user_hkey
126+
hkey = env_user_hkey
119127
else:
120-
hkey = system_hkey
128+
hkey = env_system_hkey
121129

122130
# get it to check if variable exits
123131
try:
@@ -150,9 +158,9 @@ def get_environment_variable(envvar_name: str, scope: str = "user") -> str | Non
150158
"""
151159
# user or system
152160
if scope == "user":
153-
hkey = user_hkey
161+
hkey = env_user_hkey
154162
else:
155-
hkey = system_hkey
163+
hkey = env_system_hkey
156164

157165
# try to get the value
158166
try:
@@ -281,9 +289,9 @@ def set_environment_variable(
281289
"""
282290
# user or system
283291
if scope == "user":
284-
hkey = user_hkey
292+
hkey = env_user_hkey
285293
else:
286-
hkey = system_hkey
294+
hkey = env_system_hkey
287295

288296
# try to set the value
289297
try:
@@ -298,6 +306,58 @@ def set_environment_variable(
298306
return False
299307

300308

309+
def read_registry_value(
310+
key: tuple, value_name: str, access: int = winreg.KEY_READ
311+
) -> str | None:
312+
r"""Read a value from the Windows registry.
313+
Args:
314+
key (tuple): registry key to read from, e.g. (winreg.HKEY_CURRENT_USER, r"Software\Classes\QGIS Project\Shell\open\command")
315+
value_name (str): name of the value to read
316+
access (int, optional): access mode for the registry key, defaults to winreg.KEY_READ
317+
Returns:
318+
str | None: the value as a string if found, None if not found or an error occurs
319+
"""
320+
try:
321+
with winreg.OpenKey(*key, access=access) as reg_key:
322+
value, _ = winreg.QueryValueEx(reg_key, value_name)
323+
return value
324+
except FileNotFoundError:
325+
logger.error(f"Registry key {key} or value {value_name} not found.")
326+
return None
327+
except OSError as err:
328+
logger.error(f"Error reading registry key {key}: {err}")
329+
return None
330+
331+
332+
def set_qgis_command(qgis_cmd: str, scope: str = "user") -> bool:
333+
"""Set QGIS command in Windows registry.
334+
335+
Args:
336+
qgis_path (str): path to QGIS installation folder
337+
scope (str, optional): environment variable scope. Must be "user" or "system",
338+
defaults to "user". Defaults to "user".
339+
340+
Returns:
341+
bool: True is the variable has been successfully set
342+
"""
343+
# user or system
344+
if scope == "user":
345+
hkey = qgis_command_user_hkey
346+
else:
347+
hkey = qgis_command_system_hkey
348+
349+
# try to set the value
350+
try:
351+
with winreg.OpenKey(*hkey, access=winreg.KEY_WRITE) as key:
352+
winreg.SetValueEx(key, "", 0, winreg.REG_SZ, qgis_cmd)
353+
return True
354+
except OSError as err:
355+
logger.error(
356+
f"Set QGIS command '{qgis_cmd}' to scope '{scope}' failed. Trace: {err}"
357+
)
358+
return False
359+
360+
301361
# #############################################################################
302362
# ##### Stand alone program ########
303363
# ##################################

tests/test_job_default_profile_setter.py

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,12 +17,18 @@
1717
# Standard library
1818
import tempfile
1919
import unittest
20+
from os import environ, getenv
2021
from pathlib import Path
22+
from sys import platform as opersys
2123

2224
# package
2325
from qgis_deployment_toolbelt.jobs.job_default_profile_setter import (
2426
JobDefaultProfileSetter,
2527
)
28+
from qgis_deployment_toolbelt.utils.win32utils import (
29+
qgis_command_user_hkey,
30+
read_registry_value,
31+
)
2632

2733

2834
# #############################################################################
@@ -115,3 +121,48 @@ def test_job_default_profile_setter_run_with_force_profile_selection_policy(self
115121
"[core]\ndefaultProfile=qdt_test_profile_minimal\nselectionPolicy=1\n\n"
116122
)
117123
self.assertEqual(content, expected_content)
124+
125+
@unittest.skipUnless(
126+
opersys == "win32" and getenv("GITHUB_ACTIONS", False),
127+
"Test specific to Windows and run only in CI to avoid modifying registry.",
128+
)
129+
def test_job_default_profile_setter_run_with_force_profile_file_association(self):
130+
"""Run the job with force profile file association."""
131+
self.default_profile_setter_job.options["force_profile_file_association"] = True
132+
environ["QDT_QGIS_EXE_PATH"] = "/usr/bin/toto"
133+
134+
self.default_profile_setter_job.run()
135+
self.assertTrue(self.profiles_ini.exists())
136+
137+
reg_value = read_registry_value(
138+
qgis_command_user_hkey,
139+
"",
140+
)
141+
expected_reg_value = (
142+
'"\\usr\\bin\\toto" --profile "qdt_test_profile_minimal" "%1"'
143+
)
144+
self.assertEqual(reg_value, expected_reg_value)
145+
146+
@unittest.skipUnless(
147+
opersys == "win32" and getenv("GITHUB_ACTIONS", False),
148+
"Test specific to Windows and run only in CI to avoid modifying registry.",
149+
)
150+
def test_job_default_profile_setter_run_with_profile_file_association_arguments(
151+
self,
152+
):
153+
"""Run the job with force profile file association."""
154+
self.default_profile_setter_job.options["force_profile_file_association"] = True
155+
self.default_profile_setter_job.options[
156+
"profile_file_association_arguments"
157+
] = "--noversioncheck"
158+
environ["QDT_QGIS_EXE_PATH"] = "/usr/bin/toto"
159+
160+
self.default_profile_setter_job.run()
161+
self.assertTrue(self.profiles_ini.exists())
162+
163+
reg_value = read_registry_value(
164+
qgis_command_user_hkey,
165+
"",
166+
)
167+
expected_reg_value = '"\\usr\\bin\\toto" --profile "qdt_test_profile_minimal" --noversioncheck "%1"'
168+
self.assertEqual(reg_value, expected_reg_value)

0 commit comments

Comments
 (0)