Compare commits
10 commits
04586c8c45
...
d297110d88
Author | SHA1 | Date | |
---|---|---|---|
d297110d88 | |||
e2338df37c | |||
4d45aa5296 | |||
f7998cd708 | |||
cd47a82ecf | |||
6cd2e52408 | |||
f60a14ae21 | |||
c93f1997fc | |||
c08c5609ca | |||
acd736c970 |
11 changed files with 130 additions and 49 deletions
19
Makefile
19
Makefile
|
@ -7,6 +7,9 @@ ci: init lint test
|
||||||
# Final pre-flight checks then deploy everywhere!
|
# Final pre-flight checks then deploy everywhere!
|
||||||
shipit: all build staging prod
|
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
|
||||||
|
|
||||||
|
|
||||||
init:
|
init:
|
||||||
poetry install
|
poetry install
|
||||||
|
@ -33,11 +36,17 @@ test-fast:
|
||||||
test-watch:
|
test-watch:
|
||||||
find . -name '*py' -or -name '*html' -or -name poetry.lock | entr -r -c make test-fast
|
find . -name '*py' -or -name '*html' -or -name poetry.lock | entr -r -c make test-fast
|
||||||
|
|
||||||
run:
|
clean:
|
||||||
podman-compose up
|
rm -rv dist || true
|
||||||
|
|
||||||
build:
|
docker:
|
||||||
podman build -t nickpegg/photojawn . --build-arg GIT_COMMIT=$(shell git rev-parse --short HEAD)
|
podman build -t nickpegg/photojawn . --build-arg GIT_COMMIT=$(shell git rev-parse --short HEAD)
|
||||||
|
|
||||||
clean:
|
dist:
|
||||||
podman-compose down --rmi all
|
poetry build
|
||||||
|
poetry run pex --project . -o dist/photojawn -c photojawn --scie eager $(foreach plat,$(scie_platforms), --scie-platform $(plat))
|
||||||
|
|
||||||
|
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))
|
||||||
|
|
11
README.md
11
README.md
|
@ -10,7 +10,16 @@ It's everything I need and nothing I don't.
|
||||||
|
|
||||||
## Getting Started
|
## Getting Started
|
||||||
|
|
||||||
TODO: Installation instructions lol
|
### Installation
|
||||||
|
|
||||||
|
Photojawn requires at least Python 3.12. If you don't know what version you
|
||||||
|
have, you can check by running `python -V`.
|
||||||
|
|
||||||
|
1. Head on over to the [releases](https://github.com/nickpegg/photojawn/releases) page
|
||||||
|
2. If you have Python >=3.12, you can install using the wheel (`.whl` file)
|
||||||
|
a. To install, run `pip install photojawn-<version>-py3-none-any.whl`
|
||||||
|
3. If you don't have Python >=3.12, you can download one of the standalone binaries depending on your OS and architecture, e.g. `photojawn-linux-x86_64`.
|
||||||
|
|
||||||
|
|
||||||
### Initialization
|
### Initialization
|
||||||
|
|
||||||
|
|
|
@ -1,4 +1,5 @@
|
||||||
import logging
|
import logging
|
||||||
|
import sys
|
||||||
from argparse import ArgumentParser, Namespace
|
from argparse import ArgumentParser, Namespace
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
@ -15,16 +16,17 @@ def main() -> None:
|
||||||
setup_logging(args.logging)
|
setup_logging(args.logging)
|
||||||
|
|
||||||
# Load config from file if it exists
|
# Load config from file if it exists
|
||||||
conf_path = Path(args.album_path) / Path(args.config)
|
if hasattr(args, "album_path"):
|
||||||
if conf_path.exists():
|
conf_path = Path(args.album_path) / Path(args.config)
|
||||||
logger.debug(f"Reading config from {conf_path}")
|
if conf_path.exists():
|
||||||
config = Config.from_yaml(conf_path.read_bytes())
|
logger.debug(f"Reading config from {conf_path}")
|
||||||
elif args.action != "init":
|
config = Config.from_yaml(conf_path.read_bytes())
|
||||||
logger.error(
|
elif args.action != "init":
|
||||||
f"No config file found at {conf_path}. If this is a new photo directory, "
|
logger.error(
|
||||||
"please run `photojawn init` in there first."
|
f"No config file found at {conf_path}. If this is a new photo directory, "
|
||||||
)
|
"please run `photojawn init` in there first."
|
||||||
return
|
)
|
||||||
|
return
|
||||||
|
|
||||||
# Call the subcommand function
|
# Call the subcommand function
|
||||||
match args.action:
|
match args.action:
|
||||||
|
@ -98,7 +100,12 @@ def parse_args() -> Namespace:
|
||||||
help="Path to the main photos directory",
|
help="Path to the main photos directory",
|
||||||
)
|
)
|
||||||
|
|
||||||
return parser.parse_args()
|
args = parser.parse_args()
|
||||||
|
if not hasattr(args, "action"):
|
||||||
|
parser.print_help()
|
||||||
|
sys.exit(0)
|
||||||
|
|
||||||
|
return args
|
||||||
|
|
||||||
|
|
||||||
########################################
|
########################################
|
||||||
|
|
|
@ -16,6 +16,9 @@ class Config:
|
||||||
# Size of the image when looking at the standalone image page
|
# Size of the image when looking at the standalone image page
|
||||||
view_size: tuple[int, int] = (1920, 1080)
|
view_size: tuple[int, int] = (1920, 1080)
|
||||||
|
|
||||||
|
# Directory inside the photo directory to output the site to
|
||||||
|
output_dir: str = "site"
|
||||||
|
|
||||||
# Quick mode:
|
# Quick mode:
|
||||||
# - Don't regenerate thumbnails if they already exist
|
# - Don't regenerate thumbnails if they already exist
|
||||||
quick: bool = False
|
quick: bool = False
|
||||||
|
@ -29,6 +32,8 @@ class Config:
|
||||||
|
|
||||||
for key, val in data.items():
|
for key, val in data.items():
|
||||||
match key:
|
match key:
|
||||||
|
case "output_dir":
|
||||||
|
conf.output_dir = val
|
||||||
case "thumnail_size":
|
case "thumnail_size":
|
||||||
conf.thumbnail_size = tuple(val)
|
conf.thumbnail_size = tuple(val)
|
||||||
case "view_size":
|
case "view_size":
|
||||||
|
|
|
@ -1,7 +1,8 @@
|
||||||
import logging
|
import logging
|
||||||
|
import shutil
|
||||||
|
import os
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from pprint import pformat
|
|
||||||
from typing import Iterator, Optional
|
from typing import Iterator, Optional
|
||||||
|
|
||||||
from jinja2 import Environment, FileSystemLoader, select_autoescape
|
from jinja2 import Environment, FileSystemLoader, select_autoescape
|
||||||
|
@ -17,7 +18,6 @@ logger = logging.getLogger(__name__)
|
||||||
@dataclass
|
@dataclass
|
||||||
class ImageDirectory:
|
class ImageDirectory:
|
||||||
path: Path
|
path: Path
|
||||||
rel_path: Path # Path relative to the root dir
|
|
||||||
children: list["ImageDirectory"]
|
children: list["ImageDirectory"]
|
||||||
images: list["ImagePath"]
|
images: list["ImagePath"]
|
||||||
is_root: bool = False
|
is_root: bool = False
|
||||||
|
@ -75,13 +75,22 @@ def generate(config: Config, album_path: Path) -> None:
|
||||||
"""
|
"""
|
||||||
Main generation function
|
Main generation function
|
||||||
"""
|
"""
|
||||||
root_dir = find_images(album_path)
|
# Change the working directory to the album_path so that all paths are relative to
|
||||||
logger.debug(pformat(root_dir))
|
# it when we find images. We need to do this because all the paths in HTML need to
|
||||||
generate_thumbnails(config, root_dir)
|
# 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)
|
generate_html(config, root_dir)
|
||||||
|
shutil.copytree("static", Path(config.output_dir) / "static")
|
||||||
|
|
||||||
|
os.chdir(orig_wd)
|
||||||
|
|
||||||
|
|
||||||
def find_images(root_path: Path) -> ImageDirectory:
|
def find_images(config: Config, root_path: Path) -> ImageDirectory:
|
||||||
"""
|
"""
|
||||||
Build up an ImageDirectory to track all of the directories and their images.
|
Build up an ImageDirectory to track all of the directories and their images.
|
||||||
|
|
||||||
|
@ -91,20 +100,17 @@ def find_images(root_path: Path) -> ImageDirectory:
|
||||||
# image_dirs keeps track of all directories we find with images in them, so we can
|
# image_dirs keeps track of all directories we find with images in them, so we can
|
||||||
# attach them as children to parent directories
|
# attach them as children to parent directories
|
||||||
image_dirs: dict[Path, ImageDirectory] = {
|
image_dirs: dict[Path, ImageDirectory] = {
|
||||||
root_path: ImageDirectory(
|
root_path: ImageDirectory(path=root_path, children=[], images=[], is_root=True)
|
||||||
path=root_path, rel_path=Path("."), children=[], images=[], is_root=True
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
for dirpath, dirnames, filenames in root_path.walk(top_down=False):
|
for dirpath, dirnames, filenames in root_path.walk(top_down=False):
|
||||||
if dirpath.name in {"slides", "_templates", "static"}:
|
if dirpath.name in {"slides", "_templates", "static", config.output_dir}:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
image_dir = image_dirs.get(
|
image_dir = image_dirs.get(
|
||||||
dirpath,
|
dirpath,
|
||||||
ImageDirectory(
|
ImageDirectory(
|
||||||
path=dirpath,
|
path=dirpath,
|
||||||
rel_path=dirpath.relative_to(root_path),
|
|
||||||
children=[],
|
children=[],
|
||||||
images=[],
|
images=[],
|
||||||
),
|
),
|
||||||
|
@ -141,8 +147,14 @@ def find_images(root_path: Path) -> ImageDirectory:
|
||||||
|
|
||||||
image_dir.images.append(ip)
|
image_dir.images.append(ip)
|
||||||
|
|
||||||
if image_dir.cover_path is None and len(image_dir.images) > 0:
|
if image_dir.cover_path is None:
|
||||||
image_dir.cover_path = image_dir.images[0]
|
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
|
image_dirs[image_dir.path] = image_dir
|
||||||
|
|
||||||
return image_dirs[root_path]
|
return image_dirs[root_path]
|
||||||
|
@ -159,20 +171,26 @@ def is_image(path: Path) -> bool:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
def generate_thumbnails(config: Config, root_dir: ImageDirectory) -> None:
|
def generate_images(config: Config, root_dir: ImageDirectory) -> None:
|
||||||
"""
|
"""
|
||||||
Find all of the images and generate thumbnails and on-screen versions
|
Find all of the images and generate various image sizes
|
||||||
"""
|
"""
|
||||||
# Include cover images here because we want thumbnails for all of them
|
# Include cover images here because we want thumbnails for all of them
|
||||||
|
|
||||||
all_images = root_dir.image_paths() + root_dir.cover_image_paths()
|
all_images = root_dir.image_paths() + root_dir.cover_image_paths()
|
||||||
for image_path in track(all_images, description="Making thumbnails..."):
|
|
||||||
|
for image_path in track(all_images, description="Making smaller images..."):
|
||||||
orig_img = Image.open(image_path.path)
|
orig_img = Image.open(image_path.path)
|
||||||
|
|
||||||
slides_path = image_path.path.parent / "slides"
|
slides_path = config.output_dir / image_path.path.parent / "slides"
|
||||||
slides_path.mkdir(exist_ok=True)
|
slides_path.mkdir(exist_ok=True, parents=True)
|
||||||
|
|
||||||
thumb_path = image_path.thumbnail_path()
|
# 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:
|
if not thumb_path.exists() or not config.quick:
|
||||||
thumb_img = orig_img.copy()
|
thumb_img = orig_img.copy()
|
||||||
thumb_img.thumbnail(config.thumbnail_size)
|
thumb_img.thumbnail(config.thumbnail_size)
|
||||||
|
@ -181,7 +199,7 @@ def generate_thumbnails(config: Config, root_dir: ImageDirectory) -> None:
|
||||||
f'Generated thumbnail size "{image_path.path}" -> "{thumb_path}"'
|
f'Generated thumbnail size "{image_path.path}" -> "{thumb_path}"'
|
||||||
)
|
)
|
||||||
|
|
||||||
screen_path = image_path.display_path()
|
screen_path = config.output_dir / image_path.display_path()
|
||||||
if not screen_path.exists() or not config.quick:
|
if not screen_path.exists() or not config.quick:
|
||||||
screen_img = orig_img.copy()
|
screen_img = orig_img.copy()
|
||||||
screen_img.thumbnail(config.view_size)
|
screen_img.thumbnail(config.view_size)
|
||||||
|
@ -207,8 +225,8 @@ def generate_html(config: Config, root_dir: ImageDirectory) -> None:
|
||||||
for album_dir in root_dir.walk():
|
for album_dir in root_dir.walk():
|
||||||
html_path = album_dir.path / "index.html"
|
html_path = album_dir.path / "index.html"
|
||||||
root_path = root_dir.path.relative_to(html_path.parent, walk_up=True)
|
root_path = root_dir.path.relative_to(html_path.parent, walk_up=True)
|
||||||
|
html_path = config.output_dir / html_path
|
||||||
|
|
||||||
# TODO build breadcrumbs here, (href, name)
|
|
||||||
breadcrumbs = []
|
breadcrumbs = []
|
||||||
if not album_dir.is_root:
|
if not album_dir.is_root:
|
||||||
crumb_pos = album_dir.path.parent
|
crumb_pos = album_dir.path.parent
|
||||||
|
@ -240,6 +258,7 @@ def generate_html(config: Config, root_dir: ImageDirectory) -> None:
|
||||||
|
|
||||||
html_path = image_path.html_path()
|
html_path = image_path.html_path()
|
||||||
root_path = root_dir.path.relative_to(html_path.parent, walk_up=True)
|
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)
|
html_path.parent.mkdir(exist_ok=True)
|
||||||
|
|
||||||
prev_image = None
|
prev_image = None
|
||||||
|
|
|
@ -9,20 +9,25 @@
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
/ {{album_dir.path.name}}
|
/ {{album_dir.path.name}}
|
||||||
</h1>
|
</h1>
|
||||||
|
<hr>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% if album_dir.description %}
|
{% if album_dir.description %}
|
||||||
|
<div class="caption">
|
||||||
{{ album_dir.description | safe }}
|
{{ album_dir.description | safe }}
|
||||||
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% if album_dir.children %}
|
{% if album_dir.children %}
|
||||||
|
<h2>Albums</h2>
|
||||||
<div id="album-children">
|
<div id="album-children">
|
||||||
{% for child in album_dir.children %}
|
{% for child in album_dir.children %}
|
||||||
<div class="album">
|
<div class="album">
|
||||||
<a href="{{child.path.name}}/">
|
<a href="{{child.path.name}}/">
|
||||||
<div>
|
<div>
|
||||||
{% if child.cover_path %}
|
{% if child.cover_path %}
|
||||||
<img src="{{child.path.name}}/slides/{{child.cover_path.thumbnail_filename()}}" />
|
<img
|
||||||
|
src="{{child.cover_path.path.parent.relative_to(album_dir.path)}}/slides/{{child.cover_path.thumbnail_filename()}}" />
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
|
|
|
@ -42,7 +42,7 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="photo-description">
|
<div id="photo-description" class="caption">
|
||||||
{% if image_path.description %}
|
{% if image_path.description %}
|
||||||
{{ image_path.description | safe }}
|
{{ image_path.description | safe }}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
|
@ -3,3 +3,7 @@ thumbnail_size: [256, 256]
|
||||||
|
|
||||||
# Max size of images when viewing a single one on screen
|
# Max size of images when viewing a single one on screen
|
||||||
view_size: [1024, 768]
|
view_size: [1024, 768]
|
||||||
|
|
||||||
|
# Directory where the generated site will be created in. All original images will be
|
||||||
|
# copied in to here with the same directory structure.
|
||||||
|
output_dir: "site"
|
||||||
|
|
|
@ -22,15 +22,14 @@ a {
|
||||||
|
|
||||||
#content {
|
#content {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
|
max-width: 1200px;
|
||||||
margin: 0.5em;
|
margin: 0.5em;
|
||||||
|
margin-left: auto;
|
||||||
|
margin-right: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
#content > * {
|
#content > * {
|
||||||
width: fit-content;
|
|
||||||
max-width: 1500px;
|
|
||||||
margin-top: 1em;
|
margin-top: 1em;
|
||||||
margin-left: auto;
|
|
||||||
margin-right: auto;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
ul {
|
ul {
|
||||||
|
@ -47,6 +46,9 @@ ul {
|
||||||
margin: 1em;
|
margin: 1em;
|
||||||
padding: 0.75em;
|
padding: 0.75em;
|
||||||
background-color: lightgrey;
|
background-color: lightgrey;
|
||||||
|
height: min-content;
|
||||||
|
border: thin solid black;
|
||||||
|
box-shadow: 0.25em 0.25em #ccc;
|
||||||
}
|
}
|
||||||
|
|
||||||
#album-photos {
|
#album-photos {
|
||||||
|
@ -81,6 +83,12 @@ ul {
|
||||||
height: auto;
|
height: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.caption {
|
||||||
|
max-width: 700px;
|
||||||
|
margin-left: auto;
|
||||||
|
margin-right: auto;
|
||||||
|
}
|
||||||
|
|
||||||
.arrow {
|
.arrow {
|
||||||
border: solid black;
|
border: solid black;
|
||||||
border-width: 0 3px 3px 0;
|
border-width: 0 3px 3px 0;
|
||||||
|
|
18
poetry.lock
generated
18
poetry.lock
generated
|
@ -290,6 +290,20 @@ files = [
|
||||||
{file = "packaging-24.1.tar.gz", hash = "sha256:026ed72c8ed3fcce5bf8950572258698927fd1dbda10a5e981cdf0ac37f4f002"},
|
{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]]
|
[[package]]
|
||||||
name = "pillow"
|
name = "pillow"
|
||||||
version = "10.4.0"
|
version = "10.4.0"
|
||||||
|
@ -590,5 +604,5 @@ files = [
|
||||||
|
|
||||||
[metadata]
|
[metadata]
|
||||||
lock-version = "2.0"
|
lock-version = "2.0"
|
||||||
python-versions = "^3.12"
|
python-versions = "^3.12,<3.14"
|
||||||
content-hash = "3f22f947559bd9d54a838d51953828baa88e2485fb90c33931359511805cacc9"
|
content-hash = "9ec27256e1d8988f06634a217388d357a211f6120d6ade104fdd87182b18279c"
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
[tool.poetry]
|
[tool.poetry]
|
||||||
name = "photojawn"
|
name = "photojawn"
|
||||||
version = "0.1.0"
|
version = "0.1.1"
|
||||||
description = "A simple photo album static site generator"
|
description = "A simple photo album static site generator"
|
||||||
authors = ["Nick Pegg <nick@nickpegg.com>"]
|
authors = ["Nick Pegg <nick@nickpegg.com>"]
|
||||||
repository = "https://github.com/nickpegg/photojawn"
|
repository = "https://github.com/nickpegg/photojawn"
|
||||||
|
@ -12,7 +12,7 @@ photojawn = 'photojawn.cli:main'
|
||||||
|
|
||||||
[tool.poetry.dependencies]
|
[tool.poetry.dependencies]
|
||||||
# TODO: make sure we support >=3.10 via tests
|
# TODO: make sure we support >=3.10 via tests
|
||||||
python = "<4.0,>=3.11"
|
python = "^3.12,<3.14"
|
||||||
jinja2 = "^3.1.4"
|
jinja2 = "^3.1.4"
|
||||||
pillow = "^10.4.0"
|
pillow = "^10.4.0"
|
||||||
rich = "^13.7.1"
|
rich = "^13.7.1"
|
||||||
|
@ -26,6 +26,7 @@ mypy = "*"
|
||||||
pytest-testmon = "*"
|
pytest-testmon = "*"
|
||||||
types-pyyaml = "^6.0.12.20240724"
|
types-pyyaml = "^6.0.12.20240724"
|
||||||
types-markdown = "^3.6.0.20240316"
|
types-markdown = "^3.6.0.20240316"
|
||||||
|
pex = "^2.14.1"
|
||||||
|
|
||||||
|
|
||||||
[tool.mypy]
|
[tool.mypy]
|
||||||
|
|
Loading…
Add table
Reference in a new issue