59 Commits

Author SHA1 Message Date
Valentin Niess
08ee36fc45 Update PyPI action 2025-05-19 15:51:03 +02:00
Valentin Niess
41e9b109d0 Bump version: v1.3.1 2025-05-19 15:40:36 +02:00
Valentin Niess
755dd91f45 Tweak full version 2025-05-19 15:39:38 +02:00
Valentin Niess
1a777a00c4 Bump version: v1.3.0 2025-02-10 22:51:41 +01:00
Valentin Niess
fb54370a7e Patch which appimagetool 2025-02-10 22:41:55 +01:00
Valentin Niess
b8c443b27c Merge branch 'jorio-master' 2025-02-10 22:27:16 +01:00
Valentin Niess
72a52b6f34 Select appimagetool version 2025-02-10 18:31:05 +01:00
Iliyas Jorio
583a61686a Migrate to AppImage/appimagetool (fuse3) 2025-02-08 11:47:56 +01:00
Valentin Niess
736f8dcd7c Tweak the updater 2024-10-14 16:15:53 +02:00
Valentin Niess
da067b4831 Fetch deps before patching RPATH 2024-10-14 14:48:16 +02:00
Valentin Niess
efc41b6079 Alternative packages location 2024-10-11 11:44:50 +02:00
Valentin Niess
223f35f757 shutil.copyfile -> shutil.copy 2024-10-11 11:13:40 +02:00
Valentin Niess
bb2e178c2a Merge branch 'mxmlnkn-master' 2024-10-11 11:04:07 +02:00
Valentin Niess
ea671fe7ed Python 2 compat tweaks 2024-10-11 11:03:22 +02:00
Maximilian Knespel
c8fde2906a Replace distutils usage with os and shutil 2024-10-10 15:15:23 +02:00
Sławomir Zborowski
46b2efb359 Added information about extra data in the docs 2024-06-09 22:45:53 +02:00
Valentin Niess
0c66562ad4 Merge branch 'dotzborro-aux-files' 2024-05-22 09:37:03 +02:00
Valentin Niess
03bab9b38d Multiple extra data 2024-05-22 09:34:57 +02:00
Sławomir Zborowski
a6d0da5f0b Applied review remarks 2024-05-07 13:15:56 +02:00
Sławomir Zborowski
77ae6c7d55 Added support for bundling in auxilliary files 2024-04-28 22:48:00 +02:00
Valentin Niess
9de84d8b22 Remove 2.7 from PyPI test 2024-04-27 10:31:28 +02:00
Valentin Niess
899f40102a Bump version to 1.2.6 2024-04-27 10:19:59 +02:00
Valentin Niess
48b28af040 Merge branch 'dotzborro-git-dependencies-with-branch-name' 2024-04-27 10:10:51 +02:00
Sławomir Zborowski
955149ad6a Added support for git dependencies with custom branch name. 2024-04-27 00:08:40 +02:00
Valentin Niess
af59728145 Generate update summary 2023-11-12 23:41:03 +01:00
Valentin Niess
f5f7349f46 Update release messages 2023-11-12 22:56:40 +01:00
Valentin Niess
a2a075f9db Manual triggers for actions 2023-11-11 01:16:04 +01:00
Valentin Niess
4bc98f48d4 Option to force update 2023-11-10 23:44:55 +01:00
Valentin Niess
fd7e28817c Manage SHA of git tags 2023-11-10 22:43:46 +01:00
Valentin Niess
602f65c0e8 Merge branch 'master' into dev 2023-11-10 15:17:59 +01:00
Valentin Niess
4fcdf2cba1 Python AppImages updater 2023-11-10 15:04:24 +01:00
Valentin Niess
e249fdebdb Use a named logger 2023-11-10 10:18:12 +01:00
Valentin Niess
e596fec38b List command for manylinux images 2023-11-08 18:06:02 +01:00
Valentin Niess
818fe273c1 Add $ORIGIN/../lib to RPATH 2023-02-15 10:43:43 +01:00
Valentin Niess
db5d91e0dd Bump version to 1.2.5 2022-12-23 14:06:05 +01:00
Valentin Niess
c28641bd84 Change OS for app test 2022-12-23 13:56:36 +01:00
Valentin Niess
d5875464d0 Explicit licenses 2022-12-23 13:40:44 +01:00
Valentin Niess
6fbb227e3a Correct PyPI url 2022-11-28 20:14:40 +01:00
Valentin Niess
b5ad9a6dcf Bump version to 1.2.4 2022-11-14 22:36:11 +01:00
Valentin Niess
4ec94ba00e Document 2_24 and 2_28 2022-11-14 22:32:29 +01:00
Valentin Niess
6bfb15b186 Add Manylinux 2_24 and 2_28 2022-11-14 18:45:15 +01:00
Valentin Niess
2161858718 Bump version to 1.2.3 2022-09-12 10:31:07 +02:00
Valentin Niess
6dfa764573 Protect spaces (issue 55) 2022-09-12 10:24:14 +02:00
Valentin Niess
df67460a7c Bump version to 1.2.2 2022-09-08 11:17:55 +02:00
Valentin Niess
9706c81569 Explicit version for tasmotizer test 2022-09-08 11:12:48 +02:00
Valentin Niess
528f797ddf Locate Tcl/Tk using tclsh 2022-09-08 10:27:03 +02:00
Valentin
d7fe43facf Bump version to 1.2.1 2022-08-06 22:37:41 +02:00
Stanislav Dimitrov
5d085e38ee Use importlib.util, if importing importlib fails.
Issue reported for python>3.8
2022-08-06 22:29:16 +02:00
Valentin Niess
678aae1393 Bump version to 1.2.0 2022-07-21 10:53:47 +02:00
Valentin Niess
96a8cbbfab Document local requirements 2022-07-21 10:53:15 +02:00
Valentin Niess
e2efafa081 Patch isolation flag 2022-07-21 10:31:56 +02:00
Vladimir Ivan
061fd7414d Python 2 compat for local requirements
(cherry picked from commit c34610e63cc9b509d324bc0ffb09c100063af74e)
2022-07-21 10:17:28 +02:00
Vladimir Ivan
7b9b4f2b75 Pip in isolated environment 2022-07-21 10:17:28 +02:00
Vladimir Ivan
a99d31e661 Added local copy option to dependency check 2022-07-21 10:17:28 +02:00
Valentin Niess
984a1ccec0 Bump version to 1.1.4 2022-07-18 15:16:54 +02:00
Valentin Niess
bbd549c3a1 Patch log import in system 2022-07-18 15:14:40 +02:00
Valentin Niess
5513645e55 Bump version to 1.1.3 2022-07-18 15:02:38 +02:00
Valentin Niess
dc54fa8231 Remove 3.11 from manylinux 2010 2022-07-18 14:55:32 +02:00
Valentin Niess
d259ad4f49 Tolerate single line warning(s) 2022-07-18 14:48:26 +02:00
27 changed files with 803 additions and 197 deletions

View File

