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.
@@ -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,6 @@
1
+ """Python client for the EarlyForge Pulse API."""
2
+
3
+ from .client import PulseApiError, PulseClient
4
+
5
+ __all__ = ["PulseClient", "PulseApiError"]
6
+ __version__ = "0.1.0"
@@ -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
+ 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,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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"