Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6dff7700e1 | ||
|
|
4c2f498e41 | ||
|
|
86b22226e2 | ||
|
|
fffeb7f227 | ||
|
|
4d5156f7d3 | ||
|
|
4fd688e673 | ||
|
|
512c9a663f | ||
|
|
86da224448 | ||
|
|
29d2b75209 | ||
|
|
e000b296b5 | ||
|
|
cbce35441d | ||
|
|
11ae0e2f7d | ||
|
|
4d9058b9b2 |
No files matched your search
@@ -38,7 +38,7 @@ jobs:
|
|||||||
|
|
||||||
# No `submodules:` on the checkout above, so the tap is empty here and the
|
# No `submodules:` on the checkout above, so the tap is empty here and the
|
||||||
# sdist below differs from a local `uv build` with the submodule populated.
|
# sdist below differs from a local `uv build` with the submodule populated.
|
||||||
# The published asset is therefore the authority: tools/bump_packaging.py
|
# The published asset is therefore the authority: bump_packaging.py
|
||||||
# hashes the download, never a rebuild.
|
# hashes the download, never a rebuild.
|
||||||
- name: Build the sdist and wheel
|
- name: Build the sdist and wheel
|
||||||
run: |
|
run: |
|
||||||
@@ -64,6 +64,36 @@ jobs:
|
|||||||
uses: actions/forgejo-release@v2
|
uses: actions/forgejo-release@v2
|
||||||
with:
|
with:
|
||||||
direction: upload
|
direction: upload
|
||||||
|
# A re-run finds the release it made last time and would otherwise die
|
||||||
|
# on `curl: (22) ... 409` when it tries to create it again. Override
|
||||||
|
# deletes and recreates the release (the tag survives, since it still
|
||||||
|
# matches the triggered SHA), so re-running this workflow is safe.
|
||||||
|
override: true
|
||||||
|
tag: ${{ forgejo.ref_name }}
|
||||||
|
title: ${{ forgejo.ref_name }}
|
||||||
|
release-notes-file: dist/notes.md
|
||||||
|
|
||||||
|
# Every packaging channel (AUR, the Homebrew tap and the flake) downloads
|
||||||
|
# its source from tildeforge, so the release is published there as well.
|
||||||
|
# That repository is not a mirror of this one: nothing reaches it unless
|
||||||
|
# it is pushed, hence the tag push before the upload. Pushing a tag that
|
||||||
|
# is already there and identical is a no-op, so this stays re-runnable.
|
||||||
|
- name: Push the tag to tildeforge
|
||||||
|
env:
|
||||||
|
TILDEFORGE_TOKEN: ${{ secrets.TILDEFORGE_TOKEN }}
|
||||||
|
run: |
|
||||||
|
git push \
|
||||||
|
"https://beefcarver:${TILDEFORGE_TOKEN}@tildeforge.dev/beefcarver/beefy-player.git" \
|
||||||
|
"refs/tags/${FORGEJO_REF_NAME}"
|
||||||
|
|
||||||
|
- name: Publish the release to tildeforge
|
||||||
|
uses: actions/forgejo-release@v2
|
||||||
|
with:
|
||||||
|
direction: upload
|
||||||
|
url: https://tildeforge.dev
|
||||||
|
repo: beefcarver/beefy-player
|
||||||
|
token: ${{ secrets.TILDEFORGE_TOKEN }}
|
||||||
|
override: true
|
||||||
tag: ${{ forgejo.ref_name }}
|
tag: ${{ forgejo.ref_name }}
|
||||||
title: ${{ forgejo.ref_name }}
|
title: ${{ forgejo.ref_name }}
|
||||||
release-notes-file: dist/notes.md
|
release-notes-file: dist/notes.md
|
||||||
@@ -35,7 +35,6 @@ git submodule update --init # contrib/homebrew, the Homebrew tap
|
|||||||
uv run pytest # full test suite
|
uv run pytest # full test suite
|
||||||
uvx ruff check . # lint (ruff is not a project dependency)
|
uvx ruff check . # lint (ruff is not a project dependency)
|
||||||
uv run beefy-player --list # station ids for the current catalogue
|
uv run beefy-player --list # station ids for the current catalogue
|
||||||
uv run python tools/ansi_art.py ... # regenerate a provider's header art
|
|
||||||
```
|
```
|
||||||
|
|
||||||
`uvx ruff check .` is the enforced lint and must pass. `ruff format` is **not**
|
`uvx ruff check .` is the enforced lint and must pass. `ruff format` is **not**
|
||||||
@@ -53,15 +52,20 @@ repo-wide reformat as a side effect of an unrelated change.
|
|||||||
- **`contrib/homebrew/`** — a **git submodule** of the tap at
|
- **`contrib/homebrew/`** — a **git submodule** of the tap at
|
||||||
`https://tildeforge.dev/beefcarver/homebrew`, not a copy. Formula changes
|
`https://tildeforge.dev/beefcarver/homebrew`, not a copy. Formula changes
|
||||||
belong in the tap repository; commit them there and bump the gitlink here.
|
belong in the tap repository; commit them there and bump the gitlink here.
|
||||||
Never add loose files under that path. The formula still carries a
|
Never add loose files under that path. The formula's Python dependencies are
|
||||||
placeholder `sha256`, so it only installs with `--HEAD`.
|
vendored as `resource` blocks: Homebrew installs the sdist with `--no-deps`,
|
||||||
|
so a dependency that has no block (or whose constraint changed in
|
||||||
|
`pyproject.toml`) is simply missing at runtime. Regenerate them in the tap
|
||||||
|
with `brew update-python-resources Formula/b/beefy-player.rb`;
|
||||||
|
`uv run python bump_packaging.py <version> --check` reports a dependency
|
||||||
|
without one.
|
||||||
|
|
||||||
## Releases
|
## Releases
|
||||||
|
|
||||||
`.forgejo/workflows/release.yml` fires on a `v*` tag: it asserts that the tag,
|
`.forgejo/workflows/release.yml` fires on a `v*` tag: it asserts that the tag,
|
||||||
the `pyproject.toml` version and `beefy_player.__version__` all agree, then
|
the `pyproject.toml` version and `beefy_player.__version__` all agree, then
|
||||||
publishes release notes. It does **not** touch packaging, so run
|
publishes release notes. It does **not** touch packaging, so run
|
||||||
`tools/bump_packaging.py <version>` after publishing: it rewrites
|
`uv run python bump_packaging.py <version>` after publishing: it rewrites
|
||||||
`contrib/aur/PKGBUILD` and regenerates its `.SRCINFO` from it, rewrites the tap
|
`contrib/aur/PKGBUILD` and regenerates its `.SRCINFO` from it, rewrites the tap
|
||||||
formula in `contrib/homebrew`, and with `--commit` commits the tap first and
|
formula in `contrib/homebrew`, and with `--commit` commits the tap first and
|
||||||
then this repository. Both channels build the sdist attached to the release,
|
then this repository. Both channels build the sdist attached to the release,
|
||||||
|
|||||||
@@ -330,15 +330,12 @@ Each catalogue is a self-contained provider module under
|
|||||||
`beefy_player/providers/`; `base.py` documents the interface. Every catalogue's
|
`beefy_player/providers/`; `base.py` documents the interface. Every catalogue's
|
||||||
header is a wordmark from the shared half-block font, and the header paints it
|
header is a wordmark from the shared half-block font, and the header paints it
|
||||||
with the active palette gradient (`hero_gradient`, on by default) so all
|
with the active palette gradient (`hero_gradient`, on by default) so all
|
||||||
thirteen follow the terminal's colours. `tools/ansi_art.py` converts a PNG logo
|
thirteen follow the terminal's colours.
|
||||||
into half-block ANSI art that carries colours of its own; a provider using that
|
|
||||||
art must set `hero_gradient = False`, because the header would otherwise
|
|
||||||
repaint it with the gradient.
|
|
||||||
|
|
||||||
`uvx ruff check .` is the enforced lint. The tree is not `ruff format`-clean (54
|
`uvx ruff check .` is the enforced lint. The tree is not `ruff format`-clean (54
|
||||||
files would change), so do not reformat it wholesale.
|
files would change), so do not reformat it wholesale.
|
||||||
|
|
||||||
`tools/bump_packaging.py <version>` keeps the AUR package and the Homebrew tap
|
`uv run python bump_packaging.py <version>` keeps the AUR package and the Homebrew tap
|
||||||
formula in step with a release — it rewrites `PKGBUILD`, regenerates
|
formula in step with a release — it rewrites `PKGBUILD`, regenerates
|
||||||
`.SRCINFO` from it (no makepkg needed), rewrites the formula, and `--commit`s
|
`.SRCINFO` from it (no makepkg needed), rewrites the formula, and `--commit`s
|
||||||
both. `--check` reports drift without writing.
|
both. `--check` reports drift without writing.
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
"""Beefy Player — a flashy terminal player for internet radio."""
|
"""Beefy Player — a flashy terminal player for internet radio."""
|
||||||
|
|
||||||
__version__ = "1.2.3"
|
__version__ = "1.2.5"
|
||||||
@@ -15,10 +15,10 @@ generated on the fly. The sdist's root directory is hatchling's PEP 503 name
|
|||||||
|
|
||||||
Usage:
|
Usage:
|
||||||
|
|
||||||
python tools/bump_packaging.py 1.2.3 # download the release asset
|
python bump_packaging.py 1.2.3 # download the release asset
|
||||||
python tools/bump_packaging.py 1.2.3 --asset dist/beefy_player-1.2.3.tar.gz
|
python bump_packaging.py 1.2.3 --asset dist/beefy_player-1.2.3.tar.gz
|
||||||
python tools/bump_packaging.py 1.2.3 --commit # also commit both repos
|
python bump_packaging.py 1.2.3 --commit # also commit both repos
|
||||||
python tools/bump_packaging.py 1.2.3 --check # verify, write nothing
|
python bump_packaging.py 1.2.3 --check # verify, write nothing
|
||||||
|
|
||||||
`.SRCINFO` is never edited directly; it is regenerated by evaluating
|
`.SRCINFO` is never edited directly; it is regenerated by evaluating
|
||||||
`PKGBUILD` in bash and serialising it in makepkg's field order. That keeps it
|
`PKGBUILD` in bash and serialising it in makepkg's field order. That keeps it
|
||||||
@@ -33,15 +33,18 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
import hashlib
|
import hashlib
|
||||||
|
import os
|
||||||
import re
|
import re
|
||||||
|
import ssl
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
import tarfile
|
import tarfile
|
||||||
import tempfile
|
import tempfile
|
||||||
|
import urllib.error
|
||||||
import urllib.request
|
import urllib.request
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
ROOT = Path(__file__).resolve().parent.parent
|
ROOT = Path(__file__).resolve().parent
|
||||||
PYPROJECT = ROOT / "pyproject.toml"
|
PYPROJECT = ROOT / "pyproject.toml"
|
||||||
INIT = ROOT / "beefy_player/__init__.py"
|
INIT = ROOT / "beefy_player/__init__.py"
|
||||||
FLAKE = ROOT / "flake.nix"
|
FLAKE = ROOT / "flake.nix"
|
||||||
@@ -106,7 +109,7 @@ done
|
|||||||
|
|
||||||
#: Replaces the `_commit`-pinned git-archive source with the release asset.
|
#: Replaces the `_commit`-pinned git-archive source with the release asset.
|
||||||
ASSET_SOURCE = (
|
ASSET_SOURCE = (
|
||||||
"# The sdist attached to the v$pkgver release. tools/bump_packaging.py keeps\n"
|
"# The sdist attached to the v$pkgver release. bump_packaging.py keeps\n"
|
||||||
"# this and sha256sums in step with the tag; build/check/package use\n"
|
"# this and sha256sums in step with the tag; build/check/package use\n"
|
||||||
"# $_srcname because the sdist unpacks to hatchling's PEP 503 name.\n"
|
"# $_srcname because the sdist unpacks to hatchling's PEP 503 name.\n"
|
||||||
'_srcname="beefy_player-$pkgver"\n'
|
'_srcname="beefy_player-$pkgver"\n'
|
||||||
@@ -193,6 +196,30 @@ def sha256(path: Path) -> str:
|
|||||||
return digest.hexdigest()
|
return digest.hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def ca_file() -> str | None:
|
||||||
|
"""The CA bundle to verify with, or None to let OpenSSL use its own default.
|
||||||
|
|
||||||
|
`uv python install` fetches a python-build-standalone interpreter whose
|
||||||
|
OpenSSL is compiled with default trust paths that need not exist on the
|
||||||
|
host (on macOS, and on distros that keep certs only in
|
||||||
|
/etc/ssl/certs/ca-certificates.crt, they often do not). Verification then
|
||||||
|
fails with `unable to get local issuer certificate` for every site, even
|
||||||
|
though curl and browsers are happy. Fall back to certifi, which `httpx`
|
||||||
|
already puts in the environment.
|
||||||
|
"""
|
||||||
|
if os.environ.get("SSL_CERT_FILE"):
|
||||||
|
return None # ssl honours the variable itself; do not second-guess it
|
||||||
|
paths = ssl.get_default_verify_paths()
|
||||||
|
for candidate in (paths.cafile, paths.openssl_cafile):
|
||||||
|
if candidate and Path(candidate).is_file():
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
import certifi
|
||||||
|
except ModuleNotFoundError:
|
||||||
|
return None
|
||||||
|
return certifi.where()
|
||||||
|
|
||||||
|
|
||||||
def fetch_asset(version: str, override: Path | None) -> Path:
|
def fetch_asset(version: str, override: Path | None) -> Path:
|
||||||
if override is not None:
|
if override is not None:
|
||||||
if not override.is_file():
|
if not override.is_file():
|
||||||
@@ -200,10 +227,25 @@ def fetch_asset(version: str, override: Path | None) -> Path:
|
|||||||
return override
|
return override
|
||||||
url = RELEASE_ASSET.format(version=version, name=ASSET_NAME)
|
url = RELEASE_ASSET.format(version=version, name=ASSET_NAME)
|
||||||
say(f"downloading {url}")
|
say(f"downloading {url}")
|
||||||
|
cafile = ca_file()
|
||||||
|
context = ssl.create_default_context(cafile=cafile)
|
||||||
target = Path(tempfile.mkdtemp()) / f"{ASSET_NAME}-{version}.tar.gz"
|
target = Path(tempfile.mkdtemp()) / f"{ASSET_NAME}-{version}.tar.gz"
|
||||||
try:
|
try:
|
||||||
with urllib.request.urlopen(url) as response, target.open("wb") as handle:
|
with urllib.request.urlopen(url, context=context) as response, target.open(
|
||||||
|
"wb"
|
||||||
|
) as handle:
|
||||||
handle.write(response.read())
|
handle.write(response.read())
|
||||||
|
except urllib.error.URLError as error:
|
||||||
|
# urlopen wraps socket/SSL failures; the cause is in `.reason`.
|
||||||
|
if isinstance(error.reason, ssl.SSLCertVerificationError):
|
||||||
|
bundle = cafile or ssl.get_default_verify_paths().cafile or "no bundle found"
|
||||||
|
raise fail(
|
||||||
|
f"cannot fetch the release asset ({error.reason.verify_message or error.reason}); "
|
||||||
|
f"verified against {bundle}. If that bundle is stale, point SSL_CERT_FILE "
|
||||||
|
"at a current one (on Arch and Debian that is "
|
||||||
|
"/etc/ssl/certs/ca-certificates.crt)"
|
||||||
|
) from error
|
||||||
|
raise fail(f"cannot fetch the release asset ({error.reason}); is the tag published?") from error
|
||||||
except OSError as error:
|
except OSError as error:
|
||||||
raise fail(f"cannot fetch the release asset ({error}); is the tag published?") from error
|
raise fail(f"cannot fetch the release asset ({error}); is the tag published?") from error
|
||||||
return target
|
return target
|
||||||
@@ -293,6 +335,50 @@ def bump_srcinfo() -> None:
|
|||||||
say("regenerated contrib/aur/.SRCINFO")
|
say("regenerated contrib/aur/.SRCINFO")
|
||||||
|
|
||||||
|
|
||||||
|
#: Names that may appear as `resource` blocks in the formula. A Python
|
||||||
|
#: dependency without one is silently skipped at install time because Homebrew
|
||||||
|
#: installs the sdist `--no-deps`, which surfaces as an ImportError on launch
|
||||||
|
#: rather than as a packaging error.
|
||||||
|
RESOURCE_LINE = re.compile(r'(?m)^ resource "([^"]+)" do$')
|
||||||
|
|
||||||
|
|
||||||
|
def pypi_name(name: str) -> str:
|
||||||
|
"""PEP 503 name: lowercase, runs of -_. collapsed to a single hyphen."""
|
||||||
|
return re.sub(r"[-_.]+", "-", name).lower()
|
||||||
|
|
||||||
|
|
||||||
|
def runtime_dependencies() -> list[str]:
|
||||||
|
"""The distribution names in `pyproject.toml`'s `[project] dependencies`."""
|
||||||
|
block = re.search(r"(?ms)^dependencies = \[(.*?)^\]$", PYPROJECT.read_text())
|
||||||
|
if block is None:
|
||||||
|
raise fail("no dependencies array in pyproject.toml")
|
||||||
|
return re.findall(r'^\s*"([A-Za-z0-9._-]+)', block.group(1), re.MULTILINE)
|
||||||
|
|
||||||
|
|
||||||
|
def formula_excluded_resources() -> set[str]:
|
||||||
|
"""Dependencies the formula deliberately leaves to other formulae."""
|
||||||
|
match = re.search(r'pypi_packages .*exclude_packages: "([^"]*)"', FORMULA.read_text())
|
||||||
|
if match is None:
|
||||||
|
return set()
|
||||||
|
return {pypi_name(name.strip()) for name in match.group(1).split(",") if name.strip()}
|
||||||
|
|
||||||
|
|
||||||
|
def check_formula_resources(problems: list[str]) -> None:
|
||||||
|
"""Every runtime dependency needs a `resource`, unless another formula has it."""
|
||||||
|
resources = {pypi_name(name) for name in RESOURCE_LINE.findall(FORMULA.read_text())}
|
||||||
|
if not resources:
|
||||||
|
problems.append("formula vendors no Python resources; it would install --no-deps")
|
||||||
|
excluded = formula_excluded_resources()
|
||||||
|
for dependency in runtime_dependencies():
|
||||||
|
name = pypi_name(dependency)
|
||||||
|
if name in excluded or name in resources:
|
||||||
|
continue
|
||||||
|
problems.append(
|
||||||
|
f"formula has no resource for {name}"
|
||||||
|
" (run `brew update-python-resources Formula/b/beefy-player.rb` in the tap)"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def bump_formula(version: str, checksum: str) -> None:
|
def bump_formula(version: str, checksum: str) -> None:
|
||||||
text = FORMULA.read_text()
|
text = FORMULA.read_text()
|
||||||
url = RELEASE_ASSET.format(version=version, name=ASSET_NAME)
|
url = RELEASE_ASSET.format(version=version, name=ASSET_NAME)
|
||||||
@@ -302,6 +388,8 @@ def bump_formula(version: str, checksum: str) -> None:
|
|||||||
)
|
)
|
||||||
# The default branch is master, and --HEAD is only useful if it resolves.
|
# The default branch is master, and --HEAD is only useful if it resolves.
|
||||||
text = text.replace('branch: "main"', 'branch: "master"')
|
text = text.replace('branch: "main"', 'branch: "master"')
|
||||||
|
# The `resource` blocks pin versions, not the release: they only go stale
|
||||||
|
# when pyproject.toml's dependencies change, which `--check` reports.
|
||||||
FORMULA.write_text(text)
|
FORMULA.write_text(text)
|
||||||
say("wrote contrib/homebrew/Formula/b/beefy-player.rb")
|
say("wrote contrib/homebrew/Formula/b/beefy-player.rb")
|
||||||
|
|
||||||
@@ -393,6 +481,7 @@ def check(version: str) -> int:
|
|||||||
problems.append('formula head branch is "main"; this repository uses master')
|
problems.append('formula head branch is "main"; this repository uses master')
|
||||||
if re.search(r'sha256 "0{64}"', formula):
|
if re.search(r'sha256 "0{64}"', formula):
|
||||||
problems.append("formula still has the placeholder sha256")
|
problems.append("formula still has the placeholder sha256")
|
||||||
|
check_formula_resources(problems)
|
||||||
|
|
||||||
for problem in problems:
|
for problem in problems:
|
||||||
say(f"drift: {problem}")
|
say(f"drift: {problem}")
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
pkgbase = beefy-player
|
pkgbase = beefy-player
|
||||||
pkgdesc = Flashy terminal player for internet radio with a live spectrum analyser
|
pkgdesc = Flashy terminal player for internet radio with a live spectrum analyser
|
||||||
pkgver = 1.2.2
|
pkgver = 1.2.5
|
||||||
pkgrel = 1
|
pkgrel = 1
|
||||||
url = https://tildeforge.dev/beefcarver/beefy-player
|
url = https://tildeforge.dev/beefcarver/beefy-player
|
||||||
arch = any
|
arch = any
|
||||||
@@ -22,7 +22,7 @@ pkgbase = beefy-player
|
|||||||
optdepends = libpulse: capture the output monitor so the analyser follows what you hear (parec/pactl)
|
optdepends = libpulse: capture the output monitor so the analyser follows what you hear (parec/pactl)
|
||||||
optdepends = mpv: preferred audio backend
|
optdepends = mpv: preferred audio backend
|
||||||
optdepends = tmux: detect the outer terminal for kitty graphics passthrough inside tmux
|
optdepends = tmux: detect the outer terminal for kitty graphics passthrough inside tmux
|
||||||
source = beefy-player-1.2.2.tar.gz::https://tildeforge.dev/beefcarver/beefy-player/archive/941c7446384bf346e40d1438a7ce67939d9615df.tar.gz
|
source = beefy-player-1.2.5.tar.gz::https://tildeforge.dev/beefcarver/beefy-player/releases/download/v1.2.5/beefy_player-1.2.5.tar.gz
|
||||||
sha256sums = 8ac568676ef5ef8345441c28e4f8f5d61fb06d5225e566ac578acae18672c778
|
sha256sums = 344f2266cb63d05bf294349599ecef636b3c8c25c8b29bcf0eff8818394b5f74
|
||||||
|
|
||||||
pkgname = beefy-player
|
pkgname = beefy-player
|
||||||
+10
-12
@@ -1,7 +1,7 @@
|
|||||||
# Maintainer: beefy-player contributors <[email protected]>
|
# Maintainer: beefy-player contributors <[email protected]>
|
||||||
|
|
||||||
pkgname=beefy-player
|
pkgname=beefy-player
|
||||||
pkgver=1.2.2
|
pkgver=1.2.5
|
||||||
pkgrel=1
|
pkgrel=1
|
||||||
pkgdesc="Flashy terminal player for internet radio with a live spectrum analyser"
|
pkgdesc="Flashy terminal player for internet radio with a live spectrum analyser"
|
||||||
arch=('any')
|
arch=('any')
|
||||||
@@ -33,27 +33,25 @@ optdepends=(
|
|||||||
'tmux: detect the outer terminal for kitty graphics passthrough inside tmux'
|
'tmux: detect the outer terminal for kitty graphics passthrough inside tmux'
|
||||||
)
|
)
|
||||||
|
|
||||||
# Upstream does not tag releases yet, so the source is the archive of the
|
# The sdist attached to the v$pkgver release. bump_packaging.py keeps
|
||||||
# commit that carries this version. Once a `v$pkgver` tag exists, switch to
|
# this and sha256sums in step with the tag; build/check/package use
|
||||||
#
|
# $_srcname because the sdist unpacks to hatchling's PEP 503 name.
|
||||||
# source=("$pkgname-$pkgver.tar.gz::$url/archive/v$pkgver.tar.gz")
|
_srcname="beefy_player-$pkgver"
|
||||||
#
|
source=("$pkgname-$pkgver.tar.gz::$url/releases/download/v$pkgver/$_srcname.tar.gz")
|
||||||
_commit=941c7446384bf346e40d1438a7ce67939d9615df
|
sha256sums=('344f2266cb63d05bf294349599ecef636b3c8c25c8b29bcf0eff8818394b5f74')
|
||||||
source=("$pkgname-$pkgver.tar.gz::$url/archive/$_commit.tar.gz")
|
|
||||||
sha256sums=('8ac568676ef5ef8345441c28e4f8f5d61fb06d5225e566ac578acae18672c778')
|
|
||||||
|
|
||||||
build() {
|
build() {
|
||||||
cd "$pkgname"
|
cd "$_srcname"
|
||||||
python -m build --wheel --no-isolation
|
python -m build --wheel --no-isolation
|
||||||
}
|
}
|
||||||
|
|
||||||
check() {
|
check() {
|
||||||
cd "$pkgname"
|
cd "$_srcname"
|
||||||
# The suite is offline: catalogues are fixtures, mpv/parec are faked.
|
# The suite is offline: catalogues are fixtures, mpv/parec are faked.
|
||||||
python -m pytest -q
|
python -m pytest -q
|
||||||
}
|
}
|
||||||
|
|
||||||
package() {
|
package() {
|
||||||
cd "$pkgname"
|
cd "$_srcname"
|
||||||
python -m installer --destdir="$pkgdir" dist/*.whl
|
python -m installer --destdir="$pkgdir" dist/*.whl
|
||||||
}
|
}
|
||||||
+1
-1
Submodule contrib/homebrew updated: ad6fcc5732...c958da0067.
@@ -34,7 +34,7 @@
|
|||||||
in
|
in
|
||||||
py.buildPythonApplication {
|
py.buildPythonApplication {
|
||||||
pname = "beefy-player";
|
pname = "beefy-player";
|
||||||
version = "1.2.3";
|
version = "1.2.5";
|
||||||
pyproject = true;
|
pyproject = true;
|
||||||
src = self;
|
src = self;
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "beefy-player"
|
name = "beefy-player"
|
||||||
version = "1.2.3"
|
version = "1.2.5"
|
||||||
description = "A flashy terminal player for internet radio — live spectrum analyser, MPRIS / macOS Now Playing support, song history and pluggable station catalogues (SomaFM, Radio Paradise, dublab, Rinse FM and more)"
|
description = "A flashy terminal player for internet radio — live spectrum analyser, MPRIS / macOS Now Playing support, song history and pluggable station catalogues (SomaFM, Radio Paradise, dublab, Rinse FM and more)"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
license = "WTFPL"
|
license = "WTFPL"
|
||||||
|
|||||||
@@ -1,275 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""Turn a logo into half-block ANSI art for the header hero.
|
|
||||||
|
|
||||||
The header hero is a handful of text rows, so an image has to survive a brutal
|
|
||||||
downsample: two pixels per cell (upper/lower half block), one ANSI colour per
|
|
||||||
half. Colours are quantised to the stock 16-colour ANSI palette on purpose —
|
|
||||||
the rest of the UI never emits RGB, and a hero built from palette names still
|
|
||||||
reads as the station's art in any terminal theme.
|
|
||||||
|
|
||||||
Shipped catalogues no longer use image art: each one renders a wordmark from
|
|
||||||
beefy_player/providers/wordmark.py, which the header repaints with the active
|
|
||||||
palette gradient (RadioProvider.hero_gradient, on by default). Art from this
|
|
||||||
tool carries colours of its own, so a provider that uses it must set
|
|
||||||
hero_gradient = False — otherwise the header repaints the art with the gradient
|
|
||||||
and the palette names below are wasted.
|
|
||||||
|
|
||||||
Usage:
|
|
||||||
|
|
||||||
python tools/ansi_art.py logo.png --cols 22 --rows 7
|
|
||||||
python tools/ansi_art.py --url https://example.org/logo.png \\
|
|
||||||
--cols 22 --rows 7 --module beefy_player/providers/<slug>/hero.py
|
|
||||||
|
|
||||||
``--preview out.png`` draws what the cells will look like, which is much easier
|
|
||||||
to judge than a wall of ▀ and ▄.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import argparse
|
|
||||||
import sys
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
from PIL import Image, ImageDraw
|
|
||||||
|
|
||||||
#: The stock xterm colours the names below resolve to in a terminal.
|
|
||||||
ANSI16: dict[str, tuple[int, int, int]] = {
|
|
||||||
"black": (0, 0, 0),
|
|
||||||
"red": (170, 0, 0),
|
|
||||||
"green": (0, 170, 0),
|
|
||||||
"yellow": (170, 85, 0),
|
|
||||||
"blue": (0, 0, 170),
|
|
||||||
"magenta": (170, 0, 170),
|
|
||||||
"cyan": (0, 170, 170),
|
|
||||||
"white": (170, 170, 170),
|
|
||||||
"bright_black": (85, 85, 85),
|
|
||||||
"bright_red": (255, 85, 85),
|
|
||||||
"bright_green": (85, 255, 85),
|
|
||||||
"bright_yellow": (255, 255, 85),
|
|
||||||
"bright_blue": (85, 85, 255),
|
|
||||||
"bright_magenta": (255, 85, 255),
|
|
||||||
"bright_cyan": (85, 255, 255),
|
|
||||||
"bright_white": (255, 255, 255),
|
|
||||||
}
|
|
||||||
|
|
||||||
#: Below this alpha a half-cell counts as empty (terminal background).
|
|
||||||
ALPHA_CUTOFF = 110
|
|
||||||
|
|
||||||
#: Ink weaker than this is treated as fringe when cropping the image.
|
|
||||||
CROP_CUTOFF = 96
|
|
||||||
|
|
||||||
|
|
||||||
def nearest_colour(rgb: tuple[int, int, int]) -> str:
|
|
||||||
best, best_distance = "white", float("inf")
|
|
||||||
for name, reference in ANSI16.items():
|
|
||||||
distance = sum((a - b) ** 2 for a, b in zip(rgb, reference, strict=True))
|
|
||||||
if distance < best_distance:
|
|
||||||
best, best_distance = name, distance
|
|
||||||
return best
|
|
||||||
|
|
||||||
|
|
||||||
def key_white(image: Image.Image, tolerance: int) -> Image.Image:
|
|
||||||
"""Make near-white pixels transparent.
|
|
||||||
|
|
||||||
A logo exported on a white background (or one whose anti-aliasing bleeds
|
|
||||||
white) otherwise survives the downsample as a glaring box.
|
|
||||||
"""
|
|
||||||
image = image.copy()
|
|
||||||
pixels = image.load()
|
|
||||||
width, height = image.size
|
|
||||||
threshold = 255 - max(0, tolerance)
|
|
||||||
for y in range(height):
|
|
||||||
for x in range(width):
|
|
||||||
red, green, blue, alpha = pixels[x, y]
|
|
||||||
if alpha and min(red, green, blue) >= threshold:
|
|
||||||
pixels[x, y] = (red, green, blue, 0)
|
|
||||||
return image
|
|
||||||
|
|
||||||
|
|
||||||
def resize_premultiplied(image: Image.Image, size: tuple[int, int]) -> Image.Image:
|
|
||||||
"""Resize in premultiplied alpha, so transparent pixels cannot bleed in.
|
|
||||||
|
|
||||||
Plain ``Image.resize`` averages the RGB of fully transparent pixels (often
|
|
||||||
white) with the surrounding ink, which is how a clean logo turns into a
|
|
||||||
grey haze at 20x14.
|
|
||||||
"""
|
|
||||||
import numpy as np
|
|
||||||
|
|
||||||
data = np.asarray(image.convert("RGBA"), dtype=np.float32)
|
|
||||||
alpha = data[..., 3:4] / 255.0
|
|
||||||
premultiplied = np.concatenate([data[..., :3] * alpha, data[..., 3:4]], axis=-1)
|
|
||||||
small = Image.fromarray(premultiplied.astype(np.uint8), "RGBA").resize(
|
|
||||||
size, Image.LANCZOS
|
|
||||||
)
|
|
||||||
out = np.asarray(small, dtype=np.float32)
|
|
||||||
out_alpha = out[..., 3:4] / 255.0
|
|
||||||
rgb = out[..., :3] / np.maximum(out_alpha, 1e-6)
|
|
||||||
return Image.fromarray(
|
|
||||||
np.concatenate([np.clip(rgb, 0, 255), out[..., 3:4]], axis=-1).astype(np.uint8),
|
|
||||||
"RGBA",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def load_image(
|
|
||||||
path: Path | None, url: str | None, key_white_background: bool = False
|
|
||||||
) -> Image.Image:
|
|
||||||
if path is None and url:
|
|
||||||
import httpx
|
|
||||||
|
|
||||||
response = httpx.get(url, follow_redirects=True, timeout=30.0)
|
|
||||||
response.raise_for_status()
|
|
||||||
from io import BytesIO
|
|
||||||
|
|
||||||
image = Image.open(BytesIO(response.content))
|
|
||||||
elif path is not None:
|
|
||||||
image = Image.open(path)
|
|
||||||
else: # pragma: no cover - argparse enforces one of the two
|
|
||||||
raise SystemExit("pass an image path or --url")
|
|
||||||
|
|
||||||
image = image.convert("RGBA")
|
|
||||||
if key_white_background:
|
|
||||||
image = key_white(image, 24)
|
|
||||||
# Trim the transparent margin: the art should fill the box it is given.
|
|
||||||
opaque = image.getchannel("A").point(lambda value: 255 if value > CROP_CUTOFF else 0)
|
|
||||||
box = opaque.getbbox()
|
|
||||||
return image.crop(box) if box else image
|
|
||||||
|
|
||||||
|
|
||||||
def art_rows(image: Image.Image, cols: int, rows: int) -> list[list[tuple[str, str, str]]]:
|
|
||||||
"""(glyph, foreground, background) per cell, two source pixels per cell."""
|
|
||||||
small = resize_premultiplied(image, (cols, rows * 2))
|
|
||||||
pixels = small.load()
|
|
||||||
grid: list[list[tuple[str, str, str]]] = []
|
|
||||||
for row in range(rows):
|
|
||||||
cells: list[tuple[str, str, str]] = []
|
|
||||||
for column in range(cols):
|
|
||||||
top = pixels[column, row * 2]
|
|
||||||
bottom = pixels[column, row * 2 + 1]
|
|
||||||
top_ink = top[3] >= ALPHA_CUTOFF
|
|
||||||
bottom_ink = bottom[3] >= ALPHA_CUTOFF
|
|
||||||
if not top_ink and not bottom_ink:
|
|
||||||
cells.append((" ", "", ""))
|
|
||||||
elif top_ink and bottom_ink:
|
|
||||||
cells.append(("▀", nearest_colour(top[:3]), nearest_colour(bottom[:3])))
|
|
||||||
elif top_ink:
|
|
||||||
cells.append(("▀", nearest_colour(top[:3]), ""))
|
|
||||||
else:
|
|
||||||
cells.append(("▄", nearest_colour(bottom[:3]), ""))
|
|
||||||
grid.append(cells)
|
|
||||||
return grid
|
|
||||||
|
|
||||||
|
|
||||||
def markup(grid: list[list[tuple[str, str, str]]]) -> list[str]:
|
|
||||||
"""One rich-markup string per row, run-length encoded by style."""
|
|
||||||
lines: list[str] = []
|
|
||||||
for cells in grid:
|
|
||||||
line: list[str] = []
|
|
||||||
index = 0
|
|
||||||
while index < len(cells):
|
|
||||||
glyph, foreground, background = cells[index]
|
|
||||||
if not background and glyph == " ":
|
|
||||||
line.append(" ")
|
|
||||||
index += 1
|
|
||||||
continue
|
|
||||||
run = [glyph]
|
|
||||||
index += 1
|
|
||||||
while index < len(cells) and cells[index] == (glyph, foreground, background):
|
|
||||||
run.append(cells[index][0])
|
|
||||||
index += 1
|
|
||||||
style = " ".join(
|
|
||||||
part for part in (foreground, f"on {background}" if background else "") if part
|
|
||||||
)
|
|
||||||
body = "".join(run)
|
|
||||||
line.append(f"[{style}]{body}[/]" if style else body)
|
|
||||||
lines.append("".join(line).rstrip())
|
|
||||||
return lines
|
|
||||||
|
|
||||||
|
|
||||||
def preview(grid: list[list[tuple[str, str, str]]], path: Path, cell: tuple[int, int]) -> None:
|
|
||||||
width, height = cell
|
|
||||||
image = Image.new("RGB", (len(grid[0]) * width, len(grid) * height), (24, 24, 24))
|
|
||||||
draw = ImageDraw.Draw(image)
|
|
||||||
for row, cells in enumerate(grid):
|
|
||||||
for column, (glyph, foreground, background) in enumerate(cells):
|
|
||||||
x, y = column * width, row * height
|
|
||||||
if background:
|
|
||||||
draw.rectangle([x, y, x + width - 1, y + height - 1], fill=ANSI16[background])
|
|
||||||
if glyph == " ":
|
|
||||||
continue
|
|
||||||
colour = ANSI16.get(foreground, (140, 140, 140))
|
|
||||||
if glyph == "▀":
|
|
||||||
box = [x, y, x + width - 1, y + height // 2 - 1]
|
|
||||||
else:
|
|
||||||
box = [x, y + height // 2, x + width - 1, y + height - 1]
|
|
||||||
draw.rectangle(box, fill=colour)
|
|
||||||
image.resize((image.width * 3, image.height * 3), Image.NEAREST).save(path)
|
|
||||||
|
|
||||||
|
|
||||||
MODULE_TEMPLATE = '''"""Generated header art — do not edit by hand.
|
|
||||||
|
|
||||||
{provenance}
|
|
||||||
|
|
||||||
Regenerate with::
|
|
||||||
|
|
||||||
{command}
|
|
||||||
|
|
||||||
Each row is rich markup; ``▀`` is a half block whose foreground is the top
|
|
||||||
pixel and whose background is the pixel below it. Colours are stock ANSI
|
|
||||||
palette names, so the art keeps the terminal's own palette.
|
|
||||||
|
|
||||||
The header paints every hero with the active palette gradient unless the
|
|
||||||
provider opts out, so the class using this art must set
|
|
||||||
``hero_gradient = False``.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
HERO: tuple[str, ...] = (
|
|
||||||
{rows}
|
|
||||||
)
|
|
||||||
'''
|
|
||||||
|
|
||||||
|
|
||||||
def main(argv: list[str] | None = None) -> int:
|
|
||||||
parser = argparse.ArgumentParser(description=__doc__)
|
|
||||||
parser.add_argument("image", nargs="?", type=Path, help="local image file")
|
|
||||||
parser.add_argument("--url", help="fetch the image from this URL instead")
|
|
||||||
parser.add_argument("--cols", type=int, default=22, help="terminal columns (default 22)")
|
|
||||||
parser.add_argument("--rows", type=int, default=7, help="terminal rows (default 7)")
|
|
||||||
parser.add_argument("--module", type=Path, help="write a Python module with HERO")
|
|
||||||
parser.add_argument("--preview", type=Path, help="write a PNG preview of the cells")
|
|
||||||
parser.add_argument("--provenance", default="", help="source note for the module header")
|
|
||||||
parser.add_argument(
|
|
||||||
"--key-white",
|
|
||||||
action="store_true",
|
|
||||||
help="treat near-white pixels as transparent (logos on white)",
|
|
||||||
)
|
|
||||||
args = parser.parse_args(argv)
|
|
||||||
|
|
||||||
image = load_image(args.image, args.url, args.key_white)
|
|
||||||
grid = art_rows(image, args.cols, args.rows)
|
|
||||||
rows = markup(grid)
|
|
||||||
|
|
||||||
if args.preview:
|
|
||||||
preview(grid, args.preview, (10, 20))
|
|
||||||
|
|
||||||
if args.module:
|
|
||||||
source = args.provenance or args.url or str(args.image)
|
|
||||||
command = "uv run python tools/ansi_art.py " + " ".join(sys.argv[1:])
|
|
||||||
body = "\n".join(f" {row!r}," for row in rows)
|
|
||||||
args.module.write_text(
|
|
||||||
MODULE_TEMPLATE.format(
|
|
||||||
provenance=f"Source: {source}",
|
|
||||||
command=command,
|
|
||||||
rows=body,
|
|
||||||
),
|
|
||||||
encoding="utf-8",
|
|
||||||
)
|
|
||||||
print(f"wrote {args.module} ({len(rows)} rows x {args.cols} cols)")
|
|
||||||
else:
|
|
||||||
print("\n".join(rows))
|
|
||||||
return 0
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
raise SystemExit(main())
|
|
||||||
@@ -32,7 +32,7 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "beefy-player"
|
name = "beefy-player"
|
||||||
version = "1.2.2"
|
version = "1.2.5"
|
||||||
source = { editable = "." }
|
source = { editable = "." }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "dbus-next", marker = "sys_platform == 'linux'" },
|
{ name = "dbus-next", marker = "sys_platform == 'linux'" },
|
||||||
|
|||||||
Reference in new issue
Block a user