koi-net 1.0.0b18__py3-none-any.whl → 1.1.0__py3-none-any.whl

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.

koi_net/actor.py ADDED
@@ -0,0 +1,60 @@
1
+ from logging import getLogger
2
+ from rid_lib.types import KoiNetNode
3
+ from rid_lib import RIDType
4
+ from koi_net.context import HandlerContext
5
+ from koi_net.protocol.api_models import ErrorResponse
6
+ from .protocol.event import Event, EventType
7
+
8
+
9
+ logger = getLogger(__name__)
10
+
11
+
12
+ class Actor:
13
+ ctx: HandlerContext
14
+
15
+ def __init__(self):
16
+ pass
17
+
18
+ def set_ctx(self, ctx: HandlerContext):
19
+ self.ctx = ctx
20
+
21
+ def handshake_with(self, target: KoiNetNode):
22
+ logger.debug(f"Initiating handshake with {target}")
23
+ self.ctx.event_queue.push_event_to(
24
+ Event.from_rid(
25
+ event_type=EventType.FORGET,
26
+ rid=self.ctx.identity.rid),
27
+ node=target
28
+ )
29
+
30
+ self.ctx.event_queue.push_event_to(
31
+ event=Event.from_bundle(
32
+ event_type=EventType.NEW,
33
+ bundle=self.ctx.effector.deref(self.ctx.identity.rid)),
34
+ node=target
35
+ )
36
+
37
+ self.ctx.event_queue.flush_webhook_queue(target)
38
+
39
+ def identify_coordinators(self):
40
+ return self.ctx.resolver.get_state_providers(KoiNetNode)
41
+
42
+ def catch_up_with(self, target: KoiNetNode, rid_types: list[RIDType] = []):
43
+ logger.debug(f"catching up with {target} on {rid_types or 'all types'}")
44
+
45
+ payload = self.ctx.request_handler.fetch_manifests(
46
+ node=target,
47
+ rid_types=rid_types
48
+ )
49
+ if type(payload) == ErrorResponse:
50
+ logger.debug("failed to reach node")
51
+ return
52
+
53
+ for manifest in payload.manifests:
54
+ if manifest.rid == self.ctx.identity.rid:
55
+ continue
56
+
57
+ self.ctx.handle(
58
+ manifest=manifest,
59
+ source=target
60
+ )
koi_net/config.py CHANGED
@@ -1,32 +1,43 @@
1
1
  import os
2
- from typing import TypeVar
3
2
  from ruamel.yaml import YAML
4
- from koi_net.protocol.node import NodeProfile
5
- from rid_lib.types import KoiNetNode
6
3
  from pydantic import BaseModel, Field, PrivateAttr
7
4
  from dotenv import load_dotenv
5
+ from rid_lib.ext.utils import sha256_hash
6
+ from rid_lib.types import KoiNetNode
7
+ from .protocol.secure import PrivateKey
8
+ from .protocol.node import NodeProfile, NodeType
8
9
 
9
10
 
10
11
  class ServerConfig(BaseModel):
11
- host: str | None = "127.0.0.1"
12
- port: int | None = 8000
12
+ host: str = "127.0.0.1"
13
+ port: int = 8000
13
14
  path: str | None = "/koi-net"
14
15
 
15
16
  @property
16
17
  def url(self) -> str:
17
18
  return f"http://{self.host}:{self.port}{self.path or ''}"
18
19
 
20
+ class NodeContact(BaseModel):
21
+ rid: KoiNetNode | None = None
22
+ url: str | None = None
23
+
19
24
  class KoiNetConfig(BaseModel):
20
25
  node_name: str
21
26
  node_rid: KoiNetNode | None = None
22
27
  node_profile: NodeProfile
23
28
 
24
- cache_directory_path: str | None = ".rid_cache"
25
- event_queues_path: str | None = "event_queues.json"
26
-
27
- first_contact: str | None = None
29
+ cache_directory_path: str = ".rid_cache"
30
+ event_queues_path: str = "event_queues.json"
31
+ private_key_pem_path: str = "priv_key.pem"
32
+ polling_interval: int = 5
33
+
34
+ first_contact: NodeContact = Field(default_factory=NodeContact)
35
+
36
+ _priv_key: PrivateKey | None = PrivateAttr(default=None)
28
37
 
