diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 433eccd03..17b97d1c5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -60,17 +60,16 @@ jobs: timeout-minutes: 75 strategy: matrix: - # PR + push runs cover the latest two; the daily cron run - # below adds 3.11 so the minimum-supported version stays tested. - python-version: ${{ github.event_name == 'schedule' && fromJSON('["3.11", "3.12", "3.13"]') || fromJSON('["3.12", "3.13"]') }} + # ONE full sharded leg on the latest supported version; the other + # versions get import-smoke jobs instead. + python-version: ["3.14"] # Shard the suite across parallel jobs. xdist parallelises WITHIN a job, # but a hosted runner is 2 vCPU, so -n auto caps at 2 workers however # many tests exist -- and the suite has grown 4845 -> 10250 (#2135). # Cores per job were the bottleneck, not the tests. # # A public repo gets ~20 concurrent jobs free and we were using 2, so - # this costs nothing. 2 versions x 4 shards = 8 jobs on PR/push, and - # 12 on the scheduled run that adds 3.11. + # this costs nothing. 1 version x 4 shards = 4 jobs on PR/push. shard: [1, 2, 3, 4] steps: @@ -118,10 +117,9 @@ jobs: if: matrix.shard == 1 run: uv run --no-sync python -c "from tinyagentos.app import create_app; print('OK')" - # Import smoke for the declared minimum Python version (3.11). The shard - # matrix only runs 3.11 on the nightly schedule, so a 3.11-only regression - # could merge green and fail at import on every supported host. This job - # runs on every PR and push to catch that class of defect before merge. + # Import smoke for each supported Python version that is NOT the full-shard + # leg. The full suite runs only on 3.14; these jobs guard the remaining + # versions against import-time regressions that the shard leg would miss. py311-import-smoke: runs-on: ubuntu-latest timeout-minutes: 10 @@ -145,11 +143,58 @@ jobs: - name: Compile all tinyagentos modules run: uv run --no-sync python -m compileall -q tinyagentos + py312-import-smoke: + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v7 + + - name: Set up uv + uses: astral-sh/setup-uv@v7 + with: + enable-cache: true + + - name: Provision Python 3.12 via uv + run: uv python install 3.12 + + - name: Install dependencies + run: uv sync --frozen --python 3.12 + + - name: Verify app and routes import under 3.12 + run: uv run --no-sync python -c "import pkgutil, importlib, tinyagentos.app, tinyagentos.routes as r; mods=[importlib.import_module(n) for _, n, _ in pkgutil.walk_packages(r.__path__, 'tinyagentos.routes.')]; print('3.12 import ok:', len(mods), 'route modules')" + + - name: Compile all tinyagentos modules + run: uv run --no-sync python -m compileall -q tinyagentos + + py313-import-smoke: + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v7 + + - name: Set up uv + uses: astral-sh/setup-uv@v7 + with: + enable-cache: true + + - name: Provision Python 3.13 via uv + run: uv python install 3.13 + + - name: Install dependencies + run: uv sync --frozen --python 3.13 + + - name: Verify app and routes import under 3.13 + run: uv run --no-sync python -c "import pkgutil, importlib, tinyagentos.app, tinyagentos.routes as r; mods=[importlib.import_module(n) for _, n, _ in pkgutil.walk_packages(r.__path__, 'tinyagentos.routes.')]; print('3.13 import ok:', len(mods), 'route modules')" + + - name: Compile all tinyagentos modules + run: uv run --no-sync python -m compileall -q tinyagentos + # Aggregate gate. Reports the exact check names branch protection requires - # ("test (3.11)", "test (3.12)", "test (3.13)") so the required-context list - # does not have to change when the shard count does. An explicit `name:` is - # used verbatim by GitHub rather than having the matrix values appended, which - # is what makes reproducing the old names possible at all. + # ("test (3.11)", "test (3.12)", "test (3.13)", "test (3.14)") so the + # required-context list does not have to change when the shard count does. + # An explicit `name:` is used verbatim by GitHub rather than having the + # matrix values appended, which is what makes reproducing the old names + # possible at all. # # `if: always()` is load-bearing. Without it this job is SKIPPED when the # shards fail, and GitHub treats a skipped required check as satisfied -- so a @@ -159,19 +204,21 @@ jobs: needs: - shards - py311-import-smoke + - py312-import-smoke + - py313-import-smoke if: always() runs-on: ubuntu-latest strategy: - # Both gates must report their own conclusion. With the default - # fail-fast, the first gate to fail CANCELS its sibling, so a red suite - # reported "test (3.12) failure, test (3.13) cancelled" -- observed on a - # deliberate-failure branch. Cancelled is not success so protection still - # blocks, but which version broke becomes a coin flip, and a required - # check whose conclusion depends on scheduling order is not a gate worth - # trusting. + # Each version gates on its own import-smoke job (or shards for 3.14). + # With the default fail-fast, the first gate to fail CANCELS its sibling, + # so a red suite reported "test (3.12) failure, test (3.13) cancelled" + # -- observed on a deliberate-failure branch. Cancelled is not success so + # protection still blocks, but which version broke becomes a coin flip, + # and a required check whose conclusion depends on scheduling order is + # not a gate worth trusting. fail-fast: false matrix: - python-version: ["3.11", "3.12", "3.13"] + python-version: ["3.11", "3.12", "3.13", "3.14"] steps: - name: Gate on 3.11 import smoke if: matrix.python-version == '3.11' @@ -182,8 +229,26 @@ jobs: exit 1 fi + - name: Gate on 3.12 import smoke + if: matrix.python-version == '3.12' + run: | + echo "py312-import-smoke concluded: ${{ needs.py312-import-smoke.result }}" + if [ "${{ needs.py312-import-smoke.result }}" != "success" ]; then + echo "::error::3.12 import smoke did not pass" + exit 1 + fi + + - name: Gate on 3.13 import smoke + if: matrix.python-version == '3.13' + run: | + echo "py313-import-smoke concluded: ${{ needs.py313-import-smoke.result }}" + if [ "${{ needs.py313-import-smoke.result }}" != "success" ]; then + echo "::error::3.13 import smoke did not pass" + exit 1 + fi + - name: Gate on shard results - if: matrix.python-version != '3.11' + if: matrix.python-version == '3.14' run: | echo "shards concluded: ${{ needs.shards.result }}" if [ "${{ needs.shards.result }}" != "success" ]; then diff --git a/changelog.d/tsk-7za6ag-widen-python-314.md b/changelog.d/tsk-7za6ag-widen-python-314.md new file mode 100644 index 000000000..b35bc34d2 --- /dev/null +++ b/changelog.d/tsk-7za6ag-widen-python-314.md @@ -0,0 +1,12 @@ +### Added + +- Widen `requires-python` to `>=3.11,<3.15`, adding Python 3.14 support. The previous `<3.14` cap was stale: litellm 1.101.0 supports `>=3.10,<3.15`, and Alpine edge ships only Python 3.14, so the old bound forced uv to provision a private 3.13 that could not import Alpine's `py3-onnxruntime` (built for 3.14). + +### Changed + +- `scripts/install-server.sh`: `pick_system_python` now accepts up to 3.14 while still preferring 3.13 when both exist. The stale-venv self-heal check recreates venvs using Python >=3.15 instead of >=3.14. The die message and litellm comment no longer claim 3.14 is unsupported. +- `scripts/install-server.sh`: on Alpine, `py3-onnxruntime` is installed via apk and the venv is created with `--system-site-packages` when the system interpreter is used, so the distro's `onnxruntime` binding (built for the system Python) is importable. + +### Fixed + +- CI shard matrix trimmed to one full sharded leg (3.14) plus per-version import-smoke jobs for 3.11, 3.12, and 3.13, matching the existing `py311-import-smoke` pattern. diff --git a/changelog.d/tsk-qc7ejp-readme-distro-fallbacks.md b/changelog.d/tsk-qc7ejp-readme-distro-fallbacks.md new file mode 100644 index 000000000..a26fea230 --- /dev/null +++ b/changelog.d/tsk-qc7ejp-readme-distro-fallbacks.md @@ -0,0 +1,3 @@ +### Added + +- Added per-distro-family collapsible dependency fallbacks under the controller install one-liner in README.md \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index 205c8e52c..0b8cf5b6c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,12 +7,13 @@ name = "tinyagentos" version = "1.0.0-beta.52" description = "Self-hosted AI agent memory system for low-power hardware" license = { file = "LICENSE" } -# Upper-capped at <3.14 because litellm (the proxy extra, the agent/model proxy -# runtime) supports only >=3.10,<3.14. A fresh distro that defaults python3 to -# 3.14 would otherwise build the venv on 3.14 and abort with "No matching -# distribution found for litellm". The installer also steers the venv to a -# supported interpreter; this cap makes the constraint explicit to pip and uv. -requires-python = ">=3.11,<3.14" +# Upper-capped at <3.15 because the proxy extra's litellm supports +# >=3.10,<3.15 (verified 2026-09-15 against litellm 1.101.0 on PyPI). +# A fresh distro that defaults python3 to 3.14 would otherwise build the +# venv on 3.14 and abort with "No matching distribution found for litellm". +# The installer also steers the venv to a supported interpreter; this cap +# makes the constraint explicit to pip and uv. +requires-python = ">=3.11,<3.15" dependencies = [ # Cap below 0.137: fastapi 0.137.0 regressed include_router so that a # mounted APIRouter contributes none of its routes to the app, leaving diff --git a/scripts/install-server.sh b/scripts/install-server.sh index c94d63994..c7339e2df 100644 --- a/scripts/install-server.sh +++ b/scripts/install-server.sh @@ -127,7 +127,7 @@ ensure_linux_deps() { libtorrent-rasterbar boost sqlite nodejs npm sqlcipher vulkan-tools elif command -v apk >/dev/null 2>&1; then log "installing apk deps" - sudo apk add --no-cache python3 py3-pip git curl libtorrent-rasterbar sqlite nodejs npm sqlcipher-dev vulkan-tools + sudo apk add --no-cache python3 py3-pip git curl libtorrent-rasterbar sqlite nodejs npm sqlcipher-dev vulkan-tools py3-onnxruntime else warn "unrecognised package manager — assuming python3/git/curl/libtorrent/nodejs already present" fi @@ -1485,18 +1485,19 @@ install_rk3588_perf_if_needed # --- python venv + controller deps --------------------------------------- # Resolve a Python the controller deps support: litellm (the proxy extra) needs -# >=3.10,<3.14. Prefer a system interpreter in range; otherwise provision a +# >=3.10,<3.15. Prefer a system interpreter in range; otherwise provision a # standalone 3.13 with uv. The reported failure was a fresh WSL/Ubuntu 26.04 that # ships only Python 3.14 and does not package python3.13, so apt cannot help and # uv (which downloads a standalone CPython on any distro) is the reliable path. -# libtorrent is optional, so the venv is clean -- no system-site-packages binding -# juggling (a 3.13 venv could not import a 3.14-built system binding anyway). +# On Alpine, the system python3 is the expected path: py3-onnxruntime is built +# against the distro's Python, so the venv must use --system-site-packages to +# import it (a uv-provisioned 3.13 cannot import a 3.14-built binding). pick_system_python() { local c v for c in python3.13 python3.12 python3.11 python3; do command -v "$c" >/dev/null 2>&1 || continue v=$("$c" -c 'import sys;print(sys.version_info[0]*100+sys.version_info[1])' 2>/dev/null) || continue - if [ "$v" -ge 311 ] && [ "$v" -lt 314 ]; then echo "$c"; return 0; fi + if [ "$v" -ge 311 ] && [ "$v" -lt 315 ]; then echo "$c"; return 0; fi done return 1 } @@ -1511,13 +1512,13 @@ ensure_uv() { } # Self-heal a stale venv: a re-install over a .venv built with an unsupported -# Python (e.g. a 3.14 venv from an attempt before this fix) would otherwise be -# reused, and `pip install -e .` fails the requires-python <3.14 check. Recreate -# it if its interpreter is out of the supported [3.11,3.14) range. +# Python (e.g. a 3.15 venv from an attempt before this fix) would otherwise be +# reused, and `pip install -e .` fails the requires-python <3.15 check. Recreate +# it if its interpreter is out of the supported [3.11,3.15) range. if [[ -d .venv ]]; then _vv=$(.venv/bin/python -c 'import sys;print(sys.version_info[0]*100+sys.version_info[1])' 2>/dev/null || echo 0) - if [ "$_vv" -lt 311 ] || [ "$_vv" -ge 314 ]; then - warn "existing .venv uses an unsupported Python ($_vv); recreating with a 3.11-3.13 interpreter" + if [ "$_vv" -lt 311 ] || [ "$_vv" -ge 315 ]; then + warn "existing .venv uses an unsupported Python ($_vv); recreating with a 3.11-3.14 interpreter" rm -rf .venv fi fi @@ -1526,13 +1527,17 @@ if [[ ! -d .venv ]]; then PYBIN="$(pick_system_python || true)" if [[ -n "$PYBIN" ]]; then log "creating venv with $PYBIN ($("$PYBIN" --version 2>&1))" - "$PYBIN" -m venv .venv + if command -v apk >/dev/null 2>&1; then + "$PYBIN" -m venv --system-site-packages .venv + else + "$PYBIN" -m venv .venv + fi elif ensure_uv; then - log "no system Python 3.11-3.13; provisioning 3.13 with uv" + log "no system Python 3.11-3.14; provisioning 3.13 with uv" uv python install 3.13 >/dev/null 2>&1 || true uv venv --seed --python 3.13 .venv || die "uv could not create a Python 3.13 venv" else - die "taOS needs Python 3.11-3.13 (litellm has no 3.14 build yet) and uv could not be installed to provision one. Install python3.13 (e.g. 'sudo apt install python3.13 python3.13-venv') and re-run." + die "taOS needs Python 3.11-3.14 and uv could not be installed to provision one. Install python3.13 (e.g. 'sudo apt install python3.13 python3.13-venv') and re-run." fi fi diff --git a/tests/test_install_bootstrap.py b/tests/test_install_bootstrap.py new file mode 100644 index 000000000..d1cf93fa5 --- /dev/null +++ b/tests/test_install_bootstrap.py @@ -0,0 +1,152 @@ +"""Bootstrap sanity: requires-python bound and pick_system_python preference. + +These guard the Python version support contract that the installer and pyproject +share. All offline; the function under test is extracted verbatim from +scripts/install-server.sh so a regression in the production code fails this +gate, not a stale copy. +""" +from __future__ import annotations + +import os +import subprocess +import sys +import tempfile +from pathlib import Path + +import packaging.specifiers +import pytest +import tomllib + +REPO_ROOT = Path(__file__).resolve().parent.parent +INSTALL_SCRIPT = REPO_ROOT / "scripts" / "install-server.sh" + + +def _extract_pick_system_python() -> str: + """Return the body of pick_system_python() from install-server.sh.""" + text = INSTALL_SCRIPT.read_text() + m = None + for line in text.splitlines(): + if line.startswith("pick_system_python()"): + m = line + break + assert m, "pick_system_python() not found in install-server.sh" + start = text.index(m) + depth = 0 + i = start + while i < len(text): + c = text[i] + if c == "{": + depth += 1 + elif c == "}": + depth -= 1 + if depth == 0: + return text[start : i + 1] + i += 1 + raise AssertionError("could not find matching closing brace of pick_system_python()") + + +def test_requires_python_admits_3_11_and_3_14(): + """The pyproject bound must admit both the floor (3.11) and 3.14.""" + with open(REPO_ROOT / "pyproject.toml", "rb") as fh: + doc = tomllib.load(fh) + spec = packaging.specifiers.SpecifierSet(doc["project"]["requires-python"]) + assert "3.11" in spec, "requires-python must admit 3.11" + assert "3.14" in spec, "requires-python must admit 3.14" + assert "3.15" not in spec, "requires-python must not admit 3.15" + assert "3.10" not in spec, "requires-python must not admit 3.10" + + +def _write_mock_python(tmp: Path, name: str, version: int) -> Path: + """Write a mock python executable that prints `version` and exits 0.""" + exe = tmp / name + exe.write_text(f"#!/bin/sh\necho {version}\n") + exe.chmod(0o755) + return exe + + +def _run_pick_system_python(tmp: Path, python_versions: dict[str, int]) -> str | None: + """Run the real pick_system_python against mock python executables. + + `python_versions` maps interpreter name -> numeric version (e.g. 313). + Returns the name of the selected interpreter, or None if none matched. + """ + func_body = _extract_pick_system_python() + lines = [ + "#!/bin/sh", + "set -u", + 'PATH="' + str(tmp) + ':$PATH"', + ] + # The extracted body includes the function header and closing brace. + # We only need to prepend a PATH export; the function definition is complete. + for line in func_body.splitlines(): + lines.append(line) + lines.append("result=$(pick_system_python || true)") + lines.append('printf "%s\\n" "${result:-}"') + script = tmp / "wrapper.sh" + script.write_text("\n".join(lines) + "\n") + script.chmod(0o755) + # Write mock executables + for name, version in python_versions.items(): + _write_mock_python(tmp, name, version) + proc = subprocess.run( + [str(script)], + capture_output=True, + text=True, + timeout=10, + ) + assert proc.returncode == 0, f"wrapper failed: {proc.stderr}" + out = proc.stdout.strip() + return out if out else None + + +@pytest.mark.skipif(os.name != "posix", reason="bash-only test") +def test_pick_system_python_prefers_3_13_over_3_14(): + """When a 3.13 interpreter and a 3.14 python3 both exist, 3.13 must win.""" + with tempfile.TemporaryDirectory() as tmp: + result = _run_pick_system_python( + Path(tmp), + { + "python3.13": 313, + "python3.12": 310, + "python3.11": 310, + "python3": 314, + }, + ) + assert result == "python3.13", ( + f"expected python3.13 when 3.13 and a 3.14 python3 both exist, got {result}" + ) + + +@pytest.mark.skipif(os.name != "posix", reason="bash-only test") +def test_pick_system_python_accepts_3_14_via_system_python(): + """The range check must accept 3.14 (e.g. Alpine's system python3).""" + with tempfile.TemporaryDirectory() as tmp: + result = _run_pick_system_python( + Path(tmp), + { + "python3.13": 310, + "python3.12": 310, + "python3.11": 310, + "python3": 314, + }, + ) + assert result == "python3", ( + f"expected python3 (3.14) as the only in-range interpreter, got {result}" + ) + + +@pytest.mark.skipif(os.name != "posix", reason="bash-only test") +def test_pick_system_python_rejects_3_10(): + """No interpreter below 3.11 must be selected.""" + with tempfile.TemporaryDirectory() as tmp: + result = _run_pick_system_python( + Path(tmp), + { + "python3.13": 310, + "python3.12": 310, + "python3.11": 310, + "python3.10": 310, + "python3": 310, + }, + ) + assert result is None, f"expected no match for 3.10, got {result}"