From aa8c9c6f12522df51302d30ec52919d329b6413d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=90=D0=BD=D1=82=D0=BE=D0=BD=20=D0=A2=D1=80=D0=BE=D1=88?= =?UTF-8?q?=D0=B8=D0=BD?= Date: Wed, 21 Oct 2026 00:35:47 +0300 Subject: [PATCH] v3.32.0: the packaged-install updater + a repo CHANGELOG Reinstalling the package was slow because setup rebuilds pysim's prerequisites (pyscard compiles from source on Linux), while a clone updates with a plain `git pull`. - tools/update.py (+ update.sh / update.bat in the package, and the release workflow shipping them) updates a packaged install in place: it downloads the latest release package, extracts it (zip-slip guard, permission bits restored - zipfile drops them), replaces the app files over the app directory and re-installs the app into the existing .venv. The .venv and simple_lastrun.log are not in the package, so pysim and its prerequisites survive: seconds, not a rebuild. --check / --version / --zip (offline) / --no-pip / --force; the same version is a no-op. - CHANGELOG.md (Keep a Changelog, from the first packaged release; earlier history is the git log). The release workflow now uses the version's CHANGELOG section as the release body (tools/changelog_section.py, falling back to generated notes). - The READMEs document the update path and the `git clone --depth 1` option for the clone path; setup prints the update hint. - Version strings + the PWA cache bumped together (simple-v458). Verified: 1007 Python + 953 frontend; the release-package test covers the new files; the updater was exercised end to end (a v3.31.2 install with a real venv updated to the 3.32.0 package: files replaced, the venv marker kept, pip metadata 3.32.0, exec bits kept, a second run a no-op, --check correct). --- .github/workflows/release.yml | 12 +- CHANGELOG.md | 64 +++++++++++ README.md | 11 +- README_RUS.md | 11 +- frontend/index.html | 2 +- frontend/sw.js | 2 +- pyproject.toml | 2 +- pysim_simple_server/server.py | 2 +- setup.bat | 1 + setup.sh | 3 +- tests/test_release_package.py | 2 + tests/test_update.py | 157 ++++++++++++++++++++++++++ tools/build_release.py | 3 +- tools/changelog_section.py | 38 +++++++ tools/update.py | 200 ++++++++++++++++++++++++++++++++++ update.bat | 9 ++ update.sh | 13 +++ 17 files changed, 520 insertions(+), 12 deletions(-) create mode 100644 CHANGELOG.md create mode 100644 tests/test_update.py create mode 100644 tools/changelog_section.py create mode 100644 tools/update.py create mode 100755 update.bat create mode 100755 update.sh diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index cbf438e..12118b1 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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 diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..45d8af8 --- /dev/null +++ b/CHANGELOG.md @@ -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`). diff --git a/README.md b/README.md index 7581699..a5eef27 100644 --- a/README.md +++ b/README.md @@ -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 ` 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. diff --git a/README_RUS.md b/README_RUS.md index 29e2211..0defa5b 100644 --- a/README_RUS.md +++ b/README_RUS.md @@ -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**. diff --git a/frontend/index.html b/frontend/index.html index 1e253a2..5b2c993 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -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) ===== diff --git a/frontend/sw.js b/frontend/sw.js index fabc654..e29f8d1 100644 --- a/frontend/sw.js +++ b/frontend/sw.js @@ -1,4 +1,4 @@ -const CACHE = 'simple-v457'; +const CACHE = 'simple-v458'; const URLS = [ 'index.html', 'help.html', diff --git a/pyproject.toml b/pyproject.toml index 708ae9e..c6ed535 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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" diff --git a/pysim_simple_server/server.py b/pysim_simple_server/server.py index a5c8dc6..7b540cf 100644 --- a/pysim_simple_server/server.py +++ b/pysim_simple_server/server.py @@ -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 diff --git a/setup.bat b/setup.bat index df1ec84..03de8bf 100644 --- a/setup.bat +++ b/setup.bat @@ -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 \ No newline at end of file diff --git a/setup.sh b/setup.sh index b448c06..d526be8 100755 --- a/setup.sh +++ b/setup.sh @@ -62,4 +62,5 @@ fi echo "" echo "=== Setup complete ===" -echo "Run ./start.sh to start the server." \ No newline at end of file +echo "Run ./start.sh to start the server." +echo "Updating later: ./update.sh (reuses .venv - pysim is not re-installed)." \ No newline at end of file diff --git a/tests/test_release_package.py b/tests/test_release_package.py index 8af8ce6..1f75541 100644 --- a/tests/test_release_package.py +++ b/tests/test_release_package.py @@ -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'): diff --git a/tests/test_update.py b/tests/test_update.py new file mode 100644 index 0000000..08f6c57 --- /dev/null +++ b/tests/test_update.py @@ -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-/... 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() diff --git a/tools/build_release.py b/tools/build_release.py index e19f94f..06ecd94 100644 --- a/tools/build_release.py +++ b/tools/build_release.py @@ -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', ) diff --git a/tools/changelog_section.py b/tools/changelog_section.py new file mode 100644 index 0000000..2e218bc --- /dev/null +++ b/tools/changelog_section.py @@ -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)) diff --git a/tools/update.py b/tools/update.py new file mode 100644 index 0000000..04fa529 --- /dev/null +++ b/tools/update.py @@ -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()) diff --git a/update.bat b/update.bat new file mode 100755 index 0000000..9e3daf1 --- /dev/null +++ b/update.bat @@ -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 diff --git a/update.sh b/update.sh new file mode 100755 index 0000000..001f031 --- /dev/null +++ b/update.sh @@ -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" "$@"