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
3 changes: 3 additions & 0 deletions packages/databricks-vscode/src/python-setup/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,9 @@ the mapped message alone (see `reportSetupIssue.ts` for the closed routing list)
such a conflict is usually the user's own declared dependencies. When the
published constraints are what conflict, that genuine case is served by a soft,
conditional pointer in the output log instead (see `formatSetupFailureDetail`).
`E_PROVISION_CONFLICT` (the CLI's distinct pins-vs-local code) follows the same
policy; the Full-preset flow additionally offers a restore-and-retry recovery
(see `buildConflictRecoveryActions`).

**Privacy posture.** The issue body carries build metadata (error code, phase,
env key, package manager, extension/CLI versions, OS) plus the CLI's stderr. The
Expand Down

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ import {
PythonSetupAttempt,
PythonSetupResultReporter,
} from "../../telemetry/pythonSetupExtensions";
import type {PythonSetupRunTrigger} from "../../telemetry/constants";
import {PrimaryManager} from "../../language/packageManagerDetection";
import {
isUvSetupSuitable,
Expand Down Expand Up @@ -144,6 +145,26 @@ export interface PythonSetupSetupDeps {
*/
adoptInterpreter: (venvPath: string, projectRoot: string) => Promise<void>;

/**
* Open the project's pyproject.toml in an editor — the "Open pyproject.toml"
* button on a constraint-conflict failure, so the user can inspect and
* adjust the dependencies that clashed. Takes the run's captured root so it
* targets the project the failing run mutated, even after a mid-run switch.
*/
openProjectFile: (projectRoot: string) => Promise<void>;

/**
* Restore `<projectRoot>/pyproject.toml` from the CLI's pre-merge
* `backupPath` (see {@link PythonSetupResult.backupPath}) by copying it over
* the file. First step of the "Retry DB Connect setup" recovery, whose
* rationale lives on {@link buildConflictRecoveryActions}; a rejection there
* aborts the retry before any CLI run.
*/
restoreProjectFile: (
projectRoot: string,
backupPath: string
) => Promise<void>;

saveState: (state: PythonSetupPersistedState) => void;

/**
Expand Down Expand Up @@ -171,11 +192,17 @@ export interface PythonSetupSetupDeps {
* each opens an external URL or runs a VS Code command. Most failures carry
* one; `E_UV_MISSING` carries two ("Install uv" + "Installation guide", see
* `getPythonSetupErrorActions`).
*
* `options.includeShowLogs` defaults to true; pass `false` to omit the
* trailing "Show Logs" button — for a self-service toast whose own buttons
* are the remedy (the recoverable constraint conflict), so the row stays
* short. The channel is still written and revealed, so the log is reachable.
*/
showError: (
message: string,
detail?: string,
actions?: PythonSetupErrorAction[]
actions?: PythonSetupErrorAction[],
options?: {includeShowLogs?: boolean}
) => Promise<void>;

showSuccess: (result: PythonSetupResult) => Promise<void>;
Expand Down Expand Up @@ -305,19 +332,27 @@ export class PythonSetupEnvironmentSetup implements Disposable {
}

setup(): Promise<void> {
// Re-entrancy guard: coalesce concurrent callers onto the running run
// rather than spawning a second project-mutating CLI process. The guard
// releases when the run's *work* settles; the terminal notification is
// presented via {@link present} (fire-and-forget), so a toast left open
// never wedges the entry -- see that method.
return this.runGuarded(() => this.runSetup());
}

/**
* Re-entrancy guard: coalesce concurrent callers onto the running run rather
* than spawning a second project-mutating CLI process. The guard releases
* when the run's *work* settles; the terminal notification is presented via
* {@link present} (fire-and-forget), so a toast left open never wedges the
* entry -- see that method. Used both for a fresh {@link setup} and for the
* constraint-conflict retry, so a retry click cannot race a run already in
* flight.
*/
private runGuarded(run: () => Promise<void>): Promise<void> {
if (this.inFlight) {
return this.inFlight;
}
const run = this.runSetup().finally(() => {
const guarded = run().finally(() => {
this.inFlight = undefined;
});
this.inFlight = run;
return run;
this.inFlight = guarded;
return guarded;
}

/**
Expand All @@ -344,8 +379,7 @@ export class PythonSetupEnvironmentSetup implements Disposable {
}

private async runSetup(): Promise<void> {
const {cli, projectRoot, isVisible, resolveCompute, withProgress} =
this.deps;
const {projectRoot, isVisible, resolveCompute} = this.deps;

const cwd = projectRoot();
if (cwd === undefined) {
Expand Down Expand Up @@ -389,6 +423,29 @@ export class PythonSetupEnvironmentSetup implements Disposable {
return;
}

await this.runResolved(compute, cwd, preset);
}

/**
* Run a resolved invocation (compute + preset) to completion: record the
* attempt, spawn the CLI under a progress indicator, then adopt the
* interpreter and persist state on success — or surface a mapped error on
* failure. Split out from {@link runSetup} so the constraint-conflict
* "Retry DB Connect setup" recovery can re-enter it with the `dbconnect` preset
* directly, without re-prompting the compute or the preset picker.
*
* `trigger` overrides how the attempt is labeled: the retry passes
* `conflict_retry`; the normal path leaves it undefined so
* {@link recordAttempt} derives `initial` / `rerun` from readiness.
*/
private async runResolved(
compute: SetupCompute,
cwd: string,
preset: SetupPreset,
trigger?: PythonSetupRunTrigger
): Promise<void> {
const {cli, withProgress} = this.deps;

const invocation: SetupLocalInvocation = {
compute,
...presetToFlags(preset),
Expand All @@ -401,7 +458,8 @@ export class PythonSetupEnvironmentSetup implements Disposable {
const {reportResult, packageManager} = await this.recordAttempt(
invocation,
cwd,
preset
preset,
trigger
);
// Per-run report context: the static build info plus this run's manager.
const reportEnv: ReportEnvironment = {
Expand Down Expand Up @@ -469,14 +527,36 @@ export class PythonSetupEnvironmentSetup implements Disposable {
result.pythonResolution === "installed_fallback"
? "manual_selection_requested"
: result.pythonResolution;
const actions = remediationActions.some(
(candidate) =>
candidate.command === SELECT_PYTHON_INTERPRETER_COMMAND_ID
)
? remediationActions
: reportAction
? [reportAction]
: remediationActions;
// A constraint conflict is recoverable only when this run carried the
// pins (Full preset) AND the CLI saved a pre-merge backup to roll them
// back to. Without either, fall through to the ordinary doc-link
// handling rather than offer a "retry as DB Connect" that can't work
// (nothing to restore) or would loop (a run that already skipped pins).
const conflictBackupPath =
result.error?.code === "E_PROVISION_CONFLICT" &&
!invocation.skipConstraints &&
// Truthiness, not just `!== undefined`: a (contract-forbidden)
// empty backupPath has nothing to restore, so it must fall
// through rather than offer a Retry that could only throw.
result.backupPath
? result.backupPath
: undefined;
const recoverableConflict = conflictBackupPath !== undefined;
const actions = recoverableConflict
? this.buildConflictRecoveryActions(
compute,
cwd,
conflictBackupPath
)
: remediationActions.some(
(candidate) =>
candidate.command ===
SELECT_PYTHON_INTERPRETER_COMMAND_ID
)
? remediationActions
: reportAction
? [reportAction]
: remediationActions;
reportResult({
outcome: "failed",
...(pythonSetupFlow !== undefined ? {pythonSetupFlow} : {}),
Expand All @@ -495,7 +575,12 @@ export class PythonSetupEnvironmentSetup implements Disposable {
result,
reportRepo ? reportLogLink(reportRepo) : undefined
),
actions
actions,
// The recoverable conflict is self-service via its Retry /
// Open buttons, so drop the trailing "Show Logs" to keep the
// notification's button row short (the channel is revealed
// regardless).
recoverableConflict ? {includeShowLogs: false} : undefined
)
);
return;
Expand Down Expand Up @@ -601,6 +686,56 @@ export class PythonSetupEnvironmentSetup implements Disposable {
this.present(this.deps.showSuccess(result));
}

/**
* The two recovery buttons for a Full-preset constraint conflict, both
* run-actions (their behavior needs the run's live compute/cwd):
*
* - "Retry DB Connect setup" restores pyproject.toml from the CLI's pre-merge
* `backupPath`, then re-runs as DB Connect (`--no-constraints`). The restore
* is load-bearing: the conflict fails *after* the pins were merged to disk,
* so `--no-constraints` alone would leave the conflicting pins in place and
* only skip re-adding them. It runs through {@link runGuarded} — so a click
* can't race an in-flight run, and the restore only fires when the retry
* actually runs — and no-ops once the project is ready, so a stale toast's
* Retry can't downgrade an environment a later run provisioned. A failed
* restore throws before the CLI spawns, leaving showError to log it.
* - "Open pyproject.toml" opens the merged file so the user can adjust the
* dependencies that clashed.
*/
private buildConflictRecoveryActions(
compute: SetupCompute,
cwd: string,
backupPath: string
): PythonSetupErrorAction[] {
return [
{
label: "Retry DB Connect setup",
run: () => {
// A stale Retry (project provisioned by a later run since the
// conflict) must not re-provision and downgrade it.
if (this.readyRoots.has(cwd)) {
return;
}
return this.runGuarded(async () => {
// Restore before re-running; a failure throws here, before
// any attempt is recorded or the CLI spawns.
await this.deps.restoreProjectFile(cwd, backupPath);
await this.runResolved(
compute,
cwd,
"dbconnect",
"conflict_retry"
);
});
},
},
{
label: "Open pyproject.toml",
run: () => this.deps.openProjectFile(cwd),
},
];
}

/**
* Emit the attempt event for a run that is about to start and return its
* outcome reporter.
Expand All @@ -617,7 +752,8 @@ export class PythonSetupEnvironmentSetup implements Disposable {
private async recordAttempt(
invocation: SetupLocalInvocation,
projectRoot: string,
setupPreset: SetupPreset
setupPreset: SetupPreset,
trigger?: PythonSetupRunTrigger
): Promise<{
reportResult: PythonSetupResultReporter;
packageManager: PrimaryManager;
Expand Down Expand Up @@ -665,11 +801,16 @@ export class PythonSetupEnvironmentSetup implements Disposable {
mode: invocation.skipDbconnect ? "constraints-only" : "default",
setupPreset,
isGreenfield,
// A run against a project already marked ready this session is a
// re-run (the ready row's Re-run button / row click); anything
// else is the first setup. Derived from state, not the command,
// so every entry point labels the same event correctly.
trigger: this.readyRoots.has(projectRoot) ? "rerun" : "initial",
// An explicit trigger wins (the constraint-conflict retry passes
// `conflict_retry`, so its recovery clicks are countable and not
// conflated with a first-time DB Connect pick). Otherwise it is
// derived from state: a run against a project already marked ready
// this session is a re-run (the ready row's Re-run button / row
// click), anything else the first setup. Derived from state, not
// the command, so every entry point labels the same event.
trigger:
trigger ??
(this.readyRoots.has(projectRoot) ? "rerun" : "initial"),
});
return {
packageManager,
Expand Down
Loading
Loading