specklia 1.9.65__py3-none-any.whl → 1.9.67__py3-none-any.whl

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.
@@ -20,25 +20,24 @@ IMPORTANT: THE VERSION HERE IN THE SPECKLIA PACKAGE MUST NOT BE MADE DEPENDENT U
20
20
  IS PRIVATE BUT THIS PACKAGE IS PUBLIC!
21
21
  """
22
22
 
23
+ import struct
24
+ import time
23
25
  from enum import Enum
24
26
  from io import BytesIO
25
27
  from logging import Logger
26
- import struct
27
- import time
28
28
  from typing import List, Tuple, Union
29
29
 
30
+ import requests
30
31
  from geopandas import GeoDataFrame
31
32
  from geopandas import read_feather as read_geofeather
32
- from pandas import DataFrame
33
- from pandas import read_feather
34
- import requests
33
+ from pandas import DataFrame, read_feather
35
34
 
36
35
  log = Logger(__name__)
37
36
 
38
37
  CHUNK_DB_NAME = "data_transfer_chunks"
39
38
  CHUNK_METADATA_COLLECTION_NAME = "chunk_metadata"
40
39
  MAX_CHUNK_AGE_SECONDS = 3600
41
- MAX_CHUNK_SIZE_BYTES = 5 * 1024 ** 2 # must be small enough to fit into an HTTP GET Request
40
+ MAX_CHUNK_SIZE_BYTES = 5 * 1024**2 # must be small enough to fit into an HTTP GET Request
42
41
  CHUNK_DOWNLOAD_RETRIES = 10
43
42
  CHUNK_DOWNLOAD_TIMEOUT_S = 10
44
43
 
@@ -74,19 +73,16 @@ def upload_chunks(api_address: str, chunks: List[Tuple[int, bytes]]) -> str:
74
73
  The chunk set uuid of the uploaded chunks
75
74
  """
76
75
  # post the first chunk to start the upload
77
- response = requests.post(
78
- api_address + f"/chunk/upload/{chunks[0][0]}-of-{len(chunks)}",
79
- data=chunks[0][1])
76
+ response = requests.post(api_address + f"/chunk/upload/{chunks[0][0]}-of-{len(chunks)}", data=chunks[0][1])
80
77
  response.raise_for_status()
81
- log.info("response from very first /chunk/upload was '%s'", response.json())
82
- chunk_set_uuid = response.json()['chunk_set_uuid']
78
+ log.debug("response from very first /chunk/upload was '%s'", response.json())
79
+ chunk_set_uuid = response.json()["chunk_set_uuid"]
83
80
 
84
81
  # post the rest of the chunks in a random order
85
82
  for i, chunk in chunks[1:]:
86
- response = requests.post(
87
- api_address + f"/chunk/upload/{chunk_set_uuid}/{i}-of-{len(chunks)}", data=chunk)
83
+ response = requests.post(api_address + f"/chunk/upload/{chunk_set_uuid}/{i}-of-{len(chunks)}", data=chunk)
88
84
  response.raise_for_status()
89
- log.info("response from subsequent /chunk/upload/uuid call was '%s'", response.text)
85
+ log.debug("response from subsequent /chunk/upload/uuid call was '%s'", response.text)
90
86
 
91
87
  return chunk_set_uuid
92
88
 
@@ -123,31 +119,29 @@ def download_chunks(api_address: str, chunk_set_uuid: str, num_chunks: int) -> b
123
119
  while retries < CHUNK_DOWNLOAD_RETRIES and not success:
124
120
  try:
125
121
  this_chunk_response = requests.get(
126
- f"{api_address}/chunk/download/{chunk_set_uuid}/{chunk_ordinal}",
127
- timeout=CHUNK_DOWNLOAD_TIMEOUT_S
122
+ f"{api_address}/chunk/download/{chunk_set_uuid}/{chunk_ordinal}", timeout=CHUNK_DOWNLOAD_TIMEOUT_S
128
123
  )
129
124
  this_chunk_response.raise_for_status()
130
- ordinal = struct.unpack('i', this_chunk_response.content[:4])[0]
125
+ ordinal = struct.unpack("i", this_chunk_response.content[:4])[0]
131
126
  chunk = this_chunk_response.content[4:]
132
- assert ordinal == chunk_ordinal, (
133
- f"Chunk ordinal mismatch: expected {chunk_ordinal}, got {ordinal}")
127
+ assert ordinal == chunk_ordinal, f"Chunk ordinal mismatch: expected {chunk_ordinal}, got {ordinal}"
134
128
  chunks.append(chunk)
135
129
  success = True
136
130
  except (requests.Timeout, requests.ConnectionError) as e:
137
131
  retries += 1
138
- log.warning(
139
- "Request failed with %s. Retrying (%s/%s)...", e, retries, CHUNK_DOWNLOAD_RETRIES)
132
+ log.warning("Request failed with %s. Retrying (%s/%s)...", e, retries, CHUNK_DOWNLOAD_RETRIES)
140
133
  time.sleep(1) # Small backoff before retrying
141
134
  if not success:
142
135
  error_message = (
143
- f"Failed to download from chunk set {chunk_set_uuid} after {CHUNK_DOWNLOAD_TIMEOUT_S} attempts.")
136
+ f"Failed to download from chunk set {chunk_set_uuid} after {CHUNK_DOWNLOAD_TIMEOUT_S} attempts."
137
+ )
144
138
  log.error(error_message)
145
139
  raise RuntimeError(error_message)
146
140
 
147
141
  # Let the server know that we are done with this data and it can be deleted.
148
- requests.delete(f'{api_address}/chunk/delete/{chunk_set_uuid}')
142
+ requests.delete(f"{api_address}/chunk/delete/{chunk_set_uuid}")
149
143
 
150
- return b''.join(chunks)
144
+ return b"".join(chunks)
151
145
 
152
146
 
