From d01abe6f3c8330dbdcf674ef8891e2b2118ac192 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 1 Jun 2026 19:42:24 +0000 Subject: [PATCH 01/25] chore: refresh star history chart [skip ci] --- assets/star-history.svg | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/assets/star-history.svg b/assets/star-history.svg index 16b5fc6a..69571d68 100644 --- a/assets/star-history.svg +++ b/assets/star-history.svg @@ -1 +1 @@ -star-history.comMay 17May 24May 31 100200300400500600kaelio/ktxStar HistoryDateGitHub Stars +star-history.comMay 17May 24May 31 200400600kaelio/ktx-ai-data-agents-contextStar HistoryDateGitHub Stars From 13774bfcef1622a83e29f27042bde1bcdd97beb2 Mon Sep 17 00:00:00 2001 From: Andrey Avtomonov Date: Mon, 1 Jun 2026 23:31:31 +0200 Subject: [PATCH 02/25] feat(cli): stream plain ktx ingest progress to stderr (KLO-726) (#251) * feat(cli): share public ingest progress adapter * feat(cli): stream plain public ingest progress * test(cli): update plain ingest progress assertions * chore(cli): satisfy plain ingest progress checks * fix(artifacts): expect plain ingest stderr progress in installed-CLI smoke * ci(coverage): make Codecov upload non-fatal and fix repo slug The Coverage job failed because the Codecov upload returned 'Repository not found' while fail_ci_if_error was true, turning a Codecov-side issue into a hard CI failure even though all tests pass. - Set fail_ci_if_error: false on both uploads so Codecov outages or an unlinked repo no longer break CI (upload stays best-effort). - Correct the stale slug Kaelio/ktx -> Kaelio/ktx-ai-data-agents-context to match the actual GitHub repo (aligns with main). * fix(cli): isolate query-history failure capture from scan output The plain public-ingest progress path passes one captured IO as the target-level `io`. With progress deps set, both the schema scan and the query-history ingest resolved their capture to that same shared buffer, so a non-actionable query-history failure surfaced leftover scan report text (e.g. "Mode: enriched") as the skipped-facet detail instead of the real query-history message. Give the query-history ingest a phase-local capture while preserving the flow-to-io branch the foreground context-build view relies on. --------- Co-authored-by: Andrey Avtomonov <7889985+andreybavt@users.noreply.github.com> --- .github/workflows/ci.yml | 4 +- packages/cli/src/context-build-view.ts | 41 +-- packages/cli/src/progress-port-adapter.ts | 29 +++ packages/cli/src/public-ingest.ts | 139 ++++++++++- .../cli/test/progress-port-adapter.test.ts | 35 +++ packages/cli/test/public-ingest.test.ts | 235 +++++++++++++++++- scripts/package-artifacts.mjs | 10 +- scripts/package-artifacts.test.mjs | 1 + 8 files changed, 445 insertions(+), 49 deletions(-) create mode 100644 packages/cli/src/progress-port-adapter.ts create mode 100644 packages/cli/test/progress-port-adapter.test.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4c981e98..23e4d668 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -217,7 +217,7 @@ jobs: flags: typescript name: typescript disable_search: true - fail_ci_if_error: true + fail_ci_if_error: false - name: Warn when Codecov token is missing for TypeScript if: env.CODECOV_TOKEN_CONFIGURED != 'true' @@ -236,7 +236,7 @@ jobs: flags: python name: python disable_search: true - fail_ci_if_error: true + fail_ci_if_error: false - name: Warn when Codecov token is missing for Python if: env.CODECOV_TOKEN_CONFIGURED != 'true' diff --git a/packages/cli/src/context-build-view.ts b/packages/cli/src/context-build-view.ts index 4b5be38b..f088097d 100644 --- a/packages/cli/src/context-build-view.ts +++ b/packages/cli/src/context-build-view.ts @@ -1,8 +1,6 @@ -import type { KtxProgressPort, KtxProgressUpdateOptions } from './context/scan/types.js'; import type { KtxCliIo } from './index.js'; import type { KtxIngestProgressUpdate } from './ingest.js'; import type { KtxManagedPythonInstallPolicy } from './managed-python-command.js'; -import { publicDatabaseIngestMessage, publicQueryHistoryMessage } from './public-ingest-copy.js'; import type { KtxPublicIngestArgs, KtxPublicIngestDeps, @@ -10,7 +8,8 @@ import type { KtxPublicIngestProject, KtxPublicIngestTargetResult, } from './public-ingest.js'; -import { buildPublicIngestPlan, executePublicIngestTarget } from './public-ingest.js'; +import { buildPublicIngestPlan, executePublicIngestTarget, publicProgressMessage } from './public-ingest.js'; +import { createAggregateProgressPort } from './progress-port-adapter.js'; import { formatDuration } from './demo-metrics.js'; import { profileMark } from './startup-profile.js'; @@ -810,17 +809,6 @@ export function initViewState(targets: KtxPublicIngestPlanTarget[]): ContextBuil }; } -function publicProgressMessage(message: string, target: KtxPublicIngestPlanTarget): string { - let current = message; - if (target.operation === 'database-ingest') { - current = publicDatabaseIngestMessage(current); - } - if (target.steps.includes('query-history')) { - current = publicQueryHistoryMessage(current, target.connectionId); - } - return current; -} - function formatProgressDetail( update: Pick, target: KtxPublicIngestPlanTarget, @@ -829,29 +817,6 @@ function formatProgressDetail( return `[${percent}%] ${publicProgressMessage(update.message, target)}`; } -function createContextBuildProgressPort( - onProgress: (update: KtxIngestProgressUpdate) => void, - state: { progress: number } = { progress: 0 }, - start = 0, - weight = 1, -): KtxProgressPort { - return { - async update(value: number, message?: string, options?: KtxProgressUpdateOptions): Promise { - const absoluteValue = start + Math.max(0, Math.min(1, value)) * weight; - state.progress = Math.max(state.progress, Math.min(1, absoluteValue)); - if (!message) return; - onProgress({ - percent: Math.max(0, Math.min(100, Math.round(state.progress * 100))), - message, - ...(options?.transient !== undefined ? { transient: options.transient } : {}), - }); - }, - startPhase(phaseWeight: number): KtxProgressPort { - return createContextBuildProgressPort(onProgress, state, state.progress, weight * phaseWeight); - }, - }; -} - export async function runContextBuild( project: KtxPublicIngestProject, args: ContextBuildArgs, @@ -1022,7 +987,7 @@ export async function runContextBuild( }; const progressDeps: KtxPublicIngestDeps = { - scanProgress: createContextBuildProgressPort(updateSchemaPhase), + scanProgress: createAggregateProgressPort(updateSchemaPhase), ingestProgress: updateIngestPhase, runtimeIo: io, onPhaseStart, diff --git a/packages/cli/src/progress-port-adapter.ts b/packages/cli/src/progress-port-adapter.ts new file mode 100644 index 00000000..1f73636b --- /dev/null +++ b/packages/cli/src/progress-port-adapter.ts @@ -0,0 +1,29 @@ +import type { KtxProgressPort, KtxProgressUpdateOptions } from './context/scan/types.js'; +import type { KtxIngestProgressUpdate } from './ingest.js'; + +export interface AggregateProgressState { + progress: number; +} + +export function createAggregateProgressPort( + onProgress: (update: KtxIngestProgressUpdate) => void, + state: AggregateProgressState = { progress: 0 }, + start = 0, + weight = 1, +): KtxProgressPort { + return { + async update(value: number, message?: string, options?: KtxProgressUpdateOptions): Promise { + const absoluteValue = start + Math.max(0, Math.min(1, value)) * weight; + state.progress = Math.max(state.progress, Math.min(1, absoluteValue)); + if (!message) return; + onProgress({ + percent: Math.max(0, Math.min(100, Math.round(state.progress * 100))), + message, + ...(options?.transient !== undefined ? { transient: options.transient } : {}), + }); + }, + startPhase(phaseWeight: number): KtxProgressPort { + return createAggregateProgressPort(onProgress, state, state.progress, weight * phaseWeight); + }, + }; +} diff --git a/packages/cli/src/public-ingest.ts b/packages/cli/src/public-ingest.ts index 25fe30dd..f2b8cdd4 100644 --- a/packages/cli/src/public-ingest.ts +++ b/packages/cli/src/public-ingest.ts @@ -11,7 +11,12 @@ import { type ManagedPythonCommandRuntime, } from './managed-python-command.js'; import type { KtxRuntimeFeature } from './managed-python-runtime.js'; -import { publicIngestOutputLine } from './public-ingest-copy.js'; +import { + publicDatabaseIngestMessage, + publicIngestOutputLine, + publicQueryHistoryMessage, +} from './public-ingest-copy.js'; +import { createAggregateProgressPort } from './progress-port-adapter.js'; import { resolvePublicIngestRuntimeRequirements } from './runtime-requirements.js'; import type { KtxScanArgs, KtxScanDeps } from './scan.js'; import { profileMark } from './startup-profile.js'; @@ -129,6 +134,17 @@ const sourceAdapterByDriver = new Map([ ['lookml', 'lookml'], ]); +export function publicProgressMessage(message: string, target: KtxPublicIngestPlanTarget): string { + let current = message; + if (target.operation === 'database-ingest') { + current = publicDatabaseIngestMessage(current); + } + if (target.steps.includes('query-history')) { + current = publicQueryHistoryMessage(current, target.connectionId); + } + return current; +} + const queryHistoryDialectByDriver = new Map([ ['postgres', 'postgres'], ['bigquery', 'bigquery'], @@ -729,6 +745,80 @@ function createCapturedPublicIngestIo(): CapturedPublicIngestIo { }; } +function isCapturedPublicIngestIo(io: KtxCliIo): io is CapturedPublicIngestIo { + return typeof (io as Partial).capturedOutput === 'function'; +} + +const PLAIN_PUBLIC_INGEST_PHASE_LABELS: Record = { + 'database-schema': 'database schema', + 'query-history': 'query history', + 'source-ingest': 'source ingest', +}; + +interface PlainPublicIngestProgressOptions { + target: KtxPublicIngestPlanTarget; + index: number; + total: number; +} + +function firstSummaryLine(summary: string | undefined): string | undefined { + if (!summary) return undefined; + return summary.split(/\r?\n/).find((line) => line.trim().length > 0)?.trim(); +} + +function plainPhaseHeader(options: PlainPublicIngestProgressOptions, phaseKey: KtxPublicIngestPhaseKey): string { + const prefix = options.total > 1 ? `[${options.index + 1}/${options.total}] ` : ''; + return `${prefix}${options.target.connectionId} · ${PLAIN_PUBLIC_INGEST_PHASE_LABELS[phaseKey]}`; +} + +function plainPhaseEndLine(status: 'done' | 'failed' | 'skipped', summary?: string): string { + const firstLine = firstSummaryLine(summary); + return firstLine ? ` ${status} · ${firstLine}` : ` ${status}`; +} + +function createPlainPublicIngestProgress(io: KtxCliIo, options: PlainPublicIngestProgressOptions): Required< + Pick +> { + let currentPhase: KtxPublicIngestPhaseKey | null = null; + const startedPhases = new Set(); + const lastPercentByPhase = new Map(); + + const startPhase = (phaseKey: KtxPublicIngestPhaseKey): void => { + currentPhase = phaseKey; + startedPhases.add(phaseKey); + lastPercentByPhase.set(phaseKey, -1); + io.stderr.write(`${plainPhaseHeader(options, phaseKey)}\n`); + }; + + const ensurePhaseStarted = (phaseKey: KtxPublicIngestPhaseKey): void => { + if (!startedPhases.has(phaseKey)) { + startPhase(phaseKey); + return; + } + currentPhase = phaseKey; + }; + + const emitProgress = (update: KtxIngestProgressUpdate): void => { + if (currentPhase === null) return; + const rounded = Math.max(0, Math.min(100, Math.round(update.percent))); + const lastPercent = lastPercentByPhase.get(currentPhase) ?? -1; + if (rounded <= lastPercent) return; + lastPercentByPhase.set(currentPhase, rounded); + io.stderr.write(` [${rounded}%] ${publicProgressMessage(update.message, options.target)}\n`); + }; + + return { + onPhaseStart: startPhase, + onPhaseEnd(phaseKey, status, summary) { + ensurePhaseStarted(phaseKey); + io.stderr.write(`${plainPhaseEndLine(status, summary)}\n`); + currentPhase = null; + }, + scanProgress: createAggregateProgressPort(emitProgress), + ingestProgress: emitProgress, + }; +} + const INTERNAL_STATUS_LINE_RE = /^(Report|Run|Job|Status|Adapter|Connection|Sync|Diff|Tasks|Work units|Failed tasks|Saved memory|Provenance rows):\s*/; const ACTIONABLE_FAILURE_LINE_RE = @@ -790,7 +880,7 @@ export async function executePublicIngestTarget( ? { ...step, status: 'failed', - detail: target.preflightFailure, + detail: `${target.connectionId} failed: ${target.preflightFailure}`, } : step, ), @@ -810,7 +900,11 @@ export async function executePublicIngestTarget( ...(args.runtimeInstallPolicy ? { runtimeInstallPolicy: args.runtimeInstallPolicy } : {}), }; const runScan = deps.runScan ?? runKtxScan; - const capturedScanIo = deps.scanProgress ? null : createCapturedPublicIngestIo(); + const capturedScanIo = deps.scanProgress + ? isCapturedPublicIngestIo(io) + ? io + : null + : createCapturedPublicIngestIo(); const scanIo = capturedScanIo ?? io; const scanDeps = { ...(deps.scanProgress ? { progress: deps.scanProgress } : {}), @@ -853,7 +947,13 @@ export async function executePublicIngestTarget( ...(target.queryHistory.windowDays !== undefined ? { windowDays: target.queryHistory.windowDays } : {}), }, }; - const capturedIngestIo = deps.ingestProgress ? null : createCapturedPublicIngestIo(); + // Query history runs after the schema scan has already written its report + // into the shared target io, so it needs a phase-local capture. Reusing + // `io` here would let leftover scan text (e.g. "Mode: enriched") surface as + // the query-history failure detail. Only skip capture when progress is + // active and the caller manages its own buffer (io is not a capture). + const capturedIngestIo = + deps.ingestProgress && !isCapturedPublicIngestIo(io) ? null : createCapturedPublicIngestIo(); const ingestIo = capturedIngestIo ?? io; const ingestDeps = { ...(deps.ingestProgress ? { progress: deps.ingestProgress } : {}), @@ -893,7 +993,11 @@ export async function executePublicIngestTarget( allowImplicitAdapter: true, }; const runIngest = deps.runIngest ?? runKtxIngest; - const capturedIngestIo = deps.ingestProgress ? null : createCapturedPublicIngestIo(); + const capturedIngestIo = deps.ingestProgress + ? isCapturedPublicIngestIo(io) + ? io + : null + : createCapturedPublicIngestIo(); const ingestIo = capturedIngestIo ?? io; const ingestDeps = { ...(deps.ingestProgress ? { progress: deps.ingestProgress } : {}), @@ -976,9 +1080,30 @@ export async function runKtxPublicIngest( } } - for (const target of plan.targets) { + for (const [index, target] of plan.targets.entries()) { const startedAt = performance.now(); - const result = await executePublicIngestTarget(target, args, io, deps); + if (args.json) { + const result = await executePublicIngestTarget(target, args, io, deps); + results.push(result); + await emitIngestCompleted({ args, project, target, result, startedAt, io }); + continue; + } + + const capture = createCapturedPublicIngestIo(); + const progress = createPlainPublicIngestProgress(io, { + target, + index, + total: plan.targets.length, + }); + const targetDeps: KtxPublicIngestDeps = { + ...deps, + scanProgress: progress.scanProgress, + ingestProgress: progress.ingestProgress, + onPhaseStart: progress.onPhaseStart, + onPhaseEnd: progress.onPhaseEnd, + runtimeIo: deps.runtimeIo ?? io, + }; + const result = await executePublicIngestTarget(target, args, capture, targetDeps); results.push(result); await emitIngestCompleted({ args, project, target, result, startedAt, io }); } diff --git a/packages/cli/test/progress-port-adapter.test.ts b/packages/cli/test/progress-port-adapter.test.ts new file mode 100644 index 00000000..336883aa --- /dev/null +++ b/packages/cli/test/progress-port-adapter.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from 'vitest'; +import { createAggregateProgressPort } from '../src/progress-port-adapter.js'; + +describe('createAggregateProgressPort', () => { + it('flattens nested weighted progress into absolute percent updates', async () => { + const updates: Array<{ percent: number; message: string; transient?: boolean }> = []; + const progress = createAggregateProgressPort((update) => updates.push(update)); + + await progress.update(0.1, 'Preparing scan'); + const nested = progress.startPhase(0.5); + await nested.update(0.5, 'Generating descriptions 2/4 tables', { transient: true }); + await progress.update(0.95, 'Writing schema artifacts'); + + expect(updates).toEqual([ + { percent: 10, message: 'Preparing scan' }, + { percent: 35, message: 'Generating descriptions 2/4 tables', transient: true }, + { percent: 95, message: 'Writing schema artifacts' }, + ]); + }); + + it('clamps updates and never moves the shared progress state backward', async () => { + const updates: Array<{ percent: number; message: string }> = []; + const progress = createAggregateProgressPort((update) => updates.push(update)); + + await progress.update(0.8, 'Building enriched schema context'); + await progress.update(0.2, 'Older scan callback'); + await progress.update(1.4, 'Scan completed'); + + expect(updates).toEqual([ + { percent: 80, message: 'Building enriched schema context' }, + { percent: 80, message: 'Older scan callback' }, + { percent: 100, message: 'Scan completed' }, + ]); + }); +}); diff --git a/packages/cli/test/public-ingest.test.ts b/packages/cli/test/public-ingest.test.ts index 41289208..2ffbefaf 100644 --- a/packages/cli/test/public-ingest.test.ts +++ b/packages/cli/test/public-ingest.test.ts @@ -8,6 +8,7 @@ import { buildPublicIngestPlan, type KtxPublicIngestDeps, type KtxPublicIngestProject, + publicProgressMessage, runKtxPublicIngest, } from '../src/public-ingest.js'; import type { ManagedPythonCommandRuntime } from '../src/managed-python-command.js'; @@ -346,6 +347,29 @@ describe('buildPublicIngestPlan', () => { }); }); +describe('publicProgressMessage', () => { + it('rewrites internal scan and historic-sql phrasing for public ingest progress', () => { + const databaseProject = deepReadyProject({ + warehouse: { driver: 'postgres', context: { queryHistory: { enabled: true, dialect: 'postgres' } } }, + }); + const databaseTarget = buildPublicIngestPlan(databaseProject, { + projectDir: '/tmp/project', + all: false, + targetConnectionId: 'warehouse', + queryHistory: 'default', + }).targets[0]; + + expect(databaseTarget).toBeDefined(); + expect(publicProgressMessage('Inspecting database schema', databaseTarget)).toBe('Reading database schema'); + expect(publicProgressMessage('Enriching schema metadata', databaseTarget)).toBe( + 'Building enriched schema context', + ); + expect(publicProgressMessage('Fetching source files for warehouse/historic-sql', databaseTarget)).toBe( + 'Fetching query history for warehouse', + ); + }); +}); + describe('runKtxPublicIngest', () => { afterEach(() => { vi.unstubAllEnvs(); @@ -371,11 +395,13 @@ describe('runKtxPublicIngest', () => { 1, expect.objectContaining({ connectionId: 'first', mode: 'enriched', detectRelationships: true }), expect.anything(), + expect.objectContaining({ progress: expect.any(Object) }), ); expect(runScan).toHaveBeenNthCalledWith( 2, expect.objectContaining({ connectionId: 'second', mode: 'enriched', detectRelationships: true }), expect.anything(), + expect.objectContaining({ progress: expect.any(Object) }), ); }); @@ -655,7 +681,10 @@ describe('runKtxPublicIngest', () => { expect(io.stdout()).not.toContain('Report: report-docs-1'); expect(io.stdout()).not.toContain('Adapter:'); expect(io.stdout()).not.toContain('notion\n'); - expect(io.stderr()).toBe(''); + expect(io.stderr()).toContain('docs · source ingest\n'); + expect(io.stderr()).toContain(' done\n'); + expect(io.stderr()).not.toContain('Report: report-docs-1'); + expect(io.stderr()).not.toContain('Adapter:'); }); it('suppresses historic-sql report output during direct public query-history ingest', async () => { @@ -694,9 +723,168 @@ describe('runKtxPublicIngest', () => { expect(io.stdout()).not.toContain('Report: report-query-history-1'); expect(io.stdout()).not.toContain('Adapter:'); expect(io.stdout()).not.toContain('historic-sql'); + expect(io.stderr()).toContain('warehouse · database schema\n'); + expect(io.stderr()).toContain('warehouse · query history\n'); + expect(io.stderr()).toContain(' done\n'); + expect(io.stderr()).not.toContain('Report: report-query-history-1'); + expect(io.stderr()).not.toContain('Adapter:'); + expect(io.stderr()).not.toContain('historic-sql'); + }); + + it('streams plain non-json progress to stderr while keeping final results on stdout', async () => { + const io = makeIo(); + const project = deepReadyProject({ + warehouse: { driver: 'postgres', context: { queryHistory: { enabled: true, dialect: 'postgres' } } }, + docs: { driver: 'notion' }, + }); + const runScan = vi.fn>(async (_args, scanIo, deps) => { + scanIo.stdout.write('KTX scan completed\n'); + scanIo.stdout.write('Report: raw-sources/warehouse/live-database/sync-1/scan-report.json\n'); + await deps?.progress?.update(0.12, 'Inspecting database schema'); + const enrichmentProgress = deps?.progress?.startPhase(0.5); + await enrichmentProgress?.update(0.75, 'Enriching schema metadata', { transient: true }); + await deps?.progress?.update(1, 'Writing schema artifacts'); + return 0; + }); + const runIngest = vi.fn>(async (ingestArgs, ingestIo, deps) => { + if (ingestArgs.command !== 'run') { + throw new Error(`Unexpected ingest command: ${ingestArgs.command}`); + } + ingestIo.stdout.write(`Adapter: ${ingestArgs.adapter}\n`); + ingestIo.stdout.write('Report: report-progress-1\n'); + if (ingestArgs.adapter === 'historic-sql') { + deps?.progress?.({ percent: 15, message: 'Fetching source files for warehouse/historic-sql' }); + deps?.progress?.({ percent: 90, message: 'Saved memory: 1 wiki, 1 SL' }); + return 0; + } + deps?.progress?.({ percent: 55, message: 'Processing 3/8 tasks' }); + deps?.progress?.({ percent: 90, message: 'Saved memory: 6 wiki, 2 SL' }); + return 0; + }); + + await expect( + runKtxPublicIngest( + { + command: 'run', + projectDir: '/tmp/project', + all: true, + json: false, + inputMode: 'disabled', + queryHistory: 'default', + }, + io.io, + { loadProject: vi.fn(async () => project), runScan, runIngest }, + ), + ).resolves.toBe(0); + + expect(io.stdout()).toContain('Ingest finished'); + expect(io.stdout()).toContain('warehouse'); + expect(io.stdout()).toContain('docs'); + expect(io.stdout()).not.toContain('KTX scan completed'); + expect(io.stdout()).not.toContain('Report:'); + expect(io.stdout()).not.toContain('Adapter:'); + expect(io.stderr()).toContain('[1/2] warehouse · database schema\n'); + expect(io.stderr()).toContain(' [12%] Reading database schema\n'); + expect(io.stderr()).toContain(' [50%] Building enriched schema context\n'); + expect(io.stderr()).toContain('[1/2] warehouse · query history\n'); + expect(io.stderr()).toContain(' [15%] Fetching query history for warehouse\n'); + expect(io.stderr()).toContain('[2/2] docs · source ingest\n'); + expect(io.stderr()).toContain(' [55%] Processing 3/8 tasks\n'); + expect(io.stderr()).not.toContain('\r'); + }); + + it('does not emit plain progress for json public ingest output', async () => { + const io = makeIo(); + const project = deepReadyProject({ + warehouse: { driver: 'postgres' }, + }); + const runScan = vi.fn>(async (_args, _scanIo, deps) => { + expect(deps?.progress).toBeUndefined(); + return 0; + }); + + await expect( + runKtxPublicIngest( + { + command: 'run', + projectDir: '/tmp/project', + targetConnectionId: 'warehouse', + all: false, + json: true, + inputMode: 'disabled', + }, + io.io, + { loadProject: vi.fn(async () => project), runScan }, + ), + ).resolves.toBe(0); + + expect(JSON.parse(io.stdout())).toMatchObject({ + plan: { projectDir: '/tmp/project' }, + results: [{ connectionId: 'warehouse', driver: 'postgres' }], + }); expect(io.stderr()).toBe(''); }); + it('keeps captured failure details when plain progress ports are active', async () => { + const io = makeIo(); + const project = deepReadyProject({ warehouse: { driver: 'postgres' } }); + const runScan = vi.fn>(async (_args, scanIo, deps) => { + await deps?.progress?.update(0.42, 'Enriching schema metadata'); + scanIo.stdout.write('KTX scan enrichment failed after structural scan completed: embedding service timed out\n'); + return 1; + }); + + await expect( + runKtxPublicIngest( + { + command: 'run', + projectDir: '/tmp/project', + targetConnectionId: 'warehouse', + all: false, + json: false, + inputMode: 'disabled', + }, + io.io, + { loadProject: vi.fn(async () => project), runScan }, + ), + ).resolves.toBe(1); + + expect(io.stderr()).toContain('warehouse · database schema\n'); + expect(io.stderr()).toContain(' [42%] Building enriched schema context\n'); + expect(io.stderr()).toContain(' failed\n'); + expect(io.stdout()).toContain( + 'warehouse failed: Database enrichment failed after schema context completed: embedding service timed out.', + ); + expect(io.stdout()).not.toContain('KTX scan enrichment failed'); + expect(io.stdout()).not.toContain('structural scan'); + }); + + it('prints a failed plain phase when preflight fails before phase start', async () => { + const io = makeIo(); + const project = projectWithConnections({ + warehouse: { driver: 'postgres' }, + }); + + await expect( + runKtxPublicIngest( + { + command: 'run', + projectDir: '/tmp/project', + targetConnectionId: 'warehouse', + all: false, + json: false, + inputMode: 'disabled', + }, + io.io, + { loadProject: vi.fn(async () => project) }, + ), + ).resolves.toBe(1); + + expect(io.stderr()).toContain('warehouse · database schema\n'); + expect(io.stderr()).toContain(' failed · warehouse cannot be ingested: enrichment is not configured'); + expect(io.stdout()).toContain('warehouse failed: warehouse cannot be ingested: enrichment is not configured'); + }); + it('delegates interactive TTY public ingest to the foreground context-build view', async () => { const io = makeIo({ isTTY: true, interactive: true }); const project = projectWithConnections({ warehouse: { driver: 'postgres' } }); @@ -872,6 +1060,7 @@ describe('runKtxPublicIngest', () => { inputMode: 'disabled', }), expect.anything(), + expect.objectContaining({ progress: expect.any(Function) }), ); expect(runScan).toHaveBeenCalledWith( { @@ -883,6 +1072,7 @@ describe('runKtxPublicIngest', () => { dryRun: false, }, expect.anything(), + expect.objectContaining({ progress: expect.any(Object) }), ); expect(io.stdout()).toContain('Ingest finished with partial failures'); expect(io.stdout()).toContain('warehouse failed at database-schema.'); @@ -930,6 +1120,45 @@ describe('runKtxPublicIngest', () => { expect(io.stdout()).not.toContain('historic-sql'); }); + it('reports the query-history failure without leaking earlier scan report output', async () => { + const io = makeIo(); + const project = deepReadyProject({ + warehouse: { driver: 'postgres' }, + }); + const runScan = vi.fn(async (_args, scanIo) => { + scanIo.stdout.write('Run: scan-run-1\n'); + scanIo.stdout.write('Mode: enriched\n'); + scanIo.stdout.write('Dry run: no\n'); + scanIo.stdout.write('KTX scan completed\n'); + return 0; + }); + const runIngest = vi.fn(async (_args, ingestIo) => { + ingestIo.stderr.write('Stopped query history before persisting any results\n'); + return 1; + }); + + await expect( + runKtxPublicIngest( + { + command: 'run', + projectDir: '/tmp/project', + targetConnectionId: 'warehouse', + all: false, + json: false, + inputMode: 'disabled', + queryHistory: 'enabled', + }, + io.io, + { loadProject: vi.fn(async () => project), runScan, runIngest }, + ), + ).resolves.toBe(0); + + expect(io.stdout()).toContain('Skipped query history:'); + expect(io.stdout()).toContain('Stopped query history before persisting any results'); + expect(io.stdout()).not.toContain('Dry run: no'); + expect(io.stdout()).not.toContain('Mode: enriched'); + }); + it('prints the runtime artifact build hint for missing query-history runtime assets', async () => { const io = makeIo(); const project = deepReadyProject({ @@ -989,6 +1218,7 @@ describe('runKtxPublicIngest', () => { expect(runIngest).toHaveBeenCalledWith( expect.objectContaining({ command: 'run', connectionId: 'docs', adapter: 'notion' }), expect.anything(), + expect.objectContaining({ progress: expect.any(Function) }), ); expect(io.stdout()).toContain('warehouse cannot be ingested: enrichment is not configured'); }); @@ -1027,6 +1257,7 @@ describe('runKtxPublicIngest', () => { dryRun: false, }, expect.objectContaining({ capturedOutput: expect.any(Function) }), + expect.objectContaining({ progress: expect.any(Object) }), ); }); @@ -1099,6 +1330,7 @@ describe('runKtxPublicIngest', () => { sourceDir: '/repo/dbt', }), expect.objectContaining({ capturedOutput: expect.any(Function) }), + expect.objectContaining({ progress: expect.any(Function) }), ); }); @@ -1135,6 +1367,7 @@ describe('runKtxPublicIngest', () => { allowImplicitAdapter: true, }), expect.objectContaining({ capturedOutput: expect.any(Function) }), + expect.objectContaining({ progress: expect.any(Function) }), ); }); diff --git a/scripts/package-artifacts.mjs b/scripts/package-artifacts.mjs index d66d7f1a..e9ab5e9a 100644 --- a/scripts/package-artifacts.mjs +++ b/scripts/package-artifacts.mjs @@ -518,7 +518,10 @@ function requireExitCodeWithProjectStderr(label, result, projectDir, expectedCod expectedCode, label + ' failed with code ' + result.code + '\\nstdout:\\n' + result.stdout + '\\nstderr:\\n' + result.stderr, ); - assert.equal(result.stderr, 'Project: ' + projectDir + '\\n', label + ' wrote unexpected stderr'); + assert.ok( + result.stderr.startsWith('Project: ' + projectDir + '\\n'), + label + ' did not lead stderr with the project notice\\nstderr:\\n' + result.stderr, + ); } function requireSuccessWithStderr(label, result, stderrPattern) { @@ -534,6 +537,10 @@ function requireOutput(label, result, text) { assert.match(result.stdout, text, label + ' output did not match ' + text); } +function requireStderr(label, result, stderrPattern) { + assert.match(result.stderr, stderrPattern, label + ' stderr did not match ' + stderrPattern); +} + function escapeRegExp(value) { return value.replace(/[|\\\\{}()[\\]^$+*?.]/g, '\\\\$&'); } @@ -857,6 +864,7 @@ try { ), ); requireExitCodeWithProjectStderr('ktx ingest enrichment guard', databaseIngest, projectDir, 1); + requireStderr('ktx ingest enrichment guard', databaseIngest, /^ {2}failed /m); requireOutput('ktx ingest enrichment guard', databaseIngest, /Ingest finished with partial failures/); requireOutput('ktx ingest enrichment guard', databaseIngest, /enrichment is not configured/); process.stdout.write('ktx ingest enrichment guard verified\\n'); diff --git a/scripts/package-artifacts.test.mjs b/scripts/package-artifacts.test.mjs index ffc59ce6..29e7fb1e 100644 --- a/scripts/package-artifacts.test.mjs +++ b/scripts/package-artifacts.test.mjs @@ -535,6 +535,7 @@ describe('verification snippets', () => { assert.doesNotMatch(source, /'--enrich'/); assert.match(source, /ktx ingest enrichment guard verified/); assert.match(source, /enrichment is not configured/); + assert.match(source, /requireStderr\('ktx ingest enrichment guard'/); assert.match(source, /enrichment:/); assert.match(source, /mode: deterministic/); assert.doesNotMatch(source, /run\('pnpm', \['exec', 'ktx', 'ingest', 'run'/); From 41e20c9ce7c4dcfc848073d72ae7c4ea766506fc Mon Sep 17 00:00:00 2001 From: Andrey Avtomonov Date: Tue, 2 Jun 2026 00:14:43 +0200 Subject: [PATCH 03/25] chore: revert repo references to Kaelio/ktx and remove rename-resilience (#252) The GitHub repo was renamed back from Kaelio/ktx-ai-data-agents-context to Kaelio/ktx, reverting the URL changes from #250 across package metadata, CI (codecov + star-history slugs), issue/security templates, the release runbook, and docs/install commands. Also removes the rename-resilience machinery #250 added: semantic-release now reads the repository URL straight from package.json (Kaelio/ktx) again, so the repositoryUrl() derivation in scripts/semantic-release-config.cjs, its tests, and the rename note in docs/release.md are no longer needed. --- .github/ISSUE_TEMPLATE/config.yml | 2 +- .github/workflows/ci.yml | 4 +-- .github/workflows/star-history.yml | 2 +- AGENTS.md | 4 +-- CONTRIBUTING.md | 6 ++-- README.md | 12 +++---- SECURITY.md | 2 +- .../docs/ai-resources/prompt-recipes.mdx | 2 +- docs-site/content/docs/community/support.mdx | 10 +++--- .../docs/getting-started/introduction.mdx | 2 +- .../docs/getting-started/quickstart.mdx | 4 +-- docs-site/lib/llm-docs.ts | 2 +- docs/release.md | 11 +----- package.json | 6 ++-- packages/cli/package.json | 6 ++-- scripts/semantic-release-config.cjs | 19 ----------- scripts/semantic-release-config.test.mjs | 34 +------------------ 17 files changed, 34 insertions(+), 94 deletions(-) diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml index 40bd2e11..7a7b5d03 100644 --- a/.github/ISSUE_TEMPLATE/config.yml +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -10,5 +10,5 @@ contact_links: url: https://docs.kaelio.com/ktx/docs/community/support about: Full guide on where to ask what — Slack vs. GitHub Issues vs. docs. - name: Security issues - url: https://github.com/Kaelio/ktx-ai-data-agents-context/security/advisories/new + url: https://github.com/Kaelio/ktx/security/advisories/new about: Report security vulnerabilities privately via GitHub Security Advisories. Please do not file security issues publicly. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 23e4d668..cace9460 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -212,7 +212,7 @@ jobs: uses: codecov/codecov-action@75cd11691c0faa626561e295848008c8a7dddffe # v5.5.4 with: token: ${{ secrets.CODECOV_TOKEN }} - slug: Kaelio/ktx-ai-data-agents-context + slug: Kaelio/ktx files: ./packages/cli/coverage/lcov.info flags: typescript name: typescript @@ -231,7 +231,7 @@ jobs: uses: codecov/codecov-action@75cd11691c0faa626561e295848008c8a7dddffe # v5.5.4 with: token: ${{ secrets.CODECOV_TOKEN }} - slug: Kaelio/ktx-ai-data-agents-context + slug: Kaelio/ktx files: ./coverage/python.xml flags: python name: python diff --git a/.github/workflows/star-history.yml b/.github/workflows/star-history.yml index e8156407..b7d90c43 100644 --- a/.github/workflows/star-history.yml +++ b/.github/workflows/star-history.yml @@ -35,7 +35,7 @@ jobs: set -euo pipefail # cachebust forces star-history to regenerate instead of serving its # own server-side cache; --location follows the slug-normalizing 301. - url="https://api.star-history.com/svg?repos=Kaelio/ktx-ai-data-agents-context&type=Date&cachebust=${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}" + url="https://api.star-history.com/svg?repos=Kaelio/ktx&type=Date&cachebust=${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}" curl --fail --location --silent --show-error \ --retry 3 --retry-delay 5 --max-time 60 \ -o assets/star-history.svg.new "$url" diff --git a/AGENTS.md b/AGENTS.md index 7acbb732..0cd9da93 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -381,8 +381,8 @@ rather than silently skipping it. - **MUST**: Disable monospace ligatures on every surface that uses the `var(--font-mono)` family (Geist Mono). Geist Mono fuses `--` into an em-dash glyph that visually eats the adjacent space, so prompts like - `npx skills add Kaelio/ktx-ai-data-agents-context --skill ktx` render as - `Kaelio/ktx-ai-data-agents-context--skill ktx`. + `npx skills add Kaelio/ktx --skill ktx` render as + `Kaelio/ktx--skill ktx`. - **MUST**: When adding a new container that renders user-visible monospace text outside `` / `
` (e.g. a styled `
` for a copyable prompt), verify the global ligature-off rule in diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 157921f0..a4fb3040 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -8,8 +8,8 @@ layout, and verification commands, see the ## How to contribute 1. Browse open issues labeled - [`good first issue`](https://github.com/Kaelio/ktx-ai-data-agents-context/labels/good%20first%20issue) - or [`help wanted`](https://github.com/Kaelio/ktx-ai-data-agents-context/labels/help%20wanted). + [`good first issue`](https://github.com/Kaelio/ktx/labels/good%20first%20issue) + or [`help wanted`](https://github.com/Kaelio/ktx/labels/help%20wanted). 2. Comment on the issue to claim it. A maintainer will confirm scope and assign it to you. 3. For changes not covered by an existing issue, open one first so we can @@ -82,7 +82,7 @@ page for the full guide. The short version: - **Feature requests**: use the [Feature request](.github/ISSUE_TEMPLATE/feature_request.yml) template. - **Security**: report privately via - [GitHub Security Advisories](https://github.com/Kaelio/ktx-ai-data-agents-context/security/advisories/new), + [GitHub Security Advisories](https://github.com/Kaelio/ktx/security/advisories/new), not as a public issue. ## Code of conduct diff --git a/README.md b/README.md index d417d77a..d44905d5 100644 --- a/README.md +++ b/README.md @@ -8,11 +8,11 @@

npm version - Codecov - Tests + Codecov + Tests Documentation Join the ktx Slack community - License + License Y Combinator P25

@@ -130,7 +130,7 @@ Agent integration ready: yes (codex:project) > your project directory: > > ```text -> Run npx skills add Kaelio/ktx-ai-data-agents-context --skill ktx and use the ktx skill to install +> Run npx skills add Kaelio/ktx --skill ktx and use the ktx skill to install > and configure ktx in this project. > ``` @@ -201,7 +201,7 @@ then the current directory. Pass `--project-dir ` when scripting. ## Community - **[Slack](https://join.slack.com/t/ktxcommunity/shared_invite/zt-3y9b44m1x-LVyNNJD5nwaZHq4XS29LMQ)** — ask questions, share what you're building, and chat with maintainers. -- **[GitHub Issues](https://github.com/Kaelio/ktx-ai-data-agents-context/issues)** — report bugs and request features. +- **[GitHub Issues](https://github.com/Kaelio/ktx/issues)** — report bugs and request features. - **[Contributing](https://docs.kaelio.com/ktx/docs/community/contributing)** — set up the repo, run tests, and open a PR. ## Development @@ -258,7 +258,7 @@ event catalog and opt-out options. ## Star History

- + ktx Star History Chart

diff --git a/SECURITY.md b/SECURITY.md index 7d0c1909..da90c1a5 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -5,7 +5,7 @@ If you believe you've found a security vulnerability in KTX, please report it **privately** through GitHub Security Advisories: -[Report a vulnerability](https://github.com/Kaelio/ktx-ai-data-agents-context/security/advisories/new) +[Report a vulnerability](https://github.com/Kaelio/ktx/security/advisories/new) If you cannot use GitHub Security Advisories, email `support@kaelio.com` instead. Please do **not** open a public issue, post in the KTX Slack, or diff --git a/docs-site/content/docs/ai-resources/prompt-recipes.mdx b/docs-site/content/docs/ai-resources/prompt-recipes.mdx index 99106128..9ba8e3b8 100644 --- a/docs-site/content/docs/ai-resources/prompt-recipes.mdx +++ b/docs-site/content/docs/ai-resources/prompt-recipes.mdx @@ -14,7 +14,7 @@ Read https://docs.kaelio.com/ktx/llms.txt first. Then fetch only the ktx Markdow ## Set up a project ```text -Run npx skills add Kaelio/ktx-ai-data-agents-context --skill ktx and use the ktx skill to install +Run npx skills add Kaelio/ktx --skill ktx and use the ktx skill to install and configure ktx in this project. ``` diff --git a/docs-site/content/docs/community/support.mdx b/docs-site/content/docs/community/support.mdx index 2e858225..1aac5057 100644 --- a/docs-site/content/docs/community/support.mdx +++ b/docs-site/content/docs/community/support.mdx @@ -11,7 +11,7 @@ the core team trade questions, share patterns, and shape the roadmap. | You want to... | Go here | |----------------|---------| | Ask a question or chat with the community | [**ktx** Slack](https://join.slack.com/t/ktxcommunity/shared_invite/zt-3y9b44m1x-LVyNNJD5nwaZHq4XS29LMQ) | -| Report a bug or request a feature | [GitHub Issues](https://github.com/Kaelio/ktx-ai-data-agents-context/issues) | +| Report a bug or request a feature | [GitHub Issues](https://github.com/Kaelio/ktx/issues) | | Read or contribute to the docs | [docs.kaelio.com/ktx](https://docs.kaelio.com/ktx/docs/) | | Contribute code | [Contributing guide](/docs/community/contributing) | @@ -30,14 +30,14 @@ Slack is the right place for: - **Feedback** on the roadmap and early features For anything reproducible - a crash, a wrong result, an unexpected CLI error - -open a [GitHub issue](https://github.com/Kaelio/ktx-ai-data-agents-context/issues) instead. Issues are +open a [GitHub issue](https://github.com/Kaelio/ktx/issues) instead. Issues are searchable, get triaged, and stay attached to the eventual fix. ## GitHub -- **[Issues](https://github.com/Kaelio/ktx-ai-data-agents-context/issues)** - bugs and feature requests -- **[Pull requests](https://github.com/Kaelio/ktx-ai-data-agents-context/pulls)** - code, docs, and connector contributions -- **[Releases](https://github.com/Kaelio/ktx-ai-data-agents-context/releases)** - changelog and published versions +- **[Issues](https://github.com/Kaelio/ktx/issues)** - bugs and feature requests +- **[Pull requests](https://github.com/Kaelio/ktx/pulls)** - code, docs, and connector contributions +- **[Releases](https://github.com/Kaelio/ktx/releases)** - changelog and published versions ## Code of conduct diff --git a/docs-site/content/docs/getting-started/introduction.mdx b/docs-site/content/docs/getting-started/introduction.mdx index ae8bb414..cc3b0ca8 100644 --- a/docs-site/content/docs/getting-started/introduction.mdx +++ b/docs-site/content/docs/getting-started/introduction.mdx @@ -102,6 +102,6 @@ best first step for users; contributor setup lives in the community docs. Have questions, want to share what you're building, or chat with maintainers? Join the [**ktx** Slack community](https://join.slack.com/t/ktxcommunity/shared_invite/zt-3y9b44m1x-LVyNNJD5nwaZHq4XS29LMQ). For bug reports and feature requests, open a -[GitHub issue](https://github.com/Kaelio/ktx-ai-data-agents-context/issues). See +[GitHub issue](https://github.com/Kaelio/ktx/issues). See [Community & Support](/docs/community/support) for the full guide on where to ask what. diff --git a/docs-site/content/docs/getting-started/quickstart.mdx b/docs-site/content/docs/getting-started/quickstart.mdx index f1a1c8ad..5129c585 100644 --- a/docs-site/content/docs/getting-started/quickstart.mdx +++ b/docs-site/content/docs/getting-started/quickstart.mdx @@ -171,14 +171,14 @@ If you're a coding assistant choosing a docs route, start with the
- Run {'`npx skills add Kaelio/ktx-ai-data-agents-context --skill ktx`'} and use the ktx skill to install and configure ktx + Run {'`npx skills add Kaelio/ktx --skill ktx`'} and use the ktx skill to install and configure ktx
diff --git a/docs-site/lib/llm-docs.ts b/docs-site/lib/llm-docs.ts index e35978c2..fd6c8dd1 100644 --- a/docs-site/lib/llm-docs.ts +++ b/docs-site/lib/llm-docs.ts @@ -52,7 +52,7 @@ ktx provides semantic-layer files, warehouse scans, wiki pages, provenance, and ## Agent Entry Points -- Installable setup skill: run \`npx skills add Kaelio/ktx-ai-data-agents-context --skill ktx\` from +- Installable setup skill: run \`npx skills add Kaelio/ktx --skill ktx\` from the project you want to configure. ${link("/docs/ai-resources/agent-quickstart", "Agent Quickstart", "Task-first route for coding assistants using ktx")} ${link("/docs/ai-resources/markdown-access", "Markdown Access", "Fetch ktx docs as llms.txt, llms-full.txt, or per-page Markdown")} diff --git a/docs/release.md b/docs/release.md index bc90f651..3a72f54e 100644 --- a/docs/release.md +++ b/docs/release.md @@ -26,7 +26,7 @@ The workflow rejects releases from any branch other than `main`. Before you publish, confirm these requirements: - npm Trusted Publishing is configured for `@kaelio/ktx`. -- The trusted publisher points at the `Kaelio/ktx-ai-data-agents-context` repository and the +- The trusted publisher points at the `Kaelio/ktx` repository and the `.github/workflows/release.yml` workflow. - The workflow keeps `id-token: write` permission so npm can verify the GitHub Actions run through OpenID Connect. @@ -35,15 +35,6 @@ Before you publish, confirm these requirements: - The repository has a stable baseline tag when you need semantic-release to publish the first stable version as `0.1.0`. -If you rename the GitHub repository, the semantic-release run adapts on its -own: `scripts/semantic-release-config.cjs` derives `repositoryUrl` from the -runner's `GITHUB_REPOSITORY`, so `@semantic-release/github` always matches the -current clone URL. The one thing that does **not** auto-update is the npm -Trusted Publishing config — re-point it at the new repository name (plus -`release.yml`) on npm, or `npm publish --provenance` will fail OIDC -verification. The `repository` field in `package.json` is npm-display metadata -only and can stay whatever public name you prefer. - semantic-release doesn't support choosing an arbitrary first `0.x` stable release. If KTX has no stable tag yet and you need the first stable release to be `0.1.0`, create and push the baseline tag once before running the live diff --git a/package.json b/package.json index 05a8bbe5..fee7b745 100644 --- a/package.json +++ b/package.json @@ -76,10 +76,10 @@ "license": "Apache-2.0", "repository": { "type": "git", - "url": "git+https://github.com/Kaelio/ktx-ai-data-agents-context.git" + "url": "git+https://github.com/Kaelio/ktx.git" }, "bugs": { - "url": "https://github.com/Kaelio/ktx-ai-data-agents-context/issues" + "url": "https://github.com/Kaelio/ktx/issues" }, - "homepage": "https://github.com/Kaelio/ktx-ai-data-agents-context#readme" + "homepage": "https://github.com/Kaelio/ktx#readme" } diff --git a/packages/cli/package.json b/packages/cli/package.json index c0ad291a..b04fceac 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -93,11 +93,11 @@ "license": "Apache-2.0", "repository": { "type": "git", - "url": "https://github.com/Kaelio/ktx-ai-data-agents-context", + "url": "https://github.com/Kaelio/ktx", "directory": "packages/cli" }, "bugs": { - "url": "https://github.com/Kaelio/ktx-ai-data-agents-context/issues" + "url": "https://github.com/Kaelio/ktx/issues" }, - "homepage": "https://github.com/Kaelio/ktx-ai-data-agents-context#readme" + "homepage": "https://github.com/Kaelio/ktx#readme" } diff --git a/scripts/semantic-release-config.cjs b/scripts/semantic-release-config.cjs index b9f34b8a..2dee466a 100644 --- a/scripts/semantic-release-config.cjs +++ b/scripts/semantic-release-config.cjs @@ -104,22 +104,6 @@ function releaseTag(kind, env = process.env) { return `branch-${branchPrereleaseId(branchName)}`; } -function repositoryUrl(env = process.env) { - // @semantic-release/github compares this URL's owner/repo against the live - // GitHub clone_url with an exact match (no redirect following), so a repo - // rename breaks the release unless repositoryUrl tracks the *current* name. - // In CI, derive it from the runner's repository so renames never re-break the - // release. Outside CI, return undefined so semantic-release falls back to the - // package.json `repository` field (its documented default). - const repository = env.GITHUB_REPOSITORY; - if (!repository) { - return undefined; - } - - const server = env.GITHUB_SERVER_URL || 'https://github.com'; - return `${server}/${repository}.git`; -} - function releaseBranches(env = process.env) { const kind = releaseKind(env); @@ -143,12 +127,10 @@ function releaseBranches(env = process.env) { function createReleaseConfig(env = process.env) { const kind = releaseKind(env); const tag = releaseTag(kind, env); - const url = repositoryUrl(env); return { tagFormat: 'v${version}', branches: releaseBranches(env), - ...(url ? { repositoryUrl: url } : {}), plugins: [ [ '@semantic-release/commit-analyzer', @@ -221,5 +203,4 @@ module.exports = { releaseBranches, releaseKind, releaseTag, - repositoryUrl, }; diff --git a/scripts/semantic-release-config.test.mjs b/scripts/semantic-release-config.test.mjs index 02b518ed..24289896 100644 --- a/scripts/semantic-release-config.test.mjs +++ b/scripts/semantic-release-config.test.mjs @@ -3,7 +3,7 @@ import { createRequire } from 'node:module'; import { describe, it } from 'node:test'; const require = createRequire(import.meta.url); -const { createReleaseConfig, releaseBranches, releaseKind, releaseTag, repositoryUrl } = require('./semantic-release-config.cjs'); +const { createReleaseConfig, releaseBranches, releaseKind, releaseTag } = require('./semantic-release-config.cjs'); function prepareExecOptions(config) { return config.plugins.find((plugin) => Array.isArray(plugin) && plugin[0] === '@semantic-release/exec' && plugin[1].prepareCmd)[1]; @@ -141,38 +141,6 @@ describe('semantic-release config', () => { assert.match(analyzeExec[1].analyzeCommitsCmd, /FORCE_RELEASE === 'true' \? 'patch' : ''/); }); - it('pins repositoryUrl to the runner repository so a GitHub rename never re-breaks the release', () => { - // @semantic-release/github exact-matches repositoryUrl against the live - // clone_url, so the release must track the *current* repo name, not the - // static package.json value. - assert.equal( - repositoryUrl({ GITHUB_REPOSITORY: 'Kaelio/ktx-ai-data-agents-context' }), - 'https://github.com/Kaelio/ktx-ai-data-agents-context.git', - ); - assert.equal( - repositoryUrl({ GITHUB_REPOSITORY: 'Kaelio/ktx' }), - 'https://github.com/Kaelio/ktx.git', - 'a later rename back to Kaelio/ktx must resolve without any code change', - ); - assert.equal( - repositoryUrl({ GITHUB_REPOSITORY: 'Kaelio/ktx', GITHUB_SERVER_URL: 'https://ghe.example.com' }), - 'https://ghe.example.com/Kaelio/ktx.git', - ); - - const config = createReleaseConfig({ - KTX_RELEASE_KIND: 'stable', - GITHUB_REF_NAME: 'main', - GITHUB_REPOSITORY: 'Kaelio/ktx-ai-data-agents-context', - }); - assert.equal(config.repositoryUrl, 'https://github.com/Kaelio/ktx-ai-data-agents-context.git'); - }); - - it('omits repositoryUrl outside CI so semantic-release falls back to package.json', () => { - assert.equal(repositoryUrl({}), undefined); - const config = createReleaseConfig({ KTX_RELEASE_KIND: 'stable', GITHUB_REF_NAME: 'main' }); - assert.equal('repositoryUrl' in config, false); - }); - it('does not configure any commit type to create an automatic major release', () => { const config = createReleaseConfig({ KTX_RELEASE_KIND: 'stable', GITHUB_REF_NAME: 'main' }); const analyzer = config.plugins.find( From 74c6076b72d0f79d8e7bfa8ef31550de39a36d00 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 2 Jun 2026 07:46:46 +0000 Subject: [PATCH 04/25] chore: refresh star history chart [skip ci] --- assets/star-history.svg | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/assets/star-history.svg b/assets/star-history.svg index 69571d68..d6d9859e 100644 --- a/assets/star-history.svg +++ b/assets/star-history.svg @@ -1 +1 @@ -star-history.comMay 17May 24May 31 200400600kaelio/ktx-ai-data-agents-contextStar HistoryDateGitHub Stars +star-history.comMay 17May 24May 31 200400600kaelio/ktxStar HistoryDateGitHub Stars From 494618ab142505bd988156d867be047e3affc4c3 Mon Sep 17 00:00:00 2001 From: Andrey Avtomonov Date: Tue, 2 Jun 2026 13:57:11 +0200 Subject: [PATCH 05/25] feat: add codex llm backend for ktx runtime work (#253) * feat: add codex sdk runner foundation * feat: parse codex runtime events * feat: expose codex runtime mcp tools * feat: add codex llm runtime * feat: wire codex llm backend * test: avoid Array.fromAsync in codex runner test * docs: document codex llm backend * fix: tighten codex runtime config ownership * fix: use codex sdk env and thread options * fix: parse codex sdk event shapes * test: add codex backend live smoke * docs: clarify codex backend isolation * fix: drive codex loop metrics from mcp events * fix: enforce codex local step budget * docs: disclose codex isolation limits * fix: count all codex agent steps and stream step callbacks live The agent-loop step budget only counted completed mcp_tool_call items, so built-in command_execution steps (which the public Codex SDK/CLI surface can still expose) never decremented the budget, letting ingest/reconciliation run past stepBudget until Codex stopped on its own. onStepFinish was also replayed only after the whole stream drained, so live work_unit_step / reconciliation progress appeared stuck until the Codex process exited. collectEvents is now the single live step accumulator: it counts every completed agent-action item via a shared isCompletedAgentStep predicate (command_execution, mcp_tool_call, file_change, web_search), fires onStepFinish as each step completes, and enforces the budget on that broader count. A no-tool turn still counts as one step. toolFailures stays MCP-specific, since a non-zero command exit is normal agent exploration, not a loop failure. * test: align ingest llm-guard assertions with codex backend The skip-llm ingest guard message now lists codex as a valid backend and mentions a Claude Code/Codex session plus a codex setup hint, but this slow suite test still asserted the pre-codex wording. Update it to match the production message (already covered by the local-bundle-runtime unit test) and add the codex setup-line assertion. * fix: treat codex error:null tool calls as success The Codex SDK serializes error: null on successful mcp_tool_call items, so the failure check (item.error !== undefined) flagged every successful tool call as failed with the empty-payload default "Codex turn failed". This killed every ingest work unit under the codex backend before it could produce a patch. Key on status === 'failed' (authoritative, always set) and only treat a populated error object as a failure. Add a regression test built from a verbatim real-SDK event capture. * fix: default codex backend to gpt-5.5 and report real probe errors The previous default gpt-5.3-codex is an API-key-only model that the OpenAI API rejects under ChatGPT-account (subscription) auth, so codex status/setup failed with a misleading "authentication is not usable" message even though auth was fine. - Default codex model is now gpt-5.5 (works on both subscription and API-key auth); the curated setup picker offers gpt-5.5 / gpt-5.4 / gpt-5.4-mini and keeps free-form entry for account-specific ids (e.g. gpt-5.3-codex-spark). - runCodexAuthProbe now distinguishes "model not available" from an auth failure and surfaces the real API error: collectEvents retains stream events when the SDK throws on a non-zero exit, and the API error JSON envelope is unwrapped to its human-readable message. - The Codex isolation warning now renders inside the clack setup frame. - Docs updated to gpt-5.5 with a note that *-codex ids require API-key auth. * fix: require llm.models.default in status and match codex probe remediation Status reported a project ready when a non-none LLM backend was configured without llm.models.default, but the runtime (resolveModelSlots) hard-requires it, so ingest/scan/memory threw after `ktx status` said the project was usable. buildLlmStatus now fails for any non-none backend missing models.default and no longer invents a fallback model for claude-code/codex. Codex probe failures now carry a category-matched fix: a model-access failure steers the user at llm.models.default instead of the auth/install remediation. runCodexAuthProbe returns the fix and status consumes it; the message stays self-sufficient so setup output is unchanged. Docs: README now lists the codex backend and local Codex auth; ktx-setup.mdx states --llm-model only accepts codex/default or gpt-*/codex-* ids. Repaired four doctor fixtures that configured a backend without models.default (the now-correctly-blocked config) and added coverage for the new behavior. --- README.md | 10 +- .../content/docs/cli-reference/ktx-setup.mdx | 23 +- .../content/docs/cli-reference/ktx-status.mdx | 14 +- .../content/docs/configuration/ktx-yaml.mdx | 12 +- .../content/docs/guides/building-context.mdx | 16 +- .../content/docs/guides/llm-configuration.mdx | 37 ++ knip.json | 3 + package.json | 1 + packages/cli/package.json | 1 + packages/cli/src/commands/setup-commands.ts | 2 +- .../context/ingest/local-bundle-runtime.ts | 5 +- .../cli/src/context/llm/codex-exec-events.ts | 194 ++++++++ .../cli/src/context/llm/codex-isolation.ts | 9 + .../context/llm/codex-mcp-runtime-server.ts | 87 ++++ packages/cli/src/context/llm/codex-models.ts | 20 + .../src/context/llm/codex-runtime-config.ts | 38 ++ packages/cli/src/context/llm/codex-runtime.ts | 371 ++++++++++++++ .../cli/src/context/llm/codex-sdk-runner.ts | 96 ++++ packages/cli/src/context/llm/local-config.ts | 14 +- packages/cli/src/context/project/config.ts | 4 +- packages/cli/src/llm/types.ts | 2 +- packages/cli/src/setup-models.ts | 108 +++- packages/cli/src/status-project.ts | 64 ++- .../ingest/local-bundle-runtime.test.ts | 5 +- .../context/llm/codex-exec-events.test.ts | 188 +++++++ .../test/context/llm/codex-isolation.test.ts | 19 + .../llm/codex-mcp-runtime-server.test.ts | 73 +++ .../cli/test/context/llm/codex-models.test.ts | 17 + .../context/llm/codex-runtime-config.test.ts | 43 ++ .../test/context/llm/codex-runtime.test.ts | 460 ++++++++++++++++++ .../test/context/llm/codex-sdk-runner.test.ts | 97 ++++ .../context/llm/runtime-local-config.test.ts | 21 + .../cli/test/context/project/config.test.ts | 27 +- packages/cli/test/doctor.test.ts | 8 + packages/cli/test/ingest.test.ts | 7 +- packages/cli/test/llm/model-provider.test.ts | 9 + packages/cli/test/setup-models.test.ts | 81 +++ packages/cli/test/status-project.test.ts | 131 +++++ pnpm-lock.yaml | 79 +++ scripts/codex-backend-live-smoke.mjs | 160 ++++++ scripts/codex-backend-live-smoke.test.mjs | 18 + 41 files changed, 2544 insertions(+), 30 deletions(-) create mode 100644 packages/cli/src/context/llm/codex-exec-events.ts create mode 100644 packages/cli/src/context/llm/codex-isolation.ts create mode 100644 packages/cli/src/context/llm/codex-mcp-runtime-server.ts create mode 100644 packages/cli/src/context/llm/codex-models.ts create mode 100644 packages/cli/src/context/llm/codex-runtime-config.ts create mode 100644 packages/cli/src/context/llm/codex-runtime.ts create mode 100644 packages/cli/src/context/llm/codex-sdk-runner.ts create mode 100644 packages/cli/test/context/llm/codex-exec-events.test.ts create mode 100644 packages/cli/test/context/llm/codex-isolation.test.ts create mode 100644 packages/cli/test/context/llm/codex-mcp-runtime-server.test.ts create mode 100644 packages/cli/test/context/llm/codex-models.test.ts create mode 100644 packages/cli/test/context/llm/codex-runtime-config.test.ts create mode 100644 packages/cli/test/context/llm/codex-runtime.test.ts create mode 100644 packages/cli/test/context/llm/codex-sdk-runner.test.ts create mode 100644 scripts/codex-backend-live-smoke.mjs create mode 100644 scripts/codex-backend-live-smoke.test.mjs diff --git a/README.md b/README.md index d44905d5..2c433e0d 100644 --- a/README.md +++ b/README.md @@ -30,8 +30,9 @@ warehouse accurately - from approved metric definitions, joinable columns, and business knowledge it builds and maintains for you. > [!NOTE] -> Run **ktx** with your own LLM API keys or a **Claude Pro/Max** subscription. -> No extra usage billing from **ktx**. +> Run **ktx** with your own LLM API keys or a local agent sign-in — a +> **Claude Pro/Max** subscription through Claude Code, or your local Codex +> authentication. No extra usage billing from **ktx**.

@@ -175,8 +176,9 @@ then the current directory. Pass `--project-dir ` when scripting. No. **ktx** runs locally. The only data leaving your machine is what you send to the LLM provider you configured. - **Which LLM backends are supported?** - Anthropic API, Google Vertex AI, AI Gateway, and the local Claude Code - session through the Claude Agent SDK. See + Anthropic API, Google Vertex AI, AI Gateway, the local Claude Code session + through the Claude Agent SDK, and your local Codex authentication through the + Codex SDK. See [LLM configuration](https://docs.kaelio.com/ktx/docs/guides/llm-configuration). - **How is ktx different from a dbt or MetricFlow semantic layer?** **ktx** *ingests* those layers and combines them with raw-table diff --git a/docs-site/content/docs/cli-reference/ktx-setup.mdx b/docs-site/content/docs/cli-reference/ktx-setup.mdx index 0da7b339..24469a63 100644 --- a/docs-site/content/docs/cli-reference/ktx-setup.mdx +++ b/docs-site/content/docs/cli-reference/ktx-setup.mdx @@ -51,8 +51,9 @@ prompts. | Flag | Description | |------|-------------| -| `--llm-backend ` | LLM backend: `anthropic`, `vertex`, or `claude-code` | +| `--llm-backend ` | LLM backend: `anthropic`, `vertex`, `claude-code`, or `codex` | | `--llm-backend claude-code` | Use the local Claude Code session for **ktx** LLM calls | +| `--llm-backend codex` | Use local Codex authentication for **ktx** LLM calls | | `--llm-model ` | LLM model ID or backend model alias to validate and save | | `--anthropic-api-key-env ` | Environment variable containing the Anthropic API key | | `--anthropic-api-key-file ` | File containing the Anthropic API key | @@ -62,9 +63,14 @@ prompts. Choose only one Anthropic credential source. Anthropic credential flags are only valid with the Anthropic backend; Vertex flags are only valid with the Vertex -backend. The `claude-code` backend uses local Claude Code authentication instead +backend. The `claude-code` and `codex` backends use local authentication instead of Anthropic API key or Vertex flags. For Claude Code, `--llm-model` accepts -`sonnet`, `opus`, `haiku`, or a full Claude model ID. +`sonnet`, `opus`, `haiku`, or a full Claude model ID. For Codex, `--llm-model` +accepts `codex`, `default`, or a `gpt-*` / `codex-*` model ID such as +`gpt-5.5`; any other value is rejected before the auth probe. Run `codex` to +see the models available to your login, and pick a `gpt-*` / `codex-*` id from +that list. Note that `*-codex` API-billing model IDs (for example +`gpt-5.3-codex`) are not available to ChatGPT-subscription logins. ### Embeddings @@ -191,6 +197,17 @@ ktx setup \ --llm-backend claude-code \ --llm-model opus +# Configure **ktx** to use local Codex authentication for LLM work +ktx setup --llm-backend codex --llm-model gpt-5.5 --no-input +``` + +When you choose `--llm-backend codex`, setup prints a warning if the public +Codex SDK and CLI surface cannot prove full Claude-Code-style isolation. The +backend restricts **ktx** runtime MCP tools to each run, but Codex may still +load user Codex config and built-in command execution or read-only file +capabilities. + +```bash # Script a Postgres connection that reads its URL from the environment ktx setup \ --project-dir ./analytics \ diff --git a/docs-site/content/docs/cli-reference/ktx-status.mdx b/docs-site/content/docs/cli-reference/ktx-status.mdx index 51c00148..66e4964c 100644 --- a/docs-site/content/docs/cli-reference/ktx-status.mdx +++ b/docs-site/content/docs/cli-reference/ktx-status.mdx @@ -21,7 +21,7 @@ ktx status [options] | `--json` | Print JSON output | `false` | | `-v`, `--verbose` | Show every check, including passing ones | `false` | | `--validate` | Only validate the `ktx.yaml` schema; skip readiness checks | `false` | -| `--fast` | Skip checks that require external communication (query-history readiness probes and Claude Code auth probe) | `false` | +| `--fast` | Skip checks that require external communication (query-history readiness probes, Claude Code auth probe, and Codex auth probe) | `false` | | `--no-input` | Disable interactive terminal input | - | ## Examples @@ -39,7 +39,7 @@ ktx status --verbose # Validate ktx.yaml without running readiness checks ktx status --validate -# Skip slow probes (query-history readiness, Claude Code auth) +# Skip slow probes (query-history readiness, Claude Code auth, Codex auth) ktx status --fast # Check a project from another directory @@ -57,6 +57,16 @@ flow, then rerun `ktx status`. Use `--fast` to skip this probe (useful in CI or offline contexts); skipped checks render as `-` and carry `"status": "skipped"` in JSON output. +For `llm.provider.backend: codex`, `ktx status` runs a minimal non-interactive +Codex request. If the probe fails, authenticate Codex locally with the Codex CLI +and verify the Codex CLI installation. + +When `llm.provider.backend: codex` is configured, `ktx status` also prints a +warning when the installed public Codex SDK and CLI surface cannot prove full +Claude-Code-style isolation. The warning does not block authenticated Codex +usage, but it marks the project status as partial so you can make an explicit +runtime-isolation decision. + A `Local data` section summarises what the project has accumulated locally: ingest run counts, last completed timestamp per connection, knowledge page counts by scope, semantic-layer source and dictionary value counts, and the diff --git a/docs-site/content/docs/configuration/ktx-yaml.mdx b/docs-site/content/docs/configuration/ktx-yaml.mdx index 13105851..a9298443 100644 --- a/docs-site/content/docs/configuration/ktx-yaml.mdx +++ b/docs-site/content/docs/configuration/ktx-yaml.mdx @@ -376,13 +376,23 @@ llm: | Field | Type | Default | Purpose | |-------|------|---------|---------| -| `provider.backend` | `none` \| `anthropic` \| `vertex` \| `gateway` \| `claude-code` | `none` | Selected backend. `none` disables LLM features. `claude-code` uses the local Claude Code session and needs no API key. | +| `provider.backend` | `none` \| `anthropic` \| `vertex` \| `gateway` \| `claude-code` \| `codex` | `none` | Selected backend. `none` disables LLM features. `claude-code` uses the local Claude Code session and needs no API key. `codex` uses local Codex authentication and needs no API key. | | `provider.anthropic.api_key` | `string` | - | Anthropic API key. Required when `backend: anthropic`. Accepts `env:` or `file:` references. | | `provider.anthropic.base_url` | `string` | - | Override the Anthropic API base URL (proxy, self-hosted gateway). | | `provider.gateway.api_key` / `base_url` | `string` | - | Credentials for an AI Gateway provider. Required when `backend: gateway`. | | `provider.vertex.project` | `string` | - | Google Cloud project ID hosting the Vertex AI endpoint. | | `provider.vertex.location` | `string` | - | Vertex AI region (for example `us-east5`). Required when the `vertex` block is present. | +Use `codex` when local Codex authentication should power **ktx** LLM work: + +```yaml +llm: + provider: + backend: codex + models: + default: gpt-5.5 +``` + ### Model roles `models` overrides the per-role model. Keys are fixed; values are diff --git a/docs-site/content/docs/guides/building-context.mdx b/docs-site/content/docs/guides/building-context.mdx index b806c424..52179e70 100644 --- a/docs-site/content/docs/guides/building-context.mdx +++ b/docs-site/content/docs/guides/building-context.mdx @@ -39,8 +39,20 @@ ktx ingest --all Enriched ingest needs a configured model and embeddings. Run `ktx setup` first; connections without that configuration fail before any work starts. -With `claude-code`, **ktx** agent loops can invoke only the **ktx** MCP tools for the -current run. +Local-auth backends keep provider credentials out of `ktx.yaml`: + +```bash +ktx setup --llm-backend claude-code --no-input +ktx setup --llm-backend codex --llm-model gpt-5.5 --no-input +``` + +With `claude-code`, **ktx** agent loops can invoke only the **ktx** MCP tools +for the current run. With `codex`, **ktx** restricts the temporary runtime MCP +server to the current run's tool set, disables Codex web search, requests a +read-only sandbox, and sets `approval_policy=never`. The public Codex SDK and +CLI surface may still load user Codex config and built-in command execution or +read-only file capabilities, so use `claude-code` for stricter runtime tool +isolation. ## Query history diff --git a/docs-site/content/docs/guides/llm-configuration.mdx b/docs-site/content/docs/guides/llm-configuration.mdx index 880df24e..71ab9d80 100644 --- a/docs-site/content/docs/guides/llm-configuration.mdx +++ b/docs-site/content/docs/guides/llm-configuration.mdx @@ -16,6 +16,7 @@ Set `llm.provider.backend` to one of these values: - `gateway`: Use AI Gateway-compatible Anthropic model ids. - `claude-code`: Use your local Claude Code session through the Claude Agent SDK. **ktx** strips provider-routing environment variables from child processes. +- `codex`: Use your local Codex authentication through the Codex SDK. ## Claude Code @@ -47,6 +48,42 @@ model IDs are also accepted. metadata may still list host slash commands, skills, and subagents; **ktx** does not grant execution access to them. +## Codex backend + +Use `codex` when you want **ktx** to run LLM-backed workflows through your +local Codex authentication instead of a direct provider API key. + +```yaml +llm: + provider: + backend: codex + models: + default: gpt-5.5 +``` + +Configure it non-interactively: + +```bash +ktx setup --llm-backend codex --llm-model gpt-5.5 --no-input +``` + +This is separate from Codex agent-client setup. `ktx setup --agents --target +codex` installs instructions and MCP access for an end-user Codex session. +`ktx setup --llm-backend codex` makes **ktx** itself execute ingest, scan +enrichment, memory, and other LLM-backed work through Codex. + +During runtime loops, **ktx** starts a temporary loopback MCP server for the +current run, exposes only the tools passed to that run, asks Codex to use a +read-only sandbox, sets `approval_policy=never`, auto-approves only those +run-scoped MCP tools, and disables Codex web search. + +Codex backend isolation is currently limited by the public Codex SDK and CLI +surface. Codex may still load user Codex config and built-in command execution +or read-only file capabilities. Use `llm.provider.backend: claude-code` when +you need stricter Claude-Code-style runtime tool isolation, or remove host +Codex MCP and tool config before running untrusted prompts through the `codex` +backend. + ## Prompt caching `llm.promptCaching` has partial parity on `claude-code`. Status and doctor warn diff --git a/knip.json b/knip.json index 270c2310..65b1a0a2 100644 --- a/knip.json +++ b/knip.json @@ -37,6 +37,9 @@ "@semantic-release/release-notes-generator", "conventional-changelog-conventionalcommits" ], + "ignore": [ + ".context/**" + ], "ignoreBinaries": [ "uv", "lsof" diff --git a/package.json b/package.json index fee7b745..a9590d70 100644 --- a/package.json +++ b/package.json @@ -32,6 +32,7 @@ "setup:dev": "node scripts/setup-dev.mjs", "release:published-smoke": "node scripts/published-package-smoke.mjs --require-config", "release:local-embeddings-smoke": "node scripts/local-embeddings-runtime-smoke.mjs --require-opt-in", + "release:codex-backend-smoke": "node scripts/codex-backend-live-smoke.mjs", "release:readiness": "node scripts/release-readiness.mjs", "release:update-version": "node scripts/update-public-release-version.mjs", "relationships:acquire-public-fixtures": "node scripts/acquire-public-benchmark-fixtures.mjs", diff --git a/packages/cli/package.json b/packages/cli/package.json index b04fceac..9d3af54c 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -56,6 +56,7 @@ "@looker/sdk-rtl": "^21.6.5", "@modelcontextprotocol/sdk": "^1.29.0", "@notionhq/client": "^5.22.0", + "@openai/codex-sdk": "^0.133.0", "ai": "^6.0.188", "better-sqlite3": "^12.10.0", "commander": "14.0.3", diff --git a/packages/cli/src/commands/setup-commands.ts b/packages/cli/src/commands/setup-commands.ts index 19f980bd..1619a80a 100644 --- a/packages/cli/src/commands/setup-commands.ts +++ b/packages/cli/src/commands/setup-commands.ts @@ -29,7 +29,7 @@ function embeddingBackend(value: string): 'openai' | 'sentence-transformers' { } function llmBackend(value: string): KtxSetupLlmBackend { - if (value === 'anthropic' || value === 'vertex' || value === 'claude-code') { + if (value === 'anthropic' || value === 'vertex' || value === 'claude-code' || value === 'codex') { return value; } throw new InvalidArgumentError(`invalid choice '${value}'`); diff --git a/packages/cli/src/context/ingest/local-bundle-runtime.ts b/packages/cli/src/context/ingest/local-bundle-runtime.ts index 77f4234e..9d6aba95 100644 --- a/packages/cli/src/context/ingest/local-bundle-runtime.ts +++ b/packages/cli/src/context/ingest/local-bundle-runtime.ts @@ -611,9 +611,10 @@ function nextLocalJobId(): string { function localIngestLlmProviderGuardMessage(projectDir: string): string { return [ - 'ktx ingest requires llm.provider.backend: anthropic, vertex, gateway, or claude-code, or an injected agentRunner.', - 'Configure a local Claude Code session or API-backed LLM, then rerun ingest:', + 'ktx ingest requires llm.provider.backend: anthropic, vertex, gateway, claude-code, or codex, or an injected agentRunner.', + 'Configure a local Claude Code/Codex session or API-backed LLM, then rerun ingest:', ` ktx setup --project-dir ${projectDir} --llm-backend claude-code --no-input`, + ` ktx setup --project-dir ${projectDir} --llm-backend codex --llm-model gpt-5.5 --no-input`, ` ktx setup --project-dir ${projectDir} --llm-backend anthropic --anthropic-api-key-env ANTHROPIC_API_KEY --llm-model claude-sonnet-4-6 --no-input`, ].join('\n'); } diff --git a/packages/cli/src/context/llm/codex-exec-events.ts b/packages/cli/src/context/llm/codex-exec-events.ts new file mode 100644 index 00000000..86e13694 --- /dev/null +++ b/packages/cli/src/context/llm/codex-exec-events.ts @@ -0,0 +1,194 @@ +import type { LlmTokenUsage, RunLoopStopReason } from './runtime-port.js'; + +export interface CodexExecEventSummary { + finalText: string; + stopReason: RunLoopStopReason; + usage: LlmTokenUsage; + stepCount: number; + stepBoundariesMs: number[]; + toolCallCount: number; + toolFailures: string[]; + error?: Error; +} + +interface CodexEventParseOptions { + startedAt?: number; + now?: () => number; +} + +function record(value: unknown): Record | undefined { + return value && typeof value === 'object' ? (value as Record) : undefined; +} + +/** + * Codex thread items that represent a discrete agent action consuming one loop + * step. The step budget caps the total number of these regardless of which + * capability the agent reaches for, so built-in `command_execution` (and any + * file/web action the public Codex surface still exposes) count alongside our + * own `mcp_tool_call` items rather than only the MCP ones. + */ +const AGENT_STEP_ITEM_TYPES = new Set(['command_execution', 'mcp_tool_call', 'file_change', 'web_search']); + +export function isCompletedAgentStep(event: unknown): boolean { + const eventRecord = record(event); + if (eventRecord?.type !== 'item.completed') { + return false; + } + const itemType = record(eventRecord.item)?.type; + return typeof itemType === 'string' && AGENT_STEP_ITEM_TYPES.has(itemType); +} + +function text(value: unknown): string | undefined { + return typeof value === 'string' && value.trim().length > 0 ? value : undefined; +} + +function numberValue(value: unknown): number | undefined { + return typeof value === 'number' && Number.isFinite(value) ? value : undefined; +} + +function usageFrom(value: unknown): LlmTokenUsage { + const usage = record(value); + if (!usage) { + return {}; + } + const inputTokens = numberValue(usage.input_tokens ?? usage.inputTokens); + const outputTokens = numberValue(usage.output_tokens ?? usage.outputTokens); + const explicitTotalTokens = numberValue(usage.total_tokens ?? usage.totalTokens); + const totalTokens = + explicitTotalTokens ?? + (inputTokens !== undefined && outputTokens !== undefined ? inputTokens + outputTokens : undefined); + return { + ...(inputTokens !== undefined ? { inputTokens } : {}), + ...(outputTokens !== undefined ? { outputTokens } : {}), + ...(totalTokens !== undefined ? { totalTokens } : {}), + }; +} + +function stopReasonFrom(value: unknown): RunLoopStopReason { + const reason = text(value)?.toLowerCase(); + if (reason && /(budget|max_turn|max-turn|limit)/.test(reason)) { + return 'budget'; + } + return 'natural'; +} + +function errorMessageFrom(value: unknown): string { + if (value instanceof Error) { + return value.message; + } + const asRecord = record(value); + const message = text(asRecord?.message); + return message ?? text(value) ?? 'Codex turn failed'; +} + +/** + * Codex serializes API failures as a JSON envelope inside the event message + * (e.g. `{"type":"error","status":400,"error":{"message":"…"}}`). Surface the + * human-readable inner message so callers don't leak raw JSON; pass plain + * strings through unchanged. + */ +function unwrapCodexApiErrorMessage(raw: string): string { + const trimmed = raw.trim(); + if (!trimmed.startsWith('{')) { + return raw; + } + try { + const parsed = record(JSON.parse(trimmed)); + return text(record(parsed?.error)?.message) ?? text(parsed?.message) ?? raw; + } catch { + return raw; + } +} + +/** @internal */ +export function parseCodexExecEventLine(line: string): unknown { + try { + return JSON.parse(line) as unknown; + } catch (error) { + throw new Error(`Codex JSONL event stream was malformed: ${error instanceof Error ? error.message : String(error)}`); + } +} + +export function summarizeCodexExecEvents( + events: Iterable, + options: CodexEventParseOptions = {}, +): CodexExecEventSummary { + const startedAt = options.startedAt ?? Date.now(); + const now = options.now ?? Date.now; + let finalText = ''; + let stopReason: RunLoopStopReason = 'natural'; + let usage: LlmTokenUsage = {}; + let turnCount = 0; + let completedStepCount = 0; + const stepBoundariesMs: number[] = []; + let toolCallCount = 0; + const toolFailures: string[] = []; + let error: Error | undefined; + + for (const event of events) { + const eventRecord = record(event); + const eventType = text(eventRecord?.type); + if (!eventRecord || !eventType) { + continue; + } + + if (eventType === 'turn.started') { + turnCount += 1; + continue; + } + + const item = record(eventRecord.item); + const itemType = text(item?.type); + + if (eventType === 'item.started' && itemType === 'mcp_tool_call') { + toolCallCount += 1; + continue; + } + + if (isCompletedAgentStep(event)) { + completedStepCount += 1; + stepBoundariesMs.push(now() - startedAt); + // Only MCP tool calls fail the loop: a non-zero `command_execution` exit + // is normal agent exploration, not a runtime error. `status` is the + // authoritative signal (the SDK always sets it); the SDK also serializes + // `error: null` on successful calls, so an explicit-null `error` must NOT + // be read as a failure — only a populated error object counts. + if (itemType === 'mcp_tool_call' && (item?.status === 'failed' || (item?.error !== undefined && item?.error !== null))) { + const name = text(item?.name) ?? text(item?.tool) ?? text(item?.tool_name) ?? 'unknown'; + toolFailures.push(`${name}: ${errorMessageFrom(item?.error)}`); + } + continue; + } + + if (eventType === 'item.completed' && itemType === 'agent_message') { + finalText = text(item?.text) ?? finalText; + continue; + } + + if (eventType === 'turn.completed') { + usage = usageFrom(eventRecord.usage); + if (completedStepCount === 0) { + stepBoundariesMs.push(now() - startedAt); + } + stopReason = stopReasonFrom(eventRecord.reason ?? eventRecord.stop_reason ?? eventRecord.terminal_reason); + continue; + } + + if (eventType === 'turn.failed' || eventType === 'error') { + stopReason = 'error'; + error = new Error(unwrapCodexApiErrorMessage(errorMessageFrom(eventRecord.error ?? eventRecord.message))); + continue; + } + } + + return { + finalText, + stopReason, + usage, + stepCount: completedStepCount > 0 ? completedStepCount : turnCount, + stepBoundariesMs, + toolCallCount, + toolFailures, + ...(error ? { error } : {}), + }; +} diff --git a/packages/cli/src/context/llm/codex-isolation.ts b/packages/cli/src/context/llm/codex-isolation.ts new file mode 100644 index 00000000..d54ac1f8 --- /dev/null +++ b/packages/cli/src/context/llm/codex-isolation.ts @@ -0,0 +1,9 @@ +export const CODEX_ISOLATION_WARNING = + 'Codex backend isolation is limited by the public Codex SDK/CLI surface: ktx restricts the runtime MCP server to the current ktx tool set, disables Codex web search, asks for a read-only sandbox, and sets approval_policy=never, but Codex may still load user Codex config and built-in command execution or read-only file capabilities.'; + +export const CODEX_ISOLATION_WARNING_FIX = + 'Use llm.provider.backend: claude-code when you need stricter Claude-Code-style runtime tool isolation, or remove host Codex MCP/tool config before running untrusted prompts through the codex backend.'; + +export function formatCodexIsolationWarning(): string { + return `${CODEX_ISOLATION_WARNING} ${CODEX_ISOLATION_WARNING_FIX}`; +} diff --git a/packages/cli/src/context/llm/codex-mcp-runtime-server.ts b/packages/cli/src/context/llm/codex-mcp-runtime-server.ts new file mode 100644 index 00000000..eacf28f9 --- /dev/null +++ b/packages/cli/src/context/llm/codex-mcp-runtime-server.ts @@ -0,0 +1,87 @@ +import { randomBytes } from 'node:crypto'; +import type { Server } from 'node:http'; +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import type { KtxMcpServerLike } from '../mcp/types.js'; +import { runKtxMcpHttpServer, type KtxMcpHttpServerHandle } from '../../mcp-http-server.js'; +import type { KtxRuntimeToolSet } from './runtime-port.js'; +import { normalizeKtxRuntimeToolOutput } from './runtime-tools.js'; + +/** @internal */ +export interface CreateCodexRuntimeMcpServerInput { + server?: KtxMcpServerLike; + toolSet: KtxRuntimeToolSet; +} + +export interface CodexRuntimeMcpServerHandle { + url: string; + bearerTokenEnvVar: 'KTX_CODEX_RUNTIME_MCP_TOKEN'; + bearerToken: string; + close(): Promise; +} + +type RunServer = typeof runKtxMcpHttpServer; + +export interface StartCodexRuntimeMcpServerInput { + projectDir: string; + toolSet: KtxRuntimeToolSet; + runServer?: RunServer; +} + +/** @internal */ +export function createCodexRuntimeMcpServer(input: CreateCodexRuntimeMcpServerInput): KtxMcpServerLike { + const server = + input.server ?? + (new McpServer({ + name: 'ktx-runtime', + version: '0.0.0', + }) as KtxMcpServerLike); + + for (const descriptor of Object.values(input.toolSet)) { + server.registerTool( + descriptor.name, + { + description: descriptor.description, + inputSchema: descriptor.inputSchema.shape, + }, + async (toolInput) => { + const normalized = normalizeKtxRuntimeToolOutput(await descriptor.execute(toolInput)); + return { + content: [{ type: 'text', text: normalized.markdown }], + ...(normalized.structured !== undefined && normalized.structured !== null && typeof normalized.structured === 'object' + ? { structuredContent: normalized.structured as object } + : {}), + }; + }, + ); + } + + return server; +} + +function serverPort(server: Server, fallback: number): number { + const address = server.address(); + return typeof address === 'object' && address ? address.port : fallback; +} + +export async function startCodexRuntimeMcpServer( + input: StartCodexRuntimeMcpServerInput, +): Promise { + const bearerToken = randomBytes(32).toString('hex'); + const runServer = input.runServer ?? runKtxMcpHttpServer; + const handle = (await runServer({ + projectDir: input.projectDir, + host: '127.0.0.1', + port: 0, + token: bearerToken, + allowedHosts: ['127.0.0.1', 'localhost'], + allowedOrigins: [], + createMcpServer: () => createCodexRuntimeMcpServer({ toolSet: input.toolSet }) as McpServer, + })) as KtxMcpHttpServerHandle; + const port = serverPort(handle.server, 0); + return { + url: `http://127.0.0.1:${port}/mcp`, + bearerTokenEnvVar: 'KTX_CODEX_RUNTIME_MCP_TOKEN', + bearerToken, + close: () => handle.close(), + }; +} diff --git a/packages/cli/src/context/llm/codex-models.ts b/packages/cli/src/context/llm/codex-models.ts new file mode 100644 index 00000000..1a8b9b9d --- /dev/null +++ b/packages/cli/src/context/llm/codex-models.ts @@ -0,0 +1,20 @@ +export const DEFAULT_CODEX_MODEL = 'gpt-5.5'; + +const CODEX_MODEL_ALIASES: Record = { + codex: DEFAULT_CODEX_MODEL, + default: DEFAULT_CODEX_MODEL, +}; + +const EXPLICIT_CODEX_MODEL_ID = /^(?:gpt|codex)-[a-z0-9][a-z0-9._-]*$/i; + +export function resolveCodexModel(model: string): string { + const normalized = model.trim(); + const alias = CODEX_MODEL_ALIASES[normalized]; + if (alias) { + return alias; + } + if (EXPLICIT_CODEX_MODEL_ID.test(normalized)) { + return normalized; + } + throw new Error(`Unsupported Codex model "${model}". Use codex, default, or a gpt-* / codex-* model id.`); +} diff --git a/packages/cli/src/context/llm/codex-runtime-config.ts b/packages/cli/src/context/llm/codex-runtime-config.ts new file mode 100644 index 00000000..74de9efe --- /dev/null +++ b/packages/cli/src/context/llm/codex-runtime-config.ts @@ -0,0 +1,38 @@ +interface CodexRuntimeMcpConfig { + url: string; + bearerTokenEnvVar: string; + bearerToken: string; + toolNames: string[]; +} + +export interface BuildCodexRuntimeConfigInput { + model: string; + mcp?: CodexRuntimeMcpConfig; +} + +export interface CodexRuntimeConfig { + configOverrides: Record; + env: Record; +} + +export function buildCodexRuntimeConfig(input: BuildCodexRuntimeConfigInput): CodexRuntimeConfig { + const configOverrides: Record = { + history: { persistence: 'none' }, + }; + const env: Record = {}; + + if (input.mcp) { + configOverrides.mcp_servers = { + ktx: { + url: input.mcp.url, + bearer_token_env_var: input.mcp.bearerTokenEnvVar, + enabled_tools: input.mcp.toolNames, + default_tools_approval_mode: 'approve', + required: true, + }, + }; + env[input.mcp.bearerTokenEnvVar] = input.mcp.bearerToken; + } + + return { configOverrides, env }; +} diff --git a/packages/cli/src/context/llm/codex-runtime.ts b/packages/cli/src/context/llm/codex-runtime.ts new file mode 100644 index 00000000..3535072b --- /dev/null +++ b/packages/cli/src/context/llm/codex-runtime.ts @@ -0,0 +1,371 @@ +import { z } from 'zod'; +import { noopLogger, type KtxLogger } from '../core/config.js'; +import { isCompletedAgentStep, summarizeCodexExecEvents, type CodexExecEventSummary } from './codex-exec-events.js'; +import { + startCodexRuntimeMcpServer, + type CodexRuntimeMcpServerHandle, +} from './codex-mcp-runtime-server.js'; +import { resolveCodexModel } from './codex-models.js'; +import { buildCodexRuntimeConfig } from './codex-runtime-config.js'; +import { CodexSdkCliRunner, type CodexSdkRunner } from './codex-sdk-runner.js'; +import type { + KtxGenerateObjectInput, + KtxGenerateTextInput, + KtxLlmRuntimePort, + KtxRuntimeToolSet, + LlmTokenUsage, + RunLoopParams, + RunLoopResult, +} from './runtime-port.js'; + +export interface CodexKtxLlmRuntimeDeps { + projectDir: string; + modelSlots: { default: string } & Partial>; + runner?: CodexSdkRunner; + startMcpServer?: (input: { projectDir: string; toolSet: KtxRuntimeToolSet }) => Promise; + logger?: KtxLogger; +} + +function modelForRole(modelSlots: CodexKtxLlmRuntimeDeps['modelSlots'], role: string): string { + return resolveCodexModel(modelSlots[role] ?? modelSlots.default); +} + +function promptWithSystem(system: string | undefined, prompt: string): string { + return [system, prompt].filter(Boolean).join('\n\n'); +} + +interface CollectCodexEventsOptions { + stepBudget?: number; + abortController?: AbortController; + onStep?: (stepIndex: number) => void | Promise; +} + +interface CollectCodexEventsResult { + events: unknown[]; + budgetExceeded: boolean; + streamError?: Error; +} + +function eventRecord(value: unknown): Record | undefined { + return value && typeof value === 'object' ? (value as Record) : undefined; +} + +function isTurnCompleted(event: unknown): boolean { + return eventRecord(event)?.type === 'turn.completed'; +} + +/** + * Drains the Codex stream once, emitting a step as each agent action completes + * so callers see live progress and the step budget is enforced mid-run. Every + * completed agent-action item counts (see {@link isCompletedAgentStep}), so + * built-in `command_execution` steps decrement the budget the same as + * `mcp_tool_call`s. A turn that produced no actions still counts as one step, + * matching the metrics summary and the AI SDK backend. + */ +async function collectEvents( + events: AsyncIterable, + options: CollectCodexEventsOptions = {}, +): Promise { + const collected: unknown[] = []; + let completedSteps = 0; + let sawActionStep = false; + let budgetExceeded = false; + let streamError: Error | undefined; + + // The SDK yields every stdout event, then throws on a non-zero codex exec + // exit. Catch that throw so the events already collected (which carry the + // real `turn.failed`/`error` reason) survive for the summary; the masked + // exit message is kept only as a fallback when no error event was emitted. + try { + for await (const event of events) { + collected.push(event); + + const isActionStep = isCompletedAgentStep(event); + if (isActionStep) { + sawActionStep = true; + } else if (sawActionStep || !isTurnCompleted(event)) { + // Only fall back to counting a bare turn as a step when the turn produced + // no agent actions; a completed turn is terminal, so it never aborts. + continue; + } + + completedSteps += 1; + await options.onStep?.(completedSteps); + if (isActionStep && options.stepBudget !== undefined && completedSteps >= options.stepBudget) { + budgetExceeded = true; + options.abortController?.abort(); + break; + } + } + } catch (error) { + streamError = error instanceof Error ? error : new Error(String(error)); + } + + return { events: collected, budgetExceeded, ...(streamError ? { streamError } : {}) }; +} + +function metrics(summary: CodexExecEventSummary, startedAt: number): { totalMs: number; usage: LlmTokenUsage } { + return { totalMs: Date.now() - startedAt, usage: summary.usage }; +} + +function summaryError(summary: CodexExecEventSummary, streamError?: Error): Error | undefined { + // A `turn.failed`/`error` event carries the real reason; prefer it over the + // SDK's generic non-zero-exit throw. Fall back to the stream error only when + // no event explained the failure (e.g. spawn failure or auth before a turn). + if (summary.error) { + return summary.error; + } + if (summary.toolFailures.length > 0) { + return new Error(`Codex runtime tool call failed: ${summary.toolFailures.join('; ')}`); + } + return streamError; +} + +function assertSuccessfulText(summary: CodexExecEventSummary, streamError?: Error): string { + const error = summaryError(summary, streamError); + if (error) { + throw error; + } + if (!summary.finalText.trim()) { + throw new Error('Codex completed without an agent message'); + } + return summary.finalText; +} + +function parseStructuredOutput>(schema: TSchema, text: string): TOutput { + try { + return schema.parse(JSON.parse(text)); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + throw new Error(`Codex structured output failed validation: ${message}`); + } +} + +async function mcpForTools(input: { + projectDir: string; + toolSet?: KtxRuntimeToolSet; + startMcpServer: CodexKtxLlmRuntimeDeps['startMcpServer']; +}): Promise { + if (!input.toolSet || Object.keys(input.toolSet).length === 0) { + return undefined; + } + return (input.startMcpServer ?? startCodexRuntimeMcpServer)({ + projectDir: input.projectDir, + toolSet: input.toolSet, + }); +} + +function runtimeToolNames(toolSet: KtxRuntimeToolSet | undefined): string[] { + return Object.values(toolSet ?? {}).map((descriptor) => descriptor.name); +} + +export class CodexKtxLlmRuntime implements KtxLlmRuntimePort { + private readonly runner: CodexSdkRunner; + private readonly logger: KtxLogger; + + constructor(private readonly deps: CodexKtxLlmRuntimeDeps) { + this.runner = deps.runner ?? new CodexSdkCliRunner(); + this.logger = deps.logger ?? noopLogger; + } + + async generateText(input: KtxGenerateTextInput): Promise { + const startedAt = Date.now(); + const model = modelForRole(this.deps.modelSlots, input.role); + const mcp = await mcpForTools({ + projectDir: this.deps.projectDir, + toolSet: input.tools, + startMcpServer: this.deps.startMcpServer, + }); + try { + const config = buildCodexRuntimeConfig({ + model, + ...(mcp + ? { + mcp: { + url: mcp.url, + bearerTokenEnvVar: mcp.bearerTokenEnvVar, + bearerToken: mcp.bearerToken, + toolNames: runtimeToolNames(input.tools), + }, + } + : {}), + }); + const collected = await collectEvents( + await this.runner.runStreamed({ + projectDir: this.deps.projectDir, + model, + prompt: promptWithSystem(input.system, input.prompt), + configOverrides: config.configOverrides, + env: config.env, + }), + ); + const summary = summarizeCodexExecEvents(collected.events, { startedAt }); + input.onMetrics?.(metrics(summary, startedAt)); + return assertSuccessfulText(summary, collected.streamError); + } finally { + await mcp?.close(); + } + } + + async generateObject>( + input: KtxGenerateObjectInput, + ): Promise { + const startedAt = Date.now(); + const model = modelForRole(this.deps.modelSlots, input.role); + const mcp = await mcpForTools({ + projectDir: this.deps.projectDir, + toolSet: input.tools, + startMcpServer: this.deps.startMcpServer, + }); + try { + const config = buildCodexRuntimeConfig({ + model, + ...(mcp + ? { + mcp: { + url: mcp.url, + bearerTokenEnvVar: mcp.bearerTokenEnvVar, + bearerToken: mcp.bearerToken, + toolNames: runtimeToolNames(input.tools), + }, + } + : {}), + }); + const collected = await collectEvents( + await this.runner.runStreamed({ + projectDir: this.deps.projectDir, + model, + prompt: promptWithSystem(input.system, input.prompt), + configOverrides: config.configOverrides, + env: config.env, + outputSchema: z.toJSONSchema(input.schema, { target: 'draft-7' }) as Record, + }), + ); + const summary = summarizeCodexExecEvents(collected.events, { startedAt }); + input.onMetrics?.(metrics(summary, startedAt)); + return parseStructuredOutput(input.schema, assertSuccessfulText(summary, collected.streamError)); + } finally { + await mcp?.close(); + } + } + + async runAgentLoop(params: RunLoopParams): Promise { + const startedAt = Date.now(); + const model = modelForRole(this.deps.modelSlots, params.modelRole); + let mcp: CodexRuntimeMcpServerHandle | undefined; + try { + mcp = await mcpForTools({ + projectDir: this.deps.projectDir, + toolSet: params.toolSet, + startMcpServer: this.deps.startMcpServer, + }); + const config = buildCodexRuntimeConfig({ + model, + ...(mcp + ? { + mcp: { + url: mcp.url, + bearerTokenEnvVar: mcp.bearerTokenEnvVar, + bearerToken: mcp.bearerToken, + toolNames: runtimeToolNames(params.toolSet), + }, + } + : {}), + }); + const abortController = new AbortController(); + const onStep = async (stepIndex: number): Promise => { + try { + await params.onStepFinish?.({ stepIndex, stepBudget: params.stepBudget }); + } catch (error) { + this.logger.warn( + `[codex-runner] onStepFinish callback threw; ignoring: ${error instanceof Error ? error.message : String(error)}`, + ); + } + }; + const collected = await collectEvents( + await this.runner.runStreamed({ + projectDir: this.deps.projectDir, + model, + prompt: promptWithSystem(params.systemPrompt, params.userPrompt), + configOverrides: config.configOverrides, + env: config.env, + signal: abortController.signal, + }), + { stepBudget: params.stepBudget, abortController, onStep }, + ); + const summary = summarizeCodexExecEvents(collected.events, { startedAt }); + const error = summaryError(summary, collected.streamError); + const stopReason = collected.budgetExceeded ? 'budget' : error ? 'error' : summary.stopReason; + return { + stopReason, + ...(stopReason === 'error' && error ? { error } : {}), + metrics: { + totalMs: Date.now() - startedAt, + usage: summary.usage, + stepCount: summary.stepCount, + stepBoundariesMs: summary.stepBoundariesMs, + }, + }; + } catch (error) { + const err = error instanceof Error ? error : new Error(String(error)); + return { + stopReason: 'error', + error: err, + metrics: { totalMs: Date.now() - startedAt, usage: {}, stepCount: 0, stepBoundariesMs: [] }, + }; + } finally { + await mcp?.close(); + } + } +} + +// A rejected model is not an auth failure: Codex authenticated, connected, and +// the API refused the model id. These markers come from the API error envelope +// (e.g. "model is not supported", "invalid_request_error"). +const MODEL_UNAVAILABLE_MARKERS = + /\bnot supported\b|\bnot available\b|\bdoes not exist\b|invalid_request_error|\bunknown model\b|\bunsupported model\b/i; + +function describeCodexProbeFailure(model: string, message: string): { message: string; fix: string } { + if (MODEL_UNAVAILABLE_MARKERS.test(message)) { + const fix = `Run \`codex\` to see the models your account supports, then set llm.models.default in ktx.yaml (or rerun \`ktx setup\`).`; + return { + message: `Codex is authenticated, but the configured model "${model}" is not available for this Codex account. ${fix} Details: ${message}`, + fix, + }; + } + const fix = `Authenticate Codex locally with the Codex CLI, verify the Codex CLI is installed, then rerun setup or \`ktx status\`.`; + return { + message: `Codex authentication is not usable. ${fix} Details: ${message}`, + fix, + }; +} + +export async function runCodexAuthProbe(input: { + projectDir: string; + model: string; + runner?: CodexSdkRunner; +}): Promise<{ ok: true } | { ok: false; message: string; fix: string }> { + let model: string; + try { + model = resolveCodexModel(input.model); + } catch (error) { + return { + ok: false, + message: error instanceof Error ? error.message : String(error), + fix: 'Set llm.models.default in ktx.yaml to a supported codex model (codex, default, or a gpt-* / codex-* id), or rerun `ktx setup`.', + }; + } + + const runtime = new CodexKtxLlmRuntime({ + projectDir: input.projectDir, + modelSlots: { default: model }, + ...(input.runner ? { runner: input.runner } : {}), + }); + try { + await runtime.generateText({ role: 'default', prompt: 'Reply with exactly: ok' }); + return { ok: true }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return { ok: false, ...describeCodexProbeFailure(model, message) }; + } +} diff --git a/packages/cli/src/context/llm/codex-sdk-runner.ts b/packages/cli/src/context/llm/codex-sdk-runner.ts new file mode 100644 index 00000000..58170b3a --- /dev/null +++ b/packages/cli/src/context/llm/codex-sdk-runner.ts @@ -0,0 +1,96 @@ +import { Codex, type CodexOptions, type ThreadOptions, type TurnOptions } from '@openai/codex-sdk'; + +export interface CodexSdkRunnerInput { + projectDir: string; + model: string; + prompt: string; + configOverrides?: Record; + env?: Record; + outputSchema?: Record; + signal?: AbortSignal; +} + +export interface CodexSdkRunner { + runStreamed(input: CodexSdkRunnerInput): Promise>; +} + +type CodexThread = { + runStreamed(input: string, turnOptions?: TurnOptions): Promise<{ events: AsyncIterable }>; +}; + +type CodexClient = { + startThread(options: ThreadOptions): CodexThread; +}; + +type CodexConstructor = new (options?: CodexOptions) => CodexClient; + +export interface CodexSdkCliRunnerOptions { + envBase?: NodeJS.ProcessEnv; + codexPathOverride?: string; +} + +const CODEX_ENV_ALLOWLIST = new Set([ + 'HOME', + 'USERPROFILE', + 'APPDATA', + 'LOCALAPPDATA', + 'XDG_CONFIG_HOME', + 'CODEX_HOME', + 'CODEX_API_KEY', + 'OPENAI_API_KEY', + 'PATH', + 'Path', + 'SYSTEMROOT', + 'COMSPEC', + 'TMPDIR', + 'TMP', + 'TEMP', + 'SSL_CERT_FILE', + 'SSL_CERT_DIR', + 'NODE_EXTRA_CA_CERTS', + 'HTTPS_PROXY', + 'HTTP_PROXY', + 'ALL_PROXY', + 'NO_PROXY', +]); + +function buildCodexSdkEnv(baseEnv: NodeJS.ProcessEnv, overrides: Record | undefined): Record { + const env: Record = {}; + for (const key of CODEX_ENV_ALLOWLIST) { + const value = baseEnv[key]; + if (typeof value === 'string') { + env[key] = value; + } + } + return { ...env, ...(overrides ?? {}) }; +} + +export class CodexSdkCliRunner implements CodexSdkRunner { + constructor(private readonly options: CodexSdkCliRunnerOptions = {}) {} + + async runStreamed(input: CodexSdkRunnerInput): Promise> { + const CodexClass = Codex as CodexConstructor; + const codex = new CodexClass({ + ...(input.configOverrides ? { config: input.configOverrides as CodexOptions['config'] } : {}), + env: buildCodexSdkEnv(this.options.envBase ?? process.env, input.env), + ...(this.options.codexPathOverride ? { codexPathOverride: this.options.codexPathOverride } : {}), + }); + const thread = codex.startThread({ + workingDirectory: input.projectDir, + skipGitRepoCheck: true, + model: input.model, + sandboxMode: 'read-only', + webSearchMode: 'disabled', + approvalPolicy: 'never', + }); + const turnOptions: TurnOptions = { + ...(input.outputSchema ? { outputSchema: input.outputSchema } : {}), + ...(input.signal ? { signal: input.signal } : {}), + }; + const streamed = await thread.runStreamed( + input.prompt, + Object.keys(turnOptions).length > 0 ? turnOptions : undefined, + ); + return streamed.events; + } +} diff --git a/packages/cli/src/context/llm/local-config.ts b/packages/cli/src/context/llm/local-config.ts index c64a85cf..58bd29a5 100644 --- a/packages/cli/src/context/llm/local-config.ts +++ b/packages/cli/src/context/llm/local-config.ts @@ -5,6 +5,7 @@ import { resolveKtxConfigReference } from '../core/config-reference.js'; import type { KtxProjectEmbeddingConfig, KtxProjectLlmConfig } from '../project/config.js'; import { AiSdkKtxLlmRuntime } from './ai-sdk-runtime.js'; import { ClaudeCodeKtxLlmRuntime } from './claude-code-runtime.js'; +import { CodexKtxLlmRuntime } from './codex-runtime.js'; import type { KtxLlmRuntimePort } from './runtime-port.js'; interface LocalConfigDeps { @@ -13,6 +14,7 @@ interface LocalConfigDeps { createKtxLlmProvider?: typeof createKtxLlmProvider; createKtxEmbeddingProvider?: typeof createKtxEmbeddingProvider; createClaudeCodeRuntime?: (deps: ConstructorParameters[0]) => KtxLlmRuntimePort; + createCodexRuntime?: (deps: ConstructorParameters[0]) => KtxLlmRuntimePort; createAiSdkRuntime?: (deps: { llmProvider: KtxLlmProvider }) => KtxLlmRuntimePort; } @@ -104,7 +106,7 @@ export function createLocalKtxLlmProviderFromConfig( deps: LocalConfigDeps = {}, ): KtxLlmProvider | null { const resolved = resolveLocalKtxLlmConfig(config, deps.env ?? process.env); - if (!resolved || resolved.backend === 'claude-code') { + if (!resolved || resolved.backend === 'claude-code' || resolved.backend === 'codex') { return null; } return (deps.createKtxLlmProvider ?? createKtxLlmProvider)(resolved); @@ -129,6 +131,16 @@ export function createLocalKtxLlmRuntimeFromConfig( env: deps.env, }); } + if (resolved.backend === 'codex') { + const projectDir = deps.projectDir; + if (!projectDir) { + throw new Error('projectDir is required when creating the codex LLM runtime'); + } + return (deps.createCodexRuntime ?? ((runtimeDeps) => new CodexKtxLlmRuntime(runtimeDeps)))({ + projectDir, + modelSlots: resolved.modelSlots, + }); + } const llmProvider = (deps.createKtxLlmProvider ?? createKtxLlmProvider)(resolved); return (deps.createAiSdkRuntime ?? ((runtimeDeps) => new AiSdkKtxLlmRuntime(runtimeDeps)))({ llmProvider }); } diff --git a/packages/cli/src/context/project/config.ts b/packages/cli/src/context/project/config.ts index a8d38d1d..cbea79b6 100644 --- a/packages/cli/src/context/project/config.ts +++ b/packages/cli/src/context/project/config.ts @@ -3,7 +3,7 @@ import YAML from 'yaml'; import * as z from 'zod'; import { connectionConfigSchema } from './driver-schemas.js'; -const KTX_LLM_BACKENDS = ['none', 'anthropic', 'vertex', 'gateway', 'claude-code'] as const; +const KTX_LLM_BACKENDS = ['none', 'anthropic', 'vertex', 'gateway', 'claude-code', 'codex'] as const; const KTX_EMBEDDING_BACKENDS = ['none', 'openai', 'sentence-transformers'] as const; const KTX_PROMPT_CACHE_TTLS = ['5m', '1h'] as const; const KTX_ENRICHMENT_MODES = ['none', 'deterministic', 'llm'] as const; @@ -38,7 +38,7 @@ const llmProviderSchema = z .enum(KTX_LLM_BACKENDS) .default('none') .describe( - 'LLM provider backend. "none" disables LLM features; "anthropic" / "vertex" / "gateway" require the matching nested credentials block; "claude-code" uses the local Claude Code session.', + 'LLM provider backend. "none" disables LLM features; "anthropic" / "vertex" / "gateway" require the matching nested credentials block; "claude-code" uses the local Claude Code session; "codex" uses the local Codex session.', ), vertex: vertexProviderSchema.optional().describe('Vertex AI credentials, used when backend is "vertex".'), anthropic: apiCredentialsSchema.optional().describe('Anthropic API credentials, used when backend is "anthropic".'), diff --git a/packages/cli/src/llm/types.ts b/packages/cli/src/llm/types.ts index 3f7f67e2..a190b1c0 100644 --- a/packages/cli/src/llm/types.ts +++ b/packages/cli/src/llm/types.ts @@ -3,7 +3,7 @@ import type { LanguageModel, TelemetrySettings, ToolCallRepairFunction, ToolSet export const KTX_MODEL_ROLES = ['default', 'triage', 'candidateExtraction', 'curator', 'reconcile', 'repair'] as const; export type KtxModelRole = (typeof KTX_MODEL_ROLES)[number]; -type KtxLlmBackend = 'anthropic' | 'vertex' | 'gateway' | 'claude-code'; +type KtxLlmBackend = 'anthropic' | 'vertex' | 'gateway' | 'claude-code' | 'codex'; export type KtxPromptCacheTtl = '5m' | '1h'; type KtxJsonValue = diff --git a/packages/cli/src/setup-models.ts b/packages/cli/src/setup-models.ts index 041eef5c..8e8cf30b 100644 --- a/packages/cli/src/setup-models.ts +++ b/packages/cli/src/setup-models.ts @@ -3,6 +3,9 @@ import { writeFile } from 'node:fs/promises'; import { promisify } from 'node:util'; import { resolveLocalKtxLlmConfig } from './context/llm/local-config.js'; import { runClaudeCodeAuthProbe } from './context/llm/claude-code-runtime.js'; +import { formatCodexIsolationWarning } from './context/llm/codex-isolation.js'; +import { runCodexAuthProbe } from './context/llm/codex-runtime.js'; +import { DEFAULT_CODEX_MODEL } from './context/llm/codex-models.js'; import { resolveKtxConfigReference } from './context/core/config-reference.js'; import { type KtxProjectConfig, type KtxProjectLlmConfig, serializeKtxProjectConfig } from './context/project/config.js'; import { loadKtxProject } from './context/project/project.js'; @@ -56,7 +59,7 @@ export interface AnthropicModelChoice { recommended: boolean; } -export type KtxSetupLlmBackend = 'anthropic' | 'vertex' | 'claude-code'; +export type KtxSetupLlmBackend = 'anthropic' | 'vertex' | 'claude-code' | 'codex'; /** @internal */ export interface KtxSetupModelPromptAdapter { @@ -82,6 +85,7 @@ export interface KtxSetupModelDeps { model: string; env?: NodeJS.ProcessEnv; }) => Promise<{ ok: true } | { ok: false; message: string }>; + codexAuthProbe?: (input: { projectDir: string; model: string }) => Promise<{ ok: true } | { ok: false; message: string }>; readGcloudProject?: () => Promise; listGcloudProjects?: () => Promise; spinner?: () => KtxCliSpinner; @@ -110,6 +114,20 @@ const CLAUDE_CODE_MODELS: AnthropicModelChoice[] = [ { id: 'haiku', label: 'Claude Haiku', recommended: false }, ]; +// Curated Codex models from OpenAI's current lineup that work under both +// ChatGPT-account (subscription) and API-key auth. Intentionally omitted: +// the `*-codex` ids (e.g. gpt-5.3-codex, gpt-5.2-codex) are API-key-only and +// fail on ChatGPT-account auth, and gpt-5.3-codex-spark is a ChatGPT-Pro-only +// research preview. Codex resolves real availability per account at runtime +// (its binary remote-fetches the model list), so this is a convenience +// shortlist only — the manual-entry option accepts any id your account's +// `codex` picker exposes, and the auth probe reports an unsupported choice. +const CODEX_MODELS: AnthropicModelChoice[] = [ + { id: 'gpt-5.5', label: 'GPT-5.5', recommended: true }, + { id: 'gpt-5.4', label: 'GPT-5.4', recommended: false }, + { id: 'gpt-5.4-mini', label: 'GPT-5.4 mini', recommended: false }, +]; + const HIDDEN_ANTHROPIC_MODEL_PATTERNS = [ /^claude-sonnet-4$/i, /^claude-opus-4$/i, @@ -272,7 +290,12 @@ export function isKtxSetupLlmConfigReady(config: KtxProjectLlmConfig): boolean { return typeof resolved.vertex?.location === 'string' && resolved.vertex.location.trim().length > 0; } - return resolved.backend === 'anthropic' || resolved.backend === 'gateway' || resolved.backend === 'claude-code'; + return ( + resolved.backend === 'anthropic' || + resolved.backend === 'gateway' || + resolved.backend === 'claude-code' || + resolved.backend === 'codex' + ); } function hasUsableConfiguredLlm(config: KtxProjectConfig): boolean { @@ -284,7 +307,8 @@ function buildProjectLlmConfig( provider: | { backend: 'anthropic'; credentialRef: string } | { backend: 'vertex'; vertex: { project?: string; location: string } } - | { backend: 'claude-code' }, + | { backend: 'claude-code' } + | { backend: 'codex' }, model: string, ): KtxProjectLlmConfig { if (provider.backend === 'claude-code') { @@ -295,6 +319,14 @@ function buildProjectLlmConfig( }; } + if (provider.backend === 'codex') { + return { + provider: { backend: 'codex' }, + models: { ...existing.models, default: model }, + promptCaching: existing.promptCaching, + }; + } + if (provider.backend === 'vertex') { return { provider: { @@ -515,6 +547,7 @@ async function chooseBackend( message: 'Which LLM provider should KTX use?', options: [ { value: 'claude-code', label: 'Claude subscription (Pro/Max)' }, + { value: 'codex', label: 'Codex subscription' }, { value: 'anthropic', label: 'Anthropic API key' }, { value: 'vertex', label: 'Google Vertex AI for Anthropic Claude' }, { value: 'back', label: 'Back' }, @@ -525,7 +558,7 @@ async function chooseBackend( } return { status: 'ready', - backend: choice === 'vertex' || choice === 'claude-code' ? choice : 'anthropic', + backend: choice === 'vertex' || choice === 'claude-code' || choice === 'codex' ? choice : 'anthropic', prompted: true, }; } @@ -884,12 +917,51 @@ async function chooseClaudeCodeModel(args: KtxSetupModelArgs, deps: KtxSetupMode return { status: 'ready', model: choice }; } +async function chooseCodexModel(args: KtxSetupModelArgs, deps: KtxSetupModelDeps): Promise { + const providedModel = requestedModel(args); + if (providedModel) { + return { status: 'ready', model: providedModel }; + } + if (args.inputMode === 'disabled') { + return { status: 'ready', model: DEFAULT_CODEX_MODEL }; + } + + const prompts = deps.prompts ?? createPromptAdapter(); + const choice = await prompts.select({ + message: `Which Codex model should KTX use?\n\n${ANTHROPIC_MODEL_PROMPT_CONTEXT}`, + options: [ + ...CODEX_MODELS.map((model) => ({ + value: model.id, + label: model.label, + ...(model.recommended ? { hint: 'recommended' } : {}), + })), + { value: 'manual', label: 'Enter a Codex model ID manually' }, + { value: 'back', label: 'Back' }, + ], + }); + if (choice === 'back') { + return { status: 'back' }; + } + if (choice === 'manual') { + const manual = await prompts.text({ + message: withTextInputNavigation('Codex model ID'), + placeholder: CODEX_MODELS.find((model) => model.recommended)?.id ?? CODEX_MODELS[0]?.id, + }); + if (manual === undefined) { + return { status: 'back' }; + } + return manual.trim() ? { status: 'ready', model: manual.trim() } : { status: 'missing-input' }; + } + return { status: 'ready', model: choice }; +} + async function persistLlmConfig( projectDir: string, provider: | { backend: 'anthropic'; credentialRef: string } | { backend: 'vertex'; vertex: { project?: string; location: string } } - | { backend: 'claude-code' }, + | { backend: 'claude-code' } + | { backend: 'codex' }, model: string, ): Promise { const project = await loadKtxProject({ projectDir }); @@ -1031,6 +1103,32 @@ export async function runKtxSetupAnthropicModelStep( return { status: 'ready', projectDir: args.projectDir }; } + if (backendChoice.backend === 'codex') { + const model = await chooseCodexModel(backendArgs, deps); + if (model.status === 'back' && backendChoice.prompted) { + attemptArgs = buildInteractiveRetryArgs(args); + continue; + } + if (model.status === 'invalid-credential') { + return { status: 'failed', projectDir: args.projectDir }; + } + if (model.status !== 'ready') { + return { status: model.status, projectDir: args.projectDir }; + } + const probe = deps.codexAuthProbe ?? runCodexAuthProbe; + const health = await probe({ projectDir: args.projectDir, model: model.model }); + if (!health.ok) { + io.stderr.write(`${health.message}\n`); + return { status: 'failed', projectDir: args.projectDir }; + } + // Prefix the clack gutter so the warning sits inside the setup frame + // instead of breaking out of it; kept on stderr for scripted runs. + io.stderr.write(`│ ${formatCodexIsolationWarning()}\n`); + await persistLlmConfig(args.projectDir, { backend: 'codex' }, model.model); + io.stdout.write(`│ LLM ready: yes (codex, ${model.model})\n`); + return { status: 'ready', projectDir: args.projectDir }; + } + const credential = await chooseCredentialRef(backendArgs, io, deps); if (credential.status === 'back' && backendChoice.prompted) { attemptArgs = buildInteractiveRetryArgs(args); diff --git a/packages/cli/src/status-project.ts b/packages/cli/src/status-project.ts index 097f4091..ff7b98f4 100644 --- a/packages/cli/src/status-project.ts +++ b/packages/cli/src/status-project.ts @@ -1,6 +1,11 @@ import { stat as statAsync, readdir as readdirAsync } from 'node:fs/promises'; import { basename, join } from 'node:path'; import { runClaudeCodeAuthProbe } from './context/llm/claude-code-runtime.js'; +import { + CODEX_ISOLATION_WARNING, + CODEX_ISOLATION_WARNING_FIX, +} from './context/llm/codex-isolation.js'; +import { runCodexAuthProbe } from './context/llm/codex-runtime.js'; import type { KtxConfigIssue, KtxProjectConfig, KtxProjectConnectionConfig, KtxProjectEmbeddingConfig, KtxProjectLlmConfig } from './context/project/config.js'; import type { KtxLocalProject } from './context/project/project.js'; import { ktxLocalStateDbPath } from './context/project/local-state-db.js'; @@ -94,6 +99,11 @@ type ClaudeCodeAuthProbe = (input: { env?: NodeJS.ProcessEnv; }) => Promise<{ ok: true } | { ok: false; message: string }>; +type CodexAuthProbe = (input: { + projectDir: string; + model: string; +}) => Promise<{ ok: true } | { ok: false; message: string; fix: string }>; + const PROJECT_READY_COMMANDS = KTX_NEXT_STEP_DIRECT_COMMANDS.map((step) => step.command); interface LocalStatsIngestPerConnection { @@ -194,6 +204,7 @@ async function buildLlmStatus( projectDir: string; env: NodeJS.ProcessEnv; claudeCodeAuthProbe?: ClaudeCodeAuthProbe; + codexAuthProbe?: CodexAuthProbe; fast?: boolean; useSpinner?: boolean; }, @@ -210,6 +221,18 @@ async function buildLlmStatus( fix: 'Run: ktx setup (choose an LLM provider)', }; } + // The runtime (resolveModelSlots) hard-requires llm.models.default for every + // non-none backend; without it ingest/scan/memory throw. Report that here so + // status never marks a project ready that the runtime would refuse to run. + if (!model || model.trim().length === 0) { + return { + backend, + model, + status: 'fail', + detail: `llm.models.default is required for backend "${backend}"`, + fix: 'Set llm.models.default in ktx.yaml, then rerun `ktx status` (or rerun `ktx setup`).', + }; + } if (backend === 'anthropic') { const ref = config.provider.anthropic?.api_key; const resolved = resolveRef(ref, env); @@ -251,7 +274,7 @@ async function buildLlmStatus( }; } if (backend === 'claude-code') { - const modelName = model ?? 'sonnet'; + const modelName = model; if (options.fast === true) { return { backend, @@ -280,6 +303,36 @@ async function buildLlmStatus( fix: 'Authenticate Claude Code locally with the Claude Code CLI, then rerun `ktx status`.', }; } + if (backend === 'codex') { + const modelName = model; + if (options.fast === true) { + return { + backend, + model: modelName, + status: 'skipped', + detail: 'auth probe skipped (--fast)', + }; + } + const probe = options.codexAuthProbe ?? runCodexAuthProbe; + const auth = await withSpinner(options.useSpinner === true, 'Probing Codex authentication', () => + probe({ projectDir: options.projectDir, model: modelName }), + ); + if (auth.ok) { + return { + backend, + model: modelName, + status: 'ok', + detail: 'local Codex session authenticated', + }; + } + return { + backend, + model: modelName, + status: 'fail', + detail: auth.message, + fix: auth.fix, + }; + } return { backend, model, status: 'warn', detail: 'unknown LLM backend' }; } @@ -572,6 +625,13 @@ function buildWarnings( }); } + if (llm.backend === 'codex') { + warnings.push({ + message: CODEX_ISOLATION_WARNING, + fix: CODEX_ISOLATION_WARNING_FIX, + }); + } + return warnings; } @@ -634,6 +694,7 @@ export interface BuildProjectStatusOptions { env?: NodeJS.ProcessEnv; queryHistoryReadinessProbe?: HistoricSqlReadinessProbe; claudeCodeAuthProbe?: ClaudeCodeAuthProbe; + codexAuthProbe?: CodexAuthProbe; configIssues?: KtxConfigIssue[]; fast?: boolean; useSpinner?: boolean; @@ -882,6 +943,7 @@ export async function buildProjectStatus(project: KtxLocalProject, options: Buil projectDir: project.projectDir, env, claudeCodeAuthProbe: options.claudeCodeAuthProbe, + codexAuthProbe: options.codexAuthProbe, fast: options.fast, useSpinner: options.useSpinner, }); diff --git a/packages/cli/test/context/ingest/local-bundle-runtime.test.ts b/packages/cli/test/context/ingest/local-bundle-runtime.test.ts index 64fad53a..9d1ec9b4 100644 --- a/packages/cli/test/context/ingest/local-bundle-runtime.test.ts +++ b/packages/cli/test/context/ingest/local-bundle-runtime.test.ts @@ -77,9 +77,10 @@ describe('createLocalBundleIngestRuntime', () => { }), ).toThrow( [ - 'ktx ingest requires llm.provider.backend: anthropic, vertex, gateway, or claude-code, or an injected agentRunner.', - 'Configure a local Claude Code session or API-backed LLM, then rerun ingest:', + 'ktx ingest requires llm.provider.backend: anthropic, vertex, gateway, claude-code, or codex, or an injected agentRunner.', + 'Configure a local Claude Code/Codex session or API-backed LLM, then rerun ingest:', ` ktx setup --project-dir ${project.projectDir} --llm-backend claude-code --no-input`, + ` ktx setup --project-dir ${project.projectDir} --llm-backend codex --llm-model gpt-5.5 --no-input`, ` ktx setup --project-dir ${project.projectDir} --llm-backend anthropic --anthropic-api-key-env ANTHROPIC_API_KEY --llm-model claude-sonnet-4-6 --no-input`, ].join('\n'), ); diff --git a/packages/cli/test/context/llm/codex-exec-events.test.ts b/packages/cli/test/context/llm/codex-exec-events.test.ts new file mode 100644 index 00000000..5edcfed8 --- /dev/null +++ b/packages/cli/test/context/llm/codex-exec-events.test.ts @@ -0,0 +1,188 @@ +import { describe, expect, it } from 'vitest'; +import { + parseCodexExecEventLine, + summarizeCodexExecEvents, +} from '../../../src/context/llm/codex-exec-events.js'; + +describe('Codex exec event parsing', () => { + it('uses the completed turn as one step when no MCP tools run', () => { + const summary = summarizeCodexExecEvents( + [ + { type: 'thread.started', thread_id: 'thr_1' }, + { type: 'turn.started' }, + { type: 'item.completed', item: { id: 'item_1', type: 'agent_message', text: 'hello from codex' } }, + { + type: 'turn.completed', + usage: { + input_tokens: 12, + cached_input_tokens: 4, + output_tokens: 5, + reasoning_output_tokens: 2, + }, + }, + ], + { startedAt: 100, now: () => 125 }, + ); + + expect(summary).toEqual({ + finalText: 'hello from codex', + stopReason: 'natural', + usage: { inputTokens: 12, outputTokens: 5, totalTokens: 17 }, + stepCount: 1, + stepBoundariesMs: [25], + toolCallCount: 0, + toolFailures: [], + }); + }); + + it('uses completed MCP tool calls as loop steps', () => { + const offsets = [115, 140, 175]; + const summary = summarizeCodexExecEvents( + [ + { type: 'turn.started' }, + { + type: 'item.started', + item: { id: 'call_1', type: 'mcp_tool_call', server: 'ktx', tool: 'search', arguments: {}, status: 'in_progress' }, + }, + { + type: 'item.completed', + item: { id: 'call_1', type: 'mcp_tool_call', server: 'ktx', tool: 'search', arguments: {}, status: 'completed' }, + }, + { + type: 'item.started', + item: { id: 'call_2', type: 'mcp_tool_call', server: 'ktx', tool: 'lookup', arguments: {}, status: 'in_progress' }, + }, + { + type: 'item.completed', + item: { + id: 'call_2', + type: 'mcp_tool_call', + server: 'ktx', + tool: 'lookup', + arguments: {}, + status: 'failed', + error: { message: 'denied' }, + }, + }, + { type: 'item.completed', item: { id: 'item_1', type: 'agent_message', text: 'done' } }, + { type: 'turn.completed', usage: { input_tokens: 1, output_tokens: 1, cached_input_tokens: 0, reasoning_output_tokens: 0 } }, + ], + { startedAt: 100, now: () => offsets.shift() ?? 175 }, + ); + + expect(summary).toEqual({ + finalText: 'done', + stopReason: 'natural', + usage: { inputTokens: 1, outputTokens: 1, totalTokens: 2 }, + stepCount: 2, + stepBoundariesMs: [15, 40], + toolCallCount: 2, + toolFailures: ['lookup: denied'], + }); + }); + + it('does not treat a completed MCP tool call as failed when Codex sends error: null', () => { + // Captured verbatim from a real @openai/codex-sdk run: successful tool calls + // carry `error: null` and `result` alongside `status: "completed"`. + const summary = summarizeCodexExecEvents([ + { type: 'turn.started' }, + { + type: 'item.started', + item: { + id: 'item_1', + type: 'mcp_tool_call', + server: 'ktx', + tool: 'echo_value', + arguments: { value: 'ktx_codex_tool_ok' }, + result: null, + error: null, + status: 'in_progress', + }, + }, + { + type: 'item.completed', + item: { + id: 'item_1', + type: 'mcp_tool_call', + server: 'ktx', + tool: 'echo_value', + arguments: { value: 'ktx_codex_tool_ok' }, + result: { content: [{ type: 'text', text: 'echo:ktx_codex_tool_ok' }], structured_content: null }, + error: null, + status: 'completed', + }, + }, + { type: 'item.completed', item: { id: 'm1', type: 'agent_message', text: 'done' } }, + { type: 'turn.completed', usage: { input_tokens: 1, output_tokens: 1 } }, + ]); + + expect(summary.toolFailures).toEqual([]); + expect(summary.toolCallCount).toBe(1); + }); + + it('counts built-in command executions as loop steps without failing the loop', () => { + const offsets = [110, 130]; + const summary = summarizeCodexExecEvents( + [ + { type: 'turn.started' }, + { type: 'item.completed', item: { id: 'c1', type: 'command_execution', command: 'ls', status: 'completed', exit_code: 0 } }, + { type: 'item.completed', item: { id: 'c2', type: 'command_execution', command: 'cat missing', status: 'failed', exit_code: 1 } }, + { type: 'item.completed', item: { id: 'm1', type: 'agent_message', text: 'done' } }, + { type: 'turn.completed', usage: { input_tokens: 2, output_tokens: 1 } }, + ], + { startedAt: 100, now: () => offsets.shift() ?? 130 }, + ); + + expect(summary.stepCount).toBe(2); + expect(summary.stepBoundariesMs).toEqual([10, 30]); + // A non-zero command exit is normal agent exploration, not a runtime tool failure. + expect(summary.toolFailures).toEqual([]); + expect(summary.toolCallCount).toBe(0); + }); + + it('maps turn failures into error stop reason', () => { + const summary = summarizeCodexExecEvents([ + { type: 'turn.started' }, + { type: 'turn.failed', error: { message: 'Codex could not connect to required MCP server' } }, + ]); + + expect(summary.stopReason).toBe('error'); + expect(summary.error?.message).toContain('Codex could not connect to required MCP server'); + }); + + it('unwraps the Codex API error envelope into its human-readable message', () => { + // Codex serializes API errors as a JSON envelope inside the event message. + const apiError = JSON.stringify({ + type: 'error', + status: 400, + error: { + type: 'invalid_request_error', + message: "The 'gpt-5.3-codex' model is not supported when using Codex with a ChatGPT account.", + }, + }); + const summary = summarizeCodexExecEvents([ + { type: 'thread.started', thread_id: 'thr_1' }, + { type: 'turn.started' }, + { type: 'error', message: apiError }, + { type: 'turn.failed', error: { message: apiError } }, + ]); + + expect(summary.stopReason).toBe('error'); + expect(summary.error?.message).toBe( + "The 'gpt-5.3-codex' model is not supported when using Codex with a ChatGPT account.", + ); + }); + + it('maps max-turns terminal reasons into budget stop reason when Codex emits one', () => { + const summary = summarizeCodexExecEvents([ + { type: 'turn.started' }, + { type: 'turn.completed', reason: 'max_turns', usage: { input_tokens: 1, output_tokens: 1 } }, + ]); + + expect(summary.stopReason).toBe('budget'); + }); + + it('throws a clear error for malformed JSONL lines', () => { + expect(() => parseCodexExecEventLine('{not-json')).toThrow('Codex JSONL event stream was malformed'); + }); +}); diff --git a/packages/cli/test/context/llm/codex-isolation.test.ts b/packages/cli/test/context/llm/codex-isolation.test.ts new file mode 100644 index 00000000..0ef39ee3 --- /dev/null +++ b/packages/cli/test/context/llm/codex-isolation.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, it } from 'vitest'; +import { + CODEX_ISOLATION_WARNING, + CODEX_ISOLATION_WARNING_FIX, + formatCodexIsolationWarning, +} from '../../../src/context/llm/codex-isolation.js'; + +describe('Codex isolation warning', () => { + it('documents the enforced and unenforced Codex isolation boundaries', () => { + expect(CODEX_ISOLATION_WARNING).toContain('runtime MCP server to the current ktx tool set'); + expect(CODEX_ISOLATION_WARNING).toContain('disables Codex web search'); + expect(CODEX_ISOLATION_WARNING).toContain('may still load user Codex config'); + expect(CODEX_ISOLATION_WARNING).toContain('built-in command execution'); + expect(CODEX_ISOLATION_WARNING_FIX).toContain('claude-code'); + expect(formatCodexIsolationWarning()).toBe( + `${CODEX_ISOLATION_WARNING} ${CODEX_ISOLATION_WARNING_FIX}`, + ); + }); +}); diff --git a/packages/cli/test/context/llm/codex-mcp-runtime-server.test.ts b/packages/cli/test/context/llm/codex-mcp-runtime-server.test.ts new file mode 100644 index 00000000..c793afb7 --- /dev/null +++ b/packages/cli/test/context/llm/codex-mcp-runtime-server.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, it, vi } from 'vitest'; +import { z } from 'zod'; +import { + createCodexRuntimeMcpServer, + startCodexRuntimeMcpServer, +} from '../../../src/context/llm/codex-mcp-runtime-server.js'; + +describe('Codex runtime MCP server', () => { + it('registers runtime tools with markdown output', async () => { + const registered = new Map< + string, + { + config: { description?: string; inputSchema: unknown }; + handler: (input: Record) => Promise; + } + >(); + const server = createCodexRuntimeMcpServer({ + server: { + registerTool(name, config, handler) { + registered.set(name, { config, handler }); + }, + }, + toolSet: { + wiki_search: { + name: 'wiki_search', + description: 'Search the wiki', + inputSchema: z.object({ query: z.string() }), + execute: vi.fn(async () => ({ markdown: 'result markdown', structured: { matches: 1 } })), + }, + }, + }); + + expect(server).toBeDefined(); + expect([...registered.keys()]).toEqual(['wiki_search']); + expect(registered.get('wiki_search')?.config).toMatchObject({ + description: 'Search the wiki', + }); + await expect(registered.get('wiki_search')?.handler({ query: 'revenue' })).resolves.toEqual({ + content: [{ type: 'text', text: 'result markdown' }], + structuredContent: { matches: 1 }, + }); + }); + + it('starts loopback HTTP MCP with a bearer token and reports the runtime URL', async () => { + const close = vi.fn(async () => undefined); + const runServer = vi.fn(async () => ({ + server: { address: () => ({ port: 4321 }) }, + close, + })); + + const handle = await startCodexRuntimeMcpServer({ + projectDir: '/tmp/ktx-project', + toolSet: {}, + runServer: runServer as never, + }); + + expect(handle.url).toBe('http://127.0.0.1:4321/mcp'); + expect(handle.bearerTokenEnvVar).toBe('KTX_CODEX_RUNTIME_MCP_TOKEN'); + expect(handle.bearerToken).toMatch(/^[a-f0-9]{64}$/); + expect(runServer).toHaveBeenCalledWith( + expect.objectContaining({ + projectDir: '/tmp/ktx-project', + host: '127.0.0.1', + port: 0, + token: handle.bearerToken, + allowedHosts: ['127.0.0.1', 'localhost'], + allowedOrigins: [], + }), + ); + await handle.close(); + expect(close).toHaveBeenCalled(); + }); +}); diff --git a/packages/cli/test/context/llm/codex-models.test.ts b/packages/cli/test/context/llm/codex-models.test.ts new file mode 100644 index 00000000..83a1e2c8 --- /dev/null +++ b/packages/cli/test/context/llm/codex-models.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, it } from 'vitest'; +import { resolveCodexModel } from '../../../src/context/llm/codex-models.js'; + +describe('resolveCodexModel', () => { + it.each([ + ['codex', 'gpt-5.5'], + ['default', 'gpt-5.5'], + ['gpt-5.3-codex-spark', 'gpt-5.3-codex-spark'], + ['gpt-5.4', 'gpt-5.4'], + ])('maps %s to %s', (input, expected) => { + expect(resolveCodexModel(input)).toBe(expected); + }); + + it.each(['', ' ', 'sonnet', 'claude-sonnet-4-6'])('rejects %s', (input) => { + expect(() => resolveCodexModel(input)).toThrow('Unsupported Codex model'); + }); +}); diff --git a/packages/cli/test/context/llm/codex-runtime-config.test.ts b/packages/cli/test/context/llm/codex-runtime-config.test.ts new file mode 100644 index 00000000..97c80446 --- /dev/null +++ b/packages/cli/test/context/llm/codex-runtime-config.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from 'vitest'; +import { buildCodexRuntimeConfig } from '../../../src/context/llm/codex-runtime-config.js'; + +describe('buildCodexRuntimeConfig', () => { + it('builds generic config without SDK thread-option fields', () => { + expect(buildCodexRuntimeConfig({ model: 'gpt-5.3-codex' })).toEqual({ + configOverrides: { + history: { persistence: 'none' }, + }, + env: {}, + }); + }); + + it('adds only the temporary ktx MCP server and exact enabled tools', () => { + expect( + buildCodexRuntimeConfig({ + model: 'gpt-5.3-codex', + mcp: { + url: 'http://127.0.0.1:4567/mcp', + bearerTokenEnvVar: 'KTX_CODEX_RUNTIME_MCP_TOKEN', + bearerToken: 'secret-token', + toolNames: ['sl_read_source', 'wiki_search'], + }, + }), + ).toEqual({ + configOverrides: { + history: { persistence: 'none' }, + mcp_servers: { + ktx: { + url: 'http://127.0.0.1:4567/mcp', + bearer_token_env_var: 'KTX_CODEX_RUNTIME_MCP_TOKEN', + enabled_tools: ['sl_read_source', 'wiki_search'], + default_tools_approval_mode: 'approve', + required: true, + }, + }, + }, + env: { + KTX_CODEX_RUNTIME_MCP_TOKEN: 'secret-token', + }, + }); + }); +}); diff --git a/packages/cli/test/context/llm/codex-runtime.test.ts b/packages/cli/test/context/llm/codex-runtime.test.ts new file mode 100644 index 00000000..2d408543 --- /dev/null +++ b/packages/cli/test/context/llm/codex-runtime.test.ts @@ -0,0 +1,460 @@ +import { describe, expect, it, vi } from 'vitest'; +import { z } from 'zod'; +import { + CodexKtxLlmRuntime, + runCodexAuthProbe, +} from '../../../src/context/llm/codex-runtime.js'; + +async function* events(items: unknown[]) { + for (const item of items) { + yield item; + } +} + +function runner(items: unknown[]) { + return { + runStreamed: vi.fn(async () => events(items)), + }; +} + +/** Yields the given events, then throws — mirroring the SDK throwing on a non-zero codex exec exit. */ +function throwingRunner(items: unknown[], error: Error) { + return { + runStreamed: vi.fn(async () => + (async function* () { + for (const item of items) { + yield item; + } + throw error; + })(), + ), + }; +} + +const MODEL_UNSUPPORTED_API_ERROR = JSON.stringify({ + type: 'error', + status: 400, + error: { + type: 'invalid_request_error', + message: "The 'gpt-5.3-codex' model is not supported when using Codex with a ChatGPT account.", + }, +}); + +function budgetRunner() { + let observedSignal: AbortSignal | undefined; + return { + observedSignal: () => observedSignal, + runStreamed: vi.fn(async (input: { signal?: AbortSignal }) => { + observedSignal = input.signal; + return events([ + { type: 'turn.started' }, + { type: 'item.started', item: { type: 'mcp_tool_call', server: 'ktx', tool: 'first', status: 'in_progress' } }, + { type: 'item.completed', item: { type: 'mcp_tool_call', server: 'ktx', tool: 'first', status: 'completed' } }, + { type: 'item.started', item: { type: 'mcp_tool_call', server: 'ktx', tool: 'second', status: 'in_progress' } }, + { type: 'item.completed', item: { type: 'mcp_tool_call', server: 'ktx', tool: 'second', status: 'completed' } }, + { type: 'turn.completed', usage: { input_tokens: 1, output_tokens: 1 } }, + ]); + }), + }; +} + +describe('CodexKtxLlmRuntime', () => { + it('generates text with the role-selected model and metrics', async () => { + const onMetrics = vi.fn(); + const fakeRunner = runner([ + { type: 'turn.started' }, + { type: 'item.completed', item: { type: 'agent_message', text: 'hello' } }, + { type: 'turn.completed', usage: { input_tokens: 3, output_tokens: 4, total_tokens: 7 } }, + ]); + const runtime = new CodexKtxLlmRuntime({ + projectDir: '/tmp/project', + modelSlots: { default: 'codex', triage: 'gpt-5.4' }, + runner: fakeRunner, + }); + + await expect(runtime.generateText({ role: 'triage', system: 'system', prompt: 'prompt', onMetrics })).resolves.toBe('hello'); + expect(fakeRunner.runStreamed).toHaveBeenCalledWith( + expect.objectContaining({ + projectDir: '/tmp/project', + model: 'gpt-5.4', + prompt: 'system\n\nprompt', + }), + ); + expect(onMetrics).toHaveBeenCalledWith(expect.objectContaining({ usage: { inputTokens: 3, outputTokens: 4, totalTokens: 7 } })); + }); + + it('generates and validates structured output', async () => { + const fakeRunner = runner([ + { type: 'turn.started' }, + { type: 'item.completed', item: { type: 'agent_message', text: '{"answer":"yes"}' } }, + { type: 'turn.completed' }, + ]); + const runtime = new CodexKtxLlmRuntime({ + projectDir: '/tmp/project', + modelSlots: { default: 'codex' }, + runner: fakeRunner, + }); + + await expect( + runtime.generateObject({ + role: 'default', + prompt: 'json', + schema: z.object({ answer: z.string() }), + }), + ).resolves.toEqual({ answer: 'yes' }); + expect(fakeRunner.runStreamed).toHaveBeenCalledWith( + expect.objectContaining({ + outputSchema: expect.objectContaining({ type: 'object' }), + }), + ); + }); + + it('returns a structured-output error when Codex final text is invalid JSON', async () => { + const fakeRunner = runner([ + { type: 'turn.started' }, + { type: 'item.completed', item: { type: 'agent_message', text: 'not json' } }, + { type: 'turn.completed' }, + ]); + const runtime = new CodexKtxLlmRuntime({ + projectDir: '/tmp/project', + modelSlots: { default: 'codex' }, + runner: fakeRunner, + }); + + await expect( + runtime.generateObject({ + role: 'default', + prompt: 'json', + schema: z.object({ answer: z.string() }), + }), + ).rejects.toThrow('Codex structured output failed validation'); + }); + + it('starts and closes a temporary MCP server for tool-backed agent loops', async () => { + const close = vi.fn(async () => undefined); + const startMcpServer = vi.fn(async () => ({ + url: 'http://127.0.0.1:4321/mcp', + bearerTokenEnvVar: 'KTX_CODEX_RUNTIME_MCP_TOKEN' as const, + bearerToken: 'token', + close, + })); + const fakeRunner = runner([ + { type: 'turn.started' }, + { type: 'item.started', item: { type: 'mcp_tool_call', name: 'wiki_search' } }, + { type: 'item.completed', item: { type: 'agent_message', text: 'done' } }, + { type: 'turn.completed', usage: { input_tokens: 1, output_tokens: 1, total_tokens: 2 } }, + ]); + const runtime = new CodexKtxLlmRuntime({ + projectDir: '/tmp/project', + modelSlots: { default: 'codex' }, + runner: fakeRunner, + startMcpServer, + }); + const onStepFinish = vi.fn(); + + const result = await runtime.runAgentLoop({ + modelRole: 'default', + systemPrompt: 'system', + userPrompt: 'user', + stepBudget: 5, + telemetryTags: {}, + onStepFinish, + toolSet: { + aliased_wiki_tool: { + name: 'wiki_search', + description: 'Search wiki', + inputSchema: z.object({ query: z.string() }), + execute: vi.fn(), + }, + }, + }); + + expect(result.stopReason).toBe('natural'); + expect(result.metrics).toMatchObject({ stepCount: 1, usage: { inputTokens: 1, outputTokens: 1, totalTokens: 2 } }); + expect(onStepFinish).toHaveBeenCalledWith({ stepIndex: 1, stepBudget: 5 }); + expect(startMcpServer).toHaveBeenCalledWith({ projectDir: '/tmp/project', toolSet: expect.any(Object) }); + expect(fakeRunner.runStreamed).toHaveBeenCalledWith( + expect.objectContaining({ + env: { KTX_CODEX_RUNTIME_MCP_TOKEN: 'token' }, + configOverrides: expect.objectContaining({ + mcp_servers: expect.objectContaining({ + ktx: expect.objectContaining({ + url: 'http://127.0.0.1:4321/mcp', + enabled_tools: ['wiki_search'], + required: true, + }), + }), + }), + }), + ); + expect(close).toHaveBeenCalled(); + }); + + it('returns error stop reason on turn failure', async () => { + const runtime = new CodexKtxLlmRuntime({ + projectDir: '/tmp/project', + modelSlots: { default: 'codex' }, + runner: runner([{ type: 'turn.failed', error: { message: 'boom' } }]), + }); + + const result = await runtime.runAgentLoop({ + modelRole: 'default', + systemPrompt: 'system', + userPrompt: 'user', + stepBudget: 5, + telemetryTags: {}, + toolSet: {}, + }); + + expect(result.stopReason).toBe('error'); + expect(result.error?.message).toBe('boom'); + }); + + it('surfaces failed MCP tool calls as agent-loop errors', async () => { + const runtime = new CodexKtxLlmRuntime({ + projectDir: '/tmp/project', + modelSlots: { default: 'codex' }, + runner: runner([ + { type: 'turn.started' }, + { type: 'item.started', item: { type: 'mcp_tool_call', server: 'ktx', tool: 'search', status: 'in_progress' } }, + { + type: 'item.completed', + item: { + type: 'mcp_tool_call', + server: 'ktx', + tool: 'search', + status: 'failed', + error: { message: 'denied' }, + }, + }, + { type: 'turn.completed', usage: { input_tokens: 1, output_tokens: 1 } }, + ]), + }); + + const result = await runtime.runAgentLoop({ + modelRole: 'default', + systemPrompt: 'system', + userPrompt: 'user', + stepBudget: 5, + telemetryTags: {}, + toolSet: {}, + }); + + expect(result.stopReason).toBe('error'); + expect(result.error?.message).toBe('Codex runtime tool call failed: search: denied'); + expect(result.metrics).toMatchObject({ + stepCount: 1, + usage: { inputTokens: 1, outputTokens: 1, totalTokens: 2 }, + }); + }); + + it('returns budget and aborts the Codex stream when local MCP step budget is reached', async () => { + const fakeRunner = budgetRunner(); + const runtime = new CodexKtxLlmRuntime({ + projectDir: '/tmp/project', + modelSlots: { default: 'codex' }, + runner: fakeRunner, + }); + const onStepFinish = vi.fn(); + + const result = await runtime.runAgentLoop({ + modelRole: 'default', + systemPrompt: 'system', + userPrompt: 'user', + stepBudget: 1, + telemetryTags: {}, + onStepFinish, + toolSet: { + first: { + name: 'first', + description: 'First tool', + inputSchema: z.object({}), + execute: vi.fn(), + }, + }, + }); + + expect(result.stopReason).toBe('budget'); + expect(result.error).toBeUndefined(); + expect(result.metrics).toMatchObject({ stepCount: 1 }); + expect(onStepFinish).toHaveBeenCalledTimes(1); + expect(onStepFinish).toHaveBeenCalledWith({ stepIndex: 1, stepBudget: 1 }); + expect(fakeRunner.observedSignal()?.aborted).toBe(true); + }); + + it('counts built-in command_execution steps against the budget and aborts the stream', async () => { + let observedSignal: AbortSignal | undefined; + const fakeRunner = { + observedSignal: () => observedSignal, + runStreamed: vi.fn(async (input: { signal?: AbortSignal }) => { + observedSignal = input.signal; + return events([ + { type: 'turn.started' }, + { type: 'item.started', item: { type: 'command_execution', command: 'ls', status: 'in_progress' } }, + { type: 'item.completed', item: { type: 'command_execution', command: 'ls', status: 'completed', exit_code: 0 } }, + { type: 'item.started', item: { type: 'command_execution', command: 'cat a', status: 'in_progress' } }, + { type: 'item.completed', item: { type: 'command_execution', command: 'cat a', status: 'completed', exit_code: 0 } }, + { type: 'item.completed', item: { type: 'command_execution', command: 'cat b', status: 'completed', exit_code: 0 } }, + { type: 'turn.completed', usage: { input_tokens: 1, output_tokens: 1 } }, + ]); + }), + }; + const runtime = new CodexKtxLlmRuntime({ + projectDir: '/tmp/project', + modelSlots: { default: 'codex' }, + runner: fakeRunner, + }); + const onStepFinish = vi.fn(); + + const result = await runtime.runAgentLoop({ + modelRole: 'default', + systemPrompt: 'system', + userPrompt: 'user', + stepBudget: 2, + telemetryTags: {}, + onStepFinish, + toolSet: {}, + }); + + expect(result.stopReason).toBe('budget'); + expect(result.error).toBeUndefined(); + expect(result.metrics).toMatchObject({ stepCount: 2 }); + expect(onStepFinish).toHaveBeenCalledTimes(2); + expect(onStepFinish).toHaveBeenLastCalledWith({ stepIndex: 2, stepBudget: 2 }); + expect(fakeRunner.observedSignal()?.aborted).toBe(true); + }); + + it('fires onStepFinish live as each step completes, before the stream drains', async () => { + const order: string[] = []; + async function* liveEvents() { + yield { type: 'turn.started' }; + yield { type: 'item.completed', item: { type: 'mcp_tool_call', server: 'ktx', tool: 'a', status: 'completed' } }; + order.push('yielded-after-step-1'); + yield { type: 'item.completed', item: { type: 'mcp_tool_call', server: 'ktx', tool: 'b', status: 'completed' } }; + order.push('yielded-after-step-2'); + yield { type: 'item.completed', item: { type: 'agent_message', text: 'done' } }; + yield { type: 'turn.completed', usage: { input_tokens: 1, output_tokens: 1 } }; + } + const fakeRunner = { runStreamed: vi.fn(async () => liveEvents()) }; + const runtime = new CodexKtxLlmRuntime({ + projectDir: '/tmp/project', + modelSlots: { default: 'codex' }, + runner: fakeRunner, + }); + + const result = await runtime.runAgentLoop({ + modelRole: 'default', + systemPrompt: 'system', + userPrompt: 'user', + stepBudget: 10, + telemetryTags: {}, + onStepFinish: ({ stepIndex }) => { + order.push(`step-${stepIndex}`); + }, + toolSet: {}, + }); + + expect(result.stopReason).toBe('natural'); + expect(result.metrics).toMatchObject({ stepCount: 2 }); + expect(order).toEqual(['step-1', 'yielded-after-step-1', 'step-2', 'yielded-after-step-2']); + }); + + it('surfaces the real Codex error event even when the SDK stream throws afterward', async () => { + // The SDK yields the error/turn.failed events on stdout, then throws on the + // non-zero exit. The masked exit message must not hide the real API error. + const fakeRunner = throwingRunner( + [ + { type: 'thread.started', thread_id: 't' }, + { type: 'turn.started' }, + { type: 'error', message: MODEL_UNSUPPORTED_API_ERROR }, + { type: 'turn.failed', error: { message: MODEL_UNSUPPORTED_API_ERROR } }, + ], + new Error('Codex Exec exited with code 1: Reading prompt from stdin...'), + ); + const runtime = new CodexKtxLlmRuntime({ + projectDir: '/tmp/project', + modelSlots: { default: 'codex' }, + runner: fakeRunner, + }); + + await expect(runtime.generateText({ role: 'default', prompt: 'hi' })).rejects.toThrow( + 'not supported when using Codex with a ChatGPT account', + ); + }); + + it('probes Codex authentication through a minimal non-interactive turn', async () => { + const fakeRunner = runner([ + { type: 'turn.started' }, + { type: 'item.completed', item: { type: 'agent_message', text: 'ok' } }, + { type: 'turn.completed' }, + ]); + + await expect( + runCodexAuthProbe({ + projectDir: '/tmp/project', + model: 'codex', + runner: fakeRunner, + }), + ).resolves.toEqual({ ok: true }); + }); + + it('reports an unavailable model without blaming auth when Codex rejects the model', async () => { + const fakeRunner = throwingRunner( + [ + { type: 'turn.started' }, + { type: 'turn.failed', error: { message: MODEL_UNSUPPORTED_API_ERROR } }, + ], + new Error('Codex Exec exited with code 1: Reading prompt from stdin...'), + ); + + const result = await runCodexAuthProbe({ + projectDir: '/tmp/project', + model: 'gpt-5.3-codex', + runner: fakeRunner, + }); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.message).not.toContain('authentication is not usable'); + expect(result.message).toContain('not available'); + expect(result.message).toContain('gpt-5.3-codex'); + expect(result.message).toContain('not supported when using Codex with a ChatGPT account'); + // A model-access failure must steer the user at the model config, not auth. + expect(result.fix).toContain('llm.models.default'); + expect(result.fix).not.toContain('Authenticate Codex'); + } + }); + + it('reports an auth failure when Codex exits without an error event', async () => { + const fakeRunner = throwingRunner( + [], + new Error('Codex Exec exited with code 1: Not logged in. Run `codex login`.'), + ); + + const result = await runCodexAuthProbe({ + projectDir: '/tmp/project', + model: 'gpt-5.5', + runner: fakeRunner, + }); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.message).toContain('authentication is not usable'); + expect(result.message).toContain('Not logged in'); + expect(result.fix).toContain('Authenticate Codex'); + } + }); + + it('rejects an unsupported model id before probing, steering at llm.models.default', async () => { + const result = await runCodexAuthProbe({ + projectDir: '/tmp/project', + model: 'not-a-real-model', + }); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.message).toContain('Unsupported Codex model'); + expect(result.fix).toContain('llm.models.default'); + } + }); +}); diff --git a/packages/cli/test/context/llm/codex-sdk-runner.test.ts b/packages/cli/test/context/llm/codex-sdk-runner.test.ts new file mode 100644 index 00000000..fdafc666 --- /dev/null +++ b/packages/cli/test/context/llm/codex-sdk-runner.test.ts @@ -0,0 +1,97 @@ +import { describe, expect, it, vi } from 'vitest'; + +const sdkMock = vi.hoisted(() => { + const events = (async function* () { + yield { type: 'turn.completed', usage: { input_tokens: 1, output_tokens: 2 } }; + })(); + const runStreamed = vi.fn(async () => ({ events })); + const startThread = vi.fn(() => ({ runStreamed })); + const Codex = vi.fn(function Codex(this: { startThread: typeof startThread }, options?: unknown) { + Object.assign(this, { options, startThread }); + }); + return { Codex, startThread, runStreamed }; +}); + +vi.mock('@openai/codex-sdk', () => ({ Codex: sdkMock.Codex })); + +import { CodexSdkCliRunner } from '../../../src/context/llm/codex-sdk-runner.js'; + +async function collectAsync(items: AsyncIterable): Promise { + const collected: T[] = []; + for await (const item of items) { + collected.push(item); + } + return collected; +} + +describe('CodexSdkCliRunner', () => { + it('passes isolated env through the SDK and runtime controls through thread options', async () => { + const runner = new CodexSdkCliRunner({ + envBase: { + HOME: '/home/ktx-user', + PATH: '/usr/local/bin:/usr/bin', + CODEX_HOME: '/home/ktx-user/.codex', + HTTPS_PROXY: 'http://proxy.example', + KTX_UNRELATED_SECRET: 'must-not-copy', // pragma: allowlist secret + }, + }); + const previousToken = process.env.KTX_CODEX_RUNTIME_MCP_TOKEN; + process.env.KTX_CODEX_RUNTIME_MCP_TOKEN = 'outer-token'; + const outputSchema = { + type: 'object', + properties: { answer: { type: 'string' } }, + required: ['answer'], + additionalProperties: false, + }; + const controller = new AbortController(); + + try { + const events = await runner.runStreamed({ + projectDir: '/tmp/ktx-project', + model: 'gpt-5.3-codex', + prompt: 'Return JSON.', + configOverrides: { + history: { persistence: 'none' }, + }, + env: { KTX_CODEX_RUNTIME_MCP_TOKEN: 'run-token' }, + outputSchema, + signal: controller.signal, + }); + + expect(sdkMock.Codex).toHaveBeenCalledWith({ + config: { + history: { persistence: 'none' }, + }, + env: { + HOME: '/home/ktx-user', + PATH: '/usr/local/bin:/usr/bin', + CODEX_HOME: '/home/ktx-user/.codex', + HTTPS_PROXY: 'http://proxy.example', + KTX_CODEX_RUNTIME_MCP_TOKEN: 'run-token', + }, + }); + expect(process.env.KTX_CODEX_RUNTIME_MCP_TOKEN).toBe('outer-token'); + expect(sdkMock.startThread).toHaveBeenCalledWith({ + workingDirectory: '/tmp/ktx-project', + skipGitRepoCheck: true, + model: 'gpt-5.3-codex', + sandboxMode: 'read-only', + webSearchMode: 'disabled', + approvalPolicy: 'never', + }); + expect(sdkMock.runStreamed).toHaveBeenCalledWith('Return JSON.', { + outputSchema, + signal: controller.signal, + }); + await expect(collectAsync(events)).resolves.toEqual([ + { type: 'turn.completed', usage: { input_tokens: 1, output_tokens: 2 } }, + ]); + } finally { + if (previousToken === undefined) { + delete process.env.KTX_CODEX_RUNTIME_MCP_TOKEN; + } else { + process.env.KTX_CODEX_RUNTIME_MCP_TOKEN = previousToken; + } + } + }); +}); diff --git a/packages/cli/test/context/llm/runtime-local-config.test.ts b/packages/cli/test/context/llm/runtime-local-config.test.ts index 9e432cec..14adca7c 100644 --- a/packages/cli/test/context/llm/runtime-local-config.test.ts +++ b/packages/cli/test/context/llm/runtime-local-config.test.ts @@ -22,4 +22,25 @@ describe('local KTX LLM runtime config', () => { }), ).toBeNull(); }); + + it('creates a Codex runtime for codex backend without creating an AI SDK provider', () => { + const runtime = createLocalKtxLlmRuntimeFromConfig( + { + provider: { backend: 'codex' }, + models: { default: 'codex', triage: 'gpt-5.4' }, + }, + { env: {}, projectDir: '/tmp/project', createCodexRuntime: vi.fn((deps) => ({ deps }) as never) }, + ); + + expect(runtime).toMatchObject({ deps: expect.objectContaining({ projectDir: '/tmp/project' }) }); + }); + + it('returns null from the AI SDK provider factory for codex backend', () => { + expect( + createLocalKtxLlmProviderFromConfig({ + provider: { backend: 'codex' }, + models: { default: 'codex' }, + }), + ).toBeNull(); + }); }); diff --git a/packages/cli/test/context/project/config.test.ts b/packages/cli/test/context/project/config.test.ts index 670e1696..6027d454 100644 --- a/packages/cli/test/context/project/config.test.ts +++ b/packages/cli/test/context/project/config.test.ts @@ -231,6 +231,31 @@ llm: }); }); + it('parses Codex as a first-class LLM backend', () => { + const config = parseKtxProjectConfig(` +llm: + provider: + backend: codex + models: + default: gpt-5.3-codex + triage: gpt-5.3-codex + candidateExtraction: gpt-5.3-codex + curator: gpt-5.3-codex + reconcile: gpt-5.3-codex + repair: gpt-5.3-codex +`); + + expect(config.llm.provider.backend).toBe('codex'); + expect(config.llm.models).toEqual({ + default: 'gpt-5.3-codex', + triage: 'gpt-5.3-codex', + candidateExtraction: 'gpt-5.3-codex', + curator: 'gpt-5.3-codex', + reconcile: 'gpt-5.3-codex', + repair: 'gpt-5.3-codex', + }); + }); + it('parses gateway LLM, OpenAI scan embeddings, and sentence-transformers ingest embeddings', () => { const config = parseKtxProjectConfig(` llm: @@ -530,7 +555,7 @@ describe('generateKtxProjectConfigJsonSchema', () => { const llm = (schema.properties as Record }>).llm; const provider = llm?.properties?.provider as { properties?: Record }; const backend = provider?.properties?.backend as { enum?: readonly string[] }; - expect(backend?.enum).toEqual(['none', 'anthropic', 'vertex', 'gateway', 'claude-code']); + expect(backend?.enum).toEqual(['none', 'anthropic', 'vertex', 'gateway', 'claude-code', 'codex']); const storage = (schema.properties as Record }>).storage; const state = storage?.properties?.state as { enum?: readonly string[] }; diff --git a/packages/cli/test/doctor.test.ts b/packages/cli/test/doctor.test.ts index e3871f28..242331e8 100644 --- a/packages/cli/test/doctor.test.ts +++ b/packages/cli/test/doctor.test.ts @@ -422,6 +422,8 @@ describe('runKtxDoctor', () => { 'llm:', ' provider:', ' backend: anthropic', + ' models:', + ' default: claude-sonnet-4-5', '', ].join('\n'), 'utf-8', @@ -543,6 +545,8 @@ describe('runKtxDoctor', () => { 'llm:', ' provider:', ' backend: anthropic', + ' models:', + ' default: claude-sonnet-4-5', 'ingest:', ' adapters:', ' - live-database', @@ -652,6 +656,8 @@ describe('runKtxDoctor', () => { 'llm:', ' provider:', ' backend: anthropic', + ' models:', + ' default: claude-sonnet-4-5', '', ].join('\n'), 'utf-8', @@ -698,6 +704,8 @@ describe('runKtxDoctor', () => { 'llm:', ' provider:', ' backend: anthropic', + ' models:', + ' default: claude-sonnet-4-5', 'ingest:', ' adapters:', ' - live-database', diff --git a/packages/cli/test/ingest.test.ts b/packages/cli/test/ingest.test.ts index f5cd1ac5..4fc47d0c 100644 --- a/packages/cli/test/ingest.test.ts +++ b/packages/cli/test/ingest.test.ts @@ -337,10 +337,13 @@ describe('runKtxIngest', () => { expect(runIo.stdout()).toBe(''); expect(runIo.stderr()).toContain( - 'ktx ingest requires llm.provider.backend: anthropic, vertex, gateway, or claude-code, or an injected agentRunner.', + 'ktx ingest requires llm.provider.backend: anthropic, vertex, gateway, claude-code, or codex, or an injected agentRunner.', ); - expect(runIo.stderr()).toContain('Configure a local Claude Code session or API-backed LLM, then rerun ingest:'); + expect(runIo.stderr()).toContain('Configure a local Claude Code/Codex session or API-backed LLM, then rerun ingest:'); expect(runIo.stderr()).toContain(`ktx setup --project-dir ${projectDir} --llm-backend claude-code --no-input`); + expect(runIo.stderr()).toContain( + `ktx setup --project-dir ${projectDir} --llm-backend codex --llm-model gpt-5.5 --no-input`, + ); expect(runIo.stderr()).toContain( `ktx setup --project-dir ${projectDir} --llm-backend anthropic --anthropic-api-key-env ANTHROPIC_API_KEY --llm-model claude-sonnet-4-6 --no-input`, ); diff --git a/packages/cli/test/llm/model-provider.test.ts b/packages/cli/test/llm/model-provider.test.ts index 0e3ef045..17d47c6a 100644 --- a/packages/cli/test/llm/model-provider.test.ts +++ b/packages/cli/test/llm/model-provider.test.ts @@ -312,4 +312,13 @@ describe('createKtxLlmProvider', () => { }), ).toThrow('claude-code is not an AI SDK LanguageModel backend'); }); + + it('rejects codex as an AI SDK LanguageModel backend', () => { + expect(() => + createKtxLlmProvider({ + backend: 'codex', + modelSlots: { default: 'gpt-5.3-codex' }, + }), + ).toThrow('codex is not an AI SDK LanguageModel backend'); + }); }); diff --git a/packages/cli/test/setup-models.test.ts b/packages/cli/test/setup-models.test.ts index f054beff..dedf03bd 100644 --- a/packages/cli/test/setup-models.test.ts +++ b/packages/cli/test/setup-models.test.ts @@ -66,6 +66,7 @@ function makePromptAdapter(options: { nextProviderChoice === 'anthropic' || nextProviderChoice === 'vertex' || nextProviderChoice === 'claude-code' || + nextProviderChoice === 'codex' || nextProviderChoice === 'back' ) { return selectValues.shift() ?? nextProviderChoice; @@ -183,6 +184,7 @@ describe('setup Anthropic model step', () => { message: expect.stringContaining('Which LLM provider should KTX use?'), options: [ { value: 'claude-code', label: 'Claude subscription (Pro/Max)' }, + { value: 'codex', label: 'Codex subscription' }, { value: 'anthropic', label: 'Anthropic API key' }, { value: 'vertex', label: 'Google Vertex AI for Anthropic Claude' }, { value: 'back', label: 'Back' }, @@ -215,6 +217,85 @@ describe('setup Anthropic model step', () => { expect(authProbe).toHaveBeenCalledWith(expect.objectContaining({ projectDir: tempDir, model: 'sonnet' })); }); + it('configures Codex backend and validates local auth', async () => { + const io = makeIo(); + const codexAuthProbe = vi.fn(async () => ({ ok: true as const })); + + const result = await runKtxSetupAnthropicModelStep( + { + projectDir: tempDir, + inputMode: 'disabled', + llmBackend: 'codex', + llmModel: 'gpt-5.5', + skipLlm: false, + }, + io.io, + { codexAuthProbe }, + ); + + expect(result.status).toBe('ready'); + const config = parseKtxProjectConfig(await readFile(join(tempDir, 'ktx.yaml'), 'utf-8')); + expect(config.llm).toMatchObject({ + provider: { backend: 'codex' }, + models: { default: 'gpt-5.5' }, + }); + expect(codexAuthProbe).toHaveBeenCalledWith(expect.objectContaining({ projectDir: tempDir, model: 'gpt-5.5' })); + // The warning carries the clack gutter so it renders inside the setup frame. + expect(io.stderr()).toContain('│ Codex backend isolation is limited'); + expect(io.stderr()).toContain('may still load user Codex config'); + }); + + it('defaults the Codex model to gpt-5.5 when none is provided non-interactively', async () => { + const io = makeIo(); + const codexAuthProbe = vi.fn(async () => ({ ok: true as const })); + + const result = await runKtxSetupAnthropicModelStep( + { + projectDir: tempDir, + inputMode: 'disabled', + llmBackend: 'codex', + skipLlm: false, + }, + io.io, + { codexAuthProbe }, + ); + + expect(result.status).toBe('ready'); + const config = parseKtxProjectConfig(await readFile(join(tempDir, 'ktx.yaml'), 'utf-8')); + expect(config.llm).toMatchObject({ + provider: { backend: 'codex' }, + models: { default: 'gpt-5.5' }, + }); + expect(codexAuthProbe).toHaveBeenCalledWith(expect.objectContaining({ projectDir: tempDir, model: 'gpt-5.5' })); + }); + + it('offers the curated Codex models during interactive setup', async () => { + const io = makeIo(); + const prompts = makePromptAdapter({ selectValues: ['codex', 'gpt-5.5'] }); + const codexAuthProbe = vi.fn(async () => ({ ok: true as const })); + + const result = await runKtxSetupAnthropicModelStep( + { projectDir: tempDir, inputMode: 'auto', skipLlm: false }, + io.io, + { prompts, codexAuthProbe }, + ); + + expect(result.status).toBe('ready'); + expect(prompts.select).toHaveBeenCalledWith( + expect.objectContaining({ + message: expect.stringContaining('Which Codex model should KTX use?'), + options: [ + { value: 'gpt-5.5', label: 'GPT-5.5', hint: 'recommended' }, + { value: 'gpt-5.4', label: 'GPT-5.4' }, + { value: 'gpt-5.4-mini', label: 'GPT-5.4 mini' }, + { value: 'manual', label: 'Enter a Codex model ID manually' }, + { value: 'back', label: 'Back' }, + ], + }), + ); + expect(codexAuthProbe).toHaveBeenCalledWith(expect.objectContaining({ model: 'gpt-5.5' })); + }); + it('prompts for the Claude Code model during interactive setup', async () => { const io = makeIo(); const prompts = makePromptAdapter({ selectValues: ['claude-code', 'opus'] }); diff --git a/packages/cli/test/status-project.test.ts b/packages/cli/test/status-project.test.ts index 38d5aa6f..cd63cf19 100644 --- a/packages/cli/test/status-project.test.ts +++ b/packages/cli/test/status-project.test.ts @@ -44,6 +44,17 @@ function withClaudeCodeLlm(config: KtxProjectConfig): KtxProjectConfig { }; } +function withCodexLlm(config: KtxProjectConfig): KtxProjectConfig { + return { + ...config, + llm: { + ...config.llm, + provider: { backend: 'codex' }, + models: { ...config.llm.models, default: 'gpt-5.5' }, + }, + }; +} + function baseProjectConfig(): KtxProjectConfig { return withClaudeCodeLlm(buildDefaultKtxProjectConfig()); } @@ -391,6 +402,126 @@ describe('buildProjectStatus --fast', () => { }); }); +describe('buildProjectStatus codex', () => { + it('reports authenticated local Codex session', async () => { + const project = projectWithConfig(withCodexLlm(buildDefaultKtxProjectConfig())); + const status = await buildProjectStatus(project, { + codexAuthProbe: async () => ({ ok: true as const }), + }); + + expect(status.llm).toMatchObject({ + backend: 'codex', + model: 'gpt-5.5', + status: 'ok', + detail: 'local Codex session authenticated', + }); + expect(status.warnings).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + message: expect.stringContaining('Codex backend isolation is limited'), + fix: expect.stringContaining('claude-code'), + }), + ]), + ); + const rendered = renderProjectStatus(status, { verbose: false, useColor: false }); + expect(rendered).toContain('Codex backend isolation is limited'); + }); + + it('skips Codex auth probe with --fast', async () => { + let probeCalls = 0; + const project = projectWithConfig(withCodexLlm(buildDefaultKtxProjectConfig())); + const status = await buildProjectStatus(project, { + fast: true, + codexAuthProbe: async () => { + probeCalls += 1; + return { ok: true }; + }, + }); + + expect(probeCalls).toBe(0); + expect(status.llm.status).toBe('skipped'); + expect(status.llm.detail).toMatch(/--fast/); + }); + + it('surfaces the probe fix for a model-access failure instead of an auth fix', async () => { + const project = projectWithConfig(withCodexLlm(buildDefaultKtxProjectConfig())); + const status = await buildProjectStatus(project, { + codexAuthProbe: async () => ({ + ok: false, + message: 'Codex is authenticated, but the configured model "gpt-5.5" is not available...', + fix: 'Run `codex` to see the models your account supports, then set llm.models.default in ktx.yaml (or rerun `ktx setup`).', + }), + }); + + expect(status.llm.status).toBe('fail'); + expect(status.llm.fix).toContain('llm.models.default'); + expect(status.llm.fix).not.toContain('Authenticate Codex'); + }); +}); + +describe('buildProjectStatus llm models.default requirement', () => { + function withBackendNoModel( + backend: KtxProjectConfig['llm']['provider']['backend'], + ): KtxProjectConfig { + const config = buildDefaultKtxProjectConfig(); + return { + ...config, + llm: { ...config.llm, provider: { backend }, models: {} }, + }; + } + + it('fails codex without llm.models.default and never probes', async () => { + let probeCalls = 0; + const project = projectWithConfig(withBackendNoModel('codex')); + const status = await buildProjectStatus(project, { + codexAuthProbe: async () => { + probeCalls += 1; + return { ok: true }; + }, + }); + + expect(probeCalls).toBe(0); + expect(status.llm.status).toBe('fail'); + expect(status.llm.detail).toContain('llm.models.default'); + expect(status.verdict).toBe('blocked'); + }); + + it('fails claude-code without llm.models.default and never probes', async () => { + let probeCalls = 0; + const project = projectWithConfig(withBackendNoModel('claude-code')); + const status = await buildProjectStatus(project, { + claudeCodeAuthProbe: async () => { + probeCalls += 1; + return { ok: true }; + }, + }); + + expect(probeCalls).toBe(0); + expect(status.llm.status).toBe('fail'); + expect(status.llm.detail).toContain('llm.models.default'); + expect(status.verdict).toBe('blocked'); + }); + + it('fails anthropic without llm.models.default even when the key is set', async () => { + const config = withBackendNoModel('anthropic'); + const project = projectWithConfig({ + ...config, + llm: { + ...config.llm, + provider: { backend: 'anthropic', anthropic: { api_key: 'env:ANTHROPIC_API_KEY' } }, // pragma: allowlist secret + models: {}, + }, + }); + const status = await buildProjectStatus(project, { + env: { ANTHROPIC_API_KEY: 'sk-test' }, // pragma: allowlist secret + }); + + expect(status.llm.status).toBe('fail'); + expect(status.llm.detail).toContain('llm.models.default'); + expect(status.verdict).toBe('blocked'); + }); +}); + describe('buildLocalStatsStatus', () => { let tempDir: string; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 15bc75f3..a3eaad5f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -158,6 +158,9 @@ importers: '@notionhq/client': specifier: ^5.22.0 version: 5.22.0 + '@openai/codex-sdk': + specifier: ^0.133.0 + version: 0.133.0 ai: specifier: ^6.0.188 version: 6.0.188(zod@4.4.3) @@ -1288,6 +1291,51 @@ packages: '@octokit/types@16.0.0': resolution: {integrity: sha512-sKq+9r1Mm4efXW1FCk7hFSeJo4QKreL/tTbR0rz/qx/r1Oa2VV83LTA/H/MuCOX7uCIJmQVRKBcbmWoySjAnSg==} + '@openai/codex-sdk@0.133.0': + resolution: {integrity: sha512-PB82D/1Q0C7nzaV5O+1O4y5LcVwiUvxyHvCUTfz8Cwztv6bOWQ40gFHE5ZFX1EFPJx1cMV0GPVODWuXIKAuayQ==} + engines: {node: '>=18'} + + '@openai/codex@0.133.0': + resolution: {integrity: sha512-Gh42kLLBo/6gpnHmDzUWDVvyS57ekCB1+1Dz0RG2oIl3Lhk1uwrjSj/PwaJWWh4Rw/rUp1RqkwrMugFfFEOlqQ==} + engines: {node: '>=16'} + hasBin: true + + '@openai/codex@0.133.0-darwin-arm64': + resolution: {integrity: sha512-W7f8+DckLujnqGlptKCzgJU+ooeHKMuk6KYgMFP6A9asn7YUsGUgJqjiBaX8oNcXO6w/pTbKGRARx1kCNS8lIg==} + engines: {node: '>=16'} + cpu: [arm64] + os: [darwin] + + '@openai/codex@0.133.0-darwin-x64': + resolution: {integrity: sha512-Ek8ikvLOiXZ8emcIJVBXxK6fm8ratBy0kaEt3JNisTNszxGshUHf/R4xxDxIyKNcUkYYXjW7A/rMwW3iu3OFlg==} + engines: {node: '>=16'} + cpu: [x64] + os: [darwin] + + '@openai/codex@0.133.0-linux-arm64': + resolution: {integrity: sha512-uKXYYSJ3mY16sp4hcG/4BMNRjva/ZS4oARiI1+7k8+NiuoAhdCGWNe5u4KJ3sMuL3tp/IXcmc6B56EFX1+WDBQ==} + engines: {node: '>=16'} + cpu: [arm64] + os: [linux] + + '@openai/codex@0.133.0-linux-x64': + resolution: {integrity: sha512-9YfyqrfUj/UZ2+aXE4zBz47t6RXbVni95ZorGsNh857vxYK/asVpUtR2cymo9lB3JaI4mQaKFfV/t7IRItqkuA==} + engines: {node: '>=16'} + cpu: [x64] + os: [linux] + + '@openai/codex@0.133.0-win32-arm64': + resolution: {integrity: sha512-mRzND0PSGHRoLk0X41GTSoc3tFjZSF4HgDlfjU5fiQcWVi0/kLb7Ku6/tPFT/X2hOLa3YdJkbIcHC0Hc9ni80g==} + engines: {node: '>=16'} + cpu: [arm64] + os: [win32] + + '@openai/codex@0.133.0-win32-x64': + resolution: {integrity: sha512-u3ji78DIPZCGJeELuovsAnaZH+vK9gsA4F6M1y+Uy2s80Sz7/i1S0KL81qGReYji3urSjgBpkQuNP47GXOqxrQ==} + engines: {node: '>=16'} + cpu: [x64] + os: [win32] + '@opentelemetry/api@1.9.1': resolution: {integrity: sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q==} engines: {node: '>=8.0.0'} @@ -7145,6 +7193,37 @@ snapshots: dependencies: '@octokit/openapi-types': 27.0.0 + '@openai/codex-sdk@0.133.0': + dependencies: + '@openai/codex': 0.133.0 + + '@openai/codex@0.133.0': + optionalDependencies: + '@openai/codex-darwin-arm64': '@openai/codex@0.133.0-darwin-arm64' + '@openai/codex-darwin-x64': '@openai/codex@0.133.0-darwin-x64' + '@openai/codex-linux-arm64': '@openai/codex@0.133.0-linux-arm64' + '@openai/codex-linux-x64': '@openai/codex@0.133.0-linux-x64' + '@openai/codex-win32-arm64': '@openai/codex@0.133.0-win32-arm64' + '@openai/codex-win32-x64': '@openai/codex@0.133.0-win32-x64' + + '@openai/codex@0.133.0-darwin-arm64': + optional: true + + '@openai/codex@0.133.0-darwin-x64': + optional: true + + '@openai/codex@0.133.0-linux-arm64': + optional: true + + '@openai/codex@0.133.0-linux-x64': + optional: true + + '@openai/codex@0.133.0-win32-arm64': + optional: true + + '@openai/codex@0.133.0-win32-x64': + optional: true + '@opentelemetry/api@1.9.1': {} '@orama/orama@3.1.18': {} diff --git a/scripts/codex-backend-live-smoke.mjs b/scripts/codex-backend-live-smoke.mjs new file mode 100644 index 00000000..7793fefc --- /dev/null +++ b/scripts/codex-backend-live-smoke.mjs @@ -0,0 +1,160 @@ +import { execFile } from 'node:child_process'; +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +import { promisify } from 'node:util'; + +const execFileAsync = promisify(execFile); +const SCRIPT_DIR = dirname(fileURLToPath(import.meta.url)); +const ROOT_DIR = resolve(SCRIPT_DIR, '..'); +const OPT_IN_MESSAGE = + 'Set KTX_RUN_CODEX_BACKEND_SMOKE=1 or pass --force to run the Codex backend live smoke.'; + +export function codexBackendSmokeOptIn(env = process.env, args = process.argv.slice(2)) { + if (env.KTX_RUN_CODEX_BACKEND_SMOKE === '1' || args.includes('--force')) { + return { run: true }; + } + return { run: false, message: OPT_IN_MESSAGE }; +} + +async function run(command, args, options = {}) { + process.stdout.write(`$ ${command} ${args.join(' ')}\n`); + try { + const result = await execFileAsync(command, args, { + cwd: options.cwd ?? ROOT_DIR, + env: { ...process.env, ...(options.env ?? {}) }, + encoding: 'utf8', + maxBuffer: 1024 * 1024 * 20, + timeout: options.timeoutMs ?? 300_000, + }); + if (result.stdout) { + process.stdout.write(result.stdout); + } + if (result.stderr) { + process.stderr.write(result.stderr); + } + return { code: 0, stdout: result.stdout, stderr: result.stderr }; + } catch (error) { + const stdout = typeof error.stdout === 'string' ? error.stdout : ''; + const stderr = typeof error.stderr === 'string' ? error.stderr : error.message; + if (stdout) { + process.stdout.write(stdout); + } + if (stderr) { + process.stderr.write(stderr); + } + return { + code: typeof error.code === 'number' ? error.code : 1, + stdout, + stderr, + }; + } +} + +function requireSuccess(label, result) { + if (result.code !== 0) { + throw new Error(`${label} failed with code ${result.code}\nstdout:\n${result.stdout}\nstderr:\n${result.stderr}`); + } +} + +async function runSetupSmoke(projectDir) { + const result = await run( + 'node', + [ + join(ROOT_DIR, 'packages/cli/dist/bin.js'), + 'setup', + '--project-dir', + projectDir, + '--llm-backend', + 'codex', + '--llm-model', + 'gpt-5.3-codex', + '--no-input', + '--yes', + '--skip-databases', + '--skip-sources', + '--skip-agents', + ], + { timeoutMs: 600_000 }, + ); + requireSuccess('ktx setup codex backend', result); + if (!result.stdout.includes('LLM ready: yes (codex, gpt-5.3-codex)')) { + throw new Error(`setup did not report Codex LLM readiness\nstdout:\n${result.stdout}`); + } +} + +async function runRuntimeSmoke(projectDir) { + const runtimeUrl = pathToFileURL(join(ROOT_DIR, 'packages/cli/dist/context/llm/codex-runtime.js')).href; + const zodUrl = pathToFileURL(join(ROOT_DIR, 'packages/cli/node_modules/zod/index.js')).href; + const { CodexKtxLlmRuntime } = await import(runtimeUrl); + const { z } = await import(zodUrl); + const runtime = new CodexKtxLlmRuntime({ + projectDir, + modelSlots: { default: 'gpt-5.3-codex' }, + }); + + const text = await runtime.generateText({ + role: 'default', + prompt: 'Reply with exactly: ktx_codex_text_ok', + }); + if (text.trim() !== 'ktx_codex_text_ok') { + throw new Error(`Codex text smoke returned unexpected text: ${text}`); + } + + let toolCalls = 0; + const loop = await runtime.runAgentLoop({ + modelRole: 'default', + systemPrompt: 'You must use available tools when the user asks for a tool result.', + userPrompt: + 'Call the echo_value tool with {"value":"ktx_codex_tool_ok"}, then finish after the tool returns.', + toolSet: { + echo_value: { + name: 'echo_value', + description: 'Return the provided value as markdown.', + inputSchema: z.object({ value: z.string() }), + execute: async (input) => { + toolCalls += 1; + return { markdown: `echo:${input.value}` }; + }, + }, + }, + stepBudget: 4, + telemetryTags: {}, + }); + + if (loop.stopReason !== 'natural') { + throw new Error(`Codex tool smoke stopped with ${loop.stopReason}: ${loop.error?.message ?? 'no error'}`); + } + if (toolCalls !== 1) { + throw new Error(`Expected Codex to call echo_value exactly once, got ${toolCalls}`); + } +} + +export async function runCodexBackendLiveSmoke() { + const projectDir = await mkdtemp(join(tmpdir(), 'ktx-codex-backend-smoke-')); + try { + requireSuccess( + 'ktx build', + await run('pnpm', ['--filter', '@kaelio/ktx', 'run', 'build'], { timeoutMs: 600_000 }), + ); + await runSetupSmoke(projectDir); + await runRuntimeSmoke(projectDir); + process.stdout.write(`Codex backend live smoke passed in ${projectDir}\n`); + } finally { + await rm(projectDir, { recursive: true, force: true }); + } +} + +async function main() { + const optIn = codexBackendSmokeOptIn(); + if (!optIn.run) { + process.stdout.write(`${optIn.message}\n`); + return; + } + await runCodexBackendLiveSmoke(); +} + +if (import.meta.url === pathToFileURL(process.argv[1] ?? '').href) { + await main(); +} diff --git a/scripts/codex-backend-live-smoke.test.mjs b/scripts/codex-backend-live-smoke.test.mjs new file mode 100644 index 00000000..8d8c051f --- /dev/null +++ b/scripts/codex-backend-live-smoke.test.mjs @@ -0,0 +1,18 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { codexBackendSmokeOptIn } from './codex-backend-live-smoke.mjs'; + +test('codex backend smoke stays disabled by default', () => { + assert.deepEqual(codexBackendSmokeOptIn({}, []), { + run: false, + message: 'Set KTX_RUN_CODEX_BACKEND_SMOKE=1 or pass --force to run the Codex backend live smoke.', + }); +}); + +test('codex backend smoke runs with env opt-in', () => { + assert.deepEqual(codexBackendSmokeOptIn({ KTX_RUN_CODEX_BACKEND_SMOKE: '1' }, []), { run: true }); +}); + +test('codex backend smoke runs with force flag', () => { + assert.deepEqual(codexBackendSmokeOptIn({}, ['--force']), { run: true }); +}); From 6da8c3452a97bfcbeefd8bbcc3379d4d41b4dc9f Mon Sep 17 00:00:00 2001 From: Andrey Avtomonov Date: Tue, 2 Jun 2026 17:23:51 +0200 Subject: [PATCH 06/25] feat(telemetry): include error details for failures (#254) --- .../content/docs/community/telemetry.mdx | 20 +- packages/cli/src/connection.ts | 4 +- packages/cli/src/public-ingest.ts | 5 + packages/cli/src/scan.ts | 4 +- packages/cli/src/setup-context.ts | 5 +- packages/cli/src/setup.ts | 5 +- packages/cli/src/telemetry/command-hook.ts | 5 +- packages/cli/src/telemetry/events.schema.json | 31 +- packages/cli/src/telemetry/events.ts | 12 +- packages/cli/src/telemetry/scrubber.ts | 24 + packages/cli/test/connection.test.ts | 21 + packages/cli/test/public-ingest.test.ts | 26 + packages/cli/test/scan.test.ts | 31 + packages/cli/test/setup-context.test.ts | 24 + .../cli/test/telemetry/command-hook.test.ts | 19 + packages/cli/test/telemetry/scrubber.test.ts | 38 +- .../ktx_daemon/telemetry/events.schema.json | 31 +- uv.lock | 1953 +++++++++-------- 18 files changed, 1259 insertions(+), 999 deletions(-) diff --git a/docs-site/content/docs/community/telemetry.mdx b/docs-site/content/docs/community/telemetry.mdx index c2a9af21..9618af8c 100644 --- a/docs-site/content/docs/community/telemetry.mdx +++ b/docs-site/content/docs/community/telemetry.mdx @@ -25,10 +25,11 @@ Use any of these mechanisms to disable telemetry: ## What we collect -High-level signals only: which commands run, how long they take, whether they +High-level signals: which commands run, how long they take, whether they succeed or fail, and basic environment metadata (CLI version, Node version, OS -platform). For project-level analysis, **ktx** sends a salted hash of the -project directory — never the raw path. +platform). When an operation fails, we also include diagnostic detail about the +error so we can debug it. For project-level analysis, **ktx** sends a salted +hash of the project directory to group events. When an agent reaches **ktx** through MCP, we also record the connecting client tool's self-reported name and version (for example Claude Desktop, Cursor, or @@ -37,11 +38,14 @@ tool, never you or your data. ## What we never collect -- File paths, hostnames, environment variable values, or command arguments -- `ktx.yaml` contents, connection passwords, API keys, or tokens -- Schema names, table names, column names, SQL text, or query results -- Error messages or stack traces -- Git remote URLs, Git user email, OS user, or hostname +We build telemetry around counts and coarse signals, not the contents of your +data or configuration. We don't deliberately collect your `ktx.yaml`, query +results, passwords, API keys, or access tokens. + +The one place environment-specific text can appear is failure diagnostics: when +an operation errors, the detail we record is the error as your tools reported +it, which can include identifiers from your setup. If you'd rather send nothing +at all, turn telemetry off using any of the options above. ## Storage and retention diff --git a/packages/cli/src/connection.ts b/packages/cli/src/connection.ts index abc501a6..96281e82 100644 --- a/packages/cli/src/connection.ts +++ b/packages/cli/src/connection.ts @@ -17,7 +17,7 @@ import { createKtxCliScanConnector } from './local-scan-connectors.js'; import { profileMark } from './startup-profile.js'; import { isDemoConnection } from './telemetry/demo-detect.js'; import { emitTelemetryEvent } from './telemetry/index.js'; -import { scrubErrorClass } from './telemetry/scrubber.js'; +import { formatErrorDetail, scrubErrorClass } from './telemetry/scrubber.js'; profileMark('module:connection'); @@ -304,6 +304,7 @@ async function emitConnectionTest(input: { io: KtxCliIo; }): Promise { const errorClass = input.error ? scrubErrorClass(input.error) : undefined; + const errorDetail = input.error ? formatErrorDetail(input.error) : undefined; await emitTelemetryEvent({ name: 'connection_test', projectDir: input.project.projectDir, @@ -314,6 +315,7 @@ async function emitConnectionTest(input: { outcome: input.outcome, durationMs: input.durationMs, ...(errorClass ? { errorClass } : {}), + ...(errorDetail ? { errorDetail } : {}), }, }); } diff --git a/packages/cli/src/public-ingest.ts b/packages/cli/src/public-ingest.ts index f2b8cdd4..216d1d7b 100644 --- a/packages/cli/src/public-ingest.ts +++ b/packages/cli/src/public-ingest.ts @@ -22,6 +22,7 @@ import type { KtxScanArgs, KtxScanDeps } from './scan.js'; import { profileMark } from './startup-profile.js'; import { isDemoConnection } from './telemetry/demo-detect.js'; import { emitProjectStackSnapshot, emitTelemetryEvent } from './telemetry/index.js'; +import { formatErrorDetail } from './telemetry/scrubber.js'; profileMark('module:public-ingest'); @@ -635,6 +636,9 @@ async function emitIngestCompleted(input: { io: KtxCliIo; }): Promise { const failed = resultFailed(input.result); + const failureDetail = failed + ? formatErrorDetail(input.result.steps.find((step) => step.status === 'failed')?.detail) + : undefined; await emitTelemetryEvent({ name: 'ingest_completed', projectDir: input.args.projectDir, @@ -651,6 +655,7 @@ async function emitIngestCompleted(input: { rowsBucket: rowsBucket(), durationMs: Math.max(0, performance.now() - input.startedAt), outcome: failed ? 'error' : 'ok', + ...(failureDetail ? { errorDetail: failureDetail } : {}), }, }); } diff --git a/packages/cli/src/scan.ts b/packages/cli/src/scan.ts index 94b80f65..4f973e57 100644 --- a/packages/cli/src/scan.ts +++ b/packages/cli/src/scan.ts @@ -9,7 +9,7 @@ import { createKtxCliScanConnector } from './local-scan-connectors.js'; import type { KtxManagedPythonInstallPolicy } from './managed-python-command.js'; import { profileMark } from './startup-profile.js'; import { emitTelemetryEvent } from './telemetry/index.js'; -import { scrubErrorClass } from './telemetry/scrubber.js'; +import { formatErrorDetail, scrubErrorClass } from './telemetry/scrubber.js'; profileMark('module:scan'); @@ -380,6 +380,7 @@ export async function runKtxScan(args: KtxScanArgs, io: KtxCliIo = process, deps return 0; } catch (error) { const errorClass = scrubErrorClass(error); + const errorDetail = formatErrorDetail(error); await emitTelemetryEvent({ name: 'scan_completed', projectDir: args.projectDir, @@ -393,6 +394,7 @@ export async function runKtxScan(args: KtxScanArgs, io: KtxCliIo = process, deps durationMs: Math.max(0, performance.now() - startedAt), outcome: 'error', ...(errorClass ? { errorClass } : {}), + ...(errorDetail ? { errorDetail } : {}), }, }); io.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`); diff --git a/packages/cli/src/setup-context.ts b/packages/cli/src/setup-context.ts index 63b4dbdf..d6ef2639 100644 --- a/packages/cli/src/setup-context.ts +++ b/packages/cli/src/setup-context.ts @@ -6,6 +6,7 @@ import { markKtxSetupStateStepComplete, readKtxSetupState } from './context/proj import { serializeKtxProjectConfig } from './context/project/config.js'; import type { KtxCliIo } from './cli-runtime.js'; import { errorMessage, writePrefixedLines } from './clack.js'; +import { formatErrorDetail } from './telemetry/scrubber.js'; import { buildPublicIngestPlan } from './public-ingest.js'; import type { KtxManagedPythonInstallPolicy } from './managed-python-command.js'; import { @@ -67,7 +68,7 @@ export type KtxSetupContextResult = | { status: 'skipped'; projectDir: string } | { status: 'back'; projectDir: string } | { status: 'missing-input'; projectDir: string } - | { status: 'failed'; projectDir: string }; + | { status: 'failed'; projectDir: string; errorDetail?: string }; export interface KtxSetupContextStepArgs { projectDir: string; @@ -702,6 +703,6 @@ export async function runKtxSetupContextStep( return await runBuild(args, io, deps, project, targets); } catch (error) { writePrefixedLines((chunk) => io.stderr.write(chunk), errorMessage(error)); - return { status: 'failed', projectDir: args.projectDir }; + return { status: 'failed', projectDir: args.projectDir, errorDetail: formatErrorDetail(error) }; } } diff --git a/packages/cli/src/setup.ts b/packages/cli/src/setup.ts index ebc04c87..f8fc2064 100644 --- a/packages/cli/src/setup.ts +++ b/packages/cli/src/setup.ts @@ -217,6 +217,7 @@ async function recordSetupStep(input: { startedAt: number; io: KtxCliIo; cliVersion?: string; + errorDetail?: string; }): Promise { const { emitTelemetryEvent } = await import('./telemetry/index.js'); await emitTelemetryEvent({ @@ -228,6 +229,7 @@ async function recordSetupStep(input: { step: input.step, outcome: setupTelemetryOutcome(input.status), durationMs: Math.max(0, performance.now() - input.startedAt), + ...(input.errorDetail ? { errorDetail: input.errorDetail } : {}), }, }); } @@ -683,7 +685,7 @@ async function runKtxSetupInner(args: KtxSetupArgs, io: KtxCliIo, deps: KtxSetup if (!step) break; const stepStartedAt = performance.now(); - let stepResult: { status: KtxSetupFlowStatus }; + let stepResult: { status: KtxSetupFlowStatus; errorDetail?: string }; if (step === 'models') { const modelRunner = deps.model ?? ((modelArgs, modelIo) => runKtxSetupAnthropicModelStep(modelArgs, modelIo, deps.modelDeps)); @@ -844,6 +846,7 @@ async function runKtxSetupInner(args: KtxSetupArgs, io: KtxCliIo, deps: KtxSetup startedAt: stepStartedAt, io, cliVersion: args.cliVersion, + ...(stepResult.errorDetail ? { errorDetail: stepResult.errorDetail } : {}), }); if (stepResult.status === 'failed') { diff --git a/packages/cli/src/telemetry/command-hook.ts b/packages/cli/src/telemetry/command-hook.ts index e4f003d7..99f8723e 100644 --- a/packages/cli/src/telemetry/command-hook.ts +++ b/packages/cli/src/telemetry/command-hook.ts @@ -1,4 +1,4 @@ -import { scrubErrorClass } from './scrubber.js'; +import { formatErrorDetail, scrubErrorClass } from './scrubber.js'; export type CommandOutcome = 'ok' | 'error' | 'aborted'; @@ -16,6 +16,7 @@ export interface CompletedCommandSpan { durationMs: number; outcome: CommandOutcome; errorClass?: string; + errorDetail?: string; flagsPresent: Record; hasProject: boolean; projectDir?: string; @@ -40,12 +41,14 @@ export function completeCommandSpan(input: { } const errorClass = input.error ? scrubErrorClass(input.error) : undefined; + const errorDetail = input.error ? formatErrorDetail(input.error) : undefined; return { commandPath: span.commandPath, durationMs: Math.max(0, input.completedAt - span.startedAt), outcome: input.outcome, ...(errorClass ? { errorClass } : {}), + ...(errorDetail ? { errorDetail } : {}), flagsPresent: span.flagsPresent, hasProject: span.hasProject, projectDir: span.projectDir, diff --git a/packages/cli/src/telemetry/events.schema.json b/packages/cli/src/telemetry/events.schema.json index acad7988..a75f92f1 100644 --- a/packages/cli/src/telemetry/events.schema.json +++ b/packages/cli/src/telemetry/events.schema.json @@ -26,6 +26,7 @@ "durationMs", "outcome", "errorClass", + "errorDetail", "flagsPresent", "hasProject", "projectGroupAttached" @@ -37,7 +38,8 @@ "fields": [ "step", "outcome", - "durationMs" + "durationMs", + "errorDetail" ] }, { @@ -56,6 +58,7 @@ "isDemoConnection", "outcome", "errorClass", + "errorDetail", "durationMs", "serverVersion" ] @@ -84,7 +87,8 @@ "rowsBucket", "durationMs", "outcome", - "errorClass" + "errorClass", + "errorDetail" ] }, { @@ -98,7 +102,8 @@ "declaredFkCount", "durationMs", "outcome", - "errorClass" + "errorClass", + "errorDetail" ] }, { @@ -296,6 +301,10 @@ "errorClass": { "type": "string" }, + "errorDetail": { + "type": "string", + "maxLength": 1000 + }, "flagsPresent": { "type": "object", "propertyNames": { @@ -384,6 +393,10 @@ "durationMs": { "type": "number", "minimum": 0 + }, + "errorDetail": { + "type": "string", + "maxLength": 1000 } }, "required": [ @@ -494,6 +507,10 @@ "errorClass": { "type": "string" }, + "errorDetail": { + "type": "string", + "maxLength": 1000 + }, "durationMs": { "type": "number", "minimum": 0 @@ -673,6 +690,10 @@ }, "errorClass": { "type": "string" + }, + "errorDetail": { + "type": "string", + "maxLength": 1000 } }, "required": [ @@ -759,6 +780,10 @@ }, "errorClass": { "type": "string" + }, + "errorDetail": { + "type": "string", + "maxLength": 1000 } }, "required": [ diff --git a/packages/cli/src/telemetry/events.ts b/packages/cli/src/telemetry/events.ts index e751cd70..c4fc2e6f 100644 --- a/packages/cli/src/telemetry/events.ts +++ b/packages/cli/src/telemetry/events.ts @@ -21,6 +21,7 @@ const commandSchema = telemetryCommonEnvelopeSchema durationMs: z.number().nonnegative(), outcome: z.enum(['ok', 'error', 'aborted']), errorClass: z.string().optional(), + errorDetail: z.string().max(1000).optional(), flagsPresent: z.record(z.string(), z.boolean()), hasProject: z.boolean(), projectGroupAttached: z.boolean(), @@ -45,6 +46,7 @@ const setupStepSchema = telemetryCommonEnvelopeSchema ]), outcome: z.enum(['completed', 'skipped', 'abandoned']), durationMs: z.number().nonnegative(), + errorDetail: z.string().max(1000).optional(), }) .strict(); @@ -61,6 +63,7 @@ const connectionTestSchema = telemetryCommonEnvelopeSchema isDemoConnection: z.boolean(), outcome: outcomeSchema, errorClass: z.string().optional(), + errorDetail: z.string().max(1000).optional(), durationMs: z.number().nonnegative(), serverVersion: z.string().optional(), }) @@ -90,6 +93,7 @@ const ingestCompletedSchema = telemetryCommonEnvelopeSchema durationMs: z.number().nonnegative(), outcome: outcomeSchema, errorClass: z.string().optional(), + errorDetail: z.string().max(1000).optional(), }) .strict(); @@ -103,6 +107,7 @@ const scanCompletedSchema = telemetryCommonEnvelopeSchema durationMs: z.number().nonnegative(), outcome: outcomeSchema, errorClass: z.string().optional(), + errorDetail: z.string().max(1000).optional(), }) .strict(); @@ -237,6 +242,7 @@ export const telemetryEventCatalog = [ 'durationMs', 'outcome', 'errorClass', + 'errorDetail', 'flagsPresent', 'hasProject', 'projectGroupAttached', @@ -245,7 +251,7 @@ export const telemetryEventCatalog = [ { name: 'setup_step', description: 'Emitted after an interactive setup step completes, skips, or aborts.', - fields: ['step', 'outcome', 'durationMs'], + fields: ['step', 'outcome', 'durationMs', 'errorDetail'], }, { name: 'connection_added', @@ -255,7 +261,7 @@ export const telemetryEventCatalog = [ { name: 'connection_test', description: 'Emitted after ktx connection test completes.', - fields: ['driver', 'isDemoConnection', 'outcome', 'errorClass', 'durationMs', 'serverVersion'], + fields: ['driver', 'isDemoConnection', 'outcome', 'errorClass', 'errorDetail', 'durationMs', 'serverVersion'], }, { name: 'project_stack_snapshot', @@ -275,6 +281,7 @@ export const telemetryEventCatalog = [ 'durationMs', 'outcome', 'errorClass', + 'errorDetail', ], }, { @@ -289,6 +296,7 @@ export const telemetryEventCatalog = [ 'durationMs', 'outcome', 'errorClass', + 'errorDetail', ], }, { diff --git a/packages/cli/src/telemetry/scrubber.ts b/packages/cli/src/telemetry/scrubber.ts index 27e41f87..a7b8c393 100644 --- a/packages/cli/src/telemetry/scrubber.ts +++ b/packages/cli/src/telemetry/scrubber.ts @@ -26,3 +26,27 @@ export function scrubErrorClass(error: unknown): string | undefined { return constructorName; } + +const MAX_ERROR_DETAIL_LENGTH = 1000; + +/** + * Human-readable failure detail for telemetry: the error's `.code` (when + * present) prefixed onto its `message`, collapsed to a single line and + * length-capped. Captures the message only — never the stack. + * + * This intentionally forwards raw error text, which can include identifiers from + * the user's environment (table/column names, hostnames, usernames), so that + * funnel failures are diagnosable. Callers must gate it to the failure path. + */ +export function formatErrorDetail(error: unknown): string | undefined { + if (error === undefined || error === null) { + return undefined; + } + + const code = (error as { code?: unknown }).code; + const message = error instanceof Error ? error.message : String(error); + const prefix = typeof code === 'string' || typeof code === 'number' ? `${code}: ` : ''; + const detail = `${prefix}${message}`.replace(/\s+/g, ' ').trim(); + + return detail.length > 0 ? detail.slice(0, MAX_ERROR_DETAIL_LENGTH) : undefined; +} diff --git a/packages/cli/test/connection.test.ts b/packages/cli/test/connection.test.ts index 59ead362..67e55af8 100644 --- a/packages/cli/test/connection.test.ts +++ b/packages/cli/test/connection.test.ts @@ -162,6 +162,27 @@ describe('runKtxConnection', () => { expect(io.stderr()).not.toContain(projectDir); }); + it('records the raw errorDetail in connection_test telemetry when a native test fails', async () => { + vi.stubEnv('KTX_TELEMETRY_DEBUG', '1'); + vi.stubEnv('CI', ''); + const projectDir = join(tempDir, 'project'); + await initKtxProject({ projectDir }); + await writeConnections(projectDir, { + warehouse: { driver: 'sqlite' }, + }); + const { connector } = nativeConnector('sqlite', { success: false, error: 'database file is unreadable' }); + const io = makeIo(); + + const code = await runKtxConnection({ command: 'test', projectDir, connectionId: 'warehouse' }, io.io, { + createScanConnector: vi.fn(async () => connector), + }); + + expect(code).toBe(1); + expect(io.stderr()).toContain('"event":"connection_test"'); + expect(io.stderr()).toContain('"outcome":"error"'); + expect(io.stderr()).toContain('"errorDetail":"database file is unreadable"'); + }); + it('reports the connector error and still cleans up when native testConnection fails', async () => { const projectDir = join(tempDir, 'project'); await initKtxProject({ projectDir }); diff --git a/packages/cli/test/public-ingest.test.ts b/packages/cli/test/public-ingest.test.ts index 2ffbefaf..549756eb 100644 --- a/packages/cli/test/public-ingest.test.ts +++ b/packages/cli/test/public-ingest.test.ts @@ -431,6 +431,32 @@ describe('runKtxPublicIngest', () => { } }); + it('records errorDetail in ingest_completed telemetry when a target fails', async () => { + vi.stubEnv('KTX_TELEMETRY_DEBUG', '1'); + vi.stubEnv('CI', ''); + const projectDir = await mkdtemp(join(tmpdir(), 'ktx-public-ingest-telemetry-fail-')); + try { + await initKtxProject({ projectDir }); + const io = makeIo({ isTTY: true }); + const project = deepReadyProject({ + warehouse: { driver: 'sqlite', path: join(projectDir, 'warehouse.sqlite') }, + }); + + const code = await runKtxPublicIngest( + { command: 'run', projectDir, targetConnectionId: 'warehouse', all: false, json: false, inputMode: 'disabled' }, + io.io, + { loadProject: vi.fn(async () => project), runScan: vi.fn(async () => 1) }, + ); + + expect(code).toBe(1); + expect(io.stderr()).toContain('"event":"ingest_completed"'); + expect(io.stderr()).toContain('"outcome":"error"'); + expect(io.stderr()).toContain('"errorDetail"'); + } finally { + await rm(projectDir, { recursive: true, force: true }); + } + }); + it('runs query history after schema ingest with current-run window override', async () => { const io = makeIo(); const runtimeIo = makeIo({ isTTY: true }); diff --git a/packages/cli/test/scan.test.ts b/packages/cli/test/scan.test.ts index 837acb10..6a524fba 100644 --- a/packages/cli/test/scan.test.ts +++ b/packages/cli/test/scan.test.ts @@ -423,6 +423,37 @@ describe('runKtxScan', () => { expect(io.stderr()).not.toContain(tempDir); }); + it('records the raw errorDetail in scan_completed telemetry when the scan throws', async () => { + vi.stubEnv('KTX_TELEMETRY_DEBUG', '1'); + vi.stubEnv('CI', ''); + await initKtxProject({ projectDir: tempDir }); + const runLocalScan = vi.fn(async (): Promise => { + const error = new Error('introspection timed out'); + (error as { code?: unknown }).code = 'ETIMEDOUT'; + throw error; + }); + const io = makeIo({ isTTY: true }); + + const code = await runKtxScan( + { + command: 'run', + projectDir: tempDir, + connectionId: 'warehouse', + mode: 'structural', + detectRelationships: false, + dryRun: false, + databaseIntrospectionUrl: 'http://127.0.0.1:8765', + }, + io.io, + { runLocalScan, createLocalIngestAdapters: noLocalIngestAdapters }, + ); + + expect(code).toBe(1); + expect(io.stderr()).toContain('"event":"scan_completed"'); + expect(io.stderr()).toContain('"outcome":"error"'); + expect(io.stderr()).toContain('"errorDetail":"ETIMEDOUT: introspection timed out"'); + }); + it('passes KTX daemon options to local ingest adapters when no explicit daemon URL is set', async () => { await initKtxProject({ projectDir: tempDir }); const createLocalIngestAdapters = vi.fn(() => []); diff --git a/packages/cli/test/setup-context.test.ts b/packages/cli/test/setup-context.test.ts index d04e24e1..2655527b 100644 --- a/packages/cli/test/setup-context.test.ts +++ b/packages/cli/test/setup-context.test.ts @@ -332,6 +332,30 @@ describe('setup context build state', () => { }); }); + it('captures the raw errorDetail on the result when the context build throws', async () => { + await writeReadyProject(tempDir); + const io = makeIo(); + const runContextBuildMock = vi.fn>(async () => { + throw new Error('managed runtime exited with code 1'); + }); + + await expect( + runKtxSetupContextStep( + { projectDir: tempDir, inputMode: 'disabled' }, + io.io, + { + runIdFactory: () => 'setup-context-local-throw', + now: () => new Date('2026-05-09T10:00:00.000Z'), + runContextBuild: runContextBuildMock, + }, + ), + ).resolves.toEqual({ + status: 'failed', + projectDir: tempDir, + errorDetail: 'managed runtime exited with code 1', + }); + }); + it('marks context complete without prompting when initial source ingest already made agent context', async () => { await writeReadyProject(tempDir); await mkdir(join(tempDir, 'semantic-layer', 'dbt-main'), { recursive: true }); diff --git a/packages/cli/test/telemetry/command-hook.test.ts b/packages/cli/test/telemetry/command-hook.test.ts index 92105151..63909ac6 100644 --- a/packages/cli/test/telemetry/command-hook.test.ts +++ b/packages/cli/test/telemetry/command-hook.test.ts @@ -34,4 +34,23 @@ describe('telemetry command hook', () => { resetCommandSpan(); expect(completeCommandSpan({ completedAt: 200, outcome: 'ok' })).toBeUndefined(); }); + + it('captures errorClass and raw errorDetail on a failed command', () => { + resetCommandSpan(); + beginCommandSpan({ + commandPath: ['ktx', 'ingest'], + flagsPresent: {}, + hasProject: true, + attachProjectGroup: false, + startedAt: 0, + }); + + class KtxConnectionError extends Error {} + const error = new KtxConnectionError('connect ECONNREFUSED 127.0.0.1:5432'); + + const completed = completeCommandSpan({ completedAt: 10, outcome: 'error', error }); + expect(completed?.outcome).toBe('error'); + expect(completed?.errorClass).toBe('KtxConnectionError'); + expect(completed?.errorDetail).toBe('connect ECONNREFUSED 127.0.0.1:5432'); + }); }); diff --git a/packages/cli/test/telemetry/scrubber.test.ts b/packages/cli/test/telemetry/scrubber.test.ts index a12946d4..a6914665 100644 --- a/packages/cli/test/telemetry/scrubber.test.ts +++ b/packages/cli/test/telemetry/scrubber.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; -import { scrubErrorClass } from '../../src/telemetry/scrubber.js'; +import { formatErrorDetail, scrubErrorClass } from '../../src/telemetry/scrubber.js'; class KtxProjectMissingAbortError extends Error {} @@ -23,3 +23,39 @@ describe('scrubErrorClass', () => { expect(scrubErrorClass(null)).toBeUndefined(); }); }); + +describe('formatErrorDetail', () => { + it('prefixes a string or numeric .code onto the message', () => { + const refused = new Error('connect failed'); + (refused as { code?: unknown }).code = 'ECONNREFUSED'; + expect(formatErrorDetail(refused)).toBe('ECONNREFUSED: connect failed'); + + const forbidden = new Error('forbidden'); + (forbidden as { code?: unknown }).code = 403; + expect(formatErrorDetail(forbidden)).toBe('403: forbidden'); + }); + + it('uses the bare message when there is no .code', () => { + expect(formatErrorDetail(new Error('password authentication failed for user "x"'))).toBe( + 'password authentication failed for user "x"', + ); + }); + + it('accepts non-Error values', () => { + expect(formatErrorDetail('boom')).toBe('boom'); + }); + + it('collapses whitespace to a single line', () => { + expect(formatErrorDetail(new Error('line one\n line two'))).toBe('line one line two'); + }); + + it('caps the length at 1000 characters', () => { + expect(formatErrorDetail(new Error('x'.repeat(2000)))?.length).toBe(1000); + }); + + it('returns undefined for empty, null, or undefined input', () => { + expect(formatErrorDetail(new Error(' '))).toBeUndefined(); + expect(formatErrorDetail(null)).toBeUndefined(); + expect(formatErrorDetail(undefined)).toBeUndefined(); + }); +}); diff --git a/python/ktx-daemon/src/ktx_daemon/telemetry/events.schema.json b/python/ktx-daemon/src/ktx_daemon/telemetry/events.schema.json index acad7988..a75f92f1 100644 --- a/python/ktx-daemon/src/ktx_daemon/telemetry/events.schema.json +++ b/python/ktx-daemon/src/ktx_daemon/telemetry/events.schema.json @@ -26,6 +26,7 @@ "durationMs", "outcome", "errorClass", + "errorDetail", "flagsPresent", "hasProject", "projectGroupAttached" @@ -37,7 +38,8 @@ "fields": [ "step", "outcome", - "durationMs" + "durationMs", + "errorDetail" ] }, { @@ -56,6 +58,7 @@ "isDemoConnection", "outcome", "errorClass", + "errorDetail", "durationMs", "serverVersion" ] @@ -84,7 +87,8 @@ "rowsBucket", "durationMs", "outcome", - "errorClass" + "errorClass", + "errorDetail" ] }, { @@ -98,7 +102,8 @@ "declaredFkCount", "durationMs", "outcome", - "errorClass" + "errorClass", + "errorDetail" ] }, { @@ -296,6 +301,10 @@ "errorClass": { "type": "string" }, + "errorDetail": { + "type": "string", + "maxLength": 1000 + }, "flagsPresent": { "type": "object", "propertyNames": { @@ -384,6 +393,10 @@ "durationMs": { "type": "number", "minimum": 0 + }, + "errorDetail": { + "type": "string", + "maxLength": 1000 } }, "required": [ @@ -494,6 +507,10 @@ "errorClass": { "type": "string" }, + "errorDetail": { + "type": "string", + "maxLength": 1000 + }, "durationMs": { "type": "number", "minimum": 0 @@ -673,6 +690,10 @@ }, "errorClass": { "type": "string" + }, + "errorDetail": { + "type": "string", + "maxLength": 1000 } }, "required": [ @@ -759,6 +780,10 @@ }, "errorClass": { "type": "string" + }, + "errorDetail": { + "type": "string", + "maxLength": 1000 } }, "required": [ diff --git a/uv.lock b/uv.lock index f04683f3..6d00951d 100644 --- a/uv.lock +++ b/uv.lock @@ -2,21 +2,21 @@ version = 1 revision = 3 requires-python = ">=3.13" resolution-markers = [ - "python_full_version >= '3.14' and sys_platform == 'win32'", - "python_full_version >= '3.14' and sys_platform == 'emscripten'", - "python_full_version >= '3.14' and sys_platform != 'darwin' and sys_platform != 'emscripten' and sys_platform != 'win32'", - "python_full_version >= '3.14' and sys_platform == 'darwin'", - "python_full_version < '3.14' and sys_platform == 'win32'", - "python_full_version < '3.14' and sys_platform == 'emscripten'", - "python_full_version < '3.14' and sys_platform != 'darwin' and sys_platform != 'emscripten' and sys_platform != 'win32'", - "python_full_version < '3.14' and sys_platform == 'darwin'", + "python_full_version >= '3.14' and sys_platform == 'win32'", + "python_full_version >= '3.14' and sys_platform == 'emscripten'", + "python_full_version >= '3.14' and sys_platform != 'darwin' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version >= '3.14' and sys_platform == 'darwin'", + "python_full_version < '3.14' and sys_platform == 'win32'", + "python_full_version < '3.14' and sys_platform == 'emscripten'", + "python_full_version < '3.14' and sys_platform != 'darwin' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version < '3.14' and sys_platform == 'darwin'", ] [manifest] members = [ - "ktx-daemon", - "ktx-sl", - "ktx-workspace", + "ktx-daemon", + "ktx-sl", + "ktx-workspace", ] [[package]] @@ -25,7 +25,7 @@ version = "0.0.4" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/57/ba/046ceea27344560984e26a590f90bc7f4a75b06701f653222458922b558c/annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4", size = 7288, upload-time = "2025-11-10T22:07:42.062Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320", size = 5303, upload-time = "2025-11-10T22:07:40.673Z" }, + { url = "https://files.pythonhosted.org/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320", size = 5303, upload-time = "2025-11-10T22:07:40.673Z" }, ] [[package]] @@ -34,7 +34,7 @@ version = "0.7.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, + { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, ] [[package]] @@ -42,11 +42,11 @@ name = "anyio" version = "4.13.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "idna" }, + { name = "idna" }, ] sdist = { url = "https://files.pythonhosted.org/packages/19/14/2c5dd9f512b66549ae92767a9c7b330ae88e1932ca57876909410251fe13/anyio-4.13.0.tar.gz", hash = "sha256:334b70e641fd2221c1505b3890c69882fe4a2df910cba14d97019b90b24439dc", size = 231622, upload-time = "2026-03-24T12:59:09.671Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/da/42/e921fccf5015463e32a3cf6ee7f980a6ed0f395ceeaa45060b61d86486c2/anyio-4.13.0-py3-none-any.whl", hash = "sha256:08b310f9e24a9594186fd75b4f73f4a4152069e3853f1ed8bfbf58369f4ad708", size = 114353, upload-time = "2026-03-24T12:59:08.246Z" }, + { url = "https://files.pythonhosted.org/packages/da/42/e921fccf5015463e32a3cf6ee7f980a6ed0f395ceeaa45060b61d86486c2/anyio-4.13.0-py3-none-any.whl", hash = "sha256:08b310f9e24a9594186fd75b4f73f4a4152069e3853f1ed8bfbf58369f4ad708", size = 114353, upload-time = "2026-03-24T12:59:08.246Z" }, ] [[package]] @@ -55,7 +55,7 @@ version = "2.2.1" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/47/d7/5bbeb12c44d7c4f2fb5b56abce497eb5ed9f34d85701de869acedd602619/backoff-2.2.1.tar.gz", hash = "sha256:03f829f5bb1923180821643f8753b0502c3b682293992485b0eef2807afa5cba", size = 17001, upload-time = "2022-10-05T19:19:32.061Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/df/73/b6e24bd22e6720ca8ee9a85a0c4a2971af8497d8f3193fa05390cbd46e09/backoff-2.2.1-py3-none-any.whl", hash = "sha256:63579f9a0628e06278f7e47b7d7d5b6ce20dc65c5e96a6f3ca99a6adca0396e8", size = 15148, upload-time = "2022-10-05T19:19:30.546Z" }, + { url = "https://files.pythonhosted.org/packages/df/73/b6e24bd22e6720ca8ee9a85a0c4a2971af8497d8f3193fa05390cbd46e09/backoff-2.2.1-py3-none-any.whl", hash = "sha256:63579f9a0628e06278f7e47b7d7d5b6ce20dc65c5e96a6f3ca99a6adca0396e8", size = 15148, upload-time = "2022-10-05T19:19:30.546Z" }, ] [[package]] @@ -64,7 +64,7 @@ version = "2026.5.20" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/f3/ce/ee2ecad540810a79593028e88299baeae54d346cc7a0d94b6199988b89b1/certifi-2026.5.20.tar.gz", hash = "sha256:69dea482ab64caa7b9f6aba1c6bf48bb6a5448d1c0f1b17ab42ad8c763a5344d", size = 135422, upload-time = "2026-05-20T11:46:50.073Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/59/8c/57e832b7af6d7c5abe66eb3fbe3a3a32f4d11ea23a1aa7131371035be991/certifi-2026.5.20-py3-none-any.whl", hash = "sha256:3c52e209ba0a4ad7aebe60436a4ab349c39e1e602e8c134221e546902ad25897", size = 134134, upload-time = "2026-05-20T11:46:48.578Z" }, + { url = "https://files.pythonhosted.org/packages/59/8c/57e832b7af6d7c5abe66eb3fbe3a3a32f4d11ea23a1aa7131371035be991/certifi-2026.5.20-py3-none-any.whl", hash = "sha256:3c52e209ba0a4ad7aebe60436a4ab349c39e1e602e8c134221e546902ad25897", size = 134134, upload-time = "2026-05-20T11:46:48.578Z" }, ] [[package]] @@ -73,7 +73,7 @@ version = "3.5.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/4e/b5/721b8799b04bf9afe054a3899c6cf4e880fcf8563cc71c15610242490a0c/cfgv-3.5.0.tar.gz", hash = "sha256:d5b1034354820651caa73ede66a6294d6e95c1b00acc5e9b098e917404669132", size = 7334, upload-time = "2025-11-19T20:55:51.612Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/db/3c/33bac158f8ab7f89b2e59426d5fe2e4f63f7ed25df84c036890172b412b5/cfgv-3.5.0-py2.py3-none-any.whl", hash = "sha256:a8dc6b26ad22ff227d2634a65cb388215ce6cc96bbcc5cfde7641ae87e8dacc0", size = 7445, upload-time = "2025-11-19T20:55:50.744Z" }, + { url = "https://files.pythonhosted.org/packages/db/3c/33bac158f8ab7f89b2e59426d5fe2e4f63f7ed25df84c036890172b412b5/cfgv-3.5.0-py2.py3-none-any.whl", hash = "sha256:a8dc6b26ad22ff227d2634a65cb388215ce6cc96bbcc5cfde7641ae87e8dacc0", size = 7445, upload-time = "2025-11-19T20:55:50.744Z" }, ] [[package]] @@ -82,55 +82,55 @@ version = "3.4.7" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/e7/a1/67fe25fac3c7642725500a3f6cfe5821ad557c3abb11c9d20d12c7008d3e/charset_normalizer-3.4.7.tar.gz", hash = "sha256:ae89db9e5f98a11a4bf50407d4363e7b09b31e55bc117b4f7d80aab97ba009e5", size = 144271, upload-time = "2026-04-02T09:28:39.342Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c1/3b/66777e39d3ae1ddc77ee606be4ec6d8cbd4c801f65e5a1b6f2b11b8346dd/charset_normalizer-3.4.7-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:f496c9c3cc02230093d8330875c4c3cdfc3b73612a5fd921c65d39cbcef08063", size = 309627, upload-time = "2026-04-02T09:26:45.198Z" }, - { url = "https://files.pythonhosted.org/packages/2e/4e/b7f84e617b4854ade48a1b7915c8ccfadeba444d2a18c291f696e37f0d3b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ea948db76d31190bf08bd371623927ee1339d5f2a0b4b1b4a4439a65298703c", size = 207008, upload-time = "2026-04-02T09:26:46.824Z" }, - { url = "https://files.pythonhosted.org/packages/c4/bb/ec73c0257c9e11b268f018f068f5d00aa0ef8c8b09f7753ebd5f2880e248/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a277ab8928b9f299723bc1a2dabb1265911b1a76341f90a510368ca44ad9ab66", size = 228303, upload-time = "2026-04-02T09:26:48.397Z" }, - { url = "https://files.pythonhosted.org/packages/85/fb/32d1f5033484494619f701e719429c69b766bfc4dbc61aa9e9c8c166528b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3bec022aec2c514d9cf199522a802bd007cd588ab17ab2525f20f9c34d067c18", size = 224282, upload-time = "2026-04-02T09:26:49.684Z" }, - { url = "https://files.pythonhosted.org/packages/fa/07/330e3a0dda4c404d6da83b327270906e9654a24f6c546dc886a0eb0ffb23/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e044c39e41b92c845bc815e5ae4230804e8e7bc29e399b0437d64222d92809dd", size = 215595, upload-time = "2026-04-02T09:26:50.915Z" }, - { url = "https://files.pythonhosted.org/packages/e3/7c/fc890655786e423f02556e0216d4b8c6bcb6bdfa890160dc66bf52dee468/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:f495a1652cf3fbab2eb0639776dad966c2fb874d79d87ca07f9d5f059b8bd215", size = 201986, upload-time = "2026-04-02T09:26:52.197Z" }, - { url = "https://files.pythonhosted.org/packages/d8/97/bfb18b3db2aed3b90cf54dc292ad79fdd5ad65c4eae454099475cbeadd0d/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e712b419df8ba5e42b226c510472b37bd57b38e897d3eca5e8cfd410a29fa859", size = 211711, upload-time = "2026-04-02T09:26:53.49Z" }, - { url = "https://files.pythonhosted.org/packages/6f/a5/a581c13798546a7fd557c82614a5c65a13df2157e9ad6373166d2a3e645d/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7804338df6fcc08105c7745f1502ba68d900f45fd770d5bdd5288ddccb8a42d8", size = 210036, upload-time = "2026-04-02T09:26:54.975Z" }, - { url = "https://files.pythonhosted.org/packages/8c/bf/b3ab5bcb478e4193d517644b0fb2bf5497fbceeaa7a1bc0f4d5b50953861/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:481551899c856c704d58119b5025793fa6730adda3571971af568f66d2424bb5", size = 202998, upload-time = "2026-04-02T09:26:56.303Z" }, - { url = "https://files.pythonhosted.org/packages/e7/4e/23efd79b65d314fa320ec6017b4b5834d5c12a58ba4610aa353af2e2f577/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:f59099f9b66f0d7145115e6f80dd8b1d847176df89b234a5a6b3f00437aa0832", size = 230056, upload-time = "2026-04-02T09:26:57.554Z" }, - { url = "https://files.pythonhosted.org/packages/b9/9f/1e1941bc3f0e01df116e68dc37a55c4d249df5e6fa77f008841aef68264f/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:f59ad4c0e8f6bba240a9bb85504faa1ab438237199d4cce5f622761507b8f6a6", size = 211537, upload-time = "2026-04-02T09:26:58.843Z" }, - { url = "https://files.pythonhosted.org/packages/80/0f/088cbb3020d44428964a6c97fe1edfb1b9550396bf6d278330281e8b709c/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:3dedcc22d73ec993f42055eff4fcfed9318d1eeb9a6606c55892a26964964e48", size = 226176, upload-time = "2026-04-02T09:27:00.437Z" }, - { url = "https://files.pythonhosted.org/packages/6a/9f/130394f9bbe06f4f63e22641d32fc9b202b7e251c9aef4db044324dac493/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:64f02c6841d7d83f832cd97ccf8eb8a906d06eb95d5276069175c696b024b60a", size = 217723, upload-time = "2026-04-02T09:27:02.021Z" }, - { url = "https://files.pythonhosted.org/packages/73/55/c469897448a06e49f8fa03f6caae97074fde823f432a98f979cc42b90e69/charset_normalizer-3.4.7-cp313-cp313-win32.whl", hash = "sha256:4042d5c8f957e15221d423ba781e85d553722fc4113f523f2feb7b188cc34c5e", size = 148085, upload-time = "2026-04-02T09:27:03.192Z" }, - { url = "https://files.pythonhosted.org/packages/5d/78/1b74c5bbb3f99b77a1715c91b3e0b5bdb6fe302d95ace4f5b1bec37b0167/charset_normalizer-3.4.7-cp313-cp313-win_amd64.whl", hash = "sha256:3946fa46a0cf3e4c8cb1cc52f56bb536310d34f25f01ca9b6c16afa767dab110", size = 158819, upload-time = "2026-04-02T09:27:04.454Z" }, - { url = "https://files.pythonhosted.org/packages/68/86/46bd42279d323deb8687c4a5a811fd548cb7d1de10cf6535d099877a9a9f/charset_normalizer-3.4.7-cp313-cp313-win_arm64.whl", hash = "sha256:80d04837f55fc81da168b98de4f4b797ef007fc8a79ab71c6ec9bc4dd662b15b", size = 147915, upload-time = "2026-04-02T09:27:05.971Z" }, - { url = "https://files.pythonhosted.org/packages/97/c8/c67cb8c70e19ef1960b97b22ed2a1567711de46c4ddf19799923adc836c2/charset_normalizer-3.4.7-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:c36c333c39be2dbca264d7803333c896ab8fa7d4d6f0ab7edb7dfd7aea6e98c0", size = 309234, upload-time = "2026-04-02T09:27:07.194Z" }, - { url = "https://files.pythonhosted.org/packages/99/85/c091fdee33f20de70d6c8b522743b6f831a2f1cd3ff86de4c6a827c48a76/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1c2aed2e5e41f24ea8ef1590b8e848a79b56f3a5564a65ceec43c9d692dc7d8a", size = 208042, upload-time = "2026-04-02T09:27:08.749Z" }, - { url = "https://files.pythonhosted.org/packages/87/1c/ab2ce611b984d2fd5d86a5a8a19c1ae26acac6bad967da4967562c75114d/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:54523e136b8948060c0fa0bc7b1b50c32c186f2fceee897a495406bb6e311d2b", size = 228706, upload-time = "2026-04-02T09:27:09.951Z" }, - { url = "https://files.pythonhosted.org/packages/a8/29/2b1d2cb00bf085f59d29eb773ce58ec2d325430f8c216804a0a5cd83cbca/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:715479b9a2802ecac752a3b0efa2b0b60285cf962ee38414211abdfccc233b41", size = 224727, upload-time = "2026-04-02T09:27:11.175Z" }, - { url = "https://files.pythonhosted.org/packages/47/5c/032c2d5a07fe4d4855fea851209cca2b6f03ebeb6d4e3afdb3358386a684/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bd6c2a1c7573c64738d716488d2cdd3c00e340e4835707d8fdb8dc1a66ef164e", size = 215882, upload-time = "2026-04-02T09:27:12.446Z" }, - { url = "https://files.pythonhosted.org/packages/2c/c2/356065d5a8b78ed04499cae5f339f091946a6a74f91e03476c33f0ab7100/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:c45e9440fb78f8ddabcf714b68f936737a121355bf59f3907f4e17721b9d1aae", size = 200860, upload-time = "2026-04-02T09:27:13.721Z" }, - { url = "https://files.pythonhosted.org/packages/0c/cd/a32a84217ced5039f53b29f460962abb2d4420def55afabe45b1c3c7483d/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3534e7dcbdcf757da6b85a0bbf5b6868786d5982dd959b065e65481644817a18", size = 211564, upload-time = "2026-04-02T09:27:15.272Z" }, - { url = "https://files.pythonhosted.org/packages/44/86/58e6f13ce26cc3b8f4a36b94a0f22ae2f00a72534520f4ae6857c4b81f89/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e8ac484bf18ce6975760921bb6148041faa8fef0547200386ea0b52b5d27bf7b", size = 211276, upload-time = "2026-04-02T09:27:16.834Z" }, - { url = "https://files.pythonhosted.org/packages/8f/fe/d17c32dc72e17e155e06883efa84514ca375f8a528ba2546bee73fc4df81/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:a5fe03b42827c13cdccd08e6c0247b6a6d4b5e3cdc53fd1749f5896adcdc2356", size = 201238, upload-time = "2026-04-02T09:27:18.229Z" }, - { url = "https://files.pythonhosted.org/packages/6a/29/f33daa50b06525a237451cdb6c69da366c381a3dadcd833fa5676bc468b3/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:2d6eb928e13016cea4f1f21d1e10c1cebd5a421bc57ddf5b1142ae3f86824fab", size = 230189, upload-time = "2026-04-02T09:27:19.445Z" }, - { url = "https://files.pythonhosted.org/packages/b6/6e/52c84015394a6a0bdcd435210a7e944c5f94ea1055f5cc5d56c5fe368e7b/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e74327fb75de8986940def6e8dee4f127cc9752bee7355bb323cc5b2659b6d46", size = 211352, upload-time = "2026-04-02T09:27:20.79Z" }, - { url = "https://files.pythonhosted.org/packages/8c/d7/4353be581b373033fb9198bf1da3cf8f09c1082561e8e922aa7b39bf9fe8/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:d6038d37043bced98a66e68d3aa2b6a35505dc01328cd65217cefe82f25def44", size = 227024, upload-time = "2026-04-02T09:27:22.063Z" }, - { url = "https://files.pythonhosted.org/packages/30/45/99d18aa925bd1740098ccd3060e238e21115fffbfdcb8f3ece837d0ace6c/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7579e913a5339fb8fa133f6bbcfd8e6749696206cf05acdbdca71a1b436d8e72", size = 217869, upload-time = "2026-04-02T09:27:23.486Z" }, - { url = "https://files.pythonhosted.org/packages/5c/05/5ee478aa53f4bb7996482153d4bfe1b89e0f087f0ab6b294fcf92d595873/charset_normalizer-3.4.7-cp314-cp314-win32.whl", hash = "sha256:5b77459df20e08151cd6f8b9ef8ef1f961ef73d85c21a555c7eed5b79410ec10", size = 148541, upload-time = "2026-04-02T09:27:25.146Z" }, - { url = "https://files.pythonhosted.org/packages/48/77/72dcb0921b2ce86420b2d79d454c7022bf5be40202a2a07906b9f2a35c97/charset_normalizer-3.4.7-cp314-cp314-win_amd64.whl", hash = "sha256:92a0a01ead5e668468e952e4238cccd7c537364eb7d851ab144ab6627dbbe12f", size = 159634, upload-time = "2026-04-02T09:27:26.642Z" }, - { url = "https://files.pythonhosted.org/packages/c6/a3/c2369911cd72f02386e4e340770f6e158c7980267da16af8f668217abaa0/charset_normalizer-3.4.7-cp314-cp314-win_arm64.whl", hash = "sha256:67f6279d125ca0046a7fd386d01b311c6363844deac3e5b069b514ba3e63c246", size = 148384, upload-time = "2026-04-02T09:27:28.271Z" }, - { url = "https://files.pythonhosted.org/packages/94/09/7e8a7f73d24dba1f0035fbbf014d2c36828fc1bf9c88f84093e57d315935/charset_normalizer-3.4.7-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:effc3f449787117233702311a1b7d8f59cba9ced946ba727bdc329ec69028e24", size = 330133, upload-time = "2026-04-02T09:27:29.474Z" }, - { url = "https://files.pythonhosted.org/packages/8d/da/96975ddb11f8e977f706f45cddd8540fd8242f71ecdb5d18a80723dcf62c/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fbccdc05410c9ee21bbf16a35f4c1d16123dcdeb8a1d38f33654fa21d0234f79", size = 216257, upload-time = "2026-04-02T09:27:30.793Z" }, - { url = "https://files.pythonhosted.org/packages/e5/e8/1d63bf8ef2d388e95c64b2098f45f84758f6d102a087552da1485912637b/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:733784b6d6def852c814bce5f318d25da2ee65dd4839a0718641c696e09a2960", size = 234851, upload-time = "2026-04-02T09:27:32.44Z" }, - { url = "https://files.pythonhosted.org/packages/9b/40/e5ff04233e70da2681fa43969ad6f66ca5611d7e669be0246c4c7aaf6dc8/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a89c23ef8d2c6b27fd200a42aa4ac72786e7c60d40efdc76e6011260b6e949c4", size = 233393, upload-time = "2026-04-02T09:27:34.03Z" }, - { url = "https://files.pythonhosted.org/packages/be/c1/06c6c49d5a5450f76899992f1ee40b41d076aee9279b49cf9974d2f313d5/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6c114670c45346afedc0d947faf3c7f701051d2518b943679c8ff88befe14f8e", size = 223251, upload-time = "2026-04-02T09:27:35.369Z" }, - { url = "https://files.pythonhosted.org/packages/2b/9f/f2ff16fb050946169e3e1f82134d107e5d4ae72647ec8a1b1446c148480f/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:a180c5e59792af262bf263b21a3c49353f25945d8d9f70628e73de370d55e1e1", size = 206609, upload-time = "2026-04-02T09:27:36.661Z" }, - { url = "https://files.pythonhosted.org/packages/69/d5/a527c0cd8d64d2eab7459784fb4169a0ac76e5a6fc5237337982fd61347e/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3c9a494bc5ec77d43cea229c4f6db1e4d8fe7e1bbffa8b6f0f0032430ff8ab44", size = 220014, upload-time = "2026-04-02T09:27:38.019Z" }, - { url = "https://files.pythonhosted.org/packages/7e/80/8a7b8104a3e203074dc9aa2c613d4b726c0e136bad1cc734594b02867972/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8d828b6667a32a728a1ad1d93957cdf37489c57b97ae6c4de2860fa749b8fc1e", size = 218979, upload-time = "2026-04-02T09:27:39.37Z" }, - { url = "https://files.pythonhosted.org/packages/02/9a/b759b503d507f375b2b5c153e4d2ee0a75aa215b7f2489cf314f4541f2c0/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:cf1493cd8607bec4d8a7b9b004e699fcf8f9103a9284cc94962cb73d20f9d4a3", size = 209238, upload-time = "2026-04-02T09:27:40.722Z" }, - { url = "https://files.pythonhosted.org/packages/c2/4e/0f3f5d47b86bdb79256e7290b26ac847a2832d9a4033f7eb2cd4bcf4bb5b/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:0c96c3b819b5c3e9e165495db84d41914d6894d55181d2d108cc1a69bfc9cce0", size = 236110, upload-time = "2026-04-02T09:27:42.33Z" }, - { url = "https://files.pythonhosted.org/packages/96/23/bce28734eb3ed2c91dcf93abeb8a5cf393a7b2749725030bb630e554fdd8/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:752a45dc4a6934060b3b0dab47e04edc3326575f82be64bc4fc293914566503e", size = 219824, upload-time = "2026-04-02T09:27:43.924Z" }, - { url = "https://files.pythonhosted.org/packages/2c/6f/6e897c6984cc4d41af319b077f2f600fc8214eb2fe2d6bcb79141b882400/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:8778f0c7a52e56f75d12dae53ae320fae900a8b9b4164b981b9c5ce059cd1fcb", size = 233103, upload-time = "2026-04-02T09:27:45.348Z" }, - { url = "https://files.pythonhosted.org/packages/76/22/ef7bd0fe480a0ae9b656189ec00744b60933f68b4f42a7bb06589f6f576a/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ce3412fbe1e31eb81ea42f4169ed94861c56e643189e1e75f0041f3fe7020abe", size = 225194, upload-time = "2026-04-02T09:27:46.706Z" }, - { url = "https://files.pythonhosted.org/packages/c5/a7/0e0ab3e0b5bc1219bd80a6a0d4d72ca74d9250cb2382b7c699c147e06017/charset_normalizer-3.4.7-cp314-cp314t-win32.whl", hash = "sha256:c03a41a8784091e67a39648f70c5f97b5b6a37f216896d44d2cdcb82615339a0", size = 159827, upload-time = "2026-04-02T09:27:48.053Z" }, - { url = "https://files.pythonhosted.org/packages/7a/1d/29d32e0fb40864b1f878c7f5a0b343ae676c6e2b271a2d55cc3a152391da/charset_normalizer-3.4.7-cp314-cp314t-win_amd64.whl", hash = "sha256:03853ed82eeebbce3c2abfdbc98c96dc205f32a79627688ac9a27370ea61a49c", size = 174168, upload-time = "2026-04-02T09:27:49.795Z" }, - { url = "https://files.pythonhosted.org/packages/de/32/d92444ad05c7a6e41fb2036749777c163baf7a0301a040cb672d6b2b1ae9/charset_normalizer-3.4.7-cp314-cp314t-win_arm64.whl", hash = "sha256:c35abb8bfff0185efac5878da64c45dafd2b37fb0383add1be155a763c1f083d", size = 153018, upload-time = "2026-04-02T09:27:51.116Z" }, - { url = "https://files.pythonhosted.org/packages/db/8f/61959034484a4a7c527811f4721e75d02d653a35afb0b6054474d8185d4c/charset_normalizer-3.4.7-py3-none-any.whl", hash = "sha256:3dce51d0f5e7951f8bb4900c257dad282f49190fdbebecd4ba99bcc41fef404d", size = 61958, upload-time = "2026-04-02T09:28:37.794Z" }, + { url = "https://files.pythonhosted.org/packages/c1/3b/66777e39d3ae1ddc77ee606be4ec6d8cbd4c801f65e5a1b6f2b11b8346dd/charset_normalizer-3.4.7-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:f496c9c3cc02230093d8330875c4c3cdfc3b73612a5fd921c65d39cbcef08063", size = 309627, upload-time = "2026-04-02T09:26:45.198Z" }, + { url = "https://files.pythonhosted.org/packages/2e/4e/b7f84e617b4854ade48a1b7915c8ccfadeba444d2a18c291f696e37f0d3b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ea948db76d31190bf08bd371623927ee1339d5f2a0b4b1b4a4439a65298703c", size = 207008, upload-time = "2026-04-02T09:26:46.824Z" }, + { url = "https://files.pythonhosted.org/packages/c4/bb/ec73c0257c9e11b268f018f068f5d00aa0ef8c8b09f7753ebd5f2880e248/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a277ab8928b9f299723bc1a2dabb1265911b1a76341f90a510368ca44ad9ab66", size = 228303, upload-time = "2026-04-02T09:26:48.397Z" }, + { url = "https://files.pythonhosted.org/packages/85/fb/32d1f5033484494619f701e719429c69b766bfc4dbc61aa9e9c8c166528b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3bec022aec2c514d9cf199522a802bd007cd588ab17ab2525f20f9c34d067c18", size = 224282, upload-time = "2026-04-02T09:26:49.684Z" }, + { url = "https://files.pythonhosted.org/packages/fa/07/330e3a0dda4c404d6da83b327270906e9654a24f6c546dc886a0eb0ffb23/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e044c39e41b92c845bc815e5ae4230804e8e7bc29e399b0437d64222d92809dd", size = 215595, upload-time = "2026-04-02T09:26:50.915Z" }, + { url = "https://files.pythonhosted.org/packages/e3/7c/fc890655786e423f02556e0216d4b8c6bcb6bdfa890160dc66bf52dee468/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:f495a1652cf3fbab2eb0639776dad966c2fb874d79d87ca07f9d5f059b8bd215", size = 201986, upload-time = "2026-04-02T09:26:52.197Z" }, + { url = "https://files.pythonhosted.org/packages/d8/97/bfb18b3db2aed3b90cf54dc292ad79fdd5ad65c4eae454099475cbeadd0d/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e712b419df8ba5e42b226c510472b37bd57b38e897d3eca5e8cfd410a29fa859", size = 211711, upload-time = "2026-04-02T09:26:53.49Z" }, + { url = "https://files.pythonhosted.org/packages/6f/a5/a581c13798546a7fd557c82614a5c65a13df2157e9ad6373166d2a3e645d/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7804338df6fcc08105c7745f1502ba68d900f45fd770d5bdd5288ddccb8a42d8", size = 210036, upload-time = "2026-04-02T09:26:54.975Z" }, + { url = "https://files.pythonhosted.org/packages/8c/bf/b3ab5bcb478e4193d517644b0fb2bf5497fbceeaa7a1bc0f4d5b50953861/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:481551899c856c704d58119b5025793fa6730adda3571971af568f66d2424bb5", size = 202998, upload-time = "2026-04-02T09:26:56.303Z" }, + { url = "https://files.pythonhosted.org/packages/e7/4e/23efd79b65d314fa320ec6017b4b5834d5c12a58ba4610aa353af2e2f577/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:f59099f9b66f0d7145115e6f80dd8b1d847176df89b234a5a6b3f00437aa0832", size = 230056, upload-time = "2026-04-02T09:26:57.554Z" }, + { url = "https://files.pythonhosted.org/packages/b9/9f/1e1941bc3f0e01df116e68dc37a55c4d249df5e6fa77f008841aef68264f/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:f59ad4c0e8f6bba240a9bb85504faa1ab438237199d4cce5f622761507b8f6a6", size = 211537, upload-time = "2026-04-02T09:26:58.843Z" }, + { url = "https://files.pythonhosted.org/packages/80/0f/088cbb3020d44428964a6c97fe1edfb1b9550396bf6d278330281e8b709c/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:3dedcc22d73ec993f42055eff4fcfed9318d1eeb9a6606c55892a26964964e48", size = 226176, upload-time = "2026-04-02T09:27:00.437Z" }, + { url = "https://files.pythonhosted.org/packages/6a/9f/130394f9bbe06f4f63e22641d32fc9b202b7e251c9aef4db044324dac493/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:64f02c6841d7d83f832cd97ccf8eb8a906d06eb95d5276069175c696b024b60a", size = 217723, upload-time = "2026-04-02T09:27:02.021Z" }, + { url = "https://files.pythonhosted.org/packages/73/55/c469897448a06e49f8fa03f6caae97074fde823f432a98f979cc42b90e69/charset_normalizer-3.4.7-cp313-cp313-win32.whl", hash = "sha256:4042d5c8f957e15221d423ba781e85d553722fc4113f523f2feb7b188cc34c5e", size = 148085, upload-time = "2026-04-02T09:27:03.192Z" }, + { url = "https://files.pythonhosted.org/packages/5d/78/1b74c5bbb3f99b77a1715c91b3e0b5bdb6fe302d95ace4f5b1bec37b0167/charset_normalizer-3.4.7-cp313-cp313-win_amd64.whl", hash = "sha256:3946fa46a0cf3e4c8cb1cc52f56bb536310d34f25f01ca9b6c16afa767dab110", size = 158819, upload-time = "2026-04-02T09:27:04.454Z" }, + { url = "https://files.pythonhosted.org/packages/68/86/46bd42279d323deb8687c4a5a811fd548cb7d1de10cf6535d099877a9a9f/charset_normalizer-3.4.7-cp313-cp313-win_arm64.whl", hash = "sha256:80d04837f55fc81da168b98de4f4b797ef007fc8a79ab71c6ec9bc4dd662b15b", size = 147915, upload-time = "2026-04-02T09:27:05.971Z" }, + { url = "https://files.pythonhosted.org/packages/97/c8/c67cb8c70e19ef1960b97b22ed2a1567711de46c4ddf19799923adc836c2/charset_normalizer-3.4.7-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:c36c333c39be2dbca264d7803333c896ab8fa7d4d6f0ab7edb7dfd7aea6e98c0", size = 309234, upload-time = "2026-04-02T09:27:07.194Z" }, + { url = "https://files.pythonhosted.org/packages/99/85/c091fdee33f20de70d6c8b522743b6f831a2f1cd3ff86de4c6a827c48a76/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1c2aed2e5e41f24ea8ef1590b8e848a79b56f3a5564a65ceec43c9d692dc7d8a", size = 208042, upload-time = "2026-04-02T09:27:08.749Z" }, + { url = "https://files.pythonhosted.org/packages/87/1c/ab2ce611b984d2fd5d86a5a8a19c1ae26acac6bad967da4967562c75114d/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:54523e136b8948060c0fa0bc7b1b50c32c186f2fceee897a495406bb6e311d2b", size = 228706, upload-time = "2026-04-02T09:27:09.951Z" }, + { url = "https://files.pythonhosted.org/packages/a8/29/2b1d2cb00bf085f59d29eb773ce58ec2d325430f8c216804a0a5cd83cbca/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:715479b9a2802ecac752a3b0efa2b0b60285cf962ee38414211abdfccc233b41", size = 224727, upload-time = "2026-04-02T09:27:11.175Z" }, + { url = "https://files.pythonhosted.org/packages/47/5c/032c2d5a07fe4d4855fea851209cca2b6f03ebeb6d4e3afdb3358386a684/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bd6c2a1c7573c64738d716488d2cdd3c00e340e4835707d8fdb8dc1a66ef164e", size = 215882, upload-time = "2026-04-02T09:27:12.446Z" }, + { url = "https://files.pythonhosted.org/packages/2c/c2/356065d5a8b78ed04499cae5f339f091946a6a74f91e03476c33f0ab7100/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:c45e9440fb78f8ddabcf714b68f936737a121355bf59f3907f4e17721b9d1aae", size = 200860, upload-time = "2026-04-02T09:27:13.721Z" }, + { url = "https://files.pythonhosted.org/packages/0c/cd/a32a84217ced5039f53b29f460962abb2d4420def55afabe45b1c3c7483d/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3534e7dcbdcf757da6b85a0bbf5b6868786d5982dd959b065e65481644817a18", size = 211564, upload-time = "2026-04-02T09:27:15.272Z" }, + { url = "https://files.pythonhosted.org/packages/44/86/58e6f13ce26cc3b8f4a36b94a0f22ae2f00a72534520f4ae6857c4b81f89/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e8ac484bf18ce6975760921bb6148041faa8fef0547200386ea0b52b5d27bf7b", size = 211276, upload-time = "2026-04-02T09:27:16.834Z" }, + { url = "https://files.pythonhosted.org/packages/8f/fe/d17c32dc72e17e155e06883efa84514ca375f8a528ba2546bee73fc4df81/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:a5fe03b42827c13cdccd08e6c0247b6a6d4b5e3cdc53fd1749f5896adcdc2356", size = 201238, upload-time = "2026-04-02T09:27:18.229Z" }, + { url = "https://files.pythonhosted.org/packages/6a/29/f33daa50b06525a237451cdb6c69da366c381a3dadcd833fa5676bc468b3/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:2d6eb928e13016cea4f1f21d1e10c1cebd5a421bc57ddf5b1142ae3f86824fab", size = 230189, upload-time = "2026-04-02T09:27:19.445Z" }, + { url = "https://files.pythonhosted.org/packages/b6/6e/52c84015394a6a0bdcd435210a7e944c5f94ea1055f5cc5d56c5fe368e7b/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e74327fb75de8986940def6e8dee4f127cc9752bee7355bb323cc5b2659b6d46", size = 211352, upload-time = "2026-04-02T09:27:20.79Z" }, + { url = "https://files.pythonhosted.org/packages/8c/d7/4353be581b373033fb9198bf1da3cf8f09c1082561e8e922aa7b39bf9fe8/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:d6038d37043bced98a66e68d3aa2b6a35505dc01328cd65217cefe82f25def44", size = 227024, upload-time = "2026-04-02T09:27:22.063Z" }, + { url = "https://files.pythonhosted.org/packages/30/45/99d18aa925bd1740098ccd3060e238e21115fffbfdcb8f3ece837d0ace6c/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7579e913a5339fb8fa133f6bbcfd8e6749696206cf05acdbdca71a1b436d8e72", size = 217869, upload-time = "2026-04-02T09:27:23.486Z" }, + { url = "https://files.pythonhosted.org/packages/5c/05/5ee478aa53f4bb7996482153d4bfe1b89e0f087f0ab6b294fcf92d595873/charset_normalizer-3.4.7-cp314-cp314-win32.whl", hash = "sha256:5b77459df20e08151cd6f8b9ef8ef1f961ef73d85c21a555c7eed5b79410ec10", size = 148541, upload-time = "2026-04-02T09:27:25.146Z" }, + { url = "https://files.pythonhosted.org/packages/48/77/72dcb0921b2ce86420b2d79d454c7022bf5be40202a2a07906b9f2a35c97/charset_normalizer-3.4.7-cp314-cp314-win_amd64.whl", hash = "sha256:92a0a01ead5e668468e952e4238cccd7c537364eb7d851ab144ab6627dbbe12f", size = 159634, upload-time = "2026-04-02T09:27:26.642Z" }, + { url = "https://files.pythonhosted.org/packages/c6/a3/c2369911cd72f02386e4e340770f6e158c7980267da16af8f668217abaa0/charset_normalizer-3.4.7-cp314-cp314-win_arm64.whl", hash = "sha256:67f6279d125ca0046a7fd386d01b311c6363844deac3e5b069b514ba3e63c246", size = 148384, upload-time = "2026-04-02T09:27:28.271Z" }, + { url = "https://files.pythonhosted.org/packages/94/09/7e8a7f73d24dba1f0035fbbf014d2c36828fc1bf9c88f84093e57d315935/charset_normalizer-3.4.7-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:effc3f449787117233702311a1b7d8f59cba9ced946ba727bdc329ec69028e24", size = 330133, upload-time = "2026-04-02T09:27:29.474Z" }, + { url = "https://files.pythonhosted.org/packages/8d/da/96975ddb11f8e977f706f45cddd8540fd8242f71ecdb5d18a80723dcf62c/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fbccdc05410c9ee21bbf16a35f4c1d16123dcdeb8a1d38f33654fa21d0234f79", size = 216257, upload-time = "2026-04-02T09:27:30.793Z" }, + { url = "https://files.pythonhosted.org/packages/e5/e8/1d63bf8ef2d388e95c64b2098f45f84758f6d102a087552da1485912637b/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:733784b6d6def852c814bce5f318d25da2ee65dd4839a0718641c696e09a2960", size = 234851, upload-time = "2026-04-02T09:27:32.44Z" }, + { url = "https://files.pythonhosted.org/packages/9b/40/e5ff04233e70da2681fa43969ad6f66ca5611d7e669be0246c4c7aaf6dc8/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a89c23ef8d2c6b27fd200a42aa4ac72786e7c60d40efdc76e6011260b6e949c4", size = 233393, upload-time = "2026-04-02T09:27:34.03Z" }, + { url = "https://files.pythonhosted.org/packages/be/c1/06c6c49d5a5450f76899992f1ee40b41d076aee9279b49cf9974d2f313d5/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6c114670c45346afedc0d947faf3c7f701051d2518b943679c8ff88befe14f8e", size = 223251, upload-time = "2026-04-02T09:27:35.369Z" }, + { url = "https://files.pythonhosted.org/packages/2b/9f/f2ff16fb050946169e3e1f82134d107e5d4ae72647ec8a1b1446c148480f/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:a180c5e59792af262bf263b21a3c49353f25945d8d9f70628e73de370d55e1e1", size = 206609, upload-time = "2026-04-02T09:27:36.661Z" }, + { url = "https://files.pythonhosted.org/packages/69/d5/a527c0cd8d64d2eab7459784fb4169a0ac76e5a6fc5237337982fd61347e/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3c9a494bc5ec77d43cea229c4f6db1e4d8fe7e1bbffa8b6f0f0032430ff8ab44", size = 220014, upload-time = "2026-04-02T09:27:38.019Z" }, + { url = "https://files.pythonhosted.org/packages/7e/80/8a7b8104a3e203074dc9aa2c613d4b726c0e136bad1cc734594b02867972/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8d828b6667a32a728a1ad1d93957cdf37489c57b97ae6c4de2860fa749b8fc1e", size = 218979, upload-time = "2026-04-02T09:27:39.37Z" }, + { url = "https://files.pythonhosted.org/packages/02/9a/b759b503d507f375b2b5c153e4d2ee0a75aa215b7f2489cf314f4541f2c0/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:cf1493cd8607bec4d8a7b9b004e699fcf8f9103a9284cc94962cb73d20f9d4a3", size = 209238, upload-time = "2026-04-02T09:27:40.722Z" }, + { url = "https://files.pythonhosted.org/packages/c2/4e/0f3f5d47b86bdb79256e7290b26ac847a2832d9a4033f7eb2cd4bcf4bb5b/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:0c96c3b819b5c3e9e165495db84d41914d6894d55181d2d108cc1a69bfc9cce0", size = 236110, upload-time = "2026-04-02T09:27:42.33Z" }, + { url = "https://files.pythonhosted.org/packages/96/23/bce28734eb3ed2c91dcf93abeb8a5cf393a7b2749725030bb630e554fdd8/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:752a45dc4a6934060b3b0dab47e04edc3326575f82be64bc4fc293914566503e", size = 219824, upload-time = "2026-04-02T09:27:43.924Z" }, + { url = "https://files.pythonhosted.org/packages/2c/6f/6e897c6984cc4d41af319b077f2f600fc8214eb2fe2d6bcb79141b882400/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:8778f0c7a52e56f75d12dae53ae320fae900a8b9b4164b981b9c5ce059cd1fcb", size = 233103, upload-time = "2026-04-02T09:27:45.348Z" }, + { url = "https://files.pythonhosted.org/packages/76/22/ef7bd0fe480a0ae9b656189ec00744b60933f68b4f42a7bb06589f6f576a/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ce3412fbe1e31eb81ea42f4169ed94861c56e643189e1e75f0041f3fe7020abe", size = 225194, upload-time = "2026-04-02T09:27:46.706Z" }, + { url = "https://files.pythonhosted.org/packages/c5/a7/0e0ab3e0b5bc1219bd80a6a0d4d72ca74d9250cb2382b7c699c147e06017/charset_normalizer-3.4.7-cp314-cp314t-win32.whl", hash = "sha256:c03a41a8784091e67a39648f70c5f97b5b6a37f216896d44d2cdcb82615339a0", size = 159827, upload-time = "2026-04-02T09:27:48.053Z" }, + { url = "https://files.pythonhosted.org/packages/7a/1d/29d32e0fb40864b1f878c7f5a0b343ae676c6e2b271a2d55cc3a152391da/charset_normalizer-3.4.7-cp314-cp314t-win_amd64.whl", hash = "sha256:03853ed82eeebbce3c2abfdbc98c96dc205f32a79627688ac9a27370ea61a49c", size = 174168, upload-time = "2026-04-02T09:27:49.795Z" }, + { url = "https://files.pythonhosted.org/packages/de/32/d92444ad05c7a6e41fb2036749777c163baf7a0301a040cb672d6b2b1ae9/charset_normalizer-3.4.7-cp314-cp314t-win_arm64.whl", hash = "sha256:c35abb8bfff0185efac5878da64c45dafd2b37fb0383add1be155a763c1f083d", size = 153018, upload-time = "2026-04-02T09:27:51.116Z" }, + { url = "https://files.pythonhosted.org/packages/db/8f/61959034484a4a7c527811f4721e75d02d653a35afb0b6054474d8185d4c/charset_normalizer-3.4.7-py3-none-any.whl", hash = "sha256:3dce51d0f5e7951f8bb4900c257dad282f49190fdbebecd4ba99bcc41fef404d", size = 61958, upload-time = "2026-04-02T09:28:37.794Z" }, ] [[package]] @@ -138,11 +138,11 @@ name = "click" version = "8.4.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "colorama", marker = "sys_platform == 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/9b/98/518d8e5081007684232226f475082b30087d0f585e8457db087298259f49/click-8.4.1.tar.gz", hash = "sha256:918b5633eddf6b41c32d4f454bf0de810065c74e3f7dbf8ee5452f8be88d3e96", size = 353007, upload-time = "2026-05-22T04:08:37.769Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c7/0d/67e5b4109ea4a837e80daa87c2c696711955e40449a97e8926672534def2/click-8.4.1-py3-none-any.whl", hash = "sha256:482be17c6991b8c19c5429a1e995d9b0efdbb63172824c41f99965dc0ade8ec2", size = 116639, upload-time = "2026-05-22T04:08:35.26Z" }, + { url = "https://files.pythonhosted.org/packages/c7/0d/67e5b4109ea4a837e80daa87c2c696711955e40449a97e8926672534def2/click-8.4.1-py3-none-any.whl", hash = "sha256:482be17c6991b8c19c5429a1e995d9b0efdbb63172824c41f99965dc0ade8ec2", size = 116639, upload-time = "2026-05-22T04:08:35.26Z" }, ] [[package]] @@ -151,7 +151,7 @@ version = "0.4.6" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, ] [[package]] @@ -160,67 +160,67 @@ version = "7.14.1" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/54/fd/0ab2772530e946e1be1abd0bc09e647ec9b02e88f0867857601fefca8953/coverage-7.14.1.tar.gz", hash = "sha256:30c08f7d90415aa98b3c990385dea2939b0da55f38515e5b369b83655f8523be", size = 920132, upload-time = "2026-05-26T20:41:36.783Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/8a/9e/5f6d56327c62b185225d145191c607e07515294a0aa6338e58805cd4a5ac/coverage-7.14.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:be9f2c802dcfce3f71298303aa5dad0dce440a76c52f2f60dacd8656dab78793", size = 220044, upload-time = "2026-05-26T20:39:29.902Z" }, - { url = "https://files.pythonhosted.org/packages/75/92/e82aca356744cbbc0f77a0b623e38918c1872361963413a3bab5d0340393/coverage-7.14.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:6223a72fd0e4c7156353ec0f08a5f93623e1d3034d0e2683b9bb8ea674131b1d", size = 220412, upload-time = "2026-05-26T20:39:31.561Z" }, - { url = "https://files.pythonhosted.org/packages/27/c9/385bde0bf7ed0f4bf3a7ee5367060a86b5d218718cfd6fb943c0f836b34f/coverage-7.14.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:7279d2110a28cebc738b6459ecda2771735a4c18465fbbd36b3288fe5ed92247", size = 251412, upload-time = "2026-05-26T20:39:33.337Z" }, - { url = "https://files.pythonhosted.org/packages/51/8c/23faf6a2343a0d17f960a4bd56c43bc7eb4cf312f774dd6ceebd82c7d8fc/coverage-7.14.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9eeb3fcbc13ba40dfbdb22d01d196a28e9cef9ed4c29b60061a1e0e823a9929d", size = 254008, upload-time = "2026-05-26T20:39:35.009Z" }, - { url = "https://files.pythonhosted.org/packages/42/06/36f4aa9ca8a815e6036156e80706a67828bb97bd826948244f6996dda957/coverage-7.14.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5f0cfc27c539f07cf5c0a4cfe211d0b6cae039f8f40526dbaa71944e64b50a7b", size = 255241, upload-time = "2026-05-26T20:39:36.71Z" }, - { url = "https://files.pythonhosted.org/packages/ca/79/95266316352f90f6b1c6736bb413302edfde2453fb32422d3911642691b3/coverage-7.14.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:221c70f316241a78e77e607c227cefc8808d4e08f28d99c04f35694690e940be", size = 257373, upload-time = "2026-05-26T20:39:38.412Z" }, - { url = "https://files.pythonhosted.org/packages/e3/9c/58316d1f66c488b5fca8a0eb3e98348807813efa8a0d0833b9021be27488/coverage-7.14.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:da028256b04ec30e5e0114b6f76172938c313991f0a2d3d894271315cf5d5e43", size = 251635, upload-time = "2026-05-26T20:39:40.268Z" }, - { url = "https://files.pythonhosted.org/packages/ef/5a/ca2398a568e16fed7bb713e84ba3603a7164fb65779abe645c565ec890d5/coverage-7.14.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:76a085d7005236a767e3426148b2c407e53ad61695c562f8a81da2d373324901", size = 253373, upload-time = "2026-05-26T20:39:42.145Z" }, - { url = "https://files.pythonhosted.org/packages/6e/2c/0396562c32deaebe7be51d865b3a41e9a87d7561acafe1a28f53b07e019a/coverage-7.14.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:b553d04b5e778a8e56d57eb134aff42a92718ecba45e79c4764ecfa40efd92ff", size = 251341, upload-time = "2026-05-26T20:39:43.907Z" }, - { url = "https://files.pythonhosted.org/packages/fd/8f/a94f9221184c9cae1ee115820e3798e48b6b17777a9f19e46fb9a0c8dc74/coverage-7.14.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:46f714d2fb8ae2f4f29f23ada7f1e79b759fff5a70f94a1dac23af204c3ec9e4", size = 255497, upload-time = "2026-05-26T20:39:46.166Z" }, - { url = "https://files.pythonhosted.org/packages/71/69/505d70e47db1eaebcd002c39759707621ef184cd6b1ae084d9f41293f323/coverage-7.14.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:1896f5e19ff3f0431c7ce2172adc54890fd97f86b59ced8ca1649145d9ffe35d", size = 251159, upload-time = "2026-05-26T20:39:48.03Z" }, - { url = "https://files.pythonhosted.org/packages/e0/aa/58681c383aa33a9d2ed40a02d7a22fbf780d1fa4d575396365777828198c/coverage-7.14.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:62fd185ef9df3c33d1c8178c5af105f762afbad96038de9a4ae100aa6297ca33", size = 252934, upload-time = "2026-05-26T20:39:49.872Z" }, - { url = "https://files.pythonhosted.org/packages/eb/fd/11c928cd6bdffc7074bb5965c173d9ebf517fb00205e1da524b98d29ef92/coverage-7.14.1-cp313-cp313-win32.whl", hash = "sha256:ab4af6352741a604c431c6072fce5bee33bf0f20dc7a56618d6bf6bb89e9810c", size = 222584, upload-time = "2026-05-26T20:39:51.68Z" }, - { url = "https://files.pythonhosted.org/packages/6f/92/fb416fc26d340dcba19518c418d6048e913186e17243982c5e435e41fa7a/coverage-7.14.1-cp313-cp313-win_amd64.whl", hash = "sha256:7af486dabe8954d03b087f0021540897afe084f04e16ff5579e08cc46f871416", size = 223394, upload-time = "2026-05-26T20:39:53.472Z" }, - { url = "https://files.pythonhosted.org/packages/73/c6/02d56e3867972f77d5036de924643f26c056e848f00452cafb4dbc3c29b4/coverage-7.14.1-cp313-cp313-win_arm64.whl", hash = "sha256:2224f89ffd0c5605ccce1ed7a584da162bc7c55f601ab1c946bc9de31a486b42", size = 222015, upload-time = "2026-05-26T20:39:55.374Z" }, - { url = "https://files.pythonhosted.org/packages/4d/9e/fcc77914050df73f7662fa1f00902774c79c075a8388ab334074574bf77e/coverage-7.14.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:de286598cc65d2b489411174b1faec2f5a7775fb3201fd925db2a76b4030f37d", size = 220733, upload-time = "2026-05-26T20:39:57.189Z" }, - { url = "https://files.pythonhosted.org/packages/f7/67/2963cbdaf5cbadec44efa3a1e39eaa1f02df4079585f05387607a221e126/coverage-7.14.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:042c46ded7c288aeb07cf14a28b6c1e10b78fcba40171c3fa1e939377eeef0b5", size = 221086, upload-time = "2026-05-26T20:39:59.019Z" }, - { url = "https://files.pythonhosted.org/packages/c8/c5/8701645574e11881f2f47d8930f98bc48b5d43b25eb5b4430dfc4a2f9f48/coverage-7.14.1-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:f4ddbe407477f04c45115d1a4e5bc480f753553b534d338d4c3358b1cdd0ea52", size = 262381, upload-time = "2026-05-26T20:40:00.822Z" }, - { url = "https://files.pythonhosted.org/packages/7c/28/7a64d73598263e0c5abd5084211a8474488d31b3c552ff531c719dfcff62/coverage-7.14.1-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:d13e6725992e2d2fd7d81d4f5241952d13740121dfd501da09201be39b2c003a", size = 264458, upload-time = "2026-05-26T20:40:02.506Z" }, - { url = "https://files.pythonhosted.org/packages/fa/d8/4969179db9f7eb4df218e69540adf829d1c835f59452513d065d15446802/coverage-7.14.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f747dc8edcfe740130f28f32f3995e955494285717e86ee25af51db2219df08a", size = 266884, upload-time = "2026-05-26T20:40:04.421Z" }, - { url = "https://files.pythonhosted.org/packages/a6/78/a45d5794dbc9bafd97afc96a4377c86c7820d78b6cf51b89bc1d4e919275/coverage-7.14.1-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ced2f09ef276fd58611a1ef502164ad266d2b75174e5a40cabbdb4033f9f6cf2", size = 268022, upload-time = "2026-05-26T20:40:06.298Z" }, - { url = "https://files.pythonhosted.org/packages/21/cb/4f5e354e9e3e67af96bd4e57113e6db6b22298c7168b13eec408a549903d/coverage-7.14.1-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b84800013769a78ccb9ef4659402e26d06867e337b61ec365f77ad008adea80e", size = 261631, upload-time = "2026-05-26T20:40:08.226Z" }, - { url = "https://files.pythonhosted.org/packages/ec/49/eced49af4cb996d5d8b7e94e736175c513e4facd3398507b89892b4326d8/coverage-7.14.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:ea8cd6ca0ee9f616aaef3afc6882e32c2cbf18b00d96313ffd76af650574034d", size = 264443, upload-time = "2026-05-26T20:40:10.137Z" }, - { url = "https://files.pythonhosted.org/packages/f1/d8/5603a88a7c5913a6b54f6cb1a8c46f7b39cbb30f27cd3f492908da09b2d7/coverage-7.14.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:aa5e304a873fabddc11e484e9b6b738bd38bd7bed17b09aa84eecf5332e8b8bb", size = 262069, upload-time = "2026-05-26T20:40:11.999Z" }, - { url = "https://files.pythonhosted.org/packages/f0/59/2ae3cb79da554a06c8619d6c88ea19dd1e4aed4b834b6a83bb1fa243bdc5/coverage-7.14.1-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:5a1c5215be81035e629d5bc756650634d0bf31991038db7a0eccb90f025ce16d", size = 265780, upload-time = "2026-05-26T20:40:13.858Z" }, - { url = "https://files.pythonhosted.org/packages/af/5f/b130c1dc999031f2648bd25317fbce505ad8d5562079b4ed81e736a84967/coverage-7.14.1-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:79058c47dae6788504b5effb319961bcd72d7240551464b91d474bc0ed186d69", size = 260970, upload-time = "2026-05-26T20:40:16.142Z" }, - { url = "https://files.pythonhosted.org/packages/87/d1/ec13ccddeb48ec963bdfa72a11224bac2584bd045ba13beca82f8113e9c7/coverage-7.14.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:370c5afae3fa0658e11694a32b24c2778f6bc2d17718121f94ee185e69f26b54", size = 263157, upload-time = "2026-05-26T20:40:18.382Z" }, - { url = "https://files.pythonhosted.org/packages/cf/c2/cd91ead503045161092d3845f7bb95ea2f25131ce96d3e314dd835d91b9c/coverage-7.14.1-cp313-cp313t-win32.whl", hash = "sha256:3758dd0a7f1fa57365ef2e781df0f0731d38b6e3772259d13dae4bd8a958d4b1", size = 223259, upload-time = "2026-05-26T20:40:20.381Z" }, - { url = "https://files.pythonhosted.org/packages/71/9f/1e28d97e6bd2c76b07f38b7c02870f1371255ff6717f54eca578fcbbdd0e/coverage-7.14.1-cp313-cp313t-win_amd64.whl", hash = "sha256:6ff665fb023a77386fe11685190cee1f60a7d635994a30d9b0a061533d470fce", size = 224320, upload-time = "2026-05-26T20:40:22.316Z" }, - { url = "https://files.pythonhosted.org/packages/a9/e0/d936e908f0e1efa55e52b91e01b52f1055cef5e1ab2718493390ed8e2fb8/coverage-7.14.1-cp313-cp313t-win_arm64.whl", hash = "sha256:17a5a241e5997621a956a7f402a7433ef4221e5152809b785bec79e2323799f1", size = 222577, upload-time = "2026-05-26T20:40:24.894Z" }, - { url = "https://files.pythonhosted.org/packages/d6/34/fc2f101b151af3799a101f0550b0454aa008afdc0add677394ec4aa8ea10/coverage-7.14.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:d5ed429d0b8edaac649e889b4ffcedb6c80b06629a3f93050e3dddfb99235bee", size = 220091, upload-time = "2026-05-26T20:40:27.249Z" }, - { url = "https://files.pythonhosted.org/packages/3d/a7/1ebae2ab5b961b5c79bb09fe7b3ac99edb190d8be4a8c510b2cf66f46468/coverage-7.14.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:8011224a62280e50dab346960c03cf47aca1a1e09e608c0fb33fd6e0cc8e9500", size = 220421, upload-time = "2026-05-26T20:40:30.084Z" }, - { url = "https://files.pythonhosted.org/packages/5e/90/92aca9cf0acc95123c96cd1eb1f08917897a7f5dee01e15738922971ec31/coverage-7.14.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:12c42ec1e14f553c4f817e989365982e646e27211f10a0f717855b94a79c8906", size = 251466, upload-time = "2026-05-26T20:40:32.542Z" }, - { url = "https://files.pythonhosted.org/packages/26/2b/78048cbe3b999f6cbf9cc0d90abba6a88a3e0863a8c1c6cbc762f3f8802f/coverage-7.14.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:06144cd511cf2624873a035c5069cf297144f6e77a73ee3d7a55b605ec5efb42", size = 253973, upload-time = "2026-05-26T20:40:34.473Z" }, - { url = "https://files.pythonhosted.org/packages/8e/21/c2e33b29d1cfde484a19d437afc343c6cd30b08d78cbbf9f5aff14e57b2b/coverage-7.14.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a311d8e1da24be5c1ccf85cbfb06315dbaa1703d5a1eab3f6432c72b837917c8", size = 255318, upload-time = "2026-05-26T20:40:38.154Z" }, - { url = "https://files.pythonhosted.org/packages/8e/ee/aad2f108d63b769121005302f16bf66db8625c88ceaba466942e09a2607e/coverage-7.14.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c79cead5b5bc584d9c71451cb984d0e3a84e0c0937379c8efcbf27c8d661b851", size = 257633, upload-time = "2026-05-26T20:40:40.164Z" }, - { url = "https://files.pythonhosted.org/packages/c2/f8/11a2c29b4fd76d9849f81d0bb812ec0017a9396df3217214e38934a8c837/coverage-7.14.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:dcbf65f1f66a26cdd88c35cf68fb4729c5d1cd2e88added72420541dfb212034", size = 251488, upload-time = "2026-05-26T20:40:42.631Z" }, - { url = "https://files.pythonhosted.org/packages/c9/b8/9a5820de4b8ac2b71d85e3b5fb49108d7469c665f0e2ad0dd7569023e305/coverage-7.14.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fd86572566fb40189a8260446158235159bc7a82dfbc87a3b39cf4fb57fcec1c", size = 253329, upload-time = "2026-05-26T20:40:45.208Z" }, - { url = "https://files.pythonhosted.org/packages/6b/ff/f33e4823667e27548e8fd8df44217515303f9808d0ff29817db56f87d990/coverage-7.14.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:7771b601718fdde84832c3a434ca9bbf4ae9adbc49d84198b4110700c3c77c36", size = 251291, upload-time = "2026-05-26T20:40:47.502Z" }, - { url = "https://files.pythonhosted.org/packages/68/9b/489db0ebb209054766b90a9014a45f6d26eb724c02ec21311c3733b5a644/coverage-7.14.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:39b21e212c55af06fa375e3dbf90a8a8e38792f3a910c580066d23563830ddd5", size = 255564, upload-time = "2026-05-26T20:40:49.372Z" }, - { url = "https://files.pythonhosted.org/packages/27/b5/16bc2d4c2409b23c7737edb68c83bc89e345f378050549fe1d75ac7d34d5/coverage-7.14.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:f2302660e32562a532b442480121aef8aa61a5bdb20b30bf0adab29f10a5a4b4", size = 251107, upload-time = "2026-05-26T20:40:51.677Z" }, - { url = "https://files.pythonhosted.org/packages/7d/0c/2629997469a00cd069d588a41c9dc887610f2775ae89d250c4791e65272a/coverage-7.14.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:03a6f93c1ec3b7f2e77b5dbcc5573a2c21f12529a5c6bbe0f16f72303cc2fa4d", size = 252764, upload-time = "2026-05-26T20:40:54.267Z" }, - { url = "https://files.pythonhosted.org/packages/d2/ee/f78d63c8f079e0d7211c7e2401fa17e311514534ba61bae03e4b287ce4ab/coverage-7.14.1-cp314-cp314-win32.whl", hash = "sha256:8a3ce026d73290f42f08dafecbd82c193a74df280461fbf97300fec51fd133ee", size = 222837, upload-time = "2026-05-26T20:40:56.496Z" }, - { url = "https://files.pythonhosted.org/packages/dc/b9/be539854f93a70dfbeec69117f33ec70dc42ff0b65b5b07ab8d40d04228e/coverage-7.14.1-cp314-cp314-win_amd64.whl", hash = "sha256:114c95ef29302423b87d159075805f4ab973254a2638a5d7d046c94887cc87d7", size = 223650, upload-time = "2026-05-26T20:40:58.351Z" }, - { url = "https://files.pythonhosted.org/packages/fe/9e/24e2842fef40f35ac82ba3a7719c8023d011bf3bf652d0675316a9d088a1/coverage-7.14.1-cp314-cp314-win_arm64.whl", hash = "sha256:a07891c3f4805442b31b71e84ba3cf29ed1aa9a428284e06deeb4b23e5b46343", size = 222218, upload-time = "2026-05-26T20:41:00.321Z" }, - { url = "https://files.pythonhosted.org/packages/0a/1d/ac0a9df5fe31c1e8bdd658074905fc12844a05c1a7e3fdb8417e97c31e23/coverage-7.14.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:1101a5ebb083aecb625ebb6209d4105b58f647b093cb2dc8122d7b33f743cfe1", size = 220822, upload-time = "2026-05-26T20:41:02.281Z" }, - { url = "https://files.pythonhosted.org/packages/32/cf/f964fd9aff20323f9f1a726c97135f8a76bcd87b92dad141a456a43f3c64/coverage-7.14.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:851b9e1e4e8a4608e77c79714b2e77c0970d2ed7202a05e92ae407817481887b", size = 221084, upload-time = "2026-05-26T20:41:04.593Z" }, - { url = "https://files.pythonhosted.org/packages/d8/5e/7e5ef2aba844de2b80d678619fcf0841b42e3f37f16411226f3fe4c1016f/coverage-7.14.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:d5b89cdfb2ee051b71e8c3c70bd81a9eff81100f736a269136fe1a68efe00474", size = 262454, upload-time = "2026-05-26T20:41:06.641Z" }, - { url = "https://files.pythonhosted.org/packages/64/62/75809bded87015cc4935524218a2a8ed8dd1a8498bfed30a2f4f7a4b4d34/coverage-7.14.1-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0177614a0370f227888b4e436a7c55686d6a9f90eb1ade2b624ba685a1686e86", size = 264578, upload-time = "2026-05-26T20:41:08.556Z" }, - { url = "https://files.pythonhosted.org/packages/f3/42/d33392dc14633525012d2d504fa1a33b05538bf535f5c1d64675e5754b78/coverage-7.14.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2d69af5dea2de76fc485a83032a630523f985198b7e25be901ec60181587b01e", size = 266981, upload-time = "2026-05-26T20:41:10.824Z" }, - { url = "https://files.pythonhosted.org/packages/2a/49/0157c4428c2aca7f1e09d5565930586fd5ae36f1655f08b0daa7cf1fcae1/coverage-7.14.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:35ab22d91de736e8966b980dc355cbcdd2c6dbbcfe275f9a2991bc8a91b3df65", size = 268112, upload-time = "2026-05-26T20:41:12.966Z" }, - { url = "https://files.pythonhosted.org/packages/96/26/86b9ce71f4092b1ed325ce1421698081df1286b833400b6836912834d6e0/coverage-7.14.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:357d4e32935c36588aaba057d734fa32428c360c9fc2e4442afbf1b646beee6e", size = 261558, upload-time = "2026-05-26T20:41:15Z" }, - { url = "https://files.pythonhosted.org/packages/20/4c/c311210c5472cf5401d8422b0d7812cdd520f24417673afabda6c323faca/coverage-7.14.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:51bd64741cc6fa065abd300ede1afe5a5291ece9c31da8b24884deda48bcc3f8", size = 264447, upload-time = "2026-05-26T20:41:17.369Z" }, - { url = "https://files.pythonhosted.org/packages/fb/71/59513f8710ed3e6b0ac0a050a5b7e977bb9c9e880354863b5d00d8809256/coverage-7.14.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:9132cd363a68a4c3daa7c8704a654b1e39d3360f6f5b8ddd470608a945236c07", size = 262048, upload-time = "2026-05-26T20:41:19.309Z" }, - { url = "https://files.pythonhosted.org/packages/84/8d/bceed32dc494f5bbf50f775cd2e78ca814953942b5ea28d3c1c3ac316f14/coverage-7.14.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:07c6290b1697b862c0478eab545eec949a0d0e4d6d03497f446d706da3b4f2de", size = 265781, upload-time = "2026-05-26T20:41:21.559Z" }, - { url = "https://files.pythonhosted.org/packages/e7/c5/9348fe40dbfd4991aaf78df2c6c3098bfb2cc834d1fd362a64b4efef855a/coverage-7.14.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:5ea0c297e27133853b4d8a3eb799bff5a2dbd9f2f41537a240d337ac9b4df890", size = 260896, upload-time = "2026-05-26T20:41:23.428Z" }, - { url = "https://files.pythonhosted.org/packages/ca/92/1ea0f03929da7cf87206b1fa24f4c8e9c158be0455481af29ec0a1f3503f/coverage-7.14.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:01b7733daad0237daa01ef80fe2dfceffc911e6a17fa7b55d14aa8214eaaaecd", size = 263214, upload-time = "2026-05-26T20:41:25.419Z" }, - { url = "https://files.pythonhosted.org/packages/f6/a9/b2493c054c0e01a643266742ab45e15744e60743f9260cd930c7142b1124/coverage-7.14.1-cp314-cp314t-win32.whl", hash = "sha256:6adc5a36984624a70bf11d7184e20fa0a49aa7c47ffab43804106a1a695ea22e", size = 223624, upload-time = "2026-05-26T20:41:27.795Z" }, - { url = "https://files.pythonhosted.org/packages/fc/bd/3e1e6a57fccd2d7c83fcdf338e93ba98eb85c6e877dd34731ac585375490/coverage-7.14.1-cp314-cp314t-win_amd64.whl", hash = "sha256:ddf799247318f34dbcd2efa8c95a8d0642674e926bb1774cf9b63dfd2a389d1c", size = 224728, upload-time = "2026-05-26T20:41:30.098Z" }, - { url = "https://files.pythonhosted.org/packages/bb/d7/31066cf1d2f0c6c797fce911bcfa01dd35642dc6da992a950256097c5860/coverage-7.14.1-cp314-cp314t-win_arm64.whl", hash = "sha256:145986fe66647eb489f18d9a997567a3fd358584c4b5a808769113abc07466af", size = 222752, upload-time = "2026-05-26T20:41:32.123Z" }, - { url = "https://files.pythonhosted.org/packages/8a/3c/1a983b9a745d7f83d53f057bcc5bf79ba6a2bbc08266b3f0c7d6fe630c9b/coverage-7.14.1-py3-none-any.whl", hash = "sha256:a252f21c27e38347e60111a3266b03827422a7d5525951aceee313aa68bab1d2", size = 211815, upload-time = "2026-05-26T20:41:34.078Z" }, + { url = "https://files.pythonhosted.org/packages/8a/9e/5f6d56327c62b185225d145191c607e07515294a0aa6338e58805cd4a5ac/coverage-7.14.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:be9f2c802dcfce3f71298303aa5dad0dce440a76c52f2f60dacd8656dab78793", size = 220044, upload-time = "2026-05-26T20:39:29.902Z" }, + { url = "https://files.pythonhosted.org/packages/75/92/e82aca356744cbbc0f77a0b623e38918c1872361963413a3bab5d0340393/coverage-7.14.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:6223a72fd0e4c7156353ec0f08a5f93623e1d3034d0e2683b9bb8ea674131b1d", size = 220412, upload-time = "2026-05-26T20:39:31.561Z" }, + { url = "https://files.pythonhosted.org/packages/27/c9/385bde0bf7ed0f4bf3a7ee5367060a86b5d218718cfd6fb943c0f836b34f/coverage-7.14.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:7279d2110a28cebc738b6459ecda2771735a4c18465fbbd36b3288fe5ed92247", size = 251412, upload-time = "2026-05-26T20:39:33.337Z" }, + { url = "https://files.pythonhosted.org/packages/51/8c/23faf6a2343a0d17f960a4bd56c43bc7eb4cf312f774dd6ceebd82c7d8fc/coverage-7.14.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9eeb3fcbc13ba40dfbdb22d01d196a28e9cef9ed4c29b60061a1e0e823a9929d", size = 254008, upload-time = "2026-05-26T20:39:35.009Z" }, + { url = "https://files.pythonhosted.org/packages/42/06/36f4aa9ca8a815e6036156e80706a67828bb97bd826948244f6996dda957/coverage-7.14.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5f0cfc27c539f07cf5c0a4cfe211d0b6cae039f8f40526dbaa71944e64b50a7b", size = 255241, upload-time = "2026-05-26T20:39:36.71Z" }, + { url = "https://files.pythonhosted.org/packages/ca/79/95266316352f90f6b1c6736bb413302edfde2453fb32422d3911642691b3/coverage-7.14.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:221c70f316241a78e77e607c227cefc8808d4e08f28d99c04f35694690e940be", size = 257373, upload-time = "2026-05-26T20:39:38.412Z" }, + { url = "https://files.pythonhosted.org/packages/e3/9c/58316d1f66c488b5fca8a0eb3e98348807813efa8a0d0833b9021be27488/coverage-7.14.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:da028256b04ec30e5e0114b6f76172938c313991f0a2d3d894271315cf5d5e43", size = 251635, upload-time = "2026-05-26T20:39:40.268Z" }, + { url = "https://files.pythonhosted.org/packages/ef/5a/ca2398a568e16fed7bb713e84ba3603a7164fb65779abe645c565ec890d5/coverage-7.14.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:76a085d7005236a767e3426148b2c407e53ad61695c562f8a81da2d373324901", size = 253373, upload-time = "2026-05-26T20:39:42.145Z" }, + { url = "https://files.pythonhosted.org/packages/6e/2c/0396562c32deaebe7be51d865b3a41e9a87d7561acafe1a28f53b07e019a/coverage-7.14.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:b553d04b5e778a8e56d57eb134aff42a92718ecba45e79c4764ecfa40efd92ff", size = 251341, upload-time = "2026-05-26T20:39:43.907Z" }, + { url = "https://files.pythonhosted.org/packages/fd/8f/a94f9221184c9cae1ee115820e3798e48b6b17777a9f19e46fb9a0c8dc74/coverage-7.14.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:46f714d2fb8ae2f4f29f23ada7f1e79b759fff5a70f94a1dac23af204c3ec9e4", size = 255497, upload-time = "2026-05-26T20:39:46.166Z" }, + { url = "https://files.pythonhosted.org/packages/71/69/505d70e47db1eaebcd002c39759707621ef184cd6b1ae084d9f41293f323/coverage-7.14.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:1896f5e19ff3f0431c7ce2172adc54890fd97f86b59ced8ca1649145d9ffe35d", size = 251159, upload-time = "2026-05-26T20:39:48.03Z" }, + { url = "https://files.pythonhosted.org/packages/e0/aa/58681c383aa33a9d2ed40a02d7a22fbf780d1fa4d575396365777828198c/coverage-7.14.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:62fd185ef9df3c33d1c8178c5af105f762afbad96038de9a4ae100aa6297ca33", size = 252934, upload-time = "2026-05-26T20:39:49.872Z" }, + { url = "https://files.pythonhosted.org/packages/eb/fd/11c928cd6bdffc7074bb5965c173d9ebf517fb00205e1da524b98d29ef92/coverage-7.14.1-cp313-cp313-win32.whl", hash = "sha256:ab4af6352741a604c431c6072fce5bee33bf0f20dc7a56618d6bf6bb89e9810c", size = 222584, upload-time = "2026-05-26T20:39:51.68Z" }, + { url = "https://files.pythonhosted.org/packages/6f/92/fb416fc26d340dcba19518c418d6048e913186e17243982c5e435e41fa7a/coverage-7.14.1-cp313-cp313-win_amd64.whl", hash = "sha256:7af486dabe8954d03b087f0021540897afe084f04e16ff5579e08cc46f871416", size = 223394, upload-time = "2026-05-26T20:39:53.472Z" }, + { url = "https://files.pythonhosted.org/packages/73/c6/02d56e3867972f77d5036de924643f26c056e848f00452cafb4dbc3c29b4/coverage-7.14.1-cp313-cp313-win_arm64.whl", hash = "sha256:2224f89ffd0c5605ccce1ed7a584da162bc7c55f601ab1c946bc9de31a486b42", size = 222015, upload-time = "2026-05-26T20:39:55.374Z" }, + { url = "https://files.pythonhosted.org/packages/4d/9e/fcc77914050df73f7662fa1f00902774c79c075a8388ab334074574bf77e/coverage-7.14.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:de286598cc65d2b489411174b1faec2f5a7775fb3201fd925db2a76b4030f37d", size = 220733, upload-time = "2026-05-26T20:39:57.189Z" }, + { url = "https://files.pythonhosted.org/packages/f7/67/2963cbdaf5cbadec44efa3a1e39eaa1f02df4079585f05387607a221e126/coverage-7.14.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:042c46ded7c288aeb07cf14a28b6c1e10b78fcba40171c3fa1e939377eeef0b5", size = 221086, upload-time = "2026-05-26T20:39:59.019Z" }, + { url = "https://files.pythonhosted.org/packages/c8/c5/8701645574e11881f2f47d8930f98bc48b5d43b25eb5b4430dfc4a2f9f48/coverage-7.14.1-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:f4ddbe407477f04c45115d1a4e5bc480f753553b534d338d4c3358b1cdd0ea52", size = 262381, upload-time = "2026-05-26T20:40:00.822Z" }, + { url = "https://files.pythonhosted.org/packages/7c/28/7a64d73598263e0c5abd5084211a8474488d31b3c552ff531c719dfcff62/coverage-7.14.1-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:d13e6725992e2d2fd7d81d4f5241952d13740121dfd501da09201be39b2c003a", size = 264458, upload-time = "2026-05-26T20:40:02.506Z" }, + { url = "https://files.pythonhosted.org/packages/fa/d8/4969179db9f7eb4df218e69540adf829d1c835f59452513d065d15446802/coverage-7.14.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f747dc8edcfe740130f28f32f3995e955494285717e86ee25af51db2219df08a", size = 266884, upload-time = "2026-05-26T20:40:04.421Z" }, + { url = "https://files.pythonhosted.org/packages/a6/78/a45d5794dbc9bafd97afc96a4377c86c7820d78b6cf51b89bc1d4e919275/coverage-7.14.1-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ced2f09ef276fd58611a1ef502164ad266d2b75174e5a40cabbdb4033f9f6cf2", size = 268022, upload-time = "2026-05-26T20:40:06.298Z" }, + { url = "https://files.pythonhosted.org/packages/21/cb/4f5e354e9e3e67af96bd4e57113e6db6b22298c7168b13eec408a549903d/coverage-7.14.1-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b84800013769a78ccb9ef4659402e26d06867e337b61ec365f77ad008adea80e", size = 261631, upload-time = "2026-05-26T20:40:08.226Z" }, + { url = "https://files.pythonhosted.org/packages/ec/49/eced49af4cb996d5d8b7e94e736175c513e4facd3398507b89892b4326d8/coverage-7.14.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:ea8cd6ca0ee9f616aaef3afc6882e32c2cbf18b00d96313ffd76af650574034d", size = 264443, upload-time = "2026-05-26T20:40:10.137Z" }, + { url = "https://files.pythonhosted.org/packages/f1/d8/5603a88a7c5913a6b54f6cb1a8c46f7b39cbb30f27cd3f492908da09b2d7/coverage-7.14.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:aa5e304a873fabddc11e484e9b6b738bd38bd7bed17b09aa84eecf5332e8b8bb", size = 262069, upload-time = "2026-05-26T20:40:11.999Z" }, + { url = "https://files.pythonhosted.org/packages/f0/59/2ae3cb79da554a06c8619d6c88ea19dd1e4aed4b834b6a83bb1fa243bdc5/coverage-7.14.1-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:5a1c5215be81035e629d5bc756650634d0bf31991038db7a0eccb90f025ce16d", size = 265780, upload-time = "2026-05-26T20:40:13.858Z" }, + { url = "https://files.pythonhosted.org/packages/af/5f/b130c1dc999031f2648bd25317fbce505ad8d5562079b4ed81e736a84967/coverage-7.14.1-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:79058c47dae6788504b5effb319961bcd72d7240551464b91d474bc0ed186d69", size = 260970, upload-time = "2026-05-26T20:40:16.142Z" }, + { url = "https://files.pythonhosted.org/packages/87/d1/ec13ccddeb48ec963bdfa72a11224bac2584bd045ba13beca82f8113e9c7/coverage-7.14.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:370c5afae3fa0658e11694a32b24c2778f6bc2d17718121f94ee185e69f26b54", size = 263157, upload-time = "2026-05-26T20:40:18.382Z" }, + { url = "https://files.pythonhosted.org/packages/cf/c2/cd91ead503045161092d3845f7bb95ea2f25131ce96d3e314dd835d91b9c/coverage-7.14.1-cp313-cp313t-win32.whl", hash = "sha256:3758dd0a7f1fa57365ef2e781df0f0731d38b6e3772259d13dae4bd8a958d4b1", size = 223259, upload-time = "2026-05-26T20:40:20.381Z" }, + { url = "https://files.pythonhosted.org/packages/71/9f/1e28d97e6bd2c76b07f38b7c02870f1371255ff6717f54eca578fcbbdd0e/coverage-7.14.1-cp313-cp313t-win_amd64.whl", hash = "sha256:6ff665fb023a77386fe11685190cee1f60a7d635994a30d9b0a061533d470fce", size = 224320, upload-time = "2026-05-26T20:40:22.316Z" }, + { url = "https://files.pythonhosted.org/packages/a9/e0/d936e908f0e1efa55e52b91e01b52f1055cef5e1ab2718493390ed8e2fb8/coverage-7.14.1-cp313-cp313t-win_arm64.whl", hash = "sha256:17a5a241e5997621a956a7f402a7433ef4221e5152809b785bec79e2323799f1", size = 222577, upload-time = "2026-05-26T20:40:24.894Z" }, + { url = "https://files.pythonhosted.org/packages/d6/34/fc2f101b151af3799a101f0550b0454aa008afdc0add677394ec4aa8ea10/coverage-7.14.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:d5ed429d0b8edaac649e889b4ffcedb6c80b06629a3f93050e3dddfb99235bee", size = 220091, upload-time = "2026-05-26T20:40:27.249Z" }, + { url = "https://files.pythonhosted.org/packages/3d/a7/1ebae2ab5b961b5c79bb09fe7b3ac99edb190d8be4a8c510b2cf66f46468/coverage-7.14.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:8011224a62280e50dab346960c03cf47aca1a1e09e608c0fb33fd6e0cc8e9500", size = 220421, upload-time = "2026-05-26T20:40:30.084Z" }, + { url = "https://files.pythonhosted.org/packages/5e/90/92aca9cf0acc95123c96cd1eb1f08917897a7f5dee01e15738922971ec31/coverage-7.14.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:12c42ec1e14f553c4f817e989365982e646e27211f10a0f717855b94a79c8906", size = 251466, upload-time = "2026-05-26T20:40:32.542Z" }, + { url = "https://files.pythonhosted.org/packages/26/2b/78048cbe3b999f6cbf9cc0d90abba6a88a3e0863a8c1c6cbc762f3f8802f/coverage-7.14.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:06144cd511cf2624873a035c5069cf297144f6e77a73ee3d7a55b605ec5efb42", size = 253973, upload-time = "2026-05-26T20:40:34.473Z" }, + { url = "https://files.pythonhosted.org/packages/8e/21/c2e33b29d1cfde484a19d437afc343c6cd30b08d78cbbf9f5aff14e57b2b/coverage-7.14.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a311d8e1da24be5c1ccf85cbfb06315dbaa1703d5a1eab3f6432c72b837917c8", size = 255318, upload-time = "2026-05-26T20:40:38.154Z" }, + { url = "https://files.pythonhosted.org/packages/8e/ee/aad2f108d63b769121005302f16bf66db8625c88ceaba466942e09a2607e/coverage-7.14.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c79cead5b5bc584d9c71451cb984d0e3a84e0c0937379c8efcbf27c8d661b851", size = 257633, upload-time = "2026-05-26T20:40:40.164Z" }, + { url = "https://files.pythonhosted.org/packages/c2/f8/11a2c29b4fd76d9849f81d0bb812ec0017a9396df3217214e38934a8c837/coverage-7.14.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:dcbf65f1f66a26cdd88c35cf68fb4729c5d1cd2e88added72420541dfb212034", size = 251488, upload-time = "2026-05-26T20:40:42.631Z" }, + { url = "https://files.pythonhosted.org/packages/c9/b8/9a5820de4b8ac2b71d85e3b5fb49108d7469c665f0e2ad0dd7569023e305/coverage-7.14.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fd86572566fb40189a8260446158235159bc7a82dfbc87a3b39cf4fb57fcec1c", size = 253329, upload-time = "2026-05-26T20:40:45.208Z" }, + { url = "https://files.pythonhosted.org/packages/6b/ff/f33e4823667e27548e8fd8df44217515303f9808d0ff29817db56f87d990/coverage-7.14.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:7771b601718fdde84832c3a434ca9bbf4ae9adbc49d84198b4110700c3c77c36", size = 251291, upload-time = "2026-05-26T20:40:47.502Z" }, + { url = "https://files.pythonhosted.org/packages/68/9b/489db0ebb209054766b90a9014a45f6d26eb724c02ec21311c3733b5a644/coverage-7.14.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:39b21e212c55af06fa375e3dbf90a8a8e38792f3a910c580066d23563830ddd5", size = 255564, upload-time = "2026-05-26T20:40:49.372Z" }, + { url = "https://files.pythonhosted.org/packages/27/b5/16bc2d4c2409b23c7737edb68c83bc89e345f378050549fe1d75ac7d34d5/coverage-7.14.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:f2302660e32562a532b442480121aef8aa61a5bdb20b30bf0adab29f10a5a4b4", size = 251107, upload-time = "2026-05-26T20:40:51.677Z" }, + { url = "https://files.pythonhosted.org/packages/7d/0c/2629997469a00cd069d588a41c9dc887610f2775ae89d250c4791e65272a/coverage-7.14.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:03a6f93c1ec3b7f2e77b5dbcc5573a2c21f12529a5c6bbe0f16f72303cc2fa4d", size = 252764, upload-time = "2026-05-26T20:40:54.267Z" }, + { url = "https://files.pythonhosted.org/packages/d2/ee/f78d63c8f079e0d7211c7e2401fa17e311514534ba61bae03e4b287ce4ab/coverage-7.14.1-cp314-cp314-win32.whl", hash = "sha256:8a3ce026d73290f42f08dafecbd82c193a74df280461fbf97300fec51fd133ee", size = 222837, upload-time = "2026-05-26T20:40:56.496Z" }, + { url = "https://files.pythonhosted.org/packages/dc/b9/be539854f93a70dfbeec69117f33ec70dc42ff0b65b5b07ab8d40d04228e/coverage-7.14.1-cp314-cp314-win_amd64.whl", hash = "sha256:114c95ef29302423b87d159075805f4ab973254a2638a5d7d046c94887cc87d7", size = 223650, upload-time = "2026-05-26T20:40:58.351Z" }, + { url = "https://files.pythonhosted.org/packages/fe/9e/24e2842fef40f35ac82ba3a7719c8023d011bf3bf652d0675316a9d088a1/coverage-7.14.1-cp314-cp314-win_arm64.whl", hash = "sha256:a07891c3f4805442b31b71e84ba3cf29ed1aa9a428284e06deeb4b23e5b46343", size = 222218, upload-time = "2026-05-26T20:41:00.321Z" }, + { url = "https://files.pythonhosted.org/packages/0a/1d/ac0a9df5fe31c1e8bdd658074905fc12844a05c1a7e3fdb8417e97c31e23/coverage-7.14.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:1101a5ebb083aecb625ebb6209d4105b58f647b093cb2dc8122d7b33f743cfe1", size = 220822, upload-time = "2026-05-26T20:41:02.281Z" }, + { url = "https://files.pythonhosted.org/packages/32/cf/f964fd9aff20323f9f1a726c97135f8a76bcd87b92dad141a456a43f3c64/coverage-7.14.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:851b9e1e4e8a4608e77c79714b2e77c0970d2ed7202a05e92ae407817481887b", size = 221084, upload-time = "2026-05-26T20:41:04.593Z" }, + { url = "https://files.pythonhosted.org/packages/d8/5e/7e5ef2aba844de2b80d678619fcf0841b42e3f37f16411226f3fe4c1016f/coverage-7.14.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:d5b89cdfb2ee051b71e8c3c70bd81a9eff81100f736a269136fe1a68efe00474", size = 262454, upload-time = "2026-05-26T20:41:06.641Z" }, + { url = "https://files.pythonhosted.org/packages/64/62/75809bded87015cc4935524218a2a8ed8dd1a8498bfed30a2f4f7a4b4d34/coverage-7.14.1-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0177614a0370f227888b4e436a7c55686d6a9f90eb1ade2b624ba685a1686e86", size = 264578, upload-time = "2026-05-26T20:41:08.556Z" }, + { url = "https://files.pythonhosted.org/packages/f3/42/d33392dc14633525012d2d504fa1a33b05538bf535f5c1d64675e5754b78/coverage-7.14.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2d69af5dea2de76fc485a83032a630523f985198b7e25be901ec60181587b01e", size = 266981, upload-time = "2026-05-26T20:41:10.824Z" }, + { url = "https://files.pythonhosted.org/packages/2a/49/0157c4428c2aca7f1e09d5565930586fd5ae36f1655f08b0daa7cf1fcae1/coverage-7.14.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:35ab22d91de736e8966b980dc355cbcdd2c6dbbcfe275f9a2991bc8a91b3df65", size = 268112, upload-time = "2026-05-26T20:41:12.966Z" }, + { url = "https://files.pythonhosted.org/packages/96/26/86b9ce71f4092b1ed325ce1421698081df1286b833400b6836912834d6e0/coverage-7.14.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:357d4e32935c36588aaba057d734fa32428c360c9fc2e4442afbf1b646beee6e", size = 261558, upload-time = "2026-05-26T20:41:15Z" }, + { url = "https://files.pythonhosted.org/packages/20/4c/c311210c5472cf5401d8422b0d7812cdd520f24417673afabda6c323faca/coverage-7.14.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:51bd64741cc6fa065abd300ede1afe5a5291ece9c31da8b24884deda48bcc3f8", size = 264447, upload-time = "2026-05-26T20:41:17.369Z" }, + { url = "https://files.pythonhosted.org/packages/fb/71/59513f8710ed3e6b0ac0a050a5b7e977bb9c9e880354863b5d00d8809256/coverage-7.14.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:9132cd363a68a4c3daa7c8704a654b1e39d3360f6f5b8ddd470608a945236c07", size = 262048, upload-time = "2026-05-26T20:41:19.309Z" }, + { url = "https://files.pythonhosted.org/packages/84/8d/bceed32dc494f5bbf50f775cd2e78ca814953942b5ea28d3c1c3ac316f14/coverage-7.14.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:07c6290b1697b862c0478eab545eec949a0d0e4d6d03497f446d706da3b4f2de", size = 265781, upload-time = "2026-05-26T20:41:21.559Z" }, + { url = "https://files.pythonhosted.org/packages/e7/c5/9348fe40dbfd4991aaf78df2c6c3098bfb2cc834d1fd362a64b4efef855a/coverage-7.14.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:5ea0c297e27133853b4d8a3eb799bff5a2dbd9f2f41537a240d337ac9b4df890", size = 260896, upload-time = "2026-05-26T20:41:23.428Z" }, + { url = "https://files.pythonhosted.org/packages/ca/92/1ea0f03929da7cf87206b1fa24f4c8e9c158be0455481af29ec0a1f3503f/coverage-7.14.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:01b7733daad0237daa01ef80fe2dfceffc911e6a17fa7b55d14aa8214eaaaecd", size = 263214, upload-time = "2026-05-26T20:41:25.419Z" }, + { url = "https://files.pythonhosted.org/packages/f6/a9/b2493c054c0e01a643266742ab45e15744e60743f9260cd930c7142b1124/coverage-7.14.1-cp314-cp314t-win32.whl", hash = "sha256:6adc5a36984624a70bf11d7184e20fa0a49aa7c47ffab43804106a1a695ea22e", size = 223624, upload-time = "2026-05-26T20:41:27.795Z" }, + { url = "https://files.pythonhosted.org/packages/fc/bd/3e1e6a57fccd2d7c83fcdf338e93ba98eb85c6e877dd34731ac585375490/coverage-7.14.1-cp314-cp314t-win_amd64.whl", hash = "sha256:ddf799247318f34dbcd2efa8c95a8d0642674e926bb1774cf9b63dfd2a389d1c", size = 224728, upload-time = "2026-05-26T20:41:30.098Z" }, + { url = "https://files.pythonhosted.org/packages/bb/d7/31066cf1d2f0c6c797fce911bcfa01dd35642dc6da992a950256097c5860/coverage-7.14.1-cp314-cp314t-win_arm64.whl", hash = "sha256:145986fe66647eb489f18d9a997567a3fd358584c4b5a808769113abc07466af", size = 222752, upload-time = "2026-05-26T20:41:32.123Z" }, + { url = "https://files.pythonhosted.org/packages/8a/3c/1a983b9a745d7f83d53f057bcc5bf79ba6a2bbc08266b3f0c7d6fe630c9b/coverage-7.14.1-py3-none-any.whl", hash = "sha256:a252f21c27e38347e60111a3266b03827422a7d5525951aceee313aa68bab1d2", size = 211815, upload-time = "2026-05-26T20:41:34.078Z" }, ] [[package]] @@ -229,7 +229,7 @@ version = "0.4.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/96/8e/709914eb2b5749865801041647dc7f4e6d00b549cfe88b65ca192995f07c/distlib-0.4.0.tar.gz", hash = "sha256:feec40075be03a04501a973d81f633735b4b69f98b05450592310c0f401a4e0d", size = 614605, upload-time = "2025-07-17T16:52:00.465Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/33/6b/e0547afaf41bf2c42e52430072fa5658766e3d65bd4b03a563d1b6336f57/distlib-0.4.0-py2.py3-none-any.whl", hash = "sha256:9659f7d87e46584a30b5780e43ac7a2143098441670ff0a49d5f9034c54a6c16", size = 469047, upload-time = "2025-07-17T16:51:58.613Z" }, + { url = "https://files.pythonhosted.org/packages/33/6b/e0547afaf41bf2c42e52430072fa5658766e3d65bd4b03a563d1b6336f57/distlib-0.4.0-py2.py3-none-any.whl", hash = "sha256:9659f7d87e46584a30b5780e43ac7a2143098441670ff0a49d5f9034c54a6c16", size = 469047, upload-time = "2025-07-17T16:51:58.613Z" }, ] [[package]] @@ -238,7 +238,7 @@ version = "1.9.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/fc/f8/98eea607f65de6527f8a2e8885fc8015d3e6f5775df186e443e0964a11c3/distro-1.9.0.tar.gz", hash = "sha256:2fa77c6fd8940f116ee1d6b94a2f90b13b5ea8d019b98bc8bafdcabcdd9bdbed", size = 60722, upload-time = "2023-12-24T09:54:32.31Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/12/b3/231ffd4ab1fc9d679809f356cebee130ac7daa00d6d6f3206dd4fd137e9e/distro-1.9.0-py3-none-any.whl", hash = "sha256:7bffd925d65168f85027d8da9af6bddab658135b840670a223589bc0c8ef02b2", size = 20277, upload-time = "2023-12-24T09:54:30.421Z" }, + { url = "https://files.pythonhosted.org/packages/12/b3/231ffd4ab1fc9d679809f356cebee130ac7daa00d6d6f3206dd4fd137e9e/distro-1.9.0-py3-none-any.whl", hash = "sha256:7bffd925d65168f85027d8da9af6bddab658135b840670a223589bc0c8ef02b2", size = 20277, upload-time = "2023-12-24T09:54:30.421Z" }, ] [[package]] @@ -247,20 +247,20 @@ version = "1.5.3" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/69/00/d579dcb2a536b6ea3a2563cdad6844f77d81a9b2d4b22a858097f2468acf/duckdb-1.5.3.tar.gz", hash = "sha256:df39428eb130faa35ae96fd35245bdeae6ecf43936250b116b5fead568eb9f16", size = 18026640, upload-time = "2026-05-20T11:55:31.901Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/cc/9c/a528eb09d8be51954c485864bd06753e616939a080cbc3dd4417e8c94a57/duckdb-1.5.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:e75a6122c12579a99848517f6f00a4e342aebda3590c30fe9b5cc5f39d5e6afc", size = 32626254, upload-time = "2026-05-20T11:54:53.65Z" }, - { url = "https://files.pythonhosted.org/packages/ec/3c/1534c0a6db347c05eb7d0f6ecfb7aefbe74cbff398e4892a8fd1903a20e8/duckdb-1.5.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:fd3963c1cb9d9567777f4a898a9dbe388a2fe9724681801b1e7d6d93eecf1b76", size = 17300917, upload-time = "2026-05-20T11:54:56.628Z" }, - { url = "https://files.pythonhosted.org/packages/23/fa/beafb91e6e152d2161c4a9cbc472334c87607eb61ad7104b5a7fa8d8d7b1/duckdb-1.5.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:3d5db8c0b55e072cf437948ebb5d7e23d7b9d03d905fa5f9145583e65aa447f7", size = 15449411, upload-time = "2026-05-20T11:54:59.089Z" }, - { url = "https://files.pythonhosted.org/packages/50/0a/49b6fe04e2fcd63729eb607dadd44818dde77342a4f5ce086c6c92f1dd4d/duckdb-1.5.3-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ce80aed7a538422129a57eaca9141e3afb51f8bf562b1908b1576c9725b5b22", size = 19333120, upload-time = "2026-05-20T11:55:01.727Z" }, - { url = "https://files.pythonhosted.org/packages/63/4c/0907c3f76adb9dd90e67610b31e0304a35814e65c4c41a354a262c09b885/duckdb-1.5.3-cp313-cp313-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:787df63824f07bf18022dbc3b8ca4b2bfab0ebe616464f55c6e8cd0f59ea762e", size = 21453266, upload-time = "2026-05-20T11:55:04.5Z" }, - { url = "https://files.pythonhosted.org/packages/6d/9c/d2f23a7803ddbbd9413f7572ecf66a15120ed5ced7ce5c73e698c1406b76/duckdb-1.5.3-cp313-cp313-win_amd64.whl", hash = "sha256:bb5bb5dcdd09d62ee60f0ddbbef918e71cce304ffe28428b1131949d39ffaabf", size = 13118640, upload-time = "2026-05-20T11:55:07.389Z" }, - { url = "https://files.pythonhosted.org/packages/27/d5/7ba2316415bcdab6edd765bbbe35c2ca8a3800f2fe695cd70e3cdb997f09/duckdb-1.5.3-cp313-cp313-win_arm64.whl", hash = "sha256:2fa17ecdd5d3db122836cb71bb93601c2106a3be883c17dffddc02fbf3fa7888", size = 13926409, upload-time = "2026-05-20T11:55:10.166Z" }, - { url = "https://files.pythonhosted.org/packages/a5/c2/d4b6f8a5e4d3bc25773be6da76a99d9661ebbf3552c007c460d2dd59dbf8/duckdb-1.5.3-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:4bfa9a4dadf71e83e2c4eaca2f9421c82a54defecc1b0b4c0be95e2389dec4fe", size = 32636685, upload-time = "2026-05-20T11:55:13.158Z" }, - { url = "https://files.pythonhosted.org/packages/42/58/e835c8298979d29db7a62cb5acc29e9b57aeaca7cdde2fcd3ac980f5cb18/duckdb-1.5.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:aea7baf67ad7e1829ac76f67d7dcbd7fb1f57c3eb179d55ac30952df4709ae30", size = 17308134, upload-time = "2026-05-20T11:55:16.194Z" }, - { url = "https://files.pythonhosted.org/packages/c9/46/617b51363f5613418c8b224b3cce16b58e6dde80904566bec232579c1d4e/duckdb-1.5.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0b0b4f088a65d77e1217ce5d7eff889e63fedc44281200d899ff47c84d8ff836", size = 15449891, upload-time = "2026-05-20T11:55:18.687Z" }, - { url = "https://files.pythonhosted.org/packages/b3/72/354146656e8d9ba3853d3a5ee80a481b8c5f70edfc3d5ae80a8c4479c967/duckdb-1.5.3-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fe8d0c1f6a120aa03fa6e0d03897c71a1842e6cf7afd31d181348391f7108fe1", size = 19338499, upload-time = "2026-05-20T11:55:21.34Z" }, - { url = "https://files.pythonhosted.org/packages/56/8f/65fc623b51448f2bfba1a9ec6ab3debb4664c0876c0113a5e782600b53ac/duckdb-1.5.3-cp314-cp314-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d0405eae18ec6e8210a471c97dbfe87a7e4d605274b7fe572a1f276e92158f13", size = 21455828, upload-time = "2026-05-20T11:55:23.847Z" }, - { url = "https://files.pythonhosted.org/packages/2b/db/d0274cbe9f5fe219f77c0bdf900ac77103569e83c102a4225ce04cbc607d/duckdb-1.5.3-cp314-cp314-win_amd64.whl", hash = "sha256:33ae08b3e818d7613d8936744b67718c2062c2f530376895bfd89efb51b81538", size = 13640011, upload-time = "2026-05-20T11:55:26.276Z" }, - { url = "https://files.pythonhosted.org/packages/07/5d/8f1899b8bef291caf953992fcd6c24df9f29387a35645e58c2504a5ca473/duckdb-1.5.3-cp314-cp314-win_arm64.whl", hash = "sha256:746433e49bbc667b4df283153415fbe37e9083e0eff6c3cd6e54de7536869cd4", size = 14411554, upload-time = "2026-05-20T11:55:29.037Z" }, + { url = "https://files.pythonhosted.org/packages/cc/9c/a528eb09d8be51954c485864bd06753e616939a080cbc3dd4417e8c94a57/duckdb-1.5.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:e75a6122c12579a99848517f6f00a4e342aebda3590c30fe9b5cc5f39d5e6afc", size = 32626254, upload-time = "2026-05-20T11:54:53.65Z" }, + { url = "https://files.pythonhosted.org/packages/ec/3c/1534c0a6db347c05eb7d0f6ecfb7aefbe74cbff398e4892a8fd1903a20e8/duckdb-1.5.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:fd3963c1cb9d9567777f4a898a9dbe388a2fe9724681801b1e7d6d93eecf1b76", size = 17300917, upload-time = "2026-05-20T11:54:56.628Z" }, + { url = "https://files.pythonhosted.org/packages/23/fa/beafb91e6e152d2161c4a9cbc472334c87607eb61ad7104b5a7fa8d8d7b1/duckdb-1.5.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:3d5db8c0b55e072cf437948ebb5d7e23d7b9d03d905fa5f9145583e65aa447f7", size = 15449411, upload-time = "2026-05-20T11:54:59.089Z" }, + { url = "https://files.pythonhosted.org/packages/50/0a/49b6fe04e2fcd63729eb607dadd44818dde77342a4f5ce086c6c92f1dd4d/duckdb-1.5.3-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ce80aed7a538422129a57eaca9141e3afb51f8bf562b1908b1576c9725b5b22", size = 19333120, upload-time = "2026-05-20T11:55:01.727Z" }, + { url = "https://files.pythonhosted.org/packages/63/4c/0907c3f76adb9dd90e67610b31e0304a35814e65c4c41a354a262c09b885/duckdb-1.5.3-cp313-cp313-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:787df63824f07bf18022dbc3b8ca4b2bfab0ebe616464f55c6e8cd0f59ea762e", size = 21453266, upload-time = "2026-05-20T11:55:04.5Z" }, + { url = "https://files.pythonhosted.org/packages/6d/9c/d2f23a7803ddbbd9413f7572ecf66a15120ed5ced7ce5c73e698c1406b76/duckdb-1.5.3-cp313-cp313-win_amd64.whl", hash = "sha256:bb5bb5dcdd09d62ee60f0ddbbef918e71cce304ffe28428b1131949d39ffaabf", size = 13118640, upload-time = "2026-05-20T11:55:07.389Z" }, + { url = "https://files.pythonhosted.org/packages/27/d5/7ba2316415bcdab6edd765bbbe35c2ca8a3800f2fe695cd70e3cdb997f09/duckdb-1.5.3-cp313-cp313-win_arm64.whl", hash = "sha256:2fa17ecdd5d3db122836cb71bb93601c2106a3be883c17dffddc02fbf3fa7888", size = 13926409, upload-time = "2026-05-20T11:55:10.166Z" }, + { url = "https://files.pythonhosted.org/packages/a5/c2/d4b6f8a5e4d3bc25773be6da76a99d9661ebbf3552c007c460d2dd59dbf8/duckdb-1.5.3-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:4bfa9a4dadf71e83e2c4eaca2f9421c82a54defecc1b0b4c0be95e2389dec4fe", size = 32636685, upload-time = "2026-05-20T11:55:13.158Z" }, + { url = "https://files.pythonhosted.org/packages/42/58/e835c8298979d29db7a62cb5acc29e9b57aeaca7cdde2fcd3ac980f5cb18/duckdb-1.5.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:aea7baf67ad7e1829ac76f67d7dcbd7fb1f57c3eb179d55ac30952df4709ae30", size = 17308134, upload-time = "2026-05-20T11:55:16.194Z" }, + { url = "https://files.pythonhosted.org/packages/c9/46/617b51363f5613418c8b224b3cce16b58e6dde80904566bec232579c1d4e/duckdb-1.5.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0b0b4f088a65d77e1217ce5d7eff889e63fedc44281200d899ff47c84d8ff836", size = 15449891, upload-time = "2026-05-20T11:55:18.687Z" }, + { url = "https://files.pythonhosted.org/packages/b3/72/354146656e8d9ba3853d3a5ee80a481b8c5f70edfc3d5ae80a8c4479c967/duckdb-1.5.3-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fe8d0c1f6a120aa03fa6e0d03897c71a1842e6cf7afd31d181348391f7108fe1", size = 19338499, upload-time = "2026-05-20T11:55:21.34Z" }, + { url = "https://files.pythonhosted.org/packages/56/8f/65fc623b51448f2bfba1a9ec6ab3debb4664c0876c0113a5e782600b53ac/duckdb-1.5.3-cp314-cp314-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d0405eae18ec6e8210a471c97dbfe87a7e4d605274b7fe572a1f276e92158f13", size = 21455828, upload-time = "2026-05-20T11:55:23.847Z" }, + { url = "https://files.pythonhosted.org/packages/2b/db/d0274cbe9f5fe219f77c0bdf900ac77103569e83c102a4225ce04cbc607d/duckdb-1.5.3-cp314-cp314-win_amd64.whl", hash = "sha256:33ae08b3e818d7613d8936744b67718c2062c2f530376895bfd89efb51b81538", size = 13640011, upload-time = "2026-05-20T11:55:26.276Z" }, + { url = "https://files.pythonhosted.org/packages/07/5d/8f1899b8bef291caf953992fcd6c24df9f29387a35645e58c2504a5ca473/duckdb-1.5.3-cp314-cp314-win_arm64.whl", hash = "sha256:746433e49bbc667b4df283153415fbe37e9083e0eff6c3cd6e54de7536869cd4", size = 14411554, upload-time = "2026-05-20T11:55:29.037Z" }, ] [[package]] @@ -268,15 +268,15 @@ name = "fastapi" version = "0.136.3" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "annotated-doc" }, - { name = "pydantic" }, - { name = "starlette" }, - { name = "typing-extensions" }, - { name = "typing-inspection" }, + { name = "annotated-doc" }, + { name = "pydantic" }, + { name = "starlette" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, ] sdist = { url = "https://files.pythonhosted.org/packages/81/2d/ff8d91d7b564d464629a0fd50a4489c97fcb836ac230bf3a7269232a9b1f/fastapi-0.136.3.tar.gz", hash = "sha256:e487fae93ad408e6f47641ee4dfe389864fd7bec92e547ea8498fc13f43e83ab", size = 396410, upload-time = "2026-05-23T18:53:15.192Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e0/82/45359b62a067409bd929ae8a56b8ed13e5a8c8a61194b3c236920999ab83/fastapi-0.136.3-py3-none-any.whl", hash = "sha256:3d2a69bdf04b7e9f3afa292c3bc7a98816bbfafa10bc9b45f3f3700d2f761620", size = 117481, upload-time = "2026-05-23T18:53:16.924Z" }, + { url = "https://files.pythonhosted.org/packages/e0/82/45359b62a067409bd929ae8a56b8ed13e5a8c8a61194b3c236920999ab83/fastapi-0.136.3-py3-none-any.whl", hash = "sha256:3d2a69bdf04b7e9f3afa292c3bc7a98816bbfafa10bc9b45f3f3700d2f761620", size = 117481, upload-time = "2026-05-23T18:53:16.924Z" }, ] [[package]] @@ -285,7 +285,7 @@ version = "3.29.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/b5/fe/997687a931ab51049acce6fa1f23e8f01216374ea81374ddee763c493db5/filelock-3.29.0.tar.gz", hash = "sha256:69974355e960702e789734cb4871f884ea6fe50bd8404051a3530bc07809cf90", size = 57571, upload-time = "2026-04-19T15:39:10.068Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/81/47/dd9a212ef6e343a6857485ffe25bba537304f1913bdbed446a23f7f592e1/filelock-3.29.0-py3-none-any.whl", hash = "sha256:96f5f6344709aa1572bbf631c640e4ebeeb519e08da902c39a001882f30ac258", size = 39812, upload-time = "2026-04-19T15:39:08.752Z" }, + { url = "https://files.pythonhosted.org/packages/81/47/dd9a212ef6e343a6857485ffe25bba537304f1913bdbed446a23f7f592e1/filelock-3.29.0-py3-none-any.whl", hash = "sha256:96f5f6344709aa1572bbf631c640e4ebeeb519e08da902c39a001882f30ac258", size = 39812, upload-time = "2026-04-19T15:39:08.752Z" }, ] [[package]] @@ -294,7 +294,7 @@ version = "2026.4.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/d5/8d/1c51c094345df128ca4a990d633fe1a0ff28726c9e6b3c41ba65087bba1d/fsspec-2026.4.0.tar.gz", hash = "sha256:301d8ac70ae90ef3ad05dcf94d6c3754a097f9b5fe4667d2787aa359ec7df7e4", size = 312760, upload-time = "2026-04-29T20:42:38.635Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d5/0c/043d5e551459da400957a1395e0febbf771446ff34291afcbe3d8be2a279/fsspec-2026.4.0-py3-none-any.whl", hash = "sha256:11ef7bb35dab8a394fde6e608221d5cf3e8499401c249bebaeaad760a1a8dec2", size = 203402, upload-time = "2026-04-29T20:42:36.842Z" }, + { url = "https://files.pythonhosted.org/packages/d5/0c/043d5e551459da400957a1395e0febbf771446ff34291afcbe3d8be2a279/fsspec-2026.4.0-py3-none-any.whl", hash = "sha256:11ef7bb35dab8a394fde6e608221d5cf3e8499401c249bebaeaad760a1a8dec2", size = 203402, upload-time = "2026-04-29T20:42:36.842Z" }, ] [[package]] @@ -303,7 +303,7 @@ version = "0.16.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, ] [[package]] @@ -312,30 +312,30 @@ version = "1.5.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/74/d8/5c06fc76461418326a7decf8367480c35be11a41fd938633929c60a9ec6b/hf_xet-1.5.0.tar.gz", hash = "sha256:e0fb0a34d9f406eed88233e829a67ec016bec5af19e480eac65a233ea289a948", size = 837196, upload-time = "2026-05-06T06:18:15.583Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/68/9b/6912c99070915a4f28119e3c5b52a9abd1eec0ad5cb293b8c967a0c6f5a2/hf_xet-1.5.0-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:7d70fe2ce97b9db73b9c9b9c81fe3693640aec83416a966c446afea54acfae3c", size = 4023383, upload-time = "2026-05-06T06:17:53.947Z" }, - { url = "https://files.pythonhosted.org/packages/0f/6d/9563cfde59b5d8128a9c7ec972a087f4c782e4f7bac5a85234edfd5d5e49/hf_xet-1.5.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:73a0dae8c71de3b0633a45c73f4a4a5ed09e94b43441d82981a781d4f12baa42", size = 3792751, upload-time = "2026-05-06T06:17:51.791Z" }, - { url = "https://files.pythonhosted.org/packages/07/a5/ed5a0cf35b49a0571af5a8f53416dad1877a718c021c9937c3a53cb45781/hf_xet-1.5.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a60290ec57e9b71767fba7c3645ddafdd0759974b540441510c629c6db6db24a", size = 4456058, upload-time = "2026-05-06T06:17:40.735Z" }, - { url = "https://files.pythonhosted.org/packages/60/fb/3ae8bf2a7a37a4197d0195d7247fd25b3952e15cb8a599e285dfaa6f52b3/hf_xet-1.5.0-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:e5de0f6deada0dada870bb376a11bcd1f08abf3a968a6d118f33e72d1b1eb480", size = 4250783, upload-time = "2026-05-06T06:17:38.412Z" }, - { url = "https://files.pythonhosted.org/packages/a2/9b/8bae40d4d91525085137196e84eb0ed49cf65b5e96e5c3ecdadd8bd0fac2/hf_xet-1.5.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:c799d49f1a5544a0ef7591c0ee75e0d6b93d6f56dc7a4979f59f7518d2872216", size = 4445594, upload-time = "2026-05-06T06:18:04.219Z" }, - { url = "https://files.pythonhosted.org/packages/13/59/c74efbbd4e8728172b2cc72a2bc014d2947a4b7bdced932fbd3f5da1a4e5/hf_xet-1.5.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:2baea1b0b989e5c152fe81425f7745ddc8901280ba3d97c98d8cdece7b706c60", size = 4663995, upload-time = "2026-05-06T06:18:06.1Z" }, - { url = "https://files.pythonhosted.org/packages/73/32/8e1e0410af64cda9b139d1dcebdc993a8ff9c8c7c0e2696ae356d75ccc0d/hf_xet-1.5.0-cp313-cp313t-win_amd64.whl", hash = "sha256:526345b3ed45f374f6317349df489167606736c876241ba984105afe7fd4839d", size = 3966608, upload-time = "2026-05-06T06:18:19.74Z" }, - { url = "https://files.pythonhosted.org/packages/fc/34/a8febc8f4edbea8b3e21b02ebc8b628679b84ba7e45cde624a7736b51500/hf_xet-1.5.0-cp313-cp313t-win_arm64.whl", hash = "sha256:786d28e2eb8315d5035544b9d137b4a842d600c434bb91bf7d0d953cce906ad4", size = 3796946, upload-time = "2026-05-06T06:18:17.568Z" }, - { url = "https://files.pythonhosted.org/packages/2a/20/8fc8996afe5815fa1a6be8e9e5c02f24500f409d599e905800d498a4e14d/hf_xet-1.5.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:872d5601e6deea30d15865ede55d29eac6daf5a534ab417b99b6ef6b076dd96c", size = 4023495, upload-time = "2026-05-06T06:18:01.94Z" }, - { url = "https://files.pythonhosted.org/packages/32/6a/93d84463c00cecb561a7508aa6303e35ee2894294eac14245526924415fe/hf_xet-1.5.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:9929561f5abf4581c8ea79587881dfef6b8abb2a0d8a51915936fc2a614f4e73", size = 3792731, upload-time = "2026-05-06T06:18:00.021Z" }, - { url = "https://files.pythonhosted.org/packages/9d/5a/8ec8e0c863b382d00b3c2e2af6ded6b06371be617144a625903a6d562f4b/hf_xet-1.5.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f7b7bbae318e583a86fb21e5a4a175d6721d628a2874f4bd022d0e660c32a682", size = 4456738, upload-time = "2026-05-06T06:17:49.574Z" }, - { url = "https://files.pythonhosted.org/packages/c5/ca/f7effa1a67717da2bcc6b6c28f71c6ca648c77acaec4e2c32f40cbe16d85/hf_xet-1.5.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:cf7b2dc6f31a4ea754bb50f74cde482dcf5d366d184076d8530b9872787f3761", size = 4251622, upload-time = "2026-05-06T06:17:47.096Z" }, - { url = "https://files.pythonhosted.org/packages/65/f2/19247dba3e231cf77dec59ddfb878f00057635ff773d099c9b59d37812c3/hf_xet-1.5.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8dbcbab554c9ef158ef2c991545c3e970ddd8cc7acdcd0a78c5a41095dab4ded", size = 4445667, upload-time = "2026-05-06T06:18:11.983Z" }, - { url = "https://files.pythonhosted.org/packages/7f/64/6f116801a3bcfb6f59f5c251f48cadc47ea54026441c4a385079286a94fa/hf_xet-1.5.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5906bf7718d3636dc13402914736abe723492cb730f744834f5f5b67d3a12702", size = 4664619, upload-time = "2026-05-06T06:18:13.771Z" }, - { url = "https://files.pythonhosted.org/packages/5c/e8/069542d37946ed08669b127e1496fa99e78196d71de8d41eda5e9f1b7a58/hf_xet-1.5.0-cp314-cp314t-win_amd64.whl", hash = "sha256:5f3dc2248fc01cc0a00cd392ab497f1ca373fcbc7e3f2da1f452480b384e839e", size = 3966802, upload-time = "2026-05-06T06:18:28.162Z" }, - { url = "https://files.pythonhosted.org/packages/f9/91/fc6fdec27b14d04e88c386ac0a0129732b53fa23f7c4a78f4b83a039c567/hf_xet-1.5.0-cp314-cp314t-win_arm64.whl", hash = "sha256:b285cea1b5bab46b758772716ba8d6854a1a0310fed1c249d678a8b38601e5a0", size = 3797168, upload-time = "2026-05-06T06:18:26.287Z" }, - { url = "https://files.pythonhosted.org/packages/3d/fb/69ff198a82cae7eb1a69fb84d93b3a3e4816564d76817fe541ddc96874eb/hf_xet-1.5.0-cp37-abi3-macosx_10_12_x86_64.whl", hash = "sha256:dad0dc84e941b8ba3c860659fe1fdc35c049d47cce293f003287757e971a8f56", size = 4030814, upload-time = "2026-05-06T06:17:57.933Z" }, - { url = "https://files.pythonhosted.org/packages/9b/ff/edcc2b40162bef3ff78e14ab637e5f3b89243d6aee72f5949d3bb6a5af83/hf_xet-1.5.0-cp37-abi3-macosx_11_0_arm64.whl", hash = "sha256:fd6e5a9b0fdac4ed03ed45ef79254a655b1aaab514a02202617fbf643f5fdf7a", size = 3798444, upload-time = "2026-05-06T06:17:55.79Z" }, - { url = "https://files.pythonhosted.org/packages/49/4d/103f76b04310e5e57656696cc184690d20c466af0bca3ca88f8c8ea5d4f3/hf_xet-1.5.0-cp37-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3531b1823a0e6d77d80f9ed15ca0e00f0d115094f8ac033d5cae88f4564cc949", size = 4465986, upload-time = "2026-05-06T06:17:44.886Z" }, - { url = "https://files.pythonhosted.org/packages/c4/a2/546f47f464737b3edbab6f8ddb57f2599b93d2cbb66f06abb475ccb48651/hf_xet-1.5.0-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:9a0ee58cd18d5ea799f7ed11290bbccbe56bdd8b1d97ca74b9cc49a3945d7a3b", size = 4259865, upload-time = "2026-05-06T06:17:42.639Z" }, - { url = "https://files.pythonhosted.org/packages/95/7f/1be593c1f28613be2e196473481cd81bfc5910795e30a34e8f744f6cac4f/hf_xet-1.5.0-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:1e60df5a42e9bed8628b6416af2cba4cba57ae9f02de226a06b020d98e1aab18", size = 4459835, upload-time = "2026-05-06T06:18:08.026Z" }, - { url = "https://files.pythonhosted.org/packages/aa/b2/703569fc881f3284487e68cda7b42179978480da3c438042a6bbbb4a671c/hf_xet-1.5.0-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:4b35549ce62601b84da4ff9b24d970032ace3d4430f52d91bcbb26c901d6c690", size = 4672414, upload-time = "2026-05-06T06:18:09.864Z" }, - { url = "https://files.pythonhosted.org/packages/af/37/1b6def445c567286b50aa3b33828158e135b1be44938dde59f11382a500c/hf_xet-1.5.0-cp37-abi3-win_amd64.whl", hash = "sha256:2806c7c17b4d23f8d88f7c4814f838c3b6150773fe339c20af23e1cfaf2797e4", size = 3977238, upload-time = "2026-05-06T06:18:23.621Z" }, - { url = "https://files.pythonhosted.org/packages/62/94/3b66b148778ee100dcfd69c2ca22b57b41b44d3063ceec934f209e9184ce/hf_xet-1.5.0-cp37-abi3-win_arm64.whl", hash = "sha256:b6c9df403040248c76d808d3e047d64db2d923bae593eb244c41e425cf6cd7be", size = 3806916, upload-time = "2026-05-06T06:18:21.7Z" }, + { url = "https://files.pythonhosted.org/packages/68/9b/6912c99070915a4f28119e3c5b52a9abd1eec0ad5cb293b8c967a0c6f5a2/hf_xet-1.5.0-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:7d70fe2ce97b9db73b9c9b9c81fe3693640aec83416a966c446afea54acfae3c", size = 4023383, upload-time = "2026-05-06T06:17:53.947Z" }, + { url = "https://files.pythonhosted.org/packages/0f/6d/9563cfde59b5d8128a9c7ec972a087f4c782e4f7bac5a85234edfd5d5e49/hf_xet-1.5.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:73a0dae8c71de3b0633a45c73f4a4a5ed09e94b43441d82981a781d4f12baa42", size = 3792751, upload-time = "2026-05-06T06:17:51.791Z" }, + { url = "https://files.pythonhosted.org/packages/07/a5/ed5a0cf35b49a0571af5a8f53416dad1877a718c021c9937c3a53cb45781/hf_xet-1.5.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a60290ec57e9b71767fba7c3645ddafdd0759974b540441510c629c6db6db24a", size = 4456058, upload-time = "2026-05-06T06:17:40.735Z" }, + { url = "https://files.pythonhosted.org/packages/60/fb/3ae8bf2a7a37a4197d0195d7247fd25b3952e15cb8a599e285dfaa6f52b3/hf_xet-1.5.0-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:e5de0f6deada0dada870bb376a11bcd1f08abf3a968a6d118f33e72d1b1eb480", size = 4250783, upload-time = "2026-05-06T06:17:38.412Z" }, + { url = "https://files.pythonhosted.org/packages/a2/9b/8bae40d4d91525085137196e84eb0ed49cf65b5e96e5c3ecdadd8bd0fac2/hf_xet-1.5.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:c799d49f1a5544a0ef7591c0ee75e0d6b93d6f56dc7a4979f59f7518d2872216", size = 4445594, upload-time = "2026-05-06T06:18:04.219Z" }, + { url = "https://files.pythonhosted.org/packages/13/59/c74efbbd4e8728172b2cc72a2bc014d2947a4b7bdced932fbd3f5da1a4e5/hf_xet-1.5.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:2baea1b0b989e5c152fe81425f7745ddc8901280ba3d97c98d8cdece7b706c60", size = 4663995, upload-time = "2026-05-06T06:18:06.1Z" }, + { url = "https://files.pythonhosted.org/packages/73/32/8e1e0410af64cda9b139d1dcebdc993a8ff9c8c7c0e2696ae356d75ccc0d/hf_xet-1.5.0-cp313-cp313t-win_amd64.whl", hash = "sha256:526345b3ed45f374f6317349df489167606736c876241ba984105afe7fd4839d", size = 3966608, upload-time = "2026-05-06T06:18:19.74Z" }, + { url = "https://files.pythonhosted.org/packages/fc/34/a8febc8f4edbea8b3e21b02ebc8b628679b84ba7e45cde624a7736b51500/hf_xet-1.5.0-cp313-cp313t-win_arm64.whl", hash = "sha256:786d28e2eb8315d5035544b9d137b4a842d600c434bb91bf7d0d953cce906ad4", size = 3796946, upload-time = "2026-05-06T06:18:17.568Z" }, + { url = "https://files.pythonhosted.org/packages/2a/20/8fc8996afe5815fa1a6be8e9e5c02f24500f409d599e905800d498a4e14d/hf_xet-1.5.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:872d5601e6deea30d15865ede55d29eac6daf5a534ab417b99b6ef6b076dd96c", size = 4023495, upload-time = "2026-05-06T06:18:01.94Z" }, + { url = "https://files.pythonhosted.org/packages/32/6a/93d84463c00cecb561a7508aa6303e35ee2894294eac14245526924415fe/hf_xet-1.5.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:9929561f5abf4581c8ea79587881dfef6b8abb2a0d8a51915936fc2a614f4e73", size = 3792731, upload-time = "2026-05-06T06:18:00.021Z" }, + { url = "https://files.pythonhosted.org/packages/9d/5a/8ec8e0c863b382d00b3c2e2af6ded6b06371be617144a625903a6d562f4b/hf_xet-1.5.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f7b7bbae318e583a86fb21e5a4a175d6721d628a2874f4bd022d0e660c32a682", size = 4456738, upload-time = "2026-05-06T06:17:49.574Z" }, + { url = "https://files.pythonhosted.org/packages/c5/ca/f7effa1a67717da2bcc6b6c28f71c6ca648c77acaec4e2c32f40cbe16d85/hf_xet-1.5.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:cf7b2dc6f31a4ea754bb50f74cde482dcf5d366d184076d8530b9872787f3761", size = 4251622, upload-time = "2026-05-06T06:17:47.096Z" }, + { url = "https://files.pythonhosted.org/packages/65/f2/19247dba3e231cf77dec59ddfb878f00057635ff773d099c9b59d37812c3/hf_xet-1.5.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8dbcbab554c9ef158ef2c991545c3e970ddd8cc7acdcd0a78c5a41095dab4ded", size = 4445667, upload-time = "2026-05-06T06:18:11.983Z" }, + { url = "https://files.pythonhosted.org/packages/7f/64/6f116801a3bcfb6f59f5c251f48cadc47ea54026441c4a385079286a94fa/hf_xet-1.5.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5906bf7718d3636dc13402914736abe723492cb730f744834f5f5b67d3a12702", size = 4664619, upload-time = "2026-05-06T06:18:13.771Z" }, + { url = "https://files.pythonhosted.org/packages/5c/e8/069542d37946ed08669b127e1496fa99e78196d71de8d41eda5e9f1b7a58/hf_xet-1.5.0-cp314-cp314t-win_amd64.whl", hash = "sha256:5f3dc2248fc01cc0a00cd392ab497f1ca373fcbc7e3f2da1f452480b384e839e", size = 3966802, upload-time = "2026-05-06T06:18:28.162Z" }, + { url = "https://files.pythonhosted.org/packages/f9/91/fc6fdec27b14d04e88c386ac0a0129732b53fa23f7c4a78f4b83a039c567/hf_xet-1.5.0-cp314-cp314t-win_arm64.whl", hash = "sha256:b285cea1b5bab46b758772716ba8d6854a1a0310fed1c249d678a8b38601e5a0", size = 3797168, upload-time = "2026-05-06T06:18:26.287Z" }, + { url = "https://files.pythonhosted.org/packages/3d/fb/69ff198a82cae7eb1a69fb84d93b3a3e4816564d76817fe541ddc96874eb/hf_xet-1.5.0-cp37-abi3-macosx_10_12_x86_64.whl", hash = "sha256:dad0dc84e941b8ba3c860659fe1fdc35c049d47cce293f003287757e971a8f56", size = 4030814, upload-time = "2026-05-06T06:17:57.933Z" }, + { url = "https://files.pythonhosted.org/packages/9b/ff/edcc2b40162bef3ff78e14ab637e5f3b89243d6aee72f5949d3bb6a5af83/hf_xet-1.5.0-cp37-abi3-macosx_11_0_arm64.whl", hash = "sha256:fd6e5a9b0fdac4ed03ed45ef79254a655b1aaab514a02202617fbf643f5fdf7a", size = 3798444, upload-time = "2026-05-06T06:17:55.79Z" }, + { url = "https://files.pythonhosted.org/packages/49/4d/103f76b04310e5e57656696cc184690d20c466af0bca3ca88f8c8ea5d4f3/hf_xet-1.5.0-cp37-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3531b1823a0e6d77d80f9ed15ca0e00f0d115094f8ac033d5cae88f4564cc949", size = 4465986, upload-time = "2026-05-06T06:17:44.886Z" }, + { url = "https://files.pythonhosted.org/packages/c4/a2/546f47f464737b3edbab6f8ddb57f2599b93d2cbb66f06abb475ccb48651/hf_xet-1.5.0-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:9a0ee58cd18d5ea799f7ed11290bbccbe56bdd8b1d97ca74b9cc49a3945d7a3b", size = 4259865, upload-time = "2026-05-06T06:17:42.639Z" }, + { url = "https://files.pythonhosted.org/packages/95/7f/1be593c1f28613be2e196473481cd81bfc5910795e30a34e8f744f6cac4f/hf_xet-1.5.0-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:1e60df5a42e9bed8628b6416af2cba4cba57ae9f02de226a06b020d98e1aab18", size = 4459835, upload-time = "2026-05-06T06:18:08.026Z" }, + { url = "https://files.pythonhosted.org/packages/aa/b2/703569fc881f3284487e68cda7b42179978480da3c438042a6bbbb4a671c/hf_xet-1.5.0-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:4b35549ce62601b84da4ff9b24d970032ace3d4430f52d91bcbb26c901d6c690", size = 4672414, upload-time = "2026-05-06T06:18:09.864Z" }, + { url = "https://files.pythonhosted.org/packages/af/37/1b6def445c567286b50aa3b33828158e135b1be44938dde59f11382a500c/hf_xet-1.5.0-cp37-abi3-win_amd64.whl", hash = "sha256:2806c7c17b4d23f8d88f7c4814f838c3b6150773fe339c20af23e1cfaf2797e4", size = 3977238, upload-time = "2026-05-06T06:18:23.621Z" }, + { url = "https://files.pythonhosted.org/packages/62/94/3b66b148778ee100dcfd69c2ca22b57b41b44d3063ceec934f209e9184ce/hf_xet-1.5.0-cp37-abi3-win_arm64.whl", hash = "sha256:b6c9df403040248c76d808d3e047d64db2d923bae593eb244c41e425cf6cd7be", size = 3806916, upload-time = "2026-05-06T06:18:21.7Z" }, ] [[package]] @@ -343,12 +343,12 @@ name = "httpcore" version = "1.0.9" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "certifi" }, - { name = "h11" }, + { name = "certifi" }, + { name = "h11" }, ] sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, ] [[package]] @@ -357,27 +357,27 @@ version = "0.8.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/43/e5/d471fcb0e14523fe1c3f4ba58ca52480e7bd70ad7109a3846bc75892f7fb/httptools-0.8.0.tar.gz", hash = "sha256:6b2a32f18d97e16e90827d7a819ffa8dbd8cc245fc4e1fa9d1095b54ef4bd999", size = 271342, upload-time = "2026-05-25T22:17:48.841Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/5e/e5/8cfcabc5546e8022f168be28bcdaa128a240a0befdd03b59d558b4f18bd6/httptools-0.8.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:614ceea8ea606848bece2338ac03b3ce5324bcb4be8dc7d377ed708012fa4db8", size = 205148, upload-time = "2026-05-25T22:17:16.333Z" }, - { url = "https://files.pythonhosted.org/packages/2a/0e/0fb14848c19a686c8062ff9067c1a48793e3224b47bc5b201535b6036fce/httptools-0.8.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2d689918c15a013c65ef52d9fd495d766893ab831a2c8d89f2ac5940a5df847c", size = 111368, upload-time = "2026-05-25T22:17:17.586Z" }, - { url = "https://files.pythonhosted.org/packages/2e/1b/46f1cecf06b9bbde8e4b8c88034ac7908989e5ff7a3a388ef38392949c1f/httptools-0.8.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:eb3028cca2fc0a6d720e52ef61d8ebb62fcbfeb1de56874546d858d3f25a26b7", size = 486447, upload-time = "2026-05-25T22:17:18.564Z" }, - { url = "https://files.pythonhosted.org/packages/77/00/258bfc0837221f81d9725c45f9b948a6a6b2994a147a4fb66e85100c668f/httptools-0.8.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:88bdd940f2b5d487b4d032c6afa5489a7dc4694410d43de3c38c4fb3af0dc45d", size = 482448, upload-time = "2026-05-25T22:17:19.912Z" }, - { url = "https://files.pythonhosted.org/packages/04/ab/d1cef3b5523f4d272a70f42a776c3169a2dddfe3a54de4b2ce4a36341528/httptools-0.8.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6a43c9dd399758ccc0531acb0a3c4a6c299ee893ee9400e9c893b7bdcfae0681", size = 464460, upload-time = "2026-05-25T22:17:20.882Z" }, - { url = "https://files.pythonhosted.org/packages/ce/48/5d1d072442277bb2b3434e0e60690b8e8c23840ef7de8b6ea54040a536d3/httptools-0.8.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:0770728beb05094c809b98e814edff5fef69d26ad7d21185f2f6d5884a0ba683", size = 471312, upload-time = "2026-05-25T22:17:22.085Z" }, - { url = "https://files.pythonhosted.org/packages/0d/66/b96623b27e51a68199ef4efdda0613cced9233fe3062ac74e50749c5ad37/httptools-0.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:7685df791fad561384bfb139e77fde27a1ffd93134e016f95a0db424ffbf77b1", size = 90117, upload-time = "2026-05-25T22:17:23.074Z" }, - { url = "https://files.pythonhosted.org/packages/1a/12/fa3fbf5f9517b273edea2dc982aa82a8c634091e67c590792b729017bc6f/httptools-0.8.0-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:de242a49b5d18e0a8776e654e9f6bf6d89f3875a5c35b425a0e7ce940feb3fd6", size = 206183, upload-time = "2026-05-25T22:17:24.004Z" }, - { url = "https://files.pythonhosted.org/packages/30/fc/5e7c4cb443370f2090a3aba0453a07384d29ff66b7435bb90e77e1037599/httptools-0.8.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:159e9ab5f701ccd42e555a12f1ad8ff69702910fc1c996cf2bb66e5fcb7a231b", size = 112079, upload-time = "2026-05-25T22:17:25.216Z" }, - { url = "https://files.pythonhosted.org/packages/ba/53/771bd891eb0f236f32145d6a1775777ec85745f3cc983a1f23d1a3b8ddfe/httptools-0.8.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:c4a9f1707e4823d54dfec6c33fa3697d302aed536ed352a7ebb5a061ddb869d0", size = 481596, upload-time = "2026-05-25T22:17:26.186Z" }, - { url = "https://files.pythonhosted.org/packages/62/42/94e15bc68ce3d423243c45d7f1b0c7561f13844f97dc52ae23182fb65628/httptools-0.8.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d76ad7b951387e3632c8716a9bb03ac5b45c5f16119aa409db0459520887944e", size = 480865, upload-time = "2026-05-25T22:17:27.542Z" }, - { url = "https://files.pythonhosted.org/packages/1c/7c/fe2980fc03723272e30f135b62360b075f513dfe7cc73aef36c7f04012bd/httptools-0.8.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a3b7387147361c3fd47a0bde763c5c91b5b4cd4dc9989b8ece84ff436c99843b", size = 463189, upload-time = "2026-05-25T22:17:28.546Z" }, - { url = "https://files.pythonhosted.org/packages/15/1b/47fc5fff68acd1bfa20b4734059c9a06cadb88119dcd5258b5b0d21d91c8/httptools-0.8.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f256d6ce930c52ca1cb2a960b7da03548c454e7d28b06059ad41bfe789036ce0", size = 466610, upload-time = "2026-05-25T22:17:29.816Z" }, - { url = "https://files.pythonhosted.org/packages/60/bd/07b13c93ffd9bec9546e0d43f8e19378dd696dbd278511406bc07371ef1f/httptools-0.8.0-cp314-cp314-win_amd64.whl", hash = "sha256:19d1ee275bb59ba2643ba9a3a1e51cc0c788caf2b8df506368e03f56fdd08527", size = 92705, upload-time = "2026-05-25T22:17:31.133Z" }, - { url = "https://files.pythonhosted.org/packages/fd/c4/121648f68ce066d7bd762d6b6d97e620847642d38d54f3d90ff11d947629/httptools-0.8.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:de1ed58a974e75d56560acc7e7fed01a454994429456f65209789992e41f2568", size = 215023, upload-time = "2026-05-25T22:17:32.401Z" }, - { url = "https://files.pythonhosted.org/packages/b9/b0/312a062ae741ae3e8baa8c8bf20be81b2e67337b259ab4349bebc7b6142e/httptools-0.8.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:e93c227b595c6926c1acee96891dd9da4be338cfbe82e5cd3bb9d8dd7dc4ac0b", size = 117405, upload-time = "2026-05-25T22:17:33.742Z" }, - { url = "https://files.pythonhosted.org/packages/fc/37/fccd705f795386bb05bf413012fecff2a33e5aa8c2f069096de3e9fd8702/httptools-0.8.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2a021c3a8e65cc125390d72f59b968afca3bdcaff25bd67965e0a055a14946ca", size = 558497, upload-time = "2026-05-25T22:17:34.732Z" }, - { url = "https://files.pythonhosted.org/packages/bd/39/f172e8003576de35f5ba77ff417cf0e34429d35dc014deef15afa337a72c/httptools-0.8.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:48774d39cbb70e2b1f71f88852a3087ae1d3a1eb80482bb48c13067ab080c14f", size = 571585, upload-time = "2026-05-25T22:17:35.813Z" }, - { url = "https://files.pythonhosted.org/packages/3e/b9/f5564760af99f3dbbf3f9104dc00e5da27e96cf433c6bdcf77617f70bf3f/httptools-0.8.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:88eead8ec8680a9f146c655bc88445a325bd7921cfd8194c7337e9467282427d", size = 543297, upload-time = "2026-05-25T22:17:37.08Z" }, - { url = "https://files.pythonhosted.org/packages/99/67/8d9f2c313618e161b82f3873188e7196126da1d6e29688df40eb3997c77a/httptools-0.8.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:2c032fa028f46871ec7e1fc59fc15e8023eab3e6bbe6ece786a1611719a5d081", size = 539535, upload-time = "2026-05-25T22:17:38.032Z" }, - { url = "https://files.pythonhosted.org/packages/48/63/b906c01e53f50d432c0defe43ce52764a111dc1bdd028bafbeb54dcfd008/httptools-0.8.0-cp314-cp314t-win_amd64.whl", hash = "sha256:384c17174464c8e873398b7af24f0b1f44d992c820328413951a625323155d77", size = 108209, upload-time = "2026-05-25T22:17:39.473Z" }, + { url = "https://files.pythonhosted.org/packages/5e/e5/8cfcabc5546e8022f168be28bcdaa128a240a0befdd03b59d558b4f18bd6/httptools-0.8.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:614ceea8ea606848bece2338ac03b3ce5324bcb4be8dc7d377ed708012fa4db8", size = 205148, upload-time = "2026-05-25T22:17:16.333Z" }, + { url = "https://files.pythonhosted.org/packages/2a/0e/0fb14848c19a686c8062ff9067c1a48793e3224b47bc5b201535b6036fce/httptools-0.8.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2d689918c15a013c65ef52d9fd495d766893ab831a2c8d89f2ac5940a5df847c", size = 111368, upload-time = "2026-05-25T22:17:17.586Z" }, + { url = "https://files.pythonhosted.org/packages/2e/1b/46f1cecf06b9bbde8e4b8c88034ac7908989e5ff7a3a388ef38392949c1f/httptools-0.8.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:eb3028cca2fc0a6d720e52ef61d8ebb62fcbfeb1de56874546d858d3f25a26b7", size = 486447, upload-time = "2026-05-25T22:17:18.564Z" }, + { url = "https://files.pythonhosted.org/packages/77/00/258bfc0837221f81d9725c45f9b948a6a6b2994a147a4fb66e85100c668f/httptools-0.8.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:88bdd940f2b5d487b4d032c6afa5489a7dc4694410d43de3c38c4fb3af0dc45d", size = 482448, upload-time = "2026-05-25T22:17:19.912Z" }, + { url = "https://files.pythonhosted.org/packages/04/ab/d1cef3b5523f4d272a70f42a776c3169a2dddfe3a54de4b2ce4a36341528/httptools-0.8.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6a43c9dd399758ccc0531acb0a3c4a6c299ee893ee9400e9c893b7bdcfae0681", size = 464460, upload-time = "2026-05-25T22:17:20.882Z" }, + { url = "https://files.pythonhosted.org/packages/ce/48/5d1d072442277bb2b3434e0e60690b8e8c23840ef7de8b6ea54040a536d3/httptools-0.8.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:0770728beb05094c809b98e814edff5fef69d26ad7d21185f2f6d5884a0ba683", size = 471312, upload-time = "2026-05-25T22:17:22.085Z" }, + { url = "https://files.pythonhosted.org/packages/0d/66/b96623b27e51a68199ef4efdda0613cced9233fe3062ac74e50749c5ad37/httptools-0.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:7685df791fad561384bfb139e77fde27a1ffd93134e016f95a0db424ffbf77b1", size = 90117, upload-time = "2026-05-25T22:17:23.074Z" }, + { url = "https://files.pythonhosted.org/packages/1a/12/fa3fbf5f9517b273edea2dc982aa82a8c634091e67c590792b729017bc6f/httptools-0.8.0-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:de242a49b5d18e0a8776e654e9f6bf6d89f3875a5c35b425a0e7ce940feb3fd6", size = 206183, upload-time = "2026-05-25T22:17:24.004Z" }, + { url = "https://files.pythonhosted.org/packages/30/fc/5e7c4cb443370f2090a3aba0453a07384d29ff66b7435bb90e77e1037599/httptools-0.8.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:159e9ab5f701ccd42e555a12f1ad8ff69702910fc1c996cf2bb66e5fcb7a231b", size = 112079, upload-time = "2026-05-25T22:17:25.216Z" }, + { url = "https://files.pythonhosted.org/packages/ba/53/771bd891eb0f236f32145d6a1775777ec85745f3cc983a1f23d1a3b8ddfe/httptools-0.8.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:c4a9f1707e4823d54dfec6c33fa3697d302aed536ed352a7ebb5a061ddb869d0", size = 481596, upload-time = "2026-05-25T22:17:26.186Z" }, + { url = "https://files.pythonhosted.org/packages/62/42/94e15bc68ce3d423243c45d7f1b0c7561f13844f97dc52ae23182fb65628/httptools-0.8.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d76ad7b951387e3632c8716a9bb03ac5b45c5f16119aa409db0459520887944e", size = 480865, upload-time = "2026-05-25T22:17:27.542Z" }, + { url = "https://files.pythonhosted.org/packages/1c/7c/fe2980fc03723272e30f135b62360b075f513dfe7cc73aef36c7f04012bd/httptools-0.8.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a3b7387147361c3fd47a0bde763c5c91b5b4cd4dc9989b8ece84ff436c99843b", size = 463189, upload-time = "2026-05-25T22:17:28.546Z" }, + { url = "https://files.pythonhosted.org/packages/15/1b/47fc5fff68acd1bfa20b4734059c9a06cadb88119dcd5258b5b0d21d91c8/httptools-0.8.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f256d6ce930c52ca1cb2a960b7da03548c454e7d28b06059ad41bfe789036ce0", size = 466610, upload-time = "2026-05-25T22:17:29.816Z" }, + { url = "https://files.pythonhosted.org/packages/60/bd/07b13c93ffd9bec9546e0d43f8e19378dd696dbd278511406bc07371ef1f/httptools-0.8.0-cp314-cp314-win_amd64.whl", hash = "sha256:19d1ee275bb59ba2643ba9a3a1e51cc0c788caf2b8df506368e03f56fdd08527", size = 92705, upload-time = "2026-05-25T22:17:31.133Z" }, + { url = "https://files.pythonhosted.org/packages/fd/c4/121648f68ce066d7bd762d6b6d97e620847642d38d54f3d90ff11d947629/httptools-0.8.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:de1ed58a974e75d56560acc7e7fed01a454994429456f65209789992e41f2568", size = 215023, upload-time = "2026-05-25T22:17:32.401Z" }, + { url = "https://files.pythonhosted.org/packages/b9/b0/312a062ae741ae3e8baa8c8bf20be81b2e67337b259ab4349bebc7b6142e/httptools-0.8.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:e93c227b595c6926c1acee96891dd9da4be338cfbe82e5cd3bb9d8dd7dc4ac0b", size = 117405, upload-time = "2026-05-25T22:17:33.742Z" }, + { url = "https://files.pythonhosted.org/packages/fc/37/fccd705f795386bb05bf413012fecff2a33e5aa8c2f069096de3e9fd8702/httptools-0.8.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2a021c3a8e65cc125390d72f59b968afca3bdcaff25bd67965e0a055a14946ca", size = 558497, upload-time = "2026-05-25T22:17:34.732Z" }, + { url = "https://files.pythonhosted.org/packages/bd/39/f172e8003576de35f5ba77ff417cf0e34429d35dc014deef15afa337a72c/httptools-0.8.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:48774d39cbb70e2b1f71f88852a3087ae1d3a1eb80482bb48c13067ab080c14f", size = 571585, upload-time = "2026-05-25T22:17:35.813Z" }, + { url = "https://files.pythonhosted.org/packages/3e/b9/f5564760af99f3dbbf3f9104dc00e5da27e96cf433c6bdcf77617f70bf3f/httptools-0.8.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:88eead8ec8680a9f146c655bc88445a325bd7921cfd8194c7337e9467282427d", size = 543297, upload-time = "2026-05-25T22:17:37.08Z" }, + { url = "https://files.pythonhosted.org/packages/99/67/8d9f2c313618e161b82f3873188e7196126da1d6e29688df40eb3997c77a/httptools-0.8.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:2c032fa028f46871ec7e1fc59fc15e8023eab3e6bbe6ece786a1611719a5d081", size = 539535, upload-time = "2026-05-25T22:17:38.032Z" }, + { url = "https://files.pythonhosted.org/packages/48/63/b906c01e53f50d432c0defe43ce52764a111dc1bdd028bafbeb54dcfd008/httptools-0.8.0-cp314-cp314t-win_amd64.whl", hash = "sha256:384c17174464c8e873398b7af24f0b1f44d992c820328413951a625323155d77", size = 108209, upload-time = "2026-05-25T22:17:39.473Z" }, ] [[package]] @@ -385,14 +385,14 @@ name = "httpx" version = "0.28.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "anyio" }, - { name = "certifi" }, - { name = "httpcore" }, - { name = "idna" }, + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, ] sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, ] [[package]] @@ -400,20 +400,20 @@ name = "huggingface-hub" version = "1.16.4" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "click" }, - { name = "filelock" }, - { name = "fsspec" }, - { name = "hf-xet", marker = "platform_machine == 'AMD64' or platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'arm64' or platform_machine == 'x86_64'" }, - { name = "httpx" }, - { name = "packaging" }, - { name = "pyyaml" }, - { name = "tqdm" }, - { name = "typer" }, - { name = "typing-extensions" }, + { name = "click" }, + { name = "filelock" }, + { name = "fsspec" }, + { name = "hf-xet", marker = "platform_machine == 'AMD64' or platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'arm64' or platform_machine == 'x86_64'" }, + { name = "httpx" }, + { name = "packaging" }, + { name = "pyyaml" }, + { name = "tqdm" }, + { name = "typer" }, + { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/ab/11/9b6e439cb2417c479c3da108b38363232a1554721de9f8ef4836cb07422b/huggingface_hub-1.16.4.tar.gz", hash = "sha256:023bacd155f837d3fa56379ac8e23dababe6d6d87b04f8dacc258a44a38abe01", size = 792585, upload-time = "2026-05-26T17:19:09.971Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/da/86/e05d58ea272089151ba9f6fcc7b44a97aa2533d5a5bce46611220c23c6d6/huggingface_hub-1.16.4-py3-none-any.whl", hash = "sha256:994ec184c3330952d7b5f131ea0b1a6ba1047bd05461f5dec191f8fc1099fbd7", size = 668190, upload-time = "2026-05-26T17:19:08.228Z" }, + { url = "https://files.pythonhosted.org/packages/da/86/e05d58ea272089151ba9f6fcc7b44a97aa2533d5a5bce46611220c23c6d6/huggingface_hub-1.16.4-py3-none-any.whl", hash = "sha256:994ec184c3330952d7b5f131ea0b1a6ba1047bd05461f5dec191f8fc1099fbd7", size = 668190, upload-time = "2026-05-26T17:19:08.228Z" }, ] [[package]] @@ -422,7 +422,7 @@ version = "2.6.19" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/52/63/51723b5f116cc04b061cb6f5a561790abf249d25931d515cd375e063e0f4/identify-2.6.19.tar.gz", hash = "sha256:6be5020c38fcb07da56c53733538a3081ea5aa70d36a156f83044bfbf9173842", size = 99567, upload-time = "2026-04-17T18:39:50.265Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/94/84/d9273cd09688070a6523c4aee4663a8538721b2b755c4962aafae0011e72/identify-2.6.19-py2.py3-none-any.whl", hash = "sha256:20e6a87f786f768c092a721ad107fc9df0eb89347be9396cadf3f4abbd1fb78a", size = 99397, upload-time = "2026-04-17T18:39:49.221Z" }, + { url = "https://files.pythonhosted.org/packages/94/84/d9273cd09688070a6523c4aee4663a8538721b2b755c4962aafae0011e72/identify-2.6.19-py2.py3-none-any.whl", hash = "sha256:20e6a87f786f768c092a721ad107fc9df0eb89347be9396cadf3f4abbd1fb78a", size = 99397, upload-time = "2026-04-17T18:39:49.221Z" }, ] [[package]] @@ -431,7 +431,7 @@ version = "3.16" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/1a/88/bcf9709822fe69d02c2a6a77956c98ce6ea8ca8767a9aadcedc7eb6a2390/idna-3.16.tar.gz", hash = "sha256:d7a6da03db833450fca25d2358ac9ff06cd624577a4aea3a596d5c0f77b8e03d", size = 203770, upload-time = "2026-05-22T00:16:18.781Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/94/16/70255075a9859a0e3adb789b68ceb0e210dec03934245fd98d248226572f/idna-3.16-py3-none-any.whl", hash = "sha256:cc246e3a3f89580c3a951b5ad298ca4638078b2cdd4f115654332b5c26daded5", size = 74165, upload-time = "2026-05-22T00:16:16.698Z" }, + { url = "https://files.pythonhosted.org/packages/94/16/70255075a9859a0e3adb789b68ceb0e210dec03934245fd98d248226572f/idna-3.16-py3-none-any.whl", hash = "sha256:cc246e3a3f89580c3a951b5ad298ca4638078b2cdd4f115654332b5c26daded5", size = 74165, upload-time = "2026-05-22T00:16:16.698Z" }, ] [[package]] @@ -440,7 +440,7 @@ version = "2.3.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, ] [[package]] @@ -448,11 +448,11 @@ name = "jinja2" version = "3.1.6" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "markupsafe" }, + { name = "markupsafe" }, ] sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, + { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, ] [[package]] @@ -461,110 +461,110 @@ version = "1.5.3" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/41/f2/d34e8b3a08a9cc79a50b2208a93dce981fe615b64d5a4d4abee421d898df/joblib-1.5.3.tar.gz", hash = "sha256:8561a3269e6801106863fd0d6d84bb737be9e7631e33aaed3fb9ce5953688da3", size = 331603, upload-time = "2025-12-15T08:41:46.427Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/7b/91/984aca2ec129e2757d1e4e3c81c3fcda9d0f85b74670a094cc443d9ee949/joblib-1.5.3-py3-none-any.whl", hash = "sha256:5fc3c5039fc5ca8c0276333a188bbd59d6b7ab37fe6632daa76bc7f9ec18e713", size = 309071, upload-time = "2025-12-15T08:41:44.973Z" }, + { url = "https://files.pythonhosted.org/packages/7b/91/984aca2ec129e2757d1e4e3c81c3fcda9d0f85b74670a094cc443d9ee949/joblib-1.5.3-py3-none-any.whl", hash = "sha256:5fc3c5039fc5ca8c0276333a188bbd59d6b7ab37fe6632daa76bc7f9ec18e713", size = 309071, upload-time = "2025-12-15T08:41:44.973Z" }, ] [[package]] name = "ktx-daemon" -version = "0.7.0" +version = "0.8.0" source = { editable = "python/ktx-daemon" } dependencies = [ - { name = "fastapi" }, - { name = "ktx-sl" }, - { name = "lkml" }, - { name = "numpy" }, - { name = "orjson" }, - { name = "pandas" }, - { name = "posthog" }, - { name = "psycopg", extra = ["binary"] }, - { name = "pydantic" }, - { name = "requests" }, - { name = "sqlglot" }, - { name = "uvicorn", extra = ["standard"] }, + { name = "fastapi" }, + { name = "ktx-sl" }, + { name = "lkml" }, + { name = "numpy" }, + { name = "orjson" }, + { name = "pandas" }, + { name = "posthog" }, + { name = "psycopg", extra = ["binary"] }, + { name = "pydantic" }, + { name = "requests" }, + { name = "sqlglot" }, + { name = "uvicorn", extra = ["standard"] }, ] [package.optional-dependencies] local-embeddings = [ - { name = "sentence-transformers" }, - { name = "torch", version = "2.12.0", source = { registry = "https://download.pytorch.org/whl/cpu" }, marker = "sys_platform == 'darwin'" }, - { name = "torch", version = "2.12.0+cpu", source = { registry = "https://download.pytorch.org/whl/cpu" }, marker = "sys_platform != 'darwin'" }, + { name = "sentence-transformers" }, + { name = "torch", version = "2.12.0", source = { registry = "https://download.pytorch.org/whl/cpu" }, marker = "sys_platform == 'darwin'" }, + { name = "torch", version = "2.12.0+cpu", source = { registry = "https://download.pytorch.org/whl/cpu" }, marker = "sys_platform != 'darwin'" }, ] [package.dev-dependencies] dev = [ - { name = "httpx" }, - { name = "pytest" }, + { name = "httpx" }, + { name = "pytest" }, ] [package.metadata] requires-dist = [ - { name = "fastapi", specifier = ">=0.136.3" }, - { name = "ktx-sl", editable = "python/ktx-sl" }, - { name = "lkml", specifier = ">=1.3.7" }, - { name = "numpy", specifier = ">=2.4.6" }, - { name = "orjson", specifier = ">=3.11.9" }, - { name = "pandas", specifier = ">=3.0.3" }, - { name = "posthog", specifier = ">=7.16.1" }, - { name = "psycopg", extras = ["binary"], specifier = ">=3.3.4" }, - { name = "pydantic", specifier = ">=2.13.4" }, - { name = "requests", specifier = ">=2.34.2" }, - { name = "sentence-transformers", marker = "extra == 'local-embeddings'", specifier = ">=5.1.1" }, - { name = "sqlglot", specifier = ">=30" }, - { name = "torch", marker = "extra == 'local-embeddings'", specifier = ">=2.2.0", index = "https://download.pytorch.org/whl/cpu" }, - { name = "uvicorn", extras = ["standard"], specifier = ">=0.48.0" }, + { name = "fastapi", specifier = ">=0.136.3" }, + { name = "ktx-sl", editable = "python/ktx-sl" }, + { name = "lkml", specifier = ">=1.3.7" }, + { name = "numpy", specifier = ">=2.4.6" }, + { name = "orjson", specifier = ">=3.11.9" }, + { name = "pandas", specifier = ">=3.0.3" }, + { name = "posthog", specifier = ">=7.16.1" }, + { name = "psycopg", extras = ["binary"], specifier = ">=3.3.4" }, + { name = "pydantic", specifier = ">=2.13.4" }, + { name = "requests", specifier = ">=2.34.2" }, + { name = "sentence-transformers", marker = "extra == 'local-embeddings'", specifier = ">=5.1.1" }, + { name = "sqlglot", specifier = ">=30" }, + { name = "torch", marker = "extra == 'local-embeddings'", specifier = ">=2.2.0", index = "https://download.pytorch.org/whl/cpu" }, + { name = "uvicorn", extras = ["standard"], specifier = ">=0.48.0" }, ] provides-extras = ["local-embeddings"] [package.metadata.requires-dev] dev = [ - { name = "httpx", specifier = ">=0.28.1" }, - { name = "pytest", specifier = ">=9.0.2" }, + { name = "httpx", specifier = ">=0.28.1" }, + { name = "pytest", specifier = ">=9.0.2" }, ] [[package]] name = "ktx-sl" -version = "0.7.0" +version = "0.8.0" source = { editable = "python/ktx-sl" } dependencies = [ - { name = "pydantic" }, - { name = "pyyaml" }, - { name = "sqlglot" }, + { name = "pydantic" }, + { name = "pyyaml" }, + { name = "sqlglot" }, ] [package.optional-dependencies] dev = [ - { name = "pre-commit" }, - { name = "pytest" }, - { name = "pytest-cov" }, - { name = "ruff" }, + { name = "pre-commit" }, + { name = "pytest" }, + { name = "pytest-cov" }, + { name = "ruff" }, ] tpch = [ - { name = "duckdb" }, + { name = "duckdb" }, ] [package.dev-dependencies] dev = [ - { name = "pytest" }, - { name = "pytest-cov" }, + { name = "pytest" }, + { name = "pytest-cov" }, ] [package.metadata] requires-dist = [ - { name = "duckdb", marker = "extra == 'tpch'", specifier = ">=1.0" }, - { name = "pre-commit", marker = "extra == 'dev'" }, - { name = "pydantic", specifier = ">=2" }, - { name = "pytest", marker = "extra == 'dev'", specifier = ">=8" }, - { name = "pytest-cov", marker = "extra == 'dev'" }, - { name = "pyyaml", specifier = ">=6" }, - { name = "ruff", marker = "extra == 'dev'" }, - { name = "sqlglot", specifier = ">=30" }, + { name = "duckdb", marker = "extra == 'tpch'", specifier = ">=1.0" }, + { name = "pre-commit", marker = "extra == 'dev'" }, + { name = "pydantic", specifier = ">=2" }, + { name = "pytest", marker = "extra == 'dev'", specifier = ">=8" }, + { name = "pytest-cov", marker = "extra == 'dev'" }, + { name = "pyyaml", specifier = ">=6" }, + { name = "ruff", marker = "extra == 'dev'" }, + { name = "sqlglot", specifier = ">=30" }, ] provides-extras = ["dev", "tpch"] [package.metadata.requires-dev] dev = [ - { name = "pytest", specifier = ">=9.0.2" }, - { name = "pytest-cov", specifier = ">=7.1.0" }, + { name = "pytest", specifier = ">=9.0.2" }, + { name = "pytest-cov", specifier = ">=7.1.0" }, ] [[package]] @@ -574,19 +574,20 @@ source = { virtual = "." } [package.dev-dependencies] dev = [ - { name = "pre-commit" }, - { name = "pytest" }, - { name = "pytest-cov" }, - { name = "ruff" }, + { name = "pre-commit" }, + { name = "pytest" }, + { name = "pytest-cov" }, + { name = "ruff" }, ] [package.metadata] + [package.metadata.requires-dev] dev = [ - { name = "pre-commit", specifier = ">=4.6.0" }, - { name = "pytest", specifier = ">=9.0.2" }, - { name = "pytest-cov", specifier = ">=7.1.0" }, - { name = "ruff", specifier = ">=0.8.4" }, + { name = "pre-commit", specifier = ">=4.6.0" }, + { name = "pytest", specifier = ">=9.0.2" }, + { name = "pytest-cov", specifier = ">=7.1.0" }, + { name = "ruff", specifier = ">=0.8.4" }, ] [[package]] @@ -595,7 +596,7 @@ version = "1.3.7" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/bf/18/18a3d0281c5e209156b877796096d4ac7259f03465409673056386c99221/lkml-1.3.7.tar.gz", hash = "sha256:51dc9f1b7e74cd7a00e0dbbf06fb573952015328f1f4a3a0730d444444a8ae7a", size = 28763, upload-time = "2025-01-31T02:30:35.472Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c7/15/e7124d4ec54fdcafa801b55d6b67d6196ed6c8a0de554e1a8b67b66fec65/lkml-1.3.7-py2.py3-none-any.whl", hash = "sha256:ce54c517f81fbd21d452038be9e2504fa02951a5bc30f7d7f1eb552c1f3f2b39", size = 23062, upload-time = "2025-01-31T02:30:34.377Z" }, + { url = "https://files.pythonhosted.org/packages/c7/15/e7124d4ec54fdcafa801b55d6b67d6196ed6c8a0de554e1a8b67b66fec65/lkml-1.3.7-py2.py3-none-any.whl", hash = "sha256:ce54c517f81fbd21d452038be9e2504fa02951a5bc30f7d7f1eb552c1f3f2b39", size = 23062, upload-time = "2025-01-31T02:30:34.377Z" }, ] [[package]] @@ -603,11 +604,11 @@ name = "markdown-it-py" version = "4.2.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "mdurl" }, + { name = "mdurl" }, ] sdist = { url = "https://files.pythonhosted.org/packages/06/ff/7841249c247aa650a76b9ee4bbaeae59370dc8bfd2f6c01f3630c35eb134/markdown_it_py-4.2.0.tar.gz", hash = "sha256:04a21681d6fbb623de53f6f364d352309d4094dd4194040a10fd51833e418d49", size = 82454, upload-time = "2026-05-07T12:08:28.36Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b3/81/4da04ced5a082363ecfa159c010d200ecbd959ae410c10c0264a38cac0f5/markdown_it_py-4.2.0-py3-none-any.whl", hash = "sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a", size = 91687, upload-time = "2026-05-07T12:08:27.182Z" }, + { url = "https://files.pythonhosted.org/packages/b3/81/4da04ced5a082363ecfa159c010d200ecbd959ae410c10c0264a38cac0f5/markdown_it_py-4.2.0-py3-none-any.whl", hash = "sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a", size = 91687, upload-time = "2026-05-07T12:08:27.182Z" }, ] [[package]] @@ -616,50 +617,50 @@ version = "3.0.3" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/38/2f/907b9c7bbba283e68f20259574b13d005c121a0fa4c175f9bed27c4597ff/markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795", size = 11622, upload-time = "2025-09-27T18:36:41.777Z" }, - { url = "https://files.pythonhosted.org/packages/9c/d9/5f7756922cdd676869eca1c4e3c0cd0df60ed30199ffd775e319089cb3ed/markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219", size = 12029, upload-time = "2025-09-27T18:36:43.257Z" }, - { url = "https://files.pythonhosted.org/packages/00/07/575a68c754943058c78f30db02ee03a64b3c638586fba6a6dd56830b30a3/markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6", size = 24374, upload-time = "2025-09-27T18:36:44.508Z" }, - { url = "https://files.pythonhosted.org/packages/a9/21/9b05698b46f218fc0e118e1f8168395c65c8a2c750ae2bab54fc4bd4e0e8/markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676", size = 22980, upload-time = "2025-09-27T18:36:45.385Z" }, - { url = "https://files.pythonhosted.org/packages/7f/71/544260864f893f18b6827315b988c146b559391e6e7e8f7252839b1b846a/markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9", size = 21990, upload-time = "2025-09-27T18:36:46.916Z" }, - { url = "https://files.pythonhosted.org/packages/c2/28/b50fc2f74d1ad761af2f5dcce7492648b983d00a65b8c0e0cb457c82ebbe/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1", size = 23784, upload-time = "2025-09-27T18:36:47.884Z" }, - { url = "https://files.pythonhosted.org/packages/ed/76/104b2aa106a208da8b17a2fb72e033a5a9d7073c68f7e508b94916ed47a9/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc", size = 21588, upload-time = "2025-09-27T18:36:48.82Z" }, - { url = "https://files.pythonhosted.org/packages/b5/99/16a5eb2d140087ebd97180d95249b00a03aa87e29cc224056274f2e45fd6/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12", size = 23041, upload-time = "2025-09-27T18:36:49.797Z" }, - { url = "https://files.pythonhosted.org/packages/19/bc/e7140ed90c5d61d77cea142eed9f9c303f4c4806f60a1044c13e3f1471d0/markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed", size = 14543, upload-time = "2025-09-27T18:36:51.584Z" }, - { url = "https://files.pythonhosted.org/packages/05/73/c4abe620b841b6b791f2edc248f556900667a5a1cf023a6646967ae98335/markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5", size = 15113, upload-time = "2025-09-27T18:36:52.537Z" }, - { url = "https://files.pythonhosted.org/packages/f0/3a/fa34a0f7cfef23cf9500d68cb7c32dd64ffd58a12b09225fb03dd37d5b80/markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485", size = 13911, upload-time = "2025-09-27T18:36:53.513Z" }, - { url = "https://files.pythonhosted.org/packages/e4/d7/e05cd7efe43a88a17a37b3ae96e79a19e846f3f456fe79c57ca61356ef01/markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73", size = 11658, upload-time = "2025-09-27T18:36:54.819Z" }, - { url = "https://files.pythonhosted.org/packages/99/9e/e412117548182ce2148bdeacdda3bb494260c0b0184360fe0d56389b523b/markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37", size = 12066, upload-time = "2025-09-27T18:36:55.714Z" }, - { url = "https://files.pythonhosted.org/packages/bc/e6/fa0ffcda717ef64a5108eaa7b4f5ed28d56122c9a6d70ab8b72f9f715c80/markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19", size = 25639, upload-time = "2025-09-27T18:36:56.908Z" }, - { url = "https://files.pythonhosted.org/packages/96/ec/2102e881fe9d25fc16cb4b25d5f5cde50970967ffa5dddafdb771237062d/markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025", size = 23569, upload-time = "2025-09-27T18:36:57.913Z" }, - { url = "https://files.pythonhosted.org/packages/4b/30/6f2fce1f1f205fc9323255b216ca8a235b15860c34b6798f810f05828e32/markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6", size = 23284, upload-time = "2025-09-27T18:36:58.833Z" }, - { url = "https://files.pythonhosted.org/packages/58/47/4a0ccea4ab9f5dcb6f79c0236d954acb382202721e704223a8aafa38b5c8/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f", size = 24801, upload-time = "2025-09-27T18:36:59.739Z" }, - { url = "https://files.pythonhosted.org/packages/6a/70/3780e9b72180b6fecb83a4814d84c3bf4b4ae4bf0b19c27196104149734c/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb", size = 22769, upload-time = "2025-09-27T18:37:00.719Z" }, - { url = "https://files.pythonhosted.org/packages/98/c5/c03c7f4125180fc215220c035beac6b9cb684bc7a067c84fc69414d315f5/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009", size = 23642, upload-time = "2025-09-27T18:37:01.673Z" }, - { url = "https://files.pythonhosted.org/packages/80/d6/2d1b89f6ca4bff1036499b1e29a1d02d282259f3681540e16563f27ebc23/markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354", size = 14612, upload-time = "2025-09-27T18:37:02.639Z" }, - { url = "https://files.pythonhosted.org/packages/2b/98/e48a4bfba0a0ffcf9925fe2d69240bfaa19c6f7507b8cd09c70684a53c1e/markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218", size = 15200, upload-time = "2025-09-27T18:37:03.582Z" }, - { url = "https://files.pythonhosted.org/packages/0e/72/e3cc540f351f316e9ed0f092757459afbc595824ca724cbc5a5d4263713f/markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287", size = 13973, upload-time = "2025-09-27T18:37:04.929Z" }, - { url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619, upload-time = "2025-09-27T18:37:06.342Z" }, - { url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029, upload-time = "2025-09-27T18:37:07.213Z" }, - { url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408, upload-time = "2025-09-27T18:37:09.572Z" }, - { url = "https://files.pythonhosted.org/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", size = 23005, upload-time = "2025-09-27T18:37:10.58Z" }, - { url = "https://files.pythonhosted.org/packages/bc/20/b7fdf89a8456b099837cd1dc21974632a02a999ec9bf7ca3e490aacd98e7/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d", size = 22048, upload-time = "2025-09-27T18:37:11.547Z" }, - { url = "https://files.pythonhosted.org/packages/9a/a7/591f592afdc734f47db08a75793a55d7fbcc6902a723ae4cfbab61010cc5/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda", size = 23821, upload-time = "2025-09-27T18:37:12.48Z" }, - { url = "https://files.pythonhosted.org/packages/7d/33/45b24e4f44195b26521bc6f1a82197118f74df348556594bd2262bda1038/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf", size = 21606, upload-time = "2025-09-27T18:37:13.485Z" }, - { url = "https://files.pythonhosted.org/packages/ff/0e/53dfaca23a69fbfbbf17a4b64072090e70717344c52eaaaa9c5ddff1e5f0/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe", size = 23043, upload-time = "2025-09-27T18:37:14.408Z" }, - { url = "https://files.pythonhosted.org/packages/46/11/f333a06fc16236d5238bfe74daccbca41459dcd8d1fa952e8fbd5dccfb70/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9", size = 14747, upload-time = "2025-09-27T18:37:15.36Z" }, - { url = "https://files.pythonhosted.org/packages/28/52/182836104b33b444e400b14f797212f720cbc9ed6ba34c800639d154e821/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", size = 15341, upload-time = "2025-09-27T18:37:16.496Z" }, - { url = "https://files.pythonhosted.org/packages/6f/18/acf23e91bd94fd7b3031558b1f013adfa21a8e407a3fdb32745538730382/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4", size = 14073, upload-time = "2025-09-27T18:37:17.476Z" }, - { url = "https://files.pythonhosted.org/packages/3c/f0/57689aa4076e1b43b15fdfa646b04653969d50cf30c32a102762be2485da/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab", size = 11661, upload-time = "2025-09-27T18:37:18.453Z" }, - { url = "https://files.pythonhosted.org/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", size = 12069, upload-time = "2025-09-27T18:37:19.332Z" }, - { url = "https://files.pythonhosted.org/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", size = 25670, upload-time = "2025-09-27T18:37:20.245Z" }, - { url = "https://files.pythonhosted.org/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50", size = 23598, upload-time = "2025-09-27T18:37:21.177Z" }, - { url = "https://files.pythonhosted.org/packages/22/44/a0681611106e0b2921b3033fc19bc53323e0b50bc70cffdd19f7d679bb66/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e", size = 23261, upload-time = "2025-09-27T18:37:22.167Z" }, - { url = "https://files.pythonhosted.org/packages/5f/57/1b0b3f100259dc9fffe780cfb60d4be71375510e435efec3d116b6436d43/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5", size = 24835, upload-time = "2025-09-27T18:37:23.296Z" }, - { url = "https://files.pythonhosted.org/packages/26/6a/4bf6d0c97c4920f1597cc14dd720705eca0bf7c787aebc6bb4d1bead5388/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523", size = 22733, upload-time = "2025-09-27T18:37:24.237Z" }, - { url = "https://files.pythonhosted.org/packages/14/c7/ca723101509b518797fedc2fdf79ba57f886b4aca8a7d31857ba3ee8281f/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc", size = 23672, upload-time = "2025-09-27T18:37:25.271Z" }, - { url = "https://files.pythonhosted.org/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819, upload-time = "2025-09-27T18:37:26.285Z" }, - { url = "https://files.pythonhosted.org/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426, upload-time = "2025-09-27T18:37:27.316Z" }, - { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" }, + { url = "https://files.pythonhosted.org/packages/38/2f/907b9c7bbba283e68f20259574b13d005c121a0fa4c175f9bed27c4597ff/markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795", size = 11622, upload-time = "2025-09-27T18:36:41.777Z" }, + { url = "https://files.pythonhosted.org/packages/9c/d9/5f7756922cdd676869eca1c4e3c0cd0df60ed30199ffd775e319089cb3ed/markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219", size = 12029, upload-time = "2025-09-27T18:36:43.257Z" }, + { url = "https://files.pythonhosted.org/packages/00/07/575a68c754943058c78f30db02ee03a64b3c638586fba6a6dd56830b30a3/markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6", size = 24374, upload-time = "2025-09-27T18:36:44.508Z" }, + { url = "https://files.pythonhosted.org/packages/a9/21/9b05698b46f218fc0e118e1f8168395c65c8a2c750ae2bab54fc4bd4e0e8/markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676", size = 22980, upload-time = "2025-09-27T18:36:45.385Z" }, + { url = "https://files.pythonhosted.org/packages/7f/71/544260864f893f18b6827315b988c146b559391e6e7e8f7252839b1b846a/markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9", size = 21990, upload-time = "2025-09-27T18:36:46.916Z" }, + { url = "https://files.pythonhosted.org/packages/c2/28/b50fc2f74d1ad761af2f5dcce7492648b983d00a65b8c0e0cb457c82ebbe/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1", size = 23784, upload-time = "2025-09-27T18:36:47.884Z" }, + { url = "https://files.pythonhosted.org/packages/ed/76/104b2aa106a208da8b17a2fb72e033a5a9d7073c68f7e508b94916ed47a9/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc", size = 21588, upload-time = "2025-09-27T18:36:48.82Z" }, + { url = "https://files.pythonhosted.org/packages/b5/99/16a5eb2d140087ebd97180d95249b00a03aa87e29cc224056274f2e45fd6/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12", size = 23041, upload-time = "2025-09-27T18:36:49.797Z" }, + { url = "https://files.pythonhosted.org/packages/19/bc/e7140ed90c5d61d77cea142eed9f9c303f4c4806f60a1044c13e3f1471d0/markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed", size = 14543, upload-time = "2025-09-27T18:36:51.584Z" }, + { url = "https://files.pythonhosted.org/packages/05/73/c4abe620b841b6b791f2edc248f556900667a5a1cf023a6646967ae98335/markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5", size = 15113, upload-time = "2025-09-27T18:36:52.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/3a/fa34a0f7cfef23cf9500d68cb7c32dd64ffd58a12b09225fb03dd37d5b80/markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485", size = 13911, upload-time = "2025-09-27T18:36:53.513Z" }, + { url = "https://files.pythonhosted.org/packages/e4/d7/e05cd7efe43a88a17a37b3ae96e79a19e846f3f456fe79c57ca61356ef01/markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73", size = 11658, upload-time = "2025-09-27T18:36:54.819Z" }, + { url = "https://files.pythonhosted.org/packages/99/9e/e412117548182ce2148bdeacdda3bb494260c0b0184360fe0d56389b523b/markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37", size = 12066, upload-time = "2025-09-27T18:36:55.714Z" }, + { url = "https://files.pythonhosted.org/packages/bc/e6/fa0ffcda717ef64a5108eaa7b4f5ed28d56122c9a6d70ab8b72f9f715c80/markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19", size = 25639, upload-time = "2025-09-27T18:36:56.908Z" }, + { url = "https://files.pythonhosted.org/packages/96/ec/2102e881fe9d25fc16cb4b25d5f5cde50970967ffa5dddafdb771237062d/markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025", size = 23569, upload-time = "2025-09-27T18:36:57.913Z" }, + { url = "https://files.pythonhosted.org/packages/4b/30/6f2fce1f1f205fc9323255b216ca8a235b15860c34b6798f810f05828e32/markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6", size = 23284, upload-time = "2025-09-27T18:36:58.833Z" }, + { url = "https://files.pythonhosted.org/packages/58/47/4a0ccea4ab9f5dcb6f79c0236d954acb382202721e704223a8aafa38b5c8/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f", size = 24801, upload-time = "2025-09-27T18:36:59.739Z" }, + { url = "https://files.pythonhosted.org/packages/6a/70/3780e9b72180b6fecb83a4814d84c3bf4b4ae4bf0b19c27196104149734c/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb", size = 22769, upload-time = "2025-09-27T18:37:00.719Z" }, + { url = "https://files.pythonhosted.org/packages/98/c5/c03c7f4125180fc215220c035beac6b9cb684bc7a067c84fc69414d315f5/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009", size = 23642, upload-time = "2025-09-27T18:37:01.673Z" }, + { url = "https://files.pythonhosted.org/packages/80/d6/2d1b89f6ca4bff1036499b1e29a1d02d282259f3681540e16563f27ebc23/markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354", size = 14612, upload-time = "2025-09-27T18:37:02.639Z" }, + { url = "https://files.pythonhosted.org/packages/2b/98/e48a4bfba0a0ffcf9925fe2d69240bfaa19c6f7507b8cd09c70684a53c1e/markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218", size = 15200, upload-time = "2025-09-27T18:37:03.582Z" }, + { url = "https://files.pythonhosted.org/packages/0e/72/e3cc540f351f316e9ed0f092757459afbc595824ca724cbc5a5d4263713f/markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287", size = 13973, upload-time = "2025-09-27T18:37:04.929Z" }, + { url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619, upload-time = "2025-09-27T18:37:06.342Z" }, + { url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029, upload-time = "2025-09-27T18:37:07.213Z" }, + { url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408, upload-time = "2025-09-27T18:37:09.572Z" }, + { url = "https://files.pythonhosted.org/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", size = 23005, upload-time = "2025-09-27T18:37:10.58Z" }, + { url = "https://files.pythonhosted.org/packages/bc/20/b7fdf89a8456b099837cd1dc21974632a02a999ec9bf7ca3e490aacd98e7/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d", size = 22048, upload-time = "2025-09-27T18:37:11.547Z" }, + { url = "https://files.pythonhosted.org/packages/9a/a7/591f592afdc734f47db08a75793a55d7fbcc6902a723ae4cfbab61010cc5/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda", size = 23821, upload-time = "2025-09-27T18:37:12.48Z" }, + { url = "https://files.pythonhosted.org/packages/7d/33/45b24e4f44195b26521bc6f1a82197118f74df348556594bd2262bda1038/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf", size = 21606, upload-time = "2025-09-27T18:37:13.485Z" }, + { url = "https://files.pythonhosted.org/packages/ff/0e/53dfaca23a69fbfbbf17a4b64072090e70717344c52eaaaa9c5ddff1e5f0/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe", size = 23043, upload-time = "2025-09-27T18:37:14.408Z" }, + { url = "https://files.pythonhosted.org/packages/46/11/f333a06fc16236d5238bfe74daccbca41459dcd8d1fa952e8fbd5dccfb70/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9", size = 14747, upload-time = "2025-09-27T18:37:15.36Z" }, + { url = "https://files.pythonhosted.org/packages/28/52/182836104b33b444e400b14f797212f720cbc9ed6ba34c800639d154e821/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", size = 15341, upload-time = "2025-09-27T18:37:16.496Z" }, + { url = "https://files.pythonhosted.org/packages/6f/18/acf23e91bd94fd7b3031558b1f013adfa21a8e407a3fdb32745538730382/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4", size = 14073, upload-time = "2025-09-27T18:37:17.476Z" }, + { url = "https://files.pythonhosted.org/packages/3c/f0/57689aa4076e1b43b15fdfa646b04653969d50cf30c32a102762be2485da/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab", size = 11661, upload-time = "2025-09-27T18:37:18.453Z" }, + { url = "https://files.pythonhosted.org/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", size = 12069, upload-time = "2025-09-27T18:37:19.332Z" }, + { url = "https://files.pythonhosted.org/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", size = 25670, upload-time = "2025-09-27T18:37:20.245Z" }, + { url = "https://files.pythonhosted.org/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50", size = 23598, upload-time = "2025-09-27T18:37:21.177Z" }, + { url = "https://files.pythonhosted.org/packages/22/44/a0681611106e0b2921b3033fc19bc53323e0b50bc70cffdd19f7d679bb66/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e", size = 23261, upload-time = "2025-09-27T18:37:22.167Z" }, + { url = "https://files.pythonhosted.org/packages/5f/57/1b0b3f100259dc9fffe780cfb60d4be71375510e435efec3d116b6436d43/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5", size = 24835, upload-time = "2025-09-27T18:37:23.296Z" }, + { url = "https://files.pythonhosted.org/packages/26/6a/4bf6d0c97c4920f1597cc14dd720705eca0bf7c787aebc6bb4d1bead5388/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523", size = 22733, upload-time = "2025-09-27T18:37:24.237Z" }, + { url = "https://files.pythonhosted.org/packages/14/c7/ca723101509b518797fedc2fdf79ba57f886b4aca8a7d31857ba3ee8281f/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc", size = 23672, upload-time = "2025-09-27T18:37:25.271Z" }, + { url = "https://files.pythonhosted.org/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819, upload-time = "2025-09-27T18:37:26.285Z" }, + { url = "https://files.pythonhosted.org/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426, upload-time = "2025-09-27T18:37:27.316Z" }, + { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" }, ] [[package]] @@ -668,7 +669,7 @@ version = "0.1.2" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, + { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, ] [[package]] @@ -677,7 +678,7 @@ version = "1.3.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/e0/47/dd32fa426cc72114383ac549964eecb20ecfd886d1e5ccf5340b55b02f57/mpmath-1.3.0.tar.gz", hash = "sha256:7a28eb2a9774d00c7bc92411c19a89209d5da7c4c9a9e227be8330a23a25b91f", size = 508106, upload-time = "2023-03-07T16:47:11.061Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/43/e3/7d92a15f894aa0c9c4b49b8ee9ac9850d6e63b03c9c32c0367a13ae62209/mpmath-1.3.0-py3-none-any.whl", hash = "sha256:a0b2b9fe80bbcd81a6647ff13108738cfb482d481d826cc0e02f5b35e5c88d2c", size = 536198, upload-time = "2023-03-07T16:47:09.197Z" }, + { url = "https://files.pythonhosted.org/packages/43/e3/7d92a15f894aa0c9c4b49b8ee9ac9850d6e63b03c9c32c0367a13ae62209/mpmath-1.3.0-py3-none-any.whl", hash = "sha256:a0b2b9fe80bbcd81a6647ff13108738cfb482d481d826cc0e02f5b35e5c88d2c", size = 536198, upload-time = "2023-03-07T16:47:09.197Z" }, ] [[package]] @@ -686,7 +687,7 @@ version = "3.6.1" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/6a/51/63fe664f3908c97be9d2e4f1158eb633317598cfa6e1fc14af5383f17512/networkx-3.6.1.tar.gz", hash = "sha256:26b7c357accc0c8cde558ad486283728b65b6a95d85ee1cd66bafab4c8168509", size = 2517025, upload-time = "2025-12-08T17:02:39.908Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/9e/c9/b2622292ea83fbb4ec318f5b9ab867d0a28ab43c5717bb85b0a5f6b3b0a4/networkx-3.6.1-py3-none-any.whl", hash = "sha256:d47fbf302e7d9cbbb9e2555a0d267983d2aa476bac30e90dfbe5669bd57f3762", size = 2068504, upload-time = "2025-12-08T17:02:38.159Z" }, + { url = "https://files.pythonhosted.org/packages/9e/c9/b2622292ea83fbb4ec318f5b9ab867d0a28ab43c5717bb85b0a5f6b3b0a4/networkx-3.6.1-py3-none-any.whl", hash = "sha256:d47fbf302e7d9cbbb9e2555a0d267983d2aa476bac30e90dfbe5669bd57f3762", size = 2068504, upload-time = "2025-12-08T17:02:38.159Z" }, ] [[package]] @@ -695,7 +696,7 @@ version = "1.10.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/24/bf/d1bda4f6168e0b2e9e5958945e01910052158313224ada5ce1fb2e1113b8/nodeenv-1.10.0.tar.gz", hash = "sha256:996c191ad80897d076bdfba80a41994c2b47c68e224c542b48feba42ba00f8bb", size = 55611, upload-time = "2025-12-20T14:08:54.006Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/88/b2/d0896bdcdc8d28a7fc5717c305f1a861c26e18c05047949fb371034d98bd/nodeenv-1.10.0-py2.py3-none-any.whl", hash = "sha256:5bb13e3eed2923615535339b3c620e76779af4cb4c6a90deccc9e36b274d3827", size = 23438, upload-time = "2025-12-20T14:08:52.782Z" }, + { url = "https://files.pythonhosted.org/packages/88/b2/d0896bdcdc8d28a7fc5717c305f1a861c26e18c05047949fb371034d98bd/nodeenv-1.10.0-py2.py3-none-any.whl", hash = "sha256:5bb13e3eed2923615535339b3c620e76779af4cb4c6a90deccc9e36b274d3827", size = 23438, upload-time = "2025-12-20T14:08:52.782Z" }, ] [[package]] @@ -704,48 +705,48 @@ version = "2.4.6" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/d0/ad/fed0499ce6a338d2a03ebae59cd15093910c8875328855781952abf6c2fe/numpy-2.4.6.tar.gz", hash = "sha256:f3a3570c4a2a16746ac2c31a7c7c7b0c186b95ce902e33db6f28094ed7387dda", size = 20735807, upload-time = "2026-05-18T23:37:14.07Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/fb/82/bdab26d7438c6791ca31b7c024ca37c1eab8b726ba236129005cd4a06e45/numpy-2.4.6-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:511dbaf848decaaaf4b4ca48032619fb3138710c4bf7da7617765edad1ef96b0", size = 16684648, upload-time = "2026-05-18T23:34:29.41Z" }, - { url = "https://files.pythonhosted.org/packages/1b/30/a80189bcc7f5e4258b3fbc3968d909d1756f54d023299ecc39ad6fdb9ef8/numpy-2.4.6-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:bf162abab1c1a736333192707cef898e735a5ca00f38f27eeedf44b39d9e85eb", size = 14693902, upload-time = "2026-05-18T23:34:33.013Z" }, - { url = "https://files.pythonhosted.org/packages/97/12/70b5d0d7c15e1ebb8a6a84a8caa1d19e181d84fb58bb6d70aca29099dec1/numpy-2.4.6-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:043191bfa8eab18c776647b62723ac9dddece59743b13f49b2016094129c2b3f", size = 5198992, upload-time = "2026-05-18T23:34:36.132Z" }, - { url = "https://files.pythonhosted.org/packages/ba/8c/ebd2a8f8a83541f8d38cc5667e8c2b69cecfd30da6e45693e8158857d44b/numpy-2.4.6-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:6180d8b35af935aed8ece3a85e0a43f87393ae0ac87c8d2c8bd2c993f7270ef3", size = 6546944, upload-time = "2026-05-18T23:34:38.484Z" }, - { url = "https://files.pythonhosted.org/packages/bb/c5/7b863a97a91671a0338f4253bd3b5a3d3852f0692dae91711c9f4a10e787/numpy-2.4.6-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:72fbe16c6fac95aedf5937fa873445cec2110be35d8a4e9433d7501fd98dae6b", size = 15669392, upload-time = "2026-05-18T23:34:41.257Z" }, - { url = "https://files.pythonhosted.org/packages/a5/9d/3584b9984ca4c047aea75214ce1a4c4c73d849bd71b604264b7f5653f8a8/numpy-2.4.6-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a7830bab239b79cda9c08c2da014761cafb48da6150e1da17ac06283f43b6089", size = 16633220, upload-time = "2026-05-18T23:34:45.075Z" }, - { url = "https://files.pythonhosted.org/packages/05/ae/7c67fba23bd98caec7c99261f3a16072ade14813486b0282cb29846de832/numpy-2.4.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ef4aea96ce4d3b074422cb4f2f64e216bf9e213004bb58ecfdf50ea02ea8eb9a", size = 17020800, upload-time = "2026-05-18T23:34:49.065Z" }, - { url = "https://files.pythonhosted.org/packages/d9/5d/3b6725cb31d983c5e66916f5d36f6d7e5521129e4c4404d64f918292a5b6/numpy-2.4.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:dfa20cc6ca228e6b155b11da03825975ce66aea520985dbbddf0f2a5a495c605", size = 18357600, upload-time = "2026-05-18T23:34:52.709Z" }, - { url = "https://files.pythonhosted.org/packages/f7/da/2ccc6c2fe8898dee01d90c75c5f5f914a23daf99e3e0f59516a08760c8b5/numpy-2.4.6-cp313-cp313-win32.whl", hash = "sha256:56b39e5e0622a09a25bf5baf62f4bcf0cb8a41ae6e2819cf49bbc5a74c083f91", size = 5961134, upload-time = "2026-05-18T23:34:55.618Z" }, - { url = "https://files.pythonhosted.org/packages/b5/cd/9cc4dc876fb065d5c220aae4d5e14826b2715331bb7618ce1fb07a679d99/numpy-2.4.6-cp313-cp313-win_amd64.whl", hash = "sha256:c4fc99836233ea196540b17ab0983aff60ed07941751930f5f4d05bc3b3b7359", size = 12318598, upload-time = "2026-05-18T23:34:58.928Z" }, - { url = "https://files.pythonhosted.org/packages/39/1e/c0bcba1f8694116485fe28fd1be698c278fcda4141c5b0e53a2aed8b12a8/numpy-2.4.6-cp313-cp313-win_arm64.whl", hash = "sha256:a7c711e21628b52034bb5ab8d1bce291f752fcc5e92accc615778acee1ff4778", size = 10222272, upload-time = "2026-05-18T23:35:02.167Z" }, - { url = "https://files.pythonhosted.org/packages/63/6d/cc5619247c8f4204e507f5883528372e4ac4bb189e579fb859a12e480b1f/numpy-2.4.6-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:112b06a867b235ef466ed3508ddf0238050df9c727cafb5301ac385b899189a1", size = 14821197, upload-time = "2026-05-18T23:35:05.468Z" }, - { url = "https://files.pythonhosted.org/packages/00/58/f1c39161c87d9e9bed660f1ed4bafc0e403d5ec9650b6dd77aead07d489b/numpy-2.4.6-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:eaf7fa2de5c0be8ae6ff8e9bea2ccd725e980541244521d8d4b5f3354a27babe", size = 5326287, upload-time = "2026-05-18T23:35:08.693Z" }, - { url = "https://files.pythonhosted.org/packages/af/57/3917ab0fd97f271a8694513581b8a36c655f111c446852c302f04ccdb6fc/numpy-2.4.6-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:7265a2f3d436e54ef9f2b52b5c937e6be778781bd97a590319d7348f1c1ca997", size = 6646763, upload-time = "2026-05-18T23:35:11.459Z" }, - { url = "https://files.pythonhosted.org/packages/eb/0f/037e64c494b67581ae18193d770adef354c41f3f2c8ebf865602d949bf8f/numpy-2.4.6-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f74a575920ab21fe304421a3fc28793d82e299cae9eccb37084e9fc7f3617c20", size = 15728070, upload-time = "2026-05-18T23:35:14.79Z" }, - { url = "https://files.pythonhosted.org/packages/21/a6/5d2bae9c9542eb4df16dc9c46dc79c186e9bad53805dfa5399a6023c6db0/numpy-2.4.6-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ede83e07a75dd06bc501566c1eca2afc0d61677c1472ac9ad93fdee6e638a48d", size = 16681752, upload-time = "2026-05-18T23:35:18.836Z" }, - { url = "https://files.pythonhosted.org/packages/92/14/23d1dfb410ae362cd59ce53e936b1513d545eb40db3949ced632e19a459e/numpy-2.4.6-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:68bb27509ac1b9a3443094260f6326150663b06abe40b73a2f81160623da5b67", size = 17086024, upload-time = "2026-05-18T23:35:22.52Z" }, - { url = "https://files.pythonhosted.org/packages/4b/6e/23595a2c642cdf3bc567877064bdd7f91c8b0038a4453cf2daf7248eafe9/numpy-2.4.6-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:a0df0043bdb289bde1f62da130d20df23d58b45429f752bc7a8fc5325a225ecd", size = 18403398, upload-time = "2026-05-18T23:35:26.398Z" }, - { url = "https://files.pythonhosted.org/packages/8a/90/0ac3bc947217e66dec77e7cbc6a1979d1af70b6461b82f620d3bccd5e4c8/numpy-2.4.6-cp313-cp313t-win32.whl", hash = "sha256:29a287e0cf63ff528da061de6b9f64a4618da591ca1046aafc54062e40ca7eab", size = 6084971, upload-time = "2026-05-18T23:35:29.387Z" }, - { url = "https://files.pythonhosted.org/packages/77/71/5673e351671a1d2bd6063b91b44f70c0affea7d1516fa7a6572941ba4aa1/numpy-2.4.6-cp313-cp313t-win_amd64.whl", hash = "sha256:25c692919ac5a01f170a3bfcd62d745b24fd095c353d50812637d6fcab442e75", size = 12458532, upload-time = "2026-05-18T23:35:32.175Z" }, - { url = "https://files.pythonhosted.org/packages/3f/88/19d3503c5046e688f049274b27a3ef3d771152fa80d3ba3d01a3dff61abe/numpy-2.4.6-cp313-cp313t-win_arm64.whl", hash = "sha256:1e978ec1e8bd0e0e4de6bb75de9d30cbb74db6b6a2bb727618613703ca0167dd", size = 10291881, upload-time = "2026-05-18T23:35:35.465Z" }, - { url = "https://files.pythonhosted.org/packages/f8/91/3ab2044d05fd16d343c5ac2e69b127f1b2854040dd20b193257c78028bd3/numpy-2.4.6-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:06ca2f61ec4385a07a6977c55ba998a4466c123642b4a32694d3128fce18c079", size = 16683458, upload-time = "2026-05-18T23:35:38.353Z" }, - { url = "https://files.pythonhosted.org/packages/8e/62/764ce66fa4147ae6d73071a3abf804ffe606f174618697c571acdf26a7c9/numpy-2.4.6-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:38efbc8de75c7a0fc1ac190162d892787f3f47b57cc291231aafee36b80982b7", size = 14704559, upload-time = "2026-05-18T23:35:42.14Z" }, - { url = "https://files.pythonhosted.org/packages/60/61/23f27c172f022e04025b7dc2367f4d63c1a398120607ec896228649a6f48/numpy-2.4.6-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:d581b735e177fdcdce6fed8e7e8880a3fb6ee4e3653a3ac6af01c6f4c03effc5", size = 5209716, upload-time = "2026-05-18T23:35:45.377Z" }, - { url = "https://files.pythonhosted.org/packages/03/71/21cf70dc6ea3e3acb95fc53a265b2fc248b981f0194ceb5b475271b8809d/numpy-2.4.6-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:0a041d3d761dc3c35cc56ce0351506a02bcbc25f7b169f652435141a17db9096", size = 6543947, upload-time = "2026-05-18T23:35:47.926Z" }, - { url = "https://files.pythonhosted.org/packages/d5/91/64288395ee1799bd2e0b04a305dce9666da90c961e1f3fe982a05ee1c036/numpy-2.4.6-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:40fdc1ae7125e518ea98e53e69a4ebc27e1fd50510c47b7ea130cf21e5e1d42b", size = 15685197, upload-time = "2026-05-18T23:35:50.863Z" }, - { url = "https://files.pythonhosted.org/packages/f3/eb/ebffaa97dc55502df69584a8f0dcf07f69a3e0b3e2323670a2722db9aa39/numpy-2.4.6-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a2c306dea656c12c68f51f4cea133cbe78ca7435eb28c735eac1d3ebe73be6e8", size = 16638245, upload-time = "2026-05-18T23:35:54.752Z" }, - { url = "https://files.pythonhosted.org/packages/b8/0b/54f9da33128d7e350fab89c7455902eeae70349ee52bddb448dc4a576f45/numpy-2.4.6-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:33111801a01c12a8a1e3721f0a9232f8cfc8ae2c6b7098167e6f623c6073f402", size = 17036587, upload-time = "2026-05-18T23:35:58.355Z" }, - { url = "https://files.pythonhosted.org/packages/b6/f0/fdebc1052db1cc37c64beb22072d67cd6d1c71adca1299f53dec2b5e20d3/numpy-2.4.6-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ae506e6902902557576a26ff33eda8695e7ecb3cb36c3b573a0765dee114ebdb", size = 18363226, upload-time = "2026-05-18T23:36:02.845Z" }, - { url = "https://files.pythonhosted.org/packages/aa/b4/298628d98c72b57e57f7165ae6a481a1deaf6f3c28262a6e4c739c275930/numpy-2.4.6-cp314-cp314-win32.whl", hash = "sha256:aaf159caa35993cb1f56fb9b8e4610d35758e7ca005412eb1daa856a78c9c4b1", size = 6010196, upload-time = "2026-05-18T23:36:05.92Z" }, - { url = "https://files.pythonhosted.org/packages/df/ac/46de6dda46478f7942f839e094970be2d4a861e005c4b3bf07c92e291a09/numpy-2.4.6-cp314-cp314-win_amd64.whl", hash = "sha256:b507f5c4c1d508876d1819b6bf9a49d365b96320b5d4993426b33a23ca4b8261", size = 12450334, upload-time = "2026-05-18T23:36:09.107Z" }, - { url = "https://files.pythonhosted.org/packages/78/92/b8b798ac784102c0da830d2257d59358e3d3d90d1e2b3f2575dad976c5cf/numpy-2.4.6-cp314-cp314-win_arm64.whl", hash = "sha256:6f41ae150c4e32db4f3310cdaf64b1593a03dbabe29eec77fc9b50fe64061df6", size = 10495678, upload-time = "2026-05-18T23:36:12.766Z" }, - { url = "https://files.pythonhosted.org/packages/30/34/ec28d1aa8115971537c01469ab2011ee96827930f0a124de1000cc2a7ed7/numpy-2.4.6-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ece3d2cfe132e7d51f44a832b303895e6f2d499c5e74dfbdb06ee246147a304a", size = 14823672, upload-time = "2026-05-18T23:36:16.473Z" }, - { url = "https://files.pythonhosted.org/packages/16/bd/f6d1fede4e54e8042a7ff97bb495510f3c220f94bcd9e8b228e87c92cc0d/numpy-2.4.6-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:e3e5193ef5a3dc73bceee50f7fdc2c90dbb76c42df8d8fae3d1067a583df579e", size = 5328731, upload-time = "2026-05-18T23:36:19.767Z" }, - { url = "https://files.pythonhosted.org/packages/f4/f0/e105b9e2fd728a9910103884decd6951d9dd73896b914a98d9a231de02ee/numpy-2.4.6-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:17f9ade344e7d9b464a084d69bcf18fc691cb1db67c62ed80820bf4926d78f0e", size = 6649805, upload-time = "2026-05-18T23:36:22.266Z" }, - { url = "https://files.pythonhosted.org/packages/82/dd/1206a7ca6ab15e3f02069707ca96222e202af681bb73756da7527f3cb837/numpy-2.4.6-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9cd5ffd25db4e7ba6a375693b3fc0fc1791ec636c17db3720da19bde7180ec43", size = 15730496, upload-time = "2026-05-18T23:36:25.713Z" }, - { url = "https://files.pythonhosted.org/packages/51/e7/38d3ea825dcab85a591734decb2f6c67caa7c8367d374df1a1c3842f9b07/numpy-2.4.6-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7d92c3819208a60205a12a245c91ad70cb0a85336659b19b834205573ac8456e", size = 16679616, upload-time = "2026-05-18T23:36:29.652Z" }, - { url = "https://files.pythonhosted.org/packages/93/b7/caabfdf53edf663e0b4eb74d7d405d83baef09eb5e83bcd32d601d72b93e/numpy-2.4.6-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e85b752a1e912b70eaad4fafbd4d1238007ab221de2009b9a2f5ae7461239895", size = 17085145, upload-time = "2026-05-18T23:36:33.449Z" }, - { url = "https://files.pythonhosted.org/packages/f9/45/68d7c33a6bcf3e5aa3bdbd57a367e6f615286dfd6482f97e8ffeb734306e/numpy-2.4.6-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:29cb7f67d10b479ff07c17d33e39f78c07f71c40ef30d63c153d340e96cd3fb4", size = 18403813, upload-time = "2026-05-18T23:36:37.369Z" }, - { url = "https://files.pythonhosted.org/packages/9c/50/0753655aa844c99cd9e018aacf76f130f1bd81d881bb74bc0aef5d73a8ba/numpy-2.4.6-cp314-cp314t-win32.whl", hash = "sha256:260a5d70215b61ab4fadf5c7baacd64821842975eea312125ed3c39a6391b063", size = 6156982, upload-time = "2026-05-18T23:36:40.817Z" }, - { url = "https://files.pythonhosted.org/packages/b2/d4/7c67becf668f973cb490cec3e98dfd799d866f9c989a54d355672cfa0db6/numpy-2.4.6-cp314-cp314t-win_amd64.whl", hash = "sha256:81a1cca95ed5bb92aa8b10dd2cdc9a0d3853a50fad926c28b5d7e8ea54389627", size = 12638908, upload-time = "2026-05-18T23:36:43.996Z" }, - { url = "https://files.pythonhosted.org/packages/43/bb/e1c71a4295b1b1d1393d50dbb4f2a36283c6859d9d3892e84f00ec5a91d5/numpy-2.4.6-cp314-cp314t-win_arm64.whl", hash = "sha256:0c9136e14ed34a9e343a31c533d78a9813a69a3148332bce5e9821cb2f996e66", size = 10565867, upload-time = "2026-05-18T23:36:47.114Z" }, + { url = "https://files.pythonhosted.org/packages/fb/82/bdab26d7438c6791ca31b7c024ca37c1eab8b726ba236129005cd4a06e45/numpy-2.4.6-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:511dbaf848decaaaf4b4ca48032619fb3138710c4bf7da7617765edad1ef96b0", size = 16684648, upload-time = "2026-05-18T23:34:29.41Z" }, + { url = "https://files.pythonhosted.org/packages/1b/30/a80189bcc7f5e4258b3fbc3968d909d1756f54d023299ecc39ad6fdb9ef8/numpy-2.4.6-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:bf162abab1c1a736333192707cef898e735a5ca00f38f27eeedf44b39d9e85eb", size = 14693902, upload-time = "2026-05-18T23:34:33.013Z" }, + { url = "https://files.pythonhosted.org/packages/97/12/70b5d0d7c15e1ebb8a6a84a8caa1d19e181d84fb58bb6d70aca29099dec1/numpy-2.4.6-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:043191bfa8eab18c776647b62723ac9dddece59743b13f49b2016094129c2b3f", size = 5198992, upload-time = "2026-05-18T23:34:36.132Z" }, + { url = "https://files.pythonhosted.org/packages/ba/8c/ebd2a8f8a83541f8d38cc5667e8c2b69cecfd30da6e45693e8158857d44b/numpy-2.4.6-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:6180d8b35af935aed8ece3a85e0a43f87393ae0ac87c8d2c8bd2c993f7270ef3", size = 6546944, upload-time = "2026-05-18T23:34:38.484Z" }, + { url = "https://files.pythonhosted.org/packages/bb/c5/7b863a97a91671a0338f4253bd3b5a3d3852f0692dae91711c9f4a10e787/numpy-2.4.6-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:72fbe16c6fac95aedf5937fa873445cec2110be35d8a4e9433d7501fd98dae6b", size = 15669392, upload-time = "2026-05-18T23:34:41.257Z" }, + { url = "https://files.pythonhosted.org/packages/a5/9d/3584b9984ca4c047aea75214ce1a4c4c73d849bd71b604264b7f5653f8a8/numpy-2.4.6-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a7830bab239b79cda9c08c2da014761cafb48da6150e1da17ac06283f43b6089", size = 16633220, upload-time = "2026-05-18T23:34:45.075Z" }, + { url = "https://files.pythonhosted.org/packages/05/ae/7c67fba23bd98caec7c99261f3a16072ade14813486b0282cb29846de832/numpy-2.4.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ef4aea96ce4d3b074422cb4f2f64e216bf9e213004bb58ecfdf50ea02ea8eb9a", size = 17020800, upload-time = "2026-05-18T23:34:49.065Z" }, + { url = "https://files.pythonhosted.org/packages/d9/5d/3b6725cb31d983c5e66916f5d36f6d7e5521129e4c4404d64f918292a5b6/numpy-2.4.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:dfa20cc6ca228e6b155b11da03825975ce66aea520985dbbddf0f2a5a495c605", size = 18357600, upload-time = "2026-05-18T23:34:52.709Z" }, + { url = "https://files.pythonhosted.org/packages/f7/da/2ccc6c2fe8898dee01d90c75c5f5f914a23daf99e3e0f59516a08760c8b5/numpy-2.4.6-cp313-cp313-win32.whl", hash = "sha256:56b39e5e0622a09a25bf5baf62f4bcf0cb8a41ae6e2819cf49bbc5a74c083f91", size = 5961134, upload-time = "2026-05-18T23:34:55.618Z" }, + { url = "https://files.pythonhosted.org/packages/b5/cd/9cc4dc876fb065d5c220aae4d5e14826b2715331bb7618ce1fb07a679d99/numpy-2.4.6-cp313-cp313-win_amd64.whl", hash = "sha256:c4fc99836233ea196540b17ab0983aff60ed07941751930f5f4d05bc3b3b7359", size = 12318598, upload-time = "2026-05-18T23:34:58.928Z" }, + { url = "https://files.pythonhosted.org/packages/39/1e/c0bcba1f8694116485fe28fd1be698c278fcda4141c5b0e53a2aed8b12a8/numpy-2.4.6-cp313-cp313-win_arm64.whl", hash = "sha256:a7c711e21628b52034bb5ab8d1bce291f752fcc5e92accc615778acee1ff4778", size = 10222272, upload-time = "2026-05-18T23:35:02.167Z" }, + { url = "https://files.pythonhosted.org/packages/63/6d/cc5619247c8f4204e507f5883528372e4ac4bb189e579fb859a12e480b1f/numpy-2.4.6-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:112b06a867b235ef466ed3508ddf0238050df9c727cafb5301ac385b899189a1", size = 14821197, upload-time = "2026-05-18T23:35:05.468Z" }, + { url = "https://files.pythonhosted.org/packages/00/58/f1c39161c87d9e9bed660f1ed4bafc0e403d5ec9650b6dd77aead07d489b/numpy-2.4.6-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:eaf7fa2de5c0be8ae6ff8e9bea2ccd725e980541244521d8d4b5f3354a27babe", size = 5326287, upload-time = "2026-05-18T23:35:08.693Z" }, + { url = "https://files.pythonhosted.org/packages/af/57/3917ab0fd97f271a8694513581b8a36c655f111c446852c302f04ccdb6fc/numpy-2.4.6-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:7265a2f3d436e54ef9f2b52b5c937e6be778781bd97a590319d7348f1c1ca997", size = 6646763, upload-time = "2026-05-18T23:35:11.459Z" }, + { url = "https://files.pythonhosted.org/packages/eb/0f/037e64c494b67581ae18193d770adef354c41f3f2c8ebf865602d949bf8f/numpy-2.4.6-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f74a575920ab21fe304421a3fc28793d82e299cae9eccb37084e9fc7f3617c20", size = 15728070, upload-time = "2026-05-18T23:35:14.79Z" }, + { url = "https://files.pythonhosted.org/packages/21/a6/5d2bae9c9542eb4df16dc9c46dc79c186e9bad53805dfa5399a6023c6db0/numpy-2.4.6-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ede83e07a75dd06bc501566c1eca2afc0d61677c1472ac9ad93fdee6e638a48d", size = 16681752, upload-time = "2026-05-18T23:35:18.836Z" }, + { url = "https://files.pythonhosted.org/packages/92/14/23d1dfb410ae362cd59ce53e936b1513d545eb40db3949ced632e19a459e/numpy-2.4.6-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:68bb27509ac1b9a3443094260f6326150663b06abe40b73a2f81160623da5b67", size = 17086024, upload-time = "2026-05-18T23:35:22.52Z" }, + { url = "https://files.pythonhosted.org/packages/4b/6e/23595a2c642cdf3bc567877064bdd7f91c8b0038a4453cf2daf7248eafe9/numpy-2.4.6-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:a0df0043bdb289bde1f62da130d20df23d58b45429f752bc7a8fc5325a225ecd", size = 18403398, upload-time = "2026-05-18T23:35:26.398Z" }, + { url = "https://files.pythonhosted.org/packages/8a/90/0ac3bc947217e66dec77e7cbc6a1979d1af70b6461b82f620d3bccd5e4c8/numpy-2.4.6-cp313-cp313t-win32.whl", hash = "sha256:29a287e0cf63ff528da061de6b9f64a4618da591ca1046aafc54062e40ca7eab", size = 6084971, upload-time = "2026-05-18T23:35:29.387Z" }, + { url = "https://files.pythonhosted.org/packages/77/71/5673e351671a1d2bd6063b91b44f70c0affea7d1516fa7a6572941ba4aa1/numpy-2.4.6-cp313-cp313t-win_amd64.whl", hash = "sha256:25c692919ac5a01f170a3bfcd62d745b24fd095c353d50812637d6fcab442e75", size = 12458532, upload-time = "2026-05-18T23:35:32.175Z" }, + { url = "https://files.pythonhosted.org/packages/3f/88/19d3503c5046e688f049274b27a3ef3d771152fa80d3ba3d01a3dff61abe/numpy-2.4.6-cp313-cp313t-win_arm64.whl", hash = "sha256:1e978ec1e8bd0e0e4de6bb75de9d30cbb74db6b6a2bb727618613703ca0167dd", size = 10291881, upload-time = "2026-05-18T23:35:35.465Z" }, + { url = "https://files.pythonhosted.org/packages/f8/91/3ab2044d05fd16d343c5ac2e69b127f1b2854040dd20b193257c78028bd3/numpy-2.4.6-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:06ca2f61ec4385a07a6977c55ba998a4466c123642b4a32694d3128fce18c079", size = 16683458, upload-time = "2026-05-18T23:35:38.353Z" }, + { url = "https://files.pythonhosted.org/packages/8e/62/764ce66fa4147ae6d73071a3abf804ffe606f174618697c571acdf26a7c9/numpy-2.4.6-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:38efbc8de75c7a0fc1ac190162d892787f3f47b57cc291231aafee36b80982b7", size = 14704559, upload-time = "2026-05-18T23:35:42.14Z" }, + { url = "https://files.pythonhosted.org/packages/60/61/23f27c172f022e04025b7dc2367f4d63c1a398120607ec896228649a6f48/numpy-2.4.6-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:d581b735e177fdcdce6fed8e7e8880a3fb6ee4e3653a3ac6af01c6f4c03effc5", size = 5209716, upload-time = "2026-05-18T23:35:45.377Z" }, + { url = "https://files.pythonhosted.org/packages/03/71/21cf70dc6ea3e3acb95fc53a265b2fc248b981f0194ceb5b475271b8809d/numpy-2.4.6-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:0a041d3d761dc3c35cc56ce0351506a02bcbc25f7b169f652435141a17db9096", size = 6543947, upload-time = "2026-05-18T23:35:47.926Z" }, + { url = "https://files.pythonhosted.org/packages/d5/91/64288395ee1799bd2e0b04a305dce9666da90c961e1f3fe982a05ee1c036/numpy-2.4.6-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:40fdc1ae7125e518ea98e53e69a4ebc27e1fd50510c47b7ea130cf21e5e1d42b", size = 15685197, upload-time = "2026-05-18T23:35:50.863Z" }, + { url = "https://files.pythonhosted.org/packages/f3/eb/ebffaa97dc55502df69584a8f0dcf07f69a3e0b3e2323670a2722db9aa39/numpy-2.4.6-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a2c306dea656c12c68f51f4cea133cbe78ca7435eb28c735eac1d3ebe73be6e8", size = 16638245, upload-time = "2026-05-18T23:35:54.752Z" }, + { url = "https://files.pythonhosted.org/packages/b8/0b/54f9da33128d7e350fab89c7455902eeae70349ee52bddb448dc4a576f45/numpy-2.4.6-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:33111801a01c12a8a1e3721f0a9232f8cfc8ae2c6b7098167e6f623c6073f402", size = 17036587, upload-time = "2026-05-18T23:35:58.355Z" }, + { url = "https://files.pythonhosted.org/packages/b6/f0/fdebc1052db1cc37c64beb22072d67cd6d1c71adca1299f53dec2b5e20d3/numpy-2.4.6-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ae506e6902902557576a26ff33eda8695e7ecb3cb36c3b573a0765dee114ebdb", size = 18363226, upload-time = "2026-05-18T23:36:02.845Z" }, + { url = "https://files.pythonhosted.org/packages/aa/b4/298628d98c72b57e57f7165ae6a481a1deaf6f3c28262a6e4c739c275930/numpy-2.4.6-cp314-cp314-win32.whl", hash = "sha256:aaf159caa35993cb1f56fb9b8e4610d35758e7ca005412eb1daa856a78c9c4b1", size = 6010196, upload-time = "2026-05-18T23:36:05.92Z" }, + { url = "https://files.pythonhosted.org/packages/df/ac/46de6dda46478f7942f839e094970be2d4a861e005c4b3bf07c92e291a09/numpy-2.4.6-cp314-cp314-win_amd64.whl", hash = "sha256:b507f5c4c1d508876d1819b6bf9a49d365b96320b5d4993426b33a23ca4b8261", size = 12450334, upload-time = "2026-05-18T23:36:09.107Z" }, + { url = "https://files.pythonhosted.org/packages/78/92/b8b798ac784102c0da830d2257d59358e3d3d90d1e2b3f2575dad976c5cf/numpy-2.4.6-cp314-cp314-win_arm64.whl", hash = "sha256:6f41ae150c4e32db4f3310cdaf64b1593a03dbabe29eec77fc9b50fe64061df6", size = 10495678, upload-time = "2026-05-18T23:36:12.766Z" }, + { url = "https://files.pythonhosted.org/packages/30/34/ec28d1aa8115971537c01469ab2011ee96827930f0a124de1000cc2a7ed7/numpy-2.4.6-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ece3d2cfe132e7d51f44a832b303895e6f2d499c5e74dfbdb06ee246147a304a", size = 14823672, upload-time = "2026-05-18T23:36:16.473Z" }, + { url = "https://files.pythonhosted.org/packages/16/bd/f6d1fede4e54e8042a7ff97bb495510f3c220f94bcd9e8b228e87c92cc0d/numpy-2.4.6-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:e3e5193ef5a3dc73bceee50f7fdc2c90dbb76c42df8d8fae3d1067a583df579e", size = 5328731, upload-time = "2026-05-18T23:36:19.767Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f0/e105b9e2fd728a9910103884decd6951d9dd73896b914a98d9a231de02ee/numpy-2.4.6-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:17f9ade344e7d9b464a084d69bcf18fc691cb1db67c62ed80820bf4926d78f0e", size = 6649805, upload-time = "2026-05-18T23:36:22.266Z" }, + { url = "https://files.pythonhosted.org/packages/82/dd/1206a7ca6ab15e3f02069707ca96222e202af681bb73756da7527f3cb837/numpy-2.4.6-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9cd5ffd25db4e7ba6a375693b3fc0fc1791ec636c17db3720da19bde7180ec43", size = 15730496, upload-time = "2026-05-18T23:36:25.713Z" }, + { url = "https://files.pythonhosted.org/packages/51/e7/38d3ea825dcab85a591734decb2f6c67caa7c8367d374df1a1c3842f9b07/numpy-2.4.6-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7d92c3819208a60205a12a245c91ad70cb0a85336659b19b834205573ac8456e", size = 16679616, upload-time = "2026-05-18T23:36:29.652Z" }, + { url = "https://files.pythonhosted.org/packages/93/b7/caabfdf53edf663e0b4eb74d7d405d83baef09eb5e83bcd32d601d72b93e/numpy-2.4.6-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e85b752a1e912b70eaad4fafbd4d1238007ab221de2009b9a2f5ae7461239895", size = 17085145, upload-time = "2026-05-18T23:36:33.449Z" }, + { url = "https://files.pythonhosted.org/packages/f9/45/68d7c33a6bcf3e5aa3bdbd57a367e6f615286dfd6482f97e8ffeb734306e/numpy-2.4.6-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:29cb7f67d10b479ff07c17d33e39f78c07f71c40ef30d63c153d340e96cd3fb4", size = 18403813, upload-time = "2026-05-18T23:36:37.369Z" }, + { url = "https://files.pythonhosted.org/packages/9c/50/0753655aa844c99cd9e018aacf76f130f1bd81d881bb74bc0aef5d73a8ba/numpy-2.4.6-cp314-cp314t-win32.whl", hash = "sha256:260a5d70215b61ab4fadf5c7baacd64821842975eea312125ed3c39a6391b063", size = 6156982, upload-time = "2026-05-18T23:36:40.817Z" }, + { url = "https://files.pythonhosted.org/packages/b2/d4/7c67becf668f973cb490cec3e98dfd799d866f9c989a54d355672cfa0db6/numpy-2.4.6-cp314-cp314t-win_amd64.whl", hash = "sha256:81a1cca95ed5bb92aa8b10dd2cdc9a0d3853a50fad926c28b5d7e8ea54389627", size = 12638908, upload-time = "2026-05-18T23:36:43.996Z" }, + { url = "https://files.pythonhosted.org/packages/43/bb/e1c71a4295b1b1d1393d50dbb4f2a36283c6859d9d3892e84f00ec5a91d5/numpy-2.4.6-cp314-cp314t-win_arm64.whl", hash = "sha256:0c9136e14ed34a9e343a31c533d78a9813a69a3148332bce5e9821cb2f996e66", size = 10565867, upload-time = "2026-05-18T23:36:47.114Z" }, ] [[package]] @@ -754,36 +755,36 @@ version = "3.11.9" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/7e/0c/964746fcafbd16f8ff53219ad9f6b412b34f345c75f384ad434ceaadb538/orjson-3.11.9.tar.gz", hash = "sha256:4fef17e1f8722c11587a6ef18e35902450221da0028e65dbaaa543619e68e48f", size = 5599163, upload-time = "2026-05-06T15:11:08.309Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/32/33/93fcc25907235c344ae73122f8a4e01d2d393ef062b4af7d2e2487a32c37/orjson-3.11.9-cp313-cp313-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:4bab1b2d6141fe7b32ae71dac905666ece4f94936efbfb13d55bb7739a3a6021", size = 228458, upload-time = "2026-05-06T15:10:20.079Z" }, - { url = "https://files.pythonhosted.org/packages/8f/27/b1e6dadb3c080313c03fdd8067b85e6a0460c7d8d6a1c3984ef77b904e4d/orjson-3.11.9-cp313-cp313-macosx_15_0_arm64.whl", hash = "sha256:844417969855fc7a41be124aafe83dc424592a7f77cd4501900c67307122b92c", size = 128368, upload-time = "2026-05-06T15:10:21.549Z" }, - { url = "https://files.pythonhosted.org/packages/21/0f/c9ede0bf052f6b4051e64a7d4fa91b725cccf8321a6a786e86eb03519f00/orjson-3.11.9-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ffe02797b5e9f3a9d8292ddcd289b474ad13e81ad83cd1891a240811f1d2cb81", size = 132070, upload-time = "2026-05-06T15:10:23.371Z" }, - { url = "https://files.pythonhosted.org/packages/fd/26/d398e28048dc18205bbe812f2c88cb9b40313db2470778e25964796458fe/orjson-3.11.9-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0e4eed3b200023042814d2fc8a5d2e880f13b52e1ed2485e83da4f3962f7dc1a", size = 127892, upload-time = "2026-05-06T15:10:24.714Z" }, - { url = "https://files.pythonhosted.org/packages/66/60/52b0054c4c700d5aa7fc5b7ca96917400d8f061307778578e67a10e25852/orjson-3.11.9-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8aff7da9952a5ad1cef8e68017724d96c7b9a66e99e91d6252e1b133d67a7b10", size = 135217, upload-time = "2026-05-06T15:10:26.084Z" }, - { url = "https://files.pythonhosted.org/packages/d5/97/1e3dc2b2a28b7b2528f403d2fc1d79ec5f39af3bc143ab65d3ec26426385/orjson-3.11.9-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4d4e98d6f3b8afed8bc8cd9718ec0cdf46661826beefb53fe8eafb37f2bf0362", size = 145980, upload-time = "2026-05-06T15:10:28.062Z" }, - { url = "https://files.pythonhosted.org/packages/fc/39/31fbfe7850f2de32dee7e7e5c09f26d403ab01e440ac96001c6b01ad3c99/orjson-3.11.9-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3a81d52442a7c99b3662333235b3adf96a1715864658b35bb797212be7bddb97", size = 132738, upload-time = "2026-05-06T15:10:29.727Z" }, - { url = "https://files.pythonhosted.org/packages/a1/08/dca0082dd2a194acb93e5457e73455388e2e2ca464a2672449a9ddbb679d/orjson-3.11.9-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4e39364e726a8fff737309aff059ff67d8a8c8d5b677be7bb49a8b3e84b7e218", size = 134033, upload-time = "2026-05-06T15:10:31.152Z" }, - { url = "https://files.pythonhosted.org/packages/11/d4/5bdb0626801230139987385554c5d4c42255218ac906525bf4347f22cd95/orjson-3.11.9-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4fd66214623f1b17501df9f0543bef0b833979ab5b6ded1e1d123222866aa8c9", size = 141492, upload-time = "2026-05-06T15:10:32.641Z" }, - { url = "https://files.pythonhosted.org/packages/fa/88/a21fb53b3ede6703aede6dce4710ed4111e5b201cfa6bbff5e544f9d47d7/orjson-3.11.9-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:8ecc30f10465fa1e0ce13fd01d9e22c316e5053a719a8d915d4545a09a5ff677", size = 415087, upload-time = "2026-05-06T15:10:34.438Z" }, - { url = "https://files.pythonhosted.org/packages/3d/57/1b30daf70f0d8180e9a73cefbfbdd99e4bf19eb020466502b01fba7e0e50/orjson-3.11.9-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:97db4c94a7db398a5bd636273324f0b3fd58b350bbbac8bb380ceb825a9b40f4", size = 148031, upload-time = "2026-05-06T15:10:36.358Z" }, - { url = "https://files.pythonhosted.org/packages/04/83/45fbb6d962e260807f99441db9613cee868ceda4baceda59b3720a563f97/orjson-3.11.9-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9f78cf8fec5bd627f4082b8dfeac7871b43d7f3274904492a43dab39f18a19a0", size = 136915, upload-time = "2026-05-06T15:10:38.013Z" }, - { url = "https://files.pythonhosted.org/packages/5f/cc/2d10025f9056d376e4127ec05a5808b218d46f035fdc08178a5411b34250/orjson-3.11.9-cp313-cp313-win32.whl", hash = "sha256:d4087e5c0209a0a8efe4de3303c234b9c44d1174161dcd851e8eea07c7560b32", size = 131613, upload-time = "2026-05-06T15:10:39.569Z" }, - { url = "https://files.pythonhosted.org/packages/67/bd/2775ff28bfe883b9aa1ff348300542eb2ef1ee18d8ae0e3a49846817a865/orjson-3.11.9-cp313-cp313-win_amd64.whl", hash = "sha256:051b102c93b4f634e89f3866b07b9a9a98915ada541f4ec30f177067b2694979", size = 127086, upload-time = "2026-05-06T15:10:41.262Z" }, - { url = "https://files.pythonhosted.org/packages/91/2b/d26799e580939e32a7da9a39531bc9e58e15ca32ffaa6a8cb3e9bb0d22cd/orjson-3.11.9-cp313-cp313-win_arm64.whl", hash = "sha256:cce9127885941bd28f080cecf1f1d288336b7e0d812c345b08be88b572796254", size = 126696, upload-time = "2026-05-06T15:10:42.651Z" }, - { url = "https://files.pythonhosted.org/packages/8e/eb/5da01e356015aee6ecfa1187ced87aef51364e306f5e695dd52719bf0e78/orjson-3.11.9-cp314-cp314-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:b6ef1979adc4bc243523f1a2ba91418030a8e29b0a99cbe7e0e2d6807d4dce6e", size = 228465, upload-time = "2026-05-06T15:10:44.097Z" }, - { url = "https://files.pythonhosted.org/packages/64/62/3e0e0c14c957133bcd855395c62b55ed4e3b0af23ffea11b032cb1dcbdb1/orjson-3.11.9-cp314-cp314-macosx_15_0_arm64.whl", hash = "sha256:f36b7f32c7c0db4a719f1fc5824db4a9c6f8bd1a354debb91faf26ebf3a4c71e", size = 128364, upload-time = "2026-05-06T15:10:45.839Z" }, - { url = "https://files.pythonhosted.org/packages/5a/5a/07d8aa117211a8ed7630bda80c8c0b14d04e0f8dcf99bcf49656e4a710eb/orjson-3.11.9-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:08f4d8ebb44925c794e535b2bebc507cebf32209df81de22ae285fb0d8d66de0", size = 132063, upload-time = "2026-05-06T15:10:47.267Z" }, - { url = "https://files.pythonhosted.org/packages/d6/ec/4acaf21483e18aa945be74a474c74b434f284b549f275a0a39b9f98956e9/orjson-3.11.9-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6cc7923789694fd58f001cbcac7e47abc13af4d560ebbfcf3b41a8b1a0748124", size = 122356, upload-time = "2026-05-06T15:10:48.765Z" }, - { url = "https://files.pythonhosted.org/packages/13/d8/5f0555e7638801323b7a75850f92e7dfa891bc84fe27a1ba4449170d1200/orjson-3.11.9-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ea5c46eb2d3af39e806b986f4b09d5c2706a1f5afde3cbf7544ce6616127173c", size = 129592, upload-time = "2026-05-06T15:10:50.13Z" }, - { url = "https://files.pythonhosted.org/packages/b6/30/ed9860412a3603ceb3c5955bfd72d28b9d0e7ba6ed81add14f83d7114236/orjson-3.11.9-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f5d89a2ed90731df3be64bab0aa44f78bff39fdc9d71c291f4a8023aa46425b7", size = 140491, upload-time = "2026-05-06T15:10:51.582Z" }, - { url = "https://files.pythonhosted.org/packages/d0/17/adc514dea7ac7c505527febf884934b815d34f0c7b8693c1a8b39c5c4a57/orjson-3.11.9-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:25e4aed0312d292c09f61af25bba34e0b2c88546041472b09088c39a4d828af1", size = 127309, upload-time = "2026-05-06T15:10:53.329Z" }, - { url = "https://files.pythonhosted.org/packages/76/3e/c0b690253f0b82d86e99949af13533363acfb5432ecb5d53dd5b3bce9c34/orjson-3.11.9-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:aaea64f3f467d22e70eeed68bdccb3bc4f83f650446c4a03c59f2cba28a108db", size = 134030, upload-time = "2026-05-06T15:10:54.988Z" }, - { url = "https://files.pythonhosted.org/packages/c1/7a/bc82a0bb25e9faaf92dc4d9ef002732efc09737706af83e346788641d4a7/orjson-3.11.9-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a028425d1b440c5d92a6be1e1a020739dfe67ea87d96c6dbe828c1b30041728b", size = 141482, upload-time = "2026-05-06T15:10:56.663Z" }, - { url = "https://files.pythonhosted.org/packages/01/55/e69188b939f77d5d32a9833745ace31ea5ccae3ab613a1ec185d3cd2c4fb/orjson-3.11.9-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:5b192c6cf397e4455b11523c5cf2b18ed084c1bbd61b6c0926344d2129481972", size = 415178, upload-time = "2026-05-06T15:10:58.446Z" }, - { url = "https://files.pythonhosted.org/packages/2e/1a/b8a5a7ac527e80b9cb11d51e3f6689b709279183264b9ec5c7bc680bb8b5/orjson-3.11.9-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ea407d4ccf5891d667d045fecae97a7a1e5e87b3b97f97ae1803c2e741130be0", size = 148089, upload-time = "2026-05-06T15:11:00.441Z" }, - { url = "https://files.pythonhosted.org/packages/97/4e/00503f64204bf859b37213a63927028f30fb6268cd8677fb0a5ad48155e1/orjson-3.11.9-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5f63aaf97afd9f6dec5b1a68e1b8da12bfccb4cb9a9a65c3e0b6c847849e7586", size = 136921, upload-time = "2026-05-06T15:11:02.176Z" }, - { url = "https://files.pythonhosted.org/packages/0d/ba/a23b82a0a8d0ed7bed4e5f5035aae751cad4ff6a1e8d2ecd14d8860f5929/orjson-3.11.9-cp314-cp314-win32.whl", hash = "sha256:e30ab17845bb9fa54ccf67fa4f9f5282652d54faa6d17452f47d0f369d038673", size = 131638, upload-time = "2026-05-06T15:11:03.696Z" }, - { url = "https://files.pythonhosted.org/packages/f3/c3/0c6798456bade745c75c452342dabacce5798196483e77e643be1f53877d/orjson-3.11.9-cp314-cp314-win_amd64.whl", hash = "sha256:32ef5f4283a3be81913947d19608eacb7c6608026851123790cd9cc8982af34b", size = 127078, upload-time = "2026-05-06T15:11:05.123Z" }, - { url = "https://files.pythonhosted.org/packages/16/21/5a3f1e8913103b703a436a5664238e5b965ec392b555fe68943ea3691e6b/orjson-3.11.9-cp314-cp314-win_arm64.whl", hash = "sha256:eebdbdeef0094e4f5aefa20dcd4eb2368ab5e7a3b4edea27f1e7b2892e009cf9", size = 126687, upload-time = "2026-05-06T15:11:06.602Z" }, + { url = "https://files.pythonhosted.org/packages/32/33/93fcc25907235c344ae73122f8a4e01d2d393ef062b4af7d2e2487a32c37/orjson-3.11.9-cp313-cp313-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:4bab1b2d6141fe7b32ae71dac905666ece4f94936efbfb13d55bb7739a3a6021", size = 228458, upload-time = "2026-05-06T15:10:20.079Z" }, + { url = "https://files.pythonhosted.org/packages/8f/27/b1e6dadb3c080313c03fdd8067b85e6a0460c7d8d6a1c3984ef77b904e4d/orjson-3.11.9-cp313-cp313-macosx_15_0_arm64.whl", hash = "sha256:844417969855fc7a41be124aafe83dc424592a7f77cd4501900c67307122b92c", size = 128368, upload-time = "2026-05-06T15:10:21.549Z" }, + { url = "https://files.pythonhosted.org/packages/21/0f/c9ede0bf052f6b4051e64a7d4fa91b725cccf8321a6a786e86eb03519f00/orjson-3.11.9-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ffe02797b5e9f3a9d8292ddcd289b474ad13e81ad83cd1891a240811f1d2cb81", size = 132070, upload-time = "2026-05-06T15:10:23.371Z" }, + { url = "https://files.pythonhosted.org/packages/fd/26/d398e28048dc18205bbe812f2c88cb9b40313db2470778e25964796458fe/orjson-3.11.9-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0e4eed3b200023042814d2fc8a5d2e880f13b52e1ed2485e83da4f3962f7dc1a", size = 127892, upload-time = "2026-05-06T15:10:24.714Z" }, + { url = "https://files.pythonhosted.org/packages/66/60/52b0054c4c700d5aa7fc5b7ca96917400d8f061307778578e67a10e25852/orjson-3.11.9-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8aff7da9952a5ad1cef8e68017724d96c7b9a66e99e91d6252e1b133d67a7b10", size = 135217, upload-time = "2026-05-06T15:10:26.084Z" }, + { url = "https://files.pythonhosted.org/packages/d5/97/1e3dc2b2a28b7b2528f403d2fc1d79ec5f39af3bc143ab65d3ec26426385/orjson-3.11.9-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4d4e98d6f3b8afed8bc8cd9718ec0cdf46661826beefb53fe8eafb37f2bf0362", size = 145980, upload-time = "2026-05-06T15:10:28.062Z" }, + { url = "https://files.pythonhosted.org/packages/fc/39/31fbfe7850f2de32dee7e7e5c09f26d403ab01e440ac96001c6b01ad3c99/orjson-3.11.9-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3a81d52442a7c99b3662333235b3adf96a1715864658b35bb797212be7bddb97", size = 132738, upload-time = "2026-05-06T15:10:29.727Z" }, + { url = "https://files.pythonhosted.org/packages/a1/08/dca0082dd2a194acb93e5457e73455388e2e2ca464a2672449a9ddbb679d/orjson-3.11.9-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4e39364e726a8fff737309aff059ff67d8a8c8d5b677be7bb49a8b3e84b7e218", size = 134033, upload-time = "2026-05-06T15:10:31.152Z" }, + { url = "https://files.pythonhosted.org/packages/11/d4/5bdb0626801230139987385554c5d4c42255218ac906525bf4347f22cd95/orjson-3.11.9-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4fd66214623f1b17501df9f0543bef0b833979ab5b6ded1e1d123222866aa8c9", size = 141492, upload-time = "2026-05-06T15:10:32.641Z" }, + { url = "https://files.pythonhosted.org/packages/fa/88/a21fb53b3ede6703aede6dce4710ed4111e5b201cfa6bbff5e544f9d47d7/orjson-3.11.9-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:8ecc30f10465fa1e0ce13fd01d9e22c316e5053a719a8d915d4545a09a5ff677", size = 415087, upload-time = "2026-05-06T15:10:34.438Z" }, + { url = "https://files.pythonhosted.org/packages/3d/57/1b30daf70f0d8180e9a73cefbfbdd99e4bf19eb020466502b01fba7e0e50/orjson-3.11.9-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:97db4c94a7db398a5bd636273324f0b3fd58b350bbbac8bb380ceb825a9b40f4", size = 148031, upload-time = "2026-05-06T15:10:36.358Z" }, + { url = "https://files.pythonhosted.org/packages/04/83/45fbb6d962e260807f99441db9613cee868ceda4baceda59b3720a563f97/orjson-3.11.9-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9f78cf8fec5bd627f4082b8dfeac7871b43d7f3274904492a43dab39f18a19a0", size = 136915, upload-time = "2026-05-06T15:10:38.013Z" }, + { url = "https://files.pythonhosted.org/packages/5f/cc/2d10025f9056d376e4127ec05a5808b218d46f035fdc08178a5411b34250/orjson-3.11.9-cp313-cp313-win32.whl", hash = "sha256:d4087e5c0209a0a8efe4de3303c234b9c44d1174161dcd851e8eea07c7560b32", size = 131613, upload-time = "2026-05-06T15:10:39.569Z" }, + { url = "https://files.pythonhosted.org/packages/67/bd/2775ff28bfe883b9aa1ff348300542eb2ef1ee18d8ae0e3a49846817a865/orjson-3.11.9-cp313-cp313-win_amd64.whl", hash = "sha256:051b102c93b4f634e89f3866b07b9a9a98915ada541f4ec30f177067b2694979", size = 127086, upload-time = "2026-05-06T15:10:41.262Z" }, + { url = "https://files.pythonhosted.org/packages/91/2b/d26799e580939e32a7da9a39531bc9e58e15ca32ffaa6a8cb3e9bb0d22cd/orjson-3.11.9-cp313-cp313-win_arm64.whl", hash = "sha256:cce9127885941bd28f080cecf1f1d288336b7e0d812c345b08be88b572796254", size = 126696, upload-time = "2026-05-06T15:10:42.651Z" }, + { url = "https://files.pythonhosted.org/packages/8e/eb/5da01e356015aee6ecfa1187ced87aef51364e306f5e695dd52719bf0e78/orjson-3.11.9-cp314-cp314-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:b6ef1979adc4bc243523f1a2ba91418030a8e29b0a99cbe7e0e2d6807d4dce6e", size = 228465, upload-time = "2026-05-06T15:10:44.097Z" }, + { url = "https://files.pythonhosted.org/packages/64/62/3e0e0c14c957133bcd855395c62b55ed4e3b0af23ffea11b032cb1dcbdb1/orjson-3.11.9-cp314-cp314-macosx_15_0_arm64.whl", hash = "sha256:f36b7f32c7c0db4a719f1fc5824db4a9c6f8bd1a354debb91faf26ebf3a4c71e", size = 128364, upload-time = "2026-05-06T15:10:45.839Z" }, + { url = "https://files.pythonhosted.org/packages/5a/5a/07d8aa117211a8ed7630bda80c8c0b14d04e0f8dcf99bcf49656e4a710eb/orjson-3.11.9-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:08f4d8ebb44925c794e535b2bebc507cebf32209df81de22ae285fb0d8d66de0", size = 132063, upload-time = "2026-05-06T15:10:47.267Z" }, + { url = "https://files.pythonhosted.org/packages/d6/ec/4acaf21483e18aa945be74a474c74b434f284b549f275a0a39b9f98956e9/orjson-3.11.9-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6cc7923789694fd58f001cbcac7e47abc13af4d560ebbfcf3b41a8b1a0748124", size = 122356, upload-time = "2026-05-06T15:10:48.765Z" }, + { url = "https://files.pythonhosted.org/packages/13/d8/5f0555e7638801323b7a75850f92e7dfa891bc84fe27a1ba4449170d1200/orjson-3.11.9-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ea5c46eb2d3af39e806b986f4b09d5c2706a1f5afde3cbf7544ce6616127173c", size = 129592, upload-time = "2026-05-06T15:10:50.13Z" }, + { url = "https://files.pythonhosted.org/packages/b6/30/ed9860412a3603ceb3c5955bfd72d28b9d0e7ba6ed81add14f83d7114236/orjson-3.11.9-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f5d89a2ed90731df3be64bab0aa44f78bff39fdc9d71c291f4a8023aa46425b7", size = 140491, upload-time = "2026-05-06T15:10:51.582Z" }, + { url = "https://files.pythonhosted.org/packages/d0/17/adc514dea7ac7c505527febf884934b815d34f0c7b8693c1a8b39c5c4a57/orjson-3.11.9-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:25e4aed0312d292c09f61af25bba34e0b2c88546041472b09088c39a4d828af1", size = 127309, upload-time = "2026-05-06T15:10:53.329Z" }, + { url = "https://files.pythonhosted.org/packages/76/3e/c0b690253f0b82d86e99949af13533363acfb5432ecb5d53dd5b3bce9c34/orjson-3.11.9-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:aaea64f3f467d22e70eeed68bdccb3bc4f83f650446c4a03c59f2cba28a108db", size = 134030, upload-time = "2026-05-06T15:10:54.988Z" }, + { url = "https://files.pythonhosted.org/packages/c1/7a/bc82a0bb25e9faaf92dc4d9ef002732efc09737706af83e346788641d4a7/orjson-3.11.9-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a028425d1b440c5d92a6be1e1a020739dfe67ea87d96c6dbe828c1b30041728b", size = 141482, upload-time = "2026-05-06T15:10:56.663Z" }, + { url = "https://files.pythonhosted.org/packages/01/55/e69188b939f77d5d32a9833745ace31ea5ccae3ab613a1ec185d3cd2c4fb/orjson-3.11.9-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:5b192c6cf397e4455b11523c5cf2b18ed084c1bbd61b6c0926344d2129481972", size = 415178, upload-time = "2026-05-06T15:10:58.446Z" }, + { url = "https://files.pythonhosted.org/packages/2e/1a/b8a5a7ac527e80b9cb11d51e3f6689b709279183264b9ec5c7bc680bb8b5/orjson-3.11.9-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ea407d4ccf5891d667d045fecae97a7a1e5e87b3b97f97ae1803c2e741130be0", size = 148089, upload-time = "2026-05-06T15:11:00.441Z" }, + { url = "https://files.pythonhosted.org/packages/97/4e/00503f64204bf859b37213a63927028f30fb6268cd8677fb0a5ad48155e1/orjson-3.11.9-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5f63aaf97afd9f6dec5b1a68e1b8da12bfccb4cb9a9a65c3e0b6c847849e7586", size = 136921, upload-time = "2026-05-06T15:11:02.176Z" }, + { url = "https://files.pythonhosted.org/packages/0d/ba/a23b82a0a8d0ed7bed4e5f5035aae751cad4ff6a1e8d2ecd14d8860f5929/orjson-3.11.9-cp314-cp314-win32.whl", hash = "sha256:e30ab17845bb9fa54ccf67fa4f9f5282652d54faa6d17452f47d0f369d038673", size = 131638, upload-time = "2026-05-06T15:11:03.696Z" }, + { url = "https://files.pythonhosted.org/packages/f3/c3/0c6798456bade745c75c452342dabacce5798196483e77e643be1f53877d/orjson-3.11.9-cp314-cp314-win_amd64.whl", hash = "sha256:32ef5f4283a3be81913947d19608eacb7c6608026851123790cd9cc8982af34b", size = 127078, upload-time = "2026-05-06T15:11:05.123Z" }, + { url = "https://files.pythonhosted.org/packages/16/21/5a3f1e8913103b703a436a5664238e5b965ec392b555fe68943ea3691e6b/orjson-3.11.9-cp314-cp314-win_arm64.whl", hash = "sha256:eebdbdeef0094e4f5aefa20dcd4eb2368ab5e7a3b4edea27f1e7b2892e009cf9", size = 126687, upload-time = "2026-05-06T15:11:06.602Z" }, ] [[package]] @@ -792,7 +793,7 @@ version = "26.2" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" }, + { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" }, ] [[package]] @@ -800,43 +801,43 @@ name = "pandas" version = "3.0.3" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "numpy" }, - { name = "python-dateutil" }, - { name = "tzdata", marker = "sys_platform == 'emscripten' or sys_platform == 'win32'" }, + { name = "numpy" }, + { name = "python-dateutil" }, + { name = "tzdata", marker = "sys_platform == 'emscripten' or sys_platform == 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/f8/87/4341c6252d1c47b08768c3d25ac487362bf403f0313ddae4a2a26c9b1b4c/pandas-3.0.3.tar.gz", hash = "sha256:696a4a00a2a2a35d4e5deb3fc946641b96c944f02230e4f76137fe35d806c4fc", size = 4651414, upload-time = "2026-05-11T18:54:29.21Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c5/90/62d8302883c44308c477e222c3daf7c813a34c8e96985882fbd53d964352/pandas-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:67b3b64c11910cfa29f4e94a14d3bff9ee693b6fc76055e7cad549cee0aec5fa", size = 10331071, upload-time = "2026-05-11T18:52:58.838Z" }, - { url = "https://files.pythonhosted.org/packages/7f/ae/6a6493c783a101f165e4356953ba3c74d6f77f0042fa7d753da9dfbb640c/pandas-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:39436b377d56d2a2e52d0395bdbee171f01068e99af5250509aceeb929f765c7", size = 9875690, upload-time = "2026-05-11T18:53:01.431Z" }, - { url = "https://files.pythonhosted.org/packages/62/7c/5df8e9f56c69a2769fbe9382a5ef8f2658c007e376434e1e2cbb57ad895f/pandas-3.0.3-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d4be06d68f9ddcfc645b87534911da79a8fbffc7573c80e0edcf42a5020624d8", size = 10381634, upload-time = "2026-05-11T18:53:04.393Z" }, - { url = "https://files.pythonhosted.org/packages/99/68/1237369725aa617bb358263d535803e3053fdbc593513ec5ed9c9896b5b6/pandas-3.0.3-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a4eeb6830daf35a71cc09649bd823e2b542dac246cdee9614c6e4bd65028cd6a", size = 10891243, upload-time = "2026-05-11T18:53:07.643Z" }, - { url = "https://files.pythonhosted.org/packages/25/93/77d108e8af7222b4a503ebde0e30215b1c2e4f8e53a526431890f22d5586/pandas-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:1928e07221f82db493cd4af1e23c1bfca524a19a4699887975bff68f49a72bfb", size = 11388659, upload-time = "2026-05-11T18:53:10.634Z" }, - { url = "https://files.pythonhosted.org/packages/d0/bd/eff5b4399f332ac386c853f6cd2bd3fa2ca0061b9f36ecd9c4d7c4265649/pandas-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:51b1fe551acb77dac643c6fda86084d8d446c10fe64b06a9cc29c4cc8540e7f2", size = 11942880, upload-time = "2026-05-11T18:53:13.536Z" }, - { url = "https://files.pythonhosted.org/packages/2c/20/559ace4200982c3887d0b86bfd0d856a2143ef8ddab63cc07934951a964c/pandas-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:a82d532a3351d435432cd913edbccaf8b8e01d4dd0e5ced5a8d2e8ecd94c7e44", size = 9757091, upload-time = "2026-05-11T18:53:16.306Z" }, - { url = "https://files.pythonhosted.org/packages/3a/66/69055a09fe200f29f922a3eeec4804611900b95f52d932ece3393c3c0c19/pandas-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:275c14e0fce14a2ec20eee474aecd305478ea3c1e6f6a9d8fe219a165542717e", size = 9057282, upload-time = "2026-05-11T18:53:18.768Z" }, - { url = "https://files.pythonhosted.org/packages/57/0e/efe801b0e6811e8e650cd21b7f2608e30f08a7067e2bf6e8752b0d56ee3c/pandas-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:46997386d528eb40376ecd6b033cf4a8a1e5282580f68f43de875b78cba2199d", size = 10767016, upload-time = "2026-05-11T18:53:21.227Z" }, - { url = "https://files.pythonhosted.org/packages/ea/dc/eb55135a1d5f0f0519f28da1f609a206d2cad1f9c35c32d51e38dd7261ae/pandas-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:261e308dfb22448384b7580cf719d2f998fe2966c92893c3e77d14008af1f066", size = 10420210, upload-time = "2026-05-11T18:53:23.982Z" }, - { url = "https://files.pythonhosted.org/packages/c6/3e/b1d5d955ce33ffecb407465a60bc32769d74fcf68224b7ae67ae11d4dea4/pandas-3.0.3-cp313-cp313t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dd1a5d1def6a46002e964510bdc67c368aa0951df5d1d9f8365336f5a1f490cd", size = 10336126, upload-time = "2026-05-11T18:53:26.731Z" }, - { url = "https://files.pythonhosted.org/packages/f5/76/a01261711ab60a22d71b862f0de20e4c504bf80457270ad8cb42110f6abc/pandas-3.0.3-cp313-cp313t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d72828c20c6d6e83e1e22a6a3b47b326b71664112fa9705dcbccfd7a39b62085", size = 10728051, upload-time = "2026-05-11T18:53:29.125Z" }, - { url = "https://files.pythonhosted.org/packages/e9/21/ea191195e587b18cf682e97f433f81b2d0fbe341380e80a3e0d6e4403c8e/pandas-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:d26cbe1fcfc12e8fd900e2454163e466b2d3af84f7c75481df7683ffc073d870", size = 11350796, upload-time = "2026-05-11T18:53:32.056Z" }, - { url = "https://files.pythonhosted.org/packages/64/69/f0eaaf54939f0e8c6768fd06be9af2cef9b36048b96dfb9e1b2c685a807e/pandas-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:3e91cec1879ada0624fc3dc9953c5cbd60208e59c0db28f540c5d6d47502422f", size = 11799741, upload-time = "2026-05-11T18:53:34.985Z" }, - { url = "https://files.pythonhosted.org/packages/45/a4/865e0e510cae5fc2194de4db28be638952de942571ba9125934fd9c01d47/pandas-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:08d789b41f87e0905880e293cedf6197ce71fe67cc081358b1e148a491b9bd13", size = 10499958, upload-time = "2026-05-11T18:53:37.857Z" }, - { url = "https://files.pythonhosted.org/packages/86/54/effdcc3c0ff7a08037889200e148ebe94c16c4f653be078c7b3675955df1/pandas-3.0.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:3650109c0f22879df8bd6179ab9ee3d7f1d1d4e7e0094a3f0032d9f51e2e64ac", size = 10336065, upload-time = "2026-05-11T18:53:41.099Z" }, - { url = "https://files.pythonhosted.org/packages/68/10/bf2d6738d72748b961a3751ab89522d58c54efc36a8e1a12161216cd45cf/pandas-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:bab900348131a7db1f69a7309ef141fd5680f1487094193bcbbb61791573bf8f", size = 9926101, upload-time = "2026-05-11T18:53:43.515Z" }, - { url = "https://files.pythonhosted.org/packages/ae/e9/e35cf11c8a136e757b956f5f0efdcaa50aecde85ea055f1898dfc68262f3/pandas-3.0.3-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ba7e08b9ac1d54569cd1e256e3668975ed624d6826f7b68df0342b012007bddb", size = 10457553, upload-time = "2026-05-11T18:53:46.394Z" }, - { url = "https://files.pythonhosted.org/packages/58/3b/1cdec6772bdbaf7b25dab360c59f03cadf05492dd724c6540af905389b07/pandas-3.0.3-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d71c63ae4ebdbf70209742096f1fc46a83a0613c99d4b23766cced9ff8cd62a", size = 10914065, upload-time = "2026-05-11T18:53:49.134Z" }, - { url = "https://files.pythonhosted.org/packages/c4/c2/1ef644445fcd72e3627bceec77e3560636f87ddce4ed841afe76b83b5bf9/pandas-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e3a2ec42c98ffa2565a67e08e218d06d72576d758d90facb7c00805194d8f360", size = 11459188, upload-time = "2026-05-11T18:53:52.527Z" }, - { url = "https://files.pythonhosted.org/packages/7e/49/4d8d4f42cbc9c4adc7a1870f269c02cbd6cd40d059622c06fb298addcbad/pandas-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:335f62418ed562cfc3c49e9e196375c28b729dcef8543abf4f9438e381bf3c76", size = 11982966, upload-time = "2026-05-11T18:53:55.043Z" }, - { url = "https://files.pythonhosted.org/packages/38/55/792619469bab9882d8bbd5865d45a72f6478762d04a9af4bf0d08c503e95/pandas-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:3c20a521bbb85902f79f7270c80a59e1b5452d96d170c034f207181870f97ac5", size = 9876755, upload-time = "2026-05-11T18:53:58.067Z" }, - { url = "https://files.pythonhosted.org/packages/2a/af/33c469653b0ba03b50c3a98192d4c07f0c75c66b263ceb097fce0ee97d31/pandas-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:a2d2dff8a04f3917b55ab3910c32990f8ddf7eceba114947838cefa976a68977", size = 9198658, upload-time = "2026-05-11T18:54:00.733Z" }, - { url = "https://files.pythonhosted.org/packages/a2/fa/b8c257bd76b8bd060c3a9151c1fca05e9b9c5e3af5d0f549c0356f6d143d/pandas-3.0.3-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:0d589105b3c14645af1738ff279b2995102d8f7a03b0a66dc8d95550eb513e04", size = 10787242, upload-time = "2026-05-11T18:54:03.564Z" }, - { url = "https://files.pythonhosted.org/packages/54/eb/f19206ffb0bf1919002969aa448b4702c6594845156a6f8050674855aac3/pandas-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:13fc1e853d9e04743d11ba75a985ccbc2a317fe07d8af61e445a6fd24dacd6a6", size = 10436369, upload-time = "2026-05-11T18:54:06.311Z" }, - { url = "https://files.pythonhosted.org/packages/fd/24/c7c39fb4fe22b71a0c2d78bf0c585c600092d85f94f086d2b3b2f6ca27e2/pandas-3.0.3-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:819959dab7bbd0049c15623fbac4e29a191b9528160a61fb1032242d8ced2d9c", size = 10358306, upload-time = "2026-05-11T18:54:09.085Z" }, - { url = "https://files.pythonhosted.org/packages/16/ec/dd2a9eb7fa1204df88c0864164e35b228ac581062ac612ba0a67fd812e4c/pandas-3.0.3-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:60ae316d3fd75d1858d450d0db0103ea2be3e7d4a95ec2f064f7e2ae63f7b028", size = 10758394, upload-time = "2026-05-11T18:54:11.956Z" }, - { url = "https://files.pythonhosted.org/packages/95/6e/00c61ea8e85b4f6d8d35e11852a1a4998fc7fafc91c6a602d1cc9c972d64/pandas-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bd3a518890b400d32f9023722dc9a9a5c969f00b415419a3c06c043f09bb5d7d", size = 11375717, upload-time = "2026-05-11T18:54:14.539Z" }, - { url = "https://files.pythonhosted.org/packages/31/89/8fc1c268969fac43688d65fd92e67df24bd128d53cb4d2eee534cd307399/pandas-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:9c39be2d709d01fa972a0cabc522389fceca4f3969332ba25a7d6c5802cf976a", size = 11828897, upload-time = "2026-05-11T18:54:17.146Z" }, - { url = "https://files.pythonhosted.org/packages/56/3b/e7d20dea247a3e6dc0bd8a6953854afbedc03951def4e7371e05e7263e25/pandas-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4db8c527972a821cf5286b40ccc57642a39bc62e62022b42f99f8a67fca8c3a1", size = 10900855, upload-time = "2026-05-11T18:54:19.72Z" }, - { url = "https://files.pythonhosted.org/packages/0f/54/68a0978d1ef8502b8492099beaa6e7a0c1b32e3b5d4f677f5810cb08711c/pandas-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:b2c95f8bfc1ee412bf482605d7bfd30c12d1d26bd59fdd91efeef1d4718decb1", size = 9466464, upload-time = "2026-05-11T18:54:22.754Z" }, + { url = "https://files.pythonhosted.org/packages/c5/90/62d8302883c44308c477e222c3daf7c813a34c8e96985882fbd53d964352/pandas-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:67b3b64c11910cfa29f4e94a14d3bff9ee693b6fc76055e7cad549cee0aec5fa", size = 10331071, upload-time = "2026-05-11T18:52:58.838Z" }, + { url = "https://files.pythonhosted.org/packages/7f/ae/6a6493c783a101f165e4356953ba3c74d6f77f0042fa7d753da9dfbb640c/pandas-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:39436b377d56d2a2e52d0395bdbee171f01068e99af5250509aceeb929f765c7", size = 9875690, upload-time = "2026-05-11T18:53:01.431Z" }, + { url = "https://files.pythonhosted.org/packages/62/7c/5df8e9f56c69a2769fbe9382a5ef8f2658c007e376434e1e2cbb57ad895f/pandas-3.0.3-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d4be06d68f9ddcfc645b87534911da79a8fbffc7573c80e0edcf42a5020624d8", size = 10381634, upload-time = "2026-05-11T18:53:04.393Z" }, + { url = "https://files.pythonhosted.org/packages/99/68/1237369725aa617bb358263d535803e3053fdbc593513ec5ed9c9896b5b6/pandas-3.0.3-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a4eeb6830daf35a71cc09649bd823e2b542dac246cdee9614c6e4bd65028cd6a", size = 10891243, upload-time = "2026-05-11T18:53:07.643Z" }, + { url = "https://files.pythonhosted.org/packages/25/93/77d108e8af7222b4a503ebde0e30215b1c2e4f8e53a526431890f22d5586/pandas-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:1928e07221f82db493cd4af1e23c1bfca524a19a4699887975bff68f49a72bfb", size = 11388659, upload-time = "2026-05-11T18:53:10.634Z" }, + { url = "https://files.pythonhosted.org/packages/d0/bd/eff5b4399f332ac386c853f6cd2bd3fa2ca0061b9f36ecd9c4d7c4265649/pandas-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:51b1fe551acb77dac643c6fda86084d8d446c10fe64b06a9cc29c4cc8540e7f2", size = 11942880, upload-time = "2026-05-11T18:53:13.536Z" }, + { url = "https://files.pythonhosted.org/packages/2c/20/559ace4200982c3887d0b86bfd0d856a2143ef8ddab63cc07934951a964c/pandas-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:a82d532a3351d435432cd913edbccaf8b8e01d4dd0e5ced5a8d2e8ecd94c7e44", size = 9757091, upload-time = "2026-05-11T18:53:16.306Z" }, + { url = "https://files.pythonhosted.org/packages/3a/66/69055a09fe200f29f922a3eeec4804611900b95f52d932ece3393c3c0c19/pandas-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:275c14e0fce14a2ec20eee474aecd305478ea3c1e6f6a9d8fe219a165542717e", size = 9057282, upload-time = "2026-05-11T18:53:18.768Z" }, + { url = "https://files.pythonhosted.org/packages/57/0e/efe801b0e6811e8e650cd21b7f2608e30f08a7067e2bf6e8752b0d56ee3c/pandas-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:46997386d528eb40376ecd6b033cf4a8a1e5282580f68f43de875b78cba2199d", size = 10767016, upload-time = "2026-05-11T18:53:21.227Z" }, + { url = "https://files.pythonhosted.org/packages/ea/dc/eb55135a1d5f0f0519f28da1f609a206d2cad1f9c35c32d51e38dd7261ae/pandas-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:261e308dfb22448384b7580cf719d2f998fe2966c92893c3e77d14008af1f066", size = 10420210, upload-time = "2026-05-11T18:53:23.982Z" }, + { url = "https://files.pythonhosted.org/packages/c6/3e/b1d5d955ce33ffecb407465a60bc32769d74fcf68224b7ae67ae11d4dea4/pandas-3.0.3-cp313-cp313t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dd1a5d1def6a46002e964510bdc67c368aa0951df5d1d9f8365336f5a1f490cd", size = 10336126, upload-time = "2026-05-11T18:53:26.731Z" }, + { url = "https://files.pythonhosted.org/packages/f5/76/a01261711ab60a22d71b862f0de20e4c504bf80457270ad8cb42110f6abc/pandas-3.0.3-cp313-cp313t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d72828c20c6d6e83e1e22a6a3b47b326b71664112fa9705dcbccfd7a39b62085", size = 10728051, upload-time = "2026-05-11T18:53:29.125Z" }, + { url = "https://files.pythonhosted.org/packages/e9/21/ea191195e587b18cf682e97f433f81b2d0fbe341380e80a3e0d6e4403c8e/pandas-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:d26cbe1fcfc12e8fd900e2454163e466b2d3af84f7c75481df7683ffc073d870", size = 11350796, upload-time = "2026-05-11T18:53:32.056Z" }, + { url = "https://files.pythonhosted.org/packages/64/69/f0eaaf54939f0e8c6768fd06be9af2cef9b36048b96dfb9e1b2c685a807e/pandas-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:3e91cec1879ada0624fc3dc9953c5cbd60208e59c0db28f540c5d6d47502422f", size = 11799741, upload-time = "2026-05-11T18:53:34.985Z" }, + { url = "https://files.pythonhosted.org/packages/45/a4/865e0e510cae5fc2194de4db28be638952de942571ba9125934fd9c01d47/pandas-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:08d789b41f87e0905880e293cedf6197ce71fe67cc081358b1e148a491b9bd13", size = 10499958, upload-time = "2026-05-11T18:53:37.857Z" }, + { url = "https://files.pythonhosted.org/packages/86/54/effdcc3c0ff7a08037889200e148ebe94c16c4f653be078c7b3675955df1/pandas-3.0.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:3650109c0f22879df8bd6179ab9ee3d7f1d1d4e7e0094a3f0032d9f51e2e64ac", size = 10336065, upload-time = "2026-05-11T18:53:41.099Z" }, + { url = "https://files.pythonhosted.org/packages/68/10/bf2d6738d72748b961a3751ab89522d58c54efc36a8e1a12161216cd45cf/pandas-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:bab900348131a7db1f69a7309ef141fd5680f1487094193bcbbb61791573bf8f", size = 9926101, upload-time = "2026-05-11T18:53:43.515Z" }, + { url = "https://files.pythonhosted.org/packages/ae/e9/e35cf11c8a136e757b956f5f0efdcaa50aecde85ea055f1898dfc68262f3/pandas-3.0.3-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ba7e08b9ac1d54569cd1e256e3668975ed624d6826f7b68df0342b012007bddb", size = 10457553, upload-time = "2026-05-11T18:53:46.394Z" }, + { url = "https://files.pythonhosted.org/packages/58/3b/1cdec6772bdbaf7b25dab360c59f03cadf05492dd724c6540af905389b07/pandas-3.0.3-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d71c63ae4ebdbf70209742096f1fc46a83a0613c99d4b23766cced9ff8cd62a", size = 10914065, upload-time = "2026-05-11T18:53:49.134Z" }, + { url = "https://files.pythonhosted.org/packages/c4/c2/1ef644445fcd72e3627bceec77e3560636f87ddce4ed841afe76b83b5bf9/pandas-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e3a2ec42c98ffa2565a67e08e218d06d72576d758d90facb7c00805194d8f360", size = 11459188, upload-time = "2026-05-11T18:53:52.527Z" }, + { url = "https://files.pythonhosted.org/packages/7e/49/4d8d4f42cbc9c4adc7a1870f269c02cbd6cd40d059622c06fb298addcbad/pandas-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:335f62418ed562cfc3c49e9e196375c28b729dcef8543abf4f9438e381bf3c76", size = 11982966, upload-time = "2026-05-11T18:53:55.043Z" }, + { url = "https://files.pythonhosted.org/packages/38/55/792619469bab9882d8bbd5865d45a72f6478762d04a9af4bf0d08c503e95/pandas-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:3c20a521bbb85902f79f7270c80a59e1b5452d96d170c034f207181870f97ac5", size = 9876755, upload-time = "2026-05-11T18:53:58.067Z" }, + { url = "https://files.pythonhosted.org/packages/2a/af/33c469653b0ba03b50c3a98192d4c07f0c75c66b263ceb097fce0ee97d31/pandas-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:a2d2dff8a04f3917b55ab3910c32990f8ddf7eceba114947838cefa976a68977", size = 9198658, upload-time = "2026-05-11T18:54:00.733Z" }, + { url = "https://files.pythonhosted.org/packages/a2/fa/b8c257bd76b8bd060c3a9151c1fca05e9b9c5e3af5d0f549c0356f6d143d/pandas-3.0.3-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:0d589105b3c14645af1738ff279b2995102d8f7a03b0a66dc8d95550eb513e04", size = 10787242, upload-time = "2026-05-11T18:54:03.564Z" }, + { url = "https://files.pythonhosted.org/packages/54/eb/f19206ffb0bf1919002969aa448b4702c6594845156a6f8050674855aac3/pandas-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:13fc1e853d9e04743d11ba75a985ccbc2a317fe07d8af61e445a6fd24dacd6a6", size = 10436369, upload-time = "2026-05-11T18:54:06.311Z" }, + { url = "https://files.pythonhosted.org/packages/fd/24/c7c39fb4fe22b71a0c2d78bf0c585c600092d85f94f086d2b3b2f6ca27e2/pandas-3.0.3-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:819959dab7bbd0049c15623fbac4e29a191b9528160a61fb1032242d8ced2d9c", size = 10358306, upload-time = "2026-05-11T18:54:09.085Z" }, + { url = "https://files.pythonhosted.org/packages/16/ec/dd2a9eb7fa1204df88c0864164e35b228ac581062ac612ba0a67fd812e4c/pandas-3.0.3-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:60ae316d3fd75d1858d450d0db0103ea2be3e7d4a95ec2f064f7e2ae63f7b028", size = 10758394, upload-time = "2026-05-11T18:54:11.956Z" }, + { url = "https://files.pythonhosted.org/packages/95/6e/00c61ea8e85b4f6d8d35e11852a1a4998fc7fafc91c6a602d1cc9c972d64/pandas-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bd3a518890b400d32f9023722dc9a9a5c969f00b415419a3c06c043f09bb5d7d", size = 11375717, upload-time = "2026-05-11T18:54:14.539Z" }, + { url = "https://files.pythonhosted.org/packages/31/89/8fc1c268969fac43688d65fd92e67df24bd128d53cb4d2eee534cd307399/pandas-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:9c39be2d709d01fa972a0cabc522389fceca4f3969332ba25a7d6c5802cf976a", size = 11828897, upload-time = "2026-05-11T18:54:17.146Z" }, + { url = "https://files.pythonhosted.org/packages/56/3b/e7d20dea247a3e6dc0bd8a6953854afbedc03951def4e7371e05e7263e25/pandas-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4db8c527972a821cf5286b40ccc57642a39bc62e62022b42f99f8a67fca8c3a1", size = 10900855, upload-time = "2026-05-11T18:54:19.72Z" }, + { url = "https://files.pythonhosted.org/packages/0f/54/68a0978d1ef8502b8492099beaa6e7a0c1b32e3b5d4f677f5810cb08711c/pandas-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:b2c95f8bfc1ee412bf482605d7bfd30c12d1d26bd59fdd91efeef1d4718decb1", size = 9466464, upload-time = "2026-05-11T18:54:22.754Z" }, ] [[package]] @@ -845,7 +846,7 @@ version = "4.10.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/d7/47/e4501f49c178ae1d9f4a75073fda4204f52647993f075a9db4d14930e0c5/platformdirs-4.10.0.tar.gz", hash = "sha256:31e761a6a0ca04faf7353ea759bdba55652be214725111e5aac52dfa29d4bef7", size = 31224, upload-time = "2026-05-28T03:32:53.587Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/81/e6/cd9575ac904136b3cbf7aa7ee819ef86eedb7274e46f230e94ea4342e729/platformdirs-4.10.0-py3-none-any.whl", hash = "sha256:fb516cdb12eb0d857d0cd85a7c57cea4d060bee4578d6cf5a14dfdf8cbf8784a", size = 22743, upload-time = "2026-05-28T03:32:52.175Z" }, + { url = "https://files.pythonhosted.org/packages/81/e6/cd9575ac904136b3cbf7aa7ee819ef86eedb7274e46f230e94ea4342e729/platformdirs-4.10.0-py3-none-any.whl", hash = "sha256:fb516cdb12eb0d857d0cd85a7c57cea4d060bee4578d6cf5a14dfdf8cbf8784a", size = 22743, upload-time = "2026-05-28T03:32:52.175Z" }, ] [[package]] @@ -854,7 +855,7 @@ version = "1.6.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, ] [[package]] @@ -862,14 +863,14 @@ name = "posthog" version = "7.16.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "backoff" }, - { name = "distro" }, - { name = "requests" }, - { name = "typing-extensions" }, + { name = "backoff" }, + { name = "distro" }, + { name = "requests" }, + { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/b4/4f/a954175c862a3565d02c3f627874d85f18313472a0c4b08f45d84aaf3315/posthog-7.16.1.tar.gz", hash = "sha256:3619d3c619ad01f36c6d465e084950882417c63021eb3cfacacb23f900ec52d4", size = 226343, upload-time = "2026-05-27T18:46:20.129Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/3e/28/0f840699a1d0db3c1e5483c6208f0804a51f21ccfa34e6aa356161606adc/posthog-7.16.1-py3-none-any.whl", hash = "sha256:fd5aa4510033f3b039fda2fbfce45f493d140d4782f681e69639793dda317d67", size = 264231, upload-time = "2026-05-27T18:46:17.933Z" }, + { url = "https://files.pythonhosted.org/packages/3e/28/0f840699a1d0db3c1e5483c6208f0804a51f21ccfa34e6aa356161606adc/posthog-7.16.1-py3-none-any.whl", hash = "sha256:fd5aa4510033f3b039fda2fbfce45f493d140d4782f681e69639793dda317d67", size = 264231, upload-time = "2026-05-27T18:46:17.933Z" }, ] [[package]] @@ -877,15 +878,15 @@ name = "pre-commit" version = "4.6.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "cfgv" }, - { name = "identify" }, - { name = "nodeenv" }, - { name = "pyyaml" }, - { name = "virtualenv" }, + { name = "cfgv" }, + { name = "identify" }, + { name = "nodeenv" }, + { name = "pyyaml" }, + { name = "virtualenv" }, ] sdist = { url = "https://files.pythonhosted.org/packages/8e/22/2de9408ac81acbb8a7d05d4cc064a152ccf33b3d480ebe0cd292153db239/pre_commit-4.6.0.tar.gz", hash = "sha256:718d2208cef53fdc38206e40524a6d4d9576d103eb16f0fec11c875e7716e9d9", size = 198525, upload-time = "2026-04-21T20:31:41.613Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/80/6e/4b28b62ecb6aae56769c34a8ff1d661473ec1e9519e2d5f8b2c150086b26/pre_commit-4.6.0-py2.py3-none-any.whl", hash = "sha256:e2cf246f7299edcabcf15f9b0571fdce06058527f0a06535068a86d38089f29b", size = 226472, upload-time = "2026-04-21T20:31:40.092Z" }, + { url = "https://files.pythonhosted.org/packages/80/6e/4b28b62ecb6aae56769c34a8ff1d661473ec1e9519e2d5f8b2c150086b26/pre_commit-4.6.0-py2.py3-none-any.whl", hash = "sha256:e2cf246f7299edcabcf15f9b0571fdce06058527f0a06535068a86d38089f29b", size = 226472, upload-time = "2026-04-21T20:31:40.092Z" }, ] [[package]] @@ -893,16 +894,16 @@ name = "psycopg" version = "3.3.4" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "tzdata", marker = "sys_platform == 'win32'" }, + { name = "tzdata", marker = "sys_platform == 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/db/2f/cb91e5502ec9de1de6f1b76cfbf69531932725361168bb06963620c77e2e/psycopg-3.3.4.tar.gz", hash = "sha256:e21207764952cff81b6b8bdacad9a3939f2793367fdac2987b3aac36a651b5bc", size = 165799, upload-time = "2026-05-01T23:31:55.179Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/5c/e0/7b3dee031daae7743609ce3c746565d4a3ed7c2c186479eb48e34e838c64/psycopg-3.3.4-py3-none-any.whl", hash = "sha256:b6bbc25ccf05c8fad3b061d9db2ef0909a555171b84b07f29458a447253d679a", size = 213001, upload-time = "2026-05-01T23:20:50.816Z" }, + { url = "https://files.pythonhosted.org/packages/5c/e0/7b3dee031daae7743609ce3c746565d4a3ed7c2c186479eb48e34e838c64/psycopg-3.3.4-py3-none-any.whl", hash = "sha256:b6bbc25ccf05c8fad3b061d9db2ef0909a555171b84b07f29458a447253d679a", size = 213001, upload-time = "2026-05-01T23:20:50.816Z" }, ] [package.optional-dependencies] binary = [ - { name = "psycopg-binary", marker = "implementation_name != 'pypy'" }, + { name = "psycopg-binary", marker = "implementation_name != 'pypy'" }, ] [[package]] @@ -910,28 +911,28 @@ name = "psycopg-binary" version = "3.3.4" source = { registry = "https://pypi.org/simple" } wheels = [ - { url = "https://files.pythonhosted.org/packages/09/43/13e9c406fbbf354580476e248a16b64802a376873ebe6339e30bb655572d/psycopg_binary-3.3.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:fbd1d4ed566895ad2d3bf4ddfd8bae90026930ddf29df3b9d91d32c8c47866a7", size = 4590377, upload-time = "2026-05-01T23:29:18.782Z" }, - { url = "https://files.pythonhosted.org/packages/22/be/2923cd7c3683e7afdecf4f10796a18de02f5c5ddc0969aa2ad0a8cdd3bbd/psycopg_binary-3.3.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:75a9067e236f9b9ae3535b66fe99bddb33d39c0de10112e49b9ab11eee53dc31", size = 4669023, upload-time = "2026-05-01T23:29:25.884Z" }, - { url = "https://files.pythonhosted.org/packages/96/a0/2c913d6fe13d6a8bd13597d36739bf47af063ad9399e402cfecab16f3c1e/psycopg_binary-3.3.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:b56b603ebcea8aa10b46228b8410ba7f13e7c2ee54389d4d9be0927fd8ce2a70", size = 5467423, upload-time = "2026-05-01T23:29:33.416Z" }, - { url = "https://files.pythonhosted.org/packages/e7/38/205d10bc1ad0df4a21c5c51659126bd3ea0ef98fcad1e852f78c249bb9c3/psycopg_binary-3.3.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c677c4ad433cb7150c8cd304a0769ae3bcfbe5ea0676eb53faa7b1443b16d0d3", size = 5151137, upload-time = "2026-05-01T23:29:42.013Z" }, - { url = "https://files.pythonhosted.org/packages/36/fc/f0381ddcd45eff3bb70dbca6823a996048d7f507b2ec3fc92c6fabc0fe87/psycopg_binary-3.3.4-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:26df2717e59c0473e4465a97dfb1b7afebaa479277870fd5784d1436470db47c", size = 6736671, upload-time = "2026-05-01T23:29:51.626Z" }, - { url = "https://files.pythonhosted.org/packages/95/40/fa545ae152c24327651e5624e4902121e808270be36c10b12e9939be09bc/psycopg_binary-3.3.4-cp313-cp313-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1dc1f79fd16bb1f3f4421417a514607539f17804d95c7ed617265369d1981cae", size = 4979601, upload-time = "2026-05-01T23:29:56.961Z" }, - { url = "https://files.pythonhosted.org/packages/86/e4/2f8a47ee97f90cd2b933d0463081d35631ff419de2b8c984a5f369857de0/psycopg_binary-3.3.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:136f199a407b5348b9b857c504aff60c77622a28482e7195839ce1b51238c4cc", size = 4510513, upload-time = "2026-05-01T23:30:07.243Z" }, - { url = "https://files.pythonhosted.org/packages/0e/0e/94e842ff4a7f98ed162580ca2e8b8864b28c1e0350f2443f8ee47f821167/psycopg_binary-3.3.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:b6f5a29e9c775b9f12a1a717aa7a2c80f9e1db6f27ba44a5b59c80ac61d2ffcf", size = 4187243, upload-time = "2026-05-01T23:30:15.352Z" }, - { url = "https://files.pythonhosted.org/packages/d0/83/fc6c174b672e29b7de996ea77b6cbddf46c891751c3355f6974292baa6b4/psycopg_binary-3.3.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:ee17a2cf4943cde261adfad1bbc5bf38d6b3776d7afff74c7cabcbeaeb08c260", size = 3927347, upload-time = "2026-05-01T23:30:21.186Z" }, - { url = "https://files.pythonhosted.org/packages/e9/65/768364d4a97a15b1a7f47ba52688c1686f22941d8332a8398cefc468e25f/psycopg_binary-3.3.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5c4ab71be17bdca30cb34c34c4e1496e2f5d6f20c199c12bad226070b22ef9bf", size = 4236393, upload-time = "2026-05-01T23:30:26.211Z" }, - { url = "https://files.pythonhosted.org/packages/bd/3b/218efbc9e645becd80cdf651acda05f85cfe546b7a9c0458c7cbc8fe1f74/psycopg_binary-3.3.4-cp313-cp313-win_amd64.whl", hash = "sha256:dbfdb9b6cc79f31104a7b162a2b921b765fcc62af6c00540a167a8de47e4ed38", size = 3564592, upload-time = "2026-05-01T23:30:31.764Z" }, - { url = "https://files.pythonhosted.org/packages/48/a6/828c9185701dab71b234c2a76c38a08b098ebfec5020716b4e93807492b5/psycopg_binary-3.3.4-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:28b7398fdd19db3232c884fb24550bdfe951221f510e195e233299e4c9b78f97", size = 4607292, upload-time = "2026-05-01T23:30:38.962Z" }, - { url = "https://files.pythonhosted.org/packages/92/58/5b40dbc9d839045c9dae956960e4fb6d20bcabe6c59a2aa34fc3a371913f/psycopg_binary-3.3.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1fbaa292a3c8bb61b45df1ad3da1908ccee7cb889db9425e3557d9e34e2a4829", size = 4687023, upload-time = "2026-05-01T23:30:47.227Z" }, - { url = "https://files.pythonhosted.org/packages/85/a9/793f0ac107a9003b48441d0d1f9f616d96e0f37458dd8dc12528ceff55fb/psycopg_binary-3.3.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:94596f9e7633ee3f6440711d43bb70aa31cc0a46a900ab8b4201a366ace5c9e7", size = 5486985, upload-time = "2026-05-01T23:30:55.517Z" }, - { url = "https://files.pythonhosted.org/packages/8f/26/42e8533497e2592334f68ec529cf5f840f7fa4e99575a4bb61aa184dbfbf/psycopg_binary-3.3.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8c0056529e68dbe9184cd4019a1f3d8f3a4ead2f6fc7a5afcf27d3314edd1277", size = 5168745, upload-time = "2026-05-01T23:31:01.904Z" }, - { url = "https://files.pythonhosted.org/packages/15/af/b7151776cc08d5935d45c833ec818a9beb417cf7c08239af1aafbdae78ee/psycopg_binary-3.3.4-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2c09aad7051326e7603c14e50636db9c01f78272dc54b3accff03d46370461e6", size = 6761486, upload-time = "2026-05-01T23:31:14.511Z" }, - { url = "https://files.pythonhosted.org/packages/d0/ed/c92533b9124712d592cbf1cd6c76da933a2e0acea81dfe1fbe7e735f0cff/psycopg_binary-3.3.4-cp314-cp314-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:514404ed543efd620c85602b747df2a23cf1241b4067199e1a66f2d2757aaa41", size = 4997427, upload-time = "2026-05-01T23:31:20.901Z" }, - { url = "https://files.pythonhosted.org/packages/a2/23/ccadfd0de416aa188356daa199453af24087b042e296088706d190ae0295/psycopg_binary-3.3.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:46893c26858be12cc49ca4226ed6a60b4bfccadd946b3bebb783a60b38788228", size = 4533549, upload-time = "2026-05-01T23:31:26.204Z" }, - { url = "https://files.pythonhosted.org/packages/fd/a0/c8f43cee36386f7bc891ab41a9d31ea07cf9826038e732da79f26b1e5f34/psycopg_binary-3.3.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:df1d567fc430f6df15c9fcf67d87685fc49bdb325adc0db5af1adfb2f44eb5c9", size = 4210256, upload-time = "2026-05-01T23:31:33.884Z" }, - { url = "https://files.pythonhosted.org/packages/4e/2c/c1547871be3790676e8868b38655496422f94f0978dfb66b74bdba2f1676/psycopg_binary-3.3.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:6b9016b1714da4dd5ecaaa75b82098aa5a0b87854ce9b092e21c27c4ae23e014", size = 3946204, upload-time = "2026-05-01T23:31:39.626Z" }, - { url = "https://files.pythonhosted.org/packages/c4/b1/f6670f00fa7ea601584623f6c11602ab92117d83eaff885e0210f6de7418/psycopg_binary-3.3.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:47c656a8a7ba6eb0cff1801a4caaa9c8bdc12d03080e273aff1c8ac39971a77e", size = 4255811, upload-time = "2026-05-01T23:31:44.986Z" }, - { url = "https://files.pythonhosted.org/packages/eb/e6/5fff07a70d1f945ed90ae131c3bd76cab32beff7c58c6db15ad5820b6d1f/psycopg_binary-3.3.4-cp314-cp314-win_amd64.whl", hash = "sha256:c37e024c07308cd06cf3ec51bfd0e7f6157585a4d84d1bce4a7f5f7913719bf8", size = 3666849, upload-time = "2026-05-01T23:31:51.165Z" }, + { url = "https://files.pythonhosted.org/packages/09/43/13e9c406fbbf354580476e248a16b64802a376873ebe6339e30bb655572d/psycopg_binary-3.3.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:fbd1d4ed566895ad2d3bf4ddfd8bae90026930ddf29df3b9d91d32c8c47866a7", size = 4590377, upload-time = "2026-05-01T23:29:18.782Z" }, + { url = "https://files.pythonhosted.org/packages/22/be/2923cd7c3683e7afdecf4f10796a18de02f5c5ddc0969aa2ad0a8cdd3bbd/psycopg_binary-3.3.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:75a9067e236f9b9ae3535b66fe99bddb33d39c0de10112e49b9ab11eee53dc31", size = 4669023, upload-time = "2026-05-01T23:29:25.884Z" }, + { url = "https://files.pythonhosted.org/packages/96/a0/2c913d6fe13d6a8bd13597d36739bf47af063ad9399e402cfecab16f3c1e/psycopg_binary-3.3.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:b56b603ebcea8aa10b46228b8410ba7f13e7c2ee54389d4d9be0927fd8ce2a70", size = 5467423, upload-time = "2026-05-01T23:29:33.416Z" }, + { url = "https://files.pythonhosted.org/packages/e7/38/205d10bc1ad0df4a21c5c51659126bd3ea0ef98fcad1e852f78c249bb9c3/psycopg_binary-3.3.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c677c4ad433cb7150c8cd304a0769ae3bcfbe5ea0676eb53faa7b1443b16d0d3", size = 5151137, upload-time = "2026-05-01T23:29:42.013Z" }, + { url = "https://files.pythonhosted.org/packages/36/fc/f0381ddcd45eff3bb70dbca6823a996048d7f507b2ec3fc92c6fabc0fe87/psycopg_binary-3.3.4-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:26df2717e59c0473e4465a97dfb1b7afebaa479277870fd5784d1436470db47c", size = 6736671, upload-time = "2026-05-01T23:29:51.626Z" }, + { url = "https://files.pythonhosted.org/packages/95/40/fa545ae152c24327651e5624e4902121e808270be36c10b12e9939be09bc/psycopg_binary-3.3.4-cp313-cp313-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1dc1f79fd16bb1f3f4421417a514607539f17804d95c7ed617265369d1981cae", size = 4979601, upload-time = "2026-05-01T23:29:56.961Z" }, + { url = "https://files.pythonhosted.org/packages/86/e4/2f8a47ee97f90cd2b933d0463081d35631ff419de2b8c984a5f369857de0/psycopg_binary-3.3.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:136f199a407b5348b9b857c504aff60c77622a28482e7195839ce1b51238c4cc", size = 4510513, upload-time = "2026-05-01T23:30:07.243Z" }, + { url = "https://files.pythonhosted.org/packages/0e/0e/94e842ff4a7f98ed162580ca2e8b8864b28c1e0350f2443f8ee47f821167/psycopg_binary-3.3.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:b6f5a29e9c775b9f12a1a717aa7a2c80f9e1db6f27ba44a5b59c80ac61d2ffcf", size = 4187243, upload-time = "2026-05-01T23:30:15.352Z" }, + { url = "https://files.pythonhosted.org/packages/d0/83/fc6c174b672e29b7de996ea77b6cbddf46c891751c3355f6974292baa6b4/psycopg_binary-3.3.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:ee17a2cf4943cde261adfad1bbc5bf38d6b3776d7afff74c7cabcbeaeb08c260", size = 3927347, upload-time = "2026-05-01T23:30:21.186Z" }, + { url = "https://files.pythonhosted.org/packages/e9/65/768364d4a97a15b1a7f47ba52688c1686f22941d8332a8398cefc468e25f/psycopg_binary-3.3.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5c4ab71be17bdca30cb34c34c4e1496e2f5d6f20c199c12bad226070b22ef9bf", size = 4236393, upload-time = "2026-05-01T23:30:26.211Z" }, + { url = "https://files.pythonhosted.org/packages/bd/3b/218efbc9e645becd80cdf651acda05f85cfe546b7a9c0458c7cbc8fe1f74/psycopg_binary-3.3.4-cp313-cp313-win_amd64.whl", hash = "sha256:dbfdb9b6cc79f31104a7b162a2b921b765fcc62af6c00540a167a8de47e4ed38", size = 3564592, upload-time = "2026-05-01T23:30:31.764Z" }, + { url = "https://files.pythonhosted.org/packages/48/a6/828c9185701dab71b234c2a76c38a08b098ebfec5020716b4e93807492b5/psycopg_binary-3.3.4-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:28b7398fdd19db3232c884fb24550bdfe951221f510e195e233299e4c9b78f97", size = 4607292, upload-time = "2026-05-01T23:30:38.962Z" }, + { url = "https://files.pythonhosted.org/packages/92/58/5b40dbc9d839045c9dae956960e4fb6d20bcabe6c59a2aa34fc3a371913f/psycopg_binary-3.3.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1fbaa292a3c8bb61b45df1ad3da1908ccee7cb889db9425e3557d9e34e2a4829", size = 4687023, upload-time = "2026-05-01T23:30:47.227Z" }, + { url = "https://files.pythonhosted.org/packages/85/a9/793f0ac107a9003b48441d0d1f9f616d96e0f37458dd8dc12528ceff55fb/psycopg_binary-3.3.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:94596f9e7633ee3f6440711d43bb70aa31cc0a46a900ab8b4201a366ace5c9e7", size = 5486985, upload-time = "2026-05-01T23:30:55.517Z" }, + { url = "https://files.pythonhosted.org/packages/8f/26/42e8533497e2592334f68ec529cf5f840f7fa4e99575a4bb61aa184dbfbf/psycopg_binary-3.3.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8c0056529e68dbe9184cd4019a1f3d8f3a4ead2f6fc7a5afcf27d3314edd1277", size = 5168745, upload-time = "2026-05-01T23:31:01.904Z" }, + { url = "https://files.pythonhosted.org/packages/15/af/b7151776cc08d5935d45c833ec818a9beb417cf7c08239af1aafbdae78ee/psycopg_binary-3.3.4-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2c09aad7051326e7603c14e50636db9c01f78272dc54b3accff03d46370461e6", size = 6761486, upload-time = "2026-05-01T23:31:14.511Z" }, + { url = "https://files.pythonhosted.org/packages/d0/ed/c92533b9124712d592cbf1cd6c76da933a2e0acea81dfe1fbe7e735f0cff/psycopg_binary-3.3.4-cp314-cp314-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:514404ed543efd620c85602b747df2a23cf1241b4067199e1a66f2d2757aaa41", size = 4997427, upload-time = "2026-05-01T23:31:20.901Z" }, + { url = "https://files.pythonhosted.org/packages/a2/23/ccadfd0de416aa188356daa199453af24087b042e296088706d190ae0295/psycopg_binary-3.3.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:46893c26858be12cc49ca4226ed6a60b4bfccadd946b3bebb783a60b38788228", size = 4533549, upload-time = "2026-05-01T23:31:26.204Z" }, + { url = "https://files.pythonhosted.org/packages/fd/a0/c8f43cee36386f7bc891ab41a9d31ea07cf9826038e732da79f26b1e5f34/psycopg_binary-3.3.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:df1d567fc430f6df15c9fcf67d87685fc49bdb325adc0db5af1adfb2f44eb5c9", size = 4210256, upload-time = "2026-05-01T23:31:33.884Z" }, + { url = "https://files.pythonhosted.org/packages/4e/2c/c1547871be3790676e8868b38655496422f94f0978dfb66b74bdba2f1676/psycopg_binary-3.3.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:6b9016b1714da4dd5ecaaa75b82098aa5a0b87854ce9b092e21c27c4ae23e014", size = 3946204, upload-time = "2026-05-01T23:31:39.626Z" }, + { url = "https://files.pythonhosted.org/packages/c4/b1/f6670f00fa7ea601584623f6c11602ab92117d83eaff885e0210f6de7418/psycopg_binary-3.3.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:47c656a8a7ba6eb0cff1801a4caaa9c8bdc12d03080e273aff1c8ac39971a77e", size = 4255811, upload-time = "2026-05-01T23:31:44.986Z" }, + { url = "https://files.pythonhosted.org/packages/eb/e6/5fff07a70d1f945ed90ae131c3bd76cab32beff7c58c6db15ad5820b6d1f/psycopg_binary-3.3.4-cp314-cp314-win_amd64.whl", hash = "sha256:c37e024c07308cd06cf3ec51bfd0e7f6157585a4d84d1bce4a7f5f7913719bf8", size = 3666849, upload-time = "2026-05-01T23:31:51.165Z" }, ] [[package]] @@ -939,14 +940,14 @@ name = "pydantic" version = "2.13.4" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "annotated-types" }, - { name = "pydantic-core" }, - { name = "typing-extensions" }, - { name = "typing-inspection" }, + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, ] sdist = { url = "https://files.pythonhosted.org/packages/18/a5/b60d21ac674192f8ab0ba4e9fd860690f9b4a6e51ca5df118733b487d8d6/pydantic-2.13.4.tar.gz", hash = "sha256:c40756b57adaa8b1efeeced5c196f3f3b7c435f90e84ea7f443901bec8099ef6", size = 844775, upload-time = "2026-05-06T13:43:05.343Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/fd/7b/122376b1fd3c62c1ed9dc80c931ace4844b3c55407b6fb2d199377c9736f/pydantic-2.13.4-py3-none-any.whl", hash = "sha256:45a282cde31d808236fd7ea9d919b128653c8b38b393d1c4ab335c62924d9aba", size = 472262, upload-time = "2026-05-06T13:43:02.641Z" }, + { url = "https://files.pythonhosted.org/packages/fd/7b/122376b1fd3c62c1ed9dc80c931ace4844b3c55407b6fb2d199377c9736f/pydantic-2.13.4-py3-none-any.whl", hash = "sha256:45a282cde31d808236fd7ea9d919b128653c8b38b393d1c4ab335c62924d9aba", size = 472262, upload-time = "2026-05-06T13:43:02.641Z" }, ] [[package]] @@ -954,55 +955,55 @@ name = "pydantic-core" version = "2.46.4" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "typing-extensions" }, + { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/9d/56/921726b776ace8d8f5db44c4ef961006580d91dc52b803c489fafd1aa249/pydantic_core-2.46.4.tar.gz", hash = "sha256:62f875393d7f270851f20523dd2e29f082bcc82292d66db2b64ea71f64b6e1c1", size = 471464, upload-time = "2026-05-06T13:37:06.98Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/51/a2/5d30b469c5267a17b39dec53208222f76a8d351dfac4af661888c5aee77d/pydantic_core-2.46.4-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:5d5902252db0d3cedf8d4a1bc68f70eeb430f7e4c7104c8c476753519b423008", size = 2106306, upload-time = "2026-05-06T13:37:48.029Z" }, - { url = "https://files.pythonhosted.org/packages/c1/81/4fa520eaffa8bd7d1525e644cd6d39e7d60b1592bc5b516693c7340b50f1/pydantic_core-2.46.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c94f0688e7b8d0a67abf40e57a7eaaecd17cc9586706a31b76c031f63df052b4", size = 1951906, upload-time = "2026-05-06T13:37:17.012Z" }, - { url = "https://files.pythonhosted.org/packages/03/d5/fd02da45b659668b05923b17ba3a0100a0a3d5541e3bd8fcc4ecb711309e/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f027324c56cd5406ca49c124b0db10e56c69064fec039acc571c29020cc87c76", size = 1976802, upload-time = "2026-05-06T13:37:35.113Z" }, - { url = "https://files.pythonhosted.org/packages/21/f2/95727e1368be3d3ed485eaab7adbd7dda408f33f7a36e8b48e0144002b91/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e739fee756ba1010f8bcccb534252e85a35fe45ae92c295a06059ce58b74ccd3", size = 2052446, upload-time = "2026-05-06T13:37:12.313Z" }, - { url = "https://files.pythonhosted.org/packages/9c/86/5d99feea3f77c7234b8718075b23db11532773c1a0dbd9b9490215dc2eeb/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9d56801be94b86a9da183e5f3766e6310752b99ff647e38b09a9500d88e46e76", size = 2232757, upload-time = "2026-05-06T13:39:01.149Z" }, - { url = "https://files.pythonhosted.org/packages/d2/3a/508ac615935ef7588cf6d9e9b91309fdc2da751af865e02a9098de88258c/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2412e734dcb48da14d4e4006b82b46b74f2518b8a26ee7e58c6844a6cd6d03c4", size = 2309275, upload-time = "2026-05-06T13:37:41.406Z" }, - { url = "https://files.pythonhosted.org/packages/07/f8/41db9de19d7987d6b04715a02b3b40aea467000275d9d758ffaa31af7d50/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9551187363ffc0de2a00b2e47c25aeaeb1020b69b668762966df15fc5659dd5a", size = 2094467, upload-time = "2026-05-06T13:39:18.847Z" }, - { url = "https://files.pythonhosted.org/packages/2c/e2/f35033184cb11d0052daf4416e8e10a502ea2ac006fc4f459aee872727d1/pydantic_core-2.46.4-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:0186750b482eefa11d7f435892b09c5c606193ef3375bcf94aa00ae6bfb66262", size = 2134417, upload-time = "2026-05-06T13:40:17.944Z" }, - { url = "https://files.pythonhosted.org/packages/7e/7b/6ceeb1cc90e193862f444ebe373d8fdf613f0a82572dde03fb10734c6c71/pydantic_core-2.46.4-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5855698a4856556d86e8e6cd8434bc3ac0314ee8e12089ae0e143f64c6256e4e", size = 2179782, upload-time = "2026-05-06T13:40:32.618Z" }, - { url = "https://files.pythonhosted.org/packages/5a/f2/c8d7773ede6af08036423a00ae0ceffce266c3c52a096c435d68c896083f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:cbaf13819775b7f769bf4a1f066cb6df7a28d4480081a589828ef190226881cd", size = 2188782, upload-time = "2026-05-06T13:36:51.018Z" }, - { url = "https://files.pythonhosted.org/packages/59/31/0c864784e31f09f05cdd87606f08923b9c9e7f6e51dd27f20f62f975ce9f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:633147d34cf4550417f12e2b1a0383973bdf5cdfde212cb09e9a581cf10820be", size = 2328334, upload-time = "2026-05-06T13:40:37.764Z" }, - { url = "https://files.pythonhosted.org/packages/c2/eb/4f6c8a41efa30baa755590f4141abf3a8c370fab610915733e74134a7270/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:82cf5301172168103724d49a1444d3378cb20cdee30b116a1bd6031236298a5d", size = 2372986, upload-time = "2026-05-06T13:39:34.152Z" }, - { url = "https://files.pythonhosted.org/packages/5b/24/b375a480d53113860c299764bfe9f349a3dc9108b3adc0d7f0d786492ebf/pydantic_core-2.46.4-cp313-cp313-win32.whl", hash = "sha256:9fa8ae11da9e2b3126c6426f147e0fba88d96d65921799bb30c6abd1cb2c97fb", size = 1973693, upload-time = "2026-05-06T13:37:55.072Z" }, - { url = "https://files.pythonhosted.org/packages/7e/e8/cff247591966f2d22ec8c003cd7587e27b7ba7b81ab2fb888e3ab75dc285/pydantic_core-2.46.4-cp313-cp313-win_amd64.whl", hash = "sha256:6b3ace8194b0e5204818c92802dcdca7fc6d88aabbb799d7c795540d9cd6d292", size = 2071819, upload-time = "2026-05-06T13:38:49.139Z" }, - { url = "https://files.pythonhosted.org/packages/c6/1a/f4aee670d5670e9e148e0c82c7db98d780be566c6e6a97ee8035528ca0b3/pydantic_core-2.46.4-cp313-cp313-win_arm64.whl", hash = "sha256:184c081504d17f1c1066e430e117142b2c77d9448a97f7b65c6ac9fd9aee238d", size = 2027411, upload-time = "2026-05-06T13:40:45.796Z" }, - { url = "https://files.pythonhosted.org/packages/8d/74/228a26ddad29c6672b805d9fd78e8d251cd04004fa7eed0e622096cd0250/pydantic_core-2.46.4-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:428e04521a40150c85216fc8b85e8d39fece235a9cf5e383761238c7fa9b96fb", size = 2102079, upload-time = "2026-05-06T13:38:41.019Z" }, - { url = "https://files.pythonhosted.org/packages/ad/1f/8970b150a4b4365623ae00fc88603491f763c627311ae8031e3111356d6e/pydantic_core-2.46.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:23ace664830ee0bfe014a0c7bc248b1f7f25ed7ad103852c317624a1083af462", size = 1952179, upload-time = "2026-05-06T13:36:59.812Z" }, - { url = "https://files.pythonhosted.org/packages/95/30/5211a831ae054928054b2f79731661087a2bc5c01e825c672b3a4a8f1b3e/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce5c1d2a8b27468f433ca974829c44060b8097eedc39933e3c206a90ee49c4a9", size = 1978926, upload-time = "2026-05-06T13:37:39.933Z" }, - { url = "https://files.pythonhosted.org/packages/57/e9/689668733b1eb67adeef047db3c2e8788fcf65a7fd9c9e2b46b7744fe245/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7283d57845ecf5a163403eb0702dfc220cc4fbdd18919cb5ccea4f95ee1cdab4", size = 2046785, upload-time = "2026-05-06T13:38:01.995Z" }, - { url = "https://files.pythonhosted.org/packages/60/d9/6715260422ff50a2109878fd24d948a6c3446bb2664f34ee78cd972b3acd/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8daafc69c93ee8a0204506a3b6b30f586ef54028f52aeeeb5c4cfc5184fd5914", size = 2228733, upload-time = "2026-05-06T13:40:50.371Z" }, - { url = "https://files.pythonhosted.org/packages/18/ae/fdb2f64316afca925640f8e70bb1a564b0ec2721c1389e25b8eb4bf9a299/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cd2213145bcc2ba85884d0ac63d222fece9209678f77b9b4d76f054c561adb28", size = 2307534, upload-time = "2026-05-06T13:37:21.531Z" }, - { url = "https://files.pythonhosted.org/packages/89/1d/8eff589b45bb8190a9d12c49cfad0f176a5cbd1534908a6b5125e2886239/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7a5f930472650a82629163023e630d160863fce524c616f4e5186e5de9d9a49b", size = 2099732, upload-time = "2026-05-06T13:39:31.942Z" }, - { url = "https://files.pythonhosted.org/packages/06/d5/ee5a3366637fee41dee51a1fc91562dcf12ddbc68fda34e6b253da2324bb/pydantic_core-2.46.4-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:c1b3f518abeca3aa13c712fd202306e145abf59a18b094a6bafb2d2bbf59192c", size = 2129627, upload-time = "2026-05-06T13:37:25.033Z" }, - { url = "https://files.pythonhosted.org/packages/94/33/2414be571d2c6a6c4d08be21f9292b6d3fdb08949a97b6dfe985017821db/pydantic_core-2.46.4-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1a7dd0b3ee80d90150e3495a3a13ac34dbcbfd4f012996a6a1d8900e91b5c0fb", size = 2179141, upload-time = "2026-05-06T13:37:14.046Z" }, - { url = "https://files.pythonhosted.org/packages/7b/79/7daa95be995be0eecc4cf75064cb33f9bbbfe3fe0158caf2f0d4a996a5c7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:3fb702cd90b0446a3a1c5e470bfa0dd23c0233b676a9099ddcc964fa6ca13898", size = 2184325, upload-time = "2026-05-06T13:36:53.615Z" }, - { url = "https://files.pythonhosted.org/packages/9f/cb/d0a382f5c0de8a222dc61c65348e0ce831b1f68e0a018450d31c2cace3a5/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:b8458003118a712e66286df6a707db01c52c0f52f7db8e4a38f0da1d3b94fc4e", size = 2323990, upload-time = "2026-05-06T13:40:29.971Z" }, - { url = "https://files.pythonhosted.org/packages/05/db/d9ba624cc4a5aced1598e88c04fdbd8310c8a69b9d38b9a3d39ce3a61ed7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:372429a130e469c9cd698925ce5fc50940b7a1336b0d82038e63d5bbc4edc519", size = 2369978, upload-time = "2026-05-06T13:37:23.027Z" }, - { url = "https://files.pythonhosted.org/packages/f2/20/d15df15ba918c423461905802bfd2981c3af0bfa0e40d05e13edbfa48bc3/pydantic_core-2.46.4-cp314-cp314-win32.whl", hash = "sha256:85bb3611ff1802f3ee7fdd7dbff26b56f343fb432d57a4728fdd49b6ef35e2f4", size = 1966354, upload-time = "2026-05-06T13:38:03.499Z" }, - { url = "https://files.pythonhosted.org/packages/fc/b6/6b8de4c0a7d7ab3004c439c80c5c1e0a3e8d78bbae19379b01960383d9e5/pydantic_core-2.46.4-cp314-cp314-win_amd64.whl", hash = "sha256:811ff8e9c313ab425368bcbb36e5c4ebd7108c2bbf4e4089cfbb0b01eff63fac", size = 2072238, upload-time = "2026-05-06T13:39:40.807Z" }, - { url = "https://files.pythonhosted.org/packages/32/36/51eb763beec1f4cf59b1db243a7dcc39cbb41230f050a09b9d69faaf0a48/pydantic_core-2.46.4-cp314-cp314-win_arm64.whl", hash = "sha256:bfec22eab3c8cc2ceec0248aec886624116dc079afa027ecc8ad4a7e62010f8a", size = 2018251, upload-time = "2026-05-06T13:37:26.72Z" }, - { url = "https://files.pythonhosted.org/packages/e8/91/855af51d625b23aa987116a19e231d2aaef9c4a415273ddc189b79a45fee/pydantic_core-2.46.4-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:af8244b2bef6aaad6d92cda81372de7f8c8d36c9f0c3ea36e827c60e7d9467a0", size = 2099593, upload-time = "2026-05-06T13:39:47.682Z" }, - { url = "https://files.pythonhosted.org/packages/fb/1b/8784a54c65edb5f49f0a14d6977cf1b209bba85a4c77445b255c2de58ab3/pydantic_core-2.46.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5a4330cdbc57162e4b3aa303f588ba752257694c9c9be3e7ebb11b4aca659b5d", size = 1935226, upload-time = "2026-05-06T13:40:40.428Z" }, - { url = "https://files.pythonhosted.org/packages/e8/e7/1955d28d1afc56dd4b3ad7cc0cf39df1b9852964cf16e5d13912756d6d6b/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:29c61fc04a3d840155ff08e475a04809278972fe6aef51e2720554e96367e34b", size = 1974605, upload-time = "2026-05-06T13:37:32.029Z" }, - { url = "https://files.pythonhosted.org/packages/93/e2/3fedbf0ba7a22850e6e9fd78117f1c0f10f950182344d8a6c535d468fdd8/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c50f2528cf200c5eed56faf3f4e22fcd5f38c157a8b78576e6ba3168ec35f000", size = 2030777, upload-time = "2026-05-06T13:38:55.239Z" }, - { url = "https://files.pythonhosted.org/packages/f8/61/46be275fcaaba0b4f5b9669dd852267ce1ff616592dccf7a7845588df091/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0cbe8b01f948de4286c74cdd6c667aceb38f5c1e26f0693b3983d9d74887c65e", size = 2236641, upload-time = "2026-05-06T13:37:08.096Z" }, - { url = "https://files.pythonhosted.org/packages/60/db/12e93e46a8bac9988be3c016860f83293daea8c716c029c9ace279036f2f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:617d7e2ca7dcb8c5cf6bcb8c59b8832c94b36196bbf1cbd1bfb56ed341905edd", size = 2286404, upload-time = "2026-05-06T13:40:20.221Z" }, - { url = "https://files.pythonhosted.org/packages/e2/4a/4d8b19008f38d31c53b8219cfedc2e3d5de5fe99d90076b7e767de29274f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7027560ee92211647d0d34e3f7cd6f50da56399d26a9c8ad0da286d3869a53f3", size = 2109219, upload-time = "2026-05-06T13:38:12.153Z" }, - { url = "https://files.pythonhosted.org/packages/88/70/3cbc40978fefb7bb09c6708d40d4ad1a5d70fd7213c3d17f971de868ec1f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:f99626688942fb746e545232e7726926f3be91b5975f8b55327665fafda991c7", size = 2110594, upload-time = "2026-05-06T13:40:02.971Z" }, - { url = "https://files.pythonhosted.org/packages/9d/20/b8d36736216e29491125531685b2f9e61aa5b4b2599893f8268551da3338/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fc3e9034a63de20e15e8ade85358bc6efc614008cab72898b4b4952bea0509ff", size = 2159542, upload-time = "2026-05-06T13:39:27.506Z" }, - { url = "https://files.pythonhosted.org/packages/1d/a2/367df868eb584dacf6bf82a389272406d7178e301c4ac82545ab98bc2dd9/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:97e7cf2be5c77b7d1a9713a05605d49460d02c6078d38d8bef3cbe323c548424", size = 2168146, upload-time = "2026-05-06T13:38:31.93Z" }, - { url = "https://files.pythonhosted.org/packages/c1/b8/4460f77f7e201893f649a29ab355dddd3beee8a97bcb1a320db414f9a06e/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:3bf92c5d0e00fefaab325a4d27828fe6b6e2a21848686b5b60d2d9eeb09d76c6", size = 2306309, upload-time = "2026-05-06T13:37:44.717Z" }, - { url = "https://files.pythonhosted.org/packages/64/c4/be2639293acd87dc8ddbcec41a73cee9b2ebf996fe6d892a1a74e88ad3f7/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:3ecbc122d18468d06ca279dc26a8c2e2d5acb10943bb35e36ae92096dc3b5565", size = 2369736, upload-time = "2026-05-06T13:37:05.645Z" }, - { url = "https://files.pythonhosted.org/packages/30/a6/9f9f380dbb301f67023bf8f707aaa75daadf84f7152d95c410fd7e81d994/pydantic_core-2.46.4-cp314-cp314t-win32.whl", hash = "sha256:e846ae7835bf0703ae43f534ab79a867146dadd59dc9ca5c8b53d5c8f7c9ef02", size = 1955575, upload-time = "2026-05-06T13:38:51.116Z" }, - { url = "https://files.pythonhosted.org/packages/40/1f/f1eb9eb350e795d1af8586289746f5c5677d16043040d63710e22abc43c9/pydantic_core-2.46.4-cp314-cp314t-win_amd64.whl", hash = "sha256:2108ba5c1c1eca18030634489dc544844144ee36357f2f9f780b93e7ddbb44b5", size = 2051624, upload-time = "2026-05-06T13:38:21.672Z" }, - { url = "https://files.pythonhosted.org/packages/f6/d2/42dd53d0a85c27606f316d3aa5d2869c4e8470a5ed6dec30e4a1abe19192/pydantic_core-2.46.4-cp314-cp314t-win_arm64.whl", hash = "sha256:4fcbe087dbc2068af7eda3aa87634eba216dbda64d1ae73c8684b621d33f6596", size = 2017325, upload-time = "2026-05-06T13:40:52.723Z" }, + { url = "https://files.pythonhosted.org/packages/51/a2/5d30b469c5267a17b39dec53208222f76a8d351dfac4af661888c5aee77d/pydantic_core-2.46.4-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:5d5902252db0d3cedf8d4a1bc68f70eeb430f7e4c7104c8c476753519b423008", size = 2106306, upload-time = "2026-05-06T13:37:48.029Z" }, + { url = "https://files.pythonhosted.org/packages/c1/81/4fa520eaffa8bd7d1525e644cd6d39e7d60b1592bc5b516693c7340b50f1/pydantic_core-2.46.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c94f0688e7b8d0a67abf40e57a7eaaecd17cc9586706a31b76c031f63df052b4", size = 1951906, upload-time = "2026-05-06T13:37:17.012Z" }, + { url = "https://files.pythonhosted.org/packages/03/d5/fd02da45b659668b05923b17ba3a0100a0a3d5541e3bd8fcc4ecb711309e/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f027324c56cd5406ca49c124b0db10e56c69064fec039acc571c29020cc87c76", size = 1976802, upload-time = "2026-05-06T13:37:35.113Z" }, + { url = "https://files.pythonhosted.org/packages/21/f2/95727e1368be3d3ed485eaab7adbd7dda408f33f7a36e8b48e0144002b91/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e739fee756ba1010f8bcccb534252e85a35fe45ae92c295a06059ce58b74ccd3", size = 2052446, upload-time = "2026-05-06T13:37:12.313Z" }, + { url = "https://files.pythonhosted.org/packages/9c/86/5d99feea3f77c7234b8718075b23db11532773c1a0dbd9b9490215dc2eeb/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9d56801be94b86a9da183e5f3766e6310752b99ff647e38b09a9500d88e46e76", size = 2232757, upload-time = "2026-05-06T13:39:01.149Z" }, + { url = "https://files.pythonhosted.org/packages/d2/3a/508ac615935ef7588cf6d9e9b91309fdc2da751af865e02a9098de88258c/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2412e734dcb48da14d4e4006b82b46b74f2518b8a26ee7e58c6844a6cd6d03c4", size = 2309275, upload-time = "2026-05-06T13:37:41.406Z" }, + { url = "https://files.pythonhosted.org/packages/07/f8/41db9de19d7987d6b04715a02b3b40aea467000275d9d758ffaa31af7d50/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9551187363ffc0de2a00b2e47c25aeaeb1020b69b668762966df15fc5659dd5a", size = 2094467, upload-time = "2026-05-06T13:39:18.847Z" }, + { url = "https://files.pythonhosted.org/packages/2c/e2/f35033184cb11d0052daf4416e8e10a502ea2ac006fc4f459aee872727d1/pydantic_core-2.46.4-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:0186750b482eefa11d7f435892b09c5c606193ef3375bcf94aa00ae6bfb66262", size = 2134417, upload-time = "2026-05-06T13:40:17.944Z" }, + { url = "https://files.pythonhosted.org/packages/7e/7b/6ceeb1cc90e193862f444ebe373d8fdf613f0a82572dde03fb10734c6c71/pydantic_core-2.46.4-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5855698a4856556d86e8e6cd8434bc3ac0314ee8e12089ae0e143f64c6256e4e", size = 2179782, upload-time = "2026-05-06T13:40:32.618Z" }, + { url = "https://files.pythonhosted.org/packages/5a/f2/c8d7773ede6af08036423a00ae0ceffce266c3c52a096c435d68c896083f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:cbaf13819775b7f769bf4a1f066cb6df7a28d4480081a589828ef190226881cd", size = 2188782, upload-time = "2026-05-06T13:36:51.018Z" }, + { url = "https://files.pythonhosted.org/packages/59/31/0c864784e31f09f05cdd87606f08923b9c9e7f6e51dd27f20f62f975ce9f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:633147d34cf4550417f12e2b1a0383973bdf5cdfde212cb09e9a581cf10820be", size = 2328334, upload-time = "2026-05-06T13:40:37.764Z" }, + { url = "https://files.pythonhosted.org/packages/c2/eb/4f6c8a41efa30baa755590f4141abf3a8c370fab610915733e74134a7270/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:82cf5301172168103724d49a1444d3378cb20cdee30b116a1bd6031236298a5d", size = 2372986, upload-time = "2026-05-06T13:39:34.152Z" }, + { url = "https://files.pythonhosted.org/packages/5b/24/b375a480d53113860c299764bfe9f349a3dc9108b3adc0d7f0d786492ebf/pydantic_core-2.46.4-cp313-cp313-win32.whl", hash = "sha256:9fa8ae11da9e2b3126c6426f147e0fba88d96d65921799bb30c6abd1cb2c97fb", size = 1973693, upload-time = "2026-05-06T13:37:55.072Z" }, + { url = "https://files.pythonhosted.org/packages/7e/e8/cff247591966f2d22ec8c003cd7587e27b7ba7b81ab2fb888e3ab75dc285/pydantic_core-2.46.4-cp313-cp313-win_amd64.whl", hash = "sha256:6b3ace8194b0e5204818c92802dcdca7fc6d88aabbb799d7c795540d9cd6d292", size = 2071819, upload-time = "2026-05-06T13:38:49.139Z" }, + { url = "https://files.pythonhosted.org/packages/c6/1a/f4aee670d5670e9e148e0c82c7db98d780be566c6e6a97ee8035528ca0b3/pydantic_core-2.46.4-cp313-cp313-win_arm64.whl", hash = "sha256:184c081504d17f1c1066e430e117142b2c77d9448a97f7b65c6ac9fd9aee238d", size = 2027411, upload-time = "2026-05-06T13:40:45.796Z" }, + { url = "https://files.pythonhosted.org/packages/8d/74/228a26ddad29c6672b805d9fd78e8d251cd04004fa7eed0e622096cd0250/pydantic_core-2.46.4-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:428e04521a40150c85216fc8b85e8d39fece235a9cf5e383761238c7fa9b96fb", size = 2102079, upload-time = "2026-05-06T13:38:41.019Z" }, + { url = "https://files.pythonhosted.org/packages/ad/1f/8970b150a4b4365623ae00fc88603491f763c627311ae8031e3111356d6e/pydantic_core-2.46.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:23ace664830ee0bfe014a0c7bc248b1f7f25ed7ad103852c317624a1083af462", size = 1952179, upload-time = "2026-05-06T13:36:59.812Z" }, + { url = "https://files.pythonhosted.org/packages/95/30/5211a831ae054928054b2f79731661087a2bc5c01e825c672b3a4a8f1b3e/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce5c1d2a8b27468f433ca974829c44060b8097eedc39933e3c206a90ee49c4a9", size = 1978926, upload-time = "2026-05-06T13:37:39.933Z" }, + { url = "https://files.pythonhosted.org/packages/57/e9/689668733b1eb67adeef047db3c2e8788fcf65a7fd9c9e2b46b7744fe245/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7283d57845ecf5a163403eb0702dfc220cc4fbdd18919cb5ccea4f95ee1cdab4", size = 2046785, upload-time = "2026-05-06T13:38:01.995Z" }, + { url = "https://files.pythonhosted.org/packages/60/d9/6715260422ff50a2109878fd24d948a6c3446bb2664f34ee78cd972b3acd/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8daafc69c93ee8a0204506a3b6b30f586ef54028f52aeeeb5c4cfc5184fd5914", size = 2228733, upload-time = "2026-05-06T13:40:50.371Z" }, + { url = "https://files.pythonhosted.org/packages/18/ae/fdb2f64316afca925640f8e70bb1a564b0ec2721c1389e25b8eb4bf9a299/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cd2213145bcc2ba85884d0ac63d222fece9209678f77b9b4d76f054c561adb28", size = 2307534, upload-time = "2026-05-06T13:37:21.531Z" }, + { url = "https://files.pythonhosted.org/packages/89/1d/8eff589b45bb8190a9d12c49cfad0f176a5cbd1534908a6b5125e2886239/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7a5f930472650a82629163023e630d160863fce524c616f4e5186e5de9d9a49b", size = 2099732, upload-time = "2026-05-06T13:39:31.942Z" }, + { url = "https://files.pythonhosted.org/packages/06/d5/ee5a3366637fee41dee51a1fc91562dcf12ddbc68fda34e6b253da2324bb/pydantic_core-2.46.4-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:c1b3f518abeca3aa13c712fd202306e145abf59a18b094a6bafb2d2bbf59192c", size = 2129627, upload-time = "2026-05-06T13:37:25.033Z" }, + { url = "https://files.pythonhosted.org/packages/94/33/2414be571d2c6a6c4d08be21f9292b6d3fdb08949a97b6dfe985017821db/pydantic_core-2.46.4-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1a7dd0b3ee80d90150e3495a3a13ac34dbcbfd4f012996a6a1d8900e91b5c0fb", size = 2179141, upload-time = "2026-05-06T13:37:14.046Z" }, + { url = "https://files.pythonhosted.org/packages/7b/79/7daa95be995be0eecc4cf75064cb33f9bbbfe3fe0158caf2f0d4a996a5c7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:3fb702cd90b0446a3a1c5e470bfa0dd23c0233b676a9099ddcc964fa6ca13898", size = 2184325, upload-time = "2026-05-06T13:36:53.615Z" }, + { url = "https://files.pythonhosted.org/packages/9f/cb/d0a382f5c0de8a222dc61c65348e0ce831b1f68e0a018450d31c2cace3a5/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:b8458003118a712e66286df6a707db01c52c0f52f7db8e4a38f0da1d3b94fc4e", size = 2323990, upload-time = "2026-05-06T13:40:29.971Z" }, + { url = "https://files.pythonhosted.org/packages/05/db/d9ba624cc4a5aced1598e88c04fdbd8310c8a69b9d38b9a3d39ce3a61ed7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:372429a130e469c9cd698925ce5fc50940b7a1336b0d82038e63d5bbc4edc519", size = 2369978, upload-time = "2026-05-06T13:37:23.027Z" }, + { url = "https://files.pythonhosted.org/packages/f2/20/d15df15ba918c423461905802bfd2981c3af0bfa0e40d05e13edbfa48bc3/pydantic_core-2.46.4-cp314-cp314-win32.whl", hash = "sha256:85bb3611ff1802f3ee7fdd7dbff26b56f343fb432d57a4728fdd49b6ef35e2f4", size = 1966354, upload-time = "2026-05-06T13:38:03.499Z" }, + { url = "https://files.pythonhosted.org/packages/fc/b6/6b8de4c0a7d7ab3004c439c80c5c1e0a3e8d78bbae19379b01960383d9e5/pydantic_core-2.46.4-cp314-cp314-win_amd64.whl", hash = "sha256:811ff8e9c313ab425368bcbb36e5c4ebd7108c2bbf4e4089cfbb0b01eff63fac", size = 2072238, upload-time = "2026-05-06T13:39:40.807Z" }, + { url = "https://files.pythonhosted.org/packages/32/36/51eb763beec1f4cf59b1db243a7dcc39cbb41230f050a09b9d69faaf0a48/pydantic_core-2.46.4-cp314-cp314-win_arm64.whl", hash = "sha256:bfec22eab3c8cc2ceec0248aec886624116dc079afa027ecc8ad4a7e62010f8a", size = 2018251, upload-time = "2026-05-06T13:37:26.72Z" }, + { url = "https://files.pythonhosted.org/packages/e8/91/855af51d625b23aa987116a19e231d2aaef9c4a415273ddc189b79a45fee/pydantic_core-2.46.4-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:af8244b2bef6aaad6d92cda81372de7f8c8d36c9f0c3ea36e827c60e7d9467a0", size = 2099593, upload-time = "2026-05-06T13:39:47.682Z" }, + { url = "https://files.pythonhosted.org/packages/fb/1b/8784a54c65edb5f49f0a14d6977cf1b209bba85a4c77445b255c2de58ab3/pydantic_core-2.46.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5a4330cdbc57162e4b3aa303f588ba752257694c9c9be3e7ebb11b4aca659b5d", size = 1935226, upload-time = "2026-05-06T13:40:40.428Z" }, + { url = "https://files.pythonhosted.org/packages/e8/e7/1955d28d1afc56dd4b3ad7cc0cf39df1b9852964cf16e5d13912756d6d6b/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:29c61fc04a3d840155ff08e475a04809278972fe6aef51e2720554e96367e34b", size = 1974605, upload-time = "2026-05-06T13:37:32.029Z" }, + { url = "https://files.pythonhosted.org/packages/93/e2/3fedbf0ba7a22850e6e9fd78117f1c0f10f950182344d8a6c535d468fdd8/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c50f2528cf200c5eed56faf3f4e22fcd5f38c157a8b78576e6ba3168ec35f000", size = 2030777, upload-time = "2026-05-06T13:38:55.239Z" }, + { url = "https://files.pythonhosted.org/packages/f8/61/46be275fcaaba0b4f5b9669dd852267ce1ff616592dccf7a7845588df091/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0cbe8b01f948de4286c74cdd6c667aceb38f5c1e26f0693b3983d9d74887c65e", size = 2236641, upload-time = "2026-05-06T13:37:08.096Z" }, + { url = "https://files.pythonhosted.org/packages/60/db/12e93e46a8bac9988be3c016860f83293daea8c716c029c9ace279036f2f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:617d7e2ca7dcb8c5cf6bcb8c59b8832c94b36196bbf1cbd1bfb56ed341905edd", size = 2286404, upload-time = "2026-05-06T13:40:20.221Z" }, + { url = "https://files.pythonhosted.org/packages/e2/4a/4d8b19008f38d31c53b8219cfedc2e3d5de5fe99d90076b7e767de29274f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7027560ee92211647d0d34e3f7cd6f50da56399d26a9c8ad0da286d3869a53f3", size = 2109219, upload-time = "2026-05-06T13:38:12.153Z" }, + { url = "https://files.pythonhosted.org/packages/88/70/3cbc40978fefb7bb09c6708d40d4ad1a5d70fd7213c3d17f971de868ec1f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:f99626688942fb746e545232e7726926f3be91b5975f8b55327665fafda991c7", size = 2110594, upload-time = "2026-05-06T13:40:02.971Z" }, + { url = "https://files.pythonhosted.org/packages/9d/20/b8d36736216e29491125531685b2f9e61aa5b4b2599893f8268551da3338/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fc3e9034a63de20e15e8ade85358bc6efc614008cab72898b4b4952bea0509ff", size = 2159542, upload-time = "2026-05-06T13:39:27.506Z" }, + { url = "https://files.pythonhosted.org/packages/1d/a2/367df868eb584dacf6bf82a389272406d7178e301c4ac82545ab98bc2dd9/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:97e7cf2be5c77b7d1a9713a05605d49460d02c6078d38d8bef3cbe323c548424", size = 2168146, upload-time = "2026-05-06T13:38:31.93Z" }, + { url = "https://files.pythonhosted.org/packages/c1/b8/4460f77f7e201893f649a29ab355dddd3beee8a97bcb1a320db414f9a06e/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:3bf92c5d0e00fefaab325a4d27828fe6b6e2a21848686b5b60d2d9eeb09d76c6", size = 2306309, upload-time = "2026-05-06T13:37:44.717Z" }, + { url = "https://files.pythonhosted.org/packages/64/c4/be2639293acd87dc8ddbcec41a73cee9b2ebf996fe6d892a1a74e88ad3f7/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:3ecbc122d18468d06ca279dc26a8c2e2d5acb10943bb35e36ae92096dc3b5565", size = 2369736, upload-time = "2026-05-06T13:37:05.645Z" }, + { url = "https://files.pythonhosted.org/packages/30/a6/9f9f380dbb301f67023bf8f707aaa75daadf84f7152d95c410fd7e81d994/pydantic_core-2.46.4-cp314-cp314t-win32.whl", hash = "sha256:e846ae7835bf0703ae43f534ab79a867146dadd59dc9ca5c8b53d5c8f7c9ef02", size = 1955575, upload-time = "2026-05-06T13:38:51.116Z" }, + { url = "https://files.pythonhosted.org/packages/40/1f/f1eb9eb350e795d1af8586289746f5c5677d16043040d63710e22abc43c9/pydantic_core-2.46.4-cp314-cp314t-win_amd64.whl", hash = "sha256:2108ba5c1c1eca18030634489dc544844144ee36357f2f9f780b93e7ddbb44b5", size = 2051624, upload-time = "2026-05-06T13:38:21.672Z" }, + { url = "https://files.pythonhosted.org/packages/f6/d2/42dd53d0a85c27606f316d3aa5d2869c4e8470a5ed6dec30e4a1abe19192/pydantic_core-2.46.4-cp314-cp314t-win_arm64.whl", hash = "sha256:4fcbe087dbc2068af7eda3aa87634eba216dbda64d1ae73c8684b621d33f6596", size = 2017325, upload-time = "2026-05-06T13:40:52.723Z" }, ] [[package]] @@ -1011,7 +1012,7 @@ version = "2.20.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, + { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, ] [[package]] @@ -1019,15 +1020,15 @@ name = "pytest" version = "9.0.3" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "colorama", marker = "sys_platform == 'win32'" }, - { name = "iniconfig" }, - { name = "packaging" }, - { name = "pluggy" }, - { name = "pygments" }, + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, ] sdist = { url = "https://files.pythonhosted.org/packages/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size = 1572165, upload-time = "2026-04-07T17:16:18.027Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" }, + { url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" }, ] [[package]] @@ -1035,13 +1036,13 @@ name = "pytest-cov" version = "7.1.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "coverage" }, - { name = "pluggy" }, - { name = "pytest" }, + { name = "coverage" }, + { name = "pluggy" }, + { name = "pytest" }, ] sdist = { url = "https://files.pythonhosted.org/packages/b1/51/a849f96e117386044471c8ec2bd6cfebacda285da9525c9106aeb28da671/pytest_cov-7.1.0.tar.gz", hash = "sha256:30674f2b5f6351aa09702a9c8c364f6a01c27aae0c1366ae8016160d1efc56b2", size = 55592, upload-time = "2026-03-21T20:11:16.284Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl", hash = "sha256:a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678", size = 22876, upload-time = "2026-03-21T20:11:14.438Z" }, + { url = "https://files.pythonhosted.org/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl", hash = "sha256:a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678", size = 22876, upload-time = "2026-03-21T20:11:14.438Z" }, ] [[package]] @@ -1049,11 +1050,11 @@ name = "python-dateutil" version = "2.9.0.post0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "six" }, + { name = "six" }, ] sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" }, + { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" }, ] [[package]] @@ -1061,12 +1062,12 @@ name = "python-discovery" version = "1.4.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "filelock" }, - { name = "platformdirs" }, + { name = "filelock" }, + { name = "platformdirs" }, ] sdist = { url = "https://files.pythonhosted.org/packages/a6/12/38c1a0b1e64806780c9563e3fc9f6e472251839662587cfbe9bfaf2ae10a/python_discovery-1.4.0.tar.gz", hash = "sha256:eb8bc7daad3c226c147e45bb4e970a1feb1bf4048ee178e6db59e197b8010ce3", size = 68455, upload-time = "2026-05-28T01:15:37.639Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c8/8d/3d316429f65029532bb1e28ff77b797d86b5ac3915bb44ca4e19aa283d43/python_discovery-1.4.0-py3-none-any.whl", hash = "sha256:26ed78d703e234879a66244c7d4114563fb13ec5cd30a2d1357e5fb4850782da", size = 33217, upload-time = "2026-05-28T01:15:36.573Z" }, + { url = "https://files.pythonhosted.org/packages/c8/8d/3d316429f65029532bb1e28ff77b797d86b5ac3915bb44ca4e19aa283d43/python_discovery-1.4.0-py3-none-any.whl", hash = "sha256:26ed78d703e234879a66244c7d4114563fb13ec5cd30a2d1357e5fb4850782da", size = 33217, upload-time = "2026-05-28T01:15:36.573Z" }, ] [[package]] @@ -1075,7 +1076,7 @@ version = "1.2.2" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/82/ed/0301aeeac3e5353ef3d94b6ec08bbcabd04a72018415dcb29e588514bba8/python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3", size = 50135, upload-time = "2026-03-01T16:00:26.196Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" }, + { url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" }, ] [[package]] @@ -1084,34 +1085,34 @@ version = "6.0.3" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, - { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, - { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, - { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, - { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, - { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, - { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, - { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, - { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, - { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, - { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, - { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, - { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, - { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, - { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, - { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, - { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, - { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, - { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, - { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, - { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, - { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, - { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, - { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, - { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, - { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, - { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, - { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, + { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, + { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, + { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, + { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, + { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, + { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, + { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, + { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, + { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, + { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, + { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, + { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, + { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, + { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, + { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, + { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, + { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, + { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, + { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, + { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, + { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, ] [[package]] @@ -1120,70 +1121,70 @@ version = "2026.5.9" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/dc/0e/49aee608ad09480e7fd276898c99ec6192985fa331abe4eb3a986094490b/regex-2026.5.9.tar.gz", hash = "sha256:a8234aa23ec39894bfe4a3f1b85616a7032481964a13ac6fc9f10de4f6fca270", size = 416074, upload-time = "2026-05-09T23:15:19.37Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/aa/da/797e91ecec6f84135da778ddce78c20e0af5d2a15c26f87a81bc3eadb6db/regex-2026.5.9-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:d626b84406444b165fc0ba981604edea39f0588ff1f92baa23fe50799ea9afdb", size = 490303, upload-time = "2026-05-09T23:13:04.382Z" }, - { url = "https://files.pythonhosted.org/packages/44/da/bf30abaaa737b58f4a4b8c4a03659e02fd92092c822e0197ed9e0daab917/regex-2026.5.9-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:d7bdc0ab8f3dd7e1b4f9ab88634e13374669db86bb3c72e8292f07ae313f539f", size = 292019, upload-time = "2026-05-09T23:13:06.022Z" }, - { url = "https://files.pythonhosted.org/packages/2d/e7/d0eaf5713828417b9e5648cf81fa9bacd4961f6ab98c380c2034f8716e35/regex-2026.5.9-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a8820737949116ffff55fe18f9fc644530063ba6ebfcb8314239416e78f1347c", size = 289468, upload-time = "2026-05-09T23:13:08.214Z" }, - { url = "https://files.pythonhosted.org/packages/d3/9b/b3fdd62b003baa1a9b593cd8c8699c9651c2e80cc21a5c715707983c42d7/regex-2026.5.9-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:aa0fbdbac82cb3e4450d0ccde7d7a35607f4cb2dd9fba4b8b69bfaf8c9fa6aed", size = 796749, upload-time = "2026-05-09T23:13:10.573Z" }, - { url = "https://files.pythonhosted.org/packages/d4/30/66ab84588765f5b4b271a9ca09ef7ce2b87caa95176ec3d2ad65d7bc4902/regex-2026.5.9-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:57e8915c7986aa33d25e4d3629cef711cd2863f2961b10409f0c04cb8b7d9020", size = 865445, upload-time = "2026-05-09T23:13:12.523Z" }, - { url = "https://files.pythonhosted.org/packages/1a/89/f05169e8588aac365f35ffc7f3bc3184f095ef4cfded7cfaa3c7fd5dbd89/regex-2026.5.9-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:508f56a89ba9cb26e4168cbc37dbd60a28d82430a9e18ad1d25fe0883c314ca2", size = 912322, upload-time = "2026-05-09T23:13:14.281Z" }, - { url = "https://files.pythonhosted.org/packages/30/e1/c93444052cf41581f3c884ab3fb5823daf0992f11cd4388d4275ca610558/regex-2026.5.9-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b6d189041f15691cfa2b6c4290448ec221244d225b3f5fe9e7771b34ffcdf6e2", size = 801269, upload-time = "2026-05-09T23:13:16.569Z" }, - { url = "https://files.pythonhosted.org/packages/50/fe/0cf96b882f540e62e8b9956599798203d599c44cf4c77917ca27400ff69b/regex-2026.5.9-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e82db382b44d0111b22601c509c89f64434816c9e0eef9d1989cda8cc6ff1c04", size = 777085, upload-time = "2026-05-09T23:13:18.675Z" }, - { url = "https://files.pythonhosted.org/packages/23/5c/d78d4924e7fc875557b9e9b768423925fdfaac5549d06da7810019a9bd26/regex-2026.5.9-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:2acfb48634f64996b57f90f39afa692ff362162722581921fe92239a59960f3c", size = 785153, upload-time = "2026-05-09T23:13:20.525Z" }, - { url = "https://files.pythonhosted.org/packages/bf/e0/5214774090e7b4524dcea3e3c4aa74141d43043f8beb49c1599db1c8b53a/regex-2026.5.9-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:d29eebfc9525db68cad3c97eedd7f754fa265aa5cd0cf4f863b2421e1b48fc9f", size = 860164, upload-time = "2026-05-09T23:13:22.263Z" }, - { url = "https://files.pythonhosted.org/packages/6e/e1/4a57a83350319b1271f0d7a249b8672513ed928b237a741631270de6caea/regex-2026.5.9-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:debb893095e944091c16e641a6e33c1b0f4cb61ab945ec5afbf53ce7068834d8", size = 765731, upload-time = "2026-05-09T23:13:24.277Z" }, - { url = "https://files.pythonhosted.org/packages/12/f4/499e74a20c156fc75836ee04a72a38d1a063978f600937f9760467beb1b0/regex-2026.5.9-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:d659eee77986549c9ea45b861c7567e44d6287c3dc9a4565478853f7b9fe2ff6", size = 852062, upload-time = "2026-05-09T23:13:26.125Z" }, - { url = "https://files.pythonhosted.org/packages/5b/92/7eebc0d0a01e78629695f342ba17e0deaff8fb45e79cc0d7b98287da6e3e/regex-2026.5.9-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:2efa205e6d98b24d1f3ab395c11aa15cdf10935bca283d0285e0499c284fba21", size = 789577, upload-time = "2026-05-09T23:13:27.814Z" }, - { url = "https://files.pythonhosted.org/packages/05/a4/018e71f7d2ad48c1ebe6d3ae0026f9b7cb4802fd15c7cc02fdf724355102/regex-2026.5.9-cp313-cp313-win32.whl", hash = "sha256:f3844f134e834076677dd369976e9f5068679fcb8e50102fdf6b7ac96a3ec127", size = 266691, upload-time = "2026-05-09T23:13:29.549Z" }, - { url = "https://files.pythonhosted.org/packages/e6/1d/861a93719fb9ee7dbfc3761b3797b7a3e112a5d42c6129459d2d741be9b5/regex-2026.5.9-cp313-cp313-win_amd64.whl", hash = "sha256:3527bb4942d2c14552155406cdedd906567456821848aed1cb4933a391bf5eca", size = 277747, upload-time = "2026-05-09T23:13:31.859Z" }, - { url = "https://files.pythonhosted.org/packages/d9/c6/0a2436ae4da1ba76e51cb98943c6838a9a721faa40ebe2dce07694ae34e3/regex-2026.5.9-cp313-cp313-win_arm64.whl", hash = "sha256:56a33f191f17d8c417f99945ebdc1e691d3af9605d86ec68c7e54a57e3e17af6", size = 270500, upload-time = "2026-05-09T23:13:33.525Z" }, - { url = "https://files.pythonhosted.org/packages/e8/e9/d21346f7b60ed58789371358ed66b09d00f832e1bd7c06e55d9da5679882/regex-2026.5.9-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:01f28d868834624c934b8d2e0aa1c8341337e37831f4a012f18a5afcba4cbaf3", size = 494172, upload-time = "2026-05-09T23:13:35.935Z" }, - { url = "https://files.pythonhosted.org/packages/c4/43/fd1177a2032037c681baecdb3422ee4e1424aec4e4f470ef47793d325274/regex-2026.5.9-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:48036f6374aaa79eb3b754ec29c61d1c6b1606749d705a13f8854fa2539671f6", size = 293952, upload-time = "2026-05-09T23:13:38.307Z" }, - { url = "https://files.pythonhosted.org/packages/f2/7d/9fbf919768368d3f8a4f6c692cf2aa61e482b2b81ec6a298ace4cbf02480/regex-2026.5.9-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:b96350aa424e79d4fd6b567b344dcbe2b2d6bfc48dfe7717587e1fa6d43da6ff", size = 292314, upload-time = "2026-05-09T23:13:40.353Z" }, - { url = "https://files.pythonhosted.org/packages/e2/6c/e41bfeecb589716843e7c4df09ba46ff2a42961457afece19059d85caeef/regex-2026.5.9-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8f3af7a4903c5c04a11a196a5aa75cdd7dd3f8508132f9fb3259d9f5908e3b88", size = 811681, upload-time = "2026-05-09T23:13:42.543Z" }, - { url = "https://files.pythonhosted.org/packages/87/83/a5c1c525fba0aa656e88ad0face0b1829788ef4c2fb6b26df58aa1151b84/regex-2026.5.9-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7e87577720152d2caae19fe2baaf1f8d5ca12091e9e229f03915c37d1e4b9178", size = 871135, upload-time = "2026-05-09T23:13:44.326Z" }, - { url = "https://files.pythonhosted.org/packages/18/d4/80882e799e440dd878b0979cbebf8fa4d54624a332c83037c7a701649e3f/regex-2026.5.9-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c8b9b9d294cfea3cd19c718ade7cc93492b2c4991abd9a68d0b3477ae6d8e100", size = 917265, upload-time = "2026-05-09T23:13:47.295Z" }, - { url = "https://files.pythonhosted.org/packages/ae/ff/8db60211e2286e396aad7dc7725356c502bff0901ea05bd6cdc2e1a042b9/regex-2026.5.9-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:728d8bfd28a8845c8b6bc5dc7ce010453d206396786c0765c2740cb65f37791e", size = 816311, upload-time = "2026-05-09T23:13:49.885Z" }, - { url = "https://files.pythonhosted.org/packages/4c/47/742ef579c61730f8d268e5cf1f9ce0e37e2ea041ad0f5644724f2378e463/regex-2026.5.9-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7e30b874d341fac767d7df5a0870540541c2c054b80cfaac116e8d367a8a7ff2", size = 785498, upload-time = "2026-05-09T23:13:52.25Z" }, - { url = "https://files.pythonhosted.org/packages/7f/ab/cb0999802dcb0fb95b1ab005e8d4163d8afdd67efc2cb6b6630ac13f8cb1/regex-2026.5.9-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:fd190e88a895a8901325fad284a3f74ea52b1da8525b76cc811fa9b1edf0ce2b", size = 801348, upload-time = "2026-05-09T23:13:54.127Z" }, - { url = "https://files.pythonhosted.org/packages/7d/62/8ca59a24c55bc34d166eefaf3717bd77772f329fdbf984d86581e0a3571c/regex-2026.5.9-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:8e76e8161ad00694cfce6767d5dea860c6391ac5b83e5c3a39661e696f11fc7e", size = 866493, upload-time = "2026-05-09T23:13:56.067Z" }, - { url = "https://files.pythonhosted.org/packages/8d/3d/30f2ae62cef3278bb5bb821f467277a55fb73f01032cf85997e15e8289a8/regex-2026.5.9-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:ddda5340e6c01a293027dd46232fa79eaff1b48058ce7a98f572b6445b088041", size = 772811, upload-time = "2026-05-09T23:13:57.867Z" }, - { url = "https://files.pythonhosted.org/packages/d8/ae/7d2089bcd78ad0c0161bc684339df50032acb438a7bd3305e7ddb1193cec/regex-2026.5.9-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:205109e96b3cf5adf8f4cd62bedde9487feb282b9497a3535451e5a24cd706a0", size = 856584, upload-time = "2026-05-09T23:13:59.679Z" }, - { url = "https://files.pythonhosted.org/packages/a9/29/92ff47f75990131ea4f24ba17819e5a9d141e10819807e09addd73409af6/regex-2026.5.9-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:dfbe4579b9f08036aa7d101d1835437a20783574ac66327e6b29b4018a138081", size = 803453, upload-time = "2026-05-09T23:14:01.978Z" }, - { url = "https://files.pythonhosted.org/packages/04/99/eff29f1037dcab36702c9ee5d6858cf1ce2336ea8ea2987f64245b99ea5e/regex-2026.5.9-cp313-cp313t-win32.whl", hash = "sha256:ed2c9e8068b614c574d8d30e543d617cf5379b0535d46f97ef00e904745a08b5", size = 269951, upload-time = "2026-05-09T23:14:03.661Z" }, - { url = "https://files.pythonhosted.org/packages/0e/9d/8870b8981d27b22cda77bb26a5ac7ebfa9c7d9e0dea195a834a82380e748/regex-2026.5.9-cp313-cp313t-win_amd64.whl", hash = "sha256:b46b0f094dc1d3b90356c85a0bd2c9bafc4a6a190b9d6f8ddd5a033b6e088ed4", size = 281240, upload-time = "2026-05-09T23:14:05.56Z" }, - { url = "https://files.pythonhosted.org/packages/72/b1/3379415e8f135c13ac551353397cc4fe97b4978f3cac73c5fcbcded548b8/regex-2026.5.9-cp313-cp313t-win_arm64.whl", hash = "sha256:872acc074bd29ffc9913ecdfedf6ea77502312ca44a4aa0d3779089c6069d8de", size = 272383, upload-time = "2026-05-09T23:14:07.843Z" }, - { url = "https://files.pythonhosted.org/packages/13/3e/9c3cd292d8808b3645a2ce517e200179b6d0e903f176300bd8b542e14de5/regex-2026.5.9-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:1bd7587a2948b4085195d5a3374eaf4a425dc3e55784c038175355ecf3bbbf8a", size = 490376, upload-time = "2026-05-09T23:14:09.64Z" }, - { url = "https://files.pythonhosted.org/packages/60/70/d43ee8a2ca0a8b68d167f21658b85520ac0574617c7f320367c5047f7556/regex-2026.5.9-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:dea2e88e1cce4522496cce630e11e67b98b7076620bc4336c3f674bc21a375f4", size = 291964, upload-time = "2026-05-09T23:14:11.424Z" }, - { url = "https://files.pythonhosted.org/packages/21/91/9d50b433828d8e74196904e168a43abf1e6e88b2a15d47ed742456720c37/regex-2026.5.9-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:2099f7e7ff7b6aa3192312650a56e91cc091e49d50b04e4f6f8b6e28b3b27f1c", size = 289682, upload-time = "2026-05-09T23:14:13.123Z" }, - { url = "https://files.pythonhosted.org/packages/3e/d2/b835e3cafbb9d977736912436259ff551d60919f7d7b3d37d46659c63564/regex-2026.5.9-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ecd353045824e4477562a2ac718c25799cdaaa41f7aa925a806a8a3e6848a5b9", size = 796996, upload-time = "2026-05-09T23:14:14.923Z" }, - { url = "https://files.pythonhosted.org/packages/2c/a6/9f992d00019166b9de01c546dd4549bc679f2a68df11b877740b0760b7c2/regex-2026.5.9-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:65c8c8c37377794bd5b2f3ebe51919042bf17aec802e23c833d89782ed0c78af", size = 866089, upload-time = "2026-05-09T23:14:17.757Z" }, - { url = "https://files.pythonhosted.org/packages/e0/08/4d32af657e049b19cb62b02e46e38fe1518797bfb2203ee93a510b21b0dc/regex-2026.5.9-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5b73ab8afcf66c622db143d1c6fda4e58e4d537ee4f125229ad47b1ab80f34c0", size = 911530, upload-time = "2026-05-09T23:14:20.353Z" }, - { url = "https://files.pythonhosted.org/packages/d9/27/2af43dd1dc201d1fecefda64a45f4ad0995855b92724f795a777b402ee69/regex-2026.5.9-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0de5cf193997384ed2ca6f1cd4f78055b255d93d82d5a8cd6ba0d11c10b167e4", size = 800643, upload-time = "2026-05-09T23:14:22.265Z" }, - { url = "https://files.pythonhosted.org/packages/a4/dd/23a249047013b5321d4a60c4d2437462086f601b061776a525e5fba2a59f/regex-2026.5.9-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d641a8c9a61618047796d572a39a79b26167b0411d2c3031937b2fe2d081e2cf", size = 777223, upload-time = "2026-05-09T23:14:24.179Z" }, - { url = "https://files.pythonhosted.org/packages/94/6a/e85ed9538cd19586d0465076a4578a12e093ce776d15f3f8ce92733a8dd6/regex-2026.5.9-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:24b2355ef5cc9aa5b8f07d17704face1c166fdcc2290fa7bd6e6c925655a8346", size = 785760, upload-time = "2026-05-09T23:14:26.065Z" }, - { url = "https://files.pythonhosted.org/packages/2a/c4/f25473209438638e947c55f9156fd8f236f74169229028cc99116380868e/regex-2026.5.9-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:a24852d3c29ad9e47593593d8a247c44ccc3d0548ef12c822d6ed0810affe676", size = 860891, upload-time = "2026-05-09T23:14:28.17Z" }, - { url = "https://files.pythonhosted.org/packages/f9/f7/f4f86e3c74419c37370e91f150ae0c2ef7d34b2e0e4cdd5da046a02e4022/regex-2026.5.9-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:916714069da19329ef7de197dcbc77bb3104145c7c2c864dbfbe318f46b88b14", size = 765891, upload-time = "2026-05-09T23:14:30.06Z" }, - { url = "https://files.pythonhosted.org/packages/26/70/704d8e13765939146b1cd0ef4e2feb71d7929727d2290f026eed10095955/regex-2026.5.9-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:fa411799ca8da32a8d38d020a88faa5b6f91657d284761352940ecf9f7c3bbdd", size = 851380, upload-time = "2026-05-09T23:14:32.123Z" }, - { url = "https://files.pythonhosted.org/packages/26/29/1a13582a8460038edc38e49f64ceb0dd7c60f5caba77571f4bf6601965d9/regex-2026.5.9-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:1e6da47d679b7010ef27556b6e0f99771b744936db1792a10ceac6547ae1503e", size = 789350, upload-time = "2026-05-09T23:14:34.799Z" }, - { url = "https://files.pythonhosted.org/packages/73/56/3dcafe34fc72e271d62ad9a291801e88a1457bb251c132f15fcc2e5aad1a/regex-2026.5.9-cp314-cp314-win32.whl", hash = "sha256:98bd73080e8756255137e1bd3f3f00295bbc5aa383c0e0f973920e9134d7c4ad", size = 272130, upload-time = "2026-05-09T23:14:36.729Z" }, - { url = "https://files.pythonhosted.org/packages/d0/9c/02eebf0be95efe416c664db7fb8b6b05b7a0b06a7544f2884f2558b0526f/regex-2026.5.9-cp314-cp314-win_amd64.whl", hash = "sha256:ff8d372ac2acdc048d1c19916f27ee61bc5722728458ba6ca5052f2c72d51763", size = 280999, upload-time = "2026-05-09T23:14:39.126Z" }, - { url = "https://files.pythonhosted.org/packages/70/5a/1dd1abee76cb7a846a0bcf42fdc87e5720c3c33c24f3e37814310a513d9f/regex-2026.5.9-cp314-cp314-win_arm64.whl", hash = "sha256:e1d93bf647916292e8edcec150c07ddf3dc50179ccaf770c04a7f9e452155372", size = 273500, upload-time = "2026-05-09T23:14:41.059Z" }, - { url = "https://files.pythonhosted.org/packages/86/c1/c5f619b0057a7965cb78ec559c1d7a45ce8c99a35bea95483d64959a93d9/regex-2026.5.9-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:83d0ee4a57d1c87cb549e195ec300b8f0ec3a82eba66d835e4e2ed8634fe4499", size = 494269, upload-time = "2026-05-09T23:14:42.869Z" }, - { url = "https://files.pythonhosted.org/packages/05/2c/5d01f1aee33de4bbe60c8452945bfc8477ca7c5ae4450f6bfe711036cb36/regex-2026.5.9-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:d3d7eb5c9a7f6df82ed3cfac9beb93882a5cbcb5b8b157b56cb2b3b276574ac1", size = 293954, upload-time = "2026-05-09T23:14:44.822Z" }, - { url = "https://files.pythonhosted.org/packages/7a/fe/e8988b2ae2108c6ef71bd4aa8d87fbe257976dd0810e826cd75f701c68b6/regex-2026.5.9-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:075160bf16658e16d35233300b8453aac25de4cbea808d22348b6979668e924d", size = 292405, upload-time = "2026-05-09T23:14:47.211Z" }, - { url = "https://files.pythonhosted.org/packages/79/34/d2b0937faa7859263f7f0a3c6b103a1296306be6952dc173d0154e9a2f49/regex-2026.5.9-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:45375819235558a4ff1c4971dc32881f022613abdb180128f5cb4768c1765a1c", size = 811855, upload-time = "2026-05-09T23:14:49.21Z" }, - { url = "https://files.pythonhosted.org/packages/80/fe/daf53a47457a8486db66c66c01ceb9c2303eecee3f87197f1e77eb1a736d/regex-2026.5.9-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ead4b163ac30a29574510cd4b3e2e985ac5290c05fc7095557d6a5f403fc31b5", size = 871189, upload-time = "2026-05-09T23:14:51.555Z" }, - { url = "https://files.pythonhosted.org/packages/1c/75/058fc4470cbfbf57d800aff1a0022b929a3f9fa553ee10a0cdf2070eb31f/regex-2026.5.9-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8c6e4218fbdfbcd4f6c19efca40930d24a621bf4b48cb76bc6640543bd28ef20", size = 917485, upload-time = "2026-05-09T23:14:53.633Z" }, - { url = "https://files.pythonhosted.org/packages/88/e7/179cfda3a28bc843b5c6cfe7f79f23489c791ed95f151083803660878432/regex-2026.5.9-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6351571c8a42b505eb555c0dc47d740d0fb66977dc142919eea6f4325b7c56a0", size = 816369, upload-time = "2026-05-09T23:14:56.198Z" }, - { url = "https://files.pythonhosted.org/packages/41/90/6f0cc422071688266d344fca8462d787cba0a2c144acb25721f9a61ec265/regex-2026.5.9-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:002205cafd2a9e78c6290c7d1df277bf3277b3b7a30e0b4bb0dac2e2e3f7cb2d", size = 785869, upload-time = "2026-05-09T23:14:58.602Z" }, - { url = "https://files.pythonhosted.org/packages/02/67/a31f1760f09c27b251ef39e9beb541f462cf977381d067faa764c2c0e393/regex-2026.5.9-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8abd33fef90b2a9efac5557d6033ca82d1195ed3a15fea5af15ba7b463c6a63b", size = 801427, upload-time = "2026-05-09T23:15:00.642Z" }, - { url = "https://files.pythonhosted.org/packages/e3/c4/1a80654597b6bc1e1ea0494824c31200e8a956abe290afae9b19a166a148/regex-2026.5.9-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:31037c82eccb44b7ea2e9e221d7c01429430e989a1f4b91ea5a855f6017b509a", size = 866482, upload-time = "2026-05-09T23:15:03.384Z" }, - { url = "https://files.pythonhosted.org/packages/d1/11/960724e06482c08466ff5611e242e86f80062949cdf6b4b9cc317b9dd93d/regex-2026.5.9-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:5604dfd046dc37eca90250fc3be938b076c8059fa772ac0ed6f499b0f0fb0415", size = 773022, upload-time = "2026-05-09T23:15:05.625Z" }, - { url = "https://files.pythonhosted.org/packages/50/a8/a9979c3e7918280e93159ebcab5ef1a65116dd4f3bd6091be0eae4a126e8/regex-2026.5.9-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:0e1b1b4e496afbb24f4a62aba855ee4f88f25578927697b340702e48c9ee6bc2", size = 856642, upload-time = "2026-05-09T23:15:07.966Z" }, - { url = "https://files.pythonhosted.org/packages/fe/d4/a9b732f2f0072c0ab12227483abb24fffcb9f73f8a2b203df0a6d0434735/regex-2026.5.9-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:be3372b9df6ddecff6486d37e19095a7b4973137caf5512407a89f4455361f41", size = 803552, upload-time = "2026-05-09T23:15:10.215Z" }, - { url = "https://files.pythonhosted.org/packages/d5/fe/1b3113817447a1d4155e4ac76d2e072f42c0bcba2f43fa8a0e756ea2cd91/regex-2026.5.9-cp314-cp314t-win32.whl", hash = "sha256:3ddd90103f9e5c471c49c7852ecc1fe27c7e45eb99e977aefe7caa4e779f4f58", size = 275746, upload-time = "2026-05-09T23:15:12.609Z" }, - { url = "https://files.pythonhosted.org/packages/92/73/93d42045302636c91f2e5ef588b65b84b01428f28ec77de256b1dfdfbe5c/regex-2026.5.9-cp314-cp314t-win_amd64.whl", hash = "sha256:ca518ed29c46eecba6010b15f1b9a479314d2de409536e71b6a13aa04e3b8a77", size = 285685, upload-time = "2026-05-09T23:15:15.086Z" }, - { url = "https://files.pythonhosted.org/packages/da/80/35b4c33c804a165a7f55289afda3ea9e3eb6d15800341a2d66455c0f1f30/regex-2026.5.9-cp314-cp314t-win_arm64.whl", hash = "sha256:5e41809d2683fcde7d5a8c87a6567ba1fb1ce0de9f31bff578de00a4b2d76daa", size = 275713, upload-time = "2026-05-09T23:15:16.98Z" }, + { url = "https://files.pythonhosted.org/packages/aa/da/797e91ecec6f84135da778ddce78c20e0af5d2a15c26f87a81bc3eadb6db/regex-2026.5.9-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:d626b84406444b165fc0ba981604edea39f0588ff1f92baa23fe50799ea9afdb", size = 490303, upload-time = "2026-05-09T23:13:04.382Z" }, + { url = "https://files.pythonhosted.org/packages/44/da/bf30abaaa737b58f4a4b8c4a03659e02fd92092c822e0197ed9e0daab917/regex-2026.5.9-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:d7bdc0ab8f3dd7e1b4f9ab88634e13374669db86bb3c72e8292f07ae313f539f", size = 292019, upload-time = "2026-05-09T23:13:06.022Z" }, + { url = "https://files.pythonhosted.org/packages/2d/e7/d0eaf5713828417b9e5648cf81fa9bacd4961f6ab98c380c2034f8716e35/regex-2026.5.9-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a8820737949116ffff55fe18f9fc644530063ba6ebfcb8314239416e78f1347c", size = 289468, upload-time = "2026-05-09T23:13:08.214Z" }, + { url = "https://files.pythonhosted.org/packages/d3/9b/b3fdd62b003baa1a9b593cd8c8699c9651c2e80cc21a5c715707983c42d7/regex-2026.5.9-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:aa0fbdbac82cb3e4450d0ccde7d7a35607f4cb2dd9fba4b8b69bfaf8c9fa6aed", size = 796749, upload-time = "2026-05-09T23:13:10.573Z" }, + { url = "https://files.pythonhosted.org/packages/d4/30/66ab84588765f5b4b271a9ca09ef7ce2b87caa95176ec3d2ad65d7bc4902/regex-2026.5.9-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:57e8915c7986aa33d25e4d3629cef711cd2863f2961b10409f0c04cb8b7d9020", size = 865445, upload-time = "2026-05-09T23:13:12.523Z" }, + { url = "https://files.pythonhosted.org/packages/1a/89/f05169e8588aac365f35ffc7f3bc3184f095ef4cfded7cfaa3c7fd5dbd89/regex-2026.5.9-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:508f56a89ba9cb26e4168cbc37dbd60a28d82430a9e18ad1d25fe0883c314ca2", size = 912322, upload-time = "2026-05-09T23:13:14.281Z" }, + { url = "https://files.pythonhosted.org/packages/30/e1/c93444052cf41581f3c884ab3fb5823daf0992f11cd4388d4275ca610558/regex-2026.5.9-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b6d189041f15691cfa2b6c4290448ec221244d225b3f5fe9e7771b34ffcdf6e2", size = 801269, upload-time = "2026-05-09T23:13:16.569Z" }, + { url = "https://files.pythonhosted.org/packages/50/fe/0cf96b882f540e62e8b9956599798203d599c44cf4c77917ca27400ff69b/regex-2026.5.9-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e82db382b44d0111b22601c509c89f64434816c9e0eef9d1989cda8cc6ff1c04", size = 777085, upload-time = "2026-05-09T23:13:18.675Z" }, + { url = "https://files.pythonhosted.org/packages/23/5c/d78d4924e7fc875557b9e9b768423925fdfaac5549d06da7810019a9bd26/regex-2026.5.9-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:2acfb48634f64996b57f90f39afa692ff362162722581921fe92239a59960f3c", size = 785153, upload-time = "2026-05-09T23:13:20.525Z" }, + { url = "https://files.pythonhosted.org/packages/bf/e0/5214774090e7b4524dcea3e3c4aa74141d43043f8beb49c1599db1c8b53a/regex-2026.5.9-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:d29eebfc9525db68cad3c97eedd7f754fa265aa5cd0cf4f863b2421e1b48fc9f", size = 860164, upload-time = "2026-05-09T23:13:22.263Z" }, + { url = "https://files.pythonhosted.org/packages/6e/e1/4a57a83350319b1271f0d7a249b8672513ed928b237a741631270de6caea/regex-2026.5.9-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:debb893095e944091c16e641a6e33c1b0f4cb61ab945ec5afbf53ce7068834d8", size = 765731, upload-time = "2026-05-09T23:13:24.277Z" }, + { url = "https://files.pythonhosted.org/packages/12/f4/499e74a20c156fc75836ee04a72a38d1a063978f600937f9760467beb1b0/regex-2026.5.9-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:d659eee77986549c9ea45b861c7567e44d6287c3dc9a4565478853f7b9fe2ff6", size = 852062, upload-time = "2026-05-09T23:13:26.125Z" }, + { url = "https://files.pythonhosted.org/packages/5b/92/7eebc0d0a01e78629695f342ba17e0deaff8fb45e79cc0d7b98287da6e3e/regex-2026.5.9-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:2efa205e6d98b24d1f3ab395c11aa15cdf10935bca283d0285e0499c284fba21", size = 789577, upload-time = "2026-05-09T23:13:27.814Z" }, + { url = "https://files.pythonhosted.org/packages/05/a4/018e71f7d2ad48c1ebe6d3ae0026f9b7cb4802fd15c7cc02fdf724355102/regex-2026.5.9-cp313-cp313-win32.whl", hash = "sha256:f3844f134e834076677dd369976e9f5068679fcb8e50102fdf6b7ac96a3ec127", size = 266691, upload-time = "2026-05-09T23:13:29.549Z" }, + { url = "https://files.pythonhosted.org/packages/e6/1d/861a93719fb9ee7dbfc3761b3797b7a3e112a5d42c6129459d2d741be9b5/regex-2026.5.9-cp313-cp313-win_amd64.whl", hash = "sha256:3527bb4942d2c14552155406cdedd906567456821848aed1cb4933a391bf5eca", size = 277747, upload-time = "2026-05-09T23:13:31.859Z" }, + { url = "https://files.pythonhosted.org/packages/d9/c6/0a2436ae4da1ba76e51cb98943c6838a9a721faa40ebe2dce07694ae34e3/regex-2026.5.9-cp313-cp313-win_arm64.whl", hash = "sha256:56a33f191f17d8c417f99945ebdc1e691d3af9605d86ec68c7e54a57e3e17af6", size = 270500, upload-time = "2026-05-09T23:13:33.525Z" }, + { url = "https://files.pythonhosted.org/packages/e8/e9/d21346f7b60ed58789371358ed66b09d00f832e1bd7c06e55d9da5679882/regex-2026.5.9-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:01f28d868834624c934b8d2e0aa1c8341337e37831f4a012f18a5afcba4cbaf3", size = 494172, upload-time = "2026-05-09T23:13:35.935Z" }, + { url = "https://files.pythonhosted.org/packages/c4/43/fd1177a2032037c681baecdb3422ee4e1424aec4e4f470ef47793d325274/regex-2026.5.9-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:48036f6374aaa79eb3b754ec29c61d1c6b1606749d705a13f8854fa2539671f6", size = 293952, upload-time = "2026-05-09T23:13:38.307Z" }, + { url = "https://files.pythonhosted.org/packages/f2/7d/9fbf919768368d3f8a4f6c692cf2aa61e482b2b81ec6a298ace4cbf02480/regex-2026.5.9-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:b96350aa424e79d4fd6b567b344dcbe2b2d6bfc48dfe7717587e1fa6d43da6ff", size = 292314, upload-time = "2026-05-09T23:13:40.353Z" }, + { url = "https://files.pythonhosted.org/packages/e2/6c/e41bfeecb589716843e7c4df09ba46ff2a42961457afece19059d85caeef/regex-2026.5.9-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8f3af7a4903c5c04a11a196a5aa75cdd7dd3f8508132f9fb3259d9f5908e3b88", size = 811681, upload-time = "2026-05-09T23:13:42.543Z" }, + { url = "https://files.pythonhosted.org/packages/87/83/a5c1c525fba0aa656e88ad0face0b1829788ef4c2fb6b26df58aa1151b84/regex-2026.5.9-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7e87577720152d2caae19fe2baaf1f8d5ca12091e9e229f03915c37d1e4b9178", size = 871135, upload-time = "2026-05-09T23:13:44.326Z" }, + { url = "https://files.pythonhosted.org/packages/18/d4/80882e799e440dd878b0979cbebf8fa4d54624a332c83037c7a701649e3f/regex-2026.5.9-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c8b9b9d294cfea3cd19c718ade7cc93492b2c4991abd9a68d0b3477ae6d8e100", size = 917265, upload-time = "2026-05-09T23:13:47.295Z" }, + { url = "https://files.pythonhosted.org/packages/ae/ff/8db60211e2286e396aad7dc7725356c502bff0901ea05bd6cdc2e1a042b9/regex-2026.5.9-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:728d8bfd28a8845c8b6bc5dc7ce010453d206396786c0765c2740cb65f37791e", size = 816311, upload-time = "2026-05-09T23:13:49.885Z" }, + { url = "https://files.pythonhosted.org/packages/4c/47/742ef579c61730f8d268e5cf1f9ce0e37e2ea041ad0f5644724f2378e463/regex-2026.5.9-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7e30b874d341fac767d7df5a0870540541c2c054b80cfaac116e8d367a8a7ff2", size = 785498, upload-time = "2026-05-09T23:13:52.25Z" }, + { url = "https://files.pythonhosted.org/packages/7f/ab/cb0999802dcb0fb95b1ab005e8d4163d8afdd67efc2cb6b6630ac13f8cb1/regex-2026.5.9-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:fd190e88a895a8901325fad284a3f74ea52b1da8525b76cc811fa9b1edf0ce2b", size = 801348, upload-time = "2026-05-09T23:13:54.127Z" }, + { url = "https://files.pythonhosted.org/packages/7d/62/8ca59a24c55bc34d166eefaf3717bd77772f329fdbf984d86581e0a3571c/regex-2026.5.9-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:8e76e8161ad00694cfce6767d5dea860c6391ac5b83e5c3a39661e696f11fc7e", size = 866493, upload-time = "2026-05-09T23:13:56.067Z" }, + { url = "https://files.pythonhosted.org/packages/8d/3d/30f2ae62cef3278bb5bb821f467277a55fb73f01032cf85997e15e8289a8/regex-2026.5.9-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:ddda5340e6c01a293027dd46232fa79eaff1b48058ce7a98f572b6445b088041", size = 772811, upload-time = "2026-05-09T23:13:57.867Z" }, + { url = "https://files.pythonhosted.org/packages/d8/ae/7d2089bcd78ad0c0161bc684339df50032acb438a7bd3305e7ddb1193cec/regex-2026.5.9-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:205109e96b3cf5adf8f4cd62bedde9487feb282b9497a3535451e5a24cd706a0", size = 856584, upload-time = "2026-05-09T23:13:59.679Z" }, + { url = "https://files.pythonhosted.org/packages/a9/29/92ff47f75990131ea4f24ba17819e5a9d141e10819807e09addd73409af6/regex-2026.5.9-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:dfbe4579b9f08036aa7d101d1835437a20783574ac66327e6b29b4018a138081", size = 803453, upload-time = "2026-05-09T23:14:01.978Z" }, + { url = "https://files.pythonhosted.org/packages/04/99/eff29f1037dcab36702c9ee5d6858cf1ce2336ea8ea2987f64245b99ea5e/regex-2026.5.9-cp313-cp313t-win32.whl", hash = "sha256:ed2c9e8068b614c574d8d30e543d617cf5379b0535d46f97ef00e904745a08b5", size = 269951, upload-time = "2026-05-09T23:14:03.661Z" }, + { url = "https://files.pythonhosted.org/packages/0e/9d/8870b8981d27b22cda77bb26a5ac7ebfa9c7d9e0dea195a834a82380e748/regex-2026.5.9-cp313-cp313t-win_amd64.whl", hash = "sha256:b46b0f094dc1d3b90356c85a0bd2c9bafc4a6a190b9d6f8ddd5a033b6e088ed4", size = 281240, upload-time = "2026-05-09T23:14:05.56Z" }, + { url = "https://files.pythonhosted.org/packages/72/b1/3379415e8f135c13ac551353397cc4fe97b4978f3cac73c5fcbcded548b8/regex-2026.5.9-cp313-cp313t-win_arm64.whl", hash = "sha256:872acc074bd29ffc9913ecdfedf6ea77502312ca44a4aa0d3779089c6069d8de", size = 272383, upload-time = "2026-05-09T23:14:07.843Z" }, + { url = "https://files.pythonhosted.org/packages/13/3e/9c3cd292d8808b3645a2ce517e200179b6d0e903f176300bd8b542e14de5/regex-2026.5.9-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:1bd7587a2948b4085195d5a3374eaf4a425dc3e55784c038175355ecf3bbbf8a", size = 490376, upload-time = "2026-05-09T23:14:09.64Z" }, + { url = "https://files.pythonhosted.org/packages/60/70/d43ee8a2ca0a8b68d167f21658b85520ac0574617c7f320367c5047f7556/regex-2026.5.9-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:dea2e88e1cce4522496cce630e11e67b98b7076620bc4336c3f674bc21a375f4", size = 291964, upload-time = "2026-05-09T23:14:11.424Z" }, + { url = "https://files.pythonhosted.org/packages/21/91/9d50b433828d8e74196904e168a43abf1e6e88b2a15d47ed742456720c37/regex-2026.5.9-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:2099f7e7ff7b6aa3192312650a56e91cc091e49d50b04e4f6f8b6e28b3b27f1c", size = 289682, upload-time = "2026-05-09T23:14:13.123Z" }, + { url = "https://files.pythonhosted.org/packages/3e/d2/b835e3cafbb9d977736912436259ff551d60919f7d7b3d37d46659c63564/regex-2026.5.9-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ecd353045824e4477562a2ac718c25799cdaaa41f7aa925a806a8a3e6848a5b9", size = 796996, upload-time = "2026-05-09T23:14:14.923Z" }, + { url = "https://files.pythonhosted.org/packages/2c/a6/9f992d00019166b9de01c546dd4549bc679f2a68df11b877740b0760b7c2/regex-2026.5.9-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:65c8c8c37377794bd5b2f3ebe51919042bf17aec802e23c833d89782ed0c78af", size = 866089, upload-time = "2026-05-09T23:14:17.757Z" }, + { url = "https://files.pythonhosted.org/packages/e0/08/4d32af657e049b19cb62b02e46e38fe1518797bfb2203ee93a510b21b0dc/regex-2026.5.9-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5b73ab8afcf66c622db143d1c6fda4e58e4d537ee4f125229ad47b1ab80f34c0", size = 911530, upload-time = "2026-05-09T23:14:20.353Z" }, + { url = "https://files.pythonhosted.org/packages/d9/27/2af43dd1dc201d1fecefda64a45f4ad0995855b92724f795a777b402ee69/regex-2026.5.9-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0de5cf193997384ed2ca6f1cd4f78055b255d93d82d5a8cd6ba0d11c10b167e4", size = 800643, upload-time = "2026-05-09T23:14:22.265Z" }, + { url = "https://files.pythonhosted.org/packages/a4/dd/23a249047013b5321d4a60c4d2437462086f601b061776a525e5fba2a59f/regex-2026.5.9-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d641a8c9a61618047796d572a39a79b26167b0411d2c3031937b2fe2d081e2cf", size = 777223, upload-time = "2026-05-09T23:14:24.179Z" }, + { url = "https://files.pythonhosted.org/packages/94/6a/e85ed9538cd19586d0465076a4578a12e093ce776d15f3f8ce92733a8dd6/regex-2026.5.9-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:24b2355ef5cc9aa5b8f07d17704face1c166fdcc2290fa7bd6e6c925655a8346", size = 785760, upload-time = "2026-05-09T23:14:26.065Z" }, + { url = "https://files.pythonhosted.org/packages/2a/c4/f25473209438638e947c55f9156fd8f236f74169229028cc99116380868e/regex-2026.5.9-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:a24852d3c29ad9e47593593d8a247c44ccc3d0548ef12c822d6ed0810affe676", size = 860891, upload-time = "2026-05-09T23:14:28.17Z" }, + { url = "https://files.pythonhosted.org/packages/f9/f7/f4f86e3c74419c37370e91f150ae0c2ef7d34b2e0e4cdd5da046a02e4022/regex-2026.5.9-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:916714069da19329ef7de197dcbc77bb3104145c7c2c864dbfbe318f46b88b14", size = 765891, upload-time = "2026-05-09T23:14:30.06Z" }, + { url = "https://files.pythonhosted.org/packages/26/70/704d8e13765939146b1cd0ef4e2feb71d7929727d2290f026eed10095955/regex-2026.5.9-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:fa411799ca8da32a8d38d020a88faa5b6f91657d284761352940ecf9f7c3bbdd", size = 851380, upload-time = "2026-05-09T23:14:32.123Z" }, + { url = "https://files.pythonhosted.org/packages/26/29/1a13582a8460038edc38e49f64ceb0dd7c60f5caba77571f4bf6601965d9/regex-2026.5.9-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:1e6da47d679b7010ef27556b6e0f99771b744936db1792a10ceac6547ae1503e", size = 789350, upload-time = "2026-05-09T23:14:34.799Z" }, + { url = "https://files.pythonhosted.org/packages/73/56/3dcafe34fc72e271d62ad9a291801e88a1457bb251c132f15fcc2e5aad1a/regex-2026.5.9-cp314-cp314-win32.whl", hash = "sha256:98bd73080e8756255137e1bd3f3f00295bbc5aa383c0e0f973920e9134d7c4ad", size = 272130, upload-time = "2026-05-09T23:14:36.729Z" }, + { url = "https://files.pythonhosted.org/packages/d0/9c/02eebf0be95efe416c664db7fb8b6b05b7a0b06a7544f2884f2558b0526f/regex-2026.5.9-cp314-cp314-win_amd64.whl", hash = "sha256:ff8d372ac2acdc048d1c19916f27ee61bc5722728458ba6ca5052f2c72d51763", size = 280999, upload-time = "2026-05-09T23:14:39.126Z" }, + { url = "https://files.pythonhosted.org/packages/70/5a/1dd1abee76cb7a846a0bcf42fdc87e5720c3c33c24f3e37814310a513d9f/regex-2026.5.9-cp314-cp314-win_arm64.whl", hash = "sha256:e1d93bf647916292e8edcec150c07ddf3dc50179ccaf770c04a7f9e452155372", size = 273500, upload-time = "2026-05-09T23:14:41.059Z" }, + { url = "https://files.pythonhosted.org/packages/86/c1/c5f619b0057a7965cb78ec559c1d7a45ce8c99a35bea95483d64959a93d9/regex-2026.5.9-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:83d0ee4a57d1c87cb549e195ec300b8f0ec3a82eba66d835e4e2ed8634fe4499", size = 494269, upload-time = "2026-05-09T23:14:42.869Z" }, + { url = "https://files.pythonhosted.org/packages/05/2c/5d01f1aee33de4bbe60c8452945bfc8477ca7c5ae4450f6bfe711036cb36/regex-2026.5.9-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:d3d7eb5c9a7f6df82ed3cfac9beb93882a5cbcb5b8b157b56cb2b3b276574ac1", size = 293954, upload-time = "2026-05-09T23:14:44.822Z" }, + { url = "https://files.pythonhosted.org/packages/7a/fe/e8988b2ae2108c6ef71bd4aa8d87fbe257976dd0810e826cd75f701c68b6/regex-2026.5.9-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:075160bf16658e16d35233300b8453aac25de4cbea808d22348b6979668e924d", size = 292405, upload-time = "2026-05-09T23:14:47.211Z" }, + { url = "https://files.pythonhosted.org/packages/79/34/d2b0937faa7859263f7f0a3c6b103a1296306be6952dc173d0154e9a2f49/regex-2026.5.9-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:45375819235558a4ff1c4971dc32881f022613abdb180128f5cb4768c1765a1c", size = 811855, upload-time = "2026-05-09T23:14:49.21Z" }, + { url = "https://files.pythonhosted.org/packages/80/fe/daf53a47457a8486db66c66c01ceb9c2303eecee3f87197f1e77eb1a736d/regex-2026.5.9-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ead4b163ac30a29574510cd4b3e2e985ac5290c05fc7095557d6a5f403fc31b5", size = 871189, upload-time = "2026-05-09T23:14:51.555Z" }, + { url = "https://files.pythonhosted.org/packages/1c/75/058fc4470cbfbf57d800aff1a0022b929a3f9fa553ee10a0cdf2070eb31f/regex-2026.5.9-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8c6e4218fbdfbcd4f6c19efca40930d24a621bf4b48cb76bc6640543bd28ef20", size = 917485, upload-time = "2026-05-09T23:14:53.633Z" }, + { url = "https://files.pythonhosted.org/packages/88/e7/179cfda3a28bc843b5c6cfe7f79f23489c791ed95f151083803660878432/regex-2026.5.9-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6351571c8a42b505eb555c0dc47d740d0fb66977dc142919eea6f4325b7c56a0", size = 816369, upload-time = "2026-05-09T23:14:56.198Z" }, + { url = "https://files.pythonhosted.org/packages/41/90/6f0cc422071688266d344fca8462d787cba0a2c144acb25721f9a61ec265/regex-2026.5.9-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:002205cafd2a9e78c6290c7d1df277bf3277b3b7a30e0b4bb0dac2e2e3f7cb2d", size = 785869, upload-time = "2026-05-09T23:14:58.602Z" }, + { url = "https://files.pythonhosted.org/packages/02/67/a31f1760f09c27b251ef39e9beb541f462cf977381d067faa764c2c0e393/regex-2026.5.9-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8abd33fef90b2a9efac5557d6033ca82d1195ed3a15fea5af15ba7b463c6a63b", size = 801427, upload-time = "2026-05-09T23:15:00.642Z" }, + { url = "https://files.pythonhosted.org/packages/e3/c4/1a80654597b6bc1e1ea0494824c31200e8a956abe290afae9b19a166a148/regex-2026.5.9-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:31037c82eccb44b7ea2e9e221d7c01429430e989a1f4b91ea5a855f6017b509a", size = 866482, upload-time = "2026-05-09T23:15:03.384Z" }, + { url = "https://files.pythonhosted.org/packages/d1/11/960724e06482c08466ff5611e242e86f80062949cdf6b4b9cc317b9dd93d/regex-2026.5.9-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:5604dfd046dc37eca90250fc3be938b076c8059fa772ac0ed6f499b0f0fb0415", size = 773022, upload-time = "2026-05-09T23:15:05.625Z" }, + { url = "https://files.pythonhosted.org/packages/50/a8/a9979c3e7918280e93159ebcab5ef1a65116dd4f3bd6091be0eae4a126e8/regex-2026.5.9-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:0e1b1b4e496afbb24f4a62aba855ee4f88f25578927697b340702e48c9ee6bc2", size = 856642, upload-time = "2026-05-09T23:15:07.966Z" }, + { url = "https://files.pythonhosted.org/packages/fe/d4/a9b732f2f0072c0ab12227483abb24fffcb9f73f8a2b203df0a6d0434735/regex-2026.5.9-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:be3372b9df6ddecff6486d37e19095a7b4973137caf5512407a89f4455361f41", size = 803552, upload-time = "2026-05-09T23:15:10.215Z" }, + { url = "https://files.pythonhosted.org/packages/d5/fe/1b3113817447a1d4155e4ac76d2e072f42c0bcba2f43fa8a0e756ea2cd91/regex-2026.5.9-cp314-cp314t-win32.whl", hash = "sha256:3ddd90103f9e5c471c49c7852ecc1fe27c7e45eb99e977aefe7caa4e779f4f58", size = 275746, upload-time = "2026-05-09T23:15:12.609Z" }, + { url = "https://files.pythonhosted.org/packages/92/73/93d42045302636c91f2e5ef588b65b84b01428f28ec77de256b1dfdfbe5c/regex-2026.5.9-cp314-cp314t-win_amd64.whl", hash = "sha256:ca518ed29c46eecba6010b15f1b9a479314d2de409536e71b6a13aa04e3b8a77", size = 285685, upload-time = "2026-05-09T23:15:15.086Z" }, + { url = "https://files.pythonhosted.org/packages/da/80/35b4c33c804a165a7f55289afda3ea9e3eb6d15800341a2d66455c0f1f30/regex-2026.5.9-cp314-cp314t-win_arm64.whl", hash = "sha256:5e41809d2683fcde7d5a8c87a6567ba1fb1ce0de9f31bff578de00a4b2d76daa", size = 275713, upload-time = "2026-05-09T23:15:16.98Z" }, ] [[package]] @@ -1191,14 +1192,14 @@ name = "requests" version = "2.34.2" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "certifi" }, - { name = "charset-normalizer" }, - { name = "idna" }, - { name = "urllib3" }, + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, ] sdist = { url = "https://files.pythonhosted.org/packages/ac/c3/e2a2b89f2d3e2179abd6d00ebd70bff6273f37fb3e0cc209f48b39d00cbf/requests-2.34.2.tar.gz", hash = "sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed", size = 142856, upload-time = "2026-05-14T19:25:27.735Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0", size = 73075, upload-time = "2026-05-14T19:25:26.443Z" }, + { url = "https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0", size = 73075, upload-time = "2026-05-14T19:25:26.443Z" }, ] [[package]] @@ -1206,12 +1207,12 @@ name = "rich" version = "15.0.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "markdown-it-py" }, - { name = "pygments" }, + { name = "markdown-it-py" }, + { name = "pygments" }, ] sdist = { url = "https://files.pythonhosted.org/packages/c0/8f/0722ca900cc807c13a6a0c696dacf35430f72e0ec571c4275d2371fca3e9/rich-15.0.0.tar.gz", hash = "sha256:edd07a4824c6b40189fb7ac9bc4c52536e9780fbbfbddf6f1e2502c31b068c36", size = 230680, upload-time = "2026-04-12T08:24:00.75Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/82/3b/64d4899d73f91ba49a8c18a8ff3f0ea8f1c1d75481760df8c68ef5235bf5/rich-15.0.0-py3-none-any.whl", hash = "sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb", size = 310654, upload-time = "2026-04-12T08:24:02.83Z" }, + { url = "https://files.pythonhosted.org/packages/82/3b/64d4899d73f91ba49a8c18a8ff3f0ea8f1c1d75481760df8c68ef5235bf5/rich-15.0.0-py3-none-any.whl", hash = "sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb", size = 310654, upload-time = "2026-04-12T08:24:02.83Z" }, ] [[package]] @@ -1220,23 +1221,23 @@ version = "0.15.14" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/dc/8a/8bce2894573e9dae6ff4d77fe34ad727d79b9e6238ad288c5638990d90f6/ruff-0.15.14.tar.gz", hash = "sha256:48e866b165be4a9bdbf310f7d3c9a07edef2fe8cd63ffeb4e00bb590506ebf9f", size = 4700910, upload-time = "2026-05-21T14:34:55.177Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b9/c8/74a92c6ff9fcfb4f1f947126d3ebee8389276e161ecc85de5bda7cda51bd/ruff-0.15.14-py3-none-linux_armv6l.whl", hash = "sha256:8dd2db9416e487c8d4b01fa7056bb02c4d05969d4f8d17a08c229c2f4ff3c108", size = 10739177, upload-time = "2026-05-21T14:34:37.332Z" }, - { url = "https://files.pythonhosted.org/packages/45/91/254a35c20acc38a7223c9d2d594af12e794432464f2cdeb52af1dc4a892d/ruff-0.15.14-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:be4ff55af755bd71a00ab3dc6bd7ffc467bd76e0df6881e286c2e3d23e8fb43b", size = 11144969, upload-time = "2026-05-21T14:34:43.978Z" }, - { url = "https://files.pythonhosted.org/packages/56/9e/d13e40f83b8d0a94430e6778ce1d94a43b38cf2efe63278bdd2b4c65abbf/ruff-0.15.14-py3-none-macosx_11_0_arm64.whl", hash = "sha256:48d5909d7d06276ce7dde6d32bfa4b0d4cb2651145cd8ee4b440722cbc77832f", size = 10478207, upload-time = "2026-05-21T14:34:48.378Z" }, - { url = "https://files.pythonhosted.org/packages/8d/f1/b15a7839fa4f332f8acec78e20564f26bb2d866e3d21710b877fd0263000/ruff-0.15.14-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ca8cbfa94c4f90984a67561978602746d4cd27103568f745fa90eee3f0d4107d", size = 10818459, upload-time = "2026-05-21T14:34:22.318Z" }, - { url = "https://files.pythonhosted.org/packages/45/33/53d651177f84f94b400a0e27f8824eeada3dddc9d5ee8aeb048f4352a520/ruff-0.15.14-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9a6bbc0333f1ab053423bcbf6226477d266ca7cec7738c4c8e3f55647803f3c4", size = 10541800, upload-time = "2026-05-21T14:34:20.209Z" }, - { url = "https://files.pythonhosted.org/packages/b8/a6/868f87e0bf9786ed24b5d0d0ad8676b8a94fd1912f42cddf9cfc7857818a/ruff-0.15.14-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8a24a4f7605d7003a6674d4387651effd939dead3fddd0f36561eb77a9a2e542", size = 11342149, upload-time = "2026-05-21T14:34:46.365Z" }, - { url = "https://files.pythonhosted.org/packages/a7/8b/38cd5c19faffdcc05a408d2b78edccc69492ab9720eadb49ea15ef80d768/ruff-0.15.14-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:049b5326e53ed80978f2fc041a280603f69dd6b0c95464342a2bb4572d9d9e2f", size = 12212563, upload-time = "2026-05-21T14:34:28.579Z" }, - { url = "https://files.pythonhosted.org/packages/3e/4d/a3c5b874a556d5731e3e657aaf04311bb76f0a5c3ec220ed43051be6b64b/ruff-0.15.14-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d4ed42e6696c8dfa5f06728e6441993901f548eb92d73bc472cb5a38d1395fbf", size = 11493299, upload-time = "2026-05-21T14:34:41.836Z" }, - { url = "https://files.pythonhosted.org/packages/1e/c0/56472c251d09858a53e51efbd485b09e1995d8731668b76d52e5dd6ee0f1/ruff-0.15.14-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:715c543cf450c4888251f91c52f1942a800541d9bddd7ac060aa4e6b77ae7cba", size = 11455931, upload-time = "2026-05-21T14:34:57.276Z" }, - { url = "https://files.pythonhosted.org/packages/2c/4a/e2e7b4d8dbf233d4eace59c75bc3435fa6d8bd3bae82d351d4e4300c0fd1/ruff-0.15.14-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:72ebab6013ec887d439d8b7593737a0a4ffb06d45d209d4e4bf2e92813082d3f", size = 11400794, upload-time = "2026-05-21T14:34:39.773Z" }, - { url = "https://files.pythonhosted.org/packages/97/c7/83c0539fe34c3e09136204d1e75d6052492364e0b3cb05e9465423f567d7/ruff-0.15.14-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:49072d36abdbe97a8dd7f480afe9c675699c0c495d4c84076e2c1203c4550581", size = 10804759, upload-time = "2026-05-21T14:34:31.045Z" }, - { url = "https://files.pythonhosted.org/packages/86/a6/18f2bfc095a2ab4a78745644e428205532ce6653a5d0fa8501572891534d/ruff-0.15.14-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:958522aee105068640c2c2ceae08f413ae44d922f52a1374ac13d6a96032fc93", size = 10539517, upload-time = "2026-05-21T14:34:53.064Z" }, - { url = "https://files.pythonhosted.org/packages/54/3a/5a8b3b69c654d4e4bf1d246ac5b49cbcdac6eaab6905925f8915f31e3b80/ruff-0.15.14-py3-none-musllinux_1_2_i686.whl", hash = "sha256:f3707da619a143a2e8830e2abab8224478d69ace2d28cb6c20543ae97c36bf61", size = 11065169, upload-time = "2026-05-21T14:34:24.484Z" }, - { url = "https://files.pythonhosted.org/packages/ed/c5/8864e4e7925b836ea354b31d57641ec03830564e281a8b6f061f8c3e0ec1/ruff-0.15.14-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:bb01d645694e3ec0102105d07ef2d53703970407d59c04e59d3ba0b7a1d53553", size = 11560214, upload-time = "2026-05-21T14:34:50.975Z" }, - { url = "https://files.pythonhosted.org/packages/36/38/012bf76752e1f89ed50b77b99532d90f3a3e287bc7918e1fc0948ac866ac/ruff-0.15.14-py3-none-win32.whl", hash = "sha256:6d0c1ad2a0ab718d39b6d8fd2217981ce4d625cd96a720095f798fb47d8b13e6", size = 10805548, upload-time = "2026-05-21T14:34:33.453Z" }, - { url = "https://files.pythonhosted.org/packages/d1/b7/4ea2c170f10ad760fff2a5250beb18897719dc8b52b53a24cddbb9dd3f19/ruff-0.15.14-py3-none-win_amd64.whl", hash = "sha256:802342981e056db3851a7836e5b070f8f15f67d4a685ae2a6160939d364b2902", size = 11939523, upload-time = "2026-05-21T14:34:18.077Z" }, - { url = "https://files.pythonhosted.org/packages/62/d5/bc97ff895ec35cf3925d4bd60f3b39d822f377a446906ec9bcc87405e59b/ruff-0.15.14-py3-none-win_arm64.whl", hash = "sha256:ff47b90a9ef6a40c9e2f3b479c1fb78531adf055b94c1eba0a7ba04b31951826", size = 11208607, upload-time = "2026-05-21T14:34:26.525Z" }, + { url = "https://files.pythonhosted.org/packages/b9/c8/74a92c6ff9fcfb4f1f947126d3ebee8389276e161ecc85de5bda7cda51bd/ruff-0.15.14-py3-none-linux_armv6l.whl", hash = "sha256:8dd2db9416e487c8d4b01fa7056bb02c4d05969d4f8d17a08c229c2f4ff3c108", size = 10739177, upload-time = "2026-05-21T14:34:37.332Z" }, + { url = "https://files.pythonhosted.org/packages/45/91/254a35c20acc38a7223c9d2d594af12e794432464f2cdeb52af1dc4a892d/ruff-0.15.14-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:be4ff55af755bd71a00ab3dc6bd7ffc467bd76e0df6881e286c2e3d23e8fb43b", size = 11144969, upload-time = "2026-05-21T14:34:43.978Z" }, + { url = "https://files.pythonhosted.org/packages/56/9e/d13e40f83b8d0a94430e6778ce1d94a43b38cf2efe63278bdd2b4c65abbf/ruff-0.15.14-py3-none-macosx_11_0_arm64.whl", hash = "sha256:48d5909d7d06276ce7dde6d32bfa4b0d4cb2651145cd8ee4b440722cbc77832f", size = 10478207, upload-time = "2026-05-21T14:34:48.378Z" }, + { url = "https://files.pythonhosted.org/packages/8d/f1/b15a7839fa4f332f8acec78e20564f26bb2d866e3d21710b877fd0263000/ruff-0.15.14-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ca8cbfa94c4f90984a67561978602746d4cd27103568f745fa90eee3f0d4107d", size = 10818459, upload-time = "2026-05-21T14:34:22.318Z" }, + { url = "https://files.pythonhosted.org/packages/45/33/53d651177f84f94b400a0e27f8824eeada3dddc9d5ee8aeb048f4352a520/ruff-0.15.14-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9a6bbc0333f1ab053423bcbf6226477d266ca7cec7738c4c8e3f55647803f3c4", size = 10541800, upload-time = "2026-05-21T14:34:20.209Z" }, + { url = "https://files.pythonhosted.org/packages/b8/a6/868f87e0bf9786ed24b5d0d0ad8676b8a94fd1912f42cddf9cfc7857818a/ruff-0.15.14-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8a24a4f7605d7003a6674d4387651effd939dead3fddd0f36561eb77a9a2e542", size = 11342149, upload-time = "2026-05-21T14:34:46.365Z" }, + { url = "https://files.pythonhosted.org/packages/a7/8b/38cd5c19faffdcc05a408d2b78edccc69492ab9720eadb49ea15ef80d768/ruff-0.15.14-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:049b5326e53ed80978f2fc041a280603f69dd6b0c95464342a2bb4572d9d9e2f", size = 12212563, upload-time = "2026-05-21T14:34:28.579Z" }, + { url = "https://files.pythonhosted.org/packages/3e/4d/a3c5b874a556d5731e3e657aaf04311bb76f0a5c3ec220ed43051be6b64b/ruff-0.15.14-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d4ed42e6696c8dfa5f06728e6441993901f548eb92d73bc472cb5a38d1395fbf", size = 11493299, upload-time = "2026-05-21T14:34:41.836Z" }, + { url = "https://files.pythonhosted.org/packages/1e/c0/56472c251d09858a53e51efbd485b09e1995d8731668b76d52e5dd6ee0f1/ruff-0.15.14-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:715c543cf450c4888251f91c52f1942a800541d9bddd7ac060aa4e6b77ae7cba", size = 11455931, upload-time = "2026-05-21T14:34:57.276Z" }, + { url = "https://files.pythonhosted.org/packages/2c/4a/e2e7b4d8dbf233d4eace59c75bc3435fa6d8bd3bae82d351d4e4300c0fd1/ruff-0.15.14-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:72ebab6013ec887d439d8b7593737a0a4ffb06d45d209d4e4bf2e92813082d3f", size = 11400794, upload-time = "2026-05-21T14:34:39.773Z" }, + { url = "https://files.pythonhosted.org/packages/97/c7/83c0539fe34c3e09136204d1e75d6052492364e0b3cb05e9465423f567d7/ruff-0.15.14-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:49072d36abdbe97a8dd7f480afe9c675699c0c495d4c84076e2c1203c4550581", size = 10804759, upload-time = "2026-05-21T14:34:31.045Z" }, + { url = "https://files.pythonhosted.org/packages/86/a6/18f2bfc095a2ab4a78745644e428205532ce6653a5d0fa8501572891534d/ruff-0.15.14-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:958522aee105068640c2c2ceae08f413ae44d922f52a1374ac13d6a96032fc93", size = 10539517, upload-time = "2026-05-21T14:34:53.064Z" }, + { url = "https://files.pythonhosted.org/packages/54/3a/5a8b3b69c654d4e4bf1d246ac5b49cbcdac6eaab6905925f8915f31e3b80/ruff-0.15.14-py3-none-musllinux_1_2_i686.whl", hash = "sha256:f3707da619a143a2e8830e2abab8224478d69ace2d28cb6c20543ae97c36bf61", size = 11065169, upload-time = "2026-05-21T14:34:24.484Z" }, + { url = "https://files.pythonhosted.org/packages/ed/c5/8864e4e7925b836ea354b31d57641ec03830564e281a8b6f061f8c3e0ec1/ruff-0.15.14-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:bb01d645694e3ec0102105d07ef2d53703970407d59c04e59d3ba0b7a1d53553", size = 11560214, upload-time = "2026-05-21T14:34:50.975Z" }, + { url = "https://files.pythonhosted.org/packages/36/38/012bf76752e1f89ed50b77b99532d90f3a3e287bc7918e1fc0948ac866ac/ruff-0.15.14-py3-none-win32.whl", hash = "sha256:6d0c1ad2a0ab718d39b6d8fd2217981ce4d625cd96a720095f798fb47d8b13e6", size = 10805548, upload-time = "2026-05-21T14:34:33.453Z" }, + { url = "https://files.pythonhosted.org/packages/d1/b7/4ea2c170f10ad760fff2a5250beb18897719dc8b52b53a24cddbb9dd3f19/ruff-0.15.14-py3-none-win_amd64.whl", hash = "sha256:802342981e056db3851a7836e5b070f8f15f67d4a685ae2a6160939d364b2902", size = 11939523, upload-time = "2026-05-21T14:34:18.077Z" }, + { url = "https://files.pythonhosted.org/packages/62/d5/bc97ff895ec35cf3925d4bd60f3b39d822f377a446906ec9bcc87405e59b/ruff-0.15.14-py3-none-win_arm64.whl", hash = "sha256:ff47b90a9ef6a40c9e2f3b479c1fb78531adf055b94c1eba0a7ba04b31951826", size = 11208607, upload-time = "2026-05-21T14:34:26.525Z" }, ] [[package]] @@ -1245,20 +1246,20 @@ version = "0.7.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/29/9c/6e74567782559a63bd040a236edca26fd71bc7ba88de2ef35d75df3bca5e/safetensors-0.7.0.tar.gz", hash = "sha256:07663963b67e8bd9f0b8ad15bb9163606cd27cc5a1b96235a50d8369803b96b0", size = 200878, upload-time = "2025-11-19T15:18:43.199Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/fa/47/aef6c06649039accf914afef490268e1067ed82be62bcfa5b7e886ad15e8/safetensors-0.7.0-cp38-abi3-macosx_10_12_x86_64.whl", hash = "sha256:c82f4d474cf725255d9e6acf17252991c3c8aac038d6ef363a4bf8be2f6db517", size = 467781, upload-time = "2025-11-19T15:18:35.84Z" }, - { url = "https://files.pythonhosted.org/packages/e8/00/374c0c068e30cd31f1e1b46b4b5738168ec79e7689ca82ee93ddfea05109/safetensors-0.7.0-cp38-abi3-macosx_11_0_arm64.whl", hash = "sha256:94fd4858284736bb67a897a41608b5b0c2496c9bdb3bf2af1fa3409127f20d57", size = 447058, upload-time = "2025-11-19T15:18:34.416Z" }, - { url = "https://files.pythonhosted.org/packages/f1/06/578ffed52c2296f93d7fd2d844cabfa92be51a587c38c8afbb8ae449ca89/safetensors-0.7.0-cp38-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e07d91d0c92a31200f25351f4acb2bc6aff7f48094e13ebb1d0fb995b54b6542", size = 491748, upload-time = "2025-11-19T15:18:09.79Z" }, - { url = "https://files.pythonhosted.org/packages/ae/33/1debbbb70e4791dde185edb9413d1fe01619255abb64b300157d7f15dddd/safetensors-0.7.0-cp38-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8469155f4cb518bafb4acf4865e8bb9d6804110d2d9bdcaa78564b9fd841e104", size = 503881, upload-time = "2025-11-19T15:18:16.145Z" }, - { url = "https://files.pythonhosted.org/packages/8e/1c/40c2ca924d60792c3be509833df711b553c60effbd91da6f5284a83f7122/safetensors-0.7.0-cp38-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:54bef08bf00a2bff599982f6b08e8770e09cc012d7bba00783fc7ea38f1fb37d", size = 623463, upload-time = "2025-11-19T15:18:21.11Z" }, - { url = "https://files.pythonhosted.org/packages/9b/3a/13784a9364bd43b0d61eef4bea2845039bc2030458b16594a1bd787ae26e/safetensors-0.7.0-cp38-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:42cb091236206bb2016d245c377ed383aa7f78691748f3bb6ee1bfa51ae2ce6a", size = 532855, upload-time = "2025-11-19T15:18:25.719Z" }, - { url = "https://files.pythonhosted.org/packages/a0/60/429e9b1cb3fc651937727befe258ea24122d9663e4d5709a48c9cbfceecb/safetensors-0.7.0-cp38-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dac7252938f0696ddea46f5e855dd3138444e82236e3be475f54929f0c510d48", size = 507152, upload-time = "2025-11-19T15:18:33.023Z" }, - { url = "https://files.pythonhosted.org/packages/3c/a8/4b45e4e059270d17af60359713ffd83f97900d45a6afa73aaa0d737d48b6/safetensors-0.7.0-cp38-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1d060c70284127fa805085d8f10fbd0962792aed71879d00864acda69dbab981", size = 541856, upload-time = "2025-11-19T15:18:31.075Z" }, - { url = "https://files.pythonhosted.org/packages/06/87/d26d8407c44175d8ae164a95b5a62707fcc445f3c0c56108e37d98070a3d/safetensors-0.7.0-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:cdab83a366799fa730f90a4ebb563e494f28e9e92c4819e556152ad55e43591b", size = 674060, upload-time = "2025-11-19T15:18:37.211Z" }, - { url = "https://files.pythonhosted.org/packages/11/f5/57644a2ff08dc6325816ba7217e5095f17269dada2554b658442c66aed51/safetensors-0.7.0-cp38-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:672132907fcad9f2aedcb705b2d7b3b93354a2aec1b2f706c4db852abe338f85", size = 771715, upload-time = "2025-11-19T15:18:38.689Z" }, - { url = "https://files.pythonhosted.org/packages/86/31/17883e13a814bd278ae6e266b13282a01049b0c81341da7fd0e3e71a80a3/safetensors-0.7.0-cp38-abi3-musllinux_1_2_i686.whl", hash = "sha256:5d72abdb8a4d56d4020713724ba81dac065fedb7f3667151c4a637f1d3fb26c0", size = 714377, upload-time = "2025-11-19T15:18:40.162Z" }, - { url = "https://files.pythonhosted.org/packages/4a/d8/0c8a7dc9b41dcac53c4cbf9df2b9c83e0e0097203de8b37a712b345c0be5/safetensors-0.7.0-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:b0f6d66c1c538d5a94a73aa9ddca8ccc4227e6c9ff555322ea40bdd142391dd4", size = 677368, upload-time = "2025-11-19T15:18:41.627Z" }, - { url = "https://files.pythonhosted.org/packages/05/e5/cb4b713c8a93469e3c5be7c3f8d77d307e65fe89673e731f5c2bfd0a9237/safetensors-0.7.0-cp38-abi3-win32.whl", hash = "sha256:c74af94bf3ac15ac4d0f2a7c7b4663a15f8c2ab15ed0fc7531ca61d0835eccba", size = 326423, upload-time = "2025-11-19T15:18:45.74Z" }, - { url = "https://files.pythonhosted.org/packages/5d/e6/ec8471c8072382cb91233ba7267fd931219753bb43814cbc71757bfd4dab/safetensors-0.7.0-cp38-abi3-win_amd64.whl", hash = "sha256:d1239932053f56f3456f32eb9625590cc7582e905021f94636202a864d470755", size = 341380, upload-time = "2025-11-19T15:18:44.427Z" }, + { url = "https://files.pythonhosted.org/packages/fa/47/aef6c06649039accf914afef490268e1067ed82be62bcfa5b7e886ad15e8/safetensors-0.7.0-cp38-abi3-macosx_10_12_x86_64.whl", hash = "sha256:c82f4d474cf725255d9e6acf17252991c3c8aac038d6ef363a4bf8be2f6db517", size = 467781, upload-time = "2025-11-19T15:18:35.84Z" }, + { url = "https://files.pythonhosted.org/packages/e8/00/374c0c068e30cd31f1e1b46b4b5738168ec79e7689ca82ee93ddfea05109/safetensors-0.7.0-cp38-abi3-macosx_11_0_arm64.whl", hash = "sha256:94fd4858284736bb67a897a41608b5b0c2496c9bdb3bf2af1fa3409127f20d57", size = 447058, upload-time = "2025-11-19T15:18:34.416Z" }, + { url = "https://files.pythonhosted.org/packages/f1/06/578ffed52c2296f93d7fd2d844cabfa92be51a587c38c8afbb8ae449ca89/safetensors-0.7.0-cp38-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e07d91d0c92a31200f25351f4acb2bc6aff7f48094e13ebb1d0fb995b54b6542", size = 491748, upload-time = "2025-11-19T15:18:09.79Z" }, + { url = "https://files.pythonhosted.org/packages/ae/33/1debbbb70e4791dde185edb9413d1fe01619255abb64b300157d7f15dddd/safetensors-0.7.0-cp38-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8469155f4cb518bafb4acf4865e8bb9d6804110d2d9bdcaa78564b9fd841e104", size = 503881, upload-time = "2025-11-19T15:18:16.145Z" }, + { url = "https://files.pythonhosted.org/packages/8e/1c/40c2ca924d60792c3be509833df711b553c60effbd91da6f5284a83f7122/safetensors-0.7.0-cp38-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:54bef08bf00a2bff599982f6b08e8770e09cc012d7bba00783fc7ea38f1fb37d", size = 623463, upload-time = "2025-11-19T15:18:21.11Z" }, + { url = "https://files.pythonhosted.org/packages/9b/3a/13784a9364bd43b0d61eef4bea2845039bc2030458b16594a1bd787ae26e/safetensors-0.7.0-cp38-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:42cb091236206bb2016d245c377ed383aa7f78691748f3bb6ee1bfa51ae2ce6a", size = 532855, upload-time = "2025-11-19T15:18:25.719Z" }, + { url = "https://files.pythonhosted.org/packages/a0/60/429e9b1cb3fc651937727befe258ea24122d9663e4d5709a48c9cbfceecb/safetensors-0.7.0-cp38-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dac7252938f0696ddea46f5e855dd3138444e82236e3be475f54929f0c510d48", size = 507152, upload-time = "2025-11-19T15:18:33.023Z" }, + { url = "https://files.pythonhosted.org/packages/3c/a8/4b45e4e059270d17af60359713ffd83f97900d45a6afa73aaa0d737d48b6/safetensors-0.7.0-cp38-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1d060c70284127fa805085d8f10fbd0962792aed71879d00864acda69dbab981", size = 541856, upload-time = "2025-11-19T15:18:31.075Z" }, + { url = "https://files.pythonhosted.org/packages/06/87/d26d8407c44175d8ae164a95b5a62707fcc445f3c0c56108e37d98070a3d/safetensors-0.7.0-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:cdab83a366799fa730f90a4ebb563e494f28e9e92c4819e556152ad55e43591b", size = 674060, upload-time = "2025-11-19T15:18:37.211Z" }, + { url = "https://files.pythonhosted.org/packages/11/f5/57644a2ff08dc6325816ba7217e5095f17269dada2554b658442c66aed51/safetensors-0.7.0-cp38-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:672132907fcad9f2aedcb705b2d7b3b93354a2aec1b2f706c4db852abe338f85", size = 771715, upload-time = "2025-11-19T15:18:38.689Z" }, + { url = "https://files.pythonhosted.org/packages/86/31/17883e13a814bd278ae6e266b13282a01049b0c81341da7fd0e3e71a80a3/safetensors-0.7.0-cp38-abi3-musllinux_1_2_i686.whl", hash = "sha256:5d72abdb8a4d56d4020713724ba81dac065fedb7f3667151c4a637f1d3fb26c0", size = 714377, upload-time = "2025-11-19T15:18:40.162Z" }, + { url = "https://files.pythonhosted.org/packages/4a/d8/0c8a7dc9b41dcac53c4cbf9df2b9c83e0e0097203de8b37a712b345c0be5/safetensors-0.7.0-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:b0f6d66c1c538d5a94a73aa9ddca8ccc4227e6c9ff555322ea40bdd142391dd4", size = 677368, upload-time = "2025-11-19T15:18:41.627Z" }, + { url = "https://files.pythonhosted.org/packages/05/e5/cb4b713c8a93469e3c5be7c3f8d77d307e65fe89673e731f5c2bfd0a9237/safetensors-0.7.0-cp38-abi3-win32.whl", hash = "sha256:c74af94bf3ac15ac4d0f2a7c7b4663a15f8c2ab15ed0fc7531ca61d0835eccba", size = 326423, upload-time = "2025-11-19T15:18:45.74Z" }, + { url = "https://files.pythonhosted.org/packages/5d/e6/ec8471c8072382cb91233ba7267fd931219753bb43814cbc71757bfd4dab/safetensors-0.7.0-cp38-abi3-win_amd64.whl", hash = "sha256:d1239932053f56f3456f32eb9625590cc7582e905021f94636202a864d470755", size = 341380, upload-time = "2025-11-19T15:18:44.427Z" }, ] [[package]] @@ -1266,37 +1267,37 @@ name = "scikit-learn" version = "1.8.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "joblib" }, - { name = "numpy" }, - { name = "scipy" }, - { name = "threadpoolctl" }, + { name = "joblib" }, + { name = "numpy" }, + { name = "scipy" }, + { name = "threadpoolctl" }, ] sdist = { url = "https://files.pythonhosted.org/packages/0e/d4/40988bf3b8e34feec1d0e6a051446b1f66225f8529b9309becaeef62b6c4/scikit_learn-1.8.0.tar.gz", hash = "sha256:9bccbb3b40e3de10351f8f5068e105d0f4083b1a65fa07b6634fbc401a6287fd", size = 7335585, upload-time = "2025-12-10T07:08:53.618Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/03/aa/e22e0768512ce9255eba34775be2e85c2048da73da1193e841707f8f039c/scikit_learn-1.8.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0d6ae97234d5d7079dc0040990a6f7aeb97cb7fa7e8945f1999a429b23569e0a", size = 8513770, upload-time = "2025-12-10T07:08:03.251Z" }, - { url = "https://files.pythonhosted.org/packages/58/37/31b83b2594105f61a381fc74ca19e8780ee923be2d496fcd8d2e1147bd99/scikit_learn-1.8.0-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:edec98c5e7c128328124a029bceb09eda2d526997780fef8d65e9a69eead963e", size = 8044458, upload-time = "2025-12-10T07:08:05.336Z" }, - { url = "https://files.pythonhosted.org/packages/2d/5a/3f1caed8765f33eabb723596666da4ebbf43d11e96550fb18bdec42b467b/scikit_learn-1.8.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:74b66d8689d52ed04c271e1329f0c61635bcaf5b926db9b12d58914cdc01fe57", size = 8610341, upload-time = "2025-12-10T07:08:07.732Z" }, - { url = "https://files.pythonhosted.org/packages/38/cf/06896db3f71c75902a8e9943b444a56e727418f6b4b4a90c98c934f51ed4/scikit_learn-1.8.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8fdf95767f989b0cfedb85f7ed8ca215d4be728031f56ff5a519ee1e3276dc2e", size = 8900022, upload-time = "2025-12-10T07:08:09.862Z" }, - { url = "https://files.pythonhosted.org/packages/1c/f9/9b7563caf3ec8873e17a31401858efab6b39a882daf6c1bfa88879c0aa11/scikit_learn-1.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:2de443b9373b3b615aec1bb57f9baa6bb3a9bd093f1269ba95c17d870422b271", size = 7989409, upload-time = "2025-12-10T07:08:12.028Z" }, - { url = "https://files.pythonhosted.org/packages/49/bd/1f4001503650e72c4f6009ac0c4413cb17d2d601cef6f71c0453da2732fc/scikit_learn-1.8.0-cp313-cp313-win_arm64.whl", hash = "sha256:eddde82a035681427cbedded4e6eff5e57fa59216c2e3e90b10b19ab1d0a65c3", size = 7619760, upload-time = "2025-12-10T07:08:13.688Z" }, - { url = "https://files.pythonhosted.org/packages/d2/7d/a630359fc9dcc95496588c8d8e3245cc8fd81980251079bc09c70d41d951/scikit_learn-1.8.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:7cc267b6108f0a1499a734167282c00c4ebf61328566b55ef262d48e9849c735", size = 8826045, upload-time = "2025-12-10T07:08:15.215Z" }, - { url = "https://files.pythonhosted.org/packages/cc/56/a0c86f6930cfcd1c7054a2bc417e26960bb88d32444fe7f71d5c2cfae891/scikit_learn-1.8.0-cp313-cp313t-macosx_12_0_arm64.whl", hash = "sha256:fe1c011a640a9f0791146011dfd3c7d9669785f9fed2b2a5f9e207536cf5c2fd", size = 8420324, upload-time = "2025-12-10T07:08:17.561Z" }, - { url = "https://files.pythonhosted.org/packages/46/1e/05962ea1cebc1cf3876667ecb14c283ef755bf409993c5946ade3b77e303/scikit_learn-1.8.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:72358cce49465d140cc4e7792015bb1f0296a9742d5622c67e31399b75468b9e", size = 8680651, upload-time = "2025-12-10T07:08:19.952Z" }, - { url = "https://files.pythonhosted.org/packages/fe/56/a85473cd75f200c9759e3a5f0bcab2d116c92a8a02ee08ccd73b870f8bb4/scikit_learn-1.8.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:80832434a6cc114f5219211eec13dcbc16c2bac0e31ef64c6d346cde3cf054cb", size = 8925045, upload-time = "2025-12-10T07:08:22.11Z" }, - { url = "https://files.pythonhosted.org/packages/cc/b7/64d8cfa896c64435ae57f4917a548d7ac7a44762ff9802f75a79b77cb633/scikit_learn-1.8.0-cp313-cp313t-win_amd64.whl", hash = "sha256:ee787491dbfe082d9c3013f01f5991658b0f38aa8177e4cd4bf434c58f551702", size = 8507994, upload-time = "2025-12-10T07:08:23.943Z" }, - { url = "https://files.pythonhosted.org/packages/5e/37/e192ea709551799379958b4c4771ec507347027bb7c942662c7fbeba31cb/scikit_learn-1.8.0-cp313-cp313t-win_arm64.whl", hash = "sha256:bf97c10a3f5a7543f9b88cbf488d33d175e9146115a451ae34568597ba33dcde", size = 7869518, upload-time = "2025-12-10T07:08:25.71Z" }, - { url = "https://files.pythonhosted.org/packages/24/05/1af2c186174cc92dcab2233f327336058c077d38f6fe2aceb08e6ab4d509/scikit_learn-1.8.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:c22a2da7a198c28dd1a6e1136f19c830beab7fdca5b3e5c8bba8394f8a5c45b3", size = 8528667, upload-time = "2025-12-10T07:08:27.541Z" }, - { url = "https://files.pythonhosted.org/packages/a8/25/01c0af38fe969473fb292bba9dc2b8f9b451f3112ff242c647fee3d0dfe7/scikit_learn-1.8.0-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:6b595b07a03069a2b1740dc08c2299993850ea81cce4fe19b2421e0c970de6b7", size = 8066524, upload-time = "2025-12-10T07:08:29.822Z" }, - { url = "https://files.pythonhosted.org/packages/be/ce/a0623350aa0b68647333940ee46fe45086c6060ec604874e38e9ab7d8e6c/scikit_learn-1.8.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:29ffc74089f3d5e87dfca4c2c8450f88bdc61b0fc6ed5d267f3988f19a1309f6", size = 8657133, upload-time = "2025-12-10T07:08:31.865Z" }, - { url = "https://files.pythonhosted.org/packages/b8/cb/861b41341d6f1245e6ca80b1c1a8c4dfce43255b03df034429089ca2a2c5/scikit_learn-1.8.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fb65db5d7531bccf3a4f6bec3462223bea71384e2cda41da0f10b7c292b9e7c4", size = 8923223, upload-time = "2025-12-10T07:08:34.166Z" }, - { url = "https://files.pythonhosted.org/packages/76/18/a8def8f91b18cd1ba6e05dbe02540168cb24d47e8dcf69e8d00b7da42a08/scikit_learn-1.8.0-cp314-cp314-win_amd64.whl", hash = "sha256:56079a99c20d230e873ea40753102102734c5953366972a71d5cb39a32bc40c6", size = 8096518, upload-time = "2025-12-10T07:08:36.339Z" }, - { url = "https://files.pythonhosted.org/packages/d1/77/482076a678458307f0deb44e29891d6022617b2a64c840c725495bee343f/scikit_learn-1.8.0-cp314-cp314-win_arm64.whl", hash = "sha256:3bad7565bc9cf37ce19a7c0d107742b320c1285df7aab1a6e2d28780df167242", size = 7754546, upload-time = "2025-12-10T07:08:38.128Z" }, - { url = "https://files.pythonhosted.org/packages/2d/d1/ef294ca754826daa043b2a104e59960abfab4cf653891037d19dd5b6f3cf/scikit_learn-1.8.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:4511be56637e46c25721e83d1a9cea9614e7badc7040c4d573d75fbe257d6fd7", size = 8848305, upload-time = "2025-12-10T07:08:41.013Z" }, - { url = "https://files.pythonhosted.org/packages/5b/e2/b1f8b05138ee813b8e1a4149f2f0d289547e60851fd1bb268886915adbda/scikit_learn-1.8.0-cp314-cp314t-macosx_12_0_arm64.whl", hash = "sha256:a69525355a641bf8ef136a7fa447672fb54fe8d60cab5538d9eb7c6438543fb9", size = 8432257, upload-time = "2025-12-10T07:08:42.873Z" }, - { url = "https://files.pythonhosted.org/packages/26/11/c32b2138a85dcb0c99f6afd13a70a951bfdff8a6ab42d8160522542fb647/scikit_learn-1.8.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c2656924ec73e5939c76ac4c8b026fc203b83d8900362eb2599d8aee80e4880f", size = 8678673, upload-time = "2025-12-10T07:08:45.362Z" }, - { url = "https://files.pythonhosted.org/packages/c7/57/51f2384575bdec454f4fe4e7a919d696c9ebce914590abf3e52d47607ab8/scikit_learn-1.8.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:15fc3b5d19cc2be65404786857f2e13c70c83dd4782676dd6814e3b89dc8f5b9", size = 8922467, upload-time = "2025-12-10T07:08:47.408Z" }, - { url = "https://files.pythonhosted.org/packages/35/4d/748c9e2872637a57981a04adc038dacaa16ba8ca887b23e34953f0b3f742/scikit_learn-1.8.0-cp314-cp314t-win_amd64.whl", hash = "sha256:00d6f1d66fbcf4eba6e356e1420d33cc06c70a45bb1363cd6f6a8e4ebbbdece2", size = 8774395, upload-time = "2025-12-10T07:08:49.337Z" }, - { url = "https://files.pythonhosted.org/packages/60/22/d7b2ebe4704a5e50790ba089d5c2ae308ab6bb852719e6c3bd4f04c3a363/scikit_learn-1.8.0-cp314-cp314t-win_arm64.whl", hash = "sha256:f28dd15c6bb0b66ba09728cf09fd8736c304be29409bd8445a080c1280619e8c", size = 8002647, upload-time = "2025-12-10T07:08:51.601Z" }, + { url = "https://files.pythonhosted.org/packages/03/aa/e22e0768512ce9255eba34775be2e85c2048da73da1193e841707f8f039c/scikit_learn-1.8.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0d6ae97234d5d7079dc0040990a6f7aeb97cb7fa7e8945f1999a429b23569e0a", size = 8513770, upload-time = "2025-12-10T07:08:03.251Z" }, + { url = "https://files.pythonhosted.org/packages/58/37/31b83b2594105f61a381fc74ca19e8780ee923be2d496fcd8d2e1147bd99/scikit_learn-1.8.0-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:edec98c5e7c128328124a029bceb09eda2d526997780fef8d65e9a69eead963e", size = 8044458, upload-time = "2025-12-10T07:08:05.336Z" }, + { url = "https://files.pythonhosted.org/packages/2d/5a/3f1caed8765f33eabb723596666da4ebbf43d11e96550fb18bdec42b467b/scikit_learn-1.8.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:74b66d8689d52ed04c271e1329f0c61635bcaf5b926db9b12d58914cdc01fe57", size = 8610341, upload-time = "2025-12-10T07:08:07.732Z" }, + { url = "https://files.pythonhosted.org/packages/38/cf/06896db3f71c75902a8e9943b444a56e727418f6b4b4a90c98c934f51ed4/scikit_learn-1.8.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8fdf95767f989b0cfedb85f7ed8ca215d4be728031f56ff5a519ee1e3276dc2e", size = 8900022, upload-time = "2025-12-10T07:08:09.862Z" }, + { url = "https://files.pythonhosted.org/packages/1c/f9/9b7563caf3ec8873e17a31401858efab6b39a882daf6c1bfa88879c0aa11/scikit_learn-1.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:2de443b9373b3b615aec1bb57f9baa6bb3a9bd093f1269ba95c17d870422b271", size = 7989409, upload-time = "2025-12-10T07:08:12.028Z" }, + { url = "https://files.pythonhosted.org/packages/49/bd/1f4001503650e72c4f6009ac0c4413cb17d2d601cef6f71c0453da2732fc/scikit_learn-1.8.0-cp313-cp313-win_arm64.whl", hash = "sha256:eddde82a035681427cbedded4e6eff5e57fa59216c2e3e90b10b19ab1d0a65c3", size = 7619760, upload-time = "2025-12-10T07:08:13.688Z" }, + { url = "https://files.pythonhosted.org/packages/d2/7d/a630359fc9dcc95496588c8d8e3245cc8fd81980251079bc09c70d41d951/scikit_learn-1.8.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:7cc267b6108f0a1499a734167282c00c4ebf61328566b55ef262d48e9849c735", size = 8826045, upload-time = "2025-12-10T07:08:15.215Z" }, + { url = "https://files.pythonhosted.org/packages/cc/56/a0c86f6930cfcd1c7054a2bc417e26960bb88d32444fe7f71d5c2cfae891/scikit_learn-1.8.0-cp313-cp313t-macosx_12_0_arm64.whl", hash = "sha256:fe1c011a640a9f0791146011dfd3c7d9669785f9fed2b2a5f9e207536cf5c2fd", size = 8420324, upload-time = "2025-12-10T07:08:17.561Z" }, + { url = "https://files.pythonhosted.org/packages/46/1e/05962ea1cebc1cf3876667ecb14c283ef755bf409993c5946ade3b77e303/scikit_learn-1.8.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:72358cce49465d140cc4e7792015bb1f0296a9742d5622c67e31399b75468b9e", size = 8680651, upload-time = "2025-12-10T07:08:19.952Z" }, + { url = "https://files.pythonhosted.org/packages/fe/56/a85473cd75f200c9759e3a5f0bcab2d116c92a8a02ee08ccd73b870f8bb4/scikit_learn-1.8.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:80832434a6cc114f5219211eec13dcbc16c2bac0e31ef64c6d346cde3cf054cb", size = 8925045, upload-time = "2025-12-10T07:08:22.11Z" }, + { url = "https://files.pythonhosted.org/packages/cc/b7/64d8cfa896c64435ae57f4917a548d7ac7a44762ff9802f75a79b77cb633/scikit_learn-1.8.0-cp313-cp313t-win_amd64.whl", hash = "sha256:ee787491dbfe082d9c3013f01f5991658b0f38aa8177e4cd4bf434c58f551702", size = 8507994, upload-time = "2025-12-10T07:08:23.943Z" }, + { url = "https://files.pythonhosted.org/packages/5e/37/e192ea709551799379958b4c4771ec507347027bb7c942662c7fbeba31cb/scikit_learn-1.8.0-cp313-cp313t-win_arm64.whl", hash = "sha256:bf97c10a3f5a7543f9b88cbf488d33d175e9146115a451ae34568597ba33dcde", size = 7869518, upload-time = "2025-12-10T07:08:25.71Z" }, + { url = "https://files.pythonhosted.org/packages/24/05/1af2c186174cc92dcab2233f327336058c077d38f6fe2aceb08e6ab4d509/scikit_learn-1.8.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:c22a2da7a198c28dd1a6e1136f19c830beab7fdca5b3e5c8bba8394f8a5c45b3", size = 8528667, upload-time = "2025-12-10T07:08:27.541Z" }, + { url = "https://files.pythonhosted.org/packages/a8/25/01c0af38fe969473fb292bba9dc2b8f9b451f3112ff242c647fee3d0dfe7/scikit_learn-1.8.0-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:6b595b07a03069a2b1740dc08c2299993850ea81cce4fe19b2421e0c970de6b7", size = 8066524, upload-time = "2025-12-10T07:08:29.822Z" }, + { url = "https://files.pythonhosted.org/packages/be/ce/a0623350aa0b68647333940ee46fe45086c6060ec604874e38e9ab7d8e6c/scikit_learn-1.8.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:29ffc74089f3d5e87dfca4c2c8450f88bdc61b0fc6ed5d267f3988f19a1309f6", size = 8657133, upload-time = "2025-12-10T07:08:31.865Z" }, + { url = "https://files.pythonhosted.org/packages/b8/cb/861b41341d6f1245e6ca80b1c1a8c4dfce43255b03df034429089ca2a2c5/scikit_learn-1.8.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fb65db5d7531bccf3a4f6bec3462223bea71384e2cda41da0f10b7c292b9e7c4", size = 8923223, upload-time = "2025-12-10T07:08:34.166Z" }, + { url = "https://files.pythonhosted.org/packages/76/18/a8def8f91b18cd1ba6e05dbe02540168cb24d47e8dcf69e8d00b7da42a08/scikit_learn-1.8.0-cp314-cp314-win_amd64.whl", hash = "sha256:56079a99c20d230e873ea40753102102734c5953366972a71d5cb39a32bc40c6", size = 8096518, upload-time = "2025-12-10T07:08:36.339Z" }, + { url = "https://files.pythonhosted.org/packages/d1/77/482076a678458307f0deb44e29891d6022617b2a64c840c725495bee343f/scikit_learn-1.8.0-cp314-cp314-win_arm64.whl", hash = "sha256:3bad7565bc9cf37ce19a7c0d107742b320c1285df7aab1a6e2d28780df167242", size = 7754546, upload-time = "2025-12-10T07:08:38.128Z" }, + { url = "https://files.pythonhosted.org/packages/2d/d1/ef294ca754826daa043b2a104e59960abfab4cf653891037d19dd5b6f3cf/scikit_learn-1.8.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:4511be56637e46c25721e83d1a9cea9614e7badc7040c4d573d75fbe257d6fd7", size = 8848305, upload-time = "2025-12-10T07:08:41.013Z" }, + { url = "https://files.pythonhosted.org/packages/5b/e2/b1f8b05138ee813b8e1a4149f2f0d289547e60851fd1bb268886915adbda/scikit_learn-1.8.0-cp314-cp314t-macosx_12_0_arm64.whl", hash = "sha256:a69525355a641bf8ef136a7fa447672fb54fe8d60cab5538d9eb7c6438543fb9", size = 8432257, upload-time = "2025-12-10T07:08:42.873Z" }, + { url = "https://files.pythonhosted.org/packages/26/11/c32b2138a85dcb0c99f6afd13a70a951bfdff8a6ab42d8160522542fb647/scikit_learn-1.8.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c2656924ec73e5939c76ac4c8b026fc203b83d8900362eb2599d8aee80e4880f", size = 8678673, upload-time = "2025-12-10T07:08:45.362Z" }, + { url = "https://files.pythonhosted.org/packages/c7/57/51f2384575bdec454f4fe4e7a919d696c9ebce914590abf3e52d47607ab8/scikit_learn-1.8.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:15fc3b5d19cc2be65404786857f2e13c70c83dd4782676dd6814e3b89dc8f5b9", size = 8922467, upload-time = "2025-12-10T07:08:47.408Z" }, + { url = "https://files.pythonhosted.org/packages/35/4d/748c9e2872637a57981a04adc038dacaa16ba8ca887b23e34953f0b3f742/scikit_learn-1.8.0-cp314-cp314t-win_amd64.whl", hash = "sha256:00d6f1d66fbcf4eba6e356e1420d33cc06c70a45bb1363cd6f6a8e4ebbbdece2", size = 8774395, upload-time = "2025-12-10T07:08:49.337Z" }, + { url = "https://files.pythonhosted.org/packages/60/22/d7b2ebe4704a5e50790ba089d5c2ae308ab6bb852719e6c3bd4f04c3a363/scikit_learn-1.8.0-cp314-cp314t-win_arm64.whl", hash = "sha256:f28dd15c6bb0b66ba09728cf09fd8736c304be29409bd8445a080c1280619e8c", size = 8002647, upload-time = "2025-12-10T07:08:51.601Z" }, ] [[package]] @@ -1304,50 +1305,50 @@ name = "scipy" version = "1.17.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "numpy" }, + { name = "numpy" }, ] sdist = { url = "https://files.pythonhosted.org/packages/7a/97/5a3609c4f8d58b039179648e62dd220f89864f56f7357f5d4f45c29eb2cc/scipy-1.17.1.tar.gz", hash = "sha256:95d8e012d8cb8816c226aef832200b1d45109ed4464303e997c5b13122b297c0", size = 30573822, upload-time = "2026-02-23T00:26:24.851Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/76/27/07ee1b57b65e92645f219b37148a7e7928b82e2b5dbeccecb4dff7c64f0b/scipy-1.17.1-cp313-cp313-macosx_10_14_x86_64.whl", hash = "sha256:5e3c5c011904115f88a39308379c17f91546f77c1667cea98739fe0fccea804c", size = 31590199, upload-time = "2026-02-23T00:19:17.192Z" }, - { url = "https://files.pythonhosted.org/packages/ec/ae/db19f8ab842e9b724bf5dbb7db29302a91f1e55bc4d04b1025d6d605a2c5/scipy-1.17.1-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:6fac755ca3d2c3edcb22f479fceaa241704111414831ddd3bc6056e18516892f", size = 28154001, upload-time = "2026-02-23T00:19:22.241Z" }, - { url = "https://files.pythonhosted.org/packages/5b/58/3ce96251560107b381cbd6e8413c483bbb1228a6b919fa8652b0d4090e7f/scipy-1.17.1-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:7ff200bf9d24f2e4d5dc6ee8c3ac64d739d3a89e2326ba68aaf6c4a2b838fd7d", size = 20325719, upload-time = "2026-02-23T00:19:26.329Z" }, - { url = "https://files.pythonhosted.org/packages/b2/83/15087d945e0e4d48ce2377498abf5ad171ae013232ae31d06f336e64c999/scipy-1.17.1-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:4b400bdc6f79fa02a4d86640310dde87a21fba0c979efff5248908c6f15fad1b", size = 22683595, upload-time = "2026-02-23T00:19:30.304Z" }, - { url = "https://files.pythonhosted.org/packages/b4/e0/e58fbde4a1a594c8be8114eb4aac1a55bcd6587047efc18a61eb1f5c0d30/scipy-1.17.1-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2b64ca7d4aee0102a97f3ba22124052b4bd2152522355073580bf4845e2550b6", size = 32896429, upload-time = "2026-02-23T00:19:35.536Z" }, - { url = "https://files.pythonhosted.org/packages/f5/5f/f17563f28ff03c7b6799c50d01d5d856a1d55f2676f537ca8d28c7f627cd/scipy-1.17.1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:581b2264fc0aa555f3f435a5944da7504ea3a065d7029ad60e7c3d1ae09c5464", size = 35203952, upload-time = "2026-02-23T00:19:42.259Z" }, - { url = "https://files.pythonhosted.org/packages/8d/a5/9afd17de24f657fdfe4df9a3f1ea049b39aef7c06000c13db1530d81ccca/scipy-1.17.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:beeda3d4ae615106d7094f7e7cef6218392e4465cc95d25f900bebabfded0950", size = 34979063, upload-time = "2026-02-23T00:19:47.547Z" }, - { url = "https://files.pythonhosted.org/packages/8b/13/88b1d2384b424bf7c924f2038c1c409f8d88bb2a8d49d097861dd64a57b2/scipy-1.17.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6609bc224e9568f65064cfa72edc0f24ee6655b47575954ec6339534b2798369", size = 37598449, upload-time = "2026-02-23T00:19:53.238Z" }, - { url = "https://files.pythonhosted.org/packages/35/e5/d6d0e51fc888f692a35134336866341c08655d92614f492c6860dc45bb2c/scipy-1.17.1-cp313-cp313-win_amd64.whl", hash = "sha256:37425bc9175607b0268f493d79a292c39f9d001a357bebb6b88fdfaff13f6448", size = 36510943, upload-time = "2026-02-23T00:20:50.89Z" }, - { url = "https://files.pythonhosted.org/packages/2a/fd/3be73c564e2a01e690e19cc618811540ba5354c67c8680dce3281123fb79/scipy-1.17.1-cp313-cp313-win_arm64.whl", hash = "sha256:5cf36e801231b6a2059bf354720274b7558746f3b1a4efb43fcf557ccd484a87", size = 24545621, upload-time = "2026-02-23T00:20:55.871Z" }, - { url = "https://files.pythonhosted.org/packages/6f/6b/17787db8b8114933a66f9dcc479a8272e4b4da75fe03b0c282f7b0ade8cd/scipy-1.17.1-cp313-cp313t-macosx_10_14_x86_64.whl", hash = "sha256:d59c30000a16d8edc7e64152e30220bfbd724c9bbb08368c054e24c651314f0a", size = 31936708, upload-time = "2026-02-23T00:19:58.694Z" }, - { url = "https://files.pythonhosted.org/packages/38/2e/524405c2b6392765ab1e2b722a41d5da33dc5c7b7278184a8ad29b6cb206/scipy-1.17.1-cp313-cp313t-macosx_12_0_arm64.whl", hash = "sha256:010f4333c96c9bb1a4516269e33cb5917b08ef2166d5556ca2fd9f082a9e6ea0", size = 28570135, upload-time = "2026-02-23T00:20:03.934Z" }, - { url = "https://files.pythonhosted.org/packages/fd/c3/5bd7199f4ea8556c0c8e39f04ccb014ac37d1468e6cfa6a95c6b3562b76e/scipy-1.17.1-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:2ceb2d3e01c5f1d83c4189737a42d9cb2fc38a6eeed225e7515eef71ad301dce", size = 20741977, upload-time = "2026-02-23T00:20:07.935Z" }, - { url = "https://files.pythonhosted.org/packages/d9/b8/8ccd9b766ad14c78386599708eb745f6b44f08400a5fd0ade7cf89b6fc93/scipy-1.17.1-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:844e165636711ef41f80b4103ed234181646b98a53c8f05da12ca5ca289134f6", size = 23029601, upload-time = "2026-02-23T00:20:12.161Z" }, - { url = "https://files.pythonhosted.org/packages/6d/a0/3cb6f4d2fb3e17428ad2880333cac878909ad1a89f678527b5328b93c1d4/scipy-1.17.1-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:158dd96d2207e21c966063e1635b1063cd7787b627b6f07305315dd73d9c679e", size = 33019667, upload-time = "2026-02-23T00:20:17.208Z" }, - { url = "https://files.pythonhosted.org/packages/f3/c3/2d834a5ac7bf3a0c806ad1508efc02dda3c8c61472a56132d7894c312dea/scipy-1.17.1-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:74cbb80d93260fe2ffa334efa24cb8f2f0f622a9b9febf8b483c0b865bfb3475", size = 35264159, upload-time = "2026-02-23T00:20:23.087Z" }, - { url = "https://files.pythonhosted.org/packages/4d/77/d3ed4becfdbd217c52062fafe35a72388d1bd82c2d0ba5ca19d6fcc93e11/scipy-1.17.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:dbc12c9f3d185f5c737d801da555fb74b3dcfa1a50b66a1a93e09190f41fab50", size = 35102771, upload-time = "2026-02-23T00:20:28.636Z" }, - { url = "https://files.pythonhosted.org/packages/bd/12/d19da97efde68ca1ee5538bb261d5d2c062f0c055575128f11a2730e3ac1/scipy-1.17.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:94055a11dfebe37c656e70317e1996dc197e1a15bbcc351bcdd4610e128fe1ca", size = 37665910, upload-time = "2026-02-23T00:20:34.743Z" }, - { url = "https://files.pythonhosted.org/packages/06/1c/1172a88d507a4baaf72c5a09bb6c018fe2ae0ab622e5830b703a46cc9e44/scipy-1.17.1-cp313-cp313t-win_amd64.whl", hash = "sha256:e30bdeaa5deed6bc27b4cc490823cd0347d7dae09119b8803ae576ea0ce52e4c", size = 36562980, upload-time = "2026-02-23T00:20:40.575Z" }, - { url = "https://files.pythonhosted.org/packages/70/b0/eb757336e5a76dfa7911f63252e3b7d1de00935d7705cf772db5b45ec238/scipy-1.17.1-cp313-cp313t-win_arm64.whl", hash = "sha256:a720477885a9d2411f94a93d16f9d89bad0f28ca23c3f8daa521e2dcc3f44d49", size = 24856543, upload-time = "2026-02-23T00:20:45.313Z" }, - { url = "https://files.pythonhosted.org/packages/cf/83/333afb452af6f0fd70414dc04f898647ee1423979ce02efa75c3b0f2c28e/scipy-1.17.1-cp314-cp314-macosx_10_14_x86_64.whl", hash = "sha256:a48a72c77a310327f6a3a920092fa2b8fd03d7deaa60f093038f22d98e096717", size = 31584510, upload-time = "2026-02-23T00:21:01.015Z" }, - { url = "https://files.pythonhosted.org/packages/ed/a6/d05a85fd51daeb2e4ea71d102f15b34fedca8e931af02594193ae4fd25f7/scipy-1.17.1-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:45abad819184f07240d8a696117a7aacd39787af9e0b719d00285549ed19a1e9", size = 28170131, upload-time = "2026-02-23T00:21:05.888Z" }, - { url = "https://files.pythonhosted.org/packages/db/7b/8624a203326675d7746a254083a187398090a179335b2e4a20e2ddc46e83/scipy-1.17.1-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:3fd1fcdab3ea951b610dc4cef356d416d5802991e7e32b5254828d342f7b7e0b", size = 20342032, upload-time = "2026-02-23T00:21:09.904Z" }, - { url = "https://files.pythonhosted.org/packages/c9/35/2c342897c00775d688d8ff3987aced3426858fd89d5a0e26e020b660b301/scipy-1.17.1-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:7bdf2da170b67fdf10bca777614b1c7d96ae3ca5794fd9587dce41eb2966e866", size = 22678766, upload-time = "2026-02-23T00:21:14.313Z" }, - { url = "https://files.pythonhosted.org/packages/ef/f2/7cdb8eb308a1a6ae1e19f945913c82c23c0c442a462a46480ce487fdc0ac/scipy-1.17.1-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:adb2642e060a6549c343603a3851ba76ef0b74cc8c079a9a58121c7ec9fe2350", size = 32957007, upload-time = "2026-02-23T00:21:19.663Z" }, - { url = "https://files.pythonhosted.org/packages/0b/2e/7eea398450457ecb54e18e9d10110993fa65561c4f3add5e8eccd2b9cd41/scipy-1.17.1-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:eee2cfda04c00a857206a4330f0c5e3e56535494e30ca445eb19ec624ae75118", size = 35221333, upload-time = "2026-02-23T00:21:25.278Z" }, - { url = "https://files.pythonhosted.org/packages/d9/77/5b8509d03b77f093a0d52e606d3c4f79e8b06d1d38c441dacb1e26cacf46/scipy-1.17.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:d2650c1fb97e184d12d8ba010493ee7b322864f7d3d00d3f9bb97d9c21de4068", size = 35042066, upload-time = "2026-02-23T00:21:31.358Z" }, - { url = "https://files.pythonhosted.org/packages/f9/df/18f80fb99df40b4070328d5ae5c596f2f00fffb50167e31439e932f29e7d/scipy-1.17.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:08b900519463543aa604a06bec02461558a6e1cef8fdbb8098f77a48a83c8118", size = 37612763, upload-time = "2026-02-23T00:21:37.247Z" }, - { url = "https://files.pythonhosted.org/packages/4b/39/f0e8ea762a764a9dc52aa7dabcfad51a354819de1f0d4652b6a1122424d6/scipy-1.17.1-cp314-cp314-win_amd64.whl", hash = "sha256:3877ac408e14da24a6196de0ddcace62092bfc12a83823e92e49e40747e52c19", size = 37290984, upload-time = "2026-02-23T00:22:35.023Z" }, - { url = "https://files.pythonhosted.org/packages/7c/56/fe201e3b0f93d1a8bcf75d3379affd228a63d7e2d80ab45467a74b494947/scipy-1.17.1-cp314-cp314-win_arm64.whl", hash = "sha256:f8885db0bc2bffa59d5c1b72fad7a6a92d3e80e7257f967dd81abb553a90d293", size = 25192877, upload-time = "2026-02-23T00:22:39.798Z" }, - { url = "https://files.pythonhosted.org/packages/96/ad/f8c414e121f82e02d76f310f16db9899c4fcde36710329502a6b2a3c0392/scipy-1.17.1-cp314-cp314t-macosx_10_14_x86_64.whl", hash = "sha256:1cc682cea2ae55524432f3cdff9e9a3be743d52a7443d0cba9017c23c87ae2f6", size = 31949750, upload-time = "2026-02-23T00:21:42.289Z" }, - { url = "https://files.pythonhosted.org/packages/7c/b0/c741e8865d61b67c81e255f4f0a832846c064e426636cd7de84e74d209be/scipy-1.17.1-cp314-cp314t-macosx_12_0_arm64.whl", hash = "sha256:2040ad4d1795a0ae89bfc7e8429677f365d45aa9fd5e4587cf1ea737f927b4a1", size = 28585858, upload-time = "2026-02-23T00:21:47.706Z" }, - { url = "https://files.pythonhosted.org/packages/ed/1b/3985219c6177866628fa7c2595bfd23f193ceebbe472c98a08824b9466ff/scipy-1.17.1-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:131f5aaea57602008f9822e2115029b55d4b5f7c070287699fe45c661d051e39", size = 20757723, upload-time = "2026-02-23T00:21:52.039Z" }, - { url = "https://files.pythonhosted.org/packages/c0/19/2a04aa25050d656d6f7b9e7b685cc83d6957fb101665bfd9369ca6534563/scipy-1.17.1-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:9cdc1a2fcfd5c52cfb3045feb399f7b3ce822abdde3a193a6b9a60b3cb5854ca", size = 23043098, upload-time = "2026-02-23T00:21:56.185Z" }, - { url = "https://files.pythonhosted.org/packages/86/f1/3383beb9b5d0dbddd030335bf8a8b32d4317185efe495374f134d8be6cce/scipy-1.17.1-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e3dcd57ab780c741fde8dc68619de988b966db759a3c3152e8e9142c26295ad", size = 33030397, upload-time = "2026-02-23T00:22:01.404Z" }, - { url = "https://files.pythonhosted.org/packages/41/68/8f21e8a65a5a03f25a79165ec9d2b28c00e66dc80546cf5eb803aeeff35b/scipy-1.17.1-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a9956e4d4f4a301ebf6cde39850333a6b6110799d470dbbb1e25326ac447f52a", size = 35281163, upload-time = "2026-02-23T00:22:07.024Z" }, - { url = "https://files.pythonhosted.org/packages/84/8d/c8a5e19479554007a5632ed7529e665c315ae7492b4f946b0deb39870e39/scipy-1.17.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:a4328d245944d09fd639771de275701ccadf5f781ba0ff092ad141e017eccda4", size = 35116291, upload-time = "2026-02-23T00:22:12.585Z" }, - { url = "https://files.pythonhosted.org/packages/52/52/e57eceff0e342a1f50e274264ed47497b59e6a4e3118808ee58ddda7b74a/scipy-1.17.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:a77cbd07b940d326d39a1d1b37817e2ee4d79cb30e7338f3d0cddffae70fcaa2", size = 37682317, upload-time = "2026-02-23T00:22:18.513Z" }, - { url = "https://files.pythonhosted.org/packages/11/2f/b29eafe4a3fbc3d6de9662b36e028d5f039e72d345e05c250e121a230dd4/scipy-1.17.1-cp314-cp314t-win_amd64.whl", hash = "sha256:eb092099205ef62cd1782b006658db09e2fed75bffcae7cc0d44052d8aa0f484", size = 37345327, upload-time = "2026-02-23T00:22:24.442Z" }, - { url = "https://files.pythonhosted.org/packages/07/39/338d9219c4e87f3e708f18857ecd24d22a0c3094752393319553096b98af/scipy-1.17.1-cp314-cp314t-win_arm64.whl", hash = "sha256:200e1050faffacc162be6a486a984a0497866ec54149a01270adc8a59b7c7d21", size = 25489165, upload-time = "2026-02-23T00:22:29.563Z" }, + { url = "https://files.pythonhosted.org/packages/76/27/07ee1b57b65e92645f219b37148a7e7928b82e2b5dbeccecb4dff7c64f0b/scipy-1.17.1-cp313-cp313-macosx_10_14_x86_64.whl", hash = "sha256:5e3c5c011904115f88a39308379c17f91546f77c1667cea98739fe0fccea804c", size = 31590199, upload-time = "2026-02-23T00:19:17.192Z" }, + { url = "https://files.pythonhosted.org/packages/ec/ae/db19f8ab842e9b724bf5dbb7db29302a91f1e55bc4d04b1025d6d605a2c5/scipy-1.17.1-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:6fac755ca3d2c3edcb22f479fceaa241704111414831ddd3bc6056e18516892f", size = 28154001, upload-time = "2026-02-23T00:19:22.241Z" }, + { url = "https://files.pythonhosted.org/packages/5b/58/3ce96251560107b381cbd6e8413c483bbb1228a6b919fa8652b0d4090e7f/scipy-1.17.1-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:7ff200bf9d24f2e4d5dc6ee8c3ac64d739d3a89e2326ba68aaf6c4a2b838fd7d", size = 20325719, upload-time = "2026-02-23T00:19:26.329Z" }, + { url = "https://files.pythonhosted.org/packages/b2/83/15087d945e0e4d48ce2377498abf5ad171ae013232ae31d06f336e64c999/scipy-1.17.1-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:4b400bdc6f79fa02a4d86640310dde87a21fba0c979efff5248908c6f15fad1b", size = 22683595, upload-time = "2026-02-23T00:19:30.304Z" }, + { url = "https://files.pythonhosted.org/packages/b4/e0/e58fbde4a1a594c8be8114eb4aac1a55bcd6587047efc18a61eb1f5c0d30/scipy-1.17.1-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2b64ca7d4aee0102a97f3ba22124052b4bd2152522355073580bf4845e2550b6", size = 32896429, upload-time = "2026-02-23T00:19:35.536Z" }, + { url = "https://files.pythonhosted.org/packages/f5/5f/f17563f28ff03c7b6799c50d01d5d856a1d55f2676f537ca8d28c7f627cd/scipy-1.17.1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:581b2264fc0aa555f3f435a5944da7504ea3a065d7029ad60e7c3d1ae09c5464", size = 35203952, upload-time = "2026-02-23T00:19:42.259Z" }, + { url = "https://files.pythonhosted.org/packages/8d/a5/9afd17de24f657fdfe4df9a3f1ea049b39aef7c06000c13db1530d81ccca/scipy-1.17.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:beeda3d4ae615106d7094f7e7cef6218392e4465cc95d25f900bebabfded0950", size = 34979063, upload-time = "2026-02-23T00:19:47.547Z" }, + { url = "https://files.pythonhosted.org/packages/8b/13/88b1d2384b424bf7c924f2038c1c409f8d88bb2a8d49d097861dd64a57b2/scipy-1.17.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6609bc224e9568f65064cfa72edc0f24ee6655b47575954ec6339534b2798369", size = 37598449, upload-time = "2026-02-23T00:19:53.238Z" }, + { url = "https://files.pythonhosted.org/packages/35/e5/d6d0e51fc888f692a35134336866341c08655d92614f492c6860dc45bb2c/scipy-1.17.1-cp313-cp313-win_amd64.whl", hash = "sha256:37425bc9175607b0268f493d79a292c39f9d001a357bebb6b88fdfaff13f6448", size = 36510943, upload-time = "2026-02-23T00:20:50.89Z" }, + { url = "https://files.pythonhosted.org/packages/2a/fd/3be73c564e2a01e690e19cc618811540ba5354c67c8680dce3281123fb79/scipy-1.17.1-cp313-cp313-win_arm64.whl", hash = "sha256:5cf36e801231b6a2059bf354720274b7558746f3b1a4efb43fcf557ccd484a87", size = 24545621, upload-time = "2026-02-23T00:20:55.871Z" }, + { url = "https://files.pythonhosted.org/packages/6f/6b/17787db8b8114933a66f9dcc479a8272e4b4da75fe03b0c282f7b0ade8cd/scipy-1.17.1-cp313-cp313t-macosx_10_14_x86_64.whl", hash = "sha256:d59c30000a16d8edc7e64152e30220bfbd724c9bbb08368c054e24c651314f0a", size = 31936708, upload-time = "2026-02-23T00:19:58.694Z" }, + { url = "https://files.pythonhosted.org/packages/38/2e/524405c2b6392765ab1e2b722a41d5da33dc5c7b7278184a8ad29b6cb206/scipy-1.17.1-cp313-cp313t-macosx_12_0_arm64.whl", hash = "sha256:010f4333c96c9bb1a4516269e33cb5917b08ef2166d5556ca2fd9f082a9e6ea0", size = 28570135, upload-time = "2026-02-23T00:20:03.934Z" }, + { url = "https://files.pythonhosted.org/packages/fd/c3/5bd7199f4ea8556c0c8e39f04ccb014ac37d1468e6cfa6a95c6b3562b76e/scipy-1.17.1-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:2ceb2d3e01c5f1d83c4189737a42d9cb2fc38a6eeed225e7515eef71ad301dce", size = 20741977, upload-time = "2026-02-23T00:20:07.935Z" }, + { url = "https://files.pythonhosted.org/packages/d9/b8/8ccd9b766ad14c78386599708eb745f6b44f08400a5fd0ade7cf89b6fc93/scipy-1.17.1-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:844e165636711ef41f80b4103ed234181646b98a53c8f05da12ca5ca289134f6", size = 23029601, upload-time = "2026-02-23T00:20:12.161Z" }, + { url = "https://files.pythonhosted.org/packages/6d/a0/3cb6f4d2fb3e17428ad2880333cac878909ad1a89f678527b5328b93c1d4/scipy-1.17.1-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:158dd96d2207e21c966063e1635b1063cd7787b627b6f07305315dd73d9c679e", size = 33019667, upload-time = "2026-02-23T00:20:17.208Z" }, + { url = "https://files.pythonhosted.org/packages/f3/c3/2d834a5ac7bf3a0c806ad1508efc02dda3c8c61472a56132d7894c312dea/scipy-1.17.1-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:74cbb80d93260fe2ffa334efa24cb8f2f0f622a9b9febf8b483c0b865bfb3475", size = 35264159, upload-time = "2026-02-23T00:20:23.087Z" }, + { url = "https://files.pythonhosted.org/packages/4d/77/d3ed4becfdbd217c52062fafe35a72388d1bd82c2d0ba5ca19d6fcc93e11/scipy-1.17.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:dbc12c9f3d185f5c737d801da555fb74b3dcfa1a50b66a1a93e09190f41fab50", size = 35102771, upload-time = "2026-02-23T00:20:28.636Z" }, + { url = "https://files.pythonhosted.org/packages/bd/12/d19da97efde68ca1ee5538bb261d5d2c062f0c055575128f11a2730e3ac1/scipy-1.17.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:94055a11dfebe37c656e70317e1996dc197e1a15bbcc351bcdd4610e128fe1ca", size = 37665910, upload-time = "2026-02-23T00:20:34.743Z" }, + { url = "https://files.pythonhosted.org/packages/06/1c/1172a88d507a4baaf72c5a09bb6c018fe2ae0ab622e5830b703a46cc9e44/scipy-1.17.1-cp313-cp313t-win_amd64.whl", hash = "sha256:e30bdeaa5deed6bc27b4cc490823cd0347d7dae09119b8803ae576ea0ce52e4c", size = 36562980, upload-time = "2026-02-23T00:20:40.575Z" }, + { url = "https://files.pythonhosted.org/packages/70/b0/eb757336e5a76dfa7911f63252e3b7d1de00935d7705cf772db5b45ec238/scipy-1.17.1-cp313-cp313t-win_arm64.whl", hash = "sha256:a720477885a9d2411f94a93d16f9d89bad0f28ca23c3f8daa521e2dcc3f44d49", size = 24856543, upload-time = "2026-02-23T00:20:45.313Z" }, + { url = "https://files.pythonhosted.org/packages/cf/83/333afb452af6f0fd70414dc04f898647ee1423979ce02efa75c3b0f2c28e/scipy-1.17.1-cp314-cp314-macosx_10_14_x86_64.whl", hash = "sha256:a48a72c77a310327f6a3a920092fa2b8fd03d7deaa60f093038f22d98e096717", size = 31584510, upload-time = "2026-02-23T00:21:01.015Z" }, + { url = "https://files.pythonhosted.org/packages/ed/a6/d05a85fd51daeb2e4ea71d102f15b34fedca8e931af02594193ae4fd25f7/scipy-1.17.1-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:45abad819184f07240d8a696117a7aacd39787af9e0b719d00285549ed19a1e9", size = 28170131, upload-time = "2026-02-23T00:21:05.888Z" }, + { url = "https://files.pythonhosted.org/packages/db/7b/8624a203326675d7746a254083a187398090a179335b2e4a20e2ddc46e83/scipy-1.17.1-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:3fd1fcdab3ea951b610dc4cef356d416d5802991e7e32b5254828d342f7b7e0b", size = 20342032, upload-time = "2026-02-23T00:21:09.904Z" }, + { url = "https://files.pythonhosted.org/packages/c9/35/2c342897c00775d688d8ff3987aced3426858fd89d5a0e26e020b660b301/scipy-1.17.1-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:7bdf2da170b67fdf10bca777614b1c7d96ae3ca5794fd9587dce41eb2966e866", size = 22678766, upload-time = "2026-02-23T00:21:14.313Z" }, + { url = "https://files.pythonhosted.org/packages/ef/f2/7cdb8eb308a1a6ae1e19f945913c82c23c0c442a462a46480ce487fdc0ac/scipy-1.17.1-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:adb2642e060a6549c343603a3851ba76ef0b74cc8c079a9a58121c7ec9fe2350", size = 32957007, upload-time = "2026-02-23T00:21:19.663Z" }, + { url = "https://files.pythonhosted.org/packages/0b/2e/7eea398450457ecb54e18e9d10110993fa65561c4f3add5e8eccd2b9cd41/scipy-1.17.1-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:eee2cfda04c00a857206a4330f0c5e3e56535494e30ca445eb19ec624ae75118", size = 35221333, upload-time = "2026-02-23T00:21:25.278Z" }, + { url = "https://files.pythonhosted.org/packages/d9/77/5b8509d03b77f093a0d52e606d3c4f79e8b06d1d38c441dacb1e26cacf46/scipy-1.17.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:d2650c1fb97e184d12d8ba010493ee7b322864f7d3d00d3f9bb97d9c21de4068", size = 35042066, upload-time = "2026-02-23T00:21:31.358Z" }, + { url = "https://files.pythonhosted.org/packages/f9/df/18f80fb99df40b4070328d5ae5c596f2f00fffb50167e31439e932f29e7d/scipy-1.17.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:08b900519463543aa604a06bec02461558a6e1cef8fdbb8098f77a48a83c8118", size = 37612763, upload-time = "2026-02-23T00:21:37.247Z" }, + { url = "https://files.pythonhosted.org/packages/4b/39/f0e8ea762a764a9dc52aa7dabcfad51a354819de1f0d4652b6a1122424d6/scipy-1.17.1-cp314-cp314-win_amd64.whl", hash = "sha256:3877ac408e14da24a6196de0ddcace62092bfc12a83823e92e49e40747e52c19", size = 37290984, upload-time = "2026-02-23T00:22:35.023Z" }, + { url = "https://files.pythonhosted.org/packages/7c/56/fe201e3b0f93d1a8bcf75d3379affd228a63d7e2d80ab45467a74b494947/scipy-1.17.1-cp314-cp314-win_arm64.whl", hash = "sha256:f8885db0bc2bffa59d5c1b72fad7a6a92d3e80e7257f967dd81abb553a90d293", size = 25192877, upload-time = "2026-02-23T00:22:39.798Z" }, + { url = "https://files.pythonhosted.org/packages/96/ad/f8c414e121f82e02d76f310f16db9899c4fcde36710329502a6b2a3c0392/scipy-1.17.1-cp314-cp314t-macosx_10_14_x86_64.whl", hash = "sha256:1cc682cea2ae55524432f3cdff9e9a3be743d52a7443d0cba9017c23c87ae2f6", size = 31949750, upload-time = "2026-02-23T00:21:42.289Z" }, + { url = "https://files.pythonhosted.org/packages/7c/b0/c741e8865d61b67c81e255f4f0a832846c064e426636cd7de84e74d209be/scipy-1.17.1-cp314-cp314t-macosx_12_0_arm64.whl", hash = "sha256:2040ad4d1795a0ae89bfc7e8429677f365d45aa9fd5e4587cf1ea737f927b4a1", size = 28585858, upload-time = "2026-02-23T00:21:47.706Z" }, + { url = "https://files.pythonhosted.org/packages/ed/1b/3985219c6177866628fa7c2595bfd23f193ceebbe472c98a08824b9466ff/scipy-1.17.1-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:131f5aaea57602008f9822e2115029b55d4b5f7c070287699fe45c661d051e39", size = 20757723, upload-time = "2026-02-23T00:21:52.039Z" }, + { url = "https://files.pythonhosted.org/packages/c0/19/2a04aa25050d656d6f7b9e7b685cc83d6957fb101665bfd9369ca6534563/scipy-1.17.1-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:9cdc1a2fcfd5c52cfb3045feb399f7b3ce822abdde3a193a6b9a60b3cb5854ca", size = 23043098, upload-time = "2026-02-23T00:21:56.185Z" }, + { url = "https://files.pythonhosted.org/packages/86/f1/3383beb9b5d0dbddd030335bf8a8b32d4317185efe495374f134d8be6cce/scipy-1.17.1-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e3dcd57ab780c741fde8dc68619de988b966db759a3c3152e8e9142c26295ad", size = 33030397, upload-time = "2026-02-23T00:22:01.404Z" }, + { url = "https://files.pythonhosted.org/packages/41/68/8f21e8a65a5a03f25a79165ec9d2b28c00e66dc80546cf5eb803aeeff35b/scipy-1.17.1-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a9956e4d4f4a301ebf6cde39850333a6b6110799d470dbbb1e25326ac447f52a", size = 35281163, upload-time = "2026-02-23T00:22:07.024Z" }, + { url = "https://files.pythonhosted.org/packages/84/8d/c8a5e19479554007a5632ed7529e665c315ae7492b4f946b0deb39870e39/scipy-1.17.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:a4328d245944d09fd639771de275701ccadf5f781ba0ff092ad141e017eccda4", size = 35116291, upload-time = "2026-02-23T00:22:12.585Z" }, + { url = "https://files.pythonhosted.org/packages/52/52/e57eceff0e342a1f50e274264ed47497b59e6a4e3118808ee58ddda7b74a/scipy-1.17.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:a77cbd07b940d326d39a1d1b37817e2ee4d79cb30e7338f3d0cddffae70fcaa2", size = 37682317, upload-time = "2026-02-23T00:22:18.513Z" }, + { url = "https://files.pythonhosted.org/packages/11/2f/b29eafe4a3fbc3d6de9662b36e028d5f039e72d345e05c250e121a230dd4/scipy-1.17.1-cp314-cp314t-win_amd64.whl", hash = "sha256:eb092099205ef62cd1782b006658db09e2fed75bffcae7cc0d44052d8aa0f484", size = 37345327, upload-time = "2026-02-23T00:22:24.442Z" }, + { url = "https://files.pythonhosted.org/packages/07/39/338d9219c4e87f3e708f18857ecd24d22a0c3094752393319553096b98af/scipy-1.17.1-cp314-cp314t-win_arm64.whl", hash = "sha256:200e1050faffacc162be6a486a984a0497866ec54149a01270adc8a59b7c7d21", size = 25489165, upload-time = "2026-02-23T00:22:29.563Z" }, ] [[package]] @@ -1355,19 +1356,19 @@ name = "sentence-transformers" version = "5.5.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "huggingface-hub" }, - { name = "numpy" }, - { name = "scikit-learn" }, - { name = "scipy" }, - { name = "torch", version = "2.12.0", source = { registry = "https://download.pytorch.org/whl/cpu" }, marker = "sys_platform == 'darwin'" }, - { name = "torch", version = "2.12.0+cpu", source = { registry = "https://download.pytorch.org/whl/cpu" }, marker = "sys_platform != 'darwin'" }, - { name = "tqdm" }, - { name = "transformers" }, - { name = "typing-extensions" }, + { name = "huggingface-hub" }, + { name = "numpy" }, + { name = "scikit-learn" }, + { name = "scipy" }, + { name = "torch", version = "2.12.0", source = { registry = "https://download.pytorch.org/whl/cpu" }, marker = "sys_platform == 'darwin'" }, + { name = "torch", version = "2.12.0+cpu", source = { registry = "https://download.pytorch.org/whl/cpu" }, marker = "sys_platform != 'darwin'" }, + { name = "tqdm" }, + { name = "transformers" }, + { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/cf/d4/7ef93157485e978c016f49da05363c1e4e7237beb5343b64b5631101f0f1/sentence_transformers-5.5.1.tar.gz", hash = "sha256:02b7740dfc60bdbbcb6061625f5d97a5c1a4e2d3baac5f9391b912bb5eae2290", size = 445161, upload-time = "2026-05-20T07:37:44.465Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/bf/03/ee99a6b030e7a2e056547729f8a4709dd93e13d9c6f07590f74c395c4017/sentence_transformers-5.5.1-py3-none-any.whl", hash = "sha256:4fe11d433badc5282d32f7fc08bc714216b7a5aca426f9df77a45a554756deb7", size = 588887, upload-time = "2026-05-20T07:37:43.004Z" }, + { url = "https://files.pythonhosted.org/packages/bf/03/ee99a6b030e7a2e056547729f8a4709dd93e13d9c6f07590f74c395c4017/sentence_transformers-5.5.1-py3-none-any.whl", hash = "sha256:4fe11d433badc5282d32f7fc08bc714216b7a5aca426f9df77a45a554756deb7", size = 588887, upload-time = "2026-05-20T07:37:43.004Z" }, ] [[package]] @@ -1376,7 +1377,7 @@ version = "81.0.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/0d/1c/73e719955c59b8e424d015ab450f51c0af856ae46ea2da83eba51cc88de1/setuptools-81.0.0.tar.gz", hash = "sha256:487b53915f52501f0a79ccfd0c02c165ffe06631443a886740b91af4b7a5845a", size = 1198299, upload-time = "2026-02-06T21:10:39.601Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e1/e3/c164c88b2e5ce7b24d667b9bd83589cf4f3520d97cad01534cd3c4f55fdb/setuptools-81.0.0-py3-none-any.whl", hash = "sha256:fdd925d5c5d9f62e4b74b30d6dd7828ce236fd6ed998a08d81de62ce5a6310d6", size = 1062021, upload-time = "2026-02-06T21:10:37.175Z" }, + { url = "https://files.pythonhosted.org/packages/e1/e3/c164c88b2e5ce7b24d667b9bd83589cf4f3520d97cad01534cd3c4f55fdb/setuptools-81.0.0-py3-none-any.whl", hash = "sha256:fdd925d5c5d9f62e4b74b30d6dd7828ce236fd6ed998a08d81de62ce5a6310d6", size = 1062021, upload-time = "2026-02-06T21:10:37.175Z" }, ] [[package]] @@ -1385,7 +1386,7 @@ version = "1.5.4" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/58/15/8b3609fd3830ef7b27b655beb4b4e9c62313a4e8da8c676e142cc210d58e/shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de", size = 10310, upload-time = "2023-10-24T04:13:40.426Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755, upload-time = "2023-10-24T04:13:38.866Z" }, + { url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755, upload-time = "2023-10-24T04:13:38.866Z" }, ] [[package]] @@ -1394,7 +1395,7 @@ version = "1.17.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, + { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, ] [[package]] @@ -1403,7 +1404,7 @@ version = "30.8.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/0f/64/89299aefc6ebdf4fc899f5dc14c7fcb7eb9da9290a2b4d615ae7ab884b17/sqlglot-30.8.0.tar.gz", hash = "sha256:1c5f93fb742dd9aaa75eee6bb33a637794a858b9a86375fac23a2dc0f7bc127e", size = 5869750, upload-time = "2026-05-13T09:04:38.923Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/88/4e/80705091aaf9c95e125d243f0aa871bc9f3670b4c9d963e6bad3b3dce8ff/sqlglot-30.8.0-py3-none-any.whl", hash = "sha256:af903378c331d5b72277a1b41118f07bc3e50cf4478e2d47eed12c96ee6a22a4", size = 687831, upload-time = "2026-05-13T09:04:36.336Z" }, + { url = "https://files.pythonhosted.org/packages/88/4e/80705091aaf9c95e125d243f0aa871bc9f3670b4c9d963e6bad3b3dce8ff/sqlglot-30.8.0-py3-none-any.whl", hash = "sha256:af903378c331d5b72277a1b41118f07bc3e50cf4478e2d47eed12c96ee6a22a4", size = 687831, upload-time = "2026-05-13T09:04:36.336Z" }, ] [[package]] @@ -1411,11 +1412,11 @@ name = "starlette" version = "1.2.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "anyio" }, + { name = "anyio" }, ] sdist = { url = "https://files.pythonhosted.org/packages/c5/bf/616a066c2760f6c2b1ae3437cc28149734d069fbb46511712beae118a68c/starlette-1.2.0.tar.gz", hash = "sha256:3c5a6b23fff42492914e93890bb80cbfea72dbf37de268eec06185d62a4ca553", size = 2668923, upload-time = "2026-05-28T11:42:50.568Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/9f/85/492183764d5d01d4514be3730fdb8e228a80605783099551c51627578b5d/starlette-1.2.0-py3-none-any.whl", hash = "sha256:36e0c76ac59157e75dc4b3bdeafba97fb04eaf1878045f15dbef666a6f092ed7", size = 73213, upload-time = "2026-05-28T11:42:48.801Z" }, + { url = "https://files.pythonhosted.org/packages/9f/85/492183764d5d01d4514be3730fdb8e228a80605783099551c51627578b5d/starlette-1.2.0-py3-none-any.whl", hash = "sha256:36e0c76ac59157e75dc4b3bdeafba97fb04eaf1878045f15dbef666a6f092ed7", size = 73213, upload-time = "2026-05-28T11:42:48.801Z" }, ] [[package]] @@ -1423,11 +1424,11 @@ name = "sympy" version = "1.14.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "mpmath" }, + { name = "mpmath" }, ] sdist = { url = "https://files.pythonhosted.org/packages/83/d3/803453b36afefb7c2bb238361cd4ae6125a569b4db67cd9e79846ba2d68c/sympy-1.14.0.tar.gz", hash = "sha256:d3d3fe8df1e5a0b42f0e7bdf50541697dbe7d23746e894990c030e2b05e72517", size = 7793921, upload-time = "2025-04-27T18:05:01.611Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a2/09/77d55d46fd61b4a135c444fc97158ef34a095e5681d0a6c10b75bf356191/sympy-1.14.0-py3-none-any.whl", hash = "sha256:e091cc3e99d2141a0ba2847328f5479b05d94a6635cb96148ccb3f34671bd8f5", size = 6299353, upload-time = "2025-04-27T18:04:59.103Z" }, + { url = "https://files.pythonhosted.org/packages/a2/09/77d55d46fd61b4a135c444fc97158ef34a095e5681d0a6c10b75bf356191/sympy-1.14.0-py3-none-any.whl", hash = "sha256:e091cc3e99d2141a0ba2847328f5479b05d94a6635cb96148ccb3f34671bd8f5", size = 6299353, upload-time = "2025-04-27T18:04:59.103Z" }, ] [[package]] @@ -1436,7 +1437,7 @@ version = "3.6.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/b7/4d/08c89e34946fce2aec4fbb45c9016efd5f4d7f24af8e5d93296e935631d8/threadpoolctl-3.6.0.tar.gz", hash = "sha256:8ab8b4aa3491d812b623328249fab5302a68d2d71745c8a4c719a2fcaba9f44e", size = 21274, upload-time = "2025-03-13T13:49:23.031Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/32/d5/f9a850d79b0851d1d4ef6456097579a9005b31fea68726a4ae5f2d82ddd9/threadpoolctl-3.6.0-py3-none-any.whl", hash = "sha256:43a0b8fd5a2928500110039e43a5eed8480b918967083ea48dc3ab9f13c4a7fb", size = 18638, upload-time = "2025-03-13T13:49:21.846Z" }, + { url = "https://files.pythonhosted.org/packages/32/d5/f9a850d79b0851d1d4ef6456097579a9005b31fea68726a4ae5f2d82ddd9/threadpoolctl-3.6.0-py3-none-any.whl", hash = "sha256:43a0b8fd5a2928500110039e43a5eed8480b918967083ea48dc3ab9f13c4a7fb", size = 18638, upload-time = "2025-03-13T13:49:21.846Z" }, ] [[package]] @@ -1444,25 +1445,25 @@ name = "tokenizers" version = "0.22.2" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "huggingface-hub" }, + { name = "huggingface-hub" }, ] sdist = { url = "https://files.pythonhosted.org/packages/73/6f/f80cfef4a312e1fb34baf7d85c72d4411afde10978d4657f8cdd811d3ccc/tokenizers-0.22.2.tar.gz", hash = "sha256:473b83b915e547aa366d1eee11806deaf419e17be16310ac0a14077f1e28f917", size = 372115, upload-time = "2026-01-05T10:45:15.988Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/92/97/5dbfabf04c7e348e655e907ed27913e03db0923abb5dfdd120d7b25630e1/tokenizers-0.22.2-cp39-abi3-macosx_10_12_x86_64.whl", hash = "sha256:544dd704ae7238755d790de45ba8da072e9af3eea688f698b137915ae959281c", size = 3100275, upload-time = "2026-01-05T10:41:02.158Z" }, - { url = "https://files.pythonhosted.org/packages/2e/47/174dca0502ef88b28f1c9e06b73ce33500eedfac7a7692108aec220464e7/tokenizers-0.22.2-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:1e418a55456beedca4621dbab65a318981467a2b188e982a23e117f115ce5001", size = 2981472, upload-time = "2026-01-05T10:41:00.276Z" }, - { url = "https://files.pythonhosted.org/packages/d6/84/7990e799f1309a8b87af6b948f31edaa12a3ed22d11b352eaf4f4b2e5753/tokenizers-0.22.2-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2249487018adec45d6e3554c71d46eb39fa8ea67156c640f7513eb26f318cec7", size = 3290736, upload-time = "2026-01-05T10:40:32.165Z" }, - { url = "https://files.pythonhosted.org/packages/78/59/09d0d9ba94dcd5f4f1368d4858d24546b4bdc0231c2354aa31d6199f0399/tokenizers-0.22.2-cp39-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:25b85325d0815e86e0bac263506dd114578953b7b53d7de09a6485e4a160a7dd", size = 3168835, upload-time = "2026-01-05T10:40:38.847Z" }, - { url = "https://files.pythonhosted.org/packages/47/50/b3ebb4243e7160bda8d34b731e54dd8ab8b133e50775872e7a434e524c28/tokenizers-0.22.2-cp39-abi3-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:bfb88f22a209ff7b40a576d5324bf8286b519d7358663db21d6246fb17eea2d5", size = 3521673, upload-time = "2026-01-05T10:40:56.614Z" }, - { url = "https://files.pythonhosted.org/packages/e0/fa/89f4cb9e08df770b57adb96f8cbb7e22695a4cb6c2bd5f0c4f0ebcf33b66/tokenizers-0.22.2-cp39-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1c774b1276f71e1ef716e5486f21e76333464f47bece56bbd554485982a9e03e", size = 3724818, upload-time = "2026-01-05T10:40:44.507Z" }, - { url = "https://files.pythonhosted.org/packages/64/04/ca2363f0bfbe3b3d36e95bf67e56a4c88c8e3362b658e616d1ac185d47f2/tokenizers-0.22.2-cp39-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:df6c4265b289083bf710dff49bc51ef252f9d5be33a45ee2bed151114a56207b", size = 3379195, upload-time = "2026-01-05T10:40:51.139Z" }, - { url = "https://files.pythonhosted.org/packages/2e/76/932be4b50ef6ccedf9d3c6639b056a967a86258c6d9200643f01269211ca/tokenizers-0.22.2-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:369cc9fc8cc10cb24143873a0d95438bb8ee257bb80c71989e3ee290e8d72c67", size = 3274982, upload-time = "2026-01-05T10:40:58.331Z" }, - { url = "https://files.pythonhosted.org/packages/1d/28/5f9f5a4cc211b69e89420980e483831bcc29dade307955cc9dc858a40f01/tokenizers-0.22.2-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:29c30b83d8dcd061078b05ae0cb94d3c710555fbb44861139f9f83dcca3dc3e4", size = 9478245, upload-time = "2026-01-05T10:41:04.053Z" }, - { url = "https://files.pythonhosted.org/packages/6c/fb/66e2da4704d6aadebf8cb39f1d6d1957df667ab24cff2326b77cda0dcb85/tokenizers-0.22.2-cp39-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:37ae80a28c1d3265bb1f22464c856bd23c02a05bb211e56d0c5301a435be6c1a", size = 9560069, upload-time = "2026-01-05T10:45:10.673Z" }, - { url = "https://files.pythonhosted.org/packages/16/04/fed398b05caa87ce9b1a1bb5166645e38196081b225059a6edaff6440fac/tokenizers-0.22.2-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:791135ee325f2336f498590eb2f11dc5c295232f288e75c99a36c5dbce63088a", size = 9899263, upload-time = "2026-01-05T10:45:12.559Z" }, - { url = "https://files.pythonhosted.org/packages/05/a1/d62dfe7376beaaf1394917e0f8e93ee5f67fea8fcf4107501db35996586b/tokenizers-0.22.2-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:38337540fbbddff8e999d59970f3c6f35a82de10053206a7562f1ea02d046fa5", size = 10033429, upload-time = "2026-01-05T10:45:14.333Z" }, - { url = "https://files.pythonhosted.org/packages/fd/18/a545c4ea42af3df6effd7d13d250ba77a0a86fb20393143bbb9a92e434d4/tokenizers-0.22.2-cp39-abi3-win32.whl", hash = "sha256:a6bf3f88c554a2b653af81f3204491c818ae2ac6fbc09e76ef4773351292bc92", size = 2502363, upload-time = "2026-01-05T10:45:20.593Z" }, - { url = "https://files.pythonhosted.org/packages/65/71/0670843133a43d43070abeb1949abfdef12a86d490bea9cd9e18e37c5ff7/tokenizers-0.22.2-cp39-abi3-win_amd64.whl", hash = "sha256:c9ea31edff2968b44a88f97d784c2f16dc0729b8b143ed004699ebca91f05c48", size = 2747786, upload-time = "2026-01-05T10:45:18.411Z" }, - { url = "https://files.pythonhosted.org/packages/72/f4/0de46cfa12cdcbcd464cc59fde36912af405696f687e53a091fb432f694c/tokenizers-0.22.2-cp39-abi3-win_arm64.whl", hash = "sha256:9ce725d22864a1e965217204946f830c37876eee3b2ba6fc6255e8e903d5fcbc", size = 2612133, upload-time = "2026-01-05T10:45:17.232Z" }, + { url = "https://files.pythonhosted.org/packages/92/97/5dbfabf04c7e348e655e907ed27913e03db0923abb5dfdd120d7b25630e1/tokenizers-0.22.2-cp39-abi3-macosx_10_12_x86_64.whl", hash = "sha256:544dd704ae7238755d790de45ba8da072e9af3eea688f698b137915ae959281c", size = 3100275, upload-time = "2026-01-05T10:41:02.158Z" }, + { url = "https://files.pythonhosted.org/packages/2e/47/174dca0502ef88b28f1c9e06b73ce33500eedfac7a7692108aec220464e7/tokenizers-0.22.2-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:1e418a55456beedca4621dbab65a318981467a2b188e982a23e117f115ce5001", size = 2981472, upload-time = "2026-01-05T10:41:00.276Z" }, + { url = "https://files.pythonhosted.org/packages/d6/84/7990e799f1309a8b87af6b948f31edaa12a3ed22d11b352eaf4f4b2e5753/tokenizers-0.22.2-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2249487018adec45d6e3554c71d46eb39fa8ea67156c640f7513eb26f318cec7", size = 3290736, upload-time = "2026-01-05T10:40:32.165Z" }, + { url = "https://files.pythonhosted.org/packages/78/59/09d0d9ba94dcd5f4f1368d4858d24546b4bdc0231c2354aa31d6199f0399/tokenizers-0.22.2-cp39-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:25b85325d0815e86e0bac263506dd114578953b7b53d7de09a6485e4a160a7dd", size = 3168835, upload-time = "2026-01-05T10:40:38.847Z" }, + { url = "https://files.pythonhosted.org/packages/47/50/b3ebb4243e7160bda8d34b731e54dd8ab8b133e50775872e7a434e524c28/tokenizers-0.22.2-cp39-abi3-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:bfb88f22a209ff7b40a576d5324bf8286b519d7358663db21d6246fb17eea2d5", size = 3521673, upload-time = "2026-01-05T10:40:56.614Z" }, + { url = "https://files.pythonhosted.org/packages/e0/fa/89f4cb9e08df770b57adb96f8cbb7e22695a4cb6c2bd5f0c4f0ebcf33b66/tokenizers-0.22.2-cp39-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1c774b1276f71e1ef716e5486f21e76333464f47bece56bbd554485982a9e03e", size = 3724818, upload-time = "2026-01-05T10:40:44.507Z" }, + { url = "https://files.pythonhosted.org/packages/64/04/ca2363f0bfbe3b3d36e95bf67e56a4c88c8e3362b658e616d1ac185d47f2/tokenizers-0.22.2-cp39-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:df6c4265b289083bf710dff49bc51ef252f9d5be33a45ee2bed151114a56207b", size = 3379195, upload-time = "2026-01-05T10:40:51.139Z" }, + { url = "https://files.pythonhosted.org/packages/2e/76/932be4b50ef6ccedf9d3c6639b056a967a86258c6d9200643f01269211ca/tokenizers-0.22.2-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:369cc9fc8cc10cb24143873a0d95438bb8ee257bb80c71989e3ee290e8d72c67", size = 3274982, upload-time = "2026-01-05T10:40:58.331Z" }, + { url = "https://files.pythonhosted.org/packages/1d/28/5f9f5a4cc211b69e89420980e483831bcc29dade307955cc9dc858a40f01/tokenizers-0.22.2-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:29c30b83d8dcd061078b05ae0cb94d3c710555fbb44861139f9f83dcca3dc3e4", size = 9478245, upload-time = "2026-01-05T10:41:04.053Z" }, + { url = "https://files.pythonhosted.org/packages/6c/fb/66e2da4704d6aadebf8cb39f1d6d1957df667ab24cff2326b77cda0dcb85/tokenizers-0.22.2-cp39-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:37ae80a28c1d3265bb1f22464c856bd23c02a05bb211e56d0c5301a435be6c1a", size = 9560069, upload-time = "2026-01-05T10:45:10.673Z" }, + { url = "https://files.pythonhosted.org/packages/16/04/fed398b05caa87ce9b1a1bb5166645e38196081b225059a6edaff6440fac/tokenizers-0.22.2-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:791135ee325f2336f498590eb2f11dc5c295232f288e75c99a36c5dbce63088a", size = 9899263, upload-time = "2026-01-05T10:45:12.559Z" }, + { url = "https://files.pythonhosted.org/packages/05/a1/d62dfe7376beaaf1394917e0f8e93ee5f67fea8fcf4107501db35996586b/tokenizers-0.22.2-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:38337540fbbddff8e999d59970f3c6f35a82de10053206a7562f1ea02d046fa5", size = 10033429, upload-time = "2026-01-05T10:45:14.333Z" }, + { url = "https://files.pythonhosted.org/packages/fd/18/a545c4ea42af3df6effd7d13d250ba77a0a86fb20393143bbb9a92e434d4/tokenizers-0.22.2-cp39-abi3-win32.whl", hash = "sha256:a6bf3f88c554a2b653af81f3204491c818ae2ac6fbc09e76ef4773351292bc92", size = 2502363, upload-time = "2026-01-05T10:45:20.593Z" }, + { url = "https://files.pythonhosted.org/packages/65/71/0670843133a43d43070abeb1949abfdef12a86d490bea9cd9e18e37c5ff7/tokenizers-0.22.2-cp39-abi3-win_amd64.whl", hash = "sha256:c9ea31edff2968b44a88f97d784c2f16dc0729b8b143ed004699ebca91f05c48", size = 2747786, upload-time = "2026-01-05T10:45:18.411Z" }, + { url = "https://files.pythonhosted.org/packages/72/f4/0de46cfa12cdcbcd464cc59fde36912af405696f687e53a091fb432f694c/tokenizers-0.22.2-cp39-abi3-win_arm64.whl", hash = "sha256:9ce725d22864a1e965217204946f830c37876eee3b2ba6fc6255e8e903d5fcbc", size = 2612133, upload-time = "2026-01-05T10:45:17.232Z" }, ] [[package]] @@ -1470,23 +1471,23 @@ name = "torch" version = "2.12.0" source = { registry = "https://download.pytorch.org/whl/cpu" } resolution-markers = [ - "python_full_version >= '3.14' and sys_platform == 'darwin'", - "python_full_version < '3.14' and sys_platform == 'darwin'", + "python_full_version >= '3.14' and sys_platform == 'darwin'", + "python_full_version < '3.14' and sys_platform == 'darwin'", ] dependencies = [ - { name = "filelock", marker = "sys_platform == 'darwin'" }, - { name = "fsspec", marker = "sys_platform == 'darwin'" }, - { name = "jinja2", marker = "sys_platform == 'darwin'" }, - { name = "networkx", marker = "sys_platform == 'darwin'" }, - { name = "setuptools", marker = "sys_platform == 'darwin'" }, - { name = "sympy", marker = "sys_platform == 'darwin'" }, - { name = "typing-extensions", marker = "sys_platform == 'darwin'" }, + { name = "filelock", marker = "sys_platform == 'darwin'" }, + { name = "fsspec", marker = "sys_platform == 'darwin'" }, + { name = "jinja2", marker = "sys_platform == 'darwin'" }, + { name = "networkx", marker = "sys_platform == 'darwin'" }, + { name = "setuptools", marker = "sys_platform == 'darwin'" }, + { name = "sympy", marker = "sys_platform == 'darwin'" }, + { name = "typing-extensions", marker = "sys_platform == 'darwin'" }, ] wheels = [ - { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.12.0-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:90dd587a5f61bfe1307148b581e2084fc5bc4a06e2b90a20e9a36b81087ff16b", upload-time = "2026-05-12T16:20:17Z" }, - { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.12.0-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:10ee1448a9f304d3b987eb4656f664ba6e4d7b410ca7a5a7c642199777a2cf88", upload-time = "2026-05-12T16:20:21Z" }, - { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.12.0-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:f7dfae4a519197dfa050e98d8e36378a0fb5899625a875c2b54445005a2e404e", upload-time = "2026-05-12T16:20:26Z" }, - { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.12.0-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:b4556715c8572758625d62b6e0ae3b1f76c440221913a6fb5e100f321fb4fb02", upload-time = "2026-05-12T16:20:31Z" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.12.0-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:90dd587a5f61bfe1307148b581e2084fc5bc4a06e2b90a20e9a36b81087ff16b", upload-time = "2026-05-12T16:20:17Z" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.12.0-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:10ee1448a9f304d3b987eb4656f664ba6e4d7b410ca7a5a7c642199777a2cf88", upload-time = "2026-05-12T16:20:21Z" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.12.0-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:f7dfae4a519197dfa050e98d8e36378a0fb5899625a875c2b54445005a2e404e", upload-time = "2026-05-12T16:20:26Z" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.12.0-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:b4556715c8572758625d62b6e0ae3b1f76c440221913a6fb5e100f321fb4fb02", upload-time = "2026-05-12T16:20:31Z" }, ] [[package]] @@ -1494,40 +1495,40 @@ name = "torch" version = "2.12.0+cpu" source = { registry = "https://download.pytorch.org/whl/cpu" } resolution-markers = [ - "python_full_version >= '3.14' and sys_platform == 'win32'", - "python_full_version >= '3.14' and sys_platform == 'emscripten'", - "python_full_version >= '3.14' and sys_platform != 'darwin' and sys_platform != 'emscripten' and sys_platform != 'win32'", - "python_full_version < '3.14' and sys_platform == 'win32'", - "python_full_version < '3.14' and sys_platform == 'emscripten'", - "python_full_version < '3.14' and sys_platform != 'darwin' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version >= '3.14' and sys_platform == 'win32'", + "python_full_version >= '3.14' and sys_platform == 'emscripten'", + "python_full_version >= '3.14' and sys_platform != 'darwin' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version < '3.14' and sys_platform == 'win32'", + "python_full_version < '3.14' and sys_platform == 'emscripten'", + "python_full_version < '3.14' and sys_platform != 'darwin' and sys_platform != 'emscripten' and sys_platform != 'win32'", ] dependencies = [ - { name = "filelock", marker = "sys_platform != 'darwin'" }, - { name = "fsspec", marker = "sys_platform != 'darwin'" }, - { name = "jinja2", marker = "sys_platform != 'darwin'" }, - { name = "networkx", marker = "sys_platform != 'darwin'" }, - { name = "setuptools", marker = "sys_platform != 'darwin'" }, - { name = "sympy", marker = "sys_platform != 'darwin'" }, - { name = "typing-extensions", marker = "sys_platform != 'darwin'" }, + { name = "filelock", marker = "sys_platform != 'darwin'" }, + { name = "fsspec", marker = "sys_platform != 'darwin'" }, + { name = "jinja2", marker = "sys_platform != 'darwin'" }, + { name = "networkx", marker = "sys_platform != 'darwin'" }, + { name = "setuptools", marker = "sys_platform != 'darwin'" }, + { name = "sympy", marker = "sys_platform != 'darwin'" }, + { name = "typing-extensions", marker = "sys_platform != 'darwin'" }, ] wheels = [ - { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.12.0%2Bcpu-cp313-cp313-linux_s390x.whl", hash = "sha256:5e0da19e1c3bfdc9b92638c552579eac678354485d61fc8921b0461fd6c40449", upload-time = "2026-05-12T23:17:05Z" }, - { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.12.0%2Bcpu-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:68b7ddd4db4603a03e106e74c7098c8d8c8943d33c1e5ada009ca4cd885759c3", upload-time = "2026-05-12T23:17:12Z" }, - { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.12.0%2Bcpu-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:ada78018bdfa30d1c766596cd32d910dbf5b03424cd859231b6d2a00533de922", upload-time = "2026-05-12T23:17:20Z" }, - { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.12.0%2Bcpu-cp313-cp313-win_amd64.whl", hash = "sha256:59bc266826e683899d49ee0af9829f3eafd0a16e15b5db9dc591c8d955003b66", upload-time = "2026-05-12T23:17:27Z" }, - { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.12.0%2Bcpu-cp313-cp313-win_arm64.whl", hash = "sha256:97a5160abf3ca9d59a2cd7b4b4de89d9dfe290d36a1ac720262a55fbcee10b6c", upload-time = "2026-05-12T23:17:31Z" }, - { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.12.0%2Bcpu-cp313-cp313t-linux_s390x.whl", hash = "sha256:32b9b7a0974cd6149cb98def0a28a49d92d7c14a384273d5539da9624239e950", upload-time = "2026-05-12T23:17:36Z" }, - { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.12.0%2Bcpu-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:93ed8dc52c113580daf6124982b3232629045dccc5cd83a8f5ed478f7bac7340", upload-time = "2026-05-12T23:17:43Z" }, - { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.12.0%2Bcpu-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:6a1c86abd4ed15a0736cf2663ad69642ae5d1288c99e30346070e6241018a0a9", upload-time = "2026-05-12T23:17:54Z" }, - { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.12.0%2Bcpu-cp313-cp313t-win_amd64.whl", hash = "sha256:768dce4b7b3353795f667d1cb0dd7dfba06f570cd39539576097335e05bb71fe", upload-time = "2026-05-12T23:18:02Z" }, - { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.12.0%2Bcpu-cp314-cp314-linux_s390x.whl", hash = "sha256:ee1f329acfd0c2a1ccaa3393bcaf9857ea58759549bb2d67e271a6eab42382b3", upload-time = "2026-05-12T23:18:08Z" }, - { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.12.0%2Bcpu-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:797c066367792c92eb97cafba7fd0caa8d7455e6078a4ee880630077378dc372", upload-time = "2026-05-12T23:18:15Z" }, - { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.12.0%2Bcpu-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:a8f419ce3f25388d36e67153ec63b3a1b17059c49f5a7759a7e91ac4843660d3", upload-time = "2026-05-12T23:18:22Z" }, - { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.12.0%2Bcpu-cp314-cp314-win_amd64.whl", hash = "sha256:1dd196c43e74e7b3b526ff434e7efbdef3f3792a2efbecfc983d7dce501840d2", upload-time = "2026-05-12T23:18:30Z" }, - { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.12.0%2Bcpu-cp314-cp314t-linux_s390x.whl", hash = "sha256:d0d2080cb13c94ebc0c884d237e404490743d0f40192c8a180abf3b6b6f334cf", upload-time = "2026-05-12T23:18:35Z" }, - { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.12.0%2Bcpu-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:f7bc15972acad257723775237cdd120024cca844b8bc64701822fa596bcb7e14", upload-time = "2026-05-12T23:18:42Z" }, - { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.12.0%2Bcpu-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:4d79f961250d1763487ecbc90af019a80009f9e87cadc5366b3ec4ba5671fea6", upload-time = "2026-05-12T23:18:50Z" }, - { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.12.0%2Bcpu-cp314-cp314t-win_amd64.whl", hash = "sha256:46b8f4c41ac36bb5d5b47f5437b3de5541b313275e59c1d2aefd3bef32b0f531", upload-time = "2026-05-12T23:18:58Z" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.12.0%2Bcpu-cp313-cp313-linux_s390x.whl", hash = "sha256:5e0da19e1c3bfdc9b92638c552579eac678354485d61fc8921b0461fd6c40449", upload-time = "2026-05-12T23:17:05Z" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.12.0%2Bcpu-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:68b7ddd4db4603a03e106e74c7098c8d8c8943d33c1e5ada009ca4cd885759c3", upload-time = "2026-05-12T23:17:12Z" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.12.0%2Bcpu-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:ada78018bdfa30d1c766596cd32d910dbf5b03424cd859231b6d2a00533de922", upload-time = "2026-05-12T23:17:20Z" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.12.0%2Bcpu-cp313-cp313-win_amd64.whl", hash = "sha256:59bc266826e683899d49ee0af9829f3eafd0a16e15b5db9dc591c8d955003b66", upload-time = "2026-05-12T23:17:27Z" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.12.0%2Bcpu-cp313-cp313-win_arm64.whl", hash = "sha256:97a5160abf3ca9d59a2cd7b4b4de89d9dfe290d36a1ac720262a55fbcee10b6c", upload-time = "2026-05-12T23:17:31Z" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.12.0%2Bcpu-cp313-cp313t-linux_s390x.whl", hash = "sha256:32b9b7a0974cd6149cb98def0a28a49d92d7c14a384273d5539da9624239e950", upload-time = "2026-05-12T23:17:36Z" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.12.0%2Bcpu-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:93ed8dc52c113580daf6124982b3232629045dccc5cd83a8f5ed478f7bac7340", upload-time = "2026-05-12T23:17:43Z" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.12.0%2Bcpu-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:6a1c86abd4ed15a0736cf2663ad69642ae5d1288c99e30346070e6241018a0a9", upload-time = "2026-05-12T23:17:54Z" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.12.0%2Bcpu-cp313-cp313t-win_amd64.whl", hash = "sha256:768dce4b7b3353795f667d1cb0dd7dfba06f570cd39539576097335e05bb71fe", upload-time = "2026-05-12T23:18:02Z" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.12.0%2Bcpu-cp314-cp314-linux_s390x.whl", hash = "sha256:ee1f329acfd0c2a1ccaa3393bcaf9857ea58759549bb2d67e271a6eab42382b3", upload-time = "2026-05-12T23:18:08Z" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.12.0%2Bcpu-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:797c066367792c92eb97cafba7fd0caa8d7455e6078a4ee880630077378dc372", upload-time = "2026-05-12T23:18:15Z" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.12.0%2Bcpu-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:a8f419ce3f25388d36e67153ec63b3a1b17059c49f5a7759a7e91ac4843660d3", upload-time = "2026-05-12T23:18:22Z" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.12.0%2Bcpu-cp314-cp314-win_amd64.whl", hash = "sha256:1dd196c43e74e7b3b526ff434e7efbdef3f3792a2efbecfc983d7dce501840d2", upload-time = "2026-05-12T23:18:30Z" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.12.0%2Bcpu-cp314-cp314t-linux_s390x.whl", hash = "sha256:d0d2080cb13c94ebc0c884d237e404490743d0f40192c8a180abf3b6b6f334cf", upload-time = "2026-05-12T23:18:35Z" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.12.0%2Bcpu-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:f7bc15972acad257723775237cdd120024cca844b8bc64701822fa596bcb7e14", upload-time = "2026-05-12T23:18:42Z" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.12.0%2Bcpu-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:4d79f961250d1763487ecbc90af019a80009f9e87cadc5366b3ec4ba5671fea6", upload-time = "2026-05-12T23:18:50Z" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.12.0%2Bcpu-cp314-cp314t-win_amd64.whl", hash = "sha256:46b8f4c41ac36bb5d5b47f5437b3de5541b313275e59c1d2aefd3bef32b0f531", upload-time = "2026-05-12T23:18:58Z" }, ] [[package]] @@ -1535,11 +1536,11 @@ name = "tqdm" version = "4.67.3" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "colorama", marker = "sys_platform == 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/09/a9/6ba95a270c6f1fbcd8dac228323f2777d886cb206987444e4bce66338dd4/tqdm-4.67.3.tar.gz", hash = "sha256:7d825f03f89244ef73f1d4ce193cb1774a8179fd96f31d7e1dcde62092b960bb", size = 169598, upload-time = "2026-02-03T17:35:53.048Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/16/e1/3079a9ff9b8e11b846c6ac5c8b5bfb7ff225eee721825310c91b3b50304f/tqdm-4.67.3-py3-none-any.whl", hash = "sha256:ee1e4c0e59148062281c49d80b25b67771a127c85fc9676d3be5f243206826bf", size = 78374, upload-time = "2026-02-03T17:35:50.982Z" }, + { url = "https://files.pythonhosted.org/packages/16/e1/3079a9ff9b8e11b846c6ac5c8b5bfb7ff225eee721825310c91b3b50304f/tqdm-4.67.3-py3-none-any.whl", hash = "sha256:ee1e4c0e59148062281c49d80b25b67771a127c85fc9676d3be5f243206826bf", size = 78374, upload-time = "2026-02-03T17:35:50.982Z" }, ] [[package]] @@ -1547,19 +1548,19 @@ name = "transformers" version = "5.9.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "huggingface-hub" }, - { name = "numpy" }, - { name = "packaging" }, - { name = "pyyaml" }, - { name = "regex" }, - { name = "safetensors" }, - { name = "tokenizers" }, - { name = "tqdm" }, - { name = "typer" }, + { name = "huggingface-hub" }, + { name = "numpy" }, + { name = "packaging" }, + { name = "pyyaml" }, + { name = "regex" }, + { name = "safetensors" }, + { name = "tokenizers" }, + { name = "tqdm" }, + { name = "typer" }, ] sdist = { url = "https://files.pythonhosted.org/packages/51/58/7f843608f2e8421f86bb97060b54649be6239ec612b82bf9d41e65c26c00/transformers-5.9.0.tar.gz", hash = "sha256:25997cb8fa6053533171634b6162d7df54346530ec2aa9b42bb834e63668c842", size = 8642240, upload-time = "2026-05-20T14:50:49.278Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/02/ca/2eaa5359f2ccb8c2e1656bc26305ad0cf438aa392ce4b29ae67a315c186e/transformers-5.9.0-py3-none-any.whl", hash = "sha256:1d19509bcff7028ebc6b277d71caa712e8353778463d38764237d14b42b52788", size = 10787648, upload-time = "2026-05-20T14:50:45.337Z" }, + { url = "https://files.pythonhosted.org/packages/02/ca/2eaa5359f2ccb8c2e1656bc26305ad0cf438aa392ce4b29ae67a315c186e/transformers-5.9.0-py3-none-any.whl", hash = "sha256:1d19509bcff7028ebc6b277d71caa712e8353778463d38764237d14b42b52788", size = 10787648, upload-time = "2026-05-20T14:50:45.337Z" }, ] [[package]] @@ -1567,14 +1568,14 @@ name = "typer" version = "0.25.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "annotated-doc" }, - { name = "click" }, - { name = "rich" }, - { name = "shellingham" }, + { name = "annotated-doc" }, + { name = "click" }, + { name = "rich" }, + { name = "shellingham" }, ] sdist = { url = "https://files.pythonhosted.org/packages/e4/51/9aed62104cea109b820bbd6c14245af756112017d309da813ef107d42e7e/typer-0.25.1.tar.gz", hash = "sha256:9616eb8853a09ffeabab1698952f33c6f29ffdbceb4eaeecf571880e8d7664cc", size = 122276, upload-time = "2026-04-30T19:32:16.964Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/3f/f9/2b3ff4e56e5fa7debfaf9eb135d0da96f3e9a1d5b27222223c7296336e5f/typer-0.25.1-py3-none-any.whl", hash = "sha256:75caa44ed46a03fb2dab8808753ffacdbfea88495e74c85a28c5eefcf5f39c89", size = 58409, upload-time = "2026-04-30T19:32:18.271Z" }, + { url = "https://files.pythonhosted.org/packages/3f/f9/2b3ff4e56e5fa7debfaf9eb135d0da96f3e9a1d5b27222223c7296336e5f/typer-0.25.1-py3-none-any.whl", hash = "sha256:75caa44ed46a03fb2dab8808753ffacdbfea88495e74c85a28c5eefcf5f39c89", size = 58409, upload-time = "2026-04-30T19:32:18.271Z" }, ] [[package]] @@ -1583,7 +1584,7 @@ version = "4.15.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, ] [[package]] @@ -1591,11 +1592,11 @@ name = "typing-inspection" version = "0.4.2" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "typing-extensions" }, + { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, + { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, ] [[package]] @@ -1604,7 +1605,7 @@ version = "2026.2" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/ba/19/1b9b0e29f30c6d35cb345486df41110984ea67ae69dddbc0e8a100999493/tzdata-2026.2.tar.gz", hash = "sha256:9173fde7d80d9018e02a662e168e5a2d04f87c41ea174b139fbef642eda62d10", size = 198254, upload-time = "2026-04-24T15:22:08.651Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ce/e4/dccd7f47c4b64213ac01ef921a1337ee6e30e8c6466046018326977efd95/tzdata-2026.2-py2.py3-none-any.whl", hash = "sha256:bbe9af844f658da81a5f95019480da3a89415801f6cc966806612cc7169bffe7", size = 349321, upload-time = "2026-04-24T15:22:05.876Z" }, + { url = "https://files.pythonhosted.org/packages/ce/e4/dccd7f47c4b64213ac01ef921a1337ee6e30e8c6466046018326977efd95/tzdata-2026.2-py2.py3-none-any.whl", hash = "sha256:bbe9af844f658da81a5f95019480da3a89415801f6cc966806612cc7169bffe7", size = 349321, upload-time = "2026-04-24T15:22:05.876Z" }, ] [[package]] @@ -1613,7 +1614,7 @@ version = "2.7.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" }, + { url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" }, ] [[package]] @@ -1621,23 +1622,23 @@ name = "uvicorn" version = "0.48.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "click" }, - { name = "h11" }, + { name = "click" }, + { name = "h11" }, ] sdist = { url = "https://files.pythonhosted.org/packages/e6/bf/f6544ba992ddb9a6077343a576f9844f7f8f06ab819aefd00206e9255f18/uvicorn-0.48.0.tar.gz", hash = "sha256:a5504207195d08c2511bf9125ede5ac4a4b71725d519e758d01dcf0bc2d31c37", size = 91074, upload-time = "2026-05-24T12:08:41.925Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/01/be/72532be3da7acc5fdfbccdb95215cd04f995a0886532a5b423f929cda4cc/uvicorn-0.48.0-py3-none-any.whl", hash = "sha256:48097851328b87ec36117d3d575234519eb58c2b22d79666e9bbc6c49a761dad", size = 71410, upload-time = "2026-05-24T12:08:40.258Z" }, + { url = "https://files.pythonhosted.org/packages/01/be/72532be3da7acc5fdfbccdb95215cd04f995a0886532a5b423f929cda4cc/uvicorn-0.48.0-py3-none-any.whl", hash = "sha256:48097851328b87ec36117d3d575234519eb58c2b22d79666e9bbc6c49a761dad", size = 71410, upload-time = "2026-05-24T12:08:40.258Z" }, ] [package.optional-dependencies] standard = [ - { name = "colorama", marker = "sys_platform == 'win32'" }, - { name = "httptools" }, - { name = "python-dotenv" }, - { name = "pyyaml" }, - { name = "uvloop", marker = "platform_python_implementation != 'PyPy' and sys_platform != 'cygwin' and sys_platform != 'win32'" }, - { name = "watchfiles" }, - { name = "websockets" }, + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "httptools" }, + { name = "python-dotenv" }, + { name = "pyyaml" }, + { name = "uvloop", marker = "platform_python_implementation != 'PyPy' and sys_platform != 'cygwin' and sys_platform != 'win32'" }, + { name = "watchfiles" }, + { name = "websockets" }, ] [[package]] @@ -1646,24 +1647,24 @@ version = "0.22.1" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/06/f0/18d39dbd1971d6d62c4629cc7fa67f74821b0dc1f5a77af43719de7936a7/uvloop-0.22.1.tar.gz", hash = "sha256:6c84bae345b9147082b17371e3dd5d42775bddce91f885499017f4607fdaf39f", size = 2443250, upload-time = "2025-10-16T22:17:19.342Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/89/8c/182a2a593195bfd39842ea68ebc084e20c850806117213f5a299dfc513d9/uvloop-0.22.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:561577354eb94200d75aca23fbde86ee11be36b00e52a4eaf8f50fb0c86b7705", size = 1358611, upload-time = "2025-10-16T22:16:36.833Z" }, - { url = "https://files.pythonhosted.org/packages/d2/14/e301ee96a6dc95224b6f1162cd3312f6d1217be3907b79173b06785f2fe7/uvloop-0.22.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:1cdf5192ab3e674ca26da2eada35b288d2fa49fdd0f357a19f0e7c4e7d5077c8", size = 751811, upload-time = "2025-10-16T22:16:38.275Z" }, - { url = "https://files.pythonhosted.org/packages/b7/02/654426ce265ac19e2980bfd9ea6590ca96a56f10c76e63801a2df01c0486/uvloop-0.22.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e2ea3d6190a2968f4a14a23019d3b16870dd2190cd69c8180f7c632d21de68d", size = 4288562, upload-time = "2025-10-16T22:16:39.375Z" }, - { url = "https://files.pythonhosted.org/packages/15/c0/0be24758891ef825f2065cd5db8741aaddabe3e248ee6acc5e8a80f04005/uvloop-0.22.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0530a5fbad9c9e4ee3f2b33b148c6a64d47bbad8000ea63704fa8260f4cf728e", size = 4366890, upload-time = "2025-10-16T22:16:40.547Z" }, - { url = "https://files.pythonhosted.org/packages/d2/53/8369e5219a5855869bcee5f4d317f6da0e2c669aecf0ef7d371e3d084449/uvloop-0.22.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bc5ef13bbc10b5335792360623cc378d52d7e62c2de64660616478c32cd0598e", size = 4119472, upload-time = "2025-10-16T22:16:41.694Z" }, - { url = "https://files.pythonhosted.org/packages/f8/ba/d69adbe699b768f6b29a5eec7b47dd610bd17a69de51b251126a801369ea/uvloop-0.22.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1f38ec5e3f18c8a10ded09742f7fb8de0108796eb673f30ce7762ce1b8550cad", size = 4239051, upload-time = "2025-10-16T22:16:43.224Z" }, - { url = "https://files.pythonhosted.org/packages/90/cd/b62bdeaa429758aee8de8b00ac0dd26593a9de93d302bff3d21439e9791d/uvloop-0.22.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3879b88423ec7e97cd4eba2a443aa26ed4e59b45e6b76aabf13fe2f27023a142", size = 1362067, upload-time = "2025-10-16T22:16:44.503Z" }, - { url = "https://files.pythonhosted.org/packages/0d/f8/a132124dfda0777e489ca86732e85e69afcd1ff7686647000050ba670689/uvloop-0.22.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:4baa86acedf1d62115c1dc6ad1e17134476688f08c6efd8a2ab076e815665c74", size = 752423, upload-time = "2025-10-16T22:16:45.968Z" }, - { url = "https://files.pythonhosted.org/packages/a3/94/94af78c156f88da4b3a733773ad5ba0b164393e357cc4bd0ab2e2677a7d6/uvloop-0.22.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:297c27d8003520596236bdb2335e6b3f649480bd09e00d1e3a99144b691d2a35", size = 4272437, upload-time = "2025-10-16T22:16:47.451Z" }, - { url = "https://files.pythonhosted.org/packages/b5/35/60249e9fd07b32c665192cec7af29e06c7cd96fa1d08b84f012a56a0b38e/uvloop-0.22.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c1955d5a1dd43198244d47664a5858082a3239766a839b2102a269aaff7a4e25", size = 4292101, upload-time = "2025-10-16T22:16:49.318Z" }, - { url = "https://files.pythonhosted.org/packages/02/62/67d382dfcb25d0a98ce73c11ed1a6fba5037a1a1d533dcbb7cab033a2636/uvloop-0.22.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b31dc2fccbd42adc73bc4e7cdbae4fc5086cf378979e53ca5d0301838c5682c6", size = 4114158, upload-time = "2025-10-16T22:16:50.517Z" }, - { url = "https://files.pythonhosted.org/packages/f0/7a/f1171b4a882a5d13c8b7576f348acfe6074d72eaf52cccef752f748d4a9f/uvloop-0.22.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:93f617675b2d03af4e72a5333ef89450dfaa5321303ede6e67ba9c9d26878079", size = 4177360, upload-time = "2025-10-16T22:16:52.646Z" }, - { url = "https://files.pythonhosted.org/packages/79/7b/b01414f31546caf0919da80ad57cbfe24c56b151d12af68cee1b04922ca8/uvloop-0.22.1-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:37554f70528f60cad66945b885eb01f1bb514f132d92b6eeed1c90fd54ed6289", size = 1454790, upload-time = "2025-10-16T22:16:54.355Z" }, - { url = "https://files.pythonhosted.org/packages/d4/31/0bb232318dd838cad3fa8fb0c68c8b40e1145b32025581975e18b11fab40/uvloop-0.22.1-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:b76324e2dc033a0b2f435f33eb88ff9913c156ef78e153fb210e03c13da746b3", size = 796783, upload-time = "2025-10-16T22:16:55.906Z" }, - { url = "https://files.pythonhosted.org/packages/42/38/c9b09f3271a7a723a5de69f8e237ab8e7803183131bc57c890db0b6bb872/uvloop-0.22.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:badb4d8e58ee08dad957002027830d5c3b06aea446a6a3744483c2b3b745345c", size = 4647548, upload-time = "2025-10-16T22:16:57.008Z" }, - { url = "https://files.pythonhosted.org/packages/c1/37/945b4ca0ac27e3dc4952642d4c900edd030b3da6c9634875af6e13ae80e5/uvloop-0.22.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b91328c72635f6f9e0282e4a57da7470c7350ab1c9f48546c0f2866205349d21", size = 4467065, upload-time = "2025-10-16T22:16:58.206Z" }, - { url = "https://files.pythonhosted.org/packages/97/cc/48d232f33d60e2e2e0b42f4e73455b146b76ebe216487e862700457fbf3c/uvloop-0.22.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:daf620c2995d193449393d6c62131b3fbd40a63bf7b307a1527856ace637fe88", size = 4328384, upload-time = "2025-10-16T22:16:59.36Z" }, - { url = "https://files.pythonhosted.org/packages/e4/16/c1fd27e9549f3c4baf1dc9c20c456cd2f822dbf8de9f463824b0c0357e06/uvloop-0.22.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6cde23eeda1a25c75b2e07d39970f3374105d5eafbaab2a4482be82f272d5a5e", size = 4296730, upload-time = "2025-10-16T22:17:00.744Z" }, + { url = "https://files.pythonhosted.org/packages/89/8c/182a2a593195bfd39842ea68ebc084e20c850806117213f5a299dfc513d9/uvloop-0.22.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:561577354eb94200d75aca23fbde86ee11be36b00e52a4eaf8f50fb0c86b7705", size = 1358611, upload-time = "2025-10-16T22:16:36.833Z" }, + { url = "https://files.pythonhosted.org/packages/d2/14/e301ee96a6dc95224b6f1162cd3312f6d1217be3907b79173b06785f2fe7/uvloop-0.22.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:1cdf5192ab3e674ca26da2eada35b288d2fa49fdd0f357a19f0e7c4e7d5077c8", size = 751811, upload-time = "2025-10-16T22:16:38.275Z" }, + { url = "https://files.pythonhosted.org/packages/b7/02/654426ce265ac19e2980bfd9ea6590ca96a56f10c76e63801a2df01c0486/uvloop-0.22.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e2ea3d6190a2968f4a14a23019d3b16870dd2190cd69c8180f7c632d21de68d", size = 4288562, upload-time = "2025-10-16T22:16:39.375Z" }, + { url = "https://files.pythonhosted.org/packages/15/c0/0be24758891ef825f2065cd5db8741aaddabe3e248ee6acc5e8a80f04005/uvloop-0.22.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0530a5fbad9c9e4ee3f2b33b148c6a64d47bbad8000ea63704fa8260f4cf728e", size = 4366890, upload-time = "2025-10-16T22:16:40.547Z" }, + { url = "https://files.pythonhosted.org/packages/d2/53/8369e5219a5855869bcee5f4d317f6da0e2c669aecf0ef7d371e3d084449/uvloop-0.22.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bc5ef13bbc10b5335792360623cc378d52d7e62c2de64660616478c32cd0598e", size = 4119472, upload-time = "2025-10-16T22:16:41.694Z" }, + { url = "https://files.pythonhosted.org/packages/f8/ba/d69adbe699b768f6b29a5eec7b47dd610bd17a69de51b251126a801369ea/uvloop-0.22.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1f38ec5e3f18c8a10ded09742f7fb8de0108796eb673f30ce7762ce1b8550cad", size = 4239051, upload-time = "2025-10-16T22:16:43.224Z" }, + { url = "https://files.pythonhosted.org/packages/90/cd/b62bdeaa429758aee8de8b00ac0dd26593a9de93d302bff3d21439e9791d/uvloop-0.22.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3879b88423ec7e97cd4eba2a443aa26ed4e59b45e6b76aabf13fe2f27023a142", size = 1362067, upload-time = "2025-10-16T22:16:44.503Z" }, + { url = "https://files.pythonhosted.org/packages/0d/f8/a132124dfda0777e489ca86732e85e69afcd1ff7686647000050ba670689/uvloop-0.22.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:4baa86acedf1d62115c1dc6ad1e17134476688f08c6efd8a2ab076e815665c74", size = 752423, upload-time = "2025-10-16T22:16:45.968Z" }, + { url = "https://files.pythonhosted.org/packages/a3/94/94af78c156f88da4b3a733773ad5ba0b164393e357cc4bd0ab2e2677a7d6/uvloop-0.22.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:297c27d8003520596236bdb2335e6b3f649480bd09e00d1e3a99144b691d2a35", size = 4272437, upload-time = "2025-10-16T22:16:47.451Z" }, + { url = "https://files.pythonhosted.org/packages/b5/35/60249e9fd07b32c665192cec7af29e06c7cd96fa1d08b84f012a56a0b38e/uvloop-0.22.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c1955d5a1dd43198244d47664a5858082a3239766a839b2102a269aaff7a4e25", size = 4292101, upload-time = "2025-10-16T22:16:49.318Z" }, + { url = "https://files.pythonhosted.org/packages/02/62/67d382dfcb25d0a98ce73c11ed1a6fba5037a1a1d533dcbb7cab033a2636/uvloop-0.22.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b31dc2fccbd42adc73bc4e7cdbae4fc5086cf378979e53ca5d0301838c5682c6", size = 4114158, upload-time = "2025-10-16T22:16:50.517Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/f1171b4a882a5d13c8b7576f348acfe6074d72eaf52cccef752f748d4a9f/uvloop-0.22.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:93f617675b2d03af4e72a5333ef89450dfaa5321303ede6e67ba9c9d26878079", size = 4177360, upload-time = "2025-10-16T22:16:52.646Z" }, + { url = "https://files.pythonhosted.org/packages/79/7b/b01414f31546caf0919da80ad57cbfe24c56b151d12af68cee1b04922ca8/uvloop-0.22.1-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:37554f70528f60cad66945b885eb01f1bb514f132d92b6eeed1c90fd54ed6289", size = 1454790, upload-time = "2025-10-16T22:16:54.355Z" }, + { url = "https://files.pythonhosted.org/packages/d4/31/0bb232318dd838cad3fa8fb0c68c8b40e1145b32025581975e18b11fab40/uvloop-0.22.1-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:b76324e2dc033a0b2f435f33eb88ff9913c156ef78e153fb210e03c13da746b3", size = 796783, upload-time = "2025-10-16T22:16:55.906Z" }, + { url = "https://files.pythonhosted.org/packages/42/38/c9b09f3271a7a723a5de69f8e237ab8e7803183131bc57c890db0b6bb872/uvloop-0.22.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:badb4d8e58ee08dad957002027830d5c3b06aea446a6a3744483c2b3b745345c", size = 4647548, upload-time = "2025-10-16T22:16:57.008Z" }, + { url = "https://files.pythonhosted.org/packages/c1/37/945b4ca0ac27e3dc4952642d4c900edd030b3da6c9634875af6e13ae80e5/uvloop-0.22.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b91328c72635f6f9e0282e4a57da7470c7350ab1c9f48546c0f2866205349d21", size = 4467065, upload-time = "2025-10-16T22:16:58.206Z" }, + { url = "https://files.pythonhosted.org/packages/97/cc/48d232f33d60e2e2e0b42f4e73455b146b76ebe216487e862700457fbf3c/uvloop-0.22.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:daf620c2995d193449393d6c62131b3fbd40a63bf7b307a1527856ace637fe88", size = 4328384, upload-time = "2025-10-16T22:16:59.36Z" }, + { url = "https://files.pythonhosted.org/packages/e4/16/c1fd27e9549f3c4baf1dc9c20c456cd2f822dbf8de9f463824b0c0357e06/uvloop-0.22.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6cde23eeda1a25c75b2e07d39970f3374105d5eafbaab2a4482be82f272d5a5e", size = 4296730, upload-time = "2025-10-16T22:17:00.744Z" }, ] [[package]] @@ -1671,14 +1672,14 @@ name = "virtualenv" version = "21.4.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "distlib" }, - { name = "filelock" }, - { name = "platformdirs" }, - { name = "python-discovery" }, + { name = "distlib" }, + { name = "filelock" }, + { name = "platformdirs" }, + { name = "python-discovery" }, ] sdist = { url = "https://files.pythonhosted.org/packages/95/f0/b47ecf438211a25a97f8f0e4b23c22bc2496ebfea18dd6ec16210f09cc36/virtualenv-21.4.1.tar.gz", hash = "sha256:2ca543c713b72840ceffd94e9bdedfbd09a661defa1f7f69e5429ad4059442e2", size = 7613344, upload-time = "2026-05-28T04:12:49.905Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ff/dc/ac4f3a987a87e1a18556896f257c4e15c95ed157b7975347ec6b313b75ce/virtualenv-21.4.1-py3-none-any.whl", hash = "sha256:caf4ff72d1b4039057f41d8e8466e859513d67c0400d9c6b62c02c9d1ebc3e12", size = 7594078, upload-time = "2026-05-28T04:12:47.686Z" }, + { url = "https://files.pythonhosted.org/packages/ff/dc/ac4f3a987a87e1a18556896f257c4e15c95ed157b7975347ec6b313b75ce/virtualenv-21.4.1-py3-none-any.whl", hash = "sha256:caf4ff72d1b4039057f41d8e8466e859513d67c0400d9c6b62c02c9d1ebc3e12", size = 7594078, upload-time = "2026-05-28T04:12:47.686Z" }, ] [[package]] @@ -1686,71 +1687,71 @@ name = "watchfiles" version = "1.2.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "anyio" }, + { name = "anyio" }, ] sdist = { url = "https://files.pythonhosted.org/packages/cd/41/5e1a4bb12aac5f1493fa1bdc11154eca3b258ca4eba65d39c473fe19d8e9/watchfiles-1.2.0.tar.gz", hash = "sha256:c995fba777f1ea992f090f9236e9284cf7a5d1a0130dd5a3d82c598cacd76838", size = 108252, upload-time = "2026-05-18T04:32:04.251Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d1/4d/70a7feced9f87e2ff26dba42667290f41694fc64646c67261fbb8cab5d5c/watchfiles-1.2.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:01ea8d66f0693b9b60a6541c8d10263091ca9a9060d242f3c1f3143f9aad2c98", size = 399730, upload-time = "2026-05-18T04:31:38.162Z" }, - { url = "https://files.pythonhosted.org/packages/31/3a/0da302f2307aee316922806ebd5726c542cbd787c938271cf14a074c7daf/watchfiles-1.2.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7ba0480b9a74af058f43b337e937a451e109295c420916d68ad24e3dc02f5e44", size = 392842, upload-time = "2026-05-18T04:30:27.051Z" }, - { url = "https://files.pythonhosted.org/packages/db/ef/d5bdb705c224dbc256aa0c1ec47bf4e61ec52558f2afb44a71a1fe4d7015/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4f34e26a19f91f710c08e0183429f0d1d15df734e6bc78c31e77b9ea9c433658", size = 452989, upload-time = "2026-05-18T04:31:11.945Z" }, - { url = "https://files.pythonhosted.org/packages/71/29/5495f2c1661949ef7a35e4d71111d129cfe7606414a26887a919d0a55406/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b4e77f6a55f858504069abd35d336a637555c09bca453dde1ee1e5ada8a6a1fb", size = 458978, upload-time = "2026-05-18T04:30:52.606Z" }, - { url = "https://files.pythonhosted.org/packages/d5/8c/7f9c07c433811c2fffd93e13fdfb7135de9aab5f2ae41be08960fa0047dc/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0cb4d80e212f116474a545c21c912b445f16bb0cef9e6a73a498164223e14e2f", size = 490248, upload-time = "2026-05-18T04:31:36.003Z" }, - { url = "https://files.pythonhosted.org/packages/3c/11/d93632febc52fbc21be90231bb7c17fd5387f46c9076fd40a5f9c2ae6910/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b974946a10af379d425e2eef5b62f5c6ebeaccf91d45eaad6f5b27ecd4f91aa0", size = 571847, upload-time = "2026-05-18T04:31:10.862Z" }, - { url = "https://files.pythonhosted.org/packages/55/b4/383173e73aabb07ad1d9c7aa859d95437ac46a6d6a1e11005facda0c9d19/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:86bc13c25a8d1fcd70b51d0ce7c9b65e90de5666fcbfd3e34957cc73ee19aeb5", size = 465974, upload-time = "2026-05-18T04:30:17.006Z" }, - { url = "https://files.pythonhosted.org/packages/a7/6c/89b1a230a78f57c52dd8893adb1f92f94411721b6ec12596c56d98c74356/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ca148d73dea36c9763aaa351e4d7a51780ec1584217c45276f4fe8239c768b71", size = 454782, upload-time = "2026-05-18T04:30:35.656Z" }, - { url = "https://files.pythonhosted.org/packages/24/62/1732118367cfff0a9fce3bf62ff4bfded09ef5df21d9d446b858b3f70a96/watchfiles-1.2.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:c525543d91961c6955b2636b308569e84a1d1c5f5f2932041ab9ef46422f43e3", size = 465182, upload-time = "2026-05-18T04:30:20.846Z" }, - { url = "https://files.pythonhosted.org/packages/28/96/716f7e5f51339bf22963f3345f9f27d7f3b30e2eadc597e257c881dd3c53/watchfiles-1.2.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:a204794696ffb8f9b10fba6f7cb5216d42f3b2b71860ccac6b6e42f5f10973b0", size = 629841, upload-time = "2026-05-18T04:31:05.397Z" }, - { url = "https://files.pythonhosted.org/packages/4c/fe/c40783950fd771ccf66ab3ec2722d188a9af1c7f96c6e811f36e40c6e03f/watchfiles-1.2.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:10d86db20695afe7997ac9e1717637d6714a8d0220458c33f3d2061f54cec427", size = 658028, upload-time = "2026-05-18T04:31:48.22Z" }, - { url = "https://files.pythonhosted.org/packages/71/72/4508db1856d1d87fcbb3b63f4839bab1b5682cb0e8d224d122263c09654a/watchfiles-1.2.0-cp313-cp313-win32.whl", hash = "sha256:eb283ee99e21ad6443c8cdb06ac5b34b1308c329cbdf03fa02b445363714c799", size = 275183, upload-time = "2026-05-18T04:30:59.57Z" }, - { url = "https://files.pythonhosted.org/packages/f9/36/14b76ca57652e5cc5fd1c11f32a261292c08a0d19a00351013c2549cbfb2/watchfiles-1.2.0-cp313-cp313-win_amd64.whl", hash = "sha256:a0f27f01bee51861392bb6b7c4fdb290b27d1eb194e9e28788d68102a0e898d9", size = 288059, upload-time = "2026-05-18T04:32:07.937Z" }, - { url = "https://files.pythonhosted.org/packages/1b/8d/0a85e395398d8d20fadfe5c5d32c726eee17a519e78fb356f2cf7531bffe/watchfiles-1.2.0-cp313-cp313-win_arm64.whl", hash = "sha256:3651aa7058595e9cfb75d35dd5ada2bf9f48a5b8a0f3562821d3e210c507e077", size = 280186, upload-time = "2026-05-18T04:31:54.484Z" }, - { url = "https://files.pythonhosted.org/packages/37/68/36db056f1fdcc5f07302f56e631774d6835bcd6fa3ace402304621d5f9e5/watchfiles-1.2.0-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:faea288b6f0ab1902ef08f4ca6de005dccf856c4e0c4f21b8c5fce02d90a1b08", size = 399031, upload-time = "2026-05-18T04:30:44.576Z" }, - { url = "https://files.pythonhosted.org/packages/c1/64/01a9d6f66a82a5c101ce939274106cc72759d62427e153f01edd2b9f87c2/watchfiles-1.2.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:01859b11fd9fbca670f4d5da00fbac282cfea9bd67a2125d8b2833a3b5617ea9", size = 391205, upload-time = "2026-05-18T04:30:25.413Z" }, - { url = "https://files.pythonhosted.org/packages/84/2c/0a44fe058cb4bb7b8ede6b6670698bbb7c0400740e378d00022189b7b31d/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fff610d7bb2256a317bb1e96f0d7862c7aa8076733ee5df0fd41bbe76a24a4f4", size = 451892, upload-time = "2026-05-18T04:32:14.005Z" }, - { url = "https://files.pythonhosted.org/packages/67/a1/351e0d56cd35e6488b5c8b4fb11a809a5bc923e8fe8fed9faf8920be0c89/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b141a4891c995a039cd89e9a49e62df1dc8a559a5d1a6e4c7106d16c12777a55", size = 458867, upload-time = "2026-05-18T04:31:22.279Z" }, - { url = "https://files.pythonhosted.org/packages/d5/7d/9d09605187f1b838998624049fcf8bf47b73c1a3b76901fcac1782f62277/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f22943b7770483f6ea0721c6b11d022947a98eb0acae14694de034f4d0d38925", size = 490217, upload-time = "2026-05-18T04:31:43.657Z" }, - { url = "https://files.pythonhosted.org/packages/60/5d/a17a16eccb182f04188cd308ec24b1a71a9b5c4e7098269cf35d9fa56d02/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1bc6195825b7dcd217968bb1f801a60fd4c16e8eeab5bedc7fe917d7d5995ab4", size = 571458, upload-time = "2026-05-18T04:32:11.875Z" }, - { url = "https://files.pythonhosted.org/packages/d3/3d/4dd457062083ab1938e5dfd45032eb425cee2ac817287ca8ff4356183e5d/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d4a4b147f5dca2a5d325a06a832fb43f345751adfbc63204aec30e0d9ca965a2", size = 464707, upload-time = "2026-05-18T04:30:43.492Z" }, - { url = "https://files.pythonhosted.org/packages/c6/71/ea8c57b128f5383de74d0c7d2d9c57ad7c9a65a930c451bd25d524b295b7/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4543579a9bdb0c9560039b4ffddbdb39545707659fbc430ce4c10f3f68d557f9", size = 454663, upload-time = "2026-05-18T04:30:16.061Z" }, - { url = "https://files.pythonhosted.org/packages/53/fd/2e812bf938406d7db351f0703ddd3fc6c061cf30d96153a77bc79a943a44/watchfiles-1.2.0-cp313-cp313t-manylinux_2_31_riscv64.whl", hash = "sha256:20aa0e708b920bde876a4aa82dc7dd6ebea228a63a67cda6632c2fc87b787efa", size = 463537, upload-time = "2026-05-18T04:31:44.9Z" }, - { url = "https://files.pythonhosted.org/packages/86/56/d17a7f1dd1bc3035f1072694a551301272f1739c2d8e319c927cb9e29b38/watchfiles-1.2.0-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:d413349d565dab74297f2a63e84a097936be69bf8f3b3801f27f380e32040f44", size = 629194, upload-time = "2026-05-18T04:31:14.141Z" }, - { url = "https://files.pythonhosted.org/packages/be/06/f1ff66bf5cae50aa4062779a0ecd0bbaf15e466195719074078947d9a17d/watchfiles-1.2.0-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:f28b2725eb8cce327b9b3ab02415c853011dc55c95832fe90de6bc56f5315f72", size = 656194, upload-time = "2026-05-18T04:31:47.14Z" }, - { url = "https://files.pythonhosted.org/packages/e7/54/a9c7ea9a82a4ac65e7004c0a03920b5cdd2f9c3b678757d9cd425aa51d53/watchfiles-1.2.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:b8c8358484d5fa12ef34f05b7f4168eaf1932f408725ff6d023c33ec17bd79d4", size = 400205, upload-time = "2026-05-18T04:32:05.153Z" }, - { url = "https://files.pythonhosted.org/packages/aa/5d/c9ab3534374a4a67450696905d6ef16a04405448b8dc52bd752ae50423d4/watchfiles-1.2.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:9f04b092229ad2c50126dd3c922c8822e51e605993764a33058d4a791ab42281", size = 392508, upload-time = "2026-05-18T04:30:54.849Z" }, - { url = "https://files.pythonhosted.org/packages/26/ca/1ad30103535cf0cecd7b993e8d50edc5351b1820e38f2d22e3df58962feb/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7a7ce236284f002a156f70add88efe5c70879cccbb658be0822c54b1306fc09d", size = 452448, upload-time = "2026-05-18T04:30:53.727Z" }, - { url = "https://files.pythonhosted.org/packages/37/a1/ceee2cdf2afbd715fa07758d39c9859513eae411b23196f7fd039e5feedd/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b9909cc2b48468b575eefa944919e1fe8a36c5849d5c7c168f80a8c1db69398e", size = 459605, upload-time = "2026-05-18T04:30:23.312Z" }, - { url = "https://files.pythonhosted.org/packages/e8/f6/421e30fd1cb3907a84ed92ab3f1983e37ba2dca015e9a894a048418417a2/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0a37faaed405c67e28e6be45a1fa4f206ef5a2860f27c237db9fa30704c38242", size = 490757, upload-time = "2026-05-18T04:30:47.358Z" }, - { url = "https://files.pythonhosted.org/packages/41/b0/55ed1b97ed08be7bba6f9a541cac15f2a858e1d74d2b07b6da70a82aab00/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9649193aa27bd9ff2e80ff29bfaa93085496c7a3a377592823cc58b77ee88add", size = 568672, upload-time = "2026-05-18T04:30:38.915Z" }, - { url = "https://files.pythonhosted.org/packages/d1/cf/d8ae8a80dd7bafab395ea7681c10237311bbf34d37704a8c744e7cf31fc7/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4e4ff8e37f99cf1da89e255e07c9c4b37c214038c4283707bdec308cb1b0ea1f", size = 464197, upload-time = "2026-05-18T04:30:09.914Z" }, - { url = "https://files.pythonhosted.org/packages/7c/8a/3076c496ca8dafe0e8cd03fcebdfc47be4b1174b4e5b24ff6e396e6b3af2/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:054dc20fd2e3132b4c3883b4a00d72fd6e1f56fdaf89fccd12e8057d74cd74d7", size = 453181, upload-time = "2026-05-18T04:30:14.829Z" }, - { url = "https://files.pythonhosted.org/packages/e5/10/9745e17c98e7b8a86454df0a3c7b5686bd650383f1e9f26e4ebcbd6cc0c0/watchfiles-1.2.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:e140ed30ebde76796b686e67c182cff10ea2fbab186fafd1560f74bb5a473a6e", size = 465109, upload-time = "2026-05-18T04:30:28.123Z" }, - { url = "https://files.pythonhosted.org/packages/8f/95/8ef4a95481d3e0cb52d62a06fa6e972e81424be2d9698b91a2fecca9904c/watchfiles-1.2.0-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:bb7e52ecf68ba46d22df23467b87cffeb2146908aa523ebfe803019618cfda06", size = 630653, upload-time = "2026-05-18T04:31:49.304Z" }, - { url = "https://files.pythonhosted.org/packages/fd/e4/3b3bf36b0f829b50c6ebcb8d031583863c59f923d6a6af3d485e470d0fac/watchfiles-1.2.0-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:23282a321c8baf9b3a3c4afff673f9fe65eb7fdc2338d765ccad9d3d1916a5ba", size = 657838, upload-time = "2026-05-18T04:31:06.497Z" }, - { url = "https://files.pythonhosted.org/packages/21/b1/6cbbb50c1f3002ab568777d44aa21206dfb8807a840990c4037523b51812/watchfiles-1.2.0-cp314-cp314-win32.whl", hash = "sha256:c0db965c5f79aa49fe672d297cf1febc5ad149b658594944f49a54a2b96270a7", size = 275108, upload-time = "2026-05-18T04:30:06.891Z" }, - { url = "https://files.pythonhosted.org/packages/92/45/190ce6db8dcb4536682cf75d3889ff1a27182a58cb519d343cb6d9ea63d8/watchfiles-1.2.0-cp314-cp314-win_amd64.whl", hash = "sha256:71283b39fd17e5408eb123bd37aeecfd9d54c81fc184421943208aadb879d103", size = 288441, upload-time = "2026-05-18T04:32:12.901Z" }, - { url = "https://files.pythonhosted.org/packages/74/0d/3eae1c2313ab08378431d907c3f8095ecca00f3eda33111cf4f0f2591799/watchfiles-1.2.0-cp314-cp314-win_arm64.whl", hash = "sha256:c5c19526f4e54a00f2666a6c0e9e40d582c09e865055ea7378bf0009aab857b3", size = 280684, upload-time = "2026-05-18T04:31:26.902Z" }, - { url = "https://files.pythonhosted.org/packages/b1/75/fb64e6c25d6b5ca636d03df34ffb1c6e9873303e76d27967e045f8df088f/watchfiles-1.2.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:d73a585accffa5ae39c17264c36ec3166d2fad7000c780f5ef83b2722afb9dd2", size = 398857, upload-time = "2026-05-18T04:32:17.108Z" }, - { url = "https://files.pythonhosted.org/packages/73/4e/9f7adf01754cbf81843722ccfec169d8f26c69778281a302855cecd2ee08/watchfiles-1.2.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ae99b14c5f21e026e0e9d96f40e07d8570ebee6cafd9d8fc318354606daa7a28", size = 392413, upload-time = "2026-05-18T04:31:07.911Z" }, - { url = "https://files.pythonhosted.org/packages/47/c8/bec626bcc2d69f44b9acb24ce7d60ed7b16b73628eea747fcbd169d8edda/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4429f3b105524a10b72c3a819b091c495d2811d419c1e1e8df773a5a5974f831", size = 452409, upload-time = "2026-05-18T04:31:20.142Z" }, - { url = "https://files.pythonhosted.org/packages/00/b7/b6362068e81e7c556d155a34c35d40ac3ef42d747b06d7f6e5bf58e359c2/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:43d818978d06062d9b22c4fab2ebe44cf5213d42dc8e62bda8c2760cfa2eeb33", size = 458827, upload-time = "2026-05-18T04:32:06.219Z" }, - { url = "https://files.pythonhosted.org/packages/67/f8/9a813fa42afb1e0b4625e75f0479826644d3ee8dc287e093799bc01f390c/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b9f732dc58b2dbe69e464ccf8fff7a03b0dd0be439da4c0720d3558527d3d6b4", size = 490104, upload-time = "2026-05-18T04:31:56.034Z" }, - { url = "https://files.pythonhosted.org/packages/2f/bf/27dfb6094ca4c9aad21298b5525b6c53cb36121ee454331d05161e58d130/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8f200104103feb097de4cab8fe4f5dd18a2026934c7dea98c55a2f5fd6d5a33b", size = 571360, upload-time = "2026-05-18T04:31:57.133Z" }, - { url = "https://files.pythonhosted.org/packages/fb/39/44a096d67270ea93df91d33877dbe91fbda3aa4f8ec2edf799d93eda8736/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:63ac26eefbf4af1741247d6fb68b11c49a25b2f7413fbd318a83a12aaa9cf666", size = 464644, upload-time = "2026-05-18T04:30:57.33Z" }, - { url = "https://files.pythonhosted.org/packages/0e/80/c7472203bad6268e3ef1ad260739704847898938ad7ea8b63a5131f46b50/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0c4997d4e4a55f0d02b6cde327322daf3a0400e5df6c6b15948994bf72497925", size = 454771, upload-time = "2026-05-18T04:30:48.736Z" }, - { url = "https://files.pythonhosted.org/packages/51/cf/3b10b268b4b7f0fc26e9debb5eef1998b515887840f444cd3ec80c688755/watchfiles-1.2.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:4c887eba18b7945ac73067a8b4a66f21cd46c2539b2bc68588f7be6c7eb6d26b", size = 463494, upload-time = "2026-05-18T04:31:33.826Z" }, - { url = "https://files.pythonhosted.org/packages/3d/3e/a4302545cd589262a0dc7d140e86f7688eba3f9c72776c27f7e23b8864c4/watchfiles-1.2.0-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:3416ff151bb6b5a8d8d11664974fbef4d9305b9b2957839ab5a270468fd8df30", size = 629383, upload-time = "2026-05-18T04:31:15.596Z" }, - { url = "https://files.pythonhosted.org/packages/db/99/d5649df0a9a410d45b7c882304d0b790903ac9b6e8f2cfd12114e0c6b9f2/watchfiles-1.2.0-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:0e831a271c035d89789cffc386b6aa1375f39f1cd25eb7ca0997e4970d152fc5", size = 656093, upload-time = "2026-05-18T04:31:58.707Z" }, - { url = "https://files.pythonhosted.org/packages/92/b9/362702539275019a54dd2e94511b31a9b89c5f9e6a21966de7eb692549fc/watchfiles-1.2.0-cp315-cp315-macosx_10_12_x86_64.whl", hash = "sha256:37a6721cdf3f65dbb13aa9503510ccb4451603ac837e44d265d7992a597e1374", size = 400109, upload-time = "2026-05-18T04:31:16.879Z" }, - { url = "https://files.pythonhosted.org/packages/8f/75/71d5ba62db781e5587bded1d944c675374bc4aa37ff33d5018d98e8b6538/watchfiles-1.2.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:2b37d10b5a63bd4d87e18472d80fa525bd670586fae62e5dd580452764879b65", size = 392167, upload-time = "2026-05-18T04:31:28.058Z" }, - { url = "https://files.pythonhosted.org/packages/3c/01/c66dd95d0423fe30d31820e2d1d5bda773764131bbb6ac0cb1cf303ac328/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0a105bc2283f67e8fbec74253ec2d94925de92ed72c0393f1206bf326b7b7b69", size = 452372, upload-time = "2026-05-18T04:31:00.836Z" }, - { url = "https://files.pythonhosted.org/packages/91/15/2fe99557e72f85627c6a8eed50d889e8d101623e060a22ad75b875cb932d/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5327989a465505f05cfe06f04fa9d0c2fd5432bb243e10e6f012b1bdca3c8579", size = 459596, upload-time = "2026-05-18T04:31:34.96Z" }, - { url = "https://files.pythonhosted.org/packages/ed/23/d4acfa0023367428ed48351b3b9b267893037b6cadae55620c61c24bcfd4/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ecb47f183a8025b2aa18b546725c3657e542112ae9c0613a2af79b4fa8d04ad7", size = 490869, upload-time = "2026-05-18T04:31:59.923Z" }, - { url = "https://files.pythonhosted.org/packages/a4/5f/3164cbdce06c9fb95c4f7b9e2f9760b5e2797af43a9ecc317ef42a23a278/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8520a4ab0e37f770afc34459c4f8f7019e153f9124dc101c15538365875d1ab2", size = 571641, upload-time = "2026-05-18T04:32:00.948Z" }, - { url = "https://files.pythonhosted.org/packages/41/e6/85d3731c55e65cd7690f3f803d24c139588aaf863e4bf2148fe7a7fa1a19/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:71cd71740ed2c15211ebb237ced4e39a1cdf6f80566e5fe95428da1626f4fde6", size = 464444, upload-time = "2026-05-18T04:30:34.298Z" }, - { url = "https://files.pythonhosted.org/packages/f4/7d/562641012b8b09872742c3b8adf9629ec479fd78f8d68ae4a0c13da8add6/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f88af53d6ddaf72179ef613ddc905e6f4785f712b49b80b3bef9f3525e6194b4", size = 453593, upload-time = "2026-05-18T04:31:23.464Z" }, - { url = "https://files.pythonhosted.org/packages/56/fe/cb8ef3d6f929d14158fdaaad9925985b7310abc9384dcd4d82dd0016fb59/watchfiles-1.2.0-cp315-cp315-manylinux_2_31_riscv64.whl", hash = "sha256:cee9d5efd929efdac5f7e58f72b3376f676b64050a91c5b99a7094c5b2317488", size = 465096, upload-time = "2026-05-18T04:31:30.384Z" }, - { url = "https://files.pythonhosted.org/packages/25/91/80908e835e100527a9267147b08c0eee1fa6ab0ffec15edc04d1d44885f7/watchfiles-1.2.0-cp315-cp315-musllinux_1_1_aarch64.whl", hash = "sha256:b718bf356bbc15e559bd8ef41782b573b8ae0e3f177ab244b440568d7ea02cfb", size = 630638, upload-time = "2026-05-18T04:30:49.89Z" }, - { url = "https://files.pythonhosted.org/packages/46/4b/95ab2f256bb4af3cb2eb23b9317bda984ee6e0f11733a5c004a6c95b06e3/watchfiles-1.2.0-cp315-cp315-musllinux_1_1_x86_64.whl", hash = "sha256:922c0e019fe68b3ae392965a766b02a71ba1168c932cebc3733cd52c5fe5b377", size = 657684, upload-time = "2026-05-18T04:31:32.027Z" }, + { url = "https://files.pythonhosted.org/packages/d1/4d/70a7feced9f87e2ff26dba42667290f41694fc64646c67261fbb8cab5d5c/watchfiles-1.2.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:01ea8d66f0693b9b60a6541c8d10263091ca9a9060d242f3c1f3143f9aad2c98", size = 399730, upload-time = "2026-05-18T04:31:38.162Z" }, + { url = "https://files.pythonhosted.org/packages/31/3a/0da302f2307aee316922806ebd5726c542cbd787c938271cf14a074c7daf/watchfiles-1.2.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7ba0480b9a74af058f43b337e937a451e109295c420916d68ad24e3dc02f5e44", size = 392842, upload-time = "2026-05-18T04:30:27.051Z" }, + { url = "https://files.pythonhosted.org/packages/db/ef/d5bdb705c224dbc256aa0c1ec47bf4e61ec52558f2afb44a71a1fe4d7015/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4f34e26a19f91f710c08e0183429f0d1d15df734e6bc78c31e77b9ea9c433658", size = 452989, upload-time = "2026-05-18T04:31:11.945Z" }, + { url = "https://files.pythonhosted.org/packages/71/29/5495f2c1661949ef7a35e4d71111d129cfe7606414a26887a919d0a55406/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b4e77f6a55f858504069abd35d336a637555c09bca453dde1ee1e5ada8a6a1fb", size = 458978, upload-time = "2026-05-18T04:30:52.606Z" }, + { url = "https://files.pythonhosted.org/packages/d5/8c/7f9c07c433811c2fffd93e13fdfb7135de9aab5f2ae41be08960fa0047dc/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0cb4d80e212f116474a545c21c912b445f16bb0cef9e6a73a498164223e14e2f", size = 490248, upload-time = "2026-05-18T04:31:36.003Z" }, + { url = "https://files.pythonhosted.org/packages/3c/11/d93632febc52fbc21be90231bb7c17fd5387f46c9076fd40a5f9c2ae6910/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b974946a10af379d425e2eef5b62f5c6ebeaccf91d45eaad6f5b27ecd4f91aa0", size = 571847, upload-time = "2026-05-18T04:31:10.862Z" }, + { url = "https://files.pythonhosted.org/packages/55/b4/383173e73aabb07ad1d9c7aa859d95437ac46a6d6a1e11005facda0c9d19/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:86bc13c25a8d1fcd70b51d0ce7c9b65e90de5666fcbfd3e34957cc73ee19aeb5", size = 465974, upload-time = "2026-05-18T04:30:17.006Z" }, + { url = "https://files.pythonhosted.org/packages/a7/6c/89b1a230a78f57c52dd8893adb1f92f94411721b6ec12596c56d98c74356/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ca148d73dea36c9763aaa351e4d7a51780ec1584217c45276f4fe8239c768b71", size = 454782, upload-time = "2026-05-18T04:30:35.656Z" }, + { url = "https://files.pythonhosted.org/packages/24/62/1732118367cfff0a9fce3bf62ff4bfded09ef5df21d9d446b858b3f70a96/watchfiles-1.2.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:c525543d91961c6955b2636b308569e84a1d1c5f5f2932041ab9ef46422f43e3", size = 465182, upload-time = "2026-05-18T04:30:20.846Z" }, + { url = "https://files.pythonhosted.org/packages/28/96/716f7e5f51339bf22963f3345f9f27d7f3b30e2eadc597e257c881dd3c53/watchfiles-1.2.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:a204794696ffb8f9b10fba6f7cb5216d42f3b2b71860ccac6b6e42f5f10973b0", size = 629841, upload-time = "2026-05-18T04:31:05.397Z" }, + { url = "https://files.pythonhosted.org/packages/4c/fe/c40783950fd771ccf66ab3ec2722d188a9af1c7f96c6e811f36e40c6e03f/watchfiles-1.2.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:10d86db20695afe7997ac9e1717637d6714a8d0220458c33f3d2061f54cec427", size = 658028, upload-time = "2026-05-18T04:31:48.22Z" }, + { url = "https://files.pythonhosted.org/packages/71/72/4508db1856d1d87fcbb3b63f4839bab1b5682cb0e8d224d122263c09654a/watchfiles-1.2.0-cp313-cp313-win32.whl", hash = "sha256:eb283ee99e21ad6443c8cdb06ac5b34b1308c329cbdf03fa02b445363714c799", size = 275183, upload-time = "2026-05-18T04:30:59.57Z" }, + { url = "https://files.pythonhosted.org/packages/f9/36/14b76ca57652e5cc5fd1c11f32a261292c08a0d19a00351013c2549cbfb2/watchfiles-1.2.0-cp313-cp313-win_amd64.whl", hash = "sha256:a0f27f01bee51861392bb6b7c4fdb290b27d1eb194e9e28788d68102a0e898d9", size = 288059, upload-time = "2026-05-18T04:32:07.937Z" }, + { url = "https://files.pythonhosted.org/packages/1b/8d/0a85e395398d8d20fadfe5c5d32c726eee17a519e78fb356f2cf7531bffe/watchfiles-1.2.0-cp313-cp313-win_arm64.whl", hash = "sha256:3651aa7058595e9cfb75d35dd5ada2bf9f48a5b8a0f3562821d3e210c507e077", size = 280186, upload-time = "2026-05-18T04:31:54.484Z" }, + { url = "https://files.pythonhosted.org/packages/37/68/36db056f1fdcc5f07302f56e631774d6835bcd6fa3ace402304621d5f9e5/watchfiles-1.2.0-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:faea288b6f0ab1902ef08f4ca6de005dccf856c4e0c4f21b8c5fce02d90a1b08", size = 399031, upload-time = "2026-05-18T04:30:44.576Z" }, + { url = "https://files.pythonhosted.org/packages/c1/64/01a9d6f66a82a5c101ce939274106cc72759d62427e153f01edd2b9f87c2/watchfiles-1.2.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:01859b11fd9fbca670f4d5da00fbac282cfea9bd67a2125d8b2833a3b5617ea9", size = 391205, upload-time = "2026-05-18T04:30:25.413Z" }, + { url = "https://files.pythonhosted.org/packages/84/2c/0a44fe058cb4bb7b8ede6b6670698bbb7c0400740e378d00022189b7b31d/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fff610d7bb2256a317bb1e96f0d7862c7aa8076733ee5df0fd41bbe76a24a4f4", size = 451892, upload-time = "2026-05-18T04:32:14.005Z" }, + { url = "https://files.pythonhosted.org/packages/67/a1/351e0d56cd35e6488b5c8b4fb11a809a5bc923e8fe8fed9faf8920be0c89/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b141a4891c995a039cd89e9a49e62df1dc8a559a5d1a6e4c7106d16c12777a55", size = 458867, upload-time = "2026-05-18T04:31:22.279Z" }, + { url = "https://files.pythonhosted.org/packages/d5/7d/9d09605187f1b838998624049fcf8bf47b73c1a3b76901fcac1782f62277/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f22943b7770483f6ea0721c6b11d022947a98eb0acae14694de034f4d0d38925", size = 490217, upload-time = "2026-05-18T04:31:43.657Z" }, + { url = "https://files.pythonhosted.org/packages/60/5d/a17a16eccb182f04188cd308ec24b1a71a9b5c4e7098269cf35d9fa56d02/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1bc6195825b7dcd217968bb1f801a60fd4c16e8eeab5bedc7fe917d7d5995ab4", size = 571458, upload-time = "2026-05-18T04:32:11.875Z" }, + { url = "https://files.pythonhosted.org/packages/d3/3d/4dd457062083ab1938e5dfd45032eb425cee2ac817287ca8ff4356183e5d/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d4a4b147f5dca2a5d325a06a832fb43f345751adfbc63204aec30e0d9ca965a2", size = 464707, upload-time = "2026-05-18T04:30:43.492Z" }, + { url = "https://files.pythonhosted.org/packages/c6/71/ea8c57b128f5383de74d0c7d2d9c57ad7c9a65a930c451bd25d524b295b7/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4543579a9bdb0c9560039b4ffddbdb39545707659fbc430ce4c10f3f68d557f9", size = 454663, upload-time = "2026-05-18T04:30:16.061Z" }, + { url = "https://files.pythonhosted.org/packages/53/fd/2e812bf938406d7db351f0703ddd3fc6c061cf30d96153a77bc79a943a44/watchfiles-1.2.0-cp313-cp313t-manylinux_2_31_riscv64.whl", hash = "sha256:20aa0e708b920bde876a4aa82dc7dd6ebea228a63a67cda6632c2fc87b787efa", size = 463537, upload-time = "2026-05-18T04:31:44.9Z" }, + { url = "https://files.pythonhosted.org/packages/86/56/d17a7f1dd1bc3035f1072694a551301272f1739c2d8e319c927cb9e29b38/watchfiles-1.2.0-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:d413349d565dab74297f2a63e84a097936be69bf8f3b3801f27f380e32040f44", size = 629194, upload-time = "2026-05-18T04:31:14.141Z" }, + { url = "https://files.pythonhosted.org/packages/be/06/f1ff66bf5cae50aa4062779a0ecd0bbaf15e466195719074078947d9a17d/watchfiles-1.2.0-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:f28b2725eb8cce327b9b3ab02415c853011dc55c95832fe90de6bc56f5315f72", size = 656194, upload-time = "2026-05-18T04:31:47.14Z" }, + { url = "https://files.pythonhosted.org/packages/e7/54/a9c7ea9a82a4ac65e7004c0a03920b5cdd2f9c3b678757d9cd425aa51d53/watchfiles-1.2.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:b8c8358484d5fa12ef34f05b7f4168eaf1932f408725ff6d023c33ec17bd79d4", size = 400205, upload-time = "2026-05-18T04:32:05.153Z" }, + { url = "https://files.pythonhosted.org/packages/aa/5d/c9ab3534374a4a67450696905d6ef16a04405448b8dc52bd752ae50423d4/watchfiles-1.2.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:9f04b092229ad2c50126dd3c922c8822e51e605993764a33058d4a791ab42281", size = 392508, upload-time = "2026-05-18T04:30:54.849Z" }, + { url = "https://files.pythonhosted.org/packages/26/ca/1ad30103535cf0cecd7b993e8d50edc5351b1820e38f2d22e3df58962feb/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7a7ce236284f002a156f70add88efe5c70879cccbb658be0822c54b1306fc09d", size = 452448, upload-time = "2026-05-18T04:30:53.727Z" }, + { url = "https://files.pythonhosted.org/packages/37/a1/ceee2cdf2afbd715fa07758d39c9859513eae411b23196f7fd039e5feedd/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b9909cc2b48468b575eefa944919e1fe8a36c5849d5c7c168f80a8c1db69398e", size = 459605, upload-time = "2026-05-18T04:30:23.312Z" }, + { url = "https://files.pythonhosted.org/packages/e8/f6/421e30fd1cb3907a84ed92ab3f1983e37ba2dca015e9a894a048418417a2/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0a37faaed405c67e28e6be45a1fa4f206ef5a2860f27c237db9fa30704c38242", size = 490757, upload-time = "2026-05-18T04:30:47.358Z" }, + { url = "https://files.pythonhosted.org/packages/41/b0/55ed1b97ed08be7bba6f9a541cac15f2a858e1d74d2b07b6da70a82aab00/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9649193aa27bd9ff2e80ff29bfaa93085496c7a3a377592823cc58b77ee88add", size = 568672, upload-time = "2026-05-18T04:30:38.915Z" }, + { url = "https://files.pythonhosted.org/packages/d1/cf/d8ae8a80dd7bafab395ea7681c10237311bbf34d37704a8c744e7cf31fc7/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4e4ff8e37f99cf1da89e255e07c9c4b37c214038c4283707bdec308cb1b0ea1f", size = 464197, upload-time = "2026-05-18T04:30:09.914Z" }, + { url = "https://files.pythonhosted.org/packages/7c/8a/3076c496ca8dafe0e8cd03fcebdfc47be4b1174b4e5b24ff6e396e6b3af2/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:054dc20fd2e3132b4c3883b4a00d72fd6e1f56fdaf89fccd12e8057d74cd74d7", size = 453181, upload-time = "2026-05-18T04:30:14.829Z" }, + { url = "https://files.pythonhosted.org/packages/e5/10/9745e17c98e7b8a86454df0a3c7b5686bd650383f1e9f26e4ebcbd6cc0c0/watchfiles-1.2.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:e140ed30ebde76796b686e67c182cff10ea2fbab186fafd1560f74bb5a473a6e", size = 465109, upload-time = "2026-05-18T04:30:28.123Z" }, + { url = "https://files.pythonhosted.org/packages/8f/95/8ef4a95481d3e0cb52d62a06fa6e972e81424be2d9698b91a2fecca9904c/watchfiles-1.2.0-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:bb7e52ecf68ba46d22df23467b87cffeb2146908aa523ebfe803019618cfda06", size = 630653, upload-time = "2026-05-18T04:31:49.304Z" }, + { url = "https://files.pythonhosted.org/packages/fd/e4/3b3bf36b0f829b50c6ebcb8d031583863c59f923d6a6af3d485e470d0fac/watchfiles-1.2.0-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:23282a321c8baf9b3a3c4afff673f9fe65eb7fdc2338d765ccad9d3d1916a5ba", size = 657838, upload-time = "2026-05-18T04:31:06.497Z" }, + { url = "https://files.pythonhosted.org/packages/21/b1/6cbbb50c1f3002ab568777d44aa21206dfb8807a840990c4037523b51812/watchfiles-1.2.0-cp314-cp314-win32.whl", hash = "sha256:c0db965c5f79aa49fe672d297cf1febc5ad149b658594944f49a54a2b96270a7", size = 275108, upload-time = "2026-05-18T04:30:06.891Z" }, + { url = "https://files.pythonhosted.org/packages/92/45/190ce6db8dcb4536682cf75d3889ff1a27182a58cb519d343cb6d9ea63d8/watchfiles-1.2.0-cp314-cp314-win_amd64.whl", hash = "sha256:71283b39fd17e5408eb123bd37aeecfd9d54c81fc184421943208aadb879d103", size = 288441, upload-time = "2026-05-18T04:32:12.901Z" }, + { url = "https://files.pythonhosted.org/packages/74/0d/3eae1c2313ab08378431d907c3f8095ecca00f3eda33111cf4f0f2591799/watchfiles-1.2.0-cp314-cp314-win_arm64.whl", hash = "sha256:c5c19526f4e54a00f2666a6c0e9e40d582c09e865055ea7378bf0009aab857b3", size = 280684, upload-time = "2026-05-18T04:31:26.902Z" }, + { url = "https://files.pythonhosted.org/packages/b1/75/fb64e6c25d6b5ca636d03df34ffb1c6e9873303e76d27967e045f8df088f/watchfiles-1.2.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:d73a585accffa5ae39c17264c36ec3166d2fad7000c780f5ef83b2722afb9dd2", size = 398857, upload-time = "2026-05-18T04:32:17.108Z" }, + { url = "https://files.pythonhosted.org/packages/73/4e/9f7adf01754cbf81843722ccfec169d8f26c69778281a302855cecd2ee08/watchfiles-1.2.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ae99b14c5f21e026e0e9d96f40e07d8570ebee6cafd9d8fc318354606daa7a28", size = 392413, upload-time = "2026-05-18T04:31:07.911Z" }, + { url = "https://files.pythonhosted.org/packages/47/c8/bec626bcc2d69f44b9acb24ce7d60ed7b16b73628eea747fcbd169d8edda/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4429f3b105524a10b72c3a819b091c495d2811d419c1e1e8df773a5a5974f831", size = 452409, upload-time = "2026-05-18T04:31:20.142Z" }, + { url = "https://files.pythonhosted.org/packages/00/b7/b6362068e81e7c556d155a34c35d40ac3ef42d747b06d7f6e5bf58e359c2/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:43d818978d06062d9b22c4fab2ebe44cf5213d42dc8e62bda8c2760cfa2eeb33", size = 458827, upload-time = "2026-05-18T04:32:06.219Z" }, + { url = "https://files.pythonhosted.org/packages/67/f8/9a813fa42afb1e0b4625e75f0479826644d3ee8dc287e093799bc01f390c/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b9f732dc58b2dbe69e464ccf8fff7a03b0dd0be439da4c0720d3558527d3d6b4", size = 490104, upload-time = "2026-05-18T04:31:56.034Z" }, + { url = "https://files.pythonhosted.org/packages/2f/bf/27dfb6094ca4c9aad21298b5525b6c53cb36121ee454331d05161e58d130/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8f200104103feb097de4cab8fe4f5dd18a2026934c7dea98c55a2f5fd6d5a33b", size = 571360, upload-time = "2026-05-18T04:31:57.133Z" }, + { url = "https://files.pythonhosted.org/packages/fb/39/44a096d67270ea93df91d33877dbe91fbda3aa4f8ec2edf799d93eda8736/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:63ac26eefbf4af1741247d6fb68b11c49a25b2f7413fbd318a83a12aaa9cf666", size = 464644, upload-time = "2026-05-18T04:30:57.33Z" }, + { url = "https://files.pythonhosted.org/packages/0e/80/c7472203bad6268e3ef1ad260739704847898938ad7ea8b63a5131f46b50/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0c4997d4e4a55f0d02b6cde327322daf3a0400e5df6c6b15948994bf72497925", size = 454771, upload-time = "2026-05-18T04:30:48.736Z" }, + { url = "https://files.pythonhosted.org/packages/51/cf/3b10b268b4b7f0fc26e9debb5eef1998b515887840f444cd3ec80c688755/watchfiles-1.2.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:4c887eba18b7945ac73067a8b4a66f21cd46c2539b2bc68588f7be6c7eb6d26b", size = 463494, upload-time = "2026-05-18T04:31:33.826Z" }, + { url = "https://files.pythonhosted.org/packages/3d/3e/a4302545cd589262a0dc7d140e86f7688eba3f9c72776c27f7e23b8864c4/watchfiles-1.2.0-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:3416ff151bb6b5a8d8d11664974fbef4d9305b9b2957839ab5a270468fd8df30", size = 629383, upload-time = "2026-05-18T04:31:15.596Z" }, + { url = "https://files.pythonhosted.org/packages/db/99/d5649df0a9a410d45b7c882304d0b790903ac9b6e8f2cfd12114e0c6b9f2/watchfiles-1.2.0-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:0e831a271c035d89789cffc386b6aa1375f39f1cd25eb7ca0997e4970d152fc5", size = 656093, upload-time = "2026-05-18T04:31:58.707Z" }, + { url = "https://files.pythonhosted.org/packages/92/b9/362702539275019a54dd2e94511b31a9b89c5f9e6a21966de7eb692549fc/watchfiles-1.2.0-cp315-cp315-macosx_10_12_x86_64.whl", hash = "sha256:37a6721cdf3f65dbb13aa9503510ccb4451603ac837e44d265d7992a597e1374", size = 400109, upload-time = "2026-05-18T04:31:16.879Z" }, + { url = "https://files.pythonhosted.org/packages/8f/75/71d5ba62db781e5587bded1d944c675374bc4aa37ff33d5018d98e8b6538/watchfiles-1.2.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:2b37d10b5a63bd4d87e18472d80fa525bd670586fae62e5dd580452764879b65", size = 392167, upload-time = "2026-05-18T04:31:28.058Z" }, + { url = "https://files.pythonhosted.org/packages/3c/01/c66dd95d0423fe30d31820e2d1d5bda773764131bbb6ac0cb1cf303ac328/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0a105bc2283f67e8fbec74253ec2d94925de92ed72c0393f1206bf326b7b7b69", size = 452372, upload-time = "2026-05-18T04:31:00.836Z" }, + { url = "https://files.pythonhosted.org/packages/91/15/2fe99557e72f85627c6a8eed50d889e8d101623e060a22ad75b875cb932d/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5327989a465505f05cfe06f04fa9d0c2fd5432bb243e10e6f012b1bdca3c8579", size = 459596, upload-time = "2026-05-18T04:31:34.96Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/d4acfa0023367428ed48351b3b9b267893037b6cadae55620c61c24bcfd4/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ecb47f183a8025b2aa18b546725c3657e542112ae9c0613a2af79b4fa8d04ad7", size = 490869, upload-time = "2026-05-18T04:31:59.923Z" }, + { url = "https://files.pythonhosted.org/packages/a4/5f/3164cbdce06c9fb95c4f7b9e2f9760b5e2797af43a9ecc317ef42a23a278/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8520a4ab0e37f770afc34459c4f8f7019e153f9124dc101c15538365875d1ab2", size = 571641, upload-time = "2026-05-18T04:32:00.948Z" }, + { url = "https://files.pythonhosted.org/packages/41/e6/85d3731c55e65cd7690f3f803d24c139588aaf863e4bf2148fe7a7fa1a19/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:71cd71740ed2c15211ebb237ced4e39a1cdf6f80566e5fe95428da1626f4fde6", size = 464444, upload-time = "2026-05-18T04:30:34.298Z" }, + { url = "https://files.pythonhosted.org/packages/f4/7d/562641012b8b09872742c3b8adf9629ec479fd78f8d68ae4a0c13da8add6/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f88af53d6ddaf72179ef613ddc905e6f4785f712b49b80b3bef9f3525e6194b4", size = 453593, upload-time = "2026-05-18T04:31:23.464Z" }, + { url = "https://files.pythonhosted.org/packages/56/fe/cb8ef3d6f929d14158fdaaad9925985b7310abc9384dcd4d82dd0016fb59/watchfiles-1.2.0-cp315-cp315-manylinux_2_31_riscv64.whl", hash = "sha256:cee9d5efd929efdac5f7e58f72b3376f676b64050a91c5b99a7094c5b2317488", size = 465096, upload-time = "2026-05-18T04:31:30.384Z" }, + { url = "https://files.pythonhosted.org/packages/25/91/80908e835e100527a9267147b08c0eee1fa6ab0ffec15edc04d1d44885f7/watchfiles-1.2.0-cp315-cp315-musllinux_1_1_aarch64.whl", hash = "sha256:b718bf356bbc15e559bd8ef41782b573b8ae0e3f177ab244b440568d7ea02cfb", size = 630638, upload-time = "2026-05-18T04:30:49.89Z" }, + { url = "https://files.pythonhosted.org/packages/46/4b/95ab2f256bb4af3cb2eb23b9317bda984ee6e0f11733a5c004a6c95b06e3/watchfiles-1.2.0-cp315-cp315-musllinux_1_1_x86_64.whl", hash = "sha256:922c0e019fe68b3ae392965a766b02a71ba1168c932cebc3733cd52c5fe5b377", size = 657684, upload-time = "2026-05-18T04:31:32.027Z" }, ] [[package]] @@ -1759,32 +1760,32 @@ version = "16.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/04/24/4b2031d72e840ce4c1ccb255f693b15c334757fc50023e4db9537080b8c4/websockets-16.0.tar.gz", hash = "sha256:5f6261a5e56e8d5c42a4497b364ea24d94d9563e8fbd44e78ac40879c60179b5", size = 179346, upload-time = "2026-01-10T09:23:47.181Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/cc/9c/baa8456050d1c1b08dd0ec7346026668cbc6f145ab4e314d707bb845bf0d/websockets-16.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:878b336ac47938b474c8f982ac2f7266a540adc3fa4ad74ae96fea9823a02cc9", size = 177364, upload-time = "2026-01-10T09:22:59.333Z" }, - { url = "https://files.pythonhosted.org/packages/7e/0c/8811fc53e9bcff68fe7de2bcbe75116a8d959ac699a3200f4847a8925210/websockets-16.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:52a0fec0e6c8d9a784c2c78276a48a2bdf099e4ccc2a4cad53b27718dbfd0230", size = 175039, upload-time = "2026-01-10T09:23:01.171Z" }, - { url = "https://files.pythonhosted.org/packages/aa/82/39a5f910cb99ec0b59e482971238c845af9220d3ab9fa76dd9162cda9d62/websockets-16.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e6578ed5b6981005df1860a56e3617f14a6c307e6a71b4fff8c48fdc50f3ed2c", size = 175323, upload-time = "2026-01-10T09:23:02.341Z" }, - { url = "https://files.pythonhosted.org/packages/bd/28/0a25ee5342eb5d5f297d992a77e56892ecb65e7854c7898fb7d35e9b33bd/websockets-16.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:95724e638f0f9c350bb1c2b0a7ad0e83d9cc0c9259f3ea94e40d7b02a2179ae5", size = 184975, upload-time = "2026-01-10T09:23:03.756Z" }, - { url = "https://files.pythonhosted.org/packages/f9/66/27ea52741752f5107c2e41fda05e8395a682a1e11c4e592a809a90c6a506/websockets-16.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c0204dc62a89dc9d50d682412c10b3542d748260d743500a85c13cd1ee4bde82", size = 186203, upload-time = "2026-01-10T09:23:05.01Z" }, - { url = "https://files.pythonhosted.org/packages/37/e5/8e32857371406a757816a2b471939d51c463509be73fa538216ea52b792a/websockets-16.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:52ac480f44d32970d66763115edea932f1c5b1312de36df06d6b219f6741eed8", size = 185653, upload-time = "2026-01-10T09:23:06.301Z" }, - { url = "https://files.pythonhosted.org/packages/9b/67/f926bac29882894669368dc73f4da900fcdf47955d0a0185d60103df5737/websockets-16.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6e5a82b677f8f6f59e8dfc34ec06ca6b5b48bc4fcda346acd093694cc2c24d8f", size = 184920, upload-time = "2026-01-10T09:23:07.492Z" }, - { url = "https://files.pythonhosted.org/packages/3c/a1/3d6ccdcd125b0a42a311bcd15a7f705d688f73b2a22d8cf1c0875d35d34a/websockets-16.0-cp313-cp313-win32.whl", hash = "sha256:abf050a199613f64c886ea10f38b47770a65154dc37181bfaff70c160f45315a", size = 178255, upload-time = "2026-01-10T09:23:09.245Z" }, - { url = "https://files.pythonhosted.org/packages/6b/ae/90366304d7c2ce80f9b826096a9e9048b4bb760e44d3b873bb272cba696b/websockets-16.0-cp313-cp313-win_amd64.whl", hash = "sha256:3425ac5cf448801335d6fdc7ae1eb22072055417a96cc6b31b3861f455fbc156", size = 178689, upload-time = "2026-01-10T09:23:10.483Z" }, - { url = "https://files.pythonhosted.org/packages/f3/1d/e88022630271f5bd349ed82417136281931e558d628dd52c4d8621b4a0b2/websockets-16.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8cc451a50f2aee53042ac52d2d053d08bf89bcb31ae799cb4487587661c038a0", size = 177406, upload-time = "2026-01-10T09:23:12.178Z" }, - { url = "https://files.pythonhosted.org/packages/f2/78/e63be1bf0724eeb4616efb1ae1c9044f7c3953b7957799abb5915bffd38e/websockets-16.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:daa3b6ff70a9241cf6c7fc9e949d41232d9d7d26fd3522b1ad2b4d62487e9904", size = 175085, upload-time = "2026-01-10T09:23:13.511Z" }, - { url = "https://files.pythonhosted.org/packages/bb/f4/d3c9220d818ee955ae390cf319a7c7a467beceb24f05ee7aaaa2414345ba/websockets-16.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:fd3cb4adb94a2a6e2b7c0d8d05cb94e6f1c81a0cf9dc2694fb65c7e8d94c42e4", size = 175328, upload-time = "2026-01-10T09:23:14.727Z" }, - { url = "https://files.pythonhosted.org/packages/63/bc/d3e208028de777087e6fb2b122051a6ff7bbcca0d6df9d9c2bf1dd869ae9/websockets-16.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:781caf5e8eee67f663126490c2f96f40906594cb86b408a703630f95550a8c3e", size = 185044, upload-time = "2026-01-10T09:23:15.939Z" }, - { url = "https://files.pythonhosted.org/packages/ad/6e/9a0927ac24bd33a0a9af834d89e0abc7cfd8e13bed17a86407a66773cc0e/websockets-16.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:caab51a72c51973ca21fa8a18bd8165e1a0183f1ac7066a182ff27107b71e1a4", size = 186279, upload-time = "2026-01-10T09:23:17.148Z" }, - { url = "https://files.pythonhosted.org/packages/b9/ca/bf1c68440d7a868180e11be653c85959502efd3a709323230314fda6e0b3/websockets-16.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:19c4dc84098e523fd63711e563077d39e90ec6702aff4b5d9e344a60cb3c0cb1", size = 185711, upload-time = "2026-01-10T09:23:18.372Z" }, - { url = "https://files.pythonhosted.org/packages/c4/f8/fdc34643a989561f217bb477cbc47a3a07212cbda91c0e4389c43c296ebf/websockets-16.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a5e18a238a2b2249c9a9235466b90e96ae4795672598a58772dd806edc7ac6d3", size = 184982, upload-time = "2026-01-10T09:23:19.652Z" }, - { url = "https://files.pythonhosted.org/packages/dd/d1/574fa27e233764dbac9c52730d63fcf2823b16f0856b3329fc6268d6ae4f/websockets-16.0-cp314-cp314-win32.whl", hash = "sha256:a069d734c4a043182729edd3e9f247c3b2a4035415a9172fd0f1b71658a320a8", size = 177915, upload-time = "2026-01-10T09:23:21.458Z" }, - { url = "https://files.pythonhosted.org/packages/8a/f1/ae6b937bf3126b5134ce1f482365fde31a357c784ac51852978768b5eff4/websockets-16.0-cp314-cp314-win_amd64.whl", hash = "sha256:c0ee0e63f23914732c6d7e0cce24915c48f3f1512ec1d079ed01fc629dab269d", size = 178381, upload-time = "2026-01-10T09:23:22.715Z" }, - { url = "https://files.pythonhosted.org/packages/06/9b/f791d1db48403e1f0a27577a6beb37afae94254a8c6f08be4a23e4930bc0/websockets-16.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:a35539cacc3febb22b8f4d4a99cc79b104226a756aa7400adc722e83b0d03244", size = 177737, upload-time = "2026-01-10T09:23:24.523Z" }, - { url = "https://files.pythonhosted.org/packages/bd/40/53ad02341fa33b3ce489023f635367a4ac98b73570102ad2cdd770dacc9a/websockets-16.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:b784ca5de850f4ce93ec85d3269d24d4c82f22b7212023c974c401d4980ebc5e", size = 175268, upload-time = "2026-01-10T09:23:25.781Z" }, - { url = "https://files.pythonhosted.org/packages/74/9b/6158d4e459b984f949dcbbb0c5d270154c7618e11c01029b9bbd1bb4c4f9/websockets-16.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:569d01a4e7fba956c5ae4fc988f0d4e187900f5497ce46339c996dbf24f17641", size = 175486, upload-time = "2026-01-10T09:23:27.033Z" }, - { url = "https://files.pythonhosted.org/packages/e5/2d/7583b30208b639c8090206f95073646c2c9ffd66f44df967981a64f849ad/websockets-16.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:50f23cdd8343b984957e4077839841146f67a3d31ab0d00e6b824e74c5b2f6e8", size = 185331, upload-time = "2026-01-10T09:23:28.259Z" }, - { url = "https://files.pythonhosted.org/packages/45/b0/cce3784eb519b7b5ad680d14b9673a31ab8dcb7aad8b64d81709d2430aa8/websockets-16.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:152284a83a00c59b759697b7f9e9cddf4e3c7861dd0d964b472b70f78f89e80e", size = 186501, upload-time = "2026-01-10T09:23:29.449Z" }, - { url = "https://files.pythonhosted.org/packages/19/60/b8ebe4c7e89fb5f6cdf080623c9d92789a53636950f7abacfc33fe2b3135/websockets-16.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bc59589ab64b0022385f429b94697348a6a234e8ce22544e3681b2e9331b5944", size = 186062, upload-time = "2026-01-10T09:23:31.368Z" }, - { url = "https://files.pythonhosted.org/packages/88/a8/a080593f89b0138b6cba1b28f8df5673b5506f72879322288b031337c0b8/websockets-16.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:32da954ffa2814258030e5a57bc73a3635463238e797c7375dc8091327434206", size = 185356, upload-time = "2026-01-10T09:23:32.627Z" }, - { url = "https://files.pythonhosted.org/packages/c2/b6/b9afed2afadddaf5ebb2afa801abf4b0868f42f8539bfe4b071b5266c9fe/websockets-16.0-cp314-cp314t-win32.whl", hash = "sha256:5a4b4cc550cb665dd8a47f868c8d04c8230f857363ad3c9caf7a0c3bf8c61ca6", size = 178085, upload-time = "2026-01-10T09:23:33.816Z" }, - { url = "https://files.pythonhosted.org/packages/9f/3e/28135a24e384493fa804216b79a6a6759a38cc4ff59118787b9fb693df93/websockets-16.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b14dc141ed6d2dde437cddb216004bcac6a1df0935d79656387bd41632ba0bbd", size = 178531, upload-time = "2026-01-10T09:23:35.016Z" }, - { url = "https://files.pythonhosted.org/packages/6f/28/258ebab549c2bf3e64d2b0217b973467394a9cea8c42f70418ca2c5d0d2e/websockets-16.0-py3-none-any.whl", hash = "sha256:1637db62fad1dc833276dded54215f2c7fa46912301a24bd94d45d46a011ceec", size = 171598, upload-time = "2026-01-10T09:23:45.395Z" }, + { url = "https://files.pythonhosted.org/packages/cc/9c/baa8456050d1c1b08dd0ec7346026668cbc6f145ab4e314d707bb845bf0d/websockets-16.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:878b336ac47938b474c8f982ac2f7266a540adc3fa4ad74ae96fea9823a02cc9", size = 177364, upload-time = "2026-01-10T09:22:59.333Z" }, + { url = "https://files.pythonhosted.org/packages/7e/0c/8811fc53e9bcff68fe7de2bcbe75116a8d959ac699a3200f4847a8925210/websockets-16.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:52a0fec0e6c8d9a784c2c78276a48a2bdf099e4ccc2a4cad53b27718dbfd0230", size = 175039, upload-time = "2026-01-10T09:23:01.171Z" }, + { url = "https://files.pythonhosted.org/packages/aa/82/39a5f910cb99ec0b59e482971238c845af9220d3ab9fa76dd9162cda9d62/websockets-16.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e6578ed5b6981005df1860a56e3617f14a6c307e6a71b4fff8c48fdc50f3ed2c", size = 175323, upload-time = "2026-01-10T09:23:02.341Z" }, + { url = "https://files.pythonhosted.org/packages/bd/28/0a25ee5342eb5d5f297d992a77e56892ecb65e7854c7898fb7d35e9b33bd/websockets-16.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:95724e638f0f9c350bb1c2b0a7ad0e83d9cc0c9259f3ea94e40d7b02a2179ae5", size = 184975, upload-time = "2026-01-10T09:23:03.756Z" }, + { url = "https://files.pythonhosted.org/packages/f9/66/27ea52741752f5107c2e41fda05e8395a682a1e11c4e592a809a90c6a506/websockets-16.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c0204dc62a89dc9d50d682412c10b3542d748260d743500a85c13cd1ee4bde82", size = 186203, upload-time = "2026-01-10T09:23:05.01Z" }, + { url = "https://files.pythonhosted.org/packages/37/e5/8e32857371406a757816a2b471939d51c463509be73fa538216ea52b792a/websockets-16.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:52ac480f44d32970d66763115edea932f1c5b1312de36df06d6b219f6741eed8", size = 185653, upload-time = "2026-01-10T09:23:06.301Z" }, + { url = "https://files.pythonhosted.org/packages/9b/67/f926bac29882894669368dc73f4da900fcdf47955d0a0185d60103df5737/websockets-16.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6e5a82b677f8f6f59e8dfc34ec06ca6b5b48bc4fcda346acd093694cc2c24d8f", size = 184920, upload-time = "2026-01-10T09:23:07.492Z" }, + { url = "https://files.pythonhosted.org/packages/3c/a1/3d6ccdcd125b0a42a311bcd15a7f705d688f73b2a22d8cf1c0875d35d34a/websockets-16.0-cp313-cp313-win32.whl", hash = "sha256:abf050a199613f64c886ea10f38b47770a65154dc37181bfaff70c160f45315a", size = 178255, upload-time = "2026-01-10T09:23:09.245Z" }, + { url = "https://files.pythonhosted.org/packages/6b/ae/90366304d7c2ce80f9b826096a9e9048b4bb760e44d3b873bb272cba696b/websockets-16.0-cp313-cp313-win_amd64.whl", hash = "sha256:3425ac5cf448801335d6fdc7ae1eb22072055417a96cc6b31b3861f455fbc156", size = 178689, upload-time = "2026-01-10T09:23:10.483Z" }, + { url = "https://files.pythonhosted.org/packages/f3/1d/e88022630271f5bd349ed82417136281931e558d628dd52c4d8621b4a0b2/websockets-16.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8cc451a50f2aee53042ac52d2d053d08bf89bcb31ae799cb4487587661c038a0", size = 177406, upload-time = "2026-01-10T09:23:12.178Z" }, + { url = "https://files.pythonhosted.org/packages/f2/78/e63be1bf0724eeb4616efb1ae1c9044f7c3953b7957799abb5915bffd38e/websockets-16.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:daa3b6ff70a9241cf6c7fc9e949d41232d9d7d26fd3522b1ad2b4d62487e9904", size = 175085, upload-time = "2026-01-10T09:23:13.511Z" }, + { url = "https://files.pythonhosted.org/packages/bb/f4/d3c9220d818ee955ae390cf319a7c7a467beceb24f05ee7aaaa2414345ba/websockets-16.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:fd3cb4adb94a2a6e2b7c0d8d05cb94e6f1c81a0cf9dc2694fb65c7e8d94c42e4", size = 175328, upload-time = "2026-01-10T09:23:14.727Z" }, + { url = "https://files.pythonhosted.org/packages/63/bc/d3e208028de777087e6fb2b122051a6ff7bbcca0d6df9d9c2bf1dd869ae9/websockets-16.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:781caf5e8eee67f663126490c2f96f40906594cb86b408a703630f95550a8c3e", size = 185044, upload-time = "2026-01-10T09:23:15.939Z" }, + { url = "https://files.pythonhosted.org/packages/ad/6e/9a0927ac24bd33a0a9af834d89e0abc7cfd8e13bed17a86407a66773cc0e/websockets-16.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:caab51a72c51973ca21fa8a18bd8165e1a0183f1ac7066a182ff27107b71e1a4", size = 186279, upload-time = "2026-01-10T09:23:17.148Z" }, + { url = "https://files.pythonhosted.org/packages/b9/ca/bf1c68440d7a868180e11be653c85959502efd3a709323230314fda6e0b3/websockets-16.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:19c4dc84098e523fd63711e563077d39e90ec6702aff4b5d9e344a60cb3c0cb1", size = 185711, upload-time = "2026-01-10T09:23:18.372Z" }, + { url = "https://files.pythonhosted.org/packages/c4/f8/fdc34643a989561f217bb477cbc47a3a07212cbda91c0e4389c43c296ebf/websockets-16.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a5e18a238a2b2249c9a9235466b90e96ae4795672598a58772dd806edc7ac6d3", size = 184982, upload-time = "2026-01-10T09:23:19.652Z" }, + { url = "https://files.pythonhosted.org/packages/dd/d1/574fa27e233764dbac9c52730d63fcf2823b16f0856b3329fc6268d6ae4f/websockets-16.0-cp314-cp314-win32.whl", hash = "sha256:a069d734c4a043182729edd3e9f247c3b2a4035415a9172fd0f1b71658a320a8", size = 177915, upload-time = "2026-01-10T09:23:21.458Z" }, + { url = "https://files.pythonhosted.org/packages/8a/f1/ae6b937bf3126b5134ce1f482365fde31a357c784ac51852978768b5eff4/websockets-16.0-cp314-cp314-win_amd64.whl", hash = "sha256:c0ee0e63f23914732c6d7e0cce24915c48f3f1512ec1d079ed01fc629dab269d", size = 178381, upload-time = "2026-01-10T09:23:22.715Z" }, + { url = "https://files.pythonhosted.org/packages/06/9b/f791d1db48403e1f0a27577a6beb37afae94254a8c6f08be4a23e4930bc0/websockets-16.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:a35539cacc3febb22b8f4d4a99cc79b104226a756aa7400adc722e83b0d03244", size = 177737, upload-time = "2026-01-10T09:23:24.523Z" }, + { url = "https://files.pythonhosted.org/packages/bd/40/53ad02341fa33b3ce489023f635367a4ac98b73570102ad2cdd770dacc9a/websockets-16.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:b784ca5de850f4ce93ec85d3269d24d4c82f22b7212023c974c401d4980ebc5e", size = 175268, upload-time = "2026-01-10T09:23:25.781Z" }, + { url = "https://files.pythonhosted.org/packages/74/9b/6158d4e459b984f949dcbbb0c5d270154c7618e11c01029b9bbd1bb4c4f9/websockets-16.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:569d01a4e7fba956c5ae4fc988f0d4e187900f5497ce46339c996dbf24f17641", size = 175486, upload-time = "2026-01-10T09:23:27.033Z" }, + { url = "https://files.pythonhosted.org/packages/e5/2d/7583b30208b639c8090206f95073646c2c9ffd66f44df967981a64f849ad/websockets-16.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:50f23cdd8343b984957e4077839841146f67a3d31ab0d00e6b824e74c5b2f6e8", size = 185331, upload-time = "2026-01-10T09:23:28.259Z" }, + { url = "https://files.pythonhosted.org/packages/45/b0/cce3784eb519b7b5ad680d14b9673a31ab8dcb7aad8b64d81709d2430aa8/websockets-16.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:152284a83a00c59b759697b7f9e9cddf4e3c7861dd0d964b472b70f78f89e80e", size = 186501, upload-time = "2026-01-10T09:23:29.449Z" }, + { url = "https://files.pythonhosted.org/packages/19/60/b8ebe4c7e89fb5f6cdf080623c9d92789a53636950f7abacfc33fe2b3135/websockets-16.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bc59589ab64b0022385f429b94697348a6a234e8ce22544e3681b2e9331b5944", size = 186062, upload-time = "2026-01-10T09:23:31.368Z" }, + { url = "https://files.pythonhosted.org/packages/88/a8/a080593f89b0138b6cba1b28f8df5673b5506f72879322288b031337c0b8/websockets-16.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:32da954ffa2814258030e5a57bc73a3635463238e797c7375dc8091327434206", size = 185356, upload-time = "2026-01-10T09:23:32.627Z" }, + { url = "https://files.pythonhosted.org/packages/c2/b6/b9afed2afadddaf5ebb2afa801abf4b0868f42f8539bfe4b071b5266c9fe/websockets-16.0-cp314-cp314t-win32.whl", hash = "sha256:5a4b4cc550cb665dd8a47f868c8d04c8230f857363ad3c9caf7a0c3bf8c61ca6", size = 178085, upload-time = "2026-01-10T09:23:33.816Z" }, + { url = "https://files.pythonhosted.org/packages/9f/3e/28135a24e384493fa804216b79a6a6759a38cc4ff59118787b9fb693df93/websockets-16.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b14dc141ed6d2dde437cddb216004bcac6a1df0935d79656387bd41632ba0bbd", size = 178531, upload-time = "2026-01-10T09:23:35.016Z" }, + { url = "https://files.pythonhosted.org/packages/6f/28/258ebab549c2bf3e64d2b0217b973467394a9cea8c42f70418ca2c5d0d2e/websockets-16.0-py3-none-any.whl", hash = "sha256:1637db62fad1dc833276dded54215f2c7fa46912301a24bd94d45d46a011ceec", size = 171598, upload-time = "2026-01-10T09:23:45.395Z" }, ] From 2334a4b6e32599c05988d9bccf4a70b7a3074b13 Mon Sep 17 00:00:00 2001 From: Andrey Avtomonov Date: Tue, 2 Jun 2026 20:03:27 +0200 Subject: [PATCH 07/25] Emit ingest_completed once per target on every ingest path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit emitIngestCompleted was called only in runKtxPublicIngest's plain/json loop, so the foreground 'ktx ingest' view and all of 'ktx setup' — which delegate to runContextBuild -> executePublicIngestTarget — never emitted the event. That left ingest_completed near-useless for measuring ingestion. Move the emit into executePublicIngestTarget, the single per-target chokepoint every entrypoint funnels through: a thin wrapper now captures timing, runs the existing steps (extracted to runIngestTargetSteps), and emits exactly once. The telemetry echo targets deps.runtimeIo (the real user stream) so a capture buffer used for step output doesn't swallow it. Thread project through the context-build call site. No schema/field changes, so Node<->Python telemetry parity is unaffected. Add tests: the shared chokepoint emits exactly one ingest_completed for any caller, and a multi-target run emits one per target with no double-emit. --- packages/cli/src/context-build-view.ts | 2 +- packages/cli/src/public-ingest.ts | 32 ++++++++--- packages/cli/test/context-build-view.test.ts | 2 + packages/cli/test/public-ingest.test.ts | 58 ++++++++++++++++++++ 4 files changed, 86 insertions(+), 8 deletions(-) diff --git a/packages/cli/src/context-build-view.ts b/packages/cli/src/context-build-view.ts index f088097d..0ddd4922 100644 --- a/packages/cli/src/context-build-view.ts +++ b/packages/cli/src/context-build-view.ts @@ -997,7 +997,7 @@ export async function runContextBuild( let result: KtxPublicIngestTargetResult | null = null; let thrownError: unknown = null; try { - result = await execTarget(targetState.target, runArgs, capture.io, progressDeps); + result = await execTarget(targetState.target, runArgs, capture.io, progressDeps, project); } catch (error) { thrownError = error; } diff --git a/packages/cli/src/public-ingest.ts b/packages/cli/src/public-ingest.ts index 216d1d7b..7fc43ac4 100644 --- a/packages/cli/src/public-ingest.ts +++ b/packages/cli/src/public-ingest.ts @@ -862,11 +862,34 @@ function capturedFailureMessage(output: string): string | undefined { return [firstLine, ...followupLines].join('\n'); } +/** + * Run one ingest target through its scan/ingest steps. The single per-target + * chokepoint reached by every entrypoint — standalone `ktx ingest` (plain/json + * and foreground) and `ktx setup` (via `runContextBuild`). The exported + * `executePublicIngestTarget` wraps this and emits the `ingest_completed` + * telemetry event exactly once, so every path is counted. + */ export async function executePublicIngestTarget( target: KtxPublicIngestPlanTarget, args: Extract, io: KtxCliIo, deps: KtxPublicIngestDeps, + project: KtxPublicIngestProject, +): Promise { + const startedAt = performance.now(); + const result = await runIngestTargetSteps(target, args, io, deps); + // `io` may be a capture buffer for the scan/ingest step output; the telemetry + // debug echo belongs on the real user-facing stream, which callers expose as + // `deps.runtimeIo` (falling back to `io` when the step io is already real). + await emitIngestCompleted({ args, project, target, result, startedAt, io: deps.runtimeIo ?? io }); + return result; +} + +async function runIngestTargetSteps( + target: KtxPublicIngestPlanTarget, + args: Extract, + io: KtxCliIo, + deps: KtxPublicIngestDeps, ): Promise { if (target.preflightFailure) { if (target.operation === 'database-ingest') { @@ -1086,11 +1109,8 @@ export async function runKtxPublicIngest( } for (const [index, target] of plan.targets.entries()) { - const startedAt = performance.now(); if (args.json) { - const result = await executePublicIngestTarget(target, args, io, deps); - results.push(result); - await emitIngestCompleted({ args, project, target, result, startedAt, io }); + results.push(await executePublicIngestTarget(target, args, io, deps, project)); continue; } @@ -1108,9 +1128,7 @@ export async function runKtxPublicIngest( onPhaseEnd: progress.onPhaseEnd, runtimeIo: deps.runtimeIo ?? io, }; - const result = await executePublicIngestTarget(target, args, capture, targetDeps); - results.push(result); - await emitIngestCompleted({ args, project, target, result, startedAt, io }); + results.push(await executePublicIngestTarget(target, args, capture, targetDeps, project)); } if (args.json) { diff --git a/packages/cli/test/context-build-view.test.ts b/packages/cli/test/context-build-view.test.ts index 40e33606..d8692eb5 100644 --- a/packages/cli/test/context-build-view.test.ts +++ b/packages/cli/test/context-build-view.test.ts @@ -984,6 +984,7 @@ describe('runContextBuild', () => { scanProgress: expect.anything(), ingestProgress: expect.any(Function), }), + project, ); }); @@ -1015,6 +1016,7 @@ describe('runContextBuild', () => { expect.objectContaining({ runtimeIo: io.io, }), + project, ); }); diff --git a/packages/cli/test/public-ingest.test.ts b/packages/cli/test/public-ingest.test.ts index 549756eb..2c27593e 100644 --- a/packages/cli/test/public-ingest.test.ts +++ b/packages/cli/test/public-ingest.test.ts @@ -6,11 +6,17 @@ import { initKtxProject } from '../src/context/project/project.js'; import { afterEach, describe, expect, it, vi } from 'vitest'; import { buildPublicIngestPlan, + executePublicIngestTarget, type KtxPublicIngestDeps, type KtxPublicIngestProject, publicProgressMessage, runKtxPublicIngest, } from '../src/public-ingest.js'; + +/** Count non-overlapping occurrences of `needle` in `haystack`. */ +function occurrences(haystack: string, needle: string): number { + return haystack.split(needle).length - 1; +} import type { ManagedPythonCommandRuntime } from '../src/managed-python-command.js'; function makeIo(options: { isTTY?: boolean; interactive?: boolean } = {}) { @@ -457,6 +463,58 @@ describe('runKtxPublicIngest', () => { } }); + it('emits exactly one ingest_completed from the shared executePublicIngestTarget chokepoint', async () => { + // executePublicIngestTarget is the single per-target path reached by every + // entrypoint (plain/json ingest, foreground ingest via runContextBuild, and + // setup). Emitting here is what makes ingest_completed fire on every path. + vi.stubEnv('KTX_TELEMETRY_DEBUG', '1'); + vi.stubEnv('CI', ''); + const io = makeIo({ isTTY: true }); + const project = deepReadyProject({ warehouse: { driver: 'postgres' } }); + const [target] = buildPublicIngestPlan(project, { + projectDir: '/tmp/project', + targetConnectionId: 'warehouse', + all: false, + }).targets; + + const result = await executePublicIngestTarget( + target, + { command: 'run', projectDir: '/tmp/project', targetConnectionId: 'warehouse', all: false, json: false, inputMode: 'disabled' }, + io.io, + { runScan: vi.fn(async () => 0) }, + project, + ); + + expect(result.steps.some((step) => step.status === 'failed')).toBe(false); + expect(occurrences(io.stderr(), '"event":"ingest_completed"')).toBe(1); + expect(io.stderr()).toContain('"outcome":"ok"'); + }); + + it('emits one ingest_completed per target and never double-emits across a multi-target run', async () => { + vi.stubEnv('KTX_TELEMETRY_DEBUG', '1'); + vi.stubEnv('CI', ''); + const projectDir = await mkdtemp(join(tmpdir(), 'ktx-public-ingest-no-double-')); + try { + await initKtxProject({ projectDir }); + const io = makeIo({ isTTY: true }); + const project = deepReadyProject({ + first: { driver: 'sqlite', path: join(projectDir, 'first.sqlite') }, + second: { driver: 'sqlite', path: join(projectDir, 'second.sqlite') }, + }); + + const code = await runKtxPublicIngest( + { command: 'run', projectDir, all: true, json: false, inputMode: 'disabled' }, + io.io, + { loadProject: vi.fn(async () => project), runScan: vi.fn(async () => 0) }, + ); + + expect(code).toBe(0); + expect(occurrences(io.stderr(), '"event":"ingest_completed"')).toBe(2); + } finally { + await rm(projectDir, { recursive: true, force: true }); + } + }); + it('runs query history after schema ingest with current-run window override', async () => { const io = makeIo(); const runtimeIo = makeIo({ isTTY: true }); From cb6a67c2d7df2b6272cab1235812d15466feadb5 Mon Sep 17 00:00:00 2001 From: Andrey Avtomonov Date: Tue, 2 Jun 2026 23:19:37 +0200 Subject: [PATCH 08/25] Make telemetry reliable across interrupts and headless installs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three reliability gaps surfaced while auditing why PostHog numbers were untrustworthy: 1. Interrupted commands lost their events. capture() is fire-and-forget and the only flush guarantee lived in a finally block, which SIGINT/SIGTERM skip — so Ctrl-C'ing a long ingest or an MCP client killing 'ktx mcp stdio' dropped the command event and any queued events. Add SIGINT/SIGTERM handlers (real-process entry only; never under test/programmatic io) that mark the active command span aborted, emit it, drain the emitter, then exit. Idempotent with the normal finally path via the single-consume command span. 2. Headless-first installs were invisible. loadTelemetryIdentity refused to mint an installId unless stdout was a TTY, so a machine whose first run was an IDE-launched MCP server or a script emitted nothing, ever. Mint on first run regardless of surface (still honoring CI/DO_NOT_TRACK/KTX_TELEMETRY_DISABLED), writing the one-time notice to stderr — safe under the MCP stdio protocol, which reserves stdout. Drop the now-unused stdoutIsTTY option. 3. No guard against silent emit regressions (the 0.7.0 scan_completed blackout). Add tests: the shared executePublicIngestTarget chokepoint emits exactly one ingest_completed on success and on the preflight-failure branch, and a database target invokes the scan that emits scan_completed; plus coverage for the aborted-flush helper. Identity is unchanged otherwise: every event still attributes to the installId in ~/.ktx/telemetry.json. No event/field changes, so Node<->Python schema parity is untouched. Docs updated to reflect first-run-on-any-surface activation. --- .../content/docs/community/telemetry.mdx | 9 +- packages/cli/src/cli-runtime.ts | 53 ++++++++++- packages/cli/src/telemetry/identity.ts | 14 ++- packages/cli/src/telemetry/index.ts | 19 +++- packages/cli/test/public-ingest.test.ts | 36 ++++++- packages/cli/test/telemetry/identity.test.ts | 93 ++++++++++--------- packages/cli/test/telemetry/index.test.ts | 61 +++++++++++- 7 files changed, 219 insertions(+), 66 deletions(-) diff --git a/docs-site/content/docs/community/telemetry.mdx b/docs-site/content/docs/community/telemetry.mdx index 9618af8c..a3a10564 100644 --- a/docs-site/content/docs/community/telemetry.mdx +++ b/docs-site/content/docs/community/telemetry.mdx @@ -6,11 +6,10 @@ description: Understand what usage telemetry ktx collects and how to opt out. **ktx** collects aggregated usage telemetry so maintainers can see which commands work, where setup fails, and which parts of the data-agent workflow need improvement. Telemetry is opt-out: it turns on the first time you -run **ktx** in an interactive terminal, which prints a one-time notice. From -then on the same install also reports background activity that has no terminal -of its own, such as the local MCP server your agent calls. It stays disabled in -CI, whenever an opt-out is set, and until that first interactive run has shown -the notice. +run **ktx** in any way — an interactive command, a script, or an +agent-launched MCP server — and prints a one-time notice (to the terminal when +there is one, otherwise to standard error). It stays disabled in CI and whenever +an opt-out is set. ## Opt out diff --git a/packages/cli/src/cli-runtime.ts b/packages/cli/src/cli-runtime.ts index 68089720..7043143b 100644 --- a/packages/cli/src/cli-runtime.ts +++ b/packages/cli/src/cli-runtime.ts @@ -89,6 +89,46 @@ export async function runInitForCommander( return await runInit(args, io); } +function signalExitCode(signal: NodeJS.Signals): number { + // 128 + signal number: SIGINT (2) -> 130, SIGTERM (15) -> 143. + return signal === 'SIGTERM' ? 143 : 130; +} + +/** + * Flush telemetry on interrupt for the real CLI process. `capture()` is + * fire-and-forget and the only flush guarantee lives in a `finally` a signal + * skips, so Ctrl-C / `kill` of a long-running command (ingest, `mcp stdio`) + * would otherwise drop its `command` event and queued events. Installed only + * when driving the actual process; programmatic/test callers pass their own + * `io` and never reach here. Returns a disposer that removes the listeners. + */ +function installTelemetrySignalFlush(io: KtxCliIo, info: KtxCliPackageInfo): () => void { + let handling = false; + const handle = (signal: NodeJS.Signals): void => { + if (handling) { + process.exit(signalExitCode(signal)); + } + handling = true; + void (async () => { + try { + const { emitAbortedCommandAndShutdown } = await import('./telemetry/index.js'); + await emitAbortedCommandAndShutdown({ packageInfo: info, io }); + } catch { + // Best-effort: never let a telemetry hiccup block the interrupt exit. + } + process.exit(signalExitCode(signal)); + })(); + }; + const onSigint = (): void => handle('SIGINT'); + const onSigterm = (): void => handle('SIGTERM'); + process.on('SIGINT', onSigint); + process.on('SIGTERM', onSigterm); + return () => { + process.off('SIGINT', onSigint); + process.off('SIGTERM', onSigterm); + }; +} + export async function runKtxCli( argv = process.argv.slice(2), io: KtxCliIo = process, @@ -98,7 +138,14 @@ export async function runKtxCli( profileMark('runtime:runKtxCli'); const { runCommanderKtxCli } = await profileSpan('import ./cli-program.js', () => import('./cli-program.js')); - return await runCommanderKtxCli(argv, io, deps, info, { - runInit: runInitForCommander, - }); + // Real-process entry only: flush telemetry if interrupted. Test/programmatic + // callers pass their own `io`, so they never install process-level handlers. + const removeSignalFlush = (io as unknown) === process ? installTelemetrySignalFlush(io, info) : undefined; + try { + return await runCommanderKtxCli(argv, io, deps, info, { + runInit: runInitForCommander, + }); + } finally { + removeSignalFlush?.(); + } } diff --git a/packages/cli/src/telemetry/identity.ts b/packages/cli/src/telemetry/identity.ts index d699ea1f..ee5f7a39 100644 --- a/packages/cli/src/telemetry/identity.ts +++ b/packages/cli/src/telemetry/identity.ts @@ -37,7 +37,6 @@ function styleNotice(notice: string, env: TelemetryIdentityEnv): string { export interface LoadTelemetryIdentityOptions { homeDir?: string; env?: TelemetryIdentityEnv; - stdoutIsTTY: boolean; stderr: { write(chunk: string): void }; now?: () => Date; } @@ -94,13 +93,12 @@ export async function loadTelemetryIdentity(options: LoadTelemetryIdentityOption }; } - // No identity yet. Minting one means showing the one-time opt-out notice, so - // first-run creation requires an interactive surface; a headless first run - // stays disabled and defers enablement until the next interactive run. - if (options.stdoutIsTTY !== true) { - return { enabled: false, createdFile: false, noticeShown: false, path }; - } - + // No identity yet → mint one regardless of surface. Telemetry is opt-out, so + // a fresh install is counted even when its first run is headless (an + // IDE-launched `ktx mcp stdio`, a scripted invocation); otherwise those + // installs would be permanently invisible. Opt-out env vars are honored + // above. The one-time notice is written to stderr — safe even under MCP + // stdio, which reserves stdout for its JSON-RPC protocol. const timestamp = (options.now ?? (() => new Date()))().toISOString(); const next = { installId: randomUUID(), diff --git a/packages/cli/src/telemetry/index.ts b/packages/cli/src/telemetry/index.ts index c5b9b729..b02e0224 100644 --- a/packages/cli/src/telemetry/index.ts +++ b/packages/cli/src/telemetry/index.ts @@ -22,7 +22,6 @@ export type { CommandOutcome, CompletedCommandSpan }; export async function showTelemetryNoticeIfNeeded(io: KtxCliIo, packageInfo: KtxCliPackageInfo): Promise { const identity = await loadTelemetryIdentity({ - stdoutIsTTY: io.stdout.isTTY === true, stderr: io.stderr, env: process.env, }); @@ -81,7 +80,6 @@ export async function emitTelemetryEvent(input: }): Promise { const debug = telemetryDebugEnabled(); const identity = await loadTelemetryIdentity({ - stdoutIsTTY: input.io.stdout.isTTY === true, stderr: input.io.stderr, env: process.env, }); @@ -154,3 +152,20 @@ export async function emitCompletedCommand(input: { packageInfo: input.packageInfo, }); } + +/** + * Flush telemetry when the process is interrupted (Ctrl-C / kill). The normal + * `command` emit + flush lives in a `finally` that a signal skips, so without + * this an interrupted long-running command (ingest, `mcp stdio`) loses its + * `command` event and any queued events. Marks the active command span as + * `aborted`, emits it, and drains the emitter. Best-effort and idempotent: if + * the span was already completed (normal exit racing a signal) the emit no-ops. + */ +export async function emitAbortedCommandAndShutdown(input: { + packageInfo: KtxCliPackageInfo; + io: KtxCliIo; +}): Promise { + const completed = completeCommandSpan({ completedAt: performance.now(), outcome: 'aborted' }); + await emitCompletedCommand({ completed, packageInfo: input.packageInfo, io: input.io }); + await shutdownTelemetryEmitter(); +} diff --git a/packages/cli/test/public-ingest.test.ts b/packages/cli/test/public-ingest.test.ts index 2c27593e..1a8b457e 100644 --- a/packages/cli/test/public-ingest.test.ts +++ b/packages/cli/test/public-ingest.test.ts @@ -477,17 +477,51 @@ describe('runKtxPublicIngest', () => { all: false, }).targets; + const runScan = vi.fn(async () => 0); const result = await executePublicIngestTarget( target, { command: 'run', projectDir: '/tmp/project', targetConnectionId: 'warehouse', all: false, json: false, inputMode: 'disabled' }, io.io, - { runScan: vi.fn(async () => 0) }, + { runScan }, project, ); expect(result.steps.some((step) => step.status === 'failed')).toBe(false); expect(occurrences(io.stderr(), '"event":"ingest_completed"')).toBe(1); expect(io.stderr()).toContain('"outcome":"ok"'); + // A database-ingest target must run a scan — runKtxScan is what emits + // scan_completed, so this guards against the 0.7.0-style regression where a + // path stopped triggering the scan and the event silently went to zero. + expect(runScan).toHaveBeenCalledTimes(1); + }); + + it('still emits ingest_completed when a target fails preflight (early-return branch)', async () => { + // The chokepoint must emit on every internal branch, including the early + // preflight-failure return — otherwise failed-setup installs vanish. + vi.stubEnv('KTX_TELEMETRY_DEBUG', '1'); + vi.stubEnv('CI', ''); + const io = makeIo({ isTTY: true }); + // projectWithConnections leaves enrichment unconfigured → preflight failure. + const project = projectWithConnections({ warehouse: { driver: 'postgres' } }); + const [target] = buildPublicIngestPlan(project, { + projectDir: '/tmp/project', + targetConnectionId: 'warehouse', + all: false, + }).targets; + expect(target.preflightFailure).toBeTruthy(); + + const runScan = vi.fn(async () => 0); + await executePublicIngestTarget( + target, + { command: 'run', projectDir: '/tmp/project', targetConnectionId: 'warehouse', all: false, json: false, inputMode: 'disabled' }, + io.io, + { runScan }, + project, + ); + + expect(occurrences(io.stderr(), '"event":"ingest_completed"')).toBe(1); + expect(io.stderr()).toContain('"outcome":"error"'); + expect(runScan).not.toHaveBeenCalled(); }); it('emits one ingest_completed per target and never double-emits across a multi-target run', async () => { diff --git a/packages/cli/test/telemetry/identity.test.ts b/packages/cli/test/telemetry/identity.test.ts index e5b6bddf..6c7e3f46 100644 --- a/packages/cli/test/telemetry/identity.test.ts +++ b/packages/cli/test/telemetry/identity.test.ts @@ -11,18 +11,15 @@ import { type TelemetryIdentityEnv, } from '../../src/telemetry/identity.js'; -function makeIo(stdoutIsTTY = true) { +function makeIo() { let stderr = ''; return { - io: { - stdout: { isTTY: stdoutIsTTY, write: () => {} }, - stderr: { - write: (chunk: string) => { - stderr += chunk; - }, + stderr: { + write: (chunk: string) => { + stderr += chunk; }, }, - stderr: () => stderr, + read: () => stderr, }; } @@ -39,14 +36,13 @@ describe('telemetry identity', () => { await rm(homeDir, { recursive: true, force: true }); }); - it('creates the telemetry file and one-line notice on first interactive enabled load', async () => { - const testIo = makeIo(true); + it('creates the telemetry file and one-line notice on first enabled load', async () => { + const testIo = makeIo(); const identity = await loadTelemetryIdentity({ homeDir, env, - stdoutIsTTY: true, - stderr: testIo.io.stderr, + stderr: testIo.stderr, now: () => new Date('2026-05-22T14:33:02.000Z'), }); @@ -54,7 +50,7 @@ describe('telemetry identity', () => { expect(identity.installId).toMatch(/^[0-9a-f-]{36}$/); expect(identity.createdFile).toBe(true); expect(identity.noticeShown).toBe(true); - expect(testIo.stderr()).toBe(`${TELEMETRY_NOTICE}\n`); + expect(testIo.read()).toBe(`\x1b[2m${TELEMETRY_NOTICE}\x1b[22m\n`); const stored = JSON.parse(await readFile(join(homeDir, '.ktx', 'telemetry.json'), 'utf-8')) as { enabled: boolean; @@ -64,26 +60,46 @@ describe('telemetry identity', () => { expect(stored.noticeShownVersion).toBe(1); }); + it('mints an identity on a headless first run (no TTY required)', async () => { + // A fresh install whose first invocation is headless (IDE-launched + // `ktx mcp stdio`, a scripted run) must still be counted. The one-time + // notice goes to stderr, which is safe even under the MCP stdio protocol. + const testIo = makeIo(); + + const identity = await loadTelemetryIdentity({ + homeDir, + env, + stderr: testIo.stderr, + now: () => new Date('2026-05-22T14:33:02.000Z'), + }); + + expect(identity).toMatchObject({ enabled: true, createdFile: true, noticeShown: true }); + expect(identity.installId).toMatch(/^[0-9a-f-]{36}$/); + expect(testIo.read()).toBe(`\x1b[2m${TELEMETRY_NOTICE}\x1b[22m\n`); + const stored = JSON.parse(await readFile(join(homeDir, '.ktx', 'telemetry.json'), 'utf-8')) as { + enabled: boolean; + }; + expect(stored.enabled).toBe(true); + }); + it('emits the notice without ANSI when NO_COLOR is set', async () => { - const testIo = makeIo(true); + const testIo = makeIo(); await loadTelemetryIdentity({ homeDir, env: { NO_COLOR: '1' }, - stdoutIsTTY: true, - stderr: testIo.io.stderr, + stderr: testIo.stderr, now: () => new Date('2026-05-22T14:33:02.000Z'), }); - expect(testIo.stderr()).toBe(`${TELEMETRY_NOTICE}\n`); + expect(testIo.read()).toBe(`${TELEMETRY_NOTICE}\n`); }); it('does not create a file when env disables telemetry', async () => { const identity = await loadTelemetryIdentity({ homeDir, env: { KTX_TELEMETRY_DISABLED: '1' }, - stdoutIsTTY: true, - stderr: makeIo(true).io.stderr, + stderr: makeIo().stderr, now: () => new Date('2026-05-22T14:33:02.000Z'), }); @@ -91,26 +107,16 @@ describe('telemetry identity', () => { await expect(readFile(join(homeDir, '.ktx', 'telemetry.json'), 'utf-8')).rejects.toThrow(); }); - it('does not create a file for CI or non-TTY command invocations', async () => { + it('does not create a file under CI', async () => { await expect( loadTelemetryIdentity({ homeDir, env: { CI: '1' }, - stdoutIsTTY: true, - stderr: makeIo(true).io.stderr, - now: () => new Date('2026-05-22T14:33:02.000Z'), - }), - ).resolves.toMatchObject({ enabled: false, createdFile: false }); - - await expect( - loadTelemetryIdentity({ - homeDir, - env: {}, - stdoutIsTTY: false, - stderr: makeIo(false).io.stderr, + stderr: makeIo().stderr, now: () => new Date('2026-05-22T14:33:02.000Z'), }), ).resolves.toMatchObject({ enabled: false, createdFile: false }); + await expect(readFile(join(homeDir, '.ktx', 'telemetry.json'), 'utf-8')).rejects.toThrow(); }); it('honors persistent enabled false', async () => { @@ -135,8 +141,7 @@ describe('telemetry identity', () => { loadTelemetryIdentity({ homeDir, env, - stdoutIsTTY: true, - stderr: makeIo(true).io.stderr, + stderr: makeIo().stderr, now: () => new Date('2026-05-22T15:00:00.000Z'), }), ).resolves.toMatchObject({ @@ -146,7 +151,7 @@ describe('telemetry identity', () => { }); }); - it('enables a consented identity without a TTY (MCP servers run headless)', async () => { + it('honors a consented identity without re-showing the notice', async () => { await mkdir(join(homeDir, '.ktx'), { recursive: true }); await writeFile( join(homeDir, '.ktx', 'telemetry.json'), @@ -163,14 +168,13 @@ describe('telemetry identity', () => { ) + '\n', 'utf-8', ); - const testIo = makeIo(false); + const testIo = makeIo(); await expect( loadTelemetryIdentity({ homeDir, env, - stdoutIsTTY: false, - stderr: testIo.io.stderr, + stderr: testIo.stderr, now: () => new Date('2026-05-22T15:00:00.000Z'), }), ).resolves.toMatchObject({ @@ -179,12 +183,11 @@ describe('telemetry identity', () => { createdFile: false, noticeShown: false, }); - // The one-time notice belongs to interactive surfaces only; a headless load - // must never write it (the MCP stdio protocol shares the process streams). - expect(testIo.stderr()).toBe(''); + // An already-consented identity must not re-emit the one-time notice. + expect(testIo.read()).toBe(''); }); - it('keeps opt-outs suppressing a consented identity without a TTY', async () => { + it('keeps opt-outs suppressing a consented identity', async () => { await mkdir(join(homeDir, '.ktx'), { recursive: true }); await writeFile( join(homeDir, '.ktx', 'telemetry.json'), @@ -207,8 +210,7 @@ describe('telemetry identity', () => { loadTelemetryIdentity({ homeDir, env: optOut, - stdoutIsTTY: false, - stderr: makeIo(false).io.stderr, + stderr: makeIo().stderr, now: () => new Date('2026-05-22T15:00:00.000Z'), }), ).resolves.toMatchObject({ enabled: false }); @@ -222,8 +224,7 @@ describe('telemetry identity', () => { const identity = await loadTelemetryIdentity({ homeDir, env, - stdoutIsTTY: true, - stderr: makeIo(true).io.stderr, + stderr: makeIo().stderr, now: () => new Date('2026-05-22T14:33:02.000Z'), }); diff --git a/packages/cli/test/telemetry/index.test.ts b/packages/cli/test/telemetry/index.test.ts index 8d8f932b..7e88410f 100644 --- a/packages/cli/test/telemetry/index.test.ts +++ b/packages/cli/test/telemetry/index.test.ts @@ -4,7 +4,8 @@ import { join } from 'node:path'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import type { KtxCliIo } from '../../src/cli-runtime.js'; -import { emitTelemetryEvent } from '../../src/telemetry/index.js'; +import { beginCommandSpan, emitAbortedCommandAndShutdown, emitTelemetryEvent } from '../../src/telemetry/index.js'; +import { resetCommandSpan } from '../../src/telemetry/command-hook.js'; function makeIo(): { io: KtxCliIo; stderr: () => string } { let stderr = ''; @@ -61,3 +62,61 @@ describe('emitTelemetryEvent', () => { await expect(readFile(join(homeDir, '.ktx', 'telemetry.json'), 'utf-8')).rejects.toThrow(); }); }); + +describe('emitAbortedCommandAndShutdown', () => { + let homeDir: string; + + beforeEach(async () => { + homeDir = await mkdtemp(join(tmpdir(), 'ktx-telemetry-abort-')); + vi.stubEnv('HOME', homeDir); + vi.stubEnv('KTX_TELEMETRY_DEBUG', '1'); + vi.stubEnv('CI', ''); + vi.stubEnv('KTX_TELEMETRY_DISABLED', ''); + vi.stubEnv('DO_NOT_TRACK', ''); + resetCommandSpan(); + }); + + afterEach(async () => { + resetCommandSpan(); + vi.unstubAllEnvs(); + await rm(homeDir, { recursive: true, force: true }); + }); + + it('flushes the active command span as aborted (the signal path)', async () => { + const testIo = makeIo(); + beginCommandSpan({ + commandPath: ['ktx', 'ingest'], + flagsPresent: {}, + hasProject: true, + attachProjectGroup: false, + startedAt: performance.now(), + }); + + await emitAbortedCommandAndShutdown({ + packageInfo: { name: '@kaelio/ktx', version: '0.0.0-test' }, + io: testIo.io, + }); + + expect(testIo.stderr()).toContain('"event":"command"'); + expect(testIo.stderr()).toContain('"outcome":"aborted"'); + expect(testIo.stderr()).toContain('"commandPath":["ktx","ingest"]'); + }); + + it('is idempotent: a second call (or no active span) emits nothing', async () => { + const testIo = makeIo(); + beginCommandSpan({ + commandPath: ['ktx', 'ingest'], + flagsPresent: {}, + hasProject: true, + attachProjectGroup: false, + startedAt: performance.now(), + }); + const pkg = { name: '@kaelio/ktx', version: '0.0.0-test' }; + + await emitAbortedCommandAndShutdown({ packageInfo: pkg, io: testIo.io }); + const secondIo = makeIo(); + await emitAbortedCommandAndShutdown({ packageInfo: pkg, io: secondIo.io }); + + expect(secondIo.stderr()).not.toContain('"event":"command"'); + }); +}); From 45aa95d2cc121267bbbc8c184402a19573956dd4 Mon Sep 17 00:00:00 2001 From: Andrey Avtomonov Date: Wed, 3 Jun 2026 01:00:21 +0200 Subject: [PATCH 09/25] feat(cli): guide next action at end of ktx setup, not reruns (#256) Re-running setup was the dominant action for installs that completed setup but never ingested. Classify completion (incomplete | needs-context | needs-agents | ready) and drive one obvious next action per state: route a config-complete project straight to the build, point unbuilt-context users at `ktx ingest` instead of re-running setup or dropping to a bare shell, and confirm readiness for fully-set-up projects rather than reopening the edit menu. --- .../docs/getting-started/quickstart.mdx | 19 +- packages/cli/src/next-steps.ts | 3 +- packages/cli/src/setup-context.ts | 12 +- packages/cli/src/setup-ready-menu.ts | 53 ++++- packages/cli/src/setup.ts | 31 +-- packages/cli/test/next-steps.test.ts | 5 +- packages/cli/test/setup-ready-menu.test.ts | 106 ++++++++-- packages/cli/test/setup.test.ts | 190 +++++++++++++++++- 8 files changed, 360 insertions(+), 59 deletions(-) diff --git a/docs-site/content/docs/getting-started/quickstart.mdx b/docs-site/content/docs/getting-started/quickstart.mdx index 5129c585..35ec6009 100644 --- a/docs-site/content/docs/getting-started/quickstart.mdx +++ b/docs-site/content/docs/getting-started/quickstart.mdx @@ -215,8 +215,8 @@ The wizard walks you through everything **ktx** needs in one pass: SQLite, PostgreSQL, MySQL, SQL Server, BigQuery, and Snowflake. 5. **Context sources** - optionally adds dbt, MetricFlow, LookML, Looker, Metabase, or Notion. You can skip and add them later. -6. **Build** - runs the first ingest so semantic sources and wiki pages - are ready for agents. +6. **Build** - offers to run the first ingest so semantic sources and wiki + pages are ready for agents. If you skip it, build later with `ktx ingest`. 7. **Agent integration** - installs project-local rules for Claude Code, Codex, Cursor, OpenCode, or universal `.agents`. @@ -247,6 +247,18 @@ progress under `.ktx/setup/` and resumes from the remaining work. > resuming setup, connecting an agent, checking status, or exploring a > pre-built demo project. +When the wizard finishes, it states where you stand and the single next action: + +- **Context built** - **ktx** confirms it is ready for agents and points you to + open your coding agent and ask a data question. +- **Build skipped** - **ktx** tells you setup is complete and that the only step + left is to build context with `ktx ingest`. + +Re-running `ktx setup` on an already-configured project goes straight to the +remaining step - building context or connecting an agent - instead of +re-asking every question. Once everything is ready, it confirms you are set +rather than reopening the configuration menu. + ## Verify When setup finishes, check readiness: @@ -268,6 +280,9 @@ Agent integration ready: yes (codex:project) For a structured check inside scripts, use `ktx status --json`. +If you skipped the build, `ktx context built` shows `no`. Build it with +`ktx ingest` - there is no need to re-run `ktx setup`. + When setup finishes building context, its final context check looks like: ```text diff --git a/packages/cli/src/next-steps.ts b/packages/cli/src/next-steps.ts index 80a1b441..b6726e3c 100644 --- a/packages/cli/src/next-steps.ts +++ b/packages/cli/src/next-steps.ts @@ -70,8 +70,7 @@ export function formatSetupNextStepLines(state: KtxSetupNextStepState, indent = if (!state.contextReady) { return [ - `${indent}Build KTX context next.`, - `${indent}Run ingest to build database schema context before context-source ingest.`, + `${indent}Setup is complete. The only step left is to build context for your agents.`, ...commandLines(KTX_CONTEXT_BUILD_COMMANDS, indent), ]; } diff --git a/packages/cli/src/setup-context.ts b/packages/cli/src/setup-context.ts index d6ef2639..721c09bd 100644 --- a/packages/cli/src/setup-context.ts +++ b/packages/cli/src/setup-context.ts @@ -441,12 +441,10 @@ function writeMissingCapabilities(missing: string[], io: KtxCliIo): void { io.stderr.write('\nFix this in setup before building context.\n'); } -function writeSkippedContext(projectDir: string, io: KtxCliIo): void { - io.stdout.write('\nKTX is configured, but context has not been built yet.\n\n'); - io.stdout.write('Agents were not connected because KTX has not prepared searchable context for them.\n\n'); - io.stdout.write(`Resume setup:\n ktx setup --project-dir ${resolve(projectDir)}\n\n`); - io.stdout.write(`Build context:\n ktx setup --project-dir ${resolve(projectDir)}\n\n`); - io.stdout.write(`Check status:\n ktx status --project-dir ${resolve(projectDir)}\n`); +function writeSkippedContext(io: KtxCliIo): void { + // The setup completion screen owns "what to do next" (it points at `ktx ingest`), + // so keep this to a short acknowledgement rather than a competing command list. + io.stdout.write('\nLeaving context unbuilt for now.\n'); } function writeSuccess( @@ -695,7 +693,7 @@ export async function runKtxSetupContextStep( return { status: 'back', projectDir: args.projectDir }; } if (choice === 'skip') { - writeSkippedContext(args.projectDir, io); + writeSkippedContext(io); return { status: 'skipped', projectDir: args.projectDir }; } } diff --git a/packages/cli/src/setup-ready-menu.ts b/packages/cli/src/setup-ready-menu.ts index f1f736e4..de0f5a45 100644 --- a/packages/cli/src/setup-ready-menu.ts +++ b/packages/cli/src/setup-ready-menu.ts @@ -14,6 +14,12 @@ export type KtxSetupReadyAction = | 'agents' | 'exit'; +/** + * Where a project stands once its `ktx.yaml` exists. Single source of truth for the + * end-of-setup interception: each state maps to exactly one obvious next action. + */ +export type KtxSetupCompletion = 'incomplete' | 'needs-context' | 'needs-agents' | 'ready'; + interface KtxSetupReadyMenuPromptAdapter { select(options: { message: string; options: KtxSetupPromptOption[] }): Promise; cancel(message: string): void; @@ -23,7 +29,11 @@ export interface KtxSetupReadyMenuDeps { prompts?: KtxSetupReadyMenuPromptAdapter; } -export function isKtxPreAgentSetupReady(status: KtxSetupStatus): boolean { +export function setupHasContextTargets(status: KtxSetupStatus): boolean { + return status.databases.length > 0 || status.sources.length > 0; +} + +function setupConfigReady(status: KtxSetupStatus): boolean { return ( status.project.ready && status.llm.ready && @@ -31,25 +41,58 @@ export function isKtxPreAgentSetupReady(status: KtxSetupStatus): boolean { status.databases.every((database) => database.ready) && status.sources.every((source) => source.ready) && status.runtime.ready && - status.context.ready + setupHasContextTargets(status) ); } -export function isKtxSetupReady(status: KtxSetupStatus): boolean { - return isKtxPreAgentSetupReady(status) && status.agents.some((agent) => agent.ready); +export function classifyKtxSetupCompletion(status: KtxSetupStatus): KtxSetupCompletion { + if (!setupConfigReady(status)) { + return 'incomplete'; + } + if (!status.context.ready) { + return 'needs-context'; + } + if (!status.agents.some((agent) => agent.ready)) { + return 'needs-agents'; + } + return 'ready'; } function createPromptAdapter(): KtxSetupReadyMenuPromptAdapter { return createKtxSetupPromptAdapter({ selectCancelValue: 'exit' }); } +/** + * Shown when a returning user re-runs `ktx setup` on a fully-ready project. Leads with + * "you're done" (the readiness note is printed by the caller first) and keeps the + * section editor one explicit step away rather than defaulting into it. + */ +export async function runKtxSetupReadyMenu( + status: KtxSetupStatus, + deps: KtxSetupReadyMenuDeps = {}, +): Promise<{ action: KtxSetupReadyAction }> { + const prompts = deps.prompts ?? createPromptAdapter(); + const choice = await prompts.select({ + message: 'Anything else?', + options: [ + { value: 'done', label: "Done — I'll start using ktx" }, + { value: 'change', label: 'Change a setting' }, + ], + }); + if (choice !== 'change') { + return { action: 'exit' }; + } + return runKtxSetupReadyChangeMenu(status, { prompts }); +} + +/** @internal Reached only through {@link runKtxSetupReadyMenu}; exported for unit tests. */ export async function runKtxSetupReadyChangeMenu( status: KtxSetupStatus, deps: KtxSetupReadyMenuDeps = {}, ): Promise<{ action: KtxSetupReadyAction }> { const prompts = deps.prompts ?? createPromptAdapter(); const action = (await prompts.select({ - message: `KTX is already set up for ${status.project.name ?? status.project.path}. What would you like to change?`, + message: 'What would you like to change?', options: [ { value: 'models', label: 'Models' }, { value: 'embeddings', label: 'Embeddings' }, diff --git a/packages/cli/src/setup.ts b/packages/cli/src/setup.ts index f8fc2064..7d4fdb0e 100644 --- a/packages/cli/src/setup.ts +++ b/packages/cli/src/setup.ts @@ -6,7 +6,7 @@ import { ktxLocalStateDbPath } from './context/project/local-state-db.js'; import { loadKtxProject, type KtxLocalProject } from './context/project/project.js'; import { readKtxSetupState } from './context/project/setup-config.js'; import { getKtxCliPackageInfo, type KtxCliIo } from './cli-runtime.js'; -import { formatSetupNextStepLines } from './next-steps.js'; +import { formatNextStepLines, formatSetupNextStepLines } from './next-steps.js'; import { runtimeInstallPolicyFromFlags } from './managed-python-command.js'; import { readManagedPythonRuntimeStatus } from './managed-python-runtime.js'; import { resolveProjectRuntimeRequirements } from './runtime-requirements.js'; @@ -33,10 +33,10 @@ import { } from './setup-models.js'; import { type KtxSetupProjectDeps, runKtxSetupProjectStep } from './setup-project.js'; import { - isKtxPreAgentSetupReady, - isKtxSetupReady, + classifyKtxSetupCompletion, type KtxSetupReadyMenuDeps, - runKtxSetupReadyChangeMenu, + runKtxSetupReadyMenu, + setupHasContextTargets, } from './setup-ready-menu.js'; import { type KtxSetupSourcesDeps, type KtxSetupSourceType, runKtxSetupSourcesStep } from './setup-sources.js'; import { @@ -529,10 +529,6 @@ function setupStatusReady(status: KtxSetupStatus): boolean { ); } -function setupHasContextTargets(status: KtxSetupStatus): boolean { - return status.databases.length > 0 || status.sources.length > 0; -} - function setupContextReady(status: KtxSetupStatus): boolean { return status.context.ready; } @@ -630,12 +626,19 @@ async function runKtxSetupInner(args: KtxSetupArgs, io: KtxCliIo, deps: KtxSetup let readyAction: string | undefined; if (args.inputMode !== 'disabled' && !agentsRequested) { - if (isKtxSetupReady(currentStatus)) { - readyAction = (await runKtxSetupReadyChangeMenu(currentStatus, deps.readyMenuDeps)).action; - if (readyAction === 'exit') return 0; - } else if (isKtxPreAgentSetupReady(currentStatus)) { + const completion = classifyKtxSetupCompletion(currentStatus); + if (completion === 'ready') { + setupUi.note(formatNextStepLines().join('\n'), 'ktx is ready', io); + const choice = (await runKtxSetupReadyMenu(currentStatus, deps.readyMenuDeps)).action; + if (choice === 'exit') return 0; + readyAction = choice; + } else if (completion === 'needs-context') { + // Config is done; skip the re-walk and land straight on the build prompt. + readyAction = 'context'; + } else if (completion === 'needs-agents') { readyAction = 'agents'; } + // 'incomplete' → readyAction stays undefined → run the full setup walk. } const runOnly = readyAction; @@ -872,7 +875,9 @@ async function runKtxSetupInner(args: KtxSetupArgs, io: KtxCliIo, deps: KtxSetup } if (step === 'context' && stepResult.status !== 'ready') { if (shouldRunAgents && args.skipAgents !== true) { - return 0; + // Context isn't built, so skip agent install — but still reach the + // completion screen, which states readiness and points at `ktx ingest`. + break setupLoop; } } diff --git a/packages/cli/test/next-steps.test.ts b/packages/cli/test/next-steps.test.ts index c700de9e..eed0f3bf 100644 --- a/packages/cli/test/next-steps.test.ts +++ b/packages/cli/test/next-steps.test.ts @@ -65,8 +65,7 @@ describe('KTX demo next steps', () => { agentIntegrationReady: true, }).join('\n'); - expect(rendered).toContain('Build KTX context next.'); - expect(rendered).toContain('Run ingest to build database schema context before context-source ingest.'); + expect(rendered).toContain('Setup is complete. The only step left is to build context for your agents.'); expect(rendered).toContain('ktx ingest'); expect(rendered).not.toContain('resume'); expect(rendered).not.toContain('scan'); @@ -87,6 +86,6 @@ describe('KTX demo next steps', () => { expect(rendered).toContain('ktx status --json'); expect(rendered).not.toContain('ktx agent'); expect(rendered).not.toContain('ktx serve --mcp stdio --user-id local'); - expect(rendered).not.toContain('Build KTX context next.'); + expect(rendered).not.toContain('Setup is complete.'); }); }); diff --git a/packages/cli/test/setup-ready-menu.test.ts b/packages/cli/test/setup-ready-menu.test.ts index 82c92a1c..39c62a32 100644 --- a/packages/cli/test/setup-ready-menu.test.ts +++ b/packages/cli/test/setup-ready-menu.test.ts @@ -1,5 +1,9 @@ import { describe, expect, it, vi } from 'vitest'; -import { isKtxPreAgentSetupReady, isKtxSetupReady, runKtxSetupReadyChangeMenu } from '../src/setup-ready-menu.js'; +import { + classifyKtxSetupCompletion, + runKtxSetupReadyChangeMenu, + runKtxSetupReadyMenu, +} from '../src/setup-ready-menu.js'; import type { KtxSetupStatus } from '../src/setup.js'; const readyStatus: KtxSetupStatus = { @@ -13,32 +17,58 @@ const readyStatus: KtxSetupStatus = { agents: [{ target: 'codex', scope: 'project', ready: true }], }; -describe('setup ready menu', () => { - it('recognizes a ready setup only when required sections are ready', () => { - expect(isKtxSetupReady(readyStatus)).toBe(true); - expect(isKtxSetupReady({ ...readyStatus, embeddings: { ready: false } })).toBe(false); - expect(isKtxSetupReady({ ...readyStatus, runtime: { required: true, ready: false, features: ['core'] } })).toBe(false); - expect(isKtxSetupReady({ ...readyStatus, context: { ready: false, status: 'not_started' } })).toBe(false); - expect(isKtxSetupReady({ ...readyStatus, agents: [] })).toBe(false); +describe('classifyKtxSetupCompletion', () => { + it('reports ready only when config, context, and agents are all ready', () => { + expect(classifyKtxSetupCompletion(readyStatus)).toBe('ready'); }); - it('recognizes pre-agent readiness without requiring agents', () => { - expect(isKtxPreAgentSetupReady(readyStatus)).toBe(true); - expect(isKtxPreAgentSetupReady({ ...readyStatus, agents: [] })).toBe(true); - expect(isKtxPreAgentSetupReady({ ...readyStatus, embeddings: { ready: false } })).toBe(false); - expect(isKtxPreAgentSetupReady({ ...readyStatus, runtime: { required: true, ready: false, features: ['core'] } })).toBe( - false, - ); - expect(isKtxPreAgentSetupReady({ ...readyStatus, context: { ready: false, status: 'not_started' } })).toBe(false); + it('reports needs-agents when config and context are ready but no agent is installed', () => { + expect(classifyKtxSetupCompletion({ ...readyStatus, agents: [] })).toBe('needs-agents'); }); - it('maps ready-project menu choices to setup sections', async () => { - const prompts = { select: vi.fn(async () => 'agents'), cancel: vi.fn() }; + it('reports needs-context when config is ready but context is not built', () => { + expect( + classifyKtxSetupCompletion({ ...readyStatus, context: { ready: false, status: 'not_started' } }), + ).toBe('needs-context'); + }); - await expect(runKtxSetupReadyChangeMenu(readyStatus, { prompts })).resolves.toEqual({ action: 'agents' }); + it('reports incomplete when a required config section is not ready', () => { + expect(classifyKtxSetupCompletion({ ...readyStatus, embeddings: { ready: false } })).toBe('incomplete'); + expect( + classifyKtxSetupCompletion({ ...readyStatus, runtime: { required: true, ready: false, features: ['core'] } }), + ).toBe('incomplete'); + }); + it('reports incomplete when no context targets are configured', () => { + expect(classifyKtxSetupCompletion({ ...readyStatus, databases: [], sources: [] })).toBe('incomplete'); + }); +}); + +describe('runKtxSetupReadyMenu', () => { + it('exits when the user is done', async () => { + const prompts = { select: vi.fn(async () => 'done'), cancel: vi.fn() }; + + await expect(runKtxSetupReadyMenu(readyStatus, { prompts })).resolves.toEqual({ action: 'exit' }); + + expect(prompts.select).toHaveBeenCalledTimes(1); expect(prompts.select).toHaveBeenCalledWith({ - message: 'KTX is already set up for /tmp/revenue. What would you like to change?', + message: 'Anything else?', + options: [ + { value: 'done', label: "Done — I'll start using ktx" }, + { value: 'change', label: 'Change a setting' }, + ], + }); + }); + + it('opens the section menu when the user chooses to change a setting', async () => { + const select = vi.fn().mockResolvedValueOnce('change').mockResolvedValueOnce('models'); + const prompts = { select, cancel: vi.fn() }; + + await expect(runKtxSetupReadyMenu(readyStatus, { prompts })).resolves.toEqual({ action: 'models' }); + + expect(select).toHaveBeenCalledTimes(2); + expect(select).toHaveBeenLastCalledWith({ + message: 'What would you like to change?', options: [ { value: 'models', label: 'Models' }, { value: 'embeddings', label: 'Embeddings' }, @@ -51,3 +81,39 @@ describe('setup ready menu', () => { }); }); }); + +describe('runKtxSetupReadyChangeMenu', () => { + it('maps ready-project menu choices to setup sections', async () => { + const prompts = { select: vi.fn(async () => 'agents'), cancel: vi.fn() }; + + await expect(runKtxSetupReadyChangeMenu(readyStatus, { prompts })).resolves.toEqual({ action: 'agents' }); + + expect(prompts.select).toHaveBeenCalledWith({ + message: 'What would you like to change?', + options: [ + { value: 'models', label: 'Models' }, + { value: 'embeddings', label: 'Embeddings' }, + { value: 'databases', label: 'Databases' }, + { value: 'sources', label: 'Context sources' }, + { value: 'context', label: 'Rebuild KTX context' }, + { value: 'agents', label: 'Agent integration' }, + { value: 'exit', label: 'Exit' }, + ], + }); + }); + + it('includes the runtime option only when the runtime is required', async () => { + const prompts = { select: vi.fn(async () => 'runtime'), cancel: vi.fn() }; + + await runKtxSetupReadyChangeMenu( + { ...readyStatus, runtime: { required: true, ready: true, features: ['core'] } }, + { prompts }, + ); + + expect(prompts.select).toHaveBeenCalledWith( + expect.objectContaining({ + options: expect.arrayContaining([{ value: 'runtime', label: 'Runtime' }]), + }), + ); + }); +}); diff --git a/packages/cli/test/setup.test.ts b/packages/cli/test/setup.test.ts index da51e9af..e4eca44d 100644 --- a/packages/cli/test/setup.test.ts +++ b/packages/cli/test/setup.test.ts @@ -2205,8 +2205,11 @@ describe('setup status', () => { join(tempDir, 'ktx.yaml'), [ 'setup:', - ' database_connection_ids: []', - 'connections: {}', + ' database_connection_ids: [warehouse]', + 'connections:', + ' warehouse:', + ' driver: postgres', + ' url: env:DATABASE_URL', 'llm:', ' provider:', ' backend: anthropic', @@ -2222,7 +2225,7 @@ describe('setup status', () => { 'utf-8', ); await writeKtxSetupState(tempDir, { - completed_steps: ['project', 'llm', 'embeddings', 'sources', 'runtime', 'context', 'agents'], + completed_steps: ['project', 'llm', 'embeddings', 'databases', 'sources', 'runtime', 'context', 'agents'], }); await writeFile( join(tempDir, '.ktx/agents/install-manifest.json'), @@ -2275,7 +2278,12 @@ describe('setup status', () => { }, io.io, { - readyMenuDeps: { prompts: { select: vi.fn(async () => 'agents'), cancel: vi.fn() } }, + readyMenuDeps: { + prompts: { + select: vi.fn().mockResolvedValueOnce('change').mockResolvedValueOnce('agents'), + cancel: vi.fn(), + }, + }, model: async (args) => { expect(args.skipLlm).toBe(true); return { status: 'skipped', projectDir: tempDir }; @@ -2325,8 +2333,11 @@ describe('setup status', () => { join(tempDir, 'ktx.yaml'), [ 'setup:', - ' database_connection_ids: []', - 'connections: {}', + ' database_connection_ids: [warehouse]', + 'connections:', + ' warehouse:', + ' driver: postgres', + ' url: env:DATABASE_URL', 'llm:', ' provider:', ' backend: anthropic', @@ -2342,7 +2353,7 @@ describe('setup status', () => { 'utf-8', ); await writeKtxSetupState(tempDir, { - completed_steps: ['project', 'llm', 'embeddings', 'sources', 'context'], + completed_steps: ['project', 'llm', 'embeddings', 'databases', 'sources', 'context'], }); await writeKtxSetupContextState(tempDir, { runId: 'setup-context-local-ready', @@ -2415,6 +2426,171 @@ describe('setup status', () => { expect(calls).toEqual(['agents']); }); + it('routes a returning user to the context build when config is ready but context is not built', async () => { + const calls: string[] = []; + const io = makeIo(); + await writeFile( + join(tempDir, 'ktx.yaml'), + [ + 'setup:', + ' database_connection_ids: [warehouse]', + 'connections:', + ' warehouse:', + ' driver: postgres', + ' url: env:DATABASE_URL', + 'llm:', + ' provider:', + ' backend: anthropic', + ' models:', + ' default: claude-sonnet-4-6', + 'ingest:', + ' embeddings:', + ' backend: openai', + ' model: text-embedding-3-small', + ' dimensions: 1536', + '', + ].join('\n'), + 'utf-8', + ); + await writeKtxSetupState(tempDir, { + completed_steps: ['project', 'llm', 'embeddings', 'databases', 'sources', 'runtime'], + }); + + const readyMenuSelect = vi.fn(); + await expect( + runKtxSetup( + { + command: 'run', + projectDir: tempDir, + mode: 'auto', + agents: false, + inputMode: 'auto', + yes: false, + cliVersion: '0.2.0', + skipLlm: false, + skipEmbeddings: false, + skipDatabases: false, + skipSources: false, + skipAgents: false, + databaseSchemas: [], + }, + io.io, + { + readyMenuDeps: { prompts: { select: readyMenuSelect, cancel: vi.fn() } }, + model: async (args) => { + expect(args.skipLlm).toBe(true); + return { status: 'skipped', projectDir: tempDir }; + }, + embeddings: async (args) => { + expect(args.skipEmbeddings).toBe(true); + return { status: 'skipped', projectDir: tempDir }; + }, + databases: async (args) => { + expect(args.skipDatabases).toBe(true); + return { status: 'skipped', projectDir: tempDir }; + }, + sources: async (args) => { + expect(args.skipSources).toBe(true); + return { status: 'skipped', projectDir: tempDir }; + }, + runtime: async () => { + calls.push('runtime'); + return runtimeReady(tempDir); + }, + context: async (args) => { + calls.push('context'); + expect(args.forcePrompt).toBe(true); + return { status: 'skipped', projectDir: tempDir }; + }, + agents: async () => { + calls.push('agents'); + return { status: 'ready', projectDir: tempDir, installs: [] }; + }, + }, + ), + ).resolves.toBe(0); + + // Config is done, so the change-everything menu is not shown; setup routes straight + // to the build prompt and never re-walks config or installs agents. + expect(readyMenuSelect).not.toHaveBeenCalled(); + expect(calls).toContain('context'); + expect(calls).not.toContain('agents'); + const output = io.stdout(); + expect(output).toContain('Setup is complete. The only step left is to build context'); + expect(output).toContain('ktx ingest'); + }); + + it('reaches the completion screen instead of a bare shell when the context build is skipped', async () => { + const calls: string[] = []; + const io = makeIo(); + await writeFile( + join(tempDir, 'ktx.yaml'), + [ + 'setup:', + ' database_connection_ids: [warehouse]', + 'connections:', + ' warehouse:', + ' driver: postgres', + ' url: env:DATABASE_URL', + 'llm:', + ' provider:', + ' backend: anthropic', + ' models:', + ' default: claude-sonnet-4-6', + 'ingest:', + ' embeddings:', + ' backend: openai', + ' model: text-embedding-3-small', + ' dimensions: 1536', + '', + ].join('\n'), + 'utf-8', + ); + await writeKtxSetupState(tempDir, { + completed_steps: ['project', 'llm', 'embeddings', 'databases', 'sources', 'runtime'], + }); + + await expect( + runKtxSetup( + { + command: 'run', + projectDir: tempDir, + mode: 'auto', + agents: false, + inputMode: 'disabled', + yes: true, + cliVersion: '0.2.0', + skipLlm: true, + skipEmbeddings: true, + skipDatabases: true, + skipSources: true, + skipAgents: false, + databaseSchemas: [], + }, + io.io, + { + model: async () => ({ status: 'skipped', projectDir: tempDir }), + embeddings: async () => ({ status: 'skipped', projectDir: tempDir }), + databases: async () => ({ status: 'skipped', projectDir: tempDir }), + sources: async () => ({ status: 'skipped', projectDir: tempDir }), + runtime: async () => runtimeReady(tempDir), + context: async () => ({ status: 'skipped', projectDir: tempDir }), + agents: async () => { + calls.push('agents'); + return { status: 'ready', projectDir: tempDir, installs: [] }; + }, + }, + ), + ).resolves.toBe(0); + + // A skipped build must not install agents nor drop to a bare shell; the end screen + // states readiness and points at `ktx ingest`. + expect(calls).not.toContain('agents'); + const output = io.stdout(); + expect(output).toContain('Setup is complete. The only step left is to build context'); + expect(output).toContain('ktx ingest'); + }); + it('runs only project resolution and agent setup in --agents mode', async () => { const io = makeIo(); const runtime = vi.fn(async () => runtimeReady(tempDir)); From 9d3a0b751df68c19df8007c4dec4c891f73246b0 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 3 Jun 2026 07:50:39 +0000 Subject: [PATCH 10/25] chore: refresh star history chart [skip ci] --- assets/star-history.svg | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/assets/star-history.svg b/assets/star-history.svg index d6d9859e..23016f3e 100644 --- a/assets/star-history.svg +++ b/assets/star-history.svg @@ -1 +1 @@ -star-history.comMay 17May 24May 31 200400600kaelio/ktxStar HistoryDateGitHub Stars +star-history.comMay 17May 24May 31 200400600800kaelio/ktxStar HistoryDateGitHub Stars From f5dea9a0891305e7c4d90b0156638681fe75c1dc Mon Sep 17 00:00:00 2001 From: Andrey Avtomonov Date: Wed, 3 Jun 2026 13:05:59 +0200 Subject: [PATCH 11/25] fix(ingest): recover textual-conflict gate failures; fix query-history adapter (#255) * fix(ingest): recover textual-conflict gate failures; fix query-history adapter Two latent gaps in the isolated-diff local-ingest pipeline that can abort an otherwise-successful ingest: - Metabase: when a work-unit patch hit both a textual conflict and a post-merge dangling sl_ref, the after-textual-resolution branch returned a hard semantic_conflict and rolled back the whole job. It now runs the same repairGateFailure recovery the clean-apply branch already uses (re-validate, then commit the union of resolved + repaired paths), reaching parity. - Query history: the historic-sql adapter was registered only when ktx.yaml had context.queryHistory.enabled=true, so `--query-history` threw "Adapter not available for local ingest". Registration now resolves the dialect from driver capability, since the explicit --query-history request is itself the opt-in; the config-gated helper is unchanged for status/setup/probes. Adds the previously-missing tests for both paths. * chore: sync uv.lock to 0.8.0 (regenerated with pinned uv 0.11.11) * fix(ingest): drop ktx's own scan probes and dedup tables in query history Query history (historic-sql) mined two kinds of noise back into context: - ktx's own warehouse scan emits relationship- and column-profiling probes (the relationship_profile_values aggregation and the child_values/parent_values FK-overlap CTEs) into pg_stat_statements. shouldDropBySql now filters these ktx-owned, dialect-stable signatures so ktx introspection is not ingested as usage history. - The same physical table appears both bare (accounts, via search_path) and schema-qualified (orbit_raw.accounts), producing duplicate per-table work units. canonicalizeTableIdentifiers collapses a bare name into its unique qualified form before work-unit keying; ambiguous names are left untouched. On the orbit demo this removes ~35% of sampled query templates (ktx self-probes) and ~45 duplicate per-table work units. * docs(agents): add Design Reasoning Defaults section --- AGENTS.md | 59 ++++++++++++ .../historic-sql/connection-dialect.ts | 20 +++- .../adapters/historic-sql/stage-unified.ts | 62 ++++++++++++ .../ingest/isolated-diff/patch-integrator.ts | 95 ++++++++++++++++++- packages/cli/src/local-adapters.ts | 9 +- .../historic-sql/connection-dialect.test.ts | 21 +++- .../historic-sql/stage-unified.test.ts | 84 ++++++++++++++++ .../isolated-diff/patch-integrator.test.ts | 68 +++++++++++++ packages/cli/test/local-adapters.test.ts | 31 ++++++ 9 files changed, 437 insertions(+), 12 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 0cd9da93..20f9bcdf 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -159,6 +159,65 @@ and naming asymmetries are bugs in waiting — see [`docs/code-design.md`](docs/code-design.md). Treat the `MUST` / `MUST NOT` rules there with the same weight as the ones in this file. +## Design Reasoning Defaults + +When proposing a design, an approach, or any non-trivial change, apply these +defaults and run the self-check before presenting it. They encode the +corrections users most often have to make; reaching these conclusions +autonomously — without being asked the leading question — is the bar. + +- **MUST**: Optimize for the best outcome, not for an unstated constraint. Do not + silently adopt "smallest change", "least effort", "cheapest", or "least user + intervention" as the goal unless the user said so. Default to the most correct, + durable solution, and present cost / effort / scope as information for the user + to weigh — not as a ceiling you impose on their behalf. +- **MUST**: Separate one-time cost from recurring cost before discarding an + option. A fixed cost paid once (a setup-time computation, an extra LLM call + during setup, a contract change) to make every later run cheaper or more + correct is usually worth it. Do not reject it with recurring-cost reasoning; + quantify both sides. (Example smell: "don't add an LLM call to a cost-cutting + feature" — wrong when the call is one-time and the savings recur.) +- **MUST**: Treat a user's example as a representative of a class, not as the + spec. Design for the general population the example stands for, then stress-test + against deliberately different instances — another warehouse, dialect, stack + layout, or input shape — before committing. If a design only works because of an + incidental property of the example (e.g. "the noise happened to be in a separate + schema *on this demo*"), it is curve-fitting; generalize it or state the + assumption explicitly. +- **MUST**: Prefer deriving from the system's own state over enumerating cases. + Favor an allowlist computed from declared/observed state (config, scanned + catalog, query log, the user's own inputs) over a denylist of known-bad + specifics (particular tables, schemas, tools, or vendors). A hardcoded or + hand-maintained list of external specifics is a smell: it rots and fails on the + next stack. The only acceptable static patterns are genuinely universal + invariants (e.g. DB-engine system catalogs) and ktx's own self-emitted + signatures. +- **SHOULD**: Before inventing an abstraction or hand-rolling structural logic, + search for what already exists and reuse it — the codebase's canonical + representation (a structured ref/key type) instead of a parallel string scheme, + and a mandated/available tool (e.g. `sqlglot` for SQL structure; see + [SQL and Structured Parsing](#sql-and-structured-parsing)) instead of + hand-parsing. Normalize ambiguous input to the canonical form at the boundary; + do not carry the ambiguity downstream. This is the single-source-of-truth / DRY + item from the Priority Hierarchy applied at design time. + +Before presenting a design, answer these explicitly: + +1. Am I optimizing for a goal the user actually stated, or one I assumed? +2. Does this generalize beyond the example in front of me? Name a real case where + it would break. +3. Am I enumerating known-bad cases when I could derive scope from the system's + own declared/observed state? +4. Is there an existing canonical representation or mandated tool I should reuse + instead of building or parsing my own? +5. Am I discarding the better option on a weak or misapplied constraint + (one-time vs recurring cost, "more surface area", "more work now")? + +A user question that nudges toward an alternative ("would X help?", "should I +always do Y?", "will you hardcode Z?") is a signal that a better option exists. +Investigate the implied direction and reason it through *before* defending the +original proposal — and prefer to have asked yourself the question first. + ## TypeScript Standards - Use Node 22+ and pnpm workspace commands. diff --git a/packages/cli/src/context/ingest/adapters/historic-sql/connection-dialect.ts b/packages/cli/src/context/ingest/adapters/historic-sql/connection-dialect.ts index dd95f87a..7845cbbc 100644 --- a/packages/cli/src/context/ingest/adapters/historic-sql/connection-dialect.ts +++ b/packages/cli/src/context/ingest/adapters/historic-sql/connection-dialect.ts @@ -26,6 +26,21 @@ export function isQueryHistoryEnabled(connection: unknown): boolean { return queryHistoryRecord(connection)?.enabled === true; } +/** + * Resolves the query-history dialect from the connection's driver capability + * alone, ignoring whether query history is enabled in ktx.yaml. Use this on the + * adapter-registration path when query history has been explicitly requested + * for the run (e.g. via `--query-history`, which is itself the opt-in): the + * persisted `context.queryHistory.enabled` flag must not gate registration. + * Returns null when the connection's driver has no query-history reader. + */ +export function historicSqlDialectForConnectionDriver(connection: unknown): HistoricSqlDialect | null { + const conn = recordOrNull(connection); + const driver = String(conn?.driver ?? '').toLowerCase(); + const registration = getDriverRegistration(driver); + return registration?.hasHistoricSqlReader ? historicSqlDialectForDriver(registration.driver) : null; +} + /** * Resolves the query-history dialect for a connection. Returns null when * query history is disabled, or when the connection's driver has no @@ -35,8 +50,5 @@ export function queryHistoryDialectForConnection(connection: unknown): HistoricS if (!isQueryHistoryEnabled(connection)) { return null; } - const conn = recordOrNull(connection); - const driver = String(conn?.driver ?? '').toLowerCase(); - const registration = getDriverRegistration(driver); - return registration?.hasHistoricSqlReader ? historicSqlDialectForDriver(registration.driver) : null; + return historicSqlDialectForConnectionDriver(connection); } diff --git a/packages/cli/src/context/ingest/adapters/historic-sql/stage-unified.ts b/packages/cli/src/context/ingest/adapters/historic-sql/stage-unified.ts index 70997648..853a3e68 100644 --- a/packages/cli/src/context/ingest/adapters/historic-sql/stage-unified.ts +++ b/packages/cli/src/context/ingest/adapters/historic-sql/stage-unified.ts @@ -79,8 +79,21 @@ function matchesAny(value: string | null, patterns: RegExp[]): boolean { return !!value && patterns.some((pattern) => pattern.test(value)); } +// ktx's own warehouse scan emits relationship- and column-profiling probes that land in +// pg_stat_statements (relationship-validation, relationship-composite-candidates, and each +// dialect's relationship value aggregation). They are ktx introspection, not genuine query +// usage, so they must not be mined back as query history. The markers are ktx-owned +// identifiers, stable across dialects. +function isKtxScanProbe(sql: string): boolean { + if (/\brelationship_profile_values\b/i.test(sql)) { + return true; + } + return /\bchild_values\b/i.test(sql) && /\bparent_values\b/i.test(sql); +} + function shouldDropBySql(sql: string, config: HistoricSqlUnifiedPullConfig): boolean { if (NOISE_PREFIX_RE.test(sql) || SYSTEM_TABLE_RE.test(sql)) return true; + if (isKtxScanProbe(sql)) return true; if (config.filters.dropTrivialProbes !== false && TRIVIAL_SQL_RE.test(sql)) return true; return false; } @@ -148,6 +161,53 @@ function isEnabledTable(table: string, filter: EnabledTableFilter | null): boole return filter.exact.has(normalized) || filter.uniqueUnqualified.has(unqualifiedTableIdentifier(normalized)); } +/** + * pg_stat_statements records queries as written, so the same physical table can appear + * both bare (`accounts`, resolved via search_path) and schema-qualified + * (`orbit_raw.accounts`). Collapse a bare identifier into its schema-qualified form when + * exactly one qualified form shares its unqualified name, so the two never become separate + * work units. Ambiguous bare names (two qualified forms) are left untouched. + */ +function canonicalizeTableIdentifiers(parsedTemplates: ParsedTemplate[]): void { + const all = new Set(); + for (const parsed of parsedTemplates) { + for (const table of parsed.includedTables) { + all.add(table); + } + } + const qualifiedByUnqualified = new Map>(); + for (const table of all) { + if (!table.includes('.')) { + continue; + } + const unqualified = unqualifiedTableIdentifier(table); + if (unqualified.length === 0) { + continue; + } + const forms = qualifiedByUnqualified.get(unqualified) ?? new Set(); + forms.add(table); + qualifiedByUnqualified.set(unqualified, forms); + } + const canonical = new Map(); + for (const table of all) { + if (table.includes('.')) { + continue; + } + const forms = qualifiedByUnqualified.get(unqualifiedTableIdentifier(table)); + if (forms && forms.size === 1) { + canonical.set(table, [...forms][0]); + } + } + if (canonical.size === 0) { + return; + } + const remap = (table: string): string => canonical.get(table) ?? table; + for (const parsed of parsedTemplates) { + parsed.includedTables = [...new Set(parsed.includedTables.map(remap))].sort(); + parsed.tablesTouched = [...new Set(parsed.tablesTouched.map(remap))].sort(); + } +} + function historicSqlWindowDays(config: HistoricSqlUnifiedPullConfig): number { return 'windowDays' in config ? config.windowDays : 90; } @@ -323,6 +383,8 @@ export async function stageHistoricSqlAggregatedSnapshot(input: StageHistoricSql }); } + canonicalizeTableIdentifiers(parsedTemplates); + const byTable = new Map(); for (const parsed of parsedTemplates) { for (const table of parsed.includedTables) { diff --git a/packages/cli/src/context/ingest/isolated-diff/patch-integrator.ts b/packages/cli/src/context/ingest/isolated-diff/patch-integrator.ts index 869c019e..1e2f0cee 100644 --- a/packages/cli/src/context/ingest/isolated-diff/patch-integrator.ts +++ b/packages/cli/src/context/ingest/isolated-diff/patch-integrator.ts @@ -155,18 +155,103 @@ export async function integrateWorkUnitPatch(input: IntegrateWorkUnitPatchInput) }, ); } catch (semanticError) { - if (preApplyHead) { - await input.integrationGit.resetHardTo(preApplyHead); - } + const reason = errorMessage(semanticError); await input.trace.event('error', 'integration', 'patch_semantic_conflict_after_textual_resolution', { unitKey: input.unitKey, patchPath: input.patchPath, touchedPaths: textualResolution.changedPaths, - reason: errorMessage(semanticError), + reason, }); + + // A textual conflict and a semantic-gate failure can co-occur: the resolver + // reconciles the text but can leave wiki sl_refs pointing at measures the + // merged source no longer defines. Recover via the same gate repair the + // clean-apply branch uses, instead of hard-failing the whole job. + if (input.repairGateFailure) { + const gateRepair = await input.repairGateFailure({ + unitKey: input.unitKey, + patchPath: input.patchPath, + touchedPaths: textualResolution.changedPaths, + reason, + }); + + if (gateRepair.status !== 'failed') { + // The resolver wrote its merge to the worktree (unstaged); the repair + // edited a subset on top. Commit the union so neither is dropped. + const resolvedAndRepairedPaths = [ + ...new Set([...textualResolution.changedPaths, ...gateRepair.changedPaths]), + ].sort(); + try { + await traceTimed( + input.trace, + 'integration', + 'semantic_gate_after_gate_repair', + { unitKey: input.unitKey, touchedPaths: gateRepair.changedPaths }, + async () => { + await input.validateAppliedTree(gateRepair.changedPaths); + }, + ); + + const commit = await input.integrationGit.commitFiles( + resolvedAndRepairedPaths, + `ingest: resolve WorkUnit ${input.unitKey} conflict`, + input.author.name, + input.author.email, + ); + if (commit.created) { + await input.trace.event('debug', 'integration', 'patch_accepted_after_textual_resolution', { + unitKey: input.unitKey, + commitSha: commit.commitHash, + touchedPaths: resolvedAndRepairedPaths, + attempts: textualResolution.attempts, + gateRepairAttempts: gateRepair.attempts, + }); + return { + status: 'accepted', + commitSha: commit.commitHash, + touchedPaths: resolvedAndRepairedPaths, + textualResolution, + gateRepair, + }; + } + } catch (repairValidationError) { + if (preApplyHead) { + await input.integrationGit.resetHardTo(preApplyHead); + } + await input.trace.event('error', 'integration', 'patch_semantic_conflict_after_textual_resolution', { + unitKey: input.unitKey, + patchPath: input.patchPath, + touchedPaths: gateRepair.changedPaths, + reason: errorMessage(repairValidationError), + }); + return { + status: 'semantic_conflict', + reason: errorMessage(repairValidationError), + touchedPaths: gateRepair.changedPaths, + textualResolution, + gateRepair, + }; + } + } + + if (preApplyHead) { + await input.integrationGit.resetHardTo(preApplyHead); + } + return { + status: 'semantic_conflict', + reason: gateRepair.status === 'failed' ? gateRepair.reason : reason, + touchedPaths: textualResolution.changedPaths, + textualResolution, + gateRepair, + }; + } + + if (preApplyHead) { + await input.integrationGit.resetHardTo(preApplyHead); + } return { status: 'semantic_conflict', - reason: errorMessage(semanticError), + reason, touchedPaths: textualResolution.changedPaths, textualResolution, }; diff --git a/packages/cli/src/local-adapters.ts b/packages/cli/src/local-adapters.ts index cfc57adc..0cd2d940 100644 --- a/packages/cli/src/local-adapters.ts +++ b/packages/cli/src/local-adapters.ts @@ -12,7 +12,7 @@ import { isKtxSqliteConnectionConfig } from './connectors/sqlite/connector.js'; import { createSqlServerLiveDatabaseIntrospection } from './connectors/sqlserver/live-database-introspection.js'; import { isKtxSqlServerConnectionConfig } from './connectors/sqlserver/connector.js'; import { BigQueryHistoricSqlQueryHistoryReader } from './context/ingest/adapters/historic-sql/bigquery-query-history-reader.js'; -import { queryHistoryDialectForConnection } from './context/ingest/adapters/historic-sql/connection-dialect.js'; +import { historicSqlDialectForConnectionDriver } from './context/ingest/adapters/historic-sql/connection-dialect.js'; import { createDaemonLiveDatabaseIntrospection } from './context/ingest/adapters/live-database/daemon-introspection.js'; import { createDefaultLocalIngestAdapters, type DefaultLocalIngestAdaptersOptions } from './context/ingest/local-adapters.js'; import type { HistoricSqlReader } from './context/ingest/adapters/historic-sql/types.js'; @@ -268,7 +268,12 @@ function historicSqlOptionsForLocalRun(project: KtxLocalProject, options: KtxCli return undefined; } const connection = project.config.connections[connectionId]; - const dialect = queryHistoryDialectForConnection(connection); + // historicSqlConnectionId is only set when query history was explicitly + // requested for this run (e.g. `--query-history`), so resolve the dialect from + // driver capability rather than the persisted context.queryHistory.enabled + // flag — otherwise the adapter is missing and findAdapter('historic-sql') + // throws even though the run asked for it. + const dialect = historicSqlDialectForConnectionDriver(connection); if (!dialect) { return undefined; } diff --git a/packages/cli/test/context/ingest/adapters/historic-sql/connection-dialect.test.ts b/packages/cli/test/context/ingest/adapters/historic-sql/connection-dialect.test.ts index 8dc2ec88..935bab8e 100644 --- a/packages/cli/test/context/ingest/adapters/historic-sql/connection-dialect.test.ts +++ b/packages/cli/test/context/ingest/adapters/historic-sql/connection-dialect.test.ts @@ -1,5 +1,8 @@ import { describe, expect, it } from 'vitest'; -import { queryHistoryDialectForConnection } from '../../../../../src/context/ingest/adapters/historic-sql/connection-dialect.js'; +import { + historicSqlDialectForConnectionDriver, + queryHistoryDialectForConnection, +} from '../../../../../src/context/ingest/adapters/historic-sql/connection-dialect.js'; describe('queryHistoryDialectForConnection', () => { it.each([ @@ -21,3 +24,19 @@ describe('queryHistoryDialectForConnection', () => { expect(queryHistoryDialectForConnection({ driver: 'postgres', context: { queryHistory: { enabled: false } } })).toBeNull(); }); }); + +describe('historicSqlDialectForConnectionDriver', () => { + it('resolves the dialect from driver capability even when query history is disabled', () => { + expect( + historicSqlDialectForConnectionDriver({ driver: 'postgres', context: { queryHistory: { enabled: false } } }), + ).toBe('postgres'); + }); + + it('resolves the dialect when no query-history context is present', () => { + expect(historicSqlDialectForConnectionDriver({ driver: 'bigquery' })).toBe('bigquery'); + }); + + it('returns null for drivers without a historic-SQL reader', () => { + expect(historicSqlDialectForConnectionDriver({ driver: 'mysql', context: { queryHistory: { enabled: true } } })).toBeNull(); + }); +}); diff --git a/packages/cli/test/context/ingest/adapters/historic-sql/stage-unified.test.ts b/packages/cli/test/context/ingest/adapters/historic-sql/stage-unified.test.ts index b930d695..630a3939 100644 --- a/packages/cli/test/context/ingest/adapters/historic-sql/stage-unified.test.ts +++ b/packages/cli/test/context/ingest/adapters/historic-sql/stage-unified.test.ts @@ -433,4 +433,88 @@ describe('stageHistoricSqlAggregatedSnapshot', () => { const manifest = await readJson>(stagedDir, 'manifest.json'); expect(manifest.warnings).toEqual([]); }); + + it("drops ktx's own scan/relationship probes from query history", async () => { + const stagedDir = await tempDir(); + const fkOverlapProbe = + 'select * from (WITH child_values AS ( SELECT DISTINCT "account_id" AS value FROM "account_owners" WHERE "account_id" IS NOT NULL LIMIT $1 ), parent_values AS ( SELECT DISTINCT "account_id" AS value FROM "accounts" WHERE "account_id" IS NOT NULL ) SELECT (SELECT COUNT(*) FROM child_values) AS child_distinct, (SELECT COUNT(*) FROM parent_values) AS parent_distinct) probe'; + const profileProbe = + 'select * from (SELECT $1 AS column_name, (SELECT COUNT(*) FROM "orbit_raw"."accounts") AS total, (SELECT STRING_AGG(CAST(value AS TEXT), CHR(31)) FROM (SELECT DISTINCT "id" AS value FROM "orbit_raw"."accounts" LIMIT $2) AS relationship_profile_values) AS samples) profile'; + const reader: HistoricSqlReader = { + async probe() { + return { warnings: [], info: [] }; + }, + async *fetchAggregated() { + yield aggregate({ + templateId: 'analytic', + canonicalSql: 'select status, count(*) from public.orders group by status', + }); + yield aggregate({ templateId: 'ktx-fk-overlap', canonicalSql: fkOverlapProbe }); + yield aggregate({ templateId: 'ktx-profile', canonicalSql: profileProbe }); + }, + }; + const sqlAnalysis: SqlAnalysisPort = { + analyzeForFingerprint: vi.fn(), + analyzeBatch: vi.fn(async () => new Map([ + ['analytic', { tablesTouched: ['public.orders'], columnsByClause: { select: ['status'], where: [], join: [], groupBy: ['status'] } }], + ])), + validateReadOnly: vi.fn(async () => ({ ok: true })), + }; + + await stageHistoricSqlAggregatedSnapshot({ + stagedDir, + connectionId: 'warehouse', + queryClient: {}, + reader, + sqlAnalysis, + pullConfig: { dialect: 'postgres' }, + now: new Date('2026-05-11T12:00:00.000Z'), + }); + + // ktx scan probes are filtered before SQL analysis, so only the analytic query is parsed. + expect(sqlAnalysis.analyzeBatch).toHaveBeenCalledWith( + [{ id: 'analytic', sql: 'select status, count(*) from public.orders group by status' }], + 'postgres', + ); + expect(await readdir(join(stagedDir, 'tables'))).toEqual(['public.orders.json']); + }); + + it('merges bare and schema-qualified references to the same table into one work unit', async () => { + const stagedDir = await tempDir(); + const reader: HistoricSqlReader = { + async probe() { + return { warnings: [], info: [] }; + }, + async *fetchAggregated() { + yield aggregate({ templateId: 'qualified', canonicalSql: 'select count(*) from orbit_raw.accounts' }); + yield aggregate({ templateId: 'bare', canonicalSql: 'select id from accounts where active' }); + }, + }; + const sqlAnalysis: SqlAnalysisPort = { + analyzeForFingerprint: vi.fn(), + analyzeBatch: vi.fn(async () => new Map([ + ['qualified', { tablesTouched: ['orbit_raw.accounts'], columnsByClause: { select: [], where: [], join: [], groupBy: [] } }], + ['bare', { tablesTouched: ['accounts'], columnsByClause: { select: ['id'], where: ['active'], join: [], groupBy: [] } }], + ])), + validateReadOnly: vi.fn(async () => ({ ok: true })), + }; + + await stageHistoricSqlAggregatedSnapshot({ + stagedDir, + connectionId: 'warehouse', + queryClient: {}, + reader, + sqlAnalysis, + pullConfig: { dialect: 'postgres' }, + now: new Date('2026-05-11T12:00:00.000Z'), + }); + + // The bare `accounts` reference resolves to the unique qualified `orbit_raw.accounts`, + // so the two templates collapse into a single work unit instead of two. + expect(await readdir(join(stagedDir, 'tables'))).toEqual(['orbit_raw.accounts.json']); + const merged = await readJson>(stagedDir, 'tables/orbit_raw.accounts.json'); + expect(merged.topTemplates.map((t: any) => t.id).sort()).toEqual(['bare', 'qualified']); + const manifest = await readJson>(stagedDir, 'manifest.json'); + expect(manifest.touchedTableCount).toBe(1); + }); }); diff --git a/packages/cli/test/context/ingest/isolated-diff/patch-integrator.test.ts b/packages/cli/test/context/ingest/isolated-diff/patch-integrator.test.ts index 1deabfe8..e822472d 100644 --- a/packages/cli/test/context/ingest/isolated-diff/patch-integrator.test.ts +++ b/packages/cli/test/context/ingest/isolated-diff/patch-integrator.test.ts @@ -401,4 +401,72 @@ describe('integrateWorkUnitPatch', () => { }); await expect(readFile(join(configDir, 'wiki/global/a.md'), 'utf-8')).resolves.toBe('old\n'); }); + + it('repairs a semantic gate failure after a textual conflict is resolved', async () => { + const { homeDir, configDir, git } = await makeRepo(); + await mkdir(join(configDir, 'wiki/global'), { recursive: true }); + await writeFile(join(configDir, 'wiki/global/a.md'), 'base\n', 'utf-8'); + await git.commitFiles(['wiki/global/a.md'], 'base page', 'System User', 'system@example.com'); + const conflictBase = await git.revParseHead(); + + await writeFile(join(configDir, 'wiki/global/a.md'), 'accepted\n', 'utf-8'); + await git.commitFiles(['wiki/global/a.md'], 'accepted edit', 'System User', 'system@example.com'); + + const childDir = join(homeDir, 'child-conflict-repair'); + await git.addWorktree(childDir, 'child-conflict-repair', conflictBase); + const childGit = git.forWorktree(childDir); + await writeFile(join(childDir, 'wiki/global/a.md'), 'proposal\n', 'utf-8'); + await childGit.commitFiles(['wiki/global/a.md'], 'proposal edit', 'System User', 'system@example.com'); + const patchPath = join(homeDir, 'proposal-repair.patch'); + await childGit.writeBinaryNoRenamePatch(conflictBase, 'HEAD', patchPath); + + const trace = new FileIngestTraceWriter({ + tracePath: join(homeDir, '.ktx/ingest-traces/job-resolver-repair/trace.jsonl'), + jobId: 'job-resolver-repair', + connectionId: 'warehouse', + sourceKey: 'metabase', + level: 'trace', + }); + + // Gate fails on the resolver's merged tree, then passes after the repair edit. + const validateAppliedTree = vi + .fn() + .mockRejectedValueOnce( + new Error('final artifact gates failed:\narr-definition: unknown sl_refs entity mart_arr_daily.arr_dollars'), + ) + .mockResolvedValueOnce(undefined); + + const repairGateFailure = vi.fn(async (context: { unitKey: string; touchedPaths: string[] }) => { + expect(context).toMatchObject({ unitKey: 'wu-conflict-repair', touchedPaths: ['wiki/global/a.md'] }); + await writeFile(join(configDir, 'wiki/global/a.md'), 'accepted\nproposal repaired\n', 'utf-8'); + return { status: 'repaired' as const, attempts: 1, changedPaths: ['wiki/global/a.md'] }; + }); + + const result = await integrateWorkUnitPatch({ + unitKey: 'wu-conflict-repair', + patchPath, + integrationGit: git, + trace, + author: { name: 'System User', email: 'system@example.com' }, + slDisallowed: false, + allowedTargetConnectionIds: new Set(['warehouse']), + validateAppliedTree, + resolveTextualConflict: vi.fn(async () => { + await writeFile(join(configDir, 'wiki/global/a.md'), 'accepted\nproposal\n', 'utf-8'); + return { status: 'repaired' as const, attempts: 1, changedPaths: ['wiki/global/a.md'] }; + }), + repairGateFailure, + }); + + expect(result).toMatchObject({ + status: 'accepted', + touchedPaths: ['wiki/global/a.md'], + textualResolution: { status: 'repaired' }, + gateRepair: { status: 'repaired', attempts: 1, changedPaths: ['wiki/global/a.md'] }, + }); + expect(validateAppliedTree).toHaveBeenCalledTimes(2); + expect(repairGateFailure).toHaveBeenCalledOnce(); + await expect(readFile(join(configDir, 'wiki/global/a.md'), 'utf-8')).resolves.toBe('accepted\nproposal repaired\n'); + await expect(readFile(trace.tracePath, 'utf-8')).resolves.toContain('patch_accepted_after_textual_resolution'); + }); }); diff --git a/packages/cli/test/local-adapters.test.ts b/packages/cli/test/local-adapters.test.ts index c7ae58cc..345f662a 100644 --- a/packages/cli/test/local-adapters.test.ts +++ b/packages/cli/test/local-adapters.test.ts @@ -70,6 +70,37 @@ describe('CLI local ingest adapters', () => { ]); }); + it('registers historic SQL when explicitly requested even if connection query history is disabled', async () => { + await writeProject( + tempDir, + [ + 'connections:', + ' warehouse:', + ' driver: postgres', + ' url: env:WAREHOUSE_DATABASE_URL', + ' readonly: true', + ' context:', + ' queryHistory:', + ' enabled: false', + 'ingest:', + ' adapters:', + ' - historic-sql', + '', + ].join('\n'), + ); + const project = await loadKtxProject({ projectDir: tempDir }); + + // `--query-history` sets historicSqlConnectionId for the run; that explicit + // request is the opt-in, so the persisted context.queryHistory.enabled flag + // must not gate adapter registration. + const adapters = createKtxCliLocalIngestAdapters(project, { + historicSqlConnectionId: 'warehouse', + sqlAnalysis: sqlAnalysisStub(), + }); + + expect(adapters.some((adapter) => adapter.source === 'historic-sql')).toBe(true); + }); + it('registers BigQuery historic SQL from the requested connection', async () => { await writeProject( tempDir, From ce1516b357807874902d189d1d163755634083e8 Mon Sep 17 00:00:00 2001 From: Andrey Avtomonov Date: Wed, 3 Jun 2026 13:08:46 +0200 Subject: [PATCH 12/25] feat(cli): consistent connection setup recovery and build-time gate (#257) * feat(cli): block context build when a required connection fails its live test A context build can take several minutes, so a connection that is unreachable or misconfigured should stop the build up front instead of failing partway through. Before the build starts, run a live connection test for every primary- and context-source connection the build depends on. Each test's output is captured in a discarded buffer so raw error text (and database paths) never reach the user; failures are surfaced only by connection id and connector type, with a pointer to `ktx connection test ` for the underlying error. - Interactive setup lets the user fix the connection and retry without restarting, re-resolving targets so an added/removed/reconfigured connection is honored. - `--no-input` exits non-zero and writes a failed context state with a failureReason, so scripts stop early and setup never reads as ready. Extract the buffered command IO helper out of setup-databases into src/io/buffered-command-io.ts so both call sites share one implementation. * feat(cli): use recovery primitive for database setup * feat(cli): use recovery primitive for source setup * docs: document setup connection recovery * fix(cli): close database recovery gaps * fix(cli): target failing project in gate hint and preserve missing-input Address two review findings on the connection-recovery work: - The connection-gate failure hint emitted `ktx connection test ` with no --project-dir, so a setup run started with `--project-dir ./analytics` pointed users at cwd/KTX_PROJECT_DIR instead of the project that just failed. Emit the resolved project dir, matching the contextBuildCommands convention. - The non-interactive database configure path returned `cancelled`, which the recovery primitive collapses to `failed`. Sibling paths still report `missing-input` for absent flags, so incomplete-flag runs were indistinguishable from real connection failures. The database wrapper now tracks the configure missing-input signal and restores the `missing-input` step status; the shared primitive keeps its four outcomes. --- .../docs/cli-reference/ktx-connection.mdx | 4 +- .../docs/getting-started/quickstart.mdx | 23 +- packages/cli/src/connection-recovery.ts | 132 +++++ packages/cli/src/io/buffered-command-io.ts | 35 ++ packages/cli/src/setup-context.ts | 191 ++++++- packages/cli/src/setup-databases.ts | 517 +++++++++--------- packages/cli/src/setup-sources.ts | 222 ++++++-- packages/cli/test/connection-recovery.test.ts | 171 ++++++ packages/cli/test/setup-context.test.ts | 117 +++- packages/cli/test/setup-databases.test.ts | 300 +++++++++- packages/cli/test/setup-sources.test.ts | 173 +++++- 11 files changed, 1531 insertions(+), 354 deletions(-) create mode 100644 packages/cli/src/connection-recovery.ts create mode 100644 packages/cli/src/io/buffered-command-io.ts create mode 100644 packages/cli/test/connection-recovery.test.ts diff --git a/docs-site/content/docs/cli-reference/ktx-connection.mdx b/docs-site/content/docs/cli-reference/ktx-connection.mdx index 36185d68..9d78bdd8 100644 --- a/docs-site/content/docs/cli-reference/ktx-connection.mdx +++ b/docs-site/content/docs/cli-reference/ktx-connection.mdx @@ -104,6 +104,6 @@ configured connection and exit non-zero if any probe fails. | Error | Cause | Recovery | |-------|-------|----------| | No connections configured | The project has no entries under `connections` | Run `ktx setup` and add a database or context-source connection | -| Connection test fails | Credentials, network access, database, warehouse, or schema is invalid | Verify the same URL with the database's native client, then rerun `ktx setup` and reconfigure the connection | -| Mapping validation fails during setup | BI database mappings do not point at valid warehouse connections | Rerun `ktx setup` and update the context-source mapping selections | +| Connection test fails | Credentials, network access, database, warehouse, or schema is invalid | Use the setup recovery menu to retry or re-enter details; if it still fails, verify the same URL with the database's native client | +| Mapping validation fails during setup | BI database mappings do not point at valid warehouse connections | Use the setup recovery menu to retry validation or re-enter mapping selections; rerun `ktx setup` if you already exited | | Notion page picker cannot run | The terminal is non-interactive or Notion discovery failed | Rerun interactive `ktx setup`, or use non-interactive setup flags with explicit root page ids | diff --git a/docs-site/content/docs/getting-started/quickstart.mdx b/docs-site/content/docs/getting-started/quickstart.mdx index 35ec6009..abd6044d 100644 --- a/docs-site/content/docs/getting-started/quickstart.mdx +++ b/docs-site/content/docs/getting-started/quickstart.mdx @@ -295,6 +295,26 @@ Context sources: dbt_main: memory update complete ``` +Before the build starts, **ktx** runs a live test for every connection the +build depends on. A context build can take several minutes, so if any required +connection is unreachable or misconfigured the build is blocked up front and +**ktx** names the failing connection by id and connector type: + +```text +KTX cannot build context: a required connection failed its live test. + +Failed connections: + warehouse (postgres) + +Each connection must be reachable before KTX builds context. +Run `ktx connection test ` to see the error, fix the connection, then retry. +``` + +Run `ktx connection test ` to see the underlying error, fix the +connection, then continue. In interactive setup you can retry without +restarting; with `--no-input` the build exits non-zero and names the failing +connection so scripts can stop early. + ## Connect a coding agent The setup wizard installs project-local agent rules in the last step. To @@ -354,7 +374,8 @@ surface. | `ktx: command not found` | Reinstall `@kaelio/ktx` and open a new shell | | Setup resumes the wrong project | Pass `--project-dir ` | | LLM or embeddings health check fails | Rerun setup and pick a different credential, model, or backend | -| Database test fails | Verify the same connection with the database's native client, then rerun setup | +| Database test fails | Use the setup recovery menu to retry or re-enter details; if it still fails, verify the same connection with the database's native client | +| Context build blocked: a connection failed its live test | Run `ktx connection test ` to see the error, fix the connection, then retry the build | | Agent integration is incomplete | Run `ktx setup --agents --target ` | ## Next steps diff --git a/packages/cli/src/connection-recovery.ts b/packages/cli/src/connection-recovery.ts new file mode 100644 index 00000000..2cd87448 --- /dev/null +++ b/packages/cli/src/connection-recovery.ts @@ -0,0 +1,132 @@ +import type { KtxCliIo } from './cli-runtime.js'; +import type { KtxSetupPromptOption } from './setup-prompts.js'; + +export type RecoveryOutcome = 'ready' | 'skip' | 'back' | 'failed'; + +/** @internal */ +export interface RecoveryAction { + value: string; + label: string; + run: () => Promise; +} + +export type ConfigureResult = 'configured' | 'back' | 'cancelled'; + +export type ValidateResult = + | { status: 'ok' } + | { status: 'back' } + | { status: 'failed'; extraActions?: RecoveryAction[] }; + +export interface ConnectionRecoveryInput { + label: string; + interactive: boolean; + allowSkip: boolean; + io: KtxCliIo; + prompts: { + select(options: { message: string; options: KtxSetupPromptOption[] }): Promise; + }; + snapshot: () => Promise<() => Promise>; + configure: () => Promise; + validate: () => Promise; +} + +async function runRollbackOnce(input: { + rollback: () => Promise; + state: { rolledBack: boolean }; +}): Promise { + if (input.state.rolledBack) { + return; + } + input.state.rolledBack = true; + await input.rollback(); +} + +function recoveryOptions(input: { + allowSkip: boolean; + extraActions?: RecoveryAction[]; +}): KtxSetupPromptOption[] { + return [ + { value: 'retry', label: 'Retry connection test' }, + { value: 're-enter', label: 'Re-enter connection details' }, + ...(input.extraActions ?? []).map((action) => ({ + value: action.value, + label: action.label, + })), + ...(input.allowSkip ? [{ value: 'skip', label: 'Skip this connection' }] : []), + { value: 'back', label: 'Back' }, + ]; +} + +export async function runConnectionSetupWithRecovery( + input: ConnectionRecoveryInput, +): Promise { + const rollback = await input.snapshot(); + const rollbackState = { rolledBack: false }; + + const firstConfig = await input.configure(); + if (firstConfig === 'back') { + await runRollbackOnce({ rollback, state: rollbackState }); + return 'back'; + } + if (firstConfig === 'cancelled') { + await runRollbackOnce({ rollback, state: rollbackState }); + return 'failed'; + } + + let validation = await input.validate(); + while (validation.status !== 'ok') { + if (validation.status === 'back') { + await runRollbackOnce({ rollback, state: rollbackState }); + return 'back'; + } + + if (!input.interactive) { + return 'failed'; + } + + const action = await input.prompts.select({ + message: `Connection setup failed for ${input.label}`, + options: recoveryOptions({ + allowSkip: input.allowSkip, + extraActions: validation.extraActions, + }), + }); + + if (action === 'back') { + await runRollbackOnce({ rollback, state: rollbackState }); + return 'back'; + } + if (action === 'skip' && input.allowSkip) { + await runRollbackOnce({ rollback, state: rollbackState }); + return 'skip'; + } + if (action === 're-enter') { + const nextConfig = await input.configure(); + if (nextConfig === 'back') { + await runRollbackOnce({ rollback, state: rollbackState }); + return 'back'; + } + if (nextConfig === 'cancelled') { + await runRollbackOnce({ rollback, state: rollbackState }); + return 'failed'; + } + validation = await input.validate(); + continue; + } + if (action === 'retry') { + validation = await input.validate(); + continue; + } + + const extraAction = validation.extraActions?.find((candidate) => candidate.value === action); + if (extraAction) { + await extraAction.run(); + validation = await input.validate(); + continue; + } + + validation = await input.validate(); + } + + return 'ready'; +} diff --git a/packages/cli/src/io/buffered-command-io.ts b/packages/cli/src/io/buffered-command-io.ts new file mode 100644 index 00000000..6d16f385 --- /dev/null +++ b/packages/cli/src/io/buffered-command-io.ts @@ -0,0 +1,35 @@ +import type { KtxCliIo } from '../cli-runtime.js'; + +export interface BufferedCommandIo extends KtxCliIo { + stdoutText(): string; + stderrText(): string; +} + +/** + * Captures stdout/stderr from a command (e.g. `runKtxConnection`) into buffers + * instead of the terminal. Callers decide whether to flush the captured text to + * the user or discard it. + */ +export function createBufferedCommandIo(): BufferedCommandIo { + let stdout = ''; + let stderr = ''; + return { + stdout: { + isTTY: false, + write(chunk: string) { + stdout += chunk; + }, + }, + stderr: { + write(chunk: string) { + stderr += chunk; + }, + }, + stdoutText() { + return stdout; + }, + stderrText() { + return stderr; + }, + }; +} diff --git a/packages/cli/src/setup-context.ts b/packages/cli/src/setup-context.ts index 721c09bd..be458d2a 100644 --- a/packages/cli/src/setup-context.ts +++ b/packages/cli/src/setup-context.ts @@ -8,6 +8,8 @@ import type { KtxCliIo } from './cli-runtime.js'; import { errorMessage, writePrefixedLines } from './clack.js'; import { formatErrorDetail } from './telemetry/scrubber.js'; import { buildPublicIngestPlan } from './public-ingest.js'; +import { runKtxConnection } from './connection.js'; +import { type BufferedCommandIo, createBufferedCommandIo } from './io/buffered-command-io.js'; import type { KtxManagedPythonInstallPolicy } from './managed-python-command.js'; import { type ContextBuildSourceProgressUpdate, @@ -91,6 +93,7 @@ export interface KtxSetupContextDeps { now?: () => Date; runContextBuild?: typeof runContextBuild; verifyContextReady?: (projectDir: string) => Promise; + testConnection?: (projectDir: string, connectionId: string, io: KtxCliIo) => Promise; } interface KtxSetupContextTargets { @@ -277,6 +280,140 @@ function listContextTargets(project: KtxLocalProject): KtxSetupContextTargets { }; } +interface ConnectionGateFailure { + connectionId: string; + driver: string; +} + +type ConnectionGateResult = { ok: true } | { ok: false; failures: ConnectionGateFailure[] }; + +type PreparedBuild = + | { kind: 'ready'; project: KtxLocalProject; targets: KtxSetupContextTargets } + | { kind: 'result'; result: KtxSetupContextResult }; + +function requiredConnectionIds(targets: KtxSetupContextTargets): string[] { + return [...targets.primarySourceConnectionIds, ...targets.contextSourceConnectionIds]; +} + +function connectorTypeLabel(project: KtxLocalProject, connectionId: string): string { + const driver = String(project.config.connections[connectionId]?.driver ?? '') + .trim() + .toLowerCase(); + return driver.length > 0 ? driver : 'unknown'; +} + +async function defaultGateTestConnection( + projectDir: string, + connectionId: string, + io: KtxCliIo, +): Promise { + return await runKtxConnection({ command: 'test', projectDir, connectionId }, io); +} + +/** + * Runs a live connection test for every connection the build depends on. Each + * test's output is captured in a buffer and discarded so raw error text never + * reaches the user — callers surface only the connection id and connector type. + */ +async function testRequiredConnections( + projectDir: string, + project: KtxLocalProject, + targets: KtxSetupContextTargets, + testConnection: (projectDir: string, connectionId: string, io: KtxCliIo) => Promise, +): Promise { + const failures: ConnectionGateFailure[] = []; + for (const connectionId of requiredConnectionIds(targets)) { + const buffered: BufferedCommandIo = createBufferedCommandIo(); + const exitCode = await testConnection(projectDir, connectionId, buffered); + if (exitCode !== 0) { + failures.push({ connectionId, driver: connectorTypeLabel(project, connectionId) }); + } + } + return failures.length === 0 ? { ok: true } : { ok: false, failures }; +} + +/** + * Loads the project and resolves the connections the build depends on, applying + * the empty-targets and preflight-capability checks. Used both on first entry + * and on interactive retry so a fix that adds, removes, or reconfigures a + * connection is honored. + */ +async function prepareBuildTargets(args: KtxSetupContextStepArgs, io: KtxCliIo): Promise { + const project = await loadKtxProject({ projectDir: args.projectDir }); + const targets = listContextTargets(project); + if (targets.primarySourceConnectionIds.length === 0 && targets.contextSourceConnectionIds.length === 0) { + if (args.allowEmpty === true) { + return { kind: 'result', result: { status: 'skipped', projectDir: args.projectDir } }; + } + io.stderr.write('No databases or context sources are configured for a KTX context build.\n'); + return { kind: 'result', result: { status: 'failed', projectDir: args.projectDir } }; + } + const preflightPlan = buildPublicIngestPlan(project, { projectDir: project.projectDir, all: true }); + const preflightFailures = preflightPlan.targets.flatMap((target) => + target.preflightFailure ? [`${target.connectionId}: ${target.preflightFailure}`] : [], + ); + if (preflightFailures.length > 0) { + if (args.allowEmpty === true) { + return { kind: 'result', result: { status: 'skipped', projectDir: args.projectDir } }; + } + writeMissingCapabilities(preflightFailures, io); + return { kind: 'result', result: { status: 'missing-input', projectDir: args.projectDir } }; + } + return { kind: 'ready', project, targets }; +} + +function writeConnectionGateFailureLines( + io: KtxCliIo, + projectDir: string, + failures: ConnectionGateFailure[], +): void { + io.stderr.write('KTX cannot build context: a required connection failed its live test.\n\n'); + io.stderr.write('Failed connections:\n'); + for (const failure of failures) { + io.stderr.write(` ${failure.connectionId} (${failure.driver})\n`); + } + io.stderr.write('\nEach connection must be reachable before KTX builds context.\n'); + io.stderr.write( + `Run \`ktx connection test --project-dir ${resolve(projectDir)}\` to see the error, fix the connection, then retry.\n`, + ); +} + +function connectionGateFailureReason(failures: ConnectionGateFailure[]): string { + const names = failures.map((failure) => `${failure.connectionId} (${failure.driver})`).join(', '); + return `Required connections failed their live test: ${names}.`; +} + +async function writeConnectionGateFailedState( + args: KtxSetupContextStepArgs, + deps: KtxSetupContextDeps, + targets: KtxSetupContextTargets, + failures: ConnectionGateFailure[], +): Promise { + const at = (deps.now ?? (() => new Date()))().toISOString(); + await writeKtxSetupContextState(args.projectDir, { + status: 'failed', + startedAt: at, + updatedAt: at, + primarySourceConnectionIds: targets.primarySourceConnectionIds, + contextSourceConnectionIds: targets.contextSourceConnectionIds, + reportIds: [], + artifactPaths: [], + retryableFailedTargets: [], + commands: contextBuildCommands(args.projectDir), + failureReason: connectionGateFailureReason(failures), + }); +} + +async function promptConnectionGateRetry(prompts: KtxSetupContextPromptAdapter): Promise<'retry' | 'back'> { + return (await prompts.select({ + message: 'Fix the failing connection, then choose how to proceed.', + options: [ + { value: 'retry', label: 'Retry connection tests' }, + { value: 'back', label: 'Back' }, + ], + })) as 'retry' | 'back'; +} + async function hasFileWithExtension( root: string, extensions: Set, @@ -641,7 +778,6 @@ export async function runKtxSetupContextStep( deps: KtxSetupContextDeps = {}, ): Promise { try { - const project = await loadKtxProject({ projectDir: args.projectDir }); const prompts = deps.prompts ?? createPromptAdapter(); const existingState = await readKtxSetupContextState(args.projectDir); const completedSteps = (await readKtxSetupState(args.projectDir)).completed_steps; @@ -659,26 +795,12 @@ export async function runKtxSetupContextStep( io.stdout.write('Previous context build state is stale; starting a fresh foreground build.\n'); } - const targets = listContextTargets(project); - if (targets.primarySourceConnectionIds.length === 0 && targets.contextSourceConnectionIds.length === 0) { - if (args.allowEmpty === true) { - return { status: 'skipped', projectDir: args.projectDir }; - } - io.stderr.write('No databases or context sources are configured for a KTX context build.\n'); - return { status: 'failed', projectDir: args.projectDir }; - } - - const preflightPlan = buildPublicIngestPlan(project, { projectDir: project.projectDir, all: true }); - const preflightFailures = preflightPlan.targets.flatMap((target) => - target.preflightFailure ? [`${target.connectionId}: ${target.preflightFailure}`] : [], - ); - if (preflightFailures.length > 0) { - if (args.allowEmpty === true) { - return { status: 'skipped', projectDir: args.projectDir }; - } - writeMissingCapabilities(preflightFailures, io); - return { status: 'missing-input', projectDir: args.projectDir }; + const prepared = await prepareBuildTargets(args, io); + if (prepared.kind === 'result') { + return prepared.result; } + let { project, targets } = prepared; + const interactive = args.inputMode !== 'disabled' && args.prompt !== false; if (args.forcePrompt !== true && args.prompt !== false && deps.verifyContextReady === undefined) { const existingContextResult = await completeExistingContext(args, io, deps, targets); @@ -687,7 +809,7 @@ export async function runKtxSetupContextStep( } } - if (args.inputMode !== 'disabled' && args.prompt !== false) { + if (interactive) { const choice = await promptForBuild(prompts); if (choice === 'back') { return { status: 'back', projectDir: args.projectDir }; @@ -698,7 +820,32 @@ export async function runKtxSetupContextStep( } } - return await runBuild(args, io, deps, project, targets); + // Live-connection gate: every connection the build depends on must pass a + // live test before the (expensive) build starts. A red connection is a hard + // stop — we surface only the connection id and connector type, never raw + // error text. + const testConnection = deps.testConnection ?? defaultGateTestConnection; + while (true) { + const gate = await testRequiredConnections(args.projectDir, project, targets, testConnection); + if (gate.ok) { + return await runBuild(args, io, deps, project, targets); + } + writeConnectionGateFailureLines(io, args.projectDir, gate.failures); + if (!interactive) { + await writeConnectionGateFailedState(args, deps, targets, gate.failures); + return { status: 'failed', projectDir: args.projectDir }; + } + const choice = await promptConnectionGateRetry(prompts); + if (choice === 'back') { + return { status: 'back', projectDir: args.projectDir }; + } + const reprepared = await prepareBuildTargets(args, io); + if (reprepared.kind === 'result') { + return reprepared.result; + } + project = reprepared.project; + targets = reprepared.targets; + } } catch (error) { writePrefixedLines((chunk) => io.stderr.write(chunk), errorMessage(error)); return { status: 'failed', projectDir: args.projectDir, errorDetail: formatErrorDetail(error) }; diff --git a/packages/cli/src/setup-databases.ts b/packages/cli/src/setup-databases.ts index c2417031..1fd93486 100644 --- a/packages/cli/src/setup-databases.ts +++ b/packages/cli/src/setup-databases.ts @@ -22,6 +22,13 @@ import { writePrefixedLines, } from './clack.js'; import { runKtxConnection } from './connection.js'; +import { createBufferedCommandIo } from './io/buffered-command-io.js'; +import { + runConnectionSetupWithRecovery, + type ConfigureResult, + type RecoveryOutcome, + type ValidateResult, +} from './connection-recovery.js'; import { pickDatabaseScope as defaultPickDatabaseScope, type DatabaseScopePickResult, @@ -227,7 +234,6 @@ const SCOPE_DISCOVERY_SPECS: Partial; -type ConnectionSetupStatus = 'ready' | 'back' | 'failed' | 'failed-query-history-unavailable'; const DRIVER_CONNECTION_DEFAULTS: Record = { postgres: { port: '5432' }, @@ -994,35 +1000,6 @@ async function defaultScanConnection(projectDir: string, connectionId: string, i ); } -interface BufferedCommandIo extends KtxCliIo { - stdoutText(): string; - stderrText(): string; -} - -function createBufferedCommandIo(): BufferedCommandIo { - let stdout = ''; - let stderr = ''; - return { - stdout: { - isTTY: false, - write(chunk: string) { - stdout += chunk; - }, - }, - stderr: { - write(chunk: string) { - stderr += chunk; - }, - }, - stdoutText() { - return stdout; - }, - stderrText() { - return stderr; - }, - }; -} - function envWithCurrentNodeFirst(env: NodeJS.ProcessEnv = process.env): NodeJS.ProcessEnv { return { ...env, @@ -1203,6 +1180,31 @@ async function disableConnectionQueryHistory(projectDir: string, connectionId: s }); } +function okValidateResult(): ValidateResult { + return { status: 'ok' }; +} + +function backValidateResult(): ValidateResult { + return { status: 'back' }; +} + +function failedValidateResult(): ValidateResult { + return { status: 'failed' }; +} + +function queryHistoryUnavailableResult(projectDir: string, connectionId: string): ValidateResult { + return { + status: 'failed', + extraActions: [ + { + value: 'disable-query-history', + label: 'Disable query history and retry', + run: () => disableConnectionQueryHistory(projectDir, connectionId), + }, + ], + }; +} + async function createConnectionConfigRollback(projectDir: string, connectionId: string): Promise<() => Promise> { const project = await loadKtxProject({ projectDir }); const previousConnection = project.config.connections[connectionId]; @@ -1330,11 +1332,11 @@ async function maybeConfigureDatabaseScope(input: { io: KtxCliIo; prompts: KtxSetupDatabasesPromptAdapter; forcePrompt?: boolean; -}): Promise { +}): Promise { const project = await loadKtxProject({ projectDir: input.projectDir }); const connection = project.config.connections[input.connectionId]; const driver = normalizeDriver(connection?.driver); - if (!driver || driver === 'sqlite') return 'ready'; + if (!driver || driver === 'sqlite') return okValidateResult(); const spec = SCOPE_DISCOVERY_SPECS[driver]; const existingTables = connection?.enabled_tables; @@ -1343,7 +1345,7 @@ async function maybeConfigureDatabaseScope(input: { const hasExistingScope = !spec || existingScope.length > 0; if (hasExistingTables && hasExistingScope && input.forcePrompt !== true) { - return 'ready'; + return okValidateResult(); } const cliSchemas = input.args.databaseSchemas; @@ -1361,7 +1363,7 @@ async function maybeConfigureDatabaseScope(input: { input.io.stderr.write( `Could not discover ${spec.promptLabel.toLowerCase()} for ${input.connectionId}; ${detail}\n`, ); - return 'ready'; + return okValidateResult(); } } if (scopeToWrite.length > 0) { @@ -1377,7 +1379,7 @@ async function maybeConfigureDatabaseScope(input: { ]); } } - return 'ready'; + return okValidateResult(); } if (spec && cliSchemas.length > 0) { @@ -1413,7 +1415,7 @@ async function maybeConfigureDatabaseScope(input: { connectionId: input.connectionId, spec, }); - if (typed === undefined) return 'back'; + if (typed === undefined) return backValidateResult(); effectiveCliSchemas = typed; listedSchemas = typed; if (typed.length > 0) { @@ -1428,7 +1430,7 @@ async function maybeConfigureDatabaseScope(input: { } const schemas = unique(listedSchemas); if (spec && schemas.length === 0) { - return 'ready'; + return okValidateResult(); } const schemaSuggestion = effectiveCliSchemas.length > 0 @@ -1465,10 +1467,10 @@ async function maybeConfigureDatabaseScope(input: { ? `Could not discover tables for ${input.connectionId}; edit was not saved. ${detail}` : `Could not discover tables for ${input.connectionId}; continuing without table filter. ${detail}`, ); - return input.forcePrompt === true ? 'failed' : 'ready'; + return input.forcePrompt === true ? failedValidateResult() : okValidateResult(); } if (pickResult.kind === 'back') { - return 'back'; + return backValidateResult(); } const enabledTables = pickResult.enabledTables; const activeSchemas = pickResult.activeSchemas; @@ -1483,7 +1485,7 @@ async function maybeConfigureDatabaseScope(input: { } const refreshedProject = await loadKtxProject({ projectDir: input.projectDir }); const currentConnection = refreshedProject.config.connections[input.connectionId]; - if (!currentConnection) return 'ready'; + if (!currentConnection) return okValidateResult(); await writeConnectionConfig({ projectDir: input.projectDir, connectionId: input.connectionId, @@ -1500,7 +1502,7 @@ async function maybeConfigureDatabaseScope(input: { writeSetupSection(input.io, `Tables enabled for ${input.connectionId}`, [ `✓ ${enabledTables.length} tables enabled`, ]); - return 'ready'; + return okValidateResult(); } async function ensureHistoricSqlIngestDefaults(projectDir: string): Promise { @@ -1628,7 +1630,7 @@ async function validateAndScanConnection(input: { args: KtxSetupDatabasesArgs; prompts: KtxSetupDatabasesPromptAdapter; forceScopeAndTables?: boolean; -}): Promise { +}): Promise { const testConnection = input.deps.testConnection ?? defaultTestConnection; const scanConnection = input.deps.scanConnection ?? defaultScanConnection; const project = await loadKtxProject({ projectDir: input.projectDir }); @@ -1642,7 +1644,7 @@ async function validateAndScanConnection(input: { (chunk) => input.io.stderr.write(chunk), `Connection test failed for ${input.connectionId}.`, ); - return 'failed'; + return failedValidateResult(); } const testOutput = testIo.stdoutText(); const outputDriver = normalizeDriver(readOutputValue(testOutput, 'Driver')); @@ -1651,7 +1653,7 @@ async function validateAndScanConnection(input: { writeSetupSection(input.io, `Testing ${input.connectionId}`, testLines); const scopeStatus = await maybeConfigureDatabaseScope({ ...input, forcePrompt: input.forceScopeAndTables }); - if (scopeStatus !== 'ready') { + if (scopeStatus.status !== 'ok') { return scopeStatus; } @@ -1712,7 +1714,9 @@ async function validateAndScanConnection(input: { ); } if (scanCode !== 0) { - return queryHistoryAvailable ? 'failed' : 'failed-query-history-unavailable'; + return queryHistoryAvailable + ? failedValidateResult() + : queryHistoryUnavailableResult(input.projectDir, input.connectionId); } } const scanOutput = scanIo.stdoutText(); @@ -1724,7 +1728,7 @@ async function validateAndScanConnection(input: { writeSetupSection(input.io, 'Database ready', [ `${input.connectionId} · ${driverDisplay} · schema context complete`, ]); - return 'ready'; + return okValidateResult(); } async function chooseDrivers( @@ -1847,6 +1851,149 @@ async function choosePrimarySourceToEdit(input: { return choice === 'back' ? 'back' : choice; } +async function configureDatabaseConnection(input: { + projectDir: string; + connectionId: string; + driver: KtxSetupDatabaseDriver; + args: KtxSetupDatabasesArgs; + prompts: KtxSetupDatabasesPromptAdapter; + io: KtxCliIo; + canReturnToDriverSelection: boolean; + editBaseline?: KtxProjectConnectionConfig; +}): Promise { + const project = await loadKtxProject({ projectDir: input.projectDir }); + const latestConnection = project.config.connections[input.connectionId]; + let connection = await buildConnectionConfig({ + driver: input.driver, + connectionId: input.connectionId, + args: input.args, + prompts: input.prompts, + existingConnection: latestConnection, + }); + + while (!connection && input.args.inputMode !== 'disabled') { + const action = await input.prompts.select( + missingConnectionDetailsPrompt(driverLabel(input.driver), input.canReturnToDriverSelection), + ); + if (action === 'back') { + return 'back'; + } + connection = await buildConnectionConfig({ + driver: input.driver, + connectionId: input.connectionId, + args: input.args, + prompts: input.prompts, + existingConnection: latestConnection, + }); + } + + if (connection === 'back') { + return 'back'; + } + if (!connection) { + input.io.stderr.write(`Missing connection details for ${driverLabel(input.driver)}.\n`); + return 'cancelled'; + } + + const withHistoricSql = await maybeApplyHistoricSqlConfig({ + connection, + driver: input.driver, + args: input.args, + prompts: input.prompts, + }); + if (withHistoricSql === 'back') { + return 'back'; + } + + await writeConnectionConfig({ + projectDir: input.projectDir, + connectionId: input.connectionId, + connection: input.editBaseline + ? withExistingPrimaryEditPromptDefaults({ + previous: input.editBaseline, + next: withHistoricSql, + driver: input.driver, + }) + : withHistoricSql, + io: input.io, + }); + return 'configured'; +} + +async function runDatabaseConnectionSetupWithRecovery(input: { + projectDir: string; + connectionId: string; + driver: KtxSetupDatabaseDriver; + args: KtxSetupDatabasesArgs; + prompts: KtxSetupDatabasesPromptAdapter; + io: KtxCliIo; + deps: KtxSetupDatabasesDeps; + canReturnToDriverSelection: boolean; + allowSkip: boolean; + interactive?: boolean; + forceScopeAndTables?: boolean; + editBaseline?: KtxProjectConnectionConfig; + reuseExistingOnFirstConfigure?: boolean; +}): Promise { + let configureCalls = 0; + // `configureDatabaseConnection` returns 'cancelled' only when required + // connection details are absent in non-interactive mode. The recovery + // primitive collapses that into 'failed', so we track it here to restore the + // distinct 'missing-input' outcome the surrounding step reports for + // incomplete flags (vs. a real connection/probe failure). + let sawMissingInput = false; + + const outcome = await runConnectionSetupWithRecovery({ + label: input.connectionId, + interactive: input.interactive ?? input.args.inputMode !== 'disabled', + allowSkip: input.allowSkip, + io: input.io, + prompts: input.prompts, + snapshot: () => createConnectionConfigRollback(input.projectDir, input.connectionId), + configure: async () => { + configureCalls += 1; + if (input.reuseExistingOnFirstConfigure && configureCalls === 1) { + const historicSqlResult = await applyHistoricSqlConfigToExistingConnection({ + projectDir: input.projectDir, + connectionId: input.connectionId, + args: input.args, + prompts: input.prompts, + }); + return historicSqlResult === 'back' ? 'back' : 'configured'; + } + const configured = await configureDatabaseConnection({ + projectDir: input.projectDir, + connectionId: input.connectionId, + driver: input.driver, + args: input.args, + prompts: input.prompts, + io: input.io, + canReturnToDriverSelection: input.canReturnToDriverSelection, + editBaseline: input.editBaseline, + }); + if (configured === 'cancelled') { + sawMissingInput = true; + } + return configured; + }, + validate: () => + validateAndScanConnection({ + projectDir: input.projectDir, + connectionId: input.connectionId, + io: input.io, + deps: input.deps, + args: input.args, + prompts: input.prompts, + forceScopeAndTables: input.forceScopeAndTables, + }), + }); + + if (outcome === 'failed' && sawMissingInput) { + return 'missing-input'; + } + return outcome; +} + async function runPrimarySourceFullEdit(input: { projectDir: string; connectionId: string; @@ -1854,7 +2001,7 @@ async function runPrimarySourceFullEdit(input: { prompts: KtxSetupDatabasesPromptAdapter; io: KtxCliIo; deps: KtxSetupDatabasesDeps; -}): Promise<'ready' | 'back' | 'failed'> { +}): Promise<'ready' | 'back' | 'failed' | 'missing-input'> { const project = await loadKtxProject({ projectDir: input.projectDir }); const existing = project.config.connections[input.connectionId]; const driver = normalizeDriver(existing?.driver); @@ -1866,59 +2013,21 @@ async function runPrimarySourceFullEdit(input: { return 'failed'; } - const rollback = await createConnectionConfigRollback(input.projectDir, input.connectionId); - const replacement = await buildConnectionConfig({ - driver, + const outcome = await runDatabaseConnectionSetupWithRecovery({ + projectDir: input.projectDir, connectionId: input.connectionId, - args: input.args, - prompts: input.prompts, - existingConnection: existing, - }); - if (replacement === 'back') { - await rollback(); - return 'back'; - } - if (!replacement) { - await rollback(); - return 'failed'; - } - - const withHistoricSql = await maybeApplyHistoricSqlConfig({ - connection: replacement, driver, args: input.args, prompts: input.prompts, - }); - if (withHistoricSql === 'back') { - await rollback(); - return 'back'; - } - - await writeConnectionConfig({ - projectDir: input.projectDir, - connectionId: input.connectionId, - connection: withExistingPrimaryEditPromptDefaults({ - previous: existing, - next: withHistoricSql, - driver, - }), - io: input.io, - }); - - const validated = await validateAndScanConnection({ - projectDir: input.projectDir, - connectionId: input.connectionId, io: input.io, deps: input.deps, - args: input.args, - prompts: input.prompts, + canReturnToDriverSelection: true, + allowSkip: false, forceScopeAndTables: true, + editBaseline: existing, }); - if (validated !== 'ready') { - await rollback(); - return validated === 'failed-query-history-unavailable' ? 'failed' : validated; - } - return 'ready'; + + return outcome === 'skip' ? 'back' : outcome; } export async function runKtxSetupDatabasesStep( @@ -1936,28 +2045,37 @@ export async function runKtxSetupDatabasesStep( if (args.databaseConnectionIds && args.databaseConnectionIds.length > 0) { const selectedConnectionIds: string[] = []; for (const connectionId of unique(args.databaseConnectionIds)) { - const historicSqlResult = await applyHistoricSqlConfigToExistingConnection({ - projectDir: args.projectDir, - connectionId, - args, - prompts, - }); - if (historicSqlResult === 'back') return { status: 'back', projectDir: args.projectDir }; - const setupStatus = await validateAndScanConnection({ - projectDir: args.projectDir, - connectionId, - io, - deps, - args, - prompts, - }); - if (setupStatus === 'back') { - return { status: 'back', projectDir: args.projectDir }; - } - if (setupStatus === 'failed') { + const project = await loadKtxProject({ projectDir: args.projectDir }); + const driver = normalizeDriver(project.config.connections[connectionId]?.driver); + if (!driver) { + writePrefixedLines((chunk) => io.stderr.write(chunk), `Connection "${connectionId}" is not configured.`); return { status: 'failed', projectDir: args.projectDir }; } - selectedConnectionIds.push(connectionId); + const setupOutcome = await runDatabaseConnectionSetupWithRecovery({ + projectDir: args.projectDir, + connectionId, + driver, + args, + prompts, + io, + deps, + canReturnToDriverSelection: false, + allowSkip: false, + interactive: false, + reuseExistingOnFirstConfigure: true, + }); + if (setupOutcome === 'back') { + return { status: 'back', projectDir: args.projectDir }; + } + if (setupOutcome === 'missing-input') { + return { status: 'missing-input', projectDir: args.projectDir }; + } + if (setupOutcome === 'failed') { + return { status: 'failed', projectDir: args.projectDir }; + } + if (setupOutcome === 'ready') { + selectedConnectionIds.push(connectionId); + } } await markDatabasesComplete(args.projectDir, selectedConnectionIds); return { status: 'ready', projectDir: args.projectDir, connectionIds: selectedConnectionIds }; @@ -2009,6 +2127,9 @@ export async function runKtxSetupDatabasesStep( showConfiguredPrimaryMenu = true; continue; } + if (editResult === 'missing-input') { + return { status: 'missing-input', projectDir: args.projectDir }; + } if (editResult === 'failed') { return { status: 'failed', projectDir: args.projectDir }; } @@ -2064,7 +2185,6 @@ export async function runKtxSetupDatabasesStep( return { status: 'missing-input', projectDir: args.projectDir }; } - let connectionAlreadyValidated = false; if (connectionChoice.kind === 'edit') { const editResult = await runPrimarySourceFullEdit({ projectDir: args.projectDir, @@ -2079,176 +2199,41 @@ export async function runKtxSetupDatabasesStep( returnToDriverSelection = true; break; } + if (editResult === 'missing-input') { + return { status: 'missing-input', projectDir: args.projectDir }; + } if (editResult === 'failed') { return { status: 'failed', projectDir: args.projectDir }; } - connectionAlreadyValidated = true; - } else if (connectionChoice.kind === 'new') { - let connection = await buildConnectionConfig({ - driver, + } else { + const setupOutcome = await runDatabaseConnectionSetupWithRecovery({ + projectDir: args.projectDir, connectionId: connectionChoice.connectionId, + driver, args, prompts, + io, + deps, + canReturnToDriverSelection, + allowSkip: true, + reuseExistingOnFirstConfigure: connectionChoice.kind === 'existing', }); - if (connection === 'back') { + if (setupOutcome === 'back') { if (!canReturnToDriverSelection) return { status: 'back', projectDir: args.projectDir }; returnToDriverSelection = true; break; } - while (!connection && args.inputMode !== 'disabled') { - const label = driverLabel(driver); - const action = await prompts.select(missingConnectionDetailsPrompt(label, canReturnToDriverSelection)); - if (action === 'back') { - if (!canReturnToDriverSelection) return { status: 'back', projectDir: args.projectDir }; - returnToDriverSelection = true; - break; - } - connection = await buildConnectionConfig({ - driver, - connectionId: connectionChoice.connectionId, - args, - prompts, - }); - if (connection === 'back') { - if (!canReturnToDriverSelection) return { status: 'back', projectDir: args.projectDir }; - returnToDriverSelection = true; - break; - } - } - if (returnToDriverSelection) { - break; - } - if (connection === 'back') { - break; - } - if (!connection) { - io.stderr.write(`Missing connection details for ${driverLabel(driver)}.\n`); + if (setupOutcome === 'missing-input') { return { status: 'missing-input', projectDir: args.projectDir }; } - const withHistoricSql = await maybeApplyHistoricSqlConfig({ connection, driver, args, prompts }); - if (withHistoricSql === 'back') { - if (!canReturnToDriverSelection) return { status: 'back', projectDir: args.projectDir }; - returnToDriverSelection = true; - break; + if (setupOutcome === 'failed') { + return { status: 'failed', projectDir: args.projectDir }; } - await writeConnectionConfig({ - projectDir: args.projectDir, - connectionId: connectionChoice.connectionId, - connection: withHistoricSql, - io, - }); - } else { - const existing = project.config.connections[connectionChoice.connectionId]; - const withHistoricSql = await maybeApplyHistoricSqlConfig({ connection: existing, driver, args, prompts }); - if (withHistoricSql === 'back') { - if (!canReturnToDriverSelection) return { status: 'back', projectDir: args.projectDir }; - returnToDriverSelection = true; - break; - } - await writeConnectionConfig({ - projectDir: args.projectDir, - connectionId: connectionChoice.connectionId, - connection: withHistoricSql, - io, - }); - } - - let connectionSkipped = false; - let setupStatus: ConnectionSetupStatus = connectionAlreadyValidated - ? 'ready' - : await validateAndScanConnection({ - projectDir: args.projectDir, - connectionId: connectionChoice.connectionId, - io, - deps, - args, - prompts, - }); - while (!connectionAlreadyValidated && setupStatus !== 'ready') { - if (setupStatus === 'back') { - if (!canReturnToDriverSelection) return { status: 'back', projectDir: args.projectDir }; - returnToDriverSelection = true; - break; - } - if (args.inputMode === 'disabled') return { status: 'failed', projectDir: args.projectDir }; - const failureOptions = [ - { value: 'retry', label: 'Retry connection test' }, - { value: 're-enter', label: 'Re-enter connection details' }, - ...(setupStatus === 'failed-query-history-unavailable' - ? [{ value: 'disable-query-history', label: 'Disable query history and retry' }] - : []), - { value: 'skip', label: 'Skip this database' }, - { value: 'back', label: 'Back' }, - ]; - const action = await prompts.select({ - message: `Database setup failed for ${connectionChoice.connectionId}`, - options: failureOptions, - }); - if (action === 'back') { - if (!canReturnToDriverSelection) return { status: 'back', projectDir: args.projectDir }; - returnToDriverSelection = true; - break; - } - if (action === 'skip') { - connectionSkipped = true; - break; - } - if (action === 'retry') { - setupStatus = await validateAndScanConnection({ - projectDir: args.projectDir, - connectionId: connectionChoice.connectionId, - io, - deps, - args, - prompts, - }); - } else if (action === 'disable-query-history') { - await disableConnectionQueryHistory(args.projectDir, connectionChoice.connectionId); - setupStatus = await validateAndScanConnection({ - projectDir: args.projectDir, - connectionId: connectionChoice.connectionId, - io, - deps, - args, - prompts, - }); - } else if (action === 're-enter') { - const connection = await buildConnectionConfig({ - driver, - connectionId: connectionChoice.connectionId, - args, - prompts, - }); - if (connection === 'back') { - if (!canReturnToDriverSelection) return { status: 'back', projectDir: args.projectDir }; - returnToDriverSelection = true; - break; - } - if (!connection) continue; - const withHistoricSql = await maybeApplyHistoricSqlConfig({ connection, driver, args, prompts }); - if (withHistoricSql === 'back') { - if (!canReturnToDriverSelection) return { status: 'back', projectDir: args.projectDir }; - returnToDriverSelection = true; - break; - } - await writeConnectionConfig({ - projectDir: args.projectDir, - connectionId: connectionChoice.connectionId, - connection: withHistoricSql, - io, - }); - setupStatus = await validateAndScanConnection({ - projectDir: args.projectDir, - connectionId: connectionChoice.connectionId, - io, - deps, - args, - prompts, - }); + if (setupOutcome === 'skip') { + continue; } } if (returnToDriverSelection) break; - if (connectionSkipped) continue; pushUniqueConnectionId(selectedConnectionIds, connectionChoice.connectionId); } diff --git a/packages/cli/src/setup-sources.ts b/packages/cli/src/setup-sources.ts index 4f0a94bc..0a66c3a7 100644 --- a/packages/cli/src/setup-sources.ts +++ b/packages/cli/src/setup-sources.ts @@ -20,6 +20,12 @@ import type { KtxCliIo } from './cli-runtime.js'; import { errorMessage, writePrefixedLines } from './clack.js'; import { pickNotionRootPages } from './notion-page-picker.js'; import { runKtxSourceMapping } from './source-mapping.js'; +import { + runConnectionSetupWithRecovery, + type ConfigureResult, + type RecoveryOutcome, + type ValidateResult, +} from './connection-recovery.js'; import { withMultiselectNavigation, withTextInputNavigation } from './prompt-navigation.js'; import { runKtxPublicIngest } from './public-ingest.js'; import { writeProjectLocalSecretReference } from './setup-secrets.js'; @@ -866,8 +872,7 @@ type InteractiveSourceConnectionChoice = type SourceSetupChoiceResult = | { status: 'ready'; connectionId: string } - | { status: 'back' } - | { status: 'failed' }; + | { status: Exclude }; async function runSourcePromptSteps( initialState: SourcePromptState, @@ -1758,6 +1763,58 @@ async function validateSource( return await (deps.validateNotion ?? defaultValidateNotion)(args.connection); } +async function createSourceSetupRollback(projectDir: string): Promise<() => Promise> { + const project = await loadKtxProject({ projectDir }); + const previousConfig = project.config; + const configPath = project.configPath; + return async () => { + await writeFile(configPath, serializeKtxProjectConfig(previousConfig), 'utf-8'); + }; +} + +function sourceConnectionId(input: { + source: KtxSetupSourceType; + sourceChoice: Exclude; +}): string { + return input.sourceChoice.kind === 'existing' || input.sourceChoice.kind === 'edited' + ? input.sourceChoice.connectionId + : (input.sourceChoice.args.sourceConnectionId ?? `${input.source}-main`); +} + +async function validateSourceConnectionAndMapping(input: { + args: KtxSetupSourcesArgs; + source: KtxSetupSourceType; + connectionId: string; + connection: KtxProjectConnectionConfig; + prompts: KtxSetupSourcesPromptAdapter; + io: KtxCliIo; + deps: KtxSetupSourcesDeps; +}): Promise { + const validation = await validateSource( + input.source, + { projectDir: input.args.projectDir, connectionId: input.connectionId, connection: input.connection }, + input.deps, + ); + if (!validation.ok) { + input.io.stderr.write(`${validation.message}\n`); + return { status: 'failed' }; + } + + if (input.source === 'metabase' || input.source === 'looker') { + input.prompts.log?.(`Validating ${sourceLabel(input.source)} mapping...`); + const mappingCode = await (input.deps.runMapping ?? defaultRunMapping)( + input.args.projectDir, + input.connectionId, + createSetupPrefixedIo(input.io), + ); + if (mappingCode !== 0) { + return { status: 'failed' }; + } + } + + return { status: 'ok' }; +} + async function saveValidateAndMaybeBuildSource(input: { args: KtxSetupSourcesArgs; source: KtxSetupSourceType; @@ -1766,76 +1823,121 @@ async function saveValidateAndMaybeBuildSource(input: { io: KtxCliIo; deps: KtxSetupSourcesDeps; }): Promise { - const connectionId = - input.sourceChoice.kind === 'existing' - ? input.sourceChoice.connectionId - : input.sourceChoice.kind === 'edited' - ? input.sourceChoice.connectionId - : (input.sourceChoice.args.sourceConnectionId ?? `${input.source}-main`); - const connection = - input.sourceChoice.kind === 'existing' - ? input.sourceChoice.connection - : buildConnection(input.source, input.sourceChoice.args); - const rollback = - input.sourceChoice.kind === 'existing' - ? undefined - : await writeSourceConnection( - input.args.projectDir, - connectionId, - connection, - sourceAdapter(input.source), - input.io, - ); + let latestChoice = input.sourceChoice; + let latestConnectionId = sourceConnectionId({ source: input.source, sourceChoice: latestChoice }); + let latestConnection = + latestChoice.kind === 'existing' + ? latestChoice.connection + : buildConnection(input.source, latestChoice.args); + let configureCount = 0; + let rollbackAfterConfigure: (() => Promise) | undefined; - if (input.sourceChoice.kind === 'existing') { - await ensureSourceAdapterEnabled(input.args.projectDir, input.source); - } + const outcome = await runConnectionSetupWithRecovery({ + label: latestConnectionId, + interactive: input.args.inputMode !== 'disabled', + allowSkip: true, + io: input.io, + prompts: input.prompts, + snapshot: async () => { + rollbackAfterConfigure = await createSourceSetupRollback(input.args.projectDir); + return rollbackAfterConfigure; + }, + configure: async (): Promise => { + configureCount += 1; + if (latestChoice.kind === 'existing' && configureCount === 1) { + await ensureSourceAdapterEnabled(input.args.projectDir, input.source); + return 'configured'; + } - const validation = await validateSource( - input.source, - { projectDir: input.args.projectDir, connectionId, connection }, - input.deps, - ); - if (!validation.ok) { - await rollback?.(); - input.io.stderr.write(`${validation.message}\n`); - return { status: 'failed' }; - } + const project = await loadKtxProject({ projectDir: input.args.projectDir }); + const currentConnection = project.config.connections[latestConnectionId] ?? latestConnection; + const useAlreadyPromptedArgs = configureCount === 1 && latestChoice.kind !== 'existing'; + const sourceArgs = + useAlreadyPromptedArgs && latestChoice.kind !== 'existing' + ? latestChoice.args + : input.args.inputMode === 'disabled' + ? sourceArgsFromExistingConnection({ + args: input.args, + source: input.source, + connectionId: latestConnectionId, + connection: currentConnection, + }) + : await promptForInteractiveSource( + sourceArgsFromExistingConnection({ + args: input.args, + source: input.source, + connectionId: latestConnectionId, + connection: currentConnection, + }), + input.source, + input.prompts, + input.io, + { + pickNotionRootPages: input.deps.pickNotionRootPages, + discoverMetabaseDatabases: input.deps.discoverMetabaseDatabases, + }, + latestConnectionId, + input.deps.testGitRepo, + input.deps.discoverMetabaseDatabases, + ); - if (input.source === 'metabase' || input.source === 'looker') { - input.prompts.log?.(`Validating ${sourceLabel(input.source)} mapping…`); - const mappingCode = await (input.deps.runMapping ?? defaultRunMapping)( - input.args.projectDir, - connectionId, - createSetupPrefixedIo(input.io), - ); - if (mappingCode !== 0) { - await rollback?.(); - return { status: 'failed' }; - } + if (sourceArgs === 'back') { + return 'back'; + } + + latestConnectionId = sourceArgs.sourceConnectionId ?? latestConnectionId; + latestConnection = buildConnection(input.source, sourceArgs); + latestChoice = + latestChoice.kind === 'new' + ? { kind: 'new', args: sourceArgs } + : { kind: 'edited', connectionId: latestConnectionId, args: sourceArgs }; + + await writeSourceConnection( + input.args.projectDir, + latestConnectionId, + latestConnection, + sourceAdapter(input.source), + input.io, + ); + return 'configured'; + }, + validate: () => + validateSourceConnectionAndMapping({ + args: input.args, + source: input.source, + connectionId: latestConnectionId, + connection: latestConnection, + prompts: input.prompts, + io: input.io, + deps: input.deps, + }), + }); + + if (outcome !== 'ready') { + return { status: outcome }; } if (input.args.runInitialSourceIngest) { const ingestResult = await runInitialSourceIngestWithRecovery({ args: input.args, - connectionId, + connectionId: latestConnectionId, io: input.io, prompts: input.prompts, deps: input.deps, }); if (ingestResult === 'failed') { - await rollback?.(); + await rollbackAfterConfigure?.(); return { status: 'failed' }; } if (ingestResult === 'back') { - await rollback?.(); + await rollbackAfterConfigure?.(); return { status: 'back' }; } } else { - input.io.stdout.write(`│ Context source ${connectionId} saved. It will be built during the context build step.\n`); + input.io.stdout.write(`│ Context source ${latestConnectionId} saved. It will be built during the context build step.\n`); } - return { status: 'ready', connectionId }; + return { status: 'ready', connectionId: latestConnectionId }; } export async function runKtxSetupSourcesStep( @@ -1942,8 +2044,13 @@ export async function runKtxSetupSourcesStep( returnToSourceSelection = true; break; } - if (!readyConnectionIds.includes(choiceResult.connectionId)) { - readyConnectionIds.push(choiceResult.connectionId); + if (choiceResult.status === 'skip') { + continue; + } + if (choiceResult.status === 'ready') { + if (!readyConnectionIds.includes(choiceResult.connectionId)) { + readyConnectionIds.push(choiceResult.connectionId); + } } } @@ -2005,8 +2112,13 @@ export async function runKtxSetupSourcesStep( if (choiceResult.status === 'back') { continue; } - if (!readyConnectionIds.includes(choiceResult.connectionId)) { - readyConnectionIds.push(choiceResult.connectionId); + if (choiceResult.status === 'skip') { + continue; + } + if (choiceResult.status === 'ready') { + if (!readyConnectionIds.includes(choiceResult.connectionId)) { + readyConnectionIds.push(choiceResult.connectionId); + } } continue; } diff --git a/packages/cli/test/connection-recovery.test.ts b/packages/cli/test/connection-recovery.test.ts new file mode 100644 index 00000000..b164c7e2 --- /dev/null +++ b/packages/cli/test/connection-recovery.test.ts @@ -0,0 +1,171 @@ +import { describe, expect, it, vi } from 'vitest'; +import { + runConnectionSetupWithRecovery, + type ConfigureResult, + type RecoveryAction, + type ValidateResult, +} from '../src/connection-recovery.js'; + +function input(overrides: { + interactive?: boolean; + allowSkip?: boolean; + configure?: () => Promise; + validate?: () => Promise; + selectValues?: string[]; + extraActions?: RecoveryAction[]; +}) { + const selectValues = [...(overrides.selectValues ?? [])]; + const rollback = vi.fn(async () => {}); + const select = vi.fn(async () => selectValues.shift() ?? 'back'); + const validate = overrides.validate ?? vi.fn(async () => ({ status: 'ok' as const })); + return { + rollback, + select, + validate, + run: () => + runConnectionSetupWithRecovery({ + label: 'warehouse', + interactive: overrides.interactive ?? true, + allowSkip: overrides.allowSkip ?? true, + io: { + stdout: { write: vi.fn() }, + stderr: { write: vi.fn() }, + }, + prompts: { select }, + snapshot: vi.fn(async () => rollback), + configure: overrides.configure ?? vi.fn(async () => 'configured' as const), + validate, + }), + }; +} + +describe('runConnectionSetupWithRecovery', () => { + it('returns ready without opening the menu when first validation passes', async () => { + const setup = input({}); + + await expect(setup.run()).resolves.toBe('ready'); + + expect(setup.select).not.toHaveBeenCalled(); + expect(setup.rollback).not.toHaveBeenCalled(); + }); + + it('fails fast without prompting or rollback when noninteractive validation fails', async () => { + const setup = input({ + interactive: false, + validate: vi.fn(async () => ({ status: 'failed' as const })), + }); + + await expect(setup.run()).resolves.toBe('failed'); + + expect(setup.select).not.toHaveBeenCalled(); + expect(setup.rollback).not.toHaveBeenCalled(); + }); + + it('retries the same config after Retry and returns ready', async () => { + let calls = 0; + const setup = input({ + selectValues: ['retry'], + validate: vi.fn(async () => { + calls += 1; + return calls === 1 ? { status: 'failed' as const } : { status: 'ok' as const }; + }), + }); + + await expect(setup.run()).resolves.toBe('ready'); + + expect(setup.validate).toHaveBeenCalledTimes(2); + expect(setup.rollback).not.toHaveBeenCalled(); + }); + + it('re-enters config and validates the new attempt', async () => { + let calls = 0; + const configure = vi.fn(async () => 'configured' as const); + const setup = input({ + configure, + selectValues: ['re-enter'], + validate: vi.fn(async () => { + calls += 1; + return calls === 1 ? { status: 'failed' as const } : { status: 'ok' as const }; + }), + }); + + await expect(setup.run()).resolves.toBe('ready'); + + expect(configure).toHaveBeenCalledTimes(2); + expect(setup.validate).toHaveBeenCalledTimes(2); + expect(setup.rollback).not.toHaveBeenCalled(); + }); + + it('rolls back once and returns skip when Skip is selected', async () => { + const setup = input({ + selectValues: ['skip'], + validate: vi.fn(async () => ({ status: 'failed' as const })), + }); + + await expect(setup.run()).resolves.toBe('skip'); + + expect(setup.rollback).toHaveBeenCalledTimes(1); + }); + + it('omits Skip when allowSkip is false and rolls back on Back', async () => { + const setup = input({ + allowSkip: false, + selectValues: ['back'], + validate: vi.fn(async () => ({ status: 'failed' as const })), + }); + + await expect(setup.run()).resolves.toBe('back'); + + expect(setup.select).toHaveBeenCalledWith({ + message: 'Connection setup failed for warehouse', + options: [ + { value: 'retry', label: 'Retry connection test' }, + { value: 're-enter', label: 'Re-enter connection details' }, + { value: 'back', label: 'Back' }, + ], + }); + expect(setup.rollback).toHaveBeenCalledTimes(1); + }); + + it('runs an extra action and then revalidates', async () => { + const action = vi.fn(async () => {}); + let calls = 0; + const setup = input({ + selectValues: ['disable-query-history'], + validate: vi.fn(async () => { + calls += 1; + return calls === 1 + ? { + status: 'failed' as const, + extraActions: [ + { value: 'disable-query-history', label: 'Disable query history and retry', run: action }, + ], + } + : { status: 'ok' as const }; + }), + }); + + await expect(setup.run()).resolves.toBe('ready'); + + expect(action).toHaveBeenCalledTimes(1); + expect(setup.validate).toHaveBeenCalledTimes(2); + }); + + it('rolls back when re-enter returns back or cancelled', async () => { + const backSetup = input({ + selectValues: ['re-enter'], + configure: vi.fn(async () => 'back' as const), + validate: vi.fn(async () => ({ status: 'failed' as const })), + }); + await expect(backSetup.run()).resolves.toBe('back'); + expect(backSetup.rollback).toHaveBeenCalledTimes(1); + + const cancelledSetup = input({ + selectValues: ['re-enter'], + configure: vi.fn(async () => 'cancelled' as const), + validate: vi.fn(async () => ({ status: 'failed' as const })), + }); + await expect(cancelledSetup.run()).resolves.toBe('failed'); + expect(cancelledSetup.rollback).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/cli/test/setup-context.test.ts b/packages/cli/test/setup-context.test.ts index 2655527b..743cfee9 100644 --- a/packages/cli/test/setup-context.test.ts +++ b/packages/cli/test/setup-context.test.ts @@ -264,6 +264,7 @@ describe('setup context build state', () => { now: () => new Date('2026-05-09T10:00:00.000Z'), runContextBuild: runContextBuildMock, verifyContextReady, + testConnection: async () => 0, }, ), ).resolves.toEqual({ status: 'ready', projectDir: tempDir, runId: 'setup-context-local-abc123' }); @@ -315,6 +316,7 @@ describe('setup context build state', () => { runIdFactory: () => 'setup-context-local-failed', now: () => new Date('2026-05-09T10:00:00.000Z'), runContextBuild: runContextBuildMock, + testConnection: async () => 0, }, ), ).resolves.toEqual({ status: 'failed', projectDir: tempDir }); @@ -347,6 +349,7 @@ describe('setup context build state', () => { runIdFactory: () => 'setup-context-local-throw', now: () => new Date('2026-05-09T10:00:00.000Z'), runContextBuild: runContextBuildMock, + testConnection: async () => 0, }, ), ).resolves.toEqual({ @@ -423,6 +426,7 @@ describe('setup context build state', () => { runIdFactory: () => 'setup-context-local-enriched-scan', now: () => new Date('2026-05-09T10:00:00.000Z'), runContextBuild: runContextBuildMock, + testConnection: async () => 0, }, ), ).resolves.toEqual({ status: 'ready', projectDir: tempDir, runId: 'setup-context-local-enriched-scan' }); @@ -457,7 +461,7 @@ describe('setup context build state', () => { runKtxSetupContextStep( { projectDir: tempDir, inputMode: 'disabled' }, io.io, - { runContextBuild: runContextBuildMock }, + { runContextBuild: runContextBuildMock, testConnection: async () => 0 }, ), ).resolves.toMatchObject({ status: 'ready' }); @@ -552,10 +556,119 @@ describe('setup context build state', () => { runKtxSetupContextStep( { projectDir: tempDir, inputMode: 'disabled' }, io.io, - { runContextBuild: runContextBuildMock, verifyContextReady }, + { runContextBuild: runContextBuildMock, verifyContextReady, testConnection: async () => 0 }, ), ).resolves.toMatchObject({ status: 'ready' }); expect(runContextBuildMock).toHaveBeenCalledOnce(); }); + + it('blocks the build and names the failing connection without leaking raw error text', async () => { + const missingDbPath = join(tempDir, 'missing-warehouse.sqlite'); + await writeReadyProject(tempDir, { + connections: { warehouse: { driver: 'sqlite', path: missingDbPath } }, + }); + const io = makeIo(); + const runContextBuildMock = vi.fn(async () => ({ exitCode: 0 })); + + await expect( + runKtxSetupContextStep( + { projectDir: tempDir, inputMode: 'disabled' }, + io.io, + { + runIdFactory: () => 'setup-context-local-gate', + now: () => new Date('2026-05-09T10:00:00.000Z'), + runContextBuild: runContextBuildMock, + }, + ), + ).resolves.toEqual({ status: 'failed', projectDir: tempDir }); + + expect(runContextBuildMock).not.toHaveBeenCalled(); + // Names the failing connection by id + connector type, with remediation. + expect(io.stderr()).toContain('warehouse (sqlite)'); + expect(io.stderr()).toContain('ktx connection test'); + // The remediation command targets the project that just failed, not cwd. + expect(io.stderr()).toContain(`ktx connection test --project-dir ${tempDir}`); + // Never surfaces raw connection error text (or the database path) to the user. + expect(io.stderr()).not.toContain('File not found'); + expect(io.stderr()).not.toContain(missingDbPath); + // The failed context state forces context.ready=false so setup cannot read as ready. + await expect(readKtxSetupContextState(tempDir)).resolves.toMatchObject({ + status: 'failed', + failureReason: 'Required connections failed their live test: warehouse (sqlite).', + }); + expect((await readKtxSetupState(tempDir)).completed_steps).not.toContain('context'); + }); + + it('retries connection tests after a fix and then builds in interactive mode', async () => { + await writeReadyProject(tempDir, { + connections: { warehouse: { driver: 'postgres', readonly: true } }, + }); + const io = makeIo(); + const runContextBuildMock = vi.fn(async () => ({ exitCode: 0 })); + const verifyContextReady = vi.fn(async () => ({ + ready: true, + agentContextReady: true, + semanticSearchReady: true, + details: ['ready'], + })); + let gateRounds = 0; + const testConnection = vi.fn(async () => (++gateRounds === 1 ? 1 : 0)); + let selectCalls = 0; + const select = vi.fn(async () => { + selectCalls += 1; + return selectCalls === 1 ? 'build' : 'retry'; + }); + + await expect( + runKtxSetupContextStep( + { projectDir: tempDir, inputMode: 'auto' }, + io.io, + { + prompts: { select, cancel: vi.fn() }, + runContextBuild: runContextBuildMock, + verifyContextReady, + testConnection, + }, + ), + ).resolves.toMatchObject({ status: 'ready' }); + + expect(testConnection).toHaveBeenCalledTimes(2); + expect(runContextBuildMock).toHaveBeenCalledOnce(); + expect(io.stderr()).toContain('warehouse (postgres)'); + }); + + it('returns to setup when the user backs out of a failing connection in interactive mode', async () => { + await writeReadyProject(tempDir, { + connections: { warehouse: { driver: 'postgres', readonly: true } }, + }); + const io = makeIo(); + const runContextBuildMock = vi.fn(async () => ({ exitCode: 0 })); + const verifyContextReady = vi.fn(async () => ({ + ready: true, + agentContextReady: true, + semanticSearchReady: true, + details: ['ready'], + })); + let selectCalls = 0; + const select = vi.fn(async () => { + selectCalls += 1; + return selectCalls === 1 ? 'build' : 'back'; + }); + + await expect( + runKtxSetupContextStep( + { projectDir: tempDir, inputMode: 'auto' }, + io.io, + { + prompts: { select, cancel: vi.fn() }, + runContextBuild: runContextBuildMock, + verifyContextReady, + testConnection: async () => 1, + }, + ), + ).resolves.toEqual({ status: 'back', projectDir: tempDir }); + + expect(runContextBuildMock).not.toHaveBeenCalled(); + }); }); diff --git a/packages/cli/test/setup-databases.test.ts b/packages/cli/test/setup-databases.test.ts index cf7acf3c..265459e2 100644 --- a/packages/cli/test/setup-databases.test.ts +++ b/packages/cli/test/setup-databases.test.ts @@ -1261,11 +1261,16 @@ describe('setup databases step', () => { const prompts = makePromptAdapter({ textValues: ['env:DATABASE_URL'], }); + let primaryMenuCount = 0; vi.mocked(prompts.select).mockImplementation(async (options) => { - if (options.message === 'Databases configured: warehouse\nWhat would you like to do?') return 'edit'; + if (options.message === 'Databases configured: warehouse\nWhat would you like to do?') { + primaryMenuCount += 1; + return primaryMenuCount === 1 ? 'edit' : 'continue'; + } if (options.message === 'Database to edit') return 'warehouse'; if (options.message === 'How do you want to connect to PostgreSQL?') return 'url'; if (options.message.startsWith('Enable query-history ingest')) return 'no'; + if (options.message === 'Connection setup failed for warehouse') return 'back'; return 'back'; }); const listTables = vi.fn(async () => [ @@ -1286,13 +1291,283 @@ describe('setup databases step', () => { }, ); - expect(result).toEqual({ status: 'failed', projectDir: tempDir }); + expect(result).toEqual({ status: 'ready', projectDir: tempDir, connectionIds: ['warehouse'] }); const config = parseKtxProjectConfig(await readFile(join(tempDir, 'ktx.yaml'), 'utf-8')); expect(config.connections.warehouse).toMatchObject({ enabled_tables: ['public.orders'], }); }); + it('recovers from an interactive database edit failure by re-entering details', async () => { + await writeFile( + join(tempDir, 'ktx.yaml'), + [ + 'connections:', + ' analytics:', + ' driver: postgres', + ' url: env:OLD_DATABASE_URL', + 'setup:', + ' database_connection_ids:', + ' - analytics', + '', + ].join('\n'), + 'utf-8', + ); + const io = makeIo(); + const prompts = makePromptAdapter({ + selectValues: ['edit', 'analytics', 'url', 'no', 're-enter', 'url', 'no', 'continue'], + textValues: ['env:BAD_DATABASE_URL', 'env:FIXED_DATABASE_URL'], + }); + let attempts = 0; + + const result = await runKtxSetupDatabasesStep( + { + projectDir: tempDir, + inputMode: 'auto', + databaseSchemas: [], + skipDatabases: false, + }, + io.io, + { + prompts, + testConnection: vi.fn(async () => { + attempts += 1; + return attempts === 1 ? 1 : 0; + }), + scanConnection: vi.fn(async () => 0), + listSchemas: vi.fn(async () => ['public']), + listTables: vi.fn(async () => [{ catalog: null, schema: 'public', name: 'orders', kind: 'table' as const }]), + }, + ); + + expect(result.status).toBe('ready'); + expect(vi.mocked(prompts.select)).toHaveBeenCalledWith( + expect.objectContaining({ + message: 'Connection setup failed for analytics', + options: expect.arrayContaining([ + { value: 'retry', label: 'Retry connection test' }, + { value: 're-enter', label: 'Re-enter connection details' }, + { value: 'back', label: 'Back' }, + ]), + }), + ); + const config = parseKtxProjectConfig(await readFile(join(tempDir, 'ktx.yaml'), 'utf-8')); + expect(config.connections.analytics).toMatchObject({ + driver: 'postgres', + url: 'env:FIXED_DATABASE_URL', + }); + }); + + it('re-enters details after an interactive existing database validation failure', async () => { + await writeFile( + join(tempDir, 'ktx.yaml'), + [ + 'connections:', + ' warehouse:', + ' driver: postgres', + ' url: env:OLD_DATABASE_URL', + '', + ].join('\n'), + 'utf-8', + ); + const io = makeIo(); + const prompts = makePromptAdapter({ + selectValues: ['existing:warehouse', 'no', 're-enter', 'url', 'no'], + textValues: ['env:FIXED_DATABASE_URL'], + }); + let attempts = 0; + + const result = await runKtxSetupDatabasesStep( + { + projectDir: tempDir, + inputMode: 'auto', + databaseDrivers: ['postgres'], + databaseSchemas: [], + skipDatabases: false, + }, + io.io, + { + prompts, + testConnection: vi.fn(async () => { + attempts += 1; + return attempts === 1 ? 1 : 0; + }), + scanConnection: vi.fn(async () => 0), + listSchemas: vi.fn(async () => ['public']), + listTables: vi.fn(async () => [ + { catalog: null, schema: 'public', name: 'orders', kind: 'table' as const }, + ]), + }, + ); + + expect(result.status).toBe('ready'); + expect(vi.mocked(prompts.select)).toHaveBeenCalledWith({ + message: 'How do you want to connect to PostgreSQL?', + options: [ + { value: 'url', label: 'Paste a connection URL' }, + { value: 'fields', label: 'Enter connection details (host, port, database, user)' }, + { value: 'back', label: 'Back' }, + ], + }); + expect(vi.mocked(prompts.select)).toHaveBeenCalledWith( + expect.objectContaining({ + message: 'Connection setup failed for warehouse', + options: expect.arrayContaining([ + { value: 'retry', label: 'Retry connection test' }, + { value: 're-enter', label: 'Re-enter connection details' }, + { value: 'skip', label: 'Skip this connection' }, + { value: 'back', label: 'Back' }, + ]), + }), + ); + const config = parseKtxProjectConfig(await readFile(join(tempDir, 'ktx.yaml'), 'utf-8')); + expect(config.connections.warehouse).toMatchObject({ + driver: 'postgres', + url: 'env:FIXED_DATABASE_URL', + }); + }); + + it('restores the previous database config when backing out of a failed edit', async () => { + await writeFile( + join(tempDir, 'ktx.yaml'), + [ + 'connections:', + ' analytics:', + ' driver: postgres', + ' url: env:OLD_DATABASE_URL', + 'setup:', + ' database_connection_ids:', + ' - analytics', + '', + ].join('\n'), + 'utf-8', + ); + const io = makeIo(); + const prompts = makePromptAdapter({ + selectValues: ['edit', 'analytics', 'url', 'no', 'back', 'continue'], + textValues: ['env:BAD_DATABASE_URL'], + }); + + const result = await runKtxSetupDatabasesStep( + { + projectDir: tempDir, + inputMode: 'auto', + databaseSchemas: [], + skipDatabases: false, + }, + io.io, + { + prompts, + testConnection: vi.fn(async () => 1), + scanConnection: vi.fn(async () => 0), + }, + ); + + expect(result.status).toBe('ready'); + const config = parseKtxProjectConfig(await readFile(join(tempDir, 'ktx.yaml'), 'utf-8')); + expect(config.connections.analytics).toMatchObject({ + driver: 'postgres', + url: 'env:OLD_DATABASE_URL', + }); + }); + + it('keeps scripted database setup fail-fast without rolling back attempted config', async () => { + await writeFile( + join(tempDir, 'ktx.yaml'), + [ + 'connections:', + ' analytics:', + ' driver: postgres', + ' url: env:OLD_DATABASE_URL', + '', + ].join('\n'), + 'utf-8', + ); + const io = makeIo(); + + const result = await runKtxSetupDatabasesStep( + { + projectDir: tempDir, + inputMode: 'disabled', + databaseConnectionIds: ['analytics'], + databaseSchemas: [], + enableQueryHistory: true, + skipDatabases: false, + }, + io.io, + { + testConnection: vi.fn(async () => 1), + scanConnection: vi.fn(async () => 0), + }, + ); + + expect(result.status).toBe('failed'); + const config = parseKtxProjectConfig(await readFile(join(tempDir, 'ktx.yaml'), 'utf-8')); + expect(config.connections.analytics).toMatchObject({ + driver: 'postgres', + url: 'env:OLD_DATABASE_URL', + context: { + queryHistory: { + enabled: true, + }, + }, + }); + }); + + it('keeps scripted database ids fail-fast even when input mode is auto', async () => { + await writeFile( + join(tempDir, 'ktx.yaml'), + [ + 'connections:', + ' analytics:', + ' driver: postgres', + ' url: env:OLD_DATABASE_URL', + '', + ].join('\n'), + 'utf-8', + ); + const io = makeIo(); + const prompts = makePromptAdapter({}); + vi.mocked(prompts.select).mockImplementation(async ({ message }) => { + if (message === 'Connection setup failed for analytics') { + throw new Error('scripted selected-id setup opened the recovery menu'); + } + return 'finish'; + }); + + const result = await runKtxSetupDatabasesStep( + { + projectDir: tempDir, + inputMode: 'auto', + databaseConnectionIds: ['analytics'], + databaseSchemas: [], + enableQueryHistory: true, + skipDatabases: false, + }, + io.io, + { + prompts, + testConnection: vi.fn(async () => 1), + scanConnection: vi.fn(async () => 0), + }, + ); + + expect(result.status).toBe('failed'); + expect(prompts.select).not.toHaveBeenCalledWith( + expect.objectContaining({ message: 'Connection setup failed for analytics' }), + ); + const config = parseKtxProjectConfig(await readFile(join(tempDir, 'ktx.yaml'), 'utf-8')); + expect(config.connections.analytics).toMatchObject({ + driver: 'postgres', + url: 'env:OLD_DATABASE_URL', + context: { + queryHistory: { + enabled: true, + }, + }, + }); + }); + it('lets Escape from connection fields return to connection method selection', async () => { const prompts = makePromptAdapter({ selectValues: ['fields', 'url'], @@ -2517,7 +2792,7 @@ describe('setup databases step', () => { vi.mocked(prompts.select).mockImplementation(async ({ message, options }) => { if (message.startsWith('Enable query-history ingest')) return 'yes'; if (message.includes('How much database context should KTX build?')) return 'fast'; - if (message.startsWith('Database setup failed for analytics')) { + if (message.startsWith('Connection setup failed for analytics')) { failurePromptCount += 1; failurePromptOptions.push(options); if (failurePromptCount === 1) return 'disable-query-history'; @@ -2874,6 +3149,25 @@ describe('setup databases step', () => { expect(io.stderr()).toContain('Missing database connection id'); }); + it('returns missing input when a non-interactive new connection is missing required details', async () => { + const io = makeIo(); + + const result = await runKtxSetupDatabasesStep( + { + projectDir: tempDir, + inputMode: 'disabled', + databaseDrivers: ['postgres'], + databaseConnectionId: 'warehouse', + databaseSchemas: [], + skipDatabases: false, + }, + io.io, + ); + + expect(result.status).toBe('missing-input'); + expect(io.stderr()).toContain('Missing connection details'); + }); + it('accepts former ingest subcommand names as non-interactive database connection ids', async () => { const io = makeIo(); diff --git a/packages/cli/test/setup-sources.test.ts b/packages/cli/test/setup-sources.test.ts index 784dcc46..e4f7af2d 100644 --- a/packages/cli/test/setup-sources.test.ts +++ b/packages/cli/test/setup-sources.test.ts @@ -706,7 +706,18 @@ describe('setup sources step', () => { ); expect(io.stderr()).toContain('1: Metabase database does not match KTX connection database'); expect(io.stderr()).not.toContain('Metabase mapping validation failed'); - expect(testPrompts.log).toHaveBeenCalledWith('Edit the connection or pick a different source to continue.'); + expect(testPrompts.log).toHaveBeenCalledWith('Validating Metabase mapping...'); + expect(testPrompts.select).toHaveBeenCalledWith( + expect.objectContaining({ + message: 'Connection setup failed for metabase-main', + options: expect.arrayContaining([ + { value: 'retry', label: 'Retry connection test' }, + { value: 're-enter', label: 'Re-enter connection details' }, + { value: 'skip', label: 'Skip this connection' }, + { value: 'back', label: 'Back' }, + ]), + }), + ); }); it('does not mark sources complete when validation fails', async () => { @@ -961,7 +972,153 @@ describe('setup sources step', () => { expect(result.status).not.toBe('failed'); expect(io.stderr()).toContain('Failed to clone https://github.com/acme/private-repo: Authentication failed'); - expect(testPrompts.log).toHaveBeenCalledWith('Edit the connection or pick a different source to continue.'); + expect(testPrompts.select).toHaveBeenCalledWith( + expect.objectContaining({ + message: 'Connection setup failed for dbt-main', + options: expect.arrayContaining([ + { value: 'retry', label: 'Retry connection test' }, + { value: 're-enter', label: 'Re-enter connection details' }, + { value: 'skip', label: 'Skip this connection' }, + { value: 'back', label: 'Back' }, + ]), + }), + ); + }); + + it('recovers from an existing context-source validation failure by re-entering details', async () => { + await addPrimarySource(); + await addConnection('dbt-main', { + driver: 'dbt', + source_dir: '/repo/bad-dbt', + project_name: 'analytics', + }); + let attempts = 0; + const validateDbt = vi.fn(async () => { + attempts += 1; + return attempts === 1 + ? { ok: false as const, message: 'dbt project not found' } + : { ok: true as const, detail: 'project=analytics' }; + }); + const testPrompts = prompts({ + multiselect: [['dbt']], + select: ['existing:dbt-main', 're-enter', 'path', 'done'], + text: ['/repo/fixed-dbt', ''], + }); + const io = makeIo(); + + const result = await runKtxSetupSourcesStep( + { projectDir, inputMode: 'auto', runInitialSourceIngest: false, skipSources: false }, + io.io, + { prompts: testPrompts, validateDbt }, + ); + + expect(result.status).toBe('ready'); + expect(validateDbt).toHaveBeenCalledTimes(2); + expect(vi.mocked(testPrompts.select)).toHaveBeenCalledWith( + expect.objectContaining({ + message: 'Connection setup failed for dbt-main', + options: expect.arrayContaining([ + { value: 'retry', label: 'Retry connection test' }, + { value: 're-enter', label: 'Re-enter connection details' }, + { value: 'skip', label: 'Skip this connection' }, + { value: 'back', label: 'Back' }, + ]), + }), + ); + expect((await readConfig()).connections['dbt-main']).toMatchObject({ + driver: 'dbt', + source_dir: '/repo/fixed-dbt', + }); + }); + + it('restores a context-source edit and adapter enablement when recovery goes back', async () => { + await addPrimarySource(); + await addConnection('dbt-main', { + driver: 'dbt', + source_dir: '/repo/existing-dbt', + project_name: 'analytics', + }); + const testPrompts = prompts({ + multiselect: [['dbt']], + select: ['edit:dbt-main', 'path', 'back'], + text: ['/repo/bad-dbt', ''], + }); + const io = makeIo(); + + const result = await runKtxSetupSourcesStep( + { projectDir, inputMode: 'auto', runInitialSourceIngest: false, skipSources: false }, + io.io, + { + prompts: testPrompts, + validateDbt: vi.fn(async () => ({ ok: false as const, message: 'dbt project not found' })), + }, + ); + + expect(result.status).toBe('skipped'); + const config = await readConfig(); + expect(config.connections['dbt-main']).toMatchObject({ + driver: 'dbt', + source_dir: '/repo/existing-dbt', + project_name: 'analytics', + }); + expect(config.ingest.adapters).not.toContain('dbt'); + }); + + it('lets Metabase mapping failure retry through source recovery', async () => { + await addPrimarySource(); + let mappingAttempts = 0; + const runMapping = vi.fn(async () => { + mappingAttempts += 1; + return mappingAttempts === 1 ? 1 : 0; + }); + const testPrompts = prompts({ + multiselect: [['metabase']], + select: ['env', 'retry', 'done'], + text: ['metabase-main', 'https://metabase.example.com'], + }); + const io = makeIo(); + + const result = await runKtxSetupSourcesStep( + { projectDir, inputMode: 'auto', runInitialSourceIngest: false, skipSources: false }, + io.io, + { + prompts: testPrompts, + discoverMetabaseDatabases: vi.fn(async () => [ + { id: 1, name: 'Analytics', engine: 'postgres', host: 'db.example.com', dbName: 'analytics' }, + ]), + runMapping, + }, + ); + + expect(result.status).toBe('ready'); + expect(runMapping).toHaveBeenCalledTimes(2); + }); + + it('keeps noninteractive source setup fail-fast without rolling back attempted config', async () => { + await addPrimarySource(); + const io = makeIo(); + + const result = await runKtxSetupSourcesStep( + { + projectDir, + inputMode: 'disabled', + source: 'lookml', + sourceConnectionId: 'looker-repo', + sourceGitUrl: 'https://github.com/acme/lookml.git', + runInitialSourceIngest: false, + skipSources: false, + }, + io.io, + { + validateLookml: vi.fn(async () => ({ ok: false as const, message: 'No LookML files found' })), + }, + ); + + expect(result.status).toBe('failed'); + expect((await readConfig()).connections['looker-repo']).toMatchObject({ + driver: 'lookml', + repoUrl: 'https://github.com/acme/lookml.git', + }); }); it('adds a dbt source connection and enables its adapter', async () => { @@ -1371,7 +1528,17 @@ describe('setup sources step', () => { source_dir: '/repo/new-dbt', })); expect(io.stderr()).toContain('dbt project not found'); - expect(testPrompts.log).toHaveBeenCalledWith('Edit the connection or pick a different source to continue.'); + expect(testPrompts.select).toHaveBeenCalledWith( + expect.objectContaining({ + message: 'Connection setup failed for dbt-main', + options: expect.arrayContaining([ + { value: 'retry', label: 'Retry connection test' }, + { value: 're-enter', label: 'Re-enter connection details' }, + { value: 'skip', label: 'Skip this connection' }, + { value: 'back', label: 'Back' }, + ]), + }), + ); const config = await readConfig(); expect(config.connections['dbt-main']).toMatchObject({ driver: 'dbt', From e70ae1e63bcd7168ade90b8998a06b561ce36cf2 Mon Sep 17 00:00:00 2001 From: Andrey Avtomonov Date: Wed, 3 Jun 2026 17:19:42 +0200 Subject: [PATCH 13/25] feat(query-history): scope mining to modeled schemas by default (#258) * feat(query-history): structure SQL analysis table refs * feat(query-history): qualify SQL analysis table refs * feat(query-history): wire modeled scope floor through ingest * chore(query-history): verify scope floor * test(query-history): align daemon SQL batch endpoint contract * feat(query-history): build scope from same-run scan catalog * feat(query-history): fail open on scope-floor catalog failures * chore(query-history): verify scope-floor v1 closure * refactor(query-history): share scope membership * feat(setup): apply derived query history filters * docs: document derived query history filters * fix(query-history): redact filter picker LLM prompt SQL * fix(setup): run filter picker SQL analysis through managed daemon * chore(query-history): verify filter picker v1 closure * fix(query-history): fail open on partial service-account attribution * fix(query-history): aggregate BigQuery users by execution count * fix(query-history): aggregate Snowflake users by execution count * fix(query-history): use BigQuery query info hash --- .../content/docs/cli-reference/ktx-setup.mdx | 7 + .../content/docs/configuration/ktx-yaml.mdx | 13 + .../content/docs/guides/building-context.mdx | 5 +- .../bigquery-query-history-reader.ts | 91 +++- .../adapters/historic-sql/chunk-unified.ts | 3 +- .../adapters/historic-sql/pattern-inputs.ts | 10 +- .../query-history-filter-picker.ts | 278 +++++++++++++ .../adapters/historic-sql/scope-floor.ts | 260 ++++++++++++ .../adapters/historic-sql/scope-membership.ts | 45 ++ .../snowflake-query-history-reader.ts | 87 +++- .../adapters/historic-sql/stage-unified.ts | 168 +++----- .../ingest/adapters/historic-sql/types.ts | 18 +- .../cli/src/context/ingest/local-adapters.ts | 36 +- .../sql-analysis/http-sql-analysis-port.ts | 39 +- .../cli/src/context/sql-analysis/ports.ts | 17 +- packages/cli/src/local-adapters.ts | 36 +- packages/cli/src/public-ingest.ts | 89 +++- packages/cli/src/setup-databases.ts | 229 ++++++++++- packages/cli/src/setup.ts | 3 + .../bigquery-query-history-reader.test.ts | 27 +- .../historic-sql/chunk-unified.test.ts | 16 +- .../historic-sql/historic-sql.adapter.test.ts | 5 +- .../local-ingest-acceptance.test.ts | 5 +- .../historic-sql/pattern-inputs.test.ts | 13 +- .../historic-sql/postgres-pgss-reader.test.ts | 2 +- .../query-history-filter-picker.test.ts | 274 ++++++++++++ .../adapters/historic-sql/scope-floor.test.ts | 194 +++++++++ .../historic-sql/scope-membership.test.ts | 51 +++ .../snowflake-query-history-reader.test.ts | 22 +- .../historic-sql/stage-unified.test.ts | 389 ++++++++++++++++-- .../adapters/historic-sql/types.test.ts | 3 +- .../context/ingest/local-adapters.test.ts | 100 ++++- .../http-sql-analysis-port.test.ts | 68 ++- packages/cli/test/local-adapters.test.ts | 114 ++++- packages/cli/test/managed-python-http.test.ts | 4 +- packages/cli/test/public-ingest.test.ts | 126 +++++- packages/cli/test/setup-databases.test.ts | 256 ++++++++++++ packages/cli/test/setup.test.ts | 3 + packages/cli/test/sql.test.ts | 2 +- .../ktx-daemon/src/ktx_daemon/sql_analysis.py | 139 +++++-- python/ktx-daemon/tests/test_app.py | 4 +- python/ktx-daemon/tests/test_sql_analysis.py | 113 ++++- 42 files changed, 3090 insertions(+), 274 deletions(-) create mode 100644 packages/cli/src/context/ingest/adapters/historic-sql/query-history-filter-picker.ts create mode 100644 packages/cli/src/context/ingest/adapters/historic-sql/scope-floor.ts create mode 100644 packages/cli/src/context/ingest/adapters/historic-sql/scope-membership.ts create mode 100644 packages/cli/test/context/ingest/adapters/historic-sql/query-history-filter-picker.test.ts create mode 100644 packages/cli/test/context/ingest/adapters/historic-sql/scope-floor.test.ts create mode 100644 packages/cli/test/context/ingest/adapters/historic-sql/scope-membership.test.ts diff --git a/docs-site/content/docs/cli-reference/ktx-setup.mdx b/docs-site/content/docs/cli-reference/ktx-setup.mdx index 24469a63..0e6cb57c 100644 --- a/docs-site/content/docs/cli-reference/ktx-setup.mdx +++ b/docs-site/content/docs/cli-reference/ktx-setup.mdx @@ -148,6 +148,13 @@ fix the prerequisite. If the later schema-context build also fails, interactive setup offers **Disable query history and retry** so you can finish database setup with `connections..context.queryHistory.enabled: false`. +After the schema scan completes, setup can derive query-history service-account +filters from in-scope history. If **ktx** finds clear operational roles, it +prints each proposed exclusion with a reason and writes +`connections..context.queryHistory.filters.serviceAccounts` only when you +apply the proposal. In non-interactive setup with `--yes`, the proposal is +applied automatically. Existing `serviceAccounts` blocks are never overwritten. + For BigQuery, the remediation tells you to grant `roles/bigquery.resourceViewer` on the BigQuery project, or grant a custom role that contains `bigquery.jobs.listAll`. diff --git a/docs-site/content/docs/configuration/ktx-yaml.mdx b/docs-site/content/docs/configuration/ktx-yaml.mdx index a9298443..17a04c53 100644 --- a/docs-site/content/docs/configuration/ktx-yaml.mdx +++ b/docs-site/content/docs/configuration/ktx-yaml.mdx @@ -179,9 +179,22 @@ connections: context: queryHistory: enabled: true + enabledSchemas: + - orbit_raw + - orbit_analytics minExecutions: 5 ``` +- `enabledSchemas`: Optional list of schema or dataset names that query-history + ingest may mine. Omit it to let **ktx** derive the modeled schema floor from + the connection and semantic-layer sources. Use `["*"]` to disable the floor + for discovery runs. +- `filters.serviceAccounts`: Optional service-account filter block. During + setup, when query history is enabled and no service-account block already + exists, **ktx** can propose exact role patterns such as `^svc_loader$` from + observed in-scope query history. The block uses `mode: exclude` and remains + hand-editable. + ### Metabase ```yaml diff --git a/docs-site/content/docs/guides/building-context.mdx b/docs-site/content/docs/guides/building-context.mdx index 52179e70..9bcf2659 100644 --- a/docs-site/content/docs/guides/building-context.mdx +++ b/docs-site/content/docs/guides/building-context.mdx @@ -57,7 +57,10 @@ isolation. ## Query history PostgreSQL, BigQuery, and Snowflake can add query-history context: common joins, -filters, service-account patterns, redaction rules, and high-usage templates. +filters, redaction rules, high-usage templates, and service-account exclusions. +When query history is enabled during setup, **ktx** reviews observed in-scope +roles and can write exact `filters.serviceAccounts` patterns for operational +traffic such as loader or refresh roles. Enable it during setup, store it under `connections..context.queryHistory`, or request it for one run: diff --git a/packages/cli/src/context/ingest/adapters/historic-sql/bigquery-query-history-reader.ts b/packages/cli/src/context/ingest/adapters/historic-sql/bigquery-query-history-reader.ts index ac4d4c71..fe637078 100644 --- a/packages/cli/src/context/ingest/adapters/historic-sql/bigquery-query-history-reader.ts +++ b/packages/cli/src/context/ingest/adapters/historic-sql/bigquery-query-history-reader.ts @@ -200,27 +200,78 @@ export class BigQueryHistoricSqlQueryHistoryReader { config: HistoricSqlUnifiedPullConfig, ): AsyncIterable { const sql = ` +WITH filtered_jobs AS ( + SELECT + COALESCE(query_info.query_hashes.normalized_literals, TO_HEX(SHA256(query))) AS template_id, + query, + user_email, + creation_time, + end_time, + error_result + FROM ${this.viewPath} + WHERE job_type = 'QUERY' + AND statement_type IN ('SELECT', 'MERGE') + AND creation_time >= ${timestampExpression(window.start)} + AND creation_time < ${timestampExpression(window.end)} + AND query IS NOT NULL +), +template_stats AS ( + SELECT + template_id, + MIN(query) AS canonical_sql, + COUNT(*) AS executions, + COUNT(DISTINCT user_email) AS distinct_users, + MIN(creation_time) AS first_seen, + MAX(creation_time) AS last_seen, + APPROX_QUANTILES(TIMESTAMP_DIFF(end_time, creation_time, MILLISECOND), 100)[OFFSET(50)] AS p50_ms, + APPROX_QUANTILES(TIMESTAMP_DIFF(end_time, creation_time, MILLISECOND), 100)[OFFSET(95)] AS p95_ms, + SAFE_DIVIDE(COUNTIF(error_result IS NOT NULL), COUNT(*)) AS error_rate, + CAST(NULL AS INT64) AS rows_produced + FROM filtered_jobs + GROUP BY template_id + HAVING COUNT(*) >= ${config.minExecutions} +), +template_users AS ( + SELECT + template_id, + user_email AS user, + COUNT(*) AS executions, + MAX(creation_time) AS last_seen + FROM filtered_jobs + GROUP BY template_id, user_email +) SELECT - query_hash AS template_id, - MIN(query) AS canonical_sql, - COUNT(*) AS executions, - COUNT(DISTINCT user_email) AS distinct_users, - MIN(creation_time) AS first_seen, - MAX(creation_time) AS last_seen, - APPROX_QUANTILES(TIMESTAMP_DIFF(end_time, creation_time, MILLISECOND), 100)[OFFSET(50)] AS p50_ms, - APPROX_QUANTILES(TIMESTAMP_DIFF(end_time, creation_time, MILLISECOND), 100)[OFFSET(95)] AS p95_ms, - SAFE_DIVIDE(COUNTIF(error_result IS NOT NULL), COUNT(*)) AS error_rate, - CAST(NULL AS INT64) AS rows_produced, - TO_JSON_STRING(ARRAY_AGG(STRUCT(user_email AS user, 1 AS executions) ORDER BY creation_time DESC LIMIT 5)) AS top_users -FROM ${this.viewPath} -WHERE job_type = 'QUERY' - AND statement_type IN ('SELECT', 'MERGE') - AND creation_time >= ${timestampExpression(window.start)} - AND creation_time < ${timestampExpression(window.end)} - AND query IS NOT NULL -GROUP BY query_hash -HAVING COUNT(*) >= ${config.minExecutions} -ORDER BY executions DESC`.trim(); + stats.template_id, + stats.canonical_sql, + stats.executions, + stats.distinct_users, + stats.first_seen, + stats.last_seen, + stats.p50_ms, + stats.p95_ms, + stats.error_rate, + stats.rows_produced, + TO_JSON_STRING( + ARRAY_AGG( + STRUCT(users.user AS user, users.executions AS executions) + ORDER BY users.executions DESC, users.last_seen DESC + ) + ) AS top_users +FROM template_stats AS stats +JOIN template_users AS users + ON users.template_id = stats.template_id +GROUP BY + stats.template_id, + stats.canonical_sql, + stats.executions, + stats.distinct_users, + stats.first_seen, + stats.last_seen, + stats.p50_ms, + stats.p95_ms, + stats.error_rate, + stats.rows_produced +ORDER BY stats.executions DESC`.trim(); const result = await queryClient(client).executeQuery(sql); if (result.error) { throw grantsError(result.error); diff --git a/packages/cli/src/context/ingest/adapters/historic-sql/chunk-unified.ts b/packages/cli/src/context/ingest/adapters/historic-sql/chunk-unified.ts index 4477e753..56cc32e7 100644 --- a/packages/cli/src/context/ingest/adapters/historic-sql/chunk-unified.ts +++ b/packages/cli/src/context/ingest/adapters/historic-sql/chunk-unified.ts @@ -1,6 +1,7 @@ import { createHash } from 'node:crypto'; import { readFile, readdir } from 'node:fs/promises'; import { join, relative } from 'node:path'; +import { tableRefKey } from '../../../scan/table-ref.js'; import type { ChunkResult, DiffSet, ScopeDescriptor, WorkUnit } from '../../types.js'; import { isHistoricSqlPatternInputShardPath } from './pattern-inputs.js'; import { stagedManifestSchema, stagedPatternsInputSchema, stagedTableInputSchema } from './types.js'; @@ -37,7 +38,7 @@ export async function chunkHistoricSqlUnifiedStagedDir(stagedDir: string, diffSe } const table = stagedTableInputSchema.parse(await readJson(stagedDir, path)); workUnits.push({ - unitKey: `historic-sql-table-${safeUnitKey(table.table)}`, + unitKey: `historic-sql-table-${safeUnitKey(tableRefKey(table.tableRef))}`, displayLabel: `Historic SQL usage: ${table.table}`, rawFiles: [path], dependencyPaths: ['manifest.json'], diff --git a/packages/cli/src/context/ingest/adapters/historic-sql/pattern-inputs.ts b/packages/cli/src/context/ingest/adapters/historic-sql/pattern-inputs.ts index 025fa43c..2e99836e 100644 --- a/packages/cli/src/context/ingest/adapters/historic-sql/pattern-inputs.ts +++ b/packages/cli/src/context/ingest/adapters/historic-sql/pattern-inputs.ts @@ -1,4 +1,5 @@ import { Buffer } from 'node:buffer'; +import { tableRefKey } from '../../../scan/table-ref.js'; import type { StagedPatternsInput } from './types.js'; const HISTORIC_SQL_PATTERN_WORKUNIT_DIR = 'patterns-input'; @@ -44,11 +45,16 @@ function sortedAuditTemplates(templates: readonly PatternTemplate[]): PatternTem function sortedPatternCandidates(templates: readonly PatternTemplate[]): PatternTemplate[] { return [...templates] .filter((template) => template.tablesTouched.length >= 2) - .map((template) => ({ ...template, tablesTouched: [...template.tablesTouched].sort() })) + .map((template) => ({ + ...template, + tablesTouched: [...template.tablesTouched].sort((left, right) => tableRefKey(left).localeCompare(tableRefKey(right))), + })) .sort((left, right) => { const cardinality = right.tablesTouched.length - left.tablesTouched.length; if (cardinality !== 0) return cardinality; - const tableSignature = left.tablesTouched.join('\0').localeCompare(right.tablesTouched.join('\0')); + const leftSignature = left.tablesTouched.map(tableRefKey).join('\0'); + const rightSignature = right.tablesTouched.map(tableRefKey).join('\0'); + const tableSignature = leftSignature.localeCompare(rightSignature); if (tableSignature !== 0) return tableSignature; return left.id.localeCompare(right.id); }); diff --git a/packages/cli/src/context/ingest/adapters/historic-sql/query-history-filter-picker.ts b/packages/cli/src/context/ingest/adapters/historic-sql/query-history-filter-picker.ts new file mode 100644 index 00000000..bb296513 --- /dev/null +++ b/packages/cli/src/context/ingest/adapters/historic-sql/query-history-filter-picker.ts @@ -0,0 +1,278 @@ +import { z } from 'zod'; +import type { KtxLlmRuntimePort } from '../../../../context/llm/runtime-port.js'; +import type { SqlAnalysisPort } from '../../../../context/sql-analysis/ports.js'; +import { tableRefKey } from '../../../scan/table-ref.js'; +import type { KtxTableRef } from '../../../scan/types.js'; +import { bucketDistinctUsers, bucketExecutions, bucketRecency } from './buckets.js'; +import { + compileHistoricSqlRedactionPatterns, + redactHistoricSqlText, + type HistoricSqlRedactionPattern, +} from './redaction.js'; +import { includedQueryHistoryTableRefs } from './scope-membership.js'; +import { + aggregatedTemplateSchema, + historicSqlUnifiedPullConfigSchema, + type AggregatedTemplate, + type HistoricSqlDialect, + type HistoricSqlReader, +} from './types.js'; + +export interface QueryHistoryFilterProposal { + excludedRoles: Array<{ role: string; reason: string; pattern: string }>; + consideredRoleCount: number; + skipped: { reason: 'no-llm' | 'no-daemon' | 'no-in-scope-history' | 'user-block-present' } | null; + warnings: string[]; +} + +export interface ProposeQueryHistoryServiceAccountFiltersInput { + connectionId: string; + dialect: HistoricSqlDialect; + queryClient: unknown; + reader: HistoricSqlReader; + sqlAnalysis: SqlAnalysisPort; + llmRuntime: KtxLlmRuntimePort | null; + pullConfig: unknown; + now?: Date; + userServiceAccountsPresent?: boolean; +} + +interface ParsedTemplateForPicker { + template: AggregatedTemplate; + tablesTouched: KtxTableRef[]; + includedTables: KtxTableRef[]; +} + +interface RoleAccumulator { + role: string; + executions: number; + distinctUsers: number; + lastSeen: string; + tables: Map; + templates: AggregatedTemplate[]; +} + +interface QueryHistoryRoleRecord { + role: string; + inScopeTables: string[]; + executionsBucket: string; + distinctUsersBucket: string; + recencyBucket: string; + representativeTemplates: Array<{ id: string; canonicalSql: string; dialect: HistoricSqlDialect }>; +} + +const queryHistoryFilterAdjudicationSchema = z.object({ + roles: z.array( + z.object({ + role: z.string().min(1), + exclude: z.boolean(), + reason: z.string().min(1), + }).strict(), + ), +}).strict(); + +type QueryHistoryFilterAdjudication = z.infer; + +function emptyProposal(skipped: QueryHistoryFilterProposal['skipped'], warnings: string[] = []): QueryHistoryFilterProposal { + return { excludedRoles: [], consideredRoleCount: 0, skipped, warnings }; +} + +function displayTableRef(ref: KtxTableRef): string { + return [ref.catalog, ref.db, ref.name].filter((part): part is string => !!part && part.length > 0).join('.'); +} + +function redactTemplateSqlForPicker( + template: AggregatedTemplate, + redactors: readonly HistoricSqlRedactionPattern[], +): AggregatedTemplate { + if (redactors.length === 0) { + return template; + } + return { + ...template, + canonicalSql: redactHistoricSqlText(template.canonicalSql, redactors), + }; +} + +/** @internal */ +export function regexEscapeForExactRolePattern(role: string): string { + return `^${role.replace(/[\\^$.*+?()[\]{}|]/g, '\\$&')}$`; +} + +function recordRole( + acc: RoleAccumulator, + template: AggregatedTemplate, + tables: readonly KtxTableRef[], + executions: number, +): void { + acc.executions += executions; + acc.distinctUsers = Math.max(acc.distinctUsers, template.stats.distinctUsers); + acc.lastSeen = template.stats.lastSeen > acc.lastSeen ? template.stats.lastSeen : acc.lastSeen; + for (const table of tables) { + acc.tables.set(tableRefKey(table), table); + } + acc.templates.push(template); +} + +function roleRecords(parsedTemplates: readonly ParsedTemplateForPicker[], now: Date): QueryHistoryRoleRecord[] { + const byRole = new Map(); + for (const parsed of parsedTemplates) { + for (const entry of parsed.template.topUsers) { + if (!entry.user || entry.user.trim().length === 0 || entry.executions <= 0) { + continue; + } + const role = entry.user.trim(); + const acc = + byRole.get(role) ?? + { + role, + executions: 0, + distinctUsers: 0, + lastSeen: '1970-01-01T00:00:00.000Z', + tables: new Map(), + templates: [], + }; + recordRole(acc, parsed.template, parsed.includedTables, entry.executions); + byRole.set(role, acc); + } + } + + return [...byRole.values()] + .sort((left, right) => right.executions - left.executions || left.role.localeCompare(right.role)) + .map((acc) => ({ + role: acc.role, + inScopeTables: [...acc.tables.entries()] + .sort(([left], [right]) => left.localeCompare(right)) + .slice(0, 25) + .map(([, ref]) => displayTableRef(ref)), + executionsBucket: bucketExecutions(acc.executions), + distinctUsersBucket: bucketDistinctUsers(acc.distinctUsers), + recencyBucket: bucketRecency(acc.lastSeen, now), + representativeTemplates: [...acc.templates] + .sort((left, right) => right.stats.executions - left.stats.executions || left.templateId.localeCompare(right.templateId)) + .slice(0, 3) + .map((template) => ({ + id: template.templateId, + canonicalSql: template.canonicalSql, + dialect: template.dialect, + })), + })); +} + +function adjudicationSystemPrompt(): string { + return [ + 'You are helping ktx decide whether observed query-history roles are operational service accounts.', + 'Default every role to keep. Mark exclude true only when the aggregate evidence clearly shows loader, ELT, reverse-ETL, export, refresh, or maintenance traffic rather than analyst or BI-dashboard usage.', + 'Use only the observed role records. Do not rely on a hardcoded denylist. Return structured output only.', + ].join('\n'); +} + +export async function proposeQueryHistoryServiceAccountFilters( + input: ProposeQueryHistoryServiceAccountFiltersInput, +): Promise { + if (!input.llmRuntime) { + return emptyProposal({ reason: 'no-llm' }); + } + + const config = historicSqlUnifiedPullConfigSchema.parse(input.pullConfig); + const redactors = compileHistoricSqlRedactionPatterns(config.redactionPatterns); + const now = input.now ?? new Date(); + const windowDays = 'windowDays' in config ? config.windowDays : 90; + const windowStart = new Date(now.getTime() - windowDays * 24 * 60 * 60 * 1000); + const warnings: string[] = []; + const snapshot: AggregatedTemplate[] = []; + + try { + for await (const row of input.reader.fetchAggregated(input.queryClient, { start: windowStart, end: now }, config)) { + snapshot.push(aggregatedTemplateSchema.parse(row)); + } + } catch (error) { + return emptyProposal(null, [ + `query_history_filter_picker_read_failed:${error instanceof Error ? error.message : String(error)}`, + ]); + } + + if (snapshot.length === 0) { + return emptyProposal({ reason: 'no-in-scope-history' }); + } + + const analysisItems = snapshot.map((template) => ({ id: template.templateId, sql: template.canonicalSql })); + const analysisOptions = + config.modeledTableCatalog.length > 0 ? { catalog: { tables: config.modeledTableCatalog } } : undefined; + let analysis: Awaited>; + try { + analysis = await input.sqlAnalysis.analyzeBatch(analysisItems, input.dialect, analysisOptions); + } catch (error) { + return emptyProposal({ reason: 'no-daemon' }, [ + `query_history_filter_picker_analysis_failed:${error instanceof Error ? error.message : String(error)}`, + ]); + } + + const parsedTemplates: ParsedTemplateForPicker[] = []; + for (const template of snapshot) { + const parsed = analysis.get(template.templateId); + if (!parsed || parsed.error) { + warnings.push(`query_history_filter_picker_parse_failed:${template.templateId}`); + continue; + } + const tablesTouched = [...new Map(parsed.tablesTouched.map((ref) => [tableRefKey(ref), ref])).values()] + .filter((ref) => ref.name.length > 0) + .sort((left, right) => tableRefKey(left).localeCompare(tableRefKey(right))); + const includedTables = includedQueryHistoryTableRefs(tablesTouched, config); + if (includedTables.length === 0) { + continue; + } + parsedTemplates.push({ + template: redactTemplateSqlForPicker(template, redactors), + tablesTouched, + includedTables, + }); + } + + const records = roleRecords(parsedTemplates, now); + if (records.length <= 1) { + return { + excludedRoles: [], + consideredRoleCount: records.length, + skipped: { reason: 'no-in-scope-history' }, + warnings, + }; + } + + let generated: QueryHistoryFilterAdjudication; + try { + generated = await input.llmRuntime.generateObject({ + role: 'candidateExtraction', + system: adjudicationSystemPrompt(), + prompt: JSON.stringify({ connectionId: input.connectionId, dialect: input.dialect, roles: records }), + schema: queryHistoryFilterAdjudicationSchema, + }); + } catch (error) { + return { + excludedRoles: [], + consideredRoleCount: records.length, + skipped: { reason: 'no-llm' }, + warnings: [ + ...warnings, + `query_history_filter_picker_llm_failed:${error instanceof Error ? error.message : String(error)}`, + ], + }; + } + + const knownRoles = new Set(records.map((record) => record.role)); + const excludedRoles = generated.roles + .filter((role) => role.exclude && knownRoles.has(role.role)) + .sort((left, right) => left.role.localeCompare(right.role)) + .map((role) => ({ + role: role.role, + reason: role.reason, + pattern: regexEscapeForExactRolePattern(role.role), + })); + + return { + excludedRoles, + consideredRoleCount: records.length, + skipped: input.userServiceAccountsPresent ? { reason: 'user-block-present' } : null, + warnings, + }; +} diff --git a/packages/cli/src/context/ingest/adapters/historic-sql/scope-floor.ts b/packages/cli/src/context/ingest/adapters/historic-sql/scope-floor.ts new file mode 100644 index 00000000..23b36a0e --- /dev/null +++ b/packages/cli/src/context/ingest/adapters/historic-sql/scope-floor.ts @@ -0,0 +1,260 @@ +import type { Dirent } from 'node:fs'; +import { access, readdir, readFile } from 'node:fs/promises'; +import { join, relative } from 'node:path'; +import YAML from 'yaml'; +import { getDriverRegistration } from '../../../connections/drivers.js'; +import { parseDottedTableEntry } from '../../../scan/enabled-tables.js'; +import { tableRefKey, tableRefSet, type KtxTableRefKey } from '../../../scan/table-ref.js'; +import type { KtxTableRef } from '../../../scan/types.js'; +import { readLiveDatabaseTableFiles } from '../live-database/stage.js'; + +export interface QueryHistoryScopeFloorInput { + projectDir: string; + connectionId: string; + driver: string; + connection: Record; + storedQueryHistory: Record; +} + +export interface QueryHistoryScopeFloor { + enabledTables: KtxTableRef[]; + enabledTableKeys: ReadonlySet | null; + enabledSchemas: string[]; + modeledTableCatalog: KtxTableRef[]; + floorDisabled: boolean; + warnings: string[]; +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function stringArray(value: unknown): string[] { + return Array.isArray(value) + ? value + .filter((item): item is string => typeof item === 'string' && item.trim().length > 0) + .map((item) => item.trim()) + : []; +} + +function tableRefsFromValues(values: unknown): KtxTableRef[] { + if (!Array.isArray(values)) return []; + return values.flatMap((value) => { + if (typeof value === 'string') { + const ref = parseDottedTableEntry(value); + return ref ? [ref] : []; + } + if (isRecord(value) && typeof value.name === 'string' && value.name.length > 0) { + return [ + { + catalog: typeof value.catalog === 'string' ? value.catalog : null, + db: typeof value.db === 'string' ? value.db : null, + name: value.name, + }, + ]; + } + return []; + }); +} + +function declaredSchemas(driver: string, connection: Record): string[] { + const key = getDriverRegistration(driver)?.scopeConfigKey; + if (!key) return []; + return [...new Set(stringArray(connection[key]))].sort(); +} + +function uniqueSortedTableRefs(refs: readonly KtxTableRef[]): KtxTableRef[] { + const byKey = new Map(); + for (const ref of refs) { + byKey.set(tableRefKey(ref), ref); + } + return [...byKey.entries()] + .sort(([left], [right]) => left.localeCompare(right)) + .map(([, ref]) => ref); +} + +async function latestLiveDatabaseScanDir(projectDir: string, connectionId: string): Promise { + const root = join(projectDir, 'raw-sources', connectionId, 'live-database'); + let entries: Dirent[]; + try { + entries = await readdir(root, { withFileTypes: true }); + } catch (error) { + if (error && typeof error === 'object' && 'code' in error && error.code === 'ENOENT') return null; + throw error; + } + const syncDirs = entries + .filter((entry) => entry.isDirectory()) + .map((entry) => entry.name) + .sort() + .reverse(); + for (const syncDir of syncDirs) { + const absolute = join(root, syncDir); + try { + await access(join(absolute, 'connection.json')); + return absolute; + } catch { + continue; + } + } + return null; +} + +async function scannedTableRefs( + projectDir: string, + connectionId: string, +): Promise<{ refs: KtxTableRef[]; catalogAvailable: boolean; warnings: string[] }> { + const scanDir = await latestLiveDatabaseScanDir(projectDir, connectionId); + if (!scanDir) { + return { refs: [], catalogAvailable: false, warnings: [] }; + } + try { + const tableFiles = await readLiveDatabaseTableFiles(scanDir); + return { + refs: uniqueSortedTableRefs( + tableFiles.map(({ table }) => ({ catalog: table.catalog, db: table.db, name: table.name })), + ), + catalogAvailable: true, + warnings: [], + }; + } catch (error) { + return { + refs: [], + catalogAvailable: false, + warnings: [ + `query_history_scope_floor_catalog_read_failed:live_database_scan:${error instanceof Error ? error.message : String(error)}`, + ], + }; + } +} + +async function listYamlFiles(root: string): Promise { + try { + const entries = await readdir(root, { withFileTypes: true, recursive: true }); + return entries + .filter((entry) => entry.isFile() && /\.ya?ml$/i.test(entry.name)) + .map((entry) => relative(root, join(entry.parentPath, entry.name)).replace(/\\/g, '/')) + .sort(); + } catch (error) { + if (error && typeof error === 'object' && 'code' in error && error.code === 'ENOENT') return []; + throw error; + } +} + +function refsFromManifest(content: string): KtxTableRef[] { + const parsed = YAML.parse(content) as unknown; + if (!isRecord(parsed) || !isRecord(parsed.tables)) return []; + return Object.values(parsed.tables).flatMap((entry) => { + if (!isRecord(entry) || typeof entry.table !== 'string') return []; + const ref = parseDottedTableEntry(entry.table); + return ref ? [ref] : []; + }); +} + +function refsFromStandaloneSource(content: string): KtxTableRef[] { + const parsed = YAML.parse(content) as unknown; + if (!isRecord(parsed) || typeof parsed.table !== 'string') return []; + const ref = parseDottedTableEntry(parsed.table); + return ref ? [ref] : []; +} + +async function semanticTableRefs( + projectDir: string, + connectionId: string, +): Promise<{ refs: KtxTableRef[]; warnings: string[] }> { + const root = join(projectDir, 'semantic-layer', connectionId); + const files = await listYamlFiles(root); + const refs: KtxTableRef[] = []; + const warnings: string[] = []; + for (const file of files) { + try { + const content = await readFile(join(root, file), 'utf-8'); + refs.push(...(file.startsWith('_schema/') ? refsFromManifest(content) : refsFromStandaloneSource(content))); + } catch (error) { + warnings.push( + `query_history_scope_floor_catalog_read_failed:${file}:${error instanceof Error ? error.message : String(error)}`, + ); + } + } + return { refs: uniqueSortedTableRefs(refs), warnings }; +} + +export async function resolveQueryHistoryScopeFloor(input: QueryHistoryScopeFloorInput): Promise { + const explicitEnabledTables = [ + ...tableRefsFromValues(input.storedQueryHistory.enabledTables), + ...tableRefsFromValues(input.connection.enabled_tables), + ]; + const semanticTables = await semanticTableRefs(input.projectDir, input.connectionId); + const scannedTables = await scannedTableRefs(input.projectDir, input.connectionId); + const modeledTables = uniqueSortedTableRefs([ + ...semanticTables.refs, + ...scannedTables.refs, + ...explicitEnabledTables, + ]); + const warnings = [...semanticTables.warnings, ...scannedTables.warnings]; + + if (explicitEnabledTables.length > 0) { + return { + enabledTables: explicitEnabledTables, + enabledTableKeys: tableRefSet(explicitEnabledTables), + enabledSchemas: [], + modeledTableCatalog: modeledTables, + floorDisabled: false, + warnings, + }; + } + + const explicitSchemas = stringArray(input.storedQueryHistory.enabledSchemas); + if (explicitSchemas.includes('*')) { + return { + enabledTables: [], + enabledTableKeys: null, + enabledSchemas: ['*'], + modeledTableCatalog: modeledTables, + floorDisabled: true, + warnings, + }; + } + if (explicitSchemas.length > 0) { + if (!scannedTables.catalogAvailable || modeledTables.length === 0) { + return { + enabledTables: [], + enabledTableKeys: null, + enabledSchemas: ['*'], + modeledTableCatalog: modeledTables, + floorDisabled: true, + warnings: [...warnings, 'query_history_scope_floor_disabled:catalog_unavailable'], + }; + } + return { + enabledTables: [], + enabledTableKeys: null, + enabledSchemas: [...new Set(explicitSchemas)].sort(), + modeledTableCatalog: modeledTables, + floorDisabled: false, + warnings, + }; + } + + const schemas = new Set(declaredSchemas(input.driver, input.connection)); + for (const ref of semanticTables.refs) { + if (ref.db) schemas.add(ref.db); + } + if (schemas.size > 0 && (!scannedTables.catalogAvailable || modeledTables.length === 0)) { + return { + enabledTables: [], + enabledTableKeys: null, + enabledSchemas: ['*'], + modeledTableCatalog: modeledTables, + floorDisabled: true, + warnings: [...warnings, 'query_history_scope_floor_disabled:catalog_unavailable'], + }; + } + return { + enabledTables: [], + enabledTableKeys: null, + enabledSchemas: [...schemas].sort(), + modeledTableCatalog: modeledTables, + floorDisabled: false, + warnings, + }; +} diff --git a/packages/cli/src/context/ingest/adapters/historic-sql/scope-membership.ts b/packages/cli/src/context/ingest/adapters/historic-sql/scope-membership.ts new file mode 100644 index 00000000..8852a82d --- /dev/null +++ b/packages/cli/src/context/ingest/adapters/historic-sql/scope-membership.ts @@ -0,0 +1,45 @@ +import { tableRefKey, tableRefSet } from '../../../scan/table-ref.js'; +import type { KtxTableRef } from '../../../scan/types.js'; + +export interface QueryHistoryScopeMembershipConfig { + enabledTables: readonly KtxTableRef[]; + enabledSchemas: readonly string[]; +} + +function schemaNameForRef(ref: KtxTableRef): string | null { + return ref.db && ref.db.length > 0 ? ref.db : null; +} + +function schemaNamesFromConfig(enabledSchemas: readonly string[]): Set { + return new Set(enabledSchemas.filter((schema) => schema !== '*')); +} + +export function isQueryHistoryScopeFloorDisabled(config: QueryHistoryScopeMembershipConfig): boolean { + return config.enabledSchemas.includes('*'); +} + +export function shouldFailOpenQueryHistoryScope(config: QueryHistoryScopeMembershipConfig): boolean { + return ( + config.enabledTables.length === 0 && + !isQueryHistoryScopeFloorDisabled(config) && + config.enabledSchemas.length === 0 + ); +} + +export function includedQueryHistoryTableRefs( + tablesTouched: readonly KtxTableRef[], + config: QueryHistoryScopeMembershipConfig, +): KtxTableRef[] { + if (config.enabledTables.length > 0) { + const enabled = tableRefSet(config.enabledTables); + return tablesTouched.filter((ref) => enabled.has(tableRefKey(ref))); + } + if (isQueryHistoryScopeFloorDisabled(config) || shouldFailOpenQueryHistoryScope(config)) { + return [...tablesTouched]; + } + const schemas = schemaNamesFromConfig(config.enabledSchemas); + return tablesTouched.filter((ref) => { + const schema = schemaNameForRef(ref); + return schema !== null && schemas.has(schema); + }); +} diff --git a/packages/cli/src/context/ingest/adapters/historic-sql/snowflake-query-history-reader.ts b/packages/cli/src/context/ingest/adapters/historic-sql/snowflake-query-history-reader.ts index 539df3c3..65aafb12 100644 --- a/packages/cli/src/context/ingest/adapters/historic-sql/snowflake-query-history-reader.ts +++ b/packages/cli/src/context/ingest/adapters/historic-sql/snowflake-query-history-reader.ts @@ -188,26 +188,75 @@ export class SnowflakeHistoricSqlQueryHistoryReader { config: HistoricSqlUnifiedPullConfig, ): AsyncIterable { const sql = ` +WITH filtered_queries AS ( + SELECT + query_hash, + query_text, + user_name, + start_time, + total_elapsed_time, + execution_status, + rows_produced + FROM SNOWFLAKE.ACCOUNT_USAGE.QUERY_HISTORY + WHERE query_text IS NOT NULL + AND query_type IN ('SELECT', 'MERGE') + AND start_time >= ${timestampLiteral(window.start)} + AND start_time < ${timestampLiteral(window.end)} +), +template_stats AS ( + SELECT + query_hash AS template_id, + MIN(query_text) AS canonical_sql, + COUNT(*) AS executions, + COUNT(DISTINCT user_name) AS distinct_users, + MIN(start_time) AS first_seen, + MAX(start_time) AS last_seen, + APPROX_PERCENTILE(total_elapsed_time, 0.50) AS p50_ms, + APPROX_PERCENTILE(total_elapsed_time, 0.95) AS p95_ms, + DIV0(COUNT_IF(execution_status != 'SUCCESS'), COUNT(*)) AS error_rate, + SUM(rows_produced) AS rows_produced + FROM filtered_queries + GROUP BY query_hash + HAVING COUNT(*) >= ${config.minExecutions} +), +template_users AS ( + SELECT + query_hash AS template_id, + user_name AS user, + COUNT(*) AS executions, + MAX(start_time) AS last_seen + FROM filtered_queries + GROUP BY query_hash, user_name +) SELECT - query_hash AS template_id, - MIN(query_text) AS canonical_sql, - COUNT(*) AS executions, - COUNT(DISTINCT user_name) AS distinct_users, - MIN(start_time) AS first_seen, - MAX(start_time) AS last_seen, - APPROX_PERCENTILE(total_elapsed_time, 0.50) AS p50_ms, - APPROX_PERCENTILE(total_elapsed_time, 0.95) AS p95_ms, - DIV0(COUNT_IF(execution_status != 'SUCCESS'), COUNT(*)) AS error_rate, - SUM(rows_produced) AS rows_produced, - ARRAY_AGG(OBJECT_CONSTRUCT('user', user_name, 'executions', 1)) WITHIN GROUP (ORDER BY start_time DESC)::string AS top_users -FROM SNOWFLAKE.ACCOUNT_USAGE.QUERY_HISTORY -WHERE query_text IS NOT NULL - AND query_type IN ('SELECT', 'MERGE') - AND start_time >= ${timestampLiteral(window.start)} - AND start_time < ${timestampLiteral(window.end)} -GROUP BY query_hash -HAVING COUNT(*) >= ${config.minExecutions} -ORDER BY executions DESC`.trim(); + stats.template_id, + stats.canonical_sql, + stats.executions, + stats.distinct_users, + stats.first_seen, + stats.last_seen, + stats.p50_ms, + stats.p95_ms, + stats.error_rate, + stats.rows_produced, + ARRAY_AGG( + OBJECT_CONSTRUCT('user', users.user, 'executions', users.executions) + ) WITHIN GROUP (ORDER BY users.executions DESC, users.last_seen DESC)::string AS top_users +FROM template_stats AS stats +JOIN template_users AS users + ON users.template_id = stats.template_id +GROUP BY + stats.template_id, + stats.canonical_sql, + stats.executions, + stats.distinct_users, + stats.first_seen, + stats.last_seen, + stats.p50_ms, + stats.p95_ms, + stats.error_rate, + stats.rows_produced +ORDER BY stats.executions DESC`.trim(); const result = await queryClient(client).executeQuery(sql); if (result.error) { throw grantsError(result.error); diff --git a/packages/cli/src/context/ingest/adapters/historic-sql/stage-unified.ts b/packages/cli/src/context/ingest/adapters/historic-sql/stage-unified.ts index 853a3e68..84ec75a7 100644 --- a/packages/cli/src/context/ingest/adapters/historic-sql/stage-unified.ts +++ b/packages/cli/src/context/ingest/adapters/historic-sql/stage-unified.ts @@ -1,6 +1,8 @@ import { mkdir, writeFile } from 'node:fs/promises'; import { dirname, join } from 'node:path'; import type { SqlAnalysisPort } from '../../../../context/sql-analysis/ports.js'; +import { tableRefKey, type KtxTableRefKey } from '../../../scan/table-ref.js'; +import type { KtxTableRef } from '../../../scan/types.js'; import { bucketDistinctUsers, bucketErrorRate, @@ -15,6 +17,11 @@ import { redactHistoricSqlText, type HistoricSqlRedactionPattern, } from './redaction.js'; +import { + includedQueryHistoryTableRefs, + isQueryHistoryScopeFloorDisabled, + shouldFailOpenQueryHistoryScope, +} from './scope-membership.js'; import { HISTORIC_SQL_SOURCE_KEY, aggregatedTemplateSchema, @@ -38,17 +45,13 @@ interface StageHistoricSqlAggregatedSnapshotInput { interface ParsedTemplate { template: AggregatedTemplate; - tablesTouched: string[]; - includedTables: string[]; + tablesTouched: KtxTableRef[]; + includedTables: KtxTableRef[]; columnsByClause: Record; } -interface EnabledTableFilter { - exact: Set; - uniqueUnqualified: Set; -} - interface TableAccumulator { + tableRef: KtxTableRef; table: string; executions: number; distinctUsers: number; @@ -105,8 +108,7 @@ function shouldDropByUsers(template: AggregatedTemplate, config: HistoricSqlUnif const matchingExecutions = template.topUsers .filter((entry) => matchesAny(entry.user, patterns)) .reduce((sum, entry) => sum + entry.executions, 0); - const allExecutions = template.topUsers.reduce((sum, entry) => sum + entry.executions, 0); - const serviceOnly = allExecutions > 0 && matchingExecutions >= allExecutions; + const serviceOnly = template.stats.executions > 0 && matchingExecutions >= template.stats.executions; return service.mode === 'exclude' ? serviceOnly : !serviceOnly; } @@ -122,90 +124,8 @@ function shouldDropTemplate(template: AggregatedTemplate, config: HistoricSqlUni return false; } -function normalizeTableIdentifier(value: string): string { - return value.trim().toLowerCase(); -} - -function unqualifiedTableIdentifier(value: string): string { - const parts = normalizeTableIdentifier(value).split('.').filter(Boolean); - return parts.at(-1) ?? ''; -} - -function buildEnabledTableFilter(enabledTables: string[]): EnabledTableFilter | null { - if (enabledTables.length === 0) { - return null; - } - const exact = new Set(enabledTables.map(normalizeTableIdentifier).filter((value) => value.length > 0)); - const unqualifiedCounts = new Map(); - for (const table of exact) { - const unqualified = unqualifiedTableIdentifier(table); - if (unqualified.length > 0) { - unqualifiedCounts.set(unqualified, (unqualifiedCounts.get(unqualified) ?? 0) + 1); - } - } - return { - exact, - uniqueUnqualified: new Set( - [...unqualifiedCounts.entries()] - .filter(([, count]) => count === 1) - .map(([table]) => table), - ), - }; -} - -function isEnabledTable(table: string, filter: EnabledTableFilter | null): boolean { - if (!filter) { - return true; - } - const normalized = normalizeTableIdentifier(table); - return filter.exact.has(normalized) || filter.uniqueUnqualified.has(unqualifiedTableIdentifier(normalized)); -} - -/** - * pg_stat_statements records queries as written, so the same physical table can appear - * both bare (`accounts`, resolved via search_path) and schema-qualified - * (`orbit_raw.accounts`). Collapse a bare identifier into its schema-qualified form when - * exactly one qualified form shares its unqualified name, so the two never become separate - * work units. Ambiguous bare names (two qualified forms) are left untouched. - */ -function canonicalizeTableIdentifiers(parsedTemplates: ParsedTemplate[]): void { - const all = new Set(); - for (const parsed of parsedTemplates) { - for (const table of parsed.includedTables) { - all.add(table); - } - } - const qualifiedByUnqualified = new Map>(); - for (const table of all) { - if (!table.includes('.')) { - continue; - } - const unqualified = unqualifiedTableIdentifier(table); - if (unqualified.length === 0) { - continue; - } - const forms = qualifiedByUnqualified.get(unqualified) ?? new Set(); - forms.add(table); - qualifiedByUnqualified.set(unqualified, forms); - } - const canonical = new Map(); - for (const table of all) { - if (table.includes('.')) { - continue; - } - const forms = qualifiedByUnqualified.get(unqualifiedTableIdentifier(table)); - if (forms && forms.size === 1) { - canonical.set(table, [...forms][0]); - } - } - if (canonical.size === 0) { - return; - } - const remap = (table: string): string => canonical.get(table) ?? table; - for (const parsed of parsedTemplates) { - parsed.includedTables = [...new Set(parsed.includedTables.map(remap))].sort(); - parsed.tablesTouched = [...new Set(parsed.tablesTouched.map(remap))].sort(); - } +function displayTableRef(ref: KtxTableRef): string { + return [ref.catalog, ref.db, ref.name].filter((part): part is string => !!part && part.length > 0).join('.'); } function historicSqlWindowDays(config: HistoricSqlUnifiedPullConfig): number { @@ -240,9 +160,10 @@ function recordJoin(acc: TableAccumulator, otherTable: string, columns: string[] } } -function accumulatorFor(table: string): TableAccumulator { +function accumulatorFor(tableRef: KtxTableRef): TableAccumulator { return { - table, + tableRef, + table: displayTableRef(tableRef), executions: 0, distinctUsers: 0, errorRateNumerator: 0, @@ -272,8 +193,8 @@ function addTemplate(acc: TableAccumulator, parsed: ParsedTemplate): void { } } const joinColumns = parsed.columnsByClause.join ?? []; - for (const otherTable of parsed.tablesTouched.filter((table) => table !== acc.table)) { - recordJoin(acc, otherTable, joinColumns, executions); + for (const otherTable of parsed.tablesTouched.filter((table) => tableRefKey(table) !== tableRefKey(acc.tableRef))) { + recordJoin(acc, displayTableRef(otherTable), joinColumns, executions); } acc.topTemplates.push(parsed.template); } @@ -310,6 +231,7 @@ function toStagedTable(acc: TableAccumulator, now: Date): StagedTableInput { return { table: acc.table, + tableRef: acc.tableRef, stats: { executionsBucket: bucketExecutions(acc.executions), distinctUsersBucket: bucketDistinctUsers(acc.distinctUsers), @@ -329,7 +251,7 @@ function toPatternsInput(parsedTemplates: ParsedTemplate[]): StagedPatternsInput .map(({ template, tablesTouched }) => ({ id: template.templateId, canonicalSql: template.canonicalSql, - tablesTouched: [...tablesTouched].sort(), + tablesTouched: [...tablesTouched].sort((left, right) => tableRefKey(left).localeCompare(tableRefKey(right))), executionsBucket: bucketExecutions(template.stats.executions), distinctUsersBucket: bucketDistinctUsers(template.stats.distinctUsers), dialect: template.dialect, @@ -340,7 +262,6 @@ function toPatternsInput(parsedTemplates: ParsedTemplate[]): StagedPatternsInput export async function stageHistoricSqlAggregatedSnapshot(input: StageHistoricSqlAggregatedSnapshotInput): Promise { const config = historicSqlUnifiedPullConfigSchema.parse(input.pullConfig); - const enabledTableFilter = buildEnabledTableFilter(config.enabledTables); const redactors = compileHistoricSqlRedactionPatterns(config.redactionPatterns); const now = input.now ?? new Date(); const windowStart = new Date(now.getTime() - historicSqlWindowDays(config) * 24 * 60 * 60 * 1000); @@ -356,11 +277,25 @@ export async function stageHistoricSqlAggregatedSnapshot(input: StageHistoricSql } } - const analysis = await input.sqlAnalysis.analyzeBatch( - snapshot.map((template) => ({ id: template.templateId, sql: template.canonicalSql })), - config.dialect, - ); - const warnings: string[] = []; + const analysisItems = snapshot.map((template) => ({ id: template.templateId, sql: template.canonicalSql })); + const analysisOptions = + config.modeledTableCatalog.length > 0 ? { catalog: { tables: config.modeledTableCatalog } } : undefined; + const warnings: string[] = [ + ...config.scopeFloorWarnings, + ...(shouldFailOpenQueryHistoryScope(config) ? ['query_history_scope_floor_disabled:empty_modeled_scope'] : []), + ]; + let scopeDisabledByQualificationFailure = false; + let analysis: Awaited>; + try { + analysis = await input.sqlAnalysis.analyzeBatch(analysisItems, config.dialect, analysisOptions); + } catch (error) { + if (!analysisOptions || config.enabledTables.length > 0 || isQueryHistoryScopeFloorDisabled(config)) { + throw error; + } + warnings.push('query_history_scope_floor_disabled:catalog_qualification_failed'); + scopeDisabledByQualificationFailure = true; + analysis = await input.sqlAnalysis.analyzeBatch(analysisItems, config.dialect, undefined); + } const parsedTemplates: ParsedTemplate[] = []; for (const template of snapshot) { const parsed = analysis.get(template.templateId); @@ -368,8 +303,12 @@ export async function stageHistoricSqlAggregatedSnapshot(input: StageHistoricSql warnings.push(`parse_failed:${template.templateId}`); continue; } - const tablesTouched = [...new Set(parsed.tablesTouched)].filter((table) => table.length > 0).sort(); - const includedTables = tablesTouched.filter((table) => isEnabledTable(table, enabledTableFilter)); + const tablesTouched = [...new Map(parsed.tablesTouched.map((ref) => [tableRefKey(ref), ref])).values()] + .filter((ref) => ref.name.length > 0) + .sort((left, right) => tableRefKey(left).localeCompare(tableRefKey(right))); + const includedTables = scopeDisabledByQualificationFailure + ? [...tablesTouched] + : includedQueryHistoryTableRefs(tablesTouched, config); if (includedTables.length === 0) { continue; } @@ -383,24 +322,23 @@ export async function stageHistoricSqlAggregatedSnapshot(input: StageHistoricSql }); } - canonicalizeTableIdentifiers(parsedTemplates); - - const byTable = new Map(); + const byTable = new Map(); for (const parsed of parsedTemplates) { - for (const table of parsed.includedTables) { - const acc = byTable.get(table) ?? accumulatorFor(table); + for (const tableRef of parsed.includedTables) { + const key = tableRefKey(tableRef); + const acc = byTable.get(key) ?? accumulatorFor(tableRef); addTemplate(acc, parsed); - byTable.set(table, acc); + byTable.set(key, acc); } } await mkdir(input.stagedDir, { recursive: true }); - for (const [table, acc] of [...byTable.entries()].sort(([left], [right]) => left.localeCompare(right))) { - await writeJson(input.stagedDir, `tables/${table}.json`, toStagedTable(acc, now)); + for (const [, acc] of [...byTable.entries()].sort((left, right) => left[0].localeCompare(right[0]))) { + await writeJson(input.stagedDir, `tables/${acc.table}.json`, toStagedTable(acc, now)); } const patternsInput = toPatternsInput(parsedTemplates); const patternInputSplit = splitHistoricSqlPatternInputs(patternsInput); - const allWarnings = [...warnings, ...patternInputSplit.warnings]; + const allWarnings = [...new Set([...warnings, ...patternInputSplit.warnings])]; await writeJson(input.stagedDir, 'patterns-input.json', patternInputSplit.auditInput); for (const shard of patternInputSplit.shards) { await writeJson(input.stagedDir, shard.path, shard.input); diff --git a/packages/cli/src/context/ingest/adapters/historic-sql/types.ts b/packages/cli/src/context/ingest/adapters/historic-sql/types.ts index 1d256b13..aca50c4e 100644 --- a/packages/cli/src/context/ingest/adapters/historic-sql/types.ts +++ b/packages/cli/src/context/ingest/adapters/historic-sql/types.ts @@ -8,9 +8,22 @@ export type HistoricSqlDialect = z.infer; const filterModeSchema = z.enum(['exclude', 'include', 'mark-only']); +const ktxTableRefSchema = z.object({ + catalog: z.string().nullable(), + db: z.string().nullable(), + name: z.string().min(1), +}).strict(); + +const ktxTableRefWithColumnsSchema = ktxTableRefSchema.extend({ + columns: z.array(z.string().min(1)).optional(), +}).strict(); + const historicSqlCommonPullConfigSchema = z.object({ minExecutions: z.number().int().nonnegative().default(5), - enabledTables: z.array(z.string().min(1)).default([]), + enabledTables: z.array(ktxTableRefSchema).default([]), + enabledSchemas: z.array(z.string().min(1)).default([]), + modeledTableCatalog: z.array(ktxTableRefWithColumnsSchema).default([]), + scopeFloorWarnings: z.array(z.string()).default([]), filters: z.object({ serviceAccounts: z.object({ patterns: z.array(z.string()).default([]), @@ -68,6 +81,7 @@ export type AggregatedTemplate = z.infer; export const stagedTableInputSchema = z.object({ table: z.string().min(1), + tableRef: ktxTableRefSchema, stats: z.object({ executionsBucket: z.string(), distinctUsersBucket: z.string(), @@ -93,7 +107,7 @@ export const stagedPatternsInputSchema = z.object({ templates: z.array(z.object({ id: z.string(), canonicalSql: z.string(), - tablesTouched: z.array(z.string()), + tablesTouched: z.array(ktxTableRefSchema), executionsBucket: z.string(), distinctUsersBucket: z.string(), dialect: historicSqlDialectSchema, diff --git a/packages/cli/src/context/ingest/local-adapters.ts b/packages/cli/src/context/ingest/local-adapters.ts index 4739f4e4..3cd8a998 100644 --- a/packages/cli/src/context/ingest/local-adapters.ts +++ b/packages/cli/src/context/ingest/local-adapters.ts @@ -9,6 +9,7 @@ import { DbtSourceAdapter } from './adapters/dbt/dbt.adapter.js'; import { FakeSourceAdapter } from './adapters/fake/fake.adapter.js'; import { HistoricSqlSourceAdapter } from './adapters/historic-sql/historic-sql.adapter.js'; import { PostgresPgssReader } from './adapters/historic-sql/postgres-pgss-reader.js'; +import { resolveQueryHistoryScopeFloor } from './adapters/historic-sql/scope-floor.js'; import { HISTORIC_SQL_SOURCE_KEY, historicSqlUnifiedPullConfigSchema, @@ -179,12 +180,39 @@ function queryHistoryRecord(connection: unknown): Record | null return queryHistory; } -function queryHistoryPullConfig(connection: unknown): Record | null { +async function queryHistoryPullConfig( + project: KtxLocalProject, + connectionId: string, + connection: unknown, +): Promise | null> { const queryHistory = queryHistoryRecord(connection); if (queryHistory?.enabled !== true || !isRecord(connection)) return null; - const dialect = historicSqlDialectByDriver.get(String(connection.driver ?? '').toLowerCase()); + const driver = String(connection.driver ?? '').toLowerCase(); + const dialect = historicSqlDialectByDriver.get(driver); if (!dialect) return null; - return { ...queryHistory, dialect }; + const scopeFloor = await resolveQueryHistoryScopeFloor({ + projectDir: project.projectDir, + connectionId, + driver, + connection, + storedQueryHistory: queryHistory, + }); + const { + enabled: _enabled, + dialect: _dialect, + enabledTables: _enabledTables, + enabledSchemas: _enabledSchemas, + scopeFloorWarnings: _scopeFloorWarnings, + ...stored + } = queryHistory; + return { + ...stored, + dialect, + ...(scopeFloor.enabledTables.length > 0 ? { enabledTables: scopeFloor.enabledTables } : {}), + ...(scopeFloor.enabledSchemas.length > 0 ? { enabledSchemas: scopeFloor.enabledSchemas } : {}), + ...(scopeFloor.modeledTableCatalog.length > 0 ? { modeledTableCatalog: scopeFloor.modeledTableCatalog } : {}), + ...(scopeFloor.warnings.length > 0 ? { scopeFloorWarnings: scopeFloor.warnings } : {}), + }; } function stringField(value: unknown): string | null { @@ -245,7 +273,7 @@ export async function localPullConfigForAdapter( if (options.historicSqlPullConfigOverride) { return historicSqlUnifiedPullConfigSchema.parse(options.historicSqlPullConfigOverride); } - const queryHistory = queryHistoryPullConfig(connection); + const queryHistory = await queryHistoryPullConfig(project, connectionId, connection); if (!queryHistory) { throw new Error(`Connection "${connectionId}" does not have context.queryHistory.enabled: true`); } diff --git a/packages/cli/src/context/sql-analysis/http-sql-analysis-port.ts b/packages/cli/src/context/sql-analysis/http-sql-analysis-port.ts index 238b8863..3093816d 100644 --- a/packages/cli/src/context/sql-analysis/http-sql-analysis-port.ts +++ b/packages/cli/src/context/sql-analysis/http-sql-analysis-port.ts @@ -1,8 +1,10 @@ import { request as httpRequest } from 'node:http'; import { request as httpsRequest } from 'node:https'; import { URL } from 'node:url'; +import type { KtxTableRef } from '../scan/types.js'; import type { SqlAnalysisBatchItem, + SqlAnalysisBatchOptions, SqlAnalysisBatchResult, SqlAnalysisDialect, SqlAnalysisFingerprintResult, @@ -89,6 +91,14 @@ function optionalString(raw: Record, field: string): string | n throw new Error(`sql analysis response has invalid optional string field ${field}`); } +function optionalNullableStringField(raw: Record, field: string): string | null { + const value = raw[field]; + if (value === null || value === undefined || typeof value === 'string') { + return value ?? null; + } + throw new Error(`sql analysis response has invalid optional nullable string field ${field}`); +} + function requiredStringArray(raw: Record, field: string): string[] { const value = raw[field]; if (!Array.isArray(value) || value.some((item) => typeof item !== 'string')) { @@ -175,10 +185,34 @@ function mapColumnsByClause(raw: Record): SqlAnalysisBatchResul return result; } +function requiredTableRef(raw: unknown, field: string): KtxTableRef { + if (!raw || typeof raw !== 'object' || Array.isArray(raw)) { + throw new Error(`sql analysis response contains invalid table ref in ${field}`); + } + const record = raw as Record; + const name = record.name; + if (typeof name !== 'string' || name.length === 0) { + throw new Error(`sql analysis response table ref in ${field} is missing name`); + } + return { + catalog: optionalNullableStringField(record, 'catalog'), + db: optionalNullableStringField(record, 'db'), + name, + }; +} + +function requiredTableRefArray(raw: Record, field: string): KtxTableRef[] { + const value = raw[field]; + if (!Array.isArray(value)) { + throw new Error(`sql analysis response is missing table-ref[] field ${field}`); + } + return value.map((item, index) => requiredTableRef(item, `${field}.${index}`)); +} + function mapBatchResult(raw: Record): SqlAnalysisBatchResult { const error = optionalString(raw, 'error'); return { - tablesTouched: requiredStringArray(raw, 'tables_touched'), + tablesTouched: requiredTableRefArray(raw, 'tables_touched'), columnsByClause: mapColumnsByClause(raw), ...(error !== undefined ? { error } : {}), }; @@ -215,10 +249,11 @@ export function createHttpSqlAnalysisPort(options: HttpSqlAnalysisPortOptions): }); return mapResult(raw); }, - async analyzeBatch(items: SqlAnalysisBatchItem[], dialect: SqlAnalysisDialect) { + async analyzeBatch(items: SqlAnalysisBatchItem[], dialect: SqlAnalysisDialect, options?: SqlAnalysisBatchOptions) { const raw = await requestJson('/sql/analyze-batch', { dialect, items, + ...(options?.catalog ? { catalog: options.catalog } : {}), }); return mapBatchResponse(raw); }, diff --git a/packages/cli/src/context/sql-analysis/ports.ts b/packages/cli/src/context/sql-analysis/ports.ts index 887be605..898fca38 100644 --- a/packages/cli/src/context/sql-analysis/ports.ts +++ b/packages/cli/src/context/sql-analysis/ports.ts @@ -1,3 +1,5 @@ +import type { KtxTableRef } from '../scan/types.js'; + export type SqlAnalysisDialect = | 'bigquery' | 'snowflake' @@ -32,8 +34,20 @@ export interface SqlAnalysisBatchItem { sql: string; } +interface SqlAnalysisCatalogTable extends KtxTableRef { + columns?: string[]; +} + +interface SqlAnalysisCatalog { + tables: SqlAnalysisCatalogTable[]; +} + +export interface SqlAnalysisBatchOptions { + catalog?: SqlAnalysisCatalog; +} + export interface SqlAnalysisBatchResult { - tablesTouched: string[]; + tablesTouched: KtxTableRef[]; columnsByClause: Partial>; error?: string | null; } @@ -48,6 +62,7 @@ export interface SqlAnalysisPort { analyzeBatch( items: SqlAnalysisBatchItem[], dialect: SqlAnalysisDialect, + options?: SqlAnalysisBatchOptions, ): Promise>; validateReadOnly(sql: string, dialect: SqlAnalysisDialect): Promise; } diff --git a/packages/cli/src/local-adapters.ts b/packages/cli/src/local-adapters.ts index 0cd2d940..3e3b0486 100644 --- a/packages/cli/src/local-adapters.ts +++ b/packages/cli/src/local-adapters.ts @@ -15,7 +15,7 @@ import { BigQueryHistoricSqlQueryHistoryReader } from './context/ingest/adapters import { historicSqlDialectForConnectionDriver } from './context/ingest/adapters/historic-sql/connection-dialect.js'; import { createDaemonLiveDatabaseIntrospection } from './context/ingest/adapters/live-database/daemon-introspection.js'; import { createDefaultLocalIngestAdapters, type DefaultLocalIngestAdaptersOptions } from './context/ingest/local-adapters.js'; -import type { HistoricSqlReader } from './context/ingest/adapters/historic-sql/types.js'; +import type { HistoricSqlDialect, HistoricSqlReader } from './context/ingest/adapters/historic-sql/types.js'; import type { LiveDatabaseIntrospectionOptions, LiveDatabaseIntrospectionPort, @@ -31,7 +31,7 @@ import { createManagedDaemonLookerTableIdentifierParser, createManagedDaemonSqlAnalysisPort, managedDaemonDatabaseIntrospectionOptions, - type ManagedPythonCoreDaemonOptions, + type ManagedPythonDaemonHttpOptions, } from './managed-python-http.js'; import type { KtxOperationalLogger } from './io/logger.js'; import { resolveKtxConfigReference } from './context/core/config-reference.js'; @@ -161,10 +161,17 @@ export interface KtxCliLocalIngestAdaptersOptions extends DefaultLocalIngestAdap historicSqlConnectionId?: string; sqlAnalysis?: SqlAnalysisPort; sqlAnalysisUrl?: string; - managedDaemon?: ManagedPythonCoreDaemonOptions; + managedDaemon?: ManagedPythonDaemonHttpOptions; logger?: KtxOperationalLogger; } +export interface KtxCliHistoricSqlRuntime { + dialect: HistoricSqlDialect; + sqlAnalysis: SqlAnalysisPort; + reader: HistoricSqlReader; + queryClient: unknown; +} + function createEphemeralPostgresHistoricSqlClient(project: KtxLocalProject, connectionId: string) { const connection = project.config.connections[connectionId] as KtxPostgresConnectionConfig | undefined; const inputDriver = connection?.driver ?? 'unknown'; @@ -262,7 +269,10 @@ function bigQueryRegion(connection: KtxBigQueryConnectionConfig): string { : 'us'; } -function historicSqlOptionsForLocalRun(project: KtxLocalProject, options: KtxCliLocalIngestAdaptersOptions) { +function historicSqlOptionsForLocalRun( + project: KtxLocalProject, + options: KtxCliLocalIngestAdaptersOptions, +): KtxCliHistoricSqlRuntime | undefined { const connectionId = options.historicSqlConnectionId; if (!connectionId) { return undefined; @@ -285,6 +295,7 @@ function historicSqlOptionsForLocalRun(project: KtxLocalProject, options: KtxCli if (dialect === 'postgres') { return { ...base, + dialect, reader: new PostgresPgssReader() satisfies HistoricSqlReader, queryClient: createEphemeralPostgresHistoricSqlClient(project, connectionId), }; @@ -297,6 +308,7 @@ function historicSqlOptionsForLocalRun(project: KtxLocalProject, options: KtxCli } return { ...base, + dialect, reader: new BigQueryHistoricSqlQueryHistoryReader({ projectId: bigQueryProjectId(connection, process.env), region: bigQueryRegion(connection), @@ -307,6 +319,7 @@ function historicSqlOptionsForLocalRun(project: KtxLocalProject, options: KtxCli return { ...base, + dialect, reader: new SnowflakeHistoricSqlQueryHistoryReader() satisfies HistoricSqlReader, queryClient: { async executeQuery(query: string) { @@ -318,11 +331,24 @@ function historicSqlOptionsForLocalRun(project: KtxLocalProject, options: KtxCli }; } +export function createKtxCliHistoricSqlRuntime( + project: KtxLocalProject, + connectionId: string, + options: KtxCliLocalIngestAdaptersOptions = {}, +): KtxCliHistoricSqlRuntime | undefined { + return historicSqlOptionsForLocalRun(project, { + ...options, + historicSqlConnectionId: connectionId, + }); +} + export function createKtxCliLocalIngestAdapters( project: KtxLocalProject, options: KtxCliLocalIngestAdaptersOptions = {}, ): SourceAdapter[] { - const historicSql = historicSqlOptionsForLocalRun(project, options); + const historicSql = options.historicSqlConnectionId + ? createKtxCliHistoricSqlRuntime(project, options.historicSqlConnectionId, options) + : undefined; const base = createDefaultLocalIngestAdapters(project, { ...options, databaseIntrospection: ktxCliDaemonDatabaseIntrospectionOptions(options), diff --git a/packages/cli/src/public-ingest.ts b/packages/cli/src/public-ingest.ts index 7fc43ac4..44a2b024 100644 --- a/packages/cli/src/public-ingest.ts +++ b/packages/cli/src/public-ingest.ts @@ -5,6 +5,7 @@ import type { KtxProgressPort } from './context/scan/types.js'; import type { KtxCliIo } from './index.js'; import type { KtxIngestArgs, KtxIngestDeps, KtxIngestProgressUpdate } from './ingest.js'; import { isDatabaseDriver, normalizeConnectionDriver } from './connection-drivers.js'; +import { resolveQueryHistoryScopeFloor } from './context/ingest/adapters/historic-sql/scope-floor.js'; import { ensureManagedPythonCommandRuntime, type KtxManagedPythonInstallPolicy, @@ -19,6 +20,7 @@ import { import { createAggregateProgressPort } from './progress-port-adapter.js'; import { resolvePublicIngestRuntimeRequirements } from './runtime-requirements.js'; import type { KtxScanArgs, KtxScanDeps } from './scan.js'; +import type { KtxTableRef } from './context/scan/types.js'; import { profileMark } from './startup-profile.js'; import { isDemoConnection } from './telemetry/demo-detect.js'; import { emitProjectStackSnapshot, emitTelemetryEvent } from './telemetry/index.js'; @@ -281,26 +283,35 @@ function positiveInteger(value: unknown): number | undefined { return typeof value === 'number' && Number.isInteger(value) && value > 0 ? value : undefined; } -function enabledTablesForConnection(connection: KtxProjectConnectionConfig): string[] | undefined { - const raw = connection.enabled_tables; - if (!Array.isArray(raw)) { - return undefined; - } - const tables = raw.filter((value): value is string => typeof value === 'string' && value.trim().length > 0); - return tables.length > 0 ? tables : undefined; -} - -function queryHistoryPullConfig(input: { +/** @internal */ +export function queryHistoryPullConfig(input: { stored: Record; dialect: HistoricSqlDialect; windowDays?: number; - enabledTables?: string[]; + enabledTables?: KtxTableRef[]; + enabledSchemas?: string[]; + modeledTableCatalog?: KtxTableRef[]; + scopeFloorWarnings?: string[]; }): Record { - const { enabled: _enabled, dialect: _dialect, ...storedConfig } = input.stored; + const { + enabled: _enabled, + dialect: _dialect, + enabledTables: _enabledTables, + enabledSchemas: _enabledSchemas, + scopeFloorWarnings: _scopeFloorWarnings, + ...storedConfig + } = input.stored; return { ...storedConfig, dialect: input.dialect, - ...(input.enabledTables ? { enabledTables: input.enabledTables } : {}), + ...(input.enabledTables && input.enabledTables.length > 0 ? { enabledTables: input.enabledTables } : {}), + ...(input.enabledSchemas && input.enabledSchemas.length > 0 ? { enabledSchemas: input.enabledSchemas } : {}), + ...(input.modeledTableCatalog && input.modeledTableCatalog.length > 0 + ? { modeledTableCatalog: input.modeledTableCatalog } + : {}), + ...(input.scopeFloorWarnings && input.scopeFloorWarnings.length > 0 + ? { scopeFloorWarnings: input.scopeFloorWarnings } + : {}), ...(input.windowDays !== undefined ? { windowDays: input.windowDays } : {}), }; } @@ -361,7 +372,6 @@ function resolveDatabaseTargetOptions(input: { stored: storedQh, dialect, windowDays: queryHistory.windowDays, - enabledTables: enabledTablesForConnection(input.connection), }), }, steps: ['database-schema', 'query-history'], @@ -374,6 +384,43 @@ function resolveDatabaseTargetOptions(input: { }; } +async function resolvedQueryHistoryPullConfigForTarget( + target: KtxPublicIngestPlanTarget, + project: KtxPublicIngestProject, +): Promise | null> { + if (target.operation !== 'database-ingest' || target.queryHistory?.enabled !== true || !target.queryHistory.dialect) { + return null; + } + const connection = project.config.connections[target.connectionId]; + if (!connection) { + return ( + target.queryHistory.pullConfig ?? + queryHistoryPullConfig({ + stored: {}, + dialect: target.queryHistory.dialect, + windowDays: target.queryHistory.windowDays, + }) + ); + } + const stored = storedQueryHistory(connection); + const scopeFloor = await resolveQueryHistoryScopeFloor({ + projectDir: project.projectDir, + connectionId: target.connectionId, + driver: target.driver, + connection: connection as Record, + storedQueryHistory: stored, + }); + return queryHistoryPullConfig({ + stored, + dialect: target.queryHistory.dialect, + windowDays: target.queryHistory.windowDays, + enabledTables: scopeFloor.enabledTables, + enabledSchemas: scopeFloor.enabledSchemas, + modeledTableCatalog: scopeFloor.modeledTableCatalog, + scopeFloorWarnings: scopeFloor.warnings, + }); +} + function enrichmentReadinessGaps(config: KtxProjectConfig): string[] { const gaps: string[] = []; if (config.llm.provider.backend === 'none' || !config.llm.models.default) { @@ -877,7 +924,7 @@ export async function executePublicIngestTarget( project: KtxPublicIngestProject, ): Promise { const startedAt = performance.now(); - const result = await runIngestTargetSteps(target, args, io, deps); + const result = await runIngestTargetSteps(target, args, io, deps, project); // `io` may be a capture buffer for the scan/ingest step output; the telemetry // debug echo belongs on the real user-facing stream, which callers expose as // `deps.runtimeIo` (falling back to `io` when the step io is already real). @@ -890,6 +937,7 @@ async function runIngestTargetSteps( args: Extract, io: KtxCliIo, deps: KtxPublicIngestDeps, + project: KtxPublicIngestProject, ): Promise { if (target.preflightFailure) { if (target.operation === 'database-ingest') { @@ -959,6 +1007,11 @@ async function runIngestTargetSteps( if (target.queryHistory?.enabled === true) { const { runKtxIngest } = await import('./ingest.js'); const runIngest = deps.runIngest ?? runKtxIngest; + const historicSqlPullConfigOverride = + (await resolvedQueryHistoryPullConfigForTarget(target, project)) ?? { + dialect: target.queryHistory.dialect, + ...(target.queryHistory.windowDays !== undefined ? { windowDays: target.queryHistory.windowDays } : {}), + }; const ingestArgs: KtxIngestArgs = { command: 'run', projectDir: args.projectDir, @@ -969,11 +1022,7 @@ async function runIngestTargetSteps( ...(args.cliVersion ? { cliVersion: args.cliVersion } : {}), ...(args.runtimeInstallPolicy ? { runtimeInstallPolicy: args.runtimeInstallPolicy } : {}), allowImplicitAdapter: true, - historicSqlPullConfigOverride: - target.queryHistory.pullConfig ?? { - dialect: target.queryHistory.dialect, - ...(target.queryHistory.windowDays !== undefined ? { windowDays: target.queryHistory.windowDays } : {}), - }, + historicSqlPullConfigOverride, }; // Query history runs after the schema scan has already written its report // into the shared target io, so it needs a phase-local capture. Reusing diff --git a/packages/cli/src/setup-databases.ts b/packages/cli/src/setup-databases.ts index 1fd93486..3cb6c5d2 100644 --- a/packages/cli/src/setup-databases.ts +++ b/packages/cli/src/setup-databases.ts @@ -4,7 +4,15 @@ import { delimiter, dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; import { promisify } from 'node:util'; import { getDriverRegistration } from './context/connections/drivers.js'; +import { createLocalKtxLlmRuntimeFromConfig } from './context/llm/local-config.js'; +import type { KtxLlmRuntimePort } from './context/llm/runtime-port.js'; import { queryHistoryDialectForConnection } from './context/ingest/adapters/historic-sql/connection-dialect.js'; +import { + proposeQueryHistoryServiceAccountFilters, + type ProposeQueryHistoryServiceAccountFiltersInput, + type QueryHistoryFilterProposal, +} from './context/ingest/adapters/historic-sql/query-history-filter-picker.js'; +import { resolveQueryHistoryScopeFloor } from './context/ingest/adapters/historic-sql/scope-floor.js'; import type { HistoricSqlDialect } from './context/ingest/adapters/historic-sql/types.js'; import { runHistoricSqlReadinessProbe, @@ -15,7 +23,7 @@ import { type KtxProjectConnectionConfig, serializeKtxProjectConfig } from './co import { loadKtxProject } from './context/project/project.js'; import { markKtxSetupStateStepComplete, setKtxSetupDatabaseConnectionIds } from './context/project/setup-config.js'; import type { KtxTableListEntry } from './context/scan/types.js'; -import type { KtxCliIo } from './cli-runtime.js'; +import { getKtxCliPackageInfo, type KtxCliIo } from './cli-runtime.js'; import { errorMessage, flushPrefixedBufferedCommandOutput, @@ -35,6 +43,10 @@ import { type PickDatabaseScopeArgs, } from './database-tree-picker.js'; import { withMultiselectNavigation, withTextInputNavigation } from './prompt-navigation.js'; +import { createKtxCliHistoricSqlRuntime } from './local-adapters.js'; +import type { KtxManagedPythonInstallPolicy } from './managed-python-command.js'; +import type { ManagedPythonCoreDaemonOptions } from './managed-python-http.js'; +import { queryHistoryPullConfig } from './public-ingest.js'; import { runKtxScan } from './scan.js'; import { writeProjectLocalSecretReference } from './setup-secrets.js'; import { isDemoConnection } from './telemetry/demo-detect.js'; @@ -61,6 +73,9 @@ export type KtxSetupDatabaseDriver = export interface KtxSetupDatabasesArgs { projectDir: string; inputMode: 'auto' | 'disabled'; + yes?: boolean; + cliVersion?: string; + runtimeInstallPolicy?: KtxManagedPythonInstallPolicy; databaseDrivers?: KtxSetupDatabaseDriver[]; databaseConnectionIds?: string[]; databaseConnectionId?: string; @@ -123,6 +138,13 @@ export interface KtxSetupDatabasesDeps { listTables?: (projectDir: string, connectionId: string, schemas?: string[]) => Promise; pickDatabaseScope?: (args: PickDatabaseScopeArgs, io: KtxCliIo) => Promise; historicSqlReadinessProbe?: HistoricSqlReadinessProbe; + queryHistoryFilterPicker?: ( + input: ProposeQueryHistoryServiceAccountFiltersInput, + ) => Promise; + createQueryHistoryLlmRuntime?: ( + projectDir: string, + project: Awaited>, + ) => KtxLlmRuntimePort | null; } const DRIVER_OPTIONS: Array<{ value: KtxSetupDatabaseDriver; label: string }> = [ @@ -947,10 +969,14 @@ async function maybeApplyHistoricSqlConfig(input: { return withQueryHistoryConfig(input.connection, { ...existing, enabled: false }); } + const existingFilters = + existing.filters && typeof existing.filters === 'object' && !Array.isArray(existing.filters) + ? (existing.filters as Record) + : {}; const common: Record = { ...existing, enabled: true, - filters: historicSqlFiltersForSetup(input.args.queryHistoryServiceAccountPatterns), + filters: historicSqlFiltersForSetup(input.args.queryHistoryServiceAccountPatterns, existingFilters), }; if (dialect === 'postgres') { @@ -967,9 +993,13 @@ async function maybeApplyHistoricSqlConfig(input: { }); } -function historicSqlFiltersForSetup(patterns: string[] | undefined) { +function historicSqlFiltersForSetup( + patterns: string[] | undefined, + existingFilters: Record = {}, +) { const serviceAccountPatterns = patterns ?? []; return { + ...existingFilters, dropTrivialProbes: true, ...(serviceAccountPatterns.length > 0 ? { @@ -1587,6 +1617,189 @@ async function maybeRunHistoricSqlSetupProbe(input: { return result.ok; } +function hasServiceAccountsBlock(connection: KtxProjectConnectionConfig | undefined): boolean { + const queryHistory = queryHistoryConfigRecord(connection); + const filters = queryHistory?.filters; + if (!filters || typeof filters !== 'object' || Array.isArray(filters)) { + return false; + } + return 'serviceAccounts' in filters; +} + +function printQueryHistoryFilterProposal(io: KtxCliIo, proposal: QueryHistoryFilterProposal): void { + if (proposal.excludedRoles.length === 0) { + if (proposal.skipped?.reason === 'no-llm') { + io.stdout.write('│ Query-history filter picker skipped: no LLM is configured.\n'); + } else if (proposal.skipped?.reason === 'no-daemon') { + io.stdout.write('│ Query-history filter picker skipped: SQL analysis is unavailable.\n'); + } else if (proposal.skipped?.reason === 'no-in-scope-history') { + io.stdout.write('│ Query-history filter picker found no in-scope service-account exclusions.\n'); + } + for (const warning of proposal.warnings) { + io.stdout.write(`│ ! ${warning}\n`); + } + return; + } + + io.stdout.write('│ Proposed query-history service-account filters:\n'); + for (const excluded of proposal.excludedRoles) { + io.stdout.write(`│ - ${excluded.role}: ${excluded.reason}\n`); + } +} + +async function shouldApplyQueryHistoryFilterProposal(input: { + args: KtxSetupDatabasesArgs; + prompts: KtxSetupDatabasesPromptAdapter; + proposal: QueryHistoryFilterProposal; +}): Promise { + if (input.proposal.excludedRoles.length === 0 || input.proposal.skipped?.reason === 'user-block-present') { + return false; + } + if (input.args.yes === true || input.args.inputMode === 'disabled') { + return true; + } + const choice = await input.prompts.select({ + message: `Apply ${input.proposal.excludedRoles.length} derived query-history service-account exclusion${ + input.proposal.excludedRoles.length === 1 ? '' : 's' + }?`, + options: [ + { value: 'apply', label: 'Apply derived filters (recommended)' }, + { value: 'skip', label: 'Leave query history filters unchanged' }, + ], + }); + return choice === 'apply'; +} + +function createSetupQueryHistoryLlmRuntime(input: { + projectDir: string; + project: Awaited>; + deps: KtxSetupDatabasesDeps; +}): KtxLlmRuntimePort | null { + try { + return ( + input.deps.createQueryHistoryLlmRuntime?.(input.projectDir, input.project) ?? + createLocalKtxLlmRuntimeFromConfig(input.project.config.llm, { + projectDir: input.projectDir, + }) + ); + } catch { + return null; + } +} + +/** @internal */ +export function managedDaemonOptionsForSetupQueryHistoryPicker(input: { + projectDir: string; + args: Pick; + io: KtxCliIo; +}): ManagedPythonCoreDaemonOptions { + return { + cliVersion: input.args.cliVersion ?? getKtxCliPackageInfo().version, + projectDir: input.projectDir, + installPolicy: input.args.runtimeInstallPolicy ?? (input.args.inputMode === 'disabled' ? 'never' : 'prompt'), + io: input.io, + }; +} + +async function maybeProposeQueryHistoryFilters(input: { + projectDir: string; + connectionId: string; + io: KtxCliIo; + deps: KtxSetupDatabasesDeps; + args: KtxSetupDatabasesArgs; + prompts: KtxSetupDatabasesPromptAdapter; +}): Promise { + const project = await loadKtxProject({ projectDir: input.projectDir }); + const connection = project.config.connections[input.connectionId]; + const queryHistory = queryHistoryConfigRecord(connection); + if (!connection || queryHistory?.enabled !== true) { + return; + } + const dialect = queryHistoryDialectForConnection(connection); + if (!dialect) { + return; + } + + const picker = input.deps.queryHistoryFilterPicker ?? proposeQueryHistoryServiceAccountFilters; + const llmRuntime = createSetupQueryHistoryLlmRuntime({ + projectDir: input.projectDir, + project, + deps: input.deps, + }); + if (!llmRuntime && !input.deps.queryHistoryFilterPicker) { + printQueryHistoryFilterProposal(input.io, { + excludedRoles: [], + consideredRoleCount: 0, + skipped: { reason: 'no-llm' }, + warnings: [], + }); + return; + } + + const runtime = createKtxCliHistoricSqlRuntime(project, input.connectionId, { + managedDaemon: managedDaemonOptionsForSetupQueryHistoryPicker({ + projectDir: input.projectDir, + args: input.args, + io: input.io, + }), + }); + if (!runtime) { + return; + } + const userServiceAccountsPresent = hasServiceAccountsBlock(connection); + const scopeFloor = await resolveQueryHistoryScopeFloor({ + projectDir: input.projectDir, + connectionId: input.connectionId, + driver: String(connection.driver ?? ''), + connection: connection as Record, + storedQueryHistory: queryHistory, + }); + const pullConfig = queryHistoryPullConfig({ + stored: queryHistory, + dialect, + enabledTables: scopeFloor.enabledTables, + enabledSchemas: scopeFloor.enabledSchemas, + modeledTableCatalog: scopeFloor.modeledTableCatalog, + scopeFloorWarnings: scopeFloor.warnings, + }); + const proposal = await picker({ + connectionId: input.connectionId, + dialect, + queryClient: runtime.queryClient, + reader: runtime.reader, + sqlAnalysis: runtime.sqlAnalysis, + llmRuntime, + pullConfig, + userServiceAccountsPresent, + }); + + printQueryHistoryFilterProposal(input.io, proposal); + if (proposal.skipped?.reason === 'user-block-present') { + input.io.stdout.write('│ Existing query-history service-account filters left unchanged.\n'); + return; + } + if (!(await shouldApplyQueryHistoryFilterProposal({ args: input.args, prompts: input.prompts, proposal }))) { + return; + } + + await writeConnectionConfig({ + projectDir: input.projectDir, + connectionId: input.connectionId, + connection: withQueryHistoryConfig(connection, { + ...queryHistory, + filters: { + ...(queryHistory.filters && typeof queryHistory.filters === 'object' && !Array.isArray(queryHistory.filters) + ? queryHistory.filters + : {}), + serviceAccounts: { + mode: 'exclude', + patterns: proposal.excludedRoles.map((role) => role.pattern), + }, + }, + }), + }); +} + async function applyHistoricSqlConfigToExistingConnection(input: { projectDir: string; connectionId: string; @@ -1725,6 +1938,16 @@ async function validateAndScanConnection(input: { `Schema context complete for ${input.connectionId}`, [`Changes: ${summarizeScanChanges(scanOutput)}`], ); + if (queryHistoryAvailable) { + await maybeProposeQueryHistoryFilters({ + projectDir: input.projectDir, + connectionId: input.connectionId, + io: input.io, + deps: input.deps, + args: input.args, + prompts: input.prompts, + }); + } writeSetupSection(input.io, 'Database ready', [ `${input.connectionId} · ${driverDisplay} · schema context complete`, ]); diff --git a/packages/cli/src/setup.ts b/packages/cli/src/setup.ts index 7d4fdb0e..a991367e 100644 --- a/packages/cli/src/setup.ts +++ b/packages/cli/src/setup.ts @@ -735,6 +735,9 @@ async function runKtxSetupInner(args: KtxSetupArgs, io: KtxCliIo, deps: KtxSetup { projectDir: projectResult.projectDir, inputMode: args.inputMode, + yes: args.yes, + cliVersion: args.cliVersion, + runtimeInstallPolicy: setupRuntimeInstallPolicy(args), ...(args.databaseDrivers ? { databaseDrivers: args.databaseDrivers } : {}), ...(args.databaseConnectionIds ? { databaseConnectionIds: args.databaseConnectionIds } : {}), ...(args.databaseConnectionId ? { databaseConnectionId: args.databaseConnectionId } : {}), diff --git a/packages/cli/test/context/ingest/adapters/historic-sql/bigquery-query-history-reader.test.ts b/packages/cli/test/context/ingest/adapters/historic-sql/bigquery-query-history-reader.test.ts index 74393e4b..e3222ab5 100644 --- a/packages/cli/test/context/ingest/adapters/historic-sql/bigquery-query-history-reader.test.ts +++ b/packages/cli/test/context/ingest/adapters/historic-sql/bigquery-query-history-reader.test.ts @@ -91,7 +91,10 @@ describe('BigQueryHistoricSqlQueryHistoryReader', () => { 40, 0.05, null, - JSON.stringify([{ user: 'analyst@example.test', executions: 1 }]), + JSON.stringify([ + { user: 'svc-loader@example.test', executions: 40 }, + { user: 'analyst@example.test', executions: 2 }, + ]), ], ], totalRows: 1, @@ -103,15 +106,25 @@ describe('BigQueryHistoricSqlQueryHistoryReader', () => { for await (const row of reader.fetchAggregated( client, { start: new Date('2026-02-10T00:00:00.000Z'), end: new Date('2026-05-11T00:00:00.000Z') }, - { dialect: 'bigquery', minExecutions: 5, windowDays: 90, enabledTables: [], filters: { dropTrivialProbes: true }, redactionPatterns: [], staleArchiveAfterDays: 90 }, + { dialect: 'bigquery', minExecutions: 5, windowDays: 90, enabledTables: [], enabledSchemas: [], modeledTableCatalog: [], scopeFloorWarnings: [], filters: { dropTrivialProbes: true }, redactionPatterns: [], staleArchiveAfterDays: 90 }, )) { rows.push(row); } const sql = firstQuery(client); + expect(sql).toContain('WITH filtered_jobs AS'); + expect(sql).toContain('query_info.query_hashes.normalized_literals'); + expect(sql).toContain('TO_HEX(SHA256(query))'); + expect(sql).toContain('AS template_id'); + expect(sql).toContain('template_stats AS'); + expect(sql).toContain('template_users AS'); expect(sql).toContain('COUNT(*) AS executions'); expect(sql).toContain('COUNT(DISTINCT user_email) AS distinct_users'); - expect(sql).toContain('GROUP BY query_hash'); + expect(sql).toContain('GROUP BY template_id'); + expect(sql).toContain('GROUP BY template_id, user_email'); + expect(sql).toContain('ORDER BY users.executions DESC'); + expect(sql).not.toMatch(/\bquery_hash\b/); + expect(sql).not.toContain('LIMIT 5'); expect(sql).toContain('HAVING COUNT(*) >= 5'); expect(rows).toMatchObject([ { @@ -120,7 +133,10 @@ describe('BigQueryHistoricSqlQueryHistoryReader', () => { executions: 42, errorRate: 0.05, }, - topUsers: [{ user: 'analyst@example.test', executions: 1 }], + topUsers: [ + { user: 'svc-loader@example.test', executions: 40 }, + { user: 'analyst@example.test', executions: 2 }, + ], }, ]); }); @@ -137,6 +153,9 @@ describe('BigQueryHistoricSqlQueryHistoryReader', () => { minExecutions: 5, windowDays: 90, enabledTables: [], + enabledSchemas: [], + modeledTableCatalog: [], + scopeFloorWarnings: [], filters: { dropTrivialProbes: true }, redactionPatterns: [], staleArchiveAfterDays: 90, diff --git a/packages/cli/test/context/ingest/adapters/historic-sql/chunk-unified.test.ts b/packages/cli/test/context/ingest/adapters/historic-sql/chunk-unified.test.ts index a8e99c39..f3d7d293 100644 --- a/packages/cli/test/context/ingest/adapters/historic-sql/chunk-unified.test.ts +++ b/packages/cli/test/context/ingest/adapters/historic-sql/chunk-unified.test.ts @@ -30,6 +30,7 @@ async function writeUnifiedStagedDir(root: string): Promise { }); await writeJson(root, 'tables/public.orders.json', { table: 'public.orders', + tableRef: { catalog: null, db: 'public', name: 'orders' }, stats: { executionsBucket: '10-100', distinctUsersBucket: '2-5', @@ -46,7 +47,10 @@ async function writeUnifiedStagedDir(root: string): Promise { { id: 'orders', canonicalSql: 'select * from public.orders join public.customers on true', - tablesTouched: ['public.orders', 'public.customers'], + tablesTouched: [ + { catalog: null, db: 'public', name: 'orders' }, + { catalog: null, db: 'public', name: 'customers' }, + ], executionsBucket: '10-100', distinctUsersBucket: '2-5', dialect: 'postgres', @@ -58,7 +62,10 @@ async function writeUnifiedStagedDir(root: string): Promise { { id: 'orders', canonicalSql: 'select * from public.orders join public.customers on true', - tablesTouched: ['public.orders', 'public.customers'], + tablesTouched: [ + { catalog: null, db: 'public', name: 'orders' }, + { catalog: null, db: 'public', name: 'customers' }, + ], executionsBucket: '10-100', distinctUsersBucket: '2-5', dialect: 'postgres', @@ -155,7 +162,10 @@ describe('chunkHistoricSqlUnifiedStagedDir', () => { { id: 'line-items', canonicalSql: 'select * from public.orders join public.line_items on true', - tablesTouched: ['public.orders', 'public.line_items'], + tablesTouched: [ + { catalog: null, db: 'public', name: 'orders' }, + { catalog: null, db: 'public', name: 'line_items' }, + ], executionsBucket: '10-100', distinctUsersBucket: '2-5', dialect: 'postgres', diff --git a/packages/cli/test/context/ingest/adapters/historic-sql/historic-sql.adapter.test.ts b/packages/cli/test/context/ingest/adapters/historic-sql/historic-sql.adapter.test.ts index 850be576..dcd00d32 100644 --- a/packages/cli/test/context/ingest/adapters/historic-sql/historic-sql.adapter.test.ts +++ b/packages/cli/test/context/ingest/adapters/historic-sql/historic-sql.adapter.test.ts @@ -76,7 +76,10 @@ describe('HistoricSqlSourceAdapter', () => { [ 'pg:1', { - tablesTouched: ['public.orders', 'public.customers'], + tablesTouched: [ + { catalog: null, db: 'public', name: 'orders' }, + { catalog: null, db: 'public', name: 'customers' }, + ], columnsByClause: { select: ['status'], join: ['customer_id', 'id'], groupBy: ['status'] }, }, ], diff --git a/packages/cli/test/context/ingest/adapters/historic-sql/local-ingest-acceptance.test.ts b/packages/cli/test/context/ingest/adapters/historic-sql/local-ingest-acceptance.test.ts index 48e5744b..e818f1c9 100644 --- a/packages/cli/test/context/ingest/adapters/historic-sql/local-ingest-acceptance.test.ts +++ b/packages/cli/test/context/ingest/adapters/historic-sql/local-ingest-acceptance.test.ts @@ -126,7 +126,10 @@ function acceptanceSqlAnalysis(): SqlAnalysisPort { items.map((item) => [ item.id, { - tablesTouched: ['public.orders', 'public.customers'], + tablesTouched: [ + { catalog: null, db: 'public', name: 'orders' }, + { catalog: null, db: 'public', name: 'customers' }, + ], columnsByClause: { select: ['status', 'segment'], where: ['status'], diff --git a/packages/cli/test/context/ingest/adapters/historic-sql/pattern-inputs.test.ts b/packages/cli/test/context/ingest/adapters/historic-sql/pattern-inputs.test.ts index 9fae5e08..780fcf3d 100644 --- a/packages/cli/test/context/ingest/adapters/historic-sql/pattern-inputs.test.ts +++ b/packages/cli/test/context/ingest/adapters/historic-sql/pattern-inputs.test.ts @@ -9,11 +9,18 @@ import type { StagedPatternsInput } from '../../../../../src/context/ingest/adap type PatternTemplate = StagedPatternsInput['templates'][number]; +function tableRef(value: string): { catalog: string | null; db: string | null; name: string } { + const parts = value.split('.'); + if (parts.length === 3) return { catalog: parts[0]!, db: parts[1]!, name: parts[2]! }; + if (parts.length === 2) return { catalog: null, db: parts[0]!, name: parts[1]! }; + return { catalog: null, db: null, name: value }; +} + function template(id: string, tablesTouched: string[], canonicalSql = 'select 1'): PatternTemplate { return { id, canonicalSql, - tablesTouched, + tablesTouched: tablesTouched.map(tableRef), executionsBucket: '10-100', distinctUsersBucket: '2-5', dialect: 'postgres', @@ -32,7 +39,7 @@ describe('historic-SQL pattern input sharding', () => { ], }; - const result = splitHistoricSqlPatternInputs(input, { maxBytes: 760 }); + const result = splitHistoricSqlPatternInputs(input, { maxBytes: 1200 }); expect(result.auditInput.templates.map((entry) => entry.id)).toEqual([ 'orders-customers-1', @@ -51,7 +58,7 @@ describe('historic-SQL pattern input sharding', () => { 'orders-customers-1', 'orders-customers-2', ]); - expect(result.shards.every((shard) => shard.byteLength <= 760)).toBe(true); + expect(result.shards.every((shard) => shard.byteLength <= 1200)).toBe(true); expect(result.shards.flatMap((shard) => shard.input.templates).some((entry) => entry.id === 'single-table-orders')).toBe(false); expect(result.warnings).toEqual([]); }); diff --git a/packages/cli/test/context/ingest/adapters/historic-sql/postgres-pgss-reader.test.ts b/packages/cli/test/context/ingest/adapters/historic-sql/postgres-pgss-reader.test.ts index 41baf331..4c9fc7bb 100644 --- a/packages/cli/test/context/ingest/adapters/historic-sql/postgres-pgss-reader.test.ts +++ b/packages/cli/test/context/ingest/adapters/historic-sql/postgres-pgss-reader.test.ts @@ -215,7 +215,7 @@ describe('PostgresPgssReader aggregate path', () => { for await (const row of reader.fetchAggregated( { executeQuery }, { start: new Date('2026-02-10T00:00:00.000Z'), end: new Date('2026-05-11T00:00:00.000Z') }, - { dialect: 'postgres', minExecutions: 5, enabledTables: [], filters: { dropTrivialProbes: true }, redactionPatterns: [], staleArchiveAfterDays: 90 }, + { dialect: 'postgres', minExecutions: 5, enabledTables: [], enabledSchemas: [], modeledTableCatalog: [], scopeFloorWarnings: [], filters: { dropTrivialProbes: true }, redactionPatterns: [], staleArchiveAfterDays: 90 }, )) { rows.push(row); } diff --git a/packages/cli/test/context/ingest/adapters/historic-sql/query-history-filter-picker.test.ts b/packages/cli/test/context/ingest/adapters/historic-sql/query-history-filter-picker.test.ts new file mode 100644 index 00000000..4c295092 --- /dev/null +++ b/packages/cli/test/context/ingest/adapters/historic-sql/query-history-filter-picker.test.ts @@ -0,0 +1,274 @@ +import { describe, expect, it, vi } from 'vitest'; +import type { KtxLlmRuntimePort } from '../../../../../src/context/llm/runtime-port.js'; +import type { + SqlAnalysisBatchItem, + SqlAnalysisBatchResult, + SqlAnalysisPort, +} from '../../../../../src/context/sql-analysis/ports.js'; +import { + proposeQueryHistoryServiceAccountFilters, + regexEscapeForExactRolePattern, +} from '../../../../../src/context/ingest/adapters/historic-sql/query-history-filter-picker.js'; +import type { + AggregatedTemplate, + HistoricSqlReader, +} from '../../../../../src/context/ingest/adapters/historic-sql/types.js'; + +function aggregate(overrides: Partial & { templateId: string; canonicalSql: string }): AggregatedTemplate { + return { + templateId: overrides.templateId, + canonicalSql: overrides.canonicalSql, + dialect: overrides.dialect ?? 'postgres', + stats: overrides.stats ?? { + executions: 25, + distinctUsers: 1, + firstSeen: '2026-05-01T00:00:00.000Z', + lastSeen: '2026-06-01T00:00:00.000Z', + p50RuntimeMs: 50, + p95RuntimeMs: 100, + errorRate: 0, + rowsProduced: 10, + }, + topUsers: overrides.topUsers ?? [{ user: 'analyst', executions: 25 }], + }; +} + +function reader(...templates: AggregatedTemplate[]): HistoricSqlReader { + return { + async probe() { + return { warnings: [], info: [] }; + }, + async *fetchAggregated() { + for (const template of templates) { + yield template; + } + }, + }; +} + +function sqlAnalysis(tablesById: Record>): SqlAnalysisPort { + return { + analyzeForFingerprint: vi.fn(), + analyzeBatch: vi.fn(async (items: SqlAnalysisBatchItem[]): Promise> => + new Map( + items.map((item) => [ + item.id, + { + tablesTouched: tablesById[item.id] ?? [], + columnsByClause: {}, + }, + ]), + ), + ), + validateReadOnly: vi.fn(async () => ({ ok: true })), + }; +} + +function llm(decisions: Array<{ role: string; exclude: boolean; reason: string }>): KtxLlmRuntimePort { + const generateObject = vi.fn(async () => ({ roles: decisions })) as KtxLlmRuntimePort['generateObject']; + return { + generateText: vi.fn(), + generateObject, + runAgentLoop: vi.fn(), + }; +} + +describe('query-history filter picker', () => { + it('emits anchored escaped patterns for excluded roles from one batched LLM call', async () => { + const runtime = llm([ + { role: 'svc.loader+prod', exclude: true, reason: 'Runs recurring loader traffic only.' }, + { role: 'analyst', exclude: false, reason: 'Interactive analytic usage.' }, + ]); + const analysis = sqlAnalysis({ + loader: [{ catalog: null, db: 'analytics', name: 'orders' }], + analyst: [{ catalog: null, db: 'analytics', name: 'orders' }], + }); + + const proposal = await proposeQueryHistoryServiceAccountFilters({ + connectionId: 'warehouse', + dialect: 'postgres', + queryClient: {}, + reader: reader( + aggregate({ + templateId: 'loader', + canonicalSql: 'merge into analytics.orders using staging.orders_delta on orders.id = orders_delta.id', + topUsers: [{ user: 'svc.loader+prod', executions: 40 }], + }), + aggregate({ + templateId: 'analyst', + canonicalSql: 'select status, count(*) from analytics.orders group by status', + topUsers: [{ user: 'analyst', executions: 25 }], + }), + ), + sqlAnalysis: analysis, + llmRuntime: runtime, + pullConfig: { + dialect: 'postgres', + enabledSchemas: ['analytics'], + enabledTables: [], + modeledTableCatalog: [{ catalog: null, db: 'analytics', name: 'orders' }], + filters: { dropTrivialProbes: true }, + }, + now: new Date('2026-06-03T00:00:00.000Z'), + }); + + expect(runtime.generateObject).toHaveBeenCalledTimes(1); + expect(proposal).toMatchObject({ + excludedRoles: [ + { + role: 'svc.loader+prod', + pattern: '^svc\\.loader\\+prod$', + reason: 'Runs recurring loader traffic only.', + }, + ], + consideredRoleCount: 2, + skipped: null, + warnings: [], + }); + }); + + it('redacts representative SQL before sending role records to the LLM', async () => { + const originalSql = + "select * from public.api_events where api_key = 'sk_live_abc123' and note = 'Secret_Token_9f'"; // pragma: allowlist secret + const runtime = llm([ + { role: 'svc_loader', exclude: false, reason: 'Keep by default.' }, + { role: 'analyst', exclude: false, reason: 'Interactive analytic usage.' }, + ]); + const analysis = sqlAnalysis({ + secret: [{ catalog: null, db: 'public', name: 'api_events' }], + analyst: [{ catalog: null, db: 'public', name: 'orders' }], + }); + + await proposeQueryHistoryServiceAccountFilters({ + connectionId: 'warehouse', + dialect: 'postgres', + queryClient: {}, + reader: reader( + aggregate({ + templateId: 'secret', + canonicalSql: originalSql, + topUsers: [{ user: 'svc_loader', executions: 30 }], + }), + aggregate({ + templateId: 'analyst', + canonicalSql: 'select status, count(*) from public.orders group by status', + topUsers: [{ user: 'analyst', executions: 25 }], + }), + ), + sqlAnalysis: analysis, + llmRuntime: runtime, + pullConfig: { + dialect: 'postgres', + enabledSchemas: ['public'], + enabledTables: [], + modeledTableCatalog: [], + redactionPatterns: ['sk_live_[A-Za-z0-9]+', '(?i)secret_token_[a-z0-9]+'], + filters: { dropTrivialProbes: true }, + }, + now: new Date('2026-06-03T00:00:00.000Z'), + }); + + expect(analysis.analyzeBatch).toHaveBeenCalledWith( + [ + { id: 'secret', sql: originalSql }, + { id: 'analyst', sql: 'select status, count(*) from public.orders group by status' }, + ], + 'postgres', + undefined, + ); + const call = vi.mocked(runtime.generateObject).mock.calls[0]?.[0]; + expect(call?.prompt).toContain('[REDACTED]'); + expect(call?.prompt).not.toContain('sk_live_abc123'); + expect(call?.prompt).not.toContain('Secret_Token_9f'); + }); + + it('fails open with no LLM runtime', async () => { + const proposal = await proposeQueryHistoryServiceAccountFilters({ + connectionId: 'warehouse', + dialect: 'postgres', + queryClient: {}, + reader: reader(), + sqlAnalysis: sqlAnalysis({}), + llmRuntime: null, + pullConfig: { dialect: 'postgres', filters: { dropTrivialProbes: true } }, + }); + + expect(proposal).toEqual({ + excludedRoles: [], + consideredRoleCount: 0, + skipped: { reason: 'no-llm' }, + warnings: [], + }); + }); + + it('proposes nothing for a single-role stack', async () => { + const runtime = llm([{ role: 'warehouse_user', exclude: true, reason: 'Only observed role.' }]); + + const proposal = await proposeQueryHistoryServiceAccountFilters({ + connectionId: 'warehouse', + dialect: 'postgres', + queryClient: {}, + reader: reader( + aggregate({ + templateId: 'single-role', + canonicalSql: 'select * from analytics.orders', + topUsers: [{ user: 'warehouse_user', executions: 40 }], + }), + ), + sqlAnalysis: sqlAnalysis({ + 'single-role': [{ catalog: null, db: 'analytics', name: 'orders' }], + }), + llmRuntime: runtime, + pullConfig: { dialect: 'postgres', enabledSchemas: ['analytics'], filters: { dropTrivialProbes: true } }, + }); + + expect(runtime.generateObject).not.toHaveBeenCalled(); + expect(proposal.excludedRoles).toEqual([]); + expect(proposal.skipped).toEqual({ reason: 'no-in-scope-history' }); + }); + + it('keeps clean in-scope history when the model excludes nothing', async () => { + const proposal = await proposeQueryHistoryServiceAccountFilters({ + connectionId: 'warehouse', + dialect: 'bigquery', + queryClient: {}, + reader: reader( + aggregate({ + templateId: 'dashboard', + canonicalSql: 'select status, count(*) from `demo.analytics.orders` group by status', + dialect: 'bigquery', + topUsers: [{ user: 'bi_runner', executions: 1 }], + }), + aggregate({ + templateId: 'analyst', + canonicalSql: 'select * from `demo.analytics.orders` where id = @id', + dialect: 'bigquery', + topUsers: [{ user: 'analyst', executions: 1 }], + }), + ), + sqlAnalysis: sqlAnalysis({ + dashboard: [{ catalog: 'demo', db: 'analytics', name: 'orders' }], + analyst: [{ catalog: 'demo', db: 'analytics', name: 'orders' }], + }), + llmRuntime: llm([ + { role: 'bi_runner', exclude: false, reason: 'Dashboard usage is analytic.' }, + { role: 'analyst', exclude: false, reason: 'Interactive analyst usage.' }, + ]), + pullConfig: { + dialect: 'bigquery', + windowDays: 90, + enabledSchemas: ['analytics'], + filters: { dropTrivialProbes: true }, + }, + }); + + expect(proposal.excludedRoles).toEqual([]); + expect(proposal.consideredRoleCount).toBe(2); + expect(proposal.skipped).toBeNull(); + }); + + it('escapes regex metacharacters for exact role matches', () => { + expect(regexEscapeForExactRolePattern('svc.loader+prod')).toBe('^svc\\.loader\\+prod$'); + expect(regexEscapeForExactRolePattern('team[etl](west)')).toBe('^team\\[etl\\]\\(west\\)$'); + }); +}); diff --git a/packages/cli/test/context/ingest/adapters/historic-sql/scope-floor.test.ts b/packages/cli/test/context/ingest/adapters/historic-sql/scope-floor.test.ts new file mode 100644 index 00000000..597cae46 --- /dev/null +++ b/packages/cli/test/context/ingest/adapters/historic-sql/scope-floor.test.ts @@ -0,0 +1,194 @@ +import { mkdir, mkdtemp, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { describe, expect, it } from 'vitest'; +import { resolveQueryHistoryScopeFloor } from '../../../../../src/context/ingest/adapters/historic-sql/scope-floor.js'; + +async function tempProject(): Promise { + return mkdtemp(join(tmpdir(), 'ktx-qh-scope-')); +} + +async function seedLiveScanTable( + projectDir: string, + connectionId: string, + syncId: string, + table: { catalog: string | null; db: string | null; name: string }, +): Promise { + const root = join(projectDir, 'raw-sources', connectionId, 'live-database', syncId); + await mkdir(join(root, 'tables'), { recursive: true }); + await writeFile( + join(root, 'connection.json'), + `${JSON.stringify({ connectionId, driver: 'postgres' }, null, 2)}\n`, + 'utf-8', + ); + await writeFile( + join(root, 'tables', `${table.db ?? 'default'}-${table.name}.json`), + `${JSON.stringify( + { + ...table, + kind: 'table', + comment: null, + estimatedRows: null, + columns: [], + foreignKeys: [], + }, + null, + 2, + )}\n`, + 'utf-8', + ); + await writeFile( + join(root, 'scan-report.json'), + `${JSON.stringify( + { + connectionId, + driver: 'postgres', + syncId, + runId: `scan-${syncId}`, + trigger: 'cli', + mode: 'enriched', + dryRun: false, + artifactPaths: { + rawSourcesDir: `raw-sources/${connectionId}/live-database/${syncId}`, + reportPath: `raw-sources/${connectionId}/live-database/${syncId}/scan-report.json`, + manifestShards: [], + enrichmentArtifacts: [], + }, + counts: {}, + warnings: [], + enrichment: {}, + enrichmentState: {}, + }, + null, + 2, + )}\n`, + 'utf-8', + ); +} + +describe('resolveQueryHistoryScopeFloor', () => { + it('computes modeled schemas from connection schemas plus semantic source tables', async () => { + const projectDir = await tempProject(); + await mkdir(join(projectDir, 'semantic-layer/warehouse'), { recursive: true }); + await writeFile( + join(projectDir, 'semantic-layer/warehouse/revenue.yaml'), + [ + 'name: revenue', + 'table: orbit_analytics.mart_revenue', + 'grain: [id]', + 'columns:', + ' - name: id', + ' type: string', + '', + ].join('\n'), + 'utf-8', + ); + await seedLiveScanTable(projectDir, 'warehouse', 'sync-1', { + catalog: null, + db: 'orbit_raw', + name: 'accounts', + }); + + const scope = await resolveQueryHistoryScopeFloor({ + projectDir, + connectionId: 'warehouse', + driver: 'postgres', + connection: { driver: 'postgres', schemas: ['orbit_raw'] }, + storedQueryHistory: {}, + }); + + expect(scope.enabledSchemas).toEqual(['orbit_analytics', 'orbit_raw']); + expect(scope.modeledTableCatalog).toEqual([ + { catalog: null, db: 'orbit_analytics', name: 'mart_revenue' }, + { catalog: null, db: 'orbit_raw', name: 'accounts' }, + ]); + expect(scope.enabledTables).toEqual([]); + expect(scope.floorDisabled).toBe(false); + }); + + it('uses explicit enabledTables before explicit enabledSchemas and computed scope', async () => { + const scope = await resolveQueryHistoryScopeFloor({ + projectDir: await tempProject(), + connectionId: 'warehouse', + driver: 'postgres', + connection: { driver: 'postgres', schemas: ['orbit_raw'] }, + storedQueryHistory: { + enabledTables: ['orbit_analytics.mart_revenue'], + enabledSchemas: ['orbit_raw'], + }, + }); + + expect(scope.enabledTables).toEqual([{ catalog: null, db: 'orbit_analytics', name: 'mart_revenue' }]); + expect(scope.enabledSchemas).toEqual([]); + expect(scope.floorDisabled).toBe(false); + }); + + it('disables the floor for enabledSchemas star', async () => { + const scope = await resolveQueryHistoryScopeFloor({ + projectDir: await tempProject(), + connectionId: 'warehouse', + driver: 'postgres', + connection: { driver: 'postgres', schemas: ['orbit_raw'] }, + storedQueryHistory: { enabledSchemas: ['*'] }, + }); + + expect(scope.enabledTables).toEqual([]); + expect(scope.enabledSchemas).toEqual(['*']); + expect(scope.floorDisabled).toBe(true); + }); + + it('adds latest live-database scan tables to the modeled table catalog', async () => { + const projectDir = await tempProject(); + await mkdir(join(projectDir, 'semantic-layer/warehouse'), { recursive: true }); + await writeFile( + join(projectDir, 'semantic-layer/warehouse/revenue.yaml'), + [ + 'name: revenue', + 'table: orbit_analytics.mart_revenue', + 'grain: [id]', + 'columns:', + ' - name: id', + ' type: string', + '', + ].join('\n'), + 'utf-8', + ); + await seedLiveScanTable(projectDir, 'warehouse', 'sync-1', { + catalog: null, + db: 'orbit_raw', + name: 'accounts', + }); + + const scope = await resolveQueryHistoryScopeFloor({ + projectDir, + connectionId: 'warehouse', + driver: 'postgres', + connection: { driver: 'postgres', schemas: ['orbit_raw'] }, + storedQueryHistory: {}, + }); + + expect(scope.enabledSchemas).toEqual(['orbit_analytics', 'orbit_raw']); + expect(scope.modeledTableCatalog).toEqual([ + { catalog: null, db: 'orbit_analytics', name: 'mart_revenue' }, + { catalog: null, db: 'orbit_raw', name: 'accounts' }, + ]); + expect(scope.warnings).toEqual([]); + expect(scope.floorDisabled).toBe(false); + }); + + it('fails open when schema scope exists but the scan catalog is unavailable', async () => { + const scope = await resolveQueryHistoryScopeFloor({ + projectDir: await tempProject(), + connectionId: 'warehouse', + driver: 'postgres', + connection: { driver: 'postgres', schemas: ['orbit_raw'] }, + storedQueryHistory: {}, + }); + + expect(scope.enabledTables).toEqual([]); + expect(scope.enabledSchemas).toEqual(['*']); + expect(scope.modeledTableCatalog).toEqual([]); + expect(scope.floorDisabled).toBe(true); + expect(scope.warnings).toContain('query_history_scope_floor_disabled:catalog_unavailable'); + }); +}); diff --git a/packages/cli/test/context/ingest/adapters/historic-sql/scope-membership.test.ts b/packages/cli/test/context/ingest/adapters/historic-sql/scope-membership.test.ts new file mode 100644 index 00000000..bfcefc22 --- /dev/null +++ b/packages/cli/test/context/ingest/adapters/historic-sql/scope-membership.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it } from 'vitest'; +import { + includedQueryHistoryTableRefs, + isQueryHistoryScopeFloorDisabled, + shouldFailOpenQueryHistoryScope, +} from '../../../../../src/context/ingest/adapters/historic-sql/scope-membership.js'; +import type { KtxTableRef } from '../../../../../src/context/scan/types.js'; + +function ref(db: string | null, name: string, catalog: string | null = null): KtxTableRef { + return { catalog, db, name }; +} + +describe('query-history scope membership', () => { + it('prefers explicit enabled tables over schema scope', () => { + const orders = ref('analytics', 'orders'); + const noise = ref('metabase', 'application_table'); + + expect( + includedQueryHistoryTableRefs([orders, noise], { + enabledTables: [orders], + enabledSchemas: ['metabase'], + }), + ).toEqual([orders]); + }); + + it('matches schema scope by the db component across catalogs', () => { + const modeled = ref('orbit_analytics', 'orders', 'demo-project'); + const noise = ref('metabase', 'application_table', 'demo-project'); + + expect( + includedQueryHistoryTableRefs([modeled, noise], { + enabledTables: [], + enabledSchemas: ['orbit_analytics'], + }), + ).toEqual([modeled]); + }); + + it('keeps every touched ref when wildcard scope disables the floor', () => { + const tables = [ref('analytics', 'orders'), ref('metabase', 'application_table')]; + + expect(isQueryHistoryScopeFloorDisabled({ enabledTables: [], enabledSchemas: ['*'] })).toBe(true); + expect(includedQueryHistoryTableRefs(tables, { enabledTables: [], enabledSchemas: ['*'] })).toEqual(tables); + }); + + it('fails open when no tables, schemas, or wildcard are configured', () => { + const tables = [ref('metabase', 'application_table')]; + + expect(shouldFailOpenQueryHistoryScope({ enabledTables: [], enabledSchemas: [] })).toBe(true); + expect(includedQueryHistoryTableRefs(tables, { enabledTables: [], enabledSchemas: [] })).toEqual(tables); + }); +}); diff --git a/packages/cli/test/context/ingest/adapters/historic-sql/snowflake-query-history-reader.test.ts b/packages/cli/test/context/ingest/adapters/historic-sql/snowflake-query-history-reader.test.ts index 7307fcdd..ab76c533 100644 --- a/packages/cli/test/context/ingest/adapters/historic-sql/snowflake-query-history-reader.test.ts +++ b/packages/cli/test/context/ingest/adapters/historic-sql/snowflake-query-history-reader.test.ts @@ -90,7 +90,10 @@ describe('SnowflakeHistoricSqlQueryHistoryReader', () => { 40, 0.05, 100, - JSON.stringify([{ user: 'ANALYST', executions: 1 }]), + JSON.stringify([ + { user: 'SVC_LOADER', executions: 40 }, + { user: 'ANALYST', executions: 2 }, + ]), ], ], totalRows: 1, @@ -102,15 +105,20 @@ describe('SnowflakeHistoricSqlQueryHistoryReader', () => { for await (const row of reader.fetchAggregated( client, { start: new Date('2026-02-10T00:00:00.000Z'), end: new Date('2026-05-11T00:00:00.000Z') }, - { dialect: 'snowflake', minExecutions: 5, windowDays: 90, enabledTables: [], filters: { dropTrivialProbes: true }, redactionPatterns: [], staleArchiveAfterDays: 90 }, + { dialect: 'snowflake', minExecutions: 5, windowDays: 90, enabledTables: [], enabledSchemas: [], modeledTableCatalog: [], scopeFloorWarnings: [], filters: { dropTrivialProbes: true }, redactionPatterns: [], staleArchiveAfterDays: 90 }, )) { rows.push(row); } const sql = firstQuery(client); + expect(sql).toContain('WITH filtered_queries AS'); + expect(sql).toContain('template_stats AS'); + expect(sql).toContain('template_users AS'); expect(sql).toContain('SNOWFLAKE.ACCOUNT_USAGE.QUERY_HISTORY'); expect(sql).toContain('COUNT(*) AS executions'); - expect(sql).toContain('GROUP BY query_hash'); + expect(sql).toContain('COUNT(DISTINCT user_name) AS distinct_users'); + expect(sql).toContain('GROUP BY query_hash, user_name'); + expect(sql).toContain('ORDER BY users.executions DESC'); expect(sql).toContain('HAVING COUNT(*) >= 5'); expect(rows).toMatchObject([ { @@ -119,7 +127,10 @@ describe('SnowflakeHistoricSqlQueryHistoryReader', () => { executions: 42, errorRate: 0.05, }, - topUsers: [{ user: 'ANALYST', executions: 1 }], + topUsers: [ + { user: 'SVC_LOADER', executions: 40 }, + { user: 'ANALYST', executions: 2 }, + ], }, ]); }); @@ -136,6 +147,9 @@ describe('SnowflakeHistoricSqlQueryHistoryReader', () => { minExecutions: 5, windowDays: 90, enabledTables: [], + enabledSchemas: [], + modeledTableCatalog: [], + scopeFloorWarnings: [], filters: { dropTrivialProbes: true }, redactionPatterns: [], staleArchiveAfterDays: 90, diff --git a/packages/cli/test/context/ingest/adapters/historic-sql/stage-unified.test.ts b/packages/cli/test/context/ingest/adapters/historic-sql/stage-unified.test.ts index 630a3939..a104508d 100644 --- a/packages/cli/test/context/ingest/adapters/historic-sql/stage-unified.test.ts +++ b/packages/cli/test/context/ingest/adapters/historic-sql/stage-unified.test.ts @@ -14,6 +14,13 @@ async function readJson(root: string, relPath: string): Promise { return JSON.parse(await readFile(join(root, relPath), 'utf-8')) as T; } +function tableRef(value: string): { catalog: string | null; db: string | null; name: string } { + const parts = value.split('.'); + if (parts.length === 3) return { catalog: parts[0]!, db: parts[1]!, name: parts[2]! }; + if (parts.length === 2) return { catalog: null, db: parts[0]!, name: parts[1]! }; + return { catalog: null, db: null, name: value }; +} + function aggregate(overrides: Partial & { templateId: string; canonicalSql: string }): AggregatedTemplate { return { templateId: overrides.templateId, @@ -72,7 +79,7 @@ describe('stageHistoricSqlAggregatedSnapshot', () => { [ 'orders-by-status', { - tablesTouched: ['public.orders', 'public.customers'], + tablesTouched: [tableRef('public.orders'), tableRef('public.customers')], columnsByClause: { select: ['status'], where: ['created_at'], @@ -94,6 +101,7 @@ describe('stageHistoricSqlAggregatedSnapshot', () => { sqlAnalysis, pullConfig: { dialect: 'postgres', + enabledSchemas: ['public'], filters: { serviceAccounts: { patterns: ['^svc_'], mode: 'exclude' }, }, @@ -111,6 +119,7 @@ describe('stageHistoricSqlAggregatedSnapshot', () => { { id: 'bad-parse', sql: 'select broken from' }, ], 'postgres', + undefined, ); expect(await readdir(join(stagedDir, 'tables'))).toEqual(['public.customers.json', 'public.orders.json']); @@ -131,6 +140,7 @@ describe('stageHistoricSqlAggregatedSnapshot', () => { const orders = await readJson>(stagedDir, 'tables/public.orders.json'); expect(orders).toMatchObject({ table: 'public.orders', + tableRef: tableRef('public.orders'), stats: { executionsBucket: '10-100', distinctUsersBucket: '2-5', @@ -159,7 +169,7 @@ describe('stageHistoricSqlAggregatedSnapshot', () => { { id: 'orders-by-status', canonicalSql: expect.stringContaining('public.orders'), - tablesTouched: ['public.customers', 'public.orders'], + tablesTouched: [tableRef('public.customers'), tableRef('public.orders')], executionsBucket: '10-100', distinctUsersBucket: '2-5', dialect: 'postgres', @@ -167,6 +177,129 @@ describe('stageHistoricSqlAggregatedSnapshot', () => { ]); }); + it('keeps templates when service-account topUsers are only a partial execution sample', async () => { + const stagedDir = await tempDir(); + const reader: HistoricSqlReader = { + async probe() { + return { warnings: [], info: [] }; + }, + async *fetchAggregated() { + yield aggregate({ + templateId: 'shared-bigquery-template', + canonicalSql: 'select status, count(*) from `demo.analytics.orders` group by status', + dialect: 'bigquery', + stats: { + executions: 42, + distinctUsers: 2, + firstSeen: '2026-05-01T00:00:00.000Z', + lastSeen: '2026-05-11T00:00:00.000Z', + p50RuntimeMs: 20, + p95RuntimeMs: 80, + errorRate: 0, + rowsProduced: null, + }, + topUsers: [{ user: 'svc_loader', executions: 5 }], + }); + }, + }; + const sqlAnalysis: SqlAnalysisPort = { + analyzeForFingerprint: vi.fn(), + analyzeBatch: vi.fn(async () => + new Map([ + [ + 'shared-bigquery-template', + { + tablesTouched: [tableRef('demo.analytics.orders')], + columnsByClause: { select: ['status'], groupBy: ['status'] }, + }, + ], + ]), + ), + validateReadOnly: vi.fn(async () => ({ ok: true })), + }; + + await stageHistoricSqlAggregatedSnapshot({ + stagedDir, + connectionId: 'warehouse', + queryClient: {}, + reader, + sqlAnalysis, + pullConfig: { + dialect: 'bigquery', + windowDays: 90, + enabledSchemas: ['analytics'], + filters: { + serviceAccounts: { patterns: ['^svc_loader$'], mode: 'exclude' }, + }, + }, + now: new Date('2026-05-11T12:00:00.000Z'), + }); + + const patterns = await readJson>(stagedDir, 'patterns-input.json'); + expect(patterns.templates.map((template: { id: string }) => template.id)).toEqual([ + 'shared-bigquery-template', + ]); + const orders = await readJson>(stagedDir, 'tables/demo.analytics.orders.json'); + expect(orders.topTemplates).toEqual([ + { + id: 'shared-bigquery-template', + canonicalSql: 'select status, count(*) from `demo.analytics.orders` group by status', + topUsers: [{ user: 'svc_loader' }], + }, + ]); + }); + + it('drops service-account-only templates when matched users cover all executions', async () => { + const stagedDir = await tempDir(); + const reader: HistoricSqlReader = { + async probe() { + return { warnings: [], info: [] }; + }, + async *fetchAggregated() { + yield aggregate({ + templateId: 'service-only-template', + canonicalSql: 'merge into analytics.orders using staging.orders_delta on orders.id = orders_delta.id', + stats: { + executions: 12, + distinctUsers: 1, + firstSeen: '2026-05-01T00:00:00.000Z', + lastSeen: '2026-05-11T00:00:00.000Z', + p50RuntimeMs: 20, + p95RuntimeMs: 80, + errorRate: 0, + rowsProduced: 0, + }, + topUsers: [{ user: 'svc_loader', executions: 12 }], + }); + }, + }; + const sqlAnalysis: SqlAnalysisPort = { + analyzeForFingerprint: vi.fn(), + analyzeBatch: vi.fn(async () => new Map()), + validateReadOnly: vi.fn(async () => ({ ok: true })), + }; + + await stageHistoricSqlAggregatedSnapshot({ + stagedDir, + connectionId: 'warehouse', + queryClient: {}, + reader, + sqlAnalysis, + pullConfig: { + dialect: 'postgres', + enabledSchemas: ['analytics'], + filters: { + serviceAccounts: { patterns: ['^svc_loader$'], mode: 'exclude' }, + }, + }, + now: new Date('2026-05-11T12:00:00.000Z'), + }); + + expect(sqlAnalysis.analyzeBatch).toHaveBeenCalledWith([], 'postgres', undefined); + const patterns = await readJson>(stagedDir, 'patterns-input.json'); + expect(patterns.templates).toEqual([]); + }); + it('redacts configured SQL substrings in staged artifacts while analyzing original SQL', async () => { const stagedDir = await tempDir(); const originalSql = @@ -198,7 +331,7 @@ describe('stageHistoricSqlAggregatedSnapshot', () => { [ 'api-events-with-secret', { - tablesTouched: ['public.api_events'], + tablesTouched: [tableRef('public.api_events')], columnsByClause: { select: [], where: ['api_key', 'note'], @@ -219,6 +352,7 @@ describe('stageHistoricSqlAggregatedSnapshot', () => { sqlAnalysis, pullConfig: { dialect: 'postgres', + enabledSchemas: ['public'], redactionPatterns: ['sk_live_[A-Za-z0-9]+', '(?i)secret_token_[a-z0-9]+'], }, now: new Date('2026-05-11T12:00:00.000Z'), @@ -227,6 +361,7 @@ describe('stageHistoricSqlAggregatedSnapshot', () => { expect(sqlAnalysis.analyzeBatch).toHaveBeenCalledWith( [{ id: 'api-events-with-secret', sql: originalSql }], 'postgres', + undefined, ); const tableJson = await readFile(join(stagedDir, 'tables/public.api_events.json'), 'utf-8'); @@ -266,21 +401,21 @@ describe('stageHistoricSqlAggregatedSnapshot', () => { [ 'selected-qualified', { - tablesTouched: ['orbit_analytics.int_active_contract_arr'], + tablesTouched: [tableRef('orbit_analytics.int_active_contract_arr')], columnsByClause: { select: [], where: [], join: [], groupBy: [] }, }, ], [ 'selected-unqualified', { - tablesTouched: ['int_customer_health_signals'], + tablesTouched: [tableRef('orbit_analytics.int_customer_health_signals')], columnsByClause: { select: [], where: [], join: [], groupBy: [] }, }, ], [ 'unselected', { - tablesTouched: ['orbit_raw.accounts'], + tablesTouched: [tableRef('orbit_raw.accounts')], columnsByClause: { select: [], where: [], join: [], groupBy: [] }, }, ], @@ -297,16 +432,16 @@ describe('stageHistoricSqlAggregatedSnapshot', () => { pullConfig: { dialect: 'postgres', enabledTables: [ - 'orbit_analytics.int_active_contract_arr', - 'orbit_analytics.int_customer_health_signals', + tableRef('orbit_analytics.int_active_contract_arr'), + tableRef('orbit_analytics.int_customer_health_signals'), ], }, now: new Date('2026-05-11T12:00:00.000Z'), }); expect(await readdir(join(stagedDir, 'tables'))).toEqual([ - 'int_customer_health_signals.json', 'orbit_analytics.int_active_contract_arr.json', + 'orbit_analytics.int_customer_health_signals.json', ]); const manifest = await readJson>(stagedDir, 'manifest.json'); expect(manifest.touchedTableCount).toBe(2); @@ -372,7 +507,7 @@ describe('stageHistoricSqlAggregatedSnapshot', () => { [ 'orders-customers-a', { - tablesTouched: ['public.orders', 'public.customers'], + tablesTouched: [tableRef('public.orders'), tableRef('public.customers')], columnsByClause: { select: [], where: ['payload'], @@ -384,7 +519,7 @@ describe('stageHistoricSqlAggregatedSnapshot', () => { [ 'orders-customers-b', { - tablesTouched: ['public.orders', 'public.customers'], + tablesTouched: [tableRef('public.orders'), tableRef('public.customers')], columnsByClause: { select: [], where: ['payload_b'], @@ -396,7 +531,7 @@ describe('stageHistoricSqlAggregatedSnapshot', () => { [ 'orders-single-table', { - tablesTouched: ['public.orders'], + tablesTouched: [tableRef('public.orders')], columnsByClause: { select: [], where: [], @@ -415,7 +550,7 @@ describe('stageHistoricSqlAggregatedSnapshot', () => { queryClient: {}, reader, sqlAnalysis, - pullConfig: { dialect: 'postgres' }, + pullConfig: { dialect: 'postgres', enabledSchemas: ['public'] }, now: new Date('2026-05-11T12:00:00.000Z'), }); @@ -456,7 +591,13 @@ describe('stageHistoricSqlAggregatedSnapshot', () => { const sqlAnalysis: SqlAnalysisPort = { analyzeForFingerprint: vi.fn(), analyzeBatch: vi.fn(async () => new Map([ - ['analytic', { tablesTouched: ['public.orders'], columnsByClause: { select: ['status'], where: [], join: [], groupBy: ['status'] } }], + [ + 'analytic', + { + tablesTouched: [tableRef('public.orders')], + columnsByClause: { select: ['status'], where: [], join: [], groupBy: ['status'] }, + }, + ], ])), validateReadOnly: vi.fn(async () => ({ ok: true })), }; @@ -467,7 +608,7 @@ describe('stageHistoricSqlAggregatedSnapshot', () => { queryClient: {}, reader, sqlAnalysis, - pullConfig: { dialect: 'postgres' }, + pullConfig: { dialect: 'postgres', enabledSchemas: ['public'] }, now: new Date('2026-05-11T12:00:00.000Z'), }); @@ -475,26 +616,27 @@ describe('stageHistoricSqlAggregatedSnapshot', () => { expect(sqlAnalysis.analyzeBatch).toHaveBeenCalledWith( [{ id: 'analytic', sql: 'select status, count(*) from public.orders group by status' }], 'postgres', + undefined, ); expect(await readdir(join(stagedDir, 'tables'))).toEqual(['public.orders.json']); }); - it('merges bare and schema-qualified references to the same table into one work unit', async () => { + it('keeps modeled-schema refs and drops unmodeled-schema refs by default', async () => { const stagedDir = await tempDir(); const reader: HistoricSqlReader = { async probe() { return { warnings: [], info: [] }; }, async *fetchAggregated() { - yield aggregate({ templateId: 'qualified', canonicalSql: 'select count(*) from orbit_raw.accounts' }); - yield aggregate({ templateId: 'bare', canonicalSql: 'select id from accounts where active' }); + yield aggregate({ templateId: 'modeled', canonicalSql: 'select count(*) from orbit_raw.accounts' }); + yield aggregate({ templateId: 'noise', canonicalSql: 'select count(*) from metabase.application_table' }); }, }; const sqlAnalysis: SqlAnalysisPort = { analyzeForFingerprint: vi.fn(), analyzeBatch: vi.fn(async () => new Map([ - ['qualified', { tablesTouched: ['orbit_raw.accounts'], columnsByClause: { select: [], where: [], join: [], groupBy: [] } }], - ['bare', { tablesTouched: ['accounts'], columnsByClause: { select: ['id'], where: ['active'], join: [], groupBy: [] } }], + ['modeled', { tablesTouched: [{ catalog: null, db: 'orbit_raw', name: 'accounts' }], columnsByClause: {} }], + ['noise', { tablesTouched: [{ catalog: null, db: 'metabase', name: 'application_table' }], columnsByClause: {} }], ])), validateReadOnly: vi.fn(async () => ({ ok: true })), }; @@ -505,16 +647,213 @@ describe('stageHistoricSqlAggregatedSnapshot', () => { queryClient: {}, reader, sqlAnalysis, - pullConfig: { dialect: 'postgres' }, + pullConfig: { + dialect: 'postgres', + enabledSchemas: ['orbit_raw'], + modeledTableCatalog: [{ catalog: null, db: 'orbit_raw', name: 'accounts' }], + }, now: new Date('2026-05-11T12:00:00.000Z'), }); - // The bare `accounts` reference resolves to the unique qualified `orbit_raw.accounts`, - // so the two templates collapse into a single work unit instead of two. expect(await readdir(join(stagedDir, 'tables'))).toEqual(['orbit_raw.accounts.json']); - const merged = await readJson>(stagedDir, 'tables/orbit_raw.accounts.json'); - expect(merged.topTemplates.map((t: any) => t.id).sort()).toEqual(['bare', 'qualified']); const manifest = await readJson>(stagedDir, 'manifest.json'); expect(manifest.touchedTableCount).toBe(1); }); + + it('fails open when the implicit modeled scope is empty', async () => { + const stagedDir = await tempDir(); + const reader: HistoricSqlReader = { + async probe() { + return { warnings: [], info: [] }; + }, + async *fetchAggregated() { + yield aggregate({ templateId: 'any-table', canonicalSql: 'select count(*) from metabase.application_table' }); + }, + }; + const sqlAnalysis: SqlAnalysisPort = { + analyzeForFingerprint: vi.fn(), + analyzeBatch: vi.fn(async () => new Map([ + ['any-table', { tablesTouched: [{ catalog: null, db: 'metabase', name: 'application_table' }], columnsByClause: {} }], + ])), + validateReadOnly: vi.fn(async () => ({ ok: true })), + }; + + await stageHistoricSqlAggregatedSnapshot({ + stagedDir, + connectionId: 'warehouse', + queryClient: {}, + reader, + sqlAnalysis, + pullConfig: { dialect: 'postgres', enabledSchemas: [], modeledTableCatalog: [] }, + now: new Date('2026-05-11T12:00:00.000Z'), + }); + + expect(await readdir(join(stagedDir, 'tables'))).toEqual(['metabase.application_table.json']); + const manifest = await readJson>(stagedDir, 'manifest.json'); + expect(manifest.warnings).toContain('query_history_scope_floor_disabled:empty_modeled_scope'); + }); + + it('lets enabledSchemas star disable the floor', async () => { + const stagedDir = await tempDir(); + const reader: HistoricSqlReader = { + async probe() { + return { warnings: [], info: [] }; + }, + async *fetchAggregated() { + yield aggregate({ templateId: 'noise', canonicalSql: 'select count(*) from metabase.application_table' }); + }, + }; + const sqlAnalysis: SqlAnalysisPort = { + analyzeForFingerprint: vi.fn(), + analyzeBatch: vi.fn(async () => new Map([ + ['noise', { tablesTouched: [{ catalog: null, db: 'metabase', name: 'application_table' }], columnsByClause: {} }], + ])), + validateReadOnly: vi.fn(async () => ({ ok: true })), + }; + + await stageHistoricSqlAggregatedSnapshot({ + stagedDir, + connectionId: 'warehouse', + queryClient: {}, + reader, + sqlAnalysis, + pullConfig: { + dialect: 'postgres', + enabledSchemas: ['*'], + modeledTableCatalog: [{ catalog: null, db: 'orbit_raw', name: 'accounts' }], + }, + now: new Date('2026-05-11T12:00:00.000Z'), + }); + + expect(await readdir(join(stagedDir, 'tables'))).toEqual(['metabase.application_table.json']); + }); + + it('matches BigQuery dataset scope even when refs include a catalog', async () => { + const stagedDir = await tempDir(); + const reader: HistoricSqlReader = { + async probe() { + return { warnings: [], info: [] }; + }, + async *fetchAggregated() { + yield aggregate({ templateId: 'modeled', canonicalSql: 'select count(*) from `demo-project.orbit_analytics.orders`' }); + yield aggregate({ templateId: 'noise', canonicalSql: 'select count(*) from `demo-project.metabase.application_table`' }); + }, + }; + const sqlAnalysis: SqlAnalysisPort = { + analyzeForFingerprint: vi.fn(), + analyzeBatch: vi.fn(async () => new Map([ + ['modeled', { tablesTouched: [{ catalog: 'demo-project', db: 'orbit_analytics', name: 'orders' }], columnsByClause: {} }], + ['noise', { tablesTouched: [{ catalog: 'demo-project', db: 'metabase', name: 'application_table' }], columnsByClause: {} }], + ])), + validateReadOnly: vi.fn(async () => ({ ok: true })), + }; + + await stageHistoricSqlAggregatedSnapshot({ + stagedDir, + connectionId: 'warehouse', + queryClient: {}, + reader, + sqlAnalysis, + pullConfig: { + dialect: 'bigquery', + enabledSchemas: ['orbit_analytics'], + modeledTableCatalog: [{ catalog: 'demo-project', db: 'orbit_analytics', name: 'orders' }], + }, + now: new Date('2026-05-11T12:00:00.000Z'), + }); + + expect(await readdir(join(stagedDir, 'tables'))).toEqual(['demo-project.orbit_analytics.orders.json']); + }); + + it('writes propagated scope-floor warnings to the staged manifest', async () => { + const stagedDir = await tempDir(); + const reader: HistoricSqlReader = { + async probe() { + return { warnings: [], info: [] }; + }, + async *fetchAggregated() { + yield aggregate({ templateId: 'any-table', canonicalSql: 'select count(*) from metabase.application_table' }); + }, + }; + const sqlAnalysis: SqlAnalysisPort = { + analyzeForFingerprint: vi.fn(), + analyzeBatch: vi.fn(async () => new Map([ + ['any-table', { tablesTouched: [{ catalog: null, db: 'metabase', name: 'application_table' }], columnsByClause: {} }], + ])), + validateReadOnly: vi.fn(async () => ({ ok: true })), + }; + + await stageHistoricSqlAggregatedSnapshot({ + stagedDir, + connectionId: 'warehouse', + queryClient: {}, + reader, + sqlAnalysis, + pullConfig: { + dialect: 'postgres', + enabledSchemas: ['*'], + scopeFloorWarnings: ['query_history_scope_floor_disabled:catalog_unavailable'], + }, + now: new Date('2026-05-11T12:00:00.000Z'), + }); + + const manifest = await readJson>(stagedDir, 'manifest.json'); + expect(manifest.warnings).toContain('query_history_scope_floor_disabled:catalog_unavailable'); + expect(await readdir(join(stagedDir, 'tables'))).toEqual(['metabase.application_table.json']); + }); + + it('retries without the catalog and disables the floor when catalog qualification fails wholesale', async () => { + const stagedDir = await tempDir(); + const reader: HistoricSqlReader = { + async probe() { + return { warnings: [], info: [] }; + }, + async *fetchAggregated() { + yield aggregate({ templateId: 'noise', canonicalSql: 'select count(*) from metabase.application_table' }); + }, + }; + const sqlAnalysis: SqlAnalysisPort = { + analyzeForFingerprint: vi.fn(), + analyzeBatch: vi + .fn() + .mockRejectedValueOnce(new Error('catalog qualification failed')) + .mockResolvedValueOnce( + new Map([ + ['noise', { tablesTouched: [{ catalog: null, db: 'metabase', name: 'application_table' }], columnsByClause: {} }], + ]), + ), + validateReadOnly: vi.fn(async () => ({ ok: true })), + }; + + await stageHistoricSqlAggregatedSnapshot({ + stagedDir, + connectionId: 'warehouse', + queryClient: {}, + reader, + sqlAnalysis, + pullConfig: { + dialect: 'postgres', + enabledSchemas: ['orbit_raw'], + modeledTableCatalog: [{ catalog: null, db: 'orbit_raw', name: 'accounts' }], + }, + now: new Date('2026-05-11T12:00:00.000Z'), + }); + + expect(sqlAnalysis.analyzeBatch).toHaveBeenCalledTimes(2); + expect(sqlAnalysis.analyzeBatch).toHaveBeenNthCalledWith( + 1, + [{ id: 'noise', sql: 'select count(*) from metabase.application_table' }], + 'postgres', + { catalog: { tables: [{ catalog: null, db: 'orbit_raw', name: 'accounts' }] } }, + ); + expect(sqlAnalysis.analyzeBatch).toHaveBeenNthCalledWith( + 2, + [{ id: 'noise', sql: 'select count(*) from metabase.application_table' }], + 'postgres', + undefined, + ); + expect(await readdir(join(stagedDir, 'tables'))).toEqual(['metabase.application_table.json']); + const manifest = await readJson>(stagedDir, 'manifest.json'); + expect(manifest.warnings).toContain('query_history_scope_floor_disabled:catalog_qualification_failed'); + }); }); diff --git a/packages/cli/test/context/ingest/adapters/historic-sql/types.test.ts b/packages/cli/test/context/ingest/adapters/historic-sql/types.test.ts index d9417521..ca3d7e70 100644 --- a/packages/cli/test/context/ingest/adapters/historic-sql/types.test.ts +++ b/packages/cli/test/context/ingest/adapters/historic-sql/types.test.ts @@ -59,6 +59,7 @@ describe('historic-sql unified contracts', () => { expect( stagedTableInputSchema.parse({ table: 'public.orders', + tableRef: { catalog: null, db: 'public', name: 'orders' }, stats: { executionsBucket: '10-100', distinctUsersBucket: '2-5', @@ -81,7 +82,7 @@ describe('historic-sql unified contracts', () => { { id: 'pg:123', canonicalSql: 'select * from public.orders', - tablesTouched: ['public.orders'], + tablesTouched: [{ catalog: null, db: 'public', name: 'orders' }], executionsBucket: '10-100', distinctUsersBucket: '2-5', dialect: 'postgres', diff --git a/packages/cli/test/context/ingest/local-adapters.test.ts b/packages/cli/test/context/ingest/local-adapters.test.ts index f70c4879..a8799cee 100644 --- a/packages/cli/test/context/ingest/local-adapters.test.ts +++ b/packages/cli/test/context/ingest/local-adapters.test.ts @@ -1,4 +1,4 @@ -import { mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; @@ -34,6 +34,36 @@ describe('local ingest adapters', () => { }; } + async function seedLiveScanTable( + projectDir: string, + connectionId: string, + table: { catalog: string | null; db: string | null; name: string }, + ): Promise { + const rawRoot = join(projectDir, 'raw-sources', connectionId, 'live-database', 'sync-1'); + await mkdir(join(rawRoot, 'tables'), { recursive: true }); + await writeFile( + join(rawRoot, 'connection.json'), + `${JSON.stringify({ connectionId, driver: 'postgres' }, null, 2)}\n`, + 'utf-8', + ); + await writeFile( + join(rawRoot, 'tables', `${table.db ?? 'default'}-${table.name}.json`), + `${JSON.stringify( + { + ...table, + kind: 'table', + comment: null, + estimatedRows: null, + columns: [], + foreignKeys: [], + }, + null, + 2, + )}\n`, + 'utf-8', + ); + } + it('registers Metabase locally as a staged-bundle adapter', () => { const adapters = createDefaultLocalIngestAdapters(project); @@ -205,11 +235,14 @@ describe('local ingest adapters', () => { dialect: 'postgres', minExecutions: 7, enabledTables: [], + enabledSchemas: [], + modeledTableCatalog: [], filters: { serviceAccounts: { patterns: ['^svc_'], mode: 'exclude' }, dropTrivialProbes: true, }, redactionPatterns: [], + scopeFloorWarnings: [], staleArchiveAfterDays: 90, }); }); @@ -237,6 +270,71 @@ describe('local ingest adapters', () => { }); }); + it('passes computed modeled scope to direct historic-sql adapter pull config', async () => { + await mkdir(join(project.projectDir, 'semantic-layer/warehouse'), { recursive: true }); + await writeFile( + join(project.projectDir, 'semantic-layer/warehouse/revenue.yaml'), + [ + 'name: revenue', + 'table: orbit_analytics.mart_revenue', + 'grain: [id]', + 'columns:', + ' - name: id', + ' type: string', + '', + ].join('\n'), + 'utf-8', + ); + await seedLiveScanTable(project.projectDir, 'warehouse', { + catalog: null, + db: 'orbit_raw', + name: 'accounts', + }); + const projectWithQueryHistory = projectWithConnections({ + warehouse: { + driver: 'postgres', + schemas: ['orbit_raw'], + context: { + queryHistory: { + enabled: true, + minExecutions: 7, + filters: { dropTrivialProbes: true }, + }, + }, + }, + }); + const adapter = { source: 'historic-sql' } as never; + + await expect(localPullConfigForAdapter(projectWithQueryHistory, adapter, 'warehouse')).resolves.toMatchObject({ + dialect: 'postgres', + minExecutions: 7, + enabledSchemas: ['orbit_analytics', 'orbit_raw'], + modeledTableCatalog: [ + { catalog: null, db: 'orbit_analytics', name: 'mart_revenue' }, + { catalog: null, db: 'orbit_raw', name: 'accounts' }, + ], + }); + }); + + it('passes query-history scope fail-open warnings to direct historic-sql pull config', async () => { + const projectDir = await mkdtemp(join(tmpdir(), 'ktx-local-qh-scope-warning-')); + const project = await initKtxProject({ projectDir }); + project.config.connections.warehouse = { + driver: 'postgres', + schemas: ['orbit_raw'], + context: { queryHistory: { enabled: true } }, + } as never; + const adapter = { source: 'historic-sql' } as never; + + await expect(localPullConfigForAdapter(project, adapter, 'warehouse')).resolves.toMatchObject({ + dialect: 'postgres', + enabledSchemas: ['*'], + scopeFloorWarnings: ['query_history_scope_floor_disabled:catalog_unavailable'], + }); + + await rm(projectDir, { recursive: true, force: true }); + }); + it('rejects local historic-sql pulls when the connection has not enabled historic SQL', async () => { const historicSql = createDefaultLocalIngestAdapters(project, { historicSql: { diff --git a/packages/cli/test/context/sql-analysis/http-sql-analysis-port.test.ts b/packages/cli/test/context/sql-analysis/http-sql-analysis-port.test.ts index 02b275a6..df32fb8d 100644 --- a/packages/cli/test/context/sql-analysis/http-sql-analysis-port.test.ts +++ b/packages/cli/test/context/sql-analysis/http-sql-analysis-port.test.ts @@ -49,7 +49,10 @@ describe('createHttpSqlAnalysisPort', () => { const requestJson = vi.fn(async () => ({ results: { orders: { - tables_touched: ['public.orders', 'public.customers'], + tables_touched: [ + { catalog: null, db: 'public', name: 'orders' }, + { catalog: null, db: 'public', name: 'customers' }, + ], columns_by_clause: { select: ['status'], where: ['created_at'], @@ -79,7 +82,10 @@ describe('createHttpSqlAnalysisPort', () => { [ 'orders', { - tablesTouched: ['public.orders', 'public.customers'], + tablesTouched: [ + { catalog: null, db: 'public', name: 'orders' }, + { catalog: null, db: 'public', name: 'customers' }, + ], columnsByClause: { select: ['status'], where: ['created_at'], @@ -108,6 +114,62 @@ describe('createHttpSqlAnalysisPort', () => { }); }); + it('passes an optional catalog and maps structured table refs for SQL batch analysis', async () => { + const requestJson = vi.fn(async () => ({ + results: { + orders: { + tables_touched: [ + { catalog: null, db: 'orbit_raw', name: 'accounts' }, + { catalog: 'demo_project', db: 'orbit_analytics', name: 'orders' }, + ], + columns_by_clause: { select: ['id'] }, + error: null, + }, + }, + })); + const port = createHttpSqlAnalysisPort({ baseUrl: 'http://python.test', requestJson }); + + await expect( + port.analyzeBatch( + [{ id: 'orders', sql: 'select id from accounts' }], + 'postgres', + { + catalog: { + tables: [ + { catalog: null, db: 'orbit_raw', name: 'accounts', columns: ['id'] }, + { catalog: 'demo_project', db: 'orbit_analytics', name: 'orders', columns: ['id'] }, + ], + }, + }, + ), + ).resolves.toEqual( + new Map([ + [ + 'orders', + { + tablesTouched: [ + { catalog: null, db: 'orbit_raw', name: 'accounts' }, + { catalog: 'demo_project', db: 'orbit_analytics', name: 'orders' }, + ], + columnsByClause: { select: ['id'] }, + error: null, + }, + ], + ]), + ); + + expect(requestJson).toHaveBeenCalledWith('/sql/analyze-batch', { + dialect: 'postgres', + items: [{ id: 'orders', sql: 'select id from accounts' }], + catalog: { + tables: [ + { catalog: null, db: 'orbit_raw', name: 'accounts', columns: ['id'] }, + { catalog: 'demo_project', db: 'orbit_analytics', name: 'orders', columns: ['id'] }, + ], + }, + }); + }); + it('maps read-only SQL validation responses', async () => { const requests: Array<{ path: string; payload: Record }> = []; const port = createHttpSqlAnalysisPort({ @@ -150,7 +212,7 @@ describe('createHttpSqlAnalysisPort', () => { const requestJson = vi.fn(async () => ({ results: { orders: { - tables_touched: ['public.orders'], + tables_touched: [{ catalog: null, db: 'public', name: 'orders' }], columns_by_clause: { select: ['status'], where: [42] }, error: null, }, diff --git a/packages/cli/test/local-adapters.test.ts b/packages/cli/test/local-adapters.test.ts index 345f662a..467c9a56 100644 --- a/packages/cli/test/local-adapters.test.ts +++ b/packages/cli/test/local-adapters.test.ts @@ -2,8 +2,8 @@ import { mkdtemp, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { loadKtxProject } from '../src/context/project/project.js'; -import { afterEach, beforeEach, describe, expect, it } from 'vitest'; -import { createKtxCliLocalIngestAdapters } from '../src/local-adapters.js'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { createKtxCliHistoricSqlRuntime, createKtxCliLocalIngestAdapters } from '../src/local-adapters.js'; function sqlAnalysisStub() { return { @@ -70,6 +70,116 @@ describe('CLI local ingest adapters', () => { ]); }); + it('creates reusable query-history runtime dependencies for setup', async () => { + await writeProject( + tempDir, + [ + 'connections:', + ' warehouse:', + ' driver: postgres', + ' url: env:WAREHOUSE_DATABASE_URL', + ' readonly: true', + ' context:', + ' queryHistory:', + ' enabled: true', + '', + ].join('\n'), + ); + const project = await loadKtxProject({ projectDir: tempDir }); + const sqlAnalysis = sqlAnalysisStub(); + + const runtime = createKtxCliHistoricSqlRuntime(project, 'warehouse', { sqlAnalysis }); + + expect(runtime).toMatchObject({ + dialect: 'postgres', + sqlAnalysis, + }); + expect(runtime?.reader).toBeDefined(); + expect(runtime?.queryClient).toBeDefined(); + }); + + it('uses managed daemon SQL analysis when query-history runtime gets managed daemon options', async () => { + await writeProject( + tempDir, + [ + 'connections:', + ' warehouse:', + ' driver: postgres', + ' url: env:WAREHOUSE_DATABASE_URL', + ' readonly: true', + ' context:', + ' queryHistory:', + ' enabled: true', + '', + ].join('\n'), + ); + const project = await loadKtxProject({ projectDir: tempDir }); + const testIo = { + stdout: { write: vi.fn() }, + stderr: { write: vi.fn() }, + }; + const ensureRuntime = vi.fn(async () => ({ + layout: {} as never, + manifest: {} as never, + })); + const startDaemon = vi.fn(async () => ({ + status: 'started' as const, + layout: {} as never, + state: { pid: 1234 } as never, + baseUrl: 'http://127.0.0.1:61234', + })); + const postJson = vi.fn(async () => ({ + results: { + probe: { + tables_touched: [], + columns_by_clause: {}, + error: null, + }, + }, + })); + + const runtime = createKtxCliHistoricSqlRuntime(project, 'warehouse', { + managedDaemon: { + cliVersion: '0.2.0', + projectDir: tempDir, + installPolicy: 'auto', + io: testIo, + ensureRuntime, + startDaemon, + postJson, + }, + }); + + await expect(runtime?.sqlAnalysis.analyzeBatch([{ id: 'probe', sql: 'select 1' }], 'postgres')).resolves.toEqual( + new Map([ + [ + 'probe', + { + tablesTouched: [], + columnsByClause: {}, + error: null, + }, + ], + ]), + ); + expect(ensureRuntime).toHaveBeenCalledWith({ + cliVersion: '0.2.0', + installPolicy: 'auto', + io: testIo, + feature: 'core', + }); + expect(startDaemon).toHaveBeenCalledWith({ + cliVersion: '0.2.0', + projectDir: tempDir, + features: ['core'], + force: false, + }); + expect(postJson).toHaveBeenCalledWith('http://127.0.0.1:61234', '/sql/analyze-batch', { + dialect: 'postgres', + items: [{ id: 'probe', sql: 'select 1' }], + }); + }); + it('registers historic SQL when explicitly requested even if connection query history is disabled', async () => { await writeProject( tempDir, diff --git a/packages/cli/test/managed-python-http.test.ts b/packages/cli/test/managed-python-http.test.ts index f19b6d72..74334042 100644 --- a/packages/cli/test/managed-python-http.test.ts +++ b/packages/cli/test/managed-python-http.test.ts @@ -161,7 +161,7 @@ describe('KTX daemon ingest ports', () => { const requestJson = vi.fn(async () => ({ results: { orders: { - tables_touched: ['public.orders'], + tables_touched: [{ catalog: null, db: 'public', name: 'orders' }], columns_by_clause: { select: ['status'] }, error: null, }, @@ -175,7 +175,7 @@ describe('KTX daemon ingest ports', () => { [ 'orders', { - tablesTouched: ['public.orders'], + tablesTouched: [{ catalog: null, db: 'public', name: 'orders' }], columnsByClause: { select: ['status'] }, error: null, }, diff --git a/packages/cli/test/public-ingest.test.ts b/packages/cli/test/public-ingest.test.ts index 1a8b457e..ba35faf6 100644 --- a/packages/cli/test/public-ingest.test.ts +++ b/packages/cli/test/public-ingest.test.ts @@ -1,4 +1,4 @@ -import { mkdtemp, rm } from 'node:fs/promises'; +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { buildDefaultKtxProjectConfig, type KtxProjectConfig } from '../src/context/project/config.js'; @@ -668,12 +668,134 @@ describe('runKtxPublicIngest', () => { dropFailedBelow: { errorRate: 0.5, executions: 3 }, }, redactionPatterns: ['(?i)secret'], - enabledTables: ['orbit_analytics.int_active_contract_arr'], + enabledTables: [{ catalog: null, db: 'orbit_analytics', name: 'int_active_contract_arr' }], }, }); expect(ingestArgs?.historicSqlPullConfigOverride).not.toHaveProperty('enabled'); }); + it('resolves query-history scope after the schema scan writes artifacts', async () => { + const io = makeIo(); + const projectDir = await mkdtemp(join(tmpdir(), 'ktx-public-qh-scope-')); + const project = deepReadyProject({ + warehouse: { + driver: 'postgres', + schemas: ['orbit_raw'], + context: { queryHistory: { enabled: true } }, + }, + }); + const runScan = vi.fn(async () => { + await mkdir(join(projectDir, 'semantic-layer/warehouse'), { recursive: true }); + await writeFile( + join(projectDir, 'semantic-layer/warehouse/revenue.yaml'), + [ + 'name: revenue', + 'table: orbit_analytics.mart_revenue', + 'grain: [id]', + 'columns:', + ' - name: id', + ' type: string', + '', + ].join('\n'), + 'utf-8', + ); + const rawRoot = join(projectDir, 'raw-sources/warehouse/live-database/sync-1'); + await mkdir(join(rawRoot, 'tables'), { recursive: true }); + await writeFile( + join(rawRoot, 'connection.json'), + `${JSON.stringify({ connectionId: 'warehouse', driver: 'postgres' }, null, 2)}\n`, + 'utf-8', + ); + await writeFile( + join(rawRoot, 'tables/accounts.json'), + `${JSON.stringify( + { + catalog: null, + db: 'orbit_raw', + name: 'accounts', + kind: 'table', + comment: null, + estimatedRows: null, + columns: [ + { + name: 'id', + nativeType: 'integer', + normalizedType: 'integer', + dimensionType: 'number', + nullable: false, + primaryKey: true, + comment: null, + }, + ], + foreignKeys: [], + }, + null, + 2, + )}\n`, + 'utf-8', + ); + await writeFile( + join(rawRoot, 'scan-report.json'), + `${JSON.stringify( + { + connectionId: 'warehouse', + driver: 'postgres', + syncId: 'sync-1', + runId: 'scan-sync-1', + trigger: 'cli', + mode: 'enriched', + dryRun: false, + artifactPaths: { + rawSourcesDir: 'raw-sources/warehouse/live-database/sync-1', + reportPath: 'raw-sources/warehouse/live-database/sync-1/scan-report.json', + manifestShards: [], + enrichmentArtifacts: [], + }, + counts: {}, + warnings: [], + enrichment: {}, + enrichmentState: {}, + }, + null, + 2, + )}\n`, + 'utf-8', + ); + return 0; + }); + const runIngest = vi.fn>(async () => 0); + + await expect( + runKtxPublicIngest( + { + command: 'run', + projectDir, + targetConnectionId: 'warehouse', + all: false, + json: false, + inputMode: 'disabled', + queryHistory: 'enabled', + }, + io.io, + { loadProject: vi.fn(async () => ({ ...project, projectDir })), runScan, runIngest }, + ), + ).resolves.toBe(0); + + const ingestArgs = runIngest.mock.calls[0]?.[0] as + | Extract>[0], { command: 'run' }> + | undefined; + expect(ingestArgs?.historicSqlPullConfigOverride).toMatchObject({ + dialect: 'postgres', + enabledSchemas: ['orbit_analytics', 'orbit_raw'], + modeledTableCatalog: [ + { catalog: null, db: 'orbit_analytics', name: 'mart_revenue' }, + { catalog: null, db: 'orbit_raw', name: 'accounts' }, + ], + }); + + await rm(projectDir, { recursive: true, force: true }); + }); + it('prints the schema-first notice for explicit query-history runs', async () => { const io = makeIo(); const project = deepReadyProject({ diff --git a/packages/cli/test/setup-databases.test.ts b/packages/cli/test/setup-databases.test.ts index 265459e2..6adb0af0 100644 --- a/packages/cli/test/setup-databases.test.ts +++ b/packages/cli/test/setup-databases.test.ts @@ -6,6 +6,7 @@ import { parseKtxProjectConfig } from '../src/context/project/config.js'; import { readKtxSetupState, writeKtxSetupState } from '../src/context/project/setup-config.js'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { + managedDaemonOptionsForSetupQueryHistoryPicker, type KtxSetupDatabaseDriver, type KtxSetupDatabasesDeps, type KtxSetupDatabasesPromptAdapter, @@ -137,6 +138,22 @@ function textInputPrompt(message: string): string { return `${title}\n│\n│ ${bodyLines.join('\n│ ')}\n│ Press Escape to go back.\n│`; } +function queryHistoryFromConfig(connection: unknown): { + filters?: { serviceAccounts?: unknown; dropTrivialProbes?: boolean }; +} | undefined { + if (!connection || typeof connection !== 'object' || Array.isArray(connection)) { + return undefined; + } + const context = (connection as { context?: unknown }).context; + if (!context || typeof context !== 'object' || Array.isArray(context)) { + return undefined; + } + const queryHistory = (context as { queryHistory?: unknown }).queryHistory; + return queryHistory && typeof queryHistory === 'object' && !Array.isArray(queryHistory) + ? (queryHistory as { filters?: { serviceAccounts?: unknown; dropTrivialProbes?: boolean } }) + : undefined; +} + describe('setup databases step', () => { let tempDir: string; @@ -150,6 +167,61 @@ describe('setup databases step', () => { await rm(tempDir, { recursive: true, force: true }); }); + it('builds managed daemon options for setup query-history SQL analysis', () => { + const io = makeIo(); + + expect( + managedDaemonOptionsForSetupQueryHistoryPicker({ + projectDir: tempDir, + args: { + inputMode: 'disabled', + cliVersion: '0.2.0', + runtimeInstallPolicy: 'auto', + }, + io: io.io, + }), + ).toEqual({ + cliVersion: '0.2.0', + projectDir: tempDir, + installPolicy: 'auto', + io: io.io, + }); + }); + + it('defaults managed daemon setup options when the database step is called directly', () => { + const io = makeIo(); + + expect( + managedDaemonOptionsForSetupQueryHistoryPicker({ + projectDir: tempDir, + args: { + inputMode: 'disabled', + }, + io: io.io, + }), + ).toMatchObject({ + cliVersion: expect.any(String), + projectDir: tempDir, + installPolicy: 'never', + io: io.io, + }); + + expect( + managedDaemonOptionsForSetupQueryHistoryPicker({ + projectDir: tempDir, + args: { + inputMode: 'auto', + }, + io: io.io, + }), + ).toMatchObject({ + cliVersion: expect.any(String), + projectDir: tempDir, + installPolicy: 'prompt', + io: io.io, + }); + }); + it('shows every supported database in the interactive checklist', async () => { const prompts = makePromptAdapter({ multiselectValues: [['back']] }); @@ -2569,6 +2641,190 @@ describe('setup databases step', () => { expect(io.stdout()).toContain('pg_stat_statements ready'); }); + it('auto-applies derived query-history service-account filters in non-interactive setup', async () => { + const io = makeIo(); + const queryHistoryFilterPicker = vi.fn(async () => ({ + excludedRoles: [ + { + role: 'svc_loader', + pattern: '^svc_loader$', + reason: 'Runs recurring loader traffic against modeled tables.', + }, + ], + consideredRoleCount: 2, + skipped: null, + warnings: [], + })); + + const result = await runKtxSetupDatabasesStep( + { + projectDir: tempDir, + inputMode: 'disabled', + yes: true, + databaseDrivers: ['postgres'], + databaseConnectionId: 'warehouse', + databaseUrl: 'env:DATABASE_URL', + databaseSchemas: ['public'], + enableQueryHistory: true, + skipDatabases: false, + }, + io.io, + { + testConnection: vi.fn(async () => 0), + scanConnection: vi.fn(async () => 0), + historicSqlReadinessProbe: vi.fn(async () => { + const runner = fakeHistoricSqlRunner('postgres', 'pg_stat_statements'); + return { + ok: true as const, + dialect: 'postgres' as const, + runner, + result: { pgServerVersion: 'PostgreSQL 16.4', warnings: [], info: [] }, + }; + }), + queryHistoryFilterPicker, + createQueryHistoryLlmRuntime: vi.fn(() => null), + }, + ); + + expect(result.status).toBe('ready'); + expect(queryHistoryFilterPicker).toHaveBeenCalledTimes(1); + const config = parseKtxProjectConfig(await readFile(join(tempDir, 'ktx.yaml'), 'utf-8')); + expect(config.connections.warehouse).toMatchObject({ + context: { + queryHistory: { + filters: { + dropTrivialProbes: true, + serviceAccounts: { + mode: 'exclude', + patterns: ['^svc_loader$'], + }, + }, + }, + }, + }); + expect(io.stdout()).toContain('Proposed query-history service-account filters'); + expect(io.stdout()).toContain('svc_loader'); + }); + + it('lets interactive setup skip applying derived filters', async () => { + const io = makeIo(); + const prompts = makePromptAdapter({ + selectValues: ['skip'], + }); + + const result = await runKtxSetupDatabasesStep( + { + projectDir: tempDir, + inputMode: 'auto', + yes: false, + databaseDrivers: ['postgres'], + databaseConnectionId: 'warehouse', + databaseUrl: 'env:DATABASE_URL', + databaseSchemas: ['public'], + enableQueryHistory: true, + skipDatabases: false, + }, + io.io, + { + prompts, + testConnection: vi.fn(async () => 0), + scanConnection: vi.fn(async () => 0), + historicSqlReadinessProbe: vi.fn(async () => { + const runner = fakeHistoricSqlRunner('postgres', 'pg_stat_statements'); + return { + ok: true as const, + dialect: 'postgres' as const, + runner, + result: { pgServerVersion: 'PostgreSQL 16.4', warnings: [], info: [] }, + }; + }), + queryHistoryFilterPicker: vi.fn(async () => ({ + excludedRoles: [{ role: 'svc_loader', pattern: '^svc_loader$', reason: 'Loader traffic.' }], + consideredRoleCount: 2, + skipped: null, + warnings: [], + })), + createQueryHistoryLlmRuntime: vi.fn(() => null), + }, + ); + + expect(result.status).toBe('ready'); + const config = parseKtxProjectConfig(await readFile(join(tempDir, 'ktx.yaml'), 'utf-8')); + expect(queryHistoryFromConfig(config.connections.warehouse)?.filters).toEqual({ dropTrivialProbes: true }); + expect(prompts.select).toHaveBeenCalledWith({ + message: 'Apply 1 derived query-history service-account exclusion?', + options: [ + { value: 'apply', label: 'Apply derived filters (recommended)' }, + { value: 'skip', label: 'Leave query history filters unchanged' }, + ], + }); + }); + + it('does not overwrite an existing serviceAccounts block', async () => { + await writeFile( + join(tempDir, 'ktx.yaml'), + [ + 'connections:', + ' warehouse:', + ' driver: postgres', + ' url: env:DATABASE_URL', + ' context:', + ' queryHistory:', + ' enabled: true', + ' filters:', + ' dropTrivialProbes: true', + ' serviceAccounts:', + ' mode: exclude', + ' patterns:', + " - '^existing$'", + '', + ].join('\n'), + 'utf-8', + ); + + const io = makeIo(); + const result = await runKtxSetupDatabasesStep( + { + projectDir: tempDir, + inputMode: 'disabled', + yes: true, + databaseConnectionIds: ['warehouse'], + databaseSchemas: [], + enableQueryHistory: true, + skipDatabases: false, + }, + io.io, + { + testConnection: vi.fn(async () => 0), + scanConnection: vi.fn(async () => 0), + historicSqlReadinessProbe: vi.fn(async () => { + const runner = fakeHistoricSqlRunner('postgres', 'pg_stat_statements'); + return { + ok: true as const, + dialect: 'postgres' as const, + runner, + result: { pgServerVersion: 'PostgreSQL 16.4', warnings: [], info: [] }, + }; + }), + queryHistoryFilterPicker: vi.fn(async () => ({ + excludedRoles: [{ role: 'svc_loader', pattern: '^svc_loader$', reason: 'Loader traffic.' }], + consideredRoleCount: 2, + skipped: { reason: 'user-block-present' as const }, + warnings: [], + })), + createQueryHistoryLlmRuntime: vi.fn(() => null), + }, + ); + + expect(result.status).toBe('ready'); + const config = parseKtxProjectConfig(await readFile(join(tempDir, 'ktx.yaml'), 'utf-8')); + expect(queryHistoryFromConfig(config.connections.warehouse)?.filters?.serviceAccounts).toEqual({ + mode: 'exclude', + patterns: ['^existing$'], + }); + expect(io.stdout()).toContain('Existing query-history service-account filters left unchanged'); + }); + it('asks interactive Postgres setup whether to enable query history', async () => { await writeFile( join(tempDir, 'ktx.yaml'), diff --git a/packages/cli/test/setup.test.ts b/packages/cli/test/setup.test.ts index e4eca44d..9b8bf689 100644 --- a/packages/cli/test/setup.test.ts +++ b/packages/cli/test/setup.test.ts @@ -1684,6 +1684,9 @@ describe('setup status', () => { expect.objectContaining({ projectDir: tempDir, inputMode: 'disabled', + yes: true, + cliVersion: '0.2.0', + runtimeInstallPolicy: 'auto', databaseDrivers: ['postgres'], databaseConnectionId: 'warehouse', databaseUrl: 'env:DATABASE_URL', diff --git a/packages/cli/test/sql.test.ts b/packages/cli/test/sql.test.ts index b48ebe5b..ef74fd49 100644 --- a/packages/cli/test/sql.test.ts +++ b/packages/cli/test/sql.test.ts @@ -33,7 +33,7 @@ function makeIo(options: { isTTY?: boolean } = {}) { function makeSqlAnalysis(result: Awaited>): SqlAnalysisPort { return { analyzeForFingerprint: vi.fn(), - analyzeBatch: vi.fn(async () => new Map([['cli-sql', { tablesTouched: ['orders'], columnsByClause: {} }]])), + analyzeBatch: vi.fn(async () => new Map([['cli-sql', { tablesTouched: [{ catalog: null, db: null, name: 'orders' }], columnsByClause: {} }]])), validateReadOnly: vi.fn(async () => result), }; } diff --git a/python/ktx-daemon/src/ktx_daemon/sql_analysis.py b/python/ktx-daemon/src/ktx_daemon/sql_analysis.py index e831e47f..54f3d0e2 100644 --- a/python/ktx-daemon/src/ktx_daemon/sql_analysis.py +++ b/python/ktx-daemon/src/ktx_daemon/sql_analysis.py @@ -2,15 +2,32 @@ from __future__ import annotations import os from concurrent.futures import ProcessPoolExecutor +from dataclasses import dataclass from typing import Literal import sqlglot from pydantic import BaseModel, Field from sqlglot import exp +from sqlglot.optimizer.normalize_identifiers import normalize_identifiers +from sqlglot.optimizer.qualify_tables import qualify_tables SqlAnalysisClause = Literal["select", "where", "join", "groupBy", "having", "orderBy"] +class SqlAnalysisTableRef(BaseModel): + catalog: str | None = None + db: str | None = None + name: str + + +class SqlAnalysisCatalogTable(SqlAnalysisTableRef): + columns: list[str] = Field(default_factory=list) + + +class AnalyzeSqlCatalog(BaseModel): + tables: list[SqlAnalysisCatalogTable] = Field(default_factory=list) + + class AnalyzeSqlBatchItem(BaseModel): id: str sql: str @@ -19,11 +36,12 @@ class AnalyzeSqlBatchItem(BaseModel): class AnalyzeSqlBatchRequest(BaseModel): dialect: str items: list[AnalyzeSqlBatchItem] + catalog: AnalyzeSqlCatalog | None = None max_workers: int | None = Field(default=None, ge=1, le=32) class AnalyzeSqlBatchResult(BaseModel): - tables_touched: list[str] = Field(default_factory=list) + tables_touched: list[SqlAnalysisTableRef] = Field(default_factory=list) columns_by_clause: dict[SqlAnalysisClause, list[str]] = Field(default_factory=dict) error: str | None = None @@ -82,17 +100,76 @@ def _ordered_unique(values: list[str]) -> list[str]: return result -def _table_ref(table: exp.Table) -> str: - parts: list[str] = [] +def _normalize_identifier(value: str | None, dialect: str) -> str | None: + if value is None: + return None + identifier = exp.to_identifier(value) + identifier.meta["is_table"] = True + normalized = normalize_identifiers(identifier, dialect=dialect) + return str(normalized.name) + + +def _normalized_ref(ref: SqlAnalysisTableRef, dialect: str) -> SqlAnalysisTableRef: + return SqlAnalysisTableRef( + catalog=_normalize_identifier(ref.catalog, dialect), + db=_normalize_identifier(ref.db, dialect), + name=_normalize_identifier(ref.name, dialect) or ref.name, + ) + + +@dataclass(frozen=True) +class _CatalogIndex: + by_full: dict[tuple[str | None, str | None, str], SqlAnalysisTableRef] + by_name: dict[str, list[SqlAnalysisTableRef]] + + +def _catalog_index( + catalog: AnalyzeSqlCatalog | None, dialect: str +) -> _CatalogIndex | None: + if catalog is None or not catalog.tables: + return None + by_full: dict[tuple[str | None, str | None, str], SqlAnalysisTableRef] = {} + by_name: dict[str, list[SqlAnalysisTableRef]] = {} + for table in catalog.tables: + ref = _normalized_ref(table, dialect) + key = (ref.catalog, ref.db, ref.name) + by_full[key] = ref + by_name.setdefault(ref.name, []).append(ref) + return _CatalogIndex(by_full=by_full, by_name=by_name) + + +def _raw_table_ref(table: exp.Table, dialect: str) -> SqlAnalysisTableRef | None: + if not table.name: + return None catalog = table.args.get("catalog") db = table.args.get("db") - if catalog is not None and getattr(catalog, "name", None): - parts.append(str(catalog.name)) - if db is not None and getattr(db, "name", None): - parts.append(str(db.name)) - if table.name: - parts.append(str(table.name)) - return ".".join(parts) + return _normalized_ref( + SqlAnalysisTableRef( + catalog=str(catalog.name) + if catalog is not None and getattr(catalog, "name", None) + else None, + db=str(db.name) if db is not None and getattr(db, "name", None) else None, + name=str(table.name), + ), + dialect, + ) + + +def _resolve_table_refs( + raw: SqlAnalysisTableRef, + catalog: _CatalogIndex | None, +) -> list[SqlAnalysisTableRef]: + if catalog is None: + return [raw] + exact = catalog.by_full.get((raw.catalog, raw.db, raw.name)) + if exact is not None: + return [exact] + if raw.db is not None: + return [raw] + matches = catalog.by_name.get(raw.name, []) + if matches: + return matches + return [SqlAnalysisTableRef(catalog=None, db=None, name=raw.name)] def _column_name(column: exp.Column) -> str: @@ -146,33 +223,48 @@ def _columns_by_clause(tree: exp.Expression) -> dict[SqlAnalysisClause, list[str return result +def _table_refs( + tree: exp.Expression, dialect: str, catalog: _CatalogIndex | None +) -> list[SqlAnalysisTableRef]: + normalized_tree = normalize_identifiers(tree, dialect=dialect) + qualified_tree = qualify_tables(normalized_tree, dialect=dialect) + cte_names = {cte.alias_or_name.lower() for cte in qualified_tree.find_all(exp.CTE)} + refs: list[SqlAnalysisTableRef] = [] + seen: set[tuple[str | None, str | None, str]] = set() + for table in qualified_tree.find_all(exp.Table): + if table.name.lower() in cte_names: + continue + raw = _raw_table_ref(table, dialect) + if raw is None: + continue + for ref in _resolve_table_refs(raw, catalog): + key = (ref.catalog, ref.db, ref.name) + if key not in seen: + seen.add(key) + refs.append(ref) + return refs + + def _analyze_one( - item_id: str, sql: str, dialect: str + item_id: str, sql: str, dialect: str, catalog: _CatalogIndex | None ) -> tuple[str, AnalyzeSqlBatchResult]: try: tree = sqlglot.parse_one(sql, read=dialect) except sqlglot.errors.SqlglotError as exc: return item_id, AnalyzeSqlBatchResult(error=str(exc)) - cte_names = {cte.alias_or_name.lower() for cte in tree.find_all(exp.CTE)} - table_refs = [ - table_ref - for table_ref in (_table_ref(table) for table in tree.find_all(exp.Table)) - if table_ref and table_ref.split(".")[-1].lower() not in cte_names - ] - return item_id, AnalyzeSqlBatchResult( - tables_touched=_ordered_unique(table_refs), + tables_touched=_table_refs(tree, dialect, catalog), columns_by_clause=_columns_by_clause(tree), error=None, ) def _analyze_payload( - payload: tuple[str, str, str], + payload: tuple[str, str, str, _CatalogIndex | None], ) -> tuple[str, AnalyzeSqlBatchResult]: - item_id, sql, dialect = payload - return _analyze_one(item_id, sql, dialect) + item_id, sql, dialect, catalog = payload + return _analyze_one(item_id, sql, dialect, catalog) def validate_read_only_sql_response( @@ -222,7 +314,8 @@ def _worker_count(request: AnalyzeSqlBatchRequest) -> int: def analyze_sql_batch_response( request: AnalyzeSqlBatchRequest, ) -> AnalyzeSqlBatchResponse: - payloads = [(item.id, item.sql, request.dialect) for item in request.items] + catalog = _catalog_index(request.catalog, request.dialect) + payloads = [(item.id, item.sql, request.dialect, catalog) for item in request.items] if _worker_count(request) == 1: analyzed = [_analyze_payload(payload) for payload in payloads] else: diff --git a/python/ktx-daemon/tests/test_app.py b/python/ktx-daemon/tests/test_app.py index 9960daaf..2c3237ad 100644 --- a/python/ktx-daemon/tests/test_app.py +++ b/python/ktx-daemon/tests/test_app.py @@ -368,7 +368,9 @@ def test_sql_analyze_batch_endpoint_returns_per_item_results() -> None: assert response.status_code == 200 body = response.json() - assert body["results"]["orders"]["tables_touched"] == ["public.orders"] + assert body["results"]["orders"]["tables_touched"] == [ + {"catalog": None, "db": "public", "name": "orders"} + ] assert body["results"]["orders"]["columns_by_clause"] == { "select": ["status"], "where": ["created_at"], diff --git a/python/ktx-daemon/tests/test_sql_analysis.py b/python/ktx-daemon/tests/test_sql_analysis.py index 855d16fd..2fb3970a 100644 --- a/python/ktx-daemon/tests/test_sql_analysis.py +++ b/python/ktx-daemon/tests/test_sql_analysis.py @@ -32,7 +32,10 @@ def test_analyze_sql_batch_extracts_tables_and_clause_columns() -> None: result = response.results["orders_by_customer"] assert result.error is None - assert result.tables_touched == ["public.orders", "public.customers"] + assert [item.model_dump() for item in result.tables_touched] == [ + {"catalog": None, "db": "public", "name": "orders"}, + {"catalog": None, "db": "public", "name": "customers"}, + ] assert result.columns_by_clause == { "select": ["status"], "where": ["created_at"], @@ -56,6 +59,114 @@ def test_analyze_sql_batch_returns_per_item_parse_errors() -> None: assert result.error is not None +def test_analyze_sql_batch_qualifies_bare_table_from_catalog() -> None: + response = analyze_sql_batch_response( + AnalyzeSqlBatchRequest( + dialect="postgres", + catalog={ + "tables": [ + { + "catalog": None, + "db": "orbit_raw", + "name": "accounts", + "columns": ["id"], + }, + { + "catalog": None, + "db": "orbit_analytics", + "name": "orders", + "columns": ["id"], + }, + ] + }, + items=[AnalyzeSqlBatchItem(id="bare", sql="select id from accounts")], + max_workers=1, + ) + ) + + assert [item.model_dump() for item in response.results["bare"].tables_touched] == [ + {"catalog": None, "db": "orbit_raw", "name": "accounts"} + ] + + +def test_analyze_sql_batch_returns_all_ambiguous_modeled_matches() -> None: + response = analyze_sql_batch_response( + AnalyzeSqlBatchRequest( + dialect="postgres", + catalog={ + "tables": [ + { + "catalog": None, + "db": "orbit_raw", + "name": "events", + "columns": ["id"], + }, + { + "catalog": None, + "db": "orbit_analytics", + "name": "events", + "columns": ["id"], + }, + ] + }, + items=[AnalyzeSqlBatchItem(id="ambiguous", sql="select id from events")], + max_workers=1, + ) + ) + + assert [ + item.model_dump() for item in response.results["ambiguous"].tables_touched + ] == [ + {"catalog": None, "db": "orbit_raw", "name": "events"}, + {"catalog": None, "db": "orbit_analytics", "name": "events"}, + ] + + +def test_analyze_sql_batch_leaves_unresolved_bare_refs_unqualified() -> None: + response = analyze_sql_batch_response( + AnalyzeSqlBatchRequest( + dialect="postgres", + catalog={ + "tables": [{"catalog": None, "db": "orbit_raw", "name": "accounts"}] + }, + items=[AnalyzeSqlBatchItem(id="missing", sql="select * from invoices")], + max_workers=1, + ) + ) + + assert [ + item.model_dump() for item in response.results["missing"].tables_touched + ] == [{"catalog": None, "db": None, "name": "invoices"}] + + +def test_analyze_sql_batch_returns_bigquery_project_dataset_table_refs() -> None: + response = analyze_sql_batch_response( + AnalyzeSqlBatchRequest( + dialect="bigquery", + catalog={ + "tables": [ + { + "catalog": "demo-project", + "db": "orbit_analytics", + "name": "orders", + } + ] + }, + items=[ + AnalyzeSqlBatchItem( + id="bq", + sql="select * from `demo-project.orbit_analytics.orders`", + ) + ], + max_workers=1, + ) + ) + + assert [item.model_dump() for item in response.results["bq"].tables_touched] == [ + {"catalog": "demo-project", "db": "orbit_analytics", "name": "orders"} + ] + + def test_columns_from_nodes_ignores_non_expression_clause_values() -> None: assert _columns_from_nodes([True, False, None]) == [] From 7ba948a13524388a89ef54fd082eff1aaaa9bb76 Mon Sep 17 00:00:00 2001 From: semantic-release-bot Date: Wed, 3 Jun 2026 21:50:59 +0000 Subject: [PATCH 14/25] chore(release): 0.9.0 [skip ci] ## [0.9.0](https://github.com/Kaelio/ktx/compare/v0.8.0...v0.9.0) (2026-06-03) ### Features * add codex llm backend for ktx runtime work ([#253](https://github.com/Kaelio/ktx/issues/253)) ([494618a](https://github.com/Kaelio/ktx/commit/494618ab142505bd988156d867be047e3affc4c3)) * **cli:** consistent connection setup recovery and build-time gate ([#257](https://github.com/Kaelio/ktx/issues/257)) ([ce1516b](https://github.com/Kaelio/ktx/commit/ce1516b357807874902d189d1d163755634083e8)) * **cli:** guide next action at end of ktx setup, not reruns ([#256](https://github.com/Kaelio/ktx/issues/256)) ([45aa95d](https://github.com/Kaelio/ktx/commit/45aa95d2cc121267bbbc8c184402a19573956dd4)) * **cli:** stream plain ktx ingest progress to stderr (KLO-726) ([#251](https://github.com/Kaelio/ktx/issues/251)) ([13774bf](https://github.com/Kaelio/ktx/commit/13774bfcef1622a83e29f27042bde1bcdd97beb2)) * **query-history:** scope mining to modeled schemas by default ([#258](https://github.com/Kaelio/ktx/issues/258)) ([e70ae1e](https://github.com/Kaelio/ktx/commit/e70ae1e63bcd7168ade90b8998a06b561ce36cf2)) * **telemetry:** include error details for failures ([#254](https://github.com/Kaelio/ktx/issues/254)) ([6da8c34](https://github.com/Kaelio/ktx/commit/6da8c3452a97bfcbeefd8bbcc3379d4d41b4dc9f)) ### Bug Fixes * **ingest:** recover textual-conflict gate failures; fix query-history adapter ([#255](https://github.com/Kaelio/ktx/issues/255)) ([f5dea9a](https://github.com/Kaelio/ktx/commit/f5dea9a0891305e7c4d90b0156638681fe75c1dc)) ### Other Changes * refresh star history chart [skip ci] ([9d3a0b7](https://github.com/Kaelio/ktx/commit/9d3a0b751df68c19df8007c4dec4c891f73246b0)) * refresh star history chart [skip ci] ([74c6076](https://github.com/Kaelio/ktx/commit/74c6076b72d0f79d8e7bfa8ef31550de39a36d00)) * refresh star history chart [skip ci] ([d01abe6](https://github.com/Kaelio/ktx/commit/d01abe6f3c8330dbdcf674ef8891e2b2118ac192)) * revert repo references to Kaelio/ktx and remove rename-resilience ([#252](https://github.com/Kaelio/ktx/issues/252)) ([41e20c9](https://github.com/Kaelio/ktx/commit/41e20c9ce7c4dcfc848073d72ae7c4ea766506fc)), closes [#250](https://github.com/Kaelio/ktx/issues/250) [#250](https://github.com/Kaelio/ktx/issues/250) --- package.json | 2 +- packages/cli/package.json | 2 +- python/ktx-daemon/pyproject.toml | 2 +- python/ktx-sl/pyproject.toml | 2 +- release-policy.json | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/package.json b/package.json index a9590d70..e7714634 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "ktx-workspace", - "version": "0.8.0", + "version": "0.9.0", "description": "Workspace root for ktx packages", "private": true, "type": "module", diff --git a/packages/cli/package.json b/packages/cli/package.json index 9d3af54c..939a8b9c 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@kaelio/ktx", - "version": "0.8.0", + "version": "0.9.0", "description": "Standalone ktx context layer for data agents", "type": "module", "engines": { diff --git a/python/ktx-daemon/pyproject.toml b/python/ktx-daemon/pyproject.toml index 97f2c15a..0fcf8e88 100644 --- a/python/ktx-daemon/pyproject.toml +++ b/python/ktx-daemon/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "ktx-daemon" -version = "0.8.0" +version = "0.9.0" description = "Portable compute package for KTX semantic-layer operations" readme = "README.md" requires-python = ">=3.13" diff --git a/python/ktx-sl/pyproject.toml b/python/ktx-sl/pyproject.toml index 01eb184d..aaf65265 100644 --- a/python/ktx-sl/pyproject.toml +++ b/python/ktx-sl/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "ktx-sl" -version = "0.8.0" +version = "0.9.0" description = "Agent-first semantic layer engine with aggregate locality" readme = "README.md" requires-python = ">=3.13" diff --git a/release-policy.json b/release-policy.json index 7cb55839..33774673 100644 --- a/release-policy.json +++ b/release-policy.json @@ -19,7 +19,7 @@ }, "publishedPackageSmoke": { "packageName": "@kaelio/ktx", - "version": "0.8.0", + "version": "0.9.0", "registry": null }, "runtimeInstaller": { From 8eb1cd3e7947f7803d05dc8f204afcaf6d8eb92b Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 4 Jun 2026 07:45:37 +0000 Subject: [PATCH 15/25] chore: refresh star history chart [skip ci] --- assets/star-history.svg | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/assets/star-history.svg b/assets/star-history.svg index 23016f3e..98de4631 100644 --- a/assets/star-history.svg +++ b/assets/star-history.svg @@ -1 +1 @@ -star-history.comMay 17May 24May 31 200400600800kaelio/ktxStar HistoryDateGitHub Stars +star-history.comMay 17May 24May 31 200400600800kaelio/ktxStar HistoryDateGitHub Stars From c2beaf7d5569197f7267697f5d65ac3dd1c60d9f Mon Sep 17 00:00:00 2001 From: Andrey Avtomonov Date: Thu, 4 Jun 2026 14:11:08 +0200 Subject: [PATCH 16/25] feat(setup): wizard prompt tweaks and quieter query-history filter output (#259) Setup wizard flow tweaks: - Add a reveal-tail password prompt (reveal-password-prompt.ts) that unmasks the last few characters of a typed/pasted secret, and wire it into the setup prompt adapter in place of clack's password(); adds the @clack/core dep. - Reorder wizard select options: surface "Paste a key" before the environment-variable option across embeddings/models/sources, promote Metabase/Notion in the source list, put Git URL before Local path, reorder the Notion crawl-mode choices, and relabel the sources "Done" action. Query-history filter picker output: - Collapse the per-template parse-failure lines into a single count in the setup output and route the full template-id list to --debug stderr. - Model parse failures as a structured parseFailedTemplateIds field instead of warning strings. - Add a privacy-safe query_history_filter_completed telemetry event (counts/enums only), mirrored into the Python daemon schema. --- packages/cli/package.json | 1 + packages/cli/src/commands/setup-commands.ts | 3 + .../query-history-filter-picker.ts | 9 +- packages/cli/src/reveal-password-prompt.ts | 93 +++++++++++++++++++ packages/cli/src/setup-databases.ts | 45 +++++++-- packages/cli/src/setup-embeddings.ts | 2 +- packages/cli/src/setup-models.ts | 2 +- packages/cli/src/setup-prompts.ts | 4 +- packages/cli/src/setup-sources.ts | 14 +-- packages/cli/src/setup.ts | 2 + packages/cli/src/telemetry/events.schema.json | 82 ++++++++++++++++ packages/cli/src/telemetry/events.ts | 16 ++++ .../query-history-filter-picker.test.ts | 48 ++++++++++ .../cli/test/reveal-password-prompt.test.ts | 40 ++++++++ packages/cli/test/setup-databases.test.ts | 51 ++++++++++ packages/cli/test/setup-prompts.test.ts | 13 ++- packages/cli/test/setup-sources.test.ts | 12 +-- packages/cli/test/telemetry/events.test.ts | 1 + pnpm-lock.yaml | 3 + .../ktx_daemon/telemetry/events.schema.json | 82 ++++++++++++++++ .../tests/test_telemetry_schema_sync.py | 1 + uv.lock | 4 +- 22 files changed, 494 insertions(+), 34 deletions(-) create mode 100644 packages/cli/src/reveal-password-prompt.ts create mode 100644 packages/cli/test/reveal-password-prompt.test.ts diff --git a/packages/cli/package.json b/packages/cli/package.json index 939a8b9c..ba769d58 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -47,6 +47,7 @@ "@ai-sdk/devtools": "0.0.18", "@ai-sdk/google-vertex": "^4.0.134", "@anthropic-ai/claude-agent-sdk": "0.3.146", + "@clack/core": "1.3.1", "@clack/prompts": "1.4.0", "@clickhouse/client": "^1.18.5", "@commander-js/extra-typings": "14.0.0", diff --git a/packages/cli/src/commands/setup-commands.ts b/packages/cli/src/commands/setup-commands.ts index 1619a80a..0302e9ed 100644 --- a/packages/cli/src/commands/setup-commands.ts +++ b/packages/cli/src/commands/setup-commands.ts @@ -406,6 +406,8 @@ export function registerSetupCommands(program: Command, context: KtxCliCommandCo } const resolvedAgentScope = options.local ? 'local' : options.global ? 'global' : 'project'; + const debugEnabled = + ((command.optsWithGlobals ? command.optsWithGlobals() : command.opts()) as { debug?: unknown }).debug === true; await runSetupArgs(context, { command: 'run', projectDir: resolveCommandProjectDir(command), @@ -415,6 +417,7 @@ export function registerSetupCommands(program: Command, context: KtxCliCommandCo agentScope: resolvedAgentScope, skipAgents: options.skipAgents === true, inputMode: options.input === false ? 'disabled' : 'auto', + ...(debugEnabled ? { debug: true } : {}), yes: options.yes === true, cliVersion: context.packageInfo.version, ...(options.llmBackend ? { llmBackend: options.llmBackend } : {}), diff --git a/packages/cli/src/context/ingest/adapters/historic-sql/query-history-filter-picker.ts b/packages/cli/src/context/ingest/adapters/historic-sql/query-history-filter-picker.ts index bb296513..3f77900d 100644 --- a/packages/cli/src/context/ingest/adapters/historic-sql/query-history-filter-picker.ts +++ b/packages/cli/src/context/ingest/adapters/historic-sql/query-history-filter-picker.ts @@ -23,6 +23,7 @@ export interface QueryHistoryFilterProposal { consideredRoleCount: number; skipped: { reason: 'no-llm' | 'no-daemon' | 'no-in-scope-history' | 'user-block-present' } | null; warnings: string[]; + parseFailedTemplateIds: string[]; } export interface ProposeQueryHistoryServiceAccountFiltersInput { @@ -74,7 +75,7 @@ const queryHistoryFilterAdjudicationSchema = z.object({ type QueryHistoryFilterAdjudication = z.infer; function emptyProposal(skipped: QueryHistoryFilterProposal['skipped'], warnings: string[] = []): QueryHistoryFilterProposal { - return { excludedRoles: [], consideredRoleCount: 0, skipped, warnings }; + return { excludedRoles: [], consideredRoleCount: 0, skipped, warnings, parseFailedTemplateIds: [] }; } function displayTableRef(ref: KtxTableRef): string { @@ -180,6 +181,7 @@ export async function proposeQueryHistoryServiceAccountFilters( const windowDays = 'windowDays' in config ? config.windowDays : 90; const windowStart = new Date(now.getTime() - windowDays * 24 * 60 * 60 * 1000); const warnings: string[] = []; + const parseFailedTemplateIds: string[] = []; const snapshot: AggregatedTemplate[] = []; try { @@ -212,7 +214,7 @@ export async function proposeQueryHistoryServiceAccountFilters( for (const template of snapshot) { const parsed = analysis.get(template.templateId); if (!parsed || parsed.error) { - warnings.push(`query_history_filter_picker_parse_failed:${template.templateId}`); + parseFailedTemplateIds.push(template.templateId); continue; } const tablesTouched = [...new Map(parsed.tablesTouched.map((ref) => [tableRefKey(ref), ref])).values()] @@ -236,6 +238,7 @@ export async function proposeQueryHistoryServiceAccountFilters( consideredRoleCount: records.length, skipped: { reason: 'no-in-scope-history' }, warnings, + parseFailedTemplateIds, }; } @@ -256,6 +259,7 @@ export async function proposeQueryHistoryServiceAccountFilters( ...warnings, `query_history_filter_picker_llm_failed:${error instanceof Error ? error.message : String(error)}`, ], + parseFailedTemplateIds, }; } @@ -274,5 +278,6 @@ export async function proposeQueryHistoryServiceAccountFilters( consideredRoleCount: records.length, skipped: input.userServiceAccountsPresent ? { reason: 'user-block-present' } : null, warnings, + parseFailedTemplateIds, }; } diff --git a/packages/cli/src/reveal-password-prompt.ts b/packages/cli/src/reveal-password-prompt.ts new file mode 100644 index 00000000..3fe3ed66 --- /dev/null +++ b/packages/cli/src/reveal-password-prompt.ts @@ -0,0 +1,93 @@ +import { styleText } from 'node:util'; +import { PasswordPrompt, type PasswordOptions } from '@clack/core'; +import { S_BAR, S_BAR_END, S_PASSWORD_MASK, settings, symbol } from '@clack/prompts'; + +// How many trailing characters of a pasted secret to leave visible so the user +// can confirm what landed (e.g. `••••••a1b2`). Kept small on purpose. +const REVEAL_TAIL_COUNT = 4; + +/** + * Mask every character of `userInput` except the last `tail`, but only reveal the + * tail once the secret is long enough that the hidden portion still dominates + * (`length > tail * 2`). Short secrets stay fully masked so we never expose most + * of a small value. The returned string keeps the same code-unit length as the + * input so clack's cursor slicing in `userInputWithCursor` stays aligned. + * + * @internal + */ +export function maskRevealingTail(userInput: string, maskChar: string, tail: number): string { + const revealLength = userInput.length > tail * 2 ? tail : 0; + const hiddenLength = userInput.length - revealLength; + return maskChar.repeat(hiddenLength) + userInput.slice(hiddenLength); +} + +class RevealTailPasswordPrompt extends PasswordPrompt { + readonly #maskChar: string; + readonly #tail: number; + + constructor(options: PasswordOptions & { tail: number }) { + super(options); + this.#maskChar = options.mask ?? S_PASSWORD_MASK; + this.#tail = options.tail; + } + + override get masked(): string { + return maskRevealingTail(this.userInput, this.#maskChar, this.#tail); + } +} + +// Reproduces the @clack/prompts password frame (pinned to the installed version) +// so this prompt is visually identical to every other setup prompt; the only +// behavioral change is the tail-revealing `masked` getter above. +function renderPasswordFrame(prompt: Omit, message: string): string { + const withGuide = settings.withGuide; + const title = `${withGuide ? `${styleText('gray', S_BAR)}\n` : ''}${symbol(prompt.state)} ${message}\n`; + const masked = prompt.masked; + switch (prompt.state) { + case 'error': { + const bar = withGuide ? `${styleText('yellow', S_BAR)} ` : ''; + const end = withGuide ? `${styleText('yellow', S_BAR_END)} ` : ''; + return `${title.trim()}\n${bar}${masked}\n${end}${styleText('yellow', prompt.error)}\n`; + } + case 'submit': { + const bar = withGuide ? `${styleText('gray', S_BAR)} ` : ''; + return `${title}${bar}${masked ? styleText('dim', masked) : ''}`; + } + case 'cancel': { + const bar = withGuide ? `${styleText('gray', S_BAR)} ` : ''; + const body = masked ? styleText(['strikethrough', 'dim'], masked) : ''; + return `${title}${bar}${body}${masked && withGuide ? `\n${styleText('gray', S_BAR)}` : ''}`; + } + default: { + const bar = withGuide ? `${styleText('cyan', S_BAR)} ` : ''; + const end = withGuide ? styleText('cyan', S_BAR_END) : ''; + return `${title}${bar}${prompt.userInputWithCursor}\n${end}\n`; + } + } +} + +export interface RevealPasswordOptions { + message: string; + mask?: string; + tail?: number; + validate?: PasswordOptions['validate']; + signal?: AbortSignal; +} + +/** + * Drop-in replacement for clack's `password()` that reveals the last few + * characters of the entered value while typing. Resolves to the raw value or the + * clack cancel symbol, matching `password()`'s contract. + */ +export function revealPassword(options: RevealPasswordOptions): Promise { + const prompt = new RevealTailPasswordPrompt({ + mask: options.mask ?? S_PASSWORD_MASK, + tail: options.tail ?? REVEAL_TAIL_COUNT, + validate: options.validate, + signal: options.signal, + render() { + return renderPasswordFrame(this, options.message); + }, + }); + return prompt.prompt() as Promise; +} diff --git a/packages/cli/src/setup-databases.ts b/packages/cli/src/setup-databases.ts index 3cb6c5d2..002ead30 100644 --- a/packages/cli/src/setup-databases.ts +++ b/packages/cli/src/setup-databases.ts @@ -73,6 +73,7 @@ export type KtxSetupDatabaseDriver = export interface KtxSetupDatabasesArgs { projectDir: string; inputMode: 'auto' | 'disabled'; + debug?: boolean; yes?: boolean; cliVersion?: string; runtimeInstallPolicy?: KtxManagedPythonInstallPolicy; @@ -1626,7 +1627,12 @@ function hasServiceAccountsBlock(connection: KtxProjectConnectionConfig | undefi return 'serviceAccounts' in filters; } -function printQueryHistoryFilterProposal(io: KtxCliIo, proposal: QueryHistoryFilterProposal): void { +function printQueryHistoryFilterProposal(io: KtxCliIo, proposal: QueryHistoryFilterProposal, debug = false): void { + if (debug && proposal.parseFailedTemplateIds.length > 0) { + io.stderr.write( + `[debug] query-history filter picker could not parse ${proposal.parseFailedTemplateIds.length} template(s): ${proposal.parseFailedTemplateIds.join(', ')}\n`, + ); + } if (proposal.excludedRoles.length === 0) { if (proposal.skipped?.reason === 'no-llm') { io.stdout.write('│ Query-history filter picker skipped: no LLM is configured.\n'); @@ -1635,6 +1641,12 @@ function printQueryHistoryFilterProposal(io: KtxCliIo, proposal: QueryHistoryFil } else if (proposal.skipped?.reason === 'no-in-scope-history') { io.stdout.write('│ Query-history filter picker found no in-scope service-account exclusions.\n'); } + if (proposal.parseFailedTemplateIds.length > 0) { + const count = proposal.parseFailedTemplateIds.length; + io.stdout.write( + `│ Skipped ${count} query template${count === 1 ? '' : 's'} ktx could not parse (run with --debug to list them).\n`, + ); + } for (const warning of proposal.warnings) { io.stdout.write(`│ ! ${warning}\n`); } @@ -1727,12 +1739,17 @@ async function maybeProposeQueryHistoryFilters(input: { deps: input.deps, }); if (!llmRuntime && !input.deps.queryHistoryFilterPicker) { - printQueryHistoryFilterProposal(input.io, { - excludedRoles: [], - consideredRoleCount: 0, - skipped: { reason: 'no-llm' }, - warnings: [], - }); + printQueryHistoryFilterProposal( + input.io, + { + excludedRoles: [], + consideredRoleCount: 0, + skipped: { reason: 'no-llm' }, + warnings: [], + parseFailedTemplateIds: [], + }, + input.args.debug === true, + ); return; } @@ -1773,7 +1790,19 @@ async function maybeProposeQueryHistoryFilters(input: { userServiceAccountsPresent, }); - printQueryHistoryFilterProposal(input.io, proposal); + printQueryHistoryFilterProposal(input.io, proposal, input.args.debug === true); + await emitTelemetryEvent({ + name: 'query_history_filter_completed', + projectDir: input.projectDir, + io: input.io, + fields: { + dialect, + consideredRoleCount: proposal.consideredRoleCount, + excludedRoleCount: proposal.excludedRoles.length, + parseFailedCount: proposal.parseFailedTemplateIds.length, + outcome: 'ok', + }, + }); if (proposal.skipped?.reason === 'user-block-present') { input.io.stdout.write('│ Existing query-history service-account filters left unchanged.\n'); return; diff --git a/packages/cli/src/setup-embeddings.ts b/packages/cli/src/setup-embeddings.ts index 8f49bcf1..5d02e3e4 100644 --- a/packages/cli/src/setup-embeddings.ts +++ b/packages/cli/src/setup-embeddings.ts @@ -222,8 +222,8 @@ async function chooseCredentialRef( const choice = await prompts.select({ message: `How should KTX find your ${embeddingBackendDisplayName(backend)} embedding API key?`, options: [ - { value: 'env', label: `Use ${defaultEnv} from the environment` }, { value: 'paste', label: 'Paste a key and save it as a local secret file' }, + { value: 'env', label: `Use ${defaultEnv} from the environment` }, { value: 'back', label: 'Back' }, ], }); diff --git a/packages/cli/src/setup-models.ts b/packages/cli/src/setup-models.ts index 8e8cf30b..e673cb99 100644 --- a/packages/cli/src/setup-models.ts +++ b/packages/cli/src/setup-models.ts @@ -470,8 +470,8 @@ async function chooseCredentialRef( const choice = await prompts.select({ message: `How should KTX find your Anthropic API key?\n\n${ANTHROPIC_CREDENTIAL_PROMPT_CONTEXT}`, options: [ - { value: 'env', label: 'Use ANTHROPIC_API_KEY from the environment' }, { value: 'paste', label: 'Paste a key and save it as a local secret file' }, + { value: 'env', label: 'Use ANTHROPIC_API_KEY from the environment' }, { value: 'back', label: 'Back' }, ], }); diff --git a/packages/cli/src/setup-prompts.ts b/packages/cli/src/setup-prompts.ts index 1609bd76..e508d8ff 100644 --- a/packages/cli/src/setup-prompts.ts +++ b/packages/cli/src/setup-prompts.ts @@ -9,12 +9,12 @@ import { log, multiselect, note, - password, select, text, } from '@clack/prompts'; import type { KtxCliIo } from './cli-runtime.js'; import { withMenuOptionsSpacing, withTextInputNavigation } from './prompt-navigation.js'; +import { revealPassword } from './reveal-password-prompt.js'; import { withSetupInterruptConfirmation } from './setup-interrupt.js'; export interface KtxSetupPromptOption { @@ -189,7 +189,7 @@ export function createKtxSetupPromptAdapter(options: KtxSetupPromptAdapterOption }, async password(promptOptions) { const value = await withSetupInterruptConfirmation(() => - password({ ...promptOptions, message: withTextInputNavigation(promptOptions.message) }), + revealPassword({ ...promptOptions, message: withTextInputNavigation(promptOptions.message) }), ); return isCancel(value) ? undefined : String(value); }, diff --git a/packages/cli/src/setup-sources.ts b/packages/cli/src/setup-sources.ts index 0a66c3a7..25552fbf 100644 --- a/packages/cli/src/setup-sources.ts +++ b/packages/cli/src/setup-sources.ts @@ -119,11 +119,11 @@ export interface KtxSetupSourcesDeps { const SOURCE_OPTIONS: Array<{ value: KtxSetupSourceType; label: string }> = [ { value: 'dbt', label: 'dbt' }, - { value: 'metricflow', label: 'MetricFlow' }, { value: 'metabase', label: 'Metabase' }, + { value: 'notion', label: 'Notion' }, + { value: 'metricflow', label: 'MetricFlow' }, { value: 'looker', label: 'Looker' }, { value: 'lookml', label: 'LookML' }, - { value: 'notion', label: 'Notion' }, ]; const SOURCE_LABELS = Object.fromEntries(SOURCE_OPTIONS.map((option) => [option.value, option.label])) as Record< @@ -269,8 +269,8 @@ async function chooseSourceCredentialRef(input: { message: `How should KTX find your ${input.label}?`, options: [ ...(input.existingRef ? [{ value: 'keep', label: 'Keep existing credential' }] : []), - { value: 'env', label: `Use ${input.envName} from the environment` }, { value: 'paste', label: 'Paste a key and save it as a local secret file' }, + { value: 'env', label: `Use ${input.envName} from the environment` }, { value: 'back', label: 'Back' }, ], }); @@ -307,8 +307,8 @@ async function chooseGitAuthCredentialRef(input: { message: `${label} repo requires authentication.`, options: [ ...(input.existingRef ? [{ value: 'keep', label: 'Keep existing credential' }] : []), - { value: 'env', label: 'Use GITHUB_TOKEN from the environment' }, { value: 'paste', label: 'Paste a token and save it as a local secret file' }, + { value: 'env', label: 'Use GITHUB_TOKEN from the environment' }, { value: 'skip', label: 'Skip — try without authentication' }, { value: 'back', label: 'Back' }, ], @@ -1063,8 +1063,8 @@ async function promptForInteractiveSource( const selectedLocation = await prompts.select({ message: `${source} source location`, options: [ - { value: 'path', label: 'Local path' }, { value: 'git', label: 'Git URL' }, + { value: 'path', label: 'Local path' }, { value: 'back', label: 'Back' }, ], }); @@ -1343,8 +1343,8 @@ async function promptForInteractiveSource( const crawlMode = await prompts.select({ message: 'Which Notion pages should KTX ingest?', options: [ - { value: 'selected_roots', label: 'Specific pages and their subpages (choose them in a picker)' }, { value: 'all_accessible', label: 'All pages the integration can access' }, + { value: 'selected_roots', label: 'Specific pages and their subpages (choose them in a picker)' }, { value: 'back', label: 'Back' }, ], }); @@ -2064,7 +2064,7 @@ export async function runKtxSetupSourcesStep( const addMore = await prompts.select({ message: `${readyConnectionIds.length} context source${readyConnectionIds.length > 1 ? 's' : ''} configured (${readyConnectionIds.join(', ')}). Add another?`, options: [ - { value: 'done', label: 'Done — continue to context build' }, + { value: 'done', label: 'Done adding context sources' }, { value: 'edit', label: 'Edit an existing context source' }, { value: 'add', label: 'Add another context source' }, ], diff --git a/packages/cli/src/setup.ts b/packages/cli/src/setup.ts index a991367e..fc45abb3 100644 --- a/packages/cli/src/setup.ts +++ b/packages/cli/src/setup.ts @@ -80,6 +80,7 @@ export type KtxSetupArgs = agentScope?: KtxAgentScope; skipAgents?: boolean; inputMode: 'auto' | 'disabled'; + debug?: boolean; yes: boolean; cliVersion: string; llmBackend?: KtxSetupLlmBackend; @@ -735,6 +736,7 @@ async function runKtxSetupInner(args: KtxSetupArgs, io: KtxCliIo, deps: KtxSetup { projectDir: projectResult.projectDir, inputMode: args.inputMode, + ...(args.debug !== undefined ? { debug: args.debug } : {}), yes: args.yes, cliVersion: args.cliVersion, runtimeInstallPolicy: setupRuntimeInstallPolicy(args), diff --git a/packages/cli/src/telemetry/events.schema.json b/packages/cli/src/telemetry/events.schema.json index a75f92f1..c6c3d6f8 100644 --- a/packages/cli/src/telemetry/events.schema.json +++ b/packages/cli/src/telemetry/events.schema.json @@ -206,6 +206,17 @@ "errorClass", "durationMs" ] + }, + { + "name": "query_history_filter_completed", + "description": "Emitted after the setup query-history service-account filter picker runs.", + "fields": [ + "dialect", + "consideredRoleCount", + "excludedRoleCount", + "parseFailedCount", + "outcome" + ] } ], "$defs": { @@ -1434,6 +1445,77 @@ "durationMs" ], "additionalProperties": false + }, + "query_history_filter_completed": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "cliVersion": { + "type": "string" + }, + "nodeVersion": { + "type": "string" + }, + "osPlatform": { + "type": "string" + }, + "osRelease": { + "type": "string" + }, + "arch": { + "type": "string" + }, + "runtime": { + "type": "string", + "enum": [ + "node", + "daemon-py" + ] + }, + "isCi": { + "type": "boolean" + }, + "dialect": { + "type": "string" + }, + "consideredRoleCount": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "excludedRoleCount": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "parseFailedCount": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "outcome": { + "type": "string", + "enum": [ + "ok", + "error" + ] + } + }, + "required": [ + "cliVersion", + "nodeVersion", + "osPlatform", + "osRelease", + "arch", + "runtime", + "isCi", + "dialect", + "consideredRoleCount", + "excludedRoleCount", + "parseFailedCount", + "outcome" + ], + "additionalProperties": false } } } diff --git a/packages/cli/src/telemetry/events.ts b/packages/cli/src/telemetry/events.ts index c4fc2e6f..cf650492 100644 --- a/packages/cli/src/telemetry/events.ts +++ b/packages/cli/src/telemetry/events.ts @@ -206,6 +206,16 @@ const sqlGenCompletedSchema = telemetryCommonEnvelopeSchema }) .strict(); +const queryHistoryFilterCompletedSchema = telemetryCommonEnvelopeSchema + .extend({ + dialect: z.string(), + consideredRoleCount: z.number().int().nonnegative(), + excludedRoleCount: z.number().int().nonnegative(), + parseFailedCount: z.number().int().nonnegative(), + outcome: outcomeSchema, + }) + .strict(); + /** @internal */ export const telemetryEventSchemas = { install_first_run: installFirstRunSchema, @@ -225,6 +235,7 @@ export const telemetryEventSchemas = { daemon_stopped: daemonStoppedSchema, sl_plan_completed: slPlanCompletedSchema, sql_gen_completed: sqlGenCompletedSchema, + query_history_filter_completed: queryHistoryFilterCompletedSchema, } as const; /** @internal */ @@ -360,6 +371,11 @@ export const telemetryEventCatalog = [ description: 'Emitted after daemon SQL generation completes.', fields: ['outcome', 'dialect', 'errorClass', 'durationMs'], }, + { + name: 'query_history_filter_completed', + description: 'Emitted after the setup query-history service-account filter picker runs.', + fields: ['dialect', 'consideredRoleCount', 'excludedRoleCount', 'parseFailedCount', 'outcome'], + }, ] as const; export type TelemetryEventName = keyof typeof telemetryEventSchemas; diff --git a/packages/cli/test/context/ingest/adapters/historic-sql/query-history-filter-picker.test.ts b/packages/cli/test/context/ingest/adapters/historic-sql/query-history-filter-picker.test.ts index 4c295092..5c9e2e60 100644 --- a/packages/cli/test/context/ingest/adapters/historic-sql/query-history-filter-picker.test.ts +++ b/packages/cli/test/context/ingest/adapters/historic-sql/query-history-filter-picker.test.ts @@ -64,6 +64,27 @@ function sqlAnalysis(tablesById: Record>, + errorIds: string[], +): SqlAnalysisPort { + const errors = new Set(errorIds); + return { + analyzeForFingerprint: vi.fn(), + analyzeBatch: vi.fn(async (items: SqlAnalysisBatchItem[]): Promise> => + new Map( + items.map((item) => [ + item.id, + errors.has(item.id) + ? { tablesTouched: [], columnsByClause: {}, error: 'parse boom' } + : { tablesTouched: tablesById[item.id] ?? [], columnsByClause: {} }, + ]), + ), + ), + validateReadOnly: vi.fn(async () => ({ ok: true })), + }; +} + function llm(decisions: Array<{ role: string; exclude: boolean; reason: string }>): KtxLlmRuntimePort { const generateObject = vi.fn(async () => ({ roles: decisions })) as KtxLlmRuntimePort['generateObject']; return { @@ -198,6 +219,7 @@ describe('query-history filter picker', () => { consideredRoleCount: 0, skipped: { reason: 'no-llm' }, warnings: [], + parseFailedTemplateIds: [], }); }); @@ -227,6 +249,32 @@ describe('query-history filter picker', () => { expect(proposal.skipped).toEqual({ reason: 'no-in-scope-history' }); }); + it('records parse failures as template ids, not warnings', async () => { + const proposal = await proposeQueryHistoryServiceAccountFilters({ + connectionId: 'warehouse', + dialect: 'postgres', + queryClient: {}, + reader: reader( + aggregate({ + templateId: 'good', + canonicalSql: 'select * from analytics.orders', + topUsers: [{ user: 'analyst', executions: 30 }], + }), + aggregate({ + templateId: 'broken', + canonicalSql: 'select * from where', + topUsers: [{ user: 'analyst', executions: 5 }], + }), + ), + sqlAnalysis: sqlAnalysisWithErrors({ good: [{ catalog: null, db: 'analytics', name: 'orders' }] }, ['broken']), + llmRuntime: llm([]), + pullConfig: { dialect: 'postgres', enabledSchemas: ['analytics'], filters: { dropTrivialProbes: true } }, + }); + + expect(proposal.parseFailedTemplateIds).toEqual(['broken']); + expect(proposal.warnings).toEqual([]); + }); + it('keeps clean in-scope history when the model excludes nothing', async () => { const proposal = await proposeQueryHistoryServiceAccountFilters({ connectionId: 'warehouse', diff --git a/packages/cli/test/reveal-password-prompt.test.ts b/packages/cli/test/reveal-password-prompt.test.ts new file mode 100644 index 00000000..7bb8cc10 --- /dev/null +++ b/packages/cli/test/reveal-password-prompt.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from 'vitest'; +import { maskRevealingTail } from '../src/reveal-password-prompt.js'; + +const MASK = '▪'; + +describe('maskRevealingTail', () => { + it('reveals the last `tail` characters of a long value', () => { + const value = 'example-token-value-abcd'; + const masked = maskRevealingTail(value, MASK, 4); + expect(masked).toBe(`${MASK.repeat(value.length - 4)}abcd`); + expect(masked.endsWith('abcd')).toBe(true); + }); + + it('keeps the same length as the input so cursor slicing stays aligned', () => { + for (const secret of ['', 'a', 'abcdefgh', 'abcdefghijklmnop']) { + expect(maskRevealingTail(secret, MASK, 4)).toHaveLength(secret.length); + } + }); + + it('fully masks secrets that are not longer than tail * 2', () => { + expect(maskRevealingTail('abcdefgh', MASK, 4)).toBe(MASK.repeat(8)); + expect(maskRevealingTail('abcd', MASK, 4)).toBe(MASK.repeat(4)); + expect(maskRevealingTail('ab', MASK, 4)).toBe(MASK.repeat(2)); + }); + + it('reveals the tail once the secret crosses the tail * 2 boundary', () => { + // length 9 > 8 → reveal last 4, hide the first 5 + expect(maskRevealingTail('abcdefghi', MASK, 4)).toBe(`${MASK.repeat(5)}fghi`); + }); + + it('fully masks an empty value', () => { + expect(maskRevealingTail('', MASK, 4)).toBe(''); + }); + + it('honors a custom tail count', () => { + // tail 2 reveals only when length > 4 + expect(maskRevealingTail('abcde', MASK, 2)).toBe(`${MASK.repeat(3)}de`); + expect(maskRevealingTail('abcd', MASK, 2)).toBe(MASK.repeat(4)); + }); +}); diff --git a/packages/cli/test/setup-databases.test.ts b/packages/cli/test/setup-databases.test.ts index 6adb0af0..957dfdb2 100644 --- a/packages/cli/test/setup-databases.test.ts +++ b/packages/cli/test/setup-databases.test.ts @@ -2654,6 +2654,7 @@ describe('setup databases step', () => { consideredRoleCount: 2, skipped: null, warnings: [], + parseFailedTemplateIds: [], })); const result = await runKtxSetupDatabasesStep( @@ -2706,6 +2707,54 @@ describe('setup databases step', () => { expect(io.stdout()).toContain('svc_loader'); }); + it('collapses query-history parse failures to a count and lists ids only with --debug', async () => { + const io = makeIo(); + const queryHistoryFilterPicker = vi.fn(async () => ({ + excludedRoles: [], + consideredRoleCount: 1, + skipped: { reason: 'no-in-scope-history' as const }, + warnings: [], + parseFailedTemplateIds: ['111', '222'], + })); + + const result = await runKtxSetupDatabasesStep( + { + projectDir: tempDir, + inputMode: 'disabled', + debug: true, + yes: true, + databaseDrivers: ['postgres'], + databaseConnectionId: 'warehouse', + databaseUrl: 'env:DATABASE_URL', + databaseSchemas: ['public'], + enableQueryHistory: true, + skipDatabases: false, + }, + io.io, + { + testConnection: vi.fn(async () => 0), + scanConnection: vi.fn(async () => 0), + historicSqlReadinessProbe: vi.fn(async () => { + const runner = fakeHistoricSqlRunner('postgres', 'pg_stat_statements'); + return { + ok: true as const, + dialect: 'postgres' as const, + runner, + result: { pgServerVersion: 'PostgreSQL 16.4', warnings: [], info: [] }, + }; + }), + queryHistoryFilterPicker, + createQueryHistoryLlmRuntime: vi.fn(() => null), + }, + ); + + expect(result.status).toBe('ready'); + expect(io.stdout()).toContain('Skipped 2 query templates ktx could not parse'); + expect(io.stdout()).not.toContain('111'); + expect(io.stdout()).not.toContain('222'); + expect(io.stderr()).toContain('could not parse 2 template(s): 111, 222'); + }); + it('lets interactive setup skip applying derived filters', async () => { const io = makeIo(); const prompts = makePromptAdapter({ @@ -2743,6 +2792,7 @@ describe('setup databases step', () => { consideredRoleCount: 2, skipped: null, warnings: [], + parseFailedTemplateIds: [], })), createQueryHistoryLlmRuntime: vi.fn(() => null), }, @@ -2811,6 +2861,7 @@ describe('setup databases step', () => { consideredRoleCount: 2, skipped: { reason: 'user-block-present' as const }, warnings: [], + parseFailedTemplateIds: [], })), createQueryHistoryLlmRuntime: vi.fn(() => null), }, diff --git a/packages/cli/test/setup-prompts.test.ts b/packages/cli/test/setup-prompts.test.ts index 46628b1c..8e83c558 100644 --- a/packages/cli/test/setup-prompts.test.ts +++ b/packages/cli/test/setup-prompts.test.ts @@ -17,7 +17,7 @@ const mocks = vi.hoisted(() => { autocomplete: vi.fn(), autocompleteMultiselect: vi.fn(), note: vi.fn(), - password: vi.fn(), + revealPassword: vi.fn(), select: vi.fn(), text: vi.fn(), withSetupInterruptConfirmation: vi.fn((prompt: () => Promise) => prompt()), @@ -34,11 +34,14 @@ vi.mock('@clack/prompts', () => ({ autocomplete: mocks.autocomplete, autocompleteMultiselect: mocks.autocompleteMultiselect, note: mocks.note, - password: mocks.password, select: mocks.select, text: mocks.text, })); +vi.mock('../src/reveal-password-prompt.js', () => ({ + revealPassword: mocks.revealPassword, +})); + vi.mock('../src/setup-interrupt.js', () => ({ withSetupInterruptConfirmation: mocks.withSetupInterruptConfirmation, })); @@ -54,7 +57,7 @@ describe('setup prompt adapter', () => { mocks.autocomplete.mockReset(); mocks.autocompleteMultiselect.mockReset(); mocks.note.mockReset(); - mocks.password.mockReset(); + mocks.revealPassword.mockReset(); mocks.select.mockReset(); mocks.text.mockReset(); mocks.withSetupInterruptConfirmation.mockClear(); @@ -96,7 +99,7 @@ describe('setup prompt adapter', () => { it('decorates text and password prompts with setup navigation copy', async () => { mocks.text.mockResolvedValueOnce('analytics-ktx'); - mocks.password.mockResolvedValueOnce('secret'); + mocks.revealPassword.mockResolvedValueOnce('secret'); const adapter = createKtxSetupPromptAdapter({ selectCancelValue: 'back' }); await expect(adapter.text({ message: 'Project folder path', placeholder: './analytics-ktx' })).resolves.toBe( @@ -108,7 +111,7 @@ describe('setup prompt adapter', () => { message: 'Project folder path\n│ Press Escape to go back.\n│', placeholder: './analytics-ktx', }); - expect(mocks.password).toHaveBeenCalledWith({ + expect(mocks.revealPassword).toHaveBeenCalledWith({ message: 'Anthropic API key\n│ Press Escape to go back.\n│', }); }); diff --git a/packages/cli/test/setup-sources.test.ts b/packages/cli/test/setup-sources.test.ts index e4f7af2d..ef18a1b6 100644 --- a/packages/cli/test/setup-sources.test.ts +++ b/packages/cli/test/setup-sources.test.ts @@ -447,8 +447,8 @@ describe('setup sources step', () => { expect(testPrompts.select).toHaveBeenCalledWith({ message: 'Which Notion pages should KTX ingest?', options: [ - { value: 'selected_roots', label: 'Specific pages and their subpages (choose them in a picker)' }, { value: 'all_accessible', label: 'All pages the integration can access' }, + { value: 'selected_roots', label: 'Specific pages and their subpages (choose them in a picker)' }, { value: 'back', label: 'Back' }, ], }); @@ -891,8 +891,8 @@ describe('setup sources step', () => { expect(testPrompts.select).toHaveBeenCalledWith({ message: 'This repo requires authentication.', options: [ - { value: 'env', label: 'Use GITHUB_TOKEN from the environment' }, { value: 'paste', label: 'Paste a token and save it as a local secret file' }, + { value: 'env', label: 'Use GITHUB_TOKEN from the environment' }, { value: 'skip', label: 'Skip — try without authentication' }, { value: 'back', label: 'Back' }, ], @@ -1407,8 +1407,8 @@ describe('setup sources step', () => { message: 'How should KTX find your Notion integration token?', options: [ { value: 'keep', label: 'Keep existing credential' }, - { value: 'env', label: 'Use NOTION_TOKEN from the environment' }, { value: 'paste', label: 'Paste a key and save it as a local secret file' }, + { value: 'env', label: 'Use NOTION_TOKEN from the environment' }, { value: 'back', label: 'Back' }, ], }); @@ -1476,8 +1476,8 @@ describe('setup sources step', () => { message: 'How should KTX find your Metabase API key?', options: [ { value: 'keep', label: 'Keep existing credential' }, - { value: 'env', label: 'Use METABASE_API_KEY from the environment' }, { value: 'paste', label: 'Paste a key and save it as a local secret file' }, + { value: 'env', label: 'Use METABASE_API_KEY from the environment' }, { value: 'back', label: 'Back' }, ], }); @@ -1582,8 +1582,8 @@ describe('setup sources step', () => { message: 'This MetricFlow repo requires authentication.', options: [ { value: 'keep', label: 'Keep existing credential' }, - { value: 'env', label: 'Use GITHUB_TOKEN from the environment' }, { value: 'paste', label: 'Paste a token and save it as a local secret file' }, + { value: 'env', label: 'Use GITHUB_TOKEN from the environment' }, { value: 'skip', label: 'Skip — try without authentication' }, { value: 'back', label: 'Back' }, ], @@ -1627,7 +1627,7 @@ describe('setup sources step', () => { expect(testPrompts.select).toHaveBeenCalledWith({ message: '1 context source configured (dbt-main). Add another?', options: [ - { value: 'done', label: 'Done — continue to context build' }, + { value: 'done', label: 'Done adding context sources' }, { value: 'edit', label: 'Edit an existing context source' }, { value: 'add', label: 'Add another context source' }, ], diff --git a/packages/cli/test/telemetry/events.test.ts b/packages/cli/test/telemetry/events.test.ts index 29108600..033c2def 100644 --- a/packages/cli/test/telemetry/events.test.ts +++ b/packages/cli/test/telemetry/events.test.ts @@ -37,6 +37,7 @@ describe('telemetry event schemas', () => { 'daemon_stopped', 'sl_plan_completed', 'sql_gen_completed', + 'query_history_filter_completed', ]); }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a3eaad5f..871931c0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -131,6 +131,9 @@ importers: '@anthropic-ai/claude-agent-sdk': specifier: 0.3.146 version: 0.3.146(@anthropic-ai/sdk@0.97.1(zod@4.4.3))(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(zod@4.4.3) + '@clack/core': + specifier: 1.3.1 + version: 1.3.1 '@clack/prompts': specifier: 1.4.0 version: 1.4.0 diff --git a/python/ktx-daemon/src/ktx_daemon/telemetry/events.schema.json b/python/ktx-daemon/src/ktx_daemon/telemetry/events.schema.json index a75f92f1..c6c3d6f8 100644 --- a/python/ktx-daemon/src/ktx_daemon/telemetry/events.schema.json +++ b/python/ktx-daemon/src/ktx_daemon/telemetry/events.schema.json @@ -206,6 +206,17 @@ "errorClass", "durationMs" ] + }, + { + "name": "query_history_filter_completed", + "description": "Emitted after the setup query-history service-account filter picker runs.", + "fields": [ + "dialect", + "consideredRoleCount", + "excludedRoleCount", + "parseFailedCount", + "outcome" + ] } ], "$defs": { @@ -1434,6 +1445,77 @@ "durationMs" ], "additionalProperties": false + }, + "query_history_filter_completed": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "cliVersion": { + "type": "string" + }, + "nodeVersion": { + "type": "string" + }, + "osPlatform": { + "type": "string" + }, + "osRelease": { + "type": "string" + }, + "arch": { + "type": "string" + }, + "runtime": { + "type": "string", + "enum": [ + "node", + "daemon-py" + ] + }, + "isCi": { + "type": "boolean" + }, + "dialect": { + "type": "string" + }, + "consideredRoleCount": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "excludedRoleCount": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "parseFailedCount": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "outcome": { + "type": "string", + "enum": [ + "ok", + "error" + ] + } + }, + "required": [ + "cliVersion", + "nodeVersion", + "osPlatform", + "osRelease", + "arch", + "runtime", + "isCi", + "dialect", + "consideredRoleCount", + "excludedRoleCount", + "parseFailedCount", + "outcome" + ], + "additionalProperties": false } } } diff --git a/python/ktx-daemon/tests/test_telemetry_schema_sync.py b/python/ktx-daemon/tests/test_telemetry_schema_sync.py index 6f2ba634..0cc822f9 100644 --- a/python/ktx-daemon/tests/test_telemetry_schema_sync.py +++ b/python/ktx-daemon/tests/test_telemetry_schema_sync.py @@ -36,4 +36,5 @@ def test_python_schema_copy_matches_node_schema() -> None: "daemon_stopped", "sl_plan_completed", "sql_gen_completed", + "query_history_filter_completed", ] diff --git a/uv.lock b/uv.lock index 6d00951d..40553e46 100644 --- a/uv.lock +++ b/uv.lock @@ -466,7 +466,7 @@ wheels = [ [[package]] name = "ktx-daemon" -version = "0.8.0" +version = "0.9.0" source = { editable = "python/ktx-daemon" } dependencies = [ { name = "fastapi" }, @@ -523,7 +523,7 @@ dev = [ [[package]] name = "ktx-sl" -version = "0.8.0" +version = "0.9.0" source = { editable = "python/ktx-sl" } dependencies = [ { name = "pydantic" }, From ec7edf8f505291498b6c941e58a22dcedf38b7bf Mon Sep 17 00:00:00 2001 From: Andrey Avtomonov Date: Thu, 4 Jun 2026 14:51:14 +0200 Subject: [PATCH 17/25] fix(telemetry): preserve driver error class and code in connection_test (#260) Native connector test failures were flattened to `new Error(message)`, collapsing every driver's error class to `Error` and dropping `.code` / `.number`. connection_test telemetry could therefore not tell a SQL Server login rejection (ELOGIN / 18456) apart from a network or TLS error, and the only field that varied was a raw message. Connectors now return `connectorTestFailure(error)`, which preserves the original driver error as `cause`, and `testNativeConnection` re-throws that cause. `scrubErrorClass` then records the real class (e.g. ConnectionError) and `formatErrorDetail` keeps the code prefix (e.g. "ELOGIN: ..."). The helper is the single source of truth for the failure shape across all seven native connectors. User-facing terminal output is unchanged. --- packages/cli/src/connection.ts | 6 ++++ .../cli/src/connectors/bigquery/connector.ts | 6 ++-- .../src/connectors/clickhouse/connector.ts | 6 ++-- .../cli/src/connectors/mysql/connector.ts | 6 ++-- .../cli/src/connectors/postgres/connector.ts | 6 ++-- .../cli/src/connectors/snowflake/connector.ts | 6 ++-- .../cli/src/connectors/sqlite/connector.ts | 6 ++-- .../cli/src/connectors/sqlserver/connector.ts | 6 ++-- packages/cli/src/context/scan/types.ts | 22 +++++++++++++- packages/cli/test/connection.test.ts | 30 ++++++++++++++++++- 10 files changed, 82 insertions(+), 18 deletions(-) diff --git a/packages/cli/src/connection.ts b/packages/cli/src/connection.ts index 96281e82..2f4a0f4a 100644 --- a/packages/cli/src/connection.ts +++ b/packages/cli/src/connection.ts @@ -74,6 +74,12 @@ async function testNativeConnection( } const result = await connector.testConnection(); if (!result.success) { + // Re-throw the driver's original error so connection_test telemetry records + // its real class (e.g. ConnectionError) and code (e.g. ELOGIN) instead of + // collapsing every native failure to a generic Error with no code. + if (result.cause instanceof Error) { + throw result.cause; + } throw new Error(result.error ?? 'connection test failed'); } return { driver: connector.driver }; diff --git a/packages/cli/src/connectors/bigquery/connector.ts b/packages/cli/src/connectors/bigquery/connector.ts index edebe284..eae0f2ed 100644 --- a/packages/cli/src/connectors/bigquery/connector.ts +++ b/packages/cli/src/connectors/bigquery/connector.ts @@ -5,7 +5,9 @@ import { assertReadOnlySql, limitSqlForExecution } from '../../context/connectio import { tryConstraintQuery } from '../../context/scan/constraint-discovery.js'; import { scopedTableNames } from '../../context/scan/table-ref.js'; import { + connectorTestFailure, createKtxConnectorCapabilities, + type KtxConnectorTestResult, type KtxColumnSampleInput, type KtxColumnSampleResult, type KtxColumnStatsInput, @@ -320,7 +322,7 @@ export class KtxBigQueryScanConnector implements KtxScanConnector { this.id = `bigquery:${options.connectionId}`; } - async testConnection(): Promise<{ success: boolean; error?: string }> { + async testConnection(): Promise { try { const client = this.getClient(); await client.getDatasets({ maxResults: 1 }); @@ -329,7 +331,7 @@ export class KtxBigQueryScanConnector implements KtxScanConnector { } return { success: true }; } catch (error) { - return { success: false, error: error instanceof Error ? error.message : String(error) }; + return connectorTestFailure(error); } } diff --git a/packages/cli/src/connectors/clickhouse/connector.ts b/packages/cli/src/connectors/clickhouse/connector.ts index 74ef7a77..23622701 100644 --- a/packages/cli/src/connectors/clickhouse/connector.ts +++ b/packages/cli/src/connectors/clickhouse/connector.ts @@ -1,7 +1,7 @@ import { createClient } from '@clickhouse/client'; import { getDialectForDriver } from '../../context/connections/dialects.js'; import { assertReadOnlySql, limitSqlForExecution } from '../../context/connections/read-only-sql.js'; -import { createKtxConnectorCapabilities, type KtxColumnSampleInput, type KtxColumnSampleResult, type KtxColumnStatsInput, type KtxColumnStatsResult, type KtxQueryResult, type KtxReadOnlyQueryInput, type KtxScanConnector, type KtxScanContext, type KtxScanInput, type KtxSchemaColumn, type KtxSchemaSnapshot, type KtxSchemaTable, type KtxTableRef, type KtxTableSampleInput, type KtxTableListEntry, type KtxTableSampleResult } from '../../context/scan/types.js'; +import { connectorTestFailure, createKtxConnectorCapabilities, type KtxConnectorTestResult, type KtxColumnSampleInput, type KtxColumnSampleResult, type KtxColumnStatsInput, type KtxColumnStatsResult, type KtxQueryResult, type KtxReadOnlyQueryInput, type KtxScanConnector, type KtxScanContext, type KtxScanInput, type KtxSchemaColumn, type KtxSchemaSnapshot, type KtxSchemaTable, type KtxTableRef, type KtxTableSampleInput, type KtxTableListEntry, type KtxTableSampleResult } from '../../context/scan/types.js'; import { scopedTableNames } from '../../context/scan/table-ref.js'; import { readFileSync } from 'node:fs'; import { Agent as HttpsAgent } from 'node:https'; @@ -317,12 +317,12 @@ export class KtxClickHouseScanConnector implements KtxScanConnector { this.id = `clickhouse:${options.connectionId}`; } - async testConnection(): Promise<{ success: boolean; error?: string }> { + async testConnection(): Promise { try { await this.query('SELECT 1'); return { success: true }; } catch (error) { - return { success: false, error: error instanceof Error ? error.message : String(error) }; + return connectorTestFailure(error); } } diff --git a/packages/cli/src/connectors/mysql/connector.ts b/packages/cli/src/connectors/mysql/connector.ts index 29dacc26..c147c7dd 100644 --- a/packages/cli/src/connectors/mysql/connector.ts +++ b/packages/cli/src/connectors/mysql/connector.ts @@ -11,7 +11,9 @@ import { } from '../../context/scan/constraint-discovery.js'; import { scopedTableNames } from '../../context/scan/table-ref.js'; import { + connectorTestFailure, createKtxConnectorCapabilities, + type KtxConnectorTestResult, type KtxColumnSampleInput, type KtxColumnSampleResult, type KtxColumnStatsInput, @@ -413,12 +415,12 @@ export class KtxMysqlScanConnector implements KtxScanConnector { this.id = `mysql:${options.connectionId}`; } - async testConnection(): Promise<{ success: boolean; error?: string }> { + async testConnection(): Promise { try { await this.query('SELECT 1'); return { success: true }; } catch (error) { - return { success: false, error: error instanceof Error ? error.message : String(error) }; + return connectorTestFailure(error); } } diff --git a/packages/cli/src/connectors/postgres/connector.ts b/packages/cli/src/connectors/postgres/connector.ts index f206fa6a..1a956a3d 100644 --- a/packages/cli/src/connectors/postgres/connector.ts +++ b/packages/cli/src/connectors/postgres/connector.ts @@ -6,7 +6,9 @@ import { assertReadOnlySql, limitSqlForExecution } from '../../context/connectio import { tryConstraintQuery } from '../../context/scan/constraint-discovery.js'; import { scopedTableNames } from '../../context/scan/table-ref.js'; import { + connectorTestFailure, createKtxConnectorCapabilities, + type KtxConnectorTestResult, type KtxColumnSampleInput, type KtxColumnSampleResult, type KtxColumnStatsInput, @@ -442,12 +444,12 @@ export class KtxPostgresScanConnector implements KtxScanConnector { this.id = `postgres:${options.connectionId}`; } - async testConnection(): Promise<{ success: boolean; error?: string }> { + async testConnection(): Promise { try { await this.query('SELECT 1'); return { success: true }; } catch (error) { - return { success: false, error: error instanceof Error ? error.message : String(error) }; + return connectorTestFailure(error); } } diff --git a/packages/cli/src/connectors/snowflake/connector.ts b/packages/cli/src/connectors/snowflake/connector.ts index 86d7ebe7..51a91e52 100644 --- a/packages/cli/src/connectors/snowflake/connector.ts +++ b/packages/cli/src/connectors/snowflake/connector.ts @@ -7,7 +7,9 @@ import { assertReadOnlySql, limitSqlForExecution } from '../../context/connectio import { tryConstraintQuery } from '../../context/scan/constraint-discovery.js'; import { scopedTableNames } from '../../context/scan/table-ref.js'; import { + connectorTestFailure, createKtxConnectorCapabilities, + type KtxConnectorTestResult, type KtxColumnSampleInput, type KtxColumnSampleResult, type KtxColumnStatsInput, @@ -464,7 +466,7 @@ class SnowflakeSdkDriver implements KtxSnowflakeDriver { await this.query('SELECT 1'); return { success: true }; } catch (error) { - return { success: false, error: error instanceof Error ? error.message : String(error) }; + return connectorTestFailure(error); } } @@ -573,7 +575,7 @@ export class KtxSnowflakeScanConnector implements KtxScanConnector { } } - async testConnection(): Promise<{ success: boolean; error?: string }> { + async testConnection(): Promise { return this.getDriver().test(); } diff --git a/packages/cli/src/connectors/sqlite/connector.ts b/packages/cli/src/connectors/sqlite/connector.ts index e996bc25..f5ba2a55 100644 --- a/packages/cli/src/connectors/sqlite/connector.ts +++ b/packages/cli/src/connectors/sqlite/connector.ts @@ -6,7 +6,7 @@ import { fileURLToPath } from 'node:url'; import { getDialectForDriver } from '../../context/connections/dialects.js'; import { assertReadOnlySql, limitSqlForExecution } from '../../context/connections/read-only-sql.js'; import { normalizeQueryRows } from '../../context/connections/query-executor.js'; -import { createKtxConnectorCapabilities, type KtxColumnSampleInput, type KtxColumnSampleResult, type KtxColumnStatsInput, type KtxColumnStatsResult, type KtxQueryResult, type KtxReadOnlyQueryInput, type KtxScanConnector, type KtxScanContext, type KtxScanInput, type KtxSchemaForeignKey, type KtxSchemaSnapshot, type KtxSchemaTable, type KtxTableListEntry, type KtxTableRef, type KtxTableSampleInput, type KtxTableSampleResult } from '../../context/scan/types.js'; +import { connectorTestFailure, createKtxConnectorCapabilities, type KtxConnectorTestResult, type KtxColumnSampleInput, type KtxColumnSampleResult, type KtxColumnStatsInput, type KtxColumnStatsResult, type KtxQueryResult, type KtxReadOnlyQueryInput, type KtxScanConnector, type KtxScanContext, type KtxScanInput, type KtxSchemaForeignKey, type KtxSchemaSnapshot, type KtxSchemaTable, type KtxTableListEntry, type KtxTableRef, type KtxTableSampleInput, type KtxTableSampleResult } from '../../context/scan/types.js'; import { scopedTableNames } from '../../context/scan/table-ref.js'; export interface KtxSqliteConnectionConfig { @@ -167,7 +167,7 @@ export class KtxSqliteScanConnector implements KtxScanConnector { this.id = `sqlite:${options.connectionId}`; } - async testConnection(): Promise<{ success: boolean; error?: string }> { + async testConnection(): Promise { try { if (!existsSync(this.dbPath) || !statSync(this.dbPath).isFile()) { return { success: false, error: `File not found: ${this.dbPath}` }; @@ -175,7 +175,7 @@ export class KtxSqliteScanConnector implements KtxScanConnector { this.database().prepare('SELECT 1').get(); return { success: true }; } catch (error) { - return { success: false, error: error instanceof Error ? error.message : String(error) }; + return connectorTestFailure(error); } } diff --git a/packages/cli/src/connectors/sqlserver/connector.ts b/packages/cli/src/connectors/sqlserver/connector.ts index 0115781d..5dd9969b 100644 --- a/packages/cli/src/connectors/sqlserver/connector.ts +++ b/packages/cli/src/connectors/sqlserver/connector.ts @@ -3,7 +3,9 @@ import { getDialectForDriver } from '../../context/connections/dialects.js'; import { tryConstraintQuery } from '../../context/scan/constraint-discovery.js'; import { scopedTableNames } from '../../context/scan/table-ref.js'; import { + connectorTestFailure, createKtxConnectorCapabilities, + type KtxConnectorTestResult, type KtxColumnSampleInput, type KtxColumnSampleResult, type KtxColumnStatsInput, @@ -384,12 +386,12 @@ export class KtxSqlServerScanConnector implements KtxScanConnector { this.id = `sqlserver:${options.connectionId}`; } - async testConnection(): Promise<{ success: boolean; error?: string }> { + async testConnection(): Promise { try { await this.query('SELECT 1'); return { success: true }; } catch (error) { - return { success: false, error: error instanceof Error ? error.message : String(error) }; + return connectorTestFailure(error); } } diff --git a/packages/cli/src/context/scan/types.ts b/packages/cli/src/context/scan/types.ts index 1d9e6d6a..fc445b5e 100644 --- a/packages/cli/src/context/scan/types.ts +++ b/packages/cli/src/context/scan/types.ts @@ -303,9 +303,29 @@ export interface KtxTableListEntry { kind: 'table' | 'view'; } -interface KtxConnectorTestResult { +export interface KtxConnectorTestResult { success: boolean; error?: string; + /** + * The original error thrown by the driver, preserved unflattened so the + * connection-test path can re-throw it. Keeping the real error object lets + * telemetry record the driver's actual error class (e.g. `ConnectionError`) + * and `.code` (e.g. `ELOGIN`) instead of collapsing every failure to `Error`. + */ + cause?: unknown; +} + +/** + * Single source of truth for a failed connector test result. Captures the + * driver's message for display while preserving the original error as `cause` + * so callers can surface its real class and code. + */ +export function connectorTestFailure(error: unknown): KtxConnectorTestResult { + return { + success: false, + error: error instanceof Error ? error.message : String(error), + cause: error, + }; } export interface KtxScanConnector { diff --git a/packages/cli/test/connection.test.ts b/packages/cli/test/connection.test.ts index 67e55af8..da650b05 100644 --- a/packages/cli/test/connection.test.ts +++ b/packages/cli/test/connection.test.ts @@ -38,7 +38,7 @@ function makeIo() { function nativeConnector( driver: KtxConnectionDriver, - testResult: { success: true } | { success: false; error: string } = { success: true }, + testResult: { success: true } | { success: false; error: string; cause?: unknown } = { success: true }, ) { const testConnection = vi.fn(async () => testResult); const cleanup = vi.fn(async () => undefined); @@ -183,6 +183,34 @@ describe('runKtxConnection', () => { expect(io.stderr()).toContain('"errorDetail":"database file is unreadable"'); }); + it('preserves the driver error class and code in connection_test telemetry', async () => { + vi.stubEnv('KTX_TELEMETRY_DEBUG', '1'); + vi.stubEnv('CI', ''); + const projectDir = join(tempDir, 'project'); + await initKtxProject({ projectDir }); + await writeConnections(projectDir, { + warehouse: { driver: 'sqlserver', host: 'db.example.test', database: 'analytics', username: 'svc_ro' }, + }); + class ConnectionError extends Error { + readonly code = 'ELOGIN'; + } + const driverError = new ConnectionError("Login failed for user 'svc_ro'."); + const { connector } = nativeConnector('sqlserver', { + success: false, + error: driverError.message, + cause: driverError, + }); + const io = makeIo(); + + const code = await runKtxConnection({ command: 'test', projectDir, connectionId: 'warehouse' }, io.io, { + createScanConnector: vi.fn(async () => connector), + }); + + expect(code).toBe(1); + expect(io.stderr()).toContain('"errorClass":"ConnectionError"'); + expect(io.stderr()).toContain('"errorDetail":"ELOGIN: Login failed for user \'svc_ro\'."'); + }); + it('reports the connector error and still cleans up when native testConnection fails', async () => { const projectDir = join(tempDir, 'project'); await initKtxProject({ projectDir }); From 5a8821073b40ec6ecd9fdab1c60373889422e7f7 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 4 Jun 2026 18:53:21 +0000 Subject: [PATCH 18/25] chore: refresh star history chart [skip ci] --- assets/star-history.svg | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/assets/star-history.svg b/assets/star-history.svg index 98de4631..31ca9962 100644 --- a/assets/star-history.svg +++ b/assets/star-history.svg @@ -1 +1 @@ -star-history.comMay 17May 24May 31 200400600800kaelio/ktxStar HistoryDateGitHub Stars +star-history.comMay 17May 24May 31 200400600800kaelio/ktxStar HistoryDateGitHub Stars From c3d8cedb0bbeb8695eebf2c7e6a2dcd203efe5e4 Mon Sep 17 00:00:00 2001 From: Andrey Avtomonov Date: Fri, 5 Jun 2026 12:10:27 +0200 Subject: [PATCH 19/25] feat(cli): add ingest LLM rate-limit governor with paced retries (#261) * feat(cli): add ingest rate limit governor * feat(cli): wire ingest rate-limit config * feat(cli): report provider rate-limit signals * feat(cli): show ingest rate-limit waits * fix(cli): complete rate-limit event coverage * fix(cli): abort ingest provider calls cleanly * fix(cli): propagate ingest cancellation * fix(cli): reject pre-aborted ingest rate-limit waits * fix(cli): honor Claude rate-limit reset waits * fix(cli): retry thrown Codex rate-limit failures * fix(cli): type Claude rate-limit result details * fix(cli): emit ingest rate-limit countdowns from rejected signals * fix(cli): report ai sdk rate-limit header utilization * fix(cli): gate LLM rate-limit retries on the governor budget The AI SDK and Codex runtimes retried 429 / opaque rate-limit failures up to 6-7 times with no backoff when constructed without a RateLimitGovernor (scan, memory, setup) or with pacing disabled, ignoring Retry-After and worsening the limit. The outer retry loop only cooperates with the governor's pause, so without active pacing there is no backoff to apply. Route the retry bound through a single source: RateLimitGovernor .maxRetryAttempts(), which returns retry.maxAttempts when enabled and 1 (no outer retry) when absent or disabled. All three runtimes (ai-sdk, codex, claude-code) now use it, so ingest.rateLimit.retry.maxAttempts genuinely controls attempts and the hard-coded 6 (plus Codex's off-by-one extra attempt) is gone. Backend-native retry (e.g. the AI SDK's maxRetries) still handles transient 429s. Also correct the ktx.yaml docs for maxWaitMs (caps each wait, not the whole run) and maxAttempts, and sync uv.lock ktx-sl/ktx-daemon to 0.9.0. --- .../content/docs/cli-reference/ktx-ingest.mdx | 4 +- .../content/docs/configuration/ktx-yaml.mdx | 28 ++ packages/cli/src/context/core/abort.ts | 39 ++ .../curator-pagination.service.ts | 2 + .../src/context/ingest/final-gate-repair.ts | 2 + .../context/ingest/ingest-bundle.runner.ts | 82 +++- .../textual-conflict-resolver.ts | 2 + .../isolated-diff/work-unit-executor.ts | 1 + .../context/ingest/local-bundle-runtime.ts | 13 +- .../cli/src/context/ingest/local-ingest.ts | 18 +- .../src/context/ingest/memory-flow/schema.ts | 7 + .../src/context/ingest/memory-flow/types.ts | 7 + packages/cli/src/context/ingest/ports.ts | 3 + .../ingest/stages/stage-3-work-units.ts | 6 + .../ingest/stages/stage-4-reconciliation.ts | 2 + packages/cli/src/context/ingest/types.ts | 1 + .../cli/src/context/llm/ai-sdk-runtime.ts | 175 +++++++- .../src/context/llm/claude-code-runtime.ts | 159 ++++++- packages/cli/src/context/llm/codex-runtime.ts | 154 +++++-- packages/cli/src/context/llm/local-config.ts | 25 +- .../src/context/llm/rate-limit-governor.ts | 387 ++++++++++++++++++ packages/cli/src/context/llm/runtime-port.ts | 3 + packages/cli/src/context/project/config.ts | 39 ++ packages/cli/src/ingest.ts | 32 ++ packages/cli/test/context/core/abort.test.ts | 31 ++ .../ingest/ingest-bundle.runner.test.ts | 171 ++++++++ .../ingest/local-bundle-runtime.test.ts | 1 + .../context/ingest/memory-flow/schema.test.ts | 23 ++ .../test/context/llm/ai-sdk-runtime.test.ts | 193 +++++++++ .../context/llm/claude-code-runtime.test.ts | 249 +++++++++++ .../test/context/llm/codex-runtime.test.ts | 144 +++++++ .../cli/test/context/llm/local-config.test.ts | 59 +++ .../context/llm/rate-limit-governor.test.ts | 278 +++++++++++++ .../cli/test/context/project/config.test.ts | 57 +++ .../test/telemetry/project-snapshot.test.ts | 11 + 35 files changed, 2336 insertions(+), 72 deletions(-) create mode 100644 packages/cli/src/context/core/abort.ts create mode 100644 packages/cli/src/context/llm/rate-limit-governor.ts create mode 100644 packages/cli/test/context/core/abort.test.ts create mode 100644 packages/cli/test/context/llm/rate-limit-governor.test.ts diff --git a/docs-site/content/docs/cli-reference/ktx-ingest.mdx b/docs-site/content/docs/cli-reference/ktx-ingest.mdx index 80820efa..ab3d231d 100644 --- a/docs-site/content/docs/cli-reference/ktx-ingest.mdx +++ b/docs-site/content/docs/cli-reference/ktx-ingest.mdx @@ -177,7 +177,9 @@ Slowest phase: reconciliation (2m 05s, 48% of wall time). 2 work units (1 failed Work units run serially by default (`ingest.workUnits.maxConcurrency` is `1`); raise it in `ktx.yaml` if the profile shows the run is bound by serialized -work-unit agent loops. +work-unit agent loops. If the provider reports an LLM rate limit, **ktx** shows +a transient wait message and temporarily reduces effective work-unit concurrency +according to `ingest.rateLimit`. ## Common errors diff --git a/docs-site/content/docs/configuration/ktx-yaml.mdx b/docs-site/content/docs/configuration/ktx-yaml.mdx index 17a04c53..831e678a 100644 --- a/docs-site/content/docs/configuration/ktx-yaml.mdx +++ b/docs-site/content/docs/configuration/ktx-yaml.mdx @@ -452,6 +452,16 @@ ingest: stepBudget: 40 maxConcurrency: 2 failureMode: continue + rateLimit: + enabled: true + throttleThreshold: 0.8 + minConcurrencyUnderPressure: 1 + maxWaitMs: 600000 + retry: + maxAttempts: 6 + baseDelayMs: 1000 + maxDelayMs: 60000 + jitter: true ``` ### Adapters @@ -498,6 +508,24 @@ handles failures. | `workUnits.maxConcurrency` | `int > 0` | `1` | How many work units run in parallel. | | `workUnits.failureMode` | `abort` \| `continue` | `continue` | `abort` stops the whole ingest run on the first failure; `continue` records it and keeps going. | +### Rate limits + +`rateLimit` controls provider-neutral pacing for LLM calls during ingest. When a +provider reports a subscription window, retry-after delay, or HTTP 429, +**ktx** pauses new work-unit model calls, shows a transient wait in the CLI, +and reduces work-unit concurrency while the provider is under pressure. + +| Field | Type | Default | Purpose | +|-------|------|---------|---------| +| `rateLimit.enabled` | `boolean` | `true` | Master switch for ingest LLM rate-limit pacing and visible waits. | +| `rateLimit.throttleThreshold` | `number between 0 and 1` | `0.8` | Fraction of a known provider window at which **ktx** starts reducing concurrency. | +| `rateLimit.minConcurrencyUnderPressure` | `int > 0` | `1` | Effective work-unit concurrency while a provider is under rate-limit pressure. | +| `rateLimit.maxWaitMs` | `int > 0` | unset | Caps how long a single provider-reset wait can last. This bounds each wait, not the whole run: after a capped wait elapses **ktx** retries and may pause again. Omit to wait until the provider's reset time. | +| `rateLimit.retry.maxAttempts` | `int > 0` | `6` | Maximum attempts for a single rate-limited LLM call before the failure surfaces (counts the first try). Also bounds how far opaque backoff grows for responses without a reset time or retry-after value. | +| `rateLimit.retry.baseDelayMs` | `int > 0` | `1000` | Initial opaque retry delay in milliseconds. | +| `rateLimit.retry.maxDelayMs` | `int > 0` | `60000` | Maximum opaque retry delay in milliseconds. | +| `rateLimit.retry.jitter` | `boolean` | `true` | Add jitter to opaque retry delays. | + ## `scan` `scan` configures how schema-level inputs become structured context: diff --git a/packages/cli/src/context/core/abort.ts b/packages/cli/src/context/core/abort.ts new file mode 100644 index 00000000..95467c52 --- /dev/null +++ b/packages/cli/src/context/core/abort.ts @@ -0,0 +1,39 @@ +/** @internal */ +export function createAbortError(message = 'Aborted'): DOMException { + return new DOMException(message, 'AbortError'); +} + +export function isAbortError(error: unknown): boolean { + if (error instanceof DOMException && error.name === 'AbortError') { + return true; + } + if (!error || typeof error !== 'object') { + return false; + } + const record = error as { name?: unknown; code?: unknown }; + return record.name === 'AbortError' || record.code === 'ABORT_ERR'; +} + +/** @internal */ +export function throwIfAborted(signal?: AbortSignal): void { + if (signal?.aborted) { + throw createAbortError(); + } +} + +export function linkAbortSignal(parent?: AbortSignal): { controller: AbortController; dispose: () => void } { + const controller = new AbortController(); + if (!parent) { + return { controller, dispose: () => undefined }; + } + if (parent.aborted) { + controller.abort(createAbortError()); + return { controller, dispose: () => undefined }; + } + const onAbort = () => controller.abort(createAbortError()); + parent.addEventListener('abort', onAbort, { once: true }); + return { + controller, + dispose: () => parent.removeEventListener('abort', onAbort), + }; +} diff --git a/packages/cli/src/context/ingest/context-candidates/curator-pagination.service.ts b/packages/cli/src/context/ingest/context-candidates/curator-pagination.service.ts index 348544ca..7848fab7 100644 --- a/packages/cli/src/context/ingest/context-candidates/curator-pagination.service.ts +++ b/packages/cli/src/context/ingest/context-candidates/curator-pagination.service.ts @@ -40,6 +40,7 @@ export interface CuratorPaginationInput { buildToolSet: (passNumber: number) => KtxRuntimeToolSet; getReconciliationActions: () => MemoryAction[]; onStepFinish?: (info: { passNumber: number; stepIndex: number; stepBudget: number }) => void; + abortSignal?: AbortSignal; } interface CuratorPaginationResult extends ReconciliationOutcome { @@ -243,6 +244,7 @@ export class CuratorPaginationService implements CuratorPaginationPort { sourceKey: params.input.sourceKey, jobId: params.input.jobId, forceRun: params.forceRun, + abortSignal: params.input.abortSignal, onStepFinish: params.input.onStepFinish ? ({ stepIndex, stepBudget }) => params.input.onStepFinish?.({ passNumber: params.passNumber, stepIndex, stepBudget }) diff --git a/packages/cli/src/context/ingest/final-gate-repair.ts b/packages/cli/src/context/ingest/final-gate-repair.ts index 1c373aa6..f32178d8 100644 --- a/packages/cli/src/context/ingest/final-gate-repair.ts +++ b/packages/cli/src/context/ingest/final-gate-repair.ts @@ -21,6 +21,7 @@ export interface RepairFinalGateFailureInput { repairKind: FinalGateRepairKind; maxAttempts?: number; stepBudget?: number; + abortSignal?: AbortSignal; } const readRepairFileSchema = z.object({ @@ -200,6 +201,7 @@ export async function repairFinalGateFailure( jobId: input.trace.context.jobId, repairKind: input.repairKind, }, + abortSignal: input.abortSignal, }), ); diff --git a/packages/cli/src/context/ingest/ingest-bundle.runner.ts b/packages/cli/src/context/ingest/ingest-bundle.runner.ts index 3f2b41d3..a242d58a 100644 --- a/packages/cli/src/context/ingest/ingest-bundle.runner.ts +++ b/packages/cli/src/context/ingest/ingest-bundle.runner.ts @@ -3,6 +3,7 @@ import { dirname, join } from 'node:path'; import pLimit from 'p-limit'; import { z } from 'zod'; import { type KtxLogger, noopLogger } from '../../context/core/config.js'; +import type { RateLimitWaitState } from '../../context/llm/rate-limit-governor.js'; import { createRuntimeToolDescriptorFromAiTool } from '../../context/llm/runtime-tools.js'; import type { KtxRuntimeToolSet } from '../../context/llm/runtime-port.js'; import type { CaptureSession, MemoryAction } from '../../context/memory/types.js'; @@ -219,6 +220,10 @@ export class IngestBundleRunner { } async run(job: IngestBundleJob, ctx?: IngestJobContext): Promise { + const unsubscribeRateLimitGovernor = this.subscribeRateLimitGovernor({ + trace: this.createTrace(job), + memoryFlow: ctx?.memoryFlow, + }); const key = job.connectionId; const previous = this.chainByConnection.get(key); if (previous) { @@ -241,10 +246,72 @@ export class IngestBundleRunner { ctx?.memoryFlow?.finish('error', [sanitizeMemoryFlowError(error)]); throw error; } finally { + unsubscribeRateLimitGovernor(); await this.maybeEmitIngestProfile(job.jobId); } } + private formatRateLimitWait( + state: Extract, + ): string { + const seconds = Math.ceil(state.remainingMs / 1_000); + const minutes = Math.floor(seconds / 60); + const remainder = seconds % 60; + const duration = minutes > 0 ? `${minutes}m${String(remainder).padStart(2, '0')}s` : `${seconds}s`; + const type = state.rateLimitType ? ` ${state.rateLimitType}` : ''; + return `Rate-limited (${state.provider}${type}); resuming in ${duration}; Ctrl+C to stop`; + } + + private subscribeRateLimitGovernor(input: { + trace: IngestTraceWriter; + memoryFlow?: MemoryFlowEventSink; + }): () => void { + const governor = this.deps.settings.rateLimitGovernor; + if (!governor) { + return () => undefined; + } + return governor.subscribe((state: RateLimitWaitState) => { + if (state.kind === 'rate_limit_observed') { + void input.trace.event('info', 'rate_limit', 'rate_limit_observed', { ...state }); + return; + } + if (state.kind === 'concurrency_adjusted') { + void input.trace.event('info', 'rate_limit', 'concurrency_adjusted', { ...state }); + return; + } + void input.trace.event('info', 'rate_limit', state.kind, { ...state }); + if (state.kind === 'wait_tick' || state.kind === 'wait_started') { + input.memoryFlow?.emit({ + type: 'rate_limit_wait', + provider: state.provider, + ...(state.rateLimitType ? { rateLimitType: state.rateLimitType } : {}), + resumeAtMs: state.resumeAtMs, + remainingMs: state.remainingMs, + }); + input.memoryFlow?.emit({ + type: 'stage_progress', + stage: 'integration', + percent: 50, + message: this.formatRateLimitWait(state), + transient: true, + }); + } + }); + } + + private async withRateLimitWorkSlot(abortSignal: AbortSignal | undefined, fn: () => Promise): Promise { + const governor = this.deps.settings.rateLimitGovernor; + if (!governor) { + return fn(); + } + const release = await governor.acquireWorkSlot(abortSignal); + try { + return await fn(); + } finally { + release(); + } + } + /** * When profiling is enabled — via the `KTX_PROFILE_INGEST` env var or the * `ingest.profile` config setting — read the job's trace + tool transcripts @@ -877,6 +944,7 @@ export class IngestBundleRunner { includeContextEvidenceTools: boolean; currentTableExists(tableRef: string): Promise; memoryFlow?: MemoryFlowEventSink; + abortSignal?: AbortSignal; wuSkillNames: string[]; onStepFinish?: (info: { stepIndex: number; stepBudget: number }) => void; }): Promise { @@ -1029,6 +1097,7 @@ export class IngestBundleRunner { jobId: input.job.jobId, toolFailureCount: (unitKey) => input.transcriptSummaries.get(unitKey)?.fatalErrorCount ?? 0, onStepFinish: input.onStepFinish, + abortSignal: input.abortSignal, }, input.wu, ); @@ -1524,7 +1593,8 @@ export class IngestBundleRunner { try { await Promise.all( workUnits.map((wu, index) => - limitWorkUnit(async () => { + limitWorkUnit(() => + this.withRateLimitWorkSlot(ctx?.abortSignal, async () => { const outcome = await runIsolatedWorkUnit({ unitIndex: index, ingestionBaseSha, @@ -1532,6 +1602,7 @@ export class IngestBundleRunner { patchDir, trace: runTrace, workUnit: wu, + abortSignal: ctx?.abortSignal, afterSuccess: (child) => copyTransientIngestEvidence(child.workdir, sessionWorktree.workdir), run: async (child) => { const scopedWikiService = this.deps.wikiService.forWorktree(child.workdir); @@ -1565,6 +1636,7 @@ export class IngestBundleRunner { includeContextEvidenceTools: adapter.evidenceIndexing === 'documents' && !!contextReport, currentTableExists: (tableRef) => this.tableRefExistsInSemanticLayer(scopedSemanticLayerService, slConnectionIds, tableRef), + abortSignal: ctx?.abortSignal, memoryFlow, wuSkillNames, onStepFinish: ({ stepIndex, stepBudget }) => { @@ -1594,7 +1666,8 @@ export class IngestBundleRunner { completedWorkUnits / workUnits.length, `${completedWorkUnits} of ${workUnits.length} work units complete`, ); - }), + }), + ), ), ); } catch (error) { @@ -1693,6 +1766,7 @@ export class IngestBundleRunner { reason: context.reason, maxAttempts: 1, stepBudget: 12, + abortSignal: ctx?.abortSignal, }); emitStageProgress( 'integration', @@ -1714,6 +1788,7 @@ export class IngestBundleRunner { repairKind: 'patch_semantic_gate', maxAttempts: 1, stepBudget: 16, + abortSignal: ctx?.abortSignal, }); emitStageProgress( 'integration', @@ -1993,6 +2068,7 @@ export class IngestBundleRunner { ); } : undefined, + abortSignal: ctx?.abortSignal, }); curatorReport = curatorOutcome.report; curatorWarnings = curatorOutcome.warnings; @@ -2038,6 +2114,7 @@ export class IngestBundleRunner { sourceKey: job.sourceKey, jobId: job.jobId, force: !!overrideReport, + abortSignal: ctx?.abortSignal, onStepFinish: stage4 ? ({ stepIndex, stepBudget }) => { emitStageProgress('reconciliation', 85, `Reconciling results: step ${stepIndex}/${stepBudget}`, { @@ -2470,6 +2547,7 @@ export class IngestBundleRunner { repairKind: 'final_artifact_gate', maxAttempts: 1, stepBudget: 16, + abortSignal: ctx?.abortSignal, }); isolatedDiffSummary.gateRepairAttempts += gateRepair.attempts; diff --git a/packages/cli/src/context/ingest/isolated-diff/textual-conflict-resolver.ts b/packages/cli/src/context/ingest/isolated-diff/textual-conflict-resolver.ts index 5ae551d1..c4a00448 100644 --- a/packages/cli/src/context/ingest/isolated-diff/textual-conflict-resolver.ts +++ b/packages/cli/src/context/ingest/isolated-diff/textual-conflict-resolver.ts @@ -19,6 +19,7 @@ export interface ResolveTextualConflictInput { reason: string; maxAttempts?: number; stepBudget?: number; + abortSignal?: AbortSignal; } const readIntegrationFileSchema = z.object({ @@ -208,6 +209,7 @@ export async function resolveTextualConflict( jobId: input.trace.context.jobId, unitKey: input.unitKey, }, + abortSignal: input.abortSignal, }), ); diff --git a/packages/cli/src/context/ingest/isolated-diff/work-unit-executor.ts b/packages/cli/src/context/ingest/isolated-diff/work-unit-executor.ts index 7475612e..5ab52102 100644 --- a/packages/cli/src/context/ingest/isolated-diff/work-unit-executor.ts +++ b/packages/cli/src/context/ingest/isolated-diff/work-unit-executor.ts @@ -14,6 +14,7 @@ export interface RunIsolatedWorkUnitInput { patchDir: string; trace: IngestTraceWriter; workUnit: WorkUnit; + abortSignal?: AbortSignal; run(child: IngestSessionWorktree): Promise; afterSuccess?(child: IngestSessionWorktree): Promise; } diff --git a/packages/cli/src/context/ingest/local-bundle-runtime.ts b/packages/cli/src/context/ingest/local-bundle-runtime.ts index 9d6aba95..e4c45b3f 100644 --- a/packages/cli/src/context/ingest/local-bundle-runtime.ts +++ b/packages/cli/src/context/ingest/local-bundle-runtime.ts @@ -12,6 +12,7 @@ import type { KtxSemanticLayerComputePort } from '../../context/daemon/semantic- import { createRuntimeToolDescriptorFromAiTool } from '../../context/llm/runtime-tools.js'; import { createLocalKtxLlmRuntimeFromConfig } from '../../context/llm/local-config.js'; import { KtxIngestEmbeddingPortAdapter } from '../../context/llm/embedding-port.js'; +import { createRateLimitGovernorConfig, RateLimitGovernor } from '../../context/llm/rate-limit-governor.js'; import { RuntimeAgentRunner, type AgentRunnerPort, type KtxLlmRuntimePort, type KtxRuntimeToolSet } from '../../context/llm/runtime-port.js'; import type { KtxEmbeddingProvider } from '../../llm/types.js'; import type { KtxLocalProject } from '../../context/project/project.js'; @@ -619,7 +620,7 @@ function localIngestLlmProviderGuardMessage(projectDir: string): string { ].join('\n'); } -function resolveAgentRunner(options: CreateLocalBundleIngestRuntimeOptions): { +function resolveAgentRunner(options: CreateLocalBundleIngestRuntimeOptions, rateLimitGovernor: RateLimitGovernor): { agentRunner: AgentRunnerPort; llmRuntime?: KtxLlmRuntimePort; } { @@ -628,6 +629,7 @@ function resolveAgentRunner(options: CreateLocalBundleIngestRuntimeOptions): { (options.createLlmRuntime ?? createLocalKtxLlmRuntimeFromConfig)(options.project.config.llm, { projectDir: options.project.projectDir, env: process.env, + rateLimitGovernor, }) ?? undefined; @@ -677,7 +679,13 @@ export function createLocalBundleIngestRuntime( const knowledgeIndex = new LocalKnowledgeIndex(options.project, embedding); const knowledgeEvents = new NoopKnowledgeEventPort(); const wikiService = new KnowledgeWikiService(rootFileStore, embedding, knowledgeIndex, options.project.git, logger); - const { agentRunner, llmRuntime } = resolveAgentRunner(options); + const rateLimitGovernor = new RateLimitGovernor( + createRateLimitGovernorConfig({ + ...options.project.config.ingest.rateLimit, + maxConcurrency: options.project.config.ingest.workUnits.maxConcurrency, + }), + ); + const { agentRunner, llmRuntime } = resolveAgentRunner(options, rateLimitGovernor); const promptService = new PromptService({ promptsDir, partials: [], logger }); const storage = new LocalIngestStorage(options.project); const registry = registerAdapters(options.adapters); @@ -717,6 +725,7 @@ export function createLocalBundleIngestRuntime( workUnitMaxConcurrency: options.project.config.ingest.workUnits.maxConcurrency, workUnitStepBudget: options.project.config.ingest.workUnits.stepBudget, workUnitFailureMode: options.project.config.ingest.workUnits.failureMode, + rateLimitGovernor, profileIngest: options.project.config.ingest.profile, ingestTraceLevel: ingestTraceLevelFromEnv(), }, diff --git a/packages/cli/src/context/ingest/local-ingest.ts b/packages/cli/src/context/ingest/local-ingest.ts index ec8a72f4..1a219629 100644 --- a/packages/cli/src/context/ingest/local-ingest.ts +++ b/packages/cli/src/context/ingest/local-ingest.ts @@ -3,6 +3,7 @@ import { cp, mkdir, rm } from 'node:fs/promises'; import { isAbsolute, resolve } from 'node:path'; import type { KtxSqlQueryExecutorPort } from '../../context/connections/query-executor.js'; import type { KtxLogger } from '../../context/core/config.js'; +import { createAbortError, isAbortError } from '../../context/core/abort.js'; import type { KtxSemanticLayerComputePort } from '../../context/daemon/semantic-layer-compute.js'; import type { AgentRunnerPort, KtxLlmRuntimePort } from '../../context/llm/runtime-port.js'; import type { KtxLocalProject } from '../../context/project/project.js'; @@ -36,6 +37,7 @@ export interface RunLocalIngestOptions { queryExecutor?: KtxSqlQueryExecutorPort; logger?: KtxLogger; embeddingProvider?: import('../../llm/types.js').KtxEmbeddingProvider | null; + abortSignal?: AbortSignal; } export interface LocalIngestResult { @@ -123,10 +125,11 @@ function findAdapter(adapters: SourceAdapter[], source: string): SourceAdapter { return adapter; } -function localJobContext(jobId: string, memoryFlow?: MemoryFlowEventSink): IngestJobContext { +function localJobContext(jobId: string, memoryFlow?: MemoryFlowEventSink, abortSignal?: AbortSignal): IngestJobContext { return { jobId, ...(memoryFlow ? { memoryFlow } : {}), + ...(abortSignal ? { abortSignal } : {}), startPhase() { return new LocalIngestPhase(); }, @@ -158,6 +161,7 @@ async function runScheduledPullJob(options: { queryExecutor?: KtxSqlQueryExecutorPort; logger?: KtxLogger; embeddingProvider?: import('../../llm/types.js').KtxEmbeddingProvider | null; + abortSignal?: AbortSignal; }): Promise { const runtime = createLocalBundleIngestRuntime(options); const jobId = options.jobId ?? runtime.nextJobId(); @@ -169,7 +173,7 @@ async function runScheduledPullJob(options: { trigger: options.trigger ?? 'manual_resync', bundleRef: { kind: 'scheduled_pull', config: options.pullConfig }, }, - localJobContext(jobId, options.memoryFlow), + localJobContext(jobId, options.memoryFlow, options.abortSignal), ); const report = await runtime.store.findByJobId(jobId); if (!report) { @@ -212,6 +216,7 @@ export async function runLocalIngest(options: RunLocalIngestOptions): Promise KtxRuntimeToolSet; getReconciliationActions: () => MemoryAction[]; onStepFinish?: (info: { passNumber: number; stepIndex: number; stepBudget: number }) => void; + abortSignal?: AbortSignal; }): Promise; } diff --git a/packages/cli/src/context/ingest/stages/stage-3-work-units.ts b/packages/cli/src/context/ingest/stages/stage-3-work-units.ts index ec514a02..a7387c8a 100644 --- a/packages/cli/src/context/ingest/stages/stage-3-work-units.ts +++ b/packages/cli/src/context/ingest/stages/stage-3-work-units.ts @@ -1,4 +1,5 @@ import type { KtxModelRole } from '../../../llm/types.js'; +import { isAbortError } from '../../core/abort.js'; import type { AgentRunnerPort, KtxRuntimeToolSet, RunLoopMetrics } from '../../../context/llm/runtime-port.js'; import type { CaptureSession, MemoryAction } from '../../../context/memory/types.js'; import { listTouchedSlSources, type TouchedSlSource } from '../../../context/tools/touched-sl-sources.js'; @@ -28,6 +29,7 @@ export interface WorkUnitExecutionDeps { connectionId: string; jobId: string; onStepFinish?: (info: { stepIndex: number; stepBudget: number }) => void; + abortSignal?: AbortSignal; toolFailureCount?: (unitKey: string) => number; } @@ -106,8 +108,12 @@ export async function executeWorkUnit(deps: WorkUnitExecutionDeps, wu: WorkUnit) jobId: deps.jobId, }, onStepFinish: deps.onStepFinish, + abortSignal: deps.abortSignal, }); } catch (error) { + if (isAbortError(error)) { + throw error; + } return failWithResetFromCurrentHead(error instanceof Error ? error.message : String(error)); } diff --git a/packages/cli/src/context/ingest/stages/stage-4-reconciliation.ts b/packages/cli/src/context/ingest/stages/stage-4-reconciliation.ts index 5abc9bfb..c78e1b48 100644 --- a/packages/cli/src/context/ingest/stages/stage-4-reconciliation.ts +++ b/packages/cli/src/context/ingest/stages/stage-4-reconciliation.ts @@ -16,6 +16,7 @@ export interface ReconciliationContext { jobId: string; force?: boolean; onStepFinish?: (info: { stepIndex: number; stepBudget: number }) => void; + abortSignal?: AbortSignal; forceRun?: boolean; } @@ -40,6 +41,7 @@ export async function runReconciliationStage4(ctx: ReconciliationContext): Promi stepBudget: ctx.stepBudget, telemetryTags: { operationName: 'ingest-bundle-reconcile', source: ctx.sourceKey, jobId: ctx.jobId }, onStepFinish: ctx.onStepFinish, + abortSignal: ctx.abortSignal, }); return { skipped: false, stopReason: run.stopReason, error: run.error, ...(run.metrics ? { metrics: run.metrics } : {}) }; } diff --git a/packages/cli/src/context/ingest/types.ts b/packages/cli/src/context/ingest/types.ts index 337885af..925f3d82 100644 --- a/packages/cli/src/context/ingest/types.ts +++ b/packages/cli/src/context/ingest/types.ts @@ -220,5 +220,6 @@ export interface IngestJobPhase { export interface IngestJobContext { jobId: string; memoryFlow?: MemoryFlowEventSink; + abortSignal?: AbortSignal; startPhase(weight: number): IngestJobPhase; } diff --git a/packages/cli/src/context/llm/ai-sdk-runtime.ts b/packages/cli/src/context/llm/ai-sdk-runtime.ts index f5752355..d5a60c7b 100644 --- a/packages/cli/src/context/llm/ai-sdk-runtime.ts +++ b/packages/cli/src/context/llm/ai-sdk-runtime.ts @@ -3,7 +3,9 @@ import type { KtxLlmProvider } from '../../llm/types.js'; import { generateText, Output, stepCountIs, type FlexibleSchema, type TelemetrySettings, type ToolSet } from 'ai'; import type { z } from 'zod'; import { noopLogger, type KtxLogger } from '../../context/core/config.js'; +import { isAbortError } from '../core/abort.js'; import { summarizeKtxLlmDebugRequest, type KtxLlmDebugRequestRecorder } from './debug-request-recorder.js'; +import type { RateLimitGovernor, RateLimitProvider, RateLimitSignal } from './rate-limit-governor.js'; import { createAiSdkToolSet } from './runtime-tools.js'; import type { KtxGenerateObjectInput, @@ -40,12 +42,129 @@ export interface AiSdkKtxLlmRuntimeDeps { telemetry?: AgentTelemetryPort; logger?: KtxLogger; debugRequestRecorder?: KtxLlmDebugRequestRecorder; + rateLimitGovernor?: Pick; } function hasTools(tools: Record): boolean { return Object.keys(tools).length > 0; } +function modelProviderName(model: unknown): RateLimitProvider { + const provider = (model as { provider?: string }).provider ?? ''; + return provider.includes('vertex') || provider.includes('google') ? 'vertex' : 'anthropic-api'; +} + +interface HeaderLimitPair { + limit: string; + remaining: string; + rateLimitType: string; +} + +const RATE_LIMIT_HEADER_PAIRS: HeaderLimitPair[] = [ + { + limit: 'anthropic-ratelimit-requests-limit', + remaining: 'anthropic-ratelimit-requests-remaining', + rateLimitType: 'rpm', + }, + { + limit: 'anthropic-ratelimit-tokens-limit', + remaining: 'anthropic-ratelimit-tokens-remaining', + rateLimitType: 'tpm', + }, + { + limit: 'anthropic-ratelimit-input-tokens-limit', + remaining: 'anthropic-ratelimit-input-tokens-remaining', + rateLimitType: 'itpm', + }, + { + limit: 'anthropic-ratelimit-output-tokens-limit', + remaining: 'anthropic-ratelimit-output-tokens-remaining', + rateLimitType: 'otpm', + }, + { + limit: 'x-ratelimit-limit-requests', + remaining: 'x-ratelimit-remaining-requests', + rateLimitType: 'rpm', + }, + { + limit: 'x-ratelimit-limit-tokens', + remaining: 'x-ratelimit-remaining-tokens', + rateLimitType: 'tpm', + }, +]; + +function normalizeHeaders(headers: unknown): Record { + if (!headers || typeof headers !== 'object') { + return {}; + } + const get = (headers as { get?: unknown }).get; + if (typeof get === 'function') { + const out: Record = {}; + for (const pair of RATE_LIMIT_HEADER_PAIRS) { + const limit = get.call(headers, pair.limit); + const remaining = get.call(headers, pair.remaining); + if (typeof limit === 'string') out[pair.limit] = limit; + if (typeof remaining === 'string') out[pair.remaining] = remaining; + } + return out; + } + return Object.fromEntries( + Object.entries(headers as Record) + .filter((entry): entry is [string, string | number] => typeof entry[1] === 'string' || typeof entry[1] === 'number') + .map(([key, value]) => [key.toLowerCase(), String(value)]), + ); +} + +function numericHeader(headers: Record, key: string): number | undefined { + const value = Number(headers[key]); + return Number.isFinite(value) && value >= 0 ? value : undefined; +} + +function utilizationForPair(headers: Record, pair: HeaderLimitPair): number | undefined { + const limit = numericHeader(headers, pair.limit); + const remaining = numericHeader(headers, pair.remaining); + if (limit === undefined || remaining === undefined || limit <= 0) { + return undefined; + } + return 1 - Math.min(limit, remaining) / limit; +} + +function aiSdkHeaderRateLimitSignal(provider: RateLimitProvider, result: unknown): RateLimitSignal | undefined { + const headers = normalizeHeaders((result as { response?: { headers?: unknown } }).response?.headers); + let best: { utilization: number; rateLimitType: string } | undefined; + for (const pair of RATE_LIMIT_HEADER_PAIRS) { + const utilization = utilizationForPair(headers, pair); + if (utilization === undefined) { + continue; + } + if (!best || utilization > best.utilization) { + best = { utilization, rateLimitType: pair.rateLimitType }; + } + } + if (!best) { + return undefined; + } + return { + provider, + status: 'allowed', + rateLimitType: best.rateLimitType, + utilization: Number(best.utilization.toFixed(4)), + }; +} + +function retryAfterMs(error: unknown): number | undefined { + const value = (error as { retryAfter?: unknown }).retryAfter; + if (typeof value === 'number' && Number.isFinite(value) && value > 0) { + return value < 1_000 ? value * 1_000 : value; + } + return undefined; +} + +function isAiSdkRateLimitError(error: unknown): boolean { + const record = error as { name?: string; statusCode?: number; status?: number }; + return record.name === 'TooManyRequestsError' || record.statusCode === 429 || record.status === 429; +} + export class AiSdkKtxLlmRuntime implements KtxLlmRuntimePort { private readonly logger: KtxLogger; @@ -53,6 +172,41 @@ export class AiSdkKtxLlmRuntime implements KtxLlmRuntimePort { this.logger = deps.logger ?? noopLogger; } + private async generateTextWithRateLimitRetry( + provider: RateLimitProvider, + abortSignal: AbortSignal | undefined, + run: () => Promise, + ): Promise { + // maxRetryAttempts() returns 1 when no governor is present or pacing is + // disabled, so a 429 throws immediately instead of hammering the provider + // with no backoff; the AI SDK's own maxRetries still handles transient 429s. + const maxAttempts = this.deps.rateLimitGovernor?.maxRetryAttempts() ?? 1; + let attempt = 0; + while (true) { + await this.deps.rateLimitGovernor?.waitForReady(abortSignal); + try { + const result = await run(); + const signal = aiSdkHeaderRateLimitSignal(provider, result); + if (signal) { + this.deps.rateLimitGovernor?.report(signal); + } + return result; + } catch (error) { + if (isAbortError(error) || !isAiSdkRateLimitError(error) || attempt >= maxAttempts - 1) { + throw error; + } + attempt += 1; + const retryAfter = retryAfterMs(error); + this.deps.rateLimitGovernor?.report({ + provider, + status: 'rejected', + rateLimitType: 'http_429', + ...(retryAfter !== undefined ? { retryAfterMs: retryAfter } : {}), + }); + } + } + } + async generateText(input: KtxGenerateTextInput): Promise { const model = this.deps.llmProvider.getModel(input.role); if ((model as { provider?: string }).provider === 'deterministic') { @@ -67,12 +221,13 @@ export class AiSdkKtxLlmRuntime implements KtxLlmRuntimePort { }); const split = splitKtxSystemMessages(built.messages); const startedAt = Date.now(); - const result = await generateText({ + const request = { model, temperature: input.temperature ?? 0, ...(split.system ? { system: split.system } : {}), messages: split.messages, tools: built.tools as ToolSet, + ...(input.abortSignal ? { abortSignal: input.abortSignal } : {}), ...(hasTools(tools) ? { experimental_repairToolCall: this.deps.llmProvider.repairToolCallHandler({ @@ -80,7 +235,8 @@ export class AiSdkKtxLlmRuntime implements KtxLlmRuntimePort { }), } : {}), - }); + }; + const result = await this.generateTextWithRateLimitRetry(modelProviderName(model), input.abortSignal, () => generateText(request)); input.onMetrics?.({ totalMs: Date.now() - startedAt, usage: toLlmTokenUsage(result.totalUsage ?? result.usage) }); if (typeof result.text !== 'string') { throw new Error('KTX LLM text generation returned no text'); @@ -101,12 +257,13 @@ export class AiSdkKtxLlmRuntime implements KtxLlmRuntimePort { }); const split = splitKtxSystemMessages(built.messages); const startedAt = Date.now(); - const result = await generateText({ + const request = { model, temperature: input.temperature ?? 0, ...(split.system ? { system: split.system } : {}), messages: split.messages, tools: built.tools as ToolSet, + ...(input.abortSignal ? { abortSignal: input.abortSignal } : {}), ...(hasTools(tools) ? { experimental_repairToolCall: this.deps.llmProvider.repairToolCallHandler({ @@ -115,7 +272,8 @@ export class AiSdkKtxLlmRuntime implements KtxLlmRuntimePort { } : {}), output: Output.object({ schema: input.schema as unknown as FlexibleSchema }), - }); + }; + const result = await this.generateTextWithRateLimitRetry(modelProviderName(model), input.abortSignal, () => generateText(request)); input.onMetrics?.({ totalMs: Date.now() - startedAt, usage: toLlmTokenUsage(result.totalUsage ?? result.usage) }); if (result.output == null) { throw new Error('KTX LLM object generation returned no output'); @@ -152,7 +310,7 @@ export class AiSdkKtxLlmRuntime implements KtxLlmRuntimePort { }), ); - const result = await generateText({ + const request = { model, temperature: 0, stopWhen: stepCountIs(params.stepBudget), @@ -163,6 +321,7 @@ export class AiSdkKtxLlmRuntime implements KtxLlmRuntimePort { ...(promptMessages.system ? { system: promptMessages.system } : {}), messages: promptMessages.messages, tools: built.tools as ToolSet, + ...(params.abortSignal ? { abortSignal: params.abortSignal } : {}), onStepFinish: async () => { stepIndex += 1; stepBoundariesMs.push(Date.now() - startedAt); @@ -179,7 +338,8 @@ export class AiSdkKtxLlmRuntime implements KtxLlmRuntimePort { ); } }, - }); + }; + const result = await this.generateTextWithRateLimitRetry(modelProviderName(model), params.abortSignal, () => generateText(request)); return { stopReason: 'natural', metrics: { @@ -190,6 +350,9 @@ export class AiSdkKtxLlmRuntime implements KtxLlmRuntimePort { }, }; } catch (error) { + if (isAbortError(error)) { + throw error; + } const err = error instanceof Error ? error : new Error(String(error)); this.logger.warn(`[agent-runner] loop failed: ${err.message}`); return { diff --git a/packages/cli/src/context/llm/claude-code-runtime.ts b/packages/cli/src/context/llm/claude-code-runtime.ts index 0c1e6881..26bd0529 100644 --- a/packages/cli/src/context/llm/claude-code-runtime.ts +++ b/packages/cli/src/context/llm/claude-code-runtime.ts @@ -7,8 +7,10 @@ import { } from '@anthropic-ai/claude-agent-sdk'; import { z } from 'zod'; import { noopLogger, type KtxLogger } from '../../context/core/config.js'; +import { createAbortError, isAbortError, throwIfAborted } from '../core/abort.js'; import { createKtxClaudeCodeEnv } from './claude-code-env.js'; import { resolveClaudeCodeModel } from './claude-code-models.js'; +import type { RateLimitGovernor, RateLimitSignal } from './rate-limit-governor.js'; import { createClaudeSdkTools, mcpToolIds } from './runtime-tools.js'; import type { KtxGenerateObjectInput, @@ -21,7 +23,16 @@ import type { RunLoopStopReason, } from './runtime-port.js'; -type QueryFn = (params: Parameters[0]) => AsyncIterable; +type QueryResult = AsyncIterable & { + interrupt?: () => void | Promise; +}; + +type QueryFn = (params: Parameters[0]) => QueryResult; + +interface ClaudeQueryOutcome { + result: SDKResultMessage; + rejectedRateLimitSignal?: RateLimitSignal; +} function claudeTokenUsage(result: SDKResultMessage): LlmTokenUsage { const usage = (result as { usage?: { input_tokens?: number; output_tokens?: number } }).usage; @@ -43,6 +54,7 @@ export interface ClaudeCodeKtxLlmRuntimeDeps { query?: QueryFn; env?: NodeJS.ProcessEnv; logger?: KtxLogger; + rateLimitGovernor?: Pick; } const BUILTIN_TOOLS = [ @@ -157,6 +169,74 @@ function expectedMcpServerNames(tools: KtxRuntimeToolSet | undefined): Set 0 ? new Set([KTX_MCP_SERVER_NAME]) : new Set(); } +const CLAUDE_RATE_LIMIT_ERROR_MARKERS = /\b429\b|rate limit|too many requests|quota exceeded|overloaded|max_retries/i; + +function normalizeClaudeResetAtMs(value: unknown): number | undefined { + if (typeof value === 'number' && Number.isFinite(value) && value > 0) { + return Math.round(value < 10_000_000_000 ? value * 1_000 : value); + } + if (typeof value === 'string') { + const numeric = Number(value); + if (Number.isFinite(numeric) && numeric > 0) { + return normalizeClaudeResetAtMs(numeric); + } + const parsed = Date.parse(value); + return Number.isFinite(parsed) ? parsed : undefined; + } + return undefined; +} + +function isClaudeRateLimitResult(result: SDKResultMessage, rejectedSignal: RateLimitSignal | undefined): boolean { + const error = resultError(result); + if (!error) { + return false; + } + if (rejectedSignal?.status === 'rejected') { + return true; + } + const resultDetails = result as { + stop_reason?: unknown; + terminal_reason?: unknown; + errors?: unknown[]; + }; + const details = [ + error.message, + resultDetails.stop_reason, + resultDetails.terminal_reason, + ...(resultDetails.errors ?? []), + ] + .filter((value): value is string => typeof value === 'string' && value.length > 0) + .join('\n'); + return CLAUDE_RATE_LIMIT_ERROR_MARKERS.test(details); +} + +function claudeRateLimitSignal(message: SDKMessage): RateLimitSignal | null { + const record = message as unknown as Record; + if (record.type === 'rate_limit_event') { + const info = record.rate_limit_info as Record | undefined; + if (!info) return null; + const rawStatus = typeof info.status === 'string' ? info.status : 'allowed'; + const resetAtMs = normalizeClaudeResetAtMs(info.resetsAt); + return { + provider: 'claude-subscription', + status: rawStatus === 'rejected' ? 'rejected' : rawStatus === 'allowed_warning' ? 'warning' : 'allowed', + ...(resetAtMs !== undefined ? { resetAtMs } : {}), + ...(typeof info.rateLimitType === 'string' ? { rateLimitType: info.rateLimitType } : {}), + ...(typeof info.utilization === 'number' ? { utilization: info.utilization } : {}), + }; + } + if (record.subtype === 'api_retry' || record.type === 'api_retry') { + const retryDelayMs = typeof record.retry_delay_ms === 'number' ? record.retry_delay_ms : undefined; + return { + provider: 'claude-subscription', + status: 'warning', + ...(retryDelayMs !== undefined ? { retryAfterMs: retryDelayMs } : {}), + rateLimitType: 'api_retry', + }; + } + return null; +} + function managedMcpSettings(serverNames: string[]): NonNullable { return { allowManagedMcpServersOnly: true, @@ -217,21 +297,63 @@ async function collectResult(params: { allowedToolIds: Set; expectedMcpServerNames: Set; onAssistantTurn?: () => Promise; -}): Promise { + rateLimitGovernor?: Pick; + abortSignal?: AbortSignal; +}): Promise { let result: SDKResultMessage | undefined; - for await (const message of params.query({ prompt: params.prompt, options: params.options })) { - assertInitIsolation(message, params.allowedToolIds, params.expectedMcpServerNames); - if (countsAsAssistantTurn(message)) { - await params.onAssistantTurn?.(); - } - if (isResult(message)) { - result = message; + let rejectedRateLimitSignal: RateLimitSignal | undefined; + throwIfAborted(params.abortSignal); + await params.rateLimitGovernor?.waitForReady(params.abortSignal); + throwIfAborted(params.abortSignal); + const queryResult = params.query({ prompt: params.prompt, options: params.options }); + const onAbort = () => { + void Promise.resolve(queryResult.interrupt?.()).catch(() => undefined); + }; + params.abortSignal?.addEventListener('abort', onAbort, { once: true }); + try { + for await (const message of queryResult) { + throwIfAborted(params.abortSignal); + const rateLimitSignal = claudeRateLimitSignal(message); + if (rateLimitSignal) { + if (rateLimitSignal.status === 'rejected') { + rejectedRateLimitSignal = rateLimitSignal; + } + params.rateLimitGovernor?.report(rateLimitSignal); + } + assertInitIsolation(message, params.allowedToolIds, params.expectedMcpServerNames); + if (countsAsAssistantTurn(message)) { + await params.onAssistantTurn?.(); + } + if (isResult(message)) { + result = message; + } } + } finally { + params.abortSignal?.removeEventListener('abort', onAbort); + } + if (params.abortSignal?.aborted) { + throw createAbortError(); } if (!result) { throw new Error('Claude Code query returned no result message'); } - return result; + return { + result, + ...(rejectedRateLimitSignal ? { rejectedRateLimitSignal } : {}), + }; +} + +async function collectResultWithRateLimitRetry(params: Parameters[0]): Promise { + // maxRetryAttempts() returns 1 when no governor is present or pacing is + // disabled, so a rate-limited result surfaces without an extra query; the + // Claude Code SDK applies its own backoff for transient rejections. + const maxAttempts = params.rateLimitGovernor?.maxRetryAttempts() ?? 1; + for (let attempt = 0; ; attempt += 1) { + const outcome = await collectResult(params); + if (!isClaudeRateLimitResult(outcome.result, outcome.rejectedRateLimitSignal) || attempt >= maxAttempts - 1) { + return outcome.result; + } + } } export class ClaudeCodeKtxLlmRuntime implements KtxLlmRuntimePort { @@ -252,12 +374,14 @@ export class ClaudeCodeKtxLlmRuntime implements KtxLlmRuntimePort { tools: input.tools, }); const startedAt = Date.now(); - const result = await collectResult({ + const result = await collectResultWithRateLimitRetry({ query: this.runQuery, prompt: [input.system, input.prompt].filter(Boolean).join('\n\n'), options, allowedToolIds: new Set(mcpToolIds(input.tools ?? {})), expectedMcpServerNames: expectedMcpServerNames(input.tools), + rateLimitGovernor: this.deps.rateLimitGovernor, + abortSignal: input.abortSignal, }); input.onMetrics?.({ totalMs: Date.now() - startedAt, usage: claudeTokenUsage(result) }); const error = resultError(result); @@ -289,12 +413,14 @@ export class ClaudeCodeKtxLlmRuntime implements KtxLlmRuntimePort { outputFormat: { type: 'json_schema' as const, schema: jsonSchema(input.schema as z.ZodType) }, }; const startedAt = Date.now(); - const result = await collectResult({ + const result = await collectResultWithRateLimitRetry({ query: this.runQuery, prompt: [input.system, input.prompt].filter(Boolean).join('\n\n'), options, allowedToolIds: new Set([...mcpToolIds(input.tools ?? {}), STRUCTURED_OUTPUT_TOOL_NAME]), expectedMcpServerNames: expectedMcpServerNames(input.tools), + rateLimitGovernor: this.deps.rateLimitGovernor, + abortSignal: input.abortSignal, }); input.onMetrics?.({ totalMs: Date.now() - startedAt, usage: claudeTokenUsage(result) }); const error = resultError(result); @@ -319,12 +445,14 @@ export class ClaudeCodeKtxLlmRuntime implements KtxLlmRuntimePort { maxTurns: params.stepBudget, tools: params.toolSet, }); - const result = await collectResult({ + const result = await collectResultWithRateLimitRetry({ query: this.runQuery, prompt: params.userPrompt, options: { ...options, systemPrompt: params.systemPrompt }, allowedToolIds: new Set(mcpToolIds(params.toolSet)), expectedMcpServerNames: expectedMcpServerNames(params.toolSet), + rateLimitGovernor: this.deps.rateLimitGovernor, + abortSignal: params.abortSignal, onAssistantTurn: async () => { stepIndex += 1; stepBoundariesMs.push(Date.now() - startedAt); @@ -355,6 +483,9 @@ export class ClaudeCodeKtxLlmRuntime implements KtxLlmRuntimePort { }, }; } catch (error) { + if (isAbortError(error)) { + throw error; + } const err = error instanceof Error ? error : new Error(String(error)); return { stopReason: 'error', @@ -388,7 +519,7 @@ export async function runClaudeCodeAuthProbe(input: { env: input.env, maxTurns: 1, }); - const result = await collectResult({ + const result = await collectResultWithRateLimitRetry({ query: input.query ?? defaultQuery, prompt: 'Reply with exactly: ok', options, diff --git a/packages/cli/src/context/llm/codex-runtime.ts b/packages/cli/src/context/llm/codex-runtime.ts index 3535072b..2958b3f8 100644 --- a/packages/cli/src/context/llm/codex-runtime.ts +++ b/packages/cli/src/context/llm/codex-runtime.ts @@ -1,5 +1,6 @@ import { z } from 'zod'; import { noopLogger, type KtxLogger } from '../core/config.js'; +import { isAbortError, linkAbortSignal } from '../core/abort.js'; import { isCompletedAgentStep, summarizeCodexExecEvents, type CodexExecEventSummary } from './codex-exec-events.js'; import { startCodexRuntimeMcpServer, @@ -8,6 +9,7 @@ import { import { resolveCodexModel } from './codex-models.js'; import { buildCodexRuntimeConfig } from './codex-runtime-config.js'; import { CodexSdkCliRunner, type CodexSdkRunner } from './codex-sdk-runner.js'; +import type { RateLimitGovernor } from './rate-limit-governor.js'; import type { KtxGenerateObjectInput, KtxGenerateTextInput, @@ -24,6 +26,7 @@ export interface CodexKtxLlmRuntimeDeps { runner?: CodexSdkRunner; startMcpServer?: (input: { projectDir: string; toolSet: KtxRuntimeToolSet }) => Promise; logger?: KtxLogger; + rateLimitGovernor?: Pick; } function modelForRole(modelSlots: CodexKtxLlmRuntimeDeps['modelSlots'], role: string): string { @@ -159,6 +162,12 @@ function runtimeToolNames(toolSet: KtxRuntimeToolSet | undefined): string[] { return Object.values(toolSet ?? {}).map((descriptor) => descriptor.name); } +const CODEX_RATE_LIMIT_MARKERS = /\b429\b|rate limit|too many requests|quota exceeded|temporarily overloaded/i; + +function isCodexRateLimitError(error: Error | undefined): boolean { + return !!error && CODEX_RATE_LIMIT_MARKERS.test(error.message); +} + export class CodexKtxLlmRuntime implements KtxLlmRuntimePort { private readonly runner: CodexSdkRunner; private readonly logger: KtxLogger; @@ -168,6 +177,37 @@ export class CodexKtxLlmRuntime implements KtxLlmRuntimePort { this.logger = deps.logger ?? noopLogger; } + private async runWithRateLimitRetry( + abortSignal: AbortSignal | undefined, + run: () => Promise, + getError: (result: T) => Error | undefined, + ): Promise { + // maxRetryAttempts() returns 1 when no governor is present or pacing is + // disabled, so an opaque rate-limit failure surfaces on the first attempt + // instead of being retried with no backoff. + const maxAttempts = this.deps.rateLimitGovernor?.maxRetryAttempts() ?? 1; + for (let attempt = 0; ; attempt += 1) { + await this.deps.rateLimitGovernor?.waitForReady(abortSignal); + const lastAttempt = attempt >= maxAttempts - 1; + try { + const result = await run(); + const error = getError(result); + if (!isCodexRateLimitError(error) || lastAttempt) { + return result; + } + } catch (error) { + if (isAbortError(error)) { + throw error; + } + const err = error instanceof Error ? error : new Error(String(error)); + if (!isCodexRateLimitError(err) || lastAttempt) { + throw error; + } + } + this.deps.rateLimitGovernor?.report({ provider: 'codex', status: 'rejected', rateLimitType: 'opaque' }); + } + } + async generateText(input: KtxGenerateTextInput): Promise { const startedAt = Date.now(); const model = modelForRole(this.deps.modelSlots, input.role); @@ -190,18 +230,26 @@ export class CodexKtxLlmRuntime implements KtxLlmRuntimePort { } : {}), }); - const collected = await collectEvents( - await this.runner.runStreamed({ - projectDir: this.deps.projectDir, - model, - prompt: promptWithSystem(input.system, input.prompt), - configOverrides: config.configOverrides, - env: config.env, - }), + const result = await this.runWithRateLimitRetry( + input.abortSignal, + async () => { + const collected = await collectEvents( + await this.runner.runStreamed({ + projectDir: this.deps.projectDir, + model, + prompt: promptWithSystem(input.system, input.prompt), + configOverrides: config.configOverrides, + env: config.env, + ...(input.abortSignal ? { signal: input.abortSignal } : {}), + }), + ); + const summary = summarizeCodexExecEvents(collected.events, { startedAt }); + return { collected, summary }; + }, + ({ collected, summary }) => summaryError(summary, collected.streamError), ); - const summary = summarizeCodexExecEvents(collected.events, { startedAt }); - input.onMetrics?.(metrics(summary, startedAt)); - return assertSuccessfulText(summary, collected.streamError); + input.onMetrics?.(metrics(result.summary, startedAt)); + return assertSuccessfulText(result.summary, result.collected.streamError); } finally { await mcp?.close(); } @@ -231,19 +279,27 @@ export class CodexKtxLlmRuntime implements KtxLlmRuntimePort { } : {}), }); - const collected = await collectEvents( - await this.runner.runStreamed({ - projectDir: this.deps.projectDir, - model, - prompt: promptWithSystem(input.system, input.prompt), - configOverrides: config.configOverrides, - env: config.env, - outputSchema: z.toJSONSchema(input.schema, { target: 'draft-7' }) as Record, - }), + const result = await this.runWithRateLimitRetry( + input.abortSignal, + async () => { + const collected = await collectEvents( + await this.runner.runStreamed({ + projectDir: this.deps.projectDir, + model, + prompt: promptWithSystem(input.system, input.prompt), + configOverrides: config.configOverrides, + env: config.env, + outputSchema: z.toJSONSchema(input.schema, { target: 'draft-7' }) as Record, + ...(input.abortSignal ? { signal: input.abortSignal } : {}), + }), + ); + const summary = summarizeCodexExecEvents(collected.events, { startedAt }); + return { collected, summary }; + }, + ({ collected, summary }) => summaryError(summary, collected.streamError), ); - const summary = summarizeCodexExecEvents(collected.events, { startedAt }); - input.onMetrics?.(metrics(summary, startedAt)); - return parseStructuredOutput(input.schema, assertSuccessfulText(summary, collected.streamError)); + input.onMetrics?.(metrics(result.summary, startedAt)); + return parseStructuredOutput(input.schema, assertSuccessfulText(result.summary, result.collected.streamError)); } finally { await mcp?.close(); } @@ -272,7 +328,6 @@ export class CodexKtxLlmRuntime implements KtxLlmRuntimePort { } : {}), }); - const abortController = new AbortController(); const onStep = async (stepIndex: number): Promise => { try { await params.onStepFinish?.({ stepIndex, stepBudget: params.stepBudget }); @@ -282,31 +337,50 @@ export class CodexKtxLlmRuntime implements KtxLlmRuntimePort { ); } }; - const collected = await collectEvents( - await this.runner.runStreamed({ - projectDir: this.deps.projectDir, - model, - prompt: promptWithSystem(params.systemPrompt, params.userPrompt), - configOverrides: config.configOverrides, - env: config.env, - signal: abortController.signal, - }), - { stepBudget: params.stepBudget, abortController, onStep }, + const result = await this.runWithRateLimitRetry( + params.abortSignal, + async () => { + const linked = linkAbortSignal(params.abortSignal); + const abortController = linked.controller; + try { + const collected = await collectEvents( + await this.runner.runStreamed({ + projectDir: this.deps.projectDir, + model, + prompt: promptWithSystem(params.systemPrompt, params.userPrompt), + configOverrides: config.configOverrides, + env: config.env, + signal: abortController.signal, + }), + { stepBudget: params.stepBudget, abortController, onStep }, + ); + const summary = summarizeCodexExecEvents(collected.events, { startedAt }); + return { collected, summary }; + } finally { + linked.dispose(); + } + }, + ({ collected, summary }) => summaryError(summary, collected.streamError), ); - const summary = summarizeCodexExecEvents(collected.events, { startedAt }); - const error = summaryError(summary, collected.streamError); - const stopReason = collected.budgetExceeded ? 'budget' : error ? 'error' : summary.stopReason; + const error = summaryError(result.summary, result.collected.streamError); + if (isAbortError(error)) { + throw error; + } + const stopReason = result.collected.budgetExceeded ? 'budget' : error ? 'error' : result.summary.stopReason; return { stopReason, ...(stopReason === 'error' && error ? { error } : {}), metrics: { totalMs: Date.now() - startedAt, - usage: summary.usage, - stepCount: summary.stepCount, - stepBoundariesMs: summary.stepBoundariesMs, + usage: result.summary.usage, + stepCount: result.summary.stepCount, + stepBoundariesMs: result.summary.stepBoundariesMs, }, }; } catch (error) { + if (isAbortError(error)) { + throw error; + } const err = error instanceof Error ? error : new Error(String(error)); return { stopReason: 'error', diff --git a/packages/cli/src/context/llm/local-config.ts b/packages/cli/src/context/llm/local-config.ts index 58bd29a5..4c2502d1 100644 --- a/packages/cli/src/context/llm/local-config.ts +++ b/packages/cli/src/context/llm/local-config.ts @@ -6,16 +6,28 @@ import type { KtxProjectEmbeddingConfig, KtxProjectLlmConfig } from '../project/ import { AiSdkKtxLlmRuntime } from './ai-sdk-runtime.js'; import { ClaudeCodeKtxLlmRuntime } from './claude-code-runtime.js'; import { CodexKtxLlmRuntime } from './codex-runtime.js'; +import type { RateLimitGovernor } from './rate-limit-governor.js'; import type { KtxLlmRuntimePort } from './runtime-port.js'; +type ClaudeCodeRuntimeDeps = ConstructorParameters[0] & { + rateLimitGovernor?: RateLimitGovernor; +}; +type CodexRuntimeDeps = ConstructorParameters[0] & { + rateLimitGovernor?: RateLimitGovernor; +}; +type AiSdkRuntimeDeps = ConstructorParameters[0] & { + rateLimitGovernor?: RateLimitGovernor; +}; + interface LocalConfigDeps { env?: NodeJS.ProcessEnv; projectDir?: string; + rateLimitGovernor?: RateLimitGovernor; createKtxLlmProvider?: typeof createKtxLlmProvider; createKtxEmbeddingProvider?: typeof createKtxEmbeddingProvider; - createClaudeCodeRuntime?: (deps: ConstructorParameters[0]) => KtxLlmRuntimePort; - createCodexRuntime?: (deps: ConstructorParameters[0]) => KtxLlmRuntimePort; - createAiSdkRuntime?: (deps: { llmProvider: KtxLlmProvider }) => KtxLlmRuntimePort; + createClaudeCodeRuntime?: (deps: ClaudeCodeRuntimeDeps) => KtxLlmRuntimePort; + createCodexRuntime?: (deps: CodexRuntimeDeps) => KtxLlmRuntimePort; + createAiSdkRuntime?: (deps: AiSdkRuntimeDeps) => KtxLlmRuntimePort; } function resolveOptional(value: string | undefined, env: NodeJS.ProcessEnv): string | undefined { @@ -129,6 +141,7 @@ export function createLocalKtxLlmRuntimeFromConfig( projectDir, modelSlots: resolved.modelSlots, env: deps.env, + rateLimitGovernor: deps.rateLimitGovernor, }); } if (resolved.backend === 'codex') { @@ -139,10 +152,14 @@ export function createLocalKtxLlmRuntimeFromConfig( return (deps.createCodexRuntime ?? ((runtimeDeps) => new CodexKtxLlmRuntime(runtimeDeps)))({ projectDir, modelSlots: resolved.modelSlots, + rateLimitGovernor: deps.rateLimitGovernor, }); } const llmProvider = (deps.createKtxLlmProvider ?? createKtxLlmProvider)(resolved); - return (deps.createAiSdkRuntime ?? ((runtimeDeps) => new AiSdkKtxLlmRuntime(runtimeDeps)))({ llmProvider }); + return (deps.createAiSdkRuntime ?? ((runtimeDeps) => new AiSdkKtxLlmRuntime(runtimeDeps)))({ + llmProvider, + rateLimitGovernor: deps.rateLimitGovernor, + }); } export function resolveLocalKtxEmbeddingConfig( diff --git a/packages/cli/src/context/llm/rate-limit-governor.ts b/packages/cli/src/context/llm/rate-limit-governor.ts new file mode 100644 index 00000000..909e4c44 --- /dev/null +++ b/packages/cli/src/context/llm/rate-limit-governor.ts @@ -0,0 +1,387 @@ +import { createAbortError, throwIfAborted } from '../core/abort.js'; + +export type RateLimitProvider = 'claude-subscription' | 'anthropic-api' | 'vertex' | 'codex'; +type RateLimitSignalStatus = 'allowed' | 'warning' | 'rejected'; + +export interface RateLimitSignal { + provider: RateLimitProvider; + status: RateLimitSignalStatus; + resetAtMs?: number; + retryAfterMs?: number; + utilization?: number; + rateLimitType?: string; +} + +export interface RateLimitRetryConfig { + maxAttempts: number; + baseDelayMs: number; + maxDelayMs: number; + jitter: boolean; +} + +export interface RateLimitGovernorConfig { + enabled: boolean; + maxConcurrency: number; + throttleThreshold: number; + minConcurrencyUnderPressure: number; + maxWaitMs?: number; + waitStateTickMs: number; + retry: RateLimitRetryConfig; +} + +export type RateLimitWaitState = + | { + kind: 'rate_limit_observed'; + provider: RateLimitProvider; + status: RateLimitSignalStatus; + rateLimitType?: string; + resetAtMs?: number; + retryAfterMs?: number; + utilization?: number; + } + | { + kind: 'concurrency_adjusted'; + provider: RateLimitProvider; + from: number; + to: number; + reason: string; + rateLimitType?: string; + utilization?: number; + } + | { + kind: 'wait_started' | 'wait_tick' | 'wait_finished'; + provider: RateLimitProvider; + rateLimitType?: string; + resumeAtMs: number; + remainingMs: number; + }; + +export interface RateLimitGovernorDeps { + now?: () => number; + sleep?: (ms: number, signal?: AbortSignal) => Promise; + random?: () => number; +} + +export type RateLimitRelease = () => void; +type Subscriber = (state: RateLimitWaitState) => void; + +const defaultSleep = (ms: number, signal?: AbortSignal): Promise => + new Promise((resolve, reject) => { + if (signal?.aborted) { + reject(createAbortError()); + return; + } + const timeout = setTimeout(resolve, ms); + signal?.addEventListener( + 'abort', + () => { + clearTimeout(timeout); + reject(createAbortError()); + }, + { once: true }, + ); + }); + +export function createRateLimitGovernorConfig( + input: Partial & { retry?: Partial } = {}, +): RateLimitGovernorConfig { + return { + enabled: input.enabled ?? true, + maxConcurrency: input.maxConcurrency ?? 1, + throttleThreshold: input.throttleThreshold ?? 0.8, + minConcurrencyUnderPressure: input.minConcurrencyUnderPressure ?? 1, + ...(input.maxWaitMs !== undefined ? { maxWaitMs: input.maxWaitMs } : {}), + waitStateTickMs: input.waitStateTickMs ?? 1_000, + retry: { + maxAttempts: input.retry?.maxAttempts ?? 6, + baseDelayMs: input.retry?.baseDelayMs ?? 1_000, + maxDelayMs: input.retry?.maxDelayMs ?? 60_000, + jitter: input.retry?.jitter ?? true, + }, + }; +} + +export class RateLimitGovernor { + private readonly now: () => number; + private readonly sleep: (ms: number, signal?: AbortSignal) => Promise; + private readonly random: () => number; + private readonly subscribers = new Set(); + private waiters: Array<() => void> = []; + private active = 0; + private effectiveLimit: number; + private pausedUntilMs: number | null = null; + private pausedProvider: RateLimitProvider | null = null; + private pausedRateLimitType: string | undefined; + private pausedTickMs: number | null = null; + private opaqueAttempts = new Map(); + private pauseGeneration = 0; + private visibleWaitAbort: AbortController | null = null; + + constructor( + private readonly config: RateLimitGovernorConfig, + deps: RateLimitGovernorDeps = {}, + ) { + this.now = deps.now ?? Date.now; + this.sleep = deps.sleep ?? defaultSleep; + this.random = deps.random ?? Math.random; + this.effectiveLimit = Math.max(1, config.maxConcurrency); + } + + currentLimit(): number { + return this.config.enabled ? this.effectiveLimit : this.config.maxConcurrency; + } + + /** + * Total attempts a runtime should make for a single rate-limited LLM call, + * including the first try. Returns 1 (no outer retry) when pacing is disabled: + * the outer retry loop only exists to cooperate with this governor's pause, so + * without active pacing there is no backoff to apply and the backend's own + * retry handles transient rejections. + */ + maxRetryAttempts(): number { + return this.config.enabled ? Math.max(1, this.config.retry.maxAttempts) : 1; + } + + activeSlots(): number { + return this.active; + } + + subscribe(cb: Subscriber): () => void { + this.subscribers.add(cb); + if (this.pausedUntilMs !== null) { + this.startVisibleWaitTicker(); + } + return () => { + this.subscribers.delete(cb); + if (this.subscribers.size === 0) { + this.stopVisibleWaitTicker(); + this.wakeWaiters(); + } + }; + } + + report(signal: RateLimitSignal): void { + if (!this.config.enabled) { + return; + } + this.emit({ + kind: 'rate_limit_observed', + provider: signal.provider, + status: signal.status, + ...(signal.rateLimitType ? { rateLimitType: signal.rateLimitType } : {}), + ...(signal.resetAtMs !== undefined ? { resetAtMs: signal.resetAtMs } : {}), + ...(signal.retryAfterMs !== undefined ? { retryAfterMs: signal.retryAfterMs } : {}), + ...(signal.utilization !== undefined ? { utilization: signal.utilization } : {}), + }); + + if (signal.status === 'rejected') { + this.applyPause(signal); + return; + } + + if (signal.status === 'warning' || (signal.utilization ?? 0) >= this.config.throttleThreshold) { + this.adjustLimit(Math.max(1, this.config.minConcurrencyUnderPressure), signal, 'provider pressure'); + return; + } + + this.opaqueAttempts.delete(signal.provider); + if ((signal.utilization ?? 0) < this.config.throttleThreshold) { + this.adjustLimit(Math.max(1, this.config.maxConcurrency), signal, 'provider recovered'); + } + } + + async waitForReady(signal?: AbortSignal): Promise { + throwIfAborted(signal); + if (!this.config.enabled) { + return; + } + await this.waitForPause(signal); + throwIfAborted(signal); + } + + async acquireWorkSlot(signal?: AbortSignal): Promise { + throwIfAborted(signal); + if (!this.config.enabled) { + this.active += 1; + return () => { + this.active -= 1; + }; + } + + while (true) { + throwIfAborted(signal); + await this.waitForPause(signal); + throwIfAborted(signal); + if (this.active < this.effectiveLimit) { + this.active += 1; + let released = false; + return () => { + if (released) return; + released = true; + this.active -= 1; + this.wakeWaiters(); + }; + } + await this.waitForSlot(signal); + } + } + + private applyPause(signal: RateLimitSignal): void { + const resumeAtMs = this.resumeAtMsFor(signal); + const boundedResumeAtMs = + this.config.maxWaitMs === undefined ? resumeAtMs : Math.min(resumeAtMs, this.now() + this.config.maxWaitMs); + if (this.pausedUntilMs === null || boundedResumeAtMs > this.pausedUntilMs) { + this.pausedUntilMs = boundedResumeAtMs; + this.pausedProvider = signal.provider; + this.pausedRateLimitType = signal.rateLimitType; + this.pausedTickMs = signal.rateLimitType === 'opaque' ? Math.max(1, boundedResumeAtMs - this.now()) : null; + this.emitWait('wait_started'); + this.startVisibleWaitTicker(); + this.wakeWaiters(); + } + this.adjustLimit(Math.max(1, this.config.minConcurrencyUnderPressure), signal, 'provider rejected'); + } + + private resumeAtMsFor(signal: RateLimitSignal): number { + if (signal.resetAtMs !== undefined) { + return signal.resetAtMs; + } + if (signal.retryAfterMs !== undefined) { + return this.now() + signal.retryAfterMs; + } + const attempts = this.opaqueAttempts.get(signal.provider) ?? 0; + this.opaqueAttempts.set(signal.provider, Math.min(attempts + 1, this.config.retry.maxAttempts)); + const base = Math.min( + this.config.retry.maxDelayMs, + this.config.retry.baseDelayMs * 2 ** Math.min(attempts, this.config.retry.maxAttempts - 1), + ); + const jitterMultiplier = this.config.retry.jitter ? 0.75 + this.random() * 0.5 : 1; + return this.now() + Math.round(base * jitterMultiplier); + } + + private adjustLimit(to: number, signal: RateLimitSignal, reason: string): void { + const bounded = Math.max(1, Math.min(this.config.maxConcurrency, to)); + if (bounded === this.effectiveLimit) { + return; + } + const from = this.effectiveLimit; + this.effectiveLimit = bounded; + this.emit({ + kind: 'concurrency_adjusted', + provider: signal.provider, + from, + to: bounded, + reason, + ...(signal.rateLimitType ? { rateLimitType: signal.rateLimitType } : {}), + ...(signal.utilization !== undefined ? { utilization: signal.utilization } : {}), + }); + this.wakeWaiters(); + } + + private startVisibleWaitTicker(): void { + if (this.subscribers.size === 0 || this.pausedUntilMs === null) { + return; + } + this.stopVisibleWaitTicker(); + const generation = (this.pauseGeneration += 1); + const controller = new AbortController(); + this.visibleWaitAbort = controller; + void this.runVisibleWaitTicker(generation, controller.signal).catch(() => undefined); + } + + private stopVisibleWaitTicker(): void { + this.visibleWaitAbort?.abort(); + this.visibleWaitAbort = null; + } + + private async runVisibleWaitTicker(generation: number, signal: AbortSignal): Promise { + while (!signal.aborted && generation === this.pauseGeneration && this.pausedUntilMs !== null) { + const remainingMs = this.pausedUntilMs - this.now(); + if (remainingMs <= 0) { + this.finishPause(generation); + return; + } + this.emitWait('wait_tick'); + await this.sleep(Math.min(this.pausedTickMs ?? this.config.waitStateTickMs, remainingMs), signal); + } + } + + private finishPause(generation?: number): void { + if (generation !== undefined && generation !== this.pauseGeneration) { + return; + } + this.emitWait('wait_finished'); + this.pausedUntilMs = null; + this.pausedProvider = null; + this.pausedRateLimitType = undefined; + this.pausedTickMs = null; + this.stopVisibleWaitTicker(); + this.wakeWaiters(); + } + + private async waitForPause(signal?: AbortSignal): Promise { + throwIfAborted(signal); + while (this.pausedUntilMs !== null) { + const remainingMs = this.pausedUntilMs - this.now(); + if (remainingMs <= 0) { + this.finishPause(); + return; + } + if (this.visibleWaitAbort !== null) { + await this.waitForSlot(signal); + } else { + await this.sleep(Math.min(this.pausedTickMs ?? this.config.waitStateTickMs, remainingMs), signal); + } + throwIfAborted(signal); + } + } + + private waitForSlot(signal?: AbortSignal): Promise { + if (signal?.aborted) { + return Promise.reject(createAbortError()); + } + return new Promise((resolve, reject) => { + const wake = () => { + cleanup(); + resolve(); + }; + const onAbort = () => { + cleanup(); + reject(createAbortError()); + }; + const cleanup = () => { + this.waiters = this.waiters.filter((candidate) => candidate !== wake); + signal?.removeEventListener('abort', onAbort); + }; + this.waiters.push(wake); + signal?.addEventListener('abort', onAbort, { once: true }); + }); + } + + private wakeWaiters(): void { + const waiters = this.waiters; + this.waiters = []; + for (const waiter of waiters) { + waiter(); + } + } + + private emitWait(kind: Extract): void { + if (this.pausedUntilMs === null || this.pausedProvider === null) { + return; + } + this.emit({ + kind, + provider: this.pausedProvider, + ...(this.pausedRateLimitType ? { rateLimitType: this.pausedRateLimitType } : {}), + resumeAtMs: this.pausedUntilMs, + remainingMs: Math.max(0, this.pausedUntilMs - this.now()), + }); + } + + private emit(state: RateLimitWaitState): void { + for (const subscriber of this.subscribers) { + subscriber(state); + } + } +} diff --git a/packages/cli/src/context/llm/runtime-port.ts b/packages/cli/src/context/llm/runtime-port.ts index db648448..9fec6208 100644 --- a/packages/cli/src/context/llm/runtime-port.ts +++ b/packages/cli/src/context/llm/runtime-port.ts @@ -49,6 +49,7 @@ export interface RunLoopParams { stepBudget: number; telemetryTags: Record; onStepFinish?: (info: RunLoopStepInfo) => void | Promise; + abortSignal?: AbortSignal; } export interface RunLoopResult { @@ -64,6 +65,7 @@ export interface KtxGenerateTextInput { tools?: KtxRuntimeToolSet; temperature?: number; onMetrics?: (metrics: { totalMs: number; usage: LlmTokenUsage }) => void; + abortSignal?: AbortSignal; } export interface KtxGenerateObjectInput> { @@ -74,6 +76,7 @@ export interface KtxGenerateObjectInput void; + abortSignal?: AbortSignal; } export interface KtxLlmRuntimePort { diff --git a/packages/cli/src/context/project/config.ts b/packages/cli/src/context/project/config.ts index cbea79b6..fd7f482c 100644 --- a/packages/cli/src/context/project/config.ts +++ b/packages/cli/src/context/project/config.ts @@ -100,6 +100,44 @@ const workUnitsSchema = z }) .describe('Concurrency and failure handling for ingest work units.'); +const ingestRateLimitRetrySchema = z + .strictObject({ + maxAttempts: z + .int() + .positive() + .default(6) + .describe( + 'Maximum attempts for a single rate-limited LLM call before the failure surfaces, counting the first try. Also bounds how far opaque backoff grows for providers that do not expose a reset time.', + ), + baseDelayMs: z.int().positive().default(1_000).describe('Initial opaque retry delay in milliseconds.'), + maxDelayMs: z.int().positive().default(60_000).describe('Maximum opaque retry delay in milliseconds.'), + jitter: z.boolean().default(true).describe('When true, apply bounded jitter to opaque retry delays.'), + }) + .describe('Retry policy for rate-limit responses that do not include a reset time or retry-after value.'); + +const ingestRateLimitSchema = z + .strictObject({ + enabled: z.boolean().default(true).describe('Master switch for ingest LLM rate-limit pacing and visible waits.'), + throttleThreshold: z + .number() + .min(0) + .max(1) + .default(0.8) + .describe('Provider utilization at or above which ingest throttles new work-unit starts.'), + minConcurrencyUnderPressure: z + .int() + .positive() + .default(1) + .describe('Effective work-unit concurrency while a provider is under rate-limit pressure.'), + maxWaitMs: z + .int() + .positive() + .optional() + .describe('Optional cap on a single provider reset wait. Omit to wait indefinitely until the provider reset time.'), + retry: ingestRateLimitRetrySchema.prefault({}).describe('Opaque retry policy for providers without reset hints.'), + }) + .describe('Rate-limit pacing and wait policy for ingest LLM calls.'); + const ingestSchema = z .strictObject({ adapters: z @@ -110,6 +148,7 @@ const ingestSchema = z .prefault({ backend: 'none' }) .describe('Embedding configuration used when ingest adapters need to embed documents.'), workUnits: workUnitsSchema.prefault({}).describe('Concurrency and failure handling for ingest work units.'), + rateLimit: ingestRateLimitSchema.prefault({}).describe('LLM rate-limit pacing and visible-wait policy for ingest.'), profile: z .union([z.boolean(), z.literal('json')]) .default(false) diff --git a/packages/cli/src/ingest.ts b/packages/cli/src/ingest.ts index ad5ba270..319c3d1b 100644 --- a/packages/cli/src/ingest.ts +++ b/packages/cli/src/ingest.ts @@ -78,6 +78,7 @@ export interface KtxIngestDeps { readReportFile?: typeof readIngestReportSnapshotFile; renderStoredMemoryFlow?: typeof renderMemoryFlowTui; startLiveMemoryFlow?: typeof startLiveMemoryFlowTui; + abortSignal?: AbortSignal; env?: NodeJS.ProcessEnv; localIngestOptions?: Pick< RunLocalIngestOptions, @@ -93,6 +94,23 @@ export interface KtxIngestDeps { runtimeIo?: KtxIngestIo; } +function createCliAbortSignal(): { signal: AbortSignal; dispose: () => void } { + const controller = new AbortController(); + let interrupted = false; + const onSigint = () => { + if (interrupted) { + process.exit(130); + } + interrupted = true; + controller.abort(new DOMException('Aborted', 'AbortError')); + }; + process.on('SIGINT', onSigint); + return { + signal: controller.signal, + dispose: () => process.off('SIGINT', onSigint), + }; +} + const REPORT_SOURCE_LABELS = new Map([ ['live-database', 'Database schema'], ['historic-sql', 'Query history'], @@ -364,6 +382,12 @@ function plainIngestEventProgress( message: event.message, ...(event.transient !== undefined ? { transient: event.transient } : {}), }; + case 'rate_limit_wait': + return { + percent: 50, + message: `Rate-limited (${event.provider}${event.rateLimitType ? ` ${event.rateLimitType}` : ''}); resuming in ${Math.ceil(event.remainingMs / 1_000)}s`, + transient: true, + }; case 'work_unit_started': { const total = plannedWorkUnitCountThrough(snapshot, eventIndex); const ordinal = workUnitOrdinalThrough(snapshot, eventIndex, event.unitKey); @@ -750,6 +774,8 @@ export async function runKtxIngest( ); plainProgress?.start(); structuredProgress?.start(); + const cliAbort = deps.abortSignal ? null : createCliAbortSignal(); + const abortSignal = deps.abortSignal ?? cliAbort?.signal; let result: LocalMetabaseFanoutResult; try { result = await executeMetabaseFanout({ @@ -763,6 +789,7 @@ export async function runKtxIngest( embeddingProvider, ...(memoryFlow ? { memoryFlow } : {}), ...(progress ? { progress } : {}), + ...(abortSignal ? { abortSignal } : {}), }); plainProgress?.flush(); if (args.outputMode === 'json') { @@ -772,6 +799,7 @@ export async function runKtxIngest( } } finally { plainProgress?.flush(); + cliAbort?.dispose(); } return result.status === 'all_failed' ? 1 : 0; } @@ -820,6 +848,8 @@ export async function runKtxIngest( plainProgress?.start(); structuredProgress?.start(); + const cliAbort = deps.abortSignal ? null : createCliAbortSignal(); + const abortSignal = deps.abortSignal ?? cliAbort?.signal; try { const result = await executeLocalIngest({ @@ -836,6 +866,7 @@ export async function runKtxIngest( embeddingProvider, ...(args.debugLlmRequestFile ? { llmDebugRequestFile: args.debugLlmRequestFile } : {}), ...(memoryFlow ? { memoryFlow } : {}), + ...(abortSignal ? { abortSignal } : {}), }); if (shouldUseLiveViz && memoryFlow) { latestMemoryFlowSnapshot = finalRunMemoryFlowInput(memoryFlow.snapshot(), result.report); @@ -854,6 +885,7 @@ export async function runKtxIngest( } finally { plainProgress?.flush(); liveTui?.close(); + cliAbort?.dispose(); } } diff --git a/packages/cli/test/context/core/abort.test.ts b/packages/cli/test/context/core/abort.test.ts new file mode 100644 index 00000000..aed46c1e --- /dev/null +++ b/packages/cli/test/context/core/abort.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it, vi } from 'vitest'; +import { createAbortError, isAbortError, linkAbortSignal, throwIfAborted } from '../../../src/context/core/abort.js'; + +describe('abort helpers', () => { + it('recognizes DOMException abort errors and common abort-shaped errors', () => { + expect(isAbortError(createAbortError())).toBe(true); + expect(isAbortError(Object.assign(new Error('cancelled'), { name: 'AbortError' }))).toBe(true); + expect(isAbortError(Object.assign(new Error('operation aborted'), { code: 'ABORT_ERR' }))).toBe(true); + expect(isAbortError(new Error('ordinary failure'))).toBe(false); + }); + + it('throws when the provided signal is already aborted', () => { + const controller = new AbortController(); + controller.abort(); + + expect(() => throwIfAborted(controller.signal)).toThrow(/Aborted/); + }); + + it('links a child controller to a parent signal and removes the listener on dispose', () => { + const parent = new AbortController(); + const child = linkAbortSignal(parent.signal); + + expect(child.controller.signal.aborted).toBe(false); + parent.abort(); + expect(child.controller.signal.aborted).toBe(true); + + const removeSpy = vi.spyOn(parent.signal, 'removeEventListener'); + child.dispose(); + expect(removeSpy).toHaveBeenCalledWith('abort', expect.any(Function)); + }); +}); diff --git a/packages/cli/test/context/ingest/ingest-bundle.runner.test.ts b/packages/cli/test/context/ingest/ingest-bundle.runner.test.ts index 447cd01e..b491acf2 100644 --- a/packages/cli/test/context/ingest/ingest-bundle.runner.test.ts +++ b/packages/cli/test/context/ingest/ingest-bundle.runner.test.ts @@ -426,6 +426,177 @@ describe('IngestBundleRunner — Stages 1 → 7', () => { ); }); + it('uses the rate-limit governor for work-unit start slots', async () => { + const deps = makeDeps(); + const acquireWorkSlot = vi.fn(async () => vi.fn()); + const runner = buildRunner(deps, { + settings: { + probeRowCount: 1, + memoryIngestionModel: 'test-model', + workUnitMaxConcurrency: 2, + rateLimitGovernor: { acquireWorkSlot, subscribe: vi.fn(() => vi.fn()) } as never, + }, + }); + deps.adapter.chunk.mockResolvedValue({ + workUnits: [ + { unitKey: 'u1', rawFiles: ['a.yml'], peerFileIndex: [], dependencyPaths: [] }, + { unitKey: 'u2', rawFiles: ['b.yml'], peerFileIndex: [], dependencyPaths: [] }, + ], + }); + (runner as any).stageRawFilesStage1 = vi.fn().mockResolvedValue({ + currentHashes: new Map([ + ['a.yml', 'h1'], + ['b.yml', 'h2'], + ]), + rawDirInWorktree: 'raw-sources/c1/fake/s', + }); + (runner as any).resolveStagedDir = vi.fn().mockResolvedValue('/tmp/stage/upload-x'); + + await runner.run({ + jobId: 'j1', + connectionId: 'c1', + sourceKey: 'fake', + trigger: 'upload', + bundleRef: { kind: 'upload', uploadId: 'upload-x' }, + }); + + expect(acquireWorkSlot).toHaveBeenCalledTimes(2); + }); + + it('passes the job abort signal into rate-limit work-unit slots', async () => { + const deps = makeDeps(); + const controller = new AbortController(); + const acquireWorkSlot = vi.fn(async () => vi.fn()); + const runner = buildRunner(deps, { + settings: { + probeRowCount: 1, + memoryIngestionModel: 'test-model', + workUnitMaxConcurrency: 1, + rateLimitGovernor: { acquireWorkSlot, subscribe: vi.fn(() => vi.fn()) } as never, + }, + }); + (runner as any).stageRawFilesStage1 = vi.fn().mockResolvedValue({ + currentHashes: new Map([['a.yml', 'h1']]), + rawDirInWorktree: 'raw-sources/c1/fake/s', + }); + (runner as any).resolveStagedDir = vi.fn().mockResolvedValue('/tmp/stage/upload-x'); + + await runner.run( + { + jobId: 'j1', + connectionId: 'c1', + sourceKey: 'fake', + trigger: 'upload', + bundleRef: { kind: 'upload', uploadId: 'upload-x' }, + }, + { jobId: 'j1', abortSignal: controller.signal, startPhase: () => new TestJobContext('j1', null, async () => undefined, async () => undefined) } as any, + ); + + expect(acquireWorkSlot).toHaveBeenCalledWith(controller.signal); + }); + + it('does not convert aborted work-unit agent loops into failed work units', async () => { + const deps = makeDeps(); + const controller = new AbortController(); + deps.agentRunner.runLoop.mockImplementation(async () => { + controller.abort(); + throw new DOMException('Aborted', 'AbortError'); + }); + const runner = buildRunner(deps, { + settings: { + probeRowCount: 1, + memoryIngestionModel: 'test-model', + workUnitMaxConcurrency: 1, + }, + }); + (runner as any).stageRawFilesStage1 = vi.fn().mockResolvedValue({ + currentHashes: new Map([['a.yml', 'h1']]), + rawDirInWorktree: 'raw-sources/c1/fake/s', + }); + (runner as any).resolveStagedDir = vi.fn().mockResolvedValue('/tmp/stage/upload-x'); + + await expect( + runner.run( + { + jobId: 'j1', + connectionId: 'c1', + sourceKey: 'fake', + trigger: 'upload', + bundleRef: { kind: 'upload', uploadId: 'upload-x' }, + }, + { jobId: 'j1', abortSignal: controller.signal, startPhase: () => new TestJobContext('j1', null, async () => undefined, async () => undefined) } as any, + ), + ).rejects.toThrow(/Aborted/); + + expect(deps.runsRepo.markFailed).toHaveBeenCalledWith('run-1'); + expect(deps.reportsRepo.create).not.toHaveBeenCalledWith( + expect.objectContaining({ + body: expect.objectContaining({ + failedWorkUnits: expect.arrayContaining(['u1']), + }), + }), + ); + }); + + it('emits trace and memory-flow status for rate-limit waits', async () => { + const deps = makeDeps(); + let subscriber: ((state: any) => void) | undefined; + const memoryFlow = createMemoryFlowLiveBuffer(bundleReplayInput()); + const runner = buildRunner(deps, { + settings: { + probeRowCount: 1, + memoryIngestionModel: 'test-model', + rateLimitGovernor: { + acquireWorkSlot: vi.fn(async () => vi.fn()), + subscribe: vi.fn((cb: (state: any) => void) => { + subscriber = cb; + return vi.fn(); + }), + } as never, + }, + }); + (runner as any).runInner = async (_job: any, ctx: any) => { + subscriber?.({ + kind: 'wait_tick', + provider: 'claude-subscription', + rateLimitType: 'five_hour', + resumeAtMs: 2_000, + remainingMs: 1_000, + }); + ctx.memoryFlow.emit({ type: 'report_created', runId: 'run-1' }); + return { + runId: 'run-1', + syncId: 'sync-1', + diffSummary: { added: 0, modified: 0, deleted: 0, unchanged: 0 }, + workUnitCount: 0, + failedWorkUnits: [], + artifactsWritten: 0, + commitSha: null, + }; + }; + + await runner.run( + { + jobId: 'j1', + connectionId: 'c1', + sourceKey: 'fake', + trigger: 'upload', + bundleRef: { kind: 'upload', uploadId: 'upload-x' }, + }, + { memoryFlow } as any, + ); + + expect(memoryFlow.snapshot().events).toContainEqual( + expect.objectContaining({ + type: 'rate_limit_wait', + provider: 'claude-subscription', + rateLimitType: 'five_hour', + resumeAtMs: 2_000, + remainingMs: 1_000, + }), + ); + }); + it('fails before squash when reconciliation leaves a touched wiki page with dangling refs', async () => { const deps = makeDeps(); let currentToolSession: any = null; diff --git a/packages/cli/test/context/ingest/local-bundle-runtime.test.ts b/packages/cli/test/context/ingest/local-bundle-runtime.test.ts index 9d1ec9b4..e3031cc5 100644 --- a/packages/cli/test/context/ingest/local-bundle-runtime.test.ts +++ b/packages/cli/test/context/ingest/local-bundle-runtime.test.ts @@ -301,6 +301,7 @@ describe('createLocalBundleIngestRuntime', () => { 'memoryIngestionModel', 'probeRowCount', 'profileIngest', + 'rateLimitGovernor', 'workUnitFailureMode', 'workUnitMaxConcurrency', 'workUnitStepBudget', diff --git a/packages/cli/test/context/ingest/memory-flow/schema.test.ts b/packages/cli/test/context/ingest/memory-flow/schema.test.ts index 1aaeec4b..ee8f3bb9 100644 --- a/packages/cli/test/context/ingest/memory-flow/schema.test.ts +++ b/packages/cli/test/context/ingest/memory-flow/schema.test.ts @@ -146,6 +146,29 @@ describe('memory-flow schemas', () => { expect(parsed.events).toContainEqual({ type: 'stage_skipped', stage: 'actions', reason: 'requires LLM' }); }); + it('accepts rate-limit wait replay events', () => { + expect( + memoryFlowReplayInputSchema.parse({ + ...snapshot(), + events: [ + { + type: 'rate_limit_wait', + provider: 'claude-subscription', + rateLimitType: 'five_hour', + resumeAtMs: 2_000, + remainingMs: 1_000, + }, + ], + }).events[0], + ).toEqual({ + type: 'rate_limit_wait', + provider: 'claude-subscription', + rateLimitType: 'five_hour', + resumeAtMs: 2_000, + remainingMs: 1_000, + }); + }); + it('parses snapshot and closed stream events', () => { expect(memoryFlowStreamEventSchema.parse({ type: 'snapshot', snapshot: snapshot({ status: 'done' }) })).toEqual({ type: 'snapshot', diff --git a/packages/cli/test/context/llm/ai-sdk-runtime.test.ts b/packages/cli/test/context/llm/ai-sdk-runtime.test.ts index 74987094..bab7d1d7 100644 --- a/packages/cli/test/context/llm/ai-sdk-runtime.test.ts +++ b/packages/cli/test/context/llm/ai-sdk-runtime.test.ts @@ -107,6 +107,199 @@ describe('AiSdkKtxLlmRuntime.runAgentLoop', () => { expect(result.error).toBe(err); }); + it('reports AI SDK retry-after rate limits and retries through the governor', async () => { + const waitForReady = vi.fn().mockResolvedValue(undefined); + const report = vi.fn(); + const rateLimitError = Object.assign(new Error('too many requests'), { + name: 'TooManyRequestsError', + retryAfter: 2, + statusCode: 429, + }); + (generateText as any).mockRejectedValueOnce(rateLimitError).mockResolvedValueOnce({ + text: 'done', + toolCalls: [], + steps: [], + usage: { inputTokens: 1, outputTokens: 1, totalTokens: 2 }, + }); + const runtime = new AiSdkKtxLlmRuntime({ + llmProvider: llmProvider as any, + rateLimitGovernor: { waitForReady, report, maxRetryAttempts: () => 6 } as never, + }); + + const result = await runtime.runAgentLoop({ + modelRole: 'candidateExtraction', + systemPrompt: '', + userPrompt: '', + toolSet: {}, + stepBudget: 10, + telemetryTags: {}, + }); + + expect(result.stopReason).toBe('natural'); + expect(report).toHaveBeenCalledWith({ + provider: 'anthropic-api', + status: 'rejected', + retryAfterMs: 2_000, + rateLimitType: 'http_429', + }); + expect(waitForReady).toHaveBeenCalledTimes(2); + expect(generateText).toHaveBeenCalledTimes(2); + }); + + it('does not retry AI SDK rate limits without a governor', async () => { + const rateLimitError = Object.assign(new Error('too many requests'), { + name: 'TooManyRequestsError', + statusCode: 429, + }); + (generateText as any).mockRejectedValue(rateLimitError); + // The beforeEach runtime is constructed without a rateLimitGovernor. + + const result = await runtime.runAgentLoop({ + modelRole: 'candidateExtraction', + systemPrompt: '', + userPrompt: '', + toolSet: {}, + stepBudget: 10, + telemetryTags: {}, + }); + + expect(result.stopReason).toBe('error'); + expect(generateText).toHaveBeenCalledTimes(1); + }); + + it('honors a governor retry budget of one attempt without retrying', async () => { + const waitForReady = vi.fn().mockResolvedValue(undefined); + const report = vi.fn(); + const rateLimitError = Object.assign(new Error('too many requests'), { + name: 'TooManyRequestsError', + statusCode: 429, + }); + (generateText as any).mockRejectedValue(rateLimitError); + const runtime = new AiSdkKtxLlmRuntime({ + llmProvider: llmProvider as any, + rateLimitGovernor: { waitForReady, report, maxRetryAttempts: () => 1 } as never, + }); + + const result = await runtime.runAgentLoop({ + modelRole: 'candidateExtraction', + systemPrompt: '', + userPrompt: '', + toolSet: {}, + stepBudget: 10, + telemetryTags: {}, + }); + + expect(result.stopReason).toBe('error'); + expect(generateText).toHaveBeenCalledTimes(1); + expect(report).not.toHaveBeenCalled(); + }); + + it('reports Anthropic API response-header utilization to the governor', async () => { + const waitForReady = vi.fn().mockResolvedValue(undefined); + const report = vi.fn(); + (generateText as any).mockResolvedValue({ + text: 'done', + toolCalls: [], + steps: [], + response: { + headers: { + 'anthropic-ratelimit-requests-limit': '100', + 'anthropic-ratelimit-requests-remaining': '8', + 'anthropic-ratelimit-input-tokens-limit': '10000', + 'anthropic-ratelimit-input-tokens-remaining': '9000', + }, + }, + }); + const runtime = new AiSdkKtxLlmRuntime({ + llmProvider: llmProvider as any, + rateLimitGovernor: { waitForReady, report, maxRetryAttempts: () => 6 } as never, + }); + + const result = await runtime.runAgentLoop({ + modelRole: 'candidateExtraction', + systemPrompt: '', + userPrompt: '', + toolSet: {}, + stepBudget: 10, + telemetryTags: {}, + }); + + expect(result.stopReason).toBe('natural'); + expect(report).toHaveBeenCalledWith({ + provider: 'anthropic-api', + status: 'allowed', + rateLimitType: 'rpm', + utilization: 0.92, + }); + }); + + it('reports generic x-ratelimit response-header utilization for Vertex providers', async () => { + const waitForReady = vi.fn().mockResolvedValue(undefined); + const report = vi.fn(); + const vertexProvider = { + ...llmProvider, + getModel: vi.fn().mockReturnValue({ modelId: 'gemini-3-pro', provider: 'google-vertex' }), + }; + (generateText as any).mockResolvedValue({ + text: 'done', + toolCalls: [], + steps: [], + response: { + headers: { + 'x-ratelimit-limit-requests': '200', + 'x-ratelimit-remaining-requests': '30', + 'x-ratelimit-limit-tokens': '100000', + 'x-ratelimit-remaining-tokens': '4000', + }, + }, + }); + const runtime = new AiSdkKtxLlmRuntime({ + llmProvider: vertexProvider as any, + rateLimitGovernor: { waitForReady, report, maxRetryAttempts: () => 6 } as never, + }); + + const result = await runtime.runAgentLoop({ + modelRole: 'candidateExtraction', + systemPrompt: '', + userPrompt: '', + toolSet: {}, + stepBudget: 10, + telemetryTags: {}, + }); + + expect(result.stopReason).toBe('natural'); + expect(report).toHaveBeenCalledWith({ + provider: 'vertex', + status: 'allowed', + rateLimitType: 'tpm', + utilization: 0.96, + }); + }); + + it('passes abort signals into governor waits and AI SDK generateText calls', async () => { + const controller = new AbortController(); + const waitForReady = vi.fn().mockResolvedValue(undefined); + (generateText as any).mockResolvedValue({ text: 'done', toolCalls: [], steps: [] }); + const runtime = new AiSdkKtxLlmRuntime({ + llmProvider: llmProvider as any, + rateLimitGovernor: { waitForReady, report: vi.fn(), maxRetryAttempts: () => 6 } as never, + }); + + const result = await runtime.runAgentLoop({ + modelRole: 'candidateExtraction', + systemPrompt: '', + userPrompt: '', + toolSet: {}, + stepBudget: 10, + telemetryTags: {}, + abortSignal: controller.signal, + }); + + expect(result.stopReason).toBe('natural'); + expect(waitForReady).toHaveBeenCalledWith(controller.signal); + expect((generateText as any).mock.calls[0][0].abortSignal).toBe(controller.signal); + }); + it('returns metrics with stepCount, per-step boundaries, and aggregate token usage', async () => { (generateText as any).mockImplementation(async (opts: any) => { await opts.onStepFinish({}); diff --git a/packages/cli/test/context/llm/claude-code-runtime.test.ts b/packages/cli/test/context/llm/claude-code-runtime.test.ts index 5c56c26c..ba83cde6 100644 --- a/packages/cli/test/context/llm/claude-code-runtime.test.ts +++ b/packages/cli/test/context/llm/claude-code-runtime.test.ts @@ -9,6 +9,14 @@ async function* stream(messages: SDKMessage[]): AsyncGenerator } } +function deferred() { + let resolve!: (value: T | PromiseLike) => void; + const promise = new Promise((innerResolve) => { + resolve = innerResolve; + }); + return { promise, resolve }; +} + function initMessage(overrides: Partial> = {}): Extract< SDKMessage, { type: 'system'; subtype: 'init' } @@ -91,6 +99,247 @@ describe('ClaudeCodeKtxLlmRuntime', () => { }); }); + it('waits before Claude Code text generation and reports rate-limit events', async () => { + const waitForReady = vi.fn().mockResolvedValue(undefined); + const report = vi.fn(); + const query = vi.fn((_input: any) => + stream([ + { + type: 'rate_limit_event', + rate_limit_info: { + status: 'allowed_warning', + resetsAt: new Date(2_000).toISOString(), + rateLimitType: 'five_hour', + utilization: 0.91, + }, + } as unknown as SDKMessage, + resultMessage({ result: 'ok' }), + ]), + ); + const runtime = new ClaudeCodeKtxLlmRuntime({ + projectDir: '/tmp/project', + modelSlots: { default: 'sonnet' }, + query, + env: {}, + rateLimitGovernor: { waitForReady, report, maxRetryAttempts: () => 6 } as never, + }); + + await expect(runtime.generateText({ role: 'default', prompt: 'hello' })).resolves.toBe('ok'); + expect(waitForReady).toHaveBeenCalledTimes(1); + expect(report).toHaveBeenCalledWith({ + provider: 'claude-subscription', + status: 'warning', + resetAtMs: 2_000, + rateLimitType: 'five_hour', + utilization: 0.91, + }); + }); + + it('maps numeric Claude Code reset times from SDK rate-limit events', async () => { + const report = vi.fn(); + const resetAtMs = 1_700_000_000_000; + const query = vi.fn((_input: any) => + stream([ + { + type: 'rate_limit_event', + rate_limit_info: { + status: 'rejected', + resetsAt: resetAtMs, + rateLimitType: 'five_hour', + utilization: 1, + }, + } as unknown as SDKMessage, + resultMessage({ result: 'ok' }), + ]), + ); + const runtime = new ClaudeCodeKtxLlmRuntime({ + projectDir: '/tmp/project', + modelSlots: { default: 'sonnet' }, + query, + env: {}, + rateLimitGovernor: { waitForReady: vi.fn().mockResolvedValue(undefined), report, maxRetryAttempts: () => 6 } as never, + }); + + await expect(runtime.generateText({ role: 'default', prompt: 'hello' })).resolves.toBe('ok'); + + expect(report).toHaveBeenCalledWith({ + provider: 'claude-subscription', + status: 'rejected', + resetAtMs, + rateLimitType: 'five_hour', + utilization: 1, + }); + }); + + it('retries a Claude Code query after an SDK rate-limit result error', async () => { + const waitForReady = vi.fn().mockResolvedValue(undefined); + const report = vi.fn(); + const resetAtMs = 1_700_000_000_000; + const query = vi + .fn() + .mockReturnValueOnce( + stream([ + { + type: 'rate_limit_event', + rate_limit_info: { + status: 'rejected', + resetsAt: resetAtMs, + rateLimitType: 'five_hour', + utilization: 1, + }, + } as unknown as SDKMessage, + resultMessage({ + subtype: 'error_during_execution', + is_error: true, + result: '', + errors: ['rate limit retry budget exhausted'], + terminal_reason: 'model_error', + } as never), + ]), + ) + .mockReturnValueOnce(stream([resultMessage({ result: 'ok' })])); + const runtime = new ClaudeCodeKtxLlmRuntime({ + projectDir: '/tmp/project', + modelSlots: { default: 'sonnet' }, + query, + env: {}, + rateLimitGovernor: { waitForReady, report, maxRetryAttempts: () => 6 } as never, + }); + + await expect(runtime.generateText({ role: 'default', prompt: 'hello' })).resolves.toBe('ok'); + + expect(query).toHaveBeenCalledTimes(2); + expect(waitForReady).toHaveBeenCalledTimes(2); + expect(report).toHaveBeenCalledWith({ + provider: 'claude-subscription', + status: 'rejected', + resetAtMs, + rateLimitType: 'five_hour', + utilization: 1, + }); + }); + + it('reports Claude Code api retry messages as warning signals', async () => { + const report = vi.fn(); + const query = vi.fn((_input: any) => + stream([ + { + type: 'system', + subtype: 'api_retry', + retry_delay_ms: 12_000, + } as unknown as SDKMessage, + resultMessage({ result: 'ok' }), + ]), + ); + const runtime = new ClaudeCodeKtxLlmRuntime({ + projectDir: '/tmp/project', + modelSlots: { default: 'sonnet' }, + query, + env: {}, + rateLimitGovernor: { waitForReady: vi.fn().mockResolvedValue(undefined), report, maxRetryAttempts: () => 6 } as never, + }); + + await runtime.generateText({ role: 'default', prompt: 'hello' }); + expect(report).toHaveBeenCalledWith({ + provider: 'claude-subscription', + status: 'warning', + retryAfterMs: 12_000, + rateLimitType: 'api_retry', + }); + }); + + it('passes abort signals into Claude Code governor waits', async () => { + const controller = new AbortController(); + const waitForReady = vi.fn().mockResolvedValue(undefined); + const query = vi.fn((_input: any) => stream([resultMessage({ result: 'ok' })])); + const runtime = new ClaudeCodeKtxLlmRuntime({ + projectDir: '/tmp/project', + modelSlots: { default: 'sonnet' }, + query, + env: {}, + rateLimitGovernor: { waitForReady, report: vi.fn(), maxRetryAttempts: () => 6 } as never, + }); + + await expect(runtime.generateText({ role: 'default', prompt: 'hello', abortSignal: controller.signal })).resolves.toBe('ok'); + + expect(waitForReady).toHaveBeenCalledWith(controller.signal); + }); + + it('interrupts an active Claude Code query when the abort signal fires', async () => { + const controller = new AbortController(); + const streamStarted = deferred(); + const releaseStream = deferred(); + const interrupt = vi.fn(() => releaseStream.resolve()); + const queryResult = { + async *[Symbol.asyncIterator]() { + streamStarted.resolve(); + await releaseStream.promise; + yield resultMessage({ result: 'ok' }); + }, + interrupt, + }; + const query = vi.fn(() => queryResult as never); + const runtime = new ClaudeCodeKtxLlmRuntime({ + projectDir: '/tmp/project', + modelSlots: { default: 'sonnet' }, + query, + env: {}, + rateLimitGovernor: { waitForReady: vi.fn().mockResolvedValue(undefined), report: vi.fn(), maxRetryAttempts: () => 6 } as never, + }); + + const pending = runtime.generateText({ role: 'default', prompt: 'hello', abortSignal: controller.signal }); + await streamStarted.promise; + controller.abort(); + + await expect(pending).rejects.toThrow(/Aborted/); + expect(interrupt).toHaveBeenCalledTimes(1); + }); + + it('throws abort before starting Claude Code query when the signal is already aborted', async () => { + const controller = new AbortController(); + controller.abort(); + const query = vi.fn((_input: any) => stream([resultMessage({ result: 'ok' })])); + const runtime = new ClaudeCodeKtxLlmRuntime({ + projectDir: '/tmp/project', + modelSlots: { default: 'sonnet' }, + query, + env: {}, + rateLimitGovernor: { waitForReady: vi.fn().mockResolvedValue(undefined), report: vi.fn(), maxRetryAttempts: () => 6 } as never, + }); + + await expect(runtime.generateText({ role: 'default', prompt: 'hello', abortSignal: controller.signal })).rejects.toThrow(/Aborted/); + expect(query).not.toHaveBeenCalled(); + }); + + it('treats an interrupted Claude Code stream with no result as abort', async () => { + const controller = new AbortController(); + const streamStarted = deferred(); + const releaseStream = deferred(); + const interrupt = vi.fn(() => releaseStream.resolve()); + const queryResult = { + async *[Symbol.asyncIterator]() { + streamStarted.resolve(); + await releaseStream.promise; + }, + interrupt, + }; + const query = vi.fn(() => queryResult as never); + const runtime = new ClaudeCodeKtxLlmRuntime({ + projectDir: '/tmp/project', + modelSlots: { default: 'sonnet' }, + query, + env: {}, + rateLimitGovernor: { waitForReady: vi.fn().mockResolvedValue(undefined), report: vi.fn(), maxRetryAttempts: () => 6 } as never, + }); + + const pending = runtime.generateText({ role: 'default', prompt: 'hello', abortSignal: controller.signal }); + await streamStarted.promise; + controller.abort(); + + await expect(pending).rejects.toThrow(/Aborted/); + expect(interrupt).toHaveBeenCalledTimes(1); + }); + it('validates structured output with the caller schema and whitelists the SDK StructuredOutput tool', async () => { const schema = z.object({ answer: z.string() }); const query = vi.fn((_input: any) => diff --git a/packages/cli/test/context/llm/codex-runtime.test.ts b/packages/cli/test/context/llm/codex-runtime.test.ts index 2d408543..4c3fcdfd 100644 --- a/packages/cli/test/context/llm/codex-runtime.test.ts +++ b/packages/cli/test/context/llm/codex-runtime.test.ts @@ -130,6 +130,150 @@ describe('CodexKtxLlmRuntime', () => { ).rejects.toThrow('Codex structured output failed validation'); }); + it('reports Codex rate-limit failures and retries with opaque backoff', async () => { + const waitForReady = vi.fn().mockResolvedValue(undefined); + const report = vi.fn(); + const fakeRunner = { + runStreamed: vi + .fn() + .mockResolvedValueOnce(events([{ type: 'turn.failed', error: { message: '429 rate limit exceeded' } }])) + .mockResolvedValueOnce( + events([ + { type: 'turn.started' }, + { type: 'item.completed', item: { type: 'agent_message', text: 'ok' } }, + { type: 'turn.completed' }, + ]), + ), + }; + const runtime = new CodexKtxLlmRuntime({ + projectDir: '/tmp/project', + modelSlots: { default: 'codex' }, + runner: fakeRunner, + rateLimitGovernor: { waitForReady, report, maxRetryAttempts: () => 6 } as never, + }); + + await expect(runtime.generateText({ role: 'default', prompt: 'hello' })).resolves.toBe('ok'); + expect(report).toHaveBeenCalledWith({ provider: 'codex', status: 'rejected', rateLimitType: 'opaque' }); + expect(waitForReady).toHaveBeenCalledTimes(2); + expect(fakeRunner.runStreamed).toHaveBeenCalledTimes(2); + }); + + it('reports thrown Codex rate-limit failures and retries with opaque backoff', async () => { + const waitForReady = vi.fn().mockResolvedValue(undefined); + const report = vi.fn(); + const fakeRunner = { + runStreamed: vi + .fn() + .mockRejectedValueOnce(new Error('ThreadError: 429 rate limit exceeded')) + .mockResolvedValueOnce( + events([ + { type: 'turn.started' }, + { type: 'item.completed', item: { type: 'agent_message', text: 'ok' } }, + { type: 'turn.completed' }, + ]), + ), + }; + const runtime = new CodexKtxLlmRuntime({ + projectDir: '/tmp/project', + modelSlots: { default: 'codex' }, + runner: fakeRunner, + rateLimitGovernor: { waitForReady, report, maxRetryAttempts: () => 6 } as never, + }); + + await expect(runtime.generateText({ role: 'default', prompt: 'hello' })).resolves.toBe('ok'); + + expect(report).toHaveBeenCalledWith({ provider: 'codex', status: 'rejected', rateLimitType: 'opaque' }); + expect(waitForReady).toHaveBeenCalledTimes(2); + expect(fakeRunner.runStreamed).toHaveBeenCalledTimes(2); + }); + + it('surfaces Codex rate-limit failures without retrying when no governor is present', async () => { + const fakeRunner = runner([{ type: 'turn.failed', error: { message: '429 rate limit exceeded' } }]); + const runtime = new CodexKtxLlmRuntime({ + projectDir: '/tmp/project', + modelSlots: { default: 'codex' }, + runner: fakeRunner, + }); + + await expect(runtime.generateText({ role: 'default', prompt: 'hello' })).rejects.toThrow(/rate limit/i); + expect(fakeRunner.runStreamed).toHaveBeenCalledTimes(1); + }); + + it('passes abort signals into Codex text generation and governor waits', async () => { + const controller = new AbortController(); + const waitForReady = vi.fn().mockResolvedValue(undefined); + let observedSignal: AbortSignal | undefined; + const fakeRunner = { + runStreamed: vi.fn(async (input: { signal?: AbortSignal }) => { + observedSignal = input.signal; + return events([ + { type: 'turn.started' }, + { type: 'item.completed', item: { type: 'agent_message', text: 'ok' } }, + { type: 'turn.completed' }, + ]); + }), + }; + const runtime = new CodexKtxLlmRuntime({ + projectDir: '/tmp/project', + modelSlots: { default: 'codex' }, + runner: fakeRunner, + rateLimitGovernor: { waitForReady, report: vi.fn(), maxRetryAttempts: () => 6 } as never, + }); + + await expect(runtime.generateText({ role: 'default', prompt: 'hello', abortSignal: controller.signal })).resolves.toBe('ok'); + + expect(waitForReady).toHaveBeenCalledWith(controller.signal); + expect(observedSignal).toBe(controller.signal); + }); + + it('links the parent abort signal into Codex agent-loop streamed runs', async () => { + const controller = new AbortController(); + let releaseStream!: () => void; + const streamRelease = new Promise((resolve) => { + releaseStream = resolve; + }); + let markRunnerCalled!: () => void; + const runnerCalled = new Promise((resolve) => { + markRunnerCalled = resolve; + }); + let observedSignal: AbortSignal | undefined; + const fakeRunner = { + runStreamed: vi.fn(async (input: { signal?: AbortSignal }) => { + observedSignal = input.signal; + markRunnerCalled(); + return (async function* () { + await streamRelease; + yield { type: 'turn.started' }; + yield { type: 'item.completed', item: { type: 'agent_message', text: 'ok' } }; + yield { type: 'turn.completed' }; + })(); + }), + }; + const runtime = new CodexKtxLlmRuntime({ + projectDir: '/tmp/project', + modelSlots: { default: 'codex' }, + runner: fakeRunner, + }); + + const pending = runtime.runAgentLoop({ + modelRole: 'default', + systemPrompt: '', + userPrompt: '', + toolSet: {}, + stepBudget: 10, + telemetryTags: {}, + abortSignal: controller.signal, + }); + + await runnerCalled; + expect(observedSignal).toBeDefined(); + expect(observedSignal).not.toBe(controller.signal); + controller.abort(); + expect(observedSignal?.aborted).toBe(true); + releaseStream(); + await expect(pending).resolves.toMatchObject({ stopReason: 'natural' }); + }); + it('starts and closes a temporary MCP server for tool-backed agent loops', async () => { const close = vi.fn(async () => undefined); const startMcpServer = vi.fn(async () => ({ diff --git a/packages/cli/test/context/llm/local-config.test.ts b/packages/cli/test/context/llm/local-config.test.ts index e153baaf..eed66261 100644 --- a/packages/cli/test/context/llm/local-config.test.ts +++ b/packages/cli/test/context/llm/local-config.test.ts @@ -7,6 +7,7 @@ import { import { createLocalKtxEmbeddingProviderFromConfig, createLocalKtxLlmProviderFromConfig, + createLocalKtxLlmRuntimeFromConfig, resolveLocalKtxEmbeddingConfig, resolveLocalKtxLlmConfig, } from '../../../src/context/llm/local-config.js'; @@ -129,6 +130,64 @@ describe('local KTX LLM config', () => { vertexFallbackTo5m: false, }); }); + + it('passes the rate-limit governor into created runtimes', () => { + const rateLimitGovernor = {} as never; + const createClaudeCodeRuntime = vi.fn(() => ({ + generateText: vi.fn(), + generateObject: vi.fn(), + runAgentLoop: vi.fn(), + })); + const createCodexRuntime = vi.fn(() => ({ + generateText: vi.fn(), + generateObject: vi.fn(), + runAgentLoop: vi.fn(), + })); + const createAiSdkRuntime = vi.fn(() => ({ + generateText: vi.fn(), + generateObject: vi.fn(), + runAgentLoop: vi.fn(), + })); + const createKtxLlmProvider = vi.fn(() => ({ + getModel: vi.fn(), + getModelByName: vi.fn(), + cacheMarker: vi.fn(), + repairToolCallHandler: vi.fn(), + thinkingProviderOptions: vi.fn(), + telemetryConfig: vi.fn(), + promptCachingConfig: vi.fn(), + activeBackend: vi.fn(() => 'anthropic'), + })); + + createLocalKtxLlmRuntimeFromConfig( + { + provider: { backend: 'claude-code' }, + models: { default: 'sonnet' }, + promptCaching: undefined, + }, + { projectDir: '/tmp/project', env: {}, rateLimitGovernor, createClaudeCodeRuntime }, + ); + createLocalKtxLlmRuntimeFromConfig( + { + provider: { backend: 'codex' }, + models: { default: 'codex' }, + promptCaching: undefined, + }, + { projectDir: '/tmp/project', env: {}, rateLimitGovernor, createCodexRuntime }, + ); + createLocalKtxLlmRuntimeFromConfig( + { + provider: { backend: 'anthropic' }, + models: { default: 'claude-sonnet-4-6' }, + promptCaching: undefined, + }, + { env: {}, rateLimitGovernor, createAiSdkRuntime, createKtxLlmProvider: createKtxLlmProvider as never }, + ); + + expect(createClaudeCodeRuntime).toHaveBeenCalledWith(expect.objectContaining({ rateLimitGovernor })); + expect(createCodexRuntime).toHaveBeenCalledWith(expect.objectContaining({ rateLimitGovernor })); + expect(createAiSdkRuntime).toHaveBeenCalledWith(expect.objectContaining({ rateLimitGovernor })); + }); }); describe('local KTX embedding config', () => { diff --git a/packages/cli/test/context/llm/rate-limit-governor.test.ts b/packages/cli/test/context/llm/rate-limit-governor.test.ts new file mode 100644 index 00000000..51fcba84 --- /dev/null +++ b/packages/cli/test/context/llm/rate-limit-governor.test.ts @@ -0,0 +1,278 @@ +import { describe, expect, it } from 'vitest'; +import { + createRateLimitGovernorConfig, + RateLimitGovernor, + type RateLimitWaitState, +} from '../../../src/context/llm/rate-limit-governor.js'; + +function testClock(startMs = 1_000) { + let nowMs = startMs; + return { + now: () => nowMs, + advance: (ms: number) => { + nowMs += ms; + }, + }; +} + +async function flushMicrotasks(turns = 10): Promise { + for (let i = 0; i < turns; i += 1) { + await Promise.resolve(); + } +} + +describe('RateLimitGovernor', () => { + it('drops and restores the effective work-unit limit from warning signals', () => { + const clock = testClock(); + const states: RateLimitWaitState[] = []; + const governor = new RateLimitGovernor( + createRateLimitGovernorConfig({ maxConcurrency: 6, minConcurrencyUnderPressure: 1 }), + { now: clock.now, sleep: async () => undefined, random: () => 0 }, + ); + governor.subscribe((state) => states.push(state)); + + expect(governor.currentLimit()).toBe(6); + governor.report({ + provider: 'claude-subscription', + status: 'warning', + utilization: 0.91, + rateLimitType: 'five_hour', + }); + expect(governor.currentLimit()).toBe(1); + governor.report({ + provider: 'claude-subscription', + status: 'allowed', + utilization: 0.2, + rateLimitType: 'five_hour', + }); + expect(governor.currentLimit()).toBe(6); + expect(states.map((state) => state.kind)).toContain('concurrency_adjusted'); + }); + + it('blocks work slots during a rejected reset window and emits wait states', async () => { + const clock = testClock(); + const states: RateLimitWaitState[] = []; + const sleeps: number[] = []; + const governor = new RateLimitGovernor( + createRateLimitGovernorConfig({ maxConcurrency: 2, waitStateTickMs: 100 }), + { + now: clock.now, + random: () => 0, + sleep: async (ms) => { + sleeps.push(ms); + clock.advance(ms); + }, + }, + ); + governor.subscribe((state) => states.push(state)); + + governor.report({ provider: 'anthropic-api', status: 'rejected', retryAfterMs: 250, rateLimitType: 'rpm' }); + const release = await governor.acquireWorkSlot(); + release(); + + expect(sleeps).toEqual([100, 100, 50]); + expect(states.some((state) => state.kind === 'wait_started' && state.provider === 'anthropic-api')).toBe(true); + expect(states.some((state) => state.kind === 'wait_finished' && state.provider === 'anthropic-api')).toBe(true); + }); + + it('rejects an interrupted wait without consuming a work slot', async () => { + const clock = testClock(); + let abortListener: (() => void) | undefined; + const governor = new RateLimitGovernor( + createRateLimitGovernorConfig({ maxConcurrency: 1, waitStateTickMs: 100 }), + { + now: clock.now, + random: () => 0, + sleep: async (_ms, signal) => + new Promise((_resolve, reject) => { + abortListener = () => reject(new DOMException('Aborted', 'AbortError')); + signal?.addEventListener('abort', abortListener, { once: true }); + }), + }, + ); + const controller = new AbortController(); + + governor.report({ + provider: 'claude-subscription', + status: 'rejected', + resetAtMs: 2_000, + rateLimitType: 'five_hour', + }); + const pending = governor.acquireWorkSlot(controller.signal); + controller.abort(); + abortListener?.(); + + await expect(pending).rejects.toThrow(/Aborted/); + expect(governor.activeSlots()).toBe(0); + }); + + it('rejects an already-aborted ready wait', async () => { + const governor = new RateLimitGovernor( + createRateLimitGovernorConfig({ maxConcurrency: 1 }), + { sleep: async () => undefined, random: () => 0 }, + ); + const controller = new AbortController(); + controller.abort(); + + await expect(governor.waitForReady(controller.signal)).rejects.toThrow(/Aborted/); + }); + + it('rejects an already-aborted work slot without consuming capacity', async () => { + const governor = new RateLimitGovernor( + createRateLimitGovernorConfig({ maxConcurrency: 1 }), + { sleep: async () => undefined, random: () => 0 }, + ); + const controller = new AbortController(); + controller.abort(); + + await expect(governor.acquireWorkSlot(controller.signal)).rejects.toThrow(/Aborted/); + expect(governor.activeSlots()).toBe(0); + }); + + it('uses bounded opaque backoff for rejected signals without reset hints', async () => { + const clock = testClock(); + const sleeps: number[] = []; + const governor = new RateLimitGovernor( + createRateLimitGovernorConfig({ + maxConcurrency: 1, + retry: { maxAttempts: 3, baseDelayMs: 1_000, maxDelayMs: 60_000, jitter: false }, + }), + { + now: clock.now, + random: () => 0, + sleep: async (ms) => { + sleeps.push(ms); + clock.advance(ms); + }, + }, + ); + + governor.report({ provider: 'codex', status: 'rejected', rateLimitType: 'opaque' }); + const release1 = await governor.acquireWorkSlot(); + release1(); + governor.report({ provider: 'codex', status: 'rejected', rateLimitType: 'opaque' }); + const release2 = await governor.acquireWorkSlot(); + release2(); + + expect(sleeps).toEqual([1_000, 2_000]); + }); + + it('exposes the configured retry budget and disables outer retries when pacing is off', () => { + const retry = { maxAttempts: 3, baseDelayMs: 1_000, maxDelayMs: 60_000, jitter: false }; + const enabled = new RateLimitGovernor(createRateLimitGovernorConfig({ retry })); + expect(enabled.maxRetryAttempts()).toBe(3); + + const disabled = new RateLimitGovernor(createRateLimitGovernorConfig({ enabled: false, retry })); + expect(disabled.maxRetryAttempts()).toBe(1); + }); + + it('emits visible wait ticks after a rejected report without a waiting caller', async () => { + const clock = testClock(); + const states: RateLimitWaitState[] = []; + const sleeps: number[] = []; + const governor = new RateLimitGovernor( + createRateLimitGovernorConfig({ maxConcurrency: 4, minConcurrencyUnderPressure: 1, waitStateTickMs: 100 }), + { + now: clock.now, + random: () => 0, + sleep: async (ms, signal) => { + if (signal?.aborted) { + throw new DOMException('Aborted', 'AbortError'); + } + sleeps.push(ms); + clock.advance(ms); + }, + }, + ); + governor.subscribe((state) => states.push(state)); + + governor.report({ + provider: 'claude-subscription', + status: 'rejected', + resetAtMs: 1_250, + rateLimitType: 'five_hour', + }); + await flushMicrotasks(); + + expect(sleeps).toEqual([100, 100, 50]); + expect(states).toContainEqual( + expect.objectContaining({ + kind: 'wait_started', + provider: 'claude-subscription', + rateLimitType: 'five_hour', + remainingMs: 250, + }), + ); + expect(states.filter((state) => state.kind === 'wait_tick')).toHaveLength(3); + expect(states).toContainEqual( + expect.objectContaining({ + kind: 'wait_finished', + provider: 'claude-subscription', + rateLimitType: 'five_hour', + remainingMs: 0, + }), + ); + }); + + it('does not duplicate countdown sleeps when a work slot waits during the same pause', async () => { + const clock = testClock(); + const states: RateLimitWaitState[] = []; + const sleeps: number[] = []; + const governor = new RateLimitGovernor( + createRateLimitGovernorConfig({ maxConcurrency: 2, waitStateTickMs: 100 }), + { + now: clock.now, + random: () => 0, + sleep: async (ms, signal) => { + if (signal?.aborted) { + throw new DOMException('Aborted', 'AbortError'); + } + sleeps.push(ms); + clock.advance(ms); + }, + }, + ); + governor.subscribe((state) => states.push(state)); + + governor.report({ provider: 'anthropic-api', status: 'rejected', retryAfterMs: 250, rateLimitType: 'rpm' }); + const pendingRelease = governor.acquireWorkSlot(); + await flushMicrotasks(); + const release = await pendingRelease; + release(); + + expect(sleeps).toEqual([100, 100, 50]); + expect(states.filter((state) => state.kind === 'wait_tick')).toHaveLength(3); + expect(governor.activeSlots()).toBe(0); + }); + + it('stops the visible wait ticker when the last subscriber unsubscribes', async () => { + const clock = testClock(); + let abortCount = 0; + const governor = new RateLimitGovernor( + createRateLimitGovernorConfig({ maxConcurrency: 1, waitStateTickMs: 100 }), + { + now: clock.now, + random: () => 0, + sleep: async (_ms, signal) => + new Promise((_resolve, reject) => { + signal?.addEventListener( + 'abort', + () => { + abortCount += 1; + reject(new DOMException('Aborted', 'AbortError')); + }, + { once: true }, + ); + }), + }, + ); + const unsubscribe = governor.subscribe(() => undefined); + + governor.report({ provider: 'claude-subscription', status: 'rejected', retryAfterMs: 1_000 }); + await flushMicrotasks(1); + unsubscribe(); + await flushMicrotasks(1); + + expect(abortCount).toBe(1); + }); +}); diff --git a/packages/cli/test/context/project/config.test.ts b/packages/cli/test/context/project/config.test.ts index 6027d454..e5911a25 100644 --- a/packages/cli/test/context/project/config.test.ts +++ b/packages/cli/test/context/project/config.test.ts @@ -50,6 +50,17 @@ connections: maxConcurrency: 1, failureMode: 'continue', }, + rateLimit: { + enabled: true, + throttleThreshold: 0.8, + minConcurrencyUnderPressure: 1, + retry: { + maxAttempts: 6, + baseDelayMs: 1_000, + maxDelayMs: 60_000, + jitter: true, + }, + }, profile: false, }, agent: { @@ -163,6 +174,52 @@ ingest: expect(parseKtxProjectConfig('ingest:\n profile: json\n').ingest.profile).toBe('json'); }); + it('defaults ingest rate-limit settings', () => { + const config = buildDefaultKtxProjectConfig(); + expect(config.ingest.rateLimit).toEqual({ + enabled: true, + throttleThreshold: 0.8, + minConcurrencyUnderPressure: 1, + retry: { + maxAttempts: 6, + baseDelayMs: 1_000, + maxDelayMs: 60_000, + jitter: true, + }, + }); + }); + + it('validates ingest rate-limit retry settings', () => { + const config = parseKtxProjectConfig(` +llm: + provider: + backend: none +ingest: + rateLimit: + enabled: true + throttleThreshold: 0.7 + minConcurrencyUnderPressure: 2 + maxWaitMs: 300000 + retry: + maxAttempts: 4 + baseDelayMs: 500 + maxDelayMs: 30000 + jitter: false +`); + expect(config.ingest.rateLimit).toEqual({ + enabled: true, + throttleThreshold: 0.7, + minConcurrencyUnderPressure: 2, + maxWaitMs: 300_000, + retry: { + maxAttempts: 4, + baseDelayMs: 500, + maxDelayMs: 30_000, + jitter: false, + }, + }); + }); + it('parses global Vertex LLM config', () => { const config = parseKtxProjectConfig(` llm: diff --git a/packages/cli/test/telemetry/project-snapshot.test.ts b/packages/cli/test/telemetry/project-snapshot.test.ts index 973ffb08..ce58f40e 100644 --- a/packages/cli/test/telemetry/project-snapshot.test.ts +++ b/packages/cli/test/telemetry/project-snapshot.test.ts @@ -34,6 +34,17 @@ describe('buildProjectStackSnapshotFields', () => { adapters: [], embeddings: { backend: 'sentence-transformers', dimensions: 384 }, workUnits: { stepBudget: 40, maxConcurrency: 1, failureMode: 'continue' }, + rateLimit: { + enabled: true, + throttleThreshold: 0.8, + minConcurrencyUnderPressure: 1, + retry: { + maxAttempts: 6, + baseDelayMs: 1_000, + maxDelayMs: 60_000, + jitter: true, + }, + }, profile: false, }, llm: { provider: { backend: 'none' }, models: {}, promptCaching: {} }, From fb7b94b60ee4b905e1efae9e004f39911569e68b Mon Sep 17 00:00:00 2001 From: Andrey Avtomonov Date: Fri, 5 Jun 2026 19:36:21 +0200 Subject: [PATCH 20/25] feat(telemetry): collect PostHog $exception error reports in CLI and daemon (#262) * feat(telemetry): add node exception reporter * feat(telemetry): report node cli exceptions * feat(telemetry): add daemon exception reporter * feat(telemetry): report daemon exceptions * docs(telemetry): document error reports * fix(telemetry): pass redaction snapshots from node call sites * test(telemetry): verify prepared node exception payload * fix(telemetry): close daemon exception lifecycle gaps * test(telemetry): verify prepared daemon exception payload * test(telemetry): close error collection acceptance gaps * test(telemetry): close posthog exception acceptance gaps --- AGENTS.md | 21 +- README.md | 16 +- .../content/docs/community/telemetry.mdx | 27 + packages/cli/src/cli-program.ts | 24 + packages/cli/src/cli-runtime.ts | 45 ++ packages/cli/src/connection.ts | 18 +- packages/cli/src/context/mcp/context-tools.ts | 49 +- packages/cli/src/public-ingest.ts | 70 +- packages/cli/src/scan.ts | 22 +- packages/cli/src/sl.ts | 22 +- packages/cli/src/sql.ts | 22 +- packages/cli/src/telemetry/emitter.ts | 61 ++ packages/cli/src/telemetry/exception.ts | 201 ++++++ packages/cli/src/telemetry/index.ts | 5 +- .../cli/src/telemetry/redaction-secrets.ts | 117 ++++ .../cli/test/cli-program-telemetry.test.ts | 33 + packages/cli/test/connection.test.ts | 22 +- packages/cli/test/context/mcp/server.test.ts | 63 +- packages/cli/test/public-ingest.test.ts | 110 +++- packages/cli/test/scan.test.ts | 40 ++ packages/cli/test/sl.test.ts | 63 +- packages/cli/test/sql.test.ts | 17 +- .../test/telemetry/exception-payload.test.ts | 150 +++++ packages/cli/test/telemetry/exception.test.ts | 456 +++++++++++++ packages/cli/test/telemetry/index.test.ts | 35 +- .../test/telemetry/redaction-secrets.test.ts | 127 ++++ python/ktx-daemon/src/ktx_daemon/__main__.py | 71 ++- python/ktx-daemon/src/ktx_daemon/app.py | 152 ++--- .../src/ktx_daemon/semantic_layer.py | 9 +- .../src/ktx_daemon/telemetry/__init__.py | 9 +- .../ktx_daemon/telemetry/daemon_lifecycle.py | 29 + .../src/ktx_daemon/telemetry/exception.py | 156 +++++ python/ktx-daemon/tests/test_app.py | 2 + .../tests/test_exception_payload.py | 118 ++++ .../tests/test_exception_telemetry.py | 601 ++++++++++++++++++ .../ktx-daemon/tests/test_semantic_layer.py | 27 + 36 files changed, 2870 insertions(+), 140 deletions(-) create mode 100644 packages/cli/src/telemetry/exception.ts create mode 100644 packages/cli/src/telemetry/redaction-secrets.ts create mode 100644 packages/cli/test/telemetry/exception-payload.test.ts create mode 100644 packages/cli/test/telemetry/exception.test.ts create mode 100644 packages/cli/test/telemetry/redaction-secrets.test.ts create mode 100644 python/ktx-daemon/src/ktx_daemon/telemetry/daemon_lifecycle.py create mode 100644 python/ktx-daemon/src/ktx_daemon/telemetry/exception.py create mode 100644 python/ktx-daemon/tests/test_exception_payload.py create mode 100644 python/ktx-daemon/tests/test_exception_telemetry.py diff --git a/AGENTS.md b/AGENTS.md index 20f9bcdf..ec715364 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -337,7 +337,8 @@ use `PascalCase` without the suffix. ## Telemetry -**ktx** ships PostHog usage telemetry. When adding commands or events: +**ktx** ships PostHog usage telemetry. Catalog telemetry events use strict +schemas. When adding commands or events: - **MUST NOT**: Add fields that carry user data — file paths, hostnames, environment values, SQL text, schema/table/column names, error messages, @@ -354,6 +355,24 @@ use `PascalCase` without the suffix. of collected data changes. Adding another event with no new field types needs no docs change. +### Error reports + +**ktx** also sends PostHog Error Tracking `$exception` events when telemetry is +enabled. This channel is separate from the strict catalog event schema and is +used only for exception diagnostics. + +`$exception` events may include stack frames, error class names, raw error +messages, cause chains, `source`, `handled`, `fatal`, runtime version fields, +OS/runtime fields, and the hashed `projectId` when known. Stack frames may +include local file paths and the local username when those appear in paths. + +`$exception` events must never intentionally include secrets, credentials, +database URLs, auth headers, raw argv, raw environment values, SQL text, +schema/table/column names as explicit properties, customer row data, user prompt +text, or raw MCP arguments. Reporters must redact call-site-provided secret +snapshots and common static credential patterns before the SDK serializes the +exception. + ## Documentation and Specs - Keep public documentation in `README.md`, package READMEs, example READMEs, diff --git a/README.md b/README.md index 2c433e0d..67abe741 100644 --- a/README.md +++ b/README.md @@ -247,11 +247,17 @@ uv run pytest -q ## Telemetry -**ktx** collects anonymous usage telemetry from interactive CLI runs to -improve setup, command reliability, and data-agent workflows. No file paths, -hostnames, SQL, schema names, error messages, or argv are recorded. See -[Telemetry](https://docs.kaelio.com/ktx/docs/community/telemetry) for the -event catalog and opt-out options. +**ktx** collects privacy-conscious usage telemetry to understand installs and +improve setup, command reliability, and data-agent workflows. Catalog telemetry +events do not record file paths, hostnames, SQL, schema names, table names, +column names, error messages, raw environment values, or argv. Error reports use +PostHog Error Tracking and can include stack frames and raw error messages, +which may contain local file paths or the local username in those paths. +**ktx** redacts secrets, credentials, database URLs, auth headers, argv, raw +environment values, SQL text, row data, and user-typed prompt or MCP argument +text from the explicit `$exception` payload. See +[Telemetry](https://docs.kaelio.com/ktx/docs/community/telemetry) for the event +catalog and opt-out options. ## License diff --git a/docs-site/content/docs/community/telemetry.mdx b/docs-site/content/docs/community/telemetry.mdx index a3a10564..78bdb3e5 100644 --- a/docs-site/content/docs/community/telemetry.mdx +++ b/docs-site/content/docs/community/telemetry.mdx @@ -46,6 +46,33 @@ an operation errors, the detail we record is the error as your tools reported it, which can include identifiers from your setup. If you'd rather send nothing at all, turn telemetry off using any of the options above. +## Error reports + +When telemetry is enabled, **ktx** sends PostHog Error Tracking `$exception` +events for CLI and daemon exceptions. Error reports help group crashes and +handled failures into PostHog issues. + +Error reports can include: + +- Stack frames, including function names, local file paths, line numbers, and + SDK-provided source context. +- Error class names and raw error messages. +- Cause chains when the runtime exposes them. +- `source`, `handled`, and `fatal` diagnostic fields. +- Runtime version, OS, architecture, and CI fields. +- The hashed `projectId` when **ktx** knows the project. + +Error reports never intentionally include: + +- Secrets, credentials, API keys, tokens, cookies, signed URLs, or auth headers. +- Database URLs, connection strings, DSNs, raw argv, or raw environment values. +- SQL text, schema names, table names, or column names as explicit payload + properties. +- Customer row data. +- User prompt text or raw MCP arguments. + +The same opt-out controls listed above disable error reports. + ## Storage and retention Telemetry is sent to PostHog, a third-party product-analytics service used by diff --git a/packages/cli/src/cli-program.ts b/packages/cli/src/cli-program.ts index 31ab8a03..3f1b27e4 100644 --- a/packages/cli/src/cli-program.ts +++ b/packages/cli/src/cli-program.ts @@ -529,6 +529,13 @@ export async function runCommanderKtxCli( try { return await runBareInteractiveCommand(program, io, context); } catch (error) { + const telemetry = await import('./telemetry/index.js'); + await telemetry.reportException({ + error, + context: { source: 'bare-interactive', handled: true, fatal: false }, + packageInfo: info, + io, + }); io.stderr.write(`${formatCliError(error)}\n`); return 1; } @@ -563,6 +570,23 @@ export async function runCommanderKtxCli( outcome: commandOutcomeForParseResult(parseError, exitCode), error: parseError, }); + if ( + parseError && + !isCommanderExit(parseError) && + !isKtxProjectMissingAbortError(parseError) + ) { + await telemetryModule.reportException({ + error: parseError, + context: { + source: completed?.commandPath.join(' ') ?? 'commander parseAsync', + handled: true, + fatal: false, + }, + projectDir: completed?.projectGroupAttached ? completed.projectDir : undefined, + packageInfo: info, + io, + }); + } await telemetryModule.emitCompletedCommand({ completed, packageInfo: info, io }); await telemetryModule.shutdownTelemetryEmitter(); } diff --git a/packages/cli/src/cli-runtime.ts b/packages/cli/src/cli-runtime.ts index 7043143b..4e13b472 100644 --- a/packages/cli/src/cli-runtime.ts +++ b/packages/cli/src/cli-runtime.ts @@ -129,6 +129,48 @@ function installTelemetrySignalFlush(io: KtxCliIo, info: KtxCliPackageInfo): () }; } +/** @internal */ +export function createGlobalExceptionReporter(io: KtxCliIo, info: KtxCliPackageInfo) { + return async (source: 'uncaughtException' | 'unhandledRejection', error: unknown): Promise => { + const { reportException, shutdownTelemetryEmitter } = await import('./telemetry/index.js'); + await reportException({ + error, + context: { source, handled: false, fatal: true }, + io, + packageInfo: info, + immediate: true, + }); + await shutdownTelemetryEmitter(); + }; +} + +export function installGlobalExceptionHandlers(io: KtxCliIo, info: KtxCliPackageInfo): () => void { + const report = createGlobalExceptionReporter(io, info); + const handle = (source: 'uncaughtException' | 'unhandledRejection', error: unknown): void => { + void (async () => { + try { + await report(source, error); + } catch { + // Best-effort: preserve Node's process termination behavior. + } + if (error instanceof Error && error.stack) { + io.stderr.write(`${error.stack}\n`); + } else { + io.stderr.write(`${String(error)}\n`); + } + process.exit(1); + })(); + }; + const onUncaught = (error: Error): void => handle('uncaughtException', error); + const onUnhandled = (reason: unknown): void => handle('unhandledRejection', reason); + process.on('uncaughtException', onUncaught); + process.on('unhandledRejection', onUnhandled); + return () => { + process.off('uncaughtException', onUncaught); + process.off('unhandledRejection', onUnhandled); + }; +} + export async function runKtxCli( argv = process.argv.slice(2), io: KtxCliIo = process, @@ -141,11 +183,14 @@ export async function runKtxCli( // Real-process entry only: flush telemetry if interrupted. Test/programmatic // callers pass their own `io`, so they never install process-level handlers. const removeSignalFlush = (io as unknown) === process ? installTelemetrySignalFlush(io, info) : undefined; + const removeGlobalExceptionHandlers = + (io as unknown) === process ? installGlobalExceptionHandlers(io, info) : undefined; try { return await runCommanderKtxCli(argv, io, deps, info, { runInit: runInitForCommander, }); } finally { + removeGlobalExceptionHandlers?.(); removeSignalFlush?.(); } } diff --git a/packages/cli/src/connection.ts b/packages/cli/src/connection.ts index 2f4a0f4a..9b6b4294 100644 --- a/packages/cli/src/connection.ts +++ b/packages/cli/src/connection.ts @@ -16,7 +16,8 @@ import { bold, dim, green, red, SYMBOLS } from './io/symbols.js'; import { createKtxCliScanConnector } from './local-scan-connectors.js'; import { profileMark } from './startup-profile.js'; import { isDemoConnection } from './telemetry/demo-detect.js'; -import { emitTelemetryEvent } from './telemetry/index.js'; +import { emitTelemetryEvent, reportException } from './telemetry/index.js'; +import { collectTelemetryRedactionSecrets } from './telemetry/redaction-secrets.js'; import { formatErrorDetail, scrubErrorClass } from './telemetry/scrubber.js'; profileMark('module:connection'); @@ -324,6 +325,21 @@ async function emitConnectionTest(input: { ...(errorDetail ? { errorDetail } : {}), }, }); + if (input.error) { + await reportException({ + error: input.error, + context: { source: 'connection test', handled: true, fatal: false }, + projectDir: input.project.projectDir, + io: input.io, + redactionSecrets: await collectTelemetryRedactionSecrets({ + project: input.project, + connectionId: input.connectionId, + includeLlm: false, + includeEmbeddings: false, + env: process.env, + }), + }); + } } function visualWidth(text: string): number { diff --git a/packages/cli/src/context/mcp/context-tools.ts b/packages/cli/src/context/mcp/context-tools.ts index 03cd2ad4..2d07d121 100644 --- a/packages/cli/src/context/mcp/context-tools.ts +++ b/packages/cli/src/context/mcp/context-tools.ts @@ -3,7 +3,13 @@ import type { ToolAnnotations } from '@modelcontextprotocol/sdk/types.js'; import { z } from 'zod'; import type { KtxCliIo } from '../../cli-runtime.js'; import type { MemoryAgentInput } from '../../context/memory/types.js'; -import { emitTelemetryEvent, mcpTelemetrySampleRate, shouldEmitMcpTelemetry } from '../../telemetry/index.js'; +import { + emitTelemetryEvent, + mcpTelemetrySampleRate, + reportException, + shouldEmitMcpTelemetry, +} from '../../telemetry/index.js'; +import { collectTelemetryRedactionSecrets } from '../../telemetry/redaction-secrets.js'; import { scrubErrorClass } from '../../telemetry/scrubber.js'; import type { KtxMcpClientInfo, @@ -518,11 +524,26 @@ function registerParsedTool( }, schema: TSchema, handler: (input: z.infer, context?: KtxMcpToolHandlerContext) => Promise, + telemetry?: { projectDir?: string; io?: KtxCliIo }, ): void { server.registerTool(name, config, async (input, context) => { try { return await handler(schema.parse(input), context); } catch (error) { + if (telemetry?.io) { + await reportException({ + error, + context: { source: `mcp:${name}`, handled: true, fatal: false }, + projectDir: telemetry.projectDir, + io: telemetry.io, + redactionSecrets: await collectTelemetryRedactionSecrets({ + projectDir: telemetry.projectDir, + includeLlm: true, + includeEmbeddings: true, + env: process.env, + }), + }); + } return jsonErrorToolResult(formatToolError(error)); } }); @@ -571,6 +592,20 @@ function instrumentMcpServer( } return result; } catch (error) { + if (telemetry.io) { + await reportException({ + error, + context: { source: `mcp:${name}`, handled: true, fatal: false }, + projectDir: telemetry.projectDir, + io: telemetry.io, + redactionSecrets: await collectTelemetryRedactionSecrets({ + projectDir: telemetry.projectDir, + includeLlm: true, + includeEmbeddings: true, + env: process.env, + }), + }); + } if (telemetry.io && telemetry.projectDir && shouldEmitMcpTelemetry()) { const errorClass = scrubErrorClass(error); await emitTelemetryEvent({ @@ -596,6 +631,7 @@ function instrumentMcpServer( export function registerKtxContextTools(deps: RegisterKtxContextToolsDeps): void { const { ports, userContext } = deps; + const toolTelemetry = { projectDir: deps.projectDir, io: deps.io }; const server = instrumentMcpServer(deps.server, { projectDir: deps.projectDir, io: deps.io, @@ -616,6 +652,7 @@ export function registerKtxContextTools(deps: RegisterKtxContextToolsDeps): void }, connectionListSchema, async () => jsonToolResult({ connections: await connections.list() }), + toolTelemetry, ); } @@ -640,6 +677,7 @@ export function registerKtxContextTools(deps: RegisterKtxContextToolsDeps): void limit: input.limit, }), ), + toolTelemetry, ); registerParsedTool( @@ -657,6 +695,7 @@ export function registerKtxContextTools(deps: RegisterKtxContextToolsDeps): void const page = await knowledge.read({ userId: userContext.userId, key: input.key }); return page ? jsonToolResult(page) : jsonErrorToolResult(`Wiki page "${input.key}" was not found.`); }, + toolTelemetry, ); } @@ -679,6 +718,7 @@ export function registerKtxContextTools(deps: RegisterKtxContextToolsDeps): void ? jsonToolResult(source) : jsonErrorToolResult(`Semantic-layer source "${input.sourceName}" was not found.`); }, + toolTelemetry, ); registerParsedTool( @@ -711,6 +751,7 @@ export function registerKtxContextTools(deps: RegisterKtxContextToolsDeps): void ); return jsonToolResult(projectSlQueryResult(result, input.include)); }, + toolTelemetry, ); } @@ -728,6 +769,7 @@ export function registerKtxContextTools(deps: RegisterKtxContextToolsDeps): void }, entityDetailsSchema, async (input) => jsonToolResult(await entityDetails.read(input)), + toolTelemetry, ); } @@ -745,6 +787,7 @@ export function registerKtxContextTools(deps: RegisterKtxContextToolsDeps): void }, dictionarySearchSchema, async (input) => jsonToolResult(await dictionarySearch.search(input)), + toolTelemetry, ); } @@ -762,6 +805,7 @@ export function registerKtxContextTools(deps: RegisterKtxContextToolsDeps): void }, discoverDataSchema, async (input) => jsonToolResult({ refs: await discover.search(input) }), + toolTelemetry, ); } @@ -791,6 +835,7 @@ export function registerKtxContextTools(deps: RegisterKtxContextToolsDeps): void ), ); }, + toolTelemetry, ); } @@ -818,6 +863,7 @@ export function registerKtxContextTools(deps: RegisterKtxContextToolsDeps): void }; return jsonToolResult(await memoryIngest.ingest(ingestInput)); }, + toolTelemetry, ); registerParsedTool( @@ -835,6 +881,7 @@ export function registerKtxContextTools(deps: RegisterKtxContextToolsDeps): void const status = await memoryIngest.status(input.runId); return status ? jsonToolResult(status) : jsonErrorToolResult(`Memory ingest run "${input.runId}" was not found.`); }, + toolTelemetry, ); } } diff --git a/packages/cli/src/public-ingest.ts b/packages/cli/src/public-ingest.ts index 44a2b024..07f805b8 100644 --- a/packages/cli/src/public-ingest.ts +++ b/packages/cli/src/public-ingest.ts @@ -23,7 +23,8 @@ import type { KtxScanArgs, KtxScanDeps } from './scan.js'; import type { KtxTableRef } from './context/scan/types.js'; import { profileMark } from './startup-profile.js'; import { isDemoConnection } from './telemetry/demo-detect.js'; -import { emitProjectStackSnapshot, emitTelemetryEvent } from './telemetry/index.js'; +import { emitProjectStackSnapshot, emitTelemetryEvent, reportException } from './telemetry/index.js'; +import { collectTelemetryRedactionSecrets } from './telemetry/redaction-secrets.js'; import { formatErrorDetail } from './telemetry/scrubber.js'; profileMark('module:public-ingest'); @@ -1119,30 +1120,63 @@ export async function runKtxPublicIngest( feature, }); } catch (error) { + await reportException({ + error, + context: { source: 'ingest runtime', handled: true, fatal: false }, + projectDir: args.projectDir, + io, + redactionSecrets: await collectTelemetryRedactionSecrets({ + project, + projectDir: args.projectDir, + connectionId: args.targetConnectionId, + includeLlm: true, + includeEmbeddings: true, + env: deps.env ?? process.env, + }), + }); io.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`); return 1; } } const { runContextBuild } = await import('./context-build-view.js'); const contextBuild = deps.runContextBuild ?? runContextBuild; - const result = await contextBuild( - project, - { + try { + const result = await contextBuild( + project, + { + projectDir: args.projectDir, + ...(args.targetConnectionId ? { targetConnectionId: args.targetConnectionId } : {}), + all: args.all, + entrypoint: 'ingest', + inputMode: args.inputMode, + ...(args.queryHistory ? { queryHistory: args.queryHistory } : {}), + ...(args.queryHistoryWindowDays !== undefined ? { queryHistoryWindowDays: args.queryHistoryWindowDays } : {}), + ...(args.scanMode ? { scanMode: args.scanMode } : {}), + ...(args.detectRelationships !== undefined ? { detectRelationships: args.detectRelationships } : {}), + ...(args.cliVersion ? { cliVersion: args.cliVersion } : {}), + ...(args.runtimeInstallPolicy ? { runtimeInstallPolicy: args.runtimeInstallPolicy } : {}), + }, + io, + ); + return result.exitCode; + } catch (error) { + await reportException({ + error, + context: { source: 'ingest context-build', handled: true, fatal: false }, projectDir: args.projectDir, - ...(args.targetConnectionId ? { targetConnectionId: args.targetConnectionId } : {}), - all: args.all, - entrypoint: 'ingest', - inputMode: args.inputMode, - ...(args.queryHistory ? { queryHistory: args.queryHistory } : {}), - ...(args.queryHistoryWindowDays !== undefined ? { queryHistoryWindowDays: args.queryHistoryWindowDays } : {}), - ...(args.scanMode ? { scanMode: args.scanMode } : {}), - ...(args.detectRelationships !== undefined ? { detectRelationships: args.detectRelationships } : {}), - ...(args.cliVersion ? { cliVersion: args.cliVersion } : {}), - ...(args.runtimeInstallPolicy ? { runtimeInstallPolicy: args.runtimeInstallPolicy } : {}), - }, - io, - ); - return result.exitCode; + io, + redactionSecrets: await collectTelemetryRedactionSecrets({ + project, + projectDir: args.projectDir, + connectionId: args.targetConnectionId, + includeLlm: true, + includeEmbeddings: true, + env: deps.env ?? process.env, + }), + }); + io.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`); + return 1; + } } const plan = buildPublicIngestPlan(project, args); diff --git a/packages/cli/src/scan.ts b/packages/cli/src/scan.ts index 4f973e57..5961e3f1 100644 --- a/packages/cli/src/scan.ts +++ b/packages/cli/src/scan.ts @@ -1,6 +1,6 @@ import type { KtxProgressPort, KtxScanMode, KtxScanReport, KtxScanWarning } from './context/scan/types.js'; import { runLocalScan } from './context/scan/local-scan.js'; -import { loadKtxProject } from './context/project/project.js'; +import { loadKtxProject, type KtxLocalProject } from './context/project/project.js'; import { getKtxCliPackageInfo } from './cli-runtime.js'; import { resolveProjectEmbeddingProvider } from './embedding-resolution.js'; import type { KtxCliIo } from './index.js'; @@ -8,7 +8,8 @@ import { createKtxCliLocalIngestAdapters } from './local-adapters.js'; import { createKtxCliScanConnector } from './local-scan-connectors.js'; import type { KtxManagedPythonInstallPolicy } from './managed-python-command.js'; import { profileMark } from './startup-profile.js'; -import { emitTelemetryEvent } from './telemetry/index.js'; +import { emitTelemetryEvent, reportException } from './telemetry/index.js'; +import { collectTelemetryRedactionSecrets } from './telemetry/redaction-secrets.js'; import { formatErrorDetail, scrubErrorClass } from './telemetry/scrubber.js'; profileMark('module:scan'); @@ -322,8 +323,9 @@ export function createCliScanProgress( export async function runKtxScan(args: KtxScanArgs, io: KtxCliIo = process, deps: KtxScanDeps = {}): Promise { const startedAt = performance.now(); + let project: KtxLocalProject | undefined; try { - const project = await loadKtxProject({ projectDir: args.projectDir }); + project = await loadKtxProject({ projectDir: args.projectDir }); const resolveEmbeddingProvider = deps.resolveEmbeddingProvider ?? resolveProjectEmbeddingProvider; const resolution = await resolveEmbeddingProvider(project, { mode: 'ensure', @@ -397,6 +399,20 @@ export async function runKtxScan(args: KtxScanArgs, io: KtxCliIo = process, deps ...(errorDetail ? { errorDetail } : {}), }, }); + await reportException({ + error, + context: { source: 'scan run', handled: true, fatal: false }, + projectDir: args.projectDir, + io, + redactionSecrets: await collectTelemetryRedactionSecrets({ + project, + projectDir: args.projectDir, + connectionId: args.connectionId, + includeLlm: true, + includeEmbeddings: true, + env: process.env, + }), + }); io.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`); return 1; } diff --git a/packages/cli/src/sl.ts b/packages/cli/src/sl.ts index f3eeb33e..dcf5e460 100644 --- a/packages/cli/src/sl.ts +++ b/packages/cli/src/sl.ts @@ -26,7 +26,8 @@ import { type KtxManagedPythonInstallPolicy, } from './managed-python-command.js'; import { profileMark } from './startup-profile.js'; -import { emitTelemetryEvent } from './telemetry/index.js'; +import { emitTelemetryEvent, reportException } from './telemetry/index.js'; +import { collectTelemetryRedactionSecrets } from './telemetry/redaction-secrets.js'; import { scrubErrorClass } from './telemetry/scrubber.js'; profileMark('module:sl'); @@ -202,8 +203,9 @@ function ambiguousSourceMessage(sourceName: string, connectionIds: readonly stri export async function runKtxSl(args: KtxSlArgs, io: KtxSlIo = process, deps: KtxSlDeps = {}): Promise { const startedAt = performance.now(); let queryForTelemetry: SemanticLayerQueryInput | undefined; + let project: KtxLocalProject | undefined; try { - const project = await (deps.loadProject ?? loadKtxProject)({ projectDir: args.projectDir }); + project = await (deps.loadProject ?? loadKtxProject)({ projectDir: args.projectDir }); if (args.command === 'list') { const sources = await listLocalSlSources(project, { connectionId: args.connectionId }); await printSlSources({ @@ -320,7 +322,7 @@ export async function runKtxSl(args: KtxSlArgs, io: KtxSlIo = process, deps: Ktx projectDir: args.projectDir, }); const queryExecutor = args.execute ? (deps.createQueryExecutor ?? createDefaultLocalQueryExecutor)() : undefined; - const result = await compileLocalSlQuery(project as KtxLocalProject, { + const result = await compileLocalSlQuery(project, { connectionId: args.connectionId, query, compute, @@ -351,6 +353,20 @@ export async function runKtxSl(args: KtxSlArgs, io: KtxSlIo = process, deps: Ktx const _exhaustive: never = args; throw new Error(`Unsupported sl command: ${JSON.stringify(_exhaustive)}`); } catch (error) { + await reportException({ + error, + context: { source: `sl ${args.command}`, handled: true, fatal: false }, + projectDir: args.projectDir, + io, + redactionSecrets: await collectTelemetryRedactionSecrets({ + project, + projectDir: args.projectDir, + connectionId: args.connectionId, + includeLlm: args.command === 'query', + includeEmbeddings: args.command === 'search' || args.command === 'query', + env: process.env, + }), + }); if (args.command === 'validate') { const errorClass = scrubErrorClass(error); await emitTelemetryEvent({ diff --git a/packages/cli/src/sql.ts b/packages/cli/src/sql.ts index bfae0608..d3eb6a81 100644 --- a/packages/cli/src/sql.ts +++ b/packages/cli/src/sql.ts @@ -7,7 +7,8 @@ import { createKtxCliScanConnector } from './local-scan-connectors.js'; import { createManagedDaemonSqlAnalysisPort } from './managed-python-http.js'; import { profileMark } from './startup-profile.js'; import { isDemoConnection } from './telemetry/demo-detect.js'; -import { emitTelemetryEvent } from './telemetry/index.js'; +import { emitTelemetryEvent, reportException } from './telemetry/index.js'; +import { collectTelemetryRedactionSecrets } from './telemetry/redaction-secrets.js'; import { scrubErrorClass } from './telemetry/scrubber.js'; profileMark('module:sql'); @@ -142,8 +143,9 @@ export async function runKtxSql(args: KtxSqlArgs, io: KtxCliIo = process, deps: const startedAt = performance.now(); let driver = 'unknown'; let demoConnection = false; + let project: KtxLocalProject | undefined; try { - const project = await (deps.loadProject ?? loadKtxProject)({ projectDir: args.projectDir }); + project = await (deps.loadProject ?? loadKtxProject)({ projectDir: args.projectDir }); const connection = project.config.connections[args.connectionId]; if (!connection) { throw new Error(`Connection "${args.connectionId}" is not configured in ktx.yaml`); @@ -171,7 +173,7 @@ export async function runKtxSql(args: KtxSqlArgs, io: KtxCliIo = process, deps: const createScanConnector = deps.createScanConnector ?? createKtxCliScanConnector; let connector: KtxScanConnector | null = null; try { - connector = await createScanConnector(project as KtxLocalProject, args.connectionId); + connector = await createScanConnector(project, args.connectionId); if (!connector.capabilities.readOnlySql || !connector.executeReadOnly) { throw new Error(`Connection "${args.connectionId}" does not support read-only SQL execution.`); } @@ -218,6 +220,20 @@ export async function runKtxSql(args: KtxSqlArgs, io: KtxCliIo = process, deps: ...(errorClass ? { errorClass } : {}), }, }); + await reportException({ + error, + context: { source: 'sql run', handled: true, fatal: false }, + projectDir: args.projectDir, + io, + redactionSecrets: await collectTelemetryRedactionSecrets({ + project, + projectDir: args.projectDir, + connectionId: args.connectionId, + includeLlm: false, + includeEmbeddings: false, + env: process.env, + }), + }); io.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`); return 1; } diff --git a/packages/cli/src/telemetry/emitter.ts b/packages/cli/src/telemetry/emitter.ts index 3344e00b..12453262 100644 --- a/packages/cli/src/telemetry/emitter.ts +++ b/packages/cli/src/telemetry/emitter.ts @@ -16,6 +16,16 @@ type PostHogClient = { properties: Record; groups?: Record; }): void; + captureException( + error: unknown, + distinctId?: string, + additionalProperties?: Record, + ): void; + captureExceptionImmediate( + error: unknown, + distinctId?: string, + additionalProperties?: Record, + ): Promise; shutdown(): Promise | void; }; @@ -105,6 +115,57 @@ export async function trackTelemetryEvent(input: { } } +function writeDebugExceptionPayload(input: { + error: Error; + distinctId: string; + properties: Record; + stderr: TelemetrySink; +}): void { + input.stderr.write( + `[telemetry-exception] ${JSON.stringify({ + distinctId: input.distinctId, + message: input.error.message, + name: input.error.name, + properties: input.properties, + })}\n`, + ); +} + +export async function trackTelemetryException(input: { + error: Error; + distinctId: string; + properties: Record; + env?: TelemetryEmitterEnv; + stderr: TelemetrySink; + projectApiKey?: string; + host?: string; + immediate?: boolean; +}): Promise { + const env = input.env ?? process.env; + + if (debugEnabled(env)) { + writeDebugExceptionPayload(input); + return; + } + + const projectApiKey = telemetryProjectApiKey(input.projectApiKey); + const host = telemetryHost(env, input.host); + const client = await getPostHogClient(projectApiKey, host); + if (!client) { + return; + } + + try { + if (input.immediate) { + await client.captureExceptionImmediate(input.error, input.distinctId, input.properties); + return; + } + client.captureException(input.error, input.distinctId, input.properties); + } catch { + return; + } +} + export async function shutdownTelemetryEmitter(): Promise { const client = await clientPromise; if (!client) { diff --git a/packages/cli/src/telemetry/exception.ts b/packages/cli/src/telemetry/exception.ts new file mode 100644 index 00000000..0ce81244 --- /dev/null +++ b/packages/cli/src/telemetry/exception.ts @@ -0,0 +1,201 @@ +import { inspect } from 'node:util'; + +import { getKtxCliPackageInfo, type KtxCliIo, type KtxCliPackageInfo } from '../cli-runtime.js'; +import { buildCommonEnvelope } from './events.js'; +import { trackTelemetryException } from './emitter.js'; +import { computeTelemetryProjectId, loadTelemetryIdentity } from './identity.js'; + +export interface ExceptionContext { + source: string; + handled: boolean; + fatal: boolean; + extra?: Record; +} + +type AnyObject = object; + +const reportedObjects = new WeakSet(); +const recentHandledPrimitives: string[] = []; +const RECENT_PRIMITIVE_LIMIT = 128; + +function primitiveKey(value: unknown): string { + return `${typeof value}:${String(value)}`; +} + +function rememberHandledPrimitive(value: unknown): void { + recentHandledPrimitives.push(primitiveKey(value)); + if (recentHandledPrimitives.length > RECENT_PRIMITIVE_LIMIT) { + recentHandledPrimitives.splice(0, recentHandledPrimitives.length - RECENT_PRIMITIVE_LIMIT); + } +} + +function consumeHandledPrimitive(value: unknown): boolean { + const key = primitiveKey(value); + const index = recentHandledPrimitives.indexOf(key); + if (index < 0) { + return false; + } + recentHandledPrimitives.splice(index, 1); + return true; +} + +function shouldSkipAsAlreadyReported(error: unknown, handled: boolean): boolean { + if ((typeof error === 'object' || typeof error === 'function') && error !== null) { + if (reportedObjects.has(error)) { + return true; + } + reportedObjects.add(error); + return false; + } + + if (handled) { + rememberHandledPrimitive(error); + return false; + } + + return consumeHandledPrimitive(error); +} + +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +function redactStaticPatterns(value: string): string { + return value + .replace(/([a-z][a-z0-9+.-]*:\/\/[^:\s/@]+:)([^@\s/]+)(@)/gi, '$1[redacted]$3') + .replace(/\b(password|pwd)=([^;&\s]+)/gi, '$1=[redacted]') + .replace(/\bAuthorization\s*:\s*[^\r\n,;]+/gi, 'Authorization: [redacted]') + .replace(/\bBearer\s+[A-Za-z0-9._~+/=-]+/gi, 'Bearer [redacted]') + .replace(/\b(api[_-]?key)\s*[:=]\s*([^\s,;]+)/gi, '$1=[redacted]') + .replace(/\b(KTX_[A-Z0-9_]*|[A-Z0-9_]*(?:TOKEN|SECRET))\s*[:=]\s*([^\s,;]+)/g, '$1=[redacted]') + .replace(/([?&](?:X-Amz-Signature|X-Goog-Signature|sig)=)[^&\s]+/gi, '$1[redacted]'); +} + +function redactText(value: string, secrets: ReadonlyArray): string { + let redacted = value; + for (const secret of secrets) { + if (secret) { + redacted = redacted.replace(new RegExp(escapeRegExp(secret), 'g'), '[redacted]'); + } + } + return redactStaticPatterns(redacted); +} + +const FORBIDDEN_EXTRA_PROPERTY_KEYS = new Set([ + 'argv', + 'args', + 'env', + 'environment', + 'sql', + 'query', + 'prompt', + 'mcparguments', + 'mcpargs', + 'tablename', + 'schemaname', + 'columnname', + 'databaseurl', + 'connectionstring', + 'url', + 'password', + 'token', + 'apikey', + 'api_key', + 'authorization', +]); + +function safeExtraProperties( + extra: Record | undefined, +): Record { + const safe: Record = {}; + for (const [key, value] of Object.entries(extra ?? {})) { + if (!FORBIDDEN_EXTRA_PROPERTY_KEYS.has(key.replace(/[^a-z0-9_]/gi, '').toLowerCase())) { + safe[key] = value; + } + } + return safe; +} + +function toMessage(error: unknown): string { + if (error instanceof Error) { + return error.message; + } + if (typeof error === 'string') { + return error; + } + return inspect(error, { depth: 4, breakLength: 120 }); +} + +function sanitizedError(error: unknown, secrets: ReadonlyArray): Error { + if (error instanceof Error) { + const cause = 'cause' in error ? (error as Error & { cause?: unknown }).cause : undefined; + const clone = new Error(redactText(error.message, secrets), { + ...(cause !== undefined ? { cause: sanitizedError(cause, secrets) } : {}), + }); + clone.name = error.name; + if (error.stack) { + clone.stack = redactText(error.stack, secrets); + } + return clone; + } + return new Error(redactText(toMessage(error), secrets)); +} + +export async function reportException(input: { + error: unknown; + context: ExceptionContext; + io: KtxCliIo; + packageInfo?: KtxCliPackageInfo; + projectDir?: string; + immediate?: boolean; + redactionSecrets?: ReadonlyArray; +}): Promise { + try { + if (shouldSkipAsAlreadyReported(input.error, input.context.handled)) { + return; + } + + const debug = process.env.KTX_TELEMETRY_DEBUG === '1'; + const identity = await loadTelemetryIdentity({ + stderr: input.io.stderr, + env: process.env, + }); + + if ((!identity.enabled || !identity.installId) && !debug) { + return; + } + + const packageInfo = input.packageInfo ?? getKtxCliPackageInfo(); + const installId = identity.installId ?? 'debug'; + const projectId = input.projectDir ? computeTelemetryProjectId(installId, input.projectDir) : undefined; + const safeError = sanitizedError(input.error, input.redactionSecrets ?? []); + const properties: Record = { + ...buildCommonEnvelope({ + cliVersion: packageInfo.version, + isCi: Boolean(process.env.CI), + }), + source: input.context.source, + handled: input.context.handled, + fatal: input.context.fatal, + ...(projectId ? { projectId } : {}), + ...safeExtraProperties(input.context.extra), + }; + + delete properties.$groups; + await trackTelemetryException({ + error: safeError, + distinctId: installId, + properties, + env: process.env, + stderr: input.io.stderr, + immediate: input.immediate, + }); + } catch { + return; + } +} + +/** @internal */ +export function __resetTelemetryExceptionStateForTests(): void { + recentHandledPrimitives.length = 0; +} diff --git a/packages/cli/src/telemetry/index.ts b/packages/cli/src/telemetry/index.ts index b02e0224..e3716060 100644 --- a/packages/cli/src/telemetry/index.ts +++ b/packages/cli/src/telemetry/index.ts @@ -7,6 +7,7 @@ import { type CompletedCommandSpan, } from './command-hook.js'; import { shutdownTelemetryEmitter, trackTelemetryEvent } from './emitter.js'; +import { reportException, type ExceptionContext } from './exception.js'; import { buildCommonEnvelope, buildTelemetryEvent, @@ -17,8 +18,8 @@ import { import { computeTelemetryProjectId, loadTelemetryIdentity } from './identity.js'; import { buildProjectStackSnapshotFields } from './project-snapshot.js'; -export { beginCommandSpan, completeCommandSpan, shutdownTelemetryEmitter }; -export type { CommandOutcome, CompletedCommandSpan }; +export { beginCommandSpan, completeCommandSpan, reportException, shutdownTelemetryEmitter }; +export type { CommandOutcome, CompletedCommandSpan, ExceptionContext }; export async function showTelemetryNoticeIfNeeded(io: KtxCliIo, packageInfo: KtxCliPackageInfo): Promise { const identity = await loadTelemetryIdentity({ diff --git a/packages/cli/src/telemetry/redaction-secrets.ts b/packages/cli/src/telemetry/redaction-secrets.ts new file mode 100644 index 00000000..2bf7a863 --- /dev/null +++ b/packages/cli/src/telemetry/redaction-secrets.ts @@ -0,0 +1,117 @@ +import { resolveKtxConfigReference } from '../context/core/config-reference.js'; +import { loadKtxProject, type KtxLocalProject } from '../context/project/project.js'; + +const SENSITIVE_KEY = + /(password|secret|token|api[_-]?key|auth[_-]?token|auth_token_ref|private[_-]?key|passphrase|credential|authorization|url)$/i; + +type TelemetryRedactionProject = Pick; + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function addSecret(values: string[], value: string | undefined): void { + const trimmed = value?.trim(); + if (trimmed && !values.includes(trimmed)) { + values.push(trimmed); + } +} + +function tryResolve(value: string, env: NodeJS.ProcessEnv): string | undefined { + try { + return resolveKtxConfigReference(value, env); + } catch { + return undefined; + } +} + +function addUrlCredentials(values: string[], value: string): void { + try { + const parsed = new URL(value); + addSecret(values, parsed.password ? decodeURIComponent(parsed.password) : undefined); + addSecret(values, parsed.username ? decodeURIComponent(parsed.username) : undefined); + } catch { + return; + } +} + +function collectFromRecord(input: unknown, env: NodeJS.ProcessEnv, values: string[]): void { + if (Array.isArray(input)) { + for (const item of input) { + collectFromRecord(item, env, values); + } + return; + } + + if (!isRecord(input)) { + return; + } + + for (const [key, raw] of Object.entries(input)) { + if (isRecord(raw) || Array.isArray(raw)) { + collectFromRecord(raw, env, values); + continue; + } + if (typeof raw !== 'string' || !SENSITIVE_KEY.test(key)) { + continue; + } + const resolved = tryResolve(raw, env); + addSecret(values, resolved); + if (resolved) { + addUrlCredentials(values, resolved); + } + } +} + +function collectLlmSecrets(project: TelemetryRedactionProject, env: NodeJS.ProcessEnv, values: string[]): void { + collectFromRecord(project.config.llm.provider, env, values); +} + +function collectEmbeddingSecrets(project: TelemetryRedactionProject, env: NodeJS.ProcessEnv, values: string[]): void { + collectFromRecord(project.config.ingest.embeddings, env, values); + collectFromRecord(project.config.scan.enrichment.embeddings, env, values); +} + +function collectConnectionSecrets( + project: TelemetryRedactionProject, + connectionId: string | undefined, + env: NodeJS.ProcessEnv, + values: string[], +): void { + if (!connectionId) { + return; + } + collectFromRecord(project.config.connections[connectionId], env, values); +} + +export async function collectTelemetryRedactionSecrets(input: { + project?: TelemetryRedactionProject; + projectDir?: string; + connectionId?: string; + includeLlm?: boolean; + includeEmbeddings?: boolean; + env?: NodeJS.ProcessEnv; +}): Promise { + const env = input.env ?? process.env; + let project = input.project; + if (!project && input.projectDir) { + try { + project = await loadKtxProject({ projectDir: input.projectDir }); + } catch { + project = undefined; + } + } + if (!project) { + return []; + } + + const values: string[] = []; + if (input.includeLlm) { + collectLlmSecrets(project, env, values); + } + if (input.includeEmbeddings) { + collectEmbeddingSecrets(project, env, values); + } + collectConnectionSecrets(project, input.connectionId, env, values); + return values; +} diff --git a/packages/cli/test/cli-program-telemetry.test.ts b/packages/cli/test/cli-program-telemetry.test.ts index 4e7130b3..30e2bd2b 100644 --- a/packages/cli/test/cli-program-telemetry.test.ts +++ b/packages/cli/test/cli-program-telemetry.test.ts @@ -7,6 +7,12 @@ import { runCommanderKtxCli } from '../src/cli-program.js'; import type { KtxCliDeps, KtxCliIo, KtxCliPackageInfo } from '../src/cli-runtime.js'; import { TELEMETRY_NOTICE } from '../src/telemetry/identity.js'; +const reportExceptionMock = vi.hoisted(() => vi.fn(async () => {})); + +vi.mock('../src/telemetry/exception.js', () => ({ + reportException: reportExceptionMock, +})); + function makeIo(stdoutIsTTY = true): { io: KtxCliIo; stdout: () => string; stderr: () => string } { let stdout = ''; let stderr = ''; @@ -43,6 +49,7 @@ describe('runCommanderKtxCli telemetry', () => { vi.stubEnv('CI', ''); vi.stubEnv('KTX_TELEMETRY_DISABLED', ''); vi.stubEnv('DO_NOT_TRACK', ''); + reportExceptionMock.mockClear(); }); afterEach(async () => { @@ -131,4 +138,30 @@ describe('runCommanderKtxCli telemetry', () => { await expect(runCommanderKtxCli(['unknown'], unknownIo.io, {}, info, { runInit: async () => 0 })).resolves.toBe(1); expect(unknownIo.stderr()).not.toContain('[telemetry]'); }); + + it('reports genuine top-level command catches as handled exceptions', async () => { + const io = makeIo(true); + const deps: KtxCliDeps = { + doctor: async () => { + throw new Error('status failed'); + }, + }; + + await expect( + runCommanderKtxCli( + ['--project-dir', tempDir, 'status', '--json'], + io.io, + deps, + info, + { runInit: async () => 0 }, + ), + ).resolves.toBe(1); + + expect(reportExceptionMock).toHaveBeenCalledWith( + expect.objectContaining({ + context: expect.objectContaining({ source: 'ktx status', handled: true, fatal: false }), + projectDir: tempDir, + }), + ); + }); }); diff --git a/packages/cli/test/connection.test.ts b/packages/cli/test/connection.test.ts index da650b05..22c8bbe9 100644 --- a/packages/cli/test/connection.test.ts +++ b/packages/cli/test/connection.test.ts @@ -10,6 +10,12 @@ import type { KtxConnectionDriver, KtxScanConnector } from '../src/context/scan/ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { runKtxConnection } from '../src/connection.js'; +const reportExceptionMock = vi.hoisted(() => vi.fn(async () => {})); + +vi.mock('../src/telemetry/exception.js', () => ({ + reportException: reportExceptionMock, +})); + function stripAnsi(s: string): string { return s.replace(/\[[0-9;]*m/g, ''); } @@ -72,6 +78,7 @@ describe('runKtxConnection', () => { beforeEach(async () => { tempDir = await mkdtemp(join(tmpdir(), 'ktx-cli-connection-')); + reportExceptionMock.mockClear(); }); afterEach(async () => { @@ -165,12 +172,13 @@ describe('runKtxConnection', () => { it('records the raw errorDetail in connection_test telemetry when a native test fails', async () => { vi.stubEnv('KTX_TELEMETRY_DEBUG', '1'); vi.stubEnv('CI', ''); + vi.stubEnv('DATABASE_URL', 'postgres://svc:db-url-password@db.example.test/analytics'); // pragma: allowlist secret const projectDir = join(tempDir, 'project'); await initKtxProject({ projectDir }); await writeConnections(projectDir, { - warehouse: { driver: 'sqlite' }, + warehouse: { driver: 'postgres', url: 'env:DATABASE_URL' }, }); - const { connector } = nativeConnector('sqlite', { success: false, error: 'database file is unreadable' }); + const { connector } = nativeConnector('postgres', { success: false, error: 'database file is unreadable' }); const io = makeIo(); const code = await runKtxConnection({ command: 'test', projectDir, connectionId: 'warehouse' }, io.io, { @@ -181,6 +189,16 @@ describe('runKtxConnection', () => { expect(io.stderr()).toContain('"event":"connection_test"'); expect(io.stderr()).toContain('"outcome":"error"'); expect(io.stderr()).toContain('"errorDetail":"database file is unreadable"'); + expect(reportExceptionMock).toHaveBeenCalledWith( + expect.objectContaining({ + context: expect.objectContaining({ source: 'connection test', handled: true, fatal: false }), + projectDir, + redactionSecrets: expect.arrayContaining([ + 'postgres://svc:db-url-password@db.example.test/analytics', // pragma: allowlist secret + 'db-url-password', + ]), + }), + ); }); it('preserves the driver error class and code in connection_test telemetry', async () => { diff --git a/packages/cli/test/context/mcp/server.test.ts b/packages/cli/test/context/mcp/server.test.ts index 95985d68..1359d346 100644 --- a/packages/cli/test/context/mcp/server.test.ts +++ b/packages/cli/test/context/mcp/server.test.ts @@ -1,4 +1,4 @@ -import { access, mkdtemp, readFile, rm } from 'node:fs/promises'; +import { access, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { Client } from '@modelcontextprotocol/sdk/client/index.js'; @@ -7,6 +7,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; import { createLocalProjectMemoryIngest } from '../../../src/context/memory/local-memory.js'; import { detectCaptureSignals } from '../../../src/context/memory/capture-signals.js'; import type { MemoryAgentInput } from '../../../src/context/memory/types.js'; +import { parseKtxProjectConfig, serializeKtxProjectConfig } from '../../../src/context/project/config.js'; import { initKtxProject } from '../../../src/context/project/project.js'; import { jsonToolResult } from '../../../src/context/mcp/context-tools.js'; import { createDefaultKtxMcpServer, createKtxMcpServer } from '../../../src/context/mcp/server.js'; @@ -23,6 +24,12 @@ import type { MemoryIngestPort, } from '../../../src/context/mcp/types.js'; +const reportExceptionMock = vi.hoisted(() => vi.fn(async () => {})); + +vi.mock('../../../src/telemetry/exception.js', () => ({ + reportException: reportExceptionMock, +})); + type RegisteredTool = { name: string; config: { @@ -280,6 +287,60 @@ describe('createKtxMcpServer', () => { expect(io.stderrText()).not.toContain('mcpClientVersion'); }); + it('reports MCP tool exceptions with a tool-derived source', async () => { + reportExceptionMock.mockClear(); + vi.stubEnv('ANTHROPIC_API_KEY', 'mcp-anthropic-secret'); // pragma: allowlist secret + const fake = makeFakeServer(); + const io = makeIo(); + const projectDir = await mkdtemp(join(tmpdir(), 'ktx-mcp-exception-')); + try { + await initKtxProject({ projectDir }); + const config = parseKtxProjectConfig(await readFile(join(projectDir, 'ktx.yaml'), 'utf-8')); + await writeFile( + join(projectDir, 'ktx.yaml'), + serializeKtxProjectConfig({ + ...config, + llm: { + ...config.llm, + provider: { + backend: 'anthropic', + anthropic: { api_key: 'env:ANTHROPIC_API_KEY' }, // pragma: allowlist secret + }, + models: { default: 'claude-sonnet-4-6' }, + }, + }), + 'utf-8', + ); + + createKtxMcpServer({ + server: fake.server, + userContext: { userId: 'local-user' }, + projectDir, + io, + contextTools: { + knowledge: { + search: vi.fn().mockRejectedValue(new Error('wiki failed')), + read: vi.fn().mockResolvedValue(null), + }, + }, + }); + + await expect(getTool(fake.tools, 'wiki_search').handler({ query: 'revenue recognition', limit: 5 })).resolves.toMatchObject({ + isError: true, + }); + + expect(reportExceptionMock).toHaveBeenCalledWith( + expect.objectContaining({ + context: expect.objectContaining({ source: 'mcp:wiki_search', handled: true, fatal: false }), + projectDir, + redactionSecrets: expect.arrayContaining(['mcp-anthropic-secret']), + }), + ); + } finally { + await rm(projectDir, { recursive: true, force: true }); + } + }); + it('captures the connecting MCP client name and version', async () => { vi.stubEnv('KTX_TELEMETRY_DEBUG', '1'); vi.stubEnv('CI', ''); diff --git a/packages/cli/test/public-ingest.test.ts b/packages/cli/test/public-ingest.test.ts index ba35faf6..6dea8834 100644 --- a/packages/cli/test/public-ingest.test.ts +++ b/packages/cli/test/public-ingest.test.ts @@ -3,7 +3,7 @@ import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { buildDefaultKtxProjectConfig, type KtxProjectConfig } from '../src/context/project/config.js'; import { initKtxProject } from '../src/context/project/project.js'; -import { afterEach, describe, expect, it, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { buildPublicIngestPlan, executePublicIngestTarget, @@ -13,6 +13,12 @@ import { runKtxPublicIngest, } from '../src/public-ingest.js'; +const reportExceptionMock = vi.hoisted(() => vi.fn(async () => {})); + +vi.mock('../src/telemetry/exception.js', () => ({ + reportException: reportExceptionMock, +})); + /** Count non-overlapping occurrences of `needle` in `haystack`. */ function occurrences(haystack: string, needle: string): number { return haystack.split(needle).length - 1; @@ -377,6 +383,10 @@ describe('publicProgressMessage', () => { }); describe('runKtxPublicIngest', () => { + beforeEach(() => { + reportExceptionMock.mockClear(); + }); + afterEach(() => { vi.unstubAllEnvs(); }); @@ -1208,6 +1218,104 @@ describe('runKtxPublicIngest', () => { ); }); + it('reports foreground runtime preflight exceptions', async () => { + const io = makeIo({ isTTY: true, interactive: true }); + const project = projectWithConnections({ + warehouse: { driver: 'postgres' }, + }); + const ensureRuntime = vi.fn(async (): Promise => { + throw new Error('runtime unavailable'); + }); + const runContextBuild = vi.fn(async () => ({ exitCode: 0 })); + + await expect( + runKtxPublicIngest( + { + command: 'run', + projectDir: '/tmp/project', + targetConnectionId: 'warehouse', + all: false, + json: false, + inputMode: 'auto', + queryHistory: 'enabled', + cliVersion: '0.2.0', + runtimeInstallPolicy: 'prompt', + }, + io.io, + { + loadProject: vi.fn(async () => project), + ensureRuntime, + runContextBuild, + }, + ), + ).resolves.toBe(1); + + expect(runContextBuild).not.toHaveBeenCalled(); + expect(io.stderr()).toContain('runtime unavailable'); + expect(reportExceptionMock).toHaveBeenCalledWith( + expect.objectContaining({ + context: expect.objectContaining({ source: 'ingest runtime', handled: true, fatal: false }), + projectDir: '/tmp/project', + }), + ); + }); + + it('reports foreground context-build exceptions', async () => { + const io = makeIo({ isTTY: true, interactive: true }); + const config = buildDefaultKtxProjectConfig(); + const project: KtxPublicIngestProject = { + projectDir: '/tmp/project', + config: { + ...config, + connections: { warehouse: { driver: 'postgres', password: 'env:INGEST_DB_PASSWORD' } }, // pragma: allowlist secret + llm: { + ...config.llm, + provider: { + backend: 'anthropic', + anthropic: { api_key: 'env:ANTHROPIC_API_KEY' }, // pragma: allowlist secret + }, + models: { default: 'claude-sonnet-4-6' }, + }, + }, + }; + const runContextBuild = vi.fn(async () => { + throw new Error('context build failed'); + }); + + await expect( + runKtxPublicIngest( + { + command: 'run', + projectDir: '/tmp/project', + targetConnectionId: 'warehouse', + all: false, + json: false, + inputMode: 'auto', + queryHistory: 'default', + }, + io.io, + { + loadProject: vi.fn(async () => project), + runContextBuild, + env: { + ...process.env, + ANTHROPIC_API_KEY: 'ingest-anthropic-secret', // pragma: allowlist secret + INGEST_DB_PASSWORD: 'ingest-db-password', // pragma: allowlist secret + }, + }, + ), + ).resolves.toBe(1); + + expect(io.stderr()).toContain('context build failed'); + expect(reportExceptionMock).toHaveBeenCalledWith( + expect.objectContaining({ + context: expect.objectContaining({ source: 'ingest context-build', handled: true, fatal: false }), + projectDir: '/tmp/project', + redactionSecrets: expect.arrayContaining(['ingest-anthropic-secret', 'ingest-db-password']), + }), + ); + }); + it('preflights foreground managed embeddings runtime before starting the context-build view', async () => { const io = makeIo({ isTTY: true, interactive: true }); const config = buildDefaultKtxProjectConfig(); diff --git a/packages/cli/test/scan.test.ts b/packages/cli/test/scan.test.ts index 6a524fba..51c55498 100644 --- a/packages/cli/test/scan.test.ts +++ b/packages/cli/test/scan.test.ts @@ -2,12 +2,19 @@ import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import type { SourceAdapter } from '../src/context/ingest/types.js'; +import { parseKtxProjectConfig, serializeKtxProjectConfig } from '../src/context/project/config.js'; import { initKtxProject } from '../src/context/project/project.js'; import type { KtxScanReport } from '../src/context/scan/types.js'; import type { LocalScanRunResult, RunLocalScanOptions } from '../src/context/scan/local-scan.js'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { createCliScanProgress, runKtxScan, type KtxScanDeps } from '../src/scan.js'; +const reportExceptionMock = vi.hoisted(() => vi.fn(async () => {})); + +vi.mock('../src/telemetry/exception.js', () => ({ + reportException: reportExceptionMock, +})); + const sqlServerExtractSchema = vi.hoisted(() => vi.fn(async (connectionId: string) => ({ connectionId, @@ -317,6 +324,7 @@ describe('runKtxScan', () => { beforeEach(async () => { tempDir = await mkdtemp(join(tmpdir(), 'ktx-cli-scan-')); + reportExceptionMock.mockClear(); }); afterEach(async () => { @@ -426,7 +434,28 @@ describe('runKtxScan', () => { it('records the raw errorDetail in scan_completed telemetry when the scan throws', async () => { vi.stubEnv('KTX_TELEMETRY_DEBUG', '1'); vi.stubEnv('CI', ''); + vi.stubEnv('ANTHROPIC_API_KEY', 'anthropic-callsite-secret'); // pragma: allowlist secret + vi.stubEnv('DATABASE_URL', 'postgres://svc:scan-db-password@db.example.test/analytics'); // pragma: allowlist secret await initKtxProject({ projectDir: tempDir }); + const config = parseKtxProjectConfig(await readFile(join(tempDir, 'ktx.yaml'), 'utf-8')); + await writeFile( + join(tempDir, 'ktx.yaml'), + serializeKtxProjectConfig({ + ...config, + connections: { + warehouse: { driver: 'postgres', url: 'env:DATABASE_URL' }, + }, + llm: { + ...config.llm, + provider: { + backend: 'anthropic', + anthropic: { api_key: 'env:ANTHROPIC_API_KEY' }, // pragma: allowlist secret + }, + models: { default: 'claude-sonnet-4-6' }, + }, + }), + 'utf-8', + ); const runLocalScan = vi.fn(async (): Promise => { const error = new Error('introspection timed out'); (error as { code?: unknown }).code = 'ETIMEDOUT'; @@ -452,6 +481,17 @@ describe('runKtxScan', () => { expect(io.stderr()).toContain('"event":"scan_completed"'); expect(io.stderr()).toContain('"outcome":"error"'); expect(io.stderr()).toContain('"errorDetail":"ETIMEDOUT: introspection timed out"'); + expect(reportExceptionMock).toHaveBeenCalledWith( + expect.objectContaining({ + context: expect.objectContaining({ source: 'scan run', handled: true, fatal: false }), + projectDir: tempDir, + redactionSecrets: expect.arrayContaining([ + 'anthropic-callsite-secret', + 'postgres://svc:scan-db-password@db.example.test/analytics', // pragma: allowlist secret + 'scan-db-password', + ]), + }), + ); }); it('passes KTX daemon options to local ingest adapters when no explicit daemon URL is set', async () => { diff --git a/packages/cli/test/sl.test.ts b/packages/cli/test/sl.test.ts index ff9c1489..489ea950 100644 --- a/packages/cli/test/sl.test.ts +++ b/packages/cli/test/sl.test.ts @@ -1,12 +1,19 @@ -import { mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { stripVTControlCharacters } from 'node:util'; import Database from 'better-sqlite3'; +import { parseKtxProjectConfig, serializeKtxProjectConfig } from '../src/context/project/config.js'; import { initKtxProject } from '../src/context/project/project.js'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { runKtxSl } from '../src/sl.js'; +const reportExceptionMock = vi.hoisted(() => vi.fn(async () => {})); + +vi.mock('../src/telemetry/exception.js', () => ({ + reportException: reportExceptionMock, +})); + const ORDERS_YAML = [ 'name: orders', 'table: public.orders', @@ -61,6 +68,7 @@ describe('runKtxSl', () => { beforeEach(async () => { tempDir = await mkdtemp(join(tmpdir(), 'ktx-cli-sl-')); + reportExceptionMock.mockClear(); }); afterEach(async () => { @@ -351,6 +359,12 @@ describe('runKtxSl', () => { expect(validateIo.stdout()).toBe(''); expect(validateIo.stderr()).toBe('Semantic-layer source "missing_orders" was not found\n'); + expect(reportExceptionMock).toHaveBeenCalledWith( + expect.objectContaining({ + context: expect.objectContaining({ source: 'sl validate', handled: true, fatal: false }), + projectDir, + }), + ); }); it('keeps scoped validation not-found wording', async () => { @@ -552,6 +566,53 @@ joins: [] expect(stderr.write).not.toHaveBeenCalled(); }); + it('reports sl query exceptions at the query catch boundary', async () => { + vi.stubEnv('ANTHROPIC_API_KEY', 'sl-anthropic-secret'); // pragma: allowlist secret + const projectDir = join(tempDir, 'missing-query-input'); + await seedSlSource({ projectDir }); + const config = parseKtxProjectConfig(await readFile(join(projectDir, 'ktx.yaml'), 'utf-8')); + await writeFile( + join(projectDir, 'ktx.yaml'), + serializeKtxProjectConfig({ + ...config, + llm: { + ...config.llm, + provider: { + backend: 'anthropic', + anthropic: { api_key: 'env:ANTHROPIC_API_KEY' }, // pragma: allowlist secret + }, + models: { default: 'claude-sonnet-4-6' }, + }, + }), + 'utf-8', + ); + const io = makeIo(); + + await expect( + runKtxSl( + { + command: 'query', + projectDir, + connectionId: 'warehouse', + format: 'json', + execute: false, + cliVersion: '0.2.0', + runtimeInstallPolicy: 'auto', + }, + io.io, + ), + ).resolves.toBe(1); + + expect(io.stderr()).toContain('sl query requires query input'); + expect(reportExceptionMock).toHaveBeenCalledWith( + expect.objectContaining({ + context: expect.objectContaining({ source: 'sl query', handled: true, fatal: false }), + projectDir, + redactionSecrets: expect.arrayContaining(['sl-anthropic-secret']), + }), + ); + }); + it('emits debug telemetry for sl query without project paths', async () => { vi.stubEnv('KTX_TELEMETRY_DEBUG', '1'); vi.stubEnv('CI', ''); diff --git a/packages/cli/test/sql.test.ts b/packages/cli/test/sql.test.ts index ef74fd49..5e297429 100644 --- a/packages/cli/test/sql.test.ts +++ b/packages/cli/test/sql.test.ts @@ -8,6 +8,12 @@ import type { SqlAnalysisPort } from '../src/context/sql-analysis/ports.js'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { runKtxSql } from '../src/sql.js'; +const reportExceptionMock = vi.hoisted(() => vi.fn(async () => {})); + +vi.mock('../src/telemetry/exception.js', () => ({ + reportException: reportExceptionMock, +})); + function makeIo(options: { isTTY?: boolean } = {}) { let stdout = ''; let stderr = ''; @@ -76,6 +82,7 @@ describe('runKtxSql', () => { beforeEach(async () => { tempDir = await mkdtemp(join(tmpdir(), 'ktx-cli-sql-')); + reportExceptionMock.mockClear(); }); afterEach(async () => { @@ -236,9 +243,10 @@ describe('runKtxSql', () => { }); it('rejects non-read-only SQL before executing connector SQL', async () => { + vi.stubEnv('SQL_DB_PASSWORD', 'sql-db-password'); // pragma: allowlist secret const projectDir = join(tempDir, 'project'); await initKtxProject({ projectDir }); - await writeConnections(projectDir, { warehouse: { driver: 'sqlite', path: 'warehouse.db' } }); + await writeConnections(projectDir, { warehouse: { driver: 'postgres', password: 'env:SQL_DB_PASSWORD' } }); // pragma: allowlist secret const connector = makeConnector(); const io = makeIo(); @@ -265,6 +273,13 @@ describe('runKtxSql', () => { expect(connector.executeReadOnly).not.toHaveBeenCalled(); expect(connector.cleanup).not.toHaveBeenCalled(); expect(io.stderr()).toContain('SQL contains read/write operation: Delete'); + expect(reportExceptionMock).toHaveBeenCalledWith( + expect.objectContaining({ + context: expect.objectContaining({ source: 'sql run', handled: true, fatal: false }), + projectDir, + redactionSecrets: expect.arrayContaining(['sql-db-password']), + }), + ); }); it('rejects missing connections', async () => { diff --git a/packages/cli/test/telemetry/exception-payload.test.ts b/packages/cli/test/telemetry/exception-payload.test.ts new file mode 100644 index 00000000..da81e62e --- /dev/null +++ b/packages/cli/test/telemetry/exception-payload.test.ts @@ -0,0 +1,150 @@ +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { createServer, type IncomingMessage } from 'node:http'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { gunzipSync } from 'node:zlib'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import type { KtxCliIo } from '../../src/cli-runtime.js'; +import { __resetTelemetryEmitterForTests } from '../../src/telemetry/emitter.js'; +import { + __resetTelemetryExceptionStateForTests, + reportException, +} from '../../src/telemetry/exception.js'; + +function makeIo(): KtxCliIo { + return { + stdout: { write: () => {} }, + stderr: { write: () => {} }, + }; +} + +async function body(req: IncomingMessage): Promise { + const chunks: Buffer[] = []; + for await (const chunk of req) { + chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); + } + const raw = Buffer.concat(chunks); + return req.headers['content-encoding'] === 'gzip' ? gunzipSync(raw).toString('utf-8') : raw.toString('utf-8'); +} + +async function withCaptureServer(run: (url: string, payloads: unknown[]) => Promise): Promise { + const payloads: unknown[] = []; + const server = createServer(async (req, res) => { + if (req.method === 'POST') { + payloads.push(JSON.parse(await body(req))); + } + res.statusCode = 200; + res.setHeader('content-type', 'application/json'); + res.end('{}'); + }); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + const address = server.address(); + if (!address || typeof address === 'string') { + throw new Error('test server did not bind to a TCP port'); + } + try { + return await run(`http://127.0.0.1:${address.port}`, payloads); + } finally { + await new Promise((resolve) => server.close(() => resolve())); + } +} + +function findExceptionEvent(payloads: unknown[]): Record { + for (const payload of payloads) { + if (typeof payload !== 'object' || payload === null) { + continue; + } + const record = payload as Record; + const batch = Array.isArray(record.batch) ? record.batch : [record]; + for (const item of batch) { + if (typeof item === 'object' && item !== null && (item as Record).event === '$exception') { + return item as Record; + } + } + } + throw new Error(`No $exception payload found: ${JSON.stringify(payloads)}`); +} + +describe('prepared Node exception payload', () => { + let homeDir: string; + + beforeEach(async () => { + homeDir = await mkdtemp(join(tmpdir(), 'ktx-node-exception-payload-')); + await mkdir(join(homeDir, '.ktx'), { recursive: true }); + await writeFile( + join(homeDir, '.ktx', 'telemetry.json'), + `${JSON.stringify({ + installId: '00000000-0000-4000-8000-000000000000', + enabled: true, + createdAt: '2026-06-05T00:00:00.000Z', + })}\n`, + 'utf-8', + ); + vi.stubEnv('HOME', homeDir); + vi.stubEnv('CI', ''); + vi.stubEnv('KTX_TELEMETRY_DISABLED', ''); + vi.stubEnv('DO_NOT_TRACK', ''); + __resetTelemetryEmitterForTests(); + __resetTelemetryExceptionStateForTests(); + }); + + afterEach(async () => { + vi.unstubAllEnvs(); + await rm(homeDir, { recursive: true, force: true }); + }); + + it('sends projectId, omits $groups, and redacts the serialized exception list', async () => { + await withCaptureServer(async (endpoint, payloads) => { + vi.stubEnv('KTX_TELEMETRY_ENDPOINT', endpoint); + const projectDir = join(homeDir, 'project'); + const snapshotSecret = ['plain', 'secret', 'value'].join('-'); + const dbPassword = ['db', 'url', 'secret'].join('-'); + const authToken = ['abc', '123'].join(''); + const error = new Error( + `${snapshotSecret} postgres://svc:${dbPassword}@db.example.test/analytics Authorization: Basic ${authToken}`, + ); + + await reportException({ + error, + context: { source: 'scan run', handled: true, fatal: false }, + io: makeIo(), + packageInfo: { name: '@kaelio/ktx', version: '0.0.0-test' }, + projectDir, + immediate: true, + redactionSecrets: [snapshotSecret], + }); + + const event = findExceptionEvent(payloads); + const properties = event.properties as Record; + expect(properties.projectId).toMatch(/^[a-f0-9]{64}$/); + expect(properties.$groups).toBeUndefined(); + expect(JSON.stringify(properties.$exception_list)).toContain('[redacted]'); + expect(JSON.stringify(properties.$exception_list)).not.toContain(snapshotSecret); + expect(JSON.stringify(properties.$exception_list)).not.toContain(dbPassword); + expect(JSON.stringify(properties.$exception_list)).not.toContain(authToken); + for (const key of [ + 'argv', + 'args', + 'env', + 'environment', + 'sql', + 'query', + 'prompt', + 'mcpArguments', + 'tableName', + 'schemaName', + 'columnName', + 'databaseUrl', + 'connectionString', + 'url', + 'password', + 'token', + 'apiKey', + 'authorization', + ]) { + expect(properties).not.toHaveProperty(key); + } + }); + }); +}); diff --git a/packages/cli/test/telemetry/exception.test.ts b/packages/cli/test/telemetry/exception.test.ts new file mode 100644 index 00000000..01608935 --- /dev/null +++ b/packages/cli/test/telemetry/exception.test.ts @@ -0,0 +1,456 @@ +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import type { KtxCliIo } from '../../src/cli-runtime.js'; +import { __resetTelemetryEmitterForTests } from '../../src/telemetry/emitter.js'; +import { + __resetTelemetryExceptionStateForTests, + reportException, +} from '../../src/telemetry/exception.js'; + +const captures: unknown[] = []; +const immediateCaptures: unknown[] = []; +const shutdown = vi.fn(async () => {}); + +vi.mock('posthog-node', () => ({ + PostHog: vi.fn(function PostHog() { + return { + captureException: ( + error: unknown, + distinctId?: string, + properties?: Record, + ) => { + captures.push({ error, distinctId, properties }); + }, + captureExceptionImmediate: async ( + error: unknown, + distinctId?: string, + properties?: Record, + ) => { + immediateCaptures.push({ error, distinctId, properties }); + }, + capture: vi.fn(), + shutdown, + }; + }), +})); + +function makeIo(): { io: KtxCliIo; stderr: () => string } { + let stderr = ''; + return { + io: { + stdout: { write: () => {} }, + stderr: { + write: (chunk) => { + stderr += chunk; + }, + }, + }, + stderr: () => stderr, + }; +} + +async function writeIdentity(homeDir: string, enabled = true): Promise { + const path = join(homeDir, '.ktx', 'telemetry.json'); + await mkdir(join(homeDir, '.ktx'), { recursive: true }); + await writeFile( + path, + `${JSON.stringify({ + installId: '00000000-0000-4000-8000-000000000000', + enabled, + createdAt: '2026-06-05T00:00:00.000Z', + })}\n`, + 'utf-8', + ); +} + +describe('reportException', () => { + let homeDir: string; + + beforeEach(async () => { + homeDir = await mkdtemp(join(tmpdir(), 'ktx-exception-')); + await writeIdentity(homeDir); + vi.stubEnv('HOME', homeDir); + vi.stubEnv('CI', ''); + vi.stubEnv('KTX_TELEMETRY_DISABLED', ''); + vi.stubEnv('DO_NOT_TRACK', ''); + captures.length = 0; + immediateCaptures.length = 0; + shutdown.mockClear(); + __resetTelemetryEmitterForTests(); + __resetTelemetryExceptionStateForTests(); + }); + + afterEach(async () => { + vi.unstubAllEnvs(); + await rm(homeDir, { recursive: true, force: true }); + }); + + it('honors telemetry kill switches', async () => { + vi.stubEnv('KTX_TELEMETRY_DISABLED', '1'); + const { io } = makeIo(); + + await reportException({ + error: new Error('boom'), + context: { source: 'scan run', handled: true, fatal: false }, + io, + packageInfo: { name: '@kaelio/ktx', version: '0.0.0-test' }, + projectDir: join(homeDir, 'project'), + }); + + expect(captures).toEqual([]); + expect(immediateCaptures).toEqual([]); + }); + + it('prints debug payloads without sending', async () => { + vi.stubEnv('KTX_TELEMETRY_DEBUG', '1'); + vi.stubEnv('KTX_TELEMETRY_DISABLED', '1'); + const { io, stderr } = makeIo(); + + await reportException({ + error: new Error('debug boom'), + context: { source: 'scan run', handled: true, fatal: false }, + io, + packageInfo: { name: '@kaelio/ktx', version: '0.0.0-test' }, + projectDir: join(homeDir, 'project'), + }); + + expect(stderr()).toContain('[telemetry-exception]'); + expect(stderr()).toContain('"source":"scan run"'); + expect(captures).toEqual([]); + }); + + it('sends projectId as a property and omits $groups for Node exceptions', async () => { + const { io } = makeIo(); + + await reportException({ + error: new Error('project boom'), + context: { source: 'sql run', handled: true, fatal: false }, + io, + packageInfo: { name: '@kaelio/ktx', version: '0.0.0-test' }, + projectDir: join(homeDir, 'project'), + }); + + expect(captures).toHaveLength(1); + expect(captures[0]).toMatchObject({ + distinctId: '00000000-0000-4000-8000-000000000000', + properties: { + source: 'sql run', + handled: true, + fatal: false, + cliVersion: '0.0.0-test', + runtime: 'node', + }, + }); + expect( + (captures[0] as { properties: Record }).properties.projectId, + ).toMatch(/^[a-f0-9]{64}$/); + expect((captures[0] as { properties: Record }).properties.$groups).toBeUndefined(); + }); + + it('uses captureExceptionImmediate for fatal reports', async () => { + const { io } = makeIo(); + + await reportException({ + error: new Error('fatal boom'), + context: { source: 'uncaughtException', handled: false, fatal: true }, + io, + packageInfo: { name: '@kaelio/ktx', version: '0.0.0-test' }, + immediate: true, + }); + + expect(immediateCaptures).toHaveLength(1); + expect(captures).toEqual([]); + }); + + it('redacts snapshot secrets and static credential patterns from message and cause', async () => { + const { io } = makeIo(); + const cause = new Error('cause has sk-live-fixture-value and Authorization: Bearer token-123'); + const error = new Error('message has sk-live-fixture-value and password=hunter2', { cause }); + + await reportException({ + error, + context: { source: 'connection test', handled: true, fatal: false }, + io, + packageInfo: { name: '@kaelio/ktx', version: '0.0.0-test' }, + redactionSecrets: ['sk-live-fixture-value'], + }); + + const sent = captures[0] as { error: Error & { cause?: Error } }; + expect(sent.error.message).toContain('[redacted]'); + expect(sent.error.message).not.toContain('sk-live-fixture-value'); + expect(sent.error.message).not.toContain('hunter2'); + expect(sent.error.cause?.message).not.toContain('token-123'); + }); + + it('redacts URL userinfo credentials and non-bearer authorization values', async () => { + const { io } = makeIo(); + const error = new Error( + 'connect postgres://svc:db-url-secret@db.example.test/analytics Authorization: Basic abc123', // pragma: allowlist secret + ); + + await reportException({ + error, + context: { source: 'connection test', handled: true, fatal: false }, + io, + packageInfo: { name: '@kaelio/ktx', version: '0.0.0-test' }, + }); + + const sent = captures[0] as { error: Error }; + expect(sent.error.message).toContain('postgres://svc:[redacted]@db.example.test/analytics'); + expect(sent.error.message).toContain('Authorization: [redacted]'); + expect(sent.error.message).not.toContain('db-url-secret'); + expect(sent.error.message).not.toContain('abc123'); + }); + + it('does not use process-global secret discovery when no snapshot is supplied', async () => { + vi.stubEnv('KTX_FAKE_SECRET', 'plain-secret-without-pattern'); + const { io } = makeIo(); + + await reportException({ + error: new Error('plain-secret-without-pattern'), + context: { source: 'uncaughtException', handled: false, fatal: true }, + io, + packageInfo: { name: '@kaelio/ktx', version: '0.0.0-test' }, + }); + + const sent = captures[0] as { error: Error }; + expect(sent.error.message).toContain('plain-secret-without-pattern'); + }); + + it('dedupes the same Error instance between operation and global tiers', async () => { + const { io } = makeIo(); + const error = new Error('same object'); + + await reportException({ + error, + context: { source: 'scan run', handled: true, fatal: false }, + io, + packageInfo: { name: '@kaelio/ktx', version: '0.0.0-test' }, + }); + await reportException({ + error, + context: { source: 'uncaughtException', handled: false, fatal: true }, + io, + packageInfo: { name: '@kaelio/ktx', version: '0.0.0-test' }, + immediate: true, + }); + + expect(captures).toHaveLength(1); + expect(immediateCaptures).toHaveLength(0); + }); + + it('captures wrapped Error causes as distinct logical occurrences', async () => { + const { io } = makeIo(); + const inner = new Error('inner'); + const wrapper = new Error('outer', { cause: inner }); + + await reportException({ + error: inner, + context: { source: 'sl query', handled: true, fatal: false }, + io, + packageInfo: { name: '@kaelio/ktx', version: '0.0.0-test' }, + }); + await reportException({ + error: wrapper, + context: { source: 'uncaughtException', handled: false, fatal: true }, + io, + packageInfo: { name: '@kaelio/ktx', version: '0.0.0-test' }, + immediate: true, + }); + + expect(captures).toHaveLength(1); + expect(immediateCaptures).toHaveLength(1); + }); + + it('dedupes primitive and plain-object throwables propagated to the global tier', async () => { + const { io } = makeIo(); + const objectThrowable = { message: 'plain object' }; + + await reportException({ + error: 'primitive boom', + context: { source: 'mcp:sql_execution', handled: true, fatal: false }, + io, + packageInfo: { name: '@kaelio/ktx', version: '0.0.0-test' }, + }); + await reportException({ + error: 'primitive boom', + context: { source: 'unhandledRejection', handled: false, fatal: true }, + io, + packageInfo: { name: '@kaelio/ktx', version: '0.0.0-test' }, + immediate: true, + }); + await reportException({ + error: objectThrowable, + context: { source: 'mcp:discover_data', handled: true, fatal: false }, + io, + packageInfo: { name: '@kaelio/ktx', version: '0.0.0-test' }, + }); + await reportException({ + error: objectThrowable, + context: { source: 'unhandledRejection', handled: false, fatal: true }, + io, + packageInfo: { name: '@kaelio/ktx', version: '0.0.0-test' }, + immediate: true, + }); + + expect(captures).toHaveLength(2); + expect(immediateCaptures).toHaveLength(0); + }); + + it('does not collapse independent primitive throw events with the same value', async () => { + const { io } = makeIo(); + + await reportException({ + error: 'oops', + context: { source: 'scan run', handled: true, fatal: false }, + io, + packageInfo: { name: '@kaelio/ktx', version: '0.0.0-test' }, + }); + await reportException({ + error: 'oops', + context: { source: 'sql run', handled: true, fatal: false }, + io, + packageInfo: { name: '@kaelio/ktx', version: '0.0.0-test' }, + }); + + expect(captures).toHaveLength(2); + }); + + it('drops forbidden caller-supplied extra property keys', async () => { + const { io } = makeIo(); + + await reportException({ + error: new Error('extra property boom'), + context: { + source: 'sql run', + handled: true, + fatal: false, + extra: { + sql: 'select * from private_table', + tableName: 'private_table', + schemaName: 'private_schema', + columnName: 'private_column', + argv: '--password secret', + env: 'KTX_TOKEN=secret', + password: 'secret-password', // pragma: allowlist secret + token: 'secret-token', + prompt: 'user prompt', + safeCount: 3, + }, + }, + io, + packageInfo: { name: '@kaelio/ktx', version: '0.0.0-test' }, + }); + + const sent = captures[0] as { properties: Record }; + expect(sent.properties.safeCount).toBe(3); + for (const key of [ + 'sql', + 'tableName', + 'schemaName', + 'columnName', + 'argv', + 'env', + 'password', + 'token', + 'prompt', + ]) { + expect(sent.properties).not.toHaveProperty(key); + } + }); + + it('redacts every required static credential pattern and leaves benign text intact', async () => { + const { io } = makeIo(); + const cases: Array<{ message: string; leaked: string; expected: string }> = [ + { + message: 'dsn password=hunter2', + leaked: 'hunter2', + expected: 'password=[redacted]', + }, + { + message: 'dsn pwd=swordfish', + leaked: 'swordfish', + expected: 'pwd=[redacted]', + }, + { + message: 'Authorization: Basic abc123', + leaked: 'abc123', + expected: 'Authorization: [redacted]', + }, + { + message: 'Authorization: Bearer token-123', + leaked: 'token-123', + expected: 'Authorization: [redacted]', + }, + { + message: 'Bearer standalone-token', + leaked: 'standalone-token', + expected: 'Bearer [redacted]', + }, + { + message: 'api_key=sk-live-secret', + leaked: 'sk-live-secret', + expected: 'api_key=[redacted]', + }, + { + message: 'api-key: sk-dash-secret', + leaked: 'sk-dash-secret', + expected: 'api-key=[redacted]', + }, + { + message: 'KTX_PROVIDER_TOKEN=ktx-secret', + leaked: 'ktx-secret', + expected: 'KTX_PROVIDER_TOKEN=[redacted]', + }, + { + message: 'REFRESH_SECRET: refresh-secret', + leaked: 'refresh-secret', + expected: 'REFRESH_SECRET=[redacted]', + }, + { + message: 'https://s3.example.test/file?X-Amz-Signature=aws-secret&ok=1', + leaked: 'aws-secret', + expected: 'X-Amz-Signature=[redacted]', + }, + { + message: 'https://storage.example.test/file?X-Goog-Signature=goog-secret&ok=1', + leaked: 'goog-secret', + expected: 'X-Goog-Signature=[redacted]', + }, + { + message: 'https://cdn.example.test/file?sig=signed-secret&ok=1', + leaked: 'signed-secret', + expected: 'sig=[redacted]', + }, + { + message: 'postgres://svc:url-password@db.example.test/analytics', // pragma: allowlist secret + leaked: 'url-password', + expected: 'postgres://svc:[redacted]@db.example.test/analytics', + }, + ]; + + for (const item of cases) { + await reportException({ + error: new Error(item.message), + context: { source: 'connection test', handled: true, fatal: false }, + io, + packageInfo: { name: '@kaelio/ktx', version: '0.0.0-test' }, + }); + const sent = captures[captures.length - 1] as { error: Error }; + expect(sent.error.message).toContain(item.expected); + expect(sent.error.message).not.toContain(item.leaked); + } + + await reportException({ + error: new Error('token bucket metrics and passwordless auth are benign'), + context: { source: 'connection test', handled: true, fatal: false }, + io, + packageInfo: { name: '@kaelio/ktx', version: '0.0.0-test' }, + }); + const benign = captures[captures.length - 1] as { error: Error }; + expect(benign.error.message).toBe('token bucket metrics and passwordless auth are benign'); + }); +}); diff --git a/packages/cli/test/telemetry/index.test.ts b/packages/cli/test/telemetry/index.test.ts index 7e88410f..3531116a 100644 --- a/packages/cli/test/telemetry/index.test.ts +++ b/packages/cli/test/telemetry/index.test.ts @@ -3,7 +3,7 @@ import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import type { KtxCliIo } from '../../src/cli-runtime.js'; +import { createGlobalExceptionReporter, type KtxCliIo } from '../../src/cli-runtime.js'; import { beginCommandSpan, emitAbortedCommandAndShutdown, emitTelemetryEvent } from '../../src/telemetry/index.js'; import { resetCommandSpan } from '../../src/telemetry/command-hook.js'; @@ -120,3 +120,36 @@ describe('emitAbortedCommandAndShutdown', () => { expect(secondIo.stderr()).not.toContain('"event":"command"'); }); }); + +describe('global exception reporting contract', () => { + let homeDir: string; + + beforeEach(async () => { + homeDir = await mkdtemp(join(tmpdir(), 'ktx-telemetry-global-exception-')); + vi.stubEnv('HOME', homeDir); + vi.stubEnv('KTX_TELEMETRY_DEBUG', '1'); + vi.stubEnv('KTX_TELEMETRY_DISABLED', '1'); + vi.stubEnv('DO_NOT_TRACK', ''); + vi.stubEnv('CI', ''); + }); + + afterEach(async () => { + vi.unstubAllEnvs(); + await rm(homeDir, { recursive: true, force: true }); + }); + + it('reports uncaughtException through the fatal debug payload', async () => { + const testIo = makeIo(); + const report = createGlobalExceptionReporter(testIo.io, { + name: '@kaelio/ktx', + version: '0.0.0-test', + }); + + await report('uncaughtException', new Error('global boom')); + + expect(testIo.stderr()).toContain('[telemetry-exception]'); + expect(testIo.stderr()).toContain('"source":"uncaughtException"'); + expect(testIo.stderr()).toContain('"handled":false'); + expect(testIo.stderr()).toContain('"fatal":true'); + }); +}); diff --git a/packages/cli/test/telemetry/redaction-secrets.test.ts b/packages/cli/test/telemetry/redaction-secrets.test.ts new file mode 100644 index 00000000..cdc15f22 --- /dev/null +++ b/packages/cli/test/telemetry/redaction-secrets.test.ts @@ -0,0 +1,127 @@ +import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { parseKtxProjectConfig, serializeKtxProjectConfig } from '../../src/context/project/config.js'; +import { initKtxProject } from '../../src/context/project/project.js'; +import { collectTelemetryRedactionSecrets } from '../../src/telemetry/redaction-secrets.js'; + +describe('collectTelemetryRedactionSecrets', () => { + let tempDir: string; + + beforeEach(async () => { + tempDir = await mkdtemp(join(tmpdir(), 'ktx-redaction-secrets-')); + }); + + afterEach(async () => { + vi.unstubAllEnvs(); + await rm(tempDir, { recursive: true, force: true }); + }); + + async function writeConfig(projectDir: string): Promise { + const configPath = join(projectDir, 'ktx.yaml'); + const config = parseKtxProjectConfig(await readFile(configPath, 'utf-8')); + await writeFile( + configPath, + serializeKtxProjectConfig({ + ...config, + llm: { + ...config.llm, + provider: { + backend: 'anthropic', + anthropic: { api_key: 'env:ANTHROPIC_API_KEY' }, // pragma: allowlist secret + }, + models: { default: 'claude-sonnet-4-6' }, + }, + ingest: { + ...config.ingest, + embeddings: { + backend: 'openai', + model: 'text-embedding-3-small', + dimensions: 1536, + openai: { api_key: 'file:~/.ktx/secrets/openai-key' }, // pragma: allowlist secret + }, + }, + scan: { + ...config.scan, + enrichment: { + ...config.scan.enrichment, + embeddings: { + backend: 'openai', + model: 'text-embedding-3-small', + dimensions: 1536, + openai: { api_key: 'env:SCAN_OPENAI_API_KEY' }, // pragma: allowlist secret + }, + }, + }, + connections: { + warehouse: { + driver: 'postgres', + url: 'env:DATABASE_URL', + password: 'file:~/.ktx/secrets/db-password', // pragma: allowlist secret + }, + docs: { + driver: 'notion', + auth_token_ref: 'env:NOTION_TOKEN', // pragma: allowlist secret + }, + }, + }), + 'utf-8', + ); + } + + it('derives only declared project secrets and parsed URL credentials', async () => { + const homeDir = join(tempDir, 'home'); + const projectDir = join(tempDir, 'project'); + await mkdir(join(homeDir, '.ktx', 'secrets'), { recursive: true }); + await writeFile(join(homeDir, '.ktx', 'secrets', 'openai-key'), 'openai-file-secret\n', 'utf-8'); + await writeFile(join(homeDir, '.ktx', 'secrets', 'db-password'), 'db-file-password\n', 'utf-8'); + vi.stubEnv('HOME', homeDir); + vi.stubEnv('ANTHROPIC_API_KEY', 'anthropic-env-secret'); + vi.stubEnv('SCAN_OPENAI_API_KEY', 'scan-openai-env-secret'); + vi.stubEnv('DATABASE_URL', 'postgres://svc:db-url-password@db.example.test/analytics'); // pragma: allowlist secret + vi.stubEnv('NOTION_TOKEN', 'notion-env-secret'); + vi.stubEnv('UNDECLARED_SECRET', 'must-not-appear'); + await initKtxProject({ projectDir }); + await writeConfig(projectDir); + + const secrets = await collectTelemetryRedactionSecrets({ + projectDir, + connectionId: 'warehouse', + includeLlm: true, + includeEmbeddings: true, + env: process.env, + }); + + expect(secrets).toEqual( + expect.arrayContaining([ + 'anthropic-env-secret', + 'openai-file-secret', + 'scan-openai-env-secret', + 'postgres://svc:db-url-password@db.example.test/analytics', // pragma: allowlist secret + 'db-url-password', + 'db-file-password', + ]), + ); + expect(secrets).not.toContain('notion-env-secret'); + expect(secrets).not.toContain('must-not-appear'); + }); + + it('can derive a named non-database connection secret', async () => { + const projectDir = join(tempDir, 'project'); + vi.stubEnv('NOTION_TOKEN', 'notion-env-secret'); + await initKtxProject({ projectDir }); + await writeConfig(projectDir); + + const secrets = await collectTelemetryRedactionSecrets({ + projectDir, + connectionId: 'docs', + includeLlm: false, + includeEmbeddings: false, + env: process.env, + }); + + expect(secrets).toEqual(['notion-env-secret']); + }); +}); diff --git a/python/ktx-daemon/src/ktx_daemon/__main__.py b/python/ktx-daemon/src/ktx_daemon/__main__.py index 2fc00186..cbc2e228 100644 --- a/python/ktx-daemon/src/ktx_daemon/__main__.py +++ b/python/ktx-daemon/src/ktx_daemon/__main__.py @@ -6,6 +6,8 @@ import argparse import json import sys import time +from collections.abc import Callable +from types import TracebackType from typing import Any from pydantic import ValidationError @@ -90,6 +92,41 @@ def _read_stdin_json() -> dict[str, Any]: return parsed +def install_serve_http_exception_hooks(started_at: float) -> Callable[[], None]: + original_hook = sys.excepthook + + def hook( + exc_type: type[BaseException], + exc: BaseException, + tb: TracebackType | None, + ) -> None: + report_serve_http_crash(exc, started_at=started_at) + original_hook(exc_type, exc, tb) + + sys.excepthook = hook + + def dispose() -> None: + sys.excepthook = original_hook + + return dispose + + +def report_serve_http_crash(error: BaseException, *, started_at: float) -> None: + from ktx_daemon.telemetry import report_exception + from ktx_daemon.telemetry.daemon_lifecycle import emit_daemon_stopped_once + + report_exception( + error, + source="serve-http", + handled=False, + fatal=True, + ) + emit_daemon_stopped_once( + reason="crash", + uptime_ms=max(0, (time.perf_counter() - started_at) * 1000), + ) + + def run_http_server( *, host: str, @@ -102,15 +139,23 @@ def run_http_server( from ktx_daemon.app import create_app started_at = time.perf_counter() - uvicorn.run( - create_app( - enable_code_execution=enable_code_execution, - telemetry_started_at=started_at, - ), - host=host, - port=port, - log_level=log_level, - ) + dispose_hooks = install_serve_http_exception_hooks(started_at) + try: + try: + uvicorn.run( + create_app( + enable_code_execution=enable_code_execution, + telemetry_started_at=started_at, + ), + host=host, + port=port, + log_level=log_level, + ) + except Exception as error: + report_serve_http_crash(error, started_at=started_at) + raise + finally: + dispose_hooks() def main(argv: list[str] | None = None) -> int: @@ -169,6 +214,14 @@ def main(argv: list[str] | None = None) -> int: sys.stderr.write(f"{error}\n") return 1 except Exception as error: + from ktx_daemon.telemetry import report_exception + + report_exception( + error, + source=str(args.command), + handled=True, + fatal=False, + ) sys.stderr.write(f"{type(error).__name__}: {error}\n") return 1 diff --git a/python/ktx-daemon/src/ktx_daemon/app.py b/python/ktx-daemon/src/ktx_daemon/app.py index 7a3fa950..5860c4e4 100644 --- a/python/ktx-daemon/src/ktx_daemon/app.py +++ b/python/ktx-daemon/src/ktx_daemon/app.py @@ -10,8 +10,8 @@ from contextlib import asynccontextmanager from collections.abc import Callable from typing import Any -from fastapi import FastAPI, HTTPException -from fastapi.responses import Response +from fastapi import FastAPI, HTTPException, Request +from fastapi.responses import JSONResponse, Response from ktx_daemon import VERSION from ktx_daemon.code_execution import ( @@ -65,9 +65,11 @@ from ktx_daemon.table_identifier import ( ParseTableIdentifierBatchResponse, parse_table_identifier_response, ) -from ktx_daemon.telemetry import track_telemetry_event +from ktx_daemon.telemetry import report_exception, track_telemetry_event +from ktx_daemon.telemetry.daemon_lifecycle import emit_daemon_stopped_once logger = logging.getLogger(__name__) +CREDENTIAL_KEYS = {"url", "password", "token", "api_key", "apikey", "auth_header"} class NumpyORJSONResponse(Response): @@ -77,6 +79,36 @@ class NumpyORJSONResponse(Response): return dumps_numpy_json(content) +def _route_source(request: Request) -> str: + route = request.scope.get("route") + path = getattr(route, "path", None) + if isinstance(path, str) and path: + return f"app:{path}" + return f"app:{request.url.path}" + + +def _secret_snapshot_from_payload(value: Any) -> list[str]: + secrets: list[str] = [] + if isinstance(value, dict): + for key, child in value.items(): + normalized_key = str(key).lower() + if normalized_key in CREDENTIAL_KEYS and isinstance(child, str) and child: + secrets.append(child) + secrets.extend(_secret_snapshot_from_payload(child)) + elif isinstance(value, list): + for child in value: + secrets.extend(_secret_snapshot_from_payload(child)) + return secrets + + +async def _request_secret_snapshot(request: Request) -> list[str]: + try: + payload = await request.json() + except Exception: + return [] + return _secret_snapshot_from_payload(payload) + + def create_app( *, embedding_provider: EmbeddingProvider | None = None, @@ -104,12 +136,9 @@ def create_app( try: yield finally: - track_telemetry_event( - "daemon_stopped", - { - "reason": "request", - "uptimeMs": max(0, (clock() - started_at) * 1000), - }, + emit_daemon_stopped_once( + reason="request", + uptime_ms=max(0, (clock() - started_at) * 1000), ) app = FastAPI( @@ -119,6 +148,25 @@ def create_app( lifespan=lifespan, ) + @app.middleware("http") + async def report_unhandled_exceptions(request: Request, call_next): + redaction_secrets = await _request_secret_snapshot(request) + try: + return await call_next(request) + except Exception as error: + logger.exception("Unhandled daemon request failed: %s", error) + report_exception( + error, + source=_route_source(request), + handled=True, + fatal=False, + redaction_secrets=redaction_secrets, + ) + return JSONResponse( + status_code=500, + content={"detail": f"Daemon request failed: {error}"}, + ) + @app.get("/health") async def health() -> dict[str, str]: response = {"status": "healthy"} @@ -137,12 +185,6 @@ def create_app( except ValueError as error: logger.warning("Database introspection rejected: %s", error) raise HTTPException(status_code=400, detail=str(error)) from error - except Exception as error: - logger.exception("Database introspection failed: %s", error) - raise HTTPException( - status_code=500, - detail=f"Database introspection failed: {error}", - ) from error @app.post("/embeddings/compute", response_model=ComputeEmbeddingResponse) async def embedding_compute( @@ -156,12 +198,6 @@ def create_app( except ValueError as error: logger.warning("Embedding compute rejected: %s", error) raise HTTPException(status_code=400, detail=str(error)) from error - except Exception as error: - logger.exception("Embedding compute failed: %s", error) - raise HTTPException( - status_code=500, - detail=f"Embedding compute failed: {error}", - ) from error @app.post( "/embeddings/compute-bulk", @@ -178,12 +214,6 @@ def create_app( except ValueError as error: logger.warning("Bulk embedding compute rejected: %s", error) raise HTTPException(status_code=400, detail=str(error)) from error - except Exception as error: - logger.exception("Bulk embedding compute failed: %s", error) - raise HTTPException( - status_code=500, - detail=f"Bulk embedding compute failed: {error}", - ) from error if enable_code_execution: @@ -193,29 +223,15 @@ def create_app( response_class=NumpyORJSONResponse, ) async def code_execute(request: ExecuteCodeRequest) -> ExecuteCodeResponse: - try: - return execute_code_response( - request, - nest_api_url=None, - auth_header=None, - ) - except Exception as error: - logger.exception("Code execution failed: %s", error) - raise HTTPException( - status_code=500, - detail=f"Code execution failed: {error}", - ) from error + return execute_code_response( + request, + nest_api_url=None, + auth_header=None, + ) @app.post("/lookml/parse", response_model=ParseLookMLResponse) async def lookml_parse(request: ParseLookMLRequest) -> ParseLookMLResponse: - try: - return parse_lookml_project(request) - except Exception as error: - logger.exception("LookML parsing failed: %s", error) - raise HTTPException( - status_code=500, - detail=f"LookML parsing failed: {error}", - ) from error + return parse_lookml_project(request) @app.post( "/sql/parse-table-identifier", @@ -224,40 +240,19 @@ def create_app( async def sql_parse_table_identifier( request: ParseTableIdentifierBatchRequest, ) -> ParseTableIdentifierBatchResponse: - try: - return parse_table_identifier_response(request) - except Exception as error: - logger.exception("Table identifier parsing failed: %s", error) - raise HTTPException( - status_code=500, - detail=f"Table identifier parsing failed: {error}", - ) from error + return parse_table_identifier_response(request) @app.post("/sql/validate-read-only", response_model=ValidateReadOnlySqlResponse) async def sql_validate_read_only( request: ValidateReadOnlySqlRequest, ) -> ValidateReadOnlySqlResponse: - try: - return validate_read_only_sql_response(request) - except Exception as error: - logger.exception("SQL read-only validation failed: %s", error) - raise HTTPException( - status_code=500, - detail=f"SQL read-only validation failed: {error}", - ) from error + return validate_read_only_sql_response(request) @app.post("/sql/analyze-batch", response_model=AnalyzeSqlBatchResponse) async def sql_analyze_batch( request: AnalyzeSqlBatchRequest, ) -> AnalyzeSqlBatchResponse: - try: - return analyze_sql_batch_response(request) - except Exception as error: - logger.exception("SQL batch analysis failed: %s", error) - raise HTTPException( - status_code=500, - detail=f"SQL batch analysis failed: {error}", - ) from error + return analyze_sql_batch_response(request) @app.post( "/semantic-layer/generate-sources", response_model=GenerateSourcesResponse @@ -265,14 +260,7 @@ def create_app( async def semantic_generate_sources( request: GenerateSourcesRequest, ) -> GenerateSourcesResponse: - try: - return generate_sources_response(request) - except Exception as error: - logger.exception("Semantic source generation failed: %s", error) - raise HTTPException( - status_code=500, - detail=f"Semantic source generation failed: {error}", - ) from error + return generate_sources_response(request) @app.post("/semantic-layer/query", response_model=SemanticLayerQueryResponse) async def semantic_query( @@ -283,12 +271,6 @@ def create_app( except ValueError as error: logger.warning("Semantic query rejected: %s", error) raise HTTPException(status_code=400, detail=str(error)) from error - except Exception as error: - logger.exception("Semantic query failed: %s", error) - raise HTTPException( - status_code=500, - detail=f"Semantic layer query failed: {error}", - ) from error @app.post("/semantic-layer/validate", response_model=ValidateSourcesResponse) async def semantic_validate( diff --git a/python/ktx-daemon/src/ktx_daemon/semantic_layer.py b/python/ktx-daemon/src/ktx_daemon/semantic_layer.py index 78f57338..f58c6e39 100644 --- a/python/ktx-daemon/src/ktx_daemon/semantic_layer.py +++ b/python/ktx-daemon/src/ktx_daemon/semantic_layer.py @@ -5,7 +5,7 @@ from __future__ import annotations import time from typing import Any -from ktx_daemon.telemetry import error_class, track_telemetry_event +from ktx_daemon.telemetry import error_class, report_exception, track_telemetry_event from pydantic import BaseModel, ConfigDict, Field from semantic_layer.duplicate_check import validate_measure_duplicates from semantic_layer.engine import SemanticEngine @@ -150,6 +150,13 @@ def query_semantic_layer( track_telemetry_event( "sql_gen_completed", sql_fields, project_id=request.project_id ) + report_exception( + error, + source="semantic-query", + handled=True, + fatal=False, + project_id=request.project_id, + ) raise diff --git a/python/ktx-daemon/src/ktx_daemon/telemetry/__init__.py b/python/ktx-daemon/src/ktx_daemon/telemetry/__init__.py index ff9cd07f..bef42338 100644 --- a/python/ktx-daemon/src/ktx_daemon/telemetry/__init__.py +++ b/python/ktx-daemon/src/ktx_daemon/telemetry/__init__.py @@ -1,5 +1,12 @@ from __future__ import annotations +from ktx_daemon.telemetry.daemon_lifecycle import emit_daemon_stopped_once from ktx_daemon.telemetry.emitter import error_class, track_telemetry_event +from ktx_daemon.telemetry.exception import report_exception -__all__ = ["error_class", "track_telemetry_event"] +__all__ = [ + "emit_daemon_stopped_once", + "error_class", + "report_exception", + "track_telemetry_event", +] diff --git a/python/ktx-daemon/src/ktx_daemon/telemetry/daemon_lifecycle.py b/python/ktx-daemon/src/ktx_daemon/telemetry/daemon_lifecycle.py new file mode 100644 index 00000000..dc635601 --- /dev/null +++ b/python/ktx-daemon/src/ktx_daemon/telemetry/daemon_lifecycle.py @@ -0,0 +1,29 @@ +from __future__ import annotations + +from typing import Literal + +from ktx_daemon.telemetry.emitter import track_telemetry_event + +StopReason = Literal["signal", "request", "crash"] + +_daemon_stop_emitted = False + + +def emit_daemon_stopped_once(*, reason: StopReason, uptime_ms: float) -> bool: + global _daemon_stop_emitted + if _daemon_stop_emitted: + return False + _daemon_stop_emitted = True + track_telemetry_event( + "daemon_stopped", + { + "reason": reason, + "uptimeMs": max(0, uptime_ms), + }, + ) + return True + + +def reset_daemon_lifecycle_for_tests() -> None: + global _daemon_stop_emitted + _daemon_stop_emitted = False diff --git a/python/ktx-daemon/src/ktx_daemon/telemetry/exception.py b/python/ktx-daemon/src/ktx_daemon/telemetry/exception.py new file mode 100644 index 00000000..00050d1c --- /dev/null +++ b/python/ktx-daemon/src/ktx_daemon/telemetry/exception.py @@ -0,0 +1,156 @@ +from __future__ import annotations + +import json +import os +import re +import sys +from collections.abc import Mapping, Sequence +from pathlib import Path +from typing import Any + +from ktx_daemon import VERSION +from ktx_daemon.telemetry.emitter import POSTHOG_HOST, POSTHOG_PROJECT_API_KEY +from ktx_daemon.telemetry.events import _common_envelope +from ktx_daemon.telemetry.identity import load_telemetry_identity + +_KTX_REPORTED_ATTR = "__ktx_posthog_exception_reported" + + +def _debug_enabled(env: Mapping[str, str]) -> bool: + return env.get("KTX_TELEMETRY_DEBUG") == "1" + + +def _host(env: Mapping[str, str]) -> str: + return env.get("KTX_TELEMETRY_ENDPOINT") or POSTHOG_HOST + + +def _redact_static(value: str) -> str: + patterns = [ + ( + r"([a-z][a-z0-9+.-]*://[^:\s/@]+:)([^@\s/]+)(@)", + r"\1[redacted]\3", + ), + (r"\b(password|pwd)=([^;&\s]+)", r"\1=[redacted]"), + (r"\bAuthorization\s*:\s*[^\r\n,;]+", "Authorization: [redacted]"), + (r"\bBearer\s+[A-Za-z0-9._~+/=-]+", "Bearer [redacted]"), + (r"\b(api[_-]?key)\s*[:=]\s*([^\s,;]+)", r"\1=[redacted]"), + ( + r"\b(KTX_[A-Z0-9_]*|[A-Z0-9_]*(?:TOKEN|SECRET))\s*[:=]\s*([^\s,;]+)", + r"\1=[redacted]", + ), + (r"([?&](?:X-Amz-Signature|X-Goog-Signature|sig)=)[^&\s]+", r"\1[redacted]"), + ] + redacted = value + for pattern, replacement in patterns: + redacted = re.sub(pattern, replacement, redacted, flags=re.IGNORECASE) + return redacted + + +def _redact_text(value: str, secrets: Sequence[str]) -> str: + redacted = value + for secret in secrets: + if secret: + redacted = redacted.replace(secret, "[redacted]") + return _redact_static(redacted) + + +def _clone_exception(exception: BaseException, secrets: Sequence[str]) -> BaseException: + redacted_args = [_redact_text(str(arg), secrets) for arg in exception.args] + try: + cloned = type(exception)(*redacted_args) + except Exception: + cloned = RuntimeError(_redact_text(str(exception), secrets)) + cloned.__traceback__ = exception.__traceback__ + cloned.__cause__ = ( + _clone_exception(exception.__cause__, secrets) if exception.__cause__ else None + ) + cloned.__context__ = ( + _clone_exception(exception.__context__, secrets) + if exception.__context__ + else None + ) + return cloned + + +def _should_skip_as_reported(exception: BaseException) -> bool: + if getattr(exception, _KTX_REPORTED_ATTR, False): + return True + try: + setattr(exception, _KTX_REPORTED_ATTR, True) + except Exception: + return False + return False + + +def _properties(*, source: str, handled: bool, fatal: bool) -> dict[str, Any]: + return { + **_common_envelope(), + "daemonVersion": os.environ.get("KTX_DAEMON_VERSION", VERSION), + "source": source, + "handled": handled, + "fatal": fatal, + } + + +def report_exception( + exception: BaseException, + *, + source: str, + handled: bool, + fatal: bool, + project_id: str | None = None, + home_dir: Path | None = None, + env: Mapping[str, str] | None = None, + redaction_secrets: Sequence[str] | None = None, +) -> None: + source_env = env if env is not None else os.environ + try: + identity = load_telemetry_identity(home_dir=home_dir, env=source_env) + if not identity.enabled or not identity.install_id: + return + + if _should_skip_as_reported(exception): + return + + properties = _properties(source=source, handled=handled, fatal=fatal) + groups = {"project": project_id} if project_id else None + safe_exception = _clone_exception(exception, redaction_secrets or []) + + if _debug_enabled(source_env): + sys.stderr.write( + "[telemetry-exception] " + + json.dumps( + { + "distinctId": identity.install_id, + "message": str(safe_exception), + "properties": properties, + "groups": groups, + }, + sort_keys=True, + ) + + "\n" + ) + return + + if not POSTHOG_PROJECT_API_KEY.strip() or not _host(source_env).strip(): + return + + from posthog import Posthog + + client = Posthog( + POSTHOG_PROJECT_API_KEY, + host=_host(source_env), + flush_at=1, + flush_interval=0, + sync_mode=True, + timeout=1, + ) + client.capture_exception( + safe_exception, + distinct_id=identity.install_id, + properties=properties, + groups=groups, + ) + client.shutdown() + except Exception: + return diff --git a/python/ktx-daemon/tests/test_app.py b/python/ktx-daemon/tests/test_app.py index 2c3237ad..fffc2899 100644 --- a/python/ktx-daemon/tests/test_app.py +++ b/python/ktx-daemon/tests/test_app.py @@ -87,8 +87,10 @@ def test_app_lifespan_emits_daemon_lifecycle_debug_events( monkeypatch, capsys, ) -> None: + from ktx_daemon.telemetry.daemon_lifecycle import reset_daemon_lifecycle_for_tests from ktx_daemon.telemetry.identity import reset_identity_cache + reset_daemon_lifecycle_for_tests() reset_identity_cache() identity_path = tmp_path / ".ktx" / "telemetry.json" identity_path.parent.mkdir(parents=True) diff --git a/python/ktx-daemon/tests/test_exception_payload.py b/python/ktx-daemon/tests/test_exception_payload.py new file mode 100644 index 00000000..3198b08f --- /dev/null +++ b/python/ktx-daemon/tests/test_exception_payload.py @@ -0,0 +1,118 @@ +from __future__ import annotations + +import gzip +import json +import threading +from http.server import BaseHTTPRequestHandler, HTTPServer +from pathlib import Path +from typing import Any + +from ktx_daemon.telemetry.identity import reset_identity_cache + + +class CaptureHandler(BaseHTTPRequestHandler): + payloads: list[dict[str, Any]] = [] + + def do_POST(self) -> None: + length = int(self.headers.get("content-length", "0")) + raw = self.rfile.read(length) + if self.headers.get("content-encoding") == "gzip": + raw = gzip.decompress(raw) + self.payloads.append(json.loads(raw.decode("utf-8"))) + self.send_response(200) + self.send_header("content-type", "application/json") + self.end_headers() + self.wfile.write(b"{}") + + def log_message(self, _format: str, *_args: object) -> None: + return + + +def write_identity(home: Path) -> None: + target = home / ".ktx" / "telemetry.json" + target.parent.mkdir(parents=True, exist_ok=True) + target.write_text( + json.dumps( + { + "installId": "00000000-0000-4000-8000-000000000000", + "enabled": True, + "createdAt": "2026-06-05T00:00:00.000Z", + } + ) + + "\n", + encoding="utf-8", + ) + + +def find_exception_event(payloads: list[dict[str, Any]]) -> dict[str, Any]: + for payload in payloads: + batch = payload.get("batch") + events = batch if isinstance(batch, list) else [payload] + for event in events: + if isinstance(event, dict) and event.get("event") == "$exception": + return event + raise AssertionError(f"No $exception payload found: {payloads}") + + +def test_prepared_python_exception_payload_groups_and_redacts(tmp_path: Path) -> None: + from ktx_daemon.telemetry.exception import report_exception + + reset_identity_cache() + write_identity(tmp_path) + CaptureHandler.payloads.clear() + server = HTTPServer(("127.0.0.1", 0), CaptureHandler) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + try: + snapshot_secret = "-".join(["plain", "secret", "value"]) + db_password = "-".join(["db", "url", "secret"]) + auth_token = "".join(["abc", "123"]) + report_exception( + RuntimeError( + f"{snapshot_secret} postgres://svc:{db_password}@db.example.test/analytics " + f"Authorization: Basic {auth_token}" + ), + source="database-introspect", + handled=True, + fatal=False, + project_id="a" * 64, + home_dir=tmp_path, + env={"KTX_TELEMETRY_ENDPOINT": f"http://127.0.0.1:{server.server_port}"}, + redaction_secrets=[snapshot_secret], + ) + finally: + server.shutdown() + server.server_close() + thread.join(timeout=2) + + event = find_exception_event(CaptureHandler.payloads) + properties = event["properties"] + assert event.get("$groups") == {"project": "a" * 64} or properties.get( + "$groups" + ) == {"project": "a" * 64} + serialized = json.dumps(properties.get("$exception_list", [])) + assert "[redacted]" in serialized + assert snapshot_secret not in serialized + assert db_password not in serialized + assert auth_token not in serialized + forbidden_keys = { + "argv", + "args", + "env", + "environment", + "sql", + "query", + "prompt", + "mcpArguments", + "tableName", + "schemaName", + "columnName", + "databaseUrl", + "connectionString", + "url", + "password", + "token", + "apiKey", + "authorization", + } + assert forbidden_keys.isdisjoint(properties.keys()) diff --git a/python/ktx-daemon/tests/test_exception_telemetry.py b/python/ktx-daemon/tests/test_exception_telemetry.py new file mode 100644 index 00000000..43da007d --- /dev/null +++ b/python/ktx-daemon/tests/test_exception_telemetry.py @@ -0,0 +1,601 @@ +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any + +from ktx_daemon.telemetry.identity import reset_identity_cache + + +class FakePosthog: + captures: list[dict[str, Any]] = [] + shutdowns = 0 + + def __init__(self, *_args: Any, **_kwargs: Any) -> None: + pass + + def capture_exception( + self, + exception: BaseException, + *, + distinct_id: str, + properties: dict[str, Any], + groups: dict[str, str] | None = None, + ) -> None: + self.captures.append( + { + "exception": exception, + "distinct_id": distinct_id, + "properties": properties, + "groups": groups, + } + ) + + def shutdown(self) -> None: + type(self).shutdowns += 1 + + +def write_identity(home: Path, *, enabled: bool = True) -> None: + target = home / ".ktx" / "telemetry.json" + target.parent.mkdir(parents=True, exist_ok=True) + target.write_text( + json.dumps( + { + "installId": "00000000-0000-4000-8000-000000000000", + "enabled": enabled, + "createdAt": "2026-06-05T00:00:00.000Z", + } + ) + + "\n", + encoding="utf-8", + ) + + +def test_report_exception_respects_disabled_gate(tmp_path: Path, monkeypatch) -> None: + from ktx_daemon.telemetry.exception import report_exception + + reset_identity_cache() + write_identity(tmp_path) + monkeypatch.setenv("KTX_TELEMETRY_DISABLED", "1") + FakePosthog.captures.clear() + monkeypatch.setattr("posthog.Posthog", FakePosthog) + + report_exception( + RuntimeError("boom"), + source="semantic-query", + handled=True, + fatal=False, + home_dir=tmp_path, + env={"KTX_TELEMETRY_DISABLED": "1"}, + ) + + assert FakePosthog.captures == [] + + +def test_report_exception_sends_groups_and_properties( + tmp_path: Path, monkeypatch +) -> None: + from ktx_daemon.telemetry.exception import report_exception + + reset_identity_cache() + write_identity(tmp_path) + FakePosthog.captures.clear() + monkeypatch.setattr("posthog.Posthog", FakePosthog) + + report_exception( + RuntimeError("boom"), + source="semantic-query", + handled=True, + fatal=False, + project_id="a" * 64, + home_dir=tmp_path, + env={}, + ) + + assert FakePosthog.captures == [ + { + "exception": FakePosthog.captures[0]["exception"], + "distinct_id": "00000000-0000-4000-8000-000000000000", + "properties": FakePosthog.captures[0]["properties"], + "groups": {"project": "a" * 64}, + } + ] + assert FakePosthog.captures[0]["properties"]["source"] == "semantic-query" + assert FakePosthog.captures[0]["properties"]["handled"] is True + assert FakePosthog.captures[0]["properties"]["fatal"] is False + assert FakePosthog.captures[0]["properties"]["runtime"] == "daemon-py" + + +def test_report_exception_debug_prints_without_sending(tmp_path: Path, capsys) -> None: + from ktx_daemon.telemetry.exception import report_exception + + reset_identity_cache() + write_identity(tmp_path) + FakePosthog.captures.clear() + + report_exception( + RuntimeError("debug boom"), + source="app:/health", + handled=True, + fatal=False, + home_dir=tmp_path, + env={"KTX_TELEMETRY_DEBUG": "1"}, + ) + + captured = capsys.readouterr() + assert "[telemetry-exception]" in captured.err + assert '"source": "app:/health"' in captured.err + assert FakePosthog.captures == [] + + +def test_report_exception_redacts_snapshot_and_static_patterns( + tmp_path: Path, monkeypatch +) -> None: + from ktx_daemon.telemetry.exception import report_exception + + reset_identity_cache() + write_identity(tmp_path) + FakePosthog.captures.clear() + monkeypatch.setattr("posthog.Posthog", FakePosthog) + error = RuntimeError("dsn has plain-secret and password=hunter2") + error.__cause__ = ValueError("Authorization: Bearer token-123") + + report_exception( + error, + source="database-introspect", + handled=True, + fatal=False, + home_dir=tmp_path, + env={}, + redaction_secrets=["plain-secret"], + ) + + sent = FakePosthog.captures[0]["exception"] + assert "[redacted]" in str(sent) + assert "plain-secret" not in str(sent) + assert "hunter2" not in str(sent) + assert "token-123" not in str(sent.__cause__) + + +def test_report_exception_does_not_discover_env_values_without_snapshot( + tmp_path: Path, monkeypatch +) -> None: + from ktx_daemon.telemetry.exception import report_exception + + reset_identity_cache() + write_identity(tmp_path) + FakePosthog.captures.clear() + monkeypatch.setenv("KTX_FAKE_SECRET", "plain-secret-without-pattern") + monkeypatch.setattr("posthog.Posthog", FakePosthog) + + report_exception( + RuntimeError("plain-secret-without-pattern"), + source="sys.excepthook", + handled=False, + fatal=True, + home_dir=tmp_path, + env={}, + ) + + assert "plain-secret-without-pattern" in str(FakePosthog.captures[0]["exception"]) + + +def test_route_derived_boundary_reports_new_throwing_route(monkeypatch) -> None: + from fastapi import FastAPI + from fastapi.testclient import TestClient + from ktx_daemon.app import create_app + + reports: list[dict[str, object]] = [] + + def fake_report(exception: BaseException, **kwargs: object) -> None: + reports.append({"exception": exception, **kwargs}) + + monkeypatch.setattr("ktx_daemon.app.report_exception", fake_report) + app: FastAPI = create_app() + + @app.get("/new-throwing-route") + async def new_throwing_route() -> dict[str, str]: + raise RuntimeError("route boom") + + client = TestClient(app, raise_server_exceptions=False) + response = client.get("/new-throwing-route") + + assert response.status_code == 500 + assert reports + assert reports[0]["source"] in {"app:/new-throwing-route", "app:new_throwing_route"} + assert reports[0]["handled"] is True + assert reports[0]["fatal"] is False + + +def test_route_derived_boundary_covers_existing_validate_route(monkeypatch) -> None: + from fastapi.testclient import TestClient + from ktx_daemon import app as app_module + + reports: list[dict[str, object]] = [] + + def fake_report(exception: BaseException, **kwargs: object) -> None: + reports.append({"exception": exception, **kwargs}) + + monkeypatch.setattr( + app_module, + "validate_semantic_layer", + lambda _request: (_ for _ in ()).throw(RuntimeError("validate boom")), + ) + monkeypatch.setattr(app_module, "report_exception", fake_report) + + client = TestClient(app_module.create_app(), raise_server_exceptions=False) + response = client.post("/semantic-layer/validate", json={"sources": []}) + + assert response.status_code == 500 + assert reports + assert reports[0]["source"] in { + "app:/semantic-layer/validate", + "app:semantic_validate", + } + + +def test_daemon_stopped_clean_shutdown_emits_request_once(monkeypatch) -> None: + from ktx_daemon.telemetry.daemon_lifecycle import ( + emit_daemon_stopped_once, + reset_daemon_lifecycle_for_tests, + ) + + events: list[tuple[str, dict[str, object]]] = [] + monkeypatch.setattr( + "ktx_daemon.telemetry.daemon_lifecycle.track_telemetry_event", + lambda name, fields: events.append((name, fields)), + ) + reset_daemon_lifecycle_for_tests() + + emit_daemon_stopped_once(reason="request", uptime_ms=1) + emit_daemon_stopped_once(reason="request", uptime_ms=2) + + assert events == [("daemon_stopped", {"reason": "request", "uptimeMs": 1})] + + +def test_daemon_stopped_crash_wins_over_request(monkeypatch) -> None: + from ktx_daemon.telemetry.daemon_lifecycle import ( + emit_daemon_stopped_once, + reset_daemon_lifecycle_for_tests, + ) + + events: list[tuple[str, dict[str, object]]] = [] + monkeypatch.setattr( + "ktx_daemon.telemetry.daemon_lifecycle.track_telemetry_event", + lambda name, fields: events.append((name, fields)), + ) + reset_daemon_lifecycle_for_tests() + + emit_daemon_stopped_once(reason="crash", uptime_ms=3) + emit_daemon_stopped_once(reason="request", uptime_ms=4) + + assert events == [("daemon_stopped", {"reason": "crash", "uptimeMs": 3})] + + +def test_report_exception_dedupes_same_exception_object( + tmp_path: Path, monkeypatch +) -> None: + from ktx_daemon.telemetry.exception import report_exception + + reset_identity_cache() + write_identity(tmp_path) + FakePosthog.captures.clear() + monkeypatch.setattr("posthog.Posthog", FakePosthog) + error = RuntimeError("same object") + + report_exception( + error, + source="semantic-query", + handled=True, + fatal=False, + home_dir=tmp_path, + env={}, + ) + report_exception( + error, + source="app:/semantic-layer/query", + handled=True, + fatal=False, + home_dir=tmp_path, + env={}, + ) + + assert len(FakePosthog.captures) == 1 + + +def test_report_exception_redacts_url_userinfo_and_authorization( + tmp_path: Path, monkeypatch +) -> None: + from ktx_daemon.telemetry.exception import report_exception + + reset_identity_cache() + write_identity(tmp_path) + FakePosthog.captures.clear() + monkeypatch.setattr("posthog.Posthog", FakePosthog) + + db_password = ["db", "url", "secret"] + auth_token = ["abc", "123"] + report_exception( + RuntimeError( + "connect postgres://svc:" + + "-".join(db_password) + + "@db.example.test/analytics Authorization: Basic " + + "".join(auth_token) + ), + source="database-introspect", + handled=True, + fatal=False, + home_dir=tmp_path, + env={}, + ) + + sent = str(FakePosthog.captures[0]["exception"]) + assert "postgres://svc:[redacted]@db.example.test/analytics" in sent + assert "Authorization: [redacted]" in sent + assert "-".join(db_password) not in sent + assert "".join(auth_token) not in sent + + +def test_report_exception_falls_back_when_exception_type_cannot_be_reconstructed( + tmp_path: Path, monkeypatch +) -> None: + from ktx_daemon.telemetry.exception import report_exception + + class KeywordOnlyException(Exception): + def __init__(self, *, message: str) -> None: + super().__init__(message) + + reset_identity_cache() + write_identity(tmp_path) + FakePosthog.captures.clear() + monkeypatch.setattr("posthog.Posthog", FakePosthog) + + report_exception( + KeywordOnlyException(message="custom secret-value"), + source="app:/custom", + handled=True, + fatal=False, + home_dir=tmp_path, + env={}, + redaction_secrets=["secret-value"], + ) + + assert len(FakePosthog.captures) == 1 + sent = FakePosthog.captures[0]["exception"] + assert "[redacted]" in str(sent) + assert "secret-value" not in str(sent) + + +def test_report_exception_redacts_every_static_pattern_and_leaves_benign_text( + tmp_path: Path, monkeypatch +) -> None: + from ktx_daemon.telemetry.exception import report_exception + + reset_identity_cache() + write_identity(tmp_path) + FakePosthog.captures.clear() + monkeypatch.setattr("posthog.Posthog", FakePosthog) + + cases = [ + ("dsn password=hunter2", "hunter2", "password=[redacted]"), + ("dsn pwd=swordfish", "swordfish", "pwd=[redacted]"), + ("Authorization: Basic abc123", "abc123", "Authorization: [redacted]"), + ("Authorization: Bearer token-123", "token-123", "Authorization: [redacted]"), + ("Bearer standalone-token", "standalone-token", "Bearer [redacted]"), + ("api_key=sk-live-secret", "sk-live-secret", "api_key=[redacted]"), + ("api-key: sk-dash-secret", "sk-dash-secret", "api-key=[redacted]"), + ( + "KTX_PROVIDER_TOKEN=ktx-secret", + "ktx-secret", + "KTX_PROVIDER_TOKEN=[redacted]", + ), + ( + "REFRESH_SECRET: refresh-secret", + "refresh-secret", + "REFRESH_SECRET=[redacted]", + ), + ( + "https://s3.example.test/file?X-Amz-Signature=aws-secret&ok=1", + "aws-secret", + "X-Amz-Signature=[redacted]", + ), + ( + "https://storage.example.test/file?X-Goog-Signature=goog-secret&ok=1", + "goog-secret", + "X-Goog-Signature=[redacted]", + ), + ( + "https://cdn.example.test/file?sig=signed-secret&ok=1", + "signed-secret", + "sig=[redacted]", + ), + ( + "postgres://svc:url-password@db.example.test/analytics", # pragma: allowlist secret + "url-password", + "postgres://svc:[redacted]@db.example.test/analytics", + ), + ] + + for message, leaked, expected in cases: + report_exception( + RuntimeError(message), + source="database-introspect", + handled=True, + fatal=False, + home_dir=tmp_path, + env={}, + ) + sent = str(FakePosthog.captures[-1]["exception"]) + assert expected in sent + assert leaked not in sent + + report_exception( + RuntimeError("token bucket metrics and passwordless auth are benign"), + source="database-introspect", + handled=True, + fatal=False, + home_dir=tmp_path, + env={}, + ) + assert str(FakePosthog.captures[-1]["exception"]) == ( + "token bucket metrics and passwordless auth are benign" + ) + + +def test_route_derived_boundary_covers_existing_health_route(monkeypatch) -> None: + from fastapi.testclient import TestClient + from ktx_daemon import app as app_module + + reports: list[dict[str, object]] = [] + + def fake_report(exception: BaseException, **kwargs: object) -> None: + reports.append({"exception": exception, **kwargs}) + + class BrokenEnviron(dict[str, str]): + def get(self, key: str, default: str | None = None) -> str | None: + if key == "KTX_DAEMON_VERSION": + raise RuntimeError("health boom") + return default + + monkeypatch.setattr(app_module.os, "environ", BrokenEnviron()) + monkeypatch.setattr(app_module, "report_exception", fake_report) + + client = TestClient(app_module.create_app(), raise_server_exceptions=False) + response = client.get("/health") + + assert response.status_code == 500 + assert reports + assert reports[0]["source"] == "app:/health" + assert reports[0]["handled"] is True + assert reports[0]["fatal"] is False + + +def test_route_boundary_passes_request_scoped_database_secrets(monkeypatch) -> None: + from fastapi.testclient import TestClient + from ktx_daemon import app as app_module + + reports: list[dict[str, object]] = [] + + def fake_report(exception: BaseException, **kwargs: object) -> None: + reports.append({"exception": exception, **kwargs}) + + monkeypatch.setattr( + app_module, + "introspect_database_response", + lambda _request: (_ for _ in ()).throw(RuntimeError("db-url-secret")), + ) + monkeypatch.setattr(app_module, "report_exception", fake_report) + + client = TestClient(app_module.create_app(), raise_server_exceptions=False) + response = client.post( + "/database/introspect", + json={ + "connection_id": "warehouse", + "url": "postgres://svc:db-url-secret@db.example.test/analytics", # pragma: allowlist secret + "password": "db-password-secret", # pragma: allowlist secret + }, + ) + + assert response.status_code == 500 + assert reports + assert ( + reports[0]["redaction_secrets"] + == [ + "postgres://svc:db-url-secret@db.example.test/analytics", # pragma: allowlist secret + "db-password-secret", # pragma: allowlist secret + ] + ) + + +def test_serve_http_run_crash_reports_exception_and_crash_stop(monkeypatch) -> None: + import sys + + from ktx_daemon import __main__ as main_module + + reports: list[dict[str, object]] = [] + stops: list[dict[str, object]] = [] + + def fake_report(exception: BaseException, **kwargs: object) -> None: + reports.append({"exception": exception, **kwargs}) + + def fake_stop(*, reason: str, uptime_ms: float) -> bool: + stops.append({"reason": reason, "uptimeMs": uptime_ms}) + return True + + class FakeUvicorn: + @staticmethod + def run(*_args: object, **_kwargs: object) -> None: + raise RuntimeError("uvicorn crash") + + monkeypatch.setitem(sys.modules, "uvicorn", FakeUvicorn) + monkeypatch.setattr("ktx_daemon.telemetry.report_exception", fake_report) + monkeypatch.setattr( + "ktx_daemon.telemetry.daemon_lifecycle.emit_daemon_stopped_once", + fake_stop, + ) + + try: + main_module.run_http_server( + host="127.0.0.1", + port=9999, + log_level="info", + enable_code_execution=False, + ) + except RuntimeError as error: + assert str(error) == "uvicorn crash" + else: + raise AssertionError("run_http_server did not re-raise the crash") + + assert reports + assert reports[0]["source"] == "serve-http" + assert reports[0]["handled"] is False + assert reports[0]["fatal"] is True + assert stops and stops[0]["reason"] == "crash" + + +def test_one_shot_command_reports_without_excepthook_or_daemon_stopped( + monkeypatch, +) -> None: + import sys + + from ktx_daemon import __main__ as daemon_main + + original_hook = sys.excepthook + reports: list[dict[str, object]] = [] + stops: list[dict[str, object]] = [] + + def fake_report(exception: BaseException, **kwargs: object) -> None: + reports.append({"exception": exception, **kwargs}) + + def fake_stop(*, reason: str, uptime_ms: float) -> bool: + stops.append({"reason": reason, "uptimeMs": uptime_ms}) + return True + + monkeypatch.setattr( + daemon_main, + "_read_stdin_json", + lambda: { + "connection_id": "warehouse", + "driver": "postgres", + "url": "postgresql://readonly@example.test/warehouse", + "schemas": ["public"], + }, + ) + monkeypatch.setattr( + daemon_main, + "introspect_database_response", + lambda _request: (_ for _ in ()).throw(RuntimeError("one-shot boom")), + ) + monkeypatch.setattr("ktx_daemon.telemetry.report_exception", fake_report) + monkeypatch.setattr( + "ktx_daemon.telemetry.daemon_lifecycle.emit_daemon_stopped_once", + fake_stop, + ) + + assert daemon_main.main(["database-introspect"]) == 1 + assert sys.excepthook is original_hook + assert stops == [] + assert reports + assert reports[0]["source"] == "database-introspect" + assert reports[0]["handled"] is True + assert reports[0]["fatal"] is False diff --git a/python/ktx-daemon/tests/test_semantic_layer.py b/python/ktx-daemon/tests/test_semantic_layer.py index 828e9359..72040df9 100644 --- a/python/ktx-daemon/tests/test_semantic_layer.py +++ b/python/ktx-daemon/tests/test_semantic_layer.py @@ -97,6 +97,33 @@ def test_query_semantic_layer_emits_plan_and_sql_debug_events( assert "public.orders" not in captured.err +def test_query_semantic_layer_reports_exception(monkeypatch) -> None: + from ktx_daemon import semantic_layer as semantic_layer_module + + reports: list[dict[str, object]] = [] + + def fake_report(exception: BaseException, **kwargs: object) -> None: + reports.append({"exception": exception, **kwargs}) + + monkeypatch.setattr(semantic_layer_module, "report_exception", fake_report) + + with pytest.raises(ValueError): + query_semantic_layer( + SemanticLayerQueryRequest( + sources=[ORDERS_SOURCE, ORDERS_SOURCE], + dialect="postgres", + projectId="a" * 64, + query={"measures": ["orders.order_count"]}, + ) + ) + + assert reports + assert reports[0]["source"] == "semantic-query" + assert reports[0]["handled"] is True + assert reports[0]["fatal"] is False + assert reports[0]["project_id"] == "a" * 64 + + def test_semantic_layer_request_rejects_project_id_field_name() -> None: with pytest.raises(ValueError): SemanticLayerQueryRequest( From d14227468b600a6fd4da2ebd870dcd9c4ddaa5de Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 5 Jun 2026 18:44:32 +0000 Subject: [PATCH 21/25] chore: refresh star history chart [skip ci] --- assets/star-history.svg | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/assets/star-history.svg b/assets/star-history.svg index 31ca9962..b34947d2 100644 --- a/assets/star-history.svg +++ b/assets/star-history.svg @@ -1 +1 @@ -star-history.comMay 17May 24May 31 200400600800kaelio/ktxStar HistoryDateGitHub Stars +star-history.comMay 17May 24May 31 200400600800kaelio/ktxStar HistoryDateGitHub Stars From d3e20df1d53720c3baac773b9436adff8ae73f02 Mon Sep 17 00:00:00 2001 From: Luca Martial <48870843+luca-martial@users.noreply.github.com> Date: Fri, 5 Jun 2026 15:05:22 -0400 Subject: [PATCH 22/25] fix(docs-site): stop doubling the /ktx basePath on alias-host redirects (#263) ktx.sh/ and docs.ktx.sh/ redirected to https://docs.kaelio.com/ktx/ktx/docs/... (note the doubled /ktx) and 404'd. The host-agnostic `source: "/"` redirect ran before the alias-host canonicalizers, so it injected the /ktx basePath into the path on the alias domains, which the alias catch-all then prepended a second time. Reorder redirects() so alias-host canonicalization runs first, leaving the generic root/docs rules for the local/canonical host only. The /stars exclusion stays because redirects run before beforeFiles rewrites. Add Host-spoofing regression tests (the prior tests only used localhost, which never exercised the alias-host rules) and remove the vestigial website/vercel.json, which the live ktx.sh routing already bypasses. Co-authored-by: Claude Opus 4.8 --- docs-site/next.config.mjs | 51 ++++++++------ docs-site/tests/docs-index-route.test.mjs | 81 +++++++++++++++++++++++ website/vercel.json | 10 --- 3 files changed, 110 insertions(+), 32 deletions(-) delete mode 100644 website/vercel.json diff --git a/docs-site/next.config.mjs b/docs-site/next.config.mjs index 380dba85..e47a0cc7 100644 --- a/docs-site/next.config.mjs +++ b/docs-site/next.config.mjs @@ -30,7 +30,36 @@ const config = { }; }, async redirects() { + // Alias-host canonicalization MUST come before the generic root/docs + // redirects below. Those generic rules have no host guard, so if they ran + // first they would inject a "/ktx" basePath into the path on the alias + // hosts, which the alias catch-alls would then prepend a second time — + // producing https://docs.kaelio.com/ktx/ktx/docs/... Redirects also run + // before beforeFiles rewrites, so the ktx.sh catch-all must exclude + // /stars* to let the stars dashboard rewrite proxy through. return [ + { + source: "/slack", + has: [{ type: "host", value: "ktx.sh" }], + destination: + "https://join.slack.com/t/ktxcommunity/shared_invite/zt-3y9b44m1x-LVyNNJD5nwaZHq4XS29LMQ", + permanent: false, + basePath: false, + }, + { + source: "/:path*", + has: [{ type: "host", value: "docs.ktx.sh" }], + destination: "https://docs.kaelio.com/ktx/:path*", + permanent: true, + basePath: false, + }, + { + source: "/:path((?!stars(?:/|$)).*)", + has: [{ type: "host", value: "ktx.sh" }], + destination: "https://docs.kaelio.com/ktx/:path", + permanent: true, + basePath: false, + }, { source: "/", destination: "/ktx/docs/getting-started/introduction", @@ -43,28 +72,6 @@ const config = { permanent: false, basePath: false, }, - { - source: "/:path*", - has: [{ type: "host", value: "docs.ktx.sh" }], - destination: "https://docs.kaelio.com/ktx/:path*", - permanent: true, - basePath: false, - }, - { - source: "/slack", - has: [{ type: "host", value: "ktx.sh" }], - destination: - "https://join.slack.com/t/ktxcommunity/shared_invite/zt-3y9b44m1x-LVyNNJD5nwaZHq4XS29LMQ", - permanent: false, - basePath: false, - }, - { - source: "/:path((?!stars(?:/|$)).*)", - has: [{ type: "host", value: "ktx.sh" }], - destination: "https://docs.kaelio.com/ktx/:path", - permanent: true, - basePath: false, - }, ]; }, }; diff --git a/docs-site/tests/docs-index-route.test.mjs b/docs-site/tests/docs-index-route.test.mjs index fdd8ec81..6fac0e3c 100644 --- a/docs-site/tests/docs-index-route.test.mjs +++ b/docs-site/tests/docs-index-route.test.mjs @@ -2,6 +2,8 @@ import assert from "node:assert/strict"; import { spawn } from "node:child_process"; import { once } from "node:events"; import { readFile, writeFile } from "node:fs/promises"; +import http from "node:http"; +import https from "node:https"; import { dirname, join } from "node:path"; import { createServer } from "node:net"; import { after, before, test } from "node:test"; @@ -100,6 +102,37 @@ after(async () => { } }); +// Node's fetch (undici) overwrites the Host header with the connection host, +// so the alias-host redirect rules never match. The low-level http(s) client +// sends Host verbatim, which is what the alias canonicalization keys off of. +function requestWithHost(hostHeader, path) { + const target = new URL(docsSiteUrl); + const client = target.protocol === "https:" ? https : http; + const port = + target.port || (target.protocol === "https:" ? "443" : "80"); + + return new Promise((resolve, reject) => { + const request = client.request( + { + hostname: target.hostname, + port, + path, + method: "GET", + headers: { Host: hostHeader }, + }, + (response) => { + response.resume(); + resolve({ + status: response.statusCode, + location: response.headers.location, + }); + }, + ); + request.on("error", reject); + request.end(); + }); +} + test("/ktx/docs redirects to the docs introduction", async () => { const response = await fetch(`${docsSiteUrl}${docsBasePath}/docs`, { redirect: "manual", @@ -141,3 +174,51 @@ test("/ktx/api/search returns docs search results", async () => { "search should return at least one docs result", ); }); + +test("ktx.sh canonicalizes to a single /ktx basePath on the docs host", async () => { + const root = await requestWithHost("ktx.sh", "/"); + assert.equal(root.status, 308); + assert.equal(root.location, "https://docs.kaelio.com/ktx/"); + assert.ok( + !root.location.includes("/ktx/ktx"), + "the basePath must not be doubled", + ); + + const page = await requestWithHost( + "ktx.sh", + "/docs/getting-started/quickstart", + ); + assert.equal(page.status, 308); + assert.equal( + page.location, + "https://docs.kaelio.com/ktx/docs/getting-started/quickstart", + ); +}); + +test("docs.ktx.sh canonicalizes to a single /ktx basePath on the docs host", async () => { + const root = await requestWithHost("docs.ktx.sh", "/"); + assert.equal(root.status, 308); + assert.equal(root.location, "https://docs.kaelio.com/ktx"); + assert.ok( + !root.location.includes("/ktx/ktx"), + "the basePath must not be doubled", + ); + + const page = await requestWithHost("docs.ktx.sh", "/llms.txt"); + assert.equal(page.status, 308); + assert.equal(page.location, "https://docs.kaelio.com/ktx/llms.txt"); +}); + +test("ktx.sh keeps the /slack and /stars exceptions", async () => { + const slack = await requestWithHost("ktx.sh", "/slack"); + assert.equal(slack.status, 307); + assert.match(slack.location, /^https:\/\/join\.slack\.com\//); + + // /stars is proxied by a beforeFiles rewrite, so the apex catch-all must not + // canonicalize it to the docs host. + const stars = await requestWithHost("ktx.sh", "/stars"); + assert.ok( + !(stars.location ?? "").startsWith("https://docs.kaelio.com"), + "the stars dashboard must not be redirected to the docs host", + ); +}); diff --git a/website/vercel.json b/website/vercel.json deleted file mode 100644 index 7aa86301..00000000 --- a/website/vercel.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "redirects": [ - { - "source": "/:path*", - "has": [{ "type": "host", "value": "ktx.sh" }], - "destination": "https://docs.ktx.sh/:path*", - "permanent": true - } - ] -} From 377f21acd7a57f119452dab8eda27d8a72fae54a Mon Sep 17 00:00:00 2001 From: Luca Martial <48870843+luca-martial@users.noreply.github.com> Date: Fri, 5 Jun 2026 19:22:45 -0400 Subject: [PATCH 23/25] docs: add serving-phase diagram to the introduction page (#264) * feat(docs): add serving-phase diagram to the introduction page The introduction's "How ktx works" section described both the ingest and serve sides but only rendered the ingestion diagram. Add a live, theme-aware React Flow diagram for the serving phase (agent <-> ktx via MCP -> context layer + database) so both phases are shown, with a matching content test. Co-Authored-By: Claude Opus 4.8 * docs(diagram-studio): relabel context edge and use right-angle routing The hub->context edge searches and reads definitions, not just searches; relabel it "search + read". Route the serving search/read-only edges with smoothstep (right angles) to match the docs diagram. (The README PNG is a baked export and is unchanged until re-exported from the studio.) Co-Authored-By: Claude Opus 4.8 * test(docs): point product-mechanics assertions at the FlowCanvas wrapper product-mechanics renders via the shared FlowCanvas wrapper, so the ReactFlow config (nodesDraggable, zoomOnScroll, etc.) lives there now. Update the stale assertions that still expected those literals inline, fixing a pre-existing test failure. Co-Authored-By: Claude Opus 4.8 * docs(serving-diagram): shrink the boxes and drop OpenCode from the agent list Reduce node dimensions, font sizes, padding, and the canvas height so the serving diagram renders ~25% smaller and more compact. Remove OpenCode from the agent's listed clients. Co-Authored-By: Claude Opus 4.8 --------- Co-authored-by: Claude Opus 4.8 --- docs-site/components/diagram-studio/flows.ts | 6 +- docs-site/components/product-runtime.tsx | 576 ++++++++++++++++++ .../docs/getting-started/introduction.mdx | 3 + .../tests/product-mechanics-content.test.mjs | 28 +- .../tests/product-runtime-content.test.mjs | 74 +++ 5 files changed, 670 insertions(+), 17 deletions(-) create mode 100644 docs-site/components/product-runtime.tsx create mode 100644 docs-site/tests/product-runtime-content.test.mjs diff --git a/docs-site/components/diagram-studio/flows.ts b/docs-site/components/diagram-studio/flows.ts index cddf75cb..e63cc512 100644 --- a/docs-site/components/diagram-studio/flows.ts +++ b/docs-site/components/diagram-studio/flows.ts @@ -305,8 +305,8 @@ export const runtimeEdges: Edge[] = [ sourceHandle: "to-context", target: "context", targetHandle: "in", - type: "default", - label: "search", + type: "smoothstep", + label: "search + read", ...labelBg, style: edgeStyle, markerStart: marker, @@ -318,7 +318,7 @@ export const runtimeEdges: Edge[] = [ sourceHandle: "to-warehouse", target: "warehouse", targetHandle: "in", - type: "default", + type: "smoothstep", label: "read-only", ...labelBg, style: edgeStyle, diff --git a/docs-site/components/product-runtime.tsx b/docs-site/components/product-runtime.tsx new file mode 100644 index 00000000..bfe7d64a --- /dev/null +++ b/docs-site/components/product-runtime.tsx @@ -0,0 +1,576 @@ +"use client"; + +import { + type Edge, + type EdgeProps, + getSmoothStepPath, + Handle, + MarkerType, + type Node, + type NodeProps, + Position, +} from "@xyflow/react"; + +import { FlowCanvas } from "./flow-canvas"; + +type AgentNodeData = { + title: string; + items: string[]; +}; + +type HubNodeData = { + title: string; + badge: string; + rows: string[]; +}; + +type TargetNodeData = { + accent: string; + title: string; + body: string; + rows: { text: string; color?: string; mono?: boolean }[]; + badge?: string; +}; + +type AgentNode = Node; +type HubNode = Node; +type TargetNode = Node; +type FlowNode = AgentNode | HubNode | TargetNode; + +const AGENT_W = 252; +const AGENT_H = 96; +const HUB_W = 306; +const HUB_H = 190; +const TARGET_W = 268; +const TARGET_H = 148; + +const CENTER_X = 470; +const ROW_AGENT_Y = 0; +const ROW_HUB_Y = 196; +const ROW_TARGET_Y = 488; + +const AGENT_X = CENTER_X - AGENT_W / 2; +const HUB_X = CENTER_X - HUB_W / 2; + +const TARGET_GAP_X = 38; +const TARGETS_TOTAL = TARGET_W * 2 + TARGET_GAP_X; +const TARGETS_START_X = CENTER_X - TARGETS_TOTAL / 2; +const CONTEXT_X = TARGETS_START_X; +const WAREHOUSE_X = TARGETS_START_X + TARGET_W + TARGET_GAP_X; + +const EDGE_STROKE = "#94a3b8"; +const CYCLE_STROKE = "#0e7490"; +const EMERALD = "#059669"; +const TEAL = "#0e7490"; + +const nodes: FlowNode[] = [ + { + id: "agent", + type: "agent", + position: { x: AGENT_X, y: ROW_AGENT_Y }, + data: { + title: "Your agent", + items: ["Claude Code", "Cursor", "Codex"], + }, + draggable: false, + selectable: false, + }, + { + id: "hub", + type: "hub", + position: { x: HUB_X, y: ROW_HUB_Y }, + data: { + title: "ktx", + badge: "MCP + CLI", + rows: [ + "Search wiki + semantic layer", + "Return approved metrics", + "Compile metrics → SQL", + ], + }, + draggable: false, + selectable: false, + }, + { + id: "context", + type: "target", + position: { x: CONTEXT_X, y: ROW_TARGET_Y }, + data: { + accent: TEAL, + title: "Context layer", + body: "Approved definitions agents search before they answer.", + rows: [ + { text: "wiki/*.md", color: EMERALD, mono: true }, + { text: "semantic-layer/*.yaml", color: TEAL, mono: true }, + ], + }, + draggable: false, + selectable: false, + }, + { + id: "warehouse", + type: "target", + position: { x: WAREHOUSE_X, y: ROW_TARGET_Y }, + data: { + accent: "#334155", + title: "Database", + badge: "read-only", + body: "Runs the compiled SQL. ktx never writes to it.", + rows: [], + }, + draggable: false, + selectable: false, + }, +]; + +const labelBg = { + labelBgPadding: [6, 3] as [number, number], + labelBgBorderRadius: 4, + labelStyle: { + fontSize: 13, + fontWeight: 600, + fill: "var(--color-fd-muted-foreground)", + }, + labelBgStyle: { + fill: "var(--color-fd-background)", + stroke: "var(--color-fd-border)", + strokeWidth: 1, + }, +}; + +const requestMarker = { + type: MarkerType.ArrowClosed, + color: EDGE_STROKE, + width: 16, + height: 16, +}; + +const flowEdges: Edge[] = [ + { + id: "e-ask", + source: "agent", + sourceHandle: "ask", + target: "hub", + targetHandle: "ask", + type: "straight", + label: "ask", + ...labelBg, + style: { stroke: EDGE_STROKE, strokeWidth: 1.5 }, + markerEnd: requestMarker, + }, + { + id: "e-answer", + source: "hub", + sourceHandle: "answer", + target: "agent", + targetHandle: "answer", + type: "straight", + label: "answer", + ...labelBg, + style: { stroke: EDGE_STROKE, strokeWidth: 1.5 }, + markerEnd: requestMarker, + }, + { + id: "e-search", + source: "hub", + sourceHandle: "to-context", + target: "context", + targetHandle: "in", + type: "smoothstep", + label: "search + read", + ...labelBg, + style: { stroke: CYCLE_STROKE, strokeWidth: 1.5 }, + markerStart: { type: MarkerType.ArrowClosed, color: CYCLE_STROKE, width: 14, height: 14 }, + markerEnd: { type: MarkerType.ArrowClosed, color: CYCLE_STROKE, width: 14, height: 14 }, + }, + { + id: "e-readonly", + source: "hub", + sourceHandle: "to-warehouse", + target: "warehouse", + targetHandle: "in", + type: "smoothstep", + label: "read-only", + ...labelBg, + style: { stroke: CYCLE_STROKE, strokeWidth: 1.5 }, + markerStart: { type: MarkerType.ArrowClosed, color: CYCLE_STROKE, width: 14, height: 14 }, + markerEnd: { type: MarkerType.ArrowClosed, color: CYCLE_STROKE, width: 14, height: 14 }, + }, +]; + +function AgentNodeView({ data }: NodeProps) { + return ( +

+ ); +} + +function HubNodeView({ data }: NodeProps) { + return ( +
+ + + + +
+ + k + + + {data.title} + + + {data.badge} + +
+
+ {data.rows.map((row) => ( +
+ + + {row} + +
+ ))} +
+
+ ); +} + +function TargetNodeView({ data }: NodeProps) { + return ( +
+ +
+

+ {data.title} +

+ {data.badge ? ( + + {data.badge} + + ) : null} +
+ {data.rows.length > 0 ? ( +
+ {data.rows.map((row) => ( + + {row.text} + + ))} +
+ ) : null} +

+ {data.body} +

+
+ ); +} + +/* ------------------------------- Particles ------------------------------- */ + +const PARTICLE_SPEED_PX_PER_SEC = 150; +const PARTICLE_MIN_DURATION_SEC = 5; + +type Leg = { + sx: number; + sy: number; + sPos: Position; + tx: number; + ty: number; + tPos: Position; +}; + +const AGENT_ASK_X = AGENT_X + AGENT_W * 0.35; +const AGENT_ANSWER_X = AGENT_X + AGENT_W * 0.65; +const AGENT_BOTTOM_Y = ROW_AGENT_Y + AGENT_H; +const HUB_ASK_X = HUB_X + HUB_W * 0.375; +const HUB_ANSWER_X = HUB_X + HUB_W * 0.625; +const HUB_TO_CONTEXT_X = HUB_X + HUB_W * 0.44; +const HUB_TO_WAREHOUSE_X = HUB_X + HUB_W * 0.56; +const HUB_BOTTOM_Y = ROW_HUB_Y + HUB_H; +const CONTEXT_TOP_X = CONTEXT_X + TARGET_W / 2; +const WAREHOUSE_TOP_X = WAREHOUSE_X + TARGET_W / 2; + +function buildCyclePath(spokeX: number, targetX: number): { + d: string; + length: number; +} { + const legs: Leg[] = [ + // agent → hub (ask, down) + { sx: AGENT_ASK_X, sy: AGENT_BOTTOM_Y, sPos: Position.Bottom, tx: HUB_ASK_X, ty: ROW_HUB_Y, tPos: Position.Top }, + // through the hub to its spoke handle (down, drawn behind the hub) + { sx: HUB_ASK_X, sy: ROW_HUB_Y, sPos: Position.Bottom, tx: spokeX, ty: HUB_BOTTOM_Y, tPos: Position.Top }, + // hub → target (down) + { sx: spokeX, sy: HUB_BOTTOM_Y, sPos: Position.Bottom, tx: targetX, ty: ROW_TARGET_Y, tPos: Position.Top }, + // target → hub (up) + { sx: targetX, sy: ROW_TARGET_Y, sPos: Position.Top, tx: spokeX, ty: HUB_BOTTOM_Y, tPos: Position.Bottom }, + // through the hub to its answer handle (up, drawn behind the hub) + { sx: spokeX, sy: HUB_BOTTOM_Y, sPos: Position.Top, tx: HUB_ANSWER_X, ty: ROW_HUB_Y, tPos: Position.Bottom }, + // hub → agent (answer, up) + { sx: HUB_ANSWER_X, sy: ROW_HUB_Y, sPos: Position.Top, tx: AGENT_ANSWER_X, ty: AGENT_BOTTOM_Y, tPos: Position.Bottom }, + ]; + + const segments = legs.map((leg) => { + const [segment] = getSmoothStepPath({ + sourceX: leg.sx, + sourceY: leg.sy, + sourcePosition: leg.sPos, + targetX: leg.tx, + targetY: leg.ty, + targetPosition: leg.tPos, + }); + return segment; + }); + + let d = segments[0]; + for (let i = 1; i < segments.length; i += 1) { + d += ` ${segments[i].replace(/^M/, "L")}`; + } + + const length = legs.reduce( + (sum, leg) => sum + Math.abs(leg.tx - leg.sx) + Math.abs(leg.ty - leg.sy), + 0, + ); + + return { d, length }; +} + +type ParticleEdgeData = { + d: string; + duration: number; + beginOffset: number; + color: string; +}; + +type ParticleEdge = Edge; + +function ParticleEdgeView({ id, data }: EdgeProps) { + if (!data) return null; + const pathId = `runtime-particle-path-${id}`; + return ( + <> + + + + + + + + + + + ); +} + +function makeCycleEdge( + id: string, + source: string, + spokeX: number, + targetX: number, + beginFraction: number, +): ParticleEdge { + const { d, length } = buildCyclePath(spokeX, targetX); + const duration = Math.max( + PARTICLE_MIN_DURATION_SEC, + length / PARTICLE_SPEED_PX_PER_SEC, + ); + return { + id, + source, + target: source, + type: "particle", + data: { d, duration, beginOffset: duration * beginFraction, color: CYCLE_STROKE }, + }; +} + +const particleEdges: ParticleEdge[] = [ + makeCycleEdge("p-context", "context", HUB_TO_CONTEXT_X, CONTEXT_TOP_X, 0), + makeCycleEdge("p-warehouse", "warehouse", HUB_TO_WAREHOUSE_X, WAREHOUSE_TOP_X, 0.5), +]; + +const nodeTypes = { + agent: AgentNodeView, + hub: HubNodeView, + target: TargetNodeView, +}; + +const edgeTypes = { + particle: ParticleEdgeView, +}; + +const edges = [...flowEdges, ...particleEdges]; + +export function ProductRuntime() { + return ( +
+
+

+ How serving works +

+

+ At runtime, agents reach ktx through MCP. ktx searches the context + layer, returns approved metrics, and compiles them into read-only SQL + the warehouse runs. +

+
+ +
+
+

+ Serving flow +

+

+ From an agent request to a governed answer +

+

+ The agent asks in plain language. ktx is the only thing that touches + the context layer and the warehouse, and every database connection + is read-only. +

+
+ + +
+ +
+ ); +} diff --git a/docs-site/content/docs/getting-started/introduction.mdx b/docs-site/content/docs/getting-started/introduction.mdx index cc3b0ca8..50ffe20d 100644 --- a/docs-site/content/docs/getting-started/introduction.mdx +++ b/docs-site/content/docs/getting-started/introduction.mdx @@ -4,6 +4,7 @@ description: ktx is an open-source, self-improving context layer for data agents --- import { ProductMechanics } from "@/components/product-mechanics"; +import { ProductRuntime } from "@/components/product-runtime";
@@ -59,6 +60,8 @@ serves that context to agents at runtime. + + ## Use it for Use **ktx** when agents need more than raw database access. Agents can search wiki diff --git a/docs-site/tests/product-mechanics-content.test.mjs b/docs-site/tests/product-mechanics-content.test.mjs index 5cce9001..d0c9471c 100644 --- a/docs-site/tests/product-mechanics-content.test.mjs +++ b/docs-site/tests/product-mechanics-content.test.mjs @@ -85,7 +85,7 @@ test("product mechanics component explains ingestion outputs", async () => { "compile into SQL", '"use client"', "@xyflow/react", - " { ); } - assert.match( - component, + // The ReactFlow canvas config lives in the shared FlowCanvas wrapper, which + // product-mechanics renders. Assert the static read-only behavior there. + const flowCanvas = await readDocsFile("components/flow-canvas.tsx"); + for (const guard of [ /nodesDraggable=\{false\}/, - "ReactFlow canvas should disable node dragging", - ); - assert.match( - component, - /panOnDrag=\{false\}/, - "ReactFlow canvas should disable panning", - ); - assert.match( - component, + /nodesConnectable=\{false\}/, /zoomOnScroll=\{false\}/, - "ReactFlow canvas should disable scroll zoom", - ); + /elementsSelectable=\{false\}/, + ]) { + assert.match( + flowCanvas, + guard, + `shared FlowCanvas should enforce static read-only behavior: ${guard}`, + ); + } assert.doesNotMatch(component, /raw-sources/); assert.doesNotMatch(component, /\.ktx/); diff --git a/docs-site/tests/product-runtime-content.test.mjs b/docs-site/tests/product-runtime-content.test.mjs new file mode 100644 index 00000000..ac643faa --- /dev/null +++ b/docs-site/tests/product-runtime-content.test.mjs @@ -0,0 +1,74 @@ +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import { dirname, join } from "node:path"; +import { test } from "node:test"; +import { fileURLToPath } from "node:url"; + +const docsSiteDir = join(dirname(fileURLToPath(import.meta.url)), ".."); + +async function readDocsFile(path) { + return readFile(join(docsSiteDir, path), "utf8"); +} + +test("docs introduction renders the serving phase after ingestion", async () => { + const introduction = await readDocsFile( + "content/docs/getting-started/introduction.mdx", + ); + + assert.match( + introduction, + /import\s+\{\s*ProductRuntime\s*\}\s+from\s+"@\/components\/product-runtime";/, + ); + assert.match(introduction, //); + + const mechanicsIndex = introduction.indexOf(""); + const runtimeIndex = introduction.indexOf(""); + const useCaseIndex = introduction.indexOf("## Use it for"); + + assert.ok( + runtimeIndex > mechanicsIndex, + "serving diagram should appear after the ingestion diagram", + ); + assert.ok( + runtimeIndex < useCaseIndex, + "serving diagram should appear before use-case sections", + ); +}); + +test("product runtime component explains the serving cycle", async () => { + const component = await readDocsFile("components/product-runtime.tsx"); + + for (const expectedText of [ + "How serving works", + "Serving flow", + "From an agent request to a governed answer", + "Your agent", + "Claude Code", + "Cursor", + "Codex", + "Search wiki + semantic layer", + "Return approved metrics", + "Compile metrics → SQL", + "Context layer", + "Database", + "search + read", + "read-only", + "wiki/*.md", + "semantic-layer/*.yaml", + '"use client"', + "@xyflow/react", + "FlowCanvas", + "getSmoothStepPath", + "animateMotion", + "runtime-particle", + "buildCyclePath", + ]) { + assert.ok( + component.includes(expectedText), + `component should include: ${expectedText}`, + ); + } + + assert.doesNotMatch(component, /raw-sources/); + assert.doesNotMatch(component, / Date: Sat, 6 Jun 2026 10:42:10 +0200 Subject: [PATCH 24/25] feat(cli): add channel-aware update notifier (#265) * feat(cli): show cached update notices after commands * docs(cli): describe update notices * fix(cli): type update check environment * fix(cli): decouple update notice display from refresh and harden suppression Display a cached "update available" notice based solely on the lastNoticeAt 24h throttle, independent of checkedAt refresh freshness, matching the design's independent display/refresh decisions. Suppress the check unconditionally under --json, CI, and non-TTY before consulting output-mode preferences, so a KTX_OUTPUT=pretty override can no longer make CI/non-TTY contexts phone npm. --- docs-site/content/docs/cli-reference/ktx.mdx | 38 ++ packages/cli/package.json | 2 + packages/cli/src/clack.ts | 28 +- packages/cli/src/cli-program.ts | 28 +- packages/cli/src/update-check/cache.ts | 45 +++ packages/cli/src/update-check/channel.ts | 43 +++ packages/cli/src/update-check/registry.ts | 52 +++ packages/cli/src/update-check/update-check.ts | 187 ++++++++++ packages/cli/test/update-check/cache.test.ts | 95 +++++ .../cli/test/update-check/channel.test.ts | 57 +++ .../cli/test/update-check/cli-program.test.ts | 152 ++++++++ .../cli/test/update-check/registry.test.ts | 80 +++++ .../test/update-check/update-check.test.ts | 332 ++++++++++++++++++ pnpm-lock.yaml | 18 + 14 files changed, 1153 insertions(+), 4 deletions(-) create mode 100644 packages/cli/src/update-check/cache.ts create mode 100644 packages/cli/src/update-check/channel.ts create mode 100644 packages/cli/src/update-check/registry.ts create mode 100644 packages/cli/src/update-check/update-check.ts create mode 100644 packages/cli/test/update-check/cache.test.ts create mode 100644 packages/cli/test/update-check/channel.test.ts create mode 100644 packages/cli/test/update-check/cli-program.test.ts create mode 100644 packages/cli/test/update-check/registry.test.ts create mode 100644 packages/cli/test/update-check/update-check.test.ts diff --git a/docs-site/content/docs/cli-reference/ktx.mdx b/docs-site/content/docs/cli-reference/ktx.mdx index 8b9a2cc5..ebdeb1c6 100644 --- a/docs-site/content/docs/cli-reference/ktx.mdx +++ b/docs-site/content/docs/cli-reference/ktx.mdx @@ -74,6 +74,44 @@ The public context-build entrypoint is `ktx ingest [connectionId]` or | `-v`, `--version` | Show the CLI package name and version. | | `-h`, `--help` | Show help for the current command. | +## Update notices + +> **Note:** The update notifier writes only to stderr and keeps command stdout +> unchanged. + +When a newer package is available on your installed release channel, `ktx` +prints a short notice after the command finishes: + +```text +↑ Update available: ktx 0.9.0 → 0.10.0 + npm i -g @kaelio/ktx +``` + +Stable installs compare against the npm `latest` dist-tag. +Release-candidate installs compare against the `next` dist-tag and show: + +```text +npm i -g @kaelio/ktx@next +``` + +The check is skipped for JSON output, CI, non-TTY stdout, and hidden completion +commands. To opt out explicitly, set any of these environment variables: + +```bash +KTX_NO_UPDATE_CHECK=1 +NO_UPDATE_NOTIFIER=1 +DO_NOT_TRACK=1 +``` + +The `ktx` CLI prints one npm command because globally installed binaries don't +expose a reliable runtime package-manager signal. If you prefer another global +package manager, use the equivalent command: + +```bash +pnpm add -g @kaelio/ktx +yarn global add @kaelio/ktx +``` + ## Project resolution Most commands are project-aware. Pass `--project-dir ` when scripting or diff --git a/packages/cli/package.json b/packages/cli/package.json index ba769d58..3255d4c2 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -73,6 +73,7 @@ "pg": "^8.21.0", "posthog-node": "^5.34.9", "react": "^19.2.6", + "semver": "^7.8.1", "simple-git": "3.36.0", "snowflake-sdk": "^2.4.2", "yaml": "^2.9.0", @@ -86,6 +87,7 @@ "@types/node": "^25.9.1", "@types/pg": "^8.20.0", "@types/react": "^19.2.15", + "@types/semver": "^7.7.1", "@vitest/coverage-v8": "^4.1.7", "ajv": "8.20.0", "ink-testing-library": "^4.0.0", diff --git a/packages/cli/src/clack.ts b/packages/cli/src/clack.ts index 2ad51e6c..31be2e1b 100644 --- a/packages/cli/src/clack.ts +++ b/packages/cli/src/clack.ts @@ -3,6 +3,30 @@ import type { KtxCliIo } from './cli-runtime.js'; const ESC = String.fromCharCode(0x1b); +export interface CliStyleEnv { + NO_COLOR?: string; + TERM?: string; +} + +function ansiEnabled(env: CliStyleEnv = process.env): boolean { + return !env.NO_COLOR && env.TERM !== 'dumb'; +} + +function ansiColor(text: string, open: number, close: number, env?: CliStyleEnv): string { + if (!ansiEnabled(env)) { + return text; + } + return `${ESC}[${open}m${text}${ESC}[${close}m`; +} + +export function dim(text: string, env?: CliStyleEnv): string { + return ansiColor(text, 2, 22, env); +} + +export function cyan(text: string, env?: CliStyleEnv): string { + return ansiColor(text, 36, 39, env); +} + export interface RailBufferedSource { stdoutText(): string; stderrText(): string; @@ -61,11 +85,11 @@ export function createClackSpinner(): KtxCliSpinner { } function magenta(text: string): string { - return `${ESC}[35m${text}${ESC}[39m`; + return ansiColor(text, 35, 39); } function red(text: string): string { - return `${ESC}[31m${text}${ESC}[39m`; + return ansiColor(text, 31, 39); } export function createStaticCliSpinner(io: KtxCliSpinnerIo): KtxCliSpinner { diff --git a/packages/cli/src/cli-program.ts b/packages/cli/src/cli-program.ts index 3f1b27e4..6359d897 100644 --- a/packages/cli/src/cli-program.ts +++ b/packages/cli/src/cli-program.ts @@ -16,6 +16,7 @@ import { renderMissingProjectMessage } from './doctor.js'; import { findNearestKtxProjectDir, resolveKtxProjectDir } from './project-resolver.js'; import { profileMark, profileSpan } from './startup-profile.js'; import type { CommandOutcome } from './telemetry/index.js'; +import { prepareUpdateCheckNotice, type PrepareUpdateCheckNoticeOptions } from './update-check/update-check.js'; profileMark('module:cli-program'); @@ -39,6 +40,8 @@ interface KtxCommanderProgramOptions { runInit: (args: { projectDir: string; force: boolean }, io: KtxCliIo) => Promise; } +type KtxCliUpdateCheckOptions = Pick; + export interface BuildKtxProgramOptions { io: KtxCliIo; deps: KtxCliDeps; @@ -47,6 +50,7 @@ export interface BuildKtxProgramOptions { setExitCode?: (code: number) => void; argv?: string[]; setTelemetryModule?: (telemetry: typeof import('./telemetry/index.js')) => void; + updateCheck?: KtxCliUpdateCheckOptions; } type CommanderExitLike = { exitCode: number; code: string; message: string }; @@ -431,16 +435,29 @@ export function collectCommandFlagsPresent(command: CommandUnknownOpts): Record< export function buildKtxProgram(options: BuildKtxProgramOptions): Command { const program = createBaseProgram(options.packageInfo, options.io); + let pendingUpdateNotice: string | null = null; + program.hook('preAction', async (_thisCommand, actionCommand) => { // The hidden completion command must stay silent and side-effect free: skip - // the telemetry notice, command span, and project checks entirely. + // the telemetry notice, command span, project checks, and update checks entirely. if (commandPath(actionCommand as CommandPathNode).includes('__complete')) { return; } + const commandNode = actionCommand as CommandPathNode; + const updateCheck = await prepareUpdateCheckNotice({ + io: options.io, + env: options.updateCheck?.env, + fetchDistTags: options.updateCheck?.fetchDistTags, + homeDir: options.updateCheck?.homeDir, + installedVersion: options.packageInfo.version, + now: options.updateCheck?.now, + commandOptions: commandOptions(commandNode), + }); + pendingUpdateNotice = updateCheck.notice; + const telemetry = await import('./telemetry/index.js'); options.setTelemetryModule?.(telemetry); await telemetry.showTelemetryNoticeIfNeeded(options.io, options.packageInfo); - const commandNode = actionCommand as CommandPathNode; const path = commandPath(commandNode); const projectDir = resolveCommandProjectDir(commandNode); const hasProject = ktxYamlExists(projectDir); @@ -457,6 +474,13 @@ export function buildKtxProgram(options: BuildKtxProgramOptions): Command { ensureProjectAvailable(options.io, commandNode); }); + program.hook('postAction', () => { + if (pendingUpdateNotice) { + options.io.stderr.write(pendingUpdateNotice); + pendingUpdateNotice = null; + } + }); + const context: KtxCliCommandContext = { io: options.io, deps: options.deps, diff --git a/packages/cli/src/update-check/cache.ts b/packages/cli/src/update-check/cache.ts new file mode 100644 index 00000000..19ebf07a --- /dev/null +++ b/packages/cli/src/update-check/cache.ts @@ -0,0 +1,45 @@ +import { renameSync, writeFileSync } from 'node:fs'; +import { mkdir, readFile } from 'node:fs/promises'; +import { homedir } from 'node:os'; +import { dirname, join } from 'node:path'; +import { z } from 'zod'; + +const updateCheckCacheSchema = z + .object({ + checkedAt: z.string(), + channel: z.enum(['latest', 'next']), + installedVersion: z.string(), + latestForChannel: z.string(), + lastNoticeAt: z.string().optional(), + }) + .strict(); + +export type UpdateCheckCache = z.infer; + +/** @internal */ +export function updateCheckCachePath(homeDir = homedir()): string { + return join(homeDir, '.ktx', 'update-check.json'); +} + +export async function readUpdateCheckCache(options: { homeDir?: string } = {}): Promise { + try { + return updateCheckCacheSchema.parse(JSON.parse(await readFile(updateCheckCachePath(options.homeDir), 'utf-8'))); + } catch { + return null; + } +} + +export async function writeUpdateCheckCache( + value: UpdateCheckCache, + options: { homeDir?: string } = {}, +): Promise { + try { + const path = updateCheckCachePath(options.homeDir); + await mkdir(dirname(path), { recursive: true }); + const tempPath = `${path}.${process.pid}.${Date.now()}.tmp`; + writeFileSync(tempPath, `${JSON.stringify(value, null, 2)}\n`, 'utf-8'); + renameSync(tempPath, path); + } catch { + return; + } +} diff --git a/packages/cli/src/update-check/channel.ts b/packages/cli/src/update-check/channel.ts new file mode 100644 index 00000000..d8251021 --- /dev/null +++ b/packages/cli/src/update-check/channel.ts @@ -0,0 +1,43 @@ +import semver from 'semver'; + +export type UpdateChannel = 'latest' | 'next'; + +export type UpdateDecision = + | { status: 'skip' } + | { status: 'upToDate'; channel: UpdateChannel; target: string } + | { status: 'available'; channel: UpdateChannel; target: string }; + +/** @internal */ +export function inferUpdateChannel(installed: string): UpdateChannel | null { + const parsed = semver.parse(installed); + if (!parsed || installed === '0.0.0') { + return null; + } + + const [prereleaseId] = parsed.prerelease; + if (prereleaseId === undefined) { + return 'latest'; + } + if (prereleaseId === 'rc') { + return 'next'; + } + return null; +} + +export function decideUpdate(installed: string, distTags: Record): UpdateDecision { + const channel = inferUpdateChannel(installed); + if (!channel || !semver.valid(installed)) { + return { status: 'skip' }; + } + + const target = distTags[channel]; + if (!target || !semver.valid(target)) { + return { status: 'skip' }; + } + + if (semver.gt(target, installed)) { + return { status: 'available', channel, target }; + } + + return { status: 'upToDate', channel, target }; +} diff --git a/packages/cli/src/update-check/registry.ts b/packages/cli/src/update-check/registry.ts new file mode 100644 index 00000000..f0934933 --- /dev/null +++ b/packages/cli/src/update-check/registry.ts @@ -0,0 +1,52 @@ +import { request as httpsRequest } from 'node:https'; +import { URL } from 'node:url'; +import { z } from 'zod'; + +const DIST_TAGS_URL = new URL('https://registry.npmjs.org/-/package/@kaelio/ktx/dist-tags'); +const distTagsSchema = z.record(z.string(), z.string()); + +function parseDistTags(raw: string): Record { + return distTagsSchema.parse(JSON.parse(raw)); +} + +export function fetchDistTags(): Promise> { + return new Promise((resolve, reject) => { + const request = httpsRequest( + DIST_TAGS_URL, + { + method: 'GET', + headers: { + accept: 'application/json', + }, + }, + (response) => { + const chunks: Buffer[] = []; + response.on('data', (chunk: Buffer | string) => { + chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); + }); + response.on('end', () => { + const text = Buffer.concat(chunks).toString('utf8'); + const statusCode = response.statusCode ?? 0; + if (statusCode < 200 || statusCode >= 300) { + reject(new Error(`npm dist-tags request failed with ${statusCode}: ${text}`)); + return; + } + try { + resolve(parseDistTags(text)); + } catch (error) { + reject(error); + } + }); + }, + ); + + request.on('socket', (socket) => { + socket.unref(); + }); + request.on('error', reject); + request.setTimeout(5000, () => { + request.destroy(new Error('npm dist-tags request timed out')); + }); + request.end(); + }); +} diff --git a/packages/cli/src/update-check/update-check.ts b/packages/cli/src/update-check/update-check.ts new file mode 100644 index 00000000..611a43a3 --- /dev/null +++ b/packages/cli/src/update-check/update-check.ts @@ -0,0 +1,187 @@ +import type { KtxCliIo } from '../cli-runtime.js'; +import { cyan, dim, type CliStyleEnv } from '../clack.js'; +import { resolveOutputMode } from '../io/mode.js'; +import { type UpdateCheckCache, readUpdateCheckCache, writeUpdateCheckCache } from './cache.js'; +import { decideUpdate, inferUpdateChannel, type UpdateChannel } from './channel.js'; +import { fetchDistTags as defaultFetchDistTags } from './registry.js'; + +const DAY_MS = 24 * 60 * 60 * 1000; + +/** @internal */ +export interface UpdateCheckEnv extends NodeJS.ProcessEnv, CliStyleEnv { + CI?: string; + DO_NOT_TRACK?: string; + KTX_NO_UPDATE_CHECK?: string; + KTX_OUTPUT?: string; + NO_UPDATE_NOTIFIER?: string; +} + +/** @internal */ +export interface UpdateCheckCommandOptions { + format?: unknown; + json?: unknown; + output?: unknown; +} + +export interface PrepareUpdateCheckNoticeOptions { + commandOptions?: UpdateCheckCommandOptions; + env?: UpdateCheckEnv; + fetchDistTags?: () => Promise>; + homeDir?: string; + installedVersion: string; + io: KtxCliIo; + now?: () => Date; +} + +export interface PreparedUpdateCheckNotice { + notice: string | null; +} + +function truthy(value: string | undefined): boolean { + return value !== undefined && value !== '' && value !== '0' && value !== 'false'; +} + +function commandRequestsJson(options: UpdateCheckCommandOptions | undefined): boolean { + return options?.json === true || options?.output === 'json' || options?.format === 'json'; +} + +/** @internal */ +export function shouldSuppressUpdateCheck(args: { + commandOptions?: UpdateCheckCommandOptions; + env?: UpdateCheckEnv; + io: KtxCliIo; +}): boolean { + const env = args.env ?? process.env; + if (truthy(env.KTX_NO_UPDATE_CHECK) || truthy(env.NO_UPDATE_NOTIFIER) || truthy(env.DO_NOT_TRACK)) { + return true; + } + + if (commandRequestsJson(args.commandOptions) || truthy(env.CI) || args.io.stdout.isTTY !== true) { + return true; + } + + try { + const mode = resolveOutputMode({ + json: false, + io: args.io, + env, + }); + return mode !== 'pretty'; + } catch { + return true; + } +} + +/** @internal */ +export function renderUpdateNotice(args: { + channel: UpdateChannel; + env?: CliStyleEnv; + installedVersion: string; + targetVersion: string; +}): string { + const command = args.channel === 'next' ? 'npm i -g @kaelio/ktx@next' : 'npm i -g @kaelio/ktx'; + return `${cyan('↑', args.env)} Update available: ktx ${args.installedVersion} → ${args.targetVersion}\n ${dim(command, args.env)}\n`; +} + +function timestampMs(value: string | undefined): number | null { + if (!value) { + return null; + } + const parsed = Date.parse(value); + return Number.isNaN(parsed) ? null : parsed; +} + +function elapsedAtLeast(value: string | undefined, now: Date, intervalMs: number): boolean { + const previous = timestampMs(value); + if (previous === null) { + return true; + } + return now.getTime() - previous >= intervalMs; +} + +function shouldRefreshCache(cache: UpdateCheckCache | null, installedVersion: string, now: Date): boolean { + if (!cache || cache.installedVersion !== installedVersion) { + return true; + } + return elapsedAtLeast(cache.checkedAt, now, DAY_MS); +} + +async function refreshUpdateCache(args: { + cache: UpdateCheckCache | null; + fetchDistTags: () => Promise>; + homeDir?: string; + installedVersion: string; + now: Date; +}): Promise { + const distTags = await args.fetchDistTags(); + const decision = decideUpdate(args.installedVersion, distTags); + if (decision.status === 'skip') { + return; + } + + await writeUpdateCheckCache( + { + checkedAt: args.now.toISOString(), + channel: decision.channel, + installedVersion: args.installedVersion, + latestForChannel: decision.target, + ...(args.cache?.installedVersion === args.installedVersion && args.cache.channel === decision.channel + ? { lastNoticeAt: args.cache.lastNoticeAt } + : {}), + }, + { homeDir: args.homeDir }, + ); +} + +export async function prepareUpdateCheckNotice( + options: PrepareUpdateCheckNoticeOptions, +): Promise { + const env = options.env ?? process.env; + const now = (options.now ?? (() => new Date()))(); + const fetchDistTags = options.fetchDistTags ?? defaultFetchDistTags; + + if ( + shouldSuppressUpdateCheck({ + commandOptions: options.commandOptions, + env, + io: options.io, + }) + ) { + return { notice: null }; + } + + if (!inferUpdateChannel(options.installedVersion)) { + return { notice: null }; + } + + let cache = await readUpdateCheckCache({ homeDir: options.homeDir }); + let notice: string | null = null; + + if (cache?.installedVersion === options.installedVersion) { + const decision = decideUpdate(options.installedVersion, { + [cache.channel]: cache.latestForChannel, + }); + if (decision.status === 'available' && elapsedAtLeast(cache.lastNoticeAt, now, DAY_MS)) { + notice = renderUpdateNotice({ + channel: decision.channel, + env, + installedVersion: options.installedVersion, + targetVersion: decision.target, + }); + cache = { ...cache, lastNoticeAt: now.toISOString() }; + await writeUpdateCheckCache(cache, { homeDir: options.homeDir }); + } + } + + if (shouldRefreshCache(cache, options.installedVersion, now)) { + void refreshUpdateCache({ + cache, + fetchDistTags, + homeDir: options.homeDir, + installedVersion: options.installedVersion, + now, + }).catch(() => {}); + } + + return { notice }; +} diff --git a/packages/cli/test/update-check/cache.test.ts b/packages/cli/test/update-check/cache.test.ts new file mode 100644 index 00000000..446a62be --- /dev/null +++ b/packages/cli/test/update-check/cache.test.ts @@ -0,0 +1,95 @@ +import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { + readUpdateCheckCache, + updateCheckCachePath, + writeUpdateCheckCache, +} from '../../src/update-check/cache.js'; + +describe('update-check cache', () => { + let homeDir: string; + + beforeEach(async () => { + homeDir = await mkdtemp(join(tmpdir(), 'ktx-update-check-cache-')); + }); + + afterEach(async () => { + await rm(homeDir, { recursive: true, force: true }); + }); + + it('uses ~/.ktx/update-check.json', () => { + expect(updateCheckCachePath(homeDir)).toBe(join(homeDir, '.ktx', 'update-check.json')); + }); + + it('round-trips strict cache data', async () => { + await writeUpdateCheckCache( + { + checkedAt: '2026-06-06T10:00:00.000Z', + channel: 'latest', + installedVersion: '0.9.0', + latestForChannel: '0.10.0', + lastNoticeAt: '2026-06-06T11:00:00.000Z', + }, + { homeDir }, + ); + + await expect(readUpdateCheckCache({ homeDir })).resolves.toEqual({ + checkedAt: '2026-06-06T10:00:00.000Z', + channel: 'latest', + installedVersion: '0.9.0', + latestForChannel: '0.10.0', + lastNoticeAt: '2026-06-06T11:00:00.000Z', + }); + }); + + it('returns null when the cache file is missing', async () => { + await expect(readUpdateCheckCache({ homeDir })).resolves.toBeNull(); + }); + + it('returns null when the cache file is corrupt JSON', async () => { + await mkdir(join(homeDir, '.ktx'), { recursive: true }); + await writeFile(updateCheckCachePath(homeDir), '{bad json', 'utf-8'); + + await expect(readUpdateCheckCache({ homeDir })).resolves.toBeNull(); + }); + + it('returns null when the cache has unknown fields', async () => { + await mkdir(join(homeDir, '.ktx'), { recursive: true }); + await writeFile( + updateCheckCachePath(homeDir), + JSON.stringify( + { + checkedAt: '2026-06-06T10:00:00.000Z', + channel: 'latest', + installedVersion: '0.9.0', + latestForChannel: '0.10.0', + unexpected: true, + }, + null, + 2, + ), + 'utf-8', + ); + + await expect(readUpdateCheckCache({ homeDir })).resolves.toBeNull(); + }); + + it('writes formatted JSON with a trailing newline', async () => { + await writeUpdateCheckCache( + { + checkedAt: '2026-06-06T10:00:00.000Z', + channel: 'next', + installedVersion: '0.10.0-rc.1', + latestForChannel: '0.10.0-rc.2', + }, + { homeDir }, + ); + + const raw = await readFile(updateCheckCachePath(homeDir), 'utf-8'); + expect(raw).toContain('"channel": "next"'); + expect(raw.endsWith('\n')).toBe(true); + }); +}); diff --git a/packages/cli/test/update-check/channel.test.ts b/packages/cli/test/update-check/channel.test.ts new file mode 100644 index 00000000..f7b4a1e6 --- /dev/null +++ b/packages/cli/test/update-check/channel.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it } from 'vitest'; + +import { decideUpdate, inferUpdateChannel } from '../../src/update-check/channel.js'; + +describe('inferUpdateChannel', () => { + it.each([ + ['0.9.0', 'latest'], + ['0.10.0-rc.3', 'next'], + ['0.10.0-myfeat.2', null], + ['0.0.0', null], + ['not-a-version', null], + ])('maps %s to %s', (installed, expected) => { + expect(inferUpdateChannel(installed)).toBe(expected); + }); +}); + +describe('decideUpdate', () => { + it.each([ + [ + 'stable behind', + '0.9.0', + { latest: '0.10.0', next: '0.11.0-rc.1' }, + { status: 'available', channel: 'latest', target: '0.10.0' }, + ], + [ + 'stable equal', + '0.10.0', + { latest: '0.10.0', next: '0.11.0-rc.1' }, + { status: 'upToDate', channel: 'latest', target: '0.10.0' }, + ], + [ + 'stable ahead', + '0.11.0', + { latest: '0.10.0', next: '0.11.0-rc.1' }, + { status: 'upToDate', channel: 'latest', target: '0.10.0' }, + ], + [ + 'rc behind', + '0.11.0-rc.1', + { latest: '0.10.0', next: '0.11.0-rc.2' }, + { status: 'available', channel: 'next', target: '0.11.0-rc.2' }, + ], + [ + 'rc equal', + '0.11.0-rc.2', + { latest: '0.10.0', next: '0.11.0-rc.2' }, + { status: 'upToDate', channel: 'next', target: '0.11.0-rc.2' }, + ], + ['branch prerelease', '0.11.0-myfeat.1', { latest: '0.10.0', next: '0.11.0-rc.2' }, { status: 'skip' }], + ['missing channel tag', '0.9.0', { next: '0.11.0-rc.2' }, { status: 'skip' }], + ['invalid installed version', 'bad', { latest: '0.10.0' }, { status: 'skip' }], + ['invalid target version', '0.9.0', { latest: 'bad' }, { status: 'skip' }], + ['local development version', '0.0.0', { latest: '0.10.0' }, { status: 'skip' }], + ])('%s', (_name, installed, distTags, expected) => { + expect(decideUpdate(installed, distTags)).toEqual(expected); + }); +}); diff --git a/packages/cli/test/update-check/cli-program.test.ts b/packages/cli/test/update-check/cli-program.test.ts new file mode 100644 index 00000000..78116f97 --- /dev/null +++ b/packages/cli/test/update-check/cli-program.test.ts @@ -0,0 +1,152 @@ +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { buildKtxProgram } from '../../src/cli-program.js'; +import type { KtxCliDeps, KtxCliIo, KtxCliPackageInfo } from '../../src/cli-runtime.js'; +import { updateCheckCachePath } from '../../src/update-check/cache.js'; + +function makeIo(stdoutIsTTY = true): { io: KtxCliIo; stdout: () => string; stderr: () => string } { + let stdout = ''; + let stderr = ''; + return { + io: { + stdout: { + isTTY: stdoutIsTTY, + write: (chunk) => { + stdout += chunk; + }, + }, + stderr: { + write: (chunk) => { + stderr += chunk; + }, + }, + }, + stdout: () => stdout, + stderr: () => stderr, + }; +} + +describe('cli-program update check hooks', () => { + let projectDir: string; + let homeDir: string; + const info: KtxCliPackageInfo = { name: '@kaelio/ktx', version: '0.9.0' }; + + beforeEach(async () => { + projectDir = await mkdtemp(join(tmpdir(), 'ktx-update-project-')); + homeDir = await mkdtemp(join(tmpdir(), 'ktx-update-home-')); + await writeFile(join(projectDir, 'ktx.yaml'), '{}\n', 'utf-8'); + await mkdir(join(homeDir, '.ktx'), { recursive: true }); + vi.stubEnv('KTX_TELEMETRY_DISABLED', '1'); + vi.stubEnv('CI', ''); + vi.stubEnv('DO_NOT_TRACK', ''); + }); + + afterEach(async () => { + vi.unstubAllEnvs(); + await rm(projectDir, { recursive: true, force: true }); + await rm(homeDir, { recursive: true, force: true }); + }); + + it('prints a stale-cache notice without awaiting the background refresh', async () => { + await writeFile( + updateCheckCachePath(homeDir), + JSON.stringify( + { + checkedAt: '2026-06-05T10:00:00.000Z', + channel: 'latest', + installedVersion: '0.9.0', + latestForChannel: '0.10.0', + }, + null, + 2, + ), + 'utf-8', + ); + const io = makeIo(true); + const deps: KtxCliDeps = { doctor: async () => 0 }; + const fetchDistTags = vi.fn( + () => + new Promise>(() => { + return; + }), + ); + const program = buildKtxProgram({ + io: io.io, + deps, + packageInfo: info, + runInit: async () => 0, + updateCheck: { + env: { NO_COLOR: '1' }, + fetchDistTags, + homeDir, + now: () => new Date('2026-06-06T12:00:00.000Z'), + }, + }); + + await program.parseAsync(['--project-dir', projectDir, 'status'], { from: 'user' }); + + expect(fetchDistTags).toHaveBeenCalledTimes(1); + expect(io.stderr()).toContain('↑ Update available: ktx 0.9.0 → 0.10.0\n npm i -g @kaelio/ktx\n'); + }); + + it('prints a queued fresh-cache notice after the action', async () => { + await writeFile( + updateCheckCachePath(homeDir), + JSON.stringify( + { + checkedAt: '2026-06-06T11:00:00.000Z', + channel: 'latest', + installedVersion: '0.9.0', + latestForChannel: '0.10.0', + }, + null, + 2, + ), + 'utf-8', + ); + const io = makeIo(true); + const fetchDistTags = vi.fn(async () => ({ latest: '0.10.0' })); + const program = buildKtxProgram({ + io: io.io, + deps: { doctor: async () => 0 }, + packageInfo: info, + runInit: async () => 0, + updateCheck: { + env: { NO_COLOR: '1' }, + fetchDistTags, + homeDir, + now: () => new Date('2026-06-06T12:00:00.000Z'), + }, + }); + + await program.parseAsync(['--project-dir', projectDir, 'status'], { from: 'user' }); + + expect(fetchDistTags).not.toHaveBeenCalled(); + expect(io.stderr()).toContain('↑ Update available: ktx 0.9.0 → 0.10.0\n npm i -g @kaelio/ktx\n'); + }); + + it('does not run update checks for the hidden completion command', async () => { + const io = makeIo(true); + const fetchDistTags = vi.fn(async () => ({ latest: '0.10.0' })); + const program = buildKtxProgram({ + io: io.io, + deps: {}, + packageInfo: info, + runInit: async () => 0, + updateCheck: { + env: { NO_COLOR: '1' }, + fetchDistTags, + homeDir, + now: () => new Date('2026-06-06T12:00:00.000Z'), + }, + }); + + await program.parseAsync(['__complete', '--', 'ktx', 'co'], { from: 'user' }); + + expect(fetchDistTags).not.toHaveBeenCalled(); + expect(io.stderr()).not.toContain('Update available'); + }); +}); diff --git a/packages/cli/test/update-check/registry.test.ts b/packages/cli/test/update-check/registry.test.ts new file mode 100644 index 00000000..a83d360d --- /dev/null +++ b/packages/cli/test/update-check/registry.test.ts @@ -0,0 +1,80 @@ +import { EventEmitter } from 'node:events'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const requestMock = vi.hoisted(() => vi.fn()); + +vi.mock('node:https', () => ({ + request: requestMock, +})); + +type MockResponse = EventEmitter & { statusCode?: number }; +type MockRequest = EventEmitter & { + destroy: ReturnType; + end: () => void; + setTimeout: ReturnType; +}; + +function mockHttpsResponse(statusCode: number, body: string): { socket: { unref: ReturnType } } { + const socket = { unref: vi.fn() }; + requestMock.mockImplementation((_url: unknown, _options: unknown, callback: (response: MockResponse) => void) => { + const request = new EventEmitter() as MockRequest; + request.destroy = vi.fn(); + request.setTimeout = vi.fn(); + request.end = () => { + request.emit('socket', socket); + const response = new EventEmitter() as MockResponse; + response.statusCode = statusCode; + callback(response); + response.emit('data', Buffer.from(body)); + response.emit('end'); + }; + return request; + }); + return { socket }; +} + +describe('fetchDistTags', () => { + beforeEach(() => { + requestMock.mockReset(); + }); + + it('fetches @kaelio/ktx npm dist-tags and unrefs the socket', async () => { + const { socket } = mockHttpsResponse(200, JSON.stringify({ latest: '0.10.0', next: '0.11.0-rc.1' })); + const { fetchDistTags } = await import('../../src/update-check/registry.js'); + + await expect(fetchDistTags()).resolves.toEqual({ latest: '0.10.0', next: '0.11.0-rc.1' }); + + expect(requestMock).toHaveBeenCalledWith( + expect.any(URL), + expect.objectContaining({ + method: 'GET', + headers: expect.objectContaining({ accept: 'application/json' }), + }), + expect.any(Function), + ); + const [url] = requestMock.mock.calls[0] as [URL]; + expect(url.toString()).toBe('https://registry.npmjs.org/-/package/@kaelio/ktx/dist-tags'); + expect(socket.unref).toHaveBeenCalledTimes(1); + }); + + it('rejects non-2xx responses', async () => { + mockHttpsResponse(503, 'registry unavailable'); + const { fetchDistTags } = await import('../../src/update-check/registry.js'); + + await expect(fetchDistTags()).rejects.toThrow('npm dist-tags request failed with 503'); + }); + + it('rejects invalid JSON payloads', async () => { + mockHttpsResponse(200, '{bad json'); + const { fetchDistTags } = await import('../../src/update-check/registry.js'); + + await expect(fetchDistTags()).rejects.toThrow(); + }); + + it('rejects payloads that are not string dist-tag maps', async () => { + mockHttpsResponse(200, JSON.stringify({ latest: 123 })); + const { fetchDistTags } = await import('../../src/update-check/registry.js'); + + await expect(fetchDistTags()).rejects.toThrow(); + }); +}); diff --git a/packages/cli/test/update-check/update-check.test.ts b/packages/cli/test/update-check/update-check.test.ts new file mode 100644 index 00000000..a19b35bf --- /dev/null +++ b/packages/cli/test/update-check/update-check.test.ts @@ -0,0 +1,332 @@ +import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { updateCheckCachePath } from '../../src/update-check/cache.js'; +import { + prepareUpdateCheckNotice, + renderUpdateNotice, + shouldSuppressUpdateCheck, +} from '../../src/update-check/update-check.js'; + +function makeIo(stdoutIsTTY = true) { + let stderr = ''; + return { + io: { + stdout: { + isTTY: stdoutIsTTY, + write: () => {}, + }, + stderr: { + write: (chunk: string) => { + stderr += chunk; + }, + }, + }, + stderr: () => stderr, + }; +} + +async function flushAsyncWork(): Promise { + await new Promise((resolve) => { + setImmediate(resolve); + }); + await new Promise((resolve) => { + setImmediate(resolve); + }); +} + +describe('update-check orchestration', () => { + let homeDir: string; + + beforeEach(async () => { + homeDir = await mkdtemp(join(tmpdir(), 'ktx-update-check-')); + }); + + afterEach(async () => { + await rm(homeDir, { recursive: true, force: true }); + }); + + it.each([ + ['json option', true, {}, { json: true }], + ['json output option', true, {}, { output: 'json' }], + ['json format option', true, {}, { format: 'json' }], + ['CI', true, { CI: '1' }, {}], + ['non-TTY stdout', false, {}, {}], + ['KTX_NO_UPDATE_CHECK', true, { KTX_NO_UPDATE_CHECK: '1' }, {}], + ['NO_UPDATE_NOTIFIER', true, { NO_UPDATE_NOTIFIER: '1' }, {}], + ['DO_NOT_TRACK', true, { DO_NOT_TRACK: '1' }, {}], + ])('suppresses cache and network work for %s', async (_name, stdoutIsTTY, env, commandOptions) => { + const fetchDistTags = vi.fn(async () => ({ latest: '0.10.0' })); + + const result = await prepareUpdateCheckNotice({ + io: makeIo(stdoutIsTTY).io, + env, + homeDir, + installedVersion: '0.9.0', + commandOptions, + now: () => new Date('2026-06-06T12:00:00.000Z'), + fetchDistTags, + }); + + expect(result.notice).toBeNull(); + expect(fetchDistTags).not.toHaveBeenCalled(); + await expect(readFile(updateCheckCachePath(homeDir), 'utf-8')).rejects.toThrow(); + }); + + it.each([ + ['CI', true, { CI: '1', KTX_OUTPUT: 'pretty' }], + ['non-TTY stdout', false, { KTX_OUTPUT: 'pretty' }], + ])('suppresses cache and network work for %s even when pretty output is forced', async (_name, stdoutIsTTY, env) => { + const fetchDistTags = vi.fn(async () => ({ latest: '0.10.0' })); + + const result = await prepareUpdateCheckNotice({ + io: makeIo(stdoutIsTTY).io, + env, + homeDir, + installedVersion: '0.9.0', + commandOptions: {}, + now: () => new Date('2026-06-06T12:00:00.000Z'), + fetchDistTags, + }); + + expect(result.notice).toBeNull(); + expect(fetchDistTags).not.toHaveBeenCalled(); + await expect(readFile(updateCheckCachePath(homeDir), 'utf-8')).rejects.toThrow(); + }); + + it('does not suppress when only KTX_TELEMETRY_DISABLED is set', () => { + expect( + shouldSuppressUpdateCheck({ + io: makeIo(true).io, + env: { KTX_TELEMETRY_DISABLED: '1' } as NodeJS.ProcessEnv, + commandOptions: {}, + }), + ).toBe(false); + }); + + it('renders a compact no-color stable notice', () => { + expect( + renderUpdateNotice({ + installedVersion: '0.9.0', + targetVersion: '0.10.0', + channel: 'latest', + env: { NO_COLOR: '1' }, + }), + ).toBe('↑ Update available: ktx 0.9.0 → 0.10.0\n npm i -g @kaelio/ktx\n'); + }); + + it('renders the next-channel install command', () => { + expect( + renderUpdateNotice({ + installedVersion: '0.10.0-rc.1', + targetVersion: '0.10.0-rc.2', + channel: 'next', + env: { NO_COLOR: '1' }, + }), + ).toBe('↑ Update available: ktx 0.10.0-rc.1 → 0.10.0-rc.2\n npm i -g @kaelio/ktx@next\n'); + }); + + it('queues a cached notice and stamps lastNoticeAt', async () => { + await mkdir(join(homeDir, '.ktx'), { recursive: true }); + await writeFile( + updateCheckCachePath(homeDir), + JSON.stringify( + { + checkedAt: '2026-06-06T11:00:00.000Z', + channel: 'latest', + installedVersion: '0.9.0', + latestForChannel: '0.10.0', + }, + null, + 2, + ), + 'utf-8', + ); + const fetchDistTags = vi.fn(async () => ({ latest: '0.10.0' })); + + const result = await prepareUpdateCheckNotice({ + io: makeIo(true).io, + env: { NO_COLOR: '1' }, + homeDir, + installedVersion: '0.9.0', + now: () => new Date('2026-06-06T12:00:00.000Z'), + fetchDistTags, + }); + + expect(result.notice).toBe('↑ Update available: ktx 0.9.0 → 0.10.0\n npm i -g @kaelio/ktx\n'); + expect(fetchDistTags).not.toHaveBeenCalled(); + const stored = JSON.parse(await readFile(updateCheckCachePath(homeDir), 'utf-8')) as { lastNoticeAt?: string }; + expect(stored.lastNoticeAt).toBe('2026-06-06T12:00:00.000Z'); + }); + + it('queues a stale cached notice and still refreshes in the background', async () => { + await mkdir(join(homeDir, '.ktx'), { recursive: true }); + await writeFile( + updateCheckCachePath(homeDir), + JSON.stringify( + { + checkedAt: '2026-06-05T10:00:00.000Z', + channel: 'latest', + installedVersion: '0.9.0', + latestForChannel: '0.10.0', + lastNoticeAt: '2026-06-05T11:00:00.000Z', + }, + null, + 2, + ), + 'utf-8', + ); + const fetchDistTags = vi.fn(async () => ({ latest: '0.11.0' })); + + const result = await prepareUpdateCheckNotice({ + io: makeIo(true).io, + env: { NO_COLOR: '1' }, + homeDir, + installedVersion: '0.9.0', + now: () => new Date('2026-06-06T12:00:00.000Z'), + fetchDistTags, + }); + + expect(result.notice).toBe('↑ Update available: ktx 0.9.0 → 0.10.0\n npm i -g @kaelio/ktx\n'); + expect(fetchDistTags).toHaveBeenCalledTimes(1); + + await flushAsyncWork(); + await vi.waitFor(async () => { + const stored = JSON.parse(await readFile(updateCheckCachePath(homeDir), 'utf-8')) as { + latestForChannel: string; + lastNoticeAt?: string; + }; + expect(stored.latestForChannel).toBe('0.11.0'); + expect(stored.lastNoticeAt).toBe('2026-06-06T12:00:00.000Z'); + }); + }); + + it('throttles a cached notice for 24 hours', async () => { + await mkdir(join(homeDir, '.ktx'), { recursive: true }); + await writeFile( + updateCheckCachePath(homeDir), + JSON.stringify( + { + checkedAt: '2026-06-06T11:00:00.000Z', + channel: 'latest', + installedVersion: '0.9.0', + latestForChannel: '0.10.0', + lastNoticeAt: '2026-06-06T11:30:00.000Z', + }, + null, + 2, + ), + 'utf-8', + ); + + await expect( + prepareUpdateCheckNotice({ + io: makeIo(true).io, + env: { NO_COLOR: '1' }, + homeDir, + installedVersion: '0.9.0', + now: () => new Date('2026-06-06T12:00:00.000Z'), + fetchDistTags: vi.fn(async () => ({ latest: '0.10.0' })), + }), + ).resolves.toEqual({ notice: null }); + }); + + it('does not show stale cache after the installed version changes and schedules a refresh', async () => { + await mkdir(join(homeDir, '.ktx'), { recursive: true }); + await writeFile( + updateCheckCachePath(homeDir), + JSON.stringify( + { + checkedAt: '2026-06-06T11:00:00.000Z', + channel: 'latest', + installedVersion: '0.9.0', + latestForChannel: '0.10.0', + }, + null, + 2, + ), + 'utf-8', + ); + const fetchDistTags = vi.fn(async () => ({ latest: '0.10.0' })); + + const result = await prepareUpdateCheckNotice({ + io: makeIo(true).io, + env: { NO_COLOR: '1' }, + homeDir, + installedVersion: '0.10.0', + now: () => new Date('2026-06-06T12:00:00.000Z'), + fetchDistTags, + }); + + expect(result.notice).toBeNull(); + expect(fetchDistTags).toHaveBeenCalledTimes(1); + }); + + it('refreshes stale cache in the background and preserves lastNoticeAt for the same install', async () => { + await mkdir(join(homeDir, '.ktx'), { recursive: true }); + await writeFile( + updateCheckCachePath(homeDir), + JSON.stringify( + { + checkedAt: '2026-06-05T10:00:00.000Z', + channel: 'latest', + installedVersion: '0.9.0', + latestForChannel: '0.10.0', + lastNoticeAt: '2026-06-06T09:00:00.000Z', + }, + null, + 2, + ), + 'utf-8', + ); + + await prepareUpdateCheckNotice({ + io: makeIo(true).io, + env: { NO_COLOR: '1' }, + homeDir, + installedVersion: '0.9.0', + now: () => new Date('2026-06-06T12:00:00.000Z'), + fetchDistTags: vi.fn(async () => ({ latest: '0.11.0' })), + }); + await flushAsyncWork(); + + await vi.waitFor(async () => { + const stored = JSON.parse(await readFile(updateCheckCachePath(homeDir), 'utf-8')) as { + checkedAt: string; + latestForChannel: string; + lastNoticeAt?: string; + }; + expect(stored.checkedAt).toBe('2026-06-06T12:00:00.000Z'); + expect(stored.latestForChannel).toBe('0.11.0'); + expect(stored.lastNoticeAt).toBe('2026-06-06T09:00:00.000Z'); + }); + }); + + it('swallows refresh failures and leaves existing cache untouched', async () => { + await mkdir(join(homeDir, '.ktx'), { recursive: true }); + const originalCache = { + checkedAt: '2026-06-05T10:00:00.000Z', + channel: 'latest', + installedVersion: '0.9.0', + latestForChannel: '0.10.0', + lastNoticeAt: '2026-06-06T09:00:00.000Z', + }; + await writeFile(updateCheckCachePath(homeDir), JSON.stringify(originalCache, null, 2), 'utf-8'); + + await prepareUpdateCheckNotice({ + io: makeIo(true).io, + env: { NO_COLOR: '1' }, + homeDir, + installedVersion: '0.9.0', + now: () => new Date('2026-06-06T12:00:00.000Z'), + fetchDistTags: vi.fn(async () => { + throw new Error('offline'); + }), + }); + await flushAsyncWork(); + + await expect(readFile(updateCheckCachePath(homeDir), 'utf-8')).resolves.toBe(JSON.stringify(originalCache, null, 2)); + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 871931c0..cc2fb3d2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -209,6 +209,9 @@ importers: react: specifier: ^19.2.6 version: 19.2.6 + semver: + specifier: ^7.8.1 + version: 7.8.1 simple-git: specifier: 3.36.0 version: 3.36.0 @@ -243,6 +246,9 @@ importers: '@types/react': specifier: ^19.2.15 version: 19.2.15 + '@types/semver': + specifier: ^7.7.1 + version: 7.7.1 '@vitest/coverage-v8': specifier: ^4.1.7 version: 4.1.7(vitest@4.1.7) @@ -2501,6 +2507,9 @@ packages: '@types/readable-stream@4.0.23': resolution: {integrity: sha512-wwXrtQvbMHxCbBgjHaMGEmImFTQxxpfMOR/ZoQnXxB1woqkUbdLGFDgauo00Py9IudiaqSeiBiulSV9i6XIPig==} + '@types/semver@7.7.1': + resolution: {integrity: sha512-FmgJfu+MOcQ370SD0ev7EI8TlCAfKYU+B4m5T3yXc1CiRN94g/SZPtsCkk506aUDtlMnFZvasDwHHUcZUEaYuA==} + '@types/triple-beam@1.3.5': resolution: {integrity: sha512-6WaYesThRMCl19iryMYP7/x2OVgCtbIVflDGFpWnb9irXI3UjYE4AzmYuiUKY1AJstGijoY+MgUszMgRxIYTYw==} @@ -5219,6 +5228,11 @@ packages: engines: {node: '>=10'} hasBin: true + semver@7.8.1: + resolution: {integrity: sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg==} + engines: {node: '>=10'} + hasBin: true + send@1.2.1: resolution: {integrity: sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==} engines: {node: '>= 18'} @@ -8321,6 +8335,8 @@ snapshots: dependencies: '@types/node': 24.12.4 + '@types/semver@7.7.1': {} + '@types/triple-beam@1.3.5': {} '@types/unist@2.0.11': {} @@ -11433,6 +11449,8 @@ snapshots: semver@7.8.0: {} + semver@7.8.1: {} + send@1.2.1: dependencies: debug: 4.4.3 From bf1fe9748e066058e94824498a5af769f5825bf5 Mon Sep 17 00:00:00 2001 From: Luca Martial <48870843+luca-martial@users.noreply.github.com> Date: Sat, 6 Jun 2026 22:32:08 -0400 Subject: [PATCH 25/25] docs: minor README and docs-site touch-ups (#266) - Link the Y Combinator badge and the docs "by Kaelio" label - Add a maintainer line to the README - Set the npm author field on @kaelio/ktx Co-authored-by: Claude Opus 4.8 --- README.md | 6 ++- docs-site/app/layout.config.tsx | 2 +- docs-site/components/logo.tsx | 80 ++++++++++++++++++++------------- packages/cli/package.json | 4 ++ 4 files changed, 58 insertions(+), 34 deletions(-) diff --git a/README.md b/README.md index 67abe741..d286e3f1 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ Documentation Join the ktx Slack community License - Y Combinator P25 + Y Combinator P25

@@ -23,6 +23,10 @@ Slack

+

+ Built and maintained by Kaelio +

+ --- **ktx** is a self-improving context layer that teaches agents how to query your diff --git a/docs-site/app/layout.config.tsx b/docs-site/app/layout.config.tsx index 3245ab09..28ba6b03 100644 --- a/docs-site/app/layout.config.tsx +++ b/docs-site/app/layout.config.tsx @@ -5,7 +5,7 @@ import { SlackIcon } from "@/components/slack-icon"; export const baseOptions: BaseLayoutProps = { nav: { - title: , + title: Logo, transparentMode: "top", }, links: [ diff --git a/docs-site/components/logo.tsx b/docs-site/components/logo.tsx index afc926a8..77370280 100644 --- a/docs-site/components/logo.tsx +++ b/docs-site/components/logo.tsx @@ -1,40 +1,56 @@ -export function Logo() { +"use client"; + +import Link from "next/link"; + +const brandFont = { + fontFamily: "var(--font-display), var(--font-sans), sans-serif", +} as const; + +export function Logo({ href = "/", className }: { href?: string; className?: string }) { return ( -
-
- - -
-
+
+
+ + + + + + +
+ + ktx + + + by Kaelio + +
- ktx - - - by Kaelio + Docs
- - Docs -
); } diff --git a/packages/cli/package.json b/packages/cli/package.json index 3255d4c2..c08d26f2 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -2,6 +2,10 @@ "name": "@kaelio/ktx", "version": "0.9.0", "description": "Standalone ktx context layer for data agents", + "author": { + "name": "Kaelio", + "url": "https://www.kaelio.com" + }, "type": "module", "engines": { "node": ">=22.0.0"