Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
name: CI

on:
push:
branches: [master]
pull_request:
branches: [master]

jobs:
ci:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- uses: astral-sh/setup-uv@v5

- name: Install dependencies
run: uv sync --all-extras

- name: Lint
run: uv run ruff check src/ tests/

- name: Format check
run: uv run ruff format --check src/ tests/

- name: Type check
run: uv run ty check src/

- name: Test
run: uv run pytest -vv
23 changes: 23 additions & 0 deletions .github/workflows/publish.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
name: Publish to PyPI

on:
release:
types: [published]

permissions:
id-token: write # Required for PyPI Trusted Publishing

jobs:
publish:
runs-on: ubuntu-latest
environment: pypi
steps:
- uses: actions/checkout@v4

- uses: astral-sh/setup-uv@v5

- name: Build
run: uv build

- name: Publish
uses: pypa/gh-action-pypi-publish@release/v1
1 change: 1 addition & 0 deletions .python-version
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
3.11
27 changes: 0 additions & 27 deletions Makefile

This file was deleted.

34 changes: 34 additions & 0 deletions justfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
# Default: list recipes
default:
@just --list

# Install / sync dependencies with uv
sync:
uv sync --all-extras

# Lint with ruff
lint:
uv run ruff check src/ tests/

# Format with ruff
format:
uv run ruff format src/ tests/

# Type-check with ty
typecheck:
uv run ty check src/

# Run tests with pytest
test:
uv run pytest -vv

# Run all checks (lint + typecheck + test)
check: lint typecheck test

# Build distribution
build:
uv build

# Publish to PyPI
publish:
uv publish
39 changes: 24 additions & 15 deletions pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[build-system]
requires = ["setuptools"]
build-backend = "setuptools.build_meta"
requires = ["hatchling"]
build-backend = "hatchling.build"