153
147
  def split_into_chunks(data: bytes, chunk_size: int = MAX_CHUNK_SIZE_BYTES) -> List[Tuple[int, bytes]]:
@@ -166,8 +160,7 @@ def split_into_chunks(data: bytes, chunk_size: int = MAX_CHUNK_SIZE_BYTES) -> Li
166
160
  List[Tuple[int, bytes]]
167
161
  A list of tuples containing the ordinal number of the chunk and each chunk
168
162
  """
169
- return list(
170
- enumerate((data[i:i + chunk_size] for i in range(0, len(data), chunk_size)), start=1))
163
+ return list(enumerate((data[i : i + chunk_size] for i in range(0, len(data), chunk_size)), start=1))
171
164
 
172
165
 
173
166
  def deserialise_dataframe(data: bytes) -> Union[DataFrame, GeoDataFrame]:
@@ -211,7 +204,7 @@ def serialise_dataframe(df: Union[DataFrame, GeoDataFrame]) -> bytes:
211
204
 
212
205
  Parameters
213
206
  ----------
214
- df : DataFrame
207
+ df: Union[DataFrame, GeoDataFrame]
215
208
  Input dataframe
216
209
 
217
210
  Returns
@@ -221,6 +214,6 @@ def serialise_dataframe(df: Union[DataFrame, GeoDataFrame]) -> bytes:
221
214
  """
222
215
  feather_buffer = BytesIO()
223
216
  # Browser implementations of feather do not support compressed feather formats.
224
- df.to_feather(feather_buffer, compression='uncompressed')
217
+ df.to_feather(feather_buffer, compression="uncompressed")
225
218
  feather_buffer.seek(0)
226
219
  return feather_buffer.getvalue()
specklia/client.py CHANGED
@@ -1,21 +1,25 @@
1
- """This file contains the Specklia python client. It is designed to talk to the Specklia webservice."""
1
+ """File contains the Specklia python client. It is designed to talk to the Specklia webservice."""
2
+
2
3
  from __future__ import annotations
3
4
 
4
- from datetime import datetime
5
5
  import json
6
6
  import logging
7
- from typing import cast, Dict, List, Literal, Optional, Tuple, Union
8
7
  import warnings
8
+ from typing import TYPE_CHECKING, Dict, List, Literal, Optional, Tuple, Union, cast
9
9
 
10
- from dateutil import parser
11
10
  import geopandas as gpd
12
11
  import pandas as pd
13
12
  import requests
13
+ from dateutil import parser
14
14
  from shapely import MultiPolygon, Polygon, to_geojson
15
15
  from shapely.geometry import shape
16
16
 
17
17
  from specklia import chunked_transfer, utilities
18
- from specklia.utilities import NewPoints
18
+
19
+ if TYPE_CHECKING:
20
+ from datetime import datetime
21
+
22
+ from specklia.utilities import NewPoints
19
23
 
20
24
  _log = logging.getLogger(__name__)
21
25
 
@@ -26,49 +30,43 @@ class Specklia:
26
30
 
27
31
  Specklia is a geospatial point cloud database designed for Academic use.
28
32
  Further details are available at https://specklia.earthwave.co.uk.
29
- """
30
33
 
31
- def __init__(
32
- self: Specklia,
33
- auth_token: str,
34
- url: str = 'https://specklia-api.earthwave.co.uk') -> None:
35
- """
36
- Create a new Specklia client object.
34
+ This object is a Python client for connecting to Specklia's API.
37
35
 
38
- This object is a Python client for connecting to Specklia's API.
36
+ Giving the value of this object's user_id to another user will allow them to add you to private groups.
37
+ Please quote your user_id when contacting support@earthwave.co.uk.
39
38
 
40
- Giving the value of this object's user_id to another user will allow them to add you to private groups.
41
- Please quote your user_id when contacting support@earthwave.co.uk.
39
+ Parameters
40
+ ----------
41
+ auth_token : str
42
+ The authentication token to use to authorise calls to Specklia.
43
+ Obtained via https://specklia.earthwave.co.uk.
44
+ url : str
45
+ The url where Specklia is running, by default the URL of the Specklia server.
42
46
 
43
- Parameters
44
- ----------
45
- auth_token : str
46
- The authentication token to use to authorise calls to Specklia.
47
- Obtained via https://specklia.earthwave.co.uk.
48
- url : str
49
- The url where Specklia is running, by default the URL of the Specklia server.
47
+ Examples
48
+ --------
49
+ To start using Specklia, we first need to navigate to https://specklia.earthwave.co.uk and follow the
50
+ instructions to generate a Specklia API key.
50
51
 
51
- Examples
52
- --------
53
- To start using Specklia, we first need to navigate to https://specklia.earthwave.co.uk and follow the
54
- instructions to generate a Specklia API key.
52
+ The key should then be kept somewhere safe where only we can access it, and needs to be passed each time we
53
+ instantiate our Specklia client.
55
54
 
56
- The key should then be kept somewhere safe where only we can access it, and needs to be passed each time we
57
- instantiate our Specklia client.
55
+ If we save our key to a file, we can then utilise it as such::
58
56
 
59
- If we save our key to a file, we can then utilise it as such::
57
+ >>> with open("our_auth_token.jwt") as fh:
58
+ ... user_auth_token = fh.read()
59
+ >>> client = Specklia(auth_token=user_auth_token)
60
+ """
60
61
 
61
- >>> with open("our_auth_token.jwt") as fh:
62
- ... user_auth_token = fh.read()
63
- >>> client = Specklia(auth_token=user_auth_token)
64
- """
62
+ def __init__(self: Specklia, auth_token: str, url: str = "https://specklia-api.earthwave.co.uk") -> None:
65
63
  self.server_url = url
66
64
  self.auth_token = auth_token
67
65
  self._data_streaming_timeout_s = 300
68
66
  # immediately retrieve the user's ID. This serves as a check that their API token is valid.
69
67
  self._fetch_user_id()
