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.
- pymetadataeditor/__init__.py +1 -0
- pymetadataeditor/interface.py +1641 -0
- pymetadataeditor/llm_helpers.py +145 -0
- pymetadataeditor/requester.py +160 -0
- pymetadataeditor/templates.py +617 -0
- pymetadataeditor/utils.py +262 -0
- pymetadataeditor-0.1.0.dist-info/LICENSE +21 -0
- pymetadataeditor-0.1.0.dist-info/METADATA +333 -0
- pymetadataeditor-0.1.0.dist-info/RECORD +10 -0
- pymetadataeditor-0.1.0.dist-info/WHEEL +4 -0
|
@@ -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()
|