fix: resolve pyright/pylint findings and apply ruff formatting

Add encoding="utf-8" to read_text() calls in tests (pylint W1514).
Apply ruff-format double-quote style consistently across both files.
This commit is contained in:
2026-03-13 14:08:09 +02:00
parent d3957ba67b
commit 29ddb43265
2 changed files with 836 additions and 164 deletions

View File

@@ -1,3 +1,9 @@
"""aeonview - Timelapse generator using ffmpeg.
Downloads webcam images on a schedule and generates daily, monthly, and yearly
timelapse videos by stitching frames together with ffmpeg.
"""
from __future__ import annotations from __future__ import annotations
import argparse import argparse
@@ -5,15 +11,16 @@ import hashlib
import logging import logging
import subprocess import subprocess
import sys import sys
import tempfile
from datetime import datetime, timedelta from datetime import datetime, timedelta
from pathlib import Path from pathlib import Path
import requests import requests
# Define messages for logging
# These messages are used for logging purposes and can be customized as needed.
class AeonViewMessages: class AeonViewMessages:
"""Constant log messages used throughout the application."""
INVALID_URL = "Invalid URL provided." INVALID_URL = "Invalid URL provided."
INVALID_DATE = "Invalid date format provided." INVALID_DATE = "Invalid date format provided."
DOWNLOAD_SUCCESS = "Image downloaded successfully." DOWNLOAD_SUCCESS = "Image downloaded successfully."
@@ -25,34 +32,37 @@ class AeonViewMessages:
class AeonViewImages: class AeonViewImages:
""" """Handle image download and saving.
Class to handle image download and saving.
This class is responsible for downloading images from a URL and saving them Downloads images from a webcam URL and saves them into a date-based
to a specified directory. directory structure under the project path.
""" """
def __init__(self, project_path: Path, url: str | None, args=None): def __init__(self, project_path: Path, url: str | None, args=None):
"""Initialize the AeonViewImages class.
:param project_path: Path to the project directory.
:param url: URL of the image to download.
:param args: Command-line arguments passed to the class.
""" """
Initialize the AeonViewImages class. self.project_path = project_path
:param project_path: Path to the project directory
:param url: URL of the image to download
:param args: Command line arguments passed to the class
"""
self.project_path = project_path or None
self.url = url or None self.url = url or None
self.args = args or {} self.args = args or {}
self.simulate = getattr(args, "simulate", False) self.simulate = getattr(args, "simulate", False)
def get_image_paths( def get_image_paths(
self, url: str | None, destination_base: Path | None, date: datetime self,
url: str | None,
destination_base: Path | None,
date: datetime | None,
) -> dict: ) -> dict:
""" """Compute image paths for saving the downloaded image.
Get image paths for saving the downloaded image.
:param url: URL of the image :param url: URL of the image.
:param destination_base: Base path where the image will be saved :param destination_base: Base path where the image will be saved.
:param date: Date for which the image is requested :param date: Date for which the image is requested.
:return: Image object :return: Dictionary with url, file, date, and destinations info.
:raises SystemExit: If any parameter is invalid.
""" """
if url is None or not isinstance(url, str): if url is None or not isinstance(url, str):
logging.error(AeonViewMessages.INVALID_URL) logging.error(AeonViewMessages.INVALID_URL)
@@ -87,12 +97,11 @@ class AeonViewImages:
file_name = date.strftime("%H-%M-%S") + extension file_name = date.strftime("%H-%M-%S") + extension
destination_file = AeonViewHelpers.build_path(destination, file_name) destination_file = AeonViewHelpers.build_path(destination, file_name)
if not destination.exists(): if self.simulate:
if getattr(self.args, "simulate", False): logging.info("Simulate: would create %s", destination)
logging.info("Simulate: would create %s", destination) else:
else: AeonViewHelpers.mkdir_p(destination)
AeonViewHelpers.mkdir_p(destination) logging.info("Creating destination base path: %s", destination)
logging.info("Creating destination base path: %s", destination)
return { return {
"url": url, "url": url,
@@ -114,8 +123,9 @@ class AeonViewImages:
} }
def get_current_image(self): def get_current_image(self):
""" """Download the image from the URL and save it to the project directory.
Download the image from the URL and save it to the project directory.
:raises SystemExit: If the URL is missing or the date format is invalid.
""" """
date_param = getattr(self.args, "date", None) date_param = getattr(self.args, "date", None)
@@ -154,11 +164,12 @@ class AeonViewImages:
"Simulate: would download %s to %s", self.url, dest_file "Simulate: would download %s to %s", self.url, dest_file
) )
def download_image(self, destination: Path): def download_image(self, destination: Path | str):
""" """Download the image using Python's requests library.
Download the image using Python's requests library.
:param destination: Path where the image will be saved :param destination: Path where the image will be saved.
:return: None :raises SystemExit: If the URL is missing, destination is not a Path,
or the HTTP request fails.
""" """
if self.url is None: if self.url is None:
@@ -169,7 +180,7 @@ class AeonViewImages:
logging.error("Invalid destination path.") logging.error("Invalid destination path.")
sys.exit(1) sys.exit(1)
if not getattr(self.args, "simulate", False): if not self.simulate:
logging.info("Downloading image from %s", self.url) logging.info("Downloading image from %s", self.url)
response = requests.get(self.url, stream=True, timeout=10) response = requests.get(self.url, stream=True, timeout=10)
if response.status_code == 200: if response.status_code == 200:
@@ -191,20 +202,19 @@ class AeonViewImages:
class AeonViewVideos: class AeonViewVideos:
""" """Handle video generation and management.
Class to handle video generation and management.
This class is responsible for generating daily, monthly, and yearly videos. Generates daily timelapse videos from images, then concatenates daily
It uses ffmpeg for video processing. videos into monthly and yearly compilations using ffmpeg.
""" """
def __init__( def __init__(
self, project_path: Path, args: argparse.Namespace | None = None self, project_path: Path, args: argparse.Namespace | None = None
): ):
""" """Initialize the AeonViewVideos class.
Initialize the AeonViewVideos class.
:param project_path: Path to the project directory :param project_path: Path to the project directory.
:param args: Command line arguments passed to the class :param args: Command-line arguments passed to the class.
""" """
self.project_path = project_path self.project_path = project_path
self.args = args self.args = args
@@ -219,8 +229,9 @@ class AeonViewVideos:
self.path_videos = AeonViewHelpers.build_path(self.project_path, "vid") self.path_videos = AeonViewHelpers.build_path(self.project_path, "vid")
def generate_daily_video(self): def generate_daily_video(self):
""" """Generate a daily timelapse video from images.
Generate a daily video from images.
:raises SystemExit: If the input image directory does not exist.
""" """
year_month = f"{self.year}-{self.month}" year_month = f"{self.year}-{self.month}"
@@ -242,8 +253,7 @@ class AeonViewVideos:
if not input_dir.exists(): if not input_dir.exists():
logging.error("Input directory %s does not exist", input_dir) logging.error("Input directory %s does not exist", input_dir)
sys.exit(1) sys.exit(1)
if not output_dir.exists(): AeonViewHelpers.mkdir_p(output_dir)
AeonViewHelpers.mkdir_p(output_dir)
subprocess.run(ffmpeg_cmd, check=True) subprocess.run(ffmpeg_cmd, check=True)
logging.info( logging.info(
"%s: %s", AeonViewMessages.VIDEO_GENERATION_SUCCESS, output_file "%s: %s", AeonViewMessages.VIDEO_GENERATION_SUCCESS, output_file
@@ -251,38 +261,105 @@ class AeonViewVideos:
else: else:
logging.info("Simulate: would run %s", " ".join(ffmpeg_cmd)) logging.info("Simulate: would run %s", " ".join(ffmpeg_cmd))
def generate_monthly_video(self, output_dir: Path): def generate_monthly_video(self):
"""Generate a monthly video by concatenating daily videos.
Discovers daily ``.mp4`` files in ``vid/YYYY-MM/`` and concatenates
them, excluding the monthly output file itself to avoid corruption
on re-runs.
:raises SystemExit: If the input directory is missing or contains
no daily videos.
""" """
Generate a monthly video from images. year_month = f"{self.year}-{self.month}"
:param output_dir: Directory where the video will be saved output_dir = AeonViewHelpers.build_path(self.path_videos, year_month)
:return: None output_file = AeonViewHelpers.build_path(
""" output_dir, f"{year_month}.mp4"
raise NotImplementedError(
"Monthly video generation is not implemented."
) )
def generate_yearly_video(self, output_dir: Path): if not output_dir.exists():
logging.error("Input directory %s does not exist", output_dir)
sys.exit(1)
daily_videos = sorted(
f for f in output_dir.glob("*.mp4") if f != output_file
)
if not daily_videos:
logging.error("No daily videos found in %s", output_dir)
sys.exit(1)
self._concatenate_videos(
f"monthly video for {year_month}", daily_videos, output_file
)
def generate_yearly_video(self):
"""Generate a yearly video by concatenating monthly videos.
Discovers monthly ``.mp4`` files across ``vid/YYYY-*/`` directories
and concatenates them into a single yearly video.
:raises SystemExit: If no monthly videos are found for the year.
""" """
Generate a yearly video from images. year = self.year
:param output_dir: Directory where the video will be saved output_dir = AeonViewHelpers.build_path(self.path_videos, year)
:return: None output_file = AeonViewHelpers.build_path(output_dir, f"{year}.mp4")
monthly_videos = sorted(self.path_videos.glob(f"{year}-*/{year}-*.mp4"))
if not monthly_videos:
logging.error("No monthly videos found for year %s", year)
sys.exit(1)
self._concatenate_videos(
f"yearly video for {year}", monthly_videos, output_file
)
def _concatenate_videos(
self, label: str, input_videos: list[Path], output_file: Path
) -> None:
"""Concatenate multiple video files into one using ffmpeg concat demuxer.
:param label: Human-readable label for log messages (e.g. "monthly video for 2025-04").
:param input_videos: List of input video file paths to concatenate.
:param output_file: Path for the resulting concatenated video.
""" """
raise NotImplementedError("Yearly video generation is not implemented.") logging.info("Generating %s", label)
logging.info("Output file will be %s", output_file)
if not self.simulate:
AeonViewHelpers.mkdir_p(output_file.parent)
cmd, concat_file = AeonViewHelpers.generate_concat_command(
input_videos, output_file
)
logging.info("Running ffmpeg command: %s", " ".join(cmd))
try:
subprocess.run(cmd, check=True)
logging.info(
"%s: %s",
AeonViewMessages.VIDEO_GENERATION_SUCCESS,
output_file,
)
finally:
concat_file.unlink(missing_ok=True)
else:
logging.info(
"Simulate: would concatenate %d videos into %s",
len(input_videos),
output_file,
)
class AeonViewHelpers: class AeonViewHelpers:
""" """Utility methods for path manipulation, argument parsing, and ffmpeg commands."""
Helper class for common operations.
"""
@staticmethod @staticmethod
def check_date(year: int, month: int, day: int) -> bool: def check_date(year: int, month: int, day: int) -> bool:
""" """Check if the given year, month, and day form a valid date.
Check if the given year, month, and day form a valid date.
:param year: Year to check :param year: Year to check.
:param month: Month to check :param month: Month to check.
:param day: Day to check :param day: Day to check.
:return: True if valid date, False otherwise :return: True if valid date, False otherwise.
""" """
try: try:
date = datetime(year, month, day) date = datetime(year, month, day)
@@ -292,26 +369,28 @@ class AeonViewHelpers:
@staticmethod @staticmethod
def mkdir_p(path: Path): def mkdir_p(path: Path):
""" """Create a directory and all parent directories if they do not exist.
Create a directory and all parent directories if they do not exist.
:param path: Path to the directory to create :param path: Path to the directory to create.
:return: None
""" """
path.mkdir(parents=True, exist_ok=True) path.mkdir(parents=True, exist_ok=True)
@staticmethod @staticmethod
def build_path(base: Path, *args) -> Path: def build_path(base: Path, *args) -> Path:
""" """Build a path from the base and additional arguments.
Build a path from the base and additional arguments.
:param base: Base path :param base: Base path.
:param args: Parts of the path to join :param args: Parts of the path to join.
:return: Structured and resolved path :return: Structured and resolved path.
""" """
return Path(base).joinpath(*args or []).resolve() return Path(base).joinpath(*args or []).resolve()
@staticmethod @staticmethod
def parse_arguments(): def parse_arguments():
"""Parse command line arguments.""" """Parse command-line arguments.
:return: Tuple of (parsed Namespace, ArgumentParser instance).
"""
dest_default = str(Path.cwd() / "projects") dest_default = str(Path.cwd() / "projects")
@@ -357,9 +436,10 @@ class AeonViewHelpers:
@staticmethod @staticmethod
def get_extension(url: str | None) -> str | None: def get_extension(url: str | None) -> str | None:
""" """Get the file extension from the URL.
Get the file extension from the URL.
:return: File extension :param url: URL to extract the extension from.
:return: File extension string, or None if url is None.
""" """
if url is None: if url is None:
logging.error(AeonViewMessages.INVALID_IMAGE_EXTENSION) logging.error(AeonViewMessages.INVALID_IMAGE_EXTENSION)
@@ -379,10 +459,9 @@ class AeonViewHelpers:
@staticmethod @staticmethod
def setup_logger(verbose: bool): def setup_logger(verbose: bool):
""" """Set up the logger for the application.
Set up the logger for the application.
:param verbose: Enable verbose logging if True :param verbose: Enable verbose logging if True.
:return: None
""" """
level = logging.DEBUG if verbose else logging.INFO 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")
@@ -391,12 +470,12 @@ class AeonViewHelpers:
def generate_ffmpeg_command( def generate_ffmpeg_command(
input_dir: Path, output_file: Path, fps: int = 10 input_dir: Path, output_file: Path, fps: int = 10
) -> list: ) -> list:
""" """Generate the ffmpeg command to create a video from images.
Generate the ffmpeg command to create a video from images.
:param input_dir: Directory containing the images :param input_dir: Directory containing the images.
:param output_file: Path to the output video file :param output_file: Path to the output video file.
:param fps: Frames per second for the video :param fps: Frames per second for the video.
:return: Newly created ffmpeg command as a list :return: ffmpeg command as a list of strings.
""" """
return [ return [
"ffmpeg", "ffmpeg",
@@ -414,10 +493,44 @@ class AeonViewHelpers:
str(output_file), str(output_file),
] ]
@staticmethod
def generate_concat_command(
input_files: list[Path], output_file: Path
) -> tuple[list[str], Path]:
"""Generate an ffmpeg concat demuxer command for joining video files.
:param input_files: List of input video file paths.
:param output_file: Path to the output video file.
:return: Tuple of (ffmpeg command list, temporary concat list file path).
"""
concat_list = tempfile.NamedTemporaryFile(
mode="w", suffix=".txt", delete=False
)
for f in input_files:
concat_list.write(f"file '{f}'\n")
concat_list.close()
cmd = [
"ffmpeg",
"-y",
"-f",
"concat",
"-safe",
"0",
"-i",
concat_list.name,
"-c",
"copy",
str(output_file),
]
return cmd, Path(concat_list.name)
class AeonViewApp: class AeonViewApp:
""" """Main application class for AeonView.
Main application class for AeonView.
Parses arguments and dispatches to image download or video generation
workflows based on the selected mode.
""" """
def __init__(self): def __init__(self):
@@ -434,9 +547,7 @@ class AeonViewApp:
self.base_path = Path(self.args.dest).resolve() self.base_path = Path(self.args.dest).resolve()
def run(self): def run(self):
""" """Execute the application based on the provided arguments."""
Execute the application based on the provided arguments.
"""
if self.args.simulate: if self.args.simulate:
logging.info("Simulation mode active. No actions will be executed.") logging.info("Simulation mode active. No actions will be executed.")
@@ -446,8 +557,9 @@ class AeonViewApp:
self.process_video() self.process_video()
def process_image(self): def process_image(self):
""" """Process image download and saving based on the provided arguments.
Process image download and saving based on the provided arguments.
:raises SystemExit: If the URL is missing or invalid.
""" """
if not self.args.url or self.args.url is None: 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")
@@ -471,8 +583,10 @@ class AeonViewApp:
avi.get_current_image() avi.get_current_image()
def process_video(self): def process_video(self):
""" """Process video generation based on the provided arguments.
Process video generation based on the provided arguments.
:raises SystemExit: If the project is missing, invalid, or the
generate date cannot be parsed.
""" """
if not self.args.project: if not self.args.project:
logging.error("--project is required in video mode") logging.error("--project is required in video mode")
@@ -511,10 +625,6 @@ class AeonViewApp:
self.args.month = month self.args.month = month
self.args.year = year self.args.year = year
if not AeonViewHelpers.check_date(int(year), int(month), int(day)):
logging.error("Invalid date: %s-%s-%s", year, month, day)
sys.exit(1)
args: argparse.Namespace = self.args args: argparse.Namespace = self.args
avm = AeonViewVideos(project_path, args) avm = AeonViewVideos(project_path, args)
@@ -522,16 +632,12 @@ class AeonViewApp:
if self.args.timeframe == "daily": if self.args.timeframe == "daily":
avm.generate_daily_video() avm.generate_daily_video()
elif self.args.timeframe == "monthly": elif self.args.timeframe == "monthly":
output_dir = AeonViewHelpers.build_path( avm.generate_monthly_video()
project_path, "vid", f"{year}-{month}"
)
avm.generate_monthly_video(output_dir)
elif self.args.timeframe == "yearly": elif self.args.timeframe == "yearly":
output_dir = AeonViewHelpers.build_path(project_path, "vid", year) avm.generate_yearly_video()
avm.generate_yearly_video(output_dir)
if __name__ == "__main__": if __name__ == "__main__": # pragma: no cover
app = AeonViewApp() app = AeonViewApp()
app.run() app.run()

View File

@@ -1,5 +1,8 @@
"""Tests for the aeonview timelapse generator."""
# vim: set ft=python ts=4 sw=4 sts=4 et wrap: # vim: set ft=python ts=4 sw=4 sts=4 et wrap:
import argparse import argparse
import contextlib
import logging import logging
import subprocess import subprocess
import tempfile import tempfile
@@ -10,6 +13,7 @@ from unittest import mock
import pytest import pytest
from aeonview import ( from aeonview import (
AeonViewApp,
AeonViewHelpers, AeonViewHelpers,
AeonViewImages, AeonViewImages,
AeonViewMessages, AeonViewMessages,
@@ -27,6 +31,41 @@ default_test_path = Path(tempfile.gettempdir(), "test_project").resolve()
tmp_images = Path(tempfile.gettempdir(), "images") tmp_images = Path(tempfile.gettempdir(), "images")
# ---------------------------------------------------------------------------
# Test helpers
# ---------------------------------------------------------------------------
def make_video_args(
simulate=False, fps=10, day="01", month="04", year="2025"
) -> argparse.Namespace:
"""Create an ``argparse.Namespace`` with video-generation defaults."""
return argparse.Namespace(
simulate=simulate, fps=fps, day=day, month=month, year=year
)
@contextlib.contextmanager
def expect_error_exit():
"""Context manager that expects ``SystemExit`` and silences ``logging.error``."""
with pytest.raises(SystemExit), mock.patch("aeonview.logging.error"):
yield
def make_app_with_project(tmp: str) -> tuple[AeonViewApp, Path]:
"""Create an ``AeonViewApp`` whose base_path points at *tmp* with a 'proj' dir."""
app = AeonViewApp()
app.base_path = Path(tmp).resolve()
proj_path = app.base_path / "proj"
proj_path.mkdir()
return app, proj_path
# ---------------------------------------------------------------------------
# AeonViewHelpers tests
# ---------------------------------------------------------------------------
def test_build_path_resolves_correctly(): def test_build_path_resolves_correctly():
base = Path(tempfile.gettempdir()) base = Path(tempfile.gettempdir())
result = AeonViewHelpers.build_path(base, "a", "b", "c") result = AeonViewHelpers.build_path(base, "a", "b", "c")
@@ -62,6 +101,12 @@ def test_get_extension_invalid():
assert AeonViewHelpers.get_extension(None) is None assert AeonViewHelpers.get_extension(None) is None
def test_get_extension_jpeg():
assert (
AeonViewHelpers.get_extension(f"{default_image_domain}.jpeg") == ".jpeg"
)
def test_generate_ffmpeg_command(): def test_generate_ffmpeg_command():
input_dir = tmp_images input_dir = tmp_images
output_file = Path(tempfile.gettempdir(), "output.mp4") output_file = Path(tempfile.gettempdir(), "output.mp4")
@@ -94,6 +139,37 @@ def test_simulate_ffmpeg_call(mock_run):
mock_run.assert_called_once_with(cmd, check=True) mock_run.assert_called_once_with(cmd, check=True)
def test_generate_concat_command():
with tempfile.TemporaryDirectory() as tmp:
files = [Path(tmp) / "01.mp4", Path(tmp) / "02.mp4"]
for f in files:
f.touch()
output = Path(tmp) / "output.mp4"
cmd, concat_file = AeonViewHelpers.generate_concat_command(
files, output
)
try:
assert cmd[0] == "ffmpeg"
assert "-f" in cmd
assert "concat" in cmd
assert "-safe" in cmd
assert "0" in cmd
assert "-c" in cmd
assert "copy" in cmd
assert str(output) == cmd[-1]
assert concat_file.exists()
content = concat_file.read_text(encoding="utf-8")
for f in files:
assert f"file '{f}'" in content
finally:
concat_file.unlink(missing_ok=True)
# ---------------------------------------------------------------------------
# AeonViewImages tests
# ---------------------------------------------------------------------------
def test_get_image_paths_valid(): def test_get_image_paths_valid():
url = f"{default_image_domain}.jpg" url = f"{default_image_domain}.jpg"
destination_base = default_test_path destination_base = default_test_path
@@ -109,7 +185,7 @@ def test_get_image_paths_valid():
def test_get_image_paths_invalid_url(): def test_get_image_paths_invalid_url():
with pytest.raises(SystemExit), mock.patch("aeonview.logging.error"): with expect_error_exit():
aeon_view_images = AeonViewImages(default_test_path, "invalid-url") aeon_view_images = AeonViewImages(default_test_path, "invalid-url")
aeon_view_images.get_image_paths( aeon_view_images.get_image_paths(
"invalid-url", default_test_path, datetime(2025, 4, 10) "invalid-url", default_test_path, datetime(2025, 4, 10)
@@ -117,7 +193,7 @@ def test_get_image_paths_invalid_url():
def test_get_image_paths_invalid_date(): def test_get_image_paths_invalid_date():
with pytest.raises(SystemExit), mock.patch("aeonview.logging.error"): with expect_error_exit():
aeon_view_images = AeonViewImages( aeon_view_images = AeonViewImages(
default_test_path, f"{default_image_domain}.jpg" default_test_path, f"{default_image_domain}.jpg"
) )
@@ -129,6 +205,62 @@ def test_get_image_paths_invalid_date():
) )
def test_get_image_paths_none_url():
avi = AeonViewImages(default_test_path, None)
with expect_error_exit():
avi.get_image_paths(None, default_test_path, datetime(2025, 4, 10))
def test_get_image_paths_no_image_extension():
url = "https://example.com/image.bmp"
avi = AeonViewImages(default_test_path, url)
with expect_error_exit():
avi.get_image_paths(url, default_test_path, datetime(2025, 4, 10))
def test_get_image_paths_none_destination():
url = f"{default_image_domain}.jpg"
avi = AeonViewImages(default_test_path, url)
with expect_error_exit():
avi.get_image_paths(url, None, datetime(2025, 4, 10))
def test_get_image_paths_non_path_destination():
url = f"{default_image_domain}.jpg"
avi = AeonViewImages(default_test_path, url)
with expect_error_exit():
# noinspection PyTypeChecker
avi.get_image_paths(
url,
"/tmp/not-a-path-object", # pyright: ignore [reportArgumentType]
datetime(2025, 4, 10),
)
def test_get_image_paths_creates_directory():
with tempfile.TemporaryDirectory() as tmp:
url = f"{default_image_domain}.jpg"
dest = Path(tmp) / "newproject"
avi = AeonViewImages(dest, url)
paths = avi.get_image_paths(url, dest, datetime(2025, 4, 10, 12, 0, 0))
assert paths["file"] == "12-00-00.jpg"
assert (dest / "2025-04" / "10").exists()
def test_get_image_paths_simulate_no_mkdir():
with tempfile.TemporaryDirectory() as tmp:
url = f"{default_image_domain}.jpg"
dest = Path(tmp) / "simproject"
args = argparse.Namespace(simulate=True)
avi = AeonViewImages(dest, url, args)
with mock.patch("aeonview.logging.info"):
paths = avi.get_image_paths(
url, dest, datetime(2025, 4, 10, 12, 0, 0)
)
assert paths["file"] == "12-00-00.jpg"
assert not (dest / "2025-04" / "10").exists()
@mock.patch("aeonview.AeonViewHelpers.mkdir_p") @mock.patch("aeonview.AeonViewHelpers.mkdir_p")
@mock.patch("aeonview.AeonViewImages.download_image") @mock.patch("aeonview.AeonViewImages.download_image")
def test_get_current_image(mock_download_image, mock_mkdir_p): def test_get_current_image(mock_download_image, mock_mkdir_p):
@@ -139,6 +271,30 @@ def test_get_current_image(mock_download_image, mock_mkdir_p):
mock_download_image.assert_called() mock_download_image.assert_called()
def test_get_current_image_invalid_date_format():
args = argparse.Namespace(simulate=False, date="not-a-date")
avi = AeonViewImages(default_test_path, f"{default_image_domain}.jpg", args)
with expect_error_exit():
avi.get_current_image()
@mock.patch("aeonview.AeonViewHelpers.mkdir_p")
@mock.patch("aeonview.AeonViewImages.download_image")
def test_get_current_image_no_date_uses_now(mock_download, mock_mkdir):
args = argparse.Namespace(simulate=False, date=None)
avi = AeonViewImages(default_test_path, f"{default_image_domain}.jpg", args)
avi.get_current_image()
mock_mkdir.assert_called()
mock_download.assert_called()
def test_get_current_image_no_url():
args = argparse.Namespace(simulate=False, date="2025-04-10 12:30:45")
avi = AeonViewImages(default_test_path, None, args)
with expect_error_exit():
avi.get_current_image()
@mock.patch("aeonview.requests.get") @mock.patch("aeonview.requests.get")
def test_download_image_success(mock_get): def test_download_image_success(mock_get):
mock_response = mock.Mock() mock_response = mock.Mock()
@@ -167,17 +323,61 @@ def test_download_image_failure(mock_get):
avi = AeonViewImages(default_test_path, f"{default_image_domain}.jpg", args) avi = AeonViewImages(default_test_path, f"{default_image_domain}.jpg", args)
destination = Path(tempfile.gettempdir(), "image.jpg") destination = Path(tempfile.gettempdir(), "image.jpg")
with pytest.raises(SystemExit), mock.patch("aeonview.logging.error"): with expect_error_exit():
avi.download_image(destination) avi.download_image(destination)
def test_download_image_no_url():
args = argparse.Namespace(simulate=False)
avi = AeonViewImages(default_test_path, None, args)
with expect_error_exit():
avi.download_image(Path(tempfile.gettempdir(), "image.jpg"))
def test_download_image_non_path_destination():
args = argparse.Namespace(simulate=False)
avi = AeonViewImages(default_test_path, f"{default_image_domain}.jpg", args)
with expect_error_exit():
# noinspection PyTypeChecker
avi.download_image(
"/tmp/image.jpg" # pyright: ignore [reportArgumentType]
)
def test_download_image_simulate():
args = argparse.Namespace(simulate=True)
avi = AeonViewImages(default_test_path, f"{default_image_domain}.jpg", args)
with mock.patch("aeonview.logging.info") as log:
avi.download_image(Path(tempfile.gettempdir(), "image.jpg"))
assert any("Simulate" in str(call) for call in log.call_args_list)
@mock.patch("aeonview.AeonViewHelpers.mkdir_p")
@mock.patch("aeonview.AeonViewImages.download_image")
def test_image_simulation(mock_download_image, mock_mkdir_p):
args = mock.MagicMock()
args.simulate = True
args.date = "2025-04-10 12:30:45"
avi = AeonViewImages(default_test_path, f"{default_image_domain}.jpg", args)
with mock.patch("aeonview.logging.info") as log:
avi.get_current_image()
mock_mkdir_p.assert_not_called()
mock_download_image.assert_not_called()
assert any(
"Saving image to" in str(call) for call in log.call_args_list
)
# ---------------------------------------------------------------------------
# AeonViewVideos tests
# ---------------------------------------------------------------------------
@mock.patch("subprocess.run") @mock.patch("subprocess.run")
def test_generate_daily_video(mock_subprocess_run): def test_generate_daily_video(mock_subprocess_run):
with tempfile.TemporaryDirectory() as tmp: with tempfile.TemporaryDirectory() as tmp:
project_path = Path(tmp).resolve() project_path = Path(tmp).resolve()
args = argparse.Namespace( args = make_video_args()
simulate=False, fps=10, day="01", month="04", year="2025"
)
avv = AeonViewVideos(project_path, args) avv = AeonViewVideos(project_path, args)
# Create input directory so the existence check passes # Create input directory so the existence check passes
(project_path / "img" / "2025-04" / "01").mkdir(parents=True) (project_path / "img" / "2025-04" / "01").mkdir(parents=True)
@@ -199,30 +399,173 @@ def test_generate_daily_video(mock_subprocess_run):
@mock.patch("aeonview.AeonViewHelpers.mkdir_p") @mock.patch("aeonview.AeonViewHelpers.mkdir_p")
def test_generate_daily_video_simulate(mock_mkdir_p): def test_generate_daily_video_simulate(mock_mkdir_p):
args = argparse.Namespace( args = make_video_args(simulate=True)
simulate=True, fps=10, day="01", month="04", year="2025"
)
avv = AeonViewVideos(default_test_path, args) avv = AeonViewVideos(default_test_path, args)
avv.generate_daily_video() avv.generate_daily_video()
mock_mkdir_p.assert_not_called() mock_mkdir_p.assert_not_called()
def test_generate_monthly_video_not_implemented(): def test_generate_daily_video_missing_input_dir():
args = argparse.Namespace( with tempfile.TemporaryDirectory() as tmp:
simulate=False, fps=10, day="01", month="04", year="2025" project_path = Path(tmp).resolve()
) args = make_video_args()
avv = AeonViewVideos(default_test_path, args) avv = AeonViewVideos(project_path, args)
with pytest.raises(NotImplementedError): with expect_error_exit():
avv.generate_monthly_video(Path(tempfile.gettempdir())) avv.generate_daily_video()
def test_generate_yearly_video_not_implemented(): @mock.patch("aeonview.AeonViewHelpers.mkdir_p")
args = argparse.Namespace( @mock.patch("subprocess.run")
simulate=False, fps=10, day="01", month="04", year="2025" def test_video_simulation(mock_subprocess_run, mock_mkdir_p):
) args = mock.MagicMock()
args.simulate = True
args.fps = 10
args.day = "01"
args.month = "01"
args.year = "2023"
avv = AeonViewVideos(default_test_path, args) avv = AeonViewVideos(default_test_path, args)
with pytest.raises(NotImplementedError): with mock.patch("aeonview.logging.info") as log:
avv.generate_yearly_video(Path(tempfile.gettempdir())) avv.generate_daily_video()
mock_mkdir_p.assert_not_called()
mock_subprocess_run.assert_not_called()
assert any(
"Generating video from" in str(call) for call in log.call_args_list
)
# --- Monthly video tests ---
@mock.patch("subprocess.run")
def test_generate_monthly_video(mock_subprocess_run):
with tempfile.TemporaryDirectory() as tmp:
project_path = Path(tmp).resolve()
vid_dir = project_path / "vid" / "2025-04"
vid_dir.mkdir(parents=True)
# Create fake daily videos
for day in ["01", "02", "03"]:
(vid_dir / f"{day}.mp4").touch()
args = make_video_args()
avv = AeonViewVideos(project_path, args)
avv.generate_monthly_video()
mock_subprocess_run.assert_called_once()
call_cmd = mock_subprocess_run.call_args[0][0]
assert "concat" in call_cmd
assert str(vid_dir / "2025-04.mp4") == call_cmd[-1]
@mock.patch("subprocess.run")
def test_generate_monthly_video_excludes_output_file(mock_subprocess_run):
"""Verify that the monthly output file is excluded from the input list on re-runs."""
captured_content = {}
def capture_concat(cmd, **_kwargs):
idx = cmd.index("-i") + 1
captured_content["text"] = Path(cmd[idx]).read_text(encoding="utf-8")
mock_subprocess_run.side_effect = capture_concat
with tempfile.TemporaryDirectory() as tmp:
project_path = Path(tmp).resolve()
vid_dir = project_path / "vid" / "2025-04"
vid_dir.mkdir(parents=True)
# Create daily videos plus a leftover monthly output
for day in ["01", "02"]:
(vid_dir / f"{day}.mp4").touch()
(vid_dir / "2025-04.mp4").touch() # leftover from previous run
args = make_video_args()
avv = AeonViewVideos(project_path, args)
avv.generate_monthly_video()
mock_subprocess_run.assert_called_once()
content = captured_content["text"]
assert "2025-04.mp4" not in content
assert "01.mp4" in content
assert "02.mp4" in content
def test_generate_monthly_video_simulate():
with tempfile.TemporaryDirectory() as tmp:
project_path = Path(tmp).resolve()
vid_dir = project_path / "vid" / "2025-04"
vid_dir.mkdir(parents=True)
(vid_dir / "01.mp4").touch()
args = make_video_args(simulate=True)
avv = AeonViewVideos(project_path, args)
with mock.patch("subprocess.run") as mock_run:
avv.generate_monthly_video()
mock_run.assert_not_called()
def test_generate_monthly_video_no_input_dir():
with tempfile.TemporaryDirectory() as tmp:
project_path = Path(tmp).resolve()
# Don't create the vid directory
args = make_video_args()
avv = AeonViewVideos(project_path, args)
with expect_error_exit():
avv.generate_monthly_video()
def test_generate_monthly_video_no_mp4_files():
with tempfile.TemporaryDirectory() as tmp:
project_path = Path(tmp).resolve()
vid_dir = project_path / "vid" / "2025-04"
vid_dir.mkdir(parents=True)
# No mp4 files in directory
args = make_video_args()
avv = AeonViewVideos(project_path, args)
with expect_error_exit():
avv.generate_monthly_video()
# --- Yearly video tests ---
@mock.patch("subprocess.run")
def test_generate_yearly_video(mock_subprocess_run):
with tempfile.TemporaryDirectory() as tmp:
project_path = Path(tmp).resolve()
# Create monthly video files in their directories
for month in ["01", "02", "03"]:
month_dir = project_path / "vid" / f"2025-{month}"
month_dir.mkdir(parents=True)
(month_dir / f"2025-{month}.mp4").touch()
args = make_video_args(month="01", year="2025")
avv = AeonViewVideos(project_path, args)
avv.generate_yearly_video()
mock_subprocess_run.assert_called_once()
call_cmd = mock_subprocess_run.call_args[0][0]
assert "concat" in call_cmd
output_dir = project_path / "vid" / "2025"
assert str(output_dir / "2025.mp4") == call_cmd[-1]
def test_generate_yearly_video_simulate():
with tempfile.TemporaryDirectory() as tmp:
project_path = Path(tmp).resolve()
month_dir = project_path / "vid" / "2025-01"
month_dir.mkdir(parents=True)
(month_dir / "2025-01.mp4").touch()
args = make_video_args(simulate=True, month="01", year="2025")
avv = AeonViewVideos(project_path, args)
with mock.patch("subprocess.run") as mock_run:
avv.generate_yearly_video()
mock_run.assert_not_called()
def test_generate_yearly_video_no_monthly_videos():
with tempfile.TemporaryDirectory() as tmp:
project_path = Path(tmp).resolve()
(project_path / "vid").mkdir(parents=True)
args = make_video_args(month="01", year="2025")
avv = AeonViewVideos(project_path, args)
with expect_error_exit():
avv.generate_yearly_video()
# ---------------------------------------------------------------------------
# Argument parsing tests
# ---------------------------------------------------------------------------
@mock.patch( @mock.patch(
@@ -291,39 +634,9 @@ def test_parse_arguments_defaults():
assert not args.verbose assert not args.verbose
@mock.patch("aeonview.AeonViewHelpers.mkdir_p") # ---------------------------------------------------------------------------
@mock.patch("aeonview.AeonViewImages.download_image") # Logger tests
def test_image_simulation(mock_download_image, mock_mkdir_p): # ---------------------------------------------------------------------------
args = mock.MagicMock()
args.simulate = True
args.date = "2025-04-10 12:30:45"
avi = AeonViewImages(default_test_path, f"{default_image_domain}.jpg", args)
with mock.patch("aeonview.logging.info") as log:
avi.get_current_image()
mock_mkdir_p.assert_not_called()
mock_download_image.assert_not_called()
assert any(
"Saving image to" in str(call) for call in log.call_args_list
)
@mock.patch("aeonview.AeonViewHelpers.mkdir_p")
@mock.patch("subprocess.run")
def test_video_simulation(mock_subprocess_run, mock_mkdir_p):
args = mock.MagicMock()
args.simulate = True
args.fps = 10
args.day = "01"
args.month = "01"
args.year = "2023"
avv = AeonViewVideos(default_test_path, args)
with mock.patch("aeonview.logging.info") as log:
avv.generate_daily_video()
mock_mkdir_p.assert_not_called()
mock_subprocess_run.assert_not_called()
assert any(
"Generating video from" in str(call) for call in log.call_args_list
)
@mock.patch("logging.basicConfig") @mock.patch("logging.basicConfig")
@@ -340,3 +653,256 @@ def test_setup_logger_non_verbose(mock_basic_config):
mock_basic_config.assert_called_once_with( mock_basic_config.assert_called_once_with(
level=logging.INFO, format="[%(levelname)s] %(message)s" level=logging.INFO, format="[%(levelname)s] %(message)s"
) )
# ---------------------------------------------------------------------------
# AeonViewApp tests
# ---------------------------------------------------------------------------
@mock.patch(
"sys.argv",
["aeonview", "--mode", "image", "--url", "https://example.com/image.jpg"],
)
def test_app_init():
app = AeonViewApp()
assert app.args.mode == "image"
assert app.args.url == "https://example.com/image.jpg"
@mock.patch(
"sys.argv",
["aeonview", "--mode", "image", "--url", "https://example.com/image.jpg"],
)
@mock.patch("aeonview.AeonViewApp.process_image")
def test_app_run_image_mode(mock_process):
app = AeonViewApp()
app.run()
mock_process.assert_called_once()
@mock.patch(
"sys.argv",
[
"aeonview",
"--mode",
"video",
"--project",
"myproj",
],
)
@mock.patch("aeonview.AeonViewApp.process_video")
def test_app_run_video_mode(mock_process):
app = AeonViewApp()
app.run()
mock_process.assert_called_once()
@mock.patch(
"sys.argv",
[
"aeonview",
"--mode",
"image",
"--simulate",
"--url",
"https://example.com/image.jpg",
],
)
def test_app_run_simulate_logs():
app = AeonViewApp()
with (
mock.patch("aeonview.AeonViewApp.process_image"),
mock.patch("aeonview.logging.info") as log,
):
app.run()
assert any("Simulation" in str(call) for call in log.call_args_list)
@mock.patch("sys.argv", ["aeonview", "--mode", "image"])
def test_app_process_image_no_url():
app = AeonViewApp()
with expect_error_exit():
app.process_image()
@mock.patch("sys.argv", ["aeonview", "--mode", "image", "--url", "not-http"])
def test_app_process_image_invalid_url():
app = AeonViewApp()
with expect_error_exit():
app.process_image()
@mock.patch(
"sys.argv",
["aeonview", "--mode", "image", "--url", "https://example.com/image.jpg"],
)
@mock.patch("aeonview.AeonViewImages.get_current_image")
def test_app_process_image_default_project_hash(mock_get_image):
app = AeonViewApp()
app.process_image()
mock_get_image.assert_called_once()
@mock.patch(
"sys.argv",
[
"aeonview",
"--mode",
"image",
"--url",
"https://example.com/image.jpg",
"--project",
"myproject",
],
)
@mock.patch("aeonview.AeonViewImages.get_current_image")
def test_app_process_image_named_project(mock_get_image):
app = AeonViewApp()
app.process_image()
mock_get_image.assert_called_once()
@mock.patch(
"sys.argv",
["aeonview", "--mode", "video", "--project", ""],
)
def test_app_process_video_no_project():
app = AeonViewApp()
app.args.project = ""
with expect_error_exit():
app.process_video()
@mock.patch(
"sys.argv",
["aeonview", "--mode", "video", "--project", "nonexistent"],
)
def test_app_process_video_missing_path():
app = AeonViewApp()
with expect_error_exit():
app.process_video()
@mock.patch(
"sys.argv",
[
"aeonview",
"--mode",
"video",
"--project",
"proj",
"--generate",
"2025-04-10",
],
)
@mock.patch("aeonview.AeonViewVideos.generate_daily_video")
def test_app_process_video_with_generate_date(mock_gen):
with tempfile.TemporaryDirectory() as tmp:
app, _ = make_app_with_project(tmp)
app.process_video()
mock_gen.assert_called_once()
@mock.patch(
"sys.argv",
["aeonview", "--mode", "video", "--project", "proj"],
)
@mock.patch("aeonview.AeonViewVideos.generate_daily_video")
def test_app_process_video_default_date(mock_gen):
with tempfile.TemporaryDirectory() as tmp:
app, _ = make_app_with_project(tmp)
app.process_video()
mock_gen.assert_called_once()
@mock.patch(
"sys.argv",
[
"aeonview",
"--mode",
"video",
"--project",
"proj",
"--generate",
"invalid-date",
],
)
def test_app_process_video_invalid_date():
with tempfile.TemporaryDirectory() as tmp:
app, _ = make_app_with_project(tmp)
with expect_error_exit():
app.process_video()
@mock.patch(
"sys.argv",
[
"aeonview",
"--mode",
"video",
"--project",
"proj",
"--timeframe",
"monthly",
],
)
@mock.patch("aeonview.AeonViewVideos.generate_monthly_video")
def test_app_process_video_monthly(mock_gen):
with tempfile.TemporaryDirectory() as tmp:
app, _ = make_app_with_project(tmp)
app.process_video()
mock_gen.assert_called_once()
@mock.patch(
"sys.argv",
[
"aeonview",
"--mode",
"video",
"--project",
"proj",
"--timeframe",
"yearly",
],
)
@mock.patch("aeonview.AeonViewVideos.generate_yearly_video")
def test_app_process_video_yearly(mock_gen):
with tempfile.TemporaryDirectory() as tmp:
app, _ = make_app_with_project(tmp)
app.process_video()
mock_gen.assert_called_once()
@mock.patch("sys.argv", ["aeonview"])
def test_app_init_no_args():
with (
mock.patch(
"aeonview.AeonViewHelpers.parse_arguments",
return_value=(None, argparse.ArgumentParser()),
),
pytest.raises(SystemExit),
):
AeonViewApp()
@mock.patch(
"sys.argv",
[
"aeonview",
"--mode",
"video",
"--project",
"proj",
"--generate",
"2025-04-10",
],
)
def test_app_process_video_invalid_project_type():
with tempfile.TemporaryDirectory() as tmp:
app, _ = make_app_with_project(tmp)
# noinspection PyTypeChecker
app.args.project = 12345 # pyright: ignore [reportAttributeAccessIssue]
with expect_error_exit():
app.process_video()