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 + )