Skip to content

Commit 5d53900

Browse files
committed
feat: implement local database for caching (partially #5)
1 parent aab4871 commit 5d53900

8 files changed

Lines changed: 211 additions & 34 deletions

File tree

CHANGELOG

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
0.17.5
1+
0.18.0
2+
- feat: implement local database for caching (partially #5)
23
- fix: missing .rtdc resource files in filter view
34
- fix: error handling when connection to server fails on download (#75)
45
- fix: '_condensed' suffix for file stem randomly missing in downloads

dcoraid/dbmodel/db_api_cached.py

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
import logging
2+
import pathlib
3+
import time
4+
5+
from .db_core import DBInterrogator
6+
from .db_api import APIInterrogator
7+
from .meta_cache import MetaCache
8+
from .extract import DBExtract
9+
10+
11+
logger = logging.getLogger(__name__)
12+
13+
14+
class CachedAPIInterrogator(DBInterrogator):
15+
def __init__(self, api, cache_location):
16+
self.api = api.copy()
17+
self.ai = APIInterrogator(api)
18+
self.cache_location = (
19+
pathlib.Path(cache_location)
20+
/ api.hostname
21+
/ (api.user_name or "anonymous")
22+
)
23+
self.cache_location.mkdir(parents=True, exist_ok=True)
24+
25+
self._cache_fleeting = {
26+
"circles": [],
27+
"collections": [],
28+
}
29+
30+
if api.user_id:
31+
mode = "user"
32+
user_data = api.get_user_dict()
33+
else:
34+
mode = "public"
35+
user_data = None
36+
37+
self._mc = MetaCache(self.cache_location)
38+
self._mc_timestamp_path = self.cache_location / "cache_timestamp"
39+
self._mc_timestamp_path.touch()
40+
self._mc_version_path = self.cache_location / "cache_version"
41+
self._mc_version_path.touch()
42+
43+
super(CachedAPIInterrogator, self).__init__(mode=mode,
44+
user_data=user_data)
45+
46+
@property
47+
def local_timestamp(self):
48+
return float(self._mc_timestamp_path.read_text().strip() or "0")
49+
50+
@local_timestamp.setter
51+
def local_timestamp(self, timestamp):
52+
self._mc_timestamp_path.write_text(str(timestamp))
53+
54+
@property
55+
def local_version_score(self):
56+
return int(self._mc_version_path.read_text().strip() or "0")
57+
58+
def close(self):
59+
self._mc.close()
60+
61+
def get_circles(self, refresh=False):
62+
"""Return the list of DCOR Circle names
63+
"""
64+
clist = self._cache_fleeting["circles"]
65+
if not clist or refresh:
66+
clist.clear()
67+
clist += self.ai.get_circles()
68+
return clist
69+
70+
def get_collections(self, refresh=False):
71+
"""Return the list of DCOR Collection names"""
72+
clist = self._cache_fleeting["collections"]
73+
if not clist or refresh:
74+
clist.clear()
75+
clist += self.ai.get_collections()
76+
return clist
77+
78+
def get_datasets_user_following(self):
79+
"""Return datasets the user is following"""
80+
# TODO: Use datasets in self._mc
81+
return self.ai.get_datasets_user_following()
82+
83+
def get_datasets_user_owned(self):
84+
"""Return datasets the user created"""
85+
# TODO: Use datasets in self._mc
86+
return self.ai.get_datasets_user_owned()
87+
88+
def get_datasets_user_shared(self):
89+
"""Return datasets shared with the user"""
90+
# TODO: Use datasets in self._mc
91+
return self.ai.get_datasets_user_shared()
92+
93+
def get_users(self):
94+
"""Return the list of DCOR users"""
95+
return self.ai.get_users()
96+
97+
def search_dataset(self, query="", limit=100):
98+
"""Search datasets via the CKAN API
99+
100+
Parameters
101+
----------
102+
query: str
103+
search query
104+
limit: int
105+
limit number of search results; Set to 0 to get all results
106+
"""
107+
return DBExtract(self._mc.search(query, limit))
108+
109+
def update(self, reset=False, progress_callback=None):
110+
"""Update the local metadata cache based on the last local timestamp"""
111+
if self.remote_version_score != self.local_version_score:
112+
reset = True
113+
114+
if reset:
115+
self.local_timestamp = 0
116+
self._mc.destroy()
117+
self._mc = MetaCache(self.cache_location)
118+
119+
circles = self.get_circles(refresh=True)
120+
self.get_collections(refresh=True)
121+
122+
new_timestamp = time.time()
123+
for cc in circles:
124+
logger.info(f"Updating metadata cache for circle '{cc}'")
125+
for ds_dict in self.ai.search_dataset_via_api(
126+
circles=[cc],
127+
since_time=self.local_timestamp,
128+
):
129+
self._mc.upsert_dataset(ds_dict)
130+
self.local_timestamp = new_timestamp

dcoraid/gui/main.py

Lines changed: 49 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
import atexit
2+
import traceback
23
from contextlib import ExitStack
34
import logging
45
from importlib import resources
56
import signal
67
import sys
8+
import time
79
import traceback as tb
810

911
import dclab
@@ -15,13 +17,16 @@
1517
from PyQt6 import uic, QtCore, QtGui, QtWidgets
1618

1719
from ..api import APIOutdatedError
20+
from ..dbmodel import CachedAPIInterrogator
1821
from .._version import __version__
1922

23+
from .api import get_ckan_api
2024
from .preferences import PreferencesDialog
2125
from .status_widget import StatusWidget
2226
from . import updater
2327
from .wizard import SetupWizard
2428

29+
2530
file_manager = ExitStack()
2631
atexit.register(file_manager.close)
2732

@@ -33,8 +38,6 @@
3338

3439

3540
class DCORAid(QtWidgets.QMainWindow):
36-
plots_changed = QtCore.pyqtSignal()
37-
3841
def __init__(self, *args, **kwargs):
3942
"""Initialize DCOR-Aid
4043
@@ -125,8 +128,52 @@ def __init__(self, *args, **kwargs):
125128
self.show()
126129
self.raise_()
127130

131+
# setup the metadata database
132+
try:
133+
self.database = CachedAPIInterrogator(
134+
api=get_ckan_api(),
135+
cache_location=QtCore.QStandardPaths.writableLocation(
136+
QtCore.QStandardPaths.StandardLocation.CacheLocation)
137+
)
138+
self._last_asked_about_update = 0
139+
self.check_update_database(
140+
force=int(self.settings.value(
141+
"update database on startup", "0"))
142+
)
143+
except BaseException:
144+
self.logger.error(traceback.format_exc())
145+
else:
146+
self.panel_find_data.set_database(self.database)
147+
self.panel_my_data.set_database(self.database)
148+
128149
self.status_widget.request_status_update()
129150

151+
@QtCore.pyqtSlot(bool)
152+
def check_update_database(self, reset=False, force=False):
153+
doit = False
154+
if force:
155+
doit = True
156+
else:
157+
if (self.database.local_timestamp < time.time() - 24*3600
158+
and self._last_asked_about_update < time.time() - 3600):
159+
# Ask the user whether the cache should be updated
160+
button_reply = QtWidgets.QMessageBox.question(
161+
self,
162+
'Database outdated',
163+
"The local database is outdated. Would you like to "
164+
"refresh the database?",
165+
QtWidgets.QMessageBox.StandardButton.Yes
166+
| QtWidgets.QMessageBox.StandardButton.No,
167+
QtWidgets.QMessageBox.StandardButton.Yes)
168+
doit = button_reply == QtWidgets.QMessageBox.StandardButton.Yes
169+
if doit:
170+
# TODO: Progress monitoring
171+
self.setEnabled(False)
172+
self.database.update()
173+
self.setEnabled(True)
174+
175+
self._last_asked_about_update = time.time()
176+
130177
@QtCore.pyqtSlot(QtCore.QEvent)
131178
def closeEvent(self, event):
132179
root_logger = logging.getLogger()
Lines changed: 17 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,9 @@
11
import logging
2-
import traceback as tb
32

43
from importlib import resources
54

65
from PyQt6 import uic, QtCore, QtWidgets
76

8-
from ...common import ConnectionTimeoutErrors
9-
from ...dbmodel import APIInterrogator
10-
11-
from ..api import get_ckan_api
127
from ..main import DCORAid
138
from ..status_widget import StatusWidget
149

@@ -32,34 +27,35 @@ def __init__(self, *args, **kwargs):
3227
title = StatusWidget.get_title(server)
3328
self.label_search.setText(f"Search {title or 'DCOR'}")
3429

30+
self.database = None
31+
3532
# Signals for data browser
36-
self.pushButton_public_search.clicked.connect(self.on_public_search)
33+
self.pushButton_search.clicked.connect(self.on_search)
34+
self.pushButton_update_db.clicked.connect(self.on_update_db)
3735
self.public_filter_chain.download_resource.connect(
3836
self.request_download)
3937

4038
@QtCore.pyqtSlot()
41-
def on_public_search(self):
39+
def on_search(self):
40+
self.find_main_window().check_update_database()
4241
self.setCursor(QtCore.Qt.CursorShape.WaitCursor)
43-
api = get_ckan_api(
44-
public=not self.checkBox_public_include_private.isChecked())
45-
try:
46-
db = APIInterrogator(api=api)
47-
dbextract = db.search_dataset(
48-
self.lineEdit_public_search.text(),
49-
limit=self.spinBox_public_rows.value())
50-
self.public_filter_chain.set_db_extract(dbextract)
51-
except ConnectionTimeoutErrors:
52-
logger.error(tb.format_exc())
53-
QtWidgets.QMessageBox.critical(
54-
self,
55-
f"Failed to connect to {api.server}",
56-
tb.format_exc(limit=1))
42+
dbextract = self.database.search_dataset(
43+
self.lineEdit_search.text(),
44+
limit=self.spinBox_public_rows.value())
45+
self.public_filter_chain.set_db_extract(dbextract)
5746
self.setCursor(QtCore.Qt.CursorShape.ArrowCursor)
5847

48+
@QtCore.pyqtSlot()
49+
def on_update_db(self):
50+
self.find_main_window().check_update_database(force=True)
51+
5952
@staticmethod
6053
def find_main_window():
6154
# Global function to find the (open) QMainWindow in application
6255
app = QtWidgets.QApplication.instance()
6356
for widget in app.topLevelWidgets():
6457
if isinstance(widget, DCORAid):
6558
return widget
59+
60+
def set_database(self, database):
61+
self.database = database

dcoraid/gui/panel_find_data/widget_find_data.ui

Lines changed: 7 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@
2424
</widget>
2525
</item>
2626
<item>
27-
<widget class="QLineEdit" name="lineEdit_public_search">
27+
<widget class="QLineEdit" name="lineEdit_search">
2828
<property name="sizePolicy">
2929
<sizepolicy hsizetype="Expanding" vsizetype="Preferred">
3030
<horstretch>0</horstretch>
@@ -82,22 +82,19 @@
8282
</widget>
8383
</item>
8484
<item>
85-
<widget class="QCheckBox" name="checkBox_public_include_private">
85+
<widget class="QPushButton" name="pushButton_search">
8686
<property name="text">
87-
<string>include private</string>
87+
<string>Search</string>
8888
</property>
89-
<property name="checked">
90-
<bool>true</bool>
89+
<property name="shortcut">
90+
<string>Return</string>
9191
</property>
9292
</widget>
9393
</item>
9494
<item>
95-
<widget class="QPushButton" name="pushButton_public_search">
95+
<widget class="QPushButton" name="pushButton_update_db">
9696
<property name="text">
97-
<string>Search</string>
98-
</property>
99-
<property name="shortcut">
100-
<string>Return</string>
97+
<string>Update DB</string>
10198
</property>
10299
</widget>
103100
</item>

dcoraid/gui/panel_my_data/widget_my_data.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,3 +62,7 @@ def find_main_window():
6262
for widget in app.topLevelWidgets():
6363
if isinstance(widget, DCORAid):
6464
return widget
65+
66+
def set_database(self, database):
67+
# TODO: Use this database for searches
68+
self.database = database

tests/conftest.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ def pytest_configure(config):
5454
settings = QtCore.QSettings()
5555
change_settings = {
5656
"check for updates": "0",
57+
"update database on startup": "1",
5758
"user scenario": "dcor-dev",
5859
"auth/server": common.SERVER,
5960
"auth/api key": common.get_api_key(),

tests/test_gui.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,7 @@ def test_gui_anonymous(qtbot):
6868
"[General]",
6969
"user%20scenario = anonymous",
7070
"check for updates = 0",
71+
"update database on startup = 1",
7172
"[auth]",
7273
"api%20key =",
7374
"server = dcor.mpl.mpg.de",

0 commit comments

Comments
 (0)