-
Notifications
You must be signed in to change notification settings - Fork 18
Expand file tree
/
Copy pathdataset.py
More file actions
executable file
·2918 lines (2605 loc) · 111 KB
/
Copy pathdataset.py
File metadata and controls
executable file
·2918 lines (2605 loc) · 111 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
"""Dataset class containing all logic for creating, checking, and updating datasets and associated resources."""
import json
import logging
import sys
import warnings
from collections.abc import Callable, Iterable, Iterator, Mapping, Sequence
from copy import deepcopy
from datetime import datetime
from pathlib import Path
from typing import (
TYPE_CHECKING,
Any,
Optional,
Union,
)
from hdx.location.country import Country
from hdx.utilities.base_downloader import BaseDownload
from hdx.utilities.dateparse import (
default_date,
default_enddate,
now_utc,
now_utc_notz,
parse_date,
parse_date_range,
)
from hdx.utilities.dictandlist import merge_two_dictionaries
from hdx.utilities.downloader import Download
from hdx.utilities.loader import load_json
from hdx.utilities.saver import save_iterable, save_json
from hdx.utilities.uuid import is_valid_uuid
from hxl.input import HXLIOException, InputOptions, munge_url
import hdx.data.organization as org_module
import hdx.data.resource as res_module
import hdx.data.showcase as sc_module
import hdx.data.user as user
import hdx.data.vocabulary as vocabulary
from hdx.api.configuration import Configuration
from hdx.api.locations import Locations
from hdx.api.utilities.dataset_title_helper import DatasetTitleHelper
from hdx.api.utilities.date_helper import DateHelper
from hdx.api.utilities.filestore_helper import FilestoreHelper
from hdx.data.hdxobject import HDXError, HDXObject
from hdx.data.resource_matcher import ResourceMatcher
if TYPE_CHECKING:
from hdx.data.organization import Organization
from hdx.data.resource import Resource
from hdx.data.showcase import Showcase
from hdx.data.user import User
logger = logging.getLogger(__name__)
class NotRequestableError(HDXError):
pass
class Dataset(HDXObject):
"""Dataset class enabling operations on datasets and associated resources.
Args:
initial_data: Initial dataset metadata dictionary. Defaults to None.
configuration: HDX configuration. Defaults to global configuration.
"""
max_attempts = 5
max_int = sys.maxsize
update_frequencies = {
"-2": "As needed",
"-1": "Never",
"0": "Live",
"1": "Every day",
"2": "Every two days",
"7": "Every week",
"14": "Every two weeks",
"30": "Every month",
"60": "Every two months",
"90": "Every three months",
"120": "Every four months",
"180": "Every six months",
"300": "Every ten months",
"365": "Every year",
"730": "Every two years",
"as needed": "-2",
"adhoc": "-2",
"never": "-1",
"live": "0",
"every day": "1",
"every two days": "2",
"every 2 days": "2",
"every week": "7",
"every two weeks": "14",
"every month": "30",
"every 2 months": "60",
"every two months": "60",
"every 3 months": "90",
"every three months": "90",
"every quarter": "90",
"every four months": "120",
"every 4 months": "120",
"every six months": "180",
"every 6 months": "180",
"every ten months": "300",
"every 10 months": "300",
"every year": "365",
"every two years": "730",
"every 2 years": "730",
"daily": "1",
"weekly": "7",
"fortnightly": "14",
"every other week": "14",
"monthly": "30",
"quarterly": "90",
"semiannually": "180",
"semiyearly": "180",
"annually": "365",
"yearly": "365",
}
def __init__(
self,
initial_data: dict | None = None,
configuration: Configuration | None = None,
) -> None:
if not initial_data:
initial_data = {}
self.init_resources()
super().__init__(initial_data, configuration=configuration)
self._preview_resourceview = None
@staticmethod
def actions() -> dict[str, str]:
"""Dictionary of actions that can be performed on object
Returns:
Dictionary of actions that can be performed on object
"""
return {
"show": "package_show",
"create": "package_create",
"revise": "package_revise",
"delete": "hdx_dataset_purge",
"search": "package_search",
"reorder": "package_resource_reorder",
"list": "package_list",
"autocomplete": "package_autocomplete",
"create_default_views": "package_create_default_resource_views",
}
def __setitem__(self, key: Any, value: Any) -> None:
"""Set dictionary items but do not allow setting of resources
Args:
key: Key in dictionary
value: Value to put in dictionary
Returns:
None
"""
if key == "resources":
self.add_update_resources(value, ignore_datasetid=True)
return
super().__setitem__(key, value)
def separate_resources(self) -> None:
"""Move contents of resources key in internal dictionary into self.resources
Returns:
None
"""
self._separate_hdxobjects(
self._resources, "resources", "name", res_module.Resource
)
def unseparate_resources(self) -> None:
"""Move self.resources into resources key in internal dictionary
Returns:
None
"""
if self._resources:
self.data["resources"] = self._convert_hdxobjects(self._resources)
def get_dataset_dict(self) -> dict:
"""Move self.resources into resources key in internal dictionary
Returns:
Dataset dictionary
"""
package = deepcopy(self.data)
if self._resources:
package["resources"] = self._convert_hdxobjects(self._resources)
return package
def save_to_json(self, path: Path | str, follow_urls: bool = False):
"""Save dataset to JSON. If follow_urls is True, resource urls that point to
datasets, HXL proxy urls etc. are followed to retrieve final urls.
Args:
path: Path to save dataset
follow_urls: Whether to follow urls. Defaults to False.
Returns:
None
"""
dataset_dict = self.get_dataset_dict()
if follow_urls:
for resource in dataset_dict.get("resources", tuple()):
try:
resource["url"] = munge_url(resource["url"], InputOptions())
except HXLIOException:
pass
save_json(dataset_dict, path)
@staticmethod
def load_from_json(path: Path | str) -> Optional["Dataset"]:
"""Load dataset from JSON
Args:
path: Path to load dataset
Returns:
Dataset created from JSON or None
"""
jsonobj = load_json(path, loaderror_if_empty=False)
if jsonobj is None:
return None
dataset = Dataset(jsonobj)
dataset.separate_resources()
return dataset
def init_resources(self) -> None:
"""Initialise self.resources list
Returns:
None
"""
self._resources: list[res_module.Resource] = []
def _get_resource_from_obj(
self, resource: Union["Resource", dict, str]
) -> "Resource":
"""Add new or update existing resource in dataset with new metadata
Args:
resource: Either resource id or resource metadata from a Resource object or a dictionary
Returns:
Resource object
"""
if isinstance(resource, str):
if is_valid_uuid(resource) is False:
raise HDXError(f"{resource} is not a valid resource id!")
resource = res_module.Resource.read_from_hdx(
resource, configuration=self.configuration
)
elif isinstance(resource, dict):
resource = res_module.Resource(resource, configuration=self.configuration)
if not isinstance(resource, res_module.Resource):
raise HDXError(
f"Type {type(resource).__name__} cannot be added as a resource!"
)
return resource
def add_update_resource(
self,
resource: Union["Resource", dict, str],
ignore_datasetid: bool = False,
) -> "Resource":
"""Add new or update existing resource in dataset with new metadata
Args:
resource: Either resource id or resource metadata from a Resource object or a dictionary
ignore_datasetid: Whether to ignore dataset id in the resource
Returns:
The resource that was added after matching with any existing resource
"""
resource = self._get_resource_from_obj(resource)
if "package_id" in resource:
if not ignore_datasetid:
raise HDXError(
f"Resource {resource['name']} being added already has a dataset id!"
)
resource.check_both_url_filetoupload()
resource_index = ResourceMatcher.match_resource_list(self._resources, resource)
if resource_index is None:
self._resources.append(resource)
return resource
updated_resource = merge_two_dictionaries(
self._resources[resource_index], resource
)
if resource.get_file_to_upload():
updated_resource.set_file_to_upload(resource.get_file_to_upload())
if resource.is_marked_data_updated():
updated_resource.mark_data_updated()
return updated_resource
def add_update_resources(
self,
resources: Sequence[Union["Resource", dict, str]],
ignore_datasetid: bool = False,
) -> None:
"""Add new to the dataset or update existing resources with new metadata
Args:
resources: A list of either resource ids or resources metadata from either Resource objects or dictionaries
ignore_datasetid: Whether to ignore dataset id in the resource. Defaults to False.
Returns:
None
"""
resource_objects = []
for resource in resources:
resource = self._get_resource_from_obj(resource)
if "package_id" in resource:
if not ignore_datasetid:
raise HDXError(
f"Resource {resource['name']} being added already has a dataset id!"
)
resource.check_both_url_filetoupload()
resource_objects.append(resource)
(
resource_matches,
updated_resource_matches,
_,
updated_resource_no_matches,
) = ResourceMatcher.match_resource_lists(self._resources, resource_objects)
for i, resource_index in enumerate(resource_matches):
resource = resource_objects[updated_resource_matches[i]]
updated_resource = merge_two_dictionaries(
self._resources[resource_index], resource
)
if resource.get_file_to_upload():
updated_resource.set_file_to_upload(resource.get_file_to_upload())
if resource.is_marked_data_updated():
updated_resource.mark_data_updated()
for resource_index in updated_resource_no_matches:
resource = resource_objects[resource_index]
self._resources.append(resource)
def delete_resource(
self,
resource: Union["Resource", dict, str],
delete: bool = True,
) -> bool:
"""Delete a resource from the dataset and also from HDX by default
Args:
resource: Either resource id or resource metadata from a Resource object or a dictionary
delete: Whetehr to delete the resource from HDX (not just the dataset). Defaults to True.
Returns:
True if resource removed or False if not
"""
if isinstance(resource, str):
if is_valid_uuid(resource) is False:
raise HDXError(f"{resource} is not a valid resource id!")
return self._remove_hdxobject(self._resources, resource, delete=delete)
def get_resources(self) -> list["Resource"]:
"""Get dataset's resources
Returns:
List of Resource objects
"""
return self._resources
def get_resource(self, index: int = 0) -> "Resource":
"""Get one resource from dataset by index
Args:
index: Index of resource in dataset. Defaults to 0.
Returns:
Resource object
"""
return self._resources[index]
def number_of_resources(self) -> int:
"""Get number of dataset's resources
Returns:
Number of Resource objects
"""
return len(self._resources)
def reorder_resources(
self, resource_ids: Sequence[str], hxl_update: bool = True
) -> None:
"""Reorder resources in dataset according to provided list. Resources are
updated in the dataset object to match new order. However, the dataset is not
refreshed by rereading from HDX. If only some resource ids are supplied then
these are assumed to be first and the other resources will stay in their
original order.
Args:
resource_ids: List of resource ids
Returns:
None
"""
dataset_id = self.data.get("id")
if not dataset_id:
raise HDXError(
"Dataset has no id! It must be read, created or updated first."
)
data = {"id": dataset_id, "order": resource_ids}
results = self._write_to_hdx("reorder", data)
ordered_ids = results["order"]
reordered_resources = []
for resource_id in ordered_ids:
resource = next(x for x in self._resources if x["id"] == resource_id)
reordered_resources.append(resource)
self._resources = reordered_resources
def move_resource(
self,
resource_name: str,
insert_before: str,
) -> "Resource":
"""Move resource in dataset to be before the resource whose name starts
with the value of insert_before.
Args:
resource_name: Name of resource to move
insert_before: Resource to insert before
Returns:
The resource that was moved
"""
from_index = None
to_index = None
for i, resource in enumerate(self._resources):
res_name = resource["name"]
if res_name == resource_name:
from_index = i
elif res_name.startswith(insert_before):
to_index = i
if to_index is None:
# insert at the start if resource cannot be found
to_index = 0
resource = self._resources.pop(from_index)
if from_index < to_index:
# to index was calculated while element was in front
to_index -= 1
self._resources.insert(to_index, resource)
return resource
def update_from_yaml(
self, path: Path | str = Path("config", "hdx_dataset_static.yaml")
) -> None:
"""Update dataset metadata with static metadata from YAML file
Args:
path: Path to YAML dataset metadata. Defaults to config/hdx_dataset_static.yaml.
Returns:
None
"""
super().update_from_yaml(path)
self.separate_resources()
def update_from_json(
self, path: Path | str = Path("config", "hdx_dataset_static.json")
) -> None:
"""Update dataset metadata with static metadata from JSON file
Args:
path: Path to JSON dataset metadata. Defaults to config/hdx_dataset_static.json.
Returns:
None
"""
super().update_from_json(path)
self.separate_resources()
@staticmethod
def read_from_hdx(
identifier: str, configuration: Configuration | None = None
) -> Optional["Dataset"]:
"""Reads the dataset given by identifier from HDX and returns Dataset object
Args:
identifier: Identifier of dataset
configuration: HDX configuration. Defaults to global configuration.
Returns:
Dataset object if successful read, None if not
"""
dataset = Dataset(configuration=configuration)
result = dataset._dataset_load_from_hdx(identifier)
if result:
return dataset
return None
def _dataset_create_resources(self) -> None:
"""Creates resource objects in dataset"""
if "resources" in self.data:
self._old_data["resources"] = self._copy_hdxobjects(
self._resources,
res_module.Resource,
("_file_to_upload", "_data_updated", "_url_backup"),
)
self.init_resources()
self.separate_resources()
def _dataset_load_from_hdx(self, id_or_name: str) -> bool:
"""Loads the dataset given by either id or name from HDX
Args:
id_or_name: Either id or name of dataset
Returns:
True if loaded, False if not
"""
if not self._load_from_hdx("dataset", id_or_name):
return False
self._dataset_create_resources()
return True
def check_resources_url_filetoupload(self) -> None:
"""Check for error where both url or file to upload are provided for resources
Returns:
None
"""
for resource in self._resources:
resource.check_both_url_filetoupload()
def check_resources_fields(self, ignore_fields: Sequence[str] = ()) -> None:
"""Check that metadata for resources is complete. The parameter ignore_fields
should be set if required to any fields that should be ignored for the
particular operation.
Args:
ignore_fields: Fields to ignore. Default is ().
Returns:
None
"""
for resource in self._resources:
FilestoreHelper.resource_check_required_fields(
resource, ignore_fields=ignore_fields
)
def check_required_fields(
self,
ignore_fields: Sequence[str] = (),
allow_no_resources: bool = False,
**kwargs: Any,
) -> None:
"""Check that metadata for dataset is complete. The parameter ignore_fields
should be set if required to any fields that should be ignored for the
particular operation.
Args:
ignore_fields: Fields to ignore. Default is ().
allow_no_resources: Whether to allow no resources. Defaults to False.
Returns:
None
"""
if self.is_requestable():
self._check_required_fields("dataset-requestable", ignore_fields)
else:
self._check_required_fields("dataset", ignore_fields)
if len(self._resources) == 0 and not allow_no_resources:
raise HDXError(
"There are no resources! Please add at least one resource!"
)
@staticmethod
def revise(
match: dict[str, Any],
filter: Sequence[str] = (),
update: dict[str, Any] = {},
files_to_upload: dict[str, str] = {},
configuration: Configuration | None = None,
**kwargs: Any,
) -> "Dataset":
"""Revises an HDX dataset in HDX
Args:
match (Dict[str,Any]): Metadata on which to match dataset
filter: Filters to apply. Defaults to tuple().
update: Metadata updates to apply. Defaults to {}.
files_to_upload: Files to upload to HDX. Defaults to {}.
configuration: HDX configuration. Defaults to global configuration.
**kwargs: Additional arguments to pass to package_revise
Returns:
Dataset object
"""
separators = (",", ":")
data = {"match": json.dumps(match, separators=separators)}
if filter:
data["filter"] = json.dumps(filter, separators=separators)
if update:
data["update"] = json.dumps(update, separators=separators)
for key, value in kwargs.items():
data[key] = json.dumps(value, separators=separators)
dataset = Dataset(data, configuration=configuration)
result = dataset._write_to_hdx(
"revise",
data,
id_field_name="match",
files_to_upload=files_to_upload,
)
dataset.data = result["package"]
dataset.init_resources()
dataset.separate_resources()
return dataset
def _prepare_hdx_call(self, data: dict, kwargs: Any) -> None:
"""Common method used in create and update calls. Cleans tags and
processes keyword arguments populating updated_by_script (details
about what script is doing the update and when), batch
and setting batch_mode in kwargs if needed (whether datasets on the
CKAN /datasets page are grouped).
Args:
data: Dataset data to update if needed
**kwargs: See below
updated_by_script (str): Script info. Defaults to user agent.
batch (str): Batch UUID for where multiple datasets are grouped into one batch
batch_mode (bool): Whether to group by batch. Defaults to not grouping.
Returns:
None
"""
self.clean_tags()
scriptinfo = kwargs.get("updated_by_script")
if scriptinfo:
del kwargs["updated_by_script"]
else:
scriptinfo = self.configuration.get_user_agent()
# Should not output timezone info here
data["updated_by_script"] = (
f"{scriptinfo} ({now_utc_notz().isoformat(timespec='microseconds')})"
)
batch = kwargs.get("batch")
if batch:
if not is_valid_uuid(batch):
raise HDXError(f"{batch} is not a valid UUID!")
data["batch"] = batch
del kwargs["batch"]
if "batch_mode" not in kwargs:
kwargs["batch_mode"] = "DONT_GROUP"
def _revise_filter(
self,
dataset_data_to_update: dict,
keys_to_delete: Sequence[str],
resources_to_delete: Sequence[int],
):
"""Returns the revise filter parameter adding to it any keys in the
dataset metadata and resources that are specified to be deleted. Also compare
lists in original and updated metadata to see if any have had elements
removed in which case these should be added to the filter
Args:
dataset_data_to_update: Dataset data to be updated
keys_to_delete: List of top level metadata keys to delete
resources_to_delete: List of indexes of resources to delete
"""
revise_filter = []
for key in keys_to_delete:
revise_filter.append(f"-{key}")
if not self.is_requestable():
for resource_index in resources_to_delete:
revise_filter.append(f"-resources__{resource_index}")
for key, value in dataset_data_to_update.items():
if not isinstance(value, list):
continue
if key not in self._old_data:
continue
orig_list = self._old_data[key]
elements_to_remove = []
for i, orig_value in enumerate(orig_list):
if isinstance(orig_value, dict) and any(
x in orig_value for x in ("id", "name")
):
# Where lists have an id or name, we use one of those to
# match elements and work out what needs to be deleted
# ie. any elements in the original list that don't exist
# in the updated list
el_id = orig_value.get("id")
el_name = orig_value.get("name")
present = False
for value_dict in value:
if el_id and "id" in value_dict and value_dict["id"] == el_id:
present = True
break
if (
el_name
and "name" in value_dict
and value_dict["name"] == el_name
):
present = True
break
if not present:
elements_to_remove.append(i)
elif orig_value not in value:
# Otherwise we do a simple exact match of list elements
# and delete any elements that are in the original list but
# not in the updated list
elements_to_remove.append(i)
for element_index in reversed(elements_to_remove):
del orig_list[element_index]
if element_index == len(orig_list):
revise_filter.append(f"-{key}__{element_index}")
return revise_filter
def _revise_files_to_upload_resource_deletions(
self,
resources_to_update: Sequence["Resource"],
resources_to_delete: Sequence[int],
filestore_resources: dict[int, str],
):
"""Returns the files to be uploaded and updates resources_to_update and
filestore_resources to reflect any deletions.
Args:
resources_to_update: Resources to update
resources_to_delete: List of indexes of resources to delete
filestore_resources: List of (index of resources, file to upload)
"""
files_to_upload = {}
if not self.is_requestable():
for resource_index in resources_to_delete:
del resources_to_update[resource_index]
new_fsresources = {}
for index in filestore_resources:
if index > resource_index:
new_fsresources[index - 1] = filestore_resources[index]
else:
new_fsresources[index] = filestore_resources[index]
filestore_resources = new_fsresources
for (
resource_index,
file_to_upload,
) in filestore_resources.items():
files_to_upload[f"update__resources__{resource_index}__upload"] = (
file_to_upload
)
return files_to_upload
def _revise_dataset(
self,
allow_no_resources: bool,
keys_to_delete: Sequence[str],
resources_to_update: Sequence["Resource"],
resources_to_delete: Sequence[int],
filestore_resources: dict[int, str],
new_resource_order: Sequence[str] | None,
create_default_views: bool = False,
test: bool = False,
**kwargs: Any,
) -> dict:
"""Helper method to save the modified dataset and add any filestore resources
Args:
allow_no_resources: Whether to allow no resources
keys_to_delete: List of top level metadata keys to delete
resources_to_update: Resources to update
resources_to_delete: List of indexes of resources to delete
filestore_resources: List of (index of resources, file to upload)
new_resource_order: New resource order to use or None
create_default_views: Whether to create default views. Defaults to False.
test: Whether running in a test. Defaults to False.
**kwargs: See below
ignore_field (str): Any field to ignore when checking dataset metadata. Defaults to None.
Returns:
Dictionary of what gets passed to the revise call (for testing)
"""
results = {}
dataset_data_to_update = self._old_data
self._old_data = self.data
if (
"batch_mode" in kwargs
): # Whether or not CKAN should change groupings of datasets on /datasets page
dataset_data_to_update["batch_mode"] = kwargs["batch_mode"]
if (
"skip_validation" in kwargs
): # Whether or not CKAN should perform validation steps (checking fields present)
dataset_data_to_update["skip_validation"] = kwargs["skip_validation"]
dataset_data_to_update["state"] = "active"
revise_filter = self._revise_filter(
dataset_data_to_update, keys_to_delete, resources_to_delete
)
files_to_upload = self._revise_files_to_upload_resource_deletions(
resources_to_update,
resources_to_delete,
filestore_resources,
)
dataset_data_to_update["resources"] = self._convert_hdxobjects(
resources_to_update
)
results["filter"] = revise_filter
results["update"] = dataset_data_to_update
results["files_to_upload"] = files_to_upload
if test:
return results
new_dataset = self.revise(
{"id": self.data["id"]},
filter=revise_filter,
update=dataset_data_to_update,
files_to_upload=files_to_upload,
)
self.data = new_dataset.data
self._resources = new_dataset._resources
# We do field check after call so that we have the changed data
if "ignore_check" not in kwargs or not kwargs.get(
"ignore_check"
): # allow ignoring of field checks
ignore_fields = kwargs.get("ignore_fields", [])
ignore_field = self.configuration["dataset"].get("ignore_on_update")
if ignore_field and ignore_field not in ignore_fields:
ignore_fields.append(ignore_field)
ignore_field = kwargs.get("ignore_field")
if ignore_field and ignore_field not in ignore_fields:
ignore_fields.append(ignore_field)
self.check_required_fields(
ignore_fields=ignore_fields, allow_no_resources=allow_no_resources
)
self.check_resources_fields(ignore_fields=ignore_fields)
if new_resource_order:
existing_order = [(x["name"], x["format"].lower()) for x in self._resources]
if existing_order != new_resource_order:
sorted_resources = sorted(
self._resources,
key=lambda x: new_resource_order.index(
(x["name"], x["format"].lower())
),
)
self.reorder_resources([x["id"] for x in sorted_resources])
if create_default_views:
self.create_default_views()
self._create_preview_resourceview()
return results
def _dataset_update_resources(
self,
update_resources: bool,
match_resources_by_metadata: bool,
remove_additional_resources: bool,
match_resource_order: bool,
**kwargs: Any,
) -> tuple[list, list, dict, list, dict]:
"""Helper method to compare new and existing dataset data returning
resources to be updated, resources to be deleted, resources where files
need to be uploaded to the filestore and if match_resource_order is
True, then the new resource order.
Returns a tuple of resources information of the form:
(resources_to_update, resources_to_delete, filestore_resources,
new_resource_order, resource status codes)
Args:
update_resources: Whether to update resources
match_resources_by_metadata: Compare resource metadata rather than position in list
remove_additional_resources: Remove additional resources found in dataset (if updating)
match_resource_order: Match order of given resources by name
Returns:
Tuple of resources information
"""
# When the user sets up a dataset, "data" contains the metadata. The
# HDX dataset update process involves copying "data" to "old_data" and
# then reading the existing dataset on HDX into "data". Hence,
# "old_data" below contains the user-supplied data we want to use for
# updating while "data" contains the data read from HDX
resources_metadata_to_update = self._old_data.get("resources", None)
resources_to_update = []
resources_to_delete = []
filestore_resources = {}
statuses = {}
if update_resources and resources_metadata_to_update:
if match_resources_by_metadata:
(
resource_matches,
updated_resource_matches,
resource_no_matches,
updated_resource_no_matches,
) = ResourceMatcher.match_resource_lists(
self._resources, resources_metadata_to_update
)
for i, resource in enumerate(self._resources):
try:
match_index = resource_matches.index(i)
except ValueError:
resources_to_update.append(res_module.Resource({}))
continue
resource_data_to_update = resources_metadata_to_update[
updated_resource_matches[match_index]
]
resource_name = resource["name"]
logger.warning(f"Resource exists. Updating {resource['name']}")
status = FilestoreHelper.dataset_update_filestore_resource(
resource,
resource_data_to_update,
filestore_resources,
i,
**kwargs,
)
statuses[resource_name] = status
resources_to_update.append(resource_data_to_update)
resource_index = len(self._resources)
for updated_resource_index in updated_resource_no_matches:
resource_data_to_update = resources_metadata_to_update[
updated_resource_index
]
status = FilestoreHelper.check_filestore_resource(
resource_data_to_update,
filestore_resources,
resource_index,
**kwargs,
)
statuses[resource_data_to_update["name"]] = status
resource_index = resource_index + 1
resources_to_update.append(resource_data_to_update)
if remove_additional_resources:
for resource_index in resource_no_matches:
resource = self._resources[resource_index]
logger.warning(
f"Removing additional resource {resource['name']}!"
)
resources_to_delete.append(resource_index)
else: # update resources by position
for i, resource_data_to_update in enumerate(
resources_metadata_to_update
):
if len(self._resources) > i:
updated_resource_name = resource_data_to_update["name"]
resource = self._resources[i]
resource_name = resource["name"]
logger.warning(f"Resource exists. Updating {resource_name}")
if resource_name != updated_resource_name:
logger.warning(
f"Changing resource name to: {updated_resource_name}"
)
status = FilestoreHelper.dataset_update_filestore_resource(
resource,
resource_data_to_update,
filestore_resources,
i,
**kwargs,
)
statuses[updated_resource_name] = status
else:
status = FilestoreHelper.check_filestore_resource(
resource_data_to_update,
filestore_resources,
i,
**kwargs,
)
statuses[resource_data_to_update["name"]] = status
resources_to_update.append(resource_data_to_update)
for i, resource in enumerate(self._resources):
if len(resources_metadata_to_update) <= i:
resources_to_update.append(resource)
if remove_additional_resources:
logger.warning(
f"Removing additional resource {resource['name']}!"
)
resources_to_delete.append(i)
resources_to_delete = list(reversed(resources_to_delete))
if match_resource_order:
new_resource_order = [
(x["name"], x["format"].lower()) for x in resources_metadata_to_update
]
else:
new_resource_order = None
return (
resources_to_update,
resources_to_delete,
filestore_resources,
new_resource_order,
statuses,
)
def _dataset_hdx_update(
self,
allow_no_resources: bool,
update_resources: bool,
match_resources_by_metadata: bool,
keys_to_delete: Sequence[str],
remove_additional_resources: bool,
match_resource_order: bool,