Skip to content

Commit ce046d5

Browse files
authored
Merge pull request #44 from lukaspanni/test
Add essential unit tests
2 parents 818d9f7 + 6bf7a8b commit ce046d5

13 files changed

+367
-48
lines changed

.github/workflows/python-app.yml

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ jobs:
1515
runs-on: ubuntu-latest
1616
strategy:
1717
matrix:
18-
python-version: [3.8]
18+
python-version: [3.8, 3.9]
1919

2020
steps:
2121
- uses: actions/checkout@v2
@@ -26,7 +26,7 @@ jobs:
2626
- name: Install dependencies
2727
run: |
2828
python -m pip install --upgrade pip
29-
pip install flake8 pytest
29+
pip install flake8 pytest pytest-cov
3030
if [ -f requirements.txt ]; then pip install -r requirements.txt; fi
3131
- name: Lint with flake8
3232
run: |
@@ -35,4 +35,4 @@ jobs:
3535
flake8 . --count --exit-zero --max-complexity=10 --max-line-length=120 --statistics
3636
- name: Test with pytest
3737
run: |
38-
pytest
38+
pytest --cov=imagecopy

ImageCopy/Transformers/raw_separate_transform.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@ def _load_config(self, config):
1919
self.raw_dir_name += "/"
2020
else:
2121
self.raw_dir_name = "RAW/"
22+
return
23+
self.raw_dir_name = None
2224

2325
def transform(self, input_dict: dict):
2426
if self.raw_dir_name is None:

ImageCopy/Transformers/rename_transform.py

Lines changed: 7 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -16,14 +16,13 @@ def _load_config(self, config: dict):
1616
"""
1717
Load rename config
1818
"""
19-
if "in" in config:
20-
self.in_str = config["in"]
21-
if "out" in config:
22-
self.out_str = config["out"]
23-
else:
24-
raise ValueError("Invalid rename-config, regex_out missing")
25-
else:
26-
raise ValueError("Invalid rename-config, regex_in missing")
19+
if "in" not in config or "out" not in config:
20+
raise ValueError("Invalid rename-config, regex_in/regex_out missing")
21+
if not isinstance(config["in"], str) or not isinstance(config["out"], str):
22+
raise ValueError("Invalid rename-config, regex_in/regex_out has wrong type")
23+
24+
self.in_str = config["in"]
25+
self.out_str = config["out"]
2726

2827
def transform(self, input_dict: dict):
2928
"""

ImageCopy/action_runner.py

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -18,15 +18,17 @@ class ActionRunner:
1818
"""
1919

