diff --git a/.github/AGENTS.md b/.github/AGENTS.md index 81536e23..83baa029 100644 --- a/.github/AGENTS.md +++ b/.github/AGENTS.md @@ -57,9 +57,12 @@ Read `.github/workflows/deploy-pypi.yml` with `tests/README.md`; paths are relat - Validate YAML and every affected expression/shell. Trace each changed matrix row through dependency installation, source/native import checks, Nuitka/installer output, packaged architecture/dependency checks, artifact upload, and tag gates. When a target cannot run locally, add a narrow CI assertion that fails before publication with a useful reason. -- `workflows/source-tests.yml` runs isolated source unittest discovery on Windows, Linux, and macOS and is a - required dependency of PyPI publication through `workflows/deploy-pypi.yml`. It can also run manually. Hourly binary - builds retain their separate artifact scope. Source tests do not establish packaged behavior or Python/Qt +- `workflows/source-tests.yml` separates full cross-platform source unittest discovery from focused Linux + interpreter-version checks. The latter use compatible Qt/native wheel pins, dependency consistency, native-binding + imports without starting runtimes, application compilation, cold imports and real compatibility behavior. These + checks do not certify the entire suite on every version. + The reusable workflow is a required dependency of PyPI publication through `workflows/deploy-pypi.yml`, so its + version matrix must also pass. It can run manually. Hourly binary builds retain their separate artifact scope. Source tests do not establish packaged behavior or Python/Qt floors beyond their matrix. Do not call an artifact build a regression-test pass; use `tests/README.md` for source verification. The source suite's offscreen Qt environment exercises widgets and event delivery, not native tray integration, privilege prompts, or an installed application's host effects. Follow actual `needs` and tag gates diff --git a/.github/workflows/source-tests.yml b/.github/workflows/source-tests.yml index 17794665..29d36e32 100644 --- a/.github/workflows/source-tests.yml +++ b/.github/workflows/source-tests.yml @@ -31,3 +31,57 @@ jobs: run: python -m pip install -r requirements.txt "PySide6-Essentials==6.8.3" "PySide6-Addons==6.8.3" - name: Run isolated behavioral and resource tests run: python -m unittest discover -s tests -v + + python-compatibility: + name: Python ${{ matrix.python }} compatibility + runs-on: ubuntu-22.04 + timeout-minutes: 20 + strategy: + fail-fast: false + matrix: + include: + - python: "3.8" + pyside6: "6.6.3.1" + - python: "3.9" + pyside6: "6.8.3" + - python: "3.10" + pyside6: "6.8.3" + - python: "3.11" + pyside6: "6.8.3" + - python: "3.12" + pyside6: "6.8.3" + - python: "3.13" + pyside6: "6.8.3" + - python: "3.14" + pyside6: "6.10.3" + env: + QT_QPA_PLATFORM: offscreen + PYTHONUTF8: "1" + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-python@v7 + with: + python-version: ${{ matrix.python }} + - name: Install Linux Qt runtime libraries + run: sudo apt-get update && sudo apt-get install -y libegl1 libopengl0 + - name: Install interpreter-compatible dependencies + run: >- + python -m pip install --only-binary=:all: -r requirements.txt + "PySide6-Essentials==${{ matrix.pyside6 }}" + "PySide6-Addons==${{ matrix.pyside6 }}" + "zxing-cpp==2.2.0; python_version == '3.8'" + - name: Check installed dependency consistency + run: python -m pip check + - name: Check native binding imports without starting runtimes + run: | + python - <<'PY' + from importlib import import_module + + for name in ('xray', 'hysteria', 'hysteria2', 'tun2socks', 'sing_tun'): + import_module(name) + print('Native binding imports passed; no runtime or TUN started') + PY + - name: Compile application source on this interpreter + run: python -m compileall -q Furious Translation.py + - name: Check cold imports and compatibility behavior + run: python -m unittest tests.test_public_api tests.test_python_compatibility -v diff --git a/tests/README.md b/tests/README.md index a1bc7b09..2712a46c 100644 --- a/tests/README.md +++ b/tests/README.md @@ -15,9 +15,23 @@ below. To reproduce the dependency selection in python -m pip install -r requirements.txt "PySide6-Essentials==6.8.3" "PySide6-Addons==6.8.3" ``` -CI uses Python 3.13. Linux CI also installs `libegl1` and `libopengl0` for Qt. -Backend Python packages in `requirements.txt` are import dependencies; tests do -not require separately installed proxy-core executables or a working proxy. +The full CI source suite uses Python 3.13 on Windows, Linux, and macOS. +A separate Linux job checks Python 3.8 through 3.14 with compatible Qt pins: +6.6.3.1 for Python 3.8, 6.8.3 for 3.9–3.13, and 6.10.3 for 3.14. Python 3.8 +also pins zxing-cpp 2.2.0, which has a published wheel for that interpreter. +The job checks dependency consistency and harmless native-binding imports, +compiles application source, then runs: + +```text +python -m unittest tests.test_public_api tests.test_python_compatibility -v +``` + +These focused checks exercise cold imports, metadata, exact string operations, +and real executor cancellation. They do not run the entire behavioral suite +on every interpreter. Python 3.8 runs legacy branches; native-3.9-only cases +are skipped there and exercised by the newer rows. Linux jobs install +`libegl1` and `libopengl0` for Qt. Backend packages are import dependencies; +tests require neither separate proxy-core executables nor a working proxy. Select the offscreen platform **before starting Python**. Some test modules import Qt-backed Furious modules before `tests.support` can set its defensive @@ -92,7 +106,7 @@ worker. Choose tests by the changed contract rather than by filename alone. | [test_controllers.py](test_controllers.py) | Connection state/error/reconnect transitions, reentrant cancellation/replacement before launch and during completion, startup restoration, shared settings, routing fallback persistence and tray/selector agreement after custom-routing disable/re-enable. | | [test_runtime_lifecycle.py](test_runtime_lifecycle.py) | Qt-thread exit dispatch, commit/exit races, duplicate and late exits, idempotent release and retained failure/retry ownership, failed reap/handle close, spawn failure, queue/timer disposal on preparation failure. | | [test_external_core.py](test_external_core.py) | Harmless real process launch/output/shutdown, partial thread-start rollback, non-finite timeout rejection, failed reap/thread-join retry, readiness/TUN metadata, Windows paths with spaces, subscription rejection of executable profiles, bounded DNS references. | -| [test_python_compatibility.py](test_python_compatibility.py) | Once-only implementation selection without import-time metadata enumeration, exact affix removal, both metadata entry-point APIs and plugin discovery, distribution-version failures, real modern/legacy executor cancellation and statistics-owner shutdown, and profile import with an unsubscriptable collections ABC. Legacy API shapes are simulated; this does not replace execution on the minimum interpreter. | +| [test_python_compatibility.py](test_python_compatibility.py) | Once-only implementation selection without import-time metadata enumeration, exact affix removal, both metadata entry-point APIs and plugin discovery, distribution-version failures, real modern/legacy executor cancellation and statistics-owner shutdown, and profile import with an unsubscriptable collections ABC. Runs actual interpreter APIs alongside legacy-shape simulations; native-only cases skip on Python 3.8. The version matrix executes this module with the cold-import checks. | | [test_frozenlib.py](test_frozenlib.py) | Nested state guards, cleanup isolation, native peer destruction during connection/theme/translation/cleanup notifications, bounded caches/throttling, dual-stack probe selection, mocked proxy/DNS/routes/startup/session boundaries and failure handling. | | [test_native_tun_semantics.py](test_native_tun_semantics.py) | Xray/Hysteria2 runtime-copy TUN preservation/replacement, managed-TUN failures, download-test stripping, prevention of a second tun2socks owner. | | [test_sing_tun.py](test_sing_tun.py) | tun2socks engine default, saved engine choices and ownership-aware reconnect notices, sing-tun Go stack default, development-stack and congestion-control compatibility, independent backend settings and host defaults, selected-only snapshots, captured TUN log sources in both startup paths, staged settings and malformed storage, SOCKS authentication/UDP, harmless real spawn/status/stop, native readiness, cancellation, synchronous compatibility, mocked DNS/routing ownership including development IPv6 rule ordering and refused cleanup. | diff --git a/tests/test_python_compatibility.py b/tests/test_python_compatibility.py index 19a7d5a5..a353bfd3 100644 --- a/tests/test_python_compatibility.py +++ b/tests/test_python_compatibility.py @@ -27,11 +27,12 @@ from Furious.Service.TrafficStatsManager import TrafficStatsManager from shiboken6 import delete as deleteQObject from concurrent.futures import ThreadPoolExecutor -from importlib import metadata +from importlib import import_module, metadata from pathlib import Path from types import SimpleNamespace from unittest import mock +import sys import threading import unittest @@ -42,6 +43,9 @@ from tests.support import ( runPythonChild, ) +RegistryModule = import_module('Furious.Plugins.Registry') +TrafficStatsManagerModule = import_module('Furious.Service.TrafficStatsManager') + class _LegacyExecutor: """Expose the Python 3.8 shutdown signature over a real worker pool.""" @@ -70,7 +74,7 @@ class _MetadataProvider: if not self.selectable: raise AttributeError('legacy metadata has no EntryPoints type') - return metadata.EntryPoints + return object class PythonCompatibilityTest(unittest.TestCase): @@ -137,6 +141,9 @@ class PythonCompatibilityTest(unittest.TestCase): """Repeated calls retain their selected APIs without new capability probes.""" for modern in (False, True): with self.subTest(modern=modern): + if modern and sys.version_info < (3, 9): + self.skipTest('native affix methods require Python 3.9') + version = mock.MagicMock() version.__ge__.return_value = modern fetch = mock.Mock(return_value=() if modern else {}) @@ -209,7 +216,7 @@ class PythonCompatibilityTest(unittest.TestCase): return {'furious.plugins': entries, 'unrelated': ()} provider = _MetadataProvider(selectable, mock.Mock(side_effect=fetch)) - compatibility = self._loadCompatibility((3, 13), provider)[ + compatibility = self._loadCompatibility(sys.version_info, provider)[ 'PythonCompatibility' ] @@ -218,9 +225,8 @@ class PythonCompatibilityTest(unittest.TestCase): registry = PluginRegistry() try: - with mock.patch( - 'Furious.Plugins.Registry.PythonCompatibility', - compatibility, + with mock.patch.object( + RegistryModule, 'PythonCompatibility', compatibility ): registry.discover() @@ -238,23 +244,36 @@ class PythonCompatibilityTest(unittest.TestCase): self.assertEqual(provider.probes, 1) + def testInstalledMetadataUsesTheActualInterpreterAPI(self): + """Real metadata lookup works without opening any native TUN engine.""" + self.assertEqual( + PythonCompatibility.entryPoints('furious.tests.nonexistent-entry-points'), + (), + ) + self.assertEqual(SingTUN.version(), metadata.version('sing-tun')) + def testDistributionVersionKeepsMissingMetadataBehavior(self): """The engine reports unavailable only for missing distribution metadata.""" - target = 'Furious.Frozenlib.PythonCompatibility.metadata.version' - - with mock.patch(target, return_value='0.9.7.dev0') as version: + with mock.patch.object( + metadata, 'version', return_value='0.9.7.dev0' + ) as version: self.assertEqual(SingTUN.version(), '0.9.7.dev0') version.assert_called_once_with('sing-tun') - with mock.patch( - target, side_effect=PythonCompatibility.PackageNotFoundError('sing-tun') + with mock.patch.object( + metadata, + 'version', + side_effect=PythonCompatibility.PackageNotFoundError('sing-tun'), ): self.assertEqual(SingTUN.version(), 'unavailable') - with mock.patch(target, side_effect=ValueError('invalid metadata')): + with mock.patch.object( + metadata, 'version', side_effect=ValueError('invalid metadata') + ): with self.assertRaisesRegex(ValueError, 'invalid metadata'): SingTUN.version() + @unittest.skipIf(sys.version_info < (3, 9), 'native shutdown requires Python 3.9') def testModernShutdownDoesNotRetryUnrelatedTypeErrors(self): """Native shutdown errors propagate without an unsafe fallback retry.""" executor = mock.Mock() @@ -273,6 +292,9 @@ class PythonCompatibilityTest(unittest.TestCase): """Both APIs reject new work, cancel queued calls, and allow a real drain.""" for version in ((3, 8), (3, 9)): with self.subTest(version=version): + if version >= (3, 9) and sys.version_info < (3, 9): + self.skipTest('native shutdown requires Python 3.9') + started, release, queuedRan = ( threading.Event(), threading.Event(), @@ -344,9 +366,8 @@ class PythonCompatibilityTest(unittest.TestCase): compatibility = self._loadCompatibility((3, 8))['PythonCompatibility'] - with mock.patch( - 'Furious.Service.TrafficStatsManager.PythonCompatibility', - compatibility, + with mock.patch.object( + TrafficStatsManagerModule, 'PythonCompatibility', compatibility ): manager._closeExecutor()