unique_toolkit 1.1.2__py3-none-any.whl → 1.1.4__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.
- unique_toolkit/_common/api_calling/human_verification_manager.py +250 -0
- unique_toolkit/_common/endpoint_builder.py +90 -27
- unique_toolkit/_common/endpoint_requestor.py +44 -26
- unique_toolkit/_common/pydantic_helpers.py +130 -6
- unique_toolkit/_common/string_utilities.py +89 -0
- unique_toolkit/_common/validators.py +8 -1
- {unique_toolkit-1.1.2.dist-info → unique_toolkit-1.1.4.dist-info}/METADATA +8 -1
- {unique_toolkit-1.1.2.dist-info → unique_toolkit-1.1.4.dist-info}/RECORD +10 -8
- {unique_toolkit-1.1.2.dist-info → unique_toolkit-1.1.4.dist-info}/LICENSE +0 -0
- {unique_toolkit-1.1.2.dist-info → unique_toolkit-1.1.4.dist-info}/WHEEL +0 -0
@@ -0,0 +1,250 @@
|
|
1
|
+
import hashlib
|
2
|
+
from datetime import datetime
|
3
|
+
from logging import Logger
|
4
|
+
from typing import Any, Generic
|
5
|
+
|
6
|
+
import jinja2
|
7
|
+
from pydantic import BaseModel
|
8
|
+
|
9
|
+
from unique_toolkit._common.endpoint_builder import (
|
10
|
+
ApiOperationProtocol,
|
11
|
+
PathParamsSpec,
|
12
|
+
PathParamsType,
|
13
|
+
PayloadParamSpec,
|
14
|
+
PayloadType,
|
15
|
+
ResponseType,
|
16
|
+
)
|
17
|
+
from unique_toolkit._common.endpoint_requestor import (
|
18
|
+
RequestorType,
|
19
|
+
build_requestor,
|
20
|
+
)
|
21
|
+
from unique_toolkit._common.pydantic_helpers import create_union_model
|
22
|
+
from unique_toolkit._common.string_utilities import (
|
23
|
+
dict_to_markdown_table,
|
24
|
+
extract_dicts_from_string,
|
25
|
+
)
|
26
|
+
from unique_toolkit.chat.schemas import ChatMessage, ChatMessageRole
|
27
|
+
|
28
|
+
|
29
|
+
class HumanConfirmation(BaseModel):
|
30
|
+
payload_hash: str
|
31
|
+
time_stamp: datetime
|
32
|
+
|
33
|
+
|
34
|
+
NEXT_USER_MESSAGE_JINJA2_TEMPLATE = jinja2.Template("""I confirm the api call with the following data:
|
35
|
+
```json
|
36
|
+
{{ api_call_as_json }}
|
37
|
+
```""")
|
38
|
+
|
39
|
+
|
40
|
+
ASSISTANT_CONFIRMATION_MESSAGE_JINJA2_TEMPLATE = jinja2.Template("""I would like to call the api with the following data:
|
41
|
+
|
42
|
+
{{ api_call_as_markdown_table }}
|
43
|
+
|
44
|
+
[{{ button_text }}](https://prompt={{ next_user_message | urlencode }})""")
|
45
|
+
|
46
|
+
|
47
|
+
class HumanVerificationManagerForApiCalling(
|
48
|
+
Generic[
|
49
|
+
PathParamsSpec,
|
50
|
+
PathParamsType,
|
51
|
+
PayloadParamSpec,
|
52
|
+
PayloadType,
|
53
|
+
ResponseType,
|
54
|
+
]
|
55
|
+
):
|
56
|
+
"""
|
57
|
+
Manages human verification for api calling.
|
58
|
+
|
59
|
+
The idea is that the manager is able to produce the verification message to the user
|
60
|
+
and to detect an api call from the user message.
|
61
|
+
|
62
|
+
If it detects such a verification message in the user message, it will call the api
|
63
|
+
and incorporate the response into the user message.
|
64
|
+
"""
|
65
|
+
|
66
|
+
def __init__(
|
67
|
+
self,
|
68
|
+
*,
|
69
|
+
logger: Logger,
|
70
|
+
operation: type[
|
71
|
+
ApiOperationProtocol[
|
72
|
+
PathParamsSpec,
|
73
|
+
PathParamsType,
|
74
|
+
PayloadParamSpec,
|
75
|
+
PayloadType,
|
76
|
+
ResponseType,
|
77
|
+
]
|
78
|
+
],
|
79
|
+
requestor_type: RequestorType = RequestorType.REQUESTS,
|
80
|
+
**kwargs: dict[str, Any],
|
81
|
+
):
|
82
|
+
self._logger = logger
|
83
|
+
self._operation = operation
|
84
|
+
|
85
|
+
# Create internal models for this manager instance
|
86
|
+
|
87
|
+
class ConcreteApiCall(BaseModel):
|
88
|
+
confirmation: HumanConfirmation
|
89
|
+
payload: self._operation.payload_model() # type: ignore
|
90
|
+
|
91
|
+
self._api_call_model = ConcreteApiCall
|
92
|
+
|
93
|
+
self._combined_params_model = create_union_model(
|
94
|
+
model_type_a=self._operation.path_params_model(),
|
95
|
+
model_type_b=self._operation.payload_model(),
|
96
|
+
)
|
97
|
+
self._requestor_type = requestor_type
|
98
|
+
self._requestor = build_requestor(
|
99
|
+
requestor_type=requestor_type,
|
100
|
+
operation_type=operation,
|
101
|
+
combined_model=self._combined_params_model,
|
102
|
+
**kwargs,
|
103
|
+
)
|
104
|
+
|
105
|
+
def detect_api_calls_from_user_message(
|
106
|
+
self, *, last_assistant_message: ChatMessage, user_message: str
|
107
|
+
) -> PayloadType | None:
|
108
|
+
user_message_dicts = extract_dicts_from_string(user_message)
|
109
|
+
if len(user_message_dicts) == 0:
|
110
|
+
return None
|
111
|
+
|
112
|
+
user_message_dicts.reverse()
|
113
|
+
for user_message_dict in user_message_dicts:
|
114
|
+
try:
|
115
|
+
# Convert dict to payload model first, then create payload
|
116
|
+
api_call = self._api_call_model.model_validate(
|
117
|
+
user_message_dict, by_alias=True, by_name=True
|
118
|
+
)
|
119
|
+
if self._verify_human_verification(
|
120
|
+
api_call.confirmation, last_assistant_message
|
121
|
+
):
|
122
|
+
return api_call.payload
|
123
|
+
except Exception as e:
|
124
|
+
self._logger.error(f"Error detecting api calls from user message: {e}")
|
125
|
+
|
126
|
+
return None
|
127
|
+
|
128
|
+
def _verify_human_verification(
|
129
|
+
self, confirmation: HumanConfirmation, last_assistant_message: ChatMessage
|
130
|
+
) -> bool:
|
131
|
+
if (
|
132
|
+
last_assistant_message.role != ChatMessageRole.ASSISTANT
|
133
|
+
or last_assistant_message.content is None
|
134
|
+
):
|
135
|
+
self._logger.error(
|
136
|
+
"Last assistant message is not an assistant message or content is empty."
|
137
|
+
)
|
138
|
+
return False
|
139
|
+
|
140
|
+
return confirmation.payload_hash in last_assistant_message.content
|
141
|
+
|
142
|
+
def _create_next_user_message(self, payload: PayloadType) -> str:
|
143
|
+
api_call = self._api_call_model(
|
144
|
+
payload=payload,
|
145
|
+
confirmation=HumanConfirmation(
|
146
|
+
payload_hash=hashlib.sha256(
|
147
|
+
payload.model_dump_json().encode()
|
148
|
+
).hexdigest(),
|
149
|
+
time_stamp=datetime.now(),
|
150
|
+
),
|
151
|
+
)
|
152
|
+
return NEXT_USER_MESSAGE_JINJA2_TEMPLATE.render(
|
153
|
+
api_call_as_json=api_call.model_dump_json(indent=2)
|
154
|
+
)
|
155
|
+
|
156
|
+
def create_assistant_confirmation_message(self, *, payload: PayloadType) -> str:
|
157
|
+
return ASSISTANT_CONFIRMATION_MESSAGE_JINJA2_TEMPLATE.render(
|
158
|
+
api_call_as_markdown_table=dict_to_markdown_table(payload.model_dump()),
|
159
|
+
button_text="Please confirm the call by pressing this button.",
|
160
|
+
next_user_message=self._create_next_user_message(payload),
|
161
|
+
)
|
162
|
+
|
163
|
+
def call_api(
|
164
|
+
self,
|
165
|
+
*,
|
166
|
+
headers: dict[str, str],
|
167
|
+
path_params: PathParamsType,
|
168
|
+
payload: PayloadType,
|
169
|
+
) -> ResponseType:
|
170
|
+
params = path_params.model_dump()
|
171
|
+
params.update(payload.model_dump())
|
172
|
+
|
173
|
+
response = self._requestor.request(
|
174
|
+
headers=headers,
|
175
|
+
**params,
|
176
|
+
)
|
177
|
+
return self._operation.handle_response(response)
|
178
|
+
|
179
|
+
|
180
|
+
if __name__ == "__main__":
|
181
|
+
import logging
|
182
|
+
from string import Template
|
183
|
+
|
184
|
+
from unique_toolkit._common.endpoint_builder import (
|
185
|
+
EndpointMethods,
|
186
|
+
build_api_operation,
|
187
|
+
)
|
188
|
+
|
189
|
+
class GetUserPathParams(BaseModel):
|
190
|
+
user_id: int
|
191
|
+
|
192
|
+
class GetUserRequestBody(BaseModel):
|
193
|
+
include_profile: bool = False
|
194
|
+
|
195
|
+
class UserResponse(BaseModel):
|
196
|
+
id: int
|
197
|
+
name: str
|
198
|
+
|
199
|
+
class CombinedParams(GetUserPathParams, GetUserRequestBody):
|
200
|
+
pass
|
201
|
+
|
202
|
+
UserEndpoint = build_api_operation(
|
203
|
+
method=EndpointMethods.GET,
|
204
|
+
url_template=Template("https://api.example.com/users/{user_id}"),
|
205
|
+
path_params_constructor=GetUserPathParams,
|
206
|
+
payload_constructor=GetUserRequestBody,
|
207
|
+
response_model_type=UserResponse,
|
208
|
+
)
|
209
|
+
|
210
|
+
human_verification_manager = HumanVerificationManagerForApiCalling(
|
211
|
+
logger=logging.getLogger(__name__),
|
212
|
+
operation=UserEndpoint,
|
213
|
+
requestor_type=RequestorType.FAKE,
|
214
|
+
return_value={"id": 100, "name": "John Doe"},
|
215
|
+
)
|
216
|
+
|
217
|
+
payload = GetUserRequestBody(include_profile=True)
|
218
|
+
|
219
|
+
api_call = human_verification_manager._api_call_model(
|
220
|
+
payload=payload,
|
221
|
+
confirmation=HumanConfirmation(
|
222
|
+
payload_hash=hashlib.sha256(payload.model_dump_json().encode()).hexdigest(),
|
223
|
+
time_stamp=datetime.now(),
|
224
|
+
),
|
225
|
+
)
|
226
|
+
|
227
|
+
last_assistant_message = ChatMessage(
|
228
|
+
role=ChatMessageRole.ASSISTANT,
|
229
|
+
text=api_call.confirmation.payload_hash,
|
230
|
+
chat_id="123",
|
231
|
+
)
|
232
|
+
|
233
|
+
user_message_with_api_call = human_verification_manager._create_next_user_message(
|
234
|
+
payload=payload
|
235
|
+
)
|
236
|
+
|
237
|
+
print(user_message_with_api_call)
|
238
|
+
|
239
|
+
payload = human_verification_manager.detect_api_calls_from_user_message(
|
240
|
+
user_message=user_message_with_api_call,
|
241
|
+
last_assistant_message=last_assistant_message,
|
242
|
+
)
|
243
|
+
|
244
|
+
if payload is None:
|
245
|
+
print("❌ Detection failed - payload is None")
|
246
|
+
exit(1)
|
247
|
+
else:
|
248
|
+
print("✅ Detection successful!")
|
249
|
+
print(f"Payload: {payload.model_dump()}")
|
250
|
+
print("✅ Dict extraction from string works correctly!")
|
@@ -12,9 +12,11 @@ from typing import (
|
|
12
12
|
ParamSpec,
|
13
13
|
Protocol,
|
14
14
|
TypeVar,
|
15
|
+
cast,
|
15
16
|
)
|
16
17
|
|
17
18
|
from pydantic import BaseModel
|
19
|
+
from typing_extensions import deprecated
|
18
20
|
|
19
21
|
# Paramspecs
|
20
22
|
PayloadParamSpec = ParamSpec("PayloadParamSpec")
|
@@ -31,7 +33,7 @@ PayloadType = TypeVar("PayloadType", bound=BaseModel)
|
|
31
33
|
ModelConstructor = Callable[..., BaseModel]
|
32
34
|
|
33
35
|
|
34
|
-
class
|
36
|
+
class HttpMethods(StrEnum):
|
35
37
|
GET = "GET"
|
36
38
|
POST = "POST"
|
37
39
|
PUT = "PUT"
|
@@ -41,8 +43,11 @@ class EndpointMethods(StrEnum):
|
|
41
43
|
HEAD = "HEAD"
|
42
44
|
|
43
45
|
|
44
|
-
#
|
45
|
-
|
46
|
+
# Backward compatibility TODO: Remove in 2.0.0.
|
47
|
+
EndpointMethods = HttpMethods
|
48
|
+
|
49
|
+
|
50
|
+
class ApiOperationProtocol(
|
46
51
|
Protocol,
|
47
52
|
Generic[
|
48
53
|
PathParamsSpec,
|
@@ -52,9 +57,14 @@ class EndpointClassProtocol(
|
|
52
57
|
ResponseType,
|
53
58
|
],
|
54
59
|
):
|
55
|
-
|
56
|
-
|
57
|
-
|
60
|
+
@staticmethod
|
61
|
+
def path_params_model() -> type[PathParamsType]: ...
|
62
|
+
|
63
|
+
@staticmethod
|
64
|
+
def payload_model() -> type[PayloadType]: ...
|
65
|
+
|
66
|
+
@staticmethod
|
67
|
+
def response_model() -> type[ResponseType]: ...
|
58
68
|
|
59
69
|
@staticmethod
|
60
70
|
def create_url(
|
@@ -78,18 +88,23 @@ class EndpointClassProtocol(
|
|
78
88
|
@staticmethod
|
79
89
|
def request_method() -> EndpointMethods: ...
|
80
90
|
|
91
|
+
@staticmethod
|
92
|
+
def models_from_combined(
|
93
|
+
combined: dict[str, Any],
|
94
|
+
) -> tuple[PathParamsType, PayloadType]: ...
|
95
|
+
|
81
96
|
|
82
97
|
# Model for any client to implement
|
83
|
-
def
|
98
|
+
def build_api_operation(
|
84
99
|
*,
|
85
|
-
method:
|
100
|
+
method: HttpMethods,
|
86
101
|
url_template: Template,
|
87
102
|
path_params_constructor: Callable[PathParamsSpec, PathParamsType],
|
88
103
|
payload_constructor: Callable[PayloadParamSpec, PayloadType],
|
89
104
|
response_model_type: type[ResponseType],
|
90
105
|
dump_options: dict | None = None,
|
91
106
|
) -> type[
|
92
|
-
|
107
|
+
ApiOperationProtocol[
|
93
108
|
PathParamsSpec,
|
94
109
|
PathParamsType,
|
95
110
|
PayloadParamSpec,
|
@@ -107,11 +122,6 @@ def build_endpoint_class(
|
|
107
122
|
"""
|
108
123
|
|
109
124
|
# Verify that the path_params_constructor and payload_constructor are valid pydantic models
|
110
|
-
if not isinstance(path_params_constructor, type(BaseModel)):
|
111
|
-
raise ValueError("path_params_constructor must be a pydantic model")
|
112
|
-
if not isinstance(payload_constructor, type(BaseModel)):
|
113
|
-
raise ValueError("payload_constructor must be a pydantic model")
|
114
|
-
|
115
125
|
if not dump_options:
|
116
126
|
dump_options = {
|
117
127
|
"exclude_unset": True,
|
@@ -119,10 +129,18 @@ def build_endpoint_class(
|
|
119
129
|
"exclude_defaults": True,
|
120
130
|
}
|
121
131
|
|
122
|
-
class
|
123
|
-
|
124
|
-
|
125
|
-
|
132
|
+
class Operation(ApiOperationProtocol):
|
133
|
+
@staticmethod
|
134
|
+
def path_params_model() -> type[PathParamsType]:
|
135
|
+
return cast(type[PathParamsType], path_params_constructor)
|
136
|
+
|
137
|
+
@staticmethod
|
138
|
+
def payload_model() -> type[PayloadType]:
|
139
|
+
return cast(type[PayloadType], payload_constructor)
|
140
|
+
|
141
|
+
@staticmethod
|
142
|
+
def response_model() -> type[ResponseType]:
|
143
|
+
return response_model_type
|
126
144
|
|
127
145
|
@staticmethod
|
128
146
|
def create_url(
|
@@ -130,7 +148,7 @@ def build_endpoint_class(
|
|
130
148
|
**kwargs: PathParamsSpec.kwargs,
|
131
149
|
) -> str:
|
132
150
|
"""Create URL from path parameters."""
|
133
|
-
path_model =
|
151
|
+
path_model = Operation.path_params_model()(*args, **kwargs)
|
134
152
|
path_dict = path_model.model_dump(**dump_options)
|
135
153
|
|
136
154
|
# Extract expected path parameters from template
|
@@ -158,7 +176,7 @@ def build_endpoint_class(
|
|
158
176
|
*args: PayloadParamSpec.args, **kwargs: PayloadParamSpec.kwargs
|
159
177
|
) -> dict[str, Any]:
|
160
178
|
"""Create request body payload."""
|
161
|
-
request_model =
|
179
|
+
request_model = Operation.payload_model()(*args, **kwargs)
|
162
180
|
return request_model.model_dump(**dump_options)
|
163
181
|
|
164
182
|
@staticmethod
|
@@ -167,13 +185,58 @@ def build_endpoint_class(
|
|
167
185
|
|
168
186
|
@staticmethod
|
169
187
|
def handle_response(response: dict[str, Any]) -> ResponseType:
|
170
|
-
return
|
188
|
+
return Operation.response_model().model_validate(response)
|
171
189
|
|
172
190
|
@staticmethod
|
173
|
-
def request_method() ->
|
191
|
+
def request_method() -> HttpMethods:
|
174
192
|
return method
|
175
193
|
|
176
|
-
|
194
|
+
@staticmethod
|
195
|
+
def models_from_combined(
|
196
|
+
combined: dict[str, Any],
|
197
|
+
) -> tuple[PathParamsType, PayloadType]:
|
198
|
+
path_params = Operation.path_params_model().model_validate(
|
199
|
+
combined, by_alias=True, by_name=True
|
200
|
+
)
|
201
|
+
payload = Operation.payload_model().model_validate(
|
202
|
+
combined, by_alias=True, by_name=True
|
203
|
+
)
|
204
|
+
return path_params, payload
|
205
|
+
|
206
|
+
return Operation
|
207
|
+
|
208
|
+
|
209
|
+
@deprecated("Use ApiOperationProtocol instead")
|
210
|
+
class EndpointClassProtocol(ApiOperationProtocol):
|
211
|
+
pass
|
212
|
+
|
213
|
+
|
214
|
+
@deprecated("Use build_api_operation instead")
|
215
|
+
def build_endpoint_class(
|
216
|
+
*,
|
217
|
+
method: HttpMethods,
|
218
|
+
url_template: Template,
|
219
|
+
path_params_constructor: Callable[PathParamsSpec, PathParamsType],
|
220
|
+
payload_constructor: Callable[PayloadParamSpec, PayloadType],
|
221
|
+
response_model_type: type[ResponseType],
|
222
|
+
dump_options: dict | None = None,
|
223
|
+
) -> type[
|
224
|
+
ApiOperationProtocol[
|
225
|
+
PathParamsSpec,
|
226
|
+
PathParamsType,
|
227
|
+
PayloadParamSpec,
|
228
|
+
PayloadType,
|
229
|
+
ResponseType,
|
230
|
+
]
|
231
|
+
]:
|
232
|
+
return build_api_operation(
|
233
|
+
method=method,
|
234
|
+
url_template=url_template,
|
235
|
+
path_params_constructor=path_params_constructor,
|
236
|
+
payload_constructor=payload_constructor,
|
237
|
+
response_model_type=response_model_type,
|
238
|
+
dump_options=dump_options,
|
239
|
+
)
|
177
240
|
|
178
241
|
|
179
242
|
if __name__ == "__main__":
|
@@ -195,7 +258,7 @@ if __name__ == "__main__":
|
|
195
258
|
name: str
|
196
259
|
|
197
260
|
# Example usage of make_endpoint_class
|
198
|
-
|
261
|
+
UserOperation = build_endpoint_class(
|
199
262
|
url_template=Template("/users/${user_id}"),
|
200
263
|
path_params_constructor=GetUserPathParams,
|
201
264
|
payload_constructor=GetUserRequestBody,
|
@@ -204,15 +267,15 @@ if __name__ == "__main__":
|
|
204
267
|
)
|
205
268
|
|
206
269
|
# Create URL from path parameters
|
207
|
-
url =
|
270
|
+
url = UserOperation.create_url(user_id=123)
|
208
271
|
print(f"URL: {url}")
|
209
272
|
|
210
273
|
# Create payload from request body parameters
|
211
|
-
payload =
|
274
|
+
payload = UserOperation.create_payload(include_profile=True)
|
212
275
|
print(f"Payload: {payload}")
|
213
276
|
|
214
277
|
# Create response from endpoint
|
215
|
-
response =
|
278
|
+
response = UserOperation.handle_response(
|
216
279
|
{
|
217
280
|
"id": 123,
|
218
281
|
"name": "John Doe",
|
@@ -5,8 +5,8 @@ from pydantic import BaseModel
|
|
5
5
|
from typing_extensions import ParamSpec
|
6
6
|
|
7
7
|
from unique_toolkit._common.endpoint_builder import (
|
8
|
-
|
9
|
-
|
8
|
+
ApiOperationProtocol,
|
9
|
+
HttpMethods,
|
10
10
|
PathParamsSpec,
|
11
11
|
PathParamsType,
|
12
12
|
PayloadParamSpec,
|
@@ -21,19 +21,22 @@ CombinedParamsSpec = ParamSpec("CombinedParamsSpec")
|
|
21
21
|
CombinedParamsType = TypeVar("CombinedParamsType", bound=BaseModel)
|
22
22
|
|
23
23
|
|
24
|
-
|
24
|
+
ResponseT_co = TypeVar("ResponseT_co", bound=BaseModel, covariant=True)
|
25
|
+
|
26
|
+
|
27
|
+
class EndpointRequestorProtocol(Protocol, Generic[CombinedParamsSpec, ResponseT_co]):
|
25
28
|
@classmethod
|
26
29
|
def request(
|
27
30
|
cls,
|
28
31
|
headers: dict[str, str],
|
29
32
|
*args: CombinedParamsSpec.args,
|
30
33
|
**kwargs: CombinedParamsSpec.kwargs,
|
31
|
-
) ->
|
34
|
+
) -> ResponseT_co: ...
|
32
35
|
|
33
36
|
|
34
37
|
def build_fake_requestor(
|
35
|
-
|
36
|
-
|
38
|
+
operation_type: type[
|
39
|
+
ApiOperationProtocol[
|
37
40
|
PathParamsSpec,
|
38
41
|
PathParamsType,
|
39
42
|
PayloadParamSpec,
|
@@ -45,7 +48,7 @@ def build_fake_requestor(
|
|
45
48
|
return_value: dict[str, Any],
|
46
49
|
) -> type[EndpointRequestorProtocol[CombinedParamsSpec, ResponseType]]:
|
47
50
|
class FakeRequestor(EndpointRequestorProtocol):
|
48
|
-
|
51
|
+
_operation = operation_type
|
49
52
|
|
50
53
|
@classmethod
|
51
54
|
def request(
|
@@ -55,20 +58,22 @@ def build_fake_requestor(
|
|
55
58
|
**kwargs: CombinedParamsSpec.kwargs,
|
56
59
|
) -> ResponseType:
|
57
60
|
try:
|
58
|
-
|
61
|
+
path_params, payload_model = cls._operation.models_from_combined(
|
62
|
+
combined=kwargs
|
63
|
+
)
|
59
64
|
except Exception as e:
|
60
65
|
raise ValueError(
|
61
66
|
f"Invalid parameters passed to combined model {combined_model.__name__}: {e}"
|
62
67
|
)
|
63
68
|
|
64
|
-
return cls.
|
69
|
+
return cls._operation.handle_response(return_value)
|
65
70
|
|
66
71
|
return FakeRequestor
|
67
72
|
|
68
73
|
|
69
74
|
def build_request_requestor(
|
70
|
-
|
71
|
-
|
75
|
+
operation_type: type[
|
76
|
+
ApiOperationProtocol[
|
72
77
|
PathParamsSpec,
|
73
78
|
PathParamsType,
|
74
79
|
PayloadParamSpec,
|
@@ -77,11 +82,11 @@ def build_request_requestor(
|
|
77
82
|
]
|
78
83
|
],
|
79
84
|
combined_model: Callable[CombinedParamsSpec, CombinedParamsType],
|
80
|
-
) -> type[EndpointRequestorProtocol]:
|
85
|
+
) -> type[EndpointRequestorProtocol[CombinedParamsSpec, ResponseType]]:
|
81
86
|
import requests
|
82
87
|
|
83
88
|
class RequestRequestor(EndpointRequestorProtocol):
|
84
|
-
|
89
|
+
_operation = operation_type
|
85
90
|
|
86
91
|
@classmethod
|
87
92
|
def request(
|
@@ -90,18 +95,21 @@ def build_request_requestor(
|
|
90
95
|
*args: CombinedParamsSpec.args,
|
91
96
|
**kwargs: CombinedParamsSpec.kwargs,
|
92
97
|
) -> ResponseType:
|
93
|
-
|
94
|
-
|
95
|
-
|
98
|
+
# Create separate instances for path params and payload using endpoint helper
|
99
|
+
path_params, payload_model = cls._operation.models_from_combined(
|
100
|
+
combined=kwargs
|
96
101
|
)
|
97
102
|
|
103
|
+
url = cls._operation.create_url_from_model(path_params)
|
104
|
+
payload = cls._operation.create_payload_from_model(payload_model)
|
105
|
+
|
98
106
|
response = requests.request(
|
99
|
-
method=cls.
|
107
|
+
method=cls._operation.request_method(),
|
100
108
|
url=url,
|
101
109
|
headers=headers,
|
102
110
|
json=payload,
|
103
111
|
)
|
104
|
-
return cls.
|
112
|
+
return cls._operation.handle_response(response.json())
|
105
113
|
|
106
114
|
return RequestRequestor
|
107
115
|
|
@@ -113,8 +121,8 @@ class RequestorType(StrEnum):
|
|
113
121
|
|
114
122
|
def build_requestor(
|
115
123
|
requestor_type: RequestorType,
|
116
|
-
|
117
|
-
|
124
|
+
operation_type: type[
|
125
|
+
ApiOperationProtocol[
|
118
126
|
PathParamsSpec,
|
119
127
|
PathParamsType,
|
120
128
|
PayloadParamSpec,
|
@@ -129,13 +137,13 @@ def build_requestor(
|
|
129
137
|
match requestor_type:
|
130
138
|
case RequestorType.REQUESTS:
|
131
139
|
return build_request_requestor(
|
132
|
-
|
140
|
+
operation_type=operation_type, combined_model=combined_model
|
133
141
|
)
|
134
142
|
case RequestorType.FAKE:
|
135
143
|
if return_value is None:
|
136
144
|
raise ValueError("return_value is required for fake requestor")
|
137
145
|
return build_fake_requestor(
|
138
|
-
|
146
|
+
operation_type=operation_type,
|
139
147
|
combined_model=combined_model,
|
140
148
|
return_value=return_value,
|
141
149
|
)
|
@@ -144,7 +152,7 @@ def build_requestor(
|
|
144
152
|
if __name__ == "__main__":
|
145
153
|
from string import Template
|
146
154
|
|
147
|
-
from unique_toolkit._common.endpoint_builder import
|
155
|
+
from unique_toolkit._common.endpoint_builder import build_api_operation
|
148
156
|
|
149
157
|
class GetUserPathParams(BaseModel):
|
150
158
|
user_id: int
|
@@ -159,8 +167,8 @@ if __name__ == "__main__":
|
|
159
167
|
class CombinedParams(GetUserPathParams, GetUserRequestBody):
|
160
168
|
pass
|
161
169
|
|
162
|
-
UserEndpoint =
|
163
|
-
method=
|
170
|
+
UserEndpoint = build_api_operation(
|
171
|
+
method=HttpMethods.GET,
|
164
172
|
url_template=Template("https://api.example.com/users/{user_id}"),
|
165
173
|
path_params_constructor=GetUserPathParams,
|
166
174
|
payload_constructor=GetUserRequestBody,
|
@@ -168,7 +176,7 @@ if __name__ == "__main__":
|
|
168
176
|
)
|
169
177
|
|
170
178
|
FakeUserRequestor = build_fake_requestor(
|
171
|
-
|
179
|
+
operation_type=UserEndpoint,
|
172
180
|
combined_model=CombinedParams,
|
173
181
|
return_value={"id": 100, "name": "John Doe"},
|
174
182
|
)
|
@@ -180,6 +188,16 @@ if __name__ == "__main__":
|
|
180
188
|
include_profile=True,
|
181
189
|
)
|
182
190
|
|
191
|
+
RequestRequstor = build_request_requestor(
|
192
|
+
operation_type=UserEndpoint,
|
193
|
+
combined_model=CombinedParams,
|
194
|
+
)
|
195
|
+
|
196
|
+
# Check type hints
|
197
|
+
response = RequestRequstor().request(
|
198
|
+
headers={"a": "b"}, user_id=123, include_profile=True
|
199
|
+
)
|
200
|
+
|
183
201
|
print(response.model_dump())
|
184
202
|
print(response.model_json_schema())
|
185
203
|
print(response.id)
|
@@ -1,7 +1,9 @@
|
|
1
1
|
import logging
|
2
|
+
import warnings
|
3
|
+
from typing import TypeVar
|
2
4
|
|
3
5
|
import humps
|
4
|
-
from pydantic import ConfigDict
|
6
|
+
from pydantic import BaseModel, ConfigDict, Field, create_model
|
5
7
|
from pydantic.fields import ComputedFieldInfo, FieldInfo
|
6
8
|
|
7
9
|
logger = logging.getLogger(__name__)
|
@@ -19,11 +21,133 @@ def model_title_generator(model: type) -> str:
|
|
19
21
|
|
20
22
|
|
21
23
|
def get_configuration_dict(**kwargs) -> ConfigDict:
|
22
|
-
|
24
|
+
config = {
|
23
25
|
# alias_generator=to_camel,
|
24
|
-
field_title_generator
|
25
|
-
model_title_generator
|
26
|
+
"field_title_generator": field_title_generator,
|
27
|
+
"model_title_generator": model_title_generator,
|
26
28
|
# populate_by_name=True,
|
27
29
|
# protected_namespaces=(),
|
28
|
-
|
29
|
-
)
|
30
|
+
}
|
31
|
+
config.update(kwargs)
|
32
|
+
return ConfigDict(**config)
|
33
|
+
|
34
|
+
|
35
|
+
ModelTypeA = TypeVar("ModelTypeA", bound=BaseModel)
|
36
|
+
ModelTypeB = TypeVar("ModelTypeB", bound=BaseModel)
|
37
|
+
|
38
|
+
|
39
|
+
def _name_intersection(
|
40
|
+
model_type_a: type[ModelTypeA], model_type_b: type[ModelTypeB]
|
41
|
+
) -> set[str]:
|
42
|
+
field_names_a = set(model_type_a.model_fields.keys())
|
43
|
+
field_names_b = set(model_type_b.model_fields.keys())
|
44
|
+
return field_names_a.intersection(field_names_b)
|
45
|
+
|
46
|
+
|
47
|
+
def create_union_model(
|
48
|
+
model_type_a: type[ModelTypeA],
|
49
|
+
model_type_b: type[ModelTypeB],
|
50
|
+
model_name: str = "UnionModel",
|
51
|
+
config_dict: ConfigDict = ConfigDict(),
|
52
|
+
) -> type[BaseModel]:
|
53
|
+
"""
|
54
|
+
Creates a model that is the union of the two input models.
|
55
|
+
Prefers fields from model_type_a.
|
56
|
+
"""
|
57
|
+
|
58
|
+
if len(_name_intersection(model_type_a, model_type_b)) > 0:
|
59
|
+
warnings.warn(
|
60
|
+
f"The two input models have common field names: {_name_intersection(model_type_a, model_type_b)}"
|
61
|
+
)
|
62
|
+
|
63
|
+
fields = {}
|
64
|
+
for name, field in model_type_b.model_fields.items():
|
65
|
+
fields[name] = (field.annotation, field)
|
66
|
+
for name, field in model_type_a.model_fields.items():
|
67
|
+
fields[name] = (field.annotation, field)
|
68
|
+
|
69
|
+
CombinedModel = create_model(model_name, __config__=config_dict, **fields)
|
70
|
+
return CombinedModel
|
71
|
+
|
72
|
+
|
73
|
+
def create_intersection_model(
|
74
|
+
model_type_a: type[ModelTypeA],
|
75
|
+
model_type_b: type[ModelTypeB],
|
76
|
+
model_name: str = "IntersectionModel",
|
77
|
+
config_dict: ConfigDict = ConfigDict(),
|
78
|
+
) -> type[BaseModel]:
|
79
|
+
"""
|
80
|
+
Creates a model that is the intersection of the two input models.
|
81
|
+
Prefers fields from model_type_a.
|
82
|
+
"""
|
83
|
+
|
84
|
+
if len(_name_intersection(model_type_a, model_type_b)) == 0:
|
85
|
+
warnings.warn(
|
86
|
+
f"The two input models have no common field names: {_name_intersection(model_type_a, model_type_b)}"
|
87
|
+
)
|
88
|
+
|
89
|
+
fields = {}
|
90
|
+
field_names1 = set(model_type_a.model_fields.keys())
|
91
|
+
field_names2 = set(model_type_b.model_fields.keys())
|
92
|
+
common_field_names = field_names1.intersection(field_names2)
|
93
|
+
|
94
|
+
for name in common_field_names:
|
95
|
+
if name in field_names1.intersection(field_names2):
|
96
|
+
fields[name] = (
|
97
|
+
model_type_a.model_fields[name].annotation,
|
98
|
+
model_type_a.model_fields[name],
|
99
|
+
)
|
100
|
+
|
101
|
+
IntersectionModel = create_model(model_name, __config__=config_dict, **fields)
|
102
|
+
return IntersectionModel
|
103
|
+
|
104
|
+
|
105
|
+
def create_complement_model(
|
106
|
+
model_type_a: type[ModelTypeA],
|
107
|
+
model_type_b: type[ModelTypeB],
|
108
|
+
model_name: str = "ComplementModel",
|
109
|
+
config_dict: ConfigDict = ConfigDict(),
|
110
|
+
) -> type[BaseModel]:
|
111
|
+
"""
|
112
|
+
Creates a model that is the complement of the two input models
|
113
|
+
i.e all fields from model_type_a that are not in model_type_b
|
114
|
+
"""
|
115
|
+
|
116
|
+
if len(_name_intersection(model_type_a, model_type_b)) == 0:
|
117
|
+
warnings.warn(
|
118
|
+
f"The two input models have no common field names: {_name_intersection(model_type_a, model_type_b)}"
|
119
|
+
)
|
120
|
+
|
121
|
+
fields = {}
|
122
|
+
field_names_a = set(model_type_a.model_fields.keys())
|
123
|
+
field_names_b = set(model_type_b.model_fields.keys())
|
124
|
+
complement_field_names = field_names_a.difference(field_names_b)
|
125
|
+
|
126
|
+
for name in complement_field_names:
|
127
|
+
fields[name] = (
|
128
|
+
model_type_a.model_fields[name].annotation,
|
129
|
+
model_type_a.model_fields[name],
|
130
|
+
)
|
131
|
+
|
132
|
+
ComplementModel = create_model(model_name, __config__=config_dict, **fields)
|
133
|
+
|
134
|
+
return ComplementModel
|
135
|
+
|
136
|
+
|
137
|
+
if __name__ == "__main__":
|
138
|
+
|
139
|
+
class ModelType1(BaseModel):
|
140
|
+
field1: int = Field(default=1, description="Field 1")
|
141
|
+
field2: str = Field(
|
142
|
+
default="test",
|
143
|
+
description="Field 2",
|
144
|
+
json_schema_extra={"title": "Field 2"},
|
145
|
+
)
|
146
|
+
|
147
|
+
class ModelType2(BaseModel):
|
148
|
+
field3: float
|
149
|
+
field4: bool
|
150
|
+
|
151
|
+
combined_model = create_union_model(ModelType1, ModelType2)
|
152
|
+
|
153
|
+
print(combined_model.model_fields)
|
@@ -0,0 +1,89 @@
|
|
1
|
+
import json
|
2
|
+
import re
|
3
|
+
from typing import Any
|
4
|
+
|
5
|
+
|
6
|
+
def _is_elementary_type(value: Any) -> bool:
|
7
|
+
"""Check if a value is an elementary type (str, int, float, bool, None)."""
|
8
|
+
return isinstance(value, (str, int, float, bool, type(None)))
|
9
|
+
|
10
|
+
|
11
|
+
def _is_elementary_dict(data: dict[str, Any]) -> bool:
|
12
|
+
"""Check if all values in the dictionary are elementary types."""
|
13
|
+
return all(_is_elementary_type(value) for value in data.values())
|
14
|
+
|
15
|
+
|
16
|
+
def dict_to_markdown_table(data: dict[str, Any]) -> str:
|
17
|
+
"""
|
18
|
+
Convert a dictionary to a markdown table if all values are elementary types,
|
19
|
+
otherwise return stringified JSON.
|
20
|
+
|
21
|
+
Args:
|
22
|
+
data: Dictionary to convert
|
23
|
+
|
24
|
+
Returns:
|
25
|
+
Markdown table string or JSON string
|
26
|
+
"""
|
27
|
+
if not isinstance(data, dict):
|
28
|
+
return json.dumps(data, indent=2)
|
29
|
+
|
30
|
+
if not _is_elementary_dict(data):
|
31
|
+
return json.dumps(data, indent=2)
|
32
|
+
|
33
|
+
if not data: # Empty dict
|
34
|
+
return "| Key | Value |\n|-----|-------|\n| (empty) | (empty) |"
|
35
|
+
|
36
|
+
# Create markdown table
|
37
|
+
table_lines = ["| Key | Value |", "|-----|-------|"]
|
38
|
+
|
39
|
+
for key, value in data.items():
|
40
|
+
# Handle None values
|
41
|
+
if value is None:
|
42
|
+
value_str = "null"
|
43
|
+
# Handle boolean values
|
44
|
+
elif isinstance(value, bool):
|
45
|
+
value_str = "true" if value else "false"
|
46
|
+
# Handle other values
|
47
|
+
else:
|
48
|
+
value_str = str(value)
|
49
|
+
|
50
|
+
# Escape pipe characters in the content
|
51
|
+
key_escaped = str(key).replace("|", "\\|")
|
52
|
+
value_escaped = value_str.replace("|", "\\|")
|
53
|
+
|
54
|
+
table_lines.append(f"| {key_escaped} | {value_escaped} |")
|
55
|
+
|
56
|
+
return "\n".join(table_lines) + "\n"
|
57
|
+
|
58
|
+
|
59
|
+
def extract_dicts_from_string(text: str) -> list[dict[str, Any]]:
|
60
|
+
"""
|
61
|
+
Extract and parse a JSON dictionary from a string.
|
62
|
+
|
63
|
+
The string should be wrapped in ```json tags. Example:
|
64
|
+
|
65
|
+
```json
|
66
|
+
{"key": "value"}
|
67
|
+
```
|
68
|
+
|
69
|
+
Args:
|
70
|
+
text: String that may contain JSON
|
71
|
+
|
72
|
+
Returns:
|
73
|
+
Parsed dictionary or None if no valid JSON found
|
74
|
+
"""
|
75
|
+
# Find JSON-like content between ```json and ``` tags
|
76
|
+
pattern = r"```json\s*(\{.*?\})\s*```"
|
77
|
+
matches = re.findall(pattern, text, re.DOTALL)
|
78
|
+
|
79
|
+
dictionaries = []
|
80
|
+
for match in matches:
|
81
|
+
try:
|
82
|
+
# Try to parse as JSON
|
83
|
+
parsed = json.loads(match)
|
84
|
+
if isinstance(parsed, dict):
|
85
|
+
dictionaries.append(parsed)
|
86
|
+
except json.JSONDecodeError:
|
87
|
+
continue
|
88
|
+
|
89
|
+
return dictionaries
|
@@ -17,7 +17,14 @@ LMI = Annotated[
|
|
17
17
|
LanguageModelInfo,
|
18
18
|
BeforeValidator(
|
19
19
|
lambda v: validate_and_init_language_model_info(v),
|
20
|
-
json_schema_input_type=
|
20
|
+
json_schema_input_type=LanguageModelName
|
21
|
+
| Annotated[
|
22
|
+
str,
|
23
|
+
Field(
|
24
|
+
title="Language Model String",
|
25
|
+
),
|
26
|
+
]
|
27
|
+
| LanguageModelInfo,
|
21
28
|
),
|
22
29
|
PlainSerializer(
|
23
30
|
lambda v: serialize_lmi(v),
|
@@ -1,6 +1,6 @@
|
|
1
1
|
Metadata-Version: 2.1
|
2
2
|
Name: unique_toolkit
|
3
|
-
Version: 1.1.
|
3
|
+
Version: 1.1.4
|
4
4
|
Summary:
|
5
5
|
License: Proprietary
|
6
6
|
Author: Cedric Klinkert
|
@@ -118,6 +118,13 @@ All notable changes to this project will be documented in this file.
|
|
118
118
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
119
119
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
120
120
|
|
121
|
+
|
122
|
+
## [1.1.4] - 2025-09-23
|
123
|
+
- First version human verification on api calls
|
124
|
+
|
125
|
+
## [1.1.3] - 2025-09-23
|
126
|
+
- Updated LMI JSON schema input type to include annotated string field with title
|
127
|
+
|
121
128
|
## [1.1.2] - 2025-09-22
|
122
129
|
- Fixed bug tool selection for exclusive tools
|
123
130
|
|
@@ -1,6 +1,7 @@
|
|
1
1
|
unique_toolkit/__init__.py,sha256=nbOYPIKERt-ITsgifrnJhatn1YNR38Ntumw-dCn_tsA,714
|
2
2
|
unique_toolkit/_common/_base_service.py,sha256=S8H0rAebx7GsOldA7xInLp3aQJt9yEPDQdsGSFRJsGg,276
|
3
3
|
unique_toolkit/_common/_time_utils.py,sha256=ztmTovTvr-3w71Ns2VwXC65OKUUh-sQlzbHdKTQWm-w,135
|
4
|
+
unique_toolkit/_common/api_calling/human_verification_manager.py,sha256=UMOkY1cNhJ6JmtiBl3a4mQ3J21uOyKoCbVu7nR3A5eY,7799
|
4
5
|
unique_toolkit/_common/base_model_type_attribute.py,sha256=7rzVqjXa0deYEixeo_pJSJcQ7nKXpWK_UGpOiEH3yZY,10382
|
5
6
|
unique_toolkit/_common/chunk_relevancy_sorter/config.py,sha256=kDSEcXeIWGvzK4IXT3pBofTXeUnq3a9qRWaPllweR-s,1817
|
6
7
|
unique_toolkit/_common/chunk_relevancy_sorter/exception.py,sha256=1mY4zjbvnXsd5oIxwiVsma09bS2XRnHrxW8KJBGtgCM,126
|
@@ -8,17 +9,18 @@ unique_toolkit/_common/chunk_relevancy_sorter/schemas.py,sha256=YAyvXzVk8h5q6FEs
|
|
8
9
|
unique_toolkit/_common/chunk_relevancy_sorter/service.py,sha256=ZX1pxcy53zh3Ha0_pN6yYIbMX1acRxcvqKTPTKpGKwA,13938
|
9
10
|
unique_toolkit/_common/chunk_relevancy_sorter/tests/test_service.py,sha256=UhDllC40Y1OUQvkU6pe3nu6NR7v0d25yldE6FyozuZI,8926
|
10
11
|
unique_toolkit/_common/default_language_model.py,sha256=tmHSqg6e8G7RmKqmdE_tmLxkSN0x-aGoyUdy6Pl2oAE,334
|
11
|
-
unique_toolkit/_common/endpoint_builder.py,sha256=
|
12
|
-
unique_toolkit/_common/endpoint_requestor.py,sha256=
|
12
|
+
unique_toolkit/_common/endpoint_builder.py,sha256=WzJrJ7azUQhvQRd-vsFFoyj6omJpHiVYrh1UFxNQvVg,8242
|
13
|
+
unique_toolkit/_common/endpoint_requestor.py,sha256=JbbfJGLxgxLz8a3Yx1FdJvdHGbCYO8MSBd7cLg_Mtp0,5927
|
13
14
|
unique_toolkit/_common/exception.py,sha256=caQIE1btsQnpKCHqL2cgWUSbHup06enQu_Pt7uGUTTE,727
|
14
15
|
unique_toolkit/_common/feature_flags/schema.py,sha256=F1NdVJFNU8PKlS7bYzrIPeDu2LxRqHSM9pyw622a1Kk,547
|
15
|
-
unique_toolkit/_common/pydantic_helpers.py,sha256=
|
16
|
+
unique_toolkit/_common/pydantic_helpers.py,sha256=TVEYUaBnsXRXmjmdtaM0LV_0-7NX9SWfAgqNBrKReOE,4729
|
17
|
+
unique_toolkit/_common/string_utilities.py,sha256=pbsjpnz1mwGeugebHzubzmmDtlm18B8e7xJdSvLnor0,2496
|
16
18
|
unique_toolkit/_common/token/image_token_counting.py,sha256=VpFfZyY0GIH27q_Wy4YNjk2algqvbCtJyzuuROoFQPw,2189
|
17
19
|
unique_toolkit/_common/token/token_counting.py,sha256=gM4B_aUqKqEPvmStFNcvCWNMNNNNKbVaywBDxlbgIps,7121
|
18
20
|
unique_toolkit/_common/utils/structured_output/schema.py,sha256=Tp7kDYcmKtnUhcuRkH86TSYhylRff0ZZJYb2dLkISts,131
|
19
21
|
unique_toolkit/_common/utils/write_configuration.py,sha256=fzvr4C-XBL3OSM3Od9TbqIxeeDS9_d9CLEyTq6DDknY,1409
|
20
22
|
unique_toolkit/_common/validate_required_values.py,sha256=Y_M1ub9gIKP9qZ45F6Zq3ZHtuIqhmOjl8Z2Vd3avg8w,588
|
21
|
-
unique_toolkit/_common/validators.py,sha256=
|
23
|
+
unique_toolkit/_common/validators.py,sha256=LFZmAalNa886EXm1VYamFvfBuUZjYKwDdT_HOYU0BtE,2934
|
22
24
|
unique_toolkit/agentic/__init__.py,sha256=AbpHGcgLb-kRsJGnwFEktk7uzpZOCcBY74-YBdrKVGs,1
|
23
25
|
unique_toolkit/agentic/debug_info_manager/debug_info_manager.py,sha256=8u3_oxcln7y2zOsfiGh5YOm1zYAlV5QxZ5YAsbEJG0c,584
|
24
26
|
unique_toolkit/agentic/evaluation/config.py,sha256=ywHIrJs5SFdKr1WXfrofWuFfzb0iPQw8iZDpq5oEug4,953
|
@@ -124,7 +126,7 @@ unique_toolkit/short_term_memory/schemas.py,sha256=OhfcXyF6ACdwIXW45sKzjtZX_gkcJ
|
|
124
126
|
unique_toolkit/short_term_memory/service.py,sha256=5PeVBu1ZCAfyDb2HLVvlmqSbyzBBuE9sI2o9Aajqjxg,8884
|
125
127
|
unique_toolkit/smart_rules/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
126
128
|
unique_toolkit/smart_rules/compile.py,sha256=cxWjb2dxEI2HGsakKdVCkSNi7VK9mr08w5sDcFCQyWI,9553
|
127
|
-
unique_toolkit-1.1.
|
128
|
-
unique_toolkit-1.1.
|
129
|
-
unique_toolkit-1.1.
|
130
|
-
unique_toolkit-1.1.
|
129
|
+
unique_toolkit-1.1.4.dist-info/LICENSE,sha256=GlN8wHNdh53xwOPg44URnwag6TEolCjoq3YD_KrWgss,193
|
130
|
+
unique_toolkit-1.1.4.dist-info/METADATA,sha256=2BBFs3a2HGMSWyyk2MQV5gAVJUINJC3Bua6hF4VklcI,32722
|
131
|
+
unique_toolkit-1.1.4.dist-info/WHEEL,sha256=sP946D7jFCHeNz5Iq4fL4Lu-PrWrFsgfLXbbkciIZwg,88
|
132
|
+
unique_toolkit-1.1.4.dist-info/RECORD,,
|
File without changes
|
File without changes
|