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("
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