jupyter-server-ydoc 2.0.1__py3-none-any.whl → 2.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.
@@ -1 +1 @@
1
- __version__ = "2.0.1"
1
+ __version__ = "2.1.0"
@@ -105,7 +105,7 @@ class YDocExtension(ExtensionApp):
105
105
  page_config.setdefault("serverSideExecution", self.server_side_execution)
106
106
 
107
107
  # Set configurable parameters to YStore class
108
- ystore_class = partial(self.ystore_class, config=self.config)
108
+ ystore_class: type[BaseYStore] = partial(self.ystore_class, config=self.config) # type:ignore[assignment]
109
109
 
110
110
  self.ywebsocket_server = JupyterWebsocketServer(
111
111
  rooms_ready=False,
@@ -205,7 +205,7 @@ class YDocExtension(ExtensionApp):
205
205
  if copy:
206
206
  update = room.ydoc.get_update()
207
207
 
208
- fork_ydoc = Doc()
208
+ fork_ydoc: Doc = Doc()
209
209
  fork_ydoc.apply_update(update)
210
210
 
211
211
  return YDOCS.get(room.file_type, YDOCS["file"])(fork_ydoc)
@@ -5,18 +5,18 @@ from __future__ import annotations
5
5
 
6
6
  import asyncio
7
7
  import json
8
- import time
9
8
  import uuid
10
9
  from logging import Logger
11
- from typing import Any, Literal
10
+ from typing import Any
12
11
  from uuid import uuid4
12
+ from typing import cast
13
13
 
14
14
  from jupyter_server.auth import authorized
15
15
  from jupyter_server.base.handlers import APIHandler, JupyterHandler
16
16
  from jupyter_server.utils import ensure_async
17
17
  from jupyter_ydoc import ydocs as YDOCS
18
- from pycrdt import Doc, UndoManager, write_var_uint
19
- from pycrdt_websocket.websocket_server import YRoom
18
+ from pycrdt import Doc, Encoder, UndoManager
19
+ from pycrdt_websocket.yroom import YRoom
20
20
  from pycrdt_websocket.ystore import BaseYStore
21
21
  from tornado import web
22
22
  from tornado.websocket import WebSocketHandler
@@ -28,12 +28,13 @@ from .utils import (
28
28
  JUPYTER_COLLABORATION_EVENTS_URI,
29
29
  JUPYTER_COLLABORATION_FORK_EVENTS_URI,
30
30
  LogLevel,
31
- MessageType,
32
31
  decode_file_path,
33
32
  encode_file_path,
34
33
  room_id_from_encoded_path,
35
34
  )
36
35
  from .websocketserver import JupyterWebsocketServer, RoomNotFound
36
+ from .utils import MessageType
37
+ from pycrdt import Decoder
37
38
 
38
39
  YFILE = YDOCS["file"]
39
40
 
@@ -117,7 +118,10 @@ class YDocWebSocketHandler(WebSocketHandler, JupyterHandler):
117
118
 
118
119
  file = self._file_loaders[file_id]
119
120
  updates_file_path = f".{file_type}:{file_id}.y"
120
- ystore = self._ystore_class(path=updates_file_path, log=self.log)
121
+ ystore = self._ystore_class(
122
+ path=updates_file_path,
123
+ log=self.log, # type:ignore[call-arg]
124
+ )
121
125
  self.room = DocumentRoom(
122
126
  self._room_id,
123
127
  file_format,
@@ -182,7 +186,7 @@ class YDocWebSocketHandler(WebSocketHandler, JupyterHandler):
182
186
  self._websocket_server = ywebsocket_server
183
187
  self._message_queue = asyncio.Queue()
184
188
  self._room_id = ""
185
- self.room = None
189
+ self.room = None # type:ignore
186
190
 
187
191
  @property
188
192
  def path(self):
@@ -219,7 +223,7 @@ class YDocWebSocketHandler(WebSocketHandler, JupyterHandler):
219
223
  raise web.HTTPError(403)
220
224
  return await super().get(*args, **kwargs)
221
225
 
222
- async def open(self, room_id):
226
+ async def open(self, room_id: str) -> None: # type:ignore[override]
223
227
  """
224
228
  On connection open.
225
229
  """
@@ -259,7 +263,7 @@ class YDocWebSocketHandler(WebSocketHandler, JupyterHandler):
259
263
  )
260
264
 
261
265
  # Clean up the room and delete the file loader
262
- if len(self.room.clients) == 0 or self.room.clients == [self]:
266
+ if len(self.room.clients) == 0 or self.room.clients == {self}:
263
267
  self._message_queue.put_nowait(b"")
264
268
  self._cleanup_delay = 0
265
269
  await self._clean_room()
@@ -269,7 +273,7 @@ class YDocWebSocketHandler(WebSocketHandler, JupyterHandler):
269
273
  if self._room_id != "JupyterLab:globalAwareness":
270
274
  self._emit_awareness_event(self.current_user.username, "join")
271
275
 
272
- async def send(self, message):
276
+ async def send(self, message: bytes) -> None:
273
277
  """
274
278
  Send a message to the client.
275
279
  """
@@ -290,38 +294,50 @@ class YDocWebSocketHandler(WebSocketHandler, JupyterHandler):
290
294
  """
291
295
  On message receive.
292
296
  """
293
- message_type = message[0]
294
-
295
- if message_type == MessageType.CHAT:
296
- msg = message[2:].decode("utf-8")
297
-
298
- user = self.current_user
299
- data = json.dumps(
300
- {
301
- "sender": user.username,
302
- "timestamp": time.time(),
303
- "content": json.loads(msg),
297
+ decoder = Decoder(message)
298
+ header = decoder.read_var_uint()
299
+ if header == MessageType.RAW:
300
+ msg = decoder.read_var_string()
301
+ if msg == "save":
302
+ save_id = decoder.read_var_uint()
303
+ save_reply = {
304
+ "type": "save",
305
+ "responseTo": save_id,
304
306
  }
305
- ).encode("utf8")
306
-
307
- for client in self.room.clients:
308
- if client != self:
309
- task = asyncio.create_task(
310
- client.send(bytes([MessageType.CHAT]) + write_var_uint(len(data)) + data)
311
- )
312
- self._websocket_server.background_tasks.add(task)
313
- task.add_done_callback(self._websocket_server.background_tasks.discard)
307
+ try:
308
+ room = cast(DocumentRoom, self.room)
309
+ save_task = room._save_to_disc()
310
+ # task may be missing if save was already in progress
311
+ if save_task:
312
+ await save_task
313
+ await self.send(
314
+ self._encode_json_message({**save_reply, "status": "success"})
315
+ )
316
+ else:
317
+ await self.send(
318
+ self._encode_json_message({**save_reply, "status": "skipped"})
319
+ )
320
+ except Exception:
321
+ self.log.error("Couldn't save content from room: %s", self._room_id)
322
+ await self.send(self._encode_json_message({**save_reply, "status": "failed"}))
323
+ return
314
324
 
315
325
  self._message_queue.put_nowait(message)
316
326
  self._websocket_server.ypatch_nb += 1
317
327
 
328
+ def _encode_json_message(self, message: dict) -> bytes:
329
+ encoder = Encoder()
330
+ encoder.write_var_uint(MessageType.RAW)
331
+ encoder.write_var_string(json.dumps(message))
332
+ return encoder.to_bytes()
333
+
318
334
  def on_close(self) -> None:
319
335
  """
320
336
  On connection close.
321
337
  """
322
338
  # stop serving this client
323
339
  self._message_queue.put_nowait(b"")
324
- if isinstance(self.room, DocumentRoom) and self.room.clients == [self]:
340
+ if isinstance(self.room, DocumentRoom) and self.room.clients == {self}:
325
341
  # no client in this room after we disconnect
326
342
  # keep the document for a while in case someone reconnects
327
343
  self.log.info("Cleaning room: %s", self._room_id)
@@ -386,9 +402,7 @@ class YDocWebSocketHandler(WebSocketHandler, JupyterHandler):
386
402
  self._emit(LogLevel.INFO, "clean", "Loader deleted.")
387
403
  del self._room_locks[self._room_id]
388
404
 
389
- def _on_global_awareness_event(
390
- self, topic: Literal["change", "update"], changes: tuple[dict[str, Any], Any]
391
- ) -> None:
405
+ def _on_global_awareness_event(self, topic: str, changes: tuple[dict[str, Any], Any]) -> None:
392
406
  """
393
407
  Update the users when the global awareness changes.
394
408
 
@@ -489,7 +503,7 @@ class TimelineHandler(APIHandler):
489
503
  try:
490
504
  room_id = room_id_from_encoded_path(encoded_path)
491
505
  room: YRoom = await self.ywebsocket_server.get_room(room_id)
492
- fork_ydoc = Doc()
506
+ fork_ydoc: Doc = Doc()
493
507
 
494
508
  ydoc_factory = YDOCS.get(content_type)
495
509
  if ydoc_factory is None:
@@ -505,7 +519,9 @@ class TimelineHandler(APIHandler):
505
519
  FORK_DOCUMENTS[idx] = ydoc_factory(fork_ydoc)
506
520
  undo_manager: UndoManager = FORK_DOCUMENTS[idx].undo_manager
507
521
 
508
- updates_and_timestamps = [(item[0], item[-1]) async for item in room.ystore.read()]
522
+ ystore = room.ystore
523
+ assert ystore
524
+ updates_and_timestamps = [(item[0], item[-1]) async for item in ystore.read()]
509
525
 
510
526
  result_timestamps = []
511
527
 
@@ -649,7 +665,7 @@ class DocForkHandler(APIHandler):
649
665
  return self.finish({"code": 404, "error": "Root room not found"})
650
666
 
651
667
  update = root_room.ydoc.get_update()
652
- fork_ydoc = Doc()
668
+ fork_ydoc: Doc = Doc()
653
669
  fork_ydoc.apply_update(update)
654
670
  model = self.get_json_body()
655
671
  synchronize = model.get("synchronize", False)
@@ -203,13 +203,28 @@ class FileLoader:
203
203
  if self._poll_interval is None:
204
204
  return
205
205
 
206
+ consecutive_error_logs = 0
207
+ max_consecutive_logs = 3
208
+ suppression_logged = False
209
+
206
210
  while True:
207
211
  try:
208
212
  await asyncio.sleep(self._poll_interval)
209
213
  try:
210
214
  await self.maybe_notify()
215
+ consecutive_error_logs = 0
216
+ suppression_logged = False
211
217
  except Exception as e:
212
- self._log.error(f"Error watching file: {self.path}\n{e!r}", exc_info=e)
218
+ if consecutive_error_logs < max_consecutive_logs:
219
+ self._log.error(f"Error watching file: {self.path}\n{e!r}", exc_info=e)
220
+ consecutive_error_logs += 1
221
+ elif not suppression_logged:
222
+ self._log.warning(
223
+ "Too many errors while watching %s — suppressing further logs.",
224
+ self.path,
225
+ )
226
+ suppression_logged = True
227
+
213
228
  except asyncio.CancelledError:
214
229
  break
215
230
 
@@ -240,7 +240,12 @@ def rtc_add_doc_to_store(rtc_connect_doc_client):
240
240
 
241
241
  def rtc_create_SQLite_store_factory(jp_serverapp):
242
242
  async def _inner(type: str, path: str, content: str) -> DocumentRoom:
243
- db = SQLiteYStore(path=f"{type}:{path}", config=jp_serverapp.config)
243
+ db = SQLiteYStore(
244
+ path=f"{type}:{path}",
245
+ # `SQLiteYStore` here is a subclass of booth `LoggingConfigurable`
246
+ # and `pycrdt_websocket.ystore.SQLiteYStore`, but mypy gets lost:
247
+ config=jp_serverapp.config, # type:ignore[call-arg]
248
+ )
244
249
  _ = create_task(db.start())
245
250
  await db.started.wait()
246
251
 
@@ -271,7 +276,7 @@ def rtc_create_mock_document_room():
271
276
  last_modified: datetime | None = None,
272
277
  save_delay: float | None = None,
273
278
  store: SQLiteYStore | None = None,
274
- writable: bool = False,
279
+ writable: bool = True,
275
280
  ) -> tuple[FakeContentsManager, FileLoader, DocumentRoom]:
276
281
  paths = {id: path}
277
282
 
@@ -9,7 +9,7 @@ from typing import Any, Callable
9
9
 
10
10
  from jupyter_events import EventLogger
11
11
  from jupyter_ydoc import ydocs as YDOCS
12
- from pycrdt_websocket.websocket_server import YRoom
12
+ from pycrdt_websocket.yroom import YRoom
13
13
  from pycrdt_websocket.ystore import BaseYStore, YDocNotFound
14
14
 
15
15
  from .loaders import FileLoader
@@ -103,7 +103,7 @@ class DocumentRoom(YRoom):
103
103
  It is important to set the ready property in the parent class (`self.ready = True`),
104
104
  this setter will subscribe for updates on the shared document.
105
105
  """
106
- if self.ready: # type: ignore[has-type]
106
+ if self.ready:
107
107
  return
108
108
 
109
109
  self.log.info("Initializing room %s", self._room_id)
@@ -247,12 +247,40 @@ class DocumentRoom(YRoom):
247
247
  document. This tasks are debounced (60 seconds by default) so we
248
248
  need to cancel previous tasks before creating a new one.
249
249
  """
250
+ # Collect autosave values from all clients
251
+ autosave_states = [
252
+ state.get("autosave", True)
253
+ for state in self.awareness.states.values()
254
+ if state # skip empty states
255
+ ]
256
+
257
+ # If no states exist (e.g., during tests), force autosave to be True
258
+ if not autosave_states:
259
+ autosave_states = [True]
260
+
261
+ # Enable autosave if at least one client has it turned on
262
+ autosave = any(autosave_states)
263
+
264
+ if not autosave:
265
+ return
266
+ if self._update_lock.locked():
267
+ return
268
+
269
+ self._saving_document = asyncio.create_task(
270
+ self._maybe_save_document(self._saving_document)
271
+ )
272
+
273
+ def _save_to_disc(self):
274
+ """
275
+ Called when manual save is triggered. Helpful when autosave is turned off.
276
+ """
250
277
  if self._update_lock.locked():
251
278
  return
252
279
 
253
280
  self._saving_document = asyncio.create_task(
254
281
  self._maybe_save_document(self._saving_document)
255
282
  )
283
+ return self._saving_document
256
284
 
257
285
  async def _maybe_save_document(self, saving_document: asyncio.Task | None) -> None:
258
286
  """
@@ -265,7 +293,6 @@ class DocumentRoom(YRoom):
265
293
  """
266
294
  if self._save_delay is None:
267
295
  return
268
-
269
296
  if saving_document is not None and not saving_document.done():
270
297
  # the document is being saved, cancel that
271
298
  saving_document.cancel()
@@ -285,9 +312,9 @@ class DocumentRoom(YRoom):
285
312
  "content": self._document.source,
286
313
  }
287
314
  )
288
- async with self._update_lock:
289
- self._document.dirty = False
290
- if saved_model:
315
+ if saved_model:
316
+ async with self._update_lock:
317
+ self._document.dirty = False
291
318
  self._document.hash = saved_model["hash"]
292
319
 
293
320
  self._emit(LogLevel.INFO, "save", "Content saved.")
@@ -19,6 +19,7 @@ FORK_EVENTS_SCHEMA_PATH = EVENTS_FOLDER_PATH / "fork.yaml"
19
19
  class MessageType(IntEnum):
20
20
  SYNC = 0
21
21
  AWARENESS = 1
22
+ RAW = 2
22
23
  CHAT = 125
23
24
 
24
25
 
@@ -7,9 +7,10 @@ import asyncio
7
7
  from logging import Logger
8
8
  from typing import Any, Callable
9
9
 
10
- from pycrdt_websocket.websocket_server import WebsocketServer, YRoom
10
+ from pycrdt_websocket.websocket import Websocket
11
+ from pycrdt_websocket.websocket_server import WebsocketServer
12
+ from pycrdt_websocket.yroom import YRoom
11
13
  from pycrdt_websocket.ystore import BaseYStore
12
- from tornado.websocket import WebSocketHandler
13
14
 
14
15
 
15
16
  class RoomNotFound(LookupError):
@@ -38,7 +39,7 @@ class JupyterWebsocketServer(WebsocketServer):
38
39
 
39
40
  def __init__(
40
41
  self,
41
- ystore_class: BaseYStore,
42
+ ystore_class: type[BaseYStore],
42
43
  rooms_ready: bool = True,
43
44
  auto_clean_rooms: bool = True,
44
45
  exception_handler: Callable[[Exception, Logger], bool] | None = None,
@@ -132,7 +133,7 @@ class JupyterWebsocketServer(WebsocketServer):
132
133
  await self.start_room(room)
133
134
  return room
134
135
 
135
- async def serve(self, websocket: WebSocketHandler) -> None:
136
+ async def serve(self, websocket: Websocket) -> None:
136
137
  # start monitoring here as the event loop is not yet available when initializing the object
137
138
  if self.monitor_task is None:
138
139
  self.monitor_task = asyncio.create_task(self._monitor())
@@ -1,7 +1,12 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: jupyter-server-ydoc
3
- Version: 2.0.1
3
+ Version: 2.1.0
4
4
  Summary: jupyter-server extension integrating collaborative shared models.
5
+ Project-URL: Documentation, https://jupyterlab-realtime-collaboration.readthedocs.io/
6
+ Project-URL: Repository, https://github.com/jupyterlab/jupyter-collaboration
7
+ Project-URL: Changelog, https://jupyterlab-realtime-collaboration.readthedocs.io/en/latest/changelog.html
8
+ Project-URL: Source, https://github.com/jupyterlab/jupyter-collaboration/tree/main/projects/jupyter-server-ydoc
9
+ Project-URL: Issues, https://github.com/jupyterlab/jupyter-collaboration/issues/new/choose
5
10
  Author-email: Jupyter Development Team <jupyter@googlegroups.com>
6
11
  License: # Licensing terms
7
12
 
@@ -0,0 +1,19 @@
1
+ jupyter_server_ydoc/__init__.py,sha256=B8H7XLhzgrTCQD8304Lx91FYXslwabsnV9OuYu4M4Hw,346
2
+ jupyter_server_ydoc/_version.py,sha256=Xybt2skBZamGMNlLuOX1IG-h4uIxqUDGAO8MIGWrJac,22
3
+ jupyter_server_ydoc/app.py,sha256=6Zca0acaEFCKyUbXdmcPmVk1Dgu91Y-DFjYR16WKFlg,8161
4
+ jupyter_server_ydoc/handlers.py,sha256=x3R5VluXWFN460AiC7xNbipSpCR7SQSjqrcwjBorgpw,28836
5
+ jupyter_server_ydoc/loaders.py,sha256=XUQqg2EbfQUYlQVjHY183gYKeVZ6x92VHy4EsOQz4fA,11303
6
+ jupyter_server_ydoc/pytest_plugin.py,sha256=yMVlk7fvHXiM7g6ua0Ophc96cFwC0bmTWVDbbUeu2DE,8741
7
+ jupyter_server_ydoc/rooms.py,sha256=O0dYKeDRMUPFRraFJf8irqnFaSD80hirrtAYBXKTgrM,13103
8
+ jupyter_server_ydoc/stores.py,sha256=_5J6eNs3R5Tv88PCc-GGuszxQstfvNoBCYABqzBzJXA,1004
9
+ jupyter_server_ydoc/test_utils.py,sha256=utUwB5FThc_SCQshhUbLNih9GUa5qBcmMgU6-jx0ZnA,2275
10
+ jupyter_server_ydoc/utils.py,sha256=_wI4CFOEZK4MSMYCZe2moSbggUTRdGxY4qTzSDEFzdE,2183
11
+ jupyter_server_ydoc/websocketserver.py,sha256=h1yTgJcsCK17_97Ne5x-lbgIFsxylwnltxagcuAlTJY,5185
12
+ jupyter_server_ydoc/events/awareness.yaml,sha256=2FrCci5rZIaU4rn8pIPZJkd132YAZdzKjSNSwjOY7Dk,755
13
+ jupyter_server_ydoc/events/fork.yaml,sha256=3OrhQjhVyLjlBJWMiffbnZodL3GzFafLwEmSBFrK33o,1303
14
+ jupyter_server_ydoc/events/session.yaml,sha256=PS0MxowpRwY5QFYm-LJvHUxKHnsictV8_6VEwfhYxcQ,1596
15
+ jupyter_server_ydoc-2.1.0.data/data/etc/jupyter/jupyter_server_config.d/jupyter_collaboration.json,sha256=0thh2hJUxAKkZSmneJMG0U6QJRjdM6zGlwrTedEt-Jk,94
16
+ jupyter_server_ydoc-2.1.0.dist-info/METADATA,sha256=Rfb1XEPiOQTYrI0JZ8ZFpiYvWIhmxCalR0-OmFPVwT8,5587
17
+ jupyter_server_ydoc-2.1.0.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
18
+ jupyter_server_ydoc-2.1.0.dist-info/licenses/LICENSE,sha256=mhO0ZW9EiWOPg0dUgB-lNbJ0CGwRmTdbeAg_se1SOnY,2833
19
+ jupyter_server_ydoc-2.1.0.dist-info/RECORD,,
@@ -1,19 +0,0 @@
1
- jupyter_server_ydoc/__init__.py,sha256=B8H7XLhzgrTCQD8304Lx91FYXslwabsnV9OuYu4M4Hw,346
2
- jupyter_server_ydoc/_version.py,sha256=wAxkK8w13vqoF47A8iqWdSlIgRRXmZiQ0R4wePZfzhs,22
3
- jupyter_server_ydoc/app.py,sha256=JqkpijoPdo_qum0CKqbG-I6W8fpC3-v2cFA3kFxK3mg,8111
4
- jupyter_server_ydoc/handlers.py,sha256=Q0A638Ke_TR-hSdAgyz8u1gIOCk3FIqWRhV-QH2PLMM,27977
5
- jupyter_server_ydoc/loaders.py,sha256=rUPZZsMIxcE7ZEWTY8O2_ib7DUCmLvvdptqZYpsusdY,10676
6
- jupyter_server_ydoc/pytest_plugin.py,sha256=M9dQhiyDnGxFWxoGDj5l5lRVyfkYFKdU2i1Yj_Z16sU,8525
7
- jupyter_server_ydoc/rooms.py,sha256=szOAfMldhQIrmVpqoF75O0_KXY54X_TrzJz6vpjR6kE,12254
8
- jupyter_server_ydoc/stores.py,sha256=_5J6eNs3R5Tv88PCc-GGuszxQstfvNoBCYABqzBzJXA,1004
9
- jupyter_server_ydoc/test_utils.py,sha256=utUwB5FThc_SCQshhUbLNih9GUa5qBcmMgU6-jx0ZnA,2275
10
- jupyter_server_ydoc/utils.py,sha256=EgKC15js8VOS8-5jGMs4pfHQfV9drnNT2Gew5UlyXZc,2171
11
- jupyter_server_ydoc/websocketserver.py,sha256=7fLPJcWczD-4R_-LXtfvNxM_pUXFasZWDmT4RIrOQHE,5150
12
- jupyter_server_ydoc/events/awareness.yaml,sha256=2FrCci5rZIaU4rn8pIPZJkd132YAZdzKjSNSwjOY7Dk,755
13
- jupyter_server_ydoc/events/fork.yaml,sha256=3OrhQjhVyLjlBJWMiffbnZodL3GzFafLwEmSBFrK33o,1303
14
- jupyter_server_ydoc/events/session.yaml,sha256=PS0MxowpRwY5QFYm-LJvHUxKHnsictV8_6VEwfhYxcQ,1596
15
- jupyter_server_ydoc-2.0.1.data/data/etc/jupyter/jupyter_server_config.d/jupyter_collaboration.json,sha256=0thh2hJUxAKkZSmneJMG0U6QJRjdM6zGlwrTedEt-Jk,94
16
- jupyter_server_ydoc-2.0.1.dist-info/METADATA,sha256=-hxNueyhVsO-DXfdiYoiBpQ_rvk6MU9OwRnh-xHg3LA,5115
17
- jupyter_server_ydoc-2.0.1.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
18
- jupyter_server_ydoc-2.0.1.dist-info/licenses/LICENSE,sha256=mhO0ZW9EiWOPg0dUgB-lNbJ0CGwRmTdbeAg_se1SOnY,2833
19
- jupyter_server_ydoc-2.0.1.dist-info/RECORD,,