29
38
  class EnvConfig(BaseModel):
39
+ priv_key_password: str | None = "PRIV_KEY_PASSWORD"
40
+
30
41
  def __init__(self, **kwargs):
31
42
  super().__init__(**kwargs)
32
43
  load_dotenv()
@@ -41,8 +52,10 @@ class EnvConfig(BaseModel):
41
52
  return value
42
53
 
43
54
  class NodeConfig(BaseModel):
44
- server: ServerConfig | None = Field(default_factory=ServerConfig)
55
+ server: ServerConfig = Field(default_factory=ServerConfig)
45
56
  koi_net: KoiNetConfig
57
+ env: EnvConfig = Field(default_factory=EnvConfig)
58
+
46
59
  _file_path: str = PrivateAttr(default="config.yaml")
47
60
  _file_content: str | None = PrivateAttr(default=None)
48
61
 
@@ -72,13 +85,27 @@ class NodeConfig(BaseModel):
72
85
 
73
86
  config._file_path = file_path
74
87
 
75
- if generate_missing:
76
- config.koi_net.node_rid = (
77
- config.koi_net.node_rid or KoiNetNode.generate(config.koi_net.node_name)
78
- )
79
- config.koi_net.node_profile.base_url = (
80
- config.koi_net.node_profile.base_url or config.server.url
81
- )
88
+ if generate_missing:
89
+ if not config.koi_net.node_rid:
90
+ priv_key = PrivateKey.generate()
91
+ pub_key = priv_key.public_key()
92
+
93
+ config.koi_net.node_rid = KoiNetNode(
94
+ config.koi_net.node_name,
95
+ sha256_hash(pub_key.to_der())
96
+ )
97
+
98
+ with open(config.koi_net.private_key_pem_path, "w") as f:
99
+ f.write(
100
+ priv_key.to_pem(config.env.priv_key_password)
101
+ )
102
+
103
+ config.koi_net.node_profile.public_key = pub_key.to_der()
104
+
105
+ if config.koi_net.node_profile.node_type == NodeType.FULL:
106
+ config.koi_net.node_profile.base_url = (
107
+ config.koi_net.node_profile.base_url or config.server.url
108
+ )
82
109
 
83
110
  config.save_to_yaml()
84
111
 
@@ -98,4 +125,3 @@ class NodeConfig(BaseModel):
98
125
  f.write(self._file_content)
99
126
  raise e
100
127
 
101
- ConfigType = TypeVar("ConfigType", bound=NodeConfig)
koi_net/context.py ADDED
@@ -0,0 +1,63 @@
1
+ from rid_lib.ext import Cache
2
+
3
+ from koi_net.network.resolver import NetworkResolver
4
+ from .config import NodeConfig
5
+ from .effector import Effector
6
+ from .network.graph import NetworkGraph
7
+ from .network.event_queue import NetworkEventQueue
8
+ from .network.request_handler import RequestHandler
9
+ from .identity import NodeIdentity
10
+ from .processor.interface import ProcessorInterface
11
+
12
+
13
+ class ActionContext:
14
+ identity: NodeIdentity
15
+ effector: Effector
16
+
17
+ def __init__(
18
+ self,
19
+ identity: NodeIdentity,
20
+ effector: Effector
21
+ ):
22
+ self.identity = identity
23
+ self.effector = effector
24
+
25
+
26
+ class HandlerContext:
27
+ identity: NodeIdentity
28
+ config: NodeConfig
29
+ cache: Cache
30
+ event_queue: NetworkEventQueue
31
+ graph: NetworkGraph
32
+ request_handler: RequestHandler
33
+ resolver: NetworkResolver
34
+ effector: Effector
35
+ _processor: ProcessorInterface | None
36
+
37
+ def __init__(
38
+ self,
39
+ identity: NodeIdentity,
40
+ config: NodeConfig,
41
+ cache: Cache,
42
+ event_queue: NetworkEventQueue,
43
+ graph: NetworkGraph,
44
+ request_handler: RequestHandler,
45
+ resolver: NetworkResolver,
46
+ effector: Effector
47
+ ):
48
+ self.identity = identity
49
+ self.config = config
50
+ self.cache = cache
51
+ self.event_queue = event_queue
52
+ self.graph = graph
53
+ self.request_handler = request_handler
54
+ self.resolver = resolver
55
+ self.effector = effector
56
+ self._processor = None
57
+
58
+ def set_processor(self, processor: ProcessorInterface):
59
+ self._processor = processor
60
+
61
+ @property
62
+ def handle(self):
63
+ return self._processor.handle
koi_net/core.py CHANGED
@@ -1,53 +1,117 @@
1
1
  import logging
