Skip to content

Add a Posit Connect pins driver to the Data Connections pane#14874

Open
juliasilge wants to merge 17 commits into
mainfrom
feat/data-connections-pins
Open

Add a Posit Connect pins driver to the Data Connections pane#14874
juliasilge wants to merge 17 commits into
mainfrom
feat/data-connections-pins

Conversation

@juliasilge

@juliasilge juliasilge commented Jul 15, 2026

Copy link
Copy Markdown
Member

Addresses part of #14663

This PR adds a new built-in extension, positron-data-driver-pins, that registers a "Posit Connect Pins" driver in the Data Connections pane (the pane gated behind the dataConnections.enabled setting). You can add a Posit Connect server as a data connection and browse its pins in the tree, grouped by owner, plus generate R or Python code to read a pin from a console:

Screenshot 2026-07-14 at 7 23 09 PM

Context for reviewers who don't work with pins very much! 📌 We have R and Python packages for publishing and sharing pins (data frames, models, or arbitrary files) to a board such as Posit Connect. A pin is a versioned piece of content, addressed as owner/name (for example julia.silge/what-numbers-are-these), with a storage type (parquet, csv, rds, joblib, and so on). This driver is read-only; it enumerates pins via Connect's applications API (the same endpoint pin_list() uses) and reads each pin's data.txt metadata from the content's served URL, so it sees exactly what the pins packages see for a given API key.

What this PR includes

  • The positron-data-driver-pins extension, with a single connection mechanism: server URL + API key.
  • Tree browsing: owner nodes (account icon) that expand to pin nodes (pin icon) with a storage-type badge.
  • R and Python connection code generation: an environment-variable variant (the default) and an explicit-server variant; the API key is embedded only through the existing "Include Secrets" flow.
  • New Owner and Pin node kinds and icons in the Data Connections tree.
  • Unit tests (URL normalization, HTTP client, data.txt parsing, driver code generation, and tree grouping/sorting/badges) and two e2e tests (a hermetic seed-and-browse test for the Connect CI lane, and an opt-in read-only remote smoke test).

What this PR does NOT include for now

  • Opening a pin in the Data Explorer (tabular preview for parquet/csv/arrow). Pins are leaf nodes for now.
  • Pin version history; the tree stops at the pin, with no per-version nodes.
  • Auth beyond server URL + API key (environment variables and OAuth are later PRs).
  • Writing or publishing pins (that's not something we'll do at all via Connections)

I'll be addressing most of these in further PRs.

Release Notes

New Features

Bug Fixes

  • N/A

Validation Steps

The Data Connections pane is behind a setting, so set dataConnections.enabled: true and reload the window.

Automated e2e coverage: @:connect @:connections @:workbench

