mirror of
https://github.com/ivuorinen/aeonview.git
synced 2026-03-14 17:58:57 +00:00
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:
324
aeonview.py
324
aeonview.py
@@ -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()
|
||||||
|
|
||||||
|
|||||||
676
aeonview_test.py
676
aeonview_test.py
@@ -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()
|
||||||
|
|||||||
Reference in New Issue
Block a user