Skip to content

Supplement Packages pane with metadata from Posit Package Manager (p3m)#13067

Merged
bricestacey merged 27 commits intomainfrom
12928/p3m-package-metadata
Apr 23, 2026
Merged

Supplement Packages pane with metadata from Posit Package Manager (p3m)#13067
bricestacey merged 27 commits intomainfrom
12928/p3m-package-metadata

Conversation

@bricestacey
Copy link
Copy Markdown
Contributor

@bricestacey bricestacey commented Apr 16, 2026

See #12928

Adds package metadata from Posit Package Manager (P3M) to the Packages pane for Python environments (except conda). Packages that have a newer version available now show an up arrow indicator.

This adds support for fetching package metadata to supplement the packages pane. There are two methods:

  1. Anytime the Packages service refreshes the packages list, it will refresh the metadata for any uncached packages.
  2. There is an action that appears in its own group in the overflow menu: "Refresh Metadata". This doesn't wipe the cache, but it will force refetching the metadata for the provided packages.

Here is a demo with an added up arrow next to any outdated package.

Screenshot 2026-04-16 at 3 57 20 PM Screenshot 2026-04-16 at 4 07 02 PM

Note that the code has support for fetching additional metadata, which I had started to implement. However, this simple up arrow icon was the most subtle and simple solution to prove out the basic data flows, when to automatically fetch metadata, and manually supporting the action.

Details

P3M Integration:

  • New p3mSearch.ts module in positron-python extension calls POST https://p3m.dev/__api__/filter/packages
  • Parses NDJSON response format
  • Metadata includes: summary, license, latest version, available versions

UI Changes:

  • Up arrow () indicator appears next to package version when latestVersion !== version
  • Hover tooltip shows "Update available: {version}"

Architecture:

  • Metadata stored in separate cache from package list in PositronPackagesInstance
  • Package list refresh preserves cached metadata; only fetches metadata for new packages
  • Guard prevents concurrent metadata fetch calls
  • New IPC protocol method $getPackageMetadata for extension host communication

Release Notes

New Features

  • Add support for supplementing Packages pane with metadata from p3m.

Bug Fixes

  • N/A

QA Notes

This only supports PIP and UV projects. So be sure you're not in a conda session.

@:packages-pane

Add a PositronDynamicActionBar with a sort menu that allows sorting
packages by name (A-Z or Z-A). The sort order is applied after filtering.

See #12924
When the packages pane is too narrow to display the sort dropdown,
it now appears as a "Sort" submenu in the overflow menu.

See #12924
@github-actions
Copy link
Copy Markdown

github-actions Bot commented Apr 16, 2026

E2E Tests 🚀
This PR will run tests tagged with: @:critical @:packages-pane

readme  valid tags

@cindyytong
Copy link
Copy Markdown
Contributor

Newbie question - How is the Refresh Packages button different than the Refresh Metadata button?

@cindyytong
Copy link
Copy Markdown
Contributor

Nonblocking comments

  1. Can we test out an edge case where the name of the package overflows beyond the width of the package pane, will the text just get cut off ..., and then we still see version and up arrow. May want to check this with the min width of the packages pane before it collapses

  2. Thoughts on using an existing icon tied with extension updates like the codeicon cloud-upload instead of the arrow which is a bit easy to miss

https://microsoft.github.io/vscode-codicons/dist/codicon.html
Screenshot 2026-04-16 at 4 46 16 PM

Resolve merge conflicts in listPackages.tsx:
- Incorporate new ActionBarFilter props (showClearAlways, clearButtonIcon, size)
- Add handleFilterTextChanged to clear selection on filter change
- Add empty state message when no packages match filter
- Keep sort dropdown and action bar structure
The P3M API returns a 400 error when requesting download counts if
usage data is disabled on the server. Set omit_downloads to true
since download counts are not used in the UI.

See #12928
Extend LanguageRuntimePackage interface with optional metadata fields
(description, license, latestVersion, availableVersions, packageSize,
publishedDate, downloads) populated from P3M API.

Add PackageDetails component that displays metadata when a package is
selected in the list. The details panel shows description, license,
latest version with update indicator, size, published date, and
download count.

