Check supported Python versions

Signed-off-by: Loren Eteval <loren.eteval@proton.me>
This commit is contained in:
Loren Eteval
2026-10-07 18:10:50 +08:00
parent 2af7dd5587
commit 9c7a466536
4 changed files with 114 additions and 22 deletions
+6 -3
View File
@@ -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
+54
View File
@@ -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
+18 -4
View File
@@ -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. |
+36 -15
View File
@@ -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()