diffdelta 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.
- diffdelta-0.1.0/PKG-INFO +25 -0
- diffdelta-0.1.0/diffdelta/__init__.py +17 -0
- diffdelta-0.1.0/diffdelta/client.py +327 -0
- diffdelta-0.1.0/diffdelta/cursor.py +68 -0
- diffdelta-0.1.0/diffdelta/models.py +186 -0
- diffdelta-0.1.0/diffdelta.egg-info/PKG-INFO +25 -0
- diffdelta-0.1.0/diffdelta.egg-info/SOURCES.txt +10 -0
- diffdelta-0.1.0/diffdelta.egg-info/dependency_links.txt +1 -0
- diffdelta-0.1.0/diffdelta.egg-info/requires.txt +1 -0
- diffdelta-0.1.0/diffdelta.egg-info/top_level.txt +1 -0
- diffdelta-0.1.0/pyproject.toml +38 -0
- diffdelta-0.1.0/setup.cfg +4 -0
diffdelta-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: diffdelta
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Python client for DiffDelta — agent-ready intelligence feeds
|
|
5
|
+
Author-email: DiffDelta <human@diffdelta.io>
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://diffdelta.io
|
|
8
|
+
Project-URL: Documentation, https://diffdelta.io/#quickstart
|
|
9
|
+
Project-URL: Repository, https://github.com/diffdelta/diffdelta-python
|
|
10
|
+
Project-URL: Issues, https://github.com/diffdelta/diffdelta-python/issues
|
|
11
|
+
Keywords: diffdelta,ai-agents,security,changefeed,intelligence
|
|
12
|
+
Classifier: Development Status :: 4 - Beta
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.8
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
22
|
+
Classifier: Topic :: Security
|
|
23
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
24
|
+
Requires-Python: >=3.8
|
|
25
|
+
Requires-Dist: requests>=2.25.0
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""
|
|
2
|
+
DiffDelta — Agent-ready intelligence feeds.
|
|
3
|
+
|
|
4
|
+
from diffdelta import DiffDelta
|
|
5
|
+
|
|
6
|
+
dd = DiffDelta()
|
|
7
|
+
for item in dd.poll(tags=["security"]):
|
|
8
|
+
print(f"{item.source}: {item.headline}")
|
|
9
|
+
|
|
10
|
+
Full docs: https://diffdelta.io/#quickstart
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from diffdelta.client import DiffDelta
|
|
14
|
+
from diffdelta.models import FeedItem, SourceInfo, Feed, Head
|
|
15
|
+
|
|
16
|
+
__version__ = "0.1.0"
|
|
17
|
+
__all__ = ["DiffDelta", "FeedItem", "SourceInfo", "Feed", "Head"]
|
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
"""DiffDelta client — the main interface for polling intelligence feeds."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import time
|
|
6
|
+
from typing import Any, Callable, Dict, List, Optional, Sequence
|
|
7
|
+
|
|
8
|
+
import requests
|
|
9
|
+
|
|
10
|
+
from diffdelta.cursor import CursorStore
|
|
11
|
+
from diffdelta.models import Feed, FeedItem, Head, SourceInfo
|
|
12
|
+
|
|
13
|
+
DEFAULT_BASE_URL = "https://diffdelta.io"
|
|
14
|
+
DEFAULT_TIMEOUT = 15 # seconds
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class DiffDelta:
|
|
18
|
+
"""Client for polling DiffDelta intelligence feeds.
|
|
19
|
+
|
|
20
|
+
Usage::
|
|
21
|
+
|
|
22
|
+
from diffdelta import DiffDelta
|
|
23
|
+
|
|
24
|
+
dd = DiffDelta()
|
|
25
|
+
|
|
26
|
+
# Poll for new items across all sources
|
|
27
|
+
for item in dd.poll():
|
|
28
|
+
print(f"{item.source}: {item.headline}")
|
|
29
|
+
|
|
30
|
+
# Poll only security sources
|
|
31
|
+
for item in dd.poll(tags=["security"]):
|
|
32
|
+
print(f"🚨 {item.headline}")
|
|
33
|
+
|
|
34
|
+
# Poll a specific source
|
|
35
|
+
for item in dd.poll_source("cisa_kev"):
|
|
36
|
+
print(item.headline)
|
|
37
|
+
|
|
38
|
+
Args:
|
|
39
|
+
base_url: DiffDelta API base URL. Defaults to https://diffdelta.io.
|
|
40
|
+
api_key: Optional Pro/Enterprise API key (dd_live_...).
|
|
41
|
+
cursor_path: Path to cursor persistence file. Set to None to disable persistence.
|
|
42
|
+
timeout: HTTP request timeout in seconds.
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
def __init__(
|
|
46
|
+
self,
|
|
47
|
+
base_url: str = DEFAULT_BASE_URL,
|
|
48
|
+
api_key: Optional[str] = None,
|
|
49
|
+
cursor_path: Optional[str] = "", # "" = default path, None = disabled
|
|
50
|
+
timeout: int = DEFAULT_TIMEOUT,
|
|
51
|
+
) -> None:
|
|
52
|
+
self.base_url = base_url.rstrip("/")
|
|
53
|
+
self.api_key = api_key
|
|
54
|
+
self.timeout = timeout
|
|
55
|
+
|
|
56
|
+
# Set up cursor persistence
|
|
57
|
+
if cursor_path is None:
|
|
58
|
+
self._cursors: Optional[CursorStore] = None
|
|
59
|
+
else:
|
|
60
|
+
self._cursors = CursorStore(cursor_path if cursor_path else None)
|
|
61
|
+
|
|
62
|
+
# Set up HTTP session
|
|
63
|
+
self._session = requests.Session()
|
|
64
|
+
self._session.headers["User-Agent"] = "diffdelta-python/0.1.0"
|
|
65
|
+
if self.api_key:
|
|
66
|
+
self._session.headers["X-DiffDelta-Key"] = self.api_key
|
|
67
|
+
|
|
68
|
+
# ── Core polling ──
|
|
69
|
+
|
|
70
|
+
def poll(
|
|
71
|
+
self,
|
|
72
|
+
tags: Optional[List[str]] = None,
|
|
73
|
+
sources: Optional[List[str]] = None,
|
|
74
|
+
buckets: Optional[List[str]] = None,
|
|
75
|
+
) -> List[FeedItem]:
|
|
76
|
+
"""Poll the global feed for new items since last poll.
|
|
77
|
+
|
|
78
|
+
Checks head.json first (400 bytes). Only fetches the full feed
|
|
79
|
+
if the cursor has changed. Automatically saves the new cursor.
|
|
80
|
+
|
|
81
|
+
Args:
|
|
82
|
+
tags: Filter items to these tags (e.g. ["security"]).
|
|
83
|
+
sources: Filter items to these source IDs (e.g. ["cisa_kev", "nist_nvd"]).
|
|
84
|
+
buckets: Which buckets to return. Defaults to ["new", "updated"].
|
|
85
|
+
Use ["new", "updated", "removed"] to include removals.
|
|
86
|
+
|
|
87
|
+
Returns:
|
|
88
|
+
List of FeedItem objects that are new since your last poll.
|
|
89
|
+
Empty list if nothing has changed.
|
|
90
|
+
"""
|
|
91
|
+
if buckets is None:
|
|
92
|
+
buckets = ["new", "updated"]
|
|
93
|
+
|
|
94
|
+
cursor_key = "global"
|
|
95
|
+
return self._poll_feed(
|
|
96
|
+
head_url=f"{self.base_url}/diff/head.json",
|
|
97
|
+
latest_url=f"{self.base_url}/diff/latest.json",
|
|
98
|
+
cursor_key=cursor_key,
|
|
99
|
+
tags=tags,
|
|
100
|
+
sources=sources,
|
|
101
|
+
buckets=buckets,
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
def poll_source(
|
|
105
|
+
self,
|
|
106
|
+
source_id: str,
|
|
107
|
+
buckets: Optional[List[str]] = None,
|
|
108
|
+
) -> List[FeedItem]:
|
|
109
|
+
"""Poll a specific source for new items since last poll.
|
|
110
|
+
|
|
111
|
+
More efficient than poll() with sources= filter if you only
|
|
112
|
+
care about one source, since it fetches a smaller payload.
|
|
113
|
+
|
|
114
|
+
Args:
|
|
115
|
+
source_id: Source identifier (e.g. "cisa_kev").
|
|
116
|
+
buckets: Which buckets to return. Defaults to ["new", "updated"].
|
|
117
|
+
|
|
118
|
+
Returns:
|
|
119
|
+
List of FeedItem objects that are new since your last poll.
|
|
120
|
+
"""
|
|
121
|
+
if buckets is None:
|
|
122
|
+
buckets = ["new", "updated"]
|
|
123
|
+
|
|
124
|
+
cursor_key = f"source:{source_id}"
|
|
125
|
+
return self._poll_feed(
|
|
126
|
+
head_url=f"{self.base_url}/diff/source/{source_id}/head.json",
|
|
127
|
+
latest_url=f"{self.base_url}/diff/source/{source_id}/latest.json",
|
|
128
|
+
cursor_key=cursor_key,
|
|
129
|
+
tags=None,
|
|
130
|
+
sources=None,
|
|
131
|
+
buckets=buckets,
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
def _poll_feed(
|
|
135
|
+
self,
|
|
136
|
+
head_url: str,
|
|
137
|
+
latest_url: str,
|
|
138
|
+
cursor_key: str,
|
|
139
|
+
tags: Optional[List[str]],
|
|
140
|
+
sources: Optional[List[str]],
|
|
141
|
+
buckets: List[str],
|
|
142
|
+
) -> List[FeedItem]:
|
|
143
|
+
"""Internal: poll a feed with cursor comparison."""
|
|
144
|
+
# Step 1: Fetch head.json (~400 bytes)
|
|
145
|
+
head = self.head(head_url)
|
|
146
|
+
|
|
147
|
+
# Step 2: Compare cursor
|
|
148
|
+
stored_cursor = self._cursors.get(cursor_key) if self._cursors else None
|
|
149
|
+
|
|
150
|
+
if stored_cursor and stored_cursor == head.cursor:
|
|
151
|
+
# Nothing changed — return empty list
|
|
152
|
+
return []
|
|
153
|
+
|
|
154
|
+
# Step 3: Fetch full feed
|
|
155
|
+
feed = self.fetch_feed(latest_url)
|
|
156
|
+
|
|
157
|
+
# Step 4: Save new cursor
|
|
158
|
+
if self._cursors and feed.cursor:
|
|
159
|
+
self._cursors.set(cursor_key, feed.cursor)
|
|
160
|
+
|
|
161
|
+
# Step 5: Filter and return
|
|
162
|
+
items = []
|
|
163
|
+
for item in feed.items:
|
|
164
|
+
# Filter by bucket
|
|
165
|
+
if item.bucket not in buckets:
|
|
166
|
+
continue
|
|
167
|
+
|
|
168
|
+
# Filter by source
|
|
169
|
+
if sources and item.source not in sources:
|
|
170
|
+
continue
|
|
171
|
+
|
|
172
|
+
# Filter by tags (requires knowing source tags — use source index)
|
|
173
|
+
if tags:
|
|
174
|
+
# We need source metadata to filter by tags
|
|
175
|
+
# Load source info if we haven't yet
|
|
176
|
+
source_tags = self._get_source_tags()
|
|
177
|
+
item_tags = source_tags.get(item.source, [])
|
|
178
|
+
if not any(t in item_tags for t in tags):
|
|
179
|
+
continue
|
|
180
|
+
|
|
181
|
+
items.append(item)
|
|
182
|
+
|
|
183
|
+
return items
|
|
184
|
+
|
|
185
|
+
# ── Low-level fetch methods ──
|
|
186
|
+
|
|
187
|
+
def head(self, url: Optional[str] = None) -> Head:
|
|
188
|
+
"""Fetch a head.json pointer.
|
|
189
|
+
|
|
190
|
+
Args:
|
|
191
|
+
url: Full URL to head.json. Defaults to global head.
|
|
192
|
+
|
|
193
|
+
Returns:
|
|
194
|
+
Head object with cursor, hash, and metadata.
|
|
195
|
+
"""
|
|
196
|
+
if url is None:
|
|
197
|
+
url = f"{self.base_url}/diff/head.json"
|
|
198
|
+
data = self._get_json(url)
|
|
199
|
+
return Head.from_raw(data)
|
|
200
|
+
|
|
201
|
+
def fetch_feed(self, url: Optional[str] = None) -> Feed:
|
|
202
|
+
"""Fetch a full latest.json feed.
|
|
203
|
+
|
|
204
|
+
Args:
|
|
205
|
+
url: Full URL to latest.json. Defaults to global latest.
|
|
206
|
+
|
|
207
|
+
Returns:
|
|
208
|
+
Feed object with all items, buckets, and metadata.
|
|
209
|
+
"""
|
|
210
|
+
if url is None:
|
|
211
|
+
url = f"{self.base_url}/diff/latest.json"
|
|
212
|
+
data = self._get_json(url)
|
|
213
|
+
return Feed.from_raw(data)
|
|
214
|
+
|
|
215
|
+
def sources(self) -> List[SourceInfo]:
|
|
216
|
+
"""List all available DiffDelta sources.
|
|
217
|
+
|
|
218
|
+
Returns:
|
|
219
|
+
List of SourceInfo objects with metadata about each source.
|
|
220
|
+
"""
|
|
221
|
+
data = self._get_json(f"{self.base_url}/diff/sources.json")
|
|
222
|
+
return [SourceInfo.from_raw(s) for s in data.get("sources", [])]
|
|
223
|
+
|
|
224
|
+
# ── Continuous monitoring ──
|
|
225
|
+
|
|
226
|
+
def watch(
|
|
227
|
+
self,
|
|
228
|
+
callback: Callable[[FeedItem], None],
|
|
229
|
+
tags: Optional[List[str]] = None,
|
|
230
|
+
sources: Optional[List[str]] = None,
|
|
231
|
+
interval: Optional[int] = None,
|
|
232
|
+
buckets: Optional[List[str]] = None,
|
|
233
|
+
) -> None:
|
|
234
|
+
"""Continuously poll and call a function for each new item.
|
|
235
|
+
|
|
236
|
+
This runs an infinite loop. Use Ctrl+C to stop, or run in a thread.
|
|
237
|
+
|
|
238
|
+
Args:
|
|
239
|
+
callback: Function called for each new FeedItem.
|
|
240
|
+
tags: Filter to these tags.
|
|
241
|
+
sources: Filter to these source IDs.
|
|
242
|
+
interval: Seconds between polls. Defaults to feed's TTL (usually 900s).
|
|
243
|
+
buckets: Which buckets to process. Defaults to ["new", "updated"].
|
|
244
|
+
|
|
245
|
+
Example::
|
|
246
|
+
|
|
247
|
+
def handle(item):
|
|
248
|
+
print(f"🚨 {item.source}: {item.headline}")
|
|
249
|
+
# send_slack_alert(item)
|
|
250
|
+
|
|
251
|
+
dd = DiffDelta()
|
|
252
|
+
dd.watch(handle, tags=["security"]) # Runs forever
|
|
253
|
+
"""
|
|
254
|
+
if buckets is None:
|
|
255
|
+
buckets = ["new", "updated"]
|
|
256
|
+
|
|
257
|
+
# Determine polling interval from feed TTL if not specified
|
|
258
|
+
if interval is None:
|
|
259
|
+
try:
|
|
260
|
+
h = self.head()
|
|
261
|
+
interval = max(h.ttl_sec, 60) # At least 60 seconds
|
|
262
|
+
except Exception:
|
|
263
|
+
interval = 900 # Default 15 minutes
|
|
264
|
+
|
|
265
|
+
print(f"[diffdelta] Watching for changes every {interval}s...")
|
|
266
|
+
print(f"[diffdelta] Press Ctrl+C to stop.")
|
|
267
|
+
|
|
268
|
+
while True:
|
|
269
|
+
try:
|
|
270
|
+
items = self.poll(tags=tags, sources=sources, buckets=buckets)
|
|
271
|
+
if items:
|
|
272
|
+
print(f"[diffdelta] {len(items)} new item(s) found.")
|
|
273
|
+
for item in items:
|
|
274
|
+
callback(item)
|
|
275
|
+
else:
|
|
276
|
+
print(f"[diffdelta] No changes.")
|
|
277
|
+
except KeyboardInterrupt:
|
|
278
|
+
print("\n[diffdelta] Stopped.")
|
|
279
|
+
break
|
|
280
|
+
except Exception as e:
|
|
281
|
+
print(f"[diffdelta] Error: {e}. Retrying in {interval}s...")
|
|
282
|
+
|
|
283
|
+
try:
|
|
284
|
+
time.sleep(interval)
|
|
285
|
+
except KeyboardInterrupt:
|
|
286
|
+
print("\n[diffdelta] Stopped.")
|
|
287
|
+
break
|
|
288
|
+
|
|
289
|
+
# ── Cursor management ──
|
|
290
|
+
|
|
291
|
+
def reset_cursors(self, source_id: Optional[str] = None) -> None:
|
|
292
|
+
"""Reset stored cursors so the next poll returns all current items.
|
|
293
|
+
|
|
294
|
+
Args:
|
|
295
|
+
source_id: Reset cursor for a specific source. None = reset all.
|
|
296
|
+
"""
|
|
297
|
+
if self._cursors:
|
|
298
|
+
if source_id:
|
|
299
|
+
self._cursors.clear(f"source:{source_id}")
|
|
300
|
+
else:
|
|
301
|
+
self._cursors.clear()
|
|
302
|
+
|
|
303
|
+
# ── Internal ──
|
|
304
|
+
|
|
305
|
+
_source_tags_cache: Optional[Dict[str, List[str]]] = None
|
|
306
|
+
|
|
307
|
+
def _get_source_tags(self) -> Dict[str, List[str]]:
|
|
308
|
+
"""Cache source → tags mapping for tag-based filtering."""
|
|
309
|
+
if self._source_tags_cache is None:
|
|
310
|
+
try:
|
|
311
|
+
all_sources = self.sources()
|
|
312
|
+
self._source_tags_cache = {
|
|
313
|
+
s.source_id: s.tags for s in all_sources
|
|
314
|
+
}
|
|
315
|
+
except Exception:
|
|
316
|
+
self._source_tags_cache = {}
|
|
317
|
+
return self._source_tags_cache
|
|
318
|
+
|
|
319
|
+
def _get_json(self, url: str) -> Dict[str, Any]:
|
|
320
|
+
"""Fetch a URL and return parsed JSON."""
|
|
321
|
+
resp = self._session.get(url, timeout=self.timeout)
|
|
322
|
+
resp.raise_for_status()
|
|
323
|
+
return resp.json()
|
|
324
|
+
|
|
325
|
+
def __repr__(self) -> str:
|
|
326
|
+
tier = "pro" if self.api_key else "free"
|
|
327
|
+
return f"DiffDelta(base_url={self.base_url!r}, tier={tier!r})"
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""Cursor persistence — automatically saves and loads polling state."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Dict, Optional
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
DEFAULT_CURSOR_DIR = os.path.join(Path.home(), ".diffdelta")
|
|
12
|
+
DEFAULT_CURSOR_FILE = "cursors.json"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class CursorStore:
|
|
16
|
+
"""Persists cursors to a local JSON file so bots survive restarts.
|
|
17
|
+
|
|
18
|
+
By default, cursors are saved to ~/.diffdelta/cursors.json.
|
|
19
|
+
Each feed URL gets its own cursor entry.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
def __init__(self, path: Optional[str] = None) -> None:
|
|
23
|
+
if path:
|
|
24
|
+
self._path = path
|
|
25
|
+
else:
|
|
26
|
+
self._path = os.path.join(DEFAULT_CURSOR_DIR, DEFAULT_CURSOR_FILE)
|
|
27
|
+
|
|
28
|
+
self._cursors: Dict[str, str] = {}
|
|
29
|
+
self._load()
|
|
30
|
+
|
|
31
|
+
def get(self, key: str) -> Optional[str]:
|
|
32
|
+
"""Get the stored cursor for a feed key."""
|
|
33
|
+
return self._cursors.get(key)
|
|
34
|
+
|
|
35
|
+
def set(self, key: str, cursor: str) -> None:
|
|
36
|
+
"""Save a cursor and persist to disk."""
|
|
37
|
+
self._cursors[key] = cursor
|
|
38
|
+
self._save()
|
|
39
|
+
|
|
40
|
+
def clear(self, key: Optional[str] = None) -> None:
|
|
41
|
+
"""Clear cursor(s). If key is None, clears all cursors."""
|
|
42
|
+
if key:
|
|
43
|
+
self._cursors.pop(key, None)
|
|
44
|
+
else:
|
|
45
|
+
self._cursors.clear()
|
|
46
|
+
self._save()
|
|
47
|
+
|
|
48
|
+
def _load(self) -> None:
|
|
49
|
+
"""Load cursors from disk."""
|
|
50
|
+
try:
|
|
51
|
+
if os.path.exists(self._path):
|
|
52
|
+
with open(self._path, "r") as f:
|
|
53
|
+
data = json.load(f)
|
|
54
|
+
if isinstance(data, dict):
|
|
55
|
+
self._cursors = data
|
|
56
|
+
except (json.JSONDecodeError, OSError):
|
|
57
|
+
# Corrupted file — start fresh
|
|
58
|
+
self._cursors = {}
|
|
59
|
+
|
|
60
|
+
def _save(self) -> None:
|
|
61
|
+
"""Persist cursors to disk."""
|
|
62
|
+
try:
|
|
63
|
+
os.makedirs(os.path.dirname(self._path), exist_ok=True)
|
|
64
|
+
with open(self._path, "w") as f:
|
|
65
|
+
json.dump(self._cursors, f, indent=2)
|
|
66
|
+
except OSError:
|
|
67
|
+
# Can't write — silently continue (in-memory only)
|
|
68
|
+
pass
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
"""Data models for DiffDelta feeds."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from datetime import datetime
|
|
7
|
+
from typing import Any, Dict, List, Optional
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass
|
|
11
|
+
class FeedItem:
|
|
12
|
+
"""A single item from a DiffDelta feed.
|
|
13
|
+
|
|
14
|
+
Attributes:
|
|
15
|
+
source: Source identifier (e.g. "cisa_kev", "nist_nvd").
|
|
16
|
+
id: Unique item ID within the source.
|
|
17
|
+
headline: Human/agent-readable headline.
|
|
18
|
+
url: Link to the original source.
|
|
19
|
+
excerpt: Summary text extracted from the source.
|
|
20
|
+
published_at: When the item was originally published.
|
|
21
|
+
updated_at: When the item was last updated.
|
|
22
|
+
bucket: Which change bucket: "new", "updated", or "removed".
|
|
23
|
+
provenance: Raw provenance data (fetched_at, evidence_urls, content_hash).
|
|
24
|
+
raw: The full raw item dict from the feed.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
source: str
|
|
28
|
+
id: str
|
|
29
|
+
headline: str
|
|
30
|
+
url: str = ""
|
|
31
|
+
excerpt: str = ""
|
|
32
|
+
published_at: Optional[str] = None
|
|
33
|
+
updated_at: Optional[str] = None
|
|
34
|
+
bucket: str = "new" # "new" | "updated" | "removed"
|
|
35
|
+
provenance: Dict[str, Any] = field(default_factory=dict)
|
|
36
|
+
raw: Dict[str, Any] = field(default_factory=dict)
|
|
37
|
+
|
|
38
|
+
@classmethod
|
|
39
|
+
def from_raw(cls, data: Dict[str, Any], bucket: str = "new") -> "FeedItem":
|
|
40
|
+
"""Create a FeedItem from a raw feed item dict."""
|
|
41
|
+
content = data.get("content", {})
|
|
42
|
+
excerpt = ""
|
|
43
|
+
if isinstance(content, dict):
|
|
44
|
+
excerpt = content.get("excerpt_text", "") or content.get("summary", "")
|
|
45
|
+
elif isinstance(content, str):
|
|
46
|
+
excerpt = content
|
|
47
|
+
|
|
48
|
+
return cls(
|
|
49
|
+
source=data.get("source", ""),
|
|
50
|
+
id=data.get("id", ""),
|
|
51
|
+
headline=data.get("headline", ""),
|
|
52
|
+
url=data.get("url", ""),
|
|
53
|
+
excerpt=excerpt,
|
|
54
|
+
published_at=data.get("published_at"),
|
|
55
|
+
updated_at=data.get("updated_at"),
|
|
56
|
+
bucket=bucket,
|
|
57
|
+
provenance=data.get("provenance", {}),
|
|
58
|
+
raw=data,
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
def __repr__(self) -> str:
|
|
62
|
+
return f"FeedItem(source={self.source!r}, id={self.id!r}, headline={self.headline!r}, bucket={self.bucket!r})"
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@dataclass
|
|
66
|
+
class Head:
|
|
67
|
+
"""The lightweight head pointer for change detection.
|
|
68
|
+
|
|
69
|
+
Attributes:
|
|
70
|
+
cursor: Opaque cursor string for change detection.
|
|
71
|
+
hash: Content hash of the latest feed.
|
|
72
|
+
changed: Whether content has changed since last generation.
|
|
73
|
+
generated_at: When this head was generated.
|
|
74
|
+
ttl_sec: Recommended polling interval in seconds.
|
|
75
|
+
"""
|
|
76
|
+
|
|
77
|
+
cursor: str
|
|
78
|
+
hash: str = ""
|
|
79
|
+
changed: bool = False
|
|
80
|
+
generated_at: str = ""
|
|
81
|
+
ttl_sec: int = 900
|
|
82
|
+
|
|
83
|
+
@classmethod
|
|
84
|
+
def from_raw(cls, data: Dict[str, Any]) -> "Head":
|
|
85
|
+
return cls(
|
|
86
|
+
cursor=data.get("cursor", ""),
|
|
87
|
+
hash=data.get("hash", ""),
|
|
88
|
+
changed=data.get("changed", False),
|
|
89
|
+
generated_at=data.get("generated_at", ""),
|
|
90
|
+
ttl_sec=data.get("ttl_sec", 900),
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
@dataclass
|
|
95
|
+
class Feed:
|
|
96
|
+
"""A full DiffDelta feed response.
|
|
97
|
+
|
|
98
|
+
Attributes:
|
|
99
|
+
cursor: The new cursor (save this for next poll).
|
|
100
|
+
prev_cursor: The previous cursor.
|
|
101
|
+
source_id: Source ID (if per-source feed) or "global".
|
|
102
|
+
generated_at: When this feed was generated.
|
|
103
|
+
items: All items across all buckets.
|
|
104
|
+
new: Items in the "new" bucket.
|
|
105
|
+
updated: Items in the "updated" bucket.
|
|
106
|
+
removed: Items in the "removed" bucket.
|
|
107
|
+
narrative: Human-readable summary of what changed.
|
|
108
|
+
raw: The full raw feed dict.
|
|
109
|
+
"""
|
|
110
|
+
|
|
111
|
+
cursor: str
|
|
112
|
+
prev_cursor: str = ""
|
|
113
|
+
source_id: str = ""
|
|
114
|
+
generated_at: str = ""
|
|
115
|
+
items: List[FeedItem] = field(default_factory=list)
|
|
116
|
+
new: List[FeedItem] = field(default_factory=list)
|
|
117
|
+
updated: List[FeedItem] = field(default_factory=list)
|
|
118
|
+
removed: List[FeedItem] = field(default_factory=list)
|
|
119
|
+
narrative: str = ""
|
|
120
|
+
raw: Dict[str, Any] = field(default_factory=dict)
|
|
121
|
+
|
|
122
|
+
@classmethod
|
|
123
|
+
def from_raw(cls, data: Dict[str, Any]) -> "Feed":
|
|
124
|
+
buckets = data.get("buckets", {})
|
|
125
|
+
|
|
126
|
+
new_items = [FeedItem.from_raw(i, "new") for i in buckets.get("new", [])]
|
|
127
|
+
updated_items = [FeedItem.from_raw(i, "updated") for i in buckets.get("updated", [])]
|
|
128
|
+
removed_items = [FeedItem.from_raw(i, "removed") for i in buckets.get("removed", [])]
|
|
129
|
+
all_items = new_items + updated_items + removed_items
|
|
130
|
+
|
|
131
|
+
return cls(
|
|
132
|
+
cursor=data.get("cursor", ""),
|
|
133
|
+
prev_cursor=data.get("prev_cursor", ""),
|
|
134
|
+
source_id=data.get("source_id", ""),
|
|
135
|
+
generated_at=data.get("generated_at", ""),
|
|
136
|
+
items=all_items,
|
|
137
|
+
new=new_items,
|
|
138
|
+
updated=updated_items,
|
|
139
|
+
removed=removed_items,
|
|
140
|
+
narrative=data.get("batch_narrative", ""),
|
|
141
|
+
raw=data,
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
@dataclass
|
|
146
|
+
class SourceInfo:
|
|
147
|
+
"""Metadata about an available DiffDelta source.
|
|
148
|
+
|
|
149
|
+
Attributes:
|
|
150
|
+
source_id: Unique source identifier (e.g. "cisa_kev").
|
|
151
|
+
name: Human-readable display name.
|
|
152
|
+
tags: List of tags (e.g. ["security"]).
|
|
153
|
+
description: Brief description of the source.
|
|
154
|
+
homepage: URL of the source's homepage.
|
|
155
|
+
enabled: Whether the source is currently active.
|
|
156
|
+
status: Health status ("ok", "degraded", "error").
|
|
157
|
+
head_url: Path to the source's head.json.
|
|
158
|
+
latest_url: Path to the source's latest.json.
|
|
159
|
+
"""
|
|
160
|
+
|
|
161
|
+
source_id: str
|
|
162
|
+
name: str
|
|
163
|
+
tags: List[str] = field(default_factory=list)
|
|
164
|
+
description: str = ""
|
|
165
|
+
homepage: str = ""
|
|
166
|
+
enabled: bool = True
|
|
167
|
+
status: str = "ok"
|
|
168
|
+
head_url: str = ""
|
|
169
|
+
latest_url: str = ""
|
|
170
|
+
|
|
171
|
+
@classmethod
|
|
172
|
+
def from_raw(cls, data: Dict[str, Any]) -> "SourceInfo":
|
|
173
|
+
return cls(
|
|
174
|
+
source_id=data.get("source_id", ""),
|
|
175
|
+
name=data.get("name", ""),
|
|
176
|
+
tags=data.get("tags", []),
|
|
177
|
+
description=data.get("description", ""),
|
|
178
|
+
homepage=data.get("homepage", ""),
|
|
179
|
+
enabled=data.get("enabled", True),
|
|
180
|
+
status=data.get("status", "ok"),
|
|
181
|
+
head_url=data.get("head_url", ""),
|
|
182
|
+
latest_url=data.get("latest_url", ""),
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
def __repr__(self) -> str:
|
|
186
|
+
return f"SourceInfo(source_id={self.source_id!r}, name={self.name!r}, status={self.status!r})"
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: diffdelta
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Python client for DiffDelta — agent-ready intelligence feeds
|
|
5
|
+
Author-email: DiffDelta <human@diffdelta.io>
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://diffdelta.io
|
|
8
|
+
Project-URL: Documentation, https://diffdelta.io/#quickstart
|
|
9
|
+
Project-URL: Repository, https://github.com/diffdelta/diffdelta-python
|
|
10
|
+
Project-URL: Issues, https://github.com/diffdelta/diffdelta-python/issues
|
|
11
|
+
Keywords: diffdelta,ai-agents,security,changefeed,intelligence
|
|
12
|
+
Classifier: Development Status :: 4 - Beta
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.8
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
22
|
+
Classifier: Topic :: Security
|
|
23
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
24
|
+
Requires-Python: >=3.8
|
|
25
|
+
Requires-Dist: requests>=2.25.0
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
pyproject.toml
|
|
2
|
+
diffdelta/__init__.py
|
|
3
|
+
diffdelta/client.py
|
|
4
|
+
diffdelta/cursor.py
|
|
5
|
+
diffdelta/models.py
|
|
6
|
+
diffdelta.egg-info/PKG-INFO
|
|
7
|
+
diffdelta.egg-info/SOURCES.txt
|
|
8
|
+
diffdelta.egg-info/dependency_links.txt
|
|
9
|
+
diffdelta.egg-info/requires.txt
|
|
10
|
+
diffdelta.egg-info/top_level.txt
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
requests>=2.25.0
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
diffdelta
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68.0", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "diffdelta"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Python client for DiffDelta — agent-ready intelligence feeds"
|
|
9
|
+
requires-python = ">=3.8"
|
|
10
|
+
license = {text = "MIT"}
|
|
11
|
+
authors = [{name = "DiffDelta", email = "human@diffdelta.io"}]
|
|
12
|
+
keywords = ["diffdelta", "ai-agents", "security", "changefeed", "intelligence"]
|
|
13
|
+
classifiers = [
|
|
14
|
+
"Development Status :: 4 - Beta",
|
|
15
|
+
"Intended Audience :: Developers",
|
|
16
|
+
"License :: OSI Approved :: MIT License",
|
|
17
|
+
"Programming Language :: Python :: 3",
|
|
18
|
+
"Programming Language :: Python :: 3.8",
|
|
19
|
+
"Programming Language :: Python :: 3.9",
|
|
20
|
+
"Programming Language :: Python :: 3.10",
|
|
21
|
+
"Programming Language :: Python :: 3.11",
|
|
22
|
+
"Programming Language :: Python :: 3.12",
|
|
23
|
+
"Programming Language :: Python :: 3.13",
|
|
24
|
+
"Topic :: Security",
|
|
25
|
+
"Topic :: Software Development :: Libraries",
|
|
26
|
+
]
|
|
27
|
+
dependencies = [
|
|
28
|
+
"requests>=2.25.0",
|
|
29
|
+
]
|
|
30
|
+
|
|
31
|
+
[project.urls]
|
|
32
|
+
Homepage = "https://diffdelta.io"
|
|
33
|
+
Documentation = "https://diffdelta.io/#quickstart"
|
|
34
|
+
Repository = "https://github.com/diffdelta/diffdelta-python"
|
|
35
|
+
Issues = "https://github.com/diffdelta/diffdelta-python/issues"
|
|
36
|
+
|
|
37
|
+
[tool.setuptools.packages.find]
|
|
38
|
+
include = ["diffdelta*"]
|