pymetadataeditor 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- pymetadataeditor/__init__.py +1 -0
- pymetadataeditor/interface.py +1641 -0
- pymetadataeditor/llm_helpers.py +145 -0
- pymetadataeditor/requester.py +160 -0
- pymetadataeditor/templates.py +617 -0
- pymetadataeditor/utils.py +262 -0
- pymetadataeditor-0.1.0.dist-info/LICENSE +21 -0
- pymetadataeditor-0.1.0.dist-info/METADATA +333 -0
- pymetadataeditor-0.1.0.dist-info/RECORD +10 -0
- pymetadataeditor-0.1.0.dist-info/WHEEL +4 -0
|
@@ -0,0 +1,145 @@
|
|
|
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
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
from io import BufferedReader
|
|
2
|
+
from json import JSONDecodeError
|
|
3
|
+
from ssl import SSLError as ssl_SSLError
|
|
4
|
+
from typing import Any, Dict, List, Optional, Union
|
|
5
|
+
|
|
6
|
+
import requests
|
|
7
|
+
from pydantic import AnyHttpUrl, BaseModel, ConfigDict, Field, SecretStr, model_validator
|
|
8
|
+
from requests.exceptions import HTTPError, SSLError
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class RequestsWithSpecificErrors(BaseModel):
|
|
12
|
+
model_config = ConfigDict(extra="forbid", validate_assignment=True)
|
|
13
|
+
|
|
14
|
+
api_url: AnyHttpUrl
|
|
15
|
+
api_key: SecretStr = Field(repr=False)
|
|
16
|
+
allow_http: bool = Field(
|
|
17
|
+
default=False,
|
|
18
|
+
description="API urls that begin HTTPS are favoured. Set allow_http=True to use of the less secure HTTP",
|
|
19
|
+
)
|
|
20
|
+
verify_ssl: bool = Field(
|
|
21
|
+
default=True,
|
|
22
|
+
description="Calls to the API are authenticated with an SSL certificate. "
|
|
23
|
+
"Set verify_ssl=False to remove this requirement",
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
@model_validator(mode="after")
|
|
27
|
+
def _check_https(self) -> Any:
|
|
28
|
+
if str(self.api_url).startswith("https") or self.allow_http:
|
|
29
|
+
return self
|
|
30
|
+
else:
|
|
31
|
+
raise ValueError(
|
|
32
|
+
f"URL scheme should be 'https' but got {self.api_url}"
|
|
33
|
+
"To allow the less secure use of 'http', set allow_unsecure=True"
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
def _request(
|
|
37
|
+
self,
|
|
38
|
+
method: str,
|
|
39
|
+
pth: str,
|
|
40
|
+
json: Optional[Dict] = None,
|
|
41
|
+
params: Optional[Dict[str, Union[str, List[str]]]] = None,
|
|
42
|
+
id: Optional[Union[int, str]] = None,
|
|
43
|
+
data: Optional[Dict[str, str]] = None,
|
|
44
|
+
files: Optional[Dict[str, BufferedReader]] = None,
|
|
45
|
+
) -> 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.
|
|
48
|
+
|
|
49
|
+
Args:
|
|
50
|
+
method (str): Either post or get
|
|
51
|
+
pth (str): The path appended to the API_URL to which the GET or POST request is sent.
|
|
52
|
+
|
|
53
|
+
Returns:
|
|
54
|
+
Dict[str, str]: The JSON response from the server, parsed into a dictionary.
|
|
55
|
+
|
|
56
|
+
Raises:
|
|
57
|
+
ValueError: If the URL does not start with 'https'.
|
|
58
|
+
PermissionError: If the response status code is 403, indicating that access is denied.
|
|
59
|
+
Exception: If the request fails due to other HTTP errors, with details of the status code and response text.
|
|
60
|
+
Exception: If any other unexpected error occurs during the request.
|
|
61
|
+
"""
|
|
62
|
+
method = method.lower()
|
|
63
|
+
assert method in ["get", "post"], f"unknown method {method}"
|
|
64
|
+
request_kwargs = {}
|
|
65
|
+
if method == "post":
|
|
66
|
+
assert params is None, "when using post, pass json not params"
|
|
67
|
+
request_kwargs["json"] = json
|
|
68
|
+
if data is not None:
|
|
69
|
+
request_kwargs["data"] = data
|
|
70
|
+
if files is not None:
|
|
71
|
+
request_kwargs["files"] = files
|
|
72
|
+
if method == "get" and params is not None:
|
|
73
|
+
assert json is None, "when using get, pass params not json"
|
|
74
|
+
request_kwargs["params"] = params
|
|
75
|
+
|
|
76
|
+
if "{" in pth:
|
|
77
|
+
assert id is not None, "If passing a url format, an id must be passed"
|
|
78
|
+
pth = pth.format(id)
|
|
79
|
+
url = str(self.api_url).strip("/") + "/" + pth.strip("/")
|
|
80
|
+
try:
|
|
81
|
+
response = None
|
|
82
|
+
response = requests.request(
|
|
83
|
+
method,
|
|
84
|
+
url,
|
|
85
|
+
verify=self.verify_ssl,
|
|
86
|
+
headers={"x-api-key": self.api_key.get_secret_value()},
|
|
87
|
+
**request_kwargs,
|
|
88
|
+
)
|
|
89
|
+
response.raise_for_status()
|
|
90
|
+
except (SSLError, ssl_SSLError) as e:
|
|
91
|
+
raise SSLError(
|
|
92
|
+
f"Usually this means the admin of {self.api_url} has not verified an SSL certificate.\n"
|
|
93
|
+
f"You can bypass the requirement by setting MetadataEditor.verify_ssl=False.\n{e}"
|
|
94
|
+
) from None
|
|
95
|
+
except HTTPError as e:
|
|
96
|
+
if response is None or response.status_code == 404:
|
|
97
|
+
error_msg = (
|
|
98
|
+
f"Page not found. Try checking the URL.\nGenerally the required URL looks like "
|
|
99
|
+
f"'https://<name_of_your_metadata_database>.org/index.php/api', but the URL that was passed "
|
|
100
|
+
f"was '{self.api_url}'"
|
|
101
|
+
)
|
|
102
|
+
raise HTTPError(error_msg) from None
|
|
103
|
+
elif response.status_code == 403:
|
|
104
|
+
raise PermissionError(
|
|
105
|
+
f"Access to that URL is denied for {url} " "Check that the API key is correct"
|
|
106
|
+
) 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
|
|
113
|
+
else:
|
|
114
|
+
raise HTTPError(f"Status Code: {response.status_code}, Response: {response.text}") from e
|
|
115
|
+
try:
|
|
116
|
+
json_response = response.json()
|
|
117
|
+
except (JSONDecodeError, requests.exceptions.JSONDecodeError) as e:
|
|
118
|
+
raise JSONDecodeError(
|
|
119
|
+
f"Error decoding JSON response: {e.msg}\n{response.text}\nFull Response: {response}", e.doc, e.pos
|
|
120
|
+
) from e
|
|
121
|
+
# if self.verify_ssl == False:
|
|
122
|
+
# we must have shown this warning the first time, ok to silence thereafter
|
|
123
|
+
return json_response
|
|
124
|
+
|
|
125
|
+
def get_request(
|
|
126
|
+
self, pth: str, id: Optional[Union[int, str]] = None, params: Optional[Dict[str, Union[str, List[str]]]] = None
|
|
127
|
+
) -> Dict:
|
|
128
|
+
"""
|
|
129
|
+
Args:
|
|
130
|
+
pth (str): The path appended to the API_URL to which the GET request is sent.
|
|
131
|
+
id (optional int or str): The id of a specific collection or project.
|
|
132
|
+
If not none, then pth should contain '{}' where the id ought to go.
|
|
133
|
+
params (optional dict): additional parameters to send with the get request such as 'keywords'
|
|
134
|
+
|
|
135
|
+
Returns:
|
|
136
|
+
Dict[str, str]: The JSON response from the server, parsed into a dictionary.
|
|
137
|
+
"""
|
|
138
|
+
return self._request("get", pth=pth, id=id, params=params)
|
|
139
|
+
|
|
140
|
+
def post_request(
|
|
141
|
+
self,
|
|
142
|
+
pth: str,
|
|
143
|
+
json: Optional[Dict] = None,
|
|
144
|
+
id: Optional[Union[int, str]] = None,
|
|
145
|
+
data: Optional[Dict[str, str]] = None,
|
|
146
|
+
files: Optional[Dict[str, BufferedReader]] = None,
|
|
147
|
+
):
|
|
148
|
+
"""
|
|
149
|
+
Args:
|
|
150
|
+
pth (str): The path appended to the API_URL to which the POST request is sent.
|
|
151
|
+
json (optional dict): The JSON data to send with the POST request.
|
|
152
|
+
id (optional int or str): The id of a specific project, collection or template etc.
|
|
153
|
+
If not none, then pth should contain '{}' where the id ought to go.
|
|
154
|
+
data (optional dict): The data to send with the POST request.
|
|
155
|
+
files (optional dict): The files to send with the POST request.
|
|
156
|
+
|
|
157
|
+
Returns:
|
|
158
|
+
Dict[str, str]: The JSON response from the server, parsed into a dictionary.
|
|
159
|
+
"""
|
|
160
|
+
return self._request("post", pth=pth, id=id, json=json, data=data, files=files)
|