From f6ac03a869cf1391a479c14997225ef821b8e8e2 Mon Sep 17 00:00:00 2001 From: wowlikon Date: Fri, 19 Dec 2025 12:35:51 +0300 Subject: [PATCH] =?UTF-8?q?=D0=94=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB=D0=B5?= =?UTF-8?q?=D0=BD=D0=B8=D0=B5=20=D0=B0=D0=B2=D1=82=D0=BE=D1=80=D0=B8=D0=B7?= =?UTF-8?q?=D0=B0=D1=86=D0=B8=D0=B8=20=D0=B8=20=D1=83=D0=BB=D1=83=D1=87?= =?UTF-8?q?=D1=88=D0=B5=D0=BD=D0=B8=D0=B5=20=D0=BA=D0=B0=D1=87=D0=B5=D1=81?= =?UTF-8?q?=D1=82=D0=B2=D0=B0=20=D0=BA=D0=BE=D0=B4=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- library_service/main.py | 4 + library_service/routers/misc.py | 20 +- library_service/static/auth.js | 287 ++++++++++++++++++ .../static/{script.js => books.js} | 108 ++++++- library_service/static/styles.css | 88 ++++++ library_service/templates/auth.html | 193 ++++++++++++ library_service/templates/base.html | 77 +++++ library_service/templates/books.html | 66 ++++ library_service/templates/index.html | 139 --------- poetry.lock | 13 +- pyproject.toml | 5 +- 11 files changed, 836 insertions(+), 164 deletions(-) create mode 100644 library_service/static/auth.js rename library_service/static/{script.js => books.js} (56%) create mode 100644 library_service/templates/base.html create mode 100644 library_service/templates/books.html delete mode 100644 library_service/templates/index.html diff --git a/library_service/main.py b/library_service/main.py index 6fa9b1e..eb42121 100644 --- a/library_service/main.py +++ b/library_service/main.py @@ -1,9 +1,11 @@ """Основной модуль""" from contextlib import asynccontextmanager +from pathlib import Path from alembic import command from alembic.config import Config from fastapi import FastAPI +from fastapi.staticfiles import StaticFiles from .routers import api_router from .settings import engine, get_app @@ -29,3 +31,5 @@ async def lifespan(app: FastAPI): # Подключение маршрутов app.include_router(api_router) +static_path = Path(__file__).parent / "static" +app.mount("/static", StaticFiles(directory=static_path), name="static") diff --git a/library_service/routers/misc.py b/library_service/routers/misc.py index 678305d..0b94be8 100644 --- a/library_service/routers/misc.py +++ b/library_service/routers/misc.py @@ -31,7 +31,13 @@ def get_info(app) -> Dict: @router.get("/", include_in_schema=False) async def root(request: Request, app=Depends(get_app)): """Эндпоинт главной страницы""" - return templates.TemplateResponse(request, "index.html", get_info(app)) + return RedirectResponse("/books") + + +@router.get("/books", include_in_schema=False) +async def books(request: Request, app=Depends(get_app)): + """Эндпоинт страницы выбора книг""" + return templates.TemplateResponse(request, "books.html", get_info(app)) @router.get("/auth", include_in_schema=False) @@ -61,18 +67,6 @@ async def favicon(): ) -@router.get("/static/{path:path}", include_in_schema=False) -async def serve_static(path: str): - """Статические файлы""" - static_dir = Path(__file__).parent.parent / "static" - file_path = static_dir / path - - if not file_path.is_file() or not file_path.is_relative_to(static_dir): - return JSONResponse(status_code=404, content={"error": "File not found"}) - - return FileResponse(file_path) - - @router.get( "/api/info", summary="Информация о сервисе", diff --git a/library_service/static/auth.js b/library_service/static/auth.js new file mode 100644 index 0000000..14c64fe --- /dev/null +++ b/library_service/static/auth.js @@ -0,0 +1,287 @@ +$(function () { + const $loginTab = $("#login-tab"); + const $registerTab = $("#register-tab"); + const $loginForm = $("#login-form"); + const $registerForm = $("#register-form"); + + const $guestLink = $("#guest-link"); + const $userBtn = $("#user-btn"); + const $userDropdown = $("#user-dropdown"); + const $userArrow = $("#user-arrow"); + const $userAvatar = $("#user-avatar"); + const $dropdownName = $("#dropdown-name"); + const $dropdownUsername = $("#dropdown-username"); + const $dropdownEmail = $("#dropdown-email"); + const $logoutBtn = $("#logout-btn"); + const $menuContainer = $("#user-menu-container"); + + function switchToLogin() { + $loginTab.addClass("text-gray-700 bg-gray-50 border-b-2 border-gray-500").removeClass("text-gray-400"); + $registerTab.removeClass("text-gray-700 bg-gray-50 border-b-2 border-gray-500").addClass("text-gray-400"); + $loginForm.removeClass("hidden"); $registerForm.addClass("hidden"); + history.replaceState(null, "", "/auth#login"); + } + + function switchToRegister() { + $registerTab.addClass("text-gray-700 bg-gray-50 border-b-2 border-gray-500").removeClass("text-gray-400"); + $loginTab.removeClass("text-gray-700 bg-gray-50 border-b-2 border-gray-500").addClass("text-gray-400"); + $registerForm.removeClass("hidden"); $loginForm.addClass("hidden"); + history.replaceState(null, "", "/auth#register"); + } + + $loginTab.on("click", switchToLogin); + $registerTab.on("click", switchToRegister); + + $("body").on("click", ".toggle-password", function () { + const $btn = $(this); + const $input = $btn.siblings("input"); + const $eyeOpen = $btn.find(".eye-open"); + const $eyeClosed = $btn.find(".eye-closed"); + + if ($input.attr("type") === "password") { + $input.attr("type", "text"); + $eyeOpen.addClass("hidden"); + $eyeClosed.removeClass("hidden"); + } else { + $input.attr("type", "password"); + $eyeOpen.removeClass("hidden"); + $eyeClosed.addClass("hidden"); + } + }); + + $("#register-password").on("input", function () { + const password = $(this).val(); + let strength = 0; + if (password.length >= 8) strength++; + if (password.length >= 12) strength++; + if (/[a-z]/.test(password) && /[A-Z]/.test(password)) strength++; + if (/\d/.test(password)) strength++; + if (/[^a-zA-Z0-9]/.test(password)) strength++; + + const levels = [ + { width: "0%", color: "", text: "" }, + { width: "20%", color: "bg-red-500", text: "Очень слабый" }, + { width: "40%", color: "bg-orange-500", text: "Слабый" }, + { width: "60%", color: "bg-yellow-500", text: "Средний" }, + { width: "80%", color: "bg-lime-500", text: "Хороший" }, + { width: "100%", color: "bg-green-500", text: "Отличный" }, + ]; + + const level = levels[strength]; + const $bar = $("#password-strength-bar"); + + $bar.css("width", level.width); + $bar.attr("class", "h-full transition-all duration-300 " + level.color); + $("#password-strength-text").text(level.text); + + checkPasswordMatch(); + }); + + function checkPasswordMatch() { + const password = $("#register-password").val(); + const confirm = $("#register-password-confirm").val(); + const $error = $("#password-match-error"); + + if (confirm && password !== confirm) { + $error.removeClass("hidden"); + return false; + } else { + $error.addClass("hidden"); + return true; + } + } + + $("#register-password-confirm").on("input", checkPasswordMatch); + + $loginForm.on("submit", async function (event) { + event.preventDefault(); + + const $errorDiv = $("#login-error"); + const $submitBtn = $("#login-submit"); + const username = $("#login-username").val(); + const password = $("#login-password").val(); + + $errorDiv.addClass("hidden"); + $submitBtn.prop("disabled", true).text("Вход..."); + + try { + const formData = new URLSearchParams(); + formData.append("username", username); + formData.append("password", password); + + const response = await fetch("/api/auth/token", { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: formData.toString(), + }); + + const data = await response.json(); + + if (response.ok) { + localStorage.setItem("access_token", data.access_token); + if (data.refresh_token) { + localStorage.setItem("refresh_token", data.refresh_token); + } + window.location.href = "/"; + } else { + $errorDiv.text(data.detail || "Неверное имя пользователя или пароль"); + $errorDiv.removeClass("hidden"); + $submitBtn.prop("disabled", false).text("Войти"); + } + } catch (error) { + console.error("Login error:", error); + $errorDiv.text("Ошибка соединения с сервером"); + $errorDiv.removeClass("hidden"); + $submitBtn.prop("disabled", false).text("Войти"); + } + }); + + $registerForm.on("submit", async function (event) { + event.preventDefault(); + + const $errorDiv = $("#register-error"); + const $successDiv = $("#register-success"); + const $submitBtn = $("#register-submit"); + + if (!checkPasswordMatch()) { + $errorDiv.text("Пароли не совпадают").removeClass("hidden"); + return; + } + + const userData = { + username: $("#register-username").val(), + email: $("#register-email").val(), + full_name: $("#register-fullname").val() || null, + password: $("#register-password").val(), + }; + + $errorDiv.addClass("hidden"); + $successDiv.addClass("hidden"); + $submitBtn.prop("disabled", true).text("Регистрация..."); + + try { + const response = await fetch("/api/auth/register", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(userData), + }); + + const data = await response.json(); + + if (response.ok) { + $successDiv.text("Регистрация успешна! Переключаемся на вход...").removeClass("hidden"); + setTimeout(() => { + $("#login-username").val(userData.username); + switchToLogin(); + }, 2000); + } else { + let errorMessage = data.detail; + if (Array.isArray(data.detail)) { + errorMessage = data.detail.map((err) => err.msg).join(". "); + } + $errorDiv.text(errorMessage || "Ошибка регистрации").removeClass("hidden"); + } + } catch (error) { + console.error("Register error:", error); + $errorDiv.text("Ошибка соединения с сервером").removeClass("hidden"); + } finally { + $submitBtn.prop("disabled", false).text("Зарегистрироваться"); + } + }); + + let isDropdownOpen = false; + + function openDropdown() { + isDropdownOpen = true; + $userDropdown.removeClass("hidden"); + $userArrow.addClass("rotate-180"); + } + + function closeDropdown() { + isDropdownOpen = false; + $userDropdown.addClass("hidden"); + $userArrow.removeClass("rotate-180"); + } + + $userBtn.on("click", function (e) { + e.stopPropagation(); + isDropdownOpen ? closeDropdown() : openDropdown(); + }); + + $(document).on("click", function (e) { + if (isDropdownOpen && !$(e.target).closest("#user-menu-container").length) { + closeDropdown(); + } + }); + + $(document).on("keydown", function (e) { + if (e.key === "Escape" && isDropdownOpen) { + closeDropdown(); + } + }); + + $logoutBtn.on("click", function () { + localStorage.removeItem("access_token"); + localStorage.removeItem("refresh_token"); + window.location.href = "/"; + }); + + function showGuest() { + $guestLink.removeClass("hidden"); + $userBtn.addClass("hidden").removeClass("flex"); + closeDropdown(); + } + + function showUser(user) { + $guestLink.addClass("hidden"); + $userBtn.removeClass("hidden").addClass("flex"); + + const displayName = user.full_name || user.username; + const firstLetter = displayName.charAt(0).toUpperCase(); + + $userAvatar.text(firstLetter); + $dropdownName.text(displayName); + $dropdownUsername.text("@" + user.username); + $dropdownEmail.text(user.email); + } + + + function updateUserAvatar(email) { + if (!email) return; + const cleanEmail = email.trim().toLowerCase(); + const emailHash = sha256(cleanEmail); + + const avatarUrl = `https://www.gravatar.com/avatar/${emailHash}?d=identicon&s=200`; + const avatarImg = document.getElementById('user-avatar'); + if (avatarImg) { avatarImg.src = avatarUrl; } + } + + if (window.location.hash === "#register") { switchToRegister(); } + + const token = localStorage.getItem("access_token"); + + if (!token) { + showGuest(); + } else { + fetch("/api/auth/me", { + headers: { Authorization: "Bearer " + token }, + }) + .then((response) => { + if (response.ok) return response.json(); + throw new Error("Unauthorized"); + }) + .then((user) => { + showUser(user); + updateUserAvatar(user.email); + + document.getElementById('user-btn').classList.remove('hidden'); + document.getElementById('guest-link').classList.add('hidden'); + if (window.location.pathname === "/auth") { window.location.href = "/"; } + }) + .catch(() => { + localStorage.removeItem("access_token"); + localStorage.removeItem("refresh_token"); + showGuest(); + }); + } + }); \ No newline at end of file diff --git a/library_service/static/script.js b/library_service/static/books.js similarity index 56% rename from library_service/static/script.js rename to library_service/static/books.js index 48e6c97..1857d2c 100644 --- a/library_service/static/script.js +++ b/library_service/static/books.js @@ -1,7 +1,7 @@ $(document).ready(function () { Promise.all([ - fetch("/authors").then((response) => response.json()), - fetch("/genres").then((response) => response.json()), + fetch("/api/authors").then((response) => response.json()), + fetch("/api/genres").then((response) => response.json()), ]) .then(([authorsData, genresData]) => { const $dropdown = $("#author-dropdown"); @@ -112,4 +112,106 @@ $(document).ready(function () { renderSelectedAuthors(); } -}); + + const $guestLink = $("#guest-link"); + const $userBtn = $("#user-btn"); + const $userDropdown = $("#user-dropdown"); + const $userArrow = $("#user-arrow"); + const $userAvatar = $("#user-avatar"); + const $dropdownName = $("#dropdown-name"); + const $dropdownUsername = $("#dropdown-username"); + const $dropdownEmail = $("#dropdown-email"); + const $logoutBtn = $("#logout-btn"); + + let isDropdownOpen = false; + + function openDropdown() { + isDropdownOpen = true; + $userDropdown.removeClass("hidden"); + $userArrow.addClass("rotate-180"); + } + + function closeDropdown() { + isDropdownOpen = false; + $userDropdown.addClass("hidden"); + $userArrow.removeClass("rotate-180"); + } + + $userBtn.on("click", function (e) { + e.stopPropagation(); + isDropdownOpen ? closeDropdown() : openDropdown(); + }); + + $(document).on("click", function (e) { + if (isDropdownOpen && !$(e.target).closest("#user-menu-container").length) { + closeDropdown(); + } + }); + + $(document).on("keydown", function (e) { + if (e.key === "Escape" && isDropdownOpen) { + closeDropdown(); + } + }); + + $logoutBtn.on("click", function () { + localStorage.removeItem("access_token"); + localStorage.removeItem("refresh_token"); + window.location.reload(); + }); + + function showGuest() { + $guestLink.removeClass("hidden"); + $userBtn.addClass("hidden").removeClass("flex"); + closeDropdown(); + } + + function showUser(user) { + $guestLink.addClass("hidden"); + $userBtn.removeClass("hidden").addClass("flex"); + + const displayName = user.full_name || user.username; + const firstLetter = displayName.charAt(0).toUpperCase(); + + $userAvatar.text(firstLetter); + $dropdownName.text(displayName); + $dropdownUsername.text("@" + user.username); + $dropdownEmail.text(user.email); + } + + function updateUserAvatar(email) { + if (!email) return; + const cleanEmail = email.trim().toLowerCase(); + const emailHash = sha256(cleanEmail); + + const avatarUrl = `https://www.gravatar.com/avatar/${emailHash}?d=identicon&s=200`; + const avatarImg = document.getElementById('user-avatar'); + if (avatarImg) { avatarImg.src = avatarUrl; } + } + + const token = localStorage.getItem("access_token"); + + if (!token) { + showGuest(); + } else { + fetch("/api/auth/me", { + headers: { Authorization: "Bearer " + token }, + }) + .then((response) => { + if (response.ok) return response.json(); + throw new Error("Unauthorized"); + }) + .then((user) => { + showUser(user); + updateUserAvatar(user.email); + + document.getElementById('user-btn').classList.remove('hidden'); + document.getElementById('guest-link').classList.add('hidden'); + }) + .catch(() => { + localStorage.removeItem("access_token"); + localStorage.removeItem("refresh_token"); + showGuest(); + }); + } +}); \ No newline at end of file diff --git a/library_service/static/styles.css b/library_service/static/styles.css index 5a8b0a3..cb44c55 100644 --- a/library_service/static/styles.css +++ b/library_service/static/styles.css @@ -75,3 +75,91 @@ nav ul li a { border-width: 0 2px 2px 0; transform: rotate(45deg); } + +.auth-tab { + font-family: "Dited", sans-serif; + letter-spacing: 1.5px; +} + +input:focus { + transform: translateY(-1px); +} + +button:disabled { + opacity: 0.7; +} + +#login-form, +#register-form { + animation: fadeIn 0.3s ease-in-out; +} + +@keyframes fadeIn { + from { + opacity: 0; + transform: translateY(-10px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.flex.justify-center.gap-4 button:hover { + transform: translateY(-2px); +} + +.shake { + animation: shake 0.5s ease-in-out; +} + +@keyframes shake { + 0%, 100% { transform: translateX(0); } + 10%, 30%, 50%, 70%, 90% { transform: translateX(-5px); } + 20%, 40%, 60%, 80% { transform: translateX(5px); } +} + +#req-length, #req-upper, #req-lower, #req-digit { + transition: color 0.2s ease; +} + +.req-icon { + font-size: 10px; + width: 12px; + display: inline-block; +} + +#login-form:not(.hidden), +#register-form:not(.hidden) { + animation: fadeIn 0.3s ease-in-out; +} + +@keyframes fadeIn { + from { + opacity: 0; + transform: translateY(-10px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +#login-tab, #register-tab { + font-family: "Dited", sans-serif; + letter-spacing: 1.5px; + cursor: pointer; +} + +#user-dropdown { + animation: dropdownFade 0.1s ease-out; +} + +@keyframes dropdownFade { + from { opacity: 0; transform: translateY(-5px); } + to { opacity: 1; transform: translateY(0); } +} + +#user-arrow.rotate-180 { + transform: rotate(180deg); +} \ No newline at end of file diff --git a/library_service/templates/auth.html b/library_service/templates/auth.html index e69de29..31e0c95 100644 --- a/library_service/templates/auth.html +++ b/library_service/templates/auth.html @@ -0,0 +1,193 @@ + +{% extends "base.html" %} + +{% block title %}LiB - Авторизация{% endblock %} + +{% block content %} +
+
+
+
+ + +
+ +
+
+ + +
+ +
+ +
+ + +
+
+ +
+ + Забыли пароль? +
+ + + + +
+ + +
+
+
+{% endblock %} + +{% block scripts %} + +{% endblock %} \ No newline at end of file diff --git a/library_service/templates/base.html b/library_service/templates/base.html new file mode 100644 index 0000000..cb9d1c3 --- /dev/null +++ b/library_service/templates/base.html @@ -0,0 +1,77 @@ + + + + {% block title %}LiB{% endblock %} + + + + + + + {% block extra_head %}{% endblock %} + + +
+
+ + +

