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/.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/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/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/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/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_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(): diff --git a/tests/ut/test_tts.py b/tests/ut/test_tts.py index 4ea846f..a11c534 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,26 @@ 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_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 a7d92f2..87a0817 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,26 @@ 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):