@@ -1,89 +1,36 @@
name: AppImage name: AppImage
on: on:
push: workflow_dispatch:
branches: inputs:
- master dry:
paths: description: 'Dry run'
- '.github/workflows/appimage.yml' required: true
- 'python_appimage/**' type: boolean
all:
description: 'Update all'
required: true
type: boolean
schedule: schedule:
- cron: '0 3 * * 0' - cron: '0 3 * * 0'
jobs: jobs:
Build: Update:
runs-on: ubuntu-latest runs-on: ubuntu-latest
strategy: permissions:
matrix: contents: write
image: ['1', '2010', '2014']
arch: [x86_64, i686]
tag: [cp27-cp27m, cp27-cp27mu, cp35-cp35m, cp36-cp36m, cp37-cp37m,
cp38-cp38, cp39-cp39, cp310-cp310, cp311-cp311]
exclude:
- image: '1'
tag: cp310-cp310
- image: '1'
tag: cp311-cp311
- image: '2010'
tag: cp27-cp27m
- image: '2010'
tag: cp27-cp27mu
- image: '2010'
tag: cp35-cp35m
- image: '2014'
tag: cp27-cp27m
- image: '2014'
tag: cp27-cp27mu
- image: '2014'
tag: cp35-cp35m
steps: steps:
- uses: actions/checkout@v2 - uses: actions/checkout@v4
- name: Build - name: Install Dependencies
env: run: pip install PyGithub
ACTIONS_ALLOW_UNSECURE_COMMANDS: 'true'
- name: Run updater
run: | run: |
# Build the AppImage ./scripts/update-appimages.py \
python -m python_appimage build manylinux \ --token=${{ secrets.GITHUB_TOKEN }} \
${{ matrix.image }}_${{ matrix.arch }} \ --sha=${{ github.sha }} \
${{ matrix.tag }} ${{ inputs.all && '--all' || '' }} \
${{ inputs.dry && '--dry' || '' }}
# Export the AppImage name and the Python version env:
appimage=$(ls python*.AppImage) PYTHONPATH: ${{ github.workspace }}
SCRIPT=$(cat <<-END
version = '${appimage}'[6:].split('.', 2)
print('{:}.{:}'.format(*version[:2]))
END
)
version=$(python -c "${SCRIPT}")
echo "::set-env name=PYTHON_APPIMAGE::${appimage}"
echo "::set-env name=PYTHON_VERSION::${version}"
- uses: actions/upload-artifact@v1
if: github.ref == 'refs/heads/master'
with:
name: python${{ env.PYTHON_VERSION }}-appimages
path: ${{ env.PYTHON_APPIMAGE }}
Release:
needs: Build
runs-on: ubuntu-latest
if: github.ref == 'refs/heads/master'
strategy:
matrix:
version: ['2.7', '3.5', '3.6', '3.7', '3.8', '3.9', '3.10', '3.11']
steps:
- uses: actions/download-artifact@v1
with:
name: python${{ matrix.version }}-appimages
- name: Release
uses: marvinpinto/action-automatic-releases@latest
with:
automatic_release_tag: python${{ matrix.version }}
title: Python ${{ matrix.version }}
files: |
python${{ matrix.version }}-appimages/python*.AppImage
repo_token: ${{ secrets.GITHUB_TOKEN }}

View File

@@ -1,27 +1,39 @@
name: Applications name: Applications
on: on:
push: workflow_dispatch:
branches: inputs:
- master scipy:
paths: required: true
- '.github/workflows/applications.yml' default: true
- 'applications/**' type: boolean
- 'python_appimage/**' tasmotizer:
required: true
default: true
type: boolean
xonsh:
required: true
default: true
type: boolean
ssh-mitm:
required: true
default: true
type: boolean
jobs: jobs:
Test: Test:
runs-on: ubuntu-latest runs-on: ubuntu-20.04
strategy: strategy:
matrix: matrix:
version: ['2.7', '3.7', '3.9'] version: ['2.7', '3.7', '3.9']
steps: steps:
- uses: actions/checkout@v2 - uses: actions/checkout@v4
- uses: actions/setup-python@v1 - uses: actions/setup-python@v4
with: with:
python-version: ${{ matrix.version }} python-version: ${{ matrix.version }}
- name: Test scipy - name: Test scipy
if: ${{ inputs.scipy }}
run: | run: |
python -m python_appimage build app applications/scipy \ python -m python_appimage build app applications/scipy \
--python-version=2.7 \ --python-version=2.7 \
@@ -30,19 +42,22 @@ jobs:
./scipy-x86_64.AppImage -c 'import numpy, pandas, scipy' ./scipy-x86_64.AppImage -c 'import numpy, pandas, scipy'
- name: Test tasmotizer - name: Test tasmotizer
if: ${{ inputs.tasmotizer }}
run: | run: |
python -m python_appimage build app applications/tasmotizer \ python -m python_appimage build app applications/tasmotizer \
--linux-tag=manylinux2014_x86_64 --linux-tag=manylinux1_x86_64 \
--python-version=3.9
test -e tasmotizer-x86_64.AppImage test -e tasmotizer-x86_64.AppImage
- name: Test xonsh - name: Test xonsh
if: ${{ inputs.xonsh }}
run: | run: |
python -m python_appimage build app applications/xonsh python -m python_appimage build app applications/xonsh
test -e xonsh-x86_64.AppImage test -e xonsh-x86_64.AppImage
./xonsh-x86_64.AppImage -c 'import xonsh' ./xonsh-x86_64.AppImage -c 'import xonsh'
- name: Test ssh-mitm - name: Test ssh-mitm
if: ${{ matrix.version == '3.9' }} if: ${{ inputs.ssh_mitm && (matrix.version == '3.9') }}
run: | run: |
python -m python_appimage build app applications/ssh-mitm python -m python_appimage build app applications/ssh-mitm
test -e ssh-mitm-x86_64.AppImage test -e ssh-mitm-x86_64.AppImage

View File

@@ -1,13 +0,0 @@
name: Delete artifacts
on:
schedule:
- cron: '0 3 * * 0'
jobs:
delete-artifacts:
runs-on: ubuntu-latest
steps:
- uses: kolpav/purge-artifacts-action@v1
with:
token: ${{ secrets.GITHUB_TOKEN }}
expire-in: 0days

View File

@@ -5,17 +5,23 @@ on:
- master - master
paths: paths:
- 'VERSION' - 'VERSION'
workflow_dispatch:
inputs:
upload:
description: 'Upload to PyPI'
required: true
type: boolean
jobs: jobs:
Test: Test:
runs-on: ubuntu-latest runs-on: ubuntu-latest
strategy: strategy:
matrix: matrix:
version: ['2.7', '3.9'] version: ['3.11']
steps: steps:
- uses: actions/checkout@v2 - uses: actions/checkout@v4
- uses: actions/setup-python@v1 - uses: actions/setup-python@v5
with: with:
python-version: ${{ matrix.version }} python-version: ${{ matrix.version }}
@@ -31,10 +37,10 @@ jobs:
if: github.ref == 'refs/heads/master' if: github.ref == 'refs/heads/master'
steps: steps:
- uses: actions/checkout@v2 - uses: actions/checkout@v4
- uses: actions/setup-python@v1 - uses: actions/setup-python@v5
with: with:
python-version: '3.9' python-version: '3.11'
- name: Build wheel - name: Build wheel
run: | run: |
@@ -43,7 +49,7 @@ jobs:
python setup.py bdist_wheel --universal python setup.py bdist_wheel --universal
- name: Upload to PyPI - name: Upload to PyPI
if: github.ref == 'refs/heads/master' if: (github.ref == 'refs/heads/master') && inputs.upload
uses: pypa/gh-action-pypi-publish@master uses: pypa/gh-action-pypi-publish@master
with: with:
password: ${{ secrets.PYPI_TOKEN }} password: ${{ secrets.PYPI_TOKEN }}

