qodev-apollo-api 0.2.1__tar.gz → 0.3.0__tar.gz

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.
Files changed (26) hide show
  1. {qodev_apollo_api-0.2.1 → qodev_apollo_api-0.3.0}/CHANGELOG.md +8 -0
  2. {qodev_apollo_api-0.2.1 → qodev_apollo_api-0.3.0}/PKG-INFO +1 -1
  3. {qodev_apollo_api-0.2.1 → qodev_apollo_api-0.3.0}/pyproject.toml +1 -1
  4. {qodev_apollo_api-0.2.1 → qodev_apollo_api-0.3.0}/src/qodev_apollo_api/__init__.py +3 -1
  5. {qodev_apollo_api-0.2.1 → qodev_apollo_api-0.3.0}/src/qodev_apollo_api/client.py +54 -1
  6. {qodev_apollo_api-0.2.1 → qodev_apollo_api-0.3.0}/src/qodev_apollo_api/models.py +23 -2
  7. {qodev_apollo_api-0.2.1 → qodev_apollo_api-0.3.0}/tests/test_client.py +80 -0
  8. {qodev_apollo_api-0.2.1 → qodev_apollo_api-0.3.0}/tests/test_models.py +17 -0
  9. {qodev_apollo_api-0.2.1 → qodev_apollo_api-0.3.0}/uv.lock +1 -1
  10. {qodev_apollo_api-0.2.1 → qodev_apollo_api-0.3.0}/.github/workflows/ci.yml +0 -0
  11. {qodev_apollo_api-0.2.1 → qodev_apollo_api-0.3.0}/.github/workflows/publish.yml +0 -0
  12. {qodev_apollo_api-0.2.1 → qodev_apollo_api-0.3.0}/.gitignore +0 -0
  13. {qodev_apollo_api-0.2.1 → qodev_apollo_api-0.3.0}/.pre-commit-config.yaml +0 -0
  14. {qodev_apollo_api-0.2.1 → qodev_apollo_api-0.3.0}/CLAUDE.md +0 -0
  15. {qodev_apollo_api-0.2.1 → qodev_apollo_api-0.3.0}/LICENSE +0 -0
  16. {qodev_apollo_api-0.2.1 → qodev_apollo_api-0.3.0}/Makefile +0 -0
  17. {qodev_apollo_api-0.2.1 → qodev_apollo_api-0.3.0}/README.md +0 -0
  18. {qodev_apollo_api-0.2.1 → qodev_apollo_api-0.3.0}/src/qodev_apollo_api/exceptions.py +0 -0
  19. {qodev_apollo_api-0.2.1 → qodev_apollo_api-0.3.0}/src/qodev_apollo_api/py.typed +0 -0
  20. {qodev_apollo_api-0.2.1 → qodev_apollo_api-0.3.0}/src/qodev_apollo_api/utils.py +0 -0
  21. {qodev_apollo_api-0.2.1 → qodev_apollo_api-0.3.0}/tests/__init__.py +0 -0
  22. {qodev_apollo_api-0.2.1 → qodev_apollo_api-0.3.0}/tests/integration/__init__.py +0 -0
  23. {qodev_apollo_api-0.2.1 → qodev_apollo_api-0.3.0}/tests/integration/validate_all_models.py +0 -0
  24. {qodev_apollo_api-0.2.1 → qodev_apollo_api-0.3.0}/tests/integration/validate_email_task_flow.py +0 -0
  25. {qodev_apollo_api-0.2.1 → qodev_apollo_api-0.3.0}/tests/test_exceptions.py +0 -0
  26. {qodev_apollo_api-0.2.1 → qodev_apollo_api-0.3.0}/tests/test_utils.py +0 -0
@@ -7,6 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.3.0] - 2026-07-08
11
+
12
+ ### Added
13
+
14
+ - `ApolloClient.update_opportunity_roles(opportunity_id, roles)` — `POST /opportunities/update_roles`. Sets the contact roles on a deal (replaces the full set; read the current roles from `get_deal(...).opportunity_contact_roles` and modify). Returns the updated `Deal`. Surfaces the previously curl-only role-management endpoint.
15
+ - `ApolloClient.list_custom_fields()` — `GET /typed_custom_fields`. Returns the account/contact/opportunity custom field definitions as a new `CustomField` model (`id`, `modality`, `name`, `type`, `picklist_options`, `mapped_crm_field`).
16
+ - `CustomField` model, exported from the package root.
17
+
10
18
  ## [0.2.1] - 2026-07-01