[project]
name = "linkedin-cover-image"
Expand All @@ -11,9 +11,11 @@ authors = [
description = "Make geometric banner images for LinkedIn."
readme = "README.md"
license = {file = "LICENSE"}
requires-python = ">=3.11"
dependencies = [
"CairoSVG",
"click",
"typer",
"rich",
"matplotlib",
"numpy",
"scikit-learn",
Expand All @@ -24,24 +26,31 @@ classifiers = [
]

[project.scripts]
cover-image = "cover_image.main:main"
cover-image = "cover_image.main:app"

[project.urls]
"Homepage" = "https://github.com/JEHoctor/LinkedIn-Cover-Image"

[project.optional-dependencies]
dev = ["black", "build", "isort", "jupyter", "pip-tools", "pytest", "ruff", "seaborn", "twine"]
dev = ["pytest", "ruff", "ty", "jupyter", "seaborn", "twine"]

[tool.black]
line-length = 120

[tool.isort]
py_version=311
sections="FUTURE,STDLIB,THIRDPARTY,FIRSTPARTY,LOCALFOLDER"
import_heading_stdlib="standard libraries"
import_heading_thirdparty="third party libraries"
import_heading_firstparty="cover image libraries"
[tool.hatch.build.targets.wheel]
packages = ["src/cover_image"]

[tool.ruff]
line-length = 120

[tool.ruff.lint]
select = ["E", "F", "I", "UP"]
per-file-ignores = {"__init__.py" = ["F401"]}
line-length = 120

[tool.ruff.format]
quote-style = "double"
line-ending = "auto"

[tool.pytest.ini_options]
testpaths = ["tests"]
addopts = "-vv"

[tool.ty]
python-version = "3.11"
Empty file added src/cover_image/__init__.py
Empty file.
57 changes: 36 additions & 21 deletions src/cover_image/main.py
Original file line number Diff line number Diff line change
@@ -1,54 +1,69 @@
# standard libraries
import xml.etree.ElementTree as ET
from collections.abc import Callable
from enum import StrEnum
from pathlib import Path
from typing import Callable
from typing import Annotated

# third party libraries
import click
import numpy as np
import rich
import typer
from cairosvg import svg2png
from matplotlib import colormaps

# cover image libraries
from cover_image.pattern_generators import (gaussian_process_pattern,
random_pattern)
from cover_image.pattern_generators import gaussian_process_pattern, random_pattern
from cover_image.shape import Hexagon, Shape, Triangle

_name_to_shape = {s.__name__: s for s in (Hexagon, Triangle)}
app = typer.Typer()

_name_to_shape: dict[str, type[Shape]] = {s.__name__: s for s in (Hexagon, Triangle)}
_name_to_pattern = {"gaussian_process": gaussian_process_pattern, "random": random_pattern}

# locations for output files
here = Path()
out_svg = here / "cover_image.svg"
out_png = here / "cover_image.png"


# basic static result properties
colormap = colormaps.get_cmap("viridis")


def get_color(x):
class ShapeChoice(StrEnum):
Hexagon = "Hexagon"
Triangle = "Triangle"


class PatternChoice(StrEnum):
gaussian_process = "gaussian_process"
random = "random"


def get_color(x: float) -> str:
if not (0 <= x <= 1):
raise ValueError("can only convert values in [0, 1] to colors")
color_bytes = tuple(map(int, colormap(x, bytes=True)[:3]))
return f"rgb{color_bytes}"


@click.command()
@click.option("--shape", type=click.Choice(tuple(_name_to_shape), case_sensitive=False), default="Hexagon")
@click.option("--pattern", type=click.Choice(tuple(_name_to_pattern), case_sensitive=False), default="gaussian_process")
@click.option("--scale", type=float, default=10)
@click.option("--padding-factor", type=float, default=1.1)
@click.option("--width", type=int, default=1128)
@click.option("--height", type=int, default=191)
def main(shape, pattern, scale, padding_factor, width, height):
shape_cls = _name_to_shape[shape]
shape = shape_cls(scale, padding_factor, width, height)
pattern = _name_to_pattern[pattern]
_main(shape, pattern)
@app.command()
def main(
shape: Annotated[ShapeChoice, typer.Option(help="Shape to tile across the banner")] = ShapeChoice.Hexagon,
pattern: Annotated[PatternChoice, typer.Option(help="Color pattern to apply")] = PatternChoice.gaussian_process,
scale: Annotated[float, typer.Option(help="Shape scale in pixels")] = 10.0,
padding_factor: Annotated[float, typer.Option(help="Padding between shapes")] = 1.1,
width: Annotated[int, typer.Option(help="Canvas width in pixels")] = 1128,
height: Annotated[int, typer.Option(help="Canvas height in pixels")] = 191,
) -> None:
shape_cls = _name_to_shape[shape.value]
shape_obj = shape_cls(scale, padding_factor, width, height)
pattern_fn = _name_to_pattern[pattern.value]
_main(shape_obj, pattern_fn)
rich.print(f"[green]✓[/green] Saved {out_svg} and {out_png}")


def _main(shape: Shape, pattern: Callable):
def _main(shape: Shape, pattern: Callable) -> None:
# Initialize a blank canvas of the right size.
svg_root = ET.Element("svg", attrib={"viewBox": f"0 0 {shape.out_width} {shape.out_height}", "version": "1.1"})
svg_image = ET.ElementTree(element=svg_root)
Expand Down Expand Up @@ -79,4 +94,4 @@ def _main(shape: Shape, pattern: Callable):


if __name__ == "__main__":
main()
app()
Empty file added tests/__init__.py
Empty file.
Empty file added tests/cover_image/__init__.py
Empty file.
46 changes: 46 additions & 0 deletions tests/cover_image/test_pattern_generators.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
# third party libraries
import numpy as np
import pytest

# cover image libraries
from cover_image.pattern_generators import gaussian_process_pattern, random_pattern


@pytest.fixture
def sample_points():
rng = np.random.default_rng(42)
return rng.uniform(0, 100, size=(20, 2))


class TestRandomPattern:
def test_returns_correct_length(self, sample_points):
result = random_pattern(sample_points)
assert len(result) == len(sample_points)

def test_values_in_unit_interval(self, sample_points):
result = random_pattern(sample_points)
assert np.all(result >= 0.0)
assert np.all(result <= 1.0)


class TestGaussianProcessPattern:
def test_returns_correct_length(self, sample_points):
rng = np.random.RandomState(0)
result = gaussian_process_pattern(sample_points, random_state=rng)
assert len(result) == len(sample_points)

def test_values_in_unit_interval(self, sample_points):
rng = np.random.RandomState(0)
result = gaussian_process_pattern(sample_points, random_state=rng)
assert np.all(result >= 0.0)
assert np.all(result <= 1.0)

def test_reproducible_with_same_seed(self, sample_points):
result_a = gaussian_process_pattern(sample_points, random_state=np.random.RandomState(7))
result_b = gaussian_process_pattern(sample_points, random_state=np.random.RandomState(7))
np.testing.assert_array_equal(result_a, result_b)

def test_different_seeds_differ(self, sample_points):
result_a = gaussian_process_pattern(sample_points, random_state=np.random.RandomState(1))
result_b = gaussian_process_pattern(sample_points, random_state=np.random.RandomState(2))
assert not np.array_equal(result_a, result_b)
62 changes: 62 additions & 0 deletions tests/cover_image/test_shape.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
# third party libraries
import pytest

# cover image libraries
from cover_image.shape import Hexagon, Triangle


@pytest.fixture
def small_canvas():
return {"scale": 10.0, "padding_factor": 1.1, "out_width": 100, "out_height": 50}


class TestHexagon:
def test_instantiation(self, small_canvas):
h = Hexagon(**small_canvas)
assert h.scale == small_canvas["scale"]
assert h.out_width == small_canvas["out_width"]
assert h.out_height == small_canvas["out_height"]

def test_generates_polygons(self, small_canvas):
shapes = list(Hexagon(**small_canvas)())
assert len(shapes) > 0

def test_each_polygon_has_six_vertices(self, small_canvas):
shapes = list(Hexagon(**small_canvas)())
for polygon in shapes:
assert len(polygon) == 6

def test_vertices_are_2d(self, small_canvas):
shapes = list(Hexagon(**small_canvas)())
for polygon in shapes:
for vertex in polygon:
assert len(vertex) == 2


class TestTriangle:
def test_instantiation(self, small_canvas):
t = Triangle(**small_canvas)
assert t.scale == small_canvas["scale"]
assert t.out_width == small_canvas["out_width"]
assert t.out_height == small_canvas["out_height"]

def test_generates_polygons(self, small_canvas):
shapes = list(Triangle(**small_canvas)())
assert len(shapes) > 0

def test_each_polygon_has_three_vertices(self, small_canvas):
shapes = list(Triangle(**small_canvas)())
for polygon in shapes:
assert len(polygon) == 3

def test_vertices_are_2d(self, small_canvas):
shapes = list(Triangle(**small_canvas)())
for polygon in shapes:
for vertex in polygon:
assert len(vertex) == 2

def test_more_shapes_for_larger_canvas(self, small_canvas):
small = list(Triangle(**small_canvas)())
large_canvas = {**small_canvas, "out_width": 200, "out_height": 100}
large = list(Triangle(**large_canvas)())
assert len(large) > len(small)
Loading
Loading