Manual verification (which anyone here at Posit can do against https://pub.demo.posit.team/ with a real API key):

  1. Ensure a Connect server has at least one pin. Anyone at Posit can use https://pub.demo.posit.team/ and see pins that are shared with everyone there.
  2. Create an API key in Connect (profile menu, "Manage Your API Keys", "New API Key").
  3. In Positron, set dataConnections.enabled: true and reload. Open the Data Connections pane, click Add Connection, choose "Posit Connect Pins".
  4. Enter the server URL (a bare host such as pub.demo.posit.team works; it defaults to https) and the API key, then Save.
  5. Expand the connection and confirm the owner -> pin tree with type badges.
  6. Optional, if you have R and Python set up: from the connection's menu, use Create Script or Copy to get R or Python code, run it in a matching console, and confirm pin_list() / board.pin_list() returns the pins. You'll need to set up R and Python to auth to Connect as well, as outlined at https://pins.rstudio.com/reference/board_connect.html and/or https://rstudio.github.io/pins-python/reference/board_connect.html.

@github-actions

github-actions Bot commented Jul 15, 2026

Copy link
Copy Markdown

E2E Tests 🚀
This PR will run tests tagged with: @:critical @:connect @:connections @:workbench

Why these tags?
Tag Source
@:critical Always runs (required)
@:connect PR description
@:connections PR description
@:workbench PR description

More on automatic tags from changed files.

readme  valid tags

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

I used the Connect logo here, so now the dialog to set up a Connection looks like this:

Image

async listPins(search?: string): Promise<PinInfo[]> {
// The colon in the filter value is left unencoded (Connect expects `content_type:pin`);
// only the optional search term, which is user input, is encoded.
let query = `filter=content_type:pin&count=${PIN_LIST_COUNT}`;

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

This is the query that the R and Python packages use.

Comment on lines +11 to +15
/**
* Builds a {@link Logger} backed by an output channel that is created on first use. Deferring
* creation keeps the "Posit Connect Pins" channel out of the Output panel until the driver
* actually logs something (i.e. the first connection attempt), so activating the extension by
* opening the Data Connections pane doesn't add an empty channel.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

I think we should make loggers like for all the driver extensions. Maybe in a followup PR this style of lazy logger should get moved up into the core Data Connections support, so they can be consistent?

Comment on lines +44 to +45
id: 'envvar',
label: vscode.l10n.t('Environment Variables'),

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

We need to fix the spacing here in this dialog:

Image

Comment on lines +13 to +15
// Opt-in remote smoke test: point the pins driver at a real Connect server (e.g. pub.demo.posit.team)
// via env vars, read-only. It is skipped unless both vars are set, so it never runs in normal CI
// (which has no remote credentials) and never touches a shared server uninvited. It does no seeding.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

This is just to confirm that the E2E fixtures work, against pub.demo.posit.team asserting julia.silge/what-numbers-are-these shows up.

@juliasilge juliasilge marked this pull request as ready for review July 15, 2026 02:44
@juliasilge juliasilge requested a review from softwarenerd July 15, 2026 02:44
@juliasilge juliasilge requested a review from testlabauto July 15, 2026 15:10
@juliasilge

Copy link
Copy Markdown
Member Author

@testlabauto I just added you as a reviewer for the new tests and such!

@testlabauto testlabauto left a comment

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.

Looks great!

Do you think we still also need test/e2e/tests/connect/pins-r.test.ts? I think maybe your new test replaces it.

@softwarenerd

Copy link
Copy Markdown
Contributor

🤖 Claude Code review — minor suggestion

Per-pin type badge caches transient failures for the connection's lifetime

In extensions/positron-data-driver-pins/src/pinsConnection.ts, getPinType memoizes a promise that resolves to undefined whenever getPinMeta throws:

typePromise = (async () => {
    try {
        const meta = await this._client.getPinMeta(pin.guid, pin.activeBundleId);
        return meta.type || undefined;
    } catch {
        // A pin whose metadata can't be read simply shows no type badge.
        return undefined;
    }
})();
this._typeCache.set(pin.guid, typePromise);

Because the resolved undefined is cached, a transient failure fetching one pin's data.txt (a timeout or network blip) permanently suppresses that pin's type badge for the whole lifetime of the connection. This is slightly at odds with the deliberate decision for enumeration in e749e44 ("Retry after fetch failures instead of caching rejections"), where listPins is intentionally left uncached so a failed browse retries.

This is low severity: the badge is cosmetic, and the effect is bounded (the connection is torn down and rebuilt when the top-level tree entry is collapsed and re-expanded). But it's worth making the per-pin lookup behave like the enumeration for consistency.

Suggested fix: cache only the (rejectable) fetch promise so a success is still fetched once and shared across concurrent expands, but await it in a try/catch that evicts on failure and returns undefined. A still-failing lookup just shows no badge rather than failing the whole owner expansion, and a re-expand retries.

/**
 * Resolves a pin's storage type for the badge. A successful lookup is memoized; a failed one is
 * dropped from the cache (matching the enumeration's retry-rather-than-cache-rejections behavior)
 * so a later re-expand retries it, and shows no badge in the meantime rather than failing the
 * whole owner expansion.
 */
async getPinType(pin: PinInfo): Promise<string | undefined> {
    let typePromise = this._typeCache.get(pin.guid);
    if (!typePromise) {
        typePromise = this._client.getPinMeta(pin.guid, pin.activeBundleId)
            .then(meta => meta.type || undefined);
        this._typeCache.set(pin.guid, typePromise);
    }
    try {
        return await typePromise;
    } catch {
        // The lookup failed: drop it so a later re-expand retries, and show no badge this time.
        this._typeCache.delete(pin.guid);
        return undefined;
    }
}

And a test paralleling the existing "failed enumeration does not stick":

test('a failed type lookup does not stick; re-expanding the owner re-fetches the badge', async () => {
    let carsMetaAttempts = 0;
    const failFirstMeta = (async (input: string | URL): Promise<Response> => {
        const url = typeof input === 'string' ? input : input.toString();
        if (url.includes('/content/g-cars/')) {
            carsMetaAttempts++;
            // Fail the first metadata read for this pin (as a blip would), then succeed.
            if (carsMetaAttempts === 1) {
                return new Response('boom', { status: 500 });
            }
            return new Response('file: cars.parquet\ntype: parquet\napi_version: 1\n', { status: 200 });
        }
        const route = routes.find(r => url.includes(r.match));
        return new Response(route ? route.body : '', { status: route ? 200 : 404 });
    }) as typeof fetch;

    const conn = new PinsConnection(new ConnectClient('https://c.example.com', 'key', failFirstMeta));

    // First expansion: the cars badge is missing because its metadata read failed.
    const [julia1] = await conn.getChildren();
    const cars1 = (await julia1.getChildren!()).find(p => p.name === 'cars')!;
    assert.strictEqual(cars1.dataType, undefined);

    // Re-expanding re-fetches (the failure wasn't cached), so the badge now resolves.
    const [julia2] = await conn.getChildren();
    const cars2 = (await julia2.getChildren!()).find(p => p.name === 'cars')!;
    assert.strictEqual(cars2.dataType, 'parquet');
});

Entirely optional given it's cosmetic, but it keeps the two fetch paths consistent.

@softwarenerd softwarenerd left a comment

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 built and tested this locally and it appears to work well for the limited test case I was able to work with. I did a code review of the changes with Claude Code, and it came up with a minor suggestion, which I've included as a comment.

@juliasilge

Copy link
Copy Markdown
Member Author

Good call, @testlabauto, I removed that original test scaffolding in ba6f8af. I kept the connect-pins-r workspace fixture since the new test still uses it to seed.

Thanks for the suggestion, @softwarenerd, I made a change in 5c66ef2 so getPinType now caches only the fetch promise and evicts on failure so a re-expand retries, matching the enumeration's retry-rather-than-cache-rejections behavior from e749e44. Added a regression test paralleling the existing "failed enumeration does not stick" one.

I also logged some followup that I noticed while working here:

@juliasilge juliasilge enabled auto-merge (squash) July 16, 2026 03:25
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants