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.
Files changed (26) hide show
  1. {qodev_apollo_api-0.2.0 → qodev_apollo_api-0.2.2}/CHANGELOG.md +5 -0
  2. {qodev_apollo_api-0.2.0 → qodev_apollo_api-0.2.2}/PKG-INFO +1 -1
  3. {qodev_apollo_api-0.2.0 → qodev_apollo_api-0.2.2}/pyproject.toml +1 -1
  4. {qodev_apollo_api-0.2.0 → qodev_apollo_api-0.2.2}/src/qodev_apollo_api/__init__.py +1 -1
  5. {qodev_apollo_api-0.2.0 → qodev_apollo_api-0.2.2}/src/qodev_apollo_api/client.py +12 -1
  6. {qodev_apollo_api-0.2.0 → qodev_apollo_api-0.2.2}/src/qodev_apollo_api/models.py +8 -2
  7. {qodev_apollo_api-0.2.0 → qodev_apollo_api-0.2.2}/src/qodev_apollo_api/utils.py +14 -9
  8. {qodev_apollo_api-0.2.0 → qodev_apollo_api-0.2.2}/tests/test_client.py +32 -0
  9. {qodev_apollo_api-0.2.0 → qodev_apollo_api-0.2.2}/tests/test_models.py +17 -0
  10. {qodev_apollo_api-0.2.0 → qodev_apollo_api-0.2.2}/tests/test_utils.py +17 -11
  11. {qodev_apollo_api-0.2.0 → qodev_apollo_api-0.2.2}/uv.lock +1 -1
  12. {qodev_apollo_api-0.2.0 → qodev_apollo_api-0.2.2}/.github/workflows/ci.yml +0 -0
  13. {qodev_apollo_api-0.2.0 → qodev_apollo_api-0.2.2}/.github/workflows/publish.yml +0 -0
  14. {qodev_apollo_api-0.2.0 → qodev_apollo_api-0.2.2}/.gitignore +0 -0
  15. {qodev_apollo_api-0.2.0 → qodev_apollo_api-0.2.2}/.pre-commit-config.yaml +0 -0
  16. {qodev_apollo_api-0.2.0 → qodev_apollo_api-0.2.2}/CLAUDE.md +0 -0
  17. {qodev_apollo_api-0.2.0 → qodev_apollo_api-0.2.2}/LICENSE +0 -0
  18. {qodev_apollo_api-0.2.0 → qodev_apollo_api-0.2.2}/Makefile +0 -0
  19. {qodev_apollo_api-0.2.0 → qodev_apollo_api-0.2.2}/README.md +0 -0
  20. {qodev_apollo_api-0.2.0 → qodev_apollo_api-0.2.2}/src/qodev_apollo_api/exceptions.py +0 -0
  21. {qodev_apollo_api-0.2.0 → qodev_apollo_api-0.2.2}/src/qodev_apollo_api/py.typed +0 -0
  22. {qodev_apollo_api-0.2.0 → qodev_apollo_api-0.2.2}/tests/__init__.py +0 -0
  23. {qodev_apollo_api-0.2.0 → qodev_apollo_api-0.2.2}/tests/integration/__init__.py +0 -0
  24. {qodev_apollo_api-0.2.0 → qodev_apollo_api-0.2.2}/tests/integration/validate_all_models.py +0 -0
  25. {qodev_apollo_api-0.2.0 → qodev_apollo_api-0.2.2}/tests/integration/validate_email_task_flow.py +0 -0
  26. {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
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: qodev-apollo-api
3
- Version: 0.2.0
3
+ Version: 0.2.2
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.0"
3
+ version = "0.2.2"
4
4
  description = "Async Python client for Apollo.io CRM API"
5
5
  readme = "README.md"
6
6
  authors = [
@@ -151,4 +151,4 @@ __all__ = [
151
151
  "resolve_task",
152
152
  ]
153
153
 
154
- __version__ = "0.2.0"
154
+ __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 (
@@ -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 = [resolve_task(t) for t in result.get("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
- priority: TaskPriority | None = None
827
- status: TaskStatus | None = None
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 for comparison.
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
- Normalized URL (lowercase, stripped, trailing slash removed)
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
- # Normalize scheme to https://
182
- if url.startswith("http://"):
183
- url = "https://" + url[7:]
184
- elif not url.startswith("https://"):
185
- url = f"https://{url}"
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
- """Test LinkedIn URL normalization."""
86
- # Test basic normalization
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
- == "https://linkedin.com/in/johndoe"
89
+ == "http://www.linkedin.com/in/johndoe"
90
90
  )
91
91
 
92
- # Test lowercase conversion
92
+ # Lowercase conversion
93
93
  assert (
94
94
  normalize_linkedin_url("HTTPS://LinkedIn.com/in/JohnDoe")
95
- == "https://linkedin.com/in/johndoe"
95
+ == "http://www.linkedin.com/in/johndoe"
96
96
  )
97
97
 
98
- # Test trailing slash removal
98
+ # Trailing slash removal
99
99
  assert (
100
100
  normalize_linkedin_url("https://linkedin.com/in/johndoe/")
101
- == "https://linkedin.com/in/johndoe"
101
+ == "http://www.linkedin.com/in/johndoe"
102
102
  )
103
103
 
104
- # Test protocol addition
105
- assert normalize_linkedin_url("linkedin.com/in/johndoe") == "https://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
- # Test whitespace handling
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
- == "https://linkedin.com/in/johndoe"
116
+ == "http://www.linkedin.com/in/johndoe"
111
117
  )
112
118
 
113
119
 
@@ -407,7 +407,7 @@ wheels = [
407
407
 
408
408
  [[package]]
409
409
  name = "qodev-apollo-api"
410
- version = "0.1.3"
410
+ version = "0.2.2"
411
411
  source = { editable = "." }
412
412
  dependencies = [
413
413
  { name = "httpx" },