diff --git a/.github/workflows/pypi.yml b/.github/workflows/pypi.yml deleted file mode 100644 index 73e74fb..0000000 --- a/.github/workflows/pypi.yml +++ /dev/null @@ -1,25 +0,0 @@ ---- -name: 👷🏼 pypi - -on: - release: - types: [ created ] - -jobs: - build: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - name: Extract branch name - shell: bash - run: | - echo "##[set-output name=ver;]$(echo ${GITHUB_REF#refs/*/})" - id: extract_name_and_version - - run: sed -i 's/0.0.0/'"${{ steps.extract_name_and_version.outputs.ver }}"'/' pyproject.toml - - run: head -n 10 pyproject.toml - - run: sed -i 's/0.0.0/'"${{ steps.extract_name_and_version.outputs.ver }}"'/' userpic.py - - uses: astral-sh/setup-uv@v4 - - run: uv python install 3.12 - - run: uv sync --all-extras --dev - - run: uv build - - run: uv publish --username __token__ --password ${{ secrets.PYPI_TOKEN }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..d782ee6 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,91 @@ +--- +name: 🚀 release + +on: + push: + branches: [ main ] + +concurrency: + group: release + cancel-in-progress: false + +permissions: + contents: read + +jobs: + check: + runs-on: ubuntu-latest + env: + UV_PYTHON: "3.13" + steps: + - uses: actions/checkout@v4 + - uses: extractions/setup-just@v2 + - uses: astral-sh/setup-uv@v4 + - run: uv python install "${UV_PYTHON}" + - run: uv sync --all-extras --dev + - run: just lint + - run: just tests + - name: Check that the version files agree + run: | + version="$(uv run cz version --project)" + grep -qx "__version__ = '${version}'" userpic.py + + tag: + needs: check + runs-on: ubuntu-latest + permissions: + contents: write + outputs: + version: ${{ steps.tag.outputs.version }} + env: + UV_PYTHON: "3.13" + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - uses: astral-sh/setup-uv@v4 + - run: uv python install "${UV_PYTHON}" + - run: uv sync --all-extras --dev + - name: Tag the release + id: tag + run: | + version="$(uv run cz version --project)" + if git rev-parse -q --verify "refs/tags/${version}" >/dev/null; then + echo "${version} is already tagged, nothing to release" + exit 0 + fi + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git tag -a "${version}" -m "${version}" + git push origin "${version}" + echo "version=${version}" >> "${GITHUB_OUTPUT}" + + publish: + needs: tag + if: needs.tag.outputs.version != '' + runs-on: ubuntu-latest + permissions: + contents: write + env: + UV_PYTHON: "3.13" + VERSION: ${{ needs.tag.outputs.version }} + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ needs.tag.outputs.version }} + fetch-depth: 0 + - uses: astral-sh/setup-uv@v4 + - run: uv python install "${UV_PYTHON}" + - run: uv sync --all-extras --dev + - name: Build and check that the artifacts match the tag + run: | + uv build + test -f "dist/tiny_userpic-${VERSION}-py3-none-any.whl" + test -f "dist/tiny_userpic-${VERSION}.tar.gz" + - run: uv publish --username __token__ --password ${{ secrets.PYPI_TOKEN }} + - name: Publish the github release + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + uv run cz changelog "${VERSION}" --dry-run > notes.md + gh release create "${VERSION}" --title "${VERSION}" --notes-file notes.md dist/* diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 0698e4e..3ca9ad5 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -2,26 +2,45 @@ name: 🧐 test on: + push: + branches: [ main ] pull_request: branches: [ main ] +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + jobs: + lint: + runs-on: ubuntu-latest + env: + UV_PYTHON: "3.13" + steps: + - uses: actions/checkout@v4 + - uses: extractions/setup-just@v2 + - uses: astral-sh/setup-uv@v4 + - run: uv python install "${UV_PYTHON}" + - run: uv sync --all-extras --dev + - run: just lint + tests: runs-on: ubuntu-latest strategy: + fail-fast: false matrix: - just-trigger: - - "lint" - - "tests" python-version: - "3.10" - "3.11" - "3.12" - "3.13" + - "3.14" + env: + UV_PYTHON: ${{ matrix.python-version }} steps: - uses: actions/checkout@v4 - uses: extractions/setup-just@v2 - uses: astral-sh/setup-uv@v4 - - run: uv python install ${{ matrix.python-version }} + - run: uv python install "${UV_PYTHON}" - run: uv sync --all-extras --dev - - run: just ${{ matrix.just-trigger }} + - run: just tests diff --git a/.gitignore b/.gitignore index 5a1bb6a..402885d 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,5 @@ - .png -.svg +/*.png +/*.svg .idea/ # Byte-compiled / optimized / DLL files diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 184b7be..6731c65 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -11,7 +11,7 @@ repos: language: system 'types_or': [ python, pyi ] - repo: https://github.com/astral-sh/ruff-pre-commit - rev: "v0.8.1" + rev: "v0.8.2" hooks: - id: ruff args: [ --fix, --unsafe-fixes ] diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..d277074 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,58 @@ +# Changelog + +All notable changes to this project are documented in this file. + +Entries from 1.0.0 onwards are generated from [conventional commits](https://www.conventionalcommits.org/) +by [commitizen](https://commitizen-tools.github.io/commitizen/): run `just bump` in the pull request +to update the version and this file, and the release workflow tags and publishes it once the pull +request lands on `main`. Earlier releases predate that convention and are summarised by hand. + +## 1.0.0 (2026-08-22) + +### BREAKING CHANGE + +- the padding default changed from (0, 0) to (20, 20) in +make_userpic_svg and in both *_from_string functions, make_userpic was removed, +and degenerate size/image_size/padding values now raise ValueError instead of +hanging or returning a broken image. The rendered pixels and the SVG markup +differ from 0.4.0, although the pattern for a given seed or string is unchanged. + +### Feat + +- declare the api stable and automate releases + +### Fix + +- pattern rendering, input validation and svg output + +## 0.4.0 (2025-03-24) + +### Feat + +- add `make_userpic_image_from_string` and `make_userpic_svg_from_string`, deriving the pattern from + a SHA-256 digest of the input text +- add the `seed` parameter to the image and SVG generators for reproducible output +- add a test suite and the example images used by the readme + +## 0.3.0 (2024-12-12) + +### Refactor + +- rewrite the library as a single `userpic.py` module +- drop the command line interface, leaving a library-only package +- make Pillow the only runtime dependency +- move the tooling to uv and just + +Patch releases 0.3.1, 0.3.2 and 0.3.3 only touched the readme and the packaging metadata. + +## 0.2.0 (2022-07-22) + +### Refactor + +- make CairoSVG an optional dependency instead of a required one +- replace typer with click in the command line interface + +## 0.1.1 (2021-07-03) + +Initial release: a `userpic` package with an SVG generator, a Cairo-backed PNG renderer and a +typer-based command line interface. diff --git a/README.md b/README.md index ac077d0..0be2f3e 100644 --- a/README.md +++ b/README.md @@ -6,12 +6,9 @@ Oversimplified Github-like userpic (avatar) generator. [![PyPI](https://img.shields.io/pypi/v/tiny-userpic.svg)](https://pypi.python.org/pypi/tiny-userpic) [![PyPI](https://img.shields.io/pypi/dm/tiny-userpic.svg)](https://pypi.python.org/pypi/tiny-userpic) -## Features - -- Generate unique avatars from text input (email, username, etc.) -- Create both PIL Image and SVG outputs -- Customizable size, colors, and padding -- Deterministic output (same input always produces the same avatar) +Turns any text — an email, a username, a UUID — into a symmetric identicon-style +avatar, rendered as a PIL image or an SVG string. The same input always produces +the same picture. The only dependency is Pillow. ## Installation @@ -19,102 +16,92 @@ Oversimplified Github-like userpic (avatar) generator. pip install tiny-userpic ``` -## Usage - -The library provides several ways to generate avatars: +Requires Python 3.10 or newer. The package installs a single top-level module +named `userpic`. -### 1. Random Generation (Non-deterministic) -Generate a unique random avatar each time. +## Quickstart ```python -from tiny_userpic import make_userpic_image +from userpic import make_userpic_image_from_string -# Generate random avatar -random_image = make_userpic_image( - size=(7, 5), - image_size=(300, 300), - background="white", - foreground="black" -) -random_image.save("random_avatar.png") +make_userpic_image_from_string(text="user@example.com").save("avatar.png") ``` -### 2. With Custom Seed (Deterministic) -Generate an avatar with a specific seed for reproducible results. +avatar.png + +## API + +Four functions share the same parameters and differ only in the output format +and in where the pattern comes from: + +| | returns | pattern comes from | +|----------------------------------|-------------|--------------------------| +| `make_userpic_image` | `PIL.Image` | `seed` or system entropy | +| `make_userpic_svg` | `str` (SVG) | `seed` or system entropy | +| `make_userpic_image_from_string` | `PIL.Image` | `text` | +| `make_userpic_svg_from_string` | `str` (SVG) | `text` | + +### Images ```python -from tiny_userpic import make_userpic_image +from userpic import make_userpic_image -# Generate avatar with specific seed -seeded_image = make_userpic_image( +# a new random avatar on every call +make_userpic_image(size=(7, 5)).save("random.png") + +# the same integer seed always gives the same avatar +make_userpic_image(size=(7, 5), seed=42).save("seeded.png") + +# transparency: RGBA mode plus a fully transparent background +make_userpic_image( size=(7, 5), - image_size=(300, 300), - background="white", - foreground="black", - seed=42 # Any integer value will work as seed -) -seeded_image.save("seeded_avatar.png") + mode="RGBA", + background=(255, 255, 255, 0), + foreground=(0, 0, 128, 255), +).save("transparent.png") ``` -### 3. From Text Input (Deterministic) -Generate an avatar from any text input (email, username, etc.). The same input will always produce the same avatar. +### SVG ```python -from tiny_userpic import make_userpic_image_from_string, make_userpic_svg_from_string - -# Generate avatar from email -email = "user@example.com" - -# As PNG image -image = make_userpic_image_from_string( - text=email, # Input text to generate avatar from - size=(7, 5), # Pattern size (width, height) - image_size=(300, 300), # Output image size in pixels - background="white", # Background color (can be color name, hex or RGB tuple) - foreground="black" # Foreground color (can be color name, hex or RGB tuple) -) -image.save("avatar.png") - -# As SVG -svg = make_userpic_svg_from_string( - text=email, - size=(7, 5), - image_size=(300, 300), - background="white", - foreground="black" -) -with open("avatar.svg", "w") as f: - f.write(svg) +from userpic import make_userpic_svg, make_userpic_svg_from_string + +svg = make_userpic_svg_from_string(text="user@example.com", size=(7, 5)) +with open("avatar.svg", "w") as file: + file.write(svg) + +# seeded, without the background rectangle +svg = make_userpic_svg(size=(7, 5), background=None, seed=42) ``` -### Common Parameters -All generation methods share these parameters: -- `size`: Tuple of (width, height) for the pattern size -- `image_size`: Tuple of (width, height) for the output image size in pixels -- `background`: Background color (can be color name, hex or RGB tuple) -- `foreground`: Foreground color (can be color name, hex or RGB tuple) -- `padding`: Optional padding around the pattern (default: (20, 20)) -- `mode`: Image mode for PNG output (default: 'RGB', can be 'RGBA' for transparency) +### Parameters -## Examples +| name | default | description | +|--------------|--------------|-------------------------------------------------------------------------------------------| +| `text` | — | input string for the `*_from_string` functions | +| `size` | `(5, 5)` | pattern size in cells as (width, height); the width must be at least 2 | +| `image_size` | `(300, 300)` | output size in pixels | +| `padding` | `(20, 20)` | blank border around the pattern, in pixels | +| `background` | `"white"` | color name, hex string or RGB/RGBA tuple; `None` omits the background rectangle in SVG | +| `foreground` | `"black"` | same formats as `background` | +| `mode` | `"RGB"` | PIL image mode, image functions only; use `"RGBA"` for transparency | +| `seed` | `None` | any integer for reproducible output; omit for a random avatar | -### Basic (from string) -![Basic example](examples/basic.png) +The pattern has to fit into the image: `image_size` minus twice the `padding` +must leave at least one pixel per cell, otherwise a `ValueError` is raised. -### Colored -![Colored example](examples/colored.png) +## Examples -### Transparent -![Transparent example](examples/transparent.png) +| Basic | Colored | Transparent | +|--------------------------------|------------------------------------|--------------------------------------------| +| ![basic](examples/basic.png) | ![colored](examples/colored.png) | ![transparent](examples/transparent.png) | -### Small -![Small example](examples/small.png) +| Small | Large | Seeded | +|--------------------------------|------------------------------------|--------------------------------------------| +| ![small](examples/small.png) | ![large](examples/large.png) | ![seeded](examples/seeded.png) | -### Large -![Large example](examples/large.png) +The images are generated by `just examples`. -### Random (non-deterministic) -![Random example](examples/random.png) +## License -### Seeded (deterministic) -![Seeded example](examples/seeded.png) +[MIT](LICENSE) diff --git a/examples/basic.png b/examples/basic.png index 104ae48..2bb5a07 100644 Binary files a/examples/basic.png and b/examples/basic.png differ diff --git a/examples/colored.png b/examples/colored.png index f7ab0ee..da45c72 100644 Binary files a/examples/colored.png and b/examples/colored.png differ diff --git a/examples/large.png b/examples/large.png index adcea51..c883d62 100644 Binary files a/examples/large.png and b/examples/large.png differ diff --git a/examples/quickstart.png b/examples/quickstart.png new file mode 100644 index 0000000..87ca4a7 Binary files /dev/null and b/examples/quickstart.png differ diff --git a/examples/random.png b/examples/random.png deleted file mode 100644 index 79e1bb7..0000000 Binary files a/examples/random.png and /dev/null differ diff --git a/examples/seeded.png b/examples/seeded.png index 0aa2a52..7fa0d7d 100644 Binary files a/examples/seeded.png and b/examples/seeded.png differ diff --git a/examples/small.png b/examples/small.png index 3961db3..655fc93 100644 Binary files a/examples/small.png and b/examples/small.png differ diff --git a/examples/transparent.png b/examples/transparent.png index f7dda33..a7ac8c4 100644 Binary files a/examples/transparent.png and b/examples/transparent.png differ diff --git a/justfile b/justfile index d412c4d..94e4ca8 100644 --- a/justfile +++ b/justfile @@ -5,24 +5,41 @@ upgrade: uv lock --upgrade lint: - uv run ruff check {{ SOURCE_PATH }} - uv run python -m mypy --pretty {{ SOURCE_PATH }} + uv run ruff check {{ SOURCE_PATH }} {{ TEST_PATH }} + uv run python -m mypy --pretty {{ SOURCE_PATH }} {{ TEST_PATH }} fix: uv run ruff format {{ SOURCE_PATH }} {{ TEST_PATH }} - uv run ruff check --fix --unsafe-fixes {{ SOURCE_PATH }} + uv run ruff check --fix --unsafe-fixes {{ SOURCE_PATH }} {{ TEST_PATH }} tests: - uv run pytest test_userpic.py + uv run pytest +# Bump the version and the changelog from the conventional commits since the last tag +bump: + #!/usr/bin/env bash + set -euo pipefail + previous="$(uv run cz version --project)" + uv run cz bump --changelog --files-only --yes + version="$(uv run cz version --project)" + git add CHANGELOG.md pyproject.toml userpic.py + git commit -m "bump: version ${previous} → ${version}" -# Generate images with different parameters +# Generate the images used in the readme examples: #!/usr/bin/env bash - .venv/bin/python -c ' - from userpic import make_userpic_image - # Basic black and white image - make_userpic_image( + set -euo pipefail + uv run python -c ' + from userpic import make_userpic_image, make_userpic_image_from_string + + # The quickstart snippet from the readme, defaults only + make_userpic_image_from_string( + text="user@example.com" + ).save("examples/quickstart.png") + + # Basic black and white image, generated from a string + make_userpic_image_from_string( + text="user@example.com", size=(7, 5), mode="RGB", image_size=(300, 300), @@ -38,7 +55,8 @@ examples: image_size=(300, 300), padding=(20, 20), background="#f0f0f0", - foreground="#2ecc71" + foreground="#2ecc71", + seed=1 ).save("examples/colored.png") # Image with transparent background @@ -48,7 +66,8 @@ examples: image_size=(300, 300), padding=(20, 20), background=(255, 255, 255, 0), - foreground=(0, 0, 128, 255) + foreground=(0, 0, 128, 255), + seed=2 ).save("examples/transparent.png") # Image with large pattern size @@ -58,7 +77,8 @@ examples: image_size=(300, 300), padding=(20, 20), background="white", - foreground="#e74c3c" + foreground="#e74c3c", + seed=3 ).save("examples/large.png") # Image with small pattern size @@ -68,19 +88,10 @@ examples: image_size=(300, 300), padding=(20, 20), background="white", - foreground="#f1c40f" + foreground="#f1c40f", + seed=4 ).save("examples/small.png") - # Random image - make_userpic_image( - size=(7, 5), - mode="RGB", - image_size=(300, 300), - padding=(20, 20), - background="white", - foreground="#9b59b6" - ).save("examples/random.png") - # Image with fixed seed make_userpic_image( size=(7, 5), diff --git a/pyproject.toml b/pyproject.toml index 543e7c7..5fed1b8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "tiny-userpic" -version = "0.0.0" +version = "1.0.0" description = "Oversimplified Github-like userpic (avatar) generator" readme = "README.md" requires-python = ">=3.10" @@ -12,13 +12,17 @@ authors = [ { name = "Aleksandr Shpak", email = "shpaker@gmail.com" }, ] classifiers = [ - "Topic :: Software Development :: Libraries :: Python Modules", - "Development Status :: 4 - Beta", + "Development Status :: 5 - Production/Stable", + "Intended Audience :: Developers", + "Operating System :: OS Independent", "Programming Language :: Python :: 3 :: Only", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", + "Topic :: Multimedia :: Graphics", + "Topic :: Software Development :: Libraries :: Python Modules", ] keywords = ["avatar", "userpic", "avatar-generator"] @@ -26,6 +30,16 @@ keywords = ["avatar", "userpic", "avatar-generator"] Homepage = "https://github.com/shpaker/tiny-userpic/" Repository = "https://github.com/shpaker/tiny-userpic/" +[tool.commitizen] +name = "cz_conventional_commits" +version_provider = "pep621" +version_files = [ + "userpic.py:^__version__", +] +tag_format = "$version" +annotated_tag = true +update_changelog_on_bump = true + [tool.hatch.build] include = ["userpic.py"] @@ -35,6 +49,7 @@ build-backend = "hatchling.build" [dependency-groups] dev = [ + "commitizen>=4.18.0", "mypy>=1.13.0", "pytest>=8.3.5", "ruff>=0.8.2", @@ -110,6 +125,19 @@ lint.select = [ "RUF", # Ruff-specific rules ] +lint.per-file-ignores."test_userpic.py" = [ + "S101", # Use of `assert` detected + "S318", # the parsed XML is produced by the library itself, not by an untrusted source +] + [tool.ruff.format] quote-style = "single" docstring-code-format = true + +[tool.mypy] +python_version = "3.10" +strict = true + +[tool.pytest.ini_options] +testpaths = ["test_userpic.py"] +addopts = "-q" diff --git a/test_userpic.py b/test_userpic.py index a629e64..6e76dc8 100644 --- a/test_userpic.py +++ b/test_userpic.py @@ -1,9 +1,16 @@ +import hashlib +import re +from collections.abc import Callable from typing import Any +from xml.dom.minidom import parseString import pytest +from PIL import ImageOps from PIL.Image import Image from userpic import ( + _cell_edges, + _iter_bit_lines, make_userpic_image, make_userpic_image_from_string, make_userpic_svg, @@ -23,6 +30,33 @@ def default_params() -> dict[str, Any]: } +def _svg_cells(svg: str) -> set[tuple[int, int]]: + body = svg.split('', body): + cells.update((int(x) + offset, int(y)) for offset in range(int(span))) + return cells + + +def _image_cells( + image: Image, + size: tuple[int, int], + image_size: tuple[int, int], + padding: tuple[int, int], +) -> set[tuple[int, int]]: + cell_width = (image_size[0] - 2 * padding[0]) / size[0] + cell_height = (image_size[1] - 2 * padding[1]) / size[1] + return { + (x, y) + for y in range(size[1]) + for x in range(size[0]) + if image.getpixel( + (int(padding[0] + (x + 0.5) * cell_width), int(padding[1] + (y + 0.5) * cell_height)), + ) + == (0, 0, 0) + } + + def test_make_userpic_image(default_params: dict[str, Any]) -> None: image = make_userpic_image(**default_params) assert isinstance(image, Image) @@ -109,3 +143,192 @@ def test_svg_structure(default_params: dict[str, Any]) -> None: def test_empty_string() -> None: image = make_userpic_image_from_string(text='', size=(7, 5), mode='RGB', image_size=(300, 300)) assert isinstance(image, Image) + + +@pytest.mark.parametrize( + 'size', + [ + (7, 5), + (8, 6), + (5, 5), + (2, 1), + ], +) +@pytest.mark.parametrize('image_size', [(300, 300), (280, 300), (301, 299), (256, 256)]) +def test_image_is_mirror_symmetric(size: tuple[int, int], image_size: tuple[int, int]) -> None: + for seed in range(10): + image = make_userpic_image(size=size, image_size=image_size, padding=(0, 0), seed=seed) + assert image.tobytes() == ImageOps.mirror(image).tobytes() + + +@pytest.mark.parametrize('size', [(7, 5), (8, 6), (12, 12)]) +def test_svg_matches_image(size: tuple[int, int]) -> None: + image_size, padding = (300, 300), (20, 20) + for seed in range(10): + svg = make_userpic_svg(size=size, image_size=image_size, padding=padding, seed=seed) + image = make_userpic_image(size=size, image_size=image_size, padding=padding, seed=seed) + assert _svg_cells(svg) == _image_cells(image, size, image_size, padding) + + +@pytest.mark.parametrize( + 'color', + [ + 'red"/>