diff --git a/.github/workflows/python_runtime_contract.yml b/.github/workflows/python_runtime_contract.yml new file mode 100644 index 00000000..3716c7f6 --- /dev/null +++ b/.github/workflows/python_runtime_contract.yml @@ -0,0 +1,33 @@ +name: Python Runtime Contract + +on: + pull_request: + paths: + - '.github/workflows/python_runtime_contract.yml' + - 'README.md' + - 'requirements.txt' + - 'admin/test/scripts/test_dependency_compatibility.py' + - 'admin/test/scripts/test_runtime_requirements.py' + +jobs: + runtime-contract: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + python-version: ['3.10', '3.11'] + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + + - name: Install runtime dependencies + run: python -m pip install -r requirements.txt + + - name: Run admin compatibility tests + run: python -m unittest discover -s admin/test/scripts -p 'test_*.py' diff --git a/README.md b/README.md index 20f5b58d..4534313d 100755 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ Details in blog post here: https://abrignoni.blogspot.com/2020/02/aleapp-android ## Requirements -**Python 3.9 or above** (older versions of 3.x will also work with the exception of one or two modules) +**Python 3.10 or above** ### Dependencies diff --git a/admin/test/scripts/test_dependency_compatibility.py b/admin/test/scripts/test_dependency_compatibility.py new file mode 100644 index 00000000..4adf79fe --- /dev/null +++ b/admin/test/scripts/test_dependency_compatibility.py @@ -0,0 +1,161 @@ +import importlib +import os +import subprocess +import sys +import tempfile +import unittest +from pathlib import Path + +import bcrypt +import bencoding +import blackboxprotobuf +import fitdecode +import folium +import polyline +import pytz +import simplekml +import xlsxwriter +import xmltodict +from bs4 import BeautifulSoup +from geopy.geocoders import Nominatim +from packaging import version +from PIL import Image +from google.protobuf import descriptor +from Crypto.Cipher import AES +from Crypto.Util.Padding import pad, unpad + + +THIRD_PARTY_IMPORTS = ( + "bcrypt", + "bs4", + "bencoding", + "blackboxprotobuf", + "fitdecode", + "folium", + "geopy", + "packaging", + "PIL", + "polyline", + "google.protobuf", + "Crypto", + "pytz", + "simplekml", + "wheel", + "xlsxwriter", + "xmltodict", +) + +REPO_ROOT = Path(__file__).resolve().parents[3] + +CORE_MODULES = ( + "aleapp", + "scripts.ilapfuncs", + "scripts.plugin_loader", + "scripts.artifacts.notification_history_pb.notificationhistory_pb2", + "scripts.artifacts.usagestats_pb.usagestatsservice_pb2", +) + + +class TestDependencyCompatibility(unittest.TestCase): + def test_declared_packages_are_importable(self): + for module_name in THIRD_PARTY_IMPORTS: + with self.subTest(module_name=module_name): + importlib.import_module(module_name) + + def test_core_modules_import_under_supported_python(self): + for module_name in CORE_MODULES: + with self.subTest(module_name=module_name): + importlib.import_module(module_name) + + def test_plugin_loader_imports_artifact_modules(self): + from scripts.plugin_loader import PluginLoader + + loader = PluginLoader() + self.assertGreater(len(loader), 0) + + def test_dependency_runtime_smoke_behaviors(self): + self.assertTrue(bcrypt.checkpw(b"pw", bcrypt.hashpw(b"pw", bcrypt.gensalt()))) + + self.assertEqual(bencoding.bdecode(bencoding.bencode({b"a": 1})), {b"a": 1}) + + message, types = blackboxprotobuf.decode_message(b"\x08\x96\x01") + self.assertEqual(message["1"], 150) + self.assertEqual(types["1"]["type"], "int") + + self.assertTrue(hasattr(fitdecode, "FitReader")) + + with tempfile.NamedTemporaryFile(suffix=".html", delete=False) as html_file: + try: + folium.Map(location=[0, 0], zoom_start=1).save(html_file.name) + self.assertGreater(os.path.getsize(html_file.name), 0) + finally: + os.unlink(html_file.name) + + self.assertEqual(Nominatim(user_agent="aleapp-test").scheme, "https") + self.assertLess(version.parse("1.2.3"), version.parse("2.0.0")) + + with tempfile.NamedTemporaryFile(suffix=".png", delete=False) as image_file: + try: + Image.new("RGB", (2, 2), color="red").save(image_file.name) + with Image.open(image_file.name) as reopened: + self.assertEqual(reopened.size, (2, 2)) + finally: + os.unlink(image_file.name) + + encoded = polyline.encode([(38.5, -120.2), (40.7, -120.95)]) + self.assertEqual(polyline.decode(encoded), [(38.5, -120.2), (40.7, -120.95)]) + + self.assertIsNotNone(descriptor) + + key = b"0123456789abcdef" + cipher = AES.new(key, AES.MODE_ECB) + ciphertext = cipher.encrypt(pad(b"hello world", AES.block_size)) + self.assertEqual(unpad(cipher.decrypt(ciphertext), AES.block_size), b"hello world") + + self.assertEqual(pytz.timezone("UTC").zone, "UTC") + + with tempfile.NamedTemporaryFile(suffix=".kml", delete=False) as kml_file: + try: + kml = simplekml.Kml() + kml.newpoint(name="x", coords=[(0, 0)]) + kml.save(kml_file.name) + self.assertGreater(os.path.getsize(kml_file.name), 0) + finally: + os.unlink(kml_file.name) + + with tempfile.NamedTemporaryFile(suffix=".xlsx", delete=False) as xlsx_file: + try: + workbook = xlsxwriter.Workbook(xlsx_file.name) + worksheet = workbook.add_worksheet("Sheet1") + worksheet.write(0, 0, "ok") + workbook.close() + self.assertGreater(os.path.getsize(xlsx_file.name), 0) + finally: + os.unlink(xlsx_file.name) + + self.assertEqual(xmltodict.parse("1")["root"]["a"], "1") + self.assertEqual(BeautifulSoup("

