specklia 1.9.100__py3-none-any.whl → 1.9.105__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/client.py +65 -88
- specklia/internal_admin_client.py +74 -0
- {specklia-1.9.100.dist-info → specklia-1.9.105.dist-info}/METADATA +1 -1
- specklia-1.9.105.dist-info/RECORD +10 -0
- specklia-1.9.100.dist-info/RECORD +0 -9
- {specklia-1.9.100.dist-info → specklia-1.9.105.dist-info}/LICENCE +0 -0
- {specklia-1.9.100.dist-info → specklia-1.9.105.dist-info}/WHEEL +0 -0
- {specklia-1.9.100.dist-info → specklia-1.9.105.dist-info}/top_level.txt +0 -0
specklia/client.py
CHANGED
|
@@ -15,6 +15,7 @@ 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.internal_admin_client import SpeckliaInternalAdminClient
|
|
18
19
|
|
|
19
20
|
if TYPE_CHECKING:
|
|
20
21
|
from datetime import datetime
|
|
@@ -44,6 +45,13 @@ class Specklia:
|
|
|
44
45
|
url : str
|
|
45
46
|
The url where Specklia is running, by default the URL of the Specklia server.
|
|
46
47
|
|
|
48
|
+
Attributes
|
|
49
|
+
----------
|
|
50
|
+
user_id : str
|
|
51
|
+
The unique ID of the user associated with this client.
|
|
52
|
+
internal_admin : SpeckliaInternalAdminClient
|
|
53
|
+
Contains endpoints only accessible to internal Specklia administrators.
|
|
54
|
+
|
|
47
55
|
Examples
|
|
48
56
|
--------
|
|
49
57
|
To start using Specklia, we first need to navigate to https://specklia.earthwave.co.uk and follow the
|
|
@@ -59,23 +67,48 @@ class Specklia:
|
|
|
59
67
|
>>> client = Specklia(auth_token=user_auth_token)
|
|
60
68
|
"""
|
|
61
69
|
|
|
70
|
+
user_id: str
|
|
71
|
+
internal_admin: SpeckliaInternalAdminClient
|
|
72
|
+
|
|
62
73
|
def __init__(self: Specklia, auth_token: str, url: str = "https://specklia-api.earthwave.co.uk") -> None:
|
|
63
74
|
self.server_url = url
|
|
64
75
|
self.auth_token = auth_token
|
|
65
76
|
self._data_streaming_timeout_s = 300
|
|
77
|
+
|
|
78
|
+
# Internal admin routes are accessed through a separate object to keep the distinction clear.
|
|
79
|
+
self.internal_admin = SpeckliaInternalAdminClient(self._request)
|
|
80
|
+
|
|
66
81
|
# immediately retrieve the user's ID. This serves as a check that their API token is valid.
|
|
67
82
|
self._fetch_user_id()
|
|
68
83
|
|
|
69
84
|
_log.info("New Specklia client created.")
|
|
70
85
|
|
|
86
|
+
def _request(
|
|
87
|
+
self: Specklia,
|
|
88
|
+
method: Literal["GET", "POST", "PUT", "DELETE"],
|
|
89
|
+
endpoint: str,
|
|
90
|
+
params: dict | None = None,
|
|
91
|
+
json: dict | None = None,
|
|
92
|
+
data: str | None = None,
|
|
93
|
+
) -> requests.Response:
|
|
94
|
+
response = requests.request(
|
|
95
|
+
method,
|
|
96
|
+
self.server_url + "/" + endpoint,
|
|
97
|
+
headers={"Authorization": "Bearer " + self.auth_token},
|
|
98
|
+
params=params,
|
|
99
|
+
json=json,
|
|
100
|
+
data=data,
|
|
101
|
+
)
|
|
102
|
+
_check_response_ok(response)
|
|
103
|
+
return response
|
|
104
|
+
|
|
71
105
|
def _fetch_user_id(self: Specklia) -> None:
|
|
72
106
|
"""
|
|
73
107
|
Set the client's User ID.
|
|
74
108
|
|
|
75
109
|
We've separated this out for testing reasons.
|
|
76
110
|
"""
|
|
77
|
-
response =
|
|
78
|
-
_check_response_ok(response)
|
|
111
|
+
response = self._request("POST", "users")
|
|
79
112
|
self.user_id = response.json()
|
|
80
113
|
_log.info("fetched User ID for client, was %s", self.user_id)
|
|
81
114
|
|
|
@@ -114,12 +147,7 @@ class Specklia:
|
|
|
114
147
|
client.delete_user_from_group(), client.add_user_to_group(), and client.update_user_privileges to make any
|
|
115
148
|
desired changes.
|
|
116
149
|
"""
|
|
117
|
-
response =
|
|
118
|
-
self.server_url + "/users",
|
|
119
|
-
headers={"Authorization": "Bearer " + self.auth_token},
|
|
120
|
-
params={"group_id": group_id},
|
|
121
|
-
)
|
|
122
|
-
_check_response_ok(response)
|
|
150
|
+
response = self._request("GET", "users", params={"group_id": group_id})
|
|
123
151
|
_log.info("listed users within group_id %s.", group_id)
|
|
124
152
|
return pd.DataFrame(response.json()).convert_dtypes()
|
|
125
153
|
|
|
@@ -220,10 +248,7 @@ class Specklia:
|
|
|
220
248
|
}
|
|
221
249
|
|
|
222
250
|
# submit the query
|
|
223
|
-
response =
|
|
224
|
-
self.server_url + "/query", data=json.dumps(request), headers={"Authorization": "Bearer " + self.auth_token}
|
|
225
|
-
)
|
|
226
|
-
_check_response_ok(response)
|
|
251
|
+
response = self._request("POST", "query", data=json.dumps(request))
|
|
227
252
|
|
|
228
253
|
_log.info("queried dataset with ID %s.", dataset_id)
|
|
229
254
|
|
|
@@ -290,9 +315,8 @@ class Specklia:
|
|
|
290
315
|
|
|
291
316
|
You must have READ_WRITE or ADMIN permissions within the group that owns the dataset in order to do this.
|
|
292
317
|
|
|
293
|
-
Note that Ingests are temporarily restricted to
|
|
294
|
-
|
|
295
|
-
This restriction will be lifted once we have per-user billing in place for Specklia.
|
|
318
|
+
Note that Ingests are temporarily restricted to internal Specklia administrators (i.e. Specklia is read-only to
|
|
319
|
+
the general public). This restriction will be lifted once we have per-user billing in place for Specklia.
|
|
296
320
|
|
|
297
321
|
Note that this can only be called up to 30,000 times per day for OLAP datasets - if you need to load more
|
|
298
322
|
individual data files than this, ensure that you use this method on groups of files
|
|
@@ -331,17 +355,15 @@ class Specklia:
|
|
|
331
355
|
)
|
|
332
356
|
del n
|
|
333
357
|
|
|
334
|
-
|
|
335
|
-
|
|
358
|
+
self._request(
|
|
359
|
+
"POST",
|
|
360
|
+
"ingest",
|
|
336
361
|
json={
|
|
337
362
|
"dataset_id": dataset_id,
|
|
338
363
|
"new_points": upload_points,
|
|
339
364
|
"duplicate_source_behaviour": duplicate_source_behaviour,
|
|
340
365
|
},
|
|
341
|
-
headers={"Authorization": "Bearer " + self.auth_token},
|
|
342
366
|
)
|
|
343
|
-
_check_response_ok(response)
|
|
344
|
-
|
|
345
367
|
_log.info("Added new data to specklia dataset ID %s.", dataset_id)
|
|
346
368
|
|
|
347
369
|
def delete_points_in_dataset(
|
|
@@ -369,22 +391,6 @@ class Specklia:
|
|
|
369
391
|
_log.error("this method is not yet implemented.")
|
|
370
392
|
raise NotImplementedError()
|
|
371
393
|
|
|
372
|
-
def list_all_groups(self: Specklia) -> pd.DataFrame:
|
|
373
|
-
"""
|
|
374
|
-
List all groups.
|
|
375
|
-
|
|
376
|
-
You must have ADMIN permissions within the special all_users group in order to do this.
|
|
377
|
-
|
|
378
|
-
Returns
|
|
379
|
-
-------
|
|
380
|
-
pd.DataFrame
|
|
381
|
-
A dataframe describing all groups
|
|
382
|
-
"""
|
|
383
|
-
response = requests.get(self.server_url + "/groups", headers={"Authorization": "Bearer " + self.auth_token})
|
|
384
|
-
_check_response_ok(response)
|
|
385
|
-
_log.info("listing all groups within Specklia.")
|
|
386
|
-
return pd.DataFrame(response.json()).convert_dtypes()
|
|
387
|
-
|
|
388
394
|
def create_group(self: Specklia, group_name: str) -> str:
|
|
389
395
|
"""
|
|
390
396
|
Create a new Specklia group.
|
|
@@ -413,12 +419,7 @@ class Specklia:
|
|
|
413
419
|
The endpoint will return the new group's unique ID, auto-generated by Specklia. We can pass this ID to other
|
|
414
420
|
Specklia endpoints to modify the group, its members, and datasets.
|
|
415
421
|
"""
|
|
416
|
-
response =
|
|
417
|
-
self.server_url + "/groups",
|
|
418
|
-
json={"group_name": group_name},
|
|
419
|
-
headers={"Authorization": "Bearer " + self.auth_token},
|
|
420
|
-
)
|
|
421
|
-
_check_response_ok(response)
|
|
422
|
+
response = self._request("POST", "groups", json={"group_name": group_name})
|
|
422
423
|
_log.info("created new group with name %s.", group_name)
|
|
423
424
|
return response.text.strip('\n"')
|
|
424
425
|
|
|
@@ -448,12 +449,11 @@ class Specklia:
|
|
|
448
449
|
|
|
449
450
|
The group's unique ID, users, and datasets will remain unchanged.
|
|
450
451
|
"""
|
|
451
|
-
response =
|
|
452
|
-
|
|
452
|
+
response = self._request(
|
|
453
|
+
"PUT",
|
|
454
|
+
"groups",
|
|
453
455
|
json={"group_id": group_id, "new_group_name": new_group_name},
|
|
454
|
-
headers={"Authorization": "Bearer " + self.auth_token},
|
|
455
456
|
)
|
|
456
|
-
_check_response_ok(response)
|
|
457
457
|
_log.info("updated name of group ID %s to %s.", group_id, new_group_name)
|
|
458
458
|
return response.text.strip('\n"')
|
|
459
459
|
|
|
@@ -482,12 +482,7 @@ class Specklia:
|
|
|
482
482
|
The above will additionally delete any datasets owned by the group at the time of the deletion. Users within the
|
|
483
483
|
group will be removed from it, but left unchanged otherwise.
|
|
484
484
|
"""
|
|
485
|
-
response =
|
|
486
|
-
self.server_url + "/groups",
|
|
487
|
-
headers={"Authorization": "Bearer " + self.auth_token},
|
|
488
|
-
params={"group_id": group_id},
|
|
489
|
-
)
|
|
490
|
-
_check_response_ok(response)
|
|
485
|
+
response = self._request("DELETE", "groups", params={"group_id": group_id})
|
|
491
486
|
_log.info("deleted group ID %s", group_id)
|
|
492
487
|
return response.text.strip('\n"')
|
|
493
488
|
|
|
@@ -517,10 +512,7 @@ class Specklia:
|
|
|
517
512
|
|
|
518
513
|
We can now pass this ID to other Specklia endpoints to modify the group, its members, and datasets.
|
|
519
514
|
"""
|
|
520
|
-
response =
|
|
521
|
-
self.server_url + "/groupmembership", headers={"Authorization": "Bearer " + self.auth_token}
|
|
522
|
-
)
|
|
523
|
-
_check_response_ok(response)
|
|
515
|
+
response = self._request("GET", "groupmembership")
|
|
524
516
|
_log.info("listed groups that user is part of.")
|
|
525
517
|
return pd.DataFrame(response.json()).convert_dtypes()
|
|
526
518
|
|
|
@@ -560,12 +552,11 @@ class Specklia:
|
|
|
560
552
|
able to write to the group's datasets or manage users within the group, we can update their privileges via
|
|
561
553
|
client.update_user_privileges().
|
|
562
554
|
"""
|
|
563
|
-
response =
|
|
564
|
-
|
|
555
|
+
response = self._request(
|
|
556
|
+
"POST",
|
|
557
|
+
"groupmembership",
|
|
565
558
|
json={"group_id": group_id, "user_to_add_id": user_to_add_id},
|
|
566
|
-
headers={"Authorization": "Bearer " + self.auth_token},
|
|
567
559
|
)
|
|
568
|
-
_check_response_ok(response)
|
|
569
560
|
_log.info("added user ID %s to group ID %s", user_to_add_id, group_id)
|
|
570
561
|
return response.text.strip('\n"')
|
|
571
562
|
|
|
@@ -621,12 +612,11 @@ class Specklia:
|
|
|
621
612
|
|
|
622
613
|
We should always aim to grant users the lowest privileges necessary.
|
|
623
614
|
"""
|
|
624
|
-
response =
|
|
625
|
-
|
|
615
|
+
response = self._request(
|
|
616
|
+
"PUT",
|
|
617
|
+
"groupmembership",
|
|
626
618
|
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
619
|
)
|
|
629
|
-
_check_response_ok(response)
|
|
630
620
|
_log.info(
|
|
631
621
|
"Updated user ID %s privileges to %s within group ID %s.", user_to_update_id, new_privileges, group_id
|
|
632
622
|
)
|
|
@@ -665,12 +655,11 @@ class Specklia:
|
|
|
665
655
|
>>> client.delete_user_group_group(group_id=DETERMINED_GROUP_ID, user_to_delete_id=DETERMINED_USER_ID)
|
|
666
656
|
|
|
667
657
|
"""
|
|
668
|
-
response =
|
|
669
|
-
|
|
670
|
-
|
|
658
|
+
response = self._request(
|
|
659
|
+
"DELETE",
|
|
660
|
+
"groupmembership",
|
|
671
661
|
params={"group_id": group_id, "user_to_delete_id": user_to_delete_id},
|
|
672
662
|
)
|
|
673
|
-
_check_response_ok(response)
|
|
674
663
|
_log.info("Deleted user ID %s from group ID %s.", user_to_delete_id, group_id)
|
|
675
664
|
return response.text.strip('\n"')
|
|
676
665
|
|
|
@@ -805,17 +794,16 @@ class Specklia:
|
|
|
805
794
|
_log.warning(message)
|
|
806
795
|
warnings.warn(message, stacklevel=1)
|
|
807
796
|
|
|
808
|
-
response =
|
|
809
|
-
|
|
797
|
+
response = self._request(
|
|
798
|
+
"POST",
|
|
799
|
+
"metadata",
|
|
810
800
|
json={
|
|
811
801
|
"dataset_name": dataset_name,
|
|
812
802
|
"description": description,
|
|
813
803
|
"columns": columns,
|
|
814
804
|
"storage_technology": storage_technology,
|
|
815
805
|
},
|
|
816
|
-
headers={"Authorization": "Bearer " + self.auth_token},
|
|
817
806
|
)
|
|
818
|
-
_check_response_ok(response)
|
|
819
807
|
_log.info("Created a new dataset with name '%s'", dataset_name)
|
|
820
808
|
return response.text.strip('\n"')
|
|
821
809
|
|
|
@@ -860,12 +848,11 @@ class Specklia:
|
|
|
860
848
|
... group_id=important_group_id)
|
|
861
849
|
|
|
862
850
|
"""
|
|
863
|
-
response =
|
|
864
|
-
|
|
851
|
+
response = self._request(
|
|
852
|
+
"PUT",
|
|
853
|
+
"metadata",
|
|
865
854
|
json={"dataset_id": dataset_id, "new_owning_group_id": new_owning_group_id},
|
|
866
|
-
headers={"Authorization": "Bearer " + self.auth_token},
|
|
867
855
|
)
|
|
868
|
-
_check_response_ok(response)
|
|
869
856
|
_log.info("set owning group for dataset ID %s to group ID %s", dataset_id, new_owning_group_id)
|
|
870
857
|
return response.text.strip('\n"')
|
|
871
858
|
|
|
@@ -895,12 +882,7 @@ class Specklia:
|
|
|
895
882
|
Specklia will respond with a success message as long as the dataset exists and we are an ADMIN within the
|
|
896
883
|
group that owns it.
|
|
897
884
|
"""
|
|
898
|
-
response =
|
|
899
|
-
self.server_url + "/metadata",
|
|
900
|
-
params={"dataset_id": dataset_id},
|
|
901
|
-
headers={"Authorization": "Bearer " + self.auth_token},
|
|
902
|
-
)
|
|
903
|
-
_check_response_ok(response)
|
|
885
|
+
response = self._request("DELETE", "metadata", params={"dataset_id": dataset_id})
|
|
904
886
|
_log.info("Deleted dataset with ID %s", dataset_id)
|
|
905
887
|
return response.text.strip('\n"')
|
|
906
888
|
|
|
@@ -939,12 +921,7 @@ class Specklia:
|
|
|
939
921
|
Example usage:
|
|
940
922
|
>>> client.report_usage(dataset_id="GROUP_IP")
|
|
941
923
|
"""
|
|
942
|
-
response =
|
|
943
|
-
self.server_url + "/usage",
|
|
944
|
-
params={"group_id": group_id},
|
|
945
|
-
headers={"Authorization": "Bearer " + self.auth_token},
|
|
946
|
-
)
|
|
947
|
-
_check_response_ok(response)
|
|
924
|
+
response = self._request("GET", "usage", params={"group_id": group_id})
|
|
948
925
|
_log.info("Usage report queried for group_id %s", group_id)
|
|
949
926
|
return response.json()
|
|
950
927
|
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"""Specklia internal admin client module."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
from typing import Callable, Literal, Self
|
|
5
|
+
|
|
6
|
+
import pandas as pd
|
|
7
|
+
import requests
|
|
8
|
+
|
|
9
|
+
_log = logging.getLogger(__name__)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class SpeckliaInternalAdminClient:
|
|
13
|
+
"""Specklia client for endpoints only accessible to internal Specklia administrators.
|
|
14
|
+
|
|
15
|
+
Parameters
|
|
16
|
+
----------
|
|
17
|
+
specklia_request : Callable
|
|
18
|
+
The function to use to make requests to Specklia internal admin endpoints.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
def __init__(
|
|
22
|
+
self: Self,
|
|
23
|
+
specklia_request: Callable,
|
|
24
|
+
) -> None:
|
|
25
|
+
self._specklia_request = specklia_request
|
|
26
|
+
|
|
27
|
+
def _request(
|
|
28
|
+
self: Self,
|
|
29
|
+
method: Literal["GET", "POST", "PUT", "DELETE"],
|
|
30
|
+
endpoint: str,
|
|
31
|
+
params: dict | None = None,
|
|
32
|
+
json: dict | None = None,
|
|
33
|
+
data: str | None = None,
|
|
34
|
+
) -> requests.Response:
|
|
35
|
+
return self._specklia_request(
|
|
36
|
+
method=method,
|
|
37
|
+
endpoint=endpoint,
|
|
38
|
+
params=params,
|
|
39
|
+
json=json,
|
|
40
|
+
data=data,
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
def list_all_groups(self: Self) -> pd.DataFrame:
|
|
44
|
+
"""
|
|
45
|
+
List all groups.
|
|
46
|
+
|
|
47
|
+
Returns
|
|
48
|
+
-------
|
|
49
|
+
pd.DataFrame
|
|
50
|
+
A dataframe describing all groups
|
|
51
|
+
"""
|
|
52
|
+
response = self._request("GET", "groups")
|
|
53
|
+
_log.info("listing all groups within Specklia.")
|
|
54
|
+
return pd.DataFrame(response.json()).convert_dtypes()
|
|
55
|
+
|
|
56
|
+
def generate_user_api_key(self: Self, user_id: str) -> dict[str, str]:
|
|
57
|
+
"""
|
|
58
|
+
Generate an API key for a user, creating the user if they do not already exist.
|
|
59
|
+
|
|
60
|
+
This will create the user if they do not already exist, and will replace any existing API key if present.
|
|
61
|
+
|
|
62
|
+
Parameters
|
|
63
|
+
----------
|
|
64
|
+
user_id : str
|
|
65
|
+
The ID of the user to generate an API key for.
|
|
66
|
+
|
|
67
|
+
Returns
|
|
68
|
+
-------
|
|
69
|
+
dict[str, str]
|
|
70
|
+
A dictionary containing the `user_id` and the generated `token`.
|
|
71
|
+
"""
|
|
72
|
+
response = self._request("PUT", "generate_user_api_key/" + user_id)
|
|
73
|
+
_log.info("Generated API key for user ID %s.", user_id)
|
|
74
|
+
return response.json()
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
specklia/__init__.py,sha256=ePVHqq642NocoE8tS0cNTd0B5wJdUB7r3y815oQXD6A,51
|
|
2
|
+
specklia/chunked_transfer.py,sha256=pTm-x5Vwy9YtVTXcV7i0cYAo1LaSA_3qr1Of16R1u40,7732
|
|
3
|
+
specklia/client.py,sha256=oHL0-MbKoj9qMBKZOii_xEkby-RPPSjoZkPK6peNk3Q,40828
|
|
4
|
+
specklia/internal_admin_client.py,sha256=w3OyXjlWPivgd5lOmnISc6B6rjLbkGoRtmd2kO7lelA,2127
|
|
5
|
+
specklia/utilities.py,sha256=AjgDOM_UTDCY1QTb0yv83qXVuLSwi_CDKGs0vWen1oM,5087
|
|
6
|
+
specklia-1.9.105.dist-info/LICENCE,sha256=kjWTA-TtT_rJtsWuAgWvesvu01BytVXgt_uCbeQgjOg,1061
|
|
7
|
+
specklia-1.9.105.dist-info/METADATA,sha256=j3NmNFVNV0ypcuv9bZ0HSM15blSPM6Xbl0FE6Mr6PNQ,3083
|
|
8
|
+
specklia-1.9.105.dist-info/WHEEL,sha256=In9FTNxeP60KnTkGw7wk6mJPYd_dQSjEZmXdBdMCI-8,91
|
|
9
|
+
specklia-1.9.105.dist-info/top_level.txt,sha256=XgU53UpAJbqEni5EjJaPdQPYuNx16Geg2I5A9lo1BQw,9
|
|
10
|
+
specklia-1.9.105.dist-info/RECORD,,
|
|
@@ -1,9 +0,0 @@
|
|
|
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.100.dist-info/LICENCE,sha256=kjWTA-TtT_rJtsWuAgWvesvu01BytVXgt_uCbeQgjOg,1061
|
|
6
|
-
specklia-1.9.100.dist-info/METADATA,sha256=9mYjQAzt1Bcs0yHSHSWF4lFX7P58m3SX8kt5Xx5CNdQ,3083
|
|
7
|
-
specklia-1.9.100.dist-info/WHEEL,sha256=In9FTNxeP60KnTkGw7wk6mJPYd_dQSjEZmXdBdMCI-8,91
|
|
8
|
-
specklia-1.9.100.dist-info/top_level.txt,sha256=XgU53UpAJbqEni5EjJaPdQPYuNx16Geg2I5A9lo1BQw,9
|
|
9
|
-
specklia-1.9.100.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|