Skip to content

Commit 01ad24e

Browse files
author
Lorena Mesa
committed
Add file cache for bypassing re-rendering images already created
1 parent 7ddbcbd commit 01ad24e

File tree

4 files changed

+90
-25
lines changed

4 files changed

+90
-25
lines changed

README.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,14 @@
11
# Python Identicon Generator
22

3+
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.
4+
5+
For help running the script use `python3 main.py -h` to retrieve a usage prompt and overview of parameters including optional parameters.
6+
7+
Usage:
8+
1. Only providing the input `-t` text: `python3 main.py -t helloworld`.
9+
2. Providing the input `-t` text and a specified output `-o` name for the ouput `*.png` identicon: `python3 main.py -t helloworld -o helloworld`.
10+
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`.
11+
312
## Problem Prompt
413

514
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
1928
1. The identicon's should be symmetrical meaning the left horizontal half is equal to the right horizontal half.
2029
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
2130
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
31+
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.
2232

2333
## TODO:
2434
- [X] Implement core logic to generate a Python PIL image

requirements.txt

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,3 @@
11
ruff
2-
Pillow
2+
Pillow
3+
sweepai

src/main.py

Lines changed: 31 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -2,16 +2,20 @@
22

33
import argparse
44
import hashlib
5+
import io
56
from PIL import Image, ImageDraw
7+
import time
8+
9+
from sweepai.logn.cache import file_cache
610

711
__author__ = "Lorena Mesa"
812
__email__ = "[email protected]"
913

1014

1115
class Identicon:
1216

13-
def __init__(self, input_str: str) -> None:
14-
self.md5hash_str: str = self._convert_string_to_sha_hash(input_str)
17+
def __init__(self) -> None:
18+
self.md5hash_str: str = None
1519
self.grid_size: int = 5
1620
self.square_size: int = 64
1721
self.identicon_size: tuple = (self.grid_size * self.square_size, self.grid_size * self.square_size)
@@ -58,18 +62,24 @@ def _generate_pixel_fill_color(self, md5hash_str: str) -> tuple:
5862
"""
5963
return tuple(int(md5hash_str[i:i+2], base=16) for i in range(0, 2*3, 2))
6064

61-
def render(self, filename: str=None, dimensions: int=0) -> Image:
65+
@file_cache()
66+
def render(self, input_str: str, filename: str="identicon", dimensions: int=0) -> Image:
6267
"""
6368
Function that generates a grid - a list of lists - indicating which pixels
6469
are to be filled and uses the md5hash_str to generate an image fill color.
6570
Function creates a PIL Image, drawing it, and saving it. By default a 320
6671
pixel by 320 pixel identicon is rendered, if upon executing the code a
6772
dimensions parameter is passed in the image will be resized.
6873
74+
:param input_str: unique identifer input string used to generate identicon
6975
:param filename: filename of PIL png image generated
7076
:return: None
7177
"""
7278

79+
# Can uncomment to confirm the @file_cache is working
80+
# import time; time.sleep(5)
81+
82+
self.md5hash_str = self._convert_string_to_sha_hash(input_str)
7383
fill_color: tuple = self._generate_pixel_fill_color(self.md5hash_str)
7484
grid: list[list] = self._build_grid()
7585

@@ -96,19 +106,25 @@ def render(self, filename: str=None, dimensions: int=0) -> Image:
96106
row * self.square_size + self.square_size
97107
]
98108
draw.rectangle(shape_coords, fill=fill_color)
99-
100-
if not filename:
101-
filename: str = 'example'
102109

103110
if dimensions:
104111
# Possible resampling filters here: https://pillow.readthedocs.io/en/stable/reference/Image.html#PIL.Image.Image.resize
105112
# BICUBIC and LANCZOS take longer to process than NEAREST, but the quality of the former is better.
106113
width_percent: float = (dimensions / float(image.size[0]))
107114
height: int = int((float(image.size[1]) * float(width_percent)))
108115
image = image.resize((dimensions, height), Image.Resampling.LANCZOS)
109-
116+
110117
image.save(f'{filename}.png')
111118

119+
# Return a unique string with the input str value and the image bytes array
120+
# to allow a cache hit
121+
122+
byteIO = io.BytesIO()
123+
image.save(byteIO, format='PNG')
124+
im_bytes = byteIO.getvalue()
125+
# import pdb; pdb.set_trace()
126+
return f'{input_str}_{im_bytes}'
127+
112128

113129
if __name__ == '__main__':
114130
parser = argparse.ArgumentParser(
@@ -140,7 +156,8 @@ def dimensions_gt_zero(input_dimensions: str):
140156
"-o",
141157
"--output",
142158
type=len_gt_zero,
143-
help="Name for output square identicon image generated.",
159+
help="Name for output square identicon PNG image generated.",
160+
default='identicon'
144161
)
145162
parser.add_argument(
146163
"-d",
@@ -151,5 +168,9 @@ def dimensions_gt_zero(input_dimensions: str):
151168

152169
args = parser.parse_args()
153170

154-
identicon = Identicon(input_str=args.string)
155-
identicon.render(filename=args.output, dimensions=args.dimensions)
171+
# Add timer to confirm performance of code
172+
t0 = time.time()
173+
identicon = Identicon()
174+
result = identicon.render(input_str=args.string, filename=args.output, dimensions=args.dimensions)
175+
t1 = time.time()
176+
print(f"{t1-t0} seconds to render {args.output}.png is now available to download!")

test/sample_cases_test.py

Lines changed: 47 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,10 @@
22

33
from os import remove
44
from pathlib import Path
5+
import shutil
56
from PIL import Image, PngImagePlugin
67
import subprocess
8+
import time
79
import unittest
810

911
from src.main import Identicon
@@ -28,19 +30,22 @@ def test_ui_fails_to_create_identicon_with_dimensions_lt_1(self):
2830

2931
class TestHappyPath(unittest.TestCase):
3032
def test_successfully_creates_identicon(self):
31-
identicon = Identicon("931D387731bBbC988B31220")
32-
identicon.render(filename="output")
33+
identicon = Identicon()
34+
identicon.render(input_str="931D387731bBbC988B31220", filename="output")
3335
generated_image = Image.open(f"{PROJECT_ROOT}/output.png", mode="r")
3436
self.assertIsInstance(generated_image, PngImagePlugin.PngImageFile)
37+
38+
# Cleanup
3539
remove(f"{PROJECT_ROOT}/output.png")
40+
shutil.rmtree("/tmp/file_cache")
3641

3742
def test_successfully_creates_same_identicon_for_same_input_strings(self):
3843
# Make 1st identicon
39-
identicon_john_1 = Identicon("john")
40-
identicon_john_1.render(filename="john1")
44+
identicon_john_1 = Identicon()
45+
identicon_john_1.render(input_str="john", filename="john1")
4146
# Make 2nd identicon
42-
identicon_john_2 = Identicon("john")
43-
identicon_john_2.render(filename="john2")
47+
identicon_john_2 = Identicon()
48+
identicon_john_2.render(input_str="john", filename="john2")
4449

4550
# Assertions
4651
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):
5459
# Cleanup
5560
remove(f"{PROJECT_ROOT}/john1.png")
5661
remove(f"{PROJECT_ROOT}/john2.png")
62+
shutil.rmtree("/tmp/file_cache")
5763

5864
def test_does_not_create_same_identicon_for_different_input_strings(self):
5965
# Make 1st identicon
60-
identicon_john = Identicon("john")
61-
identicon_john.render(filename="john")
66+
identicon_john = Identicon()
67+
identicon_john.render(input_str="john", filename="john")
6268
# Make 2nd identicon
63-
identicon_john_2 = Identicon("jane")
64-
identicon_john_2.render(filename="jane")
69+
identicon_john_2 = Identicon()
70+
identicon_john_2.render(input_str="jane", filename="jane")
6571

6672
# Assertions
6773
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):
7581
# Cleanup
7682
remove(f"{PROJECT_ROOT}/john.png")
7783
remove(f"{PROJECT_ROOT}/jane.png")
84+
shutil.rmtree("/tmp/file_cache")
7885

7986
def test_successfully_resizes_identicon_gt_default_when_dimensions_provided(self):
80-
identicon_john = Identicon("john")
81-
identicon_john.render(filename="john", dimensions=450)
87+
identicon_john = Identicon()
88+
identicon_john.render(input_str="john", filename="john", dimensions=450)
8289

8390
# Assertions
8491
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
8794

8895
# Cleanup
8996
remove(f"{PROJECT_ROOT}/john.png")
97+
shutil.rmtree("/tmp/file_cache")
9098

9199
def test_successfully_resizes_identicon_lt_default_when_dimensions_provided(self):
92-
identicon_john = Identicon("john")
93-
identicon_john.render(filename="john", dimensions=150)
100+
identicon_john = Identicon()
101+
identicon_john.render(input_str="john", filename="john", dimensions=150)
94102

95103
# Assertions
104+
# import pdb; pdb.set_trace()
96105
generated_john = Image.open(f"{PROJECT_ROOT}/john.png", mode="r")
97106
self.assertIsInstance(generated_john, PngImagePlugin.PngImageFile)
98107
self.assertEqual(generated_john.size, (150, 150))
99108

100109
# Cleanup
101110
remove(f"{PROJECT_ROOT}/john.png")
111+
shutil.rmtree("/tmp/file_cache")
112+
113+
class TestFileCache(unittest.TestCase):
114+
def test_successfully_skips_cache_if_identicon_already_made(self):
115+
# Call first time to instantiante in the file cache
116+
t0 = time.time()
117+
identicon_johnny = Identicon()
118+
identicon_johnny.render(input_str="johnny", filename="johnny", dimensions=150)
119+
t1 = time.time()
120+
johnny_1_time = t1 - t0
121+
122+
# Call second time to retrieve from the file cache
123+
t0 = time.time()
124+
identicon_johnny_2 = Identicon()
125+
identicon_johnny_2.render(input_str="johnny", filename="johnny", dimensions=150)
126+
t1 = time.time()
127+
johnny_2_time = t1 - t0
128+
129+
# Assertions
130+
self.assertLess(johnny_2_time, johnny_1_time)
131+
132+
# Cleanup
133+
remove(f"{PROJECT_ROOT}/johnny.png")
134+
shutil.rmtree("/tmp/file_cache")
102135

103136
if __name__ == "__main__":
104137
unittest.main()

0 commit comments

Comments
 (0)