LibreDevOpsHelpers.Uv/LibreDevOpsHelpers.Uv.psm1

Set-StrictMode -Version Latest

function Invoke-LdoUvCommand {
    # Internal. Asserts uv is present, runs it with the supplied arguments, and throws a
    # descriptive error on a non-zero exit. Centralises the shell-out so every public uv
    # function logs and error-checks identically.
    [CmdletBinding()]
    [OutputType([void])]
    param(
        [Parameter(Mandatory)]
        [string[]]$ArgumentList,

        [string]$Operation
    )

    Assert-LdoCommand -Name 'uv'

    if (-not $Operation) {
        $Operation = "uv $($ArgumentList -join ' ')"
    }

    Write-LdoLog -Level INFO -Message "Running: uv $($ArgumentList -join ' ')"
    & uv @ArgumentList
    Assert-LdoLastExitCode -Operation $Operation
}

function Install-LdoUv {
    <#
    .SYNOPSIS
        Installs the uv Python package manager if it is not already present.

    .DESCRIPTION
        Installs uv via Chocolatey on Windows or Homebrew on other platforms when the uv command
        is not found on PATH. Does nothing when uv is already installed.

    .EXAMPLE
        Install-LdoUv

    .OUTPUTS
        None
    #>

    [CmdletBinding()]
    [OutputType([void])]
    param()

    if (Get-Command uv -ErrorAction SilentlyContinue) {
        Write-LdoLog -Level INFO -Message 'uv already installed.'
        return
    }

    $os = Get-LdoOperatingSystem
    if ($os -eq 'Windows') {
        Assert-LdoChocoPath
        Write-LdoLog -Level INFO -Message 'Installing uv via Chocolatey.'
        choco install uv -y
        Assert-LdoLastExitCode -Operation 'choco install uv'
    }
    else {
        Assert-LdoHomebrewPath
        Write-LdoLog -Level INFO -Message 'Installing uv via Homebrew.'
        brew install uv
        Assert-LdoLastExitCode -Operation 'brew install uv'
    }

    Write-LdoLog -Level SUCCESS -Message 'uv installed.'
}

function Test-LdoUv {
    <#
    .SYNOPSIS
        Tests whether uv is available on PATH.

    .DESCRIPTION
        Returns $true when the uv command is found, otherwise $false.

    .EXAMPLE
        if (-not (Test-LdoUv)) { Install-LdoUv }

    .OUTPUTS
        System.Boolean
    #>

    [CmdletBinding()]
    [OutputType([bool])]
    param()

    $uvPath = Get-Command uv -ErrorAction SilentlyContinue
    if ($uvPath) {
        Write-LdoLog -Level INFO -Message "uv found at: $($uvPath.Source)"
        return $true
    }

    Write-LdoLog -Level WARN -Message 'uv is not installed or not in PATH.'
    return $false
}

function Install-LdoUvPython {
    <#
    .SYNOPSIS
        Installs a Python version with uv.

    .DESCRIPTION
        Runs 'uv python install' for the requested version (for example 3.12 or 3.12.4, or
        'cpython-3.12'). Throws on failure.

    .PARAMETER Version
        Python version or request string to install.

    .PARAMETER Reinstall
        When set, passes --reinstall to force a fresh install of an already-installed version.

    .EXAMPLE
        Install-LdoUvPython -Version 3.12

    .OUTPUTS
        None
    #>

    [CmdletBinding()]
    [OutputType([void])]
    param(
        [Parameter(Mandatory)]
        [ValidateNotNullOrEmpty()]
        [string]$Version,

        [switch]$Reinstall
    )

    $uvArgs = @('python', 'install', $Version)
    if ($Reinstall) {
        $uvArgs += '--reinstall'
    }

    Invoke-LdoUvCommand -ArgumentList $uvArgs -Operation "uv python install $Version"
    Write-LdoLog -Level SUCCESS -Message "Python $Version installed via uv."
}

function Get-LdoUvPython {
    <#
    .SYNOPSIS
        Lists Python versions known to uv.

    .DESCRIPTION
        Runs 'uv python list' and returns its output lines. By default both installed and
        downloadable versions are listed; use -OnlyInstalled to restrict to installed versions.

    .PARAMETER OnlyInstalled
        When set, passes --only-installed so only installed versions are returned.

    .EXAMPLE
        Get-LdoUvPython -OnlyInstalled

    .OUTPUTS
        System.String[]
    #>

    [CmdletBinding()]
    [OutputType([string[]])]
    param(
        [switch]$OnlyInstalled
    )

    Assert-LdoCommand -Name 'uv'

    $uvArgs = @('python', 'list')
    if ($OnlyInstalled) {
        $uvArgs += '--only-installed'
    }

    Write-LdoLog -Level INFO -Message "Running: uv $($uvArgs -join ' ')"
    $output = & uv @uvArgs
    Assert-LdoLastExitCode -Operation 'uv python list'

    return $output
}