View File

@@ -31,11 +31,22 @@ Python apps, given an existing Python AppImage and a recipe folder.
through the ssh through the ssh
## License
The [`python-appimage`][PYPI] package (**A**) is under the GNU GPLv3 license,
except for files located under `python_appimage/data` which are MIT licensed.
Thus, the produced Manylinux Python AppImages (**B**) are not GPL'd. They
contain a CPython distribution that is (mostly) under the [PSF
license][PSF_LICENSE]. Other parts of **B** (e.g. `AppRun`) are under the MIT
license.
[APPLICATIONS]: https://github.com/niess/python-appimage/tree/master/applications [APPLICATIONS]: https://github.com/niess/python-appimage/tree/master/applications
[APPIMAGE]: https://appimage.org/ [APPIMAGE]: https://appimage.org/
[GITHUB]: https://github.com/niess/python-appimage [GITHUB]: https://github.com/niess/python-appimage
[GRAND]: http://grand.cnrs.fr [GRAND]: http://grand.cnrs.fr
[MANYLINUX]: https://github.com/pypa/manylinux [MANYLINUX]: https://github.com/pypa/manylinux
[PSF_LICENSE]: https://docs.python.org/3/license.html#psf-license
[PYPI]: https://pypi.org/project/python-appimage/ [PYPI]: https://pypi.org/project/python-appimage/
[READTHEDOCS]: https://python-appimage.readthedocs.io/en/latest/ [READTHEDOCS]: https://python-appimage.readthedocs.io/en/latest/
[RELEASES]: https://github.com/niess/python-appimage/releases [RELEASES]: https://github.com/niess/python-appimage/releases

View File

@@ -1 +1 @@
1.1.2 1.3.1

View File

@@ -13,7 +13,7 @@
[LINUXDEPLOY]: https://github.com/linuxdeploy/linuxdeploy/ [LINUXDEPLOY]: https://github.com/linuxdeploy/linuxdeploy/
[MANYLINUX]: https://github.com/pypa/manylinux/ [MANYLINUX]: https://github.com/pypa/manylinux/
[PATCHELF]: https://github.com/NixOS/patchelf/ [PATCHELF]: https://github.com/NixOS/patchelf/
[PYPI]: https://pypi.org/project/python-appimaAge/ [PYPI]: https://pypi.org/project/python-appimage/
[RELEASES]: {{ config.repo_url }}releases/ [RELEASES]: {{ config.repo_url }}releases/
[SHEBANG]: https://en.wikipedia.org/wiki/Shebang_(Unix)/ [SHEBANG]: https://en.wikipedia.org/wiki/Shebang_(Unix)/
[VENV]: https://docs.python.org/3/library/venv.html/ [VENV]: https://docs.python.org/3/library/venv.html/

View File

@@ -121,9 +121,9 @@ files. The `requirements.txt` file allows to specify additional site packages
to be bundled in the AppImage, using `pip`. to be bundled in the AppImage, using `pip`.
!!! Caution !!! Caution
Site packages bundled in the AppImage, as well as their dependencies, must For the application to be portable, site packages bundled in the AppImage,
either be pure python packages, or they must be available as portable binary as well as their dependencies, must must be available as binary wheels, or
wheels. be pure Python packages.
If a **C extension** is bundled from **source**, then it will likely **not If a **C extension** is bundled from **source**, then it will likely **not
be portable**, as further discussed in the [Advanced be portable**, as further discussed in the [Advanced
@@ -134,6 +134,13 @@ to be bundled in the AppImage, using `pip`.
be cross-checked by browsing the `Download files` section on the package's be cross-checked by browsing the `Download files` section on the package's
PyPI page. PyPI page.
!!! Tip
Since version 1.2, `python-appimage` allows to specify local requirements as
well, using the `local+` tag (see
[PR49](https://github.com/niess/python-appimage/pull/49)). Note however that
this performs a direct copy of the local package, which has several
limitations.
{{ begin(".capsule") }} {{ begin(".capsule") }}
### Entry point script ### Entry point script
@@ -175,6 +182,36 @@ example, `$APPDIR` points to the AppImage mount point at runtime.
`{{ python-executable }} -I` starts a fully isolated Python instance. `{{ python-executable }} -I` starts a fully isolated Python instance.
{% endraw %} {% endraw %}
### Bundling data files
`python-appimage` is also capable of bundling in auxilliary data files directly
into the resulting AppImage. `-x/--extra-data` switch exists for that task.
Consider following example.
```bash
echo -n "foo" > foo
mkdir bar
echo -n "baz" > bar/baz
python-appimage [your regular parameters] -x foo bar/*
```
User data included in such a way becomes accessible to the Python code
contained within the AppImage in a form of regular files under the directory
pointed to by `APPDIR` environment variable. Example of Python 3 script
that reads these exemplary files is presented below.
```python
import os, pathlib
for fileName in ("foo", "baz"):
print((pathlib.Path(os.getenv("APPDIR")) / fileName).read_text())
```
Above code, when executed, would print following output.
```bash
foo
baz
```
## Advanced packaging ## Advanced packaging

View File

@@ -11,11 +11,23 @@ $.getJSON("https://api.github.com/repos/niess/python-appimage/releases").done(fu
/* Parse AppImage metadata */ /* Parse AppImage metadata */
const tmp0 = asset.name.split("manylinux") const tmp0 = asset.name.split("manylinux")
const tag = tmp0[1].slice(0,-9); const tag = tmp0[1].slice(0,-9);
const tmp1 = tag.split(/_(.+)/); const tmp1 = tag.split(/_(.+)/, 2);
const linux = tmp1[0] var linux = undefined;
const arch = tmp1[1] var arch = undefined;
const tmp2 = tmp0[0].split("-") if (tmp1[0] == "") {
const python = tmp2[1] + "-" + tmp2[2] const tmp3 = tmp1[1].split("_");
linux = tmp3[0] + "_" + tmp3[1];
if (tmp3.length == 3) {
arch = tmp3[2];
} else {
arch = tmp3[2] + "_" + tmp3[3];
}
} else {
linux = tmp1[0];
arch = tmp1[1];
}
const tmp2 = tmp0[0].split("-", 3);
const python = tmp2[1] + "-" + tmp2[2];
assets.push({ assets.push({
name: asset.name, name: asset.name,
url: asset.browser_download_url, url: asset.browser_download_url,
@@ -25,8 +37,8 @@ $.getJSON("https://api.github.com/repos/niess/python-appimage/releases").done(fu
}); });
if (full_version === undefined) { if (full_version === undefined) {
const index = asset.name.indexOf("-") const index = asset.name.indexOf("-");
full_version = asset.name.slice(6, index) full_version = asset.name.slice(6, index);
} }
} }
} }
@@ -162,6 +174,17 @@ $.getJSON("https://api.github.com/repos/niess/python-appimage/releases").done(fu
ln -s ${appdir}/AppRun python${release.version} ln -s ${appdir}/AppRun python${release.version}
`); `);
set_snippet("#repackaging-example", `\
wget https://github.com/AppImage/AppImageKit/releases/download/continuous/\\
appimagetool-x86_64.AppImage
chmod +x appimagetool-x86_64.AppImage
./appimagetool-x86_64.AppImage \\
${appdir} \\
${asset.name}
`);
} }

