diff --git a/.github/workflows/github-actions-tests.yml b/.github/workflows/github-actions-tests.yml new file mode 100644 index 0000000..7ce25de --- /dev/null +++ b/.github/workflows/github-actions-tests.yml @@ -0,0 +1,13 @@ +steps: +- uses: actions/checkout@v4 +- name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.10' +- name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -r requirements.txt +- name: Test with unittest + run: | + python3 -m unittest test/sample_cases_test.py \ No newline at end of file diff --git a/src/__init__.py b/src/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/main.py b/src/main.py new file mode 100644 index 0000000..8429b7d --- /dev/null +++ b/src/main.py @@ -0,0 +1,127 @@ +#!/usr/bin/env python3 + +import argparse +import hashlib +from PIL import Image, ImageDraw + +__author__ = "Lorena Mesa" +__email__ = "me@lorenamesa.com" + + +class Identicon: + + def __init__(self, input_str: str) -> None: + self.md5hash_str: str = self._convert_string_to_sha_hash(input_str) + + def _convert_string_to_sha_hash(self, input_str: str) -> str: + """ + Function that takes an input string and returns a md5 hexdigest string. + + :return: md5 hexdigest of an input string + """ + if len(input_str) < 1: + raise ValueError("Input string cannot be empty.") + + return hashlib.md5(input_str.encode('utf-8')).hexdigest() + + def _build_grid(self) -> list[list]: + """ + Function that takes an input md5 hexdigest string and builds + a list of lists using grid size to determine the size of the + grid. Each value within the list of lists contains a row of booleans + that indicates if that given element will be filled with a color. + + :return: a list of lists representing a grid of the pixels to be drawn in a PIL Image + """ + grid_size: int = 5 + grid: list = [] + for row_number in range(grid_size): + row: list = list() + for element_number in range(grid_size): + element: int = row_number * grid_size + element_number + 6 + fill_element: bool = int(self.md5hash_str[element], base=16) % 2 == 0 + row.append(fill_element) + grid.append(row) + return grid + + def _generate_image_fill_color(self, md5hash_str: str) -> tuple: + """ + Function that generates a R,G,B value to use to fill the PIL Image. + + :param md5hash_str: md5 hexdigest of an input string + :return: a tuple of numbers representing the R,G.B value to fill the PIL Image + """ + return tuple(int(md5hash_str[i:i+2], base=16) for i in range(0, 2*3, 2)) + + def draw_image(self, filename: str=None) -> 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. Function creates a PIL Image, drawing it, + and saving it. + + :param filename: filename of PIL png image generated + :return: None + """ + + fill_color: tuple = self._generate_image_fill_color(self.md5hash_str) + grid: list[list] = self._build_grid() + + SQUARE: int = 50 + size: tuple = (5 * 50, 5 * 50) + bg_color: tuple = (214,214,214) + + image: Image = Image.new("RGB", size, bg_color) + draw: ImageDraw = ImageDraw.Draw(image) + + # Makes the identicon symmetrical + for i in range(5): + grid[i][4] = grid[i][0] + grid[i][3] = grid[i][1] + + for row in range(5): + for element in range(5): + # Boolean check to confirm 'True' to draw and fill the pixel in the iamge + if grid[row][element]: + bounding_box: list[int] = [element * SQUARE, row * SQUARE, element * SQUARE + SQUARE, row * SQUARE + SQUARE] + # TODO: Should we use multiple fill colors? May need to draw multiple rectangles to obtain this + draw.rectangle(bounding_box, fill=fill_color) + + if not filename: + filename: str = 'example' + + # TODO: Confirm overwrite file is one of same name exists + image.save(f'{filename}.png') + +if __name__ == '__main__': + parser = argparse.ArgumentParser( + description="Generate an identicon with Python 3.", + usage="""Example: python main.py -s='931D387731bBbC988B31220' or add the optional -o flag to specify name of identicon + image generated such as python main.py -s='931D387731bBbC988B31220' -o='my_identicon.jpg'.""" + ) + + def len_gt_zero(input_str: str): + if len(input_str) > 0: + return input_str + raise argparse.ArgumentTypeError("Input string must have length greater than 0 in order to generate an identicon.") + + parser.add_argument( + "-s", + "--string", + default="", + type=str, + required=True, + help="An input string used to generate an identicon.", + ) + parser.add_argument( + "-o", + "--output", + default="", + type=str, + required=False, + help="Name for output identicon image generated.", + ) + + args = parser.parse_args() + + identicon = Identicon(input_str=args.string) + identicon.draw_image(filename=args.output) diff --git a/test/sample_cases_test.py b/test/sample_cases_test.py index a31381b..a4421ed 100644 --- a/test/sample_cases_test.py +++ b/test/sample_cases_test.py @@ -1,11 +1,12 @@ #!/usr/bin/env python3 +from os import remove from pathlib import Path -from PIL import Image +from PIL import Image, PngImagePlugin import subprocess import unittest -from main import Identicon +from src.main import Identicon __author__ = "Lorena Mesa" __email__ = "me@lorenamesa.com" @@ -14,23 +15,63 @@ class TestUI(unittest.TestCase): - def test_ui_fails_to_create_identicon_with_input_text_missing(self): + def test_ui_fails_to_create_identicon_with_input_string_missing(self): with self.assertRaises(subprocess.CalledProcessError) as context: - subprocess.check_output(f"python3 {PROJECT_ROOT}/main.py", shell=True, stderr=subprocess.STDOUT).strip() - self.assertIn(context.exception.message, "main.py: error: the following arguments are required: -s/--string") + subprocess.check_output(f"python3 {PROJECT_ROOT}/src/main.py", shell=True, stderr=subprocess.STDOUT).strip() + self.assertIn("main.py: error: the following arguments are required: -s/--string", context.exception.output.decode('utf-8')) class TestHappyPath(unittest.TestCase): def test_successfully_creates_identicon(self): identicon = Identicon("931D387731bBbC988B31220") identicon.draw_image(filename="output") - image = Image.open(f"{PROJECT_ROOT}/output.png", mode="r") - self.assertIsInstance(image, Image, "Image created is not of type PIL.Image") + generated_image = Image.open(f"{PROJECT_ROOT}/output.png", mode="r") + self.assertIsInstance(generated_image, PngImagePlugin.PngImageFile) + remove(f"{PROJECT_ROOT}/output.png") + + def test_successfully_creates_same_identicon_for_same_input_strings(self): + # Make 1st identicon + identicon_john_1 = Identicon("john") + identicon_john_1.draw_image(filename="john1") + # Make 2nd identicon + identicon_john_2 = Identicon("john") + identicon_john_2.draw_image(filename="john2") + + # Assertions + generated_john_1 = Image.open(f"{PROJECT_ROOT}/john1.png", mode="r") + self.assertIsInstance(generated_john_1, PngImagePlugin.PngImageFile) + + generated_john_2 = Image.open(f"{PROJECT_ROOT}/john2.png", mode="r") + self.assertIsInstance(generated_john_2, PngImagePlugin.PngImageFile) + + self.assertEqual(generated_john_1, generated_john_2) + + # Cleanup + remove(f"{PROJECT_ROOT}/john1.png") + remove(f"{PROJECT_ROOT}/john2.png") + + def test_does_not_create_same_identicon_for_different_input_strings(self): + # Make 1st identicon + identicon_john = Identicon("john") + identicon_john.draw_image(filename="john") + # Make 2nd identicon + identicon_john_2 = Identicon("jane") + identicon_john_2.draw_image(filename="jane") + + # Assertions + generated_john = Image.open(f"{PROJECT_ROOT}/john.png", mode="r") + self.assertIsInstance(generated_john, PngImagePlugin.PngImageFile) + + generated_jane = Image.open(f"{PROJECT_ROOT}/jane.png", mode="r") + self.assertIsInstance(generated_jane, PngImagePlugin.PngImageFile) + + self.assertNotEqual(generated_john, generated_jane) + + # Cleanup + remove(f"{PROJECT_ROOT}/john.png") + remove(f"{PROJECT_ROOT}/jane.png") + - # hash_str =convert_string_to_sha_hash("931D387731bBbC988B31220") - # hash_str = convert_string_to_sha_hash("me@lorenamesa.com") - # grid = build_grid(hash_str) - # draw_image(grid, hash_str) if __name__ == '__maipython -m unittest__': unittest.main() \ No newline at end of file