chore: saving the wip state

This commit is contained in:
2025-04-29 18:09:11 +03:00
parent dd9f538bb4
commit a0b066cefe
6 changed files with 372 additions and 247 deletions

View File

@@ -6,7 +6,7 @@ root = true
[*]
charset = utf-8
end_of_line = lf
max_line_length = 100
max_line_length = 80
insert_final_newline = true
indent_style = space
indent_size = 2

View File

@@ -1,6 +1,11 @@
repos:
- repo: https://github.com/asottile/pyupgrade
rev: v3.19.1
hooks:
- id: pyupgrade
args: [--py3-plus]
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: "v0.11.4"
rev: "v0.11.5"
hooks:
- id: ruff
args: ["--fix"]

View File

@@ -13,14 +13,14 @@ import requests
# Define messages for logging
# These messages are used for logging purposes and can be customized as needed.
class AeonViewMessages:
INVALID_URL = 'Invalid URL provided.'
INVALID_DATE = 'Invalid date format provided.'
DOWNLOAD_SUCCESS = 'Image downloaded successfully.'
DOWNLOAD_FAILURE = 'Failed to download image.'
VIDEO_GENERATION_SUCCESS = 'Video generated successfully.'
VIDEO_GENERATION_FAILURE = 'Failed to generate video.'
INVALID_IMAGE_FORMAT = 'Invalid image format provided.'
INVALID_IMAGE_EXTENSION = 'Invalid image extension provided.'
INVALID_URL = "Invalid URL provided."
INVALID_DATE = "Invalid date format provided."
DOWNLOAD_SUCCESS = "Image downloaded successfully."
DOWNLOAD_FAILURE = "Failed to download image."
VIDEO_GENERATION_SUCCESS = "Video generated successfully."
VIDEO_GENERATION_FAILURE = "Failed to generate video."
INVALID_IMAGE_FORMAT = "Invalid image format provided."
INVALID_IMAGE_EXTENSION = "Invalid image extension provided."
class AeonViewImages:
@@ -58,53 +58,56 @@ class AeonViewImages:
if not isinstance(date, datetime):
logging.error(AeonViewMessages.INVALID_DATE)
sys.exit(1)
if not url.startswith('http'):
if not url.startswith("http"):
logging.error(AeonViewMessages.INVALID_URL)
sys.exit(1)
if not url.endswith(('.jpg', '.jpeg', '.png', '.gif', '.webp')):
if not url.endswith((".jpg", ".jpeg", ".png", ".gif", ".webp")):
logging.error(AeonViewMessages.INVALID_IMAGE_FORMAT)
sys.exit(1)
if destination_base is None:
logging.error('No destination base path provided.')
logging.error("No destination base path provided.")
sys.exit(1)
if not isinstance(destination_base, Path):
logging.error('Invalid destination base path.')
logging.error("Invalid destination base path.")
sys.exit(1)
year = date.strftime('%Y')
month = date.strftime('%m')
day = date.strftime('%d')
year = date.strftime("%Y")
month = date.strftime("%m")
day = date.strftime("%d")
year_month = f'{year}-{month}'
year_month = f"{year}-{month}"
destination = AeonViewHelpers.build_path(destination_base, year_month, day)
file_name = date.strftime('%H-%M-%S') + AeonViewHelpers.get_extension(url)
destination = AeonViewHelpers.build_path(
destination_base, year_month, day
)
extension = AeonViewHelpers.get_extension(url) or ""
file_name = date.strftime("%H-%M-%S") + extension
destination_file = AeonViewHelpers.build_path(destination, file_name)
if not destination.exists():
if self.args.simulate:
logging.info(f'Simulate: would create {destination}')
if self.args.get("simulate", False):
logging.info("Simulate: would create %s", destination)
else:
AeonViewHelpers.mkdir_p(destination)
logging.info(f'Creating destination base path: {destination}')
logging.info("Creating destination base path: %s", destination)
return {
'url': url,
'file': file_name,
'date': {
'year': year,
'month': month,
'day': day,
'hour': date.strftime('%H'),
'minute': date.strftime('%M'),
'second': date.strftime('%S'),
"url": url,
"file": file_name,
"date": {
"year": year,
"month": month,
"day": day,
"hour": date.strftime("%H"),
"minute": date.strftime("%M"),
"second": date.strftime("%S"),
},
'destinations': {
'base': destination_base,
'year_month': year_month,
'day': day,
'file': destination_file,
"destinations": {
"base": destination_base,
"year_month": year_month,
"day": day,
"file": destination_file,
},
}
@@ -113,17 +116,19 @@ class AeonViewImages:
Download the image from the URL and save it to the project directory.
"""
if self.args.date is not None:
date_param = self.args.get("date", None)
if date_param is not None:
try:
date = datetime.strptime(self.args.date, '%Y-%m-%d %H:%M:%S')
date = datetime.strptime(date_param, "%Y-%m-%d %H:%M:%S")
except ValueError:
logging.error(AeonViewMessages.INVALID_DATE)
sys.exit(1)
else:
date = datetime.now()
img_path = date.strftime('img/%Y-%m/%d')
img_name = date.strftime('%H-%M-%S')
img_path = date.strftime("img/%Y-%m/%d")
img_name = date.strftime("%H-%M-%S")
if self.url is None:
logging.error(AeonViewMessages.INVALID_URL)
@@ -132,16 +137,20 @@ class AeonViewImages:
file_ext = AeonViewHelpers.get_extension(self.url)
dest_dir = AeonViewHelpers.build_path(self.project_path, img_path)
dest_file = AeonViewHelpers.build_path(dest_dir, f'{img_name}{file_ext}')
dest_file = AeonViewHelpers.build_path(
dest_dir, f"{img_name}{file_ext}"
)
logging.info(f'Saving image to {dest_file}')
logging.info("Saving image to %s", dest_file)
if not self.args.simulate:
if not self.args.get("simulate", False):
AeonViewHelpers.mkdir_p(dest_dir)
self.download_image(dest_file)
else:
logging.info(f'Simulate: would create {dest_dir}')
logging.info(f'Simulate: would download {self.url} to {dest_file}')
logging.info("Simulate: would create %s", dest_dir)
logging.info(
"Simulate: would download %s to %s", self.url, dest_file
)
def download_image(self, destination: Path):
"""
@@ -155,22 +164,31 @@ class AeonViewImages:
sys.exit(1)
if not isinstance(destination, Path):
logging.error('Invalid destination path.')
logging.error("Invalid destination path.")
sys.exit(1)
if self.args.simulate is False or self.args.simulate is None:
logging.info(f'Downloading image from {self.url}')
response = requests.get(self.url, stream=True)
if (
self.args.get("simulate", False) is False
or self.args.get("simulate", None) is None
):
logging.info("Downloading image from %s", self.url)
response = requests.get(self.url, stream=True, timeout=10)
if response.status_code == 200:
with open(destination, 'wb') as f:
with open(destination, "wb") as f:
for chunk in response.iter_content(1024):
f.write(chunk)
logging.info(f'{AeonViewMessages.DOWNLOAD_SUCCESS}: {destination}')
logging.info(
"%s: %s", AeonViewMessages.DOWNLOAD_SUCCESS, destination
)
else:
logging.error(f'{AeonViewMessages.DOWNLOAD_FAILURE}: {self.url}')
logging.error(
"%s: %s", AeonViewMessages.DOWNLOAD_FAILURE, self.url
)
sys.exit(1)
else:
logging.info(f'Simulate: would download {self.url} to {destination}')
logging.info(
"Simulate: would download %s to %s", self.url, destination
)
class AeonViewVideos:
@@ -181,48 +199,58 @@ class AeonViewVideos:
It uses ffmpeg for video processing.
"""
def __init__(self, project_path: Path, args=None):
def __init__(
self, project_path: Path, args: argparse.Namespace | None = None
):
"""
Initialize the AeonViewVideos class.
:param project_path: Path to the project directory
:param args: Command line arguments passed to the class
"""
self.project_path = project_path
self.args = args or {}
self.args = args
self.args.simulate = args.simulate or False
self.args.fps = args.fps or 10
self.args["simulate"] = (
args.get("simulate", False) if isinstance(args, dict) else False
)
self.args["fps"] = args.get("fps", 10) if isinstance(args, dict) else 10
self.day = args.day or None
self.month = args.month or None
self.year = args.year or None
self.day = args.get("day", None) if args else None
self.month = args.get("month", None) if args else None
self.year = args.get("year", None) if args else None
self.path_images = AeonViewHelpers.build_path(self.project_path, 'img')
self.path_videos = AeonViewHelpers.build_path(self.project_path, 'vid')
self.path_images = AeonViewHelpers.build_path(self.project_path, "img")
self.path_videos = AeonViewHelpers.build_path(self.project_path, "vid")
def generate_daily_video(self):
"""
Generate a daily video from images.
"""
year_month = f'{self.year}-{self.month}'
year_month = f"{self.year}-{self.month}"
input_dir = AeonViewHelpers.build_path(self.path_videos, year_month, self.day)
input_dir = AeonViewHelpers.build_path(
self.path_videos, year_month, self.day
)
output_dir = AeonViewHelpers.build_path(self.path_videos, year_month)
output_file = AeonViewHelpers.build_path(output_dir, f'{self.day}.mp4')
ffmpeg_cmd = AeonViewHelpers.generate_ffmpeg_command(input_dir, output_file, self.args.fps)
output_file = AeonViewHelpers.build_path(output_dir, f"{self.day}.mp4")
ffmpeg_cmd = AeonViewHelpers.generate_ffmpeg_command(
input_dir, output_file, self.args.fps
)
logging.info(f'Generating video from {input_dir}')
logging.info(f'Output file will be {output_file}')
logging.info("Generating video from %s", input_dir)
logging.info("Output file will be %s", output_file)
if not self.args.simulate:
logging.info(f'Running ffmpeg command: {" ".join(ffmpeg_cmd)}')
if not self.args.get("simulate", False):
logging.info("Running ffmpeg command: %s", " ".join(ffmpeg_cmd))
if not os.path.exists(input_dir):
AeonViewHelpers.mkdir_p(output_dir)
subprocess.run(ffmpeg_cmd, check=True)
logging.info(f'{AeonViewMessages.VIDEO_GENERATION_SUCCESS}: {output_file}')
logging.info(
"%s: %s", AeonViewMessages.VIDEO_GENERATION_SUCCESS, output_file
)
else:
logging.info(f'Simulate: would run {" ".join(ffmpeg_cmd)}')
logging.info("Simulate: would run %s", " ".join(ffmpeg_cmd))
def generate_monthly_video(self, output_dir: Path):
"""
@@ -230,7 +258,9 @@ class AeonViewVideos:
:param output_dir: Directory where the video will be saved
:return: None
"""
raise NotImplementedError('Monthly video generation is not implemented.')
raise NotImplementedError(
"Monthly video generation is not implemented."
)
def generate_yearly_video(self, output_dir: Path):
"""
@@ -238,7 +268,7 @@ class AeonViewVideos:
:param output_dir: Directory where the video will be saved
:return: None
"""
raise NotImplementedError('Yearly video generation is not implemented.')
raise NotImplementedError("Yearly video generation is not implemented.")
class AeonViewHelpers:
@@ -284,20 +314,45 @@ class AeonViewHelpers:
def parse_arguments():
"""Parse command line arguments."""
dest_default = str(Path.cwd() / 'projects')
dest_default = str(Path.cwd() / "projects")
parser = argparse.ArgumentParser(description='aeonview - timelapse generator using ffmpeg')
parser.add_argument('--mode', choices=['image', 'video'], default='image', help='Run mode')
parser.add_argument('--project', help='Project name', default='default')
parser.add_argument('--dest', default=dest_default, help='Destination root path')
parser.add_argument('--url', help='Webcam URL (required in image mode)')
parser.add_argument('--fps', type=int, default=10, help='Frames per second')
parser.add_argument('--generate', help='Date for video generation (YYYY-MM-DD)')
parser.add_argument('--timeframe', choices=['daily', 'monthly', 'yearly'], default='daily')
parser.add_argument(
'--simulate', action='store_true', help='Simulation mode', default=False
parser = argparse.ArgumentParser(
description="aeonview - timelapse generator using ffmpeg"
)
parser.add_argument(
"--mode",
choices=["image", "video"],
default="image",
help="Run mode",
)
parser.add_argument("--project", help="Project name", default="default")
parser.add_argument(
"--dest", default=dest_default, help="Destination root path"
)
parser.add_argument("--url", help="Webcam URL (required in image mode)")
parser.add_argument(
"--fps", type=int, default=10, help="Frames per second"
)
parser.add_argument(
"--generate", help="Date for video generation (YYYY-MM-DD)"
)
parser.add_argument(
"--timeframe",
choices=["daily", "monthly", "yearly"],
default="daily",
)
parser.add_argument(
"--simulate",
action="store_true",
help="Simulation mode",
default=False,
)
parser.add_argument(
"--verbose",
action="store_true",
help="Verbose output",
default=False,
)
parser.add_argument('--verbose', action='store_true', help='Verbose output', default=False)
args = parser.parse_args()
return args, parser
@@ -311,14 +366,14 @@ class AeonViewHelpers:
logging.error(AeonViewMessages.INVALID_IMAGE_EXTENSION)
return None
if url.endswith('.png'):
return '.png'
elif url.endswith('.gif'):
return '.gif'
elif url.endswith('.webp'):
return '.webp'
if url.endswith(".png"):
return ".png"
elif url.endswith(".gif"):
return ".gif"
elif url.endswith(".webp"):
return ".webp"
else:
return '.jpg'
return ".jpg"
@staticmethod
def setup_logger(verbose: bool):
@@ -328,10 +383,12 @@ class AeonViewHelpers:
:return: None
"""
level = logging.DEBUG if verbose else logging.INFO
logging.basicConfig(level=level, format='[%(levelname)s] %(message)s')
logging.basicConfig(level=level, format="[%(levelname)s] %(message)s")
@staticmethod
def generate_ffmpeg_command(input_dir: Path, output_file: Path, fps: int = 10) -> list:
def generate_ffmpeg_command(
input_dir: Path, output_file: Path, fps: int = 10
) -> list:
"""
Generate the ffmpeg command to create a video from images.
:param input_dir: Directory containing the images
@@ -340,17 +397,17 @@ class AeonViewHelpers:
:return: Newly created ffmpeg command as a list
"""
return [
'ffmpeg',
'-framerate',
"ffmpeg",
"-framerate",
str(fps),
'-pattern_type',
'glob',
'-i',
str(input_dir / '*.{jpg,jpeg,png,gif,webp}'),
'-c:v',
'libx264',
'-pix_fmt',
'yuv420p',
"-pattern_type",
"glob",
"-i",
str(input_dir / "*.{jpg,jpeg,png,gif,webp}"),
"-c:v",
"libx264",
"-pix_fmt",
"yuv420p",
str(output_file),
]
@@ -370,11 +427,11 @@ class AeonViewApp:
Execute the application based on the provided arguments.
"""
if self.args.simulate:
logging.info('Simulation mode active. No actions will be executed.')
logging.info("Simulation mode active. No actions will be executed.")
if self.args.mode == 'image':
if self.args.mode == "image":
self.process_image()
elif self.args.mode == 'video':
elif self.args.mode == "video":
self.process_video()
def process_image(self):
@@ -382,18 +439,20 @@ class AeonViewApp:
Process image download and saving based on the provided arguments.
"""
if not self.args.url or self.args.url is None:
logging.error('--url is required in image mode')
logging.error("--url is required in image mode")
self.parser.print_help()
sys.exit(1)
if not isinstance(self.args.url, str) or not self.args.url.startswith('http'):
logging.error(f'{AeonViewMessages.INVALID_URL}: {self.args.url}')
if not isinstance(self.args.url, str) or not self.args.url.startswith(
"http"
):
logging.error("%s: %s", AeonViewMessages.INVALID_URL, self.args.url)
sys.exit(1)
url = self.args.url
project = self.args.project or 'default'
project = self.args.project or "default"
if project == 'default' and url:
if project == "default" and url:
project = hashlib.md5(url.encode()).hexdigest()[:5]
project_path = AeonViewHelpers.build_path(self.base_path, project)
@@ -405,23 +464,25 @@ class AeonViewApp:
Process video generation based on the provided arguments.
"""
if not self.args.project:
logging.error('--project is required in video mode')
logging.error("--project is required in video mode")
self.parser.print_help()
sys.exit(1)
if not isinstance(self.args.project, str):
logging.error(f'Invalid project name: {self.args.project}')
logging.error("Invalid project name: %s", self.args.project)
sys.exit(1)
project_path = AeonViewHelpers.build_path(self.base_path, self.args.project)
project_path = AeonViewHelpers.build_path(
self.base_path, self.args.project
)
if not os.path.exists(project_path):
logging.error(f'Project path {project_path} does not exist.')
logging.error("Project path %s does not exist.", project_path)
sys.exit(1)
try:
generate_date = (
datetime.strptime(self.args.generate, '%Y-%m-%d')
datetime.strptime(self.args.generate, "%Y-%m-%d")
if self.args.generate
else datetime.today() - timedelta(days=1)
)
@@ -429,31 +490,33 @@ class AeonViewApp:
logging.error(AeonViewMessages.INVALID_DATE)
sys.exit(1)
year = generate_date.strftime('%Y')
month = generate_date.strftime('%m')
day = generate_date.strftime('%d')
year = generate_date.strftime("%Y")
month = generate_date.strftime("%m")
day = generate_date.strftime("%d")
self.args.day = day
self.args.month = month
self.args.year = year
if not AeonViewHelpers.check_date(int(year), int(month), int(day)):
logging.error(f'Invalid date: {year}-{month}-{day}')
logging.error("Invalid date: %s-%s-%s", year, month, day)
sys.exit(1)
avm = AeonViewVideos(project_path, self.args)
avm = AeonViewVideos(project_path, vars(self.args))
if self.args.timeframe == 'daily':
if self.args.timeframe == "daily":
avm.generate_daily_video()
elif self.args.timeframe == 'monthly':
output_dir = AeonViewHelpers.build_path(project_path, 'vid', f'{year}-{month}')
elif self.args.timeframe == "monthly":
output_dir = AeonViewHelpers.build_path(
project_path, "vid", f"{year}-{month}"
)
avm.generate_monthly_video(output_dir)
elif self.args.timeframe == 'yearly':
output_dir = AeonViewHelpers.build_path(project_path, 'vid', year)
elif self.args.timeframe == "yearly":
output_dir = AeonViewHelpers.build_path(project_path, "vid", year)
avm.generate_yearly_video(output_dir)
if __name__ == '__main__':
if __name__ == "__main__":
app = AeonViewApp()
app.run()

View File

@@ -1,24 +1,31 @@
# vim: set ft=python ts=4 sw=4 sts=4 et wrap:
import argparse
import logging
import subprocess
import tempfile
from datetime import datetime
from pathlib import Path
from unittest import TestCase, mock
from datetime import datetime
import pytest
from aeonview import AeonViewHelpers, AeonViewImages, AeonViewVideos, AeonViewMessages
from aeonview import (
AeonViewHelpers,
AeonViewImages,
AeonViewMessages,
AeonViewVideos,
)
# Define values used in the tests
default_dest = str(Path.cwd() / 'projects')
default_project = 'default'
default_dest = str(Path.cwd() / "projects")
default_project = "default"
default_fps = 10
default_timeframe = 'daily'
default_timeframe = "daily"
default_simulate = False
default_verbose = False
default_image_domain = 'https://example.com/image'
default_test_path = Path('/tmp/test_project').resolve()
default_image_domain = "https://example.com/image"
default_test_path = Path("/tmp/test_project").resolve()
tmp_images = Path("/tmp/images")
# Define the Helpers class with methods to be tested
@@ -31,52 +38,61 @@ class TestHelpers(TestCase):
def test_mkdir_p_creates_directory(self):
with tempfile.TemporaryDirectory() as tmp:
test_path = Path(tmp) / 'a' / 'b' / 'c'
test_path = Path(tmp) / "a" / "b" / "c"
AeonViewHelpers.mkdir_p(test_path)
self.assertTrue(test_path.exists())
self.assertTrue(test_path.is_dir())
def test_get_extension_valid(self):
self.assertEqual(AeonViewHelpers.get_extension(f'{default_image_domain}.png'), '.png')
self.assertEqual(AeonViewHelpers.get_extension(f'{default_image_domain}.jpg'), '.jpg')
self.assertEqual(AeonViewHelpers.get_extension(f'{default_image_domain}.gif'), '.gif')
self.assertEqual(AeonViewHelpers.get_extension(f'{default_image_domain}.webp'), '.webp')
prefix = default_image_domain
types = ["jpg", "png", "gif", "webp"]
for ext in types:
self.assertEqual(
AeonViewHelpers.get_extension(f"{prefix}.{ext}"),
f".{ext}",
)
def test_get_extension_invalid(self):
self.assertEqual(
AeonViewHelpers.get_extension(default_image_domain), '.jpg'
AeonViewHelpers.get_extension(default_image_domain), ".jpg"
) # Default behavior
self.assertIsNone(AeonViewHelpers.get_extension(None))
class TestFFmpegCommand(TestCase):
def test_generate_ffmpeg_command(self):
input_dir = Path('/tmp/images')
output_file = Path('/tmp/output.mp4')
input_dir = tmp_images
output_file = Path("/tmp/output.mp4")
fps = 24
cmd = AeonViewHelpers.generate_ffmpeg_command(input_dir, output_file, fps)
self.assertIn('ffmpeg', cmd[0])
cmd = AeonViewHelpers.generate_ffmpeg_command(
input_dir, output_file, fps
)
self.assertIn("ffmpeg", cmd[0])
self.assertIn(str(fps), cmd)
self.assertEqual(str(output_file), cmd[-1])
self.assertIn(str(input_dir / '*.{jpg,jpeg,png,gif,webp}'), cmd)
self.assertIn(str(input_dir / "*.{jpg,jpeg,png,gif,webp}"), cmd)
def test_generate_ffmpeg_command_output_format(self):
input_dir = Path('/tmp/images')
output_file = Path('/tmp/video.mp4')
cmd = AeonViewHelpers.generate_ffmpeg_command(input_dir, output_file, 30)
self.assertIn('/tmp/images/*.{jpg,jpeg,png,gif,webp}', cmd)
self.assertIn('/tmp/video.mp4', cmd)
self.assertIn('-c:v', cmd)
self.assertIn('libx264', cmd)
self.assertIn('-pix_fmt', cmd)
self.assertIn('yuv420p', cmd)
input_dir = tmp_images
output_file = Path("/tmp/video.mp4")
cmd = AeonViewHelpers.generate_ffmpeg_command(
input_dir, output_file, 30
)
self.assertIn("/tmp/images/*.{jpg,jpeg,png,gif,webp}", cmd)
self.assertIn("/tmp/video.mp4", cmd)
self.assertIn("-c:v", cmd)
self.assertIn("libx264", cmd)
self.assertIn("-pix_fmt", cmd)
self.assertIn("yuv420p", cmd)
@mock.patch('subprocess.run')
@mock.patch("subprocess.run")
def test_simulate_ffmpeg_call(self, mock_run):
input_dir = Path('/tmp/images')
output_file = Path('/tmp/out.mp4')
cmd = AeonViewHelpers.generate_ffmpeg_command(input_dir, output_file, 10)
subprocess.run(cmd)
input_dir = tmp_images
output_file = Path("/tmp/out.mp4")
cmd = AeonViewHelpers.generate_ffmpeg_command(
input_dir, output_file, 10
)
subprocess.run(cmd, check=True)
mock_run.assert_called_once_with(cmd)
@@ -84,235 +100,276 @@ class TestAeonViewImages(TestCase):
def setUp(self):
self.args = argparse.Namespace()
self.args.simulate = False
self.args.date = '2025-04-10 12:30:45'
self.args.url = f'{default_image_domain}.jpg'
self.args.date = "2025-04-10 12:30:45"
self.args.url = f"{default_image_domain}.jpg"
self.args.dest = default_test_path
self.args.project = default_project
self.args.verbose = default_verbose
self.args.fps = default_fps
self.args.timeframe = default_timeframe
self.project_path = default_test_path
self.url = f'{default_image_domain}.jpg'
self.url = f"{default_image_domain}.jpg"
def test_get_image_paths_valid(self):
url = f'{default_image_domain}.jpg'
url = f"{default_image_domain}.jpg"
destination_base = default_test_path
date = datetime(2025, 4, 10, 12, 30, 45)
paths = AeonViewImages.get_image_paths(self, url, destination_base, date)
self.assertEqual(paths['url'], url)
self.assertEqual(paths['file'], '12-30-45.jpg')
aeon_view_images = AeonViewImages(destination_base, url)
paths = aeon_view_images.get_image_paths(url, destination_base, date)
self.assertEqual(paths["url"], url)
self.assertEqual(paths["file"], "12-30-45.jpg")
self.assertEqual(
paths['destinations']['file'], destination_base / '2025-04' / '10' / '12-30-45.jpg'
paths["destinations"]["file"],
destination_base / "2025-04" / "10" / "12-30-45.jpg",
)
def test_get_image_paths_invalid_url(self):
with pytest.raises(SystemExit):
with self.assertLogs(level='ERROR') as log:
AeonViewImages.get_image_paths(
self, 'invalid-url', default_test_path, datetime(2025, 4, 10)
with self.assertLogs(level="ERROR") as log:
aeon_view_images = AeonViewImages(
default_test_path, "invalid-url"
)
aeon_view_images.get_image_paths(
"invalid-url",
default_test_path,
datetime(2025, 4, 10),
)
self.assertIn(AeonViewMessages.INVALID_URL, log.output[0])
def test_get_image_paths_invalid_date(self):
with pytest.raises(SystemExit):
with self.assertLogs(level='ERROR') as log:
AeonViewImages.get_image_paths(
self, f'{default_image_domain}.jpg', default_test_path, 'invalid-date'
with self.assertLogs(level="ERROR") as log:
aeon_view_images = AeonViewImages(
default_test_path, f"{default_image_domain}.jpg"
)
aeon_view_images.get_image_paths(
f"{default_image_domain}.jpg",
default_test_path,
"invalid-date",
)
self.assertIn(AeonViewMessages.INVALID_DATE, log.output[0])
@mock.patch('aeonview.AeonViewHelpers.mkdir_p')
@mock.patch('aeonview.AeonViewImages.download_image')
@mock.patch("aeonview.AeonViewHelpers.mkdir_p")
@mock.patch("aeonview.AeonViewImages.download_image")
def test_get_current_image(self, mock_download_image, mock_mkdir_p):
project_path = default_test_path
url = f'{default_image_domain}.jpg'
args = argparse.Namespace(simulate=False, date='2025-04-10 12:30:45')
url = f"{default_image_domain}.jpg"
args = argparse.Namespace(simulate=False, date="2025-04-10 12:30:45")
avi = AeonViewImages(project_path, url, args)
avi.get_current_image()
mock_mkdir_p.assert_called()
mock_download_image.assert_called()
@mock.patch('aeonview.requests.get')
@mock.patch("aeonview.requests.get")
def test_download_image_success(self, mock_get):
mock_response = mock.Mock()
mock_response.status_code = 200
mock_response.iter_content = mock.Mock(return_value=[b'data'])
mock_response.iter_content = mock.Mock(return_value=[b"data"])
mock_get.return_value = mock_response
project_path = default_test_path
url = f'{default_image_domain}.jpg'
url = f"{default_image_domain}.jpg"
args = argparse.Namespace(simulate=False)
avi = AeonViewImages(project_path, url, args)
destination = Path('/tmp/image.jpg')
avi.download_image(destination)
with tempfile.NamedTemporaryFile(delete=True) as temp_file:
destination = Path(temp_file.name)
avi.download_image(destination)
mock_get.assert_called_once_with(url, stream=True)
self.assertTrue(destination.exists())
mock_get.assert_called_once_with(url, stream=True)
self.assertTrue(destination.exists())
@mock.patch('aeonview.requests.get')
@mock.patch("aeonview.requests.get")
def test_download_image_failure(self, mock_get):
mock_response = mock.Mock()
mock_response.status_code = 404
mock_get.return_value = mock_response
project_path = default_test_path
url = f'{default_image_domain}.jpg'
url = f"{default_image_domain}.jpg"
args = argparse.Namespace(simulate=False)
avi = AeonViewImages(project_path, url, args)
destination = Path('/tmp/image.jpg')
destination = Path("/tmp/image.jpg")
with pytest.raises(SystemExit):
with self.assertLogs(level='ERROR') as log:
with self.assertLogs(level="ERROR") as log:
avi.download_image(destination)
self.assertIn(AeonViewMessages.DOWNLOAD_FAILURE, log.output[0])
class TestAeonViewVideos(TestCase):
@mock.patch('aeonview.AeonViewHelpers.mkdir_p')
@mock.patch('subprocess.run')
@mock.patch("aeonview.AeonViewHelpers.mkdir_p")
@mock.patch("subprocess.run")
def test_generate_daily_video(self, mock_subprocess_run, mock_mkdir_p):
project_path = default_test_path
args = argparse.Namespace(simulate=False, fps=10, day='01', month='04', year='2025')
args = argparse.Namespace(
simulate=False, fps=10, day="01", month="04", year="2025"
)
avv = AeonViewVideos(project_path, args)
with self.assertLogs(level='INFO') as log:
with self.assertLogs(level="INFO") as log:
avv.generate_daily_video()
expected_message = f'{AeonViewMessages.VIDEO_GENERATION_SUCCESS}: {default_test_path / "vid/2025-04/01.mp4"}'
self.assertIn(expected_message, log.output[-1]) # Ensure it's the last log entry
msg = AeonViewMessages.VIDEO_GENERATION_SUCCESS
expected_message = (
f"{msg}: {default_test_path / 'vid/2025-04/01.mp4'}"
)
self.assertIn(
expected_message, log.output[-1]
) # Ensure it's the last log entry
mock_mkdir_p.assert_called()
mock_subprocess_run.assert_called()
@mock.patch('aeonview.AeonViewHelpers.mkdir_p')
@mock.patch("aeonview.AeonViewHelpers.mkdir_p")
def test_generate_daily_video_simulate(self, mock_mkdir_p):
project_path = default_test_path
args = argparse.Namespace(simulate=True, fps=10, day='01', month='04', year='2025')
args = argparse.Namespace(
simulate=True, fps=10, day="01", month="04", year="2025"
)
avv = AeonViewVideos(project_path, args)
avv.generate_daily_video()
mock_mkdir_p.assert_not_called()
def test_generate_monthly_video_not_implemented(self):
project_path = default_test_path
args = argparse.Namespace(simulate=False, fps=10, day='01', month='04', year='2025')
args = argparse.Namespace(
simulate=False, fps=10, day="01", month="04", year="2025"
)
avv = AeonViewVideos(project_path, args)
with pytest.raises(NotImplementedError):
avv.generate_monthly_video(Path('/tmp'))
avv.generate_monthly_video(Path("/tmp"))
def test_generate_yearly_video_not_implemented(self):
project_path = default_test_path
args = argparse.Namespace(simulate=False, fps=10, day='01', month='04', year='2025')
args = argparse.Namespace(
simulate=False, fps=10, day="01", month="04", year="2025"
)
avv = AeonViewVideos(project_path, args)
with pytest.raises(NotImplementedError):
avv.generate_yearly_video(Path('/tmp'))
avv.generate_yearly_video(Path("/tmp"))
class TestHelpersArguments(TestCase):
def setUp(self):
self.default_dest = str(Path.cwd() / 'projects')
self.default_dest = str(Path.cwd() / "projects")
@mock.patch(
'sys.argv', ['aeonview.py', '--mode', 'image', '--url', f'{default_image_domain}.jpg']
"sys.argv",
[
"aeonview.py",
"--mode",
"image",
"--url",
f"{default_image_domain}.jpg",
],
)
def test_parse_arguments_image_mode(self):
args, _ = AeonViewHelpers.parse_arguments()
self.assertEqual(args.mode, 'image')
self.assertEqual(args.url, f'{default_image_domain}.jpg')
self.assertEqual(args.mode, "image")
self.assertEqual(args.url, f"{default_image_domain}.jpg")
self.assertEqual(args.dest, self.default_dest)
@mock.patch('sys.argv', ['aeonview.py', '--mode', 'video', '--project', f'{default_project}'])
@mock.patch(
"sys.argv",
["aeonview.py", "--mode", "video", "--project", f"{default_project}"],
)
def test_parse_arguments_video_mode(self):
args, _ = AeonViewHelpers.parse_arguments()
self.assertEqual(args.mode, 'video')
self.assertEqual(args.project, f'{default_project}')
self.assertEqual(args.mode, "video")
self.assertEqual(args.project, f"{default_project}")
self.assertEqual(args.dest, self.default_dest)
@mock.patch('sys.argv', ['aeonview.py', '--mode', 'image', '--simulate'])
@mock.patch("sys.argv", ["aeonview.py", "--mode", "image", "--simulate"])
def test_parse_arguments_simulate_mode(self):
args, _ = AeonViewHelpers.parse_arguments()
self.assertEqual(args.mode, 'image')
self.assertEqual(args.mode, "image")
self.assertTrue(args.simulate)
@mock.patch('sys.argv', ['aeonview.py', '--mode', 'video', '--fps', '30'])
@mock.patch("sys.argv", ["aeonview.py", "--mode", "video", "--fps", "30"])
def test_parse_arguments_fps(self):
args, _ = AeonViewHelpers.parse_arguments()
self.assertEqual(args.mode, 'video')
self.assertEqual(args.project, f'{default_project}')
self.assertEqual(args.mode, "video")
self.assertEqual(args.project, f"{default_project}")
self.assertEqual(args.dest, self.default_dest)
self.assertEqual(args.fps, 30)
@mock.patch('sys.argv', ['aeonview.py', '--mode', 'video', '--generate', '2023-10-01'])
@mock.patch(
"sys.argv",
["aeonview.py", "--mode", "video", "--generate", "2023-10-01"],
)
def test_parse_arguments_generate_date(self):
args, _ = AeonViewHelpers.parse_arguments()
self.assertEqual(args.mode, 'video')
self.assertEqual(args.generate, '2023-10-01')
self.assertEqual(args.mode, "video")
self.assertEqual(args.generate, "2023-10-01")
@mock.patch('sys.argv', ['aeonview.py', '--mode', 'image', '--verbose'])
@mock.patch("sys.argv", ["aeonview.py", "--mode", "image", "--verbose"])
def test_parse_arguments_verbose(self):
args, _ = AeonViewHelpers.parse_arguments()
self.assertEqual(args.mode, 'image')
self.assertEqual(args.mode, "image")
self.assertTrue(args.verbose)
@mock.patch('sys.argv', ['aeonview.py'])
@mock.patch("sys.argv", ["aeonview.py"])
def test_parse_arguments_defaults(self):
args, _ = AeonViewHelpers.parse_arguments()
self.assertEqual(args.mode, 'image')
self.assertEqual(args.project, f'{default_project}')
self.assertEqual(args.mode, "image")
self.assertEqual(args.project, f"{default_project}")
self.assertEqual(args.dest, self.default_dest)
self.assertEqual(args.fps, 10)
self.assertEqual(args.timeframe, 'daily')
self.assertEqual(args.timeframe, "daily")
self.assertFalse(args.simulate)
self.assertFalse(args.verbose)
class TestAeonViewSimulation(TestCase):
@mock.patch('aeonview.AeonViewHelpers.mkdir_p')
@mock.patch('aeonview.AeonViewImages.download_image')
@mock.patch("aeonview.AeonViewHelpers.mkdir_p")
@mock.patch("aeonview.AeonViewImages.download_image")
def test_image_simulation(self, mock_download_image, mock_mkdir_p):
args = mock.MagicMock()
args.simulate = True
args.date = '2025-04-10 12:30:45'
args.date = "2025-04-10 12:30:45"
url = f'{default_image_domain}.jpg'
project_path = Path('/tmp/test_project').resolve()
url = f"{default_image_domain}.jpg"
project_path = Path("/tmp/test_project").resolve()
avi = AeonViewImages(project_path, url, args)
with mock.patch('aeonview.logging.info') as mock_logging:
with mock.patch("aeonview.logging.info") as mock_logging:
avi.get_current_image()
mock_mkdir_p.assert_not_called()
mock_download_image.assert_not_called()
mock_logging.assert_any_call(
f'Saving image to {project_path}/img/2025-04/10/12-30-45.jpg'
f"Saving image to {project_path}/img/2025-04/10/12-30-45.jpg"
)
@mock.patch('aeonview.AeonViewHelpers.mkdir_p')
@mock.patch('subprocess.run')
@mock.patch("aeonview.AeonViewHelpers.mkdir_p")
@mock.patch("subprocess.run")
def test_video_simulation(self, mock_subprocess_run, mock_mkdir_p):
args = mock.MagicMock()
args.simulate = True
args.fps = 10
args.day = '01'
args.month = '01'
args.year = '2023'
project_path = Path('/tmp/test_project').resolve()
args.day = "01"
args.month = "01"
args.year = "2023"
project_path = Path("/tmp/test_project").resolve()
avv = AeonViewVideos(project_path, args)
with mock.patch('aeonview.logging.info') as mock_logging:
with mock.patch("aeonview.logging.info") as mock_logging:
avv.generate_daily_video()
mock_mkdir_p.assert_not_called()
mock_subprocess_run.assert_not_called()
mock_logging.assert_any_call(f'Generating video from {project_path}/vid/2023-01/01')
mock_logging.assert_any_call(
f"Generating video from {project_path}/vid/2023-01/01"
)
class TestSetupLogger(TestCase):
@mock.patch('logging.basicConfig')
@mock.patch("logging.basicConfig")
def test_setup_logger_verbose(self, mock_basic_config):
AeonViewHelpers.setup_logger(verbose=True)
mock_basic_config.assert_called_once_with(
level=logging.DEBUG, format='[%(levelname)s] %(message)s'
level=logging.DEBUG, format="[%(levelname)s] %(message)s"
)
@mock.patch('logging.basicConfig')
@mock.patch("logging.basicConfig")
def test_setup_logger_non_verbose(self, mock_basic_config):
AeonViewHelpers.setup_logger(verbose=False)
mock_basic_config.assert_called_once_with(
level=logging.INFO, format='[%(levelname)s] %(message)s'
level=logging.INFO, format="[%(levelname)s] %(message)s"
)
mock_basic_config.reset_mock()

View File

@@ -6,4 +6,4 @@ import pytest
@pytest.fixture(autouse=True)
def no_network_requests(monkeypatch):
monkeypatch.setattr('subprocess.run', mock.Mock())
monkeypatch.setattr("subprocess.run", mock.Mock())

View File

@@ -1,5 +1,5 @@
[tool.ruff]
line-length = 100
line-length = 80
target-version = "py311"
[tool.ruff.lint]
@@ -7,10 +7,10 @@ select = ["E", "F", "I", "B", "UP", "C4", "T20"]
ignore = ["E501"]
[tool.ruff.lint.per-file-ignores]
"test_*.py" = ["S101"]
"*_test.py" = ["S101"]
[tool.ruff.format]
quote-style = "single"
quote-style = "double"
indent-style = "space"
[tool.pytest.ini_options]