View File

@@ -1,6 +1,5 @@
import argparse import argparse
from importlib import import_module from importlib import import_module
import logging
import os import os
import sys import sys
@@ -8,6 +7,11 @@ import sys
__all__ = ['main'] __all__ = ['main']
def exists(path):
if not os.path.exists(path):
raise argparse.ArgumentTypeError("could not find: {}".format(path))
return os.path.abspath(path)
def main(): def main():
'''Entry point for the CLI '''Entry point for the CLI
''' '''
@@ -23,10 +27,12 @@ def main():
help='Command to execute', help='Command to execute',
dest='command') dest='command')
parser.add_argument('-a', '--appimagetool-version',
help='set appimagetool version')
parser.add_argument('-q', '--quiet', help='disable logging', parser.add_argument('-q', '--quiet', help='disable logging',
dest='verbosity', action='store_const', const=logging.ERROR) dest='verbosity', action='store_const', const='ERROR')
parser.add_argument('-v', '--verbose', help='print extra information', parser.add_argument('-v', '--verbose', help='print extra information',
dest='verbosity', action='store_const', const=logging.DEBUG) dest='verbosity', action='store_const', const='DEBUG')
install_parser = subparsers.add_parser('install', install_parser = subparsers.add_parser('install',
description='Install binary dependencies') description='Install binary dependencies')
@@ -74,6 +80,13 @@ def main():
help='force pip in-tree-build', help='force pip in-tree-build',
action='store_true', action='store_true',
default=False) default=False)
build_app_parser.add_argument('-x', '--extra-data', type=exists,
help='extra application data (bundled under $APPDIR/)', nargs='+')
list_parser = subparsers.add_parser('list',
description='List Python versions installed in a manylinux image')
list_parser.add_argument('tag',
help='manylinux image tag (e.g. 2010_x86_64)')
which_parser = subparsers.add_parser('which', which_parser = subparsers.add_parser('which',
description='Locate a binary dependency') description='Locate a binary dependency')
@@ -84,7 +97,12 @@ def main():
# Configure the verbosity # Configure the verbosity
if args.verbosity: if args.verbosity:
logging.getLogger().setLevel(args.verbosity) from .utils import log
log.set_level(args.verbosity)
if args.appimagetool_version:
from .utils import deps
deps.APPIMAGETOOL_VERSION = args.appimagetool_version
# check if no arguments are passed # check if no arguments are passed
if args.command is None: if args.command is None:

View File

@@ -5,7 +5,7 @@ import subprocess
import sys import sys
from ..utils.compat import decode from ..utils.compat import decode
from ..utils.deps import APPIMAGETOOL, ensure_appimagetool from ..utils.deps import ensure_appimagetool
from ..utils.docker import docker_run from ..utils.docker import docker_run
from ..utils.fs import copy_tree from ..utils.fs import copy_tree
from ..utils.log import debug, log from ..utils.log import debug, log
@@ -22,10 +22,10 @@ def build_appimage(appdir=None, destination=None):
appdir = 'AppDir' appdir = 'AppDir'
log('BUILD', appdir) log('BUILD', appdir)
ensure_appimagetool() appimagetool = ensure_appimagetool()
arch = platform.machine() arch = platform.machine()
cmd = ['ARCH=' + arch, APPIMAGETOOL, '--no-appstream', appdir] cmd = ['ARCH=' + arch, appimagetool, '--no-appstream', appdir]
if destination is not None: if destination is not None:
cmd.append(destination) cmd.append(destination)
cmd = ' '.join(cmd) cmd = ' '.join(cmd)
@@ -45,7 +45,8 @@ def build_appimage(appdir=None, destination=None):
elif out: elif out:
out = out.replace('%', '%%')[:-1] out = out.replace('%', '%%')[:-1]
for line in out.split(os.linesep): for line in out.split(os.linesep):
if line.startswith('WARNING'): if line.startswith('WARNING') and \
not line[9:].startswith('zsyncmake command is missing'):
log('WARNING', line[9:]) log('WARNING', line[9:])
elif line.startswith('Error'): elif line.startswith('Error'):
raise RuntimeError(line) raise RuntimeError(line)

View File

@@ -34,6 +34,15 @@ def _get_tk_version(python_pkg):
raise RuntimeError('could not guess Tcl/Tk version') raise RuntimeError('could not guess Tcl/Tk version')
def _get_tk_libdir(version):
try:
library = system(('tclsh' + version,), stdin='puts [info library]')
except SystemError:
raise RuntimeError('could not locate Tcl/Tk' + version + ' library')
return os.path.dirname(library)
def tcltk_env_string(python_pkg): def tcltk_env_string(python_pkg):
'''Environment for using AppImage's TCl/Tk '''Environment for using AppImage's TCl/Tk
''' '''
@@ -85,15 +94,16 @@ def patch_binary(path, libdir, recursive=True):
else: else:
excluded = _excluded_libs excluded = _excluded_libs
deps = ldd(path) # Fetch deps before patching RPATH.
ensure_patchelf() ensure_patchelf()
rpath = '\'' + system((PATCHELF, '--print-rpath', path)) + '\'' rpath = '\'' + system((PATCHELF, '--print-rpath', path)) + '\''
relpath = os.path.relpath(libdir, os.path.dirname(path)) relpath = os.path.relpath(libdir, os.path.dirname(path))
relpath = '' if relpath == '.' else '/' + relpath relpath = '' if relpath == '.' else '/' + relpath
expected = '\'$ORIGIN' + relpath + '\'' expected = '\'$ORIGIN' + relpath + ':$ORIGIN/../lib\''
if rpath != expected: if rpath != expected:
system((PATCHELF, '--set-rpath', expected, path)) system((PATCHELF, '--set-rpath', expected, path))
deps = ldd(path)
for dep in deps: for dep in deps:
name = os.path.basename(dep) name = os.path.basename(dep)
if name in excluded: if name in excluded:
@@ -162,11 +172,11 @@ def relocate_python(python=None, appdir=None):
# Set some key variables & paths # Set some key variables & paths
if python: if python:
FULLVERSION = system((python, '-c', FULLVERSION = system((python, '-c', '"import sys; print(sys.version)"'))
'"import sys; print(\'{:}.{:}.{:}\'.format(*sys.version_info[:3]))"'))
FULLVERSION = FULLVERSION.strip() FULLVERSION = FULLVERSION.strip()
else: else:
FULLVERSION = '{:}.{:}.{:}'.format(*sys.version_info[:3]) FULLVERSION = sys.version
FULLVERSION = FULLVERSION.split(None, 1)[0]
VERSION = '.'.join(FULLVERSION.split('.')[:2]) VERSION = '.'.join(FULLVERSION.split('.')[:2])
PYTHON_X_Y = 'python' + VERSION PYTHON_X_Y = 'python' + VERSION
PIP_X_Y = 'pip' + VERSION PIP_X_Y = 'pip' + VERSION
@@ -193,9 +203,21 @@ def relocate_python(python=None, appdir=None):
PYTHON_LIB = PYTHON_PREFIX + '/lib' PYTHON_LIB = PYTHON_PREFIX + '/lib'
PYTHON_PKG = PYTHON_LIB + '/' + PYTHON_X_Y PYTHON_PKG = PYTHON_LIB + '/' + PYTHON_X_Y
if not os.path.exists(HOST_PKG):
paths = glob.glob(HOST_PKG + '*')
if paths:
HOST_PKG = paths[0]
PYTHON_PKG = PYTHON_LIB + '/' + os.path.basename(HOST_PKG)
else:
raise ValueError('could not find {0:}'.format(HOST_PKG))
if not os.path.exists(HOST_INC): if not os.path.exists(HOST_INC):
HOST_INC += 'm' paths = glob.glob(HOST_INC + '*')
PYTHON_INC += 'm' if paths:
HOST_INC = paths[0]
PYTHON_INC = PYTHON_INC + '/' + os.path.basename(HOST_INC)
else:
raise ValueError('could not find {0:}'.format(HOST_INC))
# Copy the running Python's install # Copy the running Python's install
@@ -280,22 +302,13 @@ def relocate_python(python=None, appdir=None):
tcltkdir = APPDIR_SHARE + '/tcltk' tcltkdir = APPDIR_SHARE + '/tcltk'
if (not os.path.exists(tcltkdir + '/tcl' + tk_version)) or \ if (not os.path.exists(tcltkdir + '/tcl' + tk_version)) or \
(not os.path.exists(tcltkdir + '/tk' + tk_version)): (not os.path.exists(tcltkdir + '/tk' + tk_version)):
hostdir = '/usr/share/tcltk' libdir = _get_tk_libdir(tk_version)
if os.path.exists(hostdir): log('INSTALL', 'Tcl/Tk' + tk_version)
make_tree(APPDIR_SHARE) make_tree(tcltkdir)
copy_tree(hostdir, tcltkdir) tclpath = libdir + '/tcl' + tk_version
else: copy_tree(tclpath, tcltkdir + '/tcl' + tk_version)
make_tree(tcltkdir) tkpath = libdir + '/tk' + tk_version
tclpath = '/usr/share/tcl' + tk_version copy_tree(tkpath, tcltkdir + '/tk' + tk_version)
if not tclpath:
raise ValueError('could not find ' + tclpath)
copy_tree(tclpath, tcltkdir + '/tcl' + tk_version)
tkpath = '/usr/share/tk' + tk_version
if not tkpath:
raise ValueError('could not find ' + tkpath)
copy_tree(tkpath, tcltkdir + '/tk' + tk_version)
# Copy any SSL certificate # Copy any SSL certificate
cert_file = os.getenv('SSL_CERT_FILE') cert_file = os.getenv('SSL_CERT_FILE')