11
19
 
12
20
  ### Fixed
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: qodev-apollo-api
3
- Version: 0.2.1
3
+ Version: 0.3.0
4
4
  Summary: Async Python client for Apollo.io CRM API
5
5
  Project-URL: Homepage, https://github.com/qodevai/apollo-api
6
6
  Project-URL: Repository, https://github.com/qodevai/apollo-api
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "qodev-apollo-api"
3
- version = "0.2.1"
3
+ version = "0.3.0"
4
4
  description = "Async Python client for Apollo.io CRM API"
5
5
  readme = "README.md"
6
6
  authors = [
@@ -35,6 +35,7 @@ from .models import (
35
35
  CrmJob,
36
36
  CrmNote,
37
37
  Currency,
38
+ CustomField,
38
39
  Deal,
39
40
  Email,
40
41
  EmailerMessage,
@@ -111,6 +112,7 @@ __all__ = [
111
112
  "CrmJob",
112
113
  "CrmNote",
113
114
  "Currency",
115
+ "CustomField",
114
116
  "Deal",
115
117
  "Email",
116
118
  "EmailParticipant",
@@ -151,4 +153,4 @@ __all__ = [
151
153
  "resolve_task",
152
154
  ]
153
155
 
154
- __version__ = "0.2.0"
156
+ __version__ = "0.2.2"
@@ -3,12 +3,14 @@
3
3
  Type-safe async wrapper for Apollo.io API.
4
4
  """
5
5
 
6
+ import logging
6
7
  import os
7
8
  from datetime import datetime
8
9
  from types import TracebackType
9
10
  from typing import Any
10
11
 
11
12
  import httpx
13
+ from pydantic import ValidationError
12
14
 
13
15
  from .exceptions import APIError, AuthenticationError, RateLimitError
14
16
  from .models import (
@@ -20,6 +22,7 @@ from .models import (
20
22
  ContactDetail,
21
23
  Conversation,
22
24
  ConversationDetail,
25
+ CustomField,
23
26
  Deal,
24
27
  Email,
25
28
  EmailerMessage,
@@ -40,6 +43,8 @@ from .models import (
40
43
  )
41
44
  from .utils import markdown_to_prosemirror, normalize_linkedin_url, prosemirror_to_markdown
42
45
 
46
+ logger = logging.getLogger(__name__)
47
+
43
48
 
44
49
  class ApolloClient:
45
50
  """Async Apollo.io API client with context manager support."""
@@ -516,6 +521,47 @@ class ApolloClient:
516
521
  page=1,
517
522
  )
518
523
 
524
+ async def update_opportunity_roles(self, opportunity_id: str, roles: list[dict]) -> Deal:
525
+ """Set the contact roles on a deal/opportunity (undocumented endpoint).
526
+
527
+ This **replaces** the full set of contact roles on the opportunity, so
528
+ callers should pass the complete desired set (read the current roles from
529
+ ``get_deal(...).opportunity_contact_roles`` and modify them). Each entry is
530
+ a dict with ``contact_id`` and, optionally, ``opportunity_contact_role_type_id``
531
+ and ``is_primary``::
532
+
533
+ roles = [
534
+ {"contact_id": "abc", "opportunity_contact_role_type_id": "t1", "is_primary": True},
535
+ {"contact_id": "def", "is_primary": False},
536
+ ]
537
+
538
+ Args:
539
+ opportunity_id: The opportunity/deal ID.
540
+ roles: The complete list of contact-role entries to set.
541
+
542
+ Returns:
543
+ The updated Deal.
544
+ """
545
+ data = {"opportunity_id": opportunity_id, "roles": roles}
546
+ result = await self._post("/opportunities/update_roles", data)
547
+ return Deal.model_validate(result.get("opportunity", result))
548
+
549
+ # ========================================================================
550
+ # CUSTOM FIELDS
551
+ # ========================================================================
552
+
553
+ async def list_custom_fields(self) -> list[CustomField]:
554
+ """List all typed custom field definitions (undocumented endpoint).
555
+
556
+ Returns definitions for custom fields across every modality (contact,
557
+ account, opportunity). Use ``modality`` to filter client-side.
558
+
559
+ Returns:
560
+ List of CustomField definitions.
561
+ """
562
+ result = await self._get("/typed_custom_fields")
563
+ return [CustomField.model_validate(f) for f in result.get("typed_custom_fields", [])]
564
+
519
565
  # ========================================================================
520
566
  # ENRICHMENT
521
567
  # ========================================================================
@@ -709,7 +755,14 @@ class ApolloClient:
709
755
  data["multi_sort"] = [{field: {"order": order}} for field, order in sort]
710
756
  result = await self._post("/tasks/search", data)
711
757
 
712
- tasks = [resolve_task(t) for t in result.get("tasks", [])]
758
+ tasks: list[Task] = []
759
+ for raw in result.get("tasks", []):
760
+ try:
761
+ tasks.append(resolve_task(raw))
762
+ except ValidationError:
763
+ # A single structurally-invalid row (e.g. missing id) must not sink the whole
764
+ # page — skip it so the rest of the results stay usable.
765
+ logger.warning("Skipping unparseable task id=%s", raw.get("id"), exc_info=True)
713
766
  pagination = result.get("pagination", {})
714
767
 
715
768
  return PaginatedResponse[Task](
@@ -640,6 +640,21 @@ class Stage(ApolloModel):
640
640
  team_id: str | None = None
641
641
 
642
642
 
643
+ class CustomField(ApolloModel):
644
+ """A typed custom field definition (from GET /typed_custom_fields).
645
+
646
+ ``modality`` is the entity the field belongs to (e.g. "contact", "account",
647
+ "opportunity"); ``type`` is the value type (e.g. "text", "date", "picklist").
648
+ """
649
+
650
+ id: str
651
+ modality: str | None = None
652
+ name: str | None = None
653
+ type: str | None = None
654
+ picklist_options: list[str] = Field(default_factory=list)
655
+ mapped_crm_field: str | None = None
656
+
657
+
643
658
  # ============================================================================
644
659
  # ACTIVITY MODELS
645
660
  # ============================================================================
@@ -823,8 +838,14 @@ class BaseTask(ApolloModel):
823
838
  title: str | None = None
824
839
  subject: str | None = None
825
840
  type: TaskType | None = None
826
- priority: TaskPriority | None = None
827
- status: TaskStatus | None = None
841
+ # Kept as raw strings, not the TaskStatus/TaskPriority enums: Apollo returns values the
842
+ # library doesn't model (status: skipped/archived/…; priority occasionally outside
843
+ # high/medium/low). A strict enum here broke deserialization — a task with e.g.
844
+ # status="skipped" failed the discriminated union AND resolve_task's OtherTask fallback
845
+ # (which inherits these fields), so a single such row raised for the *whole* search page.
846
+ # Compare against TaskStatus.* / TaskPriority.* — StrEnum equality still holds.
847
+ priority: str | None = None
848
+ status: str | None = None
828
849
  due_at: datetime | None = None
829
850
  note: str | None = None
830
851
  answered: bool | None = None
@@ -21,6 +21,7 @@ from qodev_apollo_api.models import (
21
21
  ContactDetail,
22
22
  Conversation,
23
23
  ConversationDetail,
24
+ CustomField,
24
25
  Deal,
25
26
  Email,
26
27
  EmailerMessage,
@@ -305,6 +306,38 @@ async def test_search_tasks(client: ApolloClient):
305
306
  assert client._client.request.call_args[0] == ("POST", "/tasks/search")
306
307
 
307
308
 
309
+ async def test_search_tasks_returns_task_with_unmodelled_status(client: ApolloClient):
310
+ """Regression: a task whose status Apollo returns but the library doesn't model
311
+ (skipped/archived/…) is returned with the raw status — not dropped, not raised. This
312
+ is what broke the LinkedIn connect batch: a single skipped task sank the whole page."""
313
+ client._client.request.return_value = _make_response(
314
+ {
315
+ "tasks": [{"id": "t1", "type": "linkedin_step_connect", "status": "skipped"}],
316
+ "pagination": {"total_entries": 1},
317
+ }
318
+ )
319
+
320
+ result = await client.search_tasks()
321
+
322
+ assert len(result.items) == 1
323
+ assert result.items[0].status == "skipped"
324
+
325
+
326
+ async def test_search_tasks_skips_unparseable_row(client: ApolloClient):
327
+ """A structurally-invalid row (missing id) is skipped, not fatal — the rest of the page
328
+ is still returned."""
329
+ client._client.request.return_value = _make_response(
330
+ {
331
+ "tasks": [{"type": "call"}, {"id": "t2", "type": "call"}],
332
+ "pagination": {"total_entries": 2},
333
+ }
334
+ )
335
+
336
+ result = await client.search_tasks()
337
+
338
+ assert [t.id for t in result.items] == ["t2"]
339
+
340
+
308
341
  async def test_search_tasks_with_sort(client: ApolloClient):
309
342
  """Test search_tasks with sort parameter generates multi_sort payload."""
310
343
  client._client.request.return_value = _make_response(
@@ -586,6 +619,53 @@ async def test_list_opportunity_contact_role_types(client: ApolloClient):
586
619
  assert call_args[1]["json"] == {}
587
620
 
588
621
 
622
+ async def test_update_opportunity_roles(client: ApolloClient):
623
+ """Test POST /opportunities/update_roles returns the updated Deal with the roles body."""
624
+ client._client.request.return_value = _make_response(
625
+ {"opportunity": {"id": "d1", "name": "Big Deal"}}
626
+ )
627
+
628
+ roles = [
629
+ {"contact_id": "c1", "opportunity_contact_role_type_id": "rt1", "is_primary": True},
630
+ {"contact_id": "c2", "is_primary": False},
631
+ ]
632
+ result = await client.update_opportunity_roles("d1", roles)
633
+
634
+ assert isinstance(result, Deal)
635
+ assert result.id == "d1"
636
+
637
+ call_args = client._client.request.call_args
638
+ assert call_args[0] == ("POST", "/opportunities/update_roles")
639
+ assert call_args[1]["json"] == {"opportunity_id": "d1", "roles": roles}
640
+
641
+
642
+ async def test_list_custom_fields(client: ApolloClient):
643
+ """Test GET /typed_custom_fields returns list[CustomField]."""
644
+ client._client.request.return_value = _make_response(
645
+ {
646
+ "typed_custom_fields": [
647
+ {"id": "f1", "modality": "contact", "name": "First Message", "type": "date"},
648
+ {
649
+ "id": "f2",
650
+ "modality": "opportunity",
651
+ "name": "Region",
652
+ "type": "picklist",
653
+ "picklist_options": ["EU", "US"],
654
+ },
655
+ ]
656
+ }
657
+ )
658
+
659
+ result = await client.list_custom_fields()
660
+
661
+ assert len(result) == 2
662
+ assert all(isinstance(f, CustomField) for f in result)
663
+ assert result[0].name == "First Message"
664
+ assert result[1].picklist_options == ["EU", "US"]
665
+
666
+ assert client._client.request.call_args[0] == ("GET", "/typed_custom_fields")
667
+
668
+
589
669
  async def test_get_contact_stages(client: ApolloClient):
590
670
  """Test GET /contact_stages returns list[dict]."""
591
671
  client._client.request.return_value = _make_response(
@@ -60,6 +60,7 @@ from qodev_apollo_api.models import (
60
60
  PhoneEntry,
61
61
  Pipeline,
62
62
  Stage,
63
+ TaskStatus,
63
64
  TaskType,
64
65
  Technology,
65
66
  TranscriptSegment,
@@ -890,6 +891,22 @@ def test_resolve_task_unknown_type_falls_back_to_other_task():
890
891
  assert result.id == "1"
891
892
 
892
893
 
894
+ def test_resolve_task_preserves_unmodelled_status():
895
+ """A status Apollo returns but the library doesn't model (skipped/archived/…) must
896
+ deserialize as the raw string rather than raise — otherwise a single such task sinks a
897
+ whole tasks/search page (it fails the union AND the OtherTask fallback)."""
898
+ result = resolve_task({"id": "1", "type": "linkedin_step_connect", "status": "skipped"})
899
+ assert result.status == "skipped"
900
+ # comparison against the known-value enum still works via StrEnum equality
901
+ assert result.status != TaskStatus.SCHEDULED
902
+
903
+
904
+ def test_resolve_task_preserves_unmodelled_priority():
905
+ """Same guarantee for priority (values outside high/medium/low)."""
906
+ result = resolve_task({"id": "1", "type": "linkedin_step_connect", "priority": "none"})
907
+ assert result.priority == "none"
908
+
909
+
893
910
  def test_task_with_full_emailer_message():
894
911
  """Test EmailTask with complete emailer_message including scheduling fields."""
895
912
  task = EmailTask.model_validate(
@@ -407,7 +407,7 @@ wheels = [
407
407
 
408
408
  [[package]]
409
409
  name = "qodev-apollo-api"
410
- version = "0.2.1"
410
+ version = "0.3.0"
411
411
  source = { editable = "." }
412
412
  dependencies = [
413
413
  { name = "httpx" },