function Set-LdoUvPythonPin {
    <#
    .SYNOPSIS
        Pins the Python version for the current project or directory.

    .DESCRIPTION
        Runs 'uv python pin' to write the requested version to a .python-version file so uv and
        other tools select it. Throws on failure.

    .PARAMETER Version
        Python version to pin (for example 3.12).

    .EXAMPLE
        Set-LdoUvPythonPin -Version 3.12

    .OUTPUTS
        None
    #>

    [CmdletBinding()]
    [OutputType([void])]
    param(
        [Parameter(Mandatory)]
        [ValidateNotNullOrEmpty()]
        [string]$Version
    )

    Invoke-LdoUvCommand -ArgumentList @('python', 'pin', $Version) -Operation "uv python pin $Version"
    Write-LdoLog -Level SUCCESS -Message "Pinned Python $Version."
}

function New-LdoUvVenv {
    <#
    .SYNOPSIS
        Creates a virtual environment with uv.

    .DESCRIPTION
        Runs 'uv venv' to create a virtual environment, optionally targeting a specific Python
        version. Throws on failure.

    .PARAMETER Path
        Path of the environment to create. Defaults to .venv.

    .PARAMETER Version
        Python version to use for the environment (passed as --python). Optional.

    .PARAMETER Seed
        When set, passes --seed so pip (and related) are installed into the environment.

    .PARAMETER Clear
        When set, passes --clear to remove any existing environment at the path first.

    .EXAMPLE
        New-LdoUvVenv -Version 3.12

    .OUTPUTS
        None
    #>

    [CmdletBinding()]
    [OutputType([void])]
    param(
        [string]$Path = '.venv',
        [string]$Version,
        [switch]$Seed,
        [switch]$Clear
    )

    $uvArgs = @('venv', $Path)
    if ($Version) {
        $uvArgs += @('--python', $Version)
    }
    if ($Seed) {
        $uvArgs += '--seed'
    }
    if ($Clear) {
        $uvArgs += '--clear'
    }

    Invoke-LdoUvCommand -ArgumentList $uvArgs -Operation "uv venv $Path"
    Write-LdoLog -Level SUCCESS -Message "Virtual environment created at '$Path'."
}

function Invoke-LdoUvSync {
    <#
    .SYNOPSIS
        Installs project dependencies from pyproject.toml / uv.lock with uv.

    .DESCRIPTION
        Runs 'uv sync' to resolve and install the project's dependencies into its environment.
        The original working directory is always restored when -ProjectPath is used. Throws on
        failure.

    .PARAMETER ProjectPath
        Project folder to sync. Defaults to the current directory.

    .PARAMETER Frozen
        When set, passes --frozen so the existing uv.lock is used without updating it.

    .PARAMETER NoDev
        When set, passes --no-dev to exclude development dependencies.

    .PARAMETER AllExtras
        When set, passes --all-extras to include all optional dependency groups.

    .PARAMETER AdditionalArgs
        Additional arguments passed through to uv sync.

    .EXAMPLE
        Invoke-LdoUvSync -Frozen

    .OUTPUTS
        None
    #>

    [CmdletBinding()]
    [OutputType([void])]
    param(
        [ValidateScript({ Test-Path $_ -PathType Container })]
        [string]$ProjectPath = (Get-Location).Path,
        [switch]$Frozen,
        [switch]$NoDev,
        [switch]$AllExtras,
        [string[]]$AdditionalArgs = @()
    )

    $uvArgs = @('sync')
    if ($Frozen) {
        $uvArgs += '--frozen'
    }
    if ($NoDev) {
        $uvArgs += '--no-dev'
    }
    if ($AllExtras) {
        $uvArgs += '--all-extras'
    }
    if ($AdditionalArgs) {
        $uvArgs += $AdditionalArgs
    }

    $orig = Get-Location
    try {
        Set-Location $ProjectPath
        Invoke-LdoUvCommand -ArgumentList $uvArgs -Operation 'uv sync'
        Write-LdoLog -Level SUCCESS -Message 'Dependencies synced.'
    }
    finally {
        Set-Location $orig
    }
}

