devrel-origin 0.2.14__py3-none-any.whl

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 (98) hide show
  1. devrel_origin/__init__.py +15 -0
  2. devrel_origin/cli/__init__.py +92 -0
  3. devrel_origin/cli/_common.py +243 -0
  4. devrel_origin/cli/analytics.py +28 -0
  5. devrel_origin/cli/argus.py +497 -0
  6. devrel_origin/cli/auth.py +227 -0
  7. devrel_origin/cli/config.py +108 -0
  8. devrel_origin/cli/content.py +259 -0
  9. devrel_origin/cli/cost.py +108 -0
  10. devrel_origin/cli/cro.py +298 -0
  11. devrel_origin/cli/deliverables.py +65 -0
  12. devrel_origin/cli/docs.py +91 -0
  13. devrel_origin/cli/doctor.py +178 -0
  14. devrel_origin/cli/experiment.py +29 -0
  15. devrel_origin/cli/growth.py +97 -0
  16. devrel_origin/cli/init.py +472 -0
  17. devrel_origin/cli/intel.py +27 -0
  18. devrel_origin/cli/kb.py +96 -0
  19. devrel_origin/cli/listen.py +31 -0
  20. devrel_origin/cli/marketing.py +66 -0
  21. devrel_origin/cli/migrate.py +45 -0
  22. devrel_origin/cli/run.py +46 -0
  23. devrel_origin/cli/sales.py +57 -0
  24. devrel_origin/cli/schedule.py +62 -0
  25. devrel_origin/cli/synthesize.py +28 -0
  26. devrel_origin/cli/triage.py +29 -0
  27. devrel_origin/cli/video.py +35 -0
  28. devrel_origin/core/__init__.py +58 -0
  29. devrel_origin/core/agent_config.py +75 -0
  30. devrel_origin/core/argus.py +964 -0
  31. devrel_origin/core/atlas.py +1450 -0
  32. devrel_origin/core/base.py +372 -0
  33. devrel_origin/core/cyra.py +563 -0
  34. devrel_origin/core/dex.py +708 -0
  35. devrel_origin/core/echo.py +614 -0
  36. devrel_origin/core/growth/__init__.py +27 -0
  37. devrel_origin/core/growth/recommendations.py +219 -0
  38. devrel_origin/core/growth/target_kinds.py +51 -0
  39. devrel_origin/core/iris.py +513 -0
  40. devrel_origin/core/kai.py +1367 -0
  41. devrel_origin/core/llm.py +542 -0
  42. devrel_origin/core/llm_backends.py +274 -0
  43. devrel_origin/core/mox.py +514 -0
  44. devrel_origin/core/nova.py +349 -0
  45. devrel_origin/core/pax.py +1205 -0
  46. devrel_origin/core/rex.py +532 -0
  47. devrel_origin/core/sage.py +486 -0
  48. devrel_origin/core/sentinel.py +385 -0
  49. devrel_origin/core/types.py +98 -0
  50. devrel_origin/core/video/__init__.py +22 -0
  51. devrel_origin/core/video/assembler.py +131 -0
  52. devrel_origin/core/video/browser_recorder.py +118 -0
  53. devrel_origin/core/video/desktop_recorder.py +254 -0
  54. devrel_origin/core/video/overlay_renderer.py +143 -0
  55. devrel_origin/core/video/script_parser.py +147 -0
  56. devrel_origin/core/video/tts_engine.py +82 -0
  57. devrel_origin/core/vox.py +268 -0
  58. devrel_origin/core/watchdog.py +321 -0
  59. devrel_origin/project/__init__.py +1 -0
  60. devrel_origin/project/config.py +75 -0
  61. devrel_origin/project/cost_sink.py +61 -0
  62. devrel_origin/project/init.py +104 -0
  63. devrel_origin/project/paths.py +75 -0
  64. devrel_origin/project/state.py +241 -0
  65. devrel_origin/project/templates/__init__.py +4 -0
  66. devrel_origin/project/templates/config.toml +24 -0
  67. devrel_origin/project/templates/devrel.gitignore +10 -0
  68. devrel_origin/project/templates/slop-blocklist.md +45 -0
  69. devrel_origin/project/templates/style.md +24 -0
  70. devrel_origin/project/templates/voice.md +29 -0
  71. devrel_origin/quality/__init__.py +66 -0
  72. devrel_origin/quality/editorial.py +357 -0
  73. devrel_origin/quality/persona.py +84 -0
  74. devrel_origin/quality/readability.py +148 -0
  75. devrel_origin/quality/slop.py +167 -0
  76. devrel_origin/quality/style.py +110 -0
  77. devrel_origin/quality/voice.py +15 -0
  78. devrel_origin/tools/__init__.py +9 -0
  79. devrel_origin/tools/analytics.py +304 -0
  80. devrel_origin/tools/api_client.py +393 -0
  81. devrel_origin/tools/apollo_client.py +305 -0
  82. devrel_origin/tools/code_validator.py +428 -0
  83. devrel_origin/tools/github_tools.py +297 -0
  84. devrel_origin/tools/instantly_client.py +412 -0
  85. devrel_origin/tools/kb_harvester.py +340 -0
  86. devrel_origin/tools/mcp_server.py +578 -0
  87. devrel_origin/tools/notifications.py +245 -0
  88. devrel_origin/tools/run_report.py +193 -0
  89. devrel_origin/tools/scheduler.py +231 -0
  90. devrel_origin/tools/search_tools.py +321 -0
  91. devrel_origin/tools/self_improve.py +168 -0
  92. devrel_origin/tools/sheets.py +236 -0
  93. devrel_origin-0.2.14.dist-info/METADATA +354 -0
  94. devrel_origin-0.2.14.dist-info/RECORD +98 -0
  95. devrel_origin-0.2.14.dist-info/WHEEL +5 -0
  96. devrel_origin-0.2.14.dist-info/entry_points.txt +2 -0
  97. devrel_origin-0.2.14.dist-info/licenses/LICENSE +21 -0
  98. devrel_origin-0.2.14.dist-info/top_level.txt +1 -0