70
68
 
71
- _log.info('New Specklia client created.')
69
+ _log.info("New Specklia client created.")
72
70
 
73
71
  def _fetch_user_id(self: Specklia) -> None:
74
72
  """
@@ -76,12 +74,10 @@ class Specklia:
76
74
 
77
75
  We've separated this out for testing reasons.
78
76
  """
79
- response = requests.post(
80
- self.server_url + "/users",
81
- headers={"Authorization": "Bearer " + self.auth_token})
77
+ response = requests.post(self.server_url + "/users", headers={"Authorization": "Bearer " + self.auth_token})
82
78
  _check_response_ok(response)
83
79
  self.user_id = response.json()
84
- _log.info('fetched User ID for client, was %s', self.user_id)
80
+ _log.info("fetched User ID for client, was %s", self.user_id)
85
81
 
86
82
  def list_users(self: Specklia, group_id: str) -> pd.DataFrame:
87
83
  """
@@ -121,17 +117,22 @@ class Specklia:
121
117
  response = requests.get(
122
118
  self.server_url + "/users",
123
119
  headers={"Authorization": "Bearer " + self.auth_token},
124
- params={'group_id': group_id})
120
+ params={"group_id": group_id},
121
+ )
125
122
  _check_response_ok(response)
126
- _log.info('listed users within group_id %s.', group_id)
123
+ _log.info("listed users within group_id %s.", group_id)
127
124
  return pd.DataFrame(response.json()).convert_dtypes()
128
125
 
129
- def query_dataset( # noqa: CFQ002
130
- self: Specklia, dataset_id: str, epsg4326_polygon: Union[Polygon, MultiPolygon],
131
- min_datetime: datetime, max_datetime: datetime,
132
- columns_to_return: Optional[List[str]] = None,
133
- additional_filters: Optional[List[Dict[str, Union[float, str]]]] = None,
134
- source_information_only: bool = False) -> Tuple[gpd.GeoDataFrame, List[Dict]]:
126
+ def query_dataset(
127
+ self: Specklia,
128
+ dataset_id: str,
129
+ epsg4326_polygon: Union[Polygon, MultiPolygon],
130
+ min_datetime: datetime,
131
+ max_datetime: datetime,
132
+ columns_to_return: Optional[List[str]] = None,
133
+ additional_filters: Optional[List[Dict[str, Union[float, str]]]] = None,
134
+ source_information_only: bool = False,
135
+ ) -> Tuple[gpd.GeoDataFrame, List[Dict]]:
135
136
  """
136
137
  Query data within a dataset.
137
138
 
@@ -209,44 +210,45 @@ class Specklia:
209
210
  # note the use of json.loads() here, so effectively converting the geojson
210
211
  # back into a dictionary of JSON-compatible types to avoid "double-JSONing" it.
211
212
  request = {
212
- 'dataset_id': dataset_id,
213
- 'min_timestamp': int(min_datetime.timestamp()),
214
- 'max_timestamp': int(max_datetime.timestamp()),
215
- 'epsg4326_search_area': json.loads(to_geojson(epsg4326_polygon)),
216
- 'columns_to_return': [] if columns_to_return is None else columns_to_return,
217
- 'additional_filters': [] if additional_filters is None else additional_filters,
218
- 'source_information_only': source_information_only}
213
+ "dataset_id": dataset_id,
214
+ "min_timestamp": int(min_datetime.timestamp()),
215
+ "max_timestamp": int(max_datetime.timestamp()),
216
+ "epsg4326_search_area": json.loads(to_geojson(epsg4326_polygon)),
217
+ "columns_to_return": [] if columns_to_return is None else columns_to_return,
218
+ "additional_filters": [] if additional_filters is None else additional_filters,
219
+ "source_information_only": source_information_only,
220
+ }
219
221
 
220
222
  # submit the query
221
223
  response = requests.post(
222
- self.server_url + '/query',
223
- data=json.dumps(request),
224
- headers={"Authorization": "Bearer " + self.auth_token})
224
+ self.server_url + "/query", data=json.dumps(request), headers={"Authorization": "Bearer " + self.auth_token}
225
+ )
225
226
  _check_response_ok(response)
226
227
 
227
- _log.info('queried dataset with ID %s.', dataset_id)
228
+ _log.info("queried dataset with ID %s.", dataset_id)
228
229
 
229
230
  response_dict = response.json()
230
231
 
231
232
  # stream and deserialise the results
232
- if response_dict['num_chunks'] > 0:
233
+ if response_dict["num_chunks"] > 0:
233
234
  gdf = chunked_transfer.deserialise_dataframe(
234
235
  chunked_transfer.download_chunks(
235
236
  self.server_url,
236
- response_dict['chunk_set_uuid'],
237
- response_dict['num_chunks'],
237
+ response_dict["chunk_set_uuid"],
238
+ response_dict["num_chunks"],
238
239
  )
239
240
  )
240
241
  else:
241
242
  gdf = gpd.GeoDataFrame()
242
243
 
243
244
  # perform some light deserialisation of sources for backwards compatibility.
244
- sources = utilities.deserialise_sources(response_dict['sources'])
245
+ sources = utilities.deserialise_sources(response_dict["sources"])
245
246
 
246
- return cast(gpd.GeoDataFrame, gdf), cast(list[dict], sources)
247
+ return cast("gpd.GeoDataFrame", gdf), cast("list[dict]", sources)
247
248
 
248
249
  def update_points_in_dataset(
249
- self: Specklia, _dataset_id: str, _new_points: pd.DataFrame, _source_description: Dict) -> None:
250
+ self: Specklia, _dataset_id: str, _new_points: pd.DataFrame, _source_description: Dict
251
+ ) -> None:
250
252
  """
251
253
  Update previously existing data within a dataset.
252
254
 
@@ -254,7 +256,7 @@ class Specklia:
254
256
  Should be called once for each separate source of data.
255
257
 
256
258
  Parameters
257
- ------
259
+ ----------
258
260
  _dataset_id : str
259
261
  The UUID of the dataset to update.
260
262
  _new_points : pd.DataFrame
@@ -270,12 +272,15 @@ class Specklia:
270
272
  NotImplementedError
271
273
  This route is not yet implemented.
272
274
  """
