Compare commits

..

12 commits
v0.1.1 ... main

Author SHA1 Message Date
9ae778bb79 bump version to 0.2.0 and update deps
Some checks failed
Rust / build (push) Has been cancelled
2025-05-19 10:22:24 -07:00
5ff3338b30 Fix dep spec, no need for caret
Some checks are pending
Rust / build (push) Waiting to run
2025-05-18 08:52:26 -07:00
b1d66d7e9f bump version
Some checks are pending
Rust / build (push) Waiting to run
2025-05-18 08:47:59 -07:00
aba9fa4025
Reorganize command (#4)
Some checks are pending
Rust / build (push) Waiting to run
Adds a command to reorganize a folder of photos, renaming them so that
they contain date and time so that they're sorted by that.

This also renames files associated with the photos, like the
descriptions, like IMG_1234.jpg with IMG_1234.md
2025-05-18 08:46:41 -07:00
37581ee6a0 fix .gitignore
Some checks failed
Rust / build (push) Has been cancelled
2025-05-11 14:44:51 -07:00
aa57c0d092 tweaks
Some checks failed
Rust / build (push) Has been cancelled
2025-05-10 08:49:11 -07:00
4ebaee95cc reorganize 2025-05-10 08:49:03 -07:00
9945b9eb7f
Rewrite in Rust (#3)
Some checks are pending
Rust / build (push) Waiting to run
Just what the world needs, another silly Rust re-write! But it was a good exercise in learning.

There's a lot of messy things, which is why this is 0.2.0-pre.1. Going to make some cleaning passes after landing this.
2025-05-08 12:27:49 -07:00
94a5e30a8f reconcile gh + fj
All checks were successful
Run tests / build (3.12) (push) Successful in 49s
Run tests / build (3.13) (push) Successful in 49s
2025-04-26 17:57:59 -07:00
24aeba0221 fmt
All checks were successful
Run tests / build (3.12) (push) Successful in 35s
Run tests / build (3.13) (push) Successful in 48s
2025-04-26 17:55:43 -07:00
a8eb1df562 Set up forgejo actions (#1)
Some checks failed
Run tests / build (3.12) (push) Has been cancelled
Run tests / build (3.13) (push) Has been cancelled
Reviewed-on: #1
Co-authored-by: Nick Pegg <nick@nickpegg.com>
Co-committed-by: Nick Pegg <nick@nickpegg.com>
2025-04-27 00:55:11 +00:00
d297110d88 Add installation instructions to README 2024-08-11 17:32:02 -07:00
44 changed files with 3325 additions and 1304 deletions

37
.github/workflows/rust.yml vendored Normal file
View file

@ -0,0 +1,37 @@
name: Rust
on:
push:
branches: [ "main" ]
pull_request:
branches: [ "main" ]
env:
CARGO_TERM_COLOR: always
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Cache
uses: actions/cache@v4
with:
path: |
~/.cargo/registry
~/.cargo/git
target
key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }}
- name: Rust version
run: rustup --version
- name: Check
run: cargo check
- name: Clippy
run: cargo clippy
- name: Build
run: cargo build
- name: Run tests
run: cargo test

5
.gitignore vendored
View file

@ -3,7 +3,10 @@ __pycache__
.testmondata
dist
# Rust stuff
/target
# Project specific files
test_album
/test_album*
DESIGN.md
TODO.md

1891
Cargo.lock generated Normal file

File diff suppressed because it is too large Load diff

28
Cargo.toml Normal file
View file

@ -0,0 +1,28 @@
[package]
name = "photojawn"
version = "0.2.0"
description = "A static site generator for photo albums"
authors = ["Nick Pegg <nick@nickpegg.com>"]
license = "MIT"
repository = "https://github.com/nickpegg/photojawn"
edition = "2024"
[dependencies]
anyhow = "1.0"
clap = { version = "4.5", features = ["derive"] }
env_logger = "0.11.8"
fs_extra = "1.3"
image = "0.25.6"
indicatif = "0.17.11"
kamadak-exif = "0.6.1"
log = "0.4.27"
pulldown-cmark = "0.13.0"
rayon = "1.10"
serde = { version = "1.0", features = ["derive"] }
serde_yml = "0.0.12"
tera = { version = "1.20", default-features = false }
thiserror = "2.0"
time = { version = "0.3.41", features = ["formatting", "macros", "parsing"] }
[dev-dependencies]
mktemp = "0.5.1"

View file

@ -1,42 +0,0 @@
# Build stage
FROM python:3.12-slim AS build
WORKDIR /app
RUN pip install --upgrade pip &&\
pip install --no-cache-dir poetry &&\
poetry config virtualenvs.create false &&\
apt update && apt install -y build-essential
COPY pyproject.toml poetry.lock ./
RUN poetry install --without=dev
# Clean up apt stuff
RUN apt remove -y build-essential &&\
apt autoremove -y &&\
rm -rf /var/lib/apt/lists/*
COPY . .
# Run stage
FROM python:3.12-slim
ENV PYTHONUNBUFFERED 1
ENV PYTHONDONTWRITEBYTECODE 1
# # Default port, overridable by setting an outside env var, which Heroku does
# ENV PORT 8000
# ENV NUM_WORKERS 8
# EXPOSE ${PORT}
WORKDIR /app
# Copy all the built libs from the build container
COPY --from=build / /
ARG GIT_COMMIT=unknown
LABEL git_commit=$GIT_COMMIT
ENV GIT_COMMIT $GIT_COMMIT
CMD TODO

View file

@ -2,51 +2,46 @@
all: fmt lint test
# What to have CI systems run
ci: init lint test
ci: lint test
# Final pre-flight checks then deploy everywhere!
shipit: all build staging prod
# TODO
# shipit: all build staging prod
version := $(shell yq -p toml .tool.poetry.version < pyproject.toml)
scie_platforms := linux-aarch64 linux-x86_64 macos-aarch64 macos-x86_64
# version := $(shell yq -p toml .tool.poetry.version < pyproject.toml)
init:
poetry install
# init:
# poetry install
# Everything to get the dev env set up
dev: init
# dev: init
fmt:
poetry run ruff check --select I --fix # import sorting
poetry run ruff format
cargo fmt
lint:
poetry run ruff check --fix
cargo clippy
test:
poetry run mypy .
poetry run pytest
RUST_BACKTRACE=1 cargo test
# Faster tests, only running what's changed
test-fast:
poetry run mypy .
poetry run pytest --testmon
test-watch:
find . -name '*py' -or -name '*html' -or -name poetry.lock | entr -r -c make test-fast
find . -name '*rs' -or -name '*html' -or -name Cargo.lock | entr -r -c make test
clean:
rm -rv dist || true
cargo clean
docker:
podman build -t nickpegg/photojawn . --build-arg GIT_COMMIT=$(shell git rev-parse --short HEAD)
# TODO?
# docker:
# podman build -t nickpegg/photojawn . --build-arg GIT_COMMIT=$(shell git rev-parse --short HEAD)
dist:
poetry build
poetry run pex --project . -o dist/photojawn -c photojawn --scie eager $(foreach plat,$(scie_platforms), --scie-platform $(plat))
cargo build --release
release: dist
git push --tags
gh release create --verify-tag v$(version)
gh release upload v$(version) dist/photojawn-$(version)-*whl $(foreach plat,$(scie_platforms),dist/photojawn-$(plat))
# TODO
# release: dist
# git push --tags
# gh release create --verify-tag v$(version)
# gh release upload v$(version) dist/photojawn-$(version)-*whl $(foreach plat,$(scie_platforms),dist/photojawn-$(plat))

View file

@ -1,16 +1,18 @@
# photojawn
This is a super-simple photo album static site generator. You feed it a
directory of photos (which can contain directories of photos, etc. etc.) and
it'll generate a basic HTML photo album for you. You can then host the
directory with a webserver of your choice or upload it to an S3 bucket.
This is a super-simple photo album static site generator. You feed it a directory of photos (which
can contain directories of photos, etc. etc.) and it'll generate a basic HTML photo album for you.
You can then host the directory with a webserver of your choice or upload it to an S3 bucket.
It's everything I need and nothing I don't.
## Getting Started
TODO: Installation instructions lol
### Installation
1. Head on over to the [releases](https://github.com/nickpegg/photojawn/releases) page
2. Download the binary for your OS/arch
### Initialization
@ -19,14 +21,13 @@ Then inside your photo directory, run:
photojawn init
```
This will create a config file, some [jinja2](https://jinja.palletsprojects.com/en/latest/templates/)
HTML templates, and a CSS file. Edit them to your heart's content to make your
photo album website purdy.
This will create a config file, some [Tera](https://keats.github.io/tera/docs/#templates) (similar
to Jinja) HTML templates, and a CSS file. Edit them to your heart's content to make your photo
album website purdy.
### Generating the site
To generate the HTML files and various image sizes, inside your photo
directory, run:
To generate the HTML files and various image sizes, inside your photo directory, run:
```
photojawn generate
```
@ -34,17 +35,18 @@ photojawn generate
## Special features
- HTML templates are written using [jinja2](https://jinja.palletsprojects.com/en/latest/templates/)
- HTML templates are written using [Tera](https://keats.github.io/tera/docs/#templates), which is
very similar to [Jinja](https://jinja.palletsprojects.com/en/stable/templates/)
- If you have a `description.txt` or `description.md` file in a directory with
images, its contents will be used as the album description. `.md` files will
be rendered as Markdown.
images, its contents will be used as the album description. `.md` files will be rendered as
Markdown.
- If an image file (e.g. `IMG_1234.jpg`) has a corresponding `.txt` or `.md`
file (e.g. `IMG_1234.md`) then it'll be used as the image's caption. `.md`
files will be rendered as Markdown.
file (e.g. `IMG_1234.md`) then it'll be used as the image's caption. `.md` files will be
rendered as Markdown.
- If you have an image in a directory called `cover.jpg` (or a symlink
to another image named that), then it'll be used as the cover image for the
album. If one doesn't exist, the first image in the directory will be used as
the cover image.
to another image named that), then it'll be used as the cover image for the album. If one
doesn't exist, the first image in the directory will be used as the cover image. If a directory
has no images itself, it'll use the first sub-directory's cover as its cover image
## y tho

View file

View file

View file

@ -1,175 +0,0 @@
import logging
import sys
from argparse import ArgumentParser, Namespace
from pathlib import Path
from rich.logging import RichHandler
from photojawn.config import DEFAULT_CONFIG_PATH, Config
from photojawn.generate import generate
logger = logging.getLogger("photojawn.cli")
def main() -> None:
args = parse_args()
setup_logging(args.logging)
# Load config from file if it exists
if hasattr(args, "album_path"):
conf_path = Path(args.album_path) / Path(args.config)
if conf_path.exists():
logger.debug(f"Reading config from {conf_path}")
config = Config.from_yaml(conf_path.read_bytes())
elif args.action != "init":
logger.error(
f"No config file found at {conf_path}. If this is a new photo directory, "
"please run `photojawn init` in there first."
)
return
# Call the subcommand function
match args.action:
case "init":
cmd_init(args)
case "generate":
if args.quick:
config.quick = args.quick
cmd_generate(args, config)
case "clean":
cmd_clean(args, config)
def parse_args() -> Namespace:
parser = ArgumentParser()
parser.add_argument(
"--config",
"-c",
default=DEFAULT_CONFIG_PATH,
help="Path to photojawn.config.json for the album",
)
parser.add_argument(
"--logging",
default="warning",
choices=[level.lower() for level in logging.getLevelNamesMapping().keys()],
help="Log level",
)
subcommands = parser.add_subparsers(title="subcommands")
init_cmd = subcommands.add_parser(
"init",
help="Initialize an photo directory",
)
init_cmd.set_defaults(action="init")
init_cmd.add_argument(
"album_path",
nargs="?",
default=".",
help="Path to the main photos directory",
)
# Generate subcommand
generate_cmd = subcommands.add_parser(
"generate",
help="Generate the HTML photo album",
)
generate_cmd.set_defaults(action="generate")
generate_cmd.add_argument(
"--quick",
action="store_true",
help="Quick mode - don't regenerate thumbnails",
)
generate_cmd.add_argument(
"album_path",
nargs="?",
default=".",
help="Path to the main photos directory",
)
# Clean subcommand
clean_cmd = subcommands.add_parser(
"clean",
help="Remove all generated content from the photo album directory",
)
clean_cmd.set_defaults(action="clean")
clean_cmd.add_argument(
"album_path",
nargs="?",
default=".",
help="Path to the main photos directory",
)
args = parser.parse_args()
if not hasattr(args, "action"):
parser.print_help()
sys.exit(0)
return args
########################################
# Command functions
def cmd_init(args: Namespace) -> None:
"""
Generate a basic config and template files
"""
album_path = Path(args.album_path)
config_path = album_path / args.config
if config_path.exists():
logger.warning(
f"Looks like {album_path} is already set up. If you want to start over and "
f"overwrite any of your customizations, remove {config_path}"
)
return
skel_dir = Path(__file__).parent / "skel"
logger.debug(f"Skeleton dir: {skel_dir}")
skel_files = []
for parent_path, dirnames, filenames in skel_dir.walk():
for filename in filenames:
skel_file_path = parent_path / filename
rel_path = skel_file_path.relative_to(skel_dir)
album_file_path = album_path / rel_path
skel_files.append(album_file_path)
album_file_path.parent.mkdir(exist_ok=True)
album_file_path.write_bytes(skel_file_path.read_bytes())
logger.debug(f"Created skeleton file {album_file_path}")
print("Some basic files have been created for your album. Edit them as you need:")
for p in skel_files:
print(f" - {p}")
def cmd_generate(args: Namespace, config: Config) -> None:
logger.debug(f"Generating in {args.album_path}")
generate(config, Path(args.album_path))
def cmd_clean(args: Namespace, config: Config) -> None:
"""
Clean the photo album by all files that photojawn generated
"""
pass
########################################
# CLI Util functions
def setup_logging(level_str: str) -> None:
levels = logging.getLevelNamesMapping()
level = levels[level_str.upper()]
logging.basicConfig(
level=level,
format="[%(name)s] %(message)s",
handlers=[RichHandler(rich_tracebacks=True)],
)
# Override PIL logging because debug is really noisy
if level <= logging.DEBUG:
logging.getLogger("PIL").setLevel(logging.INFO)
if __name__ == "__main__":
main()

View file

@ -1,41 +0,0 @@
import logging
from dataclasses import dataclass
import yaml
DEFAULT_CONFIG_PATH = "photojawn.conf.yml"
logger = logging.getLogger(__name__)
@dataclass
class Config:
# Size of thumbnails when looking at a folder page
thumbnail_size: tuple[int, int] = (256, 256)
# Size of the image when looking at the standalone image page
view_size: tuple[int, int] = (1920, 1080)
# Directory inside the photo directory to output the site to
output_dir: str = "site"
# Quick mode:
# - Don't regenerate thumbnails if they already exist
quick: bool = False
@classmethod
def from_yaml(cls, contents: bytes) -> "Config":
conf = cls()
data = yaml.safe_load(contents)
if data is None:
return conf
for key, val in data.items():
match key:
case "output_dir":
conf.output_dir = val
case "thumnail_size":
conf.thumbnail_size = tuple(val)
case "view_size":
conf.view_size = tuple(val)
return conf

View file

@ -1,282 +0,0 @@
import logging
import shutil
import os
from dataclasses import dataclass
from pathlib import Path
from typing import Iterator, Optional
from jinja2 import Environment, FileSystemLoader, select_autoescape
from markdown import markdown
from PIL import Image, UnidentifiedImageError
from rich.progress import Progress, track
from photojawn.config import Config
logger = logging.getLogger(__name__)
@dataclass
class ImageDirectory:
path: Path
children: list["ImageDirectory"]
images: list["ImagePath"]
is_root: bool = False
description: str = ""
cover_path: Optional["ImagePath"] = None
def walk(self) -> Iterator["ImageDirectory"]:
yield self
for child in self.children:
yield from child.walk()
def image_paths(self) -> list["ImagePath"]:
"""
Iterate through all images in this dir and children
"""
images = []
for image_dir in self.walk():
images += image_dir.images
return images
def cover_image_paths(self) -> list["ImagePath"]:
images = []
for image_dir in self.walk():
if image_dir.cover_path is not None:
images.append(image_dir.cover_path)
return images
@dataclass
class ImagePath:
path: Path
description: str = ""
def thumbnail_filename(self) -> str:
return self.path.stem + ".thumb" + self.path.suffix
def thumbnail_path(self) -> Path:
return self.path.parent / "slides" / self.thumbnail_filename()
def display_filename(self) -> str:
return self.path.stem + ".screen" + self.path.suffix
def display_path(self) -> Path:
return self.path.parent / "slides" / self.display_filename()
def html_filename(self) -> str:
return self.path.with_suffix(".html").name
def html_path(self) -> Path:
return self.path.parent / "slides" / self.html_filename()
def generate(config: Config, album_path: Path) -> None:
"""
Main generation function
"""
# Change the working directory to the album_path so that all paths are relative to
# it when we find images. We need to do this because all the paths in HTML need to
# be relative to it and we don't want to have to do a bunch of path gymnastics to
# re-relative all those paths.
orig_wd = Path.cwd()
os.chdir(album_path)
root_dir = find_images(config, Path("."))
generate_images(config, root_dir)
generate_html(config, root_dir)
shutil.copytree("static", Path(config.output_dir) / "static")
os.chdir(orig_wd)
def find_images(config: Config, root_path: Path) -> ImageDirectory:
"""
Build up an ImageDirectory to track all of the directories and their images.
A directory with no images nor childern with images will not be tracked, since we
don't want to render an album page for those.
"""
# image_dirs keeps track of all directories we find with images in them, so we can
# attach them as children to parent directories
image_dirs: dict[Path, ImageDirectory] = {
root_path: ImageDirectory(path=root_path, children=[], images=[], is_root=True)
}
for dirpath, dirnames, filenames in root_path.walk(top_down=False):
if dirpath.name in {"slides", "_templates", "static", config.output_dir}:
continue
image_dir = image_dirs.get(
dirpath,
ImageDirectory(
path=dirpath,
children=[],
images=[],
),
)
for dirname in sorted(dirnames):
child_path = dirpath / dirname
if child_path in image_dirs:
image_dir.children.append(image_dirs[child_path])
for filename in sorted(filenames):
file_path = dirpath / filename
if filename == "description.txt":
image_dir.description = file_path.read_text()
elif filename == "description.md":
image_dir.description = markdown(file_path.read_text())
elif is_image(file_path):
ip = ImagePath(file_path)
# Set a cover image for the album. Use "cover.jpg" if one exists,
# otherwise use the first image we find.
if file_path.stem == "cover":
image_dir.cover_path = ip
# Don't add the cover image to the list of images, we want to handle
# that separately
continue
# If there's an associated .txt or .md file, read it in as the image's
# description
if file_path.with_suffix(".md").exists():
ip.description = markdown(file_path.with_suffix(".md").read_text())
elif file_path.with_suffix(".txt").exists():
ip.description = file_path.with_suffix(".txt").read_text()
image_dir.images.append(ip)
if image_dir.cover_path is None:
if len(image_dir.images) > 0:
image_dir.cover_path = image_dir.images[0]
elif len(image_dir.children) > 0:
cover = image_dir.children[0].cover_path
logger.debug(f"nested cover path for {image_dir.path.name}: {cover}")
image_dir.cover_path = cover
image_dirs[image_dir.path] = image_dir
return image_dirs[root_path]
def is_image(path: Path) -> bool:
"""
Returns True if PIL thinks the file is an image
"""
try:
Image.open(path)
return True
except UnidentifiedImageError:
return False
def generate_images(config: Config, root_dir: ImageDirectory) -> None:
"""
Find all of the images and generate various image sizes
"""
# Include cover images here because we want thumbnails for all of them
all_images = root_dir.image_paths() + root_dir.cover_image_paths()
for image_path in track(all_images, description="Making smaller images..."):
orig_img = Image.open(image_path.path)
slides_path = config.output_dir / image_path.path.parent / "slides"
slides_path.mkdir(exist_ok=True, parents=True)
# Copy the original image in
orig_new_path = config.output_dir / image_path.path
if not orig_new_path.exists() or not config.quick:
logger.info(f"Copying original image to {orig_new_path}")
orig_img.save(orig_new_path)
thumb_path = config.output_dir / image_path.thumbnail_path()
if not thumb_path.exists() or not config.quick:
thumb_img = orig_img.copy()
thumb_img.thumbnail(config.thumbnail_size)
thumb_img.save(thumb_path)
logger.info(
f'Generated thumbnail size "{image_path.path}" -> "{thumb_path}"'
)
screen_path = config.output_dir / image_path.display_path()
if not screen_path.exists() or not config.quick:
screen_img = orig_img.copy()
screen_img.thumbnail(config.view_size)
screen_img.save(screen_path)
logger.info(f'Generated screen size "{image_path.path}" -> "{screen_path}"')
def generate_html(config: Config, root_dir: ImageDirectory) -> None:
"""
Recursively generate HTML files for this directory and all children
"""
jinja_env = Environment(
loader=FileSystemLoader(root_dir.path / "_templates"),
autoescape=select_autoescape(),
)
album_tmpl = jinja_env.get_template("album.html")
photo_tmpl = jinja_env.get_template("photo.html")
with Progress() as progress:
task = progress.add_task("Rendering HTML...", total=len(root_dir.image_paths()))
for album_dir in root_dir.walk():
html_path = album_dir.path / "index.html"
root_path = root_dir.path.relative_to(html_path.parent, walk_up=True)
html_path = config.output_dir / html_path
breadcrumbs = []
if not album_dir.is_root:
crumb_pos = album_dir.path.parent
while crumb_pos != root_dir.path:
breadcrumbs.append(
(
str(crumb_pos.relative_to(album_dir.path, walk_up=True)),
crumb_pos.name,
)
)
crumb_pos = crumb_pos.parent
breadcrumbs.reverse()
logger.debug(f"Rendering {html_path}")
with html_path.open("w") as f:
f.write(
album_tmpl.render(
root_path=root_path,
album_dir=album_dir,
breadcrumbs=breadcrumbs,
)
)
for pos, image_path in enumerate(album_dir.images):
# TODO: If a file with a matching name but .txt or .md, add that as the
# description for the image
if image_path.path.stem == "cover":
continue
html_path = image_path.html_path()
root_path = root_dir.path.relative_to(html_path.parent, walk_up=True)
html_path = config.output_dir / html_path
html_path.parent.mkdir(exist_ok=True)
prev_image = None
next_image = None
if pos != 0:
prev_image = album_dir.images[pos - 1]
if pos < len(album_dir.images) - 1:
next_image = album_dir.images[pos + 1]
logger.debug(f"Rendering {html_path}")
with html_path.open("w") as f:
f.write(
photo_tmpl.render(
root_path=root_path,
image_path=image_path,
prev_image=prev_image,
next_image=next_image,
)
)
progress.update(task, advance=1)

View file

@ -1,55 +0,0 @@
{% extends "base.html" %}
{% block content %}
{% if not album_dir.is_root %}
<h1>
<a href="{{root_path}}">Home</a>
{% for href, name in breadcrumbs %}
/ <a href="{{href}}">{{name}}</a>
{% endfor %}
/ {{album_dir.path.name}}
</h1>
<hr>
{% endif %}
{% if album_dir.description %}
<div class="caption">
{{ album_dir.description | safe }}
</div>
{% endif %}
{% if album_dir.children %}
<h2>Albums</h2>
<div id="album-children">
{% for child in album_dir.children %}
<div class="album">
<a href="{{child.path.name}}/">
<div>
{% if child.cover_path %}
<img
src="{{child.cover_path.path.parent.relative_to(album_dir.path)}}/slides/{{child.cover_path.thumbnail_filename()}}" />
{% endif %}
</div>
<div>
{{child.path.name}}
</div>
</a>
</div>
{% endfor %}
</div>
{% endif %}
{% if album_dir.images %}
{% if album_dir.children %}
<h2>Photos</h2>
{% endif %}
<div id="album-photos">
{% for image in album_dir.images %}
<div class="thumbnail">
<a href="slides/{{image.html_filename()}}">
<img src="slides/{{image.thumbnail_filename()}}" />
</a>
</div>
{% endfor %}
</div>
{% endif %}
{% endblock %}

608
poetry.lock generated
View file

@ -1,608 +0,0 @@
# This file is automatically @generated by Poetry 1.7.1 and should not be changed by hand.
[[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.0"
description = "Code coverage measurement for Python"
optional = false
python-versions = ">=3.8"
files = [
{file = "coverage-7.6.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:dff044f661f59dace805eedb4a7404c573b6ff0cdba4a524141bc63d7be5c7fd"},
{file = "coverage-7.6.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:a8659fd33ee9e6ca03950cfdcdf271d645cf681609153f218826dd9805ab585c"},
{file = "coverage-7.6.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7792f0ab20df8071d669d929c75c97fecfa6bcab82c10ee4adb91c7a54055463"},
{file = "coverage-7.6.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d4b3cd1ca7cd73d229487fa5caca9e4bc1f0bca96526b922d61053ea751fe791"},
{file = "coverage-7.6.0-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e7e128f85c0b419907d1f38e616c4f1e9f1d1b37a7949f44df9a73d5da5cd53c"},
{file = "coverage-7.6.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:a94925102c89247530ae1dab7dc02c690942566f22e189cbd53579b0693c0783"},
{file = "coverage-7.6.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:dcd070b5b585b50e6617e8972f3fbbee786afca71b1936ac06257f7e178f00f6"},
{file = "coverage-7.6.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:d50a252b23b9b4dfeefc1f663c568a221092cbaded20a05a11665d0dbec9b8fb"},
{file = "coverage-7.6.0-cp310-cp310-win32.whl", hash = "sha256:0e7b27d04131c46e6894f23a4ae186a6a2207209a05df5b6ad4caee6d54a222c"},
{file = "coverage-7.6.0-cp310-cp310-win_amd64.whl", hash = "sha256:54dece71673b3187c86226c3ca793c5f891f9fc3d8aa183f2e3653da18566169"},
{file = "coverage-7.6.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:c7b525ab52ce18c57ae232ba6f7010297a87ced82a2383b1afd238849c1ff933"},
{file = "coverage-7.6.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4bea27c4269234e06f621f3fac3925f56ff34bc14521484b8f66a580aacc2e7d"},
{file = "coverage-7.6.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ed8d1d1821ba5fc88d4a4f45387b65de52382fa3ef1f0115a4f7a20cdfab0e94"},
{file = "coverage-7.6.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:01c322ef2bbe15057bc4bf132b525b7e3f7206f071799eb8aa6ad1940bcf5fb1"},
{file = "coverage-7.6.0-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:03cafe82c1b32b770a29fd6de923625ccac3185a54a5e66606da26d105f37dac"},
{file = "coverage-7.6.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0d1b923fc4a40c5832be4f35a5dab0e5ff89cddf83bb4174499e02ea089daf57"},
{file = "coverage-7.6.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:4b03741e70fb811d1a9a1d75355cf391f274ed85847f4b78e35459899f57af4d"},
{file = "coverage-7.6.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:a73d18625f6a8a1cbb11eadc1d03929f9510f4131879288e3f7922097a429f63"},
{file = "coverage-7.6.0-cp311-cp311-win32.whl", hash = "sha256:65fa405b837060db569a61ec368b74688f429b32fa47a8929a7a2f9b47183713"},
{file = "coverage-7.6.0-cp311-cp311-win_amd64.whl", hash = "sha256:6379688fb4cfa921ae349c76eb1a9ab26b65f32b03d46bb0eed841fd4cb6afb1"},
{file = "coverage-7.6.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:f7db0b6ae1f96ae41afe626095149ecd1b212b424626175a6633c2999eaad45b"},
{file = "coverage-7.6.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:bbdf9a72403110a3bdae77948b8011f644571311c2fb35ee15f0f10a8fc082e8"},
{file = "coverage-7.6.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9cc44bf0315268e253bf563f3560e6c004efe38f76db03a1558274a6e04bf5d5"},
{file = "coverage-7.6.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:da8549d17489cd52f85a9829d0e1d91059359b3c54a26f28bec2c5d369524807"},
{file = "coverage-7.6.0-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0086cd4fc71b7d485ac93ca4239c8f75732c2ae3ba83f6be1c9be59d9e2c6382"},
{file = "coverage-7.6.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1fad32ee9b27350687035cb5fdf9145bc9cf0a094a9577d43e909948ebcfa27b"},
{file = "coverage-7.6.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:044a0985a4f25b335882b0966625270a8d9db3d3409ddc49a4eb00b0ef5e8cee"},
{file = "coverage-7.6.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:76d5f82213aa78098b9b964ea89de4617e70e0d43e97900c2778a50856dac605"},
{file = "coverage-7.6.0-cp312-cp312-win32.whl", hash = "sha256:3c59105f8d58ce500f348c5b56163a4113a440dad6daa2294b5052a10db866da"},
{file = "coverage-7.6.0-cp312-cp312-win_amd64.whl", hash = "sha256:ca5d79cfdae420a1d52bf177de4bc2289c321d6c961ae321503b2ca59c17ae67"},
{file = "coverage-7.6.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:d39bd10f0ae453554798b125d2f39884290c480f56e8a02ba7a6ed552005243b"},
{file = "coverage-7.6.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:beb08e8508e53a568811016e59f3234d29c2583f6b6e28572f0954a6b4f7e03d"},
{file = "coverage-7.6.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b2e16f4cd2bc4d88ba30ca2d3bbf2f21f00f382cf4e1ce3b1ddc96c634bc48ca"},
{file = "coverage-7.6.0-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6616d1c9bf1e3faea78711ee42a8b972367d82ceae233ec0ac61cc7fec09fa6b"},
{file = "coverage-7.6.0-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ad4567d6c334c46046d1c4c20024de2a1c3abc626817ae21ae3da600f5779b44"},
{file = "coverage-7.6.0-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:d17c6a415d68cfe1091d3296ba5749d3d8696e42c37fca5d4860c5bf7b729f03"},
{file = "coverage-7.6.0-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:9146579352d7b5f6412735d0f203bbd8d00113a680b66565e205bc605ef81bc6"},
{file = "coverage-7.6.0-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:cdab02a0a941af190df8782aafc591ef3ad08824f97850b015c8c6a8b3877b0b"},
{file = "coverage-7.6.0-cp38-cp38-win32.whl", hash = "sha256:df423f351b162a702c053d5dddc0fc0ef9a9e27ea3f449781ace5f906b664428"},
{file = "coverage-7.6.0-cp38-cp38-win_amd64.whl", hash = "sha256:f2501d60d7497fd55e391f423f965bbe9e650e9ffc3c627d5f0ac516026000b8"},
{file = "coverage-7.6.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:7221f9ac9dad9492cecab6f676b3eaf9185141539d5c9689d13fd6b0d7de840c"},
{file = "coverage-7.6.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:ddaaa91bfc4477d2871442bbf30a125e8fe6b05da8a0015507bfbf4718228ab2"},
{file = "coverage-7.6.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c4cbe651f3904e28f3a55d6f371203049034b4ddbce65a54527a3f189ca3b390"},
{file = "coverage-7.6.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:831b476d79408ab6ccfadaaf199906c833f02fdb32c9ab907b1d4aa0713cfa3b"},
{file = "coverage-7.6.0-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:46c3d091059ad0b9c59d1034de74a7f36dcfa7f6d3bde782c49deb42438f2450"},
{file = "coverage-7.6.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:4d5fae0a22dc86259dee66f2cc6c1d3e490c4a1214d7daa2a93d07491c5c04b6"},
{file = "coverage-7.6.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:07ed352205574aad067482e53dd606926afebcb5590653121063fbf4e2175166"},
{file = "coverage-7.6.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:49c76cdfa13015c4560702574bad67f0e15ca5a2872c6a125f6327ead2b731dd"},
{file = "coverage-7.6.0-cp39-cp39-win32.whl", hash = "sha256:482855914928c8175735a2a59c8dc5806cf7d8f032e4820d52e845d1f731dca2"},
{file = "coverage-7.6.0-cp39-cp39-win_amd64.whl", hash = "sha256:543ef9179bc55edfd895154a51792b01c017c87af0ebaae092720152e19e42ca"},
{file = "coverage-7.6.0-pp38.pp39.pp310-none-any.whl", hash = "sha256:6fe885135c8a479d3e37a7aae61cbd3a0fb2deccb4dda3c25f92a49189f766d6"},
{file = "coverage-7.6.0.tar.gz", hash = "sha256:289cc803fa1dc901f84701ac10c9ee873619320f2f9aff38794db4a4a0268d51"},
]
[package.extras]
toml = ["tomli"]
[[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 = "jinja2"
version = "3.1.4"
description = "A very fast and expressive template engine."
optional = false
python-versions = ">=3.7"
files = [
{file = "jinja2-3.1.4-py3-none-any.whl", hash = "sha256:bc5dd2abb727a5319567b7a813e6a2e7318c39f4f487cfe6c89c6f9c7d25197d"},
{file = "jinja2-3.1.4.tar.gz", hash = "sha256:4a3aee7acbbe7303aede8e9648d13b8bf88a429282aa6122a993f0ac800cb369"},
]
[package.dependencies]
MarkupSafe = ">=2.0"
[package.extras]
i18n = ["Babel (>=2.7)"]
[[package]]
name = "markdown"
version = "3.6"
description = "Python implementation of John Gruber's Markdown."
optional = false
python-versions = ">=3.8"
files = [
{file = "Markdown-3.6-py3-none-any.whl", hash = "sha256:48f276f4d8cfb8ce6527c8f79e2ee29708508bf4d40aa410fbc3b4ee832c850f"},
{file = "Markdown-3.6.tar.gz", hash = "sha256:ed4f41f6daecbeeb96e576ce414c41d2d876daa9a16cb35fa8ed8c2ddfad0224"},
]
[package.extras]
docs = ["mdx-gh-links (>=0.2)", "mkdocs (>=1.5)", "mkdocs-gen-files", "mkdocs-literate-nav", "mkdocs-nature (>=0.6)", "mkdocs-section-index", "mkdocstrings[python]"]
testing = ["coverage", "pyyaml"]
[[package]]
name = "markdown-it-py"
version = "3.0.0"
description = "Python port of markdown-it. Markdown parsing, done right!"
optional = false
python-versions = ">=3.8"
files = [
{file = "markdown-it-py-3.0.0.tar.gz", hash = "sha256:e3f60a94fa066dc52ec76661e37c851cb232d92f9886b15cb560aaada2df8feb"},
{file = "markdown_it_py-3.0.0-py3-none-any.whl", hash = "sha256:355216845c60bd96232cd8d8c40e8f9765cc86f46880e43a8fd22dc1a1a8cab1"},
]
[package.dependencies]
mdurl = ">=0.1,<1.0"
[package.extras]
benchmarking = ["psutil", "pytest", "pytest-benchmark"]
code-style = ["pre-commit (>=3.0,<4.0)"]
compare = ["commonmark (>=0.9,<1.0)", "markdown (>=3.4,<4.0)", "mistletoe (>=1.0,<2.0)", "mistune (>=2.0,<3.0)", "panflute (>=2.3,<3.0)"]
linkify = ["linkify-it-py (>=1,<3)"]
plugins = ["mdit-py-plugins"]
profiling = ["gprof2dot"]
rtd = ["jupyter_sphinx", "mdit-py-plugins", "myst-parser", "pyyaml", "sphinx", "sphinx-copybutton", "sphinx-design", "sphinx_book_theme"]
testing = ["coverage", "pytest", "pytest-cov", "pytest-regressions"]
[[package]]
name = "markupsafe"
version = "2.1.5"
description = "Safely add untrusted strings to HTML/XML markup."
optional = false
python-versions = ">=3.7"
files = [
{file = "MarkupSafe-2.1.5-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:a17a92de5231666cfbe003f0e4b9b3a7ae3afb1ec2845aadc2bacc93ff85febc"},
{file = "MarkupSafe-2.1.5-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:72b6be590cc35924b02c78ef34b467da4ba07e4e0f0454a2c5907f473fc50ce5"},
{file = "MarkupSafe-2.1.5-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e61659ba32cf2cf1481e575d0462554625196a1f2fc06a1c777d3f48e8865d46"},
{file = "MarkupSafe-2.1.5-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2174c595a0d73a3080ca3257b40096db99799265e1c27cc5a610743acd86d62f"},
{file = "MarkupSafe-2.1.5-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ae2ad8ae6ebee9d2d94b17fb62763125f3f374c25618198f40cbb8b525411900"},
{file = "MarkupSafe-2.1.5-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:075202fa5b72c86ad32dc7d0b56024ebdbcf2048c0ba09f1cde31bfdd57bcfff"},
{file = "MarkupSafe-2.1.5-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:598e3276b64aff0e7b3451b72e94fa3c238d452e7ddcd893c3ab324717456bad"},
{file = "MarkupSafe-2.1.5-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:fce659a462a1be54d2ffcacea5e3ba2d74daa74f30f5f143fe0c58636e355fdd"},
{file = "MarkupSafe-2.1.5-cp310-cp310-win32.whl", hash = "sha256:d9fad5155d72433c921b782e58892377c44bd6252b5af2f67f16b194987338a4"},
{file = "MarkupSafe-2.1.5-cp310-cp310-win_amd64.whl", hash = "sha256:bf50cd79a75d181c9181df03572cdce0fbb75cc353bc350712073108cba98de5"},
{file = "MarkupSafe-2.1.5-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:629ddd2ca402ae6dbedfceeba9c46d5f7b2a61d9749597d4307f943ef198fc1f"},
{file = "MarkupSafe-2.1.5-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:5b7b716f97b52c5a14bffdf688f971b2d5ef4029127f1ad7a513973cfd818df2"},
{file = "MarkupSafe-2.1.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6ec585f69cec0aa07d945b20805be741395e28ac1627333b1c5b0105962ffced"},
{file = "MarkupSafe-2.1.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b91c037585eba9095565a3556f611e3cbfaa42ca1e865f7b8015fe5c7336d5a5"},
{file = "MarkupSafe-2.1.5-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7502934a33b54030eaf1194c21c692a534196063db72176b0c4028e140f8f32c"},
{file = "MarkupSafe-2.1.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:0e397ac966fdf721b2c528cf028494e86172b4feba51d65f81ffd65c63798f3f"},
{file = "MarkupSafe-2.1.5-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:c061bb86a71b42465156a3ee7bd58c8c2ceacdbeb95d05a99893e08b8467359a"},
{file = "MarkupSafe-2.1.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:3a57fdd7ce31c7ff06cdfbf31dafa96cc533c21e443d57f5b1ecc6cdc668ec7f"},
{file = "MarkupSafe-2.1.5-cp311-cp311-win32.whl", hash = "sha256:397081c1a0bfb5124355710fe79478cdbeb39626492b15d399526ae53422b906"},
{file = "MarkupSafe-2.1.5-cp311-cp311-win_amd64.whl", hash = "sha256:2b7c57a4dfc4f16f7142221afe5ba4e093e09e728ca65c51f5620c9aaeb9a617"},
{file = "MarkupSafe-2.1.5-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:8dec4936e9c3100156f8a2dc89c4b88d5c435175ff03413b443469c7c8c5f4d1"},
{file = "MarkupSafe-2.1.5-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:3c6b973f22eb18a789b1460b4b91bf04ae3f0c4234a0a6aa6b0a92f6f7b951d4"},
{file = "MarkupSafe-2.1.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ac07bad82163452a6884fe8fa0963fb98c2346ba78d779ec06bd7a6262132aee"},
{file = "MarkupSafe-2.1.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f5dfb42c4604dddc8e4305050aa6deb084540643ed5804d7455b5df8fe16f5e5"},
{file = "MarkupSafe-2.1.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ea3d8a3d18833cf4304cd2fc9cbb1efe188ca9b5efef2bdac7adc20594a0e46b"},
{file = "MarkupSafe-2.1.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:d050b3361367a06d752db6ead6e7edeb0009be66bc3bae0ee9d97fb326badc2a"},
{file = "MarkupSafe-2.1.5-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:bec0a414d016ac1a18862a519e54b2fd0fc8bbfd6890376898a6c0891dd82e9f"},
{file = "MarkupSafe-2.1.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:58c98fee265677f63a4385256a6d7683ab1832f3ddd1e66fe948d5880c21a169"},
{file = "MarkupSafe-2.1.5-cp312-cp312-win32.whl", hash = "sha256:8590b4ae07a35970728874632fed7bd57b26b0102df2d2b233b6d9d82f6c62ad"},
{file = "MarkupSafe-2.1.5-cp312-cp312-win_amd64.whl", hash = "sha256:823b65d8706e32ad2df51ed89496147a42a2a6e01c13cfb6ffb8b1e92bc910bb"},
{file = "MarkupSafe-2.1.5-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:c8b29db45f8fe46ad280a7294f5c3ec36dbac9491f2d1c17345be8e69cc5928f"},
{file = "MarkupSafe-2.1.5-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ec6a563cff360b50eed26f13adc43e61bc0c04d94b8be985e6fb24b81f6dcfdf"},
{file = "MarkupSafe-2.1.5-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a549b9c31bec33820e885335b451286e2969a2d9e24879f83fe904a5ce59d70a"},
{file = "MarkupSafe-2.1.5-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4f11aa001c540f62c6166c7726f71f7573b52c68c31f014c25cc7901deea0b52"},
{file = "MarkupSafe-2.1.5-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:7b2e5a267c855eea6b4283940daa6e88a285f5f2a67f2220203786dfa59b37e9"},
{file = "MarkupSafe-2.1.5-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:2d2d793e36e230fd32babe143b04cec8a8b3eb8a3122d2aceb4a371e6b09b8df"},
{file = "MarkupSafe-2.1.5-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:ce409136744f6521e39fd8e2a24c53fa18ad67aa5bc7c2cf83645cce5b5c4e50"},
{file = "MarkupSafe-2.1.5-cp37-cp37m-win32.whl", hash = "sha256:4096e9de5c6fdf43fb4f04c26fb114f61ef0bf2e5604b6ee3019d51b69e8c371"},
{file = "MarkupSafe-2.1.5-cp37-cp37m-win_amd64.whl", hash = "sha256:4275d846e41ecefa46e2015117a9f491e57a71ddd59bbead77e904dc02b1bed2"},
{file = "MarkupSafe-2.1.5-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:656f7526c69fac7f600bd1f400991cc282b417d17539a1b228617081106feb4a"},
{file = "MarkupSafe-2.1.5-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:97cafb1f3cbcd3fd2b6fbfb99ae11cdb14deea0736fc2b0952ee177f2b813a46"},
{file = "MarkupSafe-2.1.5-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1f3fbcb7ef1f16e48246f704ab79d79da8a46891e2da03f8783a5b6fa41a9532"},
{file = "MarkupSafe-2.1.5-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fa9db3f79de01457b03d4f01b34cf91bc0048eb2c3846ff26f66687c2f6d16ab"},
{file = "MarkupSafe-2.1.5-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ffee1f21e5ef0d712f9033568f8344d5da8cc2869dbd08d87c84656e6a2d2f68"},
{file = "MarkupSafe-2.1.5-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:5dedb4db619ba5a2787a94d877bc8ffc0566f92a01c0ef214865e54ecc9ee5e0"},
{file = "MarkupSafe-2.1.5-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:30b600cf0a7ac9234b2638fbc0fb6158ba5bdcdf46aeb631ead21248b9affbc4"},
{file = "MarkupSafe-2.1.5-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:8dd717634f5a044f860435c1d8c16a270ddf0ef8588d4887037c5028b859b0c3"},
{file = "MarkupSafe-2.1.5-cp38-cp38-win32.whl", hash = "sha256:daa4ee5a243f0f20d528d939d06670a298dd39b1ad5f8a72a4275124a7819eff"},
{file = "MarkupSafe-2.1.5-cp38-cp38-win_amd64.whl", hash = "sha256:619bc166c4f2de5caa5a633b8b7326fbe98e0ccbfacabd87268a2b15ff73a029"},
{file = "MarkupSafe-2.1.5-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:7a68b554d356a91cce1236aa7682dc01df0edba8d043fd1ce607c49dd3c1edcf"},
{file = "MarkupSafe-2.1.5-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:db0b55e0f3cc0be60c1f19efdde9a637c32740486004f20d1cff53c3c0ece4d2"},
{file = "MarkupSafe-2.1.5-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3e53af139f8579a6d5f7b76549125f0d94d7e630761a2111bc431fd820e163b8"},
{file = "MarkupSafe-2.1.5-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:17b950fccb810b3293638215058e432159d2b71005c74371d784862b7e4683f3"},
{file = "MarkupSafe-2.1.5-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4c31f53cdae6ecfa91a77820e8b151dba54ab528ba65dfd235c80b086d68a465"},
{file = "MarkupSafe-2.1.5-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:bff1b4290a66b490a2f4719358c0cdcd9bafb6b8f061e45c7a2460866bf50c2e"},
{file = "MarkupSafe-2.1.5-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:bc1667f8b83f48511b94671e0e441401371dfd0f0a795c7daa4a3cd1dde55bea"},
{file = "MarkupSafe-2.1.5-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:5049256f536511ee3f7e1b3f87d1d1209d327e818e6ae1365e8653d7e3abb6a6"},
{file = "MarkupSafe-2.1.5-cp39-cp39-win32.whl", hash = "sha256:00e046b6dd71aa03a41079792f8473dc494d564611a8f89bbbd7cb93295ebdcf"},
{file = "MarkupSafe-2.1.5-cp39-cp39-win_amd64.whl", hash = "sha256:fa173ec60341d6bb97a89f5ea19c85c5643c1e7dedebc22f5181eb73573142c5"},
{file = "MarkupSafe-2.1.5.tar.gz", hash = "sha256:d283d37a890ba4c1ae73ffadf8046435c76e7bc2247bbb63c00bd1a709c6544b"},
]
[[package]]
name = "mdurl"
version = "0.1.2"
description = "Markdown URL utilities"
optional = false
python-versions = ">=3.7"
files = [
{file = "mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8"},
{file = "mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba"},
]
[[package]]
name = "mypy"
version = "1.11.1"
description = "Optional static typing for Python"
optional = false
python-versions = ">=3.8"
files = [
{file = "mypy-1.11.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:a32fc80b63de4b5b3e65f4be82b4cfa362a46702672aa6a0f443b4689af7008c"},
{file = "mypy-1.11.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:c1952f5ea8a5a959b05ed5f16452fddadbaae48b5d39235ab4c3fc444d5fd411"},
{file = "mypy-1.11.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e1e30dc3bfa4e157e53c1d17a0dad20f89dc433393e7702b813c10e200843b03"},
{file = "mypy-1.11.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:2c63350af88f43a66d3dfeeeb8d77af34a4f07d760b9eb3a8697f0386c7590b4"},
{file = "mypy-1.11.1-cp310-cp310-win_amd64.whl", hash = "sha256:a831671bad47186603872a3abc19634f3011d7f83b083762c942442d51c58d58"},
{file = "mypy-1.11.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:7b6343d338390bb946d449677726edf60102a1c96079b4f002dedff375953fc5"},
{file = "mypy-1.11.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e4fe9f4e5e521b458d8feb52547f4bade7ef8c93238dfb5bbc790d9ff2d770ca"},
{file = "mypy-1.11.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:886c9dbecc87b9516eff294541bf7f3655722bf22bb898ee06985cd7269898de"},
{file = "mypy-1.11.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:fca4a60e1dd9fd0193ae0067eaeeb962f2d79e0d9f0f66223a0682f26ffcc809"},
{file = "mypy-1.11.1-cp311-cp311-win_amd64.whl", hash = "sha256:0bd53faf56de9643336aeea1c925012837432b5faf1701ccca7fde70166ccf72"},
{file = "mypy-1.11.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:f39918a50f74dc5969807dcfaecafa804fa7f90c9d60506835036cc1bc891dc8"},
{file = "mypy-1.11.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0bc71d1fb27a428139dd78621953effe0d208aed9857cb08d002280b0422003a"},
{file = "mypy-1.11.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b868d3bcff720dd7217c383474008ddabaf048fad8d78ed948bb4b624870a417"},
{file = "mypy-1.11.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:a707ec1527ffcdd1c784d0924bf5cb15cd7f22683b919668a04d2b9c34549d2e"},
{file = "mypy-1.11.1-cp312-cp312-win_amd64.whl", hash = "sha256:64f4a90e3ea07f590c5bcf9029035cf0efeae5ba8be511a8caada1a4893f5525"},
{file = "mypy-1.11.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:749fd3213916f1751fff995fccf20c6195cae941dc968f3aaadf9bb4e430e5a2"},
{file = "mypy-1.11.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:b639dce63a0b19085213ec5fdd8cffd1d81988f47a2dec7100e93564f3e8fb3b"},
{file = "mypy-1.11.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4c956b49c5d865394d62941b109728c5c596a415e9c5b2be663dd26a1ff07bc0"},
{file = "mypy-1.11.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:45df906e8b6804ef4b666af29a87ad9f5921aad091c79cc38e12198e220beabd"},
{file = "mypy-1.11.1-cp38-cp38-win_amd64.whl", hash = "sha256:d44be7551689d9d47b7abc27c71257adfdb53f03880841a5db15ddb22dc63edb"},
{file = "mypy-1.11.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:2684d3f693073ab89d76da8e3921883019ea8a3ec20fa5d8ecca6a2db4c54bbe"},
{file = "mypy-1.11.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:79c07eb282cb457473add5052b63925e5cc97dfab9812ee65a7c7ab5e3cb551c"},
{file = "mypy-1.11.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:11965c2f571ded6239977b14deebd3f4c3abd9a92398712d6da3a772974fad69"},
{file = "mypy-1.11.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:a2b43895a0f8154df6519706d9bca8280cda52d3d9d1514b2d9c3e26792a0b74"},
{file = "mypy-1.11.1-cp39-cp39-win_amd64.whl", hash = "sha256:1a81cf05975fd61aec5ae16501a091cfb9f605dc3e3c878c0da32f250b74760b"},
{file = "mypy-1.11.1-py3-none-any.whl", hash = "sha256:0624bdb940255d2dd24e829d99a13cfeb72e4e9031f9492148f410ed30bcab54"},
{file = "mypy-1.11.1.tar.gz", hash = "sha256:f404a0b069709f18bbdb702eb3dcfe51910602995de00bd39cea3050b5772d08"},
]
[package.dependencies]
mypy-extensions = ">=1.0.0"
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 = "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 = "pex"
version = "2.14.1"
description = "The PEX packaging toolchain."
optional = false
python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,<3.14,>=2.7"
files = [
{file = "pex-2.14.1-py2.py3-none-any.whl", hash = "sha256:43210f64e5461d91ad6c99d80724e83f5010bfa82a1664bf44ffdb14e2defb58"},
{file = "pex-2.14.1.tar.gz", hash = "sha256:e71296873101732deffa5341d7dc9a9d8ed9e58413acc07579f7b39998c273a2"},
]
[package.extras]
subprocess = ["subprocess32 (>=3.2.7)"]
[[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 = "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 = "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 = "pytest"
version = "8.3.2"
description = "pytest: simple powerful testing with Python"
optional = false
python-versions = ">=3.8"
files = [
{file = "pytest-8.3.2-py3-none-any.whl", hash = "sha256:4ba08f9ae7dcf84ded419494d229b48d0903ea6407b030eaec46df5e6a73bba5"},
{file = "pytest-8.3.2.tar.gz", hash = "sha256:c132345d12ce551242c87269de812483f5bcc87cdbb4722e48487ba194f9fdce"},
]
[package.dependencies]
colorama = {version = "*", markers = "sys_platform == \"win32\""}
iniconfig = "*"
packaging = "*"
pluggy = ">=1.5,<2"
[package.extras]
dev = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "pygments (>=2.7.2)", "requests", "setuptools", "xmlschema"]
[[package]]
name = "pytest-testmon"
version = "2.1.1"
description = "selects tests affected by changed files and methods"
optional = false
python-versions = ">=3.8"
files = [
{file = "pytest-testmon-2.1.1.tar.gz", hash = "sha256:8ebe2c3de42d99306ee54cd4536fed0fc48346a954420da904b18e8d59b5da98"},
{file = "pytest_testmon-2.1.1-py3-none-any.whl", hash = "sha256:8271ca47bc8c80760c4fc7fd7895ea786b111bbb31f13eeea879a6fd11fe2226"},
]
[package.dependencies]
coverage = ">=6,<8"
pytest = ">=5,<9"
[[package]]
name = "pyyaml"
version = "6.0.1"
description = "YAML parser and emitter for Python"
optional = false
python-versions = ">=3.6"
files = [
{file = "PyYAML-6.0.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d858aa552c999bc8a8d57426ed01e40bef403cd8ccdd0fc5f6f04a00414cac2a"},
{file = "PyYAML-6.0.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:fd66fc5d0da6d9815ba2cebeb4205f95818ff4b79c3ebe268e75d961704af52f"},
{file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:69b023b2b4daa7548bcfbd4aa3da05b3a74b772db9e23b982788168117739938"},
{file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:81e0b275a9ecc9c0c0c07b4b90ba548307583c125f54d5b6946cfee6360c733d"},
{file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ba336e390cd8e4d1739f42dfe9bb83a3cc2e80f567d8805e11b46f4a943f5515"},
{file = "PyYAML-6.0.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:326c013efe8048858a6d312ddd31d56e468118ad4cdeda36c719bf5bb6192290"},
{file = "PyYAML-6.0.1-cp310-cp310-win32.whl", hash = "sha256:bd4af7373a854424dabd882decdc5579653d7868b8fb26dc7d0e99f823aa5924"},
{file = "PyYAML-6.0.1-cp310-cp310-win_amd64.whl", hash = "sha256:fd1592b3fdf65fff2ad0004b5e363300ef59ced41c2e6b3a99d4089fa8c5435d"},
{file = "PyYAML-6.0.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:6965a7bc3cf88e5a1c3bd2e0b5c22f8d677dc88a455344035f03399034eb3007"},
{file = "PyYAML-6.0.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f003ed9ad21d6a4713f0a9b5a7a0a79e08dd0f221aff4525a2be4c346ee60aab"},
{file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:42f8152b8dbc4fe7d96729ec2b99c7097d656dc1213a3229ca5383f973a5ed6d"},
{file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:062582fca9fabdd2c8b54a3ef1c978d786e0f6b3a1510e0ac93ef59e0ddae2bc"},
{file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d2b04aac4d386b172d5b9692e2d2da8de7bfb6c387fa4f801fbf6fb2e6ba4673"},
{file = "PyYAML-6.0.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:e7d73685e87afe9f3b36c799222440d6cf362062f78be1013661b00c5c6f678b"},
{file = "PyYAML-6.0.1-cp311-cp311-win32.whl", hash = "sha256:1635fd110e8d85d55237ab316b5b011de701ea0f29d07611174a1b42f1444741"},
{file = "PyYAML-6.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:bf07ee2fef7014951eeb99f56f39c9bb4af143d8aa3c21b1677805985307da34"},
{file = "PyYAML-6.0.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:855fb52b0dc35af121542a76b9a84f8d1cd886ea97c84703eaa6d88e37a2ad28"},
{file = "PyYAML-6.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:40df9b996c2b73138957fe23a16a4f0ba614f4c0efce1e9406a184b6d07fa3a9"},
{file = "PyYAML-6.0.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6c22bec3fbe2524cde73d7ada88f6566758a8f7227bfbf93a408a9d86bcc12a0"},
{file = "PyYAML-6.0.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8d4e9c88387b0f5c7d5f281e55304de64cf7f9c0021a3525bd3b1c542da3b0e4"},
{file = "PyYAML-6.0.1-cp312-cp312-win32.whl", hash = "sha256:d483d2cdf104e7c9fa60c544d92981f12ad66a457afae824d146093b8c294c54"},
{file = "PyYAML-6.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:0d3304d8c0adc42be59c5f8a4d9e3d7379e6955ad754aa9d6ab7a398b59dd1df"},
{file = "PyYAML-6.0.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:50550eb667afee136e9a77d6dc71ae76a44df8b3e51e41b77f6de2932bfe0f47"},
{file = "PyYAML-6.0.1-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1fe35611261b29bd1de0070f0b2f47cb6ff71fa6595c077e42bd0c419fa27b98"},
{file = "PyYAML-6.0.1-cp36-cp36m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:704219a11b772aea0d8ecd7058d0082713c3562b4e271b849ad7dc4a5c90c13c"},
{file = "PyYAML-6.0.1-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:afd7e57eddb1a54f0f1a974bc4391af8bcce0b444685d936840f125cf046d5bd"},
{file = "PyYAML-6.0.1-cp36-cp36m-win32.whl", hash = "sha256:fca0e3a251908a499833aa292323f32437106001d436eca0e6e7833256674585"},
{file = "PyYAML-6.0.1-cp36-cp36m-win_amd64.whl", hash = "sha256:f22ac1c3cac4dbc50079e965eba2c1058622631e526bd9afd45fedd49ba781fa"},
{file = "PyYAML-6.0.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:b1275ad35a5d18c62a7220633c913e1b42d44b46ee12554e5fd39c70a243d6a3"},
{file = "PyYAML-6.0.1-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:18aeb1bf9a78867dc38b259769503436b7c72f7a1f1f4c93ff9a17de54319b27"},
{file = "PyYAML-6.0.1-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:596106435fa6ad000c2991a98fa58eeb8656ef2325d7e158344fb33864ed87e3"},
{file = "PyYAML-6.0.1-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:baa90d3f661d43131ca170712d903e6295d1f7a0f595074f151c0aed377c9b9c"},
{file = "PyYAML-6.0.1-cp37-cp37m-win32.whl", hash = "sha256:9046c58c4395dff28dd494285c82ba00b546adfc7ef001486fbf0324bc174fba"},
{file = "PyYAML-6.0.1-cp37-cp37m-win_amd64.whl", hash = "sha256:4fb147e7a67ef577a588a0e2c17b6db51dda102c71de36f8549b6816a96e1867"},
{file = "PyYAML-6.0.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:1d4c7e777c441b20e32f52bd377e0c409713e8bb1386e1099c2415f26e479595"},
{file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a0cd17c15d3bb3fa06978b4e8958dcdc6e0174ccea823003a106c7d4d7899ac5"},
{file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:28c119d996beec18c05208a8bd78cbe4007878c6dd15091efb73a30e90539696"},
{file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7e07cbde391ba96ab58e532ff4803f79c4129397514e1413a7dc761ccd755735"},
{file = "PyYAML-6.0.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:49a183be227561de579b4a36efbb21b3eab9651dd81b1858589f796549873dd6"},
{file = "PyYAML-6.0.1-cp38-cp38-win32.whl", hash = "sha256:184c5108a2aca3c5b3d3bf9395d50893a7ab82a38004c8f61c258d4428e80206"},
{file = "PyYAML-6.0.1-cp38-cp38-win_amd64.whl", hash = "sha256:1e2722cc9fbb45d9b87631ac70924c11d3a401b2d7f410cc0e3bbf249f2dca62"},
{file = "PyYAML-6.0.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:9eb6caa9a297fc2c2fb8862bc5370d0303ddba53ba97e71f08023b6cd73d16a8"},
{file = "PyYAML-6.0.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:c8098ddcc2a85b61647b2590f825f3db38891662cfc2fc776415143f599bb859"},
{file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5773183b6446b2c99bb77e77595dd486303b4faab2b086e7b17bc6bef28865f6"},
{file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b786eecbdf8499b9ca1d697215862083bd6d2a99965554781d0d8d1ad31e13a0"},
{file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bc1bf2925a1ecd43da378f4db9e4f799775d6367bdb94671027b73b393a7c42c"},
{file = "PyYAML-6.0.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:04ac92ad1925b2cff1db0cfebffb6ffc43457495c9b3c39d3fcae417d7125dc5"},
{file = "PyYAML-6.0.1-cp39-cp39-win32.whl", hash = "sha256:faca3bdcf85b2fc05d06ff3fbc1f83e1391b3e724afa3feba7d13eeab355484c"},
{file = "PyYAML-6.0.1-cp39-cp39-win_amd64.whl", hash = "sha256:510c9deebc5c0225e8c96813043e62b680ba2f9c50a08d3724c7f28a747d1486"},
{file = "PyYAML-6.0.1.tar.gz", hash = "sha256:bfdf460b1736c775f2ba9f6a92bca30bc2095067b8a9d77876d1fad6cc3b4a43"},
]
[[package]]
name = "rich"
version = "13.7.1"
description = "Render rich text, tables, progress bars, syntax highlighting, markdown and more to the terminal"
optional = false
python-versions = ">=3.7.0"
files = [
{file = "rich-13.7.1-py3-none-any.whl", hash = "sha256:4edbae314f59eb482f54e9e30bf00d33350aaa94f4bfcd4e9e3110e64d0d7222"},
{file = "rich-13.7.1.tar.gz", hash = "sha256:9be308cb1fe2f1f57d67ce99e95af38a1e2bc71ad9813b0e247cf7ffbcc3a432"},
]
[package.dependencies]
markdown-it-py = ">=2.2.0"
pygments = ">=2.13.0,<3.0.0"
[package.extras]
jupyter = ["ipywidgets (>=7.5.1,<9)"]
[[package]]
name = "ruff"
version = "0.5.6"
description = "An extremely fast Python linter and code formatter, written in Rust."
optional = false
python-versions = ">=3.7"
files = [
{file = "ruff-0.5.6-py3-none-linux_armv6l.whl", hash = "sha256:a0ef5930799a05522985b9cec8290b185952f3fcd86c1772c3bdbd732667fdcd"},
{file = "ruff-0.5.6-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:b652dc14f6ef5d1552821e006f747802cc32d98d5509349e168f6bf0ee9f8f42"},
{file = "ruff-0.5.6-py3-none-macosx_11_0_arm64.whl", hash = "sha256:80521b88d26a45e871f31e4b88938fd87db7011bb961d8afd2664982dfc3641a"},
{file = "ruff-0.5.6-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d9bc8f328a9f1309ae80e4d392836e7dbc77303b38ed4a7112699e63d3b066ab"},
{file = "ruff-0.5.6-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:4d394940f61f7720ad371ddedf14722ee1d6250fd8d020f5ea5a86e7be217daf"},
{file = "ruff-0.5.6-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:111a99cdb02f69ddb2571e2756e017a1496c2c3a2aeefe7b988ddab38b416d36"},
{file = "ruff-0.5.6-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:e395daba77a79f6dc0d07311f94cc0560375ca20c06f354c7c99af3bf4560c5d"},
{file = "ruff-0.5.6-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c476acb43c3c51e3c614a2e878ee1589655fa02dab19fe2db0423a06d6a5b1b6"},
{file = "ruff-0.5.6-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e2ff8003f5252fd68425fd53d27c1f08b201d7ed714bb31a55c9ac1d4c13e2eb"},
{file = "ruff-0.5.6-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c94e084ba3eaa80c2172918c2ca2eb2230c3f15925f4ed8b6297260c6ef179ad"},
{file = "ruff-0.5.6-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:1f77c1c3aa0669fb230b06fb24ffa3e879391a3ba3f15e3d633a752da5a3e670"},
{file = "ruff-0.5.6-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:f908148c93c02873210a52cad75a6eda856b2cbb72250370ce3afef6fb99b1ed"},
{file = "ruff-0.5.6-py3-none-musllinux_1_2_i686.whl", hash = "sha256:563a7ae61ad284187d3071d9041c08019975693ff655438d8d4be26e492760bd"},
{file = "ruff-0.5.6-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:94fe60869bfbf0521e04fd62b74cbca21cbc5beb67cbb75ab33fe8c174f54414"},
{file = "ruff-0.5.6-py3-none-win32.whl", hash = "sha256:e6a584c1de6f8591c2570e171cc7ce482bb983d49c70ddf014393cd39e9dfaed"},
{file = "ruff-0.5.6-py3-none-win_amd64.whl", hash = "sha256:d7fe7dccb1a89dc66785d7aa0ac283b2269712d8ed19c63af908fdccca5ccc1a"},
{file = "ruff-0.5.6-py3-none-win_arm64.whl", hash = "sha256:57c6c0dd997b31b536bff49b9eee5ed3194d60605a4427f735eeb1f9c1b8d264"},
{file = "ruff-0.5.6.tar.gz", hash = "sha256:07c9e3c2a8e1fe377dd460371c3462671a728c981c3205a5217291422209f642"},
]
[[package]]
name = "types-markdown"
version = "3.6.0.20240316"
description = "Typing stubs for Markdown"
optional = false
python-versions = ">=3.8"
files = [
{file = "types-Markdown-3.6.0.20240316.tar.gz", hash = "sha256:de9fb84860b55b647b170ca576895fcca61b934a6ecdc65c31932c6795b440b8"},
{file = "types_Markdown-3.6.0.20240316-py3-none-any.whl", hash = "sha256:d3ecd26a940781787c7b57a0e3c9d77c150db64e12989ef687059edc83dfd78a"},
]
[[package]]
name = "types-pyyaml"
version = "6.0.12.20240724"
description = "Typing stubs for PyYAML"
optional = false
python-versions = ">=3.8"
files = [
{file = "types-PyYAML-6.0.12.20240724.tar.gz", hash = "sha256:cf7b31ae67e0c5b2919c703d2affc415485099d3fe6666a6912f040fd05cb67f"},
{file = "types_PyYAML-6.0.12.20240724-py3-none-any.whl", hash = "sha256:e5becec598f3aa3a2ddf671de4a75fa1c6856fbf73b2840286c9d50fae2d5d48"},
]
[[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"},
]
[metadata]
lock-version = "2.0"
python-versions = "^3.12,<3.14"
content-hash = "9ec27256e1d8988f06634a217388d357a211f6120d6ade104fdd87182b18279c"

View file

@ -1,47 +0,0 @@
[tool.poetry]
name = "photojawn"
version = "0.1.1"
description = "A simple photo album static site generator"
authors = ["Nick Pegg <nick@nickpegg.com>"]
repository = "https://github.com/nickpegg/photojawn"
readme = "README.md"
license = "MIT"
[tool.poetry.scripts]
photojawn = 'photojawn.cli:main'
[tool.poetry.dependencies]
# TODO: make sure we support >=3.10 via tests
python = "^3.12,<3.14"
jinja2 = "^3.1.4"
pillow = "^10.4.0"
rich = "^13.7.1"
pyyaml = "^6.0.1"
markdown = "^3.6"
[tool.poetry.group.dev.dependencies]
pytest = "*"
ruff = "*"
mypy = "*"
pytest-testmon = "*"
types-pyyaml = "^6.0.12.20240724"
types-markdown = "^3.6.0.20240316"
pex = "^2.14.1"
[tool.mypy]
strict = true
# Allow untyped decorators. This is mostly for @pytest.fixture
disallow_untyped_decorators = false
[[tool.mypy.overrides]]
# Various packages which don't have stubs available
module = [
]
ignore_missing_imports = true
[build-system]
requires = ["poetry-core"]
build-backend = "poetry.core.masonry.api"

View file

@ -0,0 +1,52 @@
{% extends "base.html" %}
{% block content %}
{% if root_path %}
<h1>
{% for crumb in breadcrumbs %}
<a href="{{crumb.path}}">{{crumb.name}}</a> /
{% endfor %}
{{name}}
</h1>
<hr>
{% endif %}
{% if description %}
<div class="caption">
{{ description | safe }}
</div>
{% endif %}
{% if children %}
<h2>Albums</h2>
<div id="album-children">
{% for child in children %}
<div class="album">
<a href="{{child.name}}/">
<div>
<img
src="{{child.cover_thumbnail_path}}" />
</div>
<div>
{{child.name}}
</div>
</a>
</div>
{% endfor %}
</div>
{% endif %}
{% if images %}
{% if children %}
<h2>Photos</h2>
{% endif %}
<div id="album-photos">
{% for image in images %}
<div class="thumbnail">
<a href="slides/{{image.html_filename}}">
<img src="slides/{{image.thumb_filename}}" />
</a>
</div>
{% endfor %}
</div>
{% endif %}
{% endblock %}

View file

@ -4,7 +4,7 @@
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>My Photos</title>
<link rel="stylesheet" href="{{ root_path }}/static/index.css" type="text/css">
<link rel="stylesheet" href="{%if root_path %}{{ root_path }}/{%endif%}static/index.css" type="text/css">
</head>
<body>
<div id="header">

View file

@ -5,11 +5,11 @@
document.onkeydown = function(event) {
if (event.key == "ArrowLeft") {
{% if prev_image %}
location.href = "{{prev_image.html_filename()}}";
location.href = "{{prev_image.html_filename}}";
{% endif %}
} else if (event.key == "ArrowRight") {
{% if next_image %}
location.href = "{{next_image.html_filename()}}";
location.href = "{{next_image.html_filename}}";
{% endif %}
}
}
@ -17,13 +17,13 @@
{% block content %}
<div id="photo">
<img src="{{image_path.display_filename()}}" />
<img src="{{image.screen_filename}}" />
</div>
<div id="nav">
<div>
{% if prev_image %}
<a href="{{prev_image.html_filename()}}">
<a href="{{prev_image.html_filename}}">
<i class="arrow arrow-left"></i>
</a>
{% endif %}
@ -35,7 +35,7 @@
</div>
<div>
{% if next_image %}
<a href="{{next_image.html_filename()}}">
<a href="{{next_image.html_filename}}">
<i class="arrow arrow-right"></i>
</a>
{% endif %}
@ -43,12 +43,12 @@
</div>
<div id="photo-description" class="caption">
{% if image_path.description %}
{{ image_path.description | safe }}
{% if image.description %}
{{ image.description | safe }}
{% endif %}
</div>
<div id="download">
<a href="../{{image_path.path.name}}">view full size</a>
<a href="../{{image.filename}}">view full size</a>
</div>
{% endblock %}

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

View file

@ -0,0 +1 @@
nested album

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

View file

@ -0,0 +1 @@
deeply nested album

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

View file

@ -0,0 +1 @@
mountains.jpg

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

View file

@ -0,0 +1 @@
album with a description

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

View file

@ -0,0 +1 @@
this is a moon

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

77
src/config.rs Normal file
View file

@ -0,0 +1,77 @@
use anyhow::Context;
use serde::{Deserialize, Serialize};
use std::fs;
use std::path::PathBuf;
#[derive(Serialize, Deserialize, Debug, PartialEq)]
#[serde(default)]
pub struct Config {
/// Tuple of how big thumbnails should be - (width, height)
pub thumbnail_size: (u32, u32),
/// Tuple of how big thumbnails should be - (width, height)
pub view_size: (u32, u32),
/// Directory inside the album that the site should be output to
pub output_dir: PathBuf,
}
impl Config {
pub fn from_album(path: PathBuf) -> anyhow::Result<Config> {
let config_path = path.join("photojawn.conf.yml");
let content = fs::read(&config_path).with_context(|| {
format!(
"Failed to read config from {}. Is this an album directory?",
config_path.display(),
)
})?;
let cfg = serde_yml::from_slice(&content)
.with_context(|| format!("Failed to parse config from {}", config_path.display()))?;
Ok(cfg)
}
}
impl Default for Config {
fn default() -> Self {
Self {
thumbnail_size: (256, 256),
view_size: (1024, 768),
output_dir: PathBuf::from("site"),
}
}
}
#[cfg(test)]
mod test {
use super::*;
use crate::skel::make_skeleton;
use mktemp::Temp;
#[test]
fn test_default() {
let c = Config::default();
assert_eq!(c.thumbnail_size, (256, 256));
assert_eq!(c.output_dir, PathBuf::from("site"));
}
#[test]
fn from_yaml() {
// Empty YAML gives full default values
let default_cfg = Config::default();
let cfg: Config = serde_yml::from_str("").unwrap();
assert_eq!(cfg, default_cfg);
// Default values for any unspecified fields
let cfg: Config = serde_yml::from_str("thumbnail_size: [1, 1]").unwrap();
assert_ne!(cfg, default_cfg);
assert_eq!(cfg.thumbnail_size, (1, 1));
assert_eq!(cfg.view_size, default_cfg.view_size);
}
#[test]
fn from_base_album() {
let tmpdir = Temp::new_dir().unwrap();
make_skeleton(&tmpdir).unwrap();
let cfg = Config::from_album(tmpdir.to_path_buf()).unwrap();
assert_eq!(cfg, Config::default());
}
}

412
src/generate.rs Normal file
View file

@ -0,0 +1,412 @@
pub mod album_dir;
mod image;
use crate::config::Config;
use crate::generate::image::Image;
use album_dir::AlbumDir;
use anyhow::{Context, anyhow};
use indicatif::ProgressBar;
use rayon::prelude::*;
use serde::Serialize;
use std::collections::VecDeque;
use std::collections::{HashMap, HashSet};
use std::env;
use std::ffi::OsStr;
use std::fs;
use std::path::{Path, PathBuf};
use std::sync::Mutex;
use tera::Tera;
const IMG_RESIZE_FILTER: ::image::imageops::FilterType = ::image::imageops::FilterType::Lanczos3;
/// Generate an album
///
/// `root_path` is a path to the root directory of the album. `full` if true will regenerate
/// everything, including images that already exist.
pub fn generate(root_path: &PathBuf, full: bool) -> anyhow::Result<PathBuf> {
log::debug!("Generating album in {}", root_path.display());
let config = Config::from_album(root_path.to_path_buf())?;
let orig_path = env::current_dir()?;
// Jump into the root path so that all paths are relative to the root of the album
env::set_current_dir(root_path)?;
let album = AlbumDir::try_from(root_path)?;
fs::create_dir_all(&config.output_dir)?;
copy_static(&config)?;
generate_images(&config, &album, full)?;
generate_html(&config, &album)?;
env::set_current_dir(orig_path)?;
Ok(root_path.join(config.output_dir))
}
fn copy_static(config: &Config) -> anyhow::Result<()> {
let dst = &config.output_dir.join("static");
log::info!("Copying static files from _static to {}", dst.display());
fs_extra::dir::copy(
"_static",
dst,
&fs_extra::dir::CopyOptions::new()
.content_only(true)
.overwrite(true),
)?;
Ok(())
}
fn generate_images(config: &Config, album: &AlbumDir, full: bool) -> anyhow::Result<()> {
let output_path = album.path.join(&config.output_dir);
// HashSet is to avoid dupliates, which could happen since we add covers in later
let mut all_images: HashSet<&Image> = album.iter_all_images().collect();
// also resize cover images, since we didn't count those as part of the image collections
let mut album_queue: VecDeque<&AlbumDir> = VecDeque::from([album]);
while let Some(album) = album_queue.pop_front() {
all_images.insert(&album.cover);
album_queue.extend(&album.children);
}
let path_locks: HashMap<&PathBuf, Mutex<&PathBuf>> = all_images
.iter()
.map(|i| (&i.path, Mutex::new(&i.path)))
.collect();
println!("Generating images...");
let progress = ProgressBar::new(all_images.len() as u64);
let result = all_images.par_iter().try_for_each(|img| {
// Get the lock on the path to make sure two threads don't try to generate the same image
// on disk.
let _path_lock = path_locks.get(&img.path).unwrap().lock().unwrap();
let full_size_path = output_path.join(&img.path);
if !full
&& full_size_path.exists()
&& fs::read(&full_size_path)? == fs::read(&full_size_path)?
{
log::info!("Skipping {}, already generated", img.path.display());
return Ok(());
}
log::info!(
"Copying original {} -> {}",
img.path.display(),
full_size_path.display()
);
fs::create_dir_all(full_size_path.parent().unwrap_or(Path::new("")))?;
if full_size_path.exists() {
fs::remove_file(&full_size_path)?;
}
fs::hard_link(&img.path, &full_size_path)
.with_context(|| format!("Error creating hard link at {}", full_size_path.display()))?;
let orig_image = ::image::open(&img.path)
.with_context(|| format!("Failed to read image {}", &img.path.display()))?;
let thumb_path = output_path.join(&img.thumb_path);
log::info!(
"Resizing {} -> {}",
img.path.display(),
thumb_path.display()
);
fs::create_dir_all(thumb_path.parent().unwrap_or(Path::new("")))?;
orig_image
.resize(
config.thumbnail_size.0,
config.thumbnail_size.1,
IMG_RESIZE_FILTER,
)
.save(&thumb_path)
.with_context(|| format!("Error saving {}", thumb_path.display()))?;
let screen_path = output_path.join(&img.screen_path);
log::info!(
"Resizing {} -> {}",
img.path.display(),
screen_path.display()
);
fs::create_dir_all(thumb_path.parent().unwrap_or(Path::new("")))?;
orig_image
.resize(config.view_size.0, config.view_size.1, IMG_RESIZE_FILTER)
.save(&screen_path)
.with_context(|| format!("Error saving {}", screen_path.display()))?;
progress.inc(1);
Ok(())
});
progress.finish();
result
}
fn generate_html(config: &Config, album: &AlbumDir) -> anyhow::Result<()> {
let output_path = album.path.join(&config.output_dir);
let tera = Tera::new(
album
.path
.join("_templates/*.html")
.to_str()
.ok_or(anyhow!("Album path {} is invalid", album.path.display()))?,
)?;
println!("Generating HTML...");
let mut dir_queue: VecDeque<&AlbumDir> = VecDeque::from([album]);
while let Some(album) = dir_queue.pop_front() {
let html_path = output_path.join(&album.path).join("index.html");
log::info!("Rendering album page {}", html_path.display());
let ctx = AlbumContext::try_from(album)?;
log::debug!("Album context: {ctx:?}");
fs::write(
output_path.join(&album.path).join("index.html"),
tera.render("album.html", &tera::Context::from_serialize(&ctx)?)?,
)?;
for child in album.children.iter() {
dir_queue.push_back(child);
}
for (pos, img) in album.images.iter().enumerate() {
let prev_image: Option<&Image> = match pos {
0 => None,
n => Some(&album.images[n - 1]),
};
let next_image: Option<&Image> = album.images.get(pos + 1);
// Find the path to the root by counting the parts of the path
// Start with 1 .. to get out of the slides dir
let mut path_to_root = PathBuf::from("..");
if let Some(parent) = img.path.parent() {
let mut parent = parent.to_path_buf();
while parent.pop() {
path_to_root.push("..");
}
}
log::info!("Rendering image page {}", img.html_path.display());
let ctx = SlideContext {
root_path: path_to_root,
image: img.clone(),
prev_image: prev_image.cloned(),
next_image: next_image.cloned(),
};
log::debug!("Image context: {ctx:?}");
fs::write(
output_path.join(&img.html_path),
tera.render("photo.html", &tera::Context::from_serialize(&ctx)?)?,
)?;
}
}
Ok(())
}
#[derive(Serialize, Debug)]
struct Breadcrumb {
path: PathBuf,
name: String,
}
/// A Tera context for album pages
#[derive(Serialize, Debug)]
struct AlbumContext {
/// Path required to get back to the root album
root_path: PathBuf,
name: String,
description: String,
images: Vec<Image>,
/// Path to the cover image thumbnail within /slides/, relative to the album dir. Used when
/// linking to an album from a parent album
cover_thumbnail_path: PathBuf,
/// list of:
/// - relative dir to walk up to root, e.g. "../../.."
/// - dir name
breadcrumbs: Vec<Breadcrumb>,
/// Immediate children of this album
children: Vec<AlbumContext>,
}
impl TryFrom<&AlbumDir> for AlbumContext {
type Error = anyhow::Error;
fn try_from(album: &AlbumDir) -> anyhow::Result<Self> {
let name: String = match album.path.file_name() {
Some(n) => n.to_string_lossy().to_string(),
None => String::new(),
};
// Build breadcrumbs
let mut breadcrumbs: Vec<Breadcrumb> = Vec::new();
{
let mut album_path = album.path.clone();
let mut relpath: PathBuf = PathBuf::new();
while album_path.pop() {
let filename: &OsStr = album_path.file_name().unwrap_or(OsStr::new("Home"));
relpath.push("..");
breadcrumbs.push(Breadcrumb {
path: relpath.clone(),
name: filename.to_string_lossy().to_string(),
});
}
}
breadcrumbs.reverse();
log::debug!("Crumbs for {}: {breadcrumbs:?}", album.path.display());
// The first breadcrumb path is the relative path to the root album
let root_path = match breadcrumbs.is_empty() {
false => breadcrumbs[0].path.clone(),
true => PathBuf::new(),
};
// Make the path to the thumbnail relative to the album that we're in
let cover_thumbnail_path: PathBuf;
if let Some(parent) = album.path.parent() {
cover_thumbnail_path = album.cover.thumb_path.strip_prefix(parent)?.to_path_buf()
} else {
cover_thumbnail_path = album
.cover
.thumb_path
.strip_prefix(&album.path)?
.to_path_buf()
};
let children: Vec<AlbumContext> = album
.children
.iter()
.map(AlbumContext::try_from)
.collect::<anyhow::Result<Vec<AlbumContext>>>()?;
let images: Vec<Image> = album.images.clone();
Ok(AlbumContext {
root_path,
name,
description: album.description.clone(),
breadcrumbs,
images,
children,
cover_thumbnail_path,
})
}
}
/// A Tera context for slide (individual image) pages
#[derive(Serialize, Debug)]
struct SlideContext {
// Path required to get back to the root album
root_path: PathBuf,
image: Image,
prev_image: Option<Image>,
next_image: Option<Image>,
}
#[cfg(test)]
mod tests {
use super::generate;
use std::collections::{HashSet, VecDeque};
use std::ffi::OsStr;
use std::path::{Path, PathBuf};
use crate::test_util::{init, make_test_album};
#[test]
/// Test that the generate function creates a rendered site as we expect it
fn test_generate() {
init();
let album_path = make_test_album();
let output_path = generate(&album_path.to_path_buf(), false).unwrap();
check_album(output_path).unwrap();
}
/// Does basic sanity checks on an output album
fn check_album(root_path: PathBuf) -> anyhow::Result<()> {
log::debug!("Checking album dir {}", root_path.display());
// The _static dir should have gotten copied into <output>/static
assert!(root_path.join("static/index.css").exists());
let mut dirs: VecDeque<PathBuf> = VecDeque::from([root_path.clone()]);
while let Some(dir) = dirs.pop_front() {
let mut files: Vec<PathBuf> = Vec::new();
for entry in dir.read_dir().unwrap() {
let path = entry.unwrap().path();
if path.is_dir()
&& !path.ends_with(Path::new("slides"))
&& path.file_name() != Some(OsStr::new("static"))
{
dirs.push_back(path.clone());
} else if path.is_file() {
files.push(path);
}
}
// There should be an index.html
let index_path = dir.join("index.html");
assert!(
index_path.exists(),
"Expected {} to exist",
index_path.display()
);
// There should be a slides dir
let slides_path = dir.join("slides");
assert!(
slides_path.is_dir(),
"Expected {} to be a dir",
slides_path.display()
);
// No two images should have the same path
let image_set: HashSet<&PathBuf> = files.iter().collect();
assert_eq!(image_set.len(), files.len());
// For each image in the album (including the cover), in slides there should be a:
// - <image>.html
// - <image>.screen.<ext>
// - <image>.thumb.<ext>
for file in &files {
if let Some(ext) = file.extension() {
if ext != "jpg" {
continue;
}
}
log::debug!("Checking associated files for {}", file.display());
if !file
.file_name()
.unwrap()
.to_str()
.unwrap()
.starts_with("cover")
{
let html_path =
slides_path.join(&file.with_extension("html").file_name().unwrap());
assert!(
html_path.exists(),
"Expected {} to exist",
html_path.display()
);
}
for ext in ["screen.jpg", "thumb.jpg"] {
let img_path = slides_path.join(file.with_extension(ext).file_name().unwrap());
assert!(
img_path.exists(),
"Expected {} to exist",
img_path.display()
);
}
}
// There shouldn't be any .txt or .md files hanging around
for file in &files {
if let Some(ext) = file.extension() {
assert_ne!(ext, "md");
assert_ne!(ext, "txt");
}
}
}
Ok(())
}
}

230
src/generate/album_dir.rs Normal file
View file

@ -0,0 +1,230 @@
use crate::generate::image::Image;
use anyhow::anyhow;
use image::ImageReader;
use serde::Serialize;
use std::fs;
use std::path::{Path, PathBuf};
use std::slice::Iter;
/// An album directory, which has images and possibly child albums
#[derive(Clone, Serialize)]
pub struct AlbumDir {
pub path: PathBuf,
pub images: Vec<Image>,
pub cover: Image,
pub children: Vec<AlbumDir>,
pub description: String,
}
impl AlbumDir {
/// Returns an iterator over all images in the album and subalbums
pub fn iter_all_images(&self) -> AlbumImageIter {
AlbumImageIter::new(self)
}
/// Create an AlbumDir recursively from a path. The root path is so that we can make every path
/// relative to the root.
fn from_path(p: &Path, root: &Path) -> anyhow::Result<Self> {
let mut images = vec![];
let mut cover: Option<Image> = None;
let mut children = vec![];
let mut description = String::new();
for entry in p.read_dir()? {
let entry_path = entry?.path().to_path_buf();
if entry_path.is_file() {
if let Some(filename) = entry_path.file_name() {
if filename == "description.txt" {
description = fs::read_to_string(entry_path)?;
} else if filename == "description.md" {
log::debug!("Loading Markdown from {}", entry_path.display());
let contents = fs::read_to_string(&entry_path)?;
let parser = pulldown_cmark::Parser::new(&contents);
pulldown_cmark::html::push_html(&mut description, parser);
} else {
if filename.to_string_lossy().starts_with("cover") {
log::debug!("Found explicit cover for {}", p.display());
cover = Some(Image::new(
entry_path.strip_prefix(root)?.to_path_buf(),
String::new(),
)?);
// Don't include the cover in the set of images
continue;
}
let reader = ImageReader::open(&entry_path)?.with_guessed_format()?;
if reader.format().is_some() {
// Found an image
let mut description = String::new();
// Read in any associated description file
if entry_path.with_extension("txt").exists() {
description = fs::read_to_string(entry_path.with_extension("txt"))?;
} else if entry_path.with_extension("md").exists() {
log::debug!(
"Loading Markdown from {}",
entry_path.with_extension("md").display()
);
let contents = fs::read_to_string(entry_path.with_extension("md"))?;
let parser = pulldown_cmark::Parser::new(&contents);
pulldown_cmark::html::push_html(&mut description, parser);
}
images.push(Image::new(
entry_path.strip_prefix(root)?.to_path_buf(),
description,
)?);
}
}
}
} else if entry_path.is_dir() {
if let Some(dirname) = entry_path.file_name().and_then(|n| n.to_str()) {
if dirname.starts_with("_") {
// Likely a templates or static dir
continue;
} else if dirname == "site" {
// Is a generated site dir, don't descend into it
continue;
} else if dirname == "slides" {
continue;
}
children.push(AlbumDir::from_path(&entry_path, root)?);
}
}
}
children.sort_by_key(|c| c.path.clone());
images.sort_by_key(|i| i.path.clone());
// Find a cover image if we didn't have an explicit one. Either the first image, or the
// first image from the first album that has a cover.
if cover.is_none() {
if !images.is_empty() {
cover = Some(images[0].clone());
} else {
// Find a cover image from one of the children
if !children.is_empty() {
cover = Some(children[0].cover.clone());
}
}
}
let cover = cover.ok_or(anyhow!("Could not find a cover image for {}", p.display()))?;
log::debug!("Cover for {} is {}", p.display(), cover.path.display());
Ok(AlbumDir {
path: p.strip_prefix(root)?.to_path_buf(),
images,
cover,
children,
description,
})
}
}
impl TryFrom<&PathBuf> for AlbumDir {
type Error = anyhow::Error;
fn try_from(p: &PathBuf) -> anyhow::Result<AlbumDir> {
AlbumDir::from_path(p, p)
}
}
/// An iterator which walks through all of the images in an album, and its sub-albums
pub struct AlbumImageIter<'a> {
image_iter: Box<dyn Iterator<Item = &'a Image> + 'a>,
children_iter: Iter<'a, AlbumDir>,
}
impl<'a> AlbumImageIter<'a> {
fn new(ad: &'a AlbumDir) -> Self {
Self {
image_iter: Box::new(ad.images.iter()),
children_iter: ad.children.iter(),
}
}
}
impl<'a> Iterator for AlbumImageIter<'a> {
type Item = &'a Image;
fn next(&mut self) -> Option<Self::Item> {
if let Some(img) = self.image_iter.next() {
return Some(img);
}
for album in self.children_iter.by_ref() {
// Set the child album as the current image iterator
self.image_iter = Box::new(album.iter_all_images());
// If we found a child album with an image, return the image. Otherwise we'll keep
// iterating over children.
if let Some(i) = self.image_iter.next() {
return Some(i);
}
}
None
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::HashSet;
#[test]
fn basic_album_iter() {
let mut ad = AlbumDir {
path: "".into(),
description: "".to_string(),
cover: Image::new("foo".into(), "".to_string()).unwrap(),
images: vec![
Image::new("foo".into(), "".to_string()).unwrap(),
Image::new("bar".into(), "".to_string()).unwrap(),
],
children: vec![],
};
// A child album with some images
ad.children.push(AlbumDir {
path: "subdir".into(),
description: "".to_string(),
cover: Image::new("subdir/foo".into(), "".to_string()).unwrap(),
images: vec![
Image::new("subdir/foo".into(), "".to_string()).unwrap(),
Image::new("subdir/bar".into(), "".to_string()).unwrap(),
],
children: vec![AlbumDir {
path: "subdir/deeper_subdir".into(),
description: "".to_string(),
cover: Image::new("subdir/deeper_subdir/image.jpg".into(), "".to_string()).unwrap(),
images: vec![
Image::new("subdir/deeper_subdir/image.jpg".into(), String::new()).unwrap(),
],
children: vec![],
}],
});
// A child album with no images
ad.children.push(AlbumDir {
description: "".to_string(),
cover: Image::new("blah".into(), "".to_string()).unwrap(),
path: "another_subdir".into(),
images: vec![],
children: vec![],
});
let imgs: HashSet<&str> = ad
.iter_all_images()
.map(|i| i.path.to_str().unwrap())
.collect();
let expected: HashSet<&str> = HashSet::from([
"foo",
"bar",
"subdir/foo",
"subdir/bar",
"subdir/deeper_subdir/image.jpg",
]);
assert_eq!(imgs, expected);
}
}

108
src/generate/image.rs Normal file
View file

@ -0,0 +1,108 @@
use anyhow::anyhow;
use serde::Serialize;
use std::ffi::OsString;
use std::path::{Path, PathBuf};
#[derive(Clone, Debug, Hash, PartialEq, Eq, Serialize)]
pub struct Image {
/// Path to the image, relative to the root album
pub path: PathBuf,
pub filename: String,
/// Text description of the image which is displayed below it on the HTML page
pub description: String,
pub thumb_filename: String,
pub thumb_path: PathBuf,
pub screen_filename: String,
pub screen_path: PathBuf,
pub html_filename: String,
pub html_path: PathBuf,
}
impl Image {
pub fn new(path: PathBuf, description: String) -> anyhow::Result<Self> {
let filename = path
.file_name()
.ok_or(anyhow!(
"Image path {} is missing a filename",
path.display()
))?
.to_str()
.ok_or(anyhow!("Cannot convert {} to a string", path.display()))?
.to_string();
let thumb_filename = Self::slide_filename(&path, "thumb", true)?;
let thumb_path = Self::slide_path(&path, &thumb_filename);
let screen_filename = Self::slide_filename(&path, "screen", true)?;
let screen_path = Self::slide_path(&path, &screen_filename);
let html_filename = Self::slide_filename(&path, "html", false)?;
let html_path = Self::slide_path(&path, &html_filename);
Ok(Image {
path,
description,
filename,
thumb_filename,
thumb_path,
screen_filename,
screen_path,
html_filename,
html_path,
})
}
/// Returns the filename for a given slide type. For example if ext = "thumb" and the current
/// filename is "blah.jpg" this will return "blah.thumb.jpg". If keep_ext if false, it would
/// return "blah.thumb"
fn slide_filename(path: &Path, ext: &str, keep_ext: bool) -> anyhow::Result<String> {
let mut new_ext: OsString = ext.into();
if keep_ext {
if let Some(e) = path.extension() {
new_ext = OsString::from(
ext.to_string()
+ "."
+ e.to_str().ok_or(anyhow!(
"Image {} extension is not valid UTF-8",
path.display()
))?,
)
}
}
let new_path = path.with_extension(new_ext);
let new_name = new_path
.file_name()
.ok_or(anyhow!("Image {} missing a file name", path.display()))?
.to_str()
.ok_or(anyhow!("Unable to convert {} to a string", path.display()))?
.to_string();
Ok(new_name)
}
/// Returns the path to the file in the slides dir given the path to the original image
fn slide_path(path: &Path, file_name: &str) -> PathBuf {
let mut new_path = path.to_path_buf();
new_path.pop();
new_path.push("slides");
new_path.push(file_name);
new_path
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn image_paths() {
let img = Image::new(PathBuf::from("foo/bar/image.jpg"), String::new()).unwrap();
assert_eq!(
img.thumb_path,
PathBuf::from("foo/bar/slides/image.thumb.jpg")
);
assert_eq!(
img.screen_path,
PathBuf::from("foo/bar/slides/image.screen.jpg")
);
}
}

7
src/lib.rs Normal file
View file

@ -0,0 +1,7 @@
pub(crate) mod config;
pub mod generate;
pub mod reorganize;
pub mod skel;
#[cfg(test)]
pub(crate) mod test_util;

64
src/main.rs Normal file
View file

@ -0,0 +1,64 @@
use clap::{Parser, Subcommand};
use photojawn::generate::generate;
use photojawn::reorganize::reorganize;
use photojawn::skel::make_skeleton;
use std::path::Path;
fn main() -> anyhow::Result<()> {
env_logger::init();
let cli = Cli::parse();
let album_path = Path::new(&cli.album_path).canonicalize()?;
match cli.subcommand {
Commands::Init {} => {
make_skeleton(&album_path)?;
println!("Album created in {}", album_path.display());
}
Commands::Generate { full } => {
let path = generate(&album_path.to_path_buf(), full)?;
println!("Album site generated in {}", path.display());
}
Commands::Reorganize { path, dry_run } => {
reorganize(Path::new(&path), dry_run)?;
}
}
Ok(())
}
#[derive(Parser)]
#[command(version, about, long_about = None)]
struct Cli {
/// Path to the album
#[arg(long, default_value = ".")]
album_path: String,
#[command(subcommand)]
subcommand: Commands,
}
#[derive(Subcommand)]
enum Commands {
/// Initialize a new Photojawn album directory
Init {},
/// Generates a photo album
Generate {
/// Regenerate everything, including images that have already been generated
#[arg(long)]
full: bool,
},
/// Reorganize photos in an album by date
Reorganize {
/// Directory of images you want to reorganize. Only image files will be moved.
///
/// The new image filenames will be the date and time taken, followed by the original
/// filename. For example:
/// original_filename.jpg -> YYYYMMDD_HHSS_original_filename.jpg
#[arg()]
path: String,
/// Don't actually reorganize, just say what renames would happen
#[arg(long)]
dry_run: bool,
},
}

243
src/reorganize.rs Normal file
View file

@ -0,0 +1,243 @@
use anyhow::{anyhow, Context};
use image::ImageReader;
use std::ffi::OsStr;
use std::fs::{rename, File};
use std::io::BufReader;
use std::path::{Path, PathBuf};
use std::str::from_utf8;
use thiserror::Error;
use time::macros::format_description;
use time::{OffsetDateTime, PrimitiveDateTime, UtcDateTime};
#[derive(Error, Debug)]
pub enum OrganizeError {
#[error("These files are not supported, unable to parse EXIF data: {0:?}")]
ExifNotSupported(Vec<PathBuf>),
#[error("File {0} is missing an EXIF DateTimeOriginal field")]
ExifNoDateTime(PathBuf),
}
pub fn reorganize(dir: &Path, dry_run: bool) -> anyhow::Result<()> {
let renames = get_renames(dir)?;
if renames.is_empty() {
println!("Nothing to rename");
return Ok(());
}
// Either do the renames, or if dry-run print what the names would be
if dry_run {
for (src, dst) in renames {
println!("{} -> {}", src.display(), dst.display());
}
println!("Would have renamed the above files");
} else {
for (src, dst) in renames {
println!("{} -> {}", src.display(), dst.display());
rename(&src, &dst).with_context(|| {
format!("Failed to rename {} to {}", src.display(), dst.display())
})?;
}
}
Ok(())
}
/// Returns a vec of tuples of all the renames that need to happen in a directory
fn get_renames(dir: &Path) -> anyhow::Result<Vec<(PathBuf, PathBuf)>> {
let mut renames: Vec<(PathBuf, PathBuf)> = Vec::new();
// Run through all the images and figure out new names for them
for entry in dir.read_dir()? {
let entry = entry?;
if !entry.path().is_file() {
continue;
}
// Only bother with image files, because those are the only hope for EXIF
let is_image: bool = ImageReader::open(entry.path())?
.with_guessed_format()?
.format()
.is_some();
let is_cover: bool = entry
.path()
.file_name()
.is_some_and(|n| n.to_string_lossy().starts_with("cover"));
if is_image && !is_cover {
// TODO: Should we just skip over images with no EXIF data? Find datetime some other
// way?
let Ok(dt) = get_exif_datetime(entry.path()) else {
log::warn!(
"Unable to read datetime from EXIF for {}",
entry.path().display()
);
continue;
};
let orig_filename = entry
.path()
.file_name()
.unwrap_or(OsStr::new(""))
.to_string_lossy()
.into_owned();
let ext = entry
.path()
.extension()
.ok_or(anyhow!(
"{} is missing an extension",
entry.path().display()
))?
.to_string_lossy()
.to_string();
let new_filename_base = dt.format(format_description!(
"[year][month][day]_[hour][minute][second]_"
))?;
// Renaming an already-renamed file should be a no-op
if orig_filename.starts_with(&new_filename_base) {
log::info!("{orig_filename} looks like it was already renamed, skiping");
continue;
}
let new_path = entry
.path()
.with_file_name(new_filename_base + &orig_filename)
.with_extension(ext);
renames.push((entry.path(), new_path.clone()));
// Check for files associated with this image and set them up to be renamed too, like
// description files that end with .txt or .md
for ext in ["txt", "md"] {
let side_file_path = entry.path().with_extension(ext);
if side_file_path.exists() {
let new_side_file_path = new_path.with_extension(ext);
renames.push((side_file_path, new_side_file_path));
}
}
}
}
// Sort renames by the destination
renames.sort_by_key(|(_, dst)| dst.clone());
Ok(renames)
}
/// Tries to figure out the datetime that the image was created from EXIF metadata
fn get_exif_datetime(path: PathBuf) -> anyhow::Result<UtcDateTime> {
let format_with_offset = format_description!(
"[year]:[month]:[day] [hour]:[minute]:[second][offset_hour]:[offset_minute]"
);
let format_without_offset =
format_description!(version = 2, "[year]:[month]:[day] [hour]:[minute]:[second]");
let file = File::open(&path).with_context(|| format!("Couldn't open {}", path.display()))?;
let mut bufreader = BufReader::new(file);
let exif = exif::Reader::new()
.read_from_container(&mut bufreader)
.with_context(|| format!("Couldn't read EXIF data from {}", path.display()))?;
let field = exif
.get_field(exif::Tag::DateTimeOriginal, exif::In::PRIMARY)
.ok_or(OrganizeError::ExifNoDateTime(path.clone()))?;
let dt: UtcDateTime = match &field.value {
exif::Value::Ascii(v) => {
let s = from_utf8(&v[0])?;
log::debug!("Date string from file: {s}");
match OffsetDateTime::parse(s, format_with_offset) {
Ok(v) => v.to_utc(),
Err(_) => PrimitiveDateTime::parse(s, format_without_offset)?.as_utc(),
}
}
_ => return Err(OrganizeError::ExifNoDateTime(path).into()),
};
Ok(dt)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::test_util::{init, make_test_album};
use time::{Date, Month, Time};
#[test]
/// Make sure we can get the datetime from one of our test photos
fn basic_datetime_read() {
init();
let dt = get_exif_datetime("resources/test_album/moon.jpg".into()).unwrap();
log::info!("Got dt: {dt}");
assert_eq!(
dt,
UtcDateTime::new(
Date::from_calendar_date(1970, Month::January, 1).unwrap(),
Time::from_hms(13, 37, 0).unwrap(),
)
)
}
#[test]
fn exif_datetime_missing() {
init();
let result = get_exif_datetime("resources/test_album/mountains.jpg".into());
assert!(result.is_err());
//result.unwrap();
}
#[test]
fn test_basic_renames() {
init();
let tmp_album_dir = make_test_album();
let dir = tmp_album_dir.join("with_description");
log::debug!("Getting renames for {}", dir.display());
let renames = get_renames(&dir).unwrap();
assert_eq!(
renames,
vec![
(dir.join("moon.jpg"), dir.join("19700102_133700_moon.jpg")),
(dir.join("moon.txt"), dir.join("19700102_133700_moon.txt")),
(
dir.join("mountains.jpg"),
dir.join("19700103_133700_mountains.jpg")
),
]
);
}
#[test]
/// get_renames() should ignore other stuff in the directory
fn test_other_junk() {
init();
let tmp_album_dir = make_test_album();
let renames = get_renames(&tmp_album_dir).unwrap();
// No mountain.jpg since it doesn't have EXIF data
assert_eq!(
renames,
vec![(
tmp_album_dir.join("moon.jpg"),
tmp_album_dir.join("19700101_133700_moon.jpg")
)]
);
}
#[test]
/// The rename function will prepend date and time to the original filenames. If we do it a
/// second time, it should be a no-op instead of continuing to prepend date and time.
fn test_rerename() {
let tmp_album_dir = make_test_album();
let dir = tmp_album_dir.join("with_description");
reorganize(&dir, false).unwrap();
let renames = get_renames(&dir).unwrap();
assert_eq!(renames, Vec::new());
}
}

92
src/skel.rs Normal file
View file

@ -0,0 +1,92 @@
use std::collections::HashMap;
use std::fs;
use std::io;
use std::path::{Path, PathBuf};
use thiserror::Error;
#[derive(Error, Debug)]
pub enum InitError {
#[error("Album directory already initialized - contains a {0}")]
AlreadyInitialized(PathBuf),
#[error(transparent)]
IoError(#[from] io::Error),
}
/// Creates a new album directory and creates basic versions
pub fn make_skeleton(album_path: &Path) -> Result<(), InitError> {
let files = HashMap::from([
(
album_path.join("photojawn.conf.yml"),
include_bytes!("../resources/skel/photojawn.conf.yml").as_slice(),
),
(
album_path.join("_static/index.css"),
include_bytes!("../resources/skel/_static/index.css").as_slice(),
),
(
album_path.join("_templates/base.html"),
include_bytes!("../resources/skel/_templates/base.html").as_slice(),
),
(
album_path.join("_templates/album.html"),
include_bytes!("../resources/skel/_templates/album.html").as_slice(),
),
(
album_path.join("_templates/photo.html"),
include_bytes!("../resources/skel/_templates/photo.html").as_slice(),
),
]);
// Bail if any of the files we would create exist
for path in files.keys() {
if path.exists() {
return Err(InitError::AlreadyInitialized(path.to_path_buf()));
}
}
fs::create_dir_all(album_path)?;
for (path, contents) in files.iter() {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
fs::write(path, contents)?;
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use mktemp::Temp;
#[test]
fn not_exist() {
let tmpdir = Temp::new_dir().unwrap();
make_skeleton(&tmpdir).unwrap();
assert!(tmpdir.join("photojawn.conf.yml").exists());
assert!(tmpdir.join("_static/index.css").exists());
assert!(tmpdir.join("_templates/base.html").exists());
}
#[test]
fn config_exists() {
let tmpdir = Temp::new_dir().unwrap();
fs::write(tmpdir.join("photojawn.conf.yml"), "some: config").unwrap();
let res = make_skeleton(&tmpdir);
assert!(res.is_err());
}
#[test]
fn dir_exists_no_config() {
let tmpdir = Temp::new_dir().unwrap();
fs::create_dir(tmpdir.join("_templates")).unwrap();
fs::write(tmpdir.join("_templates/base.html"), "some template").unwrap();
let res = make_skeleton(&tmpdir);
assert!(res.is_err());
// Make sure it didn't clobber our template
let contents = fs::read(tmpdir.join("_templates/base.html")).unwrap();
assert_eq!(contents, "some template".as_bytes());
}
}

25
src/test_util.rs Normal file
View file

@ -0,0 +1,25 @@
use crate::skel::make_skeleton;
use mktemp::Temp;
use std::path::Path;
pub fn init() {
let _ = env_logger::builder().is_test(true).try_init();
}
/// Copies the test album to a tempdir and returns the path to it. Returns a Temp object which
/// cleans up the directory on drop, so make sure to persist the variable until you're done with it
pub fn make_test_album() -> Temp {
let tmpdir = Temp::new_dir().unwrap();
let source_path = Path::new("resources/test_album").canonicalize().unwrap();
log::info!("Creating test album in {}", tmpdir.display());
make_skeleton(&tmpdir.to_path_buf()).unwrap();
fs_extra::dir::copy(
&source_path,
&tmpdir,
&fs_extra::dir::CopyOptions::new().content_only(true),
)
.unwrap();
tmpdir
}

View file