View File

@@ -8,9 +8,9 @@ import stat
import struct import struct
from ...appimage import build_appimage from ...appimage import build_appimage
from ...utils.compat import decode from ...utils.compat import decode, find_spec
from ...utils.deps import PREFIX from ...utils.deps import PREFIX
from ...utils.fs import copy_file, make_tree, remove_file, remove_tree from ...utils.fs import copy_file, copy_tree, make_tree, remove_file, remove_tree
from ...utils.log import log from ...utils.log import log
from ...utils.system import system from ...utils.system import system
from ...utils.template import copy_template, load_template from ...utils.template import copy_template, load_template
@@ -26,14 +26,16 @@ def _unpack_args(args):
'''Unpack command line arguments '''Unpack command line arguments
''' '''
return args.appdir, args.name, args.python_version, args.linux_tag, \ return args.appdir, args.name, args.python_version, args.linux_tag, \
args.python_tag, args.base_image, args.in_tree_build args.python_tag, args.base_image, args.in_tree_build, \
args.extra_data
_tag_pattern = re.compile('python([^-]+)[-]([^.]+)[.]AppImage') _tag_pattern = re.compile('python([^-]+)[-]([^.]+)[.]AppImage')
_linux_pattern = re.compile('manylinux([0-9]+)_' + platform.machine()) _linux_pattern = re.compile('manylinux([0-9]+)_' + platform.machine())
def execute(appdir, name=None, python_version=None, linux_tag=None, def execute(appdir, name=None, python_version=None, linux_tag=None,
python_tag=None, base_image=None, in_tree_build=False): python_tag=None, base_image=None, in_tree_build=False,
extra_data=None):
'''Build a Python application using a base AppImage '''Build a Python application using a base AppImage
''' '''
@@ -253,18 +255,49 @@ def execute(appdir, name=None, python_version=None, linux_tag=None,
'WARNING: Running pip as' 'WARNING: Running pip as'
) )
system(('./AppDir/AppRun', '-m', 'pip', 'install', '-U', in_tree_build, git_warnings = (
re.compile(r'\s+Running command git (clone|checkout) '),
re.compile(r"\s+Branch '.*' set up to track remote"),
re.compile(r"\s+Switched to a new branch '.*'"),
)
isolation_flag = '-sE' if python_version[0] == '2' else '-I'
system(('./AppDir/AppRun', isolation_flag, '-m', 'pip', 'install', '-U', in_tree_build,
'--no-warn-script-location', 'pip'), exclude=deprecation) '--no-warn-script-location', 'pip'), exclude=deprecation)
for requirement in requirements_list: for requirement in requirements_list:
if requirement.startswith('git+'): if requirement.startswith('git+'):
url, name = os.path.split(requirement) url, name = os.path.split(requirement)
log('BUNDLE', name + ' from ' + url[4:]) log('BUNDLE', name + ' from ' + url[4:])
elif requirement.startswith('local+'):
name = requirement[6:]
source = find_spec(name).origin
if source.endswith('/__init__.py'):
source = os.path.dirname(source)
elif source.endswith('/'):
source = source[:-1]
log('BUNDLE', name + ' from ' + source)
if os.path.isfile(source):
destination = 'AppDir/opt/python{0:}/lib/python{0:}/site-packages/'.format(python_version)
copy_file(source, destination)
else:
destination = 'AppDir/opt/python{0:}/lib/python{0:}/site-packages/{1:}'.format(python_version, name)
copy_tree(source, destination)
continue
else: else:
log('BUNDLE', requirement) log('BUNDLE', requirement)
system(('./AppDir/AppRun', '-m', 'pip', 'install', '-U', in_tree_build, system(('./AppDir/AppRun', isolation_flag, '-m', 'pip', 'install', '-U', in_tree_build,
'--no-warn-script-location', requirement), '--no-warn-script-location', requirement),
exclude=(deprecation, ' Running command git clone')) exclude=(deprecation + git_warnings))
# Bundle auxilliary application data
if extra_data is not None:
for path in extra_data:
basename = os.path.basename(path)
log('BUNDLE', basename)
if os.path.isdir(path):
copy_tree(path, 'AppDir/' + basename)
else:
copy_file(path, 'AppDir/')
# Bundle the entry point # Bundle the entry point
entrypoint_path = glob.glob(appdir + '/entrypoint.*') entrypoint_path = glob.glob(appdir + '/entrypoint.*')

View File