273
- _log.error('this method is not yet implemented.')
275
+ _log.error("this method is not yet implemented.")
274
276
  raise NotImplementedError()
275
277
 
276
278
  def add_points_to_dataset(
277
- self: Specklia, dataset_id: str, new_points: List[NewPoints],
278
- duplicate_source_behaviour: Literal['error', 'ignore', 'replace', 'merge'] = 'error') -> None:
279
+ self: Specklia,
280
+ dataset_id: str,
281
+ new_points: List[NewPoints],
282
+ duplicate_source_behaviour: Literal["error", "ignore", "replace", "merge"] = "error",
283
+ ) -> None:
279
284
  """
280
285
  Add new data to a dataset.
281
286
 
@@ -297,7 +302,7 @@ class Specklia:
297
302
  ----------
298
303
  dataset_id : str
299
304
  The UUID of the dataset to add data to.
300
- new_points : List[Dict[str, Union[Dict, gpd.GeoDataFrame]]]
305
+ new_points : List[NewPoints]
301
306
  A list of dictionaries with the keys 'source' and 'gdf'. Within each dictionary, the value for 'source'
302
307
  is a dictionary describing the source of the data.
303
308
  The value for 'gdf' is a GeoDataFrame containing the points to add to the dataset.
@@ -315,32 +320,33 @@ class Specklia:
315
320
  # serialise and upload each dataframe
316
321
  upload_points = []
317
322
  for n in new_points:
318
- chunks = chunked_transfer.split_into_chunks(
319
- chunked_transfer.serialise_dataframe(n['gdf']))
320
- chunk_set_uuid = chunked_transfer.upload_chunks(
321
- self.server_url, chunks)
322
- upload_points.append({
323
- 'source': n['source'],
324
- 'chunk_set_uuid': chunk_set_uuid,
325
- 'num_chunks': len(chunks),
326
- })
323
+ chunks = chunked_transfer.split_into_chunks(chunked_transfer.serialise_dataframe(n["gdf"]))
324
+ chunk_set_uuid = chunked_transfer.upload_chunks(self.server_url, chunks)
325
+ upload_points.append(
326
+ {
327
+ "source": n["source"],
328
+ "chunk_set_uuid": chunk_set_uuid,
329
+ "num_chunks": len(chunks),
330
+ }
331
+ )
327
332
  del n
328
333
 
329
334
  response = requests.post(
330
335
  self.server_url + "/ingest",
331
336
  json={
332
- 'dataset_id': dataset_id,
333
- 'new_points': upload_points,
334
- 'duplicate_source_behaviour': duplicate_source_behaviour,
337
+ "dataset_id": dataset_id,
338
+ "new_points": upload_points,
339
+ "duplicate_source_behaviour": duplicate_source_behaviour,
335
340
  },
336
341
  headers={"Authorization": "Bearer " + self.auth_token},
337
342
  )
338
343
  _check_response_ok(response)
339
344
 
340
- _log.info('Added new data to specklia dataset ID %s.', dataset_id)
345
+ _log.info("Added new data to specklia dataset ID %s.", dataset_id)
341
346
 
342
347
  def delete_points_in_dataset(
343
- self: Specklia, _dataset_id: str, _source_ids_and_source_row_ids_to_delete: List[Tuple[str, str]]) -> None:
348
+ self: Specklia, _dataset_id: str, _source_ids_and_source_row_ids_to_delete: List[Tuple[str, str]]
349
+ ) -> None:
344
350
  """
345
351
  Delete data from a dataset.
346
352
 
@@ -360,7 +366,7 @@ class Specklia:
360
366
  NotImplementedError
361
367
  This route is not yet implemented.
362
368
  """
363
- _log.error('this method is not yet implemented.')
369
+ _log.error("this method is not yet implemented.")
364
370
  raise NotImplementedError()
365
371
 
366
372
  def list_all_groups(self: Specklia) -> pd.DataFrame:
@@ -374,10 +380,9 @@ class Specklia:
374
380
  pd.DataFrame
375
381
  A dataframe describing all groups
376
382
  """
377
- response = requests.get(
378
- self.server_url + "/groups", headers={"Authorization": "Bearer " + self.auth_token})
383
+ response = requests.get(self.server_url + "/groups", headers={"Authorization": "Bearer " + self.auth_token})
379
384
  _check_response_ok(response)
380
- _log.info('listing all groups within Specklia.')
385
+ _log.info("listing all groups within Specklia.")
381
386
  return pd.DataFrame(response.json()).convert_dtypes()
382
387
 
383
388
  def create_group(self: Specklia, group_name: str) -> str:
@@ -408,10 +413,13 @@ class Specklia:
408
413
  The endpoint will return the new group's unique ID, auto-generated by Specklia. We can pass this ID to other
409
414
  Specklia endpoints to modify the group, its members, and datasets.
410
415
  """
411
- response = requests.post(self.server_url + "/groups", json={'group_name': group_name},
412
- headers={"Authorization": "Bearer " + self.auth_token})
416
+ response = requests.post(
417
+ self.server_url + "/groups",
418
+ json={"group_name": group_name},
419
+ headers={"Authorization": "Bearer " + self.auth_token},
420
+ )
413
421
  _check_response_ok(response)
414
- _log.info('created new group with name %s.', group_name)
422
+ _log.info("created new group with name %s.", group_name)
415
423
  return response.text.strip('\n"')
416
424
 
417
425
  def update_group_name(self: Specklia, group_id: str, new_group_name: str) -> str:
@@ -442,10 +450,11 @@ class Specklia:
442
450
  """
443
451
  response = requests.put(
444
452
  self.server_url + "/groups",
445
- json={'group_id': group_id, 'new_group_name': new_group_name},
446
- headers={"Authorization": "Bearer " + self.auth_token})
453
+ json={"group_id": group_id, "new_group_name": new_group_name},
454
+ headers={"Authorization": "Bearer " + self.auth_token},
455
+ )
447
456
  _check_response_ok(response)
448
- _log.info('updated name of group ID %s to %s.', group_id, new_group_name)
457
+ _log.info("updated name of group ID %s to %s.", group_id, new_group_name)
449
458
  return response.text.strip('\n"')
450
459
 
451
460
  def delete_group(self: Specklia, group_id: str) -> str:
@@ -474,10 +483,12 @@ class Specklia:
474
483
  group will be removed from it, but left unchanged otherwise.
475
484
  """
476
485
  response = requests.delete(
477
- self.server_url + "/groups", headers={"Authorization": "Bearer " + self.auth_token},
478
- params={'group_id': group_id})
486
+ self.server_url + "/groups",
487
+ headers={"Authorization": "Bearer " + self.auth_token},
488
+ params={"group_id": group_id},
489
+ )
479
490
  _check_response_ok(response)
480
- _log.info('deleted group ID %s', group_id)
491
+ _log.info("deleted group ID %s", group_id)
481
492
  return response.text.strip('\n"')
482
493
 
483
494
  def list_groups(self: Specklia) -> pd.DataFrame:
@@ -507,9 +518,10 @@ class Specklia:
507
518
  We can now pass this ID to other Specklia endpoints to modify the group, its members, and datasets.
508
519
  """
509
520
  response = requests.get(
510
- self.server_url + "/groupmembership", headers={"Authorization": "Bearer " + self.auth_token})
521
+ self.server_url + "/groupmembership", headers={"Authorization": "Bearer " + self.auth_token}
522
+ )
511
523
  _check_response_ok(response)
512
- _log.info('listed groups that user is part of.')
524
+ _log.info("listed groups that user is part of.")
513
525
  return pd.DataFrame(response.json()).convert_dtypes()
514
526
 
515
527
  def add_user_to_group(self: Specklia, user_to_add_id: str, group_id: str) -> str:
@@ -548,11 +560,13 @@ class Specklia:
548
560
  able to write to the group's datasets or manage users within the group, we can update their privileges via
549
561
  client.update_user_privileges().
550
562
  """
551
- response = requests.post(self.server_url + "/groupmembership",
552
- json={'group_id': group_id, "user_to_add_id": user_to_add_id},
553
- headers={"Authorization": "Bearer " + self.auth_token})
563
+ response = requests.post(
564
+ self.server_url + "/groupmembership",
565
+ json={"group_id": group_id, "user_to_add_id": user_to_add_id},
566
+ headers={"Authorization": "Bearer " + self.auth_token},
567
+ )
554
568
  _check_response_ok(response)
555
- _log.info('added user ID %s to group ID %s', user_to_add_id, group_id)
569
+ _log.info("added user ID %s to group ID %s", user_to_add_id, group_id)
556
570
  return response.text.strip('\n"')
557
571
 
558
572
  def update_user_privileges(self: Specklia, group_id: str, user_to_update_id: str, new_privileges: str) -> str:
@@ -609,13 +623,13 @@ class Specklia:
609
623
  """
610
624
  response = requests.put(
611
625
  self.server_url + "/groupmembership",
612
- json={'group_id': group_id,
613
- "user_to_update_id": user_to_update_id,
614
- 'new_privileges': new_privileges},
615
- headers={"Authorization": "Bearer " + self.auth_token})
626
+ json={"group_id": group_id, "user_to_update_id": user_to_update_id, "new_privileges": new_privileges},
627
+ headers={"Authorization": "Bearer " + self.auth_token},
628
+ )
616
629
  _check_response_ok(response)
617
- _log.info('Updated user ID %s privileges to %s within group ID %s.',
618
- user_to_update_id, new_privileges, group_id)
630
+ _log.info(
631
+ "Updated user ID %s privileges to %s within group ID %s.", user_to_update_id, new_privileges, group_id
632
+ )
619
633
  return response.text.strip('\n"')
620
634
 
621
635
  def delete_user_from_group(self: Specklia, group_id: str, user_to_delete_id: str) -> str:
@@ -652,10 +666,12 @@ class Specklia:
652
666
 
653
667
  """
654
668
  response = requests.delete(
655
- self.server_url + "/groupmembership", headers={"Authorization": "Bearer " + self.auth_token},
656
- params={'group_id': group_id, "user_to_delete_id": user_to_delete_id})
669
+ self.server_url + "/groupmembership",
670
+ headers={"Authorization": "Bearer " + self.auth_token},
671
+ params={"group_id": group_id, "user_to_delete_id": user_to_delete_id},
672
+ )
657
673
  _check_response_ok(response)
658
- _log.info('Deleted user ID %s from group ID %s.', user_to_delete_id, group_id)
674
+ _log.info("Deleted user ID %s from group ID %s.", user_to_delete_id, group_id)
659
675
  return response.text.strip('\n"')
660
676
 
661
677
  def list_datasets(self: Specklia) -> pd.DataFrame:
@@ -669,27 +685,32 @@ class Specklia:
669
685
  pd.DataFrame
670
686
  A dataframe describing the datasets that the user can read.
671
687
  """
672
- response = requests.get(
673
- self.server_url + "/metadata", headers={"Authorization": "Bearer " + self.auth_token}
674
- )
688
+ response = requests.get(self.server_url + "/metadata", headers={"Authorization": "Bearer " + self.auth_token})
675
689
  _check_response_ok(response)
676
- _log.info('listed Specklia datasets that the current user can read.')
690
+ _log.info("listed Specklia datasets that the current user can read.")
677
691
 
678
692
  datasets_df = pd.DataFrame(response.json())
