pymetadataeditor 0.2.0__tar.gz → 0.3.0__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.0
4
4
  Summary: Metadata Editor client for python
5
5
  License: MIT
6
6
  Author: Gordon Blackadder
@@ -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
  ####################################################################################################################
@@ -644,7 +692,7 @@ class MetadataEditor:
644
692
  llm_model_name="gpt-4o",
645
693
  tokenizer_model="o200k_base",
646
694
  max_tokens=128_000,
647
- public_llm_base_url: Optional[str] = None,
695
+ llm_base_url: Optional[str] = None,
648
696
  azure_llm_base_url: Optional[str] = None,
649
697
  ) -> Union[BaseModel, Dict, str]:
650
698
  """Automatically generate *draft* metadata for a project based on local files or web pages.
@@ -681,11 +729,11 @@ class MetadataEditor:
681
729
  The option is provided in case OpenAI deprecated the 4o model.
682
730
  max_tokens (int): The maximum number of tokens to use when sending the content to OpenAI.
683
731
  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
732
+ llm_base_url (Optional[str]): The base URL for the LLM API. If None, the default URL is used which
685
733
  sends the request to OpenAI. This argument is ignored if an azure_llm_base_url is provided.
686
734
  azure_llm_base_url (Optional[str]): The base URL for the Azure LLM API. Typically used when an organization
687
735
  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
736
+ even if a llm_base_url is provided. If None, then the llm_base_url endpoint is used. If
689
737
  that's also None, then OpenAI is used.
690
738
 
691
739
  Returns:
@@ -706,6 +754,19 @@ class MetadataEditor:
706
754
  title="My Metadata",
707
755
  )
708
756
 
757
+ # Example with a local model running on Ollama:
758
+ me.draft_metadata_from_files(
759
+ llm_api_key="ollama", # pragma: allowlist secret
760
+ files=["/path/to/word_file1.docx", "http://www.example.com/report.pdf"],
761
+ output_mode="pydantic",
762
+ metadata_type_or_template_uid="indicator",
763
+ metadata_producer_organization="My Organization",
764
+ filename="output.xlsx",
765
+ title="My Metadata",
766
+ llm_base_url="http://localhost:11434/v1/",
767
+ llm_model_name="llama3.1"
768
+ )
769
+
709
770
  # Example with an Azure instance of a Large Language Model:
710
771
  me.draft_metadata_from_files(
711
772
  llm_api_key="...",
@@ -726,7 +787,7 @@ class MetadataEditor:
726
787
  )
727
788
  enc = tiktoken.get_encoding(tokenizer_model)
728
789
  if azure_llm_base_url is None:
729
- client = OpenAI(api_key=llm_api_key, base_url=public_llm_base_url)
790
+ client = OpenAI(api_key=llm_api_key, base_url=llm_base_url)
730
791
  else:
731
792
  client = AzureOpenAI(api_key=llm_api_key, base_url=azure_llm_base_url, api_version="2024-10-01")
732
793
 
@@ -765,29 +826,29 @@ class MetadataEditor:
765
826
  f" {max_tokens}, truncating the content and proceeding."
766
827
  )
767
828
  else:
768
- print(f"Read in {doc}, running token count is {num_tokens}")
829
+ print(f"Reading {doc}, running token count is {num_tokens}")
769
830
  messages += user_message
770
831
 
771
832
  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
833
+ 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
834
  )
778
835
  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)
836
+ try:
837
+ completion = client.beta.chat.completions.parse(
838
+ model=llm_model_name,
839
+ messages=messages,
840
+ response_format=metadata_class_no_rules,
841
+ )
788
842
 
789
- metadata_dict = message.parsed.model_dump(exclude_none=True, exclude_unset=True)
843
+ message = completion.choices[0].message
844
+ if not message.parsed:
845
+ raise ValueError(message.refusal)
790
846
 
847
+ metadata_dict = message.parsed.model_dump(exclude_none=True, exclude_unset=True)
848
+ except ValidationError:
849
+ metadata_dict = call_per_field(metadata_class_no_rules, client, llm_model_name, messages).model_dump(
850
+ exclude_none=True, exclude_unset=True
851
+ )
791
852
  # if prefix:
792
853
  # metadata_dict = self._prepend_draft_drop_non_str(metadata_dict, prefix)
793
854
  metadata_class_with_rules = self.get_metadata_class(metadata_type_or_template_uid)
@@ -814,7 +875,7 @@ class MetadataEditor:
814
875
  llm_model_name="gpt-4o",
815
876
  tokenizer_model="o200k_base",
816
877
  max_tokens=128_000,
817
- public_llm_base_url: Optional[str] = None,
878
+ llm_base_url: Optional[str] = None,
818
879
  azure_llm_base_url: Optional[str] = None,
819
880
  ) -> Union[BaseModel, Dict, str]:
820
881
  """Augment existing metadata with information from files or web pages.
