build: upgrade Python from 3.11 to 3.13 (#11721)

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Jamison LahmanandClaude Opus 4.8 authored and GitHub committed 2026-06-04 08:17:19 +00:00
1 parent 22266e06d5
commit 8ffafb0c8a
32 files changed
+639 -4607

No files matched your search

+4 -3
View File
@@ -118,12 +118,13 @@ RUN apt-get update \
uvx --quiet --from playwright playwright install-deps chromium \
&& apt-get clean && rm -rf /var/lib/apt/lists/*
# Install Python 3.11 via uv into a stable, image-level path so venvs
# Install Python 3.13 via uv into a stable, image-level path so venvs
# created in bind-mounted workspaces survive across `docker run`s.
# Ubuntu 26.04's system Python is 3.14, but onnxruntime only ships
# cp311–cp313 wheels; the repo pins 3.11 via .python-version.
# cp311–cp313 wheels, so the repo pins 3.13 (the top of that range)
# via .python-version.
ENV UV_PYTHON_INSTALL_DIR=/opt/uv/python
RUN uv python install 3.11
RUN uv python install 3.13
# Create non-root dev user with passwordless sudo
RUN useradd -m -s /bin/zsh dev && \
@@ -43,7 +43,7 @@ runs:
- name: Setup Python
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # ratchet:actions/setup-python@v5
with:
python-version: "3.11"
python-version: "3.13"
- name: Create virtual environment
shell: bash
+1 -1
View File
@@ -26,7 +26,7 @@ jobs:
persist-credentials: false
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # ratchet:actions/setup-python@v6
with:
python-version: "3.11"
python-version: "3.13"
- name: Setup Terraform
uses: hashicorp/setup-terraform@5e8dbf3c6d9deaf4193ca7a8fb23f2ac83bb6c85 # ratchet:hashicorp/setup-terraform@v4.0.0
- name: Setup bun
+1 -1
View File
@@ -1 +1 @@
3.11
3.13
+1 -1
View File
@@ -135,7 +135,7 @@ NOTE: Always make sure everything is strictly typed (both in Python and Typescri
### Technology Stack
- **Backend**: Python 3.11, FastAPI, SQLAlchemy, Alembic, Celery
- **Backend**: Python 3.13, FastAPI, SQLAlchemy, Alembic, Celery
- **Frontend**: Next.js 15+, React 18, TypeScript, Tailwind CSS
- **Database**: PostgreSQL with Redis caching
- **Search**: Vespa vector database
+6 -6
View File
@@ -91,7 +91,7 @@ Onyx being a fully functional app, relies on some external software, specificall
### Prerequisites
- **Python 3.11** — If using a lower version, modifications will have to be made to the code. Higher versions may have library compatibility issues.
- **Python 3.13** — Other versions may require code or dependency changes; 3.14 is not yet supported (some dependencies, e.g. `onnxruntime` and CUDA `torch`, do not yet ship 3.14 wheels).
- **Docker** — Required for running external services (Postgres, OpenSearch, Redis, MinIO).
- **Bun** — We use [bun](https://bun.sh) as the JavaScript package manager. Install it from https://bun.sh/docs/installation.
@@ -100,7 +100,7 @@ Onyx being a fully functional app, relies on some external software, specificall
We use [uv](https://docs.astral.sh/uv/) and recommend creating a [virtual environment](https://docs.astral.sh/uv/pip/environments/#using-a-virtual-environment).
```bash
uv venv .venv --python 3.11
uv venv .venv --python 3.13
source .venv/bin/activate
```
@@ -312,16 +312,16 @@ docker compose up -d --build
### Setting up Python
Ensure [Homebrew](https://brew.sh/) is already set up, then install Python 3.11:
Ensure [Homebrew](https://brew.sh/) is already set up, then install Python 3.13:
```bash
brew install python@3.11
brew install python@3.13
```
Add Python 3.11 to your path by adding the following line to `~/.zshrc`:
Add Python 3.13 to your path by adding the following line to `~/.zshrc`:
```
export PATH="$(brew --prefix)/opt/python@3.11/libexec/bin:$PATH"
export PATH="$(brew --prefix)/opt/python@3.13/libexec/bin:$PATH"
```
> **Note:** You will need to open a new terminal for the path change above to take effect.
+4 -4
View File
@@ -9,7 +9,7 @@
# pull-through cache (<account>.dkr.ecr.us-east-2.amazonaws.com/docker-hub) to avoid Docker
# Hub rate limits. Declared before the first FROM so it applies to every base image below.
ARG BASE_IMAGE_REGISTRY=docker.io
FROM ${BASE_IMAGE_REGISTRY}/library/python:3.11-slim@sha256:6d85378d88a19cd4d76079817532d62232be95757cb45945a99fec8e8084b9c2 AS builder
FROM ${BASE_IMAGE_REGISTRY}/library/python:3.13-slim@sha256:b04b5d7233d2ad9c379e22ea8927cd1378cd15c60d4ef876c065b25ea8fb3bf3 AS builder
COPY --from=ghcr.io/astral-sh/uv:0.9.9 /uv /uvx /bin/
@@ -47,7 +47,7 @@ RUN uv pip install --system --no-cache-dir --no-deps --require-hashes \
-r /tmp/ee-requirements.txt && \
pip uninstall -y py && \
# https://github.com/tornadoweb/tornado/issues/3107
rm -f /usr/local/lib/python3.11/site-packages/tornado/test/test.key && \
rm -f /usr/local/lib/python3.13/site-packages/tornado/test/test.key && \
rm -rf ~/.cache/uv /tmp/*.txt
@@ -57,7 +57,7 @@ RUN uv pip install --system --no-cache-dir --no-deps --require-hashes \
# The actual shipped image. Installs only runtime system libraries and copies the already-built
# Python packages from the builder stage.
####################################################################################################
FROM ${BASE_IMAGE_REGISTRY}/library/python:3.11-slim@sha256:6d85378d88a19cd4d76079817532d62232be95757cb45945a99fec8e8084b9c2 AS runtime
FROM ${BASE_IMAGE_REGISTRY}/library/python:3.13-slim@sha256:b04b5d7233d2ad9c379e22ea8927cd1378cd15c60d4ef876c065b25ea8fb3bf3 AS runtime
LABEL com.danswer.maintainer="founders@onyx.app"
LABEL com.danswer.description="This image is the web/frontend container of Onyx which \
@@ -132,7 +132,7 @@ ENV PATH="/root/.opencode/bin:${PATH}"
# celery workers/beat, alembic migrations, supervisord, Playwright). Both stages share the same base
# image digest, so paths and ABI match. Listing the scripts explicitly keeps the base interpreter
# binaries and any build-stage executables out of the shipped /usr/local/bin.
COPY --from=builder /usr/local/lib/python3.11/site-packages /usr/local/lib/python3.11/site-packages
COPY --from=builder /usr/local/lib/python3.13/site-packages /usr/local/lib/python3.13/site-packages
COPY --from=builder \
/usr/local/bin/alembic \
/usr/local/bin/celery \
+1 -1
View File
@@ -4,7 +4,7 @@
ARG BASE_IMAGE_REGISTRY=docker.io
# Base stage with dependencies
FROM ${BASE_IMAGE_REGISTRY}/library/python:3.11-slim@sha256:6d85378d88a19cd4d76079817532d62232be95757cb45945a99fec8e8084b9c2 AS base
FROM ${BASE_IMAGE_REGISTRY}/library/python:3.13-slim@sha256:b04b5d7233d2ad9c379e22ea8927cd1378cd15c60d4ef876c065b25ea8fb3bf3 AS base
ENV ONYX_RUNNING_IN_DOCKER="true" \
HF_HOME=/app/.cache/huggingface
@@ -49,7 +49,7 @@ def upgrade() -> None:
"time_updated",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
server_onupdate=sa.text("now()"), # ty: ignore[invalid-argument-type]
server_onupdate=sa.text("now()"),
nullable=True,
),
sa.Column(
+2 -2
View File
@@ -28,7 +28,7 @@ def fetch_query_analytics(
stmt = (
select(
func.count(ChatMessage.id),
func.sum(case((ChatMessageFeedback.is_positive, 1), else_=0)),
func.sum(case((ChatMessageFeedback.is_positive, 1), else_=0)), # ty: ignore[invalid-argument-type]
func.sum(
case(
(ChatMessageFeedback.is_positive == False, 1), # noqa: E712
@@ -64,7 +64,7 @@ def fetch_per_user_query_analytics(
stmt = (
select(
func.count(ChatMessage.id),
func.sum(case((ChatMessageFeedback.is_positive, 1), else_=0)),
func.sum(case((ChatMessageFeedback.is_positive, 1), else_=0)), # ty: ignore[invalid-argument-type]
func.sum(
case(
(ChatMessageFeedback.is_positive == False, 1), # noqa: E712
+2 -2
View File
@@ -165,9 +165,9 @@ def get_used_seats(tenant_id: str | None = None) -> int:
select(func.count())
.select_from(User)
.where(
User.is_active == True, # noqa: E712
User.is_active == True, # noqa: E712 # ty: ignore[invalid-argument-type]
User.role != UserRole.EXT_PERM_USER,
User.email != ANONYMOUS_USER_EMAIL,
User.email != ANONYMOUS_USER_EMAIL, # ty: ignore[invalid-argument-type]
User.account_type != AccountType.SERVICE_ACCOUNT,
)
)
+1 -3
View File
@@ -16,9 +16,7 @@ logger = setup_logger()
def get_pg_conn_from_session(db_session: Session) -> connection:
return (
db_session.connection().connection.connection # ty: ignore[unresolved-attribute]
)
return db_session.connection().connection.connection
def create_populate_lobj(
+2 -2
View File
@@ -81,7 +81,7 @@ def get_live_users_count(db_session: Session) -> int:
This does NOT include invited users, "users" pulled in
from external connectors, or API keys.
"""
count_stmt = func.count(User.id)
count_stmt = func.count(User.id) # ty: ignore[invalid-argument-type]
select_stmt = select(count_stmt)
select_stmt_w_filters = _add_live_user_count_where_clause(select_stmt, False)
user_count = db_session.scalar(select_stmt_w_filters)
@@ -92,7 +92,7 @@ def get_live_users_count(db_session: Session) -> int:
async def get_user_count(only_admin_users: bool = False) -> int:
async with get_async_session_context_manager() as session:
count_stmt = func.count(User.id)
count_stmt = func.count(User.id) # ty: ignore[invalid-argument-type]
stmt = select(count_stmt)
stmt_w_filters = _add_live_user_count_where_clause(stmt, only_admin_users)
user_count = await session.scalar(stmt_w_filters)
+1 -1
View File
@@ -719,7 +719,7 @@ def upsert_documents(
# Use COALESCE to preserve existing permissions when new values are NULL.
# This prevents subsequent indexing runs (which don't fetch permissions)
# from overwriting permissions set by permission sync jobs.
update_set.update(
update_set.update( # ty: ignore[no-matching-overload]
{
"external_user_emails": func.coalesce(
insert_stmt.excluded.external_user_emails,
+1 -1
View File
@@ -178,7 +178,7 @@ def batch_create_notifications(
result = db_session.execute(stmt)
inserted_ids = set(result.scalars())
db_session.commit()
return inserted_ids
return inserted_ids # ty: ignore[invalid-return-type]
def update_notification_last_shown(
+2 -2
View File
@@ -57,9 +57,9 @@ def _discover_encrypted_columns() -> list[tuple[type, str, list[str], bool]]:
for prop in mapper.column_attrs:
for col in prop.columns:
if isinstance(col.type, EncryptedJson):
results.append((model_cls, prop.key, pk_names, True))
results.append((model_cls, prop.key, pk_names, True)) # ty: ignore[invalid-argument-type]
elif isinstance(col.type, EncryptedString):
results.append((model_cls, prop.key, pk_names, False))
results.append((model_cls, prop.key, pk_names, False)) # ty: ignore[invalid-argument-type]
return results
+1 -4
View File
@@ -68,10 +68,7 @@ def is_fk_violation(exc: IntegrityError) -> bool:
def model_to_dict(model: Base) -> dict[str, Any]:
return {
c.key: getattr(model, c.key)
for c in inspect(model).mapper.column_attrs # ty: ignore[unresolved-attribute]
}
return {c.key: getattr(model, c.key) for c in inspect(model).mapper.column_attrs}
RETRYABLE_PG_CODES = {
@@ -77,7 +77,7 @@ class HuggingFaceTokenizer(BaseTokenizer):
"""
Encode a string using the HuggingFaceTokenizer, but if it fails,
encode the string as ASCII and decode it back to a string. This helps
in cases where the string has weird characters like \udeb4.
in cases where the string has weird characters like U+DEB4.
"""
try:
return self.encoder.encode(string, add_special_tokens=False)
+1 -1
View File
@@ -282,7 +282,7 @@ def remove_invalid_unicode_chars(text: str) -> str:
This handles:
- Control characters (except tab, newline, carriage return)
- Unpaired UTF-16 surrogates (e.g. \udc00) that cause 'surrogates not allowed' errors
- Unpaired UTF-16 surrogates (e.g. U+DC00) that cause 'surrogates not allowed' errors
- Unicode non-characters
"""
return _INVALID_UNICODE_CHARS_RE.sub("", text)
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
@@ -131,7 +131,7 @@ def _skill_table_column_keys(model: type[Any]) -> list[str]:
def _skill_table_pk_keys(model: type[Any]) -> list[str]:
return [col.key for col in class_mapper(model).primary_key]
return [col.key for col in class_mapper(model).primary_key] # ty: ignore[invalid-return-type]
def _snapshot_skill_tables(
@@ -1,5 +1,5 @@
# /// script
# requires-python = ">=3.11"
# requires-python = ">=3.13"
# dependencies = [
# "fastapi>=0.110",
# "uvicorn>=0.29",
@@ -2,7 +2,7 @@
# pull-through cache (<account>.dkr.ecr.us-east-2.amazonaws.com/docker-hub) to avoid Docker
# Hub rate limits.
ARG BASE_IMAGE_REGISTRY=docker.io
FROM ${BASE_IMAGE_REGISTRY}/library/python:3.11-slim@sha256:6d85378d88a19cd4d76079817532d62232be95757cb45945a99fec8e8084b9c2
FROM ${BASE_IMAGE_REGISTRY}/library/python:3.13-slim@sha256:b04b5d7233d2ad9c379e22ea8927cd1378cd15c60d4ef876c065b25ea8fb3bf3
WORKDIR /app
@@ -239,15 +239,19 @@ def test_contextual_rag(
assert "tag1" in chunks[0].metadata_suffix_keyword
assert "tag2" in chunks[0].metadata_suffix_semantic
doc_summary = "Test1" if enable_contextual_rag else ""
chunk_context = ""
count = 2
for chunk in chunks:
if enable_contextual_rag:
chunk_context = f"Test{count}"
count += 1
assert chunk.doc_summary == doc_summary
assert chunk.chunk_context == chunk_context
# The doc summary is computed once (the first LLM call) and shared by every
# chunk. The per-chunk context calls then run in parallel
# (run_functions_tuples_in_parallel), so the mock's "TestN" counter is assigned
# to chunks in nondeterministic order — assert the SET of contexts rather than a
# per-chunk ordering.
if enable_contextual_rag:
assert all(chunk.doc_summary == "Test1" for chunk in chunks)
assert {chunk.chunk_context for chunk in chunks} == {
f"Test{n}" for n in range(2, 2 + len(chunks))
}
else:
assert all(chunk.doc_summary == "" for chunk in chunks)
assert all(chunk.chunk_context == "" for chunk in chunks)
# ---------------------------------------------------------------------------
@@ -1,6 +1,7 @@
from __future__ import annotations
import time
from collections.abc import Callable
from unittest.mock import MagicMock
from unittest.mock import patch
@@ -228,6 +229,27 @@ def _make_mock_response(
return resp
def _respond_by_url(
responses: dict[str, MagicMock | Exception],
) -> Callable[..., MagicMock]:
"""Build a ``side_effect`` that maps each URL to its response, raising any
mapped exception — independent of call order.
``OnyxWebCrawler.contents`` fetches concurrently via a ThreadPoolExecutor, so
a positional ``side_effect`` list binds responses to URLs nondeterministically
(whichever thread calls ``ssrf_safe_get`` first consumes the first element).
Keying on the URL keeps the failure-isolation assertions deterministic.
"""
def _side_effect(url: str, *_args: object, **_kwargs: object) -> MagicMock:
result = responses[url]
if isinstance(result, Exception):
raise result
return result
return _side_effect
class TestParallelExecution:
"""Verify that contents() fetches URLs in parallel."""
@@ -274,8 +296,15 @@ class TestFailureIsolation:
good_resp = _make_mock_response()
bad_resp = _make_mock_response(status_code=500)
# First and third URLs succeed, second fails
mock_get.side_effect = [good_resp, bad_resp, good_resp]
# First and third URLs succeed, second fails. Keyed by URL because the
# crawler fetches concurrently, so call order is nondeterministic.
mock_get.side_effect = _respond_by_url(
{
"http://a.com": good_resp,
"http://b.com": bad_resp,
"http://c.com": good_resp,
}
)
crawler = OnyxWebCrawler()
results = crawler.contents(["http://a.com", "http://b.com", "http://c.com"])
@@ -289,12 +318,14 @@ class TestFailureIsolation:
def test_exception_doesnt_kill_batch(self, mock_get: MagicMock) -> None:
good_resp = _make_mock_response()
# Second URL raises an exception
mock_get.side_effect = [
good_resp,
RuntimeError("connection reset"),
_make_mock_response(),
]
# Second URL raises an exception. Keyed by URL (concurrent fetch).
mock_get.side_effect = _respond_by_url(
{
"http://a.com": good_resp,
"http://b.com": RuntimeError("connection reset"),
"http://c.com": _make_mock_response(),
}
)
crawler = OnyxWebCrawler()
results = crawler.contents(["http://a.com", "http://b.com", "http://c.com"])
@@ -309,11 +340,13 @@ class TestFailureIsolation:
from onyx.utils.url import SSRFException
good_resp = _make_mock_response()
mock_get.side_effect = [
good_resp,
SSRFException("blocked"),
_make_mock_response(),
]
mock_get.side_effect = _respond_by_url(
{
"http://a.com": good_resp,
"http://internal.local": SSRFException("blocked"),
"http://c.com": _make_mock_response(),
}
)
crawler = OnyxWebCrawler()
results = crawler.contents(
+1 -1
View File
@@ -1,3 +1,3 @@
version = 1
revision = 3
requires-python = ">=3.11"
requires-python = ">=3.13"
+1 -1
View File
@@ -13,7 +13,7 @@ need a sandbox.
## Prerequisites
Builds on the CONTRIBUTING.md prereqs (Python 3.11, uv, Node.js 22, the venv,
Builds on the CONTRIBUTING.md prereqs (Python 3.13, uv, Node.js 22, the venv,
`.vscode/.env`). Docker Desktop must be running with at least 8 CPU / 16 GB
allocated.
+14 -12
View File
@@ -5,7 +5,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "onyx"
version = "0.0.0"
requires-python = ">=3.11"
requires-python = ">=3.13"
# Shared dependencies between backend and model_server
dependencies = [
"aioboto3==15.1.0",
@@ -43,7 +43,7 @@ backend = [
"croniter==6.2.2",
"cron-descriptor==2.0.8",
"dask==2026.1.1",
"discord.py==2.4.0",
"discord.py==2.7.1",
"distributed==2026.1.1",
"fastapi-users==15.0.4",
"fastapi-users-db-sqlalchemy==7.0.0",
@@ -77,10 +77,13 @@ backend = [
# NOTE: This is frozen to avoid https://foss.heptapod.net/openpyxl/openpyxl/-/issues/2147
"openpyxl==3.0.10",
"opensearch-py==3.0.0",
"passlib==1.7.4",
# libpass is the maintained drop-in fork of passlib (same `passlib.*`
# import namespace). passlib 1.7.4 is unmaintained and imports the stdlib
# `crypt` module, which was removed in Python 3.13.
"libpass==1.9.3",
"playwright==1.58.0",
"psutil==7.1.3",
"psycopg2-binary==2.9.9",
"psycopg2-binary==2.9.10",
"puremagic==1.28",
"pyairtable==3.0.1",
"pycryptodome==3.19.1",
@@ -97,7 +100,7 @@ backend = [
"requests-oauthlib==1.3.1",
"simple-salesforce==1.12.6",
"slack-sdk==3.20.2",
"SQLAlchemy==2.0.15",
"SQLAlchemy==2.0.50",
"starlette==0.49.3",
"supervisor==4.3.0",
"RapidFuzz==3.14.5",
@@ -112,8 +115,7 @@ backend = [
"stripe==10.12.0",
"urllib3==2.7.0",
"mistune==3.2.1",
# Pinned: 11.1+ requires Python 3.12.
"mitmproxy==11.0.2",
"mitmproxy==12.2.3",
"sendgrid==6.12.5",
"exa_py==1.15.4",
"braintrust==0.3.9",
@@ -203,14 +205,14 @@ override-dependencies = [
"cryptography>=42.0,<50.0",
"h2>=4.3.0,<5.0",
"hyperframe>=6.1.0,<7.0",
# mitmproxy 11.0.2 caps tornado at <=6.4.2; 11.1+/12.x that bump it require
# Python 3.12 (we're on 3.11). Override to pull in the 6.5 series, which
# mitmproxy 12.1.1 pins to anyway, for CVE-2025-47287 (multipart logging DoS).
# mitmproxy 12.x already pins tornado to the 6.5 series; keep this floor
# explicit so a future resolver change can't drop back to the <=6.4.2 that
# mitmproxy 11.x capped to (CVE-2025-47287, multipart logging DoS).
"tornado>=6.5.0,<7.0",
]
[tool.ty.environment]
python-version = "3.11"
python-version = "3.13"
root = ["./backend"]
[tool.ty.src]
@@ -244,7 +246,7 @@ typeCheckingMode = "off"
[tool.ruff]
line-length = 88
target-version = "py311"
target-version = "py313"
[tool.ruff.lint]
ignore = [
Generated
+244 -1353
View File
File diff suppressed because it is too large. Load diff