679
693
  # now convert the timestamps and polygons to appropriate dtypes
680
694
  for column in datasets_df.columns:
681
- if 'timestamp' in column:
695
+ if "timestamp" in column:
682
696
  datasets_df[column] = datasets_df[column].apply(
683
- lambda x: parser.parse(x, ignoretz=True) if x is not None else None)
684
- if column == 'epsg4326_coverage':
697
+ lambda x: parser.parse(x, ignoretz=True) if x is not None else None
698
+ )
699
+ if column == "epsg4326_coverage":
685
700
  datasets_df[column] = gpd.GeoSeries(
686
- datasets_df[column].apply(lambda x: shape(x) if x is not None else None), crs=4326) # type: ignore
701
+ datasets_df[column].apply(lambda x: shape(x) if x is not None else None), # type: ignore
702
+ crs=4326,
703
+ )
687
704
 
688
705
  return datasets_df.convert_dtypes() # convert the rest of the dtypes to pandas' best guest
689
706
 
690
707
  def create_dataset(
691
- self: Specklia, dataset_name: str, description: str,
692
- columns: Optional[List[Dict[str, str]]] = None, storage_technology: str = 'OLAP') -> str:
708
+ self: Specklia,
709
+ dataset_name: str,
710
+ description: str,
711
+ columns: Optional[List[Dict[str, str]]] = None,
712
+ storage_technology: str = "OLAP",
713
+ ) -> str:
693
714
  """
694
715
  Create a dataset.
695
716
 
@@ -705,14 +726,14 @@ class Specklia:
705
726
  personal group using Specklia.add_user_to_group().
706
727
 
707
728
  Parameters
708
- ---------
729
+ ----------
709
730
  dataset_name : str
710
731
  The name the user provides for the dataset.
711
732
  Must contain alphanumeric characters, spaces, underscores and hyphens only.
712
733
  description : str
713
734
  A description of the dataset.
714
735
  Must contain alphanumeric characters, spaces, underscores and hyphens only.
715
- columns : Optional[List[Dict[str, str]]],
736
+ columns : Optional[List[Dict[str, str]]]
716
737
  A list where each item is an additional column the user wishes to add to the dataset,
717
738
  beyond the mandatory EPSG4326 latitude, longitude and POSIX timestamp.
718
739
  A list of columns should follow the format::
@@ -773,27 +794,32 @@ class Specklia:
773
794
  If nothing is passed to the optional parameter 'columns', the created dataset will only have three columns: lat,
774
795
  long, and time.
775
796
  """
776
- if columns and any(x in ['lat', 'lon', 'long', 'latitude', 'longitude', 'timestamp', 'posix']
777
- for x in [col['name'].lower() for col in columns]):
778
- message = ("Please refrain from creating explicit EPSG4326 or POSIX timestamp columns "
779
- "as these are repetitious of Specklia's default columns.")
797
+ if columns and any(
798
+ x in ["lat", "lon", "long", "latitude", "longitude", "timestamp", "posix"]
799
+ for x in [col["name"].lower() for col in columns]
800
+ ):
801
+ message = (
802
+ "Please refrain from creating explicit EPSG4326 or POSIX timestamp columns "
803
+ "as these are repetitious of Specklia's default columns."
804
+ )
780
805
  _log.warning(message)
781
806
  warnings.warn(message, stacklevel=1)
782
807
 
783
808
  response = requests.post(
784
809
  self.server_url + "/metadata",
785
- json={'dataset_name': dataset_name,
786
- 'description': description,
787
- 'columns': columns,
788
- 'storage_technology': storage_technology},
789
- headers={"Authorization": "Bearer " + self.auth_token}
810
+ json={
811
+ "dataset_name": dataset_name,
812
+ "description": description,
813
+ "columns": columns,
814
+ "storage_technology": storage_technology,
815
+ },
816
+ headers={"Authorization": "Bearer " + self.auth_token},
790
817
  )
791
818
  _check_response_ok(response)
792
819
  _log.info("Created a new dataset with name '%s'", dataset_name)
793
820
  return response.text.strip('\n"')
794
821
 
795
- def update_dataset_ownership(
796
- self: Specklia, dataset_id: str, new_owning_group_id: str) -> str:
822
+ def update_dataset_ownership(self: Specklia, dataset_id: str, new_owning_group_id: str) -> str:
797
823
  """
798
824
  Transfer the ownership of a dataset to a different Specklia group.
799
825
 
@@ -801,7 +827,7 @@ class Specklia:
801
827
  which you wish to transfer the dataset in order to do this.
802
828
 
803
829
  Parameters
804
- ---------
830
+ ----------
805
831
  dataset_id : str
806
832
  The UUID of the dataset the user wishes to update
807
833
  new_owning_group_id : str
@@ -836,12 +862,11 @@ class Specklia:
836
862
  """
837
863
  response = requests.put(
838
864
  self.server_url + "/metadata",
839
- json={'dataset_id': dataset_id,
840
- 'new_owning_group_id': new_owning_group_id},
841
- headers={"Authorization": "Bearer " + self.auth_token}
865
+ json={"dataset_id": dataset_id, "new_owning_group_id": new_owning_group_id},
866
+ headers={"Authorization": "Bearer " + self.auth_token},
842
867
  )
843
868
  _check_response_ok(response)
844
- _log.info('set owning group for dataset ID %s to group ID %s', dataset_id, new_owning_group_id)
869
+ _log.info("set owning group for dataset ID %s to group ID %s", dataset_id, new_owning_group_id)
845
870
  return response.text.strip('\n"')
846
871
 
847
872
  def delete_dataset(self: Specklia, dataset_id: str) -> str:
@@ -851,7 +876,7 @@ class Specklia:
851
876
  You must be an ADMIN of the group that owns the dataset in order to do this.
852
877
 
853
878
  Parameters
854
- ---------
879
+ ----------
855
880
  dataset_id : str
