Skip to content
Open
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 @@ -255,6 +255,19 @@ export async function searchPyPIVersions(
headers: { Accept: 'application/vnd.pypi.simple.v1+json' },
signal: createAbortSignal(token),
});

// A project that does not exist is a routine outcome, not a failure: any
// caller can pass a name that was typed or guessed. PyPI answers 404 with
// the plain-text body `404 Not Found`, so the response must be checked
// before parsing -- feeding that body to `json()` throws an opaque
// "Unexpected non-whitespace character after JSON" instead.
if (response.status === 404) {
return [];
}
if (!response.ok) {
throw new Error(`Could not look up versions of '${name}' on PyPI (HTTP ${response.status}).`);
}

const json = (await response.json()) as { versions?: string[]; files?: PyPIFile[] };
const versions = json.versions ?? [];
const files = json.files ?? [];
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -202,6 +202,38 @@ suite('searchPyPIVersions', () => {
expect(result).to.deep.equal(['1.0']);
});

test('reports no versions for a project that does not exist', async () => {
// PyPI answers 404 with the plain-text body `404 Not Found`. Parsing that
// as JSON throws "Unexpected non-whitespace character after JSON at
// position 4", which used to reach the user verbatim.
fetchStub.resolves({
ok: false,
status: 404,
json: () => Promise.reject(new SyntaxError('Unexpected non-whitespace character after JSON at position 4')),
} as unknown as Response);

const result = await searchPyPIVersions('definitely-not-a-real-package-xyz', async () => ({}));

expect(result).to.deep.equal([]);
});

test('throws a readable error when PyPI is unavailable', async () => {
fetchStub.resolves({
ok: false,
status: 503,
json: () => Promise.reject(new SyntaxError('Unexpected token')),
} as unknown as Response);

let message = '';
try {
await searchPyPIVersions('pkg', async () => ({}));
} catch (e) {
message = e instanceof Error ? e.message : String(e);
}

expect(message).to.match(/HTTP 503/);
});

