diff --git a/.github/runtime/action.yml b/.github/runtime/action.yml deleted file mode 100644 index 8bb7e55..0000000 --- a/.github/runtime/action.yml +++ /dev/null @@ -1,39 +0,0 @@ ---- -name: Install runtime -description: Install the required runtime and related base dependencies - -inputs: - python: - description: "Install Python" - default: "true" - -outputs: - python: - description: "Resolved version" - value: ${{ steps.get_version.outputs.python }} - -runs: - using: "composite" - steps: - - name: Determine version - id: get_version - shell: bash -eo pipefail {0} - run: | - python="$(grep -E '^requires-python\s*=\s*\"' pyproject.toml | sed -E 's/.*\"[^0-9]*([0-9]+\.[0-9]+).*/\1/' | head -n1)" - if [ -z "$python" ]; then - exit 1 - fi - echo "python=${python}" >> "$GITHUB_OUTPUT" - - - name: Install Python - if: ${{ inputs.python == 'true' }} - uses: actions/setup-python@v6 - with: - python-version: ${{ steps.get_version.outputs.python }} - - - name: Show tooling information - shell: bash -eo pipefail {0} - run: | - set -x - - python --version diff --git a/.github/workflows/static-checks.yml b/.github/workflows/static-checks.yml deleted file mode 100644 index 056e48f..0000000 --- a/.github/workflows/static-checks.yml +++ /dev/null @@ -1,67 +0,0 @@ -name: Static Checks - -on: - push: - branches: - - main - pull_request: - workflow_dispatch: - -concurrency: - group: static-checks-${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true - -permissions: - contents: read - -jobs: - lint-and-typecheck: - runs-on: ubuntu-latest - timeout-minutes: 10 - - steps: - - name: Checkout - uses: actions/checkout@v6 - - - name: Install runtime - id: runtime - uses: ./.github/runtime - - - name: Set up PDM - uses: pdm-project/setup-pdm@v4 - with: - python-version: ${{ steps.runtime.outputs.python }} - cache: true - - - name: Install dependencies - run: pdm sync -dG:all - - - name: Ruff - run: pdm run ruff check src - - - name: Mypy - run: pdm run mypy -p mpd_now_playable - - tests: - runs-on: ubuntu-latest - timeout-minutes: 10 - - steps: - - name: Checkout - uses: actions/checkout@v6 - - - name: Install runtime - id: runtime - uses: ./.github/runtime - - - name: Set up PDM - uses: pdm-project/setup-pdm@v4 - with: - python-version: ${{ steps.runtime.outputs.python }} - cache: true - - - name: Install dependencies - run: pdm sync -dG:all - - - name: Run tests - run: pdm run pytest tests diff --git a/README.md b/README.md index d07cfc7..8e48af7 100644 --- a/README.md +++ b/README.md @@ -1,24 +1,11 @@ -# mpd-now-playable +# mpd-now-playable [![PyPI version](https://badge.fury.io/py/mpd-now-playable.svg)](https://badge.fury.io/py/mpd-now-playable) -[![PyPI version](https://badge.fury.io/py/mpd-now-playable.svg)](https://badge.fury.io/py/mpd-now-playable) - -This little Python program turns your MPD server into a [now playable app](https://developer.apple.com/documentation/mediaplayer/becoming_a_now_playable_app) on macOS. +This little Python program turns your MPD server into a [now playable app](https://developer.apple.com/documentation/mediaplayer/becoming_a_now_playable_app) on MacOS. This enables your keyboard's standard media keys to control MPD, as well as more esoteric music control methods like the buttons on your Bluetooth headphones. -## Table of Contents - - - -- [Installation](#installation) -- [Configuration](#configuration) -- [Limitations](#limitations) - - - ## Installation The recommended way to install mpd-now-playable and its dependencies is with [pipx](https://pypa.github.io/pipx/): - ```shell pipx install mpd-now-playable # or, if you'd like to use a separate cache service, one of these: @@ -28,27 +15,7 @@ pipx install mpd-now-playable[memcached] Once pipx is done, the `mpd-now-playable` script should be available on your `$PATH` and ready to use. -Most likely, you'll want mpd-now-playable to stay running in the background as a `launchd` service. The CLI can install a per-user LaunchAgent for you: - -```shell -mpd-now-playable install-launchagent -``` - -This writes `~/Library/LaunchAgents/me.00dani.mpd-now-playable.plist`, bootstraps it with `launchctl`, and starts it immediately. - -To replace an existing LaunchAgent plist: - -```shell -mpd-now-playable install-launchagent --force -``` - -To remove it later: - -```shell -mpd-now-playable uninstall-launchagent -``` - -You may override the launchd label in both commands with `--label`. +Most likely, you'll want mpd-now-playable to stay running in the background as a launchd service. [Here's the service plist I use](https://git.00dani.me/00dani/mpd-now-playable/src/branch/main/me.00dani.mpd-now-playable.plist), but it's hardcoded to my `$HOME` so you'll want to customise it. ## Configuration @@ -70,7 +37,6 @@ Additionally, mpd-now-playable caches your album artwork, by default simply in m You may provide a `namespace` query parameter to prefix cache keys if you wish, as well as a `password` query parameter if your service requires a password to access. As with your other environment variables, keep your cache password secure. One simple secure way to set your environment variables is with a small wrapper script like this: - ```shell #!/bin/sh export MPD_HOSTNAME=my.cool.mpd.host @@ -79,15 +45,12 @@ export MPD_PASSWORD=swordfish export MPD_NOW_PLAYABLE_CACHE='redis://localhost:6379/0?namespace=mpd-now-playable&password=fishsword' exec mpd-now-playable ``` - Make sure this wrapper script is only readable by you, with something like `chmod 700`! -If you're using launchd, you can point `ProgramArguments` in your plist at that wrapper script instead. - ## Limitations -mpd-now-playable is currently _very_ specific to macOS. I did my best to keep the generic MPD and extremely Apple parts separate, but it definitely won't work with MPRIS2 or the Windows system media feature. +mpd-now-playable is currently *very* specific to MacOS. I did my best to keep the generic MPD and extremely Apple parts separate, but it definitely won't work with MPRIS2 or the Windows system media feature. -Chances are my macOS integration code isn't the best, either. This is the first project I've written using PyObjC and it took a lot of fiddling to get working. +Chances are my MacOS integration code isn't the best, either. This is the first project I've written using PyObjC and it took a lot of fiddling to get working. I'm very open to contributions to fix any of these things, if you're interested in writing them! diff --git a/me.00dani.mpd-now-playable.plist b/me.00dani.mpd-now-playable.plist new file mode 100644 index 0000000..d75dde7 --- /dev/null +++ b/me.00dani.mpd-now-playable.plist @@ -0,0 +1,16 @@ + + + + + KeepAlive + + Label + me.00dani.mpd-now-playable + ProgramArguments + + /Users/dani/.local/bin/mpd-now-playable + + RunAtLoad + + + diff --git a/pdm.lock b/pdm.lock index 3df6f9e..61e5b3a 100644 --- a/pdm.lock +++ b/pdm.lock @@ -5,7 +5,7 @@ groups = ["default", "all", "dev", "memcached", "redis", "websockets"] strategy = [] lock_version = "4.5.0" -content_hash = "sha256:551970d3b5c9675e8edd582bf96f4b64476b4a2bb852a124439621a8df2075a4" +content_hash = "sha256:b84a0925a81adb7c4ca5a1a947ccb0db6950a18955bd92f08a605ff06cd0c26c" [[metadata.targets]] requires_python = ">=3.12" @@ -96,16 +96,6 @@ files = [ {file = "class_doc-0.2.6-py3-none-any.whl", hash = "sha256:e6f2cea2dfbe93f76dee25de13d70dc0d2269698e8b849f751d98dc894c52ea5"}, ] -[[package]] -name = "colorama" -version = "0.4.6" -requires_python = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" -summary = "Cross-platform colored terminal text." -files = [ - {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, - {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, -] - [[package]] name = "idna" version = "3.7" @@ -116,16 +106,6 @@ files = [ {file = "idna-3.7.tar.gz", hash = "sha256:028ff3aadf0609c1fd278d8ea3089299412a7a8b9bd005dd08b9f8285bcb5cfc"}, ] -[[package]] -name = "iniconfig" -version = "2.3.0" -requires_python = ">=3.10" -summary = "brain-dead simple config-ini parsing" -files = [ - {file = "iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12"}, - {file = "iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730"}, -] - [[package]] name = "markdown-it-py" version = "3.0.0" @@ -240,26 +220,6 @@ files = [ {file = "ormsgpack-1.7.0.tar.gz", hash = "sha256:6b4c98839cb7fc2a212037d2258f3a22857155249eb293d45c45cb974cfba834"}, ] -[[package]] -name = "packaging" -version = "26.0" -requires_python = ">=3.8" -summary = "Core utilities for Python packages" -files = [ - {file = "packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529"}, - {file = "packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4"}, -] - -[[package]] -name = "pluggy" -version = "1.6.0" -requires_python = ">=3.9" -summary = "plugin and hook calling mechanisms for python" -files = [ - {file = "pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746"}, - {file = "pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3"}, -] - [[package]] name = "propcache" version = "0.2.1" @@ -476,25 +436,6 @@ files = [ {file = "pyobjc_framework_quartz-11.0.tar.gz", hash = "sha256:3205bf7795fb9ae34747f701486b3db6dfac71924894d1f372977c4d70c3c619"}, ] -[[package]] -name = "pytest" -version = "9.0.2" -requires_python = ">=3.10" -summary = "pytest: simple powerful testing with Python" -dependencies = [ - "colorama>=0.4; sys_platform == \"win32\"", - "exceptiongroup>=1; python_version < \"3.11\"", - "iniconfig>=1.0.1", - "packaging>=22", - "pluggy<2,>=1.5", - "pygments>=2.7.2", - "tomli>=1; python_version < \"3.11\"", -] -files = [ - {file = "pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b"}, - {file = "pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11"}, -] - [[package]] name = "python-mpd2" version = "3.1.1" @@ -545,28 +486,28 @@ files = [ [[package]] name = "ruff" -version = "0.15.4" +version = "0.9.2" requires_python = ">=3.7" summary = "An extremely fast Python linter and code formatter, written in Rust." files = [ - {file = "ruff-0.15.4-py3-none-linux_armv6l.whl", hash = "sha256:a1810931c41606c686bae8b5b9a8072adac2f611bb433c0ba476acba17a332e0"}, - {file = "ruff-0.15.4-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:5a1632c66672b8b4d3e1d1782859e98d6e0b4e70829530666644286600a33992"}, - {file = "ruff-0.15.4-py3-none-macosx_11_0_arm64.whl", hash = "sha256:a4386ba2cd6c0f4ff75252845906acc7c7c8e1ac567b7bc3d373686ac8c222ba"}, - {file = "ruff-0.15.4-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b2496488bdfd3732747558b6f95ae427ff066d1fcd054daf75f5a50674411e75"}, - {file = "ruff-0.15.4-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3f1c4893841ff2d54cbda1b2860fa3260173df5ddd7b95d370186f8a5e66a4ac"}, - {file = "ruff-0.15.4-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:820b8766bd65503b6c30aaa6331e8ef3a6e564f7999c844e9a547c40179e440a"}, - {file = "ruff-0.15.4-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c9fb74bab47139c1751f900f857fa503987253c3ef89129b24ed375e72873e85"}, - {file = "ruff-0.15.4-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f80c98765949c518142b3a50a5db89343aa90f2c2bf7799de9986498ae6176db"}, - {file = "ruff-0.15.4-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:451a2e224151729b3b6c9ffb36aed9091b2996fe4bdbd11f47e27d8f2e8888ec"}, - {file = "ruff-0.15.4-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:a8f157f2e583c513c4f5f896163a93198297371f34c04220daf40d133fdd4f7f"}, - {file = "ruff-0.15.4-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:917cc68503357021f541e69b35361c99387cdbbf99bd0ea4aa6f28ca99ff5338"}, - {file = "ruff-0.15.4-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:e9737c8161da79fd7cfec19f1e35620375bd8b2a50c3e77fa3d2c16f574105cc"}, - {file = "ruff-0.15.4-py3-none-musllinux_1_2_i686.whl", hash = "sha256:291258c917539e18f6ba40482fe31d6f5ac023994ee11d7bdafd716f2aab8a68"}, - {file = "ruff-0.15.4-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:3f83c45911da6f2cd5936c436cf86b9f09f09165f033a99dcf7477e34041cbc3"}, - {file = "ruff-0.15.4-py3-none-win32.whl", hash = "sha256:65594a2d557d4ee9f02834fcdf0a28daa8b3b9f6cb2cb93846025a36db47ef22"}, - {file = "ruff-0.15.4-py3-none-win_amd64.whl", hash = "sha256:04196ad44f0df220c2ece5b0e959c2f37c777375ec744397d21d15b50a75264f"}, - {file = "ruff-0.15.4-py3-none-win_arm64.whl", hash = "sha256:60d5177e8cfc70e51b9c5fad936c634872a74209f934c1e79107d11787ad5453"}, - {file = "ruff-0.15.4.tar.gz", hash = "sha256:3412195319e42d634470cc97aa9803d07e9d5c9223b99bcb1518f0c725f26ae1"}, + {file = "ruff-0.9.2-py3-none-linux_armv6l.whl", hash = "sha256:80605a039ba1454d002b32139e4970becf84b5fee3a3c3bf1c2af6f61a784347"}, + {file = "ruff-0.9.2-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:b9aab82bb20afd5f596527045c01e6ae25a718ff1784cb92947bff1f83068b00"}, + {file = "ruff-0.9.2-py3-none-macosx_11_0_arm64.whl", hash = "sha256:fbd337bac1cfa96be615f6efcd4bc4d077edbc127ef30e2b8ba2a27e18c054d4"}, + {file = "ruff-0.9.2-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:82b35259b0cbf8daa22a498018e300b9bb0174c2bbb7bcba593935158a78054d"}, + {file = "ruff-0.9.2-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8b6a9701d1e371bf41dca22015c3f89769da7576884d2add7317ec1ec8cb9c3c"}, + {file = "ruff-0.9.2-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9cc53e68b3c5ae41e8faf83a3b89f4a5d7b2cb666dff4b366bb86ed2a85b481f"}, + {file = "ruff-0.9.2-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:8efd9da7a1ee314b910da155ca7e8953094a7c10d0c0a39bfde3fcfd2a015684"}, + {file = "ruff-0.9.2-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3292c5a22ea9a5f9a185e2d131dc7f98f8534a32fb6d2ee7b9944569239c648d"}, + {file = "ruff-0.9.2-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1a605fdcf6e8b2d39f9436d343d1f0ff70c365a1e681546de0104bef81ce88df"}, + {file = "ruff-0.9.2-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c547f7f256aa366834829a08375c297fa63386cbe5f1459efaf174086b564247"}, + {file = "ruff-0.9.2-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:d18bba3d3353ed916e882521bc3e0af403949dbada344c20c16ea78f47af965e"}, + {file = "ruff-0.9.2-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:b338edc4610142355ccf6b87bd356729b62bf1bc152a2fad5b0c7dc04af77bfe"}, + {file = "ruff-0.9.2-py3-none-musllinux_1_2_i686.whl", hash = "sha256:492a5e44ad9b22a0ea98cf72e40305cbdaf27fac0d927f8bc9e1df316dcc96eb"}, + {file = "ruff-0.9.2-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:af1e9e9fe7b1f767264d26b1075ac4ad831c7db976911fa362d09b2d0356426a"}, + {file = "ruff-0.9.2-py3-none-win32.whl", hash = "sha256:71cbe22e178c5da20e1514e1e01029c73dc09288a8028a5d3446e6bba87a5145"}, + {file = "ruff-0.9.2-py3-none-win_amd64.whl", hash = "sha256:c5e1d6abc798419cf46eed03f54f2e0c3adb1ad4b801119dedf23fcaf69b55b5"}, + {file = "ruff-0.9.2-py3-none-win_arm64.whl", hash = "sha256:a1b63fa24149918f8b37cef2ee6fff81f24f0d74b6f0bdc37bc3e1f2143e41c6"}, + {file = "ruff-0.9.2.tar.gz", hash = "sha256:b5eceb334d55fae5f316f783437392642ae18e16dcf4f1858d55d3c2a0f8f5d0"}, ] [[package]] diff --git a/pyproject.toml b/pyproject.toml index fb5d57e..e9f0b9d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,7 +1,7 @@ [project] name = "mpd-now-playable" dynamic = ["version"] -description = "Expose your MPD server as a 'now playable' app on macOS" +description = "Expose your MPD server as a 'now playable' app on MacOS" authors = [ {name = "Danielle McLean", email = "dani@00dani.me"}, ] @@ -52,14 +52,6 @@ mpd-now-playable = 'mpd_now_playable.cli:main' requires = ["pdm-backend"] build-backend = "pdm.backend" -[dependency-groups] -dev = [ - "pytest>=8.0", - "mypy>=1.11", - "ruff>=0.15", - "class-doc>=0.2.6", -] - [tool.mypy] mypy_path = 'stubs' plugins = ['pydantic.mypy', 'mpd_now_playable.tools.schema.plugin'] @@ -85,13 +77,8 @@ select = [ "C90", ] ignore = [ -] - -[tool.ruff.lint.per-file-ignores] -"src/corefoundationasyncio/eventloop.py" = [ - "ANN", - "I001", - "S101", + "ANN101", # missing-type-self + "ANN102", # missing-type-cls ] [tool.ruff.lint.flake8-annotations] @@ -114,3 +101,12 @@ write_template = "__version__ = '{}'" [tool.pdm.build] excludes = ["**/.mypy_cache"] + +[tool.pdm.dev-dependencies] +dev = [ + "mypy>=1.11.0", + "ruff>=0.1.6", + "class-doc>=0.2.6", +] + + diff --git a/src/mpd_now_playable/cli.py b/src/mpd_now_playable/cli.py index 05ca535..cfed306 100644 --- a/src/mpd_now_playable/cli.py +++ b/src/mpd_now_playable/cli.py @@ -1,14 +1,12 @@ -import argparse import asyncio import sys from collections.abc import Iterable -from rich import print as rich_print +from rich import print from .__version__ import __version__ from .config.load import loadConfig from .config.model import Config -from .launchd import DEFAULT_LABEL, install_launchagent, uninstall_launchagent from .mpd.listener import MpdStateListener from .song_receiver import ( Receiver, @@ -26,68 +24,25 @@ async def listen( def print_help() -> None: - parser = build_parser() - parser.print_help() - - -def build_parser() -> argparse.ArgumentParser: - parser = argparse.ArgumentParser(prog="mpd-now-playable") - parser.add_argument( - "-v", - "--version", - action="store_true", - help="Show version and exit.", - ) - - subparsers = parser.add_subparsers(dest="command") - install_cmd = subparsers.add_parser( - "install-launchagent", - help="Install and start a per-user launchd service.", - ) - install_cmd.add_argument( - "--label", - default=DEFAULT_LABEL, - help=f"launchd label to install (default: {DEFAULT_LABEL}).", - ) - install_cmd.add_argument( - "--force", - action="store_true", - help="Replace an existing plist with the same label.", - ) - - uninstall_cmd = subparsers.add_parser( - "uninstall-launchagent", - help="Unload and remove a per-user launchd service.", - ) - uninstall_cmd.add_argument( - "--label", - default=DEFAULT_LABEL, - help=f"launchd label to uninstall (default: {DEFAULT_LABEL}).", - ) - return parser + print("Usage: mpd-now-playable [OPTIONS]") + print("") + print("Options:") + print(" -h, --help Show this help message and exit.") + print(" -v, --version Show version and exit.") def main() -> None: - parser = build_parser() - args = parser.parse_args(sys.argv[1:]) - - if args.version: - rich_print(f"mpd-now-playable v{__version__}") + args = set(sys.argv[1:]) + if "-h" in args or "--help" in args: + print_help() + return + if "-v" in args or "--version" in args: + print(f"mpd-now-playable v{__version__}") return - if args.command == "install-launchagent": - path = install_launchagent(label=args.label, force=args.force) - rich_print(f"Installed LaunchAgent at {path}") - return - - if args.command == "uninstall-launchagent": - path = uninstall_launchagent(label=args.label) - rich_print(f"Uninstalled LaunchAgent at {path}") - return - - rich_print(f"mpd-now-playable v{__version__}") + print(f"mpd-now-playable v{__version__}") config = loadConfig() - rich_print(config) + print(config) listener = MpdStateListener(config.cache) receivers = tuple(construct_receiver(rec_config) for rec_config in config.receivers) diff --git a/src/mpd_now_playable/launchd.py b/src/mpd_now_playable/launchd.py deleted file mode 100644 index 71d7995..0000000 --- a/src/mpd_now_playable/launchd.py +++ /dev/null @@ -1,85 +0,0 @@ -import os -import plistlib -import subprocess -import sys -from pathlib import Path - -DEFAULT_LABEL = "me.00dani.mpd-now-playable" - - -def _require_macos() -> None: - if sys.platform != "darwin": - msg = "launchd commands are only supported on macOS" - raise RuntimeError(msg) - - -def _launch_agents_dir() -> Path: - return Path.home() / "Library" / "LaunchAgents" - - -def plist_path_for_label(label: str) -> Path: - return _launch_agents_dir() / f"{label}.plist" - - -def _service_target(label: str) -> str: - return f"gui/{os.getuid()}/{label}" - - -def _launchctl_binary() -> str: - # Prefer absolute paths to avoid relying on PATH for privileged process control. - for candidate in ("/bin/launchctl", "/usr/bin/launchctl"): - if Path(candidate).exists(): - return candidate - return "launchctl" - - -def _run_launchctl(*args: str, check: bool = True) -> subprocess.CompletedProcess[str]: - return subprocess.run( # noqa: S603 - arguments are fixed command tokens, shell is not used. - [_launchctl_binary(), *args], - check=check, - capture_output=True, - text=True, - ) - - -def _plist_bytes(label: str) -> bytes: - payload = { - "Label": label, - "ProgramArguments": [sys.executable, "-m", "mpd_now_playable.cli"], - "RunAtLoad": True, - "KeepAlive": True, - } - return plistlib.dumps(payload, fmt=plistlib.FMT_XML) - - -def install_launchagent(label: str = DEFAULT_LABEL, force: bool = False) -> Path: - _require_macos() - plist_path = plist_path_for_label(label) - plist_path.parent.mkdir(parents=True, exist_ok=True) - - if plist_path.exists() and not force: - msg = f"{plist_path} already exists; rerun with --force to replace it" - raise FileExistsError(msg) - - plist_path.write_bytes(_plist_bytes(label)) - target = _service_target(label) - - # Ignore errors if the service does not already exist. - _run_launchctl("bootout", target, check=False) - _run_launchctl("bootstrap", f"gui/{os.getuid()}", str(plist_path)) - _run_launchctl("kickstart", "-k", target) - - return plist_path - - -def uninstall_launchagent(label: str = DEFAULT_LABEL) -> Path: - _require_macos() - plist_path = plist_path_for_label(label) - target = _service_target(label) - - # Ignore errors if the service is not loaded. - _run_launchctl("bootout", target, check=False) - if plist_path.exists(): - plist_path.unlink() - - return plist_path diff --git a/stubs/mpd/base.pyi b/stubs/mpd/base.pyi index e11e5d1..44baf58 100644 --- a/stubs/mpd/base.pyi +++ b/stubs/mpd/base.pyi @@ -1,18 +1,18 @@ -from enum import IntEnum +from enum import Enum -class FailureResponseCode(IntEnum): - NOT_LIST = ... - ARG = ... - PASSWORD = ... - PERMISSION = ... - UNKNOWN = ... - NO_EXIST = ... - PLAYLIST_MAX = ... - SYSTEM = ... - PLAYLIST_LOAD = ... - UPDATE_ALREADY = ... - PLAYER_SYNC = ... - EXIST = ... +class FailureResponseCode(Enum): + NOT_LIST: int + ARG: int + PASSWORD: int + PERMISSION: int + UNKNOWN: int + NO_EXIST: int + PLAYLIST_MAX: int + SYSTEM: int + PLAYLIST_LOAD: int + UPDATE_ALREADY: int + PLAYER_SYNC: int + EXIST: int class MPDError(Exception): ... diff --git a/tests/test_cli.py b/tests/test_cli.py deleted file mode 100644 index a82811f..0000000 --- a/tests/test_cli.py +++ /dev/null @@ -1,140 +0,0 @@ -from types import SimpleNamespace - -import pytest - -import mpd_now_playable.cli as cli - - -def test_main_help_prints_usage(monkeypatch, capsys) -> None: - monkeypatch.setattr(cli.sys, "argv", ["mpd-now-playable", "--help"]) - - def fail_load() -> None: - raise AssertionError("loadConfig should not run for --help") - - monkeypatch.setattr(cli, "loadConfig", fail_load) - - with pytest.raises(SystemExit) as exc: - cli.main() - assert exc.value.code == 0 - output = capsys.readouterr().out - - assert "usage: mpd-now-playable" in output - assert "--version" in output - assert "install-launchagent" in output - assert "uninstall-launchagent" in output - - -def test_main_version_prints_version(monkeypatch, capsys) -> None: - monkeypatch.setattr(cli.sys, "argv", ["mpd-now-playable", "--version"]) - - def fail_load() -> None: - raise AssertionError("loadConfig should not run for --version") - - monkeypatch.setattr(cli, "loadConfig", fail_load) - - cli.main() - output = capsys.readouterr().out - - assert output.strip() == f"mpd-now-playable v{cli.__version__}" - - -def test_main_starts_listener_and_receivers(monkeypatch) -> None: - receiver_cfg_1 = SimpleNamespace(kind="websockets") - receiver_cfg_2 = SimpleNamespace(kind="websockets") - config = SimpleNamespace( - cache="memory://", - mpd=SimpleNamespace(host="127.0.0.1", port=6600), - receivers=(receiver_cfg_1, receiver_cfg_2), - ) - - listener = object() - receiver_1 = object() - receiver_2 = object() - fake_coroutine = object() - - seen = {} - - def fake_load_config() -> SimpleNamespace: - return config - - def fake_listener_ctor(cache: str) -> object: - seen["cache"] = cache - return listener - - def fake_construct_receiver(rcfg: SimpleNamespace) -> object: - if rcfg is receiver_cfg_1: - return receiver_1 - if rcfg is receiver_cfg_2: - return receiver_2 - raise AssertionError(f"Unexpected receiver config: {rcfg!r}") - - class FakeLoopFactory: - @staticmethod - def make_loop() -> object: - return object() - - def fake_choose_loop_factory(receivers: tuple[object, ...]) -> type[FakeLoopFactory]: - seen["receivers"] = receivers - return FakeLoopFactory - - def fake_listen(cfg: SimpleNamespace, lst: object, receivers: tuple[object, ...]) -> object: - seen["listen_args"] = (cfg, lst, receivers) - return fake_coroutine - - def fake_run(coro: object, *, loop_factory: object, debug: bool) -> None: - seen["run"] = (coro, loop_factory, debug) - - monkeypatch.setattr(cli.sys, "argv", ["mpd-now-playable"]) - monkeypatch.setattr(cli, "loadConfig", fake_load_config) - monkeypatch.setattr(cli, "MpdStateListener", fake_listener_ctor) - monkeypatch.setattr(cli, "construct_receiver", fake_construct_receiver) - monkeypatch.setattr(cli, "choose_loop_factory", fake_choose_loop_factory) - monkeypatch.setattr(cli, "listen", fake_listen) - monkeypatch.setattr(cli.asyncio, "run", fake_run) - - cli.main() - - assert seen["cache"] == "memory://" - assert seen["receivers"] == (receiver_1, receiver_2) - assert seen["listen_args"] == (config, listener, (receiver_1, receiver_2)) - assert seen["run"] == (fake_coroutine, FakeLoopFactory.make_loop, False) - - -def test_main_install_launchagent(monkeypatch, capsys) -> None: - seen = {} - - def fake_install(*, label: str, force: bool) -> str: - seen["args"] = (label, force) - return "/tmp/example.plist" - - monkeypatch.setattr( - cli.sys, - "argv", - ["mpd-now-playable", "install-launchagent", "--label", "com.example.test", "--force"], - ) - monkeypatch.setattr(cli, "install_launchagent", fake_install) - - cli.main() - output = capsys.readouterr().out - assert seen["args"] == ("com.example.test", True) - assert "Installed LaunchAgent at /tmp/example.plist" in output - - -def test_main_uninstall_launchagent(monkeypatch, capsys) -> None: - seen = {} - - def fake_uninstall(*, label: str) -> str: - seen["label"] = label - return "/tmp/example.plist" - - monkeypatch.setattr( - cli.sys, - "argv", - ["mpd-now-playable", "uninstall-launchagent", "--label", "com.example.test"], - ) - monkeypatch.setattr(cli, "uninstall_launchagent", fake_uninstall) - - cli.main() - output = capsys.readouterr().out - assert seen["label"] == "com.example.test" - assert "Uninstalled LaunchAgent at /tmp/example.plist" in output diff --git a/tests/test_launchd.py b/tests/test_launchd.py deleted file mode 100644 index 388b434..0000000 --- a/tests/test_launchd.py +++ /dev/null @@ -1,77 +0,0 @@ -import plistlib -from pathlib import Path - -import pytest - -import mpd_now_playable.launchd as launchd - - -def test_plist_bytes_uses_absolute_python(monkeypatch, tmp_path) -> None: - monkeypatch.setattr(launchd.sys, "executable", "/abs/python") - monkeypatch.setattr(Path, "home", lambda: tmp_path) - - payload = plistlib.loads(launchd._plist_bytes("com.example.test")) - - assert payload["Label"] == "com.example.test" - assert payload["ProgramArguments"] == ["/abs/python", "-m", "mpd_now_playable.cli"] - assert payload["RunAtLoad"] is True - assert payload["KeepAlive"] is True - - -def test_install_launchagent_writes_plist_and_bootstraps(monkeypatch, tmp_path) -> None: - monkeypatch.setattr(launchd.sys, "platform", "darwin") - monkeypatch.setattr(launchd.sys, "executable", "/abs/python") - monkeypatch.setattr(Path, "home", lambda: tmp_path) - monkeypatch.setattr(launchd.os, "getuid", lambda: 501) - - seen: list[tuple[tuple[str, ...], bool]] = [] - - def fake_run_launchctl(*args: str, check: bool = True) -> object: - seen.append((args, check)) - return object() - - monkeypatch.setattr(launchd, "_run_launchctl", fake_run_launchctl) - - plist_path = launchd.install_launchagent(label="com.example.test", force=False) - assert plist_path == tmp_path / "Library" / "LaunchAgents" / "com.example.test.plist" - assert plist_path.exists() - assert seen == [ - (("bootout", "gui/501/com.example.test"), False), - (("bootstrap", "gui/501", str(plist_path)), True), - (("kickstart", "-k", "gui/501/com.example.test"), True), - ] - - -def test_install_launchagent_refuses_existing_without_force(monkeypatch, tmp_path) -> None: - monkeypatch.setattr(launchd.sys, "platform", "darwin") - monkeypatch.setattr(Path, "home", lambda: tmp_path) - - plist = tmp_path / "Library" / "LaunchAgents" / "com.example.test.plist" - plist.parent.mkdir(parents=True, exist_ok=True) - plist.write_text("already here") - - with pytest.raises(FileExistsError): - launchd.install_launchagent(label="com.example.test") - - -def test_uninstall_launchagent_boots_out_and_removes_file(monkeypatch, tmp_path) -> None: - monkeypatch.setattr(launchd.sys, "platform", "darwin") - monkeypatch.setattr(Path, "home", lambda: tmp_path) - monkeypatch.setattr(launchd.os, "getuid", lambda: 501) - - seen: list[tuple[tuple[str, ...], bool]] = [] - - def fake_run_launchctl(*args: str, check: bool = True) -> object: - seen.append((args, check)) - return object() - - monkeypatch.setattr(launchd, "_run_launchctl", fake_run_launchctl) - - plist = tmp_path / "Library" / "LaunchAgents" / "com.example.test.plist" - plist.parent.mkdir(parents=True, exist_ok=True) - plist.write_text("x") - - removed = launchd.uninstall_launchagent(label="com.example.test") - assert removed == plist - assert not plist.exists() - assert seen == [(("bootout", "gui/501/com.example.test"), False)]