Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Listen to ContentsManager save events #18

Merged
merged 4 commits into from
Oct 18, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 10 additions & 3 deletions jupyter_server_fileid/extension.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,24 +19,30 @@ class FileIdExtension(ExtensionApp):

@default("file_id_manager")
def _file_id_manager_default(self):
self.log.debug("No File ID manager configured. Defaulting to FileIdManager")
self.log.debug("No File ID manager configured. Defaulting to FileIdManager.")
return FileIdManager

def initialize_settings(self):
self.log.debug(f"Configured File ID manager: {self.file_id_manager_class.__name__}")
file_id_manager = self.file_id_manager_class(log=self.log, root_dir=self.serverapp.root_dir)
self.settings.update({"file_id_manager": file_id_manager})

# attach listener to contents manager events (requires jupyter_server~=2)
if "event_logger" in self.settings:
self.initialize_event_listeners()

def initialize_event_listeners(self):
file_id_manager = self.settings["file_id_manager"]

# define event handlers per contents manager action
handlers_by_action = {
"get": None,
"save": None,
"save": lambda data: file_id_manager.save(data["path"]),
"rename": lambda data: file_id_manager.move(data["source_path"], data["path"]),
"copy": lambda data: file_id_manager.copy(data["source_path"], data["path"]),
"delete": lambda data: file_id_manager.delete(data["path"]),
}

# attach listener to contents manager events (JS2+)
async def cm_listener(logger: EventLogger, schema_id: str, data: dict) -> None:
handler = handlers_by_action[data["action"]]
if handler:
Expand All @@ -46,6 +52,7 @@ async def cm_listener(logger: EventLogger, schema_id: str, data: dict) -> None:
schema_id="https://events.jupyter.org/jupyter_server/contents_service/v1",
listener=cm_listener,
)
self.log.info("Attached event listeners.")

def initialize_handlers(self):
self.handlers.extend(
Expand Down
28 changes: 28 additions & 0 deletions jupyter_server_fileid/manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -524,6 +524,34 @@ def delete(self, path):
self.con.execute("DELETE FROM Files WHERE path = ?", (path,))
self.con.commit()

def save(self, path):
"""Handles file saves (edits) by updating recorded stat info.

Notes
-----
- This assumes that the file was present prior to the save event. That
means it's technically possible to fool this method by deleting and
creating a new file at the same path out-of-band, and then update it via
JupyterLab. This would (wrongly) preserve the association b/w the old
file ID and the current path rather than create a new file ID.
"""
path = self._normalize_path(path)

# look up record by ino and path
stat_info = self._stat(path)
row = self.con.execute(
"SELECT id FROM Files WHERE ino = ? AND path = ?", (stat_info.ino, path)
).fetchone()

# if no record exists, return early
if row is None:
return

# otherwise, update the stat info
(id,) = row
self._update(id, stat_info)
self.con.commit()

def __del__(self):
"""Cleans up `FileIdManager` by committing any pending transactions and
closing the connection."""
Expand Down
10 changes: 9 additions & 1 deletion jupyter_server_fileid/pytest_plugin.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ def fid_manager(fid_db_path, jp_root_dir):


@pytest.fixture
def fs_helpers(fid_manager):
def fs_helpers():
class FsHelpers:
# seconds after test start that the `touch` and `move` methods set
# timestamps to
Expand Down Expand Up @@ -76,4 +76,12 @@ def move(self, old_path, new_path):

self.fake_time += 1

def edit(self, path):
"""Simulates editing a file at `path` by updating its modified time
accordingly. The modified time of the file is guaranteed to be
unique."""
stat = os.stat(path)
os.utime(path, (stat.st_atime, stat.st_mtime + self.fake_time))
self.fake_time += 1

return FsHelpers()
4 changes: 2 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -19,13 +19,13 @@ classifiers = [
"Programming Language :: Python :: 3.10",
"Framework :: Jupyter",
]
dependencies = ["jupyter_server~=2.0.0rc1", "jupyter_events~=0.5.0"]
dependencies = ["jupyter_server>=1.15, <3", "jupyter_events~=0.5.0"]

[project.optional-dependencies]
test = [
"pytest",
"pytest-cov",
"jupyter_server[test]~=2.0.0rc1"
"jupyter_server[test]>=1.15, <3"
]

cli = [
Expand Down
9 changes: 9 additions & 0 deletions tests/test_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -420,3 +420,12 @@ def test_delete_recursive(fid_manager, test_path, test_path_child):
fid_manager.delete(test_path)

assert fid_manager.get_id(test_path_child) is None


def test_save(fid_manager, test_path, fs_helpers):
id = fid_manager.index(test_path)

fs_helpers.edit(test_path)
fid_manager.save(test_path)

assert fid_manager.get_id(test_path) == id