python-visual-update-express 1.0.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.
- python_visual_update_express/__init__.py +1 -0
- python_visual_update_express/application.py +18 -0
- python_visual_update_express/data/general_info.py +14 -0
- python_visual_update_express/data/general_settings.py +8 -0
- python_visual_update_express/libs/file_download.py +17 -0
- python_visual_update_express/libs/icons.py +37 -0
- python_visual_update_express/libs/threading.py +31 -0
- python_visual_update_express/libs/update_manager.py +49 -0
- python_visual_update_express/libs/updates_info.py +86 -0
- python_visual_update_express/ui/error_handling.py +12 -0
- python_visual_update_express/ui/notifications.py +13 -0
- python_visual_update_express/ui/status_text_widget.py +82 -0
- python_visual_update_express/ui/updater_window.py +59 -0
- python_visual_update_express/ui/window_content.py +227 -0
- python_visual_update_express-1.0.0.dist-info/METADATA +84 -0
- python_visual_update_express-1.0.0.dist-info/RECORD +18 -0
- python_visual_update_express-1.0.0.dist-info/WHEEL +4 -0
- python_visual_update_express-1.0.0.dist-info/licenses/LICENSE +9 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
from python_generic_updater.ui.updater_window import UpdaterWindow
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
|
|
3
|
+
from PyQt6.QtWidgets import QApplication
|
|
4
|
+
|
|
5
|
+
from python_visual_update_express.data.general_settings import APP_STYLE
|
|
6
|
+
from python_visual_update_express.ui.updater_window import UpdaterWindow
|
|
7
|
+
|
|
8
|
+
TMP_UPDATE_BASE_URL = 'http://jelmerpijnappel.nl/releases/broers-optiek/lensplan-hulp-applicatie/'
|
|
9
|
+
TMP_CURRENT_UPDATE_VERSION = '1.3.0'
|
|
10
|
+
TMP_TARGET_DIR = str(Path(__file__).resolve().parent.parent / "target")
|
|
11
|
+
|
|
12
|
+
app = QApplication([])
|
|
13
|
+
app.setStyle(APP_STYLE)
|
|
14
|
+
|
|
15
|
+
window = UpdaterWindow(TMP_UPDATE_BASE_URL, TMP_CURRENT_UPDATE_VERSION, TMP_TARGET_DIR)
|
|
16
|
+
window.show()
|
|
17
|
+
|
|
18
|
+
app.exec()
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
|
|
3
|
+
from semver import Version
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
# This object will be filled with the needed info during runtime
|
|
7
|
+
@dataclass
|
|
8
|
+
class GeneralInfo:
|
|
9
|
+
update_base_url: str
|
|
10
|
+
current_update_version: Version
|
|
11
|
+
target_directory_path: str
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
info: GeneralInfo
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
from collections.abc import Callable
|
|
2
|
+
from urllib import request
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
def download_text_file(url: str) -> str:
|
|
6
|
+
with request.urlopen(url) as response:
|
|
7
|
+
return response.read().decode("utf-8")
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def download_file_to_location(base_url: str, file_path: str, destination_path: str,
|
|
11
|
+
progress_callback: Callable[[int, int, int], object] = None) -> None:
|
|
12
|
+
destination = destination_path + file_path
|
|
13
|
+
|
|
14
|
+
# Replace spaces with url-encoded spaces because urlretrieve cannot handle spaces
|
|
15
|
+
download_url = (base_url + file_path).replace(' ', '%20')
|
|
16
|
+
|
|
17
|
+
request.urlretrieve(download_url, destination, progress_callback)
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
from enum import Enum
|
|
3
|
+
|
|
4
|
+
import qtawesome as qta
|
|
5
|
+
from PyQt6.QtCore import QSize
|
|
6
|
+
from PyQt6.QtGui import QColor
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class Icon(Enum):
|
|
10
|
+
CROSS_CIRCLE = 0
|
|
11
|
+
CHECKMARK_CIRCLE = 1
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass
|
|
15
|
+
class IconProperties:
|
|
16
|
+
id: str
|
|
17
|
+
color: QColor = QColor(50, 50, 50)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
ICON_PROPERTIES = {
|
|
21
|
+
Icon.CROSS_CIRCLE: IconProperties('fa5s.times-circle', QColor('red')),
|
|
22
|
+
Icon.CHECKMARK_CIRCLE: IconProperties('fa5s.check-circle', QColor('green')),
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class IconsLib:
|
|
27
|
+
@staticmethod
|
|
28
|
+
def get_icon(icon: Icon, size: QSize = QSize(30, 30)):
|
|
29
|
+
icons = [item.value for item in Icon]
|
|
30
|
+
if not icon.value in icons:
|
|
31
|
+
raise AssertionError('Icon "%s" could be found' % str(icon))
|
|
32
|
+
if not icon in ICON_PROPERTIES.keys():
|
|
33
|
+
raise AssertionError('Properties for icon "%s" could be found' % str(icon))
|
|
34
|
+
|
|
35
|
+
icon_props = ICON_PROPERTIES.get(icon)
|
|
36
|
+
|
|
37
|
+
return qta.icon(icon_props.id, color=icon_props.color, size=size)
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
from PyQt6.QtCore import QObject, pyqtSignal, QRunnable, pyqtSlot
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class WorkerSignals(QObject):
|
|
5
|
+
error = pyqtSignal(Exception)
|
|
6
|
+
finished = pyqtSignal()
|
|
7
|
+
success = pyqtSignal()
|
|
8
|
+
successResult = pyqtSignal(object)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class Worker(QRunnable):
|
|
12
|
+
signals: WorkerSignals
|
|
13
|
+
|
|
14
|
+
def __init__(self, fn, *args, **kwargs):
|
|
15
|
+
super().__init__()
|
|
16
|
+
# Store constructor arguments (re-used for processing)
|
|
17
|
+
self.fn = fn
|
|
18
|
+
self.args = args
|
|
19
|
+
self.kwargs = kwargs
|
|
20
|
+
self.signals = WorkerSignals()
|
|
21
|
+
|
|
22
|
+
@pyqtSlot()
|
|
23
|
+
def run(self):
|
|
24
|
+
try:
|
|
25
|
+
result = self.fn(*self.args, **self.kwargs)
|
|
26
|
+
self.signals.success.emit()
|
|
27
|
+
self.signals.successResult.emit(result)
|
|
28
|
+
except Exception as ex:
|
|
29
|
+
self.signals.error.emit(ex)
|
|
30
|
+
finally:
|
|
31
|
+
self.signals.finished.emit()
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import os
|
|
2
|
+
from tempfile import TemporaryDirectory
|
|
3
|
+
from typing import Union
|
|
4
|
+
|
|
5
|
+
from PyQt6.QtCore import pyqtSignal, QObject
|
|
6
|
+
|
|
7
|
+
from python_generic_updater.data import general_info
|
|
8
|
+
from python_generic_updater.libs.file_download import download_file_to_location
|
|
9
|
+
from python_generic_updater.libs.updates_info import UpdatesInfo
|
|
10
|
+
|
|
11
|
+
DOWNLOADABLE_FILES_PATH = 'Updates/'
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class UpdateManager(QObject):
|
|
15
|
+
completed_steps: int = 0
|
|
16
|
+
step_percentage_increment: float
|
|
17
|
+
|
|
18
|
+
download_progress_update = pyqtSignal(float)
|
|
19
|
+
|
|
20
|
+
def download_update_files(self, info: UpdatesInfo, update_base_url: str) -> Union[TemporaryDirectory, None]:
|
|
21
|
+
steps = info.get_remaining_release_steps(general_info.info.current_update_version)
|
|
22
|
+
|
|
23
|
+
files_to_download = steps['files_to_download']
|
|
24
|
+
if len(files_to_download) <= 0:
|
|
25
|
+
return None
|
|
26
|
+
|
|
27
|
+
self.step_percentage_increment = 100.0 / len(files_to_download)
|
|
28
|
+
|
|
29
|
+
tmpdir = TemporaryDirectory()
|
|
30
|
+
tmpdir_path = os.path.join(tmpdir.name, '') # This adds a trailing slash if it's missing
|
|
31
|
+
|
|
32
|
+
download_base_url = update_base_url + DOWNLOADABLE_FILES_PATH
|
|
33
|
+
for file in files_to_download:
|
|
34
|
+
download_file_to_location(download_base_url, file, tmpdir_path, self._update_download_progress)
|
|
35
|
+
return tmpdir
|
|
36
|
+
|
|
37
|
+
def _update_download_progress(self, block_num: int, block_size: int, total_size: int):
|
|
38
|
+
downloaded = block_num * block_size
|
|
39
|
+
if downloaded < total_size:
|
|
40
|
+
total_percentage_gain = downloaded / total_size * self.step_percentage_increment
|
|
41
|
+
progress_value = self._get_completion_percentage(total_percentage_gain)
|
|
42
|
+
else:
|
|
43
|
+
self.completed_steps += 1
|
|
44
|
+
progress_value = self._get_completion_percentage()
|
|
45
|
+
|
|
46
|
+
self.download_progress_update.emit(progress_value)
|
|
47
|
+
|
|
48
|
+
def _get_completion_percentage(self, offset: float = 0.0) -> float:
|
|
49
|
+
return self.completed_steps * self.step_percentage_increment + offset
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import re
|
|
2
|
+
from typing import List
|
|
3
|
+
|
|
4
|
+
from semver import Version
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class UpdatesInfo:
|
|
8
|
+
release_versions: List[Version] # Ordered list of versions from oldest to newest
|
|
9
|
+
release_version_step_lists: dict[str, dict] # Steps needed per version
|
|
10
|
+
latest_version: Version
|
|
11
|
+
|
|
12
|
+
def __init__(self, updatescript: str):
|
|
13
|
+
self.release_versions = self._get_release_versions(updatescript)
|
|
14
|
+
self.latest_version = self.release_versions[-1] if self.release_versions else None
|
|
15
|
+
self.release_version_step_lists = self._get_all_release_steps(updatescript)
|
|
16
|
+
|
|
17
|
+
def get_remaining_release_steps(self, current_version: Version):
|
|
18
|
+
steps = {'files_to_download': []}
|
|
19
|
+
|
|
20
|
+
if current_version == self.release_versions[-1]:
|
|
21
|
+
return steps
|
|
22
|
+
|
|
23
|
+
current_version_index = self.release_versions.index(current_version)
|
|
24
|
+
newer_version_nrs = self.release_versions[current_version_index + 1:]
|
|
25
|
+
newer_versions_steps = []
|
|
26
|
+
for version_nr in newer_version_nrs:
|
|
27
|
+
version_str = str(version_nr)
|
|
28
|
+
newer_versions_steps.append(self.release_version_step_lists[version_str])
|
|
29
|
+
|
|
30
|
+
for step in newer_versions_steps:
|
|
31
|
+
steps['files_to_download'].extend(step['files_to_download'])
|
|
32
|
+
|
|
33
|
+
# Remove duplicates by converting to a set and back (since set keys cannot be duplicate)
|
|
34
|
+
steps['files_to_download'] = list(set(steps['files_to_download']))
|
|
35
|
+
|
|
36
|
+
return steps
|
|
37
|
+
|
|
38
|
+
def _get_release_versions(self, updatescript: str) -> List[Version]:
|
|
39
|
+
match = re.search(r"releases\{([^}]*)}", updatescript, re.DOTALL)
|
|
40
|
+
if not match:
|
|
41
|
+
return []
|
|
42
|
+
|
|
43
|
+
block_content = match.group(1)
|
|
44
|
+
|
|
45
|
+
versions = []
|
|
46
|
+
for line in block_content.splitlines():
|
|
47
|
+
if line.strip():
|
|
48
|
+
try:
|
|
49
|
+
version = Version.parse(line.strip())
|
|
50
|
+
except:
|
|
51
|
+
continue
|
|
52
|
+
|
|
53
|
+
versions.append(version)
|
|
54
|
+
|
|
55
|
+
versions.sort()
|
|
56
|
+
return versions
|
|
57
|
+
|
|
58
|
+
def _get_all_release_steps(self, updatescript: str) -> dict:
|
|
59
|
+
release_steps = {}
|
|
60
|
+
|
|
61
|
+
matches = re.findall(r"release:(.*?)\{([^}]*)}", updatescript)
|
|
62
|
+
if not matches:
|
|
63
|
+
return {}
|
|
64
|
+
|
|
65
|
+
for match in matches:
|
|
66
|
+
version_nr = match[0]
|
|
67
|
+
block_content = match[1]
|
|
68
|
+
|
|
69
|
+
step = {
|
|
70
|
+
'files_to_download': self._get_filenames_to_download(block_content)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
release_steps[version_nr] = step
|
|
74
|
+
|
|
75
|
+
return release_steps
|
|
76
|
+
|
|
77
|
+
def _get_filenames_to_download(self, step_content: str) -> List[str]:
|
|
78
|
+
matches = re.findall(r"DownloadFile:(.*?)\n", step_content)
|
|
79
|
+
if not matches:
|
|
80
|
+
return []
|
|
81
|
+
|
|
82
|
+
filenames = []
|
|
83
|
+
for match in matches:
|
|
84
|
+
filenames.append(match)
|
|
85
|
+
|
|
86
|
+
return filenames
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import traceback
|
|
2
|
+
|
|
3
|
+
from python_generic_updater.data.general_settings import DEBUG_MODE
|
|
4
|
+
from python_generic_updater.ui.notifications import error_notification
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def process_error(ex: Exception, self=None) -> None:
|
|
8
|
+
# Enable below line for debugging
|
|
9
|
+
if DEBUG_MODE:
|
|
10
|
+
error_notification(''.join(traceback.format_exception(type(ex), ex, ex.__traceback__)), self)
|
|
11
|
+
else:
|
|
12
|
+
error_notification(str(ex), self)
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
from PyQt6.QtWidgets import QMessageBox
|
|
2
|
+
|
|
3
|
+
ERROR_TITLE = 'An error has occurred'
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def error_notification(text: str, self: any = None) -> None:
|
|
7
|
+
print(text)
|
|
8
|
+
QMessageBox.critical(self, ERROR_TITLE, text)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def warning_notification(text: str, self: any = None) -> None:
|
|
12
|
+
print(text)
|
|
13
|
+
QMessageBox.warning(self, ERROR_TITLE, text)
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
from PyQt6.QtCore import Qt
|
|
2
|
+
from PyQt6.QtGui import QColor
|
|
3
|
+
from PyQt6.QtWidgets import QWidget, QLabel, QHBoxLayout
|
|
4
|
+
from pyqtwaitingspinner import WaitingSpinner, SpinnerParameters, SpinDirection
|
|
5
|
+
from qtawesome import IconWidget
|
|
6
|
+
|
|
7
|
+
from python_generic_updater.libs.icons import IconsLib, Icon
|
|
8
|
+
|
|
9
|
+
SPINNER_PARAMS = SpinnerParameters( # These can be generated in the spinner editor `spinner-conf`
|
|
10
|
+
roundness=100.0,
|
|
11
|
+
trail_fade_percentage=50.0,
|
|
12
|
+
number_of_lines=13,
|
|
13
|
+
line_length=5,
|
|
14
|
+
line_width=2,
|
|
15
|
+
inner_radius=3,
|
|
16
|
+
revolutions_per_second=1.57,
|
|
17
|
+
color=QColor(80, 80, 80),
|
|
18
|
+
minimum_trail_opacity=3.14,
|
|
19
|
+
spin_direction=SpinDirection.CLOCKWISE,
|
|
20
|
+
center_on_parent=True,
|
|
21
|
+
disable_parent_when_spinning=False,
|
|
22
|
+
)
|
|
23
|
+
SPINNER_WIDGET_WIDTH = 30
|
|
24
|
+
SPINNER_WIDGET_HEIGHT = 30
|
|
25
|
+
MAX_TEXT_WIDTH = 300
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class StatusTextWidget(QWidget):
|
|
29
|
+
icon: IconWidget
|
|
30
|
+
status_text: QLabel
|
|
31
|
+
spinner_widget: QWidget
|
|
32
|
+
spinner: WaitingSpinner
|
|
33
|
+
|
|
34
|
+
def __init__(self):
|
|
35
|
+
super().__init__()
|
|
36
|
+
|
|
37
|
+
layout = QHBoxLayout()
|
|
38
|
+
layout.setAlignment(Qt.AlignmentFlag.AlignHCenter)
|
|
39
|
+
|
|
40
|
+
self.icon = IconWidget()
|
|
41
|
+
layout.addWidget(self.icon)
|
|
42
|
+
layout.addSpacing(5)
|
|
43
|
+
|
|
44
|
+
self.status_text = QLabel()
|
|
45
|
+
self.status_text.setFixedHeight(
|
|
46
|
+
SPINNER_WIDGET_HEIGHT) # Prevents vertical repositioning when spinner is activated
|
|
47
|
+
layout.addWidget(self.status_text)
|
|
48
|
+
|
|
49
|
+
self.spinner_widget = QWidget()
|
|
50
|
+
self.spinner = WaitingSpinner(self.spinner_widget, SPINNER_PARAMS)
|
|
51
|
+
self.set_spinner_active(False)
|
|
52
|
+
layout.addWidget(self.spinner_widget)
|
|
53
|
+
|
|
54
|
+
self.setLayout(layout)
|
|
55
|
+
|
|
56
|
+
def set_spinner_active(self, active: bool):
|
|
57
|
+
if active:
|
|
58
|
+
self.spinner.start()
|
|
59
|
+
self.spinner_widget.setFixedSize(SPINNER_WIDGET_WIDTH, SPINNER_WIDGET_HEIGHT)
|
|
60
|
+
else:
|
|
61
|
+
self.spinner.stop()
|
|
62
|
+
self.spinner_widget.setFixedSize(0, 0)
|
|
63
|
+
|
|
64
|
+
def set_status(self, status: str, spinner: bool = False, icon: Icon = None):
|
|
65
|
+
self.status_text.setWordWrap(False) # Disable word wrap for determining correct size hint
|
|
66
|
+
self.status_text.setText(status)
|
|
67
|
+
self.status_text.setStyleSheet('')
|
|
68
|
+
width = min(self.status_text.sizeHint().width(), MAX_TEXT_WIDTH)
|
|
69
|
+
self.status_text.setFixedWidth(width)
|
|
70
|
+
self.status_text.setWordWrap(True)
|
|
71
|
+
self.set_spinner_active(spinner)
|
|
72
|
+
if icon:
|
|
73
|
+
self.icon.setIcon(IconsLib.get_icon(icon))
|
|
74
|
+
|
|
75
|
+
def set_warning_status(self, status: str):
|
|
76
|
+
self.status_text.setText(status)
|
|
77
|
+
self.status_text.setStyleSheet('color: red;')
|
|
78
|
+
|
|
79
|
+
def reset_status(self):
|
|
80
|
+
self.status_text.setText('')
|
|
81
|
+
self.status_text.setStyleSheet('')
|
|
82
|
+
self.set_spinner_active(False)
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
from PyQt6.QtCore import QThreadPool, Qt, QSize
|
|
2
|
+
from PyQt6.QtGui import QGuiApplication
|
|
3
|
+
from PyQt6.QtWidgets import QMainWindow, QVBoxLayout, QWidget
|
|
4
|
+
from semver import Version
|
|
5
|
+
|
|
6
|
+
from python_generic_updater.data import general_info
|
|
7
|
+
from python_generic_updater.data.general_info import GeneralInfo
|
|
8
|
+
from python_generic_updater.data.general_settings import VERSION, WINDOW_WIDTH, WINDOW_HEIGHT
|
|
9
|
+
from python_generic_updater.ui.window_content import WindowContent
|
|
10
|
+
|
|
11
|
+
VERSION_PREFIX = 'v. '
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class UpdaterWindow(QMainWindow):
|
|
15
|
+
window_content: WindowContent
|
|
16
|
+
|
|
17
|
+
threadpool: QThreadPool
|
|
18
|
+
centered_on_init: bool = False
|
|
19
|
+
|
|
20
|
+
def __init__(self, update_base_url: str, current_update_version: str, target_directory_path: str) -> None:
|
|
21
|
+
super().__init__()
|
|
22
|
+
|
|
23
|
+
# GENERAL INFO
|
|
24
|
+
general_info.info = GeneralInfo(
|
|
25
|
+
update_base_url=update_base_url,
|
|
26
|
+
current_update_version=Version.parse(current_update_version),
|
|
27
|
+
target_directory_path=target_directory_path
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
# WINDOW
|
|
31
|
+
self.setWindowTitle('Updater ' + VERSION_PREFIX + VERSION)
|
|
32
|
+
self.setFixedSize(QSize(WINDOW_WIDTH, WINDOW_HEIGHT))
|
|
33
|
+
|
|
34
|
+
layout = QVBoxLayout()
|
|
35
|
+
layout.setContentsMargins(0, 0, 0, 0)
|
|
36
|
+
layout.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
|
37
|
+
|
|
38
|
+
# CENTER CONTENT
|
|
39
|
+
self.window_content = WindowContent()
|
|
40
|
+
self.window_content.quit_triggered.connect(self.close)
|
|
41
|
+
layout.addWidget(self.window_content)
|
|
42
|
+
|
|
43
|
+
# INITIALIZATION
|
|
44
|
+
widget = QWidget()
|
|
45
|
+
widget.setLayout(layout)
|
|
46
|
+
|
|
47
|
+
self.setCentralWidget(widget)
|
|
48
|
+
self.threadpool = QThreadPool()
|
|
49
|
+
|
|
50
|
+
# Adjust screen position on resize to center it after resizing in initialization
|
|
51
|
+
def resizeEvent(self, event) -> None:
|
|
52
|
+
if self.centered_on_init:
|
|
53
|
+
return
|
|
54
|
+
|
|
55
|
+
center = QGuiApplication.primaryScreen().geometry().center()
|
|
56
|
+
self.move(center - self.rect().center())
|
|
57
|
+
|
|
58
|
+
self.centered_on_init = True
|
|
59
|
+
return super().resizeEvent(event)
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
import shutil
|
|
2
|
+
from enum import Enum
|
|
3
|
+
from tempfile import TemporaryDirectory
|
|
4
|
+
|
|
5
|
+
from PyQt6.QtCore import Qt, QThreadPool, pyqtSignal
|
|
6
|
+
from PyQt6.QtWidgets import QWidget, QVBoxLayout, QLayout, QPushButton, QHBoxLayout, QProgressBar
|
|
7
|
+
|
|
8
|
+
from python_generic_updater.data import general_info
|
|
9
|
+
from python_generic_updater.libs.file_download import download_text_file
|
|
10
|
+
from python_generic_updater.libs.icons import Icon
|
|
11
|
+
from python_generic_updater.libs.threading import Worker
|
|
12
|
+
from python_generic_updater.libs.update_manager import UpdateManager
|
|
13
|
+
from python_generic_updater.libs.updates_info import UpdatesInfo
|
|
14
|
+
from python_generic_updater.ui.error_handling import process_error
|
|
15
|
+
from python_generic_updater.ui.status_text_widget import StatusTextWidget
|
|
16
|
+
|
|
17
|
+
TEXT_INITIAL_STATUS = 'Ready to update'
|
|
18
|
+
TEXT_CHECKING_FOR_UPDATE = 'Checking for update...'
|
|
19
|
+
TEXT_UP_TO_DATE = 'Your application is already up to date'
|
|
20
|
+
TEXT_UPDATE_IS_AVAILABLE_TEMPLATE = 'Newer version "{}" has been found and can be downloaded'
|
|
21
|
+
TEXT_UPDATE_CANCELED = 'Update has been canceled'
|
|
22
|
+
TEXT_DOWNLOADING = 'Downloading update...'
|
|
23
|
+
TEXT_INSTALLING_UPDATE = 'Installing update...'
|
|
24
|
+
TEXT_UPDATE_COMPLETE_TEMPLATE = 'Application has been updated to version {}'
|
|
25
|
+
|
|
26
|
+
UPDATESCRIPT_FILENAME = 'updatescript.ini'
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class ContentState(Enum):
|
|
30
|
+
CHECK_FOR_UPDATE = 0
|
|
31
|
+
UPDATE_AVAILABLE = 1
|
|
32
|
+
UP_TO_DATE = 2
|
|
33
|
+
RUN_UPDATE = 3
|
|
34
|
+
INSTALL_UPDATE = 4
|
|
35
|
+
UPDATE_COMPLETE = 5
|
|
36
|
+
UPDATE_FAILED = 6
|
|
37
|
+
UPDATE_CANCELED = 7
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class WindowContent(QWidget):
|
|
41
|
+
current_state: ContentState
|
|
42
|
+
update_failed_text: str = ''
|
|
43
|
+
updates_info: UpdatesInfo
|
|
44
|
+
progress_bar: QProgressBar
|
|
45
|
+
download_directory: TemporaryDirectory
|
|
46
|
+
|
|
47
|
+
layout: QVBoxLayout = None
|
|
48
|
+
update_manager: UpdateManager
|
|
49
|
+
threadpool: QThreadPool
|
|
50
|
+
|
|
51
|
+
quit_triggered = pyqtSignal()
|
|
52
|
+
|
|
53
|
+
def __init__(self) -> None:
|
|
54
|
+
super().__init__()
|
|
55
|
+
|
|
56
|
+
self.layout = QVBoxLayout()
|
|
57
|
+
self.layout.setContentsMargins(10, 10, 10, 5)
|
|
58
|
+
self.layout.setAlignment(Qt.AlignmentFlag.AlignVCenter)
|
|
59
|
+
self.setLayout(self.layout)
|
|
60
|
+
|
|
61
|
+
self.update_manager = UpdateManager()
|
|
62
|
+
self.threadpool = QThreadPool()
|
|
63
|
+
|
|
64
|
+
self._load_content_by_state(ContentState.CHECK_FOR_UPDATE)
|
|
65
|
+
|
|
66
|
+
def _load_content_by_state(self, state: ContentState) -> None:
|
|
67
|
+
current_state = state
|
|
68
|
+
|
|
69
|
+
self._clear_layout(self.layout)
|
|
70
|
+
status_text = StatusTextWidget()
|
|
71
|
+
self.layout.addStretch()
|
|
72
|
+
self.layout.addWidget(status_text)
|
|
73
|
+
|
|
74
|
+
match current_state:
|
|
75
|
+
case ContentState.CHECK_FOR_UPDATE:
|
|
76
|
+
status_text.set_status(TEXT_CHECKING_FOR_UPDATE, True)
|
|
77
|
+
self.layout.addStretch()
|
|
78
|
+
self._start_update_check()
|
|
79
|
+
|
|
80
|
+
case ContentState.UPDATE_FAILED:
|
|
81
|
+
status_text.set_status(self.update_failed_text, icon=Icon.CROSS_CIRCLE)
|
|
82
|
+
self.layout.addStretch()
|
|
83
|
+
self._add_quit_button(self.layout)
|
|
84
|
+
|
|
85
|
+
case ContentState.UP_TO_DATE:
|
|
86
|
+
status_text.set_status(TEXT_UP_TO_DATE, icon=Icon.CHECKMARK_CIRCLE)
|
|
87
|
+
self.layout.addStretch()
|
|
88
|
+
self._add_quit_button(self.layout)
|
|
89
|
+
|
|
90
|
+
case ContentState.UPDATE_AVAILABLE:
|
|
91
|
+
update_text = TEXT_UPDATE_IS_AVAILABLE_TEMPLATE.format(self.updates_info.latest_version)
|
|
92
|
+
status_text.set_status(update_text)
|
|
93
|
+
self.layout.addStretch()
|
|
94
|
+
self._add_download_button_bar(self.layout)
|
|
95
|
+
|
|
96
|
+
case ContentState.UPDATE_CANCELED:
|
|
97
|
+
status_text.set_status(TEXT_UPDATE_CANCELED)
|
|
98
|
+
self.layout.addStretch()
|
|
99
|
+
self._add_quit_button(self.layout)
|
|
100
|
+
|
|
101
|
+
case ContentState.RUN_UPDATE:
|
|
102
|
+
status_text.set_status(TEXT_DOWNLOADING)
|
|
103
|
+
self._add_download_progress_bar(self.layout)
|
|
104
|
+
self.layout.addStretch()
|
|
105
|
+
self._start_update_download()
|
|
106
|
+
|
|
107
|
+
case ContentState.INSTALL_UPDATE:
|
|
108
|
+
status_text.set_status(TEXT_INSTALLING_UPDATE)
|
|
109
|
+
self.layout.addStretch()
|
|
110
|
+
self._install_update()
|
|
111
|
+
|
|
112
|
+
case ContentState.UPDATE_COMPLETE:
|
|
113
|
+
text = TEXT_UPDATE_COMPLETE_TEMPLATE.format(self.updates_info.latest_version)
|
|
114
|
+
status_text.set_status(text)
|
|
115
|
+
self.layout.addStretch()
|
|
116
|
+
self._add_quit_button(self.layout)
|
|
117
|
+
|
|
118
|
+
def _clear_layout(self, layout) -> None:
|
|
119
|
+
if isinstance(layout, QLayout):
|
|
120
|
+
while layout.count():
|
|
121
|
+
item = layout.takeAt(0)
|
|
122
|
+
widget = item.widget()
|
|
123
|
+
if widget is not None:
|
|
124
|
+
widget.deleteLater()
|
|
125
|
+
else:
|
|
126
|
+
self._clear_layout(item.layout())
|
|
127
|
+
|
|
128
|
+
def _start_update_check(self) -> None:
|
|
129
|
+
checker = Worker(self._fetch_updatescript, general_info.info.update_base_url)
|
|
130
|
+
checker.signals.successResult.connect(self._process_updatescript)
|
|
131
|
+
checker.signals.error.connect(process_error)
|
|
132
|
+
self.threadpool.start(checker)
|
|
133
|
+
|
|
134
|
+
def _fetch_updatescript(self, url: str) -> None:
|
|
135
|
+
fetch_url = url + UPDATESCRIPT_FILENAME
|
|
136
|
+
return download_text_file(fetch_url)
|
|
137
|
+
|
|
138
|
+
def _process_updatescript(self, updatescript: str) -> None:
|
|
139
|
+
try:
|
|
140
|
+
self.updates_info = UpdatesInfo(updatescript)
|
|
141
|
+
except Exception as ex:
|
|
142
|
+
process_error(ex, self)
|
|
143
|
+
self._fail_update(
|
|
144
|
+
'Failed to parse update info from the update script. Please inform the developer of this error.')
|
|
145
|
+
return
|
|
146
|
+
|
|
147
|
+
current_version = general_info.info.current_update_version
|
|
148
|
+
if not current_version in self.updates_info.release_versions:
|
|
149
|
+
self._fail_update(
|
|
150
|
+
'Current version not supported by the update script. Please inform the developer of this error.')
|
|
151
|
+
return
|
|
152
|
+
|
|
153
|
+
if current_version != self.updates_info.latest_version:
|
|
154
|
+
self._load_content_by_state(ContentState.UPDATE_AVAILABLE)
|
|
155
|
+
else:
|
|
156
|
+
self._load_content_by_state(ContentState.UP_TO_DATE)
|
|
157
|
+
|
|
158
|
+
def _add_quit_button(self, layout: QVBoxLayout) -> None:
|
|
159
|
+
quit_button_bar = QWidget()
|
|
160
|
+
h_layout = QHBoxLayout()
|
|
161
|
+
quit_button_bar.setLayout(h_layout)
|
|
162
|
+
|
|
163
|
+
h_layout.addStretch()
|
|
164
|
+
quit_button = QPushButton('Quit')
|
|
165
|
+
quit_button.clicked.connect(self.quit_triggered.emit)
|
|
166
|
+
h_layout.addWidget(quit_button)
|
|
167
|
+
|
|
168
|
+
layout.addWidget(quit_button_bar)
|
|
169
|
+
|
|
170
|
+
def _add_download_button_bar(self, layout: QVBoxLayout) -> None:
|
|
171
|
+
button_bar = QWidget()
|
|
172
|
+
bar_layout = QHBoxLayout()
|
|
173
|
+
bar_layout.addStretch()
|
|
174
|
+
button_bar.setLayout(bar_layout)
|
|
175
|
+
|
|
176
|
+
cancel_button = QPushButton('Cancel')
|
|
177
|
+
cancel_button.clicked.connect(lambda: self._load_content_by_state(ContentState.UPDATE_CANCELED))
|
|
178
|
+
download_button = QPushButton('Download and install')
|
|
179
|
+
download_button.clicked.connect(lambda: self._load_content_by_state(ContentState.RUN_UPDATE))
|
|
180
|
+
|
|
181
|
+
bar_layout.addWidget(cancel_button)
|
|
182
|
+
bar_layout.addWidget(download_button)
|
|
183
|
+
layout.addWidget(button_bar)
|
|
184
|
+
|
|
185
|
+
def _add_download_progress_bar(self, layout: QVBoxLayout) -> None:
|
|
186
|
+
self._add_progress_bar(layout)
|
|
187
|
+
self.update_manager.download_progress_update.connect(self._update_progress_bar)
|
|
188
|
+
|
|
189
|
+
def _add_progress_bar(self, layout: QVBoxLayout) -> None:
|
|
190
|
+
self.progress_bar = QProgressBar()
|
|
191
|
+
self.progress_bar.setRange(0, 100)
|
|
192
|
+
layout.addWidget(self.progress_bar)
|
|
193
|
+
layout.addStretch()
|
|
194
|
+
|
|
195
|
+
def _start_update_download(self) -> None:
|
|
196
|
+
error_text_base = 'An error occurred while downloading. Please inform the developer of this error: '
|
|
197
|
+
|
|
198
|
+
updater = Worker(self._download_update)
|
|
199
|
+
updater.signals.successResult.connect(self._complete_download_step)
|
|
200
|
+
updater.signals.error.connect(lambda ex: self._fail_update(error_text_base + str(ex)))
|
|
201
|
+
self.threadpool.start(updater)
|
|
202
|
+
|
|
203
|
+
def _download_update(self) -> TemporaryDirectory:
|
|
204
|
+
download_directory = self.update_manager.download_update_files(
|
|
205
|
+
self.updates_info,
|
|
206
|
+
general_info.info.update_base_url)
|
|
207
|
+
|
|
208
|
+
if not download_directory:
|
|
209
|
+
raise RuntimeError('Files to download are unknown')
|
|
210
|
+
|
|
211
|
+
return download_directory
|
|
212
|
+
|
|
213
|
+
def _update_progress_bar(self, progress_value: float) -> None:
|
|
214
|
+
progress_value_int = int(progress_value)
|
|
215
|
+
self.progress_bar.setValue(progress_value_int)
|
|
216
|
+
|
|
217
|
+
def _complete_download_step(self, downloaded_files_dir: TemporaryDirectory):
|
|
218
|
+
self.download_directory = downloaded_files_dir
|
|
219
|
+
self._load_content_by_state(ContentState.INSTALL_UPDATE)
|
|
220
|
+
|
|
221
|
+
def _install_update(self):
|
|
222
|
+
shutil.copytree(self.download_directory.name, general_info.info.target_directory_path, dirs_exist_ok=True)
|
|
223
|
+
self._load_content_by_state(ContentState.UPDATE_COMPLETE)
|
|
224
|
+
|
|
225
|
+
def _fail_update(self, fail_text: str) -> None:
|
|
226
|
+
self.update_failed_text = fail_text
|
|
227
|
+
self._load_content_by_state(ContentState.UPDATE_FAILED)
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: python-visual-update-express
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: A dialog for updating specific files by syncing them from a server. Designed to work like InstallForge's "Visual Update Express".
|
|
5
|
+
Project-URL: Homepage, https://github.com/ChocolatePinecone/python-generic-updater
|
|
6
|
+
Project-URL: Issues, https://github.com/ChocolatePinecone/python-generic-updater/issues
|
|
7
|
+
Author-email: Jelmer Pijnappel <chocolatepinecone@gmail.com>
|
|
8
|
+
License-File: LICENSE
|
|
9
|
+
Classifier: Operating System :: OS Independent
|
|
10
|
+
Classifier: Programming Language :: Python :: 3
|
|
11
|
+
Requires-Python: >=3.10
|
|
12
|
+
Description-Content-Type: text/markdown
|
|
13
|
+
|
|
14
|
+
# Python Visual Update Express
|
|
15
|
+
|
|
16
|
+
A dialog for updating an application by downloading files from a server. Designed to work like InstallForge's "Visual
|
|
17
|
+
Update Express".
|
|
18
|
+
|
|
19
|
+
This software may only be used as described in the LICENSE file. Please contact me through a github issue for questions
|
|
20
|
+
about commercial use.
|
|
21
|
+
|
|
22
|
+
Compatible with Python 3.10 and up
|
|
23
|
+
|
|
24
|
+
## Installation
|
|
25
|
+
|
|
26
|
+
```sh
|
|
27
|
+
python3 -m pip install python-visual-update-express
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
## Usage
|
|
31
|
+
|
|
32
|
+
### Add updater window in python
|
|
33
|
+
|
|
34
|
+
Import the UpdaterWindow and configure it
|
|
35
|
+
|
|
36
|
+
```python
|
|
37
|
+
from python_visual_update_express import UpdaterWindow
|
|
38
|
+
|
|
39
|
+
UPDATE_BASE_URL = 'https://yoursite.com/releases/yourapplication/'
|
|
40
|
+
CURRENT_VERSION = '1.0.1'
|
|
41
|
+
UPDATE_TARGET_DIR = 'C:/yourlocationpath'
|
|
42
|
+
|
|
43
|
+
updater_window = UpdaterWindow(UPDATE_BASE_URL, CURRENT_VERSION, UPDATE_TARGET_DIR)
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
Then when you are ready, simply call `show` on the window to start the updater:
|
|
47
|
+
|
|
48
|
+
```python
|
|
49
|
+
updater_window.show()
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
### Update script
|
|
53
|
+
|
|
54
|
+
The updater works according to an updatescript.ini file on the server.
|
|
55
|
+
This file tells the updater what versions of the application are supported and how to perform updates.
|
|
56
|
+
Make sure to have this file available on the server URL used by the updater.
|
|
57
|
+
|
|
58
|
+
This is a very simple example of an updatescript.ini:
|
|
59
|
+
|
|
60
|
+
```javascript
|
|
61
|
+
releases{
|
|
62
|
+
1.0.0
|
|
63
|
+
1.0.1
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
release:1.0.0{
|
|
67
|
+
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
release:1.0.1{
|
|
71
|
+
DownloadFile:some-file.txt
|
|
72
|
+
}
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
#### Script commands
|
|
76
|
+
|
|
77
|
+
Currently only 1 command is available:
|
|
78
|
+
|
|
79
|
+
##### DownloadFile
|
|
80
|
+
|
|
81
|
+
This command will download a file on the speficied path, starting from the given update base URL + 'Updates/'.
|
|
82
|
+
So if for example the base URL of `https://yoursite.com/releases/yourapplication/` has been configured, a `DownloadFile:
|
|
83
|
+
dir1/some-file.txt` will download the file from
|
|
84
|
+
`https://yoursite.com/releases/yourapplication/Updates/dir1/some-file.txt`
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
python_visual_update_express/__init__.py,sha256=dARMO0afc-UpUd6zSNyBGDa73tobmwjAZh9oPf7SSjo,68
|
|
2
|
+
python_visual_update_express/application.py,sha256=c-Zfo_tWfB1aBa2_k6lViOb1otgbqFcsrs464re07uY,586
|
|
3
|
+
python_visual_update_express/data/general_info.py,sha256=Gh6ABo3SZ1EGh34guU90uUbtys8rRqciYZzQfQtkXak,285
|
|
4
|
+
python_visual_update_express/data/general_settings.py,sha256=vYWr1croCKrYMTKGzLu56vwQF8WG-SujO_JPFsLRQsI,176
|
|
5
|
+
python_visual_update_express/libs/file_download.py,sha256=HJ1BDAUTSkjnDLsZbLzla9b05voXy1xkv49aUACIAmg,667
|
|
6
|
+
python_visual_update_express/libs/icons.py,sha256=jTLMNPtboajtT2Rhs4wodPI_0-561RdwE8W_-QLCMbg,1020
|
|
7
|
+
python_visual_update_express/libs/threading.py,sha256=AyCWuMEHA9Rvn5lxAnLk7eqh9JXictvm9ONQmFwtwVM,857
|
|
8
|
+
python_visual_update_express/libs/update_manager.py,sha256=zi_npKq7QGvdkT6toKs3TtiFeR8aby-_96GiWMrVE2I,2005
|
|
9
|
+
python_visual_update_express/libs/updates_info.py,sha256=tA2O180UV3Q0gsDAeza33yo7lGJWtBxyH62R7jZQiMk,2889
|
|
10
|
+
python_visual_update_express/ui/error_handling.py,sha256=2UEdGopWlqUu3d930_hFkn423M2r_2juuT-XYegVWX4,435
|
|
11
|
+
python_visual_update_express/ui/notifications.py,sha256=uGT83XI9xwLz13vo1oPiaBcTZeyEjtIkyl1jfjQmDq0,338
|
|
12
|
+
python_visual_update_express/ui/status_text_widget.py,sha256=g3ud_ssPuGQ1rnKuyMVUSMY-tfhtiDDAJFARYVD9POs,2754
|
|
13
|
+
python_visual_update_express/ui/updater_window.py,sha256=sHFXolVNzJLJ7cGxbaOge77KBXjHskYOFbBftUFHNv0,2013
|
|
14
|
+
python_visual_update_express/ui/window_content.py,sha256=UpBBzCV6bTyamVctyRyB8iTIQvvxes-mfdr6YeV6oKM,9001
|
|
15
|
+
python_visual_update_express-1.0.0.dist-info/METADATA,sha256=nTqw6q0RhqTRVl5P4PgfA45TfiGzJwtgDYWz8K-LgM8,2430
|
|
16
|
+
python_visual_update_express-1.0.0.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
|
|
17
|
+
python_visual_update_express-1.0.0.dist-info/licenses/LICENSE,sha256=8XfFtZzNlgbVIWPbv6B-Nmmptkxh4-B0h7YDKShpxAY,364
|
|
18
|
+
python_visual_update_express-1.0.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
Copyright (c) 2026 Jelmer Pijnappel
|
|
2
|
+
|
|
3
|
+
Permission is granted to use, copy, and modify this software
|
|
4
|
+
for personal or internal business use, provided that attribution
|
|
5
|
+
to the original author is given.
|
|
6
|
+
|
|
7
|
+
Commercial redistribution, sublicensing, or sale of this software,
|
|
8
|
+
in whole or in part, is not permitted without explicit written
|
|
9
|
+
permission from the author.
|