Wire up pip and uv package managers to fetch P3M metadata and merge
it with installed package information.

See #12928
Split package fetching into two stages for better UX:
1. getPackages() returns basic list immediately from kernel
2. getPackageMetadata() fetches P3M metadata asynchronously

The package list now displays instantly while metadata loads in the
background. When metadata arrives, the UI updates automatically.

Added optional getPackageMetadata() method to IPackageManager and
LanguageRuntimePackageManager interfaces. The instance orchestrates
the two-stage fetch and fires onDidRefreshPackagesInstance twice.

See #12928
Implement the extension host bridge layer for getPackageMetadata:
- Add $getPackageMetadata to ExtHostLanguageRuntimeShape protocol
- Implement in extHostLanguageRuntime.ts
- Add adapter method in mainThreadLanguageRuntime.ts

The method returns undefined when the extension's package manager
doesn't implement getPackageMetadata, allowing graceful degradation.

See #12928
Use CancellationToken.None for the background metadata fetch since
it runs after the main refresh operation completes. The original
token may be disposed at that point, causing spurious cancellation
errors.

See #12928
Maps don't serialize properly over the extension host IPC channel.
Convert Map to plain object (Record) in extHostLanguageRuntime before
sending, and convert back to Map in the mainThread adapter.

See #12928
Show a simple up arrow next to packages that have a newer version
available instead of expanding a details panel when selected.

See #12928
Keep package metadata in a separate cache that persists across
refreshes. When refreshing packages, existing cached metadata is
preserved and only new packages trigger a P3M API call.

See #12928
Add a "Refresh Metadata" action in the overflow menu that clears the
metadata cache and fetches fresh data from P3M. Also add a guard to
prevent concurrent metadata fetch calls.

See #12928
@bricestacey bricestacey force-pushed the 12928/p3m-package-metadata branch from d1c152b to e81b98e Compare April 21, 2026 14:30
Conda package names don't cleanly map to PyPI, so fetching metadata from
P3M's PyPI repo could return incorrect latest versions and licenses for
conda packages.

See #12928
The sort dropdown is being developed on a separate branch; it was
unrelated to the P3M metadata work in this PR.

See #12928
The AbortController was wired up via an undisposed
token.onCancellationRequested subscription, so a long-lived token
(e.g. a session-scoped token reused across many metadata fetches) would
accumulate listeners. Inline the signal wiring and dispose the
subscription in a finally block.

See #12928
refreshPackages inlined the same two-stage fetch pattern already in
_refreshPackagesInternal. Have it delegate to the internal helper and
just wrap with the refresh state fire.

See #12928
The pip and uv package managers had identical 12-line blocks converting
P3M API fields onto the LanguageRuntimePackage shape. Hoist the mapping
into fetchP3MPackageMetadata and have the managers delegate.

See #12928
Keep only the fields currently (or imminently) consumed by the UI:
latestVersion, license, publishedDate. Drop description,
availableVersions, packageSize, and downloads from the API surface
until there's a consumer for them.

See #12928
softwarenerd
softwarenerd previously approved these changes Apr 22, 2026
Copy link
Copy Markdown
Contributor

@softwarenerd softwarenerd left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looking at refreshMetadata:

refreshMetadata's cache clear races with the background _fetchAndMergeMetadata from refreshPackages, causing the forced refresh to silently no-op (guard bails early) while the old in-flight fetch repopulates the cache with stale data keyed to the previous package list — fix by tracking the fetch as a CancelablePromise<void> and cancel-and-restart instead of guarding.

