Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
formatSetupFailureDetail,
getPythonSetupErrorAction,
getPythonSetupErrorMessage,
isIndexUnreachableFailure,
NO_COMPUTE_TARGET_MESSAGE,
PythonSetupErrorAction,
} from "../utils/errorMessages";
Expand Down Expand Up @@ -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(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ import {
formatSetupFailureDetail,
getPythonSetupErrorAction,
getPythonSetupErrorMessage,
isIndexUnreachableFailure,
UV_INDEX_DOCS_URL,
UV_INSTALL_DOCS_URL,
} from "./errorMessages";
import {
Expand All @@ -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,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
);
Expand Down Expand Up @@ -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);
});
});
Loading
Loading