@@ -7,6 +7,7 @@ import sys
from ...appimage import build_appimage, relocate_python from ...appimage import build_appimage, relocate_python
from ...utils.docker import docker_run from ...utils.docker import docker_run
from ...utils.fs import copy_tree from ...utils.fs import copy_tree
from ...utils.manylinux import format_appimage_name, format_tag
from ...utils.tmp import TemporaryDirectory from ...utils.tmp import TemporaryDirectory
@@ -27,8 +28,7 @@ def _get_appimage_name(abi, tag):
fullversion = desktop[13:-8] fullversion = desktop[13:-8]
# Finish building the AppImage on the host. See below. # Finish building the AppImage on the host. See below.
return 'python{:}-{:}-manylinux{:}.AppImage'.format( return format_appimage_name(abi, fullversion, tag)
fullversion, abi, tag)
def execute(tag, abi, contained=False): def execute(tag, abi, contained=False):
@@ -37,7 +37,7 @@ def execute(tag, abi, contained=False):
if not contained: if not contained:
# Forward the build to a Docker image # Forward the build to a Docker image
image = 'quay.io/pypa/manylinux' + tag image = 'quay.io/pypa/' + format_tag(tag)
python = '/opt/python/' + abi + '/bin/python' python = '/opt/python/' + abi + '/bin/python'
pwd = os.getcwd() pwd = os.getcwd()
@@ -45,7 +45,11 @@ def execute(tag, abi, contained=False):
with TemporaryDirectory() as tmpdir: with TemporaryDirectory() as tmpdir:
copy_tree(dirname, 'python_appimage') copy_tree(dirname, 'python_appimage')
argv = ' '.join(sys.argv[1:]) argv = sys.argv[1:]
if argv:
argv = ' '.join(argv)
else:
argv = 'build manylinux {:} {:}'.format(tag, abi)
if tag.startswith("1_"): if tag.startswith("1_"):
# On manylinux1 tk is not installed # On manylinux1 tk is not installed
script = [ script = [

View File

@@ -0,0 +1,43 @@
import os
from ..utils.docker import docker_run
from ..utils.log import log
from ..utils.tmp import TemporaryDirectory
__all__ = ['execute']
def _unpack_args(args):
'''Unpack command line arguments
'''
return (args.tag,)
def execute(tag):
'''List python versions installed in a manylinux image
'''
with TemporaryDirectory() as tmpdir:
script = (
'for dir in $(ls /opt/python | grep "^cp[0-9]"); do',
' version=$(/opt/python/$dir/bin/python -c "import sys; ' \
'sys.stdout.write(sys.version.split()[0])")',
' echo "$dir $version"',
'done',
)
if tag.startswith('2_'):
image = 'manylinux_' + tag
else:
image = 'manylinux' + tag
result = docker_run(
'quay.io/pypa/' + image,
script,
capture = True
)
pythons = [line.split() for line in result.split(os.linesep) if line]
for (abi, version) in pythons:
log('LIST', "{:7} -> /opt/python/{:}".format(version, abi))
return pythons

View File

@@ -16,6 +16,9 @@ def _unpack_args(args):
def execute(binary): def execute(binary):
'''Print the location of a binary dependency '''Print the location of a binary dependency
''' '''
path = os.path.join(os.path.dirname(deps.PATCHELF), binary) if binary == 'appimagetool':
path = deps.ensure_appimagetool(dry=True)
else:
path = os.path.join(os.path.dirname(deps.PATCHELF), binary)
if os.path.exists(path): if os.path.exists(path):
print(path) print(path)

View File

@@ -0,0 +1,19 @@
Copyright (c) Université Clermont Auvergne, CNRS/IN2P3, LPC
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:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
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.

View File

@@ -2,16 +2,16 @@
# If running from an extracted image, then export ARGV0 and APPDIR # If running from an extracted image, then export ARGV0 and APPDIR
if [ -z "${APPIMAGE}" ]; then if [ -z "${APPIMAGE}" ]; then
export ARGV0=$0 export ARGV0="$0"
self="$(readlink -f -- $0)" self=$(readlink -f -- "$0") # Protect spaces (issue 55)
here="${self%/*}" here="${self%/*}"
tmp="${here%/*}" tmp="${here%/*}"
export APPDIR="${tmp%/*}" export APPDIR="${tmp%/*}"
fi fi
# Resolve the calling command (preserving symbolic links). # Resolve the calling command (preserving symbolic links).
export APPIMAGE_COMMAND="$(command -v -- $ARGV0)" export APPIMAGE_COMMAND=$(command -v -- "$ARGV0")
{{ tcltk-env }} {{ tcltk-env }}
{{ cert-file }} {{ cert-file }}

View File

@@ -1,4 +1,7 @@
__all__ = ['decode'] import sys
__all__ = ['decode', 'encode', 'find_spec']
def decode(s): def decode(s):
@@ -8,3 +11,30 @@ def decode(s):
return s.decode() return s.decode()
except Exception: except Exception:
return str(s) return str(s)
def encode(s):
'''Encode Python 3 str as bytes
'''
try:
return s.encode()
except Exception:
return str(s)
if sys.version_info[0] == 2:
from collections import namedtuple
import imp
ModuleSpec = namedtuple('ModuleSpec', ('name', 'origin'))
def find_spec(name):
return ModuleSpec(name, imp.find_module(name)[1])
else:
import importlib
try:
find_spec = importlib.util.find_spec
except AttributeError:
import importlib.util
find_spec = importlib.util.find_spec

View File

@@ -19,29 +19,47 @@ _ARCH = platform.machine()
PREFIX = os.path.abspath(os.path.dirname(__file__) + '/..') PREFIX = os.path.abspath(os.path.dirname(__file__) + '/..')
'''Package installation prefix''' '''Package installation prefix'''
APPIMAGETOOL = os.path.expanduser('~/.local/bin/appimagetool') APPIMAGETOOL_DIR = os.path.expanduser('~/.local/bin')
'''Location of the appimagetool binary''' '''Location of the appimagetool binary'''
APPIMAGETOOL_VERSION = '12'
'''Version of the appimagetool binary'''
EXCLUDELIST = PREFIX + '/data/excludelist' EXCLUDELIST = PREFIX + '/data/excludelist'
'''AppImage exclusion list''' '''AppImage exclusion list'''
PATCHELF = os.path.expanduser('~/.local/bin/patchelf') PATCHELF = os.path.expanduser('~/.local/bin/patchelf')
'''Location of the PatchELF binary''' '''Location of the PatchELF binary'''
def ensure_appimagetool(dry=False):
def ensure_appimagetool():
'''Fetch appimagetool from the web if not available locally '''Fetch appimagetool from the web if not available locally
''' '''
if os.path.exists(APPIMAGETOOL):
return False
if APPIMAGETOOL_VERSION == '12':
appimagetool_name = 'appimagetool'
else:
appimagetool_name = 'appimagetool-' + APPIMAGETOOL_VERSION
appimagetool = os.path.join(APPIMAGETOOL_DIR, appimagetool_name)
appdir_name = '.'.join(('', appimagetool_name, 'appdir', _ARCH))
appdir = os.path.join(APPIMAGETOOL_DIR, appdir_name)
apprun = os.path.join(appdir, 'AppRun')
if dry or os.path.exists(apprun):
return apprun
appimage = 'appimagetool-{0:}.AppImage'.format(_ARCH) appimage = 'appimagetool-{0:}.AppImage'.format(_ARCH)
baseurl = 'https://github.com/AppImage/AppImageKit/releases/' \
'download/12' if APPIMAGETOOL_VERSION in map(str, range(1, 14)):
repository = 'AppImageKit'
else:
repository = 'appimagetool'
baseurl = os.path.join(
'https://github.com/AppImage',
repository,
'releases/download',
APPIMAGETOOL_VERSION
)
log('INSTALL', 'appimagetool from %s', baseurl) log('INSTALL', 'appimagetool from %s', baseurl)
appdir_name = '.appimagetool.appdir'.format(_ARCH)
appdir = os.path.join(os.path.dirname(APPIMAGETOOL), appdir_name)
if not os.path.exists(appdir): if not os.path.exists(appdir):
make_tree(os.path.dirname(appdir)) make_tree(os.path.dirname(appdir))
with TemporaryDirectory() as tmpdir: with TemporaryDirectory() as tmpdir:
@@ -50,10 +68,7 @@ def ensure_appimagetool():
system(('./' + appimage, '--appimage-extract')) system(('./' + appimage, '--appimage-extract'))
copy_tree('squashfs-root', appdir) copy_tree('squashfs-root', appdir)
if not os.path.exists(APPIMAGETOOL): return apprun
os.symlink(appdir_name + '/AppRun', APPIMAGETOOL)
return True
# Installers for dependencies # Installers for dependencies

View File

@@ -4,11 +4,12 @@ import stat
import subprocess import subprocess
import sys import sys
from .compat import decode
from .log import log from .log import log
from .system import system from .system import system
def docker_run(image, extra_cmds): def docker_run(image, extra_cmds, capture=False):
'''Execute commands within a docker container '''Execute commands within a docker container
''' '''
@@ -42,10 +43,16 @@ def docker_run(image, extra_cmds):
'type=bind,source={:},target=/pwd'.format(os.getcwd()), 'type=bind,source={:},target=/pwd'.format(os.getcwd()),
image, '/bin/bash', bash_arg)) image, '/bin/bash', bash_arg))
if capture:
opts = {'stderr': subprocess.PIPE, 'stdout': subprocess.PIPE}
else:
opts = {}
log('RUN', image) log('RUN', image)
p = subprocess.Popen(cmd, shell=True) p = subprocess.Popen(cmd, shell=True, **opts)
p.communicate() r = p.communicate()
if p.returncode != 0: if p.returncode != 0:
if p.returncode == 139: if p.returncode == 139:
sys.stderr.write("segmentation fault when running Docker (139)\n") sys.stderr.write("segmentation fault when running Docker (139)\n")
sys.exit(p.returncode) sys.exit(p.returncode)
if capture:
return decode(r[0])

