93 Commits
paper ... dev

Author SHA1 Message Date
Valentin Niess
573b4742d2 Patch openssl path detection 2025-07-02 12:48:39 +02:00
Valentin Niess
b870853d69 Setuptools dependency 2025-07-02 12:05:38 +02:00
Valentin Niess
fa25a18ba8 Smarter cacert.pem detection 2025-07-02 11:17:30 +02:00
Valentin Niess
a072354789 Bump version: v1.4.4 2025-05-24 21:18:17 +02:00
Valentin Niess
0376d42eca Use appimagetool continuous build 2025-05-24 21:12:55 +02:00
Valentin Niess
04071b3df9 Pin pypa/gh-action-pypi-publish@release/v1 2025-05-23 18:49:31 +02:00
Valentin Niess
f049580e2b Bump version: v1.4.3 2025-05-23 18:44:33 +02:00
Valentin Niess
0fabfe2810 Bundle version.py file 2025-05-23 18:28:34 +02:00
Valentin Niess
c7df0f9267 Bump version: v1.4.2 2025-05-23 18:25:24 +02:00
Valentin Niess
ab7eac67d5 Switch to pyproject.toml 2025-05-23 18:24:43 +02:00
Valentin Niess
8664b711d8 Bump version: v1.4.1 2025-05-23 16:24:39 +02:00
Valentin Niess
ad647e0ece Update workflows' dependencies 2025-05-23 16:19:34 +02:00
Valentin Niess
ac7df52db8 Bump version: v1.4.0 2025-05-23 15:46:05 +02:00
Valentin Niess
14da50382d Update requirements (Python 3.9, requests) 2025-05-23 15:34:27 +02:00
Valentin Niess
839cbc3fd4 Clean the documentation 2025-05-23 15:15:17 +02:00
Valentin Niess
19fe0dbab9 Patch the docs table 2025-05-23 10:32:53 +02:00
Valentin Niess
f9b46b5e7f Add aarch64 to the update 2025-05-22 23:46:41 +02:00
Valentin Niess
d1eb24e0f4 Manylinux1 patch 2025-05-22 17:43:43 +02:00
Valentin Niess
f6dd10d6b7 Skip some tests 2025-05-22 15:41:10 +02:00
Valentin Niess
39d91e847f No packaging option 2025-05-22 13:36:30 +02:00
Valentin Niess
ca8d4717d4 Forward arch when building 2025-05-22 12:10:33 +02:00
Valentin Niess
954e9bc91a Log tests results 2025-05-22 11:11:17 +02:00
Valentin Niess
5cf73a59d6 Build bare tarball 2025-05-22 09:55:15 +02:00
Valentin Niess
f746e5dae3 Get images to the cache 2025-05-22 09:32:51 +02:00
Valentin Niess
a7c56e3c77 Build specific image tags 2025-05-22 09:04:09 +02:00
Valentin Niess
2f663acc32 Cache management 2025-05-21 23:04:04 +02:00
Valentin Niess
eb05b77a85 Update the list command 2025-05-21 21:28:12 +02:00
Valentin Niess
fa3ec89228 Appimage test script 2025-05-21 20:27:51 +02:00
Valentin Niess
60aec8ba25 Patch some mistakes (according to flake8) 2025-05-20 23:27:20 +02:00
Valentin Niess
331fc6ab7f Relocate appification 2025-05-20 22:43:57 +02:00
Valentin Niess
75f10bbdc4 Update manylinux CLI 2025-05-20 16:16:27 +02:00
Valentin Niess
5fe8c22eb8 Harmonise installation prefixes 2025-05-20 08:52:56 +02:00
Valentin Niess
8090602b0f Tweak manylinux image extractor 2025-05-19 22:37:45 +02:00
Valentin Niess
1fdb439e70 Manylinux downloader and extractor 2025-05-19 17:13:29 +02:00
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
53 changed files with 2732 additions and 1167 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 requests
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,42 @@
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-latest
strategy: strategy:
matrix: matrix:
version: ['2.7', '3.7', '3.9'] version: ['3.9']
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 }}
- name: Install Dependencies
run: pip install requests
- 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,20 +45,24 @@ 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 }}
run: | run: |
python -m python_appimage build app applications/ssh-mitm python -m python_appimage build app applications/ssh-mitm \
--python-version=3.11
test -e ssh-mitm-x86_64.AppImage test -e ssh-mitm-x86_64.AppImage
./ssh-mitm-x86_64.AppImage --help ./ssh-mitm-x86_64.AppImage --help

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

@@ -1,24 +1,29 @@
name: PyPI name: PyPI
on: on:
push: workflow_dispatch:
branches: inputs:
- master upload:
paths: description: 'Upload to PyPI'
- 'VERSION' required: true
default: false
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 }}
- name: Install Dependencies
run: pip install requests
- name: Test local builder - name: Test local builder
run: | run: |
python -m python_appimage build local -p $(which python) \ python -m python_appimage build local -p $(which python) \
@@ -31,19 +36,18 @@ 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: |
pip install -U pip pip install -U pip build
pip install -U wheel python -m build
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@release/v1
with: with:
password: ${{ secrets.PYPI_TOKEN }} password: ${{ secrets.PYPI_TOKEN }}

4
.gitignore vendored
View File

