geoservercloud 0.1.0.dev1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,22 @@
1
+ Copyright (c) 2024, Camptocamp SA
2
+ All rights reserved.
3
+
4
+ Redistribution and use in source and binary forms, with or without
5
+ modification, are permitted provided that the following conditions are met:
6
+
7
+ 1. Redistributions of source code must retain the above copyright notice, this
8
+ list of conditions and the following disclaimer.
9
+ 2. Redistributions in binary form must reproduce the above copyright notice,
10
+ this list of conditions and the following disclaimer in the documentation
11
+ and/or other materials provided with the distribution.
12
+
13
+ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
14
+ ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
15
+ WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
16
+ DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR
17
+ ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
18
+ (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
19
+ LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND
20
+ ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
21
+ (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
22
+ SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
@@ -0,0 +1,21 @@
1
+ Metadata-Version: 2.1
2
+ Name: geoservercloud
3
+ Version: 0.1.0.dev1
4
+ Summary: Lightweight Python client to interact with GeoServer Cloud REST API, GeoServer ACL and OGC services
5
+ License: BSD-2-Clause
6
+ Author: Camptocamp
7
+ Author-email: info@camptocamp.com
8
+ Requires-Python: >=3.9
9
+ Classifier: License :: OSI Approved :: BSD License
10
+ Classifier: Programming Language :: Python :: 3
11
+ Classifier: Programming Language :: Python :: 3.9
12
+ Classifier: Programming Language :: Python :: 3.10
13
+ Classifier: Programming Language :: Python :: 3.11
14
+ Classifier: Programming Language :: Python :: 3.12
15
+ Requires-Dist: OWSLib
16
+ Requires-Dist: requests
17
+ Requires-Dist: xmltodict
18
+ Description-Content-Type: text/markdown
19
+
20
+ # python-geoservercloud
21
+
@@ -0,0 +1 @@
1
+ # python-geoservercloud
@@ -0,0 +1,3 @@
1
+ from .geoservercloud import GeoServerCloud
2
+
3
+ __all__: list[str] = ["GeoServerCloud"]
@@ -0,0 +1,657 @@
1
+ import os.path
2
+ from pathlib import Path
3
+ from typing import Any
4
+
5
+ import xmltodict
6
+ from owslib.map.wms130 import WebMapService_1_3_0
7
+ from owslib.util import ResponseWrapper
8
+ from owslib.wmts import WebMapTileService
9
+ from requests import Response
10
+
11
+ from geoservercloud import utils
12
+ from geoservercloud.restservice import RestService
13
+ from geoservercloud.templates import Templates
14
+
15
+
16
+ class GeoServerCloud:
17
+ def __init__(
18
+ self,
19
+ url: str = "http://localhost:9090/geoserver/cloud/",
20
+ user: str = "admin",
21
+ password: str = "geoserver",
22
+ ) -> None:
23
+
24
+ self.url: str = url
25
+ self.user: str = user
26
+ self.password: str = password
27
+ self.auth: tuple[str, str] = (user, password)
28
+ self.rest_service: RestService = RestService(url, self.auth)
29
+ self.wms: WebMapService_1_3_0 | None = None
30
+ self.wmts: WebMapTileService | None = None
31
+ self.default_workspace: str | None = None
32
+ self.default_datastore: str | None = None
33
+
34
+ @staticmethod
35
+ def workspace_wms_settings_path(workspace: str) -> str:
36
+ return f"/rest/services/wms/workspaces/{workspace}/settings.json"
37
+
38
+ @staticmethod
39
+ def get_wmts_layer_bbox(
40
+ url: str, layer_name: str
41
+ ) -> tuple[float, float, float, float] | None:
42
+ wmts = WebMapTileService(url)
43
+ try:
44
+ return wmts[layer_name].boundingBoxWGS84
45
+ except (KeyError, AttributeError):
46
+ return None
47
+
48
+ def create_wms(self) -> None:
49
+ if self.default_workspace:
50
+ path: str = f"/{self.default_workspace}/wms"
51
+ else:
52
+ path = "/wms"
53
+ self.wms = WebMapService_1_3_0(
54
+ f"{self.url}{path}",
55
+ username=self.user,
56
+ password=self.password,
57
+ timeout=240,
58
+ )
59
+
60
+ def create_wmts(self) -> None:
61
+ path = "/gwc/service/wmts"
62
+ self.wmts = WebMapTileService(
63
+ f"{self.url}{path}",
64
+ version="1.0.0",
65
+ username=self.user,
66
+ password=self.password,
67
+ )
68
+
69
+ def create_workspace(
70
+ self,
71
+ workspace: str,
72
+ isolated: bool = False,
73
+ set_default_workspace: bool = False,
74
+ ) -> Response:
75
+ """
76
+ Create a workspace in GeoServer, if it does not already exist.
77
+ It if exists, update it
78
+ """
79
+ payload: dict[str, dict[str, Any]] = {
80
+ "workspace": {
81
+ "name": workspace,
82
+ "isolated": isolated,
83
+ }
84
+ }
85
+ response: Response = self.post_request("/rest/workspaces.json", json=payload)
86
+ if response.status_code == 409:
87
+ response = self.put_request(
88
+ f"/rest/workspaces/{workspace}.json", json=payload
89
+ )
90
+ if set_default_workspace:
91
+ self.default_workspace = workspace
92
+ return response
93
+
94
+ def delete_workspace(self, workspace: str) -> Response:
95
+ path: str = f"/rest/workspaces/{workspace}.json?recurse=true"
96
+ response: Response = self.delete_request(path)
97
+ if self.default_workspace == workspace:
98
+ self.default_workspace = None
99
+ self.wms = None
100
+ self.wmts = None
101
+ return response
102
+
103
+ def recreate_workspace(
104
+ self, workspace: str, set_default_workspace: bool = False
105
+ ) -> None:
106
+ """
107
+ Create a workspace in GeoServer, and first delete it if it already exists.
108
+ """
109
+ self.delete_workspace(workspace)
110
+ self.create_workspace(workspace, set_default_workspace=set_default_workspace)
111
+
112
+ def publish_workspace(self, workspace) -> Response:
113
+ path: str = f"{self.workspace_wms_settings_path(workspace)}"
114
+
115
+ data: dict[str, dict[str, Any]] = Templates.workspace_wms(workspace)
116
+ return self.put_request(path, json=data)
117
+
118
+ def set_default_locale_for_service(
119
+ self, workspace: str, locale: str | None
120
+ ) -> None:
121
+ path: str = self.workspace_wms_settings_path(workspace)
122
+ data: dict[str, dict[str, Any]] = {
123
+ "wms": {
124
+ "defaultLocale": locale,
125
+ }
126
+ }
127
+ self.put_request(path, json=data)
128
+
129
+ def unset_default_locale_for_service(self, workspace) -> None:
130
+ self.set_default_locale_for_service(workspace, None)
131
+
132
+ def create_pg_datastore(
133
+ self,
134
+ workspace: str,
135
+ datastore: str,
136
+ pg_host: str,
137
+ pg_port: int,
138
+ pg_db: str,
139
+ pg_user: str,
140
+ pg_password: str,
141
+ pg_schema: str = "public",
142
+ set_default_datastore: bool = False,
143
+ ) -> Response | None:
144
+ """
145
+ Create a PostGIS datastore from the DB connection parameters, or update it if it already exist.
146
+ """
147
+ response: None | Response = None
148
+ path = f"/rest/workspaces/{workspace}/datastores.json"
149
+ resource_path = f"/rest/workspaces/{workspace}/datastores/{datastore}.json"
150
+ payload: dict[str, dict[str, Any]] = Templates.postgis_data_store(
151
+ datastore=datastore,
152
+ pg_host=pg_host,
153
+ pg_port=pg_port,
154
+ pg_db=pg_db,
155
+ pg_user=pg_user,
156
+ pg_password=pg_password,
157
+ namespace=f"http://{workspace}",
158
+ pg_schema=pg_schema,
159
+ )
160
+ if not self.resource_exists(resource_path):
161
+ response = self.post_request(path, json=payload)
162
+ else:
163
+ response = self.put_request(resource_path, json=payload)
164
+
165
+ if set_default_datastore:
166
+ self.default_datastore = datastore
167
+
168
+ return response
169
+
170
+ def create_jndi_datastore(
171
+ self,
172
+ workspace: str,
173
+ datastore: str,
174
+ jndi_reference: str,
175
+ pg_schema: str = "public",
176
+ description: str | None = None,
177
+ set_default_datastore: bool = False,
178
+ ) -> Response | None:
179
+ """
180
+ Create a PostGIS datastore from JNDI resource, or update it if it already exist.
181
+ """
182
+ response: None | Response = None
183
+ path = f"/rest/workspaces/{workspace}/datastores.json"
184
+ resource_path = f"/rest/workspaces/{workspace}/datastores/{datastore}.json"
185
+ payload: dict[str, dict[str, Any]] = Templates.postgis_jndi_data_store(
186
+ datastore=datastore,
187
+ jndi_reference=jndi_reference,
188
+ namespace=f"http://{workspace}",
189
+ pg_schema=pg_schema,
190
+ description=description,
191
+ )
192
+ if not self.resource_exists(resource_path):
193
+ response = self.post_request(path, json=payload)
194
+ else:
195
+ response = self.put_request(resource_path, json=payload)
196
+
197
+ if set_default_datastore:
198
+ self.default_datastore = datastore
199
+
200
+ return response
201
+
202
+ def create_wmts_store(
203
+ self,
204
+ workspace: str,
205
+ name: str,
206
+ capabilities: str,
207
+ ) -> Response | None:
208
+ """
209
+ Create a cascaded WMTS store, or update it if it already exist.
210
+ """
211
+ response: None | Response = None
212
+ path = f"/rest/workspaces/{workspace}/wmtsstores.json"
213
+ resource_path = f"/rest/workspaces/{workspace}/wmtsstores/{name}.json"
214
+ payload = Templates.wmts_store(workspace, name, capabilities)
215
+ if not self.resource_exists(resource_path):
216
+ return self.post_request(path, json=payload)
217
+ else:
218
+ return self.put_request(resource_path, json=payload)
219
+
220
+ def create_feature_type(
221
+ self,
222
+ layer: str,
223
+ workspace: str | None = None,
224
+ datastore: str | None = None,
225
+ title: str | dict = "Default title",
226
+ abstract: str | dict = "Default abstract",
227
+ attributes: dict = Templates.geom_point_attribute(),
228
+ epsg: int = 4326,
229
+ ) -> Response | None:
230
+ """
231
+ Create a feature type or update it if it already exist.
232
+ """
233
+ workspace = workspace or self.default_workspace
234
+ if not workspace:
235
+ raise ValueError("Workspace not provided")
236
+ datastore = datastore or self.default_datastore
237
+ if not datastore:
238
+ raise ValueError("Datastore not provided")
239
+ path: str = (
240
+ f"/rest/workspaces/{workspace}/datastores/{datastore}/featuretypes.json"
241
+ )
242
+ resource_path: str = (
243
+ f"/rest/workspaces/{workspace}/datastores/{datastore}/featuretypes/{layer}.json"
244
+ )
245
+ payload: dict[str, dict[str, Any]] = Templates.feature_type(
246
+ layer=layer,
247
+ workspace=workspace,
248
+ datastore=datastore,
249
+ attributes=utils.convert_attributes(attributes),
250
+ epsg=epsg,
251
+ )
252
+ if type(title) is dict:
253
+ payload["featureType"]["internationalTitle"] = title
254
+ else:
255
+ payload["featureType"]["title"] = title
256
+ if type(abstract) is dict:
257
+ payload["featureType"]["internationalAbstract"] = abstract
258
+ else:
259
+ payload["featureType"]["abstract"] = abstract
260
+
261
+ if not self.resource_exists(resource_path):
262
+ return self.post_request(path, json=payload)
263
+ else:
264
+ return self.put_request(resource_path, json=payload)
265
+
266
+ def create_layer_group(
267
+ self,
268
+ group: str,
269
+ workspace: str | None,
270
+ layers: list[str],
271
+ title: str | dict,
272
+ abstract: str | dict,
273
+ epsg: int = 4326,
274
+ ) -> Response | None:
275
+ """
276
+ Create a layer group if it does not already exist.
277
+ """
278
+ workspace = workspace or self.default_workspace
279
+ if not workspace:
280
+ raise ValueError("Workspace not provided")
281
+ path: str = f"/rest/workspaces/{workspace}/layergroups.json"
282
+ resource_path: str = f"/rest/workspaces/{workspace}/layergroups/{group}.json"
283
+ payload: dict[str, dict[str, Any]] = Templates.layer_group(
284
+ group=group,
285
+ layers=layers,
286
+ workspace=workspace,
287
+ title=title,
288
+ abstract=abstract,
289
+ epsg=epsg,
290
+ )
291
+ if not self.resource_exists(resource_path):
292
+ return self.post_request(path, json=payload)
293
+ else:
294
+ return self.put_request(resource_path, json=payload)
295
+
296
+ def create_wmts_layer(
297
+ self,
298
+ workspace: str,
299
+ wmts_store: str,
300
+ native_layer: str,
301
+ published_layer: str | None = None,
302
+ epsg: int = 4326,
303
+ ) -> Response | None:
304
+ """
305
+ Publish a remote WMTS layer if it does not already exist.
306
+ """
307
+ if not published_layer:
308
+ published_layer = native_layer
309
+ if self.resource_exists(
310
+ f"/rest/workspaces/{workspace}/wmtsstores/{wmts_store}/layers/{published_layer}.json"
311
+ ):
312
+ return None
313
+ wmts_store_path = f"/rest/workspaces/{workspace}/wmtsstores/{wmts_store}.json"
314
+ capabilities_url = (
315
+ self.get_request(wmts_store_path)
316
+ .json()
317
+ .get("wmtsStore")
318
+ .get("capabilitiesURL")
319
+ )
320
+ wgs84_bbox = self.get_wmts_layer_bbox(capabilities_url, native_layer)
321
+
322
+ path = f"/rest/workspaces/{workspace}/wmtsstores/{wmts_store}/layers"
323
+ payload = Templates.wmts_layer(
324
+ published_layer, native_layer, wgs84_bbox=wgs84_bbox, epsg=epsg
325
+ )
326
+
327
+ return self.post_request(path, json=payload)
328
+
329
+ def publish_gwc_layer(
330
+ self, workspace: str, layer: str, epsg: int = 4326
331
+ ) -> Response | None:
332
+ # Reload config to make sure GWC is aware of GeoServer layers
333
+ self.post_request(
334
+ "/gwc/rest/reload",
335
+ headers={"Content-Type": "application/json"},
336
+ data="reload_configuration=1",
337
+ )
338
+ payload = Templates.gwc_layer(workspace, layer, f"EPSG:{epsg}")
339
+ return self.put_request(
340
+ f"/gwc/rest/layers/{workspace}:{layer}.json",
341
+ json=payload,
342
+ )
343
+
344
+ def create_style_from_file(
345
+ self,
346
+ style: str,
347
+ file: str,
348
+ workspace: str | None = None,
349
+ ) -> Response:
350
+ """Create a style from a file, or update it if it already exists.
351
+ Supported file extensions are .sld and .zip."""
352
+ path = (
353
+ "/rest/styles" if not workspace else f"/rest/workspaces/{workspace}/styles"
354
+ )
355
+ resource_path = (
356
+ f"/rest/styles/{style}.json"
357
+ if not workspace
358
+ else f"/rest/workspaces/{workspace}/styles/{style}.json"
359
+ )
360
+
361
+ file_ext = os.path.splitext(file)[1]
362
+ if file_ext == ".sld":
363
+ content_type = "application/vnd.ogc.sld+xml"
364
+ elif file_ext == ".zip":
365
+ content_type = "application/zip"
366
+ else:
367
+ raise ValueError(f"Unsupported file extension: {file_ext}")
368
+ with open(f"{file}", "rb") as fs:
369
+ data: bytes = fs.read()
370
+ headers: dict[str, str] = {"Content-Type": content_type}
371
+
372
+ if not self.resource_exists(resource_path):
373
+ return self.post_request(path, data=data, headers=headers)
374
+ else:
375
+ return self.put_request(resource_path, data=data, headers=headers)
376
+
377
+ def set_default_layer_style(
378
+ self, layer: str, workspace: str, style: str
379
+ ) -> Response:
380
+ path = f"/rest/layers/{workspace}:{layer}.json"
381
+ data = {"layer": {"defaultStyle": {"name": style}}}
382
+ return self.put_request(path, json=data)
383
+
384
+ def get_wms_capabilities(
385
+ self, workspace: str, accept_languages=None
386
+ ) -> dict[str, Any]:
387
+ path: str = f"/{workspace}/wms"
388
+ params: dict[str, str] = {
389
+ "service": "WMS",
390
+ "version": "1.3.0",
391
+ "request": "GetCapabilities",
392
+ }
393
+ if accept_languages:
394
+ params["AcceptLanguages"] = accept_languages
395
+ response: Response = self.get_request(path, params=params)
396
+ return xmltodict.parse(response.content)
397
+
398
+ def get_wms_layers(
399
+ self, workspace: str, accept_languages: str | None = None
400
+ ) -> Any | dict[str, Any]:
401
+ capabilities: dict[str, Any] = self.get_wms_capabilities(
402
+ workspace, accept_languages
403
+ )
404
+ try:
405
+ return capabilities["WMS_Capabilities"]["Capability"]["Layer"]
406
+ except KeyError:
407
+ return capabilities
408
+
409
+ def get_wfs_capabilities(self, workspace: str) -> dict[str, Any]:
410
+ path: str = f"/{workspace}/wfs"
411
+ params: dict[str, str] = {
412
+ "service": "WFS",
413
+ "version": "1.1.0",
414
+ "request": "GetCapabilities",
415
+ }
416
+ response: Response = self.get_request(path, params=params)
417
+ return xmltodict.parse(response.content)
418
+
419
+ def get_wfs_layers(self, workspace: str) -> Any | dict[str, Any]:
420
+ capabilities: dict[str, Any] = self.get_wfs_capabilities(workspace)
421
+ try:
422
+ return capabilities["wfs:WFS_Capabilities"]["FeatureTypeList"]
423
+ except KeyError:
424
+ return capabilities
425
+
426
+ def get_map(
427
+ self,
428
+ layers: list[str],
429
+ bbox: tuple[float, float, float, float],
430
+ size: tuple[int, int],
431
+ srs: str = "EPSG:2056",
432
+ format: str = "image/png",
433
+ transparent: bool = True,
434
+ styles: list[str] | None = None,
435
+ language: str | None = None,
436
+ ) -> ResponseWrapper | None:
437
+ if not self.wms:
438
+ self.create_wms()
439
+ params: dict[str, Any] = {
440
+ "layers": layers,
441
+ "srs": srs,
442
+ "bbox": bbox,
443
+ "size": size,
444
+ "format": format,
445
+ "transparent": transparent,
446
+ "styles": styles,
447
+ }
448
+ if language is not None:
449
+ params["language"] = language
450
+ if self.wms:
451
+ return self.wms.getmap(
452
+ **params,
453
+ timeout=120,
454
+ )
455
+
456
+ def get_feature_info(
457
+ self,
458
+ layers: list[str],
459
+ bbox: tuple[float, float, float, float],
460
+ size: tuple[int, int],
461
+ srs: str = "EPSG:2056",
462
+ info_format: str = "application/json",
463
+ transparent: bool = True,
464
+ styles: list[str] | None = None,
465
+ xy: list[float] = [0, 0],
466
+ ) -> ResponseWrapper | None:
467
+ if not self.wms:
468
+ self.create_wms()
469
+ params = {
470
+ "layers": layers,
471
+ "srs": srs,
472
+ "bbox": bbox,
473
+ "size": size,
474
+ "info_format": info_format,
475
+ "transparent": transparent,
476
+ "styles": styles,
477
+ "xy": xy,
478
+ }
479
+ if self.wms:
480
+ return self.wms.getfeatureinfo(
481
+ **params,
482
+ )
483
+
484
+ def get_legend_graphic(
485
+ self,
486
+ layer: list[str],
487
+ format: str = "image/png",
488
+ language: str | None = None,
489
+ style: str | None = None,
490
+ workspace: str | None = None,
491
+ ) -> Response:
492
+ if not workspace:
493
+ path = "/wms"
494
+ else:
495
+ path: str = f"/{workspace}/wms"
496
+ params: dict[str, Any] = {
497
+ "service": "WMS",
498
+ "version": "1.3.0",
499
+ "request": "GetLegendGraphic",
500
+ "format": format,
501
+ "layer": layer,
502
+ }
503
+ if language:
504
+ params["language"] = language
505
+ if style:
506
+ params["style"] = style
507
+ response: Response = self.get_request(path, params=params)
508
+ return response
509
+
510
+ def get_tile(
511
+ self, layer, format, tile_matrix_set, tile_matrix, row, column
512
+ ) -> ResponseWrapper | None:
513
+ if not self.wmts:
514
+ self.create_wmts()
515
+ if self.wmts:
516
+ return self.wmts.gettile(
517
+ layer=layer,
518
+ format=format,
519
+ tilematrixset=tile_matrix_set,
520
+ tilematrix=tile_matrix,
521
+ row=row,
522
+ column=column,
523
+ )
524
+
525
+ def create_role(self, role_name: str):
526
+ self.post_request(f"/rest/security/roles/role/{role_name}")
527
+
528
+ def delete_role(self, role_name: str):
529
+ self.delete_request(f"/rest/security/roles/role/{role_name}")
530
+
531
+ def create_role_if_not_exists(self, role_name: str) -> Response | None:
532
+ if self.role_exists(role_name):
533
+ return None
534
+ return self.create_role(role_name)
535
+
536
+ def role_exists(self, role_name: str) -> bool:
537
+ response = self.get_request(
538
+ f"/rest/security/roles", headers={"Accept": "application/json"}
539
+ )
540
+ roles = response.json().get("roles", [])
541
+ return role_name in roles
542
+
543
+ def create_acl_admin_rule(
544
+ self,
545
+ priority: int = 0,
546
+ access: str = "ADMIN",
547
+ role: str | None = None,
548
+ user: str | None = None,
549
+ workspace: str | None = None,
550
+ ) -> Response:
551
+ path = "/acl/api/adminrules"
552
+ return self.post_request(
553
+ path,
554
+ json={
555
+ "priority": priority,
556
+ "access": access,
557
+ "role": role,
558
+ "user": user,
559
+ "workspace": workspace,
560
+ },
561
+ )
562
+
563
+ def delete_acl_admin_rule(self, id: int) -> Response:
564
+ path = f"/acl/api/adminrules/id/{id}"
565
+ return self.delete_request(path)
566
+
567
+ def delete_all_acl_admin_rules(self) -> Response:
568
+ path = "/acl/api/adminrules"
569
+ return self.delete_request(path)
570
+
571
+ def create_acl_rule(
572
+ self,
573
+ priority: int = 0,
574
+ access: str = "DENY",
575
+ role: str | None = None,
576
+ service: str | None = None,
577
+ workspace: str | None = None,
578
+ ) -> Response:
579
+ path = "/acl/api/rules"
580
+ return self.post_request(
581
+ path,
582
+ json={
583
+ "priority": priority,
584
+ "access": access,
585
+ "role": role,
586
+ "service": service,
587
+ "workspace": workspace,
588
+ },
589
+ )
590
+
591
+ def delete_all_acl_rules(self) -> Response:
592
+ path = "/acl/api/rules"
593
+ return self.delete_request(path)
594
+
595
+ def create_or_update_resource(self, path, resource_path, payload) -> Response:
596
+ if not self.resource_exists(resource_path):
597
+ return self.post_request(path, json=payload)
598
+ else:
599
+ return self.put_request(resource_path, json=payload)
600
+
601
+ def create_gridset(self, epsg: int) -> Response | None:
602
+ resource_path: str = f"/gwc/rest/gridsets/EPSG:{epsg}.xml"
603
+ if self.resource_exists(resource_path):
604
+ return None
605
+ file_path: Path = Path(__file__).parent / "gridsets" / f"{epsg}.xml"
606
+ headers: dict[str, str] = {"Content-Type": "application/xml"}
607
+ try:
608
+ data: bytes = file_path.read_bytes()
609
+ except FileNotFoundError:
610
+ raise ValueError(f"No gridset definition found for EPSG:{epsg}")
611
+ return self.put_request(resource_path, data=data, headers=headers)
612
+
613
+ def resource_exists(self, path: str) -> bool:
614
+ # GeoServer raises a 500 when posting to a datastore or feature type that already exists, so first do
615
+ # a get request
616
+ response = self.get_request(path)
617
+ return response.status_code == 200
618
+
619
+ def get_request(
620
+ self,
621
+ path,
622
+ params: dict[str, str] | None = None,
623
+ headers: dict[str, str] | None = None,
624
+ ) -> Response:
625
+ return self.rest_service.get(path, params=params, headers=headers)
626
+
627
+ def post_request(
628
+ self,
629
+ path: str,
630
+ params: dict[str, str] | None = None,
631
+ headers: dict[str, str] | None = None,
632
+ json: dict[str, Any] | None = None,
633
+ data: bytes | None = None,
634
+ ) -> Response:
635
+ return self.rest_service.post(
636
+ path, params=params, headers=headers, json=json, data=data
637
+ )
638
+
639
+ def put_request(
640
+ self,
641
+ path: str,
642
+ params: dict[str, str] | None = None,
643
+ headers: dict[str, str] | None = None,
644
+ json: dict[str, dict[str, Any]] | None = None,
645
+ data: bytes | None = None,
646
+ ) -> Response:
647
+ return self.rest_service.put(
648
+ path, params=params, headers=headers, json=json, data=data
649
+ )
650
+
651
+ def delete_request(
652
+ self,
653
+ path: str,
654
+ params: dict[str, str] | None = None,
655
+ headers: dict[str, str] | None = None,
656
+ ) -> Response:
657
+ return self.rest_service.delete(path, params=params, headers=headers)
@@ -0,0 +1,92 @@
1
+ <?xml version="1.0" encoding="UTF-8"?>
2
+ <gridSet>
3
+ <name>EPSG:3857</name>
4
+ <description>This well-known scale set has been defined to be compatible with Google Maps and
5
+ Microsoft Live Map projections and zoom levels. Level 0 allows representing the whole world
6
+ in a single 256x256 pixels. The next level represents the whole world in 2x2 tiles of
7
+ 256x256 pixels and so on in powers of 2. Scale denominator is only accurate near the
8
+ equator.</description>
9
+ <srs>
10
+ <number>3857</number>
11
+ </srs>
12
+ <extent>
13
+ <coords>
14
+ <double>-2.003750834E7</double>
15
+ <double>-2.003750834E7</double>
16
+ <double>2.003750834E7</double>
17
+ <double>2.003750834E7</double>
18
+ </coords>
19
+ </extent>
20
+ <alignTopLeft>false</alignTopLeft>
21
+ <resolutions>
22
+ <double>156543.03390625</double>
23
+ <double>78271.516953125</double>
24
+ <double>39135.7584765625</double>
25
+ <double>19567.87923828125</double>
26
+ <double>9783.939619140625</double>
27
+ <double>4891.9698095703125</double>
28
+ <double>2445.9849047851562</double>
29
+ <double>1222.9924523925781</double>
30
+ <double>611.4962261962891</double>
31
+ <double>305.74811309814453</double>
32
+ <double>152.87405654907226</double>
33
+ <double>76.43702827453613</double>
34
+ <double>38.218514137268066</double>
35
+ <double>19.109257068634033</double>
36
+ <double>9.554628534317017</double>
37
+ <double>4.777314267158508</double>
38
+ <double>2.388657133579254</double>
39
+ <double>1.194328566789627</double>
40
+ <double>0.5971642833948135</double>
41
+ <double>0.29858214169740677</double>
42
+ <double>0.14929107084870338</double>
43
+ <double>0.07464553542435169</double>
44
+ <double>0.037322767712175846</double>
45
+ <double>0.018661383856087923</double>
46
+ <double>0.009330691928043961</double>
47
+ <double>0.004665345964021981</double>
48
+ <double>0.0023326729820109904</double>
49
+ <double>0.0011663364910054952</double>
50
+ <double>5.831682455027476E-4</double>
51
+ <double>2.915841227513738E-4</double>
52
+ <double>1.457920613756869E-4</double>
53
+ </resolutions>
54
+ <metersPerUnit>1.0</metersPerUnit>
55
+ <pixelSize>2.8E-4</pixelSize>
56
+ <scaleNames>
57
+ <string>EPSG:3857:0</string>
58
+ <string>EPSG:3857:1</string>
59
+ <string>EPSG:3857:2</string>
60
+ <string>EPSG:3857:3</string>
61
+ <string>EPSG:3857:4</string>
62
+ <string>EPSG:3857:5</string>
63
+ <string>EPSG:3857:6</string>
64
+ <string>EPSG:3857:7</string>
65
+ <string>EPSG:3857:8</string>
66
+ <string>EPSG:3857:9</string>
67
+ <string>EPSG:3857:10</string>
68
+ <string>EPSG:3857:11</string>
69
+ <string>EPSG:3857:12</string>
70
+ <string>EPSG:3857:13</string>
71
+ <string>EPSG:3857:14</string>
72
+ <string>EPSG:3857:15</string>
73
+ <string>EPSG:3857:16</string>
74
+ <string>EPSG:3857:17</string>
75
+ <string>EPSG:3857:18</string>
76
+ <string>EPSG:3857:19</string>
77
+ <string>EPSG:3857:20</string>
78
+ <string>EPSG:3857:21</string>
79
+ <string>EPSG:3857:22</string>
80
+ <string>EPSG:3857:23</string>
81
+ <string>EPSG:3857:24</string>
82
+ <string>EPSG:3857:25</string>
83
+ <string>EPSG:3857:26</string>
84
+ <string>EPSG:3857:27</string>
85
+ <string>EPSG:3857:28</string>
86
+ <string>EPSG:3857:29</string>
87
+ <string>EPSG:3857:30</string>
88
+ </scaleNames>
89
+ <tileHeight>256</tileHeight>
90
+ <tileWidth>256</tileWidth>
91
+ <yCoordinateFirst>false</yCoordinateFirst>
92
+ </gridSet>
@@ -0,0 +1,81 @@
1
+ from typing import Any
2
+
3
+ import requests
4
+
5
+
6
+ class RestService:
7
+ def __init__(self, url: str, auth: tuple[str, str]) -> None:
8
+ self.url: str = url
9
+ self.auth: tuple[str, str] = auth
10
+
11
+ def get(
12
+ self,
13
+ path: str,
14
+ params: dict[str, str] | None = None,
15
+ headers: dict[str, str] | None = None,
16
+ ) -> requests.Response:
17
+ response: requests.Response = requests.get(
18
+ f"{self.url}{path}",
19
+ params=params,
20
+ headers=headers,
21
+ auth=self.auth,
22
+ )
23
+ if response.status_code != 404:
24
+ response.raise_for_status()
25
+ return response
26
+
27
+ def post(
28
+ self,
29
+ path: str,
30
+ params: dict[str, str] | None = None,
31
+ headers: dict[str, str] | None = None,
32
+ json: dict[str, dict[str, Any]] | None = None,
33
+ data: bytes | None = None,
34
+ ) -> requests.Response:
35
+
36
+ response: requests.Response = requests.post(
37
+ f"{self.url}{path}",
38
+ params=params,
39
+ headers=headers,
40
+ json=json,
41
+ data=data,
42
+ auth=self.auth,
43
+ )
44
+ if response.status_code != 409:
45
+ response.raise_for_status()
46
+ return response
47
+
48
+ def put(
49
+ self,
50
+ path: str,
51
+ params: dict[str, str] | None = None,
52
+ headers: dict[str, str] | None = None,
53
+ json: dict[str, dict[str, Any]] | None = None,
54
+ data: bytes | None = None,
55
+ ) -> requests.Response:
56
+ response: requests.Response = requests.put(
57
+ f"{self.url}{path}",
58
+ params=params,
59
+ headers=headers,
60
+ json=json,
61
+ data=data,
62
+ auth=self.auth,
63
+ )
64
+ response.raise_for_status()
65
+ return response
66
+
67
+ def delete(
68
+ self,
69
+ path: str,
70
+ params: dict[str, str] | None = None,
71
+ headers: dict[str, str] | None = None,
72
+ ) -> requests.Response:
73
+ response: requests.Response = requests.delete(
74
+ f"{self.url}{path}",
75
+ params=params,
76
+ headers=headers,
77
+ auth=self.auth,
78
+ )
79
+ if response.status_code != 404:
80
+ response.raise_for_status()
81
+ return response
@@ -0,0 +1,304 @@
1
+ from typing import Any
2
+
3
+ EPSG_BBOX = {
4
+ 2056: {
5
+ "nativeBoundingBox": {
6
+ "crs": {"$": f"EPSG:2056", "@class": "projected"},
7
+ "maxx": 2837016.9329778464,
8
+ "maxy": 1299782.763494124,
9
+ "minx": 2485014.052451379,
10
+ "miny": 1074188.6943776933,
11
+ },
12
+ "latLonBoundingBox": {
13
+ "crs": "EPSG:4326",
14
+ "maxx": 10.603307860867739,
15
+ "maxy": 47.8485348773655,
16
+ "minx": 5.902662003204146,
17
+ "miny": 45.7779277267225,
18
+ },
19
+ },
20
+ 4326: {
21
+ "nativeBoundingBox": {
22
+ "crs": {"$": f"EPSG:4326", "@class": "projected"},
23
+ "maxx": 180,
24
+ "maxy": 90,
25
+ "minx": -180,
26
+ "miny": -90,
27
+ },
28
+ "latLonBoundingBox": {
29
+ "crs": "EPSG:4326",
30
+ "maxx": 180,
31
+ "maxy": 90,
32
+ "minx": -180,
33
+ "miny": -90,
34
+ },
35
+ },
36
+ }
37
+
38
+
39
+ class Templates:
40
+
41
+ @staticmethod
42
+ def workspace_wms(workspace: str) -> dict[str, dict[str, Any]]:
43
+ return {
44
+ "wms": {
45
+ "workspace": {"name": workspace},
46
+ "enabled": True,
47
+ "name": "WMS",
48
+ "versions": {
49
+ "org.geotools.util.Version": [
50
+ {"version": "1.1.1"},
51
+ {"version": "1.3.0"},
52
+ ]
53
+ },
54
+ "citeCompliant": False,
55
+ "schemaBaseURL": "http://schemas.opengis.net",
56
+ "verbose": False,
57
+ "bboxForEachCRS": False,
58
+ "watermark": {
59
+ "enabled": False,
60
+ "position": "BOT_RIGHT",
61
+ "transparency": 100,
62
+ },
63
+ "interpolation": "Nearest",
64
+ "getFeatureInfoMimeTypeCheckingEnabled": False,
65
+ "getMapMimeTypeCheckingEnabled": False,
66
+ "dynamicStylingDisabled": False,
67
+ "featuresReprojectionDisabled": False,
68
+ "maxBuffer": 0,
69
+ "maxRequestMemory": 0,
70
+ "maxRenderingTime": 0,
71
+ "maxRenderingErrors": 0,
72
+ "maxRequestedDimensionValues": 100,
73
+ "cacheConfiguration": {
74
+ "enabled": False,
75
+ "maxEntries": 1000,
76
+ "maxEntrySize": 51200,
77
+ },
78
+ "remoteStyleMaxRequestTime": 60000,
79
+ "remoteStyleTimeout": 30000,
80
+ "defaultGroupStyleEnabled": True,
81
+ "transformFeatureInfoDisabled": False,
82
+ "autoEscapeTemplateValues": False,
83
+ }
84
+ }
85
+
86
+ @staticmethod
87
+ def postgis_data_store(
88
+ datastore: str,
89
+ pg_host: str,
90
+ pg_port: int,
91
+ pg_db: str,
92
+ pg_user: str,
93
+ pg_password: str,
94
+ namespace: str,
95
+ pg_schema: str = "public",
96
+ ) -> dict[str, dict[str, Any]]:
97
+ return {
98
+ "dataStore": {
99
+ "name": datastore,
100
+ "connectionParameters": {
101
+ "entry": [
102
+ {"@key": "dbtype", "$": "postgis"},
103
+ {"@key": "host", "$": pg_host},
104
+ {"@key": "port", "$": pg_port},
105
+ {"@key": "database", "$": pg_db},
106
+ {"@key": "user", "$": pg_user},
107
+ {"@key": "passwd", "$": pg_password},
108
+ {"@key": "schema", "$": pg_schema},
109
+ {
110
+ "@key": "namespace",
111
+ "$": namespace,
112
+ },
113
+ {"@key": "Expose primary keys", "$": "true"},
114
+ ]
115
+ },
116
+ }
117
+ }
118
+
119
+ @staticmethod
120
+ def postgis_jndi_data_store(
121
+ datastore: str,
122
+ jndi_reference: str,
123
+ namespace: str,
124
+ pg_schema: str = "public",
125
+ description: str | None = None,
126
+ ) -> dict[str, dict[str, Any]]:
127
+ return {
128
+ "dataStore": {
129
+ "name": datastore,
130
+ "description": description,
131
+ "connectionParameters": {
132
+ "entry": [
133
+ {"@key": "dbtype", "$": "postgis"},
134
+ {
135
+ "@key": "jndiReferenceName",
136
+ "$": jndi_reference,
137
+ },
138
+ {
139
+ "@key": "schema",
140
+ "$": pg_schema,
141
+ },
142
+ {
143
+ "@key": "namespace",
144
+ "$": namespace,
145
+ },
146
+ {"@key": "Expose primary keys", "$": "true"},
147
+ ]
148
+ },
149
+ }
150
+ }
151
+
152
+ @staticmethod
153
+ def wmts_store(
154
+ workspace: str, name: str, capabilities: str
155
+ ) -> dict[str, dict[str, Any]]:
156
+ return {
157
+ "wmtsStore": {
158
+ "name": name,
159
+ "type": "WMTS",
160
+ "capabilitiesURL": capabilities,
161
+ "workspace": {"name": workspace},
162
+ "enabled": True,
163
+ "metadata": {"entry": {"@key": "useConnectionPooling", "text": True}},
164
+ }
165
+ }
166
+
167
+ @staticmethod
168
+ def geom_point_attribute() -> dict[str, Any]:
169
+ return {
170
+ "geom": {
171
+ "type": "Point",
172
+ "required": True,
173
+ }
174
+ }
175
+
176
+ @staticmethod
177
+ def feature_type(
178
+ layer: str,
179
+ workspace: str,
180
+ datastore: str,
181
+ attributes: list[dict],
182
+ epsg: int = 4326,
183
+ ) -> dict[str, dict[str, Any]]:
184
+ return {
185
+ "featureType": {
186
+ "name": layer,
187
+ "nativeName": layer,
188
+ "srs": f"EPSG:{epsg}",
189
+ "enabled": True,
190
+ "store": {
191
+ "name": f"{workspace}:{datastore}",
192
+ },
193
+ "attributes": {
194
+ "attribute": attributes,
195
+ },
196
+ "nativeBoundingBox": {
197
+ "crs": EPSG_BBOX[epsg]["nativeBoundingBox"]["crs"],
198
+ "maxx": EPSG_BBOX[epsg]["nativeBoundingBox"]["maxx"],
199
+ "maxy": EPSG_BBOX[epsg]["nativeBoundingBox"]["maxy"],
200
+ "minx": EPSG_BBOX[epsg]["nativeBoundingBox"]["minx"],
201
+ "miny": EPSG_BBOX[epsg]["nativeBoundingBox"]["miny"],
202
+ },
203
+ "latLonBoundingBox": {
204
+ "crs": EPSG_BBOX[epsg]["latLonBoundingBox"]["crs"],
205
+ "maxx": EPSG_BBOX[epsg]["latLonBoundingBox"]["maxx"],
206
+ "maxy": EPSG_BBOX[epsg]["latLonBoundingBox"]["maxy"],
207
+ "minx": EPSG_BBOX[epsg]["latLonBoundingBox"]["minx"],
208
+ "miny": EPSG_BBOX[epsg]["latLonBoundingBox"]["miny"],
209
+ },
210
+ }
211
+ }
212
+
213
+ @staticmethod
214
+ def layer_group(
215
+ group: str,
216
+ layers: list[str],
217
+ workspace: str,
218
+ title: str | dict[str, Any],
219
+ abstract: str | dict[str, Any],
220
+ epsg: int = 4326,
221
+ ) -> dict[str, dict[str, Any]]:
222
+ template = {
223
+ "layerGroup": {
224
+ "name": group,
225
+ "workspace": {"name": workspace},
226
+ "mode": "SINGLE",
227
+ "publishables": {
228
+ "published": [
229
+ {"@type": "layer", "name": f"{workspace}:{layer}"}
230
+ for layer in layers
231
+ ]
232
+ },
233
+ "styles": {"style": [{"name": ""}] * len(layers)},
234
+ "bounds": {
235
+ "minx": EPSG_BBOX[epsg]["nativeBoundingBox"]["minx"],
236
+ "maxx": EPSG_BBOX[epsg]["nativeBoundingBox"]["maxx"],
237
+ "miny": EPSG_BBOX[epsg]["nativeBoundingBox"]["miny"],
238
+ "maxy": EPSG_BBOX[epsg]["nativeBoundingBox"]["maxy"],
239
+ "crs": f"EPSG:{epsg}",
240
+ },
241
+ "enabled": True,
242
+ "advertised": True,
243
+ }
244
+ }
245
+ if title:
246
+ if type(title) is dict:
247
+ template["layerGroup"]["internationalTitle"] = title
248
+ else:
249
+ template["layerGroup"]["title"] = title
250
+ if abstract:
251
+ if type(abstract) is dict:
252
+ template["layerGroup"]["internationalAbstract"] = abstract
253
+ else:
254
+ template["layerGroup"]["abstract"] = abstract
255
+ return template
256
+
257
+ @staticmethod
258
+ def wmts_layer(
259
+ name: str,
260
+ native_name: str,
261
+ epsg: int = 4326,
262
+ wgs84_bbox: tuple[float, float, float, float] | None = None,
263
+ ) -> dict[str, dict[str, Any]]:
264
+ template = {
265
+ "wmtsLayer": {
266
+ "advertised": True,
267
+ "enabled": True,
268
+ "name": name,
269
+ "nativeName": native_name,
270
+ "projectionPolicy": "FORCE_DECLARED",
271
+ "serviceConfiguration": False,
272
+ "simpleConversionEnabled": False,
273
+ "srs": f"EPSG:{epsg}",
274
+ }
275
+ }
276
+ if wgs84_bbox:
277
+ template["wmtsLayer"]["latLonBoundingBox"] = {
278
+ "crs": "EPSG:4326",
279
+ "minx": wgs84_bbox[0],
280
+ "maxx": wgs84_bbox[2],
281
+ "miny": wgs84_bbox[1],
282
+ "maxy": wgs84_bbox[3],
283
+ }
284
+ if epsg == 4326:
285
+ template["wmtsLayer"]["nativeBoundingBox"] = {
286
+ "crs": "EPSG:4326",
287
+ "minx": wgs84_bbox[0],
288
+ "maxx": wgs84_bbox[2],
289
+ "miny": wgs84_bbox[1],
290
+ "maxy": wgs84_bbox[3],
291
+ }
292
+ return template
293
+
294
+ @staticmethod
295
+ def gwc_layer(
296
+ workspace: str, layer: str, gridset: str
297
+ ) -> dict[str, dict[str, Any]]:
298
+ return {
299
+ "GeoServerLayer": {
300
+ "name": f"{workspace}:{layer}",
301
+ "enabled": "true",
302
+ "gridSubsets": {"gridSubset": [{"gridSetName": gridset}]},
303
+ }
304
+ }
@@ -0,0 +1,36 @@
1
+ def java_binding(data_type: str) -> str:
2
+ match data_type:
3
+ case "string":
4
+ return "java.lang.String"
5
+ case "integer":
6
+ return "java.lang.Integer"
7
+ case "float":
8
+ return "java.lang.Float"
9
+ case "datetime":
10
+ return "java.util.Date"
11
+ case "Point":
12
+ return "org.locationtech.jts.geom.Point"
13
+ case "Line":
14
+ return "org.locationtech.jts.geom.LineString"
15
+ case "Polygon":
16
+ return "org.locationtech.jts.geom.Polygon"
17
+ case "MultiPolygon":
18
+ return "org.locationtech.jts.geom.MultiPolygon"
19
+ case _:
20
+ return "java.lang.String"
21
+
22
+
23
+ def convert_attributes(attributes: dict) -> list[dict]:
24
+ geoserver_attributes = []
25
+ for name, data in attributes.items():
26
+ required: bool = data.get("required", False)
27
+ geoserver_attributes.append(
28
+ {
29
+ "name": name,
30
+ "minOccurs": int(required),
31
+ "maxOccurs": 1,
32
+ "nillable": not required,
33
+ "binding": java_binding(data["type"]),
34
+ }
35
+ )
36
+ return geoserver_attributes
@@ -0,0 +1,42 @@
1
+ [tool.poetry]
2
+ name = "geoservercloud"
3
+ version = "0.1.0.dev1"
4
+ description = "Lightweight Python client to interact with GeoServer Cloud REST API, GeoServer ACL and OGC services"
5
+ authors = ["Camptocamp <info@camptocamp.com>"]
6
+ license = "BSD-2-Clause"
7
+ readme = "README.md"
8
+ packages = [{include = "geoservercloud"}]
9
+
10
+ [tool.poetry.dependencies]
11
+ python = ">=3.9,<4.0"
12
+ requests = "2.32.3"
13
+ OWSLib = "0.31.0"
14
+ xmltodict = "0.13.0"
15
+
16
+ [tool.poetry.group.dev.dependencies]
17
+ pytest = "8.2.2"
18
+
19
+ [build-system]
20
+ requires = [
21
+ "poetry-core>=1.0.0",
22
+ "poetry-dynamic-versioning[plugin]",
23
+ "poetry-plugin-tweak-dependencies-version"
24
+ ]
25
+ build-backend = "poetry.core.masonry.api"
26
+
27
+ [tool.poetry-dynamic-versioning]
28
+ enable = false
29
+ vcs = "git"
30
+ pattern = "^(?P<base>\\d+(\\.\\d+)*)"
31
+ format-jinja = """
32
+ {%- if env.get("VERSION_TYPE") == "version_branch" -%}
33
+ {{serialize_pep440(bump_version(base, 1 if env.get("IS_MASTER") == "TRUE" else 2), dev=distance)}}
34
+ {%- elif distance == 0 -%}
35
+ {{serialize_pep440(base)}}
36
+ {%- else -%}
37
+ {{serialize_pep440(bump_version(base), dev=distance)}}
38
+ {%- endif -%}
39
+ """
40
+
41
+ [tool.poetry-plugin-tweak-dependencies-version]
42
+ default = "present"