function Invoke-LdoUvLock {
    <#
    .SYNOPSIS
        Resolves and writes the uv.lock lockfile.

    .DESCRIPTION
        Runs 'uv lock' to update the project's lockfile. With -Check the command instead verifies
        the lockfile is up to date without modifying it (useful in CI). Throws on failure.

    .PARAMETER Upgrade
        When set, passes --upgrade to allow all dependencies to be upgraded.

    .PARAMETER Check
        When set, passes --check to verify the lockfile is current without writing changes.

    .PARAMETER AdditionalArgs
        Additional arguments passed through to uv lock.

    .EXAMPLE
        Invoke-LdoUvLock -Upgrade

    .OUTPUTS
        None
    #>

    [CmdletBinding()]
    [OutputType([void])]
    param(
        [switch]$Upgrade,
        [switch]$Check,
        [string[]]$AdditionalArgs = @()
    )

    $uvArgs = @('lock')
    if ($Upgrade) {
        $uvArgs += '--upgrade'
    }
    if ($Check) {
        $uvArgs += '--check'
    }
    if ($AdditionalArgs) {
        $uvArgs += $AdditionalArgs
    }

    Invoke-LdoUvCommand -ArgumentList $uvArgs -Operation 'uv lock'
    Write-LdoLog -Level SUCCESS -Message 'Lockfile resolved.'
}

function Add-LdoUvPackage {
    <#
    .SYNOPSIS
        Adds one or more dependencies to the project with uv.

    .DESCRIPTION
        Runs 'uv add' to add the named packages to pyproject.toml and install them. Throws on
        failure.

    .PARAMETER Package
        One or more package requirements to add (for example requests or 'httpx>=0.27').

    .PARAMETER Dev
        When set, passes --dev to add the packages as development dependencies.

    .PARAMETER Optional
        Name of an optional dependency group to add the packages to (passed as --optional).

    .PARAMETER AdditionalArgs
        Additional arguments passed through to uv add.

    .EXAMPLE
        Add-LdoUvPackage -Package requests, 'httpx>=0.27'

    .OUTPUTS
        None
    #>

    [CmdletBinding()]
    [OutputType([void])]
    param(
        [Parameter(Mandatory)]
        [ValidateNotNullOrEmpty()]
        [string[]]$Package,
        [switch]$Dev,
        [string]$Optional,
        [string[]]$AdditionalArgs = @()
    )

    $uvArgs = @('add')
    if ($Dev) {
        $uvArgs += '--dev'
    }
    if ($Optional) {
        $uvArgs += @('--optional', $Optional)
    }
    $uvArgs += $Package
    if ($AdditionalArgs) {
        $uvArgs += $AdditionalArgs
    }

    Invoke-LdoUvCommand -ArgumentList $uvArgs -Operation "uv add $($Package -join ' ')"
    Write-LdoLog -Level SUCCESS -Message "Added: $($Package -join ', ')."
}

function Remove-LdoUvPackage {
    <#
    .SYNOPSIS
        Removes one or more dependencies from the project with uv.

    .DESCRIPTION
        Runs 'uv remove' to remove the named packages from pyproject.toml and the environment.
        Throws on failure.

    .PARAMETER Package
        One or more package names to remove.

    .PARAMETER Dev
        When set, passes --dev to remove the packages from development dependencies.

    .PARAMETER AdditionalArgs
        Additional arguments passed through to uv remove.

    .EXAMPLE
        Remove-LdoUvPackage -Package requests

    .OUTPUTS
        None
    #>

    [CmdletBinding()]
    [OutputType([void])]
    param(
        [Parameter(Mandatory)]
        [ValidateNotNullOrEmpty()]
        [string[]]$Package,
        [switch]$Dev,
        [string[]]$AdditionalArgs = @()
    )

    $uvArgs = @('remove')
    if ($Dev) {
        $uvArgs += '--dev'
    }
    $uvArgs += $Package
    if ($AdditionalArgs) {
        $uvArgs += $AdditionalArgs
    }

    Invoke-LdoUvCommand -ArgumentList $uvArgs -Operation "uv remove $($Package -join ' ')"
    Write-LdoLog -Level SUCCESS -Message "Removed: $($Package -join ', ')."
}

