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.
@@ -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)