@@ -843,11 +904,11 @@ class MetadataEditor:
843
904
  The option is provided in case OpenAI deprecated the 4o model.
844
905
  max_tokens (int): The maximum number of tokens to use when sending the content to OpenAI.
845
906
  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
907
+ llm_base_url (Optional[str]): The base URL for the LLM API. If None, the default URL is used which
847
908
  sends the request to OpenAI.
848
909
  azure_llm_base_url (Optional[str]): The base URL for the Azure LLM API. Typically used when an organization
849
910
  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
911
+ even if a llm_base_url is provided. If None, then the llm_base_url endpoint is used. If
851
912
  that's also None, then OpenAI is used.
852
913
 
853
914
  Returns:
@@ -946,7 +1007,7 @@ class MetadataEditor:
946
1007
  llm_model_name=llm_model_name,
947
1008
  tokenizer_model=tokenizer_model,
948
1009
  max_tokens=max_tokens,
949
- public_llm_base_url=public_llm_base_url,
1010
+ llm_base_url=llm_base_url,
950
1011
  azure_llm_base_url=azure_llm_base_url,
951
1012
  )
952
1013
 
@@ -1111,7 +1172,7 @@ class MetadataEditor:
1111
1172
  - "add": Adds a value to the specified path. If the path already exists, the value is replaced.
1112
1173
  - "remove": Removes the value at the specified path.
1113
1174
  - "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.
1175
+ - "test": Tests that the specified path contains the given value.
1115
1176
 
1116
1177
  The `path` is a string that uses a slash (`/`) notation to specify the location within the JSON document.
1117
1178
  For example, `/author` refers to the "author" field, and `/metadata/title` refers to the "title" field
@@ -1132,7 +1193,18 @@ class MetadataEditor:
1132
1193
  Example:
1133
1194
  ```python
1134
1195
  me = MetadataEditor(api_url = api_url, api_key = api_key)
1196
+
1197
+ # set the author of the project with ID 123 to "John Doe"
1135
1198
  me.patch_update_project_log_by_id(id=123, op="add", path="/author", value="John Doe")
1199
+
1200
+ # test that the author is "John Doe"
1201
+ me.patch_update_project_log_by_id(id=123, op="test", path="/author", value="John Doe")
1202
+
1203
+ # change the author to "Jane Doe"
1204
+ me.patch_update_project_log_by_id(id=123, op="replace", path="/author", value="Jane Doe")
1205
+
1206
+ # remove the value of author
1207
+ me.patch_update_project_log_by_id(id=123, op="remove", path="/author")
1136
1208
  ```
