koi-net 1.0.0b1__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.

Potentially problematic release.


This version of koi-net might be problematic. Click here for more details.

@@ -0,0 +1,6 @@
1
+ rid-lib
2
+ __pycache__
3
+ *.json
4
+ venv
5
+ prototypes
6
+ .vscode
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 BlockScience
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,43 @@
1
+ Metadata-Version: 2.4
2
+ Name: koi-net
3
+ Version: 1.0.0b1
4
+ Summary: Implementation of KOI-net protocol in Python
5
+ Project-URL: Homepage, https://github.com/BlockScience/koi-net/
6
+ Author-email: Luke Miller <luke@block.science>
7
+ License: MIT License
8
+
9
+ Copyright (c) 2025 BlockScience
10
+
11
+ Permission is hereby granted, free of charge, to any person obtaining a copy
12
+ of this software and associated documentation files (the "Software"), to deal
13
+ in the Software without restriction, including without limitation the rights
14
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
15
+ copies of the Software, and to permit persons to whom the Software is
16
+ furnished to do so, subject to the following conditions:
17
+
18
+ The above copyright notice and this permission notice shall be included in all
19
+ copies or substantial portions of the Software.
20
+
21
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
22
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
23
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
24
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
25
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
26
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
27
+ SOFTWARE.
28
+ License-File: LICENSE
29
+ Requires-Python: >=3.10
30
+ Requires-Dist: httpx>=0.28.1
31
+ Requires-Dist: networkx>=3.4.2
32
+ Requires-Dist: pydantic>=2.10.6
33
+ Requires-Dist: rid-lib>=3.2.1
34
+ Provides-Extra: dev
35
+ Requires-Dist: build; extra == 'dev'
36
+ Requires-Dist: twine>=6.0; extra == 'dev'
37
+ Provides-Extra: examples
38
+ Requires-Dist: fastapi; extra == 'examples'
39
+ Requires-Dist: rich; extra == 'examples'
40
+ Requires-Dist: uvicorn; extra == 'examples'
41
+ Description-Content-Type: text/markdown
42
+
43
+ # koi-net
@@ -0,0 +1 @@
1
+ # koi-net
@@ -0,0 +1,134 @@
1
+ import json
2
+ import logging
3
+ import uvicorn
4
+ from contextlib import asynccontextmanager
5
+ from rich.logging import RichHandler
6
+ from fastapi import FastAPI, BackgroundTasks
7
+ from rid_lib.types import KoiNetNode, KoiNetEdge
8
+ from koi_net import NodeInterface
9
+ from koi_net.processor.handler import HandlerType
10
+ from koi_net.processor.knowledge_object import KnowledgeObject, KnowledgeSource
11
+ from koi_net.protocol.edge import EdgeType
12
+ from koi_net.protocol.event import Event, EventType
13
+ from koi_net.protocol.helpers import generate_edge_bundle
14
+ from koi_net.protocol.node import NodeProfile, NodeType, NodeProvides
15
+ from koi_net.processor import ProcessorInterface
16
+ from koi_net.protocol.api_models import (
17
+ PollEvents,
18
+ FetchRids,
19
+ FetchManifests,
20
+ FetchBundles,
21
+ EventsPayload,
22
+ RidsPayload,
23
+ ManifestsPayload,
24
+ BundlesPayload
25
+ )
26
+ from koi_net.protocol.consts import (
27
+ BROADCAST_EVENTS_PATH,
28
+ POLL_EVENTS_PATH,
29
+ FETCH_RIDS_PATH,
30
+ FETCH_MANIFESTS_PATH,
31
+ FETCH_BUNDLES_PATH
32
+ )
33
+
34
+
35
+ logging.basicConfig(
36
+ level=logging.INFO,
37
+ format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
38
+ datefmt="%Y-%m-%d %H:%M:%S",
39
+ handlers=[RichHandler()]
40
+ )
41
+
42
+ logging.getLogger("koi_net").setLevel(logging.DEBUG)
43
+
44
+ port = 8000
45
+
46
+ node = NodeInterface(
47
+ name="coordinator",
48
+ profile=NodeProfile(
49
+ base_url=f"http://127.0.0.1:{port}/koi-net",
50
+ node_type=NodeType.FULL,
51
+ provides=NodeProvides(
52
+ event=[KoiNetNode, KoiNetEdge],
53
+ state=[KoiNetNode, KoiNetEdge]
54
+ )
55
+ ),
56
+ identity_file_path="coordinator_identity.json"
57
+ )
58
+
59
+
60
+ logger = logging.getLogger(__name__)
61
+
62
+
63
+ @node.processor.register_handler(HandlerType.Network, rid_types=[KoiNetNode])
64
+ def handshake_handler(proc: ProcessorInterface, kobj: KnowledgeObject):
65
+ logger.info("Handling node handshake")
66
+
67
+ # only respond if node declares itself as NEW
68
+ if kobj.event_type != EventType.NEW:
69
+ return
70
+
71
+ logger.info("Sharing this node's bundle with peer")
72
+ proc.network.push_event_to(
73
+ event=Event.from_bundle(EventType.NEW, proc.identity.bundle),
74
+ node=kobj.rid,
75
+ flush=True
76
+ )
77
+
78
+ logger.info("Proposing new edge")
79
+ # defer handling of proposed edge
80
+ proc.handle(bundle=generate_edge_bundle(
81
+ source=kobj.rid,
82
+ target=proc.identity.rid,
83
+ edge_type=EdgeType.WEBHOOK,
84
+ rid_types=[KoiNetNode, KoiNetEdge]
85
+ ))
86
+
87
+
88
+
89
+ @asynccontextmanager
90
+ async def lifespan(app: FastAPI):
91
+ node.initialize()
92
+ yield
93
+ node.finalize()
94
+
95
+ app = FastAPI(
96
+ lifespan=lifespan,
97
+ root_path="/koi-net",
98
+ title="KOI-net Protocol API",
99
+ version="1.0.0"
100
+ )
101
+
102
+ @app.post(BROADCAST_EVENTS_PATH)
103
+ def broadcast_events(req: EventsPayload, background: BackgroundTasks):
104
+ logger.info(f"Request to {BROADCAST_EVENTS_PATH}, received {len(req.events)} event(s)")
105
+ for event in req.events:
106
+ node.processor.handle(event=event, source=KnowledgeSource.External)
107
+
108
+ background.add_task(node.processor.flush_kobj_queue)
109
+
110
+ @app.post(POLL_EVENTS_PATH)
111
+ def poll_events(req: PollEvents) -> EventsPayload:
112
+ logger.info(f"Request to {POLL_EVENTS_PATH}")
113
+ events = node.network.flush_poll_queue(req.rid)
114
+ return EventsPayload(events=events)
115
+
116
+ @app.post(FETCH_RIDS_PATH)
117
+ def fetch_rids(req: FetchRids) -> RidsPayload:
118
+ return node.network.response_handler.fetch_rids(req)
119
+
120
+ @app.post(FETCH_MANIFESTS_PATH)
121
+ def fetch_manifests(req: FetchManifests) -> ManifestsPayload:
122
+ return node.network.response_handler.fetch_manifests(req)
123
+
124
+ @app.post(FETCH_BUNDLES_PATH)
125
+ def fetch_bundles(req: FetchBundles) -> BundlesPayload:
126
+ return node.network.response_handler.fetch_bundles(req)
127
+
128
+ openapi_spec = app.openapi()
129
+
130
+ with open("koi-net-protocol-openapi.json", "w") as f:
131
+ json.dump(openapi_spec, f, indent=2)
132
+
133
+ if __name__ == "__main__":
134
+ uvicorn.run("examples.full_node:app", port=port)
@@ -0,0 +1,82 @@
1
+ import time
2
+ import logging
3
+ from rich.logging import RichHandler
4
+ from koi_net import NodeInterface
5
+ from koi_net.processor.handler import HandlerType
6
+ from koi_net.processor.knowledge_object import KnowledgeSource, KnowledgeObject
7
+ from koi_net.processor.interface import ProcessorInterface
8
+ from koi_net.protocol.event import EventType
9
+ from koi_net.protocol.edge import EdgeType
10
+ from koi_net.protocol.node import NodeProfile, NodeType
11
+ from koi_net.protocol.helpers import generate_edge_bundle
12
+ from rid_lib.types import KoiNetNode
13
+
14
+ logging.basicConfig(
15
+ level=logging.INFO,
16
+ format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
17
+ datefmt="%Y-%m-%d %H:%M:%S",
18
+ handlers=[RichHandler()]
19
+ )
20
+
21
+ logging.getLogger("koi_net").setLevel(logging.DEBUG)
22
+ logger = logging.getLogger(__name__)
23
+
24
+
25
+ node = NodeInterface(
26
+ name="partial",
27
+ profile=NodeProfile(
28
+ node_type=NodeType.PARTIAL
29
+ ),
30
+ identity_file_path="partial_identity.json",
31
+ first_contact="http://127.0.0.1:8000/koi-net"
32
+ )
33
+
34
+ @node.processor.register_handler(HandlerType.Network, rid_types=[KoiNetNode])
35
+ def coordinator_contact(processor: ProcessorInterface, kobj: KnowledgeObject):
36
+ # when I found out about a new node
37
+ if kobj.normalized_event_type != EventType.NEW:
38
+ return
39
+
40
+ node_profile = kobj.bundle.validate_contents(NodeProfile)
41
+
42
+ # looking for event provider of nodes
43
+ if KoiNetNode not in node_profile.provides.event:
44
+ return
45
+
46
+ logger.info("Identified a coordinator!")
47
+ logger.info("Proposing new edge")
48
+
49
+ # queued for processing
50
+ processor.handle(bundle=generate_edge_bundle(
51
+ source=kobj.rid,
52
+ target=node.identity.rid,
53
+ edge_type=EdgeType.POLL,
54
+ rid_types=[KoiNetNode]
55
+ ))
56
+
57
+ logger.info("Catching up on network state")
58
+
59
+ payload = processor.network.request_handler.fetch_rids(kobj.rid, rid_types=[KoiNetNode])
60
+ for rid in payload.rids:
61
+ if rid == processor.identity.rid:
62
+ logger.info("Skipping myself")
63
+ continue
64
+ if processor.cache.exists(rid):
65
+ logger.info(f"Skipping known RID '{rid}'")
66
+ continue
67
+
68
+ # marked as external since we are handling RIDs from another node
69
+ # will fetch remotely instead of checking local cache
70
+ processor.handle(rid=rid, source=KnowledgeSource.External)
71
+ logger.info("Done")
72
+
73
+
74
+
75
+ node.initialize()
76
+
77
+ while True:
78
+ for event in node.network.poll_neighbors():
79
+ node.processor.handle(event=event, source=KnowledgeSource.External)
80
+ node.processor.flush_kobj_queue()
81
+
82
+ time.sleep(1)
@@ -0,0 +1,31 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "koi-net"
7
+ version = "1.0.0-beta.1"
8
+ description = "Implementation of KOI-net protocol in Python"
9
+ authors = [
10
+ {name = "Luke Miller", email = "luke@block.science"}
11
+ ]
12
+ readme = "README.md"
13
+ requires-python = ">=3.10"
14
+ license = {file = "LICENSE"}
15
+ dependencies = [
16
+ "rid-lib>=3.2.1",
17
+ "networkx>=3.4.2",
18
+ "httpx>=0.28.1",
19
+ "pydantic>=2.10.6"
20
+ ]
21
+
22
+ [project.optional-dependencies]
23
+ dev = ["twine>=6.0", "build"]
24
+ examples = [
25
+ "rich",
26
+ "fastapi",
27
+ "uvicorn"
28
+ ]
29
+
30
+ [project.urls]
31
+ Homepage = "https://github.com/BlockScience/koi-net/"
@@ -0,0 +1,9 @@
1
+ networkx>=3.4.2
2
+ rid-lib>=3.2.1
3
+ httpx>=0.28.1
4
+ pydantic>=2.10.6
5
+
6
+ # requirements for examples/
7
+ rich
8
+ fastapi
9
+ uvicorn
@@ -0,0 +1 @@
1
+ from .core import NodeInterface
@@ -0,0 +1,86 @@
1
+ import logging
2
+ import httpx
3
+ from rid_lib.ext import Cache, Bundle
4
+ from .network import NetworkInterface
5
+ from .processor import ProcessorInterface
6
+ from .processor import default_handlers as _default_handlers
7
+ from .processor.handler import KnowledgeHandler
8
+ from .identity import NodeIdentity
9
+ from .protocol.node import NodeProfile
10
+ from .protocol.event import Event, EventType
11
+
12
+ logger = logging.getLogger(__name__)
13
+
14
+ class NodeInterface:
15
+ def __init__(
16
+ self,
17
+ name: str,
18
+ profile: NodeProfile,
19
+ identity_file_path: str = "identity.json",
20
+ first_contact: str | None = None,
21
+ default_handlers: list[KnowledgeHandler] | None = None,
22
+ cache: Cache | None = None,
23
+ network: NetworkInterface | None = None,
24
+ processor: ProcessorInterface | None = None
25
+ ):
26
+ self.cache = cache or Cache(directory_path=f"{name}_cache")
27
+ self.identity = NodeIdentity(
28
+ name=name,
29
+ profile=profile,
30
+ cache=self.cache,
31
+ file_path=identity_file_path
32
+ )
33
+ self.first_contact = first_contact
34
+ self.network = network or NetworkInterface(
35
+ file_path=f"{self.identity.rid.name}_event_queues.json",
36
+ first_contact=self.first_contact,
37
+ cache=self.cache,
38
+ identity=self.identity
39
+ )
40
+
41
+ # pull all handlers defined in default_handlers module
42
+ if not default_handlers:
43
+ default_handlers = [
44
+ obj for obj in vars(_default_handlers).values()
45
+ if isinstance(obj, KnowledgeHandler)
46
+ ]
47
+
48
+ self.processor = processor or ProcessorInterface(
49
+ cache=self.cache,
50
+ network=self.network,
51
+ identity=self.identity,
52
+ default_handlers=default_handlers
53
+ )
54
+
55
+ def initialize(self):
56
+ self.network.graph.generate()
57
+
58
+ self.processor.handle(
59
+ bundle=Bundle.generate(
60
+ rid=self.identity.rid,
61
+ contents=self.identity.profile.model_dump()
62
+ ),
63
+ flush=True
64
+ )
65
+
66
+ if not self.network.graph.get_neighbors() and self.first_contact:
67
+ logger.info(f"I don't have any neighbors, reaching out to first contact {self.first_contact}")
68
+
69
+ events = [
70
+ Event.from_rid(EventType.FORGET, self.identity.rid),
71
+ Event.from_bundle(EventType.NEW, self.identity.bundle)
72
+ ]
73
+
74
+ try:
75
+ self.network.request_handler.broadcast_events(
76
+ url=self.first_contact,
77
+ events=events
78
+ )
79
+
80
+ except httpx.ConnectError:
81
+ logger.info("Failed to reach first contact")
82
+ return
83
+
84
+
85
+ def finalize(self):
86
+ self.network.save_event_queues()
@@ -0,0 +1,62 @@
1
+ import logging
2
+ from pydantic import BaseModel
3
+ from rid_lib.ext.bundle import Bundle
4
+ from rid_lib.ext.cache import Cache
5
+ from rid_lib.types.koi_net_node import KoiNetNode
6
+ from .protocol.node import NodeProfile
7
+
8
+ logger = logging.getLogger(__name__)
9
+
10
+
11
+ class NodeIdentityModel(BaseModel):
12
+ rid: KoiNetNode
13
+ profile: NodeProfile
14
+
15
+ class NodeIdentity:
16
+ _identity: NodeIdentityModel
17
+ file_path: str
18
+ cache: Cache
19
+
20
+ def __init__(
21
+ self,
22
+ name: str,
23
+ profile: NodeProfile,
24
+ cache: Cache,
25
+ file_path: str = "identity.json"
26
+ ):
27
+ self.cache = cache
28
+ self.file_path = file_path
29
+
30
+ self._identity = None
31
+ try:
32
+ with open(file_path, "r") as f:
33
+ self._identity = NodeIdentityModel.model_validate_json(f.read())
34
+
35
+ except FileNotFoundError:
36
+ pass
37
+
38
+ if self._identity:
39
+ if self._identity.rid.name != name:
40
+ logger.warning("Node name changed which will change this node's RID, if you really want to do this manually delete the identity JSON file")
41
+ if self._identity.profile != profile:
42
+ self._identity.profile = profile
43
+ else:
44
+ self._identity = NodeIdentityModel(
45
+ rid=KoiNetNode.generate(name),
46
+ profile=profile,
47
+ )
48
+
49
+ with open(file_path, "w") as f:
50
+ f.write(self._identity.model_dump_json(indent=2))
51
+
52
+ @property
53
+ def rid(self) -> KoiNetNode:
54
+ return self._identity.rid
55
+
56
+ @property
57
+ def profile(self) -> NodeProfile:
58
+ return self._identity.profile
59
+
60
+ @property
61
+ def bundle(self) -> Bundle:
62
+ return self.cache.read(self.rid)
@@ -0,0 +1 @@
1
+ from .interface import NetworkInterface
@@ -0,0 +1,112 @@
1
+ import logging
2
+ from typing import Literal
3
+ import networkx as nx
4
+ from rid_lib import RIDType
5
+ from rid_lib.ext import Cache
6
+ from rid_lib.types import KoiNetEdge, KoiNetNode
7
+ from ..identity import NodeIdentity
8
+ from ..protocol.edge import EdgeProfile, EdgeStatus
9
+ from ..protocol.node import NodeProfile
10
+
11
+ logger = logging.getLogger(__name__)
12
+
13
+
14
+ class NetworkGraph:
15
+ def __init__(self, cache: Cache, identity: NodeIdentity):
16
+ self.cache = cache
17
+ self.dg = nx.DiGraph()
18
+ self.identity = identity
19
+
20
+ def generate(self):
21
+ logger.info("Generating network graph")
22
+ self.dg.clear()
23
+ for rid in self.cache.list_rids():
24
+ if type(rid) == KoiNetNode:
25
+ self.dg.add_node(rid)
26
+ logger.info(f"Added node {rid}")
27
+
28
+ elif type(rid) == KoiNetEdge:
29
+ edge_profile = self.get_edge_profile(rid)
30
+ if not edge_profile:
31
+ logger.warning(f"Failed to load {rid!r}")
32
+ continue
33
+ self.dg.add_edge(edge_profile.source, edge_profile.target, rid=rid)
34
+ logger.info(f"Added edge {rid} ({edge_profile.source} -> {edge_profile.target})")
35
+ logger.info("Done")
36
+
37
+ def get_node_profile(self, rid: KoiNetNode) -> NodeProfile | None:
38
+ bundle = self.cache.read(rid)
39
+ if bundle:
40
+ return bundle.validate_contents(NodeProfile)
41
+
42
+ def get_edge_profile(
43
+ self,
44
+ rid: KoiNetEdge | None = None,
45
+ source: KoiNetNode | None = None,
46
+ target: KoiNetNode | None = None,
47
+ ) -> EdgeProfile | None:
48
+ if source and target:
49
+ if (source, target) not in self.dg.edges: return
50
+ edge_data = self.dg.get_edge_data(source, target)
51
+ if not edge_data: return
52
+ rid = edge_data.get("rid")
53
+ if not rid: return
54
+ elif not rid:
55
+ raise ValueError("Either 'rid' or 'source' and 'target' must be provided")
56
+
57
+ bundle = self.cache.read(rid)
58
+ if bundle:
59
+ return bundle.validate_contents(EdgeProfile)
60
+
61
+ def get_edges(
62
+ self,
63
+ direction: Literal["in", "out"] | None = None,
64
+ ) -> list[KoiNetEdge]:
65
+
66
+ edges = []
67
+ if direction != "in":
68
+ out_edges = self.dg.out_edges(self.identity.rid)
69
+ edges.extend([e for e in out_edges])
70
+
71
+ if direction != "out":
72
+ in_edges = self.dg.in_edges(self.identity.rid)
73
+ edges.extend([e for e in in_edges])
74
+
75
+ edge_rids = []
76
+ for edge in edges:
77
+ edge_data = self.dg.get_edge_data(*edge)
78
+ if not edge_data: continue
79
+ edge_rid = edge_data.get("rid")
80
+ if not edge_rid: continue
81
+ edge_rids.append(edge_rid)
82
+
83
+ return edge_rids
84
+
85
+ def get_neighbors(
86
+ self,
87
+ direction: Literal["in", "out"] | None = None,
88
+ status: EdgeStatus | None = None,
89
+ allowed_type: RIDType | None = None
90
+ ) -> list[KoiNetNode]:
91
+
92
+ neighbors = []
93
+ for edge_rid in self.get_edges(direction):
94
+ edge_profile = self.get_edge_profile(edge_rid)
95
+
96
+ if not edge_profile:
97
+ logger.warning(f"Failed to find edge {edge_rid!r} in cache")
98
+ continue
99
+
100
+ if status and edge_profile.status != status:
101
+ continue
102
+
103
+ if allowed_type and allowed_type not in edge_profile.rid_types:
104
+ continue
105
+
106
+ if edge_profile.target == self.identity.rid:
107
+ neighbors.append(edge_profile.source)
108
+ elif edge_profile.source == self.identity.rid:
109
+ neighbors.append(edge_profile.target)
110
+
111
+ return list(neighbors)
112
+