LiB

+
+ +
+ + + +
+
+
+ + {% block content %}{% endblock %} + + + + {% block scripts %}{% endblock %} + + \ No newline at end of file diff --git a/library_service/templates/books.html b/library_service/templates/books.html new file mode 100644 index 0000000..af6312a --- /dev/null +++ b/library_service/templates/books.html @@ -0,0 +1,66 @@ +{% extends "base.html" %} + +{% block title %}LiB - Главная{% endblock %} + +{% block content %} +
+ + +
+
+
+

Product Title 1

+

+ A short description of the product, highlighting its + key features and benefits. +

+
+ $29.99 +
+ +
+
+

Product Title 2

+

+ Another great product with amazing features. You'll + love it! +

+
+ $49.99 +
+ +
+
+

Product Title 3

+

+ This product is a must-have for every modern home. + High quality and durable. +

+
+ $19.99 +
+
+
+{% endblock %} + +{% block scripts %} + +{% endblock %} \ No newline at end of file diff --git a/library_service/templates/index.html b/library_service/templates/index.html deleted file mode 100644 index 44bee1b..0000000 --- a/library_service/templates/index.html +++ /dev/null @@ -1,139 +0,0 @@ - - - - LiB - - - - - - - - -
-
- - -

LiB

-
- - -
-
- - -
- - - -
- -
-
-