856
881
  The UUID of the dataset the user wishes to delete
857
882
 
@@ -872,11 +897,11 @@ class Specklia:
872
897
  """
873
898
  response = requests.delete(
874
899
  self.server_url + "/metadata",
875
- params={'dataset_id': dataset_id},
876
- headers={"Authorization": "Bearer " + self.auth_token}
900
+ params={"dataset_id": dataset_id},
901
+ headers={"Authorization": "Bearer " + self.auth_token},
877
902
  )
878
903
  _check_response_ok(response)
879
- _log.info('Deleted dataset with ID %s', dataset_id)
904
+ _log.info("Deleted dataset with ID %s", dataset_id)
880
905
  return response.text.strip('\n"')
881
906
 
882
907
  def report_usage(self: Specklia, group_id: str) -> List[Dict]:
@@ -916,11 +941,11 @@ class Specklia:
916
941
  """
917
942
  response = requests.get(
918
943
  self.server_url + "/usage",
919
- params={'group_id': group_id},
920
- headers={"Authorization": "Bearer " + self.auth_token}
944
+ params={"group_id": group_id},
945
+ headers={"Authorization": "Bearer " + self.auth_token},
921
946
  )
922
947
  _check_response_ok(response)
923
- _log.info('Usage report queried for group_id %s', group_id)
948
+ _log.info("Usage report queried for group_id %s", group_id)
924
949
  return response.json()
925
950
 
926
951
 
@@ -946,11 +971,14 @@ def _check_response_ok(response: requests.Response) -> None:
946
971
  except requests.exceptions.JSONDecodeError:
947
972
  response_content = response.text
948
973
  if "The request was aborted because there was no available instance" in response_content:
949
- no_instances_message = 'Specklia is over capacity. Additional resources are being '\
950
- 'brought online, please try again in one minute.'
974
+ no_instances_message = (
975
+ "Specklia is over capacity. Additional resources are being "
976
+ "brought online, please try again in one minute."
977
+ )
951
978
  _log.error(no_instances_message)
952
- raise RuntimeError(no_instances_message)
979
+ raise RuntimeError(no_instances_message) from err
953
980
  else:
954
- _log.error('Failed to interact with Specklia server, error was: %s, %s', str(err), response_content)
981
+ _log.error("Failed to interact with Specklia server, error was: %s, %s", str(err), response_content)
955
982
  raise RuntimeError(
956
- f"Failed to interact with Specklia server, error was: {str(err)}, {response_content}") from None
983
+ f"Failed to interact with Specklia server, error was: {err!s}, {response_content}"
984
+ ) from None
specklia/utilities.py CHANGED
@@ -1,7 +1,8 @@
1
- """This file contains client-side utilities provided to make it easier to use Specklia."""
2
- from datetime import datetime
1
+ """File contains client-side utilities provided to make it easier to use Specklia."""
2
+
3
3
  import os
4
- from typing import Dict, List, Optional, TypedDict
4
+ from datetime import datetime
5
+ from typing import Dict, List, TypedDict
5
6
 
6
7
  import geopandas as gpd
7
8
  import numpy as np
@@ -11,8 +12,13 @@ from shapely.geometry import shape
11
12
 
12
13
 
13
14
  def save_gdf_as_tiff(
14
- gdf: gpd.GeoDataFrame, data_col: str, bounds: Dict[str, float],
15
- output_path: str, xy_proj4: Optional[str] = None, data_type: str = 'float32') -> None:
15
+ gdf: gpd.GeoDataFrame,
16
+ data_col: str,
17
+ bounds: Dict[str, float],
18
+ output_path: str,
19
+ xy_proj4: str | None = None,
20
+ data_type: str = "float32",
21
+ ) -> None:
16
22
  """
17
23
  Save a GeoDataFrame as a GeoTIFF file.
18
24
 
@@ -29,12 +35,12 @@ def save_gdf_as_tiff(
29
35
  The GeoDataFrame to save to a GeoTIFF file.
30
36
  data_col : str
31
37
  The name of the column within the GeoDataFrame to save out as a Tiff file.
32
- bounds : Dict[str, float]:
38
+ bounds : Dict[str, float]
33
39
  A dictionary containing the keys "min_x", "min_y", "max_x" and "max_y" indicating the bounds of the saved tiff.
34
40
  These are provided separately because the data in gdf may not extend to the desired edges of the tiff file.
35
41
  output_path : str
36
42
  The output path of the GeoTIFF file.
37
- xy_proj4 : Optional[str], by default None
43
+ xy_proj4 : str | None
38
44
  If not None, the Proj4 code of the 'x' and 'y' columns in the GeoDataFrame. These columns will then be used
39
45
  to generate the raster instead of the GeoDataFrame's geometry.
40
46
  If None, the GeoDataFrame's geometry is used to generate the raster instead.
@@ -44,8 +50,8 @@ def save_gdf_as_tiff(
44
50
  # we start by working out the desired axes of the output raster
45
51
  if xy_proj4 is not None:
46
52
  # use the 'x' and 'y' columns in the GeoDataFrame
47
- x_col = gdf['x']
48
- y_col = gdf['y']
53
+ x_col = gdf["x"]
54
+ y_col = gdf["y"]
49
55
  crs = xy_proj4
50
56
  else:
51
57
  # we use the geometry within the GeoDataFrame.
@@ -62,32 +68,32 @@ def save_gdf_as_tiff(
62
68
  # generate all of the points we want to end up with in the output raster
63
69
  # we need to offset both these axes by one in order to use np.searchsorted() in a manner that matches
64
70
  # how the EOLIS Gridded products were loaded into Specklia.
65
- desired_x_axis = np.arange(bounds['min_x'], bounds['max_x'], dx) + dx
66
- desired_y_axis = np.arange(bounds['min_y'], bounds['max_y'], dy) + dy
71
+ desired_x_axis = np.arange(bounds["min_x"], bounds["max_x"], dx) + dx
72
+ desired_y_axis = np.arange(bounds["min_y"], bounds["max_y"], dy) + dy
67
73
 
68
74
  # create the output raster, but fill it with NaN
69
75
  gridded_data = np.full((len(desired_y_axis), len(desired_x_axis)), np.nan)
70
76
 
71
77
  # set the valid points within it
72
- gridded_data[np.searchsorted(desired_y_axis, y_col),
73
- np.searchsorted(desired_x_axis, x_col)] = gdf[data_col]
78
+ gridded_data[np.searchsorted(desired_y_axis, y_col), np.searchsorted(desired_x_axis, x_col)] = gdf[data_col]
74
79
 
75
80
  # finally, save the raster to file.
76
81
  # There's a lot of wierdness here w.r.t axes orientation that we have to replicate
77
82
  # in order to maintain compatibility with the Timeseries Service.
78
83
  os.makedirs(os.path.dirname(output_path), exist_ok=True)
79
84
  with rasterio.open(
80
- output_path,
81
- 'w',
82
- driver='GTiff',
83
- height=gridded_data.shape[0],
84
- width=gridded_data.shape[1],
85
- count=1,
86
- dtype=data_type,
87
- crs=crs,
88
- transform=rasterio.transform.from_origin(bounds['min_x'], bounds['max_y'], dx, dy),
89
- compress='lzw',
90
- nodata=np.nan) as rst:
85
+ output_path,
86
+ "w",
87
+ driver="GTiff",
88
+ height=gridded_data.shape[0],
89
+ width=gridded_data.shape[1],
90
+ count=1,
91
+ dtype=data_type,
92
+ crs=crs,
93
+ transform=rasterio.transform.from_origin(bounds["min_x"], bounds["max_y"], dx, dy),
94
+ compress="lzw",
95
+ nodata=np.nan,
96
+ ) as rst:
91
97
  rst.write_band(1, np.flipud(gridded_data))
92
98
 
93
99
 
@@ -109,9 +115,9 @@ def deserialise_sources(sources: List[Dict]) -> List[Dict]:
109
115
  Sources after the coverage polygon, min_time and max_time have been deserialised.
110
116
  """
