aa8c9c6f12
release / release (push) Waiting to run
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).
158 lines
6.5 KiB
Python
158 lines
6.5 KiB
Python
#!/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()
|