qodev-apollo-api 0.1.3__tar.gz → 0.2.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.1.3 → qodev_apollo_api-0.2.0}/CHANGELOG.md +13 -0
  2. {qodev_apollo_api-0.1.3 → qodev_apollo_api-0.2.0}/PKG-INFO +1 -1
  3. {qodev_apollo_api-0.1.3 → qodev_apollo_api-0.2.0}/pyproject.toml +1 -1
  4. {qodev_apollo_api-0.1.3 → qodev_apollo_api-0.2.0}/src/qodev_apollo_api/__init__.py +1 -1
  5. {qodev_apollo_api-0.1.3 → qodev_apollo_api-0.2.0}/src/qodev_apollo_api/client.py +25 -3
  6. {qodev_apollo_api-0.1.3 → qodev_apollo_api-0.2.0}/src/qodev_apollo_api/utils.py +64 -1
  7. {qodev_apollo_api-0.1.3 → qodev_apollo_api-0.2.0}/tests/test_client.py +21 -6
  8. {qodev_apollo_api-0.1.3 → qodev_apollo_api-0.2.0}/tests/test_utils.py +62 -1
  9. {qodev_apollo_api-0.1.3 → qodev_apollo_api-0.2.0}/uv.lock +1 -1
  10. {qodev_apollo_api-0.1.3 → qodev_apollo_api-0.2.0}/.github/workflows/ci.yml +0 -0
  11. {qodev_apollo_api-0.1.3 → qodev_apollo_api-0.2.0}/.github/workflows/publish.yml +0 -0
  12. {qodev_apollo_api-0.1.3 → qodev_apollo_api-0.2.0}/.gitignore +0 -0
  13. {qodev_apollo_api-0.1.3 → qodev_apollo_api-0.2.0}/.pre-commit-config.yaml +0 -0
  14. {qodev_apollo_api-0.1.3 → qodev_apollo_api-0.2.0}/CLAUDE.md +0 -0
  15. {qodev_apollo_api-0.1.3 → qodev_apollo_api-0.2.0}/LICENSE +0 -0
  16. {qodev_apollo_api-0.1.3 → qodev_apollo_api-0.2.0}/Makefile +0 -0
  17. {qodev_apollo_api-0.1.3 → qodev_apollo_api-0.2.0}/README.md +0 -0
  18. {qodev_apollo_api-0.1.3 → qodev_apollo_api-0.2.0}/src/qodev_apollo_api/exceptions.py +0 -0
  19. {qodev_apollo_api-0.1.3 → qodev_apollo_api-0.2.0}/src/qodev_apollo_api/models.py +0 -0
  20. {qodev_apollo_api-0.1.3 → qodev_apollo_api-0.2.0}/src/qodev_apollo_api/py.typed +0 -0
  21. {qodev_apollo_api-0.1.3 → qodev_apollo_api-0.2.0}/tests/__init__.py +0 -0
  22. {qodev_apollo_api-0.1.3 → qodev_apollo_api-0.2.0}/tests/integration/__init__.py +0 -0
  23. {qodev_apollo_api-0.1.3 → qodev_apollo_api-0.2.0}/tests/integration/validate_all_models.py +0 -0
  24. {qodev_apollo_api-0.1.3 → qodev_apollo_api-0.2.0}/tests/integration/validate_email_task_flow.py +0 -0
  25. {qodev_apollo_api-0.1.3 → qodev_apollo_api-0.2.0}/tests/test_exceptions.py +0 -0
  26. {qodev_apollo_api-0.1.3 → qodev_apollo_api-0.2.0}/tests/test_models.py +0 -0
@@ -7,6 +7,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.2.0] - 2026-06-02
11
+
12
+ ### Fixed
13
+ - `create_note()` posted `{"note": <plaintext>}`, which Apollo silently ignores — notes were created with empty content. It now serialises `content` to ProseMirror JSON and posts it in the `content` field (the format Apollo stores and `search_notes()` reads back). Closes #6.
14
+
15
+ ### Added
16
+ - `markdown_to_prosemirror()` in `utils` — inverse of `prosemirror_to_markdown()` (title, paragraphs, bullet/ordered lists).
17
+ - `create_note(..., title=...)` — optional note title (rendered as the ProseMirror `noteTitle`).
18
+ - `ApolloClient.delete_note(note_id)` — `DELETE /notes/{id}`.
19
+
20
+ ### Changed
21
+ - `create_note()` association args (`contact_ids`, `account_ids`, `opportunity_ids`) are now keyword-only, so the new positional `title` can't be confused with them.
22
+
10
23
  ## [0.1.3] - 2026-02-23
