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,5 +1,8 @@
"""Tests for the aeonview timelapse generator."""
# vim: set ft=python ts=4 sw=4 sts=4 et wrap:
import argparse
import contextlib
import logging
import subprocess
import tempfile
@@ -10,6 +13,7 @@ from unittest import mock
import pytest
from aeonview import (
AeonViewApp,
AeonViewHelpers,
AeonViewImages,
AeonViewMessages,
@@ -27,6 +31,41 @@ default_test_path = Path(tempfile.gettempdir(), "test_project").resolve()
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():
base = Path(tempfile.gettempdir())
result = AeonViewHelpers.build_path(base, "a", "b", "c")
@@ -62,6 +101,12 @@ def test_get_extension_invalid():
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():
input_dir = tmp_images
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)
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():
url = f"{default_image_domain}.jpg"
destination_base = default_test_path
@@ -109,7 +185,7 @@ def test_get_image_paths_valid():
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.get_image_paths(
"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():
with pytest.raises(SystemExit), mock.patch("aeonview.logging.error"):
with expect_error_exit():
aeon_view_images = AeonViewImages(
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.AeonViewImages.download_image")
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()
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")
def test_download_image_success(mock_get):
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)
destination = Path(tempfile.gettempdir(), "image.jpg")
with pytest.raises(SystemExit), mock.patch("aeonview.logging.error"):
with expect_error_exit():
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")
def test_generate_daily_video(mock_subprocess_run):
with tempfile.TemporaryDirectory() as tmp:
project_path = Path(tmp).resolve()
args = argparse.Namespace(
simulate=False, fps=10, day="01", month="04", year="2025"
)
args = make_video_args()
avv = AeonViewVideos(project_path, args)
# Create input directory so the existence check passes
(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")
def test_generate_daily_video_simulate(mock_mkdir_p):
args = argparse.Namespace(
simulate=True, fps=10, day="01", month="04", year="2025"
)
args = make_video_args(simulate=True)
avv = AeonViewVideos(default_test_path, args)
avv.generate_daily_video()
mock_mkdir_p.assert_not_called()
def test_generate_monthly_video_not_implemented():
args = argparse.Namespace(
simulate=False, fps=10, day="01", month="04", year="2025"
)
avv = AeonViewVideos(default_test_path, args)
with pytest.raises(NotImplementedError):
avv.generate_monthly_video(Path(tempfile.gettempdir()))
def test_generate_daily_video_missing_input_dir():
with tempfile.TemporaryDirectory() as tmp:
project_path = Path(tmp).resolve()
args = make_video_args()
avv = AeonViewVideos(project_path, args)
with expect_error_exit():
avv.generate_daily_video()
def test_generate_yearly_video_not_implemented():
args = argparse.Namespace(
simulate=False, fps=10, day="01", month="04", year="2025"
)
@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 pytest.raises(NotImplementedError):
avv.generate_yearly_video(Path(tempfile.gettempdir()))
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
)
# --- 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(
@@ -291,39 +634,9 @@ def test_parse_arguments_defaults():
assert not args.verbose
@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
)
@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
)
# ---------------------------------------------------------------------------
# Logger tests
# ---------------------------------------------------------------------------
@mock.patch("logging.basicConfig")
@@ -340,3 +653,256 @@ def test_setup_logger_non_verbose(mock_basic_config):
mock_basic_config.assert_called_once_with(
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()