Private/DFCatalog.Pypi.ps1

#Requires -Version 7.0

# PyPI catalog provider (query-cache kind). PyPI has NO supported search API —
# only exact-name lookups via the JSON API work, so multi-word keyword queries
# yield no PyPI results (documented limitation). Installed state comes from pipx.

function Invoke-DFCatalogPypiFetch {
    <#
    .SYNOPSIS
        Live PyPI JSON-API lookup for an exact package name, trying the -/_
        normalized twin as well.
    .PARAMETER Query
        Normalized query text.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory, Position = 0)]
        [string]$Query
    )

    if ($Query -match ' ') { return @() }

    $names = @($Query)
    $twin = $Query.Contains('-') ? ($Query -replace '-', '_') : ($Query -replace '_', '-')
    if ($twin -ne $Query) { $names += $twin }

    foreach ($name in $names) {
        try {
            $doc = Invoke-RestMethod -Uri "https://pypi.org/pypi/$([uri]::EscapeDataString($name))/json" -TimeoutSec 10
        } catch {
            Write-Verbose "DotForge: PyPI lookup for '$name' failed (probably not a package): $_"
            continue
        }
        $info = $doc.info
        $homepage = [string]($info.home_page ? $info.home_page : $info.project_urls.Homepage)

        return New-DFToolSourceInfo -Source 'pypi' `
            -PackageId $info.name -Name $info.name `
            -Description ([string]$info.summary) `
            -LatestVersion ([string]$info.version) `
            -Homepage $homepage `
            -License ([string]$info.license) `
            -MatchKind 'exact-name'
    }

    @()
}

function Search-DFCatalogPypi {
    <#
    .SYNOPSIS
        Cache-first PyPI lookup (exact names only — PyPI has no search API).
    .PARAMETER Query
        Package name.
    .PARAMETER Fresh
        Block on a live fetch.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory, Position = 0)]
        [string]$Query,

        [switch]$Fresh
    )

    Search-DFCatalogQueryCache -Provider 'pypi' -Query $Query -Fresh:$Fresh `
        -Fetch { param($q) Invoke-DFCatalogPypiFetch -Query $q }
}

function Invoke-DFCatalogPipxList {
    <#
    .SYNOPSIS
        Runs `pipx list --json` when pipx is available. Mockable seam.
    #>

    [CmdletBinding()]
    param()

    if (Get-Command pipx -ErrorAction Ignore) {
        pipx list --json 2>$null
    }
}

function Get-DFCatalogPypiInstalled {
    <#
    .SYNOPSIS
        Lists pipx-installed packages and versions.
    #>

    [CmdletBinding()]
    param()

    $json = Invoke-DFCatalogPipxList
    if (-not $json) { return }

    try { $data = ($json -join '') | ConvertFrom-Json } catch { return }
    if (-not $data.venvs) { return }

    foreach ($name in $data.venvs.PSObject.Properties.Name) {
        $main = $data.venvs.$name.metadata.main_package
        [pscustomobject]@{
            Source           = 'pypi'
            Name             = $name
            PackageId        = $name
            InstalledVersion = [string]$main.package_version
        }
    }
}

function Invoke-DFCatalogPypiDetailFetch {
    <#
    .SYNOPSIS
        Live PyPI JSON-API detail lookup, mapped to DotForge.ToolSourceDetail.
    .PARAMETER PackageId
        The PyPI package name.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory, Position = 0)]
        [string]$PackageId
    )

    $doc = Invoke-RestMethod -Uri "https://pypi.org/pypi/$([uri]::EscapeDataString($PackageId))/json" -TimeoutSec 10
    $info = $doc.info
    if (-not $info.name) { return $null }

    $urls = $info.project_urls
    $repo = [string]($urls.Repository ? $urls.Repository : ($urls.Source ? $urls.Source : ((([string]$urls.Homepage) -match 'github\.com') ? $urls.Homepage : '')))
    $docsUrl = [string]($urls.Documentation ? $urls.Documentation : $urls.Docs)

    $extra = [ordered]@{}
    if ($info.requires_python) { $extra['requires_python'] = [string]$info.requires_python }
    if ($info.description) {
        $extra['description'] = [string]$info.description
        $extra['description_content_type'] = [string]$info.description_content_type
    }

    New-DFToolSourceDetail -Source 'pypi' `
        -PackageId $info.name `
        -Publisher ([string]($info.author ? $info.author : $info.maintainer)) `
        -Tags @(([string]$info.keywords) -split '[,\s]+' -ne '') `
        -RepositoryUrl $repo `
        -DocsUrl $docsUrl `
        -InstallHint "pipx install $($info.name)" `
        -Extra $extra
}

function Get-DFCatalogPypiDetail {
    <#
    .SYNOPSIS
        Cache-first PyPI detail lookup.
    .PARAMETER PackageId
        The PyPI package name.
    .PARAMETER Fresh
        Force a live fetch.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory, Position = 0)]
        [string]$PackageId,

        [switch]$Fresh
    )

    Get-DFCatalogDetailCache -Provider 'pypi' -PackageId $PackageId -Fresh:$Fresh `
        -Fetch { param($id) Invoke-DFCatalogPypiDetailFetch -PackageId $id }
}

if (-not (Get-Variable -Name DFCatalogProviders -Scope Script -ErrorAction Ignore)) { $script:DFCatalogProviders = @{} }
$script:DFCatalogProviders['pypi'] = @{
    Name         = 'pypi'
    Kind         = 'query-cache'
    Test         = { $true }
    Search       = { param($Query, $Fresh) Search-DFCatalogPypi -Query $Query -Fresh:$Fresh }
    GetInstalled = { Get-DFCatalogPypiInstalled }
    Refresh      = { param($Query) if ($Query) { $null = Search-DFCatalogPypi -Query $Query -Fresh } }
    Detail       = { param($PackageId, $Fresh) Get-DFCatalogPypiDetail -PackageId $PackageId -Fresh:$Fresh }
}