qodev-apollo-api 0.3.2__tar.gz → 0.4.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.3.2 → qodev_apollo_api-0.4.0}/CHANGELOG.md +11 -0
  2. {qodev_apollo_api-0.3.2 → qodev_apollo_api-0.4.0}/PKG-INFO +1 -1
  3. {qodev_apollo_api-0.3.2 → qodev_apollo_api-0.4.0}/pyproject.toml +1 -1
  4. {qodev_apollo_api-0.3.2 → qodev_apollo_api-0.4.0}/src/qodev_apollo_api/client.py +75 -2
  5. {qodev_apollo_api-0.3.2 → qodev_apollo_api-0.4.0}/tests/test_client.py +94 -2
  6. {qodev_apollo_api-0.3.2 → qodev_apollo_api-0.4.0}/.github/workflows/ci.yml +0 -0
  7. {qodev_apollo_api-0.3.2 → qodev_apollo_api-0.4.0}/.github/workflows/publish.yml +0 -0
  8. {qodev_apollo_api-0.3.2 → qodev_apollo_api-0.4.0}/.gitignore +0 -0
  9. {qodev_apollo_api-0.3.2 → qodev_apollo_api-0.4.0}/.pre-commit-config.yaml +0 -0
  10. {qodev_apollo_api-0.3.2 → qodev_apollo_api-0.4.0}/CLAUDE.md +0 -0
  11. {qodev_apollo_api-0.3.2 → qodev_apollo_api-0.4.0}/LICENSE +0 -0
  12. {qodev_apollo_api-0.3.2 → qodev_apollo_api-0.4.0}/Makefile +0 -0
  13. {qodev_apollo_api-0.3.2 → qodev_apollo_api-0.4.0}/README.md +0 -0
  14. {qodev_apollo_api-0.3.2 → qodev_apollo_api-0.4.0}/src/qodev_apollo_api/__init__.py +0 -0
  15. {qodev_apollo_api-0.3.2 → qodev_apollo_api-0.4.0}/src/qodev_apollo_api/exceptions.py +0 -0
  16. {qodev_apollo_api-0.3.2 → qodev_apollo_api-0.4.0}/src/qodev_apollo_api/models.py +0 -0
  17. {qodev_apollo_api-0.3.2 → qodev_apollo_api-0.4.0}/src/qodev_apollo_api/py.typed +0 -0
  18. {qodev_apollo_api-0.3.2 → qodev_apollo_api-0.4.0}/src/qodev_apollo_api/utils.py +0 -0
  19. {qodev_apollo_api-0.3.2 → qodev_apollo_api-0.4.0}/tests/__init__.py +0 -0
  20. {qodev_apollo_api-0.3.2 → qodev_apollo_api-0.4.0}/tests/integration/__init__.py +0 -0
  21. {qodev_apollo_api-0.3.2 → qodev_apollo_api-0.4.0}/tests/integration/validate_all_models.py +0 -0
  22. {qodev_apollo_api-0.3.2 → qodev_apollo_api-0.4.0}/tests/integration/validate_email_task_flow.py +0 -0
  23. {qodev_apollo_api-0.3.2 → qodev_apollo_api-0.4.0}/tests/test_exceptions.py +0 -0
  24. {qodev_apollo_api-0.3.2 → qodev_apollo_api-0.4.0}/tests/test_models.py +0 -0
  25. {qodev_apollo_api-0.3.2 → qodev_apollo_api-0.4.0}/tests/test_utils.py +0 -0
  26. {qodev_apollo_api-0.3.2 → qodev_apollo_api-0.4.0}/uv.lock +0 -0