hi

", "html.parser").p.text, "hi") + + def test_cli_help_runs_under_supported_python(self): + result = subprocess.run( + [sys.executable, "aleapp.py", "--help"], + cwd=REPO_ROOT, + capture_output=True, + text=True, + check=False, + ) + + self.assertEqual(result.returncode, 0, msg=result.stderr) + self.assertIn("ALEAPP: Android Logs, Events, and Protobuf Parser.", result.stdout) + + def test_imagetk_imports_when_tkinter_is_available(self): + try: + importlib.import_module("tkinter") + except ModuleNotFoundError: + self.skipTest("tkinter is not available in this Python build") + + importlib.import_module("PIL.ImageTk") + + +if __name__ == "__main__": + unittest.main() diff --git a/admin/test/scripts/test_runtime_requirements.py b/admin/test/scripts/test_runtime_requirements.py new file mode 100644 index 00000000..2688e0a8 --- /dev/null +++ b/admin/test/scripts/test_runtime_requirements.py @@ -0,0 +1,30 @@ +import unittest +from pathlib import Path + + +REPO_ROOT = Path(__file__).resolve().parents[3] + + +class TestRuntimeRequirements(unittest.TestCase): + def test_readme_documents_python_3_10_or_above(self): + readme = (REPO_ROOT / "README.md").read_text(encoding="utf-8") + self.assertIn("**Python 3.10 or above**", readme) + + def test_requirements_pin_latest_pillow(self): + requirement_lines = (REPO_ROOT / "requirements.txt").read_text(encoding="utf-8").splitlines() + pillow_lines = [line.strip() for line in requirement_lines if line.strip().lower().startswith("pillow")] + self.assertEqual(pillow_lines, ["pillow==12.1.1"]) + + def test_runtime_contract_workflow_covers_python_3_10_and_3_11(self): + workflow_path = REPO_ROOT / ".github" / "workflows" / "python_runtime_contract.yml" + self.assertTrue(workflow_path.exists(), "runtime contract workflow should exist") + + workflow = workflow_path.read_text(encoding="utf-8") + self.assertIn("'3.10'", workflow) + self.assertIn("'3.11'", workflow) + self.assertIn("python -m pip install -r requirements.txt", workflow) + self.assertIn("test_*.py", workflow) + + +if __name__ == "__main__": + unittest.main() diff --git a/requirements.txt b/requirements.txt index 53629dfe..9120c9c2 100755 --- a/requirements.txt +++ b/requirements.txt @@ -6,7 +6,7 @@ fitdecode==0.10.0 folium==0.14.0 geopy==2.3.0 packaging==20.1 -pillow>=10.3.0,<12.0.0 +pillow==12.1.1 polyline==2.0.0 protobuf==3.10.0 PyCryptodome