@@ -0,0 +1,393 @@
1
+ """
2
+ PostHog API v2 async client (legacy — retained for interface compatibility).
3
+
4
+ Originally a typed, retryable wrapper around PostHog's REST API.
5
+ OpenClaw does not have an equivalent external API, so this module is kept
6
+ as a structural dependency for agent imports but is not used for live API calls.
7
+ The PostHogClient class and its DTOs remain functional for testing and
8
+ reference purposes.
9
+ """
10
+
11
+ import logging
12
+ from dataclasses import dataclass, field
13
+ from typing import Any, Optional
14
+
15
+ import httpx
16
+
17
+ logger = logging.getLogger(__name__)
18
+
19
+ # ---------------------------------------------------------------------------
20
+ # Configuration
21
+ # ---------------------------------------------------------------------------
22
+
23
+ DEFAULT_HOST = "https://app.posthog.com"
24
+ API_TIMEOUT = 30.0
25
+ MAX_RETRIES = 2
26
+
27
+
28
+ # ---------------------------------------------------------------------------
29
+ # Data transfer objects
30
+ # ---------------------------------------------------------------------------
31
+
32
+
33
+ @dataclass
34
+ class InsightQuery:
35
+ """Parameters for a PostHog insight query."""
36
+
37
+ insight: str = "TRENDS" # TRENDS, FUNNELS, RETENTION, PATHS, LIFECYCLE
38
+ events: list[dict[str, Any]] = field(default_factory=list)
39
+ properties: list[dict[str, Any]] = field(default_factory=list)
40
+ date_from: str = "-7d"
41
+ date_to: Optional[str] = None
42
+ interval: str = "day"
43
+ breakdown: Optional[str] = None
44
+ breakdown_type: Optional[str] = None
45
+ filter_test_accounts: bool = True
46
+
47
+ def to_dict(self) -> dict[str, Any]:
48
+ d: dict[str, Any] = {
49
+ "insight": self.insight,
50
+ "events": self.events,
51
+ "properties": self.properties,
52
+ "date_from": self.date_from,
53
+ "interval": self.interval,
54
+ "filter_test_accounts": self.filter_test_accounts,
55
+ }
56
+ if self.date_to:
57
+ d["date_to"] = self.date_to
58
+ if self.breakdown:
59
+ d["breakdown"] = self.breakdown
60
+ d["breakdown_type"] = self.breakdown_type or "event"
61
+ return d
62
+
63
+
64
+ @dataclass
65
+ class FeatureFlag:
66
+ """PostHog feature flag representation."""
67
+
68
+ key: str
69
+ name: str = ""
70
+ active: bool = True
71
+ rollout_percentage: Optional[int] = None
72
+ filters: dict[str, Any] = field(default_factory=dict)
73
+ ensure_experience_continuity: bool = False
74
+
75
+
76
+ @dataclass
77
+ class Experiment:
78
+ """PostHog experiment representation."""
79
+
80
+ name: str
81
+ feature_flag_key: str
82
+ description: str = ""
83
+ start_date: Optional[str] = None
84
+ end_date: Optional[str] = None
85
+ parameters: dict[str, Any] = field(default_factory=dict)
86
+
87
+
88
+ # ---------------------------------------------------------------------------
89
+ # Client
90
+ # ---------------------------------------------------------------------------
91
+
92
+
93
+ class PostHogClient:
94
+ """
95
+ Typed async client for the PostHog REST API v2.
96
+
97
+ Usage::
98
+
99
+ client = PostHogClient(api_key="phx_...", project_id="12345")
100
+ trends = await client.query_insights(
101
+ InsightQuery(events=[{"id": "$pageview"}])
102
+ )
103
+ """
104
+
105
+ def __init__(
106
+ self,
107
+ api_key: str,
108
+ project_id: str = "",
109
+ host: str = DEFAULT_HOST,
110
+ ):
111
+ self.api_key = api_key
112
+ self.project_id = project_id
113
+ self.host = host.rstrip("/")
114
+ self._client = httpx.AsyncClient(
115
+ base_url=self.host,
116
+ headers={
117
+ "Authorization": f"Bearer {api_key}",
118
+ "Content-Type": "application/json",
119
+ },
120
+ timeout=API_TIMEOUT,
121
+ )
122
+
123
+ async def close(self) -> None:
124
+ await self._client.aclose()
125
+
126
+ # -- helpers ----------------------------------------------------------
127
+
128
+ def _url(self, path: str) -> str:
129
+ """Build a project-scoped API URL."""
130
+ if self.project_id:
131
+ return f"/api/projects/{self.project_id}{path}"
132
+ return f"/api{path}"
133
+
134
+ async def _request(
135
+ self,
136
+ method: str,
137
+ path: str,
138
+ *,
139
+ json: Optional[dict[str, Any]] = None,
140
+ params: Optional[dict[str, Any]] = None,
141
+ ) -> dict[str, Any]:
142
+ """Execute an HTTP request with retry logic."""
143
+ url = self._url(path)
144
+ last_error: Optional[Exception] = None
145
+
146
+ for attempt in range(1, MAX_RETRIES + 2):
147
+ try:
148
+ resp = await self._client.request(method, url, json=json, params=params)
149
+ resp.raise_for_status()
150
+ return resp.json()
151
+ except (httpx.HTTPStatusError, httpx.RequestError) as exc:
152
+ last_error = exc
153
+ logger.warning(f"PostHog API {method} {url} failed (attempt {attempt}): {exc}")
154
+ if attempt <= MAX_RETRIES:
155
+ import asyncio
156
+
157
+ await asyncio.sleep(1.0 * attempt)
158
+
159
+ raise last_error # type: ignore[misc]
160
+
161
+ # -- Event Capture ----------------------------------------------------
162
+
163
+ async def capture(
164
+ self,
165
+ distinct_id: str,
166
+ event: str,
167
+ properties: Optional[dict[str, Any]] = None,
168
+ ) -> dict[str, Any]:
169
+ """Capture a single event."""
170
+ return await self._request(
171
+ "POST",
172
+ "/capture/",
173
+ json={
174
+ "api_key": self.api_key,
175
+ "distinct_id": distinct_id,
176
+ "event": event,
177
+ "properties": properties or {},
178
+ },
179
+ )
180
+
181
+ async def capture_batch(self, events: list[dict[str, Any]]) -> dict[str, Any]:
182
+ """Capture a batch of events."""
183
+ return await self._request(
184
+ "POST",
185
+ "/capture/",
186
+ json={"api_key": self.api_key, "batch": events},
187
+ )
188
+
189
+ # -- Insights / Queries -----------------------------------------------
190
+
191
+ async def query_insights(self, query: InsightQuery) -> dict[str, Any]:
192
+ """Run an insight query (trends, funnels, retention, etc.)."""
193
+ return await self._request("POST", "/insights/", json=query.to_dict())
194
+
195
+ async def get_insight(self, insight_id: int) -> dict[str, Any]:
196
+ """Fetch a saved insight by ID."""
197
+ return await self._request("GET", f"/insights/{insight_id}/")
198
+
199
+ async def list_insights(self, limit: int = 100, offset: int = 0) -> dict[str, Any]:
200
+ """List saved insights with pagination."""
201
+ return await self._request(
202
+ "GET",
203
+ "/insights/",
204
+ params={"limit": limit, "offset": offset},
205
+ )
206
+
207
+ # -- Feature Flags ----------------------------------------------------
208
+
209
+ async def create_feature_flag(self, flag: FeatureFlag) -> dict[str, Any]:
210
+ """Create a new feature flag."""
211
+ payload: dict[str, Any] = {
212
+ "key": flag.key,
213
+ "name": flag.name,
214
+ "active": flag.active,
215
+ "filters": flag.filters,
216
+ "ensure_experience_continuity": flag.ensure_experience_continuity,
217
+ }
218
+ if flag.rollout_percentage is not None:
219
+ payload["rollout_percentage"] = flag.rollout_percentage
220
+ return await self._request("POST", "/feature_flags/", json=payload)
221
+
222
+ async def get_feature_flag(self, flag_id: int) -> dict[str, Any]:
223
+ """Fetch a feature flag by ID."""
224
+ return await self._request("GET", f"/feature_flags/{flag_id}/")
225
+
226
+ async def list_feature_flags(self, limit: int = 100) -> dict[str, Any]:
227
+ """List all feature flags."""
228
+ return await self._request("GET", "/feature_flags/", params={"limit": limit})
229
+
230
+ async def update_feature_flag(self, flag_id: int, updates: dict[str, Any]) -> dict[str, Any]:
231
+ """Patch a feature flag."""
232
+ return await self._request("PATCH", f"/feature_flags/{flag_id}/", json=updates)
233
+
234
+ async def delete_feature_flag(self, flag_id: int) -> dict[str, Any]:
235
+ """Delete a feature flag."""
236
+ return await self._request("DELETE", f"/feature_flags/{flag_id}/")
237
+
238
+ # -- Experiments ------------------------------------------------------
239
+
240
+ async def create_experiment(self, experiment: Experiment) -> dict[str, Any]:
241
+ """Create a new experiment."""
242
+ return await self._request(
243
+ "POST",
244
+ "/experiments/",
245
+ json={
246
+ "name": experiment.name,
247
+ "feature_flag_key": experiment.feature_flag_key,
248
+ "description": experiment.description,
249
+ "start_date": experiment.start_date,
250
+ "end_date": experiment.end_date,
251
+ "parameters": experiment.parameters,
252
+ },
253
+ )
254
+
255
+ async def get_experiment(self, experiment_id: int) -> dict[str, Any]:
256
+ """Fetch an experiment by ID."""
257
+ return await self._request("GET", f"/experiments/{experiment_id}/")
258
+
259
+ async def list_experiments(self, limit: int = 100) -> dict[str, Any]:
260
+ """List all experiments."""
261
+ return await self._request("GET", "/experiments/", params={"limit": limit})
262
+
263
+ async def get_experiment_results(self, experiment_id: int) -> dict[str, Any]:
264
+ """Fetch experiment results with statistical analysis."""
265
+ return await self._request("GET", f"/experiments/{experiment_id}/results/")
266
+
267
+ # -- Cohorts ----------------------------------------------------------
268
+
269
+ async def create_cohort(
270
+ self,
271
+ name: str,
272
+ groups: list[dict[str, Any]],
273
+ is_static: bool = False,
274
+ ) -> dict[str, Any]:
275
+ """Create a new cohort."""
276
+ return await self._request(
277
+ "POST",
278
+ "/cohorts/",
279
+ json={
280
+ "name": name,
281
+ "groups": groups,
282
+ "is_static": is_static,
283
+ },
284
+ )
285
+
286
+ async def get_cohort(self, cohort_id: int) -> dict[str, Any]:
287
+ """Fetch a cohort by ID."""
288
+ return await self._request("GET", f"/cohorts/{cohort_id}/")
289
+
290
+ async def list_cohorts(self, limit: int = 100) -> dict[str, Any]:
291
+ """List all cohorts."""
292
+ return await self._request("GET", "/cohorts/", params={"limit": limit})
293
+
294
+ # -- Annotations ------------------------------------------------------
295
+
296
+ async def create_annotation(
297
+ self,
298
+ content: str,
299
+ date_marker: str,
300
+ scope: str = "organization",
301
+ ) -> dict[str, Any]:
302
+ """Create a date annotation (e.g., deploy marker)."""
303
+ return await self._request(
304
+ "POST",
305
+ "/annotations/",
306
+ json={
307
+ "content": content,
308
+ "date_marker": date_marker,
309
+ "scope": scope,
310
+ },
311
+ )
312
+
313
+ async def list_annotations(self, limit: int = 100) -> dict[str, Any]:
314
+ """List all annotations."""
315
+ return await self._request("GET", "/annotations/", params={"limit": limit})
316
+
317
+ # -- Persons ----------------------------------------------------------
318
+
319
+ async def get_person(self, distinct_id: str) -> dict[str, Any]:
320
+ """Look up a person by distinct_id."""
321
+ result = await self._request(
322
+ "GET",
323
+ "/persons/",
324
+ params={"distinct_id": distinct_id},
325
+ )
326
+ persons = result.get("results", [])
327
+ if not persons:
328
+ raise ValueError(f"No person found for distinct_id={distinct_id}")
329
+ return persons[0]
330
+
331
+ async def list_persons(self, limit: int = 100, offset: int = 0) -> dict[str, Any]:
332
+ """List persons with pagination."""
333
+ return await self._request(
334
+ "GET",
335
+ "/persons/",
336
+ params={"limit": limit, "offset": offset},
337
+ )
338
+
339
+ # -- Actions ----------------------------------------------------------
340
+
341
+ async def list_actions(self, limit: int = 100) -> dict[str, Any]:
342
+ """List defined actions."""
343
+ return await self._request("GET", "/actions/", params={"limit": limit})
344
+
345
+ # -- HogQL / Query API -----------------------------------------------
346
+
347
+ async def event_volumes(self, days: int = 7, limit: int = 50) -> list[tuple[str, int]]:
348
+ """Return [(event_name, count), ...] for the top events in the period.
349
+
350
+ Used by Cyra to auto-detect a funnel candidate (highest-volume
351
+ $pageview to custom_event chain).
352
+ """
353
+ query = {
354
+ "kind": "EventsQuery",
355
+ "select": ["event", "count()"],
356
+ "after": f"-{days}d",
357
+ "orderBy": ["-count()"],
358
+ "limit": limit,
359
+ }
360
+ data = await self._request("POST", "/query/", json={"query": query})
361
+ results = data.get("results", [])
362
+ return [(row[0], int(row[1])) for row in results]
363
+
364
+ async def funnel_query(
365
+ self,
366
+ events: list[str],
367
+ days: int = 7,
368
+ ) -> list[dict]:
369
+ """Run a funnel query for the given event sequence.
370
+
371
+ Returns one dict per step with keys: name, count, average_conversion_time.
372
+ """
373
+ query = {
374
+ "kind": "FunnelsQuery",
375
+ "series": [{"event": e, "kind": "EventsNode"} for e in events],
376
+ "dateRange": {"date_from": f"-{days}d"},
377
+ }
378
+ data = await self._request("POST", "/query/", json={"query": query})
379
+ return data.get("results", [])
380
+
381
+ # -- Session Recordings -----------------------------------------------
382
+
383
+ async def list_session_recordings(
384
+ self,
385
+ limit: int = 50,
386
+ offset: int = 0,
387
+ date_from: Optional[str] = None,
388
+ ) -> dict[str, Any]:
389
+ """List session recordings."""
390
+ params: dict[str, Any] = {"limit": limit, "offset": offset}
391
+ if date_from:
392
+ params["date_from"] = date_from
393
+ return await self._request("GET", "/session_recordings/", params=params)
@@ -0,0 +1,305 @@
1
+ # tools/apollo_client.py
2
+ """
3
+ Apollo.io API async client.
4
+
5
+ Provides typed async access to Apollo's REST API for:
6
+ - People search (find contacts by title, company, industry)
7
+ - Organization search (find companies by criteria)
8
+ - Person enrichment (enrich by email or LinkedIn URL)
9
+ - Organization enrichment (enrich by domain)
10
+
11
+ Authentication: x-api-key header.
12
+ Rate limits: ~50 RPM standard plan; handled via tenacity retry on 429.
13
+ """
14
+
15
+ import logging
16
+ from dataclasses import dataclass, field
17
+ from typing import TYPE_CHECKING, Any
18
+
19
+ import httpx
20
+ from tenacity import (
21
+ retry,
22
+ retry_if_exception_type,
23
+ stop_after_attempt,
24
+ wait_exponential,
25
+ )
26
+
27
+ if TYPE_CHECKING:
28
+ from devrel_origin.tools.instantly_client import InstantlyLead
29
+
30
+ logger = logging.getLogger(__name__)
31
+
32
+
33
+ # ---------------------------------------------------------------------------
34
+ # Errors
35
+ # ---------------------------------------------------------------------------
36
+
37
+
38
+ class ApolloAPIError(Exception):
39
+ """Non-retryable error from the Apollo API (4xx except 429)."""
40
+
41
+ def __init__(self, status_code: int, detail: str):
42
+ self.status_code = status_code
43
+ self.detail = detail
44
+ super().__init__(f"Apollo API error {status_code}: {detail}")
45
+
46
+
47
+ # ---------------------------------------------------------------------------
48
+ # Data Transfer Objects
49
+ # ---------------------------------------------------------------------------
50
+
51
+
52
+ @dataclass
53
+ class ApolloContact:
54
+ """A contact/person from Apollo."""
55
+
56
+ id: str
57
+ first_name: str
58
+ last_name: str
59
+ email: str | None = None
60
+ title: str | None = None
61
+ company_name: str | None = None
62
+ company_domain: str | None = None
63
+ linkedin_url: str | None = None
64
+ phone: str | None = None
65
+
66
+ def to_instantly_lead(self) -> "InstantlyLead":
67
+ """Convert to InstantlyLead for Instantly upload.
68
+
69
+ Field mapping:
70
+ - email -> email (empty string if None)
71
+ - first_name -> first_name
72
+ - last_name -> last_name
73
+ - company_name -> company_name
74
+ - phone, linkedin_url, title -> custom_variables (only if set)
75
+ """
76
+ from devrel_origin.tools.instantly_client import InstantlyLead
77
+
78
+ return InstantlyLead(
79
+ email=self.email or "",
80
+ first_name=self.first_name,
81
+ last_name=self.last_name,
82
+ company_name=self.company_name or "",
83
+ custom_variables={
84
+ k: v
85
+ for k, v in {
86
+ "phone": self.phone,
87
+ "linkedin_url": self.linkedin_url,
88
+ "title": self.title,
89
+ }.items()
90
+ if v
91
+ },
92
+ )
93
+
94
+
95
+ @dataclass
96
+ class ApolloOrganization:
97
+ """An organization from Apollo."""
98
+
99
+ id: str
100
+ name: str
101
+ domain: str | None = None
102
+ industry: str | None = None
103
+ estimated_headcount: int | None = None
104
+ tech_stack: list[str] = field(default_factory=list)
105
+ funding_stage: str | None = None
106
+ funding_total: float | None = None
107
+ description: str | None = None
108
+ linkedin_url: str | None = None
109
+
110
+
111
+ @dataclass
112
+ class PeopleSearchResult:
113
+ """Result from people search endpoint."""
114
+
115
+ contacts: list[ApolloContact]
116
+ total: int
117
+ page: int
118
+ per_page: int
119
+
120
+
121
+ @dataclass
122
+ class OrgSearchResult:
123
+ """Result from organization search endpoint."""
124
+
125
+ organizations: list[ApolloOrganization]
126
+ total: int
127
+ page: int
128
+ per_page: int
129
+
130
+
131
+ # ---------------------------------------------------------------------------
132
+ # Client
133
+ # ---------------------------------------------------------------------------
134
+
135
+
136
+ class ApolloClient:
137
+ """Async client for the Apollo.io REST API."""
138
+
139
+ BASE_URL = "https://api.apollo.io/v1"
140
+
141
+ def __init__(self, api_key: str):
142
+ self._api_key = api_key
143
+ self._client = httpx.AsyncClient(
144
+ base_url=self.BASE_URL,
145
+ headers={"x-api-key": api_key, "Content-Type": "application/json"},
146
+ timeout=30.0,
147
+ )
148
+
149
+ async def close(self) -> None:
150
+ await self._client.aclose()
151
+
152
+ async def __aenter__(self) -> "ApolloClient":
153
+ return self
154
+
155
+ async def __aexit__(self, *args: Any) -> None:
156
+ await self.close()
157
+
158
+ def _raise_for_status(self, response: httpx.Response) -> None:
159
+ if response.status_code == 429:
160
+ raise httpx.HTTPStatusError("Rate limited", request=response.request, response=response)
161
+ if response.status_code >= 400:
162
+ try:
163
+ detail = response.json().get("message", response.text)
164
+ except Exception:
165
+ detail = response.text
166
+ raise ApolloAPIError(status_code=response.status_code, detail=detail)
167
+
168
+ @retry(
169
+ stop=stop_after_attempt(3),
170
+ wait=wait_exponential(multiplier=2, min=4, max=60),
171
+ retry=retry_if_exception_type(httpx.HTTPStatusError),
172
+ )
173
+ async def _post(self, path: str, payload: dict[str, Any]) -> dict[str, Any]:
174
+ response = await self._client.post(path, json=payload)
175
+ self._raise_for_status(response)
176
+ return response.json()
177
+
178
+ @staticmethod
179
+ def _parse_contact(data: dict[str, Any]) -> ApolloContact:
180
+ return ApolloContact(
181
+ id=data.get("id", ""),
182
+ first_name=data.get("first_name", ""),
183
+ last_name=data.get("last_name", ""),
184
+ email=data.get("email"),
185
+ title=data.get("title"),
186
+ company_name=data.get("organization_name") or data.get("company_name"),
187
+ company_domain=data.get("organization", {}).get("primary_domain")
188
+ if isinstance(data.get("organization"), dict)
189
+ else data.get("company_domain"),
190
+ linkedin_url=data.get("linkedin_url"),
191
+ phone=data.get("sanitized_phone") or data.get("phone"),
192
+ )
193
+
194
+ @staticmethod
195
+ def _parse_organization(data: dict[str, Any]) -> ApolloOrganization:
196
+ tech = data.get("technologies") or data.get("tech_stack") or []
197
+ if isinstance(tech, list):
198
+ tech_names = [t if isinstance(t, str) else t.get("name", "") for t in tech]
199
+ else:
200
+ tech_names = []
201
+
202
+ return ApolloOrganization(
203
+ id=data.get("id", ""),
204
+ name=data.get("name", ""),
205
+ domain=data.get("primary_domain") or data.get("domain"),
206
+ industry=data.get("industry"),
207
+ estimated_headcount=data.get("estimated_num_employees"),
208
+ tech_stack=tech_names,
209
+ funding_stage=data.get("latest_funding_stage") or data.get("funding_stage"),
210
+ funding_total=data.get("total_funding"),
211
+ description=data.get("short_description") or data.get("description"),
212
+ linkedin_url=data.get("linkedin_url"),
213
+ )
214
+
215
+ async def search_people(
216
+ self,
217
+ *,
218
+ titles: list[str] | None = None,
219
+ domains: list[str] | None = None,
220
+ industries: list[str] | None = None,
221
+ page: int = 1,
222
+ per_page: int = 25,
223
+ **extra: Any,
224
+ ) -> PeopleSearchResult:
225
+ """Search for people by title, domain, or industry."""
226
+ payload: dict[str, Any] = {"page": page, "per_page": min(per_page, 100)}
227
+ if titles:
228
+ payload["person_titles"] = titles
229
+ if domains:
230
+ payload["q_organization_domains"] = domains
231
+ if industries:
232
+ payload["organization_industry_tag_ids"] = industries
233
+ payload.update(extra)
234
+
235
+ data = await self._post("/mixed_people/api_search", payload)
236
+ contacts = [self._parse_contact(c) for c in data.get("people", [])]
237
+ pagination = data.get("pagination", {})
238
+ return PeopleSearchResult(
239
+ contacts=contacts,
240
+ total=pagination.get("total_entries", data.get("total_entries", len(contacts))),
241
+ page=pagination.get("page", page),
242
+ per_page=pagination.get("per_page", per_page),
243
+ )
244
+
245
+ async def search_organizations(
246
+ self,
247
+ *,
248
+ industries: list[str] | None = None,
249
+ min_headcount: int | None = None,
250
+ max_headcount: int | None = None,
251
+ page: int = 1,
252
+ per_page: int = 25,
253
+ **extra: Any,
254
+ ) -> OrgSearchResult:
255
+ """Search for organizations by industry and headcount range."""
256
+ payload: dict[str, Any] = {"page": page, "per_page": per_page}
257
+ if industries:
258
+ payload["organization_industry_tag_ids"] = industries
259
+ if min_headcount is not None or max_headcount is not None:
260
+ payload["organization_num_employees_ranges"] = [
261
+ f"{min_headcount or 1},{max_headcount or 100_000}"
262
+ ]
263
+ payload.update(extra)
264
+
265
+ data = await self._post("/mixed_companies/search", payload)
266
+ orgs = [self._parse_organization(o) for o in data.get("organizations", [])]
267
+ pagination = data.get("pagination", {})
268
+ return OrgSearchResult(
269
+ organizations=orgs,
270
+ total=pagination.get("total_entries", len(orgs)),
271
+ page=pagination.get("page", page),
272
+ per_page=pagination.get("per_page", per_page),
273
+ )
274
+
275
+ async def enrich_person(
276
+ self,
277
+ *,
278
+ person_id: str | None = None,
279
+ email: str | None = None,
280
+ linkedin_url: str | None = None,
281
+ ) -> ApolloContact | None:
282
+ """Enrich a person by ID, email, or LinkedIn URL. Returns None if not found."""
283
+ if not person_id and not email and not linkedin_url:
284
+ raise ValueError("Provide at least one of person_id, email, or linkedin_url")
285
+ payload: dict[str, Any] = {}
286
+ if person_id:
287
+ payload["id"] = person_id
288
+ if email:
289
+ payload["email"] = email
290
+ if linkedin_url:
291
+ payload["linkedin_url"] = linkedin_url
292
+
293
+ data = await self._post("/people/match", payload)
294
+ person = data.get("person")
295
+ if not person:
296
+ return None
297
+ return self._parse_contact(person)
298
+
299
+ async def enrich_organization(self, *, domain: str) -> ApolloOrganization | None:
300
+ """Enrich an organization by domain. Returns None if not found."""
301
+ data = await self._post("/organizations/enrich", {"domain": domain})
302
+ org = data.get("organization")
303
+ if not org:
304
+ return None
305
+ return self._parse_organization(org)