pymetadataeditor 0.1.0__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.
@@ -0,0 +1,1641 @@
1
+ import warnings
2
+ from json import JSONDecodeError
3
+ from pathlib import Path
4
+ from tempfile import NamedTemporaryFile
5
+ from typing import Callable, Dict, Iterable, List, Optional, Tuple, Type, Union
6
+
7
+ import pandas as pd
8
+ import tiktoken
9
+ from markitdown import MarkItDown
10
+ from metadataschemas.metadata_manager import MetadataManager
11
+ from metadataschemas.utils.schema_base_model import SchemaBaseModel
12
+
13
+ # from metadataschemas.utils.quick_start import make_skeleton
14
+ from metadataschemas.utils.utils import merge_dicts, standardize_keys_in_dict
15
+ from openai import OpenAI
16
+ from pydantic import BaseModel, ValidationError
17
+ from requests.exceptions import HTTPError
18
+ from urllib3.exceptions import InsecureRequestWarning
19
+
20
+ from pymetadataeditor.llm_helpers import (
21
+ _iterated_validated_update_to_outline,
22
+ _prepend_draft_drop_non_str,
23
+ get_date_as_text,
24
+ json_to_markdown,
25
+ )
26
+ from pymetadataeditor.requester import RequestsWithSpecificErrors
27
+ from pymetadataeditor.templates import pydantic_from_template
28
+
29
+ from .utils import remove_empty_from_dict, validate_json_patches
30
+
31
+ __all__ = ["MetadataEditor", "DeleteNotAppliedError", "TemplateError"]
32
+
33
+ DICT_MODES = ["dict", "dictionary"]
34
+ PYDANTIC_MODES = ["pydantic", "model", "basemodel", "object"]
35
+ EXCEL_MODES = ["excel"]
36
+
37
+ shown_insecure_request_warnings = set()
38
+
39
+
40
+ # Define a custom function to display warnings once only for unique InsecureRequestWarning messages
41
+ def custom_showwarning(message, category, filename, lineno, file=None, line=None):
42
+ global shown_insecure_request_warnings
43
+ # Only handle InsecureRequestWarning
44
+ if category is InsecureRequestWarning:
45
+ # Check if this message has already been shown
46
+ warning_key = (str(message), filename, lineno)
47
+ if warning_key not in shown_insecure_request_warnings:
48
+ # Show the warning and add the message to the set
49
+ warnings._showwarnmsg_impl(warnings.WarningMessage(message, category, filename, lineno, file, line))
50
+ shown_insecure_request_warnings.add(warning_key)
51
+ else:
52
+ # Pass other warnings through without suppression
53
+ warnings._showwarnmsg_impl(warnings.WarningMessage(message, category, filename, lineno, file, line))
54
+
55
+
56
+ # Set the warnings to always trigger, but filter them through custom_showwarning
57
+ warnings.simplefilter("always", InsecureRequestWarning)
58
+ warnings.showwarning = custom_showwarning
59
+
60
+
61
+ class DeleteNotAppliedError(Exception):
62
+ def __init__(self, message="Delete request not accepted by system.", response=None):
63
+ super().__init__(message)
64
+ self.response = response
65
+
66
+
67
+ class TemplateError(Exception):
68
+ def __init__(self, message="Error with template", response=None):
69
+ super().__init__(message)
70
+ self.message = message
71
+ self.response = response
72
+
73
+ def __str__(self):
74
+ # Customize the error message to include the response if available
75
+ if self.response:
76
+ return f"{self.response}\n\n{self.message}"
77
+ else:
78
+ return self.message
79
+
80
+
81
+ class MetadataEditor:
82
+ def __init__(self, api_url: str, api_key: str, allow_http: bool = False, verify_ssl: bool = True):
83
+ """
84
+ MetadataEditor allows you to list, create, update and delete projects, manage collections and list templates in
85
+ a metadata database.
86
+
87
+ First obtain an API key and pase it into a file called '.env' in the root of your project. The contents of the
88
+ file should look like this:
89
+
90
+ METADATA_API_URL=https://<name_of_your_metadata_database>.org/index.php/api
91
+ METADATA_API_KEY=your_api_key
92
+
93
+ Then in python run
94
+
95
+ from pymetadataeditor import MetadataEditor
96
+ import os
97
+
98
+ api_url = os.getenv("METADATA_API_URL")
99
+ api_key = os.getenv("METADATA_API_KEY")
100
+ me = MetadataEditor(api_url = api_url, api_key = api_key)
101
+
102
+ Then you can list and create new projects like so:
103
+
104
+ me.list_projects(limit=100)
105
+ indicator_metadata = me.make_metadata_outline("indicator", "pydantic")
106
+ # update the indicator metadata as needed.
107
+
108
+ # View nicely formatted metadata
109
+ indicator_metadata.pretty_print()
110
+
111
+ # Then log the metadata to the database
112
+ me.create_project_log(dict_of_indicator, "indicator")
113
+
114
+ Args:
115
+ api_url (str): the URL typically looks like 'https://<name_of_your_metadata_database>.org/index.php/api'
116
+ api_key (str): typically this is created through the web interface of the metadata system
117
+ allow_http (bool): whether to allow calls to the metadata system when the URL begins "http" instead of the
118
+ more secure "https". Defaults to False.
119
+ verify_ssl (bool): Although it is good practice for API requests to verify SSL, some systems do not allow
120
+ this so setting verify_ssl=False may be required. Defaults to True.
121
+ """
122
+ self._apinterface = RequestsWithSpecificErrors(
123
+ api_url=api_url, api_key=api_key, allow_http=allow_http, verify_ssl=verify_ssl
124
+ )
125
+ self._mm = MetadataManager()
126
+ self._templates = {}
127
+ self._default_templates = None
128
+
129
+ ####################################################################################################################
130
+ # PROJECTS
131
+ ####################################################################################################################
132
+
133
+ def count_projects(self) -> int:
134
+ """
135
+ Count the number of projects you have access to
136
+
137
+ Returns:
138
+ int: The number of projects
139
+ """
140
+ list_projects_get_path = "/editor"
141
+ params = {"offset": 0, "limit": 1}
142
+ response = self._apinterface.get_request(pth=list_projects_get_path, params=params)
143
+ return response["total"]
144
+
145
+ def list_projects(
146
+ self,
147
+ limit: Union[int, str],
148
+ keywords: Optional[Union[str, List[str]]] = None,
149
+ metadata_type: Optional[str] = None,
150
+ offset: int = 0,
151
+ sort_by: Optional[str] = None,
152
+ ) -> pd.DataFrame:
153
+ """
154
+ Lists all the projects associated with your API key.
155
+
156
+ Args:
157
+
158
+ limit (int or str): Page size e.g. 10 to show 10 records. If limit='All' then all records are retrieved.
159
+ keywords (optional str or list of str): Keywords for filtering projects by title and/or idno.
160
+ metadata_type (optional str): If given, only projects of this type are returned.
161
+ offset (int): Offset for pagination e.g. 10 to skip first 10 records. Default is 0.
162
+ sort_by (optional str): valid values: "title_asc", "title_desc", "updated_asc", "updated_desc".
163
+
164
+ Returns:
165
+ pd.DataFrame: Information about the projects
166
+ """
167
+ if isinstance(limit, str):
168
+ assert limit.lower() == "all", f"Expected limit to be 'All' or a positive integer but got '{limit}'"
169
+ new_offset = offset
170
+ new_limit = 500
171
+ dfs = []
172
+ while True:
173
+ df = self.list_projects(
174
+ keywords=keywords, metadata_type=metadata_type, offset=new_offset, limit=new_limit, sort_by=sort_by
175
+ )
176
+ dfs.append(df)
177
+ if len(df) < new_limit:
178
+ break
179
+ new_offset += new_limit
180
+ return pd.concat(dfs)
181
+
182
+ list_projects_get_path = "/editor"
183
+ params = {"offset": offset, "limit": limit}
184
+ if keywords is not None:
185
+ if not isinstance(keywords, str) and isinstance(keywords, Iterable):
186
+ keywords = "%".join(keywords)
187
+ keywords = keywords.replace(" ", "%")
188
+ params["keywords"] = keywords
189
+ if metadata_type is not None:
190
+ metadata_type = self._mm.standardize_metadata_name(metadata_type)
191
+ params["type"] = metadata_type
192
+ if sort_by is not None:
193
+ sort_by = sort_by.lower()
194
+ valid_sort_by = ["title_asc", "title_desc", "updated_asc", "updated_desc"]
195
+ assert sort_by in valid_sort_by, f"{sort_by} not valid, must be one of {valid_sort_by}"
196
+ params["sort_by"] = sort_by
197
+ response = self._apinterface.get_request(pth=list_projects_get_path, params=params)
198
+ try:
199
+ projects = response["projects"]
200
+ projects = pd.DataFrame.from_dict(projects).set_index("id")
201
+ except KeyError: # is this the best way to cope with times when there are no projects?
202
+ return pd.DataFrame(columns=["id", "created"]).set_index("id")
203
+ return projects
204
+
205
+ def get_project_by_id(self, id: int) -> pd.Series:
206
+ """
207
+ Args:
208
+ id (int): the id of the project, not to be confused with the idno.
209
+
210
+ Returns:
211
+ (pd.Series): project information
212
+
213
+ Raises:
214
+ Exception: You don't have permission to access this project - often this means the id is incorrect
215
+ """
216
+ # todo(gblackadder) we could implement a get project by **idno** by using list projects and then filtering
217
+ get_project_template = "/editor/{}"
218
+ response = self._apinterface.get_request(get_project_template, id=id)
219
+ return pd.Series(response["project"])
220
+
221
+ ####################################################################################################################
222
+ # METADATA Input and Output
223
+ ####################################################################################################################
224
+
225
+ def _process_metadata_input(
226
+ self, metadata: Union[SchemaBaseModel, Dict, str], metadata_type_or_template_uid: Optional[str] = None
227
+ ) -> Tuple[BaseModel, str, None | str]:
228
+ """
229
+ Args:
230
+ metadata (Union[BaseModel, Dict, str]): The metadata to process.
231
+ metadata_type_or_template_uid (Optional[str]): The metadata type or template UID. Required if metadata is a
232
+ dictionary otherwise ignored.
233
+
234
+ Returns:
235
+ BaseModel: The metadata object.
236
+ str: The metadata type.
237
+ None | str: The template UID if the metadata is from a template, otherwise None.
238
+ """
239
+ if isinstance(metadata, BaseModel):
240
+ metadata_type, uid = (
241
+ metadata._metadata_type__
242
+ if isinstance(metadata._metadata_type__, str)
243
+ else metadata._metadata_type__.default,
244
+ metadata._template_uid__
245
+ if isinstance(metadata._template_uid__, str)
246
+ else metadata._template_uid__.default
247
+ if hasattr(metadata._template_uid__, "default")
248
+ else None,
249
+ ) # self._lookup_metadata_type_and_uid(metadata)
250
+ elif isinstance(metadata, dict):
251
+ if metadata_type_or_template_uid is None:
252
+ raise ValueError("metadata_type_or_template_uid must be passed when metadata is a dictionary")
253
+ klass, metadata_type, uid = self._get_metadata_class_and_type_and_UID(metadata_type_or_template_uid)
254
+ metadata = klass.model_validate(metadata, strict=False)
255
+ else:
256
+ # if metadata_type_or_template_uid is None:
257
+ metadata_info = self._mm.get_metadata_type_info_from_excel_file(metadata)
258
+ metadata_type = metadata_info["metadata_type"]
259
+ uid = metadata_info.get("template_uid", None)
260
+ if uid is not None:
261
+ klass = self._get_template_class_and_type_and_UID(uid)[0]
262
+ else:
263
+ klass = self._get_metadata_class_and_type_and_UID(metadata_type)[0]
264
+ metadata = self._mm.read_metadata_from_excel(metadata, klass, verbose=False)
265
+ return metadata, metadata_type, uid
266
+
267
+ def _process_metadata_output(
268
+ self,
269
+ metadata_object: BaseModel,
270
+ output_mode: str,
271
+ filename: Optional[str] = None,
272
+ title: Optional[str] = None,
273
+ simplify: Optional[bool] = None,
274
+ ) -> Union[BaseModel, Dict, str]:
275
+ """
276
+ Args:
277
+ metadata_object (BaseModel): The metadata object to output.
278
+ output_mode (str): The output mode. Must be 'dict', 'pydantic' or 'excel'.
279
+ filename (Optional[str]): If output_mode=='excel', the path to the Excel file.
280
+ If None, defaults to {name of metadata type}_metadata.xlsx
281
+ title (Optional[str]): If output_mode=='excel', the title for the Excel sheet.
282
+ If None, defaults to '{name of metadata type} Metadata'
283
+ simplify (Optional[bool]): If output_mode=='dict', then if simplify=True, only elements that were explicitly
284
+ set with non-null, non-empty values are returned in the dictionary. Default behaviour is False
285
+
286
+ Returns:
287
+ Union[BaseModel, Dict, str]: The output metadata.
288
+ """
289
+ assert (
290
+ output_mode in DICT_MODES + EXCEL_MODES + PYDANTIC_MODES
291
+ ), f"mode should be 'pydantic', 'dict' or 'excel' but found '{output_mode}'"
292
+ if output_mode in PYDANTIC_MODES:
293
+ return metadata_object
294
+ elif output_mode in DICT_MODES:
295
+ if simplify is None:
296
+ simplify = False
297
+ metadata_dict = metadata_object.model_dump(mode="json", exclude_none=simplify, exclude_unset=simplify)
298
+ if simplify:
299
+ return remove_empty_from_dict(metadata_dict)
300
+ else:
301
+ return metadata_dict
302
+ else:
303
+ metadata_type = (
304
+ metadata_object._metadata_type__
305
+ if isinstance(metadata_object._metadata_type__, str)
306
+ else metadata_object._metadata_type__.default
307
+ )
308
+ # metadata_type, _ = self._lookup_metadata_type_and_uid(metadata_object)
309
+ return self._mm.save_metadata_to_excel(
310
+ metadata_model=metadata_object,
311
+ filename=filename,
312
+ title=title,
313
+ metadata_type=metadata_type,
314
+ )
315
+
316
+ ####################################################################################################################
317
+ # Metadata Classes
318
+ ####################################################################################################################
319
+
320
+ def _get_template_class_and_type_and_UID(
321
+ self, template_uid: str, apply_template_rules: bool = True
322
+ ) -> Tuple[Type[BaseModel], str, str]:
323
+ """
324
+ Args:
325
+ template_uid (str): The UID of the template.
326
+
327
+ Returns:
328
+ Type[BaseModel]: The Pydantic class of the template.
329
+ str: The metadata type, such as 'document', 'geospatial', or 'image'.
330
+ str: The template UID.
331
+ """
332
+ if template_uid in self._templates and "class" in self._templates[template_uid] and apply_template_rules:
333
+ return self._templates[template_uid]["class"], self._templates[template_uid]["metadata_type"], template_uid
334
+ try:
335
+ temp = self.get_template_by_uid(template_uid)
336
+ except (KeyError, PermissionError) as e:
337
+ # not a template either
338
+ raise TemplateError(
339
+ f"{template_uid} is not a known template uid - consider using an alternative template "
340
+ f"from MetadataEditor.list_templates(limit='all')",
341
+ response=e,
342
+ )
343
+ else:
344
+ metadata_type = self._templates[template_uid]["metadata_type"]
345
+ if "class" not in self._templates[template_uid] or apply_template_rules is False:
346
+ parent_schema = self._mm.metadata_class_from_name(metadata_type)
347
+ klass = pydantic_from_template(
348
+ temp.template,
349
+ parent_schema=parent_schema,
350
+ uid=template_uid,
351
+ name=temp.name,
352
+ apply_rules=apply_template_rules,
353
+ )
354
+ if apply_template_rules:
355
+ self._templates[template_uid]["class"] = klass
356
+ else:
357
+ klass = self._templates[template_uid]["class"]
358
+ return klass, metadata_type, template_uid
359
+
360
+ def _get_metadata_class_and_type_and_UID(
361
+ self, metadata_type_or_template_uid: str, apply_template_rules: bool = True
362
+ ) -> Tuple[Type[BaseModel], str, None | str]:
363
+ """
364
+ Args:
365
+ metadata_type_or_template_uid (str): The metadata type or template UID.
366
+
367
+ Returns:
368
+ Type[BaseModel]: The pydantic class of the metadata.
369
+ str: The metadata type such as 'document', 'geospatial', 'image' etc.
370
+ None | str: The template UID if the metadata is from a template, otherwise None.
371
+ """
372
+ try:
373
+ metadata_type = self._mm.standardize_metadata_name(metadata_type_or_template_uid)
374
+ except ValueError:
375
+ # assume must be template
376
+ return self._get_template_class_and_type_and_UID(
377
+ metadata_type_or_template_uid, apply_template_rules=apply_template_rules
378
+ )
379
+ else:
380
+ if self._default_templates is None:
381
+ _ = self.list_templates()
382
+
383
+ if (
384
+ self._default_templates is not None
385
+ and metadata_type in self._default_templates.data_type.values
386
+ and self._default_templates[self._default_templates.data_type == metadata_type].iloc[0].uid != ""
387
+ ):
388
+ return self._get_template_class_and_type_and_UID(
389
+ self._default_templates[self._default_templates.data_type == metadata_type].iloc[0].uid,
390
+ apply_template_rules=apply_template_rules,
391
+ )
392
+ else:
393
+ klass = self._mm.metadata_class_from_name(metadata_type)
394
+ return klass, metadata_type, None
395
+
396
+ def get_metadata_class(self, metadata_type_or_template_uid: str) -> Type[BaseModel]:
397
+ return self._get_metadata_class_and_type_and_UID(
398
+ metadata_type_or_template_uid.strip(), apply_template_rules=True
399
+ )[0]
400
+
401
+ def make_metadata_outline(
402
+ self,
403
+ metadata_type_or_template_uid: str,
404
+ output_mode: str,
405
+ filename: Optional[str] = None,
406
+ title: Optional[str] = None,
407
+ ) -> Union[Dict, BaseModel, Path]:
408
+ """
409
+ Creates a skeleton outline of a given metadata type. Since the metadata can be quite complex, this function
410
+ creates all the possible fields and their subfields.
411
+
412
+ Args:
413
+ metadata_type_or_template_uid (str):
414
+ The type of a supported metadata type, currently:
415
+ document, geospatial, image, indicator, indicators_db, microdata, resource, script, table, video
416
+ If passed as a template UID then this template is retreived and an outline created.
417
+ output_mode (str): The type of output. Must be 'dict', 'pydantic' or 'excel'.
418
+ filename (Optional[str]): If output_mode=='excel', the path to the Excel file.
419
+ If None, defaults to {name of metadata type}_metadata.xlsx
420
+ title (Optional[str]): If output_mode=='excel', the title for the Excel sheet.
421
+ If None, defaults to '{name of metadata type} Metadata'
422
+
423
+ Returns:
424
+ `BaseModel` | `Dict` | `str`:
425
+ - If `output_mode == 'dict'`, a dictionary is returned.
426
+ - If `output_mode == 'pydantic'`, a pydantic model object is returned.
427
+ - If `output_mode == 'excel'`, the metadata was saved to a file and the filename is returned.
428
+
429
+ Examples:
430
+
431
+ me = MetadataEditor(api_url=..., api_key=...)
432
+
433
+ # using a metadata type will create an outline using the default template
434
+ indicator_dict = me.make_metadata_outline("indicator", "dict")
435
+ indicator_dict['metadata_information']['idno'] = "my_idno"
436
+
437
+ # using a template uid
438
+ indicator_pydantic = me.outline_metadata("timeseries-system-en", "pydantic")
439
+ indicator_pydantic.metadata_information.idno = "my_idno"
440
+
441
+ # an outline can also be written to an Excel file
442
+ path_to_indicator_excel_file = me.outline_metadata("indicator", "excel", "indicator_outline_metadata.xlsx")
443
+ """
444
+ metadata_class = self.get_metadata_class(metadata_type_or_template_uid)
445
+ # metadata_object = make_skeleton(metadata_class, debug=False)
446
+ metadata_object = self._mm.create_metadata_outline(metadata_class)
447
+ return self._process_metadata_output(
448
+ metadata_object=metadata_object,
449
+ output_mode=output_mode,
450
+ filename=filename,
451
+ title=title,
452
+ )
453
+
454
+ ####################################################################################################################
455
+ # Retrieve Metadata
456
+ ####################################################################################################################
457
+
458
+ def get_project_metadata_by_id(
459
+ self,
460
+ id: int,
461
+ output_mode: str,
462
+ template_uid: Optional[str] = None,
463
+ simplify: bool = True,
464
+ filename: Optional[str] = None,
465
+ title: Optional[str] = None,
466
+ debug: bool = False,
467
+ ) -> Union[BaseModel, Dict, str]:
468
+ """
469
+ Args:
470
+ id (int): the id of the project, not to be confused with the idno.
471
+ output_mode (str): The type of output. Must be 'dict', 'pydantic' or 'excel'.
472
+ template_uid (Optional[str]): The UID of the template to be applied to the logged metadata if different from
473
+ the template already associated with the project.
474
+ If None then the template listed in the logs will be used. And if the logs don't list a template then
475
+ the default template for that metadata type will be used.
476
+ The special value 'default' can be used to apply the default template for the metadata type.
477
+ When the output mode is 'dict' the special value 'none' can be used to remove the template from the
478
+ metadata and return whatever metadata was logged.
479
+ simplify (bool): If output_mode=='dict', then if simplify=True, only elements that were explicitly set with
480
+ non-null, non-empty values are returned in the dictionary. Default behaviour is True
481
+ filename (Optional[str]): If output_mode=='excel' then this is the path to the Excel file.
482
+ If None and output_mode=='excel', defaults to {name of metadata type}_metadata.xlsx
483
+ title (Optional[str]): If output_mode=='excel' then the title for the Excel sheet.
484
+ If None and mode=='excel', defaults to '{name of metadata type} Metadata'
485
+
486
+ Returns:
487
+ (Union[BaseModel, Dict, str]):
488
+ If mode == 'dict', a dictionary is returned.
489
+ If mode == 'pydantic', a pydantic model object is returned.
490
+ If mode == 'excel' then the metadata was saved to a file and the filename is returned.
491
+ """
492
+ output_mode = output_mode.lower().strip()
493
+ assert (
494
+ output_mode in DICT_MODES + EXCEL_MODES + PYDANTIC_MODES
495
+ ), f"mode should be 'pydantic', 'dict' or 'excel' but found '{output_mode}'"
496
+
497
+ template_uid = template_uid.lower().strip() if template_uid is not None else None
498
+ if template_uid == "none" and output_mode not in DICT_MODES:
499
+ raise ValueError("When template_uid='none' then output_mode must be 'dict'")
500
+
501
+ project = self.get_project_by_id(id=id)
502
+
503
+ if template_uid == "none":
504
+ return project.metadata
505
+
506
+ if template_uid == "default":
507
+ _, _, template_uid = self._get_metadata_class_and_type_and_UID(project.type)
508
+ if template_uid is None:
509
+ raise ValueError(
510
+ f"Cannot apply default template to metadata type '{project.type}' as "
511
+ f"no default template is set for this type"
512
+ )
513
+
514
+ if template_uid == project.template_uid:
515
+ template_uid = None
516
+
517
+ if template_uid is not None:
518
+ klass, _, uid = self._get_template_class_and_type_and_UID(template_uid)
519
+ skeleton_object = self.make_metadata_outline(uid, output_mode="dict")
520
+ validation_error_msg = (
521
+ f"The stored metadata conflicts with the specified {project.type} template with UID '{uid}'"
522
+ )
523
+ elif "template_uid" in project and project.template_uid is not None and project.template_uid != "":
524
+ assert (
525
+ project.template_uid.lower() != "none"
526
+ ), f"template_uid is 'none' but this should have been caught earlier, {project}"
527
+ klass, _, uid = self._get_template_class_and_type_and_UID(project.template_uid)
528
+ skeleton_object = self.make_metadata_outline(uid, output_mode="dict")
529
+ validation_error_msg = (
530
+ f"The stored metadata conflicts with the assigned {project.type} template "
531
+ f"with UID '{project.template_uid}'"
532
+ )
533
+ else:
534
+ klass, metadata_type, uid = self._get_metadata_class_and_type_and_UID(project.type)
535
+ skeleton_object = self.make_metadata_outline(metadata_type, output_mode="dict")
536
+ validation_error_msg = f"The stored metadata conflicts with the default {project.type} template"
537
+ if uid is not None:
538
+ validation_error_msg += f" with UID '{uid}'"
539
+
540
+ #### if unknown metadata type we should raise a warning and fall back to default template?
541
+ #### No - put the user in control - raise the error and tell them how to choose a new template.
542
+
543
+ if debug:
544
+ print(f"\nskeleton_object = {skeleton_object}\n")
545
+ print(f"\nproject_metadata = {project['metadata']}\n")
546
+ combined_dict = merge_dicts(
547
+ skeleton_object,
548
+ remove_empty_from_dict(project["metadata"]),
549
+ skeleton_mode=True,
550
+ )
551
+ combined_dict = standardize_keys_in_dict(combined_dict)
552
+ if debug:
553
+ print(f"combined_dict = {combined_dict}\n")
554
+ try:
555
+ metadata_object = klass.model_validate(standardize_keys_in_dict(combined_dict), strict=False)
556
+ except ValidationError as e:
557
+ raise TemplateError(
558
+ f"{validation_error_msg}, consider rerunning get_project_metadata_by_id with "
559
+ f"output_mode='dict' and template_uid='none' or changing the assigned template",
560
+ response=e,
561
+ )
562
+
563
+ return self._process_metadata_output(
564
+ metadata_object=metadata_object,
565
+ output_mode=output_mode,
566
+ filename=filename,
567
+ title=title,
568
+ simplify=simplify,
569
+ )
570
+
571
+ ####################################################################################################################
572
+ # Automatic Metadata Creation
573
+ ####################################################################################################################
574
+
575
+ def draft_metadata_from_files(
576
+ self,
577
+ openai_api_key: str,
578
+ files: List[str] | str,
579
+ output_mode: str,
580
+ metadata_type_or_template_uid: str,
581
+ metadata_producer_organization: Optional[str] = None,
582
+ # prefix: Optional[str] = "?",
583
+ filename: Optional[str] = None,
584
+ title: Optional[str] = None,
585
+ openai_model="gpt-4o",
586
+ tokenizer_model="o200k_base",
587
+ max_tokens=128_000,
588
+ ) -> Union[BaseModel, Dict, str]:
589
+ """
590
+ Automatically generate *draft* metadata for a project based on files such as questionnaires, reports, etc.
591
+
592
+ The files can be
593
+
594
+ •PDF
595
+ •PowerPoint
596
+ •Word
597
+ •Excel
598
+ •Images
599
+ •Audio
600
+ •HTML
601
+ •Text-based formats (CSV, XML)
602
+ •ZIP files
603
+
604
+ In the case of images and audio the files will first be passed to OpenAI for describing or transcribing.
605
+
606
+ Args:
607
+
608
+ openai_api_key (str): The OpenAI API key
609
+ files (List[str] | str): The path to the file or a list of paths to the files from which to base metadata.
610
+ output_mode (str): The type of output. Must be 'dict', 'pydantic' or 'excel'.
611
+ metadata_type_or_template_uid (str): The type of metadata to create or the UID of a template to use.
612
+ metadata_producer_organization (Optional[str]): The name of the organisation producing the metadata.
613
+ filename (Optional[str]): If output_mode=='excel', the path to the Excel file.
614
+ If None and output_mode=='excel', defaults to {name of metadata type}_metadata.xlsx
615
+ title (Optional[str]): If output_mode=='excel', the title for the Excel sheet.
616
+ If None and mode=='excel', defaults to '{name of metadata type} Metadata'
617
+ openai_model (str): The OpenAI model to use. Defaults to "gpt-4o". Note any model must accept a response
618
+ format (also called structured output). Usually you should leave this to the default value.
619
+ The option is provided in case OpenAI deprecated the 4o model.
620
+ tokenizer_model (str): The tokenizer model to use. Defaults to "o200k_base". Note this should be the
621
+ tokenizer corresponding to the OpenAI model used. Usually you should leave this to the default value.
622
+ The option is provided in case OpenAI deprecated the 4o model.
623
+ max_tokens (int): The maximum number of tokens to use when sending the content to OpenAI.
624
+ Defaults to 128_000, which has been the typical maximum for the 4o model.
625
+
626
+ Returns:
627
+ (Union[BaseModel, Dict, str]):
628
+ If mode == 'dict', a dictionary is returned.
629
+ If mode == 'pydantic', a pydantic model object is returned.
630
+ If mode == 'excel' then the metadata was saved to a file and the filename is returned.
631
+ """
632
+ # prefix (Optional[str]): A prefix to add to the metadata. Defaults to '?'. If None, no prefix is added.
633
+
634
+ metadata_class_no_rules, metadata_type, _ = self._get_metadata_class_and_type_and_UID(
635
+ metadata_type_or_template_uid, apply_template_rules=False
636
+ )
637
+ enc = tiktoken.get_encoding(tokenizer_model)
638
+ client = OpenAI(api_key=openai_api_key)
639
+
640
+ if metadata_producer_organization is not None:
641
+ system_prompt = (
642
+ f"You are an expert on producing {metadata_type} documentation from {metadata_producer_organization}. "
643
+ )
644
+ else:
645
+ system_prompt = f"You are an expert on producing {metadata_type} documentation. "
646
+ system_prompt += (
647
+ f"Based on the user content, write project metadata. "
648
+ f"If you are unsure about the correct metadata values, leave them blank. "
649
+ f"Do not guess. Accuracy is more important than completeness. "
650
+ f"The metadata is being produced today, {get_date_as_text()}."
651
+ )
652
+
653
+ # "You are an expert on survey microdata documentation. Based on the user content alone, write project metadata.
654
+ # If you are unsure about the correct metadata values, leave them blank. Do not guess. Accuracy is more
655
+ # important than completeness."},
656
+
657
+ messages = [
658
+ {"role": "system", "content": system_prompt},
659
+ ]
660
+
661
+ md = MarkItDown(llm_client=client, llm_model=openai_model)
662
+
663
+ if isinstance(files, str):
664
+ files = [files]
665
+
666
+ out = ""
667
+ for doc in files:
668
+ out += "################################################\n\n"
669
+ out += f"# {doc}\n\n"
670
+ out += md.convert(doc).text_content + "\n\n"
671
+ user_message = [{"role": "user", "content": out}]
672
+ num_tokens = len(enc.encode(messages[0]["content"]) + enc.encode(user_message[0]["content"]))
673
+ if num_tokens > max_tokens:
674
+ warnings.warn(
675
+ f"Caution - after importing {doc} the token count will be {num_tokens} which exceeds the maximum of"
676
+ f" {max_tokens}, truncating the content and proceeding."
677
+ )
678
+ else:
679
+ print(f"Read in {doc}, running token count is {num_tokens}")
680
+ messages += user_message
681
+
682
+ print("Sending to OpenAI, this may take a few minutes...")
683
+ completion = client.beta.chat.completions.parse(
684
+ model=openai_model,
685
+ messages=messages,
686
+ response_format=metadata_class_no_rules,
687
+ )
688
+
689
+ message = completion.choices[0].message
690
+ if not message.parsed:
691
+ raise ValueError(message.refusal)
692
+
693
+ metadata_dict = message.parsed.model_dump(exclude_none=True, exclude_unset=True)
694
+
695
+ # if prefix:
696
+ # metadata_dict = self._prepend_draft_drop_non_str(metadata_dict, prefix)
697
+ metadata_class_with_rules = self.get_metadata_class(metadata_type_or_template_uid)
698
+ metadata_with_rules = _iterated_validated_update_to_outline(metadata_class_with_rules, metadata_dict)
699
+
700
+ return self._process_metadata_output(
701
+ metadata_object=metadata_with_rules,
702
+ output_mode=output_mode,
703
+ filename=filename,
704
+ title=title,
705
+ )
706
+
707
+ def augment_metadata_from_files(
708
+ self,
709
+ input_metadata: Union[BaseModel, Dict, str],
710
+ openai_api_key: str,
711
+ files: List[str] | str,
712
+ output_mode: str,
713
+ metadata_type_or_template_uid: Optional[str] = None,
714
+ metadata_producer_organization: Optional[str] = None,
715
+ prefix: Optional[str] = None,
716
+ filename: Optional[str] = None,
717
+ title: Optional[str] = None,
718
+ openai_model="gpt-4o",
719
+ tokenizer_model="o200k_base",
720
+ max_tokens=128_000,
721
+ ) -> Union[BaseModel, Dict, str]:
722
+ if metadata_type_or_template_uid is None:
723
+ if isinstance(input_metadata, dict):
724
+ raise ValueError("metadata_type_or_template_uid must be passed when input_metadata is a dictionary")
725
+
726
+ _, _, metadata_type_or_template_uid = self._process_metadata_input(
727
+ input_metadata, metadata_type_or_template_uid
728
+ )
729
+
730
+ old_metadata = self.change_mode_or_template(
731
+ input_metadata, output_mode="dict", input_template_uid=metadata_type_or_template_uid, simplify=True
732
+ )
733
+
734
+ klass = self.get_metadata_class(metadata_type_or_template_uid)
735
+
736
+ old_metadata = _iterated_validated_update_to_outline(klass, updates=old_metadata)
737
+
738
+ old_metadata = self.change_mode_or_template(old_metadata, output_mode="dict", simplify=True)
739
+ old_metadata_md = "# Prevsiouly written metadata\n\n"
740
+ old_metadata_md += json_to_markdown(old_metadata, level=2)
741
+
742
+ with NamedTemporaryFile(delete=True, suffix=".txt") as f:
743
+ f.write(old_metadata_md.encode("utf-8"))
744
+ f.flush() # Ensure the content is written to disk
745
+ file_path = f.name
746
+
747
+ if isinstance(files, str):
748
+ files = [files]
749
+ files = [file_path] + files
750
+
751
+ new_metadata = self.draft_metadata_from_files(
752
+ openai_api_key=openai_api_key,
753
+ files=files,
754
+ metadata_type_or_template_uid=metadata_type_or_template_uid,
755
+ metadata_producer_organization=metadata_producer_organization,
756
+ output_mode="dict",
757
+ # prefix=prefix,
758
+ filename=None,
759
+ title=None,
760
+ openai_model=openai_model,
761
+ tokenizer_model=tokenizer_model,
762
+ max_tokens=max_tokens,
763
+ )
764
+
765
+ if new_metadata is None or len(new_metadata) == 0:
766
+ raise ValueError("No metadata was generated from the files.")
767
+
768
+ if prefix:
769
+ new_metadata = _prepend_draft_drop_non_str(new_metadata, prefix=prefix)
770
+
771
+ combined_metadata = merge_dicts(old_metadata, new_metadata)
772
+
773
+ metadata_object = _iterated_validated_update_to_outline(klass, updates=combined_metadata)
774
+ return self._process_metadata_output(
775
+ metadata_object=metadata_object,
776
+ output_mode=output_mode,
777
+ filename=filename,
778
+ title=title,
779
+ )
780
+
781
+ ####################################################################################################################
782
+ # Log and Update Metadata
783
+ ####################################################################################################################
784
+
785
+ def create_project_log(
786
+ self, metadata: Union[BaseModel, Dict, str], metadata_type_or_template_uid: Optional[str] = None
787
+ ) -> int:
788
+ """
789
+ Validates and logs metadata which can be a dictionary, a pydantic model or a path to an Excel spreadsheet.
790
+
791
+ Args:
792
+ metadata (dictionary or BaseModel or str):
793
+ If str, it's assumed this is a path to an appropriately formatted Excel file
794
+ metadata_type_or_template_uid (str):
795
+ If passing in a simple type then the supported metadata types are currently:
796
+ document, geospatial, image, indicator, indicators_db, microdata, resource, script, table, video
797
+ In this case we will use the default template for that metadata type.
798
+ Alternatively you can pass in the UID of a template. This is required if the metadata is a dictionary
799
+ otherwise ignored.
800
+
801
+ Returns:
802
+ int: The ID of the newly created document metadata
803
+ """
804
+ metadata, metadata_type, uid = self._process_metadata_input(metadata, metadata_type_or_template_uid)
805
+
806
+ if metadata_type == "indicator":
807
+ metadata_type = "timeseries"
808
+ elif metadata_type == "indicators_db":
809
+ metadata_type = "timeseries_db"
810
+ elif metadata_type == "microdata":
811
+ metadata_type = "survey"
812
+ post_request_pth = f"/editor/create/{metadata_type.replace('_', '-')}"
813
+ ret = self._apinterface.post_request(
814
+ pth=post_request_pth,
815
+ json=remove_empty_from_dict(metadata.model_dump(mode="json", exclude_none=True, exclude_unset=True)),
816
+ )
817
+ return ret["id"]
818
+
819
+ def update_project_log_by_id(self, id: int, new_metadata: Union[BaseModel, Dict, str]):
820
+ """
821
+ Updates the record of the metadata. If a dictionary is passed that only contains a subset of the possible keys
822
+ then the remaining values not mentioned are left as is.
823
+
824
+ Args:
825
+ id (int):
826
+ The ID of the metadata to update.
827
+ new_metadata (dictionary, BaseModel or str):
828
+ If str, it's assumed this is a path to an appropriately formatted Excel file
829
+ """
830
+ project_data = self.get_project_by_id(id)
831
+ try:
832
+ project_type = self._mm.standardize_metadata_name(project_data["type"])
833
+ except ValueError as e:
834
+ raise ValueError(
835
+ f"Unknown metadata type {project_data['type']}. The metadata type is required to "
836
+ f"set the metadata API endpoint path, therefore we cannot update the metadata."
837
+ ) from e
838
+
839
+ if (
840
+ "template_uid" in project_data
841
+ and project_data["template_uid"] is not None
842
+ and project_data["template_uid"] != ""
843
+ ):
844
+ project_uid = project_data["template_uid"]
845
+ metadata_type_or_template_uid = project_uid
846
+ else:
847
+ project_uid = None
848
+ metadata_type_or_template_uid = project_type
849
+
850
+ if not isinstance(new_metadata, dict):
851
+ if isinstance(new_metadata, BaseModel):
852
+ metadata_type = (
853
+ new_metadata._metadata_type__
854
+ if isinstance(new_metadata._metadata_type__, str)
855
+ else new_metadata._metadata_type__.default
856
+ )
857
+ uid = (
858
+ new_metadata._template_uid__
859
+ if isinstance(new_metadata._template_uid__, str)
860
+ else new_metadata._template_uid__.default
861
+ if hasattr(new_metadata._template_uid__, "default")
862
+ else None
863
+ )
864
+ else:
865
+ metadata_info = self._mm.get_metadata_type_info_from_excel_file(new_metadata)
866
+ metadata_type = metadata_info["metadata_type"]
867
+ uid = metadata_info.get("template_uid", None)
868
+ if uid is not None and project_uid != uid:
869
+ raise ValueError(
870
+ f"The template UID of the existing project is {project_uid} but the template UID "
871
+ f"of the new metadata is {uid}.\n"
872
+ f"The template of the new metadata should be changed to match the existing project "
873
+ f"uid='{project_uid}' or change the template of the existing project to match the new metadata "
874
+ f"uid='{uid}'."
875
+ )
876
+ elif uid is None and metadata_type != project_type:
877
+ raise ValueError(
878
+ f"The metadata type of the existing project is {project_type} but the metadata type "
879
+ f"of the new metadata is {metadata_type}."
880
+ )
881
+
882
+ metadata, metadata_type, uid = self._process_metadata_input(new_metadata, metadata_type_or_template_uid)
883
+
884
+ if project_type != metadata_type:
885
+ raise ValueError(
886
+ f"The metadata type of the existing project is {project_type} but the metadata type "
887
+ f"of the new metadata is {metadata_type}."
888
+ )
889
+
890
+ if uid is not None and project_uid is not None and project_uid != uid:
891
+ raise ValueError(
892
+ f"The template UID of the existing project is {project_uid} but the template UID "
893
+ f"of the new metadata is {uid}.\n"
894
+ "This could cause issues if the templates are incompatible.\n"
895
+ f"You should change the template of the new metadata to match the existing project "
896
+ f"uid='{project_uid}' or change the template of the existing project to match the new metadata "
897
+ f"uid='{uid}'."
898
+ )
899
+
900
+ metadata = remove_empty_from_dict(metadata.model_dump(mode="json", exclude_none=True, exclude_unset=True))
901
+
902
+ if metadata_type == "indicator":
903
+ metadata_type = "timeseries"
904
+ elif metadata_type == "indicators_db":
905
+ metadata_type = "timeseries_db"
906
+ elif metadata_type == "microdata":
907
+ metadata_type = "survey"
908
+ post_request_template_path = f"/editor/update/{metadata_type.replace('_', '-')}/" + "{}"
909
+ self._apinterface.post_request(
910
+ post_request_template_path,
911
+ id=id,
912
+ json=metadata,
913
+ )
914
+
915
+ def patch_update_project_log_by_id(self, id: int, op: str, path: str, value: Optional[str] = None):
916
+ """
917
+ Add, update, or remove parts of a project's metadata using a single JSON Patch operation.
918
+
919
+ "JSON Patch is a format for describing changes to a JSON document. It can be used to avoid sending a whole
920
+ document when only a part has changed." - https://jsonpatch.com/ accessed 2024-08-20
921
+
922
+ This method applies a single JSON Patch operation to update the metadata of a project specified by its ID.
923
+
924
+ Args:
925
+ id (int): The unique identifier of the project whose metadata is to be updated.
926
+ op (str): The JSON Patch operation to be applied. Valid operations are "add", "remove",
927
+ "replace", and "test".
928
+ path (str): The path in the project's metadata where the operation will be applied, using JSON
929
+ Pointer notation.
930
+ value (Optional[str]): The value to be added, replaced, or tested. Not required for "remove" operations.
931
+
932
+ Raises:
933
+ ValueError: If the provided operation, path, or value is invalid.
934
+
935
+ JSON Patch Operations:
936
+ - "add": Adds a value to the specified path. If the path already exists, the value is replaced.
937
+ - "remove": Removes the value at the specified path.
938
+ - "replace": Replaces the value at the specified path with a new value.
939
+ - "test": Tests that the value at the specified path matches a given value.
940
+
941
+ Path Notation:
942
+ - The `path` is a string that uses a slash (`/`) notation to specify the location within the JSON document.
943
+ For example, `/author` refers to the "author" field, and `/metadata/title` refers to the "title" field
944
+ inside the "metadata" object. If the path does not start with a `/`, the method will automatically
945
+ prepend it.
946
+
947
+ Usage:
948
+ ```python
949
+ metadata_editor.patch_update_project_log_by_id(id=123, op="add", path="/author", value="John Doe")
950
+ ```
951
+ """
952
+ project_data = self.get_project_by_id(id)
953
+ metadata_name = project_data["type"]
954
+ pth = f"editor/patch/{metadata_name}/"
955
+
956
+ patches = {"op": op, "path": path}
957
+ if value is not None:
958
+ patches["value"] = value
959
+ if isinstance(patches, dict):
960
+ patches = [patches]
961
+ patches = validate_json_patches(patches) # depend on serverside validation
962
+ self._apinterface.post_request(pth=pth + "{}", id=id, json={"patches": patches, "validate": False})
963
+
964
+ ####################################################################################################################
965
+ # TEMPLATES
966
+ ####################################################################################################################
967
+
968
+ def list_templates(self) -> pd.DataFrame:
969
+ """
970
+ Retrieves templates, both standard and any custom templates
971
+
972
+ Returns:
973
+ pd.DataFrame: A DataFrame containing the templates. If none are found, an empty DataFrame is returned.
974
+ """
975
+ response = self._apinterface.get_request("templates")
976
+ if "templates" not in response or len(response["templates"]) == 0:
977
+ return pd.DataFrame([], columns=["uid", "template_type", "name", "template"])
978
+ else:
979
+ templates = pd.DataFrame([v_item for _, v in response["templates"].items() for v_item in v])
980
+ if "default" in templates and "uid" in templates and "data_type" in templates:
981
+ self._default_templates = (
982
+ templates[templates.default].drop_duplicates("uid").drop_duplicates("data_type")
983
+ )
984
+
985
+ # in place replace each value of data_type with the standardised version
986
+ def standardize(row):
987
+ try:
988
+ return self._mm.standardize_metadata_name(row["data_type"])
989
+ except ValueError:
990
+ return row["data_type"]
991
+
992
+ self._default_templates["data_type"] = self._default_templates.apply(standardize, axis=1)
993
+ return templates
994
+
995
+ def get_template_by_uid(self, uid: str) -> pd.Series:
996
+ """
997
+ Retrieves given template by *UID*, not id.
998
+
999
+ Args:
1000
+ uid (str): The Unique Identifier of the template to retrieve.
1001
+
1002
+ Returns:
1003
+ pd.Series: A pandas Series containing the template details.
1004
+ """
1005
+ if uid in self._templates:
1006
+ return self._templates[uid]["template"]
1007
+ response = self._apinterface.get_request("templates/{}", uid)
1008
+ if "result" not in response:
1009
+ raise KeyError(f"No result returned.\nResponse: {response}")
1010
+
1011
+ temp = pd.Series(response["result"], name=response["result"]["name"])
1012
+ self._templates[uid] = {"template": temp, "metadata_type": self._mm.standardize_metadata_name(temp.data_type)}
1013
+ try:
1014
+ parent_schema = self._mm.metadata_class_from_name(temp.data_type)
1015
+ klass = pydantic_from_template(temp.template, parent_schema=parent_schema, uid=uid, name=temp.name)
1016
+ self._templates[uid]["class"] = klass
1017
+ finally:
1018
+ return temp
1019
+
1020
+ def change_mode_or_template(
1021
+ self,
1022
+ metadata: Union[BaseModel, Dict, str],
1023
+ output_mode: str,
1024
+ output_template_uid: Optional[str] = None,
1025
+ input_template_uid: Optional[str] = None,
1026
+ filename: Optional[str] = None,
1027
+ title: Optional[str] = None,
1028
+ simplify: Optional[bool] = None,
1029
+ ) -> Union[BaseModel, Dict, str]:
1030
+ """
1031
+ Change the mode or template of a metadata object. In terms of modes, you can convert
1032
+ - a dict to a pydantic model, or vice versa,
1033
+ - a dict to an Excel file, or vice versa,
1034
+ - a pydantic model to an Excel file, or vice versa
1035
+
1036
+ And in terms of templates, you can convert a metadata object from one template to another.
1037
+
1038
+ Args:
1039
+ metadata (Union[BaseModel, Dict, str]): The metadata to process.
1040
+ output_mode (str): The output mode. Must be 'dict', 'pydantic' or 'excel'.
1041
+ output_template_uid (Optional[str]): The UID of the new template. If None then the existing template is
1042
+ used.
1043
+ input_template_uid (Optional[str]): The UID of the input template. Required if the metadata is a dictionary.
1044
+ Ignored if metadata is a pydantic model or a path to an Excel file.
1045
+ filename (Optional[str]): If output_mode=='excel', the path to the Excel file.
1046
+ If None, defaults to {name of metadata type}_metadata.xlsx
1047
+ title (Optional[str]): If output_mode=='excel', the title for the Excel sheet.
1048
+ If None, defaults to '{name of metadata type} Metadata'
1049
+ simplify (Optional[bool]): If output_mode=='dict', then if simplify=True, only elements that were explicitly
1050
+ set with non-null, non-empty values are returned in the dictionary. Default behaviour is False
1051
+
1052
+ Returns:
1053
+ Union[BaseModel, Dict, str]: The updated metadata object.
1054
+ """
1055
+ metadata, metadata_type, uid = self._process_metadata_input(metadata, input_template_uid)
1056
+
1057
+ if not (output_template_uid is None or output_template_uid == uid):
1058
+ new_klass, new_type, _ = self._get_metadata_class_and_type_and_UID(output_template_uid)
1059
+ try:
1060
+ metadata = new_klass.model_validate(
1061
+ remove_empty_from_dict(metadata.model_dump(mode="json", exclude_none=True, exclude_unset=True)),
1062
+ strict=False,
1063
+ )
1064
+ except ValidationError as e:
1065
+ raise TemplateError(
1066
+ f"Could not coerce metadata from the {metadata_type} template with UID "
1067
+ f"'{input_template_uid}' to {new_type} template with UID '{output_template_uid}'",
1068
+ e,
1069
+ )
1070
+ if metadata_type != new_type:
1071
+ warnings.warn(
1072
+ f"The metadata type of the logged project is {metadata_type} but the metadata type "
1073
+ f"of the new template is {new_type}, which could lead to data loss."
1074
+ )
1075
+
1076
+ return self._process_metadata_output(
1077
+ metadata_object=metadata,
1078
+ output_mode=output_mode,
1079
+ filename=filename,
1080
+ title=title,
1081
+ simplify=simplify,
1082
+ )
1083
+
1084
+ ####################################################################################################################
1085
+ # EXCEL INTERFACE
1086
+ ####################################################################################################################
1087
+
1088
+ def save_metadata_to_excel(
1089
+ self,
1090
+ metadata_model: BaseModel | dict | str,
1091
+ metadata_type_or_template_uid: Optional[str] = None,
1092
+ filename: Optional[str] = None,
1093
+ title: Optional[str] = None,
1094
+ ) -> str:
1095
+ """
1096
+ Save a metadata object to an Excel file.
1097
+
1098
+ Args:
1099
+ metadata_model (BaseModel|dict|str): The pydantic object, python dictionary or path to an Excel file.
1100
+ metadata_type_or_template_uid (Optional[str]): If the metadata is a dictionary, this is the UID of the
1101
+ template to use. Ignored if metadata is a pydantic model or a path to an Excel file.
1102
+ filename (Optional[str]): The path to the output Excel file. Defaults to {name}_metadata.xlsx
1103
+ title (Optional[str]): The title for the output Excel sheet. Defaults to '{name} Metadata'
1104
+
1105
+ Returns:
1106
+ str: The path to the saved Excel file.
1107
+ """
1108
+ processed_model = self._process_metadata_input(
1109
+ metadata_model, metadata_type_or_template_uid=metadata_type_or_template_uid
1110
+ )[0]
1111
+ return self._process_metadata_output(
1112
+ metadata_object=processed_model, output_mode="excel", filename=filename, title=title
1113
+ )
1114
+
1115
+ def read_metadata_from_excel(
1116
+ self,
1117
+ filename: str,
1118
+ output_mode: str = "pydantic",
1119
+ exclude_unset=True,
1120
+ ) -> Union[BaseModel, Dict]:
1121
+ """
1122
+ Read metadata from an Excel file.
1123
+
1124
+ Args:
1125
+ filename (str): The path to the Excel file.
1126
+ mode (str): The output mode. Must be 'pydantic' or 'dict'.
1127
+ exclude_unset (bool): If mode=='dict', then if exclude_unset=True, only elements that were explicitly set
1128
+ with non-null, non-empty values are returned in the dictionary.
1129
+
1130
+ Returns:
1131
+ Union[BaseModel, Dict]: The metadata object or dictionary.
1132
+ """
1133
+ assert (
1134
+ output_mode not in EXCEL_MODES
1135
+ ), f"read_metadata_from_excel output_mode should be 'pydantic' or 'dict' but found '{output_mode}'"
1136
+ object = self._process_metadata_input(filename)[0]
1137
+ return self._process_metadata_output(object, output_mode, simplify=exclude_unset)
1138
+
1139
+ ####################################################################################################################
1140
+ # COLLECTION METHODS
1141
+ ####################################################################################################################
1142
+
1143
+ def list_collections(self) -> pd.DataFrame:
1144
+ """
1145
+ Lists all the collections associated with your API key.
1146
+
1147
+ Returns:
1148
+ pd.DataFrame: Collection information
1149
+ """
1150
+ response = self._apinterface.get_request("collections")
1151
+ if "collections" not in response or len(response["collections"]) == 0:
1152
+ return pd.DataFrame([], columns=["id", "title", "created"]).set_index("id")
1153
+ return pd.DataFrame(response["collections"]).set_index("id")
1154
+
1155
+ def get_collection_by_id(self, id: int) -> pd.Series:
1156
+ """
1157
+ Args:
1158
+ id (int): the id of the collection.
1159
+
1160
+ Returns:
1161
+ (pd.Series): a pandas series of the collection information
1162
+
1163
+ Raises:
1164
+ Exception: You don't have permission to access this project - often this means the id is incorrect
1165
+ """
1166
+ collection_data = self._apinterface.get_request("collections/{}", id=id)
1167
+ if "collection" not in collection_data:
1168
+ raise ValueError(f"API call was good but collection data missing from: {collection_data}")
1169
+ return pd.Series(collection_data["collection"])
1170
+
1171
+ def create_collection(self, title: str, description: str):
1172
+ """
1173
+ Creates a new collection with the specified title and description.
1174
+
1175
+ Args:
1176
+ title (str): The title of the collection.
1177
+ description (str): The description of the collection.
1178
+
1179
+ Returns:
1180
+ (int): The id of the newly created collection
1181
+ """
1182
+ assert title != "", "The collection must have a title but an empty string was passed"
1183
+ ret = self._apinterface.post_request("collections", json={"title": title, "description": description})
1184
+ return ret["collection"]
1185
+
1186
+ def update_collection(self, id: int, title: Optional[str] = None, description: Optional[str] = None):
1187
+ """
1188
+ Updates the specified collection with a new title and/or description.
1189
+
1190
+ Args:
1191
+ id (int): The unique identifier of the collection to update.
1192
+ title (Optional[str]): The new title of the collection. Defaults to None.
1193
+ description (Optional[str]): The new description of the collection. Defaults to None.
1194
+
1195
+ Raises:
1196
+ Assertion error if both title and description are None, since we must update one or the other.
1197
+ """
1198
+ # Is it clear this updates title/description of the collection and not the data in the collection?
1199
+ assert title is not None or description is not None, "can update title or description or both, but not neither"
1200
+ metadata = {}
1201
+ if title is not None:
1202
+ metadata["title"] = title
1203
+ if description is not None:
1204
+ metadata["description"] = description
1205
+ self._apinterface.post_request("collections/update/{}", id=id, json=metadata)
1206
+
1207
+ def count_projects_in_collection(self, collection: int) -> int:
1208
+ """
1209
+ Count the number of projects you have access to
1210
+
1211
+ Args:
1212
+ collection (int):
1213
+ The id of the collection.
1214
+
1215
+ Returns:
1216
+ int: The number of projects
1217
+ """
1218
+ params = {"offset": 0, "limit": 1}
1219
+ response = self._apinterface.get_request("editor?collection={}", id=collection, params=params)
1220
+ return response["total"]
1221
+
1222
+ def list_projects_in_collection(
1223
+ self,
1224
+ collection: int,
1225
+ limit: Union[int, str],
1226
+ keywords: Optional[Union[str, List[str]]] = None,
1227
+ offset: int = 0,
1228
+ sort_by: Optional[str] = None,
1229
+ ) -> pd.DataFrame:
1230
+ """
1231
+ Retrieve projects that have been added to the given collection.
1232
+
1233
+ Args:
1234
+
1235
+ collection (int):
1236
+ The id of the collection.
1237
+ limit (int):
1238
+ The maximum number of projects to return. If limit='All' then all records are retrieved.
1239
+ keyword (optional str):
1240
+ filter projects based on whether the project title or idno contains this keyword
1241
+ offset (int):
1242
+ Offset for pagination e.g. 10 to skip first 10 records. Default is 0.
1243
+ sort_by (optional str):
1244
+ valid values: "title_asc", "title_desc", "updated_asc", "updated_desc".
1245
+
1246
+ Returns:
1247
+
1248
+ pd.DataFrame:
1249
+ Information on the projects in the collection, such as id, idno, title and type.
1250
+ """
1251
+
1252
+ if isinstance(limit, str):
1253
+ assert limit.lower() == "all", f"Expected limit to be 'all' or a positive integer but got '{limit}'"
1254
+ new_offset = offset
1255
+ new_limit = 500
1256
+ dfs = []
1257
+ while True:
1258
+ df = self.list_projects_in_collection(
1259
+ collection=collection, keywords=keywords, offset=new_offset, limit=new_limit, sort_by=sort_by
1260
+ )
1261
+ dfs.append(df)
1262
+ if len(df) < new_limit:
1263
+ break
1264
+ new_offset += new_limit
1265
+ return pd.concat(dfs)
1266
+
1267
+ params = {"limit": limit, "offset": offset}
1268
+ if keywords is not None:
1269
+ if not isinstance(keywords, str) and isinstance(keywords, Iterable):
1270
+ keywords = "%".join(keywords)
1271
+ keywords = keywords.replace(" ", "%")
1272
+ params["keywords"] = keywords
1273
+ if sort_by is not None:
1274
+ sort_by = sort_by.lower()
1275
+ valid_sort_by = ["title_asc", "title_desc", "updated_asc", "updated_desc"]
1276
+ assert sort_by in valid_sort_by, f"{sort_by} not valid, must be one of {valid_sort_by}"
1277
+ params["sort_by"] = sort_by
1278
+ ret = self._apinterface.get_request("editor?collection={}", id=collection, params=params)
1279
+ if len(ret["projects"]) == 0:
1280
+ return pd.DataFrame([], columns=["id", "type", "idno", "title"]).set_index("id")
1281
+ return pd.DataFrame(ret["projects"]).set_index("id")
1282
+
1283
+ def add_projects_to_collection(
1284
+ self, collection: Union[int, List[int]], id_format: str, projects: Union[int, List[int], str, List[str]]
1285
+ ):
1286
+ """
1287
+ Adds project or projects to specified collection or collections.
1288
+
1289
+ This method associates one or more projects with one or more collections. The `collection`
1290
+ parameter can be a single collection ID or a list of collection IDs. The `projects` parameter
1291
+ can be a single project ID, a single project ID number (idno), a list of project IDs, or a list
1292
+ of project idnos. The `id_format` parameter specifies whether the project identifiers
1293
+ are in the form of IDs (integer) or idno (string).
1294
+
1295
+ Args:
1296
+
1297
+ collection : Union[int, List[int]]
1298
+ A single collection ID or a list of collection IDs to which projects should be added.
1299
+ id_format : str
1300
+ Specifies the format of the project identifiers. Must be either 'id' or 'idno'.
1301
+ projects : Union[int, List[int], str, List[str]]
1302
+ A single project ID, a single project idno, a list of project IDs, or a list of
1303
+ project idnos to be added to the specified collection(s).
1304
+
1305
+ Raises:
1306
+
1307
+ AssertionError
1308
+ If `id_format` is not 'id' or 'idno'.
1309
+
1310
+ AssertionError
1311
+ If a project ID is not an integer when `id_format` is 'id'.
1312
+
1313
+ AssertionError
1314
+ If a project ID number is not a string when `id_format` is 'idno'.
1315
+
1316
+ Example:
1317
+ >>> client.add_projects_to_collection(collection=1, id_format='id', projects=[101, 102])
1318
+ >>> client.add_projects_to_collection(collection=[1, 2], id_format='idno', projects=['A101', 'A102'])
1319
+ """
1320
+ assert id_format.lower() in ["id", "idno"], f"id_format must be either 'id' or 'idno' but got '{id_format}'"
1321
+ if not isinstance(collection, Iterable) or isinstance(collection, str):
1322
+ collection = [collection]
1323
+
1324
+ if not isinstance(projects, Iterable) or isinstance(projects, str):
1325
+ projects = [projects]
1326
+ if id_format.lower() == "id":
1327
+ for proj in projects:
1328
+ assert isinstance(proj, int), f"When passing ids the projects must be ints, but found: {proj}"
1329
+ else:
1330
+ for proj in projects:
1331
+ assert isinstance(proj, str), f"While passing idnos the projects must be strs but found: {proj}"
1332
+ self._apinterface.post_request(
1333
+ "collections/add_projects",
1334
+ json={"collections": collection, "id_format": id_format, "projects": projects},
1335
+ )
1336
+
1337
+ def remove_projects_from_collection(
1338
+ self, collection: Union[int, List[int]], id_format: str, projects: Union[int, List[int], str, List[str]]
1339
+ ):
1340
+ """
1341
+ Removes project or projects from specified collection or collections.
1342
+
1343
+ This method dissociates one or more projects from one or more collections. The `collection`
1344
+ parameter can be a single collection ID or a list of collection IDs. The `projects` parameter
1345
+ can be a single project ID, a single project ID number (idno), a list of project IDs, or a list
1346
+ of project idnos. The `id_format` parameter specifies whether the project identifiers
1347
+ are in the form of IDs or idnos.
1348
+
1349
+ Note that if the project(s) are not in the collection there is no error raised since either way the project
1350
+ will not be in the collection after this function executes
1351
+
1352
+ Args
1353
+
1354
+ collection : Union[int, List[int]]
1355
+ A single collection ID or a list of collection IDs to which projects should be removed.
1356
+
1357
+ id_format : str
1358
+ Specifies the format of the project identifiers. Must be either 'id' or 'idno'.
1359
+
1360
+ projects : Union[int, List[int], str, List[str]]
1361
+ A single project ID, a single project idno, a list of project IDs, or a list of
1362
+ project idnos to be removed from the specified collection(s).
1363
+
1364
+ Raises:
1365
+
1366
+ AssertionError
1367
+ If `id_format` is not 'id' or 'idno'.
1368
+
1369
+ AssertionError
1370
+ If a project ID is not an integer when `id_format` is 'id'.
1371
+
1372
+ AssertionError
1373
+ If a project ID number is not a string when `id_format` is 'idno'.
1374
+
1375
+
1376
+ Example:
1377
+
1378
+ >>> client.remove_projects_from_collection(collection=1, id_format='id', projects=[101, 102])
1379
+ >>> client.remove_projects_from_collection(collection=[1, 2], id_format='idno', projects=['A101', 'A102'])
1380
+
1381
+ """
1382
+ assert id_format.lower() in ["id", "idno"], f"id_format must be either 'id' or 'idno' but got '{id_format}'"
1383
+ if not isinstance(projects, Iterable) or isinstance(projects, str):
1384
+ projects = [projects]
1385
+ if id_format.lower() == "id":
1386
+ for proj in projects:
1387
+ assert isinstance(proj, int), f"When passing ids the projects must be ints, but found: {proj}"
1388
+ else:
1389
+ for proj in projects:
1390
+ assert isinstance(proj, str), f"While passing idnos the projects must be strs but found: {proj}"
1391
+
1392
+ self._apinterface.post_request(
1393
+ "collections/remove_projects",
1394
+ json={"collections": collection, "id_format": id_format, "projects": projects},
1395
+ )
1396
+
1397
+ def set_template_for_collection(self, collection_id: int, template_uid: str):
1398
+ """
1399
+ Set the specified template to be used for all metadata of its type (for example microdata, indicator, or
1400
+ document metadata) in the collection.
1401
+
1402
+ Args:
1403
+ collection_id (int): the id of the collection.
1404
+ template_uid (str): The Unique Identifier of the template to apply to the collection.
1405
+ """
1406
+ # check collection exists
1407
+ self.get_collection_by_id(collection_id)
1408
+ template = self.get_template_by_uid(uid=template_uid)
1409
+ template_type = template["data_type"]
1410
+ self._apinterface.post_request(
1411
+ "collections/template",
1412
+ json={"collection_id": collection_id, "template_uid": template_uid, "project_type": template_type},
1413
+ )
1414
+
1415
+ ####################################################################################################################
1416
+ # RESOURCE METHODS
1417
+ ####################################################################################################################
1418
+
1419
+ def get_resources_by_id(self, id: int) -> pd.DataFrame:
1420
+ """
1421
+ List documentation (Reports, Questionnaires, Tables, etc.) for a project
1422
+
1423
+ Args:
1424
+ id (int): project id
1425
+
1426
+ Returns:
1427
+ (pd.DataFrame): resource information including titles, subtitles, authors and file formats
1428
+ """
1429
+ response = self._apinterface.get_request("resources/{}", id=id)
1430
+ if "resources" in response and len(response["resources"]) > 0:
1431
+ return pd.DataFrame(response["resources"])
1432
+ else:
1433
+ return pd.DataFrame(columns=["id", "sid", "dctype", "title", "subtitle", "author", "filename", "dcformat"])
1434
+
1435
+ def log_resource(
1436
+ self,
1437
+ project_id: int,
1438
+ dctype: str,
1439
+ title: str,
1440
+ author: Optional[str] = None,
1441
+ dcdate: Optional[str] = None,
1442
+ country: Optional[str] = None,
1443
+ language: Optional[str] = None,
1444
+ contributor: Optional[str] = None,
1445
+ publisher: Optional[str] = None,
1446
+ rights: Optional[str] = None,
1447
+ description: Optional[str] = None,
1448
+ abstract: Optional[str] = None,
1449
+ toc: Optional[str] = None,
1450
+ filename: Optional[str] = None,
1451
+ ) -> int:
1452
+ """
1453
+ Log a new resource associated with a project (Reports, Questionnaires, Tables, etc.)
1454
+
1455
+ If filename is provided then the file is uploaded and logged.
1456
+
1457
+ Args:
1458
+ project_id (int): The project id the resource is associated with.
1459
+ dctype (str): The type of the resource (e.g., 'txt', 'pdf').
1460
+ title (str): The title of the resource.
1461
+ author (Optional[str]): The author of the resource. Defaults to None.
1462
+ dcdate (Optional[str]): The date of the resource. Defaults to None.
1463
+ country (Optional[str]): The country associated with the resource. Defaults to None.
1464
+ language (Optional[str]): The language of the resource. Defaults to None.
1465
+ contributor (Optional[str]): The contributor to the resource. Defaults to None.
1466
+ publisher (Optional[str]): The publisher of the resource. Defaults to None.
1467
+ rights (Optional[str]): The rights associated with the resource. Defaults to None.
1468
+ description (Optional[str]): A brief description of the resource. Defaults to None.
1469
+ abstract (Optional[str]): An abstract summarizing the resource. Defaults to None.
1470
+ toc (Optional[str]): The table of contents of the resource. Defaults to None.
1471
+ filename (Optional[str]): The filename of the resource which will be uploaded. Defaults to None.
1472
+
1473
+ Returns:
1474
+ int: The unique identifier of the logged resource.
1475
+ """
1476
+ data = {
1477
+ "dctype": dctype,
1478
+ "title": title,
1479
+ "author": author,
1480
+ "dcdate": dcdate,
1481
+ "country": country,
1482
+ "language": language,
1483
+ "contributor": contributor,
1484
+ "publisher": publisher,
1485
+ "rights": rights,
1486
+ "description": description,
1487
+ "abstract": abstract,
1488
+ "toc": toc,
1489
+ }
1490
+
1491
+ if filename is not None:
1492
+ files = {"file": open(filename, "rb")}
1493
+ else:
1494
+ files = None
1495
+
1496
+ response = self._apinterface.post_request(pth="resources/{}", id=project_id, data=data, files=files)
1497
+ return int(response["resource"]["id"])
1498
+
1499
+ def update_resource(
1500
+ self,
1501
+ project_id: int,
1502
+ resource_id: int,
1503
+ dctype: str,
1504
+ title: str,
1505
+ author: Optional[str] = None,
1506
+ dcdate: Optional[str] = None,
1507
+ country: Optional[str] = None,
1508
+ language: Optional[str] = None,
1509
+ contributor: Optional[str] = None,
1510
+ publisher: Optional[str] = None,
1511
+ rights: Optional[str] = None,
1512
+ description: Optional[str] = None,
1513
+ abstract: Optional[str] = None,
1514
+ toc: Optional[str] = None,
1515
+ filename: Optional[str] = None,
1516
+ ) -> int:
1517
+ """
1518
+ Update the log of resource associated with a project (Reports, Questionnaires, Tables, etc.)
1519
+
1520
+ If filename is provided then the file is uploaded and logged.
1521
+
1522
+ Args:
1523
+ project_id (int): The project id the resource is associated with.
1524
+ resource_id (int): The id of the resource.
1525
+ dctype (str): The type of the resource (e.g., 'txt', 'pdf').
1526
+ title (str): The title of the resource.
1527
+ author (Optional[str]): The author of the resource. Defaults to None.
1528
+ dcdate (Optional[str]): The date of the resource. Defaults to None.
1529
+ country (Optional[str]): The country associated with the resource. Defaults to None.
1530
+ language (Optional[str]): The language of the resource. Defaults to None.
1531
+ contributor (Optional[str]): The contributor to the resource. Defaults to None.
1532
+ publisher (Optional[str]): The publisher of the resource. Defaults to None.
1533
+ rights (Optional[str]): The rights associated with the resource. Defaults to None.
1534
+ description (Optional[str]): A brief description of the resource. Defaults to None.
1535
+ abstract (Optional[str]): An abstract summarizing the resource. Defaults to None.
1536
+ toc (Optional[str]): The table of contents of the resource. Defaults to None.
1537
+ filename (Optional[str]): The filename of the resource which will be uploaded. Defaults to None.
1538
+ """
1539
+
1540
+ data = {
1541
+ "dctype": dctype,
1542
+ "title": title,
1543
+ "author": author,
1544
+ "dcdate": dcdate,
1545
+ "country": country,
1546
+ "language": language,
1547
+ "contributor": contributor,
1548
+ "publisher": publisher,
1549
+ "rights": rights,
1550
+ "description": description,
1551
+ "abstract": abstract,
1552
+ "toc": toc,
1553
+ }
1554
+
1555
+ if filename is not None:
1556
+ files = {"file": open(filename, "rb")}
1557
+ else:
1558
+ files = None
1559
+
1560
+ self._apinterface.post_request(pth="resources/{}/" + f"{resource_id}", id=project_id, data=data, files=files)
1561
+
1562
+ ####################################################################################################################
1563
+ # DELETE METHODS
1564
+ ####################################################################################################################
1565
+
1566
+ def delete_project_by_id(self, id: int):
1567
+ """
1568
+ If the project exists then delete it and check it was deleted.
1569
+
1570
+ Args:
1571
+ id (int): the id of the project, not to be confused with the idno.
1572
+
1573
+ Raises:
1574
+ DeleteNotAppliedError: This can be the result of system admins blocking data deletion
1575
+ """
1576
+ pth = "editor/delete/{}"
1577
+ self._delete_by_id(pth=pth, id=id, checker_fn=self.get_project_by_id)
1578
+
1579
+ def delete_collection_by_id(self, id: int):
1580
+ """
1581
+ If the collection exists then deletes it and check it was deleted.
1582
+
1583
+ Args:
1584
+ id (int): the id of the colection.
1585
+
1586
+ Raises:
1587
+ DeleteNotAppliedError: This can be the result of system admins blocking data deletion
1588
+ """
1589
+ pth = "collections/delete/{}"
1590
+ self._delete_by_id(pth=pth, id=id, checker_fn=self.get_collection_by_id)
1591
+
1592
+ def delete_resource_by_id(self, project_id, resource_id):
1593
+ """
1594
+ If the resource exists then deletes it and check it was deleted.
1595
+
1596
+ Args:
1597
+ project_id (int): The project id the resource is associated with.
1598
+ resource_id (int): The id of the resource.
1599
+
1600
+ Raises:
1601
+ DeleteNotAppliedError: This can be the result of system admins blocking data deletion
1602
+ """
1603
+
1604
+ def check_exists(resource_id):
1605
+ resource_ids = self.get_resources_by_id(project_id)["id"].apply(int).values
1606
+ if resource_id not in resource_ids:
1607
+ raise IndexError(f"{resource_id} not in list of resources for project id {project_id}")
1608
+
1609
+ pth = f"resources/delete/{project_id}/" + "{}"
1610
+ self._delete_by_id(pth, resource_id, checker_fn=check_exists)
1611
+
1612
+ def delete_template(self, uid: str):
1613
+ """
1614
+ Deletes the given template.
1615
+
1616
+ Args:
1617
+ uid (str): The Unique Identifier of the template to delete.
1618
+
1619
+ """
1620
+ if uid in self._templates:
1621
+ self._templates.pop(uid, None)
1622
+ self._delete_by_id("templates/delete/{}", id=uid, checker_fn=self.get_template_by_uid)
1623
+
1624
+ def _delete_by_id(self, pth: str, id: Union[int, str], checker_fn: Callable[[int], pd.Series]):
1625
+ """
1626
+ Internal, generic method for deleting either collections or projects or similar.
1627
+ """
1628
+ # # first check that the project/collection is there to be deleted
1629
+ # checker_fn(id)
1630
+ try:
1631
+ self._apinterface.post_request(pth=pth, id=id)
1632
+ except JSONDecodeError:
1633
+ pass
1634
+
1635
+ # check that the entity was deleted
1636
+ try:
1637
+ checker_fn(id)
1638
+ except (PermissionError, JSONDecodeError, IndexError, HTTPError):
1639
+ pass # evidently the entity was deleted because now it can't be found
1640
+ else:
1641
+ raise DeleteNotAppliedError()