learning-loop-node 0.24.0__py3-none-any.whl → 0.25.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.
@@ -4,7 +4,9 @@ from enum import Enum
4
4
 
5
5
  class TrainerState(str, Enum):
6
6
  Idle = 'idle'
7
+ Blocked = 'blocked' # idle, but unable to take a training
7
8
  Initialized = 'initialized'
9
+ WaitingForSlot = 'waiting_for_slot'
8
10
  Preparing = 'preparing'
9
11
  DataDownloading = 'data_downloading'
10
12
  DataDownloaded = 'data_downloaded'
@@ -150,7 +150,7 @@ def ensure_socket_response(func):
150
150
  if isinstance(value, bool):
151
151
  return asdict(SocketResponse.from_bool(value))
152
152
  if isinstance(value, SocketResponse):
153
- return value
153
+ return asdict(value)
154
154
  if (args[0] in ['connect', 'disconnect', 'connect_error']):
155
155
  return value
156
156
  if value is None:
@@ -5,6 +5,18 @@ class CriticalError(Exception):
5
5
  """
6
6
 
7
7
 
8
+ class TrainingRefusedError(Exception):
9
+ """Raised when a trainer cannot take on a training it was asked to begin; nothing was set up."""
10
+
11
+
12
+ class TrainingSlotTakenError(TrainingRefusedError):
13
+ """Raised when a sibling node holds the training slot."""
14
+
15
+ def __init__(self, holder: str) -> None:
16
+ super().__init__(f'the training slot is held by {holder}')
17
+ self.holder = holder
18
+
19
+
8
20
  class InsufficientMemoryError(RuntimeError):
9
21
  """Raised when not even the smallest unit of work fits in memory."""
10
22
 
@@ -26,8 +26,9 @@ from ..helpers.misc import (
26
26
  is_valid_uuid4,
27
27
  )
28
28
  from .downloader import TrainingsDownloader
29
- from .exceptions import CriticalError, NodeNeedsRestartError
29
+ from .exceptions import CriticalError, NodeNeedsRestartError, TrainingRefusedError
30
30
  from .io_helpers import ActiveTrainingIO, EnvironmentVars, LastTrainingIO
31
+ from .training_slot import TrainingSlot, training_slot_from_env
31
32
 
32
33
  if TYPE_CHECKING:
33
34
  from .trainer_node import TrainerNode
@@ -37,7 +38,7 @@ logger = logging.getLogger('learning_loop_node.trainer_logic_generic')
37
38
 
38
39
  class TrainerLogicGeneric(ABC):
39
40
 
40
- def __init__(self, model_format: str) -> None:
41
+ def __init__(self, model_format: str, training_slot: TrainingSlot | None = None) -> None:
41
42
 
42
43
  # NOTE: model_format is used in the file path for the model on the server:
43
44
  # It acts as a key for list of files (cf. _get_latest_model_files)
@@ -54,6 +55,7 @@ class TrainerLogicGeneric(ABC):
54
55
  self._training: Optional[Training] = None
55
56
  self._active_training_io: Optional[ActiveTrainingIO] = None
56
57
  self._environment_vars = EnvironmentVars()
58
+ self.training_slot = training_slot or training_slot_from_env()
57
59
 
58
60
  # ---------------------------------------- PROPERTIES TO AVOID CHECKING FOR NONE ----------------------------------------
59
61
 
@@ -98,9 +100,17 @@ class TrainerLogicGeneric(ABC):
98
100
 
99
101
  @property
100
102
  def state(self) -> str:
101
- """Returns the current state of the training. Used solely by the node in send_status().
103
+ """Returns the current state of the training.
104
+
105
+ Without a training the state is `idle`, or `blocked` while a sibling node holds the training slot.
106
+ A training resumed after a restart reports `waiting_for_slot` until it gets the slot: it has a
107
+ training, so it is never `blocked`.
102
108
  """
103
- if (not self.training_active) or (self.training.training_state is None):
109
+ if not self.training_active:
110
+ return TrainerState.Blocked.value if self.training_slot.taken_by_sibling else TrainerState.Idle.value
111
+ if not self.training_slot.held:
112
+ return TrainerState.WaitingForSlot.value
113
+ if self.training.training_state is None:
104
114
  return TrainerState.Idle.value
