python-visual-update-express 1.0.0__tar.gz

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.
Files changed (21) hide show
  1. python_visual_update_express-1.0.0/.gitignore +25 -0
  2. python_visual_update_express-1.0.0/DEV-README.md +46 -0
  3. python_visual_update_express-1.0.0/LICENSE +9 -0
  4. python_visual_update_express-1.0.0/PKG-INFO +84 -0
  5. python_visual_update_express-1.0.0/README.md +71 -0
  6. python_visual_update_express-1.0.0/pyproject.toml +22 -0
  7. python_visual_update_express-1.0.0/python_visual_update_express/__init__.py +1 -0
  8. python_visual_update_express-1.0.0/python_visual_update_express/application.py +18 -0
  9. python_visual_update_express-1.0.0/python_visual_update_express/data/general_info.py +14 -0
  10. python_visual_update_express-1.0.0/python_visual_update_express/data/general_settings.py +8 -0
  11. python_visual_update_express-1.0.0/python_visual_update_express/libs/file_download.py +17 -0
  12. python_visual_update_express-1.0.0/python_visual_update_express/libs/icons.py +37 -0
  13. python_visual_update_express-1.0.0/python_visual_update_express/libs/threading.py +31 -0
  14. python_visual_update_express-1.0.0/python_visual_update_express/libs/update_manager.py +49 -0
  15. python_visual_update_express-1.0.0/python_visual_update_express/libs/updates_info.py +86 -0
  16. python_visual_update_express-1.0.0/python_visual_update_express/ui/error_handling.py +12 -0
  17. python_visual_update_express-1.0.0/python_visual_update_express/ui/notifications.py +13 -0
  18. python_visual_update_express-1.0.0/python_visual_update_express/ui/status_text_widget.py +82 -0
  19. python_visual_update_express-1.0.0/python_visual_update_express/ui/updater_window.py +59 -0
  20. python_visual_update_express-1.0.0/python_visual_update_express/ui/window_content.py +227 -0
  21. python_visual_update_express-1.0.0/requirements.txt +6 -0
@@ -0,0 +1,25 @@
1
+ # Application
2
+ cache/
3
+ target/*
4
+ dist/
5
+
6
+ # Byte-compiled / optimized / DLL files
7
+ __pycache__/
8
+ *.py[cod]
9
+ *$py.class
10
+
11
+ # Environments
12
+ .env
13
+ .venv
14
+ env/
15
+ venv/
16
+ ENV/
17
+ env.bak/
18
+ venv.bak/
19
+
20
+ # PyCharm
21
+ # JetBrains specific template is maintained in a separate JetBrains.gitignore that can
22
+ # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
23
+ # and can be added to the global gitignore or merged into this file. For a more nuclear
24
+ # option (not recommended) you can uncomment the following to ignore the entire idea folder.
25
+ .idea/
@@ -0,0 +1,46 @@
1
+ # Python Visual Update Express
2
+
3
+ A dialog for updating an application by downloading files from a server. Designed to work like InstallForge's "Visual
4
+ Update Express".
5
+
6
+ ## Development Commands
7
+
8
+ ### Setup
9
+
10
+ First create a new python virtual environment. this will create a virtual environment in the 'venv' directory
11
+
12
+ ```sh
13
+ python -m venv venv
14
+ source venv/bin/activate # This activates the virtual environment
15
+ ```
16
+
17
+ Or in Windows PowerShell:
18
+
19
+ ```sh
20
+ py -m venv venv
21
+ .\venv\Scripts\activate # This activates the virtual environment
22
+ ```
23
+
24
+ To set up all required dependencies, run pip with the requirements.txt:
25
+
26
+ ```sh
27
+ python -m pip install -r requirements.txt
28
+ ```
29
+
30
+ ### Build application
31
+
32
+ For releasing a new build on PyPi, follow these steps:
33
+
34
+ - First, Update the release version in the pyproject.toml
35
+
36
+ - Build the python package files with the command
37
+
38
+ ```sh
39
+ python -m build
40
+ ```
41
+
42
+ - Upload the new release to PyPi
43
+
44
+ ```sh
45
+ python -m twine upload --repository pypi dist/*
46
+ ```
@@ -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.
@@ -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,71 @@
1
+ # Python Visual Update Express
2
+
3
+ A dialog for updating an application by downloading files from a server. Designed to work like InstallForge's "Visual
4
+ Update Express".
5
+
6
+ This software may only be used as described in the LICENSE file. Please contact me through a github issue for questions
7
+ about commercial use.
8
+
9
+ Compatible with Python 3.10 and up
10
+
11
+ ## Installation
12
+
13
+ ```sh
14
+ python3 -m pip install python-visual-update-express
15
+ ```
16
+
17
+ ## Usage
18
+
19
+ ### Add updater window in python
20
+
21
+ Import the UpdaterWindow and configure it
22
+
23
+ ```python
24
+ from python_visual_update_express import UpdaterWindow
25
+
26
+ UPDATE_BASE_URL = 'https://yoursite.com/releases/yourapplication/'
27
+ CURRENT_VERSION = '1.0.1'
28
+ UPDATE_TARGET_DIR = 'C:/yourlocationpath'
29
+
30
+ updater_window = UpdaterWindow(UPDATE_BASE_URL, CURRENT_VERSION, UPDATE_TARGET_DIR)
31
+ ```
32
+
33
+ Then when you are ready, simply call `show` on the window to start the updater:
34
+
35
+ ```python
36
+ updater_window.show()
37
+ ```
38
+
39
+ ### Update script
40
+
41
+ The updater works according to an updatescript.ini file on the server.
42
+ This file tells the updater what versions of the application are supported and how to perform updates.
43
+ Make sure to have this file available on the server URL used by the updater.
44
+
45
+ This is a very simple example of an updatescript.ini:
46
+
47
+ ```javascript
48
+ releases{
49
+ 1.0.0
50
+ 1.0.1
51
+ }
52
+
53
+ release:1.0.0{
54
+
55
+ }
56
+
57
+ release:1.0.1{
58
+ DownloadFile:some-file.txt
59
+ }
60
+ ```
61
+
62
+ #### Script commands
63
+
64
+ Currently only 1 command is available:
65
+
66
+ ##### DownloadFile
67
+
68
+ This command will download a file on the speficied path, starting from the given update base URL + 'Updates/'.
69
+ So if for example the base URL of `https://yoursite.com/releases/yourapplication/` has been configured, a `DownloadFile:
70
+ dir1/some-file.txt` will download the file from
71
+ `https://yoursite.com/releases/yourapplication/Updates/dir1/some-file.txt`
@@ -0,0 +1,22 @@
1
+ [build-system]
2
+ requires = ["hatchling >= 1.26"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "python-visual-update-express"
7
+ version = "1.0.0"
8
+ authors = [
9
+ { name = "Jelmer Pijnappel", email = "chocolatepinecone@gmail.com" },
10
+ ]
11
+ description = "A dialog for updating specific files by syncing them from a server. Designed to work like InstallForge's \"Visual Update Express\"."
12
+ readme = "README.md"
13
+ requires-python = ">=3.10"
14
+ classifiers = [
15
+ "Programming Language :: Python :: 3",
16
+ "Operating System :: OS Independent",
17
+ ]
18
+ license-files = ["LICEN[CS]E*"]
19
+
20
+ [project.urls]
21
+ Homepage = "https://github.com/ChocolatePinecone/python-generic-updater"
22
+ Issues = "https://github.com/ChocolatePinecone/python-generic-updater/issues"
@@ -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,8 @@
1
+ import os
2
+
3
+ VERSION = '1.0.0'
4
+ WINDOW_WIDTH = 400
5
+ WINDOW_HEIGHT = 200
6
+ ROOT_PATH = os.path.dirname(os.path.abspath(__file__)) + '/'
7
+ APP_STYLE = 'Fusion'
8
+ DEBUG_MODE = True
@@ -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,6 @@
1
+ PyQt6
2
+ pyqtwaitingspinner==1.3.2
3
+ semver
4
+ QtAwesome
5
+ build
6
+ twine