earlyforge 0.1.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.
- earlyforge-0.1.0/PKG-INFO +171 -0
- earlyforge-0.1.0/README.md +158 -0
- earlyforge-0.1.0/earlyforge/__init__.py +6 -0
- earlyforge-0.1.0/earlyforge/client.py +181 -0
- earlyforge-0.1.0/earlyforge.egg-info/PKG-INFO +171 -0
- earlyforge-0.1.0/earlyforge.egg-info/SOURCES.txt +10 -0
- earlyforge-0.1.0/earlyforge.egg-info/dependency_links.txt +1 -0
- earlyforge-0.1.0/earlyforge.egg-info/requires.txt +1 -0
- earlyforge-0.1.0/earlyforge.egg-info/top_level.txt +1 -0
- earlyforge-0.1.0/pyproject.toml +21 -0
- earlyforge-0.1.0/setup.cfg +4 -0
- earlyforge-0.1.0/tests/test_client.py +89 -0
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: earlyforge
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Python client for the EarlyForge Pulse API (free trend data, authenticated)
|
|
5
|
+
Author: EarlyForge
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://earlyforge.ai
|
|
8
|
+
Project-URL: Documentation, https://docs.earlyforge.ai/guides/api/trends-api
|
|
9
|
+
Project-URL: Source, https://github.com/Earlyforge-official/earlyforge
|
|
10
|
+
Requires-Python: >=3.9
|
|
11
|
+
Description-Content-Type: text/markdown
|
|
12
|
+
Requires-Dist: httpx>=0.27.0
|
|
13
|
+
|
|
14
|
+
# earlyforge (Python)
|
|
15
|
+
|
|
16
|
+
Python client for the **EarlyForge Trends API**, with two tiers under the
|
|
17
|
+
same `/v1/api/trends/*` prefix:
|
|
18
|
+
|
|
19
|
+
- **Pulse mirror** — free on every account, no plan required. An
|
|
20
|
+
authenticated mirror of the free, anonymous trend data already shown on
|
|
21
|
+
[pulse.earlyforge.ai](https://pulse.earlyforge.ai): same daily snapshot,
|
|
22
|
+
same trend count, same fields, no extra filters.
|
|
23
|
+
- **Full trends feed** — paid plans only, and **not "all trends
|
|
24
|
+
unlocked."** Every plan reveals a bigger, but still limited, daily,
|
|
25
|
+
random, category-diversified budget of trend names — exactly what your
|
|
26
|
+
organization's dashboard Trends page shows unlocked today, no more, no
|
|
27
|
+
less. See `full_trends()` below.
|
|
28
|
+
|
|
29
|
+
This is **not** a client for articles, briefs, or alerts — the public API
|
|
30
|
+
never exposes those on any plan.
|
|
31
|
+
|
|
32
|
+
Full reference: [docs.earlyforge.ai/guides/api/python-sdk](https://docs.earlyforge.ai/guides/api/python-sdk). Source: [github.com/Earlyforge-official/earlyforge](https://github.com/Earlyforge-official/earlyforge/tree/main/sdk-python-earlyforge).
|
|
33
|
+
|
|
34
|
+
## Install
|
|
35
|
+
|
|
36
|
+
Not published to PyPI yet. Contact [support@earlyforge.ai](mailto:support@earlyforge.ai) for early access.
|
|
37
|
+
|
|
38
|
+
## Quickstart
|
|
39
|
+
|
|
40
|
+
```python
|
|
41
|
+
from earlyforge import PulseClient
|
|
42
|
+
|
|
43
|
+
client = PulseClient(api_key="ef_live_xxxxxxxxxxxxxxxx")
|
|
44
|
+
|
|
45
|
+
snapshot = client.top_trends(locale="fr")
|
|
46
|
+
print(snapshot["trends"][0]["display_name"])
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
Get a key from **Settings → API Keys** in the EarlyForge console — any
|
|
50
|
+
account works, Pulse access is free.
|
|
51
|
+
|
|
52
|
+
## Methods
|
|
53
|
+
|
|
54
|
+
### `top_trends(date=None, locale=None)`
|
|
55
|
+
|
|
56
|
+
Global top trends — same snapshot as the Pulse homepage.
|
|
57
|
+
|
|
58
|
+
```python
|
|
59
|
+
client.top_trends()
|
|
60
|
+
# {
|
|
61
|
+
# "trends": [
|
|
62
|
+
# {
|
|
63
|
+
# "trend_id": "tr_a1b2c3",
|
|
64
|
+
# "display_name": "AI regulation",
|
|
65
|
+
# "region": "US",
|
|
66
|
+
# "language": "en",
|
|
67
|
+
# "categories": ["politics"],
|
|
68
|
+
# "global_score": 87.5,
|
|
69
|
+
# "lifecycle_stage": "growing",
|
|
70
|
+
# "detected_at": "2026-11-04T08:00:00Z",
|
|
71
|
+
# "hours_since_detection": 6.2,
|
|
72
|
+
# "velocity_zscore": 2.1,
|
|
73
|
+
# "acceleration": 0.4,
|
|
74
|
+
# "n_sources": 12,
|
|
75
|
+
# "source_diversity": 0.8,
|
|
76
|
+
# "cross_lingual_root": false,
|
|
77
|
+
# "google_trends_lead_hours": null,
|
|
78
|
+
# "topic_class": null,
|
|
79
|
+
# "entities": [],
|
|
80
|
+
# "description": None
|
|
81
|
+
# }
|
|
82
|
+
# ],
|
|
83
|
+
# "total": 20,
|
|
84
|
+
# "cached_at": "2026-11-04T06:00:00Z",
|
|
85
|
+
# "cache_ttl_seconds": 3600
|
|
86
|
+
# }
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
### `trends_by_region(region, date=None, locale=None)`
|
|
90
|
+
|
|
91
|
+
Top trends for an ISO 3166-1 alpha-2 region code (e.g. `"US"`, `"FR"`).
|
|
92
|
+
Same `TrendList` shape as `top_trends`.
|
|
93
|
+
|
|
94
|
+
### `trends_by_category(category, date=None, locale=None)`
|
|
95
|
+
|
|
96
|
+
Top trends for a category (e.g. `"politics"`, `"tech"`). Same `TrendList`
|
|
97
|
+
shape as `top_trends`.
|
|
98
|
+
|
|
99
|
+
### `get_trend(trend_id)`
|
|
100
|
+
|
|
101
|
+
Detail for one trend. Same fields as a `Trend`, plus `canonical_name`,
|
|
102
|
+
`sources`, a full `description`, and a `timeline` of `{ts, score,
|
|
103
|
+
sources_count}` points.
|
|
104
|
+
|
|
105
|
+
```python
|
|
106
|
+
client.get_trend("tr_a1b2c3")
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
### `full_trends(region=None, category=None, page=1, limit=20)`
|
|
110
|
+
|
|
111
|
+
**Paid plans only.** A bigger, paginated slice of the trend corpus than
|
|
112
|
+
the fixed Pulse snapshot — gated by the same daily random name-visibility
|
|
113
|
+
budget as the dashboard. Items outside today's budget stay in the list
|
|
114
|
+
with their name fields redacted (`name_locked: true`), not removed.
|
|
115
|
+
|
|
116
|
+
```python
|
|
117
|
+
client.full_trends(limit=50)
|
|
118
|
+
# {
|
|
119
|
+
# "items": [
|
|
120
|
+
# {"trend_id": "tr_a1b2c3", "display_name": "Ransomware wave", ...},
|
|
121
|
+
# {"trend_id": "tr_d4e5f6", "display_name": "••••••••••", "name_locked": True, ...}
|
|
122
|
+
# ],
|
|
123
|
+
# "total": 2,
|
|
124
|
+
# "page": 1,
|
|
125
|
+
# "per_page": 50
|
|
126
|
+
# }
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
A free-plan key raises `PulseApiError` with `status_code=402` and
|
|
130
|
+
`detail={"error": "upgrade_required", ...}` before this even runs.
|
|
131
|
+
|
|
132
|
+
### `get_full_trend(trend_id)`
|
|
133
|
+
|
|
134
|
+
**Paid plans only.** Full detail for one trend — same TD-sourced fields
|
|
135
|
+
as the dashboard's detail panel (`topsis_score`, `editorial_title`, etc.).
|
|
136
|
+
Raises `PulseApiError` with `status_code=402` and
|
|
137
|
+
`detail={"code": "trend_locked", ...}` if the trend is outside today's
|
|
138
|
+
visible-name budget.
|
|
139
|
+
|
|
140
|
+
```python
|
|
141
|
+
client.get_full_trend("tr_a1b2c3")
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
## Errors
|
|
145
|
+
|
|
146
|
+
Every non-2xx response raises `earlyforge.PulseApiError` with
|
|
147
|
+
`status_code` and `detail` attributes (mirroring the OpenAPI spec's `401`
|
|
148
|
+
missing/invalid/revoked key, `404` unknown trend, and `502` upstream
|
|
149
|
+
unavailable responses). Network failures (DNS, timeout, connection
|
|
150
|
+
refused) also raise `PulseApiError`.
|
|
151
|
+
|
|
152
|
+
```python
|
|
153
|
+
from earlyforge import PulseApiError
|
|
154
|
+
|
|
155
|
+
try:
|
|
156
|
+
client.get_trend("does-not-exist")
|
|
157
|
+
except PulseApiError as exc:
|
|
158
|
+
print(exc.status_code, exc.detail)
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
## Rate limits
|
|
162
|
+
|
|
163
|
+
No monthly call quota on this surface, for any plan — only the shared
|
|
164
|
+
60 requests/60s burst limit applies, same as the anonymous Pulse page.
|
|
165
|
+
|
|
166
|
+
## Tests
|
|
167
|
+
|
|
168
|
+
```bash
|
|
169
|
+
pip install -e . pytest
|
|
170
|
+
pytest
|
|
171
|
+
```
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
# earlyforge (Python)
|
|
2
|
+
|
|
3
|
+
Python client for the **EarlyForge Trends API**, with two tiers under the
|
|
4
|
+
same `/v1/api/trends/*` prefix:
|
|
5
|
+
|
|
6
|
+
- **Pulse mirror** — free on every account, no plan required. An
|
|
7
|
+
authenticated mirror of the free, anonymous trend data already shown on
|
|
8
|
+
[pulse.earlyforge.ai](https://pulse.earlyforge.ai): same daily snapshot,
|
|
9
|
+
same trend count, same fields, no extra filters.
|
|
10
|
+
- **Full trends feed** — paid plans only, and **not "all trends
|
|
11
|
+
unlocked."** Every plan reveals a bigger, but still limited, daily,
|
|
12
|
+
random, category-diversified budget of trend names — exactly what your
|
|
13
|
+
organization's dashboard Trends page shows unlocked today, no more, no
|
|
14
|
+
less. See `full_trends()` below.
|
|
15
|
+
|
|
16
|
+
This is **not** a client for articles, briefs, or alerts — the public API
|
|
17
|
+
never exposes those on any plan.
|
|
18
|
+
|
|
19
|
+
Full reference: [docs.earlyforge.ai/guides/api/python-sdk](https://docs.earlyforge.ai/guides/api/python-sdk). Source: [github.com/Earlyforge-official/earlyforge](https://github.com/Earlyforge-official/earlyforge/tree/main/sdk-python-earlyforge).
|
|
20
|
+
|
|
21
|
+
## Install
|
|
22
|
+
|
|
23
|
+
Not published to PyPI yet. Contact [support@earlyforge.ai](mailto:support@earlyforge.ai) for early access.
|
|
24
|
+
|
|
25
|
+
## Quickstart
|
|
26
|
+
|
|
27
|
+
```python
|
|
28
|
+
from earlyforge import PulseClient
|
|
29
|
+
|
|
30
|
+
client = PulseClient(api_key="ef_live_xxxxxxxxxxxxxxxx")
|
|
31
|
+
|
|
32
|
+
snapshot = client.top_trends(locale="fr")
|
|
33
|
+
print(snapshot["trends"][0]["display_name"])
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
Get a key from **Settings → API Keys** in the EarlyForge console — any
|
|
37
|
+
account works, Pulse access is free.
|
|
38
|
+
|
|
39
|
+
## Methods
|
|
40
|
+
|
|
41
|
+
### `top_trends(date=None, locale=None)`
|
|
42
|
+
|
|
43
|
+
Global top trends — same snapshot as the Pulse homepage.
|
|
44
|
+
|
|
45
|
+
```python
|
|
46
|
+
client.top_trends()
|
|
47
|
+
# {
|
|
48
|
+
# "trends": [
|
|
49
|
+
# {
|
|
50
|
+
# "trend_id": "tr_a1b2c3",
|
|
51
|
+
# "display_name": "AI regulation",
|
|
52
|
+
# "region": "US",
|
|
53
|
+
# "language": "en",
|
|
54
|
+
# "categories": ["politics"],
|
|
55
|
+
# "global_score": 87.5,
|
|
56
|
+
# "lifecycle_stage": "growing",
|
|
57
|
+
# "detected_at": "2026-11-04T08:00:00Z",
|
|
58
|
+
# "hours_since_detection": 6.2,
|
|
59
|
+
# "velocity_zscore": 2.1,
|
|
60
|
+
# "acceleration": 0.4,
|
|
61
|
+
# "n_sources": 12,
|
|
62
|
+
# "source_diversity": 0.8,
|
|
63
|
+
# "cross_lingual_root": false,
|
|
64
|
+
# "google_trends_lead_hours": null,
|
|
65
|
+
# "topic_class": null,
|
|
66
|
+
# "entities": [],
|
|
67
|
+
# "description": None
|
|
68
|
+
# }
|
|
69
|
+
# ],
|
|
70
|
+
# "total": 20,
|
|
71
|
+
# "cached_at": "2026-11-04T06:00:00Z",
|
|
72
|
+
# "cache_ttl_seconds": 3600
|
|
73
|
+
# }
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
### `trends_by_region(region, date=None, locale=None)`
|
|
77
|
+
|
|
78
|
+
Top trends for an ISO 3166-1 alpha-2 region code (e.g. `"US"`, `"FR"`).
|
|
79
|
+
Same `TrendList` shape as `top_trends`.
|
|
80
|
+
|
|
81
|
+
### `trends_by_category(category, date=None, locale=None)`
|
|
82
|
+
|
|
83
|
+
Top trends for a category (e.g. `"politics"`, `"tech"`). Same `TrendList`
|
|
84
|
+
shape as `top_trends`.
|
|
85
|
+
|
|
86
|
+
### `get_trend(trend_id)`
|
|
87
|
+
|
|
88
|
+
Detail for one trend. Same fields as a `Trend`, plus `canonical_name`,
|
|
89
|
+
`sources`, a full `description`, and a `timeline` of `{ts, score,
|
|
90
|
+
sources_count}` points.
|
|
91
|
+
|
|
92
|
+
```python
|
|
93
|
+
client.get_trend("tr_a1b2c3")
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
### `full_trends(region=None, category=None, page=1, limit=20)`
|
|
97
|
+
|
|
98
|
+
**Paid plans only.** A bigger, paginated slice of the trend corpus than
|
|
99
|
+
the fixed Pulse snapshot — gated by the same daily random name-visibility
|
|
100
|
+
budget as the dashboard. Items outside today's budget stay in the list
|
|
101
|
+
with their name fields redacted (`name_locked: true`), not removed.
|
|
102
|
+
|
|
103
|
+
```python
|
|
104
|
+
client.full_trends(limit=50)
|
|
105
|
+
# {
|
|
106
|
+
# "items": [
|
|
107
|
+
# {"trend_id": "tr_a1b2c3", "display_name": "Ransomware wave", ...},
|
|
108
|
+
# {"trend_id": "tr_d4e5f6", "display_name": "••••••••••", "name_locked": True, ...}
|
|
109
|
+
# ],
|
|
110
|
+
# "total": 2,
|
|
111
|
+
# "page": 1,
|
|
112
|
+
# "per_page": 50
|
|
113
|
+
# }
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
A free-plan key raises `PulseApiError` with `status_code=402` and
|
|
117
|
+
`detail={"error": "upgrade_required", ...}` before this even runs.
|
|
118
|
+
|
|
119
|
+
### `get_full_trend(trend_id)`
|
|
120
|
+
|
|
121
|
+
**Paid plans only.** Full detail for one trend — same TD-sourced fields
|
|
122
|
+
as the dashboard's detail panel (`topsis_score`, `editorial_title`, etc.).
|
|
123
|
+
Raises `PulseApiError` with `status_code=402` and
|
|
124
|
+
`detail={"code": "trend_locked", ...}` if the trend is outside today's
|
|
125
|
+
visible-name budget.
|
|
126
|
+
|
|
127
|
+
```python
|
|
128
|
+
client.get_full_trend("tr_a1b2c3")
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
## Errors
|
|
132
|
+
|
|
133
|
+
Every non-2xx response raises `earlyforge.PulseApiError` with
|
|
134
|
+
`status_code` and `detail` attributes (mirroring the OpenAPI spec's `401`
|
|
135
|
+
missing/invalid/revoked key, `404` unknown trend, and `502` upstream
|
|
136
|
+
unavailable responses). Network failures (DNS, timeout, connection
|
|
137
|
+
refused) also raise `PulseApiError`.
|
|
138
|
+
|
|
139
|
+
```python
|
|
140
|
+
from earlyforge import PulseApiError
|
|
141
|
+
|
|
142
|
+
try:
|
|
143
|
+
client.get_trend("does-not-exist")
|
|
144
|
+
except PulseApiError as exc:
|
|
145
|
+
print(exc.status_code, exc.detail)
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
## Rate limits
|
|
149
|
+
|
|
150
|
+
No monthly call quota on this surface, for any plan — only the shared
|
|
151
|
+
60 requests/60s burst limit applies, same as the anonymous Pulse page.
|
|
152
|
+
|
|
153
|
+
## Tests
|
|
154
|
+
|
|
155
|
+
```bash
|
|
156
|
+
pip install -e . pytest
|
|
157
|
+
pytest
|
|
158
|
+
```
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
"""Client for the EarlyForge Trends API.
|
|
2
|
+
|
|
3
|
+
Covers 2 tiers under the same /v1/api/trends/* prefix:
|
|
4
|
+
|
|
5
|
+
- Pulse mirror (free, any plan):
|
|
6
|
+
GET /v1/api/trends/top
|
|
7
|
+
GET /v1/api/trends/region/{region}
|
|
8
|
+
GET /v1/api/trends/category/{category}
|
|
9
|
+
GET /v1/api/trends/{trend_id}
|
|
10
|
+
- Full trends feed (paid plans only — NOT "all trends unlocked", see
|
|
11
|
+
full_trends()):
|
|
12
|
+
GET /v1/api/trends/full
|
|
13
|
+
GET /v1/api/trends/full/{trend_id}
|
|
14
|
+
|
|
15
|
+
Contract: docs/api/public-api-v1/openapi.yaml (repo root). This client
|
|
16
|
+
never sends a parameter the OpenAPI spec doesn't document.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
from typing import Any, Dict, Optional
|
|
22
|
+
from urllib.parse import quote
|
|
23
|
+
|
|
24
|
+
import httpx
|
|
25
|
+
|
|
26
|
+
DEFAULT_BASE_URL = "https://api.earlyforge.ai"
|
|
27
|
+
DEFAULT_TIMEOUT_SECONDS = 15.0
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class PulseApiError(Exception):
|
|
31
|
+
"""Raised when the Pulse API returns a non-2xx response.
|
|
32
|
+
|
|
33
|
+
Attributes:
|
|
34
|
+
status_code: The HTTP status code, when available.
|
|
35
|
+
detail: The error detail returned by the API, when available.
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
def __init__(self, message: str, *, status_code: Optional[int] = None, detail: Optional[str] = None) -> None:
|
|
39
|
+
super().__init__(message)
|
|
40
|
+
self.status_code = status_code
|
|
41
|
+
self.detail = detail
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class PulseClient:
|
|
45
|
+
"""A thin, synchronous HTTP client for the EarlyForge Pulse API.
|
|
46
|
+
|
|
47
|
+
Example:
|
|
48
|
+
>>> client = PulseClient(api_key="ef_live_xxxxxxxxxxxxxxxx")
|
|
49
|
+
>>> snapshot = client.top_trends(locale="fr")
|
|
50
|
+
>>> snapshot["trends"][0]["display_name"]
|
|
51
|
+
"""
|
|
52
|
+
|
|
53
|
+
def __init__(
|
|
54
|
+
self,
|
|
55
|
+
api_key: str,
|
|
56
|
+
base_url: str = DEFAULT_BASE_URL,
|
|
57
|
+
*,
|
|
58
|
+
timeout: float = DEFAULT_TIMEOUT_SECONDS,
|
|
59
|
+
) -> None:
|
|
60
|
+
if not api_key:
|
|
61
|
+
raise ValueError("api_key is required")
|
|
62
|
+
self.api_key = api_key
|
|
63
|
+
self.base_url = base_url.rstrip("/")
|
|
64
|
+
self.timeout = timeout
|
|
65
|
+
|
|
66
|
+
def _headers(self) -> Dict[str, str]:
|
|
67
|
+
return {"Authorization": f"Bearer {self.api_key}"}
|
|
68
|
+
|
|
69
|
+
def _get(self, path: str, params: Optional[Dict[str, Any]] = None) -> Any:
|
|
70
|
+
clean_params = {k: v for k, v in (params or {}).items() if v is not None}
|
|
71
|
+
url = f"{self.base_url}{path}"
|
|
72
|
+
try:
|
|
73
|
+
response = httpx.get(
|
|
74
|
+
url,
|
|
75
|
+
params=clean_params,
|
|
76
|
+
headers=self._headers(),
|
|
77
|
+
timeout=self.timeout,
|
|
78
|
+
)
|
|
79
|
+
except httpx.RequestError as exc:
|
|
80
|
+
raise PulseApiError(f"Could not reach EarlyForge API at {url}: {exc}") from exc
|
|
81
|
+
|
|
82
|
+
if response.status_code >= 400:
|
|
83
|
+
detail = None
|
|
84
|
+
try:
|
|
85
|
+
detail = response.json().get("detail")
|
|
86
|
+
except Exception:
|
|
87
|
+
detail = response.text[:300] or None
|
|
88
|
+
raise PulseApiError(
|
|
89
|
+
f"EarlyForge API returned HTTP {response.status_code} for {url}",
|
|
90
|
+
status_code=response.status_code,
|
|
91
|
+
detail=detail,
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
return response.json()
|
|
95
|
+
|
|
96
|
+
def top_trends(self, *, date: Optional[str] = None, locale: Optional[str] = None) -> Any:
|
|
97
|
+
"""Global top trends — same daily snapshot as the Pulse homepage.
|
|
98
|
+
|
|
99
|
+
Args:
|
|
100
|
+
date: Pick a past daily snapshot (YYYY-MM-DD). Omit for the latest.
|
|
101
|
+
locale: Locale for translated fields (e.g. "fr").
|
|
102
|
+
|
|
103
|
+
Returns:
|
|
104
|
+
{"trends": [...], "total": int, "cached_at": str, "cache_ttl_seconds": int}
|
|
105
|
+
"""
|
|
106
|
+
return self._get("/v1/api/trends/top", {"date": date, "locale": locale})
|
|
107
|
+
|
|
108
|
+
def trends_by_region(
|
|
109
|
+
self, region: str, *, date: Optional[str] = None, locale: Optional[str] = None
|
|
110
|
+
) -> Any:
|
|
111
|
+
"""Top trends for a region — same snapshot as the Pulse region view.
|
|
112
|
+
|
|
113
|
+
Args:
|
|
114
|
+
region: ISO 3166-1 alpha-2 region code (e.g. "US", "FR").
|
|
115
|
+
date: Pick a past daily snapshot (YYYY-MM-DD). Omit for the latest.
|
|
116
|
+
locale: Locale for translated fields (e.g. "fr").
|
|
117
|
+
"""
|
|
118
|
+
return self._get(f"/v1/api/trends/region/{quote(region, safe='')}", {"date": date, "locale": locale})
|
|
119
|
+
|
|
120
|
+
def trends_by_category(
|
|
121
|
+
self, category: str, *, date: Optional[str] = None, locale: Optional[str] = None
|
|
122
|
+
) -> Any:
|
|
123
|
+
"""Top trends for a category — same snapshot as the Pulse category view.
|
|
124
|
+
|
|
125
|
+
Args:
|
|
126
|
+
category: Category filter (e.g. "politics", "tech").
|
|
127
|
+
date: Pick a past daily snapshot (YYYY-MM-DD). Omit for the latest.
|
|
128
|
+
locale: Locale for translated fields (e.g. "fr").
|
|
129
|
+
"""
|
|
130
|
+
return self._get(f"/v1/api/trends/category/{quote(category, safe='')}", {"date": date, "locale": locale})
|
|
131
|
+
|
|
132
|
+
def get_trend(self, trend_id: str) -> Any:
|
|
133
|
+
"""Trend detail — same payload as the public trend detail page.
|
|
134
|
+
|
|
135
|
+
Args:
|
|
136
|
+
trend_id: The trend's id, e.g. "tr_a1b2c3" (from top_trends()).
|
|
137
|
+
"""
|
|
138
|
+
return self._get(f"/v1/api/trends/{quote(trend_id, safe='')}")
|
|
139
|
+
|
|
140
|
+
def full_trends(
|
|
141
|
+
self,
|
|
142
|
+
*,
|
|
143
|
+
region: Optional[str] = None,
|
|
144
|
+
category: Optional[str] = None,
|
|
145
|
+
page: int = 1,
|
|
146
|
+
limit: int = 20,
|
|
147
|
+
) -> Any:
|
|
148
|
+
"""Full trends feed — paid plans only, and NOT "all trends unlocked".
|
|
149
|
+
|
|
150
|
+
Every plan still reveals only a daily, random, category-diversified
|
|
151
|
+
budget of trend names (same budget the dashboard's Trends page shows
|
|
152
|
+
for this org today). Items outside that budget stay in the list but
|
|
153
|
+
with their name fields redacted (``name_locked: true``). A free-plan
|
|
154
|
+
key gets ``PulseApiError`` with ``status_code=402`` before this even
|
|
155
|
+
runs.
|
|
156
|
+
|
|
157
|
+
Args:
|
|
158
|
+
region: Filter by region.
|
|
159
|
+
category: Filter by category.
|
|
160
|
+
page: Page number, default 1.
|
|
161
|
+
limit: Items per page, default 20, max 100.
|
|
162
|
+
|
|
163
|
+
Returns:
|
|
164
|
+
{"items": [...], "total": int, "page": int, "per_page": int}
|
|
165
|
+
"""
|
|
166
|
+
return self._get(
|
|
167
|
+
"/v1/api/trends/full",
|
|
168
|
+
{"region": region, "category": category, "page": page, "limit": limit},
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
def get_full_trend(self, trend_id: str) -> Any:
|
|
172
|
+
"""Full detail for one trend — paid plans only.
|
|
173
|
+
|
|
174
|
+
Returns ``PulseApiError`` with ``status_code=402`` if the trend is
|
|
175
|
+
outside today's visible-name budget — same restriction the
|
|
176
|
+
dashboard's own detail panel enforces.
|
|
177
|
+
|
|
178
|
+
Args:
|
|
179
|
+
trend_id: The trend's id, e.g. "tr_a1b2c3" (from full_trends()).
|
|
180
|
+
"""
|
|
181
|
+
return self._get(f"/v1/api/trends/full/{quote(trend_id, safe='')}")
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: earlyforge
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Python client for the EarlyForge Pulse API (free trend data, authenticated)
|
|
5
|
+
Author: EarlyForge
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://earlyforge.ai
|
|
8
|
+
Project-URL: Documentation, https://docs.earlyforge.ai/guides/api/trends-api
|
|
9
|
+
Project-URL: Source, https://github.com/Earlyforge-official/earlyforge
|
|
10
|
+
Requires-Python: >=3.9
|
|
11
|
+
Description-Content-Type: text/markdown
|
|
12
|
+
Requires-Dist: httpx>=0.27.0
|
|
13
|
+
|
|
14
|
+
# earlyforge (Python)
|
|
15
|
+
|
|
16
|
+
Python client for the **EarlyForge Trends API**, with two tiers under the
|
|
17
|
+
same `/v1/api/trends/*` prefix:
|
|
18
|
+
|
|
19
|
+
- **Pulse mirror** — free on every account, no plan required. An
|
|
20
|
+
authenticated mirror of the free, anonymous trend data already shown on
|
|
21
|
+
[pulse.earlyforge.ai](https://pulse.earlyforge.ai): same daily snapshot,
|
|
22
|
+
same trend count, same fields, no extra filters.
|
|
23
|
+
- **Full trends feed** — paid plans only, and **not "all trends
|
|
24
|
+
unlocked."** Every plan reveals a bigger, but still limited, daily,
|
|
25
|
+
random, category-diversified budget of trend names — exactly what your
|
|
26
|
+
organization's dashboard Trends page shows unlocked today, no more, no
|
|
27
|
+
less. See `full_trends()` below.
|
|
28
|
+
|
|
29
|
+
This is **not** a client for articles, briefs, or alerts — the public API
|
|
30
|
+
never exposes those on any plan.
|
|
31
|
+
|
|
32
|
+
Full reference: [docs.earlyforge.ai/guides/api/python-sdk](https://docs.earlyforge.ai/guides/api/python-sdk). Source: [github.com/Earlyforge-official/earlyforge](https://github.com/Earlyforge-official/earlyforge/tree/main/sdk-python-earlyforge).
|
|
33
|
+
|
|
34
|
+
## Install
|
|
35
|
+
|
|
36
|
+
Not published to PyPI yet. Contact [support@earlyforge.ai](mailto:support@earlyforge.ai) for early access.
|
|
37
|
+
|
|
38
|
+
## Quickstart
|
|
39
|
+
|
|
40
|
+
```python
|
|
41
|
+
from earlyforge import PulseClient
|
|
42
|
+
|
|
43
|
+
client = PulseClient(api_key="ef_live_xxxxxxxxxxxxxxxx")
|
|
44
|
+
|
|
45
|
+
snapshot = client.top_trends(locale="fr")
|
|
46
|
+
print(snapshot["trends"][0]["display_name"])
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
Get a key from **Settings → API Keys** in the EarlyForge console — any
|
|
50
|
+
account works, Pulse access is free.
|
|
51
|
+
|
|
52
|
+
## Methods
|
|
53
|
+
|
|
54
|
+
### `top_trends(date=None, locale=None)`
|
|
55
|
+
|
|
56
|
+
Global top trends — same snapshot as the Pulse homepage.
|
|
57
|
+
|
|
58
|
+
```python
|
|
59
|
+
client.top_trends()
|
|
60
|
+
# {
|
|
61
|
+
# "trends": [
|
|
62
|
+
# {
|
|
63
|
+
# "trend_id": "tr_a1b2c3",
|
|
64
|
+
# "display_name": "AI regulation",
|
|
65
|
+
# "region": "US",
|
|
66
|
+
# "language": "en",
|
|
67
|
+
# "categories": ["politics"],
|
|
68
|
+
# "global_score": 87.5,
|
|
69
|
+
# "lifecycle_stage": "growing",
|
|
70
|
+
# "detected_at": "2026-11-04T08:00:00Z",
|
|
71
|
+
# "hours_since_detection": 6.2,
|
|
72
|
+
# "velocity_zscore": 2.1,
|
|
73
|
+
# "acceleration": 0.4,
|
|
74
|
+
# "n_sources": 12,
|
|
75
|
+
# "source_diversity": 0.8,
|
|
76
|
+
# "cross_lingual_root": false,
|
|
77
|
+
# "google_trends_lead_hours": null,
|
|
78
|
+
# "topic_class": null,
|
|
79
|
+
# "entities": [],
|
|
80
|
+
# "description": None
|
|
81
|
+
# }
|
|
82
|
+
# ],
|
|
83
|
+
# "total": 20,
|
|
84
|
+
# "cached_at": "2026-11-04T06:00:00Z",
|
|
85
|
+
# "cache_ttl_seconds": 3600
|
|
86
|
+
# }
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
### `trends_by_region(region, date=None, locale=None)`
|
|
90
|
+
|
|
91
|
+
Top trends for an ISO 3166-1 alpha-2 region code (e.g. `"US"`, `"FR"`).
|
|
92
|
+
Same `TrendList` shape as `top_trends`.
|
|
93
|
+
|
|
94
|
+
### `trends_by_category(category, date=None, locale=None)`
|
|
95
|
+
|
|
96
|
+
Top trends for a category (e.g. `"politics"`, `"tech"`). Same `TrendList`
|
|
97
|
+
shape as `top_trends`.
|
|
98
|
+
|
|
99
|
+
### `get_trend(trend_id)`
|
|
100
|
+
|
|
101
|
+
Detail for one trend. Same fields as a `Trend`, plus `canonical_name`,
|
|
102
|
+
`sources`, a full `description`, and a `timeline` of `{ts, score,
|
|
103
|
+
sources_count}` points.
|
|
104
|
+
|
|
105
|
+
```python
|
|
106
|
+
client.get_trend("tr_a1b2c3")
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
### `full_trends(region=None, category=None, page=1, limit=20)`
|
|
110
|
+
|
|
111
|
+
**Paid plans only.** A bigger, paginated slice of the trend corpus than
|
|
112
|
+
the fixed Pulse snapshot — gated by the same daily random name-visibility
|
|
113
|
+
budget as the dashboard. Items outside today's budget stay in the list
|
|
114
|
+
with their name fields redacted (`name_locked: true`), not removed.
|
|
115
|
+
|
|
116
|
+
```python
|
|
117
|
+
client.full_trends(limit=50)
|
|
118
|
+
# {
|
|
119
|
+
# "items": [
|
|
120
|
+
# {"trend_id": "tr_a1b2c3", "display_name": "Ransomware wave", ...},
|
|
121
|
+
# {"trend_id": "tr_d4e5f6", "display_name": "••••••••••", "name_locked": True, ...}
|
|
122
|
+
# ],
|
|
123
|
+
# "total": 2,
|
|
124
|
+
# "page": 1,
|
|
125
|
+
# "per_page": 50
|
|
126
|
+
# }
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
A free-plan key raises `PulseApiError` with `status_code=402` and
|
|
130
|
+
`detail={"error": "upgrade_required", ...}` before this even runs.
|
|
131
|
+
|
|
132
|
+
### `get_full_trend(trend_id)`
|
|
133
|
+
|
|
134
|
+
**Paid plans only.** Full detail for one trend — same TD-sourced fields
|
|
135
|
+
as the dashboard's detail panel (`topsis_score`, `editorial_title`, etc.).
|
|
136
|
+
Raises `PulseApiError` with `status_code=402` and
|
|
137
|
+
`detail={"code": "trend_locked", ...}` if the trend is outside today's
|
|
138
|
+
visible-name budget.
|
|
139
|
+
|
|
140
|
+
```python
|
|
141
|
+
client.get_full_trend("tr_a1b2c3")
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
## Errors
|
|
145
|
+
|
|
146
|
+
Every non-2xx response raises `earlyforge.PulseApiError` with
|
|
147
|
+
`status_code` and `detail` attributes (mirroring the OpenAPI spec's `401`
|
|
148
|
+
missing/invalid/revoked key, `404` unknown trend, and `502` upstream
|
|
149
|
+
unavailable responses). Network failures (DNS, timeout, connection
|
|
150
|
+
refused) also raise `PulseApiError`.
|
|
151
|
+
|
|
152
|
+
```python
|
|
153
|
+
from earlyforge import PulseApiError
|
|
154
|
+
|
|
155
|
+
try:
|
|
156
|
+
client.get_trend("does-not-exist")
|
|
157
|
+
except PulseApiError as exc:
|
|
158
|
+
print(exc.status_code, exc.detail)
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
## Rate limits
|
|
162
|
+
|
|
163
|
+
No monthly call quota on this surface, for any plan — only the shared
|
|
164
|
+
60 requests/60s burst limit applies, same as the anonymous Pulse page.
|
|
165
|
+
|
|
166
|
+
## Tests
|
|
167
|
+
|
|
168
|
+
```bash
|
|
169
|
+
pip install -e . pytest
|
|
170
|
+
pytest
|
|
171
|
+
```
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
pyproject.toml
|
|
3
|
+
earlyforge/__init__.py
|
|
4
|
+
earlyforge/client.py
|
|
5
|
+
earlyforge.egg-info/PKG-INFO
|
|
6
|
+
earlyforge.egg-info/SOURCES.txt
|
|
7
|
+
earlyforge.egg-info/dependency_links.txt
|
|
8
|
+
earlyforge.egg-info/requires.txt
|
|
9
|
+
earlyforge.egg-info/top_level.txt
|
|
10
|
+
tests/test_client.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
httpx>=0.27.0
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
earlyforge
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "earlyforge"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Python client for the EarlyForge Pulse API (free trend data, authenticated)"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.9"
|
|
11
|
+
license = { text = "MIT" }
|
|
12
|
+
authors = [{ name = "EarlyForge" }]
|
|
13
|
+
dependencies = ["httpx>=0.27.0"]
|
|
14
|
+
|
|
15
|
+
[project.urls]
|
|
16
|
+
Homepage = "https://earlyforge.ai"
|
|
17
|
+
Documentation = "https://docs.earlyforge.ai/guides/api/trends-api"
|
|
18
|
+
Source = "https://github.com/Earlyforge-official/earlyforge"
|
|
19
|
+
|
|
20
|
+
[tool.setuptools.packages.find]
|
|
21
|
+
include = ["earlyforge*"]
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import httpx
|
|
4
|
+
import pytest
|
|
5
|
+
|
|
6
|
+
from earlyforge import PulseApiError, PulseClient
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def _handler(request: httpx.Request) -> httpx.Response:
|
|
10
|
+
assert request.headers["authorization"] == "Bearer ef_live_test"
|
|
11
|
+
if request.url.path == "/v1/api/trends/top":
|
|
12
|
+
return httpx.Response(200, json={"trends": [], "total": 0})
|
|
13
|
+
if request.url.path == "/v1/api/trends/region/US":
|
|
14
|
+
return httpx.Response(200, json={"trends": [{"trend_id": "tr_1"}], "total": 1})
|
|
15
|
+
if request.url.path == "/v1/api/trends/tr_missing":
|
|
16
|
+
return httpx.Response(404, json={"detail": "Not found"})
|
|
17
|
+
if request.url.path == "/v1/api/trends/full":
|
|
18
|
+
if request.url.params.get("region") == "locked-org":
|
|
19
|
+
return httpx.Response(
|
|
20
|
+
402,
|
|
21
|
+
json={"detail": {"error": "upgrade_required", "upgrade_url": "/settings/billing"}},
|
|
22
|
+
)
|
|
23
|
+
return httpx.Response(
|
|
24
|
+
200,
|
|
25
|
+
json={"items": [{"trend_id": "tr_1"}], "total": 1, "page": 1, "per_page": 20},
|
|
26
|
+
)
|
|
27
|
+
if request.url.path == "/v1/api/trends/full/tr_locked":
|
|
28
|
+
return httpx.Response(
|
|
29
|
+
402, json={"detail": {"error": "This trend is locked on your plan", "code": "trend_locked"}}
|
|
30
|
+
)
|
|
31
|
+
return httpx.Response(500, json={"detail": "unexpected"})
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@pytest.fixture(autouse=True)
|
|
35
|
+
def _patch_transport(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
36
|
+
transport = httpx.MockTransport(_handler)
|
|
37
|
+
|
|
38
|
+
def fake_get(url, *, params=None, headers=None, timeout=None):
|
|
39
|
+
with httpx.Client(transport=transport) as client:
|
|
40
|
+
return client.get(url, params=params, headers=headers, timeout=timeout)
|
|
41
|
+
|
|
42
|
+
monkeypatch.setattr(httpx, "get", fake_get)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def test_top_trends_sends_bearer_token_and_builds_url() -> None:
|
|
46
|
+
client = PulseClient(api_key="ef_live_test", base_url="https://api.test")
|
|
47
|
+
result = client.top_trends(locale="fr")
|
|
48
|
+
assert result == {"trends": [], "total": 0}
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def test_trends_by_region_builds_path_param() -> None:
|
|
52
|
+
client = PulseClient(api_key="ef_live_test", base_url="https://api.test")
|
|
53
|
+
result = client.trends_by_region("US")
|
|
54
|
+
assert result["total"] == 1
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def test_get_trend_raises_pulse_api_error_on_404() -> None:
|
|
58
|
+
client = PulseClient(api_key="ef_live_test", base_url="https://api.test")
|
|
59
|
+
with pytest.raises(PulseApiError) as excinfo:
|
|
60
|
+
client.get_trend("tr_missing")
|
|
61
|
+
assert excinfo.value.status_code == 404
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def test_requires_api_key() -> None:
|
|
65
|
+
with pytest.raises(ValueError):
|
|
66
|
+
PulseClient(api_key="")
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def test_full_trends_returns_items_with_total() -> None:
|
|
70
|
+
client = PulseClient(api_key="ef_live_test", base_url="https://api.test")
|
|
71
|
+
result = client.full_trends(limit=20)
|
|
72
|
+
assert result["total"] == 1
|
|
73
|
+
assert result["items"][0]["trend_id"] == "tr_1"
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def test_full_trends_raises_402_on_free_plan() -> None:
|
|
77
|
+
client = PulseClient(api_key="ef_live_test", base_url="https://api.test")
|
|
78
|
+
with pytest.raises(PulseApiError) as excinfo:
|
|
79
|
+
client.full_trends(region="locked-org")
|
|
80
|
+
assert excinfo.value.status_code == 402
|
|
81
|
+
assert excinfo.value.detail["error"] == "upgrade_required"
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def test_get_full_trend_raises_402_trend_locked() -> None:
|
|
85
|
+
client = PulseClient(api_key="ef_live_test", base_url="https://api.test")
|
|
86
|
+
with pytest.raises(PulseApiError) as excinfo:
|
|
87
|
+
client.get_full_trend("tr_locked")
|
|
88
|
+
assert excinfo.value.status_code == 402
|
|
89
|
+
assert excinfo.value.detail["code"] == "trend_locked"
|