2
- from typing import Generic
3
- import httpx
4
- from rid_lib.ext import Cache, Bundle
5
- from .network import NetworkInterface
6
- from .processor import ProcessorInterface
2
+ from typing import Generic, TypeVar
3
+ from rid_lib.ext import Cache
4
+ from .network.resolver import NetworkResolver
5
+ from .network.event_queue import NetworkEventQueue
6
+ from .network.graph import NetworkGraph
7
+ from .network.request_handler import RequestHandler
8
+ from .network.response_handler import ResponseHandler
9
+ from .network.error_handler import ErrorHandler
10
+ from .actor import Actor
11
+ from .processor.interface import ProcessorInterface
7
12
  from .processor import default_handlers
8
13
  from .processor.handler import KnowledgeHandler
14
+ from .processor.knowledge_pipeline import KnowledgePipeline
9
15
  from .identity import NodeIdentity
10
- from .protocol.event import Event, EventType
11
- from .config import ConfigType
16
+ from .secure import Secure
17
+ from .config import NodeConfig
18
+ from .context import HandlerContext, ActionContext
19
+ from .effector import Effector
20
+ from .server import NodeServer
21
+ from .lifecycle import NodeLifecycle
22
+ from .poller import NodePoller
23
+ from . import default_actions
12
24
 
13
25
  logger = logging.getLogger(__name__)
14
26
 
15
27
 
28
+ T = TypeVar("T", bound=NodeConfig)
16
29
 
17
- class NodeInterface(Generic[ConfigType]):
18
- config: ConfigType
30
+ class NodeInterface(Generic[T]):
31
+ config: T
19
32
  cache: Cache
20
33
  identity: NodeIdentity
21
- network: NetworkInterface
34
+ resolver: NetworkResolver
35
+ event_queue: NetworkEventQueue
36
+ graph: NetworkGraph
22
37
  processor: ProcessorInterface
38
+ secure: Secure
39
+ server: NodeServer
23
40
 
24
41
  use_kobj_processor_thread: bool
25
42
 
26
43
  def __init__(
27
- self,
28
- config: ConfigType,
44
+ self,
45
+ config: T,
29
46
  use_kobj_processor_thread: bool = False,
30
-
31
47
  handlers: list[KnowledgeHandler] | None = None,
32
48
 
33
- cache: Cache | None = None,
34
- network: NetworkInterface | None = None,
35
- processor: ProcessorInterface | None = None
49
+ CacheOverride: type[Cache] | None = None,
50
+ NodeIdentityOverride: type[NodeIdentity] | None = None,
51
+ EffectorOverride: type[Effector] | None = None,
52
+ NetworkGraphOverride: type[NetworkGraph] | None = None,
53
+ SecureOverride: type[Secure] | None = None,
54
+ RequestHandlerOverride: type[RequestHandler] | None = None,
55
+ ResponseHandlerOverride: type[ResponseHandler] | None = None,
56
+ NetworkResolverOverride: type[NetworkResolver] | None = None,
57
+ NetworkEventQueueOverride: type[NetworkEventQueue] | None = None,
58
+ ActorOverride: type[Actor] | None = None,
59
+ ActionContextOverride: type[ActionContext] | None = None,
60
+ HandlerContextOverride: type[HandlerContext] | None = None,
61
+ KnowledgePipelineOverride: type[KnowledgePipeline] | None = None,
62
+ ProcessorInterfaceOverride: type[ProcessorInterface] | None = None,
63
+ ErrorHandlerOverride: type[ErrorHandler] | None = None,
64
+ NodeLifecycleOverride: type[NodeLifecycle] | None = None,
65
+ NodeServerOverride: type[NodeServer] | None = None,
66
+ NodePollerOverride: type[NodePoller] | None = None,
36
67
  ):