function Invoke-LdoUvRun {
    <#
    .SYNOPSIS
        Runs a command in the project's environment with uv.

    .DESCRIPTION
        Runs 'uv run' followed by the supplied command and arguments, ensuring the project's
        environment is up to date first. The original working directory is always restored when
        -ProjectPath is used. Throws when the command exits non-zero.

    .PARAMETER Command
        The command and any arguments to run (for example pytest -q). Accepts the remaining
        arguments to the function.

    .PARAMETER ProjectPath
        Project folder to run in. Defaults to the current directory.

    .EXAMPLE
        Invoke-LdoUvRun pytest -q

    .EXAMPLE
        Invoke-LdoUvRun -ProjectPath ./app -- python -m my_module

    .OUTPUTS
        None
    #>

    [CmdletBinding()]
    [OutputType([void])]
    param(
        [string]$ProjectPath = (Get-Location).Path,

        [Parameter(Mandatory, ValueFromRemainingArguments)]
        [string[]]$Command
    )

    if (-not (Test-Path $ProjectPath -PathType Container)) {
        throw "Project path not found: $ProjectPath"
    }

    $uvArgs = @('run') + $Command

    $orig = Get-Location
    try {
        Set-Location $ProjectPath
        Invoke-LdoUvCommand -ArgumentList $uvArgs -Operation "uv run $($Command -join ' ')"
        Write-LdoLog -Level SUCCESS -Message 'uv run completed.'
    }
    finally {
        Set-Location $orig
    }
}

function Invoke-LdoUvPipInstall {
    <#
    .SYNOPSIS
        Installs packages using uv's pip interface.

    .DESCRIPTION
        Runs 'uv pip install' for the named packages and/or a requirements file. Throws on
        failure.

    .PARAMETER Package
        One or more package requirements to install. Optional when -RequirementsFile is supplied.

    .PARAMETER RequirementsFile
        Path to a requirements file to install from (passed as -r). Optional.

    .PARAMETER Upgrade
        When set, passes --upgrade to upgrade already-installed packages.

    .PARAMETER AdditionalArgs
        Additional arguments passed through to uv pip install.

    .EXAMPLE
        Invoke-LdoUvPipInstall -Package requests

    .EXAMPLE
        Invoke-LdoUvPipInstall -RequirementsFile requirements.txt

    .OUTPUTS
        None
    #>

    [Diagnostics.CodeAnalysis.SuppressMessage('PSUseSingularNouns', '', Justification = 'Installs multiple packages.')]
    [CmdletBinding()]
    [OutputType([void])]
    param(
        [string[]]$Package = @(),
        [string]$RequirementsFile,
        [switch]$Upgrade,
        [string[]]$AdditionalArgs = @()
    )

    if (-not $Package -and -not $RequirementsFile) {
        throw 'Specify -Package, -RequirementsFile, or both.'
    }
    if ($RequirementsFile -and -not (Test-Path $RequirementsFile)) {
        throw "Requirements file not found: $RequirementsFile"
    }

    $uvArgs = @('pip', 'install')
    if ($Upgrade) {
        $uvArgs += '--upgrade'
    }
    if ($RequirementsFile) {
        $uvArgs += @('-r', $RequirementsFile)
    }
    if ($Package) {
        $uvArgs += $Package
    }
    if ($AdditionalArgs) {
        $uvArgs += $AdditionalArgs
    }

    Invoke-LdoUvCommand -ArgumentList $uvArgs -Operation 'uv pip install'
    Write-LdoLog -Level SUCCESS -Message 'Packages installed.'
}

function Invoke-LdoUvPipUninstall {
    <#
    .SYNOPSIS
        Uninstalls packages using uv's pip interface.

    .DESCRIPTION
        Runs 'uv pip uninstall' for the named packages. Throws on failure.

    .PARAMETER Package
        One or more package names to uninstall.

    .PARAMETER AdditionalArgs
        Additional arguments passed through to uv pip uninstall.

    .EXAMPLE
        Invoke-LdoUvPipUninstall -Package requests

    .OUTPUTS
        None
    #>

    [Diagnostics.CodeAnalysis.SuppressMessage('PSUseSingularNouns', '', Justification = 'Uninstalls multiple packages.')]
    [CmdletBinding()]
    [OutputType([void])]
    param(
        [Parameter(Mandatory)]
        [ValidateNotNullOrEmpty()]
        [string[]]$Package,
        [string[]]$AdditionalArgs = @()
    )

    $uvArgs = @('pip', 'uninstall') + $Package
    if ($AdditionalArgs) {
        $uvArgs += $AdditionalArgs
    }

    Invoke-LdoUvCommand -ArgumentList $uvArgs -Operation "uv pip uninstall $($Package -join ' ')"
    Write-LdoLog -Level SUCCESS -Message "Uninstalled: $($Package -join ', ')."
}

Export-ModuleMember -Function `
    Install-LdoUv, `
    Test-LdoUv, `
    Install-LdoUvPython, `
    Get-LdoUvPython, `
    Set-LdoUvPythonPin, `
    New-LdoUvVenv, `
    Invoke-LdoUvSync, `
    Invoke-LdoUvLock, `
    Add-LdoUvPackage, `
    Remove-LdoUvPackage, `
    Invoke-LdoUvRun, `
    Invoke-LdoUvPipInstall, `
    Invoke-LdoUvPipUninstall