aiagents4pharma 1.30.1__py3-none-any.whl → 1.30.2__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.
@@ -3,10 +3,20 @@ Unit tests for Zotero path utility in zotero_path.py.
3
3
  """
4
4
 
5
5
  import unittest
6
- from unittest.mock import MagicMock
6
+ from unittest.mock import MagicMock, patch
7
+ import pytest
7
8
  from aiagents4pharma.talk2scholars.tools.zotero.utils.zotero_path import (
9
+ fetch_papers_for_save,
10
+ find_or_create_collection,
11
+ get_all_collection_paths,
8
12
  get_item_collections,
9
13
  )
14
+ from aiagents4pharma.talk2scholars.tools.zotero.zotero_read import (
15
+ zotero_read,
16
+ )
17
+ from aiagents4pharma.talk2scholars.tools.zotero.zotero_write import (
18
+ zotero_write,
19
+ )
10
20
 
11
21
 
12
22
  class TestGetItemCollections(unittest.TestCase):
@@ -55,3 +65,411 @@ class TestGetItemCollections(unittest.TestCase):
55
65
 
56
66
  result = get_item_collections(fake_zot)
57
67
  self.assertEqual(result, expected_mapping)
68
+
69
+
70
+ class TestFindOrCreateCollectionExtra(unittest.TestCase):
71
+ """Extra tests for the find_or_create_collection function."""
72
+
73
+ def setUp(self):
74
+ """Set up a fake Zotero client with some default collections."""
75
+ # Set up a fake Zotero client with some default collections.
76
+ self.fake_zot = MagicMock()
77
+ self.fake_zot.collections.return_value = [
78
+ {"key": "parent1", "data": {"name": "Parent", "parentCollection": None}},
79
+ {"key": "child1", "data": {"name": "Child", "parentCollection": "parent1"}},
80
+ ]
81
+
82
+ def test_empty_path(self):
83
+ """Test that an empty path returns None."""
84
+ result = find_or_create_collection(self.fake_zot, "", create_missing=False)
85
+ self.assertIsNone(result)
86
+
87
+ def test_create_collection_with_success_key(self):
88
+ """
89
+ Test that when create_missing is True and the response contains a "success" key,
90
+ the function returns the new collection key.
91
+ """
92
+ # Simulate no existing collections (so direct match fails)
93
+ self.fake_zot.collections.return_value = []
94
+ # Simulate create_collection returning a dict with a "success" key.
95
+ self.fake_zot.create_collection.return_value = {
96
+ "success": {"0": "new_key_success"}
97
+ }
98
+ result = find_or_create_collection(
99
+ self.fake_zot, "/NewCollection", create_missing=True
100
+ )
101
+ self.assertEqual(result, "new_key_success")
102
+ # Verify payload formatting: for a simple (non-nested) path, no parentCollection.
103
+ args, _ = self.fake_zot.create_collection.call_args
104
+ payload = args[0]
105
+ self.assertEqual(payload["name"], "newcollection")
106
+ self.assertNotIn("parentCollection", payload)
107
+
108
+ def test_create_collection_with_successful_key(self):
109
+ """
110
+ Test that when create_missing is True and the response contains a "successful" key,
111
+ the function returns the new collection key.
112
+ """
113
+ self.fake_zot.collections.return_value = []
114
+ self.fake_zot.create_collection.return_value = {
115
+ "successful": {"0": {"data": {"key": "new_key_successful"}}}
116
+ }
117
+ result = find_or_create_collection(
118
+ self.fake_zot, "/NewCollection", create_missing=True
119
+ )
120
+ self.assertEqual(result, "new_key_successful")
121
+
122
+ def test_create_collection_exception(self):
123
+ """
124
+ Test that if create_collection raises an exception,
125
+ the function logs the error and returns None.
126
+ """
127
+ self.fake_zot.collections.return_value = []
128
+ self.fake_zot.create_collection.side_effect = Exception("Creation error")
129
+ result = find_or_create_collection(
130
+ self.fake_zot, "/NewCollection", create_missing=True
131
+ )
132
+ self.assertIsNone(result)
133
+
134
+
135
+ class TestZoteroPath:
136
+ """Tests for the zotero_path utility functions."""
137
+
138
+ def test_fetch_papers_for_save_no_papers(self):
139
+ """Test that fetch_papers_for_save returns None when no papers are available."""
140
+ # Empty state
141
+ state = {}
142
+ assert fetch_papers_for_save(state) is None
143
+
144
+ # State with empty last_displayed_papers
145
+ state = {"last_displayed_papers": ""}
146
+ assert fetch_papers_for_save(state) is None
147
+
148
+ # State with last_displayed_papers pointing to non-existent key
149
+ state = {"last_displayed_papers": "nonexistent_key"}
150
+ assert fetch_papers_for_save(state) is None
151
+
152
+ def test_fetch_papers_for_save_with_papers(self):
153
+ """Test that fetch_papers_for_save correctly retrieves papers from state."""
154
+ # State with direct papers
155
+ sample_papers = {"paper1": {"Title": "Test Paper"}}
156
+ state = {"last_displayed_papers": sample_papers}
157
+ assert fetch_papers_for_save(state) == sample_papers
158
+
159
+ # State with papers referenced by key
160
+ state = {"last_displayed_papers": "zotero_read", "zotero_read": sample_papers}
161
+ assert fetch_papers_for_save(state) == sample_papers
162
+
163
+ @patch("pyzotero.zotero.Zotero")
164
+ def test_find_or_create_collection_exact_match(self, mock_zotero):
165
+ """Test that find_or_create_collection correctly finds an exact match."""
166
+ # Setup mock
167
+ mock_zot = MagicMock()
168
+ mock_zotero.return_value = mock_zot
169
+
170
+ # Setup collections
171
+ collections = [
172
+ {"key": "abc123", "data": {"name": "Curiosity", "parentCollection": None}},
173
+ {
174
+ "key": "def456",
175
+ "data": {"name": "Curiosity1", "parentCollection": "abc123"},
176
+ },
177
+ {"key": "ghi789", "data": {"name": "Random", "parentCollection": None}},
178
+ {"key": "rad123", "data": {"name": "radiation", "parentCollection": None}},
179
+ ]
180
+ mock_zot.collections.return_value = collections
181
+
182
+ # Test finding "Curiosity"
183
+ result = find_or_create_collection(mock_zot, "/Curiosity")
184
+ assert result == "abc123"
185
+
186
+ # Test finding with different case
187
+ result = find_or_create_collection(mock_zot, "/curiosity")
188
+ assert result == "abc123"
189
+
190
+ # Test finding "radiation" - direct match
191
+ result = find_or_create_collection(mock_zot, "/radiation")
192
+ assert result == "rad123"
193
+
194
+ # Test finding without leading slash
195
+ result = find_or_create_collection(mock_zot, "radiation")
196
+ assert result == "rad123"
197
+
198
+ @patch("pyzotero.zotero.Zotero")
199
+ def test_find_or_create_collection_no_match(self, mock_zotero):
200
+ """Test that find_or_create_collection returns None for non-existent collections."""
201
+ # Setup mock
202
+ mock_zot = MagicMock()
203
+ mock_zotero.return_value = mock_zot
204
+
205
+ # Setup collections
206
+ collections = [
207
+ {"key": "abc123", "data": {"name": "Curiosity", "parentCollection": None}},
208
+ {
209
+ "key": "def456",
210
+ "data": {"name": "Curiosity1", "parentCollection": "abc123"},
211
+ },
212
+ ]
213
+ mock_zot.collections.return_value = collections
214
+
215
+ # Test finding non-existent "Curiosity2"
216
+ result = find_or_create_collection(mock_zot, "/Curiosity2")
217
+ assert result is None
218
+
219
+ # Test finding non-existent nested path
220
+ result = find_or_create_collection(mock_zot, "/Curiosity/Curiosity2")
221
+ assert result is None
222
+
223
+ @patch("pyzotero.zotero.Zotero")
224
+ def test_find_or_create_collection_with_creation(self, mock_zotero):
225
+ """Test that find_or_create_collection creates collections when requested."""
226
+ # Setup mock
227
+ mock_zot = MagicMock()
228
+ mock_zotero.return_value = mock_zot
229
+
230
+ # Setup collections
231
+ collections = [
232
+ {"key": "abc123", "data": {"name": "Curiosity", "parentCollection": None}}
233
+ ]
234
+ mock_zot.collections.return_value = collections
235
+
236
+ # Setup create_collection response
237
+ mock_zot.create_collection.return_value = {
238
+ "successful": {"0": {"data": {"key": "new_key"}}}
239
+ }
240
+
241
+ # Test creating "Curiosity2" - note we're expecting lowercase in the call
242
+ result = find_or_create_collection(mock_zot, "/Curiosity2", create_missing=True)
243
+ assert result == "new_key"
244
+ # Use case-insensitive check for the collection name
245
+ mock_zot.create_collection.assert_called_once()
246
+ call_args = mock_zot.create_collection.call_args[0][0]
247
+ assert "name" in call_args
248
+ assert call_args["name"].lower() == "curiosity2"
249
+
250
+ # Test creating nested "Curiosity/Curiosity2"
251
+ mock_zot.create_collection.reset_mock()
252
+ result = find_or_create_collection(
253
+ mock_zot, "/Curiosity/Curiosity2", create_missing=True
254
+ )
255
+ assert result == "new_key"
256
+ # Check that the call includes parentCollection
257
+ mock_zot.create_collection.assert_called_once()
258
+ call_args = mock_zot.create_collection.call_args[0][0]
259
+ assert "name" in call_args
260
+ assert "parentCollection" in call_args
261
+ assert call_args["name"].lower() == "curiosity2"
262
+ assert call_args["parentCollection"] == "abc123"
263
+
264
+ @patch("pyzotero.zotero.Zotero")
265
+ def test_get_all_collection_paths(self, mock_zotero):
266
+ """Test that get_all_collection_paths returns correct paths."""
267
+ # Setup mock
268
+ mock_zot = MagicMock()
269
+ mock_zotero.return_value = mock_zot
270
+
271
+ # Setup collections
272
+ collections = [
273
+ {"key": "abc123", "data": {"name": "Curiosity", "parentCollection": None}},
274
+ {
275
+ "key": "def456",
276
+ "data": {"name": "Curiosity1", "parentCollection": "abc123"},
277
+ },
278
+ {"key": "ghi789", "data": {"name": "Random", "parentCollection": None}},
279
+ ]
280
+ mock_zot.collections.return_value = collections
281
+
282
+ # Test getting all paths
283
+ result = get_all_collection_paths(mock_zot)
284
+ assert "/Curiosity" in result
285
+ assert "/Random" in result
286
+ assert "/Curiosity/Curiosity1" in result
287
+
288
+
289
+ class TestZoteroWrite:
290
+ """Integration tests for zotero_write.py."""
291
+
292
+ @pytest.fixture
293
+ def mock_hydra(self):
294
+ """Fixture to mock hydra configuration."""
295
+ with patch("hydra.compose") as mock_compose:
296
+ cfg = MagicMock()
297
+ cfg.tools.zotero_write.user_id = "test_user"
298
+ cfg.tools.zotero_write.library_type = "user"
299
+ cfg.tools.zotero_write.api_key = "test_key"
300
+ cfg.tools.zotero_write.zotero = MagicMock()
301
+ cfg.tools.zotero_write.zotero.max_limit = 50
302
+ mock_compose.return_value = cfg
303
+ yield cfg
304
+
305
+ @pytest.fixture
306
+ def mock_zotero(self):
307
+ """Fixture to mock Zotero client."""
308
+ with patch("pyzotero.zotero.Zotero") as mock_zot_class:
309
+ mock_zot = MagicMock()
310
+ mock_zot_class.return_value = mock_zot
311
+ yield mock_zot
312
+
313
+ @patch(
314
+ "aiagents4pharma.talk2scholars.tools.zotero.utils.zotero_path.fetch_papers_for_save"
315
+ )
316
+ def test_zotero_write_no_papers(self, mock_fetch):
317
+ """When no papers exist (even after approval), the function raises a ValueError."""
318
+ mock_fetch.return_value = None
319
+
320
+ state = {
321
+ "zotero_write_approval_status": {
322
+ "approved": True,
323
+ "collection_path": "/Curiosity",
324
+ }
325
+ }
326
+
327
+ with pytest.raises(ValueError) as excinfo:
328
+ zotero_write.run(
329
+ {
330
+ "tool_call_id": "test_id",
331
+ "collection_path": "/Curiosity",
332
+ "state": state,
333
+ }
334
+ )
335
+ assert "No fetched papers were found to save" in str(excinfo.value)
336
+
337
+ @patch(
338
+ "aiagents4pharma.talk2scholars.tools.zotero.utils.zotero_path.fetch_papers_for_save"
339
+ )
340
+ @patch(
341
+ "aiagents4pharma.talk2scholars.tools.zotero.utils.zotero_path.find_or_create_collection"
342
+ )
343
+ def test_zotero_write_invalid_collection(self, mock_find, mock_fetch, mock_zotero):
344
+ """Saving to a nonexistent Zotero collection returns an error Command."""
345
+ sample = {"paper1": {"Title": "Test Paper"}}
346
+ mock_fetch.return_value = sample
347
+ mock_find.return_value = None
348
+ mock_zotero.collections.return_value = [
349
+ {"key": "k1", "data": {"name": "Curiosity"}},
350
+ {"key": "k2", "data": {"name": "Random"}},
351
+ ]
352
+
353
+ state = {
354
+ "zotero_write_approval_status": {
355
+ "approved": True,
356
+ "collection_path": "/NonExistent",
357
+ },
358
+ "last_displayed_papers": "papers",
359
+ "papers": sample,
360
+ }
361
+
362
+ result = zotero_write.run(
363
+ {
364
+ "tool_call_id": "test_id",
365
+ "collection_path": "/NonExistent",
366
+ "state": state,
367
+ }
368
+ )
369
+
370
+ msg = result.update["messages"][0].content
371
+ assert "does not exist in Zotero" in msg
372
+ assert "Curiosity, Random" in msg
373
+
374
+ @patch(
375
+ "aiagents4pharma.talk2scholars.tools.zotero.utils.zotero_path.fetch_papers_for_save"
376
+ )
377
+ @patch(
378
+ "aiagents4pharma.talk2scholars.tools.zotero.utils.zotero_path.find_or_create_collection"
379
+ )
380
+ def test_zotero_write_success(self, mock_find, mock_fetch, mock_hydra, mock_zotero):
381
+ """A valid approved save returns a success Command with summary."""
382
+ sample = {"paper1": {"Title": "Test Paper", "Authors": ["Test Author"]}}
383
+ mock_fetch.return_value = sample
384
+ mock_find.return_value = "abc123"
385
+ mock_zotero.collections.return_value = [
386
+ {"key": "abc123", "data": {"name": "radiation"}}
387
+ ]
388
+ mock_zotero.create_items.return_value = {
389
+ "successful": {"0": {"key": "item123"}}
390
+ }
391
+ mock_hydra.tools.zotero_write.zotero.max_limit = 50
392
+
393
+ state = {
394
+ "zotero_write_approval_status": {
395
+ "approved": True,
396
+ "collection_path": "/radiation",
397
+ },
398
+ "last_displayed_papers": "papers",
399
+ "papers": sample,
400
+ }
401
+
402
+ result = zotero_write.run(
403
+ {
404
+ "tool_call_id": "test_id",
405
+ "collection_path": "/radiation",
406
+ "state": state,
407
+ }
408
+ )
409
+
410
+ msg = result.update["messages"][0].content
411
+ assert "Save was successful" in msg
412
+ assert "radiation" in msg
413
+
414
+
415
+ class TestZoteroRead:
416
+ """Integration tests for zotero_read.py."""
417
+
418
+ @pytest.fixture
419
+ def mock_hydra(self):
420
+ """Fixture to mock hydra configuration."""
421
+ with patch("hydra.initialize"), patch("hydra.compose") as mock_compose:
422
+ cfg = MagicMock()
423
+ cfg.tools.zotero_read.user_id = "test_user"
424
+ cfg.tools.zotero_read.library_type = "user"
425
+ cfg.tools.zotero_read.api_key = "test_key"
426
+ cfg.tools.zotero_read.zotero = MagicMock()
427
+ cfg.tools.zotero_read.zotero.max_limit = 50
428
+ cfg.tools.zotero_read.zotero.filter_item_types = [
429
+ "journalArticle",
430
+ "conferencePaper",
431
+ ]
432
+ mock_compose.return_value = cfg
433
+ yield cfg
434
+
435
+ @pytest.fixture
436
+ def mock_zotero(self):
437
+ """Fixture to mock Zotero client."""
438
+ with patch("pyzotero.zotero.Zotero") as mock_zot_class:
439
+ mock_zot = MagicMock()
440
+ mock_zot_class.return_value = mock_zot
441
+ yield mock_zot
442
+
443
+ @patch(
444
+ "aiagents4pharma.talk2scholars.tools.zotero.utils.zotero_path.get_item_collections"
445
+ )
446
+ def test_zotero_read_item_collections_error(
447
+ self, mock_get_collections, mock_hydra, mock_zotero
448
+ ):
449
+ """Test that zotero_read handles errors in get_item_collections."""
450
+
451
+ mock_get_collections.side_effect = Exception("Test error")
452
+
453
+ mock_zotero.items.return_value = [
454
+ {
455
+ "data": {
456
+ "key": "paper1",
457
+ "title": "Test Paper",
458
+ "itemType": "journalArticle",
459
+ }
460
+ }
461
+ ]
462
+ mock_hydra.tools.zotero_read.zotero.max_limit = 50
463
+
464
+ result = zotero_read.run(
465
+ {
466
+ "query": "test",
467
+ "only_articles": True,
468
+ "tool_call_id": "test_id",
469
+ "limit": 2,
470
+ }
471
+ )
472
+
473
+ assert result is not None
474
+ assert isinstance(result.update, dict)
475
+ assert "zotero_read" in result.update
@@ -7,7 +7,7 @@ import unittest
7
7
  from unittest.mock import patch, MagicMock
8
8
  from langgraph.types import Command
9
9
  from aiagents4pharma.talk2scholars.tools.zotero.zotero_read import (
10
- zotero_search_tool,
10
+ zotero_read,
11
11
  )
12
12
 
13
13
 
@@ -87,7 +87,7 @@ class TestZoteroSearchTool(unittest.TestCase):
87
87
  "tool_call_id": tool_call_id,
88
88
  "limit": 2,
89
89
  }
90
- result = zotero_search_tool.run(tool_input)
90
+ result = zotero_read.run(tool_input)
91
91
 
92
92
  # Verify the Command update structure and contents
93
93
  self.assertIsInstance(result, Command)
@@ -144,7 +144,7 @@ class TestZoteroSearchTool(unittest.TestCase):
144
144
  "tool_call_id": tool_call_id,
145
145
  "limit": 2,
146
146
  }
147
- result = zotero_search_tool.run(tool_input)
147
+ result = zotero_read.run(tool_input)
148
148
 
149
149
  update = result.update
150
150
  filtered_papers = update["zotero_read"]
@@ -183,7 +183,7 @@ class TestZoteroSearchTool(unittest.TestCase):
183
183
  "limit": 2,
184
184
  }
185
185
  with self.assertRaises(RuntimeError) as context:
186
- zotero_search_tool.run(tool_input)
186
+ zotero_read.run(tool_input)
187
187
  self.assertIn("No items returned from Zotero", str(context.exception))
188
188
 
189
189
  @patch(
@@ -244,7 +244,7 @@ class TestZoteroSearchTool(unittest.TestCase):
244
244
  "limit": 2,
245
245
  }
246
246
  # Instead of expecting a RuntimeError, we now expect both items to be returned.
247
- result = zotero_search_tool.run(tool_input)
247
+ result = zotero_read.run(tool_input)
248
248
  update = result.update
249
249
  filtered_papers = update["zotero_read"]
250
250
  self.assertIn("paper1", filtered_papers)
@@ -281,7 +281,7 @@ class TestZoteroSearchTool(unittest.TestCase):
281
281
  "limit": 2,
282
282
  }
283
283
  with self.assertRaises(RuntimeError) as context:
284
- zotero_search_tool.run(tool_input)
284
+ zotero_read.run(tool_input)
285
285
  self.assertIn("Failed to fetch items from Zotero", str(context.exception))
286
286
 
287
287
  @patch(
@@ -336,7 +336,7 @@ class TestZoteroSearchTool(unittest.TestCase):
336
336
  "tool_call_id": tool_call_id,
337
337
  "limit": 2,
338
338
  }
339
- result = zotero_search_tool.run(tool_input)
339
+ result = zotero_read.run(tool_input)
340
340
 
341
341
  update = result.update
342
342
  filtered_papers = update["zotero_read"]
@@ -378,7 +378,7 @@ class TestZoteroSearchTool(unittest.TestCase):
378
378
  "limit": 2,
379
379
  }
380
380
  with self.assertRaises(RuntimeError) as context:
381
- zotero_search_tool.run(tool_input)
381
+ zotero_read.run(tool_input)
382
382
  self.assertIn("No matching papers returned from Zotero", str(context.exception))
383
383
 
384
384
  @patch(
@@ -415,5 +415,5 @@ class TestZoteroSearchTool(unittest.TestCase):
415
415
  "limit": 2,
416
416
  }
417
417
  with self.assertRaises(RuntimeError) as context:
418
- zotero_search_tool.run(tool_input)
418
+ zotero_read.run(tool_input)
419
419
  self.assertIn("No matching papers returned from Zotero", str(context.exception))