mirror of
https://github.com/onyx-dot-app/onyx.git
synced 2026-02-24 11:15:47 +00:00
Compare commits
26 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a4387f230b | ||
|
|
d91e452658 | ||
|
|
dd274f8667 | ||
|
|
2c82f0da16 | ||
|
|
26101636f2 | ||
|
|
5e2c0c6cf4 | ||
|
|
33b64db498 | ||
|
|
b925cc1a56 | ||
|
|
bac4b7c945 | ||
|
|
6f6ef1e657 | ||
|
|
885c69f460 | ||
|
|
4b837303ff | ||
|
|
d856a9befb | ||
|
|
adade353c5 | ||
|
|
3cb6ec2f85 | ||
|
|
691eebf00a | ||
|
|
905b6633e6 | ||
|
|
fd088196ff | ||
|
|
cafbf5b8be | ||
|
|
1235181559 | ||
|
|
caa2e45632 | ||
|
|
9c62e03120 | ||
|
|
0937305064 | ||
|
|
e4c06570e3 | ||
|
|
78fc7c86d7 | ||
|
|
84d3aea847 |
2
.github/pull_request_template.md
vendored
2
.github/pull_request_template.md
vendored
@@ -8,5 +8,5 @@
|
||||
|
||||
## Additional Options
|
||||
|
||||
- [ ] [Required] I have considered whether this PR needs to be cherry-picked to the latest beta branch.
|
||||
- [ ] [Optional] Please cherry-pick this PR to the latest release version.
|
||||
- [ ] [Optional] Override Linear Check
|
||||
|
||||
79
.github/workflows/post-merge-beta-cherry-pick.yml
vendored
Normal file
79
.github/workflows/post-merge-beta-cherry-pick.yml
vendored
Normal file
@@ -0,0 +1,79 @@
|
||||
name: Post-Merge Beta Cherry-Pick
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
cherry-pick-to-latest-release:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 45
|
||||
steps:
|
||||
- name: Resolve merged PR and checkbox state
|
||||
id: gate
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
# For the commit that triggered this workflow (HEAD on main), fetch all
|
||||
# associated PRs and keep only the PR that was actually merged into main
|
||||
# with this exact merge commit SHA.
|
||||
pr_numbers="$(gh api "repos/${GITHUB_REPOSITORY}/commits/${GITHUB_SHA}/pulls" | jq -r --arg sha "${GITHUB_SHA}" '.[] | select(.merged_at != null and .base.ref == "main" and .merge_commit_sha == $sha) | .number')"
|
||||
match_count="$(printf '%s\n' "$pr_numbers" | sed '/^[[:space:]]*$/d' | wc -l | tr -d ' ')"
|
||||
pr_number="$(printf '%s\n' "$pr_numbers" | sed '/^[[:space:]]*$/d' | head -n 1)"
|
||||
|
||||
if [ "${match_count}" -gt 1 ]; then
|
||||
echo "::warning::Multiple merged PRs matched commit ${GITHUB_SHA}. Using PR #${pr_number}."
|
||||
fi
|
||||
|
||||
if [ -z "$pr_number" ]; then
|
||||
echo "No merged PR associated with commit ${GITHUB_SHA}; skipping."
|
||||
echo "should_cherrypick=false" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Read the PR body and check whether the helper checkbox is checked.
|
||||
pr_body="$(gh api "repos/${GITHUB_REPOSITORY}/pulls/${pr_number}" --jq '.body // ""')"
|
||||
echo "pr_number=$pr_number" >> "$GITHUB_OUTPUT"
|
||||
|
||||
if echo "$pr_body" | grep -qiE "\\[x\\][[:space:]]*(\\[[^]]+\\][[:space:]]*)?Please cherry-pick this PR to the latest release version"; then
|
||||
echo "should_cherrypick=true" >> "$GITHUB_OUTPUT"
|
||||
echo "Cherry-pick checkbox checked for PR #${pr_number}."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "should_cherrypick=false" >> "$GITHUB_OUTPUT"
|
||||
echo "Cherry-pick checkbox not checked for PR #${pr_number}. Skipping."
|
||||
|
||||
- name: Checkout repository
|
||||
if: steps.gate.outputs.should_cherrypick == 'true'
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # ratchet:actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: true
|
||||
ref: main
|
||||
|
||||
- name: Install the latest version of uv
|
||||
if: steps.gate.outputs.should_cherrypick == 'true'
|
||||
uses: astral-sh/setup-uv@61cb8a9741eeb8a550a1b8544337180c0fc8476b # ratchet:astral-sh/setup-uv@v7
|
||||
with:
|
||||
enable-cache: false
|
||||
version: "0.9.9"
|
||||
|
||||
- name: Configure git identity
|
||||
if: steps.gate.outputs.should_cherrypick == 'true'
|
||||
run: |
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||
|
||||
- name: Create cherry-pick PR to latest release
|
||||
if: steps.gate.outputs.should_cherrypick == 'true'
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
uv run --no-sync --with onyx-devtools ods cherry-pick "${GITHUB_SHA}" --yes --no-verify
|
||||
28
.github/workflows/pr-beta-cherrypick-check.yml
vendored
28
.github/workflows/pr-beta-cherrypick-check.yml
vendored
@@ -1,28 +0,0 @@
|
||||
name: Require beta cherry-pick consideration
|
||||
concurrency:
|
||||
group: Require-Beta-Cherrypick-Consideration-${{ github.workflow }}-${{ github.head_ref || github.event.workflow_run.head_branch || github.run_id }}
|
||||
cancel-in-progress: true
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, edited, reopened, synchronize]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
beta-cherrypick-check:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 45
|
||||
steps:
|
||||
- name: Check PR body for beta cherry-pick consideration
|
||||
env:
|
||||
PR_BODY: ${{ github.event.pull_request.body }}
|
||||
run: |
|
||||
if echo "$PR_BODY" | grep -qiE "\\[x\\][[:space:]]*\\[Required\\][[:space:]]*I have considered whether this PR needs to be cherry[- ]picked to the latest beta branch"; then
|
||||
echo "Cherry-pick consideration box is checked. Check passed."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "::error::Please check the 'I have considered whether this PR needs to be cherry-picked to the latest beta branch' box in the PR description."
|
||||
exit 1
|
||||
@@ -21,15 +21,14 @@ import sys
|
||||
import threading
|
||||
import time
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
from typing import List, NamedTuple
|
||||
from typing import NamedTuple
|
||||
|
||||
from alembic.config import Config
|
||||
from alembic.script import ScriptDirectory
|
||||
from sqlalchemy import text
|
||||
|
||||
from onyx.db.engine.sql_engine import is_valid_schema_name
|
||||
from onyx.db.engine.sql_engine import SqlEngine
|
||||
from onyx.db.engine.tenant_utils import get_all_tenant_ids
|
||||
from onyx.db.engine.tenant_utils import get_schemas_needing_migration
|
||||
from shared_configs.configs import TENANT_ID_PREFIX
|
||||
|
||||
|
||||
@@ -105,56 +104,6 @@ def get_head_revision() -> str | None:
|
||||
return script.get_current_head()
|
||||
|
||||
|
||||
def get_schemas_needing_migration(
|
||||
tenant_schemas: List[str], head_rev: str
|
||||
) -> List[str]:
|
||||
"""Return only schemas whose current alembic version is not at head."""
|
||||
if not tenant_schemas:
|
||||
return []
|
||||
|
||||
engine = SqlEngine.get_engine()
|
||||
|
||||
with engine.connect() as conn:
|
||||
# Find which schemas actually have an alembic_version table
|
||||
rows = conn.execute(
|
||||
text(
|
||||
"SELECT table_schema FROM information_schema.tables "
|
||||
"WHERE table_name = 'alembic_version' "
|
||||
"AND table_schema = ANY(:schemas)"
|
||||
),
|
||||
{"schemas": tenant_schemas},
|
||||
)
|
||||
schemas_with_table = set(row[0] for row in rows)
|
||||
|
||||
# Schemas without the table definitely need migration
|
||||
needs_migration = [s for s in tenant_schemas if s not in schemas_with_table]
|
||||
|
||||
if not schemas_with_table:
|
||||
return needs_migration
|
||||
|
||||
# Validate schema names before interpolating into SQL
|
||||
for schema in schemas_with_table:
|
||||
if not is_valid_schema_name(schema):
|
||||
raise ValueError(f"Invalid schema name: {schema}")
|
||||
|
||||
# Single query to get every schema's current revision at once.
|
||||
# Use integer tags instead of interpolating schema names into
|
||||
# string literals to avoid quoting issues.
|
||||
schema_list = list(schemas_with_table)
|
||||
union_parts = [
|
||||
f'SELECT {i} AS idx, version_num FROM "{schema}".alembic_version'
|
||||
for i, schema in enumerate(schema_list)
|
||||
]
|
||||
rows = conn.execute(text(" UNION ALL ".join(union_parts)))
|
||||
version_by_schema = {schema_list[row[0]]: row[1] for row in rows}
|
||||
|
||||
needs_migration.extend(
|
||||
s for s in schemas_with_table if version_by_schema.get(s) != head_rev
|
||||
)
|
||||
|
||||
return needs_migration
|
||||
|
||||
|
||||
def run_migrations_parallel(
|
||||
schemas: list[str],
|
||||
max_workers: int,
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
"""code interpreter seed
|
||||
|
||||
Revision ID: 07b98176f1de
|
||||
Revises: 7cb492013621
|
||||
Create Date: 2026-02-23 15:55:07.606784
|
||||
|
||||
"""
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = "07b98176f1de"
|
||||
down_revision = "7cb492013621"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# Seed the single instance of code_interpreter_server
|
||||
# NOTE: There should only exist at most and at minimum 1 code_interpreter_server row
|
||||
op.execute(
|
||||
sa.text("INSERT INTO code_interpreter_server (server_enabled) VALUES (true)")
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.execute(sa.text("DELETE FROM code_interpreter_server"))
|
||||
@@ -127,9 +127,14 @@ class ScimDAL(DAL):
|
||||
self,
|
||||
external_id: str,
|
||||
user_id: UUID,
|
||||
scim_username: str | None = None,
|
||||
) -> ScimUserMapping:
|
||||
"""Create a mapping between a SCIM externalId and an Onyx user."""
|
||||
mapping = ScimUserMapping(external_id=external_id, user_id=user_id)
|
||||
mapping = ScimUserMapping(
|
||||
external_id=external_id,
|
||||
user_id=user_id,
|
||||
scim_username=scim_username,
|
||||
)
|
||||
self._session.add(mapping)
|
||||
self._session.flush()
|
||||
return mapping
|
||||
@@ -248,11 +253,11 @@ class ScimDAL(DAL):
|
||||
scim_filter: ScimFilter | None,
|
||||
start_index: int = 1,
|
||||
count: int = 100,
|
||||
) -> tuple[list[tuple[User, str | None]], int]:
|
||||
) -> tuple[list[tuple[User, ScimUserMapping | None]], int]:
|
||||
"""Query users with optional SCIM filter and pagination.
|
||||
|
||||
Returns:
|
||||
A tuple of (list of (user, external_id) pairs, total_count).
|
||||
A tuple of (list of (user, mapping) pairs, total_count).
|
||||
|
||||
Raises:
|
||||
ValueError: If the filter uses an unsupported attribute.
|
||||
@@ -292,33 +297,104 @@ class ScimDAL(DAL):
|
||||
users = list(
|
||||
self._session.scalars(
|
||||
query.order_by(User.id).offset(offset).limit(count) # type: ignore[arg-type]
|
||||
).all()
|
||||
)
|
||||
.unique()
|
||||
.all()
|
||||
)
|
||||
|
||||
# Batch-fetch external IDs to avoid N+1 queries
|
||||
ext_id_map = self._get_user_external_ids([u.id for u in users])
|
||||
return [(u, ext_id_map.get(u.id)) for u in users], total
|
||||
# Batch-fetch SCIM mappings to avoid N+1 queries
|
||||
mapping_map = self._get_user_mappings_batch([u.id for u in users])
|
||||
return [(u, mapping_map.get(u.id)) for u in users], total
|
||||
|
||||
def sync_user_external_id(self, user_id: UUID, new_external_id: str | None) -> None:
|
||||
def sync_user_external_id(
|
||||
self,
|
||||
user_id: UUID,
|
||||
new_external_id: str | None,
|
||||
scim_username: str | None = None,
|
||||
) -> None:
|
||||
"""Create, update, or delete the external ID mapping for a user."""
|
||||
mapping = self.get_user_mapping_by_user_id(user_id)
|
||||
if new_external_id:
|
||||
if mapping:
|
||||
if mapping.external_id != new_external_id:
|
||||
mapping.external_id = new_external_id
|
||||
if scim_username is not None:
|
||||
mapping.scim_username = scim_username
|
||||
else:
|
||||
self.create_user_mapping(external_id=new_external_id, user_id=user_id)
|
||||
self.create_user_mapping(
|
||||
external_id=new_external_id,
|
||||
user_id=user_id,
|
||||
scim_username=scim_username,
|
||||
)
|
||||
elif mapping:
|
||||
self.delete_user_mapping(mapping.id)
|
||||
|
||||
def _get_user_external_ids(self, user_ids: list[UUID]) -> dict[UUID, str]:
|
||||
"""Batch-fetch external IDs for a list of user IDs."""
|
||||
def _get_user_mappings_batch(
|
||||
self, user_ids: list[UUID]
|
||||
) -> dict[UUID, ScimUserMapping]:
|
||||
"""Batch-fetch SCIM user mappings keyed by user ID."""
|
||||
if not user_ids:
|
||||
return {}
|
||||
mappings = self._session.scalars(
|
||||
select(ScimUserMapping).where(ScimUserMapping.user_id.in_(user_ids))
|
||||
).all()
|
||||
return {m.user_id: m.external_id for m in mappings}
|
||||
return {m.user_id: m for m in mappings}
|
||||
|
||||
def get_user_groups(self, user_id: UUID) -> list[tuple[int, str]]:
|
||||
"""Get groups a user belongs to as ``(group_id, group_name)`` pairs.
|
||||
|
||||
Excludes groups marked for deletion.
|
||||
"""
|
||||
rels = self._session.scalars(
|
||||
select(User__UserGroup).where(User__UserGroup.user_id == user_id)
|
||||
).all()
|
||||
|
||||
group_ids = [r.user_group_id for r in rels]
|
||||
if not group_ids:
|
||||
return []
|
||||
|
||||
groups = self._session.scalars(
|
||||
select(UserGroup).where(
|
||||
UserGroup.id.in_(group_ids),
|
||||
UserGroup.is_up_for_deletion.is_(False),
|
||||
)
|
||||
).all()
|
||||
return [(g.id, g.name) for g in groups]
|
||||
|
||||
def get_users_groups_batch(
|
||||
self, user_ids: list[UUID]
|
||||
) -> dict[UUID, list[tuple[int, str]]]:
|
||||
"""Batch-fetch group memberships for multiple users.
|
||||
|
||||
Returns a mapping of ``user_id → [(group_id, group_name), ...]``.
|
||||
Avoids N+1 queries when building user list responses.
|
||||
"""
|
||||
if not user_ids:
|
||||
return {}
|
||||
|
||||
rels = self._session.scalars(
|
||||
select(User__UserGroup).where(User__UserGroup.user_id.in_(user_ids))
|
||||
).all()
|
||||
|
||||
group_ids = list({r.user_group_id for r in rels})
|
||||
if not group_ids:
|
||||
return {}
|
||||
|
||||
groups = self._session.scalars(
|
||||
select(UserGroup).where(
|
||||
UserGroup.id.in_(group_ids),
|
||||
UserGroup.is_up_for_deletion.is_(False),
|
||||
)
|
||||
).all()
|
||||
groups_by_id = {g.id: g.name for g in groups}
|
||||
|
||||
result: dict[UUID, list[tuple[int, str]]] = {}
|
||||
for r in rels:
|
||||
if r.user_id and r.user_group_id in groups_by_id:
|
||||
result.setdefault(r.user_id, []).append(
|
||||
(r.user_group_id, groups_by_id[r.user_group_id])
|
||||
)
|
||||
return result
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Group mapping operations
|
||||
@@ -483,9 +559,13 @@ class ScimDAL(DAL):
|
||||
if not user_ids:
|
||||
return []
|
||||
|
||||
users = self._session.scalars(
|
||||
select(User).where(User.id.in_(user_ids)) # type: ignore[attr-defined]
|
||||
).all()
|
||||
users = (
|
||||
self._session.scalars(
|
||||
select(User).where(User.id.in_(user_ids)) # type: ignore[attr-defined]
|
||||
)
|
||||
.unique()
|
||||
.all()
|
||||
)
|
||||
users_by_id = {u.id: u for u in users}
|
||||
|
||||
return [
|
||||
@@ -504,9 +584,13 @@ class ScimDAL(DAL):
|
||||
"""
|
||||
if not uuids:
|
||||
return []
|
||||
existing_users = self._session.scalars(
|
||||
select(User).where(User.id.in_(uuids)) # type: ignore[attr-defined]
|
||||
).all()
|
||||
existing_users = (
|
||||
self._session.scalars(
|
||||
select(User).where(User.id.in_(uuids)) # type: ignore[attr-defined]
|
||||
)
|
||||
.unique()
|
||||
.all()
|
||||
)
|
||||
existing_ids = {u.id for u in existing_users}
|
||||
return [uid for uid in uuids if uid not in existing_ids]
|
||||
|
||||
|
||||
@@ -34,7 +34,7 @@ class SendSearchQueryRequest(BaseModel):
|
||||
filters: BaseFilters | None = None
|
||||
num_docs_fed_to_llm_selection: int | None = None
|
||||
run_query_expansion: bool = False
|
||||
num_hits: int = 50
|
||||
num_hits: int = 30
|
||||
|
||||
include_content: bool = False
|
||||
stream: bool = False
|
||||
|
||||
@@ -26,12 +26,10 @@ from sqlalchemy.orm import Session
|
||||
from ee.onyx.db.scim import ScimDAL
|
||||
from ee.onyx.server.scim.auth import verify_scim_token
|
||||
from ee.onyx.server.scim.filtering import parse_scim_filter
|
||||
from ee.onyx.server.scim.models import ScimEmail
|
||||
from ee.onyx.server.scim.models import ScimError
|
||||
from ee.onyx.server.scim.models import ScimGroupMember
|
||||
from ee.onyx.server.scim.models import ScimGroupResource
|
||||
from ee.onyx.server.scim.models import ScimListResponse
|
||||
from ee.onyx.server.scim.models import ScimMeta
|
||||
from ee.onyx.server.scim.models import ScimName
|
||||
from ee.onyx.server.scim.models import ScimPatchRequest
|
||||
from ee.onyx.server.scim.models import ScimResourceType
|
||||
@@ -41,6 +39,8 @@ from ee.onyx.server.scim.models import ScimUserResource
|
||||
from ee.onyx.server.scim.patch import apply_group_patch
|
||||
from ee.onyx.server.scim.patch import apply_user_patch
|
||||
from ee.onyx.server.scim.patch import ScimPatchError
|
||||
from ee.onyx.server.scim.providers.base import get_default_provider
|
||||
from ee.onyx.server.scim.providers.base import ScimProvider
|
||||
from ee.onyx.server.scim.schema_definitions import GROUP_RESOURCE_TYPE
|
||||
from ee.onyx.server.scim.schema_definitions import GROUP_SCHEMA_DEF
|
||||
from ee.onyx.server.scim.schema_definitions import SERVICE_PROVIDER_CONFIG
|
||||
@@ -53,7 +53,6 @@ from onyx.db.models import UserGroup
|
||||
from onyx.db.models import UserRole
|
||||
from onyx.utils.variable_functionality import fetch_ee_implementation_or_noop
|
||||
|
||||
|
||||
# NOTE: All URL paths in this router (/ServiceProviderConfig, /ResourceTypes,
|
||||
# /Schemas, /Users, /Groups) are mandated by the SCIM spec (RFC 7643/7644).
|
||||
# IdPs like Okta and Azure AD hardcode these exact paths, so they cannot be
|
||||
@@ -63,6 +62,18 @@ scim_router = APIRouter(prefix="/scim/v2", tags=["SCIM"])
|
||||
_pw_helper = PasswordHelper()
|
||||
|
||||
|
||||
def _get_provider(
|
||||
_token: ScimToken = Depends(verify_scim_token),
|
||||
) -> ScimProvider:
|
||||
"""Resolve the SCIM provider for the current request.
|
||||
|
||||
Currently returns OktaProvider for all requests. When multi-provider
|
||||
support is added (ENG-3652), this will resolve based on token metadata
|
||||
or tenant configuration — no endpoint changes required.
|
||||
"""
|
||||
return get_default_provider()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Service Discovery Endpoints (unauthenticated)
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -100,28 +111,6 @@ def _scim_error_response(status: int, detail: str) -> JSONResponse:
|
||||
)
|
||||
|
||||
|
||||
def _user_to_scim(user: User, external_id: str | None = None) -> ScimUserResource:
|
||||
"""Convert an Onyx User to a SCIM User resource representation."""
|
||||
name = None
|
||||
if user.personal_name:
|
||||
parts = user.personal_name.split(" ", 1)
|
||||
name = ScimName(
|
||||
givenName=parts[0],
|
||||
familyName=parts[1] if len(parts) > 1 else None,
|
||||
formatted=user.personal_name,
|
||||
)
|
||||
|
||||
return ScimUserResource(
|
||||
id=str(user.id),
|
||||
externalId=external_id,
|
||||
userName=user.email,
|
||||
name=name,
|
||||
emails=[ScimEmail(value=user.email, type="work", primary=True)],
|
||||
active=user.is_active,
|
||||
meta=ScimMeta(resourceType="User"),
|
||||
)
|
||||
|
||||
|
||||
def _check_seat_availability(dal: ScimDAL) -> str | None:
|
||||
"""Return an error message if seat limit is reached, else None."""
|
||||
check_fn = fetch_ee_implementation_or_noop(
|
||||
@@ -155,9 +144,10 @@ def _scim_name_to_str(name: ScimName | None) -> str | None:
|
||||
"""
|
||||
if not name:
|
||||
return None
|
||||
return name.formatted or " ".join(
|
||||
part for part in [name.givenName, name.familyName] if part
|
||||
)
|
||||
# Build from givenName/familyName first — IdPs like Okta may send a stale
|
||||
# ``formatted`` value while updating the individual name components.
|
||||
parts = " ".join(part for part in [name.givenName, name.familyName] if part)
|
||||
return parts or name.formatted
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -171,6 +161,7 @@ def list_users(
|
||||
startIndex: int = Query(1, ge=1),
|
||||
count: int = Query(100, ge=0, le=500),
|
||||
_token: ScimToken = Depends(verify_scim_token),
|
||||
provider: ScimProvider = Depends(_get_provider),
|
||||
db_session: Session = Depends(get_session),
|
||||
) -> ScimListResponse | JSONResponse:
|
||||
"""List users with optional SCIM filter and pagination."""
|
||||
@@ -183,12 +174,19 @@ def list_users(
|
||||
return _scim_error_response(400, str(e))
|
||||
|
||||
try:
|
||||
users_with_ext_ids, total = dal.list_users(scim_filter, startIndex, count)
|
||||
users_with_mappings, total = dal.list_users(scim_filter, startIndex, count)
|
||||
except ValueError as e:
|
||||
return _scim_error_response(400, str(e))
|
||||
|
||||
user_groups_map = dal.get_users_groups_batch([u.id for u, _ in users_with_mappings])
|
||||
resources: list[ScimUserResource | ScimGroupResource] = [
|
||||
_user_to_scim(user, ext_id) for user, ext_id in users_with_ext_ids
|
||||
provider.build_user_resource(
|
||||
user,
|
||||
mapping.external_id if mapping else None,
|
||||
groups=user_groups_map.get(user.id, []),
|
||||
scim_username=mapping.scim_username if mapping else None,
|
||||
)
|
||||
for user, mapping in users_with_mappings
|
||||
]
|
||||
|
||||
return ScimListResponse(
|
||||
@@ -203,6 +201,7 @@ def list_users(
|
||||
def get_user(
|
||||
user_id: str,
|
||||
_token: ScimToken = Depends(verify_scim_token),
|
||||
provider: ScimProvider = Depends(_get_provider),
|
||||
db_session: Session = Depends(get_session),
|
||||
) -> ScimUserResource | JSONResponse:
|
||||
"""Get a single user by ID."""
|
||||
@@ -215,20 +214,26 @@ def get_user(
|
||||
user = result
|
||||
|
||||
mapping = dal.get_user_mapping_by_user_id(user.id)
|
||||
return _user_to_scim(user, mapping.external_id if mapping else None)
|
||||
return provider.build_user_resource(
|
||||
user,
|
||||
mapping.external_id if mapping else None,
|
||||
groups=dal.get_user_groups(user.id),
|
||||
scim_username=mapping.scim_username if mapping else None,
|
||||
)
|
||||
|
||||
|
||||
@scim_router.post("/Users", status_code=201, response_model=None)
|
||||
def create_user(
|
||||
user_resource: ScimUserResource,
|
||||
_token: ScimToken = Depends(verify_scim_token),
|
||||
provider: ScimProvider = Depends(_get_provider),
|
||||
db_session: Session = Depends(get_session),
|
||||
) -> ScimUserResource | JSONResponse:
|
||||
"""Create a new user from a SCIM provisioning request."""
|
||||
dal = ScimDAL(db_session)
|
||||
dal.update_token_last_used(_token.id)
|
||||
|
||||
email = user_resource.userName.strip().lower()
|
||||
email = user_resource.userName.strip()
|
||||
|
||||
# externalId is how the IdP correlates this user on subsequent requests.
|
||||
# Without it, the IdP can't find the user and will try to re-create,
|
||||
@@ -264,11 +269,14 @@ def create_user(
|
||||
|
||||
# Create SCIM mapping (externalId is validated above, always present)
|
||||
external_id = user_resource.externalId
|
||||
dal.create_user_mapping(external_id=external_id, user_id=user.id)
|
||||
scim_username = user_resource.userName.strip()
|
||||
dal.create_user_mapping(
|
||||
external_id=external_id, user_id=user.id, scim_username=scim_username
|
||||
)
|
||||
|
||||
dal.commit()
|
||||
|
||||
return _user_to_scim(user, external_id)
|
||||
return provider.build_user_resource(user, external_id, scim_username=scim_username)
|
||||
|
||||
|
||||
@scim_router.put("/Users/{user_id}", response_model=None)
|
||||
@@ -276,6 +284,7 @@ def replace_user(
|
||||
user_id: str,
|
||||
user_resource: ScimUserResource,
|
||||
_token: ScimToken = Depends(verify_scim_token),
|
||||
provider: ScimProvider = Depends(_get_provider),
|
||||
db_session: Session = Depends(get_session),
|
||||
) -> ScimUserResource | JSONResponse:
|
||||
"""Replace a user entirely (RFC 7644 §3.5.1)."""
|
||||
@@ -293,19 +302,27 @@ def replace_user(
|
||||
if seat_error:
|
||||
return _scim_error_response(403, seat_error)
|
||||
|
||||
personal_name = _scim_name_to_str(user_resource.name)
|
||||
|
||||
dal.update_user(
|
||||
user,
|
||||
email=user_resource.userName.strip().lower(),
|
||||
email=user_resource.userName.strip(),
|
||||
is_active=user_resource.active,
|
||||
personal_name=_scim_name_to_str(user_resource.name),
|
||||
personal_name=personal_name,
|
||||
)
|
||||
|
||||
new_external_id = user_resource.externalId
|
||||
dal.sync_user_external_id(user.id, new_external_id)
|
||||
scim_username = user_resource.userName.strip()
|
||||
dal.sync_user_external_id(user.id, new_external_id, scim_username=scim_username)
|
||||
|
||||
dal.commit()
|
||||
|
||||
return _user_to_scim(user, new_external_id)
|
||||
return provider.build_user_resource(
|
||||
user,
|
||||
new_external_id,
|
||||
groups=dal.get_user_groups(user.id),
|
||||
scim_username=scim_username,
|
||||
)
|
||||
|
||||
|
||||
@scim_router.patch("/Users/{user_id}", response_model=None)
|
||||
@@ -313,6 +330,7 @@ def patch_user(
|
||||
user_id: str,
|
||||
patch_request: ScimPatchRequest,
|
||||
_token: ScimToken = Depends(verify_scim_token),
|
||||
provider: ScimProvider = Depends(_get_provider),
|
||||
db_session: Session = Depends(get_session),
|
||||
) -> ScimUserResource | JSONResponse:
|
||||
"""Partially update a user (RFC 7644 §3.5.2).
|
||||
@@ -330,11 +348,19 @@ def patch_user(
|
||||
|
||||
mapping = dal.get_user_mapping_by_user_id(user.id)
|
||||
external_id = mapping.external_id if mapping else None
|
||||
current_scim_username = mapping.scim_username if mapping else None
|
||||
|
||||
current = _user_to_scim(user, external_id)
|
||||
current = provider.build_user_resource(
|
||||
user,
|
||||
external_id,
|
||||
groups=dal.get_user_groups(user.id),
|
||||
scim_username=current_scim_username,
|
||||
)
|
||||
|
||||
try:
|
||||
patched = apply_user_patch(patch_request.Operations, current)
|
||||
patched = apply_user_patch(
|
||||
patch_request.Operations, current, provider.ignored_patch_paths
|
||||
)
|
||||
except ScimPatchError as e:
|
||||
return _scim_error_response(e.status, e.detail)
|
||||
|
||||
@@ -345,22 +371,40 @@ def patch_user(
|
||||
if seat_error:
|
||||
return _scim_error_response(403, seat_error)
|
||||
|
||||
# Track the scim_username — if userName was patched, update it
|
||||
new_scim_username = patched.userName.strip() if patched.userName else None
|
||||
|
||||
# If displayName was explicitly patched (different from the original), use
|
||||
# it as personal_name directly. Otherwise, derive from name components.
|
||||
personal_name: str | None
|
||||
if patched.displayName and patched.displayName != current.displayName:
|
||||
personal_name = patched.displayName
|
||||
else:
|
||||
personal_name = _scim_name_to_str(patched.name)
|
||||
|
||||
dal.update_user(
|
||||
user,
|
||||
email=(
|
||||
patched.userName.strip().lower()
|
||||
if patched.userName.lower() != user.email
|
||||
patched.userName.strip()
|
||||
if patched.userName.strip().lower() != user.email.lower()
|
||||
else None
|
||||
),
|
||||
is_active=patched.active if patched.active != user.is_active else None,
|
||||
personal_name=_scim_name_to_str(patched.name),
|
||||
personal_name=personal_name,
|
||||
)
|
||||
|
||||
dal.sync_user_external_id(user.id, patched.externalId)
|
||||
dal.sync_user_external_id(
|
||||
user.id, patched.externalId, scim_username=new_scim_username
|
||||
)
|
||||
|
||||
dal.commit()
|
||||
|
||||
return _user_to_scim(user, patched.externalId)
|
||||
return provider.build_user_resource(
|
||||
user,
|
||||
patched.externalId,
|
||||
groups=dal.get_user_groups(user.id),
|
||||
scim_username=new_scim_username,
|
||||
)
|
||||
|
||||
|
||||
@scim_router.delete("/Users/{user_id}", status_code=204, response_model=None)
|
||||
@@ -398,24 +442,6 @@ def delete_user(
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _group_to_scim(
|
||||
group: UserGroup,
|
||||
members: list[tuple[UUID, str | None]],
|
||||
external_id: str | None = None,
|
||||
) -> ScimGroupResource:
|
||||
"""Convert an Onyx UserGroup to a SCIM Group resource."""
|
||||
scim_members = [
|
||||
ScimGroupMember(value=str(uid), display=email) for uid, email in members
|
||||
]
|
||||
return ScimGroupResource(
|
||||
id=str(group.id),
|
||||
externalId=external_id,
|
||||
displayName=group.name,
|
||||
members=scim_members,
|
||||
meta=ScimMeta(resourceType="Group"),
|
||||
)
|
||||
|
||||
|
||||
def _fetch_group_or_404(group_id: str, dal: ScimDAL) -> UserGroup | JSONResponse:
|
||||
"""Parse *group_id* as int, look up the group, or return a 404 error."""
|
||||
try:
|
||||
@@ -474,6 +500,7 @@ def list_groups(
|
||||
startIndex: int = Query(1, ge=1),
|
||||
count: int = Query(100, ge=0, le=500),
|
||||
_token: ScimToken = Depends(verify_scim_token),
|
||||
provider: ScimProvider = Depends(_get_provider),
|
||||
db_session: Session = Depends(get_session),
|
||||
) -> ScimListResponse | JSONResponse:
|
||||
"""List groups with optional SCIM filter and pagination."""
|
||||
@@ -491,7 +518,7 @@ def list_groups(
|
||||
return _scim_error_response(400, str(e))
|
||||
|
||||
resources: list[ScimUserResource | ScimGroupResource] = [
|
||||
_group_to_scim(group, dal.get_group_members(group.id), ext_id)
|
||||
provider.build_group_resource(group, dal.get_group_members(group.id), ext_id)
|
||||
for group, ext_id in groups_with_ext_ids
|
||||
]
|
||||
|
||||
@@ -507,6 +534,7 @@ def list_groups(
|
||||
def get_group(
|
||||
group_id: str,
|
||||
_token: ScimToken = Depends(verify_scim_token),
|
||||
provider: ScimProvider = Depends(_get_provider),
|
||||
db_session: Session = Depends(get_session),
|
||||
) -> ScimGroupResource | JSONResponse:
|
||||
"""Get a single group by ID."""
|
||||
@@ -521,13 +549,16 @@ def get_group(
|
||||
mapping = dal.get_group_mapping_by_group_id(group.id)
|
||||
members = dal.get_group_members(group.id)
|
||||
|
||||
return _group_to_scim(group, members, mapping.external_id if mapping else None)
|
||||
return provider.build_group_resource(
|
||||
group, members, mapping.external_id if mapping else None
|
||||
)
|
||||
|
||||
|
||||
@scim_router.post("/Groups", status_code=201, response_model=None)
|
||||
def create_group(
|
||||
group_resource: ScimGroupResource,
|
||||
_token: ScimToken = Depends(verify_scim_token),
|
||||
provider: ScimProvider = Depends(_get_provider),
|
||||
db_session: Session = Depends(get_session),
|
||||
) -> ScimGroupResource | JSONResponse:
|
||||
"""Create a new group from a SCIM provisioning request."""
|
||||
@@ -565,7 +596,7 @@ def create_group(
|
||||
dal.commit()
|
||||
|
||||
members = dal.get_group_members(db_group.id)
|
||||
return _group_to_scim(db_group, members, external_id)
|
||||
return provider.build_group_resource(db_group, members, external_id)
|
||||
|
||||
|
||||
@scim_router.put("/Groups/{group_id}", response_model=None)
|
||||
@@ -573,6 +604,7 @@ def replace_group(
|
||||
group_id: str,
|
||||
group_resource: ScimGroupResource,
|
||||
_token: ScimToken = Depends(verify_scim_token),
|
||||
provider: ScimProvider = Depends(_get_provider),
|
||||
db_session: Session = Depends(get_session),
|
||||
) -> ScimGroupResource | JSONResponse:
|
||||
"""Replace a group entirely (RFC 7644 §3.5.1)."""
|
||||
@@ -595,7 +627,7 @@ def replace_group(
|
||||
dal.commit()
|
||||
|
||||
members = dal.get_group_members(group.id)
|
||||
return _group_to_scim(group, members, group_resource.externalId)
|
||||
return provider.build_group_resource(group, members, group_resource.externalId)
|
||||
|
||||
|
||||
@scim_router.patch("/Groups/{group_id}", response_model=None)
|
||||
@@ -603,6 +635,7 @@ def patch_group(
|
||||
group_id: str,
|
||||
patch_request: ScimPatchRequest,
|
||||
_token: ScimToken = Depends(verify_scim_token),
|
||||
provider: ScimProvider = Depends(_get_provider),
|
||||
db_session: Session = Depends(get_session),
|
||||
) -> ScimGroupResource | JSONResponse:
|
||||
"""Partially update a group (RFC 7644 §3.5.2).
|
||||
@@ -621,11 +654,11 @@ def patch_group(
|
||||
external_id = mapping.external_id if mapping else None
|
||||
|
||||
current_members = dal.get_group_members(group.id)
|
||||
current = _group_to_scim(group, current_members, external_id)
|
||||
current = provider.build_group_resource(group, current_members, external_id)
|
||||
|
||||
try:
|
||||
patched, added_ids, removed_ids = apply_group_patch(
|
||||
patch_request.Operations, current
|
||||
patch_request.Operations, current, provider.ignored_patch_paths
|
||||
)
|
||||
except ScimPatchError as e:
|
||||
return _scim_error_response(e.status, e.detail)
|
||||
@@ -652,7 +685,7 @@ def patch_group(
|
||||
dal.commit()
|
||||
|
||||
members = dal.get_group_members(group.id)
|
||||
return _group_to_scim(group, members, patched.externalId)
|
||||
return provider.build_group_resource(group, members, patched.externalId)
|
||||
|
||||
|
||||
@scim_router.delete("/Groups/{group_id}", status_code=204, response_model=None)
|
||||
|
||||
@@ -63,6 +63,13 @@ class ScimMeta(BaseModel):
|
||||
location: str | None = None
|
||||
|
||||
|
||||
class ScimUserGroupRef(BaseModel):
|
||||
"""Group reference within a User resource (RFC 7643 §4.1.2, read-only)."""
|
||||
|
||||
value: str
|
||||
display: str | None = None
|
||||
|
||||
|
||||
class ScimUserResource(BaseModel):
|
||||
"""SCIM User resource representation (RFC 7643 §4.1).
|
||||
|
||||
@@ -76,8 +83,10 @@ class ScimUserResource(BaseModel):
|
||||
externalId: str | None = None # IdP's identifier for this user
|
||||
userName: str # Typically the user's email address
|
||||
name: ScimName | None = None
|
||||
displayName: str | None = None
|
||||
emails: list[ScimEmail] = Field(default_factory=list)
|
||||
active: bool = True
|
||||
groups: list[ScimUserGroupRef] = Field(default_factory=list)
|
||||
meta: ScimMeta | None = None
|
||||
|
||||
|
||||
@@ -121,12 +130,40 @@ class ScimPatchOperationType(str, Enum):
|
||||
REMOVE = "remove"
|
||||
|
||||
|
||||
class ScimPatchResourceValue(BaseModel):
|
||||
"""Partial resource dict for path-less PATCH replace operations.
|
||||
|
||||
When an IdP sends a PATCH without a ``path``, the ``value`` is a dict
|
||||
of resource attributes to set. IdPs may include read-only fields
|
||||
(``id``, ``schemas``, ``meta``) alongside actual changes — these are
|
||||
stripped by the provider's ``ignored_patch_paths`` before processing.
|
||||
|
||||
``extra="allow"`` lets unknown attributes pass through so the patch
|
||||
handler can decide what to do with them (ignore or reject).
|
||||
"""
|
||||
|
||||
model_config = ConfigDict(extra="allow")
|
||||
|
||||
active: bool | None = None
|
||||
userName: str | None = None
|
||||
displayName: str | None = None
|
||||
externalId: str | None = None
|
||||
name: ScimName | None = None
|
||||
members: list[ScimGroupMember] | None = None
|
||||
id: str | None = None
|
||||
schemas: list[str] | None = None
|
||||
meta: ScimMeta | None = None
|
||||
|
||||
|
||||
ScimPatchValue = str | bool | list[ScimGroupMember] | ScimPatchResourceValue | None
|
||||
|
||||
|
||||
class ScimPatchOperation(BaseModel):
|
||||
"""Single PATCH operation (RFC 7644 §3.5.2)."""
|
||||
|
||||
op: ScimPatchOperationType
|
||||
path: str | None = None
|
||||
value: str | list[dict[str, str]] | dict[str, str | bool] | bool | None = None
|
||||
value: ScimPatchValue = None
|
||||
|
||||
|
||||
class ScimPatchRequest(BaseModel):
|
||||
|
||||
@@ -16,9 +16,12 @@ from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
from ee.onyx.server.scim.models import ScimGroupMember
|
||||
from ee.onyx.server.scim.models import ScimGroupResource
|
||||
from ee.onyx.server.scim.models import ScimPatchOperation
|
||||
from ee.onyx.server.scim.models import ScimPatchOperationType
|
||||
from ee.onyx.server.scim.models import ScimPatchResourceValue
|
||||
from ee.onyx.server.scim.models import ScimPatchValue
|
||||
from ee.onyx.server.scim.models import ScimUserResource
|
||||
|
||||
|
||||
@@ -41,9 +44,15 @@ _MEMBER_FILTER_RE = re.compile(
|
||||
def apply_user_patch(
|
||||
operations: list[ScimPatchOperation],
|
||||
current: ScimUserResource,
|
||||
ignored_paths: frozenset[str] = frozenset(),
|
||||
) -> ScimUserResource:
|
||||
"""Apply SCIM PATCH operations to a user resource.
|
||||
|
||||
Args:
|
||||
operations: The PATCH operations to apply.
|
||||
current: The current user resource state.
|
||||
ignored_paths: SCIM attribute paths to silently skip (from provider).
|
||||
|
||||
Returns a new ``ScimUserResource`` with the modifications applied.
|
||||
The original object is not mutated.
|
||||
|
||||
@@ -55,9 +64,9 @@ def apply_user_patch(
|
||||
|
||||
for op in operations:
|
||||
if op.op == ScimPatchOperationType.REPLACE:
|
||||
_apply_user_replace(op, data, name_data)
|
||||
_apply_user_replace(op, data, name_data, ignored_paths)
|
||||
elif op.op == ScimPatchOperationType.ADD:
|
||||
_apply_user_replace(op, data, name_data)
|
||||
_apply_user_replace(op, data, name_data, ignored_paths)
|
||||
else:
|
||||
raise ScimPatchError(
|
||||
f"Unsupported operation '{op.op.value}' on User resource"
|
||||
@@ -71,30 +80,34 @@ def _apply_user_replace(
|
||||
op: ScimPatchOperation,
|
||||
data: dict,
|
||||
name_data: dict,
|
||||
ignored_paths: frozenset[str],
|
||||
) -> None:
|
||||
"""Apply a replace/add operation to user data."""
|
||||
path = (op.path or "").lower()
|
||||
|
||||
if not path:
|
||||
# No path — value is a dict of top-level attributes to set
|
||||
if isinstance(op.value, dict):
|
||||
for key, val in op.value.items():
|
||||
_set_user_field(key.lower(), val, data, name_data)
|
||||
# No path — value is a resource dict of top-level attributes to set
|
||||
if isinstance(op.value, ScimPatchResourceValue):
|
||||
for key, val in op.value.model_dump(exclude_unset=True).items():
|
||||
_set_user_field(key.lower(), val, data, name_data, ignored_paths)
|
||||
else:
|
||||
raise ScimPatchError("Replace without path requires a dict value")
|
||||
return
|
||||
|
||||
_set_user_field(path, op.value, data, name_data)
|
||||
_set_user_field(path, op.value, data, name_data, ignored_paths)
|
||||
|
||||
|
||||
def _set_user_field(
|
||||
path: str,
|
||||
value: str | bool | dict | list | None,
|
||||
value: ScimPatchValue,
|
||||
data: dict,
|
||||
name_data: dict,
|
||||
ignored_paths: frozenset[str],
|
||||
) -> None:
|
||||
"""Set a single field on user data by SCIM path."""
|
||||
if path == "active":
|
||||
if path in ignored_paths:
|
||||
return
|
||||
elif path == "active":
|
||||
data["active"] = value
|
||||
elif path == "username":
|
||||
data["userName"] = value
|
||||
@@ -107,7 +120,7 @@ def _set_user_field(
|
||||
elif path == "name.formatted":
|
||||
name_data["formatted"] = value
|
||||
elif path == "displayname":
|
||||
# Some IdPs send displayName on users; map to formatted name
|
||||
data["displayName"] = value
|
||||
name_data["formatted"] = value
|
||||
else:
|
||||
raise ScimPatchError(f"Unsupported path '{path}' for User PATCH")
|
||||
@@ -116,9 +129,15 @@ def _set_user_field(
|
||||
def apply_group_patch(
|
||||
operations: list[ScimPatchOperation],
|
||||
current: ScimGroupResource,
|
||||
ignored_paths: frozenset[str] = frozenset(),
|
||||
) -> tuple[ScimGroupResource, list[str], list[str]]:
|
||||
"""Apply SCIM PATCH operations to a group resource.
|
||||
|
||||
Args:
|
||||
operations: The PATCH operations to apply.
|
||||
current: The current group resource state.
|
||||
ignored_paths: SCIM attribute paths to silently skip (from provider).
|
||||
|
||||
Returns:
|
||||
A tuple of (modified group, added member IDs, removed member IDs).
|
||||
The caller uses the member ID lists to update the database.
|
||||
@@ -133,7 +152,9 @@ def apply_group_patch(
|
||||
|
||||
for op in operations:
|
||||
if op.op == ScimPatchOperationType.REPLACE:
|
||||
_apply_group_replace(op, data, current_members, added_ids, removed_ids)
|
||||
_apply_group_replace(
|
||||
op, data, current_members, added_ids, removed_ids, ignored_paths
|
||||
)
|
||||
elif op.op == ScimPatchOperationType.ADD:
|
||||
_apply_group_add(op, current_members, added_ids)
|
||||
elif op.op == ScimPatchOperationType.REMOVE:
|
||||
@@ -154,38 +175,48 @@ def _apply_group_replace(
|
||||
current_members: list[dict],
|
||||
added_ids: list[str],
|
||||
removed_ids: list[str],
|
||||
ignored_paths: frozenset[str],
|
||||
) -> None:
|
||||
"""Apply a replace operation to group data."""
|
||||
path = (op.path or "").lower()
|
||||
|
||||
if not path:
|
||||
if isinstance(op.value, dict):
|
||||
for key, val in op.value.items():
|
||||
if isinstance(op.value, ScimPatchResourceValue):
|
||||
dumped = op.value.model_dump(exclude_unset=True)
|
||||
for key, val in dumped.items():
|
||||
if key.lower() == "members":
|
||||
_replace_members(val, current_members, added_ids, removed_ids)
|
||||
else:
|
||||
_set_group_field(key.lower(), val, data)
|
||||
_set_group_field(key.lower(), val, data, ignored_paths)
|
||||
else:
|
||||
raise ScimPatchError("Replace without path requires a dict value")
|
||||
return
|
||||
|
||||
if path == "members":
|
||||
_replace_members(op.value, current_members, added_ids, removed_ids)
|
||||
_replace_members(
|
||||
_members_to_dicts(op.value), current_members, added_ids, removed_ids
|
||||
)
|
||||
return
|
||||
|
||||
_set_group_field(path, op.value, data)
|
||||
_set_group_field(path, op.value, data, ignored_paths)
|
||||
|
||||
|
||||
def _members_to_dicts(
|
||||
value: str | bool | list[ScimGroupMember] | ScimPatchResourceValue | None,
|
||||
) -> list[dict]:
|
||||
"""Convert a member list value to a list of dicts for internal processing."""
|
||||
if not isinstance(value, list):
|
||||
raise ScimPatchError("Replace members requires a list value")
|
||||
return [m.model_dump(exclude_none=True) for m in value]
|
||||
|
||||
|
||||
def _replace_members(
|
||||
value: str | list | dict | bool | None,
|
||||
value: list[dict],
|
||||
current_members: list[dict],
|
||||
added_ids: list[str],
|
||||
removed_ids: list[str],
|
||||
) -> None:
|
||||
"""Replace the entire group member list."""
|
||||
if not isinstance(value, list):
|
||||
raise ScimPatchError("Replace members requires a list value")
|
||||
|
||||
old_ids = {m["value"] for m in current_members}
|
||||
new_ids = {m.get("value", "") for m in value}
|
||||
|
||||
@@ -197,11 +228,14 @@ def _replace_members(
|
||||
|
||||
def _set_group_field(
|
||||
path: str,
|
||||
value: str | bool | dict | list | None,
|
||||
value: ScimPatchValue,
|
||||
data: dict,
|
||||
ignored_paths: frozenset[str],
|
||||
) -> None:
|
||||
"""Set a single field on group data by SCIM path."""
|
||||
if path == "displayname":
|
||||
if path in ignored_paths:
|
||||
return
|
||||
elif path == "displayname":
|
||||
data["displayName"] = value
|
||||
elif path == "externalid":
|
||||
data["externalId"] = value
|
||||
@@ -223,8 +257,10 @@ def _apply_group_add(
|
||||
if not isinstance(op.value, list):
|
||||
raise ScimPatchError("Add members requires a list value")
|
||||
|
||||
member_dicts = [m.model_dump(exclude_none=True) for m in op.value]
|
||||
|
||||
existing_ids = {m["value"] for m in members}
|
||||
for member_data in op.value:
|
||||
for member_data in member_dicts:
|
||||
member_id = member_data.get("value", "")
|
||||
if member_id and member_id not in existing_ids:
|
||||
members.append(member_data)
|
||||
|
||||
0
backend/ee/onyx/server/scim/providers/__init__.py
Normal file
0
backend/ee/onyx/server/scim/providers/__init__.py
Normal file
123
backend/ee/onyx/server/scim/providers/base.py
Normal file
123
backend/ee/onyx/server/scim/providers/base.py
Normal file
@@ -0,0 +1,123 @@
|
||||
"""Base SCIM provider abstraction."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from abc import ABC
|
||||
from abc import abstractmethod
|
||||
from uuid import UUID
|
||||
|
||||
from ee.onyx.server.scim.models import ScimEmail
|
||||
from ee.onyx.server.scim.models import ScimGroupMember
|
||||
from ee.onyx.server.scim.models import ScimGroupResource
|
||||
from ee.onyx.server.scim.models import ScimMeta
|
||||
from ee.onyx.server.scim.models import ScimName
|
||||
from ee.onyx.server.scim.models import ScimUserGroupRef
|
||||
from ee.onyx.server.scim.models import ScimUserResource
|
||||
from onyx.db.models import User
|
||||
from onyx.db.models import UserGroup
|
||||
|
||||
|
||||
class ScimProvider(ABC):
|
||||
"""Base class for provider-specific SCIM behavior.
|
||||
|
||||
Subclass this to handle IdP-specific quirks. The base class provides
|
||||
RFC 7643-compliant response builders that populate all standard fields.
|
||||
"""
|
||||
|
||||
@property
|
||||
@abstractmethod
|
||||
def name(self) -> str:
|
||||
"""Short identifier for this provider (e.g. ``"okta"``)."""
|
||||
...
|
||||
|
||||
@property
|
||||
@abstractmethod
|
||||
def ignored_patch_paths(self) -> frozenset[str]:
|
||||
"""SCIM attribute paths to silently skip in PATCH value-object dicts.
|
||||
|
||||
IdPs may include read-only or meta fields alongside actual changes
|
||||
(e.g. Okta sends ``{"id": "...", "active": false}``). Paths listed
|
||||
here are silently dropped instead of raising an error.
|
||||
"""
|
||||
...
|
||||
|
||||
def build_user_resource(
|
||||
self,
|
||||
user: User,
|
||||
external_id: str | None = None,
|
||||
groups: list[tuple[int, str]] | None = None,
|
||||
scim_username: str | None = None,
|
||||
) -> ScimUserResource:
|
||||
"""Build a SCIM User response from an Onyx User.
|
||||
|
||||
Args:
|
||||
user: The Onyx user model.
|
||||
external_id: The IdP's external identifier for this user.
|
||||
groups: List of ``(group_id, group_name)`` tuples for the
|
||||
``groups`` read-only attribute. Pass ``None`` or ``[]``
|
||||
for newly-created users.
|
||||
scim_username: The original-case userName from the IdP. Falls
|
||||
back to ``user.email`` (lowercase) when not available.
|
||||
"""
|
||||
group_refs = [
|
||||
ScimUserGroupRef(value=str(gid), display=gname)
|
||||
for gid, gname in (groups or [])
|
||||
]
|
||||
|
||||
# Use original-case userName if stored, otherwise fall back to the
|
||||
# lowercased email from the User model.
|
||||
username = scim_username or user.email
|
||||
|
||||
return ScimUserResource(
|
||||
id=str(user.id),
|
||||
externalId=external_id,
|
||||
userName=username,
|
||||
name=self._build_scim_name(user),
|
||||
displayName=user.personal_name,
|
||||
emails=[ScimEmail(value=username, type="work", primary=True)],
|
||||
active=user.is_active,
|
||||
groups=group_refs,
|
||||
meta=ScimMeta(resourceType="User"),
|
||||
)
|
||||
|
||||
def build_group_resource(
|
||||
self,
|
||||
group: UserGroup,
|
||||
members: list[tuple[UUID, str | None]],
|
||||
external_id: str | None = None,
|
||||
) -> ScimGroupResource:
|
||||
"""Build a SCIM Group response from an Onyx UserGroup."""
|
||||
scim_members = [
|
||||
ScimGroupMember(value=str(uid), display=email) for uid, email in members
|
||||
]
|
||||
return ScimGroupResource(
|
||||
id=str(group.id),
|
||||
externalId=external_id,
|
||||
displayName=group.name,
|
||||
members=scim_members,
|
||||
meta=ScimMeta(resourceType="Group"),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _build_scim_name(user: User) -> ScimName | None:
|
||||
"""Extract SCIM name components from a user's personal name."""
|
||||
if not user.personal_name:
|
||||
return None
|
||||
parts = user.personal_name.split(" ", 1)
|
||||
return ScimName(
|
||||
givenName=parts[0],
|
||||
familyName=parts[1] if len(parts) > 1 else None,
|
||||
formatted=user.personal_name,
|
||||
)
|
||||
|
||||
|
||||
def get_default_provider() -> ScimProvider:
|
||||
"""Return the default SCIM provider.
|
||||
|
||||
Currently returns ``OktaProvider`` since Okta is the primary supported
|
||||
IdP. When provider detection is added (via token metadata or tenant
|
||||
config), this can be replaced with dynamic resolution.
|
||||
"""
|
||||
from ee.onyx.server.scim.providers.okta import OktaProvider
|
||||
|
||||
return OktaProvider()
|
||||
25
backend/ee/onyx/server/scim/providers/okta.py
Normal file
25
backend/ee/onyx/server/scim/providers/okta.py
Normal file
@@ -0,0 +1,25 @@
|
||||
"""Okta SCIM provider."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from ee.onyx.server.scim.providers.base import ScimProvider
|
||||
|
||||
|
||||
class OktaProvider(ScimProvider):
|
||||
"""Okta SCIM provider.
|
||||
|
||||
Okta behavioral notes:
|
||||
- Uses ``PATCH {"active": false}`` for deprovisioning (not DELETE)
|
||||
- Sends path-less PATCH with value dicts containing extra fields
|
||||
(``id``, ``schemas``)
|
||||
- Expects ``displayName`` and ``groups`` in user responses
|
||||
- Only uses ``eq`` operator for ``userName`` filter
|
||||
"""
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return "okta"
|
||||
|
||||
@property
|
||||
def ignored_patch_paths(self) -> frozenset[str]:
|
||||
return frozenset({"id", "schemas", "meta"})
|
||||
@@ -277,13 +277,32 @@ def verify_email_domain(email: str) -> None:
|
||||
detail="Email is not valid",
|
||||
)
|
||||
|
||||
domain = email.split("@")[-1].lower()
|
||||
local_part, domain = email.split("@")
|
||||
domain = domain.lower()
|
||||
|
||||
if AUTH_TYPE == AuthType.CLOUD:
|
||||
# Normalize googlemail.com to gmail.com (they deliver to the same inbox)
|
||||
if domain == "googlemail.com":
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail={"reason": "Please use @gmail.com instead of @googlemail.com."},
|
||||
)
|
||||
|
||||
if "+" in local_part and domain != "onyx.app":
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail={
|
||||
"reason": "Email addresses with '+' are not allowed. Please use your base email address."
|
||||
},
|
||||
)
|
||||
|
||||
# Check if email uses a disposable/temporary domain
|
||||
if is_disposable_email(email):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Disposable email addresses are not allowed. Please use a permanent email address.",
|
||||
detail={
|
||||
"reason": "Disposable email addresses are not allowed. Please use a permanent email address."
|
||||
},
|
||||
)
|
||||
|
||||
# Check domain whitelist if configured
|
||||
|
||||
@@ -5,8 +5,10 @@ from uuid import UUID
|
||||
|
||||
import httpx
|
||||
import sqlalchemy as sa
|
||||
from celery import Celery
|
||||
from celery import shared_task
|
||||
from celery import Task
|
||||
from redis import Redis
|
||||
from redis.lock import Lock as RedisLock
|
||||
from retry import retry
|
||||
from sqlalchemy import select
|
||||
@@ -24,12 +26,14 @@ from onyx.configs.constants import CELERY_GENERIC_BEAT_LOCK_TIMEOUT
|
||||
from onyx.configs.constants import CELERY_USER_FILE_PROCESSING_LOCK_TIMEOUT
|
||||
from onyx.configs.constants import CELERY_USER_FILE_PROCESSING_TASK_EXPIRES
|
||||
from onyx.configs.constants import CELERY_USER_FILE_PROJECT_SYNC_LOCK_TIMEOUT
|
||||
from onyx.configs.constants import CELERY_USER_FILE_PROJECT_SYNC_TASK_EXPIRES
|
||||
from onyx.configs.constants import DocumentSource
|
||||
from onyx.configs.constants import OnyxCeleryPriority
|
||||
from onyx.configs.constants import OnyxCeleryQueues
|
||||
from onyx.configs.constants import OnyxCeleryTask
|
||||
from onyx.configs.constants import OnyxRedisLocks
|
||||
from onyx.configs.constants import USER_FILE_PROCESSING_MAX_QUEUE_DEPTH
|
||||
from onyx.configs.constants import USER_FILE_PROJECT_SYNC_MAX_QUEUE_DEPTH
|
||||
from onyx.connectors.file.connector import LocalFileConnector
|
||||
from onyx.connectors.models import Document
|
||||
from onyx.connectors.models import HierarchyNode
|
||||
@@ -75,10 +79,58 @@ def _user_file_project_sync_lock_key(user_file_id: str | UUID) -> str:
|
||||
return f"{OnyxRedisLocks.USER_FILE_PROJECT_SYNC_LOCK_PREFIX}:{user_file_id}"
|
||||
|
||||
|
||||
def _user_file_project_sync_queued_key(user_file_id: str | UUID) -> str:
|
||||
return f"{OnyxRedisLocks.USER_FILE_PROJECT_SYNC_QUEUED_PREFIX}:{user_file_id}"
|
||||
|
||||
|
||||
def _user_file_delete_lock_key(user_file_id: str | UUID) -> str:
|
||||
return f"{OnyxRedisLocks.USER_FILE_DELETE_LOCK_PREFIX}:{user_file_id}"
|
||||
|
||||
|
||||
def get_user_file_project_sync_queue_depth(celery_app: Celery) -> int:
|
||||
redis_celery: Redis = celery_app.broker_connection().channel().client # type: ignore
|
||||
return celery_get_queue_length(
|
||||
OnyxCeleryQueues.USER_FILE_PROJECT_SYNC, redis_celery
|
||||
)
|
||||
|
||||
|
||||
def enqueue_user_file_project_sync_task(
|
||||
*,
|
||||
celery_app: Celery,
|
||||
redis_client: Redis,
|
||||
user_file_id: str | UUID,
|
||||
tenant_id: str,
|
||||
priority: OnyxCeleryPriority = OnyxCeleryPriority.HIGH,
|
||||
) -> bool:
|
||||
"""Enqueue a project-sync task if no matching queued task already exists."""
|
||||
queued_key = _user_file_project_sync_queued_key(user_file_id)
|
||||
|
||||
# NX+EX gives us atomic dedupe and a self-healing TTL.
|
||||
queued_guard_set = redis_client.set(
|
||||
queued_key,
|
||||
1,
|
||||
nx=True,
|
||||
ex=CELERY_USER_FILE_PROJECT_SYNC_TASK_EXPIRES,
|
||||
)
|
||||
if not queued_guard_set:
|
||||
return False
|
||||
|
||||
try:
|
||||
celery_app.send_task(
|
||||
OnyxCeleryTask.PROCESS_SINGLE_USER_FILE_PROJECT_SYNC,
|
||||
kwargs={"user_file_id": str(user_file_id), "tenant_id": tenant_id},
|
||||
queue=OnyxCeleryQueues.USER_FILE_PROJECT_SYNC,
|
||||
priority=priority,
|
||||
expires=CELERY_USER_FILE_PROJECT_SYNC_TASK_EXPIRES,
|
||||
)
|
||||
except Exception:
|
||||
# Roll back the queued guard if task publish fails.
|
||||
redis_client.delete(queued_key)
|
||||
raise
|
||||
|
||||
return True
|
||||
|
||||
|
||||
@retry(tries=3, delay=1, backoff=2, jitter=(0.0, 1.0))
|
||||
def _visit_chunks(
|
||||
*,
|
||||
@@ -632,8 +684,8 @@ def process_single_user_file_delete(
|
||||
ignore_result=True,
|
||||
)
|
||||
def check_for_user_file_project_sync(self: Task, *, tenant_id: str) -> None:
|
||||
"""Scan for user files with PROJECT_SYNC status and enqueue per-file tasks."""
|
||||
task_logger.info("check_for_user_file_project_sync - Starting")
|
||||
"""Scan for user files needing project sync and enqueue per-file tasks."""
|
||||
task_logger.info("Starting")
|
||||
|
||||
redis_client = get_redis_client(tenant_id=tenant_id)
|
||||
lock: RedisLock = redis_client.lock(
|
||||
@@ -645,7 +697,16 @@ def check_for_user_file_project_sync(self: Task, *, tenant_id: str) -> None:
|
||||
return None
|
||||
|
||||
enqueued = 0
|
||||
skipped_guard = 0
|
||||
try:
|
||||
queue_depth = get_user_file_project_sync_queue_depth(self.app)
|
||||
if queue_depth > USER_FILE_PROJECT_SYNC_MAX_QUEUE_DEPTH:
|
||||
task_logger.warning(
|
||||
f"Queue depth {queue_depth} exceeds "
|
||||
f"{USER_FILE_PROJECT_SYNC_MAX_QUEUE_DEPTH}, skipping enqueue for tenant={tenant_id}"
|
||||
)
|
||||
return None
|
||||
|
||||
with get_session_with_current_tenant() as db_session:
|
||||
user_file_ids = (
|
||||
db_session.execute(
|
||||
@@ -661,19 +722,23 @@ def check_for_user_file_project_sync(self: Task, *, tenant_id: str) -> None:
|
||||
)
|
||||
|
||||
for user_file_id in user_file_ids:
|
||||
self.app.send_task(
|
||||
OnyxCeleryTask.PROCESS_SINGLE_USER_FILE_PROJECT_SYNC,
|
||||
kwargs={"user_file_id": str(user_file_id), "tenant_id": tenant_id},
|
||||
queue=OnyxCeleryQueues.USER_FILE_PROJECT_SYNC,
|
||||
if not enqueue_user_file_project_sync_task(
|
||||
celery_app=self.app,
|
||||
redis_client=redis_client,
|
||||
user_file_id=user_file_id,
|
||||
tenant_id=tenant_id,
|
||||
priority=OnyxCeleryPriority.HIGH,
|
||||
)
|
||||
):
|
||||
skipped_guard += 1
|
||||
continue
|
||||
enqueued += 1
|
||||
finally:
|
||||
if lock.owned():
|
||||
lock.release()
|
||||
|
||||
task_logger.info(
|
||||
f"check_for_user_file_project_sync - Enqueued {enqueued} tasks for tenant={tenant_id}"
|
||||
f"Enqueued {enqueued} "
|
||||
f"Skipped guard {skipped_guard} tasks for tenant={tenant_id}"
|
||||
)
|
||||
return None
|
||||
|
||||
@@ -692,6 +757,8 @@ def process_single_user_file_project_sync(
|
||||
)
|
||||
|
||||
redis_client = get_redis_client(tenant_id=tenant_id)
|
||||
redis_client.delete(_user_file_project_sync_queued_key(user_file_id))
|
||||
|
||||
file_lock: RedisLock = redis_client.lock(
|
||||
_user_file_project_sync_lock_key(user_file_id),
|
||||
timeout=CELERY_USER_FILE_PROJECT_SYNC_LOCK_TIMEOUT,
|
||||
|
||||
@@ -30,6 +30,7 @@ from onyx.configs.constants import DocumentSource
|
||||
from onyx.configs.constants import MessageType
|
||||
from onyx.context.search.models import SearchDoc
|
||||
from onyx.context.search.models import SearchDocsResponse
|
||||
from onyx.db.engine.sql_engine import get_session_with_current_tenant
|
||||
from onyx.db.memory import add_memory
|
||||
from onyx.db.memory import update_memory_at_index
|
||||
from onyx.db.memory import UserMemoryContext
|
||||
@@ -656,7 +657,12 @@ def run_llm_loop(
|
||||
fallback_extraction_attempted: bool = False
|
||||
citation_mapping: dict[int, str] = {} # Maps citation_num -> document_id/URL
|
||||
|
||||
default_base_system_prompt: str = get_default_base_system_prompt(db_session)
|
||||
# Fetch this in a short-lived session so the long-running stream loop does
|
||||
# not pin a connection just to keep read state alive.
|
||||
with get_session_with_current_tenant() as prompt_db_session:
|
||||
default_base_system_prompt: str = get_default_base_system_prompt(
|
||||
prompt_db_session
|
||||
)
|
||||
system_prompt = None
|
||||
custom_agent_prompt_msg = None
|
||||
|
||||
|
||||
@@ -856,6 +856,11 @@ def handle_stream_message_objects(
|
||||
reserved_tokens=reserved_token_count,
|
||||
)
|
||||
|
||||
# Release any read transaction before entering the long-running LLM stream.
|
||||
# Without this, the request-scoped session can keep a connection checked out
|
||||
# for the full stream duration.
|
||||
db_session.commit()
|
||||
|
||||
# The stream generator can resume on a different worker thread after early yields.
|
||||
# Set this right before launching the LLM loop so run_in_background copies the right context.
|
||||
if new_msg_req.mock_llm_response is not None:
|
||||
|
||||
@@ -167,6 +167,14 @@ CELERY_USER_FILE_PROCESSING_TASK_EXPIRES = 60 # 1 minute (in seconds)
|
||||
# beat generator stops adding more. Prevents unbounded queue growth when workers
|
||||
# fall behind.
|
||||
USER_FILE_PROCESSING_MAX_QUEUE_DEPTH = 500
|
||||
# How long a queued user-file-project-sync task remains valid.
|
||||
# Should be short enough to discard stale queue entries under load while still
|
||||
# allowing workers enough time to pick up new tasks.
|
||||
CELERY_USER_FILE_PROJECT_SYNC_TASK_EXPIRES = 60 # 1 minute (in seconds)
|
||||
|
||||
# Max queue depth before user-file-project-sync producers stop enqueuing.
|
||||
# This applies backpressure when workers are falling behind.
|
||||
USER_FILE_PROJECT_SYNC_MAX_QUEUE_DEPTH = 500
|
||||
|
||||
CELERY_USER_FILE_PROJECT_SYNC_LOCK_TIMEOUT = 5 * 60 # 5 minutes (in seconds)
|
||||
|
||||
@@ -459,6 +467,7 @@ class OnyxRedisLocks:
|
||||
USER_FILE_QUEUED_PREFIX = "da_lock:user_file_queued"
|
||||
USER_FILE_PROJECT_SYNC_BEAT_LOCK = "da_lock:check_user_file_project_sync_beat"
|
||||
USER_FILE_PROJECT_SYNC_LOCK_PREFIX = "da_lock:user_file_project_sync"
|
||||
USER_FILE_PROJECT_SYNC_QUEUED_PREFIX = "da_lock:user_file_project_sync_queued"
|
||||
USER_FILE_DELETE_BEAT_LOCK = "da_lock:check_user_file_delete_beat"
|
||||
USER_FILE_DELETE_LOCK_PREFIX = "da_lock:user_file_delete"
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ from typing import cast
|
||||
|
||||
from pydantic import BaseModel
|
||||
from pydantic import Field
|
||||
from pydantic import field_validator
|
||||
from pydantic import model_validator
|
||||
|
||||
from onyx.access.models import ExternalAccess
|
||||
@@ -167,6 +168,14 @@ class DocumentBase(BaseModel):
|
||||
# list of strings.
|
||||
metadata: dict[str, str | list[str]]
|
||||
|
||||
@field_validator("metadata", mode="before")
|
||||
@classmethod
|
||||
def _coerce_metadata_values(cls, v: dict[str, Any]) -> dict[str, str | list[str]]:
|
||||
return {
|
||||
key: [str(item) for item in val] if isinstance(val, list) else str(val)
|
||||
for key, val in v.items()
|
||||
}
|
||||
|
||||
# UTC time
|
||||
doc_updated_at: datetime | None = None
|
||||
chunk_count: int | None = None
|
||||
|
||||
@@ -1592,6 +1592,7 @@ class SharepointConnector(
|
||||
if certificate_data is None:
|
||||
raise RuntimeError("Failed to load certificate")
|
||||
|
||||
logger.info(f"Creating MSAL app with authority url {authority_url}")
|
||||
self.msal_app = msal.ConfidentialClientApplication(
|
||||
authority=authority_url,
|
||||
client_id=sp_client_id,
|
||||
|
||||
@@ -59,12 +59,11 @@ def _build_index_filters(
|
||||
|
||||
base_filters = user_provided_filters or BaseFilters()
|
||||
|
||||
if (
|
||||
user_provided_filters
|
||||
and user_provided_filters.document_set is None
|
||||
and persona_document_sets is not None
|
||||
):
|
||||
base_filters.document_set = persona_document_sets
|
||||
document_set_filter = (
|
||||
base_filters.document_set
|
||||
if base_filters.document_set is not None
|
||||
else persona_document_sets
|
||||
)
|
||||
|
||||
time_filter = base_filters.time_cutoff or persona_time_cutoff
|
||||
source_filter = base_filters.source_type
|
||||
@@ -120,7 +119,7 @@ def _build_index_filters(
|
||||
user_file_ids=user_file_ids,
|
||||
project_id=project_id,
|
||||
source_type=source_filter,
|
||||
document_set=persona_document_sets,
|
||||
document_set=document_set_filter,
|
||||
time_cutoff=time_filter,
|
||||
tags=base_filters.tags,
|
||||
access_control_list=user_acl_filters,
|
||||
|
||||
@@ -1,11 +1,102 @@
|
||||
from sqlalchemy import text
|
||||
|
||||
from onyx.db.engine.sql_engine import get_session_with_shared_schema
|
||||
from onyx.db.engine.sql_engine import SqlEngine
|
||||
from shared_configs.configs import MULTI_TENANT
|
||||
from shared_configs.configs import POSTGRES_DEFAULT_SCHEMA
|
||||
from shared_configs.configs import TENANT_ID_PREFIX
|
||||
|
||||
|
||||
def get_schemas_needing_migration(
|
||||
tenant_schemas: list[str], head_rev: str
|
||||
) -> list[str]:
|
||||
"""Return only schemas whose current alembic version is not at head.
|
||||
|
||||
Uses a server-side PL/pgSQL loop to collect each schema's alembic version
|
||||
into a temp table one at a time. This avoids building a massive UNION ALL
|
||||
query (which locks the DB and times out at 17k+ schemas) and instead
|
||||
acquires locks sequentially, one schema per iteration.
|
||||
"""
|
||||
if not tenant_schemas:
|
||||
return []
|
||||
|
||||
engine = SqlEngine.get_engine()
|
||||
|
||||
with engine.connect() as conn:
|
||||
# Populate a temp input table with exactly the schemas we care about.
|
||||
# The DO block reads from this table so it only iterates the requested
|
||||
# schemas instead of every tenant_% schema in the database.
|
||||
conn.execute(text("DROP TABLE IF EXISTS _alembic_version_snapshot"))
|
||||
conn.execute(text("DROP TABLE IF EXISTS _tenant_schemas_input"))
|
||||
conn.execute(text("CREATE TEMP TABLE _tenant_schemas_input (schema_name text)"))
|
||||
conn.execute(
|
||||
text(
|
||||
"INSERT INTO _tenant_schemas_input (schema_name) "
|
||||
"SELECT unnest(CAST(:schemas AS text[]))"
|
||||
),
|
||||
{"schemas": tenant_schemas},
|
||||
)
|
||||
conn.execute(
|
||||
text(
|
||||
"CREATE TEMP TABLE _alembic_version_snapshot "
|
||||
"(schema_name text, version_num text)"
|
||||
)
|
||||
)
|
||||
|
||||
conn.execute(
|
||||
text(
|
||||
"""
|
||||
DO $$
|
||||
DECLARE
|
||||
s text;
|
||||
schemas text[];
|
||||
BEGIN
|
||||
SELECT array_agg(schema_name) INTO schemas
|
||||
FROM _tenant_schemas_input;
|
||||
|
||||
IF schemas IS NULL THEN
|
||||
RAISE NOTICE 'No tenant schemas found.';
|
||||
RETURN;
|
||||
END IF;
|
||||
|
||||
FOREACH s IN ARRAY schemas LOOP
|
||||
BEGIN
|
||||
EXECUTE format(
|
||||
'INSERT INTO _alembic_version_snapshot
|
||||
SELECT %L, version_num FROM %I.alembic_version',
|
||||
s, s
|
||||
);
|
||||
EXCEPTION
|
||||
-- undefined_table: schema exists but has no alembic_version
|
||||
-- table yet (new tenant, not yet migrated).
|
||||
-- invalid_schema_name: tenant is registered but its
|
||||
-- PostgreSQL schema does not exist yet (e.g. provisioning
|
||||
-- incomplete). Both cases mean no version is available and
|
||||
-- the schema will be included in the migration list.
|
||||
WHEN undefined_table THEN NULL;
|
||||
WHEN invalid_schema_name THEN NULL;
|
||||
END;
|
||||
END LOOP;
|
||||
END;
|
||||
$$
|
||||
"""
|
||||
)
|
||||
)
|
||||
|
||||
rows = conn.execute(
|
||||
text("SELECT schema_name, version_num FROM _alembic_version_snapshot")
|
||||
)
|
||||
version_by_schema = {row[0]: row[1] for row in rows}
|
||||
|
||||
conn.execute(text("DROP TABLE IF EXISTS _alembic_version_snapshot"))
|
||||
conn.execute(text("DROP TABLE IF EXISTS _tenant_schemas_input"))
|
||||
|
||||
# Schemas missing from the snapshot have no alembic_version table yet and
|
||||
# also need migration. version_by_schema.get(s) returns None for those,
|
||||
# and None != head_rev, so they are included automatically.
|
||||
return [s for s in tenant_schemas if version_by_schema.get(s) != head_rev]
|
||||
|
||||
|
||||
def get_all_tenant_ids() -> list[str]:
|
||||
"""Returning [None] means the only tenant is the 'public' or self hosted tenant."""
|
||||
|
||||
|
||||
@@ -554,10 +554,9 @@ class VespaDocumentIndex(DocumentIndex):
|
||||
num_to_retrieve: int,
|
||||
) -> list[InferenceChunk]:
|
||||
vespa_where_clauses = build_vespa_filters(filters)
|
||||
# Needs to be at least as much as the rerank-count value set in the
|
||||
# Vespa schema config. Otherwise we would be getting fewer results than
|
||||
# expected for reranking.
|
||||
target_hits = max(10 * num_to_retrieve, RERANK_COUNT)
|
||||
# Avoid over-fetching a very large candidate set for global-phase reranking.
|
||||
# Keep enough headroom for quality while capping cost on larger indices.
|
||||
target_hits = min(max(4 * num_to_retrieve, 100), RERANK_COUNT)
|
||||
|
||||
yql = (
|
||||
YQL_BASE.format(index_name=self._index_name)
|
||||
|
||||
@@ -68,6 +68,7 @@ from onyx.server.features.build.db.sandbox import create_sandbox__no_commit
|
||||
from onyx.server.features.build.db.sandbox import get_running_sandbox_count_by_tenant
|
||||
from onyx.server.features.build.db.sandbox import get_sandbox_by_session_id
|
||||
from onyx.server.features.build.db.sandbox import get_sandbox_by_user_id
|
||||
from onyx.server.features.build.db.sandbox import get_snapshots_for_session
|
||||
from onyx.server.features.build.db.sandbox import update_sandbox_heartbeat
|
||||
from onyx.server.features.build.db.sandbox import update_sandbox_status__no_commit
|
||||
from onyx.server.features.build.sandbox import get_sandbox_manager
|
||||
@@ -1035,6 +1036,23 @@ class SessionManager:
|
||||
# workspace cleanup fails (e.g., if pod is already terminated)
|
||||
logger.warning(f"Failed to cleanup session workspace {session_id}: {e}")
|
||||
|
||||
# Delete snapshot files from S3 before removing DB records
|
||||
snapshots = get_snapshots_for_session(self._db_session, session_id)
|
||||
if snapshots:
|
||||
from onyx.file_store.file_store import get_default_file_store
|
||||
from onyx.server.features.build.sandbox.manager.snapshot_manager import (
|
||||
SnapshotManager,
|
||||
)
|
||||
|
||||
snapshot_manager = SnapshotManager(get_default_file_store())
|
||||
for snapshot in snapshots:
|
||||
try:
|
||||
snapshot_manager.delete_snapshot(snapshot.storage_path)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"Failed to delete snapshot file {snapshot.storage_path}: {e}"
|
||||
)
|
||||
|
||||
# Delete session (uses flush, caller commits)
|
||||
return delete_build_session__no_commit(session_id, user_id, self._db_session)
|
||||
|
||||
|
||||
@@ -12,11 +12,18 @@ from pydantic import BaseModel
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from onyx.auth.users import current_user
|
||||
from onyx.background.celery.tasks.user_file_processing.tasks import (
|
||||
enqueue_user_file_project_sync_task,
|
||||
)
|
||||
from onyx.background.celery.tasks.user_file_processing.tasks import (
|
||||
get_user_file_project_sync_queue_depth,
|
||||
)
|
||||
from onyx.background.celery.versioned_apps.client import app as client_app
|
||||
from onyx.configs.constants import OnyxCeleryPriority
|
||||
from onyx.configs.constants import OnyxCeleryQueues
|
||||
from onyx.configs.constants import OnyxCeleryTask
|
||||
from onyx.configs.constants import PUBLIC_API_TAGS
|
||||
from onyx.configs.constants import USER_FILE_PROJECT_SYNC_MAX_QUEUE_DEPTH
|
||||
from onyx.db.engine.sql_engine import get_session
|
||||
from onyx.db.enums import UserFileStatus
|
||||
from onyx.db.models import ChatSession
|
||||
@@ -27,6 +34,7 @@ from onyx.db.models import UserProject
|
||||
from onyx.db.persona import get_personas_by_ids
|
||||
from onyx.db.projects import get_project_token_count
|
||||
from onyx.db.projects import upload_files_to_user_files_with_indexing
|
||||
from onyx.redis.redis_pool import get_redis_client
|
||||
from onyx.server.features.projects.models import CategorizedFilesSnapshot
|
||||
from onyx.server.features.projects.models import ChatSessionRequest
|
||||
from onyx.server.features.projects.models import TokenCountResponse
|
||||
@@ -47,6 +55,33 @@ class UserFileDeleteResult(BaseModel):
|
||||
assistant_names: list[str] = []
|
||||
|
||||
|
||||
def _trigger_user_file_project_sync(user_file_id: UUID, tenant_id: str) -> None:
|
||||
queue_depth = get_user_file_project_sync_queue_depth(client_app)
|
||||
if queue_depth > USER_FILE_PROJECT_SYNC_MAX_QUEUE_DEPTH:
|
||||
logger.warning(
|
||||
f"Skipping immediate project sync for user_file_id={user_file_id} due to "
|
||||
f"queue depth {queue_depth}>{USER_FILE_PROJECT_SYNC_MAX_QUEUE_DEPTH}. "
|
||||
"It will be picked up by beat later."
|
||||
)
|
||||
return
|
||||
|
||||
redis_client = get_redis_client(tenant_id=tenant_id)
|
||||
enqueued = enqueue_user_file_project_sync_task(
|
||||
celery_app=client_app,
|
||||
redis_client=redis_client,
|
||||
user_file_id=user_file_id,
|
||||
tenant_id=tenant_id,
|
||||
priority=OnyxCeleryPriority.HIGHEST,
|
||||
)
|
||||
if not enqueued:
|
||||
logger.info(
|
||||
f"Skipped duplicate project sync enqueue for user_file_id={user_file_id}"
|
||||
)
|
||||
return
|
||||
|
||||
logger.info(f"Triggered project sync for user_file_id={user_file_id}")
|
||||
|
||||
|
||||
@router.get("", tags=PUBLIC_API_TAGS)
|
||||
def get_projects(
|
||||
user: User = Depends(current_user),
|
||||
@@ -189,15 +224,7 @@ def unlink_user_file_from_project(
|
||||
db_session.commit()
|
||||
|
||||
tenant_id = get_current_tenant_id()
|
||||
task = client_app.send_task(
|
||||
OnyxCeleryTask.PROCESS_SINGLE_USER_FILE_PROJECT_SYNC,
|
||||
kwargs={"user_file_id": user_file.id, "tenant_id": tenant_id},
|
||||
queue=OnyxCeleryQueues.USER_FILE_PROJECT_SYNC,
|
||||
priority=OnyxCeleryPriority.HIGHEST,
|
||||
)
|
||||
logger.info(
|
||||
f"Triggered project sync for user_file_id={user_file.id} with task_id={task.id}"
|
||||
)
|
||||
_trigger_user_file_project_sync(user_file.id, tenant_id)
|
||||
|
||||
return Response(status_code=204)
|
||||
|
||||
@@ -241,15 +268,7 @@ def link_user_file_to_project(
|
||||
db_session.commit()
|
||||
|
||||
tenant_id = get_current_tenant_id()
|
||||
task = client_app.send_task(
|
||||
OnyxCeleryTask.PROCESS_SINGLE_USER_FILE_PROJECT_SYNC,
|
||||
kwargs={"user_file_id": user_file.id, "tenant_id": tenant_id},
|
||||
queue=OnyxCeleryQueues.USER_FILE_PROJECT_SYNC,
|
||||
priority=OnyxCeleryPriority.HIGHEST,
|
||||
)
|
||||
logger.info(
|
||||
f"Triggered project sync for user_file_id={user_file.id} with task_id={task.id}"
|
||||
)
|
||||
_trigger_user_file_project_sync(user_file.id, tenant_id)
|
||||
|
||||
return UserFileSnapshot.from_model(user_file)
|
||||
|
||||
|
||||
@@ -587,6 +587,7 @@ def handle_send_chat_message(
|
||||
request.headers
|
||||
),
|
||||
mcp_headers=chat_message_req.mcp_headers,
|
||||
additional_context=chat_message_req.additional_context,
|
||||
external_state_container=state_container,
|
||||
)
|
||||
result = gather_stream_full(packets, state_container)
|
||||
@@ -609,6 +610,7 @@ def handle_send_chat_message(
|
||||
request.headers
|
||||
),
|
||||
mcp_headers=chat_message_req.mcp_headers,
|
||||
additional_context=chat_message_req.additional_context,
|
||||
external_state_container=state_container,
|
||||
):
|
||||
yield get_json_line(obj.model_dump())
|
||||
|
||||
@@ -125,6 +125,11 @@ class SendMessageRequest(BaseModel):
|
||||
# - No CitationInfo packets are emitted during streaming
|
||||
include_citations: bool = True
|
||||
|
||||
# Additional context injected into the LLM call but NOT stored in the DB
|
||||
# (not shown in chat history). Used e.g. by the Chrome extension to pass
|
||||
# the current tab URL when "Read this tab" is enabled.
|
||||
additional_context: str | None = None
|
||||
|
||||
@model_validator(mode="after")
|
||||
def check_chat_session_id_or_info(self) -> "SendMessageRequest":
|
||||
# If neither is provided, default to creating a new chat session using the
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import json
|
||||
from collections.abc import Generator
|
||||
from typing import Literal
|
||||
from typing import TypedDict
|
||||
from typing import Union
|
||||
|
||||
import requests
|
||||
from pydantic import BaseModel
|
||||
@@ -36,6 +39,39 @@ class ExecuteResponse(BaseModel):
|
||||
files: list[WorkspaceFile]
|
||||
|
||||
|
||||
class StreamOutputEvent(BaseModel):
|
||||
"""SSE 'output' event: a chunk of stdout or stderr"""
|
||||
|
||||
stream: Literal["stdout", "stderr"]
|
||||
data: str
|
||||
|
||||
|
||||
class StreamResultEvent(BaseModel):
|
||||
"""SSE 'result' event: final execution result"""
|
||||
|
||||
exit_code: int | None
|
||||
timed_out: bool
|
||||
duration_ms: int
|
||||
files: list[WorkspaceFile]
|
||||
|
||||
|
||||
class StreamErrorEvent(BaseModel):
|
||||
"""SSE 'error' event: execution-level error"""
|
||||
|
||||
message: str
|
||||
|
||||
|
||||
StreamEvent = Union[StreamOutputEvent, StreamResultEvent, StreamErrorEvent]
|
||||
|
||||
_SSE_EVENT_MAP: dict[
|
||||
str, type[StreamOutputEvent | StreamResultEvent | StreamErrorEvent]
|
||||
] = {
|
||||
"output": StreamOutputEvent,
|
||||
"result": StreamResultEvent,
|
||||
"error": StreamErrorEvent,
|
||||
}
|
||||
|
||||
|
||||
class CodeInterpreterClient:
|
||||
"""Client for Code Interpreter service"""
|
||||
|
||||
@@ -45,6 +81,23 @@ class CodeInterpreterClient:
|
||||
self.base_url = base_url.rstrip("/")
|
||||
self.session = requests.Session()
|
||||
|
||||
def _build_payload(
|
||||
self,
|
||||
code: str,
|
||||
stdin: str | None,
|
||||
timeout_ms: int,
|
||||
files: list[FileInput] | None,
|
||||
) -> dict:
|
||||
payload: dict = {
|
||||
"code": code,
|
||||
"timeout_ms": timeout_ms,
|
||||
}
|
||||
if stdin is not None:
|
||||
payload["stdin"] = stdin
|
||||
if files:
|
||||
payload["files"] = files
|
||||
return payload
|
||||
|
||||
def execute(
|
||||
self,
|
||||
code: str,
|
||||
@@ -52,25 +105,110 @@ class CodeInterpreterClient:
|
||||
timeout_ms: int = 30000,
|
||||
files: list[FileInput] | None = None,
|
||||
) -> ExecuteResponse:
|
||||
"""Execute Python code"""
|
||||
"""Execute Python code (batch)"""
|
||||
url = f"{self.base_url}/v1/execute"
|
||||
|
||||
payload = {
|
||||
"code": code,
|
||||
"timeout_ms": timeout_ms,
|
||||
}
|
||||
|
||||
if stdin is not None:
|
||||
payload["stdin"] = stdin
|
||||
|
||||
if files:
|
||||
payload["files"] = files
|
||||
payload = self._build_payload(code, stdin, timeout_ms, files)
|
||||
|
||||
response = self.session.post(url, json=payload, timeout=timeout_ms / 1000 + 10)
|
||||
response.raise_for_status()
|
||||
|
||||
return ExecuteResponse(**response.json())
|
||||
|
||||
def execute_streaming(
|
||||
self,
|
||||
code: str,
|
||||
stdin: str | None = None,
|
||||
timeout_ms: int = 30000,
|
||||
files: list[FileInput] | None = None,
|
||||
) -> Generator[StreamEvent, None, None]:
|
||||
"""Execute Python code with streaming SSE output.
|
||||
|
||||
Yields StreamEvent objects (StreamOutputEvent, StreamResultEvent,
|
||||
StreamErrorEvent) as execution progresses. Falls back to batch
|
||||
execution if the streaming endpoint is not available (older
|
||||
code-interpreter versions).
|
||||
"""
|
||||
url = f"{self.base_url}/v1/execute/stream"
|
||||
payload = self._build_payload(code, stdin, timeout_ms, files)
|
||||
|
||||
response = self.session.post(
|
||||
url,
|
||||
json=payload,
|
||||
stream=True,
|
||||
timeout=timeout_ms / 1000 + 10,
|
||||
)
|
||||
|
||||
if response.status_code == 404:
|
||||
logger.info(
|
||||
"Streaming endpoint not available, " "falling back to batch execution"
|
||||
)
|
||||
response.close()
|
||||
yield from self._batch_as_stream(code, stdin, timeout_ms, files)
|
||||
return
|
||||
|
||||
response.raise_for_status()
|
||||
yield from self._parse_sse(response)
|
||||
|
||||
def _parse_sse(
|
||||
self, response: requests.Response
|
||||
) -> Generator[StreamEvent, None, None]:
|
||||
"""Parse SSE streaming response into StreamEvent objects.
|
||||
|
||||
Expected format per event:
|
||||
event: <type>
|
||||
data: <json>
|
||||
<blank line>
|
||||
"""
|
||||
event_type: str | None = None
|
||||
data_lines: list[str] = []
|
||||
|
||||
for line in response.iter_lines(decode_unicode=True):
|
||||
if line is None:
|
||||
continue
|
||||
|
||||
if line == "":
|
||||
# Blank line marks end of an SSE event
|
||||
if event_type is not None and data_lines:
|
||||
data = "\n".join(data_lines)
|
||||
model_cls = _SSE_EVENT_MAP.get(event_type)
|
||||
if model_cls is not None:
|
||||
yield model_cls(**json.loads(data))
|
||||
else:
|
||||
logger.warning(f"Unknown SSE event type: {event_type}")
|
||||
event_type = None
|
||||
data_lines = []
|
||||
elif line.startswith("event:"):
|
||||
event_type = line[len("event:") :].strip()
|
||||
elif line.startswith("data:"):
|
||||
data_lines.append(line[len("data:") :].strip())
|
||||
|
||||
if event_type is not None or data_lines:
|
||||
logger.warning(
|
||||
f"SSE stream ended with incomplete event: "
|
||||
f"event_type={event_type}, data_lines={data_lines}"
|
||||
)
|
||||
|
||||
def _batch_as_stream(
|
||||
self,
|
||||
code: str,
|
||||
stdin: str | None,
|
||||
timeout_ms: int,
|
||||
files: list[FileInput] | None,
|
||||
) -> Generator[StreamEvent, None, None]:
|
||||
"""Execute via batch endpoint and yield results as stream events."""
|
||||
result = self.execute(code, stdin, timeout_ms, files)
|
||||
|
||||
if result.stdout:
|
||||
yield StreamOutputEvent(stream="stdout", data=result.stdout)
|
||||
if result.stderr:
|
||||
yield StreamOutputEvent(stream="stderr", data=result.stderr)
|
||||
yield StreamResultEvent(
|
||||
exit_code=result.exit_code,
|
||||
timed_out=result.timed_out,
|
||||
duration_ms=result.duration_ms,
|
||||
files=result.files,
|
||||
)
|
||||
|
||||
def upload_file(self, file_content: bytes, filename: str) -> str:
|
||||
"""Upload file to Code Interpreter and return file_id"""
|
||||
url = f"{self.base_url}/v1/files"
|
||||
|
||||
@@ -28,6 +28,15 @@ from onyx.tools.tool_implementations.python.code_interpreter_client import (
|
||||
CodeInterpreterClient,
|
||||
)
|
||||
from onyx.tools.tool_implementations.python.code_interpreter_client import FileInput
|
||||
from onyx.tools.tool_implementations.python.code_interpreter_client import (
|
||||
StreamErrorEvent,
|
||||
)
|
||||
from onyx.tools.tool_implementations.python.code_interpreter_client import (
|
||||
StreamOutputEvent,
|
||||
)
|
||||
from onyx.tools.tool_implementations.python.code_interpreter_client import (
|
||||
StreamResultEvent,
|
||||
)
|
||||
from onyx.utils.logger import setup_logger
|
||||
|
||||
|
||||
@@ -181,19 +190,50 @@ class PythonTool(Tool[PythonToolOverrideKwargs]):
|
||||
try:
|
||||
logger.debug(f"Executing code: {code}")
|
||||
|
||||
# Execute code with timeout
|
||||
response = client.execute(
|
||||
# Execute code with streaming (falls back to batch if unavailable)
|
||||
stdout_parts: list[str] = []
|
||||
stderr_parts: list[str] = []
|
||||
result_event: StreamResultEvent | None = None
|
||||
|
||||
for event in client.execute_streaming(
|
||||
code=code,
|
||||
timeout_ms=CODE_INTERPRETER_DEFAULT_TIMEOUT_MS,
|
||||
files=files_to_stage or None,
|
||||
)
|
||||
):
|
||||
if isinstance(event, StreamOutputEvent):
|
||||
if event.stream == "stdout":
|
||||
stdout_parts.append(event.data)
|
||||
else:
|
||||
stderr_parts.append(event.data)
|
||||
# Emit incremental delta to frontend
|
||||
self.emitter.emit(
|
||||
Packet(
|
||||
placement=placement,
|
||||
obj=PythonToolDelta(
|
||||
stdout=event.data if event.stream == "stdout" else "",
|
||||
stderr=event.data if event.stream == "stderr" else "",
|
||||
),
|
||||
)
|
||||
)
|
||||
elif isinstance(event, StreamResultEvent):
|
||||
result_event = event
|
||||
elif isinstance(event, StreamErrorEvent):
|
||||
raise RuntimeError(f"Code interpreter error: {event.message}")
|
||||
|
||||
if result_event is None:
|
||||
raise RuntimeError(
|
||||
"Code interpreter stream ended without a result event"
|
||||
)
|
||||
|
||||
full_stdout = "".join(stdout_parts)
|
||||
full_stderr = "".join(stderr_parts)
|
||||
|
||||
# Truncate output for LLM consumption
|
||||
truncated_stdout = _truncate_output(
|
||||
response.stdout, CODE_INTERPRETER_MAX_OUTPUT_LENGTH, "stdout"
|
||||
full_stdout, CODE_INTERPRETER_MAX_OUTPUT_LENGTH, "stdout"
|
||||
)
|
||||
truncated_stderr = _truncate_output(
|
||||
response.stderr, CODE_INTERPRETER_MAX_OUTPUT_LENGTH, "stderr"
|
||||
full_stderr, CODE_INTERPRETER_MAX_OUTPUT_LENGTH, "stderr"
|
||||
)
|
||||
|
||||
# Handle generated files
|
||||
@@ -202,7 +242,7 @@ class PythonTool(Tool[PythonToolOverrideKwargs]):
|
||||
file_ids_to_cleanup: list[str] = []
|
||||
file_store = get_default_file_store()
|
||||
|
||||
for workspace_file in response.files:
|
||||
for workspace_file in result_event.files:
|
||||
if workspace_file.kind != "file" or not workspace_file.file_id:
|
||||
continue
|
||||
|
||||
@@ -258,26 +298,23 @@ class PythonTool(Tool[PythonToolOverrideKwargs]):
|
||||
f"Failed to delete Code Interpreter staged file {file_mapping['file_id']}: {e}"
|
||||
)
|
||||
|
||||
# Emit delta with stdout/stderr and generated files
|
||||
self.emitter.emit(
|
||||
Packet(
|
||||
placement=placement,
|
||||
obj=PythonToolDelta(
|
||||
stdout=truncated_stdout,
|
||||
stderr=truncated_stderr,
|
||||
file_ids=generated_file_ids,
|
||||
),
|
||||
# Emit file_ids once files are processed
|
||||
if generated_file_ids:
|
||||
self.emitter.emit(
|
||||
Packet(
|
||||
placement=placement,
|
||||
obj=PythonToolDelta(file_ids=generated_file_ids),
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
# Build result
|
||||
result = LlmPythonExecutionResult(
|
||||
stdout=truncated_stdout,
|
||||
stderr=truncated_stderr,
|
||||
exit_code=response.exit_code,
|
||||
timed_out=response.timed_out,
|
||||
exit_code=result_event.exit_code,
|
||||
timed_out=result_event.timed_out,
|
||||
generated_files=generated_files,
|
||||
error=None if response.exit_code == 0 else truncated_stderr,
|
||||
error=None if result_event.exit_code == 0 else truncated_stderr,
|
||||
)
|
||||
|
||||
# Serialize result for LLM
|
||||
|
||||
@@ -6,6 +6,8 @@ aioboto3==15.1.0
|
||||
# via onyx
|
||||
aiobotocore==2.24.0
|
||||
# via aioboto3
|
||||
aiofile==3.9.0
|
||||
# via py-key-value-aio
|
||||
aiofiles==25.1.0
|
||||
# via
|
||||
# aioboto3
|
||||
@@ -40,8 +42,10 @@ anyio==4.11.0
|
||||
# httpx
|
||||
# mcp
|
||||
# openai
|
||||
# py-key-value-aio
|
||||
# sse-starlette
|
||||
# starlette
|
||||
# watchfiles
|
||||
argon2-cffi==23.1.0
|
||||
# via pwdlib
|
||||
argon2-cffi-bindings==25.1.0
|
||||
@@ -74,9 +78,7 @@ backports-tarfile==1.2.0 ; python_full_version < '3.12'
|
||||
bcrypt==4.3.0
|
||||
# via pwdlib
|
||||
beartype==0.22.6
|
||||
# via
|
||||
# py-key-value-aio
|
||||
# py-key-value-shared
|
||||
# via py-key-value-aio
|
||||
beautifulsoup4==4.12.3
|
||||
# via
|
||||
# atlassian-python-api
|
||||
@@ -110,6 +112,8 @@ cachetools==6.2.2
|
||||
# via
|
||||
# google-auth
|
||||
# py-key-value-aio
|
||||
caio==0.9.25
|
||||
# via aiofile
|
||||
celery==5.5.1
|
||||
# via onyx
|
||||
certifi==2025.11.12
|
||||
@@ -170,7 +174,6 @@ cloudpickle==3.1.2
|
||||
# via
|
||||
# dask
|
||||
# distributed
|
||||
# pydocket
|
||||
cobble==0.1.4
|
||||
# via mammoth
|
||||
cohere==5.6.1
|
||||
@@ -218,8 +221,6 @@ deprecated==1.3.1
|
||||
# pygithub
|
||||
discord-py==2.4.0
|
||||
# via onyx
|
||||
diskcache==5.6.3
|
||||
# via py-key-value-aio
|
||||
distributed==2026.1.1
|
||||
# via onyx
|
||||
distro==1.9.0
|
||||
@@ -256,8 +257,6 @@ exceptiongroup==1.3.0
|
||||
# via
|
||||
# braintrust
|
||||
# fastmcp
|
||||
fakeredis==2.33.0
|
||||
# via pydocket
|
||||
fastapi==0.128.0
|
||||
# via
|
||||
# fastapi-limiter
|
||||
@@ -273,7 +272,7 @@ fastapi-users-db-sqlalchemy==7.0.0
|
||||
# via onyx
|
||||
fastavro==1.12.1
|
||||
# via cohere
|
||||
fastmcp==2.14.2
|
||||
fastmcp==3.0.2
|
||||
# via onyx
|
||||
fastuuid==0.14.0
|
||||
# via litellm
|
||||
@@ -478,7 +477,9 @@ jsonpatch==1.33
|
||||
jsonpointer==3.0.0
|
||||
# via jsonpatch
|
||||
jsonref==1.1.0
|
||||
# via onyx
|
||||
# via
|
||||
# fastmcp
|
||||
# onyx
|
||||
jsonschema==4.25.1
|
||||
# via
|
||||
# litellm
|
||||
@@ -513,8 +514,6 @@ locket==1.0.0
|
||||
# via
|
||||
# distributed
|
||||
# partd
|
||||
lupa==2.6
|
||||
# via fakeredis
|
||||
lxml==5.3.0
|
||||
# via
|
||||
# htmldate
|
||||
@@ -556,7 +555,7 @@ marshmallow==3.26.2
|
||||
# via dataclasses-json
|
||||
matrix-client==0.3.2
|
||||
# via zulip
|
||||
mcp==1.25.0
|
||||
mcp==1.26.0
|
||||
# via
|
||||
# claude-agent-sdk
|
||||
# fastmcp
|
||||
@@ -613,7 +612,7 @@ oauthlib==3.2.2
|
||||
# kubernetes
|
||||
# onyx
|
||||
# requests-oauthlib
|
||||
office365-rest-python-client==2.5.9
|
||||
office365-rest-python-client==2.6.2
|
||||
# via onyx
|
||||
olefile==0.47
|
||||
# via
|
||||
@@ -642,22 +641,16 @@ opensearch-py==3.0.0
|
||||
opentelemetry-api==1.39.1
|
||||
# via
|
||||
# ddtrace
|
||||
# fastmcp
|
||||
# langfuse
|
||||
# openinference-instrumentation
|
||||
# opentelemetry-exporter-otlp-proto-http
|
||||
# opentelemetry-exporter-prometheus
|
||||
# opentelemetry-instrumentation
|
||||
# opentelemetry-sdk
|
||||
# opentelemetry-semantic-conventions
|
||||
# pydocket
|
||||
opentelemetry-exporter-otlp-proto-common==1.39.1
|
||||
# via opentelemetry-exporter-otlp-proto-http
|
||||
opentelemetry-exporter-otlp-proto-http==1.39.1
|
||||
# via langfuse
|
||||
opentelemetry-exporter-prometheus==0.60b1
|
||||
# via pydocket
|
||||
opentelemetry-instrumentation==0.60b1
|
||||
# via pydocket
|
||||
opentelemetry-proto==1.39.1
|
||||
# via
|
||||
# onyx
|
||||
@@ -668,17 +661,15 @@ opentelemetry-sdk==1.39.1
|
||||
# langfuse
|
||||
# openinference-instrumentation
|
||||
# opentelemetry-exporter-otlp-proto-http
|
||||
# opentelemetry-exporter-prometheus
|
||||
opentelemetry-semantic-conventions==0.60b1
|
||||
# via
|
||||
# opentelemetry-instrumentation
|
||||
# opentelemetry-sdk
|
||||
# via opentelemetry-sdk
|
||||
orjson==3.11.4 ; platform_python_implementation != 'PyPy'
|
||||
# via langsmith
|
||||
packaging==24.2
|
||||
# via
|
||||
# dask
|
||||
# distributed
|
||||
# fastmcp
|
||||
# google-cloud-aiplatform
|
||||
# google-cloud-bigquery
|
||||
# huggingface-hub
|
||||
@@ -689,7 +680,6 @@ packaging==24.2
|
||||
# langsmith
|
||||
# marshmallow
|
||||
# onnxruntime
|
||||
# opentelemetry-instrumentation
|
||||
# pytest
|
||||
# pywikibot
|
||||
pandas==2.3.3
|
||||
@@ -702,8 +692,6 @@ passlib==1.7.4
|
||||
# via onyx
|
||||
pathable==0.4.4
|
||||
# via jsonschema-path
|
||||
pathvalidate==3.3.1
|
||||
# via py-key-value-aio
|
||||
pdfminer-six==20251107
|
||||
# via markitdown
|
||||
pillow==12.1.1
|
||||
@@ -723,9 +711,7 @@ ply==3.11
|
||||
prometheus-client==0.23.1
|
||||
# via
|
||||
# onyx
|
||||
# opentelemetry-exporter-prometheus
|
||||
# prometheus-fastapi-instrumentator
|
||||
# pydocket
|
||||
prometheus-fastapi-instrumentator==7.1.0
|
||||
# via onyx
|
||||
prompt-toolkit==3.0.52
|
||||
@@ -764,12 +750,8 @@ pwdlib==0.3.0
|
||||
# via fastapi-users
|
||||
py==1.11.0
|
||||
# via retry
|
||||
py-key-value-aio==0.3.0
|
||||
# via
|
||||
# fastmcp
|
||||
# pydocket
|
||||
py-key-value-shared==0.3.0
|
||||
# via py-key-value-aio
|
||||
py-key-value-aio==0.4.4
|
||||
# via fastmcp
|
||||
pyairtable==3.0.1
|
||||
# via onyx
|
||||
pyasn1==0.6.2
|
||||
@@ -806,8 +788,6 @@ pydantic-core==2.33.2
|
||||
# via pydantic
|
||||
pydantic-settings==2.12.0
|
||||
# via mcp
|
||||
pydocket==0.16.3
|
||||
# via fastmcp
|
||||
pyee==13.0.0
|
||||
# via playwright
|
||||
pygithub==2.5.0
|
||||
@@ -879,8 +859,6 @@ python-http-client==3.3.7
|
||||
# via sendgrid
|
||||
python-iso639==2025.11.16
|
||||
# via unstructured
|
||||
python-json-logger==4.0.0
|
||||
# via pydocket
|
||||
python-magic==0.4.27
|
||||
# via unstructured
|
||||
python-multipart==0.0.22
|
||||
@@ -918,6 +896,7 @@ pyyaml==6.0.3
|
||||
# via
|
||||
# dask
|
||||
# distributed
|
||||
# fastmcp
|
||||
# huggingface-hub
|
||||
# jsonschema-path
|
||||
# kubernetes
|
||||
@@ -928,11 +907,8 @@ rapidfuzz==3.13.0
|
||||
# unstructured
|
||||
redis==5.0.8
|
||||
# via
|
||||
# fakeredis
|
||||
# fastapi-limiter
|
||||
# onyx
|
||||
# py-key-value-aio
|
||||
# pydocket
|
||||
referencing==0.36.2
|
||||
# via
|
||||
# jsonschema
|
||||
@@ -1007,7 +983,6 @@ rich==14.2.0
|
||||
# via
|
||||
# cyclopts
|
||||
# fastmcp
|
||||
# pydocket
|
||||
# rich-rst
|
||||
# typer
|
||||
rich-rst==1.3.2
|
||||
@@ -1056,9 +1031,7 @@ sniffio==1.3.1
|
||||
# anyio
|
||||
# openai
|
||||
sortedcontainers==2.4.0
|
||||
# via
|
||||
# distributed
|
||||
# fakeredis
|
||||
# via distributed
|
||||
soupsieve==2.8
|
||||
# via beautifulsoup4
|
||||
sqlalchemy==2.0.15
|
||||
@@ -1124,9 +1097,7 @@ tqdm==4.67.1
|
||||
trafilatura==1.12.2
|
||||
# via onyx
|
||||
typer==0.20.0
|
||||
# via
|
||||
# mcp
|
||||
# pydocket
|
||||
# via mcp
|
||||
types-awscrt==0.28.4
|
||||
# via botocore-stubs
|
||||
types-openpyxl==3.0.4.7
|
||||
@@ -1162,11 +1133,10 @@ typing-extensions==4.15.0
|
||||
# opentelemetry-exporter-otlp-proto-http
|
||||
# opentelemetry-sdk
|
||||
# opentelemetry-semantic-conventions
|
||||
# py-key-value-shared
|
||||
# py-key-value-aio
|
||||
# pyairtable
|
||||
# pydantic
|
||||
# pydantic-core
|
||||
# pydocket
|
||||
# pyee
|
||||
# pygithub
|
||||
# python-docx
|
||||
@@ -1234,6 +1204,8 @@ vine==5.1.0
|
||||
# kombu
|
||||
voyageai==0.2.3
|
||||
# via onyx
|
||||
watchfiles==1.1.1
|
||||
# via fastmcp
|
||||
wcwidth==0.2.14
|
||||
# via prompt-toolkit
|
||||
webencodings==0.5.1
|
||||
@@ -1254,7 +1226,6 @@ wrapt==1.17.3
|
||||
# deprecated
|
||||
# langfuse
|
||||
# openinference-instrumentation
|
||||
# opentelemetry-instrumentation
|
||||
# unstructured
|
||||
xlrd==2.0.2
|
||||
# via markitdown
|
||||
|
||||
@@ -288,7 +288,7 @@ matplotlib-inline==0.2.1
|
||||
# via
|
||||
# ipykernel
|
||||
# ipython
|
||||
mcp==1.25.0
|
||||
mcp==1.26.0
|
||||
# via claude-agent-sdk
|
||||
multidict==6.7.0
|
||||
# via
|
||||
|
||||
@@ -211,7 +211,7 @@ litellm==1.81.6
|
||||
# via onyx
|
||||
markupsafe==3.0.3
|
||||
# via jinja2
|
||||
mcp==1.25.0
|
||||
mcp==1.26.0
|
||||
# via claude-agent-sdk
|
||||
monotonic==1.6
|
||||
# via posthog
|
||||
|
||||
@@ -246,7 +246,7 @@ litellm==1.81.6
|
||||
# via onyx
|
||||
markupsafe==3.0.3
|
||||
# via jinja2
|
||||
mcp==1.25.0
|
||||
mcp==1.26.0
|
||||
# via claude-agent-sdk
|
||||
mpmath==1.3.0
|
||||
# via sympy
|
||||
|
||||
@@ -9,6 +9,7 @@ from collections.abc import AsyncGenerator
|
||||
from collections.abc import Generator
|
||||
from contextlib import asynccontextmanager
|
||||
from unittest.mock import MagicMock
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
from dotenv import load_dotenv
|
||||
@@ -46,11 +47,15 @@ def mock_current_admin_user() -> MagicMock:
|
||||
|
||||
@pytest.fixture(scope="function")
|
||||
def client() -> Generator[TestClient, None, None]:
|
||||
# Initialize TestClient with the FastAPI app using a no-op test lifespan
|
||||
# Initialize TestClient with the FastAPI app using a no-op test lifespan.
|
||||
# Patch out prometheus metrics setup to avoid "Duplicated timeseries in
|
||||
# CollectorRegistry" errors when multiple tests each create a new app
|
||||
# (prometheus registers metrics globally and rejects duplicate names).
|
||||
get_app = fetch_versioned_implementation(
|
||||
module="onyx.main", attribute="get_application"
|
||||
)
|
||||
app: FastAPI = get_app(lifespan_override=test_lifespan)
|
||||
with patch("onyx.main.setup_prometheus_metrics"):
|
||||
app: FastAPI = get_app(lifespan_override=test_lifespan)
|
||||
|
||||
# Override the database session dependency with a mock
|
||||
# (these tests don't actually need DB access)
|
||||
|
||||
@@ -990,6 +990,27 @@ class _MockCIHandler(BaseHTTPRequestHandler):
|
||||
self._respond_json(
|
||||
200, {"file_id": f"mock-ci-file-{self.server._file_counter}"}
|
||||
)
|
||||
elif self.path == "/v1/execute/stream":
|
||||
if self.server.streaming_enabled:
|
||||
self._respond_sse(
|
||||
[
|
||||
(
|
||||
"output",
|
||||
{"stream": "stdout", "data": "mock output\n"},
|
||||
),
|
||||
(
|
||||
"result",
|
||||
{
|
||||
"exit_code": 0,
|
||||
"timed_out": False,
|
||||
"duration_ms": 50,
|
||||
"files": [],
|
||||
},
|
||||
),
|
||||
]
|
||||
)
|
||||
else:
|
||||
self._respond_json(404, {"error": "not found"})
|
||||
elif self.path == "/v1/execute":
|
||||
self._respond_json(
|
||||
200,
|
||||
@@ -1027,6 +1048,17 @@ class _MockCIHandler(BaseHTTPRequestHandler):
|
||||
self.end_headers()
|
||||
self.wfile.write(payload)
|
||||
|
||||
def _respond_sse(self, events: list[tuple[str, dict[str, Any]]]) -> None:
|
||||
frames = []
|
||||
for event_type, data in events:
|
||||
frames.append(f"event: {event_type}\ndata: {json.dumps(data)}\n\n")
|
||||
payload = "".join(frames).encode()
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", "text/event-stream")
|
||||
self.send_header("Content-Length", str(len(payload)))
|
||||
self.end_headers()
|
||||
self.wfile.write(payload)
|
||||
|
||||
def log_message(self, format: str, *args: Any) -> None: # noqa: A002
|
||||
pass
|
||||
|
||||
@@ -1038,6 +1070,7 @@ class MockCodeInterpreterServer(HTTPServer):
|
||||
super().__init__(("localhost", 0), _MockCIHandler)
|
||||
self.captured_requests: list[CapturedRequest] = []
|
||||
self._file_counter = 0
|
||||
self.streaming_enabled: bool = True
|
||||
|
||||
@property
|
||||
def url(self) -> str:
|
||||
@@ -1168,17 +1201,19 @@ def test_code_interpreter_receives_chat_files(
|
||||
finally:
|
||||
ci_mod.CodeInterpreterClient.__init__.__defaults__ = original_defaults
|
||||
|
||||
# Verify: file uploaded, code executed, staged file cleaned up
|
||||
# Verify: file uploaded, code executed via streaming, staged file cleaned up
|
||||
assert len(mock_ci_server.get_requests(method="POST", path="/v1/files")) == 1
|
||||
assert len(mock_ci_server.get_requests(method="POST", path="/v1/execute")) == 1
|
||||
assert (
|
||||
len(mock_ci_server.get_requests(method="POST", path="/v1/execute/stream")) == 1
|
||||
)
|
||||
|
||||
delete_requests = mock_ci_server.get_requests(method="DELETE")
|
||||
assert len(delete_requests) == 1
|
||||
assert delete_requests[0].path.startswith("/v1/files/")
|
||||
|
||||
execute_body = mock_ci_server.get_requests(method="POST", path="/v1/execute")[
|
||||
0
|
||||
].json_body()
|
||||
execute_body = mock_ci_server.get_requests(
|
||||
method="POST", path="/v1/execute/stream"
|
||||
)[0].json_body()
|
||||
assert execute_body["code"] == code
|
||||
assert len(execute_body["files"]) == 1
|
||||
assert execute_body["files"][0]["path"] == "data.csv"
|
||||
@@ -1284,7 +1319,9 @@ def test_code_interpreter_replay_packets_include_code_and_output(
|
||||
db_session=db_session,
|
||||
)
|
||||
|
||||
assert len(mock_ci_server.get_requests(method="POST", path="/v1/execute")) == 1
|
||||
assert (
|
||||
len(mock_ci_server.get_requests(method="POST", path="/v1/execute/stream")) == 1
|
||||
)
|
||||
|
||||
# The response contains `packets` — a list of packet-lists, one per
|
||||
# assistant message. We should have exactly one assistant message.
|
||||
@@ -1313,3 +1350,76 @@ def test_code_interpreter_replay_packets_include_code_and_output(
|
||||
delta_obj = delta_packets[0].obj
|
||||
assert isinstance(delta_obj, PythonToolDelta)
|
||||
assert "mock output" in delta_obj.stdout
|
||||
|
||||
|
||||
def test_code_interpreter_streaming_fallback_to_batch(
|
||||
db_session: Session,
|
||||
mock_ci_server: MockCodeInterpreterServer,
|
||||
_attach_python_tool_to_default_persona: None,
|
||||
initialize_file_store: None, # noqa: ARG001
|
||||
) -> None:
|
||||
"""When the streaming endpoint is not available (older code-interpreter),
|
||||
execute_streaming should fall back to the batch /v1/execute endpoint."""
|
||||
mock_ci_server.captured_requests.clear()
|
||||
mock_ci_server._file_counter = 0
|
||||
mock_ci_server.streaming_enabled = False
|
||||
mock_url = mock_ci_server.url
|
||||
|
||||
user = create_test_user(db_session, "ci_fallback_test")
|
||||
chat_session = create_chat_session(db_session=db_session, user=user)
|
||||
|
||||
code = 'print("fallback test")'
|
||||
msg_req = SendMessageRequest(
|
||||
message="Print fallback test",
|
||||
chat_session_id=chat_session.id,
|
||||
stream=True,
|
||||
)
|
||||
|
||||
original_defaults = ci_mod.CodeInterpreterClient.__init__.__defaults__
|
||||
with (
|
||||
use_mock_llm() as mock_llm,
|
||||
patch(
|
||||
"onyx.tools.tool_implementations.python.python_tool.CODE_INTERPRETER_BASE_URL",
|
||||
mock_url,
|
||||
),
|
||||
patch(
|
||||
"onyx.tools.tool_implementations.python.code_interpreter_client.CODE_INTERPRETER_BASE_URL",
|
||||
mock_url,
|
||||
),
|
||||
):
|
||||
mock_llm.add_response(
|
||||
LLMToolCallResponse(
|
||||
tool_name="python",
|
||||
tool_call_id="call_fallback",
|
||||
tool_call_argument_tokens=[json.dumps({"code": code})],
|
||||
)
|
||||
)
|
||||
mock_llm.forward_till_end()
|
||||
|
||||
ci_mod.CodeInterpreterClient.__init__.__defaults__ = (mock_url,)
|
||||
try:
|
||||
packets = list(
|
||||
handle_stream_message_objects(
|
||||
new_msg_req=msg_req, user=user, db_session=db_session
|
||||
)
|
||||
)
|
||||
finally:
|
||||
ci_mod.CodeInterpreterClient.__init__.__defaults__ = original_defaults
|
||||
mock_ci_server.streaming_enabled = True
|
||||
|
||||
# Streaming was attempted first (returned 404), then fell back to batch
|
||||
assert (
|
||||
len(mock_ci_server.get_requests(method="POST", path="/v1/execute/stream")) == 1
|
||||
)
|
||||
assert len(mock_ci_server.get_requests(method="POST", path="/v1/execute")) == 1
|
||||
|
||||
# Verify output still made it through
|
||||
delta_packets = [
|
||||
p
|
||||
for p in packets
|
||||
if isinstance(p, Packet) and isinstance(p.obj, PythonToolDelta)
|
||||
]
|
||||
assert len(delta_packets) >= 1
|
||||
first_delta = delta_packets[0].obj
|
||||
assert isinstance(first_delta, PythonToolDelta)
|
||||
assert "mock output" in first_delta.stdout
|
||||
|
||||
@@ -5,22 +5,17 @@ from fastapi import FastAPI
|
||||
from fastapi.responses import PlainTextResponse
|
||||
from fastmcp import FastMCP
|
||||
from fastmcp.server.auth import StaticTokenVerifier
|
||||
from fastmcp.server.server import FunctionTool
|
||||
|
||||
|
||||
def make_many_tools(mcp: FastMCP) -> list[FunctionTool]:
|
||||
def make_tool(i: int) -> FunctionTool:
|
||||
def make_many_tools(mcp: FastMCP) -> None:
|
||||
def make_tool(i: int) -> None:
|
||||
@mcp.tool(name=f"tool_{i}", description=f"Get secret value {i}")
|
||||
def tool_name(name: str) -> str: # noqa: ARG001
|
||||
"""Get secret value."""
|
||||
return f"Secret value {200 - i}!"
|
||||
|
||||
return tool_name
|
||||
|
||||
tools = []
|
||||
for i in range(100):
|
||||
tools.append(make_tool(i))
|
||||
return tools
|
||||
make_tool(i)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -28,7 +28,6 @@ from fastmcp import FastMCP
|
||||
from fastmcp.server.auth import AccessToken
|
||||
from fastmcp.server.auth import TokenVerifier
|
||||
from fastmcp.server.dependencies import get_access_token
|
||||
from fastmcp.server.server import FunctionTool
|
||||
|
||||
# Google's tokeninfo endpoint for validating access tokens
|
||||
GOOGLE_TOKENINFO_URL = "https://oauth2.googleapis.com/tokeninfo"
|
||||
@@ -148,24 +147,19 @@ class GoogleOAuthTokenVerifier(TokenVerifier):
|
||||
await self._http_client.aclose()
|
||||
|
||||
|
||||
def make_tools(mcp: FastMCP) -> list[FunctionTool]:
|
||||
def make_tools(mcp: FastMCP) -> None:
|
||||
"""Create test tools for the MCP server."""
|
||||
tools: list[FunctionTool] = []
|
||||
|
||||
@mcp.tool(name="echo", description="Echo back the input message")
|
||||
def echo(message: str) -> str:
|
||||
"""Echo the message back to the caller."""
|
||||
return f"You said: {message}"
|
||||
|
||||
tools.append(echo)
|
||||
|
||||
@mcp.tool(name="get_secret", description="Get a secret value (requires auth)")
|
||||
def get_secret(secret_name: str) -> str:
|
||||
"""Get a secret value. This proves the token was validated."""
|
||||
return f"Secret value for '{secret_name}': super-secret-value-12345"
|
||||
|
||||
tools.append(get_secret)
|
||||
|
||||
@mcp.tool(name="whoami", description="Get information about the authenticated user")
|
||||
async def whoami() -> dict[str, Any]:
|
||||
"""Get information about the authenticated user from their Google token."""
|
||||
@@ -182,9 +176,6 @@ def make_tools(mcp: FastMCP) -> list[FunctionTool]:
|
||||
"access_type": tok.claims.get("access_type"),
|
||||
}
|
||||
|
||||
tools.append(whoami)
|
||||
|
||||
# Add some numbered tools for testing tool discovery
|
||||
for i in range(5):
|
||||
|
||||
@mcp.tool(name=f"oauth_tool_{i}", description=f"Test tool number {i}")
|
||||
@@ -192,10 +183,6 @@ def make_tools(mcp: FastMCP) -> list[FunctionTool]:
|
||||
"""A numbered test tool."""
|
||||
return f"Tool {_i} says hello to {name}!"
|
||||
|
||||
tools.append(numbered_tool)
|
||||
|
||||
return tools
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
port = int(sys.argv[1] if len(sys.argv) > 1 else "8006")
|
||||
|
||||
@@ -2,7 +2,6 @@ import os
|
||||
import sys
|
||||
|
||||
from fastmcp import FastMCP
|
||||
from fastmcp.server.server import FunctionTool
|
||||
|
||||
mcp = FastMCP("My HTTP MCP")
|
||||
|
||||
@@ -13,19 +12,15 @@ def hello(name: str) -> str:
|
||||
return f"Hello, {name}!"
|
||||
|
||||
|
||||
def make_many_tools() -> list[FunctionTool]:
|
||||
def make_tool(i: int) -> FunctionTool:
|
||||
def make_many_tools() -> None:
|
||||
def make_tool(i: int) -> None:
|
||||
@mcp.tool(name=f"tool_{i}", description=f"Get secret value {i}")
|
||||
def tool_name(name: str) -> str: # noqa: ARG001
|
||||
"""Get secret value."""
|
||||
return f"Secret value {100 - i}!"
|
||||
|
||||
return tool_name
|
||||
|
||||
tools = []
|
||||
for i in range(100):
|
||||
tools.append(make_tool(i))
|
||||
return tools
|
||||
make_tool(i)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -15,7 +15,6 @@ from fastapi.responses import Response
|
||||
from fastmcp import FastMCP
|
||||
from fastmcp.server.auth.providers.jwt import JWTVerifier
|
||||
from fastmcp.server.dependencies import get_access_token
|
||||
from fastmcp.server.server import FunctionTool
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
|
||||
# uncomment for debug logs
|
||||
@@ -37,18 +36,15 @@ Enable authorization code and store the client id and secret.
|
||||
"""
|
||||
|
||||
|
||||
def make_many_tools(mcp: FastMCP) -> list[FunctionTool]:
|
||||
def make_tool(i: int) -> FunctionTool:
|
||||
def make_many_tools(mcp: FastMCP) -> None:
|
||||
def make_tool(i: int) -> None:
|
||||
@mcp.tool(name=f"tool_{i}", description=f"Get secret value {i}")
|
||||
def tool_name(name: str) -> str: # noqa: ARG001
|
||||
"""Get secret value."""
|
||||
return f"Secret value {500 - i}!"
|
||||
|
||||
return tool_name
|
||||
|
||||
tools = []
|
||||
for i in range(100):
|
||||
tools.append(make_tool(i))
|
||||
make_tool(i)
|
||||
|
||||
@mcp.tool
|
||||
async def whoami() -> dict[str, Any]:
|
||||
@@ -59,9 +55,6 @@ def make_many_tools(mcp: FastMCP) -> list[FunctionTool]:
|
||||
"claims": tok.claims if tok else {},
|
||||
}
|
||||
|
||||
tools.append(whoami)
|
||||
return tools
|
||||
|
||||
|
||||
# ---------- FASTAPI APP ----------
|
||||
|
||||
|
||||
@@ -10,7 +10,6 @@ from fastmcp import FastMCP
|
||||
from fastmcp.server.auth.auth import AccessToken
|
||||
from fastmcp.server.auth.auth import TokenVerifier
|
||||
from fastmcp.server.dependencies import get_access_token
|
||||
from fastmcp.server.server import FunctionTool
|
||||
|
||||
# pip install fastmcp bcrypt
|
||||
|
||||
@@ -93,19 +92,15 @@ class ApiKeyVerifier(TokenVerifier):
|
||||
# ---- server -----------------------------------------------------------------
|
||||
|
||||
|
||||
def make_many_tools(mcp: FastMCP) -> list[FunctionTool]:
|
||||
def make_tool(i: int) -> FunctionTool:
|
||||
def make_many_tools(mcp: FastMCP) -> None:
|
||||
def make_tool(i: int) -> None:
|
||||
@mcp.tool(name=f"tool_{i}", description=f"Get secret value {i}")
|
||||
def tool_name(name: str) -> str: # noqa: ARG001
|
||||
"""Get secret value."""
|
||||
return f"Secret value {400 - i}!"
|
||||
|
||||
return tool_name
|
||||
|
||||
tools = []
|
||||
for i in range(100):
|
||||
tools.append(make_tool(i))
|
||||
return tools
|
||||
make_tool(i)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -106,13 +106,13 @@ class TestGuildDataIsolation:
|
||||
|
||||
# Create admin user for tenant 1
|
||||
admin_user1: DATestUser = UserManager.create(
|
||||
email=f"discord_admin1+{unique}@example.com",
|
||||
email=f"discord_admin1_{unique}@example.com",
|
||||
)
|
||||
assert UserManager.is_role(admin_user1, UserRole.ADMIN)
|
||||
|
||||
# Create admin user for tenant 2
|
||||
admin_user2: DATestUser = UserManager.create(
|
||||
email=f"discord_admin2+{unique}@example.com",
|
||||
email=f"discord_admin2_{unique}@example.com",
|
||||
)
|
||||
assert UserManager.is_role(admin_user2, UserRole.ADMIN)
|
||||
|
||||
@@ -170,10 +170,10 @@ class TestGuildDataIsolation:
|
||||
|
||||
# Create admin users for two tenants
|
||||
admin_user1: DATestUser = UserManager.create(
|
||||
email=f"discord_list1+{unique}@example.com",
|
||||
email=f"discord_list1_{unique}@example.com",
|
||||
)
|
||||
admin_user2: DATestUser = UserManager.create(
|
||||
email=f"discord_list2+{unique}@example.com",
|
||||
email=f"discord_list2_{unique}@example.com",
|
||||
)
|
||||
|
||||
# Create 1 guild in tenant 1
|
||||
@@ -350,10 +350,10 @@ class TestGuildAccessIsolation:
|
||||
|
||||
# Create admin users for two tenants
|
||||
admin_user1: DATestUser = UserManager.create(
|
||||
email=f"discord_access1+{unique}@example.com",
|
||||
email=f"discord_access1_{unique}@example.com",
|
||||
)
|
||||
admin_user2: DATestUser = UserManager.create(
|
||||
email=f"discord_access2+{unique}@example.com",
|
||||
email=f"discord_access2_{unique}@example.com",
|
||||
)
|
||||
|
||||
# Create a guild in tenant 1
|
||||
|
||||
@@ -21,7 +21,7 @@ def test_admin_can_invite_users(reset_multitenant: None) -> None: # noqa: ARG00
|
||||
|
||||
# Admin user invites the previously registered and non-registered user
|
||||
UserManager.invite_user(invited_user.email, admin_user)
|
||||
UserManager.invite_user(f"{INVITED_BASIC_USER}+{unique}@example.com", admin_user)
|
||||
UserManager.invite_user(f"{INVITED_BASIC_USER}_{unique}@example.com", admin_user)
|
||||
|
||||
# Verify users are in the invited users list
|
||||
invited_users = UserManager.get_invited_users(admin_user)
|
||||
@@ -40,7 +40,7 @@ def test_non_registered_user_gets_basic_role(
|
||||
assert UserManager.is_role(admin_user, UserRole.ADMIN)
|
||||
|
||||
# Admin user invites a non-registered user
|
||||
invited_email = f"{INVITED_BASIC_USER}+{unique}@example.com"
|
||||
invited_email = f"{INVITED_BASIC_USER}_{unique}@example.com"
|
||||
UserManager.invite_user(invited_email, admin_user)
|
||||
|
||||
# Non-registered user registers
|
||||
@@ -58,7 +58,7 @@ def test_user_can_accept_invitation(reset_multitenant: None) -> None: # noqa: A
|
||||
assert UserManager.is_role(admin_user, UserRole.ADMIN)
|
||||
|
||||
# Create a user to be invited
|
||||
invited_user_email = f"invited_user+{unique}@example.com"
|
||||
invited_user_email = f"invited_user_{unique}@example.com"
|
||||
|
||||
# User registers with the same email as the invitation
|
||||
invited_user: DATestUser = UserManager.create(
|
||||
|
||||
@@ -20,13 +20,13 @@ def setup_test_tenants(reset_multitenant: None) -> dict[str, Any]: # noqa: ARG0
|
||||
unique = uuid4().hex
|
||||
# Creating an admin user for Tenant 1
|
||||
admin_user1: DATestUser = UserManager.create(
|
||||
email=f"admin+{unique}@example.com",
|
||||
email=f"admin_{unique}@example.com",
|
||||
)
|
||||
assert UserManager.is_role(admin_user1, UserRole.ADMIN)
|
||||
|
||||
# Create Tenant 2 and its Admin User
|
||||
admin_user2: DATestUser = UserManager.create(
|
||||
email=f"admin2+{unique}@example.com",
|
||||
email=f"admin2_{unique}@example.com",
|
||||
)
|
||||
assert UserManager.is_role(admin_user2, UserRole.ADMIN)
|
||||
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
"""
|
||||
Integration tests for onyx.db.engine.tenant_utils.get_schemas_needing_migration.
|
||||
|
||||
These tests require a live database and exercise the function directly,
|
||||
independent of the alembic migration runner script.
|
||||
|
||||
Usage:
|
||||
pytest tests/integration/multitenant_tests/test_get_schemas_needing_migration.py -v
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
import uuid
|
||||
from collections.abc import Generator
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.engine import Engine
|
||||
|
||||
from onyx.db.engine.sql_engine import SqlEngine
|
||||
from onyx.db.engine.tenant_utils import get_schemas_needing_migration
|
||||
|
||||
_BACKEND_DIR = __file__[: __file__.index("/tests/")]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fixtures
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def engine() -> Engine:
|
||||
return SqlEngine.get_engine()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def current_head_rev() -> str:
|
||||
result = subprocess.run(
|
||||
["alembic", "heads", "--resolve-dependencies"],
|
||||
cwd=_BACKEND_DIR,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.STDOUT,
|
||||
text=True,
|
||||
)
|
||||
assert (
|
||||
result.returncode == 0
|
||||
), f"alembic heads failed (exit {result.returncode}):\n{result.stdout}"
|
||||
rev = result.stdout.strip().split()[0]
|
||||
assert len(rev) > 0
|
||||
return rev
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def tenant_schema_at_head(
|
||||
engine: Engine, current_head_rev: str
|
||||
) -> Generator[str, None, None]:
|
||||
"""Tenant schema with alembic_version already at head — should be excluded."""
|
||||
schema = f"tenant_test_{uuid.uuid4().hex[:12]}"
|
||||
with engine.connect() as conn:
|
||||
conn.execute(text(f'CREATE SCHEMA "{schema}"'))
|
||||
conn.execute(
|
||||
text(
|
||||
f'CREATE TABLE "{schema}".alembic_version '
|
||||
f"(version_num VARCHAR(32) NOT NULL)"
|
||||
)
|
||||
)
|
||||
conn.execute(
|
||||
text(f'INSERT INTO "{schema}".alembic_version (version_num) VALUES (:rev)'),
|
||||
{"rev": current_head_rev},
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
yield schema
|
||||
|
||||
with engine.connect() as conn:
|
||||
conn.execute(text(f'DROP SCHEMA IF EXISTS "{schema}" CASCADE'))
|
||||
conn.commit()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def tenant_schema_empty(engine: Engine) -> Generator[str, None, None]:
|
||||
"""Tenant schema with no tables — should be included (needs migration)."""
|
||||
schema = f"tenant_test_{uuid.uuid4().hex[:12]}"
|
||||
with engine.connect() as conn:
|
||||
conn.execute(text(f'CREATE SCHEMA "{schema}"'))
|
||||
conn.commit()
|
||||
|
||||
yield schema
|
||||
|
||||
with engine.connect() as conn:
|
||||
conn.execute(text(f'DROP SCHEMA IF EXISTS "{schema}" CASCADE'))
|
||||
conn.commit()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def tenant_schema_stale_rev(engine: Engine) -> Generator[str, None, None]:
|
||||
"""Tenant schema with a non-head revision — should be included (needs migration)."""
|
||||
schema = f"tenant_test_{uuid.uuid4().hex[:12]}"
|
||||
with engine.connect() as conn:
|
||||
conn.execute(text(f'CREATE SCHEMA "{schema}"'))
|
||||
conn.execute(
|
||||
text(
|
||||
f'CREATE TABLE "{schema}".alembic_version '
|
||||
f"(version_num VARCHAR(32) NOT NULL)"
|
||||
)
|
||||
)
|
||||
conn.execute(
|
||||
text(
|
||||
f'INSERT INTO "{schema}".alembic_version (version_num) '
|
||||
f"VALUES ('stalerev000000000000')"
|
||||
)
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
yield schema
|
||||
|
||||
with engine.connect() as conn:
|
||||
conn.execute(text(f'DROP SCHEMA IF EXISTS "{schema}" CASCADE'))
|
||||
conn.commit()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_classifies_all_cases(
|
||||
current_head_rev: str,
|
||||
tenant_schema_at_head: str,
|
||||
tenant_schema_empty: str,
|
||||
tenant_schema_stale_rev: str,
|
||||
) -> None:
|
||||
"""Correctly classifies all three schema states:
|
||||
- at head → excluded
|
||||
- no table → included (needs migration)
|
||||
- stale rev → included (needs migration)
|
||||
"""
|
||||
all_schemas = [tenant_schema_at_head, tenant_schema_empty, tenant_schema_stale_rev]
|
||||
result = get_schemas_needing_migration(all_schemas, current_head_rev)
|
||||
|
||||
assert tenant_schema_at_head not in result
|
||||
assert tenant_schema_empty in result
|
||||
assert tenant_schema_stale_rev in result
|
||||
|
||||
|
||||
def test_idempotent(
|
||||
current_head_rev: str,
|
||||
tenant_schema_at_head: str,
|
||||
tenant_schema_empty: str,
|
||||
) -> None:
|
||||
"""Calling the function twice returns the same result.
|
||||
|
||||
Verifies that the DROP TABLE IF EXISTS guards correctly clean up temp
|
||||
tables so a second call succeeds even if the first left state behind.
|
||||
"""
|
||||
schemas = [tenant_schema_at_head, tenant_schema_empty]
|
||||
|
||||
first = get_schemas_needing_migration(schemas, current_head_rev)
|
||||
second = get_schemas_needing_migration(schemas, current_head_rev)
|
||||
|
||||
assert first == second
|
||||
|
||||
|
||||
def test_empty_input(current_head_rev: str) -> None:
|
||||
"""An empty input list returns immediately without touching the DB."""
|
||||
assert get_schemas_needing_migration([], current_head_rev) == []
|
||||
@@ -3,6 +3,7 @@ from fastapi import HTTPException
|
||||
|
||||
import onyx.auth.users as users
|
||||
from onyx.auth.users import verify_email_domain
|
||||
from onyx.configs.constants import AuthType
|
||||
|
||||
|
||||
def test_verify_email_domain_allows_case_insensitive_match(
|
||||
@@ -35,3 +36,37 @@ def test_verify_email_domain_invalid_email_format(
|
||||
verify_email_domain("userexample.com") # missing '@'
|
||||
assert exc.value.status_code == 400
|
||||
assert "Email is not valid" in exc.value.detail
|
||||
|
||||
|
||||
def test_verify_email_domain_rejects_plus_addressing(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.setattr(users, "VALID_EMAIL_DOMAINS", [], raising=False)
|
||||
monkeypatch.setattr(users, "AUTH_TYPE", AuthType.CLOUD, raising=False)
|
||||
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
verify_email_domain("user+tag@gmail.com")
|
||||
assert exc.value.status_code == 400
|
||||
assert "'+'" in str(exc.value.detail)
|
||||
|
||||
|
||||
def test_verify_email_domain_allows_plus_for_onyx_app(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.setattr(users, "VALID_EMAIL_DOMAINS", [], raising=False)
|
||||
monkeypatch.setattr(users, "AUTH_TYPE", AuthType.CLOUD, raising=False)
|
||||
|
||||
# Should not raise for onyx.app domain
|
||||
verify_email_domain("user+tag@onyx.app")
|
||||
|
||||
|
||||
def test_verify_email_domain_rejects_googlemail(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.setattr(users, "VALID_EMAIL_DOMAINS", [], raising=False)
|
||||
monkeypatch.setattr(users, "AUTH_TYPE", AuthType.CLOUD, raising=False)
|
||||
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
verify_email_domain("user@googlemail.com")
|
||||
assert exc.value.status_code == 400
|
||||
assert "gmail.com" in str(exc.value.detail)
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
from unittest.mock import MagicMock
|
||||
from unittest.mock import patch
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
|
||||
from onyx.background.celery.tasks.user_file_processing.tasks import (
|
||||
_user_file_project_sync_queued_key,
|
||||
)
|
||||
from onyx.background.celery.tasks.user_file_processing.tasks import (
|
||||
check_for_user_file_project_sync,
|
||||
)
|
||||
from onyx.background.celery.tasks.user_file_processing.tasks import (
|
||||
enqueue_user_file_project_sync_task,
|
||||
)
|
||||
from onyx.background.celery.tasks.user_file_processing.tasks import (
|
||||
process_single_user_file_project_sync,
|
||||
)
|
||||
from onyx.configs.constants import CELERY_USER_FILE_PROJECT_SYNC_TASK_EXPIRES
|
||||
from onyx.configs.constants import OnyxCeleryPriority
|
||||
from onyx.configs.constants import OnyxCeleryQueues
|
||||
from onyx.configs.constants import OnyxCeleryTask
|
||||
from onyx.configs.constants import USER_FILE_PROJECT_SYNC_MAX_QUEUE_DEPTH
|
||||
|
||||
|
||||
def _build_redis_mock_with_lock() -> tuple[MagicMock, MagicMock]:
|
||||
redis_client = MagicMock()
|
||||
lock = MagicMock()
|
||||
lock.acquire.return_value = True
|
||||
lock.owned.return_value = True
|
||||
redis_client.lock.return_value = lock
|
||||
return redis_client, lock
|
||||
|
||||
|
||||
@patch(
|
||||
"onyx.background.celery.tasks.user_file_processing.tasks."
|
||||
"get_user_file_project_sync_queue_depth"
|
||||
)
|
||||
@patch("onyx.background.celery.tasks.user_file_processing.tasks.get_redis_client")
|
||||
def test_check_for_user_file_project_sync_applies_queue_backpressure(
|
||||
mock_get_redis_client: MagicMock,
|
||||
mock_get_queue_depth: MagicMock,
|
||||
) -> None:
|
||||
redis_client, lock = _build_redis_mock_with_lock()
|
||||
mock_get_redis_client.return_value = redis_client
|
||||
mock_get_queue_depth.return_value = USER_FILE_PROJECT_SYNC_MAX_QUEUE_DEPTH + 1
|
||||
|
||||
task_app = MagicMock()
|
||||
with patch.object(check_for_user_file_project_sync, "app", task_app):
|
||||
check_for_user_file_project_sync.run(tenant_id="test-tenant")
|
||||
|
||||
task_app.send_task.assert_not_called()
|
||||
lock.release.assert_called_once()
|
||||
|
||||
|
||||
@patch(
|
||||
"onyx.background.celery.tasks.user_file_processing.tasks."
|
||||
"enqueue_user_file_project_sync_task"
|
||||
)
|
||||
@patch(
|
||||
"onyx.background.celery.tasks.user_file_processing.tasks."
|
||||
"get_user_file_project_sync_queue_depth"
|
||||
)
|
||||
@patch(
|
||||
"onyx.background.celery.tasks.user_file_processing.tasks."
|
||||
"get_session_with_current_tenant"
|
||||
)
|
||||
@patch("onyx.background.celery.tasks.user_file_processing.tasks.get_redis_client")
|
||||
def test_check_for_user_file_project_sync_skips_duplicates(
|
||||
mock_get_redis_client: MagicMock,
|
||||
mock_get_session: MagicMock,
|
||||
mock_get_queue_depth: MagicMock,
|
||||
mock_enqueue: MagicMock,
|
||||
) -> None:
|
||||
redis_client, lock = _build_redis_mock_with_lock()
|
||||
mock_get_redis_client.return_value = redis_client
|
||||
mock_get_queue_depth.return_value = 0
|
||||
|
||||
user_file_id_one = uuid4()
|
||||
user_file_id_two = uuid4()
|
||||
|
||||
session = MagicMock()
|
||||
session.execute.return_value.scalars.return_value.all.return_value = [
|
||||
user_file_id_one,
|
||||
user_file_id_two,
|
||||
]
|
||||
mock_get_session.return_value.__enter__.return_value = session
|
||||
mock_enqueue.side_effect = [True, False]
|
||||
|
||||
task_app = MagicMock()
|
||||
with patch.object(check_for_user_file_project_sync, "app", task_app):
|
||||
check_for_user_file_project_sync.run(tenant_id="test-tenant")
|
||||
|
||||
assert mock_enqueue.call_count == 2
|
||||
lock.release.assert_called_once()
|
||||
|
||||
|
||||
def test_enqueue_user_file_project_sync_task_sets_guard_and_expiry() -> None:
|
||||
redis_client = MagicMock()
|
||||
redis_client.set.return_value = True
|
||||
celery_app = MagicMock()
|
||||
user_file_id = str(uuid4())
|
||||
|
||||
enqueued = enqueue_user_file_project_sync_task(
|
||||
celery_app=celery_app,
|
||||
redis_client=redis_client,
|
||||
user_file_id=user_file_id,
|
||||
tenant_id="test-tenant",
|
||||
priority=OnyxCeleryPriority.HIGHEST,
|
||||
)
|
||||
|
||||
assert enqueued is True
|
||||
redis_client.set.assert_called_once_with(
|
||||
_user_file_project_sync_queued_key(user_file_id),
|
||||
1,
|
||||
nx=True,
|
||||
ex=CELERY_USER_FILE_PROJECT_SYNC_TASK_EXPIRES,
|
||||
)
|
||||
celery_app.send_task.assert_called_once_with(
|
||||
OnyxCeleryTask.PROCESS_SINGLE_USER_FILE_PROJECT_SYNC,
|
||||
kwargs={"user_file_id": user_file_id, "tenant_id": "test-tenant"},
|
||||
queue=OnyxCeleryQueues.USER_FILE_PROJECT_SYNC,
|
||||
priority=OnyxCeleryPriority.HIGHEST,
|
||||
expires=CELERY_USER_FILE_PROJECT_SYNC_TASK_EXPIRES,
|
||||
)
|
||||
|
||||
|
||||
def test_enqueue_user_file_project_sync_task_rolls_back_guard_on_publish_failure() -> (
|
||||
None
|
||||
):
|
||||
redis_client = MagicMock()
|
||||
redis_client.set.return_value = True
|
||||
celery_app = MagicMock()
|
||||
celery_app.send_task.side_effect = RuntimeError("publish failed")
|
||||
|
||||
user_file_id = str(uuid4())
|
||||
with pytest.raises(RuntimeError):
|
||||
enqueue_user_file_project_sync_task(
|
||||
celery_app=celery_app,
|
||||
redis_client=redis_client,
|
||||
user_file_id=user_file_id,
|
||||
tenant_id="test-tenant",
|
||||
)
|
||||
|
||||
redis_client.delete.assert_called_once_with(
|
||||
_user_file_project_sync_queued_key(user_file_id)
|
||||
)
|
||||
|
||||
|
||||
@patch("onyx.background.celery.tasks.user_file_processing.tasks.get_redis_client")
|
||||
def test_process_single_user_file_project_sync_clears_queued_guard_on_pickup(
|
||||
mock_get_redis_client: MagicMock,
|
||||
) -> None:
|
||||
redis_client = MagicMock()
|
||||
lock = MagicMock()
|
||||
lock.acquire.return_value = False
|
||||
redis_client.lock.return_value = lock
|
||||
mock_get_redis_client.return_value = redis_client
|
||||
|
||||
user_file_id = str(uuid4())
|
||||
process_single_user_file_project_sync.run(
|
||||
user_file_id=user_file_id,
|
||||
tenant_id="test-tenant",
|
||||
)
|
||||
|
||||
redis_client.delete.assert_called_once_with(
|
||||
_user_file_project_sync_queued_key(user_file_id)
|
||||
)
|
||||
@@ -0,0 +1,95 @@
|
||||
from onyx.configs.constants import DocumentSource
|
||||
from onyx.connectors.models import Document
|
||||
from onyx.connectors.models import DocumentBase
|
||||
from onyx.connectors.models import TextSection
|
||||
|
||||
|
||||
def _minimal_doc_kwargs(metadata: dict) -> dict:
|
||||
return {
|
||||
"id": "test-doc",
|
||||
"sections": [TextSection(text="hello", link="http://example.com")],
|
||||
"source": DocumentSource.NOT_APPLICABLE,
|
||||
"semantic_identifier": "Test Doc",
|
||||
"metadata": metadata,
|
||||
}
|
||||
|
||||
|
||||
def test_int_values_coerced_to_str() -> None:
|
||||
doc = Document(**_minimal_doc_kwargs({"count": 42}))
|
||||
assert doc.metadata == {"count": "42"}
|
||||
|
||||
|
||||
def test_float_values_coerced_to_str() -> None:
|
||||
doc = Document(**_minimal_doc_kwargs({"score": 3.14}))
|
||||
assert doc.metadata == {"score": "3.14"}
|
||||
|
||||
|
||||
def test_bool_values_coerced_to_str() -> None:
|
||||
doc = Document(**_minimal_doc_kwargs({"active": True}))
|
||||
assert doc.metadata == {"active": "True"}
|
||||
|
||||
|
||||
def test_list_of_ints_coerced_to_list_of_str() -> None:
|
||||
doc = Document(**_minimal_doc_kwargs({"ids": [1, 2, 3]}))
|
||||
assert doc.metadata == {"ids": ["1", "2", "3"]}
|
||||
|
||||
|
||||
def test_list_of_mixed_types_coerced_to_list_of_str() -> None:
|
||||
doc = Document(**_minimal_doc_kwargs({"tags": ["a", 1, True, 2.5]}))
|
||||
assert doc.metadata == {"tags": ["a", "1", "True", "2.5"]}
|
||||
|
||||
|
||||
def test_list_of_dicts_coerced_to_list_of_str() -> None:
|
||||
raw = {"nested": [{"key": "val"}, {"key2": "val2"}]}
|
||||
doc = Document(**_minimal_doc_kwargs(raw))
|
||||
assert doc.metadata == {"nested": ["{'key': 'val'}", "{'key2': 'val2'}"]}
|
||||
|
||||
|
||||
def test_dict_value_coerced_to_str() -> None:
|
||||
raw = {"info": {"inner_key": "inner_val"}}
|
||||
doc = Document(**_minimal_doc_kwargs(raw))
|
||||
assert doc.metadata == {"info": "{'inner_key': 'inner_val'}"}
|
||||
|
||||
|
||||
def test_none_value_coerced_to_str() -> None:
|
||||
doc = Document(**_minimal_doc_kwargs({"empty": None}))
|
||||
assert doc.metadata == {"empty": "None"}
|
||||
|
||||
|
||||
def test_already_valid_str_values_unchanged() -> None:
|
||||
doc = Document(**_minimal_doc_kwargs({"key": "value"}))
|
||||
assert doc.metadata == {"key": "value"}
|
||||
|
||||
|
||||
def test_already_valid_list_of_str_unchanged() -> None:
|
||||
doc = Document(**_minimal_doc_kwargs({"tags": ["a", "b", "c"]}))
|
||||
assert doc.metadata == {"tags": ["a", "b", "c"]}
|
||||
|
||||
|
||||
def test_empty_metadata_unchanged() -> None:
|
||||
doc = Document(**_minimal_doc_kwargs({}))
|
||||
assert doc.metadata == {}
|
||||
|
||||
|
||||
def test_mixed_metadata_values() -> None:
|
||||
raw = {
|
||||
"str_val": "hello",
|
||||
"int_val": 99,
|
||||
"list_val": [1, "two", 3.0],
|
||||
"dict_val": {"nested": True},
|
||||
}
|
||||
doc = Document(**_minimal_doc_kwargs(raw))
|
||||
assert doc.metadata == {
|
||||
"str_val": "hello",
|
||||
"int_val": "99",
|
||||
"list_val": ["1", "two", "3.0"],
|
||||
"dict_val": "{'nested': True}",
|
||||
}
|
||||
|
||||
|
||||
def test_coercion_works_on_base_class() -> None:
|
||||
kwargs = _minimal_doc_kwargs({"count": 42})
|
||||
kwargs.pop("source")
|
||||
kwargs.pop("id")
|
||||
doc = DocumentBase(**kwargs)
|
||||
assert doc.metadata == {"count": "42"}
|
||||
@@ -97,6 +97,7 @@ class TestScimDALUserMappings:
|
||||
assert model_attrs(added_obj) == {
|
||||
"external_id": "ext-1",
|
||||
"user_id": user_id,
|
||||
"scim_username": None,
|
||||
}
|
||||
|
||||
def test_delete_user_mapping(
|
||||
|
||||
@@ -15,7 +15,10 @@ from sqlalchemy.orm import Session
|
||||
from ee.onyx.server.scim.models import ScimGroupResource
|
||||
from ee.onyx.server.scim.models import ScimName
|
||||
from ee.onyx.server.scim.models import ScimUserResource
|
||||
from ee.onyx.server.scim.providers.base import ScimProvider
|
||||
from ee.onyx.server.scim.providers.okta import OktaProvider
|
||||
from onyx.db.models import ScimToken
|
||||
from onyx.db.models import ScimUserMapping
|
||||
from onyx.db.models import User
|
||||
from onyx.db.models import UserGroup
|
||||
from onyx.db.models import UserRole
|
||||
@@ -35,6 +38,12 @@ def mock_token() -> MagicMock:
|
||||
return token
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def provider() -> ScimProvider:
|
||||
"""An OktaProvider instance for endpoint tests."""
|
||||
return OktaProvider()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_dal() -> Generator[MagicMock, None, None]:
|
||||
"""Patch ScimDAL construction in api module and yield the mock instance."""
|
||||
@@ -53,6 +62,9 @@ def mock_dal() -> Generator[MagicMock, None, None]:
|
||||
dal.get_group_mapping_by_external_id.return_value = None
|
||||
dal.get_group_members.return_value = []
|
||||
dal.list_groups.return_value = ([], 0)
|
||||
# User-group relationship defaults
|
||||
dal.get_user_groups.return_value = []
|
||||
dal.get_users_groups_batch.return_value = {}
|
||||
yield dal
|
||||
|
||||
|
||||
@@ -96,6 +108,16 @@ def make_db_group(**kwargs: Any) -> MagicMock:
|
||||
return group
|
||||
|
||||
|
||||
def make_user_mapping(**kwargs: Any) -> MagicMock:
|
||||
"""Build a mock ScimUserMapping ORM object with configurable attributes."""
|
||||
mapping = MagicMock(spec=ScimUserMapping)
|
||||
mapping.id = kwargs.get("id", 1)
|
||||
mapping.external_id = kwargs.get("external_id", "ext-default")
|
||||
mapping.user_id = kwargs.get("user_id", uuid4())
|
||||
mapping.scim_username = kwargs.get("scim_username", None)
|
||||
return mapping
|
||||
|
||||
|
||||
def assert_scim_error(result: object, expected_status: int) -> None:
|
||||
"""Assert *result* is a JSONResponse with the given status code."""
|
||||
assert isinstance(result, JSONResponse)
|
||||
|
||||
@@ -21,6 +21,7 @@ from ee.onyx.server.scim.models import ScimPatchOperation
|
||||
from ee.onyx.server.scim.models import ScimPatchOperationType
|
||||
from ee.onyx.server.scim.models import ScimPatchRequest
|
||||
from ee.onyx.server.scim.patch import ScimPatchError
|
||||
from ee.onyx.server.scim.providers.base import ScimProvider
|
||||
from tests.unit.onyx.server.scim.conftest import assert_scim_error
|
||||
from tests.unit.onyx.server.scim.conftest import make_db_group
|
||||
from tests.unit.onyx.server.scim.conftest import make_scim_group
|
||||
@@ -34,6 +35,7 @@ class TestListGroups:
|
||||
mock_db_session: MagicMock,
|
||||
mock_token: MagicMock,
|
||||
mock_dal: MagicMock,
|
||||
provider: ScimProvider,
|
||||
) -> None:
|
||||
mock_dal.list_groups.return_value = ([], 0)
|
||||
|
||||
@@ -42,6 +44,7 @@ class TestListGroups:
|
||||
startIndex=1,
|
||||
count=100,
|
||||
_token=mock_token,
|
||||
provider=provider,
|
||||
db_session=mock_db_session,
|
||||
)
|
||||
|
||||
@@ -54,6 +57,7 @@ class TestListGroups:
|
||||
mock_db_session: MagicMock,
|
||||
mock_token: MagicMock,
|
||||
mock_dal: MagicMock,
|
||||
provider: ScimProvider,
|
||||
) -> None:
|
||||
mock_dal.list_groups.side_effect = ValueError(
|
||||
"Unsupported filter attribute: userName"
|
||||
@@ -64,6 +68,7 @@ class TestListGroups:
|
||||
startIndex=1,
|
||||
count=100,
|
||||
_token=mock_token,
|
||||
provider=provider,
|
||||
db_session=mock_db_session,
|
||||
)
|
||||
|
||||
@@ -74,6 +79,7 @@ class TestListGroups:
|
||||
mock_db_session: MagicMock,
|
||||
mock_token: MagicMock,
|
||||
mock_dal: MagicMock,
|
||||
provider: ScimProvider,
|
||||
) -> None:
|
||||
group = make_db_group(id=5, name="Engineering")
|
||||
uid = uuid4()
|
||||
@@ -85,6 +91,7 @@ class TestListGroups:
|
||||
startIndex=1,
|
||||
count=100,
|
||||
_token=mock_token,
|
||||
provider=provider,
|
||||
db_session=mock_db_session,
|
||||
)
|
||||
|
||||
@@ -106,6 +113,7 @@ class TestGetGroup:
|
||||
mock_db_session: MagicMock,
|
||||
mock_token: MagicMock,
|
||||
mock_dal: MagicMock,
|
||||
provider: ScimProvider,
|
||||
) -> None:
|
||||
group = make_db_group(id=5, name="Engineering")
|
||||
mock_dal.get_group.return_value = group
|
||||
@@ -114,6 +122,7 @@ class TestGetGroup:
|
||||
result = get_group(
|
||||
group_id="5",
|
||||
_token=mock_token,
|
||||
provider=provider,
|
||||
db_session=mock_db_session,
|
||||
)
|
||||
|
||||
@@ -126,10 +135,12 @@ class TestGetGroup:
|
||||
mock_db_session: MagicMock,
|
||||
mock_token: MagicMock,
|
||||
mock_dal: MagicMock, # noqa: ARG002
|
||||
provider: ScimProvider,
|
||||
) -> None:
|
||||
result = get_group(
|
||||
group_id="not-a-number",
|
||||
_token=mock_token,
|
||||
provider=provider,
|
||||
db_session=mock_db_session,
|
||||
)
|
||||
|
||||
@@ -140,12 +151,14 @@ class TestGetGroup:
|
||||
mock_db_session: MagicMock,
|
||||
mock_token: MagicMock,
|
||||
mock_dal: MagicMock,
|
||||
provider: ScimProvider,
|
||||
) -> None:
|
||||
mock_dal.get_group.return_value = None
|
||||
|
||||
result = get_group(
|
||||
group_id="999",
|
||||
_token=mock_token,
|
||||
provider=provider,
|
||||
db_session=mock_db_session,
|
||||
)
|
||||
|
||||
@@ -162,6 +175,7 @@ class TestCreateGroup:
|
||||
mock_db_session: MagicMock,
|
||||
mock_token: MagicMock,
|
||||
mock_dal: MagicMock,
|
||||
provider: ScimProvider,
|
||||
) -> None:
|
||||
mock_dal.get_group_by_name.return_value = None
|
||||
mock_validate.return_value = ([], None)
|
||||
@@ -172,6 +186,7 @@ class TestCreateGroup:
|
||||
result = create_group(
|
||||
group_resource=resource,
|
||||
_token=mock_token,
|
||||
provider=provider,
|
||||
db_session=mock_db_session,
|
||||
)
|
||||
|
||||
@@ -185,6 +200,7 @@ class TestCreateGroup:
|
||||
mock_db_session: MagicMock,
|
||||
mock_token: MagicMock,
|
||||
mock_dal: MagicMock,
|
||||
provider: ScimProvider,
|
||||
) -> None:
|
||||
mock_dal.get_group_by_name.return_value = make_db_group()
|
||||
resource = make_scim_group()
|
||||
@@ -192,6 +208,7 @@ class TestCreateGroup:
|
||||
result = create_group(
|
||||
group_resource=resource,
|
||||
_token=mock_token,
|
||||
provider=provider,
|
||||
db_session=mock_db_session,
|
||||
)
|
||||
|
||||
@@ -204,6 +221,7 @@ class TestCreateGroup:
|
||||
mock_db_session: MagicMock,
|
||||
mock_token: MagicMock,
|
||||
mock_dal: MagicMock,
|
||||
provider: ScimProvider,
|
||||
) -> None:
|
||||
mock_dal.get_group_by_name.return_value = None
|
||||
mock_validate.return_value = ([], "Invalid member ID: bad-uuid")
|
||||
@@ -213,6 +231,7 @@ class TestCreateGroup:
|
||||
result = create_group(
|
||||
group_resource=resource,
|
||||
_token=mock_token,
|
||||
provider=provider,
|
||||
db_session=mock_db_session,
|
||||
)
|
||||
|
||||
@@ -225,6 +244,7 @@ class TestCreateGroup:
|
||||
mock_db_session: MagicMock,
|
||||
mock_token: MagicMock,
|
||||
mock_dal: MagicMock,
|
||||
provider: ScimProvider,
|
||||
) -> None:
|
||||
mock_dal.get_group_by_name.return_value = None
|
||||
uid = uuid4()
|
||||
@@ -235,6 +255,7 @@ class TestCreateGroup:
|
||||
result = create_group(
|
||||
group_resource=resource,
|
||||
_token=mock_token,
|
||||
provider=provider,
|
||||
db_session=mock_db_session,
|
||||
)
|
||||
|
||||
@@ -247,6 +268,7 @@ class TestCreateGroup:
|
||||
mock_db_session: MagicMock,
|
||||
mock_token: MagicMock,
|
||||
mock_dal: MagicMock,
|
||||
provider: ScimProvider,
|
||||
) -> None:
|
||||
mock_dal.get_group_by_name.return_value = None
|
||||
mock_validate.return_value = ([], None)
|
||||
@@ -257,6 +279,7 @@ class TestCreateGroup:
|
||||
result = create_group(
|
||||
group_resource=resource,
|
||||
_token=mock_token,
|
||||
provider=provider,
|
||||
db_session=mock_db_session,
|
||||
)
|
||||
|
||||
@@ -274,6 +297,7 @@ class TestReplaceGroup:
|
||||
mock_db_session: MagicMock,
|
||||
mock_token: MagicMock,
|
||||
mock_dal: MagicMock,
|
||||
provider: ScimProvider,
|
||||
) -> None:
|
||||
group = make_db_group(id=5, name="Old Name")
|
||||
mock_dal.get_group.return_value = group
|
||||
@@ -286,6 +310,7 @@ class TestReplaceGroup:
|
||||
group_id="5",
|
||||
group_resource=resource,
|
||||
_token=mock_token,
|
||||
provider=provider,
|
||||
db_session=mock_db_session,
|
||||
)
|
||||
|
||||
@@ -299,6 +324,7 @@ class TestReplaceGroup:
|
||||
mock_db_session: MagicMock,
|
||||
mock_token: MagicMock,
|
||||
mock_dal: MagicMock,
|
||||
provider: ScimProvider,
|
||||
) -> None:
|
||||
mock_dal.get_group.return_value = None
|
||||
|
||||
@@ -306,6 +332,7 @@ class TestReplaceGroup:
|
||||
group_id="999",
|
||||
group_resource=make_scim_group(),
|
||||
_token=mock_token,
|
||||
provider=provider,
|
||||
db_session=mock_db_session,
|
||||
)
|
||||
|
||||
@@ -318,6 +345,7 @@ class TestReplaceGroup:
|
||||
mock_db_session: MagicMock,
|
||||
mock_token: MagicMock,
|
||||
mock_dal: MagicMock,
|
||||
provider: ScimProvider,
|
||||
) -> None:
|
||||
group = make_db_group(id=5)
|
||||
mock_dal.get_group.return_value = group
|
||||
@@ -329,6 +357,7 @@ class TestReplaceGroup:
|
||||
group_id="5",
|
||||
group_resource=resource,
|
||||
_token=mock_token,
|
||||
provider=provider,
|
||||
db_session=mock_db_session,
|
||||
)
|
||||
|
||||
@@ -341,6 +370,7 @@ class TestReplaceGroup:
|
||||
mock_db_session: MagicMock,
|
||||
mock_token: MagicMock,
|
||||
mock_dal: MagicMock,
|
||||
provider: ScimProvider,
|
||||
) -> None:
|
||||
group = make_db_group(id=5)
|
||||
mock_dal.get_group.return_value = group
|
||||
@@ -353,6 +383,7 @@ class TestReplaceGroup:
|
||||
group_id="5",
|
||||
group_resource=resource,
|
||||
_token=mock_token,
|
||||
provider=provider,
|
||||
db_session=mock_db_session,
|
||||
)
|
||||
|
||||
@@ -369,6 +400,7 @@ class TestPatchGroup:
|
||||
mock_db_session: MagicMock,
|
||||
mock_token: MagicMock,
|
||||
mock_dal: MagicMock,
|
||||
provider: ScimProvider,
|
||||
) -> None:
|
||||
group = make_db_group(id=5, name="Old Name")
|
||||
mock_dal.get_group.return_value = group
|
||||
@@ -391,6 +423,7 @@ class TestPatchGroup:
|
||||
group_id="5",
|
||||
patch_request=patch_req,
|
||||
_token=mock_token,
|
||||
provider=provider,
|
||||
db_session=mock_db_session,
|
||||
)
|
||||
|
||||
@@ -402,6 +435,7 @@ class TestPatchGroup:
|
||||
mock_db_session: MagicMock,
|
||||
mock_token: MagicMock,
|
||||
mock_dal: MagicMock,
|
||||
provider: ScimProvider,
|
||||
) -> None:
|
||||
mock_dal.get_group.return_value = None
|
||||
|
||||
@@ -419,6 +453,7 @@ class TestPatchGroup:
|
||||
group_id="999",
|
||||
patch_request=patch_req,
|
||||
_token=mock_token,
|
||||
provider=provider,
|
||||
db_session=mock_db_session,
|
||||
)
|
||||
|
||||
@@ -431,6 +466,7 @@ class TestPatchGroup:
|
||||
mock_db_session: MagicMock,
|
||||
mock_token: MagicMock,
|
||||
mock_dal: MagicMock,
|
||||
provider: ScimProvider,
|
||||
) -> None:
|
||||
group = make_db_group(id=5)
|
||||
mock_dal.get_group.return_value = group
|
||||
@@ -452,6 +488,7 @@ class TestPatchGroup:
|
||||
group_id="5",
|
||||
patch_request=patch_req,
|
||||
_token=mock_token,
|
||||
provider=provider,
|
||||
db_session=mock_db_session,
|
||||
)
|
||||
|
||||
@@ -464,6 +501,7 @@ class TestPatchGroup:
|
||||
mock_db_session: MagicMock,
|
||||
mock_token: MagicMock,
|
||||
mock_dal: MagicMock,
|
||||
provider: ScimProvider,
|
||||
) -> None:
|
||||
group = make_db_group(id=5)
|
||||
mock_dal.get_group.return_value = group
|
||||
@@ -483,7 +521,7 @@ class TestPatchGroup:
|
||||
ScimPatchOperation(
|
||||
op=ScimPatchOperationType.ADD,
|
||||
path="members",
|
||||
value=[{"value": uid}],
|
||||
value=[ScimGroupMember(value=uid)],
|
||||
)
|
||||
]
|
||||
)
|
||||
@@ -492,6 +530,7 @@ class TestPatchGroup:
|
||||
group_id="5",
|
||||
patch_request=patch_req,
|
||||
_token=mock_token,
|
||||
provider=provider,
|
||||
db_session=mock_db_session,
|
||||
)
|
||||
|
||||
@@ -506,6 +545,7 @@ class TestPatchGroup:
|
||||
mock_db_session: MagicMock,
|
||||
mock_token: MagicMock,
|
||||
mock_dal: MagicMock,
|
||||
provider: ScimProvider,
|
||||
) -> None:
|
||||
group = make_db_group(id=5)
|
||||
mock_dal.get_group.return_value = group
|
||||
@@ -525,7 +565,7 @@ class TestPatchGroup:
|
||||
ScimPatchOperation(
|
||||
op=ScimPatchOperationType.ADD,
|
||||
path="members",
|
||||
value=[{"value": str(uid)}],
|
||||
value=[ScimGroupMember(value=str(uid))],
|
||||
)
|
||||
]
|
||||
)
|
||||
@@ -534,6 +574,7 @@ class TestPatchGroup:
|
||||
group_id="5",
|
||||
patch_request=patch_req,
|
||||
_token=mock_token,
|
||||
provider=provider,
|
||||
db_session=mock_db_session,
|
||||
)
|
||||
|
||||
@@ -546,6 +587,7 @@ class TestPatchGroup:
|
||||
mock_db_session: MagicMock,
|
||||
mock_token: MagicMock,
|
||||
mock_dal: MagicMock,
|
||||
provider: ScimProvider,
|
||||
) -> None:
|
||||
group = make_db_group(id=5)
|
||||
mock_dal.get_group.return_value = group
|
||||
@@ -568,6 +610,7 @@ class TestPatchGroup:
|
||||
group_id="5",
|
||||
patch_request=patch_req,
|
||||
_token=mock_token,
|
||||
provider=provider,
|
||||
db_session=mock_db_session,
|
||||
)
|
||||
|
||||
|
||||
@@ -2,13 +2,19 @@ import pytest
|
||||
|
||||
from ee.onyx.server.scim.models import ScimGroupMember
|
||||
from ee.onyx.server.scim.models import ScimGroupResource
|
||||
from ee.onyx.server.scim.models import ScimMeta
|
||||
from ee.onyx.server.scim.models import ScimName
|
||||
from ee.onyx.server.scim.models import ScimPatchOperation
|
||||
from ee.onyx.server.scim.models import ScimPatchOperationType
|
||||
from ee.onyx.server.scim.models import ScimPatchResourceValue
|
||||
from ee.onyx.server.scim.models import ScimPatchValue
|
||||
from ee.onyx.server.scim.models import ScimUserResource
|
||||
from ee.onyx.server.scim.patch import apply_group_patch
|
||||
from ee.onyx.server.scim.patch import apply_user_patch
|
||||
from ee.onyx.server.scim.patch import ScimPatchError
|
||||
from ee.onyx.server.scim.providers.okta import OktaProvider
|
||||
|
||||
_OKTA_IGNORED = OktaProvider().ignored_patch_paths
|
||||
|
||||
|
||||
def _make_user(**kwargs: object) -> ScimUserResource:
|
||||
@@ -29,14 +35,14 @@ def _make_group(**kwargs: object) -> ScimGroupResource:
|
||||
|
||||
def _replace_op(
|
||||
path: str | None = None,
|
||||
value: str | bool | dict | list | None = None,
|
||||
value: ScimPatchValue = None,
|
||||
) -> ScimPatchOperation:
|
||||
return ScimPatchOperation(op=ScimPatchOperationType.REPLACE, path=path, value=value)
|
||||
|
||||
|
||||
def _add_op(
|
||||
path: str | None = None,
|
||||
value: str | bool | dict | list | None = None,
|
||||
value: ScimPatchValue = None,
|
||||
) -> ScimPatchOperation:
|
||||
return ScimPatchOperation(op=ScimPatchOperationType.ADD, path=path, value=value)
|
||||
|
||||
@@ -80,7 +86,12 @@ class TestApplyUserPatch:
|
||||
def test_replace_without_path_uses_dict(self) -> None:
|
||||
user = _make_user()
|
||||
result = apply_user_patch(
|
||||
[_replace_op(None, {"active": False, "userName": "new@example.com"})],
|
||||
[
|
||||
_replace_op(
|
||||
None,
|
||||
ScimPatchResourceValue(active=False, userName="new@example.com"),
|
||||
)
|
||||
],
|
||||
user,
|
||||
)
|
||||
assert result.active is False
|
||||
@@ -119,6 +130,86 @@ class TestApplyUserPatch:
|
||||
with pytest.raises(ScimPatchError, match="Unsupported operation"):
|
||||
apply_user_patch([_remove_op("active")], user)
|
||||
|
||||
def test_replace_without_path_ignores_id(self) -> None:
|
||||
"""Okta sends 'id' alongside actual changes — it should be silently ignored."""
|
||||
user = _make_user()
|
||||
result = apply_user_patch(
|
||||
[_replace_op(None, ScimPatchResourceValue(active=False, id="some-uuid"))],
|
||||
user,
|
||||
ignored_paths=_OKTA_IGNORED,
|
||||
)
|
||||
assert result.active is False
|
||||
|
||||
def test_replace_without_path_ignores_schemas(self) -> None:
|
||||
"""The 'schemas' key in a value dict should be silently ignored."""
|
||||
user = _make_user()
|
||||
result = apply_user_patch(
|
||||
[
|
||||
_replace_op(
|
||||
None,
|
||||
ScimPatchResourceValue(
|
||||
active=False,
|
||||
schemas=["urn:ietf:params:scim:schemas:core:2.0:User"],
|
||||
),
|
||||
)
|
||||
],
|
||||
user,
|
||||
ignored_paths=_OKTA_IGNORED,
|
||||
)
|
||||
assert result.active is False
|
||||
|
||||
def test_okta_deactivation_payload(self) -> None:
|
||||
"""Exact Okta deactivation payload: path-less replace with id + active."""
|
||||
user = _make_user()
|
||||
result = apply_user_patch(
|
||||
[
|
||||
_replace_op(
|
||||
None,
|
||||
ScimPatchResourceValue(id="abc-123", active=False),
|
||||
)
|
||||
],
|
||||
user,
|
||||
ignored_paths=_OKTA_IGNORED,
|
||||
)
|
||||
assert result.active is False
|
||||
assert result.userName == "test@example.com"
|
||||
|
||||
def test_replace_displayname(self) -> None:
|
||||
user = _make_user()
|
||||
result = apply_user_patch(
|
||||
[_replace_op("displayName", "New Display Name")], user
|
||||
)
|
||||
assert result.displayName == "New Display Name"
|
||||
assert result.name is not None
|
||||
assert result.name.formatted == "New Display Name"
|
||||
|
||||
def test_replace_without_path_complex_value_dict(self) -> None:
|
||||
"""Okta sends id/schemas/meta alongside actual changes — complex types
|
||||
(lists, nested dicts) must not cause Pydantic validation errors."""
|
||||
user = _make_user()
|
||||
result = apply_user_patch(
|
||||
[
|
||||
_replace_op(
|
||||
None,
|
||||
ScimPatchResourceValue(
|
||||
active=False,
|
||||
id="some-uuid",
|
||||
schemas=["urn:ietf:params:scim:schemas:core:2.0:User"],
|
||||
meta=ScimMeta(resourceType="User"),
|
||||
),
|
||||
)
|
||||
],
|
||||
user,
|
||||
ignored_paths=_OKTA_IGNORED,
|
||||
)
|
||||
assert result.active is False
|
||||
assert result.userName == "test@example.com"
|
||||
|
||||
def test_add_operation_works_like_replace(self) -> None:
|
||||
user = _make_user()
|
||||
result = apply_user_patch([_add_op("externalId", "ext-456")], user)
|
||||
assert result.externalId == "ext-456"
|
||||
|
||||
|
||||
class TestApplyGroupPatch:
|
||||
"""Tests for SCIM group PATCH operations."""
|
||||
@@ -135,7 +226,12 @@ class TestApplyGroupPatch:
|
||||
def test_add_members(self) -> None:
|
||||
group = _make_group()
|
||||
result, added, removed = apply_group_patch(
|
||||
[_add_op("members", [{"value": "user-1"}, {"value": "user-2"}])],
|
||||
[
|
||||
_add_op(
|
||||
"members",
|
||||
[ScimGroupMember(value="user-1"), ScimGroupMember(value="user-2")],
|
||||
)
|
||||
],
|
||||
group,
|
||||
)
|
||||
assert len(result.members) == 2
|
||||
@@ -145,7 +241,7 @@ class TestApplyGroupPatch:
|
||||
def test_add_members_without_path(self) -> None:
|
||||
group = _make_group()
|
||||
result, added, _ = apply_group_patch(
|
||||
[_add_op(None, [{"value": "user-1"}])],
|
||||
[_add_op(None, [ScimGroupMember(value="user-1")])],
|
||||
group,
|
||||
)
|
||||
assert len(result.members) == 1
|
||||
@@ -154,7 +250,12 @@ class TestApplyGroupPatch:
|
||||
def test_add_duplicate_member_skipped(self) -> None:
|
||||
group = _make_group(members=[ScimGroupMember(value="user-1")])
|
||||
result, added, _ = apply_group_patch(
|
||||
[_add_op("members", [{"value": "user-1"}, {"value": "user-2"}])],
|
||||
[
|
||||
_add_op(
|
||||
"members",
|
||||
[ScimGroupMember(value="user-1"), ScimGroupMember(value="user-2")],
|
||||
)
|
||||
],
|
||||
group,
|
||||
)
|
||||
assert len(result.members) == 2
|
||||
@@ -190,7 +291,7 @@ class TestApplyGroupPatch:
|
||||
result, added, removed = apply_group_patch(
|
||||
[
|
||||
_replace_op("displayName", "Renamed"),
|
||||
_add_op("members", [{"value": "user-2"}]),
|
||||
_add_op("members", [ScimGroupMember(value="user-2")]),
|
||||
_remove_op('members[value eq "user-1"]'),
|
||||
],
|
||||
group,
|
||||
@@ -221,7 +322,12 @@ class TestApplyGroupPatch:
|
||||
]
|
||||
)
|
||||
result, added, removed = apply_group_patch(
|
||||
[_replace_op("members", [{"value": "user-2"}, {"value": "user-3"}])],
|
||||
[
|
||||
_replace_op(
|
||||
"members",
|
||||
[ScimGroupMember(value="user-2"), ScimGroupMember(value="user-3")],
|
||||
)
|
||||
],
|
||||
group,
|
||||
)
|
||||
assert len(result.members) == 2
|
||||
@@ -256,3 +362,55 @@ class TestApplyGroupPatch:
|
||||
group = _make_group()
|
||||
apply_group_patch([_replace_op("displayName", "Changed")], group)
|
||||
assert group.displayName == "Engineering"
|
||||
|
||||
def test_replace_without_path_ignores_id(self) -> None:
|
||||
"""Group replace with 'id' in value dict should be silently ignored."""
|
||||
group = _make_group()
|
||||
result, _, _ = apply_group_patch(
|
||||
[
|
||||
_replace_op(
|
||||
None, ScimPatchResourceValue(displayName="Updated", id="some-id")
|
||||
)
|
||||
],
|
||||
group,
|
||||
ignored_paths=_OKTA_IGNORED,
|
||||
)
|
||||
assert result.displayName == "Updated"
|
||||
|
||||
def test_replace_without_path_ignores_schemas(self) -> None:
|
||||
group = _make_group()
|
||||
result, _, _ = apply_group_patch(
|
||||
[
|
||||
_replace_op(
|
||||
None,
|
||||
ScimPatchResourceValue(
|
||||
displayName="Updated",
|
||||
schemas=["urn:ietf:params:scim:schemas:core:2.0:Group"],
|
||||
),
|
||||
)
|
||||
],
|
||||
group,
|
||||
ignored_paths=_OKTA_IGNORED,
|
||||
)
|
||||
assert result.displayName == "Updated"
|
||||
|
||||
def test_replace_without_path_complex_value_dict(self) -> None:
|
||||
"""Group PATCH with complex types in value dict (lists, nested dicts)
|
||||
must not cause Pydantic validation errors."""
|
||||
group = _make_group()
|
||||
result, _, _ = apply_group_patch(
|
||||
[
|
||||
_replace_op(
|
||||
None,
|
||||
ScimPatchResourceValue(
|
||||
displayName="Updated",
|
||||
id="123",
|
||||
schemas=["urn:ietf:params:scim:schemas:core:2.0:Group"],
|
||||
meta=ScimMeta(resourceType="Group"),
|
||||
),
|
||||
)
|
||||
],
|
||||
group,
|
||||
ignored_paths=_OKTA_IGNORED,
|
||||
)
|
||||
assert result.displayName == "Updated"
|
||||
|
||||
167
backend/tests/unit/onyx/server/scim/test_providers.py
Normal file
167
backend/tests/unit/onyx/server/scim/test_providers.py
Normal file
@@ -0,0 +1,167 @@
|
||||
from unittest.mock import MagicMock
|
||||
from uuid import UUID
|
||||
from uuid import uuid4
|
||||
|
||||
from ee.onyx.server.scim.models import ScimEmail
|
||||
from ee.onyx.server.scim.models import ScimGroupMember
|
||||
from ee.onyx.server.scim.models import ScimGroupResource
|
||||
from ee.onyx.server.scim.models import ScimMeta
|
||||
from ee.onyx.server.scim.models import ScimName
|
||||
from ee.onyx.server.scim.models import ScimUserGroupRef
|
||||
from ee.onyx.server.scim.models import ScimUserResource
|
||||
from ee.onyx.server.scim.providers.base import get_default_provider
|
||||
from ee.onyx.server.scim.providers.okta import OktaProvider
|
||||
|
||||
|
||||
def _make_mock_user(
|
||||
user_id: UUID | None = None,
|
||||
email: str = "test@example.com",
|
||||
personal_name: str | None = "Test User",
|
||||
is_active: bool = True,
|
||||
) -> MagicMock:
|
||||
user = MagicMock()
|
||||
user.id = user_id or uuid4()
|
||||
user.email = email
|
||||
user.personal_name = personal_name
|
||||
user.is_active = is_active
|
||||
return user
|
||||
|
||||
|
||||
def _make_mock_group(group_id: int = 42, name: str = "Engineering") -> MagicMock:
|
||||
group = MagicMock()
|
||||
group.id = group_id
|
||||
group.name = name
|
||||
return group
|
||||
|
||||
|
||||
class TestOktaProvider:
|
||||
def test_name(self) -> None:
|
||||
assert OktaProvider().name == "okta"
|
||||
|
||||
def test_ignored_patch_paths(self) -> None:
|
||||
assert OktaProvider().ignored_patch_paths == frozenset(
|
||||
{"id", "schemas", "meta"}
|
||||
)
|
||||
|
||||
def test_build_user_resource_basic(self) -> None:
|
||||
provider = OktaProvider()
|
||||
user = _make_mock_user()
|
||||
result = provider.build_user_resource(user, "ext-123")
|
||||
|
||||
assert result == ScimUserResource(
|
||||
id=str(user.id),
|
||||
externalId="ext-123",
|
||||
userName="test@example.com",
|
||||
name=ScimName(givenName="Test", familyName="User", formatted="Test User"),
|
||||
displayName="Test User",
|
||||
emails=[ScimEmail(value="test@example.com", type="work", primary=True)],
|
||||
active=True,
|
||||
groups=[],
|
||||
meta=ScimMeta(resourceType="User"),
|
||||
)
|
||||
|
||||
def test_build_user_resource_with_groups(self) -> None:
|
||||
provider = OktaProvider()
|
||||
user = _make_mock_user()
|
||||
groups = [(1, "Engineering"), (2, "Design")]
|
||||
result = provider.build_user_resource(user, "ext-123", groups=groups)
|
||||
|
||||
assert result.groups == [
|
||||
ScimUserGroupRef(value="1", display="Engineering"),
|
||||
ScimUserGroupRef(value="2", display="Design"),
|
||||
]
|
||||
|
||||
def test_build_user_resource_empty_groups(self) -> None:
|
||||
provider = OktaProvider()
|
||||
user = _make_mock_user()
|
||||
result = provider.build_user_resource(user, "ext-123", groups=[])
|
||||
|
||||
assert result.groups == []
|
||||
|
||||
def test_build_user_resource_no_groups(self) -> None:
|
||||
provider = OktaProvider()
|
||||
user = _make_mock_user()
|
||||
result = provider.build_user_resource(user, "ext-123")
|
||||
|
||||
assert result.groups == []
|
||||
|
||||
def test_build_user_resource_name_parsing(self) -> None:
|
||||
provider = OktaProvider()
|
||||
user = _make_mock_user(personal_name="Jane Doe")
|
||||
result = provider.build_user_resource(user, None)
|
||||
|
||||
assert result.name == ScimName(
|
||||
givenName="Jane", familyName="Doe", formatted="Jane Doe"
|
||||
)
|
||||
|
||||
def test_build_user_resource_single_name(self) -> None:
|
||||
provider = OktaProvider()
|
||||
user = _make_mock_user(personal_name="Madonna")
|
||||
result = provider.build_user_resource(user, None)
|
||||
|
||||
assert result.name == ScimName(
|
||||
givenName="Madonna", familyName=None, formatted="Madonna"
|
||||
)
|
||||
|
||||
def test_build_user_resource_no_name(self) -> None:
|
||||
provider = OktaProvider()
|
||||
user = _make_mock_user(personal_name=None)
|
||||
result = provider.build_user_resource(user, None)
|
||||
|
||||
assert result.name is None
|
||||
assert result.displayName is None
|
||||
|
||||
def test_build_user_resource_scim_username_preserves_case(self) -> None:
|
||||
"""When scim_username is set, userName and emails use original case."""
|
||||
provider = OktaProvider()
|
||||
user = _make_mock_user(email="alice@example.com")
|
||||
result = provider.build_user_resource(
|
||||
user, "ext-1", scim_username="Alice@Example.com"
|
||||
)
|
||||
|
||||
assert result.userName == "Alice@Example.com"
|
||||
assert result.emails[0].value == "Alice@Example.com"
|
||||
|
||||
def test_build_user_resource_scim_username_none_falls_back(self) -> None:
|
||||
"""When scim_username is None, userName falls back to user.email."""
|
||||
provider = OktaProvider()
|
||||
user = _make_mock_user(email="alice@example.com")
|
||||
result = provider.build_user_resource(user, "ext-1", scim_username=None)
|
||||
|
||||
assert result.userName == "alice@example.com"
|
||||
assert result.emails[0].value == "alice@example.com"
|
||||
|
||||
def test_build_group_resource(self) -> None:
|
||||
provider = OktaProvider()
|
||||
group = _make_mock_group()
|
||||
uid1, uid2 = uuid4(), uuid4()
|
||||
members: list[tuple[UUID, str | None]] = [
|
||||
(uid1, "alice@example.com"),
|
||||
(uid2, "bob@example.com"),
|
||||
]
|
||||
|
||||
result = provider.build_group_resource(group, members, "ext-g-1")
|
||||
|
||||
assert result == ScimGroupResource(
|
||||
id="42",
|
||||
externalId="ext-g-1",
|
||||
displayName="Engineering",
|
||||
members=[
|
||||
ScimGroupMember(value=str(uid1), display="alice@example.com"),
|
||||
ScimGroupMember(value=str(uid2), display="bob@example.com"),
|
||||
],
|
||||
meta=ScimMeta(resourceType="Group"),
|
||||
)
|
||||
|
||||
def test_build_group_resource_empty_members(self) -> None:
|
||||
provider = OktaProvider()
|
||||
group = _make_mock_group()
|
||||
result = provider.build_group_resource(group, [])
|
||||
|
||||
assert result.members == []
|
||||
|
||||
|
||||
class TestGetDefaultProvider:
|
||||
def test_returns_okta(self) -> None:
|
||||
provider = get_default_provider()
|
||||
assert isinstance(provider, OktaProvider)
|
||||
@@ -9,6 +9,7 @@ from uuid import uuid4
|
||||
from fastapi import Response
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
|
||||
from ee.onyx.server.scim.api import _scim_name_to_str
|
||||
from ee.onyx.server.scim.api import create_user
|
||||
from ee.onyx.server.scim.api import delete_user
|
||||
from ee.onyx.server.scim.api import get_user
|
||||
@@ -22,9 +23,11 @@ from ee.onyx.server.scim.models import ScimPatchOperationType
|
||||
from ee.onyx.server.scim.models import ScimPatchRequest
|
||||
from ee.onyx.server.scim.models import ScimUserResource
|
||||
from ee.onyx.server.scim.patch import ScimPatchError
|
||||
from ee.onyx.server.scim.providers.base import ScimProvider
|
||||
from tests.unit.onyx.server.scim.conftest import assert_scim_error
|
||||
from tests.unit.onyx.server.scim.conftest import make_db_user
|
||||
from tests.unit.onyx.server.scim.conftest import make_scim_user
|
||||
from tests.unit.onyx.server.scim.conftest import make_user_mapping
|
||||
|
||||
|
||||
class TestListUsers:
|
||||
@@ -35,6 +38,7 @@ class TestListUsers:
|
||||
mock_db_session: MagicMock,
|
||||
mock_token: MagicMock,
|
||||
mock_dal: MagicMock,
|
||||
provider: ScimProvider,
|
||||
) -> None:
|
||||
mock_dal.list_users.return_value = ([], 0)
|
||||
|
||||
@@ -43,6 +47,7 @@ class TestListUsers:
|
||||
startIndex=1,
|
||||
count=100,
|
||||
_token=mock_token,
|
||||
provider=provider,
|
||||
db_session=mock_db_session,
|
||||
)
|
||||
|
||||
@@ -55,15 +60,20 @@ class TestListUsers:
|
||||
mock_db_session: MagicMock,
|
||||
mock_token: MagicMock,
|
||||
mock_dal: MagicMock,
|
||||
provider: ScimProvider,
|
||||
) -> None:
|
||||
user = make_db_user(email="alice@example.com", personal_name="Alice Smith")
|
||||
mock_dal.list_users.return_value = ([(user, "ext-abc")], 1)
|
||||
mapping = make_user_mapping(
|
||||
external_id="ext-abc", user_id=user.id, scim_username="Alice@example.com"
|
||||
)
|
||||
mock_dal.list_users.return_value = ([(user, mapping)], 1)
|
||||
|
||||
result = list_users(
|
||||
filter=None,
|
||||
startIndex=1,
|
||||
count=100,
|
||||
_token=mock_token,
|
||||
provider=provider,
|
||||
db_session=mock_db_session,
|
||||
)
|
||||
|
||||
@@ -72,7 +82,7 @@ class TestListUsers:
|
||||
assert len(result.Resources) == 1
|
||||
resource = result.Resources[0]
|
||||
assert isinstance(resource, ScimUserResource)
|
||||
assert resource.userName == "alice@example.com"
|
||||
assert resource.userName == "Alice@example.com"
|
||||
assert resource.externalId == "ext-abc"
|
||||
|
||||
def test_unsupported_filter_attribute_returns_400(
|
||||
@@ -80,6 +90,7 @@ class TestListUsers:
|
||||
mock_db_session: MagicMock,
|
||||
mock_token: MagicMock,
|
||||
mock_dal: MagicMock,
|
||||
provider: ScimProvider,
|
||||
) -> None:
|
||||
mock_dal.list_users.side_effect = ValueError(
|
||||
"Unsupported filter attribute: emails"
|
||||
@@ -90,6 +101,7 @@ class TestListUsers:
|
||||
startIndex=1,
|
||||
count=100,
|
||||
_token=mock_token,
|
||||
provider=provider,
|
||||
db_session=mock_db_session,
|
||||
)
|
||||
|
||||
@@ -100,12 +112,14 @@ class TestListUsers:
|
||||
mock_db_session: MagicMock,
|
||||
mock_token: MagicMock,
|
||||
mock_dal: MagicMock, # noqa: ARG002
|
||||
provider: ScimProvider,
|
||||
) -> None:
|
||||
result = list_users(
|
||||
filter="not a valid filter",
|
||||
startIndex=1,
|
||||
count=100,
|
||||
_token=mock_token,
|
||||
provider=provider,
|
||||
db_session=mock_db_session,
|
||||
)
|
||||
|
||||
@@ -120,6 +134,7 @@ class TestGetUser:
|
||||
mock_db_session: MagicMock,
|
||||
mock_token: MagicMock,
|
||||
mock_dal: MagicMock,
|
||||
provider: ScimProvider,
|
||||
) -> None:
|
||||
user = make_db_user(email="alice@example.com")
|
||||
mock_dal.get_user.return_value = user
|
||||
@@ -127,6 +142,7 @@ class TestGetUser:
|
||||
result = get_user(
|
||||
user_id=str(user.id),
|
||||
_token=mock_token,
|
||||
provider=provider,
|
||||
db_session=mock_db_session,
|
||||
)
|
||||
|
||||
@@ -139,10 +155,12 @@ class TestGetUser:
|
||||
mock_db_session: MagicMock,
|
||||
mock_token: MagicMock,
|
||||
mock_dal: MagicMock, # noqa: ARG002
|
||||
provider: ScimProvider,
|
||||
) -> None:
|
||||
result = get_user(
|
||||
user_id="not-a-uuid",
|
||||
_token=mock_token,
|
||||
provider=provider,
|
||||
db_session=mock_db_session,
|
||||
)
|
||||
|
||||
@@ -153,12 +171,14 @@ class TestGetUser:
|
||||
mock_db_session: MagicMock,
|
||||
mock_token: MagicMock,
|
||||
mock_dal: MagicMock,
|
||||
provider: ScimProvider,
|
||||
) -> None:
|
||||
mock_dal.get_user.return_value = None
|
||||
|
||||
result = get_user(
|
||||
user_id=str(uuid4()),
|
||||
_token=mock_token,
|
||||
provider=provider,
|
||||
db_session=mock_db_session,
|
||||
)
|
||||
|
||||
@@ -175,6 +195,7 @@ class TestCreateUser:
|
||||
mock_db_session: MagicMock,
|
||||
mock_token: MagicMock,
|
||||
mock_dal: MagicMock,
|
||||
provider: ScimProvider,
|
||||
) -> None:
|
||||
mock_dal.get_user_by_email.return_value = None
|
||||
resource = make_scim_user(userName="new@example.com")
|
||||
@@ -182,6 +203,7 @@ class TestCreateUser:
|
||||
result = create_user(
|
||||
user_resource=resource,
|
||||
_token=mock_token,
|
||||
provider=provider,
|
||||
db_session=mock_db_session,
|
||||
)
|
||||
|
||||
@@ -195,12 +217,14 @@ class TestCreateUser:
|
||||
mock_db_session: MagicMock,
|
||||
mock_token: MagicMock,
|
||||
mock_dal: MagicMock, # noqa: ARG002
|
||||
provider: ScimProvider,
|
||||
) -> None:
|
||||
resource = make_scim_user(externalId=None)
|
||||
|
||||
result = create_user(
|
||||
user_resource=resource,
|
||||
_token=mock_token,
|
||||
provider=provider,
|
||||
db_session=mock_db_session,
|
||||
)
|
||||
|
||||
@@ -213,6 +237,7 @@ class TestCreateUser:
|
||||
mock_db_session: MagicMock,
|
||||
mock_token: MagicMock,
|
||||
mock_dal: MagicMock,
|
||||
provider: ScimProvider,
|
||||
) -> None:
|
||||
mock_dal.get_user_by_email.return_value = make_db_user()
|
||||
resource = make_scim_user()
|
||||
@@ -220,6 +245,7 @@ class TestCreateUser:
|
||||
result = create_user(
|
||||
user_resource=resource,
|
||||
_token=mock_token,
|
||||
provider=provider,
|
||||
db_session=mock_db_session,
|
||||
)
|
||||
|
||||
@@ -232,6 +258,7 @@ class TestCreateUser:
|
||||
mock_db_session: MagicMock,
|
||||
mock_token: MagicMock,
|
||||
mock_dal: MagicMock,
|
||||
provider: ScimProvider,
|
||||
) -> None:
|
||||
mock_dal.get_user_by_email.return_value = None
|
||||
mock_dal.add_user.side_effect = IntegrityError("dup", {}, Exception())
|
||||
@@ -240,6 +267,7 @@ class TestCreateUser:
|
||||
result = create_user(
|
||||
user_resource=resource,
|
||||
_token=mock_token,
|
||||
provider=provider,
|
||||
db_session=mock_db_session,
|
||||
)
|
||||
|
||||
@@ -253,6 +281,7 @@ class TestCreateUser:
|
||||
mock_db_session: MagicMock,
|
||||
mock_token: MagicMock,
|
||||
mock_dal: MagicMock, # noqa: ARG002
|
||||
provider: ScimProvider,
|
||||
) -> None:
|
||||
mock_seats.return_value = "Seat limit reached"
|
||||
resource = make_scim_user()
|
||||
@@ -260,6 +289,7 @@ class TestCreateUser:
|
||||
result = create_user(
|
||||
user_resource=resource,
|
||||
_token=mock_token,
|
||||
provider=provider,
|
||||
db_session=mock_db_session,
|
||||
)
|
||||
|
||||
@@ -272,6 +302,7 @@ class TestCreateUser:
|
||||
mock_db_session: MagicMock,
|
||||
mock_token: MagicMock,
|
||||
mock_dal: MagicMock,
|
||||
provider: ScimProvider,
|
||||
) -> None:
|
||||
mock_dal.get_user_by_email.return_value = None
|
||||
resource = make_scim_user(externalId="ext-123")
|
||||
@@ -279,6 +310,7 @@ class TestCreateUser:
|
||||
result = create_user(
|
||||
user_resource=resource,
|
||||
_token=mock_token,
|
||||
provider=provider,
|
||||
db_session=mock_db_session,
|
||||
)
|
||||
|
||||
@@ -295,6 +327,7 @@ class TestReplaceUser:
|
||||
mock_db_session: MagicMock,
|
||||
mock_token: MagicMock,
|
||||
mock_dal: MagicMock,
|
||||
provider: ScimProvider,
|
||||
) -> None:
|
||||
user = make_db_user(email="old@example.com")
|
||||
mock_dal.get_user.return_value = user
|
||||
@@ -307,6 +340,7 @@ class TestReplaceUser:
|
||||
user_id=str(user.id),
|
||||
user_resource=resource,
|
||||
_token=mock_token,
|
||||
provider=provider,
|
||||
db_session=mock_db_session,
|
||||
)
|
||||
|
||||
@@ -319,6 +353,7 @@ class TestReplaceUser:
|
||||
mock_db_session: MagicMock,
|
||||
mock_token: MagicMock,
|
||||
mock_dal: MagicMock,
|
||||
provider: ScimProvider,
|
||||
) -> None:
|
||||
mock_dal.get_user.return_value = None
|
||||
|
||||
@@ -326,6 +361,7 @@ class TestReplaceUser:
|
||||
user_id=str(uuid4()),
|
||||
user_resource=make_scim_user(),
|
||||
_token=mock_token,
|
||||
provider=provider,
|
||||
db_session=mock_db_session,
|
||||
)
|
||||
|
||||
@@ -338,6 +374,7 @@ class TestReplaceUser:
|
||||
mock_db_session: MagicMock,
|
||||
mock_token: MagicMock,
|
||||
mock_dal: MagicMock,
|
||||
provider: ScimProvider,
|
||||
) -> None:
|
||||
user = make_db_user(is_active=False)
|
||||
mock_dal.get_user.return_value = user
|
||||
@@ -348,6 +385,7 @@ class TestReplaceUser:
|
||||
user_id=str(user.id),
|
||||
user_resource=resource,
|
||||
_token=mock_token,
|
||||
provider=provider,
|
||||
db_session=mock_db_session,
|
||||
)
|
||||
|
||||
@@ -359,6 +397,7 @@ class TestReplaceUser:
|
||||
mock_db_session: MagicMock,
|
||||
mock_token: MagicMock,
|
||||
mock_dal: MagicMock,
|
||||
provider: ScimProvider,
|
||||
) -> None:
|
||||
user = make_db_user()
|
||||
mock_dal.get_user.return_value = user
|
||||
@@ -369,11 +408,14 @@ class TestReplaceUser:
|
||||
user_id=str(user.id),
|
||||
user_resource=resource,
|
||||
_token=mock_token,
|
||||
provider=provider,
|
||||
db_session=mock_db_session,
|
||||
)
|
||||
|
||||
assert isinstance(result, ScimUserResource)
|
||||
mock_dal.sync_user_external_id.assert_called_once_with(user.id, None)
|
||||
mock_dal.sync_user_external_id.assert_called_once_with(
|
||||
user.id, None, scim_username="test@example.com"
|
||||
)
|
||||
|
||||
|
||||
class TestPatchUser:
|
||||
@@ -384,6 +426,7 @@ class TestPatchUser:
|
||||
mock_db_session: MagicMock,
|
||||
mock_token: MagicMock,
|
||||
mock_dal: MagicMock,
|
||||
provider: ScimProvider,
|
||||
) -> None:
|
||||
user = make_db_user(is_active=True)
|
||||
mock_dal.get_user.return_value = user
|
||||
@@ -401,6 +444,7 @@ class TestPatchUser:
|
||||
user_id=str(user.id),
|
||||
patch_request=patch_req,
|
||||
_token=mock_token,
|
||||
provider=provider,
|
||||
db_session=mock_db_session,
|
||||
)
|
||||
|
||||
@@ -412,6 +456,7 @@ class TestPatchUser:
|
||||
mock_db_session: MagicMock,
|
||||
mock_token: MagicMock,
|
||||
mock_dal: MagicMock,
|
||||
provider: ScimProvider,
|
||||
) -> None:
|
||||
mock_dal.get_user.return_value = None
|
||||
patch_req = ScimPatchRequest(
|
||||
@@ -428,11 +473,45 @@ class TestPatchUser:
|
||||
user_id=str(uuid4()),
|
||||
patch_request=patch_req,
|
||||
_token=mock_token,
|
||||
provider=provider,
|
||||
db_session=mock_db_session,
|
||||
)
|
||||
|
||||
assert_scim_error(result, 404)
|
||||
|
||||
def test_patch_displayname_persists(
|
||||
self,
|
||||
mock_db_session: MagicMock,
|
||||
mock_token: MagicMock,
|
||||
mock_dal: MagicMock,
|
||||
provider: ScimProvider,
|
||||
) -> None:
|
||||
"""PATCH displayName should update personal_name in the DB."""
|
||||
user = make_db_user(personal_name="Old Name")
|
||||
mock_dal.get_user.return_value = user
|
||||
patch_req = ScimPatchRequest(
|
||||
Operations=[
|
||||
ScimPatchOperation(
|
||||
op=ScimPatchOperationType.REPLACE,
|
||||
path="displayName",
|
||||
value="New Display Name",
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
result = patch_user(
|
||||
user_id=str(user.id),
|
||||
patch_request=patch_req,
|
||||
_token=mock_token,
|
||||
provider=provider,
|
||||
db_session=mock_db_session,
|
||||
)
|
||||
|
||||
assert isinstance(result, ScimUserResource)
|
||||
# Verify the update_user call received the new display name
|
||||
call_kwargs = mock_dal.update_user.call_args
|
||||
assert call_kwargs[1]["personal_name"] == "New Display Name"
|
||||
|
||||
@patch("ee.onyx.server.scim.api.apply_user_patch")
|
||||
def test_patch_error_returns_error_response(
|
||||
self,
|
||||
@@ -440,6 +519,7 @@ class TestPatchUser:
|
||||
mock_db_session: MagicMock,
|
||||
mock_token: MagicMock,
|
||||
mock_dal: MagicMock,
|
||||
provider: ScimProvider,
|
||||
) -> None:
|
||||
user = make_db_user()
|
||||
mock_dal.get_user.return_value = user
|
||||
@@ -457,6 +537,7 @@ class TestPatchUser:
|
||||
user_id=str(user.id),
|
||||
patch_request=patch_req,
|
||||
_token=mock_token,
|
||||
provider=provider,
|
||||
db_session=mock_db_session,
|
||||
)
|
||||
|
||||
@@ -519,3 +600,87 @@ class TestDeleteUser:
|
||||
)
|
||||
|
||||
assert_scim_error(result, 404)
|
||||
|
||||
|
||||
class TestScimNameToStr:
|
||||
"""Tests for _scim_name_to_str helper."""
|
||||
|
||||
def test_prefers_given_family_over_formatted(self) -> None:
|
||||
"""Okta may send stale formatted while updating givenName/familyName."""
|
||||
name = ScimName(givenName="Jane", familyName="Smith", formatted="Old Name")
|
||||
assert _scim_name_to_str(name) == "Jane Smith"
|
||||
|
||||
def test_given_name_only(self) -> None:
|
||||
name = ScimName(givenName="Jane")
|
||||
assert _scim_name_to_str(name) == "Jane"
|
||||
|
||||
def test_family_name_only(self) -> None:
|
||||
name = ScimName(familyName="Smith")
|
||||
assert _scim_name_to_str(name) == "Smith"
|
||||
|
||||
def test_falls_back_to_formatted(self) -> None:
|
||||
name = ScimName(formatted="Display Name")
|
||||
assert _scim_name_to_str(name) == "Display Name"
|
||||
|
||||
def test_none_returns_none(self) -> None:
|
||||
assert _scim_name_to_str(None) is None
|
||||
|
||||
def test_empty_name_returns_none(self) -> None:
|
||||
name = ScimName()
|
||||
assert _scim_name_to_str(name) is None
|
||||
|
||||
|
||||
class TestEmailCasePreservation:
|
||||
"""Tests verifying email case is preserved through SCIM endpoints."""
|
||||
|
||||
@patch("ee.onyx.server.scim.api._check_seat_availability", return_value=None)
|
||||
def test_create_preserves_username_case(
|
||||
self,
|
||||
mock_seats: MagicMock, # noqa: ARG002
|
||||
mock_db_session: MagicMock,
|
||||
mock_token: MagicMock,
|
||||
mock_dal: MagicMock,
|
||||
provider: ScimProvider,
|
||||
) -> None:
|
||||
"""POST /Users with mixed-case userName returns the original case."""
|
||||
mock_dal.get_user_by_email.return_value = None
|
||||
resource = make_scim_user(userName="Alice@Example.COM")
|
||||
|
||||
result = create_user(
|
||||
user_resource=resource,
|
||||
_token=mock_token,
|
||||
provider=provider,
|
||||
db_session=mock_db_session,
|
||||
)
|
||||
|
||||
assert isinstance(result, ScimUserResource)
|
||||
assert result.userName == "Alice@Example.COM"
|
||||
assert result.emails[0].value == "Alice@Example.COM"
|
||||
|
||||
def test_get_preserves_username_case(
|
||||
self,
|
||||
mock_db_session: MagicMock,
|
||||
mock_token: MagicMock,
|
||||
mock_dal: MagicMock,
|
||||
provider: ScimProvider,
|
||||
) -> None:
|
||||
"""GET /Users/{id} returns the original-case userName from mapping."""
|
||||
user = make_db_user(email="alice@example.com")
|
||||
mock_dal.get_user.return_value = user
|
||||
mapping = make_user_mapping(
|
||||
external_id="ext-1",
|
||||
user_id=user.id,
|
||||
scim_username="Alice@Example.COM",
|
||||
)
|
||||
mock_dal.get_user_mapping_by_user_id.return_value = mapping
|
||||
|
||||
result = get_user(
|
||||
user_id=str(user.id),
|
||||
_token=mock_token,
|
||||
provider=provider,
|
||||
db_session=mock_db_session,
|
||||
)
|
||||
|
||||
assert isinstance(result, ScimUserResource)
|
||||
assert result.userName == "Alice@Example.COM"
|
||||
assert result.emails[0].value == "Alice@Example.COM"
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
"""Unit tests for CodeInterpreterClient streaming-to-batch fallback.
|
||||
|
||||
When the streaming endpoint (/v1/execute/stream) returns 404 — e.g. because the
|
||||
code-interpreter service is an older version that doesn't support streaming — the
|
||||
client should transparently fall back to the batch endpoint (/v1/execute) and
|
||||
convert the batch response into the same stream-event interface.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import MagicMock
|
||||
from unittest.mock import patch
|
||||
|
||||
from onyx.tools.tool_implementations.python.code_interpreter_client import (
|
||||
CodeInterpreterClient,
|
||||
)
|
||||
from onyx.tools.tool_implementations.python.code_interpreter_client import FileInput
|
||||
from onyx.tools.tool_implementations.python.code_interpreter_client import (
|
||||
StreamOutputEvent,
|
||||
)
|
||||
from onyx.tools.tool_implementations.python.code_interpreter_client import (
|
||||
StreamResultEvent,
|
||||
)
|
||||
|
||||
|
||||
def _make_batch_response(
|
||||
stdout: str = "",
|
||||
stderr: str = "",
|
||||
exit_code: int = 0,
|
||||
timed_out: bool = False,
|
||||
duration_ms: int = 50,
|
||||
) -> MagicMock:
|
||||
"""Build a mock ``requests.Response`` for the batch /v1/execute endpoint."""
|
||||
resp = MagicMock()
|
||||
resp.status_code = 200
|
||||
resp.raise_for_status = MagicMock()
|
||||
resp.json.return_value = {
|
||||
"stdout": stdout,
|
||||
"stderr": stderr,
|
||||
"exit_code": exit_code,
|
||||
"timed_out": timed_out,
|
||||
"duration_ms": duration_ms,
|
||||
"files": [],
|
||||
}
|
||||
return resp
|
||||
|
||||
|
||||
def _make_404_response() -> MagicMock:
|
||||
"""Build a mock ``requests.Response`` that returns 404 (streaming not found)."""
|
||||
resp = MagicMock()
|
||||
resp.status_code = 404
|
||||
return resp
|
||||
|
||||
|
||||
def test_execute_streaming_fallback_to_batch_on_404() -> None:
|
||||
"""When /v1/execute/stream returns 404, the client should fall back to
|
||||
/v1/execute and yield equivalent StreamEvent objects."""
|
||||
|
||||
client = CodeInterpreterClient(base_url="http://fake:9000")
|
||||
|
||||
stream_resp = _make_404_response()
|
||||
batch_resp = _make_batch_response(
|
||||
stdout="hello world\n",
|
||||
stderr="a warning\n",
|
||||
)
|
||||
|
||||
urls_called: list[str] = []
|
||||
|
||||
def mock_post(url: str, **_kwargs: object) -> MagicMock:
|
||||
urls_called.append(url)
|
||||
if url.endswith("/v1/execute/stream"):
|
||||
return stream_resp
|
||||
if url.endswith("/v1/execute"):
|
||||
return batch_resp
|
||||
raise AssertionError(f"Unexpected URL: {url}")
|
||||
|
||||
with patch.object(client.session, "post", side_effect=mock_post):
|
||||
events = list(client.execute_streaming(code="print('hello world')"))
|
||||
|
||||
# Streaming endpoint was attempted first, then batch
|
||||
assert len(urls_called) == 2
|
||||
assert urls_called[0].endswith("/v1/execute/stream")
|
||||
assert urls_called[1].endswith("/v1/execute")
|
||||
|
||||
# The 404 response must be closed before making the batch call
|
||||
stream_resp.close.assert_called_once()
|
||||
|
||||
# _batch_as_stream yields: stdout event, stderr event, result event
|
||||
assert len(events) == 3
|
||||
|
||||
assert isinstance(events[0], StreamOutputEvent)
|
||||
assert events[0].stream == "stdout"
|
||||
assert events[0].data == "hello world\n"
|
||||
|
||||
assert isinstance(events[1], StreamOutputEvent)
|
||||
assert events[1].stream == "stderr"
|
||||
assert events[1].data == "a warning\n"
|
||||
|
||||
assert isinstance(events[2], StreamResultEvent)
|
||||
assert events[2].exit_code == 0
|
||||
assert not events[2].timed_out
|
||||
assert events[2].duration_ms == 50
|
||||
assert events[2].files == []
|
||||
|
||||
|
||||
def test_execute_streaming_fallback_stdout_only() -> None:
|
||||
"""Fallback with only stdout (no stderr) should yield two events:
|
||||
one StreamOutputEvent for stdout and one StreamResultEvent."""
|
||||
|
||||
client = CodeInterpreterClient(base_url="http://fake:9000")
|
||||
|
||||
stream_resp = _make_404_response()
|
||||
batch_resp = _make_batch_response(stdout="result: 42\n")
|
||||
|
||||
def mock_post(url: str, **_kwargs: object) -> MagicMock:
|
||||
if url.endswith("/v1/execute/stream"):
|
||||
return stream_resp
|
||||
if url.endswith("/v1/execute"):
|
||||
return batch_resp
|
||||
raise AssertionError(f"Unexpected URL: {url}")
|
||||
|
||||
with patch.object(client.session, "post", side_effect=mock_post):
|
||||
events = list(client.execute_streaming(code="print(42)"))
|
||||
|
||||
# No stderr → only stdout + result
|
||||
assert len(events) == 2
|
||||
|
||||
assert isinstance(events[0], StreamOutputEvent)
|
||||
assert events[0].stream == "stdout"
|
||||
assert events[0].data == "result: 42\n"
|
||||
|
||||
assert isinstance(events[1], StreamResultEvent)
|
||||
assert events[1].exit_code == 0
|
||||
|
||||
|
||||
def test_execute_streaming_fallback_preserves_files_param() -> None:
|
||||
"""When falling back, the files parameter must be forwarded to the
|
||||
batch endpoint so staged files are still available for execution."""
|
||||
|
||||
client = CodeInterpreterClient(base_url="http://fake:9000")
|
||||
|
||||
stream_resp = _make_404_response()
|
||||
batch_resp = _make_batch_response(stdout="ok\n")
|
||||
|
||||
captured_payloads: list[dict] = []
|
||||
|
||||
def mock_post(url: str, **kwargs: object) -> MagicMock:
|
||||
if "json" in kwargs:
|
||||
captured_payloads.append(kwargs["json"]) # type: ignore[arg-type]
|
||||
if url.endswith("/v1/execute/stream"):
|
||||
return stream_resp
|
||||
if url.endswith("/v1/execute"):
|
||||
return batch_resp
|
||||
raise AssertionError(f"Unexpected URL: {url}")
|
||||
|
||||
files_input: list[FileInput] = [{"path": "data.csv", "file_id": "file-abc123"}]
|
||||
|
||||
with patch.object(client.session, "post", side_effect=mock_post):
|
||||
events = list(
|
||||
client.execute_streaming(
|
||||
code="import pandas",
|
||||
files=files_input,
|
||||
)
|
||||
)
|
||||
|
||||
# Both the streaming attempt and the batch fallback should include files
|
||||
assert len(captured_payloads) == 2
|
||||
for payload in captured_payloads:
|
||||
assert payload["files"] == files_input
|
||||
assert payload["code"] == "import pandas"
|
||||
|
||||
# Should still yield valid events
|
||||
assert any(isinstance(e, StreamResultEvent) for e in events)
|
||||
@@ -45,6 +45,6 @@ dependencies:
|
||||
repository: https://charts.min.io/
|
||||
condition: minio.enabled
|
||||
- name: code-interpreter
|
||||
version: 0.2.2
|
||||
version: 0.2.1
|
||||
repository: https://onyx-dot-app.github.io/python-sandbox/
|
||||
condition: codeInterpreter.enabled
|
||||
|
||||
@@ -957,7 +957,7 @@ minio:
|
||||
|
||||
# Code Interpreter - Python code execution service (beta feature)
|
||||
codeInterpreter:
|
||||
enabled: true
|
||||
enabled: false # Disabled by default (beta feature)
|
||||
|
||||
replicaCount: 1
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"manifest_version": 3,
|
||||
"name": "Onyx",
|
||||
"version": "1.0",
|
||||
"version": "1.1",
|
||||
"description": "Onyx lets you research, create, and automate with LLMs powered by your team's unique knowledge",
|
||||
"permissions": [
|
||||
"sidePanel",
|
||||
|
||||
@@ -43,8 +43,12 @@ async function openSidePanel(tabId) {
|
||||
}
|
||||
}
|
||||
|
||||
function encodeUserPrompt(text) {
|
||||
return encodeURIComponent(text).replace(/\(/g, "%28").replace(/\)/g, "%29");
|
||||
}
|
||||
|
||||
async function sendToOnyx(info, tab) {
|
||||
const selectedText = encodeURIComponent(info.selectionText);
|
||||
const selectedText = encodeUserPrompt(info.selectionText);
|
||||
const currentUrl = encodeURIComponent(tab.url);
|
||||
|
||||
try {
|
||||
@@ -153,6 +157,23 @@ chrome.commands.onCommand.addListener(async (command) => {
|
||||
}
|
||||
});
|
||||
|
||||
async function sendActiveTabUrlToPanel() {
|
||||
try {
|
||||
const [tab] = await chrome.tabs.query({
|
||||
active: true,
|
||||
lastFocusedWindow: true,
|
||||
});
|
||||
if (tab?.url) {
|
||||
chrome.runtime.sendMessage({
|
||||
action: ACTIONS.TAB_URL_UPDATED,
|
||||
url: tab.url,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("[Onyx SW] Error sending tab URL:", error);
|
||||
}
|
||||
}
|
||||
|
||||
chrome.runtime.onMessage.addListener((request, sender, sendResponse) => {
|
||||
if (request.action === ACTIONS.GET_CURRENT_ONYX_DOMAIN) {
|
||||
chrome.storage.local.get(
|
||||
@@ -188,7 +209,7 @@ chrome.runtime.onMessage.addListener((request, sender, sendResponse) => {
|
||||
chrome.storage.local.get(
|
||||
{ [CHROME_SPECIFIC_STORAGE_KEYS.ONYX_DOMAIN]: DEFAULT_ONYX_DOMAIN },
|
||||
(result) => {
|
||||
const encodedText = encodeURIComponent(selectedText);
|
||||
const encodedText = encodeUserPrompt(selectedText);
|
||||
const onyxDomain = result[CHROME_SPECIFIC_STORAGE_KEYS.ONYX_DOMAIN];
|
||||
const url = `${onyxDomain}${SIDE_PANEL_PATH}?user-prompt=${encodedText}`;
|
||||
|
||||
@@ -222,6 +243,15 @@ chrome.runtime.onMessage.addListener((request, sender, sendResponse) => {
|
||||
}
|
||||
return true;
|
||||
}
|
||||
if (request.action === ACTIONS.TAB_READING_ENABLED) {
|
||||
chrome.storage.session.set({ tabReadingEnabled: true });
|
||||
sendActiveTabUrlToPanel();
|
||||
return false;
|
||||
}
|
||||
if (request.action === ACTIONS.TAB_READING_DISABLED) {
|
||||
chrome.storage.session.set({ tabReadingEnabled: false });
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
chrome.storage.onChanged.addListener((changes, namespace) => {
|
||||
@@ -273,4 +303,40 @@ chrome.omnibox.onInputChanged.addListener((text, suggest) => {
|
||||
}
|
||||
});
|
||||
|
||||
chrome.tabs.onActivated.addListener(async (activeInfo) => {
|
||||
const result = await chrome.storage.session.get({ tabReadingEnabled: false });
|
||||
if (!result.tabReadingEnabled) return;
|
||||
try {
|
||||
const tab = await chrome.tabs.get(activeInfo.tabId);
|
||||
if (tab.url) {
|
||||
chrome.runtime.sendMessage({
|
||||
action: ACTIONS.TAB_URL_UPDATED,
|
||||
url: tab.url,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("[Onyx SW] Error on tab activated:", error);
|
||||
}
|
||||
});
|
||||
|
||||
chrome.tabs.onUpdated.addListener(async (tabId, changeInfo, tab) => {
|
||||
if (!changeInfo.url) return;
|
||||
const result = await chrome.storage.session.get({ tabReadingEnabled: false });
|
||||
if (!result.tabReadingEnabled) return;
|
||||
try {
|
||||
const [activeTab] = await chrome.tabs.query({
|
||||
active: true,
|
||||
lastFocusedWindow: true,
|
||||
});
|
||||
if (activeTab?.id === tabId) {
|
||||
chrome.runtime.sendMessage({
|
||||
action: ACTIONS.TAB_URL_UPDATED,
|
||||
url: changeInfo.url,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("[Onyx SW] Error on tab updated:", error);
|
||||
}
|
||||
});
|
||||
|
||||
setupSidePanel();
|
||||
|
||||
@@ -132,9 +132,7 @@ import { getOnyxDomain } from "../utils/storage.js";
|
||||
return;
|
||||
}
|
||||
|
||||
setIframeSrc(
|
||||
items[CHROME_SPECIFIC_STORAGE_KEYS.ONYX_DOMAIN] + "/chat/nrf",
|
||||
);
|
||||
setIframeSrc(items[CHROME_SPECIFIC_STORAGE_KEYS.ONYX_DOMAIN] + "/nrf");
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -15,6 +15,15 @@ import {
|
||||
let iframeLoadTimeout;
|
||||
let authRequired = false;
|
||||
|
||||
// Returns the origin of the Onyx app loaded in the iframe.
|
||||
// We derive the origin from iframe.src so postMessage payloads
|
||||
// (including tab URLs) are only delivered to the expected page.
|
||||
// Throws if iframe.src is not a valid URL — this is intentional:
|
||||
// postMessage must never fall back to the unsafe wildcard "*".
|
||||
function getIframeOrigin() {
|
||||
return new URL(iframe.src).origin;
|
||||
}
|
||||
|
||||
async function checkPendingInput() {
|
||||
try {
|
||||
const result = await chrome.storage.session.get("pendingInput");
|
||||
@@ -57,7 +66,7 @@ import {
|
||||
type: WEB_MESSAGE.PAGE_CHANGE,
|
||||
url: pageUrl,
|
||||
},
|
||||
"*",
|
||||
getIframeOrigin(),
|
||||
);
|
||||
currentUrl = pageUrl;
|
||||
}
|
||||
@@ -76,15 +85,34 @@ import {
|
||||
}
|
||||
|
||||
function handleMessage(event) {
|
||||
// Only trust messages from the Onyx app iframe.
|
||||
// Check both source identity and origin so that a cross-origin page
|
||||
// navigated to inside the iframe cannot send privileged extension
|
||||
// messages (e.g. TAB_READING_ENABLED) after iframe.src changes.
|
||||
// getIframeOrigin() throws if iframe.src is not yet a valid URL —
|
||||
// catching it here fails closed (message is rejected, not processed).
|
||||
if (event.source !== iframe.contentWindow) return;
|
||||
try {
|
||||
if (event.origin !== getIframeOrigin()) return;
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
if (event.data.type === CHROME_MESSAGE.ONYX_APP_LOADED) {
|
||||
clearTimeout(iframeLoadTimeout);
|
||||
iframeLoaded = true;
|
||||
showIframe();
|
||||
if (iframe.contentWindow) {
|
||||
iframe.contentWindow.postMessage({ type: "PANEL_READY" }, "*");
|
||||
iframe.contentWindow.postMessage(
|
||||
{ type: "PANEL_READY" },
|
||||
getIframeOrigin(),
|
||||
);
|
||||
}
|
||||
} else if (event.data.type === CHROME_MESSAGE.AUTH_REQUIRED) {
|
||||
authRequired = true;
|
||||
} else if (event.data.type === CHROME_MESSAGE.TAB_READING_ENABLED) {
|
||||
chrome.runtime.sendMessage({ action: ACTIONS.TAB_READING_ENABLED });
|
||||
} else if (event.data.type === CHROME_MESSAGE.TAB_READING_DISABLED) {
|
||||
chrome.runtime.sendMessage({ action: ACTIONS.TAB_READING_DISABLED });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -117,6 +145,13 @@ import {
|
||||
setIframeSrc(request.url, request.pageUrl);
|
||||
} else if (request.action === ACTIONS.UPDATE_PAGE_URL) {
|
||||
sendWebsiteToIframe(request.pageUrl);
|
||||
} else if (request.action === ACTIONS.TAB_URL_UPDATED) {
|
||||
if (iframe.contentWindow) {
|
||||
iframe.contentWindow.postMessage(
|
||||
{ type: CHROME_MESSAGE.TAB_URL_UPDATED, url: request.url },
|
||||
getIframeOrigin(),
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ export const THEMES = {
|
||||
|
||||
export const DEFAULT_ONYX_DOMAIN = "http://localhost:3000";
|
||||
|
||||
export const SIDE_PANEL_PATH = "/chat/nrf/side-panel";
|
||||
export const SIDE_PANEL_PATH = "/nrf/side-panel";
|
||||
|
||||
export const ACTIONS = {
|
||||
GET_SELECTED_TEXT: "getSelectedText",
|
||||
@@ -17,6 +17,9 @@ export const ACTIONS = {
|
||||
OPEN_SIDE_PANEL_WITH_INPUT: "openSidePanelWithInput",
|
||||
OPEN_ONYX_WITH_INPUT: "openOnyxWithInput",
|
||||
CLOSE_SIDE_PANEL: "closeSidePanel",
|
||||
TAB_URL_UPDATED: "tabUrlUpdated",
|
||||
TAB_READING_ENABLED: "tabReadingEnabled",
|
||||
TAB_READING_DISABLED: "tabReadingDisabled",
|
||||
};
|
||||
|
||||
export const CHROME_SPECIFIC_STORAGE_KEYS = {
|
||||
@@ -36,6 +39,9 @@ export const CHROME_MESSAGE = {
|
||||
LOAD_NEW_CHAT_PAGE: "LOAD_NEW_CHAT_PAGE",
|
||||
LOAD_NEW_PAGE: "LOAD_NEW_PAGE",
|
||||
AUTH_REQUIRED: "AUTH_REQUIRED",
|
||||
TAB_READING_ENABLED: "TAB_READING_ENABLED",
|
||||
TAB_READING_DISABLED: "TAB_READING_DISABLED",
|
||||
TAB_URL_UPDATED: "TAB_URL_UPDATED",
|
||||
};
|
||||
|
||||
export const WEB_MESSAGE = {
|
||||
|
||||
@@ -49,7 +49,7 @@ backend = [
|
||||
"fastapi-users==15.0.4",
|
||||
"fastapi-users-db-sqlalchemy==7.0.0",
|
||||
"fastapi-limiter==0.1.6",
|
||||
"fastmcp==2.14.2",
|
||||
"fastmcp==3.0.2",
|
||||
"filelock==3.20.3",
|
||||
"google-api-python-client==2.86.0",
|
||||
"google-auth-httplib2==0.1.0",
|
||||
@@ -71,10 +71,10 @@ backend = [
|
||||
"lxml==5.3.0",
|
||||
"Mako==1.2.4",
|
||||
"markitdown[pdf, docx, pptx, xlsx, xls]==0.1.2",
|
||||
"mcp[cli]==1.25.0",
|
||||
"mcp[cli]==1.26.0",
|
||||
"msal==1.34.0",
|
||||
"msoffcrypto-tool==5.4.2",
|
||||
"Office365-REST-Python-Client==2.5.9",
|
||||
"Office365-REST-Python-Client==2.6.2",
|
||||
"oauthlib==3.2.2",
|
||||
# NOTE: This is frozen to avoid https://foss.heptapod.net/openpyxl/openpyxl/-/issues/2147
|
||||
"openpyxl==3.0.10",
|
||||
|
||||
@@ -490,7 +490,6 @@ func createCherryPickPR(headBranch, baseBranch, title string, commitSHAs, commit
|
||||
|
||||
// Add standard checklist
|
||||
body += "\n\n"
|
||||
body += "- [x] [Required] I have considered whether this PR needs to be cherry-picked to the latest beta branch.\n"
|
||||
body += "- [x] [Optional] Override Linear Check\n"
|
||||
|
||||
cmd := exec.Command("gh", "pr", "create",
|
||||
|
||||
@@ -118,7 +118,7 @@ func runCI(cmd *cobra.Command, args []string, opts *RunCIOptions) {
|
||||
// Create the CI branch
|
||||
ciBranch := fmt.Sprintf("run-ci/%s", prNumber)
|
||||
prTitle := fmt.Sprintf("chore: [Running GitHub actions for #%s]", prNumber)
|
||||
prBody := fmt.Sprintf("This PR runs GitHub Actions CI for #%s.\n\n- [x] I have considered whether this PR needs to be cherry-picked to the latest beta branch.\n- [x] Override Linear Check\n\n**This PR should be closed (not merged) after CI completes.**", prNumber)
|
||||
prBody := fmt.Sprintf("This PR runs GitHub Actions CI for #%s.\n\n- [x] Override Linear Check\n\n**This PR should be closed (not merged) after CI completes.**", prNumber)
|
||||
|
||||
// Fetch the fork's branch
|
||||
if forkRepo == "" {
|
||||
|
||||
338
uv.lock
generated
338
uv.lock
generated
@@ -86,6 +86,18 @@ boto3 = [
|
||||
{ name = "boto3" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "aiofile"
|
||||
version = "3.9.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "caio" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/67/e2/d7cb819de8df6b5c1968a2756c3cb4122d4fa2b8fc768b53b7c9e5edb646/aiofile-3.9.0.tar.gz", hash = "sha256:e5ad718bb148b265b6df1b3752c4d1d83024b93da9bd599df74b9d9ffcf7919b", size = 17943, upload-time = "2024-10-08T10:39:35.846Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/50/25/da1f0b4dd970e52bf5a36c204c107e11a0c6d3ed195eba0bfbc664c312b2/aiofile-3.9.0-py3-none-any.whl", hash = "sha256:ce2f6c1571538cbdfa0143b04e16b208ecb0e9cb4148e528af8a640ed51cc8aa", size = 19539, upload-time = "2024-10-08T10:39:32.955Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "aiofiles"
|
||||
version = "25.1.0"
|
||||
@@ -736,6 +748,23 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/e6/46/eb6eca305c77a4489affe1c5d8f4cae82f285d9addd8de4ec084a7184221/cachetools-6.2.2-py3-none-any.whl", hash = "sha256:6c09c98183bf58560c97b2abfcedcbaf6a896a490f534b031b661d3723b45ace", size = 11503, upload-time = "2025-11-13T17:42:50.232Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "caio"
|
||||
version = "0.9.25"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/92/88/b8527e1b00c1811db339a1df8bd1ae49d146fcea9d6a5c40e3a80aaeb38d/caio-0.9.25.tar.gz", hash = "sha256:16498e7f81d1d0f5a4c0ad3f2540e65fe25691376e0a5bd367f558067113ed10", size = 26781, upload-time = "2025-12-26T15:21:36.501Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/ec/90/543f556fcfcfa270713eef906b6352ab048e1e557afec12925c991dc93c2/caio-0.9.25-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:d6956d9e4a27021c8bd6c9677f3a59eb1d820cc32d0343cea7961a03b1371965", size = 36839, upload-time = "2025-12-26T15:21:40.267Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/51/3b/36f3e8ec38dafe8de4831decd2e44c69303d2a3892d16ceda42afed44e1b/caio-0.9.25-cp311-cp311-manylinux2010_x86_64.manylinux2014_x86_64.manylinux_2_12_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:bf84bfa039f25ad91f4f52944452a5f6f405e8afab4d445450978cd6241d1478", size = 80255, upload-time = "2025-12-26T15:22:20.271Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d3/25/79c98ebe12df31548ba4eaf44db11b7cad6b3e7b4203718335620939083c/caio-0.9.25-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:fb7ff95af4c31ad3f03179149aab61097a71fd85e05f89b4786de0359dffd044", size = 36983, upload-time = "2025-12-26T15:21:36.075Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a3/2b/21288691f16d479945968a0a4f2856818c1c5be56881d51d4dac9b255d26/caio-0.9.25-cp312-cp312-manylinux2010_x86_64.manylinux2014_x86_64.manylinux_2_12_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:97084e4e30dfa598449d874c4d8e0c8d5ea17d2f752ef5e48e150ff9d240cd64", size = 82012, upload-time = "2025-12-26T15:22:20.983Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/31/57/5e6ff127e6f62c9f15d989560435c642144aa4210882f9494204bc892305/caio-0.9.25-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:d6c2a3411af97762a2b03840c3cec2f7f728921ff8adda53d7ea2315a8563451", size = 36979, upload-time = "2025-12-26T15:21:35.484Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a3/9f/f21af50e72117eb528c422d4276cbac11fb941b1b812b182e0a9c70d19c5/caio-0.9.25-cp313-cp313-manylinux2010_x86_64.manylinux2014_x86_64.manylinux_2_12_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0998210a4d5cd5cb565b32ccfe4e53d67303f868a76f212e002a8554692870e6", size = 81900, upload-time = "2025-12-26T15:22:21.919Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/69/ca/a08fdc7efdcc24e6a6131a93c85be1f204d41c58f474c42b0670af8c016b/caio-0.9.25-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:fab6078b9348e883c80a5e14b382e6ad6aabbc4429ca034e76e730cf464269db", size = 36978, upload-time = "2025-12-26T15:21:41.055Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5e/6c/d4d24f65e690213c097174d26eda6831f45f4734d9d036d81790a27e7b78/caio-0.9.25-cp314-cp314-manylinux2010_x86_64.manylinux2014_x86_64.manylinux_2_12_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:44a6b58e52d488c75cfaa5ecaa404b2b41cc965e6c417e03251e868ecd5b6d77", size = 81832, upload-time = "2025-12-26T15:22:22.757Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/86/93/1f76c8d1bafe3b0614e06b2195784a3765bbf7b0a067661af9e2dd47fc33/caio-0.9.25-py3-none-any.whl", hash = "sha256:06c0bb02d6b929119b1cfbe1ca403c768b2013a369e2db46bfa2a5761cf82e40", size = 19087, upload-time = "2025-12-26T15:22:00.221Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "celery"
|
||||
version = "5.5.1"
|
||||
@@ -1448,15 +1477,6 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/23/10/3c44e9331a5ec3bae8b2919d51f611a5b94e179563b1b89eb6423a8f43eb/discord.py-2.4.0-py3-none-any.whl", hash = "sha256:b8af6711c70f7e62160bfbecb55be699b5cb69d007426759ab8ab06b1bd77d1d", size = 1125988, upload-time = "2024-06-22T01:20:19.764Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "diskcache"
|
||||
version = "5.6.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/3f/21/1c1ffc1a039ddcc459db43cc108658f32c57d271d7289a2794e401d0fdb6/diskcache-5.6.3.tar.gz", hash = "sha256:2c3a3fa2743d8535d832ec61c2054a1641f41775aa7c556758a109941e33e4fc", size = 67916, upload-time = "2023-08-31T06:12:00.316Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/3f/27/4570e78fc0bf5ea0ca45eb1de3818a23787af9b390c0b0a0033a1b8236f9/diskcache-5.6.3-py3-none-any.whl", hash = "sha256:5e31b2d5fbad117cc363ebaf6b689474db18a1f6438bc82358b024abd4c2ca19", size = 45550, upload-time = "2023-08-31T06:11:58.822Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "distlib"
|
||||
version = "0.4.0"
|
||||
@@ -1666,24 +1686,6 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/46/ec/91a434c8a53d40c3598966621dea9c50512bec6ce8e76fa1751015e74cef/faker-40.1.2-py3-none-any.whl", hash = "sha256:93503165c165d330260e4379fd6dc07c94da90c611ed3191a0174d2ab9966a42", size = 1985633, upload-time = "2026-01-13T20:51:47.982Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fakeredis"
|
||||
version = "2.33.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "redis" },
|
||||
{ name = "sortedcontainers" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/5f/f9/57464119936414d60697fcbd32f38909bb5688b616ae13de6e98384433e0/fakeredis-2.33.0.tar.gz", hash = "sha256:d7bc9a69d21df108a6451bbffee23b3eba432c21a654afc7ff2d295428ec5770", size = 175187, upload-time = "2025-12-16T19:45:52.269Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/6e/78/a850fed8aeef96d4a99043c90b818b2ed5419cd5b24a4049fd7cfb9f1471/fakeredis-2.33.0-py3-none-any.whl", hash = "sha256:de535f3f9ccde1c56672ab2fdd6a8efbc4f2619fc2f1acc87b8737177d71c965", size = 119605, upload-time = "2025-12-16T19:45:51.08Z" },
|
||||
]
|
||||
|
||||
[package.optional-dependencies]
|
||||
lua = [
|
||||
{ name = "lupa" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fastapi"
|
||||
version = "0.128.0"
|
||||
@@ -1785,29 +1787,33 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "fastmcp"
|
||||
version = "2.14.2"
|
||||
version = "3.0.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "authlib" },
|
||||
{ name = "cyclopts" },
|
||||
{ name = "exceptiongroup" },
|
||||
{ name = "httpx" },
|
||||
{ name = "jsonref" },
|
||||
{ name = "jsonschema-path" },
|
||||
{ name = "mcp" },
|
||||
{ name = "openapi-pydantic" },
|
||||
{ name = "opentelemetry-api" },
|
||||
{ name = "packaging" },
|
||||
{ name = "platformdirs" },
|
||||
{ name = "py-key-value-aio", extra = ["disk", "keyring", "memory"] },
|
||||
{ name = "py-key-value-aio", extra = ["filetree", "keyring", "memory"] },
|
||||
{ name = "pydantic", extra = ["email"] },
|
||||
{ name = "pydocket" },
|
||||
{ name = "pyperclip" },
|
||||
{ name = "python-dotenv" },
|
||||
{ name = "pyyaml" },
|
||||
{ name = "rich" },
|
||||
{ name = "uvicorn" },
|
||||
{ name = "watchfiles" },
|
||||
{ name = "websockets" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/d1/1e/e3528227688c248283f6d86869b1e900563ffc223eff00f4f923d2750365/fastmcp-2.14.2.tar.gz", hash = "sha256:bd23d1b808b6f446444f10114dac468b11bfb9153ed78628f5619763d0cf573e", size = 8272966, upload-time = "2025-12-31T15:26:13.433Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/11/6b/1a7ec89727797fb07ec0928e9070fa2f45e7b35718e1fe01633a34c35e45/fastmcp-3.0.2.tar.gz", hash = "sha256:6bd73b4a3bab773ee6932df5249dcbcd78ed18365ed0aeeb97bb42702a7198d7", size = 17239351, upload-time = "2026-02-22T16:32:28.843Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/0d/67/8456d39484fcb7afd0defed21918e773ed59a98b39e5b633328527c88367/fastmcp-2.14.2-py3-none-any.whl", hash = "sha256:e33cd622e1ebd5110af6a981804525b6cd41072e3c7d68268ed69ef3be651aca", size = 413279, upload-time = "2025-12-31T15:26:11.178Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0a/5a/f410a9015cfde71adf646dab4ef2feae49f92f34f6050fcfb265eb126b30/fastmcp-3.0.2-py3-none-any.whl", hash = "sha256:f513d80d4b30b54749fe8950116b1aab843f3c293f5cb971fc8665cb48dbb028", size = 606268, upload-time = "2026-02-22T16:32:30.992Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3353,69 +3359,6 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/db/bc/83e112abc66cd466c6b83f99118035867cecd41802f8d044638aa78a106e/locket-1.0.0-py2.py3-none-any.whl", hash = "sha256:b6c819a722f7b6bd955b80781788e4a66a55628b858d347536b7e81325a3a5e3", size = 4398, upload-time = "2022-04-20T22:04:42.23Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "lupa"
|
||||
version = "2.6"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/b8/1c/191c3e6ec6502e3dbe25a53e27f69a5daeac3e56de1f73c0138224171ead/lupa-2.6.tar.gz", hash = "sha256:9a770a6e89576be3447668d7ced312cd6fd41d3c13c2462c9dc2c2ab570e45d9", size = 7240282, upload-time = "2025-10-24T07:20:29.738Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/ca/29/1f66907c1ebf1881735afa695e646762c674f00738ebf66d795d59fc0665/lupa-2.6-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:6d988c0f9331b9f2a5a55186701a25444ab10a1432a1021ee58011499ecbbdd5", size = 962875, upload-time = "2025-10-24T07:17:39.107Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e6/67/4a748604be360eb9c1c215f6a0da921cd1a2b44b2c5951aae6fb83019d3a/lupa-2.6-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:ebe1bbf48259382c72a6fe363dea61a0fd6fe19eab95e2ae881e20f3654587bf", size = 1935390, upload-time = "2025-10-24T07:17:41.427Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ac/0c/8ef9ee933a350428b7bdb8335a37ef170ab0bb008bbf9ca8f4f4310116b6/lupa-2.6-cp311-cp311-macosx_11_0_x86_64.whl", hash = "sha256:a8fcee258487cf77cdd41560046843bb38c2e18989cd19671dd1e2596f798306", size = 992193, upload-time = "2025-10-24T07:17:43.231Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/65/46/e6c7facebdb438db8a65ed247e56908818389c1a5abbf6a36aab14f1057d/lupa-2.6-cp311-cp311-manylinux2010_i686.manylinux_2_12_i686.manylinux_2_28_i686.whl", hash = "sha256:561a8e3be800827884e767a694727ed8482d066e0d6edfcbf423b05e63b05535", size = 1165844, upload-time = "2025-10-24T07:17:45.437Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1c/26/9f1154c6c95f175ccbf96aa96c8f569c87f64f463b32473e839137601a8b/lupa-2.6-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:af880a62d47991cae78b8e9905c008cbfdc4a3a9723a66310c2634fc7644578c", size = 1048069, upload-time = "2025-10-24T07:17:47.181Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/68/67/2cc52ab73d6af81612b2ea24c870d3fa398443af8e2875e5befe142398b1/lupa-2.6-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:80b22923aa4023c86c0097b235615f89d469a0c4eee0489699c494d3367c4c85", size = 2079079, upload-time = "2025-10-24T07:17:49.755Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2e/dc/f843f09bbf325f6e5ee61730cf6c3409fc78c010d968c7c78acba3019ca7/lupa-2.6-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:153d2cc6b643f7efb9cfc0c6bb55ec784d5bac1a3660cfc5b958a7b8f38f4a75", size = 1071428, upload-time = "2025-10-24T07:17:51.991Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2e/60/37533a8d85bf004697449acb97ecdacea851acad28f2ad3803662487dd2a/lupa-2.6-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:3fa8777e16f3ded50b72967dc17e23f5a08e4f1e2c9456aff2ebdb57f5b2869f", size = 1181756, upload-time = "2025-10-24T07:17:53.752Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e4/f2/cf29b20dbb4927b6a3d27c339ac5d73e74306ecc28c8e2c900b2794142ba/lupa-2.6-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:8dbdcbe818c02a2f56f5ab5ce2de374dab03e84b25266cfbaef237829bc09b3f", size = 2175687, upload-time = "2025-10-24T07:17:56.228Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/94/7c/050e02f80c7131b63db1474bff511e63c545b5a8636a24cbef3fc4da20b6/lupa-2.6-cp311-cp311-win32.whl", hash = "sha256:defaf188fde8f7a1e5ce3a5e6d945e533b8b8d547c11e43b96c9b7fe527f56dc", size = 1412592, upload-time = "2025-10-24T07:17:59.062Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6f/9a/6f2af98aa5d771cea661f66c8eb8f53772ec1ab1dfbce24126cfcd189436/lupa-2.6-cp311-cp311-win_amd64.whl", hash = "sha256:9505ae600b5c14f3e17e70f87f88d333717f60411faca1ddc6f3e61dce85fa9e", size = 1669194, upload-time = "2025-10-24T07:18:01.647Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/94/86/ce243390535c39d53ea17ccf0240815e6e457e413e40428a658ea4ee4b8d/lupa-2.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:47ce718817ef1cc0c40d87c3d5ae56a800d61af00fbc0fad1ca9be12df2f3b56", size = 951707, upload-time = "2025-10-24T07:18:03.884Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/86/85/cedea5e6cbeb54396fdcc55f6b741696f3f036d23cfaf986d50d680446da/lupa-2.6-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:7aba985b15b101495aa4b07112cdc08baa0c545390d560ad5cfde2e9e34f4d58", size = 1916703, upload-time = "2025-10-24T07:18:05.6Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/24/be/3d6b5f9a8588c01a4d88129284c726017b2089f3a3fd3ba8bd977292fea0/lupa-2.6-cp312-cp312-macosx_11_0_x86_64.whl", hash = "sha256:b766f62f95b2739f2248977d29b0722e589dcf4f0ccfa827ccbd29f0148bd2e5", size = 985152, upload-time = "2025-10-24T07:18:08.561Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/eb/23/9f9a05beee5d5dce9deca4cb07c91c40a90541fc0a8e09db4ee670da550f/lupa-2.6-cp312-cp312-manylinux2010_i686.manylinux_2_12_i686.manylinux_2_28_i686.whl", hash = "sha256:00a934c23331f94cb51760097ebfab14b005d55a6b30a2b480e3c53dd2fa290d", size = 1159599, upload-time = "2025-10-24T07:18:10.346Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/40/4e/e7c0583083db9d7f1fd023800a9767d8e4391e8330d56c2373d890ac971b/lupa-2.6-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:21de9f38bd475303e34a042b7081aabdf50bd9bafd36ce4faea2f90fd9f15c31", size = 1038686, upload-time = "2025-10-24T07:18:12.112Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1c/9f/5a4f7d959d4feba5e203ff0c31889e74d1ca3153122be4a46dca7d92bf7c/lupa-2.6-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cf3bda96d3fc41237e964a69c23647d50d4e28421111360274d4799832c560e9", size = 2071956, upload-time = "2025-10-24T07:18:14.572Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/92/34/2f4f13ca65d01169b1720176aedc4af17bc19ee834598c7292db232cb6dc/lupa-2.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5a76ead245da54801a81053794aa3975f213221f6542d14ec4b859ee2e7e0323", size = 1057199, upload-time = "2025-10-24T07:18:16.379Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/35/2a/5f7d2eebec6993b0dcd428e0184ad71afb06a45ba13e717f6501bfed1da3/lupa-2.6-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:8dd0861741caa20886ddbda0a121d8e52fb9b5bb153d82fa9bba796962bf30e8", size = 1173693, upload-time = "2025-10-24T07:18:18.153Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e4/29/089b4d2f8e34417349af3904bb40bec40b65c8731f45e3fd8d497ca573e5/lupa-2.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:239e63948b0b23023f81d9a19a395e768ed3da6a299f84e7963b8f813f6e3f9c", size = 2164394, upload-time = "2025-10-24T07:18:20.403Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f3/1b/79c17b23c921f81468a111cad843b076a17ef4b684c4a8dff32a7969c3f0/lupa-2.6-cp312-cp312-win32.whl", hash = "sha256:325894e1099499e7a6f9c351147661a2011887603c71086d36fe0f964d52d1ce", size = 1420647, upload-time = "2025-10-24T07:18:23.368Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b8/15/5121e68aad3584e26e1425a5c9a79cd898f8a152292059e128c206ee817c/lupa-2.6-cp312-cp312-win_amd64.whl", hash = "sha256:c735a1ce8ee60edb0fe71d665f1e6b7c55c6021f1d340eb8c865952c602cd36f", size = 1688529, upload-time = "2025-10-24T07:18:25.523Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/28/1d/21176b682ca5469001199d8b95fa1737e29957a3d185186e7a8b55345f2e/lupa-2.6-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:663a6e58a0f60e7d212017d6678639ac8df0119bc13c2145029dcba084391310", size = 947232, upload-time = "2025-10-24T07:18:27.878Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ce/4c/d327befb684660ca13cf79cd1f1d604331808f9f1b6fb6bf57832f8edf80/lupa-2.6-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:d1f5afda5c20b1f3217a80e9bc1b77037f8a6eb11612fd3ada19065303c8f380", size = 1908625, upload-time = "2025-10-24T07:18:29.944Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/66/8e/ad22b0a19454dfd08662237a84c792d6d420d36b061f239e084f29d1a4f3/lupa-2.6-cp313-cp313-macosx_11_0_x86_64.whl", hash = "sha256:26f2b3c085fe76e9119e48c1013c1cccdc1f51585d456858290475aa38e7089e", size = 981057, upload-time = "2025-10-24T07:18:31.553Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5c/48/74859073ab276bd0566c719f9ca0108b0cfc1956ca0d68678d117d47d155/lupa-2.6-cp313-cp313-manylinux2010_i686.manylinux_2_12_i686.manylinux_2_28_i686.whl", hash = "sha256:60d2f902c7b96fb8ab98493dcff315e7bb4d0b44dc9dd76eb37de575025d5685", size = 1156227, upload-time = "2025-10-24T07:18:33.981Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/09/6c/0e9ded061916877253c2266074060eb71ed99fb21d73c8c114a76725bce2/lupa-2.6-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a02d25dee3a3250967c36590128d9220ae02f2eda166a24279da0b481519cbff", size = 1035752, upload-time = "2025-10-24T07:18:36.32Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/dd/ef/f8c32e454ef9f3fe909f6c7d57a39f950996c37a3deb7b391fec7903dab7/lupa-2.6-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6eae1ee16b886b8914ff292dbefbf2f48abfbdee94b33a88d1d5475e02423203", size = 2069009, upload-time = "2025-10-24T07:18:38.072Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/53/dc/15b80c226a5225815a890ee1c11f07968e0aba7a852df41e8ae6fe285063/lupa-2.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:b0edd5073a4ee74ab36f74fe61450148e6044f3952b8d21248581f3c5d1a58be", size = 1056301, upload-time = "2025-10-24T07:18:40.165Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/31/14/2086c1425c985acfb30997a67e90c39457122df41324d3c179d6ee2292c6/lupa-2.6-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:0c53ee9f22a8a17e7d4266ad48e86f43771951797042dd51d1494aaa4f5f3f0a", size = 1170673, upload-time = "2025-10-24T07:18:42.426Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/10/e5/b216c054cf86576c0191bf9a9f05de6f7e8e07164897d95eea0078dca9b2/lupa-2.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:de7c0f157a9064a400d828789191a96da7f4ce889969a588b87ec80de9b14772", size = 2162227, upload-time = "2025-10-24T07:18:46.112Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/59/2f/33ecb5bedf4f3bc297ceacb7f016ff951331d352f58e7e791589609ea306/lupa-2.6-cp313-cp313-win32.whl", hash = "sha256:ee9523941ae0a87b5b703417720c5d78f72d2f5bc23883a2ea80a949a3ed9e75", size = 1419558, upload-time = "2025-10-24T07:18:48.371Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f9/b4/55e885834c847ea610e111d87b9ed4768f0afdaeebc00cd46810f25029f6/lupa-2.6-cp313-cp313-win_amd64.whl", hash = "sha256:b1335a5835b0a25ebdbc75cf0bda195e54d133e4d994877ef025e218c2e59db9", size = 1683424, upload-time = "2025-10-24T07:18:50.976Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/66/9d/d9427394e54d22a35d1139ef12e845fd700d4872a67a34db32516170b746/lupa-2.6-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:dcb6d0a3264873e1653bc188499f48c1fb4b41a779e315eba45256cfe7bc33c1", size = 953818, upload-time = "2025-10-24T07:18:53.378Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/10/41/27bbe81953fb2f9ecfced5d9c99f85b37964cfaf6aa8453bb11283983721/lupa-2.6-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:a37e01f2128f8c36106726cb9d360bac087d58c54b4522b033cc5691c584db18", size = 1915850, upload-time = "2025-10-24T07:18:55.259Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a3/98/f9ff60db84a75ba8725506bbf448fb085bc77868a021998ed2a66d920568/lupa-2.6-cp314-cp314-macosx_11_0_x86_64.whl", hash = "sha256:458bd7e9ff3c150b245b0fcfbb9bd2593d1152ea7f0a7b91c1d185846da033fe", size = 982344, upload-time = "2025-10-24T07:18:57.05Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/41/f7/f39e0f1c055c3b887d86b404aaf0ca197b5edfd235a8b81b45b25bac7fc3/lupa-2.6-cp314-cp314-manylinux2010_i686.manylinux_2_12_i686.manylinux_2_28_i686.whl", hash = "sha256:052ee82cac5206a02df77119c325339acbc09f5ce66967f66a2e12a0f3211cad", size = 1156543, upload-time = "2025-10-24T07:18:59.251Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9e/9c/59e6cffa0d672d662ae17bd7ac8ecd2c89c9449dee499e3eb13ca9cd10d9/lupa-2.6-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96594eca3c87dd07938009e95e591e43d554c1dbd0385be03c100367141db5a8", size = 1047974, upload-time = "2025-10-24T07:19:01.449Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/23/c6/a04e9cef7c052717fcb28fb63b3824802488f688391895b618e39be0f684/lupa-2.6-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e8faddd9d198688c8884091173a088a8e920ecc96cda2ffed576a23574c4b3f6", size = 2073458, upload-time = "2025-10-24T07:19:03.369Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e6/10/824173d10f38b51fc77785228f01411b6ca28826ce27404c7c912e0e442c/lupa-2.6-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:daebb3a6b58095c917e76ba727ab37b27477fb926957c825205fbda431552134", size = 1067683, upload-time = "2025-10-24T07:19:06.2Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b6/dc/9692fbcf3c924d9c4ece2d8d2f724451ac2e09af0bd2a782db1cef34e799/lupa-2.6-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:f3154e68972befe0f81564e37d8142b5d5d79931a18309226a04ec92487d4ea3", size = 1171892, upload-time = "2025-10-24T07:19:08.544Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/84/ff/e318b628d4643c278c96ab3ddea07fc36b075a57383c837f5b11e537ba9d/lupa-2.6-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:e4dadf77b9fedc0bfa53417cc28dc2278a26d4cbd95c29f8927ad4d8fe0a7ef9", size = 2166641, upload-time = "2025-10-24T07:19:10.485Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/12/f7/a6f9ec2806cf2d50826980cdb4b3cffc7691dc6f95e13cc728846d5cb793/lupa-2.6-cp314-cp314-win32.whl", hash = "sha256:cb34169c6fa3bab3e8ac58ca21b8a7102f6a94b6a5d08d3636312f3f02fafd8f", size = 1456857, upload-time = "2025-10-24T07:19:37.989Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c5/de/df71896f25bdc18360fdfa3b802cd7d57d7fede41a0e9724a4625b412c85/lupa-2.6-cp314-cp314-win_amd64.whl", hash = "sha256:b74f944fe46c421e25d0f8692aef1e842192f6f7f68034201382ac440ef9ea67", size = 1731191, upload-time = "2025-10-24T07:19:40.281Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/47/3c/a1f23b01c54669465f5f4c4083107d496fbe6fb45998771420e9aadcf145/lupa-2.6-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0e21b716408a21ab65723f8841cf7f2f37a844b7a965eeabb785e27fca4099cf", size = 999343, upload-time = "2025-10-24T07:19:12.519Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c5/6d/501994291cb640bfa2ccf7f554be4e6914afa21c4026bd01bff9ca8aac57/lupa-2.6-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:589db872a141bfff828340079bbdf3e9a31f2689f4ca0d88f97d9e8c2eae6142", size = 2000730, upload-time = "2025-10-24T07:19:14.869Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/53/a5/457ffb4f3f20469956c2d4c4842a7675e884efc895b2f23d126d23e126cc/lupa-2.6-cp314-cp314t-macosx_11_0_x86_64.whl", hash = "sha256:cd852a91a4a9d4dcbb9a58100f820a75a425703ec3e3f049055f60b8533b7953", size = 1021553, upload-time = "2025-10-24T07:19:17.123Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/51/6b/36bb5a5d0960f2a5c7c700e0819abb76fd9bf9c1d8a66e5106416d6e9b14/lupa-2.6-cp314-cp314t-manylinux2010_i686.manylinux_2_12_i686.manylinux_2_28_i686.whl", hash = "sha256:0334753be028358922415ca97a64a3048e4ed155413fc4eaf87dd0a7e2752983", size = 1133275, upload-time = "2025-10-24T07:19:20.51Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/19/86/202ff4429f663013f37d2229f6176ca9f83678a50257d70f61a0a97281bf/lupa-2.6-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:661d895cd38c87658a34780fac54a690ec036ead743e41b74c3fb81a9e65a6aa", size = 1038441, upload-time = "2025-10-24T07:19:22.509Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a7/42/d8125f8e420714e5b52e9c08d88b5329dfb02dcca731b4f21faaee6cc5b5/lupa-2.6-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6aa58454ccc13878cc177c62529a2056be734da16369e451987ff92784994ca7", size = 2058324, upload-time = "2025-10-24T07:19:24.979Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2b/2c/47bf8b84059876e877a339717ddb595a4a7b0e8740bacae78ba527562e1c/lupa-2.6-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:1425017264e470c98022bba8cff5bd46d054a827f5df6b80274f9cc71dafd24f", size = 1060250, upload-time = "2025-10-24T07:19:27.262Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c2/06/d88add2b6406ca1bdec99d11a429222837ca6d03bea42ca75afa169a78cb/lupa-2.6-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:224af0532d216e3105f0a127410f12320f7c5f1aa0300bdf9646b8d9afb0048c", size = 1151126, upload-time = "2025-10-24T07:19:29.522Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b4/a0/89e6a024c3b4485b89ef86881c9d55e097e7cb0bdb74efb746f2fa6a9a76/lupa-2.6-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:9abb98d5a8fd27c8285302e82199f0e56e463066f88f619d6594a450bf269d80", size = 2153693, upload-time = "2025-10-24T07:19:31.379Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b6/36/a0f007dc58fc1bbf51fb85dcc82fcb1f21b8c4261361de7dab0e3d8521ef/lupa-2.6-cp314-cp314t-win32.whl", hash = "sha256:1849efeba7a8f6fb8aa2c13790bee988fd242ae404bd459509640eeea3d1e291", size = 1590104, upload-time = "2025-10-24T07:19:33.514Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7d/5e/db903ce9cf82c48d6b91bf6d63ae4c8d0d17958939a4e04ba6b9f38b8643/lupa-2.6-cp314-cp314t-win_amd64.whl", hash = "sha256:fc1498d1a4fc028bc521c26d0fad4ca00ed63b952e32fb95949bda76a04bad52", size = 1913818, upload-time = "2025-10-24T07:19:36.039Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "lxml"
|
||||
version = "5.3.0"
|
||||
@@ -3790,7 +3733,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "mcp"
|
||||
version = "1.25.0"
|
||||
version = "1.26.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "anyio" },
|
||||
@@ -3808,9 +3751,9 @@ dependencies = [
|
||||
{ name = "typing-inspection" },
|
||||
{ name = "uvicorn", marker = "sys_platform != 'emscripten'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/d5/2d/649d80a0ecf6a1f82632ca44bec21c0461a9d9fc8934d38cb5b319f2db5e/mcp-1.25.0.tar.gz", hash = "sha256:56310361ebf0364e2d438e5b45f7668cbb124e158bb358333cd06e49e83a6802", size = 605387, upload-time = "2025-12-19T10:19:56.985Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/fc/6d/62e76bbb8144d6ed86e202b5edd8a4cb631e7c8130f3f4893c3f90262b10/mcp-1.26.0.tar.gz", hash = "sha256:db6e2ef491eecc1a0d93711a76f28dec2e05999f93afd48795da1c1137142c66", size = 608005, upload-time = "2026-01-24T19:40:32.468Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/e2/fc/6dc7659c2ae5ddf280477011f4213a74f806862856b796ef08f028e664bf/mcp-1.25.0-py3-none-any.whl", hash = "sha256:b37c38144a666add0862614cc79ec276e97d72aa8ca26d622818d4e278b9721a", size = 233076, upload-time = "2025-12-19T10:19:55.416Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fd/d9/eaa1f80170d2b7c5ba23f3b59f766f3a0bb41155fbc32a69adfa1adaaef9/mcp-1.26.0-py3-none-any.whl", hash = "sha256:904a21c33c25aa98ddbeb47273033c435e595bbacfdb177f4bd87f6dceebe1ca", size = 233615, upload-time = "2026-01-24T19:40:30.652Z" },
|
||||
]
|
||||
|
||||
[package.optional-dependencies]
|
||||
@@ -4408,7 +4351,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "office365-rest-python-client"
|
||||
version = "2.5.9"
|
||||
version = "2.6.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "msal" },
|
||||
@@ -4416,9 +4359,9 @@ dependencies = [
|
||||
{ name = "requests" },
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/e5/7d/7219ab9e1091024a74ee32cef0205a7bd44d3058d08a4948d30c58793eee/Office365-REST-Python-Client-2.5.9.tar.gz", hash = "sha256:95c1c01d6a52c1bd3fcd8cc914c373d2c3e1578173070e65f325da9c5ba6f5ad", size = 605076, upload-time = "2024-05-06T19:38:38.98Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/bc/04/6dce2d581c54a8e55a3b128cf79a93821a68a62bb9a956e65476c5bb247e/office365_rest_python_client-2.6.2.tar.gz", hash = "sha256:ce27f5a1c0cc3ff97041ccd9b386145692be4c64739f243f7d6ac3edbe0a3c46", size = 659460, upload-time = "2025-05-11T10:24:21.895Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/b7/47/d01eaa338abadcbabf69b913d91ffb54a7f62f538ceb402cd2cf572115a8/Office365_REST_Python_Client-2.5.9-py3-none-any.whl", hash = "sha256:79b2718572763492f6fd351ad388089bdb3fde07fe5c1f92d716255ceadc0e47", size = 1168849, upload-time = "2024-05-06T19:38:32.534Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3a/a4/611155711f8af347875c15b8b83f5fd9e978bd4de45f90085b9a583b684d/Office365_REST_Python_Client-2.6.2-py3-none-any.whl", hash = "sha256:06fc6829c39b503897caa9d881db419d7f97a8e4f1c95c4c2d12db36ea6c955d", size = 1337139, upload-time = "2025-05-11T10:24:18.926Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4673,7 +4616,7 @@ requires-dist = [
|
||||
{ name = "fastapi-limiter", marker = "extra == 'backend'", specifier = "==0.1.6" },
|
||||
{ name = "fastapi-users", marker = "extra == 'backend'", specifier = "==15.0.4" },
|
||||
{ name = "fastapi-users-db-sqlalchemy", marker = "extra == 'backend'", specifier = "==7.0.0" },
|
||||
{ name = "fastmcp", marker = "extra == 'backend'", specifier = "==2.14.2" },
|
||||
{ name = "fastmcp", marker = "extra == 'backend'", specifier = "==3.0.2" },
|
||||
{ name = "filelock", marker = "extra == 'backend'", specifier = "==3.20.3" },
|
||||
{ name = "google-api-python-client", marker = "extra == 'backend'", specifier = "==2.86.0" },
|
||||
{ name = "google-auth-httplib2", marker = "extra == 'backend'", specifier = "==0.1.0" },
|
||||
@@ -4701,7 +4644,7 @@ requires-dist = [
|
||||
{ name = "manygo", marker = "extra == 'dev'", specifier = "==0.2.0" },
|
||||
{ name = "markitdown", extras = ["pdf", "docx", "pptx", "xlsx", "xls"], marker = "extra == 'backend'", specifier = "==0.1.2" },
|
||||
{ name = "matplotlib", marker = "extra == 'dev'", specifier = "==3.10.8" },
|
||||
{ name = "mcp", extras = ["cli"], marker = "extra == 'backend'", specifier = "==1.25.0" },
|
||||
{ name = "mcp", extras = ["cli"], marker = "extra == 'backend'", specifier = "==1.26.0" },
|
||||
{ name = "mistune", marker = "extra == 'backend'", specifier = "==3.2.0" },
|
||||
{ name = "msal", marker = "extra == 'backend'", specifier = "==1.34.0" },
|
||||
{ name = "msoffcrypto-tool", marker = "extra == 'backend'", specifier = "==5.4.2" },
|
||||
@@ -4710,7 +4653,7 @@ requires-dist = [
|
||||
{ name = "nest-asyncio", marker = "extra == 'backend'", specifier = "==1.6.0" },
|
||||
{ name = "numpy", marker = "extra == 'model-server'", specifier = "==2.4.1" },
|
||||
{ name = "oauthlib", marker = "extra == 'backend'", specifier = "==3.2.2" },
|
||||
{ name = "office365-rest-python-client", marker = "extra == 'backend'", specifier = "==2.5.9" },
|
||||
{ name = "office365-rest-python-client", marker = "extra == 'backend'", specifier = "==2.6.2" },
|
||||
{ name = "onyx-devtools", marker = "extra == 'dev'", specifier = "==0.6.0" },
|
||||
{ name = "openai", specifier = "==2.14.0" },
|
||||
{ name = "openapi-generator-cli", marker = "extra == 'dev'", specifier = "==7.17.0" },
|
||||
@@ -4967,35 +4910,6 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/95/f1/b27d3e2e003cd9a3592c43d099d2ed8d0a947c15281bf8463a256db0b46c/opentelemetry_exporter_otlp_proto_http-1.39.1-py3-none-any.whl", hash = "sha256:d9f5207183dd752a412c4cd564ca8875ececba13be6e9c6c370ffb752fd59985", size = 19641, upload-time = "2025-12-11T13:32:22.248Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "opentelemetry-exporter-prometheus"
|
||||
version = "0.60b1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "opentelemetry-api" },
|
||||
{ name = "opentelemetry-sdk" },
|
||||
{ name = "prometheus-client" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/14/39/7dafa6fff210737267bed35a8855b6ac7399b9e582b8cf1f25f842517012/opentelemetry_exporter_prometheus-0.60b1.tar.gz", hash = "sha256:a4011b46906323f71724649d301b4dc188aaa068852e814f4df38cc76eac616b", size = 14976, upload-time = "2025-12-11T13:32:42.944Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/9b/0d/4be6bf5477a3eb3d917d2f17d3c0b6720cd6cb97898444a61d43cc983f5c/opentelemetry_exporter_prometheus-0.60b1-py3-none-any.whl", hash = "sha256:49f59178de4f4590e3cef0b8b95cf6e071aae70e1f060566df5546fad773b8fd", size = 13019, upload-time = "2025-12-11T13:32:23.974Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "opentelemetry-instrumentation"
|
||||
version = "0.60b1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "opentelemetry-api" },
|
||||
{ name = "opentelemetry-semantic-conventions" },
|
||||
{ name = "packaging" },
|
||||
{ name = "wrapt" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/41/0f/7e6b713ac117c1f5e4e3300748af699b9902a2e5e34c9cf443dde25a01fa/opentelemetry_instrumentation-0.60b1.tar.gz", hash = "sha256:57ddc7974c6eb35865af0426d1a17132b88b2ed8586897fee187fd5b8944bd6a", size = 31706, upload-time = "2025-12-11T13:36:42.515Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/77/d2/6788e83c5c86a2690101681aeef27eeb2a6bf22df52d3f263a22cee20915/opentelemetry_instrumentation-0.60b1-py3-none-any.whl", hash = "sha256:04480db952b48fb1ed0073f822f0ee26012b7be7c3eac1a3793122737c78632d", size = 33096, upload-time = "2025-12-11T13:35:33.067Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "opentelemetry-proto"
|
||||
version = "1.39.1"
|
||||
@@ -5237,15 +5151,6 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/cc/20/ff623b09d963f88bfde16306a54e12ee5ea43e9b597108672ff3a408aad6/pathspec-0.12.1-py3-none-any.whl", hash = "sha256:a0d503e138a4c123b27490a4f7beda6a01c6f288df0e4a8b79c7eb0dc7b4cc08", size = 31191, upload-time = "2023-12-10T22:30:43.14Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pathvalidate"
|
||||
version = "3.3.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/fa/2a/52a8da6fe965dea6192eb716b357558e103aea0a1e9a8352ad575a8406ca/pathvalidate-3.3.1.tar.gz", hash = "sha256:b18c07212bfead624345bb8e1d6141cdcf15a39736994ea0b94035ad2b1ba177", size = 63262, upload-time = "2025-06-15T09:07:20.736Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/9a/70/875f4a23bfc4731703a5835487d0d2fb999031bd415e7d17c0ae615c18b7/pathvalidate-3.3.1-py3-none-any.whl", hash = "sha256:5263baab691f8e1af96092fa5137ee17df5bdfbd6cff1fcac4d6ef4bc2e1735f", size = 24305, upload-time = "2025-06-15T09:07:19.117Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pdfminer-six"
|
||||
version = "20251107"
|
||||
@@ -5709,21 +5614,21 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "py-key-value-aio"
|
||||
version = "0.3.0"
|
||||
version = "0.4.4"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "beartype" },
|
||||
{ name = "py-key-value-shared" },
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/93/ce/3136b771dddf5ac905cc193b461eb67967cf3979688c6696e1f2cdcde7ea/py_key_value_aio-0.3.0.tar.gz", hash = "sha256:858e852fcf6d696d231266da66042d3355a7f9871650415feef9fca7a6cd4155", size = 50801, upload-time = "2025-11-17T16:50:04.711Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/04/3c/0397c072a38d4bc580994b42e0c90c5f44f679303489e4376289534735e5/py_key_value_aio-0.4.4.tar.gz", hash = "sha256:e3012e6243ed7cc09bb05457bd4d03b1ba5c2b1ca8700096b3927db79ffbbe55", size = 92300, upload-time = "2026-02-16T21:21:43.245Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/99/10/72f6f213b8f0bce36eff21fda0a13271834e9eeff7f9609b01afdc253c79/py_key_value_aio-0.3.0-py3-none-any.whl", hash = "sha256:1c781915766078bfd608daa769fefb97e65d1d73746a3dfb640460e322071b64", size = 96342, upload-time = "2025-11-17T16:50:03.801Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/32/69/f1b537ee70b7def42d63124a539ed3026a11a3ffc3086947a1ca6e861868/py_key_value_aio-0.4.4-py3-none-any.whl", hash = "sha256:18e17564ecae61b987f909fc2cd41ee2012c84b4b1dcb8c055cf8b4bc1bf3f5d", size = 152291, upload-time = "2026-02-16T21:21:44.241Z" },
|
||||
]
|
||||
|
||||
[package.optional-dependencies]
|
||||
disk = [
|
||||
{ name = "diskcache" },
|
||||
{ name = "pathvalidate" },
|
||||
filetree = [
|
||||
{ name = "aiofile" },
|
||||
{ name = "anyio" },
|
||||
]
|
||||
keyring = [
|
||||
{ name = "keyring" },
|
||||
@@ -5731,22 +5636,6 @@ keyring = [
|
||||
memory = [
|
||||
{ name = "cachetools" },
|
||||
]
|
||||
redis = [
|
||||
{ name = "redis" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "py-key-value-shared"
|
||||
version = "0.3.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "beartype" },
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/7b/e4/1971dfc4620a3a15b4579fe99e024f5edd6e0967a71154771a059daff4db/py_key_value_shared-0.3.0.tar.gz", hash = "sha256:8fdd786cf96c3e900102945f92aa1473138ebe960ef49da1c833790160c28a4b", size = 11666, upload-time = "2025-11-17T16:50:06.849Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/51/e4/b8b0a03ece72f47dce2307d36e1c34725b7223d209fc679315ffe6a4e2c3/py_key_value_shared-0.3.0-py3-none-any.whl", hash = "sha256:5b0efba7ebca08bb158b1e93afc2f07d30b8f40c2fc12ce24a4c0d84f42f9298", size = 19560, upload-time = "2025-11-17T16:50:05.954Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pyairtable"
|
||||
@@ -5911,29 +5800,6 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/c1/60/5d4751ba3f4a40a6891f24eec885f51afd78d208498268c734e256fb13c4/pydantic_settings-2.12.0-py3-none-any.whl", hash = "sha256:fddb9fd99a5b18da837b29710391e945b1e30c135477f484084ee513adb93809", size = 51880, upload-time = "2025-11-10T14:25:45.546Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pydocket"
|
||||
version = "0.16.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "cloudpickle" },
|
||||
{ name = "fakeredis", extra = ["lua"] },
|
||||
{ name = "opentelemetry-api" },
|
||||
{ name = "opentelemetry-exporter-prometheus" },
|
||||
{ name = "opentelemetry-instrumentation" },
|
||||
{ name = "prometheus-client" },
|
||||
{ name = "py-key-value-aio", extra = ["memory", "redis"] },
|
||||
{ name = "python-json-logger" },
|
||||
{ name = "redis" },
|
||||
{ name = "rich" },
|
||||
{ name = "typer" },
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/e0/c5/61dcfce4d50b66a3f09743294d37fab598b81bb0975054b7f732da9243ec/pydocket-0.16.3.tar.gz", hash = "sha256:78e9da576de09e9f3f410d2471ef1c679b7741ddd21b586c97a13872b69bd265", size = 297080, upload-time = "2025-12-23T23:37:33.32Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/2c/94/93b7f5981aa04f922e0d9ce7326a4587866ec7e39f7c180ffcf408e66ee8/pydocket-0.16.3-py3-none-any.whl", hash = "sha256:e2b50925356e7cd535286255195458ac7bba15f25293356651b36d223db5dd7c", size = 67087, upload-time = "2025-12-23T23:37:31.829Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pyee"
|
||||
version = "13.0.0"
|
||||
@@ -6268,15 +6134,6 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/b5/2d/563849c31e58eb2e273fa0c391a7d9987db32f4d9152fe6ecdac0a8ffe93/python_iso639-2025.11.16-py3-none-any.whl", hash = "sha256:65f6ac6c6d8e8207f6175f8bf7fff7db486c6dc5c1d8866c2b77d2a923370896", size = 167818, upload-time = "2025-11-16T21:53:35.36Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "python-json-logger"
|
||||
version = "4.0.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/29/bf/eca6a3d43db1dae7070f70e160ab20b807627ba953663ba07928cdd3dc58/python_json_logger-4.0.0.tar.gz", hash = "sha256:f58e68eb46e1faed27e0f574a55a0455eecd7b8a5b88b85a784519ba3cff047f", size = 17683, upload-time = "2025-10-06T04:15:18.984Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/51/e5/fecf13f06e5e5f67e8837d777d1bc43fac0ed2b77a676804df5c34744727/python_json_logger-4.0.0-py3-none-any.whl", hash = "sha256:af09c9daf6a813aa4cc7180395f50f2a9e5fa056034c9953aec92e381c5ba1e2", size = 15548, upload-time = "2025-10-06T04:15:17.553Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "python-magic"
|
||||
version = "0.4.27"
|
||||
@@ -8064,6 +7921,93 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/bf/7c/43fb4689fe287eceb701f389863aab35211835d63bbb9a798cfefa80d7de/voyageai-0.2.3-py3-none-any.whl", hash = "sha256:59c4958bd991e83cedb5a82d5e14ac698ce67e42713ea10467631a48ee272b15", size = 19748, upload-time = "2024-05-29T08:12:44.968Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "watchfiles"
|
||||
version = "1.1.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "anyio" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/c2/c9/8869df9b2a2d6c59d79220a4db37679e74f807c559ffe5265e08b227a210/watchfiles-1.1.1.tar.gz", hash = "sha256:a173cb5c16c4f40ab19cecf48a534c409f7ea983ab8fed0741304a1c0a31b3f2", size = 94440, upload-time = "2025-10-14T15:06:21.08Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/1f/f8/2c5f479fb531ce2f0564eda479faecf253d886b1ab3630a39b7bf7362d46/watchfiles-1.1.1-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:f57b396167a2565a4e8b5e56a5a1c537571733992b226f4f1197d79e94cf0ae5", size = 406529, upload-time = "2025-10-14T15:04:32.899Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fe/cd/f515660b1f32f65df671ddf6f85bfaca621aee177712874dc30a97397977/watchfiles-1.1.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:421e29339983e1bebc281fab40d812742268ad057db4aee8c4d2bce0af43b741", size = 394384, upload-time = "2025-10-14T15:04:33.761Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7b/c3/28b7dc99733eab43fca2d10f55c86e03bd6ab11ca31b802abac26b23d161/watchfiles-1.1.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6e43d39a741e972bab5d8100b5cdacf69db64e34eb19b6e9af162bccf63c5cc6", size = 448789, upload-time = "2025-10-14T15:04:34.679Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4a/24/33e71113b320030011c8e4316ccca04194bf0cbbaeee207f00cbc7d6b9f5/watchfiles-1.1.1-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f537afb3276d12814082a2e9b242bdcf416c2e8fd9f799a737990a1dbe906e5b", size = 460521, upload-time = "2025-10-14T15:04:35.963Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f4/c3/3c9a55f255aa57b91579ae9e98c88704955fa9dac3e5614fb378291155df/watchfiles-1.1.1-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b2cd9e04277e756a2e2d2543d65d1e2166d6fd4c9b183f8808634fda23f17b14", size = 488722, upload-time = "2025-10-14T15:04:37.091Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/49/36/506447b73eb46c120169dc1717fe2eff07c234bb3232a7200b5f5bd816e9/watchfiles-1.1.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5f3f58818dc0b07f7d9aa7fe9eb1037aecb9700e63e1f6acfed13e9fef648f5d", size = 596088, upload-time = "2025-10-14T15:04:38.39Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/82/ab/5f39e752a9838ec4d52e9b87c1e80f1ee3ccdbe92e183c15b6577ab9de16/watchfiles-1.1.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9bb9f66367023ae783551042d31b1d7fd422e8289eedd91f26754a66f44d5cff", size = 472923, upload-time = "2025-10-14T15:04:39.666Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/af/b9/a419292f05e302dea372fa7e6fda5178a92998411f8581b9830d28fb9edb/watchfiles-1.1.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:aebfd0861a83e6c3d1110b78ad54704486555246e542be3e2bb94195eabb2606", size = 456080, upload-time = "2025-10-14T15:04:40.643Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b0/c3/d5932fd62bde1a30c36e10c409dc5d54506726f08cb3e1d8d0ba5e2bc8db/watchfiles-1.1.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:5fac835b4ab3c6487b5dbad78c4b3724e26bcc468e886f8ba8cc4306f68f6701", size = 629432, upload-time = "2025-10-14T15:04:41.789Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f7/77/16bddd9779fafb795f1a94319dc965209c5641db5bf1edbbccace6d1b3c0/watchfiles-1.1.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:399600947b170270e80134ac854e21b3ccdefa11a9529a3decc1327088180f10", size = 623046, upload-time = "2025-10-14T15:04:42.718Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/46/ef/f2ecb9a0f342b4bfad13a2787155c6ee7ce792140eac63a34676a2feeef2/watchfiles-1.1.1-cp311-cp311-win32.whl", hash = "sha256:de6da501c883f58ad50db3a32ad397b09ad29865b5f26f64c24d3e3281685849", size = 271473, upload-time = "2025-10-14T15:04:43.624Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/94/bc/f42d71125f19731ea435c3948cad148d31a64fccde3867e5ba4edee901f9/watchfiles-1.1.1-cp311-cp311-win_amd64.whl", hash = "sha256:35c53bd62a0b885bf653ebf6b700d1bf05debb78ad9292cf2a942b23513dc4c4", size = 287598, upload-time = "2025-10-14T15:04:44.516Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/57/c9/a30f897351f95bbbfb6abcadafbaca711ce1162f4db95fc908c98a9165f3/watchfiles-1.1.1-cp311-cp311-win_arm64.whl", hash = "sha256:57ca5281a8b5e27593cb7d82c2ac927ad88a96ed406aa446f6344e4328208e9e", size = 277210, upload-time = "2025-10-14T15:04:45.883Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/74/d5/f039e7e3c639d9b1d09b07ea412a6806d38123f0508e5f9b48a87b0a76cc/watchfiles-1.1.1-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:8c89f9f2f740a6b7dcc753140dd5e1ab9215966f7a3530d0c0705c83b401bd7d", size = 404745, upload-time = "2025-10-14T15:04:46.731Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a5/96/a881a13aa1349827490dab2d363c8039527060cfcc2c92cc6d13d1b1049e/watchfiles-1.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:bd404be08018c37350f0d6e34676bd1e2889990117a2b90070b3007f172d0610", size = 391769, upload-time = "2025-10-14T15:04:48.003Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4b/5b/d3b460364aeb8da471c1989238ea0e56bec24b6042a68046adf3d9ddb01c/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8526e8f916bb5b9a0a777c8317c23ce65de259422bba5b31325a6fa6029d33af", size = 449374, upload-time = "2025-10-14T15:04:49.179Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b9/44/5769cb62d4ed055cb17417c0a109a92f007114a4e07f30812a73a4efdb11/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2edc3553362b1c38d9f06242416a5d8e9fe235c204a4072e988ce2e5bb1f69f6", size = 459485, upload-time = "2025-10-14T15:04:50.155Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/19/0c/286b6301ded2eccd4ffd0041a1b726afda999926cf720aab63adb68a1e36/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:30f7da3fb3f2844259cba4720c3fc7138eb0f7b659c38f3bfa65084c7fc7abce", size = 488813, upload-time = "2025-10-14T15:04:51.059Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c7/2b/8530ed41112dd4a22f4dcfdb5ccf6a1baad1ff6eed8dc5a5f09e7e8c41c7/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f8979280bdafff686ba5e4d8f97840f929a87ed9cdf133cbbd42f7766774d2aa", size = 594816, upload-time = "2025-10-14T15:04:52.031Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ce/d2/f5f9fb49489f184f18470d4f99f4e862a4b3e9ac2865688eb2099e3d837a/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:dcc5c24523771db3a294c77d94771abcfcb82a0e0ee8efd910c37c59ec1b31bb", size = 475186, upload-time = "2025-10-14T15:04:53.064Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cf/68/5707da262a119fb06fbe214d82dd1fe4a6f4af32d2d14de368d0349eb52a/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1db5d7ae38ff20153d542460752ff397fcf5c96090c1230803713cf3147a6803", size = 456812, upload-time = "2025-10-14T15:04:55.174Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/66/ab/3cbb8756323e8f9b6f9acb9ef4ec26d42b2109bce830cc1f3468df20511d/watchfiles-1.1.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:28475ddbde92df1874b6c5c8aaeb24ad5be47a11f87cde5a28ef3835932e3e94", size = 630196, upload-time = "2025-10-14T15:04:56.22Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/78/46/7152ec29b8335f80167928944a94955015a345440f524d2dfe63fc2f437b/watchfiles-1.1.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:36193ed342f5b9842edd3532729a2ad55c4160ffcfa3700e0d54be496b70dd43", size = 622657, upload-time = "2025-10-14T15:04:57.521Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0a/bf/95895e78dd75efe9a7f31733607f384b42eb5feb54bd2eb6ed57cc2e94f4/watchfiles-1.1.1-cp312-cp312-win32.whl", hash = "sha256:859e43a1951717cc8de7f4c77674a6d389b106361585951d9e69572823f311d9", size = 272042, upload-time = "2025-10-14T15:04:59.046Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/87/0a/90eb755f568de2688cb220171c4191df932232c20946966c27a59c400850/watchfiles-1.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:91d4c9a823a8c987cce8fa2690923b069966dabb196dd8d137ea2cede885fde9", size = 288410, upload-time = "2025-10-14T15:05:00.081Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/36/76/f322701530586922fbd6723c4f91ace21364924822a8772c549483abed13/watchfiles-1.1.1-cp312-cp312-win_arm64.whl", hash = "sha256:a625815d4a2bdca61953dbba5a39d60164451ef34c88d751f6c368c3ea73d404", size = 278209, upload-time = "2025-10-14T15:05:01.168Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bb/f4/f750b29225fe77139f7ae5de89d4949f5a99f934c65a1f1c0b248f26f747/watchfiles-1.1.1-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:130e4876309e8686a5e37dba7d5e9bc77e6ed908266996ca26572437a5271e18", size = 404321, upload-time = "2025-10-14T15:05:02.063Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2b/f9/f07a295cde762644aa4c4bb0f88921d2d141af45e735b965fb2e87858328/watchfiles-1.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:5f3bde70f157f84ece3765b42b4a52c6ac1a50334903c6eaf765362f6ccca88a", size = 391783, upload-time = "2025-10-14T15:05:03.052Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bc/11/fc2502457e0bea39a5c958d86d2cb69e407a4d00b85735ca724bfa6e0d1a/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:14e0b1fe858430fc0251737ef3824c54027bedb8c37c38114488b8e131cf8219", size = 449279, upload-time = "2025-10-14T15:05:04.004Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e3/1f/d66bc15ea0b728df3ed96a539c777acfcad0eb78555ad9efcaa1274688f0/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f27db948078f3823a6bb3b465180db8ebecf26dd5dae6f6180bd87383b6b4428", size = 459405, upload-time = "2025-10-14T15:05:04.942Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/be/90/9f4a65c0aec3ccf032703e6db02d89a157462fbb2cf20dd415128251cac0/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:059098c3a429f62fc98e8ec62b982230ef2c8df68c79e826e37b895bc359a9c0", size = 488976, upload-time = "2025-10-14T15:05:05.905Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/37/57/ee347af605d867f712be7029bb94c8c071732a4b44792e3176fa3c612d39/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bfb5862016acc9b869bb57284e6cb35fdf8e22fe59f7548858e2f971d045f150", size = 595506, upload-time = "2025-10-14T15:05:06.906Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a8/78/cc5ab0b86c122047f75e8fc471c67a04dee395daf847d3e59381996c8707/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:319b27255aacd9923b8a276bb14d21a5f7ff82564c744235fc5eae58d95422ae", size = 474936, upload-time = "2025-10-14T15:05:07.906Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/62/da/def65b170a3815af7bd40a3e7010bf6ab53089ef1b75d05dd5385b87cf08/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c755367e51db90e75b19454b680903631d41f9e3607fbd941d296a020c2d752d", size = 456147, upload-time = "2025-10-14T15:05:09.138Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/57/99/da6573ba71166e82d288d4df0839128004c67d2778d3b566c138695f5c0b/watchfiles-1.1.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:c22c776292a23bfc7237a98f791b9ad3144b02116ff10d820829ce62dff46d0b", size = 630007, upload-time = "2025-10-14T15:05:10.117Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a8/51/7439c4dd39511368849eb1e53279cd3454b4a4dbace80bab88feeb83c6b5/watchfiles-1.1.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:3a476189be23c3686bc2f4321dd501cb329c0a0469e77b7b534ee10129ae6374", size = 622280, upload-time = "2025-10-14T15:05:11.146Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/95/9c/8ed97d4bba5db6fdcdb2b298d3898f2dd5c20f6b73aee04eabe56c59677e/watchfiles-1.1.1-cp313-cp313-win32.whl", hash = "sha256:bf0a91bfb5574a2f7fc223cf95eeea79abfefa404bf1ea5e339c0c1560ae99a0", size = 272056, upload-time = "2025-10-14T15:05:12.156Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1f/f3/c14e28429f744a260d8ceae18bf58c1d5fa56b50d006a7a9f80e1882cb0d/watchfiles-1.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:52e06553899e11e8074503c8e716d574adeeb7e68913115c4b3653c53f9bae42", size = 288162, upload-time = "2025-10-14T15:05:13.208Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/dc/61/fe0e56c40d5cd29523e398d31153218718c5786b5e636d9ae8ae79453d27/watchfiles-1.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:ac3cc5759570cd02662b15fbcd9d917f7ecd47efe0d6b40474eafd246f91ea18", size = 277909, upload-time = "2025-10-14T15:05:14.49Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/79/42/e0a7d749626f1e28c7108a99fb9bf524b501bbbeb9b261ceecde644d5a07/watchfiles-1.1.1-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:563b116874a9a7ce6f96f87cd0b94f7faf92d08d0021e837796f0a14318ef8da", size = 403389, upload-time = "2025-10-14T15:05:15.777Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/15/49/08732f90ce0fbbc13913f9f215c689cfc9ced345fb1bcd8829a50007cc8d/watchfiles-1.1.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3ad9fe1dae4ab4212d8c91e80b832425e24f421703b5a42ef2e4a1e215aff051", size = 389964, upload-time = "2025-10-14T15:05:16.85Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/27/0d/7c315d4bd5f2538910491a0393c56bf70d333d51bc5b34bee8e68e8cea19/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce70f96a46b894b36eba678f153f052967a0d06d5b5a19b336ab0dbbd029f73e", size = 448114, upload-time = "2025-10-14T15:05:17.876Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c3/24/9e096de47a4d11bc4df41e9d1e61776393eac4cb6eb11b3e23315b78b2cc/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:cb467c999c2eff23a6417e58d75e5828716f42ed8289fe6b77a7e5a91036ca70", size = 460264, upload-time = "2025-10-14T15:05:18.962Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cc/0f/e8dea6375f1d3ba5fcb0b3583e2b493e77379834c74fd5a22d66d85d6540/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:836398932192dae4146c8f6f737d74baeac8b70ce14831a239bdb1ca882fc261", size = 487877, upload-time = "2025-10-14T15:05:20.094Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ac/5b/df24cfc6424a12deb41503b64d42fbea6b8cb357ec62ca84a5a3476f654a/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:743185e7372b7bc7c389e1badcc606931a827112fbbd37f14c537320fca08620", size = 595176, upload-time = "2025-10-14T15:05:21.134Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8f/b5/853b6757f7347de4e9b37e8cc3289283fb983cba1ab4d2d7144694871d9c/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:afaeff7696e0ad9f02cbb8f56365ff4686ab205fcf9c4c5b6fdfaaa16549dd04", size = 473577, upload-time = "2025-10-14T15:05:22.306Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e1/f7/0a4467be0a56e80447c8529c9fce5b38eab4f513cb3d9bf82e7392a5696b/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3f7eb7da0eb23aa2ba036d4f616d46906013a68caf61b7fdbe42fc8b25132e77", size = 455425, upload-time = "2025-10-14T15:05:23.348Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8e/e0/82583485ea00137ddf69bc84a2db88bd92ab4a6e3c405e5fb878ead8d0e7/watchfiles-1.1.1-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:831a62658609f0e5c64178211c942ace999517f5770fe9436be4c2faeba0c0ef", size = 628826, upload-time = "2025-10-14T15:05:24.398Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/28/9a/a785356fccf9fae84c0cc90570f11702ae9571036fb25932f1242c82191c/watchfiles-1.1.1-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:f9a2ae5c91cecc9edd47e041a930490c31c3afb1f5e6d71de3dc671bfaca02bf", size = 622208, upload-time = "2025-10-14T15:05:25.45Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c3/f4/0872229324ef69b2c3edec35e84bd57a1289e7d3fe74588048ed8947a323/watchfiles-1.1.1-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:d1715143123baeeaeadec0528bb7441103979a1d5f6fd0e1f915383fea7ea6d5", size = 404315, upload-time = "2025-10-14T15:05:26.501Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7b/22/16d5331eaed1cb107b873f6ae1b69e9ced582fcf0c59a50cd84f403b1c32/watchfiles-1.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:39574d6370c4579d7f5d0ad940ce5b20db0e4117444e39b6d8f99db5676c52fd", size = 390869, upload-time = "2025-10-14T15:05:27.649Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b2/7e/5643bfff5acb6539b18483128fdc0ef2cccc94a5b8fbda130c823e8ed636/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7365b92c2e69ee952902e8f70f3ba6360d0d596d9299d55d7d386df84b6941fb", size = 449919, upload-time = "2025-10-14T15:05:28.701Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/51/2e/c410993ba5025a9f9357c376f48976ef0e1b1aefb73b97a5ae01a5972755/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bfff9740c69c0e4ed32416f013f3c45e2ae42ccedd1167ef2d805c000b6c71a5", size = 460845, upload-time = "2025-10-14T15:05:30.064Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8e/a4/2df3b404469122e8680f0fcd06079317e48db58a2da2950fb45020947734/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b27cf2eb1dda37b2089e3907d8ea92922b673c0c427886d4edc6b94d8dfe5db3", size = 489027, upload-time = "2025-10-14T15:05:31.064Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ea/84/4587ba5b1f267167ee715b7f66e6382cca6938e0a4b870adad93e44747e6/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:526e86aced14a65a5b0ec50827c745597c782ff46b571dbfe46192ab9e0b3c33", size = 595615, upload-time = "2025-10-14T15:05:32.074Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6a/0f/c6988c91d06e93cd0bb3d4a808bcf32375ca1904609835c3031799e3ecae/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:04e78dd0b6352db95507fd8cb46f39d185cf8c74e4cf1e4fbad1d3df96faf510", size = 474836, upload-time = "2025-10-14T15:05:33.209Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b4/36/ded8aebea91919485b7bbabbd14f5f359326cb5ec218cd67074d1e426d74/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5c85794a4cfa094714fb9c08d4a218375b2b95b8ed1666e8677c349906246c05", size = 455099, upload-time = "2025-10-14T15:05:34.189Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/98/e0/8c9bdba88af756a2fce230dd365fab2baf927ba42cd47521ee7498fd5211/watchfiles-1.1.1-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:74d5012b7630714b66be7b7b7a78855ef7ad58e8650c73afc4c076a1f480a8d6", size = 630626, upload-time = "2025-10-14T15:05:35.216Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2a/84/a95db05354bf2d19e438520d92a8ca475e578c647f78f53197f5a2f17aaf/watchfiles-1.1.1-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:8fbe85cb3201c7d380d3d0b90e63d520f15d6afe217165d7f98c9c649654db81", size = 622519, upload-time = "2025-10-14T15:05:36.259Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1d/ce/d8acdc8de545de995c339be67711e474c77d643555a9bb74a9334252bd55/watchfiles-1.1.1-cp314-cp314-win32.whl", hash = "sha256:3fa0b59c92278b5a7800d3ee7733da9d096d4aabcfabb9a928918bd276ef9b9b", size = 272078, upload-time = "2025-10-14T15:05:37.63Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c4/c9/a74487f72d0451524be827e8edec251da0cc1fcf111646a511ae752e1a3d/watchfiles-1.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:c2047d0b6cea13b3316bdbafbfa0c4228ae593d995030fda39089d36e64fc03a", size = 287664, upload-time = "2025-10-14T15:05:38.95Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/df/b8/8ac000702cdd496cdce998c6f4ee0ca1f15977bba51bdf07d872ebdfc34c/watchfiles-1.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:842178b126593addc05acf6fce960d28bc5fae7afbaa2c6c1b3a7b9460e5be02", size = 277154, upload-time = "2025-10-14T15:05:39.954Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/47/a8/e3af2184707c29f0f14b1963c0aace6529f9d1b8582d5b99f31bbf42f59e/watchfiles-1.1.1-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:88863fbbc1a7312972f1c511f202eb30866370ebb8493aef2812b9ff28156a21", size = 403820, upload-time = "2025-10-14T15:05:40.932Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c0/ec/e47e307c2f4bd75f9f9e8afbe3876679b18e1bcec449beca132a1c5ffb2d/watchfiles-1.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:55c7475190662e202c08c6c0f4d9e345a29367438cf8e8037f3155e10a88d5a5", size = 390510, upload-time = "2025-10-14T15:05:41.945Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d5/a0/ad235642118090f66e7b2f18fd5c42082418404a79205cdfca50b6309c13/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3f53fa183d53a1d7a8852277c92b967ae99c2d4dcee2bfacff8868e6e30b15f7", size = 448408, upload-time = "2025-10-14T15:05:43.385Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/df/85/97fa10fd5ff3332ae17e7e40e20784e419e28521549780869f1413742e9d/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6aae418a8b323732fa89721d86f39ec8f092fc2af67f4217a2b07fd3e93c6101", size = 458968, upload-time = "2025-10-14T15:05:44.404Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/47/c2/9059c2e8966ea5ce678166617a7f75ecba6164375f3b288e50a40dc6d489/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f096076119da54a6080e8920cbdaac3dbee667eb91dcc5e5b78840b87415bd44", size = 488096, upload-time = "2025-10-14T15:05:45.398Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/94/44/d90a9ec8ac309bc26db808a13e7bfc0e4e78b6fc051078a554e132e80160/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:00485f441d183717038ed2e887a7c868154f216877653121068107b227a2f64c", size = 596040, upload-time = "2025-10-14T15:05:46.502Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/95/68/4e3479b20ca305cfc561db3ed207a8a1c745ee32bf24f2026a129d0ddb6e/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a55f3e9e493158d7bfdb60a1165035f1cf7d320914e7b7ea83fe22c6023b58fc", size = 473847, upload-time = "2025-10-14T15:05:47.484Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4f/55/2af26693fd15165c4ff7857e38330e1b61ab8c37d15dc79118cdba115b7a/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8c91ed27800188c2ae96d16e3149f199d62f86c7af5f5f4d2c61a3ed8cd3666c", size = 455072, upload-time = "2025-10-14T15:05:48.928Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/66/1d/d0d200b10c9311ec25d2273f8aad8c3ef7cc7ea11808022501811208a750/watchfiles-1.1.1-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:311ff15a0bae3714ffb603e6ba6dbfba4065ab60865d15a6ec544133bdb21099", size = 629104, upload-time = "2025-10-14T15:05:49.908Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e3/bd/fa9bb053192491b3867ba07d2343d9f2252e00811567d30ae8d0f78136fe/watchfiles-1.1.1-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:a916a2932da8f8ab582f242c065f5c81bed3462849ca79ee357dd9551b0e9b01", size = 622112, upload-time = "2025-10-14T15:05:50.941Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d3/8e/e500f8b0b77be4ff753ac94dc06b33d8f0d839377fee1b78e8c8d8f031bf/watchfiles-1.1.1-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:db476ab59b6765134de1d4fe96a1a9c96ddf091683599be0f26147ea1b2e4b88", size = 408250, upload-time = "2025-10-14T15:06:10.264Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bd/95/615e72cd27b85b61eec764a5ca51bd94d40b5adea5ff47567d9ebc4d275a/watchfiles-1.1.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:89eef07eee5e9d1fda06e38822ad167a044153457e6fd997f8a858ab7564a336", size = 396117, upload-time = "2025-10-14T15:06:11.28Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c9/81/e7fe958ce8a7fb5c73cc9fb07f5aeaf755e6aa72498c57d760af760c91f8/watchfiles-1.1.1-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce19e06cbda693e9e7686358af9cd6f5d61312ab8b00488bc36f5aabbaf77e24", size = 450493, upload-time = "2025-10-14T15:06:12.321Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6e/d4/ed38dd3b1767193de971e694aa544356e63353c33a85d948166b5ff58b9e/watchfiles-1.1.1-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3e6f39af2eab0118338902798b5aa6664f46ff66bc0280de76fca67a7f262a49", size = 457546, upload-time = "2025-10-14T15:06:13.372Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wcwidth"
|
||||
version = "0.2.14"
|
||||
|
||||
@@ -3,9 +3,9 @@ import "@opal/components/tooltip.css";
|
||||
import {
|
||||
Interactive,
|
||||
type InteractiveBaseProps,
|
||||
type InteractiveContainerHeightVariant,
|
||||
type InteractiveContainerWidthVariant,
|
||||
} from "@opal/core";
|
||||
import type { SizeVariant } from "@opal/shared";
|
||||
import type { TooltipSide } from "@opal/components";
|
||||
import type { IconFunctionComponent } from "@opal/types";
|
||||
import * as TooltipPrimitive from "@radix-ui/react-tooltip";
|
||||
@@ -22,7 +22,7 @@ const iconVariants = {
|
||||
|
||||
function iconWrapper(
|
||||
Icon: IconFunctionComponent | undefined,
|
||||
size: InteractiveContainerHeightVariant,
|
||||
size: SizeVariant,
|
||||
includeSpacer: boolean
|
||||
) {
|
||||
const { padding: p, size: s } = iconVariants[size];
|
||||
@@ -75,8 +75,11 @@ type ButtonContentProps =
|
||||
|
||||
type ButtonProps = InteractiveBaseProps &
|
||||
ButtonContentProps & {
|
||||
/** Size preset — controls gap, text size, and Container height/rounding. */
|
||||
size?: InteractiveContainerHeightVariant;
|
||||
/**
|
||||
* Size preset — controls gap, text size, and Container height/rounding.
|
||||
* Uses the shared `SizeVariant` scale from `@opal/shared`.
|
||||
*/
|
||||
size?: SizeVariant;
|
||||
|
||||
/** HTML button type. When provided, Container renders a `<button>` element. */
|
||||
type?: "submit" | "button" | "reset";
|
||||
|
||||
@@ -4,7 +4,6 @@ export {
|
||||
type InteractiveBaseProps,
|
||||
type InteractiveBaseVariantProps,
|
||||
type InteractiveContainerProps,
|
||||
type InteractiveContainerHeightVariant,
|
||||
type InteractiveContainerWidthVariant,
|
||||
type InteractiveContainerRoundingVariant,
|
||||
} from "@opal/core/interactive/components";
|
||||
|
||||
@@ -3,6 +3,7 @@ import React from "react";
|
||||
import { Slot } from "@radix-ui/react-slot";
|
||||
import { cn } from "@opal/utils";
|
||||
import type { WithoutStyles } from "@opal/types";
|
||||
import { sizeVariants, type SizeVariant } from "@opal/shared";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
@@ -38,31 +39,6 @@ type InteractiveBaseVariantProps =
|
||||
selected?: never;
|
||||
};
|
||||
|
||||
/**
|
||||
* Height presets for `Interactive.Container`.
|
||||
*
|
||||
* - `"lg"` — 2.25rem (36px), suitable for most buttons/items
|
||||
* - `"md"` — 1.75rem (28px), standard compact size
|
||||
* - `"sm"` — 1.5rem (24px), for denser UIs
|
||||
* - `"xs"` — 1.25rem (20px), for inline elements
|
||||
* - `"2xs"` — 1rem (16px), for micro elements
|
||||
* - `"fit"` — Shrink-wraps to content height (`h-fit`), for variable-height layouts
|
||||
*/
|
||||
type InteractiveContainerHeightVariant =
|
||||
keyof typeof interactiveContainerSizeVariants;
|
||||
const interactiveContainerSizeVariants = {
|
||||
lg: { height: "h-[2.25rem]", minWidth: "min-w-[2.25rem]", padding: "p-2" },
|
||||
md: { height: "h-[1.75rem]", minWidth: "min-w-[1.75rem]", padding: "p-1" },
|
||||
sm: { height: "h-[1.5rem]", minWidth: "min-w-[1.5rem]", padding: "p-1" },
|
||||
xs: {
|
||||
height: "h-[1.25rem]",
|
||||
minWidth: "min-w-[1.25rem]",
|
||||
padding: "p-0.5",
|
||||
},
|
||||
"2xs": { height: "h-[1rem]", minWidth: "min-w-[1rem]", padding: "p-0.5" },
|
||||
fit: { height: "h-fit", minWidth: "", padding: "p-0" },
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Width presets for `Interactive.Container`.
|
||||
*
|
||||
@@ -353,18 +329,13 @@ interface InteractiveContainerProps
|
||||
roundingVariant?: InteractiveContainerRoundingVariant;
|
||||
|
||||
/**
|
||||
* Height preset controlling the container's vertical size.
|
||||
*
|
||||
* - `"lg"` — 2.25rem (36px), typical button/item height
|
||||
* - `"md"` — 1.75rem (28px), standard compact size
|
||||
* - `"sm"` — 1.5rem (24px), for denser UIs
|
||||
* - `"xs"` — 1.25rem (20px), for inline elements
|
||||
* - `"2xs"` — 1rem (16px), for micro elements
|
||||
* - `"fit"` — Shrink-wraps to content height (`h-fit`)
|
||||
* Size preset controlling the container's height, min-width, and padding.
|
||||
* Uses the shared `SizeVariant` scale from `@opal/shared`.
|
||||
*
|
||||
* @default "lg"
|
||||
* @see {@link SizeVariant} for the full list of presets.
|
||||
*/
|
||||
heightVariant?: InteractiveContainerHeightVariant;
|
||||
heightVariant?: SizeVariant;
|
||||
|
||||
/**
|
||||
* Width preset controlling the container's horizontal size.
|
||||
@@ -433,8 +404,7 @@ function InteractiveContainer({
|
||||
target?: string;
|
||||
rel?: string;
|
||||
};
|
||||
const { height, minWidth, padding } =
|
||||
interactiveContainerSizeVariants[heightVariant];
|
||||
const { height, minWidth, padding } = sizeVariants[heightVariant];
|
||||
const sharedProps = {
|
||||
...rest,
|
||||
className: cn(
|
||||
@@ -520,7 +490,6 @@ export {
|
||||
type InteractiveBaseVariantProps,
|
||||
type InteractiveBaseSelectVariantProps,
|
||||
type InteractiveContainerProps,
|
||||
type InteractiveContainerHeightVariant,
|
||||
type InteractiveContainerWidthVariant,
|
||||
type InteractiveContainerRoundingVariant,
|
||||
};
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@opal/components/buttons/Button/components";
|
||||
import type { InteractiveContainerHeightVariant } from "@opal/core";
|
||||
import type { SizeVariant } from "@opal/shared";
|
||||
import SvgEdit from "@opal/icons/edit";
|
||||
import type { IconFunctionComponent } from "@opal/types";
|
||||
import { cn } from "@opal/utils";
|
||||
@@ -25,8 +25,8 @@ interface HeadingPresetConfig {
|
||||
titleFont: string;
|
||||
/** Title line-height — also used as icon container min-height (CSS value). */
|
||||
lineHeight: string;
|
||||
/** Button `size` prop for the edit button. */
|
||||
editButtonSize: InteractiveContainerHeightVariant;
|
||||
/** Button `size` prop for the edit button. Uses the shared `SizeVariant` scale. */
|
||||
editButtonSize: SizeVariant;
|
||||
/** Tailwind padding class for the edit button container. */
|
||||
editButtonPadding: string;
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { Button } from "@opal/components/buttons/Button/components";
|
||||
import { Tag, type TagProps } from "@opal/components/Tag/components";
|
||||
import type { InteractiveContainerHeightVariant } from "@opal/core";
|
||||
import type { SizeVariant } from "@opal/shared";
|
||||
import SvgAlertCircle from "@opal/icons/alert-circle";
|
||||
import SvgAlertTriangle from "@opal/icons/alert-triangle";
|
||||
import SvgEdit from "@opal/icons/edit";
|
||||
@@ -26,7 +26,8 @@ interface LabelPresetConfig {
|
||||
titleFont: string;
|
||||
lineHeight: string;
|
||||
gap: string;
|
||||
editButtonSize: InteractiveContainerHeightVariant;
|
||||
/** Button `size` prop for the edit button. Uses the shared `SizeVariant` scale. */
|
||||
editButtonSize: SizeVariant;
|
||||
editButtonPadding: string;
|
||||
optionalFont: string;
|
||||
/** Aux icon size = lineHeight − 2 × p-0.5. */
|
||||
|
||||
99
web/lib/opal/src/layouts/ContentAction/README.md
Normal file
99
web/lib/opal/src/layouts/ContentAction/README.md
Normal file
@@ -0,0 +1,99 @@
|
||||
# ContentAction
|
||||
|
||||
**Import:** `import { ContentAction, type ContentActionProps } from "@opal/layouts";`
|
||||
|
||||
A row layout that pairs a [`Content`](../Content/README.md) block with optional right-side action children (buttons, badges, icons, etc.).
|
||||
|
||||
## Why ContentAction?
|
||||
|
||||
`Content` renders icon + title + description but has no slot for actions. When you need a settings row, card header, or list item with an action on the right you would typically wrap `Content` in a manual flex-row. `ContentAction` standardises that pattern and adds padding alignment with `Interactive.Container` and `Button` via the shared `SizeVariant` scale.
|
||||
|
||||
## Props
|
||||
|
||||
Inherits **all** props from [`Content`](../Content/README.md) (same discriminated-union API) plus:
|
||||
|
||||
| Prop | Type | Default | Description |
|
||||
|---|---|---|---|
|
||||
| `rightChildren` | `ReactNode` | `undefined` | Content rendered on the right side. Wrapper stretches to the full height of the row. |
|
||||
| `paddingVariant` | `SizeVariant` | `"lg"` | Padding preset applied around the `Content` area. Uses the shared size scale from `@opal/shared`. |
|
||||
|
||||
### `paddingVariant` reference
|
||||
|
||||
| Value | Padding class | Effective padding |
|
||||
|---|---|---|
|
||||
| `lg` | `p-2` | 0.5rem (8px) |
|
||||
| `md` | `p-1` | 0.25rem (4px) |
|
||||
| `sm` | `p-1` | 0.25rem (4px) |
|
||||
| `xs` | `p-0.5` | 0.125rem (2px) |
|
||||
| `2xs` | `p-0.5` | 0.125rem (2px) |
|
||||
| `fit` | `p-0` | 0 |
|
||||
|
||||
These values are identical to the padding applied by `Interactive.Container` at each size, so `ContentAction` labels naturally align with adjacent buttons of the same size.
|
||||
|
||||
## Layout Structure
|
||||
|
||||
```
|
||||
[ Content (flex-1, padded) ][ rightChildren (shrink-0, full height) ]
|
||||
```
|
||||
|
||||
- The outer wrapper is `flex flex-row items-stretch w-full`.
|
||||
- `Content` sits inside a `flex-1 min-w-0` div with padding from `paddingVariant`.
|
||||
- `rightChildren` is wrapped in `flex items-stretch shrink-0` so it stretches vertically.
|
||||
|
||||
## Usage Examples
|
||||
|
||||
### Settings row with an edit button
|
||||
|
||||
```tsx
|
||||
import { ContentAction } from "@opal/layouts";
|
||||
import { Button } from "@opal/components";
|
||||
import SvgSettings from "@opal/icons/settings";
|
||||
|
||||
<ContentAction
|
||||
icon={SvgSettings}
|
||||
title="OpenAI"
|
||||
description="GPT"
|
||||
sizePreset="main-content"
|
||||
variant="section"
|
||||
tag={{ title: "Default", color: "blue" }}
|
||||
paddingVariant="lg"
|
||||
rightChildren={
|
||||
<Button icon={SvgSettings} prominence="tertiary" onClick={handleEdit} />
|
||||
}
|
||||
/>
|
||||
```
|
||||
|
||||
### Card header with connect action
|
||||
|
||||
```tsx
|
||||
import { ContentAction } from "@opal/layouts";
|
||||
import { Button } from "@opal/components";
|
||||
import { SvgArrowExchange, SvgCloud } from "@opal/icons";
|
||||
|
||||
<ContentAction
|
||||
icon={SvgCloud}
|
||||
title="Google Cloud Vertex AI"
|
||||
description="Gemini"
|
||||
sizePreset="main-content"
|
||||
variant="section"
|
||||
paddingVariant="md"
|
||||
rightChildren={
|
||||
<Button rightIcon={SvgArrowExchange} prominence="tertiary">
|
||||
Connect
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
```
|
||||
|
||||
### No right children (padding-only wrapper)
|
||||
|
||||
```tsx
|
||||
<ContentAction
|
||||
title="Section Header"
|
||||
sizePreset="main-content"
|
||||
variant="section"
|
||||
paddingVariant="lg"
|
||||
/>
|
||||
```
|
||||
|
||||
When `rightChildren` is omitted the component renders only the padded `Content` — useful for alignment consistency when some rows have actions and others don't.
|
||||
75
web/lib/opal/src/layouts/ContentAction/components.tsx
Normal file
75
web/lib/opal/src/layouts/ContentAction/components.tsx
Normal file
@@ -0,0 +1,75 @@
|
||||
import { Content, type ContentProps } from "@opal/layouts/Content/components";
|
||||
import { sizeVariants, type SizeVariant } from "@opal/shared";
|
||||
import { cn } from "@opal/utils";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type ContentActionProps = ContentProps & {
|
||||
/** Content rendered on the right side, stretched to full height. */
|
||||
rightChildren?: React.ReactNode;
|
||||
|
||||
/**
|
||||
* Padding applied around the `Content` area.
|
||||
* Uses the shared `SizeVariant` scale from `@opal/shared`.
|
||||
*
|
||||
* @default "lg"
|
||||
* @see {@link SizeVariant} for the full list of presets.
|
||||
*/
|
||||
paddingVariant?: SizeVariant;
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ContentAction
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* A row layout that pairs a {@link Content} block with optional right-side
|
||||
* action children (e.g. buttons, badges).
|
||||
*
|
||||
* The `Content` area receives padding controlled by `paddingVariant`, using
|
||||
* the same size scale as `Interactive.Container` and `Button`. The
|
||||
* `rightChildren` wrapper stretches to the full height of the row.
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* import { ContentAction } from "@opal/layouts";
|
||||
* import { Button } from "@opal/components";
|
||||
* import SvgSettings from "@opal/icons/settings";
|
||||
*
|
||||
* <ContentAction
|
||||
* icon={SvgSettings}
|
||||
* title="OpenAI"
|
||||
* description="GPT"
|
||||
* sizePreset="main-content"
|
||||
* variant="section"
|
||||
* paddingVariant="lg"
|
||||
* rightChildren={<Button icon={SvgSettings} prominence="tertiary" />}
|
||||
* />
|
||||
* ```
|
||||
*/
|
||||
function ContentAction({
|
||||
rightChildren,
|
||||
paddingVariant = "lg",
|
||||
...contentProps
|
||||
}: ContentActionProps) {
|
||||
const { padding } = sizeVariants[paddingVariant];
|
||||
|
||||
return (
|
||||
<div className="flex flex-row items-stretch w-full">
|
||||
<div className={cn("flex-1 min-w-0", padding)}>
|
||||
<Content {...contentProps} />
|
||||
</div>
|
||||
{rightChildren && (
|
||||
<div className="flex items-stretch shrink-0">{rightChildren}</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Exports
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export { ContentAction, type ContentActionProps };
|
||||
93
web/lib/opal/src/layouts/README.md
Normal file
93
web/lib/opal/src/layouts/README.md
Normal file
@@ -0,0 +1,93 @@
|
||||
# @opal/layouts
|
||||
|
||||
**Import:** `import { Content, ContentAction } from "@opal/layouts";`
|
||||
|
||||
Layout primitives for composing icon + title + description rows. These components handle sizing, font selection, icon alignment, and optional inline editing — things that are tedious to get right by hand and easy to get wrong.
|
||||
|
||||
## Components
|
||||
|
||||
| Component | Description | Docs |
|
||||
|---|---|---|
|
||||
| [`Content`](./Content/README.md) | Icon + title + description row. Routes to an internal layout (`HeadingLayout`, `LabelLayout`, or `BodyLayout`) based on `sizePreset` and `variant`. | [Content README](./Content/README.md) |
|
||||
| [`ContentAction`](./ContentAction/README.md) | Wraps `Content` in a flex-row with an optional `rightChildren` slot for action buttons. Adds padding alignment via the shared `SizeVariant` scale. | [ContentAction README](./ContentAction/README.md) |
|
||||
|
||||
## Quick Start
|
||||
|
||||
```tsx
|
||||
import { Content, ContentAction } from "@opal/layouts";
|
||||
import { Button } from "@opal/components";
|
||||
import SvgSettings from "@opal/icons/settings";
|
||||
|
||||
// Simple heading
|
||||
<Content
|
||||
icon={SvgSettings}
|
||||
title="Account Settings"
|
||||
description="Manage your preferences"
|
||||
sizePreset="headline"
|
||||
variant="heading"
|
||||
/>
|
||||
|
||||
// Label with tag
|
||||
<Content
|
||||
icon={SvgSettings}
|
||||
title="OpenAI"
|
||||
description="GPT"
|
||||
sizePreset="main-content"
|
||||
variant="section"
|
||||
tag={{ title: "Default", color: "blue" }}
|
||||
/>
|
||||
|
||||
// Row with action button
|
||||
<ContentAction
|
||||
icon={SvgSettings}
|
||||
title="Provider Name"
|
||||
description="Some description"
|
||||
sizePreset="main-content"
|
||||
variant="section"
|
||||
paddingVariant="lg"
|
||||
rightChildren={
|
||||
<Button icon={SvgSettings} prominence="tertiary" />
|
||||
}
|
||||
/>
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
### Two-axis design (`Content`)
|
||||
|
||||
`Content` uses a two-axis system:
|
||||
|
||||
- **`sizePreset`** — controls sizing tokens (icon size, padding, gap, font, line-height).
|
||||
- **`variant`** — controls structural layout (icon placement, description rendering).
|
||||
|
||||
Valid preset/variant combinations are enforced at the type level via a discriminated union. See the [Content README](./Content/README.md) for the full matrix.
|
||||
|
||||
### Shared size scale (`ContentAction`)
|
||||
|
||||
`ContentAction` uses the same `SizeVariant` scale (`lg`, `md`, `sm`, `xs`, `2xs`, `fit`) defined in `@opal/shared` that powers `Interactive.Container` and `Button`. This ensures that padding on content rows aligns with adjacent interactive elements at the same size.
|
||||
|
||||
## Exports
|
||||
|
||||
From `@opal/layouts`:
|
||||
|
||||
```ts
|
||||
// Components
|
||||
Content
|
||||
ContentAction
|
||||
|
||||
// Types
|
||||
ContentProps
|
||||
ContentActionProps
|
||||
SizePreset
|
||||
ContentVariant
|
||||
```
|
||||
|
||||
## Internal Layout Components
|
||||
|
||||
These are not exported — `Content` routes to them automatically:
|
||||
|
||||
| Layout | Used when | File |
|
||||
|---|---|---|
|
||||
| `HeadingLayout` | `sizePreset` is `headline` or `section` | `Content/HeadingLayout.tsx` |
|
||||
| `LabelLayout` | `sizePreset` is `main-content`, `main-ui`, or `secondary` with `variant="section"` | `Content/LabelLayout.tsx` |
|
||||
| `BodyLayout` | `variant="body"` | `Content/BodyLayout.tsx` |
|
||||
@@ -5,3 +5,9 @@ export {
|
||||
type SizePreset,
|
||||
type ContentVariant,
|
||||
} from "@opal/layouts/Content/components";
|
||||
|
||||
/* ContentAction */
|
||||
export {
|
||||
ContentAction,
|
||||
type ContentActionProps,
|
||||
} from "@opal/layouts/ContentAction/components";
|
||||
|
||||
53
web/lib/opal/src/shared.ts
Normal file
53
web/lib/opal/src/shared.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
/**
|
||||
* @opal/shared — Shared constants and types for the opal design system.
|
||||
*
|
||||
* This module holds design tokens that are referenced by multiple opal
|
||||
* packages (core, components, layouts). Centralising them here avoids
|
||||
* circular imports and gives every consumer a single source of truth.
|
||||
*/
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Size Variants
|
||||
//
|
||||
// A named scale of size presets (lg → 2xs, plus fit) that map to Tailwind
|
||||
// utility classes for height, min-width, and padding.
|
||||
//
|
||||
// Consumers:
|
||||
// - Interactive.Container (height + min-width + padding)
|
||||
// - Button (icon sizing)
|
||||
// - ContentAction (padding only)
|
||||
// - Content (HeadingLayout / LabelLayout) (edit-button size)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Size-variant scale.
|
||||
*
|
||||
* Each entry maps a named preset to Tailwind utility classes for
|
||||
* `height`, `min-width`, and `padding`.
|
||||
*
|
||||
* | Key | Height | Padding |
|
||||
* |-------|---------------|----------|
|
||||
* | `lg` | 2.25rem (36px)| `p-2` |
|
||||
* | `md` | 1.75rem (28px)| `p-1` |
|
||||
* | `sm` | 1.5rem (24px) | `p-1` |
|
||||
* | `xs` | 1.25rem (20px)| `p-0.5` |
|
||||
* | `2xs` | 1rem (16px) | `p-0.5` |
|
||||
* | `fit` | h-fit | `p-0` |
|
||||
*/
|
||||
const sizeVariants = {
|
||||
lg: { height: "h-[2.25rem]", minWidth: "min-w-[2.25rem]", padding: "p-2" },
|
||||
md: { height: "h-[1.75rem]", minWidth: "min-w-[1.75rem]", padding: "p-1" },
|
||||
sm: { height: "h-[1.5rem]", minWidth: "min-w-[1.5rem]", padding: "p-1" },
|
||||
xs: {
|
||||
height: "h-[1.25rem]",
|
||||
minWidth: "min-w-[1.25rem]",
|
||||
padding: "p-0.5",
|
||||
},
|
||||
"2xs": { height: "h-[1rem]", minWidth: "min-w-[1rem]", padding: "p-0.5" },
|
||||
fit: { height: "h-fit", minWidth: "", padding: "p-0" },
|
||||
} as const;
|
||||
|
||||
/** Named size preset key. */
|
||||
type SizeVariant = keyof typeof sizeVariants;
|
||||
|
||||
export { sizeVariants, type SizeVariant };
|
||||
@@ -105,6 +105,18 @@ const nextConfig = {
|
||||
destination: "/app",
|
||||
permanent: true,
|
||||
},
|
||||
// NRF routes: Redirect to /nrf which doesn't require auth
|
||||
// (NRFPage handles unauthenticated users gracefully with a login modal)
|
||||
{
|
||||
source: "/app/nrf/side-panel",
|
||||
destination: "/nrf/side-panel",
|
||||
permanent: true,
|
||||
},
|
||||
{
|
||||
source: "/app/nrf",
|
||||
destination: "/nrf",
|
||||
permanent: true,
|
||||
},
|
||||
{
|
||||
source: "/chat/:path*",
|
||||
destination: "/app/:path*",
|
||||
|
||||
@@ -2,10 +2,10 @@
|
||||
|
||||
import Image from "next/image";
|
||||
import { useMemo, useState, useReducer } from "react";
|
||||
import { AdminPageTitle } from "@/components/admin/Title";
|
||||
import { InfoIcon } from "@/components/icons/icons";
|
||||
import Text from "@/refresh-components/texts/Text";
|
||||
import Separator from "@/refresh-components/Separator";
|
||||
import * as SettingsLayouts from "@/layouts/settings-layouts";
|
||||
import { Content } from "@opal/layouts";
|
||||
import useSWR from "swr";
|
||||
import { errorHandlingFetcher, FetchError } from "@/lib/fetcher";
|
||||
import { ThreeDotsLoader } from "@/components/Loading";
|
||||
@@ -22,7 +22,6 @@ import {
|
||||
SvgOnyxLogo,
|
||||
SvgX,
|
||||
} from "@opal/icons";
|
||||
|
||||
import { WebProviderSetupModal } from "@/app/admin/configuration/web-search/WebProviderSetupModal";
|
||||
import {
|
||||
SEARCH_PROVIDERS_URL,
|
||||
@@ -402,36 +401,40 @@ export default function Page() {
|
||||
: undefined);
|
||||
|
||||
return (
|
||||
<>
|
||||
<AdminPageTitle
|
||||
title="Web Search"
|
||||
<SettingsLayouts.Root>
|
||||
<SettingsLayouts.Header
|
||||
icon={SvgGlobe}
|
||||
includeDivider={false}
|
||||
title="Web Search"
|
||||
description="Search settings for external search across the internet."
|
||||
separator
|
||||
/>
|
||||
<Callout type="danger" title="Failed to load web search settings">
|
||||
{message}
|
||||
{detail && (
|
||||
<Text as="p" className="mt-2 text-text-03" mainContentBody text03>
|
||||
{detail}
|
||||
</Text>
|
||||
)}
|
||||
</Callout>
|
||||
</>
|
||||
<SettingsLayouts.Body>
|
||||
<Callout type="danger" title="Failed to load web search settings">
|
||||
{message}
|
||||
{detail && (
|
||||
<Text as="p" className="mt-2 text-text-03" mainContentBody text03>
|
||||
{detail}
|
||||
</Text>
|
||||
)}
|
||||
</Callout>
|
||||
</SettingsLayouts.Body>
|
||||
</SettingsLayouts.Root>
|
||||
);
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<>
|
||||
<AdminPageTitle
|
||||
title="Web Search"
|
||||
<SettingsLayouts.Root>
|
||||
<SettingsLayouts.Header
|
||||
icon={SvgGlobe}
|
||||
includeDivider={false}
|
||||
title="Web Search"
|
||||
description="Search settings for external search across the internet."
|
||||
separator
|
||||
/>
|
||||
<div className="mt-8">
|
||||
<SettingsLayouts.Body>
|
||||
<ThreeDotsLoader />
|
||||
</div>
|
||||
</>
|
||||
</SettingsLayouts.Body>
|
||||
</SettingsLayouts.Root>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -827,32 +830,22 @@ export default function Page() {
|
||||
|
||||
return (
|
||||
<>
|
||||
<>
|
||||
<AdminPageTitle icon={SvgGlobe} title="Web Search" />
|
||||
<div className="pt-4 pb-4">
|
||||
<Text as="p" className="text-text-dark">
|
||||
Search settings for external search across the internet.
|
||||
</Text>
|
||||
</div>
|
||||
<SettingsLayouts.Root>
|
||||
<SettingsLayouts.Header
|
||||
icon={SvgGlobe}
|
||||
title="Web Search"
|
||||
description="Search settings for external search across the internet."
|
||||
separator
|
||||
/>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="flex w-full flex-col gap-8 pb-6">
|
||||
<div className="flex w-full max-w-[960px] flex-col gap-3">
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<Text as="p" mainContentEmphasis text05>
|
||||
Search Engine
|
||||
</Text>
|
||||
<Text
|
||||
as="p"
|
||||
className="flex items-start gap-[2px] self-stretch text-text-03"
|
||||
secondaryBody
|
||||
text03
|
||||
>
|
||||
External search engine API used for web search result URLs,
|
||||
snippets, and metadata.
|
||||
</Text>
|
||||
</div>
|
||||
<SettingsLayouts.Body>
|
||||
<div className="flex w-full flex-col gap-3">
|
||||
<Content
|
||||
title="Search Engine"
|
||||
description="External search engine API used for web search result URLs, snippets, and metadata."
|
||||
sizePreset="main-content"
|
||||
variant="section"
|
||||
/>
|
||||
|
||||
{activationError && (
|
||||
<Callout type="danger" title="Unable to update default provider">
|
||||
@@ -974,14 +967,12 @@ export default function Page() {
|
||||
size: 16,
|
||||
isHighlighted,
|
||||
})}
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<Text as="p" mainUiAction text05>
|
||||
{label}
|
||||
</Text>
|
||||
<Text as="p" secondaryBody text03>
|
||||
{subtitle}
|
||||
</Text>
|
||||
</div>
|
||||
<Content
|
||||
title={label}
|
||||
description={subtitle}
|
||||
sizePreset="main-ui"
|
||||
variant="section"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
{isConfigured && (
|
||||
@@ -1045,20 +1036,13 @@ export default function Page() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex w-full max-w-[960px] flex-col gap-3">
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<Text as="p" mainContentEmphasis text05>
|
||||
Web Crawler
|
||||
</Text>
|
||||
<Text
|
||||
as="p"
|
||||
className="flex items-start gap-[2px] self-stretch text-text-03"
|
||||
secondaryBody
|
||||
text03
|
||||
>
|
||||
Used to read the full contents of search result pages.
|
||||
</Text>
|
||||
</div>
|
||||
<div className="flex w-full flex-col gap-3">
|
||||
<Content
|
||||
title="Web Crawler"
|
||||
description="Used to read the full contents of search result pages."
|
||||
sizePreset="main-content"
|
||||
variant="section"
|
||||
/>
|
||||
|
||||
{contentActivationError && (
|
||||
<Callout type="danger" title="Unable to update crawler">
|
||||
@@ -1173,14 +1157,12 @@ export default function Page() {
|
||||
size: 16,
|
||||
isHighlighted: isCurrentCrawler,
|
||||
})}
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<Text as="p" mainUiAction text05>
|
||||
{label}
|
||||
</Text>
|
||||
<Text as="p" secondaryBody text03>
|
||||
{subtitle}
|
||||
</Text>
|
||||
</div>
|
||||
<Content
|
||||
title={label}
|
||||
description={subtitle}
|
||||
sizePreset="main-ui"
|
||||
variant="section"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
{provider.provider_type !== "onyx_web_crawler" &&
|
||||
@@ -1244,8 +1226,8 @@ export default function Page() {
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
</SettingsLayouts.Body>
|
||||
</SettingsLayouts.Root>
|
||||
|
||||
<WebProviderSetupModal
|
||||
isOpen={selectedProviderType !== null}
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
import { unstable_noStore as noStore } from "next/cache";
|
||||
import { InstantSSRAutoRefresh } from "@/components/SSRAutoRefresh";
|
||||
import { cookies } from "next/headers";
|
||||
import NRFPage from "./NRFPage";
|
||||
import { NRFPreferencesProvider } from "@/components/context/NRFPreferencesContext";
|
||||
import * as AppLayouts from "@/layouts/app-layouts";
|
||||
|
||||
export default async function Page() {
|
||||
noStore();
|
||||
const requestCookies = await cookies();
|
||||
|
||||
return (
|
||||
<AppLayouts.Root>
|
||||
<InstantSSRAutoRefresh />
|
||||
<NRFPreferencesProvider>
|
||||
<NRFPage />
|
||||
</NRFPreferencesProvider>
|
||||
</AppLayouts.Root>
|
||||
);
|
||||
}
|
||||
@@ -126,6 +126,9 @@ export interface SendMessageParams {
|
||||
temperature?: number;
|
||||
// Origin of the message for telemetry tracking
|
||||
origin?: MessageOrigin;
|
||||
// Additional context injected into the LLM call but not stored/shown in chat.
|
||||
// Used e.g. by Chrome extension "Read this tab" feature.
|
||||
additionalContext?: string;
|
||||
}
|
||||
|
||||
export async function* sendMessage({
|
||||
@@ -142,6 +145,7 @@ export async function* sendMessage({
|
||||
modelVersion,
|
||||
temperature,
|
||||
origin,
|
||||
additionalContext,
|
||||
}: SendMessageParams): AsyncGenerator<PacketType, void, unknown> {
|
||||
// Build payload for new send-chat-message API
|
||||
const payload = {
|
||||
@@ -163,6 +167,7 @@ export async function* sendMessage({
|
||||
: null,
|
||||
// Default to "unknown" for consistency with backend; callers should set explicitly
|
||||
origin: origin ?? "unknown",
|
||||
additional_context: additionalContext ?? null,
|
||||
};
|
||||
|
||||
const body = JSON.stringify(payload);
|
||||
|
||||
@@ -75,11 +75,10 @@ export function useOnboardingModal(): OnboardingModalController {
|
||||
level: existingPersona?.level,
|
||||
};
|
||||
|
||||
// Check if user has completed initial onboarding
|
||||
// Check if user has completed initial onboarding (only role required, not name)
|
||||
const hasUserInfo = useMemo(() => {
|
||||
const existingPersona = getBuildUserPersona();
|
||||
return !!(user?.personalization?.name && existingPersona?.workArea);
|
||||
}, [user?.personalization?.name]);
|
||||
return !!getBuildUserPersona()?.workArea;
|
||||
}, [user]);
|
||||
|
||||
// Check if all providers are configured (skip LLM step entirely if so)
|
||||
const allProvidersConfigured = useMemo(
|
||||
@@ -94,7 +93,7 @@ export function useOnboardingModal(): OnboardingModalController {
|
||||
);
|
||||
|
||||
// Auto-open initial onboarding modal on first load
|
||||
// Shows if: user info is missing OR (admin AND no providers configured)
|
||||
// Shows if: user info (role) missing OR (admin AND no providers configured)
|
||||
useEffect(() => {
|
||||
if (hasInitialized || isLoadingLlm || !user) return;
|
||||
|
||||
|
||||
26
web/src/app/nrf/(main)/layout.tsx
Normal file
26
web/src/app/nrf/(main)/layout.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
import { unstable_noStore as noStore } from "next/cache";
|
||||
import AppSidebar from "@/sections/sidebar/AppSidebar";
|
||||
import { getCurrentUserSS } from "@/lib/userSS";
|
||||
|
||||
export interface LayoutProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* NRF Main (New Tab) Layout
|
||||
*
|
||||
* Shows the app sidebar when the user is authenticated.
|
||||
* This layout is NOT used by the side-panel route.
|
||||
*/
|
||||
export default async function Layout({ children }: LayoutProps) {
|
||||
noStore();
|
||||
|
||||
const user = await getCurrentUserSS();
|
||||
|
||||
return (
|
||||
<div className="flex flex-row w-full h-full">
|
||||
{user && <AppSidebar />}
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
31
web/src/app/nrf/(main)/page.tsx
Normal file
31
web/src/app/nrf/(main)/page.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
import { unstable_noStore as noStore } from "next/cache";
|
||||
import { InstantSSRAutoRefresh } from "@/components/SSRAutoRefresh";
|
||||
import NRFPage from "@/app/nrf/NRFPage";
|
||||
import { NRFPreferencesProvider } from "@/components/context/NRFPreferencesContext";
|
||||
import NRFChrome from "../NRFChrome";
|
||||
|
||||
/**
|
||||
* NRF (New Tab Page) Route - No Auth Required
|
||||
*
|
||||
* This route is placed outside /app/app/ to bypass the authentication
|
||||
* requirement in /app/app/layout.tsx. The NRFPage component handles
|
||||
* unauthenticated users gracefully by showing a login modal instead of
|
||||
* redirecting, which is better UX for the Chrome extension.
|
||||
*
|
||||
* Instead of AppLayouts.Root (which pulls in heavy Header state management),
|
||||
* we use NRFChrome — a lightweight overlay that renders only the search/chat
|
||||
* mode toggle and footer, floating transparently over NRFPage's background.
|
||||
*/
|
||||
export default async function Page() {
|
||||
noStore();
|
||||
|
||||
return (
|
||||
<div className="relative w-full h-full">
|
||||
<InstantSSRAutoRefresh />
|
||||
<NRFPreferencesProvider>
|
||||
<NRFPage />
|
||||
</NRFPreferencesProvider>
|
||||
<NRFChrome />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
149
web/src/app/nrf/NRFChrome.tsx
Normal file
149
web/src/app/nrf/NRFChrome.tsx
Normal file
@@ -0,0 +1,149 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { cn, ensureHrefProtocol, noProp } from "@/lib/utils";
|
||||
import type { Components } from "react-markdown";
|
||||
import Text from "@/refresh-components/texts/Text";
|
||||
import Popover from "@/refresh-components/Popover";
|
||||
import { OpenButton } from "@opal/components";
|
||||
import LineItem from "@/refresh-components/buttons/LineItem";
|
||||
import IconButton from "@/refresh-components/buttons/IconButton";
|
||||
import { SvgBubbleText, SvgSearchMenu, SvgSidebar } from "@opal/icons";
|
||||
import MinimalMarkdown from "@/components/chat/MinimalMarkdown";
|
||||
import { useSettingsContext } from "@/providers/SettingsProvider";
|
||||
import { AppMode, useAppMode } from "@/providers/AppModeProvider";
|
||||
import useAppFocus from "@/hooks/useAppFocus";
|
||||
import { useQueryController } from "@/providers/QueryControllerProvider";
|
||||
import { usePaidEnterpriseFeaturesEnabled } from "@/components/settings/usePaidEnterpriseFeaturesEnabled";
|
||||
import { useAppSidebarContext } from "@/providers/AppSidebarProvider";
|
||||
import useScreenSize from "@/hooks/useScreenSize";
|
||||
|
||||
const footerMarkdownComponents = {
|
||||
p: ({ children }: { children?: React.ReactNode }) => (
|
||||
<Text as="p" text03 secondaryAction className="!my-0 text-center">
|
||||
{children}
|
||||
</Text>
|
||||
),
|
||||
a: ({
|
||||
href,
|
||||
className,
|
||||
children,
|
||||
...rest
|
||||
}: React.AnchorHTMLAttributes<HTMLAnchorElement>) => {
|
||||
const fullHref = ensureHrefProtocol(href);
|
||||
return (
|
||||
<a
|
||||
href={fullHref}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
{...rest}
|
||||
className={cn(className, "underline underline-offset-2")}
|
||||
>
|
||||
<Text text03 secondaryAction>
|
||||
{children}
|
||||
</Text>
|
||||
</a>
|
||||
);
|
||||
},
|
||||
} satisfies Partial<Components>;
|
||||
|
||||
/**
|
||||
* Lightweight chrome overlay for the NRF page.
|
||||
*
|
||||
* Renders only the search/chat mode toggle (top-left) and footer (bottom),
|
||||
* absolutely positioned so they float transparently over NRFPage's own
|
||||
* background. This avoids pulling in the full AppLayouts.Root Header which
|
||||
* carries heavy state management (share/delete/move modals) that the
|
||||
* extension doesn't need.
|
||||
*/
|
||||
export default function NRFChrome() {
|
||||
const isPaidEnterpriseFeaturesEnabled = usePaidEnterpriseFeaturesEnabled();
|
||||
const { appMode, setAppMode } = useAppMode();
|
||||
const settings = useSettingsContext();
|
||||
const { isMobile } = useScreenSize();
|
||||
const { setFolded } = useAppSidebarContext();
|
||||
const appFocus = useAppFocus();
|
||||
const { classification } = useQueryController();
|
||||
const [modePopoverOpen, setModePopoverOpen] = useState(false);
|
||||
|
||||
const effectiveMode: AppMode = appFocus.isNewSession() ? appMode : "chat";
|
||||
|
||||
const customFooterContent =
|
||||
settings?.enterpriseSettings?.custom_lower_disclaimer_content ||
|
||||
`[Onyx ${
|
||||
settings?.webVersion || "dev"
|
||||
}](https://www.onyx.app/) - Open Source AI Platform`;
|
||||
|
||||
const showModeToggle =
|
||||
isPaidEnterpriseFeaturesEnabled &&
|
||||
settings.isSearchModeAvailable &&
|
||||
appFocus.isNewSession() &&
|
||||
!classification;
|
||||
|
||||
const showHeader = isMobile || showModeToggle;
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Header chrome — top-left, mirrors position of settings button at top-right */}
|
||||
{showHeader && (
|
||||
<div className="absolute top-0 left-0 p-4 z-10 flex flex-row items-center gap-2">
|
||||
{isMobile && (
|
||||
<IconButton
|
||||
icon={SvgSidebar}
|
||||
onClick={() => setFolded(false)}
|
||||
internal
|
||||
/>
|
||||
)}
|
||||
{showModeToggle && (
|
||||
<Popover open={modePopoverOpen} onOpenChange={setModePopoverOpen}>
|
||||
<Popover.Trigger asChild>
|
||||
<OpenButton
|
||||
icon={
|
||||
effectiveMode === "search" ? SvgSearchMenu : SvgBubbleText
|
||||
}
|
||||
>
|
||||
{effectiveMode === "search" ? "Search" : "Chat"}
|
||||
</OpenButton>
|
||||
</Popover.Trigger>
|
||||
<Popover.Content align="start" width="lg">
|
||||
<Popover.Menu>
|
||||
<LineItem
|
||||
icon={SvgSearchMenu}
|
||||
selected={effectiveMode === "search"}
|
||||
description="Quick search for documents"
|
||||
onClick={noProp(() => {
|
||||
setAppMode("search");
|
||||
setModePopoverOpen(false);
|
||||
})}
|
||||
>
|
||||
Search
|
||||
</LineItem>
|
||||
<LineItem
|
||||
icon={SvgBubbleText}
|
||||
selected={effectiveMode === "chat"}
|
||||
description="Conversation and research"
|
||||
onClick={noProp(() => {
|
||||
setAppMode("chat");
|
||||
setModePopoverOpen(false);
|
||||
})}
|
||||
>
|
||||
Chat
|
||||
</LineItem>
|
||||
</Popover.Menu>
|
||||
</Popover.Content>
|
||||
</Popover>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Footer — bottom-center, transparent background */}
|
||||
<footer className="absolute bottom-0 left-0 w-full z-10 flex flex-row justify-center items-center gap-2 px-2 pb-2 pointer-events-auto">
|
||||
<MinimalMarkdown
|
||||
content={customFooterContent}
|
||||
className="max-w-full text-center"
|
||||
components={footerMarkdownComponents}
|
||||
/>
|
||||
</footer>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -11,8 +11,10 @@ import IconButton from "@/refresh-components/buttons/IconButton";
|
||||
import Modal from "@/refresh-components/Modal";
|
||||
import { useFilters, useLlmManager } from "@/lib/hooks";
|
||||
import Dropzone from "react-dropzone";
|
||||
import { useSendMessageToParent } from "@/lib/extension/utils";
|
||||
import { useSendMessageToParent, getPanelOrigin } from "@/lib/extension/utils";
|
||||
import { useNRFPreferences } from "@/components/context/NRFPreferencesContext";
|
||||
import SidePanelHeader from "@/app/nrf/side-panel/SidePanelHeader";
|
||||
import { CHROME_MESSAGE } from "@/lib/extension/constants";
|
||||
import { SettingsPanel } from "@/app/components/nrf/SettingsPanel";
|
||||
import LoginPage from "@/app/auth/login/LoginPage";
|
||||
import { sendSetDefaultNewTabMessage } from "@/lib/extension/utils";
|
||||
@@ -33,16 +35,9 @@ import ChatScrollContainer from "@/sections/chat/ChatScrollContainer";
|
||||
import WelcomeMessage from "@/app/app/components/WelcomeMessage";
|
||||
import useChatSessions from "@/hooks/useChatSessions";
|
||||
import { cn } from "@/lib/utils";
|
||||
import Logo from "@/refresh-components/Logo";
|
||||
import Spacer from "@/refresh-components/Spacer";
|
||||
import { useAppSidebarContext } from "@/providers/AppSidebarProvider";
|
||||
import { DEFAULT_CONTEXT_TOKENS } from "@/lib/constants";
|
||||
import {
|
||||
SvgUser,
|
||||
SvgMenu,
|
||||
SvgExternalLink,
|
||||
SvgAlertTriangle,
|
||||
} from "@opal/icons";
|
||||
import { SvgUser, SvgMenu, SvgAlertTriangle } from "@opal/icons";
|
||||
import { useAppBackground } from "@/providers/AppBackgroundProvider";
|
||||
import { MinimalOnyxDocument } from "@/lib/search/interfaces";
|
||||
import DocumentsSidebar from "@/sections/document-sidebar/DocumentsSidebar";
|
||||
@@ -67,14 +62,6 @@ export default function NRFPage({ isSidePanel = false }: NRFPageProps) {
|
||||
const searchParams = useSearchParams();
|
||||
const filterManager = useFilters();
|
||||
const { user, authTypeMetadata } = useUser();
|
||||
const { setFolded } = useAppSidebarContext();
|
||||
|
||||
// Hide sidebar when in side panel mode
|
||||
useEffect(() => {
|
||||
if (isSidePanel) {
|
||||
setFolded(true);
|
||||
}
|
||||
}, [isSidePanel, setFolded]);
|
||||
|
||||
// Chat sessions
|
||||
const { refreshChatSessions } = useChatSessions();
|
||||
@@ -129,6 +116,8 @@ export default function NRFPage({ isSidePanel = false }: NRFPageProps) {
|
||||
// State
|
||||
const [message, setMessage] = useState("");
|
||||
const [settingsOpen, setSettingsOpen] = useState<boolean>(false);
|
||||
const [tabReadingEnabled, setTabReadingEnabled] = useState<boolean>(false);
|
||||
const [currentTabUrl, setCurrentTabUrl] = useState<string | null>(null);
|
||||
const [presentingDocument, setPresentingDocument] =
|
||||
useState<MinimalOnyxDocument | null>(null);
|
||||
|
||||
@@ -137,6 +126,12 @@ export default function NRFPage({ isSidePanel = false }: NRFPageProps) {
|
||||
const updateCurrentDocumentSidebarVisible = useChatSessionStore(
|
||||
(state) => state.updateCurrentDocumentSidebarVisible
|
||||
);
|
||||
const setCurrentSession = useChatSessionStore(
|
||||
(state) => state.setCurrentSession
|
||||
);
|
||||
const currentSessionId = useChatSessionStore(
|
||||
(state) => state.currentSessionId
|
||||
);
|
||||
|
||||
// Memoized callback for closing document sidebar
|
||||
const handleDocumentSidebarClose = useCallback(() => {
|
||||
@@ -201,6 +196,26 @@ export default function NRFPage({ isSidePanel = false }: NRFPageProps) {
|
||||
|
||||
useSendMessageToParent();
|
||||
|
||||
// Listen for tab URL updates from the Chrome extension
|
||||
useEffect(() => {
|
||||
if (!isSidePanel) return;
|
||||
|
||||
function handleExtensionMessage(event: MessageEvent) {
|
||||
// Only trust messages from the Chrome extension parent.
|
||||
// Checking the origin (chrome-extension://) prevents a non-extension
|
||||
// page that embeds NRFPage as an iframe from injecting arbitrary URLs
|
||||
// into the prompt context via TAB_URL_UPDATED.
|
||||
if (!event.origin.startsWith("chrome-extension://")) return;
|
||||
if (event.source !== window.parent) return;
|
||||
if (event.data?.type === CHROME_MESSAGE.TAB_URL_UPDATED) {
|
||||
setCurrentTabUrl(event.data.url as string);
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener("message", handleExtensionMessage);
|
||||
return () => window.removeEventListener("message", handleExtensionMessage);
|
||||
}, [isSidePanel]);
|
||||
|
||||
const toggleSettings = () => {
|
||||
setSettingsOpen((prev) => !prev);
|
||||
};
|
||||
@@ -268,23 +283,16 @@ export default function NRFPage({ isSidePanel = false }: NRFPageProps) {
|
||||
[handleMessageSpecificFileUpload]
|
||||
);
|
||||
|
||||
// Handler for chat submission (used by query controller)
|
||||
const onChat = useCallback(
|
||||
(chatMessage: string) => {
|
||||
resetInputBar();
|
||||
onSubmit({
|
||||
message: chatMessage,
|
||||
currentMessageFiles: currentMessageFiles,
|
||||
deepResearch: deepResearchEnabled,
|
||||
});
|
||||
},
|
||||
[onSubmit, currentMessageFiles, deepResearchEnabled, resetInputBar]
|
||||
);
|
||||
|
||||
// Handle submit from AppInputBar - routes through query controller for search/chat classification
|
||||
const handleChatInputSubmit = useCallback(
|
||||
async (submittedMessage: string) => {
|
||||
if (!submittedMessage.trim()) return;
|
||||
|
||||
const additionalContext =
|
||||
tabReadingEnabled && currentTabUrl
|
||||
? `The user is currently viewing: ${currentTabUrl}. Use the open_url tool to read this page and use its content as additional context for your response.`
|
||||
: undefined;
|
||||
|
||||
// If we already have messages (chat session started), always use chat mode
|
||||
// (matches AppPage behavior where existing sessions bypass classification)
|
||||
if (hasMessages) {
|
||||
@@ -293,9 +301,22 @@ export default function NRFPage({ isSidePanel = false }: NRFPageProps) {
|
||||
message: submittedMessage,
|
||||
currentMessageFiles: currentMessageFiles,
|
||||
deepResearch: deepResearchEnabled,
|
||||
additionalContext,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Build an onChat closure that captures additionalContext for this submission
|
||||
const onChat = (chatMessage: string) => {
|
||||
resetInputBar();
|
||||
onSubmit({
|
||||
message: chatMessage,
|
||||
currentMessageFiles: currentMessageFiles,
|
||||
deepResearch: deepResearchEnabled,
|
||||
additionalContext,
|
||||
});
|
||||
};
|
||||
|
||||
// Use submitQuery which will classify the query and either:
|
||||
// - Route to search (sets classification to "search" and shows SearchUI)
|
||||
// - Route to chat (calls onChat callback)
|
||||
@@ -308,7 +329,8 @@ export default function NRFPage({ isSidePanel = false }: NRFPageProps) {
|
||||
deepResearchEnabled,
|
||||
resetInputBar,
|
||||
submitQuery,
|
||||
onChat,
|
||||
tabReadingEnabled,
|
||||
currentTabUrl,
|
||||
]
|
||||
);
|
||||
|
||||
@@ -331,9 +353,34 @@ export default function NRFPage({ isSidePanel = false }: NRFPageProps) {
|
||||
});
|
||||
}, [messageHistory, onSubmit, currentMessageFiles, deepResearchEnabled]);
|
||||
|
||||
const handleOpenInOnyx = () => {
|
||||
window.open(`${window.location.origin}/app`, "_blank");
|
||||
};
|
||||
// Start a new chat session in the side panel
|
||||
const handleNewChat = useCallback(() => {
|
||||
setCurrentSession(null);
|
||||
setTabReadingEnabled(false);
|
||||
setCurrentTabUrl(null);
|
||||
resetInputBar();
|
||||
// Notify the service worker so it stops sending tab URL updates
|
||||
window.parent.postMessage(
|
||||
{ type: CHROME_MESSAGE.TAB_READING_DISABLED },
|
||||
getPanelOrigin()
|
||||
);
|
||||
}, [setCurrentSession, resetInputBar]);
|
||||
|
||||
const handleToggleTabReading = useCallback(() => {
|
||||
const next = !tabReadingEnabled;
|
||||
setTabReadingEnabled(next);
|
||||
if (!next) {
|
||||
setCurrentTabUrl(null);
|
||||
}
|
||||
window.parent.postMessage(
|
||||
{
|
||||
type: next
|
||||
? CHROME_MESSAGE.TAB_READING_ENABLED
|
||||
: CHROME_MESSAGE.TAB_READING_DISABLED,
|
||||
},
|
||||
getPanelOrigin()
|
||||
);
|
||||
}, [tabReadingEnabled]);
|
||||
|
||||
// Handle search result document click
|
||||
const handleSearchDocumentClick = useCallback(
|
||||
@@ -362,18 +409,10 @@ export default function NRFPage({ isSidePanel = false }: NRFPageProps) {
|
||||
|
||||
{/* Side panel header */}
|
||||
{isSidePanel && (
|
||||
<header className="flex items-center justify-between px-4 py-3 border-b border-border-01 bg-background">
|
||||
<div className="flex items-center gap-2">
|
||||
<Logo />
|
||||
</div>
|
||||
<Button
|
||||
tertiary
|
||||
rightIcon={SvgExternalLink}
|
||||
onClick={handleOpenInOnyx}
|
||||
>
|
||||
Open in Onyx
|
||||
</Button>
|
||||
</header>
|
||||
<SidePanelHeader
|
||||
onNewChat={handleNewChat}
|
||||
chatSessionId={currentSessionId}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Settings button */}
|
||||
@@ -382,9 +421,8 @@ export default function NRFPage({ isSidePanel = false }: NRFPageProps) {
|
||||
<IconButton
|
||||
icon={SvgMenu}
|
||||
onClick={toggleSettings}
|
||||
tertiary
|
||||
secondary
|
||||
tooltip="Open settings"
|
||||
className="bg-mask-02 backdrop-blur-[12px] rounded-full shadow-01 hover:bg-mask-03"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
@@ -393,18 +431,22 @@ export default function NRFPage({ isSidePanel = false }: NRFPageProps) {
|
||||
{({ getRootProps }) => (
|
||||
<div
|
||||
{...getRootProps()}
|
||||
className="h-full w-full flex flex-col items-center outline-none"
|
||||
className={cn(
|
||||
"flex-1 min-h-0 w-full flex flex-col items-center outline-none",
|
||||
isSidePanel && "px-3"
|
||||
)}
|
||||
>
|
||||
{/* Chat area with messages */}
|
||||
{hasMessages && resolvedAssistant && (
|
||||
<>
|
||||
{/* Fake header */}
|
||||
<Spacer rem={2} />
|
||||
{/* Fake header - pushes content below absolute settings button (non-side-panel only) */}
|
||||
{!isSidePanel && <Spacer rem={2} />}
|
||||
<ChatScrollContainer
|
||||
sessionId="nrf-session"
|
||||
anchorSelector={anchorSelector}
|
||||
autoScroll={autoScrollEnabled}
|
||||
isStreaming={isStreaming}
|
||||
hideScrollbar={isSidePanel}
|
||||
>
|
||||
<ChatUI
|
||||
liveAssistant={resolvedAssistant}
|
||||
@@ -433,7 +475,11 @@ export default function NRFPage({ isSidePanel = false }: NRFPageProps) {
|
||||
{/* AppInputBar container - in normal flex flow like AppPage */}
|
||||
<div
|
||||
ref={inputRef}
|
||||
className="w-full max-w-[var(--app-page-main-content-width)] flex flex-col px-4"
|
||||
className={cn(
|
||||
"w-full flex flex-col",
|
||||
!isSidePanel &&
|
||||
"max-w-[var(--app-page-main-content-width)] px-4"
|
||||
)}
|
||||
>
|
||||
<AppInputBar
|
||||
ref={chatInputBarRef}
|
||||
@@ -456,8 +502,13 @@ export default function NRFPage({ isSidePanel = false }: NRFPageProps) {
|
||||
disabled={
|
||||
!llmManager.isLoadingProviders && !llmManager.hasAnyProvider
|
||||
}
|
||||
{...(isSidePanel && {
|
||||
tabReadingEnabled,
|
||||
currentTabUrl,
|
||||
onToggleTabReading: handleToggleTabReading,
|
||||
})}
|
||||
/>
|
||||
<Spacer rem={0.5} />
|
||||
<Spacer rem={isSidePanel ? 1 : 0.5} />
|
||||
</div>
|
||||
|
||||
{/* Search results - shown when query is classified as search */}
|
||||
15
web/src/app/nrf/layout.tsx
Normal file
15
web/src/app/nrf/layout.tsx
Normal file
@@ -0,0 +1,15 @@
|
||||
import { ProjectsProvider } from "@/providers/ProjectsContext";
|
||||
|
||||
export interface LayoutProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* NRF Root Layout - Shared by all NRF routes
|
||||
*
|
||||
* Provides ProjectsProvider (needed by NRFPage) without auth redirect.
|
||||
* Sidebar and chrome are handled by sub-layouts / individual pages.
|
||||
*/
|
||||
export default function Layout({ children }: LayoutProps) {
|
||||
return <ProjectsProvider>{children}</ProjectsProvider>;
|
||||
}
|
||||
40
web/src/app/nrf/side-panel/SidePanelHeader.tsx
Normal file
40
web/src/app/nrf/side-panel/SidePanelHeader.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
"use client";
|
||||
|
||||
import Logo from "@/refresh-components/Logo";
|
||||
import IconButton from "@/refresh-components/buttons/IconButton";
|
||||
import { SvgEditBig, SvgExternalLink } from "@opal/icons";
|
||||
|
||||
interface SidePanelHeaderProps {
|
||||
onNewChat: () => void;
|
||||
chatSessionId?: string | null;
|
||||
}
|
||||
|
||||
export default function SidePanelHeader({
|
||||
onNewChat,
|
||||
chatSessionId,
|
||||
}: SidePanelHeaderProps) {
|
||||
const handleOpenInOnyx = () => {
|
||||
const path = chatSessionId ? `/app?chatId=${chatSessionId}` : "/app";
|
||||
window.open(`${window.location.origin}${path}`, "_blank");
|
||||
};
|
||||
|
||||
return (
|
||||
<header className="flex items-center justify-between px-4 py-3 border-b border-border-01 bg-background">
|
||||
<Logo />
|
||||
<div className="flex items-center gap-1">
|
||||
<IconButton
|
||||
icon={SvgEditBig}
|
||||
onClick={onNewChat}
|
||||
tertiary
|
||||
tooltip="New chat"
|
||||
/>
|
||||
<IconButton
|
||||
icon={SvgExternalLink}
|
||||
onClick={handleOpenInOnyx}
|
||||
tertiary
|
||||
tooltip="Open in Onyx"
|
||||
/>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
@@ -1,18 +1,24 @@
|
||||
import { unstable_noStore as noStore } from "next/cache";
|
||||
import { InstantSSRAutoRefresh } from "@/components/SSRAutoRefresh";
|
||||
import NRFPage from "@/app/app/nrf/NRFPage";
|
||||
import NRFPage from "@/app/nrf/NRFPage";
|
||||
import { NRFPreferencesProvider } from "@/components/context/NRFPreferencesContext";
|
||||
import * as AppLayouts from "@/layouts/app-layouts";
|
||||
|
||||
/**
|
||||
* NRF Side Panel Route - No Auth Required
|
||||
*
|
||||
* Side panel variant — no NRFChrome overlay needed since the side panel
|
||||
* has its own header (logo + "Open in Onyx" button) and doesn't show
|
||||
* the mode toggle or footer.
|
||||
*/
|
||||
export default async function Page() {
|
||||
noStore();
|
||||
|
||||
return (
|
||||
<AppLayouts.Root>
|
||||
<>
|
||||
<InstantSSRAutoRefresh />
|
||||
<NRFPreferencesProvider>
|
||||
<NRFPage isSidePanel />
|
||||
</NRFPreferencesProvider>
|
||||
</AppLayouts.Root>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -49,7 +49,7 @@ export async function searchDocuments(
|
||||
const request: SendSearchQueryRequest = {
|
||||
search_query: query,
|
||||
filters: options?.filters,
|
||||
num_hits: options?.numHits ?? 50,
|
||||
num_hits: options?.numHits ?? 30,
|
||||
include_content: options?.includeContent ?? false,
|
||||
stream: false,
|
||||
};
|
||||
|
||||
@@ -67,7 +67,7 @@ export function QueryControllerProvider({
|
||||
searchQuery,
|
||||
{
|
||||
filters,
|
||||
numHits: 50,
|
||||
numHits: 30,
|
||||
includeContent: false,
|
||||
signal: controller.signal,
|
||||
}
|
||||
|
||||
@@ -89,6 +89,8 @@ export interface OnSubmitProps {
|
||||
isSeededChat?: boolean;
|
||||
modelOverride?: LlmDescriptor;
|
||||
regenerationRequest?: RegenerationRequest | null;
|
||||
// Additional context injected into the LLM call but not stored/shown in chat.
|
||||
additionalContext?: string;
|
||||
}
|
||||
|
||||
interface RegenerationRequest {
|
||||
@@ -368,6 +370,7 @@ export default function useChatController({
|
||||
isSeededChat,
|
||||
modelOverride,
|
||||
regenerationRequest,
|
||||
additionalContext,
|
||||
}: OnSubmitProps) => {
|
||||
const projectId = params(SEARCH_PARAM_NAMES.PROJECT_ID);
|
||||
{
|
||||
@@ -725,6 +728,7 @@ export default function useChatController({
|
||||
: undefined,
|
||||
forcedToolId: effectiveForcedToolId,
|
||||
origin: messageOrigin,
|
||||
additionalContext,
|
||||
});
|
||||
|
||||
const delay = (ms: number) => {
|
||||
|
||||
@@ -36,10 +36,11 @@
|
||||
import BackButton from "@/refresh-components/buttons/BackButton";
|
||||
import { cn } from "@/lib/utils";
|
||||
import Separator from "@/refresh-components/Separator";
|
||||
import Text from "@/refresh-components/texts/Text";
|
||||
import { WithoutStyles } from "@/types";
|
||||
import { IconProps } from "@opal/types";
|
||||
import { IconFunctionComponent } from "@opal/types";
|
||||
import { HtmlHTMLAttributes, useEffect, useRef, useState } from "react";
|
||||
import { Content } from "@opal/layouts";
|
||||
import Spacer from "@/refresh-components/Spacer";
|
||||
|
||||
const widthClasses = {
|
||||
md: "w-[min(50rem,100%)]",
|
||||
@@ -163,7 +164,7 @@ function SettingsRoot({ width = "md", ...props }: SettingsRootProps) {
|
||||
* ```
|
||||
*/
|
||||
export interface SettingsHeaderProps {
|
||||
icon: React.FunctionComponent<IconProps>;
|
||||
icon: IconFunctionComponent;
|
||||
title: string;
|
||||
description?: string;
|
||||
children?: React.ReactNode;
|
||||
@@ -184,7 +185,10 @@ function SettingsHeader({
|
||||
}: SettingsHeaderProps) {
|
||||
const [showShadow, setShowShadow] = useState(false);
|
||||
const headerRef = useRef<HTMLDivElement>(null);
|
||||
const isSticky = !!rightChildren; //headers with actions are always sticky, others are not
|
||||
|
||||
// # NOTE (@Subash-Mohan)
|
||||
// Headers with actions are always sticky, others are not.
|
||||
const isSticky = !!rightChildren;
|
||||
|
||||
useEffect(() => {
|
||||
if (!isSticky) return;
|
||||
@@ -221,34 +225,35 @@ function SettingsHeader({
|
||||
<BackButton behaviorOverride={onBack} />
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className={cn("flex flex-col gap-6 px-4", backButton ? "pt-2" : "pt-4")}
|
||||
>
|
||||
<div className="flex flex-col">
|
||||
<div className="flex flex-row justify-between items-center gap-4">
|
||||
<Icon className="stroke-text-04 h-[1.75rem] w-[1.75rem]" />
|
||||
{rightChildren}
|
||||
</div>
|
||||
<div className={cn("flex flex-col", separator ? "pb-6" : "pb-2")}>
|
||||
<div aria-label="admin-page-title">
|
||||
<Text as="p" headingH2>
|
||||
{title}
|
||||
</Text>
|
||||
</div>
|
||||
{description && (
|
||||
<Text secondaryBody text03>
|
||||
{description}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
<Spacer vertical rem={1} />
|
||||
|
||||
<div className="flex flex-col gap-6 px-4">
|
||||
<div className="flex w-full justify-between">
|
||||
<div aria-label="admin-page-title">
|
||||
<Content
|
||||
icon={Icon}
|
||||
title={title}
|
||||
description={description}
|
||||
sizePreset="headline"
|
||||
variant="heading"
|
||||
/>
|
||||
</div>
|
||||
{rightChildren}
|
||||
</div>
|
||||
|
||||
{children}
|
||||
</div>
|
||||
{separator && (
|
||||
|
||||
{separator ? (
|
||||
<>
|
||||
<Spacer vertical rem={1.5} />
|
||||
<Separator noPadding className="px-4" />
|
||||
</>
|
||||
) : (
|
||||
<Spacer vertical rem={0.5} />
|
||||
)}
|
||||
|
||||
{isSticky && (
|
||||
<div
|
||||
className={cn(
|
||||
|
||||
@@ -17,6 +17,9 @@ export const CHROME_MESSAGE = {
|
||||
LOAD_NEW_CHAT_PAGE: "LOAD_NEW_CHAT_PAGE",
|
||||
LOAD_NEW_PAGE: "LOAD_NEW_PAGE",
|
||||
AUTH_REQUIRED: "AUTH_REQUIRED",
|
||||
TAB_READING_ENABLED: "TAB_READING_ENABLED",
|
||||
TAB_READING_DISABLED: "TAB_READING_DISABLED",
|
||||
TAB_URL_UPDATED: "TAB_URL_UPDATED",
|
||||
};
|
||||
|
||||
export const SUBMIT_MESSAGE_TYPES = {
|
||||
|
||||
@@ -3,6 +3,14 @@ import { CHROME_MESSAGE } from "./constants";
|
||||
|
||||
export type ExtensionContext = "new_tab" | "side_panel" | null;
|
||||
|
||||
// Returns the origin of the Chrome extension panel (our parent frame).
|
||||
// window.location.ancestorOrigins is Chrome-specific and only populated
|
||||
// when the page is loaded inside an iframe (e.g. the Chrome extension panel).
|
||||
// Falls back to "*" in regular browser contexts (no parent frame).
|
||||
export function getPanelOrigin(): string {
|
||||
return window.location.ancestorOrigins?.[0] ?? "*";
|
||||
}
|
||||
|
||||
export function getExtensionContext(): {
|
||||
isExtension: boolean;
|
||||
context: ExtensionContext;
|
||||
@@ -11,26 +19,29 @@ export function getExtensionContext(): {
|
||||
return { isExtension: false, context: null };
|
||||
|
||||
const pathname = window.location.pathname;
|
||||
if (pathname.includes("/app/nrf/side-panel")) {
|
||||
if (pathname.includes("/nrf/side-panel")) {
|
||||
return { isExtension: true, context: "side_panel" };
|
||||
}
|
||||
if (pathname.includes("/app/nrf")) {
|
||||
if (pathname.includes("/nrf")) {
|
||||
return { isExtension: true, context: "new_tab" };
|
||||
}
|
||||
return { isExtension: false, context: null };
|
||||
}
|
||||
export function sendSetDefaultNewTabMessage(value: boolean) {
|
||||
if (typeof window !== "undefined" && window.parent) {
|
||||
if (typeof window !== "undefined" && window.parent !== window) {
|
||||
window.parent.postMessage(
|
||||
{ type: CHROME_MESSAGE.SET_DEFAULT_NEW_TAB, value },
|
||||
"*"
|
||||
getPanelOrigin()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export const sendAuthRequiredMessage = () => {
|
||||
if (typeof window !== "undefined" && window.parent) {
|
||||
window.parent.postMessage({ type: CHROME_MESSAGE.AUTH_REQUIRED }, "*");
|
||||
if (typeof window !== "undefined" && window.parent !== window) {
|
||||
window.parent.postMessage(
|
||||
{ type: CHROME_MESSAGE.AUTH_REQUIRED },
|
||||
getPanelOrigin()
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -41,8 +52,11 @@ export const useSendAuthRequiredMessage = () => {
|
||||
};
|
||||
|
||||
export const sendMessageToParent = () => {
|
||||
if (typeof window !== "undefined" && window.parent) {
|
||||
window.parent.postMessage({ type: CHROME_MESSAGE.ONYX_APP_LOADED }, "*");
|
||||
if (typeof window !== "undefined" && window.parent !== window) {
|
||||
window.parent.postMessage(
|
||||
{ type: CHROME_MESSAGE.ONYX_APP_LOADED },
|
||||
getPanelOrigin()
|
||||
);
|
||||
}
|
||||
};
|
||||
export const useSendMessageToParent = () => {
|
||||
|
||||
@@ -7,15 +7,26 @@ interface LinguistLanguage {
|
||||
filenames?: string[];
|
||||
}
|
||||
|
||||
const allLanguages = Object.values(languages) as LinguistLanguage[];
|
||||
|
||||
// Collect extensions that linguist-languages assigns to "Markdown" so we can
|
||||
// exclude them from the code-language map
|
||||
const markdownExtensions = new Set(
|
||||
allLanguages
|
||||
.find((lang) => lang.name === "Markdown")
|
||||
?.extensions?.map((ext) => ext.toLowerCase()) ?? []
|
||||
);
|
||||
|
||||
// Build extension → language name and filename → language name maps at module load
|
||||
const extensionMap = new Map<string, string>();
|
||||
const filenameMap = new Map<string, string>();
|
||||
|
||||
for (const lang of Object.values(languages) as LinguistLanguage[]) {
|
||||
for (const lang of allLanguages) {
|
||||
if (lang.type !== "programming") continue;
|
||||
|
||||
const name = lang.name.toLowerCase();
|
||||
for (const ext of lang.extensions ?? []) {
|
||||
if (markdownExtensions.has(ext.toLowerCase())) continue;
|
||||
// First language to claim an extension wins
|
||||
if (!extensionMap.has(ext)) {
|
||||
extensionMap.set(ext, name);
|
||||
@@ -38,3 +49,12 @@ export function getCodeLanguage(name: string): string | null {
|
||||
const ext = lower.match(/\.[^.]+$/)?.[0];
|
||||
return (ext && extensionMap.get(ext)) ?? filenameMap.get(lower) ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if the file name has a Markdown extension (as defined by
|
||||
* linguist-languages) and should be rendered as rich text rather than code.
|
||||
*/
|
||||
export function isMarkdownFile(name: string): boolean {
|
||||
const ext = name.toLowerCase().match(/\.[^.]+$/)?.[0];
|
||||
return !!ext && markdownExtensions.has(ext);
|
||||
}
|
||||
|
||||
@@ -240,7 +240,7 @@ export interface SendSearchQueryRequest {
|
||||
filters?: BaseFilters | null;
|
||||
num_docs_fed_to_llm_selection?: number | null;
|
||||
run_query_expansion?: boolean;
|
||||
num_hits?: number; // default 50
|
||||
num_hits?: number; // default 30
|
||||
include_content?: boolean;
|
||||
stream?: boolean;
|
||||
}
|
||||
|
||||
@@ -114,7 +114,7 @@ const heightClasses = {
|
||||
* </Modal.Content>
|
||||
* ```
|
||||
*/
|
||||
interface ModalContentProps
|
||||
export interface ModalContentProps
|
||||
extends WithoutStyles<
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
|
||||
> {
|
||||
|
||||
@@ -135,8 +135,10 @@ function PopoverContent({
|
||||
ref={ref}
|
||||
align={align}
|
||||
sideOffset={sideOffset}
|
||||
collisionPadding={8}
|
||||
className={cn(
|
||||
"bg-background-neutral-00 p-1 z-popover rounded-12 overflow-hidden border shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
"max-h-[var(--radix-popover-content-available-height)]",
|
||||
widthClasses[width]
|
||||
)}
|
||||
{...props}
|
||||
|
||||
21
web/src/refresh-components/PreviewImage.tsx
Normal file
21
web/src/refresh-components/PreviewImage.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface PreviewImageProps {
|
||||
src: string;
|
||||
alt: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export default function PreviewImage({
|
||||
src,
|
||||
alt,
|
||||
className,
|
||||
}: PreviewImageProps) {
|
||||
return (
|
||||
<img
|
||||
src={src}
|
||||
alt={alt}
|
||||
className={cn("object-contain object-center", className)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import Button from "@/refresh-components/buttons/Button";
|
||||
import { useRouter } from "next/navigation";
|
||||
import type { Route } from "next";
|
||||
import { Button } from "@opal/components";
|
||||
import { SvgArrowLeft } from "@opal/icons";
|
||||
|
||||
export interface BackButtonProps {
|
||||
@@ -18,8 +18,8 @@ export default function BackButton({
|
||||
|
||||
return (
|
||||
<Button
|
||||
leftIcon={SvgArrowLeft}
|
||||
tertiary
|
||||
icon={SvgArrowLeft}
|
||||
prominence="tertiary"
|
||||
onClick={() => {
|
||||
if (behaviorOverride) {
|
||||
behaviorOverride();
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user