kiln-ai 0.20.1__py3-none-any.whl → 0.22.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.
Potentially problematic release.
This version of kiln-ai might be problematic. Click here for more details.
- kiln_ai/adapters/__init__.py +6 -0
- kiln_ai/adapters/adapter_registry.py +43 -226
- kiln_ai/adapters/chunkers/__init__.py +13 -0
- kiln_ai/adapters/chunkers/base_chunker.py +42 -0
- kiln_ai/adapters/chunkers/chunker_registry.py +16 -0
- kiln_ai/adapters/chunkers/fixed_window_chunker.py +39 -0
- kiln_ai/adapters/chunkers/helpers.py +23 -0
- kiln_ai/adapters/chunkers/test_base_chunker.py +63 -0
- kiln_ai/adapters/chunkers/test_chunker_registry.py +28 -0
- kiln_ai/adapters/chunkers/test_fixed_window_chunker.py +346 -0
- kiln_ai/adapters/chunkers/test_helpers.py +75 -0
- kiln_ai/adapters/data_gen/test_data_gen_task.py +9 -3
- kiln_ai/adapters/embedding/__init__.py +0 -0
- kiln_ai/adapters/embedding/base_embedding_adapter.py +44 -0
- kiln_ai/adapters/embedding/embedding_registry.py +32 -0
- kiln_ai/adapters/embedding/litellm_embedding_adapter.py +199 -0
- kiln_ai/adapters/embedding/test_base_embedding_adapter.py +283 -0
- kiln_ai/adapters/embedding/test_embedding_registry.py +166 -0
- kiln_ai/adapters/embedding/test_litellm_embedding_adapter.py +1149 -0
- kiln_ai/adapters/eval/eval_runner.py +6 -2
- kiln_ai/adapters/eval/test_base_eval.py +1 -3
- kiln_ai/adapters/eval/test_g_eval.py +1 -1
- kiln_ai/adapters/extractors/__init__.py +18 -0
- kiln_ai/adapters/extractors/base_extractor.py +72 -0
- kiln_ai/adapters/extractors/encoding.py +20 -0
- kiln_ai/adapters/extractors/extractor_registry.py +44 -0
- kiln_ai/adapters/extractors/extractor_runner.py +112 -0
- kiln_ai/adapters/extractors/litellm_extractor.py +406 -0
- kiln_ai/adapters/extractors/test_base_extractor.py +244 -0
- kiln_ai/adapters/extractors/test_encoding.py +54 -0
- kiln_ai/adapters/extractors/test_extractor_registry.py +181 -0
- kiln_ai/adapters/extractors/test_extractor_runner.py +181 -0
- kiln_ai/adapters/extractors/test_litellm_extractor.py +1290 -0
- kiln_ai/adapters/fine_tune/test_dataset_formatter.py +2 -2
- kiln_ai/adapters/fine_tune/test_fireworks_tinetune.py +2 -6
- kiln_ai/adapters/fine_tune/test_together_finetune.py +2 -6
- kiln_ai/adapters/ml_embedding_model_list.py +494 -0
- kiln_ai/adapters/ml_model_list.py +876 -18
- kiln_ai/adapters/model_adapters/litellm_adapter.py +40 -75
- kiln_ai/adapters/model_adapters/test_litellm_adapter.py +79 -1
- kiln_ai/adapters/model_adapters/test_litellm_adapter_tools.py +119 -5
- kiln_ai/adapters/model_adapters/test_saving_adapter_results.py +9 -3
- kiln_ai/adapters/model_adapters/test_structured_output.py +9 -10
- kiln_ai/adapters/ollama_tools.py +69 -12
- kiln_ai/adapters/provider_tools.py +190 -46
- kiln_ai/adapters/rag/deduplication.py +49 -0
- kiln_ai/adapters/rag/progress.py +252 -0
- kiln_ai/adapters/rag/rag_runners.py +844 -0
- kiln_ai/adapters/rag/test_deduplication.py +195 -0
- kiln_ai/adapters/rag/test_progress.py +785 -0
- kiln_ai/adapters/rag/test_rag_runners.py +2376 -0
- kiln_ai/adapters/remote_config.py +80 -8
- kiln_ai/adapters/test_adapter_registry.py +579 -86
- kiln_ai/adapters/test_ml_embedding_model_list.py +239 -0
- kiln_ai/adapters/test_ml_model_list.py +202 -0
- kiln_ai/adapters/test_ollama_tools.py +340 -1
- kiln_ai/adapters/test_prompt_builders.py +1 -1
- kiln_ai/adapters/test_provider_tools.py +199 -8
- kiln_ai/adapters/test_remote_config.py +551 -56
- kiln_ai/adapters/vector_store/__init__.py +1 -0
- kiln_ai/adapters/vector_store/base_vector_store_adapter.py +83 -0
- kiln_ai/adapters/vector_store/lancedb_adapter.py +389 -0
- kiln_ai/adapters/vector_store/test_base_vector_store.py +160 -0
- kiln_ai/adapters/vector_store/test_lancedb_adapter.py +1841 -0
- kiln_ai/adapters/vector_store/test_vector_store_registry.py +199 -0
- kiln_ai/adapters/vector_store/vector_store_registry.py +33 -0
- kiln_ai/datamodel/__init__.py +16 -13
- kiln_ai/datamodel/basemodel.py +201 -4
- kiln_ai/datamodel/chunk.py +158 -0
- kiln_ai/datamodel/datamodel_enums.py +27 -0
- kiln_ai/datamodel/embedding.py +64 -0
- kiln_ai/datamodel/external_tool_server.py +206 -54
- kiln_ai/datamodel/extraction.py +317 -0
- kiln_ai/datamodel/project.py +33 -1
- kiln_ai/datamodel/rag.py +79 -0
- kiln_ai/datamodel/task.py +5 -0
- kiln_ai/datamodel/task_output.py +41 -11
- kiln_ai/datamodel/test_attachment.py +649 -0
- kiln_ai/datamodel/test_basemodel.py +270 -14
- kiln_ai/datamodel/test_chunk_models.py +317 -0
- kiln_ai/datamodel/test_dataset_split.py +1 -1
- kiln_ai/datamodel/test_datasource.py +50 -0
- kiln_ai/datamodel/test_embedding_models.py +448 -0
- kiln_ai/datamodel/test_eval_model.py +6 -6
- kiln_ai/datamodel/test_external_tool_server.py +534 -152
- kiln_ai/datamodel/test_extraction_chunk.py +206 -0
- kiln_ai/datamodel/test_extraction_model.py +501 -0
- kiln_ai/datamodel/test_rag.py +641 -0
- kiln_ai/datamodel/test_task.py +35 -1
- kiln_ai/datamodel/test_tool_id.py +187 -1
- kiln_ai/datamodel/test_vector_store.py +320 -0
- kiln_ai/datamodel/tool_id.py +58 -0
- kiln_ai/datamodel/vector_store.py +141 -0
- kiln_ai/tools/base_tool.py +12 -3
- kiln_ai/tools/built_in_tools/math_tools.py +12 -4
- kiln_ai/tools/kiln_task_tool.py +158 -0
- kiln_ai/tools/mcp_server_tool.py +2 -2
- kiln_ai/tools/mcp_session_manager.py +51 -22
- kiln_ai/tools/rag_tools.py +164 -0
- kiln_ai/tools/test_kiln_task_tool.py +527 -0
- kiln_ai/tools/test_mcp_server_tool.py +4 -15
- kiln_ai/tools/test_mcp_session_manager.py +187 -227
- kiln_ai/tools/test_rag_tools.py +929 -0
- kiln_ai/tools/test_tool_registry.py +290 -7
- kiln_ai/tools/tool_registry.py +69 -16
- kiln_ai/utils/__init__.py +3 -0
- kiln_ai/utils/async_job_runner.py +62 -17
- kiln_ai/utils/config.py +2 -2
- kiln_ai/utils/env.py +15 -0
- kiln_ai/utils/filesystem.py +14 -0
- kiln_ai/utils/filesystem_cache.py +60 -0
- kiln_ai/utils/litellm.py +94 -0
- kiln_ai/utils/lock.py +100 -0
- kiln_ai/utils/mime_type.py +38 -0
- kiln_ai/utils/open_ai_types.py +19 -2
- kiln_ai/utils/pdf_utils.py +59 -0
- kiln_ai/utils/test_async_job_runner.py +151 -35
- kiln_ai/utils/test_env.py +142 -0
- kiln_ai/utils/test_filesystem_cache.py +316 -0
- kiln_ai/utils/test_litellm.py +206 -0
- kiln_ai/utils/test_lock.py +185 -0
- kiln_ai/utils/test_mime_type.py +66 -0
- kiln_ai/utils/test_open_ai_types.py +88 -12
- kiln_ai/utils/test_pdf_utils.py +86 -0
- kiln_ai/utils/test_uuid.py +111 -0
- kiln_ai/utils/test_validation.py +524 -0
- kiln_ai/utils/uuid.py +9 -0
- kiln_ai/utils/validation.py +90 -0
- {kiln_ai-0.20.1.dist-info → kiln_ai-0.22.0.dist-info}/METADATA +9 -1
- kiln_ai-0.22.0.dist-info/RECORD +213 -0
- kiln_ai-0.20.1.dist-info/RECORD +0 -138
- {kiln_ai-0.20.1.dist-info → kiln_ai-0.22.0.dist-info}/WHEEL +0 -0
- {kiln_ai-0.20.1.dist-info → kiln_ai-0.22.0.dist-info}/licenses/LICENSE.txt +0 -0
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
|
|
3
|
+
from .lock import AsyncLockManager, shared_async_lock_manager
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
async def test_same_key_returns_same_lock():
|
|
7
|
+
"""Test that the same key returns the same lock object."""
|
|
8
|
+
locks = AsyncLockManager()
|
|
9
|
+
|
|
10
|
+
# Test that the same key gets the same lock entry
|
|
11
|
+
async with locks.acquire("test_key"):
|
|
12
|
+
# The lock should exist in the manager
|
|
13
|
+
snapshot = await locks.snapshot()
|
|
14
|
+
assert "test_key" in snapshot
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
async def test_different_keys_return_different_locks():
|
|
18
|
+
"""Test that different keys return different lock objects."""
|
|
19
|
+
locks = AsyncLockManager()
|
|
20
|
+
|
|
21
|
+
# Use different keys
|
|
22
|
+
async with locks.acquire("key1"):
|
|
23
|
+
async with locks.acquire("key2"):
|
|
24
|
+
snapshot = await locks.snapshot()
|
|
25
|
+
assert "key1" in snapshot
|
|
26
|
+
assert "key2" in snapshot
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
async def test_lock_functionality():
|
|
30
|
+
"""Test that the locks actually provide mutual exclusion."""
|
|
31
|
+
results = []
|
|
32
|
+
locks = AsyncLockManager()
|
|
33
|
+
|
|
34
|
+
async def worker(worker_id: int):
|
|
35
|
+
async with locks.acquire("shared_resource"):
|
|
36
|
+
# Record start
|
|
37
|
+
results.append(f"worker_{worker_id}_start")
|
|
38
|
+
await asyncio.sleep(0.1) # Simulate work
|
|
39
|
+
# Record end
|
|
40
|
+
results.append(f"worker_{worker_id}_end")
|
|
41
|
+
|
|
42
|
+
# Run multiple workers concurrently
|
|
43
|
+
await asyncio.gather(*[worker(i) for i in range(3)])
|
|
44
|
+
|
|
45
|
+
# Verify that the work was done exclusively
|
|
46
|
+
# Each worker's start should be immediately followed by its end
|
|
47
|
+
i = 0
|
|
48
|
+
while i < len(results):
|
|
49
|
+
start_event = results[i]
|
|
50
|
+
end_event = results[i + 1]
|
|
51
|
+
|
|
52
|
+
# Extract worker ID from start event
|
|
53
|
+
worker_id = start_event.split("_")[1]
|
|
54
|
+
expected_end = f"worker_{worker_id}_end"
|
|
55
|
+
|
|
56
|
+
assert end_event == expected_end, (
|
|
57
|
+
f"Non-exclusive access detected. Expected {expected_end}, got {end_event}. Full results: {results}"
|
|
58
|
+
)
|
|
59
|
+
i += 2
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
async def test_lock_cleanup():
|
|
63
|
+
"""Test that locks are automatically cleaned up when no longer needed."""
|
|
64
|
+
locks = AsyncLockManager()
|
|
65
|
+
|
|
66
|
+
# Use a lock
|
|
67
|
+
async with locks.acquire("cleanup_test"):
|
|
68
|
+
pass
|
|
69
|
+
|
|
70
|
+
# Check that the lock was cleaned up
|
|
71
|
+
snapshot = await locks.snapshot()
|
|
72
|
+
assert "cleanup_test" not in snapshot
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
async def test_multiple_holders_cleanup():
|
|
76
|
+
"""Test that locks are cleaned up when multiple holders finish."""
|
|
77
|
+
locks = AsyncLockManager()
|
|
78
|
+
|
|
79
|
+
# Create multiple tasks that will hold the lock sequentially
|
|
80
|
+
async def holder(holder_id: int):
|
|
81
|
+
async with locks.acquire("multi_holder"):
|
|
82
|
+
await asyncio.sleep(0.05)
|
|
83
|
+
return f"holder_{holder_id}_done"
|
|
84
|
+
|
|
85
|
+
# Run multiple holders sequentially (not concurrently to avoid deadlock)
|
|
86
|
+
results = []
|
|
87
|
+
for i in range(3):
|
|
88
|
+
result = await holder(i)
|
|
89
|
+
results.append(result)
|
|
90
|
+
|
|
91
|
+
# Check that all holders completed
|
|
92
|
+
assert len(results) == 3
|
|
93
|
+
assert all(result.startswith("holder_") for result in results)
|
|
94
|
+
|
|
95
|
+
# Check that the lock was cleaned up
|
|
96
|
+
snapshot = await locks.snapshot()
|
|
97
|
+
assert "multi_holder" not in snapshot
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
async def test_global_instance():
|
|
101
|
+
"""Test that the global shared_async_lock_manager instance works correctly."""
|
|
102
|
+
results = []
|
|
103
|
+
|
|
104
|
+
async def worker(worker_id: int):
|
|
105
|
+
async with shared_async_lock_manager.acquire("global_test"):
|
|
106
|
+
results.append(f"worker_{worker_id}_start")
|
|
107
|
+
await asyncio.sleep(0.05)
|
|
108
|
+
results.append(f"worker_{worker_id}_end")
|
|
109
|
+
|
|
110
|
+
# Run multiple workers sequentially to avoid deadlock
|
|
111
|
+
for i in range(2):
|
|
112
|
+
await worker(i)
|
|
113
|
+
|
|
114
|
+
# Verify sequential access
|
|
115
|
+
assert len(results) == 4
|
|
116
|
+
assert results[0] == "worker_0_start"
|
|
117
|
+
assert results[1] == "worker_0_end"
|
|
118
|
+
assert results[2] == "worker_1_start"
|
|
119
|
+
assert results[3] == "worker_1_end"
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
async def test_timeout():
|
|
123
|
+
"""Test that timeout functionality works correctly."""
|
|
124
|
+
locks = AsyncLockManager()
|
|
125
|
+
|
|
126
|
+
# Hold the lock for a while
|
|
127
|
+
async def holder():
|
|
128
|
+
async with locks.acquire("timeout_test"):
|
|
129
|
+
await asyncio.sleep(0.3)
|
|
130
|
+
|
|
131
|
+
# Try to acquire with a short timeout
|
|
132
|
+
async def waiter():
|
|
133
|
+
try:
|
|
134
|
+
async with locks.acquire("timeout_test", timeout=0.1):
|
|
135
|
+
assert False, "Should have timed out"
|
|
136
|
+
except asyncio.TimeoutError:
|
|
137
|
+
return "timed_out"
|
|
138
|
+
|
|
139
|
+
# Start holder first
|
|
140
|
+
holder_task = asyncio.create_task(holder())
|
|
141
|
+
await asyncio.sleep(0.05) # Let holder acquire the lock
|
|
142
|
+
|
|
143
|
+
# Then try waiter
|
|
144
|
+
result = await waiter()
|
|
145
|
+
assert result == "timed_out"
|
|
146
|
+
|
|
147
|
+
# Wait for holder to finish
|
|
148
|
+
await holder_task
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
async def test_cancellation():
|
|
152
|
+
"""Test that cancellation is handled correctly."""
|
|
153
|
+
locks = AsyncLockManager()
|
|
154
|
+
|
|
155
|
+
# Hold the lock
|
|
156
|
+
async def holder():
|
|
157
|
+
async with locks.acquire("cancel_test"):
|
|
158
|
+
await asyncio.sleep(0.3)
|
|
159
|
+
|
|
160
|
+
# Try to acquire but get cancelled
|
|
161
|
+
async def waiter():
|
|
162
|
+
try:
|
|
163
|
+
async with locks.acquire("cancel_test"):
|
|
164
|
+
assert False, "Should not acquire lock"
|
|
165
|
+
except asyncio.CancelledError:
|
|
166
|
+
return "cancelled"
|
|
167
|
+
|
|
168
|
+
# Start holder first
|
|
169
|
+
holder_task = asyncio.create_task(holder())
|
|
170
|
+
await asyncio.sleep(0.05) # Let holder acquire the lock
|
|
171
|
+
|
|
172
|
+
# Start waiter and then cancel it
|
|
173
|
+
waiter_task = asyncio.create_task(waiter())
|
|
174
|
+
await asyncio.sleep(0.05)
|
|
175
|
+
waiter_task.cancel()
|
|
176
|
+
|
|
177
|
+
# Check result
|
|
178
|
+
try:
|
|
179
|
+
result = await waiter_task
|
|
180
|
+
assert result == "cancelled"
|
|
181
|
+
except asyncio.CancelledError:
|
|
182
|
+
pass # Expected
|
|
183
|
+
|
|
184
|
+
# Wait for holder to finish
|
|
185
|
+
await holder_task
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
from kiln_ai.utils.mime_type import guess_mime_type
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def test_mov_files():
|
|
5
|
+
assert guess_mime_type("video.mov") == "video/quicktime"
|
|
6
|
+
assert guess_mime_type("my_video.mov") == "video/quicktime"
|
|
7
|
+
assert guess_mime_type("path/to/video.mov") == "video/quicktime"
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def test_mp3_files():
|
|
11
|
+
assert guess_mime_type("song.mp3") == "audio/mpeg"
|
|
12
|
+
assert guess_mime_type("music_file.mp3") == "audio/mpeg"
|
|
13
|
+
assert guess_mime_type("audio/track.mp3") == "audio/mpeg"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def test_wav_files():
|
|
17
|
+
assert guess_mime_type("sound.wav") == "audio/wav"
|
|
18
|
+
assert guess_mime_type("audio_file.wav") == "audio/wav"
|
|
19
|
+
assert guess_mime_type("sounds/effect.wav") == "audio/wav"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def test_mp4_files():
|
|
23
|
+
assert guess_mime_type("movie.mp4") == "video/mp4"
|
|
24
|
+
assert guess_mime_type("video_file.mp4") == "video/mp4"
|
|
25
|
+
assert guess_mime_type("videos/clip.mp4") == "video/mp4"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def test_case_insensitive_extensions():
|
|
29
|
+
assert guess_mime_type("video.MOV") == "video/quicktime"
|
|
30
|
+
assert guess_mime_type("song.MP3") == "audio/mpeg"
|
|
31
|
+
assert guess_mime_type("sound.WAV") == "audio/wav"
|
|
32
|
+
assert guess_mime_type("movie.MP4") == "video/mp4"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def test_standard_mimetypes_fallback():
|
|
36
|
+
assert guess_mime_type("document.pdf") == "application/pdf"
|
|
37
|
+
assert guess_mime_type("image.jpg") == "image/jpeg"
|
|
38
|
+
assert guess_mime_type("image.png") == "image/png"
|
|
39
|
+
assert guess_mime_type("text.txt") == "text/plain"
|
|
40
|
+
assert guess_mime_type("data.json") == "application/json"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def test_unknown_extensions():
|
|
44
|
+
assert guess_mime_type("file.invalidmime") is None
|
|
45
|
+
assert guess_mime_type("no_extension") is None
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def test_edge_cases():
|
|
49
|
+
# Files with multiple dots
|
|
50
|
+
assert guess_mime_type("video.backup.mov") == "video/quicktime"
|
|
51
|
+
assert guess_mime_type("song.remix.mp3") == "audio/mpeg"
|
|
52
|
+
|
|
53
|
+
# Files with dots in the middle
|
|
54
|
+
assert guess_mime_type("my.video.mov") == "video/quicktime"
|
|
55
|
+
assert guess_mime_type("track.1.mp3") == "audio/mpeg"
|
|
56
|
+
|
|
57
|
+
# Empty filename
|
|
58
|
+
assert guess_mime_type("") is None
|
|
59
|
+
|
|
60
|
+
# Just extension
|
|
61
|
+
assert guess_mime_type(".mov") == "video/quicktime"
|
|
62
|
+
assert guess_mime_type(".mp3") == "audio/mpeg"
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def test_priority_order():
|
|
66
|
+
assert guess_mime_type("file.mov.mp3") == "audio/mpeg"
|
|
@@ -8,9 +8,13 @@ from openai.types.chat import (
|
|
|
8
8
|
from openai.types.chat import (
|
|
9
9
|
ChatCompletionMessageParam as OpenAIChatCompletionMessageParam,
|
|
10
10
|
)
|
|
11
|
+
from openai.types.chat import (
|
|
12
|
+
ChatCompletionToolMessageParam as OpenAIChatCompletionToolMessageParam,
|
|
13
|
+
)
|
|
11
14
|
|
|
12
15
|
from kiln_ai.utils.open_ai_types import (
|
|
13
16
|
ChatCompletionAssistantMessageParamWrapper,
|
|
17
|
+
ChatCompletionToolMessageParamWrapper,
|
|
14
18
|
)
|
|
15
19
|
from kiln_ai.utils.open_ai_types import (
|
|
16
20
|
ChatCompletionMessageParam as KilnChatCompletionMessageParam,
|
|
@@ -45,10 +49,40 @@ def test_assistant_message_param_properties_match():
|
|
|
45
49
|
)
|
|
46
50
|
|
|
47
51
|
|
|
52
|
+
def test_tool_message_param_properties_match():
|
|
53
|
+
"""
|
|
54
|
+
Test that ChatCompletionToolMessageParamWrapper has all the same properties
|
|
55
|
+
as OpenAI's ChatCompletionToolMessageParam, plus the kiln_task_tool_data property.
|
|
56
|
+
|
|
57
|
+
This will catch any changes to the OpenAI types that we haven't updated our wrapper for.
|
|
58
|
+
"""
|
|
59
|
+
# Get annotations for both types
|
|
60
|
+
openai_annotations = OpenAIChatCompletionToolMessageParam.__annotations__
|
|
61
|
+
kiln_annotations = ChatCompletionToolMessageParamWrapper.__annotations__
|
|
62
|
+
|
|
63
|
+
# Check that both have the same property names
|
|
64
|
+
openai_properties = set(openai_annotations.keys())
|
|
65
|
+
kiln_properties = set(kiln_annotations.keys())
|
|
66
|
+
|
|
67
|
+
# Kiln task tool data is an added property. Confirm it's there and remove it from the comparison.
|
|
68
|
+
assert "kiln_task_tool_data" in kiln_properties, (
|
|
69
|
+
"Kiln should have kiln_task_tool_data"
|
|
70
|
+
)
|
|
71
|
+
kiln_properties.remove("kiln_task_tool_data")
|
|
72
|
+
|
|
73
|
+
assert openai_properties == kiln_properties, (
|
|
74
|
+
f"Property names don't match. "
|
|
75
|
+
f"OpenAI has: {openai_properties}, "
|
|
76
|
+
f"Kiln has: {kiln_properties}, "
|
|
77
|
+
f"Missing from Kiln: {openai_properties - kiln_properties}, "
|
|
78
|
+
f"Extra in Kiln: {kiln_properties - openai_properties}"
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
|
|
48
82
|
def test_chat_completion_message_param_union_compatibility():
|
|
49
83
|
"""
|
|
50
84
|
Test that our ChatCompletionMessageParam union contains the same types as OpenAI's,
|
|
51
|
-
except with our
|
|
85
|
+
except with our wrappers instead of the original assistant and tool message params.
|
|
52
86
|
"""
|
|
53
87
|
# Get the union members for both types
|
|
54
88
|
openai_union_args = get_args(OpenAIChatCompletionMessageParam)
|
|
@@ -70,10 +104,16 @@ def test_chat_completion_message_param_union_compatibility():
|
|
|
70
104
|
openai_type_names = {arg.__name__ for arg in openai_union_args}
|
|
71
105
|
kiln_type_names = {arg.__name__ for arg in kiln_union_args}
|
|
72
106
|
|
|
73
|
-
# Expected
|
|
74
|
-
# Kiln has ChatCompletionAssistantMessageParamWrapper
|
|
75
|
-
expected_openai_only = {
|
|
76
|
-
|
|
107
|
+
# Expected differences: OpenAI has ChatCompletionAssistantMessageParam and ChatCompletionToolMessageParam,
|
|
108
|
+
# Kiln has ChatCompletionAssistantMessageParamWrapper and ChatCompletionToolMessageParamWrapper
|
|
109
|
+
expected_openai_only = {
|
|
110
|
+
"ChatCompletionAssistantMessageParam",
|
|
111
|
+
"ChatCompletionToolMessageParam",
|
|
112
|
+
}
|
|
113
|
+
expected_kiln_only = {
|
|
114
|
+
"ChatCompletionAssistantMessageParamWrapper",
|
|
115
|
+
"ChatCompletionToolMessageParamWrapper",
|
|
116
|
+
}
|
|
77
117
|
|
|
78
118
|
openai_only = openai_type_names - kiln_type_names
|
|
79
119
|
kiln_only = kiln_type_names - openai_type_names
|
|
@@ -91,7 +131,6 @@ def test_chat_completion_message_param_union_compatibility():
|
|
|
91
131
|
"ChatCompletionDeveloperMessageParam",
|
|
92
132
|
"ChatCompletionSystemMessageParam",
|
|
93
133
|
"ChatCompletionUserMessageParam",
|
|
94
|
-
"ChatCompletionToolMessageParam",
|
|
95
134
|
"ChatCompletionFunctionMessageParam",
|
|
96
135
|
}
|
|
97
136
|
|
|
@@ -100,17 +139,17 @@ def test_chat_completion_message_param_union_compatibility():
|
|
|
100
139
|
)
|
|
101
140
|
|
|
102
141
|
|
|
103
|
-
def
|
|
104
|
-
"""Test that our wrapper can be instantiated with the same data as the original."""
|
|
105
|
-
#
|
|
106
|
-
|
|
142
|
+
def test_assistant_message_wrapper_can_be_instantiated():
|
|
143
|
+
"""Test that our assistant message wrapper can be instantiated with the same data as the original."""
|
|
144
|
+
# Test basic assistant message
|
|
145
|
+
sample_assistant_message: ChatCompletionAssistantMessageParamWrapper = {
|
|
107
146
|
"role": "assistant",
|
|
108
147
|
"content": "Hello, world!",
|
|
109
148
|
}
|
|
110
149
|
|
|
111
150
|
# This should work without type errors (runtime test)
|
|
112
|
-
assert
|
|
113
|
-
assert
|
|
151
|
+
assert sample_assistant_message["role"] == "assistant"
|
|
152
|
+
assert sample_assistant_message.get("content") == "Hello, world!"
|
|
114
153
|
|
|
115
154
|
# Test with tool calls using List instead of Iterable
|
|
116
155
|
sample_with_tools: ChatCompletionAssistantMessageParamWrapper = {
|
|
@@ -129,3 +168,40 @@ def test_wrapper_can_be_instantiated():
|
|
|
129
168
|
tool_calls = sample_with_tools.get("tool_calls", [])
|
|
130
169
|
if tool_calls:
|
|
131
170
|
assert tool_calls[0]["id"] == "call_123"
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def test_tool_message_wrapper_can_be_instantiated():
|
|
174
|
+
"""Test that our tool message wrapper can be instantiated with the same data as the original."""
|
|
175
|
+
# Test basic tool message
|
|
176
|
+
sample_tool_message: ChatCompletionToolMessageParamWrapper = {
|
|
177
|
+
"role": "tool",
|
|
178
|
+
"content": "Tool response",
|
|
179
|
+
"tool_call_id": "call_123",
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
assert sample_tool_message["role"] == "tool"
|
|
183
|
+
assert sample_tool_message.get("content") == "Tool response"
|
|
184
|
+
assert sample_tool_message.get("tool_call_id") == "call_123"
|
|
185
|
+
|
|
186
|
+
# Test with kiln_task_tool_data
|
|
187
|
+
sample_with_kiln_data: ChatCompletionToolMessageParamWrapper = {
|
|
188
|
+
"role": "tool",
|
|
189
|
+
"content": "Tool response",
|
|
190
|
+
"tool_call_id": "call_123",
|
|
191
|
+
"kiln_task_tool_data": "project_123:::tool_456:::task_789:::run_101",
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
assert (
|
|
195
|
+
sample_with_kiln_data.get("kiln_task_tool_data")
|
|
196
|
+
== "project_123:::tool_456:::task_789:::run_101"
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
# Test with kiln_task_tool_data as None
|
|
200
|
+
sample_with_none_kiln_data: ChatCompletionToolMessageParamWrapper = {
|
|
201
|
+
"role": "tool",
|
|
202
|
+
"content": "Tool response",
|
|
203
|
+
"tool_call_id": "call_123",
|
|
204
|
+
"kiln_task_tool_data": None,
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
assert sample_with_none_kiln_data.get("kiln_task_tool_data") is None
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import tempfile
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
|
|
4
|
+
import pytest
|
|
5
|
+
from pypdf import PdfReader
|
|
6
|
+
|
|
7
|
+
from conftest import MockFileFactoryMimeType
|
|
8
|
+
from kiln_ai.utils.pdf_utils import convert_pdf_to_images, split_pdf_into_pages
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
async def test_split_pdf_into_pages_success(mock_file_factory):
|
|
12
|
+
"""Test that split_pdf_into_pages successfully splits a PDF into individual pages."""
|
|
13
|
+
test_file = mock_file_factory(MockFileFactoryMimeType.PDF)
|
|
14
|
+
|
|
15
|
+
async with split_pdf_into_pages(test_file) as page_paths:
|
|
16
|
+
# Verify we get the expected number of pages (test PDF has 2 pages)
|
|
17
|
+
assert len(page_paths) == 2
|
|
18
|
+
|
|
19
|
+
# Verify all page files exist
|
|
20
|
+
for page_path in page_paths:
|
|
21
|
+
assert page_path.exists()
|
|
22
|
+
assert page_path.suffix == ".pdf"
|
|
23
|
+
|
|
24
|
+
# Verify page files are named correctly
|
|
25
|
+
assert page_paths[0].name == "page_1.pdf"
|
|
26
|
+
assert page_paths[1].name == "page_2.pdf"
|
|
27
|
+
|
|
28
|
+
# Verify each page file is a valid PDF with exactly 1 page
|
|
29
|
+
for page_path in page_paths:
|
|
30
|
+
with open(page_path, "rb") as file:
|
|
31
|
+
reader = PdfReader(file)
|
|
32
|
+
assert len(reader.pages) == 1
|
|
33
|
+
|
|
34
|
+
# Verify cleanup: all page files should be removed after context exit
|
|
35
|
+
for page_path in page_paths:
|
|
36
|
+
assert not page_path.exists()
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
async def test_split_pdf_into_pages_cleanup_on_exception(mock_file_factory):
|
|
40
|
+
"""Test that temporary files are cleaned up even when an exception occurs during normal usage."""
|
|
41
|
+
test_file = mock_file_factory(MockFileFactoryMimeType.PDF)
|
|
42
|
+
captured_page_paths = []
|
|
43
|
+
|
|
44
|
+
# Test that cleanup happens even when an exception occurs during the with block
|
|
45
|
+
with pytest.raises(RuntimeError, match="Simulated error during usage"):
|
|
46
|
+
async with split_pdf_into_pages(test_file) as page_paths:
|
|
47
|
+
# Capture the page paths before the exception
|
|
48
|
+
captured_page_paths.extend(page_paths)
|
|
49
|
+
# Simulate an exception during normal usage of the context manager
|
|
50
|
+
raise RuntimeError("Simulated error during usage")
|
|
51
|
+
|
|
52
|
+
# Verify cleanup happened: the specific page files we created should be gone
|
|
53
|
+
for page_path in captured_page_paths:
|
|
54
|
+
assert not page_path.exists()
|
|
55
|
+
|
|
56
|
+
# Also verify the temporary directory itself is gone
|
|
57
|
+
if captured_page_paths:
|
|
58
|
+
temp_dir = captured_page_paths[0].parent
|
|
59
|
+
assert not temp_dir.exists()
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
async def test_split_pdf_into_pages_temporary_directory_creation(mock_file_factory):
|
|
63
|
+
"""Test that temporary directories are created with the correct prefix."""
|
|
64
|
+
test_file = mock_file_factory(MockFileFactoryMimeType.PDF)
|
|
65
|
+
captured_temp_dirs = []
|
|
66
|
+
|
|
67
|
+
async with split_pdf_into_pages(test_file) as page_paths:
|
|
68
|
+
# Check that page paths are in a directory with the expected prefix
|
|
69
|
+
temp_dir = page_paths[0].parent
|
|
70
|
+
captured_temp_dirs.append(temp_dir)
|
|
71
|
+
assert "kiln_pdf_pages_" in temp_dir.name
|
|
72
|
+
assert temp_dir.exists()
|
|
73
|
+
|
|
74
|
+
# Verify the temporary directory is cleaned up
|
|
75
|
+
for temp_dir in captured_temp_dirs:
|
|
76
|
+
assert not temp_dir.exists()
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
async def test_convert_pdf_to_images(mock_file_factory):
|
|
80
|
+
"""Test that convert_pdf_to_images successfully converts a PDF into individual images."""
|
|
81
|
+
test_file = mock_file_factory(MockFileFactoryMimeType.PDF)
|
|
82
|
+
with tempfile.TemporaryDirectory() as temp_dir:
|
|
83
|
+
images = await convert_pdf_to_images(test_file, Path(temp_dir))
|
|
84
|
+
assert len(images) == 2
|
|
85
|
+
assert all(image.exists() for image in images)
|
|
86
|
+
assert all(image.suffix == ".png" for image in images)
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import uuid
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
|
|
5
|
+
from kiln_ai.utils.uuid import string_to_uuid
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class TestStringToUuid:
|
|
9
|
+
"""Test the string_to_uuid function for consistency and correctness."""
|
|
10
|
+
|
|
11
|
+
def test_same_string_produces_same_uuid(self):
|
|
12
|
+
"""Test that the same string consistently produces the same UUID."""
|
|
13
|
+
test_string = "hello world"
|
|
14
|
+
|
|
15
|
+
uuid1 = string_to_uuid(test_string)
|
|
16
|
+
uuid2 = string_to_uuid(test_string)
|
|
17
|
+
uuid3 = string_to_uuid(test_string)
|
|
18
|
+
|
|
19
|
+
assert uuid1 == uuid2 == uuid3
|
|
20
|
+
assert isinstance(uuid1, uuid.UUID)
|
|
21
|
+
|
|
22
|
+
def test_different_strings_produce_different_uuids(self):
|
|
23
|
+
"""Test that different strings produce different UUIDs."""
|
|
24
|
+
uuid1 = string_to_uuid("hello")
|
|
25
|
+
uuid2 = string_to_uuid("world")
|
|
26
|
+
uuid3 = string_to_uuid("hello world")
|
|
27
|
+
|
|
28
|
+
assert uuid1 != uuid2
|
|
29
|
+
assert uuid1 != uuid3
|
|
30
|
+
assert uuid2 != uuid3
|
|
31
|
+
|
|
32
|
+
def test_case_sensitivity(self):
|
|
33
|
+
"""Test that string case affects the generated UUID."""
|
|
34
|
+
uuid_lower = string_to_uuid("hello")
|
|
35
|
+
uuid_upper = string_to_uuid("HELLO")
|
|
36
|
+
uuid_mixed = string_to_uuid("Hello")
|
|
37
|
+
|
|
38
|
+
assert uuid_lower != uuid_upper
|
|
39
|
+
assert uuid_lower != uuid_mixed
|
|
40
|
+
assert uuid_upper != uuid_mixed
|
|
41
|
+
|
|
42
|
+
@pytest.mark.parametrize(
|
|
43
|
+
"test_string",
|
|
44
|
+
[
|
|
45
|
+
"",
|
|
46
|
+
"a",
|
|
47
|
+
"test string with spaces",
|
|
48
|
+
"string_with_underscores",
|
|
49
|
+
"string-with-dashes",
|
|
50
|
+
"string.with.dots",
|
|
51
|
+
"string/with/slashes",
|
|
52
|
+
"string@with#special$characters!",
|
|
53
|
+
"1234567890",
|
|
54
|
+
"string with 数字 and unicode 🚀",
|
|
55
|
+
"\n\t\r", # whitespace characters
|
|
56
|
+
"a" * 1000, # very long string
|
|
57
|
+
],
|
|
58
|
+
)
|
|
59
|
+
def test_various_string_inputs(self, test_string):
|
|
60
|
+
"""Test that various string inputs produce consistent UUIDs."""
|
|
61
|
+
uuid1 = string_to_uuid(test_string)
|
|
62
|
+
uuid2 = string_to_uuid(test_string)
|
|
63
|
+
|
|
64
|
+
assert uuid1 == uuid2
|
|
65
|
+
assert isinstance(uuid1, uuid.UUID)
|
|
66
|
+
|
|
67
|
+
def test_uuid_format_is_valid(self):
|
|
68
|
+
"""Test that the generated UUID is a valid UUID5."""
|
|
69
|
+
test_string = "test"
|
|
70
|
+
result_uuid = string_to_uuid(test_string)
|
|
71
|
+
|
|
72
|
+
# UUID5 should have version 5
|
|
73
|
+
assert result_uuid.version == 5
|
|
74
|
+
|
|
75
|
+
# Should be a valid UUID string format
|
|
76
|
+
uuid_str = str(result_uuid)
|
|
77
|
+
assert len(uuid_str) == 36
|
|
78
|
+
assert uuid_str.count("-") == 4
|
|
79
|
+
|
|
80
|
+
# Should be able to recreate UUID from string
|
|
81
|
+
recreated_uuid = uuid.UUID(uuid_str)
|
|
82
|
+
assert recreated_uuid == result_uuid
|
|
83
|
+
|
|
84
|
+
def test_deterministic_across_runs(self):
|
|
85
|
+
"""Test that the function is deterministic across multiple test runs."""
|
|
86
|
+
# These are known expected values for specific inputs using UUID5 with DNS namespace
|
|
87
|
+
expected_mappings = {
|
|
88
|
+
"hello": "aec070645fe53ee3b3763059376134f058cc337247c978add178b6ccdfb0019f",
|
|
89
|
+
"test": "098f6bcd4621d373cade4e832627b4f6",
|
|
90
|
+
"": "e3b0c44298fc1c149afbf4c8996fb924",
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
for test_string in expected_mappings.keys():
|
|
94
|
+
result_uuid = string_to_uuid(test_string)
|
|
95
|
+
# The actual UUID will be different, but it should be consistent
|
|
96
|
+
# We're mainly testing that it's deterministic, not the exact value
|
|
97
|
+
second_result = string_to_uuid(test_string)
|
|
98
|
+
assert result_uuid == second_result
|
|
99
|
+
|
|
100
|
+
def test_known_uuid5_behavior(self):
|
|
101
|
+
"""Test that the function behaves as expected for UUID5 generation."""
|
|
102
|
+
test_string = "example.com"
|
|
103
|
+
result_uuid = string_to_uuid(test_string)
|
|
104
|
+
|
|
105
|
+
# Manually generate the same UUID using uuid.uuid5 to verify behavior
|
|
106
|
+
assert str(result_uuid) == "cea6b86d-3f0b-5b2f-b6f2-1174f00da196", (
|
|
107
|
+
f"Expected {test_string} to produce {result_uuid}. You may have changed the mapping from string to UUID5 - that will break backwards compatibility with code relying on the mapping being deterministic."
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
# Verify it's using the DNS namespace as expected
|
|
111
|
+
assert result_uuid.version == 5
|