Fix a bug in the *.spec files and also apply the following major changes:
- Add support for adding the base folder to PATH. - Only display one pop up window at a time in order to avoid confusion. - Add automatic updates feature: - Windows and Linux versions will be shipped with an updater program used to update both Artemis and the updater itself. - MacOs versions will not have the updater. Instead the user will be asked to download the new software version (if present) via browser.
This commit is contained in:
5
.gitignore
vendored
5
.gitignore
vendored
@@ -1,8 +1,9 @@
|
||||
__pycache__
|
||||
Data
|
||||
csv_info.txt
|
||||
src/themes/.current_theme
|
||||
src/themes/__current_theme
|
||||
designer.bat
|
||||
launch.bat
|
||||
.vscode/
|
||||
.code-workspace
|
||||
spec_files/**/output
|
||||
*.txt
|
||||
|
||||
@@ -5,14 +5,15 @@ The first release is [3.0.0] because this is actually the third major version (c
|
||||
|
||||
## [Unreleased]
|
||||
### Added
|
||||
- The software version displayed has now a `.Dev` appended when running from script (_e.g._ 3.1.0.Dev) to differentiate from an actual binary executable. The `.Dev` thus implies that the
|
||||
running version of the software could not correspond to a particular release.
|
||||
- The `*.spec` files files can be executed without copying the source code in
|
||||
- Automatic updates. From this version Artemis can update itself if a new version is available. Works only when running the executable version (disabled when running from source). The feature is partially unavailable for Mac, you can only download the new version.
|
||||
- The software version displayed has now a `.Dev` appended when running from script (_e.g._ 3.1.0.Dev) to differentiate from an actual binary executable. The `.Dev` thus implies that the running version of the software could not correspond to a particular release.
|
||||
- The `*.spec` files can be executed without copying the source code into
|
||||
their folder.
|
||||
- Add a link to the GitHub repository in the action bar.
|
||||
- Add support for signals with multiple-value acf ([#9](https://github.com/AresValley/Artemis/pull/9)). This breaks the backward compatibility because the database changed structure.
|
||||
- Add support for signals with multiple-value acf ([#9](https://github.com/AresValley/Artemis/pull/9)). This partially breaks the backward compatibility because the database changed structure.
|
||||
|
||||
### Fixed
|
||||
- Adding the `Artemis` folder to `PATH` as the expected behaviour. Prior to this fix, Artemis could not find the `Data` and `themes` folders if started from outside the `Artemis` folder.
|
||||
- The audio buttons are of the same dimension also for high resolution screens ([#13](https://github.com/AresValley/Artemis/pull/13))
|
||||
- An audio sample can be paused and a different one can be played without a program crash ([#12](https://github.com/AresValley/Artemis/pull/12))
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
numpy==1.17.2
|
||||
pandas>=0.24.2
|
||||
certifi>=2019.6.16
|
||||
aiohttp>=3.5.4
|
||||
|
||||
@@ -11,8 +11,9 @@ SRC_PATH = "../../src/"
|
||||
|
||||
data_file = [
|
||||
(f, '.') for f in glob.glob(SRC_PATH + '*.[pu][yi]')
|
||||
if f.split('/')[-1] != "artemis.py"
|
||||
].append((SRC_PATH + 'cacert.pem', '.'))
|
||||
if f.split('/')[-1] != "artemis.py" and f.split('/')[-1] != "updater.py"
|
||||
]
|
||||
data_file.append((SRC_PATH + 'cacert.pem', '.'))
|
||||
|
||||
a = Analysis([SRC_PATH + 'artemis.py'], # noqa: 821
|
||||
pathex=[os.getcwd()],
|
||||
|
||||
@@ -12,7 +12,8 @@ SRC_PATH = "../../src/"
|
||||
data_file = [
|
||||
(f, '.') for f in glob.glob(SRC_PATH + '*.[pu][yi]')
|
||||
if f.split('/')[-1] != "artemis.py"
|
||||
].append((SRC_PATH + 'cacert.pem', '.'))
|
||||
]
|
||||
data_file.append((SRC_PATH + 'cacert.pem', '.'))
|
||||
|
||||
a = Analysis([SRC_PATH + 'artemis.py'], # noqa: 821
|
||||
pathex=[os.getcwd()],
|
||||
|
||||
42
spec_files/Linux/build.sh
Normal file
42
spec_files/Linux/build.sh
Normal file
@@ -0,0 +1,42 @@
|
||||
echo "Build Artemis executable.."
|
||||
|
||||
rm -rf output
|
||||
|
||||
mkdir output
|
||||
mkdir output/artemis
|
||||
|
||||
pyinstaller Artemis.spec
|
||||
|
||||
mv -v ./dist/Artemis ./output/Artemis
|
||||
rm -rfv dist build
|
||||
|
||||
echo "Build _ArtemisUpdater.."
|
||||
|
||||
pyinstaller updater.spec
|
||||
|
||||
mv -v ./dist/_ArtemisUpdater ./output/_ArtemisUpdater
|
||||
rm -rfv dist build
|
||||
|
||||
echo "Create single archives"
|
||||
cd output
|
||||
|
||||
cp -r ../../../src/themes artemis/themes
|
||||
rm -f artemis/themes/__current_theme
|
||||
cp Artemis artemis/Artemis
|
||||
cp _ArtemisUpdater artemis/_ArtemisUpdater
|
||||
|
||||
tar -czvf Artemis_linux.tar.gz Artemis -C artemis themes
|
||||
tar -czvf _ArtemisUpdater_linux.tar.gz ./_ArtemisUpdater
|
||||
|
||||
echo "Create full archive for website"
|
||||
|
||||
cp ../artemis3.svg artemis
|
||||
cp ../create_shortcut.sh artemis
|
||||
|
||||
tar -czvf ArtemisWebDownlaod_linux.tar.gz artemis
|
||||
|
||||
echo "Get size and sha256"
|
||||
python ../../__get_hash_code.py Artemis_linux.tar.gz _ArtemisUpdater_linux.tar.gz ArtemisWebDownlaod_linux.tar.gz
|
||||
|
||||
cd ..
|
||||
echo "Done."
|
||||
51
spec_files/Linux/updater.spec
Normal file
51
spec_files/Linux/updater.spec
Normal file
@@ -0,0 +1,51 @@
|
||||
# -*- mode: python ; coding: utf-8 -*-
|
||||
|
||||
import os
|
||||
|
||||
|
||||
BASE_FOLDER = "../../src/"
|
||||
block_cipher = None
|
||||
|
||||
data_file = [
|
||||
(os.path.join(BASE_FOLDER, "download_db_window.ui"), "."),
|
||||
(os.path.join(BASE_FOLDER, "download_window.py"), "."),
|
||||
(os.path.join(BASE_FOLDER, "utilities.py"), "."),
|
||||
(os.path.join(BASE_FOLDER, "versioncontroller.py"), "."),
|
||||
(os.path.join(BASE_FOLDER, "downloadtargetfactory.py"), "."),
|
||||
(os.path.join(BASE_FOLDER, "executable_utilities.py"), "."),
|
||||
(os.path.join(BASE_FOLDER, "os_utilities.py"), "."),
|
||||
(os.path.join(BASE_FOLDER, "web_utilities.py"), "."),
|
||||
(os.path.join(BASE_FOLDER, "constants.py"), "."),
|
||||
(os.path.join(BASE_FOLDER, "threads.py"), "."),
|
||||
(os.path.join(BASE_FOLDER, "default_imgs_rc.py"), "."),
|
||||
(os.path.join(BASE_FOLDER, "cacert.pem"), "."),
|
||||
]
|
||||
a = Analysis([os.path.join(BASE_FOLDER, 'updater.py')], # noqa: 821
|
||||
pathex=[os.getcwd()],
|
||||
binaries=[],
|
||||
datas=data_file,
|
||||
hiddenimports=[],
|
||||
hookspath=[],
|
||||
runtime_hooks=[],
|
||||
excludes=[],
|
||||
win_no_prefer_redirects=False,
|
||||
win_private_assemblies=False,
|
||||
cipher=block_cipher,
|
||||
noarchive=False)
|
||||
pyz = PYZ(a.pure, # noqa: 821
|
||||
a.zipped_data,
|
||||
cipher=block_cipher)
|
||||
exe = EXE(pyz, # noqa: 821
|
||||
a.scripts,
|
||||
a.binaries,
|
||||
a.zipfiles,
|
||||
a.datas,
|
||||
[],
|
||||
name='_ArtemisUpdater',
|
||||
debug=False,
|
||||
bootloader_ignore_signals=False,
|
||||
strip=False,
|
||||
upx=True,
|
||||
runtime_tmpdir=None,
|
||||
console=False,
|
||||
icon='Artemis3.ico')
|
||||
@@ -16,6 +16,8 @@ Artemis 3 .spec files are used by the package **pyinstaller** (https://www.pyins
|
||||
|
||||
**IMPORTANT (LINUX COMPILING):** *The executable that PyInstaller builds is not fully static, in that it still depends on the system libc. **Under Linux, the ABI of GLIBC is backward compatible, but not forward compatible. So if you link against a newer GLIBC, you can't run the resulting executable on an older system**. The supplied binary bootloader should work with older GLIBC. However, the libpython.so and other dynamic libraries still depend on the newer GLIBC. The solution is to compile the Python interpreter with its modules (and also probably bootloader) on the oldest system you have around so that it gets linked with the oldest version of GLIBC.* (Source: PyInstaller)
|
||||
|
||||
**NOTE.** Depending on the number of packages installed in your python environemnt, the size of the executables file can vary significantly. In order to get the smallest size possible it is recommend to initialize a fresh virtual environment with just the requirements and pyinstaller.
|
||||
|
||||
## Package Building (standalone aka one-file, high portability, **suggested**)
|
||||
1. Download/clone the git repository.
|
||||
2. In the `spec_files/<your OS>` folder open a terminal and type
|
||||
@@ -35,6 +37,16 @@ pyinstaller Artemis_onedir.spec
|
||||
be deleted.
|
||||
|
||||
|
||||
You can save a copy of the executable in a folder of you choice. At startup it will ask you to download
|
||||
the database and also warn you that the `themes` folder is missing. To avoid this,
|
||||
copy `src/Data` and `src/themes` in the folder containing the executable.
|
||||
You can save a copy of the executable in a folder of you choice. At startup it will ask you to download the database and also warn you that the `themes` folder is missing. To avoid this, copy `src/Data` and `src/themes` in the folder containing the executable.
|
||||
|
||||
## Build scripts
|
||||
Provided you satisfy the requirements (see [requirements.txt](../requirements/requirements.txt)) and have pyinstaller installed, running a `build.*` script in `<your os>` folder will produce an `output` folder with:
|
||||
|
||||
- Executable versions of Artemis and the updater;
|
||||
- compressed versions of the same files;
|
||||
- a folder called `Artemis/` containing the executables and the `theme` folder
|
||||
- a compressed version of the folder
|
||||
|
||||
At the end of the process the script writes on standard output the size and sha256 code for the compressed files.
|
||||
|
||||
**NOTE.** For Windows you will need a 7z installation. Also check the path hardcoded in `/Windows/build.bat`.
|
||||
@@ -12,7 +12,8 @@ SRC_PATH = "../../src/"
|
||||
data_file = [
|
||||
(f, '.') for f in glob.glob(SRC_PATH + '*.[pu][yi]')
|
||||
if f.split('/')[-1] != "artemis.py"
|
||||
].append((SRC_PATH + 'cacert.pem', '.'))
|
||||
]
|
||||
data_file.append((SRC_PATH + 'cacert.pem', '.'))
|
||||
|
||||
a = Analysis(SRC_PATH + ['artemis.py'], # noqa: 821
|
||||
pathex=[os.getcwd()],
|
||||
|
||||
@@ -11,8 +11,9 @@ SRC_PATH = "../../src/"
|
||||
|
||||
data_file = [
|
||||
(f, '.') for f in glob.glob(SRC_PATH + '*.[pu][yi]')
|
||||
if f.split('/')[-1] != "artemis.py"
|
||||
].append((SRC_PATH + 'cacert.pem', '.'))
|
||||
if f.split('/')[-1] != "artemis.py" and f.split('/')[-1] != "updater.py"
|
||||
]
|
||||
data_file.append((SRC_PATH + 'cacert.pem', '.'))
|
||||
|
||||
a = Analysis([SRC_PATH + 'artemis.py'], # noqa: 821
|
||||
pathex=[os.getcwd()],
|
||||
@@ -42,4 +43,5 @@ exe = EXE(pyz, # noqa: 821
|
||||
upx=True,
|
||||
runtime_tmpdir=None,
|
||||
console=False,
|
||||
icon='Artemis3.ico')
|
||||
icon='Artemis3.ico',
|
||||
uac_admin=True)
|
||||
|
||||
30
spec_files/Windows/build.bat
Normal file
30
spec_files/Windows/build.bat
Normal file
@@ -0,0 +1,30 @@
|
||||
ECHO OFF
|
||||
ECHO Building Artemis executable...
|
||||
RMDIR /s /q output
|
||||
MKDIR output
|
||||
pyinstaller artemis.spec
|
||||
ECHO Remove directories
|
||||
MOVE dist\Artemis.exe .\output\Artemis.exe
|
||||
RMDIR /s /q dist
|
||||
RMDIR /s /q build
|
||||
ECHO *************
|
||||
ECHO *************
|
||||
ECHO Building updater...
|
||||
pyinstaller updater.spec
|
||||
ECHO Remove directories
|
||||
MOVE dist\_ArtemisUpdater.exe .\output\_ArtemisUpdater.exe
|
||||
RMDIR /s /q dist
|
||||
RMDIR /s /q build
|
||||
CD output
|
||||
MKDIR Artemis
|
||||
XCOPY /y Artemis.exe Artemis\
|
||||
XCOPY /e /k /y ..\..\..\src\themes Artemis\themes\ /EXCLUDE:..\excluded_files.txt
|
||||
XCOPY /y _ArtemisUpdater.exe Artemis\
|
||||
ECHO "Compress files themes+Artemis.exe -> Artemis.zip"
|
||||
"C:\Program Files\7-Zip\7z.exe" a -r Artemis_win.zip Artemis.exe Artemis\themes
|
||||
"C:\Program Files\7-Zip\7z.exe" a _ArtemisUpdater_win.zip _ArtemisUpdater.exe
|
||||
ECHO "Compress all files for website download"
|
||||
"C:\Program Files\7-Zip\7z.exe" a ArtemisWebsite_win.zip Artemis\
|
||||
python ..\..\__get_hash_code.py Artemis_win.zip _ArtemisUpdater_win.zip ArtemisWebsite_win.zip
|
||||
CD ..
|
||||
ECHO Done.
|
||||
1
spec_files/Windows/excluded_files.txt
Normal file
1
spec_files/Windows/excluded_files.txt
Normal file
@@ -0,0 +1 @@
|
||||
__current_theme
|
||||
52
spec_files/Windows/updater.spec
Normal file
52
spec_files/Windows/updater.spec
Normal file
@@ -0,0 +1,52 @@
|
||||
# -*- mode: python ; coding: utf-8 -*-
|
||||
|
||||
import os
|
||||
|
||||
|
||||
BASE_FOLDER = "../../src/"
|
||||
block_cipher = None
|
||||
|
||||
data_file = [
|
||||
(os.path.join(BASE_FOLDER, "download_db_window.ui"), "."),
|
||||
(os.path.join(BASE_FOLDER, "download_window.py"), "."),
|
||||
(os.path.join(BASE_FOLDER, "utilities.py"), "."),
|
||||
(os.path.join(BASE_FOLDER, "versioncontroller.py"), "."),
|
||||
(os.path.join(BASE_FOLDER, "downloadtargetfactory.py"), "."),
|
||||
(os.path.join(BASE_FOLDER, "executable_utilities.py"), "."),
|
||||
(os.path.join(BASE_FOLDER, "os_utilities.py"), "."),
|
||||
(os.path.join(BASE_FOLDER, "web_utilities.py"), "."),
|
||||
(os.path.join(BASE_FOLDER, "constants.py"), "."),
|
||||
(os.path.join(BASE_FOLDER, "threads.py"), "."),
|
||||
(os.path.join(BASE_FOLDER, "default_imgs_rc.py"), "."),
|
||||
(os.path.join(BASE_FOLDER, "cacert.pem"), "."),
|
||||
]
|
||||
a = Analysis([os.path.join(BASE_FOLDER, 'updater.py')], # noqa: 821
|
||||
pathex=[os.getcwd()],
|
||||
binaries=[],
|
||||
datas=data_file,
|
||||
hiddenimports=[],
|
||||
hookspath=[],
|
||||
runtime_hooks=[],
|
||||
excludes=[],
|
||||
win_no_prefer_redirects=False,
|
||||
win_private_assemblies=False,
|
||||
cipher=block_cipher,
|
||||
noarchive=False)
|
||||
pyz = PYZ(a.pure, # noqa: 821
|
||||
a.zipped_data,
|
||||
cipher=block_cipher)
|
||||
exe = EXE(pyz, # noqa: 821
|
||||
a.scripts,
|
||||
a.binaries,
|
||||
a.zipfiles,
|
||||
a.datas,
|
||||
[],
|
||||
name='_ArtemisUpdater',
|
||||
debug=False,
|
||||
bootloader_ignore_signals=False,
|
||||
strip=False,
|
||||
upx=True,
|
||||
runtime_tmpdir=None,
|
||||
console=False,
|
||||
icon='Artemis3.ico',
|
||||
uac_admin=True)
|
||||
31
spec_files/__get_hash_code.py
Normal file
31
spec_files/__get_hash_code.py
Normal file
@@ -0,0 +1,31 @@
|
||||
import hashlib
|
||||
import sys
|
||||
|
||||
|
||||
"""Print on stadard output the size in KB and sha256 codes of a list
|
||||
of command line-provided file names."""
|
||||
|
||||
print()
|
||||
|
||||
try:
|
||||
fnames = sys.argv[1:]
|
||||
except Exception:
|
||||
print("Provide a valid filename.")
|
||||
exit()
|
||||
|
||||
for fname in fnames:
|
||||
try:
|
||||
with open(fname, mode='rb') as f:
|
||||
target = f.read()
|
||||
except Exception:
|
||||
print("File not found")
|
||||
exit()
|
||||
|
||||
code = hashlib.sha256()
|
||||
code.update(target)
|
||||
hash_code = code.hexdigest()
|
||||
|
||||
print("File name:", fname)
|
||||
print("Size (KB):", round(len(target) / 1024, 3))
|
||||
print("Hash code:", hash_code)
|
||||
print("-" * 80)
|
||||
@@ -12,7 +12,8 @@ SRC_PATH = "../../src/"
|
||||
data_file = [
|
||||
(f, '.') for f in glob.glob(SRC_PATH + '*.[pu][yi]')
|
||||
if f.split('/')[-1] != "artemis.py"
|
||||
].extend(((SRC_PATH + 'cacert.pem', '.'), ('themes', './themes')))
|
||||
]
|
||||
data_file.extend(((SRC_PATH + 'cacert.pem', '.'), ((SRC_PATH + 'themes', './themes')))
|
||||
|
||||
a = Analysis([SRC_PATH + 'artemis.py'], # noqa: 821
|
||||
pathex=[os.getcwd()],
|
||||
|
||||
121
src/artemis.py
121
src/artemis.py
@@ -7,45 +7,58 @@ from time import sleep, time
|
||||
|
||||
from pandas import read_csv
|
||||
|
||||
from PyQt5.QtWidgets import (QMainWindow,
|
||||
QApplication,
|
||||
qApp,
|
||||
QDesktopWidget,
|
||||
QListWidgetItem,
|
||||
QMessageBox,
|
||||
QSplashScreen,)
|
||||
from PyQt5.QtWidgets import (
|
||||
QMainWindow,
|
||||
QApplication,
|
||||
qApp,
|
||||
QDesktopWidget,
|
||||
QListWidgetItem,
|
||||
QMessageBox,
|
||||
QSplashScreen,
|
||||
)
|
||||
from PyQt5.QtGui import QPixmap
|
||||
from PyQt5 import uic
|
||||
from PyQt5.QtCore import (QFileInfo,
|
||||
Qt,
|
||||
pyqtSlot,)
|
||||
from PyQt5.QtCore import (
|
||||
QFileInfo,
|
||||
Qt,
|
||||
pyqtSlot,
|
||||
)
|
||||
from acfvalue import ACFValue
|
||||
from audio_player import AudioPlayer
|
||||
from weatherdata import ForecastData
|
||||
from download_window import DownloadWindow
|
||||
from spaceweathermanager import SpaceWeatherManager
|
||||
from constants import (Constants,
|
||||
GfdType,
|
||||
Database,
|
||||
ChecksumWhat,
|
||||
Messages,
|
||||
Signal,)
|
||||
from constants import (
|
||||
Constants,
|
||||
GfdType,
|
||||
Database,
|
||||
DownloadTarget,
|
||||
Messages,
|
||||
Signal,
|
||||
__BASE_FOLDER__,
|
||||
)
|
||||
from themesmanager import ThemeManager
|
||||
from filters import Filters
|
||||
from utilities import (checksum_ok,
|
||||
pop_up,
|
||||
is_undef_freq,
|
||||
is_undef_band,
|
||||
format_numbers,
|
||||
resource_path,
|
||||
safe_cast,
|
||||
is_mac_os)
|
||||
from utilities import (
|
||||
checksum_ok,
|
||||
pop_up,
|
||||
is_undef_freq,
|
||||
is_undef_band,
|
||||
format_numbers,
|
||||
safe_cast,
|
||||
)
|
||||
from executable_utilities import IS_BINARY, resource_path
|
||||
from os_utilities import IS_MAC
|
||||
from web_utilities import get_db_hash_code
|
||||
from downloadtargetfactory import get_download_target
|
||||
from updatescontroller import UpdatesController
|
||||
|
||||
# import default_imgs_rc
|
||||
|
||||
__LATEST_VERSION__ = "3.0.1"
|
||||
|
||||
if hasattr(sys, '_MEIPASS'):
|
||||
__LATEST_VERSION__ = "3.1.0"
|
||||
|
||||
if IS_BINARY:
|
||||
__VERSION__ = __LATEST_VERSION__
|
||||
else:
|
||||
__VERSION__ = __LATEST_VERSION__ + ".Dev"
|
||||
@@ -66,7 +79,7 @@ class Artemis(QMainWindow, Ui_MainWindow):
|
||||
self.set_initial_size()
|
||||
self.closing = False
|
||||
self.download_window = DownloadWindow()
|
||||
self.download_window.complete.connect(self.show_downloaded_signals)
|
||||
self.download_window.complete.connect(self.action_after_download)
|
||||
self.actionExit.triggered.connect(qApp.quit)
|
||||
self.action_update_database.triggered.connect(self.ask_if_download)
|
||||
self.action_check_db_ver.triggered.connect(self.check_db_ver)
|
||||
@@ -185,13 +198,27 @@ class Artemis(QMainWindow, Ui_MainWindow):
|
||||
|
||||
self.main_tab.currentChanged.connect(self.hide_show_right_widget)
|
||||
|
||||
# Final operations.
|
||||
self.updates_controller = UpdatesController(__LATEST_VERSION__, self)
|
||||
self.updates_controller.start()
|
||||
self.action_check_software_version.triggered.connect(
|
||||
self.updates_controller.start_verify_software_version
|
||||
)
|
||||
|
||||
# Final operations.
|
||||
self.theme_manager.start()
|
||||
self.load_db()
|
||||
self.display_signals()
|
||||
|
||||
def action_after_download(self):
|
||||
"""Decide what to do after a successful download.
|
||||
|
||||
If a new database was downloaded, show the signals."""
|
||||
if self.download_window.target is DownloadTarget.DATA_FOLDER:
|
||||
self.show_downloaded_signals()
|
||||
|
||||
@pyqtSlot()
|
||||
def hide_show_right_widget(self):
|
||||
"""Hide the audio player when forecast tabs are displayed."""
|
||||
if self.main_tab.currentWidget() == self.forecast_tab:
|
||||
self.fixed_audio_and_image.setVisible(False)
|
||||
elif not self.fixed_audio_and_image.isVisible():
|
||||
@@ -320,8 +347,9 @@ class Artemis(QMainWindow, Ui_MainWindow):
|
||||
Do nothing if already downloading.
|
||||
"""
|
||||
if not self.download_window.isVisible():
|
||||
self.download_window.start_download()
|
||||
self.download_window.show()
|
||||
self.download_window.activate(
|
||||
get_download_target(DownloadTarget.DATA_FOLDER)
|
||||
)
|
||||
|
||||
@pyqtSlot()
|
||||
def ask_if_download(self):
|
||||
@@ -341,7 +369,7 @@ class Artemis(QMainWindow, Ui_MainWindow):
|
||||
self.download_db()
|
||||
else:
|
||||
try:
|
||||
is_checksum_ok = checksum_ok(db, ChecksumWhat.DB)
|
||||
is_checksum_ok = checksum_ok(db, get_db_hash_code())
|
||||
except Exception:
|
||||
pop_up(self, title=Messages.NO_CONNECTION,
|
||||
text=Messages.NO_CONNECTION_MSG).show()
|
||||
@@ -349,8 +377,8 @@ class Artemis(QMainWindow, Ui_MainWindow):
|
||||
if not is_checksum_ok:
|
||||
self.download_db()
|
||||
else:
|
||||
answer = pop_up(self, title=Messages.DB_UP_TO_DATE,
|
||||
text=Messages.DB_UP_TO_DATE_MSG,
|
||||
answer = pop_up(self, title=Messages.UP_TO_DATE,
|
||||
text=Messages.UP_TO_DATE_MSG,
|
||||
informative_text=Messages.DOWNLOAD_ANYWAY_QUESTION,
|
||||
is_question=True,
|
||||
default_btn=QMessageBox.No).exec()
|
||||
@@ -381,14 +409,14 @@ class Artemis(QMainWindow, Ui_MainWindow):
|
||||
self.download_db()
|
||||
else:
|
||||
try:
|
||||
is_checksum_ok = checksum_ok(db, ChecksumWhat.DB)
|
||||
is_checksum_ok = checksum_ok(db, get_db_hash_code())
|
||||
except Exception:
|
||||
pop_up(self, title=Messages.NO_CONNECTION,
|
||||
text=Messages.NO_CONNECTION_MSG).show()
|
||||
else:
|
||||
if is_checksum_ok:
|
||||
pop_up(self, title=Messages.DB_UP_TO_DATE,
|
||||
text=Messages.DB_UP_TO_DATE_MSG).show()
|
||||
pop_up(self, title=Messages.UP_TO_DATE,
|
||||
text=Messages.UP_TO_DATE_MSG).show()
|
||||
else:
|
||||
answer = pop_up(self, title=Messages.DB_NEW_VER,
|
||||
text=Messages.DB_NEW_VER_MSG,
|
||||
@@ -411,12 +439,14 @@ class Artemis(QMainWindow, Ui_MainWindow):
|
||||
Handle possible missing file error.
|
||||
"""
|
||||
try:
|
||||
self.db = read_csv(os.path.join(Constants.DATA_FOLDER, Database.NAME),
|
||||
sep=Database.DELIMITER,
|
||||
header=None,
|
||||
index_col=0,
|
||||
dtype={name: str for name in Database.STRINGS},
|
||||
names=Database.NAMES)
|
||||
self.db = read_csv(
|
||||
os.path.join(Constants.DATA_FOLDER, Database.NAME),
|
||||
sep=Database.DELIMITER,
|
||||
header=None,
|
||||
index_col=0,
|
||||
dtype={name: str for name in Database.STRINGS},
|
||||
names=Database.NAMES
|
||||
)
|
||||
except FileNotFoundError:
|
||||
self.search_bar.setDisabled(True)
|
||||
answer = pop_up(self, title=Messages.NO_DB,
|
||||
@@ -449,9 +479,9 @@ class Artemis(QMainWindow, Ui_MainWindow):
|
||||
)
|
||||
|
||||
def collect_list(self, list_property, separator=Constants.FIELD_SEPARATOR):
|
||||
"""Collect all the entrys of a QListWidget.
|
||||
"""Collect all the entries of a QListWidget.
|
||||
|
||||
Handle multiple entries in one item seprated by a separator.
|
||||
Handle multiple entries in one item separated by a separator.
|
||||
Keyword argument:
|
||||
separator -- the separator character for multiple-entries items.
|
||||
"""
|
||||
@@ -581,7 +611,6 @@ class Artemis(QMainWindow, Ui_MainWindow):
|
||||
if item:
|
||||
spectrogram_name = item.text()
|
||||
path_spectr = os.path.join(
|
||||
Constants.DATA_FOLDER,
|
||||
Constants.SPECTRA_FOLDER,
|
||||
spectrogram_name + Constants.SPECTRA_EXT
|
||||
)
|
||||
@@ -658,7 +687,7 @@ class Artemis(QMainWindow, Ui_MainWindow):
|
||||
|
||||
if __name__ == '__main__':
|
||||
# For executables running on Mac Os systems.
|
||||
if hasattr(sys, "_MEIPASS") and is_mac_os():
|
||||
if IS_BINARY and IS_MAC and __BASE_FOLDER__ == os.curdir:
|
||||
os.chdir(sys._MEIPASS)
|
||||
|
||||
my_app = QApplication(sys.argv)
|
||||
|
||||
@@ -9514,6 +9514,7 @@ QSlider::handle:horizontal {
|
||||
</property>
|
||||
<addaction name="action_check_db_ver"/>
|
||||
<addaction name="action_update_database"/>
|
||||
<addaction name="action_check_software_version"/>
|
||||
</widget>
|
||||
<widget class="QMenu" name="menu_themes">
|
||||
<property name="title">
|
||||
@@ -9592,6 +9593,11 @@ QSlider::handle:horizontal {
|
||||
<string>GitHub</string>
|
||||
</property>
|
||||
</action>
|
||||
<action name="action_check_software_version">
|
||||
<property name="text">
|
||||
<string>Check software version</string>
|
||||
</property>
|
||||
</action>
|
||||
</widget>
|
||||
<customwidgets>
|
||||
<customwidget>
|
||||
|
||||
@@ -2,8 +2,8 @@ import os
|
||||
from pygame import mixer
|
||||
from PyQt5.QtCore import QTimer, pyqtSlot, QObject
|
||||
|
||||
from constants import Constants
|
||||
import qtawesome as qta
|
||||
from constants import Constants
|
||||
|
||||
|
||||
class AudioPlayer(QObject):
|
||||
@@ -127,7 +127,6 @@ class AudioPlayer(QObject):
|
||||
"""Set the current audio sample."""
|
||||
self._reset_audio_widget()
|
||||
full_name = os.path.join(
|
||||
Constants.DATA_FOLDER,
|
||||
Constants.AUDIO_FOLDER,
|
||||
fname + '.ogg'
|
||||
)
|
||||
|
||||
106
src/constants.py
106
src/constants.py
@@ -1,13 +1,21 @@
|
||||
from collections import namedtuple
|
||||
from enum import Enum, auto
|
||||
import os.path
|
||||
from executable_utilities import get_executable_path
|
||||
|
||||
|
||||
__BASE_FOLDER__ = get_executable_path()
|
||||
|
||||
|
||||
class SupportedOs:
|
||||
"""Supported operating systems."""
|
||||
WINDOWS = "windows"
|
||||
LINUX = "linux"
|
||||
MAC = "mac"
|
||||
|
||||
|
||||
class Ftype:
|
||||
"""Container class to differentiate between frequency and band.
|
||||
|
||||
Used in reset_fb_filters.
|
||||
"""
|
||||
"""Container class to differentiate between frequency and band."""
|
||||
|
||||
FREQ = "freq"
|
||||
BAND = "band"
|
||||
@@ -20,30 +28,13 @@ class GfdType(Enum):
|
||||
LOC = auto()
|
||||
|
||||
|
||||
class ChecksumWhat(Enum):
|
||||
"""Enum class to distinguish the object you want to verify the checksum."""
|
||||
class DownloadTarget(Enum):
|
||||
"""Enum class to distinguish the object being downloaded."""
|
||||
|
||||
FOLDER = auto()
|
||||
DB = auto()
|
||||
|
||||
|
||||
class Messages:
|
||||
"""Container class for messages to be displayed."""
|
||||
|
||||
DB_UP_TO_DATE = "Already up to date"
|
||||
DB_UP_TO_DATE_MSG = "No newer version to download."
|
||||
DB_NEW_VER = "New version available"
|
||||
DB_NEW_VER_MSG = "A new version of the database is available for download."
|
||||
NO_DB_AVAIL = "No database detected."
|
||||
NO_DB = "No database"
|
||||
DOWNLOAD_NOW_QUESTION = "Do you want to download it now?"
|
||||
DOWNLOAD_ANYWAY_QUESTION = "Do you want to download it anyway?"
|
||||
NO_CONNECTION = "No connection"
|
||||
NO_CONNECTION_MSG = "Unable to establish an internet connection."
|
||||
BAD_DOWNLOAD = "Something went wrong"
|
||||
BAD_DOWNLOAD_MSG = "Something went wrong with the download.\nCheck your internet connection and try again."
|
||||
SLOW_CONN = "Slow internet connection"
|
||||
SLOW_CONN_MSG = "Your internet connection is unstable or too slow."
|
||||
DATA_FOLDER = auto()
|
||||
DB = auto()
|
||||
SOFTWARE = auto()
|
||||
UPDATER = auto()
|
||||
|
||||
|
||||
class Signal:
|
||||
@@ -109,7 +100,10 @@ _Band = namedtuple("Band", ["lower", "upper"])
|
||||
class Constants:
|
||||
"""Container class for several constants of the software."""
|
||||
|
||||
EXECUTABLE_NAME = os.path.join(__BASE_FOLDER__, "Artemis")
|
||||
UPDATER_SOFTWARE = os.path.join(__BASE_FOLDER__, "_ArtemisUpdater")
|
||||
CLICK_TO_UPDATE_STR = "Click to update"
|
||||
VERSION_LINK = "https://aresvalley.com/Storage/Artemis/Package/latest_versions.json"
|
||||
SIGIDWIKI = "https://www.sigidwiki.com/wiki/Signal_Identification_Guide"
|
||||
ADD_SIGNAL_LINK = "https://www.sigidwiki.com/index.php/Special:FormEdit/Signal/?preload=Signal_Identification_Wiki:Signal_form_preload_text"
|
||||
FORUM_LINK = "https://aresvalley.com/community/"
|
||||
@@ -136,10 +130,7 @@ class Constants:
|
||||
"https://amunters.home.xs4all.nl/aurorastatus.gif"]
|
||||
SEARCH_LABEL_IMG = "search_icon.png"
|
||||
VOLUME_LABEL_IMG = "volume.png"
|
||||
DATA_FOLDER = "Data"
|
||||
SPECTRA_FOLDER = "Spectra"
|
||||
SPECTRA_EXT = ".png"
|
||||
AUDIO_FOLDER = "Audio"
|
||||
ACTIVE = "active"
|
||||
INACTIVE = "inactive"
|
||||
LABEL_ON_COLOR = "on"
|
||||
@@ -184,6 +175,61 @@ class Constants:
|
||||
NOT_SELECTED = "nosignalselected.png"
|
||||
FIELD_SEPARATOR = ";"
|
||||
ACF_SEPARATOR = " - "
|
||||
DATA_FOLDER = os.path.join(__BASE_FOLDER__, "Data")
|
||||
SPECTRA_FOLDER = os.path.join(DATA_FOLDER, "Spectra")
|
||||
AUDIO_FOLDER = os.path.join(DATA_FOLDER, "Audio")
|
||||
DEFAULT_IMGS_FOLDER = os.path.join(":", "pics", "default_pics")
|
||||
DEFAULT_NOT_SELECTED = os.path.join(DEFAULT_IMGS_FOLDER, NOT_SELECTED)
|
||||
DEFAULT_NOT_AVAILABLE = os.path.join(DEFAULT_IMGS_FOLDER, NOT_AVAILABLE)
|
||||
|
||||
|
||||
class Messages:
|
||||
"""Container class for messages to be displayed."""
|
||||
|
||||
FEATURE_NOT_AVAILABLE = "Feature not available"
|
||||
SCRIPT_NOT_UPDATE = "When running from source, software updates\ncannot be checked."
|
||||
UPDATES_AVAILABALE = "Updates available"
|
||||
UPDATES_MSG = "Do you want to install the updates now?"
|
||||
UP_TO_DATE = "Already up to date"
|
||||
UP_TO_DATE_MSG = "No newer version to download."
|
||||
DB_NEW_VER = "New version available"
|
||||
DB_NEW_VER_MSG = "A new version of the database is available for download."
|
||||
NO_DB_AVAIL = "No database detected."
|
||||
NO_DB = "No database"
|
||||
DOWNLOAD_NOW_QUESTION = "Do you want to download it now?"
|
||||
DOWNLOAD_ANYWAY_QUESTION = "Do you want to download it anyway?"
|
||||
NO_CONNECTION = "No connection"
|
||||
NO_CONNECTION_MSG = "Unable to establish an internet connection."
|
||||
BAD_DOWNLOAD = "Something went wrong"
|
||||
BAD_DOWNLOAD_MSG = "Something went wrong with the download.\nCheck your internet connection and try again."
|
||||
SLOW_CONN = "Slow internet connection"
|
||||
SLOW_CONN_MSG = "Your internet connection is unstable or too slow."
|
||||
NEW_VERSION_AVAILABLE = "New software version"
|
||||
NEW_VERSION_MSG = lambda v: f"The software version {v} is available." # noqa: E731
|
||||
DOWNLOAD_SUGG_MSG = "Download new version now?"
|
||||
|
||||
|
||||
class ThemeConstants:
|
||||
"""Container class for all the theme-related constants."""
|
||||
|
||||
EXTENSION = ".qss"
|
||||
ICONS_FOLDER = "icons"
|
||||
DEFAULT = "dark"
|
||||
CURRENT = "__current_theme"
|
||||
COLORS = "colors.txt"
|
||||
COLOR_SEPARATOR = "="
|
||||
DEFAULT_ACTIVE_COLOR = "#000000"
|
||||
DEFAULT_INACTIVE_COLOR = "#9f9f9f"
|
||||
DEFAULT_OFF_COLORS = "#000000", "#434343"
|
||||
DEFAULT_ON_COLORS = "#4b79a1", "#283e51"
|
||||
DEFAULT_TEXT_COLOR = "#ffffff"
|
||||
THEME_NOT_FOUND = "Theme not found"
|
||||
MISSING_THEME = "Missing theme folder."
|
||||
MISSING_THEME_FOLDER = "Themes folder not found.\nOnly the basic theme is available."
|
||||
THEME_FOLDER_NOT_FOUND = "Themes folder not found"
|
||||
FOLDER = os.path.join(__BASE_FOLDER__, "themes")
|
||||
DEFAULT_ICONS_PATH = os.path.join(FOLDER, DEFAULT, ICONS_FOLDER)
|
||||
DEFAULT_SEARCH_LABEL_PATH = os.path.join(DEFAULT_ICONS_PATH, Constants.SEARCH_LABEL_IMG)
|
||||
DEFAULT_VOLUME_LABEL_PATH = os.path.join(DEFAULT_ICONS_PATH, Constants.VOLUME_LABEL_IMG)
|
||||
CURRENT_THEME_FILE = os.path.join(FOLDER, CURRENT)
|
||||
DEFAULT_THEME_PATH = os.path.join(FOLDER, DEFAULT)
|
||||
|
||||
@@ -7,11 +7,11 @@
|
||||
<x>0</x>
|
||||
<y>0</y>
|
||||
<width>400</width>
|
||||
<height>160</height>
|
||||
<height>185</height>
|
||||
</rect>
|
||||
</property>
|
||||
<property name="windowTitle">
|
||||
<string>Download database</string>
|
||||
<string>Downloading</string>
|
||||
</property>
|
||||
<property name="windowIcon">
|
||||
<iconset resource="default_imgs.qrc">
|
||||
@@ -29,7 +29,7 @@
|
||||
</font>
|
||||
</property>
|
||||
<property name="text">
|
||||
<string>Downloading database
|
||||
<string>Downloading updates
|
||||
Please wait...
|
||||
</string>
|
||||
</property>
|
||||
@@ -69,7 +69,7 @@ Please wait...
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QProgressBar" name="progressBar">
|
||||
<widget class="QProgressBar" name="_progress_bar">
|
||||
<property name="minimum">
|
||||
<number>0</number>
|
||||
</property>
|
||||
|
||||
@@ -2,7 +2,8 @@ from PyQt5 import uic
|
||||
from PyQt5.QtCore import Qt, pyqtSlot, pyqtSignal
|
||||
from PyQt5.QtWidgets import QWidget
|
||||
from threads import DownloadThread, ThreadStatus
|
||||
from utilities import pop_up, resource_path
|
||||
from utilities import pop_up
|
||||
from executable_utilities import resource_path
|
||||
from constants import Constants, Messages
|
||||
|
||||
|
||||
@@ -12,10 +13,12 @@ Ui_Download_window, _ = uic.loadUiType(
|
||||
|
||||
|
||||
class DownloadWindow(QWidget, Ui_Download_window):
|
||||
"""Subclass QWidget and Ui_Download_window. It is the window displayed during the database download."""
|
||||
"""Subclass QWidget and Ui_Download_window. It is the window displayed during
|
||||
downloads and software updates."""
|
||||
|
||||
complete = pyqtSignal()
|
||||
closed = pyqtSignal()
|
||||
_PROGRESS_CONEVERSION_FACTOR = 1024
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize the window."""
|
||||
@@ -47,10 +50,22 @@ class DownloadWindow(QWidget, Ui_Download_window):
|
||||
self._download_thread.speed_progress.connect(self._display_speed)
|
||||
self.closed.connect(self._download_thread.set_exit)
|
||||
self.cancel_btn.clicked.connect(self._terminate_process)
|
||||
self._size = 0
|
||||
self.target = None
|
||||
|
||||
def start_download(self):
|
||||
def _prepare_progress_bar(self, size):
|
||||
"""Prepare the progress bar for the upcoming download."""
|
||||
self._progress_bar.setMinimum(0)
|
||||
self._progress_bar.setMaximum(size)
|
||||
self._progress_bar.setValue(0)
|
||||
|
||||
def activate(self, target):
|
||||
"""Start the download thread."""
|
||||
self._download_thread.start()
|
||||
self._size = target.size
|
||||
self.target = target.target
|
||||
self._prepare_progress_bar(target.size)
|
||||
self._download_thread.start(target)
|
||||
self.show()
|
||||
|
||||
def _download_format_str(self, n):
|
||||
"""Return a well-formatted string with the downloaded MB."""
|
||||
@@ -77,6 +92,8 @@ class DownloadWindow(QWidget, Ui_Download_window):
|
||||
self.status_lbl.setText(self._download_format_str(progress))
|
||||
elif progress == Constants.EXTRACTING_CODE:
|
||||
self.status_lbl.setText(Constants.EXTRACTING_MSG)
|
||||
if self._size > 0:
|
||||
self._progress_bar.setValue(progress * self._PROGRESS_CONEVERSION_FACTOR)
|
||||
|
||||
def show(self):
|
||||
"""Extends QWidget.show. Set downloaded MB and speed to zero."""
|
||||
|
||||
164
src/downloadtargetfactory.py
Normal file
164
src/downloadtargetfactory.py
Normal file
@@ -0,0 +1,164 @@
|
||||
from contextlib import contextmanager
|
||||
from shutil import rmtree
|
||||
from os import remove
|
||||
import os.path
|
||||
import stat
|
||||
from constants import (
|
||||
Constants,
|
||||
Database,
|
||||
__BASE_FOLDER__,
|
||||
ThemeConstants,
|
||||
DownloadTarget,
|
||||
SupportedOs,
|
||||
)
|
||||
from os_utilities import get_os
|
||||
from web_utilities import get_folder_hash_code
|
||||
|
||||
from zipfile import ZipFile
|
||||
from tarfile import TarFile
|
||||
|
||||
|
||||
class _ZipExtractor:
|
||||
"""Extractor class for zip files.
|
||||
|
||||
Exposes a static method which can be used as a context manager."""
|
||||
@staticmethod
|
||||
@contextmanager
|
||||
def open(fileobj):
|
||||
zipped = ZipFile(fileobj)
|
||||
try:
|
||||
yield zipped
|
||||
finally:
|
||||
zipped.close()
|
||||
|
||||
|
||||
class _TarExtractor:
|
||||
"""Extractor class for tar files.
|
||||
|
||||
Exposes a static method which can be used as a context manager."""
|
||||
@staticmethod
|
||||
@contextmanager
|
||||
def open(fileobj):
|
||||
tarfile = TarFile.open(fileobj=fileobj)
|
||||
try:
|
||||
yield tarfile
|
||||
finally:
|
||||
tarfile.close()
|
||||
|
||||
|
||||
EXTRACTORS = {
|
||||
SupportedOs.WINDOWS: _ZipExtractor,
|
||||
SupportedOs.LINUX: _TarExtractor,
|
||||
# No extractor for MacOs, just download the file through the browser.
|
||||
}
|
||||
|
||||
|
||||
def _on_rmtree_error(func, path, excinfo):
|
||||
"""Function called whenever rmtree fails."""
|
||||
os.chmod(path, stat.S_IWRITE)
|
||||
func(path)
|
||||
|
||||
|
||||
def _delete_data_folder():
|
||||
"""Delete the Data folder."""
|
||||
if os.path.exists(Constants.DATA_FOLDER):
|
||||
rmtree(Constants.DATA_FOLDER, onerror=_on_rmtree_error)
|
||||
|
||||
|
||||
def _delete_updater():
|
||||
"""Delete the updater program."""
|
||||
if os.path.exists(Constants.UPDATER_SOFTWARE):
|
||||
remove(Constants.UPDATER_SOFTWARE)
|
||||
|
||||
|
||||
def _delete_software():
|
||||
"""Delete the main program and the themes folder."""
|
||||
if os.path.exists(Constants.EXECUTABLE_NAME):
|
||||
remove(Constants.EXECUTABLE_NAME) # Remove Artemis executable.
|
||||
if os.path.exists(ThemeConstants.FOLDER): # One could not have the theme folder for some reason.
|
||||
rmtree(ThemeConstants.FOLDER, onerror=_on_rmtree_error)
|
||||
|
||||
|
||||
class _DataFolderInfo:
|
||||
"""Simple class to implement the interface of a 'target' object for the data folder:
|
||||
|
||||
- url;
|
||||
- hash_code;
|
||||
- size."""
|
||||
def __init__(self):
|
||||
self.url = Database.LINK_LOC
|
||||
self.hash_code = get_folder_hash_code()
|
||||
self.size = 0
|
||||
|
||||
|
||||
class _BaseDownloadTarget:
|
||||
"""Base class for the '_Download*Target' objects.
|
||||
|
||||
Contains all the attributes needed by DownloadWindow and DownloadThread
|
||||
to do the job."""
|
||||
def __init__(self, target, dest_path, target_enum, Extractor, delete_files):
|
||||
self.url = target.url
|
||||
self.hash_code = target.hash_code
|
||||
self.size = target.size
|
||||
self.dest_path = dest_path
|
||||
self.target = target_enum
|
||||
self.Extractor = Extractor
|
||||
self.delete_files = delete_files
|
||||
|
||||
|
||||
class _DownloadDataFolderTarget(_BaseDownloadTarget):
|
||||
"""Extend _BaseDownloadTarget. Represent the data folder."""
|
||||
def __init__(self, data_folder_info, dest_path=__BASE_FOLDER__):
|
||||
super().__init__(
|
||||
target=data_folder_info,
|
||||
dest_path=dest_path,
|
||||
target_enum=DownloadTarget.DATA_FOLDER,
|
||||
Extractor=_ZipExtractor,
|
||||
delete_files=_delete_data_folder
|
||||
)
|
||||
|
||||
|
||||
class _DownloadSoftwareTarget(_BaseDownloadTarget):
|
||||
"""Extends _BaseDownloadTarget. Represents the main software."""
|
||||
def __init__(self, software, dest_path=__BASE_FOLDER__):
|
||||
super().__init__(
|
||||
target=software,
|
||||
dest_path=dest_path,
|
||||
target_enum=DownloadTarget.SOFTWARE,
|
||||
Extractor=EXTRACTORS[get_os()],
|
||||
delete_files=_delete_software
|
||||
)
|
||||
|
||||
|
||||
class _DownloadUpdaterTarget(_BaseDownloadTarget):
|
||||
"""Extends _BaseDownloadTarget. Represents the updater software."""
|
||||
def __init__(self, updater, dest_path=__BASE_FOLDER__):
|
||||
super().__init__(
|
||||
target=updater,
|
||||
dest_path=dest_path,
|
||||
target_enum=DownloadTarget.UPDATER,
|
||||
Extractor=EXTRACTORS[get_os()],
|
||||
delete_files=_delete_updater
|
||||
)
|
||||
|
||||
|
||||
def get_download_target(target_type, target=None):
|
||||
"""Return a Download*Obj based on the target download.
|
||||
|
||||
These objects expose a common interface:
|
||||
Attributes:
|
||||
- url;
|
||||
- hash_code;
|
||||
- dest_path;
|
||||
- target: an element of the enum DownloadTarget;
|
||||
- Extractor: an object which exposes an 'open(fileobj)' method
|
||||
to extract compressed files;
|
||||
- delete_files: a function to remove the old files."""
|
||||
if target_type is DownloadTarget.DATA_FOLDER:
|
||||
return _DownloadDataFolderTarget(_DataFolderInfo())
|
||||
elif target_type is DownloadTarget.UPDATER and target is not None:
|
||||
return _DownloadUpdaterTarget(target)
|
||||
elif target_type is DownloadTarget.SOFTWARE and target is not None:
|
||||
return _DownloadSoftwareTarget(target)
|
||||
else:
|
||||
raise Exception("ERROR: Invalid download target!")
|
||||
33
src/executable_utilities.py
Normal file
33
src/executable_utilities.py
Normal file
@@ -0,0 +1,33 @@
|
||||
import sys
|
||||
from shutil import which
|
||||
import os
|
||||
import os.path
|
||||
|
||||
|
||||
def _is_executable_version():
|
||||
"""Return whether the binary version is running."""
|
||||
return hasattr(sys, "_MEIPASS")
|
||||
|
||||
|
||||
IS_BINARY = _is_executable_version()
|
||||
|
||||
|
||||
def get_executable_path():
|
||||
"""Check whether the executable is in the PATH folder.
|
||||
|
||||
Return the full path or just an ampty string if it is not found
|
||||
in the PATH folder."""
|
||||
path = which("Artemis")
|
||||
if path is not None:
|
||||
return os.path.dirname(path)
|
||||
else: # Assume that the executable is in the cwd.
|
||||
return os.curdir
|
||||
|
||||
|
||||
def resource_path(relative_path):
|
||||
"""Get absolute path to resource, works for dev and for PyInstaller."""
|
||||
try:
|
||||
base_path = sys._MEIPASS
|
||||
except Exception:
|
||||
base_path = os.path.abspath(".")
|
||||
return os.path.join(base_path, relative_path)
|
||||
@@ -7,21 +7,23 @@ The only class exposed is Filters which provides the following methods:
|
||||
|
||||
from functools import partial
|
||||
import webbrowser
|
||||
|
||||
from PyQt5.QtWidgets import QListWidgetItem, QTreeWidgetItem
|
||||
from PyQt5.QtCore import pyqtSlot, QObject
|
||||
|
||||
from constants import (Constants,
|
||||
Ftype,
|
||||
Signal,)
|
||||
from utilities import (uncheck_and_emit,
|
||||
connect_events_to_func,
|
||||
filters_limit,
|
||||
is_undef_freq,
|
||||
is_undef_band,
|
||||
safe_cast,
|
||||
show_matching_strings,
|
||||
get_field_entries,)
|
||||
from constants import (
|
||||
Constants,
|
||||
Ftype,
|
||||
Signal,
|
||||
)
|
||||
from utilities import (
|
||||
uncheck_and_emit,
|
||||
connect_events_to_func,
|
||||
filters_limit,
|
||||
is_undef_freq,
|
||||
is_undef_band,
|
||||
safe_cast,
|
||||
show_matching_strings,
|
||||
get_field_entries,
|
||||
)
|
||||
|
||||
|
||||
class _BaseFilter(QObject):
|
||||
|
||||
34
src/os_utilities.py
Normal file
34
src/os_utilities.py
Normal file
@@ -0,0 +1,34 @@
|
||||
import sys
|
||||
from constants import SupportedOs
|
||||
|
||||
|
||||
def _is_mac_os():
|
||||
"""Return True if running OS is Mac."""
|
||||
return sys.platform == 'darwin'
|
||||
|
||||
|
||||
def _is_win_os():
|
||||
"""Return True if running OS is Windows."""
|
||||
return sys.platform == 'win32'
|
||||
|
||||
|
||||
def _is_linux_os():
|
||||
"""Return True if running OS is Linux."""
|
||||
return sys.platform == 'linux'
|
||||
|
||||
|
||||
IS_MAC = _is_mac_os()
|
||||
IS_LINUX = _is_linux_os()
|
||||
IS_WINDOWS = _is_win_os()
|
||||
|
||||
|
||||
def get_os():
|
||||
"""Get the name of the current running operating system."""
|
||||
if IS_WINDOWS:
|
||||
return SupportedOs.WINDOWS
|
||||
elif IS_LINUX:
|
||||
return SupportedOs.LINUX
|
||||
elif IS_MAC:
|
||||
return SupportedOs.MAC
|
||||
else:
|
||||
raise Exception("ERROR: OS not recognized.")
|
||||
@@ -404,4 +404,4 @@ QScrollBar::add-line:horizontal, QScrollBar::sub-line:horizontal,
|
||||
QScrollBar::add-line:vertical, QScrollBar::sub-line:vertical {
|
||||
border: none;
|
||||
background: none;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,36 +4,10 @@ import re
|
||||
from PyQt5.QtWidgets import QAction, QActionGroup
|
||||
from PyQt5.QtCore import pyqtSlot
|
||||
from PyQt5.QtGui import QPixmap
|
||||
from constants import Constants
|
||||
from constants import Constants, ThemeConstants
|
||||
from utilities import pop_up
|
||||
|
||||
|
||||
class ThemeConstants:
|
||||
"""Container class for all the theme-related constants."""
|
||||
|
||||
FOLDER = "themes"
|
||||
EXTENSION = ".qss"
|
||||
ICONS_FOLDER = "icons"
|
||||
DEFAULT = "dark"
|
||||
CURRENT = ".current_theme"
|
||||
COLORS = "colors.txt"
|
||||
COLOR_SEPARATOR = "="
|
||||
DEFAULT_ACTIVE_COLOR = "#000000"
|
||||
DEFAULT_INACTIVE_COLOR = "#9f9f9f"
|
||||
DEFAULT_OFF_COLORS = "#000000", "#434343"
|
||||
DEFAULT_ON_COLORS = "#4b79a1", "#283e51"
|
||||
DEFAULT_TEXT_COLOR = "#ffffff"
|
||||
THEME_NOT_FOUND = "Theme not found"
|
||||
MISSING_THEME = "Missing theme in '" + FOLDER + "' folder."
|
||||
MISSING_THEME_FOLDER = "'" + FOLDER + "'" + " folder not found.\nOnly the basic theme is available."
|
||||
THEME_FOLDER_NOT_FOUND = "'" + FOLDER + "'" + " folder not found"
|
||||
DEFAULT_ICONS_PATH = os.path.join(FOLDER, DEFAULT, ICONS_FOLDER)
|
||||
DEFAULT_SEARCH_LABEL_PATH = os.path.join(DEFAULT_ICONS_PATH, Constants.SEARCH_LABEL_IMG)
|
||||
DEFAULT_VOLUME_LABEL_PATH = os.path.join(DEFAULT_ICONS_PATH, Constants.VOLUME_LABEL_IMG)
|
||||
CURRENT_THEME_FILE = os.path.join(FOLDER, CURRENT)
|
||||
DEFAULT_THEME_PATH = os.path.join(FOLDER, DEFAULT)
|
||||
|
||||
|
||||
class _ColorsHandler:
|
||||
"""Manage the theme's secondary colors.
|
||||
|
||||
@@ -125,7 +99,6 @@ class ThemeManager:
|
||||
self._owner.spaceweather_screen.refreshable_labels.set(
|
||||
"switch_off_colors", ThemeConstants.DEFAULT_OFF_COLORS
|
||||
)
|
||||
|
||||
self._theme_names = {}
|
||||
|
||||
@pyqtSlot()
|
||||
@@ -179,7 +152,8 @@ class ThemeManager:
|
||||
new_theme = ag.addAction(
|
||||
QAction(
|
||||
theme_name,
|
||||
self._owner, checkable=True
|
||||
self._owner,
|
||||
checkable=True
|
||||
)
|
||||
)
|
||||
self._owner.menu_themes.addAction(new_theme)
|
||||
@@ -295,31 +269,37 @@ class ThemeManager:
|
||||
|
||||
try:
|
||||
with open(ThemeConstants.CURRENT_THEME_FILE, "w") as current_theme:
|
||||
current_theme.write(self._theme_path)
|
||||
current_theme.write(os.path.basename(self._theme_path))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def apply_default_theme(self):
|
||||
"""Apply the default theme if no theme is set or the theme name is invalid."""
|
||||
try:
|
||||
self._theme_names[
|
||||
self._pretty_name(ThemeConstants.DEFAULT)
|
||||
].setChecked(True)
|
||||
except Exception:
|
||||
pop_up(
|
||||
self._owner,
|
||||
title=ThemeConstants.THEME_NOT_FOUND,
|
||||
text=ThemeConstants.MISSING_THEME
|
||||
).show()
|
||||
else:
|
||||
self._apply(ThemeConstants.DEFAULT_THEME_PATH)
|
||||
|
||||
def start(self):
|
||||
"""Start the theme manager."""
|
||||
self._detect_themes()
|
||||
if os.path.exists(ThemeConstants.CURRENT_THEME_FILE):
|
||||
with open(ThemeConstants.CURRENT_THEME_FILE, "r") as current_theme_path:
|
||||
theme_path = current_theme_path.read()
|
||||
with open(ThemeConstants.CURRENT_THEME_FILE, "r") as current_theme_name:
|
||||
theme_path = os.path.join(ThemeConstants.FOLDER, current_theme_name.read())
|
||||
theme_name = self._pretty_name(os.path.basename(theme_path))
|
||||
try:
|
||||
self._theme_names[theme_name].setChecked(True)
|
||||
except Exception:
|
||||
pop_up(self._owner, title=ThemeConstants.THEME_NOT_FOUND,
|
||||
text=ThemeConstants.MISSING_THEME).show()
|
||||
self.apply_default_theme()
|
||||
else:
|
||||
self._apply(theme_path)
|
||||
else:
|
||||
try:
|
||||
self._theme_names[
|
||||
self._pretty_name(ThemeConstants.DEFAULT)
|
||||
].setChecked(True)
|
||||
except Exception:
|
||||
pop_up(self._owner, title=ThemeConstants.THEME_NOT_FOUND,
|
||||
text=ThemeConstants.MISSING_THEME).show()
|
||||
else:
|
||||
self._apply(ThemeConstants.DEFAULT_THEME_PATH)
|
||||
self.apply_default_theme()
|
||||
|
||||
190
src/threads.py
190
src/threads.py
@@ -2,18 +2,18 @@ import asyncio
|
||||
from enum import Enum, auto
|
||||
from io import BytesIO
|
||||
from math import ceil
|
||||
import os.path
|
||||
from shutil import rmtree
|
||||
from time import perf_counter
|
||||
from zipfile import ZipFile
|
||||
import aiohttp
|
||||
from PyQt5.QtCore import QThread, pyqtSignal
|
||||
from constants import Constants, Database, ChecksumWhat
|
||||
from utilities import checksum_ok, get_pool_manager, get_cacert_file
|
||||
import ssl
|
||||
|
||||
from time import perf_counter
|
||||
import aiohttp
|
||||
from PyQt5.QtCore import QThread, pyqtSignal, pyqtSlot
|
||||
from constants import Constants
|
||||
from utilities import checksum_ok
|
||||
from web_utilities import (
|
||||
get_cacert_file,
|
||||
get_pool_manager,
|
||||
)
|
||||
# Needed for pyinstaller compilation.
|
||||
import encodings.idna
|
||||
import encodings.idna # noqa: 401
|
||||
|
||||
|
||||
class ThreadStatus(Enum):
|
||||
@@ -39,30 +39,31 @@ class BaseDownloadThread(QThread):
|
||||
super().__init__(parent)
|
||||
self.status = ThreadStatus.UNDEFINED
|
||||
|
||||
def __del__(self):
|
||||
"""Force the termination of the thread."""
|
||||
self.terminate()
|
||||
self.wait()
|
||||
# def __del__(self):
|
||||
# """Force the termination of the thread."""
|
||||
# self.terminate()
|
||||
# self.wait()
|
||||
|
||||
|
||||
class DownloadThread(BaseDownloadThread):
|
||||
"""Subclass BaseDownloadThread. Download the database, images and audio samples."""
|
||||
"""Subclass BaseDownloadThread. Download the database folder. Used also for software updates."""
|
||||
|
||||
progress = pyqtSignal(int)
|
||||
speed_progress = pyqtSignal(float)
|
||||
_CHUNK = 128 * 1024
|
||||
_MEGA = 1024**2
|
||||
_DELTAT = 2
|
||||
|
||||
def __init__(self):
|
||||
def __init__(self, min_bytes=1024**2):
|
||||
"""Just call super().__init__."""
|
||||
self._db = None
|
||||
self._exit_call = False
|
||||
super().__init__()
|
||||
self._min_bytes = min_bytes
|
||||
self._data = None
|
||||
self._exit_call = False
|
||||
self._target = None
|
||||
|
||||
def _pretty_len(self, byte_obj):
|
||||
"""Return a well-formatted number of downloaded MB."""
|
||||
mega = len(byte_obj) / self._MEGA
|
||||
mega = len(byte_obj) / self._min_bytes
|
||||
if mega.is_integer():
|
||||
return int(mega)
|
||||
else:
|
||||
@@ -71,87 +72,144 @@ class DownloadThread(BaseDownloadThread):
|
||||
def _get_download_speed(self, data, delta):
|
||||
"""Return the download speed in MB/s."""
|
||||
return round(
|
||||
(len(data) / self._MEGA) / delta, 2
|
||||
(len(data) / self._min_bytes) / delta, 2
|
||||
)
|
||||
|
||||
@pyqtSlot()
|
||||
def set_exit(self):
|
||||
"""Time to shutdown the thread.
|
||||
|
||||
Executed in the main thread."""
|
||||
self._exit_call = True
|
||||
|
||||
def start(self, target):
|
||||
"""Start the thread. Set the correct download options first."""
|
||||
self._target = target
|
||||
super().start()
|
||||
|
||||
def _download_loop(self):
|
||||
"""Read a chunck of the downloaded data at every iteration."""
|
||||
raw_data = bytes(0)
|
||||
sub_data = bytes(0)
|
||||
start = perf_counter()
|
||||
prev_downloaded = 0
|
||||
while True:
|
||||
try:
|
||||
data = self._data.read(self._CHUNK)
|
||||
except Exception:
|
||||
raise _SlowConnError
|
||||
else:
|
||||
delta = perf_counter() - start
|
||||
if not data:
|
||||
break
|
||||
raw_data += data
|
||||
sub_data += data
|
||||
# Emit a progress signal only if at least self._min_bytes has been downloaded.
|
||||
if len(raw_data) - prev_downloaded >= self._min_bytes:
|
||||
prev_downloaded = len(raw_data)
|
||||
self.progress.emit(self._pretty_len(raw_data))
|
||||
if delta >= self._DELTAT:
|
||||
self.speed_progress.emit(
|
||||
self._get_download_speed(sub_data, delta)
|
||||
)
|
||||
sub_data = bytes(0)
|
||||
start = perf_counter()
|
||||
if self._exit_call:
|
||||
self._data.release_conn()
|
||||
break
|
||||
return raw_data
|
||||
|
||||
def run(self):
|
||||
"""Override QThread.run. Download the database, images and audio samples.
|
||||
|
||||
Handle all possible exceptions. Also extract the files
|
||||
in the local folder."""
|
||||
in the destination folder."""
|
||||
self.status = ThreadStatus.UNDEFINED
|
||||
self._db = None
|
||||
raw_data = bytes(0)
|
||||
sub_data = bytes(0)
|
||||
self._data = None
|
||||
|
||||
try:
|
||||
self._db = get_pool_manager().request(
|
||||
self._data = get_pool_manager().request(
|
||||
'GET',
|
||||
Database.LINK_LOC,
|
||||
self._target.url,
|
||||
preload_content=False,
|
||||
timeout=4.0
|
||||
)
|
||||
start = perf_counter()
|
||||
prev_downloaded = 0
|
||||
while True:
|
||||
try:
|
||||
data = self._db.read(self._CHUNK)
|
||||
except Exception:
|
||||
raise _SlowConnError
|
||||
else:
|
||||
delta = perf_counter() - start
|
||||
if not data:
|
||||
break
|
||||
raw_data += data
|
||||
sub_data += data
|
||||
# Emit a progress signal only if at least 1 MB has been downloaded.
|
||||
if len(raw_data) - prev_downloaded >= self._MEGA:
|
||||
prev_downloaded = len(raw_data)
|
||||
self.progress.emit(self._pretty_len(raw_data))
|
||||
if delta >= self._DELTAT:
|
||||
self.speed_progress.emit(
|
||||
self._get_download_speed(sub_data, delta)
|
||||
)
|
||||
sub_data = bytes(0)
|
||||
start = perf_counter()
|
||||
if self._exit_call:
|
||||
self._exit_call = False
|
||||
self._db.release_conn()
|
||||
return
|
||||
raw_data = self._download_loop()
|
||||
if self._exit_call:
|
||||
self._exit_call = False
|
||||
return
|
||||
except Exception as e: # No (or bad) internet connection.
|
||||
self._db.release_conn()
|
||||
self._data.release_conn()
|
||||
if isinstance(e, _SlowConnError):
|
||||
self.status = ThreadStatus.SLOW_CONN_ERR
|
||||
else:
|
||||
self.status = ThreadStatus.NO_CONNECTION_ERR
|
||||
return
|
||||
if self._db.status != 200:
|
||||
if self._data.status != 200:
|
||||
self.status = ThreadStatus.BAD_DOWNLOAD_ERR
|
||||
return
|
||||
try:
|
||||
is_checksum_ok = checksum_ok(raw_data, ChecksumWhat.FOLDER)
|
||||
except Exception: # checksum_ok unable to connect to the reference.
|
||||
self.status = ThreadStatus.NO_CONNECTION_ERR
|
||||
if self._wrong_checksum(raw_data):
|
||||
return
|
||||
self._target.delete_files()
|
||||
self._extract(raw_data)
|
||||
|
||||
def _wrong_checksum(self, raw_data):
|
||||
"""Verify the checksum of the downloaded data and set the status accordingly."""
|
||||
try:
|
||||
is_checksum_ok = checksum_ok(raw_data, self._target.hash_code)
|
||||
except Exception: # Invalid hash code.
|
||||
self.status = ThreadStatus.NO_CONNECTION_ERR
|
||||
return True
|
||||
else:
|
||||
if not is_checksum_ok:
|
||||
self.status = ThreadStatus.BAD_DOWNLOAD_ERR
|
||||
return
|
||||
if os.path.exists(Constants.DATA_FOLDER):
|
||||
rmtree(Constants.DATA_FOLDER)
|
||||
return True
|
||||
return False
|
||||
|
||||
def _extract(self, raw_data):
|
||||
"""Unzip and save the downloaded data into the destination folder."""
|
||||
try:
|
||||
self.progress.emit(Constants.EXTRACTING_CODE)
|
||||
self.speed_progress.emit(Constants.ZERO_FINAL_SPEED)
|
||||
with ZipFile(BytesIO(raw_data)) as zipped:
|
||||
zipped.extractall()
|
||||
with self._target.Extractor.open(fileobj=BytesIO(raw_data)) as zipped:
|
||||
zipped.extractall(path=self._target.dest_path)
|
||||
except Exception:
|
||||
self.status = ThreadStatus.UNKNOWN_ERR
|
||||
else:
|
||||
self.status = ThreadStatus.OK
|
||||
|
||||
|
||||
class UpdatesControllerThread(BaseDownloadThread):
|
||||
|
||||
on_success = pyqtSignal(bool)
|
||||
|
||||
def __init__(self, version_controller):
|
||||
super().__init__()
|
||||
self.version_controller = version_controller
|
||||
|
||||
def run(self):
|
||||
if self.version_controller.update():
|
||||
self.on_success.emit(True)
|
||||
else:
|
||||
self.on_success.emit(False)
|
||||
|
||||
|
||||
# class GenercWorkerThread(BaseDownloadThread):
|
||||
# def __init__(self, func, *args, **kwargs):
|
||||
# super().__init__()
|
||||
# self._args = args
|
||||
# self._kwargs = kwargs
|
||||
# self._func
|
||||
|
||||
# def run(self):
|
||||
# self.status = ThreadStatus.UNDEFINED
|
||||
# try:
|
||||
# self._func(self._args, self._kwargs)
|
||||
# except Exception:
|
||||
# self.status = ThreadStatus.UNKNOWN_ERR
|
||||
# else:
|
||||
# self.status = ThreadStatus.OK
|
||||
|
||||
|
||||
class _AsyncDownloader:
|
||||
"""Mixin class for asynchronous threads."""
|
||||
|
||||
|
||||
200
src/updater.py
Normal file
200
src/updater.py
Normal file
@@ -0,0 +1,200 @@
|
||||
import argparse
|
||||
import os
|
||||
import os.path
|
||||
import sys
|
||||
from PyQt5.QtCore import QObject, QProcess
|
||||
from PyQt5.QtGui import QPixmap
|
||||
from PyQt5.QtWidgets import QApplication, qApp
|
||||
from download_window import DownloadWindow
|
||||
from constants import Constants, DownloadTarget
|
||||
from downloadtargetfactory import get_download_target
|
||||
|
||||
|
||||
__VERSION__ = "0.0.1"
|
||||
|
||||
|
||||
# Global stylesheet.
|
||||
stylesheet = """
|
||||
/*************************************
|
||||
Main Window and Splitters
|
||||
**************************************/
|
||||
QWidget:window {
|
||||
background-color: #29353B;
|
||||
}
|
||||
|
||||
/*************************************
|
||||
Main menu (Bar)
|
||||
**************************************/
|
||||
QMenuBar {
|
||||
background-color: transparent;
|
||||
color: #AFBDC4;
|
||||
}
|
||||
|
||||
QMenuBar::item {
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
QMenuBar::item:disabled {
|
||||
color: gray;
|
||||
}
|
||||
|
||||
QMenuBar::item:selected {
|
||||
color: #FFFFFF;
|
||||
border-bottom: 2px solid #88cc00;
|
||||
}
|
||||
|
||||
QMenuBar::item:pressed {
|
||||
color: #FFFFFF;
|
||||
border-bottom: 2px solid #88cc00;
|
||||
}
|
||||
|
||||
QToolBar {
|
||||
background-color: transparent;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
|
||||
QToolBar:handle {
|
||||
background-color: transparent;
|
||||
border-left: 2px dotted #80CBC4;
|
||||
color: transparent;
|
||||
}
|
||||
|
||||
QToolBar::separator {
|
||||
border: 0;
|
||||
}
|
||||
|
||||
QMenu {
|
||||
background-color: #263238;
|
||||
color: #AFBDC4;
|
||||
}
|
||||
|
||||
QMenu::item:selected {
|
||||
color: #FFFFFF;
|
||||
}
|
||||
|
||||
QMenu::item:pressed {
|
||||
color: #FFFFFF;
|
||||
}
|
||||
|
||||
QMenu::separator {
|
||||
background-color: transparent;
|
||||
height: 1px;
|
||||
margin-left: 10px;
|
||||
margin-right: 10px;
|
||||
margin-top: 5px;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
|
||||
/*************************************
|
||||
Progressbar
|
||||
**************************************/
|
||||
QProgressBar
|
||||
{
|
||||
border: 2px solid grey;
|
||||
border-radius: 5px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
QProgressBar::chunk
|
||||
{
|
||||
background-color: #88cc00;
|
||||
width: 2.15px;
|
||||
margin: 0.5px;
|
||||
}
|
||||
|
||||
/*************************************
|
||||
Labels and Rich Text boxes
|
||||
**************************************/
|
||||
QLabel {
|
||||
background-color: transparent;
|
||||
color: #CFD8DC;
|
||||
}
|
||||
|
||||
QDialog {
|
||||
background-color: transparent;
|
||||
color: #949a9c;
|
||||
}
|
||||
|
||||
QTextBrowser {
|
||||
background-color: transparent;
|
||||
color: #949a9c;
|
||||
}
|
||||
|
||||
/*************************************
|
||||
Buttons
|
||||
**************************************/
|
||||
QPushButton {
|
||||
background-color: transparent;
|
||||
color: #AFBDC4;
|
||||
border: 1px solid transparent;
|
||||
padding: 4px 22px;
|
||||
}
|
||||
|
||||
QPushButton:hover {
|
||||
border-left: 2px solid #88cc00;
|
||||
border-right: 2px solid #88cc00;
|
||||
color: #FFFFFF;
|
||||
}
|
||||
|
||||
QPushButton:pressed {
|
||||
color: #FFFFFF;
|
||||
}
|
||||
|
||||
QPushButton:disabled {
|
||||
color:#546E7A;
|
||||
}
|
||||
|
||||
QPushButton:checked {
|
||||
color: #88cc00;
|
||||
}
|
||||
"""
|
||||
|
||||
|
||||
class _ArtemisUpdater(QObject):
|
||||
"""Updater of the main software."""
|
||||
|
||||
def __init__(self, target):
|
||||
super().__init__()
|
||||
self.target = get_download_target(DownloadTarget.SOFTWARE, target)
|
||||
self.download_window = DownloadWindow()
|
||||
self.download_window.setStyleSheet(stylesheet)
|
||||
self.download_window.cancel_btn.clicked.connect(qApp.quit)
|
||||
self.download_window.complete.connect(self.start_main_program)
|
||||
|
||||
def start(self):
|
||||
"""Close the main program and start the download."""
|
||||
self.download_window.activate(self.target)
|
||||
|
||||
def init_ok(self):
|
||||
return self.target.url and self.target.hash_code and self.target.size > 0
|
||||
|
||||
def start_main_program(self):
|
||||
"""Restart the (updated) main program and close the updater."""
|
||||
self.download_window.setVisible(False)
|
||||
artemis = QProcess()
|
||||
try:
|
||||
artemis.startDetached(Constants.EXECUTABLE_NAME)
|
||||
except BaseException:
|
||||
pass
|
||||
qApp.quit()
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
parser = argparse.ArgumentParser(prog='Artemis Updater')
|
||||
parser.add_argument("url", nargs="?", default="", type=str, help="Download url")
|
||||
parser.add_argument("hash_code", nargs="?", default="", type=str, help="sha256 of the file")
|
||||
parser.add_argument("size", nargs="?", default=0, type=int, help="Size (KB) of the file")
|
||||
parser.add_argument('--version', action='version', version=__VERSION__)
|
||||
args = parser.parse_args()
|
||||
|
||||
my_app = QApplication(sys.argv)
|
||||
ARTEMIS_ICON = os.path.join(":", "icon", "default_pics", "Artemis3.500px.png")
|
||||
img = QPixmap(ARTEMIS_ICON)
|
||||
updater = _ArtemisUpdater(args)
|
||||
|
||||
if not updater.init_ok():
|
||||
updater.start_main_program()
|
||||
else:
|
||||
updater.start()
|
||||
sys.exit(my_app.exec_())
|
||||
141
src/updatescontroller.py
Normal file
141
src/updatescontroller.py
Normal file
@@ -0,0 +1,141 @@
|
||||
import subprocess as sp
|
||||
import webbrowser
|
||||
from PyQt5.QtCore import QObject, pyqtSlot, QProcess
|
||||
from PyQt5.QtWidgets import QMessageBox, qApp
|
||||
from constants import Constants, Messages, DownloadTarget
|
||||
from downloadtargetfactory import get_download_target
|
||||
from utilities import pop_up
|
||||
from os_utilities import IS_MAC
|
||||
from executable_utilities import IS_BINARY
|
||||
from threads import UpdatesControllerThread
|
||||
from versioncontroller import VersionController
|
||||
|
||||
|
||||
class UpdatesController(QObject):
|
||||
|
||||
def __init__(self, current_version, owner):
|
||||
super().__init__()
|
||||
self._owner = owner
|
||||
self._download_window = self._owner.download_window
|
||||
self._current_version = current_version
|
||||
self.version_controller = VersionController()
|
||||
self._updates_thread = UpdatesControllerThread(self.version_controller)
|
||||
self._updates_thread.on_success.connect(self._startup_updates_check)
|
||||
|
||||
def start(self):
|
||||
"""Start the thread."""
|
||||
if IS_BINARY:
|
||||
self._updates_thread.start()
|
||||
|
||||
@pyqtSlot()
|
||||
def start_verify_software_version(self):
|
||||
if not IS_BINARY:
|
||||
pop_up(
|
||||
self._owner,
|
||||
title=Messages.FEATURE_NOT_AVAILABLE,
|
||||
text=Messages.SCRIPT_NOT_UPDATE
|
||||
).show()
|
||||
return
|
||||
if not self._download_window.isVisible():
|
||||
self._updates_thread.start()
|
||||
|
||||
@pyqtSlot(bool)
|
||||
def _verify_software_version(self, success):
|
||||
"""Verify if there is a new software version.
|
||||
|
||||
Otherwise notify the user that the software is up to date."""
|
||||
if not self._download_window.isVisible():
|
||||
if success:
|
||||
new_version_found = self._check_new_version()
|
||||
if not new_version_found:
|
||||
pop_up(
|
||||
self._owner,
|
||||
title=Messages.UP_TO_DATE,
|
||||
text=Messages.UP_TO_DATE_MSG
|
||||
).show()
|
||||
else:
|
||||
pop_up(
|
||||
self._owner,
|
||||
title=Messages.NO_CONNECTION,
|
||||
text=Messages.NO_CONNECTION_MSG
|
||||
).show()
|
||||
|
||||
@pyqtSlot(bool)
|
||||
def _startup_updates_check(self, success):
|
||||
self._updates_thread.on_success.disconnect()
|
||||
self._updates_thread.on_success.connect(self._verify_software_version)
|
||||
if success:
|
||||
if not self._check_new_version():
|
||||
# Check for a new version of the updater only if Artemis is up to date.
|
||||
self._check_updater_version()
|
||||
|
||||
def _check_new_version(self):
|
||||
"""Check whether there is a new software version available.
|
||||
|
||||
Does something only if the running program is a compiled version."""
|
||||
if not IS_BINARY:
|
||||
return None
|
||||
latest_version = self.version_controller.software.version
|
||||
if latest_version is None:
|
||||
return False
|
||||
if latest_version == self._current_version:
|
||||
return False
|
||||
answer = pop_up(
|
||||
self._owner,
|
||||
title=Messages.NEW_VERSION_AVAILABLE,
|
||||
text=Messages.NEW_VERSION_MSG(latest_version),
|
||||
informative_text=Messages.DOWNLOAD_SUGG_MSG,
|
||||
is_question=True,
|
||||
).exec()
|
||||
if answer == QMessageBox.Yes:
|
||||
if IS_MAC:
|
||||
webbrowser.open(self.version_controller.software.url)
|
||||
else:
|
||||
updater = QProcess()
|
||||
command = Constants.UPDATER_SOFTWARE + " " + \
|
||||
self.version_controller.software.url + \
|
||||
" " + self.version_controller.software.hash_code + \
|
||||
" " + str(self.version_controller.software.size)
|
||||
try:
|
||||
updater.startDetached(command)
|
||||
except BaseException:
|
||||
pass
|
||||
else:
|
||||
qApp.quit()
|
||||
return True
|
||||
|
||||
def _check_updater_version(self):
|
||||
"""Check is a new version of the updater is available.
|
||||
|
||||
If so, ask to download the new version.
|
||||
If the software is not a compiled version, the function is a NOP."""
|
||||
if not IS_BINARY or IS_MAC:
|
||||
return
|
||||
latest_updater_version = self.version_controller.updater.version
|
||||
try:
|
||||
with sp.Popen(
|
||||
[Constants.UPDATER_SOFTWARE, "--version"],
|
||||
encoding="UTF-8",
|
||||
stdout=sp.PIPE,
|
||||
stderr=sp.STDOUT,
|
||||
stdin=sp.DEVNULL # Needed to avoid OsError: [WinError 6] The handle is invalid.
|
||||
) as proc:
|
||||
updater_version = proc.stdout.read().rstrip("\r\n") # Strip any possible newline, to be sure.
|
||||
except Exception:
|
||||
updater_version = latest_updater_version
|
||||
if latest_updater_version is None:
|
||||
return
|
||||
if updater_version != latest_updater_version:
|
||||
answer = pop_up(
|
||||
self._owner,
|
||||
title=Messages.UPDATES_AVAILABALE,
|
||||
text=Messages.UPDATES_MSG,
|
||||
is_question=True,
|
||||
).exec()
|
||||
if answer == QMessageBox.Yes:
|
||||
self._download_window.activate(
|
||||
get_download_target(
|
||||
DownloadTarget.UPDATER,
|
||||
self.version_controller.updater
|
||||
)
|
||||
)
|
||||
@@ -1,19 +1,29 @@
|
||||
from functools import partial
|
||||
import hashlib
|
||||
import sys
|
||||
import os
|
||||
from PyQt5.QtWidgets import QMessageBox
|
||||
import urllib3
|
||||
from constants import Constants, Signal, Database, ChecksumWhat
|
||||
from constants import Constants, Signal
|
||||
|
||||
|
||||
def resource_path(relative_path):
|
||||
"""Get absolute path to resource, works for dev and for PyInstaller."""
|
||||
try:
|
||||
base_path = sys._MEIPASS
|
||||
except Exception:
|
||||
base_path = os.path.abspath(".")
|
||||
return os.path.join(base_path, relative_path)
|
||||
class UniqueMessageBox(QMessageBox):
|
||||
"""Subclass of QMessageBox. Overrides only the exec method.
|
||||
|
||||
Only one instance of this class can execute super().exec() exec at a given time.
|
||||
If another instance is the the exec loop, calling exec simply return None."""
|
||||
|
||||
_open_message = False
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
def exec(self):
|
||||
"""Overrides QMessageBox.exec. Call the parent method if there are no
|
||||
other instances executing exec. Otherwise return None,"""
|
||||
if UniqueMessageBox._open_message:
|
||||
return None
|
||||
UniqueMessageBox._open_message = True
|
||||
answer = super().exec()
|
||||
UniqueMessageBox._open_message = False
|
||||
return answer
|
||||
|
||||
|
||||
def uncheck_and_emit(button):
|
||||
@@ -46,7 +56,7 @@ def get_field_entries(db_entry, separator=Constants.FIELD_SEPARATOR):
|
||||
]
|
||||
|
||||
|
||||
def pop_up(cls, title, text,
|
||||
def pop_up(instance, title, text,
|
||||
informative_text=None,
|
||||
connection=None,
|
||||
is_question=False,
|
||||
@@ -58,7 +68,7 @@ def pop_up(cls, title, text,
|
||||
connection -- a callable to connect the message when emitting the finished signal.
|
||||
is_question -- whether the message contains a question.
|
||||
default_btn -- the default button for the possible answer to the question."""
|
||||
msg = QMessageBox(cls)
|
||||
msg = UniqueMessageBox(instance)
|
||||
msg.setWindowTitle(title)
|
||||
msg.setText(text)
|
||||
if informative_text:
|
||||
@@ -72,46 +82,15 @@ def pop_up(cls, title, text,
|
||||
return msg
|
||||
|
||||
|
||||
def is_mac_os():
|
||||
"""Return True if running OS is Mac."""
|
||||
return sys.platform == 'darwin'
|
||||
def checksum_ok(data, reference_hash_code):
|
||||
"""Check whether the checksum of the 'data' argument is correct.
|
||||
|
||||
|
||||
def get_cacert_file():
|
||||
"""Return the path to the cacert.pem file."""
|
||||
if hasattr(sys, "_MEIPASS"):
|
||||
ca_certs = os.path.join(sys._MEIPASS, 'cacert.pem')
|
||||
else:
|
||||
ca_certs = 'cacert.pem'
|
||||
return ca_certs
|
||||
|
||||
|
||||
def get_pool_manager():
|
||||
"""Return a urllib3.PoolManager object."""
|
||||
return urllib3.PoolManager(ca_certs=get_cacert_file())
|
||||
|
||||
|
||||
def checksum_ok(data, what):
|
||||
"""Check whether the checksum of the 'data' argument is correct."""
|
||||
Expects a sha256 code as argument."""
|
||||
if reference_hash_code is None:
|
||||
raise Exception("ERROR: Invalid hash code.")
|
||||
code = hashlib.sha256()
|
||||
code.update(data)
|
||||
if what is ChecksumWhat.FOLDER:
|
||||
n = 0
|
||||
elif what is ChecksumWhat.DB:
|
||||
n = 1
|
||||
else:
|
||||
raise ValueError("Wrong entry name.")
|
||||
try:
|
||||
# The downloaded file is a csv file with columns (last version == last line):
|
||||
# data.zip_SHA256 | db.csv_SHA256 | Version | Creation_date
|
||||
reference = get_pool_manager().request(
|
||||
'GET',
|
||||
Database.LINK_REF,
|
||||
timeout=4.0
|
||||
).data.decode("utf-8").splitlines()[-1].split(Database.DELIMITER)[n]
|
||||
except Exception:
|
||||
raise
|
||||
return code.hexdigest() == reference
|
||||
return code.hexdigest() == reference_hash_code
|
||||
|
||||
|
||||
def connect_events_to_func(events_to_connect, fun_to_connect, fun_args):
|
||||
|
||||
111
src/versioncontroller.py
Normal file
111
src/versioncontroller.py
Normal file
@@ -0,0 +1,111 @@
|
||||
from io import BytesIO
|
||||
import json
|
||||
from constants import Constants
|
||||
from os_utilities import get_os
|
||||
from web_utilities import download_file
|
||||
|
||||
|
||||
"""This module exposes just one class: VersionController.
|
||||
|
||||
All the relevant information can be accessed with the dot notation on an instance of such class, e.g.:
|
||||
version_controller.software.hash_code
|
||||
is the hash_code of the latest release of the software running on the current OS."""
|
||||
|
||||
|
||||
def _download_versions_file():
|
||||
"""Download the json file containing all the information
|
||||
about the latest version of the software. Return a dictionary
|
||||
containing only the information for the running OS.
|
||||
|
||||
Return a dictionary from a json with the following structure:
|
||||
{
|
||||
"windows": {
|
||||
"software": {
|
||||
"version": "...",
|
||||
"url": "...",
|
||||
"hash_code": "...",
|
||||
"size": ...
|
||||
},
|
||||
"updater": {
|
||||
"version": "...",
|
||||
"url": "...",
|
||||
"hash_code": "...",
|
||||
"size": ...
|
||||
}
|
||||
},
|
||||
"linux": {
|
||||
"software": {
|
||||
"version": "...",
|
||||
"url": "...",
|
||||
"hash_code": "...",
|
||||
"size": ...
|
||||
},
|
||||
"updater": {
|
||||
"version": "...",
|
||||
"url": "...",
|
||||
"hash_code": "...",
|
||||
"size": ...
|
||||
}
|
||||
},
|
||||
"mac": {
|
||||
"software": {
|
||||
"version": "...",
|
||||
"url": "...",
|
||||
"hash_code": "...",
|
||||
"size": ...
|
||||
},
|
||||
"updater": {
|
||||
"version": "...",
|
||||
"url": "...",
|
||||
"hash_code": "...",
|
||||
"size": ...
|
||||
}
|
||||
}
|
||||
}
|
||||
"""
|
||||
try:
|
||||
version_dict = json.load(
|
||||
BytesIO(download_file(Constants.VERSION_LINK))
|
||||
)[get_os()]
|
||||
except Exception:
|
||||
return None
|
||||
else:
|
||||
return version_dict
|
||||
|
||||
|
||||
class VersionController:
|
||||
"""Dynamically create attributes corresponding to elements of a dictionary.
|
||||
|
||||
Used to get updates information."""
|
||||
|
||||
def __init__(self, dct=None):
|
||||
"""Initialize the dictionary"""
|
||||
super().__init__()
|
||||
self._dct = dct
|
||||
|
||||
def __getattr__(self, attr):
|
||||
"""Override super().__getattr__. Dynamically create new attributes
|
||||
corresponding to elements of the diciotnary."""
|
||||
if self._dct is None:
|
||||
if not self.update():
|
||||
return None
|
||||
try:
|
||||
dct_element = self._dct[attr]
|
||||
except Exception("ERROR: Invalid attribute!"):
|
||||
return None
|
||||
else:
|
||||
if isinstance(dct_element, dict):
|
||||
setattr(self, attr, type(self)(dct_element))
|
||||
else:
|
||||
setattr(self, attr, dct_element)
|
||||
return getattr(self, attr)
|
||||
|
||||
def update(self):
|
||||
"""Reset the dictionary to the correspondig json file containing
|
||||
the latest version information. Call this function inside a Qthread."""
|
||||
dct = _download_versions_file()
|
||||
if dct is not None:
|
||||
self._dct = dct
|
||||
return True
|
||||
else:
|
||||
return False
|
||||
@@ -1,10 +1,12 @@
|
||||
import re
|
||||
from PyQt5.QtGui import QPixmap
|
||||
from PyQt5.QtCore import pyqtSlot, pyqtSignal, QObject
|
||||
from threads import (BaseDownloadThread,
|
||||
UpdateSpaceWeatherThread,
|
||||
ThreadStatus,
|
||||
UpdateForecastThread)
|
||||
from threads import (
|
||||
BaseDownloadThread,
|
||||
UpdateSpaceWeatherThread,
|
||||
ThreadStatus,
|
||||
UpdateForecastThread
|
||||
)
|
||||
from constants import Constants
|
||||
from switchable_label import MultiColorSwitchableLabel
|
||||
from utilities import safe_cast
|
||||
|
||||
51
src/web_utilities.py
Normal file
51
src/web_utilities.py
Normal file
@@ -0,0 +1,51 @@
|
||||
import os
|
||||
import sys
|
||||
import urllib3
|
||||
from constants import Database
|
||||
from executable_utilities import IS_BINARY
|
||||
|
||||
|
||||
def get_cacert_file():
|
||||
"""Return the path to the cacert.pem file."""
|
||||
if IS_BINARY:
|
||||
ca_certs = os.path.join(sys._MEIPASS, 'cacert.pem')
|
||||
else:
|
||||
ca_certs = 'cacert.pem'
|
||||
return ca_certs
|
||||
|
||||
|
||||
def get_pool_manager():
|
||||
"""Return a urllib3.PoolManager object."""
|
||||
return urllib3.PoolManager(ca_certs=get_cacert_file())
|
||||
|
||||
|
||||
def download_file(url, encoding=""):
|
||||
resp = get_pool_manager().request(
|
||||
'GET',
|
||||
url,
|
||||
preload_content=True,
|
||||
timeout=4.0
|
||||
).data
|
||||
if encoding:
|
||||
return resp.decode(encoding)
|
||||
return resp
|
||||
|
||||
|
||||
def _download_multiline_file_as_list(url=Database.LINK_REF):
|
||||
"""Download a text file and return the last line as a list.
|
||||
|
||||
The downloaded file is a csv file with columns (last version == last line):
|
||||
data.zip_SHA256 | db.csv_SHA256 | Version | Creation_date"""
|
||||
try:
|
||||
f = download_file(url, encoding="UTF-8").splitlines()[-1].split(Database.DELIMITER)
|
||||
except Exception:
|
||||
return None
|
||||
return f
|
||||
|
||||
|
||||
def get_folder_hash_code():
|
||||
return _download_multiline_file_as_list()[0]
|
||||
|
||||
|
||||
def get_db_hash_code():
|
||||
return _download_multiline_file_as_list()[1]
|
||||
Reference in New Issue
Block a user