13 Commits
Author SHA1 Message Date
beefcarver 6dff7700e1 docs(agents): document Homebrew resource blocks and --check drift reporting 2026-11-15 11:30:33 -05:00
beefcarver 4c2f498e41 feat(packaging): check Homebrew formula has a resource per runtime dependency 2026-11-15 11:30:29 -05:00
beefcarver 86b22226e2 build: bump tap to vendored resources 2026-11-15 11:24:48 -05:00
beefcarver fffeb7f227 chore(homebrew): bump tap submodule to the 1.2.5 formula 2026-11-15 10:17:01 -05:00
beefcarver 4d5156f7d3 build(aur): bump to 1.2.5 and build from the release sdist 2026-11-15 10:16:58 -05:00
beefcarver 4fd688e673 fix(packaging): verify the release download against a CA bundle that exists
python-build-standalone's OpenSSL has default trust paths that need not
exist on the host, so urlopen failed with "unable to get local issuer
certificate" while browsers and curl accepted the same chain.  Fall back
to certifi when OpenSSL's default cafile is missing, and name the bundle
in use when verification still fails so the next occurrence is one line
to diagnose.
2026-11-15 09:51:01 -05:00
beefcarver 512c9a663f fix(packaging): resolve repo root from script directory 2026-11-15 09:45:48 -05:00
beefcarver 86da224448 chore(release): 1.2.5 2026-11-15 09:18:45 -05:00
beefcarver 29d2b75209 chore(tools): drop the unused ansi_art generator 2026-11-15 09:07:51 -05:00
beefcarver e000b296b5 chore(tools): move bump_packaging.py to the repository root 2026-11-15 09:07:42 -05:00
beefcarver cbce35441d ci(release): publish the release to tildeforge as well 2026-11-15 09:07:37 -05:00
beefcarver 11ae0e2f7d chore(release): 1.2.4 2026-11-14 17:28:24 -05:00
beefcarver 4d9058b9b2 ci(release): let a re-run override the existing release 2026-11-14 17:27:40 -05:00
12 changed files with 155 additions and 312 deletions

No files matched your search

+31 -1
View File
@@ -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
+8 -4
View File
@@ -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,
+2 -5
View File
@@ -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 -1
View File
@@ -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"
+96 -7
View File
@@ -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}")
+3 -3
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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"
-275
View File
@@ -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())
Generated
+1 -1
View File
@@ -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'" },