├── .gitattributes ├── tests ├── data │ ├── jms.pub │ ├── versions.gz │ ├── pyu-wi-1.1.tar.gz │ ├── pyu-win-0.0.2.xz │ ├── pyu-win-1.tar.gz │ ├── Acme-mac-4.1.tar.gz │ ├── with spaces-nix-0.0.1b1.zip │ ├── with spaces-win-0.0.1a2.zip │ ├── keypack.pyu │ ├── update_repo_extract │ │ ├── .pyupdater │ │ │ └── config.pyu │ │ ├── client_config.py │ │ ├── app_extract_02.py │ │ ├── app_extract_01.py │ │ ├── app_extract_onedir.py │ │ ├── build_onedir_extract.py │ │ └── build_onefile_extract.py │ ├── update_repo_restart │ │ ├── .pyupdater │ │ │ └── config.pyu │ │ ├── client_config.py │ │ ├── app_restart_02.py │ │ ├── app_restart_01.py │ │ ├── app_restart_onedir.py │ │ ├── build_onefile_restart.py │ │ └── build_onedir_restart.py │ ├── version.json │ └── dont delete pyu test.txt ├── README.md ├── requirements.txt ├── test_keys.py ├── tconfig.py ├── test_version_file.py ├── test_builder.py ├── test_config.py ├── conftest.py ├── test_uploader.py ├── test_patcher.py ├── test_downloader.py ├── test_utils.py ├── test_package_handler.py └── test_cli.py ├── pyupdater ├── hooks │ ├── hook-nacl.py │ ├── __init__.py │ └── hook-dsdev_utils.py ├── utils │ ├── encoding.py │ ├── pyinstaller_compat.py │ ├── storage.py │ ├── exceptions.py │ └── config.py ├── __main__.py ├── settings.py ├── __init__.py ├── cli │ ├── __init__.py │ └── helpers.py └── core │ ├── __init__.py │ ├── key_handler │ ├── keys.py │ └── __init__.py │ ├── package_handler │ └── patch.py │ └── uploader.py ├── MANIFEST.in ├── .github ├── FUNDING.yml └── workflows │ └── main.yaml ├── dev ├── requirements.txt ├── namespace-test.py ├── move.py └── clean.py ├── CONTRIBUTING.md ├── docs ├── demo-wxpython.md ├── downloads.md ├── contributing.md ├── contributors.md ├── danger-zone.md ├── usage-cli-advanced.md ├── oss.md ├── migrate-from-esky.md ├── license.md ├── installation.md ├── data-folder-layout.md ├── upgrading.md ├── index.md ├── usage-cli-asset.md ├── create-upload-plugin.md ├── usage-client-asset.md ├── usage-client.md ├── usage-client-advanced.md ├── usage-cli.md └── api.md ├── requirements.txt ├── setup.cfg ├── README.md ├── .coveragerc ├── Makefile ├── tox.ini ├── .gitignore ├── Makefile.bat ├── mkdocs.yml └── setup.py /.gitattributes: -------------------------------------------------------------------------------- 1 | pyupdater/_version.py export-subst 2 | -------------------------------------------------------------------------------- /tests/data/jms.pub: -------------------------------------------------------------------------------- 1 | MEviIkxfr74822UoF97+RJ9GdS9eVtkKe4PKH2J3khA -------------------------------------------------------------------------------- /pyupdater/hooks/hook-nacl.py: -------------------------------------------------------------------------------- 1 | hiddenimports = [u"_cffi_backend"] 2 | -------------------------------------------------------------------------------- /tests/data/versions.gz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Digital-Sapphire/PyUpdater/HEAD/tests/data/versions.gz -------------------------------------------------------------------------------- /tests/data/pyu-wi-1.1.tar.gz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Digital-Sapphire/PyUpdater/HEAD/tests/data/pyu-wi-1.1.tar.gz -------------------------------------------------------------------------------- /tests/data/pyu-win-0.0.2.xz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Digital-Sapphire/PyUpdater/HEAD/tests/data/pyu-win-0.0.2.xz -------------------------------------------------------------------------------- /tests/data/pyu-win-1.tar.gz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Digital-Sapphire/PyUpdater/HEAD/tests/data/pyu-win-1.tar.gz -------------------------------------------------------------------------------- /tests/data/Acme-mac-4.1.tar.gz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Digital-Sapphire/PyUpdater/HEAD/tests/data/Acme-mac-4.1.tar.gz -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include requirements.txt 2 | include versioneer.py 3 | include docs/license.md 4 | include pyupdater/_version.py 5 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | patreon: JMSwag 4 | custom: https://cash.app/$jmswag 5 | -------------------------------------------------------------------------------- /dev/requirements.txt: -------------------------------------------------------------------------------- 1 | memory_profiler 2 | mkdocs 3 | mkdocs-material 4 | PyUpdater-s3-Plugin 5 | tox 6 | versioneer==0.19 7 | vulture 8 | -------------------------------------------------------------------------------- /tests/data/with spaces-nix-0.0.1b1.zip: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Digital-Sapphire/PyUpdater/HEAD/tests/data/with spaces-nix-0.0.1b1.zip -------------------------------------------------------------------------------- /tests/data/with spaces-win-0.0.1a2.zip: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Digital-Sapphire/PyUpdater/HEAD/tests/data/with spaces-win-0.0.1a2.zip -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | To get started, sign the Contributor License Agreement. 2 | -------------------------------------------------------------------------------- /docs/demo-wxpython.md: -------------------------------------------------------------------------------- 1 | # wxPython Demo 2 | 3 | Check out the [wxPython Demo](http://pyupdater-wx-demo.readthedocs.io) to see how easy it is to integrate PyUpdater with wxPython. 4 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | appdirs>=1.4.3 2 | bsdiff4 3 | certifi>=2019.3.9 4 | dsdev-utils>=1.0.4 5 | pyinstaller>=3.0 6 | pynacl>=1.4.0 7 | stevedore>=1.30.1, <4.0 8 | urllib3>=1.24.1, <2.0 9 | -------------------------------------------------------------------------------- /tests/README.md: -------------------------------------------------------------------------------- 1 | ##Things to know about tests 2 | 3 | ####pytest-datadir: 4 | Used to easily access files in the data folder. [pytest-datadir](https://github.com/gabrielcnr/pytest-datadir) -------------------------------------------------------------------------------- /docs/downloads.md: -------------------------------------------------------------------------------- 1 | # Downloads 2 | 3 | #### [Zip](https://github.com/Digital-Sapphire/PyUpdater/zipball/master) 4 | 5 | #### [Tarball](https://github.com/Digital-Sapphire/PyUpdater/tarball/master) 6 | -------------------------------------------------------------------------------- /docs/contributing.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | - Stick to PEP8 4 | - Ensure to rebase from master 5 | - Squash commits into 1 commit 6 | - Check open issues for places to help 7 | - PR's are always welcome 8 | -------------------------------------------------------------------------------- /tests/requirements.txt: -------------------------------------------------------------------------------- 1 | codecov>=1.4.0 2 | coverage==4.5.4 3 | filelock 4 | pytest>=4.4.0 5 | pytest-datadir>=1.3.0, <2.0 6 | pytest-cov 7 | pytest-ordering 8 | pytest-xdist[psutil] 9 | pyupdater-scp-plugin 10 | pyupdater-s3-plugin 11 | tox-pyenv 12 | wheel 13 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [metadata] 2 | description-file = README.md 3 | 4 | [bdist_wheel] 5 | universal=1 6 | 7 | [versioneer] 8 | VCS = git 9 | style = pep440 10 | versionfile_source=pyupdater/_version.py 11 | versionfile_build=pyupdater/_version.py 12 | tag_prefix= 13 | parentdir_prefix= 14 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # This is the end. 2 | 3 | I haven't made any desktop apps in a while. Mostly making PWA's. I also no longer use Python for my backends, in favor of Golang. 4 | 5 | # PyUpdater 6 | #### An auto-update framework for pyinstaller that enables simple, secure & efficient shipment of app updates. 7 | -------------------------------------------------------------------------------- /docs/contributors.md: -------------------------------------------------------------------------------- 1 | # Contributors 2 | 3 | [@cbenhagen](https://github.com/cbenhagen) 4 | 5 | [@dlernstrom](https://github.com/dlernstrom) 6 | 7 | [@LucaBernstein](https://github.com/LucaBernstein) 8 | 9 | [@jameshilliard](https://github.com/jameshilliard) 10 | 11 | [@mayli](https://github.com/mayli) 12 | 13 | [@moscoquera](https://github.com/moscoquera) 14 | -------------------------------------------------------------------------------- /tests/data/keypack.pyu: -------------------------------------------------------------------------------- 1 | { 2 | "client": { 3 | "offline_public": "a/Hr20EtHRSg13qWOFJGnDPFOH9KkyBLKWWhITlpThg" 4 | }, 5 | "repo": { 6 | "app_private": "p433qLjrmZpH6CnFztBQh+NuJoqpzxemS36U7LKx86s" 7 | }, 8 | "upload": { 9 | "app_public": "tPOGc6w2m+PG46mAdO7cgKHvy1G8I6ffELIwNchpI70", 10 | "signature": "a/nMf5UUNk6UeNU/vlwFsBVnfeAWhwoohu0mAyc8xMb4RUdmI1oh+sfyHbHA126qUUfR00U609XtisDe1cb2CQ" 11 | } 12 | } -------------------------------------------------------------------------------- /.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | source = pyupdater 3 | 4 | [report] 5 | omit = 6 | pyupdater/_version.py 7 | 8 | exclude_lines = 9 | # Have to re-enable the standard pragma 10 | pragma: no cover 11 | 12 | # Don't complain if tests don't hit defensive assertion code: 13 | raise AssertionError 14 | raise NotImplementedError 15 | 16 | # Don't complain if non-runnable code isn't run: 17 | if 0: 18 | if __name__ == .__main__.: -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | clean: 2 | python dev/clean.py 3 | 4 | deps: 5 | pip install -r requirements.txt --upgrade 6 | 7 | deps-dev: 8 | pip install -r dev/requirements.txt --upgrade 9 | 10 | api-md: 11 | python dev/api_docs.py 12 | 13 | docs-deploy: 14 | mkdocs build --clean 15 | python dev/move.py 16 | 17 | register: 18 | python setup.py register -r pypi 19 | 20 | register-test: 21 | python setup.py register -r pypitest 22 | 23 | test: clean 24 | tox 25 | -------------------------------------------------------------------------------- /docs/danger-zone.md: -------------------------------------------------------------------------------- 1 | # Danger Zone 2 | 3 | 4 | ### Spec File 5 | 6 | Do not change the name attribute in the spec file 7 | 8 | 9 | ### Version Numbers 10 | 11 | Version numbers should be in the format specified below. Supported release versions are alpha and beta. For shorthand you can use a and b respectively. 12 | 13 | 1.0 14 | 15 | 1.1alpha 16 | 17 | 1.1-alpha2 18 | 19 | 1.1-alpha-3 20 | 21 | 1.1.1 22 | 23 | 1.0.2beta 24 | 25 | 1.0.2-beta2 26 | 27 | 1.0.2-beta-3 28 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist=py{36,37,38,39} 3 | 4 | [testenv] 5 | commands = pytest 6 | recreate = True 7 | setenv = TOX_ENV_NAME={envname} 8 | sitepackages = True 9 | usedevelop = True 10 | 11 | deps = 12 | -r tests/requirements.txt 13 | -r requirements.txt 14 | --upgrade 15 | 16 | [pytest] 17 | addopts = 18 | -n auto 19 | -vv 20 | --durations=20 21 | --cov 22 | --cov-config .coveragerc 23 | --cov-report xml 24 | --cov pyupdater 25 | --cov-append 26 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.egg-info 2 | 3 | *.log 4 | 5 | *.pkg 6 | 7 | *.pyc 8 | 9 | *.pyz 10 | 11 | *.toc 12 | 13 | .coverage 14 | 15 | .coverage.* 16 | 17 | .idea 18 | 19 | MANIFEST 20 | 21 | .DS_Store 22 | 23 | htmlcov 24 | 25 | site 26 | 27 | .coverage.gw* 28 | 29 | .tox 30 | 31 | .cache/v/cache/lastfailed 32 | 33 | tests/dev 34 | 35 | unused.txt 36 | 37 | keypack.pyu 38 | 39 | private 40 | 41 | test.py 42 | 43 | .python-version 44 | 45 | coverage.xml 46 | 47 | .pytest_cache 48 | 49 | Acme* 50 | -------------------------------------------------------------------------------- /docs/usage-cli-advanced.md: -------------------------------------------------------------------------------- 1 | # Usage | CLI | Advanced 2 | PyUpdater supports 3 release channels. Release channels are specified when providing a version number to the --app-version flag. Patches are created for each channel. Examples below. 3 | 4 | ### Example - Setting channels 5 | Stable: 6 | ``` 7 | $ pyupdater build --app-version=3.30.1 8 | $ pyupdater build --app-version=2.1 9 | $ pyupdater build --app-version=1.0 10 | ``` 11 | 12 | Beta: 13 | ``` 14 | $ pyupdater build --app-version=1.0.1b 15 | $ pyupdater build --app-version=3.1b2 16 | $ pyupdater build --app-version=11.3.1beta2 17 | ``` 18 | 19 | Alpha: 20 | ``` 21 | $ pyupdater build --app-version=1.0.1a 22 | $ pyupdater build --app-version=5.0alpha 23 | $ pyupdater build --app-version=1.1.1alpha1 24 | ``` -------------------------------------------------------------------------------- /Makefile.bat: -------------------------------------------------------------------------------- 1 | @ECHO OFF 2 | CLS 3 | ECHO 1. Clean 4 | ECHO 2. Install Dev Dependencies 5 | ECHO 3. Run Tests 6 | ECHO 4. Generate docs 7 | ECHO. 8 | 9 | CHOICE /C 1234 /M "Enter your choice:" 10 | 11 | :: Note - list ERRORLEVELS in decreasing order 12 | IF ERRORLEVEL 4 GOTO Docs 13 | IF ERRORLEVEL 3 GOTO TEST 14 | IF ERRORLEVEL 2 GOTO Install 15 | IF ERRORLEVEL 1 GOTO Clean 16 | 17 | :Clean 18 | ECHO cleaning temp items 19 | python dev\clean.py 20 | GOTO End 21 | 22 | :Install 23 | ECHO Installing development dependencies 24 | pip install -r requirements --upgrade 25 | pip install -r dev\requirements.txt --upgrade 26 | GOTO End 27 | 28 | :Docs 29 | ECHO Creating Docs 30 | mkdocs build --clean 31 | GOTO End 32 | 33 | :Test 34 | ECHO Starting Test 35 | python dev\clean.py 36 | tox 37 | GOTO End 38 | 39 | :End -------------------------------------------------------------------------------- /.github/workflows/main.yaml: -------------------------------------------------------------------------------- 1 | name: PyUpdater Tests 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | branches: 9 | - main 10 | 11 | jobs: 12 | build: 13 | runs-on: ${{ matrix.os }} 14 | strategy: 15 | fail-fast: false 16 | matrix: 17 | python: [3.6, 3.7, 3.8, 3.9] 18 | os: [windows-2016, ubuntu-16.04, macos-10.15] 19 | 20 | steps: 21 | - uses: actions/checkout@v2 22 | - name: Setup Python 23 | uses: actions/setup-python@v2 24 | with: 25 | python-version: ${{ matrix.python }} 26 | - name: Install Tox and any other packages 27 | run: pip install tox 28 | - name: Install self 29 | run: pip install -e . 30 | - name: Run Tox 31 | # Run tox using the version of Python in `PATH` 32 | run: tox -e py 33 | - uses: codecov/codecov-action@v1 34 | -------------------------------------------------------------------------------- /docs/oss.md: -------------------------------------------------------------------------------- 1 | # O.S.S. 2 | 3 | PyUpdater uses the libraries listed below. 4 | 5 | ### appdirs 6 | https://github.com/ActiveState/appdirs/blob/master/LICENSE.txt 7 | 8 | 9 | ### bsdiff4 10 | https://github.com/ilanschnell/bsdiff4/blob/master/README.rst 11 | 12 | 13 | ### certifi 14 | https://github.com/certifi/python-certifi/blob/master/LICENSE 15 | 16 | 17 | ### dsdev-utils 18 | https://github.com/Digital-Sapphire/dsdev-utils/blob/master/LICENSE 19 | 20 | 21 | ### PyInstaller 22 | https://github.com/pyinstaller/pyinstaller/blob/develop/COPYING.txt 23 | 24 | 25 | ### PyNacl 26 | https://github.com/pyca/pynacl/blob/master/LICENSE 27 | 28 | 29 | ### six 30 | https://bitbucket.org/gutworth/six/src/986089f3d04e468458cc9f5b9b3be26fb3d75ade/LICENSE?at=default 31 | 32 | 33 | ### stevedore 34 | https://github.com/dreamhost/stevedore/blob/master/LICENSE 35 | 36 | 37 | ### urllib3 38 | https://github.com/shazow/urllib3/blob/master/LICENSE.txt 39 | -------------------------------------------------------------------------------- /pyupdater/utils/encoding.py: -------------------------------------------------------------------------------- 1 | import base64 2 | 3 | 4 | class UnpaddedBase64Encoder(object): 5 | """ 6 | A simple encoder class to encode/decode to base64 with the 'padding' (trailing equal characters) removed. 7 | 8 | This is needed for PyNaCL to be compatible with keys generated by the old ed25519 library. 9 | """ 10 | 11 | @staticmethod 12 | def encode(data): 13 | return base64.b64encode(data).rstrip(b"=") 14 | 15 | @staticmethod 16 | def decode(data): 17 | # We need to add the padding back if it's been removed. 18 | # The correct type of the padding depends on what the input is, 19 | # as base64 (and the decoder in general) is happy to take in both bytes and unicode. 20 | 21 | if isinstance(data, bytes): 22 | padding = b"=" 23 | else: 24 | padding = u"=" 25 | 26 | data += padding * ((4 - len(data) % 4) % 4) 27 | 28 | return base64.b64decode(data) 29 | -------------------------------------------------------------------------------- /docs/migrate-from-esky.md: -------------------------------------------------------------------------------- 1 | # Migrate from Esky 2 | 3 | Currently the best way to migrate from Esky is follow the [Getting Started](usage-cli.md) guide. 4 | End Users will have to download this, updated version of your app, directly from a download link. 5 | 6 | ### Migration Help 7 | 8 | ##### Configuration 9 | 10 | Esky: 11 | 12 | - Uses setup.py 13 | 14 | PyUpdater: 15 | 16 | - Set during repository initialization. 17 | 18 | 19 | ##### Parsing App Current Versions 20 | 21 | Esky: 22 | 23 | - Parses local repository. 24 | 25 | PyUpdater: 26 | 27 | - Version is set within application script. 28 | 29 | 30 | ##### Parsing App Update Versions 31 | 32 | Esky: 33 | 34 | - Parses update repository. 35 | 36 | PyUpdater: 37 | 38 | - Uses a dedicated version file which includes hashes for security and integrity. 39 | 40 | 41 | ##### App Update Process 42 | 43 | Esky: 44 | 45 | - Initialize Esky update client, then use update method. 46 | 47 | PyUpdater: 48 | 49 | - Initialize PyUpdater client, then use update method. -------------------------------------------------------------------------------- /docs/license.md: -------------------------------------------------------------------------------- 1 | # License 2 | 3 | Copyright (c) 2015-2019 Digital Sapphire 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 6 | 7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 8 | 9 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- /tests/data/update_repo_extract/.pyupdater/config.pyu: -------------------------------------------------------------------------------- 1 | { 2 | "version_meta": { 3 | "signature": "SFZSIMCiVl1AdjRvIAq5Qyxtk3Yk83SX8AgSFASsJT0RQ+BsNUFuRZ0OVXbnBXoNHuAfhrb7bzZhMeaxQCVSDg", 4 | "updates": { 5 | }, 6 | "latest": { 7 | } 8 | }, 9 | "count": 0, 10 | "sync_threshold": 3, 11 | "py_repo_config": { 12 | "patches": { 13 | "Acme": 4 14 | }, 15 | "package": { 16 | } 17 | }, 18 | "app_config": { 19 | "APP_NAME": "Acme", 20 | "UPDATE_URLS": [ 21 | "http://localhost:8000/" 22 | ], 23 | "PLUGIN_CONFIGS": {}, 24 | "UPDATE_PATCHES": true, 25 | "COMPANY_NAME": "Digital", 26 | "CLIENT_CONFIG_PATH": [ 27 | "client_config.py" 28 | ] 29 | }, 30 | "keypack": { 31 | "repo": { 32 | "app_private": "M7o+RcxPuqKBHEka3LjbnjMQr+zYWm2t6cNk9giu7MM" 33 | }, 34 | "client": { 35 | "offline_public": "rRp4eJzzsPxN1nLXBOuLCqI33HWTridHKJpNnDSUlbU" 36 | }, 37 | "upload": { 38 | "app_public": "MEviIkxfr74822UoF97+RJ9GdS9eVtkKe4PKH2J3khA", 39 | "signature": "WtNNMd2AsDlOIti1sFRylWJb/jyBOlfiF7eJPbSl/FNjvnQ+0L/7ooaDYAzRWsHCs58JyB7XYMcyfKSwDjO9Cg" 40 | } 41 | }, 42 | "config_dir": ".pyupdater" 43 | } 44 | -------------------------------------------------------------------------------- /tests/data/update_repo_restart/.pyupdater/config.pyu: -------------------------------------------------------------------------------- 1 | { 2 | "version_meta": { 3 | "signature": "SFZSIMCiVl1AdjRvIAq5Qyxtk3Yk83SX8AgSFASsJT0RQ+BsNUFuRZ0OVXbnBXoNHuAfhrb7bzZhMeaxQCVSDg", 4 | "updates": { 5 | }, 6 | "latest": { 7 | } 8 | }, 9 | "count": 0, 10 | "sync_threshold": 3, 11 | "py_repo_config": { 12 | "patches": { 13 | "Acme": 4 14 | }, 15 | "package": { 16 | } 17 | }, 18 | "app_config": { 19 | "APP_NAME": "Acme", 20 | "UPDATE_URLS": [ 21 | "http://localhost:8000/" 22 | ], 23 | "PLUGIN_CONFIGS": {}, 24 | "UPDATE_PATCHES": true, 25 | "COMPANY_NAME": "Digital", 26 | "CLIENT_CONFIG_PATH": [ 27 | "client_config.py" 28 | ] 29 | }, 30 | "keypack": { 31 | "repo": { 32 | "app_private": "M7o+RcxPuqKBHEka3LjbnjMQr+zYWm2t6cNk9giu7MM" 33 | }, 34 | "client": { 35 | "offline_public": "rRp4eJzzsPxN1nLXBOuLCqI33HWTridHKJpNnDSUlbU" 36 | }, 37 | "upload": { 38 | "app_public": "MEviIkxfr74822UoF97+RJ9GdS9eVtkKe4PKH2J3khA", 39 | "signature": "WtNNMd2AsDlOIti1sFRylWJb/jyBOlfiF7eJPbSl/FNjvnQ+0L/7ooaDYAzRWsHCs58JyB7XYMcyfKSwDjO9Cg" 40 | } 41 | }, 42 | "config_dir": ".pyupdater" 43 | } 44 | -------------------------------------------------------------------------------- /docs/installation.md: -------------------------------------------------------------------------------- 1 | # Installation 2 | PyUpdater depends on a few external libraries: 3 | [appdirs](https://pypi.python.org/pypi/appdirs/), [bsdiff4](https://github.com/ilanschnell/bsdiff4), [certifi](https://pypi.python.org/pypi/certifi), [dsdev-utils](https://pypi.python.org/pypi/dsdev-utils), [pyinstaller](https://github.com/pyinstaller/pyinstaller), [pynacl](https://pypi.org/project/PyNaCl/), [stevedore](https://pypi.python.org/pypi/stevedore) & [urllib3](https://pypi.python.org/pypi/urllib3). 4 | 5 | #### Install Stable version 6 | 7 | $ pip install --upgrade PyUpdater 8 | 9 | 10 | #### Install w/ upload plugins 11 | 12 | $ pip install --upgrade PyUpdater[s3] 13 | 14 | $ pip install --upgrade PyUpdater[scp] 15 | 16 | 17 | #### For complete install 18 | 19 | $ pip install --upgrade PyUpdater[all] 20 | 21 | 22 | Be sure to check the plugins docs for setup & configuration options. 23 | 24 | [PyUpdater-S3-Plugin](https://github.com/Digital-Sapphire/pyupdater-s3-plugin) 25 | 26 | [PyUpdater-SCP-Plugin](https://github.com/Digital-Sapphire/pyupdater-scp-plugin) 27 | 28 | 29 | #### Install Development version 30 | We are not responsible for broken things but please report your findings ;) 31 | 32 | $ pip install --upgrade git+https://github.com/Digital-Sapphire/PyUpdater.git@master 33 | -------------------------------------------------------------------------------- /mkdocs.yml: -------------------------------------------------------------------------------- 1 | site_name: PyUpdater 2 | site_url: http://www.pyupdater.org 3 | site_description: Pyinstaller Auto-Update Framework 4 | google_analytics: ['UA-76455631-6', 'www.pyupdater.org'] 5 | nav: 6 | - Home: index.md 7 | - User Guide: 8 | - Installation: installation.md 9 | - Commands: commands.md 10 | - Usage | CLI: usage-cli.md 11 | - Usage | CLI | Assets: usage-cli-asset.md 12 | - Usage | CLI | Advanced: usage-cli-advanced.md 13 | - Usage | Client: usage-client.md 14 | - Usage | Client | Asset: usage-client-asset.md 15 | - Usage | Client | Advanced: usage-client-advanced.md 16 | - Upgrading PyUpdater: upgrading.md 17 | - Create Upload Plugins: create-upload-plugin.md 18 | - Demo | wxPython: demo-wxpython.md 19 | - Danger Zone: danger-zone.md 20 | - API: api.md 21 | - Change Log: changelog.md 22 | - Extras: 23 | - Data Folder Layout: data-folder-layout.md 24 | - Migrate From Esky: migrate-from-esky.md 25 | - Contributing: contributing.md 26 | - Contributors: contributors.md 27 | - Downloads: downloads.md 28 | - License: license.md 29 | - O.S.S.: oss.md 30 | 31 | theme: 'material' 32 | repo_name: 'GitHub' 33 | repo_url: 'https://github.com/Digital-Sapphire/PyUpdater' 34 | extra: 35 | palette: 36 | primary: 'blue' 37 | accent: 'light blue' 38 | 39 | dev_addr: 0.0.0.0:8080 40 | -------------------------------------------------------------------------------- /docs/data-folder-layout.md: -------------------------------------------------------------------------------- 1 | # PyUpdater Local Repository Layout. 2 | ``` 3 | . 4 | ├── SuperApp.py 5 | ├── client_config.py 6 | ├── pyu-data 7 | │   ├── deploy 8 | │   ├── files 9 | │   └── new 10 | └── requirements.txt 11 | ``` 12 | - client_config.py: Written by pyupdater. 13 | - pyu-data 14 | 15 | - New: Where you place newly compiled programs ready for signing 16 | 17 | - Deploy: After updates have been signed, they'll be staged here. The version meta data and public keys will also be staged here for upload. 18 | 19 | - Files: Most recent update of each app/lib is place here. Will be used as a base to crate a patch on the next build. 20 | 21 | 22 | ``` 23 | .pyupdater 24 | ├── config.pyu 25 | ├── spec 26 | │   └── mac.spec 27 | └── work 28 | └── mac 29 | ├── out00-Analysis.toc 30 | ├── out00-EXE.toc 31 | ├── out00-PKG.pkg 32 | ├── out00-PKG.toc 33 | ├── out00-PYZ.pyz 34 | ├── out00-PYZ.toc 35 | ├── out00-Tree.toc 36 | ├── out01-Tree.toc 37 | └── warnmac.txt 38 | ``` 39 | 40 | - .pyupdater/ 41 | 42 | - config.pyu: This apps configuration information. 43 | 44 | - spec: Spec files generated by pyinstaller 45 | 46 | - mac.spec 47 | 48 | - win.spec 49 | 50 | - nix.spec 51 | 52 | - nix64.spec 53 | 54 | - work - Build artifacts generated by pyinstaller 55 | -------------------------------------------------------------------------------- /docs/upgrading.md: -------------------------------------------------------------------------------- 1 | # Upgrading PyUpdater 2 | Note: Major version will maintain API compatibility. 3 | 4 | ## To PyUpdater >= 3.0 5 | 6 | You need to change the download signature 7 | ``` 8 | AppUpdate.download(async=True) 9 | # To 10 | AppUpdate.download(background=True) 11 | 12 | LibUpdate.download(async=True) 13 | # To 14 | LibUpdate.download(background=True) 15 | ``` 16 | 17 | 18 | ## To PyUpdater >= 2.0.3 19 | Deprecated since bsdiff4 is installed by default. 20 | 21 | ``` 22 | $ pip install pyupdater[patch] 23 | ``` 24 | 25 | ## To PyUpdater >= 2.0 26 | Coming from PyUpdater >= 1.1 27 | 1. Run the command below 28 | 2. Press enter to use the default value. 29 | 30 | ``` 31 | $ pyupdater settings --config-path 32 | ``` 33 | Coming from PyUpdater < 1.1 34 | 1. You'll need to update to PyUpdater 1.1.15 35 | 2. If using progress hooks note that progress_hook changed to progress_hooks and only accepts lists 36 | 3. Release a new version of your application that uses PyUpdater 1.1.15 37 | 4. Ensure all end users are using the latest version of your application. 38 | 5. Once all of your end users have the latest version or your application, you can upgrade to PyUpdater 2.0 39 | 40 | This extra step is required because the schema of the version manifest file changed to support release channels. Version 1.1 was released about 7+ months ago. Future releases will document, in the changelog, when backwards compatibility code is marked for deprecation. -------------------------------------------------------------------------------- /pyupdater/hooks/__init__.py: -------------------------------------------------------------------------------- 1 | # ------------------------------------------------------------------------------ 2 | # Copyright (c) 2015-2020 Digital Sapphire 3 | # 4 | # Permission is hereby granted, free of charge, to any person obtaining 5 | # a copy of this software and associated documentation files 6 | # (the "Software"), to deal in the Software without restriction, including 7 | # without limitation the rights to use, copy, modify, merge, publish, 8 | # distribute, sublicense, and/or sell copies of the Software, and to permit 9 | # persons to whom the Software is furnished to do so, subject to the 10 | # following conditions: 11 | # 12 | # The above copyright notice and this permission notice shall be 13 | # included in all copies or substantial portions of the Software. 14 | # 15 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF 16 | # ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED 17 | # TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A 18 | # PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT 19 | # SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR 20 | # ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN 21 | # ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 22 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE 23 | # OR OTHER DEALINGS IN THE SOFTWARE. 24 | # ------------------------------------------------------------------------------ 25 | import os 26 | 27 | 28 | def get_hook_dir(): 29 | return os.path.abspath(os.path.dirname(__file__)) 30 | -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | # Welcome to PyUpdater 2 | 3 | If you think PyUpdater is awesome, please give [this issue](https://github.com/vinta/awesome-python/pull/720) an up vote on [awesome-python](https://github.com/vinta/awesome-python). 4 | 5 | 6 | ## What is PyUpdater? 7 | 8 | An auto-update library and cli tool that enables simple, secure & efficient shipment of app updates. 9 | 10 | This project would not be possible without [Pyinstaller](https://github.com/pyinstaller/pyinstaller). 11 | ## Status 12 | 13 | [![](https://badge.fury.io/py/PyUpdater.svg)](http://badge.fury.io/py/PyUpdater) 14 | ![](https://github.com/Digital-Sapphire/PyUpdater/actions/workflows/main.yaml/badge.svg) 15 | [![codecov](https://codecov.io/gh/Digital-Sapphire/PyUpdater/branch/master/graph/badge.svg)](https://codecov.io/gh/JMSwag/PyUpdater) 16 | 17 | ## Features 18 | 19 | - Easy Setup 20 | - CI/CD Support 21 | - Basic Auth support 22 | - Secured with EdDSA 23 | - Cryptographically secure off line update 24 | - Release channels 25 | - Automatic patch update support 26 | - Intelligent update workflow 27 | - Asynchronous downloads 28 | - Update versioned external assets 29 | - Dual key verification 30 | - An offline private key signs an application specific key pair. 31 | - The application specific key pair is used to sign and verify update meta data. 32 | - The client is shipped with the offline public key to bootstrap the verification process. 33 | - Download progress callback 34 | - Uploading to the cloud handled by plugins. 35 | - S3 and SCP plugins available 36 | -------------------------------------------------------------------------------- /pyupdater/__main__.py: -------------------------------------------------------------------------------- 1 | # ------------------------------------------------------------------------------ 2 | # Copyright (c) 2015-2019 Digital Sapphire 3 | # 4 | # Permission is hereby granted, free of charge, to any person obtaining 5 | # a copy of this software and associated documentation files 6 | # (the "Software"), to deal in the Software without restriction, including 7 | # without limitation the rights to use, copy, modify, merge, publish, 8 | # distribute, sublicense, and/or sell copies of the Software, and to permit 9 | # persons to whom the Software is furnished to do so, subject to the 10 | # following conditions: 11 | # 12 | # The above copyright notice and this permission notice shall be 13 | # included in all copies or substantial portions of the Software. 14 | # 15 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF 16 | # ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED 17 | # TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A 18 | # PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT 19 | # SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR 20 | # ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN 21 | # ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 22 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE 23 | # OR OTHER DEALINGS IN THE SOFTWARE. 24 | # ------------------------------------------------------------------------------ 25 | if __name__ == "__main__": 26 | # Enable python -m pyupdater [...] 27 | from pyupdater.cli import main 28 | import sys 29 | 30 | arguments = sys.argv[1:] 31 | main(arguments) 32 | -------------------------------------------------------------------------------- /pyupdater/hooks/hook-dsdev_utils.py: -------------------------------------------------------------------------------- 1 | # ------------------------------------------------------------------------------ 2 | # Copyright (c) 2015-2020 Digital Sapphire 3 | # 4 | # Permission is hereby granted, free of charge, to any person obtaining 5 | # a copy of this software and associated documentation files 6 | # (the "Software"), to deal in the Software without restriction, including 7 | # without limitation the rights to use, copy, modify, merge, publish, 8 | # distribute, sublicense, and/or sell copies of the Software, and to permit 9 | # persons to whom the Software is furnished to do so, subject to the 10 | # following conditions: 11 | # 12 | # The above copyright notice and this permission notice shall be 13 | # included in all copies or substantial portions of the Software. 14 | # 15 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF 16 | # ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED 17 | # TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A 18 | # PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT 19 | # SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR 20 | # ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN 21 | # ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 22 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE 23 | # OR OTHER DEALINGS IN THE SOFTWARE. 24 | # ------------------------------------------------------------------------------ 25 | hiddenimports = [ 26 | u"dsdev_utils.app", 27 | u"dsdev_utils.logger", 28 | u"dsdev_utils.paths", 29 | u"dsdev_utils.system", 30 | u"dsdev_utils.terminal", 31 | ] # pragma: no cover 32 | -------------------------------------------------------------------------------- /tests/data/update_repo_extract/client_config.py: -------------------------------------------------------------------------------- 1 | # ------------------------------------------------------------------------------ 2 | # Copyright (c) 2015-2020 Digital Sapphire 3 | # 4 | # Permission is hereby granted, free of charge, to any person obtaining 5 | # a copy of this software and associated documentation files 6 | # (the "Software"), to deal in the Software without restriction, including 7 | # without limitation the rights to use, copy, modify, merge, publish, 8 | # distribute, sublicense, and/or sell copies of the Software, and to permit 9 | # persons to whom the Software is furnished to do so, subject to the 10 | # following conditions: 11 | # 12 | # The above copyright notice and this permission notice shall be 13 | # included in all copies or substantial portions of the Software. 14 | # 15 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF 16 | # ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED 17 | # TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A 18 | # PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT 19 | # SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR 20 | # ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN 21 | # ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 22 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE 23 | # OR OTHER DEALINGS IN THE SOFTWARE. 24 | # ------------------------------------------------------------------------------ 25 | 26 | 27 | class ClientConfig(object): 28 | APP_NAME = "Acme" 29 | COMPANY_NAME = "Digital" 30 | UPDATE_URLS = ["http://localhost:8000/"] 31 | PUBLIC_KEY = "rRp4eJzzsPxN1nLXBOuLCqI33HWTridHKJpNnDSUlbU" 32 | -------------------------------------------------------------------------------- /tests/data/update_repo_restart/client_config.py: -------------------------------------------------------------------------------- 1 | # ------------------------------------------------------------------------------ 2 | # Copyright (c) 2015-2020 Digital Sapphire 3 | # 4 | # Permission is hereby granted, free of charge, to any person obtaining 5 | # a copy of this software and associated documentation files 6 | # (the "Software"), to deal in the Software without restriction, including 7 | # without limitation the rights to use, copy, modify, merge, publish, 8 | # distribute, sublicense, and/or sell copies of the Software, and to permit 9 | # persons to whom the Software is furnished to do so, subject to the 10 | # following conditions: 11 | # 12 | # The above copyright notice and this permission notice shall be 13 | # included in all copies or substantial portions of the Software. 14 | # 15 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF 16 | # ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED 17 | # TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A 18 | # PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT 19 | # SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR 20 | # ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN 21 | # ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 22 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE 23 | # OR OTHER DEALINGS IN THE SOFTWARE. 24 | # ------------------------------------------------------------------------------ 25 | 26 | 27 | class ClientConfig(object): 28 | APP_NAME = "Acme" 29 | COMPANY_NAME = "Digital" 30 | UPDATE_URLS = ["http://localhost:8001/"] 31 | PUBLIC_KEY = "rRp4eJzzsPxN1nLXBOuLCqI33HWTridHKJpNnDSUlbU" 32 | -------------------------------------------------------------------------------- /tests/data/update_repo_extract/app_extract_02.py: -------------------------------------------------------------------------------- 1 | # ------------------------------------------------------------------------------ 2 | # Copyright (c) 2015-2020 Digital Sapphire 3 | # 4 | # Permission is hereby granted, free of charge, to any person obtaining 5 | # a copy of this software and associated documentation files 6 | # (the "Software"), to deal in the Software without restriction, including 7 | # without limitation the rights to use, copy, modify, merge, publish, 8 | # distribute, sublicense, and/or sell copies of the Software, and to permit 9 | # persons to whom the Software is furnished to do so, subject to the 10 | # following conditions: 11 | # 12 | # The above copyright notice and this permission notice shall be 13 | # included in all copies or substantial portions of the Software. 14 | # 15 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF 16 | # ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED 17 | # TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A 18 | # PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT 19 | # SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR 20 | # ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN 21 | # ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 22 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE 23 | # OR OTHER DEALINGS IN THE SOFTWARE. 24 | # ------------------------------------------------------------------------------ 25 | from __future__ import print_function 26 | 27 | VERSION = "4.2" 28 | 29 | 30 | def main(): 31 | print(VERSION) 32 | with open("version1.txt", "w") as f: 33 | f.write(VERSION) 34 | return VERSION 35 | 36 | 37 | if __name__ == "__main__": 38 | main() 39 | -------------------------------------------------------------------------------- /docs/usage-cli-asset.md: -------------------------------------------------------------------------------- 1 | # Usage | CLI | Assets 2 | 3 | ### Step 1 - Prepare external asset 4 | 5 | Place asset in pyu-data/new directory 6 | 7 | ### Step 2 - Run archive command 8 | ``` 9 | $ pyupdater archive --name example.so --version 0.1.0 10 | ``` 11 | 12 | ### Step 3 - Create patches 13 | 14 | Time to create binary patches if enabled, add sha256 hashes to version manifest & copy files to deploy folder. You can combine --process with --sign. 15 | ``` 16 | $ pyupdater pkg --process 17 | ``` 18 | 19 | ### Step 4 - Cryptographically Sign 20 | 21 | Now lets sign our version manifest file, gzip our version manifest, gzip keyfile & place in the deploy directory. You can combine --sign with --process 22 | ``` 23 | $ pyupdater pkg --sign 24 | ``` 25 | 26 | ### Step 5 - List Installed Plugins 27 | 28 | Get a list of the plugins you have installed. You probably wont have to run this many times :) 29 | ``` 30 | # You can install the official plugins with 31 | # pip install pyupdater[s3, scp] 32 | $ pyupdater plugins 33 | 34 | s3 by Digital Sapphire 35 | scp by Digital Sapphire 36 | 37 | ``` 38 | 39 | ### Step 6 - Configure Plugin 40 | 41 | Your plugin may or may not need configuration. If you are not sure then go ahead and check. It won't hurt anything. If nothing happens then the coast is clear. Plugin authors may ask you to set env vars. Please consult their docs. Now off we go. 42 | ``` 43 | $ pyupdater settings --plugin s3 44 | ``` 45 | 46 | ### Step 7 - Upload 47 | 48 | We've made it. Time to upload our updates, patches & metadata. On the first run you will not have any patches. There's no src files yet. It'll happen on the next build. 49 | ``` 50 | $ pyupdater upload --service s3 51 | ``` 52 | -------------------------------------------------------------------------------- /tests/data/update_repo_restart/app_restart_02.py: -------------------------------------------------------------------------------- 1 | # ------------------------------------------------------------------------------ 2 | # Copyright (c) 2015-2020 Digital Sapphire 3 | # 4 | # Permission is hereby granted, free of charge, to any person obtaining 5 | # a copy of this software and associated documentation files 6 | # (the "Software"), to deal in the Software without restriction, including 7 | # without limitation the rights to use, copy, modify, merge, publish, 8 | # distribute, sublicense, and/or sell copies of the Software, and to permit 9 | # persons to whom the Software is furnished to do so, subject to the 10 | # following conditions: 11 | # 12 | # The above copyright notice and this permission notice shall be 13 | # included in all copies or substantial portions of the Software. 14 | # 15 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF 16 | # ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED 17 | # TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A 18 | # PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT 19 | # SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR 20 | # ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN 21 | # ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 22 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE 23 | # OR OTHER DEALINGS IN THE SOFTWARE. 24 | # ------------------------------------------------------------------------------ 25 | from __future__ import print_function 26 | 27 | VERSION = "4.2" 28 | 29 | 30 | def main(): 31 | print(VERSION) 32 | with open("version2.txt", "w") as f: 33 | print("Writing version file") 34 | f.write(VERSION) 35 | 36 | print("Leaving main()") 37 | 38 | 39 | if __name__ == "__main__": 40 | main() 41 | -------------------------------------------------------------------------------- /docs/create-upload-plugin.md: -------------------------------------------------------------------------------- 1 | # Create Upload Plugins 2 | 3 | - PyUpdater finds plugins by setuptools entry points. 4 | - Base class provides helper methods to get and set configuration information. 5 | 6 | ## Simple Example 7 | my_uploader.py 8 | ``` 9 | 10 | from pyupdater.core.uploader import BaseUploader 11 | 12 | 13 | class MyUploader(BaseUploader): 14 | 15 | name = 'my uploader' 16 | author = 'Jane Doe' 17 | 18 | def init_config(self, config): 19 | self.server_url = config["server_url"] 20 | 21 | def set_config(self, config): 22 | server_name = self.get_answer("Please enter server name\n--> ") 23 | config["server_url"] = server_name 24 | 25 | def upload_file(self, filename): 26 | # Make the magic happen 27 | files = {'file': open(filename, 'rb')} 28 | r = request.post(self.server_url, files=files) 29 | 30 | ``` 31 | 32 | 33 | setup.py 34 | ``` 35 | setup( 36 | provides=['pyupdater.plugins',], 37 | entry_points={ 38 | 'pyupdater.plugins': [ 39 | 'my_uploader = my_uploader:MyUploader', 40 | ] 41 | }, 42 | ``` 43 | 44 | ## Plugin Settings 45 | Plugin authors have 2 ways of getting and setting config information. 46 | 47 | The first way would be to request the information from the user. In your plugin you'd do something like this. 48 | ``` 49 | # Saves the config to disk. 50 | def set_config(self, config): 51 | server_name = self.get_answer("Please enter server name\n--> ") 52 | config["server_url"] = server_name 53 | 54 | 55 | # Will be called after the class is initialized. 56 | def init_config(self, config): 57 | self.server_url = config["server_url"] 58 | 59 | ``` 60 | 61 | The second way would be env var. 62 | 63 | ## Examples plugins 64 | [S3 Plugin](https://github.com/Digital-Sapphire/PyUpdater-S3-Plugin) 65 | 66 | [SCP Plugin](https://github.com/Digital-Sapphire/PyUpdater-SCP-Plugin) 67 | 68 | 69 | -------------------------------------------------------------------------------- /tests/test_keys.py: -------------------------------------------------------------------------------- 1 | # ------------------------------------------------------------------------------ 2 | # Copyright (c) 2015-2020 Digital Sapphire 3 | # 4 | # Permission is hereby granted, free of charge, to any person obtaining 5 | # a copy of this software and associated documentation files 6 | # (the "Software"), to deal in the Software without restriction, including 7 | # without limitation the rights to use, copy, modify, merge, publish, 8 | # distribute, sublicense, and/or sell copies of the Software, and to permit 9 | # persons to whom the Software is furnished to do so, subject to the 10 | # following conditions: 11 | # 12 | # The above copyright notice and this permission notice shall be 13 | # included in all copies or substantial portions of the Software. 14 | # 15 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF 16 | # ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED 17 | # TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A 18 | # PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT 19 | # SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR 20 | # ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN 21 | # ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 22 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE 23 | # OR OTHER DEALINGS IN THE SOFTWARE. 24 | # ------------------------------------------------------------------------------ 25 | from __future__ import unicode_literals 26 | import os 27 | 28 | import pytest 29 | 30 | from pyupdater.core.key_handler.keys import KeyImporter, Keys 31 | 32 | 33 | @pytest.mark.usefixtures("cleandir") 34 | class TestKeys(object): 35 | def test_create_keypack(self): 36 | k = Keys(test=True) 37 | for name in ["one", "two", "three"]: 38 | assert k.make_keypack(name) is True 39 | assert os.path.exists(k.data_dir) is True 40 | 41 | def test_key_importer(self): 42 | k = Keys(test=True) 43 | k.make_keypack("one") 44 | 45 | ki = KeyImporter() 46 | assert ki.start() is True 47 | 48 | def test_key_importer_fail(self): 49 | ki = KeyImporter() 50 | assert ki.start() is False 51 | -------------------------------------------------------------------------------- /tests/tconfig.py: -------------------------------------------------------------------------------- 1 | # ------------------------------------------------------------------------------ 2 | # Copyright (c) 2015-2019 Digital Sapphire 3 | # 4 | # Permission is hereby granted, free of charge, to any person obtaining 5 | # a copy of this software and associated documentation files 6 | # (the "Software"), to deal in the Software without restriction, including 7 | # without limitation the rights to use, copy, modify, merge, publish, 8 | # distribute, sublicense, and/or sell copies of the Software, and to permit 9 | # persons to whom the Software is furnished to do so, subject to the 10 | # following conditions: 11 | # 12 | # The above copyright notice and this permission notice shall be 13 | # included in all copies or substantial portions of the Software. 14 | # 15 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF 16 | # ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED 17 | # TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A 18 | # PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT 19 | # SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR 20 | # ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN 21 | # ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 22 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE 23 | # OR OTHER DEALINGS IN THE SOFTWARE. 24 | # ------------------------------------------------------------------------------ 25 | from __future__ import unicode_literals 26 | 27 | 28 | class TConfig(object): 29 | bad_attr = "bad attr" 30 | # If left None "Not_So_TUF" will be used 31 | APP_NAME = "Acme" 32 | 33 | COMPANY_NAME = "Digital" 34 | 35 | DATA_DIR = None 36 | 37 | # Public Key used by your app to verify update data 38 | # REQUIRED 39 | PUBLIC_KEY = "rRp4eJzzsPxN1nLXBOuLCqI33HWTridHKJpNnDSUlbU" 40 | 41 | # Online repository where you host your packages 42 | # and version file 43 | # REQUIRED 44 | UPDATE_URLS = ["https://s3-us-west-1.amazonaws.com/pyu-tester/"] 45 | UPDATE_PATCHES = True 46 | 47 | # Upload Setup 48 | REMOTE_DIR = None 49 | HOST = None 50 | 51 | # Tests seem to fail when this is True 52 | VERIFY_SERVER_CERT = True 53 | -------------------------------------------------------------------------------- /tests/data/version.json: -------------------------------------------------------------------------------- 1 | { 2 | "latest": { 3 | "Acme": { 4 | "stable": { 5 | "mac": "4.4.0.2.0" 6 | } 7 | } 8 | }, 9 | "signature": "xyRZyduSdlLS+9eCmfU/NJeCk9Ybr/bmKQ5GareDbf9/BIajHZevErcRsZi0oTKqKos4b/6fjcNbO9W112T+Ag", 10 | "updates": { 11 | "Acme": { 12 | "4.1.0.2.0": { 13 | "mac": { 14 | "file_hash": "7ade54611f1b632e8df7b6eb0a59f4eb5f4eaaf6d4d806a9b3b7cb7129214e6a", 15 | "file_size": 4997814, 16 | "filename": "Acme-mac-4.1.tar.gz" 17 | } 18 | }, 19 | "4.2.0.2.0": { 20 | "mac": { 21 | "file_hash": "7872d3b61026048e6b114a15819742ad00afb2395bcd35106d1b8dc42df00b14", 22 | "file_size": 4997728, 23 | "filename": "Acme-mac-4.2.tar.gz", 24 | "patch_hash": "70f3529a891af3145782722a6f5d39f8ea3d87b5ffebbc9d2110d230a49f840d", 25 | "patch_name": "Acme-mac-2", 26 | "patch_size": 1276614 27 | } 28 | }, 29 | "4.3.0.2.0": { 30 | "mac": { 31 | "file_hash": "7d3d3b110be99648d70e10ea63f6520e54071f241fb712d23b8f7abc3494c600", 32 | "file_size": 4997724, 33 | "filename": "Acme-mac-4.3.tar.gz", 34 | "patch_hash": "b74269e7433535aa0884eb69937c19bed133fdd7a112a853f66f5815a1bea803", 35 | "patch_name": "Acme-mac-3", 36 | "patch_size": 824443 37 | } 38 | }, 39 | "4.4.0.2.0": { 40 | "mac": { 41 | "file_hash": "4f583cdc3ef72f6a4876bdaae690aa634a3d6507211dea7b9e9f771563c1d8d4", 42 | "file_size": 4997724, 43 | "filename": "Acme-mac-4.4.tar.gz", 44 | "patch_hash": "025f443c98b58714e56d7aa9dbb174a28d0d5926942f1005c1a7ae7c5d2cde08", 45 | "patch_name": "Acme-mac-4", 46 | "patch_size": 612937 47 | } 48 | } 49 | } 50 | } 51 | } -------------------------------------------------------------------------------- /dev/namespace-test.py: -------------------------------------------------------------------------------- 1 | # ------------------------------------------------------------------------------ 2 | # Copyright (c) 2015-2020 Digital Sapphire 3 | # 4 | # Permission is hereby granted, free of charge, to any person obtaining 5 | # a copy of this software and associated documentation files 6 | # (the "Software"), to deal in the Software without restriction, including 7 | # without limitation the rights to use, copy, modify, merge, publish, 8 | # distribute, sublicense, and/or sell copies of the Software, and to permit 9 | # persons to whom the Software is furnished to do so, subject to the 10 | # following conditions: 11 | # 12 | # The above copyright notice and this permission notice shall be 13 | # included in all copies or substantial portions of the Software. 14 | # 15 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF 16 | # ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED 17 | # TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A 18 | # PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT 19 | # SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR 20 | # ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN 21 | # ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 22 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE 23 | # OR OTHER DEALINGS IN THE SOFTWARE. 24 | # ------------------------------------------------------------------------------ 25 | import logging 26 | import os 27 | 28 | from stevedore.extension import ExtensionManager 29 | 30 | log = logging.getLogger() 31 | log.setLevel(logging.DEBUG) 32 | sh = logging.StreamHandler() 33 | sh.setLevel(logging.DEBUG) 34 | log.addHandler(sh) 35 | 36 | namespace = "pyupdater.plugins" 37 | 38 | 39 | def upgrade_pip(): 40 | command = "./update.sh" 41 | log.debug("command: %s", command) 42 | os.system(command) 43 | 44 | 45 | def main(): 46 | # upgrade_pip() 47 | mgr = ExtensionManager(namespace) 48 | eps = mgr.ENTRY_POINT_CACHE 49 | log.debug("EP Cache: %s", eps) 50 | 51 | for ep in eps[namespace]: 52 | try: 53 | ep.load() 54 | log.info("Successful Plugin Load") 55 | except Exception as err: 56 | log.error(str(err), exc_info=True) 57 | 58 | log.info("Test Complete") 59 | 60 | 61 | main() 62 | -------------------------------------------------------------------------------- /tests/test_version_file.py: -------------------------------------------------------------------------------- 1 | # ------------------------------------------------------------------------------ 2 | # Copyright (c) 2015-2020 Digital Sapphire 3 | # 4 | # Permission is hereby granted, free of charge, to any person obtaining 5 | # a copy of this software and associated documentation files 6 | # (the "Software"), to deal in the Software without restriction, including 7 | # without limitation the rights to use, copy, modify, merge, publish, 8 | # distribute, sublicense, and/or sell copies of the Software, and to permit 9 | # persons to whom the Software is furnished to do so, subject to the 10 | # following conditions: 11 | # 12 | # The above copyright notice and this permission notice shall be 13 | # included in all copies or substantial portions of the Software. 14 | # 15 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF 16 | # ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED 17 | # TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A 18 | # PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT 19 | # SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR 20 | # ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN 21 | # ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 22 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE 23 | # OR OTHER DEALINGS IN THE SOFTWARE. 24 | # ------------------------------------------------------------------------------ 25 | from __future__ import unicode_literals 26 | 27 | import json 28 | 29 | from nacl.signing import VerifyKey 30 | import pytest 31 | 32 | from pyupdater.utils.encoding import UnpaddedBase64Encoder 33 | 34 | 35 | @pytest.mark.usefixtures("cleandir") 36 | class TestVersionFile(object): 37 | def test_signature(self, shared_datadir): 38 | json_raw = (shared_datadir / "version.json").read_text() 39 | version_data = json.loads(json_raw) 40 | 41 | sig = version_data["signature"] 42 | del version_data["signature"] 43 | 44 | data = json.dumps(version_data, sort_keys=True) 45 | 46 | version_data = bytes(data, "utf-8") 47 | 48 | jms_pub = (shared_datadir / "jms.pub").read_text() 49 | public_key = VerifyKey(jms_pub, UnpaddedBase64Encoder()) 50 | 51 | sig = UnpaddedBase64Encoder.decode(sig) 52 | public_key.verify(version_data, sig) 53 | -------------------------------------------------------------------------------- /docs/usage-client-asset.md: -------------------------------------------------------------------------------- 1 | # Usage | Client | Asset 2 | 3 | ### Step 1 - Imports & Constants 4 | 5 | The client_config.py is written to the root of the repository by default. 6 | ``` 7 | from pyupdater.client import Client 8 | from client_config import ClientConfig 9 | 10 | APP_NAME = 'Super App' 11 | APP_VERSION = '1.1.0' 12 | 13 | ASSET_NAME = 'ffmpeg' 14 | ASSET_VERSION = '2.3.2' 15 | ``` 16 | 17 | ### Step 2 - Create callback 18 | 19 | This callback will print download progress. 20 | ``` 21 | def print_status_info(info): 22 | total = info.get(u'total') 23 | downloaded = info.get(u'downloaded') 24 | status = info.get(u'status') 25 | print downloaded, total, status 26 | ``` 27 | 28 | ### Step 3a - Initialize Client 29 | 30 | Initialize a client with ClientConfig & later call refresh to get latest update data. You can also add progress hooks later. 31 | ``` 32 | client = Client(ClientConfig()) 33 | client.refresh() 34 | 35 | client.add_progress_hook(print_status_info) 36 | ``` 37 | 38 | ### Step 3b - Initialize Client Alt 39 | 40 | Initialize a client with ClientConfig, add progress hook & refresh during initialization. 41 | ``` 42 | client = Client(ClientConfig(), refresh=True, 43 | progress_hooks=[print_status_info]) 44 | ``` 45 | 46 | ### Step 4a - Update Check 47 | 48 | The update_check method returns an LibUpdate object if there is an update available. 49 | ``` 50 | lib_update = client.update_check(ASSET_NAME, ASSET_VERSION) 51 | ``` 52 | 53 | ### Step 4b - Update Check Alt 54 | 55 | Checking for updates on the beta channel. 56 | ``` 57 | lib_update = client.update_check(ASSET_NAME, ASSET_VERSION, channel='beta') 58 | ``` 59 | 60 | ### Step 5a - Download Update 61 | 62 | If we get an update object we can proceed to download the update. 63 | ``` 64 | if lib_update is not None: 65 | lib_update.download() 66 | ``` 67 | 68 | ### Step 5b - Download Update Alt 69 | 70 | We can also download in a background thread. 71 | ``` 72 | if lib_update is not None: 73 | lib_update.download(background=True) 74 | ``` 75 | 76 | ### Step 6a - Extract 77 | 78 | Ensure file downloaded successfully, extract update. 79 | 80 | ``` 81 | if lib_update.is_downloaded(): 82 | lib_update.extract() 83 | 84 | # Update will be extracted in the update folder 85 | lib_update.update_folder 86 | ``` 87 | -------------------------------------------------------------------------------- /tests/data/dont delete pyu test.txt: -------------------------------------------------------------------------------- 1 | kdkda;fkdj;afk;afkd;akdfj;afj;akdkda;fkdj;afk;afkd;akdfj;afj;akdkda;fkdj;afk;afkd;akdfj;afj;akdkda;fkdj;afk;afkd;akdfj;afj;akdkda;fkdj;afk;afkd;akdfj;afj;akdkda;fkdj;afk;afkd;akdfj;afj;akdkda;fkdj;afk;afkd;akdfj;afj;akdkda;fkdj;afk;afkd;akdfj;afj;akdkda;fkdj;afk;afkd;akdfj;afj;akdkda;fkdj;afk;afkd;akdfj;afj;akdkda;fkdj;afk;afkd;akdfj;afj;akdkda;fkdj;afk;afkd;akdfj;afj;akdkda;fkdj;afk;afkd;akdfj;afj;akdkda;fkdj;afk;afkd;akdfj;afj;akdkda;fkdj;afk;afkd;akdfj;afj;akdkda;fkdj;afk;afkd;akdfj;afj;akdkda;fkdj;afk;afkd;akdfj;afj;akdkda;fkdj;afk;afkd;akdfj;afj;akdkda;fkdj;afk;afkd;akdfj;afj;akdkda;fkdj;afk;afkd;akdfj;afj;akdkda;fkdj;afk;afkd;akdfj;afj;akdkda;fkdj;afk;afkd;akdfj;afj;akdkda;fkdj;afk;afkd;akdfj;afj;akdkda;fkdj;afk;afkd;akdfj;afj;akdkda;fkdj;afk;afkd;akdfj;afj;akdkda;fkdj;afk;afkd;akdfj;afj;akdkda;fkdj;afk;afkd;akdfj;afj;akdkda;fkdj;afk;afkd;akdfj;afj;akdkda;fkdj;afk;afkd;akdfj;afj;akdkda;fkdj;afk;afkd;akdfj;afj;akdkda;fkdj;afk;afkd;akdfj;afj;akdkda;fkdj;afk;afkd;akdfj;afj;akdkda;fkdj;afk;afkd;akdfj;afj;akdkda;fkdj;afk;afkd;akdfj;afj;akdkda;fkdj;afk;afkd;akdfj;afj;akdkda;fkdj;afk;afkd;akdfj;afj;akdkda;fkdj;afk;afkd;akdfj;afj;akdkda;fkdj;afk;afkd;akdfj;afj;akdkda;fkdj;afk;afkd;akdfj;afj;akdkda;fkdj;afk;afkd;akdfj;afj;akdkda;fkdj;afk;afkd;akdfj;afj;akdkda;fkdj;afk;afkd;akdfj;afj;akdkda;fkdj;afk;afkd;akdfj;afj;akdkda;fkdj;afk;afkd;akdfj;afj;akdkda;fkdj;afk;afkd;akdfj;afj;akdkda;fkdj;afk;afkd;akdfj;afj;akdkda;fkdj;afk;afkd;akdfj;afj;akdkda;fkdj;afk;afkd;akdfj;afj;akdkda;fkdj;afk;afkd;akdfj;afj;akdkda;fkdj;afk;afkd;akdfj;afj;akdkda;fkdj;afk;afkd;akdfj;afj;akdkda;fkdj;afk;afkd;akdfj;afj;akdkda;fkdj;afk;afkd;akdfj;afj;akdkda;fkdj;afk;afkd;akdfj;afj;akdkda;fkdj;afk;afkd;akdfj;afj;akdkda;fkdj;afk;afkd;akdfj;afj;akdkda;fkdj;afk;afkd;akdfj;afj;akdkda;fkdj;afk;afkd;akdfj;afj;akdkda;fkdj;afk;afkd;akdfj;afj;akdkda;fkdj;afk;afkd;akdfj;afj;akdkda;fkdj;afk;afkd;akdfj;afj;akdkda;fkdj;afk;afkd;akdfj;afj;akdkda;fkdj;afk;afkd;akdfj;afj;akdkda;fkdj;afk;afkd;akdfj;afj;akdkda;fkdj;afk;afkd;akdfj;afj;akdkda;fkdj;afk;afkd;akdfj;afj;akdkda;fkdj;afk;afkd;akdfj;afj;akdkda;fkdj;afk;afkd;akdfj;afj;akdkda;fkdj;afk;afkd;akdfj;afj;akdkda;fkdj;afk;afkd;akdfj;afj;akdkda;fkdj;afk;afkd;akdfj;afj;akdkda;fkdj;afk;afkd;akdfj;afj;akdkda;fkdj;afk;afkd;akdfj;afj;akdkda;fkdj;afk;afkd;akdfj;afj;akdkda;fkdj;afk;afkd;akdfj;afj;akdkda;fkdj;afk;afkd;akdfj;afj;akdkda;fkdj;afk;afkd;akdfj;afj;a -------------------------------------------------------------------------------- /dev/move.py: -------------------------------------------------------------------------------- 1 | # ------------------------------------------------------------------------------ 2 | # Copyright (c) 2015-2020 Digital Sapphire 3 | # 4 | # Permission is hereby granted, free of charge, to any person obtaining 5 | # a copy of this software and associated documentation files 6 | # (the "Software"), to deal in the Software without restriction, including 7 | # without limitation the rights to use, copy, modify, merge, publish, 8 | # distribute, sublicense, and/or sell copies of the Software, and to permit 9 | # persons to whom the Software is furnished to do so, subject to the 10 | # following conditions: 11 | # 12 | # The above copyright notice and this permission notice shall be 13 | # included in all copies or substantial portions of the Software. 14 | # 15 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF 16 | # ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED 17 | # TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A 18 | # PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT 19 | # SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR 20 | # ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN 21 | # ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 22 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE 23 | # OR OTHER DEALINGS IN THE SOFTWARE. 24 | # ------------------------------------------------------------------------------ 25 | from __future__ import print_function 26 | 27 | import os 28 | import shutil 29 | 30 | from dsdev_utils.paths import ChDir, remove_any 31 | 32 | HTML_DIR = os.path.join(os.getcwd(), "site") 33 | DEST_DIR = os.path.join( 34 | os.path.expanduser("~"), "src", "Web", "PyUpdater", "public_html" 35 | ) 36 | 37 | 38 | def main(): 39 | with ChDir(DEST_DIR): 40 | files = os.listdir(os.getcwd()) 41 | for f in files: 42 | remove_any(f) 43 | 44 | with ChDir(HTML_DIR): 45 | files = os.listdir(os.getcwd()) 46 | for f in files: 47 | if f.startswith(u"."): 48 | continue 49 | if f.startswith("__init__"): 50 | continue 51 | if os.path.isfile(f): 52 | shutil.copy(f, os.path.join(DEST_DIR, f)) 53 | elif os.path.isdir(f): 54 | shutil.copytree(f, DEST_DIR + os.sep + f) 55 | 56 | shutil.rmtree(HTML_DIR) 57 | 58 | 59 | if __name__ == "__main__": 60 | main() 61 | print(u"Move complete") 62 | -------------------------------------------------------------------------------- /tests/test_builder.py: -------------------------------------------------------------------------------- 1 | # ------------------------------------------------------------------------------ 2 | # Copyright (c) 2015-2020 Digital Sapphire 3 | # 4 | # Permission is hereby granted, free of charge, to any person obtaining 5 | # a copy of this software and associated documentation files 6 | # (the "Software"), to deal in the Software without restriction, including 7 | # without limitation the rights to use, copy, modify, merge, publish, 8 | # distribute, sublicense, and/or sell copies of the Software, and to permit 9 | # persons to whom the Software is furnished to do so, subject to the 10 | # following conditions: 11 | # 12 | # The above copyright notice and this permission notice shall be 13 | # included in all copies or substantial portions of the Software. 14 | # 15 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF 16 | # ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED 17 | # TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A 18 | # PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT 19 | # SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR 20 | # ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN 21 | # ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 22 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE 23 | # OR OTHER DEALINGS IN THE SOFTWARE. 24 | # ------------------------------------------------------------------------------ 25 | from __future__ import unicode_literals 26 | import io 27 | import os 28 | import sys 29 | 30 | from dsdev_utils.system import get_system 31 | import pytest 32 | 33 | from pyupdater.utils.builder import ExternalLib 34 | from pyupdater.utils.config import ConfigManager 35 | 36 | 37 | CONFIG = {"APP_NAME": "PyUpdater Test", "COMPANY_NAME": "ACME", "UPDATE_PATCHES": True} 38 | 39 | if sys.platform == "win32": 40 | EXT = ".zip" 41 | else: 42 | EXT = ".tar.gz" 43 | 44 | 45 | @pytest.mark.usefixtures("cleandir") 46 | class TestBuilder(object): 47 | def test_build(self): 48 | cm = ConfigManager() 49 | config = cm.load_config() 50 | config.update(CONFIG) 51 | cm.save_config(config) 52 | 53 | 54 | @pytest.mark.usefixtures("cleandir") 55 | class TestExternalLib(object): 56 | def test_archive(self): 57 | with io.open("test", "w", encoding="utf-8") as f: 58 | f.write("this is a test") 59 | ex = ExternalLib("test", "0.1") 60 | ex.archive() 61 | assert os.path.exists("test-{}-0.1{}".format(get_system(), EXT)) 62 | -------------------------------------------------------------------------------- /docs/usage-client.md: -------------------------------------------------------------------------------- 1 | # Usage | Client 2 | 3 | ### Step 1 - Imports & Constants 4 | The client_config.py is written to the root of the repository by default. 5 | ``` 6 | from pyupdater.client import Client 7 | from client_config import ClientConfig 8 | 9 | APP_NAME = 'Super App' 10 | APP_VERSION = '1.1.0' 11 | 12 | ASSET_NAME = 'ffmpeg' 13 | ASSET_VERSION = '2.3.2' 14 | ``` 15 | 16 | ### Step 2 - Create callback 17 | This callback will print download progress. 18 | ``` 19 | def print_status_info(info): 20 | total = info.get(u'total') 21 | downloaded = info.get(u'downloaded') 22 | status = info.get(u'status') 23 | print downloaded, total, status 24 | ``` 25 | 26 | ### Step 3a - Initialize Client 27 | 28 | Initialize a client with the ClientConfig & later call refresh to get latest update data. Progress hooks can also be added later. 29 | ``` 30 | client = Client(ClientConfig()) 31 | client.refresh() 32 | 33 | client.add_progress_hook(print_status_info) 34 | ``` 35 | 36 | ### Step 3b - Initialize Client Alt 37 | 38 | Initialize a client with the ClientConfig, add progress hook & refresh during initialization. 39 | ``` 40 | client = Client( 41 | ClientConfig(), refresh=True, progress_hooks=[print_status_info] 42 | ) 43 | ``` 44 | 45 | ### Step 4a - Update Check 46 | 47 | Method update_check returns an AppUpdate object if there is an update available else None 48 | ``` 49 | app_update = client.update_check(APP_NAME, APP_VERSION) 50 | ``` 51 | 52 | ### Step 4b - Update Check Alt 53 | 54 | Checking for updates on the beta channel 55 | ``` 56 | app_update = client.update_check(APP_NAME, APP_VERSION, channel='beta') 57 | ``` 58 | 59 | ### Step 5a - Download Update 60 | 61 | If an update object was returned, we can proceed to download the update. 62 | ``` 63 | if app_update is not None: 64 | app_update.download() 65 | ``` 66 | 67 | ### Step 5b - Download Update Alt 68 | 69 | We can also download in a background thread. 70 | ``` 71 | if app_update is not None: 72 | app_update.download(background=True) 73 | ``` 74 | 75 | ### Step 6a - Overwrite 76 | 77 | Ensure file downloaded successfully, extract update & overwrite current application. Note that is_downloaded verifies the files hash. 78 | 79 | ``` 80 | if app_update.is_downloaded(): 81 | app_update.extract_overwrite() 82 | ``` 83 | 84 | ### Step 6b - Restart 85 | 86 | Ensure file downloaded successfully, extract update, overwrite current application & restart application with the updated binary. 87 | 88 | ``` 89 | if app_update.is_downloaded(): 90 | app_update.extract_restart() 91 | ``` 92 | -------------------------------------------------------------------------------- /pyupdater/settings.py: -------------------------------------------------------------------------------- 1 | # ------------------------------------------------------------------------------ 2 | # Copyright (c) 2015-2019 Digital Sapphire 3 | # 4 | # Permission is hereby granted, free of charge, to any person obtaining 5 | # a copy of this software and associated documentation files 6 | # (the "Software"), to deal in the Software without restriction, including 7 | # without limitation the rights to use, copy, modify, merge, publish, 8 | # distribute, sublicense, and/or sell copies of the Software, and to permit 9 | # persons to whom the Software is furnished to do so, subject to the 10 | # following conditions: 11 | # 12 | # The above copyright notice and this permission notice shall be 13 | # included in all copies or substantial portions of the Software. 14 | # 15 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF 16 | # ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED 17 | # TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A 18 | # PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT 19 | # SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR 20 | # ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN 21 | # ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 22 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE 23 | # OR OTHER DEALINGS IN THE SOFTWARE. 24 | # ------------------------------------------------------------------------------ 25 | from __future__ import unicode_literals 26 | import struct 27 | import sys 28 | 29 | from dsdev_utils import system 30 | 31 | 32 | APP_NAME = "PyUpdater" 33 | APP_AUTHOR = "Digital Sapphire" 34 | 35 | # Used to hold PyUpdater config info for repo 36 | CONFIG_DATA_FOLDER = ".pyupdater" 37 | 38 | # User config file 39 | CONFIG_FILE_USER = "config.pyu" 40 | 41 | CONFIG_DB_KEY_APP_CONFIG = "app_config" 42 | CONFIG_DB_KEY_KEYPACK = "keypack" 43 | CONFIG_DB_KEY_VERSION_META = "version_meta" 44 | CONFIG_DB_KEY_PY_REPO_CONFIG = "py_repo_config" 45 | 46 | DEFAULT_CLIENT_CONFIG = ["client_config.py"] 47 | 48 | GENERIC_APP_NAME = "PyUpdater App" 49 | GENERIC_COMPANY_NAME = "PyUpdater" 50 | 51 | # Log filename 52 | LOG_FILENAME_DEBUG = "pyu-debug.log" 53 | 54 | # KeyFile 55 | KEYPACK_FILENAME = "keypack.pyu" 56 | 57 | # Main user visible data folder 58 | USER_DATA_FOLDER = "pyu-data" 59 | 60 | # Key in version file where value are update meta data 61 | UPDATES_KEY = "updates" 62 | 63 | # Folder on client system where updates are stored 64 | UPDATE_FOLDER = "update" 65 | 66 | # Name of version file in online repo 67 | VERSION_FILE_FILENAME = "versions-{}.gz".format(system.get_system()) 68 | VERSION_FILE_FILENAME_COMPAT = "versions.gz" 69 | KEY_FILE_FILENAME = "keys.gz" 70 | -------------------------------------------------------------------------------- /pyupdater/__init__.py: -------------------------------------------------------------------------------- 1 | # ------------------------------------------------------------------------------ 2 | # Copyright (c) 2015-2019 Digital Sapphire 3 | # 4 | # Permission is hereby granted, free of charge, to any person obtaining 5 | # a copy of this software and associated documentation files 6 | # (the "Software"), to deal in the Software without restriction, including 7 | # without limitation the rights to use, copy, modify, merge, publish, 8 | # distribute, sublicense, and/or sell copies of the Software, and to permit 9 | # persons to whom the Software is furnished to do so, subject to the 10 | # following conditions: 11 | # 12 | # The above copyright notice and this permission notice shall be 13 | # included in all copies or substantial portions of the Software. 14 | # 15 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF 16 | # ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED 17 | # TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A 18 | # PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT 19 | # SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR 20 | # ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN 21 | # ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 22 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE 23 | # OR OTHER DEALINGS IN THE SOFTWARE. 24 | # ------------------------------------------------------------------------------ 25 | import logging 26 | from logging.handlers import RotatingFileHandler 27 | import os 28 | 29 | from appdirs import user_log_dir 30 | from dsdev_utils.logger import logging_formatter 31 | 32 | from pyupdater import settings 33 | from pyupdater.core import PyUpdater 34 | 35 | __all__ = ["PyUpdater"] 36 | 37 | 38 | log = logging.getLogger(__name__) 39 | log.setLevel(logging.DEBUG) 40 | 41 | # Console logger 42 | fmt = logging.Formatter("[%(levelname)s] %(message)s") 43 | sh = logging.StreamHandler() 44 | sh.setLevel(logging.INFO) 45 | sh.setFormatter(fmt) 46 | log.addHandler(sh) 47 | 48 | # Log to pyu.log if available 49 | local_debug_file_path = os.path.join(os.getcwd(), "pyu.log") 50 | if os.path.exists(local_debug_file_path): # pragma: no cover 51 | fh = logging.FileHandler(local_debug_file_path) 52 | fh.setLevel(logging.DEBUG) 53 | fh.setFormatter(logging_formatter) 54 | log.addHandler(fh) 55 | 56 | # Default log directory 57 | LOG_DIR = user_log_dir(settings.APP_NAME, settings.APP_AUTHOR) 58 | if not os.path.exists(LOG_DIR): # pragma: no cover 59 | os.makedirs(LOG_DIR) 60 | 61 | log_file = os.path.join(LOG_DIR, settings.LOG_FILENAME_DEBUG) 62 | rfh = RotatingFileHandler(log_file, maxBytes=1048576, backupCount=2) 63 | rfh.setLevel(logging.DEBUG) 64 | rfh.setFormatter(logging_formatter) 65 | log.addHandler(rfh) 66 | 67 | # noinspection PyPep8 68 | from ._version import get_versions 69 | 70 | __version__ = get_versions()["version"] 71 | del get_versions 72 | log.debug("Version - %s", __version__) 73 | -------------------------------------------------------------------------------- /dev/clean.py: -------------------------------------------------------------------------------- 1 | # ------------------------------------------------------------------------------ 2 | # Copyright (c) 2015-2020 Digital Sapphire 3 | # 4 | # Permission is hereby granted, free of charge, to any person obtaining 5 | # a copy of this software and associated documentation files 6 | # (the "Software"), to deal in the Software without restriction, including 7 | # without limitation the rights to use, copy, modify, merge, publish, 8 | # distribute, sublicense, and/or sell copies of the Software, and to permit 9 | # persons to whom the Software is furnished to do so, subject to the 10 | # following conditions: 11 | # 12 | # The above copyright notice and this permission notice shall be 13 | # included in all copies or substantial portions of the Software. 14 | # 15 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF 16 | # ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED 17 | # TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A 18 | # PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT 19 | # SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR 20 | # ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN 21 | # ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 22 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE 23 | # OR OTHER DEALINGS IN THE SOFTWARE. 24 | # ------------------------------------------------------------------------------ 25 | import shutil 26 | import os 27 | 28 | # If clean.py is moved from dev dir please update 29 | HOME = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) 30 | 31 | 32 | print("Start dir: {}".format(HOME)) 33 | 34 | 35 | def check_x(x): 36 | def bad_file(f): 37 | ext = os.path.splitext(f)[1] 38 | if ext == ".pyc": 39 | return True 40 | basename = os.path.basename(f) 41 | if basename.startswith(".coverage."): 42 | return True 43 | return False 44 | 45 | def bad_dir(d): 46 | basename = os.path.basename(d) 47 | bad = ["__pycache__", "htmlcov", "build", "dist", "PyUpdater.egg-info"] 48 | if basename in bad: 49 | return True 50 | return False 51 | 52 | if os.path.isfile(x): 53 | if bad_file(x) is True: 54 | remove(x) 55 | if os.path.isdir(x): 56 | if bad_dir(x) is True: 57 | remove(x) 58 | 59 | 60 | def remove(x): 61 | removed = False 62 | if os.path.isfile(x): 63 | removed = True 64 | os.remove(x) 65 | if os.path.isdir(x): 66 | removed = True 67 | shutil.rmtree(x, ignore_errors=True) 68 | if removed is True: 69 | print("Removed {}".format(x)) 70 | 71 | 72 | def main(): 73 | for root, dirs, files in os.walk(HOME): 74 | for f in files: 75 | path = os.path.abspath(os.path.join(root, f)) 76 | check_x(path) 77 | for d in dirs: 78 | path = os.path.abspath(os.path.join(root, d)) 79 | check_x(path) 80 | 81 | 82 | if __name__ == "__main__": 83 | main() 84 | -------------------------------------------------------------------------------- /tests/test_config.py: -------------------------------------------------------------------------------- 1 | # ------------------------------------------------------------------------------ 2 | # Copyright (c) 2015-2020 Digital Sapphire 3 | # 4 | # Permission is hereby granted, free of charge, to any person obtaining 5 | # a copy of this software and associated documentation files 6 | # (the "Software"), to deal in the Software without restriction, including 7 | # without limitation the rights to use, copy, modify, merge, publish, 8 | # distribute, sublicense, and/or sell copies of the Software, and to permit 9 | # persons to whom the Software is furnished to do so, subject to the 10 | # following conditions: 11 | # 12 | # The above copyright notice and this permission notice shall be 13 | # included in all copies or substantial portions of the Software. 14 | # 15 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF 16 | # ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED 17 | # TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A 18 | # PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT 19 | # SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR 20 | # ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN 21 | # ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 22 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE 23 | # OR OTHER DEALINGS IN THE SOFTWARE. 24 | # ------------------------------------------------------------------------------ 25 | from __future__ import unicode_literals 26 | 27 | import os 28 | 29 | from pyupdater.utils.config import Config, ConfigManager 30 | 31 | 32 | class DevConfig(object): 33 | TESTING = True 34 | TEST_LOVE = True 35 | MORE_INFO = "No Thanks" 36 | Bad_Attr = True 37 | 38 | 39 | class ProdConfig(object): 40 | TESTING = False 41 | DEBUG = False 42 | MORE_INFO = "Yes Please" 43 | 44 | 45 | class BasicCofig(object): 46 | APP_NAME = "Tester" 47 | COMPANY_NAME = "Test App LLC" 48 | UPDATE_URLS = "http://acme.com/updates" 49 | PUBLIC_KEYS = "838d88df8adkld8s9s" 50 | 51 | 52 | def test_dev_config(): 53 | config = Config() 54 | test_config = DevConfig() 55 | config.from_object(test_config) 56 | assert config["TESTING"] is True 57 | 58 | 59 | def test_dev_config_bad_attr(): 60 | config = Config() 61 | test_config = DevConfig() 62 | config.from_object(test_config) 63 | assert config.get("BAD_ATTR", None) is None 64 | 65 | 66 | def test_prod_config(): 67 | config = Config() 68 | prod_config = ProdConfig() 69 | config.from_object(prod_config) 70 | assert config["MORE_INFO"] == "Yes Please" 71 | 72 | 73 | def test_prod_bad_atter(): 74 | config = Config() 75 | prod_config = ProdConfig() 76 | config.from_object(prod_config) 77 | assert config.get("DEBUG", None) is not None 78 | 79 | 80 | def test_write_config(cleandir): 81 | config = Config() 82 | prod_config = ProdConfig() 83 | config.from_object(prod_config) 84 | cm = ConfigManager() 85 | cm.write_config_py(config) 86 | assert "client_config.py" in os.listdir(os.getcwd()) 87 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | # ------------------------------------------------------------------------------ 2 | # Copyright (c) 2015-2020 Digital Sapphire 3 | # 4 | # Permission is hereby granted, free of charge, to any person obtaining 5 | # a copy of this software and associated documentation files 6 | # (the "Software"), to deal in the Software without restriction, including 7 | # without limitation the rights to use, copy, modify, merge, publish, 8 | # distribute, sublicense, and/or sell copies of the Software, and to permit 9 | # persons to whom the Software is furnished to do so, subject to the 10 | # following conditions: 11 | # 12 | # The above copyright notice and this permission notice shall be 13 | # included in all copies or substantial portions of the Software. 14 | # 15 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF 16 | # ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED 17 | # TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A 18 | # PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT 19 | # SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR 20 | # ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN 21 | # ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 22 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE 23 | # OR OTHER DEALINGS IN THE SOFTWARE. 24 | # ------------------------------------------------------------------------------ 25 | from setuptools import find_packages, setup 26 | 27 | import versioneer 28 | 29 | KEYWORDS = ( 30 | "PyUpdater Pyinstaller Auto Update AutoUpdate Auto-Update Esky " 31 | "updater4pyi bbfreeze ccfreeze freeze cz_freeze pyupdate" 32 | ) 33 | 34 | 35 | with open(u"requirements.txt", u"r") as f: 36 | required = f.read().splitlines() 37 | 38 | 39 | with open("README.md", "r") as f: 40 | readme = f.read() 41 | 42 | 43 | extra_s3 = "PyUpdater-s3-Plugin >= 4.0.5" 44 | extra_scp = "PyUpdater-scp-Plugin >= 4.0" 45 | 46 | 47 | setup( 48 | name="PyUpdater", 49 | version=versioneer.get_version(), 50 | description="Python Auto Update Library for Pyinstaller", 51 | long_description=readme, 52 | long_description_content_type="text/markdown", 53 | author="Digital Sapphire", 54 | author_email="oss@digitalsapphire.io", 55 | url="https://www.pyupdater.org", 56 | download_url=("https://github.com/Digital-Sapphire/PyUpdater/archive/master.zip"), 57 | license="MIT", 58 | keywords=KEYWORDS, 59 | extras_require={"s3": extra_s3, "scp": extra_scp, "all": [extra_s3, extra_scp]}, 60 | zip_safe=False, 61 | include_package_data=True, 62 | tests_require=["pytest"], 63 | cmdclass=versioneer.get_cmdclass(), 64 | install_requires=required, 65 | packages=find_packages(), 66 | entry_points=""" 67 | [console_scripts] 68 | pyupdater=pyupdater.cli:main 69 | """, 70 | classifiers=[ 71 | "Development Status :: 5 - Production/Stable", 72 | "Environment :: Console", 73 | "Intended Audience :: Developers", 74 | "License :: OSI Approved :: MIT License", 75 | "Operating System :: OS Independent", 76 | "Programming Language :: Python :: 3 :: Only", 77 | ], 78 | ) 79 | -------------------------------------------------------------------------------- /tests/data/update_repo_extract/app_extract_01.py: -------------------------------------------------------------------------------- 1 | # ------------------------------------------------------------------------------ 2 | # Copyright (c) 2015-2020 Digital Sapphire 3 | # 4 | # Permission is hereby granted, free of charge, to any person obtaining 5 | # a copy of this software and associated documentation files 6 | # (the "Software"), to deal in the Software without restriction, including 7 | # without limitation the rights to use, copy, modify, merge, publish, 8 | # distribute, sublicense, and/or sell copies of the Software, and to permit 9 | # persons to whom the Software is furnished to do so, subject to the 10 | # following conditions: 11 | # 12 | # The above copyright notice and this permission notice shall be 13 | # included in all copies or substantial portions of the Software. 14 | # 15 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF 16 | # ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED 17 | # TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A 18 | # PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT 19 | # SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR 20 | # ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN 21 | # ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 22 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE 23 | # OR OTHER DEALINGS IN THE SOFTWARE. 24 | # ------------------------------------------------------------------------------ 25 | from __future__ import print_function 26 | import os 27 | import sys 28 | 29 | from pyupdater.client import Client 30 | from dsdev_utils.paths import get_mac_dot_app_dir 31 | 32 | import client_config 33 | 34 | APPNAME = "Acme" 35 | VERSION = "4.1" 36 | 37 | 38 | def cb(status): 39 | out = "" 40 | percent = status.get("percent_complete") 41 | time = status.get("time") 42 | if percent is not None: 43 | out += "{}%".format(percent) 44 | if time is not None: 45 | out += " - {}".format(time) 46 | if len(out) == 0: 47 | out = "Nothing yet..." 48 | sys.stdout.write("\r{}".format(out)) 49 | sys.stdout.flush() 50 | 51 | 52 | def main(): 53 | print(VERSION) 54 | data_dir = None 55 | config = client_config.ClientConfig() 56 | if getattr(config, "USE_CUSTOM_DIR", False): 57 | if sys.platform == "darwin" and os.path.dirname(sys.executable).endswith( 58 | "MacOS" 59 | ): 60 | data_dir = os.path.join( 61 | get_mac_dot_app_dir(os.path.dirname(sys.executable)), ".update" 62 | ) 63 | else: 64 | data_dir = os.path.join( 65 | os.path.dirname(os.path.dirname(sys.executable)), ".update" 66 | ) 67 | client = Client(config, refresh=True, progress_hooks=[cb], data_dir=data_dir) 68 | update = client.update_check(APPNAME, VERSION) 69 | if update is not None: 70 | success = update.download() 71 | print("") 72 | if success is True: 73 | print("Update download successful") 74 | print("Extracting & overwriting") 75 | update.extract_overwrite() 76 | else: 77 | print("Failed to download update") 78 | return VERSION 79 | 80 | 81 | if __name__ == "__main__": 82 | main() 83 | -------------------------------------------------------------------------------- /tests/data/update_repo_extract/app_extract_onedir.py: -------------------------------------------------------------------------------- 1 | # ------------------------------------------------------------------------------ 2 | # Copyright (c) 2015-2020 Digital Sapphire 3 | # 4 | # Permission is hereby granted, free of charge, to any person obtaining 5 | # a copy of this software and associated documentation files 6 | # (the "Software"), to deal in the Software without restriction, including 7 | # without limitation the rights to use, copy, modify, merge, publish, 8 | # distribute, sublicense, and/or sell copies of the Software, and to permit 9 | # persons to whom the Software is furnished to do so, subject to the 10 | # following conditions: 11 | # 12 | # The above copyright notice and this permission notice shall be 13 | # included in all copies or substantial portions of the Software. 14 | # 15 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF 16 | # ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED 17 | # TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A 18 | # PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT 19 | # SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR 20 | # ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN 21 | # ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 22 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE 23 | # OR OTHER DEALINGS IN THE SOFTWARE. 24 | # ------------------------------------------------------------------------------ 25 | from __future__ import print_function 26 | import os 27 | import sys 28 | 29 | from pyupdater.client import Client 30 | from dsdev_utils.paths import get_mac_dot_app_dir 31 | 32 | import client_config 33 | 34 | APPNAME = "Acme" 35 | VERSION = "4.1" 36 | 37 | 38 | def cb(status): 39 | out = "" 40 | percent = status.get("percent_complete") 41 | time = status.get("time") 42 | if percent is not None: 43 | out += "{}%".format(percent) 44 | if time is not None: 45 | out += " - {}".format(time) 46 | if len(out) == 0: 47 | out = "Nothing yet..." 48 | sys.stdout.write("\r{}".format(out)) 49 | sys.stdout.flush() 50 | 51 | 52 | def main(): 53 | print(VERSION) 54 | data_dir = None 55 | config = client_config.ClientConfig() 56 | if getattr(config, "USE_CUSTOM_DIR", False): 57 | if sys.platform == "darwin" and os.path.dirname(sys.executable).endswith( 58 | "MacOS" 59 | ): 60 | data_dir = os.path.join( 61 | get_mac_dot_app_dir(os.path.dirname(sys.executable)), ".update" 62 | ) 63 | else: 64 | data_dir = os.path.join( 65 | os.path.dirname(os.path.dirname(sys.executable)), ".update" 66 | ) 67 | client = Client(config, refresh=True, progress_hooks=[cb], data_dir=data_dir) 68 | update = client.update_check(APPNAME, VERSION) 69 | if update is not None: 70 | success = update.download() 71 | print("") 72 | if success is True: 73 | print("Update download successful") 74 | print("Extracting & overwriting") 75 | update.extract_overwrite() 76 | else: 77 | print("Failed to download update") 78 | return VERSION 79 | 80 | 81 | if __name__ == "__main__": 82 | main() 83 | -------------------------------------------------------------------------------- /tests/data/update_repo_restart/app_restart_01.py: -------------------------------------------------------------------------------- 1 | # ------------------------------------------------------------------------------ 2 | # Copyright (c) 2015-2020 Digital Sapphire 3 | # 4 | # Permission is hereby granted, free of charge, to any person obtaining 5 | # a copy of this software and associated documentation files 6 | # (the "Software"), to deal in the Software without restriction, including 7 | # without limitation the rights to use, copy, modify, merge, publish, 8 | # distribute, sublicense, and/or sell copies of the Software, and to permit 9 | # persons to whom the Software is furnished to do so, subject to the 10 | # following conditions: 11 | # 12 | # The above copyright notice and this permission notice shall be 13 | # included in all copies or substantial portions of the Software. 14 | # 15 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF 16 | # ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED 17 | # TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A 18 | # PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT 19 | # SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR 20 | # ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN 21 | # ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 22 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE 23 | # OR OTHER DEALINGS IN THE SOFTWARE. 24 | # ------------------------------------------------------------------------------ 25 | from __future__ import print_function 26 | import os 27 | import sys 28 | 29 | from pyupdater.client import Client 30 | from dsdev_utils.paths import get_mac_dot_app_dir 31 | 32 | import client_config 33 | 34 | APPNAME = "Acme" 35 | VERSION = "4.1" 36 | 37 | 38 | def cb(status): 39 | out = "" 40 | percent = status.get("percent_complete") 41 | time = status.get("time") 42 | if percent is not None: 43 | out += "{}%".format(percent) 44 | if time is not None: 45 | out += " - {}".format(time) 46 | if len(out) == 0: 47 | out = "Nothing yet..." 48 | sys.stdout.write("\r{}".format(out)) 49 | sys.stdout.flush() 50 | 51 | 52 | def main(): 53 | print(VERSION) 54 | data_dir = None 55 | config = client_config.ClientConfig() 56 | if getattr(config, "USE_CUSTOM_DIR", False): 57 | print("Using custom directory") 58 | if sys.platform == "darwin" and os.path.dirname(sys.executable).endswith( 59 | "MacOS" 60 | ): 61 | data_dir = os.path.join( 62 | get_mac_dot_app_dir(os.path.dirname(sys.executable)), ".update" 63 | ) 64 | else: 65 | data_dir = os.path.join(os.path.dirname(sys.executable), ".update") 66 | client = Client(config, refresh=True, progress_hooks=[cb], data_dir=data_dir) 67 | update = client.update_check(APPNAME, VERSION) 68 | if update is not None: 69 | print("We have an update") 70 | retry_count = 0 71 | while retry_count < 5: 72 | success = update.download() 73 | if success is True: 74 | break 75 | print("Retry Download. Count {}".format(retry_count + 1)) 76 | retry_count += 1 77 | 78 | if success: 79 | print("Update download successful") 80 | print("Restarting") 81 | update.extract_restart() 82 | else: 83 | print("Failed to download update") 84 | 85 | print("Leaving main()") 86 | 87 | 88 | if __name__ == "__main__": 89 | main() 90 | -------------------------------------------------------------------------------- /pyupdater/utils/pyinstaller_compat.py: -------------------------------------------------------------------------------- 1 | # ------------------------------------------------------------------------------ 2 | # Copyright (c) 2015-2020 Digital Sapphire 3 | # 4 | # Permission is hereby granted, free of charge, to any person obtaining 5 | # a copy of this software and associated documentation files 6 | # (the "Software"), to deal in the Software without restriction, including 7 | # without limitation the rights to use, copy, modify, merge, publish, 8 | # distribute, sublicense, and/or sell copies of the Software, and to permit 9 | # persons to whom the Software is furnished to do so, subject to the 10 | # following conditions: 11 | # 12 | # The above copyright notice and this permission notice shall be 13 | # included in all copies or substantial portions of the Software. 14 | # 15 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF 16 | # ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED 17 | # TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A 18 | # PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT 19 | # SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR 20 | # ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN 21 | # ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 22 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE 23 | # OR OTHER DEALINGS IN THE SOFTWARE. 24 | # ------------------------------------------------------------------------------ 25 | import argparse 26 | import logging 27 | import os 28 | 29 | try: 30 | from PyInstaller import __version__ as pyi_version 31 | except ImportError: 32 | pyi_version = "0.0" 33 | from PyInstaller.building import makespec as _pyi_makespec 34 | from PyInstaller import compat as _pyi_compat 35 | from PyInstaller import log as _pyi_log 36 | 37 | 38 | log = logging.getLogger(__name__) 39 | 40 | 41 | def pyi_makespec(pyi_args): # pragma: no cover 42 | """Wrapper to configure make_spec for multipule pyinstaller versions""" 43 | 44 | def run_makespec(_args): 45 | """Setup args & run make_spec command""" 46 | # Split pathex by using the path separator 47 | temppaths = _args.pathex[:] 48 | _args.pathex = [] 49 | for p in temppaths: 50 | _args.pathex.extend(p.split(os.pathsep)) 51 | 52 | # Fix for issue #4 - Not searching cwd for modules 53 | _args.pathex.insert(0, os.getcwd()) 54 | 55 | spec_file = _pyi_makespec.main(_args.scriptname, **vars(_args)) 56 | log.debug("wrote %s", spec_file) 57 | 58 | parser = argparse.ArgumentParser() 59 | # We are hacking into pyinstaller here & are aware of the risks 60 | # using noqa below so landscape.io will ignore it 61 | _pyi_makespec.__add_options(parser) # noqa 62 | _pyi_log.__add_options(parser) # noqa 63 | if hasattr(_pyi_compat, "__add_obsolete_options"): 64 | _pyi_compat.__add_obsolete_options(parser) # noqa 65 | # End hacking 66 | parser.add_argument("scriptname", nargs="+") 67 | 68 | args = parser.parse_args(pyi_args) 69 | 70 | # We call init because it loads logger into the global 71 | # namespace of the Pyinstaller.log module. logger is used 72 | # in the Pyinstaller.log.__process_options call 73 | if hasattr(_pyi_log, "init"): 74 | _pyi_log.init() 75 | # We are hacking into pyinstaller here & are aware of the risks 76 | # using noqa below so landscape.io will ignore it 77 | _pyi_log.__process_options(parser, args) # noqa 78 | # End hacking 79 | 80 | run_makespec(args) 81 | 82 | return True 83 | -------------------------------------------------------------------------------- /tests/data/update_repo_restart/app_restart_onedir.py: -------------------------------------------------------------------------------- 1 | # ------------------------------------------------------------------------------ 2 | # Copyright (c) 2015-2020 Digital Sapphire 3 | # 4 | # Permission is hereby granted, free of charge, to any person obtaining 5 | # a copy of this software and associated documentation files 6 | # (the "Software"), to deal in the Software without restriction, including 7 | # without limitation the rights to use, copy, modify, merge, publish, 8 | # distribute, sublicense, and/or sell copies of the Software, and to permit 9 | # persons to whom the Software is furnished to do so, subject to the 10 | # following conditions: 11 | # 12 | # The above copyright notice and this permission notice shall be 13 | # included in all copies or substantial portions of the Software. 14 | # 15 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF 16 | # ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED 17 | # TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A 18 | # PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT 19 | # SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR 20 | # ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN 21 | # ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 22 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE 23 | # OR OTHER DEALINGS IN THE SOFTWARE. 24 | # ------------------------------------------------------------------------------ 25 | from __future__ import print_function 26 | import os 27 | import sys 28 | 29 | from pyupdater.client import Client 30 | from dsdev_utils.paths import get_mac_dot_app_dir 31 | 32 | import client_config 33 | 34 | APPNAME = "Acme" 35 | VERSION = "4.1" 36 | 37 | 38 | def cb(status): 39 | out = "" 40 | percent = status.get("percent_complete") 41 | time = status.get("time") 42 | if percent is not None: 43 | out += "{}%".format(percent) 44 | if time is not None: 45 | out += " - {}".format(time) 46 | if len(out) == 0: 47 | out = "Nothing yet..." 48 | sys.stdout.write("\r{}".format(out)) 49 | sys.stdout.flush() 50 | 51 | 52 | def main(): 53 | print(VERSION) 54 | data_dir = None 55 | config = client_config.ClientConfig() 56 | if getattr(config, "USE_CUSTOM_DIR", False): 57 | print("Using custom directory") 58 | if sys.platform == "darwin" and os.path.dirname(sys.executable).endswith( 59 | "MacOS" 60 | ): 61 | data_dir = os.path.join( 62 | get_mac_dot_app_dir(os.path.dirname(sys.executable)), ".update" 63 | ) 64 | else: 65 | data_dir = os.path.join( 66 | os.path.dirname(os.path.dirname(sys.executable)), ".update" 67 | ) 68 | client = Client(config, refresh=True, progress_hooks=[cb], data_dir=data_dir) 69 | update = client.update_check(APPNAME, VERSION) 70 | if update is not None: 71 | print("We have an update") 72 | retry_count = 0 73 | while retry_count < 5: 74 | success = update.download() 75 | if success is True: 76 | break 77 | print("Retry Download. Count {}".format(retry_count + 1)) 78 | retry_count += 1 79 | 80 | if success: 81 | print("Update download successful") 82 | print("Restarting") 83 | update.extract_restart() 84 | else: 85 | print("Failed to download update") 86 | 87 | print("Leaving main()") 88 | 89 | 90 | if __name__ == "__main__": 91 | main() 92 | -------------------------------------------------------------------------------- /pyupdater/cli/__init__.py: -------------------------------------------------------------------------------- 1 | # ------------------------------------------------------------------------------ 2 | # Copyright (c) 2015-2020 Digital Sapphire 3 | # 4 | # Permission is hereby granted, free of charge, to any person obtaining 5 | # a copy of this software and associated documentation files 6 | # (the "Software"), to deal in the Software without restriction, including 7 | # without limitation the rights to use, copy, modify, merge, publish, 8 | # distribute, sublicense, and/or sell copies of the Software, and to permit 9 | # persons to whom the Software is furnished to do so, subject to the 10 | # following conditions: 11 | # 12 | # The above copyright notice and this permission notice shall be 13 | # included in all copies or substantial portions of the Software. 14 | # 15 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF 16 | # ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED 17 | # TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A 18 | # PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT 19 | # SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR 20 | # ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN 21 | # ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 22 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE 23 | # OR OTHER DEALINGS IN THE SOFTWARE. 24 | # ------------------------------------------------------------------------------ 25 | from __future__ import unicode_literals 26 | import logging 27 | import sys 28 | 29 | from appdirs import user_log_dir 30 | 31 | from pyupdater import __version__, settings 32 | from pyupdater.cli import commands 33 | from pyupdater.cli.options import get_parser 34 | 35 | 36 | logging.getLogger("dsdev_utils").setLevel(logging.ERROR) 37 | 38 | log = logging.getLogger(__name__) 39 | 40 | # The collect_debug_info command will use this 41 | commands.LOG_DIR = user_log_dir(settings.APP_NAME, settings.APP_AUTHOR) 42 | 43 | 44 | def _real_main(args, namespace_test_helper=None): # pragma: no cover 45 | pyi_args = None 46 | if args is None: 47 | args = sys.argv[1:] 48 | 49 | if namespace_test_helper is None: 50 | parser = get_parser() 51 | args, pyi_args = parser.parse_known_args(args) 52 | else: 53 | # Used for tests 54 | args = namespace_test_helper 55 | dispatch_command(args, pyi_args) 56 | 57 | 58 | # Dispatch the passed in command to its respective function in 59 | # pyupdater.cli.commands 60 | def dispatch_command(args, pyi_args=None, test=False): 61 | # Turns collect-debug-info into collect_debug_info 62 | cmd_str = "_cmd_" + args.command.replace("-", "_") 63 | if hasattr(commands, cmd_str): 64 | cmd = getattr(commands, cmd_str) 65 | # We are just making sure we can load the function 66 | if test: 67 | return True 68 | cmd(args, pyi_args) 69 | else: 70 | # This should only get hit by misconfigured tests. 71 | # "Should" being the key word here :) 72 | log.error("Unknown Command: %s", cmd_str) 73 | return False 74 | 75 | 76 | def main(args=None): # pragma: no cover 77 | log.info("PyUpdater %s", __version__) 78 | try: 79 | _real_main(args) 80 | except KeyboardInterrupt: 81 | # Someones quick on the draw 82 | print("\n") 83 | msg = "Exited by user" 84 | log.warning(msg) 85 | except Exception as err: 86 | print(err) 87 | log.error(err) 88 | log.debug(err, exc_info=True) 89 | 90 | 91 | if __name__ == "__main__": # pragma: no cover 92 | main(sys.argv[1:]) 93 | -------------------------------------------------------------------------------- /docs/usage-client-advanced.md: -------------------------------------------------------------------------------- 1 | # Usage | Client | Advanced 2 | PyUpdater supports 3 release channels. Release channels are specified when providing a version number to the --app-version flag. Patches are only created for the stable channel. Examples below. 3 | 4 | ### Example - Update check 5 | 6 | Examples below of specifying channels when checking for updates: 7 | ``` 8 | # Requesting updates from the beta channel 9 | app_update = client.update_check(APP_NAME, APP_VERSION, channel='beta') 10 | 11 | # If no channel is specified, stable will be used 12 | app_update = client.update_check(APP_NAME, APP_VERSION) 13 | ``` 14 | 15 | Example of background download: 16 | ``` 17 | app_update = client.update_check(APP_NAME, APP_VERSION) 18 | if app_update: 19 | app_update.download(background=True) 20 | 21 | # To check the status of the download 22 | # Returns a boolean 23 | app_update.is_downloaded() 24 | ``` 25 | 26 | Examples of setting one or more callbacks. PyUpdater calls set() on the list of plugins to ensure duplicates are not added. 27 | ``` 28 | # Progress hooks get passed a dict with the below keys. 29 | # total: total file size 30 | # downloaded: data received so far 31 | # status: will show either downloading or finished 32 | # percent_complete: Percentage of file downloaded so far 33 | # time: Time left to complete download 34 | 35 | def progress(data): 36 | print('Time remaining'.format(data['time'])) 37 | 38 | def log_progress(data): 39 | log.debug('Total file size %s', data['total']) 40 | 41 | 42 | # You can initialize the client with a callbacks 43 | client = Client(ClientConfig(), progress_hooks=[progress, log_progress]) 44 | 45 | # Or you can add them later. 46 | client = Client(ClientConfig()) 47 | client.add_progress_hook(log_progress) 48 | client.add_progress_hook(progress) 49 | ``` 50 | 51 | ### Using basic authentication 52 | 53 | Basic authentication is an easy way to prevent unauthorized people from downloading your app from your update server. 54 | 55 | Once you've configured your web server to require basic authentication from clients accessing your update repository, modify your update client code like below. 56 | 57 | Other headers can be sent in the same way. 58 | ``` 59 | headers = {'basic_auth': 'user:pass'} 60 | client = Client(ClientConfig(), headers=headers) 61 | ``` 62 | 63 | 64 | ### Use your own file downloader 65 | PyUpdater was originally written to work with servers that provide a directory listing. 66 | For services which expose an API to retrieve files, you can use a custom downloader. 67 | Your custom downloader must have the same signature as the MyDownloader class below. 68 | 69 | Do note that your file downloader will not always be given a hexdigest kwarg. In those 70 | cases skip hex verification. 71 | 72 | ```python 73 | from pyupdater.client import Client, DefaultClientConfig 74 | 75 | 76 | class MyDownloader: 77 | 78 | def __init__(self, filename, urls, **kwargs): 79 | self.filename = filename 80 | self.urls = urls 81 | self.hexdigest = kwargs.get("hexdigest") 82 | 83 | self._data = None 84 | 85 | def download_verify_return(self): 86 | # Download the data from the endpoint and return 87 | return self._data 88 | 89 | def download_verify_write(self): 90 | # Write the downloaded data to the current dir 91 | try: 92 | with open(self.filename, 'wb') as f: 93 | f.write(self._data) 94 | return True 95 | except: 96 | return False 97 | 98 | client = Client(DefaultClientConfig(), downloader=MyDownloader) 99 | 100 | ``` 101 | -------------------------------------------------------------------------------- /pyupdater/core/__init__.py: -------------------------------------------------------------------------------- 1 | # ------------------------------------------------------------------------------ 2 | # Copyright (c) 2015-2020 Digital Sapphire 3 | # 4 | # Permission is hereby granted, free of charge, to any person obtaining 5 | # a copy of this software and associated documentation files 6 | # (the "Software"), to deal in the Software without restriction, including 7 | # without limitation the rights to use, copy, modify, merge, publish, 8 | # distribute, sublicense, and/or sell copies of the Software, and to permit 9 | # persons to whom the Software is furnished to do so, subject to the 10 | # following conditions: 11 | # 12 | # The above copyright notice and this permission notice shall be 13 | # included in all copies or substantial portions of the Software. 14 | # 15 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF 16 | # ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED 17 | # TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A 18 | # PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT 19 | # SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR 20 | # ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN 21 | # ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 22 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE 23 | # OR OTHER DEALINGS IN THE SOFTWARE. 24 | # ------------------------------------------------------------------------------ 25 | from __future__ import unicode_literals 26 | import os 27 | 28 | from .key_handler import KeyHandler 29 | from .key_handler.keys import KeyImporter 30 | from .package_handler import PackageHandler 31 | from .uploader import Uploader 32 | from pyupdater.utils.config import Config 33 | 34 | 35 | class PyUpdater(object): 36 | """Processes, signs & uploads updates 37 | 38 | Kwargs: 39 | 40 | config (obj): config object 41 | """ 42 | 43 | def __init__(self, config=None): 44 | self.config = Config() 45 | # Important to keep this before updating config 46 | if config is not None: 47 | self.update_config(config) 48 | 49 | def update_config(self, config): 50 | """Updates internal config 51 | 52 | Args: 53 | 54 | config (obj): config object 55 | """ 56 | if not hasattr(config, "DATA_DIR"): 57 | config.DATA_DIR = None 58 | if config.DATA_DIR is None: 59 | config.DATA_DIR = os.getcwd() 60 | self.config.from_object(config) 61 | self._update(self.config) 62 | 63 | def _update(self, config): 64 | self.kh = KeyHandler() 65 | self.key_importer = KeyImporter() 66 | self.ph = PackageHandler(config) 67 | self.up = Uploader(config) 68 | 69 | def setup(self): 70 | """Sets up root dir with required PyUpdater folders""" 71 | self.ph.setup() 72 | 73 | def process_packages(self, report_errors=False): 74 | """Creates hash for updates & adds information about update to 75 | version file 76 | """ 77 | self.ph.process_packages(report_errors) 78 | 79 | def set_uploader(self, requested_uploader, keep=False): 80 | """Sets upload destination 81 | 82 | Args: 83 | 84 | requested_uploader (str): upload service. i.e. s3, scp 85 | 86 | keep (bool): False to delete files after upload. 87 | True to keep files. Default False. 88 | 89 | """ 90 | self.up.set_uploader(requested_uploader, keep) 91 | 92 | def upload(self): 93 | """Uploads files in deploy folder""" 94 | return self.up.upload() 95 | 96 | def get_plugin_names(self): 97 | return self.up.get_plugin_names() 98 | 99 | def import_keypack(self): 100 | """Creates signing keys""" 101 | return self.key_importer.start() 102 | 103 | def sign_update(self, split_version): 104 | """Signs version file with signing key""" 105 | self.kh.sign_update(split_version) 106 | -------------------------------------------------------------------------------- /pyupdater/utils/storage.py: -------------------------------------------------------------------------------- 1 | # ------------------------------------------------------------------------------ 2 | # Copyright (c) 2015-2020 Digital Sapphire 3 | # 4 | # Permission is hereby granted, free of charge, to any person obtaining 5 | # a copy of this software and associated documentation files 6 | # (the "Software"), to deal in the Software without restriction, including 7 | # without limitation the rights to use, copy, modify, merge, publish, 8 | # distribute, sublicense, and/or sell copies of the Software, and to permit 9 | # persons to whom the Software is furnished to do so, subject to the 10 | # following conditions: 11 | # 12 | # The above copyright notice and this permission notice shall be 13 | # included in all copies or substantial portions of the Software. 14 | # 15 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF 16 | # ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED 17 | # TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A 18 | # PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT 19 | # SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR 20 | # ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN 21 | # ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 22 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE 23 | # OR OTHER DEALINGS IN THE SOFTWARE. 24 | # ------------------------------------------------------------------------------ 25 | from __future__ import print_function, unicode_literals 26 | import logging 27 | import os 28 | 29 | from pyupdater import settings 30 | from pyupdater.utils import JSONStore 31 | 32 | log = logging.getLogger(__name__) 33 | 34 | 35 | # Used by KeyHandler, PackageHandler & Config to 36 | # store data in a json file 37 | class Storage(object): 38 | def __init__(self): 39 | """Loads & saves config file to file-system.""" 40 | self.config_dir = os.path.join(os.getcwd(), settings.CONFIG_DATA_FOLDER) 41 | if not os.path.exists(self.config_dir): 42 | log.debug("Creating config dir") 43 | os.mkdir(self.config_dir) 44 | log.debug("Config Dir: %s", self.config_dir) 45 | self.filename = os.path.join(self.config_dir, settings.CONFIG_FILE_USER) 46 | log.debug("Config DB: %s", self.filename) 47 | self.db = JSONStore(self.filename) 48 | self.count = 0 49 | self._load_db() 50 | 51 | def __getattr__(self, name): 52 | return self.__class__.__dict__.get(name) 53 | 54 | def __setattr__(self, name, value): 55 | setattr(self.__class__, name, value) 56 | 57 | def __delattr__(self, name): 58 | raise AttributeError("Cannot delete attributes!") 59 | 60 | def __getitem__(self, name): 61 | try: 62 | return self.__class__.__dict__[name] 63 | except KeyError: 64 | return self.__dict__[name] 65 | 66 | def __setitem__(self, name, value): 67 | setattr(Storage, name, value) 68 | 69 | def _load_db(self): 70 | """Loads database into memory.""" 71 | for k, v in self.db: 72 | setattr(Storage, k, v) 73 | 74 | def save(self, key, value): 75 | """Saves key & value to database 76 | 77 | Args: 78 | 79 | key (str): used to retrieve value from database 80 | 81 | value (obj): python object to store in database 82 | 83 | """ 84 | setattr(Storage, key, value) 85 | for k, v in Storage.__dict__.items(): 86 | self.db[k] = v 87 | log.debug("Syncing db to filesystem") 88 | self.db.sync() 89 | 90 | def load(self, key): 91 | """Loads value for given key 92 | 93 | Args: 94 | 95 | key (str): The key associated with the value you want 96 | form the database. 97 | 98 | Returns: 99 | 100 | Object if exists or else None 101 | """ 102 | return self.__class__.__dict__.get(key) 103 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | # ------------------------------------------------------------------------------ 2 | # Copyright (c) 2015-2020 Digital Sapphire 3 | # 4 | # Permission is hereby granted, free of charge, to any person obtaining 5 | # a copy of this software and associated documentation files 6 | # (the "Software"), to deal in the Software without restriction, including 7 | # without limitation the rights to use, copy, modify, merge, publish, 8 | # distribute, sublicense, and/or sell copies of the Software, and to permit 9 | # persons to whom the Software is furnished to do so, subject to the 10 | # following conditions: 11 | # 12 | # The above copyright notice and this permission notice shall be 13 | # included in all copies or substantial portions of the Software. 14 | # 15 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF 16 | # ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED 17 | # TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A 18 | # PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT 19 | # SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR 20 | # ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN 21 | # ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 22 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE 23 | # OR OTHER DEALINGS IN THE SOFTWARE. 24 | # ------------------------------------------------------------------------------ 25 | import os 26 | import tempfile 27 | import threading 28 | 29 | from http.server import SimpleHTTPRequestHandler as RequestHandler 30 | 31 | import socketserver as SocketServer 32 | 33 | import pytest 34 | 35 | from pyupdater import PyUpdater 36 | from pyupdater.cli.options import make_parser 37 | from pyupdater.client import Client 38 | from pyupdater.core.key_handler.keys import Keys 39 | from pyupdater.utils.config import ConfigManager 40 | from pyupdater.utils.storage import Storage 41 | from tconfig import TConfig 42 | 43 | 44 | @pytest.fixture 45 | def cleandir(): 46 | new_path = tempfile.mkdtemp() 47 | os.chdir(new_path) 48 | 49 | 50 | @pytest.fixture 51 | def client(): 52 | t_config = TConfig() 53 | t_config.DATA_DIR = os.getcwd() 54 | client = Client(t_config, refresh=True, test=True) 55 | client.FROZEN = True 56 | return client 57 | 58 | 59 | @pytest.fixture 60 | def create_keypack(): 61 | keys = Keys(test=True) 62 | keys.make_keypack("test") 63 | 64 | 65 | @pytest.fixture 66 | def db(): 67 | db = Storage() 68 | return db 69 | 70 | 71 | @pytest.fixture 72 | def loader(): 73 | default_config = { 74 | "APP_NAME": "PyUpdater Test", 75 | "COMPANY_NAME": "ACME", 76 | "UPDATE_PATCHES": True, 77 | } 78 | 79 | cm = ConfigManager() 80 | config = cm.load_config() 81 | config.update(default_config) 82 | cm.save_config(config) 83 | return config 84 | 85 | 86 | @pytest.fixture 87 | def parser(): 88 | parser = make_parser() 89 | return parser 90 | 91 | 92 | @pytest.fixture 93 | def pyu(): 94 | t_config = TConfig() 95 | t_config.DATA_DIR = os.getcwd() 96 | pyu = PyUpdater(t_config) 97 | return pyu 98 | 99 | 100 | @pytest.fixture 101 | def simpleserver(): 102 | class Server(object): 103 | def __init__(self): 104 | self.count = 0 105 | self._server = None 106 | 107 | def start(self, port=None): 108 | if port is None: 109 | raise ValueError("Port cannot be None.") 110 | self.count += 1 111 | if self._server is not None: 112 | return 113 | SocketServer.TCPServer.allow_reuse_address = True 114 | httpd = SocketServer.TCPServer(("", port), RequestHandler) 115 | 116 | self._server = threading.Thread(target=httpd.serve_forever) 117 | self._server.daemon = True 118 | self._server.start() 119 | 120 | def stop(self): 121 | self.count -= 1 122 | if self._server is not None and self.count == 0: 123 | self._server.alive = False 124 | self._server = None 125 | 126 | return Server() 127 | -------------------------------------------------------------------------------- /docs/usage-cli.md: -------------------------------------------------------------------------------- 1 | # Usage | CLI 2 | 3 | ### Step 1 - Create Keypack 4 | 5 | Create a keypack on an air-gapped computer, not your dev machine, for best security. You'll be asked for the name of the application. It's important that you use the same name when creating a new keypack for the same application. If you don't your application will not be able to verify update meta-data and will not auto-update! 6 | 7 | ``` 8 | $ pyupdater keys -c 9 | ``` 10 | 11 | ### Step 2 - Copy Keypack 12 | 13 | Copy the keypack to the dev machine & place in the root of the code repository. 14 | 15 | ```bash 16 | $ scp dev-machine:keypack.pyu . 17 | ``` 18 | 19 | ### Step 3 - Init Repo 20 | 21 | In the root of your repo run the init command. You'll be asked a few questions about your application then 2 directories & a config file will be created. The first directory is pyu-data which is used as a working directory for PyUpdater. The other is a hidden directory for repository configuration and data files. 22 | 23 | ``` 24 | $ pyupdater init 25 | ``` 26 | 27 | ### Step 4 - Import Keypack 28 | 29 | Make sure the keypack file is in the root the repo. After you've successfully imported the keypack it's safe to delete. 30 | 31 | ``` 32 | $ pyupdater keys -i 33 | ``` 34 | 35 | ### Step 5 - Integrate PyUpdater 36 | 37 | [See Usage | Client](usage-client.md) 38 | 39 | ### Step 6 - Make Spec 40 | 41 | Is your app more of the demanding type? Generate your spec file with PyUpdater then make edits as necessary. If you do not need a custom spec file skip to the next step. Please see the spec-file section in the [Danger Zone](danger-zone.md). 42 | 43 | ``` 44 | $ pyupdater make-spec -w main.py 45 | 46 | # or 47 | 48 | $ pyupdater make-spec -F -w main.py 49 | 50 | # To show pyinstaller build info use --pyinstaller-log-info 51 | ``` 52 | 53 | ### Step 7 - Build 54 | 55 | You have two options when building. You can specify a spec file or a main script. Please see the version numbers section in the [Danger Zone](danger-zone.md). 56 | 57 | ``` 58 | # Build from a spec file. 59 | $ pyupdater build --app-version=1.0.0 main.spec 60 | 61 | # Build from a script. 62 | $ pyupdater build --app-version=1.0.0 main.py 63 | 64 | # To show pyinstaller build info use --pyinstaller-log-info 65 | ``` 66 | 67 | ### Step 8 - Create patches 68 | 69 | Gets sha256 hashes, file sizes, adds meta-data to version manifest and copies files to deploy folder. If a source files is available, patches will also be created. You can combine --process with --sign. 70 | 71 | ``` 72 | $ pyupdater pkg --process 73 | ``` 74 | 75 | ### Step 9 - Cryptographically Sign 76 | 77 | Now lets sign & gzip our version manifest, gzip our keyfile & place both in the deploy directory. Note that the signing process works without any user intervention. You can combine --sign with --process 78 | 79 | ``` 80 | $ pyupdater pkg --sign 81 | 82 | # For CI/CD 83 | $ pyupdater pkg --sign --split-version 84 | ``` 85 | 86 | ### Step 10 - List Installed Plugins 87 | 88 | Get a list of the plugins you have installed. You probably wont have to run this many times :) 89 | 90 | ``` 91 | # You can install the official plugins with 92 | # pip install pyupdater[s3, scp] 93 | $ pyupdater plugins 94 | 95 | s3 by Digital Sapphire 96 | scp by Digital Sapphire 97 | 98 | ``` 99 | 100 | ### Step 11 - Configure Plugin 101 | 102 | Your plugin may or may not need configuration. If you are not sure then please check the documentation for that plugin. 103 | 104 | ``` 105 | $ pyupdater settings --plugin s3 106 | ``` 107 | 108 | ### Step 12 - Upload 109 | 110 | We've made it. Time to upload our updates, patches & metadata. You won't have any patches to upload on the first run. Don't worry they'll be there next time. 111 | 112 | ``` 113 | $ pyupdater upload --service s3 114 | ``` 115 | 116 | ### Debugging Issues 117 | 118 | Sometimes issues arise like misconfigured upload URLs or keys. To debug issues that involve PyUpdater, create the file `pyu.log` in the root of your app. 119 | 120 | PyUpdater will log debug statements to this file and hopefully provide you with information to assist with your debugging. 121 | -------------------------------------------------------------------------------- /pyupdater/utils/exceptions.py: -------------------------------------------------------------------------------- 1 | # ------------------------------------------------------------------------------ 2 | # Copyright (c) 2015-2020 Digital Sapphire 3 | # 4 | # Permission is hereby granted, free of charge, to any person obtaining 5 | # a copy of this software and associated documentation files 6 | # (the "Software"), to deal in the Software without restriction, including 7 | # without limitation the rights to use, copy, modify, merge, publish, 8 | # distribute, sublicense, and/or sell copies of the Software, and to permit 9 | # persons to whom the Software is furnished to do so, subject to the 10 | # following conditions: 11 | # 12 | # The above copyright notice and this permission notice shall be 13 | # included in all copies or substantial portions of the Software. 14 | # 15 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF 16 | # ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED 17 | # TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A 18 | # PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT 19 | # SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR 20 | # ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN 21 | # ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 22 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE 23 | # OR OTHER DEALINGS IN THE SOFTWARE. 24 | # ------------------------------------------------------------------------------ 25 | from __future__ import unicode_literals 26 | import sys 27 | import traceback 28 | 29 | 30 | class STDError(Exception): 31 | """Extends exceptions to show added message if error isn't expected. 32 | 33 | Args: 34 | 35 | msg (str): error message 36 | 37 | Kwargs: 38 | 39 | tb (obj): is the original traceback so that it can be printed. 40 | 41 | expected (bool): 42 | 43 | Meaning: 44 | 45 | True - Report issue msg not shown 46 | 47 | False - Report issue msg shown 48 | """ 49 | 50 | def __init__(self, msg, tb=None, expected=False): 51 | if expected is False: 52 | msg = msg + ( 53 | "; please report this issue on https://github.com" 54 | "/Digital-Sapphire/PyUpdater/issues" 55 | ) 56 | super(STDError, self).__init__(msg) 57 | 58 | self.traceback = tb 59 | self.exc_info = sys.exc_info() # preserve original exception 60 | 61 | def format_traceback(self): # pragma: no cover 62 | if self.traceback is None: 63 | return None 64 | return "".join(traceback.format_tb(self.traceback)) 65 | 66 | 67 | class ClientError(STDError): 68 | """Raised for Client exceptions""" 69 | 70 | def __init__(self, *args, **kwargs): 71 | super(ClientError, self).__init__(*args, **kwargs) 72 | 73 | 74 | class FileDownloaderError(STDError): 75 | def __init__(self, *args, **kwargs): 76 | super(FileDownloaderError, self).__init__(*args, **kwargs) 77 | 78 | 79 | class KeyHandlerError(STDError): 80 | def __init__(self, *args, **kwargs): 81 | super(KeyHandlerError, self).__init__(*args, **kwargs) 82 | 83 | 84 | class PackageHandlerError(STDError): 85 | """Raised for PackageHandler exceptions""" 86 | 87 | def __init__(self, *args, **kwargs): 88 | super(PackageHandlerError, self).__init__(*args, **kwargs) 89 | 90 | 91 | class PatcherError(STDError): 92 | """Raised for Patcher exceptions""" 93 | 94 | def __init__(self, *args, **kwargs): 95 | super(PatcherError, self).__init__(*args, **kwargs) 96 | 97 | 98 | class UploaderError(STDError): 99 | """Raised for Uploader exceptions""" 100 | 101 | def __init__(self, *args, **kwargs): 102 | super(UploaderError, self).__init__(*args, **kwargs) 103 | 104 | 105 | class UploaderPluginError(STDError): 106 | """Raised for Uploader exceptions""" 107 | 108 | def __init__(self, *args, **kwargs): 109 | super(UploaderPluginError, self).__init__(*args, **kwargs) 110 | 111 | 112 | class UtilsError(STDError): 113 | """Raised for Utils exceptions""" 114 | 115 | def __init__(self, *args, **kwargs): 116 | super(UtilsError, self).__init__(*args, **kwargs) 117 | -------------------------------------------------------------------------------- /tests/test_uploader.py: -------------------------------------------------------------------------------- 1 | # ------------------------------------------------------------------------------ 2 | # Copyright (c) 2015-2020 Digital Sapphire 3 | # 4 | # Permission is hereby granted, free of charge, to any person obtaining 5 | # a copy of this software and associated documentation files 6 | # (the "Software"), to deal in the Software without restriction, including 7 | # without limitation the rights to use, copy, modify, merge, publish, 8 | # distribute, sublicense, and/or sell copies of the Software, and to permit 9 | # persons to whom the Software is furnished to do so, subject to the 10 | # following conditions: 11 | # 12 | # The above copyright notice and this permission notice shall be 13 | # included in all copies or substantial portions of the Software. 14 | # 15 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF 16 | # ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED 17 | # TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A 18 | # PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT 19 | # SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR 20 | # ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN 21 | # ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 22 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE 23 | # OR OTHER DEALINGS IN THE SOFTWARE. 24 | # ------------------------------------------------------------------------------ 25 | import os 26 | import sys 27 | 28 | from dsdev_utils.paths import ChDir 29 | import pytest 30 | 31 | from pyupdater.core.uploader import BaseUploader, Uploader 32 | from pyupdater.utils.config import Config 33 | 34 | 35 | class BaseTestUploader(BaseUploader): 36 | def init_config(self, config): 37 | pass 38 | 39 | def set_config(self, config): 40 | pass 41 | 42 | 43 | class TestUploaderPass(BaseTestUploader): 44 | 45 | name = "success" 46 | author = "Digital Sapphire" 47 | 48 | def upload_file(self, filename): 49 | print(filename) 50 | return True 51 | 52 | 53 | class TestUploaderFail(BaseTestUploader): 54 | 55 | name = "fail" 56 | author = "Digital Sapphire" 57 | 58 | def upload_file(self, filename): 59 | print(filename) 60 | return False 61 | 62 | 63 | class TestUploaderRetryPass(BaseTestUploader): 64 | 65 | name = "retry-success" 66 | author = "Digital Sapphire" 67 | first_pass = False 68 | 69 | def upload_file(self, filename): 70 | print(filename) 71 | out = self.first_pass 72 | self.first_pass = True 73 | return out 74 | 75 | 76 | class TestUploaderRetryFail(BaseTestUploader): 77 | 78 | name = "retry-fail" 79 | author = "Digital Sapphire" 80 | 81 | def upload_file(self, filename): 82 | print(filename) 83 | return False 84 | 85 | 86 | class TestUploader(object): 87 | @pytest.mark.parametrize( 88 | "upload_plugin_type", ["success", "fail", "retry-success", "retry-fail"] 89 | ) 90 | def test_uploader(self, cleandir, shared_datadir, upload_plugin_type): 91 | 92 | uploaders = [ 93 | TestUploaderPass(), 94 | TestUploaderFail(), 95 | TestUploaderRetryPass(), 96 | TestUploaderRetryFail(), 97 | ] 98 | 99 | # Don't mind the name, should only contain 1 plugin 100 | upload_plugins = [u for u in uploaders if u.name == upload_plugin_type] 101 | 102 | if sys.version_info[1] == 5: 103 | data_dir = str(shared_datadir) 104 | else: 105 | data_dir = shared_datadir 106 | 107 | if not os.path.exists(data_dir): 108 | os.makedirs(data_dir) 109 | 110 | with ChDir(data_dir): 111 | uploader = Uploader(config=Config(), plugins=upload_plugins) 112 | 113 | upload_plugin = upload_plugins[0] 114 | 115 | uploader.set_uploader(upload_plugin_type) 116 | assert uploader.uploader.name == upload_plugin_type 117 | 118 | if "success" in upload_plugin.name: 119 | assert uploader.upload(["/file1"]) is True 120 | elif "fail" in upload_plugin.name: 121 | assert uploader.upload(["/file2"]) is False 122 | -------------------------------------------------------------------------------- /tests/data/update_repo_extract/build_onedir_extract.py: -------------------------------------------------------------------------------- 1 | # ------------------------------------------------------------------------------ 2 | # Copyright (c) 2015-2020 Digital Sapphire 3 | # 4 | # Permission is hereby granted, free of charge, to any person obtaining 5 | # a copy of this software and associated documentation files 6 | # (the "Software"), to deal in the Software without restriction, including 7 | # without limitation the rights to use, copy, modify, merge, publish, 8 | # distribute, sublicense, and/or sell copies of the Software, and to permit 9 | # persons to whom the Software is furnished to do so, subject to the 10 | # following conditions: 11 | # 12 | # The above copyright notice and this permission notice shall be 13 | # included in all copies or substantial portions of the Software. 14 | # 15 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF 16 | # ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED 17 | # TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A 18 | # PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT 19 | # SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR 20 | # ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN 21 | # ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 22 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE 23 | # OR OTHER DEALINGS IN THE SOFTWARE. 24 | # ------------------------------------------------------------------------------ 25 | import logging 26 | import os 27 | import re 28 | import sys 29 | import tarfile 30 | import zipfile 31 | 32 | from dsdev_utils.system import get_system 33 | 34 | log = logging.getLogger() 35 | 36 | 37 | home_dir = os.path.dirname(os.path.abspath(__file__)) 38 | 39 | 40 | def build(app): 41 | os.environ["PYINSTALLER_CONFIG_DIR"] = os.path.join(home_dir, ".cache") 42 | cmd = "pyupdater build -D --clean {} --path={} " "--app-version={} {}".format( 43 | app[2], home_dir, app[1], app[0] 44 | ) 45 | os.system(cmd) 46 | 47 | 48 | def extract(filename): 49 | ext = os.path.splitext(filename)[1] 50 | if ext == ".zip": 51 | archive = zipfile.ZipFile(filename, "r") 52 | else: 53 | archive = tarfile.open(filename, "r:gz") 54 | 55 | archive.extractall() 56 | 57 | 58 | def main(use_custom_dir, port, windowed, split_version): 59 | cmd1 = "pyupdater pkg -P" 60 | cmd2 = "pyupdater pkg -S" 61 | 62 | if split_version: 63 | cmd2 += " --split-version" 64 | 65 | scripts = [ 66 | ("app_extract_onedir.py", "4.1", "--windowed" if windowed else ""), 67 | ("app_extract_02.py", "4.2", "--windowed" if windowed else ""), 68 | ] 69 | 70 | # We use this flag to untar & move our binary to the 71 | # current working directory 72 | first = True 73 | # patch config_file for custom port number 74 | config_file = open("client_config.py", "rt").read() 75 | config_file = re.sub(r"localhost:\d+", "localhost:%s" % port, config_file) 76 | 77 | # patch config_file for use_custom_dir 78 | if use_custom_dir: 79 | config_file += "\n USE_CUSTOM_DIR = True\n" 80 | open("client_config.py", "wt").write(config_file) 81 | for s in scripts: 82 | build(s) 83 | if first: 84 | if sys.platform == "win32": 85 | ext = ".zip" 86 | else: 87 | ext = ".tar.gz" 88 | 89 | # Build path to archive 90 | archive_path = os.path.join( 91 | "pyu-data", "new", "Acme-{}-4.1{}".format(get_system(), ext) 92 | ) 93 | 94 | if not os.path.exists(archive_path): 95 | print("Archive did not build!") 96 | sys.exit(1) 97 | 98 | # We extract the Acme binary here. When we call pyupdater pkg -P 99 | # the Acme binary will be moved to the deploy folder. In our test 100 | # (test_pyupdater.TestExecution.test_execution_update_*) we 101 | # move all of the files from the deploy directory to the cwd 102 | # of the test runner. 103 | extract(archive_path) 104 | 105 | first = False 106 | 107 | os.system(cmd1) 108 | 109 | os.system(cmd2) 110 | 111 | 112 | if __name__ == "__main__": 113 | if len(sys.argv) != 5: 114 | print( 115 | "usage: %s " % sys.argv[0] 116 | ) 117 | else: 118 | main( 119 | sys.argv[1] == "True", 120 | sys.argv[2], 121 | sys.argv[3] == "True", 122 | sys.argv[4] == "True", 123 | ) 124 | -------------------------------------------------------------------------------- /tests/data/update_repo_extract/build_onefile_extract.py: -------------------------------------------------------------------------------- 1 | # ------------------------------------------------------------------------------ 2 | # Copyright (c) 2015-2020 Digital Sapphire 3 | # 4 | # Permission is hereby granted, free of charge, to any person obtaining 5 | # a copy of this software and associated documentation files 6 | # (the "Software"), to deal in the Software without restriction, including 7 | # without limitation the rights to use, copy, modify, merge, publish, 8 | # distribute, sublicense, and/or sell copies of the Software, and to permit 9 | # persons to whom the Software is furnished to do so, subject to the 10 | # following conditions: 11 | # 12 | # The above copyright notice and this permission notice shall be 13 | # included in all copies or substantial portions of the Software. 14 | # 15 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF 16 | # ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED 17 | # TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A 18 | # PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT 19 | # SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR 20 | # ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN 21 | # ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 22 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE 23 | # OR OTHER DEALINGS IN THE SOFTWARE. 24 | # ------------------------------------------------------------------------------ 25 | import logging 26 | import os 27 | import re 28 | import sys 29 | import tarfile 30 | import zipfile 31 | 32 | from dsdev_utils.system import get_system 33 | 34 | log = logging.getLogger() 35 | 36 | 37 | home_dir = os.path.dirname(os.path.abspath(__file__)) 38 | 39 | 40 | def build(app): 41 | os.environ["PYINSTALLER_CONFIG_DIR"] = os.path.join(home_dir, ".cache") 42 | cmd = "pyupdater build -F {} --clean --path={} " "--app-version={} {}".format( 43 | app[2], home_dir, app[1], app[0] 44 | ) 45 | os.system(cmd) 46 | 47 | 48 | def extract(filename): 49 | ext = os.path.splitext(filename)[1] 50 | if ext == ".zip": 51 | archive = zipfile.ZipFile(filename, "r") 52 | else: 53 | archive = tarfile.open(filename, "r:gz") 54 | 55 | archive.extractall() 56 | 57 | 58 | def main(use_custom_dir, port, windowed, split_version): 59 | cmd1 = "pyupdater pkg -P" 60 | cmd2 = "pyupdater pkg -S" 61 | 62 | if split_version: 63 | cmd2 += " --split-version" 64 | 65 | scripts = [ 66 | ("app_extract_01.py", "4.1", "--windowed" if windowed else ""), 67 | ("app_extract_02.py", "4.2", "--windowed" if windowed else ""), 68 | ] 69 | 70 | # We use this flag to untar & move our binary to the 71 | # current working directory 72 | first = True 73 | # patch config_file for custom port number 74 | config_file = open("client_config.py", "rt").read() 75 | config_file = re.sub(r"localhost:\d+", "localhost:%s" % port, config_file) 76 | 77 | # patch config_file for use_custom_dir 78 | if use_custom_dir: 79 | config_file += "\n USE_CUSTOM_DIR = True\n" 80 | open("client_config.py", "wt").write(config_file) 81 | for s in scripts: 82 | build(s) 83 | if first: 84 | if sys.platform == "win32": 85 | ext = ".zip" 86 | else: 87 | ext = ".tar.gz" 88 | 89 | # Build path to archive 90 | archive_path = os.path.join( 91 | "pyu-data", "new", "Acme-{}-4.1{}".format(get_system(), ext) 92 | ) 93 | 94 | if not os.path.exists(archive_path): 95 | print("Archive did not build!") 96 | sys.exit(1) 97 | 98 | # We extract the Acme binary here. When we call pyupdater pkg -P 99 | # the Acme binary will be moved to the deploy folder. In our test 100 | # (test_pyupdater.TestExecution.test_execution_update_*) we 101 | # move all of the files from the deploy directory to the cwd 102 | # of the test runner. 103 | extract(archive_path) 104 | 105 | first = False 106 | 107 | os.system(cmd1) 108 | 109 | os.system(cmd2) 110 | 111 | 112 | if __name__ == "__main__": 113 | if len(sys.argv) != 5: 114 | print( 115 | "usage: %s " % sys.argv[0] 116 | ) 117 | else: 118 | main( 119 | sys.argv[1] == "True", 120 | sys.argv[2], 121 | sys.argv[3] == "True", 122 | sys.argv[4] == "True", 123 | ) 124 | -------------------------------------------------------------------------------- /tests/data/update_repo_restart/build_onefile_restart.py: -------------------------------------------------------------------------------- 1 | # ------------------------------------------------------------------------------ 2 | # Copyright (c) 2015-2020 Digital Sapphire 3 | # 4 | # Permission is hereby granted, free of charge, to any person obtaining 5 | # a copy of this software and associated documentation files 6 | # (the "Software"), to deal in the Software without restriction, including 7 | # without limitation the rights to use, copy, modify, merge, publish, 8 | # distribute, sublicense, and/or sell copies of the Software, and to permit 9 | # persons to whom the Software is furnished to do so, subject to the 10 | # following conditions: 11 | # 12 | # The above copyright notice and this permission notice shall be 13 | # included in all copies or substantial portions of the Software. 14 | # 15 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF 16 | # ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED 17 | # TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A 18 | # PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT 19 | # SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR 20 | # ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN 21 | # ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 22 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE 23 | # OR OTHER DEALINGS IN THE SOFTWARE. 24 | # ------------------------------------------------------------------------------ 25 | import logging 26 | import os 27 | import re 28 | import sys 29 | import tarfile 30 | import zipfile 31 | 32 | from dsdev_utils.system import get_system 33 | 34 | log = logging.getLogger() 35 | 36 | 37 | home_dir = os.path.dirname(os.path.abspath(__file__)) 38 | 39 | 40 | def build(app): 41 | # Pyinstaller's --clean is not 'multiprocessing safe', 42 | # let's use our own cache 43 | os.environ["PYINSTALLER_CONFIG_DIR"] = os.path.join(home_dir, ".cache") 44 | cmd = "pyupdater build -F {} --clean --path={} " "--app-version={} {}".format( 45 | app[2], home_dir, app[1], app[0] 46 | ) 47 | os.system(cmd) 48 | 49 | 50 | def extract(filename): 51 | ext = os.path.splitext(filename)[1] 52 | if ext == ".zip": 53 | archive = zipfile.ZipFile(filename, "r") 54 | else: 55 | archive = tarfile.open(filename, "r:gz") 56 | 57 | archive.extractall() 58 | 59 | 60 | def main(use_custom_dir, port, windowed, split_version): 61 | cmd1 = "pyupdater pkg -P" 62 | cmd2 = "pyupdater pkg -S" 63 | 64 | if split_version: 65 | cmd2 += " --split-version" 66 | 67 | scripts = [ 68 | ("app_restart_01.py", "4.1", "--windowed" if windowed else ""), 69 | ("app_restart_02.py", "4.2", "--windowed" if windowed else ""), 70 | ] 71 | 72 | # We use this flag to untar & move our binary to the 73 | # current working directory 74 | first = True 75 | # patch config_file for custom port number 76 | config_file = open("client_config.py", "rt").read() 77 | config_file = re.sub(r"localhost:\d+", "localhost:%s" % port, config_file) 78 | # patch config_file for use_custom_dir 79 | if use_custom_dir: 80 | config_file += "\n USE_CUSTOM_DIR = True\n" 81 | open("client_config.py", "wt").write(config_file) 82 | for s in scripts: 83 | build(s) 84 | if first: 85 | if sys.platform == "win32": 86 | ext = ".zip" 87 | else: 88 | ext = ".tar.gz" 89 | 90 | # Build path to archive 91 | archive_path = os.path.join( 92 | "pyu-data", "new", "Acme-{}-4.1{}".format(get_system(), ext) 93 | ) 94 | 95 | if not os.path.exists(archive_path): 96 | print("Archive did not build!") 97 | sys.exit(1) 98 | 99 | # We extract the Acme binary here. When we call pyupdater pkg -P 100 | # the Acme binary will be moved to the deploy folder. In our test 101 | # (test_pyupdater.TestExecution.test_execution_update_*) we 102 | # move all of the files from the deploy directory to the cwd 103 | # of the test runner. 104 | extract(archive_path) 105 | 106 | first = False 107 | 108 | os.system(cmd1) 109 | 110 | os.system(cmd2) 111 | 112 | 113 | if __name__ == "__main__": 114 | if len(sys.argv) != 5: 115 | print( 116 | "usage: %s " % sys.argv[0] 117 | ) 118 | else: 119 | main( 120 | sys.argv[1] == "True", 121 | sys.argv[2], 122 | sys.argv[3] == "True", 123 | sys.argv[4] == "True", 124 | ) 125 | -------------------------------------------------------------------------------- /tests/data/update_repo_restart/build_onedir_restart.py: -------------------------------------------------------------------------------- 1 | # ------------------------------------------------------------------------------ 2 | # Copyright (c) 2015-2020 Digital Sapphire 3 | # 4 | # Permission is hereby granted, free of charge, to any person obtaining 5 | # a copy of this software and associated documentation files 6 | # (the "Software"), to deal in the Software without restriction, including 7 | # without limitation the rights to use, copy, modify, merge, publish, 8 | # distribute, sublicense, and/or sell copies of the Software, and to permit 9 | # persons to whom the Software is furnished to do so, subject to the 10 | # following conditions: 11 | # 12 | # The above copyright notice and this permission notice shall be 13 | # included in all copies or substantial portions of the Software. 14 | # 15 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF 16 | # ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED 17 | # TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A 18 | # PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT 19 | # SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR 20 | # ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN 21 | # ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 22 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE 23 | # OR OTHER DEALINGS IN THE SOFTWARE. 24 | # ------------------------------------------------------------------------------ 25 | import logging 26 | import os 27 | import re 28 | import sys 29 | import tarfile 30 | import zipfile 31 | 32 | from dsdev_utils.system import get_system 33 | 34 | 35 | log = logging.getLogger() 36 | 37 | 38 | home_dir = os.path.dirname(os.path.abspath(__file__)) 39 | 40 | 41 | def build(app): 42 | # Pyinstaller's --clean is not 'multiprocessing safe', 43 | # let's use our own cache 44 | os.environ["PYINSTALLER_CONFIG_DIR"] = os.path.join(home_dir, ".cache") 45 | cmd = "pyupdater build -D {} --clean --path={} " "--app-version={} {}".format( 46 | app[2], home_dir, app[1], app[0] 47 | ) 48 | os.system(cmd) 49 | 50 | 51 | def extract(filename): 52 | ext = os.path.splitext(filename)[1] 53 | if ext == ".zip": 54 | archive = zipfile.ZipFile(filename, "r") 55 | else: 56 | archive = tarfile.open(filename, "r:gz") 57 | 58 | archive.extractall() 59 | 60 | 61 | def main(use_custom_dir, port, windowed, split_version): 62 | cmd1 = "pyupdater pkg -P" 63 | cmd2 = "pyupdater pkg -S" 64 | 65 | if split_version: 66 | cmd2 += " --split-version" 67 | 68 | scripts = [ 69 | ("app_restart_onedir.py", "4.1", "--windowed" if windowed else ""), 70 | ("app_restart_02.py", "4.2", "--windowed" if windowed else ""), 71 | ] 72 | 73 | # We use this flag to untar & move our binary to the 74 | # current working directory 75 | first = True 76 | # patch config_file for custom port number 77 | config_file = open("client_config.py", "rt").read() 78 | config_file = re.sub(r"localhost:\d+", "localhost:%s" % port, config_file) 79 | 80 | # patch config_file for use_custom_dir 81 | if use_custom_dir: 82 | config_file += "\n USE_CUSTOM_DIR = True\n" 83 | open("client_config.py", "wt").write(config_file) 84 | for s in scripts: 85 | build(s) 86 | if first: 87 | if sys.platform == "win32": 88 | ext = ".zip" 89 | else: 90 | ext = ".tar.gz" 91 | 92 | # Build path to archive 93 | archive_path = os.path.join( 94 | "pyu-data", "new", "Acme-{}-4.1{}".format(get_system(), ext) 95 | ) 96 | 97 | if not os.path.exists(archive_path): 98 | print("Archive did not build!") 99 | sys.exit(1) 100 | 101 | # We extract the Acme binary here. When we call pyupdater pkg -P 102 | # the Acme binary will be moved to the deploy folder. In our test 103 | # (test_pyupdater.TestExecution.test_execution_update_*) we 104 | # move all of the files from the deploy directory to the cwd 105 | # of the test runner. 106 | extract(archive_path) 107 | 108 | first = False 109 | 110 | os.system(cmd1) 111 | 112 | os.system(cmd2) 113 | 114 | 115 | if __name__ == "__main__": 116 | if len(sys.argv) != 5: 117 | print( 118 | "usage: %s " % sys.argv[0] 119 | ) 120 | else: 121 | main( 122 | sys.argv[1] == "True", 123 | sys.argv[2], 124 | sys.argv[3] == "True", 125 | sys.argv[4] == "True", 126 | ) 127 | -------------------------------------------------------------------------------- /tests/test_patcher.py: -------------------------------------------------------------------------------- 1 | # ------------------------------------------------------------------------------ 2 | # Copyright (c) 2015-2020 Digital Sapphire 3 | # 4 | # Permission is hereby granted, free of charge, to any person obtaining 5 | # a copy of this software and associated documentation files 6 | # (the "Software"), to deal in the Software without restriction, including 7 | # without limitation the rights to use, copy, modify, merge, publish, 8 | # distribute, sublicense, and/or sell copies of the Software, and to permit 9 | # persons to whom the Software is furnished to do so, subject to the 10 | # following conditions: 11 | # 12 | # The above copyright notice and this permission notice shall be 13 | # included in all copies or substantial portions of the Software. 14 | # 15 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF 16 | # ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED 17 | # TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A 18 | # PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT 19 | # SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR 20 | # ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN 21 | # ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 22 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE 23 | # OR OTHER DEALINGS IN THE SOFTWARE. 24 | # ------------------------------------------------------------------------------ 25 | from __future__ import unicode_literals, print_function 26 | import json 27 | import os 28 | 29 | import pytest 30 | 31 | from pyupdater.client.patcher import Patcher 32 | 33 | 34 | def cb1(status): 35 | pass 36 | 37 | 38 | # Just throwing a monkey wrench into. 39 | def cb2(status): 40 | raise IndexError 41 | 42 | 43 | update_data = { 44 | "name": "Acme", 45 | "current_filename": "Acme-mac-4.1.tar.gz", 46 | "current_version": "4.1.0.2.0", 47 | "latest_version": "4.4.0.2.0", 48 | "update_folder": None, 49 | "update_urls": ["https://pyu-tester.s3.amazonaws.com/"], 50 | "platform": "mac", 51 | "progress_hooks": [cb1, cb2], 52 | } 53 | 54 | 55 | # noinspection PyStatementEffect,PyStatementEffect 56 | @pytest.mark.usefixtures("cleandir") 57 | class TestFails(object): 58 | 59 | base_binary = "Acme-mac-4.1.tar.gz" 60 | 61 | @pytest.fixture 62 | def json_data(self, shared_datadir): 63 | version_data_str = (shared_datadir / "version.json").read_text() 64 | return json.loads(version_data_str) 65 | 66 | def test_no_base_binary(self, json_data): 67 | assert os.listdir(os.getcwd()) == [] 68 | data = update_data.copy() 69 | data["update_folder"] = os.getcwd() 70 | data["json_data"] = json_data 71 | p = Patcher(**data) 72 | assert p.start() is False 73 | 74 | def test_bad_hash_current_version(self, shared_datadir, json_data): 75 | data = update_data.copy() 76 | data["update_folder"] = str(shared_datadir) 77 | data["json_data"] = json_data 78 | data["current_file_hash"] = "Thisisabadhash" 79 | p = Patcher(**data) 80 | assert p.start() is False 81 | 82 | @pytest.mark.run(order=8) 83 | def test_missing_version(self, shared_datadir, json_data): 84 | data = update_data.copy() 85 | data["update_folder"] = str(shared_datadir) 86 | data["json_data"] = json_data 87 | data["latest_version"] = "0.0.4.2.0" 88 | p = Patcher(**data) 89 | assert p.start() is False 90 | 91 | 92 | # noinspection PyStatementEffect,PyStatementEffect 93 | @pytest.mark.usefixtures("cleandir") 94 | class TestExecution(object): 95 | 96 | base_binary = "Acme-mac-4.1.tar.gz" 97 | 98 | @pytest.fixture 99 | def json_data(self, shared_datadir): 100 | version_data_str = (shared_datadir / "version.json").read_text() 101 | return json.loads(version_data_str) 102 | 103 | @pytest.mark.run(order=7) 104 | def test_execution(self, shared_datadir, json_data): 105 | data = update_data.copy() 106 | data["update_folder"] = str(shared_datadir) 107 | data["json_data"] = json_data 108 | data["channel"] = "stable" 109 | p = Patcher(**data) 110 | assert p.start() is True 111 | 112 | def test_execution_callback(self, shared_datadir, json_data): 113 | def cb(status): 114 | assert "downloaded" in status.keys() 115 | assert "total" in status.keys() 116 | assert "status" in status.keys() 117 | assert "percent_complete" in status.keys() 118 | 119 | data = update_data.copy() 120 | data["update_folder"] = str(shared_datadir) 121 | data["json_data"] = json_data 122 | data["channel"] = "stable" 123 | data["progress_hooks"] = [cb] 124 | p = Patcher(**data) 125 | assert p.start() is True 126 | -------------------------------------------------------------------------------- /tests/test_downloader.py: -------------------------------------------------------------------------------- 1 | # ------------------------------------------------------------------------------ 2 | # Copyright (c) 2015-2020 Digital Sapphire 3 | # 4 | # Permission is hereby granted, free of charge, to any person obtaining 5 | # a copy of this software and associated documentation files 6 | # (the "Software"), to deal in the Software without restriction, including 7 | # without limitation the rights to use, copy, modify, merge, publish, 8 | # distribute, sublicense, and/or sell copies of the Software, and to permit 9 | # persons to whom the Software is furnished to do so, subject to the 10 | # following conditions: 11 | # 12 | # The above copyright notice and this permission notice shall be 13 | # included in all copies or substantial portions of the Software. 14 | # 15 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF 16 | # ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED 17 | # TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A 18 | # PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT 19 | # SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR 20 | # ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN 21 | # ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 22 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE 23 | # OR OTHER DEALINGS IN THE SOFTWARE. 24 | # ------------------------------------------------------------------------------ 25 | from __future__ import unicode_literals 26 | 27 | import pytest 28 | 29 | from pyupdater.client.downloader import FileDownloader, get_hash 30 | from pyupdater.utils.exceptions import FileDownloaderError 31 | 32 | 33 | FILENAME = "dont delete pyu test.txt" 34 | FILE_HASH = "82719546b992ef81f4544fb2690c6a05b300a0216eeaa8f3616b3b107a311629" 35 | URLS = ["https://pyu-tester.s3.amazonaws.com/"] 36 | 37 | 38 | @pytest.mark.usefixtures("cleandir") 39 | @pytest.mark.parametrize("download_max_size", [0, 4 * 1024 * 1024]) 40 | class TestData(object): 41 | def test_return(self, download_max_size): 42 | fd = FileDownloader(FILENAME, URLS, FILE_HASH, verify=True) 43 | fd.download_max_size = download_max_size 44 | binary_data = fd.download_verify_return() 45 | assert binary_data is not None 46 | 47 | def test_cb(self, download_max_size): 48 | def cb(status): 49 | pass 50 | 51 | fd = FileDownloader( 52 | FILENAME, URLS, hexdigest=FILE_HASH, progress_hooks=[cb], verify=True 53 | ) 54 | fd.download_max_size = download_max_size 55 | binary_data = fd.download_verify_return() 56 | assert binary_data is not None 57 | 58 | def test_return_fail(self, download_max_size): 59 | fd = FileDownloader(FILENAME, URLS, "JKFEIFJILEFJ983NKFNKL", verify=True) 60 | fd.download_max_size = download_max_size 61 | binary_data = fd.download_verify_return() 62 | assert binary_data is None 63 | 64 | 65 | @pytest.mark.usefixtures("cleandir") 66 | class TestBasicAuthUrlLib(object): 67 | def test_basic_auth(self): 68 | headers = {"basic_auth": "user:pass"} 69 | fd = FileDownloader("test", ["test"], headers=headers) 70 | http = fd._get_http_pool(secure=True) 71 | sc = http.request("GET", "https://httpbin.org/basic-auth/user/pass").status 72 | assert sc == 200 73 | 74 | 75 | @pytest.mark.usefixtures("cleandir") 76 | class TestAuthorizationHeader(object): 77 | def test_auth_header(self): 78 | headers = {"Authorization": "Basic dXNlcjpwYXNz"} 79 | fd = FileDownloader("test", ["test"], headers=headers) 80 | http = fd._get_http_pool(secure=True) 81 | sc = http.request("GET", "https://httpbin.org/basic-auth/user/pass").status 82 | assert sc == 200 83 | 84 | 85 | @pytest.mark.usefixtures("cleandir") 86 | class TestUrl(object): 87 | def test_bad_url(self): 88 | fd = FileDownloader(FILENAME, ["bad url"], hexdigest="bad hash", verify=True) 89 | binary_data = fd.download_verify_return() 90 | assert binary_data is None 91 | 92 | def test_url_as_string(self): 93 | with pytest.raises(FileDownloaderError): 94 | FileDownloader(FILENAME, URLS[0]) 95 | 96 | 97 | @pytest.mark.usefixtures("cleandir") 98 | class TestContentLength(object): 99 | def test_bad_content_length(self): 100 | class FakeHeaders(object): 101 | headers = {} 102 | 103 | fd = FileDownloader(FILENAME, URLS, hexdigest=FILE_HASH, verify=True) 104 | data = FakeHeaders() 105 | assert fd._get_content_length(data) is None 106 | 107 | def test_good_content_length(self): 108 | fd = FileDownloader(FILENAME, URLS, hexdigest=FILE_HASH, verify=True) 109 | fd.download_verify_return() 110 | assert fd.content_length == 2387 111 | 112 | 113 | @pytest.mark.usefixtures("cleandir") 114 | class TestGetHash(object): 115 | def test_get_hash(self): 116 | digest = "380fd2bf3d78bb411e4c1801ce3ce7804bf5a22d79" "405d950e5d5c8f3169fca0" 117 | assert digest == get_hash("Get this hash please") 118 | -------------------------------------------------------------------------------- /tests/test_utils.py: -------------------------------------------------------------------------------- 1 | # ------------------------------------------------------------------------------ 2 | # Copyright (c) 2015-2020 Digital Sapphire 3 | # 4 | # Permission is hereby granted, free of charge, to any person obtaining 5 | # a copy of this software and associated documentation files 6 | # (the "Software"), to deal in the Software without restriction, including 7 | # without limitation the rights to use, copy, modify, merge, publish, 8 | # distribute, sublicense, and/or sell copies of the Software, and to permit 9 | # persons to whom the Software is furnished to do so, subject to the 10 | # following conditions: 11 | # 12 | # The above copyright notice and this permission notice shall be 13 | # included in all copies or substantial portions of the Software. 14 | # 15 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF 16 | # ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED 17 | # TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A 18 | # PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT 19 | # SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR 20 | # ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN 21 | # ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 22 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE 23 | # OR OTHER DEALINGS IN THE SOFTWARE. 24 | # ------------------------------------------------------------------------------ 25 | from __future__ import unicode_literals 26 | import io 27 | import os 28 | 29 | import pytest 30 | 31 | from pyupdater.utils.config import Config 32 | from pyupdater.utils import ( 33 | check_repo, 34 | create_asset_archive, 35 | make_archive, 36 | PluginManager, 37 | remove_dot_files, 38 | run, 39 | ) 40 | 41 | 42 | @pytest.mark.usefixtures("cleandir") 43 | class TestUtils(object): 44 | def test_make_archive(self): 45 | with io.open("hash-test1.txt", "w", encoding="utf-8") as f: 46 | f.write("I should find some lorem text" * 11) 47 | 48 | with io.open("hash-test2.txt", "w", encoding="utf-8") as f: 49 | f.write("I should find some lorem text" * 5) 50 | 51 | filename1 = make_archive("hash", "hash-test1.txt", "0.2", "default") 52 | filename2 = make_archive("hash", "hash-test2.txt", "0.3", "default") 53 | 54 | assert os.path.exists(filename1) 55 | assert os.path.exists(filename2) 56 | 57 | def test_create_asset_archive(self): 58 | with io.open("hash-test1.dll", "w", encoding="utf-8") as f: 59 | f.write("I should find some lorem text" * 20) 60 | 61 | with io.open("hash-test2.so", "w", encoding="utf-8") as f: 62 | f.write("I should find some lorem text" * 11) 63 | 64 | with io.open("binary", "w", encoding="utf-8") as f: 65 | f.write("I should find some lorem text" * 5) 66 | 67 | filename = create_asset_archive("hash-test1.dll", "0.1") 68 | filename1 = create_asset_archive("hash-test2.so", "0.2") 69 | filename2 = create_asset_archive("binary", "0.3") 70 | 71 | assert os.path.exists(filename) 72 | assert os.path.exists(filename1) 73 | assert os.path.exists(filename2) 74 | 75 | def test_check_repo_fail(self): 76 | assert check_repo() is False 77 | 78 | def test_remove_dot_files(self): 79 | bad_list = [".DS_Store", "test", "stuff", ".trash"] 80 | good_list = ["test", "stuff"] 81 | for n in remove_dot_files(bad_list): 82 | assert n in good_list 83 | 84 | def test_run(self): 85 | assert run('echo "hello world!"') == 0 86 | 87 | @pytest.mark.parametrize( 88 | "n, a", 89 | [("test", None), (None, "test"), (1, "test"), ("test", 1), ("test", "test")], 90 | ) 91 | def test_plugin_manager_load(self, n, a): 92 | class Plugin(object): 93 | name = n 94 | author = a 95 | 96 | pm = PluginManager(Config(), plugins=[Plugin()]) 97 | 98 | if isinstance(n, str) and isinstance(a, str): 99 | assert len(pm.plugins) == 1 100 | else: 101 | assert len(pm.plugins) == 0 102 | 103 | def test_default_plugin_detection_s3(self): 104 | pm = PluginManager(Config()) 105 | 106 | plugin_names = [n["name"] for n in pm.plugins] 107 | assert "s3" in plugin_names 108 | 109 | def test_default_plugin_detection_scp(self): 110 | pm = PluginManager(Config()) 111 | 112 | plugin_names = [n["name"] for n in pm.plugins] 113 | assert "scp" in plugin_names 114 | 115 | def test_plugin_unique_names(self): 116 | class Plugin1(object): 117 | name = "test" 118 | author = "test1" 119 | 120 | class Plugin2(object): 121 | name = "test" 122 | author = "test2" 123 | 124 | pm = PluginManager(Config(), plugins=[Plugin1(), Plugin2()]) 125 | 126 | plugin_names = [n["name"] for n in pm.plugins] 127 | 128 | assert "test" in plugin_names 129 | assert "test2" in plugin_names 130 | 131 | def test_plugin_config(self): 132 | class TPlugin(object): 133 | name = "test" 134 | author = "test1" 135 | bucket = "" 136 | 137 | def init_config(self, config): 138 | self.bucket = config.get("bucket", "bad") 139 | 140 | master_config = Config() 141 | 142 | master_config["PLUGIN_CONFIGS"] = {"test-test1": {"bucket": "test_bucket"}} 143 | 144 | pm = PluginManager(master_config, plugins=[TPlugin()]) 145 | 146 | p = pm.get_plugin("test", False) 147 | assert p.bucket == "" 148 | 149 | p = pm.get_plugin("test", True) 150 | assert p.bucket == "test_bucket" 151 | -------------------------------------------------------------------------------- /pyupdater/cli/helpers.py: -------------------------------------------------------------------------------- 1 | # ------------------------------------------------------------------------------ 2 | # Copyright (c) 2015-2020 Digital Sapphire 3 | # 4 | # Permission is hereby granted, free of charge, to any person obtaining 5 | # a copy of this software and associated documentation files 6 | # (the "Software"), to deal in the Software without restriction, including 7 | # without limitation the rights to use, copy, modify, merge, publish, 8 | # distribute, sublicense, and/or sell copies of the Software, and to permit 9 | # persons to whom the Software is furnished to do so, subject to the 10 | # following conditions: 11 | # 12 | # The above copyright notice and this permission notice shall be 13 | # included in all copies or substantial portions of the Software. 14 | # 15 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF 16 | # ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED 17 | # TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A 18 | # PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT 19 | # SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR 20 | # ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN 21 | # ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 22 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE 23 | # OR OTHER DEALINGS IN THE SOFTWARE. 24 | # ------------------------------------------------------------------------------ 25 | import logging 26 | import os 27 | import sys 28 | 29 | from dsdev_utils import terminal 30 | 31 | from pyupdater import settings 32 | from pyupdater.utils import PluginManager 33 | 34 | log = logging.getLevelName(__name__) 35 | 36 | 37 | def print_plugin_settings(plugin_name, config): # pragma: no cover 38 | pm = PluginManager(config) 39 | config = pm.get_plugin_settings(plugin_name) 40 | if len(config.keys()) == 0: 41 | print("No config found for {}".format(plugin_name)) 42 | else: 43 | print(plugin_name) 44 | print(config) 45 | 46 | 47 | def setup_appname(config): # pragma: no cover 48 | if config.APP_NAME is not None: 49 | default = config.APP_NAME 50 | else: 51 | default = None 52 | config.APP_NAME = terminal.get_correct_answer( 53 | "Please enter app name", required=True, default=default 54 | ) 55 | 56 | 57 | def setup_client_config_path(config): # pragma: no cover 58 | _default_dir = os.path.basename(os.path.abspath(os.getcwd())) 59 | question = ( 60 | "Please enter the path to where pyupdater " 61 | "will write the client_config.py file. " 62 | "You'll need to import this file to " 63 | "initialize the update process. \nExamples:\n\n" 64 | "lib/utils, src/lib, src. \n\nLeave blank to use " 65 | "the current directory" 66 | ) 67 | answer = terminal.get_correct_answer(question, default=_default_dir) 68 | 69 | if answer == _default_dir: 70 | config.CLIENT_CONFIG_PATH = settings.DEFAULT_CLIENT_CONFIG 71 | else: 72 | answer = answer.split(os.sep) 73 | answer.append(settings.DEFAULT_CLIENT_CONFIG[0]) 74 | 75 | config.CLIENT_CONFIG_PATH = answer 76 | 77 | 78 | def setup_company(config): # pragma: no cover 79 | if config.COMPANY_NAME is not None: 80 | default = config.COMPANY_NAME 81 | else: 82 | default = None 83 | temp = terminal.get_correct_answer( 84 | "Please enter your name or company name", required=True, default=default 85 | ) 86 | config.COMPANY_NAME = temp 87 | 88 | 89 | def setup_max_download_retries(config): # pragma: no cover 90 | default = config.MAX_DOWNLOAD_RETRIES 91 | while 1: 92 | temp = terminal.get_correct_answer( 93 | "Enter max download retries", required=True, default=str(default) 94 | ) 95 | try: 96 | temp = int(temp) 97 | except Exception as err: 98 | log.error(err) 99 | log.debug(err, exc_info=True) 100 | continue 101 | 102 | if temp > 10 or temp < 1: 103 | log.error("Max retries can only be from 1 to 10") 104 | continue 105 | break 106 | 107 | config.MAX_DOWNLOAD_RETRIES = temp 108 | 109 | 110 | def setup_http_timeout(config): # pragma: no cover 111 | default = config.HTTP_TIMEOUT 112 | while 1: 113 | temp = terminal.get_correct_answer( 114 | "Enter HTTP timeout in seconds", required=True, default=str(default) 115 | ) 116 | try: 117 | temp = int(temp) 118 | except Exception as err: 119 | log.error(err) 120 | continue 121 | 122 | if temp < 1: 123 | log.error("HTTP timeout has to be >= 1") 124 | continue 125 | break 126 | 127 | config.HTTP_TIMEOUT = temp 128 | 129 | 130 | def setup_patches(config): # pragma: no cover 131 | question = "Would you like to enable patch updates?" 132 | config.UPDATE_PATCHES = terminal.ask_yes_no(question, default="yes") 133 | 134 | 135 | def setup_plugin(name, config): # pragma: no cover 136 | pgm = PluginManager(config) 137 | plugin = pgm.get_plugin(name) 138 | if plugin is None: 139 | sys.exit("Invalid plugin name...") 140 | 141 | pgm.config_plugin(name, config) 142 | 143 | 144 | def setup_urls(config): # pragma: no cover 145 | url = terminal.get_correct_answer("Enter a url to ping for updates.", required=True) 146 | config.UPDATE_URLS = [url] 147 | while 1: 148 | answer = terminal.ask_yes_no( 149 | "Would you like to add " "another url for backup?", default="no" 150 | ) 151 | if answer is True: 152 | url = terminal.get_correct_answer("Enter another url.", required=True) 153 | config.UPDATE_URLS.append(url) 154 | else: 155 | break 156 | 157 | 158 | def initial_setup(config): # pragma: no cover 159 | setup_appname(config) 160 | setup_company(config) 161 | setup_urls(config) 162 | setup_patches(config) 163 | setup_client_config_path(config) 164 | return config 165 | -------------------------------------------------------------------------------- /pyupdater/utils/config.py: -------------------------------------------------------------------------------- 1 | # ------------------------------------------------------------------------------ 2 | # Copyright (c) 2015-2020 Digital Sapphire 3 | # 4 | # Permission is hereby granted, free of charge, to any person obtaining 5 | # a copy of this software and associated documentation files 6 | # (the "Software"), to deal in the Software without restriction, including 7 | # without limitation the rights to use, copy, modify, merge, publish, 8 | # distribute, sublicense, and/or sell copies of the Software, and to permit 9 | # persons to whom the Software is furnished to do so, subject to the 10 | # following conditions: 11 | # 12 | # The above copyright notice and this permission notice shall be 13 | # included in all copies or substantial portions of the Software. 14 | # 15 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF 16 | # ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED 17 | # TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A 18 | # PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT 19 | # SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR 20 | # ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN 21 | # ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 22 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE 23 | # OR OTHER DEALINGS IN THE SOFTWARE. 24 | # ------------------------------------------------------------------------------ 25 | from __future__ import unicode_literals 26 | import logging 27 | import os 28 | 29 | from pyupdater import settings 30 | from pyupdater.utils.storage import Storage 31 | 32 | log = logging.getLogger(__name__) 33 | 34 | 35 | class Config(dict): 36 | def __init__(self, *args, **kwargs): 37 | super(Config, self).__init__(*args, **kwargs) 38 | self.__dict__ = self 39 | self._set_default() 40 | 41 | def _set_default(self): 42 | config_template = { 43 | # If left None "PyUpdater App" will be used 44 | "APP_NAME": settings.GENERIC_APP_NAME, 45 | # path to place client config 46 | "CLIENT_CONFIG_PATH": settings.DEFAULT_CLIENT_CONFIG, 47 | # Company/Your name 48 | "COMPANY_NAME": settings.GENERIC_APP_NAME, 49 | "PLUGIN_CONFIGS": {}, 50 | # Support for patch updates 51 | "UPDATE_PATCHES": True, 52 | # Max retries for downloads 53 | "MAX_DOWNLOAD_RETRIES": 3, 54 | # HTTP TIMEOUT 55 | "HTTP_TIMEOUT": 30, 56 | } 57 | self.update(config_template) 58 | 59 | def from_object(self, obj): 60 | # Updates the values from the given object 61 | 62 | # Args: 63 | 64 | # obj (instance): Object with config attributes 65 | 66 | # Objects are classes. 67 | 68 | # Just the uppercase variables in that object are stored in the config. 69 | # Example usage:: 70 | 71 | # from yourapplication import default_config 72 | # app.config.from_object(default_config()) 73 | for key in dir(obj): 74 | if key.isupper(): 75 | self[key] = getattr(obj, key) 76 | 77 | 78 | # Loads & saves config file 79 | class ConfigManager(object): 80 | def __init__(self): 81 | self.cwd = os.getcwd() 82 | self.db = Storage() 83 | self.config_key = settings.CONFIG_DB_KEY_APP_CONFIG 84 | 85 | # Loads config from database (json file) 86 | def load_config(self): 87 | config_data = self.db.load(self.config_key) 88 | if config_data is None: 89 | config_data = {} 90 | config = Config() 91 | for k, v in config_data.items(): 92 | config[k] = v 93 | config.DATA_DIR = os.getcwd() 94 | return config 95 | 96 | def get_app_name(self): 97 | config = self.load_config() 98 | return config.APP_NAME 99 | 100 | # Saves config to database (json file) 101 | def save_config(self, obj): 102 | log.debug("Saving Config") 103 | self.db.save(self.config_key, obj) 104 | log.debug("Config saved") 105 | self.write_config_py(obj) 106 | log.debug("Wrote client config") 107 | 108 | # Writes client config to client_config.py 109 | def write_config_py(self, obj): 110 | keypack_data = self.db.load(settings.CONFIG_DB_KEY_KEYPACK) 111 | if keypack_data is None: 112 | log.debug("*** Keypack data is None ***") 113 | public_key = None 114 | else: 115 | public_key = keypack_data["client"]["offline_public"] 116 | 117 | filename = os.path.join(self.cwd, *obj.CLIENT_CONFIG_PATH) 118 | attr_str_format = " {} = '{}'\n" 119 | attr_format = " {} = {}\n" 120 | 121 | log.debug("Writing client_config.py") 122 | with open(filename, "w") as f: 123 | f.write("class ClientConfig(object):\n") 124 | 125 | log.debug("Adding PUBLIC_KEY to client_config.py") 126 | f.write(attr_str_format.format("PUBLIC_KEY", public_key)) 127 | 128 | if hasattr(obj, "APP_NAME"): 129 | log.debug("Adding APP_NAME to client_config.py") 130 | f.write(attr_str_format.format("APP_NAME", obj.APP_NAME)) 131 | 132 | if hasattr(obj, "COMPANY_NAME"): 133 | log.debug("Adding COMPANY_NAME to client_config.py") 134 | f.write(attr_str_format.format("COMPANY_NAME", obj.COMPANY_NAME)) 135 | 136 | if hasattr(obj, "HTTP_TIMEOUT"): 137 | log.debug("Adding HTTP_TIMEOUT to cilent_config.py") 138 | f.write(attr_format.format("HTTP_TIMEOUT", obj.HTTP_TIMEOUT)) 139 | 140 | if hasattr(obj, "MAX_DOWNLOAD_RETRIES"): 141 | log.debug("Adding MAX_DOWNLOAD_RETRIES to client_config.py") 142 | f.write( 143 | attr_format.format("MAX_DOWNLOAD_RETRIES", obj.MAX_DOWNLOAD_RETRIES) 144 | ) 145 | 146 | if hasattr(obj, "UPDATE_URLS"): 147 | log.debug("Adding UPDATE_URLS to client_config.py") 148 | f.write(attr_format.format("UPDATE_URLS", obj.UPDATE_URLS)) 149 | -------------------------------------------------------------------------------- /pyupdater/core/key_handler/keys.py: -------------------------------------------------------------------------------- 1 | # ------------------------------------------------------------------------------ 2 | # Copyright (c) 2015-2020 Digital Sapphire 3 | # 4 | # Permission is hereby granted, free of charge, to any person obtaining 5 | # a copy of this software and associated documentation files 6 | # (the "Software"), to deal in the Software without restriction, including 7 | # without limitation the rights to use, copy, modify, merge, publish, 8 | # distribute, sublicense, and/or sell copies of the Software, and to permit 9 | # persons to whom the Software is furnished to do so, subject to the 10 | # following conditions: 11 | # 12 | # The above copyright notice and this permission notice shall be 13 | # included in all copies or substantial portions of the Software. 14 | # 15 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF 16 | # ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED 17 | # TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A 18 | # PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT 19 | # SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR 20 | # ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN 21 | # ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 22 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE 23 | # OR OTHER DEALINGS IN THE SOFTWARE. 24 | # ------------------------------------------------------------------------------ 25 | from __future__ import unicode_literals 26 | import io 27 | import logging 28 | import json 29 | import os 30 | 31 | from appdirs import user_data_dir 32 | from nacl.signing import SigningKey 33 | 34 | from pyupdater import settings 35 | from pyupdater.utils.exceptions import KeyHandlerError 36 | from pyupdater.utils.encoding import UnpaddedBase64Encoder 37 | from pyupdater.utils.storage import Storage 38 | 39 | 40 | log = logging.getLogger(__name__) 41 | 42 | 43 | class Keys(object): 44 | def __init__(self, test=False): 45 | # We use base64 encoding for easy human consumption 46 | self.key_encoder = UnpaddedBase64Encoder() 47 | self.key_data = {} 48 | 49 | if test: 50 | self.data_dir = os.path.join("private", "data") 51 | else: 52 | self.data_dir = user_data_dir("PyUpdater", "Digital Sapphire") 53 | 54 | if not os.path.exists(self.data_dir): 55 | os.makedirs(self.data_dir) 56 | 57 | # The name of the offline key database 58 | self.keypack_filename = os.path.join(self.data_dir, "offline_keys.db") 59 | self._load() 60 | 61 | def make_keypack(self, name): 62 | # Will generate keypack specific to app name provided 63 | try: 64 | keypack = self._gen_keypack(name) 65 | except AssertionError: 66 | log.debug("Failed to generate keypack") 67 | return False 68 | except KeyHandlerError as err: 69 | log.error(err) 70 | return False 71 | 72 | # Write keypack to cwd 73 | with io.open(settings.KEYPACK_FILENAME, "w", encoding="utf-8") as f: 74 | out = json.dumps(keypack, indent=2, sort_keys=True) 75 | f.write(out) 76 | return True 77 | 78 | def _load(self): 79 | if not os.path.exists(self.keypack_filename): 80 | self._save() 81 | else: 82 | with io.open(self.keypack_filename, "r", encoding="utf-8") as f: 83 | self.key_data = json.loads(f.read()) 84 | 85 | def _save(self): 86 | with io.open(self.keypack_filename, "w", encoding="utf-8") as f: 87 | out = json.dumps(self.key_data, indent=2, sort_keys=True) 88 | f.write(out) 89 | 90 | def _gen_keypack(self, name): 91 | # Create new public & private key for app signing 92 | try: 93 | app_pri, app_pub = self._make_keys() 94 | except Exception as err: 95 | log.error(err) 96 | log.debug(err, exc_info=True) 97 | raise KeyHandlerError("Failed to create keypair") 98 | 99 | # Load app specific private & public key 100 | off_pri, off_pub = self._load_offline_keys(name) 101 | 102 | log.debug("off_pri type: %s", type(off_pri)) 103 | off_pri = off_pri.encode() 104 | 105 | signing_key = SigningKey(off_pri, self.key_encoder) 106 | 107 | # Create signature from app signing public key 108 | signature = signing_key.sign(app_pub)[:64] 109 | signature = self.key_encoder.encode(signature).decode() 110 | 111 | app_pri = app_pri.decode() 112 | app_pub = app_pub.decode() 113 | 114 | keypack = { 115 | "upload": {"app_public": app_pub, "signature": signature}, 116 | "client": {"offline_public": off_pub}, 117 | "repo": {"app_private": app_pri}, 118 | } 119 | return keypack 120 | 121 | def _load_offline_keys(self, name): 122 | if name not in self.key_data.keys(): 123 | # We create new offline keys for each app 124 | pri, pub = self._make_keys() 125 | pri = pri.decode() 126 | pub = pub.decode() 127 | self.key_data[name] = {"private": pri, "public": pub} 128 | self._save() 129 | return self.key_data[name]["private"], self.key_data[name]["public"] 130 | 131 | def _make_keys(self): 132 | # Makes a set of private and public keys 133 | privkey = SigningKey.generate() 134 | pubkey = privkey.verify_key 135 | 136 | pri = privkey.encode(self.key_encoder) 137 | pub = pubkey.encode(self.key_encoder) 138 | return pri, pub 139 | 140 | 141 | class KeyImporter(object): 142 | def __init__(self): 143 | self.db = Storage() 144 | 145 | @staticmethod 146 | def _look_for_keypack(): 147 | files = os.listdir(os.getcwd()) 148 | if settings.KEYPACK_FILENAME not in files: 149 | return False 150 | return True 151 | 152 | @staticmethod 153 | def _load_keypack(): 154 | json_data = None 155 | try: 156 | with io.open(settings.KEYPACK_FILENAME, "r", encoding="utf-8") as f: 157 | data = f.read() 158 | except Exception as err: 159 | log.debug(err, exc_info=True) 160 | else: 161 | try: 162 | json_data = json.loads(data) 163 | except Exception as err: 164 | log.debug(err, exc_info=True) 165 | return json_data 166 | 167 | def start(self): 168 | found = KeyImporter._look_for_keypack() 169 | if found is False: 170 | return False 171 | keypack = KeyImporter._load_keypack() 172 | if keypack is None: 173 | return False 174 | self.db.save(settings.CONFIG_DB_KEY_KEYPACK, keypack) 175 | return True 176 | -------------------------------------------------------------------------------- /pyupdater/core/key_handler/__init__.py: -------------------------------------------------------------------------------- 1 | # ------------------------------------------------------------------------------ 2 | # Copyright (c) 2015-2020 Digital Sapphire 3 | # 4 | # Permission is hereby granted, free of charge, to any person obtaining 5 | # a copy of this software and associated documentation files 6 | # (the "Software"), to deal in the Software without restriction, including 7 | # without limitation the rights to use, copy, modify, merge, publish, 8 | # distribute, sublicense, and/or sell copies of the Software, and to permit 9 | # persons to whom the Software is furnished to do so, subject to the 10 | # following conditions: 11 | # 12 | # The above copyright notice and this permission notice shall be 13 | # included in all copies or substantial portions of the Software. 14 | # 15 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF 16 | # ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED 17 | # TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A 18 | # PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT 19 | # SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR 20 | # ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN 21 | # ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 22 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE 23 | # OR OTHER DEALINGS IN THE SOFTWARE. 24 | # ------------------------------------------------------------------------------ 25 | from __future__ import print_function, unicode_literals 26 | import copy 27 | import gzip 28 | import json 29 | import logging 30 | import os 31 | 32 | from nacl.signing import SigningKey 33 | 34 | from pyupdater import settings 35 | from pyupdater.utils.storage import Storage 36 | from pyupdater.utils.encoding import UnpaddedBase64Encoder 37 | 38 | 39 | log = logging.getLogger(__name__) 40 | 41 | 42 | class KeyHandler(object): 43 | """KeyHandler object is used to manage keys used for signing updates 44 | 45 | Kwargs: 46 | 47 | app (obj): Config object to get config values from 48 | """ 49 | 50 | def __init__(self): 51 | self.db = Storage() 52 | 53 | self.key_encoder = UnpaddedBase64Encoder() 54 | data_dir = os.getcwd() 55 | self.data_dir = os.path.join(data_dir, settings.USER_DATA_FOLDER) 56 | self.deploy_dir = os.path.join(self.data_dir, "deploy") 57 | 58 | # Name of the keypack to import. It should be placed 59 | # in the root of the repo 60 | self.keypack_filename = os.path.join( 61 | data_dir, settings.CONFIG_DATA_FOLDER, settings.KEYPACK_FILENAME 62 | ) 63 | 64 | # The name of the gzipped version file in 65 | # the pyu-data/deploy directory 66 | self.version_file = os.path.join( 67 | self.deploy_dir, settings.VERSION_FILE_FILENAME 68 | ) 69 | 70 | self.version_file_compat = os.path.join( 71 | self.deploy_dir, settings.VERSION_FILE_FILENAME_COMPAT 72 | ) 73 | 74 | # The name of the gzipped key file in 75 | # the pyu-data/deploy directory 76 | self.key_file = os.path.join(self.deploy_dir, settings.KEY_FILE_FILENAME) 77 | 78 | def sign_update(self, split_version): 79 | """Signs version file with private key 80 | 81 | Proxy method for :meth:`_add_sig` 82 | """ 83 | # Loads private key 84 | # Loads version file to memory 85 | # Signs Version file 86 | # Writes version file back to disk 87 | self._add_sig(split_version) 88 | 89 | def _load_private_keys(self): 90 | # Loads private key 91 | log.debug("Loading private key") 92 | 93 | # Loading keypack data from .pyupdater/config.pyu 94 | keypack_data = self.db.load(settings.CONFIG_DB_KEY_KEYPACK) 95 | private_key = None 96 | if keypack_data is not None: 97 | try: 98 | private_key = keypack_data["repo"]["app_private"] 99 | except KeyError: 100 | # We will exit in _add_sig if private_key is None 101 | pass 102 | return private_key 103 | 104 | def _add_sig(self, split_version): 105 | # Adding new signature to version file 106 | # Raw private key will need to be converted into 107 | # a signing key object 108 | private_key_raw = self._load_private_keys() 109 | if private_key_raw is None: 110 | log.error("Private Key not found. Please " "import a keypack & try again") 111 | return 112 | 113 | # Load update manifest 114 | update_data = self._load_update_data() 115 | 116 | # We don't want to verify the signature 117 | if "signature" in update_data: 118 | log.debug("Removing signatures from version file") 119 | del update_data["signature"] 120 | 121 | # We create a signature from the string 122 | update_data_str = json.dumps(update_data, sort_keys=True) 123 | 124 | # Creating signing key object 125 | private_key = SigningKey(private_key_raw, self.key_encoder) 126 | log.debug("Signing update data") 127 | # Signs update data with private key 128 | signature = private_key.sign(bytes(update_data_str, "latin-1")) 129 | 130 | signature = self.key_encoder.encode(signature[:64]).decode() 131 | 132 | log.debug("Sig: %s", signature) 133 | 134 | # Create new dict from json string 135 | update_data = json.loads(update_data_str) 136 | 137 | # Add signatures to update data 138 | update_data["signature"] = signature 139 | log.debug("Adding signature to update data") 140 | 141 | # Write updated version file to .pyupdater/config.pyu 142 | self._write_update_data(update_data, split_version) 143 | 144 | # Write gzipped key file 145 | self._write_key_file() 146 | 147 | def _write_update_data(self, data, split_version): 148 | log.debug("Saved version meta data") 149 | 150 | if split_version: 151 | version_file = self.version_file 152 | else: 153 | version_file = self.version_file_compat 154 | 155 | # Gzip update date 156 | with gzip.open(version_file, "wb") as f: 157 | new_data = json.dumps(data) 158 | f.write(bytes(new_data, "utf-8")) 159 | 160 | log.debug("Created gzipped version manifest in deploy dir") 161 | 162 | def _write_key_file(self): 163 | keypack_data = self.db.load(settings.CONFIG_DB_KEY_KEYPACK) 164 | if keypack_data is None: 165 | log.error("Private Key not found. Please import a keypack & try again") 166 | return 167 | 168 | upload_data = keypack_data["upload"] 169 | with gzip.open(self.key_file, "wb") as f: 170 | new_data = json.dumps(upload_data) 171 | f.write(bytes(new_data, "utf-8")) 172 | log.debug("Created gzipped key file in deploy dir") 173 | 174 | def _load_update_data(self): 175 | log.debug("Loading version data") 176 | update_data = self.db.load(settings.CONFIG_DB_KEY_VERSION_META) 177 | # If update_data is None, create a new one 178 | if update_data is None: 179 | update_data = {} 180 | log.error("Version meta data not found") 181 | self.db.save(settings.CONFIG_DB_KEY_VERSION_META, update_data) 182 | log.debug("Created new version meta data") 183 | log.debug("Version file loaded") 184 | 185 | return copy.deepcopy(update_data) 186 | -------------------------------------------------------------------------------- /pyupdater/core/package_handler/patch.py: -------------------------------------------------------------------------------- 1 | # ------------------------------------------------------------------------------ 2 | # Copyright (c) 2015-2020 Digital Sapphire 3 | # 4 | # Permission is hereby granted, free of charge, to any person obtaining 5 | # a copy of this software and associated documentation files 6 | # (the "Software"), to deal in the Software without restriction, including 7 | # without limitation the rights to use, copy, modify, merge, publish, 8 | # distribute, sublicense, and/or sell copies of the Software, and to permit 9 | # persons to whom the Software is furnished to do so, subject to the 10 | # following conditions: 11 | # 12 | # The above copyright notice and this permission notice shall be 13 | # included in all copies or substantial portions of the Software. 14 | # 15 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF 16 | # ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED 17 | # TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A 18 | # PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT 19 | # SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR 20 | # ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN 21 | # ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 22 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE 23 | # OR OTHER DEALINGS IN THE SOFTWARE. 24 | # ------------------------------------------------------------------------------ 25 | from __future__ import unicode_literals 26 | import json 27 | import logging 28 | import os 29 | 30 | try: # pragma: no cover 31 | import bsdiff4 32 | except ImportError: # pragma: no cover 33 | bsdiff4 = None 34 | from dsdev_utils.paths import ChDir 35 | 36 | from pyupdater import settings 37 | from pyupdater.utils import remove_dot_files 38 | 39 | 40 | log = logging.getLogger(__name__) 41 | 42 | 43 | def make_patch(patch): 44 | log.debug("Patch source path: %s", patch.src) 45 | log.debug("Patch destination path: %s", patch.dst) 46 | log.info("Creating patch... %s", patch.basename) 47 | 48 | bsdiff4.file_diff(patch.src, patch.dst, patch.patch_name) 49 | 50 | log.info("Done creating patch... %s", patch.basename) 51 | 52 | return patch 53 | 54 | 55 | class Patch(object): 56 | def __init__(self, **kwargs): 57 | self._pkg_info = kwargs.get("pkg_info") 58 | self._filename = kwargs.get("filename") 59 | self._files_dir = kwargs.get("files_dir") 60 | self._new_dir = kwargs.get("new_dir") 61 | self._json_data = kwargs.get("json_data") 62 | self._config = kwargs.get("config") 63 | self._test = kwargs.get("test", False) 64 | 65 | self.ok = False 66 | self.patch_num = None 67 | self.src = None 68 | self.dst = None 69 | self.patch_name = None 70 | self.basename = None 71 | self.hash = None 72 | self.size = None 73 | 74 | self._check_make_patch() 75 | 76 | if self.ok: 77 | self.dst = os.path.abspath(self._filename) 78 | _patch_name = "{}-{}-{}-{}".format( 79 | self._pkg_info.name, 80 | self._pkg_info.platform, 81 | self._pkg_info.channel, 82 | self.patch_num, 83 | ) 84 | self.patch_name = os.path.join(self._new_dir, _patch_name) 85 | self.basename = os.path.basename(self.patch_name) 86 | self.dst_filename = self._pkg_info.filename 87 | self.channel = self._pkg_info.channel 88 | 89 | def __str__(self): 90 | tmpl = "Patch(ok={ok}, patch_name={patch_name}, basename={basename})" 91 | return tmpl.format( 92 | ok=self.ok, patch_name=self.patch_name, basename=self.basename 93 | ) 94 | 95 | def _check_make_patch(self): 96 | # Check to see if previous version is available to 97 | # make patch updates. Also calculates patch number 98 | if self._json_data.get("latest") is not None: 99 | log.debug(json.dumps(self._json_data["latest"], indent=2)) 100 | log.debug("Checking if patch creation is possible") 101 | if bsdiff4 is None: 102 | log.warning("Bsdiff is missing. Cannot create patches") 103 | return 104 | 105 | if os.path.exists(self._files_dir): 106 | with ChDir(self._files_dir): 107 | files = os.listdir(os.getcwd()) 108 | log.debug("Found %s files in files dir", len(files)) 109 | 110 | files = remove_dot_files(files) 111 | # No src files to patch from. Exit quickly 112 | if len(files) == 0: 113 | log.debug("No src file to patch from") 114 | return 115 | 116 | _name = self._pkg_info.name 117 | _plat = self._pkg_info.platform 118 | _channel = self._pkg_info.channel 119 | if self._test is False: 120 | # If latest not available in version file. Exit 121 | try: 122 | log.debug("Looking for %s on %s", _name, _plat) 123 | latest = self._json_data["latest"][_name][_channel][_plat] 124 | log.debug("Found latest version for patches: %s", latest) 125 | except KeyError: 126 | log.debug("Cannot find latest version in version meta") 127 | return 128 | try: 129 | u_key = settings.UPDATES_KEY 130 | latest_platform = self._json_data[u_key][_name][latest] 131 | log.debug("Found latest platform for patches") 132 | try: 133 | filename = latest_platform[_plat]["filename"] 134 | log.debug("Found filename for patches") 135 | except KeyError: 136 | log.error( 137 | "Found old version file. Please read " 138 | "the upgrade section in the docs." 139 | ) 140 | log.debug("Found old verison file") 141 | return 142 | except Exception as err: 143 | log.debug(err, exc_info=True) 144 | return 145 | else: 146 | filename = self._filename 147 | 148 | log.debug("Generating src file path") 149 | self.src = os.path.join(self._files_dir, filename) 150 | log.debug("Source path: %s", self.src) 151 | if not os.path.exists(self.src): 152 | log.warning("Source path does not exist: %s", filename) 153 | return 154 | 155 | try: 156 | patch_num = self._config["patches"][_name] 157 | log.debug("Found patch number") 158 | self._config["patches"][_name] += 1 159 | except KeyError: 160 | log.debug("Cannot find patch number") 161 | # If no patch number we will start at 1 162 | patch_num = 1 163 | if "patches" not in self._config.keys(): 164 | log.debug("Adding patches to version meta") 165 | self._config["patches"] = {} 166 | if _name not in self._config["patches"].keys(): 167 | log.debug("Adding %s to patches version meta", _name) 168 | self._config["patches"][_name] = patch_num + 1 169 | self.patch_num = patch_num + 1 170 | log.debug("Patch Number: %s", self.patch_num) 171 | self.ok = True 172 | -------------------------------------------------------------------------------- /tests/test_package_handler.py: -------------------------------------------------------------------------------- 1 | # ------------------------------------------------------------------------------ 2 | # Copyright (c) 2015-2020 Digital Sapphire 3 | # 4 | # Permission is hereby granted, free of charge, to any person obtaining 5 | # a copy of this software and associated documentation files 6 | # (the "Software"), to deal in the Software without restriction, including 7 | # without limitation the rights to use, copy, modify, merge, publish, 8 | # distribute, sublicense, and/or sell copies of the Software, and to permit 9 | # persons to whom the Software is furnished to do so, subject to the 10 | # following conditions: 11 | # 12 | # The above copyright notice and this permission notice shall be 13 | # included in all copies or substantial portions of the Software. 14 | # 15 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF 16 | # ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED 17 | # TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A 18 | # PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT 19 | # SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR 20 | # ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN 21 | # ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 22 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE 23 | # OR OTHER DEALINGS IN THE SOFTWARE. 24 | # ------------------------------------------------------------------------------ 25 | from __future__ import unicode_literals 26 | 27 | import io 28 | import os 29 | 30 | from dsdev_utils.paths import ChDir 31 | import pytest 32 | 33 | from pyupdater import settings 34 | from pyupdater.core.package_handler import PackageHandler 35 | from pyupdater.core.package_handler.package import Package, parse_platform 36 | from pyupdater.core.package_handler.patch import Patch 37 | from pyupdater.utils.config import Config 38 | from pyupdater.utils.exceptions import PackageHandlerError 39 | 40 | from tconfig import TConfig 41 | 42 | user_data_dir = settings.USER_DATA_FOLDER 43 | 44 | 45 | @pytest.mark.usefixtures("cleandir", "pyu") 46 | class TestUtils(object): 47 | def test_init(self): 48 | data_dir = os.getcwd() 49 | t_config = TConfig() 50 | t_config.DATA_DIR = data_dir 51 | config = Config() 52 | config.from_object(t_config) 53 | p = PackageHandler(config) 54 | assert p.files_dir == os.path.join(data_dir, user_data_dir, "files") 55 | assert p.deploy_dir == os.path.join(data_dir, user_data_dir, "deploy") 56 | 57 | def test_no_patch_support(self): 58 | data_dir = os.getcwd() 59 | t_config = TConfig() 60 | t_config.DATA_DIR = data_dir 61 | t_config.UPDATE_PATCHES = False 62 | config = Config() 63 | config.from_object(t_config) 64 | p = PackageHandler(config) 65 | p.process_packages() 66 | 67 | def test_parse_platform(self): 68 | assert parse_platform("app-mac-0.1.0.tar.gz") == "mac" 69 | assert parse_platform("app-win-1.0.0.zip") == "win" 70 | assert parse_platform("Email Parser-mac-0.2.0.tar.gz") == "mac" 71 | assert parse_platform("Hangman-nix-0.0.1b1.zip") == "nix" 72 | 73 | def test_parse_platform_fail(self): 74 | with pytest.raises(PackageHandlerError): 75 | parse_platform("app-nex-1.0.0.tar.gz") 76 | 77 | 78 | @pytest.mark.usefixtures("cleandir", "pyu") 79 | class TestExecution(object): 80 | def test_process_packages(self): 81 | data_dir = os.getcwd() 82 | t_config = TConfig() 83 | t_config.DATA_DIR = data_dir 84 | t_config.UPDATE_PATCHES = False 85 | config = Config() 86 | config.from_object(t_config) 87 | p = PackageHandler(config) 88 | p.process_packages() 89 | 90 | 91 | @pytest.mark.usefixtures("cleandir") 92 | class TestPackage(object): 93 | def test_package_1(self, shared_datadir): 94 | test_file = "Acme-mac-4.1.tar.gz" 95 | p1 = Package(shared_datadir / test_file) 96 | 97 | assert p1.name == "Acme" 98 | assert p1.version == "4.1.0.2.0" 99 | assert p1.filename == test_file 100 | assert p1.platform == "mac" 101 | assert p1.channel == "stable" 102 | assert p1.info["status"] is True 103 | 104 | def test_package_name_with_spaces(self, shared_datadir): 105 | test_file = "with spaces-nix-0.0.1b1.zip" 106 | p1 = Package(shared_datadir / test_file) 107 | 108 | assert p1.name == "with spaces" 109 | assert p1.version == "0.0.1.1.1" 110 | assert p1.filename == test_file 111 | assert p1.platform == "nix" 112 | assert p1.channel == "beta" 113 | assert p1.info["status"] is True 114 | 115 | def test_package_alpha(self, shared_datadir): 116 | test_file = "with spaces-win-0.0.1a2.zip" 117 | p1 = Package(shared_datadir / test_file) 118 | 119 | assert p1.name == "with spaces" 120 | assert p1.version == "0.0.1.0.2" 121 | assert p1.filename == test_file 122 | assert p1.platform == "win" 123 | assert p1.channel == "alpha" 124 | assert p1.info["status"] is True 125 | 126 | def test_package_ignored_file(self): 127 | with io.open(".DS_Store", "w", encoding="utf-8") as f: 128 | f.write("") 129 | p = Package(".DS_Store") 130 | assert p.info["status"] is False 131 | 132 | def test_package_bad_extension(self, shared_datadir): 133 | test_file_2 = "pyu-win-0.0.2.xz" 134 | p2 = Package(shared_datadir / test_file_2) 135 | 136 | assert p2.filename == test_file_2 137 | assert p2.name is None 138 | assert p2.version is None 139 | assert p2.info["status"] is False 140 | assert p2.info["reason"] == ( 141 | "Not a supported archive format: " "{}".format(test_file_2) 142 | ) 143 | 144 | def test_package_bad_version(self, shared_datadir): 145 | filename = "pyu-win-1.tar.gz" 146 | p = Package(shared_datadir / filename) 147 | out = "Package version not formatted correctly: {}" 148 | assert p.info["reason"] == out.format(filename) 149 | 150 | def test_package_bad_platform(self, shared_datadir): 151 | filename = "pyu-wi-1.1.tar.gz" 152 | p = Package(shared_datadir / filename) 153 | out = "Package platform not formatted correctly" 154 | assert p.info["reason"] == out 155 | 156 | 157 | @pytest.mark.usefixtures("cleandir") 158 | class TestPatch(object): 159 | new_dir = "pyu-data/new" 160 | files_dir = "pyu-data/files" 161 | 162 | @pytest.fixture 163 | def patch_setup(self): 164 | os.makedirs(self.new_dir) 165 | os.makedirs(self.files_dir) 166 | 167 | with open(os.path.join(self.new_dir, "Acme-mac-4.2.tar.gz"), "w") as f: 168 | f.write("v2") 169 | 170 | with open(os.path.join(self.files_dir, "Acme-mac-4.1.tar.gz"), "w") as f: 171 | f.write("v1") 172 | 173 | def test_patch(self, patch_setup): 174 | filename = "Acme-mac-4.2.tar.gz" 175 | 176 | with ChDir(self.new_dir): 177 | full_path = os.path.abspath(filename) 178 | pkg = Package(full_path) 179 | 180 | config = {} 181 | version_data = {} 182 | data = { 183 | "filename": full_path, 184 | "files_dir": self.files_dir, 185 | "new_dir": self.new_dir, 186 | "json_data": version_data, 187 | "pkg_info": pkg, 188 | "config": config, 189 | "test": True, 190 | } 191 | 192 | patch = Patch(**data) 193 | 194 | assert patch.ok 195 | assert config["patches"][pkg.name] 196 | 197 | def test_patch_fail(self): 198 | pass 199 | -------------------------------------------------------------------------------- /docs/api.md: -------------------------------------------------------------------------------- 1 | # API 2 | 3 | 4 | 5 | ## Table of contents 6 | 7 | ### [pyupdater.client](#pyupdaterclient) 8 | 9 | * [pyupdater.client.get_highest_version](#pyupdaterclientget_highest_versionname-plat-channel-easy_data-strict) 10 | * [pyupdater.client.AppUpdate](#pyupdaterclientappupdate) 11 | * [pyupdater.client.Client](#pyupdaterclientclient) 12 | * [pyupdater.client.ClientError](#pyupdaterclientclienterror) 13 | * [pyupdater.client.DefaultClientConfig](#pyupdaterclientdefaultclientconfig) 14 | * [pyupdater.client.FileDownloader](#pyupdaterclientfiledownloader) 15 | * [pyupdater.client.LibUpdate](#pyupdaterclientlibupdate) 16 | * [pyupdater.client.UnpaddedBase64Encoder](#pyupdaterclientunpaddedbase64encoder) 17 | * [pyupdater.client.UpdateStrategy](#pyupdaterclientupdatestrategy) 18 | * [pyupdater.client.VerifyKey](#pyupdaterclientverifykey) 19 | 20 | 21 | 22 | 23 | ## pyupdater.client 24 | 25 | 26 | 27 | ##### pyupdater.client.get_highest_version(name, plat, channel, easy_data, strict) 28 | 29 | 30 | 31 | ### pyupdater.client.AppUpdate 32 | 33 | Used to update an application. This object is returned by 34 | pyupdater.client.Client.update_check 35 | 36 | Args: 37 | 38 | data (dict): Info dict 39 | 40 | #### Methods 41 | 42 | ##### AppUpdate.cleanup() 43 | 44 | Cleans up old update archives for this app or asset 45 | 46 | ##### AppUpdate.download(background=False) 47 | 48 | Downloads update 49 | 50 | ######Args: 51 | 52 | background (bool): Perform download in background thread 53 | 54 | ##### AppUpdate.extract() 55 | 56 | Will extract the update from its archive to the update folder. 57 | If updating a lib you can take over from there. If updating 58 | an app this call should be followed by method "restart" to 59 | complete update. 60 | 61 | ######Returns: 62 | 63 | (bool) True - Extract successful. False - Extract failed. 64 | 65 | ##### AppUpdate.extract_overwrite() 66 | 67 | Will extract the update then overwrite the current binary 68 | 69 | ##### AppUpdate.extract_restart() 70 | 71 | Will extract the update, overwrite the current binary, 72 | then restart the application using the updated binary. 73 | 74 | ##### AppUpdate.is_downloaded() 75 | 76 | Used to check if update has been downloaded. 77 | 78 | ######Returns (bool): 79 | 80 | True - File is already downloaded. 81 | 82 | False - File has not been downloaded. 83 | 84 | ### pyupdater.client.Client 85 | 86 | Used to check for updates & returns an updateobject if there 87 | is an update. 88 | 89 | ######Args: 90 | 91 | obj (instance): config object 92 | 93 | ######Kwargs: 94 | 95 | refresh (bool): True - Refresh update manifest on init 96 | False - Don't refresh update manifest on init 97 | 98 | progress_hooks (list): List of callbacks 99 | 100 | data_dir (str): Path to custom update folder 101 | 102 | headers (dict): A dictionary of generic and/or urllib3.utils.make_headers compatible headers 103 | 104 | strategy (str): The update strategy to use (default: overwrite). See the UpdateStrategy enum for options. 105 | 106 | test (bool): Used to initialize a test client 107 | 108 | #### Methods 109 | 110 | ##### Client.add_progress_hook(cb) 111 | 112 | Add a download progress callback function to the list of progress 113 | hooks. 114 | 115 | total: Total size of the file to download 116 | 117 | downloaded: The amount of bytes that have been downloaded so far. 118 | 119 | percent_complete: The percentage downloaded so far 120 | 121 | status: Status of download 122 | 123 | Args: 124 | 125 | cb (function): Function which takes a dict as its first argument 126 | 127 | ##### Client.refresh() 128 | 129 | Will download and verify the version manifest. 130 | 131 | ##### Client.update_check(name, version, channel='stable', strict=True) 132 | 133 | Checks for available updates 134 | 135 | ######Args: 136 | 137 | name (str): Name of file to update 138 | 139 | version (str): Current version number of file to update 140 | 141 | channel (str): Release channel 142 | 143 | strict (bool): 144 | True - Only look for updates on specified channel. 145 | False - Look for updates on all channels 146 | 147 | ######Returns: 148 | 149 | (updateobject): 150 | 151 | AppUpdate - Used to update current binary 152 | 153 | LibUpdate - Used to update external assets 154 | 155 | None - No Updates available 156 | 157 | ### pyupdater.client.ClientError 158 | 159 | Raised for Client exceptions 160 | 161 | #### Methods 162 | 163 | ##### ClientError.format_traceback() 164 | 165 | 166 | 167 | ### pyupdater.client.DefaultClientConfig 168 | 169 | 170 | 171 | #### Methods 172 | 173 | ### pyupdater.client.FileDownloader 174 | 175 | The FileDownloader object downloads files to memory and 176 | verifies their hash. If hash is verified data is either 177 | written to disk to returned to calling object 178 | 179 | ######Args: 180 | 181 | filename (str): The name of file to download 182 | 183 | urls (list): List of urls to use for file download 184 | 185 | hexdigest (str): The hash of the file to download 186 | 187 | ######Kwargs: 188 | 189 | headers (str): 190 | 191 | hexdigest (str): The hash of the file to download 192 | 193 | verify (bool): 194 | 195 | True: Verify https connection 196 | 197 | False: Do not verify https connection 198 | 199 | #### Methods 200 | 201 | ##### FileDownloader.download_verify_return() 202 | 203 | Downloads file to memory, checks against provided hash 204 | If matched returns binary data 205 | 206 | Returns: 207 | 208 | (data): 209 | 210 | Binary data - If hashes match or no hash was given during 211 | initialization. 212 | 213 | None - If any verification didn't pass 214 | 215 | ##### FileDownloader.download_verify_write() 216 | 217 | Downloads file then verifies against provided hash 218 | If hash verfies then writes data to disk 219 | 220 | Returns: 221 | 222 | (bool): 223 | 224 | True - Hashes match or no hash was given during initialization. 225 | 226 | False - Hashes don't match 227 | 228 | ### pyupdater.client.LibUpdate 229 | 230 | Used to update library files used by an application. This object is 231 | returned by pyupdater.client.Client.update_check 232 | 233 | ######Args: 234 | 235 | data (dict): Info dict 236 | 237 | #### Methods 238 | 239 | ##### LibUpdate.cleanup() 240 | 241 | Cleans up old update archives for this app or asset 242 | 243 | ##### LibUpdate.download(background=False) 244 | 245 | Downloads update 246 | 247 | ######Args: 248 | 249 | background (bool): Perform download in background thread 250 | 251 | ##### LibUpdate.extract() 252 | 253 | Will extract the update from its archive to the update folder. 254 | If updating a lib you can take over from there. If updating 255 | an app this call should be followed by method "restart" to 256 | complete update. 257 | 258 | ######Returns: 259 | 260 | (bool) True - Extract successful. False - Extract failed. 261 | 262 | ##### LibUpdate.is_downloaded() 263 | 264 | Used to check if update has been downloaded. 265 | 266 | ######Returns (bool): 267 | 268 | True - File is already downloaded. 269 | 270 | False - File has not been downloaded. 271 | 272 | ### pyupdater.client.UnpaddedBase64Encoder 273 | 274 | A simple encoder class to encode/decode to base64 with the 'padding' (trailing equal characters) removed. 275 | 276 | This is needed for PyNaCL to be compatible with keys generated by the old ed25519 library. 277 | 278 | #### Methods 279 | 280 | ##### UnpaddedBase64Encoder.decode(data) 281 | 282 | 283 | 284 | ##### UnpaddedBase64Encoder.encode(data) 285 | 286 | 287 | 288 | ### pyupdater.client.UpdateStrategy 289 | 290 | Enum representing the update strategies available 291 | 292 | #### Methods 293 | 294 | ### pyupdater.client.VerifyKey 295 | 296 | The public key counterpart to an Ed25519 SigningKey for producing digital 297 | signatures. 298 | 299 | :param key: [:class:`bytes`] Serialized Ed25519 public key 300 | :param encoder: A class that is able to decode the `key` 301 | 302 | #### Methods 303 | 304 | -------------------------------------------------------------------------------- /tests/test_cli.py: -------------------------------------------------------------------------------- 1 | # ------------------------------------------------------------------------------ 2 | # Copyright (c) 2015-2020 Digital Sapphire 3 | # 4 | # Permission is hereby granted, free of charge, to any person obtaining 5 | # a copy of this software and associated documentation files 6 | # (the "Software"), to deal in the Software without restriction, including 7 | # without limitation the rights to use, copy, modify, merge, publish, 8 | # distribute, sublicense, and/or sell copies of the Software, and to permit 9 | # persons to whom the Software is furnished to do so, subject to the 10 | # following conditions: 11 | # 12 | # The above copyright notice and this permission notice shall be 13 | # included in all copies or substantial portions of the Software. 14 | # 15 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF 16 | # ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED 17 | # TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A 18 | # PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT 19 | # SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR 20 | # ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN 21 | # ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 22 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE 23 | # OR OTHER DEALINGS IN THE SOFTWARE. 24 | # ------------------------------------------------------------------------------ 25 | from __future__ import unicode_literals 26 | 27 | import io 28 | import os 29 | 30 | import pytest 31 | 32 | from pyupdater.cli import commands, dispatch_command 33 | from pyupdater.cli.options import ( 34 | add_build_parser, 35 | add_clean_parser, 36 | add_keys_parser, 37 | add_make_spec_parser, 38 | add_package_parser, 39 | add_upload_parser, 40 | make_subparser, 41 | ) 42 | 43 | commands.TEST = True 44 | 45 | 46 | class NamespaceHelper(object): 47 | def __init__(self, **kwargs): 48 | self.reload(**kwargs) 49 | 50 | def _clear(self): 51 | self.__dict__.clear() 52 | 53 | def _pre_update(self): 54 | self.__dict__.update(test=True) 55 | 56 | def reload(self, **kwargs): 57 | self._clear() 58 | self._pre_update() 59 | self.__dict__.update(**kwargs) 60 | 61 | def __getattribute__(self, name): 62 | try: 63 | return object.__getattribute__(self, name) 64 | except AttributeError: 65 | self.__dict__[name] = None 66 | return None 67 | 68 | 69 | @pytest.mark.usefixtures("cleandir") 70 | class TestCommandDispatch(object): 71 | def test_build(self): 72 | assert dispatch_command(NamespaceHelper(command="build"), test=True) is True 73 | 74 | def test_clean(self): 75 | assert dispatch_command(NamespaceHelper(command="clean"), test=True) is True 76 | 77 | def test_settings(self): 78 | assert dispatch_command(NamespaceHelper(command="settings"), test=True) is True 79 | 80 | def test_init(self): 81 | assert dispatch_command(NamespaceHelper(command="init"), test=True) is True 82 | 83 | def test_keys(self): 84 | assert dispatch_command(NamespaceHelper(command="keys"), test=True) is True 85 | 86 | def test_make_spec(self): 87 | assert dispatch_command(NamespaceHelper(command="make-spec"), test=True) is True 88 | 89 | def test_pkg(self): 90 | assert dispatch_command(NamespaceHelper(command="pkg"), test=True) is True 91 | 92 | def test_collect_debug_info(self): 93 | assert ( 94 | dispatch_command(NamespaceHelper(command="collect-debug-info"), test=True) 95 | is True 96 | ) 97 | 98 | def test_upload(self): 99 | assert dispatch_command(NamespaceHelper(command="upload"), test=True) is True 100 | 101 | def test_version(self): 102 | assert dispatch_command(NamespaceHelper(command="version"), test=True) is True 103 | 104 | 105 | @pytest.mark.usefixtures("cleandir") 106 | class TestBuilder(object): 107 | def test_build_no_options(self, parser): 108 | subparser = make_subparser(parser) 109 | add_build_parser(subparser) 110 | with pytest.raises(SystemExit): 111 | parser.parse_known_args(["build"]) 112 | 113 | def test_build_no_arguments(self, parser, pyu): 114 | pyu.setup() 115 | subparser = make_subparser(parser) 116 | add_build_parser(subparser) 117 | with pytest.raises(SystemExit): 118 | with io.open("app.py", "w", encoding="utf-8") as f: 119 | f.write("from __future__ import print_function\n") 120 | f.write('print("Hello, World!")') 121 | opts, other = parser.parse_known_args(["build", "app.py"]) 122 | commands._cmd_build(opts, other) 123 | 124 | 125 | @pytest.mark.usefixtures("cleandir", "parser") 126 | class TestClean(object): 127 | def test_no_args(self, parser): 128 | subparser = make_subparser(parser) 129 | add_clean_parser(subparser) 130 | assert parser.parse_known_args(["clean"]) 131 | 132 | def test_execution(self, parser): 133 | update_folder = "pyu-data" 134 | data_folder = ".pyupdater" 135 | subparser = make_subparser(parser) 136 | add_clean_parser(subparser) 137 | os.mkdir(update_folder) 138 | os.mkdir(data_folder) 139 | args, other = parser.parse_known_args(["clean", "-y"]) 140 | commands._cmd_clean(args) 141 | assert not os.path.exists(update_folder) 142 | assert not os.path.exists(data_folder) 143 | 144 | def test_execution_no_clean(self, parser): 145 | update_folder = "pyu-data" 146 | data_folder = ".pyupdater" 147 | subparser = make_subparser(parser) 148 | add_clean_parser(subparser) 149 | args, other = parser.parse_known_args(["clean", "-y"]) 150 | commands._cmd_clean(args) 151 | assert not os.path.exists(update_folder) 152 | assert not os.path.exists(data_folder) 153 | 154 | 155 | @pytest.mark.usefixtures("cleandir", "parser") 156 | class TestKeys(object): 157 | def test_no_options(self, parser): 158 | subparser = make_subparser(parser) 159 | add_keys_parser(subparser) 160 | assert parser.parse_known_args(["keys"]) 161 | 162 | def test_create_keys(self): 163 | commands._cmd_keys(NamespaceHelper(command="keys", create_keys=True)) 164 | assert os.path.exists("keypack.pyu") 165 | 166 | 167 | @pytest.mark.usefixtures("cleandir", "parser", "pyu") 168 | class TestMakeSpec(object): 169 | def test_deprecated_opts(self, parser, pyu): 170 | pyu.setup() 171 | subparser = make_subparser(parser) 172 | add_make_spec_parser(subparser) 173 | with io.open("app.py", "w", encoding="utf-8") as f: 174 | f.write('print "Hello World"') 175 | opts, other = parser.parse_known_args(["make-spec", "-F", "app.py"]) 176 | commands._cmd_make_spec(opts, other) 177 | 178 | def test_execution(self, parser, pyu): 179 | pyu.setup() 180 | subparser = make_subparser(parser) 181 | add_make_spec_parser(subparser) 182 | with io.open("app.py", "w", encoding="utf-8") as f: 183 | f.write('print "Hello World"') 184 | opts, other = parser.parse_known_args(["make-spec", "-F", "app.py"]) 185 | commands._cmd_make_spec(opts, other) 186 | 187 | 188 | @pytest.mark.usefixtures("cleandir") 189 | class TestPkg(object): 190 | def test_pkg_execution(self, parser, pyu): 191 | subparser = make_subparser(parser) 192 | add_package_parser(subparser) 193 | pyu.update_config(pyu.config) 194 | pyu.setup() 195 | cmd = ["pkg", "-P", "-S"] 196 | opts, other = parser.parse_known_args(cmd) 197 | commands._cmd_pkg(opts) 198 | 199 | def test_pkg_execution_no_opts(self, parser, pyu): 200 | subparser = make_subparser(parser) 201 | add_package_parser(subparser) 202 | 203 | pyu.update_config(pyu.config) 204 | pyu.setup() 205 | cmd = ["pkg"] 206 | 207 | opts, other = parser.parse_known_args(cmd) 208 | commands._cmd_pkg(opts) 209 | -------------------------------------------------------------------------------- /pyupdater/core/uploader.py: -------------------------------------------------------------------------------- 1 | # ------------------------------------------------------------------------------ 2 | # Copyright (c) 2015-2020 Digital Sapphire 3 | # 4 | # Permission is hereby granted, free of charge, to any person obtaining 5 | # a copy of this software and associated documentation files 6 | # (the "Software"), to deal in the Software without restriction, including 7 | # without limitation the rights to use, copy, modify, merge, publish, 8 | # distribute, sublicense, and/or sell copies of the Software, and to permit 9 | # persons to whom the Software is furnished to do so, subject to the 10 | # following conditions: 11 | # 12 | # The above copyright notice and this permission notice shall be 13 | # included in all copies or substantial portions of the Software. 14 | # 15 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF 16 | # ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED 17 | # TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A 18 | # PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT 19 | # SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR 20 | # ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN 21 | # ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 22 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE 23 | # OR OTHER DEALINGS IN THE SOFTWARE. 24 | # ------------------------------------------------------------------------------ 25 | from __future__ import print_function, unicode_literals 26 | import logging 27 | import os 28 | 29 | from dsdev_utils.paths import remove_any 30 | from dsdev_utils.terminal import get_correct_answer 31 | 32 | from pyupdater import settings 33 | from pyupdater.utils import remove_dot_files, PluginManager 34 | from pyupdater.utils.exceptions import UploaderError, UploaderPluginError 35 | 36 | log = logging.getLogger(__name__) 37 | 38 | 39 | class Uploader(object): 40 | """Uploads updates to configured servers. SSH, SFTP, S3 41 | Will automatically pick the correct uploader depending on 42 | what is configured thorough the config object 43 | 44 | Sets up client with config values from obj 45 | 46 | Args: 47 | 48 | config (instance): config object 49 | """ 50 | 51 | def __init__(self, config, plugins=None): 52 | # Specifies whether to keep a file after uploading 53 | self.keep = False 54 | 55 | data_dir = os.path.join(os.getcwd(), settings.USER_DATA_FOLDER) 56 | self.deploy_dir = os.path.join(data_dir, "deploy") 57 | 58 | # The upload plugin that'll be used to upload our files 59 | self.uploader = None 60 | 61 | # Files to be uploaded 62 | self.files = [] 63 | 64 | # Extension Manager 65 | self.plg_mgr = PluginManager(config, plugins) 66 | 67 | def _get_files_to_upload(self, files=None): 68 | if files: 69 | self.files = files 70 | else: 71 | try: 72 | _files = os.listdir(self.deploy_dir) 73 | except OSError: 74 | _files = [] 75 | 76 | files = [] 77 | for f in _files: 78 | files.append(os.path.join(self.deploy_dir, f)) 79 | 80 | self.files = remove_dot_files(files) 81 | 82 | def get_plugin_names(self): 83 | return self.plg_mgr.get_plugin_names() 84 | 85 | def set_uploader(self, requested_uploader, keep=False): 86 | """Sets the named upload plugin. 87 | 88 | Args: 89 | 90 | requested_uploader (string): Either s3 or scp 91 | 92 | keep (bool): False to delete files after upload. 93 | True to keep files. Default False. 94 | 95 | """ 96 | self.keep = keep 97 | if isinstance(requested_uploader, str) is False: 98 | raise UploaderError("Must pass str to set_uploader", expected=True) 99 | 100 | self.uploader = self.plg_mgr.get_plugin(requested_uploader, init=True) 101 | if self.uploader is None: 102 | log.debug("PLUGIN_NAMESPACE: %s", self.plg_mgr.PLUGIN_NAMESPACE) 103 | raise UploaderPluginError( 104 | "Requested uploader is not installed", expected=True 105 | ) 106 | 107 | msg = "Requested uploader: {}".format(requested_uploader) 108 | log.debug(msg) 109 | 110 | def upload(self, files=None): 111 | """Uploads all files in file_list""" 112 | self._get_files_to_upload(files) 113 | 114 | failed_uploads = [] 115 | files_completed = 1 116 | file_count = len(self.files) 117 | log.info("Plugin: %s", self.uploader.name) 118 | log.info("Author: %s", self.uploader.author) 119 | 120 | for f in self.files: 121 | basename = os.path.basename(f) 122 | msg = "\n\nUploading: {}".format(basename) 123 | msg2 = " - File {} of {}\n".format(files_completed, file_count) 124 | print(msg + msg2) 125 | complete = self.uploader.upload_file(f) 126 | 127 | if complete: 128 | log.debug("%s uploaded successfully", basename) 129 | if self.keep is False: 130 | remove_any(f) 131 | files_completed += 1 132 | else: 133 | log.debug("%s failed to upload. will retry", basename) 134 | failed_uploads.append(f) 135 | 136 | if len(failed_uploads) > 0: 137 | failed_uploads = self._retry_upload(failed_uploads) 138 | 139 | if len(failed_uploads) < 1: 140 | return True 141 | else: 142 | log.error("The following files were not uploaded") 143 | for i in failed_uploads: 144 | log.error("%s failed to upload", os.path.basename(i)) 145 | return False 146 | 147 | def _retry_upload(self, failed_uploads): 148 | # Takes list of failed downloads and tries to re upload them 149 | retry = failed_uploads[:] 150 | failed_uploads = [] 151 | failed_count = len(retry) 152 | count = 1 153 | for f in retry: 154 | msg = "Retyring: {} - File {} of {}".format(f, count, failed_count) 155 | log.info(msg) 156 | complete = self.uploader.upload_file(f) 157 | if complete: 158 | log.debug("%s uploaded on retry", f) 159 | if self.keep is False: 160 | remove_any(f) 161 | count += 1 162 | else: 163 | failed_uploads.append(f) 164 | 165 | return failed_uploads 166 | 167 | 168 | # noinspection PyProtectedMember 169 | class AbstractBaseUploaderMeta(type): 170 | def __call__(cls, *args, **kwargs): 171 | obj = type.__call__(cls, *args, **kwargs) 172 | # We are using this meta class to ensure plugin authors have 173 | # a plugin name & author name as class attributes. 174 | obj._check_attributes() # noqa 175 | # End ignore 176 | return obj 177 | 178 | 179 | class BaseUploader(object, metaclass=AbstractBaseUploaderMeta): 180 | name = None 181 | author = None 182 | """Base Uploader. All uploaders should subclass 183 | this base class 184 | """ 185 | 186 | def _check_attributes(self): 187 | if self.name is None or self.author is None: 188 | raise NotImplementedError 189 | 190 | @staticmethod 191 | def get_answer(question, default=None): 192 | return get_correct_answer(question, default=default) 193 | 194 | def init_config(self, config): 195 | """Used to initialize plugin with saved config. 196 | 197 | Args: 198 | 199 | config (dict): config dict for plugin 200 | """ 201 | raise NotImplementedError( 202 | "{} by {} must implemented in " "subclass.".format(self.name, self.author) 203 | ) 204 | 205 | def set_config(self, config): 206 | """Used to ask user questions and return config 207 | for saving 208 | 209 | Args: 210 | 211 | config (dict): config dict that can be used to query 212 | already set values 213 | 214 | """ 215 | raise NotImplementedError( 216 | "{} by {} must implemented in " "subclass.".format(self.name, self.author) 217 | ) 218 | 219 | def upload_file(self, filename): 220 | """Uploads file to remote repository 221 | 222 | Args: 223 | filename (str): file to upload 224 | 225 | Returns: 226 | (bool): 227 | 228 | True - Upload Successful 229 | 230 | False - Upload Failed 231 | """ 232 | raise NotImplementedError( 233 | "{} by {} must implemented in " "subclass.".format(self.name, self.author) 234 | ) 235 | --------------------------------------------------------------------------------