diff --git a/CHANGELOG.md b/CHANGELOG.md index fd8e28e..1c80bc2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,12 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.3.0] - 2024-10-08 + +### Added + +- core.FileWatcher: watch a file for changes + ## [0.2.0] - 2024-10-08 ### Added @@ -49,7 +55,8 @@ _First release._ - core.ColoredDataFrameTableModel: An extension of DataFrameTableModel providing color-mapped numerical data. - widgets.StatefulButton: A QPushButton that maintains an active/inactive state. +[0.3.0]: https://github.com/int-brain-lab/iblqt/releases/tag/v0.3.0 [0.2.0]: https://github.com/int-brain-lab/iblqt/releases/tag/v0.2.0 [0.1.2]: https://github.com/int-brain-lab/iblqt/releases/tag/v0.1.2 [0.1.1]: https://github.com/int-brain-lab/iblqt/releases/tag/v0.1.1 -[0.1.0]: https://github.com/int-brain-lab/iblqt/releases/tag/v0.1.0 \ No newline at end of file +[0.1.0]: https://github.com/int-brain-lab/iblqt/releases/tag/v0.1.0 diff --git a/iblqt/__init__.py b/iblqt/__init__.py index 6c6de40..1ef9198 100644 --- a/iblqt/__init__.py +++ b/iblqt/__init__.py @@ -1,3 +1,3 @@ """A collection of extensions to the Qt framework.""" -__version__ = '0.2.0' +__version__ = '0.3.0' diff --git a/iblqt/core.py b/iblqt/core.py index fff466e..838155f 100644 --- a/iblqt/core.py +++ b/iblqt/core.py @@ -1,13 +1,16 @@ """Non-GUI functionality, including event handling, data types, and data management.""" import logging +from pathlib import Path from typing import Any from pyqtgraph import ColorMap, colormap # type: ignore from qtpy.QtCore import ( QAbstractTableModel, + QFileSystemWatcher, Qt, QModelIndex, + QReadWriteLock, QObject, Property, Signal, @@ -410,3 +413,48 @@ def data(self, index: QModelIndex, role: int = Qt.ItemDataRole.DisplayRole) -> A lum = self._foreground[row][col] return QColor('black' if (lum * self._alpha) < 32512 else 'white') return super().data(index, role) + + +class FileWatcher(QObject): + """Watch a file for changes.""" + + fileChanged = Signal() # type: Signal + """Emitted when the file's content has changed.""" + + fileSizeChanged = Signal(int) # type: Signal + """Emitted when the file's size has changed. The signal carries the new size.""" + + def __init__(self, parent: QObject, file: Path | str): + """Initialize the FileWatcher. + + Parameters + ---------- + parent : QObject + The parent object. + file : Path or str + The path to the file to watch. + """ + super().__init__(parent=parent) + self._file = Path(file) + if not self._file.exists(): + raise FileNotFoundError(self._file) + if self._file.is_dir(): + raise IsADirectoryError(self._file) + + self._size = self._file.stat().st_size + self._lock = QReadWriteLock() + self._fileWatcher = QFileSystemWatcher([str(file)], parent) + self._fileWatcher.fileChanged.connect(self._onFileChanged) + + @Slot(str) + def _onFileChanged(self, _): + self.fileChanged.emit() + new_size = self._file.stat().st_size + + self._lock.lockForWrite() + try: + if new_size != self._size: + self.fileSizeChanged.emit(new_size) + self._size = new_size + finally: + self._lock.unlock() diff --git a/pyproject.toml b/pyproject.toml index 080a7fb..d510520 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -84,4 +84,3 @@ files = ["iblqt/**/*.py", "tests/**/*.py"] addopts = "--cov=iblqt --cov-report=html --cov-report=xml" minversion = "6.0" testpaths = [ "tests" ] -qt_api = 'pyqt5' \ No newline at end of file diff --git a/tests/test_core.py b/tests/test_core.py index 02ee36d..b05dc11 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -1,5 +1,9 @@ +from pathlib import Path + +import pytest from qtpy.QtCore import Qt, QModelIndex from iblqt import core +import tempfile import pandas as pd @@ -72,3 +76,27 @@ def test_dataframe_model(qtbot): assert model.alpha == 128 assert model.data(model.index(0, 0), Qt.ItemDataRole.BackgroundRole).alpha() == 128 assert model.data(model.index(2, 0), Qt.ItemDataRole.BackgroundRole).alpha() == 128 + + +def test_fileWatcher(qtbot): + with tempfile.NamedTemporaryFile() as file: + parent = core.QObject() + path = Path(file.name) + + w = core.FileWatcher(parent=parent, file=path) + + # Modify the file to trigger the watcher + with qtbot.waitSignal(w.fileChanged): + with qtbot.waitSignal(w.fileSizeChanged) as blocker: + with open(path, 'a') as f: + f.write('Hello, World!') + assert blocker.args[0] == path.stat().st_size + + # Modify the file (without changing its size) + with qtbot.waitSignal(w.fileChanged): + with qtbot.assertNotEmitted(w.fileSizeChanged, wait=100): + with open(path, 'w') as f: + f.write('Hello, World?') + + with pytest.raises(FileNotFoundError): + core.FileWatcher(parent=parent, file='non-existent file')