11
24
 
12
25
  ### Added
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: qodev-apollo-api
3
- Version: 0.1.3
3
+ Version: 0.2.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.1.3"
3
+ version = "0.2.0"
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.1.3"
154
+ __version__ = "0.2.0"
@@ -38,7 +38,7 @@ from .models import (
38
38
  TaskType,
39
39
  resolve_task,
40
40
  )
41
- from .utils import normalize_linkedin_url, prosemirror_to_markdown
41
+ from .utils import markdown_to_prosemirror, normalize_linkedin_url, prosemirror_to_markdown
42
42
 
43
43
 
44
44
  class ApolloClient:
@@ -597,14 +597,23 @@ class ApolloClient:
597
597
  async def create_note(
598
598
  self,
599
599
  content: str,
600
+ title: str | None = None,
601
+ *,
600
602
  contact_ids: list[str] | None = None,
601
603
  account_ids: list[str] | None = None,
602
604
  opportunity_ids: list[str] | None = None,
603
605
  ) -> dict:
604
606
  """Create a note.
605
607
 
608
+ Apollo stores note bodies in the ``content`` field as ProseMirror JSON,
609
+ so the plain text / Markdown ``content`` is converted via
610
+ :func:`~qodev_apollo_api.utils.markdown_to_prosemirror` before posting.
611
+ (Posting ``{"note": <plaintext>}`` is silently ignored by Apollo and
612
+ produces an empty note.)
613
+
606
614
  Args:
607
- content: Note content (plain text or Markdown)
615
+ content: Note body as plain text / Markdown.
616
+ title: Optional note title (rendered as the ProseMirror noteTitle).
608
617
  contact_ids: List of contact IDs to associate
609
618
  account_ids: List of account IDs to associate
610
619
  opportunity_ids: List of opportunity IDs to associate
@@ -612,7 +621,9 @@ class ApolloClient:
612
621
  Returns:
613
622
  Raw API response with created note data
614
623
  """
615
- data: dict[str, str | list[str]] = {"note": content}
624
+ data: dict[str, str | list[str]] = {
625
+ "content": markdown_to_prosemirror(content, title=title)
626
+ }
616
627
  if contact_ids:
617
628
  data["contact_ids"] = contact_ids
618
629
  if account_ids:
@@ -622,6 +633,17 @@ class ApolloClient:
622
633
 
623
634
  return await self._post("/notes", data)
624
635
 
636
+ async def delete_note(self, note_id: str) -> dict:
637
+ """Delete a note by ID.
638
+
639
+ Args:
640
+ note_id: Apollo note ID.
641
+
642
+ Returns:
643
+ Raw API response.
644
+ """
645
+ return await self._request("DELETE", f"/notes/{note_id}")
646
+
625
647
  # ========================================================================
626
648
  # ACTIVITIES
627
649
  # ========================================================================
@@ -1,9 +1,12 @@
1
1
  """Utility functions for Apollo client.
2
2
 
3
- Includes ProseMirror JSON to Markdown conversion for notes.
3
+ Includes ProseMirror JSON <-> Markdown conversion for notes.
4
4
  """
5
5
 
6
6
  import json
7
+ import re
8
+
9
+ _ORDERED_ITEM_RE = re.compile(r"^\d+\.\s+(.*)$")
7
10
 
8
11
 
9
12
  def prosemirror_to_markdown(content_json: str) -> tuple[str, str]:
@@ -103,6 +106,66 @@ def _extract_text_from_list_item(item: dict) -> str:
103
106
  return " ".join(text_parts)
104
107
 
105
108
 