View File

@@ -1,8 +1,30 @@
from distutils.dir_util import mkpath as _mkpath, remove_tree as _remove_tree
from distutils.file_util import copy_file as _copy_file
import errno import errno
import os import os
try:
from distutils.dir_util import mkpath as _mkpath
from distutils.dir_util import remove_tree as _remove_tree
from distutils.file_util import copy_file as _copy_file
except ImportError:
import shutil
def _mkpath(path):
os.makedirs(path, exist_ok=True)
def _remove_tree(path):
shutil.rmtree(path)
def _copy_file(source, destination, update=0):
if os.path.exists(source) and (
not update
or (
(not os.path.exists(destination))
or (os.path.getmtime(source) > os.path.getmtime(destination))
)
):
shutil.copy(source, destination)
from .log import debug from .log import debug

View File

@@ -7,17 +7,25 @@ __all__ = ['debug', 'log']
# Configure the logger # Configure the logger
logging.basicConfig( logging.basicConfig(
format='[%(asctime)s] %(message)s', format='[%(asctime)s] %(message)s',
level=logging.INFO level=logging.ERROR
) )
logging.getLogger('python-appimage').setLevel(logging.INFO)
def log(task, fmt, *args): def log(task, fmt, *args):
'''Log a standard message '''Log a standard message
''' '''
logging.info('%-8s ' + fmt, task, *args) logging.getLogger('python-appimage').info('%-8s ' + fmt, task, *args)
def debug(task, fmt, *args): def debug(task, fmt, *args):
'''Report some debug information '''Report some debug information
''' '''
logging.debug('%-8s ' + fmt, task, *args) logging.getLogger('python-appimage').debug('%-8s ' + fmt, task, *args)
def set_level(level):
'''Set the threshold for logs
'''
level = getattr(logging, level)
logging.getLogger('python-appimage').setLevel(level)

View File

@@ -0,0 +1,14 @@
def format_appimage_name(abi, version, tag):
'''Format the Python AppImage name using the ABI, python version and OS tags
'''
return 'python{:}-{:}-{:}.AppImage'.format(
version, abi, format_tag(tag))
def format_tag(tag):
'''Format Manylinux tag
'''
if tag.startswith('2_'):
return 'manylinux_' + tag
else:
return 'manylinux' + tag

View File

@@ -2,8 +2,8 @@ import os
import re import re
import subprocess import subprocess
from .compat import decode from .compat import decode, encode
from .log import debug from .log import debug, log
__all__ = ['ldd', 'system'] __all__ = ['ldd', 'system']
@@ -15,7 +15,7 @@ except NameError:
basestring = (str, bytes) basestring = (str, bytes)
def system(args, exclude=None): def system(args, exclude=None, stdin=None):
'''System call with capturing output '''System call with capturing output
''' '''
cmd = ' '.join(args) cmd = ' '.join(args)
@@ -29,17 +29,36 @@ def system(args, exclude=None):
exclude = list(exclude) exclude = list(exclude)
exclude.append('fuse: warning:') exclude.append('fuse: warning:')
if stdin:
in_arg = subprocess.PIPE
stdin = encode(stdin)
else:
in_arg = None
p = subprocess.Popen(cmd, shell=True, stdout=subprocess.PIPE, p = subprocess.Popen(cmd, shell=True, stdout=subprocess.PIPE,
stderr=subprocess.PIPE) stderr=subprocess.PIPE, stdin=in_arg)
out, err = p.communicate() out, err = p.communicate(input=stdin)
if err: if err:
err = decode(err) err = decode(err)
stripped = [line for line in err.split(os.linesep) if line] stripped = [line for line in err.split(os.linesep) if line]
def matches_pattern(line, pattern):
if isinstance(pattern, re.Pattern):
return bool(pattern.match(line))
return line.startswith(pattern)
for pattern in exclude: for pattern in exclude:
stripped = [line for line in stripped stripped = [line for line in stripped
if not line.startswith(pattern)] if not matches_pattern(line, pattern)]
if stripped: if stripped:
raise RuntimeError(err) # Tolerate single line warning(s)
for line in stripped:
if (len(line) < 8) or (line[:8].lower() != "warning:"):
raise RuntimeError(err)
else:
for line in stripped:
log('WARNING', line[8:].strip())
return str(decode(out).strip()) return str(decode(out).strip())

View File

@@ -20,5 +20,6 @@ def TemporaryDirectory():
try: try:
yield tmpdir yield tmpdir
finally: finally:
debug('REMOVE', tmpdir)
os.chdir(pwd) os.chdir(pwd)
remove_tree(tmpdir) remove_tree(tmpdir)

330
scripts/update-appimages.py Executable file
View File