37
- self.config: ConfigType = config
38
- self.cache = cache or Cache(
39
- self.config.koi_net.cache_directory_path)
40
-
41
- self.identity = NodeIdentity(
68
+ self.config = config
69
+ self.cache = (CacheOverride or Cache)(
70
+ directory_path=self.config.koi_net.cache_directory_path
71
+ )
72
+
73
+ self.identity = (NodeIdentityOverride or NodeIdentity)(config=self.config)
74
+ self.effector = (EffectorOverride or Effector)(cache=self.cache)
75
+
76
+ self.graph = (NetworkGraphOverride or NetworkGraph)(
77
+ cache=self.cache,
78
+ identity=self.identity
79
+ )
80
+
81
+ self.secure = (SecureOverride or Secure)(
82
+ identity=self.identity,
83
+ effector=self.effector,
84
+ config=self.config
85
+ )
86
+
87
+ self.request_handler = (RequestHandlerOverride or RequestHandler)(
88
+ effector=self.effector,
89
+ identity=self.identity,
90
+ secure=self.secure
91
+ )
92
+
93
+ self.response_handler = (ResponseHandlerOverride or ResponseHandler)(self.cache, self.effector)
94
+
95
+ self.resolver = (NetworkResolverOverride or NetworkResolver)(
42
96
  config=self.config,
43
- cache=self.cache)
44
-
45
- self.network = network or NetworkInterface(
97
+ cache=self.cache,
98
+ identity=self.identity,
99
+ graph=self.graph,
100
+ request_handler=self.request_handler,
101
+ effector=self.effector
102
+ )
103
+
104
+ self.event_queue = (NetworkEventQueueOverride or NetworkEventQueue)(
46
105
  config=self.config,
47
- cache=self.cache,
48
- identity=self.identity
106
+ cache=self.cache,
107
+ identity=self.identity,
108
+ graph=self.graph,
109
+ request_handler=self.request_handler,
110
+ effector=self.effector
49
111
  )
50
112
 
113
+ self.actor = (ActorOverride or Actor)()
114
+
51
115
  # pull all handlers defined in default_handlers module
52
116
  if handlers is None:
53
117
  handlers = [
@@ -56,71 +120,75 @@ class NodeInterface(Generic[ConfigType]):
56
120
  ]
57
121
 
58
122
  self.use_kobj_processor_thread = use_kobj_processor_thread
59
- self.processor = processor or ProcessorInterface(
123
+
124
+ self.action_context = (ActionContextOverride or ActionContext)(
125
+ identity=self.identity,
126
+ effector=self.effector
127
+ )
128
+
129
+ self.handler_context = (HandlerContextOverride or HandlerContext)(
130
+ identity=self.identity,
60
131
  config=self.config,
61
- cache=self.cache,
62
- network=self.network,
63
- identity=self.identity,
64
- use_kobj_processor_thread=self.use_kobj_processor_thread,
65
- default_handlers=handlers
132
+ cache=self.cache,
133
+ event_queue=self.event_queue,
134
+ graph=self.graph,
135
+ request_handler=self.request_handler,
136
+ resolver=self.resolver,
137
+ effector=self.effector
66
138
  )
67
-
68
- def start(self) -> None:
69
- """Starts a node, call this method first.
70
139
 
71
- Starts the processor thread (if enabled). Loads event queues into memory. Generates network graph from nodes and edges in cache. Processes any state changes of node bundle. Initiates handshake with first contact (if provided) if node doesn't have any neighbors.
72
- """
73
- if self.use_kobj_processor_thread:
74
- logger.info("Starting processor worker thread")
75
- self.processor.worker_thread.start()
140
+ self.pipeline = (KnowledgePipelineOverride or KnowledgePipeline)(
141
+ handler_context=self.handler_context,
142
+ cache=self.cache,
143
+ request_handler=self.request_handler,
144
+ event_queue=self.event_queue,
145
+ graph=self.graph,
146
+ default_handlers=handlers
147
+ )
76
148
 
77
- self.network._load_event_queues()
78
- self.network.graph.generate()
149
+ self.processor = (ProcessorInterfaceOverride or ProcessorInterface)(
150
+ pipeline=self.pipeline,
151
+ use_kobj_processor_thread=self.use_kobj_processor_thread
152
+ )
79
153
 
80
- self.processor.handle(
81
- bundle=Bundle.generate(
82
- rid=self.identity.rid,
83
- contents=self.identity.profile.model_dump()
84
- )
154
+ self.error_handler = (ErrorHandlerOverride or ErrorHandler)(
155
+ processor=self.processor,
156
+ actor=self.actor
85
157
  )
86
158
 
87
- logger.debug("Waiting for kobj queue to empty")
88
- if self.use_kobj_processor_thread:
89
- self.processor.kobj_queue.join()
90
- else:
91
- self.processor.flush_kobj_queue()
92
- logger.debug("Done")
93
-
94
- if not self.network.graph.get_neighbors() and self.config.koi_net.first_contact:
95
- logger.debug(f"I don't have any neighbors, reaching out to first contact {self.config.koi_net.first_contact}")
96
-
97
- events = [
98
- Event.from_rid(EventType.FORGET, self.identity.rid),
99
- Event.from_bundle(EventType.NEW, self.identity.bundle)
100
- ]
101
-
102
- try:
103
- self.network.request_handler.broadcast_events(
104
- url=self.config.koi_net.first_contact,
105
- events=events
106
- )
107
-
108
- except httpx.ConnectError:
109
- logger.warning("Failed to reach first contact")
110
- return
111
-
112
-
113
- def stop(self):
114
- """Stops a node, call this method last.
159
+ self.request_handler.set_error_handler(self.error_handler)
115
160
 
116
- Finishes processing knowledge object queue. Saves event queues to storage.
117
- """
118
- logger.info("Stopping node...")
161
+ self.handler_context.set_processor(self.processor)
119
162
 
120
- if self.use_kobj_processor_thread:
121
- logger.info(f"Waiting for kobj queue to empty ({self.processor.kobj_queue.unfinished_tasks} tasks remaining)")
122
- self.processor.kobj_queue.join()
123
- else:
124
- self.processor.flush_kobj_queue()
163
+ self.effector.set_processor(self.processor)
164
+ self.effector.set_resolver(self.resolver)
165
+ self.effector.set_action_context(self.action_context)
125
166
 
126
- self.network._save_event_queues()
167
+ self.actor.set_ctx(self.handler_context)
168
+
169
+ self.lifecycle = (NodeLifecycleOverride or NodeLifecycle)(
170
+ config=self.config,
171
+ identity=self.identity,
172
+ graph=self.graph,
173
+ processor=self.processor,
174
+ effector=self.effector,
175
+ actor=self.actor,
176
+ use_kobj_processor_thread=use_kobj_processor_thread
177
+ )
178
+
179
+ # if self.config.koi_net.node_profile.node_type == NodeType.FULL:
180
+ self.server = (NodeServerOverride or NodeServer)(
181
+ config=self.config,
182
+ lifecycle=self.lifecycle,
183
+ secure=self.secure,
184
+ processor=self.processor,
185
+ event_queue=self.event_queue,
186
+ response_handler=self.response_handler
187
+ )
188
+
189
+ self.poller = (NodePollerOverride or NodePoller)(
190
+ processor=self.processor,
191
+ lifecycle=self.lifecycle,
192
+ resolver=self.resolver,
193
+ config=self.config
194
+ )
@@ -0,0 +1,15 @@
1
+ from .context import ActionContext
2
+ from rid_lib.types import KoiNetNode
3
+ from rid_lib.ext import Bundle
4
+ from .effector import Effector
5
+
6
+
7
+ @Effector.register_default_action(KoiNetNode)
8
+ def dereference_koi_node(ctx: ActionContext, rid: KoiNetNode) -> Bundle:
9
+ if rid != ctx.identity.rid:
10
+ return
11
+
12
+ return Bundle.generate(
13
+ rid=ctx.identity.rid,
14
+ contents=ctx.identity.profile.model_dump()
15
+ )
koi_net/effector.py ADDED
@@ -0,0 +1,139 @@
1
+ import logging
2
+ from typing import Callable
3
+ from enum import StrEnum
4
+ from rid_lib.ext import Cache, Bundle
5
+ from rid_lib.core import RID, RIDType
6
+ from rid_lib.types import KoiNetNode
7
+
8
+ from typing import TYPE_CHECKING
9
+
10
+ if TYPE_CHECKING:
11
+ from .network.resolver import NetworkResolver
12
+ from .processor.interface import ProcessorInterface
13
+ from .context import ActionContext
14
+
15
+ logger = logging.getLogger(__name__)
16
+
17
+
18
+ class BundleSource(StrEnum):
19
+ CACHE = "CACHE"
20
+ ACTION = "ACTION"
21
+
22
+ class Effector:
23
+ cache: Cache
24
+ resolver: "NetworkResolver | None"
25
+ processor: "ProcessorInterface | None"
26
+ action_context: "ActionContext | None"
27
+ _action_table: dict[
28
+ type[RID],
29
+ Callable[
30
+ ["ActionContext", RID],
31
+ Bundle | None
32
+ ]
33
+ ] = dict()
34
+
35
+ def __init__(
36
+ self,
37
+ cache: Cache,
38
+ ):
39
+ self.cache = cache
40
+ self.resolver = None
41
+ self.processor = None
42
+ self.action_context = None
43
+ self._action_table = self.__class__._action_table.copy()
44
+
45
+ def set_processor(self, processor: "ProcessorInterface"):
46
+ self.processor = processor
47
+
48
+ def set_resolver(self, resolver: "NetworkResolver"):
49
+ self.resolver = resolver
50
+
51
+ def set_action_context(self, action_context: "ActionContext"):
52
+ self.action_context = action_context
53
+
54
+ @classmethod
55
+ def register_default_action(cls, rid_type: RIDType):
56
+ def decorator(func: Callable) -> Callable:
57
+ cls._action_table[rid_type] = func
58
+ return func
59
+ return decorator
60
+
61
+ def register_action(self, rid_type: RIDType):
62
+ def decorator(func: Callable) -> Callable:
63
+ self._action_table[rid_type] = func
64
+ return func
65
+ return decorator
66
+
67
+ def _try_cache(self, rid: RID) -> tuple[Bundle, BundleSource] | None:
68
+ bundle = self.cache.read(rid)
69
+
70
+ if bundle:
71
+ logger.debug("Cache hit")
72
+ return bundle, BundleSource.CACHE
73
+ else:
74
+ logger.debug("Cache miss")
75
+ return None
76
+
77
+ def _try_action(self, rid: RID) -> tuple[Bundle, BundleSource] | None:
78
+ if type(rid) not in self._action_table:
79
+ logger.debug("No action available")
80
+ return None
81
+
82
+ logger.debug("Action available")
83
+ func = self._action_table[type(rid)]
84
+ bundle = func(
85
+ ctx=self.action_context,
86
+ rid=rid
87
+ )
88
+
89
+ if bundle:
90
+ logger.debug("Action hit")
91
+ return bundle, BundleSource.ACTION
92
+ else:
93
+ logger.debug("Action miss")
94
+ return None
95
+
96
+
97
+ def _try_network(self, rid: RID) -> tuple[Bundle, KoiNetNode] | None:
98
+ bundle, source = self.resolver.fetch_remote_bundle(rid)
99
+
100
+ if bundle:
101
+ logger.debug("Network hit")
102
+ return bundle, source
103
+ else:
104
+ logger.debug("Network miss")
105
+ return None
106
+
107
+
108
+ def deref(
109
+ self,
110
+ rid: RID,
111
+ refresh_cache: bool = False,
112
+ use_network: bool = False,
113
+ handle_result: bool = True
114
+ ) -> Bundle | None:
115
+ logger.debug(f"Dereferencing {rid!r}")
116
+
117
+ bundle, source = (
118
+ # if `refresh_cache`, skip try cache
119
+ not refresh_cache and self._try_cache(rid) or
120
+ self._try_action(rid) or
121
+ use_network and self._try_network(rid) or
122
+ # if not found, bundle and source set to None
123
+ (None, None)
124
+ )
125
+
126
+ if (
127
+ handle_result
128
+ and bundle is not None
129
+ and source != BundleSource.CACHE
130
+ ):
131
+ self.processor.handle(
132
+ bundle=bundle,
133
+ source=source if type(source) is KoiNetNode else None
134
+ )
135
+
136
+ # TODO: refactor for general solution, param to write through to cache before continuing
137
+ # like `self.processor.kobj_queue.join()``
138
+
139
+ return bundle