qodev-apollo-api 0.2.0__tar.gz → 0.2.2__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.0 → qodev_apollo_api-0.2.2}/CHANGELOG.md +5 -0
- {qodev_apollo_api-0.2.0 → qodev_apollo_api-0.2.2}/PKG-INFO +1 -1
- {qodev_apollo_api-0.2.0 → qodev_apollo_api-0.2.2}/pyproject.toml +1 -1
- {qodev_apollo_api-0.2.0 → qodev_apollo_api-0.2.2}/src/qodev_apollo_api/__init__.py +1 -1
- {qodev_apollo_api-0.2.0 → qodev_apollo_api-0.2.2}/src/qodev_apollo_api/client.py +12 -1
- {qodev_apollo_api-0.2.0 → qodev_apollo_api-0.2.2}/src/qodev_apollo_api/models.py +8 -2
- {qodev_apollo_api-0.2.0 → qodev_apollo_api-0.2.2}/src/qodev_apollo_api/utils.py +14 -9
- {qodev_apollo_api-0.2.0 → qodev_apollo_api-0.2.2}/tests/test_client.py +32 -0
- {qodev_apollo_api-0.2.0 → qodev_apollo_api-0.2.2}/tests/test_models.py +17 -0
- {qodev_apollo_api-0.2.0 → qodev_apollo_api-0.2.2}/tests/test_utils.py +17 -11
- {qodev_apollo_api-0.2.0 → qodev_apollo_api-0.2.2}/uv.lock +1 -1
- {qodev_apollo_api-0.2.0 → qodev_apollo_api-0.2.2}/.github/workflows/ci.yml +0 -0
- {qodev_apollo_api-0.2.0 → qodev_apollo_api-0.2.2}/.github/workflows/publish.yml +0 -0
- {qodev_apollo_api-0.2.0 → qodev_apollo_api-0.2.2}/.gitignore +0 -0
- {qodev_apollo_api-0.2.0 → qodev_apollo_api-0.2.2}/.pre-commit-config.yaml +0 -0
- {qodev_apollo_api-0.2.0 → qodev_apollo_api-0.2.2}/CLAUDE.md +0 -0
- {qodev_apollo_api-0.2.0 → qodev_apollo_api-0.2.2}/LICENSE +0 -0
- {qodev_apollo_api-0.2.0 → qodev_apollo_api-0.2.2}/Makefile +0 -0
- {qodev_apollo_api-0.2.0 → qodev_apollo_api-0.2.2}/README.md +0 -0
- {qodev_apollo_api-0.2.0 → qodev_apollo_api-0.2.2}/src/qodev_apollo_api/exceptions.py +0 -0
- {qodev_apollo_api-0.2.0 → qodev_apollo_api-0.2.2}/src/qodev_apollo_api/py.typed +0 -0
- {qodev_apollo_api-0.2.0 → qodev_apollo_api-0.2.2}/tests/__init__.py +0 -0
- {qodev_apollo_api-0.2.0 → qodev_apollo_api-0.2.2}/tests/integration/__init__.py +0 -0
- {qodev_apollo_api-0.2.0 → qodev_apollo_api-0.2.2}/tests/integration/validate_all_models.py +0 -0
- {qodev_apollo_api-0.2.0 → qodev_apollo_api-0.2.2}/tests/integration/validate_email_task_flow.py +0 -0
- {qodev_apollo_api-0.2.0 → qodev_apollo_api-0.2.2}/tests/test_exceptions.py +0 -0
|
@@ -7,6 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.2.1] - 2026-07-01
|
|
11
|
+
|
|
12
|
+
### Fixed
|
|
13
|
+
- `normalize_linkedin_url()` normalized to `https://` and never added `www`, but Apollo stores and **exact-matches** LinkedIn URLs as `http://www.linkedin.com/in/<slug>`. As a result `find_contact_by_linkedin_url()`'s URL tier always missed (silently falling through to name search), and any `search_contacts(linkedin_url=...)` filter built from it returned zero. It now produces Apollo's `http://www` form, so URL lookups actually match.
|
|
14
|
+
|
|
10
15
|
## [0.2.0] - 2026-06-02
|
|
11
16
|
|
|
12
17
|
### Fixed
|
|
@@ -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 (
|
|
@@ -40,6 +42,8 @@ from .models import (
|
|
|
40
42
|
)
|
|
41
43
|
from .utils import markdown_to_prosemirror, normalize_linkedin_url, prosemirror_to_markdown
|
|
42
44
|
|
|
45
|
+
logger = logging.getLogger(__name__)
|
|
46
|
+
|
|
43
47
|
|
|
44
48
|
class ApolloClient:
|
|
45
49
|
"""Async Apollo.io API client with context manager support."""
|
|
@@ -709,7 +713,14 @@ class ApolloClient:
|
|
|
709
713
|
data["multi_sort"] = [{field: {"order": order}} for field, order in sort]
|
|
710
714
|
result = await self._post("/tasks/search", data)
|
|
711
715
|
|
|
712
|
-
tasks
|
|
716
|
+
tasks: list[Task] = []
|
|
717
|
+
for raw in result.get("tasks", []):
|
|
718
|
+
try:
|
|
719
|
+
tasks.append(resolve_task(raw))
|
|
720
|
+
except ValidationError:
|
|
721
|
+
# A single structurally-invalid row (e.g. missing id) must not sink the whole
|
|
722
|
+
# page — skip it so the rest of the results stay usable.
|
|
723
|
+
logger.warning("Skipping unparseable task id=%s", raw.get("id"), exc_info=True)
|
|
713
724
|
pagination = result.get("pagination", {})
|
|
714
725
|
|
|
715
726
|
return PaginatedResponse[Task](
|
|
@@ -823,8 +823,14 @@ class BaseTask(ApolloModel):
|
|
|
823
823
|
title: str | None = None
|
|
824
824
|
subject: str | None = None
|
|
825
825
|
type: TaskType | None = None
|
|
826
|
-
|
|
827
|
-
status:
|
|
826
|
+
# Kept as raw strings, not the TaskStatus/TaskPriority enums: Apollo returns values the
|
|
827
|
+
# library doesn't model (status: skipped/archived/…; priority occasionally outside
|
|
828
|
+
# high/medium/low). A strict enum here broke deserialization — a task with e.g.
|
|
829
|
+
# status="skipped" failed the discriminated union AND resolve_task's OtherTask fallback
|
|
830
|
+
# (which inherits these fields), so a single such row raised for the *whole* search page.
|
|
831
|
+
# Compare against TaskStatus.* / TaskPriority.* — StrEnum equality still holds.
|
|
832
|
+
priority: str | None = None
|
|
833
|
+
status: str | None = None
|
|
828
834
|
due_at: datetime | None = None
|
|
829
835
|
note: str | None = None
|
|
830
836
|
answered: bool | None = None
|
|
@@ -167,20 +167,25 @@ def markdown_to_prosemirror(content: str, title: str | None = None) -> str:
|
|
|
167
167
|
|
|
168
168
|
|
|
169
169
|
def normalize_linkedin_url(url: str) -> str:
|
|
170
|
-
"""Normalize LinkedIn URL
|
|
170
|
+
"""Normalize a LinkedIn URL to Apollo's stored, exact-match form.
|
|
171
|
+
|
|
172
|
+
Apollo stores and exact-matches LinkedIn URLs as ``http://www.linkedin.com/in/<slug>``
|
|
173
|
+
— **http** scheme (not https), ``www`` host, no trailing slash, lowercase. Producing
|
|
174
|
+
that exact form is what lets a ``linkedin_url`` search actually match; a ``https://``
|
|
175
|
+
or ``www``-less form silently returns zero results. It also doubles as a stable key for
|
|
176
|
+
comparing two URLs.
|
|
171
177
|
|
|
172
178
|
Args:
|
|
173
|
-
url: LinkedIn profile URL
|
|
179
|
+
url: LinkedIn profile URL (any common shape).
|
|
174
180
|
|
|
175
181
|
Returns:
|
|
176
|
-
|
|
182
|
+
The URL as ``http://www.linkedin.com/...`` (or ``""`` for a falsy input).
|
|
177
183
|
"""
|
|
178
184
|
if not url:
|
|
179
185
|
return ""
|
|
180
186
|
url = url.lower().strip().rstrip("/")
|
|
181
|
-
#
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
return url
|
|
187
|
+
# Rebuild to Apollo's stored form: strip the scheme, force the www host, force http.
|
|
188
|
+
url = re.sub(r"^https?://", "", url)
|
|
189
|
+
if url.startswith("linkedin.com/"):
|
|
190
|
+
url = "www." + url
|
|
191
|
+
return f"http://{url}"
|
|
@@ -305,6 +305,38 @@ async def test_search_tasks(client: ApolloClient):
|
|
|
305
305
|
assert client._client.request.call_args[0] == ("POST", "/tasks/search")
|
|
306
306
|
|
|
307
307
|
|
|
308
|
+
async def test_search_tasks_returns_task_with_unmodelled_status(client: ApolloClient):
|
|
309
|
+
"""Regression: a task whose status Apollo returns but the library doesn't model
|
|
310
|
+
(skipped/archived/…) is returned with the raw status — not dropped, not raised. This
|
|
311
|
+
is what broke the LinkedIn connect batch: a single skipped task sank the whole page."""
|
|
312
|
+
client._client.request.return_value = _make_response(
|
|
313
|
+
{
|
|
314
|
+
"tasks": [{"id": "t1", "type": "linkedin_step_connect", "status": "skipped"}],
|
|
315
|
+
"pagination": {"total_entries": 1},
|
|
316
|
+
}
|
|
317
|
+
)
|
|
318
|
+
|
|
319
|
+
result = await client.search_tasks()
|
|
320
|
+
|
|
321
|
+
assert len(result.items) == 1
|
|
322
|
+
assert result.items[0].status == "skipped"
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
async def test_search_tasks_skips_unparseable_row(client: ApolloClient):
|
|
326
|
+
"""A structurally-invalid row (missing id) is skipped, not fatal — the rest of the page
|
|
327
|
+
is still returned."""
|
|
328
|
+
client._client.request.return_value = _make_response(
|
|
329
|
+
{
|
|
330
|
+
"tasks": [{"type": "call"}, {"id": "t2", "type": "call"}],
|
|
331
|
+
"pagination": {"total_entries": 2},
|
|
332
|
+
}
|
|
333
|
+
)
|
|
334
|
+
|
|
335
|
+
result = await client.search_tasks()
|
|
336
|
+
|
|
337
|
+
assert [t.id for t in result.items] == ["t2"]
|
|
338
|
+
|
|
339
|
+
|
|
308
340
|
async def test_search_tasks_with_sort(client: ApolloClient):
|
|
309
341
|
"""Test search_tasks with sort parameter generates multi_sort payload."""
|
|
310
342
|
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(
|
|
@@ -82,32 +82,38 @@ def test_prosemirror_empty_doc():
|
|
|
82
82
|
|
|
83
83
|
|
|
84
84
|
def test_normalize_linkedin_url():
|
|
85
|
-
"""
|
|
86
|
-
#
|
|
85
|
+
"""Normalizes to Apollo's stored, exact-match form: http://www.linkedin.com/..."""
|
|
86
|
+
# https is rewritten to Apollo's http, and www is added
|
|
87
87
|
assert (
|
|
88
88
|
normalize_linkedin_url("https://linkedin.com/in/johndoe")
|
|
89
|
-
== "
|
|
89
|
+
== "http://www.linkedin.com/in/johndoe"
|
|
90
90
|
)
|
|
91
91
|
|
|
92
|
-
#
|
|
92
|
+
# Lowercase conversion
|
|
93
93
|
assert (
|
|
94
94
|
normalize_linkedin_url("HTTPS://LinkedIn.com/in/JohnDoe")
|
|
95
|
-
== "
|
|
95
|
+
== "http://www.linkedin.com/in/johndoe"
|
|
96
96
|
)
|
|
97
97
|
|
|
98
|
-
#
|
|
98
|
+
# Trailing slash removal
|
|
99
99
|
assert (
|
|
100
100
|
normalize_linkedin_url("https://linkedin.com/in/johndoe/")
|
|
101
|
-
== "
|
|
101
|
+
== "http://www.linkedin.com/in/johndoe"
|
|
102
102
|
)
|
|
103
103
|
|
|
104
|
-
#
|
|
105
|
-
assert normalize_linkedin_url("linkedin.com/in/johndoe") == "
|
|
104
|
+
# Protocol addition
|
|
105
|
+
assert normalize_linkedin_url("linkedin.com/in/johndoe") == "http://www.linkedin.com/in/johndoe"
|
|
106
106
|
|
|
107
|
-
#
|
|
107
|
+
# An already-www URL keeps a single www (no www.www)
|
|
108
|
+
assert (
|
|
109
|
+
normalize_linkedin_url("https://www.linkedin.com/in/johndoe")
|
|
110
|
+
== "http://www.linkedin.com/in/johndoe"
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
# Whitespace handling
|
|
108
114
|
assert (
|
|
109
115
|
normalize_linkedin_url(" https://linkedin.com/in/johndoe ")
|
|
110
|
-
== "
|
|
116
|
+
== "http://www.linkedin.com/in/johndoe"
|
|
111
117
|
)
|
|
112
118
|
|
|
113
119
|
|
|
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.0 → qodev_apollo_api-0.2.2}/tests/integration/validate_email_task_flow.py
RENAMED
|
File without changes
|
|
File without changes
|