diff --git a/packages/databricks-vscode/src/python-setup/controllers/PythonSetupEnvironmentSetup.test.ts b/packages/databricks-vscode/src/python-setup/controllers/PythonSetupEnvironmentSetup.test.ts index d04d0920e..d28a201ec 100644 --- a/packages/databricks-vscode/src/python-setup/controllers/PythonSetupEnvironmentSetup.test.ts +++ b/packages/databricks-vscode/src/python-setup/controllers/PythonSetupEnvironmentSetup.test.ts @@ -872,11 +872,45 @@ describe("PythonSetupEnvironmentSetup telemetry", () => { errorCode: ERROR_NO_TARGET.error!.code, envKey: ERROR_NO_TARGET.compute?.envKey, diskMutated: ERROR_NO_TARGET.error!.diskMutated, + // E_NO_TARGET is not one of the package-fetching phases. + indexUnreachable: false, warnings: ERROR_NO_TARGET.warnings, }, ]); }); + it("flags indexUnreachable when uv cannot reach the package index", async () => { + const telemetry = makeTelemetryRecorder(); + // A provision failure whose message is uv's connection-refused signature + // (blocked pypi.org needing a proxy), not a dependency conflict. + const blockedIndex: PythonSetupResult = { + ...ERROR_NO_TARGET, + phases: [ + {phase: "preflight", status: "ok"}, + {phase: "resolve", status: "ok"}, + {phase: "fetch", status: "ok"}, + {phase: "merge", status: "ok"}, + {phase: "provision", status: "error"}, + {phase: "validate", status: "pending"}, + ], + error: { + code: "E_PROVISION", + failurePhase: "provision", + message: + "error: Failed to fetch: `https://pypi.org/simple/ipykernel/`\n" + + " Caused by: tcp connect error: Connection refused (os error 61)", + diskMutated: false, + }, + }; + const setup = new PythonSetupEnvironmentSetup( + makeDeps({...telemetry, cli: makeCli({resolve: blockedIndex})}) + ); + + await setup.setup(); + + expect(telemetry.results[0].indexUnreachable).to.equal(true); + }); + it('reports the synthetic "adopt" phase when interpreter adoption fails', async () => { const telemetry = makeTelemetryRecorder(); const setup = new PythonSetupEnvironmentSetup( diff --git a/packages/databricks-vscode/src/python-setup/controllers/PythonSetupEnvironmentSetup.ts b/packages/databricks-vscode/src/python-setup/controllers/PythonSetupEnvironmentSetup.ts index 8afdbcdf2..ca61a081c 100644 --- a/packages/databricks-vscode/src/python-setup/controllers/PythonSetupEnvironmentSetup.ts +++ b/packages/databricks-vscode/src/python-setup/controllers/PythonSetupEnvironmentSetup.ts @@ -14,6 +14,7 @@ import { formatSetupFailureDetail, getPythonSetupErrorAction, getPythonSetupErrorMessage, + isIndexUnreachableFailure, NO_COMPUTE_TARGET_MESSAGE, PythonSetupErrorAction, } from "../utils/errorMessages"; @@ -379,6 +380,7 @@ export class PythonSetupEnvironmentSetup implements Disposable { errorCode: result.error?.code, envKey: result.compute?.envKey, diskMutated: result.error?.diskMutated, + indexUnreachable: isIndexUnreachableFailure(result), warnings: result.warnings, }); this.present( diff --git a/packages/databricks-vscode/src/python-setup/utils/errorMessages.test.ts b/packages/databricks-vscode/src/python-setup/utils/errorMessages.test.ts index 712733145..e4bbfdf36 100644 --- a/packages/databricks-vscode/src/python-setup/utils/errorMessages.test.ts +++ b/packages/databricks-vscode/src/python-setup/utils/errorMessages.test.ts @@ -3,6 +3,8 @@ import { formatSetupFailureDetail, getPythonSetupErrorAction, getPythonSetupErrorMessage, + isIndexUnreachableFailure, + UV_INDEX_DOCS_URL, UV_INSTALL_DOCS_URL, } from "./errorMessages"; import { @@ -14,6 +16,16 @@ import { ERROR_USAGE, } from "../models/fixtures/setupLocalResults"; +/** + * A uv "package index unreachable" error message, mirroring the real CLI text a + * locked-down corporate machine produces when pypi.org is blocked (see the + * `os error 61` / connection-refused signature). + */ +const INDEX_UNREACHABLE_CLI_MSG = + "Using CPython 3.12.8\n" + + "error: Failed to fetch: `https://pypi.org/simple/ipykernel/`\n" + + " Caused by: tcp connect error: Connection refused (os error 61)"; + /** Build a minimal failed result carrying a specific error. */ function failure( code: PythonSetupErrorCode, @@ -77,6 +89,45 @@ describe("getPythonSetupErrorMessage", () => { ); }); + it("maps a blocked-index E_PROVISION to proxy guidance, not a conflict message", () => { + const msg = getPythonSetupErrorMessage( + failure("E_PROVISION", {message: INDEX_UNREACHABLE_CLI_MSG}) + ); + expect(msg).to.match(/package index|pypi\.org/i); + expect(msg).to.match(/UV_INDEX_URL|pip\.conf|proxy/i); + // Must NOT claim a dependency conflict, which would misdirect the user. + expect(msg).to.not.match(/conflict|version conflict/i); + }); + + it("does NOT give index/proxy guidance for an E_PYTHON_INSTALL download failure", () => { + // uv fetches a managed CPython build from a different mirror + // (UV_PYTHON_INSTALL_MIRROR), which UV_INDEX_URL / pip index-url cannot + // fix — so this keeps the plain Python-install message. + const msg = getPythonSetupErrorMessage( + failure("E_PYTHON_INSTALL", { + message: + "error: Failed to download `cpython-3.12.8`\n" + + " Caused by: tcp connect error: Connection refused (os error 61)", + }) + ); + expect(msg).to.not.match(/UV_INDEX_URL|pip\.conf|package index/i); + expect(msg).to.match(/python version/i); + }); + + it("keeps the dependency-conflict message when E_PROVISION is a real conflict", () => { + // A resolution conflict has no connectivity symptom, so it must not be + // mistaken for a blocked index. + const msg = getPythonSetupErrorMessage( + failure("E_PROVISION", { + message: + "error: No solution found when resolving dependencies: " + + "x==1 depends on y<2, but the runtime requires y==2", + }) + ); + expect(msg).to.match(/resolve|dependenc/i); + expect(msg).to.not.match(/UV_INDEX_URL|pip\.conf/i); + }); + it("maps E_FETCH to an offline/unreachable message", () => { expect(getPythonSetupErrorMessage(failure("E_FETCH"))).to.match( /reach|offline|network/i @@ -218,7 +269,17 @@ describe("getPythonSetupErrorAction", () => { }); }); - it("offers no action for error codes other than E_UV_MISSING", () => { + it("offers a Configure package index action for a blocked index", () => { + const action = getPythonSetupErrorAction( + failure("E_PROVISION", {message: INDEX_UNREACHABLE_CLI_MSG}) + ); + expect(action).to.deep.equal({ + label: "Configure package index", + url: UV_INDEX_DOCS_URL, + }); + }); + + it("offers no action for an ordinary E_PROVISION conflict", () => { expect(getPythonSetupErrorAction(failure("E_PROVISION"))).to.equal( undefined ); @@ -277,4 +338,203 @@ describe("formatSetupFailureDetail", () => { ok.error = null; expect(formatSetupFailureDetail(ok)).to.equal(undefined); }); + + it("appends copy-pasteable proxy remediation for a blocked index", () => { + const detail = formatSetupFailureDetail( + failure("E_PROVISION", {message: INDEX_UNREACHABLE_CLI_MSG}) + ); + // Still carries the raw CLI error … + expect(detail).to.contain("Connection refused"); + // … plus both remediation paths. + expect(detail).to.contain("UV_INDEX_URL"); + expect(detail).to.contain("index-url"); + expect(detail).to.contain("extra-index-url"); + }); + + it("adds no remediation block for a non-connectivity E_PROVISION", () => { + const detail = formatSetupFailureDetail( + failure("E_PROVISION", { + message: "No solution found when resolving dependencies", + }) + ); + expect(detail).to.not.contain("UV_INDEX_URL"); + }); +}); + +describe("isIndexUnreachableFailure", () => { + it("is true for E_PROVISION with a connection-refused message", () => { + expect( + isIndexUnreachableFailure( + failure("E_PROVISION", {message: INDEX_UNREACHABLE_CLI_MSG}) + ) + ).to.equal(true); + }); + + it("is true for a DNS/name-resolution failure fetching the index", () => { + expect( + isIndexUnreachableFailure( + failure("E_PROVISION", { + message: + "error: Failed to fetch: `https://pypi.org/simple/foo/`\n" + + " Caused by: failed to lookup address information: " + + "Temporary failure in name resolution", + }) + ) + ).to.equal(true); + }); + + it("is true for the macOS getaddrinfo DNS phrasing (failed to lookup address)", () => { + // macOS wording lacks "name resolution"; the "failed to lookup address" + // symptom is what catches it. + expect( + isIndexUnreachableFailure( + failure("E_PROVISION", { + message: + "error: Failed to fetch: `https://pypi.org/simple/foo/`\n" + + " Caused by: failed to lookup address information: " + + "nodename nor servname provided, or not known", + }) + ) + ).to.equal(true); + }); + + it("is false for a genuine dependency conflict (no connectivity symptom)", () => { + expect( + isIndexUnreachableFailure( + failure("E_PROVISION", { + message: "No solution found when resolving dependencies", + }) + ) + ).to.equal(false); + }); + + it("is false for E_PYTHON_INSTALL (a CPython download, not an index fetch)", () => { + // Scoped to E_PROVISION: the managed-Python download uses a different + // mirror that the index/proxy guidance cannot fix. + expect( + isIndexUnreachableFailure( + failure("E_PYTHON_INSTALL", { + message: INDEX_UNREACHABLE_CLI_MSG, + }) + ) + ).to.equal(false); + }); + + it("is false for codes outside the provision phase", () => { + // Even with a connectivity-looking message, E_FETCH (constraints repo) + // keeps its own mapping — this predicate scopes to E_PROVISION. + expect( + isIndexUnreachableFailure( + failure("E_FETCH", {message: INDEX_UNREACHABLE_CLI_MSG}) + ) + ).to.equal(false); + }); + + it("is true for a git-NAMED package on a blocked index (not a git source)", () => { + // The failing index URL contains "git" (the package `gitpython`), but it + // is a /simple/ index fetch — must still be detected. Guards against a + // naive bare-"git" exclusion. + expect( + isIndexUnreachableFailure( + failure("E_PROVISION", { + message: + "error: Failed to fetch: `https://pypi.org/simple/gitpython/`\n" + + " Caused by: tcp connect error: Connection refused (os error 61)", + }) + ) + ).to.equal(true); + }); + + it("is false for a git-dependency source fetch (no /simple index path)", () => { + // uv prefixes git-clone errors with "failed to fetch" too, but there is no + // /simple index path — the fix is unrelated to the package index. + expect( + isIndexUnreachableFailure( + failure("E_PROVISION", { + message: + "error: Failed to fetch git repository " + + "`git+https://github.com/acme/pkg`\n" + + " Caused by: tcp connect error: Connection refused", + }) + ) + ).to.equal(false); + }); + + it("is false for a direct wheel/URL dependency fetch (no /simple index path)", () => { + // A `pkg @ https://host/pkg.whl` fetch failing to connect is not a package + // index, so the index/proxy guidance would be wrong. + expect( + isIndexUnreachableFailure( + failure("E_PROVISION", { + message: + "error: Failed to fetch: `https://host.example/pkg-1.0-py3-none-any.whl`\n" + + " Caused by: tcp connect error: Connection refused", + }) + ) + ).to.equal(false); + }); + + it("is false when 'simple' only appears in a name, not the /simple/ index path", () => { + // Guards the trailing slash: a git source or wheel whose path contains + // "simple" (e.g. simple-salesforce) must not be read as an index fetch. + expect( + isIndexUnreachableFailure( + failure("E_PROVISION", { + message: + "error: Failed to fetch git repository " + + "`git+https://github.com/simple-salesforce/simple-salesforce`\n" + + " Caused by: tcp connect error: Connection refused", + }) + ) + ).to.equal(false); + }); + + it("is false for a git source even when its path contains /simple/ (org named 'simple')", () => { + // Structural exclusion: git+ / "git repository" wins over a /simple/ that + // happens to be a path segment of the git URL. + expect( + isIndexUnreachableFailure( + failure("E_PROVISION", { + message: + "error: Failed to fetch git repository " + + "`git+https://github.com/simple/foo`\n" + + " Caused by: tcp connect error: Connection refused", + }) + ) + ).to.equal(false); + }); + + it("is false for a direct wheel hosted under a /simple/ path", () => { + // Structural exclusion: a distribution file (.whl) is not an index listing, + // even when served from a /simple/ directory. + expect( + isIndexUnreachableFailure( + failure("E_PROVISION", { + message: + "error: Failed to fetch: `https://host.example/simple/pkg-1.0-py3-none-any.whl`\n" + + " Caused by: tcp connect error: Connection refused", + }) + ) + ).to.equal(false); + }); + + it("is false for a connectivity symptom without an index-fetch context", () => { + // A build backend's own stderr ("timed out") with no "failed to fetch" + // is not a blocked index. + expect( + isIndexUnreachableFailure( + failure("E_PROVISION", { + message: + "error: Failed to build `foo==1.0`\n" + + " Caused by: the build backend timed out", + }) + ) + ).to.equal(false); + }); + + it("is false when there is no error object", () => { + const ok = failure("E_PROVISION"); + ok.error = null; + expect(isIndexUnreachableFailure(ok)).to.equal(false); + }); }); diff --git a/packages/databricks-vscode/src/python-setup/utils/errorMessages.ts b/packages/databricks-vscode/src/python-setup/utils/errorMessages.ts index d372cf82c..92b25571d 100644 --- a/packages/databricks-vscode/src/python-setup/utils/errorMessages.ts +++ b/packages/databricks-vscode/src/python-setup/utils/errorMessages.ts @@ -34,6 +34,68 @@ export const NO_COMPUTE_TARGET_MESSAGE = export const UV_INSTALL_DOCS_URL = "https://docs.astral.sh/uv/getting-started/installation/"; +/** + * uv's guide to configuring a package index. Single-sourced here so the popup + * action for a blocked index and its test point at the same page. + */ +export const UV_INDEX_DOCS_URL = + "https://docs.astral.sh/uv/configuration/indexes/"; + +/** + * "Cannot reach the host" phrases in uv's error text (matched case-insensitively). + * TLS-interception and proxy-auth (407) are deliberately left out: they need a + * CA-trust / credentials fix, not a different index. + */ +const INDEX_CONNECTIVITY_SYMPTOMS = [ + "connection refused", + "connect error", // e.g. "tcp connect error" + "connection reset", + "timed out", + "name resolution", // DNS: "… name resolution" (Linux) + "failed to lookup address", // DNS: macOS getaddrinfo phrasing + "dns error", + "network is unreachable", + "no route to host", + "could not connect", +]; + +/** + * True when an E_PROVISION failure is a blocked *package index* (pypi.org blocked, + * proxy needed), not a dependency conflict — both share the code, so the conflict + * copy would misdirect. The CLI emits no distinct code, so we read uv's message. + * + * Non-obvious choices: the index marker is the PEP 503 "/simple/" path (so a + * git-*named* package like /simple/gitpython/ still counts), backed by structural + * exclusion of git sources and direct distribution URLs — which can also carry + * "/simple/". E_PYTHON_INSTALL is excluded (its CPython download uses a different + * mirror this can't fix). Precision over recall: an unmatched phrasing falls back + * to the per-code copy, never wrong remediation. + */ +export function isIndexUnreachableFailure(result: PythonSetupResult): boolean { + const err = result.error; + if (!err || err.code !== "E_PROVISION") { + return false; + } + const msg = err.message?.toLowerCase() ?? ""; + // A git source or a direct distribution URL can also "failed to fetch" and may + // even carry "/simple/" in their path (an org named "simple", a wheel under a + // /simple/ dir) — but neither is a package index, so exclude them structurally. + if ( + msg.includes("git+") || + msg.includes("git repository") || + msg.includes(".whl") || + msg.includes(".tar.") + ) { + return false; + } + // "/simple/" with the trailing slash: the real index fetch URL is always + // {index}/simple/{package}/ (never a name like /simplejson-… or a file). + if (!msg.includes("failed to fetch") || !msg.includes("/simple/")) { + return false; + } + return INDEX_CONNECTIVITY_SYMPTOMS.some((s) => msg.includes(s)); +} + /** * An optional remediation button to attach to a failure popup: a label and the * external URL it opens. Kept alongside {@link getPythonSetupErrorMessage} so the @@ -86,24 +148,41 @@ const BASE_MESSAGE: Record< const GENERIC = "Python environment setup failed."; +/** + * Popup copy for a blocked index, replacing E_PROVISION's misleading conflict + * text. Summary only — the copy-pasteable fix lives in {@link formatSetupFailureDetail}. + */ +const INDEX_UNREACHABLE_MESSAGE = + "Couldn't reach the Python package index — this often means a corporate " + + "network is blocking the public index (pypi.org) and a proxy is required. " + + "Point uv at your organization's package index (set the UV_INDEX_URL " + + "environment variable, or add an index-url to your pip config), then try " + + "again. See the logs for details."; + export function getPythonSetupErrorMessage(result: PythonSetupResult): string { const err = result.error; if (!err) { return GENERIC; } - const base = BASE_MESSAGE[err.code]?.(result) ?? GENERIC; + // Checked before the per-code map: a blocked index arrives as E_PROVISION, + // whose generic "dependency conflict" copy points at the wrong cause. + const base = isIndexUnreachableFailure(result) + ? INDEX_UNREACHABLE_MESSAGE + : BASE_MESSAGE[err.code]?.(result) ?? GENERIC; return base + diskStateSuffix(result, err); } /** - * The remediation button, if any, for a failed setup result. Only `E_UV_MISSING` - * carries one today: the CLI could neither find nor auto-install uv, so we point - * the user at uv's install guide. All other codes are actionable from the message - * and logs alone, so they get no extra button. + * The remediation button, if any: a blocked index → uv's index-config docs, or + * `E_UV_MISSING` → uv's install docs. Other codes are actionable from the message + * and logs alone. */ export function getPythonSetupErrorAction( result: PythonSetupResult ): PythonSetupErrorAction | undefined { + if (isIndexUnreachableFailure(result)) { + return {label: "Configure package index", url: UV_INDEX_DOCS_URL}; + } if (result.error?.code === "E_UV_MISSING") { return {label: "Install uv", url: UV_INSTALL_DOCS_URL}; } @@ -141,6 +220,27 @@ export function formatSetupFailureDetail( result.phases.map((p) => `${p.phase}=${p.status}`).join(", ") ); } + // For a blocked index, follow the raw error with concrete, copy-pasteable + // remediation — the popup only summarises it. Kept here (not the popup) so + // the multi-line commands render as-is in the output channel. + if (isIndexUnreachableFailure(result)) { + lines.push( + "", + "This looks like the Python package index (pypi.org) is unreachable — " + + "often a corporate network that blocks it and requires a proxy. Point uv " + + "at your organization's package index in one of these ways, then re-run setup:", + "", + " 1. Set the UV_INDEX_URL environment variable to https:///simple", + " macOS/Linux: export UV_INDEX_URL=https:///simple", + " Windows: setx UV_INDEX_URL https:///simple", + "", + " 2. Or add an index-url to your pip config (pip.conf, or pip.ini on Windows);", + " the CLI bridges it to uv:", + " [global]", + " index-url = https:///simple", + " Use index-url (not extra-index-url) so pypi.org is replaced, not merely supplemented." + ); + } // Bracket with blank lines so the block stands apart from any streamed CLI // output already in the channel. return `\n${lines.join("\n")}\n`; diff --git a/packages/databricks-vscode/src/telemetry/constants.ts b/packages/databricks-vscode/src/telemetry/constants.ts index 37a335c84..bc04eaad4 100644 --- a/packages/databricks-vscode/src/telemetry/constants.ts +++ b/packages/databricks-vscode/src/telemetry/constants.ts @@ -498,6 +498,7 @@ export class EventTypes { errorCode?: PythonSetupErrorCode; envKey?: string; diskMutated?: boolean; + indexUnreachable?: boolean; warningsCount?: number; // A code->count histogram, not a list: JSON-stringified into a property // by recordEvent (numbers alone become metrics). Keys are a closed @@ -542,6 +543,13 @@ export class EventTypes { comment: "Whether the failed run had already modified project files. Omitted when the CLI reported no error object", }, + indexUnreachable: { + comment: + "Whether the failure was uv being unable to reach the package index (a blocked pypi.org " + + "needing a proxy) rather than a dependency conflict — both arrive as E_PROVISION. Present " + + "on every CLI setup failure, so false is meaningful (a non-index failure, the rate's " + + "denominator); omitted with no CLI result and on post-CLI adopt/persist failures", + }, warningsCount: { comment: "How many merge-phase advisories the CLI emitted (env-owned pins conflicting with the " + diff --git a/packages/databricks-vscode/src/telemetry/pythonSetupExtensions.test.ts b/packages/databricks-vscode/src/telemetry/pythonSetupExtensions.test.ts index ffdb7a51c..d541d1bf1 100644 --- a/packages/databricks-vscode/src/telemetry/pythonSetupExtensions.test.ts +++ b/packages/databricks-vscode/src/telemetry/pythonSetupExtensions.test.ts @@ -155,6 +155,39 @@ describe(__filename, () => { }); }); + it("emits indexUnreachable on a blocked-index failure", () => { + const {telemetry, events} = makeTelemetry(); + + const reportResult = telemetry.recordPythonSetupAttempt({ + packageManager: "uv", + targetType: "cluster", + mode: "default", + trigger: "initial", + }); + reportResult({ + outcome: "failed", + failurePhase: "provision", + errorCode: "E_PROVISION", + indexUnreachable: true, + }); + + expect(events[1].props["event.indexUnreachable"]).to.equal("true"); + }); + + it("omits indexUnreachable when it is not reported", () => { + const {telemetry, events} = makeTelemetry(); + + const reportResult = telemetry.recordPythonSetupAttempt({ + packageManager: "uv", + targetType: "cluster", + mode: "default", + trigger: "initial", + }); + reportResult({outcome: "failed", failurePhase: "provision"}); + + expect(events[1].props).to.not.have.property("event.indexUnreachable"); + }); + it("reports at most one result per attempt", () => { const {telemetry, events} = makeTelemetry(); diff --git a/packages/databricks-vscode/src/telemetry/pythonSetupExtensions.ts b/packages/databricks-vscode/src/telemetry/pythonSetupExtensions.ts index 1585eccaf..24490ac20 100644 --- a/packages/databricks-vscode/src/telemetry/pythonSetupExtensions.ts +++ b/packages/databricks-vscode/src/telemetry/pythonSetupExtensions.ts @@ -52,6 +52,13 @@ export interface PythonSetupOutcomeReport { errorCode?: PythonSetupErrorCode; envKey?: string; diskMutated?: boolean; + /** + * Blocked package index vs. a genuine dependency conflict — both arrive as + * `E_PROVISION`, so this splits them to gauge how often proxies bite. Set on + * every CLI setup failure (`false` is meaningful — the rate's denominator); + * omitted with no CLI result and on post-CLI adopt/persist failures. + */ + indexUnreachable?: boolean; /** * The CLI's merge-phase warnings, verbatim from the result. Present whenever * the CLI produced a result (so `[]` reads as "a run happened with no @@ -252,6 +259,9 @@ Telemetry.prototype.recordPythonSetupAttempt = function ( ...(report.diskMutated !== undefined ? {diskMutated: report.diskMutated} : {}), + ...(report.indexUnreachable !== undefined + ? {indexUnreachable: report.indexUnreachable} + : {}), // A present `warnings` array means the CLI produced a result, so the // count is meaningful even at 0 (a clean merge) -- unlike the omitted // fields above, 0 is a value, not "unknown". The per-code histogram is