1137
1209
  """
1138
1210
  project_data = self.get_project_by_id(id)
@@ -1144,7 +1216,7 @@ class MetadataEditor:
1144
1216
  patches["value"] = value
1145
1217
  if isinstance(patches, dict):
1146
1218
  patches = [patches]
1147
- patches = validate_json_patches(patches) # depend on serverside validation
1219
+ patches = validate_json_patches(patches) # could instead only depend on serverside validation...
1148
1220
  self._apinterface.post_request(pth=pth + "{}", id=id, json={"patches": patches, "validate": False})
1149
1221
 
1150
1222
  ####################################################################################################################
@@ -1175,6 +1247,13 @@ class MetadataEditor:
1175
1247
  return row["data_type"]
1176
1248
 
1177
1249
  self._default_templates["data_type"] = self._default_templates.apply(standardize, axis=1)
1250
+
1251
+ try:
1252
+ new_index = templates.index.astype(int)
1253
+ except ValueError:
1254
+ pass
1255
+ else:
1256
+ templates.index = new_index
1178
1257
  return templates
1179
1258
 
1180
1259
  def get_template_by_uid(self, uid: str) -> pd.Series:
@@ -1242,9 +1321,12 @@ class MetadataEditor:
1242
1321
  if not (output_template_uid is None or output_template_uid == uid):
1243
1322
  new_klass, new_type, _ = self._get_metadata_class_and_type_and_UID(output_template_uid)
1244
1323
  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,
1324
+ # metadata = new_klass.model_validate(
1325
+ # remove_empty_from_dict(metadata.model_dump(mode="json", exclude_none=True, exclude_unset=True)),
1326
+ # strict=False,
1327
+ # )
1328
+ metadata = _iterated_validated_update_to_outline(
1329
+ new_klass, updates=metadata.model_dump(mode="json", exclude_none=True, exclude_unset=True)
1248
1330
  )
1249
1331
  except ValidationError as e:
1250
1332
  raise TemplateError(
@@ -1332,7 +1414,14 @@ class MetadataEditor:
1332
1414
  response = self._apinterface.get_request("collections")
1333
1415
  if "collections" not in response or len(response["collections"]) == 0:
1334
1416
  return pd.DataFrame([], columns=["id", "title", "created"]).set_index("id")
1335
- return pd.DataFrame(response["collections"]).set_index("id")
1417
+ df = pd.DataFrame(response["collections"]).set_index("id")
1418
+ try:
1419
+ new_index = df.index.astype(int)
1420
+ except ValueError:
1421
+ pass
1422
+ else:
1423
+ df.index = new_index
1424
+ return df
1336
1425
 
1337
1426
  def get_collection_by_id(self, id: int) -> pd.Series:
1338
1427
  """Get information about a collection like title, description, created date.
@@ -1449,7 +1538,15 @@ class MetadataEditor:
1449
1538
  ret = self._apinterface.get_request("editor?collection={}", id=collection, params=params)
1450
1539
  if len(ret["projects"]) == 0:
1451
1540
  return pd.DataFrame([], columns=["id", "type", "idno", "title"]).set_index("id")
1452
- return pd.DataFrame(ret["projects"]).set_index("id")
1541
+ df = pd.DataFrame(ret["projects"]).set_index("id")
1542
+
1543
+ try:
1544
+ new_index = df.index.astype(int)
1545
+ except ValueError:
1546
+ pass
1547
+ else:
1548
+ df.index = new_index
1549
+ return df
1453
1550
 
1454
1551
  def add_projects_to_collection(
1455
1552
  self, collection: Union[int, List[int]], id_format: str, projects: Union[int, List[int], str, List[str]]
@@ -1580,7 +1677,14 @@ class MetadataEditor:
1580
1677
  """
1581
1678
  response = self._apinterface.get_request("resources/{}", id=id)
1582
1679
  if "resources" in response and len(response["resources"]) > 0:
1583
- return pd.DataFrame(response["resources"])
1680
+ df = pd.DataFrame(response["resources"])
1681
+ try:
1682
+ new_index = df.index.astype(int)
1683
+ except ValueError:
1684
+ pass
1685
+ else:
1686
+ df.index = new_index
1687
+ return df
1584
1688
  else:
1585
1689
  return pd.DataFrame(columns=["id", "sid", "dctype", "title", "subtitle", "author", "filename", "dcformat"])
1586
1690
 
@@ -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.
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "pymetadataeditor"
3
- version = "0.2.0"
3
+ version = "0.3.0"
4
4
  description = "Metadata Editor client for python"
5
5
  authors = ["Gordon Blackadder <gblackadder@worldbank.org>"]
6
6
  readme = "README.md"
@@ -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