diff --git a/README.md b/README.md index 4aea21a..9677775 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,14 @@ # Python Identicon Generator +A Python 3.10 CLI script that generates identicons. Default size for identicons created is 320X320 pixels, as this is the recommended size by many social media platforms like Instagram. + +For help running the script use `python3 main.py -h` to retrieve a usage prompt and overview of parameters including optional parameters. + +Usage: +1. Only providing the input `-t` text: `python3 main.py -t helloworld`. +2. Providing the input `-t` text and a specified output `-o` name for the ouput `*.png` identicon: `python3 main.py -t helloworld -o helloworld`. +3. Providing the input `-t` text and a specified output `-o` name for the ouput `*.png` identicon and overriding default dimensions of 320X320 pixels e.g. 150X150 pixels: `python3 main.py -t helloworld -o helloworld -d 150`. + ## Problem Prompt Users often work collaboratively in digital environments where a profile picture is not available. Some platforms have attempted to solve this problem with the creation of randomly generated, unique icons for each user ([github](https://github.blog/2013-08-14-identicons/), [slack](https://slack.zendesk.com/hc/article_attachments/360048182573/Screen_Shot_2019-10-01_at_5.08.29_PM.png), [ethereum wallets](https://github.com/ethereum/blockies)) sometimes called *Identicons*. Given an arbitrary string, create an image that can serve as a unique identifier for a user of a B2B productivity app like slack, notion, etc. @@ -19,6 +28,7 @@ Users often work collaboratively in digital environments where a profile picture 1. The identicon's should be symmetrical meaning the left horizontal half is equal to the right horizontal half. 2. The identicon is 5X5 pixels, following the standard specified for [GitHub identicons](https://github.blog/2013-08-14-identicons/), so we'll generate square identicons only with a default of 320X320 pixels which other social media platforms like Instagram define as an ideal size 3. Identicon's should use proper resizing sampling technique to ensure quality is maintained, see [Pillow](https://pillow.readthedocs.io/en/stable/reference/Image.html#PIL.Image.Image.resize) documentation for options +4. Avoid replicating creating identicon by confirming if an image for said user has been generated, if so retrieve from a persistence layer. A NoSQL solution like mongodb would be useful, but we'll use a modified version of `@lru_cache` from the `sweepai` project - `@file_cache` which persists pickled objects between runs. ## TODO: - [X] Implement core logic to generate a Python PIL image diff --git a/requirements.txt b/requirements.txt index 52da738..8920eb3 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,2 +1,3 @@ ruff -Pillow \ No newline at end of file +Pillow +sweepai \ No newline at end of file diff --git a/src/main.py b/src/main.py index 8bae1a0..b7aee6a 100644 --- a/src/main.py +++ b/src/main.py @@ -2,7 +2,11 @@ import argparse import hashlib +import io from PIL import Image, ImageDraw +import time + +from sweepai.logn.cache import file_cache __author__ = "Lorena Mesa" __email__ = "me@lorenamesa.com" @@ -10,8 +14,8 @@ class Identicon: - def __init__(self, input_str: str) -> None: - self.md5hash_str: str = self._convert_string_to_sha_hash(input_str) + def __init__(self) -> None: + self.md5hash_str: str = None self.grid_size: int = 5 self.square_size: int = 64 self.identicon_size: tuple = (self.grid_size * self.square_size, self.grid_size * self.square_size) @@ -58,7 +62,8 @@ def _generate_pixel_fill_color(self, md5hash_str: str) -> tuple: """ return tuple(int(md5hash_str[i:i+2], base=16) for i in range(0, 2*3, 2)) - def render(self, filename: str=None, dimensions: int=0) -> Image: + @file_cache() + def render(self, input_str: str, filename: str="identicon", dimensions: int=0) -> Image: """ Function that generates a grid - a list of lists - indicating which pixels are to be filled and uses the md5hash_str to generate an image fill color. @@ -66,10 +71,15 @@ def render(self, filename: str=None, dimensions: int=0) -> Image: pixel by 320 pixel identicon is rendered, if upon executing the code a dimensions parameter is passed in the image will be resized. + :param input_str: unique identifer input string used to generate identicon :param filename: filename of PIL png image generated :return: None """ + # Can uncomment to confirm the @file_cache is working + # import time; time.sleep(5) + + self.md5hash_str = self._convert_string_to_sha_hash(input_str) fill_color: tuple = self._generate_pixel_fill_color(self.md5hash_str) grid: list[list] = self._build_grid() @@ -96,9 +106,6 @@ def render(self, filename: str=None, dimensions: int=0) -> Image: row * self.square_size + self.square_size ] draw.rectangle(shape_coords, fill=fill_color) - - if not filename: - filename: str = 'example' if dimensions: # Possible resampling filters here: https://pillow.readthedocs.io/en/stable/reference/Image.html#PIL.Image.Image.resize @@ -106,9 +113,18 @@ def render(self, filename: str=None, dimensions: int=0) -> Image: width_percent: float = (dimensions / float(image.size[0])) height: int = int((float(image.size[1]) * float(width_percent))) image = image.resize((dimensions, height), Image.Resampling.LANCZOS) - + image.save(f'{filename}.png') + # Return a unique string with the input str value and the image bytes array + # to allow a cache hit + + byteIO = io.BytesIO() + image.save(byteIO, format='PNG') + im_bytes = byteIO.getvalue() + # import pdb; pdb.set_trace() + return f'{input_str}_{im_bytes}' + if __name__ == '__main__': parser = argparse.ArgumentParser( @@ -140,7 +156,8 @@ def dimensions_gt_zero(input_dimensions: str): "-o", "--output", type=len_gt_zero, - help="Name for output square identicon image generated.", + help="Name for output square identicon PNG image generated.", + default='identicon' ) parser.add_argument( "-d", @@ -151,5 +168,9 @@ def dimensions_gt_zero(input_dimensions: str): args = parser.parse_args() - identicon = Identicon(input_str=args.string) - identicon.render(filename=args.output, dimensions=args.dimensions) + # Add timer to confirm performance of code + t0 = time.time() + identicon = Identicon() + result = identicon.render(input_str=args.string, filename=args.output, dimensions=args.dimensions) + t1 = time.time() + print(f"{t1-t0} seconds to render {args.output}.png is now available to download!") diff --git a/test/sample_cases_test.py b/test/sample_cases_test.py index 2e16707..de42773 100644 --- a/test/sample_cases_test.py +++ b/test/sample_cases_test.py @@ -2,8 +2,10 @@ from os import remove from pathlib import Path +import shutil from PIL import Image, PngImagePlugin import subprocess +import time import unittest from src.main import Identicon @@ -28,19 +30,22 @@ def test_ui_fails_to_create_identicon_with_dimensions_lt_1(self): class TestHappyPath(unittest.TestCase): def test_successfully_creates_identicon(self): - identicon = Identicon("931D387731bBbC988B31220") - identicon.render(filename="output") + identicon = Identicon() + identicon.render(input_str="931D387731bBbC988B31220", filename="output") generated_image = Image.open(f"{PROJECT_ROOT}/output.png", mode="r") self.assertIsInstance(generated_image, PngImagePlugin.PngImageFile) + + # Cleanup remove(f"{PROJECT_ROOT}/output.png") + shutil.rmtree("/tmp/file_cache") def test_successfully_creates_same_identicon_for_same_input_strings(self): # Make 1st identicon - identicon_john_1 = Identicon("john") - identicon_john_1.render(filename="john1") + identicon_john_1 = Identicon() + identicon_john_1.render(input_str="john", filename="john1") # Make 2nd identicon - identicon_john_2 = Identicon("john") - identicon_john_2.render(filename="john2") + identicon_john_2 = Identicon() + identicon_john_2.render(input_str="john", filename="john2") # Assertions generated_john_1 = Image.open(f"{PROJECT_ROOT}/john1.png", mode="r") @@ -54,14 +59,15 @@ def test_successfully_creates_same_identicon_for_same_input_strings(self): # Cleanup remove(f"{PROJECT_ROOT}/john1.png") remove(f"{PROJECT_ROOT}/john2.png") + shutil.rmtree("/tmp/file_cache") def test_does_not_create_same_identicon_for_different_input_strings(self): # Make 1st identicon - identicon_john = Identicon("john") - identicon_john.render(filename="john") + identicon_john = Identicon() + identicon_john.render(input_str="john", filename="john") # Make 2nd identicon - identicon_john_2 = Identicon("jane") - identicon_john_2.render(filename="jane") + identicon_john_2 = Identicon() + identicon_john_2.render(input_str="jane", filename="jane") # Assertions generated_john = Image.open(f"{PROJECT_ROOT}/john.png", mode="r") @@ -75,10 +81,11 @@ def test_does_not_create_same_identicon_for_different_input_strings(self): # Cleanup remove(f"{PROJECT_ROOT}/john.png") remove(f"{PROJECT_ROOT}/jane.png") + shutil.rmtree("/tmp/file_cache") def test_successfully_resizes_identicon_gt_default_when_dimensions_provided(self): - identicon_john = Identicon("john") - identicon_john.render(filename="john", dimensions=450) + identicon_john = Identicon() + identicon_john.render(input_str="john", filename="john", dimensions=450) # Assertions generated_john = Image.open(f"{PROJECT_ROOT}/john.png", mode="r") @@ -87,18 +94,44 @@ def test_successfully_resizes_identicon_gt_default_when_dimensions_provided(self # Cleanup remove(f"{PROJECT_ROOT}/john.png") + shutil.rmtree("/tmp/file_cache") def test_successfully_resizes_identicon_lt_default_when_dimensions_provided(self): - identicon_john = Identicon("john") - identicon_john.render(filename="john", dimensions=150) + identicon_john = Identicon() + identicon_john.render(input_str="john", filename="john", dimensions=150) # Assertions + # import pdb; pdb.set_trace() generated_john = Image.open(f"{PROJECT_ROOT}/john.png", mode="r") self.assertIsInstance(generated_john, PngImagePlugin.PngImageFile) self.assertEqual(generated_john.size, (150, 150)) # Cleanup remove(f"{PROJECT_ROOT}/john.png") + shutil.rmtree("/tmp/file_cache") + +class TestFileCache(unittest.TestCase): + def test_successfully_skips_cache_if_identicon_already_made(self): + # Call first time to instantiante in the file cache + t0 = time.time() + identicon_johnny = Identicon() + identicon_johnny.render(input_str="johnny", filename="johnny", dimensions=150) + t1 = time.time() + johnny_1_time = t1 - t0 + + # Call second time to retrieve from the file cache + t0 = time.time() + identicon_johnny_2 = Identicon() + identicon_johnny_2.render(input_str="johnny", filename="johnny", dimensions=150) + t1 = time.time() + johnny_2_time = t1 - t0 + + # Assertions + self.assertLess(johnny_2_time, johnny_1_time) + + # Cleanup + remove(f"{PROJECT_ROOT}/johnny.png") + shutil.rmtree("/tmp/file_cache") if __name__ == "__main__": unittest.main() \ No newline at end of file