test('maps files to the longest matching version (1.0 vs 1.0.1)', async () => {
fetchStub.resolves(
makeVersionsResponse(
Expand Down
9 changes: 7 additions & 2 deletions src/vs/platform/commands/common/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,8 +56,13 @@ export interface ICommandMetadata {
// --- Start Positron ---
/**
* When true, this command is exposed to AI agents via
* `positron.ai.getAgentAllowedCommands()`. The command must also be
* palette-exposed (`f1: true`) so a user could run it themselves.
* `positron.ai.getAgentAllowedCommands()`.
*
* Prefer exposing the command in the command palette as well (usually
* `f1: true`), so that anything an agent can do, a user can do for
* themselves. That is a preference rather than a requirement: this flag
* alone decides what is advertised, so a command outside the palette is
* still exposed to agents.
*/
readonly agentCompatible?: boolean;
// --- End Positron ---
Expand Down
117 changes: 117 additions & 0 deletions src/vs/workbench/contrib/positronPackages/browser/packageVersions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
/*---------------------------------------------------------------------------------------------
* Copyright (C) 2026 Posit Software, PBC. All rights reserved.
* Licensed under the Elastic License 2.0. See LICENSE.txt for license information.
*--------------------------------------------------------------------------------------------*/

import * as semver from '../../../../base/common/semver/semver.js';

/**
* Sort version strings in descending order (newest first).
* Uses semver comparison when possible, falls back to string comparison.
*
* This drives the version quick-pick, where the user picks from the whole list,
* so its ordering is deliberately left as-is. `newestAvailableVersion` does not
* use it: choosing a version automatically needs the more precise comparison
* below, because `semver.coerce` truncates anything past three segments.
*/
export function sortVersionsDescending(versions: string[]): string[] {
return [...versions].sort((a, b) => {
const aSemver = semver.valid(a, true) ? a : semver.coerce(a);
const bSemver = semver.valid(b, true) ? b : semver.coerce(b);

if (aSemver && bSemver) {
return semver.rcompare(aSemver, bSemver, true);
}

// Fall back to simple string comparison
return a < b ? 1 : a > b ? -1 : 0;
});
}

/**
* Prerelease and development suffixes, as PEP 440 and semver spell them:
* `2.0.0rc1`, `1.0a2`, `2.0.0-rc.1`, `1.0.0.dev1`, `3.0.0alpha1`.
*
* A trailing digit group is required, which is what keeps three things that only
* look similar out: conda's letter-suffixed builds (`1.1.1c`), R's patch levels
* (`1.0-3`), and PEP 440 post-releases (`1.0.post1`, which is *newer* than
* `1.0`, not a prerelease).
*/
const PRERELEASE_SUFFIX = /[-_.]?(?:a|b|c|rc|alpha|beta|pre|preview|dev)[-_.]?\d+$/i;

/**
* Whether a version string names a prerelease.
*
* The string is tested directly as well as through semver, because semver alone
* is not enough: `semver.valid` rejects two-component versions like `1.0a2` and
* `2.0b1`, and `semver.coerce` then drops the suffix, so they would read as the
* stable `1.0.0` and `2.0.0`.
*/
function isPrerelease(version: string): boolean {
const trimmed = version.trim();
if (PRERELEASE_SUFFIX.test(trimmed)) {
return true;
}
const parsed = semver.valid(trimmed, true) || semver.coerce(trimmed);
return parsed ? !!semver.prerelease(parsed, true) : false;
}

/**
* Split a version into the parts that decide which of two versions is newer: a
* PEP 440 epoch, and the numeric groups that follow it.
*
* Every numeric group is kept, however many there are, so four-segment versions
* (`1.2.3.4`) and R patch levels (`1.0-3`) compare on their real values rather
* than being truncated to three segments. Digits inside a suffix count too,
* which is what orders `1.0.post1` above `1.0` and `1.0.0rc2` above `1.0.0rc1`.
*/
function releaseKey(version: string): { epoch: number; segments: number[] } {
const trimmed = version.trim();
const epochSeparator = trimmed.indexOf('!');
const epoch = epochSeparator === -1 ? 0 : Number(trimmed.slice(0, epochSeparator)) || 0;
const release = epochSeparator === -1 ? trimmed : trimmed.slice(epochSeparator + 1);
const segments = (release.match(/\d+/g) ?? []).map(Number);
return { epoch, segments };
}

/**
* Compare two versions, newest first. A higher epoch always wins; otherwise the
* numeric groups are compared in order, treating a missing group as zero so
* `1.2` and `1.2.0` are equal. Versions with no digits at all fall back to
* string comparison.
*/
function compareVersionsDescending(a: string, b: string): number {
const aKey = releaseKey(a);
const bKey = releaseKey(b);
if (aKey.epoch !== bKey.epoch) {
return bKey.epoch - aKey.epoch;
}
const length = Math.max(aKey.segments.length, bKey.segments.length);
for (let i = 0; i < length; i++) {
const aSegment = aKey.segments[i] ?? 0;
const bSegment = bKey.segments[i] ?? 0;
if (aSegment !== bSegment) {
return bSegment - aSegment;
}
}
return a < b ? 1 : a > b ? -1 : 0;
}

/**
* Pick the version to use when the caller asked for the newest available one.
*
* Prefers the newest stable release over a newer prerelease, matching what the
* package managers install by default: with versions 1.8, 1.9 and 2.0.0rc1,
* `pip install <name>` installs 1.9, not the release candidate.
*
* Backends disagree on ordering -- R returns a single version, PyPI returns
* ascending order and does not filter prereleases, conda returns newest-first --
* so the list is sorted here rather than trusted.
*
* Falls back to the newest of everything when a package has published only
* prereleases, so a caller always gets a version if one exists.
*/
export function newestAvailableVersion(versions: string[]): string | undefined {
const sorted = [...versions].sort(compareVersionsDescending);
return sorted.find(version => !isPrerelease(version)) ?? sorted.at(0);
}
Loading
Loading