Product Title 1

-

- A short description of the product, highlighting its - key features and benefits. -

-
- $29.99 -
- - -
-
-

Product Title 2

-

- Another great product with amazing features. You'll - love it! -

-
- $49.99 -
- - -
-
-

Product Title 3

-

- This product is a must-have for every modern home. - High quality and durable. -

-
- $19.99 -
-
-
- - - - - - diff --git a/poetry.lock b/poetry.lock index 7b421ae..1477661 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 2.1.2 and should not be changed by hand. +# This file is automatically @generated by Poetry 2.2.1 and should not be changed by hand. [[package]] name = "aiofiles" @@ -59,6 +59,7 @@ files = [ [package.dependencies] idna = ">=2.8" sniffio = ">=1.1" +typing_extensions = {version = ">=4.5", markers = "python_version < \"3.13\""} [package.extras] doc = ["Sphinx (>=8.2,<9.0)", "packaging", "sphinx-autodoc-typehints (>=1.2.0)", "sphinx_rtd_theme"] @@ -525,7 +526,7 @@ description = "Lightweight in-process concurrent programming" optional = false python-versions = ">=3.9" groups = ["main"] -markers = "python_version == \"3.13\" and (platform_machine == \"aarch64\" or platform_machine == \"ppc64le\" or platform_machine == \"x86_64\" or platform_machine == \"amd64\" or platform_machine == \"AMD64\" or platform_machine == \"win32\" or platform_machine == \"WIN32\")" +markers = "python_version < \"3.14\" and (platform_machine == \"aarch64\" or platform_machine == \"ppc64le\" or platform_machine == \"x86_64\" or platform_machine == \"amd64\" or platform_machine == \"AMD64\" or platform_machine == \"win32\" or platform_machine == \"WIN32\")" files = [ {file = "greenlet-3.2.3-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:1afd685acd5597349ee6d7a88a8bec83ce13c106ac78c196ee9dde7c04fe87be"}, {file = "greenlet-3.2.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:761917cac215c61e9dc7324b2606107b3b292a8349bdebb31503ab4de3f559ac"}, @@ -1471,6 +1472,7 @@ files = [ [package.dependencies] pytest = ">=8.2,<10" +typing-extensions = {version = ">=4.12", markers = "python_version < \"3.13\""} [package.extras] docs = ["sphinx (>=5.3)", "sphinx-rtd-theme (>=1)"] @@ -1855,11 +1857,12 @@ version = "4.15.0" description = "Backported and Experimental Type Hints for Python 3.9+" optional = false python-versions = ">=3.9" -groups = ["main"] +groups = ["main", "dev"] files = [ {file = "typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548"}, {file = "typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466"}, ] +markers = {dev = "python_version == \"3.12\""} [[package]] name = "typing-inspection" @@ -2243,5 +2246,5 @@ files = [ [metadata] lock-version = "2.1" -python-versions = "^3.13" -content-hash = "2fdd550e819b1456733250a62196acb442127a296ff60e010c424ecbebec294e" +python-versions = "^3.12" +content-hash = "a8d44f0decfa3ba437e998207c16ca7429ee42e930e8aa1d40f87231e71f219f" diff --git a/pyproject.toml b/pyproject.toml index 822e221..36b5601 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,7 +7,7 @@ readme = "README.md" packages = [{ include = "library_service" }] [tool.poetry.dependencies] -python = "^3.13" +python = "^3.12" fastapi = { extras = ["all"], version = "^0.115.12" } psycopg2-binary = "^2.9.10" alembic = "^1.16.1" @@ -28,9 +28,6 @@ isort = "^7.0.0" pytest-asyncio = "^1.3.0" pylint = "^4.0.4" -[tool.poetry.requires-plugins] -poetry-plugin-export = ">=1.8" - [build-system] requires = ["poetry-core"] build-backend = "poetry.core.masonry.api"