Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| aa8c9c6f12 |
@@ -79,9 +79,17 @@ jobs:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
TAG="${{ github.event.inputs.tag || github.ref_name }}"
|
||||
ZIP="dist/simple-${TAG#v}.zip"
|
||||
VER="${TAG#v}"
|
||||
ZIP="dist/simple-$VER.zip"
|
||||
# The release body is the CHANGELOG section of the version; a missing
|
||||
# section falls back to generated notes.
|
||||
if python tools/changelog_section.py "$VER" > /tmp/notes.md 2>/dev/null; then
|
||||
NOTES="--notes-file /tmp/notes.md"
|
||||
else
|
||||
NOTES="--generate-notes"
|
||||
fi
|
||||
if gh release view "$TAG" >/dev/null 2>&1; then
|
||||
gh release upload "$TAG" "$ZIP" --clobber
|
||||
else
|
||||
gh release create "$TAG" "$ZIP" --title "$TAG" --generate-notes
|
||||
gh release create "$TAG" "$ZIP" --title "$TAG" $NOTES
|
||||
fi
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
# Changelog
|
||||
|
||||
All notable changes to SIMple (the PWA and `pysim-simple-server`).
|
||||
The format follows [Keep a Changelog](https://keepachangelog.com/);
|
||||
versions use `MAJOR.MINOR.PATCH` (see the README).
|
||||
|
||||
History before the first packaged release (v3.31.1) lives in the git log —
|
||||
every change carries a descriptive commit message.
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [3.32.0] — 2026-10-21
|
||||
|
||||
### Added
|
||||
|
||||
- **Updating a packaged install** — `update.sh` / `update.bat` (and `tools/update.py`
|
||||
beneath them): downloads the latest release package, replaces the application files
|
||||
in place and re-installs the app into the **existing `.venv`**. pysim and its
|
||||
prerequisites (the slow part: pyscard builds from source on Linux) are kept, so an
|
||||
update takes seconds. `--check` reports the current and latest versions, `--version`
|
||||
picks a specific release, `--zip` updates from a local package (offline), `--no-pip`
|
||||
refreshes the files only, `--force` re-applies the same version.
|
||||
- The release workflow now takes the release notes from this file's matching version
|
||||
section (falling back to generated notes when it is missing).
|
||||
|
||||
### Changed
|
||||
|
||||
- Installs made from the v3.31.x packages have no updater yet: copy `update.sh`,
|
||||
`update.bat` and `tools/update.py` from this package once (or re-run `setup` from it).
|
||||
Later updates use `update.sh` / `update.bat` — or `git pull` for a clone.
|
||||
|
||||
## [3.31.2] — 2026-10-20
|
||||
|
||||
### Changed
|
||||
|
||||
- The **SCP81 TLS-PSK listener states its Python requirement**: it needs Python 3.13+
|
||||
(`ssl`'s PSK callbacks exist only since 3.13). Below that it raises a clear error
|
||||
instead of `AttributeError`, and the PSK tests are skipped (they run on 3.13+).
|
||||
- The READMEs and the setup scripts: **Windows strongly recommends Python 3.13**
|
||||
(pyscard has wheels up to 3.13; on 3.14 pyscard and some pysim dependency wheels are
|
||||
unavailable and build from source with MSVC).
|
||||
|
||||
## [3.31.1] — 2026-10-20
|
||||
|
||||
The first packaged release (`simple-3.31.1.zip`).
|
||||
|
||||
### Added
|
||||
|
||||
- **EF_PBR (phonebook reference) support** — the PBR is decoded in the decoded file
|
||||
views (the `A8`/`A9`/`AA` containers, the `C0`–`CC` kinds, the master EF), and the
|
||||
phonebook files it references (they are not in pySim's model) are added to the file
|
||||
tree server-side, selectable and readable.
|
||||
- **In-app dialogs** — the native alert/confirm/prompt dialogs are replaced by a shared
|
||||
modal (queued, keyboard-aware, focus-trapped, ARIA-labelled).
|
||||
- **Windows console encoding guard** — the `start.bat` PowerShell pipe can no longer
|
||||
kill the server on a character the ANSI code page lacks.
|
||||
- **GPL-2.0-or-later licensing** (`LICENSE`, `THIRD-PARTY-NOTICES`), matching pysim.
|
||||
- The curated **release package** (`tools/build_release.py`) and the tag-triggered
|
||||
`.github/workflows/release.yml`.
|
||||
|
||||
### Changed
|
||||
|
||||
- `setup.sh` / `setup.bat` and the READMEs: on Linux `pyscard` builds from source and
|
||||
needs the PC/SC headers (`libpcsclite-dev`).
|
||||
@@ -30,11 +30,17 @@ The package is built from the tagged source — no git history, no development f
|
||||
Git is still required: `setup` installs **pysim** from its upstream repository
|
||||
(nothing from pysim or its dependencies is bundled).
|
||||
|
||||
**Updates are fast:** `./update.sh` (or `update.bat`) downloads the latest release
|
||||
package, replaces the application files in place and re-installs the app into the
|
||||
existing `.venv` — pysim and its prerequisites are kept, so nothing is rebuilt.
|
||||
`./update.sh --check` reports the current and latest versions, `--version X.Y.Z`
|
||||
picks a specific release and `--zip file.zip` updates from a local package.
|
||||
|
||||
**2. Clone the repository (for development, or changes not released yet).**
|
||||
|
||||
```sh
|
||||
git clone https://github.com/anttro/simple.git
|
||||
cd simple
|
||||
git clone https://github.com/anttro/simple.git # or --depth 1: no history,
|
||||
cd simple # `git pull` still updates it
|
||||
./setup.sh # or setup.bat on Windows
|
||||
./start.sh # or start.bat
|
||||
```
|
||||
@@ -748,6 +754,7 @@ The bundled Python server wraps [pySim](https://osmocom.org/projects/pysim/wiki)
|
||||
|--------|-------------|
|
||||
| `setup.sh` / `setup.bat` | Creates `.venv/`, installs pysim and the server. Run once after cloning or after unzipping the release package. |
|
||||
| `start.sh` / `start.bat` | Starts the server from the venv (serves the PWA + API on `:8080`). |
|
||||
| `update.sh` / `update.bat` | Updates a packaged install in place: fetches the latest release package, replaces the app files and re-installs the app into the existing `.venv` (pysim is kept). `--check`, `--version X.Y.Z`, `--zip FILE`, `--no-pip`, `--force`. |
|
||||
|
||||
`start.sh` asks for the first PC/SC reader when `pcscd` is running; otherwise the server still auto-detects a PC/SC reader itself, and with none present pysim falls back to its serial default `/dev/ttyUSB0` (pass `-d <device>` for a serial/FTDI reader). `start.bat` always passes `-p 0` (PC/SC is built into Windows). Extra arguments are passed through to the server, e.g. `./start.sh --gsmtap` or `start.bat --gsmtap 10.0.0.5:4729`. If no reader is found the server still starts ("Reader: none") — initialize the card later via the **Equip** button.
|
||||
|
||||
|
||||
+9
-2
@@ -30,11 +30,17 @@ SIMple — инструментарий для специалистов по UIC
|
||||
разработки. Git всё равно нужен: `setup` ставит **pysim** из его upstream-репозитория
|
||||
(ни pysim, ни его зависимости в пакет не входят).
|
||||
|
||||
**Обновление быстрое:** `./update.sh` (или `update.bat`) скачивает последний релизный
|
||||
пакет, заменяет файлы приложения на месте и переустанавливает приложение в существующий
|
||||
`.venv` — pysim и его зависимости остаются, ничего не пересобирается.
|
||||
`./update.sh --check` показывает текущую и последнюю версии, `--version X.Y.Z` —
|
||||
конкретный релиз, `--zip file.zip` — обновление из локального пакета.
|
||||
|
||||
**2. Клонировать репозиторий (для разработки или ещё не выпущенных изменений).**
|
||||
|
||||
```sh
|
||||
git clone https://github.com/anttro/simple.git
|
||||
cd simple
|
||||
git clone https://github.com/anttro/simple.git # или --depth 1: без истории,
|
||||
cd simple # `git pull` всё равно обновляет
|
||||
./setup.sh # или setup.bat в Windows
|
||||
./start.sh # или start.bat
|
||||
```
|
||||
@@ -732,6 +738,7 @@ PWA проверяет версию сервера при подключении
|
||||
|--------|-------------|
|
||||
| `setup.sh` / `setup.bat` | Создаёт `.venv/`, ставит pysim и сервер. Запускать один раз после клонирования или распаковки релизного пакета. |
|
||||
| `start.sh` / `start.bat` | Запускает сервер из venv (раздаёт PWA + API на `:8080`). |
|
||||
| `update.sh` / `update.bat` | Обновляет пакетную установку на месте: скачивает последний релизный пакет, заменяет файлы приложения и переустанавливает приложение в существующий `.venv` (pysim сохраняется). `--check`, `--version X.Y.Z`, `--zip FILE`, `--no-pip`, `--force`. |
|
||||
|
||||
`start.sh` при работающем `pcscd` запрашивает первый PC/SC-ридер; иначе сервер сам автоопределяет PC/SC-ридер, а если его нет — pysim откатывается на последовательное устройство по умолчанию `/dev/ttyUSB0` (для serial/FTDI-ридера укажите `-d <устройство>`). `start.bat` всегда передаёт `-p 0`. Дополнительные аргументы передаются серверу, например `./start.sh --gsmtap` или `start.bat --gsmtap 10.0.0.5:4729`. Без ридера сервер всё равно стартует («Reader: none») — карту можно инициализировать позже кнопкой **Equip**.
|
||||
|
||||
|
||||
+1
-1
@@ -1943,7 +1943,7 @@
|
||||
// ===== Version =====
|
||||
// Single source of truth for the PWA version: shown in the header and used
|
||||
// by the server version check in pysimConnect().
|
||||
const SIMPLE_VERSION = '3.31.2';
|
||||
const SIMPLE_VERSION = '3.32.0';
|
||||
document.getElementById('app-version').textContent = 'v' + SIMPLE_VERSION;
|
||||
|
||||
// ===== view history (the browser Back/Forward buttons) =====
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
const CACHE = 'simple-v457';
|
||||
const CACHE = 'simple-v458';
|
||||
const URLS = [
|
||||
'index.html',
|
||||
'help.html',
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "pysim-simple-server"
|
||||
version = "3.31.2"
|
||||
version = "3.32.0"
|
||||
description = "HTTP REST server wrapping pysim for the SIMple PWA"
|
||||
license = { text = "GPL-2.0-or-later" }
|
||||
requires-python = ">=3.9"
|
||||
|
||||
@@ -37,7 +37,7 @@ from osmocom.tlv import BER_TLV_IE
|
||||
from osmocom.utils import rpad
|
||||
|
||||
|
||||
VERSION = '3.31.2'
|
||||
VERSION = '3.32.0'
|
||||
|
||||
MAX_ENVELOPE_SEGMENTS = 5 # max SMS segments for outgoing C-APDU in ENVELOPE
|
||||
|
||||
|
||||
@@ -86,4 +86,5 @@ echo.
|
||||
|
||||
echo === Setup complete ===
|
||||
echo Run start.bat to start the server.
|
||||
echo Updating later: update.bat ^(reuses .venv - pysim is not re-installed^).
|
||||
pause
|
||||
@@ -62,4 +62,5 @@ fi
|
||||
|
||||
echo ""
|
||||
echo "=== Setup complete ==="
|
||||
echo "Run ./start.sh to start the server."
|
||||
echo "Run ./start.sh to start the server."
|
||||
echo "Updating later: ./update.sh (reuses .venv - pysim is not re-installed)."
|
||||
@@ -69,6 +69,8 @@ class ReleasePackageTests(unittest.TestCase):
|
||||
'pysim_simple_server/data/mcc-mnc-list.json',
|
||||
'pysim_simple_server/data/mcc-mnc-list.LICENSE',
|
||||
'setup.sh', 'setup.bat', 'start.sh', 'start.bat',
|
||||
'update.sh', 'update.bat', 'tools/update.py',
|
||||
'tools/changelog_section.py', 'CHANGELOG.md',
|
||||
'pyproject.toml', 'requirements-pysim.txt',
|
||||
'README.md', 'README_RUS.md', 'LICENSE', 'THIRD-PARTY-NOTICES',
|
||||
'docs/api.md'):
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Updating a packaged install in place (tools/update.py).
|
||||
|
||||
The update replaces the application files, keeps the .venv (pysim and its
|
||||
prerequisites - the slow part of a reinstall) and the run log, preserves the
|
||||
scripts' exec bits, and refuses a package whose version does not match or that
|
||||
carries unsafe paths. tests/test_release_package.py pins that the update
|
||||
scripts are in the package.
|
||||
"""
|
||||
|
||||
import importlib.util
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
|
||||
REPO = Path(__file__).resolve().parents[1]
|
||||
UPDATE = REPO / 'tools' / 'update.py'
|
||||
CHANGELOG_SECTION = REPO / 'tools' / 'changelog_section.py'
|
||||
|
||||
|
||||
def _load(path, name):
|
||||
spec = importlib.util.spec_from_file_location(name, path)
|
||||
mod = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(mod)
|
||||
return mod
|
||||
|
||||
|
||||
def _package(path, version, entries=None):
|
||||
"""A minimal release package: simple-<version>/... with the real updater."""
|
||||
root = 'simple-%s/' % version
|
||||
with zipfile.ZipFile(path, 'w') as z:
|
||||
def add(name, data, mode=0o644):
|
||||
info = zipfile.ZipInfo(root + name)
|
||||
info.external_attr = mode << 16
|
||||
z.writestr(info, data)
|
||||
add('pysim_simple_server/server.py', "VERSION = '%s'\n" % version)
|
||||
add('README.md', '# readme %s\n' % version)
|
||||
add('update.sh', '#!/bin/sh\n', 0o755)
|
||||
add('tools/update.py', UPDATE.read_text(encoding='utf-8'))
|
||||
for name, data in (entries or {}).items():
|
||||
add(name, data)
|
||||
return path
|
||||
|
||||
|
||||
def _app(tmp):
|
||||
"""A simulated packaged install: app files, a .venv and the run log."""
|
||||
app = Path(tmp) / 'app'
|
||||
(app / 'tools').mkdir(parents=True)
|
||||
(app / 'tools' / 'update.py').write_text(UPDATE.read_text(encoding='utf-8'))
|
||||
(app / 'pysim_simple_server').mkdir()
|
||||
(app / 'pysim_simple_server' / 'server.py').write_text("VERSION = '1.0.0'\n")
|
||||
(app / 'README.md').write_text('# old\n')
|
||||
(app / '.venv').mkdir()
|
||||
(app / '.venv' / 'MARKER').write_text('venv')
|
||||
(app / 'simple_lastrun.log').write_text('log')
|
||||
return app
|
||||
|
||||
|
||||
class InPlaceUpdateTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.tmp = tempfile.TemporaryDirectory()
|
||||
self.dir = Path(self.tmp.name)
|
||||
self.app = _app(self.tmp.name)
|
||||
self.zip = _package(self.dir / 'simple-2.0.0.zip', '2.0.0',
|
||||
{'frontend/new-asset.txt': 'new'})
|
||||
|
||||
def tearDown(self):
|
||||
self.tmp.cleanup()
|
||||
|
||||
def run_update(self, *args):
|
||||
return subprocess.run(
|
||||
[sys.executable, str(self.app / 'tools' / 'update.py')] + list(args),
|
||||
capture_output=True, text=True)
|
||||
|
||||
def test_the_files_are_replaced_and_the_venv_and_log_kept(self):
|
||||
r = self.run_update('--zip', str(self.zip), '--no-pip')
|
||||
self.assertEqual(r.returncode, 0, r.stderr)
|
||||
self.assertIn("VERSION = '2.0.0'",
|
||||
(self.app / 'pysim_simple_server' / 'server.py').read_text())
|
||||
self.assertEqual((self.app / 'README.md').read_text(), '# readme 2.0.0\n')
|
||||
self.assertEqual((self.app / 'frontend' / 'new-asset.txt').read_text(), 'new')
|
||||
# pysim lives in .venv - it must survive untouched, as does the run log
|
||||
self.assertEqual((self.app / '.venv' / 'MARKER').read_text(), 'venv')
|
||||
self.assertEqual((self.app / 'simple_lastrun.log').read_text(), 'log')
|
||||
self.assertIn('restart the server', r.stdout)
|
||||
|
||||
@unittest.skipIf(os.name == 'nt', 'POSIX permission bits')
|
||||
def test_the_scripts_keep_their_exec_bits(self):
|
||||
self.run_update('--zip', str(self.zip), '--no-pip')
|
||||
# zipfile drops the mode on extraction; the updater restores it
|
||||
self.assertTrue(os.access(self.app / 'update.sh', os.X_OK))
|
||||
|
||||
def test_the_same_version_is_not_reapplied_unless_forced(self):
|
||||
self.run_update('--zip', str(self.zip), '--no-pip')
|
||||
r = self.run_update('--zip', str(self.zip), '--no-pip')
|
||||
self.assertEqual(r.returncode, 0)
|
||||
self.assertIn('already up to date', r.stdout)
|
||||
(self.app / 'README.md').write_text('# edited\n')
|
||||
r = self.run_update('--zip', str(self.zip), '--no-pip', '--force')
|
||||
self.assertEqual(r.returncode, 0)
|
||||
self.assertEqual((self.app / 'README.md').read_text(), '# readme 2.0.0\n')
|
||||
|
||||
def test_check_reports_both_versions_without_touching_anything(self):
|
||||
r = self.run_update('--check', '--version', '2.0.0')
|
||||
self.assertEqual(r.returncode, 0)
|
||||
self.assertIn('current: 1.0.0', r.stdout)
|
||||
self.assertIn('latest: 2.0.0', r.stdout)
|
||||
self.assertEqual((self.app / 'README.md').read_text(), '# old\n')
|
||||
|
||||
def test_a_version_mismatch_is_refused(self):
|
||||
bad = self.dir / 'simple-9.9.9.zip'
|
||||
_package(bad, '8.8.8') # the file name says 9.9.9
|
||||
r = self.run_update('--zip', str(bad), '--no-pip', '--version', '9.9.9')
|
||||
self.assertNotEqual(r.returncode, 0)
|
||||
self.assertIn('does not match', r.stderr)
|
||||
self.assertEqual((self.app / 'pysim_simple_server' / 'server.py').read_text(),
|
||||
"VERSION = '1.0.0'\n")
|
||||
|
||||
def test_an_unsafe_path_is_refused(self):
|
||||
bad = self.dir / 'simple-2.0.0-evil.zip'
|
||||
with zipfile.ZipFile(bad, 'w') as z:
|
||||
z.writestr('simple-2.0.0/../../evil.txt', 'x')
|
||||
z.writestr('simple-2.0.0/pysim_simple_server/server.py', "VERSION = '2.0.0'\n")
|
||||
r = self.run_update('--zip', str(bad), '--no-pip')
|
||||
self.assertNotEqual(r.returncode, 0)
|
||||
self.assertIn('unsafe path', r.stderr)
|
||||
self.assertFalse((self.dir / 'evil.txt').exists())
|
||||
|
||||
|
||||
class ChangelogSectionTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.mod = _load(CHANGELOG_SECTION, 'changelog_section')
|
||||
|
||||
def test_the_current_version_has_a_section(self):
|
||||
# the server version is the single source of truth
|
||||
text = (REPO / 'pysim_simple_server' / 'server.py').read_text(encoding='utf-8')
|
||||
ver = re.search(r"^VERSION = '([^']+)'$", text, re.M).group(1)
|
||||
body = self.mod.section(ver)
|
||||
self.assertIsNotNone(body, 'CHANGELOG.md has no [%s] section' % ver)
|
||||
self.assertIn('### ', body)
|
||||
|
||||
def test_an_unknown_version_has_no_section(self):
|
||||
self.assertIsNone(self.mod.section('0.0.0'))
|
||||
|
||||
def test_the_helper_exits_non_zero_for_an_unknown_version(self):
|
||||
r = subprocess.run([sys.executable, str(CHANGELOG_SECTION), '0.0.0'],
|
||||
capture_output=True, text=True)
|
||||
self.assertNotEqual(r.returncode, 0)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -37,8 +37,9 @@ PACKAGE_ENTRIES = (
|
||||
'docs',
|
||||
'tools',
|
||||
'setup.sh', 'setup.bat', 'start.sh', 'start.bat',
|
||||
'update.sh', 'update.bat',
|
||||
'pyproject.toml', 'requirements-pysim.txt',
|
||||
'README.md', 'README_RUS.md',
|
||||
'README.md', 'README_RUS.md', 'CHANGELOG.md',
|
||||
'LICENSE', 'THIRD-PARTY-NOTICES',
|
||||
)
|
||||
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Print the CHANGELOG.md section of a version (the release notes body).
|
||||
|
||||
Usage: python tools/changelog_section.py X.Y.Z
|
||||
Exits non-zero when the version has no section - the workflow then falls back
|
||||
to generated notes.
|
||||
"""
|
||||
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
CHANGELOG = Path(__file__).resolve().parent.parent / 'CHANGELOG.md'
|
||||
|
||||
|
||||
def section(version):
|
||||
text = CHANGELOG.read_text(encoding='utf-8')
|
||||
pat = re.compile(r'^## \[%s\][^\n]*\n(.*?)(?=^## |\Z)'
|
||||
% re.escape(version), re.M | re.S)
|
||||
m = pat.search(text)
|
||||
if not m:
|
||||
return None
|
||||
body = m.group(1).strip()
|
||||
return body + '\n' if body else None
|
||||
|
||||
|
||||
def main(argv):
|
||||
if len(argv) != 2:
|
||||
sys.exit('usage: changelog_section.py X.Y.Z')
|
||||
body = section(argv[1])
|
||||
if body is None:
|
||||
sys.exit('error: no CHANGELOG.md section for %s' % argv[1])
|
||||
sys.stdout.write(body)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
sys.exit(main(sys.argv))
|
||||
+200
@@ -0,0 +1,200 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Update a packaged SIMple install in place.
|
||||
|
||||
The release package is a snapshot of the application, and the slow part of a
|
||||
reinstall is pysim and its prerequisites (built into ``.venv`` by setup -
|
||||
pyscard compiles from source on Linux). This script downloads the latest
|
||||
release package, replaces the application files in place and re-installs the
|
||||
app into the **existing** ``.venv``: pysim is kept, so an update takes seconds.
|
||||
|
||||
Usage:
|
||||
python tools/update.py [--check] [--version X.Y.Z] [--zip FILE] [--no-pip] [--force]
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import urllib.request
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
|
||||
APP = Path(__file__).resolve().parent.parent
|
||||
REPO_API = 'https://api.github.com/repos/anttro/simple/releases/latest'
|
||||
ASSET_URL = 'https://github.com/anttro/simple/releases/download/v%s/simple-%s.zip'
|
||||
UA = {'User-Agent': 'simple-update'}
|
||||
# Never overwritten even if a package carried them (it never does).
|
||||
SKIP_TOP = {'.venv', 'dist', '__pycache__', '.git'}
|
||||
|
||||
|
||||
def current_version():
|
||||
"""The installed app's VERSION - the same source the build reads."""
|
||||
text = (APP / 'pysim_simple_server' / 'server.py').read_text(encoding='utf-8')
|
||||
m = re.search(r"^VERSION = '([^']+)'$", text, re.M)
|
||||
if not m:
|
||||
sys.exit('error: cannot read VERSION from pysim_simple_server/server.py')
|
||||
return m.group(1)
|
||||
|
||||
|
||||
def latest_version():
|
||||
req = urllib.request.Request(REPO_API, headers=UA)
|
||||
with urllib.request.urlopen(req, timeout=20) as r:
|
||||
tag = (json.load(r).get('tag_name') or '').strip()
|
||||
if not tag.startswith('v') or len(tag) < 2:
|
||||
sys.exit('error: unexpected release tag %r' % tag)
|
||||
return tag[1:]
|
||||
|
||||
|
||||
def download(version, dest):
|
||||
url = ASSET_URL % (version, version)
|
||||
req = urllib.request.Request(url, headers=UA)
|
||||
with urllib.request.urlopen(req, timeout=120) as r, open(dest, 'wb') as f:
|
||||
shutil.copyfileobj(r, f)
|
||||
|
||||
|
||||
def safe_extract(zf, dest):
|
||||
"""Extract, refusing paths outside dest and restoring the permission bits
|
||||
(zipfile drops them, which would strip +x from setup.sh/start.sh/update.sh)."""
|
||||
dest = Path(dest).resolve()
|
||||
for info in zf.infolist():
|
||||
target = (dest / info.filename).resolve()
|
||||
if target != dest and dest not in target.parents:
|
||||
sys.exit('error: unsafe path in the package: %s' % info.filename)
|
||||
zf.extractall(dest)
|
||||
for info in zf.infolist():
|
||||
mode = (info.external_attr >> 16) & 0o777
|
||||
if mode and not info.is_dir():
|
||||
try:
|
||||
os.chmod(dest / info.filename, mode)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def verify(root, version):
|
||||
"""The package must be the version it claims (the tag is the version)."""
|
||||
src = root / 'pysim_simple_server' / 'server.py'
|
||||
if not src.is_file():
|
||||
sys.exit('error: the package lacks pysim_simple_server/server.py')
|
||||
m = re.search(r"^VERSION = '([^']+)'$", src.read_text(encoding='utf-8'), re.M)
|
||||
if not m or m.group(1) != version:
|
||||
sys.exit('error: the package version (%s) does not match %s'
|
||||
% (m.group(1) if m else '?', version))
|
||||
|
||||
|
||||
def replace_tree(new_root):
|
||||
"""Copy the new package over the app directory; the venv and the run log
|
||||
(neither is in the package) stay untouched."""
|
||||
count = 0
|
||||
for src in sorted(new_root.rglob('*')):
|
||||
if not src.is_file():
|
||||
continue
|
||||
rel = src.relative_to(new_root)
|
||||
if rel.parts[0] in SKIP_TOP or '__pycache__' in rel.parts or src.suffix == '.pyc':
|
||||
continue
|
||||
dst = APP / rel
|
||||
dst.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copy2(src, dst)
|
||||
count += 1
|
||||
return count
|
||||
|
||||
|
||||
def venv_python():
|
||||
for rel in ('bin/python', 'Scripts/python.exe'):
|
||||
p = APP / '.venv' / rel
|
||||
if p.exists():
|
||||
return p
|
||||
return None
|
||||
|
||||
|
||||
def reinstall():
|
||||
py = venv_python()
|
||||
if py is None:
|
||||
print('note: no .venv in %s - run setup.sh / setup.bat (or install manually)' % APP)
|
||||
return False
|
||||
# --no-build-isolation: setuptools is in the venv (setup installed it);
|
||||
# this keeps the update working offline.
|
||||
r = subprocess.run([str(py), '-m', 'pip', 'install', '--no-build-isolation',
|
||||
'-e', str(APP)], capture_output=True, text=True)
|
||||
if r.returncode != 0:
|
||||
print('warning: the files were updated, but "pip install -e" failed:')
|
||||
print((r.stderr or r.stdout).strip()[-800:])
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser(description='Update a packaged SIMple install in place.')
|
||||
ap.add_argument('--check', action='store_true',
|
||||
help='print the current and latest versions and exit')
|
||||
ap.add_argument('--version', metavar='X.Y.Z',
|
||||
help='update to this release (default: the latest)')
|
||||
ap.add_argument('--zip', metavar='FILE',
|
||||
help='update from a local package file (offline)')
|
||||
ap.add_argument('--no-pip', action='store_true',
|
||||
help='replace the files only (do not re-install into .venv)')
|
||||
ap.add_argument('--force', action='store_true', help='re-apply the same version')
|
||||
args = ap.parse_args()
|
||||
|
||||
cur = current_version()
|
||||
version = args.version
|
||||
if not version and not args.zip:
|
||||
try:
|
||||
version = latest_version()
|
||||
except Exception as e:
|
||||
sys.exit('error: cannot reach GitHub (%s); pass --version X.Y.Z' % e)
|
||||
if args.check:
|
||||
print('current: %s' % cur)
|
||||
print('latest: %s' % (version or '?'))
|
||||
return 0
|
||||
if not version:
|
||||
m = re.search(r'simple-([0-9][^\\/]*)\.zip$', str(args.zip))
|
||||
if not m:
|
||||
sys.exit('error: cannot tell the package version; pass --version')
|
||||
version = m.group(1)
|
||||
if version == cur and not args.force:
|
||||
print('already up to date (%s)' % cur)
|
||||
return 0
|
||||
|
||||
with tempfile.TemporaryDirectory(prefix='simple-update-') as tmp:
|
||||
tmp = Path(tmp)
|
||||
if args.zip:
|
||||
zpath = Path(args.zip)
|
||||
if not zpath.is_file():
|
||||
sys.exit('error: %s not found' % zpath)
|
||||
else:
|
||||
zpath = tmp / 'package.zip'
|
||||
print('downloading %s ...' % (ASSET_URL % (version, version)))
|
||||
try:
|
||||
download(version, zpath)
|
||||
except Exception as e:
|
||||
sys.exit('error: download failed (%s)' % e)
|
||||
try:
|
||||
with zipfile.ZipFile(zpath) as zf:
|
||||
safe_extract(zf, tmp)
|
||||
except zipfile.BadZipFile as e:
|
||||
sys.exit('error: %s is not a valid package: %s' % (zpath, e))
|
||||
root = tmp / ('simple-%s' % version)
|
||||
if not root.is_dir():
|
||||
found = [p for p in tmp.iterdir()
|
||||
if p.is_dir() and (p / 'pysim_simple_server').is_dir()]
|
||||
if len(found) != 1:
|
||||
sys.exit('error: the package layout is unexpected')
|
||||
root = found[0]
|
||||
verify(root, version)
|
||||
count = replace_tree(root)
|
||||
|
||||
print('updated %s -> %s (%d files)' % (cur, version, count))
|
||||
if args.no_pip:
|
||||
print('note: --no-pip - .venv was left untouched')
|
||||
elif reinstall():
|
||||
print('app re-installed into .venv (pysim kept)')
|
||||
print('restart the server: ./start.sh (or start.bat)')
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
sys.exit(main())
|
||||
Executable
+9
@@ -0,0 +1,9 @@
|
||||
@echo off
|
||||
REM Update this packaged SIMple install in place (see tools\update.py --help).
|
||||
REM The .venv is reused - pysim and its prerequisites are not re-installed.
|
||||
if exist "%~dp0.venv\Scripts\python.exe" (
|
||||
"%~dp0.venv\Scripts\python.exe" "%~dp0tools\update.py" %*
|
||||
) else (
|
||||
python "%~dp0tools\update.py" %*
|
||||
)
|
||||
pause
|
||||
@@ -0,0 +1,13 @@
|
||||
#!/usr/bin/env bash
|
||||
# Update this packaged SIMple install in place (see tools/update.py --help).
|
||||
# The .venv is reused - pysim and its prerequisites are not re-installed.
|
||||
set -e
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
if [ -x "$SCRIPT_DIR/.venv/bin/python" ]; then
|
||||
PY="$SCRIPT_DIR/.venv/bin/python"
|
||||
elif command -v python3 >/dev/null 2>&1; then
|
||||
PY=python3
|
||||
else
|
||||
PY=python
|
||||
fi
|
||||
exec "$PY" "$SCRIPT_DIR/tools/update.py" "$@"
|
||||
Reference in New Issue
Block a user