├── src └── alga │ ├── py.typed │ ├── __init__.py │ ├── cli_version.py │ ├── cli_adhoc.py │ ├── cli_media.py │ ├── config.py │ ├── cli_remote.py │ ├── cli_power.py │ ├── types.py │ ├── __main__.py │ ├── cli_sound_output.py │ ├── cli_input.py │ ├── cli_volume.py │ ├── client.py │ ├── cli_app.py │ ├── cli_channel.py │ ├── cli_tv.py │ └── payloads.py ├── .python-version ├── tests ├── test_cli_client.py ├── test_payloads.py ├── test_cli_version.py ├── test_cli_remote.py ├── conftest.py ├── test_types.py ├── test_cli_adhoc.py ├── test_cli_media.py ├── test_cli_sound_output.py ├── test_config.py ├── test_cli_power.py ├── test_cli_input.py ├── test_cli_volume.py ├── test_cli_app.py ├── test_client.py ├── test_cli_tv.py └── test_cli_channel.py ├── .gitignore ├── generate-usage.sh ├── stubs ├── getmac.pyi └── cfgs.pyi ├── .pre-commit-config.yaml ├── .github ├── renovate.json └── workflows │ ├── release-to-pypi.yml │ └── checks.yml ├── LICENSE ├── README.md ├── flake.nix ├── pyproject.toml ├── flake.lock ├── usage.md └── uv.lock /src/alga/py.typed: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.python-version: -------------------------------------------------------------------------------- 1 | 3.14 2 | -------------------------------------------------------------------------------- /tests/test_cli_client.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .coverage 2 | __pycache__/ 3 | dist/ 4 | -------------------------------------------------------------------------------- /src/alga/__init__.py: -------------------------------------------------------------------------------- 1 | from alga.types import State 2 | 3 | 4 | __version__ = "2.2.0" 5 | 6 | state = State() 7 | -------------------------------------------------------------------------------- /generate-usage.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | typer alga.__main__ utils docs --name alga --title "Available commands" --output usage.md 4 | -------------------------------------------------------------------------------- /stubs/getmac.pyi: -------------------------------------------------------------------------------- 1 | def get_mac_address( 2 | interface: str | None = None, 3 | ip: str | None = None, 4 | ip6: str | None = None, 5 | hostname: str | None = None, 6 | network_request: bool = True, 7 | ) -> str | None: ... 8 | -------------------------------------------------------------------------------- /src/alga/cli_version.py: -------------------------------------------------------------------------------- 1 | from rich import print 2 | from typer import Typer 3 | 4 | from alga import __version__ 5 | 6 | 7 | app = Typer() 8 | 9 | 10 | @app.command() 11 | def version() -> None: 12 | """Print Alga version""" 13 | 14 | print(f"alga version [bold]{__version__}[/bold]") 15 | -------------------------------------------------------------------------------- /tests/test_payloads.py: -------------------------------------------------------------------------------- 1 | from faker import Faker 2 | 3 | from alga.payloads import get_hello_data 4 | 5 | 6 | def test_get_hello_data(faker: Faker) -> None: 7 | client_key = faker.pystr() 8 | 9 | result = get_hello_data(client_key) 10 | 11 | assert isinstance(result, dict) 12 | assert result["payload"]["client-key"] == client_key 13 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | repos: 3 | - repo: https://github.com/astral-sh/ruff-pre-commit 4 | rev: v0.14.10 5 | hooks: 6 | - id: ruff-check 7 | args: 8 | - --fix 9 | - id: ruff-format 10 | 11 | - repo: https://github.com/woodruffw/zizmor-pre-commit 12 | rev: v1.19.0 13 | hooks: 14 | - id: zizmor 15 | -------------------------------------------------------------------------------- /tests/test_cli_version.py: -------------------------------------------------------------------------------- 1 | from typer.testing import CliRunner 2 | 3 | from alga import __version__ 4 | from alga.__main__ import app 5 | 6 | 7 | runner = CliRunner() 8 | 9 | 10 | def test_version() -> None: 11 | result = runner.invoke(app, ["version"]) 12 | assert result.exit_code == 0 13 | assert result.stdout == f"alga version {__version__}\n" 14 | -------------------------------------------------------------------------------- /.github/renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json", 3 | "extends": [ 4 | "config:recommended", 5 | "helpers:pinGitHubActionDigests", 6 | ":enablePreCommit", 7 | ":maintainLockFilesWeekly", 8 | ":semanticCommitsDisabled" 9 | ], 10 | 11 | "nix": { 12 | "enabled": true 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /stubs/cfgs.pyi: -------------------------------------------------------------------------------- 1 | from typing import Any 2 | 3 | class App: 4 | config: Directory 5 | 6 | def __init__(self, name: str, format: str = ...) -> None: ... 7 | 8 | class Directory: 9 | def __init__(self, home: str, dirs: str, format: str) -> None: ... 10 | def open(self, filename: str | None = None) -> File: ... 11 | 12 | class File: 13 | contents: dict[str, Any] 14 | def write(self) -> None: ... 15 | -------------------------------------------------------------------------------- /src/alga/cli_adhoc.py: -------------------------------------------------------------------------------- 1 | import json 2 | from typing import Annotated 3 | 4 | from rich import print 5 | from typer import Argument, Typer 6 | 7 | from alga import client 8 | 9 | 10 | app = Typer() 11 | 12 | 13 | @app.command() 14 | def adhoc(path: str, data: Annotated[str | None, Argument()] = None) -> None: 15 | """Send raw request to the TV""" 16 | 17 | if data: 18 | print(client.request(path, json.loads(data))) 19 | else: 20 | print(client.request(path)) 21 | -------------------------------------------------------------------------------- /tests/test_cli_remote.py: -------------------------------------------------------------------------------- 1 | from unittest.mock import MagicMock 2 | 3 | from faker import Faker 4 | from typer.testing import CliRunner 5 | 6 | from alga.__main__ import app 7 | 8 | 9 | runner = CliRunner() 10 | 11 | 12 | def test_send(faker: Faker, mock_input: MagicMock) -> None: 13 | button = faker.pystr() 14 | 15 | result = runner.invoke(app, ["remote", "send", button]) 16 | 17 | mock_input.assert_called_once_with(f"type:button\nname:{button.upper()}\n\n") 18 | assert result.exit_code == 0 19 | assert result.stdout == "" 20 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | from collections.abc import Iterator 2 | from unittest.mock import MagicMock, patch 3 | 4 | import pytest 5 | 6 | 7 | @pytest.fixture 8 | def mock_request() -> Iterator[MagicMock]: 9 | with patch("alga.client.request") as mocked: 10 | yield mocked 11 | 12 | 13 | @pytest.fixture 14 | def mock_input() -> Iterator[MagicMock]: 15 | with patch("alga.cli_remote._input_connection") as mocked: 16 | yield mocked.return_value.__enter__.return_value.send 17 | 18 | 19 | @pytest.fixture 20 | def mock_config() -> Iterator[MagicMock]: 21 | with patch("alga.config.get") as mocked: 22 | yield mocked 23 | 24 | 25 | @pytest.fixture 26 | def mock_config_file() -> Iterator[MagicMock]: 27 | with patch("alga.config._config_file") as mocked: 28 | yield mocked 29 | -------------------------------------------------------------------------------- /src/alga/cli_media.py: -------------------------------------------------------------------------------- 1 | from typer import Typer 2 | 3 | from alga import client 4 | 5 | 6 | app = Typer(no_args_is_help=True, help="Control the playing media") 7 | 8 | 9 | @app.command() 10 | def fast_forward() -> None: 11 | """Fast forward media""" 12 | 13 | client.request("ssap://media.controls/fastForward") 14 | 15 | 16 | @app.command() 17 | def pause() -> None: 18 | """Pause media""" 19 | 20 | client.request("ssap://media.controls/pause") 21 | 22 | 23 | @app.command() 24 | def play() -> None: 25 | """Play media""" 26 | 27 | client.request("ssap://media.controls/play") 28 | 29 | 30 | @app.command() 31 | def rewind() -> None: 32 | """Rewind media""" 33 | 34 | client.request("ssap://media.controls/rewind") 35 | 36 | 37 | @app.command() 38 | def stop() -> None: 39 | """Stop media""" 40 | 41 | client.request("ssap://media.controls/stop") 42 | -------------------------------------------------------------------------------- /tests/test_types.py: -------------------------------------------------------------------------------- 1 | from faker import Faker 2 | 3 | from alga.types import App, Channel, InputDevice, SoundOutputDevice 4 | 5 | 6 | def test_app(faker: Faker) -> None: 7 | id_, title = faker.pystr(), faker.pystr() 8 | app = App({"id": id_, "title": title}) 9 | 10 | assert str(app) == f"{title} ({id_})" 11 | 12 | 13 | def test_channel(faker: Faker) -> None: 14 | number, name = faker.pystr(), faker.pystr() 15 | channel = Channel( 16 | {"channelId": faker.pystr(), "channelNumber": number, "channelName": name} 17 | ) 18 | 19 | assert str(channel) == f"{number}: {name}" 20 | 21 | 22 | def test_input_device(faker: Faker) -> None: 23 | id_, name = faker.pystr(), faker.pystr() 24 | input_device = InputDevice({"id": id_, "label": name}) 25 | 26 | assert str(input_device) == f"{name} ({id_})" 27 | 28 | 29 | def test_sound_output_device(faker: Faker) -> None: 30 | name = faker.pystr() 31 | sound_output_device = SoundOutputDevice(faker.pystr(), name) 32 | 33 | assert str(sound_output_device) == name 34 | -------------------------------------------------------------------------------- /src/alga/config.py: -------------------------------------------------------------------------------- 1 | from typing import Any 2 | 3 | from cfgs import App 4 | 5 | 6 | _config_file = App("alga").config.open("config.json") 7 | _latest_version = 2 8 | 9 | 10 | def get() -> dict[str, Any]: 11 | config = _config_file.contents 12 | 13 | if config.setdefault("version", _latest_version) < _latest_version: 14 | config = migrate(config) 15 | write(config) 16 | 17 | config.setdefault("tvs", {}) 18 | 19 | return config 20 | 21 | 22 | def migrate(config: dict[str, Any]) -> dict[str, Any]: 23 | if config["version"] == 1: 24 | config = { 25 | "version": 2, 26 | "default_tv": "default", 27 | "tvs": { 28 | "default": { 29 | "hostname": config["hostname"], 30 | "key": config["key"], 31 | "mac": config["mac"], 32 | } 33 | }, 34 | } 35 | 36 | return config 37 | 38 | 39 | def write(config: dict[str, Any]) -> None: 40 | _config_file.contents = config 41 | _config_file.write() 42 | -------------------------------------------------------------------------------- /src/alga/cli_remote.py: -------------------------------------------------------------------------------- 1 | import ssl 2 | from collections.abc import Iterator 3 | from contextlib import contextmanager 4 | from typing import Annotated 5 | 6 | from typer import Argument, Typer 7 | from websocket import WebSocket 8 | 9 | from alga import client 10 | 11 | 12 | app = Typer(no_args_is_help=True, help="Remote control button presses") 13 | 14 | 15 | @contextmanager 16 | def _input_connection() -> Iterator[WebSocket]: # pragma: no cover 17 | response = client.request( 18 | "ssap://com.webos.service.networkinput/getPointerInputSocket" 19 | ) 20 | 21 | connection = WebSocket(sslopt={"cert_reqs": ssl.CERT_NONE}) 22 | connection.connect(response["socketPath"], suppress_origin=True, timeout=3) # type: ignore[no-untyped-call] 23 | 24 | try: 25 | yield connection 26 | finally: 27 | connection.close() 28 | 29 | 30 | @app.command() 31 | def send(button: Annotated[str, Argument()]) -> None: 32 | """Send a button press to the TV""" 33 | with _input_connection() as connection: 34 | connection.send(f"type:button\nname:{button.upper()}\n\n") 35 | -------------------------------------------------------------------------------- /tests/test_cli_adhoc.py: -------------------------------------------------------------------------------- 1 | import json 2 | from unittest.mock import MagicMock 3 | 4 | from faker import Faker 5 | from typer.testing import CliRunner 6 | 7 | from alga.__main__ import app 8 | 9 | 10 | runner = CliRunner() 11 | 12 | 13 | def test_without_data(faker: Faker, mock_request: MagicMock) -> None: 14 | path = faker.pystr() 15 | return_value = faker.pystr() 16 | mock_request.return_value = return_value 17 | 18 | result = runner.invoke(app, ["adhoc", path]) 19 | 20 | mock_request.assert_called_once_with(path) 21 | assert result.exit_code == 0 22 | assert result.stdout == f"{return_value}\n" 23 | 24 | 25 | def test_with_data(faker: Faker, mock_request: MagicMock) -> None: 26 | path = faker.pystr() 27 | data = faker.pydict(allowed_types=[str, float, int]) 28 | return_value = faker.pystr() 29 | mock_request.return_value = return_value 30 | 31 | result = runner.invoke(app, ["adhoc", path, json.dumps(data)]) 32 | 33 | mock_request.assert_called_once_with(path, data) 34 | assert result.exit_code == 0 35 | assert result.stdout == f"{return_value}\n" 36 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Jeppe Fihl-Pearson 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/alga/cli_power.py: -------------------------------------------------------------------------------- 1 | from rich import print 2 | from typer import Typer 3 | from wakeonlan import send_magic_packet 4 | 5 | from alga import client, config, state 6 | 7 | 8 | app = Typer(no_args_is_help=True, help="Turn TV (or screen) on and off") 9 | 10 | 11 | @app.command() 12 | def off() -> None: 13 | """Turn TV off""" 14 | 15 | client.request("ssap://system/turnOff") 16 | 17 | 18 | @app.command() 19 | def on() -> None: 20 | """Turn TV on via Wake-on-LAN""" 21 | 22 | cfg = config.get() 23 | 24 | tv_id = state.tv_id or cfg["default_tv"] 25 | tv = cfg["tvs"][tv_id] 26 | 27 | send_magic_packet(tv["mac"]) 28 | 29 | 30 | @app.command() 31 | def screen_off() -> None: 32 | """Turn TV screen off""" 33 | 34 | client.request("ssap://com.webos.service.tvpower/power/turnOffScreen") 35 | 36 | 37 | @app.command() 38 | def screen_on() -> None: 39 | """Turn TV screen on""" 40 | 41 | client.request("ssap://com.webos.service.tvpower/power/turnOnScreen") 42 | 43 | 44 | @app.command() 45 | def screen_state() -> None: 46 | """Show if TV screen is active or off""" 47 | 48 | response = client.request("ssap://com.webos.service.tvpower/power/getPowerState") 49 | print(f"The TV screen is currently: [bold]{response['state']}[/bold]") 50 | -------------------------------------------------------------------------------- /src/alga/types.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | from typing import Any 3 | 4 | 5 | @dataclass 6 | class App: 7 | id_: str 8 | name: str 9 | 10 | def __init__(self, app: dict[str, Any]) -> None: 11 | self.id_ = app["id"] 12 | self.name = app["title"] 13 | 14 | def __str__(self) -> str: 15 | return f"{self.name} ({self.id_})" 16 | 17 | 18 | @dataclass 19 | class Channel: 20 | id_: str 21 | number: str 22 | name: str 23 | 24 | def __init__(self, channel: dict[str, Any]) -> None: 25 | self.id_ = channel["channelId"] 26 | self.number = channel["channelNumber"] 27 | self.name = channel["channelName"] 28 | 29 | def __str__(self) -> str: 30 | return f"{self.number}: {self.name}" 31 | 32 | 33 | @dataclass 34 | class InputDevice: 35 | id_: str 36 | name: str 37 | 38 | def __init__(self, input_device: dict[str, Any]) -> None: 39 | self.id_ = input_device["id"] 40 | self.name = input_device["label"] 41 | 42 | def __str__(self) -> str: 43 | return f"{self.name} ({self.id_})" 44 | 45 | 46 | @dataclass 47 | class SoundOutputDevice: 48 | id_: str 49 | name: str 50 | 51 | def __str__(self) -> str: 52 | return self.name 53 | 54 | 55 | @dataclass 56 | class State: 57 | tv_id: str | None = None 58 | timeout: int | None = None 59 | -------------------------------------------------------------------------------- /src/alga/__main__.py: -------------------------------------------------------------------------------- 1 | from typing import Annotated 2 | 3 | from typer import Option, Typer 4 | 5 | from alga import ( 6 | cli_adhoc, 7 | cli_app, 8 | cli_channel, 9 | cli_input, 10 | cli_media, 11 | cli_power, 12 | cli_remote, 13 | cli_sound_output, 14 | cli_tv, 15 | cli_version, 16 | cli_volume, 17 | state, 18 | ) 19 | 20 | 21 | def global_options( 22 | tv: Annotated[ 23 | str | None, Option(help="Specify which TV the command should be sent to") 24 | ] = None, 25 | timeout: Annotated[ 26 | int | None, 27 | Option(help="Number of seconds to wait before a response (default 10)"), 28 | ] = None, 29 | ) -> None: 30 | state.tv_id = tv 31 | state.timeout = timeout 32 | 33 | 34 | app = Typer(no_args_is_help=True, callback=global_options) 35 | app.add_typer(cli_adhoc.app) 36 | app.add_typer(cli_app.app, name="app") 37 | app.add_typer(cli_channel.app, name="channel") 38 | app.add_typer(cli_input.app, name="input") 39 | app.add_typer(cli_media.app, name="media") 40 | app.add_typer(cli_power.app, name="power") 41 | app.add_typer(cli_remote.app, name="remote") 42 | app.add_typer(cli_sound_output.app, name="sound-output") 43 | app.add_typer(cli_tv.app, name="tv") 44 | app.add_typer(cli_version.app) 45 | app.add_typer(cli_volume.app, name="volume") 46 | 47 | 48 | if __name__ == "__main__": 49 | app() 50 | -------------------------------------------------------------------------------- /src/alga/cli_sound_output.py: -------------------------------------------------------------------------------- 1 | from typing import Annotated 2 | 3 | from pzp import pzp 4 | from rich import print 5 | from typer import Argument, Typer 6 | 7 | from alga import client 8 | from alga.types import SoundOutputDevice 9 | 10 | 11 | app = Typer(no_args_is_help=True, help="Audio output device") 12 | 13 | 14 | @app.command() 15 | def get() -> None: 16 | """Show the current output device""" 17 | 18 | response = client.request("ssap://audio/getSoundOutput") 19 | print(f"The current sound output is [bold]{response['soundOutput']}[/bold]") 20 | 21 | 22 | @app.command() 23 | def pick() -> None: 24 | """Show picker for selecting a sound output device.""" 25 | 26 | sound_output_device = pzp( 27 | candidates=[ 28 | SoundOutputDevice("tv_speaker", "TV Speaker"), 29 | SoundOutputDevice("external_optical", "Optical Out Device"), 30 | SoundOutputDevice("tv_external_speaker", "Optical Out Device + TV Speaker"), 31 | SoundOutputDevice("external_arc", "HDMI (ARC) Device"), 32 | ], 33 | fullscreen=False, 34 | layout="reverse", 35 | ) 36 | if sound_output_device: 37 | client.request( 38 | "ssap://audio/changeSoundOutput", {"output": sound_output_device.id_} 39 | ) 40 | 41 | 42 | @app.command() 43 | def set(value: Annotated[str, Argument()]) -> None: 44 | """Change the output device""" 45 | 46 | client.request("ssap://audio/changeSoundOutput", {"output": value}) 47 | -------------------------------------------------------------------------------- /tests/test_cli_media.py: -------------------------------------------------------------------------------- 1 | from unittest.mock import MagicMock 2 | 3 | from typer.testing import CliRunner 4 | 5 | from alga.__main__ import app 6 | 7 | 8 | runner = CliRunner() 9 | 10 | 11 | def test_play(mock_request: MagicMock) -> None: 12 | result = runner.invoke(app, ["media", "play"]) 13 | 14 | mock_request.assert_called_once_with("ssap://media.controls/play") 15 | assert result.exit_code == 0 16 | assert result.stdout == "" 17 | 18 | 19 | def test_pause(mock_request: MagicMock) -> None: 20 | result = runner.invoke(app, ["media", "pause"]) 21 | 22 | mock_request.assert_called_once_with("ssap://media.controls/pause") 23 | assert result.exit_code == 0 24 | assert result.stdout == "" 25 | 26 | 27 | def test_stop(mock_request: MagicMock) -> None: 28 | result = runner.invoke(app, ["media", "stop"]) 29 | 30 | mock_request.assert_called_once_with("ssap://media.controls/stop") 31 | assert result.exit_code == 0 32 | assert result.stdout == "" 33 | 34 | 35 | def test_fast_forward(mock_request: MagicMock) -> None: 36 | result = runner.invoke(app, ["media", "fast-forward"]) 37 | 38 | mock_request.assert_called_once_with("ssap://media.controls/fastForward") 39 | assert result.exit_code == 0 40 | assert result.stdout == "" 41 | 42 | 43 | def test_rewind(mock_request: MagicMock) -> None: 44 | result = runner.invoke(app, ["media", "rewind"]) 45 | 46 | mock_request.assert_called_once_with("ssap://media.controls/rewind") 47 | assert result.exit_code == 0 48 | assert result.stdout == "" 49 | -------------------------------------------------------------------------------- /src/alga/cli_input.py: -------------------------------------------------------------------------------- 1 | from typing import Annotated 2 | 3 | from pzp import pzp 4 | from rich.console import Console 5 | from rich.table import Table 6 | from typer import Argument, Typer 7 | 8 | from alga import client 9 | from alga.types import InputDevice 10 | 11 | 12 | app = Typer(no_args_is_help=True, help="HDMI and similar inputs") 13 | 14 | 15 | @app.command() 16 | def list() -> None: 17 | """List available inputs""" 18 | 19 | response = client.request("ssap://tv/getExternalInputList") 20 | 21 | table = Table() 22 | table.add_column("Name") 23 | table.add_column("ID") 24 | 25 | all_inputs = [] 26 | for i in response["devices"]: 27 | all_inputs.append([i["label"], i["id"]]) 28 | 29 | for row in all_inputs: 30 | table.add_row(*row) 31 | 32 | console = Console() 33 | console.print(table) 34 | 35 | 36 | @app.command() 37 | def pick() -> None: 38 | """Show picker for selecting an input.""" 39 | 40 | response = client.request("ssap://tv/getExternalInputList") 41 | input_devices = [] 42 | 43 | for input_device in response["devices"]: 44 | input_devices.append(InputDevice(input_device)) 45 | 46 | input_device = pzp(candidates=input_devices, fullscreen=False, layout="reverse") 47 | if input_device: 48 | client.request("ssap://tv/switchInput", {"inputId": input_device.id_}) 49 | 50 | 51 | @app.command() 52 | def set(value: Annotated[str, Argument()]) -> None: 53 | """Switch to given input""" 54 | 55 | client.request("ssap://tv/switchInput", {"inputId": value}) 56 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Alga 2 | ==== 3 | 4 | A command line utility for controlling a LG webOS TV over the network. 5 | 6 | 7 | Installing 8 | ---------- 9 | 10 | Alga is [available on PyPI](https://pypi.org/project/alga/). 11 | I would recommend installing it via [pipx](https://pipx.pypa.io/stable/): 12 | 13 | ```shell 14 | $ pipx install alga 15 | ``` 16 | 17 | Or, via [Nix flakes](https://nixos.org/): 18 | 19 | ```shell 20 | nix run github:Tenzer/alga 21 | ``` 22 | 23 | 24 | Setup 25 | ----- 26 | 27 | The first time you use the utility, you will need to setup a connection to the TV. 28 | With the TV on, run `alga tv add [hostname/IP]`. 29 | This will bring up a prompt on the TV asking if you want to accept the pairing. 30 | When accepted, Alga will be ready to use. 31 | 32 | If no hostname or IP address is provided to `alga tv add`, it will be default try to connect to "lgwebostv" which should work. 33 | 34 | The hostname, a key and MAC address will be written to `~/.config/alga/config.json` for future use. 35 | 36 | 37 | Usage 38 | ----- 39 | 40 | See [usage](usage.md) for a list of available commands. 41 | 42 | 43 | Development 44 | ----------- 45 | 46 | The code base is fully type annotated and test coverage is being enforced. 47 | Types can be checked via `uv run ty check .` and tests via `uv run pytest`. 48 | 49 | Tests are run for each of the supported Python versions in CI. 50 | 51 | [pre-commit](https://pre-commit.com/)/[prek](https://prek.j178.dev/) is used to run Ruff for linting and formatting. 52 | 53 | `usage.md` is updated via `uv run ./generate-usage.sh`. 54 | -------------------------------------------------------------------------------- /src/alga/cli_volume.py: -------------------------------------------------------------------------------- 1 | from typing import Annotated 2 | 3 | from rich import print 4 | from typer import Argument, Typer 5 | 6 | from alga import client 7 | 8 | 9 | app = Typer(no_args_is_help=True, help="Audio volume") 10 | 11 | 12 | @app.command() 13 | def down() -> None: 14 | """Turn volume down""" 15 | 16 | client.request("ssap://audio/volumeDown") 17 | 18 | 19 | @app.command() 20 | def get() -> None: 21 | """Get current volume""" 22 | 23 | response = client.request("ssap://audio/getVolume") 24 | 25 | if "volume" in response: 26 | # webOS 3.0 27 | volume = response["volume"] 28 | muted = response["muted"] 29 | else: 30 | # Newer versions 31 | volume = response["volumeStatus"]["volume"] 32 | muted = response["volumeStatus"]["muteStatus"] 33 | 34 | print( 35 | f"Volume is currently set to [bold]{volume}[/bold] and is currently {'[red]' if muted else '[green]not '}muted" 36 | ) 37 | 38 | 39 | @app.command() 40 | def mute() -> None: 41 | """Mute audio""" 42 | 43 | client.request("ssap://audio/setMute", {"mute": True}) 44 | 45 | 46 | @app.command() 47 | def set(value: Annotated[int, Argument()]) -> None: 48 | """Set volume to specific amount""" 49 | 50 | client.request("ssap://audio/setVolume", {"volume": value}) 51 | 52 | 53 | @app.command() 54 | def unmute() -> None: 55 | """Unmute audio""" 56 | 57 | client.request("ssap://audio/setMute", {"mute": False}) 58 | 59 | 60 | @app.command() 61 | def up() -> None: 62 | """Turn volume up""" 63 | 64 | client.request("ssap://audio/volumeUp") 65 | -------------------------------------------------------------------------------- /tests/test_cli_sound_output.py: -------------------------------------------------------------------------------- 1 | from unittest.mock import MagicMock, patch 2 | 3 | from faker import Faker 4 | from typer.testing import CliRunner 5 | 6 | from alga.__main__ import app 7 | from alga.types import SoundOutputDevice 8 | 9 | 10 | runner = CliRunner() 11 | 12 | 13 | def test_get(faker: Faker, mock_request: MagicMock) -> None: 14 | sound_output = faker.pystr() 15 | mock_request.return_value = {"soundOutput": sound_output} 16 | 17 | result = runner.invoke(app, ["sound-output", "get"]) 18 | 19 | mock_request.assert_called_once_with("ssap://audio/getSoundOutput") 20 | assert result.exit_code == 0 21 | assert result.stdout == f"The current sound output is {sound_output}\n" 22 | 23 | 24 | def test_set(faker: Faker, mock_request: MagicMock) -> None: 25 | sound_output = faker.pystr() 26 | 27 | result = runner.invoke(app, ["sound-output", "set", sound_output]) 28 | 29 | mock_request.assert_called_once_with( 30 | "ssap://audio/changeSoundOutput", {"output": sound_output} 31 | ) 32 | assert result.exit_code == 0 33 | assert result.stdout == "" 34 | 35 | 36 | def test_pick(faker: Faker, mock_request: MagicMock) -> None: 37 | sound_output_device = SoundOutputDevice(faker.pystr(), faker.pystr()) 38 | 39 | with patch("alga.cli_sound_output.pzp") as mock_pzp: 40 | mock_pzp.return_value = sound_output_device 41 | 42 | result = runner.invoke(app, ["sound-output", "pick"]) 43 | 44 | mock_request.assert_called_once_with( 45 | "ssap://audio/changeSoundOutput", {"output": sound_output_device.id_} 46 | ) 47 | assert result.exit_code == 0 48 | -------------------------------------------------------------------------------- /.github/workflows/release-to-pypi.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: Release to PyPI 3 | 4 | on: 5 | push: 6 | tags: 7 | - v* 8 | 9 | jobs: 10 | build: 11 | runs-on: ubuntu-24.04 12 | 13 | permissions: 14 | attestations: write 15 | contents: read 16 | id-token: write 17 | 18 | steps: 19 | - name: Checkout the code 20 | uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 21 | with: 22 | persist-credentials: false 23 | 24 | - name: Install uv 25 | uses: astral-sh/setup-uv@681c641aba71e4a1c380be3ab5e12ad51f415867 # v7.1.6 26 | with: 27 | enable-cache: false 28 | 29 | - name: Install dependencies 30 | run: uv sync --frozen 31 | 32 | - name: Build package 33 | run: uv build 34 | 35 | - name: Attest 36 | uses: actions/attest-build-provenance@00014ed6ed5efc5b1ab7f7f34a39eb55d41aa4f8 # v3.1.0 37 | with: 38 | subject-path: dist/* 39 | 40 | - name: Store release files 41 | uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 42 | with: 43 | name: release 44 | path: dist/ 45 | 46 | upload: 47 | runs-on: ubuntu-24.04 48 | needs: 49 | - build 50 | 51 | permissions: 52 | id-token: write 53 | 54 | steps: 55 | - name: Fetch release files 56 | uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0 57 | with: 58 | name: release 59 | path: dist/ 60 | 61 | - name: Publish to PyPI 62 | uses: pypa/gh-action-pypi-publish@ed0c53931b1dc9bd32cbe73a98c7f6766f8a527e # v1.13.0 63 | -------------------------------------------------------------------------------- /tests/test_config.py: -------------------------------------------------------------------------------- 1 | from unittest.mock import MagicMock, patch 2 | 3 | from faker import Faker 4 | 5 | from alga import config 6 | 7 | 8 | def test_get_sets_defaults(mock_config_file: MagicMock) -> None: 9 | mock_config_file.contents = {} 10 | 11 | cfg = config.get() 12 | 13 | assert cfg == {"version": 2, "tvs": {}} 14 | 15 | 16 | def test_get_does_not_override_version( 17 | mock_config_file: MagicMock, faker: Faker 18 | ) -> None: 19 | version = faker.pyint() 20 | mock_config_file.contents = {"version": version} 21 | 22 | cfg = config.get() 23 | 24 | assert cfg == {"version": version, "tvs": {}} 25 | 26 | 27 | def test_get_returns_data(mock_config_file: MagicMock, faker: Faker) -> None: 28 | data = {"version": faker.pyint(), "tvs": {}} | faker.pydict() 29 | mock_config_file.contents = data 30 | 31 | cfg = config.get() 32 | 33 | assert cfg == data 34 | 35 | 36 | def test_get_calls_migrate(mock_config_file: MagicMock) -> None: 37 | mock_config_file.contents = {"version": 1} 38 | 39 | with patch("alga.config.migrate") as mock_migrate: 40 | config.get() 41 | 42 | mock_migrate.assert_called_once() 43 | 44 | 45 | def test_write(mock_config_file: MagicMock, faker: Faker) -> None: 46 | data = faker.pydict() 47 | 48 | config.write(data) 49 | 50 | mock_config_file.write.assert_called_once() 51 | assert mock_config_file.contents == data 52 | 53 | 54 | def test_migrate_v1_to_v2(faker: Faker) -> None: 55 | hostname, key, mac = faker.pystr(), faker.pystr(), faker.pystr() 56 | v1_config = {"version": 1, "hostname": hostname, "key": key, "mac": mac} 57 | 58 | assert config.migrate(v1_config) == { 59 | "version": 2, 60 | "default_tv": "default", 61 | "tvs": {"default": {"hostname": hostname, "key": key, "mac": mac}}, 62 | } 63 | -------------------------------------------------------------------------------- /flake.nix: -------------------------------------------------------------------------------- 1 | { 2 | description = "CLI for remote controlling LG webOS TVs"; 3 | 4 | inputs = { 5 | flake-utils.url = "github:numtide/flake-utils"; 6 | 7 | nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; 8 | 9 | pyproject-nix = { 10 | url = "github:pyproject-nix/pyproject.nix"; 11 | inputs.nixpkgs.follows = "nixpkgs"; 12 | }; 13 | 14 | uv2nix = { 15 | url = "github:pyproject-nix/uv2nix"; 16 | inputs.pyproject-nix.follows = "pyproject-nix"; 17 | inputs.nixpkgs.follows = "nixpkgs"; 18 | }; 19 | 20 | pyproject-build-systems = { 21 | url = "github:pyproject-nix/build-system-pkgs"; 22 | inputs.pyproject-nix.follows = "pyproject-nix"; 23 | inputs.uv2nix.follows = "uv2nix"; 24 | inputs.nixpkgs.follows = "nixpkgs"; 25 | }; 26 | }; 27 | 28 | outputs = 29 | { 30 | self, 31 | flake-utils, 32 | nixpkgs, 33 | uv2nix, 34 | pyproject-nix, 35 | pyproject-build-systems, 36 | ... 37 | }: 38 | flake-utils.lib.eachDefaultSystem ( 39 | system: 40 | let 41 | inherit (nixpkgs) lib; 42 | 43 | workspace = uv2nix.lib.workspace.loadWorkspace { workspaceRoot = ./.; }; 44 | overlay = workspace.mkPyprojectOverlay { sourcePreference = "wheel"; }; 45 | 46 | pkgs = nixpkgs.legacyPackages.${system}; 47 | python = pkgs.python313; 48 | 49 | pythonSet = (pkgs.callPackage pyproject-nix.build.packages { inherit python; }).overrideScope ( 50 | lib.composeManyExtensions [ 51 | pyproject-build-systems.overlays.default 52 | overlay 53 | ] 54 | ); 55 | 56 | inherit (pkgs.callPackages pyproject-nix.build.util { }) mkApplication; 57 | package = mkApplication { 58 | venv = pythonSet.mkVirtualEnv "alga" workspace.deps.default; 59 | package = pythonSet.alga; 60 | }; 61 | in 62 | { 63 | packages = { 64 | alga = package; 65 | default = package; 66 | }; 67 | legacyPackages = pkgs; 68 | } 69 | ); 70 | } 71 | -------------------------------------------------------------------------------- /tests/test_cli_power.py: -------------------------------------------------------------------------------- 1 | from unittest.mock import MagicMock, patch 2 | 3 | from faker import Faker 4 | from typer.testing import CliRunner 5 | 6 | from alga.__main__ import app 7 | 8 | 9 | runner = CliRunner() 10 | 11 | 12 | def test_off(mock_request: MagicMock) -> None: 13 | result = runner.invoke(app, ["power", "off"]) 14 | 15 | mock_request.assert_called_once_with("ssap://system/turnOff") 16 | assert result.exit_code == 0 17 | assert result.stdout == "" 18 | 19 | 20 | def test_screen_off(mock_request: MagicMock) -> None: 21 | result = runner.invoke(app, ["power", "screen-off"]) 22 | 23 | mock_request.assert_called_once_with( 24 | "ssap://com.webos.service.tvpower/power/turnOffScreen" 25 | ) 26 | assert result.exit_code == 0 27 | assert result.stdout == "" 28 | 29 | 30 | def test_screen_on(mock_request: MagicMock) -> None: 31 | result = runner.invoke(app, ["power", "screen-on"]) 32 | 33 | mock_request.assert_called_once_with( 34 | "ssap://com.webos.service.tvpower/power/turnOnScreen" 35 | ) 36 | assert result.exit_code == 0 37 | assert result.stdout == "" 38 | 39 | 40 | def test_on(mock_config: MagicMock, faker: Faker) -> None: 41 | mac_address = faker.pystr() 42 | mock_config.return_value = { 43 | "default_tv": "default", 44 | "tvs": {"default": {"mac": mac_address}}, 45 | } 46 | 47 | with patch("alga.cli_power.send_magic_packet") as mock_send_magic_packet: 48 | result = runner.invoke(app, ["power", "on"]) 49 | 50 | mock_send_magic_packet.assert_called_once_with(mac_address) 51 | assert result.exit_code == 0 52 | assert result.stdout == "" 53 | 54 | 55 | def test_screen_status(mock_request: MagicMock, faker: Faker) -> None: 56 | state = faker.pystr() 57 | mock_request.return_value = {"state": state} 58 | 59 | result = runner.invoke(app, ["power", "screen-state"]) 60 | 61 | mock_request.assert_called_once_with( 62 | "ssap://com.webos.service.tvpower/power/getPowerState" 63 | ) 64 | assert result.exit_code == 0 65 | assert result.stdout == f"The TV screen is currently: {state}\n" 66 | -------------------------------------------------------------------------------- /src/alga/client.py: -------------------------------------------------------------------------------- 1 | import json 2 | import ssl 3 | from collections.abc import Iterator 4 | from contextlib import contextmanager 5 | from typing import Any, cast 6 | 7 | from rich import print 8 | from typer import Exit 9 | from websocket import WebSocket 10 | 11 | from alga import config, state 12 | from alga.payloads import get_hello_data 13 | 14 | 15 | @contextmanager 16 | def connect(hostname: str, timeout: int | None = None) -> Iterator[WebSocket]: 17 | connection = WebSocket(sslopt={"cert_reqs": ssl.CERT_NONE}) 18 | connection.connect( 19 | f"wss://{hostname}:3001/", 20 | suppress_origin=True, 21 | timeout=state.timeout or timeout or 10, 22 | ) # type: ignore[no-untyped-call] 23 | 24 | try: 25 | yield connection 26 | finally: 27 | connection.close() 28 | 29 | 30 | def do_handshake(connection: WebSocket, key: str) -> None: 31 | connection.send(json.dumps(get_hello_data(key))) 32 | response = json.loads(connection.recv()) 33 | if "client-key" not in response["payload"]: 34 | raise Exception( 35 | f"Something went wrong with performing a handshake. Response: {response}" 36 | ) 37 | 38 | 39 | def request(uri: str, data: dict[str, Any] | None = None) -> dict[str, Any]: 40 | cfg = config.get() 41 | tv_id = state.tv_id or cfg.get("default_tv") 42 | 43 | if not tv_id: 44 | print("[red]No connection configured, run 'alga tv add' first[/red]") 45 | raise Exit(code=1) 46 | 47 | if tv_id not in cfg["tvs"]: 48 | print(f"[red]'{tv_id}' was not found in the configuration[/red]") 49 | raise Exit(code=1) 50 | 51 | tv = cfg["tvs"][tv_id] 52 | 53 | with connect(tv["hostname"]) as connection: 54 | do_handshake(connection, tv["key"]) 55 | 56 | request: dict[str, Any] = {"type": "request", "uri": uri} 57 | 58 | if data: 59 | request.update(payload=data) 60 | 61 | connection.send(json.dumps(request)) 62 | 63 | raw_response = connection.recv() 64 | response = json.loads(raw_response) 65 | 66 | assert response.get("payload", {}).get("returnValue") is True 67 | return cast(dict[str, Any], response["payload"]) 68 | -------------------------------------------------------------------------------- /tests/test_cli_input.py: -------------------------------------------------------------------------------- 1 | from unittest.mock import MagicMock, call, patch 2 | 3 | from faker import Faker 4 | from typer.testing import CliRunner 5 | 6 | from alga.__main__ import app 7 | from alga.types import InputDevice 8 | 9 | 10 | runner = CliRunner() 11 | 12 | 13 | def test_set(faker: Faker, mock_request: MagicMock) -> None: 14 | input_name = faker.pystr() 15 | 16 | result = runner.invoke(app, ["input", "set", input_name]) 17 | 18 | mock_request.assert_called_once_with( 19 | "ssap://tv/switchInput", {"inputId": input_name} 20 | ) 21 | assert result.exit_code == 0 22 | assert result.stdout == "" 23 | 24 | 25 | def test_list(faker: Faker, mock_request: MagicMock) -> None: 26 | return_value = { 27 | "devices": [ 28 | {"id": faker.pystr(), "label": faker.pystr()}, 29 | {"id": faker.pystr(), "label": faker.pystr()}, 30 | {"id": faker.pystr(), "label": faker.pystr()}, 31 | ] 32 | } 33 | mock_request.return_value = return_value 34 | 35 | result = runner.invoke(app, ["input", "list"]) 36 | 37 | mock_request.assert_called_once_with("ssap://tv/getExternalInputList") 38 | assert result.exit_code == 0 39 | 40 | splitted_output = result.stdout.split("\n") 41 | assert len(splitted_output) == ( 42 | 3 # table header 43 | + 3 # inputs 44 | + 1 # table footer 45 | + 1 # trailing newline 46 | ) 47 | 48 | 49 | def test_pick(faker: Faker, mock_request: MagicMock) -> None: 50 | return_value = { 51 | "devices": [ 52 | {"id": faker.pystr(), "label": faker.pystr()}, 53 | {"id": faker.pystr(), "label": faker.pystr()}, 54 | {"id": faker.pystr(), "label": faker.pystr()}, 55 | ] 56 | } 57 | mock_request.return_value = return_value 58 | first_input = return_value["devices"][0] 59 | 60 | with patch("alga.cli_input.pzp") as mock_pzp: 61 | mock_pzp.return_value = InputDevice(first_input) 62 | 63 | result = runner.invoke(app, ["input", "pick"]) 64 | 65 | mock_request.assert_has_calls( 66 | [ 67 | call("ssap://tv/getExternalInputList"), 68 | call("ssap://tv/switchInput", {"inputId": first_input["id"]}), 69 | ] 70 | ) 71 | assert result.exit_code == 0 72 | -------------------------------------------------------------------------------- /src/alga/cli_app.py: -------------------------------------------------------------------------------- 1 | import json 2 | from typing import Annotated 3 | 4 | from pzp import pzp 5 | from rich import print 6 | from rich.console import Console 7 | from rich.table import Table 8 | from typer import Argument, Typer 9 | 10 | from alga import client 11 | from alga.types import App 12 | 13 | 14 | app = Typer(no_args_is_help=True, help="Apps installed on the TV") 15 | 16 | 17 | @app.command() 18 | def close(app_id: Annotated[str, Argument()]) -> None: 19 | """Close the provided app""" 20 | 21 | client.request("ssap://system.launcher/close", {"id": app_id}) 22 | 23 | 24 | @app.command() 25 | def current() -> None: 26 | """Get the current app""" 27 | 28 | response = client.request( 29 | "ssap://com.webos.applicationManager/getForegroundAppInfo" 30 | ) 31 | print(f"The current app is [bold]{response['appId']}[/bold]") 32 | 33 | 34 | @app.command() 35 | def info(app_id: str) -> None: 36 | """Show info about specific app""" 37 | 38 | response = client.request( 39 | "ssap://com.webos.applicationManager/getAppInfo", {"id": app_id} 40 | ) 41 | 42 | print(response["appInfo"]) 43 | 44 | 45 | @app.command() 46 | def launch( 47 | app_id: Annotated[str, Argument()], data: Annotated[str | None, Argument()] = None 48 | ) -> None: 49 | """Launch an app""" 50 | 51 | payload = {"id": app_id} 52 | if data: 53 | payload.update(json.loads(data)) 54 | client.request("ssap://system.launcher/launch", payload) 55 | 56 | 57 | @app.command() 58 | def list() -> None: 59 | """List installed apps""" 60 | 61 | response = client.request("ssap://com.webos.applicationManager/listApps") 62 | 63 | table = Table() 64 | table.add_column("Name") 65 | table.add_column("ID") 66 | 67 | all_apps = [] 68 | for app in response["apps"]: 69 | all_apps.append([app["title"], app["id"]]) 70 | 71 | for row in sorted(all_apps): 72 | table.add_row(*row) 73 | 74 | console = Console() 75 | console.print(table) 76 | 77 | 78 | @app.command() 79 | def pick() -> None: 80 | """Show picker for selecting an app.""" 81 | 82 | response = client.request("ssap://com.webos.applicationManager/listApps") 83 | apps = [] 84 | 85 | for app in response["apps"]: 86 | apps.append(App(app)) 87 | 88 | app = pzp(candidates=apps, fullscreen=False, layout="reverse") 89 | if app: 90 | client.request("ssap://system.launcher/launch", {"id": app.id_}) 91 | -------------------------------------------------------------------------------- /tests/test_cli_volume.py: -------------------------------------------------------------------------------- 1 | from unittest.mock import MagicMock 2 | 3 | import pytest 4 | from faker import Faker 5 | from typer.testing import CliRunner 6 | 7 | from alga.__main__ import app 8 | 9 | 10 | runner = CliRunner() 11 | 12 | 13 | def test_up(mock_request: MagicMock) -> None: 14 | result = runner.invoke(app, ["volume", "up"]) 15 | 16 | mock_request.assert_called_once_with("ssap://audio/volumeUp") 17 | assert result.exit_code == 0 18 | assert result.stdout == "" 19 | 20 | 21 | def test_down(mock_request: MagicMock) -> None: 22 | result = runner.invoke(app, ["volume", "down"]) 23 | 24 | mock_request.assert_called_once_with("ssap://audio/volumeDown") 25 | assert result.exit_code == 0 26 | assert result.stdout == "" 27 | 28 | 29 | def test_set(faker: Faker, mock_request: MagicMock) -> None: 30 | volume = faker.pyint() 31 | 32 | result = runner.invoke(app, ["volume", "set", f"{volume}"]) 33 | 34 | mock_request.assert_called_once_with("ssap://audio/setVolume", {"volume": volume}) 35 | assert result.exit_code == 0 36 | assert result.stdout == "" 37 | 38 | 39 | @pytest.mark.parametrize( 40 | ["muted", "version"], [[True, "old"], [False, "old"], [True, "new"], [False, "new"]] 41 | ) 42 | def test_get(faker: Faker, mock_request: MagicMock, muted: bool, version: str) -> None: 43 | volume = faker.pyint() 44 | if version == "old": 45 | mock_request.return_value = {"volume": volume, "muted": muted} 46 | else: 47 | mock_request.return_value = { 48 | "volumeStatus": {"volume": volume, "muteStatus": muted} 49 | } 50 | 51 | result = runner.invoke(app, ["volume", "get"]) 52 | 53 | mock_request.assert_called_once_with("ssap://audio/getVolume") 54 | assert result.exit_code == 0 55 | 56 | muted_text = "" 57 | if not muted: 58 | muted_text = "not " 59 | 60 | assert ( 61 | result.stdout 62 | == f"Volume is currently set to {volume} and is currently {muted_text}muted\n" 63 | ) 64 | 65 | 66 | def test_mute(faker: Faker, mock_request: MagicMock) -> None: 67 | result = runner.invoke(app, ["volume", "mute"]) 68 | 69 | mock_request.assert_called_once_with("ssap://audio/setMute", {"mute": True}) 70 | assert result.exit_code == 0 71 | assert result.stdout == "" 72 | 73 | 74 | def test_unmute(faker: Faker, mock_request: MagicMock) -> None: 75 | result = runner.invoke(app, ["volume", "unmute"]) 76 | 77 | mock_request.assert_called_once_with("ssap://audio/setMute", {"mute": False}) 78 | assert result.exit_code == 0 79 | assert result.stdout == "" 80 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "alga" 3 | version = "2.2.0" 4 | description = "CLI for remote controlling LG webOS TVs" 5 | authors = [{ name = "Jeppe Fihl-Pearson", email = "jeppe@tenzer.dk" }] 6 | license = "MIT" 7 | readme = "README.md" 8 | requires-python = ">= 3.10" 9 | 10 | keywords = [ 11 | "lg", 12 | "lgtv", 13 | "remote", 14 | "remote-control", 15 | "webos", 16 | "webos-tv", 17 | ] 18 | 19 | classifiers = [ 20 | "Development Status :: 5 - Production/Stable", 21 | "Environment :: Console", 22 | "Intended Audience :: End Users/Desktop", 23 | "License :: OSI Approved :: MIT License", 24 | "Natural Language :: English", 25 | "Operating System :: OS Independent", 26 | "Programming Language :: Python :: 3.10", 27 | "Programming Language :: Python :: 3.11", 28 | "Programming Language :: Python :: 3.12", 29 | "Programming Language :: Python :: 3.13", 30 | "Programming Language :: Python :: 3.14", 31 | "Programming Language :: Python :: 3.15", 32 | "Topic :: Home Automation", 33 | "Topic :: Multimedia", 34 | "Typing :: Typed", 35 | ] 36 | 37 | dependencies = [ 38 | "cfgs >= 0.13.0", 39 | "getmac >= 0.9.0", 40 | "pzp >= 0.0.25", 41 | "rich >= 13.0.0", 42 | "typer >= 0.15.4", 43 | "wakeonlan >= 2.0.0", 44 | "websocket-client >= 1.0.0", 45 | ] 46 | 47 | [dependency-groups] 48 | dev = [ 49 | "coverage == 7.13.0", 50 | "faker == 39.0.0", 51 | "pytest == 9.0.2", 52 | "pytest-cov == 7.0.0", 53 | "ty ==0.0.5", 54 | ] 55 | 56 | [project.urls] 57 | documentation = "https://github.com/Tenzer/alga/blob/main/usage.md" 58 | homepage = "https://github.com/Tenzer/alga" 59 | issues = "https://github.com/Tenzer/alga/issues" 60 | releasenotes = "https://github.com/Tenzer/alga/releases" 61 | source = "https://github.com/Tenzer/alga.git" 62 | 63 | [project.scripts] 64 | alga = "alga.__main__:app" 65 | 66 | [build-system] 67 | requires = ["hatchling"] 68 | build-backend = "hatchling.build" 69 | 70 | 71 | [tool.ruff] 72 | target-version = "py310" 73 | 74 | [tool.ruff.lint] 75 | select = [ 76 | "ANN", # flake8-annotations 77 | "E", # pycodestyle 78 | "F", # Pyflakes 79 | "I", # isort 80 | "PL", # Pylint 81 | "UP", # pyupgrade 82 | ] 83 | ignore = [ 84 | "E501", # Line too long 85 | "UP007", # Use `X | Y` for type annotations" 86 | ] 87 | 88 | [tool.ruff.lint.isort] 89 | combine-as-imports = true 90 | known-first-party = ["alga"] 91 | lines-after-imports = 2 92 | split-on-trailing-comma = false 93 | 94 | [tool.ruff.format] 95 | skip-magic-trailing-comma = true 96 | 97 | 98 | [tool.pytest] 99 | addopts = [ 100 | "--cov", 101 | "--cov-fail-under=100", 102 | "--cov-report=term-missing:skip-covered", 103 | "--no-cov-on-fail", 104 | ] 105 | 106 | 107 | [tool.coverage.report] 108 | exclude_also = [ 109 | "if __name__ == .__main__.:", 110 | ] 111 | -------------------------------------------------------------------------------- /src/alga/cli_channel.py: -------------------------------------------------------------------------------- 1 | from typing import Annotated 2 | 3 | from pzp import pzp 4 | from rich import print 5 | from rich.console import Console 6 | from rich.table import Table 7 | from typer import Argument, Typer 8 | 9 | from alga import client 10 | from alga.types import Channel 11 | 12 | 13 | app = Typer(no_args_is_help=True, help="TV channels") 14 | 15 | 16 | @app.command() 17 | def current() -> None: 18 | """Get the current channel""" 19 | 20 | response = client.request("ssap://tv/getCurrentChannel") 21 | print( 22 | f"The current channel is [bold]{response['channelName']}[/bold] ([italic]{response['channelNumber']}[/italic])" 23 | ) 24 | 25 | 26 | @app.command() 27 | def down() -> None: 28 | """Change channel down""" 29 | 30 | client.request("ssap://tv/channelDown") 31 | 32 | 33 | @app.command() 34 | def list() -> None: 35 | """List available channels""" 36 | 37 | response = client.request("ssap://tv/getChannelList") 38 | 39 | table = Table() 40 | table.add_column("Type") 41 | table.add_column("Number") 42 | table.add_column("Name") 43 | 44 | all_channels = [] 45 | type_to_emoji = {1: "📺", 2: "📻"} 46 | for channel in response["channelList"]: 47 | # The first item is for sorting 48 | all_channels.append( 49 | [ 50 | int(channel["channelNumber"]), 51 | type_to_emoji.get(channel["channelTypeId"], "❓"), 52 | channel["channelNumber"], 53 | channel["channelName"], 54 | ] 55 | ) 56 | 57 | for row in sorted(all_channels): 58 | table.add_row(*row[1:]) 59 | 60 | console = Console() 61 | console.print(table) 62 | 63 | 64 | @app.command() 65 | def pick() -> None: 66 | """Show picker for selecting a channel.""" 67 | 68 | response = client.request("ssap://tv/getChannelList") 69 | channels = [] 70 | 71 | for channel in response["channelList"]: 72 | channels.append(Channel(channel)) 73 | 74 | channel = pzp(candidates=channels, fullscreen=False, layout="reverse") 75 | if channel: 76 | client.request("ssap://tv/openChannel", {"channelId": channel.id_}) 77 | 78 | 79 | @app.command() 80 | def set(value: Annotated[str, Argument()]) -> None: 81 | """Change to specific channel""" 82 | 83 | if value.isnumeric(): 84 | # If a channel number is provided, we look up the channel ID as some models require it. 85 | response = client.request("ssap://tv/getChannelList") 86 | 87 | for channel in response["channelList"]: 88 | if channel["channelNumber"] == value: 89 | value = channel["channelId"] 90 | break 91 | 92 | client.request("ssap://tv/openChannel", {"channelId": value}) 93 | 94 | 95 | @app.command() 96 | def up() -> None: 97 | """Change channel up""" 98 | 99 | client.request("ssap://tv/channelUp") 100 | -------------------------------------------------------------------------------- /flake.lock: -------------------------------------------------------------------------------- 1 | { 2 | "nodes": { 3 | "flake-utils": { 4 | "inputs": { 5 | "systems": "systems" 6 | }, 7 | "locked": { 8 | "lastModified": 1731533236, 9 | "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=", 10 | "owner": "numtide", 11 | "repo": "flake-utils", 12 | "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b", 13 | "type": "github" 14 | }, 15 | "original": { 16 | "owner": "numtide", 17 | "repo": "flake-utils", 18 | "type": "github" 19 | } 20 | }, 21 | "nixpkgs": { 22 | "locked": { 23 | "lastModified": 1765472234, 24 | "narHash": "sha256-9VvC20PJPsleGMewwcWYKGzDIyjckEz8uWmT0vCDYK0=", 25 | "owner": "NixOS", 26 | "repo": "nixpkgs", 27 | "rev": "2fbfb1d73d239d2402a8fe03963e37aab15abe8b", 28 | "type": "github" 29 | }, 30 | "original": { 31 | "owner": "NixOS", 32 | "ref": "nixos-unstable", 33 | "repo": "nixpkgs", 34 | "type": "github" 35 | } 36 | }, 37 | "pyproject-build-systems": { 38 | "inputs": { 39 | "nixpkgs": [ 40 | "nixpkgs" 41 | ], 42 | "pyproject-nix": [ 43 | "pyproject-nix" 44 | ], 45 | "uv2nix": [ 46 | "uv2nix" 47 | ] 48 | }, 49 | "locked": { 50 | "lastModified": 1763662255, 51 | "narHash": "sha256-4bocaOyLa3AfiS8KrWjZQYu+IAta05u3gYZzZ6zXbT0=", 52 | "owner": "pyproject-nix", 53 | "repo": "build-system-pkgs", 54 | "rev": "042904167604c681a090c07eb6967b4dd4dae88c", 55 | "type": "github" 56 | }, 57 | "original": { 58 | "owner": "pyproject-nix", 59 | "repo": "build-system-pkgs", 60 | "type": "github" 61 | } 62 | }, 63 | "pyproject-nix": { 64 | "inputs": { 65 | "nixpkgs": [ 66 | "nixpkgs" 67 | ] 68 | }, 69 | "locked": { 70 | "lastModified": 1764134915, 71 | "narHash": "sha256-xaKvtPx6YAnA3HQVp5LwyYG1MaN4LLehpQI8xEdBvBY=", 72 | "owner": "pyproject-nix", 73 | "repo": "pyproject.nix", 74 | "rev": "2c8df1383b32e5443c921f61224b198a2282a657", 75 | "type": "github" 76 | }, 77 | "original": { 78 | "owner": "pyproject-nix", 79 | "repo": "pyproject.nix", 80 | "type": "github" 81 | } 82 | }, 83 | "root": { 84 | "inputs": { 85 | "flake-utils": "flake-utils", 86 | "nixpkgs": "nixpkgs", 87 | "pyproject-build-systems": "pyproject-build-systems", 88 | "pyproject-nix": "pyproject-nix", 89 | "uv2nix": "uv2nix" 90 | } 91 | }, 92 | "systems": { 93 | "locked": { 94 | "lastModified": 1681028828, 95 | "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", 96 | "owner": "nix-systems", 97 | "repo": "default", 98 | "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", 99 | "type": "github" 100 | }, 101 | "original": { 102 | "owner": "nix-systems", 103 | "repo": "default", 104 | "type": "github" 105 | } 106 | }, 107 | "uv2nix": { 108 | "inputs": { 109 | "nixpkgs": [ 110 | "nixpkgs" 111 | ], 112 | "pyproject-nix": [ 113 | "pyproject-nix" 114 | ] 115 | }, 116 | "locked": { 117 | "lastModified": 1765631794, 118 | "narHash": "sha256-90d//IZ4GXipNsngO4sb2SAPbIC/a2P+IAdAWOwpcOM=", 119 | "owner": "pyproject-nix", 120 | "repo": "uv2nix", 121 | "rev": "4cca323a547a1aaa9b94929c4901bed5343eafe8", 122 | "type": "github" 123 | }, 124 | "original": { 125 | "owner": "pyproject-nix", 126 | "repo": "uv2nix", 127 | "type": "github" 128 | } 129 | } 130 | }, 131 | "root": "root", 132 | "version": 7 133 | } 134 | -------------------------------------------------------------------------------- /.github/workflows/checks.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: Checks 3 | 4 | on: 5 | pull_request: 6 | push: 7 | branches: 8 | - main 9 | 10 | permissions: 11 | contents: read 12 | 13 | jobs: 14 | nix-flake: 15 | runs-on: ubuntu-24.04 16 | 17 | steps: 18 | - name: Checkout the code 19 | uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 20 | with: 21 | persist-credentials: false 22 | 23 | - name: Check Nix flake inputs 24 | uses: DeterminateSystems/flake-checker-action@3164002371bc90729c68af0e24d5aacf20d7c9f6 # v12 25 | 26 | - name: Install Nix 27 | uses: DeterminateSystems/nix-installer-action@c5a866b6ab867e88becbed4467b93592bce69f8a # v21 28 | with: 29 | determinate: false 30 | 31 | - name: Build package 32 | run: nix build 33 | 34 | - name: Run command 35 | run: nix run . -- --help 36 | 37 | 38 | pre-commit: 39 | runs-on: ubuntu-24.04 40 | 41 | steps: 42 | - name: Checkout the code 43 | uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 44 | with: 45 | persist-credentials: false 46 | 47 | - name: prek 48 | uses: j178/prek-action@91fd7d7cf70ae1dee9f4f44e7dfa5d1073fe6623 # v1.0.11 49 | 50 | 51 | pytest: 52 | runs-on: ubuntu-24.04 53 | 54 | strategy: 55 | fail-fast: false 56 | matrix: 57 | package-resolution: 58 | - --frozen 59 | - --resolution=lowest-direct 60 | python-version: 61 | - "3.10" 62 | - "3.11" 63 | - "3.12" 64 | - "3.13" 65 | - "3.14" 66 | - "3.15" 67 | 68 | steps: 69 | - name: Checkout the code 70 | uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 71 | with: 72 | persist-credentials: false 73 | 74 | - name: Install uv 75 | uses: astral-sh/setup-uv@681c641aba71e4a1c380be3ab5e12ad51f415867 # v7.1.6 76 | with: 77 | python-version: ${{ matrix.python-version }} 78 | 79 | - name: Install dependencies 80 | run: uv sync ${{ matrix.package-resolution }} 81 | 82 | - name: Run pytest 83 | run: uv run pytest 84 | 85 | 86 | ty: 87 | runs-on: ubuntu-24.04 88 | 89 | steps: 90 | - name: Checkout the code 91 | uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 92 | with: 93 | persist-credentials: false 94 | 95 | - name: Install uv 96 | uses: astral-sh/setup-uv@681c641aba71e4a1c380be3ab5e12ad51f415867 # v7.1.6 97 | with: 98 | enable-cache: true 99 | 100 | - name: Install dependencies 101 | run: uv sync --frozen 102 | 103 | - name: Run ty 104 | run: uv run ty check . 105 | 106 | 107 | usage: 108 | runs-on: ubuntu-24.04 109 | 110 | steps: 111 | - name: Checkout the code 112 | uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 113 | with: 114 | persist-credentials: false 115 | 116 | - name: Install uv 117 | uses: astral-sh/setup-uv@681c641aba71e4a1c380be3ab5e12ad51f415867 # v7.1.6 118 | with: 119 | enable-cache: true 120 | 121 | - name: Install dependencies 122 | run: uv sync --frozen 123 | 124 | - name: Generate usage.md 125 | run: uv run ./generate-usage.sh 126 | 127 | - name: Check usage.md is up-to-date 128 | run: git diff --exit-code 129 | 130 | 131 | all-checks-passed: 132 | if: always() 133 | 134 | needs: 135 | - nix-flake 136 | - pre-commit 137 | - pytest 138 | - ty 139 | - usage 140 | 141 | runs-on: ubuntu-24.04 142 | 143 | steps: 144 | - name: Decide whether the needed jobs succeeded or failed 145 | uses: re-actors/alls-green@05ac9388f0aebcb5727afa17fcccfecd6f8ec5fe # v1.2.2 146 | with: 147 | jobs: ${{ toJSON(needs) }} 148 | -------------------------------------------------------------------------------- /tests/test_cli_app.py: -------------------------------------------------------------------------------- 1 | import json 2 | from unittest.mock import MagicMock, call, patch 3 | 4 | from faker import Faker 5 | from typer.testing import CliRunner 6 | 7 | from alga.__main__ import app 8 | from alga.types import App 9 | 10 | 11 | runner = CliRunner() 12 | 13 | 14 | def test_current(faker: Faker, mock_request: MagicMock) -> None: 15 | app_id = faker.pystr() 16 | mock_request.return_value = {"appId": app_id} 17 | 18 | result = runner.invoke(app, ["app", "current"]) 19 | 20 | mock_request.assert_called_once_with( 21 | "ssap://com.webos.applicationManager/getForegroundAppInfo" 22 | ) 23 | assert result.exit_code == 0 24 | assert result.stdout == f"The current app is {app_id}\n" 25 | 26 | 27 | def test_close(faker: Faker, mock_request: MagicMock) -> None: 28 | app_id = faker.pystr() 29 | 30 | result = runner.invoke(app, ["app", "close", app_id]) 31 | 32 | mock_request.assert_called_once_with("ssap://system.launcher/close", {"id": app_id}) 33 | assert result.exit_code == 0 34 | assert result.stdout == "" 35 | 36 | 37 | def test_launch_without_data(faker: Faker, mock_request: MagicMock) -> None: 38 | app_id = faker.pystr() 39 | 40 | result = runner.invoke(app, ["app", "launch", app_id]) 41 | 42 | mock_request.assert_called_once_with( 43 | "ssap://system.launcher/launch", {"id": app_id} 44 | ) 45 | assert result.exit_code == 0 46 | assert result.stdout == "" 47 | 48 | 49 | def test_launch_with_data(faker: Faker, mock_request: MagicMock) -> None: 50 | app_id = faker.pystr() 51 | data = faker.pydict() 52 | 53 | result = runner.invoke(app, ["app", "launch", app_id, json.dumps(data)]) 54 | 55 | mock_request.assert_called_once_with( 56 | "ssap://system.launcher/launch", {"id": app_id} | data 57 | ) 58 | assert result.exit_code == 0 59 | assert result.stdout == "" 60 | 61 | 62 | def test_list(faker: Faker, mock_request: MagicMock) -> None: 63 | return_value = { 64 | "apps": [ 65 | {"id": faker.pystr(), "title": faker.pystr()}, 66 | {"id": faker.pystr(), "title": faker.pystr()}, 67 | {"id": faker.pystr(), "title": faker.pystr()}, 68 | ] 69 | } 70 | mock_request.return_value = return_value 71 | 72 | result = runner.invoke(app, ["app", "list"]) 73 | 74 | mock_request.assert_called_once_with("ssap://com.webos.applicationManager/listApps") 75 | assert result.exit_code == 0 76 | 77 | splitted_output = result.stdout.split("\n") 78 | assert len(splitted_output) == ( 79 | 3 # table header 80 | + 3 # apps 81 | + 1 # table footer 82 | + 1 # trailing newline 83 | ) 84 | assert splitted_output[3:6] == sorted(splitted_output[3:6]) 85 | 86 | 87 | def test_info(faker: Faker, mock_request: MagicMock) -> None: 88 | app_id = faker.pystr() 89 | app_info = faker.pystr() 90 | mock_request.return_value = {"appInfo": app_info} 91 | 92 | result = runner.invoke(app, ["app", "info", app_id]) 93 | 94 | mock_request.assert_called_once_with( 95 | "ssap://com.webos.applicationManager/getAppInfo", {"id": app_id} 96 | ) 97 | assert result.exit_code == 0 98 | assert result.stdout == f"{app_info}\n" 99 | 100 | 101 | def test_pick(faker: Faker, mock_request: MagicMock) -> None: 102 | return_value = { 103 | "apps": [ 104 | {"id": faker.pystr(), "title": faker.pystr()}, 105 | {"id": faker.pystr(), "title": faker.pystr()}, 106 | {"id": faker.pystr(), "title": faker.pystr()}, 107 | ] 108 | } 109 | mock_request.return_value = return_value 110 | first_app = return_value["apps"][0] 111 | 112 | with patch("alga.cli_app.pzp") as mock_pzp: 113 | mock_pzp.return_value = App(first_app) 114 | 115 | result = runner.invoke(app, ["app", "pick"]) 116 | 117 | mock_request.assert_has_calls( 118 | [ 119 | call("ssap://com.webos.applicationManager/listApps"), 120 | call("ssap://system.launcher/launch", {"id": first_app["id"]}), 121 | ] 122 | ) 123 | assert result.exit_code == 0 124 | -------------------------------------------------------------------------------- /tests/test_client.py: -------------------------------------------------------------------------------- 1 | import json 2 | import ssl 3 | from unittest.mock import ANY, MagicMock, call, patch 4 | 5 | import pytest 6 | from faker import Faker 7 | from typer import Exit 8 | 9 | from alga import client 10 | from alga.payloads import get_hello_data 11 | 12 | 13 | def test_connect(faker: Faker) -> None: 14 | hostname = faker.pystr() 15 | timeout = faker.pyint() 16 | 17 | with patch("alga.client.WebSocket") as mock_websocket: 18 | mock_websocket 19 | 20 | with client.connect(hostname, timeout): 21 | pass 22 | 23 | mock_websocket.assert_has_calls( 24 | [ 25 | call(sslopt={"cert_reqs": ssl.CERT_NONE}), 26 | call().connect( 27 | f"wss://{hostname}:3001/", suppress_origin=True, timeout=timeout 28 | ), 29 | call().close(), 30 | ] 31 | ) 32 | 33 | 34 | def test_do_handshake(faker: Faker) -> None: 35 | key = faker.pystr() 36 | mock_connection = MagicMock() 37 | 38 | mock_connection.recv.return_value = json.dumps( 39 | {"payload": {"client-key": faker.pystr()}} 40 | ) 41 | 42 | client.do_handshake(mock_connection, key) 43 | 44 | mock_connection.send.assert_called_once_with(json.dumps(get_hello_data(key))) 45 | 46 | 47 | def test_do_handshake_error(faker: Faker) -> None: 48 | key = faker.pystr() 49 | mock_connection = MagicMock() 50 | 51 | mock_connection.recv.return_value = json.dumps({"payload": {}}) 52 | 53 | with pytest.raises( 54 | Exception, match="Something went wrong with performing a handshake" 55 | ): 56 | client.do_handshake(mock_connection, key) 57 | 58 | 59 | def test_request_no_config(faker: Faker, mock_config: MagicMock) -> None: 60 | mock_config.return_value = {} 61 | 62 | with pytest.raises(Exit) as exc_info: 63 | client.request(faker.pystr()) 64 | 65 | assert exc_info.value.exit_code == 1 66 | 67 | 68 | def test_request_tv_id_not_in_config(faker: Faker, mock_config: MagicMock) -> None: 69 | mock_config.return_value = {"default_tv": faker.pystr(), "tvs": {}} 70 | 71 | with pytest.raises(Exit) as exc_info: 72 | client.request(faker.pystr()) 73 | 74 | assert exc_info.value.exit_code == 1 75 | 76 | 77 | def test_request_no_data(faker: Faker, mock_config: MagicMock) -> None: 78 | name, hostname, key = faker.pystr(), faker.pystr(), faker.pystr() 79 | mock_config.return_value = { 80 | "default_tv": name, 81 | "tvs": {name: {"hostname": hostname, "key": key}}, 82 | } 83 | 84 | uri = faker.pystr() 85 | payload = {"returnValue": True} | faker.pydict() 86 | 87 | with patch("alga.client.connect") as mock_connect: 88 | mock_connect().__enter__().recv.side_effect = [ 89 | json.dumps({"payload": {"client-key": faker.pystr()}}), 90 | json.dumps({"payload": payload}), 91 | ] 92 | 93 | response = client.request(uri) 94 | 95 | assert response == payload 96 | 97 | mock_connect().__enter__().send.assert_has_calls( 98 | [ 99 | call(ANY), # Handshake 100 | call(json.dumps({"type": "request", "uri": uri})), 101 | ] 102 | ) 103 | 104 | 105 | def test_request_with_data(faker: Faker, mock_config: MagicMock) -> None: 106 | name, hostname, key = faker.pystr(), faker.pystr(), faker.pystr() 107 | mock_config.return_value = { 108 | "default_tv": name, 109 | "tvs": {name: {"hostname": hostname, "key": key}}, 110 | } 111 | 112 | uri, data = faker.pystr(), faker.pydict(allowed_types=[str, float, int]) 113 | payload = {"returnValue": True} | faker.pydict() 114 | 115 | with patch("alga.client.connect") as mock_connect: 116 | mock_connect().__enter__().recv.side_effect = [ 117 | json.dumps({"payload": {"client-key": faker.pystr()}}), 118 | json.dumps({"payload": payload}), 119 | ] 120 | 121 | response = client.request(uri, data) 122 | 123 | assert response == payload 124 | 125 | mock_connect().__enter__().send.assert_has_calls( 126 | [ 127 | call(ANY), # Handshake 128 | call(json.dumps({"type": "request", "uri": uri, "payload": data})), 129 | ] 130 | ) 131 | -------------------------------------------------------------------------------- /tests/test_cli_tv.py: -------------------------------------------------------------------------------- 1 | from socket import AF_INET, SOCK_DGRAM, SOCK_STREAM, gaierror 2 | from unittest.mock import MagicMock, patch 3 | 4 | from faker import Faker 5 | from typer.testing import CliRunner 6 | 7 | from alga.__main__ import app 8 | from alga.cli_tv import _ip_from_hostname 9 | 10 | 11 | runner = CliRunner() 12 | 13 | 14 | @patch("alga.cli_tv.getaddrinfo") 15 | def test_ip_from_hostname(mock_getaddrinfo: MagicMock, faker: Faker) -> None: 16 | hostname = faker.hostname() 17 | ip_address = faker.ipv4() 18 | 19 | mock_getaddrinfo.return_value = [ 20 | (AF_INET, SOCK_DGRAM, 17, "", (ip_address, 0)), 21 | (AF_INET, SOCK_STREAM, 6, "", (ip_address, 0)), 22 | ] 23 | 24 | result = _ip_from_hostname(hostname) 25 | 26 | mock_getaddrinfo.assert_called_once_with(host=hostname, port=None) 27 | assert result == ip_address 28 | 29 | 30 | @patch("alga.cli_tv.getaddrinfo") 31 | def test_ip_from_hostname_not_found(mock_getaddrinfo: MagicMock, faker: Faker) -> None: 32 | hostname = faker.hostname() 33 | mock_getaddrinfo.side_effect = gaierror( 34 | "[Errno 8] nodename nor servname provided, or not known" 35 | ) 36 | 37 | result = _ip_from_hostname(hostname) 38 | 39 | mock_getaddrinfo.assert_called_once_with(host=hostname, port=None) 40 | assert result is None 41 | 42 | 43 | @patch("alga.cli_tv._ip_from_hostname") 44 | def test_add_ip_not_found(mock_ip_from_hostname: MagicMock, faker: Faker) -> None: 45 | hostname = faker.hostname() 46 | mock_ip_from_hostname.return_value = None 47 | 48 | result = runner.invoke(app, ["tv", "add", "name", hostname]) 49 | 50 | mock_ip_from_hostname.assert_called_once_with(hostname) 51 | assert result.exit_code == 1 52 | assert ( 53 | result.stdout.replace("\n", "") 54 | == f"Could not find any host by the name '{hostname}'. Is the TV on and connected to the network?" 55 | ) 56 | 57 | 58 | def test_list(faker: Faker, mock_config: MagicMock) -> None: 59 | mock_config.return_value = { 60 | "default_tv": faker.pystr(), 61 | "tvs": {faker.pystr(): {"hostname": faker.pystr(), "mac": faker.pystr()}}, 62 | } 63 | 64 | result = runner.invoke(app, ["tv", "list"]) 65 | 66 | assert result.exit_code == 0 67 | 68 | 69 | def test_remove(faker: Faker, mock_config: MagicMock) -> None: 70 | name = faker.pystr() 71 | mock_config.return_value = {"default_tv": name, "tvs": {name: {}}} 72 | 73 | with patch("alga.cli_tv.config.write") as mock_write: 74 | result = runner.invoke(app, ["tv", "remove", name]) 75 | 76 | assert result.exit_code == 0 77 | mock_write.assert_called_once_with({"default_tv": "", "tvs": {}}) 78 | 79 | 80 | def test_remove_not_found(faker: Faker, mock_config: MagicMock) -> None: 81 | mock_config.return_value = {"default_tv": "", "tvs": {}} 82 | name = faker.pystr() 83 | 84 | result = runner.invoke(app, ["tv", "remove", name]) 85 | 86 | assert result.exit_code == 1 87 | 88 | 89 | def test_rename(faker: Faker, mock_config: MagicMock) -> None: 90 | old_name, new_name = faker.pystr(), faker.pystr() 91 | mock_config.return_value = {"default_tv": old_name, "tvs": {old_name: {}}} 92 | 93 | with patch("alga.cli_tv.config.write") as mock_write: 94 | result = runner.invoke(app, ["tv", "rename", old_name, new_name]) 95 | 96 | assert result.exit_code == 0 97 | mock_write.assert_called_once_with({"default_tv": new_name, "tvs": {new_name: {}}}) 98 | 99 | 100 | def test_rename_not_found(faker: Faker, mock_config: MagicMock) -> None: 101 | mock_config.return_value = {"default_tv": "", "tvs": {}} 102 | old_name, new_name = faker.pystr(), faker.pystr() 103 | 104 | result = runner.invoke(app, ["tv", "rename", old_name, new_name]) 105 | 106 | assert result.exit_code == 1 107 | 108 | 109 | def test_set_default(faker: Faker, mock_config: MagicMock) -> None: 110 | name = faker.pystr() 111 | mock_config.return_value = {"default_tv": "", "tvs": {name: {}}} 112 | 113 | with patch("alga.cli_tv.config.write") as mock_write: 114 | result = runner.invoke(app, ["tv", "set-default", name]) 115 | 116 | assert result.exit_code == 0 117 | mock_write.assert_called_once_with({"default_tv": name, "tvs": {name: {}}}) 118 | 119 | 120 | def test_set_default_not_found(faker: Faker, mock_config: MagicMock) -> None: 121 | name = faker.pystr() 122 | mock_config.return_value = {"default_tv": "", "tvs": {}} 123 | 124 | result = runner.invoke(app, ["tv", "set-default", name]) 125 | 126 | assert result.exit_code == 1 127 | -------------------------------------------------------------------------------- /src/alga/cli_tv.py: -------------------------------------------------------------------------------- 1 | import json 2 | from ipaddress import ip_address 3 | from socket import gaierror, getaddrinfo 4 | from typing import Annotated, cast 5 | 6 | from getmac import get_mac_address 7 | from rich import print 8 | from rich.console import Console 9 | from rich.table import Table 10 | from typer import Argument, Exit, Typer 11 | 12 | from alga import client, config 13 | from alga.payloads import get_hello_data 14 | 15 | 16 | def _ip_from_hostname(hostname: str) -> str | None: 17 | try: 18 | results = getaddrinfo(host=hostname, port=None) 19 | # TODO: Do we want to handle receiving multiple IP addresses? 20 | first_address = results[0] 21 | sockaddr = first_address[4] 22 | address = sockaddr[0] 23 | return cast(str, address) 24 | except gaierror: 25 | return None 26 | 27 | 28 | app = Typer(no_args_is_help=True, help="Set up TVs to manage via Alga") 29 | 30 | 31 | @app.command() 32 | def add( 33 | name: Annotated[str, Argument()], hostname: Annotated[str, Argument()] = "lgwebostv" 34 | ) -> None: # pragma: no cover 35 | """Pair a new TV""" 36 | 37 | # Check if we have been passed an IP address 38 | ip: str | None 39 | try: 40 | ip = ip_address(hostname).compressed 41 | except ValueError: 42 | ip = _ip_from_hostname(hostname) 43 | if not ip: 44 | print( 45 | f"[red]Could not find any host by the name '{hostname}'.[/red] Is the TV on and connected to the network?" 46 | ) 47 | raise Exit(code=1) 48 | 49 | with client.connect(hostname=hostname, timeout=60) as connection: 50 | connection.send(json.dumps(get_hello_data())) 51 | response = json.loads(connection.recv()) 52 | assert response == { 53 | "id": "register_0", 54 | "payload": {"pairingType": "PROMPT", "returnValue": True}, 55 | "type": "response", 56 | }, "Unexpected response received" 57 | 58 | console = Console() 59 | with console.status("Please approve the connection request on the TV now..."): 60 | response = json.loads(connection.recv()) 61 | 62 | if "client-key" not in response["payload"]: 63 | print("[red]Setup failed![/red]") 64 | raise Exit(code=1) 65 | 66 | mac_address = get_mac_address(ip=ip) 67 | 68 | cfg = config.get() 69 | cfg["tvs"][name] = { 70 | "hostname": hostname, 71 | "key": response["payload"]["client-key"], 72 | "mac": mac_address, 73 | } 74 | 75 | if not cfg.get("default_tv"): 76 | cfg["default_tv"] = name 77 | 78 | config.write(cfg) 79 | 80 | print("TV configured, Alga is ready to use") 81 | 82 | 83 | @app.command() 84 | def list() -> None: 85 | """List current TVs""" 86 | 87 | cfg = config.get() 88 | 89 | table = Table() 90 | table.add_column("Default") 91 | table.add_column("Name") 92 | table.add_column("Hostname/IP") 93 | table.add_column("MAC address") 94 | 95 | for name, tv in cfg["tvs"].items(): 96 | default = "*" if cfg["default_tv"] == name else "" 97 | table.add_row(default, name, tv["hostname"], tv["mac"]) 98 | 99 | console = Console() 100 | console.print(table) 101 | 102 | 103 | @app.command() 104 | def remove(name: Annotated[str, Argument()]) -> None: 105 | """Remove a TV""" 106 | 107 | cfg = config.get() 108 | 109 | try: 110 | cfg["tvs"].pop(name) 111 | 112 | if cfg["default_tv"] == name: 113 | cfg["default_tv"] = "" 114 | 115 | config.write(cfg) 116 | except KeyError: 117 | print( 118 | f"[red]A TV with the name '{name}' was not found in the configuration[/red]" 119 | ) 120 | raise Exit(code=1) 121 | 122 | 123 | @app.command() 124 | def rename( 125 | old_name: Annotated[str, Argument()], new_name: Annotated[str, Argument()] 126 | ) -> None: 127 | """Change the identifier for a TV""" 128 | 129 | cfg = config.get() 130 | 131 | try: 132 | cfg["tvs"][new_name] = cfg["tvs"].pop(old_name) 133 | 134 | if cfg["default_tv"] == old_name: 135 | cfg["default_tv"] = new_name 136 | 137 | config.write(cfg) 138 | except KeyError: 139 | print( 140 | f"[red]A TV with the name '{old_name}' was not found in the configuration[/red]" 141 | ) 142 | raise Exit(code=1) 143 | 144 | 145 | @app.command() 146 | def set_default(name: Annotated[str, Argument()]) -> None: 147 | """Set the default TV""" 148 | 149 | cfg = config.get() 150 | 151 | if name in cfg["tvs"]: 152 | cfg["default_tv"] = name 153 | config.write(cfg) 154 | else: 155 | print( 156 | f"[red]A TV with the name '{name}' was not found in the configuration[/red]" 157 | ) 158 | raise Exit(code=1) 159 | -------------------------------------------------------------------------------- /tests/test_cli_channel.py: -------------------------------------------------------------------------------- 1 | from unittest.mock import MagicMock, call, patch 2 | 3 | from faker import Faker 4 | from typer.testing import CliRunner 5 | 6 | from alga.__main__ import app 7 | from alga.types import Channel 8 | 9 | 10 | runner = CliRunner() 11 | 12 | 13 | def test_current(faker: Faker, mock_request: MagicMock) -> None: 14 | channel_name = faker.pystr() 15 | channel_number = faker.pyint() 16 | mock_request.return_value = { 17 | "channelName": channel_name, 18 | "channelNumber": channel_number, 19 | } 20 | 21 | result = runner.invoke(app, ["channel", "current"]) 22 | 23 | mock_request.assert_called_once_with("ssap://tv/getCurrentChannel") 24 | assert result.exit_code == 0 25 | assert ( 26 | result.stdout == f"The current channel is {channel_name} ({channel_number})\n" 27 | ) 28 | 29 | 30 | def test_up(mock_request: MagicMock) -> None: 31 | result = runner.invoke(app, ["channel", "up"]) 32 | 33 | mock_request.assert_called_once_with("ssap://tv/channelUp") 34 | assert result.exit_code == 0 35 | assert result.stdout == "" 36 | 37 | 38 | def test_down(mock_request: MagicMock) -> None: 39 | result = runner.invoke(app, ["channel", "down"]) 40 | 41 | mock_request.assert_called_once_with("ssap://tv/channelDown") 42 | assert result.exit_code == 0 43 | assert result.stdout == "" 44 | 45 | 46 | def test_set_channel_id(faker: Faker, mock_request: MagicMock) -> None: 47 | channel_id = faker.pystr() 48 | 49 | result = runner.invoke(app, ["channel", "set", channel_id]) 50 | 51 | mock_request.assert_called_once_with( 52 | "ssap://tv/openChannel", {"channelId": channel_id} 53 | ) 54 | assert result.exit_code == 0 55 | assert result.stdout == "" 56 | 57 | 58 | def test_set_channel_number(faker: Faker, mock_request: MagicMock) -> None: 59 | channel_number = str(faker.pyint()) 60 | channel_id = faker.pystr() 61 | 62 | mock_request.return_value = { 63 | "channelList": [{"channelId": channel_id, "channelNumber": channel_number}] 64 | } 65 | 66 | result = runner.invoke(app, ["channel", "set", channel_number]) 67 | 68 | mock_request.assert_called_with("ssap://tv/openChannel", {"channelId": channel_id}) 69 | assert result.exit_code == 0 70 | assert result.stdout == "" 71 | 72 | 73 | def test_list(faker: Faker, mock_request: MagicMock) -> None: 74 | return_value = { 75 | "channelList": [ 76 | { 77 | "channelNumber": f"{faker.pyint()}", 78 | "channelTypeId": faker.random_element([1, 2]), 79 | "channelName": faker.pystr(), 80 | }, 81 | { 82 | "channelNumber": f"{faker.pyint()}", 83 | "channelTypeId": faker.random_element([1, 2]), 84 | "channelName": faker.pystr(), 85 | }, 86 | { 87 | "channelNumber": f"{faker.pyint()}", 88 | "channelTypeId": faker.random_element([1, 2]), 89 | "channelName": faker.pystr(), 90 | }, 91 | ] 92 | } 93 | mock_request.return_value = return_value 94 | 95 | result = runner.invoke(app, ["channel", "list"]) 96 | 97 | mock_request.assert_called_once_with("ssap://tv/getChannelList") 98 | assert result.exit_code == 0 99 | 100 | splitted_output = result.stdout.split("\n") 101 | assert len(splitted_output) == ( 102 | 3 # table header 103 | + 3 # channels 104 | + 1 # table footer 105 | + 1 # trailing newline 106 | ) 107 | 108 | 109 | def test_pick(faker: Faker, mock_request: MagicMock) -> None: 110 | return_value = { 111 | "channelList": [ 112 | { 113 | "channelId": faker.pystr(), 114 | "channelName": faker.pystr(), 115 | "channelNumber": f"{faker.pyint()}", 116 | }, 117 | { 118 | "channelId": faker.pystr(), 119 | "channelName": faker.pystr(), 120 | "channelNumber": f"{faker.pyint()}", 121 | }, 122 | { 123 | "channelId": faker.pystr(), 124 | "channelName": faker.pystr(), 125 | "channelNumber": f"{faker.pyint()}", 126 | }, 127 | ] 128 | } 129 | mock_request.return_value = return_value 130 | first_channel = return_value["channelList"][0] 131 | 132 | with patch("alga.cli_channel.pzp") as mock_pzp: 133 | mock_pzp.return_value = Channel(first_channel) 134 | 135 | result = runner.invoke(app, ["channel", "pick"]) 136 | 137 | mock_request.assert_has_calls( 138 | [ 139 | call("ssap://tv/getChannelList"), 140 | call("ssap://tv/openChannel", {"channelId": first_channel["channelId"]}), 141 | ] 142 | ) 143 | assert result.exit_code == 0 144 | -------------------------------------------------------------------------------- /src/alga/payloads.py: -------------------------------------------------------------------------------- 1 | from typing import Any 2 | 3 | 4 | def get_hello_data(client_key: str | None = None) -> dict[str, Any]: 5 | return { 6 | "id": "register_0", 7 | "payload": { 8 | "client-key": client_key, 9 | "forcePairing": False, 10 | "manifest": { 11 | "appVersion": "1.1", 12 | "manifestVersion": 1, 13 | "permissions": [ 14 | "ADD_LAUNCHER_CHANNEL", 15 | "APP_TO_APP", 16 | "CHECK_BLUETOOTH_DEVICE", 17 | "CLOSE", 18 | "CONTROL_AUDIO", 19 | "CONTROL_BLUETOOTH", 20 | "CONTROL_BOX_CHANNEL", 21 | "CONTROL_CHANNEL_BLOCK", 22 | "CONTROL_CHANNEL_GROUP", 23 | "CONTROL_DISPLAY", 24 | "CONTROL_FAVORITE_GROUP", 25 | "CONTROL_INPUT_JOYSTICK", 26 | "CONTROL_INPUT_MEDIA_PLAYBACK", 27 | "CONTROL_INPUT_MEDIA_RECORDING", 28 | "CONTROL_INPUT_TV", 29 | "CONTROL_POWER", 30 | "CONTROL_RECORDING", 31 | "CONTROL_TIMER_INFO", 32 | "CONTROL_TV_POWER", 33 | "CONTROL_TV_SCREEN", 34 | "CONTROL_TV_STANBY", 35 | "CONTROL_USER_INFO", 36 | "CONTROL_WOL", 37 | "DELETE_SELECT_CHANNEL", 38 | "LAUNCH", 39 | "LAUNCH_WEBAPP", 40 | "READ_APP_STATUS", 41 | "READ_COUNTRY_INFO", 42 | "READ_CURRENT_CHANNEL", 43 | "READ_INPUT_DEVICE_LIST", 44 | "READ_NETWORK_STATE", 45 | "READ_POWER_STATE", 46 | "READ_RECORDING_LIST", 47 | "READ_RECORDING_SCHEDULE", 48 | "READ_RECORDING_STATE", 49 | "READ_RUNNING_APPS", 50 | "READ_SETTINGS", 51 | "READ_STORAGE_DEVICE_LIST", 52 | "READ_TV_ACR_AUTH_TOKEN", 53 | "READ_TV_CHANNEL_LIST", 54 | "READ_TV_CONTENT_STATE", 55 | "READ_TV_CURRENT_TIME", 56 | "READ_TV_PROGRAM_INFO", 57 | "RELEASE_CHANNEL_SKIP", 58 | "SCAN_TV_CHANNELS", 59 | "SET_CHANNEL_SKIP", 60 | "STB_INTERNAL_CONNECTION", 61 | "TEST_OPEN", 62 | "TEST_PROTECTED", 63 | "WRITE_NOTIFICATION_TOAST", 64 | "WRITE_RECORDING_LIST", 65 | "WRITE_RECORDING_SCHEDULE", 66 | ], 67 | "signatures": [ 68 | { 69 | "signature": "eyJhbGdvcml0aG0iOiJSU0EtU0hBMjU2Iiwia2V5SWQiOiJ0ZXN0LXNpZ25pbmctY2VydCIsInNpZ25hdHVyZVZlcnNpb24iOjF9.hrVRgjCwXVvE2OOSpDZ58hR+59aFNwYDyjQgKk3auukd7pcegmE2CzPCa0bJ0ZsRAcKkCTJrWo5iDzNhMBWRyaMOv5zWSrthlf7G128qvIlpMT0YNY+n/FaOHE73uLrS/g7swl3/qH/BGFG2Hu4RlL48eb3lLKqTt2xKHdCs6Cd4RMfJPYnzgvI4BNrFUKsjkcu+WD4OO2A27Pq1n50cMchmcaXadJhGrOqH5YmHdOCj5NSHzJYrsW0HPlpuAx/ECMeIZYDh6RMqaFM2DXzdKX9NmmyqzJ3o/0lkk/N97gfVRLW5hA29yeAwaCViZNCP8iC9aO0q9fQojoa7NQnAtw==", 70 | "signatureVersion": 1, 71 | } 72 | ], 73 | # These can't be changed, then we get "insufficient permissions" errors 74 | "signed": { 75 | "appId": "com.lge.test", 76 | "created": "20140509", 77 | "localizedAppNames": { 78 | "": "LG Remote App", 79 | "ko-KR": "리모컨 앱", 80 | "zxx-XX": "ЛГ Rэмotэ AПП", 81 | }, 82 | "localizedVendorNames": {"": "LG Electronics"}, 83 | "permissions": [ 84 | "TEST_SECURE", 85 | "CONTROL_INPUT_TEXT", 86 | "CONTROL_MOUSE_AND_KEYBOARD", 87 | "READ_INSTALLED_APPS", 88 | "READ_LGE_SDX", 89 | "READ_NOTIFICATIONS", 90 | "SEARCH", 91 | "WRITE_SETTINGS", 92 | "WRITE_NOTIFICATION_ALERT", 93 | "CONTROL_POWER", 94 | "READ_CURRENT_CHANNEL", 95 | "READ_RUNNING_APPS", 96 | "READ_UPDATE_INFO", 97 | "UPDATE_FROM_REMOTE_APP", 98 | "READ_LGE_TV_INPUT_EVENTS", 99 | "READ_TV_CURRENT_TIME", 100 | ], 101 | "serial": "2f930e2d2cfe083771f68e4fe7bb07", 102 | "vendorId": "com.lge", 103 | }, 104 | }, 105 | "pairingType": "PROMPT", 106 | }, 107 | "type": "register", 108 | } 109 | -------------------------------------------------------------------------------- /usage.md: -------------------------------------------------------------------------------- 1 | # Available commands 2 | 3 | **Usage**: 4 | 5 | ```console 6 | $ alga [OPTIONS] COMMAND [ARGS]... 7 | ``` 8 | 9 | **Options**: 10 | 11 | * `--tv TEXT`: Specify which TV the command should be sent to 12 | * `--timeout INTEGER`: Number of seconds to wait before a response (default 10) 13 | * `--install-completion`: Install completion for the current shell. 14 | * `--show-completion`: Show completion for the current shell, to copy it or customize the installation. 15 | * `--help`: Show this message and exit. 16 | 17 | **Commands**: 18 | 19 | * `adhoc`: Send raw request to the TV 20 | * `app`: Apps installed on the TV 21 | * `channel`: TV channels 22 | * `input`: HDMI and similar inputs 23 | * `media`: Control the playing media 24 | * `power`: Turn TV (or screen) on and off 25 | * `remote`: Remote control button presses 26 | * `sound-output`: Audio output device 27 | * `tv`: Set up TVs to manage via Alga 28 | * `version`: Print Alga version 29 | * `volume`: Audio volume 30 | 31 | ## `alga adhoc` 32 | 33 | Send raw request to the TV 34 | 35 | **Usage**: 36 | 37 | ```console 38 | $ alga adhoc [OPTIONS] PATH [DATA] 39 | ``` 40 | 41 | **Arguments**: 42 | 43 | * `PATH`: [required] 44 | * `[DATA]` 45 | 46 | **Options**: 47 | 48 | * `--help`: Show this message and exit. 49 | 50 | ## `alga app` 51 | 52 | Apps installed on the TV 53 | 54 | **Usage**: 55 | 56 | ```console 57 | $ alga app [OPTIONS] COMMAND [ARGS]... 58 | ``` 59 | 60 | **Options**: 61 | 62 | * `--help`: Show this message and exit. 63 | 64 | **Commands**: 65 | 66 | * `close`: Close the provided app 67 | * `current`: Get the current app 68 | * `info`: Show info about specific app 69 | * `launch`: Launch an app 70 | * `list`: List installed apps 71 | * `pick`: Show picker for selecting an app. 72 | 73 | ### `alga app close` 74 | 75 | Close the provided app 76 | 77 | **Usage**: 78 | 79 | ```console 80 | $ alga app close [OPTIONS] APP_ID 81 | ``` 82 | 83 | **Arguments**: 84 | 85 | * `APP_ID`: [required] 86 | 87 | **Options**: 88 | 89 | * `--help`: Show this message and exit. 90 | 91 | ### `alga app current` 92 | 93 | Get the current app 94 | 95 | **Usage**: 96 | 97 | ```console 98 | $ alga app current [OPTIONS] 99 | ``` 100 | 101 | **Options**: 102 | 103 | * `--help`: Show this message and exit. 104 | 105 | ### `alga app info` 106 | 107 | Show info about specific app 108 | 109 | **Usage**: 110 | 111 | ```console 112 | $ alga app info [OPTIONS] APP_ID 113 | ``` 114 | 115 | **Arguments**: 116 | 117 | * `APP_ID`: [required] 118 | 119 | **Options**: 120 | 121 | * `--help`: Show this message and exit. 122 | 123 | ### `alga app launch` 124 | 125 | Launch an app 126 | 127 | **Usage**: 128 | 129 | ```console 130 | $ alga app launch [OPTIONS] APP_ID [DATA] 131 | ``` 132 | 133 | **Arguments**: 134 | 135 | * `APP_ID`: [required] 136 | * `[DATA]` 137 | 138 | **Options**: 139 | 140 | * `--help`: Show this message and exit. 141 | 142 | ### `alga app list` 143 | 144 | List installed apps 145 | 146 | **Usage**: 147 | 148 | ```console 149 | $ alga app list [OPTIONS] 150 | ``` 151 | 152 | **Options**: 153 | 154 | * `--help`: Show this message and exit. 155 | 156 | ### `alga app pick` 157 | 158 | Show picker for selecting an app. 159 | 160 | **Usage**: 161 | 162 | ```console 163 | $ alga app pick [OPTIONS] 164 | ``` 165 | 166 | **Options**: 167 | 168 | * `--help`: Show this message and exit. 169 | 170 | ## `alga channel` 171 | 172 | TV channels 173 | 174 | **Usage**: 175 | 176 | ```console 177 | $ alga channel [OPTIONS] COMMAND [ARGS]... 178 | ``` 179 | 180 | **Options**: 181 | 182 | * `--help`: Show this message and exit. 183 | 184 | **Commands**: 185 | 186 | * `current`: Get the current channel 187 | * `down`: Change channel down 188 | * `list`: List available channels 189 | * `pick`: Show picker for selecting a channel. 190 | * `set`: Change to specific channel 191 | * `up`: Change channel up 192 | 193 | ### `alga channel current` 194 | 195 | Get the current channel 196 | 197 | **Usage**: 198 | 199 | ```console 200 | $ alga channel current [OPTIONS] 201 | ``` 202 | 203 | **Options**: 204 | 205 | * `--help`: Show this message and exit. 206 | 207 | ### `alga channel down` 208 | 209 | Change channel down 210 | 211 | **Usage**: 212 | 213 | ```console 214 | $ alga channel down [OPTIONS] 215 | ``` 216 | 217 | **Options**: 218 | 219 | * `--help`: Show this message and exit. 220 | 221 | ### `alga channel list` 222 | 223 | List available channels 224 | 225 | **Usage**: 226 | 227 | ```console 228 | $ alga channel list [OPTIONS] 229 | ``` 230 | 231 | **Options**: 232 | 233 | * `--help`: Show this message and exit. 234 | 235 | ### `alga channel pick` 236 | 237 | Show picker for selecting a channel. 238 | 239 | **Usage**: 240 | 241 | ```console 242 | $ alga channel pick [OPTIONS] 243 | ``` 244 | 245 | **Options**: 246 | 247 | * `--help`: Show this message and exit. 248 | 249 | ### `alga channel set` 250 | 251 | Change to specific channel 252 | 253 | **Usage**: 254 | 255 | ```console 256 | $ alga channel set [OPTIONS] VALUE 257 | ``` 258 | 259 | **Arguments**: 260 | 261 | * `VALUE`: [required] 262 | 263 | **Options**: 264 | 265 | * `--help`: Show this message and exit. 266 | 267 | ### `alga channel up` 268 | 269 | Change channel up 270 | 271 | **Usage**: 272 | 273 | ```console 274 | $ alga channel up [OPTIONS] 275 | ``` 276 | 277 | **Options**: 278 | 279 | * `--help`: Show this message and exit. 280 | 281 | ## `alga input` 282 | 283 | HDMI and similar inputs 284 | 285 | **Usage**: 286 | 287 | ```console 288 | $ alga input [OPTIONS] COMMAND [ARGS]... 289 | ``` 290 | 291 | **Options**: 292 | 293 | * `--help`: Show this message and exit. 294 | 295 | **Commands**: 296 | 297 | * `list`: List available inputs 298 | * `pick`: Show picker for selecting an input. 299 | * `set`: Switch to given input 300 | 301 | ### `alga input list` 302 | 303 | List available inputs 304 | 305 | **Usage**: 306 | 307 | ```console 308 | $ alga input list [OPTIONS] 309 | ``` 310 | 311 | **Options**: 312 | 313 | * `--help`: Show this message and exit. 314 | 315 | ### `alga input pick` 316 | 317 | Show picker for selecting an input. 318 | 319 | **Usage**: 320 | 321 | ```console 322 | $ alga input pick [OPTIONS] 323 | ``` 324 | 325 | **Options**: 326 | 327 | * `--help`: Show this message and exit. 328 | 329 | ### `alga input set` 330 | 331 | Switch to given input 332 | 333 | **Usage**: 334 | 335 | ```console 336 | $ alga input set [OPTIONS] VALUE 337 | ``` 338 | 339 | **Arguments**: 340 | 341 | * `VALUE`: [required] 342 | 343 | **Options**: 344 | 345 | * `--help`: Show this message and exit. 346 | 347 | ## `alga media` 348 | 349 | Control the playing media 350 | 351 | **Usage**: 352 | 353 | ```console 354 | $ alga media [OPTIONS] COMMAND [ARGS]... 355 | ``` 356 | 357 | **Options**: 358 | 359 | * `--help`: Show this message and exit. 360 | 361 | **Commands**: 362 | 363 | * `fast-forward`: Fast forward media 364 | * `pause`: Pause media 365 | * `play`: Play media 366 | * `rewind`: Rewind media 367 | * `stop`: Stop media 368 | 369 | ### `alga media fast-forward` 370 | 371 | Fast forward media 372 | 373 | **Usage**: 374 | 375 | ```console 376 | $ alga media fast-forward [OPTIONS] 377 | ``` 378 | 379 | **Options**: 380 | 381 | * `--help`: Show this message and exit. 382 | 383 | ### `alga media pause` 384 | 385 | Pause media 386 | 387 | **Usage**: 388 | 389 | ```console 390 | $ alga media pause [OPTIONS] 391 | ``` 392 | 393 | **Options**: 394 | 395 | * `--help`: Show this message and exit. 396 | 397 | ### `alga media play` 398 | 399 | Play media 400 | 401 | **Usage**: 402 | 403 | ```console 404 | $ alga media play [OPTIONS] 405 | ``` 406 | 407 | **Options**: 408 | 409 | * `--help`: Show this message and exit. 410 | 411 | ### `alga media rewind` 412 | 413 | Rewind media 414 | 415 | **Usage**: 416 | 417 | ```console 418 | $ alga media rewind [OPTIONS] 419 | ``` 420 | 421 | **Options**: 422 | 423 | * `--help`: Show this message and exit. 424 | 425 | ### `alga media stop` 426 | 427 | Stop media 428 | 429 | **Usage**: 430 | 431 | ```console 432 | $ alga media stop [OPTIONS] 433 | ``` 434 | 435 | **Options**: 436 | 437 | * `--help`: Show this message and exit. 438 | 439 | ## `alga power` 440 | 441 | Turn TV (or screen) on and off 442 | 443 | **Usage**: 444 | 445 | ```console 446 | $ alga power [OPTIONS] COMMAND [ARGS]... 447 | ``` 448 | 449 | **Options**: 450 | 451 | * `--help`: Show this message and exit. 452 | 453 | **Commands**: 454 | 455 | * `off`: Turn TV off 456 | * `on`: Turn TV on via Wake-on-LAN 457 | * `screen-off`: Turn TV screen off 458 | * `screen-on`: Turn TV screen on 459 | * `screen-state`: Show if TV screen is active or off 460 | 461 | ### `alga power off` 462 | 463 | Turn TV off 464 | 465 | **Usage**: 466 | 467 | ```console 468 | $ alga power off [OPTIONS] 469 | ``` 470 | 471 | **Options**: 472 | 473 | * `--help`: Show this message and exit. 474 | 475 | ### `alga power on` 476 | 477 | Turn TV on via Wake-on-LAN 478 | 479 | **Usage**: 480 | 481 | ```console 482 | $ alga power on [OPTIONS] 483 | ``` 484 | 485 | **Options**: 486 | 487 | * `--help`: Show this message and exit. 488 | 489 | ### `alga power screen-off` 490 | 491 | Turn TV screen off 492 | 493 | **Usage**: 494 | 495 | ```console 496 | $ alga power screen-off [OPTIONS] 497 | ``` 498 | 499 | **Options**: 500 | 501 | * `--help`: Show this message and exit. 502 | 503 | ### `alga power screen-on` 504 | 505 | Turn TV screen on 506 | 507 | **Usage**: 508 | 509 | ```console 510 | $ alga power screen-on [OPTIONS] 511 | ``` 512 | 513 | **Options**: 514 | 515 | * `--help`: Show this message and exit. 516 | 517 | ### `alga power screen-state` 518 | 519 | Show if TV screen is active or off 520 | 521 | **Usage**: 522 | 523 | ```console 524 | $ alga power screen-state [OPTIONS] 525 | ``` 526 | 527 | **Options**: 528 | 529 | * `--help`: Show this message and exit. 530 | 531 | ## `alga remote` 532 | 533 | Remote control button presses 534 | 535 | **Usage**: 536 | 537 | ```console 538 | $ alga remote [OPTIONS] COMMAND [ARGS]... 539 | ``` 540 | 541 | **Options**: 542 | 543 | * `--help`: Show this message and exit. 544 | 545 | **Commands**: 546 | 547 | * `send`: Send a button press to the TV 548 | 549 | ### `alga remote send` 550 | 551 | Send a button press to the TV 552 | 553 | **Usage**: 554 | 555 | ```console 556 | $ alga remote send [OPTIONS] BUTTON 557 | ``` 558 | 559 | **Arguments**: 560 | 561 | * `BUTTON`: [required] 562 | 563 | **Options**: 564 | 565 | * `--help`: Show this message and exit. 566 | 567 | ## `alga sound-output` 568 | 569 | Audio output device 570 | 571 | **Usage**: 572 | 573 | ```console 574 | $ alga sound-output [OPTIONS] COMMAND [ARGS]... 575 | ``` 576 | 577 | **Options**: 578 | 579 | * `--help`: Show this message and exit. 580 | 581 | **Commands**: 582 | 583 | * `get`: Show the current output device 584 | * `pick`: Show picker for selecting a sound output... 585 | * `set`: Change the output device 586 | 587 | ### `alga sound-output get` 588 | 589 | Show the current output device 590 | 591 | **Usage**: 592 | 593 | ```console 594 | $ alga sound-output get [OPTIONS] 595 | ``` 596 | 597 | **Options**: 598 | 599 | * `--help`: Show this message and exit. 600 | 601 | ### `alga sound-output pick` 602 | 603 | Show picker for selecting a sound output device. 604 | 605 | **Usage**: 606 | 607 | ```console 608 | $ alga sound-output pick [OPTIONS] 609 | ``` 610 | 611 | **Options**: 612 | 613 | * `--help`: Show this message and exit. 614 | 615 | ### `alga sound-output set` 616 | 617 | Change the output device 618 | 619 | **Usage**: 620 | 621 | ```console 622 | $ alga sound-output set [OPTIONS] VALUE 623 | ``` 624 | 625 | **Arguments**: 626 | 627 | * `VALUE`: [required] 628 | 629 | **Options**: 630 | 631 | * `--help`: Show this message and exit. 632 | 633 | ## `alga tv` 634 | 635 | Set up TVs to manage via Alga 636 | 637 | **Usage**: 638 | 639 | ```console 640 | $ alga tv [OPTIONS] COMMAND [ARGS]... 641 | ``` 642 | 643 | **Options**: 644 | 645 | * `--help`: Show this message and exit. 646 | 647 | **Commands**: 648 | 649 | * `add`: Pair a new TV 650 | * `list`: List current TVs 651 | * `remove`: Remove a TV 652 | * `rename`: Change the identifier for a TV 653 | * `set-default`: Set the default TV 654 | 655 | ### `alga tv add` 656 | 657 | Pair a new TV 658 | 659 | **Usage**: 660 | 661 | ```console 662 | $ alga tv add [OPTIONS] NAME [HOSTNAME] 663 | ``` 664 | 665 | **Arguments**: 666 | 667 | * `NAME`: [required] 668 | * `[HOSTNAME]`: [default: lgwebostv] 669 | 670 | **Options**: 671 | 672 | * `--help`: Show this message and exit. 673 | 674 | ### `alga tv list` 675 | 676 | List current TVs 677 | 678 | **Usage**: 679 | 680 | ```console 681 | $ alga tv list [OPTIONS] 682 | ``` 683 | 684 | **Options**: 685 | 686 | * `--help`: Show this message and exit. 687 | 688 | ### `alga tv remove` 689 | 690 | Remove a TV 691 | 692 | **Usage**: 693 | 694 | ```console 695 | $ alga tv remove [OPTIONS] NAME 696 | ``` 697 | 698 | **Arguments**: 699 | 700 | * `NAME`: [required] 701 | 702 | **Options**: 703 | 704 | * `--help`: Show this message and exit. 705 | 706 | ### `alga tv rename` 707 | 708 | Change the identifier for a TV 709 | 710 | **Usage**: 711 | 712 | ```console 713 | $ alga tv rename [OPTIONS] OLD_NAME NEW_NAME 714 | ``` 715 | 716 | **Arguments**: 717 | 718 | * `OLD_NAME`: [required] 719 | * `NEW_NAME`: [required] 720 | 721 | **Options**: 722 | 723 | * `--help`: Show this message and exit. 724 | 725 | ### `alga tv set-default` 726 | 727 | Set the default TV 728 | 729 | **Usage**: 730 | 731 | ```console 732 | $ alga tv set-default [OPTIONS] NAME 733 | ``` 734 | 735 | **Arguments**: 736 | 737 | * `NAME`: [required] 738 | 739 | **Options**: 740 | 741 | * `--help`: Show this message and exit. 742 | 743 | ## `alga version` 744 | 745 | Print Alga version 746 | 747 | **Usage**: 748 | 749 | ```console 750 | $ alga version [OPTIONS] 751 | ``` 752 | 753 | **Options**: 754 | 755 | * `--help`: Show this message and exit. 756 | 757 | ## `alga volume` 758 | 759 | Audio volume 760 | 761 | **Usage**: 762 | 763 | ```console 764 | $ alga volume [OPTIONS] COMMAND [ARGS]... 765 | ``` 766 | 767 | **Options**: 768 | 769 | * `--help`: Show this message and exit. 770 | 771 | **Commands**: 772 | 773 | * `down`: Turn volume down 774 | * `get`: Get current volume 775 | * `mute`: Mute audio 776 | * `set`: Set volume to specific amount 777 | * `unmute`: Unmute audio 778 | * `up`: Turn volume up 779 | 780 | ### `alga volume down` 781 | 782 | Turn volume down 783 | 784 | **Usage**: 785 | 786 | ```console 787 | $ alga volume down [OPTIONS] 788 | ``` 789 | 790 | **Options**: 791 | 792 | * `--help`: Show this message and exit. 793 | 794 | ### `alga volume get` 795 | 796 | Get current volume 797 | 798 | **Usage**: 799 | 800 | ```console 801 | $ alga volume get [OPTIONS] 802 | ``` 803 | 804 | **Options**: 805 | 806 | * `--help`: Show this message and exit. 807 | 808 | ### `alga volume mute` 809 | 810 | Mute audio 811 | 812 | **Usage**: 813 | 814 | ```console 815 | $ alga volume mute [OPTIONS] 816 | ``` 817 | 818 | **Options**: 819 | 820 | * `--help`: Show this message and exit. 821 | 822 | ### `alga volume set` 823 | 824 | Set volume to specific amount 825 | 826 | **Usage**: 827 | 828 | ```console 829 | $ alga volume set [OPTIONS] VALUE 830 | ``` 831 | 832 | **Arguments**: 833 | 834 | * `VALUE`: [required] 835 | 836 | **Options**: 837 | 838 | * `--help`: Show this message and exit. 839 | 840 | ### `alga volume unmute` 841 | 842 | Unmute audio 843 | 844 | **Usage**: 845 | 846 | ```console 847 | $ alga volume unmute [OPTIONS] 848 | ``` 849 | 850 | **Options**: 851 | 852 | * `--help`: Show this message and exit. 853 | 854 | ### `alga volume up` 855 | 856 | Turn volume up 857 | 858 | **Usage**: 859 | 860 | ```console 861 | $ alga volume up [OPTIONS] 862 | ``` 863 | 864 | **Options**: 865 | 866 | * `--help`: Show this message and exit. 867 | -------------------------------------------------------------------------------- /uv.lock: -------------------------------------------------------------------------------- 1 | version = 1 2 | revision = 3 3 | requires-python = ">=3.10" 4 | 5 | [[package]] 6 | name = "alga" 7 | version = "2.2.0" 8 | source = { editable = "." } 9 | dependencies = [ 10 | { name = "cfgs" }, 11 | { name = "getmac" }, 12 | { name = "pzp" }, 13 | { name = "rich" }, 14 | { name = "typer" }, 15 | { name = "wakeonlan" }, 16 | { name = "websocket-client" }, 17 | ] 18 | 19 | [package.dev-dependencies] 20 | dev = [ 21 | { name = "coverage" }, 22 | { name = "faker" }, 23 | { name = "pytest" }, 24 | { name = "pytest-cov" }, 25 | { name = "ty" }, 26 | ] 27 | 28 | [package.metadata] 29 | requires-dist = [ 30 | { name = "cfgs", specifier = ">=0.13.0" }, 31 | { name = "getmac", specifier = ">=0.9.0" }, 32 | { name = "pzp", specifier = ">=0.0.25" }, 33 | { name = "rich", specifier = ">=13.0.0" }, 34 | { name = "typer", specifier = ">=0.15.4" }, 35 | { name = "wakeonlan", specifier = ">=2.0.0" }, 36 | { name = "websocket-client", specifier = ">=1.0.0" }, 37 | ] 38 | 39 | [package.metadata.requires-dev] 40 | dev = [ 41 | { name = "coverage", specifier = "==7.13.0" }, 42 | { name = "faker", specifier = "==39.0.0" }, 43 | { name = "pytest", specifier = "==9.0.2" }, 44 | { name = "pytest-cov", specifier = "==7.0.0" }, 45 | { name = "ty", specifier = "==0.0.5" }, 46 | ] 47 | 48 | [[package]] 49 | name = "cfgs" 50 | version = "0.13.0" 51 | source = { registry = "https://pypi.org/simple" } 52 | sdist = { url = "https://files.pythonhosted.org/packages/0f/95/b7d0e411b1b64cfe18016616b5e9baf733071182c6d8ff6a7d4ed1138751/cfgs-0.13.0.tar.gz", hash = "sha256:cef47e67f0512783ee83e24cc2f39e5b23b5d4ca0c32bbd721bd64f48636667e", size = 7482, upload-time = "2023-10-05T14:15:56.443Z" } 53 | wheels = [ 54 | { url = "https://files.pythonhosted.org/packages/0f/bb/870a4df371ec417f05cb13304b14687eba0730de11921f8bf4b30a9f0a46/cfgs-0.13.0-py3-none-any.whl", hash = "sha256:a3e80a31e32c2fae987cb3c38481bbbf4b276bc517e8862f87e9ff174620fa09", size = 7767, upload-time = "2023-10-05T14:14:42.31Z" }, 55 | ] 56 | 57 | [[package]] 58 | name = "click" 59 | version = "8.3.1" 60 | source = { registry = "https://pypi.org/simple" } 61 | dependencies = [ 62 | { name = "colorama", marker = "sys_platform == 'win32'" }, 63 | ] 64 | sdist = { url = "https://files.pythonhosted.org/packages/3d/fa/656b739db8587d7b5dfa22e22ed02566950fbfbcdc20311993483657a5c0/click-8.3.1.tar.gz", hash = "sha256:12ff4785d337a1bb490bb7e9c2b1ee5da3112e94a8622f26a6c77f5d2fc6842a", size = 295065, upload-time = "2025-11-15T20:45:42.706Z" } 65 | wheels = [ 66 | { url = "https://files.pythonhosted.org/packages/98/78/01c019cdb5d6498122777c1a43056ebb3ebfeef2076d9d026bfe15583b2b/click-8.3.1-py3-none-any.whl", hash = "sha256:981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6", size = 108274, upload-time = "2025-11-15T20:45:41.139Z" }, 67 | ] 68 | 69 | [[package]] 70 | name = "colorama" 71 | version = "0.4.6" 72 | source = { registry = "https://pypi.org/simple" } 73 | sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } 74 | wheels = [ 75 | { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, 76 | ] 77 | 78 | [[package]] 79 | name = "coverage" 80 | version = "7.13.0" 81 | source = { registry = "https://pypi.org/simple" } 82 | sdist = { url = "https://files.pythonhosted.org/packages/b6/45/2c665ca77ec32ad67e25c77daf1cee28ee4558f3bc571cdbaf88a00b9f23/coverage-7.13.0.tar.gz", hash = "sha256:a394aa27f2d7ff9bc04cf703817773a59ad6dfbd577032e690f961d2460ee936", size = 820905, upload-time = "2025-12-08T13:14:38.055Z" } 83 | wheels = [ 84 | { url = "https://files.pythonhosted.org/packages/db/08/bdd7ccca14096f7eb01412b87ac11e5d16e4cb54b6e328afc9dee8bdaec1/coverage-7.13.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:02d9fb9eccd48f6843c98a37bd6817462f130b86da8660461e8f5e54d4c06070", size = 217979, upload-time = "2025-12-08T13:12:14.505Z" }, 85 | { url = "https://files.pythonhosted.org/packages/fa/f0/d1302e3416298a28b5663ae1117546a745d9d19fde7e28402b2c5c3e2109/coverage-7.13.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:367449cf07d33dc216c083f2036bb7d976c6e4903ab31be400ad74ad9f85ce98", size = 218496, upload-time = "2025-12-08T13:12:16.237Z" }, 86 | { url = "https://files.pythonhosted.org/packages/07/26/d36c354c8b2a320819afcea6bffe72839efd004b98d1d166b90801d49d57/coverage-7.13.0-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:cdb3c9f8fef0a954c632f64328a3935988d33a6604ce4bf67ec3e39670f12ae5", size = 245237, upload-time = "2025-12-08T13:12:17.858Z" }, 87 | { url = "https://files.pythonhosted.org/packages/91/52/be5e85631e0eec547873d8b08dd67a5f6b111ecfe89a86e40b89b0c1c61c/coverage-7.13.0-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:d10fd186aac2316f9bbb46ef91977f9d394ded67050ad6d84d94ed6ea2e8e54e", size = 247061, upload-time = "2025-12-08T13:12:19.132Z" }, 88 | { url = "https://files.pythonhosted.org/packages/0f/45/a5e8fa0caf05fbd8fa0402470377bff09cc1f026d21c05c71e01295e55ab/coverage-7.13.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7f88ae3e69df2ab62fb0bc5219a597cb890ba5c438190ffa87490b315190bb33", size = 248928, upload-time = "2025-12-08T13:12:20.702Z" }, 89 | { url = "https://files.pythonhosted.org/packages/f5/42/ffb5069b6fd1b95fae482e02f3fecf380d437dd5a39bae09f16d2e2e7e01/coverage-7.13.0-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c4be718e51e86f553bcf515305a158a1cd180d23b72f07ae76d6017c3cc5d791", size = 245931, upload-time = "2025-12-08T13:12:22.243Z" }, 90 | { url = "https://files.pythonhosted.org/packages/95/6e/73e809b882c2858f13e55c0c36e94e09ce07e6165d5644588f9517efe333/coverage-7.13.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:a00d3a393207ae12f7c49bb1c113190883b500f48979abb118d8b72b8c95c032", size = 246968, upload-time = "2025-12-08T13:12:23.52Z" }, 91 | { url = "https://files.pythonhosted.org/packages/87/08/64ebd9e64b6adb8b4a4662133d706fbaccecab972e0b3ccc23f64e2678ad/coverage-7.13.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:3a7b1cd820e1b6116f92c6128f1188e7afe421c7e1b35fa9836b11444e53ebd9", size = 244972, upload-time = "2025-12-08T13:12:24.781Z" }, 92 | { url = "https://files.pythonhosted.org/packages/12/97/f4d27c6fe0cb375a5eced4aabcaef22de74766fb80a3d5d2015139e54b22/coverage-7.13.0-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:37eee4e552a65866f15dedd917d5e5f3d59805994260720821e2c1b51ac3248f", size = 245241, upload-time = "2025-12-08T13:12:28.041Z" }, 93 | { url = "https://files.pythonhosted.org/packages/0c/94/42f8ae7f633bf4c118bf1038d80472f9dade88961a466f290b81250f7ab7/coverage-7.13.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:62d7c4f13102148c78d7353c6052af6d899a7f6df66a32bddcc0c0eb7c5326f8", size = 245847, upload-time = "2025-12-08T13:12:29.337Z" }, 94 | { url = "https://files.pythonhosted.org/packages/a8/2f/6369ca22b6b6d933f4f4d27765d313d8914cc4cce84f82a16436b1a233db/coverage-7.13.0-cp310-cp310-win32.whl", hash = "sha256:24e4e56304fdb56f96f80eabf840eab043b3afea9348b88be680ec5986780a0f", size = 220573, upload-time = "2025-12-08T13:12:30.905Z" }, 95 | { url = "https://files.pythonhosted.org/packages/f1/dc/a6a741e519acceaeccc70a7f4cfe5d030efc4b222595f0677e101af6f1f3/coverage-7.13.0-cp310-cp310-win_amd64.whl", hash = "sha256:74c136e4093627cf04b26a35dab8cbfc9b37c647f0502fc313376e11726ba303", size = 221509, upload-time = "2025-12-08T13:12:32.09Z" }, 96 | { url = "https://files.pythonhosted.org/packages/f1/dc/888bf90d8b1c3d0b4020a40e52b9f80957d75785931ec66c7dfaccc11c7d/coverage-7.13.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:0dfa3855031070058add1a59fdfda0192fd3e8f97e7c81de0596c145dea51820", size = 218104, upload-time = "2025-12-08T13:12:33.333Z" }, 97 | { url = "https://files.pythonhosted.org/packages/8d/ea/069d51372ad9c380214e86717e40d1a743713a2af191cfba30a0911b0a4a/coverage-7.13.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4fdb6f54f38e334db97f72fa0c701e66d8479af0bc3f9bfb5b90f1c30f54500f", size = 218606, upload-time = "2025-12-08T13:12:34.498Z" }, 98 | { url = "https://files.pythonhosted.org/packages/68/09/77b1c3a66c2aa91141b6c4471af98e5b1ed9b9e6d17255da5eb7992299e3/coverage-7.13.0-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:7e442c013447d1d8d195be62852270b78b6e255b79b8675bad8479641e21fd96", size = 248999, upload-time = "2025-12-08T13:12:36.02Z" }, 99 | { url = "https://files.pythonhosted.org/packages/0a/32/2e2f96e9d5691eaf1181d9040f850b8b7ce165ea10810fd8e2afa534cef7/coverage-7.13.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:1ed5630d946859de835a85e9a43b721123a8a44ec26e2830b296d478c7fd4259", size = 250925, upload-time = "2025-12-08T13:12:37.221Z" }, 100 | { url = "https://files.pythonhosted.org/packages/7b/45/b88ddac1d7978859b9a39a8a50ab323186148f1d64bc068f86fc77706321/coverage-7.13.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7f15a931a668e58087bc39d05d2b4bf4b14ff2875b49c994bbdb1c2217a8daeb", size = 253032, upload-time = "2025-12-08T13:12:38.763Z" }, 101 | { url = "https://files.pythonhosted.org/packages/71/cb/e15513f94c69d4820a34b6bf3d2b1f9f8755fa6021be97c7065442d7d653/coverage-7.13.0-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:30a3a201a127ea57f7e14ba43c93c9c4be8b7d17a26e03bb49e6966d019eede9", size = 249134, upload-time = "2025-12-08T13:12:40.382Z" }, 102 | { url = "https://files.pythonhosted.org/packages/09/61/d960ff7dc9e902af3310ce632a875aaa7860f36d2bc8fc8b37ee7c1b82a5/coverage-7.13.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7a485ff48fbd231efa32d58f479befce52dcb6bfb2a88bb7bf9a0b89b1bc8030", size = 250731, upload-time = "2025-12-08T13:12:41.992Z" }, 103 | { url = "https://files.pythonhosted.org/packages/98/34/c7c72821794afc7c7c2da1db8f00c2c98353078aa7fb6b5ff36aac834b52/coverage-7.13.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:22486cdafba4f9e471c816a2a5745337742a617fef68e890d8baf9f3036d7833", size = 248795, upload-time = "2025-12-08T13:12:43.331Z" }, 104 | { url = "https://files.pythonhosted.org/packages/0a/5b/e0f07107987a43b2def9aa041c614ddb38064cbf294a71ef8c67d43a0cdd/coverage-7.13.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:263c3dbccc78e2e331e59e90115941b5f53e85cfcc6b3b2fbff1fd4e3d2c6ea8", size = 248514, upload-time = "2025-12-08T13:12:44.546Z" }, 105 | { url = "https://files.pythonhosted.org/packages/71/c2/c949c5d3b5e9fc6dd79e1b73cdb86a59ef14f3709b1d72bf7668ae12e000/coverage-7.13.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:e5330fa0cc1f5c3c4c3bb8e101b742025933e7848989370a1d4c8c5e401ea753", size = 249424, upload-time = "2025-12-08T13:12:45.759Z" }, 106 | { url = "https://files.pythonhosted.org/packages/11/f1/bbc009abd6537cec0dffb2cc08c17a7f03de74c970e6302db4342a6e05af/coverage-7.13.0-cp311-cp311-win32.whl", hash = "sha256:0f4872f5d6c54419c94c25dd6ae1d015deeb337d06e448cd890a1e89a8ee7f3b", size = 220597, upload-time = "2025-12-08T13:12:47.378Z" }, 107 | { url = "https://files.pythonhosted.org/packages/c4/f6/d9977f2fb51c10fbaed0718ce3d0a8541185290b981f73b1d27276c12d91/coverage-7.13.0-cp311-cp311-win_amd64.whl", hash = "sha256:51a202e0f80f241ccb68e3e26e19ab5b3bf0f813314f2c967642f13ebcf1ddfe", size = 221536, upload-time = "2025-12-08T13:12:48.7Z" }, 108 | { url = "https://files.pythonhosted.org/packages/be/ad/3fcf43fd96fb43e337a3073dea63ff148dcc5c41ba7a14d4c7d34efb2216/coverage-7.13.0-cp311-cp311-win_arm64.whl", hash = "sha256:d2a9d7f1c11487b1c69367ab3ac2d81b9b3721f097aa409a3191c3e90f8f3dd7", size = 220206, upload-time = "2025-12-08T13:12:50.365Z" }, 109 | { url = "https://files.pythonhosted.org/packages/9b/f1/2619559f17f31ba00fc40908efd1fbf1d0a5536eb75dc8341e7d660a08de/coverage-7.13.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:0b3d67d31383c4c68e19a88e28fc4c2e29517580f1b0ebec4a069d502ce1e0bf", size = 218274, upload-time = "2025-12-08T13:12:52.095Z" }, 110 | { url = "https://files.pythonhosted.org/packages/2b/11/30d71ae5d6e949ff93b2a79a2c1b4822e00423116c5c6edfaeef37301396/coverage-7.13.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:581f086833d24a22c89ae0fe2142cfaa1c92c930adf637ddf122d55083fb5a0f", size = 218638, upload-time = "2025-12-08T13:12:53.418Z" }, 111 | { url = "https://files.pythonhosted.org/packages/79/c2/fce80fc6ded8d77e53207489d6065d0fed75db8951457f9213776615e0f5/coverage-7.13.0-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:0a3a30f0e257df382f5f9534d4ce3d4cf06eafaf5192beb1a7bd066cb10e78fb", size = 250129, upload-time = "2025-12-08T13:12:54.744Z" }, 112 | { url = "https://files.pythonhosted.org/packages/5b/b6/51b5d1eb6fcbb9a1d5d6984e26cbe09018475c2922d554fd724dd0f056ee/coverage-7.13.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:583221913fbc8f53b88c42e8dbb8fca1d0f2e597cb190ce45916662b8b9d9621", size = 252885, upload-time = "2025-12-08T13:12:56.401Z" }, 113 | { url = "https://files.pythonhosted.org/packages/0d/f8/972a5affea41de798691ab15d023d3530f9f56a72e12e243f35031846ff7/coverage-7.13.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5f5d9bd30756fff3e7216491a0d6d520c448d5124d3d8e8f56446d6412499e74", size = 253974, upload-time = "2025-12-08T13:12:57.718Z" }, 114 | { url = "https://files.pythonhosted.org/packages/8a/56/116513aee860b2c7968aa3506b0f59b22a959261d1dbf3aea7b4450a7520/coverage-7.13.0-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a23e5a1f8b982d56fa64f8e442e037f6ce29322f1f9e6c2344cd9e9f4407ee57", size = 250538, upload-time = "2025-12-08T13:12:59.254Z" }, 115 | { url = "https://files.pythonhosted.org/packages/d6/75/074476d64248fbadf16dfafbf93fdcede389ec821f74ca858d7c87d2a98c/coverage-7.13.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:9b01c22bc74a7fb44066aaf765224c0d933ddf1f5047d6cdfe4795504a4493f8", size = 251912, upload-time = "2025-12-08T13:13:00.604Z" }, 116 | { url = "https://files.pythonhosted.org/packages/f2/d2/aa4f8acd1f7c06024705c12609d8698c51b27e4d635d717cd1934c9668e2/coverage-7.13.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:898cce66d0836973f48dda4e3514d863d70142bdf6dfab932b9b6a90ea5b222d", size = 250054, upload-time = "2025-12-08T13:13:01.892Z" }, 117 | { url = "https://files.pythonhosted.org/packages/19/98/8df9e1af6a493b03694a1e8070e024e7d2cdc77adedc225a35e616d505de/coverage-7.13.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:3ab483ea0e251b5790c2aac03acde31bff0c736bf8a86829b89382b407cd1c3b", size = 249619, upload-time = "2025-12-08T13:13:03.236Z" }, 118 | { url = "https://files.pythonhosted.org/packages/d8/71/f8679231f3353018ca66ef647fa6fe7b77e6bff7845be54ab84f86233363/coverage-7.13.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:1d84e91521c5e4cb6602fe11ece3e1de03b2760e14ae4fcf1a4b56fa3c801fcd", size = 251496, upload-time = "2025-12-08T13:13:04.511Z" }, 119 | { url = "https://files.pythonhosted.org/packages/04/86/9cb406388034eaf3c606c22094edbbb82eea1fa9d20c0e9efadff20d0733/coverage-7.13.0-cp312-cp312-win32.whl", hash = "sha256:193c3887285eec1dbdb3f2bd7fbc351d570ca9c02ca756c3afbc71b3c98af6ef", size = 220808, upload-time = "2025-12-08T13:13:06.422Z" }, 120 | { url = "https://files.pythonhosted.org/packages/1c/59/af483673df6455795daf5f447c2f81a3d2fcfc893a22b8ace983791f6f34/coverage-7.13.0-cp312-cp312-win_amd64.whl", hash = "sha256:4f3e223b2b2db5e0db0c2b97286aba0036ca000f06aca9b12112eaa9af3d92ae", size = 221616, upload-time = "2025-12-08T13:13:07.95Z" }, 121 | { url = "https://files.pythonhosted.org/packages/64/b0/959d582572b30a6830398c60dd419c1965ca4b5fb38ac6b7093a0d50ca8d/coverage-7.13.0-cp312-cp312-win_arm64.whl", hash = "sha256:086cede306d96202e15a4b77ace8472e39d9f4e5f9fd92dd4fecdfb2313b2080", size = 220261, upload-time = "2025-12-08T13:13:09.581Z" }, 122 | { url = "https://files.pythonhosted.org/packages/7c/cc/bce226595eb3bf7d13ccffe154c3c487a22222d87ff018525ab4dd2e9542/coverage-7.13.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:28ee1c96109974af104028a8ef57cec21447d42d0e937c0275329272e370ebcf", size = 218297, upload-time = "2025-12-08T13:13:10.977Z" }, 123 | { url = "https://files.pythonhosted.org/packages/3b/9f/73c4d34600aae03447dff3d7ad1d0ac649856bfb87d1ca7d681cfc913f9e/coverage-7.13.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d1e97353dcc5587b85986cda4ff3ec98081d7e84dd95e8b2a6d59820f0545f8a", size = 218673, upload-time = "2025-12-08T13:13:12.562Z" }, 124 | { url = "https://files.pythonhosted.org/packages/63/ab/8fa097db361a1e8586535ae5073559e6229596b3489ec3ef2f5b38df8cb2/coverage-7.13.0-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:99acd4dfdfeb58e1937629eb1ab6ab0899b131f183ee5f23e0b5da5cba2fec74", size = 249652, upload-time = "2025-12-08T13:13:13.909Z" }, 125 | { url = "https://files.pythonhosted.org/packages/90/3a/9bfd4de2ff191feb37ef9465855ca56a6f2f30a3bca172e474130731ac3d/coverage-7.13.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:ff45e0cd8451e293b63ced93161e189780baf444119391b3e7d25315060368a6", size = 252251, upload-time = "2025-12-08T13:13:15.553Z" }, 126 | { url = "https://files.pythonhosted.org/packages/df/61/b5d8105f016e1b5874af0d7c67542da780ccd4a5f2244a433d3e20ceb1ad/coverage-7.13.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f4f72a85316d8e13234cafe0a9f81b40418ad7a082792fa4165bd7d45d96066b", size = 253492, upload-time = "2025-12-08T13:13:16.849Z" }, 127 | { url = "https://files.pythonhosted.org/packages/f3/b8/0fad449981803cc47a4694768b99823fb23632150743f9c83af329bb6090/coverage-7.13.0-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:11c21557d0e0a5a38632cbbaca5f008723b26a89d70db6315523df6df77d6232", size = 249850, upload-time = "2025-12-08T13:13:18.142Z" }, 128 | { url = "https://files.pythonhosted.org/packages/9a/e9/8d68337c3125014d918cf4327d5257553a710a2995a6a6de2ac77e5aa429/coverage-7.13.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:76541dc8d53715fb4f7a3a06b34b0dc6846e3c69bc6204c55653a85dd6220971", size = 251633, upload-time = "2025-12-08T13:13:19.56Z" }, 129 | { url = "https://files.pythonhosted.org/packages/55/14/d4112ab26b3a1bc4b3c1295d8452dcf399ed25be4cf649002fb3e64b2d93/coverage-7.13.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:6e9e451dee940a86789134b6b0ffbe31c454ade3b849bb8a9d2cca2541a8e91d", size = 249586, upload-time = "2025-12-08T13:13:20.883Z" }, 130 | { url = "https://files.pythonhosted.org/packages/2c/a9/22b0000186db663b0d82f86c2f1028099ae9ac202491685051e2a11a5218/coverage-7.13.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:5c67dace46f361125e6b9cace8fe0b729ed8479f47e70c89b838d319375c8137", size = 249412, upload-time = "2025-12-08T13:13:22.22Z" }, 131 | { url = "https://files.pythonhosted.org/packages/a1/2e/42d8e0d9e7527fba439acdc6ed24a2b97613b1dc85849b1dd935c2cffef0/coverage-7.13.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f59883c643cb19630500f57016f76cfdcd6845ca8c5b5ea1f6e17f74c8e5f511", size = 251191, upload-time = "2025-12-08T13:13:23.899Z" }, 132 | { url = "https://files.pythonhosted.org/packages/a4/af/8c7af92b1377fd8860536aadd58745119252aaaa71a5213e5a8e8007a9f5/coverage-7.13.0-cp313-cp313-win32.whl", hash = "sha256:58632b187be6f0be500f553be41e277712baa278147ecb7559983c6d9faf7ae1", size = 220829, upload-time = "2025-12-08T13:13:25.182Z" }, 133 | { url = "https://files.pythonhosted.org/packages/58/f9/725e8bf16f343d33cbe076c75dc8370262e194ff10072c0608b8e5cf33a3/coverage-7.13.0-cp313-cp313-win_amd64.whl", hash = "sha256:73419b89f812f498aca53f757dd834919b48ce4799f9d5cad33ca0ae442bdb1a", size = 221640, upload-time = "2025-12-08T13:13:26.836Z" }, 134 | { url = "https://files.pythonhosted.org/packages/8a/ff/e98311000aa6933cc79274e2b6b94a2fe0fe3434fca778eba82003675496/coverage-7.13.0-cp313-cp313-win_arm64.whl", hash = "sha256:eb76670874fdd6091eedcc856128ee48c41a9bbbb9c3f1c7c3cf169290e3ffd6", size = 220269, upload-time = "2025-12-08T13:13:28.116Z" }, 135 | { url = "https://files.pythonhosted.org/packages/cf/cf/bbaa2e1275b300343ea865f7d424cc0a2e2a1df6925a070b2b2d5d765330/coverage-7.13.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:6e63ccc6e0ad8986386461c3c4b737540f20426e7ec932f42e030320896c311a", size = 218990, upload-time = "2025-12-08T13:13:29.463Z" }, 136 | { url = "https://files.pythonhosted.org/packages/21/1d/82f0b3323b3d149d7672e7744c116e9c170f4957e0c42572f0366dbb4477/coverage-7.13.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:494f5459ffa1bd45e18558cd98710c36c0b8fbfa82a5eabcbe671d80ecffbfe8", size = 219340, upload-time = "2025-12-08T13:13:31.524Z" }, 137 | { url = "https://files.pythonhosted.org/packages/fb/e3/fe3fd4702a3832a255f4d43013eacb0ef5fc155a5960ea9269d8696db28b/coverage-7.13.0-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:06cac81bf10f74034e055e903f5f946e3e26fc51c09fc9f584e4a1605d977053", size = 260638, upload-time = "2025-12-08T13:13:32.965Z" }, 138 | { url = "https://files.pythonhosted.org/packages/ad/01/63186cb000307f2b4da463f72af9b85d380236965574c78e7e27680a2593/coverage-7.13.0-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f2ffc92b46ed6e6760f1d47a71e56b5664781bc68986dbd1836b2b70c0ce2071", size = 262705, upload-time = "2025-12-08T13:13:34.378Z" }, 139 | { url = "https://files.pythonhosted.org/packages/7c/a1/c0dacef0cc865f2455d59eed3548573ce47ed603205ffd0735d1d78b5906/coverage-7.13.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0602f701057c6823e5db1b74530ce85f17c3c5be5c85fc042ac939cbd909426e", size = 265125, upload-time = "2025-12-08T13:13:35.73Z" }, 140 | { url = "https://files.pythonhosted.org/packages/ef/92/82b99223628b61300bd382c205795533bed021505eab6dd86e11fb5d7925/coverage-7.13.0-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:25dc33618d45456ccb1d37bce44bc78cf269909aa14c4db2e03d63146a8a1493", size = 259844, upload-time = "2025-12-08T13:13:37.69Z" }, 141 | { url = "https://files.pythonhosted.org/packages/cf/2c/89b0291ae4e6cd59ef042708e1c438e2290f8c31959a20055d8768349ee2/coverage-7.13.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:71936a8b3b977ddd0b694c28c6a34f4fff2e9dd201969a4ff5d5fc7742d614b0", size = 262700, upload-time = "2025-12-08T13:13:39.525Z" }, 142 | { url = "https://files.pythonhosted.org/packages/bf/f9/a5f992efae1996245e796bae34ceb942b05db275e4b34222a9a40b9fbd3b/coverage-7.13.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:936bc20503ce24770c71938d1369461f0c5320830800933bc3956e2a4ded930e", size = 260321, upload-time = "2025-12-08T13:13:41.172Z" }, 143 | { url = "https://files.pythonhosted.org/packages/4c/89/a29f5d98c64fedbe32e2ac3c227fbf78edc01cc7572eee17d61024d89889/coverage-7.13.0-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:af0a583efaacc52ae2521f8d7910aff65cdb093091d76291ac5820d5e947fc1c", size = 259222, upload-time = "2025-12-08T13:13:43.282Z" }, 144 | { url = "https://files.pythonhosted.org/packages/b3/c3/940fe447aae302a6701ee51e53af7e08b86ff6eed7631e5740c157ee22b9/coverage-7.13.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:f1c23e24a7000da892a312fb17e33c5f94f8b001de44b7cf8ba2e36fbd15859e", size = 261411, upload-time = "2025-12-08T13:13:44.72Z" }, 145 | { url = "https://files.pythonhosted.org/packages/eb/31/12a4aec689cb942a89129587860ed4d0fd522d5fda81237147fde554b8ae/coverage-7.13.0-cp313-cp313t-win32.whl", hash = "sha256:5f8a0297355e652001015e93be345ee54393e45dc3050af4a0475c5a2b767d46", size = 221505, upload-time = "2025-12-08T13:13:46.332Z" }, 146 | { url = "https://files.pythonhosted.org/packages/65/8c/3b5fe3259d863572d2b0827642c50c3855d26b3aefe80bdc9eba1f0af3b0/coverage-7.13.0-cp313-cp313t-win_amd64.whl", hash = "sha256:6abb3a4c52f05e08460bd9acf04fec027f8718ecaa0d09c40ffbc3fbd70ecc39", size = 222569, upload-time = "2025-12-08T13:13:47.79Z" }, 147 | { url = "https://files.pythonhosted.org/packages/b0/39/f71fa8316a96ac72fc3908839df651e8eccee650001a17f2c78cdb355624/coverage-7.13.0-cp313-cp313t-win_arm64.whl", hash = "sha256:3ad968d1e3aa6ce5be295ab5fe3ae1bf5bb4769d0f98a80a0252d543a2ef2e9e", size = 220841, upload-time = "2025-12-08T13:13:49.243Z" }, 148 | { url = "https://files.pythonhosted.org/packages/f8/4b/9b54bedda55421449811dcd5263a2798a63f48896c24dfb92b0f1b0845bd/coverage-7.13.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:453b7ec753cf5e4356e14fe858064e5520c460d3bbbcb9c35e55c0d21155c256", size = 218343, upload-time = "2025-12-08T13:13:50.811Z" }, 149 | { url = "https://files.pythonhosted.org/packages/59/df/c3a1f34d4bba2e592c8979f924da4d3d4598b0df2392fbddb7761258e3dc/coverage-7.13.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:af827b7cbb303e1befa6c4f94fd2bf72f108089cfa0f8abab8f4ca553cf5ca5a", size = 218672, upload-time = "2025-12-08T13:13:52.284Z" }, 150 | { url = "https://files.pythonhosted.org/packages/07/62/eec0659e47857698645ff4e6ad02e30186eb8afd65214fd43f02a76537cb/coverage-7.13.0-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:9987a9e4f8197a1000280f7cc089e3ea2c8b3c0a64d750537809879a7b4ceaf9", size = 249715, upload-time = "2025-12-08T13:13:53.791Z" }, 151 | { url = "https://files.pythonhosted.org/packages/23/2d/3c7ff8b2e0e634c1f58d095f071f52ed3c23ff25be524b0ccae8b71f99f8/coverage-7.13.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:3188936845cd0cb114fa6a51842a304cdbac2958145d03be2377ec41eb285d19", size = 252225, upload-time = "2025-12-08T13:13:55.274Z" }, 152 | { url = "https://files.pythonhosted.org/packages/aa/ac/fb03b469d20e9c9a81093575003f959cf91a4a517b783aab090e4538764b/coverage-7.13.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a2bdb3babb74079f021696cb46b8bb5f5661165c385d3a238712b031a12355be", size = 253559, upload-time = "2025-12-08T13:13:57.161Z" }, 153 | { url = "https://files.pythonhosted.org/packages/29/62/14afa9e792383c66cc0a3b872a06ded6e4ed1079c7d35de274f11d27064e/coverage-7.13.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7464663eaca6adba4175f6c19354feea61ebbdd735563a03d1e472c7072d27bb", size = 249724, upload-time = "2025-12-08T13:13:58.692Z" }, 154 | { url = "https://files.pythonhosted.org/packages/31/b7/333f3dab2939070613696ab3ee91738950f0467778c6e5a5052e840646b7/coverage-7.13.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:8069e831f205d2ff1f3d355e82f511eb7c5522d7d413f5db5756b772ec8697f8", size = 251582, upload-time = "2025-12-08T13:14:00.642Z" }, 155 | { url = "https://files.pythonhosted.org/packages/81/cb/69162bda9381f39b2287265d7e29ee770f7c27c19f470164350a38318764/coverage-7.13.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:6fb2d5d272341565f08e962cce14cdf843a08ac43bd621783527adb06b089c4b", size = 249538, upload-time = "2025-12-08T13:14:02.556Z" }, 156 | { url = "https://files.pythonhosted.org/packages/e0/76/350387b56a30f4970abe32b90b2a434f87d29f8b7d4ae40d2e8a85aacfb3/coverage-7.13.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:5e70f92ef89bac1ac8a99b3324923b4749f008fdbd7aa9cb35e01d7a284a04f9", size = 249349, upload-time = "2025-12-08T13:14:04.015Z" }, 157 | { url = "https://files.pythonhosted.org/packages/86/0d/7f6c42b8d59f4c7e43ea3059f573c0dcfed98ba46eb43c68c69e52ae095c/coverage-7.13.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:4b5de7d4583e60d5fd246dd57fcd3a8aa23c6e118a8c72b38adf666ba8e7e927", size = 251011, upload-time = "2025-12-08T13:14:05.505Z" }, 158 | { url = "https://files.pythonhosted.org/packages/d7/f1/4bb2dff379721bb0b5c649d5c5eaf438462cad824acf32eb1b7ca0c7078e/coverage-7.13.0-cp314-cp314-win32.whl", hash = "sha256:a6c6e16b663be828a8f0b6c5027d36471d4a9f90d28444aa4ced4d48d7d6ae8f", size = 221091, upload-time = "2025-12-08T13:14:07.127Z" }, 159 | { url = "https://files.pythonhosted.org/packages/ba/44/c239da52f373ce379c194b0ee3bcc121020e397242b85f99e0afc8615066/coverage-7.13.0-cp314-cp314-win_amd64.whl", hash = "sha256:0900872f2fdb3ee5646b557918d02279dc3af3dfb39029ac4e945458b13f73bc", size = 221904, upload-time = "2025-12-08T13:14:08.542Z" }, 160 | { url = "https://files.pythonhosted.org/packages/89/1f/b9f04016d2a29c2e4a0307baefefad1a4ec5724946a2b3e482690486cade/coverage-7.13.0-cp314-cp314-win_arm64.whl", hash = "sha256:3a10260e6a152e5f03f26db4a407c4c62d3830b9af9b7c0450b183615f05d43b", size = 220480, upload-time = "2025-12-08T13:14:10.958Z" }, 161 | { url = "https://files.pythonhosted.org/packages/16/d4/364a1439766c8e8647860584171c36010ca3226e6e45b1753b1b249c5161/coverage-7.13.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:9097818b6cc1cfb5f174e3263eba4a62a17683bcfe5c4b5d07f4c97fa51fbf28", size = 219074, upload-time = "2025-12-08T13:14:13.345Z" }, 162 | { url = "https://files.pythonhosted.org/packages/ce/f4/71ba8be63351e099911051b2089662c03d5671437a0ec2171823c8e03bec/coverage-7.13.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0018f73dfb4301a89292c73be6ba5f58722ff79f51593352759c1790ded1cabe", size = 219342, upload-time = "2025-12-08T13:14:15.02Z" }, 163 | { url = "https://files.pythonhosted.org/packages/5e/25/127d8ed03d7711a387d96f132589057213e3aef7475afdaa303412463f22/coverage-7.13.0-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:166ad2a22ee770f5656e1257703139d3533b4a0b6909af67c6b4a3adc1c98657", size = 260713, upload-time = "2025-12-08T13:14:16.907Z" }, 164 | { url = "https://files.pythonhosted.org/packages/fd/db/559fbb6def07d25b2243663b46ba9eb5a3c6586c0c6f4e62980a68f0ee1c/coverage-7.13.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f6aaef16d65d1787280943f1c8718dc32e9cf141014e4634d64446702d26e0ff", size = 262825, upload-time = "2025-12-08T13:14:18.68Z" }, 165 | { url = "https://files.pythonhosted.org/packages/37/99/6ee5bf7eff884766edb43bd8736b5e1c5144d0fe47498c3779326fe75a35/coverage-7.13.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e999e2dcc094002d6e2c7bbc1fb85b58ba4f465a760a8014d97619330cdbbbf3", size = 265233, upload-time = "2025-12-08T13:14:20.55Z" }, 166 | { url = "https://files.pythonhosted.org/packages/d8/90/92f18fe0356ea69e1f98f688ed80cec39f44e9f09a1f26a1bbf017cc67f2/coverage-7.13.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:00c3d22cf6fb1cf3bf662aaaa4e563be8243a5ed2630339069799835a9cc7f9b", size = 259779, upload-time = "2025-12-08T13:14:22.367Z" }, 167 | { url = "https://files.pythonhosted.org/packages/90/5d/b312a8b45b37a42ea7d27d7d3ff98ade3a6c892dd48d1d503e773503373f/coverage-7.13.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:22ccfe8d9bb0d6134892cbe1262493a8c70d736b9df930f3f3afae0fe3ac924d", size = 262700, upload-time = "2025-12-08T13:14:24.309Z" }, 168 | { url = "https://files.pythonhosted.org/packages/63/f8/b1d0de5c39351eb71c366f872376d09386640840a2e09b0d03973d791e20/coverage-7.13.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:9372dff5ea15930fea0445eaf37bbbafbc771a49e70c0aeed8b4e2c2614cc00e", size = 260302, upload-time = "2025-12-08T13:14:26.068Z" }, 169 | { url = "https://files.pythonhosted.org/packages/aa/7c/d42f4435bc40c55558b3109a39e2d456cddcec37434f62a1f1230991667a/coverage-7.13.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:69ac2c492918c2461bc6ace42d0479638e60719f2a4ef3f0815fa2df88e9f940", size = 259136, upload-time = "2025-12-08T13:14:27.604Z" }, 170 | { url = "https://files.pythonhosted.org/packages/b8/d3/23413241dc04d47cfe19b9a65b32a2edd67ecd0b817400c2843ebc58c847/coverage-7.13.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:739c6c051a7540608d097b8e13c76cfa85263ced467168dc6b477bae3df7d0e2", size = 261467, upload-time = "2025-12-08T13:14:29.09Z" }, 171 | { url = "https://files.pythonhosted.org/packages/13/e6/6e063174500eee216b96272c0d1847bf215926786f85c2bd024cf4d02d2f/coverage-7.13.0-cp314-cp314t-win32.whl", hash = "sha256:fe81055d8c6c9de76d60c94ddea73c290b416e061d40d542b24a5871bad498b7", size = 221875, upload-time = "2025-12-08T13:14:31.106Z" }, 172 | { url = "https://files.pythonhosted.org/packages/3b/46/f4fb293e4cbe3620e3ac2a3e8fd566ed33affb5861a9b20e3dd6c1896cbc/coverage-7.13.0-cp314-cp314t-win_amd64.whl", hash = "sha256:445badb539005283825959ac9fa4a28f712c214b65af3a2c464f1adc90f5fcbc", size = 222982, upload-time = "2025-12-08T13:14:33.1Z" }, 173 | { url = "https://files.pythonhosted.org/packages/68/62/5b3b9018215ed9733fbd1ae3b2ed75c5de62c3b55377a52cae732e1b7805/coverage-7.13.0-cp314-cp314t-win_arm64.whl", hash = "sha256:de7f6748b890708578fc4b7bb967d810aeb6fcc9bff4bb77dbca77dab2f9df6a", size = 221016, upload-time = "2025-12-08T13:14:34.601Z" }, 174 | { url = "https://files.pythonhosted.org/packages/8d/4c/1968f32fb9a2604645827e11ff84a31e59d532e01995f904723b4f5328b3/coverage-7.13.0-py3-none-any.whl", hash = "sha256:850d2998f380b1e266459ca5b47bc9e7daf9af1d070f66317972f382d46f1904", size = 210068, upload-time = "2025-12-08T13:14:36.236Z" }, 175 | ] 176 | 177 | [package.optional-dependencies] 178 | toml = [ 179 | { name = "tomli", marker = "python_full_version <= '3.11'" }, 180 | ] 181 | 182 | [[package]] 183 | name = "exceptiongroup" 184 | version = "1.3.1" 185 | source = { registry = "https://pypi.org/simple" } 186 | dependencies = [ 187 | { name = "typing-extensions", marker = "python_full_version < '3.13'" }, 188 | ] 189 | sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } 190 | wheels = [ 191 | { url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740, upload-time = "2025-11-21T23:01:53.443Z" }, 192 | ] 193 | 194 | [[package]] 195 | name = "faker" 196 | version = "39.0.0" 197 | source = { registry = "https://pypi.org/simple" } 198 | dependencies = [ 199 | { name = "tzdata" }, 200 | ] 201 | sdist = { url = "https://files.pythonhosted.org/packages/30/b9/0897fb5888ddda099dc0f314a8a9afb5faa7e52eaf6865c00686dfb394db/faker-39.0.0.tar.gz", hash = "sha256:ddae46d3b27e01cea7894651d687b33bcbe19a45ef044042c721ceac6d3da0ff", size = 1941757, upload-time = "2025-12-17T19:19:04.762Z" } 202 | wheels = [ 203 | { url = "https://files.pythonhosted.org/packages/eb/5a/26cdb1b10a55ac6eb11a738cea14865fa753606c4897d7be0f5dc230df00/faker-39.0.0-py3-none-any.whl", hash = "sha256:c72f1fca8f1a24b8da10fcaa45739135a19772218ddd61b86b7ea1b8c790dce7", size = 1980775, upload-time = "2025-12-17T19:19:02.926Z" }, 204 | ] 205 | 206 | [[package]] 207 | name = "getmac" 208 | version = "0.9.5" 209 | source = { registry = "https://pypi.org/simple" } 210 | sdist = { url = "https://files.pythonhosted.org/packages/89/a8/4af8e06912cd83b1cc6493e9b5d0589276c858f7bdccaf1855df748983de/getmac-0.9.5.tar.gz", hash = "sha256:456435cdbf1f5f45c433a250b8b795146e893b6fc659060f15451e812a2ab17d", size = 94031, upload-time = "2024-07-16T01:47:05.642Z" } 211 | wheels = [ 212 | { url = "https://files.pythonhosted.org/packages/18/85/4cdbc925381422397bd2b3280680e130091173f2c8dfafb9216eaaa91b00/getmac-0.9.5-py2.py3-none-any.whl", hash = "sha256:22b8a3e15bc0c6bfa94651a3f7f6cd91b59432e1d8199411d4fe12804423e0aa", size = 35781, upload-time = "2024-07-16T01:47:03.75Z" }, 213 | ] 214 | 215 | [[package]] 216 | name = "iniconfig" 217 | version = "2.3.0" 218 | source = { registry = "https://pypi.org/simple" } 219 | sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } 220 | wheels = [ 221 | { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, 222 | ] 223 | 224 | [[package]] 225 | name = "markdown-it-py" 226 | version = "4.0.0" 227 | source = { registry = "https://pypi.org/simple" } 228 | dependencies = [ 229 | { name = "mdurl" }, 230 | ] 231 | sdist = { url = "https://files.pythonhosted.org/packages/5b/f5/4ec618ed16cc4f8fb3b701563655a69816155e79e24a17b651541804721d/markdown_it_py-4.0.0.tar.gz", hash = "sha256:cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3", size = 73070, upload-time = "2025-08-11T12:57:52.854Z" } 232 | wheels = [ 233 | { url = "https://files.pythonhosted.org/packages/94/54/e7d793b573f298e1c9013b8c4dade17d481164aa517d1d7148619c2cedbf/markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147", size = 87321, upload-time = "2025-08-11T12:57:51.923Z" }, 234 | ] 235 | 236 | [[package]] 237 | name = "mdurl" 238 | version = "0.1.2" 239 | source = { registry = "https://pypi.org/simple" } 240 | sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" } 241 | wheels = [ 242 | { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, 243 | ] 244 | 245 | [[package]] 246 | name = "packaging" 247 | version = "25.0" 248 | source = { registry = "https://pypi.org/simple" } 249 | sdist = { url = "https://files.pythonhosted.org/packages/a1/d4/1fc4078c65507b51b96ca8f8c3ba19e6a61c8253c72794544580a7b6c24d/packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f", size = 165727, upload-time = "2025-04-19T11:48:59.673Z" } 250 | wheels = [ 251 | { url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469, upload-time = "2025-04-19T11:48:57.875Z" }, 252 | ] 253 | 254 | [[package]] 255 | name = "pluggy" 256 | version = "1.6.0" 257 | source = { registry = "https://pypi.org/simple" } 258 | sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } 259 | wheels = [ 260 | { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, 261 | ] 262 | 263 | [[package]] 264 | name = "pygments" 265 | version = "2.19.2" 266 | source = { registry = "https://pypi.org/simple" } 267 | sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" } 268 | wheels = [ 269 | { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" }, 270 | ] 271 | 272 | [[package]] 273 | name = "pytest" 274 | version = "9.0.2" 275 | source = { registry = "https://pypi.org/simple" } 276 | dependencies = [ 277 | { name = "colorama", marker = "sys_platform == 'win32'" }, 278 | { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, 279 | { name = "iniconfig" }, 280 | { name = "packaging" }, 281 | { name = "pluggy" }, 282 | { name = "pygments" }, 283 | { name = "tomli", marker = "python_full_version < '3.11'" }, 284 | ] 285 | sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" } 286 | wheels = [ 287 | { url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" }, 288 | ] 289 | 290 | [[package]] 291 | name = "pytest-cov" 292 | version = "7.0.0" 293 | source = { registry = "https://pypi.org/simple" } 294 | dependencies = [ 295 | { name = "coverage", extra = ["toml"] }, 296 | { name = "pluggy" }, 297 | { name = "pytest" }, 298 | ] 299 | sdist = { url = "https://files.pythonhosted.org/packages/5e/f7/c933acc76f5208b3b00089573cf6a2bc26dc80a8aece8f52bb7d6b1855ca/pytest_cov-7.0.0.tar.gz", hash = "sha256:33c97eda2e049a0c5298e91f519302a1334c26ac65c1a483d6206fd458361af1", size = 54328, upload-time = "2025-09-09T10:57:02.113Z" } 300 | wheels = [ 301 | { url = "https://files.pythonhosted.org/packages/ee/49/1377b49de7d0c1ce41292161ea0f721913fa8722c19fb9c1e3aa0367eecb/pytest_cov-7.0.0-py3-none-any.whl", hash = "sha256:3b8e9558b16cc1479da72058bdecf8073661c7f57f7d3c5f22a1c23507f2d861", size = 22424, upload-time = "2025-09-09T10:57:00.695Z" }, 302 | ] 303 | 304 | [[package]] 305 | name = "pzp" 306 | version = "0.0.28" 307 | source = { registry = "https://pypi.org/simple" } 308 | sdist = { url = "https://files.pythonhosted.org/packages/0e/d6/37b838d97b433c12b7cdb6d795c1e6fefbac74f90573d984e936918926a9/pzp-0.0.28.tar.gz", hash = "sha256:c4edf1dafe724f9731cf8a5aed483f7f6b1016425c30855258b7e3fcb9bbd04e", size = 25150, upload-time = "2025-02-08T17:02:42.1Z" } 309 | wheels = [ 310 | { url = "https://files.pythonhosted.org/packages/13/7c/27586576587cbab57850081f04df07b7e3f185794982deacb1b697d73ecb/pzp-0.0.28-py2.py3-none-any.whl", hash = "sha256:e90deea90538d99199f9b129f46d09a0b3308ab4b59512d8e8629bba49834da5", size = 25630, upload-time = "2025-02-08T17:02:40.235Z" }, 311 | ] 312 | 313 | [[package]] 314 | name = "rich" 315 | version = "14.2.0" 316 | source = { registry = "https://pypi.org/simple" } 317 | dependencies = [ 318 | { name = "markdown-it-py" }, 319 | { name = "pygments" }, 320 | ] 321 | sdist = { url = "https://files.pythonhosted.org/packages/fb/d2/8920e102050a0de7bfabeb4c4614a49248cf8d5d7a8d01885fbb24dc767a/rich-14.2.0.tar.gz", hash = "sha256:73ff50c7c0c1c77c8243079283f4edb376f0f6442433aecb8ce7e6d0b92d1fe4", size = 219990, upload-time = "2025-10-09T14:16:53.064Z" } 322 | wheels = [ 323 | { url = "https://files.pythonhosted.org/packages/25/7a/b0178788f8dc6cafce37a212c99565fa1fe7872c70c6c9c1e1a372d9d88f/rich-14.2.0-py3-none-any.whl", hash = "sha256:76bc51fe2e57d2b1be1f96c524b890b816e334ab4c1e45888799bfaab0021edd", size = 243393, upload-time = "2025-10-09T14:16:51.245Z" }, 324 | ] 325 | 326 | [[package]] 327 | name = "shellingham" 328 | version = "1.5.4" 329 | source = { registry = "https://pypi.org/simple" } 330 | sdist = { url = "https://files.pythonhosted.org/packages/58/15/8b3609fd3830ef7b27b655beb4b4e9c62313a4e8da8c676e142cc210d58e/shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de", size = 10310, upload-time = "2023-10-24T04:13:40.426Z" } 331 | wheels = [ 332 | { url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755, upload-time = "2023-10-24T04:13:38.866Z" }, 333 | ] 334 | 335 | [[package]] 336 | name = "tomli" 337 | version = "2.3.0" 338 | source = { registry = "https://pypi.org/simple" } 339 | sdist = { url = "https://files.pythonhosted.org/packages/52/ed/3f73f72945444548f33eba9a87fc7a6e969915e7b1acc8260b30e1f76a2f/tomli-2.3.0.tar.gz", hash = "sha256:64be704a875d2a59753d80ee8a533c3fe183e3f06807ff7dc2232938ccb01549", size = 17392, upload-time = "2025-10-08T22:01:47.119Z" } 340 | wheels = [ 341 | { url = "https://files.pythonhosted.org/packages/b3/2e/299f62b401438d5fe1624119c723f5d877acc86a4c2492da405626665f12/tomli-2.3.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:88bd15eb972f3664f5ed4b57c1634a97153b4bac4479dcb6a495f41921eb7f45", size = 153236, upload-time = "2025-10-08T22:01:00.137Z" }, 342 | { url = "https://files.pythonhosted.org/packages/86/7f/d8fffe6a7aefdb61bced88fcb5e280cfd71e08939da5894161bd71bea022/tomli-2.3.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:883b1c0d6398a6a9d29b508c331fa56adbcdff647f6ace4dfca0f50e90dfd0ba", size = 148084, upload-time = "2025-10-08T22:01:01.63Z" }, 343 | { url = "https://files.pythonhosted.org/packages/47/5c/24935fb6a2ee63e86d80e4d3b58b222dafaf438c416752c8b58537c8b89a/tomli-2.3.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d1381caf13ab9f300e30dd8feadb3de072aeb86f1d34a8569453ff32a7dea4bf", size = 234832, upload-time = "2025-10-08T22:01:02.543Z" }, 344 | { url = "https://files.pythonhosted.org/packages/89/da/75dfd804fc11e6612846758a23f13271b76d577e299592b4371a4ca4cd09/tomli-2.3.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a0e285d2649b78c0d9027570d4da3425bdb49830a6156121360b3f8511ea3441", size = 242052, upload-time = "2025-10-08T22:01:03.836Z" }, 345 | { url = "https://files.pythonhosted.org/packages/70/8c/f48ac899f7b3ca7eb13af73bacbc93aec37f9c954df3c08ad96991c8c373/tomli-2.3.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0a154a9ae14bfcf5d8917a59b51ffd5a3ac1fd149b71b47a3a104ca4edcfa845", size = 239555, upload-time = "2025-10-08T22:01:04.834Z" }, 346 | { url = "https://files.pythonhosted.org/packages/ba/28/72f8afd73f1d0e7829bfc093f4cb98ce0a40ffc0cc997009ee1ed94ba705/tomli-2.3.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:74bf8464ff93e413514fefd2be591c3b0b23231a77f901db1eb30d6f712fc42c", size = 245128, upload-time = "2025-10-08T22:01:05.84Z" }, 347 | { url = "https://files.pythonhosted.org/packages/b6/eb/a7679c8ac85208706d27436e8d421dfa39d4c914dcf5fa8083a9305f58d9/tomli-2.3.0-cp311-cp311-win32.whl", hash = "sha256:00b5f5d95bbfc7d12f91ad8c593a1659b6387b43f054104cda404be6bda62456", size = 96445, upload-time = "2025-10-08T22:01:06.896Z" }, 348 | { url = "https://files.pythonhosted.org/packages/0a/fe/3d3420c4cb1ad9cb462fb52967080575f15898da97e21cb6f1361d505383/tomli-2.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:4dc4ce8483a5d429ab602f111a93a6ab1ed425eae3122032db7e9acf449451be", size = 107165, upload-time = "2025-10-08T22:01:08.107Z" }, 349 | { url = "https://files.pythonhosted.org/packages/ff/b7/40f36368fcabc518bb11c8f06379a0fd631985046c038aca08c6d6a43c6e/tomli-2.3.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d7d86942e56ded512a594786a5ba0a5e521d02529b3826e7761a05138341a2ac", size = 154891, upload-time = "2025-10-08T22:01:09.082Z" }, 350 | { url = "https://files.pythonhosted.org/packages/f9/3f/d9dd692199e3b3aab2e4e4dd948abd0f790d9ded8cd10cbaae276a898434/tomli-2.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:73ee0b47d4dad1c5e996e3cd33b8a76a50167ae5f96a2607cbe8cc773506ab22", size = 148796, upload-time = "2025-10-08T22:01:10.266Z" }, 351 | { url = "https://files.pythonhosted.org/packages/60/83/59bff4996c2cf9f9387a0f5a3394629c7efa5ef16142076a23a90f1955fa/tomli-2.3.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:792262b94d5d0a466afb5bc63c7daa9d75520110971ee269152083270998316f", size = 242121, upload-time = "2025-10-08T22:01:11.332Z" }, 352 | { url = "https://files.pythonhosted.org/packages/45/e5/7c5119ff39de8693d6baab6c0b6dcb556d192c165596e9fc231ea1052041/tomli-2.3.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4f195fe57ecceac95a66a75ac24d9d5fbc98ef0962e09b2eddec5d39375aae52", size = 250070, upload-time = "2025-10-08T22:01:12.498Z" }, 353 | { url = "https://files.pythonhosted.org/packages/45/12/ad5126d3a278f27e6701abde51d342aa78d06e27ce2bb596a01f7709a5a2/tomli-2.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e31d432427dcbf4d86958c184b9bfd1e96b5b71f8eb17e6d02531f434fd335b8", size = 245859, upload-time = "2025-10-08T22:01:13.551Z" }, 354 | { url = "https://files.pythonhosted.org/packages/fb/a1/4d6865da6a71c603cfe6ad0e6556c73c76548557a8d658f9e3b142df245f/tomli-2.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7b0882799624980785240ab732537fcfc372601015c00f7fc367c55308c186f6", size = 250296, upload-time = "2025-10-08T22:01:14.614Z" }, 355 | { url = "https://files.pythonhosted.org/packages/a0/b7/a7a7042715d55c9ba6e8b196d65d2cb662578b4d8cd17d882d45322b0d78/tomli-2.3.0-cp312-cp312-win32.whl", hash = "sha256:ff72b71b5d10d22ecb084d345fc26f42b5143c5533db5e2eaba7d2d335358876", size = 97124, upload-time = "2025-10-08T22:01:15.629Z" }, 356 | { url = "https://files.pythonhosted.org/packages/06/1e/f22f100db15a68b520664eb3328fb0ae4e90530887928558112c8d1f4515/tomli-2.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:1cb4ed918939151a03f33d4242ccd0aa5f11b3547d0cf30f7c74a408a5b99878", size = 107698, upload-time = "2025-10-08T22:01:16.51Z" }, 357 | { url = "https://files.pythonhosted.org/packages/89/48/06ee6eabe4fdd9ecd48bf488f4ac783844fd777f547b8d1b61c11939974e/tomli-2.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5192f562738228945d7b13d4930baffda67b69425a7f0da96d360b0a3888136b", size = 154819, upload-time = "2025-10-08T22:01:17.964Z" }, 358 | { url = "https://files.pythonhosted.org/packages/f1/01/88793757d54d8937015c75dcdfb673c65471945f6be98e6a0410fba167ed/tomli-2.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:be71c93a63d738597996be9528f4abe628d1adf5e6eb11607bc8fe1a510b5dae", size = 148766, upload-time = "2025-10-08T22:01:18.959Z" }, 359 | { url = "https://files.pythonhosted.org/packages/42/17/5e2c956f0144b812e7e107f94f1cc54af734eb17b5191c0bbfb72de5e93e/tomli-2.3.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c4665508bcbac83a31ff8ab08f424b665200c0e1e645d2bd9ab3d3e557b6185b", size = 240771, upload-time = "2025-10-08T22:01:20.106Z" }, 360 | { url = "https://files.pythonhosted.org/packages/d5/f4/0fbd014909748706c01d16824eadb0307115f9562a15cbb012cd9b3512c5/tomli-2.3.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4021923f97266babc6ccab9f5068642a0095faa0a51a246a6a02fccbb3514eaf", size = 248586, upload-time = "2025-10-08T22:01:21.164Z" }, 361 | { url = "https://files.pythonhosted.org/packages/30/77/fed85e114bde5e81ecf9bc5da0cc69f2914b38f4708c80ae67d0c10180c5/tomli-2.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4ea38c40145a357d513bffad0ed869f13c1773716cf71ccaa83b0fa0cc4e42f", size = 244792, upload-time = "2025-10-08T22:01:22.417Z" }, 362 | { url = "https://files.pythonhosted.org/packages/55/92/afed3d497f7c186dc71e6ee6d4fcb0acfa5f7d0a1a2878f8beae379ae0cc/tomli-2.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ad805ea85eda330dbad64c7ea7a4556259665bdf9d2672f5dccc740eb9d3ca05", size = 248909, upload-time = "2025-10-08T22:01:23.859Z" }, 363 | { url = "https://files.pythonhosted.org/packages/f8/84/ef50c51b5a9472e7265ce1ffc7f24cd4023d289e109f669bdb1553f6a7c2/tomli-2.3.0-cp313-cp313-win32.whl", hash = "sha256:97d5eec30149fd3294270e889b4234023f2c69747e555a27bd708828353ab606", size = 96946, upload-time = "2025-10-08T22:01:24.893Z" }, 364 | { url = "https://files.pythonhosted.org/packages/b2/b7/718cd1da0884f281f95ccfa3a6cc572d30053cba64603f79d431d3c9b61b/tomli-2.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:0c95ca56fbe89e065c6ead5b593ee64b84a26fca063b5d71a1122bf26e533999", size = 107705, upload-time = "2025-10-08T22:01:26.153Z" }, 365 | { url = "https://files.pythonhosted.org/packages/19/94/aeafa14a52e16163008060506fcb6aa1949d13548d13752171a755c65611/tomli-2.3.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:cebc6fe843e0733ee827a282aca4999b596241195f43b4cc371d64fc6639da9e", size = 154244, upload-time = "2025-10-08T22:01:27.06Z" }, 366 | { url = "https://files.pythonhosted.org/packages/db/e4/1e58409aa78eefa47ccd19779fc6f36787edbe7d4cd330eeeedb33a4515b/tomli-2.3.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4c2ef0244c75aba9355561272009d934953817c49f47d768070c3c94355c2aa3", size = 148637, upload-time = "2025-10-08T22:01:28.059Z" }, 367 | { url = "https://files.pythonhosted.org/packages/26/b6/d1eccb62f665e44359226811064596dd6a366ea1f985839c566cd61525ae/tomli-2.3.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c22a8bf253bacc0cf11f35ad9808b6cb75ada2631c2d97c971122583b129afbc", size = 241925, upload-time = "2025-10-08T22:01:29.066Z" }, 368 | { url = "https://files.pythonhosted.org/packages/70/91/7cdab9a03e6d3d2bb11beae108da5bdc1c34bdeb06e21163482544ddcc90/tomli-2.3.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0eea8cc5c5e9f89c9b90c4896a8deefc74f518db5927d0e0e8d4a80953d774d0", size = 249045, upload-time = "2025-10-08T22:01:31.98Z" }, 369 | { url = "https://files.pythonhosted.org/packages/15/1b/8c26874ed1f6e4f1fcfeb868db8a794cbe9f227299402db58cfcc858766c/tomli-2.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b74a0e59ec5d15127acdabd75ea17726ac4c5178ae51b85bfe39c4f8a278e879", size = 245835, upload-time = "2025-10-08T22:01:32.989Z" }, 370 | { url = "https://files.pythonhosted.org/packages/fd/42/8e3c6a9a4b1a1360c1a2a39f0b972cef2cc9ebd56025168c4137192a9321/tomli-2.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b5870b50c9db823c595983571d1296a6ff3e1b88f734a4c8f6fc6188397de005", size = 253109, upload-time = "2025-10-08T22:01:34.052Z" }, 371 | { url = "https://files.pythonhosted.org/packages/22/0c/b4da635000a71b5f80130937eeac12e686eefb376b8dee113b4a582bba42/tomli-2.3.0-cp314-cp314-win32.whl", hash = "sha256:feb0dacc61170ed7ab602d3d972a58f14ee3ee60494292d384649a3dc38ef463", size = 97930, upload-time = "2025-10-08T22:01:35.082Z" }, 372 | { url = "https://files.pythonhosted.org/packages/b9/74/cb1abc870a418ae99cd5c9547d6bce30701a954e0e721821df483ef7223c/tomli-2.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:b273fcbd7fc64dc3600c098e39136522650c49bca95df2d11cf3b626422392c8", size = 107964, upload-time = "2025-10-08T22:01:36.057Z" }, 373 | { url = "https://files.pythonhosted.org/packages/54/78/5c46fff6432a712af9f792944f4fcd7067d8823157949f4e40c56b8b3c83/tomli-2.3.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:940d56ee0410fa17ee1f12b817b37a4d4e4dc4d27340863cc67236c74f582e77", size = 163065, upload-time = "2025-10-08T22:01:37.27Z" }, 374 | { url = "https://files.pythonhosted.org/packages/39/67/f85d9bd23182f45eca8939cd2bc7050e1f90c41f4a2ecbbd5963a1d1c486/tomli-2.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f85209946d1fe94416debbb88d00eb92ce9cd5266775424ff81bc959e001acaf", size = 159088, upload-time = "2025-10-08T22:01:38.235Z" }, 375 | { url = "https://files.pythonhosted.org/packages/26/5a/4b546a0405b9cc0659b399f12b6adb750757baf04250b148d3c5059fc4eb/tomli-2.3.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a56212bdcce682e56b0aaf79e869ba5d15a6163f88d5451cbde388d48b13f530", size = 268193, upload-time = "2025-10-08T22:01:39.712Z" }, 376 | { url = "https://files.pythonhosted.org/packages/42/4f/2c12a72ae22cf7b59a7fe75b3465b7aba40ea9145d026ba41cb382075b0e/tomli-2.3.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c5f3ffd1e098dfc032d4d3af5c0ac64f6d286d98bc148698356847b80fa4de1b", size = 275488, upload-time = "2025-10-08T22:01:40.773Z" }, 377 | { url = "https://files.pythonhosted.org/packages/92/04/a038d65dbe160c3aa5a624e93ad98111090f6804027d474ba9c37c8ae186/tomli-2.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5e01decd096b1530d97d5d85cb4dff4af2d8347bd35686654a004f8dea20fc67", size = 272669, upload-time = "2025-10-08T22:01:41.824Z" }, 378 | { url = "https://files.pythonhosted.org/packages/be/2f/8b7c60a9d1612a7cbc39ffcca4f21a73bf368a80fc25bccf8253e2563267/tomli-2.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:8a35dd0e643bb2610f156cca8db95d213a90015c11fee76c946aa62b7ae7e02f", size = 279709, upload-time = "2025-10-08T22:01:43.177Z" }, 379 | { url = "https://files.pythonhosted.org/packages/7e/46/cc36c679f09f27ded940281c38607716c86cf8ba4a518d524e349c8b4874/tomli-2.3.0-cp314-cp314t-win32.whl", hash = "sha256:a1f7f282fe248311650081faafa5f4732bdbfef5d45fe3f2e702fbc6f2d496e0", size = 107563, upload-time = "2025-10-08T22:01:44.233Z" }, 380 | { url = "https://files.pythonhosted.org/packages/84/ff/426ca8683cf7b753614480484f6437f568fd2fda2edbdf57a2d3d8b27a0b/tomli-2.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:70a251f8d4ba2d9ac2542eecf008b3c8a9fc5c3f9f02c56a9d7952612be2fdba", size = 119756, upload-time = "2025-10-08T22:01:45.234Z" }, 381 | { url = "https://files.pythonhosted.org/packages/77/b8/0135fadc89e73be292b473cb820b4f5a08197779206b33191e801feeae40/tomli-2.3.0-py3-none-any.whl", hash = "sha256:e95b1af3c5b07d9e643909b5abbec77cd9f1217e6d0bca72b0234736b9fb1f1b", size = 14408, upload-time = "2025-10-08T22:01:46.04Z" }, 382 | ] 383 | 384 | [[package]] 385 | name = "ty" 386 | version = "0.0.5" 387 | source = { registry = "https://pypi.org/simple" } 388 | sdist = { url = "https://files.pythonhosted.org/packages/9e/db/6299d478000f4f1c6f9bf2af749359381610ffc4cbe6713b66e436ecf6e7/ty-0.0.5.tar.gz", hash = "sha256:983da6330773ff71e2b249810a19c689f9a0372f6e21bbf7cde37839d05b4346", size = 4806218, upload-time = "2025-12-20T21:19:17.24Z" } 389 | wheels = [ 390 | { url = "https://files.pythonhosted.org/packages/7c/98/c1f61ba378b4191e641bb36c07b7fcc70ff844d61be7a4bf2fea7472b4a9/ty-0.0.5-py3-none-linux_armv6l.whl", hash = "sha256:1594cd9bb68015eb2f5a3c68a040860f3c9306dc6667d7a0e5f4df9967b460e2", size = 9785554, upload-time = "2025-12-20T21:19:05.024Z" }, 391 | { url = "https://files.pythonhosted.org/packages/ab/f9/b37b77c03396bd779c1397dae4279b7ad79315e005b3412feed8812a4256/ty-0.0.5-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:7c0140ba980233d28699d9ddfe8f43d0b3535d6a3bbff9935df625a78332a3cf", size = 9603995, upload-time = "2025-12-20T21:19:15.256Z" }, 392 | { url = "https://files.pythonhosted.org/packages/7d/70/4e75c11903b0e986c0203040472627cb61d6a709e1797fb08cdf9d565743/ty-0.0.5-py3-none-macosx_11_0_arm64.whl", hash = "sha256:15de414712cde92048ae4b1a77c4dc22920bd23653fe42acaf73028bad88f6b9", size = 9145815, upload-time = "2025-12-20T21:19:36.481Z" }, 393 | { url = "https://files.pythonhosted.org/packages/89/05/93983dfcf871a41dfe58e5511d28e6aa332a1f826cc67333f77ae41a2f8a/ty-0.0.5-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:438aa51ad6c5fae64191f8d58876266e26f9250cf09f6624b6af47a22fa88618", size = 9619849, upload-time = "2025-12-20T21:19:19.084Z" }, 394 | { url = "https://files.pythonhosted.org/packages/82/b6/896ab3aad59f846823f202e94be6016fb3f72434d999d2ae9bd0f28b3af9/ty-0.0.5-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:1b3d373fd96af1564380caf153600481c676f5002ee76ba8a7c3508cdff82ee0", size = 9606611, upload-time = "2025-12-20T21:19:24.583Z" }, 395 | { url = "https://files.pythonhosted.org/packages/ca/ae/098e33fc92330285ed843e2750127e896140c4ebd2d73df7732ea496f588/ty-0.0.5-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8453692503212ad316cf8b99efbe85a91e5f63769c43be5345e435a1b16cba5a", size = 10029523, upload-time = "2025-12-20T21:19:07.055Z" }, 396 | { url = "https://files.pythonhosted.org/packages/04/5a/f4b4c33758b9295e9aca0de9645deca0f4addd21d38847228723a6e780fc/ty-0.0.5-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:2e4c454139473abbd529767b0df7a795ed828f780aef8d0d4b144558c0dc4446", size = 10870892, upload-time = "2025-12-20T21:19:34.495Z" }, 397 | { url = "https://files.pythonhosted.org/packages/c3/c5/4e3e7e88389365aa1e631c99378711cf0c9d35a67478cb4720584314cf44/ty-0.0.5-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:426d4f3b82475b1ec75f3cc9ee5a667c8a4ae8441a09fcd8e823a53b706d00c7", size = 10599291, upload-time = "2025-12-20T21:19:26.557Z" }, 398 | { url = "https://files.pythonhosted.org/packages/c1/5d/138f859ea87bd95e17b9818e386ae25a910e46521c41d516bf230ed83ffc/ty-0.0.5-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5710817b67c6b2e4c0224e4f319b7decdff550886e9020f6d46aa1ce8f89a609", size = 10413515, upload-time = "2025-12-20T21:19:11.094Z" }, 399 | { url = "https://files.pythonhosted.org/packages/27/21/1cbcd0d3b1182172f099e88218137943e0970603492fb10c7c9342369d9a/ty-0.0.5-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e23c55ef08882c7c5ced1ccb90b4eeefa97f690aea254f58ac0987896c590f76", size = 10144992, upload-time = "2025-12-20T21:19:13.225Z" }, 400 | { url = "https://files.pythonhosted.org/packages/ad/30/fdac06a5470c09ad2659a0806497b71f338b395d59e92611f71b623d05a0/ty-0.0.5-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:b9e4c1a28a23b14cf8f4f793f4da396939f16c30bfa7323477c8cc234e352ac4", size = 9606408, upload-time = "2025-12-20T21:19:09.212Z" }, 401 | { url = "https://files.pythonhosted.org/packages/09/93/e99dcd7f53295192d03efd9cbcec089a916f49cad4935c0160ea9adbd53d/ty-0.0.5-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:4e9ebb61529b9745af662e37c37a01ad743cdd2c95f0d1421705672874d806cd", size = 9630040, upload-time = "2025-12-20T21:19:38.165Z" }, 402 | { url = "https://files.pythonhosted.org/packages/d7/f8/6d1e87186e4c35eb64f28000c1df8fd5f73167ce126c5e3dd21fd1204a23/ty-0.0.5-py3-none-musllinux_1_2_i686.whl", hash = "sha256:5eb191a8e332f50f56dfe45391bdd7d43dd4ef6e60884710fd7ce84c5d8c1eb5", size = 9754016, upload-time = "2025-12-20T21:19:32.79Z" }, 403 | { url = "https://files.pythonhosted.org/packages/28/e6/20f989342cb3115852dda404f1d89a10a3ce93f14f42b23f095a3d1a00c9/ty-0.0.5-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:92ed7451a1e82ee134a2c24ca43b74dd31e946dff2b08e5c34473e6b051de542", size = 10252877, upload-time = "2025-12-20T21:19:20.787Z" }, 404 | { url = "https://files.pythonhosted.org/packages/57/9d/fc66fa557443233dfad9ae197ff3deb70ae0efcfb71d11b30ef62f5cdcc3/ty-0.0.5-py3-none-win32.whl", hash = "sha256:71f6707e4c1c010c158029a688a498220f28bb22fdb6707e5c20e09f11a5e4f2", size = 9212640, upload-time = "2025-12-20T21:19:30.817Z" }, 405 | { url = "https://files.pythonhosted.org/packages/68/b6/05c35f6dea29122e54af0e9f8dfedd0a100c721affc8cc801ebe2bc2ed13/ty-0.0.5-py3-none-win_amd64.whl", hash = "sha256:2b8b754a0d7191e94acdf0c322747fec34371a4d0669f5b4e89549aef28814ae", size = 10034701, upload-time = "2025-12-20T21:19:28.311Z" }, 406 | { url = "https://files.pythonhosted.org/packages/df/ca/4201ed5cb2af73912663d0c6ded927c28c28b3c921c9348aa8d2cfef4853/ty-0.0.5-py3-none-win_arm64.whl", hash = "sha256:83bea5a5296caac20d52b790ded2b830a7ff91c4ed9f36730fe1f393ceed6654", size = 9566474, upload-time = "2025-12-20T21:19:22.518Z" }, 407 | ] 408 | 409 | [[package]] 410 | name = "typer" 411 | version = "0.20.0" 412 | source = { registry = "https://pypi.org/simple" } 413 | dependencies = [ 414 | { name = "click" }, 415 | { name = "rich" }, 416 | { name = "shellingham" }, 417 | { name = "typing-extensions" }, 418 | ] 419 | sdist = { url = "https://files.pythonhosted.org/packages/8f/28/7c85c8032b91dbe79725b6f17d2fffc595dff06a35c7a30a37bef73a1ab4/typer-0.20.0.tar.gz", hash = "sha256:1aaf6494031793e4876fb0bacfa6a912b551cf43c1e63c800df8b1a866720c37", size = 106492, upload-time = "2025-10-20T17:03:49.445Z" } 420 | wheels = [ 421 | { url = "https://files.pythonhosted.org/packages/78/64/7713ffe4b5983314e9d436a90d5bd4f63b6054e2aca783a3cfc44cb95bbf/typer-0.20.0-py3-none-any.whl", hash = "sha256:5b463df6793ec1dca6213a3cf4c0f03bc6e322ac5e16e13ddd622a889489784a", size = 47028, upload-time = "2025-10-20T17:03:47.617Z" }, 422 | ] 423 | 424 | [[package]] 425 | name = "typing-extensions" 426 | version = "4.15.0" 427 | source = { registry = "https://pypi.org/simple" } 428 | sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } 429 | wheels = [ 430 | { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, 431 | ] 432 | 433 | [[package]] 434 | name = "tzdata" 435 | version = "2025.3" 436 | source = { registry = "https://pypi.org/simple" } 437 | sdist = { url = "https://files.pythonhosted.org/packages/5e/a7/c202b344c5ca7daf398f3b8a477eeb205cf3b6f32e7ec3a6bac0629ca975/tzdata-2025.3.tar.gz", hash = "sha256:de39c2ca5dc7b0344f2eba86f49d614019d29f060fc4ebc8a417896a620b56a7", size = 196772, upload-time = "2025-12-13T17:45:35.667Z" } 438 | wheels = [ 439 | { url = "https://files.pythonhosted.org/packages/c7/b0/003792df09decd6849a5e39c28b513c06e84436a54440380862b5aeff25d/tzdata-2025.3-py2.py3-none-any.whl", hash = "sha256:06a47e5700f3081aab02b2e513160914ff0694bce9947d6b76ebd6bf57cfc5d1", size = 348521, upload-time = "2025-12-13T17:45:33.889Z" }, 440 | ] 441 | 442 | [[package]] 443 | name = "wakeonlan" 444 | version = "3.1.0" 445 | source = { registry = "https://pypi.org/simple" } 446 | sdist = { url = "https://files.pythonhosted.org/packages/ec/98/b92125baeaf67b3a838bfdb4ac4e685c793ce2771686b10df44275e424a4/wakeonlan-3.1.0.tar.gz", hash = "sha256:aa12edc2587353528a89ad58a54c63212dc2a12226c186b7fcc02caa162cd962", size = 4242, upload-time = "2023-11-17T13:39:38.238Z" } 447 | wheels = [ 448 | { url = "https://files.pythonhosted.org/packages/e9/47/99a02d847104bbc08d10b147e77593c127c405774fa7f5247afd152754e5/wakeonlan-3.1.0-py3-none-any.whl", hash = "sha256:9414da87f48e5dc8a1bb0fa15aba5a0079cfd014231c4cc8e6f2477a0d078c7e", size = 4984, upload-time = "2023-11-17T13:39:36.753Z" }, 449 | ] 450 | 451 | [[package]] 452 | name = "websocket-client" 453 | version = "1.9.0" 454 | source = { registry = "https://pypi.org/simple" } 455 | sdist = { url = "https://files.pythonhosted.org/packages/2c/41/aa4bf9664e4cda14c3b39865b12251e8e7d239f4cd0e3cc1b6c2ccde25c1/websocket_client-1.9.0.tar.gz", hash = "sha256:9e813624b6eb619999a97dc7958469217c3176312b3a16a4bd1bc7e08a46ec98", size = 70576, upload-time = "2025-10-07T21:16:36.495Z" } 456 | wheels = [ 457 | { url = "https://files.pythonhosted.org/packages/34/db/b10e48aa8fff7407e67470363eac595018441cf32d5e1001567a7aeba5d2/websocket_client-1.9.0-py3-none-any.whl", hash = "sha256:af248a825037ef591efbf6ed20cc5faa03d3b47b9e5a2230a529eeee1c1fc3ef", size = 82616, upload-time = "2025-10-07T21:16:34.951Z" }, 458 | ] 459 | --------------------------------------------------------------------------------