v3.32.0: the packaged-install updater + a repo CHANGELOG
release / release (push) Has been cancelled
release / release (push) Has been cancelled
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).
This commit is contained in:
@@ -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()
|
||||
Reference in New Issue
Block a user