specklia 1.8.218__tar.gz → 1.9.0__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.
- {specklia-1.8.218 → specklia-1.9.0}/PKG-INFO +13 -4
- {specklia-1.8.218 → specklia-1.9.0}/setup.cfg +1 -0
- {specklia-1.8.218 → specklia-1.9.0}/setup.py +0 -2
- specklia-1.9.0/specklia/chunked_transfer.py +214 -0
- {specklia-1.8.218 → specklia-1.9.0}/specklia/client.py +40 -42
- {specklia-1.8.218 → specklia-1.9.0}/specklia/utilities.py +28 -2
- {specklia-1.8.218 → specklia-1.9.0}/specklia.egg-info/PKG-INFO +13 -4
- {specklia-1.8.218 → specklia-1.9.0}/specklia.egg-info/SOURCES.txt +3 -3
- {specklia-1.8.218 → specklia-1.9.0}/specklia.egg-info/requires.txt +0 -2
- specklia-1.9.0/tests/test_chunked_transfer.py +51 -0
- {specklia-1.8.218 → specklia-1.9.0}/tests/test_client.py +57 -52
- specklia-1.8.218/specklia/_websocket_helpers.py +0 -401
- specklia-1.8.218/tests/test_websocket_helpers.py +0 -285
- {specklia-1.8.218 → specklia-1.9.0}/LICENCE +0 -0
- {specklia-1.8.218 → specklia-1.9.0}/README.md +0 -0
- {specklia-1.8.218 → specklia-1.9.0}/specklia/__init__.py +0 -0
- {specklia-1.8.218 → specklia-1.9.0}/specklia.egg-info/dependency_links.txt +0 -0
- {specklia-1.8.218 → specklia-1.9.0}/specklia.egg-info/top_level.txt +0 -0
- {specklia-1.8.218 → specklia-1.9.0}/tests/test_utilities.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
Metadata-Version: 2.
|
|
1
|
+
Metadata-Version: 2.2
|
|
2
2
|
Name: specklia
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.9.0
|
|
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
|
|
@@ -23,14 +23,23 @@ Requires-Python: >=3.11
|
|
|
23
23
|
Description-Content-Type: text/markdown
|
|
24
24
|
License-File: LICENCE
|
|
25
25
|
Requires-Dist: blosc
|
|
26
|
-
Requires-Dist: flask
|
|
27
26
|
Requires-Dist: geopandas
|
|
28
27
|
Requires-Dist: pandas
|
|
29
28
|
Requires-Dist: pyarrow
|
|
30
29
|
Requires-Dist: rasterio
|
|
31
30
|
Requires-Dist: requests
|
|
32
31
|
Requires-Dist: shapely
|
|
33
|
-
|
|
32
|
+
Dynamic: author
|
|
33
|
+
Dynamic: author-email
|
|
34
|
+
Dynamic: classifier
|
|
35
|
+
Dynamic: description
|
|
36
|
+
Dynamic: description-content-type
|
|
37
|
+
Dynamic: home-page
|
|
38
|
+
Dynamic: license
|
|
39
|
+
Dynamic: project-url
|
|
40
|
+
Dynamic: requires-dist
|
|
41
|
+
Dynamic: requires-python
|
|
42
|
+
Dynamic: summary
|
|
34
43
|
|
|
35
44
|
# Specklia
|
|
36
45
|
|
|
@@ -53,13 +53,11 @@ setup(
|
|
|
53
53
|
# requirements.txt should contain a specific known working version instead.
|
|
54
54
|
install_requires=[
|
|
55
55
|
'blosc',
|
|
56
|
-
'flask',
|
|
57
56
|
'geopandas',
|
|
58
57
|
'pandas',
|
|
59
58
|
'pyarrow',
|
|
60
59
|
'rasterio',
|
|
61
60
|
'requests',
|
|
62
61
|
'shapely',
|
|
63
|
-
'simple-websocket',
|
|
64
62
|
],
|
|
65
63
|
)
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Interface to Mongo for using Mongo as a buffer for chunked data transfer.
|
|
3
|
+
|
|
4
|
+
We use Mongo as a buffer because we cannot guarantee that all of the requests
|
|
5
|
+
for individual chunks will hit the same worker. While we could use streamed responses for the download,
|
|
6
|
+
they're not available for upload, so for simplicity we use the same approach in both directions.
|
|
7
|
+
|
|
8
|
+
The intended usage pattern is that a single message is stored as a single "chunk set".
|
|
9
|
+
The chunk set is first "filled" (either by the client or the server), then "emptied" to obtain the data
|
|
10
|
+
(again, by either the client or the server).
|
|
11
|
+
|
|
12
|
+
Note that while this can be used for pagination, it is not in itself pagination.
|
|
13
|
+
|
|
14
|
+
We plan to gather most of this material into ew_common after the chunked transfer interface has been rolled out
|
|
15
|
+
to its three main users (ew_geostore, ew_specklia, ew_online_processing_service) and proven effective for each.
|
|
16
|
+
At that point, this entire module will move into ew_common. Note that the chunked transfer interface will always
|
|
17
|
+
require MongoDB or a similar provision to work correctly.
|
|
18
|
+
|
|
19
|
+
IMPORTANT: THE VERSION HERE IN THE SPECKLIA PACKAGE MUST NOT BE MADE DEPENDENT UPON EW_COMMON SINCE EW_COMMON
|
|
20
|
+
IS PRIVATE BUT THIS PACKAGE IS PUBLIC!
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from enum import Enum
|
|
24
|
+
from http import HTTPStatus
|
|
25
|
+
from io import BytesIO
|
|
26
|
+
from logging import Logger
|
|
27
|
+
import struct
|
|
28
|
+
from typing import List, Tuple, Union
|
|
29
|
+
|
|
30
|
+
from geopandas import GeoDataFrame, read_feather as read_geofeather
|
|
31
|
+
from pandas import DataFrame, read_feather
|
|
32
|
+
import requests
|
|
33
|
+
|
|
34
|
+
CHUNK_DB_NAME = "data_transfer_chunks"
|
|
35
|
+
CHUNK_METADATA_COLLECTION_NAME = "chunk_metadata"
|
|
36
|
+
MAX_CHUNK_AGE_SECONDS = 3600
|
|
37
|
+
MAX_CHUNK_SIZE_BYTES = 5 * 1024 ** 2 # must be small enough to fit into an HTTP GET Request
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class ChunkSetStatus(Enum):
|
|
41
|
+
"""
|
|
42
|
+
Chunk set status.
|
|
43
|
+
|
|
44
|
+
Prevents the accidental access of chunk sets that have not yet received all of their data.
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
FILLING = 0
|
|
48
|
+
EMPTYING = 1
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def upload_chunks(api_address: str, chunks: List[Tuple[int, bytes]], logger: Logger) -> str:
|
|
52
|
+
"""
|
|
53
|
+
Upload data chunks.
|
|
54
|
+
|
|
55
|
+
Upload a series of data chunks through the chunked transfer mechanism.
|
|
56
|
+
This method is for use on the client, not the server.
|
|
57
|
+
|
|
58
|
+
Parameters
|
|
59
|
+
----------
|
|
60
|
+
api_address : str
|
|
61
|
+
The full URL of the API, including port but not including endpoint, e.g. "http://127.0.0.1:9999"
|
|
62
|
+
chunks : List[Tuple[int, bytes]]
|
|
63
|
+
A list of tuples containing the ordinal number of the chunk and each chunk
|
|
64
|
+
logger : Logger
|
|
65
|
+
A logger with which to log the upload.
|
|
66
|
+
|
|
67
|
+
Returns
|
|
68
|
+
-------
|
|
69
|
+
str
|
|
70
|
+
The chunk set uuid of the uploaded chunks
|
|
71
|
+
"""
|
|
72
|
+
# post the first chunk to start the upload
|
|
73
|
+
response = requests.post(
|
|
74
|
+
api_address + f"/chunk/upload/{chunks[0][0]}-of-{len(chunks)}",
|
|
75
|
+
data=chunks[0][1])
|
|
76
|
+
logger.info("response from very first /chunk/upload was '%s'", response.json())
|
|
77
|
+
assert response.status_code == HTTPStatus.OK, response.text
|
|
78
|
+
chunk_set_uuid = response.json()['chunk_set_uuid']
|
|
79
|
+
|
|
80
|
+
# post the rest of the chunks in a random order
|
|
81
|
+
for i, chunk in chunks[1:]:
|
|
82
|
+
response = requests.post(
|
|
83
|
+
api_address + f"/chunk/upload/{chunk_set_uuid}/{i}-of-{len(chunks)}", data=chunk)
|
|
84
|
+
logger.info("response from subsequent /chunk/upload/uuid call was '%s'", response.text)
|
|
85
|
+
assert response.status_code == HTTPStatus.OK, response.text
|
|
86
|
+
|
|
87
|
+
return chunk_set_uuid
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def download_chunks(api_address: str, chunk_set_uuid: str) -> List[Tuple[int, bytes]]:
|
|
91
|
+
"""
|
|
92
|
+
Download data chunks.
|
|
93
|
+
|
|
94
|
+
Download a series of data chunks through the chunked transfer mechanism.
|
|
95
|
+
This method is for use on the client, not the server.
|
|
96
|
+
|
|
97
|
+
Parameters
|
|
98
|
+
----------
|
|
99
|
+
api_address : str
|
|
100
|
+
The full URL of the API, including port but not including endpoint, e.g. "http://127.0.0.1:9999"
|
|
101
|
+
chunk_set_uuid : str
|
|
102
|
+
The uuid of the chunk set to download.
|
|
103
|
+
|
|
104
|
+
Returns
|
|
105
|
+
-------
|
|
106
|
+
chunks : List[Tuple[int, bytes]]
|
|
107
|
+
A list of tuples containing the ordinal number of the chunk and each chunk
|
|
108
|
+
"""
|
|
109
|
+
# fetch the data
|
|
110
|
+
data_chunks = []
|
|
111
|
+
finished = False
|
|
112
|
+
while not finished:
|
|
113
|
+
this_chunk_response = requests.get(api_address + f"/chunk/download/{chunk_set_uuid}")
|
|
114
|
+
if this_chunk_response.status_code == HTTPStatus.NO_CONTENT:
|
|
115
|
+
finished = True
|
|
116
|
+
else:
|
|
117
|
+
data_chunks.append((
|
|
118
|
+
struct.unpack('i', this_chunk_response.content[:4])[0],
|
|
119
|
+
this_chunk_response.content[4:]))
|
|
120
|
+
|
|
121
|
+
return data_chunks
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def split_into_chunks(data: bytes, chunk_size: int = MAX_CHUNK_SIZE_BYTES) -> List[Tuple[int, bytes]]:
|
|
125
|
+
"""
|
|
126
|
+
Split data into compressed chunks for transport.
|
|
127
|
+
|
|
128
|
+
Parameters
|
|
129
|
+
----------
|
|
130
|
+
data : bytes
|
|
131
|
+
The data to be split into chunks.
|
|
132
|
+
chunk_size: int
|
|
133
|
+
The maximum number of bytes allowed in each chunk.
|
|
134
|
+
|
|
135
|
+
Returns
|
|
136
|
+
-------
|
|
137
|
+
List[Tuple[int, bytes]]
|
|
138
|
+
A list of tuples containing the ordinal number of the chunk and each chunk
|
|
139
|
+
"""
|
|
140
|
+
return list(
|
|
141
|
+
enumerate((data[i:i + chunk_size] for i in range(0, len(data), chunk_size)), start=1))
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def merge_from_chunks(chunks: List[Tuple[int, bytes]]) -> bytes:
|
|
145
|
+
"""
|
|
146
|
+
Merge data that has been split into compressed chunks back into a single message.
|
|
147
|
+
|
|
148
|
+
Parameters
|
|
149
|
+
----------
|
|
150
|
+
chunks : List[Tuple[int, bytes]]
|
|
151
|
+
A list of tuples containing the ordinal number of the chunk and each chunk
|
|
152
|
+
|
|
153
|
+
Returns
|
|
154
|
+
-------
|
|
155
|
+
bytes
|
|
156
|
+
The merged data
|
|
157
|
+
"""
|
|
158
|
+
return b''.join([dc[1] for dc in sorted(chunks, key=lambda x: x[0])])
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def deserialise_dataframe(data: bytes) -> Union[DataFrame, GeoDataFrame]:
|
|
162
|
+
"""
|
|
163
|
+
Convert a binary serialised feather table to pandas dataframe.
|
|
164
|
+
|
|
165
|
+
Parameters
|
|
166
|
+
----------
|
|
167
|
+
data : bytes
|
|
168
|
+
Binary serialised feather table.
|
|
169
|
+
|
|
170
|
+
Returns
|
|
171
|
+
-------
|
|
172
|
+
Union[DataFrame, GeoDataFrame]
|
|
173
|
+
Input table converted to a pandas dataframe.
|
|
174
|
+
|
|
175
|
+
Raises
|
|
176
|
+
------
|
|
177
|
+
ValueError
|
|
178
|
+
When bytes can't be interpreted as meaningful dataframe.
|
|
179
|
+
"""
|
|
180
|
+
try:
|
|
181
|
+
buffer = BytesIO(data)
|
|
182
|
+
df = read_geofeather(buffer)
|
|
183
|
+
except ValueError as e:
|
|
184
|
+
# First attempt to deserialise as a geodataframe. If geo meta is missing, we expect a clear ValueError
|
|
185
|
+
# and we then load as a plain dataframe instead.
|
|
186
|
+
if "Missing geo meta" in e.args[0] or "'geo' metadata" in e.args[0]:
|
|
187
|
+
try:
|
|
188
|
+
df = read_feather(BytesIO(data))
|
|
189
|
+
except ValueError as e:
|
|
190
|
+
raise ValueError("Couldn't deserialise table format") from e
|
|
191
|
+
else:
|
|
192
|
+
raise ValueError("Couldn't deserialise table format") from e
|
|
193
|
+
return df
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def serialise_dataframe(df: Union[DataFrame, GeoDataFrame]) -> bytes:
|
|
197
|
+
"""
|
|
198
|
+
Serialise a dataframe using the feather table format.
|
|
199
|
+
|
|
200
|
+
Parameters
|
|
201
|
+
----------
|
|
202
|
+
df : DataFrame
|
|
203
|
+
Input dataframe
|
|
204
|
+
|
|
205
|
+
Returns
|
|
206
|
+
-------
|
|
207
|
+
bytes
|
|
208
|
+
Serialised feather table.
|
|
209
|
+
"""
|
|
210
|
+
feather_buffer = BytesIO()
|
|
211
|
+
# Browser implementations of feather do not support compressed feather formats.
|
|
212
|
+
df.to_feather(feather_buffer, compression='uncompressed')
|
|
213
|
+
feather_buffer.seek(0)
|
|
214
|
+
return feather_buffer.getvalue()
|
|
@@ -2,7 +2,6 @@
|
|
|
2
2
|
from __future__ import annotations
|
|
3
3
|
|
|
4
4
|
from datetime import datetime
|
|
5
|
-
from http import HTTPStatus
|
|
6
5
|
import json
|
|
7
6
|
import logging
|
|
8
7
|
from typing import Dict, List, Optional, Tuple, Union
|
|
@@ -14,9 +13,8 @@ import pandas as pd
|
|
|
14
13
|
import requests
|
|
15
14
|
from shapely import MultiPolygon, Polygon, to_geojson
|
|
16
15
|
from shapely.geometry import shape
|
|
17
|
-
import simple_websocket
|
|
18
16
|
|
|
19
|
-
from specklia import
|
|
17
|
+
from specklia import chunked_transfer, utilities
|
|
20
18
|
|
|
21
19
|
_log = logging.getLogger(__name__)
|
|
22
20
|
|
|
@@ -168,11 +166,6 @@ class Specklia:
|
|
|
168
166
|
source_information_only: bool
|
|
169
167
|
If True, no geodataframe is returned, only the set of unique sources. By default, False
|
|
170
168
|
|
|
171
|
-
Raises
|
|
172
|
-
------
|
|
173
|
-
RuntimeError
|
|
174
|
-
If the query failed for some reason.
|
|
175
|
-
|
|
176
169
|
Returns
|
|
177
170
|
-------
|
|
178
171
|
Tuple[gpd.GeoDataFrame, List[Dict]]
|
|
@@ -214,26 +207,39 @@ class Specklia:
|
|
|
214
207
|
"""
|
|
215
208
|
# note the use of json.loads() here, so effectively converting the geojson
|
|
216
209
|
# back into a dictionary of JSON-compatible types to avoid "double-JSONing" it.
|
|
217
|
-
|
|
218
|
-
self.server_url.replace("http://", "ws://") + "/query")
|
|
219
|
-
# Authorise the connection and then send the requestion dictionary.
|
|
220
|
-
ws.send(bytes(self.auth_token, encoding="utf-8"))
|
|
221
|
-
_websocket_helpers.send_object_to_websocket(ws, {
|
|
210
|
+
request = {
|
|
222
211
|
'dataset_id': dataset_id,
|
|
223
212
|
'min_timestamp': int(min_datetime.timestamp()),
|
|
224
213
|
'max_timestamp': int(max_datetime.timestamp()),
|
|
225
214
|
'epsg4326_search_area': json.loads(to_geojson(epsg4326_polygon)),
|
|
226
215
|
'columns_to_return': [] if columns_to_return is None else columns_to_return,
|
|
227
216
|
'additional_filters': [] if additional_filters is None else additional_filters,
|
|
228
|
-
'source_information_only': source_information_only}
|
|
217
|
+
'source_information_only': source_information_only}
|
|
218
|
+
|
|
219
|
+
# submit the query
|
|
220
|
+
response = requests.post(
|
|
221
|
+
self.server_url + '/query',
|
|
222
|
+
data=json.dumps(request),
|
|
223
|
+
headers={"Authorization": "Bearer " + self.auth_token})
|
|
224
|
+
_check_response_ok(response)
|
|
225
|
+
|
|
226
|
+
_log.info('queried dataset with ID %s.', dataset_id)
|
|
229
227
|
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
228
|
+
response_dict = response.json()
|
|
229
|
+
|
|
230
|
+
# stream and deserialise the results
|
|
231
|
+
if response_dict['num_chunks'] > 0:
|
|
232
|
+
gdf = chunked_transfer.deserialise_dataframe(
|
|
233
|
+
chunked_transfer.merge_from_chunks(
|
|
234
|
+
chunked_transfer.download_chunks(
|
|
235
|
+
self.server_url, response_dict['chunk_set_uuid'])))
|
|
234
236
|
else:
|
|
235
|
-
|
|
236
|
-
|
|
237
|
+
gdf = gpd.GeoDataFrame()
|
|
238
|
+
|
|
239
|
+
# perform some light deserialisation of sources for backwards compatibility.
|
|
240
|
+
sources = utilities.deserialise_sources(response_dict['sources'])
|
|
241
|
+
|
|
242
|
+
return gdf, sources
|
|
237
243
|
|
|
238
244
|
def update_points_in_dataset(
|
|
239
245
|
self: Specklia, _dataset_id: str, _new_points: pd.DataFrame, _source_description: Dict) -> None:
|
|
@@ -294,28 +300,20 @@ class Specklia:
|
|
|
294
300
|
The timestamp column must contain POSIX timestamps.
|
|
295
301
|
The 'geometry' column must contain Points following the (lon, lat) convention.
|
|
296
302
|
The GeoDataFrame must have its CRS specified as EPSG 4326.
|
|
297
|
-
|
|
298
|
-
Raises
|
|
299
|
-
------
|
|
300
|
-
RuntimeError
|
|
301
|
-
If the ingest failed for some reason.
|
|
302
303
|
"""
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
304
|
+
# serialise and upload each dataframe
|
|
305
|
+
for n in new_points:
|
|
306
|
+
n['chunk_set_uuid'] = chunked_transfer.upload_chunks(
|
|
307
|
+
self.server_url, chunked_transfer.split_into_chunks(
|
|
308
|
+
chunked_transfer.serialise_dataframe(n['gdf'])), _log)
|
|
309
|
+
del n['gdf']
|
|
306
310
|
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
'new_points': new_points})
|
|
311
|
+
response = requests.post(self.server_url + "/ingest",
|
|
312
|
+
json={'dataset_id': dataset_id, 'new_points': new_points},
|
|
313
|
+
headers={"Authorization": "Bearer " + self.auth_token})
|
|
314
|
+
_check_response_ok(response)
|
|
312
315
|
|
|
313
|
-
|
|
314
|
-
if response['status'] == HTTPStatus.OK:
|
|
315
|
-
_log.info('Added new data to specklia dataset ID %s.', dataset_id)
|
|
316
|
-
else:
|
|
317
|
-
_log.error('Failed to interact with Specklia server, error was %s', str(response))
|
|
318
|
-
raise RuntimeError(str(response))
|
|
316
|
+
_log.info('Added new data to specklia dataset ID %s.', dataset_id)
|
|
319
317
|
|
|
320
318
|
def delete_points_in_dataset(
|
|
321
319
|
self: Specklia, _dataset_id: str, _source_ids_and_source_row_ids_to_delete: List[Tuple[str, str]]) -> None:
|
|
@@ -453,7 +451,7 @@ class Specklia:
|
|
|
453
451
|
"""
|
|
454
452
|
response = requests.delete(
|
|
455
453
|
self.server_url + "/groups", headers={"Authorization": "Bearer " + self.auth_token},
|
|
456
|
-
|
|
454
|
+
params={'group_id': group_id})
|
|
457
455
|
_check_response_ok(response)
|
|
458
456
|
_log.info('deleted group ID %s', group_id)
|
|
459
457
|
return response.text.strip('\n"')
|
|
@@ -631,7 +629,7 @@ class Specklia:
|
|
|
631
629
|
"""
|
|
632
630
|
response = requests.delete(
|
|
633
631
|
self.server_url + "/groupmembership", headers={"Authorization": "Bearer " + self.auth_token},
|
|
634
|
-
|
|
632
|
+
params={'group_id': group_id, "user_to_delete_id": user_to_delete_id})
|
|
635
633
|
_check_response_ok(response)
|
|
636
634
|
_log.info('Deleted user ID %s from group ID %s.', user_to_delete_id, group_id)
|
|
637
635
|
return response.text.strip('\n"')
|
|
@@ -850,7 +848,7 @@ class Specklia:
|
|
|
850
848
|
"""
|
|
851
849
|
response = requests.delete(
|
|
852
850
|
self.server_url + "/metadata",
|
|
853
|
-
|
|
851
|
+
params={'dataset_id': dataset_id},
|
|
854
852
|
headers={"Authorization": "Bearer " + self.auth_token}
|
|
855
853
|
)
|
|
856
854
|
_check_response_ok(response)
|
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
"""This file contains client-side utilities provided to make it easier to use Specklia."""
|
|
2
|
-
|
|
2
|
+
from datetime import datetime
|
|
3
3
|
import os
|
|
4
|
-
from typing import Dict, Optional
|
|
4
|
+
from typing import Dict, List, Optional
|
|
5
5
|
|
|
6
6
|
import geopandas as gpd
|
|
7
7
|
import numpy as np
|
|
8
8
|
import rasterio
|
|
9
|
+
from shapely.geometry import shape
|
|
9
10
|
|
|
10
11
|
|
|
11
12
|
def save_gdf_as_tiff(
|
|
@@ -87,3 +88,28 @@ def save_gdf_as_tiff(
|
|
|
87
88
|
compress='lzw',
|
|
88
89
|
nodata=np.nan) as rst:
|
|
89
90
|
rst.write_band(1, np.flipud(gridded_data))
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def deserialise_sources(sources: List[Dict]) -> List[Dict]:
|
|
94
|
+
"""
|
|
95
|
+
Reverse some serialisation of sources returned from /query.
|
|
96
|
+
|
|
97
|
+
Reverses some serialisation of the sources dictionary returned from the /query endpoint for end-user convenience.
|
|
98
|
+
Convert the WKB coverage polygon into a Shapely geometry object, and the min and max times into datetimes.
|
|
99
|
+
|
|
100
|
+
Parameters
|
|
101
|
+
----------
|
|
102
|
+
sources: List[Dict]
|
|
103
|
+
A list of sources returned from Specklia
|
|
104
|
+
|
|
105
|
+
Returns
|
|
106
|
+
-------
|
|
107
|
+
List[Dict]
|
|
108
|
+
Sources after the coverage polygon, min_time and max_time have been deserialised.
|
|
109
|
+
"""
|
|
110
|
+
for source in sources:
|
|
111
|
+
source['geospatial_coverage'] = shape(source['geospatial_coverage'])
|
|
112
|
+
source['min_time'] = datetime.fromisoformat(source['min_time'])
|
|
113
|
+
source['max_time'] = datetime.fromisoformat(source['max_time'])
|
|
114
|
+
|
|
115
|
+
return sources
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
Metadata-Version: 2.
|
|
1
|
+
Metadata-Version: 2.2
|
|
2
2
|
Name: specklia
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.9.0
|
|
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
|
|
@@ -23,14 +23,23 @@ Requires-Python: >=3.11
|
|
|
23
23
|
Description-Content-Type: text/markdown
|
|
24
24
|
License-File: LICENCE
|
|
25
25
|
Requires-Dist: blosc
|
|
26
|
-
Requires-Dist: flask
|
|
27
26
|
Requires-Dist: geopandas
|
|
28
27
|
Requires-Dist: pandas
|
|
29
28
|
Requires-Dist: pyarrow
|
|
30
29
|
Requires-Dist: rasterio
|
|
31
30
|
Requires-Dist: requests
|
|
32
31
|
Requires-Dist: shapely
|
|
33
|
-
|
|
32
|
+
Dynamic: author
|
|
33
|
+
Dynamic: author-email
|
|
34
|
+
Dynamic: classifier
|
|
35
|
+
Dynamic: description
|
|
36
|
+
Dynamic: description-content-type
|
|
37
|
+
Dynamic: home-page
|
|
38
|
+
Dynamic: license
|
|
39
|
+
Dynamic: project-url
|
|
40
|
+
Dynamic: requires-dist
|
|
41
|
+
Dynamic: requires-python
|
|
42
|
+
Dynamic: summary
|
|
34
43
|
|
|
35
44
|
# Specklia
|
|
36
45
|
|
|
@@ -3,7 +3,7 @@ README.md
|
|
|
3
3
|
setup.cfg
|
|
4
4
|
setup.py
|
|
5
5
|
specklia/__init__.py
|
|
6
|
-
specklia/
|
|
6
|
+
specklia/chunked_transfer.py
|
|
7
7
|
specklia/client.py
|
|
8
8
|
specklia/utilities.py
|
|
9
9
|
specklia.egg-info/PKG-INFO
|
|
@@ -11,6 +11,6 @@ specklia.egg-info/SOURCES.txt
|
|
|
11
11
|
specklia.egg-info/dependency_links.txt
|
|
12
12
|
specklia.egg-info/requires.txt
|
|
13
13
|
specklia.egg-info/top_level.txt
|
|
14
|
+
tests/test_chunked_transfer.py
|
|
14
15
|
tests/test_client.py
|
|
15
|
-
tests/test_utilities.py
|
|
16
|
-
tests/test_websocket_helpers.py
|
|
16
|
+
tests/test_utilities.py
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""Unit tests for chunked_transfer.py."""
|
|
2
|
+
from http import HTTPStatus
|
|
3
|
+
import struct
|
|
4
|
+
from unittest.mock import call, MagicMock, patch
|
|
5
|
+
|
|
6
|
+
import pandas as pd
|
|
7
|
+
|
|
8
|
+
from specklia import chunked_transfer
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def test_split_into_chunks():
|
|
12
|
+
assert chunked_transfer.split_into_chunks(b'abcdefghijklmnop', chunk_size=5) == [
|
|
13
|
+
(1, b'abcde'), (2, b'fghij'), (3, b'klmno'), (4, b'p')]
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def test_merge_from_chunks():
|
|
17
|
+
assert chunked_transfer.merge_from_chunks([
|
|
18
|
+
(1, b'abcde'), (2, b'fghij'), (3, b'klmno'), (4, b'p')]) == b'abcdefghijklmnop'
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def test_upload_chunks():
|
|
22
|
+
with patch('specklia.chunked_transfer.requests.post') as mock_post:
|
|
23
|
+
mock_post.return_value.status_code = HTTPStatus.OK
|
|
24
|
+
mock_post.return_value.json.return_value = {'chunk_set_uuid': 'cheese'}
|
|
25
|
+
|
|
26
|
+
assert chunked_transfer.upload_chunks(
|
|
27
|
+
api_address='wibble', chunks=[(1, b'a'), (2, b'b')], logger=MagicMock(name="mock_logger")) == 'cheese'
|
|
28
|
+
|
|
29
|
+
mock_post.assert_has_calls([
|
|
30
|
+
call('wibble/chunk/upload/1-of-2', data=b'a'),
|
|
31
|
+
call().json(),
|
|
32
|
+
call().json(),
|
|
33
|
+
call('wibble/chunk/upload/cheese/2-of-2', data=b'b')])
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def test_download_chunks():
|
|
37
|
+
with patch('specklia.chunked_transfer.requests.get') as mock_get:
|
|
38
|
+
mock_get.side_effect = [
|
|
39
|
+
MagicMock(name="mock_response_1", status_code=HTTPStatus.OK, content=struct.pack('i', 1) + b'wibble'),
|
|
40
|
+
MagicMock(name="mock_response_2", status_code=HTTPStatus.OK, content=struct.pack('i', 2) + b'wobble'),
|
|
41
|
+
MagicMock(name="mock_response_3", status_code=HTTPStatus.NO_CONTENT, content=b'')]
|
|
42
|
+
|
|
43
|
+
assert chunked_transfer.download_chunks(api_address='wibble', chunk_set_uuid='rawr') == [
|
|
44
|
+
(1, b'wibble'), (2, b'wobble')]
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def test_serialise_dataframe_roundtrip():
|
|
48
|
+
df = pd.DataFrame({'a': [1, 1, 2, 3], 'b': ['alfred', 'dave', 'ken', 'sally'], 'c': [1, 2, 4, 4.4]})
|
|
49
|
+
|
|
50
|
+
pd.testing.assert_frame_equal(
|
|
51
|
+
df, chunked_transfer.deserialise_dataframe(chunked_transfer.serialise_dataframe(df)))
|