@@ -5,8 +5,6 @@ __pycache__
AppDir AppDir
build/* build/*
dist dist
docs/ENV
python_appimage.egg-info python_appimage.egg-info
python_appimage/bin
python_appimage/data/excludelist
python_appimage/version.py
!python_appimage/commands/build !python_appimage/commands/build

View File

@@ -1,9 +1,13 @@
version: 2 version: 2
build:
os: ubuntu-22.04
tools:
python: "3.9"
mkdocs: mkdocs:
configuration: docs/mkdocs.yml configuration: docs/mkdocs.yml
python: python:
version: 3.8
install: install:
- requirements: docs/requirements.txt - requirements: docs/requirements.txt

1
MANIFEST.in Normal file
View File

@@ -0,0 +1 @@
include python_appimage/data/*

View File

@@ -5,18 +5,18 @@ _Ready to use AppImages of Python are available as GitHub [releases][RELEASES]._
## Quickstart ## Quickstart
We provide relocatable Python runtimes as [AppImages][APPIMAGE] for Linux We provide relocatable Python runtimes in the form of [AppImages][APPIMAGE] for
systems. These runtimes are extracted from [Manylinux][MANYLINUX] Docker images, Linux systems. These runtimes are extracted from [Manylinux][MANYLINUX] Docker
and they are available as GitHub [releases][RELEASES]. Our Python AppImages are images and are available as GitHub [releases][RELEASES]. Our Python AppImages
updated weekly, on every Sunday. are updated weekly, on every Sunday.
Instructions for _installing_ and running _Python AppImages_ are provided on Instructions for _installing_ and running _Python AppImages_ can be found on
[Read the Docs][READTHEDOCS]. [Read the Docs][READTHEDOCS].
In addition, the online documentation describes the [`python-appimage`][PYPI] The online documentation also describes the [`python-appimage`][PYPI] utility
utility, for application developers. This utility can facilitate the building of for application developers. This utility can facilitate the development of
Python apps, given an existing Python AppImage and a recipe folder. Python applications, provided you have an existing Python AppImage and a recipe
[Examples][APPLICATIONS] of recipes are available from GitHub. folder. [Examples][APPLICATIONS] of recipes are available on GitHub.
## Projects using [`python-appimage`][GITHUB] ## Projects using [`python-appimage`][GITHUB]
@@ -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 +0,0 @@
1.1.2

View File

@@ -12,8 +12,10 @@
[GITHUB]: {{ config.repo_url }} [GITHUB]: {{ config.repo_url }}
[LINUXDEPLOY]: https://github.com/linuxdeploy/linuxdeploy/ [LINUXDEPLOY]: https://github.com/linuxdeploy/linuxdeploy/
[MANYLINUX]: https://github.com/pypa/manylinux/ [MANYLINUX]: https://github.com/pypa/manylinux/
[NUMPY]: https://numpy.org/
[PATCHELF]: https://github.com/NixOS/patchelf/ [PATCHELF]: https://github.com/NixOS/patchelf/
[PYPI]: https://pypi.org/project/python-appimaAge/ [PEP_425]: https://peps.python.org/pep-0425/
[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

@@ -10,7 +10,7 @@ docs_dir: src
nav: nav:
- Python AppImages: index.md - Python AppImages: index.md
- Developers corner: apps.md - Developers' corner: apps.md
theme: theme:
name: readthedocs name: readthedocs

View File

@@ -10,150 +10,169 @@
{% include "references.md" %} {% include "references.md" %}
# Developers corner # Developers' corner
Python [AppImages][APPIMAGE] are built with the `python-appimage` utility, Python [AppImages][APPIMAGE] are created using the `python-appimage` utility,
available from [PyPI][PYPI]. This utility can also help packaging Python based which is available on [PyPI][PYPI]. This utility can also be used to package
applications as AppImages, using an existing Python AppImage and a recipe Python-based applications as AppImages using an existing AppImage and a recipe
folder. folder.
!!! Caution !!! Caution
The `python-appimage` utility can only package applications that can be The `python-appimage` utility can only package applications that can be
directly installed with `pip`. For more advanced usage, one needs to extract installed directly with `pip`. For more advanced usage, it is necessary to
the Python AppImage and to edit it, e.g. as explained in the [Advanced extract and edit the Python AppImage, as explained in the [Advanced
installation](index.md#advanced-installation) section. Additional details installation](index.md#advanced-installation) section. Further details on
on this use case are provided [below](#advanced-packaging). this use case can be found [below](#advanced-packaging).
## Building a Python AppImage ## Building a Python AppImage
The primary scope of `python-appimage` is to relocate an existing Python The primary purpose of `python-appimage` is to relocate an existing Python
installation inside an AppDir, and to build the corresponding AppImage. For installation to an AppDir and build the corresponding AppImage. For example, the
example, the following command
```bash ```bash
python-appimage build local -p $(which python2) python-appimage build local -p $(which python2)
``` ```
should build an AppImage of your local Python 2 installation, provided that it should create an AppImage of your local Python installation, provided that it
exists. exists.
!!! Tip !!! Tip
Help on available arguments and options to `python-appimage` can be obtained Help on the available arguments and options for `python-appimage` can be
with the `-h` flag. For example, `python-appimage build local -h` provides obtained by using the `-h` flag. For example, running
help on local builds. `python-appimage build local -h` provides help on local builds.
{{ begin(".capsule") }} {{ begin(".capsule") }}
### Auxiliary tools ### Auxiliary tools
The `python-appimage` utility relies on auxiliary tools that are downloaded and The `python-appimage` utility relies on auxiliary tools that are downloaded and
installed at runtime, on need. Those are [appimagetool][APPIMAGETOOL] for installed on demand during application execution. These are
building AppImages, and [patchelf][PATCHELF] in order to edit ELFs runtime paths [appimagetool][APPIMAGETOOL], which is used to build AppImages, and
(`RPATH`). Auxiliary tools are installed to the the user space. One can get [patchelf][PATCHELF], which is used to edit runtime paths (`RPATH`) in ELF
their location with the `which` command word. For example, files. These auxiliary tools are installed in the application cache. Their
location can be found using the `which` command. For example, the command
```bash ```bash
python-appimage which appimagetool python-appimage which appimagetool
``` ```
returns the location of `appimagetool`, if it has been installed. If not, the returns the location of [appimagetool][APPIMAGETOOL] if it has been installed.
`install` command word can be used in order to trigger its installation. If not, the `install` command can be used to trigger its installation.
{{ end(".capsule") }} {{ end(".capsule") }}
## Manylinux Python AppImages ## Manylinux Python AppImages
AppImages of your local `python` are unlikely to be portable, except if you run AppImages of your local `python` are unlikely to be portable, unless you are
an ancient Linux distribution. Indeed, a core component preventing portability running an outdated Linux distribution. A core component that prevents
across Linuses is the use of different versions of the `glibc` system library. portability across Linux distributions is the use of different versions of the
Hopefully, `glibc` is highly backward compatible. Therefore, a simple `glibc` system library. Fortunately, `glibc` is highly backward compatible.
work-around is to compile binaries using the oldest Linux distro you can afford Therefore, a simple workaround is to compile binaries using the oldest Linux
to. This is the strategy used for creating portable AppImages, as well as for distribution you can. This strategy is used to create portable AppImages and to
distributing Python site packages as ready-to-use binary [wheels][WHEELS]. distribute Python site packages as ready-to-use binary [wheels][WHEELS].
The Python Packaging Authority (PyPA) has defined standard platform tags for The Python Packaging Authority (PyPA) has defined standard platform tags for
building Python site packages, labelled [manylinux][MANYLINUX]. These build building Python site packages labelled [Manylinux][MANYLINUX]. These build
platforms are available as Docker images with various versions of Python already platforms are available as Docker images, with different versions of Python
installed. The `python-appimage` utility can be used to package those installs already installed. The `python-appimage` utility can be used to package these
as AppImages. For example, the following command installations as AppImages. For example, the following command
```bash ```bash
python-appimage build manylinux 2014_x86_64 cp310-cp310 python-appimage build manylinux 2014_x86_64 cp313-cp313
``` ```
should build an AppImage of Python 3.10 using the CPython (_cp310-cp310_) should build an AppImage of Python __3.13__ using the CPython (__cp313-cp313__)
install found in the `manylinux2014_x86_64` Docker image. installation found in the `manylinux2014_x86_64` Docker image.
!!! Note !!! Note
Docker needs to be already installed on your system in order to build From version `1.4.0` of `python-appimage` onwards, Docker is **no longer**
Manylinux Python images. However, the command above can be run on the host. required to build the Manylinux Python images. Cross-building is also
That is, you need **not** to explictly shell inside the manylinux Docker supported, for example producing an `aarch64` Python image from an `x86_64`
image. host.
!!! Warning
Creating multiple Manylinux Python images can significantly increase the
size of the application cache. This can be managed using the
`python-appimage cache` command.
!!! Tip !!! Tip
A compilation of ready-to-use Manylinux Python AppImages is available from A compilation of ready-to-use Manylinux Python AppImages is available in the
the [releases][RELEASES] area of the `python-appimage` [GitHub [releases][RELEASES] section of the `python-appimage` [GitHub
repository][GITHUB]. These AppImages are updated weekly, on every Sunday. repository][GITHUB]. These AppImages are updated weekly, on every Sunday.
!!! Tip
Instead of an AppImage, the `python-appimage build manylinux` command can
produce either an `AppDir` or a bare tarball (i.e. without the AppImage
layer) of a Manylinux Python installation. See the `-b` and `-n` command
line options for more information.
## Simple packaging ## Simple packaging
The `python-appimage` utility can also be used in order to build simple The `python-appimage` utility can also be used to package simple AppImage
applications, that can be `pip` installed. The syntax is applications, whose dependencies can be installed using `pip`. The syntax is
```bash ```bash
python-appimage build app -p 3.10 /path/to/recipe/folder python-appimage build app -p 3.13 /path/to/recipe/folder
``` ```
in order to build a Python 3.10 based application from a recipe folder. to build a Python 3.13-based application from a recipe folder. Examples of
Examples of recipes can be found on GitHub in the [applications][APPLICATIONS] recipes can be found in the [applications][APPLICATIONS] folder on GitHub. The
folder. The recipe folder contains: recipe folder contains
- the AppImage metadata (`application.xml` and `application.desktop`), - the AppImage metadata (`application.xml` and `application.desktop`),
- an application icon (e.g. `application.png`), - an application icon (e.g. `application.png`),
- a Python requirements file (`requirements.txt`) - a Python requirements file (`requirements.txt`),
- an entry point script (`entrypoint.sh`). - an entry point script (`entrypoint.sh`).
Additional information on metadata can be found in the AppImage documentation. Further information on metadata can be found in the AppImage documentation
That is, for [desktop][APPIMAGE_DESKTOP] and [AppStream XML][APPIMAGE_XML] (e.g., regarding [desktop][APPIMAGE_DESKTOP] and [AppStream XML][APPIMAGE_XML]
files. The `requirements.txt` file allows to specify additional site packages files). The `requirements.txt` file enables additional site packages to be
to be bundled in the AppImage, using `pip`. specified for bundling in the AppImage using `pip`.
!!! Caution !!! Caution
Site packages bundled in the AppImage, as well as their dependencies, must In order for the application to be portable, the site packages bundled in
either be pure python packages, or they must be available as portable binary the AppImage and their dependencies must be available as binary wheels or
wheels. pure Python packages.
If a **C extension** is bundled from **source**, then it will likely **not If a **C extension** is bundled from **source**, it will likely **not be
be portable**, as further discussed in the [Advanced portable**; this is discussed further in the [Advanced
packaging](#advanced-packaging) section. packaging](#advanced-packaging) section.
!!! Tip !!! Tip
Some site packages are available only for specific Manylinux tags. This can Some site packages are only available for specific Manylinux tags. You can
be cross-checked by browsing the `Download files` section on the package's check this by browsing the `Download files` section on the package's PyPI
PyPI page. page.
!!! Tip
Since version 1.2, `python-appimage` allows local requirements to be
specified using the `local+` tag (see
[PR49](https://github.com/niess/python-appimage/pull/49)). Please note,
however, that this involves directly copying the local package, which has
several limitations.
{{ begin(".capsule") }} {{ begin(".capsule") }}
### Entry point script ### Entry point script
{% raw %} {% raw %}
The entry point script deserves some additional explanations. This script allows The entry point script deserves some additional explanations. This script lets
to customize the startup of your application. A typical `entrypoint.sh` script you customise your application's startup. A typical `entrypoint.sh` script would
would look like look like this
```bash ```bash
{{ python-executable }} ${APPDIR}/opt/python{{ python-version }}/bin/my_app.py "$@" {{ python-executable }} ${APPDIR}/opt/python{{ python-version }}/bin/my_app.py "$@"
``` ```
where `my_app.py` is the application startup script, installed by `pip`. As can where `my_app.py` is the application startup script installed by `pip`. As can
be seen from the previous example, the `entrypoint.sh` script recognises some be seen from the previous example, the `entrypoint.sh` script recognises
particular variables, nested between double curly braces, `{{ }}`. Those particular variables nested between double curly braces (`{{}}`). These
variables are listed in the table hereafter. In addition, usual [AppImage variables are listed in the table below. In addition, the usual [AppImage
environement variables][APPIMAGE_ENV] can be used as well, if needed. For environement variables][APPIMAGE_ENV] can be used if needed. For instance,
example, `$APPDIR` points to the AppImage mount point at runtime. `$APPDIR` points to the AppImage mount point at runtime.
{% endraw %} {% endraw %}
| variable | Description | | variable | Description |
|----------------------|---------------------------------------------------------------| |----------------------|---------------------------------------------------------------|
| `architecture` | The AppImage architecture, e.g. `x86_64`. | | `architecture` | The AppImage architecture, e.g. `x86_64`. |
@@ -166,49 +185,78 @@ example, `$APPDIR` points to the AppImage mount point at runtime.
{% raw %} {% raw %}
!!! Note !!! Note
By default, Python AppImages are not isolated from the user space, nor from By default, Python AppImages are not isolated from user space or
Python specific environment variables, the like `PYTHONPATH`. Depending on Python-specific environment variables such as `PYTHONPATH`. Depending on
your use case, this can be problematic. your use case, this can cause problems.
The runtime isolation level can be changed by adding the `-E`, `-s` or `-I` You can change the isolation level by adding the `-E`, `-s` or `-I` options
options, when invoking the runtime. For example, when invoking the runtime. For example, `{{ python-executable }} -I` starts
`{{ python-executable }} -I` starts a fully isolated Python instance. a fully isolated Python instance.
{% endraw %} {% endraw %}
### Bundling data files
`python-appimage` is also capable of bundling auxiliary data files directly into
the resulting AppImage. The `-x/--extra-data` switch is used for this purpose.
Consider the following example.
```bash
echo -n "foo" > foo
mkdir bar
echo -n "baz" > bar/baz
python-appimage [your regular parameters] -x foo bar/*
```
In this way, user data becomes accessible to the Python code contained within
the AppImage as regular files under the directory pointed to by the `APPDIR`
environment variable. An example of a Python 3 script that reads these files is
presented below.
```python
import os, pathlib
for fileName in ("foo", "baz"):
print((pathlib.Path(os.getenv("APPDIR")) / fileName).read_text())
```
When executed, the above code would produce the following output.
```bash
foo
baz
```
## Advanced packaging ## Advanced packaging
In more complex cases, e.g. if your application relies on external C libraries In more complex cases, for example if your application relies on external C
not bundled with the Python runtime, then the simple packaging scheme described libraries that are not bundled with the Python runtime, the simple packaging
previously will fail. Indeed, this falls out of the scope of `python-appimage`, scheme described previously will not work. This falls outside the scope of
whose main purpose it to relocate an existing Python install. In this case, you `python-appimage`, which is primarily intended for relocating an existing Python
might rather refer to the initial AppImage [Packaging installation. In this case, you may wish to refer to the initial AppImage
Guide][APPIMAGE_PACKAGING], and use alternative tools like [Packaging Guide][APPIMAGE_PACKAGING], and use alternative tools such as
[linuxdeploy][LINUXDEPLOY]. [linuxdeploy][LINUXDEPLOY].
However, `python-appimage` can still be of use in more complex cases by However, `python-appimage` can still be useful in more complex cases, as it can
extracting its AppImages to an AppDir, as discussed in the [Advanced generate a base AppDir containing a relocatable Python runtime (e.g., using the
installation](index.md#advanced-installation) section. The extracted AppImages `-n` option). This can then serve as a starting point to create more complex
contain a relocatable Python runtime, that can be used as a starting base for AppImages.
building more complex AppImages.
!!! Tip !!! Tip
In some cases, a simple workaround to missing external libraries can be to In some cases, a simple workaround for missing external libraries is to
fetch portable versions of those from a Manylinux distro, and to bundle them download portable versions of them from a Manylinux distribution and bundle
under `AppDir/usr/lib`. You might also need to edit their dynamic section, them in `AppDir/usr/lib`. You may also need to edit the dynamic section
e.g. using [`patchelf`][PATCHELF], which is installed by `python-appimage`. using [`patchelf`][PATCHELF], which is installed by `python-appimage`.
{{ begin(".capsule") }} {{ begin(".capsule") }}
### C extension modules ### C extension modules
If your application relies on C extension modules, they need to be compiled on a If your application relies on C extension modules, these must be compiled on a
Manylinux distro in order to be portable. In addition, their dependencies need Manylinux distribution in order to be portable. Their dependencies also need to
to be bundled as well. In this case, you might better start by building a binary be bundled. In this case, it would be better to start by building a binary wheel
wheel of your package, using tools like [Auditwheel][AUDITWHEEL] which can of your package using tools like [Auditwheel][AUDITWHEEL], which can automate
automate some parts of the packaging process. Note that `auditwheel` is already some parts of the packaging process. Please note that `auditwheel` is already
installed on the Manylinux Docker images. installed on the Manylinux Docker images.
Once you have built a binary wheel of your package, it can be used with Once you have built a binary wheel of your package, you can use it with
`python-appimage` in order to package your application as an AppImage. `python-appimage` to package your application as an AppImage.
{{ end(".capsule") }} {{ end(".capsule") }}

View File

@@ -15,8 +15,8 @@
# Python AppImages # Python AppImages
We provide relocatable Python runtimes for _Linux_ systems, as We provide relocatable Python runtimes for _Linux_ systems, as
[AppImages][APPIMAGE]. These runtimes have been extracted from [AppImages][APPIMAGE]. These runtimes have been extracted from a variety of
[manylinux][MANYLINUX] Docker images. [Manylinux][MANYLINUX] Docker images.
{{ "" | id("append-releases-list") }} {{ "" | id("append-releases-list") }}
## Basic installation ## Basic installation
@@ -35,23 +35,24 @@ chmod +x python3.10.2-cp310-cp310-manylinux2014_x86_64.AppImage
!!! Note !!! Note
As can be seen from the previous [example](#basic-installation-example), the As can be seen from the previous [example](#basic-installation-example), the
AppImage name contains several informations. That are, the Python full AppImage name contains several pieces of information. This includes the
version ({{ "3.10.2" | id("example-full-version") }}), the CPython tag Python full version ({{ "3.10.2" | id("example-full-version") }}), the
({{ "cp310-cp310" | id("example-python-tag") }}), the Linux compatibility [CPython tag][PEP_425] ({{ "cp310-cp310" | id("example-python-tag") }}), the
tag ({{ "manylinux2014" | id("example-linux-tag") }}) and the machine [Linux compatibility tag][MANYLINUX] ({{ "manylinux2014" |
architecture ({{ "x86_64" | id("example-arch-tag") }}). id("example-linux-tag") }}) and the machine architecture ({{ "x86_64" |
id("example-arch-tag") }}).
!!! Caution !!! Caution
One needs to **select an AppImage** that matches **system requirements**. A It is essential to **select an AppImage** that aligns with the **system's
summmary of available Python AppImages is provided at the specifications**. An overview of the available Python AppImages is provided
[bottom](#available-python-appimages) of this page. at the [bottom](#available-python-appimages) of this page.
{{ begin(".capsule") }} {{ begin(".capsule") }}
### Creating a symbolic link ### Creating a symbolic link
Since AppImages native names are rather lengthy, one might create a symbolic As AppImages' native names are quite lengthy, it might be relevant to create a
link, e.g. as symbolic link, for example as
{{ begin("#basic-installation-example-symlink") }} {{ begin("#basic-installation-example-symlink") }}
```bash ```bash
@@ -59,23 +60,23 @@ ln -s python3.10.2-cp310-cp310-manylinux2014_x86_64.AppImage python3.10
``` ```
{{ end("#basic-installation-example-symlink") }} {{ end("#basic-installation-example-symlink") }}
Then, executing the AppImage as Executing the AppImage as {{ "`./python3.10`" |
{{ "`./python3.10`" | id("basic-installation-example-execution") }} should id("basic-installation-example-execution") }} should then start a Python
start a Python interactive session on _almost_ any Linux, provided that **fuse** interactive session on almost any Linux distribution, provided that **fuse** is
is supported. supported.
{{ end(".capsule") }} {{ end(".capsule") }}
!!! Tip !!! Tip
Fuse is not supported on Windows Subsytem for Linux v1 (WSL1), preventing Fuse is not supported on Windows Subsystem for Linux v1 (WSL1), which
AppImages direct execution. Yet, one can still extract the content of Python prevents the direct execution of AppImages. However, it is still possible to
AppImages and use them, as explained in the [Advanced extract the contents of Python AppImages and use them, as explained in the
installation](#advanced-installation) section. [Advanced installation](#advanced-installation) section.
## Installing site packages ## Installing site packages
Site packages can be installed using `pip`, distributed with the AppImage. For Site packages can be installed using `pip`, which is distributed with Python
example, the following AppImages. For example, the following command
{{ begin("#site-packages-example") }} {{ begin("#site-packages-example") }}
```bash ```bash
@@ -83,23 +84,22 @@ example, the following
``` ```
{{ end("#site-packages-example") }} {{ end("#site-packages-example") }}
installs the numpy package, where it is assumed that a symlink to the AppImage installs the [numpy][NUMPY] package, assuming that a symlink to the AppImage has
has been previously created. When using the **basic installation** scheme, by been created beforehand. When using this **basic installation** scheme, Python
default Python packages are installed to your **user space**, i.e. under packages are installed by default to your **user space** (i.e. under `~/.local`
`~/.local` on Linux. on Linux).
!!! Note !!! Note
AppImage are read-only. Therefore, site packages cannot be directly AppImages are read-only. Therefore, site packages cannot be installed
installed to the AppImage. However, the AppImage can be extracted, as directly to the Python AppImage. However, the AppImage can be extracted, as
explained in the [Advanced installation](#advanced-installation) section. explained in the [Advanced installation](#advanced-installation) section.
{{ begin(".capsule") }} {{ begin(".capsule") }}
### Alternative site packages location ### Alternative site packages location
One can The `--target option` of pip can be used to specify an alternative installation
specify an alternative installation directory for site packages using the directory for site packages. For example, the following command
`--target` option of pip. For example, the following
{{ begin("#site-packages-example-target") }} {{ begin("#site-packages-example-target") }}
```bash ```bash
@@ -107,7 +107,9 @@ specify an alternative installation directory for site packages using the
``` ```
{{ end("#site-packages-example-target") }} {{ end("#site-packages-example-target") }}
installs the numpy package besides the AppImage, in a `packages` folder. installs the [numpy][NUMPY] package in the `packages` folder, besides the
AppImage.
{{ end(".capsule") }} {{ end(".capsule") }}
!!! Tip !!! Tip
@@ -116,33 +118,33 @@ installs the numpy package besides the AppImage, in a `packages` folder.
`PYTHONPATH` environment variable. `PYTHONPATH` environment variable.
!!! Caution !!! Caution
While Python AppImages are relocatable, site packages might not be. In Although Python AppImages are relocatable, site packages may not be. In
particular, packages installing executable Python scripts assume a fix particular, packages that install executable Python scripts assume a fixed
location of the Python runtime. If the Python AppImage is moved, then these location for the Python runtime. If the Python AppImage is moved, these
scripts will fail. This can be patched by editing the script scripts will fail. This can be resolved by either editing the script
[shebang][SHEBANG], or be reinstalling the corresponding package. [shebang][SHEBANG] or reinstalling the corresponding package.
## Isolating from the user environment ## Isolating from the user environment
By default, Python AppImages are not isolated from the user environment. For By default, Python AppImages are not isolated from the user environment. For
example, packages located under `~/.local/lib/pythonX.Y/site-packages` are example, packages located under `~/.local/lib/pythonX.Y/site-packages` are
loaded prior to AppImage's (system) ones. Note that this is the usual Python loaded before the AppImage's ones. Note that this is the standard Python runtime
runtime behaviour. However, it can be conflictual for some applications. behaviour. However, this can be conflictual for some applications.
In order to isolate your application from the user environment, the Python To isolate your application from the user environment, the Python runtime
runtime provides the `-E`, `-s` and `-I` options. For example, invoking a Python provides the `-E`, `-s` and `-I` options. For example, running {{ "`./python3.10
AppImage as {{ "`./python3.10 -s`" | id("user-isolation-example") }} prevents -s`" | id("user-isolation-example") }} prevents the loading of user site
the loading of user site packages (located under `~/.local`). Additionaly, the packages located under `~/.local`. Additionally, the `-E` option disables
`-E` option disables Python related environment variables. In particular, it Python-related environment variables. In particular, it prevents packages under
prevents packages under `PYTHONPATH` to be loaded. The `-I` option triggers both `PYTHONPATH` from being loaded. The `-I` option triggers both the `-E` and `-s`
`-E` and `-s`. options.
## Using a virtual environement ## Using a virtual environement
Isolation can also be achieved with a [virtual environment][VENV]. Python [Virtual environments][VENV] can also be used to achieve isolation. For example,
AppImages can create a `venv` using the standard syntax, e.g. as Python AppImages can create a `venv` using the standard syntax, as
{{ begin("#venv-example") }} {{ begin("#venv-example") }}
```bash ```bash
@@ -150,15 +152,16 @@ AppImages can create a `venv` using the standard syntax, e.g. as
``` ```
{{ end("#venv-example") }} {{ end("#venv-example") }}
Note that moving the base Python AppImage to another location breaks the virtual Please note that moving the base Python AppImage to a different location will
environment. This can be patched by editing symbolic links under `venv/bin`, as break the virtual environment. This can be resolved by editing the symbolic
well as the `home` variable in `venv/pyvenv.cfg`. The latter must point to the links in `venv/bin`, as well as the `home` variable in `venv/pyvenv.cfg`. The
AppImage directory. latter must point to the AppImage directory.
!!! Tip !!! Tip
Old Python AppImages, created before version 1.1, fail setting up `pip` Old Python AppImages created before version 1.1 fail to set up `pip`
automaticaly during `venv` creation. However, this can be patched by calling automatically during `venv` creation. However, this can be resolved by
`ensurepip` from within the `venv`, after its creation. For example, as calling `ensurepip` within the virtual environment after its creation. For
example, as
```bash ```bash
source /path/to/new/virtual/environment/bin/activate source /path/to/new/virtual/environment/bin/activate
@@ -170,10 +173,10 @@ python -m ensurepip
## Advanced installation ## Advanced installation
The [basic installation](#basic-installation) scheme described previously has The [basic installation](#basic-installation) scheme described previously has
some limitations when using Python AppImages as a runtime. For example, site certain limitations when Python AppImages are used as the runtime environment.
packages need to be installed to a separate location. This can be solved by For example, site packages need to be installed in a different location. This
extracting a Python AppImage to an `*.AppDir` directory, e.g. as issue can be resolved by extracting a Python AppImage to an `AppDir`
directory, e.g. as
{{ begin("#advanced-installation-example") }} {{ begin("#advanced-installation-example") }}
```bash ```bash
@@ -185,32 +188,32 @@ ln -s python3.10.2-cp310-cp310-manylinux2014_x86_64.AppDir/AppRun python3.10
``` ```
{{ end("#advanced-installation-example") }} {{ end("#advanced-installation-example") }}
Then, by default **site packages** are installed to the extracted **AppDir**, Then, by default, **site packages** are installed to the extracted `AppDir`
when using `pip`. In addition, executable scripts installed by `pip` are patched when using `pip`. Additionally, executable scripts installed by `pip` are
in order to use relative [shebangs][SHEBANG]. Consequently, the AppDir can be patched to use relative [shebangs][SHEBANG]. Consequently, the `AppDir` can be
freely moved around. moved around freely.
!!! Note !!! Note
Python AppDirs follow the [manylinux][MANYLINUX] installation scheme. Python `AppDirs` follow the [Manylinux][MANYLINUX] installation scheme.
Executable scripts are installed under `AppDir/opt/pythonX.Y/bin` where _X_ Executable scripts are installed under the `AppDir/opt/pythonX.Y/bin`
and _Y_ in _pythonX.Y_ stand for the major and minor version numbers. Site directory, where _X_ and _Y_ represent the major and minor version numbers,
packages are located under respectively. Site packages are located under
`AppDir/opt/pythonX.Y/lib/pythonX.Y/site-packages`. For convenience, `pip` `AppDir/opt/pythonX.Y/lib/pythonX.Y/site-packages`. For convenience,
installed applications are also mirrored under `AppDir/usr/bin`, using applications installed using `pip` are also mirrored under `AppDir/usr/bin`
symbolic links. using symbolic links.
!!! Tip !!! Tip
As for Python AppImages, by default the extracted runtime is [not isolated As for Python AppImages, the extracted runtime is [not isolated from the
from the user environment](#isolating-from-the-user-environment). This user environment](#isolating-from-the-user-environment) by default. This
behaviour can be changed by editing the `AppDir/usr/bin/pythonX.Y` wrapper behaviour can be changed by editing the `AppDir/usr/bin/pythonX.Y` wrapper
script, and by adding the `-s`, `-E` or `-I` option at the very bottom, script and adding the `-s`, `-E` or `-I` option to the line invoking Python
where Python is invoked. (at the end of the script).
{{ begin(".capsule") }} {{ begin(".capsule") }}
### Repackaging the AppImage ### Repackaging the AppImage
An extracted AppDir can be re-packaged as an AppImage using An extracted `AppDir` can be re-packaged as an AppImage using
[appimagetool][APPIMAGETOOL], e.g. as [appimagetool][APPIMAGETOOL], e.g. as
@@ -227,21 +230,21 @@ chmod +x appimagetool-x86_64.AppImage
``` ```
{{ end("#repackaging-example") }} {{ end("#repackaging-example") }}
This allows to customize your Python AppImage, for example by adding your This allows you to personalise your Python AppImage by adding your preferred
preferred site packages. site packages, for example.
{{ end(".capsule") }} {{ end(".capsule") }}
!!! Note !!! Note
Python AppImages can also be used for packaging Python based applications, Python AppImages can also be used to package Python-based applications as
as AppImages. Additional details are provided in the [developers AppImages. Further information can be found in the [developers'
section](apps). section](apps).
## Available Python AppImages ## Available Python AppImages
A summary of available Python AppImages [releases][RELEASES] is provided in the The [table](#appimages-download-links) below provides a summary of the available
[table](#appimages-download-links) below. Clicking on a badge should download Python AppImage [releases][RELEASES]. Clicking on a badge should download the
the corresponding AppImage. corresponding AppImage.
{{ begin("#suggest-appimage-download") }} {{ begin("#suggest-appimage-download") }}
!!! Caution !!! Caution

View File

@@ -4,6 +4,7 @@ $.getJSON("https://api.github.com/repos/niess/python-appimage/releases").done(fu
/* Unpack release metadata */ /* Unpack release metadata */
const releases = [] const releases = []
for (const datum of data) { for (const datum of data) {
if (!datum.name.startsWith("Python")) continue;
var full_version = undefined; var full_version = undefined;
const assets = []; const assets = [];
for (const asset of datum.assets) { for (const asset of datum.assets) {
@@ -11,11 +12,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 +38,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);
} }
} }
} }
@@ -62,7 +75,7 @@ $.getJSON("https://api.github.com/repos/niess/python-appimage/releases").done(fu
elements.push(`<a href="${release.url}">${release.version}</a>`) elements.push(`<a href="${release.url}">${release.version}</a>`)
} }
$("#append-releases-list").html( $("#append-releases-list").html(
" Available Python versions are " + " The available Python versions are " +
elements.slice(0, -1).join(", ") + elements.slice(0, -1).join(", ") +
" and " + " and " +
elements[elements.length - 1] + elements[elements.length - 1] +
@@ -162,11 +175,26 @@ $.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}
`);
} }
function badge (asset, pad) { function badge (asset, pad) {
const colors = {i686: "lightgrey", x86_64: "blue"}; const colors = {
aarch64: "d8dee9",
i686: "81a1c1",
x86_64: "5e81ac"
};
const python = asset.python.split("-")[1]; const python = asset.python.split("-")[1];
const arch = asset.arch.replace("_", "__"); const arch = asset.arch.replace("_", "__");
var color = colors[asset.arch]; var color = colors[asset.arch];

View File

@@ -1,13 +0,0 @@
paper.pdf: paper.md paper.bib
docker run --rm \
--volume ${PWD}:/data \
--user $(id -u):$(id -g) \
--env JOURNAL=joss \
openjournals/inara \
-o pdf \
/data/paper.md
.PHONY: clean
clean:
rm -f paper.pdf

View File

@@ -1,132 +0,0 @@
@software{kernel,
title = {Linux Kernel},
author = "Torvalds, Linus and others",
howpublished = "\url{https://kernel.org/}",
year = 1991
}
@software{appimage,
title = {AppImage},
booktitle = {Linux applications that run everywhere},
author = "Peter, Simon and others",
howpublished = "\url{https://appimage.org/}",
year = 2013
}
@software{python,
title = {Python},
author = "{van Rossum}, Guido and others",
howpublished = "\url{https://www.python.org/}",
year = 1991
}
@software{anaconda,
title = {Anaconda},
author = "Anaconda{, Inc.}",
howpublished = "\url{https://www.anaconda.com/}",
year = 2012
}
@software{wheels,
title = {Python wheels},
author = "{Python Software Foundation}",
howpublished = "\url{https://pythonwheels.com/}",
year = 2012
}
@software{flatpak,
title = {Flatpak},
author = "Larsson, Alexander and others",
howpublished = "\url{https://flatpak.org/}",
year = 2015
}
@software{snap,
title = {Snap},
author = "{Canonical Group Limited}",
howpublished = "\url{https://snapcraft.io/}",
year = 2016
}
@software{libfuse,
title = {Linux FUSE},
author = "Szeredi, Miklos and Rath, Nikolaus and others",
howpublished = "\url{https://github.com/libfuse/libfuse}",
year = 2013
}
@software{glibc,
title = {The GNU C Library},
author = "McGrath, Roland and Drepper, Ulrich and others",
howpublished = "\url{https://www.gnu.org/software/libc/}",
year = 1987
}
@software{docker,
title = {Docker},
author = "{Docker, Inc.}",
howpublished = "\url{https://www.docker.com/}",
year = 2013
}
@software{singularity,
title = {Singularity},
author = "Kurtzer, Gregory and others",
howpublished = "\url{https://apptainer.org/}",
year = 2015
}
@software{patchelf,
title = {PatchELF},
author = "Dolstra, Eelco and others",
howpublished = "\url{https://github.com/NixOS/patchelf}",
year = 2004
}
@software{linuxdeploy,
title = {linuxdeploy},
author = "{@TheAssassin} and others",
howpublished = "\url{https://github.com/linuxdeploy/linuxdeploy}",
year = 2018
}
@online{Gillmor:2014,
title = "{Q}\&{A} with {Linus} {Torvalds}",
date = 2014,
organization = "{Debian} {Conference} 2014",
author = "Gillmor, Daniel and Guerrero López, Ana and Torvalds,
Linus and others",
url = {https://www.youtube.com/watch?v=5PmHRSeA2c8}
}
@online{manylinux,
title = "Manylinux",
date = 2016,
organization = "{GitHub}",
author = "{Python Packaging Authority}",
url = {https://github.com/pypa/manylinux}
}
@online{tiobe,
title = "TIOBE index",
date = 2022,
author = "{The Software Quality Company}",
url = {https://www.tiobe.com/tiobe-index/}
}
@online{grand,
title = "Giant Radio Array for Neutrino Detection",
date = 2018,
author = "{The GRAND collaboration}",
url = {https://grand.cnrs.fr/}
}
@article{Alvarez-Muniz:2020,
author = "J. Álvarez-Muñiz and others",
doi = {10.1007/s11433-018-9385-7},
issue = {1},
journal = {Science China: Physics, Mechanics and Astronomy},
title = {The Giant Radio Array for Neutrino Detection (GRAND): Science and design},
volume = {63},
year = {2020}
}

View File

@@ -1,150 +0,0 @@
---
title: 'The Python-AppImage project'
tags:
- AppImage
- Linux
- Packaging
- Python
authors:
- name: Valentin Niess
orcid: 0000-0001-7148-6819
affiliation: 1
affiliations:
- name: Université Clermont Auvergne, CNRS/IN2P3, LPC, F-63000 Clermont-Ferrand, France.
index: 1
date: 22 June 2022
bibliography: paper.bib
---
# Summary
Since its initial release in 1991, the Linux Kernel [@kernel] has given birth to
more than 600 hundred Linux distributions (distros). While this is an impressive
success, the diversity of Linux flavours complicates the distribution of
applications for Linux (see e.g. [@Gillmor:2014]). Thus, contrary to other
operating systems, on Linux, source distributions long prevailed over binary
(precompiled) ones. Specifically, this is still the case for the Linux Python
runtime [@python] distributed by the Python Software Foundation (PSF).
Previously, this was also the case for Python packages available from the Python
Package Index (PyPI). This situation contributed to the emergence of an
alternative Python packaging system [@anaconda] delivering ready-to-use
precompiled runtimes and packages for Linux.
Over the last decade, a change of paradigm occurred in the Linux world. Cross
distros packaging systems have emerged, the like AppImage [@appimage], Flatpak
[@flatpak] and Snap [@snap]. At the same time, the PSF encouraged the conversion
of PyPI packages from source to binary distributions, using the new `wheel`
packaging format [@wheels].
The AppImage format is of particular interest for the present discussion.
Contrary to Flatpak and Snap, AppImages do not require to install an external
package manager. AppImage applications are bundled as a single executable file
upstreamed by developpers, ready-to-use after download. However, building proper
AppImages adds some complexity on the developers side.
Technically, an AppImage is an executable file embedding a software application
over a compressed virtual filesystem (VFS). The VFS is extracted and mounted at
runtime using `libfuse` [@libfuse]. Apart from core libraries, the like `libc`,
application dependencies are directly bundled inside the AppImage. In this
context, binary compatibility usually stems down to the host glibc [@glibc]
version used at compile time. As a matter of fact, glibc is admirably backward
compatible, down to version 2.1 where symbol versioning was introduced. Thus, in
practice binary compatibility is achieved by precompiling the application and
its relevant dependencies on an *old enough* Linux distro. This is greatly
facilitated by recent container technologies, the like Docker [@docker] and
Singularity [@singularity].
In practice, producing a portable binary wheel for Linux, or packaging an
application as an AppImage, faces the same issues. Accordingly, almost identical
strategies and tools are used in both cases. In particular, the PSF has
defined standard build platforms for Linux wheels. Those are available as Docker
images from the Manylinux project [@manylinux]. These images contain
precompiled Python runtimes with binary compatibility down to glibc 2.5 (for
manylinux1, corresponding to CentOS 5).
The Python-AppImage project provides relocatable Python runtimes as AppImages.
These runtimes are extracted from the Manylinux Docker images. Consequently,
they have the exact same binary compatibility as PyPI wheels. Python AppImages
are available as rolling GitHub releases. They are updated automatically on
every Sunday using GitHub Actions.
At the core of the Python-AppImage project, there is the `python-appimage`
executable, written in pure Python, and available from PyPI using `pip install`
(down to Python 2.7). The main functionality of `python-appimage` is to relocate
an existing (system) Python installation to an AppImage. Vanilla Python is not
relocatable. Therefore, some tweaks are needed in order to run Python from an
AppImage. In particular,
- Python initialisation is slightly modified in order to set `sys.executable`
and `sys.prefix` to the AppImage and to its temporary mount point. This is
achieved by a small edit of the `site` package.
- The run-time search path of Linux ELF files (Python runtime, binary packages,
shared libraries) is changed to a relative location, according to the AppImage
VFS hierarchy. Note that `patchelf` [@patchelf] allows to edit the
corresponding ELF entry. Thus, the Python runtime and its binary packages need
not to be recompiled.
Besides, `python-appimage` can also build simple Python based applications
according to a recipe folder. The recipe folder contains an entry point script,
AppImage metadata and an optional `requirements.txt` Python file. The
application is built from an existing Manylinux Python AppImage. Extra
dependencies are fetched from PyPI with `pip`. Note that they must be available
as binary wheels for the resulting AppImage to be portable.
# Statement of need
Python is among the top computing languages used nowadays. It was ranked number
one in 2021 and 2022 according to the TIOBE index [@tiobe]. In particular,
Python is widely used by the scientific community. A peculiarity of the Python
language is that it constantly evolves, owing to an Open Source community of
contributors structured by the PSF. While I personally find this very exciting
when working with Python, it comes at a price. Different projects use different
Python versions, and package requirements might conflict. This can be
circumvented by using virtual environments. For example, the `venv` package
allows one to manage different sets of requirements. However, it requires an
existing Python installation, i.e. a specific runtime version. On the contrary,
`conda` environments automate the management of both different runtime versions,
and sets of requirements. But, unfortunately Anaconda fragmented Python
packaging since, in practice, `conda` packages and Python wheels are not
(always) binary compatible.
Python-AppImage offers alternative solutions when working within the PSF context
(for `conda` based AppImages, `linuxdeploy` [@linuxdeploy] could be used).
First, Python-AppImage complements `venv` by providing ready-to-use Linux
runtimes for different Python versions. Moreover, Python AppImages can be used
as a replacement to `venv` by bundling extra (site) packages aside the runtime.
In this case, since AppImages are read-only, it can be convenient to extract
their content to a local folder (using the built-in `--appimage-extract`
option). Then, the extracted AppImage appears as a classic Python installation,
but it is relocatable. Especially, one can `pip install` additional packages to
the extracted folder, and it can be moved around, e.g. from a development host
directly to a production computing center.
# Mention
This project was born in the context of the Giant Radio Array for Neutrino
Detection (GRAND) [@grand] in order to share Python based software across
various Linux hosts: personal or office computers, computing centers, etc.
Specifically, Python AppImages were used for the neutrino sensitivity studies
presented in the GRAND whitepaper [@Alvarez-Muniz:2020].
I do not track usage of Python AppImages, which are available as simple
downloads. Therefore, I cannot provide detailed statistics. However, I am aware
of a dozen of (non scientific) Python based applications using
`python-appimage`. Besides, I personally use Python AppImages for my daily
academic work.
# Acknowledgements
We are grateful to several GitHub users for comments, feedback and contributions
to this project. In particular, we would like to thank Andy Kipp (\@anki-code),
Simon Peter (\@probonopd) and \@TheAssassin for support at the very roots of
this project. In addition, we are grateful to our colleagues from the GRAND
collaboration for beta testing Python AppImages.
# References

59
pyproject.toml Normal file
View File

@@ -0,0 +1,59 @@
[project]
name = "python_appimage"
authors = [
{ name="Valentin Niess", email="valentin.niess@gmail.com" },
]
dynamic = ["version"]
description = "Appimage releases of Python"
readme = "README.md"
requires-python = ">=3.9"
dependencies = [
"requests",
"setuptools",
]
classifiers = [
"Development Status :: 4 - Beta",
"Intended Audience :: Developers",
"Programming Language :: Python",
"Topic :: Software Development",
"Operating System :: POSIX :: Linux",
]
license = "GPL-3.0-or-later"
license-files = ["LICENSE"]
[project.scripts]
python-appimage = "python_appimage.__main__:main"
[project.urls]
homepage = "https://github.com/niess/python-appimage"
documentation = "https://python-appimage.readthedocs.io"
download = "https://pypi.python.org/pypi/python-appimage"
source = "https://github.com/niess/python-appimage"
issues = "https://github.com/niess/python-appimage/issues"
[build-system]
requires = ["setuptools >= 77.0.3"]
build-backend = "setuptools.build_meta"
[tool.setuptools.packages.find]
include = ["python_appimage*"]
[tool.setuptools.dynamic]
version = {attr = "python_appimage.__version__"}
[tool.bumpversion]
current_version = "1.4.4"
parse = "(?P<major>\\d+)\\.(?P<minor>\\d+)\\.(?P<patch>\\d+)"
serialize = ["{major}.{minor}.{patch}"]
search = "{current_version}"
replace = "{new_version}"
regex = false
ignore_missing_version = false
tag = false
allow_dirty = false
commit = true
message = "Bump version: v{new_version}"
commit_args = ""
[[tool.bumpversion.files]]
filename = "python_appimage/version.py"

View File

@@ -1,5 +1,7 @@
import platform import platform
from .version import version as __version__
if platform.system() != 'Linux': if platform.system() != 'Linux':
raise RuntimeError('invalid system: ' + platform.system()) raise RuntimeError('invalid system: ' + platform.system())

View File

@@ -1,13 +1,17 @@
import argparse import argparse
from importlib import import_module from importlib import import_module
import logging
import os import os
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,23 +27,45 @@ 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')
build_parser = subparsers.add_parser('build',
description='Build a Python appimage')
build_subparsers = build_parser.add_subparsers(title='type',
help='Type of AppImage build', dest='sub_command')
cache_parser = subparsers.add_parser('cache',
description='Manage Python appimage cache')
cache_subparsers = cache_parser.add_subparsers(title='operation',
help='Type of cache operation', dest='sub_command')
cache_clean_parser = cache_subparsers.add_parser('clean',
description='Clean cached image(s)')
cache_clean_parser.add_argument('tags', nargs='*',
help='manylinux image tag(s) (e.g. 2014_x86_64)')
cache_clean_parser.add_argument('-a', '--all', action='store_true',
help='remove all image(s) data')
cache_get_parser = cache_subparsers.add_parser('get',
description='Download image(s) to the cache')
cache_get_parser.add_argument('tags', nargs='+',
help='manylinux image tag(s) (e.g. 2014_x86_64)')
cache_get_parser.add_argument('-e', '--extract', action='store_true',
help='extract compressed image data')
cache_list_parser = cache_subparsers.add_parser('list',
description='List cached image(s)')
install_parser = subparsers.add_parser('install', install_parser = subparsers.add_parser('install',
description='Install binary dependencies') description='Install binary dependencies')
install_parser.add_argument('binary', nargs='+', install_parser.add_argument('binary', nargs='+',
choices=binaries, help='one or more binary name') choices=binaries, help='one or more binary name')
build_parser = subparsers.add_parser('build',
description='Build a Python appimage')
build_subparsers = build_parser.add_subparsers(
title='type',
help='Type of AppImage build',
dest='sub_command')
build_local_parser = build_subparsers.add_parser('local', build_local_parser = build_subparsers.add_parser('local',
description='Bundle a local Python installation') description='Bundle a local Python installation')
build_local_parser.add_argument('-d', '--destination', build_local_parser.add_argument('-d', '--destination',
@@ -47,14 +73,18 @@ def main():
build_local_parser.add_argument('-p', '--python', help='python executable') build_local_parser.add_argument('-p', '--python', help='python executable')
build_manylinux_parser = build_subparsers.add_parser('manylinux', build_manylinux_parser = build_subparsers.add_parser('manylinux',
description='Bundle a manylinux Python installation using docker') description='Bundle a manylinux Python installation')
build_manylinux_parser.add_argument('tag', build_manylinux_parser.add_argument('tag',
help='manylinux image tag (e.g. 2010_x86_64)') help='manylinux image tag (e.g. 2010_x86_64)')
build_manylinux_parser.add_argument('abi', build_manylinux_parser.add_argument('abi',
help='python ABI (e.g. cp37-cp37m)') help='python ABI (e.g. cp37-cp37m)')
build_manylinux_parser.add_argument('-b', '--bare',
build_manylinux_parser.add_argument('--contained', help=argparse.SUPPRESS, help='produce a bare image without the AppImage layer',
action='store_true', default=False) action='store_true')
build_manylinux_parser.add_argument('-c', '--clean',
help='clean the cache after extraction', action='store_true')
build_manylinux_parser.add_argument('-n', '--no-packaging',
help='do not package (compress) the image', action='store_true')
build_app_parser = build_subparsers.add_parser('app', build_app_parser = build_subparsers.add_parser('app',
description='Build a Python application using a base AppImage') description='Build a Python application using a base AppImage')
@@ -66,6 +96,8 @@ def main():
help='linux compatibility tag (e.g. manylinux1_x86_64)') help='linux compatibility tag (e.g. manylinux1_x86_64)')
build_app_parser.add_argument('-n', '--name', build_app_parser.add_argument('-n', '--name',
help='application name') help='application name')
build_app_parser.add_argument('--no-packaging',
help='do not package the app', action='store_true')
build_app_parser.add_argument('--python-tag', build_app_parser.add_argument('--python-tag',
help='python compatibility tag (e.g. cp37-cp37m)') help='python compatibility tag (e.g. cp37-cp37m)')
build_app_parser.add_argument('-p', '--python-version', build_app_parser.add_argument('-p', '--python-version',
@@ -74,6 +106,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 +123,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:
@@ -109,5 +153,5 @@ def main():
command.execute(*command._unpack_args(args)) command.execute(*command._unpack_args(args))
if __name__ == "__main__": if __name__ == '__main__':
main() main()

View File

@@ -1,7 +1,7 @@
from .build import build_appimage from .build import build_appimage
from .relocate import cert_file_env_string, patch_binary, relocate_python, \ from .appify import Appifier, tcltk_env_string
tcltk_env_string from .relocate import patch_binary, relocate_python
__all__ = ['build_appimage', 'cert_file_env_string', 'patch_binary', __all__ = ['Appifier', 'build_appimage', 'patch_binary', 'relocate_python',
'relocate_python', 'tcltk_env_string'] 'tcltk_env_string']

View File

@@ -0,0 +1,270 @@
from dataclasses import dataclass
import glob
import os
from typing import Optional
from ..utils.deps import PREFIX
from ..utils.fs import copy_file, make_tree, remove_file
from ..utils.log import log
from ..utils.template import copy_template, load_template
@dataclass(frozen=True)
class Appifier:
'''Helper class for bundling AppImage specific files'''
'''Path to AppDir root.'''
appdir: str
'''Path to AppDir executables.'''
appdir_bin: str
'''Path to Python executables.'''
python_bin: str
'''Path to Python site-packages.'''
python_pkg: str
'''Tcl/Tk version.'''
tk_version: str
'''Python version.'''
version: 'PythonVersion'
'''Path to SSL certification file.'''
cert_src: Optional[str]=None
def appify(self):
'''Bundle Appimage specific files'''
python_x_y = f'python{self.version.short()}'
pip_x_y = f'pip{self.version.short()}'
# Add a runtime patch for sys.executable, before site.main() execution
log('PATCH', f'{python_x_y} sys.executable')
set_executable_patch(
self.version.short(),
self.python_pkg,
PREFIX + '/data/_initappimage.py'
)
# Set a hook for cleaning sys.path, after site.main() execution
log('HOOK', f'{python_x_y} sys.path')
sitepkgs = self.python_pkg + '/site-packages'
make_tree(sitepkgs)
copy_file(PREFIX + '/data/sitecustomize.py', sitepkgs)
# Symlink SSL certificates
# (see https://github.com/niess/python-appimage/issues/24)
cert_file = '/opt/_internal/certs.pem'
cert_dst = f'{self.appdir}{cert_file}'
if self.cert_src is not None:
if os.path.exists(self.cert_src):
if not os.path.exists(cert_dst):
dirname, basename = os.path.split(cert_dst)
relpath = os.path.relpath(self.cert_src, dirname)
make_tree(dirname)
os.symlink(relpath, cert_dst)
log('INSTALL', basename)
if not os.path.exists(cert_dst):
cert_file = None
# Bundle the python wrapper
wrapper = f'{self.appdir_bin}/{python_x_y}'
if not os.path.exists(wrapper):
log('INSTALL', f'{python_x_y} wrapper')
entrypoint_path = PREFIX + '/data/entrypoint.sh'
entrypoint = load_template(
entrypoint_path,
python=f'python{self.version.flavoured()}'
)
dictionary = {
'entrypoint': entrypoint,
'shebang': '#! /bin/bash',
'tcltk-env': tcltk_env_string(self.python_pkg, self.tk_version)
}
if cert_file:
dictionary['cert-file'] = cert_file_env_string(cert_file)
else:
dictionary['cert-file'] = ''
_copy_template('python-wrapper.sh', wrapper, **dictionary)
# Set or update symlinks to python and pip.
pip_target = f'{self.python_bin}/{pip_x_y}'
if os.path.exists(pip_target):
relpath = os.path.relpath(pip_target, self.appdir_bin)
os.symlink(relpath, f'{self.appdir_bin}/{pip_x_y}')
pythons = glob.glob(self.appdir_bin + '/python?.*')
versions = [os.path.basename(python)[6:] for python in pythons]
latest2, latest3 = '0.0', '0.0'
for version in versions:
if version.startswith('2') and version >= latest2:
latest2 = version
elif version.startswith('3') and version >= latest3:
latest3 = version
if latest2 == self.version.short():
python2 = self.appdir_bin + '/python2'
remove_file(python2)
os.symlink(python_x_y, python2)
has_pip = os.path.exists(self.appdir_bin + '/' + pip_x_y)
if has_pip:
pip2 = self.appdir_bin + '/pip2'
remove_file(pip2)
os.symlink(pip_x_y, pip2)
if latest3 == '0.0':
log('SYMLINK', 'python, python2 to ' + python_x_y)
python = self.appdir_bin + '/python'
remove_file(python)
os.symlink('python2', python)
if has_pip:
log('SYMLINK', 'pip, pip2 to ' + pip_x_y)
pip = self.appdir_bin + '/pip'
remove_file(pip)
os.symlink('pip2', pip)
else:
log('SYMLINK', 'python2 to ' + python_x_y)
if has_pip:
log('SYMLINK', 'pip2 to ' + pip_x_y)
elif latest3 == self.version.short():
log('SYMLINK', 'python, python3 to ' + python_x_y)
python3 = self.appdir_bin + '/python3'
remove_file(python3)
os.symlink(python_x_y, python3)
python = self.appdir_bin + '/python'
remove_file(python)
os.symlink('python3', python)
if os.path.exists(self.appdir_bin + '/' + pip_x_y):
log('SYMLINK', 'pip, pip3 to ' + pip_x_y)
pip3 = self.appdir_bin + '/pip3'
remove_file(pip3)
os.symlink(pip_x_y, pip3)
pip = self.appdir_bin + '/pip'
remove_file(pip)
os.symlink('pip3', pip)
# Bundle the entry point
apprun = f'{self.appdir}/AppRun'
if not os.path.exists(apprun):
log('INSTALL', 'AppRun')
relpath = os.path.relpath(wrapper, self.appdir)
os.symlink(relpath, apprun)
# Bundle the desktop file
desktop_name = f'python{self.version.long()}.desktop'
desktop = os.path.join(self.appdir, desktop_name)
if not os.path.exists(desktop):
log('INSTALL', desktop_name)
apps = 'usr/share/applications'
appfile = f'{self.appdir}/{apps}/{desktop_name}'
if not os.path.exists(appfile):
make_tree(os.path.join(self.appdir, apps))
_copy_template('python.desktop', appfile,
version=self.version.short(),
fullversion=self.version.long())
os.symlink(os.path.join(apps, desktop_name), desktop)
# Bundle icons
icons = 'usr/share/icons/hicolor/256x256/apps'
icon = os.path.join(self.appdir, 'python.png')
if not os.path.exists(icon):
log('INSTALL', 'python.png')
make_tree(os.path.join(self.appdir, icons))
copy_file(PREFIX + '/data/python.png',
os.path.join(self.appdir, icons, 'python.png'))
os.symlink(os.path.join(icons, 'python.png'), icon)
diricon = os.path.join(self.appdir, '.DirIcon')
if not os.path.exists(diricon):
os.symlink('python.png', diricon)
# Bundle metadata
meta_name = f'python{self.version.long()}.appdata.xml'
meta_dir = os.path.join(self.appdir, 'usr/share/metainfo')
meta_file = os.path.join(meta_dir, meta_name)
if not os.path.exists(meta_file):
log('INSTALL', meta_name)
make_tree(meta_dir)
_copy_template(
'python.appdata.xml',
meta_file,
version = self.version.short(),
fullversion = self.version.long()
)
def cert_file_env_string(cert_file):
'''Environment for using a bundled certificate
'''
if cert_file:
return '''
# Export SSL certificate
export SSL_CERT_FILE="${{APPDIR}}{cert_file:}"'''.format(
cert_file=cert_file)
else:
return ''
def _copy_template(name, destination, **kwargs):
path = os.path.join(PREFIX, 'data', name)
copy_template(path, destination, **kwargs)
def tcltk_env_string(python_pkg, tk_version):
'''Environment for using AppImage's TCl/Tk
'''
if tk_version:
return '''
# Export TCl/Tk
export TCL_LIBRARY="${{APPDIR}}/usr/share/tcltk/tcl{tk_version:}"
export TK_LIBRARY="${{APPDIR}}/usr/share/tcltk/tk{tk_version:}"
export TKPATH="${{TK_LIBRARY}}"'''.format(
tk_version=tk_version)
else:
return ''
def set_executable_patch(version, pkgpath, patch):
'''Set a runtime patch for sys.executable name
'''
# This patch needs to be executed before site.main() is called. A natural
# option is to apply it directy to the site module. But, starting with
# Python 3.11, the site module is frozen within Python executable. Then,
# doing so would require to recompile Python. Thus, starting with 3.11 we
# instead apply the patch to the encodings package. Indeed, the latter is
# loaded before the site module, and it is not frozen (as for now).
major, minor = [int(v) for v in version.split('.')]
if (major >= 3) and (minor >= 11):
path = os.path.join(pkgpath, 'encodings', '__init__.py')
else:
path = os.path.join(pkgpath, 'site.py')
with open(path) as f:
source = f.read()
if '_initappimage' in source: return
lines = source.split(os.linesep)
if path.endswith('site.py'):
# Insert the patch before the main function
for i, line in enumerate(lines):
if line.startswith('def main('): break
else:
# Append the patch at end of file
i = len(lines)
with open(patch) as f:
patch = f.read()
lines.insert(i, patch)
lines.insert(i + 1, '')
source = os.linesep.join(lines)
with open(path, 'w') as f:
f.write(source)

View File

@@ -5,27 +5,25 @@ 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.fs import copy_tree
from ..utils.log import debug, log from ..utils.log import debug, log
from ..utils.tmp import TemporaryDirectory
__all__ = ['build_appimage'] __all__ = ['build_appimage']
def build_appimage(appdir=None, destination=None): def build_appimage(appdir=None, *, arch=None, destination=None):
'''Build an AppImage from an AppDir '''Build an AppImage from an AppDir
''' '''
if appdir is None: if appdir is None:
appdir = 'AppDir' appdir = 'AppDir'
log('BUILD', appdir) log('BUILD', os.path.basename(appdir))
ensure_appimagetool() appimagetool = ensure_appimagetool()
if arch is None:
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)
@@ -36,7 +34,7 @@ def build_appimage(appdir=None, destination=None):
appimage_pattern = re.compile('should be packaged as ([^ ]+[.]AppImage)') appimage_pattern = re.compile('should be packaged as ([^ ]+[.]AppImage)')
stdout, appimage = [], None stdout = []
while True: while True:
out = decode(p.stdout.readline()) out = decode(p.stdout.readline())
stdout.append(out) stdout.append(out)
@@ -45,7 +43,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

@@ -4,62 +4,17 @@ import re
import shutil import shutil
import sys import sys
from ..utils.deps import EXCLUDELIST, PATCHELF, PREFIX, ensure_excludelist, \ from .appify import Appifier
from ..manylinux import PythonVersion
from ..utils.deps import EXCLUDELIST, PATCHELF, ensure_excludelist, \
ensure_patchelf ensure_patchelf
from ..utils.fs import copy_file, copy_tree, make_tree, remove_file, remove_tree from ..utils.fs import copy_file, copy_tree, make_tree, remove_file, \
from ..utils.log import debug, log remove_tree
from ..utils.log import log
from ..utils.system import ldd, system from ..utils.system import ldd, system
from ..utils.template import copy_template, load_template
__all__ = ["cert_file_env_string", "patch_binary", "relocate_python", __all__ = ['patch_binary', 'relocate_python']
"tcltk_env_string"]
def _copy_template(name, destination, **kwargs):
path = os.path.join(PREFIX, 'data', name)
copy_template(path, destination, **kwargs)
def _get_tk_version(python_pkg):
tkinter = glob.glob(python_pkg + '/lib-dynload/_tkinter*.so')
if tkinter:
tkinter = tkinter[0]
for dep in ldd(tkinter):
name = os.path.basename(dep)
if name.startswith('libtk'):
match = re.search('libtk([0-9]+[.][0-9]+)', name)
return match.group(1)
else:
raise RuntimeError('could not guess Tcl/Tk version')
def tcltk_env_string(python_pkg):
'''Environment for using AppImage's TCl/Tk
'''
tk_version = _get_tk_version(python_pkg)
if tk_version:
return '''
# Export TCl/Tk
export TCL_LIBRARY="${{APPDIR}}/usr/share/tcltk/tcl{tk_version:}"
export TK_LIBRARY="${{APPDIR}}/usr/share/tcltk/tk{tk_version:}"
export TKPATH="${{TK_LIBRARY}}"'''.format(
tk_version=tk_version)
else:
return ''
def cert_file_env_string(cert_file):
'''Environment for using a bundled certificate
'''
if cert_file:
return '''
# Export SSL certificate
export SSL_CERT_FILE="${{APPDIR}}{cert_file:}"'''.format(
cert_file=cert_file)
else:
return ''
_excluded_libs = None _excluded_libs = None
@@ -68,7 +23,7 @@ _excluded_libs = None
def patch_binary(path, libdir, recursive=True): def patch_binary(path, libdir, recursive=True):
'''Patch the RPATH of a binary and and fetch its dependencies '''Patch the RPATH of a binary and fetch its dependencies
''' '''
global _excluded_libs global _excluded_libs
@@ -85,69 +40,27 @@ 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:
continue continue
target = libdir + '/' + name target = libdir + '/' + name
if not os.path.exists(target): if not os.path.exists(target):
libname = os.path.basename(dep)
copy_file(dep, target) copy_file(dep, target)
if recursive: if recursive:
patch_binary(target, libdir, recursive=True) patch_binary(target, libdir, recursive=True)
def set_executable_patch(version, pkgpath, patch):
'''Set a runtime patch for sys.executable name
'''
# This patch needs to be executed before site.main() is called. A natural
# option is to apply it directy to the site module. But, starting with
# Python 3.11, the site module is frozen within Python executable. Then,
# doing so would require to recompile Python. Thus, starting with 3.11 we
# instead apply the patch to the encodings package. Indeed, the latter is
# loaded before the site module, and it is not frozen (as for now).
major, minor = [int(v) for v in version.split('.')]
if (major >= 3) and (minor >= 11):
path = os.path.join(pkgpath, 'encodings', '__init__.py')
else:
path = os.path.join(pkgpath, 'site.py')
with open(path) as f:
source = f.read()
if '_initappimage' in source: return
lines = source.split(os.linesep)
if path.endswith('site.py'):
# Insert the patch before the main function
for i, line in enumerate(lines):
if line.startswith('def main('): break
else:
# Append the patch at end of file
i = len(lines)
with open(patch) as f:
patch = f.read()
lines.insert(i, patch)
lines.insert(i + 1, '')
source = os.linesep.join(lines)
with open(path, 'w') as f:
f.write(source)
def relocate_python(python=None, appdir=None): def relocate_python(python=None, appdir=None):
'''Bundle a Python install inside an AppDir '''Bundle a Python install inside an AppDir
''' '''
@@ -162,11 +75,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 +106,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
@@ -233,9 +158,6 @@ def relocate_python(python=None, appdir=None):
f.write(body) f.write(body)
shutil.copymode(pip_source, target) shutil.copymode(pip_source, target)
relpath = os.path.relpath(target, APPDIR_BIN)
os.symlink(relpath, APPDIR_BIN + '/' + PIP_X_Y)
# Remove unrelevant files # Remove unrelevant files
log('PRUNE', '%s packages', PYTHON_X_Y) log('PRUNE', '%s packages', PYTHON_X_Y)
@@ -247,17 +169,6 @@ def relocate_python(python=None, appdir=None):
for path in matches: for path in matches:
remove_tree(path) remove_tree(path)
# Add a runtime patch for sys.executable, before site.main() execution
log('PATCH', '%s sys.executable', PYTHON_X_Y)
set_executable_patch(VERSION, PYTHON_PKG, PREFIX + '/data/_initappimage.py')
# Set a hook for cleaning sys.path, after site.main() execution
log('HOOK', '%s sys.path', PYTHON_X_Y)
sitepkgs = PYTHON_PKG + '/site-packages'
make_tree(sitepkgs)
copy_file(PREFIX + '/data/sitecustomize.py', sitepkgs)
# Set RPATHs and bundle external libraries # Set RPATHs and bundle external libraries
log('LINK', '%s C-extensions', PYTHON_X_Y) log('LINK', '%s C-extensions', PYTHON_X_Y)
@@ -280,23 +191,14 @@ 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)
copy_tree(hostdir, tcltkdir)
else:
make_tree(tcltkdir) make_tree(tcltkdir)
tclpath = '/usr/share/tcl' + tk_version tclpath = libdir + '/tcl' + tk_version
if not tclpath:
raise ValueError('could not find ' + tclpath)
copy_tree(tclpath, tcltkdir + '/tcl' + tk_version) copy_tree(tclpath, tcltkdir + '/tcl' + tk_version)
tkpath = libdir + '/tk' + 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_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')
if cert_file: if cert_file:
@@ -307,111 +209,35 @@ def relocate_python(python=None, appdir=None):
copy_file(cert_file, 'AppDir' + cert_file) copy_file(cert_file, 'AppDir' + cert_file)
log('INSTALL', basename) log('INSTALL', basename)
# Bundle AppImage specific files.
appifier = Appifier(
appdir = APPDIR,
appdir_bin = APPDIR_BIN,
python_bin = PYTHON_BIN,
python_pkg = PYTHON_PKG,
tk_version = tk_version,
version = PythonVersion.from_str(FULLVERSION)
)
appifier.appify()
# Bundle the python wrapper
wrapper = APPDIR_BIN + '/' + PYTHON_X_Y
if not os.path.exists(wrapper):
log('INSTALL', '%s wrapper', PYTHON_X_Y)
entrypoint_path = PREFIX + '/data/entrypoint.sh'
entrypoint = load_template(entrypoint_path, python=PYTHON_X_Y)
dictionary = {'entrypoint': entrypoint,
'shebang': '#! /bin/bash',
'tcltk-env': tcltk_env_string(PYTHON_PKG),
'cert-file': cert_file_env_string(cert_file)}
_copy_template('python-wrapper.sh', wrapper, **dictionary)
# Set or update symlinks to python def _get_tk_version(python_pkg):
pythons = glob.glob(APPDIR_BIN + '/python?.*') tkinter = glob.glob(python_pkg + '/lib-dynload/_tkinter*.so')
versions = [os.path.basename(python)[6:] for python in pythons] if tkinter:
latest2, latest3 = '0.0', '0.0' tkinter = tkinter[0]
for version in versions: for dep in ldd(tkinter):
if version.startswith('2') and version >= latest2: name = os.path.basename(dep)
latest2 = version if name.startswith('libtk'):
elif version.startswith('3') and version >= latest3: match = re.search('libtk([0-9]+[.][0-9]+)', name)
latest3 = version return match.group(1)
if latest2 == VERSION:
python2 = APPDIR_BIN + '/python2'
remove_file(python2)
os.symlink(PYTHON_X_Y, python2)
has_pip = os.path.exists(APPDIR_BIN + '/' + PIP_X_Y)
if has_pip:
pip2 = APPDIR_BIN + '/pip2'
remove_file(pip2)
os.symlink(PIP_X_Y, pip2)
if latest3 == '0.0':
log('SYMLINK', 'python, python2 to ' + PYTHON_X_Y)
python = APPDIR_BIN + '/python'
remove_file(python)
os.symlink('python2', python)
if has_pip:
log('SYMLINK', 'pip, pip2 to ' + PIP_X_Y)
pip = APPDIR_BIN + '/pip'
remove_file(pip)
os.symlink('pip2', pip)
else: else:
log('SYMLINK', 'python2 to ' + PYTHON_X_Y) raise RuntimeError('could not guess Tcl/Tk version')
if has_pip:
log('SYMLINK', 'pip2 to ' + PIP_X_Y)
elif latest3 == VERSION:
log('SYMLINK', 'python, python3 to ' + PYTHON_X_Y)
python3 = APPDIR_BIN + '/python3'
remove_file(python3)
os.symlink(PYTHON_X_Y, python3)
python = APPDIR_BIN + '/python'
remove_file(python)
os.symlink('python3', python)
if os.path.exists(APPDIR_BIN + '/' + PIP_X_Y):
log('SYMLINK', 'pip, pip3 to ' + PIP_X_Y)
pip3 = APPDIR_BIN + '/pip3'
remove_file(pip3)
os.symlink(PIP_X_Y, pip3)
pip = APPDIR_BIN + '/pip'
remove_file(pip)
os.symlink('pip3', pip)
# Bundle the entry point
apprun = APPDIR + '/AppRun'
if not os.path.exists(apprun):
log('INSTALL', 'AppRun')
relpath = os.path.relpath(wrapper, APPDIR)
os.symlink(relpath, APPDIR + '/AppRun')
# Bundle the desktop file
desktop_name = 'python{:}.desktop'.format(FULLVERSION)
desktop = os.path.join(APPDIR, desktop_name)
if not os.path.exists(desktop):
log('INSTALL', desktop_name)
apps = 'usr/share/applications'
appfile = '{:}/{:}/python{:}.desktop'.format(APPDIR, apps, FULLVERSION)
if not os.path.exists(appfile):
make_tree(os.path.join(APPDIR, apps))
_copy_template('python.desktop', appfile, version=VERSION,
fullversion=FULLVERSION)
os.symlink(os.path.join(apps, desktop_name), desktop)
# Bundle icons def _get_tk_libdir(version):
icons = 'usr/share/icons/hicolor/256x256/apps' try:
icon = os.path.join(APPDIR, 'python.png') library = system(('tclsh' + version,), stdin='puts [info library]')
if not os.path.exists(icon): except SystemError:
log('INSTALL', 'python.png') raise RuntimeError('could not locate Tcl/Tk' + version + ' library')
make_tree(os.path.join(APPDIR, icons))
copy_file(PREFIX + '/data/python.png',
os.path.join(APPDIR, icons, 'python.png'))
os.symlink(os.path.join(icons, 'python.png'), icon)
diricon = os.path.join(APPDIR, '.DirIcon') return os.path.dirname(library)
if not os.path.exists(diricon):
os.symlink('python.png', diricon)
# Bundle metadata
meta_name = 'python{:}.appdata.xml'.format(FULLVERSION)
meta_dir = os.path.join(APPDIR, 'usr/share/metainfo')
meta_file = os.path.join(meta_dir, meta_name)
if not os.path.exists(meta_file):
log('INSTALL', meta_name)
make_tree(meta_dir)
_copy_template('python.appdata.xml', meta_file, version=VERSION,
fullversion=FULLVERSION)

View File

@@ -1,16 +1,16 @@
import json import json
import glob import glob
import os import os
from pathlib import Path
import platform import platform
import re import re
import shutil
import stat 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 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,17 @@ 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, args.no_packaging
_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, no_packaging=None):
'''Build a Python application using a base AppImage '''Build a Python application using a base AppImage
''' '''
@@ -253,18 +256,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.*')
@@ -278,8 +312,6 @@ def execute(appdir, name=None, python_version=None, linux_tag=None,
shebang = '#! /bin/bash' shebang = '#! /bin/bash'
entrypoint = load_template(entrypoint_path, **dictionary) entrypoint = load_template(entrypoint_path, **dictionary)
python_pkg = 'AppDir/opt/python{0:}/lib/python{0:}'.format(
python_version)
dictionary = {'entrypoint': entrypoint, dictionary = {'entrypoint': entrypoint,
'shebang': shebang} 'shebang': shebang}
if os.path.exists('AppDir/AppRun'): if os.path.exists('AppDir/AppRun'):
@@ -289,7 +321,10 @@ def execute(appdir, name=None, python_version=None, linux_tag=None,
# Build the new AppImage # Build the new AppImage
destination = '{:}-{:}.AppImage'.format(application_name, fullname = '{:}-{:}'.format(application_name, platform.machine())
platform.machine()) if no_packaging:
copy_tree('AppDir', Path(pwd) / fullname)
else:
destination = f'{fullname}.AppImage'
build_appimage(destination=destination) build_appimage(destination=destination)
shutil.move(destination, os.path.join(pwd, destination)) copy_file(destination, os.path.join(pwd, destination))

View File

@@ -1,12 +1,11 @@
import glob
import os import os
import platform from pathlib import Path
import shutil import tarfile
import sys
from ...appimage import build_appimage, relocate_python from ...appimage import build_appimage
from ...utils.docker import docker_run from ...manylinux import ensure_image, PythonExtractor
from ...utils.fs import copy_tree from ...utils.fs import copy_file, copy_tree
from ...utils.log import log
from ...utils.tmp import TemporaryDirectory from ...utils.tmp import TemporaryDirectory
@@ -16,85 +15,55 @@ __all__ = ['execute']
def _unpack_args(args): def _unpack_args(args):
'''Unpack command line arguments '''Unpack command line arguments
''' '''
return args.tag, args.abi, args.contained return args.tag, args.abi, args.bare, args.clean, args.no_packaging
def _get_appimage_name(abi, tag): def execute(tag, abi, bare=False, clean=False, no_packaging=False):
'''Format the Python AppImage name using the ABI and OS tags '''Build a Python AppImage using a Manylinux image
'''
# Read the Python version from the desktop file
desktop = glob.glob('AppDir/python*.desktop')[0]
fullversion = desktop[13:-8]
# Finish building the AppImage on the host. See below.
return 'python{:}-{:}-manylinux{:}.AppImage'.format(
fullversion, abi, tag)
def execute(tag, abi, contained=False):
'''Build a Python AppImage using a manylinux docker image
''' '''
if not contained: image = ensure_image(tag, clean=clean)
# Forward the build to a Docker image
image = 'quay.io/pypa/manylinux' + tag
python = '/opt/python/' + abi + '/bin/python'
pwd = os.getcwd() pwd = os.getcwd()
dirname = os.path.abspath(os.path.dirname(__file__) + '/../..')
with TemporaryDirectory() as tmpdir: with TemporaryDirectory() as tmpdir:
copy_tree(dirname, 'python_appimage') python_extractor = PythonExtractor(
arch = image.arch,
argv = ' '.join(sys.argv[1:]) prefix = image.path,
if tag.startswith("1_"): tag = abi
# On manylinux1 tk is not installed
script = [
'yum --disablerepo="*" --enablerepo=base install -q -y tk']
else:
# tk is already installed on other platforms
script = []
script += [
python + ' -m python_appimage ' + argv + ' --contained',
''
]
docker_run(image, script)
appimage_name = _get_appimage_name(abi, tag)
if tag.startswith('1_') or tag.startswith('2010_'):
# appimagetool does not run on manylinux1 (CentOS 5) or
# manylinux2010 (CentOS 6). Below is a patch for these specific
# cases.
arch = tag.split('_', 1)[-1]
if arch == platform.machine():
# Pack the image directly from the host
build_appimage(destination=appimage_name)
else:
# Use a manylinux2014 Docker image (CentOS 7) in order to
# pack the image.
script = (
'python -m python_appimage ' + argv + ' --contained',
''
) )
docker_run('quay.io/pypa/manylinux2014_' + arch, script) appdir = Path(tmpdir) / 'AppDir'
appify = not bare
python_extractor.extract(appdir, appify=appify)
shutil.move(appimage_name, os.path.join(pwd, appimage_name)) fullname = '-'.join((
f'{python_extractor.impl}{python_extractor.version.long()}',
abi,
f'{image.tag}_{image.arch}'
))
if no_packaging:
copy_tree(
Path(tmpdir) / 'AppDir',
Path(pwd) / fullname
)
elif bare:
log('COMPRESS', fullname)
destination = f'{fullname}.tar.gz'
tar_path = Path(tmpdir) / destination
with tarfile.open(tar_path, "w:gz") as tar:
tar.add(appdir, arcname=fullname)
copy_file(
tar_path,
Path(pwd) / destination
)
else: else:
# We are running within a manylinux Docker image destination = f'{fullname}.AppImage'
is_manylinux_old = tag.startswith('1_') or tag.startswith('2010_') build_appimage(
appdir = str(appdir),
if not os.path.exists('AppDir'): arch = str(image.arch),
# Relocate the targeted manylinux Python installation destination = destination
relocate_python() )
else: copy_file(
# This is a second stage build. The Docker image has actually been Path(tmpdir) / destination,
# overriden (see above). Path(pwd) / destination
is_manylinux_old = False )
if is_manylinux_old:
# Build only the AppDir when running within a manylinux1 Docker
# image because appimagetool does not support CentOS 5 or CentOS 6.
pass
else:
build_appimage(destination=_get_appimage_name(abi, tag))

View File

64
python_appimage/commands/cache/clean.py vendored Normal file
View File

@@ -0,0 +1,64 @@
import glob
import json
import os
from pathlib import Path
import subprocess
from ...utils.deps import CACHE_DIR
from ...utils.fs import remove_file, remove_tree
from ...utils.log import log
__all__ = ['execute']
def _unpack_args(args):
'''Unpack command line arguments
'''
return (args.tags, args.all)
def execute(images, all_):
'''Clean cached image(s)
'''
cache = Path(CACHE_DIR)
if not images:
images = [image[9:] for image in sorted(os.listdir(cache /
'share/images'))]
for image in images:
try:
image, tag = image.rsplit(':', 1)
except ValueError:
tag = None
if not image.replace('_', '').isalnum():
raise ValueError(f'bad image tag ({image})')
path = cache / f'share/images/manylinux{image}'
if not path.exists():
raise ValueError(f'no such image ({image})')
if tag is None:
if not all_:
path = path / 'extracted'
remove_tree(str(path))
else:
tag_file = path / f'tags/{tag}.json'
if not tag_file.exists():
raise ValueError(f'no such image ({image}:{tag})')
if all_:
with tag_file.open() as f:
layers = json.load(f)["layers"]
for layer in layers:
layer = path / f'layers/{layer}.tar.gz'
if layer.exists():
remove_file(str(layer))
remove_file(str(tag_file))
else:
path = cache / f'share/images/{image}/extracted/{tag}'
if path.exists():
remove_tree(str(path))

18
python_appimage/commands/cache/get.py vendored Normal file
View File

@@ -0,0 +1,18 @@
from ...manylinux import ensure_image
__all__ = ['execute']
def _unpack_args(args):
'''Unpack command line arguments
'''
return (args.tags, args.extract)
def execute(images, extract):
'''Download image(s) to the cache
'''
for image in images:
ensure_image(image, extract=extract)

41
python_appimage/commands/cache/list.py vendored Normal file
View File

@@ -0,0 +1,41 @@
import glob
import os
from pathlib import Path
import subprocess
from ...utils.deps import CACHE_DIR
from ...utils.log import log
__all__ = ['execute']
def _unpack_args(args):
'''Unpack command line arguments
'''
return tuple()
def execute():
'''List cached image(s)
'''
cache = Path(CACHE_DIR)
images = sorted(os.listdir(cache / 'share/images'))
for image in images:
tags = ', '.join((
tag[:-5] for tag in \
sorted(os.listdir(cache / f'share/images/{image}/tags'))
))
if not tags:
continue
path = cache / f'share/images/{image}'
memory = _getsize(path)
log('LIST', f'{image} ({tags}) [{memory}]')
def _getsize(path: Path):
r = subprocess.run(f'du -sh {path}', capture_output=True, check=True,
shell=True)
return r.stdout.decode().split(None, 1)[0]

View File

@@ -0,0 +1,34 @@
import glob
from pathlib import Path
from ..manylinux import ensure_image, PythonVersion
from ..utils.log import log
__all__ = ['execute']
def _unpack_args(args):
'''Unpack command line arguments
'''
return (args.tag,)
def execute(tag):
'''List python versions installed in a manylinux image
'''
image = ensure_image(tag)
pythons = []
for path in glob.glob(str(image.path / 'opt/python/cp*')):
path = Path(path)
version = PythonVersion.from_str(path.readlink().name[8:]).long()
pythons.append((path.name, version))
pythons = sorted(pythons)
n = max(len(version) for (_, version) in pythons)
for (abi, version) in pythons:
log('LIST', "{:{n}} -> /opt/python/{:}".format(version, abi, n=n))
return pythons

View File

@@ -1,7 +1,6 @@
import os import os
from ..utils import deps from ..utils import deps
from ..utils.log import log
__all__ = ['execute'] __all__ = ['execute']
@@ -16,6 +15,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
''' '''
if binary == 'appimagetool':
path = deps.ensure_appimagetool(dry=True)
else:
path = os.path.join(os.path.dirname(deps.PATCHELF), binary) 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

@@ -0,0 +1,52 @@
from types import SimpleNamespace
from .config import Arch, LinuxTag, PythonImpl, PythonVersion
from .download import Downloader
from .extract import ImageExtractor, PythonExtractor
from .patch import Patcher
__all__ = ['Arch', 'Downloader', 'ensure_image', 'ImageExtractor', 'LinuxTag',
'Patcher', 'PythonExtractor', 'PythonImpl', 'PythonVersion']
def ensure_image(tag, *, clean=False, extract=True):
'''Download a manylinux image to the cache'''
try:
tag, image_tag = tag.rsplit(':', 1)
except ValueError:
image_tag = 'latest'
if tag.startswith('2_'):
tag, arch = tag[2:].split('_', 1)
tag = f'2_{tag}'
else:
tag, arch = tag.split('_', 1)
tag = LinuxTag.from_brief(tag)
arch = Arch.from_str(arch)
downloader = Downloader(tag=tag, arch=arch)
downloader.download(tag=image_tag)
if extract:
image_extractor = ImageExtractor(
prefix = downloader.default_destination(),
tag = image_tag
)
image_extractor.extract(clean=clean)
patcher = Patcher(tag=tag, arch=arch)
patcher.patch(destination = image_extractor.default_destination())
return SimpleNamespace(
arch = arch,
tag = tag,
path = image_extractor.default_destination(),
)
else:
return SimpleNamespace(
arch = arch,
tag = tag,
path = downloader.default_destination(),
)

View File

@@ -0,0 +1,109 @@
from enum import auto, Enum
import platform
from typing import NamedTuple, Optional, Union
__all__ = ['Arch', 'PythonImpl', 'PythonVersion']
class Arch(Enum):
'''Supported platform architectures.'''
AARCH64 = auto()
I686 = auto()
X86_64 = auto()
def __str__(self):
return self.name.lower()
@classmethod
def from_host(cls) -> 'Arch':
return cls.from_str(platform.machine())
@classmethod
def from_str(cls, value) -> 'Arch':
for arch in cls:
if value == str(arch):
return arch
else:
raise NotImplementedError(value)
class LinuxTag(Enum):
'''Supported platform tags.'''
MANYLINUX_1 = auto()
MANYLINUX_2010 = auto()
MANYLINUX_2014 = auto()
MANYLINUX_2_24 = auto()
MANYLINUX_2_28 = auto()
def __str__(self):
tag = self.name.lower()
if self in (LinuxTag.MANYLINUX_1, LinuxTag.MANYLINUX_2010,
LinuxTag.MANYLINUX_2014):
return tag.replace('_', '')
else:
return tag
@classmethod
def from_str(cls, value) -> 'LinuxTag':
for tag in cls:
if value == str(tag):
return tag
else:
raise NotImplementedError(value)
@classmethod
def from_brief(cls, value) -> 'LinuxTag':
if value.startswith('2_'):
return cls.from_str('manylinux_' + value)
else:
return cls.from_str('manylinux' + value)
class PythonImpl(Enum):
'''Supported Python implementations.'''
CPYTHON = auto()
def __str__(self):
return 'python'
class PythonVersion(NamedTuple):
''''''
major: int
minor: int
patch: Union[int, str]
flavour: Optional[str]=None
@classmethod
def from_str(cls, value: str) -> 'PythonVersion':
major, minor, patch = value.split('.', 2)
try:
patch, flavour = patch.split('-', 1)
except ValueError:
flavour = None
else:
if flavour == 'nogil':
flavour = 't'
elif flavour == 'ucs2':
flavour = 'm'
elif flavour == 'ucs4':
flavour = 'mu'
else:
raise NotImplementedError(value)
try:
patch = int(patch)
except ValueError:
pass
return cls(int(major), int(minor), patch, flavour)
def flavoured(self) -> str:
flavour = self.flavour if self.flavour == 't' else ''
return f'{self.major}.{self.minor}{flavour}'
def long(self) -> str:
return f'{self.major}.{self.minor}.{self.patch}'
def short(self) -> str:
return f'{self.major}.{self.minor}'

View File

@@ -0,0 +1,167 @@
from dataclasses import dataclass, field
import glob
import hashlib
import json
from pathlib import Path
import requests
import shutil
import tempfile
from typing import Optional
from .config import Arch, LinuxTag
from ..utils.deps import CACHE_DIR
from ..utils.log import debug, log
CHUNK_SIZE = 8189
SUCCESS = 200
class DownloadError(Exception):
pass
@dataclass(frozen=True)
class Downloader:
'''Manylinux tag.'''
tag: LinuxTag
'''Platform architecture.'''
arch: Optional[Arch] = None
'''Docker image.'''
image: str = field(init=False)
'''Authentication token.'''
token: str = field(init=False)
def __post_init__(self):
# Set host arch if not explictly specified.
if self.arch is None:
arch = Arch.from_host()
object.__setattr__(self, 'arch', arch)
# Set image name.
image = f'{self.tag}_{self.arch}'
object.__setattr__(self, 'image', image)
def default_destination(self):
return Path(CACHE_DIR) / f'share/images/{self.image}'
def download(
self,
destination: Optional[Path]=None,
*,
tag: Optional[str] = 'latest'
):
'''Download Manylinux image'''
destination = destination or self.default_destination()
# Authenticate to quay.io.
repository = f'pypa/{self.image}'
log('PULL', f'{self.image}:{tag}')
url = 'https://quay.io/v2/auth'
url = f'{url}?service=quay.io&scope=repository:{repository}:pull'
debug('GET', url)
r = requests.request('GET', url)
if r.status_code == SUCCESS:
object.__setattr__(self, 'token', r.json()['token'])
else:
raise DownloadError(r.status_code, r.text, r.headers)
# Fetch image manifest.
repository = f'pypa/{self.image}'
url = f'https://quay.io/v2/{repository}/manifests/{tag}'
headers = {
'Authorization': f'Bearer {self.token}',
'Accept': 'application/vnd.docker.distribution.manifest.v2+json'
}
debug('GET', url)
r = requests.request('GET', url, headers=headers)
if r.status_code == SUCCESS:
image_digest = r.headers['Docker-Content-Digest'].split(':', 1)[-1]
manifest = r.json()
else:
raise DownloadError(r.status_code, r.text, r.headers)
# Check missing layers to download.
required = [layer['digest'].split(':', 1)[-1] for layer in
manifest['layers']]
missing = []
for hash_ in required:
path = destination / f'layers/{hash_}.tar.gz'
if path.exists():
hasher = hashlib.sha256()
with path.open('rb') as f:
while True:
chunk = f.read(CHUNK_SIZE)
if not chunk:
break
else:
hasher.update(chunk)
h = hasher.hexdigest()
if h != hash_:
missing.append(hash_)
else:
debug('FOUND', f'{hash_}.tar.gz')
else:
missing.append(hash_)
# Fetch missing layers.
with tempfile.TemporaryDirectory() as tmpdir:
workdir = Path(tmpdir)
for i, hash_ in enumerate(missing):
debug('DOWNLOAD', f'{self.image}:{tag} '
f'[{i + 1} / {len(missing)}]')
filename = f'{hash_}.tar.gz'
url = f'https://quay.io/v2/{repository}/blobs/sha256:{hash_}'
debug('GET', url)
r = requests.request('GET', url, headers=headers, stream=True)
if r.status_code == SUCCESS:
debug('STREAM', filename)
else:
raise DownloadError(r.status_code, r.text, r.headers)
hasher = hashlib.sha256()
tmp = workdir / 'layer.tgz'
with open(tmp, "wb") as f:
for chunk in r.iter_content(CHUNK_SIZE):
if chunk:
f.write(chunk)
hasher.update(chunk)
h = hasher.hexdigest()
if h != hash_:
raise DownloadError(
f'bad hash (expected {hash_}, found {h})'
)
layers_dir = destination / 'layers'
layers_dir.mkdir(exist_ok=True, parents=True)
shutil.move(tmp, layers_dir / filename)
tags_dir = destination / 'tags'
tags_dir.mkdir(exist_ok=True, parents=True)
with open(tags_dir / f'{tag}.json', "w") as f:
json.dump({'digest': image_digest, 'layers': required}, f)
# Remove unused layers.
required = set(required)
for tag in glob.glob(str(destination / 'tags/*.json')):
with open(tag) as f:
tag = json.load(f)
required |= set(tag["layers"])
required = [f'{hash_}.tar.gz' for hash_ in required]
for layer in glob.glob(str(destination / 'layers/*.tar.gz')):
layer = Path(layer)
if layer.name not in required:
debug('REMOVE', f'{self.image} [layer/{layer.stem}]')
layer.unlink()

View File

@@ -0,0 +1,418 @@
import atexit
from dataclasses import dataclass, field
from distutils.version import LooseVersion
import glob
import json
import os
import re
from pathlib import Path
import shutil
import stat
import subprocess
from typing import Dict, List, Optional
from .config import Arch, PythonImpl, PythonVersion
from ..appimage import Appifier
from ..utils.deps import ensure_excludelist, ensure_patchelf, EXCLUDELIST, \
PATCHELF
from ..utils.log import debug, log
@dataclass(frozen=True)
class PythonExtractor:
'''Python extractor from an extracted Manylinux image.'''
arch: Arch
'''Target architecture'''
prefix: Path
'''Target image path'''
tag: str
'''Python binary tag'''
excludelist: Optional[Path] = None
'''Exclude list for shared libraries.'''
patchelf: Optional[Path] = None
'''Patchelf executable.'''
excluded: List[str] = field(init=False)
'''Excluded shared libraries.'''
impl: PythonImpl = field(init=False)
'''Python implementation'''
library_path: List[str] = field(init=False)
'''Search paths for libraries (LD_LIBRARY_PATH)'''
python_prefix: Path = field(init=False)
'''Python installation prefix'''
version: PythonVersion = field(init=False)
'''Python version'''
def __post_init__(self):
# Locate Python installation.
link = os.readlink(self.prefix / f'opt/python/{self.tag}')
if not link.startswith('/'):
raise NotImplementedError()
object.__setattr__(self, 'python_prefix', self.prefix / link[1:])
# Parse implementation and version.
head, tail = Path(link).name.split('-', 1)
if head == 'cpython':
impl = PythonImpl.CPYTHON
version = PythonVersion.from_str(tail)
else:
raise NotImplementedError()
object.__setattr__(self, 'impl', impl)
object.__setattr__(self, 'version', version)
# Set libraries search path.
paths = []
if self.arch in (Arch.AARCH64, Arch.X86_64):
paths.append(self.prefix / 'lib64')
paths.append(self.prefix / 'usr/lib64')
if self.arch == Arch.X86_64:
paths.append(self.prefix / 'lib/x86_64-linux-gnu')
paths.append(self.prefix / 'usr/lib/x86_64-linux-gnu')
else:
paths.append(self.prefix / 'lib/aarch64-linux-gnu')
paths.append(self.prefix / 'usr/lib/aarch64-linux-gnu')
elif self.arch == Arch.I686:
paths.append(self.prefix / 'lib')
paths.append(self.prefix / 'usr/lib')
paths.append(self.prefix / 'lib/i386-linux-gnu')
paths.append(self.prefix / 'usr/lib/i386-linux-gnu')
else:
raise NotImplementedError()
paths.append(self.prefix / 'usr/local/lib')
patterns = (
'curl-*',
'mpdecimal-*',
'openssl-*',
'sqlite*',
)
for pattern in patterns:
pattern = str(self.prefix / f'opt/_internal/{pattern}/lib')
for match in glob.glob(pattern):
paths.append(Path(match))
object.__setattr__(self, 'library_path', paths)
# Set excluded libraries.
if self.excludelist:
excludelist = Path(self.excludelist)
else:
ensure_excludelist()
excludelist = Path(EXCLUDELIST)
excluded = set()
with excludelist.open() as f:
for line in f:
line = line.strip()
if line and not line.startswith('#'):
excluded.add(line)
excluded.add('ld-linux-aarch64.so.1') # patch for aarch64.
object.__setattr__(self, 'excluded', excluded)
# Set patchelf, if not provided.
if self.patchelf is None:
ensure_patchelf()
object.__setattr__(self, 'patchelf', PATCHELF)
else:
assert(self.patchelf.exists())
def extract(
self,
destination: Path,
*,
appify: Optional[bool]=False,
python_prefix: Optional[str]=None,
system_prefix: Optional[str]=None,
):
'''Extract Python runtime.'''
python = f'python{self.version.short()}'
flavoured_python = f'python{self.version.flavoured()}'
runtime = f'bin/{flavoured_python}'
packages = f'lib/{flavoured_python}'
pip = f'bin/pip{self.version.short()}'
if python_prefix is None:
python_prefix = f'opt/{flavoured_python}'
if system_prefix is None:
system_prefix = 'usr'
python_dest = destination / python_prefix
system_dest = destination / system_prefix
# Locate include files.
include = glob.glob(str(self.python_prefix / 'include/*'))
if include:
include = Path(include[0]).name
include = f'include/{include}'
else:
raise NotImplementedError()
# Clone Python runtime.
log('CLONE',
f'{python} from {self.python_prefix.relative_to(self.prefix)}')
(python_dest / 'bin').mkdir(exist_ok=True, parents=True)
shutil.copy(self.python_prefix / runtime, python_dest / runtime)
# Clone pip wrapper.
with open(self.python_prefix / pip) as f:
f.readline() # Skip shebang.
body = f.read()
with open(python_dest / pip, 'w') as f:
f.write('#! /bin/sh\n')
f.write(' '.join((
'"exec"',
f'"$(dirname $(readlink -f ${0}))/{flavoured_python}"',
'"$0"',
'"$@"\n'
)))
f.write(body)
shutil.copymode(self.python_prefix / pip, python_dest / pip)
# Clone Python packages.
for folder in (packages, include):
shutil.copytree(self.python_prefix / folder, python_dest / folder,
symlinks=True, dirs_exist_ok=True)
# Remove some clutters.
log('PRUNE', '%s packages', python)
shutil.rmtree(python_dest / packages / 'test', ignore_errors=True)
for root, dirs, files in os.walk(python_dest / packages):
root = Path(root)
for d in dirs:
if d == '__pycache__':
shutil.rmtree(root / d, ignore_errors=True)
for f in files:
if f.endswith('.pyc'):
(root / f).unlink()
# Map binary dependencies.
libs = self.ldd(self.python_prefix / f'bin/{flavoured_python}')
path = Path(self.python_prefix / f'{packages}/lib-dynload')
for module in glob.glob(str(path / "*.so")):
l = self.ldd(module)
libs.update(l)
# Copy and patch binary dependencies.
libdir = system_dest / 'lib'
libdir.mkdir(exist_ok=True, parents=True)
for (name, src) in libs.items():
dst = libdir / name
shutil.copy(src, dst, follow_symlinks=True)
# Some libraries are read-only, which prevents overriding the
# destination directory. Below, we change the permission of
# destination files to read-write (for the owner).
mode = dst.stat().st_mode
if not (mode & stat.S_IWUSR):
mode = mode | stat.S_IWUSR
dst.chmod(mode)
self.set_rpath(dst, '$ORIGIN')
# Patch RPATHs of binary modules.
log('LINK', '%s C-extensions', python)
path = Path(python_dest / f'{packages}/lib-dynload')
for module in glob.glob(str(path / "*.so")):
src = Path(module)
dst = os.path.relpath(libdir, src.parent)
self.set_rpath(src, f'$ORIGIN/{dst}')
# Patch RPATHs of Python runtime.
src = python_dest / runtime
dst = os.path.relpath(libdir, src.parent)
self.set_rpath(src, f'$ORIGIN/{dst}')
# Copy SSL certificates (i.e. clone certifi).
certs = self.prefix / 'opt/_internal/certs.pem'
if certs.is_symlink():
dst = self.prefix / str(certs.readlink())[1:]
certifi = dst.parent
assert(certifi.name == 'certifi')
site_packages = certifi.parent
assert(site_packages.name == 'site-packages')
log('INSTALL', certifi.name)
matches = [
Path(src) for src in glob.glob(str(site_packages / 'certifi*'))
]
matches = sorted(matches, key=lambda src: src.name)
cert_src = None
for src in matches:
dst = python_dest / f'{packages}/site-packages/{src.name}'
if not dst.exists():
shutil.copytree(src, dst, symlinks=True)
if cert_src is None:
cacert_pem = dst / 'cacert.pem'
if cacert_pem.exists():
cert_src = cacert_pem
assert(cert_src is not None)
else:
raise NotImplementedError()
# Copy Tcl & Tk data.
tx_version = []
for match in glob.glob(str(system_dest / 'lib/libtk*')):
path = system_dest / f'lib/{match}'
tx_version.append(LooseVersion(path.name[5:8]))
if tx_version:
tx_version.sort()
tx_version = tx_version[-1]
for location in ('usr/local/lib', 'usr/share', 'usr/share/tcltk'):
tcltk_src = self.prefix / location
path = tcltk_src / f'tk{tx_version}'
if path.exists() and path.is_dir():
break
else:
raise ValueError(f'could not locate Tcl/Tk{tx_version}')
log('INSTALL', f'Tcl/Tk{tx_version}')
tcltk_dir = Path(system_dest / 'share/tcltk')
tcltk_dir.mkdir(exist_ok=True, parents=True)
for tx in ('tcl', 'tk'):
name = f'{tx}{tx_version}'
src = tcltk_src / name
dst = tcltk_dir / name
shutil.copytree(src, dst, symlinks=True, dirs_exist_ok=True)
if appify:
appifier = Appifier(
appdir = str(destination),
appdir_bin = str(system_dest / 'bin'),
python_bin = str(python_dest / 'bin'),
python_pkg = str(python_dest / packages),
version = self.version,
tk_version = tx_version,
cert_src = cert_src
)
appifier.appify()
def ldd(self, target: Path) -> Dict[str, Path]:
'''Cross-platform implementation of ldd, using readelf.'''
pattern = re.compile(r'[(]NEEDED[)]\s+Shared library:\s+\[([^\]]+)\]')
dependencies = dict()
def recurse(target: Path):
result = subprocess.run(f'readelf -d {target}', shell=True,
check=True, capture_output=True)
stdout = result.stdout.decode()
matches = pattern.findall(stdout)
for match in matches:
if (match not in dependencies) and (match not in self.excluded):
path = self.locate_library(match)
dependencies[match] = path
recurse(path)
recurse(target)
return dependencies
def locate_library(self, name: str) -> Path:
'''Locate a library given its qualified name.'''
for dirname in self.library_path:
path = dirname / name
if path.exists():
return path
else:
raise FileNotFoundError(name)
def set_rpath(self, target, rpath):
cmd = f'{self.patchelf} --print-rpath {target}'
result = subprocess.run(cmd, shell=True, check=True,
capture_output=True)
current_rpath = result.stdout.decode().strip()
if current_rpath != rpath:
cmd = f"{self.patchelf} --set-rpath '{rpath}' {target}"
subprocess.run(cmd, shell=True, check=True, capture_output=True)
@dataclass(frozen=True)
class ImageExtractor:
'''Manylinux image extractor from layers.'''
prefix: Path
'''Manylinux image prefix.'''
tag: Optional[str] = 'latest'
'''Manylinux image tag.'''
def default_destination(self):
return self.prefix / f'extracted/{self.tag}'
def extract(self, destination: Optional[Path]=None, *, clean=False):
'''Extract Manylinux image.'''
if destination is None:
destination = self.default_destination()
if clean:
def clean(destination):
shutil.rmtree(destination, ignore_errors=True)
atexit.register(clean, destination)
log('EXTRACT', f'{self.prefix.name}:{self.tag}')
with open(self.prefix / f'tags/{self.tag}.json') as f:
meta = json.load(f)
layers = meta['layers']
extracted = []
extracted_file = destination / '.extracted'
if destination.exists():
clean_destination = True
if extracted_file.exists():
with extracted_file.open() as f:
extracted = f.read().split(os.linesep)[:-1]
for a, b in zip(layers, extracted):
if a != b:
break
else:
clean_destination = False
if clean_destination:
shutil.rmtree(destination, ignore_errors=True)
for i, layer in enumerate(layers):
try:
if layer == extracted[i]:
continue
except IndexError:
pass
debug('EXTRACT', f'{layer}.tar.gz')
filename = self.prefix / f'layers/{layer}.tar.gz'
cmd = ''.join((
f'trap \'chmod u+rw -R {destination}\' EXIT ; ',
f'mkdir -p {destination} && ',
f'tar -xzf {filename} --exclude=dev -C {destination} && ',
f'echo \'{layer}\' >> {extracted_file}'
))
r = subprocess.run(f'/bin/bash -c "{cmd}"', shell=True,
capture_output=True)
if r.returncode != 0:
raise ValueError(r.stderr.decode())

View File

@@ -0,0 +1,48 @@
from dataclasses import dataclass
from pathlib import Path
import os
import stat
import subprocess
from typing import Optional
from .config import Arch, LinuxTag
from ..utils.deps import CACHE_DIR
from ..utils.log import debug, log
from ..utils.url import urlretrieve
@dataclass(frozen=True)
class Patcher:
'''Manylinux tag.'''
tag: LinuxTag
'''Platform architecture.'''
arch: Optional[Arch] = None
def patch(self, destination: Path):
'''Apply any patch'''
cache = Path(CACHE_DIR) / f'share/patches/'
if self.tag == LinuxTag.MANYLINUX_1:
patch = f'tk-manylinux1_{self.arch}'
log('PATCH', patch)
tarfile = f'{patch}.tar.gz'
path = cache / tarfile
if not path.exists():
url = f'https://github.com/niess/python-appimage/releases/download/manylinux1/{tarfile}'
urlretrieve(url, path)
mode = os.stat(path)[stat.ST_MODE]
os.chmod(path, mode | stat.S_IWGRP | stat.S_IWOTH)
debug('EXTRACT', tarfile)
cmd = ''.join((
f'trap \'chmod u+rw -R {destination}\' EXIT ; ',
f'mkdir -p {destination} && ',
f'tar -xzf {path} -C {destination}',
))
r = subprocess.run(f'/bin/bash -c "{cmd}"', shell=True,
capture_output=True)
if r.returncode != 0:
raise ValueError(r.stderr.decode())

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

@@ -9,39 +9,58 @@ from .tmp import TemporaryDirectory
from .url import urlretrieve from .url import urlretrieve
__all__ = ['APPIMAGETOOL', 'EXCLUDELIST', 'PATCHELF', 'PREFIX',
'ensure_appimagetool', 'ensure_excludelist', 'ensure_patchelf']
_ARCH = platform.machine() _ARCH = platform.machine()
CACHE_DIR = os.path.expanduser('~/.cache/python-appimage')
'''Package cache location'''
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.join(CACHE_DIR, 'bin')
'''Location of the appimagetool binary''' '''Location of the appimagetool binary'''
EXCLUDELIST = PREFIX + '/data/excludelist' APPIMAGETOOL_VERSION = 'continuous'
'''Version of the appimagetool binary'''
EXCLUDELIST = os.path.join(CACHE_DIR, 'share/excludelist')
'''AppImage exclusion list''' '''AppImage exclusion list'''
PATCHELF = os.path.expanduser('~/.local/bin/patchelf') PATCHELF = os.path.join(CACHE_DIR, 'bin/patchelf')
'''Location of the PatchELF binary''' '''Location of the PatchELF binary'''
PATCHELF_VERSION = '0.14.3'
'''Version of the patchelf binary'''
def ensure_appimagetool():
def ensure_appimagetool(dry=False):
'''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
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 +69,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
@@ -76,19 +92,18 @@ def ensure_patchelf():
if os.path.exists(PATCHELF): if os.path.exists(PATCHELF):
return False return False
iarch = 'i386' if _ARCH == 'i686' else _ARCH tgz = '-'.join(('patchelf', PATCHELF_VERSION, _ARCH)) + '.tar.gz'
appimage = 'patchelf-{0:}.AppImage'.format(iarch) baseurl = 'https://github.com/NixOS/patchelf'
baseurl = 'https://github.com/niess/patchelf.appimage/releases/download'
log('INSTALL', 'patchelf from %s', baseurl) log('INSTALL', 'patchelf from %s', baseurl)
dirname = os.path.dirname(PATCHELF) dirname = os.path.dirname(PATCHELF)
patchelf = dirname + '/patchelf' patchelf = dirname + '/patchelf'
make_tree(dirname) make_tree(dirname)
with TemporaryDirectory() as tmpdir: with TemporaryDirectory() as tmpdir:
urlretrieve(os.path.join(baseurl, 'rolling', appimage), appimage) urlretrieve(os.path.join(baseurl, 'releases', 'download',
os.chmod(appimage, stat.S_IRWXU) PATCHELF_VERSION, tgz), tgz)
system(('./' + appimage, '--appimage-extract')) system(('tar', 'xzf', tgz))
copy_file('squashfs-root/usr/bin/patchelf', patchelf) copy_file('bin/patchelf', patchelf)
os.chmod(patchelf, stat.S_IRWXU | stat.S_IRWXG | stat.S_IRWXO) os.chmod(patchelf, stat.S_IRWXU | stat.S_IRWXG | stat.S_IRWXO)
return True return True

View File

@@ -1,51 +0,0 @@
import os
import platform
import stat
import subprocess
import sys
from .log import log
from .system import system
def docker_run(image, extra_cmds):
'''Execute commands within a docker container
'''
ARCH = platform.machine()
if image.endswith(ARCH):
bash_arg = '/pwd/run.sh'
elif image.endswith('i686') and ARCH == 'x86_64':
bash_arg = '-c "linux32 /pwd/run.sh"'
elif image.endswith('x86_64') and ARCH == 'i686':
bash_arg = '-c "linux64 /pwd/run.sh"'
else:
raise ValueError('Unsupported Docker image: ' + image)
log('PULL', image)
system(('docker', 'pull', image))
script = [
'set -e',
'trap "chown -R {:}:{:} *" EXIT'.format(os.getuid(),
os.getgid()),
'cd /pwd'
]
script += extra_cmds
with open('run.sh', 'w') as f:
f.write(os.linesep.join(script))
os.chmod('run.sh', stat.S_IRWXU)
cmd = ' '.join(('docker', 'run', '--mount',
'type=bind,source={:},target=/pwd'.format(os.getcwd()),
image, '/bin/bash', bash_arg))
log('RUN', image)
p = subprocess.Popen(cmd, shell=True)
p.communicate()
if p.returncode != 0:
if p.returncode == 139:
sys.stderr.write("segmentation fault when running Docker (139)\n")
sys.exit(p.returncode)

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:
# Tolerate single line warning(s)
for line in stripped:
if (len(line) < 8) or (line[:8].lower() != "warning:"):
raise RuntimeError(err) 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)

View File

@@ -28,10 +28,14 @@ def urlretrieve(url, filename=None):
''' '''
if filename is None: if filename is None:
filename = os.path.basename(url) filename = os.path.basename(url)
debug('DOWNLOAD', '%s from %s', name, os.path.dirname(url)) debug('DOWNLOAD', '%s from %s', filename, os.path.dirname(url))
else: else:
debug('DOWNLOAD', '%s as %s', url, filename) debug('DOWNLOAD', '%s as %s', url, filename)
parent_directory = os.path.dirname(filename)
if parent_directory and not os.path.exists(parent_directory):
os.makedirs(parent_directory)
if _urlretrieve is None: if _urlretrieve is None:
data = urllib2.urlopen(url).read() data = urllib2.urlopen(url).read()
with open(filename, 'w') as f: with open(filename, 'w') as f:

View File

@@ -0,0 +1 @@
version = '1.4.4'

319
scripts/test-appimage.py Executable file
View File

@@ -0,0 +1,319 @@
#! /usr/bin/env python3
import argparse
from enum import auto, Enum
import inspect
import os
from pathlib import Path
import shutil
import subprocess
import sys
import tempfile
from types import FunctionType
from typing import NamedTuple
from python_appimage.manylinux import PythonVersion
ARGS = None
def assert_eq(expected, found):
if expected != found:
raise AssertionError('expected "{}", found "{}"'.format(
expected, found))
class Script(NamedTuple):
'''Python script wrapper'''
content: str
def run(self, appimage: Path):
'''Run the script through an appimage'''
with tempfile.TemporaryDirectory() as tmpdir:
script = f'{tmpdir}/script.py'
with open(script, 'w') as f:
f.write(inspect.getsource(assert_eq))
f.write(os.linesep)
f.write(self.content)
return system(f'{appimage} {script}')
class Status(Enum):
'''Test exit status'''
FAILED = auto()
SKIPPED = auto()
SUCCESS = auto()
def __str__(self):
return self.name
def system(cmd):
'''Run a system command'''
r = subprocess.run(cmd, capture_output=True, shell=True)
if r.returncode != 0:
raise ValueError(r.stderr.decode())
else:
return r.stdout.decode()
class TestContext:
'''Context for testing an image'''
def __init__(self, appimage):
self.appimage = appimage
# Guess python version from appimage name.
version, _, abi, *_ = appimage.name.split('-', 3)
version = version[6:]
if abi.endswith('t'):
version += '-nogil'
self.version = PythonVersion.from_str(version)
# Get some specific AppImage env variables.
self.env = eval(Script('''
import os
appdir = os.environ['APPDIR']
env = {}
for var in ('SSL_CERT_FILE', 'TCL_LIBRARY', 'TK_LIBRARY', 'TKPATH'):
try:
env[var] = os.environ[var].replace(appdir, '$APPDIR')
except KeyError:
pass
print(env)
''').run(appimage))
# Extract the AppImage.
tmpdir = tempfile.TemporaryDirectory()
dst = Path(tmpdir.name) / appimage.name
shutil.copy(appimage, dst)
system(f'cd {tmpdir.name} && ./{appimage.name} --appimage-extract')
self.appdir = Path(tmpdir.name) / 'squashfs-root'
self.tmpdir = tmpdir
def list_content(self, path=None):
'''List the content of an extracted directory'''
path = self.appdir if path is None else self.appdir / path
return sorted(os.listdir(path))
def run(self):
'''Run all tests'''
tests = []
for key, value in self.__class__.__dict__.items():
if isinstance(value, FunctionType):
if key.startswith('test_'):
tests.append(value)
n = len(tests)
m = max(len(test.__doc__) for test in tests)
for i, test in enumerate(tests):
sys.stdout.write(
f'[ {self.appimage.name} | {i + 1:2}/{n} ] {test.__doc__:{m}}'
)
sys.stdout.flush()
try:
status = test(self)
except Exception as e:
status = Status.FAILED
sys.stdout.write(
f' -> {status} ({test.__name__}){os.linesep}')
sys.stdout.flush()
raise e
else:
sys.stdout.write(f' -> {status}{os.linesep}')
sys.stdout.flush()
def test_root_content(self):
'''Check the appimage root content'''
content = self.list_content()
expected = ['.DirIcon', 'AppRun', 'opt', 'python.png',
f'python{self.version.long()}.desktop', 'usr']
assert_eq(expected, content)
return Status.SUCCESS
def test_python_content(self):
'''Check the appimage python content'''
prefix = f'opt/python{self.version.flavoured()}'
content = self.list_content(prefix)
assert_eq(['bin', 'include', 'lib'], content)
content = self.list_content(f'{prefix}/bin')
assert_eq(
[f'pip{self.version.short()}', f'python{self.version.flavoured()}'],
content
)
content = self.list_content(f'{prefix}/include')
if (self.version.major == 3) and (self.version.minor <= 7):
expected = [f'python{self.version.short()}m']
else:
expected = [f'python{self.version.flavoured()}']
assert_eq(expected, content)
content = self.list_content(f'{prefix}/lib')
assert_eq([f'python{self.version.flavoured()}'], content)
return Status.SUCCESS
def test_system_content(self):
'''Check the appimage system content'''
content = self.list_content('usr')
assert_eq(['bin', 'lib', 'share'], content)
content = self.list_content('usr/bin')
expected = [
'pip', f'pip{self.version.major}', f'pip{self.version.short()}',
'python', f'python{self.version.major}',
f'python{self.version.short()}'
]
assert_eq(expected, content)
return Status.SUCCESS
def test_tcltk_bundling(self):
'''Check Tcl/Tk bundling'''
if 'TK_LIBRARY' not in self.env:
return Status.SKIPPED
else:
for var in ('TCL_LIBRARY', 'TK_LIBRARY', 'TKPATH'):
path = Path(self.env[var].replace('$APPDIR', str(self.appdir)))
assert path.exists()
return Status.SUCCESS
def test_ssl_bundling(self):
'''Check SSL certs bundling'''
var = 'SSL_CERT_FILE'
path = Path(self.env[var].replace('$APPDIR', str(self.appdir)))
assert path.exists()
return Status.SUCCESS
def test_bin_symlinks(self):
'''Check /usr/bin symlinks'''
assert_eq(
(self.appdir /
f'opt/python{self.version.flavoured()}/bin/pip{self.version.short()}'),
(self.appdir / f'usr/bin/pip{self.version.short()}').resolve()
)
assert_eq(
f'pip{self.version.short()}',
str((self.appdir / f'usr/bin/pip{self.version.major}').readlink())
)
assert_eq(
f'pip{self.version.major}',
str((self.appdir / 'usr/bin/pip').readlink())
)
assert_eq(
f'python{self.version.short()}',
str((self.appdir / f'usr/bin/python{self.version.major}').readlink())
)
assert_eq(
f'python{self.version.major}',
str((self.appdir / 'usr/bin/python').readlink())
)
return Status.SUCCESS
def test_appimage_hook(self):
'''Test the appimage hook'''
Script(f'''
import os
assert_eq(os.environ['APPIMAGE_COMMAND'], '{self.appimage}')
import sys
assert_eq('{self.appimage}', sys.executable)
assert_eq('{self.appimage}', sys._base_executable)
''').run(self.appimage)
return Status.SUCCESS
def test_python_prefix(self):
'''Test the python prefix'''
Script(f'''
import os
import sys
expected = os.environ["APPDIR"] + '/opt/python{self.version.flavoured()}'
assert_eq(expected, sys.prefix)
''').run(self.appimage)
return Status.SUCCESS
def test_ssl_request(self):
'''Test SSL request (see issue #24)'''
if self.version.major == 2:
return Status.SKIPPED
else:
Script('''
from http import HTTPStatus
import urllib.request
with urllib.request.urlopen('https://wikipedia.org') as r:
assert_eq(r.status, HTTPStatus.OK)
''').run(self.appimage)
return Status.SUCCESS
def test_pip_install(self):
'''Test pip installing to an extracted AppImage'''
r = system(f'{self.appdir}/AppRun -m pip install pip-install-test')
assert('Successfully installed pip-install-test' in r)
path = self.appdir / f'opt/python{self.version.flavoured()}/lib/python{self.version.flavoured()}/site-packages/pip_install_test'
assert(path.exists())
return Status.SUCCESS
def test_tkinter_usage(self):
'''Test basic tkinter usage'''
try:
os.environ['DISPLAY']
self.env['TK_LIBRARY']
except KeyError:
return Status.SKIPPED
else:
tkinter = 'tkinter' if self.version.major > 2 else 'Tkinter'
Script(f'''
import {tkinter} as tkinter
tkinter.Tk()
''').run(self.appimage)
return Status.SUCCESS
def test_venv_usage(self):
'''Test venv creation'''
if self.version.major == 2:
return Status.SKIPPED
else:
system(' && '.join((
f'cd {self.tmpdir.name}',
f'./{self.appimage.name} -m venv ENV',
'. ENV/bin/activate',
)))
python = Path(f'{self.tmpdir.name}/ENV/bin/python')
assert_eq(self.appimage.name, str(python.readlink()))
return Status.SUCCESS
def test():
'''Test Python AppImage(s)'''
for appimage in ARGS.appimage:
context = TestContext(appimage)
context.run()
if __name__ == '__main__':
parser = argparse.ArgumentParser(description = test.__doc__)
parser.add_argument('appimage',
help = 'path to appimage(s)',
nargs = '+',
type = lambda x: Path(x).absolute()
)
ARGS = parser.parse_args()
test()

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

@@ -0,0 +1,339 @@
#! /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', 'aarch64')
MANYLINUSES = ('1', '2010', '2014', '2_24', '2_28')
EXCLUDES = ('2_28_i686', '1_aarch64', '2010_aarch64')
# 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')
if not args.build:
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)
if args.dry:
return
# 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('-b', '--build',
help = 'build AppImages (in dry mode)',
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)

View File

@@ -1,88 +0,0 @@
import json
import os
import setuptools
import ssl
import subprocess
from python_appimage.utils.deps import ensure_excludelist
from python_appimage.utils.url import urlopen
CLASSIFIERS = '''\
Development Status :: 4 - Beta
Intended Audience :: Developers
License :: OSI Approved :: GNU General Public License v3 (GPLv3)
Programming Language :: Python
Topic :: Software Development
Operating System :: POSIX :: Linux
'''
with open('README.md') as f:
long_description = f.read()
def get_version():
'''Get the next version number from PyPI
'''
with open('VERSION') as f:
version = f.read().strip()
p = subprocess.Popen(
'git describe --match=NeVeRmAtCh --always --dirty 2> /dev/null || '
'echo unknown',
shell=True, stdout=subprocess.PIPE,
stderr=subprocess.STDOUT)
stdout, _ = p.communicate()
try:
stdout = stdout.decode()
except AttributeError:
stdout = str(stdout)
git_revision = stdout.strip()
with open('python_appimage/version.py', 'w+') as f:
f.write('''\
# This file was generated by setup.py
version = '{version:}'
git_revision = '{git_revision:}'
'''.format(version=version, git_revision=git_revision))
return version
def get_package_data():
'''Get the list of package data
'''
ensure_excludelist()
prefix = os.path.dirname(__file__) or '.'
return ['data/' + file_
for file_ in os.listdir(prefix + '/python_appimage/data')]
setuptools.setup(
name = 'python_appimage',
version = get_version(),
author = 'Valentin Niess',
author_email = 'valentin.niess@gmail.com',
description = 'Appimage releases of Python',
long_description = long_description,
long_description_content_type = 'text/markdown',
url = 'https://github.com/niess/python-appimage',
download_url = 'https://pypi.python.org/pypi/python-appimage',
project_urls = {
'Bug Tracker' : 'https://github.com/niess/python-appimage/issues',
'Source Code' : 'https://github.com/niess/python-appimage',
},
packages = setuptools.find_packages(),
classifiers = [s for s in CLASSIFIERS.split(os.linesep) if s.strip()],
license = 'GPLv3',
platforms = ['Linux'],
python_requires = '>=2.7',
include_package_data = True,
package_data = {'': get_package_data()},
entry_points = {
'console_scripts' : (
'python-appimage = python_appimage.__main__:main',)
}
)