/**
* P3M package metadata returned from the API.
*/
interface P3MPackageMetadata {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Everything in P3MPackageMetadata should be readonly.

interface P3MPackageMetadata {
    readonly name: string;
    readonly version: string;
    readonly summary: string | null;
    readonly license: string | null;
    readonly licenses?: readonly string[];
    readonly license_types?: readonly string[];
    readonly package_date: string | null;
    readonly package_size: number | null;
    readonly downloads: number | null;
    readonly available_versions?: readonly string[];
    readonly dependencies?: {
        readonly imports?: ReadonlyArray<{ readonly name: string; readonly version?: string; readonly operator?: string }>;
        readonly suggests?: ReadonlyArray<{ readonly name: string; readonly version?: string; readonly operator?: string }>;
    };
}

/**
* Request body for the P3M filter packages API.
*/
interface P3MFilterRequest {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Everything in P3MFilterRequest should be readonly.

interface P3MFilterRequest {
    readonly names: readonly string[];
    readonly repo: string;
    readonly omit_downloads?: boolean;
    readonly omit_dependencies?: boolean;
    readonly omit_package_details?: boolean;
}

* Parse NDJSON (newline-delimited JSON) response into an array of objects.
* Each line in the response is a separate JSON object.
*/
function parseNDJSON<T>(text: string): T[] {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm curious as to why this is a generic when it's used only once as:

const packages = parseNDJSON<P3MPackageMetadata>(text);

Do you expect there to be other uses in the future?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This was just what Claude did originally.

The generic implementation makes sense to me and could be reused if necessary. It's all compile time and has no bearing on its actual functionality.

IMO the non-generic version is kinda weird because it's so specific to a given type, when the functionality doesn't care about the type.


const controller = token ? new AbortController() : undefined;
const cancelSubscription =
controller && token ? token.onCancellationRequested(() => controller.abort()) : undefined;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

const controller = new AbortController();
const cancelSubscription = token?.onCancellationRequested(() => controller.abort());

Seems simpler.

* Licensed under the Elastic License 2.0. See LICENSE.txt for license information.
*--------------------------------------------------------------------------------------------*/

'use strict';
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No-op in TS modules (already strict).

}

// Clear the cache to force a full refresh
this._metadataCache.clear();
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Race between refreshMetadata's cache clear and the background fetch.

refreshPackages fires off _fetchAndMergeMetadata as a background task with CancellationToken.None (intentional, so it isn't killed by the caller's token). If the user invokes refreshMetadata while that background fetch is in flight, the current code:

  1. Clears _metadataCache.
  2. Calls _fetchAndMergeMetadata, which hits the _metadataFetchInProgress guard and returns early.
  3. Resolves, so the caller thinks the forced refresh succeeded.
  4. The original background fetch eventually completes and repopulates the cache — with data keyed against the earlier package list, not the current one.

The "force refresh" contract isn't met, and the final cache state depends on whichever request was in flight.

Suggested fix: replace the _metadataFetchInProgress boolean with a tracked in-flight handle so callers can cancel it explicitly, and use cancel-and-restart instead of a guard.

private _metadataFetch?: CancelablePromise<void>;
  • _fetchAndMergeMetadata wraps its body in createCancelablePromise, stores the handle on this._metadataFetch, and cancels any prior value at the top (so re-entrance supersedes rather than no-ops).
  • Pass the internal token into packageManager.getPackageMetadata and re-check token.isCancellationRequested after the await, before writing to the cache — guarantees a cancelled fetch can't pollute the cache after a caller has cleared it.
  • Propagate the external caller's token: externalToken.onCancellationRequested(() => fetch.cancel()), and dispose the subscription in a .finally.
  • Swallow CancellationError (via isCancellationError); keep logging for genuine failures.
  • In refreshMetadata, call this._metadataFetch?.cancel() before this._metadataCache.clear() — closes the race.
  • Override dispose() to cancel any in-flight fetch so background work doesn't outlive the instance.

Net effect: the _metadataFetchInProgress flag goes away, and refreshMetadata reliably performs a full re-fetch regardless of what else is in flight.

refreshMetadata's cache clear could race with the background fetch
from refreshPackages: the boolean guard would bail the forced refresh
early, and the original fetch would then repopulate the cache keyed
to the previous package list. Replace the guard with a
CancelablePromise handle so re-entrance supersedes the prior fetch,
cancel it before clearing the cache, and re-check cancellation
post-await so a cancelled fetch can't pollute the cache.

Also address PR feedback: readonly interfaces in p3mSearch, simpler
AbortController setup, drop 'use strict' in the unit test.
@bricestacey bricestacey merged commit 5fb1459 into main Apr 23, 2026
39 checks passed
@bricestacey bricestacey deleted the 12928/p3m-package-metadata branch April 23, 2026 15:44
@github-actions github-actions Bot locked and limited conversation to collaborators Apr 23, 2026
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants