Coverage for gws-app/gws/plugin/qgis/project.py: 58%
97 statements
« prev ^ index » next coverage.py v7.17.0, created at 2026-10-23 20:06 +0200
« prev ^ index » next coverage.py v7.17.0, created at 2026-10-23 20:06 +0200
1"""Loading and storing QGIS projects."""
3import os
5import gws
6import gws.base.database
7import gws.config.util
8import gws.lib.jsonx
9import gws.lib.datetimex
10import gws.lib.xmlx
11import gws.lib.zipx
12import gws.lib.sa as sa
14from . import caps
17class Error(gws.Error):
18 """QGIS project error."""
20 pass
23class StoreType(gws.Enum):
24 """Where a QGIS project is stored."""
26 file = 'file'
27 """Project file (``.qgs`` or ``.qgz``)."""
28 postgres = 'postgres'
29 """Postgres table ``qgis_projects``."""
32class Store(gws.Data):
33 """Location of a QGIS project."""
35 type: StoreType
36 """Store type."""
37 path: gws.FilePath
38 """Project file path, for file stores."""
39 dbUid: str
40 """Database provider UID, for Postgres stores."""
41 schema: str
42 """Schema of the projects table, for Postgres stores. Defaults to ``public``."""
43 projectName: str
44 """Project name."""
47_PRJ_EXT = '.qgs'
48_ZIP_EXT = '.qgz'
49_PRJ_TABLE = 'qgis_projects'
52def from_store(root: gws.Root, store: Store) -> 'Object':
53 """Load a QGIS project from a store.
55 Args:
56 root: Root object, used to find the database provider.
57 store: Project location.
59 Returns:
60 The project.
62 Raises:
63 ``Error``: If the project cannot be found or loaded.
64 """
65 if store.type == StoreType.file:
66 return from_path(store.path)
67 if store.type == StoreType.postgres:
68 return _from_db(root, store)
69 raise Error(f'qgis project cannot be loaded')
72def from_path(path: str) -> 'Object':
73 """Load a QGIS project from a ``.qgs`` or ``.qgz`` file.
75 Args:
76 path: Project file path.
78 Returns:
79 The project.
81 Raises:
82 ``Error``: If a ``.qgz`` archive contains no project or the QGIS version is not supported.
83 """
84 if path.endswith(_ZIP_EXT):
85 return _from_zipped_bytes(gws.u.read_file_b(path))
86 return from_string(gws.u.read_file(path))
89def from_string(text: str) -> 'Object':
90 """Create a QGIS project from XML text.
92 Args:
93 text: Project XML.
95 Returns:
96 The project.
98 Raises:
99 ``Error``: If the QGIS version is not supported.
100 """
101 return Object(text)
104def _from_zipped_bytes(b: bytes) -> 'Object':
105 d = gws.lib.zipx.unzip_bytes_to_dict(b)
106 for k, v in d.items():
107 if k.endswith(_PRJ_EXT):
108 return from_string(v.decode('utf8'))
109 raise Error(f'no qgis project')
112def _from_db(root: gws.Root, store: Store):
113 db = root.app.databaseMgr.find_provider(ext_type='postgres', uid=store.dbUid)
114 schema = store.get('schema') or 'public'
115 tab = db.table(f'{schema}.{_PRJ_TABLE}')
117 with db.begin() as conn:
118 for row in conn.execute(sa.select(tab.c.content).where(tab.c.name == store.projectName)):
119 return _from_zipped_bytes(row[0])
120 raise Error(f'{store.projectName!r} not found')
123def _to_db(root: gws.Root, store: Store, content: bytes):
124 db = root.app.databaseMgr.find_provider(ext_type='postgres', uid=store.dbUid)
125 schema = store.get('schema') or 'public'
126 tab = db.table(f'{schema}.{_PRJ_TABLE}')
128 metadata = {
129 'last_modified_time': gws.lib.datetimex.to_iso_string(),
130 'last_modified_user': 'GWS',
131 }
133 with db.begin() as conn:
134 conn.execute(tab.delete().where(tab.c.name == store.projectName + '.bak'))
135 conn.execute(tab.update().values(name=store.projectName + '.bak').where(tab.c.name == store.projectName))
136 conn.execute(tab.insert().values(
137 name=store.projectName,
138 metadata=metadata,
139 content=content,
140 ))
143class Object:
144 """A QGIS project, held as XML text.
146 Only QGIS 3 projects are supported. The parsed XML tree is created on
147 demand and is not pickled.
148 """
150 version: str
151 """QGIS version that wrote the project, e.g. ``3.34.0``."""
152 sourceHash: str
153 """SHA-256 hash of the project XML text."""
155 def __init__(self, text: str):
156 """Create a project from XML text.
158 Args:
159 text: Project XML.
161 Raises:
162 ``Error``: If the QGIS version is not supported.
163 """
164 self.text = text
165 self.sourceHash = gws.u.sha256(self.text)
167 ver = self.xml_root().get('version', '').split('-')[0]
168 if not ver.startswith('3'):
169 raise Error(f'unsupported qgis version {ver!r}')
170 self.version = ver
172 def __getstate__(self):
173 """Return the pickle state without the parsed XML tree."""
174 return gws.u.omit(vars(self), '_xml_root')
176 def xml_root(self) -> gws.XmlElement:
177 """Return the root element of the project XML.
179 The XML is parsed on the first call. Changes to the tree are kept
180 and written by ``to_xml``, ``to_path`` and ``to_store``.
182 Returns:
183 Root element.
184 """
185 if not hasattr(self, '_xml_root'):
186 setattr(self, '_xml_root', gws.lib.xmlx.from_string(self.text))
187 return getattr(self, '_xml_root')
189 def to_store(self, root: gws.Root, store: Store):
190 """Save the project to a store.
192 In a Postgres store, an existing project with the same name is
193 renamed to ``<name>.bak``, replacing an older backup.
195 Args:
196 root: Root object, used to find the database provider.
197 store: Project location.
199 Raises:
200 ``Error``: If the store type is not supported.
201 """
202 if store.type == StoreType.file:
203 return self.to_path(store.path)
204 if store.type == StoreType.postgres:
205 src = self.to_xml()
206 name = store.projectName + _PRJ_EXT
207 content = gws.lib.zipx.zip_to_bytes([{name: src}])
208 return _to_db(root, store, content)
209 raise Error(f'qgis project cannot be stored')
211 def to_path(self, path: str):
212 """Save the project to a file.
214 If the path ends with ``.qgz``, the project is saved as a zip archive.
216 Args:
217 path: File path.
218 """
219 src = self.to_xml()
220 if path.endswith(_ZIP_EXT):
221 name = os.path.basename(path).replace(_ZIP_EXT, _PRJ_EXT)
222 content = gws.lib.zipx.zip_to_bytes([{name: src}])
223 gws.u.write_file_b(path, content)
224 else:
225 gws.u.write_file(path, src)
227 def to_xml(self):
228 """Serialize the project XML.
230 Returns:
231 Project XML text.
232 """
233 return self.xml_root().to_string()
235 def caps(self) -> caps.Caps:
236 """Parse the project capabilities.
238 Returns:
239 Project capabilities.
241 Raises:
242 ``gws.Error``: If the project CRS is invalid.
243 """
244 return caps.parse_element(self.xml_root())