feedo-sdk 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.
- feedo_sdk-0.1.0/PKG-INFO +11 -0
- feedo_sdk-0.1.0/feedo/__init__.py +4 -0
- feedo_sdk-0.1.0/feedo/client.py +13 -0
- feedo_sdk-0.1.0/feedo/modules/__init__.py +1 -0
- feedo_sdk-0.1.0/feedo/modules/consensus.py +46 -0
- feedo_sdk-0.1.0/feedo/modules/search.py +40 -0
- feedo_sdk-0.1.0/feedo/modules/storage.py +44 -0
- feedo_sdk-0.1.0/feedo/router.py +65 -0
- feedo_sdk-0.1.0/feedo_sdk.egg-info/PKG-INFO +11 -0
- feedo_sdk-0.1.0/feedo_sdk.egg-info/SOURCES.txt +14 -0
- feedo_sdk-0.1.0/feedo_sdk.egg-info/dependency_links.txt +1 -0
- feedo_sdk-0.1.0/feedo_sdk.egg-info/requires.txt +1 -0
- feedo_sdk-0.1.0/feedo_sdk.egg-info/top_level.txt +1 -0
- feedo_sdk-0.1.0/setup.cfg +4 -0
- feedo_sdk-0.1.0/setup.py +13 -0
- feedo_sdk-0.1.0/tests/test_router.py +47 -0
feedo_sdk-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: feedo-sdk
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: The official Developer SDK for Feedo Network
|
|
5
|
+
Author: Feedo Network
|
|
6
|
+
Requires-Python: >=3.8
|
|
7
|
+
Requires-Dist: httpx>=0.24.0
|
|
8
|
+
Dynamic: author
|
|
9
|
+
Dynamic: requires-dist
|
|
10
|
+
Dynamic: requires-python
|
|
11
|
+
Dynamic: summary
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
from typing import List, Optional
|
|
2
|
+
from .router import NodeRouter
|
|
3
|
+
from .modules.search import SearchModule
|
|
4
|
+
from .modules.consensus import ConsensusModule
|
|
5
|
+
from .modules.storage import StorageModule
|
|
6
|
+
|
|
7
|
+
class FeedoClient:
|
|
8
|
+
def __init__(self, search_seeds: Optional[List[str]] = None, consensus_seeds: Optional[List[str]] = None, storage_seeds: Optional[List[str]] = None):
|
|
9
|
+
self.router = NodeRouter(search_seeds, consensus_seeds, storage_seeds)
|
|
10
|
+
|
|
11
|
+
self.search = SearchModule(self.router)
|
|
12
|
+
self.consensus = ConsensusModule(self.router)
|
|
13
|
+
self.storage = StorageModule(self.router)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# Init modules
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import httpx
|
|
2
|
+
from typing import Dict, Any, Optional
|
|
3
|
+
from ..router import NodeRouter
|
|
4
|
+
|
|
5
|
+
class ConsensusModule:
|
|
6
|
+
def __init__(self, router: NodeRouter):
|
|
7
|
+
self.router = router
|
|
8
|
+
|
|
9
|
+
async def _request(self, method: str, path: str, json: Optional[Dict] = None) -> Any:
|
|
10
|
+
base_url = await self.router.get_consensus_node()
|
|
11
|
+
url = f"{base_url}{path}"
|
|
12
|
+
|
|
13
|
+
async with httpx.AsyncClient() as client:
|
|
14
|
+
try:
|
|
15
|
+
response = await client.request(method, url, json=json)
|
|
16
|
+
response.raise_for_status()
|
|
17
|
+
return response.json()
|
|
18
|
+
except Exception:
|
|
19
|
+
print(f"Consensus request failed on {base_url}, finding new node...")
|
|
20
|
+
self.router.invalidate_consensus_node()
|
|
21
|
+
base_url = await self.router.get_consensus_node()
|
|
22
|
+
url = f"{base_url}{path}"
|
|
23
|
+
response = await client.request(method, url, json=json)
|
|
24
|
+
response.raise_for_status()
|
|
25
|
+
return response.json()
|
|
26
|
+
|
|
27
|
+
async def resolve_name(self, name: str):
|
|
28
|
+
return await self._request("GET", f"/resolve/{name}")
|
|
29
|
+
|
|
30
|
+
async def resolve_cid(self, cid: str):
|
|
31
|
+
return await self._request("GET", f"/resolve_cid/{cid}")
|
|
32
|
+
|
|
33
|
+
async def get_did_balance(self, did: str):
|
|
34
|
+
return await self._request("GET", f"/did/{did}/balance")
|
|
35
|
+
|
|
36
|
+
async def register_did(self, pubkey_hex: str, signature_hex: str):
|
|
37
|
+
return await self._request("POST", "/did/register", json={"pubkey_hex": pubkey_hex, "signature_hex": signature_hex})
|
|
38
|
+
|
|
39
|
+
async def register_name(self, name: str, did: str, cid: str, signature_hex: str):
|
|
40
|
+
return await self._request("POST", "/name/register", json={"name": name, "did": did, "cid": cid, "signature_hex": signature_hex})
|
|
41
|
+
|
|
42
|
+
async def update_name_cid(self, name: str, new_cid: str, signature_hex: str):
|
|
43
|
+
return await self._request("POST", "/name/update_cid", json={"name": name, "new_cid": new_cid, "signature_hex": signature_hex})
|
|
44
|
+
|
|
45
|
+
async def list_grants(self):
|
|
46
|
+
return await self._request("GET", "/grants")
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import httpx
|
|
2
|
+
from typing import Dict, Any, Optional
|
|
3
|
+
from ..router import NodeRouter
|
|
4
|
+
|
|
5
|
+
class SearchModule:
|
|
6
|
+
def __init__(self, router: NodeRouter):
|
|
7
|
+
self.router = router
|
|
8
|
+
|
|
9
|
+
async def _request(self, method: str, path: str, json: Optional[Dict] = None, params: Optional[Dict] = None) -> Any:
|
|
10
|
+
base_url = await self.router.get_search_node()
|
|
11
|
+
url = f"{base_url}{path}"
|
|
12
|
+
|
|
13
|
+
async with httpx.AsyncClient() as client:
|
|
14
|
+
try:
|
|
15
|
+
response = await client.request(method, url, json=json, params=params)
|
|
16
|
+
response.raise_for_status()
|
|
17
|
+
return response.json()
|
|
18
|
+
except Exception as e:
|
|
19
|
+
print(f"Search request failed on {base_url}, finding new node...")
|
|
20
|
+
self.router.invalidate_search_node()
|
|
21
|
+
base_url = await self.router.get_search_node()
|
|
22
|
+
url = f"{base_url}{path}"
|
|
23
|
+
response = await client.request(method, url, json=json, params=params)
|
|
24
|
+
response.raise_for_status()
|
|
25
|
+
return response.json()
|
|
26
|
+
|
|
27
|
+
async def query(self, query_text: str, limit: int = 10):
|
|
28
|
+
return await self._request("GET", "/query", params={"q": query_text, "limit": limit})
|
|
29
|
+
|
|
30
|
+
async def index_document(self, content: str, metadata: Optional[Dict] = None):
|
|
31
|
+
return await self._request("POST", "/index_document", json={"content": content, "metadata": metadata or {}})
|
|
32
|
+
|
|
33
|
+
async def deploy_proxy(self, directory_path: str, domain: str):
|
|
34
|
+
return await self._request("POST", "/proxy/publish_feedo", json={"source_dir": directory_path, "domain": domain})
|
|
35
|
+
|
|
36
|
+
async def unpin(self, cid: str):
|
|
37
|
+
return await self._request("DELETE", f"/proxy/unpin_feedo/{cid}")
|
|
38
|
+
|
|
39
|
+
async def get_stats(self):
|
|
40
|
+
return await self._request("GET", "/explorer/stats")
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import httpx
|
|
2
|
+
from typing import Dict, Any, Optional
|
|
3
|
+
from ..router import NodeRouter
|
|
4
|
+
|
|
5
|
+
class StorageModule:
|
|
6
|
+
def __init__(self, router: NodeRouter):
|
|
7
|
+
self.router = router
|
|
8
|
+
|
|
9
|
+
async def _request(self, method: str, path: str, json: Optional[Dict] = None, data: Any = None, files: Any = None) -> Any:
|
|
10
|
+
base_url = await self.router.get_storage_node()
|
|
11
|
+
url = f"{base_url}{path}"
|
|
12
|
+
|
|
13
|
+
async with httpx.AsyncClient() as client:
|
|
14
|
+
try:
|
|
15
|
+
response = await client.request(method, url, json=json, data=data, files=files)
|
|
16
|
+
response.raise_for_status()
|
|
17
|
+
# download endpoint might not return json
|
|
18
|
+
if response.headers.get("content-type") == "application/json":
|
|
19
|
+
return response.json()
|
|
20
|
+
return response.content
|
|
21
|
+
except Exception:
|
|
22
|
+
print(f"Storage request failed on {base_url}, finding new node...")
|
|
23
|
+
self.router.invalidate_storage_node()
|
|
24
|
+
base_url = await self.router.get_storage_node()
|
|
25
|
+
url = f"{base_url}{path}"
|
|
26
|
+
response = await client.request(method, url, json=json, data=data, files=files)
|
|
27
|
+
response.raise_for_status()
|
|
28
|
+
if response.headers.get("content-type") == "application/json":
|
|
29
|
+
return response.json()
|
|
30
|
+
return response.content
|
|
31
|
+
|
|
32
|
+
async def upload_file(self, file_path: str, filename: str = "file"):
|
|
33
|
+
with open(file_path, "rb") as f:
|
|
34
|
+
files = {"file": (filename, f)}
|
|
35
|
+
return await self._request("POST", "/upload", files=files)
|
|
36
|
+
|
|
37
|
+
async def download_file(self, hash_id: str) -> bytes:
|
|
38
|
+
return await self._request("GET", f"/download/{hash_id}")
|
|
39
|
+
|
|
40
|
+
async def ingest_json(self, payload: Dict):
|
|
41
|
+
return await self._request("POST", "/api/v1/ingest/post", json=payload)
|
|
42
|
+
|
|
43
|
+
async def get_recent_files(self):
|
|
44
|
+
return await self._request("GET", "/api/files/recent")
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import httpx
|
|
2
|
+
import asyncio
|
|
3
|
+
from typing import List, Optional, Dict
|
|
4
|
+
|
|
5
|
+
DEFAULT_SEEDS = {
|
|
6
|
+
"search": ["http://localhost:8000"],
|
|
7
|
+
"consensus": ["http://localhost:8080"],
|
|
8
|
+
"storage": ["http://localhost:8081"]
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
class NodeRouter:
|
|
12
|
+
def __init__(self, search_seeds: Optional[List[str]] = None, consensus_seeds: Optional[List[str]] = None, storage_seeds: Optional[List[str]] = None):
|
|
13
|
+
self.search_nodes = search_seeds or DEFAULT_SEEDS["search"]
|
|
14
|
+
self.consensus_nodes = consensus_seeds or DEFAULT_SEEDS["consensus"]
|
|
15
|
+
self.storage_nodes = storage_seeds or DEFAULT_SEEDS["storage"]
|
|
16
|
+
|
|
17
|
+
self._active_search_node = None
|
|
18
|
+
self._active_consensus_node = None
|
|
19
|
+
self._active_storage_node = None
|
|
20
|
+
|
|
21
|
+
async def _find_fastest_node(self, nodes: List[str], health_endpoint: str) -> str:
|
|
22
|
+
async def ping(node: str) -> str:
|
|
23
|
+
async with httpx.AsyncClient() as client:
|
|
24
|
+
url = f"{node}{health_endpoint}"
|
|
25
|
+
response = await client.get(url, timeout=3.0)
|
|
26
|
+
response.raise_for_status()
|
|
27
|
+
return node
|
|
28
|
+
|
|
29
|
+
tasks = [asyncio.create_task(ping(node)) for node in nodes]
|
|
30
|
+
|
|
31
|
+
while tasks:
|
|
32
|
+
done, pending = await asyncio.wait(tasks, return_when=asyncio.FIRST_COMPLETED)
|
|
33
|
+
for task in done:
|
|
34
|
+
try:
|
|
35
|
+
return task.result()
|
|
36
|
+
except Exception:
|
|
37
|
+
pass
|
|
38
|
+
tasks = list(pending)
|
|
39
|
+
|
|
40
|
+
print(f"Warning: All seed nodes failed. Falling back to {nodes[0]}")
|
|
41
|
+
return nodes[0]
|
|
42
|
+
|
|
43
|
+
async def get_search_node(self) -> str:
|
|
44
|
+
if not self._active_search_node:
|
|
45
|
+
self._active_search_node = await self._find_fastest_node(self.search_nodes, "/explorer/stats")
|
|
46
|
+
return self._active_search_node
|
|
47
|
+
|
|
48
|
+
async def get_consensus_node(self) -> str:
|
|
49
|
+
if not self._active_consensus_node:
|
|
50
|
+
self._active_consensus_node = await self._find_fastest_node(self.consensus_nodes, "/grants")
|
|
51
|
+
return self._active_consensus_node
|
|
52
|
+
|
|
53
|
+
async def get_storage_node(self) -> str:
|
|
54
|
+
if not self._active_storage_node:
|
|
55
|
+
self._active_storage_node = await self._find_fastest_node(self.storage_nodes, "/api/files/recent")
|
|
56
|
+
return self._active_storage_node
|
|
57
|
+
|
|
58
|
+
def invalidate_search_node(self):
|
|
59
|
+
self._active_search_node = None
|
|
60
|
+
|
|
61
|
+
def invalidate_consensus_node(self):
|
|
62
|
+
self._active_consensus_node = None
|
|
63
|
+
|
|
64
|
+
def invalidate_storage_node(self):
|
|
65
|
+
self._active_storage_node = None
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: feedo-sdk
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: The official Developer SDK for Feedo Network
|
|
5
|
+
Author: Feedo Network
|
|
6
|
+
Requires-Python: >=3.8
|
|
7
|
+
Requires-Dist: httpx>=0.24.0
|
|
8
|
+
Dynamic: author
|
|
9
|
+
Dynamic: requires-dist
|
|
10
|
+
Dynamic: requires-python
|
|
11
|
+
Dynamic: summary
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
setup.py
|
|
2
|
+
feedo/__init__.py
|
|
3
|
+
feedo/client.py
|
|
4
|
+
feedo/router.py
|
|
5
|
+
feedo/modules/__init__.py
|
|
6
|
+
feedo/modules/consensus.py
|
|
7
|
+
feedo/modules/search.py
|
|
8
|
+
feedo/modules/storage.py
|
|
9
|
+
feedo_sdk.egg-info/PKG-INFO
|
|
10
|
+
feedo_sdk.egg-info/SOURCES.txt
|
|
11
|
+
feedo_sdk.egg-info/dependency_links.txt
|
|
12
|
+
feedo_sdk.egg-info/requires.txt
|
|
13
|
+
feedo_sdk.egg-info/top_level.txt
|
|
14
|
+
tests/test_router.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
httpx>=0.24.0
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
feedo
|
feedo_sdk-0.1.0/setup.py
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
from setuptools import setup, find_packages
|
|
2
|
+
|
|
3
|
+
setup(
|
|
4
|
+
name="feedo-sdk",
|
|
5
|
+
version="0.1.0",
|
|
6
|
+
description="The official Developer SDK for Feedo Network",
|
|
7
|
+
author="Feedo Network",
|
|
8
|
+
packages=find_packages(),
|
|
9
|
+
install_requires=[
|
|
10
|
+
"httpx>=0.24.0",
|
|
11
|
+
],
|
|
12
|
+
python_requires=">=3.8",
|
|
13
|
+
)
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import pytest
|
|
2
|
+
import respx
|
|
3
|
+
import httpx
|
|
4
|
+
import asyncio
|
|
5
|
+
from feedo.router import NodeRouter
|
|
6
|
+
|
|
7
|
+
@pytest.mark.asyncio
|
|
8
|
+
async def test_find_fastest_node_all_up():
|
|
9
|
+
router = NodeRouter(search_seeds=["http://node1", "http://node2"])
|
|
10
|
+
|
|
11
|
+
with respx.mock:
|
|
12
|
+
# Mock node1 to respond instantly
|
|
13
|
+
route1 = respx.get("http://node1/explorer/stats").mock(return_value=httpx.Response(200, json={}))
|
|
14
|
+
|
|
15
|
+
# Mock node2 to be slightly delayed (not natively easy in respx without custom side effects,
|
|
16
|
+
# so we just let them both return and asyncio.wait handles whichever resolves first - since route1 is first,
|
|
17
|
+
# or we can mock one to raise a timeout and the other to succeed)
|
|
18
|
+
|
|
19
|
+
route2 = respx.get("http://node2/explorer/stats").mock(side_effect=httpx.TimeoutException("Timeout"))
|
|
20
|
+
|
|
21
|
+
active_node = await router.get_search_node()
|
|
22
|
+
assert active_node == "http://node1"
|
|
23
|
+
|
|
24
|
+
@pytest.mark.asyncio
|
|
25
|
+
async def test_fallback_when_one_down():
|
|
26
|
+
router = NodeRouter(search_seeds=["http://node1", "http://node2"])
|
|
27
|
+
|
|
28
|
+
with respx.mock:
|
|
29
|
+
# node1 fails
|
|
30
|
+
respx.get("http://node1/explorer/stats").mock(return_value=httpx.Response(500))
|
|
31
|
+
# node2 succeeds
|
|
32
|
+
respx.get("http://node2/explorer/stats").mock(return_value=httpx.Response(200, json={}))
|
|
33
|
+
|
|
34
|
+
active_node = await router.get_search_node()
|
|
35
|
+
assert active_node == "http://node2"
|
|
36
|
+
|
|
37
|
+
@pytest.mark.asyncio
|
|
38
|
+
async def test_fallback_when_all_down():
|
|
39
|
+
router = NodeRouter(search_seeds=["http://node1", "http://node2"])
|
|
40
|
+
|
|
41
|
+
with respx.mock:
|
|
42
|
+
respx.get("http://node1/explorer/stats").mock(side_effect=httpx.ConnectError("Down"))
|
|
43
|
+
respx.get("http://node2/explorer/stats").mock(side_effect=httpx.ConnectError("Down"))
|
|
44
|
+
|
|
45
|
+
active_node = await router.get_search_node()
|
|
46
|
+
# Should fallback to the first node if all fail
|
|
47
|
+
assert active_node == "http://node1"
|