2020
def __init__(self, config: Config):
21+
if config is None or not isinstance(config, Config):
22+
raise ValueError("No valid Config-Object provided")
2123
self.config = config
2224
self.path_transformers = []
2325
self.after_actions = []
2426
if self.config.grouping is not None:
2527
self.path_transformers.append(GroupingTransform(self.config.grouping))
26-
if self.config.raw_separate:
27-
self.path_transformers.append(RawSeparateTransform(config.raw_separate))
28-
if self.config.rename:
29-
self.path_transformers.append(RenameTransform(config.rename))
28+
if self.config.raw_separate is not None:
29+
self.path_transformers.append(RawSeparateTransform(self.config.raw_separate))
30+
if self.config.rename is not None:
31+
self.path_transformers.append(RenameTransform(self.config.rename))
3032
if self.config.exif is not None:
3133
self.after_actions.append(ExifEditing(self.config.exif))
3234
# if self.config.greyscale is not None:
@@ -64,4 +66,4 @@ def after_action_process(after_actions: list, image_queue: Queue, feedback_queue
6466
action.execute(command)
6567
counter += 1
6668
feedback_queue.put(counter)
67-
feedback_queue.put("END")
69+
feedback_queue.put("END")

ImageCopy/copier.py

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
import os
2+
import shutil
3+
import time
4+
from enum import Enum
5+
6+
from ImageCopy.config import Config
7+
from ImageCopy.image_file import ImageFile
8+
9+
10+
class Copier:
11+
class OverwriteOptions(Enum):
12+
OVERWRITE = "always-overwrite"
13+
WARN = "warn-before-overwrite"
14+
NO_OVERWRITE = "never-overwrite"
15+
APPEND_SUFFIX = "append-suffix"
16+
17+
def __init__(self, config: Config):
18+
self.mode = Copier.OverwriteOptions.OVERWRITE
19+
if config.copy is not None:
20+
if (mode := config.copy["mode"]) is not None:
21+
try:
22+
self.mode = Copier.OverwriteOptions(mode)
23+
# if self.mode == Copier.OverwriteOptions.APPEND_SUFFIX:
24+
# if config.copy["suffix"] is not None:
25+
# self.suffix = config.copy["suffix"]
26+
# else:
27+
# self.suffix = str(int(time.time()))
28+
except ValueError:
29+
print("Provided Copy-Mode", config.copy["mode"], "is not valid. Using 'always-overwrite'")
30+
pass
31+
32+
def copy(self, image: ImageFile, destination: str):
33+
"""
34+
Copy the given image to its new location.
35+
36+
:param image: image file to copy.
37+
:param destination: destination directory with or without new filename
38+
"""
39+
split_path = destination.split("/")
40+
# Workaround to allow rename
41+
if "." in split_path[-1]:
42+
target_dir = "/".join(split_path[:-1])
43+
else:
44+
target_dir = destination
45+
if not os.path.exists(target_dir):
46+
os.makedirs(target_dir, exist_ok=True)
47+
48+
# Check Overwrite Mode, TODO: Consider polymorphic implementation (especially if implementation changes)
49+
if self.mode == Copier.OverwriteOptions.WARN:
50+
# WARN
51+
if os.path.exists(destination):
52+
print("\nOverwriting Image", destination)
53+
54+
if self.mode == Copier.OverwriteOptions.APPEND_SUFFIX:
55+
# TODO: Add Incremental Suffixes
56+
if os.path.exists(destination):
57+
destination = destination[:-len(image.extension)] + "_" + str(int(time.time())) + image.extension
58+
if self.mode == Copier.OverwriteOptions.NO_OVERWRITE:
59+
if os.path.exists(destination):
60+
return None
61+
62+
return shutil.copy2(str(image), destination)

ImageCopy/image_copy.py

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
from ImageCopy.image_finder import ImageFinder
55
from ImageCopy.action_runner import ActionRunner
66
from ImageCopy.config import Config
7-
from ImageCopy.image_file import copy
7+
from ImageCopy.copier import Copier
88
from multiprocessing import Process, Queue
99

1010
CONFIG_FILE = "config.yml"
@@ -41,22 +41,24 @@ def progress_bar(iteration, total, prefix='', suffix='', decimals=1, length=100,
4141
runner.execute_transformers(images)
4242

4343
after_copy_actions = runner.get_after_action_count() > 0
44+
after_copy_queue = Queue()
45+
feedback_queue = Queue()
4446
if after_copy_actions:
45-
after_copy_queue = Queue()
46-
feedback_queue = Queue()
4747
after_copy_process = Process(target=runner.after_action_process,
4848
args=(runner.after_actions, after_copy_queue, feedback_queue))
4949
after_copy_process.start()
5050

51+
5152
i = 0
5253
print("Copying images from", config.io.input_dir, "to", config.io.output_dir)
5354
progress_bar(i, len(images), prefix="Progress:", suffix="Complete", length=50, end="")
55+
copier = Copier(config)
5456
for image in images:
5557
i += 1
5658
progress_bar(i, len(images), prefix="Progress:", suffix="Complete", length=50, end="")
5759
try:
58-
images[image] = copy(image, images[image])
59-
if after_copy_actions:
60+
images[image] = copier.copy(image, images[image])
61+
if after_copy_actions and images[image] is not None:
6062
after_copy_queue.put({image: images[image]})
6163
except PermissionError as per:
6264
print("\n", per) # TODO: Error handling

ImageCopy/image_file.py

Lines changed: 0 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@
33
"""
44
import os
55
import platform
6-
import shutil
76
import time
87
from pathlib import Path
98

@@ -50,20 +49,3 @@ def get_creation_time(self) -> time.struct_time:
5049
def __str__(self) -> str:
5150
return str(self.path)
5251

53-
54-
def copy(image: ImageFile, destination: str):
55-
"""
56-
Copy the given image to its new location.
57-
58-
:param image: image file to copy.
59-
:param destination: destination directory with or without new filename
60-
"""
61-
split_path = destination.split("/")
62-
# Workaround to allow rename
63-
if "." in split_path[-1]:
64-
target_dir = "/".join(split_path[:-1])
65-
else:
66-
target_dir = destination
67-
if not os.path.exists(target_dir):
68-
os.makedirs(target_dir, exist_ok=True)
69-
return shutil.copy2(str(image), destination)

tests/mock/mocks.py

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
from ImageCopy.Actions.after_copy_action import AfterCopyAction
2+
from ImageCopy.Transformers.path_transform import PathTransform
3+
from ImageCopy.config import Config
4+
5+
"""
6+
Collection of Mock-Implementations
7+
Refactor in multiple files if more than 4 Mock-Classes or one Mock-Class with more than 4 Methods or code lines > 50
8+
"""
9+
10+
11+
class MockConfig(Config):
12+
def __init__(self, grouping=None, raw_separate=None, rename=None, exif=None):
13+
self.grouping = grouping
14+
self.raw_separate = raw_separate
15+
self.rename = rename
16+
self.exif = exif
17+
18+
19+
class MockTransformer(PathTransform):
20+
21+
def __init__(self):
22+
self.transform_called = False
23+
self.transform_called_with = dict()
24+
25+
def transform(self, input_dict: dict):
26+
self.transform_called = True
27+
self.transform_called_with = input_dict
28+
29+
def _load_config(self, config):
30+
pass
31+
32+
33+
class MockAction(AfterCopyAction):
34+
35+
def __init__(self):
36+
self.execute_called = False
37+
self.execute_called_with = dict()
38+
39+
def execute(self, images: dict):
40+
self.execute_called = True
41+
self.execute_called_with = images

tests/test_Copier.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
from ImageCopy.config import Config
2+
from ImageCopy.copier import Copier
3+
4+
5+
class TestCopier:
6+
7+
class TestConfig(Config):
8+
def __init__(self, config_path):
9+
try:
10+
super().__init__(config_path)
11+
except Exception:
12+
pass
13+
14+
def test_constructor_config_default(self):
15+
config_dict = {"copy": {"mode": "invalid"}}
16+
config = TestCopier.TestConfig(None)
17+
config._cfg = config_dict
18+
19+
copier = Copier(config)
20+
assert copier.mode == Copier.OverwriteOptions.OVERWRITE
21+
22+
def test_constructor_config(self):
23+
config_dict = {"copy": {"mode": "warn-before-overwrite"}}
24+
config = TestCopier.TestConfig(None)
25+
config._cfg = config_dict
26+
27+
copier = Copier(config)
28+
assert copier.mode == Copier.OverwriteOptions.WARN

tests/test_action_runner.py

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
import random
2+
import pytest
3+
4+
from ImageCopy.Actions.exif_editing import ExifEditing
5+
from ImageCopy.Transformers.grouping_transform import GroupingTransform
6+
from ImageCopy.Transformers.raw_separate_transform import RawSeparateTransform
7+
from ImageCopy.Transformers.rename_transform import RenameTransform
8+
from ImageCopy.action_runner import ActionRunner
9+
from tests.mock.mocks import MockConfig, MockTransformer, MockAction
10+
11+
12+
class TestActionRunner:
13+
14+
def test_invalid_config(self):
15+
with pytest.raises(ValueError):
16+
runner = ActionRunner(None)
17+
18+
def test_tranformers_actions_not_created(self):
19+
mockConfig = MockConfig()
20+
runner = ActionRunner(mockConfig)
21+
assert len(runner.path_transformers) == 0
22+
assert len(runner.after_actions) == 0
23+
24+
def test_transformers_actions_created(self):
25+
mockConfig = MockConfig(dict(), dict(), {"in": "", "out": ""}, dict())
26+
runner = ActionRunner(mockConfig)
27+
assert len(runner.path_transformers) == 3 # grouping, raw_separate, rename
28+
assert any(isinstance(transform, GroupingTransform) for transform in runner.path_transformers)
29+
assert any(isinstance(transform, RawSeparateTransform) for transform in runner.path_transformers)
30+
assert any(isinstance(transform, RenameTransform) for transform in runner.path_transformers)
31+
assert len(runner.after_actions) == 1 # exif
32+
assert len(runner.after_actions) == runner.get_after_action_count()
33+
assert any(isinstance(action, ExifEditing) for action in runner.after_actions)
34+
35+
def test_executes_all_transformers(self):
36+
mockConfig = MockConfig()
37+
mockTransformers = [MockTransformer(), MockTransformer(), MockTransformer()]
38+
runner = ActionRunner(mockConfig)
39+
input_dict = {'test': True, 'x': 42}
40+
runner.path_transformers = mockTransformers
41+
runner.execute_transformers(input_dict)
42+
assert all(transform.transform_called for transform in mockTransformers)
43+
assert all(transform.transform_called_with == input_dict for transform in mockTransformers)
44+
45+
def test_executes_all_transformers_randomized_amount(self):
46+
mockConfig = MockConfig()
47+
mockTransformers = [MockTransformer() for x in range(random.randint(1, 20))]
48+
runner = ActionRunner(mockConfig)
49+
input_dict = {'test': True, 'x': 42}
50+
runner.path_transformers = mockTransformers
51+
runner.execute_transformers(input_dict)
52+
assert all(transform.transform_called for transform in mockTransformers)
53+
assert all(transform.transform_called_with == input_dict for transform in mockTransformers)
54+
55+
def test_executes_all_actions(self):
56+
mockConfig = MockConfig()
57+
mockActions = [MockAction(), MockAction()]
58+
runner = ActionRunner(mockConfig)
59+
input_dict = {'test': True, 'x': 42}
60+
runner.after_actions = mockActions
61+
runner.execute_after_actions(input_dict, lambda: None)
62+
assert all(action.execute_called for action in mockActions)
63+
assert all(action.execute_called_with == input_dict for action in mockActions)
64+
65+
def test_executes_all_actions_randomized_amaount(self):
66+
mockConfig = MockConfig()
67+
mockActions = [MockAction() for x in range(random.randint(1, 20))]
68+
runner = ActionRunner(mockConfig)
69+
input_dict = {'test': True, 'x': 42}
70+
runner.after_actions = mockActions
71+
runner.execute_after_actions(input_dict, lambda: None)
72+
assert all(action.execute_called for action in mockActions)
73+
assert all(action.execute_called_with == input_dict for action in mockActions)
74+
75+
def test_execute_actions_register_progress(self):
76+
77+
class Counter:
78+
def __init__(self):
79+
self.progress_counter = 0
80+
81+
def progress(self):
82+
self.progress_counter += 1
83+
84+
mockConfig = MockConfig()
85+
mockActions = [MockAction() for x in range(random.randint(1, 20))]
86+
runner = ActionRunner(mockConfig)
87+
input_dict = {'test': True, 'x': 42}
88+
runner.after_actions = mockActions
89+
counter = Counter()
90+
runner.execute_after_actions(input_dict, counter.progress)
91+
assert counter.progress_counter == len(mockActions)

0 commit comments

Comments
 (0)