diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml
new file mode 100644
index 0000000..b136fbb
--- /dev/null
+++ b/.github/workflows/tests.yml
@@ -0,0 +1,33 @@
+name: unit tests
+
+on:
+ push:
+ branches:
+ - master
+ pull_request:
+ branches:
+ - master
+ workflow_dispatch:
+
+jobs:
+ test:
+ runs-on: ubuntu-20.04
+
+ strategy:
+ matrix:
+ python-version: [3.9.2]
+
+ steps:
+ - uses: actions/checkout@v2
+ - name: Set up Python ${{ matrix.python-version }}
+ uses: actions/setup-python@v2
+ with:
+ python-version: ${{ matrix.python-version }}
+ - name: Install dependencies
+ run: |
+ python -m pip install --upgrade pip
+ pip install poetry
+ poetry install --with test
+ - name: Run tests
+ run: |
+ poetry run pytest
diff --git a/README.md b/README.md
index faf8117..a7704c1 100644
--- a/README.md
+++ b/README.md
@@ -1,6 +1,9 @@
[![Python package](https://github.com/MPI-IS/nightskycam-images/actions/workflows/tests.yml/badge.svg)](https://github.com/MPI-IS/nightskycam-images/actions/workflows/tests.yml)
[![PyPI version](https://img.shields.io/pypi/v/nightskycam-images.svg)](https://pypi.org/project/nightskycam-images/)
+> 🚧 **Under Construction**
+> This project is currently under development. Please check back later for updates.
+
# Nightskycam Images
diff --git a/nightskycam_images/__init__.py b/nightskycam_images/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/nightskycam_images/__pycache__/__init__.cpython-311.pyc b/nightskycam_images/__pycache__/__init__.cpython-311.pyc
new file mode 100644
index 0000000..7f8919b
Binary files /dev/null and b/nightskycam_images/__pycache__/__init__.cpython-311.pyc differ
diff --git a/nightskycam_images/__pycache__/__init__.cpython-39.pyc b/nightskycam_images/__pycache__/__init__.cpython-39.pyc
new file mode 100644
index 0000000..7c2b3fd
Binary files /dev/null and b/nightskycam_images/__pycache__/__init__.cpython-39.pyc differ
diff --git a/nightskycam_images/__pycache__/constants.cpython-311.pyc b/nightskycam_images/__pycache__/constants.cpython-311.pyc
new file mode 100644
index 0000000..5c9da55
Binary files /dev/null and b/nightskycam_images/__pycache__/constants.cpython-311.pyc differ
diff --git a/nightskycam_images/__pycache__/constants.cpython-39.pyc b/nightskycam_images/__pycache__/constants.cpython-39.pyc
new file mode 100644
index 0000000..03ba5ab
Binary files /dev/null and b/nightskycam_images/__pycache__/constants.cpython-39.pyc differ
diff --git a/nightskycam_images/__pycache__/convert_npy.cpython-39.pyc b/nightskycam_images/__pycache__/convert_npy.cpython-39.pyc
new file mode 100644
index 0000000..0dfec4e
Binary files /dev/null and b/nightskycam_images/__pycache__/convert_npy.cpython-39.pyc differ
diff --git a/nightskycam_images/__pycache__/folder_change.cpython-39.pyc b/nightskycam_images/__pycache__/folder_change.cpython-39.pyc
new file mode 100644
index 0000000..e65263a
Binary files /dev/null and b/nightskycam_images/__pycache__/folder_change.cpython-39.pyc differ
diff --git a/nightskycam_images/__pycache__/image.cpython-39.pyc b/nightskycam_images/__pycache__/image.cpython-39.pyc
new file mode 100644
index 0000000..5310467
Binary files /dev/null and b/nightskycam_images/__pycache__/image.cpython-39.pyc differ
diff --git a/nightskycam_images/__pycache__/thumbnail.cpython-39.pyc b/nightskycam_images/__pycache__/thumbnail.cpython-39.pyc
new file mode 100644
index 0000000..630fda0
Binary files /dev/null and b/nightskycam_images/__pycache__/thumbnail.cpython-39.pyc differ
diff --git a/nightskycam_images/__pycache__/video.cpython-39.pyc b/nightskycam_images/__pycache__/video.cpython-39.pyc
new file mode 100644
index 0000000..2eb3c4e
Binary files /dev/null and b/nightskycam_images/__pycache__/video.cpython-39.pyc differ
diff --git a/nightskycam_images/__pycache__/walk.cpython-39.pyc b/nightskycam_images/__pycache__/walk.cpython-39.pyc
new file mode 100644
index 0000000..e8bd52a
Binary files /dev/null and b/nightskycam_images/__pycache__/walk.cpython-39.pyc differ
diff --git a/nightskycam_images/__pycache__/weather.cpython-39.pyc b/nightskycam_images/__pycache__/weather.cpython-39.pyc
new file mode 100644
index 0000000..17616c3
Binary files /dev/null and b/nightskycam_images/__pycache__/weather.cpython-39.pyc differ
diff --git a/nightskycam_images/constants.py b/nightskycam_images/constants.py
new file mode 100644
index 0000000..afc6b35
--- /dev/null
+++ b/nightskycam_images/constants.py
@@ -0,0 +1,36 @@
+"""
+Definition of project-level constants.
+"""
+
+from typing import Tuple
+
+# Files in general.
+FILE_PERMISSIONS: int = 0o755 # Octal integer.
+
+# HD image.
+IMAGE_FILE_FORMATS: Tuple[str, ...] = ("npy", "tiff", "jpg", "jpeg")
+
+# Thumbnail image.
+THUMBNAIL_DIR_NAME: str = "thumbnails"
+THUMBNAIL_FILE_FORMAT: str = "jpeg"
+THUMBNAIL_WIDTH: int = 200 # In pixels.
+
+# Zip.
+ZIP_DIR_NAME: str = "zip"
+
+# Video.
+VIDEO_FILE_NAME: str = "day_summary.webm"
+
+# Weather summary.
+WEATHER_SUMMARY_FILE_NAME: str = "weathers.toml"
+
+# Format patterns.
+
+# formats for filename, e.g. nightskycamX_2024_09_26_13_57_50.jpeg
+DATE_FORMAT_FILE: str = "%Y_%m_%d"
+TIME_FORMAT_FILE: str = "%H_%M_%S"
+DATETIME_FORMATS: Tuple[str, ...] = ("%d_%m_%Y_%H_%M_%S", "%Y_%m_%d_%H_%M_%S")
+
+# formats for displaying on the website
+DATE_FORMAT: str = "%Y-%m-%d"
+TIME_FORMAT: str = "%H:%M:%S"
diff --git a/nightskycam_images/convert_npy.py b/nightskycam_images/convert_npy.py
new file mode 100644
index 0000000..af645ed
--- /dev/null
+++ b/nightskycam_images/convert_npy.py
@@ -0,0 +1,44 @@
+from pathlib import Path
+import tempfile
+
+import PIL.Image as PILImage
+import cv2
+import numpy as np
+import numpy.typing as npt
+
+
+def _bits_reduction(data: npt.NDArray, target: type) -> npt.NDArray:
+ original_max = np.iinfo(data.dtype).max
+ target_max = np.iinfo(target).max
+ ratio = target_max / original_max
+ return (data * ratio).astype(target)
+
+
+def _to_8bits(image: npt.NDArray) -> npt.NDArray:
+ return _bits_reduction(image, np.uint8)
+
+
+def npy_file_to_pil(image_path: Path) -> PILImage.Image:
+ """
+ Read the file (expected to be an .npy file) and
+ return a corresponding instance of PIL Image.
+ It first converts the image to 8 bits.
+ """
+ img_array = np.load(image_path)
+ img_array = _to_8bits(img_array)
+
+ # Create a temporary directory for intermediary tiff file.
+ with tempfile.TemporaryDirectory() as tmp_dir:
+ tiff_file_path = Path(tmp_dir) / f"{image_path.stem}.tiff"
+ cv2.imwrite(str(tiff_file_path), img_array, [cv2.IMWRITE_TIFF_COMPRESSION, 1])
+ return PILImage.open(str(tiff_file_path))
+
+
+def npy_file_to_numpy(image_path: Path) -> np.ndarray:
+ """
+ Read the file (expected to be an .npy file) and
+ return a corresponding numpy array, converted to 8 bits.
+ """
+ img_array = np.load(image_path)
+ img_array = _to_8bits(img_array)
+ return img_array
diff --git a/nightskycam_images/folder_change.py b/nightskycam_images/folder_change.py
new file mode 100644
index 0000000..c566996
--- /dev/null
+++ b/nightskycam_images/folder_change.py
@@ -0,0 +1,42 @@
+import os
+from pathlib import Path
+from typing import Optional
+
+
+def folder_has_changed(
+ folder: Path, history: Optional[dict[Path, Optional[float]]]
+) -> bool:
+ """
+ Check whether the last-modified-time of the directory changed
+ since the previous check.
+
+ If there is no history of a previous check:
+ return True and initialise history.
+
+ Parameters
+ ----------
+ folder
+ Path to directory.
+ history
+ Last modification times found in previous check:
+ - key:
+ Path to directory.
+ - value:
+ Time of last modification of directory.
+ """
+ history = history if history else {}
+
+ # Float: number of seconds since epoch.
+ last_modified_time = os.path.getmtime(folder)
+
+ # Set to dummy value when key does NOT exist.
+ previous_modified_time = history.get(folder, None)
+
+ # If the folder has NO previous history
+ # OR was modified since previous history,
+ # update the history.
+ if not previous_modified_time or previous_modified_time < last_modified_time:
+ history[folder] = last_modified_time
+ return True
+
+ return False
diff --git a/nightskycam_images/image.py b/nightskycam_images/image.py
new file mode 100644
index 0000000..a984d37
--- /dev/null
+++ b/nightskycam_images/image.py
@@ -0,0 +1,226 @@
+import datetime as dt
+from pathlib import Path
+from typing import Any, Dict, Optional, cast
+
+import toml
+
+from .constants import (
+ DATE_FORMAT,
+ DATE_FORMAT_FILE,
+ IMAGE_FILE_FORMATS,
+ THUMBNAIL_DIR_NAME,
+ THUMBNAIL_FILE_FORMAT,
+ VIDEO_FILE_NAME,
+)
+
+
+class Image:
+ def __init__(self) -> None:
+
+ # Stem of file name (without extension).
+ self.filename_stem: Optional[str] = None
+ # Datetime instance.
+ # NOT named `datetime` to avoid confusion with package/class.
+ self.date_and_time: Optional[dt.datetime] = None
+ # System name.
+ self.system: Optional[str] = None
+
+ # Absolute path to directory containing the HD images.
+ # Also contains sub-directory containing the thumbnail images.
+ self.dir_path: Optional[Path] = None
+
+ @property
+ def filename(self) -> Optional[str]:
+ """
+ Alias for filename_stem
+ """
+ return self.filename_stem
+
+ @property
+ def date(self) -> Optional[dt.datetime]:
+ """
+ Alias for date_and_time
+ """
+ return self.date_and_time
+
+ @property
+ def thumbnail(self) -> Optional[Path]:
+ """
+ Absolute path to thumbnail image file.
+ """
+ if self.dir_path is None:
+ return None
+
+ file_path = (
+ self.dir_path
+ / THUMBNAIL_DIR_NAME
+ / f"{self.filename_stem}.{THUMBNAIL_FILE_FORMAT}"
+ )
+ if file_path.is_file():
+ return file_path
+
+ return None
+
+ @property
+ def video(self) -> Optional[Path]:
+ """
+ Absolute path to video file.
+ """
+ if self.dir_path is None:
+ return None
+
+ file_path = self.dir_path / THUMBNAIL_DIR_NAME / VIDEO_FILE_NAME
+ if file_path.is_file():
+ return file_path
+
+ return None
+
+ @property
+ def hd(self) -> Optional[Path]:
+ """
+ Absolute path to HD image file.
+ """
+ if self.dir_path is None:
+ return None
+
+ for f in IMAGE_FILE_FORMATS:
+ file_path = self.dir_path / f"{self.filename_stem}.{f}"
+ if file_path.is_file():
+ return file_path
+
+ return None
+
+ @property
+ def path(self) -> Optional[Path]:
+ """
+ Alias for hd
+ """
+ return self.hd
+
+ @property
+ def meta_path(self) -> Optional[Path]:
+ """
+ Absolute path to meta data file.
+ """
+ if self.dir_path is None:
+ return None
+
+ file_path = self.dir_path / f"{self.filename_stem}.toml"
+ if file_path.is_file():
+ return file_path
+
+ return None
+
+ @property
+ def meta(self) -> Dict[str, Any]:
+
+ if self.dir_path is None or self.filename_stem is None:
+ return {}
+ meta_file = self.meta_path
+ if not meta_file:
+ return {}
+ if not meta_file.is_file():
+ return {}
+ try:
+ meta = toml.load(meta_file)
+ except toml.decoder.TomlDecodeError as e:
+ meta = {"error": f"failed to read {meta_file}: {e}"}
+ return meta
+
+ @property
+ def nightstart_date(self) -> Optional[dt.date]:
+ """
+ Start date of the night.
+
+ Purpose:
+ Utility for bundling images per night instead of date.
+
+ Context:
+ The date switches at midnight.
+ """
+ if self.date_and_time is None:
+ return None
+ hour = self.date_and_time.hour
+
+ # Before noon,
+ # therefore assign to date of previous day.
+ if hour < 12:
+ return (self.date_and_time - dt.timedelta(days=1)).date()
+
+ return self.date_and_time.date()
+
+ @property
+ def day(self) -> Optional[dt.date]:
+ """
+ Alias for nightstart_date
+ """
+ return self.nightstart_date
+
+ @property
+ def day_as_str(self) -> Optional[str]:
+ if self.day is None:
+ return None
+ return self.day.strftime(DATE_FORMAT_FILE)
+
+ @property
+ def nightstart_date_as_str(self) -> Optional[str]:
+ """
+ String representation of the property `nightstart_date`.
+ """
+ nightstart_date = self.nightstart_date
+ if nightstart_date is None:
+ return None
+ return nightstart_date.strftime(DATE_FORMAT)
+
+ @staticmethod
+ def date_from_str(date: str) -> dt.date:
+ """
+ Get date instance from its string representation.
+ """
+ return dt.datetime.strptime(date, DATE_FORMAT_FILE).date()
+
+ @staticmethod
+ def day_from_str(day: str) -> dt.date:
+ """
+ Alias for date_from_str
+ """
+ return dt.datetime.strptime(day, DATE_FORMAT_FILE).date()
+
+ @property
+ def datetime_as_str(
+ self, datetime_format: str = "%b. %d, %Y, %-I:%M %p"
+ ) -> Optional[str]:
+ """
+ String representation of the attribute `date_and_time`.
+ """
+ if self.date is None:
+ return None
+ return self.date.strftime(datetime_format)
+
+ def to_dict(self) -> Dict[str, str]:
+ """
+ Get dictionary representation of image instance.
+ """
+ r: Dict[str, str] = {}
+ r["path"] = str(self.hd)
+ r["thumbnail"] = str(self.thumbnail)
+ r["video"] = str(self.video)
+ r["date_as_str"] = str(self.datetime_as_str)
+ r["day_as_str"] = str(self.nightstart_date_as_str)
+ r["system"] = str(self.system)
+ r["meta"] = repr(self.meta)
+ r["meta_path"] = str(self.meta_path)
+ return r
+
+ def __eq__(self, other: object) -> bool:
+ return self.date_and_time == Image.date_and_time
+
+ def __gt__(self, other: object) -> bool:
+ other_ = cast("Image", other)
+ if self.date_and_time is None:
+ return False
+ if other_.date_and_time is None:
+ return True
+ if self.date_and_time >= other_.date_and_time:
+ return True
+ return False
diff --git a/nightskycam_images/main.py b/nightskycam_images/main.py
new file mode 100644
index 0000000..2745663
--- /dev/null
+++ b/nightskycam_images/main.py
@@ -0,0 +1,52 @@
+import argparse
+from pathlib import Path
+import sys
+
+from .video import VideoFormat, create_video
+from .walk import walk_thumbnails
+
+# TODO: Move to resp. combine with project-level constant?
+# @Vincent:
+# Is it intended that there is an additional image format "png"?
+# If yes, could this be merged with the project-level constant
+# IMAGE_FILE_FORMATS: Tuple[str, ...] = ("npy", "tiff", "jpg", "jpeg")
+# by adding "png" to the project-level constant?
+image_formats = ("jpeg", "jpg", "png", "tiff", "npy")
+
+_video_format = VideoFormat()
+
+
+def thumbnails():
+ parser = argparse.ArgumentParser(description="list the thumbnails folders")
+ parser.add_argument("folder_path", type=str, help="Path to the folder")
+ args = parser.parse_args()
+ p = Path(args.folder_path)
+ if not p.is_dir():
+ sys.stderr.write(
+ f"The path {args.folder_path} does not exist or is not a directory."
+ )
+ sys.exit(1)
+
+ for tp in walk_thumbnails(p):
+ sys.stdout.write(f"{tp}\n")
+ sys.exit(0)
+
+
+def _list_images(current: Path) -> list[Path]:
+ images: list[Path] = []
+ for format_ in image_formats:
+ images.extend(list(current.glob(f"*.{format_}")))
+ return images
+
+
+def main(video_format: VideoFormat = _video_format) -> None:
+ current_path = Path(".")
+ output = current_path / f"output.{video_format.format}"
+ image_files = _list_images(current_path)
+ image_files.sort()
+ create_video(
+ output,
+ image_files,
+ [str(img_file) for img_file in image_files],
+ video_format,
+ )
diff --git a/nightskycam_images/py.typed b/nightskycam_images/py.typed
new file mode 100644
index 0000000..e69de29
diff --git a/nightskycam_images/thumbnail.py b/nightskycam_images/thumbnail.py
new file mode 100644
index 0000000..9a0a21f
--- /dev/null
+++ b/nightskycam_images/thumbnail.py
@@ -0,0 +1,259 @@
+from concurrent.futures import ProcessPoolExecutor
+import logging
+from multiprocessing import cpu_count
+from pathlib import Path
+from typing import Callable, Generator, Iterable, Optional
+
+from PIL import Image as PILImage
+import cv2
+
+from .constants import (
+ FILE_PERMISSIONS,
+ THUMBNAIL_DIR_NAME,
+ THUMBNAIL_FILE_FORMAT,
+ THUMBNAIL_WIDTH,
+)
+from .convert_npy import npy_file_to_pil
+from .folder_change import folder_has_changed
+
+logging.getLogger("PIL").setLevel(logging.ERROR)
+
+_nb_workers = 1 if cpu_count() == 1 else cpu_count() - 1
+
+
+# TODO: confirm.
+# @Vincent:
+# Is this still used or is this deprecated code?
+class ThumbnailText:
+ def __init__(self) -> None:
+ self.position: tuple[int, int] = (0, 20)
+ self.font = cv2.FONT_HERSHEY_SIMPLEX
+ self.font_scale: int = 1
+ self.color: tuple[int, int, int] = (0, 0, 255)
+ self.thickness: int = 1
+
+
+# TODO: refactor?
+# The functionality is very similar to the property `thumbnail_path`
+# in `nightskycam_images.image`.
+#
+# @Vincent:
+# Would it make sense to combine this with the property `thumbnail_path`
+# in `nightskycam_images.image`?
+def _thumbnail_path(
+ image_path: Path,
+ thumbnail_format: str = THUMBNAIL_FILE_FORMAT,
+) -> Path:
+ """
+ Get path to thumbnail image file for given HD image path.
+
+ Parameters
+ ----------
+ image_path
+ Path to HD image file.
+ thumbnail_format
+ Format of thumbnail image file.
+
+ Returns
+ -------
+ Path
+ Path to thumbnail image file.
+ """
+ return (
+ image_path.parent / THUMBNAIL_DIR_NAME / f"{image_path.stem}.{thumbnail_format}"
+ )
+
+
+def create_thumbnail(
+ image_path: Path,
+ # TODO: Remove parameter and only keep the default behaviour?
+ # Default behaviour:
+ # Automatic creation of thumbnail image path.
+ # Reasoning:
+ # Makes it impossible to input faulty combinations of
+ # image_path/thumbnail_path.
+ # @Vincent:
+ # Would this make sense?
+ thumbnail_path: Optional[Path] = None,
+ thumbnail_width: int = THUMBNAIL_WIDTH,
+ thumbnail_format: str = THUMBNAIL_FILE_FORMAT,
+ overwrite: bool = False,
+ permissions: int = FILE_PERMISSIONS,
+) -> Path:
+ """
+ Write thumbnail image file for given HD image.
+
+ Parameters
+ ----------
+ image_path
+ Path to HD image file.
+ thumbnail_path
+ Path to output thumbnail image file.
+ thumbnail_width
+ Width of the thumbnail image (in pixels).
+ thumbnail_format
+ File format of the thumbnail image.
+ overwrite
+ Whether to overwrite an existing thumbnail image file.
+ permissions
+ File permissions for the thumbnail image.
+
+ Returns
+ -------
+ Path
+ Path to output thumbnail image file.
+ """
+ if thumbnail_path is None:
+ thumbnail_path = _thumbnail_path(image_path)
+
+ if thumbnail_path.is_file() and overwrite is False:
+ logging.info("thumbnail for %s already exists, skipping", image_path.stem)
+ return thumbnail_path
+
+ logging.info("creating thumbnail for %s", image_path.stem)
+ thumbnail_path.parent.mkdir(parents=True, exist_ok=True)
+
+ if image_path.suffix == ".npy":
+ img = npy_file_to_pil(image_path)
+ else:
+ img = PILImage.open(image_path)
+
+ # Height for the thumbnail image (in pixels).
+ # Use same scaling ratio between thumbnail and HD image
+ # as for width.
+ thumbnail_height = int((thumbnail_width / img.width) * img.height)
+ # Convert HD image into thumbnail image (in-place).
+ img.thumbnail((thumbnail_width, thumbnail_height))
+ # Save thumbnail image.
+ img.save(thumbnail_path, thumbnail_format)
+ # Set file permissions.
+ thumbnail_path.chmod(permissions)
+
+ return thumbnail_path
+
+
+def create_thumbnails(
+ image_path_s: Iterable[Path],
+ thumbnail_width: int = THUMBNAIL_WIDTH,
+ thumbnail_format: str = THUMBNAIL_FILE_FORMAT,
+ # TODO: confirm.
+ # @Vincent:
+ # Is it safe to ignore all errors as the default behaviour?
+ skip_error: bool = True,
+ overwrite: bool = False,
+ permissions: int = FILE_PERMISSIONS,
+) -> list[Path]:
+ """
+ Write thumbnail image file for each given HD image.
+
+ Parameters
+ ----------
+ image_path_s
+ Paths to HD image files.
+ thumbnail_width
+ Width of the thumbnail image (in pixels).
+ thumbnail_format
+ File format of the thumbnail image.
+ skip_error
+ Whether to ignore errors.
+ overwrite
+ Whether to overwrite an existing thumbnail image file.
+ permissions
+ File permissions for the thumbnail image.
+
+ Returns
+ -------
+ Path
+ Paths to output thumbnail image files.
+ """
+ thumbnail_path_s: list[Path] = []
+ for image_path in image_path_s:
+ if isinstance(image_path, str):
+ image_path = Path(image_path)
+ try:
+ thumbnail_path_s.append(
+ create_thumbnail(
+ image_path,
+ thumbnail_width=thumbnail_width,
+ thumbnail_format=thumbnail_format,
+ overwrite=overwrite,
+ permissions=permissions,
+ )
+ )
+ except Exception as e:
+ logging.error("failed to create thumbnail for %s: %s", image_path.stem, e)
+ if skip_error:
+ logging.error("-> skipping the exception")
+ else:
+ raise e
+ return thumbnail_path_s
+
+
+# TODO: Improve maintainability of docstrings?
+# @Vincent, @Jean-Claude:
+# Would it make sense to document parameters like `history`
+# with `parameter for function 'folder_change.folder_has_changed'`
+# instead of duplicating the documentation?
+# This might make it easier to maintain the code in the case that
+# the used function changes?
+def create_all_thumbnails(
+ walk_folders: Callable[[], Generator[Path, None, None]],
+ list_images: Callable[[Path], Iterable[Path]],
+ thumbnail_width: int = THUMBNAIL_WIDTH,
+ thumbnail_format: str = THUMBNAIL_FILE_FORMAT,
+ skip_error: bool = True,
+ nb_workers: int = _nb_workers,
+ overwrite: bool = False,
+ history: Optional[dict[Path, Optional[float]]] = None,
+ permissions: int = FILE_PERMISSIONS,
+) -> None:
+ """
+ Write thumbnail image file for all images in the date directories.
+
+ Parameters
+ ----------
+ walk_folders
+ Function for iterating over date directories
+ (containing image files).
+ list_images
+ Function for detecting and listing all image files
+ in the date directories.
+ thumbnail_width
+ Width of the thumbnail image (in pixels).
+ thumbnail_format
+ File format of the thumbnail image.
+ skip_error
+ Whether to ignore errors.
+ overwrite
+ Whether to overwrite an existing thumbnail image file.
+ history
+ Last modification times found in previous check:
+ - key:
+ Path to directory.
+ - value:
+ Time of last modification of directory.
+ permissions
+ File permissions for the thumbnail image.
+ """
+ # With process pool
+ # for executing tasks asynchronously.
+ with ProcessPoolExecutor(max_workers=nb_workers) as executor:
+ # Iterate over date directories
+ # (containing image files).
+ for folder in walk_folders():
+ # If last-modified-time of directory changed.
+ if folder_has_changed(folder, history):
+ # Image files in date directory.
+ image_path_s = list(list_images(folder))
+ # Submit task to process pool.
+ executor.submit(
+ # Callable.
+ create_thumbnails,
+ # Parameters of callable.
+ image_path_s,
+ thumbnail_width=thumbnail_width,
+ thumbnail_format=thumbnail_format,
+ skip_error=skip_error,
+ overwrite=overwrite,
+ permissions=permissions,
+ )
diff --git a/nightskycam_images/version.py b/nightskycam_images/version.py
new file mode 100644
index 0000000..a4e2017
--- /dev/null
+++ b/nightskycam_images/version.py
@@ -0,0 +1 @@
+__version__ = "0.1"
diff --git a/nightskycam_images/video.py b/nightskycam_images/video.py
new file mode 100644
index 0000000..0f14d4b
--- /dev/null
+++ b/nightskycam_images/video.py
@@ -0,0 +1,375 @@
+from concurrent.futures import ProcessPoolExecutor
+from contextlib import contextmanager
+import logging
+from multiprocessing import cpu_count
+from pathlib import Path
+import shutil
+import tempfile
+from typing import Any, Callable, Generator, Iterable, Optional, Sequence
+
+import cv2
+import numpy as np
+
+from .constants import FILE_PERMISSIONS
+from .convert_npy import npy_file_to_numpy
+from .folder_change import folder_has_changed
+
+# TODO: Confirm.
+# @Vincent:
+# If I remember correctly, the modules mainly use type hints in the
+# format of Tuple[int, int] instead of tuple[int, int].
+# Is the former format preferred?
+
+
+class TextFormat:
+ def __init__(self) -> None:
+ self.position: tuple[int, int] = (10, 50)
+ self.font = cv2.FONT_HERSHEY_SIMPLEX
+ self.font_scale: int = 1
+ self.color: tuple[int, int, int] = (255, 0, 0)
+ self.thickness: int = 2
+
+
+class VideoFormat:
+ def __init__(self) -> None:
+ self.filename_stem: str = "day_summary"
+ self.size: tuple[int, int] = (480, 360) # (width, height).
+ self.codec = cv2.VideoWriter_fourcc(*"VP90") # type: ignore
+ self.format: str = "webm"
+ self.fps: float = 10.0
+ self.text_format: TextFormat = TextFormat()
+
+ @classmethod
+ def from_dict(cls, config: dict[str, Any]) -> "VideoFormat":
+ instance = cls()
+ vkeys = ("size", "codec", "format", "fps", "filename")
+ for k in vkeys:
+ if k not in config.keys():
+ raise KeyError(
+ "failed to create an instance of" f"VideoFormat: missing key {k}"
+ )
+ setattr(instance, k, config[k])
+ tkeys = (
+ "font",
+ "font_scale",
+ "font_color",
+ "font_thickness",
+ "text_position",
+ )
+ for k in tkeys:
+ if k not in config.keys():
+ raise KeyError(
+ "failed to create an instance of" f"VideoFormat: missing key {k}"
+ )
+ instance.text_format.font = config["font"]
+ instance.text_format.font_scale = config["font_scale"]
+ instance.text_format.color = config["font_color"]
+ instance.text_format.thickness = config["font_thickness"]
+ instance.text_format.position = config["text_position"]
+ return instance
+
+
+_video_format = VideoFormat()
+_nb_workers = 1 if cpu_count() == 1 else cpu_count() - 1
+
+
+@contextmanager
+def _get_video_capture(
+ video_path: Path,
+) -> Generator[cv2.VideoCapture, None, None]:
+ """
+ Get video capture instance,
+ which is used for opening/reading video files.
+
+ Parameters
+ ----------
+ video_path
+ Path to video file.
+
+ Yields
+ ------
+ cv2.VideoCapture
+ Video capture instance.
+ """
+ capture = cv2.VideoCapture(str(video_path))
+ yield capture
+ capture.release()
+
+
+@contextmanager
+def _get_video_writer(
+ video_path: Path, video_format: VideoFormat
+) -> Generator[cv2.VideoWriter, None, None]:
+ """
+ Get video writer instance,
+ which is used for writing video files.
+
+ Parameters
+ ----------
+ video_path
+ Path to output video file.
+
+ Yields
+ ------
+ cv2.VideoWriter
+ Video writer instance.
+ """
+ writer = cv2.VideoWriter(
+ str(video_path),
+ video_format.codec,
+ video_format.fps,
+ video_format.size,
+ )
+ yield writer
+ writer.release()
+
+
+def _write_to_image(image: np.ndarray, text: str, text_format: TextFormat) -> None:
+ """
+ Write text to the image.
+
+ Parameters
+ ----------
+ image
+ Image.
+ text
+ Text that will be added to the image.
+ text_format
+ Format specification for the text.
+ """
+ cv2.putText( # type: ignore
+ image,
+ text,
+ text_format.position,
+ text_format.font,
+ text_format.font_scale,
+ text_format.color,
+ text_format.thickness,
+ )
+
+
+def _setup_image_array(
+ image_path: Path, text: Optional[str], video_format: VideoFormat
+) -> Optional[np.ndarray]:
+ """
+ Set up image array in preparation for creating video:
+ - Resize image to video format size.
+ - Add (optional) text to image.
+
+ Parameters
+ ----------
+ image_path
+ Path to image file.
+ text
+ Text that will be added to the image.
+ video_format
+ Format configurations (e.g. size) for video.
+
+ Returns
+ -------
+ np.ndarray
+ Numpy array of the result image.
+ """
+ # If it is a numpy pickle file.
+ if image_path.suffix == ".npy":
+ # Load pickled array from file.
+ image_array = npy_file_to_numpy(image_path)
+ # If it is NOT a numpy pickle file.
+ else:
+ image_array = cv2.imread(str(image_path))
+
+ # Resize image to set video format size.
+ image_array = cv2.resize(image_array, video_format.size)
+ if text:
+ # Add text to image.
+ _write_to_image(image_array, text, video_format.text_format)
+
+ return image_array
+
+
+def _count_frames(video_path: Path) -> int:
+ """
+ Get number of frames for video.
+
+ Parameters
+ ----------
+ video_path
+ Path to video file.
+
+ Returns
+ -------
+ int
+ Number of frames.
+ """
+ with _get_video_capture(video_path) as capture:
+ n_frames = int(capture.get(cv2.CAP_PROP_FRAME_COUNT))
+ return n_frames
+
+
+def _write_video(
+ video_path: Path,
+ image_path_s: Iterable[Path],
+ text_s: Iterable[Optional[str]] = tuple(),
+ video_format: VideoFormat = _video_format,
+ permissions: int = FILE_PERMISSIONS,
+) -> None:
+ """
+ Write video to file.
+
+ Parameters
+ ----------
+ video_path
+ Path to video file.
+ image_path_s
+ Paths to image files.
+ text_s
+ Texts that will be added to images.
+ video_format
+ Format configurations (e.g. size) for video.
+ permissions
+ File permissions for resulting video file.
+ """
+ with _get_video_writer(video_path, video_format) as writer:
+ for image, text in zip(image_path_s, text_s):
+ image_array = _setup_image_array(image, text, video_format)
+ if image_array is not None:
+ writer.write(image_array)
+ # Set file permissions.
+ video_path.chmod(permissions)
+
+
+def _create_video(
+ video_path: Path,
+ image_path_s: Sequence[Path],
+ text_s: Sequence[Optional[str]] = tuple(),
+ video_format: VideoFormat = _video_format,
+ permissions: int = FILE_PERMISSIONS,
+) -> None:
+ """
+ Write/overwrite video file, if necessary.
+
+ If the target video file already exists but does NOT contain all images,
+ overwrite existing video with updated video (containing all images).
+
+ Parameters
+ ----------
+ video_path
+ Path to video file.
+ image_path_s
+ Paths to image files.
+ text_s
+ Texts that will be added to images.
+ video_format
+ Format configurations (e.g. size) for video.
+ permissions
+ File permissions for resulting video file.
+ """
+ if not image_path_s:
+ return
+
+ # If there is NO already existing video file.
+ if not video_path.is_file():
+ # Create new video file.
+ logging.info(
+ "using %s images to create video %s.", len(image_path_s), video_path
+ )
+ _write_video(video_path, image_path_s, text_s, video_format, permissions)
+ return
+
+ # If there is already an existing video file.
+
+ # Get number of frames in existing video file.
+ n_frames = _count_frames(video_path)
+ # If existing video already contains all images.
+ if n_frames == len(image_path_s):
+ return
+
+ # Only used for logging.
+ if n_frames < 0:
+ n_frames = 0
+
+ # Create new video file to replace existing one.
+ logging.info("adding %s new images to %s", len(image_path_s) - n_frames, video_path)
+ with tempfile.TemporaryDirectory() as tmp_dir:
+ tmp_output = Path(tmp_dir) / f"out.{video_format.format}"
+ _write_video(tmp_output, image_path_s, text_s, video_format)
+ # Replace existing video file.
+ shutil.move(str(tmp_output), video_path)
+ # Set file permissions.
+ video_path.chmod(permissions)
+
+
+def create_video(
+ video_path: Path,
+ image_path_s: Sequence[Path],
+ text_s: Sequence[Optional[str]] = tuple(),
+ video_format: VideoFormat = _video_format,
+ # TODO: confirm.
+ # @Vincent:
+ # Is it safe to ignore all errors as the default behaviour?
+ skip_error: bool = True,
+ permissions: int = FILE_PERMISSIONS,
+) -> None:
+ """
+ Write/overwrite video file, if necessary.
+
+ If the target video file already exists but does NOT contain all images,
+ overwrite existing video with updated video (containing all images).
+
+ Parameters
+ ----------
+ video_path
+ Path to video file.
+ image_path_s
+ Paths to image files.
+ text_s
+ Texts that will be added to images.
+ video_format
+ Format configurations (e.g. size) for video.
+ skip_error
+ Whether to ignore errors.
+ permissions
+ File permissions for resulting video file.
+ """
+ try:
+ _create_video(
+ video_path,
+ image_path_s,
+ text_s=text_s,
+ video_format=video_format,
+ permissions=permissions,
+ )
+ except Exception as e:
+ logging.error("failed to create/update video %s: %s", video_path, e)
+ if skip_error:
+ pass
+ else:
+ raise e
+
+
+def create_all_videos(
+ filename: str,
+ walk_folders: Callable[[], Generator[Path, None, None]],
+ list_images: Callable[[Path], Sequence[Path]],
+ extract_text: Callable[[Path], str],
+ video_format: VideoFormat = _video_format,
+ skip_error: bool = True,
+ nb_workers: int = _nb_workers,
+ history: Optional[dict[Path, Optional[float]]] = None,
+ permissions: int = 0o755,
+) -> None:
+ with ProcessPoolExecutor(max_workers=nb_workers) as executor:
+ for folder in walk_folders():
+ if folder_has_changed(folder, history):
+ images = list_images(folder)
+ output = folder / "thumbnails" / f"{filename}.{video_format.format}"
+ texts = [extract_text(image) for image in images]
+ executor.submit(
+ create_video,
+ output,
+ images,
+ texts,
+ video_format,
+ skip_error,
+ permissions=permissions,
+ )
diff --git a/nightskycam_images/walk.py b/nightskycam_images/walk.py
new file mode 100644
index 0000000..73db10c
--- /dev/null
+++ b/nightskycam_images/walk.py
@@ -0,0 +1,634 @@
+import datetime as dt
+import os
+from pathlib import Path
+from typing import (
+ Any,
+ Dict,
+ Generator,
+ Iterable,
+ List,
+ NewType,
+ Optional,
+ Tuple,
+ Union,
+ cast,
+)
+import zipfile
+
+import toml
+import tomli_w
+
+from .constants import (
+ DATE_FORMAT_FILE,
+ DATETIME_FORMATS,
+ THUMBNAIL_DIR_NAME,
+ THUMBNAIL_FILE_FORMAT,
+)
+from .image import Image
+from .weather import WeatherReport
+
+Month = NewType("Month", int)
+Year = NewType("Year", int)
+WEATHER_FILENAME = "weathers.toml"
+
+# TODO: Alias `iter_system_paths`?
+
+
+def walk_systems(root: Path) -> Generator[Path, None, None]:
+ """
+ Iterate over paths of directories (representing systems)
+ in media root directory.
+
+ Parameters
+ ----------
+ root
+ Path to media root directory.
+
+ Yields
+ ------
+ Path
+ Absolute path to system directory.
+ """
+ if not root.is_dir():
+ raise FileNotFoundError(
+ f"Failed to open nightskycam root {root}: not a directory"
+ )
+
+ # Directories and files in root directory.
+ for path in root.iterdir(): # Note: iterdir is not ordered.
+ # Only yield directories.
+ if path.is_dir():
+ yield path
+
+
+def get_system_path(root: Path, system_name: str) -> Optional[Path]:
+ """
+ Get path to directory (representing system) in media root directory.
+
+ Parameters
+ ----------
+ root
+ Path to media root directory.
+ system_name
+ Name of system.
+
+ Returns
+ -------
+ Path
+ Absolute path to system directory.
+ """
+ for system_path in walk_systems(root):
+ if system_path.name == system_name:
+ return system_path
+ return None
+
+
+# TODO: improve handling of future dates?
+# @Vincent:
+# Is it intended that this function returns True for all future dates?
+# Would it make sense to raise an exception for future dates?
+def _is_date_within_days(date: dt.date, nb_days: Optional[int]) -> bool:
+ """
+ Whether date is within nb_days in the past (counting today).
+
+ I.e. for nb_days=1:
+ - return False for any date before today.
+ - return True for today (and any future date).
+ """
+ today = dt.datetime.now().date()
+
+ if nb_days is None:
+ return True
+
+ return (today - date).days < nb_days
+
+
+def walk_dates(
+ system_dir: Path, within_nb_days: Optional[int] = None
+) -> Generator[Tuple[dt.date, Path], None, None]:
+ """
+ Iterate over dates in system directory.
+
+ Parameters
+ ----------
+ system_dir
+ Path to system directory.
+ within_nb_days
+ If specified:
+ only yield dates that are within number of days
+ (counting today).
+
+ Yields
+ ------
+ datetime.date
+ Date instance.
+ Path
+ Path of date directory.
+ """
+ if not system_dir.is_dir():
+ raise FileNotFoundError(
+ f"Failed to open nightskycam folder {system_dir}: not a directory"
+ )
+
+ # Directories and files in system directory.
+ for path in system_dir.iterdir(): # Note: iterdir is not ordered.
+ # Only use directories.
+ if path.is_dir():
+ date_ = dt.datetime.strptime(path.name, DATE_FORMAT_FILE).date()
+ if within_nb_days is None or _is_date_within_days(date_, within_nb_days):
+ yield date_, path
+ return None
+
+
+def walk_all(
+ root: Path,
+ within_nb_days: Optional[int] = None,
+ specific_date: Optional[dt.date] = None,
+) -> Generator[Path, None, None]:
+ """
+ Iterate over paths of directories (representing dates)
+ in ALL system directories present in media root directory.
+
+ Parameters
+ ----------
+ root
+ Path to media root directory.
+ within_nb_days
+ If specified:
+ only yield dates that are within number of days
+ (counting today).
+ specific_date
+ If specified:
+ only return paths for the specified date.
+
+ Yields
+ ------
+ Path
+ Path of date directory
+ """
+ for system_path in walk_systems(root):
+ for date_, date_path in walk_dates(system_path, within_nb_days=within_nb_days):
+ if specific_date is None:
+ yield date_path
+ else:
+ if specific_date == date_:
+ yield date_path
+ return None
+
+
+# TODO: Add docstring + test.
+def walk_thumbnails(
+ root: Path,
+) -> Generator[Path, None, None]: # TODO: Rename `root` -> `data_dir_path`.
+ for folder in walk_all(root):
+ f = folder / THUMBNAIL_DIR_NAME
+ if f.is_dir():
+ yield f
+ return None
+
+
+# TODO: Rename function to `get_date_path` (analogous to `get_system_path`).
+def get_images_folder(root: Path, system_name: str, date: dt.date) -> Optional[Path]:
+ """
+ Get directory (containing the image files) for specified date and system.
+
+ Parameters
+ ----------
+ root
+ Path to media root directory.
+ system_name
+ Name of system.
+ date
+ Date instance.
+
+ Returns
+ -------
+ Optional[Path]
+ Path to date directory (containing the image files).
+ """
+ system_path = get_system_path(root, system_name)
+ if system_path is None:
+ return None
+ for date_, date_path in walk_dates(system_path):
+ if date_ == date:
+ return date_path
+ return None
+
+
+def get_ordered_dates(
+ root: Path, system_name: str
+) -> Dict[Year, Dict[Month, List[Tuple[dt.date, Path]]]]:
+ """
+ Get ordered dates and their directory paths (grouped by year and month)
+ for the specified system.
+
+ Parameters
+ ----------
+ root
+ Path to media root directory.
+ system_name
+ Name of system.
+
+ Returns
+ -------
+ Dict
+ Grouped and ordered dates.
+ - key: Year
+ - value: Dict
+ - key: Month
+ - value: List[Tuple]:
+ - datetime.date:
+ Date instance.
+ - Path:
+ Date directory path.
+ """
+
+ year_to_month_dict: Dict[Year, Dict[Month, List[Tuple[dt.date, Path]]]] = {}
+
+ system_path = get_system_path(root, system_name)
+ if system_path is None:
+ return year_to_month_dict
+
+ for date_, date_path in walk_dates(system_path):
+
+ # Note:
+ # `cast` is a signal to type checker
+ # (returns value unchanged).
+ year = cast(Year, date_.year)
+ month = cast(Month, date_.month)
+
+ try:
+ month_dict = year_to_month_dict[year]
+ # If dictionary does NOT have this year as key yet.
+ except KeyError:
+ month_dict = {}
+ year_to_month_dict[year] = month_dict
+
+ try:
+ date_and_path_s = month_dict[month]
+ # If dictionary does NOT have this month as key yet.
+ except KeyError:
+ date_and_path_s = []
+ month_dict[month] = date_and_path_s
+
+ date_and_path_s.append((date_, date_path))
+
+ # Ensure correct order of list items.
+ year_to_month_dict = {
+ year: {
+ # Sort tuples (date, path) in list by date,
+ # otherwise order would be arbitrary on some operating systems.
+ month: sorted(date_and_path_s)
+ for month, date_and_path_s in month_to_date_and_path_s.items()
+ }
+ for year, month_to_date_and_path_s in year_to_month_dict.items()
+ }
+
+ return year_to_month_dict
+
+
+def parse_image_path(
+ image_file_path: Path,
+ datetime_formats: Union[str, Tuple[str, ...]] = DATETIME_FORMATS,
+) -> Tuple[str, dt.datetime]:
+ """
+ Get system name and datetime instance by parsing the name of the
+ (HD or thumbnail) image file.
+
+ Parameters
+ ----------
+ image
+ Path of image file.
+ datetime_formats
+ Possible patterns of datetime format.
+
+ Returns
+ -------
+ str
+ System name.
+ datetime.datetime
+ Datetime instance.
+ """
+ # File name (without suffix).
+ filename_stem = image_file_path.stem
+ filename_parts = filename_stem.split("_")
+
+ # If single string value was given as datetime-format.
+ if isinstance(datetime_formats, str):
+ # Bundle datetime-format in an iterable.
+ datetime_formats = (datetime_formats,)
+
+ for datetime_format in datetime_formats:
+ # Number of parts in datetime-format.
+ n = datetime_format.count("_") + 1
+ # Partition.
+ system_str = "_".join(filename_parts[:-n])
+ datetime_str = "_".join(filename_parts[-n:])
+
+ try:
+ datetime_ = dt.datetime.strptime(datetime_str, datetime_format)
+ except ValueError:
+ pass
+ else:
+ break
+ return system_str, datetime_
+
+
+def _get_image_instance(
+ thumbnail_file_path: Path,
+ datetime_formats: Tuple[str, ...] = DATETIME_FORMATS,
+) -> Image:
+ """
+ Set up and return an image instance for the given thumbnail file path.
+ """
+
+ def _get_folder(thumbnail_file_path: Path) -> Path:
+ # FIXME FIXME FIXME
+ # JC: Bug responsible for adding a leading / at the beginning of the path
+ # because the first part of the path is '/'
+ # This implementation is really robust,
+ # as it relies on having one and only one "thumbnails folder"
+ # Also, why do we return this value in case of an error?
+
+ path_rec = thumbnail_file_path
+
+ try:
+ # We go up until we either find "thumbnails" or reach the root
+ root_path = thumbnail_file_path.absolute().anchor # "/" in POSIX
+ while thumbnail_file_path.name != "thumbnails" and thumbnail_file_path != root_path:
+ thumbnail_file_path = thumbnail_file_path.parent
+ return thumbnail_file_path.parent
+ except ValueError:
+ return path_rec.parent
+
+ system_name, datetime = parse_image_path(
+ thumbnail_file_path, datetime_formats=datetime_formats
+ )
+
+ # Set up Image instance.
+ instance = Image()
+ instance.filename_stem = thumbnail_file_path.stem
+ instance.date_and_time = datetime
+ instance.system = system_name
+ instance.dir_path = _get_folder(thumbnail_file_path)
+
+ return instance
+
+
+def get_images(
+ date_dir_path: Path,
+ datetime_formats: Tuple[str, ...] = DATETIME_FORMATS,
+) -> List[Image]:
+ """
+ Get image instances (contains paths of both HD and thumbnail images).
+
+ Parameters
+ ----------
+ date_dir_path
+ Path to date directory.
+ datetime_formats
+ Possible patterns of datetime format.
+
+ Returns
+ -------
+ List[Image]
+ List of image instances.
+ """
+ # Directory containing thumbnail images.
+ thumbnail_dir_path = date_dir_path / THUMBNAIL_DIR_NAME
+
+ # If thumbnail directory does NOT exist.
+ if not thumbnail_dir_path.is_dir():
+ return []
+
+ thumbnail_file_paths: List[Path] = [
+ file_path
+ # Note: iterdir is not ordered.
+ for file_path in thumbnail_dir_path.iterdir()
+ if file_path.is_file() and file_path.suffix == f".{THUMBNAIL_FILE_FORMAT}"
+ ]
+ return [
+ # Convert path to image instance.
+ _get_image_instance(
+ thumbnail_file_path,
+ datetime_formats=datetime_formats,
+ )
+ for thumbnail_file_path in thumbnail_file_paths
+ ]
+
+
+# TODO: Add test.
+# TODO: Confirm?
+# @Vincent:
+# Is this function used externally?
+# If not, it might make sense to make this function private.
+def get_weather_report(
+ root: Path,
+ # TODO: Rename `root` -> `date_dir_path`.
+ file_name: str = WEATHER_FILENAME,
+) -> Optional[WeatherReport]:
+ """
+ Parse weather file (TOML format) in date directory.
+
+ Parameters
+ ----------
+ root
+ Path to media root directory.
+ file_name
+ Name of weather file.
+
+ Returns
+ -------
+ Optional[WeatherReport]
+ WeatherReport instance parsed from weather file.
+ (None, if parsing failed.)
+ """
+ file_path = root / file_name
+ if not file_path.is_file():
+ return None
+
+ try:
+ report = toml.load(file_path)
+ except toml.decoder.TomlDecodeError:
+ return None
+
+ return report["weathers"], report["skipped"]
+
+
+# TODO: Rename to `get_ordered_dates_with_info`?
+def get_monthly_nb_images(
+ root: Path,
+ system_name: str,
+ year: Year,
+ month: Month,
+ datetime_formats: Tuple[str, ...] = DATETIME_FORMATS,
+) -> List[Tuple[dt.date, Path, int, Optional[WeatherReport]]]:
+ """
+ Get the date instance, date directory path, the number of
+ image instances and the weather report
+ for each date (ordered) of the specified system, year and month.
+
+ Parameters
+ ----------
+ root
+ Path to media root directory.
+ system_name
+ Name of system.
+ year
+ Year of query.
+ month
+ Month of query.
+ datetime_formats
+ Possible patterns of datetime format.
+
+ Returns
+ -------
+ List[Tuple]:
+ - datetime.date:
+ Date instance.
+ - Path:
+ Date directory path.
+ - int:
+ Number of image instances for date.
+ - Optional[WeatherReport]:
+ WeatherReport instance parsed from weather file (TOML format)
+ in date directory.
+ (None, if parsing failed.)
+ """
+ year_to_month_dict = get_ordered_dates(root, system_name)
+
+ try:
+ month_dict = year_to_month_dict[year]
+ except KeyError:
+ return []
+
+ try:
+ date_and_path_s = month_dict[month]
+ except KeyError:
+ return []
+
+ return [
+ (
+ date_,
+ date_path,
+ len(get_images(date_path, datetime_formats=datetime_formats)),
+ get_weather_report(date_path),
+ )
+ for date_, date_path in date_and_path_s
+ ]
+
+
+# TODO: Add test.
+# TODO: Confirm?
+# @Vincent:
+# Is this function used externally?
+# If not, it might make sense to make this function private.
+def meta_data_file(
+ images: Iterable[Image],
+ target_file: Path, # TODO: Rename to `zip_file`
+ datetime_format: str = DATETIME_FORMATS[0],
+) -> None:
+ """
+ Write meta data of images to target file (in TOML format).
+ """
+ all_meta: Dict[str, Dict[str, Any]] = {}
+ for image in images:
+ if image.meta and image.date_and_time:
+ all_meta[image.date_and_time.strftime(datetime_format)] = image.meta
+ # TODO: Confirm.
+ # @Vincent:
+ # Does this work as intended?
+ # Is it intended to directly write to the zip archive file?
+ # In the subsequent steps in `_create_zip_file` the meta data
+ # files are also added to the zip archive file (again), but this
+ # time with ZipFile.write.
+ with open(target_file, "wb") as f:
+ tomli_w.dump(all_meta, f)
+
+
+def _create_zip_file(
+ images: Iterable[Image],
+ target_file: Path, # TODO: Rename to `zip_file`?
+ meta_file: Optional[Path] = None,
+ datetime_format: str = DATETIME_FORMATS[0],
+) -> None:
+ """
+ Create zip file containing images (and optional meta data).
+ """
+ # All images are relevant for zipping.
+ all_files = [image.hd for image in images if image.hd]
+
+ if meta_file:
+ # Add meta data file as relevant for zipping.
+ meta_data_file(images, meta_file, datetime_format=datetime_format)
+ all_files.append(meta_file)
+
+ # Add all files to zip archive.
+ with zipfile.ZipFile(target_file, "w") as zipf:
+ for file_ in all_files:
+ if file_ is not None:
+ zipf.write(file_, arcname=file_.name)
+
+
+# TODO: Remove argument `zip_dir_path`?
+# @Vincent:
+# Would it make sense to remove the argument `zip_dir_path` and
+# use a new constant `ZIP_DIR_NAME`
+# (similar to `THUMBNAIL_DIR_NAME`, inside date_dir_path)?
+# @Vincent:
+# Would it make sense to use a temporary directory for the zip
+# directory, as the zip file currently does not seem reusable?
+def images_zip_file(
+ root: Path,
+ system_name: str,
+ date: dt.date,
+ zip_dir_path: Path,
+ datetime_formats: Tuple[str, ...] = DATETIME_FORMATS,
+ date_format: str = DATE_FORMAT_FILE,
+ only_if_toml: bool = False, # TODO: Never used in function -> remove?
+) -> Path:
+ """
+ Create and get zip archive for images (and optional meta data).
+
+ Parameters
+ ----------
+ root
+ Path to media root directory.
+ system_name
+ Name of system.
+ date
+ Date instance of query.
+ zip_dir_path
+ Path of zip directory.
+ datetime_formats
+ Possible patterns of datetime format.
+ date_format
+ Patterns of date format.
+
+ Returns
+ -------
+ Path
+ Path of zip file.
+ """
+ date_dir_path = get_images_folder(root, system_name, date)
+ if date_dir_path is None:
+ raise ValueError(
+ f"failed to find any image for system {system_name} at date {date}"
+ )
+ images = get_images(
+ date_dir_path,
+ datetime_formats=datetime_formats,
+ )
+
+ date_str = date.strftime(date_format)
+ meta_file_path = zip_dir_path / f"{system_name}_{date_str}.toml"
+ target_file_path = zip_dir_path / f"{system_name}_{date_str}.zip"
+
+ _create_zip_file(
+ images,
+ target_file_path,
+ meta_file=meta_file_path,
+ datetime_format=datetime_formats[0],
+ )
+
+ return target_file_path
diff --git a/nightskycam_images/weather.py b/nightskycam_images/weather.py
new file mode 100644
index 0000000..bccb0a3
--- /dev/null
+++ b/nightskycam_images/weather.py
@@ -0,0 +1,243 @@
+import logging
+from pathlib import Path
+from typing import Callable, Dict, Generator, List, Optional, Tuple
+
+import toml
+import tomli_w
+
+from .constants import FILE_PERMISSIONS, IMAGE_FILE_FORMATS, WEATHER_SUMMARY_FILE_NAME
+from .folder_change import folder_has_changed
+
+# linking weather description from here: https://www.meteosource.com/documentation
+# to icon from here: https://icons.getbootstrap.com/
+
+WEATHER_TO_BOOTSTRAP_ICON: Dict[str, str] = {
+ "not available": "question-circle",
+ "sunny": "stars",
+ "mostly sunny": "cloud-moon",
+ "partly sunny": "cloud-moon",
+ "mostly cloudy": "cloud",
+ "cloudy": "cloud",
+ "overcast": "cloud-fill",
+ "fog": "cloud-fog",
+ "light rain": "cloud-drizzle",
+ "rain": "cloud-rain-heavy",
+ "possible rain": "cloud-drizzle",
+ "rain shower": "cloud-drizzle",
+ "thunderstorm": "cloud-lightning",
+ "light snow": "cloud-snow",
+ "snow": "cloud-snow",
+ "possible snow": "cloud-snow",
+ "snow shower": "cloud-snow",
+ "hail": "cloud-hail",
+ "clear": "stars",
+ "mostly clear": "cloud-moon",
+ "partly clear": "cloud-moon",
+}
+
+# ( {weather type: nb of images of this weather} , number of images skipped)
+WeatherReport = Tuple[Dict[str, int], int]
+
+
+def _bootstrap_wrap(icon: str) -> str:
+ # HTML command for adding bootstrap icon.
+ return f''
+
+
+def get_weather_icon(weather: str) -> str:
+ """
+ Get HTML command for adding icon for weather.
+ If no icon can be matched to the weather, return raw input string of
+ weather instead.
+
+ Parameters
+ ----------
+ weather
+ Weather description from meteosource
+ (https://www.meteosource.com/documentation).
+
+ Returns
+ -------
+ str
+ HTML command for adding weather icon
+ OR
+ input weather string, if NO matching icon was found.
+ """
+ # If weather string is specified in dictionary.
+ try:
+ return _bootstrap_wrap(WEATHER_TO_BOOTSTRAP_ICON[weather.lower()])
+ except KeyError:
+ pass
+
+ # If weather string is NOT specified in dictionary.
+ # Approximate with most closely related key string in dictionary.
+ for superkey in (
+ "rain",
+ "clear",
+ "snow",
+ "sunny",
+ "overcast",
+ "thunderstorm",
+ "fog",
+ ):
+ if superkey in weather.lower():
+ return _bootstrap_wrap(WEATHER_TO_BOOTSTRAP_ICON[superkey])
+
+ # If approximation was NOT possible.
+ # Fallback: Use raw input string instead of an icon.
+ return weather
+
+
+def weather_summary(
+ folder: Path,
+ summary_path: Optional[Path] = None,
+ permissions: int = FILE_PERMISSIONS,
+) -> WeatherReport:
+ """
+ Create weather summary for a date directory
+ (containing pairs of image and weather files).
+
+ Parameters
+ ----------
+ folder
+ Path to date directory containing the image and weather files (toml format).
+ There is a weather file for each image file.
+ These individual weather files will be used for creating the output
+ weather summary.
+ toml_report
+ Path to weather summary file (toml format).
+ permissions
+ File permissions for the weather summary file.
+
+ Returns
+ -------
+ WeatherReport
+ - Dict:
+ - key: weather type
+ - value: number of images
+ - number of skipped images
+ """
+ # Get individual weather files (toml format).
+ #
+ # Get all toml files in the directory.
+ toml_files: List[Path] = list(folder.glob("*.toml"))
+ # If result summary file was specified.
+ if summary_path is not None:
+ # Exclude toml file with same stem as the summary file.
+ #
+ # TODO: match filename instead of stem?
+ # @Vincent:
+ # Would it be safer to match the filename instead of only the stem?
+ toml_files = [tf for tf in toml_files if not tf.stem == summary_path.stem]
+
+ # Get all image files in the directory.
+ image_path_s: List[Path] = []
+ for ext in IMAGE_FILE_FORMATS:
+ image_path_s.extend(folder.glob(f"*.{ext}"))
+ image_stem_s = [img.stem for img in image_path_s]
+
+ # {weather type: nb of images of this weather}
+ weather_to_count: Dict[str, int] = {}
+ # TODO#1: confirm (connected to TODO#2).
+ # @Vincent:
+ # Do I understand this correctly?
+ #
+ # Number of TOML-files that can be parsed as TOML,
+ # but are skipped because of NOT containing weather information.
+ skipped = 0
+
+ for tf in toml_files:
+ weather: Optional[str] = None
+ try:
+ # Parse as dictionary.
+ content = toml.load(tf)
+ # TOML file could NOT be parsed.
+ except Exception:
+ # TODO#2: confirm (connected to TODO#1).
+ # @Vincent:
+ # Is it intended that this does NOT increase the
+ # `skipped` counter?
+ continue
+ try:
+ weather = content["weather"]
+ except KeyError:
+ if tf.stem in image_stem_s:
+ weather = "?"
+ else:
+ skipped += 1
+ continue
+ if weather is not None:
+ try:
+ weather_to_count[weather] += 1
+ # New key.
+ except KeyError:
+ # Initialise new key with counter.
+ weather_to_count[weather] = 1
+
+ if summary_path is not None:
+ with open(summary_path, "wb") as f:
+ tomli_w.dump({"skipped": skipped, "weathers": weather_to_count}, f)
+ # Set file permissions for weather summary.
+ summary_path.chmod(permissions)
+ logging.info("created weather summary for %s successfully", folder)
+
+ return weather_to_count, skipped
+
+
+def create_weather_summaries(
+ walk_folders: Callable[[], Generator[Path, None, None]],
+ history: Optional[dict[Path, Optional[float]]] = None,
+ permissions: int = FILE_PERMISSIONS,
+ summary_file_name: str = WEATHER_SUMMARY_FILE_NAME,
+) -> None:
+ """
+ Create weather summary for one or more date directories
+ (containing pairs of image and weather files).
+
+ Parameters
+ ----------
+ walk_folders
+ Function for iterating over date directories
+ (containing image and weather files).
+ history
+ Last modification times found in previous check:
+ - key:
+ Path to directory.
+ - value:
+ Time of last modification of directory.
+ permissions
+ File permissions for the weather summary file.
+ summary_filename
+ Name of weather summary file.
+ """
+ # Iterate over date directories
+ # (containing image and weather files).
+ for folder in walk_folders():
+ # If last-modified-time of directory changed.
+ if folder_has_changed(folder, history):
+ # Create weather summary file for directory.
+ weather_summary(folder, summary_path=folder / summary_file_name)
+
+
+def weather_report_to_str(
+ report: Optional[WeatherReport], short: bool = False, html: bool = False
+) -> str:
+ if report is None:
+ return ""
+ total_images = sum(report[0].values())
+ skipped = report[1]
+ images_taken = total_images - skipped
+ if html:
+ icons = set([get_weather_icon(w) for w in report[0].keys()])
+ weathers = " ".join(icons)
+ else:
+ weathers = ", ".join([w for w in report[0].keys()])
+ if short:
+ return f"{images_taken} images - {weathers}"
+ if skipped > 0:
+ return (
+ f"{images_taken} images ({skipped} images skipped because of bad weather). "
+ f"Weather: {weathers}"
+ )
+ else:
+ return f"{images_taken} images. Weather: {weathers}"
diff --git a/poetry.lock b/poetry.lock
new file mode 100644
index 0000000..573312a
--- /dev/null
+++ b/poetry.lock
@@ -0,0 +1,1132 @@
+# This file is automatically @generated by Poetry 1.7.1 and should not be changed by hand.
+
+[[package]]
+name = "attrs"
+version = "24.2.0"
+description = "Classes Without Boilerplate"
+optional = false
+python-versions = ">=3.7"
+files = [
+ {file = "attrs-24.2.0-py3-none-any.whl", hash = "sha256:81921eb96de3191c8258c199618104dd27ac608d9366f5e35d011eae1867ede2"},
+ {file = "attrs-24.2.0.tar.gz", hash = "sha256:5cfb1b9148b5b086569baec03f20d7b6bf3bcacc9a42bebf87ffaaca362f6346"},
+]
+
+[package.extras]
+benchmark = ["cloudpickle", "hypothesis", "mypy (>=1.11.1)", "pympler", "pytest (>=4.3.0)", "pytest-codspeed", "pytest-mypy-plugins", "pytest-xdist[psutil]"]
+cov = ["cloudpickle", "coverage[toml] (>=5.3)", "hypothesis", "mypy (>=1.11.1)", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins", "pytest-xdist[psutil]"]
+dev = ["cloudpickle", "hypothesis", "mypy (>=1.11.1)", "pre-commit", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins", "pytest-xdist[psutil]"]
+docs = ["cogapp", "furo", "myst-parser", "sphinx", "sphinx-notfound-page", "sphinxcontrib-towncrier", "towncrier (<24.7)"]
+tests = ["cloudpickle", "hypothesis", "mypy (>=1.11.1)", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins", "pytest-xdist[psutil]"]
+tests-mypy = ["mypy (>=1.11.1)", "pytest-mypy-plugins"]
+
+[[package]]
+name = "black"
+version = "24.10.0"
+description = "The uncompromising code formatter."
+optional = false
+python-versions = ">=3.9"
+files = [
+ {file = "black-24.10.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e6668650ea4b685440857138e5fe40cde4d652633b1bdffc62933d0db4ed9812"},
+ {file = "black-24.10.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:1c536fcf674217e87b8cc3657b81809d3c085d7bf3ef262ead700da345bfa6ea"},
+ {file = "black-24.10.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:649fff99a20bd06c6f727d2a27f401331dc0cc861fb69cde910fe95b01b5928f"},
+ {file = "black-24.10.0-cp310-cp310-win_amd64.whl", hash = "sha256:fe4d6476887de70546212c99ac9bd803d90b42fc4767f058a0baa895013fbb3e"},
+ {file = "black-24.10.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:5a2221696a8224e335c28816a9d331a6c2ae15a2ee34ec857dcf3e45dbfa99ad"},
+ {file = "black-24.10.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f9da3333530dbcecc1be13e69c250ed8dfa67f43c4005fb537bb426e19200d50"},
+ {file = "black-24.10.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4007b1393d902b48b36958a216c20c4482f601569d19ed1df294a496eb366392"},
+ {file = "black-24.10.0-cp311-cp311-win_amd64.whl", hash = "sha256:394d4ddc64782e51153eadcaaca95144ac4c35e27ef9b0a42e121ae7e57a9175"},
+ {file = "black-24.10.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b5e39e0fae001df40f95bd8cc36b9165c5e2ea88900167bddf258bacef9bbdc3"},
+ {file = "black-24.10.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:d37d422772111794b26757c5b55a3eade028aa3fde43121ab7b673d050949d65"},
+ {file = "black-24.10.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:14b3502784f09ce2443830e3133dacf2c0110d45191ed470ecb04d0f5f6fcb0f"},
+ {file = "black-24.10.0-cp312-cp312-win_amd64.whl", hash = "sha256:30d2c30dc5139211dda799758559d1b049f7f14c580c409d6ad925b74a4208a8"},
+ {file = "black-24.10.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:1cbacacb19e922a1d75ef2b6ccaefcd6e93a2c05ede32f06a21386a04cedb981"},
+ {file = "black-24.10.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:1f93102e0c5bb3907451063e08b9876dbeac810e7da5a8bfb7aeb5a9ef89066b"},
+ {file = "black-24.10.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ddacb691cdcdf77b96f549cf9591701d8db36b2f19519373d60d31746068dbf2"},
+ {file = "black-24.10.0-cp313-cp313-win_amd64.whl", hash = "sha256:680359d932801c76d2e9c9068d05c6b107f2584b2a5b88831c83962eb9984c1b"},
+ {file = "black-24.10.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:17374989640fbca88b6a448129cd1745c5eb8d9547b464f281b251dd00155ccd"},
+ {file = "black-24.10.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:63f626344343083322233f175aaf372d326de8436f5928c042639a4afbbf1d3f"},
+ {file = "black-24.10.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfa1d0cb6200857f1923b602f978386a3a2758a65b52e0950299ea014be6800"},
+ {file = "black-24.10.0-cp39-cp39-win_amd64.whl", hash = "sha256:2cd9c95431d94adc56600710f8813ee27eea544dd118d45896bb734e9d7a0dc7"},
+ {file = "black-24.10.0-py3-none-any.whl", hash = "sha256:3bb2b7a1f7b685f85b11fed1ef10f8a9148bceb49853e47a294a3dd963c1dd7d"},
+ {file = "black-24.10.0.tar.gz", hash = "sha256:846ea64c97afe3bc677b761787993be4991810ecc7a4a937816dd6bddedc4875"},
+]
+
+[package.dependencies]
+click = ">=8.0.0"
+mypy-extensions = ">=0.4.3"
+packaging = ">=22.0"
+pathspec = ">=0.9.0"
+platformdirs = ">=2"
+tomli = {version = ">=1.1.0", markers = "python_version < \"3.11\""}
+typing-extensions = {version = ">=4.0.1", markers = "python_version < \"3.11\""}
+
+[package.extras]
+colorama = ["colorama (>=0.4.3)"]
+d = ["aiohttp (>=3.10)"]
+jupyter = ["ipython (>=7.8.0)", "tokenize-rt (>=3.2.0)"]
+uvloop = ["uvloop (>=0.15.2)"]
+
+[[package]]
+name = "cfgv"
+version = "3.4.0"
+description = "Validate configuration and produce human readable error messages."
+optional = false
+python-versions = ">=3.8"
+files = [
+ {file = "cfgv-3.4.0-py2.py3-none-any.whl", hash = "sha256:b7265b1f29fd3316bfcd2b330d63d024f2bfd8bcb8b0272f8e19a504856c48f9"},
+ {file = "cfgv-3.4.0.tar.gz", hash = "sha256:e52591d4c5f5dead8e0f673fb16db7949d2cfb3f7da4582893288f0ded8fe560"},
+]
+
+[[package]]
+name = "click"
+version = "8.1.7"
+description = "Composable command line interface toolkit"
+optional = false
+python-versions = ">=3.7"
+files = [
+ {file = "click-8.1.7-py3-none-any.whl", hash = "sha256:ae74fb96c20a0277a1d615f1e4d73c8414f5a98db8b799a7931d1582f3390c28"},
+ {file = "click-8.1.7.tar.gz", hash = "sha256:ca9853ad459e787e2192211578cc907e7594e294c7ccc834310722b41b9ca6de"},
+]
+
+[package.dependencies]
+colorama = {version = "*", markers = "platform_system == \"Windows\""}
+
+[[package]]
+name = "codespell"
+version = "2.3.0"
+description = "Codespell"
+optional = false
+python-versions = ">=3.8"
+files = [
+ {file = "codespell-2.3.0-py3-none-any.whl", hash = "sha256:a9c7cef2501c9cfede2110fd6d4e5e62296920efe9abfb84648df866e47f58d1"},
+ {file = "codespell-2.3.0.tar.gz", hash = "sha256:360c7d10f75e65f67bad720af7007e1060a5d395670ec11a7ed1fed9dd17471f"},
+]
+
+[package.extras]
+dev = ["Pygments", "build", "chardet", "pre-commit", "pytest", "pytest-cov", "pytest-dependency", "ruff", "tomli", "twine"]
+hard-encoding-detection = ["chardet"]
+toml = ["tomli"]
+types = ["chardet (>=5.1.0)", "mypy", "pytest", "pytest-cov", "pytest-dependency"]
+
+[[package]]
+name = "colorama"
+version = "0.4.6"
+description = "Cross-platform colored terminal text."
+optional = false
+python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7"
+files = [
+ {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"},
+ {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"},
+]
+
+[[package]]
+name = "coverage"
+version = "7.6.2"
+description = "Code coverage measurement for Python"
+optional = false
+python-versions = ">=3.9"
+files = [
+ {file = "coverage-7.6.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:c9df1950fb92d49970cce38100d7e7293c84ed3606eaa16ea0b6bc27175bb667"},
+ {file = "coverage-7.6.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:24500f4b0e03aab60ce575c85365beab64b44d4db837021e08339f61d1fbfe52"},
+ {file = "coverage-7.6.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a663b180b6669c400b4630a24cc776f23a992d38ce7ae72ede2a397ce6b0f170"},
+ {file = "coverage-7.6.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:bfde025e2793a22efe8c21f807d276bd1d6a4bcc5ba6f19dbdfc4e7a12160909"},
+ {file = "coverage-7.6.2-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:087932079c065d7b8ebadd3a0160656c55954144af6439886c8bcf78bbbcde7f"},
+ {file = "coverage-7.6.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:9c6b0c1cafd96213a0327cf680acb39f70e452caf8e9a25aeb05316db9c07f89"},
+ {file = "coverage-7.6.2-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:6e85830eed5b5263ffa0c62428e43cb844296f3b4461f09e4bdb0d44ec190bc2"},
+ {file = "coverage-7.6.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:62ab4231c01e156ece1b3a187c87173f31cbeee83a5e1f6dff17f288dca93345"},
+ {file = "coverage-7.6.2-cp310-cp310-win32.whl", hash = "sha256:7b80fbb0da3aebde102a37ef0138aeedff45997e22f8962e5f16ae1742852676"},
+ {file = "coverage-7.6.2-cp310-cp310-win_amd64.whl", hash = "sha256:d20c3d1f31f14d6962a4e2f549c21d31e670b90f777ef4171be540fb7fb70f02"},
+ {file = "coverage-7.6.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:bb21bac7783c1bf6f4bbe68b1e0ff0d20e7e7732cfb7995bc8d96e23aa90fc7b"},
+ {file = "coverage-7.6.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a7b2e437fbd8fae5bc7716b9c7ff97aecc95f0b4d56e4ca08b3c8d8adcaadb84"},
+ {file = "coverage-7.6.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:536f77f2bf5797983652d1d55f1a7272a29afcc89e3ae51caa99b2db4e89d658"},
+ {file = "coverage-7.6.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f361296ca7054f0936b02525646b2731b32c8074ba6defab524b79b2b7eeac72"},
+ {file = "coverage-7.6.2-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7926d8d034e06b479797c199747dd774d5e86179f2ce44294423327a88d66ca7"},
+ {file = "coverage-7.6.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0bbae11c138585c89fb4e991faefb174a80112e1a7557d507aaa07675c62e66b"},
+ {file = "coverage-7.6.2-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:fcad7d5d2bbfeae1026b395036a8aa5abf67e8038ae7e6a25c7d0f88b10a8e6a"},
+ {file = "coverage-7.6.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f01e53575f27097d75d42de33b1b289c74b16891ce576d767ad8c48d17aeb5e0"},
+ {file = "coverage-7.6.2-cp311-cp311-win32.whl", hash = "sha256:7781f4f70c9b0b39e1b129b10c7d43a4e0c91f90c60435e6da8288efc2b73438"},
+ {file = "coverage-7.6.2-cp311-cp311-win_amd64.whl", hash = "sha256:9bcd51eeca35a80e76dc5794a9dd7cb04b97f0e8af620d54711793bfc1fbba4b"},
+ {file = "coverage-7.6.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ebc94fadbd4a3f4215993326a6a00e47d79889391f5659bf310f55fe5d9f581c"},
+ {file = "coverage-7.6.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:9681516288e3dcf0aa7c26231178cc0be6cac9705cac06709f2353c5b406cfea"},
+ {file = "coverage-7.6.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8d9c5d13927d77af4fbe453953810db766f75401e764727e73a6ee4f82527b3e"},
+ {file = "coverage-7.6.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b92f9ca04b3e719d69b02dc4a69debb795af84cb7afd09c5eb5d54b4a1ae2191"},
+ {file = "coverage-7.6.2-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0ff2ef83d6d0b527b5c9dad73819b24a2f76fdddcfd6c4e7a4d7e73ecb0656b4"},
+ {file = "coverage-7.6.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:47ccb6e99a3031ffbbd6e7cc041e70770b4fe405370c66a54dbf26a500ded80b"},
+ {file = "coverage-7.6.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:a867d26f06bcd047ef716175b2696b315cb7571ccb951006d61ca80bbc356e9e"},
+ {file = "coverage-7.6.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:cdfcf2e914e2ba653101157458afd0ad92a16731eeba9a611b5cbb3e7124e74b"},
+ {file = "coverage-7.6.2-cp312-cp312-win32.whl", hash = "sha256:f9035695dadfb397bee9eeaf1dc7fbeda483bf7664a7397a629846800ce6e276"},
+ {file = "coverage-7.6.2-cp312-cp312-win_amd64.whl", hash = "sha256:5ed69befa9a9fc796fe015a7040c9398722d6b97df73a6b608e9e275fa0932b0"},
+ {file = "coverage-7.6.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:4eea60c79d36a8f39475b1af887663bc3ae4f31289cd216f514ce18d5938df40"},
+ {file = "coverage-7.6.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:aa68a6cdbe1bc6793a9dbfc38302c11599bbe1837392ae9b1d238b9ef3dafcf1"},
+ {file = "coverage-7.6.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3ec528ae69f0a139690fad6deac8a7d33629fa61ccce693fdd07ddf7e9931fba"},
+ {file = "coverage-7.6.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ed5ac02126f74d190fa2cc14a9eb2a5d9837d5863920fa472b02eb1595cdc925"},
+ {file = "coverage-7.6.2-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:21c0ea0d4db8a36b275cb6fb2437a3715697a4ba3cb7b918d3525cc75f726304"},
+ {file = "coverage-7.6.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:35a51598f29b2a19e26d0908bd196f771a9b1c5d9a07bf20be0adf28f1ad4f77"},
+ {file = "coverage-7.6.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:c9192925acc33e146864b8cf037e2ed32a91fdf7644ae875f5d46cd2ef086a5f"},
+ {file = "coverage-7.6.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:bf4eeecc9e10f5403ec06138978235af79c9a79af494eb6b1d60a50b49ed2869"},
+ {file = "coverage-7.6.2-cp313-cp313-win32.whl", hash = "sha256:e4ee15b267d2dad3e8759ca441ad450c334f3733304c55210c2a44516e8d5530"},
+ {file = "coverage-7.6.2-cp313-cp313-win_amd64.whl", hash = "sha256:c71965d1ced48bf97aab79fad56df82c566b4c498ffc09c2094605727c4b7e36"},
+ {file = "coverage-7.6.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:7571e8bbecc6ac066256f9de40365ff833553e2e0c0c004f4482facb131820ef"},
+ {file = "coverage-7.6.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:078a87519057dacb5d77e333f740708ec2a8f768655f1db07f8dfd28d7a005f0"},
+ {file = "coverage-7.6.2-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1e5e92e3e84a8718d2de36cd8387459cba9a4508337b8c5f450ce42b87a9e760"},
+ {file = "coverage-7.6.2-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ebabdf1c76593a09ee18c1a06cd3022919861365219ea3aca0247ededf6facd6"},
+ {file = "coverage-7.6.2-cp313-cp313t-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:12179eb0575b8900912711688e45474f04ab3934aaa7b624dea7b3c511ecc90f"},
+ {file = "coverage-7.6.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:39d3b964abfe1519b9d313ab28abf1d02faea26cd14b27f5283849bf59479ff5"},
+ {file = "coverage-7.6.2-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:84c4315577f7cd511d6250ffd0f695c825efe729f4205c0340f7004eda51191f"},
+ {file = "coverage-7.6.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:ff797320dcbff57caa6b2301c3913784a010e13b1f6cf4ab3f563f3c5e7919db"},
+ {file = "coverage-7.6.2-cp313-cp313t-win32.whl", hash = "sha256:2b636a301e53964550e2f3094484fa5a96e699db318d65398cfba438c5c92171"},
+ {file = "coverage-7.6.2-cp313-cp313t-win_amd64.whl", hash = "sha256:d03a060ac1a08e10589c27d509bbdb35b65f2d7f3f8d81cf2fa199877c7bc58a"},
+ {file = "coverage-7.6.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:c37faddc8acd826cfc5e2392531aba734b229741d3daec7f4c777a8f0d4993e5"},
+ {file = "coverage-7.6.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:ab31fdd643f162c467cfe6a86e9cb5f1965b632e5e65c072d90854ff486d02cf"},
+ {file = "coverage-7.6.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:97df87e1a20deb75ac7d920c812e9326096aa00a9a4b6d07679b4f1f14b06c90"},
+ {file = "coverage-7.6.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:343056c5e0737487a5291f5691f4dfeb25b3e3c8699b4d36b92bb0e586219d14"},
+ {file = "coverage-7.6.2-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ad4ef1c56b47b6b9024b939d503ab487231df1f722065a48f4fc61832130b90e"},
+ {file = "coverage-7.6.2-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:7fca4a92c8a7a73dee6946471bce6d1443d94155694b893b79e19ca2a540d86e"},
+ {file = "coverage-7.6.2-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:69f251804e052fc46d29d0e7348cdc5fcbfc4861dc4a1ebedef7e78d241ad39e"},
+ {file = "coverage-7.6.2-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:e8ea055b3ea046c0f66217af65bc193bbbeca1c8661dc5fd42698db5795d2627"},
+ {file = "coverage-7.6.2-cp39-cp39-win32.whl", hash = "sha256:6c2ba1e0c24d8fae8f2cf0aeb2fc0a2a7f69b6d20bd8d3749fd6b36ecef5edf0"},
+ {file = "coverage-7.6.2-cp39-cp39-win_amd64.whl", hash = "sha256:2186369a654a15628e9c1c9921409a6b3eda833e4b91f3ca2a7d9f77abb4987c"},
+ {file = "coverage-7.6.2-pp39.pp310-none-any.whl", hash = "sha256:667952739daafe9616db19fbedbdb87917eee253ac4f31d70c7587f7ab531b4e"},
+ {file = "coverage-7.6.2.tar.gz", hash = "sha256:a5f81e68aa62bc0cfca04f7b19eaa8f9c826b53fc82ab9e2121976dc74f131f3"},
+]
+
+[package.extras]
+toml = ["tomli"]
+
+[[package]]
+name = "coverage2clover"
+version = "4.0.0"
+description = "A tool to convert python-coverage xml report to Atlassian Clover xml report format"
+optional = false
+python-versions = ">=3.6"
+files = [
+ {file = "coverage2clover-4.0.0-py3-none-any.whl", hash = "sha256:16bdb41f4765c8bf1dc3c9a609c3f2271233551dbcc298e64daf489ea170e030"},
+ {file = "coverage2clover-4.0.0.tar.gz", hash = "sha256:17c42528b3c902f1819239b9b431ccc800e2c41e70e52acbe168477c7c767660"},
+]
+
+[package.dependencies]
+coverage = ">=5.3,<8.0"
+pygount = "1.2.4"
+
+[[package]]
+name = "distlib"
+version = "0.3.9"
+description = "Distribution utilities"
+optional = false
+python-versions = "*"
+files = [
+ {file = "distlib-0.3.9-py2.py3-none-any.whl", hash = "sha256:47f8c22fd27c27e25a65601af709b38e4f0a45ea4fc2e710f65755fa8caaaf87"},
+ {file = "distlib-0.3.9.tar.gz", hash = "sha256:a60f20dea646b8a33f3e7772f74dc0b2d0772d2837ee1342a00645c81edf9403"},
+]
+
+[[package]]
+name = "exceptiongroup"
+version = "1.2.2"
+description = "Backport of PEP 654 (exception groups)"
+optional = false
+python-versions = ">=3.7"
+files = [
+ {file = "exceptiongroup-1.2.2-py3-none-any.whl", hash = "sha256:3111b9d131c238bec2f8f516e123e14ba243563fb135d3fe885990585aa7795b"},
+ {file = "exceptiongroup-1.2.2.tar.gz", hash = "sha256:47c2edf7c6738fafb49fd34290706d1a1a2f4d1c6df275526b62cbb4aa5393cc"},
+]
+
+[package.extras]
+test = ["pytest (>=6)"]
+
+[[package]]
+name = "faker"
+version = "26.3.0"
+description = "Faker is a Python package that generates fake data for you."
+optional = false
+python-versions = ">=3.8"
+files = [
+ {file = "Faker-26.3.0-py3-none-any.whl", hash = "sha256:97fe1e7e953dd640ca2cd4dfac4db7c4d2432dd1b7a244a3313517707f3b54e9"},
+ {file = "Faker-26.3.0.tar.gz", hash = "sha256:7c10ebdf74aaa0cc4fe6ec6db5a71e8598ec33503524bd4b5f4494785a5670dd"},
+]
+
+[package.dependencies]
+python-dateutil = ">=2.4"
+
+[[package]]
+name = "filelock"
+version = "3.16.1"
+description = "A platform independent file lock."
+optional = false
+python-versions = ">=3.8"
+files = [
+ {file = "filelock-3.16.1-py3-none-any.whl", hash = "sha256:2082e5703d51fbf98ea75855d9d5527e33d8ff23099bec374a134febee6946b0"},
+ {file = "filelock-3.16.1.tar.gz", hash = "sha256:c249fbfcd5db47e5e2d6d62198e565475ee65e4831e2561c8e313fa7eb961435"},
+]
+
+[package.extras]
+docs = ["furo (>=2024.8.6)", "sphinx (>=8.0.2)", "sphinx-autodoc-typehints (>=2.4.1)"]
+testing = ["covdefaults (>=2.3)", "coverage (>=7.6.1)", "diff-cover (>=9.2)", "pytest (>=8.3.3)", "pytest-asyncio (>=0.24)", "pytest-cov (>=5)", "pytest-mock (>=3.14)", "pytest-timeout (>=2.3.1)", "virtualenv (>=20.26.4)"]
+typing = ["typing-extensions (>=4.12.2)"]
+
+[[package]]
+name = "flake8"
+version = "7.1.1"
+description = "the modular source code checker: pep8 pyflakes and co"
+optional = false
+python-versions = ">=3.8.1"
+files = [
+ {file = "flake8-7.1.1-py2.py3-none-any.whl", hash = "sha256:597477df7860daa5aa0fdd84bf5208a043ab96b8e96ab708770ae0364dd03213"},
+ {file = "flake8-7.1.1.tar.gz", hash = "sha256:049d058491e228e03e67b390f311bbf88fce2dbaa8fa673e7aea87b7198b8d38"},
+]
+
+[package.dependencies]
+mccabe = ">=0.7.0,<0.8.0"
+pycodestyle = ">=2.12.0,<2.13.0"
+pyflakes = ">=3.2.0,<3.3.0"
+
+[[package]]
+name = "flake8-bugbear"
+version = "24.8.19"
+description = "A plugin for flake8 finding likely bugs and design problems in your program. Contains warnings that don't belong in pyflakes and pycodestyle."
+optional = false
+python-versions = ">=3.8.1"
+files = [
+ {file = "flake8_bugbear-24.8.19-py3-none-any.whl", hash = "sha256:25bc3867f7338ee3b3e0916bf8b8a0b743f53a9a5175782ddc4325ed4f386b89"},
+ {file = "flake8_bugbear-24.8.19.tar.gz", hash = "sha256:9b77627eceda28c51c27af94560a72b5b2c97c016651bdce45d8f56c180d2d32"},
+]
+
+[package.dependencies]
+attrs = ">=19.2.0"
+flake8 = ">=6.0.0"
+
+[package.extras]
+dev = ["coverage", "hypothesis", "hypothesmith (>=0.2)", "pre-commit", "pytest", "tox"]
+
+[[package]]
+name = "identify"
+version = "2.6.1"
+description = "File identification library for Python"
+optional = false
+python-versions = ">=3.8"
+files = [
+ {file = "identify-2.6.1-py2.py3-none-any.whl", hash = "sha256:53863bcac7caf8d2ed85bd20312ea5dcfc22226800f6d6881f232d861db5a8f0"},
+ {file = "identify-2.6.1.tar.gz", hash = "sha256:91478c5fb7c3aac5ff7bf9b4344f803843dc586832d5f110d672b19aa1984c98"},
+]
+
+[package.extras]
+license = ["ukkonen"]
+
+[[package]]
+name = "iniconfig"
+version = "2.0.0"
+description = "brain-dead simple config-ini parsing"
+optional = false
+python-versions = ">=3.7"
+files = [
+ {file = "iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374"},
+ {file = "iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3"},
+]
+
+[[package]]
+name = "isort"
+version = "5.13.2"
+description = "A Python utility / library to sort Python imports."
+optional = false
+python-versions = ">=3.8.0"
+files = [
+ {file = "isort-5.13.2-py3-none-any.whl", hash = "sha256:8ca5e72a8d85860d5a3fa69b8745237f2939afe12dbf656afbcb47fe72d947a6"},
+ {file = "isort-5.13.2.tar.gz", hash = "sha256:48fdfcb9face5d58a4f6dde2e72a1fb8dcaf8ab26f95ab49fab84c2ddefb0109"},
+]
+
+[package.extras]
+colors = ["colorama (>=0.4.6)"]
+
+[[package]]
+name = "lxml"
+version = "5.3.0"
+description = "Powerful and Pythonic XML processing library combining libxml2/libxslt with the ElementTree API."
+optional = false
+python-versions = ">=3.6"
+files = [
+ {file = "lxml-5.3.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:dd36439be765e2dde7660212b5275641edbc813e7b24668831a5c8ac91180656"},
+ {file = "lxml-5.3.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:ae5fe5c4b525aa82b8076c1a59d642c17b6e8739ecf852522c6321852178119d"},
+ {file = "lxml-5.3.0-cp310-cp310-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:501d0d7e26b4d261fca8132854d845e4988097611ba2531408ec91cf3fd9d20a"},
+ {file = "lxml-5.3.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fb66442c2546446944437df74379e9cf9e9db353e61301d1a0e26482f43f0dd8"},
+ {file = "lxml-5.3.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9e41506fec7a7f9405b14aa2d5c8abbb4dbbd09d88f9496958b6d00cb4d45330"},
+ {file = "lxml-5.3.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f7d4a670107d75dfe5ad080bed6c341d18c4442f9378c9f58e5851e86eb79965"},
+ {file = "lxml-5.3.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:41ce1f1e2c7755abfc7e759dc34d7d05fd221723ff822947132dc934d122fe22"},
+ {file = "lxml-5.3.0-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:44264ecae91b30e5633013fb66f6ddd05c006d3e0e884f75ce0b4755b3e3847b"},
+ {file = "lxml-5.3.0-cp310-cp310-manylinux_2_28_ppc64le.whl", hash = "sha256:3c174dc350d3ec52deb77f2faf05c439331d6ed5e702fc247ccb4e6b62d884b7"},
+ {file = "lxml-5.3.0-cp310-cp310-manylinux_2_28_s390x.whl", hash = "sha256:2dfab5fa6a28a0b60a20638dc48e6343c02ea9933e3279ccb132f555a62323d8"},
+ {file = "lxml-5.3.0-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:b1c8c20847b9f34e98080da785bb2336ea982e7f913eed5809e5a3c872900f32"},
+ {file = "lxml-5.3.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:2c86bf781b12ba417f64f3422cfc302523ac9cd1d8ae8c0f92a1c66e56ef2e86"},
+ {file = "lxml-5.3.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:c162b216070f280fa7da844531169be0baf9ccb17263cf5a8bf876fcd3117fa5"},
+ {file = "lxml-5.3.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:36aef61a1678cb778097b4a6eeae96a69875d51d1e8f4d4b491ab3cfb54b5a03"},
+ {file = "lxml-5.3.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:f65e5120863c2b266dbcc927b306c5b78e502c71edf3295dfcb9501ec96e5fc7"},
+ {file = "lxml-5.3.0-cp310-cp310-win32.whl", hash = "sha256:ef0c1fe22171dd7c7c27147f2e9c3e86f8bdf473fed75f16b0c2e84a5030ce80"},
+ {file = "lxml-5.3.0-cp310-cp310-win_amd64.whl", hash = "sha256:052d99051e77a4f3e8482c65014cf6372e61b0a6f4fe9edb98503bb5364cfee3"},
+ {file = "lxml-5.3.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:74bcb423462233bc5d6066e4e98b0264e7c1bed7541fff2f4e34fe6b21563c8b"},
+ {file = "lxml-5.3.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a3d819eb6f9b8677f57f9664265d0a10dd6551d227afb4af2b9cd7bdc2ccbf18"},
+ {file = "lxml-5.3.0-cp311-cp311-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5b8f5db71b28b8c404956ddf79575ea77aa8b1538e8b2ef9ec877945b3f46442"},
+ {file = "lxml-5.3.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2c3406b63232fc7e9b8783ab0b765d7c59e7c59ff96759d8ef9632fca27c7ee4"},
+ {file = "lxml-5.3.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2ecdd78ab768f844c7a1d4a03595038c166b609f6395e25af9b0f3f26ae1230f"},
+ {file = "lxml-5.3.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:168f2dfcfdedf611eb285efac1516c8454c8c99caf271dccda8943576b67552e"},
+ {file = "lxml-5.3.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:aa617107a410245b8660028a7483b68e7914304a6d4882b5ff3d2d3eb5948d8c"},
+ {file = "lxml-5.3.0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:69959bd3167b993e6e710b99051265654133a98f20cec1d9b493b931942e9c16"},
+ {file = "lxml-5.3.0-cp311-cp311-manylinux_2_28_ppc64le.whl", hash = "sha256:bd96517ef76c8654446fc3db9242d019a1bb5fe8b751ba414765d59f99210b79"},
+ {file = "lxml-5.3.0-cp311-cp311-manylinux_2_28_s390x.whl", hash = "sha256:ab6dd83b970dc97c2d10bc71aa925b84788c7c05de30241b9e96f9b6d9ea3080"},
+ {file = "lxml-5.3.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:eec1bb8cdbba2925bedc887bc0609a80e599c75b12d87ae42ac23fd199445654"},
+ {file = "lxml-5.3.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:6a7095eeec6f89111d03dabfe5883a1fd54da319c94e0fb104ee8f23616b572d"},
+ {file = "lxml-5.3.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:6f651ebd0b21ec65dfca93aa629610a0dbc13dbc13554f19b0113da2e61a4763"},
+ {file = "lxml-5.3.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:f422a209d2455c56849442ae42f25dbaaba1c6c3f501d58761c619c7836642ec"},
+ {file = "lxml-5.3.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:62f7fdb0d1ed2065451f086519865b4c90aa19aed51081979ecd05a21eb4d1be"},
+ {file = "lxml-5.3.0-cp311-cp311-win32.whl", hash = "sha256:c6379f35350b655fd817cd0d6cbeef7f265f3ae5fedb1caae2eb442bbeae9ab9"},
+ {file = "lxml-5.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:9c52100e2c2dbb0649b90467935c4b0de5528833c76a35ea1a2691ec9f1ee7a1"},
+ {file = "lxml-5.3.0-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:e99f5507401436fdcc85036a2e7dc2e28d962550afe1cbfc07c40e454256a859"},
+ {file = "lxml-5.3.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:384aacddf2e5813a36495233b64cb96b1949da72bef933918ba5c84e06af8f0e"},
+ {file = "lxml-5.3.0-cp312-cp312-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:874a216bf6afaf97c263b56371434e47e2c652d215788396f60477540298218f"},
+ {file = "lxml-5.3.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:65ab5685d56914b9a2a34d67dd5488b83213d680b0c5d10b47f81da5a16b0b0e"},
+ {file = "lxml-5.3.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:aac0bbd3e8dd2d9c45ceb82249e8bdd3ac99131a32b4d35c8af3cc9db1657179"},
+ {file = "lxml-5.3.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b369d3db3c22ed14c75ccd5af429086f166a19627e84a8fdade3f8f31426e52a"},
+ {file = "lxml-5.3.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c24037349665434f375645fa9d1f5304800cec574d0310f618490c871fd902b3"},
+ {file = "lxml-5.3.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:62d172f358f33a26d6b41b28c170c63886742f5b6772a42b59b4f0fa10526cb1"},
+ {file = "lxml-5.3.0-cp312-cp312-manylinux_2_28_ppc64le.whl", hash = "sha256:c1f794c02903c2824fccce5b20c339a1a14b114e83b306ff11b597c5f71a1c8d"},
+ {file = "lxml-5.3.0-cp312-cp312-manylinux_2_28_s390x.whl", hash = "sha256:5d6a6972b93c426ace71e0be9a6f4b2cfae9b1baed2eed2006076a746692288c"},
+ {file = "lxml-5.3.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:3879cc6ce938ff4eb4900d901ed63555c778731a96365e53fadb36437a131a99"},
+ {file = "lxml-5.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:74068c601baff6ff021c70f0935b0c7bc528baa8ea210c202e03757c68c5a4ff"},
+ {file = "lxml-5.3.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:ecd4ad8453ac17bc7ba3868371bffb46f628161ad0eefbd0a855d2c8c32dd81a"},
+ {file = "lxml-5.3.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:7e2f58095acc211eb9d8b5771bf04df9ff37d6b87618d1cbf85f92399c98dae8"},
+ {file = "lxml-5.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e63601ad5cd8f860aa99d109889b5ac34de571c7ee902d6812d5d9ddcc77fa7d"},
+ {file = "lxml-5.3.0-cp312-cp312-win32.whl", hash = "sha256:17e8d968d04a37c50ad9c456a286b525d78c4a1c15dd53aa46c1d8e06bf6fa30"},
+ {file = "lxml-5.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:c1a69e58a6bb2de65902051d57fde951febad631a20a64572677a1052690482f"},
+ {file = "lxml-5.3.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:8c72e9563347c7395910de6a3100a4840a75a6f60e05af5e58566868d5eb2d6a"},
+ {file = "lxml-5.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e92ce66cd919d18d14b3856906a61d3f6b6a8500e0794142338da644260595cd"},
+ {file = "lxml-5.3.0-cp313-cp313-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1d04f064bebdfef9240478f7a779e8c5dc32b8b7b0b2fc6a62e39b928d428e51"},
+ {file = "lxml-5.3.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5c2fb570d7823c2bbaf8b419ba6e5662137f8166e364a8b2b91051a1fb40ab8b"},
+ {file = "lxml-5.3.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0c120f43553ec759f8de1fee2f4794452b0946773299d44c36bfe18e83caf002"},
+ {file = "lxml-5.3.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:562e7494778a69086f0312ec9689f6b6ac1c6b65670ed7d0267e49f57ffa08c4"},
+ {file = "lxml-5.3.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:423b121f7e6fa514ba0c7918e56955a1d4470ed35faa03e3d9f0e3baa4c7e492"},
+ {file = "lxml-5.3.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:c00f323cc00576df6165cc9d21a4c21285fa6b9989c5c39830c3903dc4303ef3"},
+ {file = "lxml-5.3.0-cp313-cp313-manylinux_2_28_ppc64le.whl", hash = "sha256:1fdc9fae8dd4c763e8a31e7630afef517eab9f5d5d31a278df087f307bf601f4"},
+ {file = "lxml-5.3.0-cp313-cp313-manylinux_2_28_s390x.whl", hash = "sha256:658f2aa69d31e09699705949b5fc4719cbecbd4a97f9656a232e7d6c7be1a367"},
+ {file = "lxml-5.3.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:1473427aff3d66a3fa2199004c3e601e6c4500ab86696edffdbc84954c72d832"},
+ {file = "lxml-5.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a87de7dd873bf9a792bf1e58b1c3887b9264036629a5bf2d2e6579fe8e73edff"},
+ {file = "lxml-5.3.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:0d7b36afa46c97875303a94e8f3ad932bf78bace9e18e603f2085b652422edcd"},
+ {file = "lxml-5.3.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:cf120cce539453ae086eacc0130a324e7026113510efa83ab42ef3fcfccac7fb"},
+ {file = "lxml-5.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:df5c7333167b9674aa8ae1d4008fa4bc17a313cc490b2cca27838bbdcc6bb15b"},
+ {file = "lxml-5.3.0-cp313-cp313-win32.whl", hash = "sha256:c802e1c2ed9f0c06a65bc4ed0189d000ada8049312cfeab6ca635e39c9608957"},
+ {file = "lxml-5.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:406246b96d552e0503e17a1006fd27edac678b3fcc9f1be71a2f94b4ff61528d"},
+ {file = "lxml-5.3.0-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:8f0de2d390af441fe8b2c12626d103540b5d850d585b18fcada58d972b74a74e"},
+ {file = "lxml-5.3.0-cp36-cp36m-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1afe0a8c353746e610bd9031a630a95bcfb1a720684c3f2b36c4710a0a96528f"},
+ {file = "lxml-5.3.0-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:56b9861a71575f5795bde89256e7467ece3d339c9b43141dbdd54544566b3b94"},
+ {file = "lxml-5.3.0-cp36-cp36m-manylinux_2_28_x86_64.whl", hash = "sha256:9fb81d2824dff4f2e297a276297e9031f46d2682cafc484f49de182aa5e5df99"},
+ {file = "lxml-5.3.0-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:2c226a06ecb8cdef28845ae976da407917542c5e6e75dcac7cc33eb04aaeb237"},
+ {file = "lxml-5.3.0-cp36-cp36m-musllinux_1_2_x86_64.whl", hash = "sha256:7d3d1ca42870cdb6d0d29939630dbe48fa511c203724820fc0fd507b2fb46577"},
+ {file = "lxml-5.3.0-cp36-cp36m-win32.whl", hash = "sha256:094cb601ba9f55296774c2d57ad68730daa0b13dc260e1f941b4d13678239e70"},
+ {file = "lxml-5.3.0-cp36-cp36m-win_amd64.whl", hash = "sha256:eafa2c8658f4e560b098fe9fc54539f86528651f61849b22111a9b107d18910c"},
+ {file = "lxml-5.3.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:cb83f8a875b3d9b458cada4f880fa498646874ba4011dc974e071a0a84a1b033"},
+ {file = "lxml-5.3.0-cp37-cp37m-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:25f1b69d41656b05885aa185f5fdf822cb01a586d1b32739633679699f220391"},
+ {file = "lxml-5.3.0-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:23e0553b8055600b3bf4a00b255ec5c92e1e4aebf8c2c09334f8368e8bd174d6"},
+ {file = "lxml-5.3.0-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9ada35dd21dc6c039259596b358caab6b13f4db4d4a7f8665764d616daf9cc1d"},
+ {file = "lxml-5.3.0-cp37-cp37m-manylinux_2_28_aarch64.whl", hash = "sha256:81b4e48da4c69313192d8c8d4311e5d818b8be1afe68ee20f6385d0e96fc9512"},
+ {file = "lxml-5.3.0-cp37-cp37m-manylinux_2_28_x86_64.whl", hash = "sha256:2bc9fd5ca4729af796f9f59cd8ff160fe06a474da40aca03fcc79655ddee1a8b"},
+ {file = "lxml-5.3.0-cp37-cp37m-musllinux_1_2_aarch64.whl", hash = "sha256:07da23d7ee08577760f0a71d67a861019103e4812c87e2fab26b039054594cc5"},
+ {file = "lxml-5.3.0-cp37-cp37m-musllinux_1_2_x86_64.whl", hash = "sha256:ea2e2f6f801696ad7de8aec061044d6c8c0dd4037608c7cab38a9a4d316bfb11"},
+ {file = "lxml-5.3.0-cp37-cp37m-win32.whl", hash = "sha256:5c54afdcbb0182d06836cc3d1be921e540be3ebdf8b8a51ee3ef987537455f84"},
+ {file = "lxml-5.3.0-cp37-cp37m-win_amd64.whl", hash = "sha256:f2901429da1e645ce548bf9171784c0f74f0718c3f6150ce166be39e4dd66c3e"},
+ {file = "lxml-5.3.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:c56a1d43b2f9ee4786e4658c7903f05da35b923fb53c11025712562d5cc02753"},
+ {file = "lxml-5.3.0-cp38-cp38-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6ee8c39582d2652dcd516d1b879451500f8db3fe3607ce45d7c5957ab2596040"},
+ {file = "lxml-5.3.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0fdf3a3059611f7585a78ee10399a15566356116a4288380921a4b598d807a22"},
+ {file = "lxml-5.3.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:146173654d79eb1fc97498b4280c1d3e1e5d58c398fa530905c9ea50ea849b22"},
+ {file = "lxml-5.3.0-cp38-cp38-manylinux_2_28_aarch64.whl", hash = "sha256:0a7056921edbdd7560746f4221dca89bb7a3fe457d3d74267995253f46343f15"},
+ {file = "lxml-5.3.0-cp38-cp38-manylinux_2_28_x86_64.whl", hash = "sha256:9e4b47ac0f5e749cfc618efdf4726269441014ae1d5583e047b452a32e221920"},
+ {file = "lxml-5.3.0-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:f914c03e6a31deb632e2daa881fe198461f4d06e57ac3d0e05bbcab8eae01945"},
+ {file = "lxml-5.3.0-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:213261f168c5e1d9b7535a67e68b1f59f92398dd17a56d934550837143f79c42"},
+ {file = "lxml-5.3.0-cp38-cp38-win32.whl", hash = "sha256:218c1b2e17a710e363855594230f44060e2025b05c80d1f0661258142b2add2e"},
+ {file = "lxml-5.3.0-cp38-cp38-win_amd64.whl", hash = "sha256:315f9542011b2c4e1d280e4a20ddcca1761993dda3afc7a73b01235f8641e903"},
+ {file = "lxml-5.3.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:1ffc23010330c2ab67fac02781df60998ca8fe759e8efde6f8b756a20599c5de"},
+ {file = "lxml-5.3.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:2b3778cb38212f52fac9fe913017deea2fdf4eb1a4f8e4cfc6b009a13a6d3fcc"},
+ {file = "lxml-5.3.0-cp39-cp39-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4b0c7a688944891086ba192e21c5229dea54382f4836a209ff8d0a660fac06be"},
+ {file = "lxml-5.3.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:747a3d3e98e24597981ca0be0fd922aebd471fa99d0043a3842d00cdcad7ad6a"},
+ {file = "lxml-5.3.0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:86a6b24b19eaebc448dc56b87c4865527855145d851f9fc3891673ff97950540"},
+ {file = "lxml-5.3.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b11a5d918a6216e521c715b02749240fb07ae5a1fefd4b7bf12f833bc8b4fe70"},
+ {file = "lxml-5.3.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:68b87753c784d6acb8a25b05cb526c3406913c9d988d51f80adecc2b0775d6aa"},
+ {file = "lxml-5.3.0-cp39-cp39-manylinux_2_28_aarch64.whl", hash = "sha256:109fa6fede314cc50eed29e6e56c540075e63d922455346f11e4d7a036d2b8cf"},
+ {file = "lxml-5.3.0-cp39-cp39-manylinux_2_28_ppc64le.whl", hash = "sha256:02ced472497b8362c8e902ade23e3300479f4f43e45f4105c85ef43b8db85229"},
+ {file = "lxml-5.3.0-cp39-cp39-manylinux_2_28_s390x.whl", hash = "sha256:6b038cc86b285e4f9fea2ba5ee76e89f21ed1ea898e287dc277a25884f3a7dfe"},
+ {file = "lxml-5.3.0-cp39-cp39-manylinux_2_28_x86_64.whl", hash = "sha256:7437237c6a66b7ca341e868cda48be24b8701862757426852c9b3186de1da8a2"},
+ {file = "lxml-5.3.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:7f41026c1d64043a36fda21d64c5026762d53a77043e73e94b71f0521939cc71"},
+ {file = "lxml-5.3.0-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:482c2f67761868f0108b1743098640fbb2a28a8e15bf3f47ada9fa59d9fe08c3"},
+ {file = "lxml-5.3.0-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:1483fd3358963cc5c1c9b122c80606a3a79ee0875bcac0204149fa09d6ff2727"},
+ {file = "lxml-5.3.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:2dec2d1130a9cda5b904696cec33b2cfb451304ba9081eeda7f90f724097300a"},
+ {file = "lxml-5.3.0-cp39-cp39-win32.whl", hash = "sha256:a0eabd0a81625049c5df745209dc7fcef6e2aea7793e5f003ba363610aa0a3ff"},
+ {file = "lxml-5.3.0-cp39-cp39-win_amd64.whl", hash = "sha256:89e043f1d9d341c52bf2af6d02e6adde62e0a46e6755d5eb60dc6e4f0b8aeca2"},
+ {file = "lxml-5.3.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:7b1cd427cb0d5f7393c31b7496419da594fe600e6fdc4b105a54f82405e6626c"},
+ {file = "lxml-5.3.0-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:51806cfe0279e06ed8500ce19479d757db42a30fd509940b1701be9c86a5ff9a"},
+ {file = "lxml-5.3.0-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ee70d08fd60c9565ba8190f41a46a54096afa0eeb8f76bd66f2c25d3b1b83005"},
+ {file = "lxml-5.3.0-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:8dc2c0395bea8254d8daebc76dcf8eb3a95ec2a46fa6fae5eaccee366bfe02ce"},
+ {file = "lxml-5.3.0-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:6ba0d3dcac281aad8a0e5b14c7ed6f9fa89c8612b47939fc94f80b16e2e9bc83"},
+ {file = "lxml-5.3.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:6e91cf736959057f7aac7adfc83481e03615a8e8dd5758aa1d95ea69e8931dba"},
+ {file = "lxml-5.3.0-pp37-pypy37_pp73-macosx_10_9_x86_64.whl", hash = "sha256:94d6c3782907b5e40e21cadf94b13b0842ac421192f26b84c45f13f3c9d5dc27"},
+ {file = "lxml-5.3.0-pp37-pypy37_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c300306673aa0f3ed5ed9372b21867690a17dba38c68c44b287437c362ce486b"},
+ {file = "lxml-5.3.0-pp37-pypy37_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:78d9b952e07aed35fe2e1a7ad26e929595412db48535921c5013edc8aa4a35ce"},
+ {file = "lxml-5.3.0-pp37-pypy37_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:01220dca0d066d1349bd6a1726856a78f7929f3878f7e2ee83c296c69495309e"},
+ {file = "lxml-5.3.0-pp37-pypy37_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:2d9b8d9177afaef80c53c0a9e30fa252ff3036fb1c6494d427c066a4ce6a282f"},
+ {file = "lxml-5.3.0-pp37-pypy37_pp73-win_amd64.whl", hash = "sha256:20094fc3f21ea0a8669dc4c61ed7fa8263bd37d97d93b90f28fc613371e7a875"},
+ {file = "lxml-5.3.0-pp38-pypy38_pp73-macosx_10_9_x86_64.whl", hash = "sha256:ace2c2326a319a0bb8a8b0e5b570c764962e95818de9f259ce814ee666603f19"},
+ {file = "lxml-5.3.0-pp38-pypy38_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:92e67a0be1639c251d21e35fe74df6bcc40cba445c2cda7c4a967656733249e2"},
+ {file = "lxml-5.3.0-pp38-pypy38_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dd5350b55f9fecddc51385463a4f67a5da829bc741e38cf689f38ec9023f54ab"},
+ {file = "lxml-5.3.0-pp38-pypy38_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:4c1fefd7e3d00921c44dc9ca80a775af49698bbfd92ea84498e56acffd4c5469"},
+ {file = "lxml-5.3.0-pp38-pypy38_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:71a8dd38fbd2f2319136d4ae855a7078c69c9a38ae06e0c17c73fd70fc6caad8"},
+ {file = "lxml-5.3.0-pp38-pypy38_pp73-win_amd64.whl", hash = "sha256:97acf1e1fd66ab53dacd2c35b319d7e548380c2e9e8c54525c6e76d21b1ae3b1"},
+ {file = "lxml-5.3.0-pp39-pypy39_pp73-macosx_10_15_x86_64.whl", hash = "sha256:68934b242c51eb02907c5b81d138cb977b2129a0a75a8f8b60b01cb8586c7b21"},
+ {file = "lxml-5.3.0-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b710bc2b8292966b23a6a0121f7a6c51d45d2347edcc75f016ac123b8054d3f2"},
+ {file = "lxml-5.3.0-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:18feb4b93302091b1541221196a2155aa296c363fd233814fa11e181adebc52f"},
+ {file = "lxml-5.3.0-pp39-pypy39_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:3eb44520c4724c2e1a57c0af33a379eee41792595023f367ba3952a2d96c2aab"},
+ {file = "lxml-5.3.0-pp39-pypy39_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:609251a0ca4770e5a8768ff902aa02bf636339c5a93f9349b48eb1f606f7f3e9"},
+ {file = "lxml-5.3.0-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:516f491c834eb320d6c843156440fe7fc0d50b33e44387fcec5b02f0bc118a4c"},
+ {file = "lxml-5.3.0.tar.gz", hash = "sha256:4e109ca30d1edec1ac60cdbe341905dc3b8f55b16855e03a54aaf59e51ec8c6f"},
+]
+
+[package.extras]
+cssselect = ["cssselect (>=0.7)"]
+html-clean = ["lxml-html-clean"]
+html5 = ["html5lib"]
+htmlsoup = ["BeautifulSoup4"]
+source = ["Cython (>=3.0.11)"]
+
+[[package]]
+name = "mccabe"
+version = "0.7.0"
+description = "McCabe checker, plugin for flake8"
+optional = false
+python-versions = ">=3.6"
+files = [
+ {file = "mccabe-0.7.0-py2.py3-none-any.whl", hash = "sha256:6c2d30ab6be0e4a46919781807b4f0d834ebdd6c6e3dca0bda5a15f863427b6e"},
+ {file = "mccabe-0.7.0.tar.gz", hash = "sha256:348e0240c33b60bbdf4e523192ef919f28cb2c3d7d5c7794f74009290f236325"},
+]
+
+[[package]]
+name = "mypy"
+version = "1.11.2"
+description = "Optional static typing for Python"
+optional = false
+python-versions = ">=3.8"
+files = [
+ {file = "mypy-1.11.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d42a6dd818ffce7be66cce644f1dff482f1d97c53ca70908dff0b9ddc120b77a"},
+ {file = "mypy-1.11.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:801780c56d1cdb896eacd5619a83e427ce436d86a3bdf9112527f24a66618fef"},
+ {file = "mypy-1.11.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:41ea707d036a5307ac674ea172875f40c9d55c5394f888b168033177fce47383"},
+ {file = "mypy-1.11.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:6e658bd2d20565ea86da7d91331b0eed6d2eee22dc031579e6297f3e12c758c8"},
+ {file = "mypy-1.11.2-cp310-cp310-win_amd64.whl", hash = "sha256:478db5f5036817fe45adb7332d927daa62417159d49783041338921dcf646fc7"},
+ {file = "mypy-1.11.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:75746e06d5fa1e91bfd5432448d00d34593b52e7e91a187d981d08d1f33d4385"},
+ {file = "mypy-1.11.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a976775ab2256aadc6add633d44f100a2517d2388906ec4f13231fafbb0eccca"},
+ {file = "mypy-1.11.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cd953f221ac1379050a8a646585a29574488974f79d8082cedef62744f0a0104"},
+ {file = "mypy-1.11.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:57555a7715c0a34421013144a33d280e73c08df70f3a18a552938587ce9274f4"},
+ {file = "mypy-1.11.2-cp311-cp311-win_amd64.whl", hash = "sha256:36383a4fcbad95f2657642a07ba22ff797de26277158f1cc7bd234821468b1b6"},
+ {file = "mypy-1.11.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:e8960dbbbf36906c5c0b7f4fbf2f0c7ffb20f4898e6a879fcf56a41a08b0d318"},
+ {file = "mypy-1.11.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:06d26c277962f3fb50e13044674aa10553981ae514288cb7d0a738f495550b36"},
+ {file = "mypy-1.11.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6e7184632d89d677973a14d00ae4d03214c8bc301ceefcdaf5c474866814c987"},
+ {file = "mypy-1.11.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:3a66169b92452f72117e2da3a576087025449018afc2d8e9bfe5ffab865709ca"},
+ {file = "mypy-1.11.2-cp312-cp312-win_amd64.whl", hash = "sha256:969ea3ef09617aff826885a22ece0ddef69d95852cdad2f60c8bb06bf1f71f70"},
+ {file = "mypy-1.11.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:37c7fa6121c1cdfcaac97ce3d3b5588e847aa79b580c1e922bb5d5d2902df19b"},
+ {file = "mypy-1.11.2-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:4a8a53bc3ffbd161b5b2a4fff2f0f1e23a33b0168f1c0778ec70e1a3d66deb86"},
+ {file = "mypy-1.11.2-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2ff93107f01968ed834f4256bc1fc4475e2fecf6c661260066a985b52741ddce"},
+ {file = "mypy-1.11.2-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:edb91dded4df17eae4537668b23f0ff6baf3707683734b6a818d5b9d0c0c31a1"},
+ {file = "mypy-1.11.2-cp38-cp38-win_amd64.whl", hash = "sha256:ee23de8530d99b6db0573c4ef4bd8f39a2a6f9b60655bf7a1357e585a3486f2b"},
+ {file = "mypy-1.11.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:801ca29f43d5acce85f8e999b1e431fb479cb02d0e11deb7d2abb56bdaf24fd6"},
+ {file = "mypy-1.11.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:af8d155170fcf87a2afb55b35dc1a0ac21df4431e7d96717621962e4b9192e70"},
+ {file = "mypy-1.11.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f7821776e5c4286b6a13138cc935e2e9b6fde05e081bdebf5cdb2bb97c9df81d"},
+ {file = "mypy-1.11.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:539c570477a96a4e6fb718b8d5c3e0c0eba1f485df13f86d2970c91f0673148d"},
+ {file = "mypy-1.11.2-cp39-cp39-win_amd64.whl", hash = "sha256:3f14cd3d386ac4d05c5a39a51b84387403dadbd936e17cb35882134d4f8f0d24"},
+ {file = "mypy-1.11.2-py3-none-any.whl", hash = "sha256:b499bc07dbdcd3de92b0a8b29fdf592c111276f6a12fe29c30f6c417dd546d12"},
+ {file = "mypy-1.11.2.tar.gz", hash = "sha256:7f9993ad3e0ffdc95c2a14b66dee63729f021968bff8ad911867579c65d13a79"},
+]
+
+[package.dependencies]
+mypy-extensions = ">=1.0.0"
+tomli = {version = ">=1.1.0", markers = "python_version < \"3.11\""}
+typing-extensions = ">=4.6.0"
+
+[package.extras]
+dmypy = ["psutil (>=4.0)"]
+install-types = ["pip"]
+mypyc = ["setuptools (>=50)"]
+reports = ["lxml"]
+
+[[package]]
+name = "mypy-extensions"
+version = "1.0.0"
+description = "Type system extensions for programs checked with the mypy type checker."
+optional = false
+python-versions = ">=3.5"
+files = [
+ {file = "mypy_extensions-1.0.0-py3-none-any.whl", hash = "sha256:4392f6c0eb8a5668a69e23d168ffa70f0be9ccfd32b5cc2d26a34ae5b844552d"},
+ {file = "mypy_extensions-1.0.0.tar.gz", hash = "sha256:75dbf8955dc00442a438fc4d0666508a9a97b6bd41aa2f0ffe9d2f2725af0782"},
+]
+
+[[package]]
+name = "nodeenv"
+version = "1.9.1"
+description = "Node.js virtual environment builder"
+optional = false
+python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7"
+files = [
+ {file = "nodeenv-1.9.1-py2.py3-none-any.whl", hash = "sha256:ba11c9782d29c27c70ffbdda2d7415098754709be8a7056d79a737cd901155c9"},
+ {file = "nodeenv-1.9.1.tar.gz", hash = "sha256:6ec12890a2dab7946721edbfbcd91f3319c6ccc9aec47be7c7e6b7011ee6645f"},
+]
+
+[[package]]
+name = "numpy"
+version = "2.0.2"
+description = "Fundamental package for array computing in Python"
+optional = false
+python-versions = ">=3.9"
+files = [
+ {file = "numpy-2.0.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:51129a29dbe56f9ca83438b706e2e69a39892b5eda6cedcb6b0c9fdc9b0d3ece"},
+ {file = "numpy-2.0.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:f15975dfec0cf2239224d80e32c3170b1d168335eaedee69da84fbe9f1f9cd04"},
+ {file = "numpy-2.0.2-cp310-cp310-macosx_14_0_arm64.whl", hash = "sha256:8c5713284ce4e282544c68d1c3b2c7161d38c256d2eefc93c1d683cf47683e66"},
+ {file = "numpy-2.0.2-cp310-cp310-macosx_14_0_x86_64.whl", hash = "sha256:becfae3ddd30736fe1889a37f1f580e245ba79a5855bff5f2a29cb3ccc22dd7b"},
+ {file = "numpy-2.0.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2da5960c3cf0df7eafefd806d4e612c5e19358de82cb3c343631188991566ccd"},
+ {file = "numpy-2.0.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:496f71341824ed9f3d2fd36cf3ac57ae2e0165c143b55c3a035ee219413f3318"},
+ {file = "numpy-2.0.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:a61ec659f68ae254e4d237816e33171497e978140353c0c2038d46e63282d0c8"},
+ {file = "numpy-2.0.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:d731a1c6116ba289c1e9ee714b08a8ff882944d4ad631fd411106a30f083c326"},
+ {file = "numpy-2.0.2-cp310-cp310-win32.whl", hash = "sha256:984d96121c9f9616cd33fbd0618b7f08e0cfc9600a7ee1d6fd9b239186d19d97"},
+ {file = "numpy-2.0.2-cp310-cp310-win_amd64.whl", hash = "sha256:c7b0be4ef08607dd04da4092faee0b86607f111d5ae68036f16cc787e250a131"},
+ {file = "numpy-2.0.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:49ca4decb342d66018b01932139c0961a8f9ddc7589611158cb3c27cbcf76448"},
+ {file = "numpy-2.0.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:11a76c372d1d37437857280aa142086476136a8c0f373b2e648ab2c8f18fb195"},
+ {file = "numpy-2.0.2-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:807ec44583fd708a21d4a11d94aedf2f4f3c3719035c76a2bbe1fe8e217bdc57"},
+ {file = "numpy-2.0.2-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:8cafab480740e22f8d833acefed5cc87ce276f4ece12fdaa2e8903db2f82897a"},
+ {file = "numpy-2.0.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a15f476a45e6e5a3a79d8a14e62161d27ad897381fecfa4a09ed5322f2085669"},
+ {file = "numpy-2.0.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:13e689d772146140a252c3a28501da66dfecd77490b498b168b501835041f951"},
+ {file = "numpy-2.0.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:9ea91dfb7c3d1c56a0e55657c0afb38cf1eeae4544c208dc465c3c9f3a7c09f9"},
+ {file = "numpy-2.0.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c1c9307701fec8f3f7a1e6711f9089c06e6284b3afbbcd259f7791282d660a15"},
+ {file = "numpy-2.0.2-cp311-cp311-win32.whl", hash = "sha256:a392a68bd329eafac5817e5aefeb39038c48b671afd242710b451e76090e81f4"},
+ {file = "numpy-2.0.2-cp311-cp311-win_amd64.whl", hash = "sha256:286cd40ce2b7d652a6f22efdfc6d1edf879440e53e76a75955bc0c826c7e64dc"},
+ {file = "numpy-2.0.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:df55d490dea7934f330006d0f81e8551ba6010a5bf035a249ef61a94f21c500b"},
+ {file = "numpy-2.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8df823f570d9adf0978347d1f926b2a867d5608f434a7cff7f7908c6570dcf5e"},
+ {file = "numpy-2.0.2-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:9a92ae5c14811e390f3767053ff54eaee3bf84576d99a2456391401323f4ec2c"},
+ {file = "numpy-2.0.2-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:a842d573724391493a97a62ebbb8e731f8a5dcc5d285dfc99141ca15a3302d0c"},
+ {file = "numpy-2.0.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c05e238064fc0610c840d1cf6a13bf63d7e391717d247f1bf0318172e759e692"},
+ {file = "numpy-2.0.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0123ffdaa88fa4ab64835dcbde75dcdf89c453c922f18dced6e27c90d1d0ec5a"},
+ {file = "numpy-2.0.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:96a55f64139912d61de9137f11bf39a55ec8faec288c75a54f93dfd39f7eb40c"},
+ {file = "numpy-2.0.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ec9852fb39354b5a45a80bdab5ac02dd02b15f44b3804e9f00c556bf24b4bded"},
+ {file = "numpy-2.0.2-cp312-cp312-win32.whl", hash = "sha256:671bec6496f83202ed2d3c8fdc486a8fc86942f2e69ff0e986140339a63bcbe5"},
+ {file = "numpy-2.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:cfd41e13fdc257aa5778496b8caa5e856dc4896d4ccf01841daee1d96465467a"},
+ {file = "numpy-2.0.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:9059e10581ce4093f735ed23f3b9d283b9d517ff46009ddd485f1747eb22653c"},
+ {file = "numpy-2.0.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:423e89b23490805d2a5a96fe40ec507407b8ee786d66f7328be214f9679df6dd"},
+ {file = "numpy-2.0.2-cp39-cp39-macosx_14_0_arm64.whl", hash = "sha256:2b2955fa6f11907cf7a70dab0d0755159bca87755e831e47932367fc8f2f2d0b"},
+ {file = "numpy-2.0.2-cp39-cp39-macosx_14_0_x86_64.whl", hash = "sha256:97032a27bd9d8988b9a97a8c4d2c9f2c15a81f61e2f21404d7e8ef00cb5be729"},
+ {file = "numpy-2.0.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1e795a8be3ddbac43274f18588329c72939870a16cae810c2b73461c40718ab1"},
+ {file = "numpy-2.0.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f26b258c385842546006213344c50655ff1555a9338e2e5e02a0756dc3e803dd"},
+ {file = "numpy-2.0.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:5fec9451a7789926bcf7c2b8d187292c9f93ea30284802a0ab3f5be8ab36865d"},
+ {file = "numpy-2.0.2-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:9189427407d88ff25ecf8f12469d4d39d35bee1db5d39fc5c168c6f088a6956d"},
+ {file = "numpy-2.0.2-cp39-cp39-win32.whl", hash = "sha256:905d16e0c60200656500c95b6b8dca5d109e23cb24abc701d41c02d74c6b3afa"},
+ {file = "numpy-2.0.2-cp39-cp39-win_amd64.whl", hash = "sha256:a3f4ab0caa7f053f6797fcd4e1e25caee367db3112ef2b6ef82d749530768c73"},
+ {file = "numpy-2.0.2-pp39-pypy39_pp73-macosx_10_9_x86_64.whl", hash = "sha256:7f0a0c6f12e07fa94133c8a67404322845220c06a9e80e85999afe727f7438b8"},
+ {file = "numpy-2.0.2-pp39-pypy39_pp73-macosx_14_0_x86_64.whl", hash = "sha256:312950fdd060354350ed123c0e25a71327d3711584beaef30cdaa93320c392d4"},
+ {file = "numpy-2.0.2-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:26df23238872200f63518dd2aa984cfca675d82469535dc7162dc2ee52d9dd5c"},
+ {file = "numpy-2.0.2-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:a46288ec55ebbd58947d31d72be2c63cbf839f0a63b49cb755022310792a3385"},
+ {file = "numpy-2.0.2.tar.gz", hash = "sha256:883c987dee1880e2a864ab0dc9892292582510604156762362d9326444636e78"},
+]
+
+[[package]]
+name = "numpy"
+version = "2.1.2"
+description = "Fundamental package for array computing in Python"
+optional = false
+python-versions = ">=3.10"
+files = [
+ {file = "numpy-2.1.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:30d53720b726ec36a7f88dc873f0eec8447fbc93d93a8f079dfac2629598d6ee"},
+ {file = "numpy-2.1.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e8d3ca0a72dd8846eb6f7dfe8f19088060fcb76931ed592d29128e0219652884"},
+ {file = "numpy-2.1.2-cp310-cp310-macosx_14_0_arm64.whl", hash = "sha256:fc44e3c68ff00fd991b59092a54350e6e4911152682b4782f68070985aa9e648"},
+ {file = "numpy-2.1.2-cp310-cp310-macosx_14_0_x86_64.whl", hash = "sha256:7c1c60328bd964b53f8b835df69ae8198659e2b9302ff9ebb7de4e5a5994db3d"},
+ {file = "numpy-2.1.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6cdb606a7478f9ad91c6283e238544451e3a95f30fb5467fbf715964341a8a86"},
+ {file = "numpy-2.1.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d666cb72687559689e9906197e3bec7b736764df6a2e58ee265e360663e9baf7"},
+ {file = "numpy-2.1.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:c6eef7a2dbd0abfb0d9eaf78b73017dbfd0b54051102ff4e6a7b2980d5ac1a03"},
+ {file = "numpy-2.1.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:12edb90831ff481f7ef5f6bc6431a9d74dc0e5ff401559a71e5e4611d4f2d466"},
+ {file = "numpy-2.1.2-cp310-cp310-win32.whl", hash = "sha256:a65acfdb9c6ebb8368490dbafe83c03c7e277b37e6857f0caeadbbc56e12f4fb"},
+ {file = "numpy-2.1.2-cp310-cp310-win_amd64.whl", hash = "sha256:860ec6e63e2c5c2ee5e9121808145c7bf86c96cca9ad396c0bd3e0f2798ccbe2"},
+ {file = "numpy-2.1.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:b42a1a511c81cc78cbc4539675713bbcf9d9c3913386243ceff0e9429ca892fe"},
+ {file = "numpy-2.1.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:faa88bc527d0f097abdc2c663cddf37c05a1c2f113716601555249805cf573f1"},
+ {file = "numpy-2.1.2-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:c82af4b2ddd2ee72d1fc0c6695048d457e00b3582ccde72d8a1c991b808bb20f"},
+ {file = "numpy-2.1.2-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:13602b3174432a35b16c4cfb5de9a12d229727c3dd47a6ce35111f2ebdf66ff4"},
+ {file = "numpy-2.1.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1ebec5fd716c5a5b3d8dfcc439be82a8407b7b24b230d0ad28a81b61c2f4659a"},
+ {file = "numpy-2.1.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e2b49c3c0804e8ecb05d59af8386ec2f74877f7ca8fd9c1e00be2672e4d399b1"},
+ {file = "numpy-2.1.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:2cbba4b30bf31ddbe97f1c7205ef976909a93a66bb1583e983adbd155ba72ac2"},
+ {file = "numpy-2.1.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8e00ea6fc82e8a804433d3e9cedaa1051a1422cb6e443011590c14d2dea59146"},
+ {file = "numpy-2.1.2-cp311-cp311-win32.whl", hash = "sha256:5006b13a06e0b38d561fab5ccc37581f23c9511879be7693bd33c7cd15ca227c"},
+ {file = "numpy-2.1.2-cp311-cp311-win_amd64.whl", hash = "sha256:f1eb068ead09f4994dec71c24b2844f1e4e4e013b9629f812f292f04bd1510d9"},
+ {file = "numpy-2.1.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d7bf0a4f9f15b32b5ba53147369e94296f5fffb783db5aacc1be15b4bf72f43b"},
+ {file = "numpy-2.1.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b1d0fcae4f0949f215d4632be684a539859b295e2d0cb14f78ec231915d644db"},
+ {file = "numpy-2.1.2-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:f751ed0a2f250541e19dfca9f1eafa31a392c71c832b6bb9e113b10d050cb0f1"},
+ {file = "numpy-2.1.2-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:bd33f82e95ba7ad632bc57837ee99dba3d7e006536200c4e9124089e1bf42426"},
+ {file = "numpy-2.1.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1b8cde4f11f0a975d1fd59373b32e2f5a562ade7cde4f85b7137f3de8fbb29a0"},
+ {file = "numpy-2.1.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6d95f286b8244b3649b477ac066c6906fbb2905f8ac19b170e2175d3d799f4df"},
+ {file = "numpy-2.1.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:ab4754d432e3ac42d33a269c8567413bdb541689b02d93788af4131018cbf366"},
+ {file = "numpy-2.1.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e585c8ae871fd38ac50598f4763d73ec5497b0de9a0ab4ef5b69f01c6a046142"},
+ {file = "numpy-2.1.2-cp312-cp312-win32.whl", hash = "sha256:9c6c754df29ce6a89ed23afb25550d1c2d5fdb9901d9c67a16e0b16eaf7e2550"},
+ {file = "numpy-2.1.2-cp312-cp312-win_amd64.whl", hash = "sha256:456e3b11cb79ac9946c822a56346ec80275eaf2950314b249b512896c0d2505e"},
+ {file = "numpy-2.1.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:a84498e0d0a1174f2b3ed769b67b656aa5460c92c9554039e11f20a05650f00d"},
+ {file = "numpy-2.1.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:4d6ec0d4222e8ffdab1744da2560f07856421b367928026fb540e1945f2eeeaf"},
+ {file = "numpy-2.1.2-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:259ec80d54999cc34cd1eb8ded513cb053c3bf4829152a2e00de2371bd406f5e"},
+ {file = "numpy-2.1.2-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:675c741d4739af2dc20cd6c6a5c4b7355c728167845e3c6b0e824e4e5d36a6c3"},
+ {file = "numpy-2.1.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:05b2d4e667895cc55e3ff2b56077e4c8a5604361fc21a042845ea3ad67465aa8"},
+ {file = "numpy-2.1.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:43cca367bf94a14aca50b89e9bc2061683116cfe864e56740e083392f533ce7a"},
+ {file = "numpy-2.1.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:76322dcdb16fccf2ac56f99048af32259dcc488d9b7e25b51e5eca5147a3fb98"},
+ {file = "numpy-2.1.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:32e16a03138cabe0cb28e1007ee82264296ac0983714094380b408097a418cfe"},
+ {file = "numpy-2.1.2-cp313-cp313-win32.whl", hash = "sha256:242b39d00e4944431a3cd2db2f5377e15b5785920421993770cddb89992c3f3a"},
+ {file = "numpy-2.1.2-cp313-cp313-win_amd64.whl", hash = "sha256:f2ded8d9b6f68cc26f8425eda5d3877b47343e68ca23d0d0846f4d312ecaa445"},
+ {file = "numpy-2.1.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:2ffef621c14ebb0188a8633348504a35c13680d6da93ab5cb86f4e54b7e922b5"},
+ {file = "numpy-2.1.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:ad369ed238b1959dfbade9018a740fb9392c5ac4f9b5173f420bd4f37ba1f7a0"},
+ {file = "numpy-2.1.2-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:d82075752f40c0ddf57e6e02673a17f6cb0f8eb3f587f63ca1eaab5594da5b17"},
+ {file = "numpy-2.1.2-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:1600068c262af1ca9580a527d43dc9d959b0b1d8e56f8a05d830eea39b7c8af6"},
+ {file = "numpy-2.1.2-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a26ae94658d3ba3781d5e103ac07a876b3e9b29db53f68ed7df432fd033358a8"},
+ {file = "numpy-2.1.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:13311c2db4c5f7609b462bc0f43d3c465424d25c626d95040f073e30f7570e35"},
+ {file = "numpy-2.1.2-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:2abbf905a0b568706391ec6fa15161fad0fb5d8b68d73c461b3c1bab6064dd62"},
+ {file = "numpy-2.1.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:ef444c57d664d35cac4e18c298c47d7b504c66b17c2ea91312e979fcfbdfb08a"},
+ {file = "numpy-2.1.2-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:bdd407c40483463898b84490770199d5714dcc9dd9b792f6c6caccc523c00952"},
+ {file = "numpy-2.1.2-pp310-pypy310_pp73-macosx_14_0_x86_64.whl", hash = "sha256:da65fb46d4cbb75cb417cddf6ba5e7582eb7bb0b47db4b99c9fe5787ce5d91f5"},
+ {file = "numpy-2.1.2-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1c193d0b0238638e6fc5f10f1b074a6993cb13b0b431f64079a509d63d3aa8b7"},
+ {file = "numpy-2.1.2-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:a7d80b2e904faa63068ead63107189164ca443b42dd1930299e0d1cb041cec2e"},
+ {file = "numpy-2.1.2.tar.gz", hash = "sha256:13532a088217fa624c99b843eeb54640de23b3414b14aa66d023805eb731066c"},
+]
+
+[[package]]
+name = "opencv-python"
+version = "4.10.0.84"
+description = "Wrapper package for OpenCV python bindings."
+optional = false
+python-versions = ">=3.6"
+files = [
+ {file = "opencv-python-4.10.0.84.tar.gz", hash = "sha256:72d234e4582e9658ffea8e9cae5b63d488ad06994ef12d81dc303b17472f3526"},
+ {file = "opencv_python-4.10.0.84-cp37-abi3-macosx_11_0_arm64.whl", hash = "sha256:fc182f8f4cda51b45f01c64e4cbedfc2f00aff799debebc305d8d0210c43f251"},
+ {file = "opencv_python-4.10.0.84-cp37-abi3-macosx_12_0_x86_64.whl", hash = "sha256:71e575744f1d23f79741450254660442785f45a0797212852ee5199ef12eed98"},
+ {file = "opencv_python-4.10.0.84-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:09a332b50488e2dda866a6c5573ee192fe3583239fb26ff2f7f9ceb0bc119ea6"},
+ {file = "opencv_python-4.10.0.84-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9ace140fc6d647fbe1c692bcb2abce768973491222c067c131d80957c595b71f"},
+ {file = "opencv_python-4.10.0.84-cp37-abi3-win32.whl", hash = "sha256:2db02bb7e50b703f0a2d50c50ced72e95c574e1e5a0bb35a8a86d0b35c98c236"},
+ {file = "opencv_python-4.10.0.84-cp37-abi3-win_amd64.whl", hash = "sha256:32dbbd94c26f611dc5cc6979e6b7aa1f55a64d6b463cc1dcd3c95505a63e48fe"},
+]
+
+[package.dependencies]
+numpy = [
+ {version = ">=1.21.0", markers = "python_version == \"3.9\" and platform_system == \"Darwin\" and platform_machine == \"arm64\""},
+ {version = ">=1.26.0", markers = "python_version >= \"3.12\""},
+ {version = ">=1.23.5", markers = "python_version >= \"3.11\" and python_version < \"3.12\""},
+ {version = ">=1.21.4", markers = "python_version >= \"3.10\" and platform_system == \"Darwin\" and python_version < \"3.11\""},
+ {version = ">=1.21.2", markers = "platform_system != \"Darwin\" and python_version >= \"3.10\" and python_version < \"3.11\""},
+ {version = ">=1.19.3", markers = "platform_system == \"Linux\" and platform_machine == \"aarch64\" and python_version >= \"3.8\" and python_version < \"3.10\" or python_version > \"3.9\" and python_version < \"3.10\" or python_version >= \"3.9\" and platform_system != \"Darwin\" and python_version < \"3.10\" or python_version >= \"3.9\" and platform_machine != \"arm64\" and python_version < \"3.10\""},
+]
+
+[[package]]
+name = "packaging"
+version = "24.1"
+description = "Core utilities for Python packages"
+optional = false
+python-versions = ">=3.8"
+files = [
+ {file = "packaging-24.1-py3-none-any.whl", hash = "sha256:5b8f2217dbdbd2f7f384c41c628544e6d52f2d0f53c6d0c3ea61aa5d1d7ff124"},
+ {file = "packaging-24.1.tar.gz", hash = "sha256:026ed72c8ed3fcce5bf8950572258698927fd1dbda10a5e981cdf0ac37f4f002"},
+]
+
+[[package]]
+name = "pathspec"
+version = "0.12.1"
+description = "Utility library for gitignore style pattern matching of file paths."
+optional = false
+python-versions = ">=3.8"
+files = [
+ {file = "pathspec-0.12.1-py3-none-any.whl", hash = "sha256:a0d503e138a4c123b27490a4f7beda6a01c6f288df0e4a8b79c7eb0dc7b4cc08"},
+ {file = "pathspec-0.12.1.tar.gz", hash = "sha256:a482d51503a1ab33b1c67a6c3813a26953dbdc71c31dacaef9a838c4e29f5712"},
+]
+
+[[package]]
+name = "pillow"
+version = "10.4.0"
+description = "Python Imaging Library (Fork)"
+optional = false
+python-versions = ">=3.8"
+files = [
+ {file = "pillow-10.4.0-cp310-cp310-macosx_10_10_x86_64.whl", hash = "sha256:4d9667937cfa347525b319ae34375c37b9ee6b525440f3ef48542fcf66f2731e"},
+ {file = "pillow-10.4.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:543f3dc61c18dafb755773efc89aae60d06b6596a63914107f75459cf984164d"},
+ {file = "pillow-10.4.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7928ecbf1ece13956b95d9cbcfc77137652b02763ba384d9ab508099a2eca856"},
+ {file = "pillow-10.4.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e4d49b85c4348ea0b31ea63bc75a9f3857869174e2bf17e7aba02945cd218e6f"},
+ {file = "pillow-10.4.0-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:6c762a5b0997f5659a5ef2266abc1d8851ad7749ad9a6a5506eb23d314e4f46b"},
+ {file = "pillow-10.4.0-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:a985e028fc183bf12a77a8bbf36318db4238a3ded7fa9df1b9a133f1cb79f8fc"},
+ {file = "pillow-10.4.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:812f7342b0eee081eaec84d91423d1b4650bb9828eb53d8511bcef8ce5aecf1e"},
+ {file = "pillow-10.4.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:ac1452d2fbe4978c2eec89fb5a23b8387aba707ac72810d9490118817d9c0b46"},
+ {file = "pillow-10.4.0-cp310-cp310-win32.whl", hash = "sha256:bcd5e41a859bf2e84fdc42f4edb7d9aba0a13d29a2abadccafad99de3feff984"},
+ {file = "pillow-10.4.0-cp310-cp310-win_amd64.whl", hash = "sha256:ecd85a8d3e79cd7158dec1c9e5808e821feea088e2f69a974db5edf84dc53141"},
+ {file = "pillow-10.4.0-cp310-cp310-win_arm64.whl", hash = "sha256:ff337c552345e95702c5fde3158acb0625111017d0e5f24bf3acdb9cc16b90d1"},
+ {file = "pillow-10.4.0-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:0a9ec697746f268507404647e531e92889890a087e03681a3606d9b920fbee3c"},
+ {file = "pillow-10.4.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:dfe91cb65544a1321e631e696759491ae04a2ea11d36715eca01ce07284738be"},
+ {file = "pillow-10.4.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5dc6761a6efc781e6a1544206f22c80c3af4c8cf461206d46a1e6006e4429ff3"},
+ {file = "pillow-10.4.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5e84b6cc6a4a3d76c153a6b19270b3526a5a8ed6b09501d3af891daa2a9de7d6"},
+ {file = "pillow-10.4.0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:bbc527b519bd3aa9d7f429d152fea69f9ad37c95f0b02aebddff592688998abe"},
+ {file = "pillow-10.4.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:76a911dfe51a36041f2e756b00f96ed84677cdeb75d25c767f296c1c1eda1319"},
+ {file = "pillow-10.4.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:59291fb29317122398786c2d44427bbd1a6d7ff54017075b22be9d21aa59bd8d"},
+ {file = "pillow-10.4.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:416d3a5d0e8cfe4f27f574362435bc9bae57f679a7158e0096ad2beb427b8696"},
+ {file = "pillow-10.4.0-cp311-cp311-win32.whl", hash = "sha256:7086cc1d5eebb91ad24ded9f58bec6c688e9f0ed7eb3dbbf1e4800280a896496"},
+ {file = "pillow-10.4.0-cp311-cp311-win_amd64.whl", hash = "sha256:cbed61494057c0f83b83eb3a310f0bf774b09513307c434d4366ed64f4128a91"},
+ {file = "pillow-10.4.0-cp311-cp311-win_arm64.whl", hash = "sha256:f5f0c3e969c8f12dd2bb7e0b15d5c468b51e5017e01e2e867335c81903046a22"},
+ {file = "pillow-10.4.0-cp312-cp312-macosx_10_10_x86_64.whl", hash = "sha256:673655af3eadf4df6b5457033f086e90299fdd7a47983a13827acf7459c15d94"},
+ {file = "pillow-10.4.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:866b6942a92f56300012f5fbac71f2d610312ee65e22f1aa2609e491284e5597"},
+ {file = "pillow-10.4.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:29dbdc4207642ea6aad70fbde1a9338753d33fb23ed6956e706936706f52dd80"},
+ {file = "pillow-10.4.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bf2342ac639c4cf38799a44950bbc2dfcb685f052b9e262f446482afaf4bffca"},
+ {file = "pillow-10.4.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:f5b92f4d70791b4a67157321c4e8225d60b119c5cc9aee8ecf153aace4aad4ef"},
+ {file = "pillow-10.4.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:86dcb5a1eb778d8b25659d5e4341269e8590ad6b4e8b44d9f4b07f8d136c414a"},
+ {file = "pillow-10.4.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:780c072c2e11c9b2c7ca37f9a2ee8ba66f44367ac3e5c7832afcfe5104fd6d1b"},
+ {file = "pillow-10.4.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:37fb69d905be665f68f28a8bba3c6d3223c8efe1edf14cc4cfa06c241f8c81d9"},
+ {file = "pillow-10.4.0-cp312-cp312-win32.whl", hash = "sha256:7dfecdbad5c301d7b5bde160150b4db4c659cee2b69589705b6f8a0c509d9f42"},
+ {file = "pillow-10.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:1d846aea995ad352d4bdcc847535bd56e0fd88d36829d2c90be880ef1ee4668a"},
+ {file = "pillow-10.4.0-cp312-cp312-win_arm64.whl", hash = "sha256:e553cad5179a66ba15bb18b353a19020e73a7921296a7979c4a2b7f6a5cd57f9"},
+ {file = "pillow-10.4.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8bc1a764ed8c957a2e9cacf97c8b2b053b70307cf2996aafd70e91a082e70df3"},
+ {file = "pillow-10.4.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:6209bb41dc692ddfee4942517c19ee81b86c864b626dbfca272ec0f7cff5d9fb"},
+ {file = "pillow-10.4.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bee197b30783295d2eb680b311af15a20a8b24024a19c3a26431ff83eb8d1f70"},
+ {file = "pillow-10.4.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1ef61f5dd14c300786318482456481463b9d6b91ebe5ef12f405afbba77ed0be"},
+ {file = "pillow-10.4.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:297e388da6e248c98bc4a02e018966af0c5f92dfacf5a5ca22fa01cb3179bca0"},
+ {file = "pillow-10.4.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:e4db64794ccdf6cb83a59d73405f63adbe2a1887012e308828596100a0b2f6cc"},
+ {file = "pillow-10.4.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bd2880a07482090a3bcb01f4265f1936a903d70bc740bfcb1fd4e8a2ffe5cf5a"},
+ {file = "pillow-10.4.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4b35b21b819ac1dbd1233317adeecd63495f6babf21b7b2512d244ff6c6ce309"},
+ {file = "pillow-10.4.0-cp313-cp313-win32.whl", hash = "sha256:551d3fd6e9dc15e4c1eb6fc4ba2b39c0c7933fa113b220057a34f4bb3268a060"},
+ {file = "pillow-10.4.0-cp313-cp313-win_amd64.whl", hash = "sha256:030abdbe43ee02e0de642aee345efa443740aa4d828bfe8e2eb11922ea6a21ea"},
+ {file = "pillow-10.4.0-cp313-cp313-win_arm64.whl", hash = "sha256:5b001114dd152cfd6b23befeb28d7aee43553e2402c9f159807bf55f33af8a8d"},
+ {file = "pillow-10.4.0-cp38-cp38-macosx_10_10_x86_64.whl", hash = "sha256:8d4d5063501b6dd4024b8ac2f04962d661222d120381272deea52e3fc52d3736"},
+ {file = "pillow-10.4.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:7c1ee6f42250df403c5f103cbd2768a28fe1a0ea1f0f03fe151c8741e1469c8b"},
+ {file = "pillow-10.4.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b15e02e9bb4c21e39876698abf233c8c579127986f8207200bc8a8f6bb27acf2"},
+ {file = "pillow-10.4.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7a8d4bade9952ea9a77d0c3e49cbd8b2890a399422258a77f357b9cc9be8d680"},
+ {file = "pillow-10.4.0-cp38-cp38-manylinux_2_28_aarch64.whl", hash = "sha256:43efea75eb06b95d1631cb784aa40156177bf9dd5b4b03ff38979e048258bc6b"},
+ {file = "pillow-10.4.0-cp38-cp38-manylinux_2_28_x86_64.whl", hash = "sha256:950be4d8ba92aca4b2bb0741285a46bfae3ca699ef913ec8416c1b78eadd64cd"},
+ {file = "pillow-10.4.0-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:d7480af14364494365e89d6fddc510a13e5a2c3584cb19ef65415ca57252fb84"},
+ {file = "pillow-10.4.0-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:73664fe514b34c8f02452ffb73b7a92c6774e39a647087f83d67f010eb9a0cf0"},
+ {file = "pillow-10.4.0-cp38-cp38-win32.whl", hash = "sha256:e88d5e6ad0d026fba7bdab8c3f225a69f063f116462c49892b0149e21b6c0a0e"},
+ {file = "pillow-10.4.0-cp38-cp38-win_amd64.whl", hash = "sha256:5161eef006d335e46895297f642341111945e2c1c899eb406882a6c61a4357ab"},
+ {file = "pillow-10.4.0-cp39-cp39-macosx_10_10_x86_64.whl", hash = "sha256:0ae24a547e8b711ccaaf99c9ae3cd975470e1a30caa80a6aaee9a2f19c05701d"},
+ {file = "pillow-10.4.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:298478fe4f77a4408895605f3482b6cc6222c018b2ce565c2b6b9c354ac3229b"},
+ {file = "pillow-10.4.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:134ace6dc392116566980ee7436477d844520a26a4b1bd4053f6f47d096997fd"},
+ {file = "pillow-10.4.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:930044bb7679ab003b14023138b50181899da3f25de50e9dbee23b61b4de2126"},
+ {file = "pillow-10.4.0-cp39-cp39-manylinux_2_28_aarch64.whl", hash = "sha256:c76e5786951e72ed3686e122d14c5d7012f16c8303a674d18cdcd6d89557fc5b"},
+ {file = "pillow-10.4.0-cp39-cp39-manylinux_2_28_x86_64.whl", hash = "sha256:b2724fdb354a868ddf9a880cb84d102da914e99119211ef7ecbdc613b8c96b3c"},
+ {file = "pillow-10.4.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:dbc6ae66518ab3c5847659e9988c3b60dc94ffb48ef9168656e0019a93dbf8a1"},
+ {file = "pillow-10.4.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:06b2f7898047ae93fad74467ec3d28fe84f7831370e3c258afa533f81ef7f3df"},
+ {file = "pillow-10.4.0-cp39-cp39-win32.whl", hash = "sha256:7970285ab628a3779aecc35823296a7869f889b8329c16ad5a71e4901a3dc4ef"},
+ {file = "pillow-10.4.0-cp39-cp39-win_amd64.whl", hash = "sha256:961a7293b2457b405967af9c77dcaa43cc1a8cd50d23c532e62d48ab6cdd56f5"},
+ {file = "pillow-10.4.0-cp39-cp39-win_arm64.whl", hash = "sha256:32cda9e3d601a52baccb2856b8ea1fc213c90b340c542dcef77140dfa3278a9e"},
+ {file = "pillow-10.4.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:5b4815f2e65b30f5fbae9dfffa8636d992d49705723fe86a3661806e069352d4"},
+ {file = "pillow-10.4.0-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:8f0aef4ef59694b12cadee839e2ba6afeab89c0f39a3adc02ed51d109117b8da"},
+ {file = "pillow-10.4.0-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9f4727572e2918acaa9077c919cbbeb73bd2b3ebcfe033b72f858fc9fbef0026"},
+ {file = "pillow-10.4.0-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ff25afb18123cea58a591ea0244b92eb1e61a1fd497bf6d6384f09bc3262ec3e"},
+ {file = "pillow-10.4.0-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:dc3e2db6ba09ffd7d02ae9141cfa0ae23393ee7687248d46a7507b75d610f4f5"},
+ {file = "pillow-10.4.0-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:02a2be69f9c9b8c1e97cf2713e789d4e398c751ecfd9967c18d0ce304efbf885"},
+ {file = "pillow-10.4.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:0755ffd4a0c6f267cccbae2e9903d95477ca2f77c4fcf3a3a09570001856c8a5"},
+ {file = "pillow-10.4.0-pp39-pypy39_pp73-macosx_10_15_x86_64.whl", hash = "sha256:a02364621fe369e06200d4a16558e056fe2805d3468350df3aef21e00d26214b"},
+ {file = "pillow-10.4.0-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:1b5dea9831a90e9d0721ec417a80d4cbd7022093ac38a568db2dd78363b00908"},
+ {file = "pillow-10.4.0-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9b885f89040bb8c4a1573566bbb2f44f5c505ef6e74cec7ab9068c900047f04b"},
+ {file = "pillow-10.4.0-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:87dd88ded2e6d74d31e1e0a99a726a6765cda32d00ba72dc37f0651f306daaa8"},
+ {file = "pillow-10.4.0-pp39-pypy39_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:2db98790afc70118bd0255c2eeb465e9767ecf1f3c25f9a1abb8ffc8cfd1fe0a"},
+ {file = "pillow-10.4.0-pp39-pypy39_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:f7baece4ce06bade126fb84b8af1c33439a76d8a6fd818970215e0560ca28c27"},
+ {file = "pillow-10.4.0-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:cfdd747216947628af7b259d274771d84db2268ca062dd5faf373639d00113a3"},
+ {file = "pillow-10.4.0.tar.gz", hash = "sha256:166c1cd4d24309b30d61f79f4a9114b7b2313d7450912277855ff5dfd7cd4a06"},
+]
+
+[package.extras]
+docs = ["furo", "olefile", "sphinx (>=7.3)", "sphinx-copybutton", "sphinx-inline-tabs", "sphinxext-opengraph"]
+fpx = ["olefile"]
+mic = ["olefile"]
+tests = ["check-manifest", "coverage", "defusedxml", "markdown2", "olefile", "packaging", "pyroma", "pytest", "pytest-cov", "pytest-timeout"]
+typing = ["typing-extensions"]
+xmp = ["defusedxml"]
+
+[[package]]
+name = "platformdirs"
+version = "4.3.6"
+description = "A small Python package for determining appropriate platform-specific dirs, e.g. a `user data dir`."
+optional = false
+python-versions = ">=3.8"
+files = [
+ {file = "platformdirs-4.3.6-py3-none-any.whl", hash = "sha256:73e575e1408ab8103900836b97580d5307456908a03e92031bab39e4554cc3fb"},
+ {file = "platformdirs-4.3.6.tar.gz", hash = "sha256:357fb2acbc885b0419afd3ce3ed34564c13c9b95c89360cd9563f73aa5e2b907"},
+]
+
+[package.extras]
+docs = ["furo (>=2024.8.6)", "proselint (>=0.14)", "sphinx (>=8.0.2)", "sphinx-autodoc-typehints (>=2.4)"]
+test = ["appdirs (==1.4.4)", "covdefaults (>=2.3)", "pytest (>=8.3.2)", "pytest-cov (>=5)", "pytest-mock (>=3.14)"]
+type = ["mypy (>=1.11.2)"]
+
+[[package]]
+name = "pluggy"
+version = "1.5.0"
+description = "plugin and hook calling mechanisms for python"
+optional = false
+python-versions = ">=3.8"
+files = [
+ {file = "pluggy-1.5.0-py3-none-any.whl", hash = "sha256:44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669"},
+ {file = "pluggy-1.5.0.tar.gz", hash = "sha256:2cffa88e94fdc978c4c574f15f9e59b7f4201d439195c3715ca9e2486f1d0cf1"},
+]
+
+[package.extras]
+dev = ["pre-commit", "tox"]
+testing = ["pytest", "pytest-benchmark"]
+
+[[package]]
+name = "pre-commit"
+version = "3.8.0"
+description = "A framework for managing and maintaining multi-language pre-commit hooks."
+optional = false
+python-versions = ">=3.9"
+files = [
+ {file = "pre_commit-3.8.0-py2.py3-none-any.whl", hash = "sha256:9a90a53bf82fdd8778d58085faf8d83df56e40dfe18f45b19446e26bf1b3a63f"},
+ {file = "pre_commit-3.8.0.tar.gz", hash = "sha256:8bb6494d4a20423842e198980c9ecf9f96607a07ea29549e180eef9ae80fe7af"},
+]
+
+[package.dependencies]
+cfgv = ">=2.0.0"
+identify = ">=1.0.0"
+nodeenv = ">=0.11.1"
+pyyaml = ">=5.1"
+virtualenv = ">=20.10.0"
+
+[[package]]
+name = "pycodestyle"
+version = "2.12.1"
+description = "Python style guide checker"
+optional = false
+python-versions = ">=3.8"
+files = [
+ {file = "pycodestyle-2.12.1-py2.py3-none-any.whl", hash = "sha256:46f0fb92069a7c28ab7bb558f05bfc0110dac69a0cd23c61ea0040283a9d78b3"},
+ {file = "pycodestyle-2.12.1.tar.gz", hash = "sha256:6838eae08bbce4f6accd5d5572075c63626a15ee3e6f842df996bf62f6d73521"},
+]
+
+[[package]]
+name = "pyflakes"
+version = "3.2.0"
+description = "passive checker of Python programs"
+optional = false
+python-versions = ">=3.8"
+files = [
+ {file = "pyflakes-3.2.0-py2.py3-none-any.whl", hash = "sha256:84b5be138a2dfbb40689ca07e2152deb896a65c3a3e24c251c5c62489568074a"},
+ {file = "pyflakes-3.2.0.tar.gz", hash = "sha256:1c61603ff154621fb2a9172037d84dca3500def8c8b630657d1701f026f8af3f"},
+]
+
+[[package]]
+name = "pygments"
+version = "2.18.0"
+description = "Pygments is a syntax highlighting package written in Python."
+optional = false
+python-versions = ">=3.8"
+files = [
+ {file = "pygments-2.18.0-py3-none-any.whl", hash = "sha256:b8e6aca0523f3ab76fee51799c488e38782ac06eafcf95e7ba832985c8e7b13a"},
+ {file = "pygments-2.18.0.tar.gz", hash = "sha256:786ff802f32e91311bff3889f6e9a86e81505fe99f2735bb6d60ae0c5004f199"},
+]
+
+[package.extras]
+windows-terminal = ["colorama (>=0.4.6)"]
+
+[[package]]
+name = "pygount"
+version = "1.2.4"
+description = "count source lines of code (SLOC) using pygments"
+optional = false
+python-versions = ">=3.5"
+files = [
+ {file = "pygount-1.2.4-py3-none-any.whl", hash = "sha256:8ec56e58cfcb2be8bb54f32f02e7130d33302e2543c8a37b441e606ea3b8a2c5"},
+]
+
+[package.dependencies]
+pygments = ">=2.0"
+
+[[package]]
+name = "pytest"
+version = "8.3.3"
+description = "pytest: simple powerful testing with Python"
+optional = false
+python-versions = ">=3.8"
+files = [
+ {file = "pytest-8.3.3-py3-none-any.whl", hash = "sha256:a6853c7375b2663155079443d2e45de913a911a11d669df02a50814944db57b2"},
+ {file = "pytest-8.3.3.tar.gz", hash = "sha256:70b98107bd648308a7952b06e6ca9a50bc660be218d53c257cc1fc94fda10181"},
+]
+
+[package.dependencies]
+colorama = {version = "*", markers = "sys_platform == \"win32\""}
+exceptiongroup = {version = ">=1.0.0rc8", markers = "python_version < \"3.11\""}
+iniconfig = "*"
+packaging = "*"
+pluggy = ">=1.5,<2"
+tomli = {version = ">=1", markers = "python_version < \"3.11\""}
+
+[package.extras]
+dev = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "pygments (>=2.7.2)", "requests", "setuptools", "xmlschema"]
+
+[[package]]
+name = "python-dateutil"
+version = "2.9.0.post0"
+description = "Extensions to the standard Python datetime module"
+optional = false
+python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7"
+files = [
+ {file = "python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3"},
+ {file = "python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427"},
+]
+
+[package.dependencies]
+six = ">=1.5"
+
+[[package]]
+name = "pyyaml"
+version = "6.0.2"
+description = "YAML parser and emitter for Python"
+optional = false
+python-versions = ">=3.8"
+files = [
+ {file = "PyYAML-6.0.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0a9a2848a5b7feac301353437eb7d5957887edbf81d56e903999a75a3d743086"},
+ {file = "PyYAML-6.0.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:29717114e51c84ddfba879543fb232a6ed60086602313ca38cce623c1d62cfbf"},
+ {file = "PyYAML-6.0.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8824b5a04a04a047e72eea5cec3bc266db09e35de6bdfe34c9436ac5ee27d237"},
+ {file = "PyYAML-6.0.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7c36280e6fb8385e520936c3cb3b8042851904eba0e58d277dca80a5cfed590b"},
+ {file = "PyYAML-6.0.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ec031d5d2feb36d1d1a24380e4db6d43695f3748343d99434e6f5f9156aaa2ed"},
+ {file = "PyYAML-6.0.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:936d68689298c36b53b29f23c6dbb74de12b4ac12ca6cfe0e047bedceea56180"},
+ {file = "PyYAML-6.0.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:23502f431948090f597378482b4812b0caae32c22213aecf3b55325e049a6c68"},
+ {file = "PyYAML-6.0.2-cp310-cp310-win32.whl", hash = "sha256:2e99c6826ffa974fe6e27cdb5ed0021786b03fc98e5ee3c5bfe1fd5015f42b99"},
+ {file = "PyYAML-6.0.2-cp310-cp310-win_amd64.whl", hash = "sha256:a4d3091415f010369ae4ed1fc6b79def9416358877534caf6a0fdd2146c87a3e"},
+ {file = "PyYAML-6.0.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:cc1c1159b3d456576af7a3e4d1ba7e6924cb39de8f67111c735f6fc832082774"},
+ {file = "PyYAML-6.0.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1e2120ef853f59c7419231f3bf4e7021f1b936f6ebd222406c3b60212205d2ee"},
+ {file = "PyYAML-6.0.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5d225db5a45f21e78dd9358e58a98702a0302f2659a3c6cd320564b75b86f47c"},
+ {file = "PyYAML-6.0.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5ac9328ec4831237bec75defaf839f7d4564be1e6b25ac710bd1a96321cc8317"},
+ {file = "PyYAML-6.0.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3ad2a3decf9aaba3d29c8f537ac4b243e36bef957511b4766cb0057d32b0be85"},
+ {file = "PyYAML-6.0.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:ff3824dc5261f50c9b0dfb3be22b4567a6f938ccce4587b38952d85fd9e9afe4"},
+ {file = "PyYAML-6.0.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:797b4f722ffa07cc8d62053e4cff1486fa6dc094105d13fea7b1de7d8bf71c9e"},
+ {file = "PyYAML-6.0.2-cp311-cp311-win32.whl", hash = "sha256:11d8f3dd2b9c1207dcaf2ee0bbbfd5991f571186ec9cc78427ba5bd32afae4b5"},
+ {file = "PyYAML-6.0.2-cp311-cp311-win_amd64.whl", hash = "sha256:e10ce637b18caea04431ce14fabcf5c64a1c61ec9c56b071a4b7ca131ca52d44"},
+ {file = "PyYAML-6.0.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:c70c95198c015b85feafc136515252a261a84561b7b1d51e3384e0655ddf25ab"},
+ {file = "PyYAML-6.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ce826d6ef20b1bc864f0a68340c8b3287705cae2f8b4b1d932177dcc76721725"},
+ {file = "PyYAML-6.0.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1f71ea527786de97d1a0cc0eacd1defc0985dcf6b3f17bb77dcfc8c34bec4dc5"},
+ {file = "PyYAML-6.0.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9b22676e8097e9e22e36d6b7bda33190d0d400f345f23d4065d48f4ca7ae0425"},
+ {file = "PyYAML-6.0.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:80bab7bfc629882493af4aa31a4cfa43a4c57c83813253626916b8c7ada83476"},
+ {file = "PyYAML-6.0.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:0833f8694549e586547b576dcfaba4a6b55b9e96098b36cdc7ebefe667dfed48"},
+ {file = "PyYAML-6.0.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8b9c7197f7cb2738065c481a0461e50ad02f18c78cd75775628afb4d7137fb3b"},
+ {file = "PyYAML-6.0.2-cp312-cp312-win32.whl", hash = "sha256:ef6107725bd54b262d6dedcc2af448a266975032bc85ef0172c5f059da6325b4"},
+ {file = "PyYAML-6.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:7e7401d0de89a9a855c839bc697c079a4af81cf878373abd7dc625847d25cbd8"},
+ {file = "PyYAML-6.0.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:efdca5630322a10774e8e98e1af481aad470dd62c3170801852d752aa7a783ba"},
+ {file = "PyYAML-6.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:50187695423ffe49e2deacb8cd10510bc361faac997de9efef88badc3bb9e2d1"},
+ {file = "PyYAML-6.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0ffe8360bab4910ef1b9e87fb812d8bc0a308b0d0eef8c8f44e0254ab3b07133"},
+ {file = "PyYAML-6.0.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:17e311b6c678207928d649faa7cb0d7b4c26a0ba73d41e99c4fff6b6c3276484"},
+ {file = "PyYAML-6.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:70b189594dbe54f75ab3a1acec5f1e3faa7e8cf2f1e08d9b561cb41b845f69d5"},
+ {file = "PyYAML-6.0.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:41e4e3953a79407c794916fa277a82531dd93aad34e29c2a514c2c0c5fe971cc"},
+ {file = "PyYAML-6.0.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:68ccc6023a3400877818152ad9a1033e3db8625d899c72eacb5a668902e4d652"},
+ {file = "PyYAML-6.0.2-cp313-cp313-win32.whl", hash = "sha256:bc2fa7c6b47d6bc618dd7fb02ef6fdedb1090ec036abab80d4681424b84c1183"},
+ {file = "PyYAML-6.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:8388ee1976c416731879ac16da0aff3f63b286ffdd57cdeb95f3f2e085687563"},
+ {file = "PyYAML-6.0.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:24471b829b3bf607e04e88d79542a9d48bb037c2267d7927a874e6c205ca7e9a"},
+ {file = "PyYAML-6.0.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d7fded462629cfa4b685c5416b949ebad6cec74af5e2d42905d41e257e0869f5"},
+ {file = "PyYAML-6.0.2-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d84a1718ee396f54f3a086ea0a66d8e552b2ab2017ef8b420e92edbc841c352d"},
+ {file = "PyYAML-6.0.2-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9056c1ecd25795207ad294bcf39f2db3d845767be0ea6e6a34d856f006006083"},
+ {file = "PyYAML-6.0.2-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:82d09873e40955485746739bcb8b4586983670466c23382c19cffecbf1fd8706"},
+ {file = "PyYAML-6.0.2-cp38-cp38-win32.whl", hash = "sha256:43fa96a3ca0d6b1812e01ced1044a003533c47f6ee8aca31724f78e93ccc089a"},
+ {file = "PyYAML-6.0.2-cp38-cp38-win_amd64.whl", hash = "sha256:01179a4a8559ab5de078078f37e5c1a30d76bb88519906844fd7bdea1b7729ff"},
+ {file = "PyYAML-6.0.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:688ba32a1cffef67fd2e9398a2efebaea461578b0923624778664cc1c914db5d"},
+ {file = "PyYAML-6.0.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:a8786accb172bd8afb8be14490a16625cbc387036876ab6ba70912730faf8e1f"},
+ {file = "PyYAML-6.0.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d8e03406cac8513435335dbab54c0d385e4a49e4945d2909a581c83647ca0290"},
+ {file = "PyYAML-6.0.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f753120cb8181e736c57ef7636e83f31b9c0d1722c516f7e86cf15b7aa57ff12"},
+ {file = "PyYAML-6.0.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3b1fdb9dc17f5a7677423d508ab4f243a726dea51fa5e70992e59a7411c89d19"},
+ {file = "PyYAML-6.0.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:0b69e4ce7a131fe56b7e4d770c67429700908fc0752af059838b1cfb41960e4e"},
+ {file = "PyYAML-6.0.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:a9f8c2e67970f13b16084e04f134610fd1d374bf477b17ec1599185cf611d725"},
+ {file = "PyYAML-6.0.2-cp39-cp39-win32.whl", hash = "sha256:6395c297d42274772abc367baaa79683958044e5d3835486c16da75d2a694631"},
+ {file = "PyYAML-6.0.2-cp39-cp39-win_amd64.whl", hash = "sha256:39693e1f8320ae4f43943590b49779ffb98acb81f788220ea932a6b6c51004d8"},
+ {file = "pyyaml-6.0.2.tar.gz", hash = "sha256:d584d9ec91ad65861cc08d42e834324ef890a082e591037abe114850ff7bbc3e"},
+]
+
+[[package]]
+name = "six"
+version = "1.16.0"
+description = "Python 2 and 3 compatibility utilities"
+optional = false
+python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*"
+files = [
+ {file = "six-1.16.0-py2.py3-none-any.whl", hash = "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254"},
+ {file = "six-1.16.0.tar.gz", hash = "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926"},
+]
+
+[[package]]
+name = "toml"
+version = "0.10.2"
+description = "Python Library for Tom's Obvious, Minimal Language"
+optional = false
+python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*"
+files = [
+ {file = "toml-0.10.2-py2.py3-none-any.whl", hash = "sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b"},
+ {file = "toml-0.10.2.tar.gz", hash = "sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f"},
+]
+
+[[package]]
+name = "tomli"
+version = "2.0.2"
+description = "A lil' TOML parser"
+optional = false
+python-versions = ">=3.8"
+files = [
+ {file = "tomli-2.0.2-py3-none-any.whl", hash = "sha256:2ebe24485c53d303f690b0ec092806a085f07af5a5aa1464f3931eec36caaa38"},
+ {file = "tomli-2.0.2.tar.gz", hash = "sha256:d46d457a85337051c36524bc5349dd91b1877838e2979ac5ced3e710ed8a60ed"},
+]
+
+[[package]]
+name = "tomli-w"
+version = "1.1.0"
+description = "A lil' TOML writer"
+optional = false
+python-versions = ">=3.9"
+files = [
+ {file = "tomli_w-1.1.0-py3-none-any.whl", hash = "sha256:1403179c78193e3184bfaade390ddbd071cba48a32a2e62ba11aae47490c63f7"},
+ {file = "tomli_w-1.1.0.tar.gz", hash = "sha256:49e847a3a304d516a169a601184932ef0f6b61623fe680f836a2aa7128ed0d33"},
+]
+
+[[package]]
+name = "types-toml"
+version = "0.10.8.20240310"
+description = "Typing stubs for toml"
+optional = false
+python-versions = ">=3.8"
+files = [
+ {file = "types-toml-0.10.8.20240310.tar.gz", hash = "sha256:3d41501302972436a6b8b239c850b26689657e25281b48ff0ec06345b8830331"},
+ {file = "types_toml-0.10.8.20240310-py3-none-any.whl", hash = "sha256:627b47775d25fa29977d9c70dc0cbab3f314f32c8d8d0c012f2ef5de7aaec05d"},
+]
+
+[[package]]
+name = "typing-extensions"
+version = "4.12.2"
+description = "Backported and Experimental Type Hints for Python 3.8+"
+optional = false
+python-versions = ">=3.8"
+files = [
+ {file = "typing_extensions-4.12.2-py3-none-any.whl", hash = "sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d"},
+ {file = "typing_extensions-4.12.2.tar.gz", hash = "sha256:1a7ead55c7e559dd4dee8856e3a88b41225abfe1ce8df57b7c13915fe121ffb8"},
+]
+
+[[package]]
+name = "unittest-xml-reporting"
+version = "3.2.0"
+description = "unittest-based test runner with Ant/JUnit like XML reporting."
+optional = false
+python-versions = ">=3.7"
+files = [
+ {file = "unittest-xml-reporting-3.2.0.tar.gz", hash = "sha256:edd8d3170b40c3a81b8cf910f46c6a304ae2847ec01036d02e9c0f9b85762d28"},
+ {file = "unittest_xml_reporting-3.2.0-py2.py3-none-any.whl", hash = "sha256:f3d7402e5b3ac72a5ee3149278339db1a8f932ee405f48bcb9c681372f2717d5"},
+]
+
+[package.dependencies]
+lxml = "*"
+
+[[package]]
+name = "virtualenv"
+version = "20.26.6"
+description = "Virtual Python Environment builder"
+optional = false
+python-versions = ">=3.7"
+files = [
+ {file = "virtualenv-20.26.6-py3-none-any.whl", hash = "sha256:7345cc5b25405607a624d8418154577459c3e0277f5466dd79c49d5e492995f2"},
+ {file = "virtualenv-20.26.6.tar.gz", hash = "sha256:280aede09a2a5c317e409a00102e7077c6432c5a38f0ef938e643805a7ad2c48"},
+]
+
+[package.dependencies]
+distlib = ">=0.3.7,<1"
+filelock = ">=3.12.2,<4"
+platformdirs = ">=3.9.1,<5"
+
+[package.extras]
+docs = ["furo (>=2023.7.26)", "proselint (>=0.13)", "sphinx (>=7.1.2,!=7.3)", "sphinx-argparse (>=0.4)", "sphinxcontrib-towncrier (>=0.2.1a0)", "towncrier (>=23.6)"]
+test = ["covdefaults (>=2.3)", "coverage (>=7.2.7)", "coverage-enable-subprocess (>=1)", "flaky (>=3.7)", "packaging (>=23.1)", "pytest (>=7.4)", "pytest-env (>=0.8.2)", "pytest-freezer (>=0.4.8)", "pytest-mock (>=3.11.1)", "pytest-randomly (>=3.12)", "pytest-timeout (>=2.1)", "setuptools (>=68)", "time-machine (>=2.10)"]
+
+[metadata]
+lock-version = "2.0"
+python-versions = "^3.9"
+content-hash = "e7e8308460d5d037688a5741b618f45f58a4861e61859555570f49a7a3182344"
diff --git a/pyproject.toml b/pyproject.toml
new file mode 100644
index 0000000..27258d7
--- /dev/null
+++ b/pyproject.toml
@@ -0,0 +1,78 @@
+[tool.poetry]
+name = "nightskycam-images"
+version = "0.1.0"
+description = ""
+authors = ["Vincent Berenz "]
+readme = "README.md"
+packages = [{include = "nightskycam_images"}]
+
+[tool.poetry.dependencies]
+python = "^3.9"
+# Normal dependencies:
+# Installable with both poetry and pip.
+#opencv-contrib-python = "^4.10.0.84"
+pillow = "^10.4.0"
+toml = "^0.10.2"
+tomli = "^2.0.1"
+tomli-w = "^1.0.0"
+opencv-python = "^4.10.0.84"
+
+[tool.poetry.group.dev]
+optional = true
+
+[tool.poetry.group.dev.dependencies]
+# Dependencies for development:
+# Only installable with poetry (NOT pip).
+black = "^24.8.0"
+codespell = "^2.3.0"
+flake8 = "^7.1.1"
+flake8-bugbear = "^24.4.26"
+isort = "^5.13.2"
+mypy = "^1.11.1"
+pre-commit = "^3.8.0"
+types-toml = "^0.10.8.20240310"
+
+[tool.poetry.group.test]
+optional = true
+
+[tool.poetry.group.test.dependencies]
+# Dependencies for testing:
+# Only installable with poetry (NOT pip).
+coverage = "^7.6.1"
+coverage2clover = "^4.0.0"
+faker = "^26.2.0"
+pytest = "^8.3.2"
+unittest-xml-reporting = "^3.2.0"
+
+[tool.poetry.scripts]
+nightskycam-thumbnails = 'nightskycam_images.main:thumbnails'
+
+[build-system]
+requires = ["poetry-core"]
+build-backend = "poetry.core.masonry.api"
+
+# Coverage.
+[tool.coverage.run]
+branch = true
+source = ["."]
+omit = ["tests/*"]
+
+[tool.coverage.report]
+show_missing = true
+skip_empty = true
+omit = [
+ # For avoiding "No source for code" warnings
+ # for files that should NOT even exist.
+ # Confusion seems to be caused by site-packages.
+ "config-3.py",
+ "config.py",
+]
+
+# isort.
+[tool.isort]
+# Do not distinguish import style (import/from) for sorting.
+force_sort_within_sections = true
+# Use same formatting as black.
+profile = "black"
+
+# flake8 settings are in .flake8 .
diff --git a/tests/__init__.py b/tests/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/tests/__pycache__/__init__.cpython-39.pyc b/tests/__pycache__/__init__.cpython-39.pyc
new file mode 100644
index 0000000..8c76b0c
Binary files /dev/null and b/tests/__pycache__/__init__.cpython-39.pyc differ
diff --git a/tests/__pycache__/conftest.cpython-39-pytest-8.3.2.pyc b/tests/__pycache__/conftest.cpython-39-pytest-8.3.2.pyc
new file mode 100644
index 0000000..a8b2935
Binary files /dev/null and b/tests/__pycache__/conftest.cpython-39-pytest-8.3.2.pyc differ
diff --git a/tests/__pycache__/conftest.cpython-39-pytest-8.3.3.pyc b/tests/__pycache__/conftest.cpython-39-pytest-8.3.3.pyc
new file mode 100644
index 0000000..a5e4b28
Binary files /dev/null and b/tests/__pycache__/conftest.cpython-39-pytest-8.3.3.pyc differ
diff --git a/tests/__pycache__/test_images.cpython-311-pytest-7.4.1.pyc b/tests/__pycache__/test_images.cpython-311-pytest-7.4.1.pyc
new file mode 100644
index 0000000..8339973
Binary files /dev/null and b/tests/__pycache__/test_images.cpython-311-pytest-7.4.1.pyc differ
diff --git a/tests/__pycache__/test_thumbnail.cpython-39-pytest-8.3.2.pyc b/tests/__pycache__/test_thumbnail.cpython-39-pytest-8.3.2.pyc
new file mode 100644
index 0000000..020ea64
Binary files /dev/null and b/tests/__pycache__/test_thumbnail.cpython-39-pytest-8.3.2.pyc differ
diff --git a/tests/__pycache__/test_thumbnail.cpython-39-pytest-8.3.3.pyc b/tests/__pycache__/test_thumbnail.cpython-39-pytest-8.3.3.pyc
new file mode 100644
index 0000000..b79647a
Binary files /dev/null and b/tests/__pycache__/test_thumbnail.cpython-39-pytest-8.3.3.pyc differ
diff --git a/tests/__pycache__/test_thumbnails.cpython-311-pytest-7.4.1.pyc b/tests/__pycache__/test_thumbnails.cpython-311-pytest-7.4.1.pyc
new file mode 100644
index 0000000..12d352d
Binary files /dev/null and b/tests/__pycache__/test_thumbnails.cpython-311-pytest-7.4.1.pyc differ
diff --git a/tests/__pycache__/test_video.cpython-39-pytest-8.3.2.pyc b/tests/__pycache__/test_video.cpython-39-pytest-8.3.2.pyc
new file mode 100644
index 0000000..7463233
Binary files /dev/null and b/tests/__pycache__/test_video.cpython-39-pytest-8.3.2.pyc differ
diff --git a/tests/__pycache__/test_video.cpython-39-pytest-8.3.3.pyc b/tests/__pycache__/test_video.cpython-39-pytest-8.3.3.pyc
new file mode 100644
index 0000000..c06e863
Binary files /dev/null and b/tests/__pycache__/test_video.cpython-39-pytest-8.3.3.pyc differ
diff --git a/tests/__pycache__/test_videos.cpython-311-pytest-7.4.1.pyc b/tests/__pycache__/test_videos.cpython-311-pytest-7.4.1.pyc
new file mode 100644
index 0000000..e860d5b
Binary files /dev/null and b/tests/__pycache__/test_videos.cpython-311-pytest-7.4.1.pyc differ
diff --git a/tests/__pycache__/test_walk.cpython-39-pytest-8.3.2.pyc b/tests/__pycache__/test_walk.cpython-39-pytest-8.3.2.pyc
new file mode 100644
index 0000000..cf61608
Binary files /dev/null and b/tests/__pycache__/test_walk.cpython-39-pytest-8.3.2.pyc differ
diff --git a/tests/__pycache__/test_walk.cpython-39-pytest-8.3.3.pyc b/tests/__pycache__/test_walk.cpython-39-pytest-8.3.3.pyc
new file mode 100644
index 0000000..f79592f
Binary files /dev/null and b/tests/__pycache__/test_walk.cpython-39-pytest-8.3.3.pyc differ
diff --git a/tests/__pycache__/test_weather.cpython-311-pytest-7.4.1.pyc b/tests/__pycache__/test_weather.cpython-311-pytest-7.4.1.pyc
new file mode 100644
index 0000000..4a8bc42
Binary files /dev/null and b/tests/__pycache__/test_weather.cpython-311-pytest-7.4.1.pyc differ
diff --git a/tests/__pycache__/test_weather.cpython-39-pytest-8.3.2.pyc b/tests/__pycache__/test_weather.cpython-39-pytest-8.3.2.pyc
new file mode 100644
index 0000000..bb05d57
Binary files /dev/null and b/tests/__pycache__/test_weather.cpython-39-pytest-8.3.2.pyc differ
diff --git a/tests/__pycache__/test_weather.cpython-39-pytest-8.3.3.pyc b/tests/__pycache__/test_weather.cpython-39-pytest-8.3.3.pyc
new file mode 100644
index 0000000..6fe9eef
Binary files /dev/null and b/tests/__pycache__/test_weather.cpython-39-pytest-8.3.3.pyc differ
diff --git a/tests/conftest.py b/tests/conftest.py
new file mode 100644
index 0000000..93d2071
--- /dev/null
+++ b/tests/conftest.py
@@ -0,0 +1,118 @@
+"""
+Fixtures shared by multiple test modules.
+"""
+
+import logging
+from pathlib import Path
+import tempfile
+import time
+from typing import Generator, List, Tuple
+
+import cv2
+from faker import Faker
+import numpy as np
+import pytest
+
+from nightskycam_images.constants import DATE_FORMAT
+
+# Number of image files for tests.
+IMAGE_BATCH_SIZE: int = 5
+
+
+FAKE = Faker()
+# Seed random generator.
+SEED = int(time.time() * 1000) # Current time in ms.
+FAKE.seed_instance(SEED)
+
+
+# Default parameter values for fixture.
+@pytest.fixture(
+ params=[
+ {"date_dir_num": 1, "image_file_format": "npy"},
+ {"date_dir_num": 1, "image_file_format": "jpg"},
+ ]
+)
+def setup_data(
+ request: pytest.FixtureRequest,
+) -> Generator[Tuple[Path, List[Path], List[str], str], None, None]:
+ """
+ Set up temporary directory with image files for tests.
+
+ Parameters
+ ----------
+ request
+ Use `request.param` to pass parameters to this fixture:
+ - date_dir_num:
+ Number of date directories that will contain image files.
+ - image_file_format:
+ Format of the image files that will be set up.
+
+ Yields
+ ------
+ Path
+ Path to temporary directory
+ that contains the date directories (containing the image files).
+ List[Path]
+ Image file paths.
+ List[str]
+ Dummy text for each image.
+ str
+ Image file format.
+ """
+ # Parameters for fixture.
+ date_dir_num = request.param["date_dir_num"]
+ image_file_format = request.param["image_file_format"]
+
+ # Initialise.
+ image_path_s = []
+ text_s = []
+
+ # Create a temporary directory.
+ with tempfile.TemporaryDirectory() as tmp_dir:
+
+ for _ in range(date_dir_num):
+ date_dir_name = FAKE.unique.date(pattern=DATE_FORMAT)
+
+ # Directory for HD images.
+ date_dir_path = Path(tmp_dir) / date_dir_name
+ date_dir_path.mkdir()
+
+ # Generate numpy images of random sizes.
+ for i in range(IMAGE_BATCH_SIZE):
+ image_array = np.random.randint(
+ 0,
+ 255,
+ (i + 1 + 480, i + 1 + 360, 3), # (height, width, RGB)
+ dtype=np.uint8,
+ )
+
+ # Pickled numpy array format.
+ if image_file_format == "npy":
+ # Pickle numpy array to file.
+ image_path = date_dir_path / f"image_{i}.npy"
+ np.save(image_path, image_array)
+
+ # jpg format.
+ elif image_file_format == "jpg":
+ # Convert numpy array to image.
+ image_path = date_dir_path / f"image_{i}.{image_file_format}"
+ cv2.imwrite(str(image_path), image_array)
+
+ else:
+ raise ValueError(
+ f"image_file_format was set to '{image_file_format}', "
+ f"but should be either 'npy' or 'jpg' instead."
+ )
+
+ # Update.
+ image_path_s.append(image_path)
+
+ # Generate dummy text for each image.
+ text_s.append(f"text_{i}")
+
+ yield Path(tmp_dir), image_path_s, text_s, image_file_format
+
+ # If there are failed tests.
+ if request.session.testsfailed:
+ # Log seed.
+ logging.warning("Seed for RNG: %s", SEED)
diff --git a/tests/test_thumbnail.py b/tests/test_thumbnail.py
new file mode 100644
index 0000000..f8770f4
--- /dev/null
+++ b/tests/test_thumbnail.py
@@ -0,0 +1,116 @@
+"""
+Tests for module: thumbnail.
+"""
+
+from pathlib import Path
+from typing import Generator, Iterable
+
+import cv2
+import pytest
+
+from nightskycam_images.constants import THUMBNAIL_DIR_NAME
+from nightskycam_images.thumbnail import (
+ _thumbnail_path,
+ create_all_thumbnails,
+ create_thumbnail,
+ create_thumbnails,
+)
+from tests.conftest import FAKE
+
+#
+# fixture setup_data: see conftest.py in same directory
+#
+
+
+def test_create_thumbnail(setup_data):
+ """
+ Test for function: thumbnail.create_thumbnail.
+ """
+
+ # Use fixture.
+ _, image_path_s, _, _ = setup_data
+
+ for image_path in image_path_s:
+
+ # Tested function.
+ thumbnail_path = create_thumbnail(image_path)
+
+ # Check: Path is file in thumbnails directory.
+ assert thumbnail_path.is_file()
+ assert thumbnail_path.parent.name == THUMBNAIL_DIR_NAME
+
+ # Check: File can be parsed as image.
+ thumbnail_array = cv2.imread(str(thumbnail_path))
+ assert thumbnail_array is not None
+
+
+def test_create_thumbnails(setup_data):
+ """
+ Test for function: thumbnail.create_thumbnails.
+ """
+
+ # Use fixture.
+ _, image_path_s, _, _ = setup_data
+
+ # Tested function.
+ thumbnail_path_s = create_thumbnails(image_path_s)
+
+ # Check:
+ # Number of thumbnail images matches number of input HD images.
+ assert len(image_path_s) == len(thumbnail_path_s)
+
+ for thumbnail_path in thumbnail_path_s:
+ # Check: Path is file in thumbnails directory.
+ assert thumbnail_path.is_file()
+ assert thumbnail_path.parent.name == THUMBNAIL_DIR_NAME
+
+ # Check: File can be parsed as image.
+ thumbnail_array = cv2.imread(str(thumbnail_path))
+ assert thumbnail_array is not None
+
+
+# Override parameter value for fixture.
+@pytest.mark.parametrize(
+ "setup_data",
+ [
+ {
+ "date_dir_num": FAKE.random_int(min=0, max=10),
+ "image_file_format": "jpg",
+ },
+ {
+ "date_dir_num": FAKE.random_int(min=0, max=10),
+ "image_file_format": "npy",
+ },
+ ],
+ indirect=True,
+)
+def test_create_all_thumbnails(setup_data):
+
+ # Use fixture.
+ tmp_dir_path, image_path_s, _, image_file_format = setup_data
+
+ # Argument for tested function.
+ def _walk_folders() -> Generator[Path, None, None]:
+ for path in tmp_dir_path.iterdir():
+ yield path
+
+ # Argument for tested function.
+ def _list_images(folder: Path) -> Iterable[Path]:
+ return list(folder.glob(f"*.{image_file_format}"))
+
+ # Tested function.
+ create_all_thumbnails(
+ _walk_folders,
+ _list_images,
+ )
+
+ for image_path in image_path_s:
+ thumbnail_path = _thumbnail_path(image_path)
+
+ # Check: Path is file in thumbnails directory.
+ assert thumbnail_path.is_file()
+ assert thumbnail_path.parent.name == THUMBNAIL_DIR_NAME
+
+ # Check: File can be parsed as image.
+ thumbnail_array = cv2.imread(str(thumbnail_path))
+ assert thumbnail_array is not None
diff --git a/tests/test_video.py b/tests/test_video.py
new file mode 100644
index 0000000..eb221fd
--- /dev/null
+++ b/tests/test_video.py
@@ -0,0 +1,120 @@
+"""
+Tests for module: video.
+"""
+
+from pathlib import Path
+import tempfile
+
+import numpy as np
+import pytest
+
+from nightskycam_images.convert_npy import npy_file_to_numpy
+from nightskycam_images.video import (
+ VideoFormat,
+ _count_frames,
+ _setup_image_array,
+ _write_to_image,
+ _write_video,
+ create_video,
+)
+
+
+# Override parameter value for fixture.
+@pytest.mark.parametrize(
+ "setup_data", [{"date_dir_num": 1, "image_file_format": "npy"}], indirect=True
+)
+def test_write_to_image(setup_data):
+ """
+ Test for function: videos._write_to_image.
+ """
+
+ # Use fixture.
+ _, image_path_s, text_s, _ = setup_data
+
+ for image_path, text in zip(image_path_s, text_s):
+
+ # Load pickled array from file.
+ image_array = npy_file_to_numpy(image_path)
+
+ # Before modification of image array by tested function:
+ # Conserve copy for comparison.
+ image_array_before = image_array.copy()
+
+ # Tested function: write text to image.
+ # -> Modifies image_array.
+ _write_to_image(image_array, text, VideoFormat().text_format)
+
+ # Check: image array changed.
+ assert not np.array_equal(image_array, image_array_before)
+
+
+def test_setup_image_array(setup_data):
+ """
+ Test for function: videos._setup_image_array.
+ """
+
+ # Use fixture.
+ _, image_path_s, text_s, _ = setup_data
+
+ # Get width and height of video format.
+ video_format = VideoFormat()
+ video_width, video_height = video_format.size
+
+ # Test by setting up video image for each image
+ for image_path, text in zip(image_path_s, text_s):
+ # Tested function.
+ image_array = _setup_image_array(image_path, text, video_format)
+
+ # Check: Image shape corresponds to video format size.
+ assert image_array.shape == (video_height, video_width, 3)
+
+
+def test_count_frames(setup_data):
+ """
+ Test for function: videos._count_frames.
+ """
+
+ # Use fixture.
+ _, image_path_s, text_s, _ = setup_data
+
+ # Create a temporary video file.
+ with tempfile.NamedTemporaryFile(suffix=".webm") as tmp_video:
+ # Needed for tested function:
+ # Set up video file.
+ video_path = Path(tmp_video.name)
+ _write_video(video_path, image_path_s, text_s)
+ # Tested function.
+ num_frames = _count_frames(video_path)
+ # Check: Number of video frames matches number of input images.
+ assert num_frames == len(image_path_s)
+
+
+def test_write_video(setup_data):
+ """
+ Test for function: videos._write_video.
+ """
+
+ # Use fixture.
+ _, image_path_s, text_s, _ = setup_data
+
+ # Create a temporary video file.
+ with tempfile.NamedTemporaryFile(suffix=".webm") as tmp_video:
+ video_path = Path(tmp_video.name)
+ _write_video(video_path, image_path_s, text_s)
+ # Check: Video path points to a file.
+ assert video_path.is_file()
+
+
+def test_create_video(setup_data):
+ """
+ Test for function: videos.create_video.
+ """
+
+ # Use fixture.
+ _, image_path_s, text_s, _ = setup_data
+
+ with tempfile.NamedTemporaryFile(suffix=".webm") as tmp_video:
+ video_path = Path(tmp_video.name)
+ create_video(video_path, image_path_s, text_s)
+ # Check: Video path points to a file.
+ assert video_path.is_file()
diff --git a/tests/test_walk.py b/tests/test_walk.py
new file mode 100644
index 0000000..69b1636
--- /dev/null
+++ b/tests/test_walk.py
@@ -0,0 +1,451 @@
+"""
+Tests for module: walk.
+"""
+
+import datetime as dt
+import logging
+from pathlib import Path
+import tempfile
+from typing import Dict, Generator, Set, Tuple
+
+import pytest
+
+from nightskycam_images import walk
+from nightskycam_images.constants import (
+ DATE_FORMAT,
+ DATE_FORMAT_FILE,
+ THUMBNAIL_DIR_NAME,
+ THUMBNAIL_FILE_FORMAT,
+ TIME_FORMAT,
+ TIME_FORMAT_FILE,
+ VIDEO_FILE_NAME,
+ ZIP_DIR_NAME,
+)
+from tests.conftest import FAKE, SEED
+
+# Number of different system names.
+SYSTEM_BATCH_SIZE: int = 2
+# Number of different dates for system.
+DATE_BATCH_SIZE: int = 3
+# Number of different times for date.
+TIME_BATCH_SIZE: int = 2
+# Date is at most ~2 months in the past,
+# so that it is likely that there are dates of the same year and month
+# for the set DATE_BATCH_SIZE.
+DATE_MAX_DAYS_AGO: int = 60 # Not counting today.
+
+
+@pytest.fixture
+def setup_tmp_media(
+ request: pytest.FixtureRequest,
+) -> Generator[Tuple[Path, Dict[str, Dict[str, Set[str]]]], None, None]:
+ """
+ Set up temporary media directory with content for tests.
+
+ Yields
+ ------
+ Path
+ Path to temporary media directory.
+ Dict
+ Information on content of media directory.
+ (Usable as ground-truth in tests.)
+ - key: str
+ system name
+ - value: Dict
+ - key: str
+ date
+ - value: Set[str]
+ set of time values
+ """
+ # Reset memory of already generated fake values
+ # (used for ensuring uniqueness).
+ # Necessary for using uniqueness in combination with
+ # pytest.mark.parametrize.
+ FAKE.unique.clear()
+
+ # Set up temporary media directory.
+ with tempfile.TemporaryDirectory() as tmp_media_dir_strpath:
+
+ # Cast string to Path object.
+ tmp_media_dir_path = Path(tmp_media_dir_strpath)
+
+ # Generate random names for content (directories and files)
+ # of media directory.
+ system_to_dict = {
+ # System name.
+ FAKE.unique.user_name(): {
+ # Date (string) between today and maximum days ago.
+ FAKE.unique.date_between(
+ dt.timedelta(days=-DATE_MAX_DAYS_AGO)
+ ).strftime(DATE_FORMAT_FILE): {
+ # Time (string).
+ FAKE.unique.time(pattern=TIME_FORMAT_FILE)
+ for _ in range(TIME_BATCH_SIZE)
+ }
+ for _ in range(DATE_BATCH_SIZE)
+ }
+ for _ in range(SYSTEM_BATCH_SIZE)
+ }
+
+ # Set up content of temporary media directory.
+ for system, date_to_time_s in system_to_dict.items():
+ for date_str, time_str_s in date_to_time_s.items():
+ # Set up directory structure.
+ #
+ # Directory for HD images.
+ date_dir_path = tmp_media_dir_path / system / date_str
+ date_dir_path.mkdir(parents=True)
+ # Directory for thumbnail images.
+ thumbnail_dir_path = date_dir_path / THUMBNAIL_DIR_NAME
+ thumbnail_dir_path.mkdir()
+
+ # Set up dummy files in directories.
+ #
+ # Thumbnail video.
+ (thumbnail_dir_path / VIDEO_FILE_NAME).touch()
+ #
+ for time_str in time_str_s:
+ # Shared stem of file names.
+ filename_stem = f"{system}_{date_str}_{time_str}"
+ # HD image file.
+ (date_dir_path / f"{filename_stem}.npy").touch()
+ # Thumbnail image file.
+ (
+ thumbnail_dir_path / f"{filename_stem}.{THUMBNAIL_FILE_FORMAT}"
+ ).touch()
+ # Meta data file.
+ (date_dir_path / f"{filename_stem}.toml").touch()
+
+ yield tmp_media_dir_path, system_to_dict
+
+ # If there are failed tests.
+ if request.session.testsfailed:
+ # Log seed.
+ logging.warning("Seed for RNG: %s", SEED)
+
+
+def test_walk_systems(setup_tmp_media):
+ """
+ Test for function: walk.walk_systems.
+ """
+
+ # Use fixture.
+ tmp_media_path, system_to_dict = setup_tmp_media
+
+ system_s = system_to_dict.keys()
+
+ # Tested function.
+ obs_system_path_s = list(walk.walk_systems(tmp_media_path))
+
+ # Check: Number of systems.
+ assert len(obs_system_path_s) == len(system_s)
+
+ for obs_system_path in obs_system_path_s:
+ # Check: System path is a directory inside of media directory.
+ assert obs_system_path.parent == tmp_media_path
+ assert obs_system_path.is_dir()
+ # Check: System name.
+ obs_system = str(obs_system_path.name)
+ assert obs_system in system_to_dict.keys()
+
+
+def test_get_system_path(setup_tmp_media):
+ """
+ Test for function: walk.get_system_path.
+ """
+
+ # Use fixture.
+ tmp_media_path, system_to_dict = setup_tmp_media
+
+ for system in system_to_dict.keys():
+ # Tested function.
+ obs_system_path = walk.get_system_path(tmp_media_path, system)
+ # Check: System path is a directory inside of media directory.
+ assert obs_system_path.parent == tmp_media_path
+ assert obs_system_path.is_dir()
+ # Check: System name.
+ assert obs_system_path.name == system
+
+
+@pytest.mark.parametrize(
+ "param_within_nb_days, exp_date_count",
+ [
+ (None, DATE_BATCH_SIZE),
+ (DATE_MAX_DAYS_AGO + 1, DATE_BATCH_SIZE), # +1: Count today as well.
+ (0, 0),
+ ],
+)
+def test_walk_dates(setup_tmp_media, param_within_nb_days, exp_date_count):
+ """
+ Test for function: walk.walk_dates.
+ """
+
+ # Use fixture.
+ tmp_media_path, system_to_dict = setup_tmp_media
+
+ for system, date_to_time_s in system_to_dict.items():
+
+ date_str_s = date_to_time_s.keys()
+
+ # Parameter for tested function.
+ system_path = walk.get_system_path(tmp_media_path, system)
+
+ # Tested function.
+ obs_date_and_path_s = list(
+ walk.walk_dates(system_path, within_nb_days=param_within_nb_days)
+ )
+
+ # Check: Number of date instances/paths.
+ obs_date_count = len(obs_date_and_path_s)
+ assert obs_date_count == exp_date_count
+
+ for obs_date, obs_path in obs_date_and_path_s:
+ # Check: Date.
+ obs_date_str = obs_date.strftime(DATE_FORMAT_FILE)
+ assert obs_date_str in date_str_s
+ # Check: Date path is a directory inside of system directory.
+ assert obs_path.parent == system_path
+ assert obs_path.is_dir()
+
+
+def test_walk_all(setup_tmp_media):
+ """
+ Test for function: walk.walk_all.
+ """
+
+ # Use fixture.
+ tmp_media_path, system_to_dict = setup_tmp_media
+
+ # Ground-truth.
+ system_s = system_to_dict.keys()
+ # Number of date directory paths.
+ path_count = sum(len(date_to_time) for date_to_time in system_to_dict.values())
+
+ # Tested function.
+ obs_path_s = list(walk.walk_all(tmp_media_path))
+
+ # Check: Number of date directory paths.
+ assert len(obs_path_s) == path_count
+
+ for obs_path in obs_path_s:
+ # Check: Date path is a directory inside of system directory.
+ assert obs_path.parent.name in system_s
+ assert obs_path.is_dir()
+
+
+def test_get_images_folder(setup_tmp_media):
+ """
+ Test for function: walk.get_images_folder.
+ """
+
+ # Use fixture.
+ tmp_media_path, system_to_dict = setup_tmp_media
+
+ for system, date_to_time_s in system_to_dict.items():
+ for date_str in date_to_time_s.keys():
+
+ # Tested function.
+ date = dt.datetime.strptime(date_str, DATE_FORMAT_FILE).date()
+ obs_date_path = walk.get_images_folder(tmp_media_path, system, date)
+
+ # Check: Date path is a directory inside of system directory.
+ assert obs_date_path.parent.name == system
+ assert obs_date_path.is_dir()
+ # Check: Name of date directory.
+ assert obs_date_path.name == date_str
+
+
+def test_get_ordered_dates(setup_tmp_media):
+ """
+ Test for function: walk.get_ordered_dates.
+ """
+
+ # Use fixture.
+ tmp_media_path, system_to_dict = setup_tmp_media
+
+ for system in system_to_dict.keys():
+
+ # Ground-truth:
+ #
+ # Total count of dates for system.
+ date_to_time_s = system_to_dict[system]
+ date_count = len(date_to_time_s.keys())
+ # Dates.
+ date_s = [
+ dt.datetime.strptime(date_str, DATE_FORMAT_FILE)
+ for date_str in date_to_time_s.keys()
+ ]
+ year_s = [date.year for date in date_s]
+
+ # Tested function.
+ obs_year_to_month_dict = walk.get_ordered_dates(tmp_media_path, system)
+
+ # Test result:
+ # Total count of dates for system.
+ obs_date_count = 0
+
+ for obs_year, obs_month_dict in obs_year_to_month_dict.items():
+
+ # Check: Year.
+ assert obs_year in year_s
+
+ # Ground-truth.
+ month_s = [date.month for date in date_s if date.year == obs_year]
+
+ for obs_month, obs_date_and_path_s in obs_month_dict.items():
+
+ # Check: Month.
+ assert obs_month in month_s
+
+ obs_date_count += len(obs_date_and_path_s)
+
+ obs_date_s = [obs_date for obs_date, _ in obs_date_and_path_s]
+
+ # Compare adjacent date values in list.
+ for obs_date_current, obs_date_next in zip(
+ obs_date_s[:-1], obs_date_s[1:]
+ ):
+ # Check: Dates are in non-descending order.
+ assert obs_date_current < obs_date_next
+
+ for _, obs_date_path in obs_date_and_path_s:
+ # Check:
+ # Date path is a directory inside of system directory.
+ assert obs_date_path.parent.name == system
+ assert obs_date_path.is_dir()
+
+ # Check: Number of dates.
+ assert obs_date_count == date_count
+
+
+def test_parse_image_path(setup_tmp_media):
+ """
+ Test for function: walk.parse_image_path.
+ """
+
+ # Use fixture.
+ tmp_media_path, system_to_dict = setup_tmp_media
+
+ for system, date_to_time_s in system_to_dict.items():
+ for date_str, time_str_s in date_to_time_s.items():
+ for time_str in time_str_s:
+ image_file_path = Path(
+ f"{system}/{date_str}/{system}_{date_str}_{time_str}.jpeg"
+ )
+ datetime_ = dt.datetime.strptime(
+ f"{date_str}_{time_str}",
+ f"{DATE_FORMAT_FILE}_{TIME_FORMAT_FILE}",
+ )
+
+ # Tested function.
+ obs_system, obs_datetime = walk.parse_image_path(image_file_path)
+
+ # Check: System name.
+ assert obs_system == system
+ # Check: Datetime instance.
+ assert obs_datetime == datetime_
+
+
+def test_get_images(setup_tmp_media):
+ """
+ Test for function: walk.get_images.
+ """
+
+ # Use fixture.
+ tmp_media_path, system_to_dict = setup_tmp_media
+
+ for system, date_to_time_s in system_to_dict.items():
+ for date_str, time_str_s in date_to_time_s.items():
+ date_ = dt.datetime.strptime(date_str, DATE_FORMAT_FILE).date()
+ date_dir_path = walk.get_images_folder(tmp_media_path, system, date_)
+
+ # Tested function.
+ obs_image_s = walk.get_images(date_dir_path)
+
+ # Check: Number of image instances.
+ assert len(obs_image_s) == len(time_str_s)
+
+ for obs_image in obs_image_s:
+
+ # Check: Integrity of filename.
+ assert system in obs_image.filename_stem
+ assert date_str in obs_image.filename_stem
+
+ # Check: Integrity of other attributes.
+ assert obs_image.date_and_time.date() == date_
+ assert obs_image.system == system
+ assert obs_image.dir_path.is_dir()
+ assert obs_image.hd.is_file()
+ assert obs_image.thumbnail.is_file()
+ assert obs_image.meta_path.is_file()
+ # Check: Day is either date or the date before.
+ date_before = date_ - dt.timedelta(days=1)
+ assert obs_image.nightstart_date in [date_before, date_]
+
+
+def test_get_monthly_nb_images(setup_tmp_media):
+ """
+ Test for function: walk.get_monthly_nb_images.
+ """
+
+ # Use fixture.
+ tmp_media_path, system_to_dict = setup_tmp_media
+
+ for system, date_to_time_s in system_to_dict.items():
+ for date_str, time_str_s in date_to_time_s.items():
+
+ # Parse date.
+ year, month, _ = date_str.split("_")
+ year = int(year)
+ month = int(month)
+
+ # Tested function.
+ obs_result = walk.get_monthly_nb_images(tmp_media_path, system, year, month)
+
+ # TODO:
+ # Add dummy file "weathers.toml" to date directory in fixture
+ # setup,
+ # so that the weather data can be parsed in the test
+ # (and does not simply return None for failed parsing as in the
+ # current fixture setup).
+ for (
+ obs_date,
+ obs_date_path,
+ obs_nb_images,
+ _,
+ ) in obs_result: # `_` is None for failed parsing of weather file.
+
+ # Check: Date.
+ obs_date_str = obs_date.strftime(DATE_FORMAT_FILE)
+ assert obs_date_str in date_to_time_s.keys()
+
+ # Check: Path of date directory.
+ assert obs_date_path.is_dir()
+ # Check: Number of image instances for date.
+ assert obs_nb_images == len(time_str_s)
+
+
+def test_images_zip_file(setup_tmp_media):
+ """
+ Test for function: walk.images_zip_file.
+ """
+
+ # Use fixture.
+ tmp_media_path, system_to_dict = setup_tmp_media
+
+ # Set up zip result directory.
+ zip_dir_path = tmp_media_path / ZIP_DIR_NAME
+ zip_dir_path.mkdir()
+
+ for system, date_to_time_s in system_to_dict.items():
+ for date_str in date_to_time_s.keys():
+
+ date_ = dt.datetime.strptime(date_str, DATE_FORMAT_FILE).date()
+
+ # Tested function.
+ zip_file_path = walk.images_zip_file(
+ tmp_media_path, system, date_, zip_dir_path
+ )
+
+ # Check: Zip result is a file.
+ assert zip_file_path.is_file()
diff --git a/tests/test_weather.py b/tests/test_weather.py
new file mode 100644
index 0000000..d4ca6ea
--- /dev/null
+++ b/tests/test_weather.py
@@ -0,0 +1,190 @@
+"""
+Tests for module: weather.
+"""
+
+import logging
+from pathlib import Path
+import tempfile
+from typing import Dict, Generator, Set, Tuple, cast
+
+import pytest
+import toml
+import tomli_w
+
+from nightskycam_images.constants import DATE_FORMAT, WEATHER_SUMMARY_FILE_NAME
+from nightskycam_images.weather import create_weather_summaries
+from tests.conftest import FAKE, SEED
+
+VALID_WEATHER_S: Set[str] = {
+ "cloudy",
+ "sunny", # Complete match.
+ "storm", # Partial match for "thunderstorm".
+}
+INVALID_WEATHER_S: Set[str] = {"does_not_exist_1", "does_not_exist_2"}
+
+# Number of date directories.
+DATE_BATCH_SIZE: int = 2
+# Count range for pairs of image and weather files.
+COUNT_MIN: int = 1
+COUNT_MAX: int = 3
+
+
+@pytest.fixture
+def setup_data(
+ request: pytest.FixtureRequest,
+) -> Generator[Tuple[Path, Dict[str, Dict[str, int]]], None, None]:
+ """
+ Set up temporary directory with pairs of image and weather files
+ for tests.
+
+ Yields
+ ------
+ Path
+ Path to temporary directory
+ that contains the date directories
+ (containing the pairs of image and weather files).
+ Dict
+ Information on content of temporary directory.
+ (Usable as ground-truth in tests.)
+ - key:
+ Name of date directory.
+ - value:
+ - key:
+ Weather type.
+ - value:
+ Count.
+ """
+ # Create a temporary directory.
+ with tempfile.TemporaryDirectory() as tmp_dir:
+
+ # Generate random content.
+ date_dir_to_weather_dict = {
+ # Name of date directory.
+ FAKE.unique.date(pattern=DATE_FORMAT): {
+ # Weather type: count.
+ weather: FAKE.random_int(min=COUNT_MIN, max=COUNT_MAX)
+ for weather in cast(
+ str,
+ FAKE.random_sample( # Cast for mypy.
+ # Use union of both sets.
+ list(VALID_WEATHER_S | INVALID_WEATHER_S)
+ ),
+ )
+ }
+ for _ in range(DATE_BATCH_SIZE)
+ }
+
+ for date_dir_name, weather_to_count in date_dir_to_weather_dict.items():
+
+ # Directory for HD images.
+ date_dir_path = Path(tmp_dir) / date_dir_name
+ date_dir_path.mkdir()
+
+ for weather, count in weather_to_count.items():
+
+ for _ in range(count):
+ # Dummy unique stem for file name.
+ file_stem = FAKE.unique.user_name()
+
+ image_file_path = Path(tmp_dir) / date_dir_name / f"{file_stem}.jpg"
+ weather_file_path = (
+ Path(tmp_dir) / date_dir_name / f"{file_stem}.toml"
+ )
+
+ # Create dummy (empty) image file.
+ image_file_path.touch()
+ # Create weather file (toml format).
+ with open(weather_file_path, "wb") as f:
+ tomli_w.dump({"weather": weather}, f)
+
+ yield Path(tmp_dir), date_dir_to_weather_dict
+
+ # If there are failed tests.
+ if request.session.testsfailed:
+ # Log seed.
+ logging.warning("Seed for RNG: %s", SEED)
+
+
+def _check_weather_summary_file(
+ tmp_dir_path: Path,
+ summary_file_name: str,
+ date_dir_to_weather_dict: Dict[str, Dict[str, int]],
+) -> None:
+ """
+ Check integrity of weather summary file.
+
+ Parameters
+ ----------
+ tmp_dir_path
+ Path to temporary directory
+ containing the date directories
+ that contain the pairs of image and weather files.
+ summary_file_name
+ Name of weather summary file.
+ date_dir_to_weather_dict
+ Information on content of temporary directory.
+ (Used as ground-truth.)
+ """
+ # For each date directory.
+ for date_dir_name in date_dir_to_weather_dict.keys():
+ date_dir_path = tmp_dir_path / date_dir_name
+
+ # Check: Integrity of weather summary file.
+ summary_file_path = date_dir_path / summary_file_name
+
+ # Ground-truth.
+ weather_to_count = date_dir_to_weather_dict[date_dir_name]
+
+ # Check: Weather summary is a file.
+ assert summary_file_path.is_file()
+
+ # Parse weather summary file.
+ parsed_summary = toml.load(summary_file_path)
+ obs_weather_to_count = parsed_summary["weathers"]
+ obs_skipped = parsed_summary["skipped"]
+
+ # For each observed weather type.
+ for obs_weather, obs_count in obs_weather_to_count.items():
+ # Check: Mapping of weather type to count.
+ assert obs_weather in weather_to_count.keys()
+ assert obs_count == weather_to_count[obs_weather]
+ # Check:
+ # Number of input weather files that are in toml format
+ # but have been skipped because of
+ # NOT containing weather information.
+ assert (
+ obs_skipped == 0
+ ) # TODO: Expand tests so that number of skipped is not always zero.
+
+
+def test_create_weather_summaries(setup_data):
+ """
+ Test for function: weather.create_weather_summaries.
+ """
+ # Use fixture.
+ tmp_dir_path, date_dir_to_weather_dict = setup_data
+
+ # Argument for tested function.
+ def _walk_folders() -> Generator[Path, None, None]:
+ for date_dir_path in tmp_dir_path.iterdir():
+ yield date_dir_path
+
+ summary_file_name = WEATHER_SUMMARY_FILE_NAME
+
+ # Tested function:
+ # WITHOUT existing weather summary file.
+ create_weather_summaries(_walk_folders, summary_file_name=summary_file_name)
+
+ # Check: Integrity of weather summary file in all date directories.
+ _check_weather_summary_file(
+ tmp_dir_path, summary_file_name, date_dir_to_weather_dict
+ )
+
+ # Tested function:
+ # with existing weather summary file (from previous run).
+ create_weather_summaries(_walk_folders, summary_file_name=summary_file_name)
+
+ # Check: Integrity of weather summary file in all date directories.
+ _check_weather_summary_file(
+ tmp_dir_path, summary_file_name, date_dir_to_weather_dict
+ )