Added helper functions alongside their unit tests
This commit is contained in:
3
main.py
3
main.py
@@ -8,8 +8,7 @@ Usage:
|
|||||||
python main.py audio test [--device ID] [--duration N] [--save]
|
python main.py audio test [--device ID] [--duration N] [--save]
|
||||||
python main.py audio config
|
python main.py audio config
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from src.cli import app
|
from src.cli import app
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
app()
|
app()
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ server_app.command("check")(server.check)
|
|||||||
audio_app.command("list")(audio.list_devices)
|
audio_app.command("list")(audio.list_devices)
|
||||||
audio_app.command("test")(audio.test_device)
|
audio_app.command("test")(audio.test_device)
|
||||||
audio_app.command("config")(audio.config_info)
|
audio_app.command("config")(audio.config_info)
|
||||||
|
audio_app.command("install")(audio.install)
|
||||||
|
|
||||||
# Register subcommands
|
# Register subcommands
|
||||||
app.add_typer(server_app, name="server")
|
app.add_typer(server_app, name="server")
|
||||||
|
|||||||
@@ -1,79 +1,14 @@
|
|||||||
import sounddevice as sd
|
import sounddevice as sd
|
||||||
import subprocess
|
|
||||||
import typer
|
|
||||||
import numpy as np
|
import numpy as np
|
||||||
import os
|
import os
|
||||||
import json
|
from typing import Optional, List
|
||||||
from typing import Optional, List, Dict, Any, Tuple
|
|
||||||
from dataclasses import dataclass
|
|
||||||
|
|
||||||
from rich.console import Console
|
from ..devices import get_audio_devices_windows, install_audio_cmdlets
|
||||||
from rich.table import Table
|
from ..core.utils import *
|
||||||
|
|
||||||
from ..utils import SortConf, sort_by, FilterConf, filter_by
|
|
||||||
from ..config import AppConfig
|
from ..config import AppConfig
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
def _detect_wsl() -> bool:
|
||||||
class WindowsAudioDevice:
|
|
||||||
"""Windows audio device data structure."""
|
|
||||||
name: str
|
|
||||||
status: str
|
|
||||||
device_type: str # "Input", "Output", "Unknown"
|
|
||||||
instance_id: str
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class LinuxAudioDevice:
|
|
||||||
"""Linux audio device data structure."""
|
|
||||||
device_id: int
|
|
||||||
name: str
|
|
||||||
max_input_channels: int
|
|
||||||
max_output_channels: int
|
|
||||||
default_samplerate: float
|
|
||||||
hostapi_name: str
|
|
||||||
|
|
||||||
|
|
||||||
def list_devices(
|
|
||||||
device: Optional[int] = typer.Argument(default=None, help="Show details of a device, by its Id"),
|
|
||||||
pulse: bool = typer.Option(False, "--pulse", help="Show PulseAudio sources"),
|
|
||||||
native: bool = typer.Option(False, "--native", help="Force native Linux view (WSL only)"),
|
|
||||||
sort_by: Optional[str] = typer.Option(None, "--sort", help="Sort by column"),
|
|
||||||
desc: bool = typer.Option(False, "--desc", help="Set the sorting order to descending"),
|
|
||||||
filter_by: Optional[str] = typer.Option(None, "--filter", help="Filter by a column value. Use column=value"),
|
|
||||||
config_file: Optional[str] = typer.Option(None, "--config", "-c", help="Path to config file")
|
|
||||||
):
|
|
||||||
"""List audio devices."""
|
|
||||||
|
|
||||||
# Load configuration for context
|
|
||||||
config = AppConfig.load(config_file)
|
|
||||||
|
|
||||||
# Determine title based on environment and options
|
|
||||||
is_wsl = detect_wsl()
|
|
||||||
use_windows = is_wsl and not native
|
|
||||||
sort_config = SortConf(sort_by, not desc) if sort_by else None
|
|
||||||
filter_config = FilterConf.new(filter_by) if filter_by else None
|
|
||||||
if use_windows:
|
|
||||||
typer.echo(typer.style("WSL detected, showing Windows audio devices (use --native for WSL view)",
|
|
||||||
fg=typer.colors.BLUE))
|
|
||||||
windows_devices = get_windows_audio_devices()
|
|
||||||
display_windows_devices(windows_devices, sort_config, filter_config)
|
|
||||||
|
|
||||||
else:
|
|
||||||
linux_devices = get_linux_audio_devices()
|
|
||||||
display_linux_devices(linux_devices, config, pulse, sort_config)
|
|
||||||
|
|
||||||
# Show legend
|
|
||||||
typer.echo(f"\nLegend:")
|
|
||||||
if use_windows:
|
|
||||||
typer.echo(typer.style(" ✓ = Device working", fg=typer.colors.GREEN))
|
|
||||||
typer.echo(typer.style(" ✗ = Device problem", fg=typer.colors.RED))
|
|
||||||
else:
|
|
||||||
typer.echo(typer.style(" * = Configured device", fg=typer.colors.YELLOW))
|
|
||||||
typer.echo(f" IN/OUT = Input/Output channel count")
|
|
||||||
|
|
||||||
|
|
||||||
def detect_wsl() -> bool:
|
|
||||||
"""Detect if running under Windows Subsystem for Linux."""
|
"""Detect if running under Windows Subsystem for Linux."""
|
||||||
try:
|
try:
|
||||||
# Method 1: Check /proc/version for Microsoft
|
# Method 1: Check /proc/version for Microsoft
|
||||||
@@ -99,365 +34,7 @@ def detect_wsl() -> bool:
|
|||||||
except Exception:
|
except Exception:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
def _get_device_type(instance_id: str) -> str:
|
||||||
def get_windows_audio_devices_com() -> List[WindowsAudioDevice]:
|
|
||||||
"""
|
|
||||||
Retrieve Windows audio devices using Core Audio (COM) via an inline C# helper compiled by PowerShell.
|
|
||||||
Returns a list of WindowsAudioDevice with accurate device_type ('Input' or 'Output') based on DataFlow.
|
|
||||||
"""
|
|
||||||
ps_script = r"""
|
|
||||||
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8;
|
|
||||||
|
|
||||||
$code = @"
|
|
||||||
using System;
|
|
||||||
using System.Runtime.InteropServices;
|
|
||||||
using System.Collections.Generic;
|
|
||||||
|
|
||||||
[ComImport]
|
|
||||||
[Guid("BCDE0395-E52F-467C-8E3D-C4579291692E")]
|
|
||||||
class MMDeviceEnumerator {}
|
|
||||||
|
|
||||||
[Guid("A95664D2-9614-4F35-A746-DE8DB63617E6"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
|
|
||||||
interface IMMDeviceEnumerator {
|
|
||||||
int EnumAudioEndpoints(EDataFlow dataFlow, int dwStateMask, out IMMDeviceCollection ppDevices);
|
|
||||||
// Other methods not used are omitted
|
|
||||||
}
|
|
||||||
|
|
||||||
enum EDataFlow { eRender = 0, eCapture = 1, eAll = 2 }
|
|
||||||
|
|
||||||
[Flags]
|
|
||||||
enum DEVICE_STATE : uint { ACTIVE = 0x1, DISABLED = 0x2, NOTPRESENT = 0x4, UNPLUGGED = 0x8, ALL = 0xF }
|
|
||||||
|
|
||||||
[Guid("0BD7A1BE-7A1A-44DB-8397-C0A794AF5630"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
|
|
||||||
interface IMMDeviceCollection {
|
|
||||||
int GetCount(out int pcDevices);
|
|
||||||
int Item(int nDevice, out IMMDevice ppDevice);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Guid("D666063F-1587-4E43-81F1-B948E807363F"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
|
|
||||||
interface IMMDevice {
|
|
||||||
int Activate(ref Guid iid, int dwClsCtx, IntPtr pActivationParams, out IntPtr ppInterface);
|
|
||||||
int OpenPropertyStore(int stgmAccess, out IPropertyStore ppProperties);
|
|
||||||
int GetId(out string ppstrId);
|
|
||||||
int GetState(out int pdwState);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Guid("886d8eeb-8cf2-4446-8d02-cdba1dbdcf99"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
|
|
||||||
interface IPropertyStore {
|
|
||||||
int GetCount(out int cProps);
|
|
||||||
int GetAt(int iProp, out PROPERTYKEY pkey);
|
|
||||||
int GetValue(ref PROPERTYKEY key, out PROPVARIANT pv);
|
|
||||||
int SetValue(ref PROPERTYKEY key, ref PROPVARIANT propvar);
|
|
||||||
int Commit();
|
|
||||||
}
|
|
||||||
|
|
||||||
[StructLayout(LayoutKind.Sequential)]
|
|
||||||
struct PROPERTYKEY {
|
|
||||||
public Guid fmtid;
|
|
||||||
public int pid;
|
|
||||||
}
|
|
||||||
|
|
||||||
[StructLayout(LayoutKind.Explicit)]
|
|
||||||
struct PROPVARIANT {
|
|
||||||
[FieldOffset(0)] public ushort vt;
|
|
||||||
[FieldOffset(8)] public IntPtr pointerValue;
|
|
||||||
}
|
|
||||||
|
|
||||||
static class PKEY {
|
|
||||||
// PKEY_Device_FriendlyName
|
|
||||||
public static PROPERTYKEY Device_FriendlyName = new PROPERTYKEY {
|
|
||||||
fmtid = new Guid("A45C254E-DF1C-4EFD-8020-67D146A850E0"), pid = 14
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
public class DeviceInfo {
|
|
||||||
public string Name { get; set; }
|
|
||||||
public string Id { get; set; }
|
|
||||||
public string Type { get; set; } // "Input" or "Output"
|
|
||||||
public string Status { get; set; } // "OK", "Disabled", "Unplugged", "NotPresent", "Unknown"
|
|
||||||
}
|
|
||||||
|
|
||||||
public static class CoreAudioEnumerator {
|
|
||||||
[DllImport("ole32.dll")]
|
|
||||||
private static extern int PropVariantClear(ref PROPVARIANT pvar);
|
|
||||||
|
|
||||||
private static string ReadString(IPropertyStore store, PROPERTYKEY key) {
|
|
||||||
PROPVARIANT pv = new PROPVARIANT();
|
|
||||||
try {
|
|
||||||
store.GetValue(ref key, out pv);
|
|
||||||
// VT_LPWSTR = 31
|
|
||||||
if (pv.vt == 31) {
|
|
||||||
return Marshal.PtrToStringUni(pv.pointerValue);
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
} finally {
|
|
||||||
PropVariantClear(ref pv);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public static List<DeviceInfo> GetAll() {
|
|
||||||
var result = new List<DeviceInfo>();
|
|
||||||
var enumerator = (IMMDeviceEnumerator)new MMDeviceEnumerator();
|
|
||||||
|
|
||||||
foreach (EDataFlow flow in new[] { EDataFlow.eCapture, EDataFlow.eRender }) {
|
|
||||||
IMMDeviceCollection coll;
|
|
||||||
enumerator.EnumAudioEndpoints(flow, (int)DEVICE_STATE.ALL, out coll);
|
|
||||||
|
|
||||||
int count;
|
|
||||||
coll.GetCount(out count);
|
|
||||||
|
|
||||||
for (int i = 0; i < count; i++) {
|
|
||||||
IMMDevice dev;
|
|
||||||
coll.Item(i, out dev);
|
|
||||||
|
|
||||||
string id;
|
|
||||||
dev.GetId(out id);
|
|
||||||
|
|
||||||
int state;
|
|
||||||
dev.GetState(out state);
|
|
||||||
|
|
||||||
IPropertyStore store;
|
|
||||||
dev.OpenPropertyStore(0 /* STGM_READ */, out store);
|
|
||||||
string name = ReadString(store, PKEY.Device_FriendlyName) ?? "(unknown)";
|
|
||||||
|
|
||||||
string type = flow == EDataFlow.eCapture ? "Input" : "Output";
|
|
||||||
|
|
||||||
string status =
|
|
||||||
(state & (int)DEVICE_STATE.ACTIVE) != 0 ? "OK" :
|
|
||||||
(state & (int)DEVICE_STATE.DISABLED) != 0 ? "Disabled" :
|
|
||||||
(state & (int)DEVICE_STATE.UNPLUGGED) != 0 ? "Unplugged" :
|
|
||||||
(state & (int)DEVICE_STATE.NOTPRESENT) != 0 ? "NotPresent" :
|
|
||||||
"Unknown";
|
|
||||||
|
|
||||||
result.Add(new DeviceInfo { Name = name, Id = id, Type = type, Status = status });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
"@
|
|
||||||
|
|
||||||
Add-Type -TypeDefinition $code -Language CSharp -ErrorAction Stop
|
|
||||||
|
|
||||||
$devices = [CoreAudioEnumerator]::GetAll() | ForEach-Object {
|
|
||||||
[PSCustomObject]@{
|
|
||||||
FriendlyName = $_.Name
|
|
||||||
InstanceId = $_.Id
|
|
||||||
Type = $_.Type
|
|
||||||
Status = $_.Status
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$devices | ConvertTo-Json
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
result = subprocess.run(
|
|
||||||
[
|
|
||||||
"powershell.exe",
|
|
||||||
"-NoProfile",
|
|
||||||
"-NonInteractive",
|
|
||||||
"-Command",
|
|
||||||
ps_script,
|
|
||||||
],
|
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
encoding="utf-8",
|
|
||||||
timeout=30,
|
|
||||||
)
|
|
||||||
|
|
||||||
if result.returncode != 0:
|
|
||||||
err = result.stderr.strip() if result.stderr else f"exit code {result.returncode}"
|
|
||||||
typer.echo(typer.style(f"PowerShell (CoreAudio) command failed: {err}", fg=typer.colors.RED))
|
|
||||||
return []
|
|
||||||
|
|
||||||
output = result.stdout.strip()
|
|
||||||
if not output:
|
|
||||||
typer.echo(typer.style("PowerShell (CoreAudio) returned empty output", fg=typer.colors.YELLOW))
|
|
||||||
return []
|
|
||||||
|
|
||||||
data = json.loads(output)
|
|
||||||
items = data if isinstance(data, list) else [data]
|
|
||||||
|
|
||||||
devices: List[WindowsAudioDevice] = []
|
|
||||||
for it in items:
|
|
||||||
name = (it.get("FriendlyName") or "").strip()
|
|
||||||
instance_id = it.get("InstanceId") or ""
|
|
||||||
dev_type = it.get("Type") or "Unknown"
|
|
||||||
status = it.get("Status") or "Unknown"
|
|
||||||
|
|
||||||
if not name:
|
|
||||||
continue
|
|
||||||
|
|
||||||
devices.append(
|
|
||||||
WindowsAudioDevice(
|
|
||||||
name=name,
|
|
||||||
status=status,
|
|
||||||
device_type=dev_type,
|
|
||||||
instance_id=instance_id,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
return devices
|
|
||||||
|
|
||||||
except json.JSONDecodeError as e:
|
|
||||||
typer.echo(typer.style(f"Failed to parse CoreAudio JSON output: {e}", fg=typer.colors.RED))
|
|
||||||
return []
|
|
||||||
except subprocess.TimeoutExpired:
|
|
||||||
typer.echo(typer.style("PowerShell (CoreAudio) command timed out after 30 seconds", fg=typer.colors.RED))
|
|
||||||
return []
|
|
||||||
except Exception as e:
|
|
||||||
typer.echo(typer.style(f"Unexpected error calling CoreAudio via PowerShell: {type(e).__name__}: {e}",
|
|
||||||
fg=typer.colors.RED))
|
|
||||||
return []
|
|
||||||
|
|
||||||
|
|
||||||
def get_windows_default_devices() -> Dict[str, str]:
|
|
||||||
"""Get Windows default audio devices via PowerShell."""
|
|
||||||
ps_script = r"""
|
|
||||||
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8;
|
|
||||||
|
|
||||||
$code = @"
|
|
||||||
using System;
|
|
||||||
using System.Runtime.InteropServices;
|
|
||||||
|
|
||||||
[ComImport]
|
|
||||||
[Guid("BCDE0395-E52F-467C-8E3D-C4579291692E")]
|
|
||||||
class MMDeviceEnumerator {}
|
|
||||||
|
|
||||||
[Guid("A95664D2-9614-4F35-A746-DE8DB63617E6"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
|
|
||||||
interface IMMDeviceEnumerator {
|
|
||||||
int GetDefaultAudioEndpoint(EDataFlow dataFlow, ERole role, out IMMDevice ppEndpoint);
|
|
||||||
}
|
|
||||||
|
|
||||||
enum EDataFlow { eRender = 0, eCapture = 1 }
|
|
||||||
enum ERole { eConsole = 0, eMultimedia = 1, eCommunications = 2 }
|
|
||||||
|
|
||||||
[Guid("D666063F-1587-4E43-81F1-B948E807363F"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
|
|
||||||
interface IMMDevice {
|
|
||||||
int GetId(out string ppstrId);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static class DefaultDeviceHelper {
|
|
||||||
public static string GetDefaultInputDevice() {
|
|
||||||
try {
|
|
||||||
var enumerator = (IMMDeviceEnumerator)new MMDeviceEnumerator();
|
|
||||||
IMMDevice device;
|
|
||||||
enumerator.GetDefaultAudioEndpoint(EDataFlow.eCapture, ERole.eConsole, out device);
|
|
||||||
string id;
|
|
||||||
device.GetId(out id);
|
|
||||||
return id;
|
|
||||||
} catch { return null; }
|
|
||||||
}
|
|
||||||
|
|
||||||
public static string GetDefaultOutputDevice() {
|
|
||||||
try {
|
|
||||||
var enumerator = (IMMDeviceEnumerator)new MMDeviceEnumerator();
|
|
||||||
IMMDevice device;
|
|
||||||
enumerator.GetDefaultAudioEndpoint(EDataFlow.eRender, ERole.eConsole, out device);
|
|
||||||
string id;
|
|
||||||
device.GetId(out id);
|
|
||||||
return id;
|
|
||||||
} catch { return null; }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
"@
|
|
||||||
|
|
||||||
Add-Type -TypeDefinition $code -Language CSharp -ErrorAction Stop
|
|
||||||
|
|
||||||
$defaultInput = [DefaultDeviceHelper]::GetDefaultInputDevice()
|
|
||||||
$defaultOutput = [DefaultDeviceHelper]::GetDefaultOutputDevice()
|
|
||||||
|
|
||||||
[PSCustomObject]@{
|
|
||||||
DefaultInput = $defaultInput
|
|
||||||
DefaultOutput = $defaultOutput
|
|
||||||
} | ConvertTo-Json
|
|
||||||
"""
|
|
||||||
|
|
||||||
try:
|
|
||||||
result = subprocess.run([
|
|
||||||
"powershell.exe",
|
|
||||||
"-NoProfile",
|
|
||||||
"-NonInteractive",
|
|
||||||
"-Command",
|
|
||||||
ps_script,
|
|
||||||
], capture_output=True, text=True, encoding="utf-8", timeout=15)
|
|
||||||
|
|
||||||
if result.returncode != 0:
|
|
||||||
return {"default_input": None, "default_output": None}
|
|
||||||
|
|
||||||
if not result.stdout.strip():
|
|
||||||
return {"default_input": None, "default_output": None}
|
|
||||||
|
|
||||||
data = json.loads(result.stdout)
|
|
||||||
return {
|
|
||||||
"default_input": data.get("DefaultInput"),
|
|
||||||
"default_output": data.get("DefaultOutput")
|
|
||||||
}
|
|
||||||
|
|
||||||
except Exception:
|
|
||||||
return {"default_input": None, "default_output": None}
|
|
||||||
|
|
||||||
|
|
||||||
def get_windows_audio_devices() -> List[WindowsAudioDevice]:
|
|
||||||
"""Get detailed Windows audio devices via PowerShell from WSL."""
|
|
||||||
try:
|
|
||||||
result = subprocess.run([
|
|
||||||
"powershell.exe",
|
|
||||||
"-NoProfile",
|
|
||||||
"-NonInteractive",
|
|
||||||
"-Command",
|
|
||||||
# Force UTF-8 output from PowerShell before running the command
|
|
||||||
"[Console]::OutputEncoding = [System.Text.Encoding]::UTF8; "
|
|
||||||
"Get-PnpDevice -Class AudioEndpoint | "
|
|
||||||
"Select-Object FriendlyName, Status, InstanceId | ConvertTo-Json"
|
|
||||||
], capture_output=True, text=True, encoding="utf-8", timeout=15)
|
|
||||||
|
|
||||||
if result.returncode != 0:
|
|
||||||
error_msg = result.stderr.strip() if result.stderr else f"PowerShell exit code: {result.returncode}"
|
|
||||||
typer.echo(typer.style(f"PowerShell command failed: {error_msg}", fg=typer.colors.RED))
|
|
||||||
return []
|
|
||||||
|
|
||||||
if not result.stdout.strip():
|
|
||||||
typer.echo(typer.style("PowerShell returned empty output", fg=typer.colors.YELLOW))
|
|
||||||
return []
|
|
||||||
|
|
||||||
data = json.loads(result.stdout)
|
|
||||||
devices = data if isinstance(data, list) else [data]
|
|
||||||
|
|
||||||
windows_devices = []
|
|
||||||
for device in devices:
|
|
||||||
name = device.get('FriendlyName', '').strip()
|
|
||||||
status = device.get('Status', 'Unknown')
|
|
||||||
instance_id = device.get('InstanceId', '')
|
|
||||||
|
|
||||||
if not name:
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Determine device type from InstanceId
|
|
||||||
device_type = _categorize_device_type(instance_id)
|
|
||||||
|
|
||||||
windows_devices.append(WindowsAudioDevice(
|
|
||||||
name=name,
|
|
||||||
status=status,
|
|
||||||
device_type=device_type,
|
|
||||||
instance_id=instance_id
|
|
||||||
))
|
|
||||||
|
|
||||||
return windows_devices
|
|
||||||
|
|
||||||
except json.JSONDecodeError as e:
|
|
||||||
typer.echo(typer.style(f"Failed to parse PowerShell JSON output: {e}", fg=typer.colors.RED))
|
|
||||||
return []
|
|
||||||
except subprocess.TimeoutExpired:
|
|
||||||
typer.echo(typer.style("PowerShell command timed out after 15 seconds", fg=typer.colors.RED))
|
|
||||||
return []
|
|
||||||
except Exception as e:
|
|
||||||
typer.echo(typer.style(f"Unexpected error calling PowerShell: {type(e).__name__}: {e}", fg=typer.colors.RED))
|
|
||||||
return []
|
|
||||||
|
|
||||||
|
|
||||||
def _categorize_device_type(instance_id: str) -> str:
|
|
||||||
"""Categorize device type based on InstanceId."""
|
"""Categorize device type based on InstanceId."""
|
||||||
instance_lower = instance_id.lower()
|
instance_lower = instance_id.lower()
|
||||||
if '0.0.1.00000000' in instance_lower:
|
if '0.0.1.00000000' in instance_lower:
|
||||||
@@ -467,8 +44,69 @@ def _categorize_device_type(instance_id: str) -> str:
|
|||||||
else:
|
else:
|
||||||
return "Unknown"
|
return "Unknown"
|
||||||
|
|
||||||
|
def list_devices(
|
||||||
|
device: Optional[int] = typer.Argument(default=None, help="Show details of a device, by its Id"),
|
||||||
|
pulse: bool = typer.Option(False, "--pulse", help="Show PulseAudio sources"),
|
||||||
|
native: bool = typer.Option(False, "--native", help="Force native Linux view (WSL only)"),
|
||||||
|
sort_info: Optional[str] = typer.Option(None, "--sort", help="Sort by column"),
|
||||||
|
desc_info: bool = typer.Option(False, "--desc", help="Set the sorting order to descending"),
|
||||||
|
filter_info: Optional[str] = typer.Option(None, "--filter", help="Filter by a column value. Use column=value"),
|
||||||
|
config_file: Optional[str] = typer.Option(None, "--config", "-c", help="Path to config file")
|
||||||
|
):
|
||||||
|
"""List audio devices."""
|
||||||
|
|
||||||
|
# Load configuration for context
|
||||||
|
config = AppConfig.load(config_file)
|
||||||
|
|
||||||
|
# Determine title based on environment and options
|
||||||
|
is_wsl = _detect_wsl()
|
||||||
|
use_windows = is_wsl and not native
|
||||||
|
if use_windows:
|
||||||
|
typer.echo(typer.style("WSL detected, showing Windows audio devices (use --native for WSL view)",
|
||||||
|
fg=typer.colors.BLUE))
|
||||||
|
result = get_audio_devices_windows()
|
||||||
|
if not check_result(result):
|
||||||
|
return
|
||||||
|
|
||||||
|
windows_devices = get_results(result.stdout, {"Index": '', "ID": '', 'Name': '', 'Type': '', 'Default': ''})
|
||||||
|
|
||||||
|
# apply sorting and filtering
|
||||||
|
windows_devices = filter_and_sort(windows_devices, filter_info, sort_info, desc_info)
|
||||||
|
|
||||||
|
display_as_table(windows_devices, [
|
||||||
|
{"name": "Index"},
|
||||||
|
{"name": "ID"},
|
||||||
|
{"name": "Name"},
|
||||||
|
{"name": "Type"},
|
||||||
|
{"name": "Default"},
|
||||||
|
])
|
||||||
|
|
||||||
|
|
||||||
|
else:
|
||||||
|
linux_devices = get_linux_audio_devices()
|
||||||
|
display_linux_devices(linux_devices, config, pulse, sort_config)
|
||||||
|
|
||||||
|
# Show legend
|
||||||
|
typer.echo(f"\nLegend:")
|
||||||
|
if use_windows:
|
||||||
|
typer.echo(typer.style(" ✓ = Device working", fg=typer.colors.GREEN))
|
||||||
|
typer.echo(typer.style(" ✗ = Device problem", fg=typer.colors.RED))
|
||||||
|
else:
|
||||||
|
typer.echo(typer.style(" * = Configured device", fg=typer.colors.YELLOW))
|
||||||
|
typer.echo(f" IN/OUT = Input/Output channel count")
|
||||||
|
|
||||||
def get_linux_audio_devices() -> List[LinuxAudioDevice]:
|
|
||||||
|
def install():
|
||||||
|
result = install_audio_cmdlets()
|
||||||
|
if not check_result(result):
|
||||||
|
return
|
||||||
|
|
||||||
|
typer.echo(result.stdout)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def get_linux_audio_devices() -> list:
|
||||||
"""Get Linux audio devices using sounddevice."""
|
"""Get Linux audio devices using sounddevice."""
|
||||||
try:
|
try:
|
||||||
devices = sd.query_devices()
|
devices = sd.query_devices()
|
||||||
@@ -492,87 +130,7 @@ def get_linux_audio_devices() -> List[LinuxAudioDevice]:
|
|||||||
raise typer.Exit(1)
|
raise typer.Exit(1)
|
||||||
|
|
||||||
|
|
||||||
def display_windows_devices(devices: List[WindowsAudioDevice],
|
def display_linux_devices(devices: list,
|
||||||
sort_conf: Optional[SortConf] = None,
|
|
||||||
filter_conf: Optional[FilterConf] = None
|
|
||||||
) -> None:
|
|
||||||
"""Display Windows audio devices with optimized formatting."""
|
|
||||||
|
|
||||||
def _get_id(_instance_id):
|
|
||||||
return _instance_id[31:39]
|
|
||||||
|
|
||||||
if not devices:
|
|
||||||
typer.echo(typer.style("No Windows audio devices found!", fg=typer.colors.RED))
|
|
||||||
return
|
|
||||||
|
|
||||||
# Get default devices
|
|
||||||
default_devices = get_windows_default_devices()
|
|
||||||
default_input_id = default_devices.get("default_input")
|
|
||||||
default_output_id = default_devices.get("default_output")
|
|
||||||
|
|
||||||
if not devices:
|
|
||||||
typer.echo(typer.style("No audio devices found!", fg=typer.colors.RED))
|
|
||||||
|
|
||||||
# Create rich table
|
|
||||||
table = Table(show_header=True)
|
|
||||||
table.add_column("ID", width=8, no_wrap=True, overflow="ellipsis")
|
|
||||||
table.add_column("Name", width=55, no_wrap=True, overflow="ellipsis")
|
|
||||||
table.add_column("Type", width=8, justify="center")
|
|
||||||
table.add_column("Status", width=3, justify="center")
|
|
||||||
table.add_column("*", width=2, justify="center")
|
|
||||||
|
|
||||||
# Add devices to table
|
|
||||||
rows = [] # list of dict
|
|
||||||
for device in devices:
|
|
||||||
# Determine if device is selected (default)
|
|
||||||
is_selected = False
|
|
||||||
if device.device_type == "Input" and device.instance_id == default_input_id:
|
|
||||||
is_selected = True
|
|
||||||
elif device.device_type == "Output" and device.instance_id == default_output_id:
|
|
||||||
is_selected = True
|
|
||||||
|
|
||||||
selected_marker = "*" if is_selected else ""
|
|
||||||
|
|
||||||
# Color coding for status
|
|
||||||
if device.status == "OK":
|
|
||||||
status_text = f"[green]{device.status}[/green]"
|
|
||||||
else:
|
|
||||||
status_text = f"[red]{device.status}[/red]"
|
|
||||||
|
|
||||||
# Style selected row
|
|
||||||
if is_selected:
|
|
||||||
row_style = "bold"
|
|
||||||
id_text = f"[bold]{_get_id(device.instance_id)}[/bold]"
|
|
||||||
name_text = f"[bold]{device.name}[/bold]"
|
|
||||||
type_text = f"[bold]{device.device_type}[/bold]"
|
|
||||||
selected_text = f"[bold yellow]{selected_marker}[/bold yellow]"
|
|
||||||
else:
|
|
||||||
row_style = None
|
|
||||||
id_text = _get_id(device.instance_id)
|
|
||||||
name_text = device.name
|
|
||||||
type_text = device.device_type
|
|
||||||
selected_text = selected_marker
|
|
||||||
|
|
||||||
rows.append({
|
|
||||||
"id": id_text,
|
|
||||||
"name": name_text,
|
|
||||||
"type": type_text,
|
|
||||||
"status": status_text,
|
|
||||||
"selected": selected_text
|
|
||||||
})
|
|
||||||
|
|
||||||
rows = sort_by(rows, sort_conf)
|
|
||||||
rows = filter_by(rows, filter_conf)
|
|
||||||
|
|
||||||
for row in rows:
|
|
||||||
table.add_row(*row.values())
|
|
||||||
|
|
||||||
# Display table
|
|
||||||
console = Console()
|
|
||||||
console.print(table)
|
|
||||||
|
|
||||||
|
|
||||||
def display_linux_devices(devices: List[LinuxAudioDevice],
|
|
||||||
config: AppConfig,
|
config: AppConfig,
|
||||||
show_pulse: bool = False,
|
show_pulse: bool = False,
|
||||||
sort_conf: Optional[SortConf] = None) -> None:
|
sort_conf: Optional[SortConf] = None) -> None:
|
||||||
@@ -625,14 +183,14 @@ def display_linux_devices(devices: List[LinuxAudioDevice],
|
|||||||
|
|
||||||
|
|
||||||
# Legacy function for backwards compatibility
|
# Legacy function for backwards compatibility
|
||||||
def get_audio_devices():
|
# def get_audio_devices():
|
||||||
"""Get comprehensive information about audio devices."""
|
# """Get comprehensive information about audio devices."""
|
||||||
try:
|
# try:
|
||||||
devices = sd.query_devices()
|
# devices = sd.query_devices()
|
||||||
return devices
|
# return devices
|
||||||
except Exception as e:
|
# except Exception as e:
|
||||||
typer.echo(typer.style(f"Error querying audio devices: {e}", fg=typer.colors.RED, bold=True))
|
# typer.echo(typer.style(f"Error querying audio devices: {e}", fg=typer.colors.RED, bold=True))
|
||||||
raise typer.Exit(1)
|
# raise typer.Exit(1)
|
||||||
|
|
||||||
|
|
||||||
def get_pulseaudio_sources():
|
def get_pulseaudio_sources():
|
||||||
|
|||||||
104
src/core/Expando.py
Normal file
104
src/core/Expando.py
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
NO_DEFAULT = object()
|
||||||
|
|
||||||
|
|
||||||
|
class Expando:
|
||||||
|
"""
|
||||||
|
Readonly dynamic class that eases the access to attributes and sub attributes
|
||||||
|
It is initialized with a dict
|
||||||
|
You can then access the property using dot '.' (ex. obj.prop1.prop2)
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, props):
|
||||||
|
self._props = props
|
||||||
|
|
||||||
|
def __getattr__(self, item):
|
||||||
|
if item not in self._props:
|
||||||
|
raise AttributeError(item)
|
||||||
|
|
||||||
|
current = self._props[item]
|
||||||
|
return Expando(current) if isinstance(current, dict) else current
|
||||||
|
|
||||||
|
def __setitem__(self, key, value):
|
||||||
|
self._props[key] = value
|
||||||
|
|
||||||
|
def get(self, path):
|
||||||
|
"""
|
||||||
|
returns the value, from a string with represents the path
|
||||||
|
:param path:
|
||||||
|
:return:
|
||||||
|
"""
|
||||||
|
current = self._props
|
||||||
|
for attr in path.split("."):
|
||||||
|
if isinstance(current, list):
|
||||||
|
temp = []
|
||||||
|
for value in current:
|
||||||
|
if value and attr in value:
|
||||||
|
temp.append(value[attr])
|
||||||
|
current = temp
|
||||||
|
|
||||||
|
else:
|
||||||
|
if current is None or attr not in current:
|
||||||
|
return None
|
||||||
|
current = current[attr]
|
||||||
|
|
||||||
|
return current
|
||||||
|
|
||||||
|
def geti(self, item, default=NO_DEFAULT):
|
||||||
|
"""
|
||||||
|
insensitive get
|
||||||
|
:param item:
|
||||||
|
:param default:
|
||||||
|
:return:
|
||||||
|
"""
|
||||||
|
if item in self._props:
|
||||||
|
return self._props[item]
|
||||||
|
|
||||||
|
for k, v in self._props.items():
|
||||||
|
if k.lower() == item.lower():
|
||||||
|
return v
|
||||||
|
|
||||||
|
if default is not NO_DEFAULT:
|
||||||
|
return default
|
||||||
|
|
||||||
|
raise AttributeError(f"Item '{item}' not found")
|
||||||
|
|
||||||
|
def as_dict(self):
|
||||||
|
"""
|
||||||
|
Return the information as a dictionary
|
||||||
|
:return:
|
||||||
|
"""
|
||||||
|
return self._props.copy()
|
||||||
|
|
||||||
|
def to_dict(self, mappings: dict) -> dict:
|
||||||
|
"""
|
||||||
|
Return the information as a dictionary, with the given mappings
|
||||||
|
"""
|
||||||
|
return {prop_name: self.get(path) for path, prop_name in mappings.items() if prop_name is not None}
|
||||||
|
|
||||||
|
def __hasattr__(self, item):
|
||||||
|
return item in self._props
|
||||||
|
|
||||||
|
def values(self):
|
||||||
|
return self._props.values()
|
||||||
|
|
||||||
|
def copy(self):
|
||||||
|
return Expando(self._props.copy())
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
if "key" in self._props:
|
||||||
|
return f"Expando(key={self._props["key"]})"
|
||||||
|
|
||||||
|
props_as_str = str(self._props)
|
||||||
|
if len(props_as_str) > 50:
|
||||||
|
props_as_str = props_as_str[:50] + "..."
|
||||||
|
|
||||||
|
return f"Expando({props_as_str})"
|
||||||
|
|
||||||
|
def __eq__(self, other):
|
||||||
|
if not isinstance(other, Expando):
|
||||||
|
return False
|
||||||
|
|
||||||
|
return self._props == other._props
|
||||||
|
|
||||||
|
def __hash__(self):
|
||||||
|
return hash(tuple(sorted(self._props.items())))
|
||||||
0
src/core/__init__.py
Normal file
0
src/core/__init__.py
Normal file
221
src/core/utils.py
Normal file
221
src/core/utils.py
Normal file
@@ -0,0 +1,221 @@
|
|||||||
|
import json
|
||||||
|
import subprocess
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from subprocess import CompletedProcess
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import typer
|
||||||
|
from rich.console import Console
|
||||||
|
from rich.table import Table
|
||||||
|
|
||||||
|
from .Expando import Expando
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class SortConf:
|
||||||
|
property: str
|
||||||
|
ascending: bool = True
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class FilterConf:
|
||||||
|
property: str
|
||||||
|
value: str
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def new(text):
|
||||||
|
"""Initialize FilterConf from text."""
|
||||||
|
if text is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
parts = text.split("=")
|
||||||
|
if len(parts) != 2:
|
||||||
|
return None
|
||||||
|
|
||||||
|
if not parts[0].strip() or not parts[1].strip():
|
||||||
|
return None
|
||||||
|
|
||||||
|
return FilterConf(parts[0].strip(), parts[1].strip())
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ProcessResult:
|
||||||
|
result: Any
|
||||||
|
error: str = None
|
||||||
|
|
||||||
|
|
||||||
|
def sort_by(items: list[Expando], sort_conf: SortConf):
|
||||||
|
"""Sort a list of items by a given property."""
|
||||||
|
|
||||||
|
def _safe_sort_key(item):
|
||||||
|
"""Get sort key with safe handling of None values."""
|
||||||
|
value = item.geti(property_name, "")
|
||||||
|
# Convert None to empty string for consistent sorting
|
||||||
|
return "" if value is None else str(value)
|
||||||
|
|
||||||
|
|
||||||
|
if sort_conf is None:
|
||||||
|
return items
|
||||||
|
|
||||||
|
if len(items) == 0:
|
||||||
|
return items
|
||||||
|
|
||||||
|
if sort_conf.property is None:
|
||||||
|
return items
|
||||||
|
|
||||||
|
property_name = sort_conf.property.strip()
|
||||||
|
return sorted(items, key=_safe_sort_key, reverse=not sort_conf.ascending)
|
||||||
|
|
||||||
|
|
||||||
|
def filter_by(items, filter_conf: FilterConf):
|
||||||
|
"""Filter a list of items by a given property."""
|
||||||
|
|
||||||
|
def _contains(item, value):
|
||||||
|
return str(value).lower() in str(item).lower()
|
||||||
|
|
||||||
|
if (filter_conf is None
|
||||||
|
or filter_conf.property is None
|
||||||
|
or len(items) == 0):
|
||||||
|
return items
|
||||||
|
|
||||||
|
predicate = _contains
|
||||||
|
property_name = filter_conf.property.strip()
|
||||||
|
|
||||||
|
return [
|
||||||
|
item for item in items if predicate(item.geti(property_name), filter_conf.value)
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def run_ps_command(command: str) -> CompletedProcess[str]:
|
||||||
|
"""Run a PowerShell command and return the output"""
|
||||||
|
completed = subprocess.run(
|
||||||
|
["powershell.exe",
|
||||||
|
"-NoProfile",
|
||||||
|
"-ExecutionPolicy",
|
||||||
|
"Bypass",
|
||||||
|
"-Command",
|
||||||
|
"[Console]::OutputEncoding = [System.Text.Encoding]::UTF8;",
|
||||||
|
command],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
encoding="utf-8", timeout=5
|
||||||
|
)
|
||||||
|
|
||||||
|
return completed
|
||||||
|
|
||||||
|
|
||||||
|
def run_ps_command_live(command: str) -> CompletedProcess[str]:
|
||||||
|
"""Run a PowerShell command and display output in real-time"""
|
||||||
|
process = subprocess.Popen(
|
||||||
|
["powershell.exe",
|
||||||
|
"-NoProfile",
|
||||||
|
"-ExecutionPolicy",
|
||||||
|
"Bypass",
|
||||||
|
"-Command",
|
||||||
|
"[Console]::OutputEncoding = [System.Text.Encoding]::UTF8;",
|
||||||
|
command],
|
||||||
|
stdout=subprocess.PIPE,
|
||||||
|
stderr=subprocess.PIPE,
|
||||||
|
text=True,
|
||||||
|
encoding="utf-8"
|
||||||
|
)
|
||||||
|
|
||||||
|
stdout_lines = []
|
||||||
|
stderr_lines = []
|
||||||
|
|
||||||
|
# Read output in real-time
|
||||||
|
while True:
|
||||||
|
stdout_line = process.stdout.readline()
|
||||||
|
stderr_line = process.stderr.readline()
|
||||||
|
|
||||||
|
if stdout_line:
|
||||||
|
print(stdout_line.rstrip())
|
||||||
|
stdout_lines.append(stdout_line)
|
||||||
|
|
||||||
|
if stderr_line:
|
||||||
|
print(f"ERROR: {stderr_line.rstrip()}")
|
||||||
|
stderr_lines.append(stderr_line)
|
||||||
|
|
||||||
|
# Check if process is finished
|
||||||
|
if process.poll() is not None:
|
||||||
|
break
|
||||||
|
|
||||||
|
# Read any remaining lines
|
||||||
|
remaining_stdout, remaining_stderr = process.communicate()
|
||||||
|
if remaining_stdout:
|
||||||
|
print(remaining_stdout.rstrip())
|
||||||
|
stdout_lines.append(remaining_stdout)
|
||||||
|
if remaining_stderr:
|
||||||
|
print(f"ERROR: {remaining_stderr.rstrip()}")
|
||||||
|
stderr_lines.append(remaining_stderr)
|
||||||
|
|
||||||
|
# Create CompletedProcess object to maintain compatibility
|
||||||
|
completed = CompletedProcess(
|
||||||
|
args=["powershell.exe", "-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", command],
|
||||||
|
returncode=process.returncode,
|
||||||
|
stdout=''.join(stdout_lines),
|
||||||
|
stderr=''.join(stderr_lines)
|
||||||
|
)
|
||||||
|
|
||||||
|
return completed
|
||||||
|
|
||||||
|
|
||||||
|
def get_results(stdout: str, mappings) -> list[dict]:
|
||||||
|
stripped = stdout.strip()
|
||||||
|
if not stripped:
|
||||||
|
typer.echo(typer.style("PowerShell returned empty output", fg=typer.colors.YELLOW))
|
||||||
|
return []
|
||||||
|
|
||||||
|
data = json.loads(stripped)
|
||||||
|
as_list = data if isinstance(data, list) else [data]
|
||||||
|
|
||||||
|
res = []
|
||||||
|
for item in as_list:
|
||||||
|
mapped = {key: item.get(key, default_value) for key, default_value in mappings.items()}
|
||||||
|
res.append(Expando(mapped))
|
||||||
|
|
||||||
|
return res
|
||||||
|
|
||||||
|
|
||||||
|
def display_as_table(result, columns_settings: list):
|
||||||
|
formatters = {}
|
||||||
|
table = Table(show_header=True)
|
||||||
|
for col in [c for c in columns_settings if "name" in c]:
|
||||||
|
col_name = col["name"]
|
||||||
|
table.add_column(col_name, **col["attrs"] if "attrs" in col else {})
|
||||||
|
formatter = col.pop("formatter", None)
|
||||||
|
if formatter:
|
||||||
|
formatters[col_name] = formatter
|
||||||
|
|
||||||
|
for item in result:
|
||||||
|
cloned = item.copy()
|
||||||
|
# apply formatters
|
||||||
|
for col, formatter in formatters.items():
|
||||||
|
cloned[col] = formatter(item)
|
||||||
|
|
||||||
|
table.add_row(*[str(value) for value in cloned.values()])
|
||||||
|
|
||||||
|
# Display table
|
||||||
|
console = Console()
|
||||||
|
console.print(table)
|
||||||
|
|
||||||
|
|
||||||
|
def check_result(result):
|
||||||
|
if result.returncode != 0:
|
||||||
|
error_msg = result.stderr.strip() if result.stderr else f"PowerShell exit code: {result.returncode}"
|
||||||
|
typer.echo(typer.style(f"PowerShell command failed: {error_msg}", fg=typer.colors.RED))
|
||||||
|
return False
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def filter_and_sort(items, filter_info, sort_info, desc_info=False):
|
||||||
|
if filter_info:
|
||||||
|
filter_config = FilterConf.new(filter_info) if filter_by else None
|
||||||
|
items = filter_by(items, filter_config)
|
||||||
|
|
||||||
|
if sort_info:
|
||||||
|
sort_config = SortConf(sort_info, not desc_info) if sort_by else None
|
||||||
|
items = sort_by(items, sort_config)
|
||||||
|
|
||||||
|
return items
|
||||||
78
src/devices.py
Normal file
78
src/devices.py
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
from subprocess import CompletedProcess
|
||||||
|
import sounddevice as sd
|
||||||
|
|
||||||
|
from .core.Expando import Expando
|
||||||
|
from .core.utils import run_ps_command, run_ps_command_live, ProcessResult
|
||||||
|
|
||||||
|
|
||||||
|
def _is_audio_device_cmdlets_installed():
|
||||||
|
# PowerShell command to check if module is available
|
||||||
|
ps_command = "Get-Module -ListAvailable -Name AudioDeviceCmdlets"
|
||||||
|
|
||||||
|
result = run_ps_command(ps_command)
|
||||||
|
|
||||||
|
# If something is returned in stdout, the module is installed
|
||||||
|
return bool(result.stdout.strip())
|
||||||
|
|
||||||
|
|
||||||
|
def install_audio_cmdlets():
|
||||||
|
"""Install AudioDevice cmdlet for better audio device management"""
|
||||||
|
if _is_audio_device_cmdlets_installed():
|
||||||
|
return CompletedProcess(
|
||||||
|
args=["is_audio_device_cmdlets_installed()"],
|
||||||
|
returncode=0,
|
||||||
|
stdout="AudioDevice cmdlet is already installed",
|
||||||
|
stderr="")
|
||||||
|
|
||||||
|
ps_script = r"""
|
||||||
|
# 1) Trust PSGallery
|
||||||
|
Set-PSRepository -Name "PSGallery" -InstallationPolicy Trusted -ErrorAction SilentlyContinue
|
||||||
|
|
||||||
|
# 2) Ensure NuGet provider is available
|
||||||
|
if (-not (Get-PackageProvider -Name NuGet -ErrorAction SilentlyContinue)) {
|
||||||
|
Install-PackageProvider -Name NuGet -Force -Scope CurrentUser
|
||||||
|
}
|
||||||
|
|
||||||
|
# 3) Install AudioDeviceCmdlets if missing
|
||||||
|
if (-not (Get-Module -ListAvailable -Name AudioDeviceCmdlets)) {
|
||||||
|
Install-Module -Name AudioDeviceCmdlets -Scope CurrentUser -Force -AllowClobber
|
||||||
|
}
|
||||||
|
|
||||||
|
# 4) Import the module
|
||||||
|
Import-Module AudioDeviceCmdlets -ErrorAction SilentlyContinue
|
||||||
|
"""
|
||||||
|
return run_ps_command_live(ps_script)
|
||||||
|
|
||||||
|
|
||||||
|
def get_audio_devices_windows():
|
||||||
|
"""Get all audio devices using AudioDevice cmdlet"""
|
||||||
|
ps_script = "Get-AudioDevice -List | ConvertTo-Json"
|
||||||
|
return run_ps_command(ps_script)
|
||||||
|
|
||||||
|
|
||||||
|
def get_audio_devices_windows_from_pnp():
|
||||||
|
ps_script = "Get-PnpDevice -Class AudioEndpoint | Select-Object FriendlyName, Status, InstanceId | ConvertTo-Json"
|
||||||
|
return run_ps_command(ps_script)
|
||||||
|
|
||||||
|
|
||||||
|
def get_audio_devices_linux():
|
||||||
|
"""Get all audio devices using sounddevice module"""
|
||||||
|
try:
|
||||||
|
devices = sd.query_devices()
|
||||||
|
result = []
|
||||||
|
|
||||||
|
for i, device in enumerate(devices):
|
||||||
|
hostapi_name = sd.query_hostapis(device['hostapi'])['name']
|
||||||
|
|
||||||
|
result.append(Expando({
|
||||||
|
"device_id": i,
|
||||||
|
"name": device['name'],
|
||||||
|
"max_input_channels": device['max_input_channels'],
|
||||||
|
"max_output_channels": device['max_output_channels'],
|
||||||
|
"default_samplerate": device['default_samplerate'],
|
||||||
|
"hostapi_name": hostapi_name}
|
||||||
|
))
|
||||||
|
|
||||||
|
return ProcessResult(result)
|
||||||
|
except Exception as e:
|
||||||
|
ProcessResult(None, f"Error querying Linux audio devices: {e}")
|
||||||
66
src/utils.py
66
src/utils.py
@@ -1,66 +0,0 @@
|
|||||||
from dataclasses import dataclass
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class SortConf:
|
|
||||||
property: str
|
|
||||||
ascending: bool = True
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class FilterConf:
|
|
||||||
property: str
|
|
||||||
value: str
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def new(text):
|
|
||||||
"""Initialize FilterConf from text."""
|
|
||||||
if text is None:
|
|
||||||
return None
|
|
||||||
|
|
||||||
parts = text.split("=")
|
|
||||||
if len(parts) != 2:
|
|
||||||
return None
|
|
||||||
|
|
||||||
return FilterConf(parts[0].strip(), parts[1].strip())
|
|
||||||
|
|
||||||
|
|
||||||
def sort_by(items, sort_conf: SortConf):
|
|
||||||
"""Sort a list of items by a given property."""
|
|
||||||
if sort_conf is None:
|
|
||||||
return items
|
|
||||||
|
|
||||||
if len(items) == 0:
|
|
||||||
return items
|
|
||||||
|
|
||||||
if isinstance(items[0], dict):
|
|
||||||
property_name = sort_conf.property.lower().strip()
|
|
||||||
return sorted(items, key=lambda item: item.get(property_name), reverse=not sort_conf.ascending)
|
|
||||||
else:
|
|
||||||
return sorted(items, key=lambda item: getattr(item, sort_conf.property), reverse=not sort_conf.ascending)
|
|
||||||
|
|
||||||
|
|
||||||
def filter_by(items, filter_conf: FilterConf):
|
|
||||||
"""Filter a list of items by a given property."""
|
|
||||||
|
|
||||||
def _contains(item, value):
|
|
||||||
return str(value).lower() in str(item).lower()
|
|
||||||
|
|
||||||
if (filter_conf is None
|
|
||||||
or filter_conf.property is None
|
|
||||||
or len(items) == 0):
|
|
||||||
return items
|
|
||||||
|
|
||||||
predicate = _contains
|
|
||||||
|
|
||||||
if isinstance(items[0], dict):
|
|
||||||
property_name = filter_conf.property.lower().strip()
|
|
||||||
return [
|
|
||||||
item for item in items if predicate(item.get(property_name), filter_conf.value)
|
|
||||||
]
|
|
||||||
|
|
||||||
else:
|
|
||||||
|
|
||||||
return [
|
|
||||||
item for item in items if getattr(item, filter_conf.property) == filter_conf.value
|
|
||||||
]
|
|
||||||
@@ -1,407 +0,0 @@
|
|||||||
import pytest
|
|
||||||
from unittest.mock import Mock, patch, mock_open, MagicMock
|
|
||||||
import json
|
|
||||||
import subprocess
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
from src.commands.audio import (
|
|
||||||
detect_wsl,
|
|
||||||
get_windows_audio_devices,
|
|
||||||
get_linux_audio_devices,
|
|
||||||
_categorize_device_type,
|
|
||||||
display_windows_devices,
|
|
||||||
display_linux_devices,
|
|
||||||
WindowsAudioDevice,
|
|
||||||
LinuxAudioDevice
|
|
||||||
)
|
|
||||||
from src.config import AppConfig, AudioConfig, ServerConfig
|
|
||||||
|
|
||||||
|
|
||||||
# WSL Detection Tests
|
|
||||||
|
|
||||||
@patch('pathlib.Path.exists')
|
|
||||||
@patch('builtins.open', mock_open(read_data='Linux version 5.4.0-microsoft-standard'))
|
|
||||||
def test_detect_wsl_via_proc_version_microsoft(mock_exists):
|
|
||||||
"""Test WSL detection via /proc/version containing 'microsoft'."""
|
|
||||||
mock_exists.return_value = True
|
|
||||||
|
|
||||||
result = detect_wsl()
|
|
||||||
|
|
||||||
assert result is True
|
|
||||||
|
|
||||||
|
|
||||||
@patch('pathlib.Path.exists')
|
|
||||||
@patch('builtins.open', mock_open(read_data='Linux version 5.4.0-wsl2-standard'))
|
|
||||||
def test_detect_wsl_via_proc_version_wsl(mock_exists):
|
|
||||||
"""Test WSL detection via /proc/version containing 'wsl'."""
|
|
||||||
mock_exists.return_value = True
|
|
||||||
|
|
||||||
result = detect_wsl()
|
|
||||||
|
|
||||||
assert result is True
|
|
||||||
|
|
||||||
|
|
||||||
@patch('pathlib.Path.exists')
|
|
||||||
@patch('builtins.open', mock_open(read_data='Linux version 5.4.0-generic'))
|
|
||||||
@patch('os.getenv')
|
|
||||||
def test_detect_wsl_via_env_distro_name(mock_getenv, mock_exists):
|
|
||||||
"""Test WSL detection via WSL_DISTRO_NAME environment variable."""
|
|
||||||
mock_exists.return_value = True
|
|
||||||
mock_getenv.side_effect = lambda key: 'Ubuntu' if key == 'WSL_DISTRO_NAME' else None
|
|
||||||
|
|
||||||
result = detect_wsl()
|
|
||||||
|
|
||||||
assert result is True
|
|
||||||
|
|
||||||
|
|
||||||
@patch('pathlib.Path.exists')
|
|
||||||
@patch('builtins.open', mock_open(read_data='Linux version 5.4.0-generic'))
|
|
||||||
@patch('os.getenv')
|
|
||||||
def test_detect_wsl_via_env_wslenv(mock_getenv, mock_exists):
|
|
||||||
"""Test WSL detection via WSLENV environment variable."""
|
|
||||||
mock_exists.return_value = True
|
|
||||||
mock_getenv.side_effect = lambda key: 'PATH/l' if key == 'WSLENV' else None
|
|
||||||
|
|
||||||
result = detect_wsl()
|
|
||||||
|
|
||||||
assert result is True
|
|
||||||
|
|
||||||
|
|
||||||
@patch('pathlib.Path.exists')
|
|
||||||
@patch('builtins.open', mock_open(read_data='Linux version 5.4.0-generic'))
|
|
||||||
@patch('os.getenv', return_value=None)
|
|
||||||
def test_detect_wsl_false_native_linux(mock_getenv, mock_exists):
|
|
||||||
"""Test WSL detection returns False on native Linux."""
|
|
||||||
mock_exists.return_value = True
|
|
||||||
|
|
||||||
result = detect_wsl()
|
|
||||||
|
|
||||||
assert result is False
|
|
||||||
|
|
||||||
|
|
||||||
@patch('pathlib.Path.exists', return_value=False)
|
|
||||||
@patch('os.getenv', return_value=None)
|
|
||||||
def test_detect_wsl_false_no_proc_version(mock_getenv, mock_exists):
|
|
||||||
"""Test WSL detection returns False when /proc/version doesn't exist."""
|
|
||||||
result = detect_wsl()
|
|
||||||
|
|
||||||
assert result is False
|
|
||||||
|
|
||||||
|
|
||||||
@patch('pathlib.Path.exists')
|
|
||||||
@patch('builtins.open', side_effect=Exception("Permission denied"))
|
|
||||||
@patch('os.getenv', return_value=None)
|
|
||||||
def test_detect_wsl_handles_proc_version_read_error(mock_getenv, mock_exists):
|
|
||||||
"""Test WSL detection handles /proc/version read errors gracefully."""
|
|
||||||
mock_exists.return_value = True
|
|
||||||
|
|
||||||
result = detect_wsl()
|
|
||||||
|
|
||||||
assert result is False
|
|
||||||
|
|
||||||
|
|
||||||
# Device Type Categorization Tests
|
|
||||||
|
|
||||||
def test_categorize_input_device():
|
|
||||||
"""Test categorization of input device."""
|
|
||||||
instance_id = "SWD\\MMDEVAPI\\{0.0.1.00000000}.{a1b2c3d4-e5f6-7890-abcd-ef1234567890}\\capture"
|
|
||||||
|
|
||||||
result = _categorize_device_type(instance_id)
|
|
||||||
|
|
||||||
assert result == "Input"
|
|
||||||
|
|
||||||
|
|
||||||
def test_categorize_output_device():
|
|
||||||
"""Test categorization of output device."""
|
|
||||||
instance_id = "SWD\\MMDEVAPI\\{0.0.0.00000000}.{a1b2c3d4-e5f6-7890-abcd-ef1234567890}\\render"
|
|
||||||
|
|
||||||
result = _categorize_device_type(instance_id)
|
|
||||||
|
|
||||||
assert result == "Output"
|
|
||||||
|
|
||||||
|
|
||||||
def test_categorize_unknown_device():
|
|
||||||
"""Test categorization of unknown device type."""
|
|
||||||
instance_id = "SWD\\MMDEVAPI\\{0.0.2.00000000}.{a1b2c3d4-e5f6-7890-abcd-ef1234567890}\\unknown"
|
|
||||||
|
|
||||||
result = _categorize_device_type(instance_id)
|
|
||||||
|
|
||||||
assert result == "Unknown"
|
|
||||||
|
|
||||||
|
|
||||||
def test_categorize_empty_instance_id():
|
|
||||||
"""Test categorization with empty instance ID."""
|
|
||||||
result = _categorize_device_type("")
|
|
||||||
|
|
||||||
assert result == "Unknown"
|
|
||||||
|
|
||||||
|
|
||||||
# Windows Audio Devices Tests
|
|
||||||
|
|
||||||
@patch('subprocess.run')
|
|
||||||
@patch('typer.echo')
|
|
||||||
def test_get_windows_devices_success_single_device(mock_echo, mock_run):
|
|
||||||
"""Test successful retrieval of single Windows device."""
|
|
||||||
mock_result = Mock()
|
|
||||||
mock_result.returncode = 0
|
|
||||||
mock_result.stdout = json.dumps({
|
|
||||||
"FriendlyName": "Blue Yeti Microphone",
|
|
||||||
"Status": "OK",
|
|
||||||
"InstanceId": "USB\\VID_0B05&PID_1234\\capture"
|
|
||||||
})
|
|
||||||
mock_result.stderr = ""
|
|
||||||
mock_run.return_value = mock_result
|
|
||||||
|
|
||||||
result = get_windows_audio_devices()
|
|
||||||
|
|
||||||
assert len(result) == 1
|
|
||||||
assert result[0].name == "Blue Yeti Microphone"
|
|
||||||
assert result[0].status == "OK"
|
|
||||||
assert result[0].device_type == "Input"
|
|
||||||
assert "USB\\VID_0B05&PID_1234\\capture" in result[0].instance_id
|
|
||||||
|
|
||||||
|
|
||||||
@patch('subprocess.run')
|
|
||||||
@patch('typer.echo')
|
|
||||||
def test_get_windows_devices_success_multiple_devices(mock_echo, mock_run):
|
|
||||||
"""Test successful retrieval of multiple Windows devices."""
|
|
||||||
mock_result = Mock()
|
|
||||||
mock_result.returncode = 0
|
|
||||||
mock_result.stdout = json.dumps([
|
|
||||||
{
|
|
||||||
"FriendlyName": "Microphone",
|
|
||||||
"Status": "OK",
|
|
||||||
"InstanceId": "capture_device"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"FriendlyName": "Speakers",
|
|
||||||
"Status": "OK",
|
|
||||||
"InstanceId": "render_device"
|
|
||||||
}
|
|
||||||
])
|
|
||||||
mock_result.stderr = ""
|
|
||||||
mock_run.return_value = mock_result
|
|
||||||
|
|
||||||
result = get_windows_audio_devices()
|
|
||||||
|
|
||||||
assert len(result) == 2
|
|
||||||
assert result[0].device_type == "Input"
|
|
||||||
assert result[1].device_type == "Output"
|
|
||||||
|
|
||||||
|
|
||||||
@patch('subprocess.run')
|
|
||||||
@patch('typer.echo')
|
|
||||||
def test_get_windows_devices_powershell_error(mock_echo, mock_run):
|
|
||||||
"""Test PowerShell command failure."""
|
|
||||||
mock_result = Mock()
|
|
||||||
mock_result.returncode = 1
|
|
||||||
mock_result.stdout = ""
|
|
||||||
mock_result.stderr = "Get-PnpDevice : Access denied"
|
|
||||||
mock_run.return_value = mock_result
|
|
||||||
|
|
||||||
result = get_windows_audio_devices()
|
|
||||||
|
|
||||||
assert result == []
|
|
||||||
mock_echo.assert_called()
|
|
||||||
|
|
||||||
|
|
||||||
@patch('subprocess.run')
|
|
||||||
@patch('typer.echo')
|
|
||||||
def test_get_windows_devices_empty_output(mock_echo, mock_run):
|
|
||||||
"""Test PowerShell returning empty output."""
|
|
||||||
mock_result = Mock()
|
|
||||||
mock_result.returncode = 0
|
|
||||||
mock_result.stdout = ""
|
|
||||||
mock_result.stderr = ""
|
|
||||||
mock_run.return_value = mock_result
|
|
||||||
|
|
||||||
result = get_windows_audio_devices()
|
|
||||||
|
|
||||||
assert result == []
|
|
||||||
mock_echo.assert_called()
|
|
||||||
|
|
||||||
|
|
||||||
@patch('subprocess.run')
|
|
||||||
@patch('typer.echo')
|
|
||||||
def test_get_windows_devices_invalid_json(mock_echo, mock_run):
|
|
||||||
"""Test invalid JSON response from PowerShell."""
|
|
||||||
mock_result = Mock()
|
|
||||||
mock_result.returncode = 0
|
|
||||||
mock_result.stdout = "Invalid JSON {"
|
|
||||||
mock_result.stderr = ""
|
|
||||||
mock_run.return_value = mock_result
|
|
||||||
|
|
||||||
result = get_windows_audio_devices()
|
|
||||||
|
|
||||||
assert result == []
|
|
||||||
mock_echo.assert_called()
|
|
||||||
|
|
||||||
|
|
||||||
@patch('subprocess.run', side_effect=subprocess.TimeoutExpired("powershell.exe", 15))
|
|
||||||
@patch('typer.echo')
|
|
||||||
def test_get_windows_devices_timeout(mock_echo, mock_run):
|
|
||||||
"""Test PowerShell command timeout."""
|
|
||||||
result = get_windows_audio_devices()
|
|
||||||
|
|
||||||
assert result == []
|
|
||||||
mock_echo.assert_called()
|
|
||||||
|
|
||||||
|
|
||||||
@patch('subprocess.run')
|
|
||||||
@patch('typer.echo')
|
|
||||||
def test_get_windows_devices_filters_empty_names(mock_echo, mock_run):
|
|
||||||
"""Test filtering of devices with empty names."""
|
|
||||||
mock_result = Mock()
|
|
||||||
mock_result.returncode = 0
|
|
||||||
mock_result.stdout = json.dumps([
|
|
||||||
{
|
|
||||||
"FriendlyName": "Valid Device",
|
|
||||||
"Status": "OK",
|
|
||||||
"InstanceId": "capture_device"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"FriendlyName": "",
|
|
||||||
"Status": "OK",
|
|
||||||
"InstanceId": "empty_name_device"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"FriendlyName": None,
|
|
||||||
"Status": "OK",
|
|
||||||
"InstanceId": "null_name_device"
|
|
||||||
}
|
|
||||||
])
|
|
||||||
mock_result.stderr = ""
|
|
||||||
mock_run.return_value = mock_result
|
|
||||||
|
|
||||||
result = get_windows_audio_devices()
|
|
||||||
|
|
||||||
assert len(result) == 1
|
|
||||||
assert result[0].name == "Valid Device"
|
|
||||||
|
|
||||||
|
|
||||||
# Linux Audio Devices Tests
|
|
||||||
|
|
||||||
@patch('sounddevice.query_devices')
|
|
||||||
@patch('sounddevice.query_hostapis')
|
|
||||||
@patch('typer.echo')
|
|
||||||
def test_get_linux_devices_success(mock_echo, mock_hostapis, mock_devices):
|
|
||||||
"""Test successful retrieval of Linux devices."""
|
|
||||||
mock_devices.return_value = [
|
|
||||||
{
|
|
||||||
'name': 'pulse',
|
|
||||||
'max_input_channels': 32,
|
|
||||||
'max_output_channels': 32,
|
|
||||||
'default_samplerate': 44100.0,
|
|
||||||
'hostapi': 0
|
|
||||||
},
|
|
||||||
{
|
|
||||||
'name': 'default',
|
|
||||||
'max_input_channels': 32,
|
|
||||||
'max_output_channels': 32,
|
|
||||||
'default_samplerate': 44100.0,
|
|
||||||
'hostapi': 0
|
|
||||||
}
|
|
||||||
]
|
|
||||||
mock_hostapis.return_value = {'name': 'ALSA'}
|
|
||||||
|
|
||||||
result = get_linux_audio_devices()
|
|
||||||
|
|
||||||
assert len(result) == 2
|
|
||||||
assert result[0].name == 'pulse'
|
|
||||||
assert result[0].device_id == 0
|
|
||||||
assert result[0].hostapi_name == 'ALSA'
|
|
||||||
assert result[1].name == 'default'
|
|
||||||
assert result[1].device_id == 1
|
|
||||||
|
|
||||||
|
|
||||||
@patch('sounddevice.query_devices', side_effect=Exception("ALSA error"))
|
|
||||||
@patch('typer.echo')
|
|
||||||
def test_get_linux_devices_sounddevice_error(mock_echo, mock_devices):
|
|
||||||
"""Test sounddevice query failure."""
|
|
||||||
with pytest.raises(SystemExit):
|
|
||||||
get_linux_audio_devices()
|
|
||||||
|
|
||||||
|
|
||||||
# Display Functions Tests
|
|
||||||
|
|
||||||
@patch('typer.echo')
|
|
||||||
def test_display_windows_devices_empty_list(mock_echo):
|
|
||||||
"""Test display with empty device list."""
|
|
||||||
display_windows_devices([], True, True)
|
|
||||||
|
|
||||||
mock_echo.assert_called()
|
|
||||||
|
|
||||||
|
|
||||||
@patch('typer.echo')
|
|
||||||
@patch('src.commands.audio._display_single_windows_device')
|
|
||||||
def test_display_windows_devices_input_only(mock_display_single, mock_echo):
|
|
||||||
"""Test display showing only input devices."""
|
|
||||||
devices = [
|
|
||||||
WindowsAudioDevice('Microphone', 'OK', 'Input', 'mic1'),
|
|
||||||
WindowsAudioDevice('Speakers', 'OK', 'Output', 'spk1')
|
|
||||||
]
|
|
||||||
|
|
||||||
display_windows_devices(devices, show_inputs=True, show_outputs=False)
|
|
||||||
|
|
||||||
mock_display_single.assert_called_once_with(devices[0])
|
|
||||||
|
|
||||||
|
|
||||||
@patch('typer.echo')
|
|
||||||
@patch('src.commands.audio._display_single_windows_device')
|
|
||||||
def test_display_windows_devices_with_unknown(mock_display_single, mock_echo):
|
|
||||||
"""Test display including unknown device types."""
|
|
||||||
devices = [
|
|
||||||
WindowsAudioDevice('Microphone', 'OK', 'Input', 'mic1'),
|
|
||||||
WindowsAudioDevice('Unknown Device', 'OK', 'Unknown', 'unk1')
|
|
||||||
]
|
|
||||||
|
|
||||||
display_windows_devices(devices, show_inputs=True, show_outputs=True)
|
|
||||||
|
|
||||||
assert mock_display_single.call_count == 2
|
|
||||||
|
|
||||||
|
|
||||||
@patch('typer.echo')
|
|
||||||
def test_display_linux_devices_no_matching_devices(mock_echo):
|
|
||||||
"""Test display with no matching devices."""
|
|
||||||
devices = [
|
|
||||||
LinuxAudioDevice(0, 'pulse', 0, 32, 44100.0, 'ALSA')
|
|
||||||
]
|
|
||||||
config = AppConfig(ServerConfig(), AudioConfig())
|
|
||||||
|
|
||||||
with pytest.raises(SystemExit):
|
|
||||||
display_linux_devices(devices, show_inputs=True, show_outputs=False, config=config)
|
|
||||||
|
|
||||||
|
|
||||||
@patch('typer.echo')
|
|
||||||
@patch('sounddevice.default')
|
|
||||||
def test_display_linux_devices_success(mock_default, mock_echo):
|
|
||||||
"""Test successful display of Linux devices."""
|
|
||||||
mock_default.device = [0, 1]
|
|
||||||
|
|
||||||
devices = [
|
|
||||||
LinuxAudioDevice(0, 'pulse', 32, 32, 44100.0, 'ALSA')
|
|
||||||
]
|
|
||||||
config = AppConfig(ServerConfig(), AudioConfig())
|
|
||||||
|
|
||||||
display_linux_devices(devices, show_inputs=True, show_outputs=True, config=config)
|
|
||||||
|
|
||||||
# Verify that echo was called with device information
|
|
||||||
assert mock_echo.call_count > 0
|
|
||||||
|
|
||||||
|
|
||||||
@patch('typer.echo')
|
|
||||||
@patch('sounddevice.default')
|
|
||||||
def test_display_linux_devices_with_configured_device(mock_default, mock_echo):
|
|
||||||
"""Test display with configured device marked."""
|
|
||||||
mock_default.device = [0, 1]
|
|
||||||
|
|
||||||
devices = [
|
|
||||||
LinuxAudioDevice(0, 'pulse', 32, 32, 44100.0, 'ALSA'),
|
|
||||||
LinuxAudioDevice(1, 'default', 32, 32, 44100.0, 'ALSA')
|
|
||||||
]
|
|
||||||
config = AppConfig(ServerConfig(), AudioConfig(device=1))
|
|
||||||
|
|
||||||
display_linux_devices(devices, show_inputs=True, show_outputs=True, config=config)
|
|
||||||
|
|
||||||
# Verify that echo was called and device is marked as configured
|
|
||||||
assert mock_echo.call_count > 0
|
|
||||||
95
tests/test_expando.py
Normal file
95
tests/test_expando.py
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
import pytest
|
||||||
|
|
||||||
|
from core.Expando import Expando
|
||||||
|
|
||||||
|
|
||||||
|
def test_i_can_get_properties():
|
||||||
|
props = {"a": 10,
|
||||||
|
"b": {
|
||||||
|
"c": "value",
|
||||||
|
"d": 20
|
||||||
|
}}
|
||||||
|
dynamic = Expando(props)
|
||||||
|
|
||||||
|
assert dynamic.a == 10
|
||||||
|
assert dynamic.b.c == "value"
|
||||||
|
|
||||||
|
with pytest.raises(AttributeError):
|
||||||
|
assert dynamic.unknown == "some_value"
|
||||||
|
|
||||||
|
|
||||||
|
def test_i_can_get():
|
||||||
|
props = {"a": 10,
|
||||||
|
"b": {
|
||||||
|
"c": "value",
|
||||||
|
"d": 20
|
||||||
|
}}
|
||||||
|
dynamic = Expando(props)
|
||||||
|
|
||||||
|
assert dynamic.get("a") == 10
|
||||||
|
assert dynamic.get("b.c") == "value"
|
||||||
|
assert dynamic.get("unknown") is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_i_can_get_insensitive():
|
||||||
|
props = {"A": 10,
|
||||||
|
"b": 20}
|
||||||
|
dynamic = Expando(props)
|
||||||
|
|
||||||
|
assert dynamic.geti("a") == 10
|
||||||
|
assert dynamic.geti("A") == 10
|
||||||
|
assert dynamic.geti("a.c", None) is None
|
||||||
|
with pytest.raises(AttributeError):
|
||||||
|
assert dynamic.geti("unknown") == "some_value"
|
||||||
|
|
||||||
|
|
||||||
|
def test_i_can_get_insensitive_when_the_two_entries_exist():
|
||||||
|
props = {"A": 10,
|
||||||
|
"a": 20}
|
||||||
|
dynamic = Expando(props)
|
||||||
|
assert dynamic.geti("A") == 10
|
||||||
|
assert dynamic.geti("a") == 20
|
||||||
|
|
||||||
|
|
||||||
|
def test_i_can_get_from_list():
|
||||||
|
props = {"a": [{"c": "value1", "d": 1}, {"c": "value2", "d": 2}]}
|
||||||
|
dynamic = Expando(props)
|
||||||
|
|
||||||
|
assert dynamic.get("a.c") == ["value1", "value2"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_none_is_returned_when_get_from_list_and_property_does_not_exist():
|
||||||
|
props = {"a": [{"c": "value1", "d": 1},
|
||||||
|
{"a": "value2", "d": 2} # 'c' does not exist in the second row
|
||||||
|
]}
|
||||||
|
dynamic = Expando(props)
|
||||||
|
|
||||||
|
assert dynamic.get("a.c") == ["value1"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_i_can_manage_none_values():
|
||||||
|
props = {"a": 10,
|
||||||
|
"b": None}
|
||||||
|
dynamic = Expando(props)
|
||||||
|
|
||||||
|
assert dynamic.get("b.c") is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_i_can_manage_none_values_in_list():
|
||||||
|
props = {"a": [{"b": {"c": "value"}},
|
||||||
|
{"b": None}
|
||||||
|
]}
|
||||||
|
dynamic = Expando(props)
|
||||||
|
|
||||||
|
assert dynamic.get("a.b.c") == ["value"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_i_can_add_new_properties():
|
||||||
|
props = {"a": 10,
|
||||||
|
"b": 20}
|
||||||
|
dynamic = Expando(props)
|
||||||
|
dynamic["c"] = 30
|
||||||
|
|
||||||
|
assert dynamic.a == 10
|
||||||
|
assert dynamic.b == 20
|
||||||
|
assert dynamic.c == 30
|
||||||
165
tests/test_filter_by.py
Normal file
165
tests/test_filter_by.py
Normal file
@@ -0,0 +1,165 @@
|
|||||||
|
import pytest
|
||||||
|
|
||||||
|
from src.core.utils import FilterConf, filter_by
|
||||||
|
from src.core.Expando import Expando
|
||||||
|
|
||||||
|
|
||||||
|
def test_i_can_filter_by_matching_string():
|
||||||
|
"""Test basic filtering with string matching."""
|
||||||
|
items = [
|
||||||
|
Expando({"name": "alice"}),
|
||||||
|
Expando({"name": "bob"}),
|
||||||
|
Expando({"name": "charlie"})
|
||||||
|
]
|
||||||
|
filter_conf = FilterConf(property="name", value="alice")
|
||||||
|
|
||||||
|
result = filter_by(items, filter_conf)
|
||||||
|
|
||||||
|
assert len(result) == 1
|
||||||
|
assert result[0].name == "alice"
|
||||||
|
|
||||||
|
|
||||||
|
def test_i_can_filter_by_case_insensitive():
|
||||||
|
"""Test case-insensitive filtering."""
|
||||||
|
items = [
|
||||||
|
Expando({"name": "ALICE"}),
|
||||||
|
Expando({"name": "Bob"}),
|
||||||
|
Expando({"name": "charlie"})
|
||||||
|
]
|
||||||
|
filter_conf = FilterConf(property="name", value="alice")
|
||||||
|
|
||||||
|
result = filter_by(items, filter_conf)
|
||||||
|
|
||||||
|
assert len(result) == 1
|
||||||
|
assert result[0].name == "ALICE"
|
||||||
|
|
||||||
|
|
||||||
|
def test_i_can_filter_by_partial_match():
|
||||||
|
"""Test partial substring matching."""
|
||||||
|
items = [
|
||||||
|
Expando({"name": "alice smith"}),
|
||||||
|
Expando({"name": "bob jones"}),
|
||||||
|
Expando({"name": "charlie brown"})
|
||||||
|
]
|
||||||
|
filter_conf = FilterConf(property="name", value="smith")
|
||||||
|
|
||||||
|
result = filter_by(items, filter_conf)
|
||||||
|
|
||||||
|
assert len(result) == 1
|
||||||
|
assert result[0].name == "alice smith"
|
||||||
|
|
||||||
|
|
||||||
|
def test_i_can_handle_none_filter_conf():
|
||||||
|
"""Test that None filter configuration returns items unchanged."""
|
||||||
|
items = [
|
||||||
|
Expando({"name": "alice"}),
|
||||||
|
Expando({"name": "bob"})
|
||||||
|
]
|
||||||
|
|
||||||
|
result = filter_by(items, None)
|
||||||
|
|
||||||
|
assert result == items
|
||||||
|
assert len(result) == 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_i_can_handle_empty_list():
|
||||||
|
"""Test that empty list returns empty list."""
|
||||||
|
items = []
|
||||||
|
filter_conf = FilterConf(property="name", value="alice")
|
||||||
|
|
||||||
|
result = filter_by(items, filter_conf)
|
||||||
|
|
||||||
|
assert result == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_i_can_handle_none_property_in_filter_conf():
|
||||||
|
"""Test that FilterConf with None property returns items unchanged."""
|
||||||
|
items = [
|
||||||
|
Expando({"name": "alice"}),
|
||||||
|
Expando({"name": "bob"})
|
||||||
|
]
|
||||||
|
# Create FilterConf with None property (simulating invalid state)
|
||||||
|
filter_conf = FilterConf(property=None, value="alice")
|
||||||
|
|
||||||
|
result = filter_by(items, filter_conf)
|
||||||
|
|
||||||
|
assert result == items
|
||||||
|
assert len(result) == 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_i_can_filter_with_no_matches():
|
||||||
|
"""Test filtering when no items match the criteria."""
|
||||||
|
items = [
|
||||||
|
Expando({"name": "alice"}),
|
||||||
|
Expando({"name": "bob"}),
|
||||||
|
Expando({"name": "charlie"})
|
||||||
|
]
|
||||||
|
filter_conf = FilterConf(property="name", value="xyz")
|
||||||
|
|
||||||
|
result = filter_by(items, filter_conf)
|
||||||
|
|
||||||
|
assert result == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_i_cannot_filter_with_missing_properties():
|
||||||
|
"""Test that missing properties raise AttributeError."""
|
||||||
|
items = [
|
||||||
|
Expando({"name": "alice"}),
|
||||||
|
Expando({"age": 25}), # no name property
|
||||||
|
Expando({"name": "bob"})
|
||||||
|
]
|
||||||
|
filter_conf = FilterConf(property="name", value="alice")
|
||||||
|
|
||||||
|
# Should raise AttributeError when trying to access missing property
|
||||||
|
with pytest.raises(AttributeError):
|
||||||
|
filter_by(items, filter_conf)
|
||||||
|
|
||||||
|
|
||||||
|
def test_i_can_filter_with_none_values():
|
||||||
|
"""Test filtering when properties have None values."""
|
||||||
|
items = [
|
||||||
|
Expando({"name": "alice"}),
|
||||||
|
Expando({"name": None}),
|
||||||
|
Expando({"name": "bob"})
|
||||||
|
]
|
||||||
|
filter_conf = FilterConf(property="name", value="alice")
|
||||||
|
|
||||||
|
result = filter_by(items, filter_conf)
|
||||||
|
|
||||||
|
# None gets converted to "none" string, shouldn't match "alice"
|
||||||
|
assert len(result) == 1
|
||||||
|
assert result[0].name == "alice"
|
||||||
|
|
||||||
|
|
||||||
|
def test_i_can_filter_with_case_insensitive_property_names():
|
||||||
|
"""Test filtering using case-insensitive property names."""
|
||||||
|
items = [
|
||||||
|
Expando({"Name": "alice"}), # uppercase N
|
||||||
|
Expando({"Name": "bob"}),
|
||||||
|
Expando({"Name": "charlie"})
|
||||||
|
]
|
||||||
|
filter_conf = FilterConf(property="name", value="alice") # lowercase n
|
||||||
|
|
||||||
|
result = filter_by(items, filter_conf)
|
||||||
|
|
||||||
|
assert len(result) == 1
|
||||||
|
assert result[0].Name == "alice"
|
||||||
|
|
||||||
|
|
||||||
|
def test_i_can_filter_with_numbers_and_mixed_types():
|
||||||
|
"""Test filtering with mixed types (numbers, strings)."""
|
||||||
|
items = [
|
||||||
|
Expando({"value": "123"}),
|
||||||
|
Expando({"value": 123}),
|
||||||
|
Expando({"value": "hello123"}),
|
||||||
|
Expando({"value": "world"})
|
||||||
|
]
|
||||||
|
filter_conf = FilterConf(property="value", value="123")
|
||||||
|
|
||||||
|
result = filter_by(items, filter_conf)
|
||||||
|
|
||||||
|
# Should match "123", 456 (converted to "456"), and "hello123"
|
||||||
|
assert len(result) == 3
|
||||||
|
expected_values = ["123", 123, "hello123"]
|
||||||
|
actual_values = [item.value for item in result]
|
||||||
|
assert all(val in expected_values for val in actual_values)
|
||||||
85
tests/test_filter_conf.py
Normal file
85
tests/test_filter_conf.py
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
from src.core.utils import FilterConf
|
||||||
|
|
||||||
|
|
||||||
|
def test_i_can_create_filter_conf_from_valid_text():
|
||||||
|
"""Test creating FilterConf from valid property=value format."""
|
||||||
|
result = FilterConf.new("property=value")
|
||||||
|
|
||||||
|
assert result is not None
|
||||||
|
assert isinstance(result, FilterConf)
|
||||||
|
assert result.property == "property"
|
||||||
|
assert result.value == "value"
|
||||||
|
|
||||||
|
|
||||||
|
def test_i_can_handle_none_input():
|
||||||
|
"""Test that None input returns None."""
|
||||||
|
result = FilterConf.new(None)
|
||||||
|
|
||||||
|
assert result is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_i_can_handle_whitespace_around_equals():
|
||||||
|
"""Test that whitespace around property and value is stripped."""
|
||||||
|
result = FilterConf.new(" property = value ")
|
||||||
|
|
||||||
|
assert result is not None
|
||||||
|
assert result.property == "property"
|
||||||
|
assert result.value == "value"
|
||||||
|
|
||||||
|
|
||||||
|
def test_i_cannot_create_filter_conf_without_equals():
|
||||||
|
"""Test that text without equals sign returns None."""
|
||||||
|
result = FilterConf.new("property")
|
||||||
|
|
||||||
|
assert result is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_i_cannot_create_filter_conf_with_multiple_equals():
|
||||||
|
"""Test that text with multiple equals signs returns None."""
|
||||||
|
result = FilterConf.new("property=value=another")
|
||||||
|
|
||||||
|
assert result is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_i_cannot_create_filter_conf_from_empty_string():
|
||||||
|
"""Test that empty string returns None."""
|
||||||
|
result = FilterConf.new("")
|
||||||
|
|
||||||
|
assert result is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_i_cannot_create_filter_conf_with_empty_property():
|
||||||
|
"""Test that empty property name returns None."""
|
||||||
|
result = FilterConf.new("=value")
|
||||||
|
|
||||||
|
assert result is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_i_cannot_create_filter_conf_with_empty_value():
|
||||||
|
"""Test that empty value returns None."""
|
||||||
|
result = FilterConf.new("property=")
|
||||||
|
|
||||||
|
assert result is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_i_cannot_create_filter_conf_with_both_empty():
|
||||||
|
"""Test that both empty property and value returns None."""
|
||||||
|
result = FilterConf.new("=")
|
||||||
|
|
||||||
|
assert result is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_i_cannot_create_filter_conf_with_whitespace_only():
|
||||||
|
"""Test that whitespace-only parts return None."""
|
||||||
|
result = FilterConf.new(" = ")
|
||||||
|
|
||||||
|
assert result is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_i_can_handle_special_characters_in_values():
|
||||||
|
"""Test that special characters in property names and values are handled correctly."""
|
||||||
|
result = FilterConf.new("device_type=USB Audio Device (2.0)")
|
||||||
|
|
||||||
|
assert result is not None
|
||||||
|
assert result.property == "device_type"
|
||||||
|
assert result.value == "USB Audio Device (2.0)"
|
||||||
152
tests/test_sort_by.py
Normal file
152
tests/test_sort_by.py
Normal file
@@ -0,0 +1,152 @@
|
|||||||
|
from src.core.utils import FilterConf, SortConf, sort_by
|
||||||
|
from src.core.Expando import Expando
|
||||||
|
|
||||||
|
|
||||||
|
def test_i_can_sort_by_ascending_strings():
|
||||||
|
"""Test sorting strings in ascending order."""
|
||||||
|
items = [
|
||||||
|
Expando({"name": "charlie"}),
|
||||||
|
Expando({"name": "alice"}),
|
||||||
|
Expando({"name": "bob"})
|
||||||
|
]
|
||||||
|
sort_conf = SortConf(property="name", ascending=True)
|
||||||
|
|
||||||
|
result = sort_by(items, sort_conf)
|
||||||
|
|
||||||
|
assert len(result) == 3
|
||||||
|
assert result[0].name == "alice"
|
||||||
|
assert result[1].name == "bob"
|
||||||
|
assert result[2].name == "charlie"
|
||||||
|
|
||||||
|
|
||||||
|
def test_i_can_sort_by_descending_strings():
|
||||||
|
"""Test sorting strings in descending order."""
|
||||||
|
items = [
|
||||||
|
Expando({"name": "alice"}),
|
||||||
|
Expando({"name": "charlie"}),
|
||||||
|
Expando({"name": "bob"})
|
||||||
|
]
|
||||||
|
sort_conf = SortConf(property="name", ascending=False)
|
||||||
|
|
||||||
|
result = sort_by(items, sort_conf)
|
||||||
|
|
||||||
|
assert len(result) == 3
|
||||||
|
assert result[0].name == "charlie"
|
||||||
|
assert result[1].name == "bob"
|
||||||
|
assert result[2].name == "alice"
|
||||||
|
|
||||||
|
|
||||||
|
def test_i_can_handle_none_sort_conf():
|
||||||
|
"""Test that None sort configuration returns items unchanged."""
|
||||||
|
items = [
|
||||||
|
Expando({"name": "charlie"}),
|
||||||
|
Expando({"name": "alice"})
|
||||||
|
]
|
||||||
|
|
||||||
|
result = sort_by(items, None)
|
||||||
|
|
||||||
|
assert result == items
|
||||||
|
assert result[0].name == "charlie"
|
||||||
|
assert result[1].name == "alice"
|
||||||
|
|
||||||
|
|
||||||
|
def test_i_can_handle_empty_list():
|
||||||
|
"""Test that empty list returns empty list."""
|
||||||
|
items = []
|
||||||
|
sort_conf = SortConf(property="name")
|
||||||
|
|
||||||
|
result = sort_by(items, sort_conf)
|
||||||
|
|
||||||
|
assert result == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_i_can_sort_with_missing_properties():
|
||||||
|
"""Test sorting when some objects don't have the property."""
|
||||||
|
items = [
|
||||||
|
Expando({"name": "bob"}),
|
||||||
|
Expando({"age": 25}), # no name property
|
||||||
|
Expando({"name": "alice"})
|
||||||
|
]
|
||||||
|
sort_conf = SortConf(property="name", ascending=True)
|
||||||
|
|
||||||
|
result = sort_by(items, sort_conf)
|
||||||
|
|
||||||
|
assert len(result) == 3
|
||||||
|
# Items without the property should get empty string and sort first
|
||||||
|
assert result[0].geti("name", "") == "" # the one with missing property
|
||||||
|
assert result[1].name == "alice"
|
||||||
|
assert result[2].name == "bob"
|
||||||
|
|
||||||
|
|
||||||
|
def test_i_can_sort_with_none_values():
|
||||||
|
"""Test sorting when properties have None values."""
|
||||||
|
items = [
|
||||||
|
Expando({"name": "bob"}),
|
||||||
|
Expando({"name": None}),
|
||||||
|
Expando({"name": "alice"})
|
||||||
|
]
|
||||||
|
sort_conf = SortConf(property="name", ascending=True)
|
||||||
|
|
||||||
|
result = sort_by(items, sort_conf)
|
||||||
|
|
||||||
|
assert len(result) == 3
|
||||||
|
# None values should be treated as empty strings and sort first
|
||||||
|
assert result[0].name is None
|
||||||
|
assert result[1].name == "alice"
|
||||||
|
assert result[2].name == "bob"
|
||||||
|
|
||||||
|
|
||||||
|
def test_i_can_sort_with_case_insensitive_property_names():
|
||||||
|
"""Test sorting using case-insensitive property names."""
|
||||||
|
items = [
|
||||||
|
Expando({"Name": "charlie"}), # uppercase N
|
||||||
|
Expando({"Name": "alice"}),
|
||||||
|
Expando({"Name": "bob"})
|
||||||
|
]
|
||||||
|
sort_conf = SortConf(property="name", ascending=True) # lowercase n
|
||||||
|
|
||||||
|
result = sort_by(items, sort_conf)
|
||||||
|
|
||||||
|
assert len(result) == 3
|
||||||
|
assert result[0].Name == "alice"
|
||||||
|
assert result[1].Name == "bob"
|
||||||
|
assert result[2].Name == "charlie"
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def test_i_can_sort_mixed_types():
|
||||||
|
"""Test sorting with mixed types (numbers, strings, None)."""
|
||||||
|
items = [
|
||||||
|
Expando({"value": "zebra"}),
|
||||||
|
Expando({"value": 10}),
|
||||||
|
Expando({"value": None}),
|
||||||
|
Expando({"value": "apple"})
|
||||||
|
]
|
||||||
|
sort_conf = SortConf(property="value", ascending=True)
|
||||||
|
|
||||||
|
result = sort_by(items, sort_conf)
|
||||||
|
|
||||||
|
assert len(result) == 4
|
||||||
|
# Mixed types will be converted to strings for comparison
|
||||||
|
# None -> "", numbers -> str(number), strings stay strings
|
||||||
|
assert result[0].value is None # None becomes "" and sorts first
|
||||||
|
assert result[1].value == 10 # "10" sorts before "apple"
|
||||||
|
assert result[2].value == "apple" # "apple" sorts before "zebra"
|
||||||
|
assert result[3].value == "zebra"
|
||||||
|
|
||||||
|
|
||||||
|
def test_i_can_sort_with_whitespace_in_property_name():
|
||||||
|
"""Test that property names are stripped of whitespace."""
|
||||||
|
items = [
|
||||||
|
Expando({"name": "charlie"}),
|
||||||
|
Expando({"name": "alice"}),
|
||||||
|
Expando({"name": "bob"})
|
||||||
|
]
|
||||||
|
sort_conf = SortConf(property=" name ", ascending=True) # whitespace around property
|
||||||
|
|
||||||
|
result = sort_by(items, sort_conf)
|
||||||
|
|
||||||
|
assert len(result) == 3
|
||||||
|
assert result[0].name == "alice"
|
||||||
|
assert result[1].name == "bob"
|
||||||
|
assert result[2].name == "charlie"
|
||||||
Reference in New Issue
Block a user