specklia 1.9.66__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.
- specklia/chunked_transfer.py +21 -28
- specklia/client.py +178 -150
- specklia/utilities.py +33 -27
- {specklia-1.9.66.dist-info → specklia-1.9.67.dist-info}/METADATA +1 -1
- specklia-1.9.67.dist-info/RECORD +9 -0
- specklia-1.9.66.dist-info/RECORD +0 -9
- {specklia-1.9.66.dist-info → specklia-1.9.67.dist-info}/LICENCE +0 -0
- {specklia-1.9.66.dist-info → specklia-1.9.67.dist-info}/WHEEL +0 -0
- {specklia-1.9.66.dist-info → specklia-1.9.67.dist-info}/top_level.txt +0 -0
specklia/chunked_transfer.py
CHANGED
|
@@ -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
|
|
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.
|
|
82
|
-
chunk_set_uuid = response.json()[
|
|
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.
|
|
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(
|
|
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
|
|
142
|
+
requests.delete(f"{api_address}/chunk/delete/{chunk_set_uuid}")
|
|
149
143
|
|
|
150
|
-
return b
|
|
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
|
|
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=
|
|
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
|
-
"""
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
41
|
-
|
|
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
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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
|
-
|
|
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
|
-
|
|
57
|
-
instantiate our Specklia client.
|
|
55
|
+
If we save our key to a file, we can then utilise it as such::
|
|
58
56
|
|
|
59
|
-
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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={
|
|
120
|
+
params={"group_id": group_id},
|
|
121
|
+
)
|
|
125
122
|
_check_response_ok(response)
|
|
126
|
-
_log.info(
|
|
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(
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
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
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
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 +
|
|
223
|
-
|
|
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(
|
|
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[
|
|
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[
|
|
237
|
-
response_dict[
|
|
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[
|
|
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
|
-
|
|
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(
|
|
275
|
+
_log.error("this method is not yet implemented.")
|
|
274
276
|
raise NotImplementedError()
|
|
275
277
|
|
|
276
278
|
def add_points_to_dataset(
|
|
277
|
-
|
|
278
|
-
|
|
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[
|
|
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
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
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
|
-
|
|
333
|
-
|
|
334
|
-
|
|
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(
|
|
345
|
+
_log.info("Added new data to specklia dataset ID %s.", dataset_id)
|
|
341
346
|
|
|
342
347
|
def delete_points_in_dataset(
|
|
343
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
412
|
-
|
|
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(
|
|
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={
|
|
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(
|
|
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",
|
|
478
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
552
|
-
|
|
553
|
-
|
|
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(
|
|
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={
|
|
613
|
-
|
|
614
|
-
|
|
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(
|
|
618
|
-
|
|
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",
|
|
656
|
-
|
|
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(
|
|
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(
|
|
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
|
|
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
|
-
|
|
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),
|
|
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
|
-
|
|
692
|
-
|
|
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(
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
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={
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
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={
|
|
840
|
-
|
|
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(
|
|
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={
|
|
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(
|
|
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={
|
|
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(
|
|
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 =
|
|
950
|
-
|
|
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(
|
|
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: {
|
|
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
|
-
"""
|
|
2
|
-
|
|
1
|
+
"""File contains client-side utilities provided to make it easier to use Specklia."""
|
|
2
|
+
|
|
3
3
|
import os
|
|
4
|
-
from
|
|
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
|
-
|
|
15
|
-
|
|
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 :
|
|
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[
|
|
48
|
-
y_col = gdf[
|
|
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[
|
|
66
|
-
desired_y_axis = np.arange(bounds[
|
|
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
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
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[
|
|
113
|
-
source[
|
|
114
|
-
source[
|
|
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
|
|
|
@@ -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,,
|
specklia-1.9.66.dist-info/RECORD
DELETED
|
@@ -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.66.dist-info/LICENCE,sha256=kjWTA-TtT_rJtsWuAgWvesvu01BytVXgt_uCbeQgjOg,1061
|
|
6
|
-
specklia-1.9.66.dist-info/METADATA,sha256=v4nTCXD-qRvQW6vHQbONXCiDBMRFeK2nPVP56flek6Y,3082
|
|
7
|
-
specklia-1.9.66.dist-info/WHEEL,sha256=In9FTNxeP60KnTkGw7wk6mJPYd_dQSjEZmXdBdMCI-8,91
|
|
8
|
-
specklia-1.9.66.dist-info/top_level.txt,sha256=XgU53UpAJbqEni5EjJaPdQPYuNx16Geg2I5A9lo1BQw,9
|
|
9
|
-
specklia-1.9.66.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|