From 82f0bd67dc791bf37a4f1690de90529bac0515de Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rapha=C3=ABl=20Bournhonesque?= Date: Tue, 22 Oct 2024 12:04:45 +0200 Subject: [PATCH] feat: add nutrition extractor --- poetry.lock | 387 +++++++----- pyproject.toml | 2 + robotoff/cli/main.py | 46 ++ robotoff/insights/annotate.py | 22 +- robotoff/insights/importer.py | 28 + robotoff/off.py | 12 +- .../nutrition_extraction/__init__.py | 568 ++++++++++++++++++ robotoff/products.py | 3 + robotoff/triton.py | 16 + robotoff/types.py | 10 +- robotoff/workers/tasks/import_image.py | 110 +++- .../prediction/test_nutrition_extraction.py | 370 ++++++++++++ 12 files changed, 1392 insertions(+), 182 deletions(-) create mode 100644 robotoff/prediction/nutrition_extraction/__init__.py create mode 100644 tests/unit/prediction/test_nutrition_extraction.py diff --git a/poetry.lock b/poetry.lock index dde8339212..73e4f1c2f9 100644 --- a/poetry.lock +++ b/poetry.lock @@ -547,38 +547,38 @@ toml = ["tomli"] [[package]] name = "cryptography" -version = "43.0.1" +version = "43.0.3" description = "cryptography is a package which provides cryptographic recipes and primitives to Python developers." optional = false python-versions = ">=3.7" files = [ - {file = "cryptography-43.0.1-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:8385d98f6a3bf8bb2d65a73e17ed87a3ba84f6991c155691c51112075f9ffc5d"}, - {file = "cryptography-43.0.1-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:27e613d7077ac613e399270253259d9d53872aaf657471473ebfc9a52935c062"}, - {file = "cryptography-43.0.1-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:68aaecc4178e90719e95298515979814bda0cbada1256a4485414860bd7ab962"}, - {file = "cryptography-43.0.1-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:de41fd81a41e53267cb020bb3a7212861da53a7d39f863585d13ea11049cf277"}, - {file = "cryptography-43.0.1-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:f98bf604c82c416bc829e490c700ca1553eafdf2912a91e23a79d97d9801372a"}, - {file = "cryptography-43.0.1-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:61ec41068b7b74268fa86e3e9e12b9f0c21fcf65434571dbb13d954bceb08042"}, - {file = "cryptography-43.0.1-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:014f58110f53237ace6a408b5beb6c427b64e084eb451ef25a28308270086494"}, - {file = "cryptography-43.0.1-cp37-abi3-win32.whl", hash = "sha256:2bd51274dcd59f09dd952afb696bf9c61a7a49dfc764c04dd33ef7a6b502a1e2"}, - {file = "cryptography-43.0.1-cp37-abi3-win_amd64.whl", hash = "sha256:666ae11966643886c2987b3b721899d250855718d6d9ce41b521252a17985f4d"}, - {file = "cryptography-43.0.1-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:ac119bb76b9faa00f48128b7f5679e1d8d437365c5d26f1c2c3f0da4ce1b553d"}, - {file = "cryptography-43.0.1-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1bbcce1a551e262dfbafb6e6252f1ae36a248e615ca44ba302df077a846a8806"}, - {file = "cryptography-43.0.1-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58d4e9129985185a06d849aa6df265bdd5a74ca6e1b736a77959b498e0505b85"}, - {file = "cryptography-43.0.1-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:d03a475165f3134f773d1388aeb19c2d25ba88b6a9733c5c590b9ff7bbfa2e0c"}, - {file = "cryptography-43.0.1-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:511f4273808ab590912a93ddb4e3914dfd8a388fed883361b02dea3791f292e1"}, - {file = "cryptography-43.0.1-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:80eda8b3e173f0f247f711eef62be51b599b5d425c429b5d4ca6a05e9e856baa"}, - {file = "cryptography-43.0.1-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:38926c50cff6f533f8a2dae3d7f19541432610d114a70808f0926d5aaa7121e4"}, - {file = "cryptography-43.0.1-cp39-abi3-win32.whl", hash = "sha256:a575913fb06e05e6b4b814d7f7468c2c660e8bb16d8d5a1faf9b33ccc569dd47"}, - {file = "cryptography-43.0.1-cp39-abi3-win_amd64.whl", hash = "sha256:d75601ad10b059ec832e78823b348bfa1a59f6b8d545db3a24fd44362a1564cb"}, - {file = "cryptography-43.0.1-pp310-pypy310_pp73-macosx_10_9_x86_64.whl", hash = "sha256:ea25acb556320250756e53f9e20a4177515f012c9eaea17eb7587a8c4d8ae034"}, - {file = "cryptography-43.0.1-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:c1332724be35d23a854994ff0b66530119500b6053d0bd3363265f7e5e77288d"}, - {file = "cryptography-43.0.1-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:fba1007b3ef89946dbbb515aeeb41e30203b004f0b4b00e5e16078b518563289"}, - {file = "cryptography-43.0.1-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:5b43d1ea6b378b54a1dc99dd8a2b5be47658fe9a7ce0a58ff0b55f4b43ef2b84"}, - {file = "cryptography-43.0.1-pp39-pypy39_pp73-macosx_10_9_x86_64.whl", hash = "sha256:88cce104c36870d70c49c7c8fd22885875d950d9ee6ab54df2745f83ba0dc365"}, - {file = "cryptography-43.0.1-pp39-pypy39_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:9d3cdb25fa98afdd3d0892d132b8d7139e2c087da1712041f6b762e4f807cc96"}, - {file = "cryptography-43.0.1-pp39-pypy39_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:e710bf40870f4db63c3d7d929aa9e09e4e7ee219e703f949ec4073b4294f6172"}, - {file = "cryptography-43.0.1-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:7c05650fe8023c5ed0d46793d4b7d7e6cd9c04e68eabe5b0aeea836e37bdcec2"}, - {file = "cryptography-43.0.1.tar.gz", hash = "sha256:203e92a75716d8cfb491dc47c79e17d0d9207ccffcbcb35f598fbe463ae3444d"}, + {file = "cryptography-43.0.3-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:bf7a1932ac4176486eab36a19ed4c0492da5d97123f1406cf15e41b05e787d2e"}, + {file = "cryptography-43.0.3-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:63efa177ff54aec6e1c0aefaa1a241232dcd37413835a9b674b6e3f0ae2bfd3e"}, + {file = "cryptography-43.0.3-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7e1ce50266f4f70bf41a2c6dc4358afadae90e2a1e5342d3c08883df1675374f"}, + {file = "cryptography-43.0.3-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:443c4a81bb10daed9a8f334365fe52542771f25aedaf889fd323a853ce7377d6"}, + {file = "cryptography-43.0.3-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:74f57f24754fe349223792466a709f8e0c093205ff0dca557af51072ff47ab18"}, + {file = "cryptography-43.0.3-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:9762ea51a8fc2a88b70cf2995e5675b38d93bf36bd67d91721c309df184f49bd"}, + {file = "cryptography-43.0.3-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:81ef806b1fef6b06dcebad789f988d3b37ccaee225695cf3e07648eee0fc6b73"}, + {file = "cryptography-43.0.3-cp37-abi3-win32.whl", hash = "sha256:cbeb489927bd7af4aa98d4b261af9a5bc025bd87f0e3547e11584be9e9427be2"}, + {file = "cryptography-43.0.3-cp37-abi3-win_amd64.whl", hash = "sha256:f46304d6f0c6ab8e52770addfa2fc41e6629495548862279641972b6215451cd"}, + {file = "cryptography-43.0.3-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:8ac43ae87929a5982f5948ceda07001ee5e83227fd69cf55b109144938d96984"}, + {file = "cryptography-43.0.3-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:846da004a5804145a5f441b8530b4bf35afbf7da70f82409f151695b127213d5"}, + {file = "cryptography-43.0.3-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0f996e7268af62598f2fc1204afa98a3b5712313a55c4c9d434aef49cadc91d4"}, + {file = "cryptography-43.0.3-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:f7b178f11ed3664fd0e995a47ed2b5ff0a12d893e41dd0494f406d1cf555cab7"}, + {file = "cryptography-43.0.3-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:c2e6fc39c4ab499049df3bdf567f768a723a5e8464816e8f009f121a5a9f4405"}, + {file = "cryptography-43.0.3-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:e1be4655c7ef6e1bbe6b5d0403526601323420bcf414598955968c9ef3eb7d16"}, + {file = "cryptography-43.0.3-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:df6b6c6d742395dd77a23ea3728ab62f98379eff8fb61be2744d4679ab678f73"}, + {file = "cryptography-43.0.3-cp39-abi3-win32.whl", hash = "sha256:d56e96520b1020449bbace2b78b603442e7e378a9b3bd68de65c782db1507995"}, + {file = "cryptography-43.0.3-cp39-abi3-win_amd64.whl", hash = "sha256:0c580952eef9bf68c4747774cde7ec1d85a6e61de97281f2dba83c7d2c806362"}, + {file = "cryptography-43.0.3-pp310-pypy310_pp73-macosx_10_9_x86_64.whl", hash = "sha256:d03b5621a135bffecad2c73e9f4deb1a0f977b9a8ffe6f8e002bf6c9d07b918c"}, + {file = "cryptography-43.0.3-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:a2a431ee15799d6db9fe80c82b055bae5a752bef645bba795e8e52687c69efe3"}, + {file = "cryptography-43.0.3-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:281c945d0e28c92ca5e5930664c1cefd85efe80e5c0d2bc58dd63383fda29f83"}, + {file = "cryptography-43.0.3-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:f18c716be16bc1fea8e95def49edf46b82fccaa88587a45f8dc0ff6ab5d8e0a7"}, + {file = "cryptography-43.0.3-pp39-pypy39_pp73-macosx_10_9_x86_64.whl", hash = "sha256:4a02ded6cd4f0a5562a8887df8b3bd14e822a90f97ac5e544c162899bc467664"}, + {file = "cryptography-43.0.3-pp39-pypy39_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:53a583b6637ab4c4e3591a15bc9db855b8d9dee9a669b550f311480acab6eb08"}, + {file = "cryptography-43.0.3-pp39-pypy39_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:1ec0bcf7e17c0c5669d881b1cd38c4972fade441b27bda1051665faaa89bdcaa"}, + {file = "cryptography-43.0.3-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:2ce6fae5bdad59577b44e4dfed356944fbf1d925269114c28be377692643b4ff"}, + {file = "cryptography-43.0.3.tar.gz", hash = "sha256:315b9001266a492a6ff443b61238f956b214dbec9910a081ba5b6646a055a805"}, ] [package.dependencies] @@ -591,7 +591,7 @@ nox = ["nox"] pep8test = ["check-sdist", "click", "mypy", "ruff"] sdist = ["build"] ssh = ["bcrypt (>=3.1.5)"] -test = ["certifi", "cryptography-vectors (==43.0.1)", "pretend", "pytest (>=6.2.0)", "pytest-benchmark", "pytest-cov", "pytest-xdist"] +test = ["certifi", "cryptography-vectors (==43.0.3)", "pretend", "pytest (>=6.2.0)", "pytest-benchmark", "pytest-cov", "pytest-xdist"] test-randomorder = ["pytest-randomly"] [[package]] @@ -831,13 +831,13 @@ peewee = "*" [[package]] name = "faker" -version = "30.4.0" +version = "30.8.0" description = "Faker is a Python package that generates fake data for you." optional = false python-versions = ">=3.8" files = [ - {file = "Faker-30.4.0-py3-none-any.whl", hash = "sha256:b6c2d61861dcf1084b8e10959418fe3380a1a3dcd2796a73d43f738a42aabb4c"}, - {file = "faker-30.4.0.tar.gz", hash = "sha256:6fd328db7195e70cdee479ee687fef6623c9b57b8023c582adbe88a01dc54297"}, + {file = "Faker-30.8.0-py3-none-any.whl", hash = "sha256:4cd0c5ea4bc1e4c902967f6e662f5f5da69f1674d9a94f54e516d27f3c2a6a16"}, + {file = "faker-30.8.0.tar.gz", hash = "sha256:3608c7fcac2acde0eaa6da28dae97628f18f14d54eaa2a92b96ae006f1621bd7"}, ] [package.dependencies] @@ -1046,13 +1046,13 @@ woff = ["brotli (>=1.0.1)", "brotlicffi (>=0.8.0)", "zopfli (>=0.1.4)"] [[package]] name = "fsspec" -version = "2024.9.0" +version = "2024.10.0" description = "File-system specification" optional = false python-versions = ">=3.8" files = [ - {file = "fsspec-2024.9.0-py3-none-any.whl", hash = "sha256:a0947d552d8a6efa72cc2c730b12c41d043509156966cca4fb157b0f2a0c574b"}, - {file = "fsspec-2024.9.0.tar.gz", hash = "sha256:4b0afb90c2f21832df142f292649035d80b421f60a9e1c027802e5a0da2b04e8"}, + {file = "fsspec-2024.10.0-py3-none-any.whl", hash = "sha256:03b9a6785766a4de40368b88906366755e2819e758b83705c88cd7cb5fe81871"}, + {file = "fsspec-2024.10.0.tar.gz", hash = "sha256:eda2d8a4116d4f2429db8550f2457da57279247dd930bb12f821b58391359493"}, ] [package.extras] @@ -1421,13 +1421,13 @@ numpy = ">=1.14.5" [[package]] name = "huggingface-hub" -version = "0.25.2" +version = "0.26.1" description = "Client library to download and publish models, datasets and other repos on the huggingface.co hub" optional = false python-versions = ">=3.8.0" files = [ - {file = "huggingface_hub-0.25.2-py3-none-any.whl", hash = "sha256:1897caf88ce7f97fe0110603d8f66ac264e3ba6accdf30cd66cc0fed5282ad25"}, - {file = "huggingface_hub-0.25.2.tar.gz", hash = "sha256:a1014ea111a5f40ccd23f7f7ba8ac46e20fa3b658ced1f86a00c75c06ec6423c"}, + {file = "huggingface_hub-0.26.1-py3-none-any.whl", hash = "sha256:5927a8fc64ae68859cd954b7cc29d1c8390a5e15caba6d3d349c973be8fdacf3"}, + {file = "huggingface_hub-0.26.1.tar.gz", hash = "sha256:414c0d9b769eecc86c70f9d939d0f48bb28e8461dd1130021542eff0212db890"}, ] [package.dependencies] @@ -1440,16 +1440,16 @@ tqdm = ">=4.42.1" typing-extensions = ">=3.7.4.3" [package.extras] -all = ["InquirerPy (==0.3.4)", "Jinja2", "Pillow", "aiohttp", "fastapi", "gradio", "jedi", "minijinja (>=1.0)", "mypy (==1.5.1)", "numpy", "pytest (>=8.1.1,<8.2.2)", "pytest-asyncio", "pytest-cov", "pytest-env", "pytest-mock", "pytest-rerunfailures", "pytest-vcr", "pytest-xdist", "ruff (>=0.5.0)", "soundfile", "types-PyYAML", "types-requests", "types-simplejson", "types-toml", "types-tqdm", "types-urllib3", "typing-extensions (>=4.8.0)", "urllib3 (<2.0)"] +all = ["InquirerPy (==0.3.4)", "Jinja2", "Pillow", "aiohttp", "fastapi", "gradio (>=4.0.0)", "jedi", "libcst (==1.4.0)", "mypy (==1.5.1)", "numpy", "pytest (>=8.1.1,<8.2.2)", "pytest-asyncio", "pytest-cov", "pytest-env", "pytest-mock", "pytest-rerunfailures", "pytest-vcr", "pytest-xdist", "ruff (>=0.5.0)", "soundfile", "types-PyYAML", "types-requests", "types-simplejson", "types-toml", "types-tqdm", "types-urllib3", "typing-extensions (>=4.8.0)", "urllib3 (<2.0)"] cli = ["InquirerPy (==0.3.4)"] -dev = ["InquirerPy (==0.3.4)", "Jinja2", "Pillow", "aiohttp", "fastapi", "gradio", "jedi", "minijinja (>=1.0)", "mypy (==1.5.1)", "numpy", "pytest (>=8.1.1,<8.2.2)", "pytest-asyncio", "pytest-cov", "pytest-env", "pytest-mock", "pytest-rerunfailures", "pytest-vcr", "pytest-xdist", "ruff (>=0.5.0)", "soundfile", "types-PyYAML", "types-requests", "types-simplejson", "types-toml", "types-tqdm", "types-urllib3", "typing-extensions (>=4.8.0)", "urllib3 (<2.0)"] +dev = ["InquirerPy (==0.3.4)", "Jinja2", "Pillow", "aiohttp", "fastapi", "gradio (>=4.0.0)", "jedi", "libcst (==1.4.0)", "mypy (==1.5.1)", "numpy", "pytest (>=8.1.1,<8.2.2)", "pytest-asyncio", "pytest-cov", "pytest-env", "pytest-mock", "pytest-rerunfailures", "pytest-vcr", "pytest-xdist", "ruff (>=0.5.0)", "soundfile", "types-PyYAML", "types-requests", "types-simplejson", "types-toml", "types-tqdm", "types-urllib3", "typing-extensions (>=4.8.0)", "urllib3 (<2.0)"] fastai = ["fastai (>=2.4)", "fastcore (>=1.3.27)", "toml"] hf-transfer = ["hf-transfer (>=0.1.4)"] -inference = ["aiohttp", "minijinja (>=1.0)"] -quality = ["mypy (==1.5.1)", "ruff (>=0.5.0)"] +inference = ["aiohttp"] +quality = ["libcst (==1.4.0)", "mypy (==1.5.1)", "ruff (>=0.5.0)"] tensorflow = ["graphviz", "pydot", "tensorflow"] tensorflow-testing = ["keras (<3.0)", "tensorflow"] -testing = ["InquirerPy (==0.3.4)", "Jinja2", "Pillow", "aiohttp", "fastapi", "gradio", "jedi", "minijinja (>=1.0)", "numpy", "pytest (>=8.1.1,<8.2.2)", "pytest-asyncio", "pytest-cov", "pytest-env", "pytest-mock", "pytest-rerunfailures", "pytest-vcr", "pytest-xdist", "soundfile", "urllib3 (<2.0)"] +testing = ["InquirerPy (==0.3.4)", "Jinja2", "Pillow", "aiohttp", "fastapi", "gradio (>=4.0.0)", "jedi", "numpy", "pytest (>=8.1.1,<8.2.2)", "pytest-asyncio", "pytest-cov", "pytest-env", "pytest-mock", "pytest-rerunfailures", "pytest-vcr", "pytest-xdist", "soundfile", "urllib3 (<2.0)"] torch = ["safetensors[torch]", "torch"] typing = ["types-PyYAML", "types-requests", "types-simplejson", "types-toml", "types-tqdm", "types-urllib3", "typing-extensions (>=4.8.0)"] @@ -1751,74 +1751,98 @@ files = [ [package.extras] 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 = "3.0.1" +version = "3.0.2" description = "Safely add untrusted strings to HTML/XML markup." optional = false python-versions = ">=3.9" files = [ - {file = "MarkupSafe-3.0.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:db842712984e91707437461930e6011e60b39136c7331e971952bb30465bc1a1"}, - {file = "MarkupSafe-3.0.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:3ffb4a8e7d46ed96ae48805746755fadd0909fea2306f93d5d8233ba23dda12a"}, - {file = "MarkupSafe-3.0.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:67c519635a4f64e495c50e3107d9b4075aec33634272b5db1cde839e07367589"}, - {file = "MarkupSafe-3.0.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:48488d999ed50ba8d38c581d67e496f955821dc183883550a6fbc7f1aefdc170"}, - {file = "MarkupSafe-3.0.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f31ae06f1328595d762c9a2bf29dafd8621c7d3adc130cbb46278079758779ca"}, - {file = "MarkupSafe-3.0.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:80fcbf3add8790caddfab6764bde258b5d09aefbe9169c183f88a7410f0f6dea"}, - {file = "MarkupSafe-3.0.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:3341c043c37d78cc5ae6e3e305e988532b072329639007fd408a476642a89fd6"}, - {file = "MarkupSafe-3.0.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:cb53e2a99df28eee3b5f4fea166020d3ef9116fdc5764bc5117486e6d1211b25"}, - {file = "MarkupSafe-3.0.1-cp310-cp310-win32.whl", hash = "sha256:db15ce28e1e127a0013dfb8ac243a8e392db8c61eae113337536edb28bdc1f97"}, - {file = "MarkupSafe-3.0.1-cp310-cp310-win_amd64.whl", hash = "sha256:4ffaaac913c3f7345579db4f33b0020db693f302ca5137f106060316761beea9"}, - {file = "MarkupSafe-3.0.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:26627785a54a947f6d7336ce5963569b5d75614619e75193bdb4e06e21d447ad"}, - {file = "MarkupSafe-3.0.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:b954093679d5750495725ea6f88409946d69cfb25ea7b4c846eef5044194f583"}, - {file = "MarkupSafe-3.0.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:973a371a55ce9ed333a3a0f8e0bcfae9e0d637711534bcb11e130af2ab9334e7"}, - {file = "MarkupSafe-3.0.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:244dbe463d5fb6d7ce161301a03a6fe744dac9072328ba9fc82289238582697b"}, - {file = "MarkupSafe-3.0.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d98e66a24497637dd31ccab090b34392dddb1f2f811c4b4cd80c230205c074a3"}, - {file = "MarkupSafe-3.0.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:ad91738f14eb8da0ff82f2acd0098b6257621410dcbd4df20aaa5b4233d75a50"}, - {file = "MarkupSafe-3.0.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:7044312a928a66a4c2a22644147bc61a199c1709712069a344a3fb5cfcf16915"}, - {file = "MarkupSafe-3.0.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:a4792d3b3a6dfafefdf8e937f14906a51bd27025a36f4b188728a73382231d91"}, - {file = "MarkupSafe-3.0.1-cp311-cp311-win32.whl", hash = "sha256:fa7d686ed9883f3d664d39d5a8e74d3c5f63e603c2e3ff0abcba23eac6542635"}, - {file = "MarkupSafe-3.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:9ba25a71ebf05b9bb0e2ae99f8bc08a07ee8e98c612175087112656ca0f5c8bf"}, - {file = "MarkupSafe-3.0.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:8ae369e84466aa70f3154ee23c1451fda10a8ee1b63923ce76667e3077f2b0c4"}, - {file = "MarkupSafe-3.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:40f1e10d51c92859765522cbd79c5c8989f40f0419614bcdc5015e7b6bf97fc5"}, - {file = "MarkupSafe-3.0.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5a4cb365cb49b750bdb60b846b0c0bc49ed62e59a76635095a179d440540c346"}, - {file = "MarkupSafe-3.0.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ee3941769bd2522fe39222206f6dd97ae83c442a94c90f2b7a25d847d40f4729"}, - {file = "MarkupSafe-3.0.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:62fada2c942702ef8952754abfc1a9f7658a4d5460fabe95ac7ec2cbe0d02abc"}, - {file = "MarkupSafe-3.0.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:4c2d64fdba74ad16138300815cfdc6ab2f4647e23ced81f59e940d7d4a1469d9"}, - {file = "MarkupSafe-3.0.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:fb532dd9900381d2e8f48172ddc5a59db4c445a11b9fab40b3b786da40d3b56b"}, - {file = "MarkupSafe-3.0.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:0f84af7e813784feb4d5e4ff7db633aba6c8ca64a833f61d8e4eade234ef0c38"}, - {file = "MarkupSafe-3.0.1-cp312-cp312-win32.whl", hash = "sha256:cbf445eb5628981a80f54087f9acdbf84f9b7d862756110d172993b9a5ae81aa"}, - {file = "MarkupSafe-3.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:a10860e00ded1dd0a65b83e717af28845bb7bd16d8ace40fe5531491de76b79f"}, - {file = "MarkupSafe-3.0.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:e81c52638315ff4ac1b533d427f50bc0afc746deb949210bc85f05d4f15fd772"}, - {file = "MarkupSafe-3.0.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:312387403cd40699ab91d50735ea7a507b788091c416dd007eac54434aee51da"}, - {file = "MarkupSafe-3.0.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2ae99f31f47d849758a687102afdd05bd3d3ff7dbab0a8f1587981b58a76152a"}, - {file = "MarkupSafe-3.0.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c97ff7fedf56d86bae92fa0a646ce1a0ec7509a7578e1ed238731ba13aabcd1c"}, - {file = "MarkupSafe-3.0.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a7420ceda262dbb4b8d839a4ec63d61c261e4e77677ed7c66c99f4e7cb5030dd"}, - {file = "MarkupSafe-3.0.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:45d42d132cff577c92bfba536aefcfea7e26efb975bd455db4e6602f5c9f45e7"}, - {file = "MarkupSafe-3.0.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:4c8817557d0de9349109acb38b9dd570b03cc5014e8aabf1cbddc6e81005becd"}, - {file = "MarkupSafe-3.0.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6a54c43d3ec4cf2a39f4387ad044221c66a376e58c0d0e971d47c475ba79c6b5"}, - {file = "MarkupSafe-3.0.1-cp313-cp313-win32.whl", hash = "sha256:c91b394f7601438ff79a4b93d16be92f216adb57d813a78be4446fe0f6bc2d8c"}, - {file = "MarkupSafe-3.0.1-cp313-cp313-win_amd64.whl", hash = "sha256:fe32482b37b4b00c7a52a07211b479653b7fe4f22b2e481b9a9b099d8a430f2f"}, - {file = "MarkupSafe-3.0.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:17b2aea42a7280db02ac644db1d634ad47dcc96faf38ab304fe26ba2680d359a"}, - {file = "MarkupSafe-3.0.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:852dc840f6d7c985603e60b5deaae1d89c56cb038b577f6b5b8c808c97580f1d"}, - {file = "MarkupSafe-3.0.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0778de17cff1acaeccc3ff30cd99a3fd5c50fc58ad3d6c0e0c4c58092b859396"}, - {file = "MarkupSafe-3.0.1-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:800100d45176652ded796134277ecb13640c1a537cad3b8b53da45aa96330453"}, - {file = "MarkupSafe-3.0.1-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d06b24c686a34c86c8c1fba923181eae6b10565e4d80bdd7bc1c8e2f11247aa4"}, - {file = "MarkupSafe-3.0.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:33d1c36b90e570ba7785dacd1faaf091203d9942bc036118fab8110a401eb1a8"}, - {file = "MarkupSafe-3.0.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:beeebf760a9c1f4c07ef6a53465e8cfa776ea6a2021eda0d0417ec41043fe984"}, - {file = "MarkupSafe-3.0.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:bbde71a705f8e9e4c3e9e33db69341d040c827c7afa6789b14c6e16776074f5a"}, - {file = "MarkupSafe-3.0.1-cp313-cp313t-win32.whl", hash = "sha256:82b5dba6eb1bcc29cc305a18a3c5365d2af06ee71b123216416f7e20d2a84e5b"}, - {file = "MarkupSafe-3.0.1-cp313-cp313t-win_amd64.whl", hash = "sha256:730d86af59e0e43ce277bb83970530dd223bf7f2a838e086b50affa6ec5f9295"}, - {file = "MarkupSafe-3.0.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:4935dd7883f1d50e2ffecca0aa33dc1946a94c8f3fdafb8df5c330e48f71b132"}, - {file = "MarkupSafe-3.0.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:e9393357f19954248b00bed7c56f29a25c930593a77630c719653d51e7669c2a"}, - {file = "MarkupSafe-3.0.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:40621d60d0e58aa573b68ac5e2d6b20d44392878e0bfc159012a5787c4e35bc8"}, - {file = "MarkupSafe-3.0.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f94190df587738280d544971500b9cafc9b950d32efcb1fba9ac10d84e6aa4e6"}, - {file = "MarkupSafe-3.0.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b6a387d61fe41cdf7ea95b38e9af11cfb1a63499af2759444b99185c4ab33f5b"}, - {file = "MarkupSafe-3.0.1-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:8ad4ad1429cd4f315f32ef263c1342166695fad76c100c5d979c45d5570ed58b"}, - {file = "MarkupSafe-3.0.1-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:e24bfe89c6ac4c31792793ad9f861b8f6dc4546ac6dc8f1c9083c7c4f2b335cd"}, - {file = "MarkupSafe-3.0.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:2a4b34a8d14649315c4bc26bbfa352663eb51d146e35eef231dd739d54a5430a"}, - {file = "MarkupSafe-3.0.1-cp39-cp39-win32.whl", hash = "sha256:242d6860f1fd9191aef5fae22b51c5c19767f93fb9ead4d21924e0bcb17619d8"}, - {file = "MarkupSafe-3.0.1-cp39-cp39-win_amd64.whl", hash = "sha256:93e8248d650e7e9d49e8251f883eed60ecbc0e8ffd6349e18550925e31bd029b"}, - {file = "markupsafe-3.0.1.tar.gz", hash = "sha256:3e683ee4f5d0fa2dde4db77ed8dd8a876686e3fc417655c2ece9a90576905344"}, + {file = "MarkupSafe-3.0.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:7e94c425039cde14257288fd61dcfb01963e658efbc0ff54f5306b06054700f8"}, + {file = "MarkupSafe-3.0.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:9e2d922824181480953426608b81967de705c3cef4d1af983af849d7bd619158"}, + {file = "MarkupSafe-3.0.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:38a9ef736c01fccdd6600705b09dc574584b89bea478200c5fbf112a6b0d5579"}, + {file = "MarkupSafe-3.0.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bbcb445fa71794da8f178f0f6d66789a28d7319071af7a496d4d507ed566270d"}, + {file = "MarkupSafe-3.0.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:57cb5a3cf367aeb1d316576250f65edec5bb3be939e9247ae594b4bcbc317dfb"}, + {file = "MarkupSafe-3.0.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:3809ede931876f5b2ec92eef964286840ed3540dadf803dd570c3b7e13141a3b"}, + {file = "MarkupSafe-3.0.2-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e07c3764494e3776c602c1e78e298937c3315ccc9043ead7e685b7f2b8d47b3c"}, + {file = "MarkupSafe-3.0.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:b424c77b206d63d500bcb69fa55ed8d0e6a3774056bdc4839fc9298a7edca171"}, + {file = "MarkupSafe-3.0.2-cp310-cp310-win32.whl", hash = "sha256:fcabf5ff6eea076f859677f5f0b6b5c1a51e70a376b0579e0eadef8db48c6b50"}, + {file = "MarkupSafe-3.0.2-cp310-cp310-win_amd64.whl", hash = "sha256:6af100e168aa82a50e186c82875a5893c5597a0c1ccdb0d8b40240b1f28b969a"}, + {file = "MarkupSafe-3.0.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:9025b4018f3a1314059769c7bf15441064b2207cb3f065e6ea1e7359cb46db9d"}, + {file = "MarkupSafe-3.0.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:93335ca3812df2f366e80509ae119189886b0f3c2b81325d39efdb84a1e2ae93"}, + {file = "MarkupSafe-3.0.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2cb8438c3cbb25e220c2ab33bb226559e7afb3baec11c4f218ffa7308603c832"}, + {file = "MarkupSafe-3.0.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a123e330ef0853c6e822384873bef7507557d8e4a082961e1defa947aa59ba84"}, + {file = "MarkupSafe-3.0.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1e084f686b92e5b83186b07e8a17fc09e38fff551f3602b249881fec658d3eca"}, + {file = "MarkupSafe-3.0.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d8213e09c917a951de9d09ecee036d5c7d36cb6cb7dbaece4c71a60d79fb9798"}, + {file = "MarkupSafe-3.0.2-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:5b02fb34468b6aaa40dfc198d813a641e3a63b98c2b05a16b9f80b7ec314185e"}, + {file = "MarkupSafe-3.0.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:0bff5e0ae4ef2e1ae4fdf2dfd5b76c75e5c2fa4132d05fc1b0dabcd20c7e28c4"}, + {file = "MarkupSafe-3.0.2-cp311-cp311-win32.whl", hash = "sha256:6c89876f41da747c8d3677a2b540fb32ef5715f97b66eeb0c6b66f5e3ef6f59d"}, + {file = "MarkupSafe-3.0.2-cp311-cp311-win_amd64.whl", hash = "sha256:70a87b411535ccad5ef2f1df5136506a10775d267e197e4cf531ced10537bd6b"}, + {file = "MarkupSafe-3.0.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:9778bd8ab0a994ebf6f84c2b949e65736d5575320a17ae8984a77fab08db94cf"}, + {file = "MarkupSafe-3.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:846ade7b71e3536c4e56b386c2a47adf5741d2d8b94ec9dc3e92e5e1ee1e2225"}, + {file = "MarkupSafe-3.0.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1c99d261bd2d5f6b59325c92c73df481e05e57f19837bdca8413b9eac4bd8028"}, + {file = "MarkupSafe-3.0.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e17c96c14e19278594aa4841ec148115f9c7615a47382ecb6b82bd8fea3ab0c8"}, + {file = "MarkupSafe-3.0.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:88416bd1e65dcea10bc7569faacb2c20ce071dd1f87539ca2ab364bf6231393c"}, + {file = "MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2181e67807fc2fa785d0592dc2d6206c019b9502410671cc905d132a92866557"}, + {file = "MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:52305740fe773d09cffb16f8ed0427942901f00adedac82ec8b67752f58a1b22"}, + {file = "MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ad10d3ded218f1039f11a75f8091880239651b52e9bb592ca27de44eed242a48"}, + {file = "MarkupSafe-3.0.2-cp312-cp312-win32.whl", hash = "sha256:0f4ca02bea9a23221c0182836703cbf8930c5e9454bacce27e767509fa286a30"}, + {file = "MarkupSafe-3.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:8e06879fc22a25ca47312fbe7c8264eb0b662f6db27cb2d3bbbc74b1df4b9b87"}, + {file = "MarkupSafe-3.0.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ba9527cdd4c926ed0760bc301f6728ef34d841f405abf9d4f959c478421e4efd"}, + {file = "MarkupSafe-3.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f8b3d067f2e40fe93e1ccdd6b2e1d16c43140e76f02fb1319a05cf2b79d99430"}, + {file = "MarkupSafe-3.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:569511d3b58c8791ab4c2e1285575265991e6d8f8700c7be0e88f86cb0672094"}, + {file = "MarkupSafe-3.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:15ab75ef81add55874e7ab7055e9c397312385bd9ced94920f2802310c930396"}, + {file = "MarkupSafe-3.0.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f3818cb119498c0678015754eba762e0d61e5b52d34c8b13d770f0719f7b1d79"}, + {file = "MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:cdb82a876c47801bb54a690c5ae105a46b392ac6099881cdfb9f6e95e4014c6a"}, + {file = "MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:cabc348d87e913db6ab4aa100f01b08f481097838bdddf7c7a84b7575b7309ca"}, + {file = "MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:444dcda765c8a838eaae23112db52f1efaf750daddb2d9ca300bcae1039adc5c"}, + {file = "MarkupSafe-3.0.2-cp313-cp313-win32.whl", hash = "sha256:bcf3e58998965654fdaff38e58584d8937aa3096ab5354d493c77d1fdd66d7a1"}, + {file = "MarkupSafe-3.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:e6a2a455bd412959b57a172ce6328d2dd1f01cb2135efda2e4576e8a23fa3b0f"}, + {file = "MarkupSafe-3.0.2-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:b5a6b3ada725cea8a5e634536b1b01c30bcdcd7f9c6fff4151548d5bf6b3a36c"}, + {file = "MarkupSafe-3.0.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:a904af0a6162c73e3edcb969eeeb53a63ceeb5d8cf642fade7d39e7963a22ddb"}, + {file = "MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4aa4e5faecf353ed117801a068ebab7b7e09ffb6e1d5e412dc852e0da018126c"}, + {file = "MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c0ef13eaeee5b615fb07c9a7dadb38eac06a0608b41570d8ade51c56539e509d"}, + {file = "MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d16a81a06776313e817c951135cf7340a3e91e8c1ff2fac444cfd75fffa04afe"}, + {file = "MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:6381026f158fdb7c72a168278597a5e3a5222e83ea18f543112b2662a9b699c5"}, + {file = "MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:3d79d162e7be8f996986c064d1c7c817f6df3a77fe3d6859f6f9e7be4b8c213a"}, + {file = "MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:131a3c7689c85f5ad20f9f6fb1b866f402c445b220c19fe4308c0b147ccd2ad9"}, + {file = "MarkupSafe-3.0.2-cp313-cp313t-win32.whl", hash = "sha256:ba8062ed2cf21c07a9e295d5b8a2a5ce678b913b45fdf68c32d95d6c1291e0b6"}, + {file = "MarkupSafe-3.0.2-cp313-cp313t-win_amd64.whl", hash = "sha256:e444a31f8db13eb18ada366ab3cf45fd4b31e4db1236a4448f68778c1d1a5a2f"}, + {file = "MarkupSafe-3.0.2-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:eaa0a10b7f72326f1372a713e73c3f739b524b3af41feb43e4921cb529f5929a"}, + {file = "MarkupSafe-3.0.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:48032821bbdf20f5799ff537c7ac3d1fba0ba032cfc06194faffa8cda8b560ff"}, + {file = "MarkupSafe-3.0.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1a9d3f5f0901fdec14d8d2f66ef7d035f2157240a433441719ac9a3fba440b13"}, + {file = "MarkupSafe-3.0.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:88b49a3b9ff31e19998750c38e030fc7bb937398b1f78cfa599aaef92d693144"}, + {file = "MarkupSafe-3.0.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:cfad01eed2c2e0c01fd0ecd2ef42c492f7f93902e39a42fc9ee1692961443a29"}, + {file = "MarkupSafe-3.0.2-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:1225beacc926f536dc82e45f8a4d68502949dc67eea90eab715dea3a21c1b5f0"}, + {file = "MarkupSafe-3.0.2-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:3169b1eefae027567d1ce6ee7cae382c57fe26e82775f460f0b2778beaad66c0"}, + {file = "MarkupSafe-3.0.2-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:eb7972a85c54febfb25b5c4b4f3af4dcc731994c7da0d8a0b4a6eb0640e1d178"}, + {file = "MarkupSafe-3.0.2-cp39-cp39-win32.whl", hash = "sha256:8c4e8c3ce11e1f92f6536ff07154f9d49677ebaaafc32db9db4620bc11ed480f"}, + {file = "MarkupSafe-3.0.2-cp39-cp39-win_amd64.whl", hash = "sha256:6e296a513ca3d94054c2c881cc913116e90fd030ad1c656b3869762b754f5f8a"}, + {file = "markupsafe-3.0.2.tar.gz", hash = "sha256:ee55d3edf80167e48ea11a923c7386f4669df67d7994554387f84e7d8b0a2bf0"}, ] [[package]] @@ -1895,6 +1919,17 @@ files = [ {file = "mccabe-0.6.1.tar.gz", hash = "sha256:dd8d182285a0fe56bace7f45b5e7d1a6ebcbf524e8f3bd87eb0f125271b8831f"}, ] +[[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 = "mdx-truly-sane-lists" version = "1.2" @@ -2593,48 +2628,78 @@ files = [ [[package]] name = "psycopg2-binary" -version = "2.9.9" +version = "2.9.10" description = "psycopg2 - Python-PostgreSQL Database Adapter" optional = false -python-versions = ">=3.7" +python-versions = ">=3.8" files = [ - {file = "psycopg2_binary-2.9.9-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:c2470da5418b76232f02a2fcd2229537bb2d5a7096674ce61859c3229f2eb202"}, - {file = "psycopg2_binary-2.9.9-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:c6af2a6d4b7ee9615cbb162b0738f6e1fd1f5c3eda7e5da17861eacf4c717ea7"}, - {file = "psycopg2_binary-2.9.9-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:75723c3c0fbbf34350b46a3199eb50638ab22a0228f93fb472ef4d9becc2382b"}, - {file = "psycopg2_binary-2.9.9-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:83791a65b51ad6ee6cf0845634859d69a038ea9b03d7b26e703f94c7e93dbcf9"}, - {file = "psycopg2_binary-2.9.9-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0ef4854e82c09e84cc63084a9e4ccd6d9b154f1dbdd283efb92ecd0b5e2b8c84"}, - {file = "psycopg2_binary-2.9.9-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ed1184ab8f113e8d660ce49a56390ca181f2981066acc27cf637d5c1e10ce46e"}, - {file = "psycopg2_binary-2.9.9-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:d2997c458c690ec2bc6b0b7ecbafd02b029b7b4283078d3b32a852a7ce3ddd98"}, - {file = "psycopg2_binary-2.9.9-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:b58b4710c7f4161b5e9dcbe73bb7c62d65670a87df7bcce9e1faaad43e715245"}, - {file = "psycopg2_binary-2.9.9-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:0c009475ee389757e6e34611d75f6e4f05f0cf5ebb76c6037508318e1a1e0d7e"}, - {file = "psycopg2_binary-2.9.9-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:8dbf6d1bc73f1d04ec1734bae3b4fb0ee3cb2a493d35ede9badbeb901fb40f6f"}, - {file = "psycopg2_binary-2.9.9-cp310-cp310-win32.whl", hash = "sha256:3f78fd71c4f43a13d342be74ebbc0666fe1f555b8837eb113cb7416856c79682"}, - {file = "psycopg2_binary-2.9.9-cp310-cp310-win_amd64.whl", hash = "sha256:876801744b0dee379e4e3c38b76fc89f88834bb15bf92ee07d94acd06ec890a0"}, - {file = "psycopg2_binary-2.9.9-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:ee825e70b1a209475622f7f7b776785bd68f34af6e7a46e2e42f27b659b5bc26"}, - {file = "psycopg2_binary-2.9.9-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1ea665f8ce695bcc37a90ee52de7a7980be5161375d42a0b6c6abedbf0d81f0f"}, - {file = "psycopg2_binary-2.9.9-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:143072318f793f53819048fdfe30c321890af0c3ec7cb1dfc9cc87aa88241de2"}, - {file = "psycopg2_binary-2.9.9-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c332c8d69fb64979ebf76613c66b985414927a40f8defa16cf1bc028b7b0a7b0"}, - {file = "psycopg2_binary-2.9.9-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f7fc5a5acafb7d6ccca13bfa8c90f8c51f13d8fb87d95656d3950f0158d3ce53"}, - {file = "psycopg2_binary-2.9.9-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:977646e05232579d2e7b9c59e21dbe5261f403a88417f6a6512e70d3f8a046be"}, - {file = "psycopg2_binary-2.9.9-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:b6356793b84728d9d50ead16ab43c187673831e9d4019013f1402c41b1db9b27"}, - {file = "psycopg2_binary-2.9.9-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:bc7bb56d04601d443f24094e9e31ae6deec9ccb23581f75343feebaf30423359"}, - {file = "psycopg2_binary-2.9.9-cp311-cp311-musllinux_1_1_ppc64le.whl", hash = "sha256:77853062a2c45be16fd6b8d6de2a99278ee1d985a7bd8b103e97e41c034006d2"}, - {file = "psycopg2_binary-2.9.9-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:78151aa3ec21dccd5cdef6c74c3e73386dcdfaf19bced944169697d7ac7482fc"}, - {file = "psycopg2_binary-2.9.9-cp311-cp311-win32.whl", hash = "sha256:dc4926288b2a3e9fd7b50dc6a1909a13bbdadfc67d93f3374d984e56f885579d"}, - {file = "psycopg2_binary-2.9.9-cp311-cp311-win_amd64.whl", hash = "sha256:b76bedd166805480ab069612119ea636f5ab8f8771e640ae103e05a4aae3e417"}, - {file = "psycopg2_binary-2.9.9-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:8532fd6e6e2dc57bcb3bc90b079c60de896d2128c5d9d6f24a63875a95a088cf"}, - {file = "psycopg2_binary-2.9.9-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8f8544b092a29a6ddd72f3556a9fcf249ec412e10ad28be6a0c0d948924f2212"}, - {file = "psycopg2_binary-2.9.9-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2d423c8d8a3c82d08fe8af900ad5b613ce3632a1249fd6a223941d0735fce493"}, - {file = "psycopg2_binary-2.9.9-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2e5afae772c00980525f6d6ecf7cbca55676296b580c0e6abb407f15f3706996"}, - {file = "psycopg2_binary-2.9.9-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6e6f98446430fdf41bd36d4faa6cb409f5140c1c2cf58ce0bbdaf16af7d3f119"}, - {file = "psycopg2_binary-2.9.9-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:c77e3d1862452565875eb31bdb45ac62502feabbd53429fdc39a1cc341d681ba"}, - {file = "psycopg2_binary-2.9.9-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:cb16c65dcb648d0a43a2521f2f0a2300f40639f6f8c1ecbc662141e4e3e1ee07"}, - {file = "psycopg2_binary-2.9.9-cp312-cp312-musllinux_1_1_ppc64le.whl", hash = "sha256:911dda9c487075abd54e644ccdf5e5c16773470a6a5d3826fda76699410066fb"}, - {file = "psycopg2_binary-2.9.9-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:57fede879f08d23c85140a360c6a77709113efd1c993923c59fde17aa27599fe"}, - {file = "psycopg2_binary-2.9.9-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:2293b001e319ab0d869d660a704942c9e2cce19745262a8aba2115ef41a0a42a"}, - {file = "psycopg2_binary-2.9.9-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:03ef7df18daf2c4c07e2695e8cfd5ee7f748a1d54d802330985a78d2a5a6dca9"}, - {file = "psycopg2_binary-2.9.9-cp37-cp37m-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0a602ea5aff39bb9fac6308e9c9d82b9a35c2bf288e184a816002c9fae930b77"}, - {file = "psycopg2_binary-2.9.9-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8359bf4791968c5a78c56103702000105501adb557f3cf772b2c207284273984"}, + {file = "psycopg2-binary-2.9.10.tar.gz", hash = "sha256:4b3df0e6990aa98acda57d983942eff13d824135fe2250e6522edaa782a06de2"}, + {file = "psycopg2_binary-2.9.10-cp310-cp310-macosx_12_0_x86_64.whl", hash = "sha256:0ea8e3d0ae83564f2fc554955d327fa081d065c8ca5cc6d2abb643e2c9c1200f"}, + {file = "psycopg2_binary-2.9.10-cp310-cp310-macosx_14_0_arm64.whl", hash = "sha256:3e9c76f0ac6f92ecfc79516a8034a544926430f7b080ec5a0537bca389ee0906"}, + {file = "psycopg2_binary-2.9.10-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2ad26b467a405c798aaa1458ba09d7e2b6e5f96b1ce0ac15d82fd9f95dc38a92"}, + {file = "psycopg2_binary-2.9.10-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:270934a475a0e4b6925b5f804e3809dd5f90f8613621d062848dd82f9cd62007"}, + {file = "psycopg2_binary-2.9.10-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:48b338f08d93e7be4ab2b5f1dbe69dc5e9ef07170fe1f86514422076d9c010d0"}, + {file = "psycopg2_binary-2.9.10-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7f4152f8f76d2023aac16285576a9ecd2b11a9895373a1f10fd9db54b3ff06b4"}, + {file = "psycopg2_binary-2.9.10-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:32581b3020c72d7a421009ee1c6bf4a131ef5f0a968fab2e2de0c9d2bb4577f1"}, + {file = "psycopg2_binary-2.9.10-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:2ce3e21dc3437b1d960521eca599d57408a695a0d3c26797ea0f72e834c7ffe5"}, + {file = "psycopg2_binary-2.9.10-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:e984839e75e0b60cfe75e351db53d6db750b00de45644c5d1f7ee5d1f34a1ce5"}, + {file = "psycopg2_binary-2.9.10-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:3c4745a90b78e51d9ba06e2088a2fe0c693ae19cc8cb051ccda44e8df8a6eb53"}, + {file = "psycopg2_binary-2.9.10-cp310-cp310-win32.whl", hash = "sha256:e5720a5d25e3b99cd0dc5c8a440570469ff82659bb09431c1439b92caf184d3b"}, + {file = "psycopg2_binary-2.9.10-cp310-cp310-win_amd64.whl", hash = "sha256:3c18f74eb4386bf35e92ab2354a12c17e5eb4d9798e4c0ad3a00783eae7cd9f1"}, + {file = "psycopg2_binary-2.9.10-cp311-cp311-macosx_12_0_x86_64.whl", hash = "sha256:04392983d0bb89a8717772a193cfaac58871321e3ec69514e1c4e0d4957b5aff"}, + {file = "psycopg2_binary-2.9.10-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:1a6784f0ce3fec4edc64e985865c17778514325074adf5ad8f80636cd029ef7c"}, + {file = "psycopg2_binary-2.9.10-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b5f86c56eeb91dc3135b3fd8a95dc7ae14c538a2f3ad77a19645cf55bab1799c"}, + {file = "psycopg2_binary-2.9.10-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2b3d2491d4d78b6b14f76881905c7a8a8abcf974aad4a8a0b065273a0ed7a2cb"}, + {file = "psycopg2_binary-2.9.10-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2286791ececda3a723d1910441c793be44625d86d1a4e79942751197f4d30341"}, + {file = "psycopg2_binary-2.9.10-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:512d29bb12608891e349af6a0cccedce51677725a921c07dba6342beaf576f9a"}, + {file = "psycopg2_binary-2.9.10-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:5a507320c58903967ef7384355a4da7ff3f28132d679aeb23572753cbf2ec10b"}, + {file = "psycopg2_binary-2.9.10-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:6d4fa1079cab9018f4d0bd2db307beaa612b0d13ba73b5c6304b9fe2fb441ff7"}, + {file = "psycopg2_binary-2.9.10-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:851485a42dbb0bdc1edcdabdb8557c09c9655dfa2ca0460ff210522e073e319e"}, + {file = "psycopg2_binary-2.9.10-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:35958ec9e46432d9076286dda67942ed6d968b9c3a6a2fd62b48939d1d78bf68"}, + {file = "psycopg2_binary-2.9.10-cp311-cp311-win32.whl", hash = "sha256:ecced182e935529727401b24d76634a357c71c9275b356efafd8a2a91ec07392"}, + {file = "psycopg2_binary-2.9.10-cp311-cp311-win_amd64.whl", hash = "sha256:ee0e8c683a7ff25d23b55b11161c2663d4b099770f6085ff0a20d4505778d6b4"}, + {file = "psycopg2_binary-2.9.10-cp312-cp312-macosx_12_0_x86_64.whl", hash = "sha256:880845dfe1f85d9d5f7c412efea7a08946a46894537e4e5d091732eb1d34d9a0"}, + {file = "psycopg2_binary-2.9.10-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:9440fa522a79356aaa482aa4ba500b65f28e5d0e63b801abf6aa152a29bd842a"}, + {file = "psycopg2_binary-2.9.10-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e3923c1d9870c49a2d44f795df0c889a22380d36ef92440ff618ec315757e539"}, + {file = "psycopg2_binary-2.9.10-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7b2c956c028ea5de47ff3a8d6b3cc3330ab45cf0b7c3da35a2d6ff8420896526"}, + {file = "psycopg2_binary-2.9.10-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f758ed67cab30b9a8d2833609513ce4d3bd027641673d4ebc9c067e4d208eec1"}, + {file = "psycopg2_binary-2.9.10-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8cd9b4f2cfab88ed4a9106192de509464b75a906462fb846b936eabe45c2063e"}, + {file = "psycopg2_binary-2.9.10-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:6dc08420625b5a20b53551c50deae6e231e6371194fa0651dbe0fb206452ae1f"}, + {file = "psycopg2_binary-2.9.10-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:d7cd730dfa7c36dbe8724426bf5612798734bff2d3c3857f36f2733f5bfc7c00"}, + {file = "psycopg2_binary-2.9.10-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:155e69561d54d02b3c3209545fb08938e27889ff5a10c19de8d23eb5a41be8a5"}, + {file = "psycopg2_binary-2.9.10-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c3cc28a6fd5a4a26224007712e79b81dbaee2ffb90ff406256158ec4d7b52b47"}, + {file = "psycopg2_binary-2.9.10-cp312-cp312-win32.whl", hash = "sha256:ec8a77f521a17506a24a5f626cb2aee7850f9b69a0afe704586f63a464f3cd64"}, + {file = "psycopg2_binary-2.9.10-cp312-cp312-win_amd64.whl", hash = "sha256:18c5ee682b9c6dd3696dad6e54cc7ff3a1a9020df6a5c0f861ef8bfd338c3ca0"}, + {file = "psycopg2_binary-2.9.10-cp313-cp313-macosx_12_0_x86_64.whl", hash = "sha256:26540d4a9a4e2b096f1ff9cce51253d0504dca5a85872c7f7be23be5a53eb18d"}, + {file = "psycopg2_binary-2.9.10-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:e217ce4d37667df0bc1c397fdcd8de5e81018ef305aed9415c3b093faaeb10fb"}, + {file = "psycopg2_binary-2.9.10-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:245159e7ab20a71d989da00f280ca57da7641fa2cdcf71749c193cea540a74f7"}, + {file = "psycopg2_binary-2.9.10-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3c4ded1a24b20021ebe677b7b08ad10bf09aac197d6943bfe6fec70ac4e4690d"}, + {file = "psycopg2_binary-2.9.10-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3abb691ff9e57d4a93355f60d4f4c1dd2d68326c968e7db17ea96df3c023ef73"}, + {file = "psycopg2_binary-2.9.10-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8608c078134f0b3cbd9f89b34bd60a943b23fd33cc5f065e8d5f840061bd0673"}, + {file = "psycopg2_binary-2.9.10-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:230eeae2d71594103cd5b93fd29d1ace6420d0b86f4778739cb1a5a32f607d1f"}, + {file = "psycopg2_binary-2.9.10-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:bb89f0a835bcfc1d42ccd5f41f04870c1b936d8507c6df12b7737febc40f0909"}, + {file = "psycopg2_binary-2.9.10-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:f0c2d907a1e102526dd2986df638343388b94c33860ff3bbe1384130828714b1"}, + {file = "psycopg2_binary-2.9.10-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f8157bed2f51db683f31306aa497311b560f2265998122abe1dce6428bd86567"}, + {file = "psycopg2_binary-2.9.10-cp38-cp38-macosx_12_0_x86_64.whl", hash = "sha256:eb09aa7f9cecb45027683bb55aebaaf45a0df8bf6de68801a6afdc7947bb09d4"}, + {file = "psycopg2_binary-2.9.10-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b73d6d7f0ccdad7bc43e6d34273f70d587ef62f824d7261c4ae9b8b1b6af90e8"}, + {file = "psycopg2_binary-2.9.10-cp38-cp38-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ce5ab4bf46a211a8e924d307c1b1fcda82368586a19d0a24f8ae166f5c784864"}, + {file = "psycopg2_binary-2.9.10-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:056470c3dc57904bbf63d6f534988bafc4e970ffd50f6271fc4ee7daad9498a5"}, + {file = "psycopg2_binary-2.9.10-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:73aa0e31fa4bb82578f3a6c74a73c273367727de397a7a0f07bd83cbea696baa"}, + {file = "psycopg2_binary-2.9.10-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:8de718c0e1c4b982a54b41779667242bc630b2197948405b7bd8ce16bcecac92"}, + {file = "psycopg2_binary-2.9.10-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:5c370b1e4975df846b0277b4deba86419ca77dbc25047f535b0bb03d1a544d44"}, + {file = "psycopg2_binary-2.9.10-cp38-cp38-musllinux_1_2_ppc64le.whl", hash = "sha256:ffe8ed017e4ed70f68b7b371d84b7d4a790368db9203dfc2d222febd3a9c8863"}, + {file = "psycopg2_binary-2.9.10-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:8aecc5e80c63f7459a1a2ab2c64df952051df196294d9f739933a9f6687e86b3"}, + {file = "psycopg2_binary-2.9.10-cp39-cp39-macosx_12_0_x86_64.whl", hash = "sha256:7a813c8bdbaaaab1f078014b9b0b13f5de757e2b5d9be6403639b298a04d218b"}, + {file = "psycopg2_binary-2.9.10-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d00924255d7fc916ef66e4bf22f354a940c67179ad3fd7067d7a0a9c84d2fbfc"}, + {file = "psycopg2_binary-2.9.10-cp39-cp39-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7559bce4b505762d737172556a4e6ea8a9998ecac1e39b5233465093e8cee697"}, + {file = "psycopg2_binary-2.9.10-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e8b58f0a96e7a1e341fc894f62c1177a7c83febebb5ff9123b579418fdc8a481"}, + {file = "psycopg2_binary-2.9.10-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6b269105e59ac96aba877c1707c600ae55711d9dcd3fc4b5012e4af68e30c648"}, + {file = "psycopg2_binary-2.9.10-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:79625966e176dc97ddabc142351e0409e28acf4660b88d1cf6adb876d20c490d"}, + {file = "psycopg2_binary-2.9.10-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:8aabf1c1a04584c168984ac678a668094d831f152859d06e055288fa515e4d30"}, + {file = "psycopg2_binary-2.9.10-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:19721ac03892001ee8fdd11507e6a2e01f4e37014def96379411ca99d78aeb2c"}, + {file = "psycopg2_binary-2.9.10-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:7f5d859928e635fa3ce3477704acee0f667b3a3d3e4bb109f2b18d4005f38287"}, + {file = "psycopg2_binary-2.9.10-cp39-cp39-win32.whl", hash = "sha256:3216ccf953b3f267691c90c6fe742e45d890d8272326b4a8b20850a03d05b7b8"}, + {file = "psycopg2_binary-2.9.10-cp39-cp39-win_amd64.whl", hash = "sha256:30e34c4e97964805f715206c7b789d54a78b70f3ff19fbe590104b71c45600e5"}, ] [[package]] @@ -3565,6 +3630,24 @@ urllib3 = ">=1.21.1,<3" socks = ["PySocks (>=1.5.6,!=1.5.7)"] use-chardet-on-py3 = ["chardet (>=3.0.2,<6)"] +[[package]] +name = "rich" +version = "13.9.3" +description = "Render rich text, tables, progress bars, syntax highlighting, markdown and more to the terminal" +optional = false +python-versions = ">=3.8.0" +files = [ + {file = "rich-13.9.3-py3-none-any.whl", hash = "sha256:9836f5096eb2172c9e77df411c1b009bace4193d6a481d534fea75ebba758283"}, + {file = "rich-13.9.3.tar.gz", hash = "sha256:bc1e01b899537598cf02579d2b9f4a415104d3fc439313a7a2c165d76557a08e"}, +] + +[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 = "rq" version = "1.11.1" @@ -3847,13 +3930,13 @@ tornado = ["tornado (>=6)"] [[package]] name = "setuptools" -version = "75.1.0" +version = "75.2.0" description = "Easily download, build, install, upgrade, and uninstall Python packages" optional = false python-versions = ">=3.8" files = [ - {file = "setuptools-75.1.0-py3-none-any.whl", hash = "sha256:35ab7fd3bcd95e6b7fd704e4a1539513edad446c097797f2985e0e4b960772f2"}, - {file = "setuptools-75.1.0.tar.gz", hash = "sha256:d59a21b17a275fb872a9c3dae73963160ae079f1049ed956880cd7c09b120538"}, + {file = "setuptools-75.2.0-py3-none-any.whl", hash = "sha256:a7fcb66f68b4d9e8e66b42f9876150a3371558f98fa32222ffaa5bced76406f8"}, + {file = "setuptools-75.2.0.tar.gz", hash = "sha256:753bb6ebf1f465a1912e19ed1d41f403a79173a9acf66a42e7e6aec45c3c16ec"}, ] [package.extras] @@ -4412,13 +4495,13 @@ zstd = ["zstandard (>=0.18.0)"] [[package]] name = "virtualenv" -version = "20.26.6" +version = "20.27.0" description = "Virtual Python Environment builder" optional = false -python-versions = ">=3.7" +python-versions = ">=3.8" files = [ - {file = "virtualenv-20.26.6-py3-none-any.whl", hash = "sha256:7345cc5b25405607a624d8418154577459c3e0277f5466dd79c49d5e492995f2"}, - {file = "virtualenv-20.26.6.tar.gz", hash = "sha256:280aede09a2a5c317e409a00102e7077c6432c5a38f0ef938e643805a7ad2c48"}, + {file = "virtualenv-20.27.0-py3-none-any.whl", hash = "sha256:44a72c29cceb0ee08f300b314848c86e57bf8d1f13107a5e671fb9274138d655"}, + {file = "virtualenv-20.27.0.tar.gz", hash = "sha256:2ca56a68ed615b8fe4326d11a0dca5dfbe8fd68510fb6c6349163bed3c15f2b2"}, ] [package.dependencies] @@ -4506,4 +4589,4 @@ watchdog = ["watchdog (>=2.3)"] [metadata] lock-version = "2.0" python-versions = "^3.11" -content-hash = "11036af989151e172ace378e8391ef9ef3a856df40aff0c1ef6f825a7155bd2a" +content-hash = "2ea79042966beabcc2cf7384fd1c819610e59e1970edbcf1bd9453cd92ccc51f" diff --git a/pyproject.toml b/pyproject.toml index c79eeb84ad..09d533d271 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -83,6 +83,8 @@ duckdb = "~1.0.0" google-cloud-storage = "~2.14.0" pandas = "~2.2.2" pyarrow = "~17.0.0" +# Used for CLI pretty print +rich = "~13.9.2" [tool.poetry.dependencies.sentry-sdk] version = ">=1.14,<2.9" diff --git a/robotoff/cli/main.py b/robotoff/cli/main.py index d242d44e9a..9652788988 100644 --- a/robotoff/cli/main.py +++ b/robotoff/cli/main.py @@ -93,12 +93,19 @@ def create_redis_update( get_logger() client = get_redis_client() + flavor_to_product_type = { + "off": "food", + "obf": "beauty", + "opff": "petfood", + "opf": "product", + } event = { "code": barcode, "flavor": flavor, "user_id": user_id, "action": action, "comment": comment, + "product_type": flavor_to_product_type[flavor], } diffs: JSONType @@ -602,6 +609,45 @@ def run_object_detection_model( ) +@app.command() +def run_nutrition_extraction( + image_url: str = typer.Argument( + ..., help="URL of the image to run nutrition extraction on" + ), + triton_uri: Optional[str] = typer.Option( + None, + help="URI of the Triton Inference Server to use. If not provided, the default value from settings is used.", + ), +) -> None: + """Run nutrition extraction on a product image. + + The image URL should be an Open Food Facts image URL, e.g. + https://images.openfoodfacts.org/images/products/327/408/000/5003/3.jpg + + The OCR JSON is expected to be available at the same URL with a `.json` + extension, e.g. + https://images.openfoodfacts.org/images/products/327/408/000/5003/3.json + + Prediction is printed to stdout. + """ + from typing import cast + + from openfoodfacts.ocr import OCRResult + from PIL import Image + from rich import print as pprint + + from robotoff.images import get_image_from_url + from robotoff.prediction.nutrition_extraction import predict + + image = cast(Image.Image, get_image_from_url(image_url)) + ocr_result = cast(OCRResult, OCRResult.from_url(image_url.replace(".jpg", ".json"))) + prediction = predict(image, ocr_result, triton_uri=triton_uri) + if prediction is not None: + pprint(prediction) + else: + pprint("No prediction") + + @app.command() def init_elasticsearch() -> None: """This command is used for index creation.""" diff --git a/robotoff/insights/annotate.py b/robotoff/insights/annotate.py index fd096d1429..0e7f58a814 100644 --- a/robotoff/insights/annotate.py +++ b/robotoff/insights/annotate.py @@ -660,24 +660,6 @@ def process_annotation( return UPDATED_ANNOTATION_RESULT -class NutritionTableStructureAnnotator(InsightAnnotator): - @classmethod - def process_annotation( - cls, - insight: ProductInsight, - data: Optional[dict] = None, - auth: Optional[OFFAuthentication] = None, - is_vote: bool = False, - ) -> AnnotationResult: - insight.data["annotation"] = data - insight.save() - return SAVED_ANNOTATION_RESULT - - @classmethod - def is_data_required(cls) -> bool: - return True - - class IngredientSpellcheckAnnotator(InsightAnnotator): @classmethod def process_annotation( @@ -687,7 +669,8 @@ def process_annotation( auth: Optional[OFFAuthentication] = None, is_vote: bool = False, ) -> AnnotationResult: - # Possibility for the annotator to change the spellcheck correction if data is provided + # Possibility for the annotator to change the spellcheck correction if data is + # provided if data is not None: annotation = data.get("annotation") if not annotation or len(data) > 1: @@ -720,7 +703,6 @@ def process_annotation( InsightType.store.name: StoreAnnotator, InsightType.packaging.name: PackagingAnnotator, InsightType.nutrition_image.name: NutritionImageAnnotator, - InsightType.nutrition_table_structure.name: NutritionTableStructureAnnotator, InsightType.is_upc_image.name: UPCImageAnnotator, InsightType.ingredient_spellcheck.name: IngredientSpellcheckAnnotator, } diff --git a/robotoff/insights/importer.py b/robotoff/insights/importer.py index bc4c757904..6bc449c345 100644 --- a/robotoff/insights/importer.py +++ b/robotoff/insights/importer.py @@ -1524,6 +1524,33 @@ def _keep_prediction( ) +class NutrientExtractionImporter(InsightImporter): + @staticmethod + def get_type() -> InsightType: + return InsightType.nutrient_extraction + + @classmethod + def get_required_prediction_types(cls) -> set[PredictionType]: + return {PredictionType.nutrient_extraction} + + @classmethod + def generate_candidates( + cls, + product: Optional[Product], + predictions: list[Prediction], + product_id: ProductIdentifier, + ) -> Iterator[ProductInsight]: + for prediction in predictions: + yield ProductInsight(**prediction.to_dict()) + + @classmethod + def is_conflicting_insight( + cls, candidate: ProductInsight, reference: ProductInsight + ) -> bool: + # Only one insight per product + return True + + class PackagingElementTaxonomyException(Exception): pass @@ -1860,6 +1887,7 @@ def import_product_predictions( UPCImageImporter, NutritionImageImporter, IngredientSpellcheckImporter, + NutrientExtractionImporter, ] diff --git a/robotoff/off.py b/robotoff/off.py index efbb47b89f..c183fc44c3 100644 --- a/robotoff/off.py +++ b/robotoff/off.py @@ -68,8 +68,16 @@ def get_username(self) -> Optional[str]: return None -def get_source_from_url(ocr_url: str) -> str: - url_path = urlparse(ocr_url).path +def get_source_from_url(url: str) -> str: + """Get the `source_image` field from an image or OCR URL. + + It's the path of the image or OCR JSON file, but without the `/images/products` + prefix. It always ends with `.jpg`, whather it's an image or an OCR JSON file. + + :param url: the URL of the image or OCR JSON file + :return: the source image path + """ + url_path = urlparse(url).path if url_path.startswith("/images/products"): url_path = url_path[len("/images/products") :] diff --git a/robotoff/prediction/nutrition_extraction/__init__.py b/robotoff/prediction/nutrition_extraction/__init__.py new file mode 100644 index 0000000000..7f4e90ff26 --- /dev/null +++ b/robotoff/prediction/nutrition_extraction/__init__.py @@ -0,0 +1,568 @@ +import dataclasses +import functools +import re +import typing +from collections import Counter +from pathlib import Path + +import numpy as np +from openfoodfacts.ocr import OCRResult +from openfoodfacts.utils import load_json +from PIL import Image +from transformers import AutoProcessor, BatchEncoding, PreTrainedTokenizerBase +from tritonclient.grpc import service_pb2 + +from robotoff import settings +from robotoff.triton import ( + GRPCInferenceServiceStub, + add_triton_infer_input_tensor, + get_triton_inference_stub, +) +from robotoff.types import JSONType +from robotoff.utils.logger import get_logger + +logger = get_logger(__name__) + +MODEL_NAME = "nutrition_extractor" +MODEL_VERSION = f"{MODEL_NAME}-1.0" + +# The tokenizer assets are stored in the model directory +MODEL_DIR = settings.TRITON_MODELS_DIR / f"{MODEL_NAME}/1/model.onnx" + + +@dataclasses.dataclass +class NutrientPrediction: + entity: str + text: str + value: str | None + unit: str | None + score: float + start: int + end: int + char_start: int + char_end: int + + +@dataclasses.dataclass +class NutritionEntities: + raw: list[dict] + aggregated: list[dict] + postprocessed: list[dict] + + +@dataclasses.dataclass +class NutritionExtractionPrediction: + nutrients: dict[str, NutrientPrediction] + entities: NutritionEntities + + +def predict( + image: Image.Image, + ocr_result: OCRResult, + model_version: str = "1", + triton_uri: str | None = None, +) -> NutritionExtractionPrediction | None: + """Predict the nutrient values from an image and an OCR result. + + The function returns a `NutritionExtractionPrediction` object with the following + fields: + + - `nutrients`: a dictionary mapping nutrient names to `NutrientPrediction` objects + - `entities`: a `NutritionEntities` object containing the raw, aggregated and + postprocessed entities + + If the OCR result does not contain any text annotation, the function returns + `None`. + + :param image: the *original* image (not resized) + :param ocr_result: the OCR result + :param model_version: the version of the model to use, defaults to "1" + :param triton_uri: the URI of the Triton Inference Server, if not provided, the + default value from settings is used + :return: a `NutritionExtractionPrediction` object + """ + triton_stub = get_triton_inference_stub(triton_uri) + id2label = get_id2label(MODEL_DIR) + processor = get_processor(MODEL_DIR) + + preprocess_result = preprocess(image, ocr_result, processor) + + if preprocess_result is None: + return None + + words, char_offsets, _, batch_encoding = preprocess_result + logits = send_infer_request( + input_ids=batch_encoding.input_ids, + attention_mask=batch_encoding.attention_mask, + bbox=batch_encoding.bbox, + pixel_values=batch_encoding.pixel_values, + model_name=MODEL_NAME, + triton_stub=triton_stub, + model_version=model_version, + ) + return postprocess(logits[0], words, char_offsets, batch_encoding, id2label) + + +def preprocess( + image: Image.Image, ocr_result: OCRResult, processor +) -> ( + tuple[ + list[str], list[tuple[int, int]], list[tuple[int, int, int, int]], BatchEncoding + ] + | None +): + """Preprocess an image and OCR result for the LayoutLMv3 model. + + The *original* image must be provided, as we use the image size to normalize + the bounding boxes. + + The function returns a tuple containing the following elements: + + - `words`: a list of words + - `char_offsets`: a list of character offsets + - `bboxes`: a list of bounding boxes + - `batch_encoding`: the BatchEncoding returned by the tokenizer + + If the OCR result does not contain any text annotation, the function returns + `None`. + + :param image: the original image + :param ocr_result: the OCR result + :param processor: the LaymoutLM processor + :return: a tuple containing the words, character offsets, bounding boxes and + BatchEncoding + """ + if not ocr_result.full_text_annotation: + return None + + words = [] + char_offsets = [] + bboxes = [] + width, height = image.size + for page in ocr_result.full_text_annotation.pages: + for block in page.blocks: + for paragraph in block.paragraphs: + for word in paragraph.words: + words.append(word.text) + char_offsets.append((word.start_idx, word.end_idx)) + vertices = word.bounding_poly.vertices + # LayoutLM requires an integer between 0 and 1000 (excluded) + # for the dataset + x_min = int(min(v[0] for v in vertices) * 1000 / width) + x_max = int(max(v[0] for v in vertices) * 1000 / width) + y_min = int(min(v[1] for v in vertices) * 1000 / height) + y_max = int(max(v[1] for v in vertices) * 1000 / height) + bboxes.append( + ( + max(0, min(999, x_min)), + max(0, min(999, y_min)), + max(0, min(999, x_max)), + max(0, min(999, y_max)), + ) + ) + + batch_encoding = processor( + [image], + [words], + boxes=[bboxes], + truncation=True, + padding=False, + return_tensors="np", + return_offsets_mapping=True, + return_special_tokens_mask=True, + ) + return words, char_offsets, bboxes, batch_encoding + + +def postprocess( + logits: np.ndarray, + words: list[str], + char_offsets: list[tuple[int, int]], + batch_encoding: BatchEncoding, + id2label: dict[int, str], +) -> NutritionExtractionPrediction: + """Postprocess the model output to extract the nutrient predictions. + + The function returns a `NutritionExtractionPrediction` object with the following + fields: + + - `nutrients`: a dictionary mapping nutrient names to `NutrientPrediction` objects + - `entities`: a `NutritionEntities` object containing the raw, aggregated and + postprocessed entities + + :param logits: the predicted logits + :param words: the words corresponding to the input + :param char_offsets: the character offsets of the words + :param batch_encoding: the BatchEncoding returned by the tokenizer + :param id2label: a dictionary mapping label IDs to label names + :return: a `NutritionExtractionPrediction` object + """ + pre_entities = gather_pre_entities( + logits, words, char_offsets, batch_encoding, id2label + ) + aggregated_entities = aggregate_entities(pre_entities) + postprocessed_entities = postprocess_aggregated_entities(aggregated_entities) + return NutritionExtractionPrediction( + nutrients={ + entity["entity"]: NutrientPrediction( + **{k: v for k, v in entity.items() if k != "valid"} + ) + for entity in postprocessed_entities + if entity["valid"] + }, + entities=NutritionEntities( + raw=pre_entities, + aggregated=aggregated_entities, + postprocessed=postprocessed_entities, + ), + ) + + +def gather_pre_entities( + logits: np.ndarray, + words: list[str], + char_offsets: list[tuple[int, int]], + batch_encoding: BatchEncoding, + id2label: dict[int, str], +) -> list[JSONType]: + """Gather the pre-entities extracted by the model. + + This function takes as input the predicted logits returned by the model and + additional preprocessing outputs (words, char_offsets, batch_encoding) and returns a + list of pre-entities with the following fields: + + - `word`: the word corresponding to the entity (string) + - `entity`: the entity type (string, ex: "ENERGY_KCAL_100G") + - `score`: the score of the entity (float) + - `index`: the index of the word in the input + - `char_start`: the character start index of the entity + - `char_end`: the character end index of the entity + + :param logits: the predicted logits + :param words: the words corresponding to the input + :param char_offsets: the character offsets of the words + :param batch_encoding: the BatchEncoding returned by the tokenizer + :param id2label: a dictionary mapping label IDs to label names + :return: a list of pre-entities + """ + special_tokens_mask = batch_encoding.special_tokens_mask[0] + + maxes = np.max(logits, axis=-1, keepdims=True) + shifted_exp = np.exp(logits - maxes) + scores = shifted_exp / shifted_exp.sum(axis=-1, keepdims=True) + label_ids = logits.argmax(axis=-1) + + pre_entities = [] + previous_word_id = None + word_ids = batch_encoding.word_ids() + + for idx in range(len(scores)): + # idx may be out of bounds if the input_ids are padded + # word_id corresponds to the index of the input words, while + # idx is the index of the token. A word can have multiple tokens + # if it is split into subwords. + word_id = word_ids[idx] if idx < len(word_ids) else None + # Filter special_tokens (BOS, EOS, PAD) + if special_tokens_mask[idx]: + previous_word_id = word_id + continue + + # The token is a subword if it has the same word_id as the previous token + is_subword = word_id == previous_word_id + if int(batch_encoding.input_ids[0, idx]) == 3: # unknown token + is_subword = False + + if is_subword: + # If the token is a subword, we skip it + # The entity will be attached to the first token of the word + # and the score will be the score of the first token + continue + + previous_word_id = word_id + word = words[word_id] + label_id = label_ids[idx] + score = float(scores[idx, label_id]) + label = id2label[label_id] + entity = label.split("-", maxsplit=1)[-1] + + pre_entity = { + "word": word, + "entity": entity, + "score": score, + "index": word_id, + "char_start": char_offsets[word_id][0], + "char_end": char_offsets[word_id][1], + } + pre_entities.append(pre_entity) + return pre_entities + + +def aggregate_entities(pre_entities: list[JSONType]) -> list[JSONType]: + """Aggregate the entities extracted by the model. + + This function takes as input the list of pre-entities (see the + `gather_pre_entities` function) and aggregate them into entities with the + following fields: + + - `entity`: the entity type (string, ex: "ENERGY_KCAL_100G") + - `words`: the words forming the entity (list of strings) + - `score`: the score of the entity (float), we use the score of the first token + - `start`: the token start index of the entity + - `end`: the token end index of the entity + - `char_start`: the character start index of the entity + - `char_end`: the character end index of the entity + + The entities are aggregated by grouping consecutive tokens with the same entity + type. + """ + entities = [] + + current_entity = None + for pre_entity in pre_entities: + if pre_entity["entity"] == "O": + if current_entity is not None: + entities.append(current_entity) + current_entity = None + continue + + if current_entity is None: + current_entity = { + "entity": pre_entity["entity"], + "words": [pre_entity["word"]], + # We use the score of the first word as the score of the entity + "score": pre_entity["score"], + "start": pre_entity["index"], + "end": pre_entity["index"] + 1, + "char_start": pre_entity["char_start"], + "char_end": pre_entity["char_end"], + } + continue + + if current_entity["entity"] == pre_entity["entity"]: + current_entity["words"].append(pre_entity["word"]) + current_entity["end"] = pre_entity["index"] + 1 + current_entity["char_end"] = pre_entity["char_end"] + continue + + # If we reach this point, the entity has changed + entities.append(current_entity) + current_entity = { + "entity": pre_entity["entity"], + "words": [pre_entity["word"]], + "score": pre_entity["score"], + "start": pre_entity["index"], + "end": pre_entity["index"] + 1, + "char_start": pre_entity["char_start"], + "char_end": pre_entity["char_end"], + } + + if current_entity is not None: + entities.append(current_entity) + + return entities + + +# We match "O" to handle the case where the OCR engine failed to +# recognize correctly "0" (zero) and uses "O" (letter O) instead +NUTRIENT_VALUE_REGEX = re.compile( + r"((?:[0-9]+[,.]?[0-9]*)|O) ?(g|mg|µg|mcg|kj|kcal)?", re.I +) + + +def postprocess_aggregated_entities( + aggregated_entities: list[JSONType], +) -> list[JSONType]: + """Postprocess the aggregated entities to extract the nutrient values. + + This function takes as input the list of aggregated entities (see the + `aggregate_entities` function) and add the following fields to each entity: + + - `value`: the nutrient value (string, ex: "12.5") + - `unit`: the nutrient unit (string, ex: "g") + - `valid`: a boolean indicating whether the entity is valid or not + - `invalid_reason`: a string indicating the reason why the entity is invalid + - `text`: the text of the entity + + The field `words` is removed from the aggregated entities. + + Some additional postprocessing steps are also done to handle specific cases: + + - The OCR engine can split incorrectly tokens for energy nutrients + - The OCR engine can fail to detect the word corresponding to the unit after the + value + """ + postprocessed_entities = [] + + for entity in aggregated_entities: + words = [word.strip().strip("()/") for word in entity["words"]] + if entity["entity"].startswith("ENERGY_"): + # Due to incorrect token split by the OCR, the unit (kcal or kj) can be + # attached to the next token. + # Ex: "525 kJ/126 kcal" is often tokenized into ["525", "kJ/"126", "kcal"] + # We handle this case here. + if len(words[0]) > 3 and words[0][:3].lower() == "kj/": + words[0] = words[0][3:] + + words_str = " ".join(words) + value = None + unit = None + is_valid = True + + if entity["entity"] == "SERVING_SIZE": + value = words_str + elif words_str in ("trace", "traces"): + value = "traces" + else: + match = NUTRIENT_VALUE_REGEX.search(words_str) + if match: + value = match.group(1).replace(",", ".") + + if value == "O": + # The OCR engine failed to recognize correctly "0" (zero) and uses + # "O" (letter O) instead + value = "0" + unit = match.group(2) + # Unit can be none if the OCR engine didn't detect the unit after the + # value as a word + if unit is None: + if entity["entity"].startswith("ENERGY_"): + # Due to incorrect splitting by OCR engine, we don't necessarily + # have a unit for energy, but as the entity can only have a + # single unit (kcal or kJ), we infer the unit from the entity + # name + unit = entity["entity"].split("_")[1].lower() + else: + unit = unit.lower() + if unit == "mcg": + unit = "µg" + else: + logger.warning("Could not extract nutrient value from %s", words_str) + is_valid = False + + postprocessed_entity = { + "entity": entity["entity"].lower(), + "text": words_str, + "value": value, + "unit": unit, + "score": entity["score"], + "start": entity["start"], + "end": entity["end"], + "char_start": entity["char_start"], + "char_end": entity["char_end"], + "valid": is_valid, + } + postprocessed_entities.append(postprocessed_entity) + + entity_type_multiple = set( + entity + for entity, count in Counter( + entity["entity"] for entity in postprocessed_entities + ).items() + if count > 1 + ) + for postprocessed_entity in postprocessed_entities: + if postprocessed_entity["entity"] in entity_type_multiple: + postprocessed_entity["valid"] = False + postprocessed_entity["invalid_reason"] = "multiple_entities" + + return postprocessed_entities + + +@functools.cache +def get_processor(model_dir: Path) -> PreTrainedTokenizerBase: + """Return the processor located in `model_dir`. + + The processor is only loaded once and then cached in memory. + + :param model_dir: the model directory + :return: the processor + """ + return AutoProcessor.from_pretrained(model_dir) + + +@functools.cache +def get_id2label(model_dir: Path) -> dict[int, str]: + """Return a dictionary mapping label IDs to labels for a model located in + `model_dir`.""" + config_path = model_dir / "config.json" + + if not config_path.exists(): + raise ValueError(f"Model config not found in {model_dir}") + + id2label = typing.cast(dict, load_json(config_path))["id2label"] + return {int(k): v for k, v in id2label.items()} + + +def send_infer_request( + input_ids: np.ndarray, + attention_mask: np.ndarray, + bbox: np.ndarray, + pixel_values: np.ndarray, + model_name: str, + triton_stub: GRPCInferenceServiceStub, + model_version: str = "1", +) -> np.ndarray: + """Send a NER infer request to the Triton inference server. + + The first dimension of `input_ids` and `attention_mask` must be the batch + dimension. This function returns the predicted logits. + + :param input_ids: input IDs, generated using the transformers tokenizer. + :param attention_mask: attention mask, generated using the transformers + tokenizer. + :param bbox: bounding boxes of the tokens, generated using the transformers + tokenizer. + :param pixel_values: pixel values of the image, generated using the + transformers tokenizer. + :param model_name: the name of the model to use + :param model_version: version of the model model to use, defaults to "1" + :return: the predicted logits + """ + request = build_triton_request( + input_ids=input_ids, + attention_mask=attention_mask, + bbox=bbox, + pixel_values=pixel_values, + model_name=model_name, + model_version=model_version, + ) + response = triton_stub.ModelInfer(request) + num_tokens = response.outputs[0].shape[1] + num_labels = response.outputs[0].shape[2] + return np.frombuffer( + response.raw_output_contents[0], + dtype=np.float32, + ).reshape((len(input_ids), num_tokens, num_labels)) + + +def build_triton_request( + input_ids: np.ndarray, + attention_mask: np.ndarray, + bbox: np.ndarray, + pixel_values: np.ndarray, + model_name: str, + model_version: str = "1", +): + """Build a Triton ModelInferRequest gRPC request for LayoutLMv3 models. + + :param input_ids: input IDs, generated using the transformers tokenizer. + :param attention_mask: attention mask, generated using the transformers + tokenizer. + :param bbox: bounding boxes of the tokens, generated using the transformers + tokenizer. + :param pixel_values: pixel values of the image, generated using the + transformers tokenizer. + :param model_name: the name of the model to use. + :param model_version: version of the model model to use, defaults to "1". + :return: the gRPC ModelInferRequest + """ + request = service_pb2.ModelInferRequest() + request.model_name = model_name + request.model_version = model_version + + add_triton_infer_input_tensor(request, "input_ids", input_ids, "INT64") + add_triton_infer_input_tensor(request, "attention_mask", attention_mask, "INT64") + add_triton_infer_input_tensor(request, "bbox", bbox, "INT64") + add_triton_infer_input_tensor(request, "pixel_values", pixel_values, "FP32") + + return request diff --git a/robotoff/products.py b/robotoff/products.py index b7c1dae89c..aaeb16cae5 100644 --- a/robotoff/products.py +++ b/robotoff/products.py @@ -418,6 +418,7 @@ class Product: "packagings", "lang", "ingredients_text", + "nutriments", ) def __init__(self, product: JSONType): @@ -441,6 +442,7 @@ def __init__(self, product: JSONType): ) self.lang: Optional[str] = product.get("lang") self.ingredients_text: Optional[str] = product.get("ingredients_text") + self.nutriments: JSONType = product.get("nutriments") or {} @staticmethod def get_fields(): @@ -458,6 +460,7 @@ def get_fields(): "images", "lang", "ingredients_text", + "nutriments", } diff --git a/robotoff/triton.py b/robotoff/triton.py index 63182bce75..84a06d763b 100644 --- a/robotoff/triton.py +++ b/robotoff/triton.py @@ -159,3 +159,19 @@ def serialize_byte_tensor(input_tensor): flattened = b"".join(flattened_ls) return flattened return None + + +def add_triton_infer_input_tensor(request, name: str, data: np.ndarray, datatype: str): + """Create and add an input tensor to a Triton gRPC Inference request. + + :param request: the Triton Inference request + :param name: the name of the input tensor + :param data: the input tensor data + :param datatype: the datatype of the input tensor (e.g. "FP32") + """ + input_tensor = service_pb2.ModelInferRequest().InferInputTensor() + input_tensor.name = name + input_tensor.datatype = datatype + input_tensor.shape.extend(data.shape) + request.inputs.extend([input_tensor]) + request.raw_input_contents.extend([data.tobytes()]) diff --git a/robotoff/types.py b/robotoff/types.py index d0ad8443bd..37b9b2b9f0 100644 --- a/robotoff/types.py +++ b/robotoff/types.py @@ -49,8 +49,8 @@ class PredictionType(str, enum.Enum): nutrient_mention = "nutrient_mention" image_lang = "image_lang" nutrition_image = "nutrition_image" - nutrition_table_structure = "nutrition_table_structure" is_upc_image = "is_upc_image" + nutrient_extraction = "nutrient_extraction" @enum.unique @@ -150,15 +150,13 @@ class InsightType(str, enum.Enum): # product main language. nutrition_image = "nutrition_image" - # The 'nutritional_table_structure' insight detects the nutritional table - # structure from the image. NOTE: this insight has not been generated since - # 2020. - nutrition_table_structure = "nutrition_table_structure" - # The 'is_upc_image' insight predicts whether or not the image is largely # dominated by a UPC (barcode) is_upc_image = "is_upc_image" + # Nutrient values extracted from images + nutrient_extraction = "nutrient_extraction" + class ServerType(str, enum.Enum): """ServerType is used to refer to a specific Open*Facts project: diff --git a/robotoff/workers/tasks/import_image.py b/robotoff/workers/tasks/import_image.py index 1a7ef458d8..b5b140a047 100644 --- a/robotoff/workers/tasks/import_image.py +++ b/robotoff/workers/tasks/import_image.py @@ -32,13 +32,12 @@ ImagePrediction, LogoAnnotation, LogoEmbedding, - Prediction, db, with_db, ) from robotoff.notifier import NotifierFactory from robotoff.off import generate_image_url, get_source_from_url, parse_ingredients -from robotoff.prediction import ingredient_list +from robotoff.prediction import ingredient_list, nutrition_extraction from robotoff.prediction.upc_image import UPCImageType, find_image_is_upc from robotoff.products import get_product_store from robotoff.taxonomy import get_taxonomy @@ -50,6 +49,7 @@ from robotoff.types import ( JSONType, ObjectDetectionModel, + Prediction, PredictionType, ProductIdentifier, ServerType, @@ -137,6 +137,14 @@ def run_import_image_job(product_id: ProductIdentifier, image_url: str, ocr_url: product_id=product_id, ocr_url=ocr_url, ) + enqueue_job( + extract_nutrition_job, + get_high_queue(product_id), + job_kwargs={"result_ttl": 0, "timeout": "2m"}, + product_id=product_id, + image_url=image_url, + ocr_url=ocr_url, + ) # We make sure there are no concurrent insight processing by sending # the job to the same queue. The queue is selected based on the product # barcode. See `get_high_queue` documentation for more details. @@ -779,3 +787,101 @@ def add_ingredient_in_taxonomy_field( known_ingredients_n += known_sub_ingredients_n return ingredients_n, known_ingredients_n + + +@with_db +def extract_nutrition_job( + product_id: ProductIdentifier, + image_url: str, + ocr_url: str, + triton_uri: str | None = None, +) -> None: + """Extract nutrition information from an image OCR, and save the prediction + in the DB. + + :param product_id: The identifier of the product to extract nutrition + information for. + :param image_url: The URL of the image to extract nutrition information + from. + :param ocr_url: The URL of the OCR JSON file + :param triton_uri: URI of the Triton Inference Server, defaults to None. If + not provided, the default value from settings is used. + """ + logger.info("Running nutrition extraction for %s, image %s", product_id, image_url) + source_image = get_source_from_url(image_url) + + with db: + image_model = ImageModel.get_or_none( + source_image=source_image, server_type=product_id.server_type.name + ) + + if not image_model: + logger.info("Missing image in DB for image %s", source_image) + return + + # Stop the job here if the image has already been processed + if ( + ImagePrediction.get_or_none( + image=image_model, model_name=nutrition_extraction.MODEL_NAME + ) + ) is not None: + return + + image = get_image_from_url( + image_url, error_raise=False, session=http_session, use_cache=True + ) + + if image is None: + logger.info("Error while downloading image %s", image_url) + return + + ocr_result = OCRResult.from_url(ocr_url, http_session, error_raise=False) + + if ocr_result is None: + logger.info("Error while downloading OCR JSON %s", ocr_url) + return + + output = nutrition_extraction.predict(image, ocr_result, triton_uri=triton_uri) + + if output is None: + data: JSONType = {"error": "missing_text"} + max_confidence = None + else: + max_confidence = max( + entity["score"] for entity in output.entities.aggregated + ) + data = { + "nutrients": { + entity: dataclasses.asdict(nutrient) + for entity, nutrient in output.nutrients.items() + }, + "entities": dataclasses.asdict(output.entities), + } + logger.info("create image prediction (nutrition extraction) from %s", ocr_url) + ImagePrediction.create( + image=image_model, + type="nutrition_extraction", + model_name=nutrition_extraction.MODEL_NAME, + model_version=nutrition_extraction.MODEL_VERSION, + data=data, + timestamp=datetime.datetime.now(datetime.timezone.utc), + max_confidence=max_confidence, + ) + + if max_confidence is not None: + prediction = Prediction( + barcode=product_id.barcode, + type=PredictionType.nutrient_extraction, + # value and value_tag are None, all data is in data field + value_tag=None, + value=None, + automatic_processing=False, + predictor=nutrition_extraction.MODEL_NAME, + predictor_version=nutrition_extraction.MODEL_VERSION, + data=data, + confidence=None, + server_type=product_id.server_type, + source_image=source_image, + ) + imported = import_insights([prediction], server_type=product_id.server_type) + logger.info(imported) diff --git a/tests/unit/prediction/test_nutrition_extraction.py b/tests/unit/prediction/test_nutrition_extraction.py new file mode 100644 index 0000000000..6dea1e2480 --- /dev/null +++ b/tests/unit/prediction/test_nutrition_extraction.py @@ -0,0 +1,370 @@ +from robotoff.prediction.nutrition_extraction import ( + aggregate_entities, + postprocess_aggregated_entities, +) + + +class TestProcessAggregatedEntities: + def test_postprocess_aggregated_entities_single_entity(self): + aggregated_entities = [ + { + "entity": "ENERGY_KCAL_100G", + "words": ["525", "kcal"], + "score": 0.99, + "start": 0, + "end": 2, + "char_start": 0, + "char_end": 7, + } + ] + expected_output = [ + { + "entity": "energy_kcal_100g", + "text": "525 kcal", + "value": "525", + "unit": "kcal", + "score": 0.99, + "start": 0, + "end": 2, + "char_start": 0, + "char_end": 7, + "valid": True, + } + ] + assert postprocess_aggregated_entities(aggregated_entities) == expected_output + + def test_postprocess_aggregated_entities_multiple_entities(self): + aggregated_entities = [ + { + "entity": "ENERGY_KCAL_100G", + "words": ["525", "kcal"], + "score": 0.99, + "start": 0, + "end": 2, + "char_start": 0, + "char_end": 7, + }, + { + "entity": "ENERGY_KCAL_100G", + "words": ["126", "kcal"], + "score": 0.95, + "start": 3, + "end": 5, + "char_start": 8, + "char_end": 15, + }, + ] + expected_output = [ + { + "entity": "energy_kcal_100g", + "text": "525 kcal", + "value": "525", + "unit": "kcal", + "score": 0.99, + "start": 0, + "end": 2, + "char_start": 0, + "char_end": 7, + "valid": False, + "invalid_reason": "multiple_entities", + }, + { + "entity": "energy_kcal_100g", + "text": "126 kcal", + "value": "126", + "unit": "kcal", + "score": 0.95, + "start": 3, + "end": 5, + "char_start": 8, + "char_end": 15, + "valid": False, + "invalid_reason": "multiple_entities", + }, + ] + assert postprocess_aggregated_entities(aggregated_entities) == expected_output + + def test_postprocess_aggregated_entities_no_value(self): + aggregated_entities = [ + { + "entity": "FAT_SERVING", + "words": ["fat"], + "score": 0.85, + "start": 0, + "end": 1, + "char_start": 0, + "char_end": 3, + } + ] + expected_output = [ + { + "entity": "fat_serving", + "text": "fat", + "value": None, + "unit": None, + "score": 0.85, + "start": 0, + "end": 1, + "char_start": 0, + "char_end": 3, + "valid": False, + } + ] + assert postprocess_aggregated_entities(aggregated_entities) == expected_output + + def test_postprocess_aggregated_entities_trace_value(self): + aggregated_entities = [ + { + "entity": "SALT_SERVING", + "words": ["trace"], + "score": 0.90, + "start": 0, + "end": 1, + "char_start": 0, + "char_end": 5, + } + ] + expected_output = [ + { + "entity": "salt_serving", + "text": "trace", + "value": "traces", + "unit": None, + "score": 0.90, + "start": 0, + "end": 1, + "char_start": 0, + "char_end": 5, + "valid": True, + } + ] + assert postprocess_aggregated_entities(aggregated_entities) == expected_output + + def test_postprocess_aggregated_entities_serving_size(self): + aggregated_entities = [ + { + "entity": "SERVING_SIZE", + "words": ["25", "g"], + "score": 0.95, + "start": 0, + "end": 2, + "char_start": 0, + "char_end": 5, + } + ] + expected_output = [ + { + "entity": "serving_size", + "text": "25 g", + "value": "25 g", + "unit": None, + "score": 0.95, + "start": 0, + "end": 2, + "char_start": 0, + "char_end": 5, + "valid": True, + } + ] + assert postprocess_aggregated_entities(aggregated_entities) == expected_output + + def test_postprocess_aggregated_entities_mcg(self): + aggregated_entities = [ + { + "entity": "SALT_100G", + "words": ["1.2", "mcg"], + "score": 0.95, + "start": 0, + "end": 2, + "char_start": 0, + "char_end": 7, + } + ] + expected_output = [ + { + "entity": "salt_100g", + "text": "1.2 mcg", + "value": "1.2", + "unit": "µg", + "score": 0.95, + "start": 0, + "end": 2, + "char_start": 0, + "char_end": 7, + "valid": True, + } + ] + assert postprocess_aggregated_entities(aggregated_entities) == expected_output + + def test_postprocess_aggregated_entities_merged_kcal_kj(self): + aggregated_entities = [ + { + "entity": "ENERGY_KJ_100G", + "words": ["525"], + "score": 0.99, + "start": 0, + "end": 1, + "char_start": 0, + "char_end": 3, + }, + { + "entity": "ENERGY_KCAL_100G", + "words": ["kj/126", "kcal"], + "score": 0.99, + "start": 1, + "end": 3, + "char_start": 4, + "char_end": 15, + }, + ] + expected_output = [ + { + "entity": "energy_kj_100g", + "text": "525", + "value": "525", + "unit": "kj", + "score": 0.99, + "start": 0, + "end": 1, + "char_start": 0, + "char_end": 3, + "valid": True, + }, + { + "entity": "energy_kcal_100g", + "text": "126 kcal", + "value": "126", + "unit": "kcal", + "score": 0.99, + "start": 1, + "end": 3, + "char_start": 4, + "char_end": 15, + "valid": True, + }, + ] + assert postprocess_aggregated_entities(aggregated_entities) == expected_output + + +class TestAggregateEntities: + def test_aggregate_entities_single_entity(self): + pre_entities = [ + { + "entity": "ENERGY_KCAL_100G", + "word": "525", + "score": 0.99, + "index": 0, + "char_start": 0, + "char_end": 3, + }, + { + "entity": "ENERGY_KCAL_100G", + "word": "KJ", + "score": 0.99, + "index": 1, + "char_start": 4, + "char_end": 6, + }, + { + "entity": "O", + "word": "matières", + "score": 0.99, + "index": 2, + "char_start": 7, + "char_end": 15, + }, + ] + expected_output = [ + { + "entity": "ENERGY_KCAL_100G", + "words": ["525", "KJ"], + "score": 0.99, + "start": 0, + "end": 2, + "char_start": 0, + "char_end": 6, + } + ] + assert aggregate_entities(pre_entities) == expected_output + + def test_aggregate_entities_multiple_entities(self): + pre_entities = [ + { + "entity": "SALT_SERVING", + "word": "0.1", + "score": 0.99, + "index": 0, + "char_start": 0, + "char_end": 3, + }, + { + "entity": "SALT_SERVING", + "word": "g", + "score": 0.99, + "index": 1, + "char_start": 4, + "char_end": 5, + }, + { + "entity": "PROTEINS_SERVING", + "word": "101", + "score": 0.93, + "index": 2, + "char_start": 6, + "char_end": 9, + }, + { + "entity": "O", + "word": "portion", + "score": 0.99, + "index": 3, + "char_start": 10, + "char_end": 17, + }, + { + "entity": "CARBOHYDRATES_SERVING", + "word": "126", + "score": 0.91, + "index": 4, + "char_start": 18, + "char_end": 21, + }, + { + "entity": "CARBOHYDRATES_SERVING", + "word": "g", + "score": 0.95, + "index": 5, + "char_start": 22, + "char_end": 23, + }, + ] + expected_output = [ + { + "entity": "SALT_SERVING", + "words": ["0.1", "g"], + "score": 0.99, + "start": 0, + "end": 2, + "char_start": 0, + "char_end": 5, + }, + { + "entity": "PROTEINS_SERVING", + "words": ["101"], + "score": 0.93, + "start": 2, + "end": 3, + "char_start": 6, + "char_end": 9, + }, + { + "entity": "CARBOHYDRATES_SERVING", + "words": ["126", "g"], + "score": 0.91, + "start": 4, + "end": 6, + "char_start": 18, + "char_end": 23, + }, + ] + assert aggregate_entities(pre_entities) == expected_output