pymetadataeditor 0.2.0__tar.gz → 0.3.1__tar.gz
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-0.2.0 → pymetadataeditor-0.3.1}/PKG-INFO +4 -4
- {pymetadataeditor-0.2.0 → pymetadataeditor-0.3.1}/README.md +2 -2
- {pymetadataeditor-0.2.0 → pymetadataeditor-0.3.1}/pymetadataeditor/interface.py +214 -38
- pymetadataeditor-0.3.1/pymetadataeditor/llm_helpers.py +257 -0
- {pymetadataeditor-0.2.0 → pymetadataeditor-0.3.1}/pymetadataeditor/requester.py +27 -12
- {pymetadataeditor-0.2.0 → pymetadataeditor-0.3.1}/pymetadataeditor/templates.py +17 -2
- {pymetadataeditor-0.2.0 → pymetadataeditor-0.3.1}/pyproject.toml +4 -2
- pymetadataeditor-0.2.0/pymetadataeditor/llm_helpers.py +0 -145
- {pymetadataeditor-0.2.0 → pymetadataeditor-0.3.1}/LICENSE +0 -0
- {pymetadataeditor-0.2.0 → pymetadataeditor-0.3.1}/pymetadataeditor/__init__.py +0 -0
- {pymetadataeditor-0.2.0 → pymetadataeditor-0.3.1}/pymetadataeditor/utils.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.1
|
|
2
2
|
Name: pymetadataeditor
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.1
|
|
4
4
|
Summary: Metadata Editor client for python
|
|
5
5
|
License: MIT
|
|
6
6
|
Author: Gordon Blackadder
|
|
@@ -11,7 +11,7 @@ Classifier: Programming Language :: Python :: 3
|
|
|
11
11
|
Classifier: Programming Language :: Python :: 3.11
|
|
12
12
|
Classifier: Programming Language :: Python :: 3.12
|
|
13
13
|
Requires-Dist: markitdown (>=0.0.1a3,<0.0.2)
|
|
14
|
-
Requires-Dist: metadataschemas (>=0.1.
|
|
14
|
+
Requires-Dist: metadataschemas (>=0.1.3,<0.2.0)
|
|
15
15
|
Requires-Dist: openai (>=1.59.4,<2.0.0)
|
|
16
16
|
Requires-Dist: pandas (>=2.2.2,<3.0.0)
|
|
17
17
|
Requires-Dist: pydantic (>=2.7.4,<3.0.0)
|
|
@@ -317,9 +317,9 @@ If you are running on Windows and see errors about numpy installation errors the
|
|
|
317
317
|
|
|
318
318
|
### Markdown API Documentation
|
|
319
319
|
|
|
320
|
-
Create documentation for the pyMetadataEditor class by running:
|
|
320
|
+
Create documentation for the pyMetadataEditor class by running the following command:
|
|
321
321
|
```
|
|
322
|
-
|
|
322
|
+
python make_docs.py
|
|
323
323
|
```
|
|
324
324
|
|
|
325
325
|
## Notes
|
|
@@ -295,9 +295,9 @@ If you are running on Windows and see errors about numpy installation errors the
|
|
|
295
295
|
|
|
296
296
|
### Markdown API Documentation
|
|
297
297
|
|
|
298
|
-
Create documentation for the pyMetadataEditor class by running:
|
|
298
|
+
Create documentation for the pyMetadataEditor class by running the following command:
|
|
299
299
|
```
|
|
300
|
-
|
|
300
|
+
python make_docs.py
|
|
301
301
|
```
|
|
302
302
|
|
|
303
303
|
## Notes
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
"""The interface between pyMetadataEditor and the Metadata Editor API."""
|
|
2
2
|
|
|
3
3
|
import warnings
|
|
4
|
+
from io import BufferedReader
|
|
4
5
|
from json import JSONDecodeError
|
|
5
6
|
from pathlib import Path
|
|
6
7
|
from tempfile import NamedTemporaryFile
|
|
@@ -22,6 +23,7 @@ from urllib3.exceptions import InsecureRequestWarning
|
|
|
22
23
|
from pymetadataeditor.llm_helpers import (
|
|
23
24
|
_iterated_validated_update_to_outline,
|
|
24
25
|
_prepend_draft_drop_non_str,
|
|
26
|
+
call_per_field,
|
|
25
27
|
get_date_as_text,
|
|
26
28
|
json_to_markdown,
|
|
27
29
|
)
|
|
@@ -133,7 +135,7 @@ class MetadataEditor:
|
|
|
133
135
|
|
|
134
136
|
Args:
|
|
135
137
|
api_url (str): the URL typically looks like 'https://<name_of_your_metadata_database>.org/index.php/api'
|
|
136
|
-
api_key (str): typically this is created through the web interface of the metadata system
|
|
138
|
+
api_key (str): typically this is created through the web interface of the metadata system.
|
|
137
139
|
allow_http (bool): whether to allow calls to the metadata system when the URL begins "http" instead of the
|
|
138
140
|
more secure "https". Defaults to False.
|
|
139
141
|
verify_ssl (bool): Although it is good practice for API requests to verify SSL, some systems do not allow
|
|
@@ -227,6 +229,13 @@ class MetadataEditor:
|
|
|
227
229
|
projects = pd.DataFrame.from_dict(projects).set_index("id")
|
|
228
230
|
except KeyError: # is this the best way to cope with times when there are no projects?
|
|
229
231
|
return pd.DataFrame(columns=["id", "created"]).set_index("id")
|
|
232
|
+
|
|
233
|
+
try:
|
|
234
|
+
new_index = projects.index.astype(int)
|
|
235
|
+
except ValueError:
|
|
236
|
+
pass
|
|
237
|
+
else:
|
|
238
|
+
projects.index = new_index
|
|
230
239
|
return projects
|
|
231
240
|
|
|
232
241
|
def get_project_by_id(self, id: int) -> pd.Series:
|
|
@@ -246,6 +255,45 @@ class MetadataEditor:
|
|
|
246
255
|
response = self._apinterface.get_request(get_project_template, id=id)
|
|
247
256
|
return pd.Series(response["project"])
|
|
248
257
|
|
|
258
|
+
####################################################################################################################
|
|
259
|
+
# Generic API requests
|
|
260
|
+
####################################################################################################################
|
|
261
|
+
|
|
262
|
+
def generic_api_request(
|
|
263
|
+
self,
|
|
264
|
+
method: str,
|
|
265
|
+
endpoint: str,
|
|
266
|
+
params: Optional[Dict] = None,
|
|
267
|
+
data: Optional[Dict] = None,
|
|
268
|
+
json: Optional[Dict] = None,
|
|
269
|
+
files: Optional[Dict[str, BufferedReader]] = None,
|
|
270
|
+
) -> Dict:
|
|
271
|
+
"""Make a generic API request to the Metadata Editor API.
|
|
272
|
+
|
|
273
|
+
It's generally better to use the specific functions such as list_projects, create_project_log etc. but this
|
|
274
|
+
function is provided for flexibility and to allow for future changes in the API.
|
|
275
|
+
|
|
276
|
+
Args:
|
|
277
|
+
method (str): Either 'POST' or 'GET'
|
|
278
|
+
endpoint (str): The path appended to the API_URL to which a GET or POST request is sent.
|
|
279
|
+
params (optional dict): additional parameters to send with a GET request.
|
|
280
|
+
data (optional dict): The data to send with a POST request.
|
|
281
|
+
json (optional dict): The JSON data to send with a POST request.
|
|
282
|
+
files (optional dict[str, BufferedReader]): The files to send with a POST request in the form
|
|
283
|
+
```{"filename": open(filename, "rb")}```.
|
|
284
|
+
|
|
285
|
+
Returns:
|
|
286
|
+
Dict: The response from the API as a dictionary.
|
|
287
|
+
"""
|
|
288
|
+
return self._apinterface._request(
|
|
289
|
+
method=method,
|
|
290
|
+
pth=endpoint,
|
|
291
|
+
params=params,
|
|
292
|
+
data=data,
|
|
293
|
+
json=json,
|
|
294
|
+
files=files,
|
|
295
|
+
)
|
|
296
|
+
|
|
249
297
|
####################################################################################################################
|
|
250
298
|
# METADATA Input and Output
|
|
251
299
|
####################################################################################################################
|
|
@@ -379,12 +427,21 @@ class MetadataEditor:
|
|
|
379
427
|
else:
|
|
380
428
|
metadata_type = self._templates[template_uid]["metadata_type"]
|
|
381
429
|
if "class" not in self._templates[template_uid] or apply_template_rules is False:
|
|
382
|
-
|
|
430
|
+
try:
|
|
431
|
+
parent_schema = self._mm.metadata_class_from_name(metadata_type)
|
|
432
|
+
except ValueError:
|
|
433
|
+
warnings.warn(
|
|
434
|
+
f"Could not find a metadata class for template type '{metadata_type}', UID '{template_uid}'. "
|
|
435
|
+
f"Building a class from the template without a parent schema."
|
|
436
|
+
)
|
|
437
|
+
parent_schema = SchemaBaseModel
|
|
438
|
+
|
|
383
439
|
klass = pydantic_from_template(
|
|
384
440
|
temp.template,
|
|
385
441
|
parent_schema=parent_schema,
|
|
386
442
|
uid=template_uid,
|
|
387
443
|
name=temp.name,
|
|
444
|
+
metadata_type=metadata_type,
|
|
388
445
|
apply_rules=apply_template_rules,
|
|
389
446
|
)
|
|
390
447
|
if apply_template_rules:
|
|
@@ -644,7 +701,7 @@ class MetadataEditor:
|
|
|
644
701
|
llm_model_name="gpt-4o",
|
|
645
702
|
tokenizer_model="o200k_base",
|
|
646
703
|
max_tokens=128_000,
|
|
647
|
-
|
|
704
|
+
llm_base_url: Optional[str] = None,
|
|
648
705
|
azure_llm_base_url: Optional[str] = None,
|
|
649
706
|
) -> Union[BaseModel, Dict, str]:
|
|
650
707
|
"""Automatically generate *draft* metadata for a project based on local files or web pages.
|
|
@@ -681,11 +738,11 @@ class MetadataEditor:
|
|
|
681
738
|
The option is provided in case OpenAI deprecated the 4o model.
|
|
682
739
|
max_tokens (int): The maximum number of tokens to use when sending the content to OpenAI.
|
|
683
740
|
Defaults to 128_000, which has been the typical maximum for the 4o model.
|
|
684
|
-
|
|
741
|
+
llm_base_url (Optional[str]): The base URL for the LLM API. If None, the default URL is used which
|
|
685
742
|
sends the request to OpenAI. This argument is ignored if an azure_llm_base_url is provided.
|
|
686
743
|
azure_llm_base_url (Optional[str]): The base URL for the Azure LLM API. Typically used when an organization
|
|
687
744
|
has its own deployment of an LLM model, possibly for privacy reasons. The Azure endpoint will be used
|
|
688
|
-
even if a
|
|
745
|
+
even if a llm_base_url is provided. If None, then the llm_base_url endpoint is used. If
|
|
689
746
|
that's also None, then OpenAI is used.
|
|
690
747
|
|
|
691
748
|
Returns:
|
|
@@ -706,6 +763,19 @@ class MetadataEditor:
|
|
|
706
763
|
title="My Metadata",
|
|
707
764
|
)
|
|
708
765
|
|
|
766
|
+
# Example with a local model running on Ollama:
|
|
767
|
+
me.draft_metadata_from_files(
|
|
768
|
+
llm_api_key="ollama", # pragma: allowlist secret
|
|
769
|
+
files=["/path/to/word_file1.docx", "http://www.example.com/report.pdf"],
|
|
770
|
+
output_mode="pydantic",
|
|
771
|
+
metadata_type_or_template_uid="indicator",
|
|
772
|
+
metadata_producer_organization="My Organization",
|
|
773
|
+
filename="output.xlsx",
|
|
774
|
+
title="My Metadata",
|
|
775
|
+
llm_base_url="http://localhost:11434/v1/",
|
|
776
|
+
llm_model_name="llama3.1"
|
|
777
|
+
)
|
|
778
|
+
|
|
709
779
|
# Example with an Azure instance of a Large Language Model:
|
|
710
780
|
me.draft_metadata_from_files(
|
|
711
781
|
llm_api_key="...",
|
|
@@ -726,7 +796,7 @@ class MetadataEditor:
|
|
|
726
796
|
)
|
|
727
797
|
enc = tiktoken.get_encoding(tokenizer_model)
|
|
728
798
|
if azure_llm_base_url is None:
|
|
729
|
-
client = OpenAI(api_key=llm_api_key, base_url=
|
|
799
|
+
client = OpenAI(api_key=llm_api_key, base_url=llm_base_url)
|
|
730
800
|
else:
|
|
731
801
|
client = AzureOpenAI(api_key=llm_api_key, base_url=azure_llm_base_url, api_version="2024-10-01")
|
|
732
802
|
|
|
@@ -765,29 +835,29 @@ class MetadataEditor:
|
|
|
765
835
|
f" {max_tokens}, truncating the content and proceeding."
|
|
766
836
|
)
|
|
767
837
|
else:
|
|
768
|
-
print(f"
|
|
838
|
+
print(f"Reading {doc}, running token count is {num_tokens}")
|
|
769
839
|
messages += user_message
|
|
770
840
|
|
|
771
841
|
endpoint_name = (
|
|
772
|
-
azure_llm_base_url
|
|
773
|
-
if azure_llm_base_url is not None
|
|
774
|
-
else "OpenAI"
|
|
775
|
-
if public_llm_base_url is None
|
|
776
|
-
else public_llm_base_url
|
|
842
|
+
azure_llm_base_url if azure_llm_base_url is not None else "OpenAI" if llm_base_url is None else llm_base_url
|
|
777
843
|
)
|
|
778
844
|
print(f"Sending to {endpoint_name}, this may take a few minutes...")
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
message = completion.choices[0].message
|
|
786
|
-
if not message.parsed:
|
|
787
|
-
raise ValueError(message.refusal)
|
|
845
|
+
try:
|
|
846
|
+
completion = client.beta.chat.completions.parse(
|
|
847
|
+
model=llm_model_name,
|
|
848
|
+
messages=messages,
|
|
849
|
+
response_format=metadata_class_no_rules,
|
|
850
|
+
)
|
|
788
851
|
|
|
789
|
-
|
|
852
|
+
message = completion.choices[0].message
|
|
853
|
+
if not message.parsed:
|
|
854
|
+
raise ValueError(message.refusal)
|
|
790
855
|
|
|
856
|
+
metadata_dict = message.parsed.model_dump(exclude_none=True, exclude_unset=True)
|
|
857
|
+
except ValidationError:
|
|
858
|
+
metadata_dict = call_per_field(metadata_class_no_rules, client, llm_model_name, messages).model_dump(
|
|
859
|
+
exclude_none=True, exclude_unset=True
|
|
860
|
+
)
|
|
791
861
|
# if prefix:
|
|
792
862
|
# metadata_dict = self._prepend_draft_drop_non_str(metadata_dict, prefix)
|
|
793
863
|
metadata_class_with_rules = self.get_metadata_class(metadata_type_or_template_uid)
|
|
@@ -814,7 +884,7 @@ class MetadataEditor:
|
|
|
814
884
|
llm_model_name="gpt-4o",
|
|
815
885
|
tokenizer_model="o200k_base",
|
|
816
886
|
max_tokens=128_000,
|
|
817
|
-
|
|
887
|
+
llm_base_url: Optional[str] = None,
|
|
818
888
|
azure_llm_base_url: Optional[str] = None,
|
|
819
889
|
) -> Union[BaseModel, Dict, str]:
|
|
820
890
|
"""Augment existing metadata with information from files or web pages.
|
|
@@ -843,11 +913,11 @@ class MetadataEditor:
|
|
|
843
913
|
The option is provided in case OpenAI deprecated the 4o model.
|
|
844
914
|
max_tokens (int): The maximum number of tokens to use when sending the content to OpenAI.
|
|
845
915
|
Defaults to 128_000, which has been the typical maximum for the 4o model.
|
|
846
|
-
|
|
916
|
+
llm_base_url (Optional[str]): The base URL for the LLM API. If None, the default URL is used which
|
|
847
917
|
sends the request to OpenAI.
|
|
848
918
|
azure_llm_base_url (Optional[str]): The base URL for the Azure LLM API. Typically used when an organization
|
|
849
919
|
has its own deployment of an LLM model, possibly for privacy reasons. The Azure endpoint will be used
|
|
850
|
-
even if a
|
|
920
|
+
even if a llm_base_url is provided. If None, then the llm_base_url endpoint is used. If
|
|
851
921
|
that's also None, then OpenAI is used.
|
|
852
922
|
|
|
853
923
|
Returns:
|
|
@@ -946,7 +1016,7 @@ class MetadataEditor:
|
|
|
946
1016
|
llm_model_name=llm_model_name,
|
|
947
1017
|
tokenizer_model=tokenizer_model,
|
|
948
1018
|
max_tokens=max_tokens,
|
|
949
|
-
|
|
1019
|
+
llm_base_url=llm_base_url,
|
|
950
1020
|
azure_llm_base_url=azure_llm_base_url,
|
|
951
1021
|
)
|
|
952
1022
|
|
|
@@ -982,7 +1052,7 @@ class MetadataEditor:
|
|
|
982
1052
|
document, geospatial, image, indicator, indicators_db, microdata, resource, script, table, video
|
|
983
1053
|
In this case we will use the default template for that metadata type.
|
|
984
1054
|
Alternatively you can pass in the UID of a template. This is required if the metadata is a dictionary
|
|
985
|
-
otherwise
|
|
1055
|
+
otherwise the UID associated with the pydantic model or the Excel file will be used.
|
|
986
1056
|
|
|
987
1057
|
Returns:
|
|
988
1058
|
int: The ID of the newly created document metadata
|
|
@@ -1002,6 +1072,37 @@ class MetadataEditor:
|
|
|
1002
1072
|
)
|
|
1003
1073
|
return ret["id"]
|
|
1004
1074
|
|
|
1075
|
+
# def log_project_admin_metadata(
|
|
1076
|
+
# self, id: int, metadata: Union[BaseModel, Dict, str], metadata_type_or_template_uid: Optional[str] = None
|
|
1077
|
+
# ) -> int:
|
|
1078
|
+
# """Validates and logs admin metadata which can be a dictionary, a pydantic model or a path to an Excel file.
|
|
1079
|
+
|
|
1080
|
+
# Args:
|
|
1081
|
+
# id (int): The ID of the project to associate the metadata with.
|
|
1082
|
+
# metadata (dictionary or BaseModel or str): If str, it's assumed this is a path to an appropriately
|
|
1083
|
+
# formatted Excel file.
|
|
1084
|
+
# admin_metadata_template_uid (str, optional): The UID of the admin metadata template to use.
|
|
1085
|
+
# otherwise the UID associated with the pydantic model or the Excel file will be used.
|
|
1086
|
+
|
|
1087
|
+
# Returns:
|
|
1088
|
+
# int: The ID of the newly created admin metadata
|
|
1089
|
+
# """
|
|
1090
|
+
# metadata, metadata_type, uid = self._process_metadata_input(metadata, metadata_type_or_template_uid)
|
|
1091
|
+
# post_request_pth = "admin-metadata/data/"
|
|
1092
|
+
# post_json = {
|
|
1093
|
+
# "project_id": id,
|
|
1094
|
+
# "template_uid": uid,
|
|
1095
|
+
# "metadata": remove_empty_from_dict(metadata.model_dump(mode="json",
|
|
1096
|
+
# exclude_none=True,
|
|
1097
|
+
# exclude_unset=True)),
|
|
1098
|
+
# }
|
|
1099
|
+
# ret = self._apinterface.post_request(
|
|
1100
|
+
# pth=post_request_pth,
|
|
1101
|
+
# json=post_json,
|
|
1102
|
+
# id=id,
|
|
1103
|
+
# )
|
|
1104
|
+
# return ret
|
|
1105
|
+
|
|
1005
1106
|
def update_project_log_by_id(self, id: int, new_metadata: Union[BaseModel, Dict, str]):
|
|
1006
1107
|
"""Updates the record of the metadata.
|
|
1007
1108
|
|
|
@@ -1111,7 +1212,7 @@ class MetadataEditor:
|
|
|
1111
1212
|
- "add": Adds a value to the specified path. If the path already exists, the value is replaced.
|
|
1112
1213
|
- "remove": Removes the value at the specified path.
|
|
1113
1214
|
- "replace": Replaces the value at the specified path with a new value.
|
|
1114
|
-
- "test": Tests that the
|
|
1215
|
+
- "test": Tests that the specified path contains the given value.
|
|
1115
1216
|
|
|
1116
1217
|
The `path` is a string that uses a slash (`/`) notation to specify the location within the JSON document.
|
|
1117
1218
|
For example, `/author` refers to the "author" field, and `/metadata/title` refers to the "title" field
|
|
@@ -1132,7 +1233,18 @@ class MetadataEditor:
|
|
|
1132
1233
|
Example:
|
|
1133
1234
|
```python
|
|
1134
1235
|
me = MetadataEditor(api_url = api_url, api_key = api_key)
|
|
1236
|
+
|
|
1237
|
+
# set the author of the project with ID 123 to "John Doe"
|
|
1135
1238
|
me.patch_update_project_log_by_id(id=123, op="add", path="/author", value="John Doe")
|
|
1239
|
+
|
|
1240
|
+
# test that the author is "John Doe"
|
|
1241
|
+
me.patch_update_project_log_by_id(id=123, op="test", path="/author", value="John Doe")
|
|
1242
|
+
|
|
1243
|
+
# change the author to "Jane Doe"
|
|
1244
|
+
me.patch_update_project_log_by_id(id=123, op="replace", path="/author", value="Jane Doe")
|
|
1245
|
+
|
|
1246
|
+
# remove the value of author
|
|
1247
|
+
me.patch_update_project_log_by_id(id=123, op="remove", path="/author")
|
|
1136
1248
|
```
|
|
1137
1249
|
"""
|
|
1138
1250
|
project_data = self.get_project_by_id(id)
|
|
@@ -1144,7 +1256,7 @@ class MetadataEditor:
|
|
|
1144
1256
|
patches["value"] = value
|
|
1145
1257
|
if isinstance(patches, dict):
|
|
1146
1258
|
patches = [patches]
|
|
1147
|
-
patches = validate_json_patches(patches) # depend on serverside validation
|
|
1259
|
+
patches = validate_json_patches(patches) # could instead only depend on serverside validation...
|
|
1148
1260
|
self._apinterface.post_request(pth=pth + "{}", id=id, json={"patches": patches, "validate": False})
|
|
1149
1261
|
|
|
1150
1262
|
####################################################################################################################
|
|
@@ -1175,6 +1287,12 @@ class MetadataEditor:
|
|
|
1175
1287
|
return row["data_type"]
|
|
1176
1288
|
|
|
1177
1289
|
self._default_templates["data_type"] = self._default_templates.apply(standardize, axis=1)
|
|
1290
|
+
|
|
1291
|
+
try:
|
|
1292
|
+
new_index = templates.index.astype(int)
|
|
1293
|
+
except ValueError:
|
|
1294
|
+
pass
|
|
1295
|
+
templates.index = new_index
|
|
1178
1296
|
return templates
|
|
1179
1297
|
|
|
1180
1298
|
def get_template_by_uid(self, uid: str) -> pd.Series:
|
|
@@ -1193,10 +1311,23 @@ class MetadataEditor:
|
|
|
1193
1311
|
raise KeyError(f"No result returned.\nResponse: {response}")
|
|
1194
1312
|
|
|
1195
1313
|
temp = pd.Series(response["result"], name=response["result"]["name"])
|
|
1196
|
-
self._templates[uid] = {"template": temp, "metadata_type": self._mm.standardize_metadata_name(temp.data_type)}
|
|
1197
1314
|
try:
|
|
1198
|
-
|
|
1199
|
-
|
|
1315
|
+
standard_type_name = self._mm.standardize_metadata_name(temp.data_type)
|
|
1316
|
+
except ValueError:
|
|
1317
|
+
standard_type_name = temp.data_type
|
|
1318
|
+
self._templates[uid] = {"template": temp, "metadata_type": standard_type_name}
|
|
1319
|
+
try:
|
|
1320
|
+
try:
|
|
1321
|
+
parent_schema = self._mm.metadata_class_from_name(temp.data_type)
|
|
1322
|
+
except ValueError:
|
|
1323
|
+
warnings.warn(
|
|
1324
|
+
f"Could not find a metadata class for template type '{standard_type_name}', UID '{uid}'. "
|
|
1325
|
+
f"Building a class from the template without a parent schema."
|
|
1326
|
+
)
|
|
1327
|
+
parent_schema = SchemaBaseModel
|
|
1328
|
+
klass = pydantic_from_template(
|
|
1329
|
+
temp.template, parent_schema=parent_schema, uid=uid, name=temp.name, metadata_type=standard_type_name
|
|
1330
|
+
)
|
|
1200
1331
|
self._templates[uid]["class"] = klass
|
|
1201
1332
|
finally:
|
|
1202
1333
|
return temp
|
|
@@ -1242,9 +1373,12 @@ class MetadataEditor:
|
|
|
1242
1373
|
if not (output_template_uid is None or output_template_uid == uid):
|
|
1243
1374
|
new_klass, new_type, _ = self._get_metadata_class_and_type_and_UID(output_template_uid)
|
|
1244
1375
|
try:
|
|
1245
|
-
metadata = new_klass.model_validate(
|
|
1246
|
-
|
|
1247
|
-
|
|
1376
|
+
# metadata = new_klass.model_validate(
|
|
1377
|
+
# remove_empty_from_dict(metadata.model_dump(mode="json", exclude_none=True, exclude_unset=True)),
|
|
1378
|
+
# strict=False,
|
|
1379
|
+
# )
|
|
1380
|
+
metadata = _iterated_validated_update_to_outline(
|
|
1381
|
+
new_klass, updates=metadata.model_dump(mode="json", exclude_none=True, exclude_unset=True)
|
|
1248
1382
|
)
|
|
1249
1383
|
except ValidationError as e:
|
|
1250
1384
|
raise TemplateError(
|
|
@@ -1332,7 +1466,14 @@ class MetadataEditor:
|
|
|
1332
1466
|
response = self._apinterface.get_request("collections")
|
|
1333
1467
|
if "collections" not in response or len(response["collections"]) == 0:
|
|
1334
1468
|
return pd.DataFrame([], columns=["id", "title", "created"]).set_index("id")
|
|
1335
|
-
|
|
1469
|
+
df = pd.DataFrame(response["collections"]).set_index("id")
|
|
1470
|
+
try:
|
|
1471
|
+
new_index = df.index.astype(int)
|
|
1472
|
+
except ValueError:
|
|
1473
|
+
pass
|
|
1474
|
+
else:
|
|
1475
|
+
df.index = new_index
|
|
1476
|
+
return df
|
|
1336
1477
|
|
|
1337
1478
|
def get_collection_by_id(self, id: int) -> pd.Series:
|
|
1338
1479
|
"""Get information about a collection like title, description, created date.
|
|
@@ -1385,6 +1526,26 @@ class MetadataEditor:
|
|
|
1385
1526
|
metadata["description"] = description
|
|
1386
1527
|
self._apinterface.post_request("collections/update/{}", id=id, json=metadata)
|
|
1387
1528
|
|
|
1529
|
+
def copy_collection(self, source_id: int, target_id: int):
|
|
1530
|
+
"""Copy projects and users from one collection to another.
|
|
1531
|
+
|
|
1532
|
+
Args:
|
|
1533
|
+
source_id (int): The ID of the source collection.
|
|
1534
|
+
target_id (int): The ID of the target collection.
|
|
1535
|
+
|
|
1536
|
+
"""
|
|
1537
|
+
self._apinterface.post_request("collections/copy/", json={"source_id": source_id, "target_id": target_id})
|
|
1538
|
+
|
|
1539
|
+
def move_collection(self, source_id, target_id):
|
|
1540
|
+
"""Move source collection to be a sub-collection of the target collection.
|
|
1541
|
+
|
|
1542
|
+
Args:
|
|
1543
|
+
source_id (int): The ID of the source collection.
|
|
1544
|
+
target_id (int): The ID of the target collection.
|
|
1545
|
+
|
|
1546
|
+
"""
|
|
1547
|
+
self._apinterface.post_request("collections/move/", json={"source_id": source_id, "target_id": target_id})
|
|
1548
|
+
|
|
1388
1549
|
def count_projects_in_collection(self, collection: int) -> int:
|
|
1389
1550
|
"""Count the number of projects you have access to.
|
|
1390
1551
|
|
|
@@ -1449,7 +1610,15 @@ class MetadataEditor:
|
|
|
1449
1610
|
ret = self._apinterface.get_request("editor?collection={}", id=collection, params=params)
|
|
1450
1611
|
if len(ret["projects"]) == 0:
|
|
1451
1612
|
return pd.DataFrame([], columns=["id", "type", "idno", "title"]).set_index("id")
|
|
1452
|
-
|
|
1613
|
+
df = pd.DataFrame(ret["projects"]).set_index("id")
|
|
1614
|
+
|
|
1615
|
+
try:
|
|
1616
|
+
new_index = df.index.astype(int)
|
|
1617
|
+
except ValueError:
|
|
1618
|
+
pass
|
|
1619
|
+
else:
|
|
1620
|
+
df.index = new_index
|
|
1621
|
+
return df
|
|
1453
1622
|
|
|
1454
1623
|
def add_projects_to_collection(
|
|
1455
1624
|
self, collection: Union[int, List[int]], id_format: str, projects: Union[int, List[int], str, List[str]]
|
|
@@ -1580,7 +1749,14 @@ class MetadataEditor:
|
|
|
1580
1749
|
"""
|
|
1581
1750
|
response = self._apinterface.get_request("resources/{}", id=id)
|
|
1582
1751
|
if "resources" in response and len(response["resources"]) > 0:
|
|
1583
|
-
|
|
1752
|
+
df = pd.DataFrame(response["resources"])
|
|
1753
|
+
try:
|
|
1754
|
+
new_index = df.index.astype(int)
|
|
1755
|
+
except ValueError:
|
|
1756
|
+
pass
|
|
1757
|
+
else:
|
|
1758
|
+
df.index = new_index
|
|
1759
|
+
return df
|
|
1584
1760
|
else:
|
|
1585
1761
|
return pd.DataFrame(columns=["id", "sid", "dctype", "title", "subtitle", "author", "filename", "dcformat"])
|
|
1586
1762
|
|
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
"""Helper functions for the LLM Metadata Editor when working with Pydantic models and LLM outputs."""
|
|
2
|
+
|
|
3
|
+
from datetime import datetime
|
|
4
|
+
from typing import Any, List, get_args
|
|
5
|
+
|
|
6
|
+
from metadataschemas.utils.quick_start import make_skeleton
|
|
7
|
+
from metadataschemas.utils.utils import (
|
|
8
|
+
get_subtype_of_optional_or_list,
|
|
9
|
+
is_list_annotation,
|
|
10
|
+
is_optional_annotation,
|
|
11
|
+
is_optional_list,
|
|
12
|
+
subset_pydantic_model_type,
|
|
13
|
+
)
|
|
14
|
+
from openai import APITimeoutError
|
|
15
|
+
from pydantic import BaseModel, ValidationError
|
|
16
|
+
|
|
17
|
+
# from tqdm import tqdm
|
|
18
|
+
from tqdm import tqdm
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _prepend_draft_drop_non_str(d: Any, prefix: str) -> dict | list | str | None:
|
|
22
|
+
"""Recursively prepend a prefix to all strings in a dictionary or list and drop empty strings and non-strings.
|
|
23
|
+
|
|
24
|
+
Args:
|
|
25
|
+
d (Any): The dictionary or list to process.
|
|
26
|
+
prefix (str): The prefix to prepend to all strings.
|
|
27
|
+
|
|
28
|
+
Returns:
|
|
29
|
+
dict | list | str | None: The processed dictionary or list, or the string with the prefix prepended.
|
|
30
|
+
"""
|
|
31
|
+
if isinstance(d, dict):
|
|
32
|
+
out = {}
|
|
33
|
+
for k, v in d.items():
|
|
34
|
+
new_v = _prepend_draft_drop_non_str(v, prefix)
|
|
35
|
+
if new_v is not None:
|
|
36
|
+
out[k] = new_v
|
|
37
|
+
if len(out) > 0:
|
|
38
|
+
return out
|
|
39
|
+
else:
|
|
40
|
+
return None
|
|
41
|
+
elif isinstance(d, list):
|
|
42
|
+
out = []
|
|
43
|
+
for v in d:
|
|
44
|
+
new_v = _prepend_draft_drop_non_str(v, prefix)
|
|
45
|
+
if new_v is not None:
|
|
46
|
+
out.append(new_v)
|
|
47
|
+
if len(out) > 0:
|
|
48
|
+
return out
|
|
49
|
+
else:
|
|
50
|
+
return None
|
|
51
|
+
elif isinstance(d, str):
|
|
52
|
+
if d.strip() == "":
|
|
53
|
+
return None
|
|
54
|
+
else:
|
|
55
|
+
return prefix + d
|
|
56
|
+
else:
|
|
57
|
+
return None
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _iterated_validated_update_to_outline(model_def: type[BaseModel], updates: dict, verbose=False) -> BaseModel:
|
|
61
|
+
"""Recursively updates a model definition with given updates and validates the result.
|
|
62
|
+
|
|
63
|
+
This function takes a model definition and a dictionary of updates, makes a skeleton and applies the updates
|
|
64
|
+
to that skeleton, and validates the updated model. If validation fails for any update value, the
|
|
65
|
+
original skeleton value is retained.
|
|
66
|
+
|
|
67
|
+
Args:
|
|
68
|
+
model_def (type[BaseModel]): The model definition class to be updated.
|
|
69
|
+
updates (dict): A dictionary containing the updates to be applied to the model.
|
|
70
|
+
verbose (bool, optional): If True, prints detailed information about validation failures. Defaults to False.
|
|
71
|
+
|
|
72
|
+
Returns:
|
|
73
|
+
BaseModel: The validated model instance with the applied updates.
|
|
74
|
+
|
|
75
|
+
Raises:
|
|
76
|
+
ValidationError: If the final model validation fails.
|
|
77
|
+
|
|
78
|
+
Steps:
|
|
79
|
+
1. Create a skeleton of the model and dump its initial state.
|
|
80
|
+
2. Validate the initial state of the model.
|
|
81
|
+
3. Iterate over the updates dictionary.
|
|
82
|
+
4. For each update, check if the key exists in the original model.
|
|
83
|
+
5. Determine the type of the field (annotation) and handle optional and list types.
|
|
84
|
+
6. Recursively update nested models if the value is a dictionary or a list of dictionaries.
|
|
85
|
+
7. Apply the candidate value to the original model.
|
|
86
|
+
8. Validate the updated model.
|
|
87
|
+
9. If validation fails, revert to the original value and optionally print the error.
|
|
88
|
+
10. Return the validated model instance.
|
|
89
|
+
"""
|
|
90
|
+
original_model = make_skeleton(model_def).model_dump()
|
|
91
|
+
# print(original_model)
|
|
92
|
+
model_def.model_validate(original_model, strict=False)
|
|
93
|
+
|
|
94
|
+
model_fields = model_def.model_fields
|
|
95
|
+
for key, value in updates.items():
|
|
96
|
+
if key not in original_model:
|
|
97
|
+
continue
|
|
98
|
+
|
|
99
|
+
# get annotation aka type of the field
|
|
100
|
+
annotation = model_fields[key].annotation
|
|
101
|
+
if is_optional_annotation(annotation):
|
|
102
|
+
annotation = get_args(annotation)[0]
|
|
103
|
+
is_list = False
|
|
104
|
+
if is_list_annotation(annotation):
|
|
105
|
+
annotation = get_args(annotation)[0]
|
|
106
|
+
is_list = True
|
|
107
|
+
# print(annotation)
|
|
108
|
+
|
|
109
|
+
# get candidate value
|
|
110
|
+
if isinstance(value, dict) and issubclass(annotation, BaseModel):
|
|
111
|
+
candidate_value = _iterated_validated_update_to_outline(annotation, value, verbose=verbose).model_dump()
|
|
112
|
+
elif is_list and isinstance(value, list) and issubclass(annotation, BaseModel):
|
|
113
|
+
candidate_value = []
|
|
114
|
+
for item in value:
|
|
115
|
+
candidate_value.append(
|
|
116
|
+
_iterated_validated_update_to_outline(annotation, item, verbose=verbose).model_dump()
|
|
117
|
+
)
|
|
118
|
+
else:
|
|
119
|
+
candidate_value = value
|
|
120
|
+
|
|
121
|
+
# try the candidate value
|
|
122
|
+
original_value = original_model[key]
|
|
123
|
+
original_model[key] = candidate_value
|
|
124
|
+
try:
|
|
125
|
+
model_def.model_validate(original_model, strict=False)
|
|
126
|
+
except ValidationError as e:
|
|
127
|
+
# If validation fails, leave the original value
|
|
128
|
+
if verbose:
|
|
129
|
+
print(f"Skipping {key}({annotation})={candidate_value} because of {e}\n")
|
|
130
|
+
original_model[key] = original_value
|
|
131
|
+
# print(original_model)
|
|
132
|
+
|
|
133
|
+
return model_def.model_validate(original_model, strict=False)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def get_date_as_text():
|
|
137
|
+
"""Returns the current date as a formatted string with an ordinal suffix for the day.
|
|
138
|
+
|
|
139
|
+
The format of the returned date string is "Month DaySuffix, Year", where:
|
|
140
|
+
- Month is the full name of the month (e.g., January, February).
|
|
141
|
+
- DaySuffix is the day of the month with an appropriate ordinal suffix (e.g., 1st, 2nd, 3rd, 4th, etc.).
|
|
142
|
+
- Year is the four-digit year.
|
|
143
|
+
|
|
144
|
+
Example:
|
|
145
|
+
If today's date is October 21, 2023, the function will return "October 21st, 2023".
|
|
146
|
+
|
|
147
|
+
Returns:
|
|
148
|
+
str: The formatted date string.
|
|
149
|
+
"""
|
|
150
|
+
today = datetime.today()
|
|
151
|
+
day = today.day
|
|
152
|
+
# Determine ordinal suffix
|
|
153
|
+
if 10 <= day % 100 <= 20:
|
|
154
|
+
suffix = "th"
|
|
155
|
+
else:
|
|
156
|
+
suffix = {1: "st", 2: "nd", 3: "rd"}.get(day % 10, "th")
|
|
157
|
+
|
|
158
|
+
# Format the date
|
|
159
|
+
return today.strftime(f"%B %-d{suffix}, %Y")
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def json_to_markdown(data, level=1):
|
|
163
|
+
"""Converts a JSON object into Markdown. Future versions of MarkItDown will support this natively.
|
|
164
|
+
|
|
165
|
+
Args:
|
|
166
|
+
data: The JSON object (dict, list, or primitive type).
|
|
167
|
+
level: Current Markdown heading level for nested keys.
|
|
168
|
+
|
|
169
|
+
Returns:
|
|
170
|
+
A Markdown string.
|
|
171
|
+
"""
|
|
172
|
+
markdown = ""
|
|
173
|
+
|
|
174
|
+
if isinstance(data, dict):
|
|
175
|
+
for key, value in data.items():
|
|
176
|
+
markdown += f"{'#' * level} {key}\n\n" # Add heading for the key
|
|
177
|
+
markdown += json_to_markdown(value, level + 1) # Recursively process value
|
|
178
|
+
elif isinstance(data, list):
|
|
179
|
+
for item in data:
|
|
180
|
+
markdown += f"- {json_to_markdown(item, level + 1).strip()}\n" # Format as a list
|
|
181
|
+
else:
|
|
182
|
+
# Format primitive types (string, number, boolean, null)
|
|
183
|
+
markdown += f"{data}\n\n"
|
|
184
|
+
|
|
185
|
+
return markdown
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def call_per_field(
|
|
189
|
+
klass: type[BaseModel], client, model: str, messages: list[dict[str, str]], show_progress_bar: bool = False
|
|
190
|
+
) -> BaseModel:
|
|
191
|
+
"""Calls the LLM per field and returns a validated model.
|
|
192
|
+
|
|
193
|
+
This function is typically used when the LLM model is less powerful such as when the model is running locally.
|
|
194
|
+
|
|
195
|
+
Args:
|
|
196
|
+
klass (type[BaseModel]): The Pydantic model class to be updated.
|
|
197
|
+
client: The LLM client. such as OpenAI(api_key=..., base_url=...) or AzureOpenAI(api_key=..., base_url=...)
|
|
198
|
+
model (str): The model name, for example "gpt-4o" or "llama3.1".
|
|
199
|
+
messages (list[dict[str, str]]): The messages to be sent to the LLM.
|
|
200
|
+
show_progress_bar (bool): If True, shows a progress bar. Defaults to False but is set to True nested calls.
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
Returns:
|
|
204
|
+
BaseModel: The validated model instance with the applied updates.
|
|
205
|
+
|
|
206
|
+
Example:
|
|
207
|
+
```python
|
|
208
|
+
klass = metadata_class_no_rules, metadata_type, _ = MetadataEditor._get_metadata_class_and_type_and_UID(
|
|
209
|
+
metadata_type_or_template_uid, apply_template_rules=False
|
|
210
|
+
)
|
|
211
|
+
client = OpenAI(api_key=..., base_url=...)
|
|
212
|
+
model = "gpt-4o"
|
|
213
|
+
messages = [{"role": "system", "content": "You are a helpful assistant who writes metadata."},
|
|
214
|
+
{"role": "user", "content": "A report on the state of the world"}]
|
|
215
|
+
updated_model = call_per_field(klass, client, model, messages)
|
|
216
|
+
```
|
|
217
|
+
1. The function iterates over each field in the Pydantic model.
|
|
218
|
+
2. For each field, it sends a request to the LLM with the provided messages.
|
|
219
|
+
3. If the LLM response is valid, it updates the field in the model.
|
|
220
|
+
4. If the LLM response is invalid, it attempts to handle nested fields or lists.
|
|
221
|
+
5. The function returns the updated and validated model.
|
|
222
|
+
"""
|
|
223
|
+
final_dict = {}
|
|
224
|
+
for field in tqdm(klass.model_fields.keys(), disable=not show_progress_bar):
|
|
225
|
+
try:
|
|
226
|
+
completion = client.beta.chat.completions.parse(
|
|
227
|
+
model=model,
|
|
228
|
+
messages=messages,
|
|
229
|
+
response_format=subset_pydantic_model_type(klass, [field]),
|
|
230
|
+
temperature=0.0,
|
|
231
|
+
timeout=60 * 1,
|
|
232
|
+
)
|
|
233
|
+
except APITimeoutError:
|
|
234
|
+
# print(f"{' '*(indent+2)}Timeout")
|
|
235
|
+
continue
|
|
236
|
+
except ValidationError:
|
|
237
|
+
# print(f"{' '*indent}Validation Error for {field}, attempting to step through subfields")
|
|
238
|
+
field_annotation = klass.model_fields[field].annotation
|
|
239
|
+
# print(f"{' '*indent}field")
|
|
240
|
+
if is_optional_list(field_annotation) or is_list_annotation(field_annotation):
|
|
241
|
+
# print(get_subtype_of_optional_or_list(field_annotation))
|
|
242
|
+
class subtype(BaseModel):
|
|
243
|
+
field: List[get_subtype_of_optional_or_list(field_annotation)]
|
|
244
|
+
|
|
245
|
+
final_dict[field] = call_per_field(subtype, client, model, messages, show_progress_bar=True)
|
|
246
|
+
elif is_optional_annotation(field_annotation):
|
|
247
|
+
# print(get_subtype_of_optional_or_list(field_annotation))
|
|
248
|
+
final_dict[field] = call_per_field(
|
|
249
|
+
get_subtype_of_optional_or_list(field_annotation), client, model, messages, show_progress_bar=True
|
|
250
|
+
)
|
|
251
|
+
else:
|
|
252
|
+
continue
|
|
253
|
+
else:
|
|
254
|
+
message = completion.choices[0].message
|
|
255
|
+
metadata_dict = message.parsed.model_dump(exclude_none=True, exclude_unset=True)
|
|
256
|
+
final_dict = {**final_dict, **metadata_dict}
|
|
257
|
+
return _iterated_validated_update_to_outline(klass, final_dict)
|
|
@@ -1,3 +1,9 @@
|
|
|
1
|
+
"""A module to handle HTTP requests with specific error handling for SSL and JSON decoding errors.
|
|
2
|
+
|
|
3
|
+
This module defines a class `RequestsWithSpecificErrors` that provides methods for making GET and POST requests
|
|
4
|
+
to a specified API URL. It includes error handling for SSL errors, HTTP errors, and JSON decoding errors.
|
|
5
|
+
"""
|
|
6
|
+
|
|
1
7
|
from io import BufferedReader
|
|
2
8
|
from json import JSONDecodeError
|
|
3
9
|
from ssl import SSLError as ssl_SSLError
|
|
@@ -9,6 +15,8 @@ from requests.exceptions import HTTPError, SSLError
|
|
|
9
15
|
|
|
10
16
|
|
|
11
17
|
class RequestsWithSpecificErrors(BaseModel):
|
|
18
|
+
"""A class to handle HTTP requests with specific error handling for SSL and JSON decoding errors."""
|
|
19
|
+
|
|
12
20
|
model_config = ConfigDict(extra="forbid", validate_assignment=True)
|
|
13
21
|
|
|
14
22
|
api_url: AnyHttpUrl
|
|
@@ -30,7 +38,7 @@ class RequestsWithSpecificErrors(BaseModel):
|
|
|
30
38
|
else:
|
|
31
39
|
raise ValueError(
|
|
32
40
|
f"URL scheme should be 'https' but got {self.api_url}"
|
|
33
|
-
"To allow the less secure use of 'http', set
|
|
41
|
+
"To allow the less secure use of 'http', set allow_http=True"
|
|
34
42
|
)
|
|
35
43
|
|
|
36
44
|
def _request(
|
|
@@ -43,12 +51,17 @@ class RequestsWithSpecificErrors(BaseModel):
|
|
|
43
51
|
data: Optional[Dict[str, str]] = None,
|
|
44
52
|
files: Optional[Dict[str, BufferedReader]] = None,
|
|
45
53
|
) -> Dict:
|
|
46
|
-
"""
|
|
47
|
-
Sends a GET or POST request to the specified URL with the API key in the headers and returns the JSON response.
|
|
54
|
+
"""Perform a GET or POST request to the specified URL with the API key in the headers.
|
|
48
55
|
|
|
49
56
|
Args:
|
|
50
57
|
method (str): Either post or get
|
|
51
58
|
pth (str): The path appended to the API_URL to which the GET or POST request is sent.
|
|
59
|
+
json (optional dict): The JSON data to send with the POST request.
|
|
60
|
+
params (optional dict): additional parameters to send with the get request such as 'keywords'
|
|
61
|
+
id (optional int or str): The id of a specific collection or project.
|
|
62
|
+
If not none, then pth should contain '{}' where the id ought to go.
|
|
63
|
+
data (optional dict): The data to send with the POST request.
|
|
64
|
+
files (optional dict): The files to send with the POST request.
|
|
52
65
|
|
|
53
66
|
Returns:
|
|
54
67
|
Dict[str, str]: The JSON response from the server, parsed into a dictionary.
|
|
@@ -102,14 +115,14 @@ class RequestsWithSpecificErrors(BaseModel):
|
|
|
102
115
|
raise HTTPError(error_msg) from None
|
|
103
116
|
elif response.status_code == 403:
|
|
104
117
|
raise PermissionError(
|
|
105
|
-
f"Access to that URL is denied for {url}
|
|
118
|
+
f"Access to that URL is denied for {url} Check that the API key is correct"
|
|
106
119
|
) from e
|
|
107
|
-
elif response.status_code == 400 and "message" in response.text:
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
120
|
+
# elif response.status_code == 400 and "message" in response.text:
|
|
121
|
+
# if isinstance(response.text, dict):
|
|
122
|
+
# error_message = response.text["message"]
|
|
123
|
+
# else:
|
|
124
|
+
# error_message = response.text
|
|
125
|
+
# raise PermissionError(error_message) from e
|
|
113
126
|
else:
|
|
114
127
|
raise HTTPError(f"Status Code: {response.status_code}, Response: {response.text}") from e
|
|
115
128
|
try:
|
|
@@ -125,7 +138,8 @@ class RequestsWithSpecificErrors(BaseModel):
|
|
|
125
138
|
def get_request(
|
|
126
139
|
self, pth: str, id: Optional[Union[int, str]] = None, params: Optional[Dict[str, Union[str, List[str]]]] = None
|
|
127
140
|
) -> Dict:
|
|
128
|
-
"""
|
|
141
|
+
"""Performs a GET request to the specified URL with the API key in the headers and returns the JSON response.
|
|
142
|
+
|
|
129
143
|
Args:
|
|
130
144
|
pth (str): The path appended to the API_URL to which the GET request is sent.
|
|
131
145
|
id (optional int or str): The id of a specific collection or project.
|
|
@@ -145,7 +159,8 @@ class RequestsWithSpecificErrors(BaseModel):
|
|
|
145
159
|
data: Optional[Dict[str, str]] = None,
|
|
146
160
|
files: Optional[Dict[str, BufferedReader]] = None,
|
|
147
161
|
):
|
|
148
|
-
"""
|
|
162
|
+
"""Performs a POST request to the specified URL with the API key in the headers and returns the JSON response.
|
|
163
|
+
|
|
149
164
|
Args:
|
|
150
165
|
pth (str): The path appended to the API_URL to which the POST request is sent.
|
|
151
166
|
json (optional dict): The JSON data to send with the POST request.
|
|
@@ -617,7 +617,12 @@ def append_variables_and_data_files(model_elements, parent_schema, apply_rules:
|
|
|
617
617
|
|
|
618
618
|
|
|
619
619
|
def pydantic_from_template(
|
|
620
|
-
template: Dict,
|
|
620
|
+
template: Dict,
|
|
621
|
+
parent_schema: Type[SchemaBaseModel],
|
|
622
|
+
uid: str,
|
|
623
|
+
name: Optional[str] = None,
|
|
624
|
+
metadata_type: Optional[str] = None,
|
|
625
|
+
apply_rules: bool = True,
|
|
621
626
|
) -> Type[BaseModel]:
|
|
622
627
|
"""Generate a Pydantic model from a given template.
|
|
623
628
|
|
|
@@ -626,6 +631,8 @@ def pydantic_from_template(
|
|
|
626
631
|
parent_schema (Type[SchemaBaseModel]): The parent schema model to base the new model on.
|
|
627
632
|
uid (str): A unique identifier for the new model.
|
|
628
633
|
name (Optional[str], optional): The name of the new model. Defaults to None.
|
|
634
|
+
metadata_type (Optional[str], optional): The metadata type for the new model used if the parent_schema is
|
|
635
|
+
SchemaBaseModel. Otherwise the metadata_type of the parent_schema is used. Defaults to None.
|
|
629
636
|
apply_rules (bool, optional): Whether to apply rules to the model elements. Defaults to True.
|
|
630
637
|
|
|
631
638
|
Returns:
|
|
@@ -659,4 +666,12 @@ def pydantic_from_template(
|
|
|
659
666
|
hasattr(parent_schema._metadata_type__, "default") and parent_schema._metadata_type__.default == "microdata"
|
|
660
667
|
):
|
|
661
668
|
model_elements = append_variables_and_data_files(model_elements, parent_schema, apply_rules=apply_rules)
|
|
662
|
-
|
|
669
|
+
new_model = create_model_for_template(model_elements, parent_schema, name, uid=uid)
|
|
670
|
+
if parent_schema is SchemaBaseModel:
|
|
671
|
+
if metadata_type is None:
|
|
672
|
+
raise ValueError("metadata_type must be provided if parent_schema is SchemaBaseModel. ")
|
|
673
|
+
new_model._template_name__ = f"{name.replace('-', '_').replace(' ', '_')}"
|
|
674
|
+
new_model._template_uid__ = uid
|
|
675
|
+
new_model._metadata_type__ = metadata_type.replace("-", "_").replace(" ", "_")
|
|
676
|
+
new_model._metadata_type_version__ = "unknown"
|
|
677
|
+
return new_model
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[tool.poetry]
|
|
2
2
|
name = "pymetadataeditor"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.3.1"
|
|
4
4
|
description = "Metadata Editor client for python"
|
|
5
5
|
authors = ["Gordon Blackadder <gblackadder@worldbank.org>"]
|
|
6
6
|
readme = "README.md"
|
|
@@ -15,7 +15,7 @@ pydantic = "^2.7.4"
|
|
|
15
15
|
openai = "^1.59.4"
|
|
16
16
|
tiktoken = "^0.8.0"
|
|
17
17
|
markitdown = "^0.0.1a3"
|
|
18
|
-
metadataschemas = "^0.1.
|
|
18
|
+
metadataschemas = "^0.1.3"
|
|
19
19
|
|
|
20
20
|
[tool.poetry.group.dev.dependencies]
|
|
21
21
|
pytest = "^8.2.2"
|
|
@@ -35,6 +35,8 @@ fix = true
|
|
|
35
35
|
[tool.ruff.lint]
|
|
36
36
|
extend-select = ["I","E","F","D"]
|
|
37
37
|
ignore = ["D203","D204","D213","D215","D400","D401","D404","D406","D407","D408","D409","D413"]
|
|
38
|
+
# Ignore missing docstring warnings in test files since unit tests don't need them
|
|
39
|
+
per-file-ignores = { "tests/*.py" = ["D103", "D100"] }
|
|
38
40
|
|
|
39
41
|
[tool.ruff.lint.pydocstyle]
|
|
40
42
|
convention = "google"
|
|
@@ -1,145 +0,0 @@
|
|
|
1
|
-
from datetime import datetime
|
|
2
|
-
from typing import Any, get_args
|
|
3
|
-
|
|
4
|
-
from metadataschemas.utils.quick_start import make_skeleton
|
|
5
|
-
from metadataschemas.utils.utils import is_list_annotation, is_optional_annotation
|
|
6
|
-
from pydantic import BaseModel, ValidationError
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
def _prepend_draft_drop_non_str(d: Any, prefix: str) -> dict | list | str | None:
|
|
10
|
-
"""
|
|
11
|
-
Recursively prepend a prefix to all strings in a dictionary or list and drop empty strings and non-strings.
|
|
12
|
-
|
|
13
|
-
Args:
|
|
14
|
-
|
|
15
|
-
d (Any): The dictionary or list to process.
|
|
16
|
-
"""
|
|
17
|
-
|
|
18
|
-
if isinstance(d, dict):
|
|
19
|
-
out = {}
|
|
20
|
-
for k, v in d.items():
|
|
21
|
-
new_v = _prepend_draft_drop_non_str(v, prefix)
|
|
22
|
-
if new_v is not None:
|
|
23
|
-
out[k] = new_v
|
|
24
|
-
if len(out) > 0:
|
|
25
|
-
return out
|
|
26
|
-
else:
|
|
27
|
-
return None
|
|
28
|
-
elif isinstance(d, list):
|
|
29
|
-
out = []
|
|
30
|
-
for v in d:
|
|
31
|
-
new_v = _prepend_draft_drop_non_str(v, prefix)
|
|
32
|
-
if new_v is not None:
|
|
33
|
-
out.append(new_v)
|
|
34
|
-
if len(out) > 0:
|
|
35
|
-
return out
|
|
36
|
-
else:
|
|
37
|
-
return None
|
|
38
|
-
elif isinstance(d, str):
|
|
39
|
-
if d.strip() == "":
|
|
40
|
-
return None
|
|
41
|
-
else:
|
|
42
|
-
return prefix + d
|
|
43
|
-
else:
|
|
44
|
-
return None
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
def _iterated_validated_update_to_outline(model_def: type[BaseModel], updates: dict, verbose=False) -> BaseModel:
|
|
48
|
-
original_model = make_skeleton(model_def).model_dump()
|
|
49
|
-
# print(original_model)
|
|
50
|
-
model_def.model_validate(original_model, strict=False)
|
|
51
|
-
|
|
52
|
-
model_fields = model_def.model_fields
|
|
53
|
-
for key, value in updates.items():
|
|
54
|
-
if key not in original_model:
|
|
55
|
-
continue
|
|
56
|
-
|
|
57
|
-
# get annotation aka type of the field
|
|
58
|
-
annotation = model_fields[key].annotation
|
|
59
|
-
if is_optional_annotation(annotation):
|
|
60
|
-
annotation = get_args(annotation)[0]
|
|
61
|
-
is_list = False
|
|
62
|
-
if is_list_annotation(annotation):
|
|
63
|
-
annotation = get_args(annotation)[0]
|
|
64
|
-
is_list = True
|
|
65
|
-
# print(annotation)
|
|
66
|
-
|
|
67
|
-
# get candidate value
|
|
68
|
-
if isinstance(value, dict) and issubclass(annotation, BaseModel):
|
|
69
|
-
candidate_value = _iterated_validated_update_to_outline(annotation, value, verbose=verbose).model_dump()
|
|
70
|
-
elif is_list and isinstance(value, list) and issubclass(annotation, BaseModel):
|
|
71
|
-
candidate_value = []
|
|
72
|
-
for item in value:
|
|
73
|
-
candidate_value.append(
|
|
74
|
-
_iterated_validated_update_to_outline(annotation, item, verbose=verbose).model_dump()
|
|
75
|
-
)
|
|
76
|
-
else:
|
|
77
|
-
candidate_value = value
|
|
78
|
-
|
|
79
|
-
# try the candidate value
|
|
80
|
-
original_value = original_model[key]
|
|
81
|
-
original_model[key] = candidate_value
|
|
82
|
-
try:
|
|
83
|
-
model_def.model_validate(original_model, strict=False)
|
|
84
|
-
except ValidationError as e:
|
|
85
|
-
# If validation fails, leave the original value
|
|
86
|
-
if verbose:
|
|
87
|
-
print(f"Skipping {key}({annotation})={candidate_value} because of {e}\n")
|
|
88
|
-
original_model[key] = original_value
|
|
89
|
-
# print(original_model)
|
|
90
|
-
|
|
91
|
-
return model_def.model_validate(original_model, strict=False)
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
def get_date_as_text():
|
|
95
|
-
"""
|
|
96
|
-
Returns the current date as a formatted string with an ordinal suffix for the day.
|
|
97
|
-
|
|
98
|
-
The format of the returned date string is "Month DaySuffix, Year", where:
|
|
99
|
-
- Month is the full name of the month (e.g., January, February).
|
|
100
|
-
- DaySuffix is the day of the month with an appropriate ordinal suffix (e.g., 1st, 2nd, 3rd, 4th, etc.).
|
|
101
|
-
- Year is the four-digit year.
|
|
102
|
-
|
|
103
|
-
Example:
|
|
104
|
-
If today's date is October 21, 2023, the function will return "October 21st, 2023".
|
|
105
|
-
|
|
106
|
-
Returns:
|
|
107
|
-
str: The formatted date string.
|
|
108
|
-
"""
|
|
109
|
-
today = datetime.today()
|
|
110
|
-
day = today.day
|
|
111
|
-
# Determine ordinal suffix
|
|
112
|
-
if 10 <= day % 100 <= 20:
|
|
113
|
-
suffix = "th"
|
|
114
|
-
else:
|
|
115
|
-
suffix = {1: "st", 2: "nd", 3: "rd"}.get(day % 10, "th")
|
|
116
|
-
|
|
117
|
-
# Format the date
|
|
118
|
-
return today.strftime(f"%B %-d{suffix}, %Y")
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
def json_to_markdown(data, level=1):
|
|
122
|
-
"""
|
|
123
|
-
Converts a JSON object into Markdown. Future versions of MarkItDown will support this natively.
|
|
124
|
-
|
|
125
|
-
Args:
|
|
126
|
-
data: The JSON object (dict, list, or primitive type).
|
|
127
|
-
level: Current Markdown heading level for nested keys.
|
|
128
|
-
|
|
129
|
-
Returns:
|
|
130
|
-
A Markdown string.
|
|
131
|
-
"""
|
|
132
|
-
markdown = ""
|
|
133
|
-
|
|
134
|
-
if isinstance(data, dict):
|
|
135
|
-
for key, value in data.items():
|
|
136
|
-
markdown += f"{'#' * level} {key}\n\n" # Add heading for the key
|
|
137
|
-
markdown += json_to_markdown(value, level + 1) # Recursively process value
|
|
138
|
-
elif isinstance(data, list):
|
|
139
|
-
for item in data:
|
|
140
|
-
markdown += f"- {json_to_markdown(item, level + 1).strip()}\n" # Format as a list
|
|
141
|
-
else:
|
|
142
|
-
# Format primitive types (string, number, boolean, null)
|
|
143
|
-
markdown += f"{data}\n\n"
|
|
144
|
-
|
|
145
|
-
return markdown
|
|
File without changes
|
|
File without changes
|
|
File without changes
|