@@ -0,0 +1,330 @@
#! /usr/bin/env python3
import argparse
from collections import defaultdict
from dataclasses import dataclass
import os
import subprocess
import sys
from typing import Optional
from github import Auth, Github
from python_appimage.commands.build.manylinux import execute as build_manylinux
from python_appimage.commands.list import execute as list_pythons
from python_appimage.utils.log import log
from python_appimage.utils.manylinux import format_appimage_name, format_tag
# Build matrix
ARCHS = ('x86_64', 'i686')
MANYLINUSES = ('1', '2010', '2014', '2_24', '2_28')
EXCLUDES = ('2_28_i686',)
# Build directory for AppImages
APPIMAGES_DIR = 'build-appimages'
@dataclass
class ReleaseMeta:
'''Metadata relative to a GitHub release
'''
tag: str
ref: Optional["github.GitRef"] = None
release: Optional["github.GitRelease"] = None
def message(self):
'''Returns release message'''
return f'Appimage distributions of {self.title()} (see `Assets` below)'
def title(self):
'''Returns release title'''
version = self.tag[6:]
return f'Python {version}'
@dataclass
class AssetMeta:
'''Metadata relative to a release Asset
'''
tag: str
abi: str
version: str
asset: Optional["github.GitReleaseAsset"] = None
@classmethod
def from_appimage(cls, name):
'''Returns an instance from a Python AppImage name
'''
tmp = name[6:-9]
tmp, tag = tmp.split('-manylinux', 1)
if tag.startswith('_'):
tag = tag[1:]
version, abi = tmp.split('-', 1)
return cls(
tag = tag,
abi = abi,
version = version
)
def appimage_name(self):
'''Returns Python AppImage name'''
return format_appimage_name(self.abi, self.version, self.tag)
def formated_tag(self):
'''Returns formated manylinux tag'''
return format_tag(self.tag)
def previous_version(self):
'''Returns previous version'''
if self.asset:
return self.asset.name[6:-9].split('-', 1)[0]
def release_tag(self):
'''Returns release git tag'''
version = self.version.rsplit('.', 1)[0]
return f'python{version}'
def update(args):
'''Update Python AppImage GitHub releases
'''
sha = args.sha
if sha is None:
sha = os.getenv('GITHUB_SHA')
if sha is None:
p = subprocess.run(
'git rev-parse HEAD',
shell = True,
capture_output = True,
check = True
)
sha = p.stdout.decode().strip()
# Connect to GitHub
token = args.token
if token is None:
# First, check for token in env
token = os.getenv('GITHUB_TOKEN')
if token is None:
# Else try to get a token from gh app
p = subprocess.run(
'gh auth token',
shell = True,
capture_output = True,
check = True
)
token = p.stdout.decode().strip()
auth = Auth.Token(token)
session = Github(auth=auth)
repo = session.get_repo('niess/python-appimage')
# Fetch currently released AppImages
log('FETCH', 'currently released AppImages')
releases = {}
assets = defaultdict(dict)
n_assets = 0
for release in repo.get_releases():
if release.tag_name.startswith('python'):
meta = ReleaseMeta(
tag = release.tag_name,
release = release
)
ref = repo.get_git_ref(f'tags/{meta.tag}')
if (ref.ref is not None) and (ref.object.sha != sha):
meta.ref = ref
releases[release.tag_name] = meta
for asset in release.get_assets():
if asset.name.endswith('.AppImage'):
n_assets += 1
meta = AssetMeta.from_appimage(asset.name)
assert(meta.release_tag() == release.tag_name)
meta.asset = asset
assets[meta.tag][meta.abi] = meta
n_releases = len(releases)
log('FETCH', f'found {n_assets} AppImages in {n_releases} releases')
# Look for updates.
new_releases = set()
new_assets = []
for manylinux in MANYLINUSES:
for arch in ARCHS:
tag = f'{manylinux}_{arch}'
if tag in EXCLUDES:
continue
pythons = list_pythons(tag)
for (abi, version) in pythons:
try:
meta = assets[tag][abi]
except KeyError:
meta = None
if (meta is None) or (meta.version != version) or args.all:
new_meta = AssetMeta(
tag = tag,
abi = abi,
version = version
)
if meta is not None:
new_meta.asset = meta.asset
new_assets.append(new_meta)
rtag = new_meta.release_tag()
if rtag not in releases:
new_releases.add(rtag)
if args.dry:
# Log foreseen changes and exit
for tag in new_releases:
meta = ReleaseMeta(tag)
log('DRY', f'new release for {meta.title()}')
for meta in new_assets:
log('DRY', f'create asset {meta.appimage_name()}')
if meta.asset is not None:
log('DRY', f'remove asset {meta.asset.name}')
for meta in releases.values():
if meta.ref is not None:
log('DRY', f'refs/tags/{meta.tag} -> {sha}')
if meta.release is not None:
log('DRY', f'reformat release for {meta.title()}')
if new_assets:
log('DRY', f'new update summary with {len(new_assets)} entries')
return
if new_assets:
# Build new AppImage(s)
cwd = os.getcwd()
os.makedirs(APPIMAGES_DIR, exist_ok=True)
try:
os.chdir(APPIMAGES_DIR)
for meta in new_assets:
build_manylinux(meta.tag, meta.abi)
finally:
os.chdir(cwd)
# Create any new release(s).
for tag in new_releases:
meta = ReleaseMeta(tag)
title = meta.title()
meta.release = repo.create_git_release(
tag = meta.tag,
name = title,
message = meta.message(),
prerelease = True
)
releases[tag] = meta
log('UPDATE', f'new release for {title}')
# Update assets.
update_summary = []
for meta in new_assets:
release = releases[meta.release_tag()].release
appimage = meta.appimage_name()
if meta.asset and (meta.asset.name == appimage):
meta.asset.delete_asset()
update_summary.append(
f'- update {meta.formated_tag()}/{meta.abi} {meta.version}'
)
new_asset = release.upload_asset(
path = f'{APPIMAGES_DIR}/{appimage}',
name = appimage
)
else:
new_asset = release.upload_asset(
path = f'{APPIMAGES_DIR}/{appimage}',
name = appimage
)
if meta.asset:
meta.asset.delete_asset()
update_summary.append(
f'- update {meta.formated_tag()}/{meta.abi} '
f'{meta.previous_version()} -> {meta.version}'
)
else:
update_summary.append(
f'- add {meta.formated_tag()}/{meta.abi} {meta.version}'
)
meta.asset = new_asset
assets[meta.tag][meta.abi] = meta
# Update git tags SHA
for meta in releases.values():
if meta.ref is not None:
meta.ref.edit(
sha = sha,
force = True
)
log('UPDATE', f'refs/tags/{meta.tag} -> {sha}')
if meta.release is not None:
title = meta.title()
meta.release.update_release(
name = title,
message = meta.message(),
prerelease = True,
tag_name = meta.tag
)
log('UPDATE', f'reformat release for {title}')
# Generate update summary
if update_summary:
for release in repo.get_releases():
if release.tag_name == 'update-summary':
release.delete_release()
break
message = os.linesep.join(update_summary)
repo.create_git_release(
tag = 'update-summary',
name = 'Update summary',
message = message,
prerelease = True
)
if __name__ == '__main__':
parser = argparse.ArgumentParser(
description = 'Update GitHub releases of Python AppImages'
)
parser.add_argument('-a', '--all',
help = 'force update of all available releases',
action = 'store_true',
default = False
)
parser.add_argument('-d', '--dry',
help = 'dry run (only log changes)',
action = 'store_true',
default = False
)
parser.add_argument('-m', '--manylinux',
help = 'target specific manylinux tags',
nargs = "+"
)
parser.add_argument("-s", "--sha",
help = "reference commit SHA"
)
parser.add_argument('-t', '--token',
help = 'GitHub authentication token'
)
args = parser.parse_args()
if args.manylinux:
MANYLINUSES = args.manylinux
sys.argv = sys.argv[:1] # Empty args for fake call
update(args)