Remove APScheduler dependency, Python 3.10 support (#1086)

This commit is contained in:
Manu 2021-10-27 08:37:28 +04:00 committed by GitHub
parent c963aaf3f2
commit c7a1077fec
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
23 changed files with 469 additions and 523 deletions

View File

@ -18,7 +18,7 @@ jobs:
fail-fast: false
matrix:
python-version: [3.6, 3.7, 3.8, 3.9]
python-version: ['3.7', '3.8', '3.9', '3.10']
os: [ubuntu-latest, macos-latest]
steps:

View File

@ -36,12 +36,9 @@
"dependencies/python3-borgbackup.json",
"dependencies/python3-llfuse.json",
"dependencies/python3-appdirs.json",
"dependencies/python3-apscheduler.json",
"dependencies/python3-paramiko.json",
"dependencies/python3-peewee.json",
"dependencies/python3-psutil.json",
"dependencies/python3-python-dateutil.json",
"dependencies/python3-qdarkstyle.json",
"dependencies/python3-secretstorage.json",
"dependencies/python3-pytest-runner.json",
{

View File

@ -1,34 +0,0 @@
{
"name": "python3-apscheduler",
"buildsystem": "simple",
"build-commands": [
"pip3 install --no-index --find-links=\"file://${PWD}\" --prefix=${FLATPAK_DEST} apscheduler"
],
"sources": [
{
"type": "file",
"url": "https://files.pythonhosted.org/packages/73/fb/00a976f728d0d1fecfe898238ce23f502a721c0ac0ecfedb80e0d88c64e9/six-1.12.0-py2.py3-none-any.whl",
"sha256": "3350809f0555b11f552448330d0b52d5f24c91a322ea4a15ef22629740f3761c"
},
{
"type": "file",
"url": "https://files.pythonhosted.org/packages/6a/9a/50fadfd53ec909e4399b67c74cc7f4e883488035cfcdb90b685758fa8b34/setuptools-41.4.0-py2.py3-none-any.whl",
"sha256": "8d01f7ee4191d9fdcd9cc5796f75199deccb25b154eba82d44d6a042cf873670"
},
{
"type": "file",
"url": "https://files.pythonhosted.org/packages/e7/f9/f0b53f88060247251bf481fa6ea62cd0d25bf1b11a87888e53ce5b7c8ad2/pytz-2019.3-py2.py3-none-any.whl",
"sha256": "1c557d7d0e871de1f5ccd5833f60fb2550652da6be2693c1e02300743d21500d"
},
{
"type": "file",
"url": "https://files.pythonhosted.org/packages/ef/99/53bd1ac9349262f59c1c421d8fcc2559ae8a5eeffed9202684756b648d33/tzlocal-2.0.0-py2.py3-none-any.whl",
"sha256": "11c9f16e0a633b4b60e1eede97d8a46340d042e67b670b290ca526576e039048"
},
{
"type": "file",
"url": "https://files.pythonhosted.org/packages/09/ff/d5b0e81846cd5e92d02e5f2682b78c73a5d9d61bc1eae32cea5ac15c0d47/APScheduler-3.6.1-py2.py3-none-any.whl",
"sha256": "cde18f6dbffa1b75aff67fd7fe423a3020cb0363f6c67bd45f24306d90898231"
}
]
}

View File

@ -1,8 +0,0 @@
{
"name": "python3-dateutil",
"buildsystem": "simple",
"build-commands": [
"pip3 install --no-index --find-links=\"file://${PWD}\" --prefix=${FLATPAK_DEST} dateutil"
],
"sources": []
}

View File

@ -1,19 +0,0 @@
{
"name": "python3-python-dateutil",
"buildsystem": "simple",
"build-commands": [
"pip3 install --no-index --find-links=\"file://${PWD}\" --prefix=${FLATPAK_DEST} python-dateutil"
],
"sources": [
{
"type": "file",
"url": "https://files.pythonhosted.org/packages/73/fb/00a976f728d0d1fecfe898238ce23f502a721c0ac0ecfedb80e0d88c64e9/six-1.12.0-py2.py3-none-any.whl",
"sha256": "3350809f0555b11f552448330d0b52d5f24c91a322ea4a15ef22629740f3761c"
},
{
"type": "file",
"url": "https://files.pythonhosted.org/packages/41/17/c62faccbfbd163c7f57f3844689e3a78bae1f403648a6afb1d0866d87fbb/python_dateutil-2.8.0-py2.py3-none-any.whl",
"sha256": "7e6584c74aeed623791615e26efd690f29817a27c73085b78e4bad02493df2fb"
}
]
}

View File

@ -1,39 +0,0 @@
{
"name": "python3-qdarkstyle",
"buildsystem": "simple",
"build-commands": [
"pip3 install --no-index --find-links=\"file://${PWD}\" --prefix=${FLATPAK_DEST} qdarkstyle"
],
"sources": [
{
"type": "file",
"url": "https://files.pythonhosted.org/packages/45/dc/3241eef99eb45f1def35cf93af35d1cf9ef4c0991792583b8f33ea41b092/more_itertools-7.2.0-py3-none-any.whl",
"sha256": "92b8c4b06dac4f0611c0729b2f2ede52b2e1bac1ab48f089c7ddc12e26bb60c4"
},
{
"type": "file",
"url": "https://files.pythonhosted.org/packages/74/3d/1ee25a26411ba0401b43c6376d2316a71addcc72ef8690b101b4ea56d76a/zipp-0.6.0-py2.py3-none-any.whl",
"sha256": "f06903e9f1f43b12d371004b4ac7b06ab39a44adc747266928ae6debfa7b3335"
},
{
"type": "file",
"url": "https://files.pythonhosted.org/packages/f6/d2/40b3fa882147719744e6aa50ac39cf7a22a913cbcba86a0371176c425a3b/importlib_metadata-0.23-py2.py3-none-any.whl",
"sha256": "d5f18a79777f3aa179c145737780282e27b508fc8fd688cb17c7a813e8bd39af"
},
{
"type": "file",
"url": "https://files.pythonhosted.org/packages/1c/ca/5b8c1fe032a458c2c4bcbe509d1401dca9dda35c7fc46b36bb81c2834740/psutil-5.6.3.tar.gz",
"sha256": "863a85c1c0a5103a12c05a35e59d336e1d665747e531256e061213e2e90f63f3"
},
{
"type": "file",
"url": "https://files.pythonhosted.org/packages/56/23/7bc32827ea3a34bdb19bded6c6be86e4ae69fceb21a5e27956fa6ef624f2/helpdev-0.6.10-py3-none-any.whl",
"sha256": "6cb2c604d97101a47b81d6d234b48e0f452efe4490b4cc67c33708420c2deaa0"
},
{
"type": "file",
"url": "https://files.pythonhosted.org/packages/7b/30/b528832a64e09bba180d14219262ed35f611863d032fee468e5686b52e21/QDarkStyle-2.7-py2.py3-none-any.whl",
"sha256": "c012bfaa482a1444b477b43a0b3585ea2f844ac16bfa2dff40ea535ce669c054"
}
]
}

View File

@ -15,10 +15,10 @@ classifiers =
Operating System :: MacOS
Operating System :: POSIX
License :: OSI Approved :: GNU General Public License v3 (GPLv3)
Programming Language :: Python :: 3.6
Programming Language :: Python :: 3.7
Programming Language :: Python :: 3.8
Programming Language :: Python :: 3.9
Programming Language :: Python :: 3.10
Topic :: System :: Archiving :: Backup
Topic :: System :: Systems Administration
Topic :: Utilities
@ -31,7 +31,7 @@ project_urls =
Source Code = https://github.com/borgbase/vorta
[options]
python_requires = >= 3.6
python_requires = >=3.7
setup_requires =
pip >= 10
pytest-runner
@ -41,12 +41,9 @@ install_requires =
paramiko
pyqt5
peewee
python-dateutil
apscheduler < 4.0
psutil
setuptools
secretstorage; sys_platform != 'darwin'
tzlocal==2.1
pyobjc-core; sys_platform == 'darwin'
pyobjc-framework-Cocoa; sys_platform == 'darwin'
pyobjc-framework-LaunchServices; sys_platform == 'darwin'

View File

@ -57,7 +57,7 @@ class VortaApp(QtSingleApplication):
init_translations(self)
self.setQuitOnLastWindowClosed(False)
self.scheduler = VortaScheduler(self)
self.scheduler = VortaScheduler()
self.setApplicationName("Vorta")
@ -73,7 +73,6 @@ class VortaApp(QtSingleApplication):
elif SettingsModel.get(key='foreground').value:
self.open_main_window_action()
self.backup_cancelled_event.connect(self.scheduler.cancel_all_jobs)
self.backup_started_event.connect(self.backup_started_event_response)
self.backup_finished_event.connect(self.backup_finished_event_response)
self.backup_cancelled_event.connect(self.backup_cancelled_event_response)
@ -107,7 +106,6 @@ class VortaApp(QtSingleApplication):
def quit_app_action(self):
self.backup_cancelled_event.emit()
self.scheduler.shutdown()
del self.main_window
self.tray.deleteLater()
del self.tray
@ -147,6 +145,7 @@ class VortaApp(QtSingleApplication):
self.tray.set_tray_icon()
def backup_cancelled_event_response(self):
self.scheduler.cancel_all_jobs()
self.tray.set_tray_icon()
def message_received_event_response(self, message):
@ -178,6 +177,7 @@ class VortaApp(QtSingleApplication):
borg_compat.set_version(result['data']['version'], result['data']['path'])
self.main_window.miscTab.set_borg_details(borg_compat.version, borg_compat.path)
self.main_window.repoTab.toggle_available_compression()
self.scheduler.reload_all_timers() # Start timer after Borg version is set.
else:
self._alert_missing_borg()

View File

@ -21,12 +21,11 @@
<widget class="QToolBox" name="toolBox">
<property name="font">
<font>
<weight>50</weight>
<bold>false</bold>
</font>
</property>
<property name="currentIndex">
<number>3</number>
<number>0</number>
</property>
<widget class="QWidget" name="schedule">
<property name="geometry">
@ -39,7 +38,6 @@
</property>
<property name="font">
<font>
<weight>50</weight>
<bold>false</bold>
</font>
</property>
@ -54,284 +52,268 @@
<number>0</number>
</property>
<item>
<layout class="QHBoxLayout" name="horizontalLayout_2">
<property name="sizeConstraint">
<enum>QLayout::SetFixedSize</enum>
</property>
<property name="bottomMargin">
<number>7</number>
</property>
<item>
<widget class="QRadioButton" name="scheduleOffRadio">
<property name="sizePolicy">
<sizepolicy hsizetype="Minimum" vsizetype="Fixed">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="text">
<string>Backup manually</string>
</property>
</widget>
</item>
</layout>
</item>
<item>
<layout class="QHBoxLayout" name="horizontalLayout_3">
<item>
<widget class="QRadioButton" name="scheduleIntervalRadio">
<property name="sizePolicy">
<sizepolicy hsizetype="Fixed" vsizetype="Preferred">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="text">
<string>Backup every </string>
</property>
</widget>
</item>
<item>
<widget class="QSpinBox" name="scheduleIntervalHours">
<property name="minimum">
<number>1</number>
</property>
<property name="maximum">
<number>740</number>
</property>
</widget>
</item>
<item alignment="Qt::AlignLeft">
<widget class="QLabel" name="label">
<property name="sizePolicy">
<sizepolicy hsizetype="Fixed" vsizetype="Preferred">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="text">
<string>hours at</string>
</property>
<property name="textFormat">
<enum>Qt::PlainText</enum>
</property>
</widget>
</item>
<item>
<widget class="QSpinBox" name="scheduleIntervalMinutes">
<property name="maximum">
<number>59</number>
</property>
</widget>
</item>
<item>
<widget class="QLabel" name="label_2">
<property name="sizePolicy">
<sizepolicy hsizetype="Fixed" vsizetype="Preferred">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="text">
<string>minutes past the hour</string>
</property>
</widget>
</item>
<item>
<spacer name="horizontalSpacer_2">
<property name="orientation">
<enum>Qt::Horizontal</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
<width>40</width>
<height>20</height>
</size>
</property>
</spacer>
</item>
</layout>
</item>
<item>
<layout class="QHBoxLayout" name="horizontalLayout_5">
<item>
<widget class="QRadioButton" name="scheduleFixedRadio">
<property name="text">
<string>Backup daily at</string>
</property>
</widget>
</item>
<item>
<widget class="QTimeEdit" name="scheduleFixedTime"/>
</item>
<item>
<spacer name="horizontalSpacer">
<property name="orientation">
<enum>Qt::Horizontal</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
<width>40</width>
<height>20</height>
</size>
</property>
</spacer>
</item>
</layout>
</item>
<item>
<layout class="QHBoxLayout" name="horizontalLayout_6">
<property name="topMargin">
<number>0</number>
</property>
<item>
<widget class="QCheckBox" name="validationCheckBox">
<property name="text">
<string>Validate repository data every</string>
</property>
<property name="checked">
<bool>false</bool>
</property>
<property name="tristate">
<bool>false</bool>
</property>
</widget>
</item>
<item>
<widget class="QSpinBox" name="validationSpinBox">
<property name="minimum">
<number>1</number>
</property>
<property name="maximum">
<number>52</number>
</property>
<property name="value">
<number>3</number>
</property>
</widget>
</item>
<item>
<widget class="QLabel" name="label_5">
<property name="text">
<string>weeks</string>
</property>
</widget>
</item>
<item>
<spacer name="horizontalSpacer_4">
<property name="orientation">
<enum>Qt::Horizontal</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
<width>40</width>
<height>20</height>
</size>
</property>
</spacer>
</item>
</layout>
</item>
<item>
<layout class="QHBoxLayout" name="horizontalLayout_8">
<property name="topMargin">
<number>0</number>
</property>
<item>
<widget class="QCheckBox" name="pruneCheckBox">
<property name="text">
<string>Prune old Archives after each backup</string>
</property>
<property name="tristate">
<bool>false</bool>
</property>
</widget>
</item>
<item>
<spacer name="horizontalSpacer_6">
<property name="orientation">
<enum>Qt::Horizontal</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
<width>40</width>
<height>20</height>
</size>
</property>
</spacer>
</item>
</layout>
</item>
<item>
<widget class="Line" name="line_2">
<widget class="Line" name="line">
<property name="orientation">
<enum>Qt::Horizontal</enum>
</property>
</widget>
</item>
<item>
<layout class="QHBoxLayout" name="horizontalLayout">
<layout class="QGridLayout" name="gridLayout_6">
<property name="topMargin">
<number>0</number>
</property>
<property name="rightMargin">
<number>0</number>
<property name="bottomMargin">
<number>10</number>
</property>
<item>
<widget class="QPushButton" name="scheduleApplyButton">
<property name="text">
<string>Apply</string>
<item row="0" column="0">
<layout class="QHBoxLayout" name="horizontalLayout_2">
<property name="sizeConstraint">
<enum>QLayout::SetFixedSize</enum>
</property>
</widget>
<property name="bottomMargin">
<number>7</number>
</property>
<item>
<widget class="QRadioButton" name="scheduleOffRadio">
<property name="sizePolicy">
<sizepolicy hsizetype="Minimum" vsizetype="Fixed">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="text">
<string>Backup manually</string>
</property>
</widget>
</item>
<item>
<spacer name="horizontalSpacer_7">
<property name="orientation">
<enum>Qt::Horizontal</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
<width>40</width>
<height>20</height>
</size>
</property>
</spacer>
</item>
</layout>
</item>
<item>
<spacer name="horizontalSpacer_5">
<property name="orientation">
<enum>Qt::Horizontal</enum>
</property>
<property name="sizeType">
<enum>QSizePolicy::Fixed</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
<width>10</width>
<height>20</height>
</size>
</property>
</spacer>
<item row="2" column="0">
<layout class="QHBoxLayout" name="horizontalLayout_5">
<item>
<widget class="QRadioButton" name="scheduleFixedRadio">
<property name="text">
<string>Backup daily at</string>
</property>
</widget>
</item>
<item>
<widget class="QTimeEdit" name="scheduleFixedTime"/>
</item>
<item>
<spacer name="horizontalSpacer">
<property name="orientation">
<enum>Qt::Horizontal</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
<width>40</width>
<height>20</height>
</size>
</property>
</spacer>
</item>
</layout>
</item>
<item>
<widget class="QLabel" name="label_3">
<property name="font">
<font>
<weight>75</weight>
<bold>true</bold>
</font>
</property>
<property name="text">
<string>Next Backup:</string>
</property>
</widget>
<item row="1" column="0">
<layout class="QHBoxLayout" name="horizontalLayout_3">
<item>
<widget class="QRadioButton" name="scheduleIntervalRadio">
<property name="sizePolicy">
<sizepolicy hsizetype="Fixed" vsizetype="Preferred">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="text">
<string>Backup every </string>
</property>
</widget>
</item>
<item>
<widget class="QSpinBox" name="scheduleIntervalCount">
<property name="minimum">
<number>1</number>
</property>
<property name="maximum">
<number>740</number>
</property>
</widget>
</item>
<item>
<widget class="QComboBox" name="scheduleIntervalUnit"/>
</item>
<item>
<spacer name="horizontalSpacer_2">
<property name="orientation">
<enum>Qt::Horizontal</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
<width>40</width>
<height>20</height>
</size>
</property>
</spacer>
</item>
</layout>
</item>
<item>
<widget class="QLabel" name="nextBackupDateTimeLabel">
<property name="text">
<string>Off</string>
<item row="3" column="0">
<layout class="QHBoxLayout" name="horizontalLayout">
<property name="topMargin">
<number>0</number>
</property>
</widget>
<property name="rightMargin">
<number>0</number>
</property>
<item>
<widget class="QLabel" name="label_3">
<property name="font">
<font>
<bold>true</bold>
</font>
</property>
<property name="text">
<string>Next Backup:</string>
</property>
</widget>
</item>
<item>
<widget class="QLabel" name="nextBackupDateTimeLabel">
<property name="text">
<string>-</string>
</property>
</widget>
</item>
<item>
<spacer name="horizontalSpacer_3">
<property name="orientation">
<enum>Qt::Horizontal</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
<width>40</width>
<height>20</height>
</size>
</property>
</spacer>
</item>
</layout>
</item>
<item>
<spacer name="horizontalSpacer_3">
<property name="orientation">
<enum>Qt::Horizontal</enum>
<item row="1" column="1">
<layout class="QHBoxLayout" name="horizontalLayout_8">
<property name="topMargin">
<number>0</number>
</property>
<property name="sizeHint" stdset="0">
<size>
<width>40</width>
<height>20</height>
</size>
<item>
<widget class="QCheckBox" name="pruneCheckBox">
<property name="text">
<string>Prune old Archives after each backup</string>
</property>
</widget>
</item>
<item>
<spacer name="horizontalSpacer_6">
<property name="orientation">
<enum>Qt::Horizontal</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
<width>40</width>
<height>20</height>
</size>
</property>
</spacer>
</item>
</layout>
</item>
<item row="0" column="1">
<layout class="QHBoxLayout" name="horizontalLayout_9">
<property name="topMargin">
<number>0</number>
</property>
</spacer>
<item>
<widget class="QCheckBox" name="missedBackupsCheckBox">
<property name="text">
<string>Run missed backups right after startup or wakeup</string>
</property>
</widget>
</item>
<item>
<spacer name="horizontalSpacer_5">
<property name="orientation">
<enum>Qt::Horizontal</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
<width>40</width>
<height>20</height>
</size>
</property>
</spacer>
</item>
</layout>
</item>
<item row="2" column="1">
<layout class="QHBoxLayout" name="horizontalLayout_6">
<property name="topMargin">
<number>0</number>
</property>
<item>
<widget class="QCheckBox" name="validationCheckBox">
<property name="text">
<string>Validate repository data every</string>
</property>
</widget>
</item>
<item>
<widget class="QSpinBox" name="validationWeeksCount">
<property name="minimum">
<number>1</number>
</property>
<property name="maximum">
<number>52</number>
</property>
<property name="value">
<number>3</number>
</property>
</widget>
</item>
<item>
<widget class="QLabel" name="label_5">
<property name="text">
<string>weeks</string>
</property>
</widget>
</item>
<item>
<spacer name="horizontalSpacer_4">
<property name="orientation">
<enum>Qt::Horizontal</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
<width>40</width>
<height>20</height>
</size>
</property>
</spacer>
</item>
</layout>
</item>
</layout>
</item>
@ -355,8 +337,8 @@
<rect>
<x>0</x>
<y>0</y>
<width>663</width>
<height>354</height>
<width>282</width>
<height>146</height>
</rect>
</property>
<attribute name="label">
@ -416,8 +398,8 @@
<rect>
<x>0</x>
<y>0</y>
<width>663</width>
<height>354</height>
<width>98</width>
<height>86</height>
</rect>
</property>
<attribute name="label">
@ -432,7 +414,6 @@
<property name="font">
<font>
<pointsize>11</pointsize>
<weight>50</weight>
<bold>false</bold>
</font>
</property>
@ -479,8 +460,8 @@
<rect>
<x>0</x>
<y>0</y>
<width>663</width>
<height>354</height>
<width>494</width>
<height>288</height>
</rect>
</property>
<attribute name="label">
@ -531,7 +512,6 @@
<property name="font">
<font>
<pointsize>11</pointsize>
<weight>50</weight>
<bold>false</bold>
</font>
</property>

View File

@ -7,6 +7,7 @@ import shlex
import select
import time
import logging
from datetime import datetime as dt
from collections import namedtuple
from threading import Lock
from PyQt5 import QtCore
@ -24,7 +25,7 @@ temp_mutex = Lock()
logger = logging.getLogger(__name__)
FakeRepo = namedtuple('Repo', ['url', 'id', 'extra_borg_arguments', 'encryption'])
FakeProfile = namedtuple('FakeProfile', ['repo', 'name', 'ssh_key'])
FakeProfile = namedtuple('FakeProfile', ['id', 'repo', 'name', 'ssh_key'])
"""
All methods in this class must be thread safe. Particularly,
@ -180,6 +181,7 @@ class BorgJob(Job, BackupProfileMixin):
ret['repo_url'] = profile.repo.url
ret['extra_borg_arguments'] = profile.repo.extra_borg_arguments
ret['profile_name'] = profile.name
ret['profile_id'] = profile.id
ret['ok'] = True
@ -205,9 +207,9 @@ class BorgJob(Job, BackupProfileMixin):
def run(self):
self.started_event()
log_entry = EventLogModel(category='borg-run',
log_entry = EventLogModel(category=self.params.get('category', 'user'),
subcommand=self.cmd[1],
profile=self.params.get('profile_name', None)
profile=self.params.get('profile_id', None)
)
log_entry.save()
logger.info('Running command %s', ' '.join(self.cmd))
@ -285,6 +287,7 @@ class BorgJob(Job, BackupProfileMixin):
log_entry.returncode = p.returncode
log_entry.repo_url = self.params.get('repo_url', None)
log_entry.end_time = dt.now()
log_entry.save()
self.process_result(result)

View File

@ -1,7 +1,7 @@
import os
import tempfile
from dateutil import parser
import subprocess
from datetime import datetime as dt
from vorta.i18n import trans_late
from vorta.utils import format_archive_name, borg_compat, get_network_status_monitor
@ -16,7 +16,7 @@ class BorgCreateJob(BorgJob):
snapshot_id=result['data']['archive']['id'],
defaults={
'name': result['data']['archive']['name'],
'time': parser.parse(result['data']['archive']['start']),
'time': dt.fromisoformat(result['data']['archive']['start']),
'repo': result['params']['repo_id'],
'duration': result['data']['archive']['duration'],
'size': result['data']['archive']['stats']['deduplicated_size']

View File

@ -16,6 +16,7 @@ class BorgInfoRepoJob(BorgJob):
# Build fake profile because we don't have it in the DB yet. Assume unencrypted.
profile = FakeProfile(
999,
FakeRepo(params['repo_url'], 999, params['extra_borg_arguments'], 'none'),
'New Repo',
params['ssh_key']

View File

@ -12,6 +12,7 @@ class BorgInitJob(BorgJob):
# Build fake profile because we don't have it in the DB yet.
profile = FakeProfile(
999,
FakeRepo(params['repo_url'], 999, params['extra_borg_arguments'],
params['encryption']), 'Init Repo', params['ssh_key']
)

View File

@ -1,4 +1,4 @@
from dateutil import parser
from datetime import datetime as dt
from .borg_job import BorgJob
from vorta.models import ArchiveModel, RepoModel
@ -49,7 +49,7 @@ class BorgListRepoJob(BorgJob):
repo=repo.id,
defaults={
'name': archive['name'],
'time': parser.parse(archive['time'])
'time': dt.fromisoformat(archive['time'])
}
)
new_archive.save()

View File

@ -8,7 +8,6 @@ import json
import os
import sys
from datetime import datetime, timedelta
from dateutil.relativedelta import relativedelta as rd
import peewee as pw
from playhouse.migrate import SqliteMigrator, migrate
@ -16,7 +15,7 @@ from playhouse.migrate import SqliteMigrator, migrate
from vorta.i18n import trans_late
from vorta.utils import slugify
SCHEMA_VERSION = 17
SCHEMA_VERSION = 18
db = pw.Proxy()
@ -75,10 +74,13 @@ class BackupProfileModel(pw.Model):
exclude_patterns = pw.TextField(null=True)
exclude_if_present = pw.TextField(null=True)
schedule_mode = pw.CharField(default='off')
schedule_interval_hours = pw.IntegerField(default=3)
schedule_interval_minutes = pw.IntegerField(default=42)
schedule_interval_count = pw.IntegerField(default=3)
schedule_interval_unit = pw.CharField(default='hours')
schedule_fixed_hour = pw.IntegerField(default=3)
schedule_fixed_minute = pw.IntegerField(default=42)
schedule_interval_hours = pw.IntegerField(default=3) # no longer used
schedule_interval_minutes = pw.IntegerField(default=42) # no longer used
schedule_make_up_missed = pw.BooleanField(default=True)
validation_on = pw.BooleanField(default=True)
validation_weeks = pw.IntegerField(default=3)
prune_on = pw.BooleanField(default=False)
@ -148,6 +150,7 @@ class WifiSettingModel(pw.Model):
class EventLogModel(pw.Model):
"""Keep a log of background jobs."""
start_time = pw.DateTimeField(default=datetime.now)
end_time = pw.DateTimeField(default=datetime.now)
category = pw.CharField()
subcommand = pw.CharField(null=True)
message = pw.CharField(null=True)
@ -414,6 +417,19 @@ def init_db(con=None):
'create_backup_cmd', pw.CharField(default=''))
)
if current_schema.version < 18:
_apply_schema_update(
current_schema, 18,
migrator.add_column(BackupProfileModel._meta.table_name,
'schedule_interval_unit', pw.CharField(default='hours')),
migrator.add_column(BackupProfileModel._meta.table_name,
'schedule_interval_count', pw.IntegerField(default=3)),
migrator.add_column(BackupProfileModel._meta.table_name,
'schedule_make_up_missed', pw.BooleanField(default=False)),
migrator.add_column(EventLogModel._meta.table_name,
'end_time', pw.DateTimeField(default=datetime.now))
)
# Create missing settings and update labels. Leave setting values untouched.
for setting in get_misc_settings():
s, created = SettingsModel.get_or_create(key=setting['key'], defaults=setting)
@ -421,5 +437,5 @@ def init_db(con=None):
s.save()
# Delete old log entries after 3 months.
three_months_ago = datetime.now() - rd(months=3)
three_months_ago = datetime.now() - timedelta(days=3)
EventLogModel.delete().where(EventLogModel.start_time < three_months_ago).execute()

View File

@ -1,9 +1,7 @@
import logging
from datetime import date, timedelta
from datetime import datetime as dt, date, timedelta
from PyQt5 import QtCore
from apscheduler.schedulers.qt import QtScheduler
from apscheduler.triggers import cron
from vorta.borg.check import BorgCheckJob
from vorta.borg.create import BorgCreateJob
from vorta.borg.job_scheduler import DEBUG, JobsManager
@ -17,101 +15,136 @@ from vorta.notifications import VortaNotifications
logger = logging.getLogger(__name__)
# TODO: refactor to use QtCore.QTimer directly
class VortaScheduler(QtScheduler):
def __init__(self, parent):
class VortaScheduler(QtCore.QObject):
def __init__(self):
super().__init__()
self.app = parent
self.start()
self.jobs_manager = JobsManager()
self.reload()
self.jobs_manager = JobsManager() # push scheduled jobs to JobManager for execution
self.timers = dict() # keep mapping of profiles to timers
# Set timer to make sure background tasks are scheduled
# Set additional timer to make sure background tasks stay scheduled.
# E.g. after hibernation
self.qt_timer = QtCore.QTimer()
self.qt_timer.timeout.connect(self.reload)
self.qt_timer.setInterval(45 * 60 * 1000)
self.qt_timer.timeout.connect(self.reload_all_timers)
self.qt_timer.setInterval(15 * 60 * 1000)
self.qt_timer.start()
def cancel_all_jobs(self):
if DEBUG:
print("Cancel all Jobs on Vorta Queue")
logger.debug('Cancelling all jobs on job queue')
self.jobs_manager.cancel_all_jobs()
def tr(self, *args, **kwargs):
scope = self.__class__.__name__
return translate(scope, *args, **kwargs)
def reload(self):
def set_timer_for_profile(self, profile_id):
"""
Set a timer for next scheduled backup run of this profile.
Does nothing if set to manual backups or no repo is assigned.
Else will look for previous scheduled backups and catch up if
schedule_make_up_missed is enabled.
Or, if catch-up is not enabled, will add interval to last run to find
next suitable backup time.
"""
# Stop and remove any existing timer for this profile
if profile_id in self.timers:
self.timers[profile_id]['qtt'].stop()
del self.timers[profile_id]
profile = BackupProfileModel.get_or_none(id=profile_id)
if profile is None \
or profile.repo is None \
or profile.schedule_mode == 'off':
return
logger.info('Setting timer for profile %s', profile_id)
last_run_log = EventLogModel.select().where(
EventLogModel.subcommand == 'create',
EventLogModel.category == 'scheduled',
EventLogModel.profile == profile.id,
).order_by(EventLogModel.end_time.desc()).first()
# Desired interval between scheduled backups. Uses datetime.timedelta() units.
if profile.schedule_mode == 'interval':
interval = {profile.schedule_interval_unit: profile.schedule_interval_count}
elif profile.schedule_mode == 'fixed':
interval = {'days': 1}
# If last run was too long ago and catch-up is enabled, run now
if profile.schedule_make_up_missed \
and last_run_log is not None \
and last_run_log.end_time + timedelta(**interval) < dt.now():
logger.debug('Catching up by running job for %s', profile.name)
self.create_backup(profile.id)
return
# If the job never ran, use midnight as random starting point
if last_run_log is None:
last_run = dt.now().replace(hour=0, minute=0)
else:
last_run = last_run_log.end_time
# Squash seconds to get nice starting time
last_run = last_run.replace(second=0, microsecond=0)
# Fixed time is a special case of days=1 interval
if profile.schedule_mode == 'fixed':
last_run = last_run.replace(hour=profile.schedule_fixed_hour, minute=profile.schedule_fixed_minute)
# Add interval to last run time to arrive at next run.
next_run = last_run
now = dt.now()
while next_run < now:
next_run += timedelta(**interval)
logger.debug('Scheduling next run for %s', next_run)
timer_ms = (next_run - dt.now()).total_seconds() * 1000
timer = QtCore.QTimer()
timer.setSingleShot(True)
timer.setInterval(int(timer_ms))
timer.timeout.connect(lambda: self.create_backup(profile_id))
timer.start()
self.timers[profile_id] = {'qtt': timer, 'dt': next_run}
def reload_all_timers(self):
logger.debug('Refreshing all scheduler timers')
for profile in BackupProfileModel.select():
trigger = None
job_id = f'{profile.id}'
if profile.schedule_mode == 'interval':
if profile.schedule_interval_hours >= 24:
days = profile.schedule_interval_hours // 24
leftover_hours = profile.schedule_interval_hours % 24
if leftover_hours == 0:
cron_hours = '1'
else:
cron_hours = f'*/{leftover_hours}'
trigger = cron.CronTrigger(day=f'*/{days}',
hour=cron_hours,
minute=profile.schedule_interval_minutes)
else:
trigger = cron.CronTrigger(hour=f'*/{profile.schedule_interval_hours}',
minute=profile.schedule_interval_minutes)
elif profile.schedule_mode == 'fixed':
trigger = cron.CronTrigger(hour=profile.schedule_fixed_hour,
minute=profile.schedule_fixed_minute)
if self.get_job(job_id) is not None and trigger is not None:
self.reschedule_job(job_id, trigger=trigger)
logger.debug('Job for profile %s was rescheduled.', profile.name)
elif trigger is not None:
if profile.repo is not None:
repo_id = profile.repo.id
else:
repo_id = -1
self.add_job(
func=self.create_backup,
args=[profile.id, repo_id],
trigger=trigger,
id=job_id,
misfire_grace_time=180
)
logger.debug('New job for profile %s was added.', profile.name)
elif self.get_job(job_id) is not None and trigger is None:
self.remove_job(job_id)
logger.debug('Job for profile %s was removed.', profile.name)
self.set_timer_for_profile(profile.id)
@property
def next_job(self):
self.wakeup()
self._process_jobs()
jobs = []
for job in self.get_jobs():
jobs.append((job.next_run_time, job.id))
next_job = dt.now()
next_profile = None
for profile_id, timer in self.timers.items():
if timer['dt'] > next_job and timer['qtt'].isActive():
next_job = timer['dt']
next_profile = profile_id
if jobs:
jobs.sort(key=lambda job: job[0])
profile = BackupProfileModel.get(id=int(jobs[0][1]))
return f"{jobs[0][0].strftime('%H:%M')} ({profile.name})"
if next_profile is not None:
profile = BackupProfileModel.get_or_none(id=next_profile)
return f"{next_job.strftime('%H:%M')} ({profile.name})"
else:
return self.tr('None scheduled')
def next_job_for_profile(self, profile_id):
self.wakeup()
job = self.get_job(str(profile_id))
job = self.timers.get(profile_id)
if job is None:
return self.tr('None scheduled')
else:
return job.next_run_time.strftime('%Y-%m-%d %H:%M')
return job['dt'].strftime('%Y-%m-%d %H:%M')
def create_backup(self, profile_id, repo_id):
if DEBUG:
print("start backup for profile ", profile_id)
def create_backup(self, profile_id):
logger.debug('Start scheduled backup for profile %s', profile_id)
notifier = VortaNotifications.pick()
profile = BackupProfileModel.get(id=profile_id)
profile = BackupProfileModel.get_or_none(id=profile_id)
if profile is None:
logger.info('Profile not found. Maybe deleted?')
return
logger.info('Starting background backup for %s', profile.name)
notifier.deliver(self.tr('Vorta Backup'),
@ -120,7 +153,8 @@ class VortaScheduler(QtScheduler):
msg = BorgCreateJob.prepare(profile)
if msg['ok']:
logger.info('Preparation for backup successful.')
job = BorgCreateJob(msg['cmd'], msg, repo_id)
msg['category'] = 'scheduled'
job = BorgCreateJob(msg['cmd'], msg, profile.repo.id)
job.result.connect(self.notify)
self.jobs_manager.add_job(job)
@ -146,6 +180,8 @@ class VortaScheduler(QtScheduler):
notifier.deliver(self.tr('Vorta Backup'), self.tr('Error during backup creation.'), level='error')
logger.error('Error during backup creation.')
self.set_timer_for_profile(profile_id)
def post_backup_tasks(self, profile_id):
"""
Pruning and checking after successful backup.

View File

@ -59,7 +59,6 @@ class MainWindow(MainWindowBase, MainWindowUI):
self.repoTab.repo_changed.connect(self.archiveTab.populate_from_profile)
self.repoTab.repo_changed.connect(self.scheduleTab.populate_from_profile)
self.repoTab.repo_added.connect(self.archiveTab.list_action)
self.tabWidget.currentChanged.connect(self.scheduleTab._draw_next_scheduled_backup)
self.createStartBtn.clicked.connect(self.app.create_backup_action)
self.cancelButton.clicked.connect(self.app.backup_cancelled_event.emit)

View File

@ -39,13 +39,27 @@ class ScheduleTab(ScheduleBase, ScheduleUI, BackupProfileMixin):
self.logTableWidget.setSelectionBehavior(QTableView.SelectRows)
self.logTableWidget.setEditTriggers(QTableView.NoEditTriggers)
# Scheduler intervals we know
self.scheduleIntervalUnit.addItem(self.tr('Minutes'), 'minutes')
self.scheduleIntervalUnit.addItem(self.tr('Hours'), 'hours')
self.scheduleIntervalUnit.addItem(self.tr('Days'), 'days')
self.scheduleIntervalUnit.addItem(self.tr('Weeks'), 'weeks')
# Populate with data
self.populate_from_profile()
self.set_icons()
# Connect events
self.scheduleApplyButton.clicked.connect(self.on_scheduler_apply)
self.app.backup_finished_event.connect(self.populate_logs)
# Scheduler events
for label, obj in self.schedulerRadioMapping.items():
obj.clicked.connect(self.on_scheduler_change)
self.scheduleIntervalCount.valueChanged.connect(self.on_scheduler_change)
self.scheduleIntervalUnit.currentIndexChanged.connect(self.on_scheduler_change)
self.scheduleFixedTime.timeChanged.connect(self.on_scheduler_change)
# Network and shell commands events
self.dontRunOnMeteredNetworksCheckBox.stateChanged.connect(
lambda new_val, attr='dont_run_on_metered_networks': self.save_profile_attr(attr, new_val))
self.postBackupCmdLineEdit.textEdited.connect(
@ -54,6 +68,29 @@ class ScheduleTab(ScheduleBase, ScheduleUI, BackupProfileMixin):
lambda new_val, attr='pre_backup_cmd': self.save_profile_attr(attr, new_val))
self.createCmdLineEdit.textEdited.connect(
lambda new_val, attr='create_backup_cmd': self.save_repo_attr(attr, new_val))
self.missedBackupsCheckBox.stateChanged.connect(
lambda new_val, attr='schedule_make_up_missed': self.save_profile_attr(attr, new_val))
self.pruneCheckBox.stateChanged.connect(
lambda new_val, attr='prune_on': self.save_profile_attr(attr, new_val))
self.validationCheckBox.stateChanged.connect(
lambda new_val, attr='validation_on': self.save_profile_attr(attr, new_val))
self.validationWeeksCount.valueChanged.connect(
lambda new_val, attr='validation_weeks': self.save_profile_attr(attr, new_val))
def on_scheduler_change(self, _):
profile = self.profile()
# Save scheduler settings, apply new scheduler and display next task for profile.
for label, obj in self.schedulerRadioMapping.items():
if obj.isChecked():
profile.schedule_mode = label
profile.schedule_interval_unit = self.scheduleIntervalUnit.currentData()
profile.schedule_interval_count = self.scheduleIntervalCount.value()
qtime = self.scheduleFixedTime.time()
profile.schedule_fixed_hour, profile.schedule_fixed_minute = qtime.hour(), qtime.minute()
profile.save()
self.app.scheduler.set_timer_for_profile(profile.id)
self.draw_next_scheduled_backup()
def set_icons(self):
self.toolBox.setItemIcon(0, get_colored_icon('clock-o'))
@ -66,20 +103,26 @@ class ScheduleTab(ScheduleBase, ScheduleUI, BackupProfileMixin):
profile = self.profile()
self.schedulerRadioMapping[profile.schedule_mode].setChecked(True)
self.scheduleIntervalHours.setValue(profile.schedule_interval_hours)
self.scheduleIntervalMinutes.setValue(profile.schedule_interval_minutes)
# Set interval scheduler options
self.scheduleIntervalUnit.setCurrentIndex(
self.scheduleIntervalUnit.findData(profile.schedule_interval_unit))
self.scheduleIntervalCount.setValue(profile.schedule_interval_count)
# Set fixed daily time scheduler options
self.scheduleFixedTime.setTime(
QtCore.QTime(profile.schedule_fixed_hour, profile.schedule_fixed_minute))
# Set checking options
self.validationCheckBox.setCheckState(profile.validation_on)
self.validationSpinBox.setValue(profile.validation_weeks)
# Set borg-check options
self.validationCheckBox.setCheckState(QtCore.Qt.Checked if profile.validation_on else QtCore.Qt.Unchecked)
self.validationWeeksCount.setValue(profile.validation_weeks)
self.pruneCheckBox.setCheckState(profile.prune_on)
self.validationCheckBox.setTristate(False)
self.pruneCheckBox.setTristate(False)
self.dontRunOnMeteredNetworksCheckBox.setChecked(profile.dont_run_on_metered_networks)
# Other checkbox options
self.pruneCheckBox.setCheckState(
QtCore.Qt.Checked if profile.prune_on else QtCore.Qt.Unchecked)
self.missedBackupsCheckBox.setCheckState(
QtCore.Qt.Checked if profile.schedule_make_up_missed else QtCore.Qt.Unchecked)
self.dontRunOnMeteredNetworksCheckBox.setChecked(
QtCore.Qt.Checked if profile.dont_run_on_metered_networks else QtCore.Qt.Unchecked)
self.preBackupCmdLineEdit.setText(profile.pre_backup_cmd)
self.postBackupCmdLineEdit.setText(profile.post_backup_cmd)
@ -89,10 +132,13 @@ class ScheduleTab(ScheduleBase, ScheduleUI, BackupProfileMixin):
else:
self.createCmdLineEdit.setEnabled(False)
self._draw_next_scheduled_backup()
self.populate_wifi()
self.populate_logs()
def draw_next_scheduled_backup(self):
self.nextBackupDateTimeLabel.setText(self.app.scheduler.next_job_for_profile(self.profile().id))
self.nextBackupDateTimeLabel.repaint()
def populate_wifi(self):
self.wifiListWidget.clear()
for wifi in get_sorted_wifis(self.profile()):
@ -135,27 +181,3 @@ class ScheduleTab(ScheduleBase, ScheduleUI, BackupProfileMixin):
self.logTableWidget.setItem(row, LogTableColumn.Repository, QTableWidgetItem(log_line.repo_url))
self.logTableWidget.setItem(row, LogTableColumn.ReturnCode, QTableWidgetItem(str(log_line.returncode)))
self.logTableWidget.setSortingEnabled(sorting) # restore sorting now that modifications are done
def _draw_next_scheduled_backup(self):
self.nextBackupDateTimeLabel.setText(self.app.scheduler.next_job_for_profile(self.profile().id))
self.nextBackupDateTimeLabel.repaint()
def on_scheduler_apply(self):
profile = self.profile()
# Save checking options
profile.validation_weeks = self.validationSpinBox.value()
profile.validation_on = self.validationCheckBox.isChecked()
profile.prune_on = self.pruneCheckBox.isChecked()
# Save scheduler timing and activate if needed.
for label, obj in self.schedulerRadioMapping.items():
if obj.isChecked():
profile.schedule_mode = label
profile.schedule_interval_hours = self.scheduleIntervalHours.value()
profile.schedule_interval_minutes = self.scheduleIntervalMinutes.value()
qtime = self.scheduleFixedTime.time()
profile.schedule_fixed_hour, profile.schedule_fixed_minute = qtime.hour(), qtime.minute()
profile.save()
self.app.scheduler.reload()
self._draw_next_scheduled_backup()

View File

@ -72,7 +72,7 @@ def init_db(qapp, qtbot, tmpdir_factory):
qapp.main_window = MainWindow(qapp) # Re-open main window to apply mock data in UI
yield
qapp.backup_cancelled_event.emit()
# qapp.backup_cancelled_event.emit()
qtbot.waitUntil(lambda: not vorta.borg.job_scheduler.JobsManager.is_worker_running())
mock_db.close()

View File

@ -17,8 +17,8 @@
"exclude_patterns": null,
"exclude_if_present": ".nobackup",
"schedule_mode": "off",
"schedule_interval_hours": 1,
"schedule_interval_minutes": 26,
"schedule_interval_unit": "hours",
"schedule_interval_count": 2,
"schedule_fixed_hour": 16,
"schedule_fixed_minute": 0,
"validation_on": true,

View File

@ -19,7 +19,7 @@ def test_autostart(qapp, qtbot):
if checkbox.text().startswith("Automatically"):
# Have to use pos to click checkbox correctly
# https://stackoverflow.com/questions/19418125/pysides-qtest-not-checking-box/24070484#24070484
qtbot.mouseClick(checkbox, QtCore.Qt.LeftButton, pos=QtCore.QPoint(2, checkbox.height() / 2))
qtbot.mouseClick(checkbox, QtCore.Qt.LeftButton, pos=QtCore.QPoint(2, int(checkbox.height() / 2)))
break
click_autostart()

View File

@ -5,21 +5,14 @@ from PyQt5 import QtCore
def test_schedule_tab(qapp, qtbot):
main = qapp.main_window
tab = main.scheduleTab
qtbot.mouseClick(tab.scheduleApplyButton, QtCore.Qt.LeftButton)
qtbot.mouseClick(tab.scheduleOffRadio, QtCore.Qt.LeftButton)
assert tab.nextBackupDateTimeLabel.text() == 'None scheduled'
tab.scheduleIntervalRadio.setChecked(True)
tab.scheduleIntervalHours.setValue(5)
tab.scheduleIntervalMinutes.setValue(10)
qtbot.mouseClick(tab.scheduleApplyButton, QtCore.Qt.LeftButton)
assert tab.nextBackupDateTimeLabel.text().startswith('20')
tab.scheduleIntervalCount.setValue(5)
qtbot.mouseClick(tab.scheduleIntervalRadio, QtCore.Qt.LeftButton)
assert "None" not in tab.nextBackupDateTimeLabel.text()
tab.scheduleOffRadio.setChecked(True)
qtbot.mouseClick(tab.scheduleApplyButton, QtCore.Qt.LeftButton)
assert tab.nextBackupDateTimeLabel.text() == 'None scheduled'
tab.scheduleFixedRadio.setChecked(True)
tab.scheduleFixedTime.setTime(QtCore.QTime(23, 59))
qtbot.mouseClick(tab.scheduleApplyButton, QtCore.Qt.LeftButton)
qtbot.mouseClick(tab.scheduleFixedRadio, QtCore.Qt.LeftButton)
next_backup = dt.combine(date.today(), time(23, 59))
assert tab.nextBackupDateTimeLabel.text() == next_backup.strftime('%Y-%m-%d %H:%M')

View File

@ -4,9 +4,10 @@ import vorta.models
def test_scheduler_create_backup(qapp, qtbot, mocker, borg_json_output):
events_before = vorta.models.EventLogModel.select().count()
stdout, stderr = borg_json_output('create')
popen_result = mocker.MagicMock(stdout=stdout, stderr=stderr, returncode=0)
mocker.patch.object(vorta.borg.borg_job, 'Popen', return_value=popen_result)
qapp.scheduler.create_backup(1, 1)
qapp.scheduler.create_backup(1)
qtbot.waitUntil(lambda: vorta.models.EventLogModel.select().count() == 2, **pytest._wait_defaults)
qtbot.waitUntil(lambda: vorta.models.EventLogModel.select().count() == events_before + 1, **pytest._wait_defaults)