From a0b066cefe418c435b6f684a5dae21f78bea5537 Mon Sep 17 00:00:00 2001 From: Ismo Vuorinen Date: Tue, 29 Apr 2025 18:09:11 +0300 Subject: [PATCH] chore: saving the wip state --- .editorconfig | 2 +- .pre-commit-config.yaml | 7 +- aeonview.py | 319 ++++++++++++++++++++++++---------------- aeonview_test.py | 283 +++++++++++++++++++++-------------- conftest.py | 2 +- pyproject.toml | 6 +- 6 files changed, 372 insertions(+), 247 deletions(-) diff --git a/.editorconfig b/.editorconfig index 9d0107b..52fb4a4 100644 --- a/.editorconfig +++ b/.editorconfig @@ -6,7 +6,7 @@ root = true [*] charset = utf-8 end_of_line = lf -max_line_length = 100 +max_line_length = 80 insert_final_newline = true indent_style = space indent_size = 2 diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index c9b0216..a3a4928 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,6 +1,11 @@ repos: +- repo: https://github.com/asottile/pyupgrade + rev: v3.19.1 + hooks: + - id: pyupgrade + args: [--py3-plus] - repo: https://github.com/astral-sh/ruff-pre-commit - rev: "v0.11.4" + rev: "v0.11.5" hooks: - id: ruff args: ["--fix"] diff --git a/aeonview.py b/aeonview.py index 3e211dd..75b811c 100644 --- a/aeonview.py +++ b/aeonview.py @@ -13,14 +13,14 @@ import requests # Define messages for logging # These messages are used for logging purposes and can be customized as needed. class AeonViewMessages: - INVALID_URL = 'Invalid URL provided.' - INVALID_DATE = 'Invalid date format provided.' - DOWNLOAD_SUCCESS = 'Image downloaded successfully.' - DOWNLOAD_FAILURE = 'Failed to download image.' - VIDEO_GENERATION_SUCCESS = 'Video generated successfully.' - VIDEO_GENERATION_FAILURE = 'Failed to generate video.' - INVALID_IMAGE_FORMAT = 'Invalid image format provided.' - INVALID_IMAGE_EXTENSION = 'Invalid image extension provided.' + INVALID_URL = "Invalid URL provided." + INVALID_DATE = "Invalid date format provided." + DOWNLOAD_SUCCESS = "Image downloaded successfully." + DOWNLOAD_FAILURE = "Failed to download image." + VIDEO_GENERATION_SUCCESS = "Video generated successfully." + VIDEO_GENERATION_FAILURE = "Failed to generate video." + INVALID_IMAGE_FORMAT = "Invalid image format provided." + INVALID_IMAGE_EXTENSION = "Invalid image extension provided." class AeonViewImages: @@ -58,53 +58,56 @@ class AeonViewImages: if not isinstance(date, datetime): logging.error(AeonViewMessages.INVALID_DATE) sys.exit(1) - if not url.startswith('http'): + if not url.startswith("http"): logging.error(AeonViewMessages.INVALID_URL) sys.exit(1) - if not url.endswith(('.jpg', '.jpeg', '.png', '.gif', '.webp')): + if not url.endswith((".jpg", ".jpeg", ".png", ".gif", ".webp")): logging.error(AeonViewMessages.INVALID_IMAGE_FORMAT) sys.exit(1) if destination_base is None: - logging.error('No destination base path provided.') + logging.error("No destination base path provided.") sys.exit(1) if not isinstance(destination_base, Path): - logging.error('Invalid destination base path.') + logging.error("Invalid destination base path.") sys.exit(1) - year = date.strftime('%Y') - month = date.strftime('%m') - day = date.strftime('%d') + year = date.strftime("%Y") + month = date.strftime("%m") + day = date.strftime("%d") - year_month = f'{year}-{month}' + year_month = f"{year}-{month}" - destination = AeonViewHelpers.build_path(destination_base, year_month, day) - file_name = date.strftime('%H-%M-%S') + AeonViewHelpers.get_extension(url) + destination = AeonViewHelpers.build_path( + destination_base, year_month, day + ) + extension = AeonViewHelpers.get_extension(url) or "" + file_name = date.strftime("%H-%M-%S") + extension destination_file = AeonViewHelpers.build_path(destination, file_name) if not destination.exists(): - if self.args.simulate: - logging.info(f'Simulate: would create {destination}') + if self.args.get("simulate", False): + logging.info("Simulate: would create %s", destination) else: AeonViewHelpers.mkdir_p(destination) - logging.info(f'Creating destination base path: {destination}') + logging.info("Creating destination base path: %s", destination) return { - 'url': url, - 'file': file_name, - 'date': { - 'year': year, - 'month': month, - 'day': day, - 'hour': date.strftime('%H'), - 'minute': date.strftime('%M'), - 'second': date.strftime('%S'), + "url": url, + "file": file_name, + "date": { + "year": year, + "month": month, + "day": day, + "hour": date.strftime("%H"), + "minute": date.strftime("%M"), + "second": date.strftime("%S"), }, - 'destinations': { - 'base': destination_base, - 'year_month': year_month, - 'day': day, - 'file': destination_file, + "destinations": { + "base": destination_base, + "year_month": year_month, + "day": day, + "file": destination_file, }, } @@ -113,17 +116,19 @@ class AeonViewImages: Download the image from the URL and save it to the project directory. """ - if self.args.date is not None: + date_param = self.args.get("date", None) + + if date_param is not None: try: - date = datetime.strptime(self.args.date, '%Y-%m-%d %H:%M:%S') + date = datetime.strptime(date_param, "%Y-%m-%d %H:%M:%S") except ValueError: logging.error(AeonViewMessages.INVALID_DATE) sys.exit(1) else: date = datetime.now() - img_path = date.strftime('img/%Y-%m/%d') - img_name = date.strftime('%H-%M-%S') + img_path = date.strftime("img/%Y-%m/%d") + img_name = date.strftime("%H-%M-%S") if self.url is None: logging.error(AeonViewMessages.INVALID_URL) @@ -132,16 +137,20 @@ class AeonViewImages: file_ext = AeonViewHelpers.get_extension(self.url) dest_dir = AeonViewHelpers.build_path(self.project_path, img_path) - dest_file = AeonViewHelpers.build_path(dest_dir, f'{img_name}{file_ext}') + dest_file = AeonViewHelpers.build_path( + dest_dir, f"{img_name}{file_ext}" + ) - logging.info(f'Saving image to {dest_file}') + logging.info("Saving image to %s", dest_file) - if not self.args.simulate: + if not self.args.get("simulate", False): AeonViewHelpers.mkdir_p(dest_dir) self.download_image(dest_file) else: - logging.info(f'Simulate: would create {dest_dir}') - logging.info(f'Simulate: would download {self.url} to {dest_file}') + logging.info("Simulate: would create %s", dest_dir) + logging.info( + "Simulate: would download %s to %s", self.url, dest_file + ) def download_image(self, destination: Path): """ @@ -155,22 +164,31 @@ class AeonViewImages: sys.exit(1) if not isinstance(destination, Path): - logging.error('Invalid destination path.') + logging.error("Invalid destination path.") sys.exit(1) - if self.args.simulate is False or self.args.simulate is None: - logging.info(f'Downloading image from {self.url}') - response = requests.get(self.url, stream=True) + if ( + self.args.get("simulate", False) is False + or self.args.get("simulate", None) is None + ): + logging.info("Downloading image from %s", self.url) + response = requests.get(self.url, stream=True, timeout=10) if response.status_code == 200: - with open(destination, 'wb') as f: + with open(destination, "wb") as f: for chunk in response.iter_content(1024): f.write(chunk) - logging.info(f'{AeonViewMessages.DOWNLOAD_SUCCESS}: {destination}') + logging.info( + "%s: %s", AeonViewMessages.DOWNLOAD_SUCCESS, destination + ) else: - logging.error(f'{AeonViewMessages.DOWNLOAD_FAILURE}: {self.url}') + logging.error( + "%s: %s", AeonViewMessages.DOWNLOAD_FAILURE, self.url + ) sys.exit(1) else: - logging.info(f'Simulate: would download {self.url} to {destination}') + logging.info( + "Simulate: would download %s to %s", self.url, destination + ) class AeonViewVideos: @@ -181,48 +199,58 @@ class AeonViewVideos: It uses ffmpeg for video processing. """ - def __init__(self, project_path: Path, args=None): + def __init__( + self, project_path: Path, args: argparse.Namespace | None = None + ): """ Initialize the AeonViewVideos class. :param project_path: Path to the project directory :param args: Command line arguments passed to the class """ self.project_path = project_path - self.args = args or {} + self.args = args - self.args.simulate = args.simulate or False - self.args.fps = args.fps or 10 + self.args["simulate"] = ( + args.get("simulate", False) if isinstance(args, dict) else False + ) + self.args["fps"] = args.get("fps", 10) if isinstance(args, dict) else 10 - self.day = args.day or None - self.month = args.month or None - self.year = args.year or None + self.day = args.get("day", None) if args else None + self.month = args.get("month", None) if args else None + self.year = args.get("year", None) if args else None - self.path_images = AeonViewHelpers.build_path(self.project_path, 'img') - self.path_videos = AeonViewHelpers.build_path(self.project_path, 'vid') + self.path_images = AeonViewHelpers.build_path(self.project_path, "img") + self.path_videos = AeonViewHelpers.build_path(self.project_path, "vid") def generate_daily_video(self): """ Generate a daily video from images. """ - year_month = f'{self.year}-{self.month}' + year_month = f"{self.year}-{self.month}" - input_dir = AeonViewHelpers.build_path(self.path_videos, year_month, self.day) + input_dir = AeonViewHelpers.build_path( + self.path_videos, year_month, self.day + ) output_dir = AeonViewHelpers.build_path(self.path_videos, year_month) - output_file = AeonViewHelpers.build_path(output_dir, f'{self.day}.mp4') - ffmpeg_cmd = AeonViewHelpers.generate_ffmpeg_command(input_dir, output_file, self.args.fps) + output_file = AeonViewHelpers.build_path(output_dir, f"{self.day}.mp4") + ffmpeg_cmd = AeonViewHelpers.generate_ffmpeg_command( + input_dir, output_file, self.args.fps + ) - logging.info(f'Generating video from {input_dir}') - logging.info(f'Output file will be {output_file}') + logging.info("Generating video from %s", input_dir) + logging.info("Output file will be %s", output_file) - if not self.args.simulate: - logging.info(f'Running ffmpeg command: {" ".join(ffmpeg_cmd)}') + if not self.args.get("simulate", False): + logging.info("Running ffmpeg command: %s", " ".join(ffmpeg_cmd)) if not os.path.exists(input_dir): AeonViewHelpers.mkdir_p(output_dir) subprocess.run(ffmpeg_cmd, check=True) - logging.info(f'{AeonViewMessages.VIDEO_GENERATION_SUCCESS}: {output_file}') + logging.info( + "%s: %s", AeonViewMessages.VIDEO_GENERATION_SUCCESS, output_file + ) else: - logging.info(f'Simulate: would run {" ".join(ffmpeg_cmd)}') + logging.info("Simulate: would run %s", " ".join(ffmpeg_cmd)) def generate_monthly_video(self, output_dir: Path): """ @@ -230,7 +258,9 @@ class AeonViewVideos: :param output_dir: Directory where the video will be saved :return: None """ - raise NotImplementedError('Monthly video generation is not implemented.') + raise NotImplementedError( + "Monthly video generation is not implemented." + ) def generate_yearly_video(self, output_dir: Path): """ @@ -238,7 +268,7 @@ class AeonViewVideos: :param output_dir: Directory where the video will be saved :return: None """ - raise NotImplementedError('Yearly video generation is not implemented.') + raise NotImplementedError("Yearly video generation is not implemented.") class AeonViewHelpers: @@ -284,20 +314,45 @@ class AeonViewHelpers: def parse_arguments(): """Parse command line arguments.""" - dest_default = str(Path.cwd() / 'projects') + dest_default = str(Path.cwd() / "projects") - parser = argparse.ArgumentParser(description='aeonview - timelapse generator using ffmpeg') - parser.add_argument('--mode', choices=['image', 'video'], default='image', help='Run mode') - parser.add_argument('--project', help='Project name', default='default') - parser.add_argument('--dest', default=dest_default, help='Destination root path') - parser.add_argument('--url', help='Webcam URL (required in image mode)') - parser.add_argument('--fps', type=int, default=10, help='Frames per second') - parser.add_argument('--generate', help='Date for video generation (YYYY-MM-DD)') - parser.add_argument('--timeframe', choices=['daily', 'monthly', 'yearly'], default='daily') - parser.add_argument( - '--simulate', action='store_true', help='Simulation mode', default=False + parser = argparse.ArgumentParser( + description="aeonview - timelapse generator using ffmpeg" + ) + parser.add_argument( + "--mode", + choices=["image", "video"], + default="image", + help="Run mode", + ) + parser.add_argument("--project", help="Project name", default="default") + parser.add_argument( + "--dest", default=dest_default, help="Destination root path" + ) + parser.add_argument("--url", help="Webcam URL (required in image mode)") + parser.add_argument( + "--fps", type=int, default=10, help="Frames per second" + ) + parser.add_argument( + "--generate", help="Date for video generation (YYYY-MM-DD)" + ) + parser.add_argument( + "--timeframe", + choices=["daily", "monthly", "yearly"], + default="daily", + ) + parser.add_argument( + "--simulate", + action="store_true", + help="Simulation mode", + default=False, + ) + parser.add_argument( + "--verbose", + action="store_true", + help="Verbose output", + default=False, ) - parser.add_argument('--verbose', action='store_true', help='Verbose output', default=False) args = parser.parse_args() return args, parser @@ -311,14 +366,14 @@ class AeonViewHelpers: logging.error(AeonViewMessages.INVALID_IMAGE_EXTENSION) return None - if url.endswith('.png'): - return '.png' - elif url.endswith('.gif'): - return '.gif' - elif url.endswith('.webp'): - return '.webp' + if url.endswith(".png"): + return ".png" + elif url.endswith(".gif"): + return ".gif" + elif url.endswith(".webp"): + return ".webp" else: - return '.jpg' + return ".jpg" @staticmethod def setup_logger(verbose: bool): @@ -328,10 +383,12 @@ class AeonViewHelpers: :return: None """ level = logging.DEBUG if verbose else logging.INFO - logging.basicConfig(level=level, format='[%(levelname)s] %(message)s') + logging.basicConfig(level=level, format="[%(levelname)s] %(message)s") @staticmethod - def generate_ffmpeg_command(input_dir: Path, output_file: Path, fps: int = 10) -> list: + def generate_ffmpeg_command( + input_dir: Path, output_file: Path, fps: int = 10 + ) -> list: """ Generate the ffmpeg command to create a video from images. :param input_dir: Directory containing the images @@ -340,17 +397,17 @@ class AeonViewHelpers: :return: Newly created ffmpeg command as a list """ return [ - 'ffmpeg', - '-framerate', + "ffmpeg", + "-framerate", str(fps), - '-pattern_type', - 'glob', - '-i', - str(input_dir / '*.{jpg,jpeg,png,gif,webp}'), - '-c:v', - 'libx264', - '-pix_fmt', - 'yuv420p', + "-pattern_type", + "glob", + "-i", + str(input_dir / "*.{jpg,jpeg,png,gif,webp}"), + "-c:v", + "libx264", + "-pix_fmt", + "yuv420p", str(output_file), ] @@ -370,11 +427,11 @@ class AeonViewApp: Execute the application based on the provided arguments. """ if self.args.simulate: - logging.info('Simulation mode active. No actions will be executed.') + logging.info("Simulation mode active. No actions will be executed.") - if self.args.mode == 'image': + if self.args.mode == "image": self.process_image() - elif self.args.mode == 'video': + elif self.args.mode == "video": self.process_video() def process_image(self): @@ -382,18 +439,20 @@ class AeonViewApp: Process image download and saving based on the provided arguments. """ if not self.args.url or self.args.url is None: - logging.error('--url is required in image mode') + logging.error("--url is required in image mode") self.parser.print_help() sys.exit(1) - if not isinstance(self.args.url, str) or not self.args.url.startswith('http'): - logging.error(f'{AeonViewMessages.INVALID_URL}: {self.args.url}') + if not isinstance(self.args.url, str) or not self.args.url.startswith( + "http" + ): + logging.error("%s: %s", AeonViewMessages.INVALID_URL, self.args.url) sys.exit(1) url = self.args.url - project = self.args.project or 'default' + project = self.args.project or "default" - if project == 'default' and url: + if project == "default" and url: project = hashlib.md5(url.encode()).hexdigest()[:5] project_path = AeonViewHelpers.build_path(self.base_path, project) @@ -405,23 +464,25 @@ class AeonViewApp: Process video generation based on the provided arguments. """ if not self.args.project: - logging.error('--project is required in video mode') + logging.error("--project is required in video mode") self.parser.print_help() sys.exit(1) if not isinstance(self.args.project, str): - logging.error(f'Invalid project name: {self.args.project}') + logging.error("Invalid project name: %s", self.args.project) sys.exit(1) - project_path = AeonViewHelpers.build_path(self.base_path, self.args.project) + project_path = AeonViewHelpers.build_path( + self.base_path, self.args.project + ) if not os.path.exists(project_path): - logging.error(f'Project path {project_path} does not exist.') + logging.error("Project path %s does not exist.", project_path) sys.exit(1) try: generate_date = ( - datetime.strptime(self.args.generate, '%Y-%m-%d') + datetime.strptime(self.args.generate, "%Y-%m-%d") if self.args.generate else datetime.today() - timedelta(days=1) ) @@ -429,31 +490,33 @@ class AeonViewApp: logging.error(AeonViewMessages.INVALID_DATE) sys.exit(1) - year = generate_date.strftime('%Y') - month = generate_date.strftime('%m') - day = generate_date.strftime('%d') + year = generate_date.strftime("%Y") + month = generate_date.strftime("%m") + day = generate_date.strftime("%d") self.args.day = day self.args.month = month self.args.year = year if not AeonViewHelpers.check_date(int(year), int(month), int(day)): - logging.error(f'Invalid date: {year}-{month}-{day}') + logging.error("Invalid date: %s-%s-%s", year, month, day) sys.exit(1) - avm = AeonViewVideos(project_path, self.args) + avm = AeonViewVideos(project_path, vars(self.args)) - if self.args.timeframe == 'daily': + if self.args.timeframe == "daily": avm.generate_daily_video() - elif self.args.timeframe == 'monthly': - output_dir = AeonViewHelpers.build_path(project_path, 'vid', f'{year}-{month}') + elif self.args.timeframe == "monthly": + output_dir = AeonViewHelpers.build_path( + project_path, "vid", f"{year}-{month}" + ) avm.generate_monthly_video(output_dir) - elif self.args.timeframe == 'yearly': - output_dir = AeonViewHelpers.build_path(project_path, 'vid', year) + elif self.args.timeframe == "yearly": + output_dir = AeonViewHelpers.build_path(project_path, "vid", year) avm.generate_yearly_video(output_dir) -if __name__ == '__main__': +if __name__ == "__main__": app = AeonViewApp() app.run() diff --git a/aeonview_test.py b/aeonview_test.py index 5f0abb7..9a6d3fe 100644 --- a/aeonview_test.py +++ b/aeonview_test.py @@ -1,24 +1,31 @@ +# vim: set ft=python ts=4 sw=4 sts=4 et wrap: import argparse import logging import subprocess import tempfile +from datetime import datetime from pathlib import Path from unittest import TestCase, mock -from datetime import datetime import pytest -from aeonview import AeonViewHelpers, AeonViewImages, AeonViewVideos, AeonViewMessages +from aeonview import ( + AeonViewHelpers, + AeonViewImages, + AeonViewMessages, + AeonViewVideos, +) # Define values used in the tests -default_dest = str(Path.cwd() / 'projects') -default_project = 'default' +default_dest = str(Path.cwd() / "projects") +default_project = "default" default_fps = 10 -default_timeframe = 'daily' +default_timeframe = "daily" default_simulate = False default_verbose = False -default_image_domain = 'https://example.com/image' -default_test_path = Path('/tmp/test_project').resolve() +default_image_domain = "https://example.com/image" +default_test_path = Path("/tmp/test_project").resolve() +tmp_images = Path("/tmp/images") # Define the Helpers class with methods to be tested @@ -31,52 +38,61 @@ class TestHelpers(TestCase): def test_mkdir_p_creates_directory(self): with tempfile.TemporaryDirectory() as tmp: - test_path = Path(tmp) / 'a' / 'b' / 'c' + test_path = Path(tmp) / "a" / "b" / "c" AeonViewHelpers.mkdir_p(test_path) self.assertTrue(test_path.exists()) self.assertTrue(test_path.is_dir()) def test_get_extension_valid(self): - self.assertEqual(AeonViewHelpers.get_extension(f'{default_image_domain}.png'), '.png') - self.assertEqual(AeonViewHelpers.get_extension(f'{default_image_domain}.jpg'), '.jpg') - self.assertEqual(AeonViewHelpers.get_extension(f'{default_image_domain}.gif'), '.gif') - self.assertEqual(AeonViewHelpers.get_extension(f'{default_image_domain}.webp'), '.webp') + prefix = default_image_domain + types = ["jpg", "png", "gif", "webp"] + for ext in types: + self.assertEqual( + AeonViewHelpers.get_extension(f"{prefix}.{ext}"), + f".{ext}", + ) def test_get_extension_invalid(self): self.assertEqual( - AeonViewHelpers.get_extension(default_image_domain), '.jpg' + AeonViewHelpers.get_extension(default_image_domain), ".jpg" ) # Default behavior self.assertIsNone(AeonViewHelpers.get_extension(None)) class TestFFmpegCommand(TestCase): def test_generate_ffmpeg_command(self): - input_dir = Path('/tmp/images') - output_file = Path('/tmp/output.mp4') + input_dir = tmp_images + output_file = Path("/tmp/output.mp4") fps = 24 - cmd = AeonViewHelpers.generate_ffmpeg_command(input_dir, output_file, fps) - self.assertIn('ffmpeg', cmd[0]) + cmd = AeonViewHelpers.generate_ffmpeg_command( + input_dir, output_file, fps + ) + self.assertIn("ffmpeg", cmd[0]) self.assertIn(str(fps), cmd) self.assertEqual(str(output_file), cmd[-1]) - self.assertIn(str(input_dir / '*.{jpg,jpeg,png,gif,webp}'), cmd) + self.assertIn(str(input_dir / "*.{jpg,jpeg,png,gif,webp}"), cmd) def test_generate_ffmpeg_command_output_format(self): - input_dir = Path('/tmp/images') - output_file = Path('/tmp/video.mp4') - cmd = AeonViewHelpers.generate_ffmpeg_command(input_dir, output_file, 30) - self.assertIn('/tmp/images/*.{jpg,jpeg,png,gif,webp}', cmd) - self.assertIn('/tmp/video.mp4', cmd) - self.assertIn('-c:v', cmd) - self.assertIn('libx264', cmd) - self.assertIn('-pix_fmt', cmd) - self.assertIn('yuv420p', cmd) + input_dir = tmp_images + output_file = Path("/tmp/video.mp4") + cmd = AeonViewHelpers.generate_ffmpeg_command( + input_dir, output_file, 30 + ) + self.assertIn("/tmp/images/*.{jpg,jpeg,png,gif,webp}", cmd) + self.assertIn("/tmp/video.mp4", cmd) + self.assertIn("-c:v", cmd) + self.assertIn("libx264", cmd) + self.assertIn("-pix_fmt", cmd) + self.assertIn("yuv420p", cmd) - @mock.patch('subprocess.run') + @mock.patch("subprocess.run") def test_simulate_ffmpeg_call(self, mock_run): - input_dir = Path('/tmp/images') - output_file = Path('/tmp/out.mp4') - cmd = AeonViewHelpers.generate_ffmpeg_command(input_dir, output_file, 10) - subprocess.run(cmd) + input_dir = tmp_images + output_file = Path("/tmp/out.mp4") + cmd = AeonViewHelpers.generate_ffmpeg_command( + input_dir, output_file, 10 + ) + subprocess.run(cmd, check=True) mock_run.assert_called_once_with(cmd) @@ -84,235 +100,276 @@ class TestAeonViewImages(TestCase): def setUp(self): self.args = argparse.Namespace() self.args.simulate = False - self.args.date = '2025-04-10 12:30:45' - self.args.url = f'{default_image_domain}.jpg' + self.args.date = "2025-04-10 12:30:45" + self.args.url = f"{default_image_domain}.jpg" self.args.dest = default_test_path self.args.project = default_project self.args.verbose = default_verbose self.args.fps = default_fps self.args.timeframe = default_timeframe self.project_path = default_test_path - self.url = f'{default_image_domain}.jpg' + self.url = f"{default_image_domain}.jpg" def test_get_image_paths_valid(self): - url = f'{default_image_domain}.jpg' + url = f"{default_image_domain}.jpg" destination_base = default_test_path date = datetime(2025, 4, 10, 12, 30, 45) - paths = AeonViewImages.get_image_paths(self, url, destination_base, date) - self.assertEqual(paths['url'], url) - self.assertEqual(paths['file'], '12-30-45.jpg') + aeon_view_images = AeonViewImages(destination_base, url) + paths = aeon_view_images.get_image_paths(url, destination_base, date) + self.assertEqual(paths["url"], url) + self.assertEqual(paths["file"], "12-30-45.jpg") self.assertEqual( - paths['destinations']['file'], destination_base / '2025-04' / '10' / '12-30-45.jpg' + paths["destinations"]["file"], + destination_base / "2025-04" / "10" / "12-30-45.jpg", ) def test_get_image_paths_invalid_url(self): with pytest.raises(SystemExit): - with self.assertLogs(level='ERROR') as log: - AeonViewImages.get_image_paths( - self, 'invalid-url', default_test_path, datetime(2025, 4, 10) + with self.assertLogs(level="ERROR") as log: + aeon_view_images = AeonViewImages( + default_test_path, "invalid-url" + ) + aeon_view_images.get_image_paths( + "invalid-url", + default_test_path, + datetime(2025, 4, 10), ) self.assertIn(AeonViewMessages.INVALID_URL, log.output[0]) def test_get_image_paths_invalid_date(self): with pytest.raises(SystemExit): - with self.assertLogs(level='ERROR') as log: - AeonViewImages.get_image_paths( - self, f'{default_image_domain}.jpg', default_test_path, 'invalid-date' + with self.assertLogs(level="ERROR") as log: + aeon_view_images = AeonViewImages( + default_test_path, f"{default_image_domain}.jpg" + ) + aeon_view_images.get_image_paths( + f"{default_image_domain}.jpg", + default_test_path, + "invalid-date", ) self.assertIn(AeonViewMessages.INVALID_DATE, log.output[0]) - @mock.patch('aeonview.AeonViewHelpers.mkdir_p') - @mock.patch('aeonview.AeonViewImages.download_image') + @mock.patch("aeonview.AeonViewHelpers.mkdir_p") + @mock.patch("aeonview.AeonViewImages.download_image") def test_get_current_image(self, mock_download_image, mock_mkdir_p): project_path = default_test_path - url = f'{default_image_domain}.jpg' - args = argparse.Namespace(simulate=False, date='2025-04-10 12:30:45') + url = f"{default_image_domain}.jpg" + args = argparse.Namespace(simulate=False, date="2025-04-10 12:30:45") avi = AeonViewImages(project_path, url, args) avi.get_current_image() mock_mkdir_p.assert_called() mock_download_image.assert_called() - @mock.patch('aeonview.requests.get') + @mock.patch("aeonview.requests.get") def test_download_image_success(self, mock_get): mock_response = mock.Mock() mock_response.status_code = 200 - mock_response.iter_content = mock.Mock(return_value=[b'data']) + mock_response.iter_content = mock.Mock(return_value=[b"data"]) mock_get.return_value = mock_response project_path = default_test_path - url = f'{default_image_domain}.jpg' + url = f"{default_image_domain}.jpg" args = argparse.Namespace(simulate=False) avi = AeonViewImages(project_path, url, args) - destination = Path('/tmp/image.jpg') - avi.download_image(destination) + with tempfile.NamedTemporaryFile(delete=True) as temp_file: + destination = Path(temp_file.name) + avi.download_image(destination) - mock_get.assert_called_once_with(url, stream=True) - self.assertTrue(destination.exists()) + mock_get.assert_called_once_with(url, stream=True) + self.assertTrue(destination.exists()) - @mock.patch('aeonview.requests.get') + @mock.patch("aeonview.requests.get") def test_download_image_failure(self, mock_get): mock_response = mock.Mock() mock_response.status_code = 404 mock_get.return_value = mock_response project_path = default_test_path - url = f'{default_image_domain}.jpg' + url = f"{default_image_domain}.jpg" args = argparse.Namespace(simulate=False) avi = AeonViewImages(project_path, url, args) - destination = Path('/tmp/image.jpg') + destination = Path("/tmp/image.jpg") with pytest.raises(SystemExit): - with self.assertLogs(level='ERROR') as log: + with self.assertLogs(level="ERROR") as log: avi.download_image(destination) self.assertIn(AeonViewMessages.DOWNLOAD_FAILURE, log.output[0]) class TestAeonViewVideos(TestCase): - @mock.patch('aeonview.AeonViewHelpers.mkdir_p') - @mock.patch('subprocess.run') + @mock.patch("aeonview.AeonViewHelpers.mkdir_p") + @mock.patch("subprocess.run") def test_generate_daily_video(self, mock_subprocess_run, mock_mkdir_p): project_path = default_test_path - args = argparse.Namespace(simulate=False, fps=10, day='01', month='04', year='2025') + args = argparse.Namespace( + simulate=False, fps=10, day="01", month="04", year="2025" + ) avv = AeonViewVideos(project_path, args) - with self.assertLogs(level='INFO') as log: + with self.assertLogs(level="INFO") as log: avv.generate_daily_video() - expected_message = f'{AeonViewMessages.VIDEO_GENERATION_SUCCESS}: {default_test_path / "vid/2025-04/01.mp4"}' - self.assertIn(expected_message, log.output[-1]) # Ensure it's the last log entry + msg = AeonViewMessages.VIDEO_GENERATION_SUCCESS + expected_message = ( + f"{msg}: {default_test_path / 'vid/2025-04/01.mp4'}" + ) + self.assertIn( + expected_message, log.output[-1] + ) # Ensure it's the last log entry mock_mkdir_p.assert_called() mock_subprocess_run.assert_called() - @mock.patch('aeonview.AeonViewHelpers.mkdir_p') + @mock.patch("aeonview.AeonViewHelpers.mkdir_p") def test_generate_daily_video_simulate(self, mock_mkdir_p): project_path = default_test_path - args = argparse.Namespace(simulate=True, fps=10, day='01', month='04', year='2025') + args = argparse.Namespace( + simulate=True, fps=10, day="01", month="04", year="2025" + ) avv = AeonViewVideos(project_path, args) avv.generate_daily_video() mock_mkdir_p.assert_not_called() def test_generate_monthly_video_not_implemented(self): project_path = default_test_path - args = argparse.Namespace(simulate=False, fps=10, day='01', month='04', year='2025') + args = argparse.Namespace( + simulate=False, fps=10, day="01", month="04", year="2025" + ) avv = AeonViewVideos(project_path, args) with pytest.raises(NotImplementedError): - avv.generate_monthly_video(Path('/tmp')) + avv.generate_monthly_video(Path("/tmp")) def test_generate_yearly_video_not_implemented(self): project_path = default_test_path - args = argparse.Namespace(simulate=False, fps=10, day='01', month='04', year='2025') + args = argparse.Namespace( + simulate=False, fps=10, day="01", month="04", year="2025" + ) avv = AeonViewVideos(project_path, args) with pytest.raises(NotImplementedError): - avv.generate_yearly_video(Path('/tmp')) + avv.generate_yearly_video(Path("/tmp")) class TestHelpersArguments(TestCase): def setUp(self): - self.default_dest = str(Path.cwd() / 'projects') + self.default_dest = str(Path.cwd() / "projects") @mock.patch( - 'sys.argv', ['aeonview.py', '--mode', 'image', '--url', f'{default_image_domain}.jpg'] + "sys.argv", + [ + "aeonview.py", + "--mode", + "image", + "--url", + f"{default_image_domain}.jpg", + ], ) def test_parse_arguments_image_mode(self): args, _ = AeonViewHelpers.parse_arguments() - self.assertEqual(args.mode, 'image') - self.assertEqual(args.url, f'{default_image_domain}.jpg') + self.assertEqual(args.mode, "image") + self.assertEqual(args.url, f"{default_image_domain}.jpg") self.assertEqual(args.dest, self.default_dest) - @mock.patch('sys.argv', ['aeonview.py', '--mode', 'video', '--project', f'{default_project}']) + @mock.patch( + "sys.argv", + ["aeonview.py", "--mode", "video", "--project", f"{default_project}"], + ) def test_parse_arguments_video_mode(self): args, _ = AeonViewHelpers.parse_arguments() - self.assertEqual(args.mode, 'video') - self.assertEqual(args.project, f'{default_project}') + self.assertEqual(args.mode, "video") + self.assertEqual(args.project, f"{default_project}") self.assertEqual(args.dest, self.default_dest) - @mock.patch('sys.argv', ['aeonview.py', '--mode', 'image', '--simulate']) + @mock.patch("sys.argv", ["aeonview.py", "--mode", "image", "--simulate"]) def test_parse_arguments_simulate_mode(self): args, _ = AeonViewHelpers.parse_arguments() - self.assertEqual(args.mode, 'image') + self.assertEqual(args.mode, "image") self.assertTrue(args.simulate) - @mock.patch('sys.argv', ['aeonview.py', '--mode', 'video', '--fps', '30']) + @mock.patch("sys.argv", ["aeonview.py", "--mode", "video", "--fps", "30"]) def test_parse_arguments_fps(self): args, _ = AeonViewHelpers.parse_arguments() - self.assertEqual(args.mode, 'video') - self.assertEqual(args.project, f'{default_project}') + self.assertEqual(args.mode, "video") + self.assertEqual(args.project, f"{default_project}") self.assertEqual(args.dest, self.default_dest) self.assertEqual(args.fps, 30) - @mock.patch('sys.argv', ['aeonview.py', '--mode', 'video', '--generate', '2023-10-01']) + @mock.patch( + "sys.argv", + ["aeonview.py", "--mode", "video", "--generate", "2023-10-01"], + ) def test_parse_arguments_generate_date(self): args, _ = AeonViewHelpers.parse_arguments() - self.assertEqual(args.mode, 'video') - self.assertEqual(args.generate, '2023-10-01') + self.assertEqual(args.mode, "video") + self.assertEqual(args.generate, "2023-10-01") - @mock.patch('sys.argv', ['aeonview.py', '--mode', 'image', '--verbose']) + @mock.patch("sys.argv", ["aeonview.py", "--mode", "image", "--verbose"]) def test_parse_arguments_verbose(self): args, _ = AeonViewHelpers.parse_arguments() - self.assertEqual(args.mode, 'image') + self.assertEqual(args.mode, "image") self.assertTrue(args.verbose) - @mock.patch('sys.argv', ['aeonview.py']) + @mock.patch("sys.argv", ["aeonview.py"]) def test_parse_arguments_defaults(self): args, _ = AeonViewHelpers.parse_arguments() - self.assertEqual(args.mode, 'image') - self.assertEqual(args.project, f'{default_project}') + self.assertEqual(args.mode, "image") + self.assertEqual(args.project, f"{default_project}") self.assertEqual(args.dest, self.default_dest) self.assertEqual(args.fps, 10) - self.assertEqual(args.timeframe, 'daily') + self.assertEqual(args.timeframe, "daily") self.assertFalse(args.simulate) self.assertFalse(args.verbose) class TestAeonViewSimulation(TestCase): - @mock.patch('aeonview.AeonViewHelpers.mkdir_p') - @mock.patch('aeonview.AeonViewImages.download_image') + @mock.patch("aeonview.AeonViewHelpers.mkdir_p") + @mock.patch("aeonview.AeonViewImages.download_image") def test_image_simulation(self, mock_download_image, mock_mkdir_p): args = mock.MagicMock() args.simulate = True - args.date = '2025-04-10 12:30:45' + args.date = "2025-04-10 12:30:45" - url = f'{default_image_domain}.jpg' - project_path = Path('/tmp/test_project').resolve() + url = f"{default_image_domain}.jpg" + project_path = Path("/tmp/test_project").resolve() avi = AeonViewImages(project_path, url, args) - with mock.patch('aeonview.logging.info') as mock_logging: + with mock.patch("aeonview.logging.info") as mock_logging: avi.get_current_image() mock_mkdir_p.assert_not_called() mock_download_image.assert_not_called() mock_logging.assert_any_call( - f'Saving image to {project_path}/img/2025-04/10/12-30-45.jpg' + f"Saving image to {project_path}/img/2025-04/10/12-30-45.jpg" ) - @mock.patch('aeonview.AeonViewHelpers.mkdir_p') - @mock.patch('subprocess.run') + @mock.patch("aeonview.AeonViewHelpers.mkdir_p") + @mock.patch("subprocess.run") def test_video_simulation(self, mock_subprocess_run, mock_mkdir_p): args = mock.MagicMock() args.simulate = True args.fps = 10 - args.day = '01' - args.month = '01' - args.year = '2023' - project_path = Path('/tmp/test_project').resolve() + args.day = "01" + args.month = "01" + args.year = "2023" + project_path = Path("/tmp/test_project").resolve() avv = AeonViewVideos(project_path, args) - with mock.patch('aeonview.logging.info') as mock_logging: + with mock.patch("aeonview.logging.info") as mock_logging: avv.generate_daily_video() mock_mkdir_p.assert_not_called() mock_subprocess_run.assert_not_called() - mock_logging.assert_any_call(f'Generating video from {project_path}/vid/2023-01/01') + mock_logging.assert_any_call( + f"Generating video from {project_path}/vid/2023-01/01" + ) class TestSetupLogger(TestCase): - @mock.patch('logging.basicConfig') + @mock.patch("logging.basicConfig") def test_setup_logger_verbose(self, mock_basic_config): AeonViewHelpers.setup_logger(verbose=True) mock_basic_config.assert_called_once_with( - level=logging.DEBUG, format='[%(levelname)s] %(message)s' + level=logging.DEBUG, format="[%(levelname)s] %(message)s" ) - @mock.patch('logging.basicConfig') + @mock.patch("logging.basicConfig") def test_setup_logger_non_verbose(self, mock_basic_config): AeonViewHelpers.setup_logger(verbose=False) mock_basic_config.assert_called_once_with( - level=logging.INFO, format='[%(levelname)s] %(message)s' + level=logging.INFO, format="[%(levelname)s] %(message)s" ) mock_basic_config.reset_mock() diff --git a/conftest.py b/conftest.py index b5fc6c1..12f27ef 100644 --- a/conftest.py +++ b/conftest.py @@ -6,4 +6,4 @@ import pytest @pytest.fixture(autouse=True) def no_network_requests(monkeypatch): - monkeypatch.setattr('subprocess.run', mock.Mock()) + monkeypatch.setattr("subprocess.run", mock.Mock()) diff --git a/pyproject.toml b/pyproject.toml index 581e602..8c66d37 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,5 +1,5 @@ [tool.ruff] -line-length = 100 +line-length = 80 target-version = "py311" [tool.ruff.lint] @@ -7,10 +7,10 @@ select = ["E", "F", "I", "B", "UP", "C4", "T20"] ignore = ["E501"] [tool.ruff.lint.per-file-ignores] -"test_*.py" = ["S101"] +"*_test.py" = ["S101"] [tool.ruff.format] -quote-style = "single" +quote-style = "double" indent-style = "space" [tool.pytest.ini_options]