105
115
  return self.training.training_state
106
116
 
@@ -213,8 +223,21 @@ class TrainerLogicGeneric(ABC):
213
223
 
214
224
  async def begin_training(self, organization: str, project: str, training_config: Dict) -> None:
215
225
  """Called on `begin_training` event from the Learning Loop.
226
+
227
+ The training slot is taken before anything is set up, so a refused training leaves the
228
+ previous training folders in place.
229
+
230
+ :raise TrainingRefusedError: if a training is active or a sibling node holds the training slot.
216
231
  """
217
- self._init_new_training(Context(organization=organization, project=project), training_config)
232
+ if self.training_active:
233
+ raise TrainingRefusedError(f'training {self.training.id} is still active')
234
+ context = Context(organization=organization, project=project)
235
+ self.training_slot.claim(self._slot_claim(context, training_config.get('training_number')))
236
+ try:
237
+ self._init_new_training(context, training_config)
238
+ except Exception:
239
+ self.training_slot.release()
240
+ raise
218
241
  self._begin_training_task()
219
242
 
220
243
  def _begin_training_task(self) -> None:
@@ -242,6 +265,7 @@ class TrainerLogicGeneric(ABC):
242
265
  """
243
266
  self.errors.reset_all()
244
267
  try:
268
+ await self.training_slot.wait_for(self._slot_claim(self.training.context, self.training.training_number))
245
269
  await self._training_loop()
246
270
  except asyncio.CancelledError:
247
271
  if not self.shutdown_event.is_set():
@@ -254,6 +278,12 @@ class TrainerLogicGeneric(ABC):
254
278
  logger.info('CancelledError in _run - shutting down')
255
279
  except Exception:
256
280
  logger.exception('(Ignored) exception in trainer_logic._run:')
281
+ finally:
282
+ self._training = None
283
+ self.training_slot.release()
284
+
285
+ def _slot_claim(self, context: Context, training_number: Any) -> str:
286
+ return f'node {self.node.uuid}, training #{training_number} of {context.organization}/{context.project}'
257
287
 
258
288
  # ---------------------------------------- TRAINING STATES ----------------------------------------
259
289
 
@@ -7,7 +7,10 @@ from typing import Dict, Optional
7
7
  from fastapi.encoders import jsonable_encoder
8
8
  from socketio import AsyncClient, exceptions
9
9
 
10
+ from ..data_classes import SocketResponse
11
+ from ..enums import TrainerState
10
12
  from ..node import Node
13
+ from .exceptions import TrainingRefusedError
11
14
  from .io_helpers import LastTrainingIO
12
15
  from .rest import backdoor_controls
13
16
  from .trainer_logic_generic import TrainerLogicGeneric
@@ -64,7 +67,11 @@ class TrainerNode(Node):
64
67
  @sio_client.event
65
68
  async def begin_training(organization: str, project: str, details: Dict):
66
69
  self.log.info('received begin_training from server')
67
- await self.trainer_logic.begin_training(organization, project, details)
70
+ try:
71
+ await self.trainer_logic.begin_training(organization, project, details)
72
+ except TrainingRefusedError as e:
73
+ self.log.warning('refused begin_training: %s', e)
74
+ return SocketResponse(success=False, error_msg=str(e), payload='refused')
68
75
  return True
69
76
 
70
77
  @sio_client.event
@@ -102,7 +109,7 @@ class TrainerNode(Node):
102
109
  if not self._idle_timeout:
103
110
  return
104
111
 
105
- if self.trainer_logic.state == 'idle':
112
+ if self.trainer_logic.state in (TrainerState.Idle, TrainerState.Blocked):
106
113
  if self._first_idle_time is None:
107
114
  self._first_idle_time = time.time()
108
115
  idle_time = time.time() - self._first_idle_time
@@ -0,0 +1,207 @@
1
+ """The right to use the GPU, shared between the trainer nodes on one machine.
2
+
3
+ A node holds the slot from the moment it accepts a training until the training is cleared.
4
+ """
5
+ import asyncio
6
+ import fcntl
7
+ import logging
8
+ import os
9
+ import socket
10
+ import weakref
11
+ from abc import ABC, abstractmethod
12
+ from pathlib import Path
13
+
14
+ from .exceptions import TrainingSlotTakenError
15
+
16
+ ENV_VAR = 'TRAINING_SLOT_LOCK'
17
+ _CLAIM_ATTEMPTS = 3
18
+
19
+ logger = logging.getLogger('learning_loop_node.training_slot')
20
+
21
+
22
+ class TrainingSlot(ABC):
23
+ """The slot as a trainer uses it; an adapter implements `_acquire`, `_sibling_holder`, `release` and `held`."""
24
+
25
+ def claim(self, training: str) -> None:
26
+ """Take the slot for `training` without waiting; a no-op if this slot already holds it.
27
+
28
+ :raise TrainingSlotTakenError: if a sibling node holds the slot.
29
+ """
30
+ holder = self._describe(training)
31
+ for _ in range(_CLAIM_ATTEMPTS - 1):
32
+ try:
33
+ self._acquire(holder)
34
+ return
35
+ except TrainingSlotTakenError:
36
+ if self._sibling_holder() is not None:
37
+ raise
38
+ self._acquire(holder)
39
+
40
+ async def wait_for(self, training: str) -> None:
41
+ """Take the slot for `training`, waiting as long as a sibling node holds it."""
42
+ try:
43
+ self.claim(training)
44
+ return
45
+ except TrainingSlotTakenError as e:
46
+ logger.info('%s - waiting', e)
47
+ while True:
48
+ await asyncio.sleep(1)
49
+ try:
50
+ self.claim(training)
51
+ except TrainingSlotTakenError:
52
+ continue
53
+ logger.info('acquired training slot')
54
+ return
55
+
56
+ @abstractmethod
57
+ def release(self) -> None:
58
+ """Give the slot back; a no-op if this slot does not hold it."""
59
+
60
+ @property
61
+ @abstractmethod
62
+ def held(self) -> bool:
63
+ """Whether this slot object holds the slot."""
64
+
65
+ @property
66
+ def taken_by_sibling(self) -> bool:
67
+ return not self.held and self._sibling_holder() is not None
68
+
69
+ @abstractmethod
70
+ def _acquire(self, holder: str) -> None:
71
+ """Take the slot without waiting; a no-op if this slot already holds it.
72
+
73
+ :raise TrainingSlotTakenError: if the slot is held elsewhere, including by a momentary probe.
74
+ """
75
+
76
+ @abstractmethod
77
+ def _sibling_holder(self) -> str | None:
78
+ """Who holds the slot, None if it is free or cannot be inspected; only asked while this slot does not hold it."""
79
+
80
+ @staticmethod
81
+ def _describe(training: str) -> str:
82
+ return f'pid {os.getpid()} on {socket.gethostname()}, {training}'
83
+
84
+
85
+ class AlwaysFreeSlot(TrainingSlot):
86
+ """The slot of a node that has the GPU to itself."""
87
+
88
+ def __init__(self) -> None:
89
+ self._held = False
90
+
91
+ def release(self) -> None:
92
+ self._held = False
93
+
94
+ @property
95
+ def held(self) -> bool:
96
+ return self._held
97
+
98
+ def _acquire(self, holder: str) -> None:
99
+ self._held = True
100
+
101
+ def _sibling_holder(self) -> str | None:
102
+ return None
103
+
104
+
105
+ class FileTrainingSlot(TrainingSlot):
106
+ """A slot backed by an exclusive `flock` on a file every sibling node mounts.
107
+
108
+ The kernel drops the lock with the process, so a node that dies in any way frees the slot.
109
+ The lock belongs to the inode: the file is only ever truncated and rewritten in place, never
110
+ replaced, and the mounted path is its directory.
111
+
112
+ The file's content names the holder and is diagnostic only — `_sibling_holder` tells a free slot
113
+ from a taken one by trying the lock, and that probe holds the lock for an instant, so a
114
+ claim that loses against a probe tries again. The file is made writable for everyone,
115
+ since sibling containers may run as different users.
116
+
117
+ A forked child shares the parent's lock and would keep it alive after the parent releases it,
118
+ so the child closes its copy right after the fork; the parent's lock is unaffected.
119
+ """
120
+
121
+ _instances: 'weakref.WeakSet[FileTrainingSlot]' = weakref.WeakSet()
122
+
123
+ def __init__(self, path: str | Path) -> None:
124
+ self.path = Path(path)
125
+ self._fd: int | None = None
126
+ FileTrainingSlot._instances.add(self)
127
+
128
+ def _acquire(self, holder: str) -> None:
129
+ if self._fd is not None:
130
+ return
131
+ fd = self._open()
132
+ try:
133
+ self._lock(fd)
134
+ os.ftruncate(fd, 0)
135
+ os.write(fd, holder.encode())
136
+ except BaseException:
137
+ os.close(fd)
138
+ raise
139
+ self._fd = fd
140
+
141
+ def release(self) -> None:
142
+ if self._fd is None:
143
+ return
144
+ try:
145
+ os.ftruncate(self._fd, 0)
146
+ except OSError:
147
+ logger.warning('cannot clear the holder from training slot %s', self.path, exc_info=True)
148
+ finally:
149
+ os.close(self._fd)
150
+ self._fd = None
151
+
152
+ @property
153
+ def held(self) -> bool:
154
+ return self._fd is not None
155
+
156
+ def _sibling_holder(self) -> str | None:
157
+ try:
158
+ fd = os.open(self.path, os.O_RDONLY)
159
+ except FileNotFoundError:
160
+ return None
161
+ except OSError:
162
+ logger.warning('cannot inspect training slot %s - treating it as free', self.path, exc_info=True)
163
+ return None
164
+ try:
165
+ self._lock(fd)
166
+ except TrainingSlotTakenError as e:
167
+ return e.holder
168
+ finally:
169
+ os.close(fd)
170
+ return None
171
+
172
+ @classmethod
173
+ def _drop_inherited_locks(cls) -> None:
174
+ for slot in list(cls._instances):
175
+ if slot._fd is not None:
176
+ os.close(slot._fd)
177
+ slot._fd = None
178
+
179
+ def _open(self) -> int:
180
+ fd = os.open(self.path, os.O_RDWR | os.O_CREAT, 0o666)
181
+ try:
182
+ os.fchmod(fd, 0o666)
183
+ except PermissionError:
184
+ pass
185
+ return fd
186
+
187
+ @staticmethod
188
+ def _lock(fd: int) -> None:
189
+ try:
190
+ fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
191
+ except BlockingIOError as e:
192
+ raise TrainingSlotTakenError(os.read(fd, 4096).decode(errors='replace') or 'unknown') from e
193
+
194
+ os.register_at_fork(after_in_child=FileTrainingSlot._drop_inherited_locks) # pylint: disable=protected-access
195
+
196
+
197
+ def training_slot_from_env() -> TrainingSlot:
198
+ """The slot `TRAINING_SLOT_LOCK` names, or a free one if the variable is unset.
199
+
200
+ :raise FileNotFoundError: if the lock file's directory does not exist.
201
+ """
202
+ path = os.environ.get(ENV_VAR)
203
+ if not path:
204
+ return AlwaysFreeSlot()
205
+ if not Path(path).parent.is_dir():
206
+ raise FileNotFoundError(f'{ENV_VAR}={path}: the directory does not exist; mount it from the host')
207
+ return FileTrainingSlot(path)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: learning_loop_node
3
- Version: 0.24.0
3
+ Version: 0.25.0
4
4
  Summary: Python Library for Nodes which connect to the Zauberzeug Learning Loop
5
5
  Author-email: Zauberzeug GmbH <info@zauberzeug.com>
6
6
  License: MIT
@@ -67,6 +67,7 @@ You can configure connection to our Learning Loop by specifying the following en
67
67
  | RESTART_AFTER_TRAINING | - | Restart the trainer after training (set to 1) | Trainer (opt.) | 0 |
68
68
  | KEEP_OLD_TRAININGS | - | Do not delete old trainings (set to 1) | Trainer (opt.) | 0 |
69
69
  | TRAINER_IDLE_TIMEOUT_SEC | - | Automatically shutdown trainer after timeout (in seconds) | Trainer (opt.) | 0 (disabled) |
70
+ | TRAINING_SLOT_LOCK | - | Lock file shared by the trainers on one GPU (see Trainer Node) | Trainer (opt.) | - (disabled) |
70
71
  | USE_BACKDOOR_CONTROLS | - | Always enable backdoor controls (set to 1) | Trainer / Detector (opt.) | 0 |
71
72
 
72
73
  Note that organization and project IDs are always lower case and may differ from the names in the Learning Loop which can have uppercase letters.
@@ -250,6 +251,20 @@ Trainers fetch the images and anntoations from the Learning Loop to train new mo
250
251
 
251
252
  - if the command line tool "jpeginfo" is installed, the downloader will drop corrupted images automatically
252
253
 
254
+ ### Several trainers on one GPU
255
+
256
+ Trainers that share a GPU (say, one per model architecture on the same machine) coordinate through
257
+ a lock file: mount one host directory into each of them and point `TRAINING_SLOT_LOCK` at a file
258
+ inside it, e.g. `/slot/training.lock`. A trainer takes the lock when it accepts a training and holds
259
+ it until the training is cleaned up, including detection and upload. While a sibling holds it, the
260
+ others report the state `blocked` instead of `idle`, so the Learning Loop neither offers them for a
261
+ training nor dispatches queued jobs to them; a trainer that is sent `begin_training` regardless
262
+ refuses it with `{"success": false, "error_msg": ...}` in the acknowledgement and sets nothing up. A
263
+ training resumed after a restart cannot be refused — it waits for the lock instead, reporting
264
+ `waiting_for_slot` meanwhile. The lock is an `flock`, so the kernel releases it whenever the holding
265
+ process ends, however it ends — there is nothing to clean up after a crash. Mount the directory,
266
+ not the file, and never replace the file: the lock belongs to its inode.
267
+
253
268
  ## Converter Node
254
269
 
255
270
  A Conveter Node converts models from one format into another.
@@ -39,31 +39,32 @@ learning_loop_node/enums/__init__.py,sha256=tjSrhztIQ8W656_QuXfTbbVNtH_wDXP5hpYZ
39
39
  learning_loop_node/enums/annotator.py,sha256=mtTAw-8LJIrHcYkBjYHCZuhYEEHS6QzSK8k6BhLusvQ,285
40
40
  learning_loop_node/enums/detector.py,sha256=Qvm5LWWR9BfsDxHEQ8YzaPaUuSmp4BescYuV4X4ikwE,512
41
41
  learning_loop_node/enums/general.py,sha256=OSpcIpdFjuRByON2xBoiIXl1SgDREIqkwnaxkTFjnHU,164
42
- learning_loop_node/enums/trainer.py,sha256=VaD63guLO4aKgVfXT0EryPlXKQGegSET3Cp4R0uC_Ew,746
42
+ learning_loop_node/enums/trainer.py,sha256=VdamD-k6Q8kMm_cwPA8iaEUfZvmTxLfsBHQpEi43Ahk,849
43
43
  learning_loop_node/helpers/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
44
44
  learning_loop_node/helpers/background_tasks.py,sha256=sbbS8JO1ls0ZCbNW68KOT9QLOFyFoJRXwwAq78iOmoA,3256
45
45
  learning_loop_node/helpers/entrypoint.py,sha256=U4vHFqgke6mXjDL-E4MND8RO7P1k2VBGwdDOOoJvD9Y,3915
46
46
  learning_loop_node/helpers/environment_reader.py,sha256=zsoFysxNxBCOJzUwX4Pbm3ftYhxx5zsbOHQfD9VcfvA,1937
47
47
  learning_loop_node/helpers/gdrive_downloader.py,sha256=zeYJciTAJVRpu_eFjwgYLCpIa6hU1d71anqEBb564Rk,1145
48
48
  learning_loop_node/helpers/log_conf.py,sha256=hqVAa_9NnYEU6N0dcOKmph82p7MpgKqeF_eomTLYzWY,961
49
- learning_loop_node/helpers/misc.py,sha256=qdN18Ukbw6eR6sFL4BxNq0EZzg2tVm2Cm8cTbFyy8QU,8931
49
+ learning_loop_node/helpers/misc.py,sha256=zfy1SumqNBwBw6-BYignQJpwMeMf1k_MA3xhYt_GiG4,8939
50
50
  learning_loop_node/helpers/run.py,sha256=_uox-j3_K_bL3yCAwy3JYSOiIxrnhzVxyxWpCe8_J9U,876
51
51
  learning_loop_node/trainer/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
52
52
  learning_loop_node/trainer/batch_size.py,sha256=LjA-zyY4hdnSMy6ZsdQOhWbmd5H_DI-9LG3yFInv04E,5476
53
53
  learning_loop_node/trainer/cuda.py,sha256=ehpfWqPBCVFu06SnGsjmWbSDRJ8sZc-L_nCtemRLUOU,12567
54
54
  learning_loop_node/trainer/downloader.py,sha256=AIyEKM4XYtd6VgmXrP3VayV9DpJzdURK1Brx81ePNSM,1470
55
- learning_loop_node/trainer/exceptions.py,sha256=IRT7qvn7-RjXaqL8mF21ySm7JWhQtqm1Cl0Gi3DChVY,540
55
+ learning_loop_node/trainer/exceptions.py,sha256=hhd23WvuqV-6fiJo3vCNLN0hmF6TxAH9C5Bc7pCtmUM,940
56
56
  learning_loop_node/trainer/executor.py,sha256=XQ1TwgOF2pQv7bgzSaljDWG4I1ySjYXL8r9c_p9MX1Q,3947
57
57
  learning_loop_node/trainer/io_helpers.py,sha256=zWl0-HLP8zxOG3ybm76vXgEozULj_1QRl95jkrF126k,7255
58
58
  learning_loop_node/trainer/metrics.py,sha256=H1jQh5fHN3-cTdMTuH8mF_uODV0T9DBdqcPUD-ZqFAI,1439
59
59
  learning_loop_node/trainer/subprocess.py,sha256=WxuTly0zhpClEjTd9wqoI_-7YTNMaiZpRHxu00vttKw,2647
60
60
  learning_loop_node/trainer/test_executor.py,sha256=6BVGDN_6f5GEMMEvDLSG1yzMybSvgXaP5uYpSfsVPP0,2224
61
61
  learning_loop_node/trainer/trainer_logic.py,sha256=Yaw1GNTC05rVLQczY6vKUgkymBYfm3_0XaR5C0q-CP4,9159
62
- learning_loop_node/trainer/trainer_logic_generic.py,sha256=IRP68ZGpnOX4OS03OwB7HmTUvk6nbBRmCjMCVupGE_Q,27196
63
- learning_loop_node/trainer/trainer_node.py,sha256=ZnydfHJ2ehAhb821Xke_09qthCE3vDf7iqeYUrliSSo,4938
62
+ learning_loop_node/trainer/trainer_logic_generic.py,sha256=OtjdZlixz-2UXZkG_o5PaCdXCFvwgGuD5J4I9n5bROY,28812
63
+ learning_loop_node/trainer/trainer_node.py,sha256=GU4LyFA-JC9s4d-PbyKhKWyGMMIzTMqKJFNxM6tDJE4,5316
64
+ learning_loop_node/trainer/training_slot.py,sha256=Njq6Z0T2Lqlr24ftoIp11Pw2OxCo9Z2RGn1tuFhHqLY,6737
64
65
  learning_loop_node/trainer/rest/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
65
66
  learning_loop_node/trainer/rest/backdoor_controls.py,sha256=ZnK8ypY5r_q0-YZbtaOxhQThzuZvMsQHM5gJGESd_dE,5131
66
- learning_loop_node-0.24.0.dist-info/METADATA,sha256=rQNtas26mLdkkjI37mJ_jteRe7RtUO_3DpReanEUC2U,18609
67
- learning_loop_node-0.24.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
68
- learning_loop_node-0.24.0.dist-info/top_level.txt,sha256=nS5qiFc4stVF_VXVCVJxZnrqVOv3IGX5-_E3sLby6OE,19
69
- learning_loop_node-0.24.0.dist-info/RECORD,,
67
+ learning_loop_node-0.25.0.dist-info/METADATA,sha256=UOGaOXyynobyOFSEROGxOPH49odFh2-V2IGZ_LhQNsI,19855
68
+ learning_loop_node-0.25.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
69
+ learning_loop_node-0.25.0.dist-info/top_level.txt,sha256=nS5qiFc4stVF_VXVCVJxZnrqVOv3IGX5-_E3sLby6OE,19
70
+ learning_loop_node-0.25.0.dist-info/RECORD,,