@@ -7,6 +7,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.4.0] - 2026-07-09
11
+
12
+ ### Added
13
+
14
+ - `create_deal(name, **fields)` creates a deal/opportunity via `POST /opportunities`. `name` is the only required field; optional `owner_id`, `account_id`, `amount`, `opportunity_stage_id`, `closed_date` are forwarded as-is. Requires a **master** API key (non-master keys return 403). Live-verified against the real API.
15
+
16
+ ### Fixed
17
+
18
+ - `update_opportunity_roles(...)` now sends Apollo's expected **nested** role shape — `{"contact_id": …, "is_primary": …, "role": [{"opportunity_contact_role_type_id": …, "is_primary": …}]}` — instead of the flat `opportunity_contact_role_type_id` on the entry. The flat shape made Apollo 422 with `undefined method 'map' for nil`, so setting a contact's role on a deal failed every time. The public `RoleAssignment` interface is unchanged (callers still pass flat entries).
19
+ - `search_accounts(**filters)` now validates filter keys against an allowlist (`q_organization_name`, `account_stage_ids`, `account_label_ids`, `sort_by_field`, `sort_ascending`) and raises `ValueError` on unknown keys. Apollo silently ignores unrecognised keys and returns an unfiltered default page that looks like a real match (e.g. `query="…"` returned ~28k accounts, "Google" first) — fail-loud now prevents wrong-account attribution.
20
+
10
21
  ## [0.3.2] - 2026-07-08
11
22
 
12
23
  ### Fixed
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: qodev-apollo-api
3
- Version: 0.3.2
3
+ Version: 0.4.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.3.2"
3
+ version = "0.4.0"
4
4
  description = "Async Python client for Apollo.io CRM API"
5
5
  readme = "README.md"
