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.
- {qodev_apollo_api-0.2.1 → qodev_apollo_api-0.3.0}/CHANGELOG.md +8 -0
- {qodev_apollo_api-0.2.1 → qodev_apollo_api-0.3.0}/PKG-INFO +1 -1
- {qodev_apollo_api-0.2.1 → qodev_apollo_api-0.3.0}/pyproject.toml +1 -1
- {qodev_apollo_api-0.2.1 → qodev_apollo_api-0.3.0}/src/qodev_apollo_api/__init__.py +3 -1
- {qodev_apollo_api-0.2.1 → qodev_apollo_api-0.3.0}/src/qodev_apollo_api/client.py +54 -1
- {qodev_apollo_api-0.2.1 → qodev_apollo_api-0.3.0}/src/qodev_apollo_api/models.py +23 -2
- {qodev_apollo_api-0.2.1 → qodev_apollo_api-0.3.0}/tests/test_client.py +80 -0
- {qodev_apollo_api-0.2.1 → qodev_apollo_api-0.3.0}/tests/test_models.py +17 -0
- {qodev_apollo_api-0.2.1 → qodev_apollo_api-0.3.0}/uv.lock +1 -1
- {qodev_apollo_api-0.2.1 → qodev_apollo_api-0.3.0}/.github/workflows/ci.yml +0 -0
- {qodev_apollo_api-0.2.1 → qodev_apollo_api-0.3.0}/.github/workflows/publish.yml +0 -0
- {qodev_apollo_api-0.2.1 → qodev_apollo_api-0.3.0}/.gitignore +0 -0
- {qodev_apollo_api-0.2.1 → qodev_apollo_api-0.3.0}/.pre-commit-config.yaml +0 -0
- {qodev_apollo_api-0.2.1 → qodev_apollo_api-0.3.0}/CLAUDE.md +0 -0
- {qodev_apollo_api-0.2.1 → qodev_apollo_api-0.3.0}/LICENSE +0 -0
- {qodev_apollo_api-0.2.1 → qodev_apollo_api-0.3.0}/Makefile +0 -0
- {qodev_apollo_api-0.2.1 → qodev_apollo_api-0.3.0}/README.md +0 -0
- {qodev_apollo_api-0.2.1 → qodev_apollo_api-0.3.0}/src/qodev_apollo_api/exceptions.py +0 -0
- {qodev_apollo_api-0.2.1 → qodev_apollo_api-0.3.0}/src/qodev_apollo_api/py.typed +0 -0
- {qodev_apollo_api-0.2.1 → qodev_apollo_api-0.3.0}/src/qodev_apollo_api/utils.py +0 -0
- {qodev_apollo_api-0.2.1 → qodev_apollo_api-0.3.0}/tests/__init__.py +0 -0
- {qodev_apollo_api-0.2.1 → qodev_apollo_api-0.3.0}/tests/integration/__init__.py +0 -0
- {qodev_apollo_api-0.2.1 → qodev_apollo_api-0.3.0}/tests/integration/validate_all_models.py +0 -0
- {qodev_apollo_api-0.2.1 → qodev_apollo_api-0.3.0}/tests/integration/validate_email_task_flow.py +0 -0
- {qodev_apollo_api-0.2.1 → qodev_apollo_api-0.3.0}/tests/test_exceptions.py +0 -0
- {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
|
|
@@ -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.
|
|
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
|
|
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
|
-
|
|
827
|
-
status:
|
|
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(
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{qodev_apollo_api-0.2.1 → qodev_apollo_api-0.3.0}/tests/integration/validate_email_task_flow.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|