From adc96aefbee8191da0076cec14edc8b929004128 Mon Sep 17 00:00:00 2001 From: "zhansheng.lzs" Date: Tue, 10 Nov 2026 17:20:41 +0800 Subject: [PATCH 1/3] fix(voice): degrade gracefully when audio backend is unavailable Mock sounddevice in offline voice tests instead of requiring a running audio server on CI runners, and return a friendly unavailable message when importing sounddevice fails with a non-ImportError backend error such as PortAudioError. Align the release-document check with the actual 1.0.0 release date and disable matrix fail-fast so every Python version reports its own result. --- .github/workflows/test.yml | 2 ++ CHANGELOG.md | 3 +++ src/dashscope_agenticx/ui/tts.py | 2 ++ src/dashscope_agenticx/ui/voice.py | 2 ++ tests/ut/test_docs_consistency.py | 3 +-- tests/ut/test_tts.py | 24 ++++++++++++++++++++++++ tests/ut/test_ui_edge.py | 26 +++++++++++++++++++++++++- 7 files changed, 59 insertions(+), 3 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 0320e72..67fc8ad 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -23,6 +23,7 @@ jobs: unit-tests: runs-on: ubuntu-latest strategy: + fail-fast: false matrix: python-version: ["3.10", "3.11", "3.12", "3.13"] steps: @@ -81,6 +82,7 @@ jobs: package-and-security: runs-on: ubuntu-latest strategy: + fail-fast: false matrix: python-version: ["3.10", "3.13"] scope: [base, all] diff --git a/CHANGELOG.md b/CHANGELOG.md index b70a307..4585495 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -63,6 +63,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Python 3.10 audit environments upgrade their bootstrap setuptools instead of retaining the runner's vulnerable version. Linux unit-test runners install PortAudio so voice degradation tests exercise their intended paths. +- Audio backend initialization failures now degrade gracefully. Offline voice + tests mock sounddevice rather than requiring a running audio server or + physical device, and release-document checks use the actual release date. - Dependency requirements exclude the security scan's affected Runtime versions. Pillow-backed vision/media require Python 3.11+; Python 3.10 retains base Runtime support and the `all` extra omits Pillow on that version. diff --git a/src/dashscope_agenticx/ui/tts.py b/src/dashscope_agenticx/ui/tts.py index fa802e0..a1f2a3e 100644 --- a/src/dashscope_agenticx/ui/tts.py +++ b/src/dashscope_agenticx/ui/tts.py @@ -112,6 +112,8 @@ def is_available() -> tuple[bool, str]: "Voice output requires dependencies: " "pip install dashscope-agenticx[voice]" ) + except Exception: + return False, "Voice output audio backend failed to initialize." def _strip_markdown(text: str) -> str: diff --git a/src/dashscope_agenticx/ui/voice.py b/src/dashscope_agenticx/ui/voice.py index 4171e39..354e2b0 100644 --- a/src/dashscope_agenticx/ui/voice.py +++ b/src/dashscope_agenticx/ui/voice.py @@ -26,6 +26,8 @@ def is_available() -> tuple[bool, str]: "Voice input requires dependencies: " "pip install dashscope-agenticx[voice]" ) + except Exception: + return False, "Voice input audio backend failed to initialize." SAMPLE_RATE = 16000 diff --git a/tests/ut/test_docs_consistency.py b/tests/ut/test_docs_consistency.py index ab5e443..f8d87cb 100644 --- a/tests/ut/test_docs_consistency.py +++ b/tests/ut/test_docs_consistency.py @@ -158,8 +158,7 @@ def test_local_document_links_exist(self, document: Path): def test_public_changelog_excludes_pre_one_point_zero_history(self): text = (REPO_ROOT / "CHANGELOG.md").read_text(encoding="utf-8") - assert "## [Released]" in text - assert "## [1.0.0] - 2026-11-09" in text + assert "## [1.0.0] - 2026-11-10" in text assert "## [1.0.0rc3]" in text assert "## [0.7.2]" not in text assert "Chinese archive" not in text diff --git a/tests/ut/test_tts.py b/tests/ut/test_tts.py index 4ea846f..00826e1 100644 --- a/tests/ut/test_tts.py +++ b/tests/ut/test_tts.py @@ -4,6 +4,7 @@ from __future__ import annotations import sys +from types import ModuleType import pytest @@ -19,6 +20,11 @@ ) +@pytest.fixture(autouse=True) +def mocked_sounddevice(monkeypatch): + monkeypatch.setitem(sys.modules, "sounddevice", ModuleType("sounddevice")) + + class TestIsAvailable: def test_ok_when_deps_importable(self): ok, err = is_available() @@ -37,6 +43,24 @@ def test_missing_sounddevice(self, monkeypatch): assert ok is False assert "agenticx[voice]" in err + @pytest.mark.parametrize("error_type", [OSError, Exception]) + def test_audio_backend_initialization_failure( + self, monkeypatch, error_type + ): + import builtins + + original_import = builtins.__import__ + + def failing_import(name, *args, **kwargs): + if name == "sounddevice": + raise error_type("audio backend unavailable") + return original_import(name, *args, **kwargs) + + monkeypatch.setattr(builtins, "__import__", failing_import) + ok, err = is_available() + assert ok is False + assert "audio backend" in err + class TestStripMarkdown: def test_code_block_replaced(self): diff --git a/tests/ut/test_ui_edge.py b/tests/ut/test_ui_edge.py index a7d92f2..30b4271 100644 --- a/tests/ut/test_ui_edge.py +++ b/tests/ut/test_ui_edge.py @@ -5,8 +5,9 @@ from __future__ import annotations import subprocess +import sys import threading -from types import SimpleNamespace +from types import ModuleType, SimpleNamespace from unittest.mock import MagicMock import pytest @@ -90,6 +91,11 @@ async def test_missing_deps_reports_error(self, monkeypatch): async def test_asr_start_failure_reports_error(self, monkeypatch): monkeypatch.setattr(voice, "is_available", lambda: (True, "")) + monkeypatch.setitem( + sys.modules, + "sounddevice", + ModuleType("sounddevice"), + ) class FakeASR: def __init__(self, api_key, model): @@ -107,6 +113,24 @@ def start(self): assert result is None assert any("连接拒绝" in s for s in shown) + @pytest.mark.parametrize("error_type", [OSError, Exception]) + def test_audio_backend_initialization_failure( + self, monkeypatch, error_type + ): + import builtins + + original_import = builtins.__import__ + + def failing_import(name, *args, **kwargs): + if name == "sounddevice": + raise error_type("audio backend unavailable") + return original_import(name, *args, **kwargs) + + monkeypatch.setattr(builtins, "__import__", failing_import) + ok, err = voice.is_available() + assert ok is False + assert "audio backend" in err + class TestCameraDegradation: def test_capture_without_deps(self, monkeypatch): From dc0e6fc0a7388aeeaf245dde1ce611f13f261c90 Mon Sep 17 00:00:00 2001 From: "zhansheng.lzs" Date: Tue, 10 Nov 2026 17:32:07 +0800 Subject: [PATCH 2/3] fix(tui): stop clipboard watcher when screen widgets are unmounted The clipboard settle timer can fire while the app is shutting down and Textual has already unmounted widget component classes, so rendering the screen raised KeyError. Stop watching instead of crashing, apply Black's parameter formatting to the new voice tests, and cover the shutdown race with a regression test. --- src/dashscope_agenticx/ui/tui.py | 13 ++++++++--- tests/ut/test_tts.py | 4 +++- tests/ut/test_tui_scroll.py | 37 ++++++++++++++++++++++++++++++++ tests/ut/test_ui_edge.py | 4 +++- 4 files changed, 53 insertions(+), 5 deletions(-) diff --git a/src/dashscope_agenticx/ui/tui.py b/src/dashscope_agenticx/ui/tui.py index 4a4131b..c5e8cd1 100644 --- a/src/dashscope_agenticx/ui/tui.py +++ b/src/dashscope_agenticx/ui/tui.py @@ -2077,9 +2077,16 @@ def _settle_clipboard_copy( if request.selections is not None: candidates += output.native_copy_candidates() if request.screen_selection is not None: - candidates += ( - self._native_screen_candidate(request.screen_selection), - ) + try: + candidates += ( + self._native_screen_candidate(request.screen_selection), + ) + except KeyError: + # App shutdown unmounts widget component classes while the + # settle timer may still fire; the screen can no longer + # be rendered, so stop watching instead of crashing. + self._stop_clipboard_watch() + return request.candidates = tuple(dict.fromkeys(candidates)) copied_text = request.clipboard.read_text() if request.clipboard.change_count() != revision: diff --git a/tests/ut/test_tts.py b/tests/ut/test_tts.py index 00826e1..a11c534 100644 --- a/tests/ut/test_tts.py +++ b/tests/ut/test_tts.py @@ -45,7 +45,9 @@ def test_missing_sounddevice(self, monkeypatch): @pytest.mark.parametrize("error_type", [OSError, Exception]) def test_audio_backend_initialization_failure( - self, monkeypatch, error_type + self, + monkeypatch, + error_type, ): import builtins diff --git a/tests/ut/test_tui_scroll.py b/tests/ut/test_tui_scroll.py index 96e1445..d00609e 100644 --- a/tests/ut/test_tui_scroll.py +++ b/tests/ut/test_tui_scroll.py @@ -2832,6 +2832,43 @@ async def test_native_copy_watcher_preserves_unrelated_clipboard( assert macos_clipboard.writes == initial_writes assert app._clipboard_settle_timer is None + async def test_settle_stops_when_screen_widgets_already_unmounted( + self, + macos_clipboard, + monkeypatch, + ): + """The settle timer may fire during app shutdown, after Textual + unmounted widget component classes; rendering must not crash.""" + app = _make_app() + async with app.run_test(size=(80, 30)) as pilot: + self._select_multiscreen_output(app) + await pilot.pause() + app.on_text_selected(events.TextSelected()) + request = app._clipboard_request + assert request is not None + request.verify = True + request.screen_selection = Selection.from_offsets( + Offset(0, 0), + Offset(10, 5), + ) + + def unmounted_render(): + raise KeyError( + "No 'text-area--gutter' key in COMPONENT_CLASSES", + ) + + monkeypatch.setattr( + app.screen._compositor, + "render_strips", + unmounted_render, + ) + app._settle_clipboard_copy( + request, + app._clipboard_copy_generation, + ) + assert app._clipboard_request is None + assert macos_clipboard.writes == [] + async def test_native_copy_watcher_rejects_selected_text_outside_output( self, macos_clipboard, diff --git a/tests/ut/test_ui_edge.py b/tests/ut/test_ui_edge.py index 30b4271..87a0817 100644 --- a/tests/ut/test_ui_edge.py +++ b/tests/ut/test_ui_edge.py @@ -115,7 +115,9 @@ def start(self): @pytest.mark.parametrize("error_type", [OSError, Exception]) def test_audio_backend_initialization_failure( - self, monkeypatch, error_type + self, + monkeypatch, + error_type, ): import builtins From 0ad240687eac5ac6845c551168cf1c49594e3fa1 Mon Sep 17 00:00:00 2001 From: "zhansheng.lzs" Date: Tue, 10 Nov 2026 17:36:45 +0800 Subject: [PATCH 3/3] ci(release): publish Python distributions to PyPI Add a trusted-publishing (OIDC) pypi job to the tag workflow, gated by a manually approved pypi environment, and document the one-time PyPI setup plus the manual twine alternative. Prune the internal bench/ directory from the source distribution alongside md/ and cover both exclusions in the packaging contract test. --- .github/workflows/release.yml | 16 ++++++++++++++++ MANIFEST.in | 1 + RELEASE.md | 19 +++++++++++++++++-- tests/ut/test_packaging_contract.py | 4 ++++ 4 files changed, 38 insertions(+), 2 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 7c0d66b..754fa6c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -100,6 +100,22 @@ jobs: dist/agenticx-${{ matrix.platform }}.tar.gz dist/SHA256SUMS-${{ matrix.platform }} + pypi: + needs: [package] + runs-on: ubuntu-latest + environment: + name: pypi + url: https://pypi.org/p/dashscope-agenticx + permissions: + id-token: write + steps: + - uses: actions/download-artifact@v4 + with: + name: python-distribution + path: dist/ + - name: Publish to PyPI + uses: pypa/gh-action-pypi-publish@release/v1 + release: needs: [build, package] runs-on: ubuntu-latest diff --git a/MANIFEST.in b/MANIFEST.in index 61bd8ac..b3ce7a0 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -9,4 +9,5 @@ include SECURITY.md include CONTRIBUTING.md include RELEASE.md prune md +prune bench recursive-include src/dashscope_agenticx/bundled_apps *.toml *.json *.md diff --git a/RELEASE.md b/RELEASE.md index ec8913d..3f728df 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -23,5 +23,20 @@ The tag workflow builds Python distributions and platform binaries, smoke tests the binaries, and uploads assets plus SHA-256 checksums to a draft GitHub Release. Use the version's CHANGELOG entry as its release notes and publish the draft only after reviewing the build results and assets. -Publishing a GitHub Release does not upload the Python package to PyPI; -PyPI publication is a separate, explicitly authorized step. + +PyPI publication uses the tag workflow's `pypi` job with trusted publishing +(OIDC), so no API token is stored in the repository. One-time setup: on +PyPI, add a trusted publisher for `dashscope/dashscope-agenticx`, workflow +`release.yml`, environment `pypi`; in the GitHub repository, create the +`pypi` environment and require manual approval so every upload stays an +explicitly authorized step. The source distribution includes only public +documents; the internal `md/` and `bench/` directories are pruned. + +To publish manually instead, build and verify the exact artifacts first: + +``` +python -m build +twine check dist/* +python scripts/release_check.py +twine upload dist/dashscope_agenticx-* +``` diff --git a/tests/ut/test_packaging_contract.py b/tests/ut/test_packaging_contract.py index 902d678..b842f98 100644 --- a/tests/ut/test_packaging_contract.py +++ b/tests/ut/test_packaging_contract.py @@ -123,9 +123,13 @@ def test_source_manifest_includes_public_docs_and_excludes_internal_md(): assert (root / name).is_file() assert f"include {name}" in directives assert "prune md" in directives + assert "prune bench" in directives assert not any( line.startswith("recursive-include md ") for line in directives ) + assert not any( + line.startswith("recursive-include bench ") for line in directives + ) def test_offline_release_check_passes():