6
6
  authors = [
@@ -46,6 +46,20 @@ from .utils import markdown_to_prosemirror, normalize_linkedin_url, prosemirror_
46
46
 
47
47
  logger = logging.getLogger(__name__)
48
48
 
49
+ # Filters accepted by POST /accounts/search (besides page/per_page, which are
50
+ # passed explicitly). Apollo *silently drops* unrecognised keys and returns an
51
+ # unfiltered default page, so we validate against this allowlist and raise
52
+ # instead of returning wrong data. See ``ApolloClient.search_accounts``.
53
+ ACCOUNT_SEARCH_FILTERS = frozenset(
54
+ {
55
+ "q_organization_name",
56
+ "account_stage_ids",
57
+ "account_label_ids",
58
+ "sort_by_field",
59
+ "sort_ascending",
60
+ }
61
+ )
62
+
49
63
 
50
64
  class ApolloClient:
51
65
  """Async Apollo.io API client with context manager support."""
@@ -350,11 +364,32 @@ class ApolloClient:
350
364
  Args:
351
365
  page: Page number (default 1)
352
366
  limit: Results per page (default 100, max 100)
353
- **filters: Additional filters (q_organization_name, account_stage_ids, etc.)
367
+ **filters: Search filters. Must be keys Apollo actually supports
368
+ (see ``ACCOUNT_SEARCH_FILTERS``): ``q_organization_name``,
369
+ ``account_stage_ids``, ``account_label_ids``, ``sort_by_field``,
370
+ ``sort_ascending``.
354
371
 
355
372
  Returns:
356
373
  Paginated response with Account items
374
+
375
+ Raises:
376
+ ValueError: If an unrecognised filter key is passed. Apollo silently
377
+ ignores unknown keys and returns an unfiltered default page (which
378
+ looks like a real match), so we fail loudly instead of returning the
379
+ wrong accounts. Note ``query=`` is **not** a valid filter — use
380
+ ``q_organization_name=`` to search by name.
357
381
  """
382
+ unknown = set(filters) - ACCOUNT_SEARCH_FILTERS
383
+ if unknown:
384
+ raise ValueError(
385
+ "Unknown account search filter(s): "
386
+ + ", ".join(sorted(unknown))
387
+ + ". Apollo silently ignores unrecognised keys and returns an unfiltered "
388
+ "default page. Supported filters: "
389
+ + ", ".join(sorted(ACCOUNT_SEARCH_FILTERS))
390
+ + " (to search by name use q_organization_name=)."
391
+ )
392
+
358
393
  data = {"page": page, "per_page": min(limit, 100), **filters}
359
394
  result = await self._post("/accounts/search", data)
360
395
 
@@ -420,6 +455,25 @@ class ApolloClient:
420
455
  result = await self._get(f"/opportunities/{deal_id}")
421
456
  return Deal.model_validate(result.get("opportunity", {}))
422
457
 
458
+ async def create_deal(self, name: str, **fields) -> Deal:
459
+ """Create a new deal/opportunity.
460
+
461
+ Note:
462
+ Apollo requires a **master** API key for this endpoint; a non-master
463
+ key returns 403. ``name`` is the only required field.
464
+
465
+ Args:
466
+ name: Human-readable deal name (required).
467
+ **fields: Additional fields (owner_id, account_id, amount,
468
+ opportunity_stage_id, closed_date [YYYY-MM-DD], etc.).
469
+
470
+ Returns:
471
+ The created Deal model.
472
+ """
473
+ data = {"name": name, **fields}
474
+ result = await self._post("/opportunities", data)
475
+ return Deal.model_validate(result.get("opportunity", result))
476
+
423
477
  # ========================================================================
424
478
  # PIPELINES & STAGES
425
479
  # ========================================================================
@@ -545,7 +599,26 @@ class ApolloClient:
545
599
  Returns:
546
600
  The updated Deal.
547
601
  """
548
- data = {"opportunity_id": opportunity_id, "roles": roles}
602
+ # Apollo's endpoint expects each entry's role type *nested* under a ``role``
603
+ # array — sending ``opportunity_contact_role_type_id`` flat on the entry (with
604
+ # no ``role`` key) makes the server call ``.map`` on nil and 422 with
605
+ # "undefined method 'map' for nil". Reshape the flat RoleAssignment entries
606
+ # into the wire format the server actually accepts.
607
+ wire_roles: list[dict] = []
608
+ for entry in roles:
609
+ # RoleAssignment types is_primary as a bool; default to False when omitted.
610
+ # Avoid bool(...) coercion, which would turn a stray truthy non-bool (e.g.
611
+ # the string "false") into True.
612
+ is_primary = entry.get("is_primary", False)
613
+ role_obj: dict[str, Any] = {"is_primary": is_primary}
614
+ role_type_id = entry.get("opportunity_contact_role_type_id")
615
+ if role_type_id:
616
+ role_obj["opportunity_contact_role_type_id"] = role_type_id
617
+ wire_roles.append(
618
+ {"contact_id": entry["contact_id"], "is_primary": is_primary, "role": [role_obj]}
619
+ )
620
+
621
+ data = {"opportunity_id": opportunity_id, "roles": wire_roles}
549
622
  result = await self._post("/opportunities/update_roles", data)
550
623
  return Deal.model_validate(result.get("opportunity", result))
551
624
 
@@ -251,6 +251,45 @@ async def test_search_accounts(client: ApolloClient):
251
251
  assert result.items[0].name == "Acme Corp"
252
252
 
253
253
 
254
+ async def test_search_accounts_rejects_unknown_filter(client: ApolloClient):
255
+ """An unrecognised filter raises instead of returning a wrong default page.
256
+
257
+ Regression: Apollo silently drops unknown keys (e.g. ``query=``) and returns
258
+ an unfiltered default list that looks like a real match.
259
+ """
260
+ with pytest.raises(ValueError, match="Unknown account search filter"):
261
+ await client.search_accounts(query="Red and Bundle")
262
+
263
+ # The bad request must never reach Apollo.
264
+ client._client.request.assert_not_called()
265
+
266
+
267
+ async def test_search_accounts_error_names_the_bad_key_and_suggests_fix(client: ApolloClient):
268
+ """The message names the offending key and points to q_organization_name."""
269
+ with pytest.raises(ValueError, match=r"query.*q_organization_name"):
270
+ await client.search_accounts(query="x")
271
+
272
+
273
+ async def test_search_accounts_allows_documented_filters(client: ApolloClient):
274
+ """All allowlisted keys pass validation and reach the request body."""
275
+ client._client.request.return_value = _make_response(
276
+ {"accounts": [], "pagination": {"total_entries": 0}}
277
+ )
278
+
279
+ await client.search_accounts(
280
+ q_organization_name="Acme",
281
+ account_stage_ids=["s1"],
282
+ account_label_ids=["l1"],
283
+ sort_by_field="account_created_at",
284
+ sort_ascending=True,
285
+ )
286
+
287
+ body = client._client.request.call_args[1]["json"]
288
+ assert body["q_organization_name"] == "Acme"
289
+ assert body["account_stage_ids"] == ["s1"]
290
+ assert body["sort_by_field"] == "account_created_at"
291
+
292
+
254
293
  async def test_search_deals(client: ApolloClient):
255
294
  """Test POST /opportunities/search returns PaginatedResponse[Deal]."""
256
295
  client._client.request.return_value = _make_response(
@@ -533,6 +572,43 @@ async def test_get_deal(client: ApolloClient):
533
572
  client._client.request.assert_called_once_with("GET", "/opportunities/d1")
534
573
 
535
574
 
575
+ async def test_create_deal(client: ApolloClient):
576
+ """Test POST /opportunities returns the created Deal with name + extra fields in the body."""
577
+ client._client.request.return_value = _make_response(
578
+ {"opportunity": {"id": "d9", "name": "New Deal", "amount": "1000"}}
579
+ )
580
+
581
+ result = await client.create_deal(
582
+ "New Deal", owner_id="o1", account_id="a1", amount=1000, opportunity_stage_id="st1"
583
+ )
584
+
585
+ assert isinstance(result, Deal)
586
+ assert result.id == "d9"
587
+
588
+ call_args = client._client.request.call_args
589
+ assert call_args[0] == ("POST", "/opportunities")
590
+ assert call_args[1]["json"] == {
591
+ "name": "New Deal",
592
+ "owner_id": "o1",
593
+ "account_id": "a1",
594
+ "amount": 1000,
595
+ "opportunity_stage_id": "st1",
596
+ }
597
+
598
+
599
+ async def test_create_deal_name_only(client: ApolloClient):
600
+ """Only ``name`` is required; the body carries nothing else."""
601
+ client._client.request.return_value = _make_response(
602
+ {"opportunity": {"id": "d10", "name": "Minimal"}}
603
+ )
604
+
605
+ result = await client.create_deal("Minimal")
606
+
607
+ assert isinstance(result, Deal)
608
+ assert result.id == "d10"
609
+ assert client._client.request.call_args[1]["json"] == {"name": "Minimal"}
610
+
611
+
536
612
  async def test_get_pipeline(client: ApolloClient):
537
613
  """Test GET /opportunity_pipelines/{id} returns Pipeline."""
538
614
  client._client.request.return_value = _make_response(
@@ -643,7 +719,11 @@ async def test_list_opportunity_contact_role_types(client: ApolloClient):
643
719
 
644
720
 
645
721
  async def test_update_opportunity_roles(client: ApolloClient):
646
- """Test POST /opportunities/update_roles returns the updated Deal with the roles body."""
722
+ """The flat RoleAssignment entries are reshaped into Apollo's nested ``role`` wire format.
723
+
724
+ Regression: sending ``opportunity_contact_role_type_id`` flat on the entry (no
725
+ ``role`` key) makes Apollo 422 with "undefined method 'map' for nil".
726
+ """
647
727
  client._client.request.return_value = _make_response(
648
728
  {"opportunity": {"id": "d1", "name": "Big Deal"}}
649
729
  )
@@ -659,7 +739,19 @@ async def test_update_opportunity_roles(client: ApolloClient):
659
739
 
660
740
  call_args = client._client.request.call_args
661
741
  assert call_args[0] == ("POST", "/opportunities/update_roles")
662
- assert call_args[1]["json"] == {"opportunity_id": "d1", "roles": roles}
742
+ assert call_args[1]["json"] == {
743
+ "opportunity_id": "d1",
744
+ "roles": [
745
+ {
746
+ "contact_id": "c1",
747
+ "is_primary": True,
748
+ "role": [{"is_primary": True, "opportunity_contact_role_type_id": "rt1"}],
749
+ },
750
+ # No role type → the nested role object carries only is_primary (never a
751
+ # flat/absent role type, which is what triggered the nil.map crash).
752
+ {"contact_id": "c2", "is_primary": False, "role": [{"is_primary": False}]},
753
+ ],
754
+ }
663
755
 
664
756
 
665
757
  async def test_list_custom_fields(client: ApolloClient):