diff --git a/.github/workflows/test-funasr-onnx-package.yml b/.github/workflows/test-funasr-onnx-package.yml index 2501f2a98..6f9ba5ca8 100644 --- a/.github/workflows/test-funasr-onnx-package.yml +++ b/.github/workflows/test-funasr-onnx-package.yml @@ -6,6 +6,7 @@ on: - ".github/workflows/test-funasr-onnx-package.yml" - "runtime/python/onnxruntime/**" - "tests/test_funasr_onnx_release.py" + - "tests/test_funasr_onnx_installed.py" push: branches: - main @@ -13,6 +14,7 @@ on: - ".github/workflows/test-funasr-onnx-package.yml" - "runtime/python/onnxruntime/**" - "tests/test_funasr_onnx_release.py" + - "tests/test_funasr_onnx_installed.py" workflow_dispatch: permissions: @@ -60,7 +62,7 @@ jobs: import importlib.util from importlib.metadata import requires, version - assert version("funasr-onnx") == "0.4.2" + assert version("funasr-onnx") == "0.4.3" assert importlib.util.find_spec("torch") is None requirements = requires("funasr-onnx") or [] @@ -76,10 +78,19 @@ jobs: ) PY + - name: Verify installed wrapper error paths + run: python tests/test_funasr_onnx_installed.py + + - name: Verify source distribution error paths + run: | + python -m pip install --force-reinstall --no-deps dist/funasr-onnx/*.tar.gz + python -m pip check + python tests/test_funasr_onnx_installed.py + - name: Upload release candidate if: matrix.python-version == '3.12' uses: actions/upload-artifact@v7 with: - name: funasr-onnx-0.4.2 + name: funasr-onnx-0.4.3 path: dist/funasr-onnx/* if-no-files-found: error diff --git a/runtime/python/onnxruntime/README.md b/runtime/python/onnxruntime/README.md index 269262204..0f62fb188 100644 --- a/runtime/python/onnxruntime/README.md +++ b/runtime/python/onnxruntime/README.md @@ -2,6 +2,11 @@ ## Install `funasr-onnx` +`funasr-onnx` is a separate distribution from `funasr`. Upgrading `funasr` +does not update the installed ONNX wrappers. Check their versions separately +with `python -m pip show funasr-onnx funasr`; use the source installation below +when a required wrapper fix is newer than the published PyPI package. + install from pip ```shell diff --git a/runtime/python/onnxruntime/setup.py b/runtime/python/onnxruntime/setup.py index 652371465..3d7b40915 100644 --- a/runtime/python/onnxruntime/setup.py +++ b/runtime/python/onnxruntime/setup.py @@ -13,7 +13,7 @@ def get_readme(): MODULE_NAME = "funasr_onnx" -VERSION_NUM = "0.4.2" +VERSION_NUM = "0.4.3" setuptools.setup( name=MODULE_NAME, diff --git a/tests/test_funasr_onnx_installed.py b/tests/test_funasr_onnx_installed.py new file mode 100644 index 000000000..15f262a95 --- /dev/null +++ b/tests/test_funasr_onnx_installed.py @@ -0,0 +1,147 @@ +"""Run against an installed wheel/sdist, outside the source package directory.""" + +import ast +import builtins +import contextlib +import importlib.metadata +import importlib.util +import io +import json +from pathlib import Path +import subprocess +import sys +import tempfile +from types import SimpleNamespace +import unittest +from unittest.mock import patch + +if importlib.util.find_spec('funasr_onnx') is None: + raise unittest.SkipTest('Install the standalone funasr-onnx distribution first.') + +import funasr_onnx +from funasr_onnx.paraformer_online_bin import Paraformer as ParaformerOnline + + +ENTRYPOINTS = ( + funasr_onnx.Paraformer, + funasr_onnx.ContextualParaformer, + funasr_onnx.SeacoParaformer, + ParaformerOnline, + funasr_onnx.Fsmn_vad, + funasr_onnx.Fsmn_vad_online, + funasr_onnx.CT_Transformer, + funasr_onnx.CT_Transformer_VadRealtime, + funasr_onnx.SenseVoiceSmall, +) + + +class InstalledOnnxPackageTest(unittest.TestCase): + def test_installed_distribution_version_and_origin(self): + distribution = importlib.metadata.distribution('funasr-onnx') + self.assertEqual(distribution.version, '0.4.3') + self.assertEqual( + Path(funasr_onnx.__file__).resolve().parent, + Path(distribution.locate_file('funasr_onnx')).resolve(), + ) + + def test_installed_modules_have_no_string_exceptions(self): + offenders = [] + for source in Path(funasr_onnx.__file__).parent.rglob('*.py'): + for node in ast.walk(ast.parse(source.read_text(encoding='utf-8'))): + if not isinstance(node, ast.Raise): + continue + literal = node.exc + if ( + isinstance(literal, ast.Call) + and isinstance(literal.func, ast.Attribute) + and literal.func.attr == 'format' + ): + literal = literal.func.value + if isinstance(literal, ast.Constant) and isinstance(literal.value, str): + offenders.append(f'{source.name}:{node.lineno}') + self.assertEqual(offenders, []) + + def check_export_import_error(self, error, expected_type, preserves_cause): + original_import = builtins.__import__ + + def broken_export_import(name, *args, **kwargs): + if name == 'funasr': + raise error + return original_import(name, *args, **kwargs) + + # Only the optional exporter import is replaced; constructors are installed code. + with tempfile.TemporaryDirectory() as model_dir: + for entrypoint in ENTRYPOINTS: + with self.subTest(entrypoint=entrypoint.__name__): + error.__traceback__ = None + with patch('builtins.__import__', side_effect=broken_export_import): + with contextlib.redirect_stdout(io.StringIO()): + with self.assertRaises(expected_type) as raised: + entrypoint(model_dir) + if preserves_cause: + self.assertIs(raised.exception.__cause__, error) + else: + self.assertIs(raised.exception, error) + + def test_missing_transitive_export_dependency_remains_visible(self): + error = ModuleNotFoundError("No module named 'torchaudio'", name='torchaudio') + self.check_export_import_error(error, ImportError, preserves_cause=True) + + def test_unrelated_export_import_failure_is_not_misreported(self): + error = RuntimeError('exporter initialization failed') + self.check_export_import_error(error, RuntimeError, preserves_cause=False) + + def test_failed_model_download_preserves_cause(self): + original_import = builtins.__import__ + error = OSError('model download failed') + + def failed_download(*args, **kwargs): + raise error + + def controlled_download_import(name, *args, **kwargs): + if name == 'modelscope.hub.snapshot_download': + return SimpleNamespace(snapshot_download=failed_download) + return original_import(name, *args, **kwargs) + + with tempfile.TemporaryDirectory() as directory: + missing_model = str(Path(directory) / 'not-downloaded') + for entrypoint in ENTRYPOINTS: + with self.subTest(entrypoint=entrypoint.__name__): + error.__traceback__ = None + with patch('builtins.__import__', side_effect=controlled_download_import): + with self.assertRaises(RuntimeError) as raised: + entrypoint(missing_model) + self.assertIs(raised.exception.__cause__, error) + + def test_missing_onnxruntime_import_preserves_cause(self): + script = ''' +import builtins +import json +original_import = builtins.__import__ +def broken_import(name, *args, **kwargs): + if name == 'onnxruntime': + raise ModuleNotFoundError("No module named 'onnxruntime'", name='onnxruntime') + return original_import(name, *args, **kwargs) +builtins.__import__ = broken_import +try: + import funasr_onnx +except Exception as error: + print(json.dumps({ + 'type': type(error).__name__, + 'cause': type(error.__cause__).__name__, + 'missing': getattr(error.__cause__, 'name', None), + })) +else: + print(json.dumps({'type': None})) +''' + result = subprocess.run( + [sys.executable, '-c', script], capture_output=True, text=True, timeout=60, + check=True, + ) + self.assertEqual(json.loads(result.stdout.splitlines()[-1]), { + 'type': 'ImportError', 'cause': 'ModuleNotFoundError', 'missing': 'onnxruntime', + }) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/test_funasr_onnx_release.py b/tests/test_funasr_onnx_release.py index 1c255bede..08d85b4d2 100644 --- a/tests/test_funasr_onnx_release.py +++ b/tests/test_funasr_onnx_release.py @@ -7,7 +7,7 @@ ROOT = Path(__file__).resolve().parents[1] PACKAGE_ROOT = ROOT / "runtime" / "python" / "onnxruntime" SETUP_PATH = PACKAGE_ROOT / "setup.py" -EXPECTED_VERSION = "0.4.2" +EXPECTED_VERSION = "0.4.3" REQUIREMENT_NAME_PATTERN = re.compile(r"^\s*([A-Za-z0-9](?:[A-Za-z0-9._-]*[A-Za-z0-9])?)") @@ -44,7 +44,7 @@ def normalized_requirement_name(requirement): class FunASROnnxReleaseContractTest(unittest.TestCase): - def test_release_version_is_0_4_2(self): + def test_release_version_is_0_4_3(self): self.assertEqual(assigned_literal(read_setup_tree(), "VERSION_NUM"), EXPECTED_VERSION) def test_runtime_dependencies_keep_onnx_install_torch_free(self):