111
117
  for source in sources:
112
- source['geospatial_coverage'] = shape(source['geospatial_coverage'])
113
- source['min_time'] = datetime.fromisoformat(source['min_time'])
114
- source['max_time'] = datetime.fromisoformat(source['max_time'])
118
+ source["geospatial_coverage"] = shape(source["geospatial_coverage"])
119
+ source["min_time"] = datetime.fromisoformat(source["min_time"])
120
+ source["max_time"] = datetime.fromisoformat(source["max_time"])
115
121
 
116
122
  return sources
117
123
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.2
2
2
  Name: specklia
3
- Version: 1.9.65
3
+ Version: 1.9.67
4
4
  Summary: Python client for Specklia, a geospatial point cloud database by Earthwave.
5
5
  Home-page: https://specklia.earthwave.co.uk/
6
6
  Author: Earthwave Ltd
@@ -0,0 +1,9 @@
1
+ specklia/__init__.py,sha256=ePVHqq642NocoE8tS0cNTd0B5wJdUB7r3y815oQXD6A,51
2
+ specklia/chunked_transfer.py,sha256=pTm-x5Vwy9YtVTXcV7i0cYAo1LaSA_3qr1Of16R1u40,7732
3
+ specklia/client.py,sha256=6JYcjSpKtg_Lu2VnXAPwUuQuqUQF0ShvSuQU5Mk-p8c,42173
4
+ specklia/utilities.py,sha256=AjgDOM_UTDCY1QTb0yv83qXVuLSwi_CDKGs0vWen1oM,5087
5
+ specklia-1.9.67.dist-info/LICENCE,sha256=kjWTA-TtT_rJtsWuAgWvesvu01BytVXgt_uCbeQgjOg,1061
6
+ specklia-1.9.67.dist-info/METADATA,sha256=mdKthiyhdnD5rcKV0xioUjO5Fse9sfL2Sykjzn1Ay7Q,3082
7
+ specklia-1.9.67.dist-info/WHEEL,sha256=In9FTNxeP60KnTkGw7wk6mJPYd_dQSjEZmXdBdMCI-8,91
8
+ specklia-1.9.67.dist-info/top_level.txt,sha256=XgU53UpAJbqEni5EjJaPdQPYuNx16Geg2I5A9lo1BQw,9
9
+ specklia-1.9.67.dist-info/RECORD,,
@@ -1,9 +0,0 @@
1
- specklia/__init__.py,sha256=ePVHqq642NocoE8tS0cNTd0B5wJdUB7r3y815oQXD6A,51
2
- specklia/chunked_transfer.py,sha256=qjGJ976CyW8imYtFhba8-SxxgJzSMptw9TAMtWi8Q50,7818
3
- specklia/client.py,sha256=ujSkx62VIuOJ3FfTon7rAztXkzZCQl5J6QEmFwK8aP8,42183
4
- specklia/utilities.py,sha256=fs9DOSq-0hdgOlGAnPY_og5QngDcu3essVAupz6ychM,5170
5
- specklia-1.9.65.dist-info/LICENCE,sha256=kjWTA-TtT_rJtsWuAgWvesvu01BytVXgt_uCbeQgjOg,1061
6
- specklia-1.9.65.dist-info/METADATA,sha256=VCENT09A1fx7TEDQYg37Gf9vHQPFDu3QBgrUGe16760,3082
7
- specklia-1.9.65.dist-info/WHEEL,sha256=In9FTNxeP60KnTkGw7wk6mJPYd_dQSjEZmXdBdMCI-8,91
8
- specklia-1.9.65.dist-info/top_level.txt,sha256=XgU53UpAJbqEni5EjJaPdQPYuNx16Geg2I5A9lo1BQw,9
9
- specklia-1.9.65.dist-info/RECORD,,