109
+ def markdown_to_prosemirror(content: str, title: str | None = None) -> str:
110
+ """Convert plain text / lightweight Markdown to a ProseMirror JSON string.
111
+
112
+ Apollo's ``POST /notes`` stores the note body in the ``content`` field as a
113
+ ProseMirror JSON string; this is the inverse of :func:`prosemirror_to_markdown`
114
+ for the node types Apollo notes use.
115
+
116
+ Supports:
117
+ * an optional note title (``noteTitle`` node)
118
+ * paragraphs (one per line; blank lines become empty paragraphs)
119
+ * bullet lists (lines starting with ``- `` or ``* ``)
120
+ * ordered lists (lines starting with ``1. ``)
121
+
122
+ Args:
123
+ content: Note body as plain text / Markdown.
124
+ title: Optional note title (rendered as the ProseMirror ``noteTitle``).
125
+
126
+ Returns:
127
+ ProseMirror document as a JSON string, ready to post to ``content``.
128
+ """
129
+ nodes: list[dict] = []
130
+ if title:
131
+ nodes.append({"type": "noteTitle", "content": [{"type": "text", "text": title}]})
132
+
133
+ def _paragraph(text: str) -> dict:
134
+ # Empty text nodes are invalid in ProseMirror — emit a bare paragraph.
135
+ if not text:
136
+ return {"type": "paragraph"}
137
+ return {"type": "paragraph", "content": [{"type": "text", "text": text}]}
138
+
139
+ def _list_item(text: str) -> dict:
140
+ return {"type": "listItem", "content": [_paragraph(text)]}
141
+
142
+ lines = (content or "").split("\n")
143
+ i = 0
144
+ while i < len(lines):
145
+ stripped = lines[i].strip()
146
+
147
+ if stripped[:2] in ("- ", "* "):
148
+ items = []
149
+ while i < len(lines) and lines[i].strip()[:2] in ("- ", "* "):
150
+ items.append(_list_item(lines[i].strip()[2:].strip()))
151
+ i += 1
152
+ nodes.append({"type": "bulletList", "content": items})
153
+ continue
154
+
155
+ if _ORDERED_ITEM_RE.match(stripped):
156
+ items = []
157
+ while i < len(lines) and (m := _ORDERED_ITEM_RE.match(lines[i].strip())):
158
+ items.append(_list_item(m.group(1).strip()))
159
+ i += 1
160
+ nodes.append({"type": "orderedList", "content": items})
161
+ continue
162
+
163
+ nodes.append(_paragraph(lines[i]))
164
+ i += 1
165
+
166
+ return json.dumps({"type": "doc", "content": nodes}, ensure_ascii=False)
167
+
168
+
106
169
  def normalize_linkedin_url(url: str) -> str:
107
170
  """Normalize LinkedIn URL for comparison.
108
171
 
@@ -1,5 +1,6 @@
1
1
  """Tests for Apollo client methods."""
2
2
 
3
+ import json
3
4
  import os
4
5
  from datetime import UTC, datetime
5
6
  from unittest.mock import AsyncMock, MagicMock, patch
@@ -1109,18 +1110,23 @@ async def test_search_people(client: ApolloClient):
1109
1110
 
1110
1111
 
1111
1112
  async def test_create_note(client: ApolloClient):
1112
- """Test POST /notes."""
1113
- client._client.request.return_value = _make_response({"note": {"id": "n1", "content": "Hello"}})
1113
+ """create_note posts ProseMirror JSON in `content` (not plaintext in `note`)."""
1114
+ client._client.request.return_value = _make_response({"note": {"id": "n1"}})
1114
1115
 
1115
- result = await client.create_note("Hello")
1116
+ result = await client.create_note("Hello", title="Greeting")
1116
1117
 
1117
- assert result == {"note": {"id": "n1", "content": "Hello"}}
1118
+ assert result == {"note": {"id": "n1"}}
1118
1119
 
1119
1120
  call_args = client._client.request.call_args
1120
1121
  assert call_args[0] == ("POST", "/notes")
1121
1122
  payload = call_args[1]["json"]
1122
- assert payload["note"] == "Hello"
1123
+ assert "note" not in payload # old (ignored) field must be gone
1123
1124
  assert "contact_ids" not in payload
1125
+ doc = json.loads(payload["content"])
1126
+ assert doc["type"] == "doc"
1127
+ assert doc["content"][0]["type"] == "noteTitle"
1128
+ assert doc["content"][0]["content"][0]["text"] == "Greeting"
1129
+ assert doc["content"][1]["content"][0]["text"] == "Hello"
1124
1130
 
1125
1131
 
1126
1132
  async def test_create_note_with_associations(client: ApolloClient):
@@ -1135,12 +1141,21 @@ async def test_create_note_with_associations(client: ApolloClient):
1135
1141
  )
1136
1142
 
1137
1143
  payload = client._client.request.call_args[1]["json"]
1138
- assert payload["note"] == "Meeting notes"
1144
+ assert "Meeting notes" in payload["content"] # body serialised into ProseMirror
1139
1145
  assert payload["contact_ids"] == ["c1", "c2"]
1140
1146
  assert payload["account_ids"] == ["a1"]
1141
1147
  assert payload["opportunity_ids"] == ["o1"]
1142
1148
 
1143
1149
 
1150
+ async def test_delete_note(client: ApolloClient):
1151
+ """delete_note issues DELETE /notes/{id}."""
1152
+ client._client.request.return_value = _make_response({})
1153
+
1154
+ await client.delete_note("n1")
1155
+
1156
+ client._client.request.assert_called_once_with("DELETE", "/notes/n1")
1157
+
1158
+
1144
1159
  async def test_get_api_usage(client: ApolloClient):
1145
1160
  """Test POST /usage_stats/api_usage_stats."""
1146
1161
  usage_data = {
@@ -2,7 +2,11 @@
2
2
 
3
3
  import json
4
4
 
5
- from qodev_apollo_api.utils import normalize_linkedin_url, prosemirror_to_markdown
5
+ from qodev_apollo_api.utils import (
6
+ markdown_to_prosemirror,
7
+ normalize_linkedin_url,
8
+ prosemirror_to_markdown,
9
+ )
6
10
 
7
11
 
8
12
  def test_prosemirror_to_markdown_simple():
@@ -111,3 +115,60 @@ def test_normalize_linkedin_url_empty():
111
115
  """Test normalization of empty URL."""
112
116
  assert normalize_linkedin_url("") == ""
113
117
  assert normalize_linkedin_url(None) == ""
118
+
119
+
120
+ def test_markdown_to_prosemirror_simple():
121
+ """Title + body become a noteTitle node and a paragraph node."""
122
+ doc = json.loads(markdown_to_prosemirror("Hello world", title="My Title"))
123
+ assert doc["type"] == "doc"
124
+ assert doc["content"][0] == {
125
+ "type": "noteTitle",
126
+ "content": [{"type": "text", "text": "My Title"}],
127
+ }
128
+ assert doc["content"][1] == {
129
+ "type": "paragraph",
130
+ "content": [{"type": "text", "text": "Hello world"}],
131
+ }
132
+
133
+
134
+ def test_markdown_to_prosemirror_blank_line_is_empty_paragraph():
135
+ """Blank lines become bare paragraphs (empty text nodes are invalid)."""
136
+ doc = json.loads(markdown_to_prosemirror("a\n\nb"))
137
+ assert doc["content"] == [
138
+ {"type": "paragraph", "content": [{"type": "text", "text": "a"}]},
139
+ {"type": "paragraph"},
140
+ {"type": "paragraph", "content": [{"type": "text", "text": "b"}]},
141
+ ]
142
+
143
+
144
+ def test_markdown_to_prosemirror_no_title():
145
+ """Without a title there is no noteTitle node."""
146
+ doc = json.loads(markdown_to_prosemirror("body only"))
147
+ assert all(n["type"] != "noteTitle" for n in doc["content"])
148
+
149
+
150
+ def test_markdown_to_prosemirror_lists():
151
+ """Bullet and ordered list lines become bulletList / orderedList nodes."""
152
+ doc = json.loads(markdown_to_prosemirror("- one\n- two\n1. first\n2. second"))
153
+ types = [n["type"] for n in doc["content"]]
154
+ assert types == ["bulletList", "orderedList"]
155
+ assert len(doc["content"][0]["content"]) == 2 # two bullet items
156
+ assert doc["content"][0]["content"][0]["content"][0]["content"][0]["text"] == "one"
157
+ assert doc["content"][1]["content"][1]["content"][0]["content"][0]["text"] == "second"
158
+
159
+
160
+ def test_markdown_prosemirror_roundtrip():
161
+ """markdown_to_prosemirror output is readable back by prosemirror_to_markdown."""
162
+ pm = markdown_to_prosemirror("First para\n\nSecond para", title="Round Trip")
163
+ title, markdown = prosemirror_to_markdown(pm)
164
+ assert title == "Round Trip"
165
+ assert markdown == "First para\n\nSecond para"
166
+
167
+
168
+ def test_markdown_to_prosemirror_empty():
169
+ """Empty content yields a valid empty doc."""
170
+ doc = json.loads(markdown_to_prosemirror(""))
171
+ assert doc["type"] == "doc"
172
+ # No invalid empty text nodes anywhere.
173
+ for node in doc["content"]:
174
+ assert node.get("content") != [{"type": "text", "text": ""}]
@@ -407,7 +407,7 @@ wheels = [
407
407
 
408
408
  [[package]]
409
409
  name = "qodev-apollo-api"
410
- version = "0.1.1"
410
+ version = "0.1.3"
411
411
  source = { editable = "." }
412
412
  dependencies = [
413
413
  { name = "httpx" },