Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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]
Expand Down
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
1 change: 1 addition & 0 deletions MANIFEST.in
Original file line number Diff line number Diff line change
Expand Up @@ -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
19 changes: 17 additions & 2 deletions RELEASE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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-<version>*
```
2 changes: 2 additions & 0 deletions src/dashscope_agenticx/ui/tts.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
13 changes: 10 additions & 3 deletions src/dashscope_agenticx/ui/tui.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
2 changes: 2 additions & 0 deletions src/dashscope_agenticx/ui/voice.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 1 addition & 2 deletions tests/ut/test_docs_consistency.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions tests/ut/test_packaging_contract.py
Original file line number Diff line number Diff line change
Expand Up @@ -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():
Expand Down
26 changes: 26 additions & 0 deletions tests/ut/test_tts.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
from __future__ import annotations

import sys
from types import ModuleType

import pytest

Expand All @@ -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()
Expand All @@ -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):
Expand Down
37 changes: 37 additions & 0 deletions tests/ut/test_tui_scroll.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
28 changes: 27 additions & 1 deletion tests/ut/test_ui_edge.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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):
Expand All @@ -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):
Expand Down
Loading