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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: pymetadataeditor
3
- Version: 0.2.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.2,<0.2.0)
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
- lazydocs pymetadataeditor.interface.MetadataEditor --no-watermark
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
- lazydocs pymetadataeditor.interface.MetadataEditor --no-watermark
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
- parent_schema = self._mm.metadata_class_from_name(metadata_type)
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
- public_llm_base_url: Optional[str] = None,
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
- public_llm_base_url (Optional[str]): The base URL for the LLM API. If None, the default URL is used which
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 public_llm_base_url is provided. If None, then the public_llm_base_url endpoint is used. If
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=public_llm_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"Read in {doc}, running token count is {num_tokens}")
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
- completion = client.beta.chat.completions.parse(
780
- model=llm_model_name,
781
- messages=messages,
782
- response_format=metadata_class_no_rules,
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
- metadata_dict = message.parsed.model_dump(exclude_none=True, exclude_unset=True)
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
- public_llm_base_url: Optional[str] = None,
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
- public_llm_base_url (Optional[str]): The base URL for the LLM API. If None, the default URL is used which
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 public_llm_base_url is provided. If None, then the public_llm_base_url endpoint is used. If
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
- public_llm_base_url=public_llm_base_url,
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 ignored.
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 value at the specified path matches a given value.
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
- parent_schema = self._mm.metadata_class_from_name(temp.data_type)
1199
- klass = pydantic_from_template(temp.template, parent_schema=parent_schema, uid=uid, name=temp.name)
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
- remove_empty_from_dict(metadata.model_dump(mode="json", exclude_none=True, exclude_unset=True)),
1247
- strict=False,
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
- return pd.DataFrame(response["collections"]).set_index("id")
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
- return pd.DataFrame(ret["projects"]).set_index("id")
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
- return pd.DataFrame(response["resources"])
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 allow_unsecure=True"
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} " "Check that the API key is correct"
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
- if isinstance(response.text, dict):
109
- error_message = response.text["message"]
110
- else:
111
- error_message = response.text
112
- raise PermissionError(error_message) from e
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, parent_schema: Type[SchemaBaseModel], uid: str, name: Optional[str] = None, apply_rules: bool = True
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
- return create_model_for_template(model_elements, parent_schema, name, uid=uid)
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.2.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.2"
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