Compare commits

..

4 Commits

Author SHA1 Message Date
5f0130d0d1 God, I hope I'm ready 2024-08-21 05:24:07 +03:00
04a27b592e Pyinstaller support for assets 2024-08-21 04:05:36 +03:00
d6ecae08bd Switched to other domain 2024-08-16 18:40:42 +03:00
aa786de5b4 Cleanup 2024-08-11 14:39:00 +03:00
14 changed files with 242 additions and 146 deletions

View File

@ -8,11 +8,7 @@ from state import State
if __name__ == "__main__": if __name__ == "__main__":
dotenv.load_dotenv() dotenv.load_dotenv()
url = os.environ.get("DRIVE_HOST_URL") url = os.environ.get("DRIVE_HOST_URL")
if not url:
url = "localhost:3000"
else:
url = url.strip()
app = QApplication(sys.argv) app = QApplication(sys.argv)
window = State(url) window = State()
window.show() window.show()
sys.exit(app.exec()) sys.exit(app.exec())

View File

@ -58,7 +58,7 @@ class RegisterWidget(QWidget):
try: try:
response = RequestClient().client.post( response = RequestClient().client.post(
"http://localhost:3000/users/register", "/users/register",
data={ data={
"username": username, "username": username,
"email": email, "email": email,
@ -79,6 +79,7 @@ class RegisterWidget(QWidget):
self, "Error", f"Registration failed: {response.text}" self, "Error", f"Registration failed: {response.text}"
) )
except httpx.HTTPError as e: except httpx.HTTPError as e:
print(e)
QMessageBox.critical(self, "HTTP Error", str(e)) QMessageBox.critical(self, "HTTP Error", str(e))
@ -116,12 +117,14 @@ class LoginWidget(QWidget):
password = self.password_input.text() password = self.password_input.text()
if not username or not password: if not username or not password:
QMessageBox.warning(self, "Input Error", "Email and Password are required") QMessageBox.warning(
self, "Input Error", "Email and Password are required"
)
return return
try: try:
response = RequestClient().client.post( response = RequestClient().client.post(
"http://localhost:3000/users/authorize", "/users/authorize",
data={"username": username, "password": password}, data={"username": username, "password": password},
) )
if response.is_success: if response.is_success:
@ -129,8 +132,12 @@ class LoginWidget(QWidget):
if access_token: if access_token:
self.switcher.login(access_token) self.switcher.login(access_token)
else: else:
QMessageBox.warning(self, "Error", "No access token received") QMessageBox.warning(
self, "Error", "No access token received"
)
else: else:
QMessageBox.warning(self, "Error", f"Login failed: {response.text}") QMessageBox.warning(
self, "Error", f"Login failed: {response.text}"
)
except httpx.HTTPError as e: except httpx.HTTPError as e:
QMessageBox.critical(self, "HTTP Error", str(e)) QMessageBox.critical(self, "HTTP Error", str(e))

View File

@ -3,12 +3,15 @@ from __future__ import annotations
import uuid import uuid
import file_widgets import file_widgets
from PyQt6.QtWidgets import QLineEdit, QMessageBox, QPushButton, QVBoxLayout, QWidget from PyQt6.QtWidgets import (QLineEdit, QMessageBox, QPushButton, QVBoxLayout,
QWidget)
from request_client import RequestClient from request_client import RequestClient
class CreateFolderWidget(QWidget): class CreateFolderWidget(QWidget):
def __init__(self, folder_id: uuid.UUID, file_list: file_widgets.FileListWidget): def __init__(
self, folder_id: uuid.UUID, file_list: file_widgets.FileListWidget
):
super().__init__() super().__init__()
self.folder_id = folder_id self.folder_id = folder_id
self.file_list = file_list self.file_list = file_list
@ -22,6 +25,7 @@ class CreateFolderWidget(QWidget):
self.setLayout(layout) self.setLayout(layout)
def submit(self): def submit(self):
try:
response = RequestClient().client.post( response = RequestClient().client.post(
"/folders", "/folders",
json={ json={
@ -30,8 +34,15 @@ class CreateFolderWidget(QWidget):
}, },
) )
if not response.is_success: if not response.is_success:
QMessageBox.warning(None, "Error creating folder", response.text) QMessageBox.warning(
None, "Error creating folder", response.text
)
else: else:
QMessageBox.information(None, "Folder created", "Folder created") QMessageBox.information(
None, "Folder created", "Folder created"
)
self.file_list.update_response() self.file_list.update_response()
self.close() self.close()
except Exception as e:
print(e)
QMessageBox.critical(self, "HTTP error", str(e))

View File

@ -6,11 +6,14 @@ import hashlib
import os import os
import uuid import uuid
import dateutil
import dateutil.tz
import file_widgets import file_widgets
import pydantic import pydantic
from PyQt6.QtGui import QIcon from PyQt6.QtGui import QIcon
from PyQt6.QtWidgets import QFileDialog, QLabel, QMessageBox, QWidget from PyQt6.QtWidgets import QFileDialog, QLabel, QMessageBox, QWidget
from request_client import RequestClient from request_client import RequestClient
from utils import resource_path
class File(pydantic.BaseModel): class File(pydantic.BaseModel):
@ -31,12 +34,14 @@ class File(pydantic.BaseModel):
def details(self, list: file_widgets.FileListWidget) -> QWidget: def details(self, list: file_widgets.FileListWidget) -> QWidget:
del list del list
file_size = self._format_bytes(self.file_size) file_size = self._format_size(self.file_size)
file_size_text = f"{file_size[0]:.2f} {file_size[1]}" file_size_text = f"{file_size[0]:.2f} {file_size[1]}"
created_at = self._format_date(self.created_at)
updated_at = self._format_date(self.updated_at)
details = ( details = (
f"file id: {self.file_id}\nfile_name: {self.file_name}\n" f"file id: {self.file_id}\nfile_name: {self.file_name}\n"
+ f"file_size: {file_size_text}\n" + f"file_size: {file_size_text}\n"
+ f"created at: {self.created_at}\nupdated at: {self.updated_at}" + f"created at: {created_at}\nupdated at: {updated_at}"
) )
label = QLabel() label = QLabel()
label.setWindowTitle("File info") label.setWindowTitle("File info")
@ -44,7 +49,7 @@ class File(pydantic.BaseModel):
return label return label
@staticmethod @staticmethod
def _format_bytes(size: int): def _format_size(size: int):
power = 2**10 power = 2**10
n = 0 n = 0
power_labels = {0: "", 1: "kibi", 2: "mebi", 3: "gibi", 4: "tebi"} power_labels = {0: "", 1: "kibi", 2: "mebi", 3: "gibi", 4: "tebi"}
@ -53,8 +58,14 @@ class File(pydantic.BaseModel):
n += 1 n += 1
return size, power_labels[n] + "bytes" return size, power_labels[n] + "bytes"
@staticmethod
def _format_date(date: datetime.datetime) -> str:
date = date.replace(tzinfo=dateutil.tz.tzutc())
date = date.astimezone(dateutil.tz.tzlocal())
return date.strftime("%Y-%m-%d %H:%M:%S")
def icon(self) -> QIcon: def icon(self) -> QIcon:
return QIcon("assets/file.png") return QIcon(resource_path("assets/file.png"))
def double_click(self, list: file_widgets.FileListWidget) -> None: def double_click(self, list: file_widgets.FileListWidget) -> None:
location = QFileDialog.getExistingDirectory( location = QFileDialog.getExistingDirectory(
@ -75,6 +86,7 @@ class File(pydantic.BaseModel):
def create(path: str, parent_id: uuid.UUID): def create(path: str, parent_id: uuid.UUID):
"""Upload the file""" """Upload the file"""
print(path)
file_name = os.path.basename(path) file_name = os.path.basename(path)
try: try:
with open(path, "rb") as f: with open(path, "rb") as f:
@ -93,14 +105,21 @@ class File(pydantic.BaseModel):
QMessageBox.critical(None, "HTTP Error", str(e)) QMessageBox.critical(None, "HTTP Error", str(e))
def download(self, path: str): def download(self, path: str):
try:
with open(path, "wb") as f, RequestClient().client.stream( with open(path, "wb") as f, RequestClient().client.stream(
"GET", "/files", params={"file_id": self.file_id} "GET", "/files", params={"file_id": self.file_id}
) as stream: ) as stream:
if not stream.is_success: if not stream.is_success:
QMessageBox.warning(None, "Error downloading the file") QMessageBox.warning(
None,
"Error downloading the file",
"Error downloading the file",
)
return return
for data in stream.iter_bytes(): for data in stream.iter_bytes():
f.write(data) f.write(data)
except Exception as e:
QMessageBox.warning(None, "Error downloading the file", str(e))
def modify(self, path: str): def modify(self, path: str):
"""Upload the file""" """Upload the file"""

View File

@ -1,15 +1,14 @@
from __future__ import annotations from __future__ import annotations
import dataclasses import dataclasses
import os
import uuid import uuid
import threading
from typing import Protocol, Self from typing import Protocol, Self
import create_folder_widget import create_folder_widget
import sync
import httpx
import pydantic import pydantic
import state import state
import sync
import user import user
from file import File from file import File
from folder import Folder from folder import Folder
@ -26,7 +25,6 @@ from PyQt6.QtWidgets import (
QListWidget, QListWidget,
QListWidgetItem, QListWidgetItem,
QMenu, QMenu,
QMessageBox,
QPushButton, QPushButton,
QVBoxLayout, QVBoxLayout,
QWidget, QWidget,
@ -129,7 +127,7 @@ class FileListWidget(QListWidget):
item = self.current_response().items()[row] item = self.current_response().items()[row]
item.double_click(self) item.double_click(self)
def current_response(self) -> ListResponse: def current_response(self) -> ResponseProtocol:
if not self.responses: if not self.responses:
self.update_response() self.update_response()
return self.responses[-1] return self.responses[-1]
@ -154,28 +152,14 @@ class FileListWidget(QListWidget):
self.upload_file(file_path) self.upload_file(file_path)
def upload_file(self, file_path): def upload_file(self, file_path):
file_name = os.path.basename(file_path) def _inner():
try: response = self.current_response()
with open(file_path, "rb") as f: if not isinstance(response, ListResponse):
files = {"file": (file_name, f)} return
response = RequestClient().client.post( File.create(file_path, response.folder_id)
"http://localhost:3000/files",
files=files,
params={
"parent_folder": self.current_response().folder_id,
},
)
if response.is_success:
QMessageBox.information(
self, "Success", "File uploaded successfully"
)
self.update_response() self.update_response()
else:
QMessageBox.warning( threading.Thread(target=_inner).start()
self, "Error", f"Upload failed: {response.text}"
)
except httpx.HTTPError as e:
QMessageBox.critical(self, "HTTP Error", str(e))
def add_item(self, item: DisplayProtocol): def add_item(self, item: DisplayProtocol):
widget = QListWidgetItem(item.name()) widget = QListWidgetItem(item.name())
@ -270,7 +254,7 @@ class Sidebar(QWidget):
self.folder_widget.show() self.folder_widget.show()
def sync_all(self): def sync_all(self):
sync.SyncData.sync_all() threading.Thread(target=sync.SyncData.sync_all).start()
class MainFileWidget(QWidget): class MainFileWidget(QWidget):

View File

@ -1,8 +1,8 @@
from __future__ import annotations from __future__ import annotations
import datetime import datetime
import uuid
import typing import typing
import uuid
if typing.TYPE_CHECKING: if typing.TYPE_CHECKING:
import file_widgets import file_widgets
@ -11,6 +11,7 @@ import pydantic
from PyQt6.QtGui import QIcon from PyQt6.QtGui import QIcon
from PyQt6.QtWidgets import QMessageBox, QWidget from PyQt6.QtWidgets import QMessageBox, QWidget
from request_client import RequestClient from request_client import RequestClient
from utils import resource_path
class Folder(pydantic.BaseModel): class Folder(pydantic.BaseModel):
@ -32,13 +33,13 @@ class Folder(pydantic.BaseModel):
if not response: if not response:
QMessageBox.warning(None, "Error deleting folder", response.text) QMessageBox.warning(None, "Error deleting folder", response.text)
def details(self, list: file_widgets.FileListWidget) -> QWidget: def details(self, _: file_widgets.FileListWidget) -> QWidget:
import folder_info import folder_info
return folder_info.FolderInfoWidget(self) return folder_info.FolderInfoWidget(self)
def icon(self) -> QIcon: def icon(self) -> QIcon:
return QIcon("assets/folder.png") return QIcon(resource_path("assets/folder.png"))
def double_click(self, list: file_widgets.FileListWidget) -> None: def double_click(self, list: file_widgets.FileListWidget) -> None:
import file_widgets import file_widgets
@ -48,6 +49,7 @@ class Folder(pydantic.BaseModel):
@staticmethod @staticmethod
def create(name: str, parent_id: uuid.UUID) -> uuid.UUID: def create(name: str, parent_id: uuid.UUID) -> uuid.UUID:
try:
response = RequestClient().client.post( response = RequestClient().client.post(
"/folders", "/folders",
json={ json={
@ -56,5 +58,9 @@ class Folder(pydantic.BaseModel):
}, },
) )
if not response.is_success: if not response.is_success:
QMessageBox.warning(None, "Error creating folder", response.text) QMessageBox.warning(
None, "Error creating folder", response.text
)
return uuid.UUID(response.text.strip('"')) return uuid.UUID(response.text.strip('"'))
except Exception as e:
QMessageBox.warning(None, "Error creating the folder", str(e))

View File

@ -30,6 +30,7 @@ class Permission(enum.StrEnum):
manage = enum.auto() manage = enum.auto()
def set(self, folder_id: uuid.UUID, user_id: int): def set(self, folder_id: uuid.UUID, user_id: int):
try:
response = RequestClient().client.post( response = RequestClient().client.post(
"/permissions", "/permissions",
json={ json={
@ -42,9 +43,12 @@ class Permission(enum.StrEnum):
QMessageBox.warning( QMessageBox.warning(
None, "Error setting permissions", response.text None, "Error setting permissions", response.text
) )
except Exception as e:
QMessageBox.warning(None, "Error setting permissions", str(e))
@staticmethod @staticmethod
def delete(folder_id: uuid.UUID, user_id: int, widget: FolderInfoWidget): def delete(folder_id: uuid.UUID, user_id: int, widget: FolderInfoWidget):
try:
response = RequestClient().client.delete( response = RequestClient().client.delete(
"/permissions", "/permissions",
params={ params={
@ -58,6 +62,8 @@ class Permission(enum.StrEnum):
) )
else: else:
widget.redraw() widget.redraw()
except Exception as e:
QMessageBox.warning(None, "Error deleting permissions", str(e))
@dataclass(slots=True) @dataclass(slots=True)
@ -66,12 +72,18 @@ class Permissions:
@staticmethod @staticmethod
def get(folder_id: uuid.UUID) -> Permissions: def get(folder_id: uuid.UUID) -> Permissions:
mapping = pydantic.TypeAdapter(dict[str, Permission]).validate_json( try:
mapping = pydantic.TypeAdapter(
dict[str, Permission]
).validate_json(
RequestClient() RequestClient()
.client.get("/permissions", params={"folder_id": folder_id}) .client.get("/permissions", params={"folder_id": folder_id})
.text .text
) )
return Permissions(mapping) return Permissions(mapping)
except Exception as e:
QMessageBox.warning(None, "Error getting permissions", str(e))
return Permissions({})
class FolderInfoWidget(QWidget): class FolderInfoWidget(QWidget):

View File

@ -9,9 +9,9 @@ class RequestClient:
def __new__(cls) -> Self: def __new__(cls) -> Self:
if cls._client is None: if cls._client is None:
url = os.environ.get("DRIVE_HOST_URL").strip() url = os.environ.get("DRIVE_HOST_URL")
if not url: if url is None:
url = "localhost:3000" url = "https://drive.stnicolay.ru"
cls._client = httpx.Client(base_url=url) cls._client = httpx.Client(base_url=url)
return super().__new__(cls) return super().__new__(cls)

View File

@ -1,20 +1,18 @@
import auth import auth
import file_widgets import file_widgets
import httpx
import keyring import keyring
import request_client import request_client
import sync import sync
from PyQt6.QtWidgets import QMainWindow, QStackedWidget from PyQt6.QtWidgets import QMainWindow, QMessageBox, QStackedWidget
class State(QMainWindow): class State(QMainWindow):
def __init__(self, url: str): def __init__(self):
super().__init__() super().__init__()
self.setWindowTitle("Auth App") self.setWindowTitle("Drive application")
self.stack = QStackedWidget() self.stack = QStackedWidget()
self.client = httpx.Client(base_url=url)
self.register_widget = auth.RegisterWidget(self) self.register_widget = auth.RegisterWidget(self)
self.login_widget = auth.LoginWidget(self) self.login_widget = auth.LoginWidget(self)
@ -38,17 +36,21 @@ class State(QMainWindow):
self.stack.setCurrentWidget(self.register_widget) self.stack.setCurrentWidget(self.register_widget)
def login(self, token: str): def login(self, token: str):
try:
keyring.set_password("auth_app", "access_token", token) keyring.set_password("auth_app", "access_token", token)
request_client.RequestClient().set_token(token) request_client.RequestClient().set_token(token)
sync.SyncData.sync_all(
# "/home/stnicolay/backups",
# uuid.UUID("0191397f-ae77-7b2a-bed7-9d28ed56a90a"),
)
self.file_widget = file_widgets.MainFileWidget(self) self.file_widget = file_widgets.MainFileWidget(self)
self.stack.addWidget(self.file_widget) self.stack.addWidget(self.file_widget)
self.stack.setCurrentWidget(self.file_widget) self.stack.setCurrentWidget(self.file_widget)
except Exception as e:
print(e)
keyring.delete_password("auth_app", "access_token")
QMessageBox.information(
None, "Error logging in", "Error logging in"
)
def logout(self): def logout(self):
keyring.delete_password("auth_app", "access_token") keyring.delete_password("auth_app", "access_token")
request_client.RequestClient().delete_token() request_client.RequestClient().delete_token()
sync.SyncData.delete_all()
self.switch_to_login() self.switch_to_login()

View File

@ -3,13 +3,15 @@ from __future__ import annotations
import datetime import datetime
import os import os
import uuid import uuid
from concurrent.futures import ThreadPoolExecutor from concurrent.futures import ProcessPoolExecutor
from time import ctime from time import ctime
from file import File from file import File
from folder import Folder from folder import Folder
from request_client import RequestClient from request_client import RequestClient
from sqlmodel import Field, Session, SQLModel, create_engine, delete, select from sqlmodel import Field, Session, SQLModel, create_engine, delete, select
import multiprocessing
from PyQt6.QtWidgets import QMessageBox
class FolderStructure(Folder): class FolderStructure(Folder):
@ -41,20 +43,29 @@ class SyncData(SQLModel, table=True):
path: str path: str
last_updated: datetime.datetime last_updated: datetime.datetime
def sync_one(self):
try:
merge_folder(
self.path,
FolderStructure.get_structure(self.folder_id),
self.last_updated,
)
except Exception as e:
SyncData.delete(self.folder_id)
QMessageBox.warning(
None,
"Error syncing folder",
f"Error syncing {self.path!r} folder:\n{e}",
)
@staticmethod @staticmethod
def sync_all(): def sync_all():
with Session(engine) as s: with Session(engine) as s:
syncs = s.exec(select(SyncData)).fetchall() syncs = s.exec(select(SyncData)).fetchall()
with ThreadPoolExecutor(3) as pool:
map_ = pool.map( with ProcessPoolExecutor(3) as pool:
lambda sync: merge_folder( tuple(pool.map(SyncData.sync_one, syncs))
sync.path,
FolderStructure.get_structure(sync.folder_id),
sync.last_updated,
),
syncs,
)
tuple(map_)
@staticmethod @staticmethod
def new_sync(path: str, folder_id: uuid.UUID): def new_sync(path: str, folder_id: uuid.UUID):
@ -66,7 +77,9 @@ class SyncData(SQLModel, table=True):
) )
s.add(sync) s.add(sync)
s.commit() s.commit()
upload_folder(path, folder_id) multiprocessing.Process(
target=lambda: upload_folder(path, folder_id)
).start()
@staticmethod @staticmethod
def get_for_folder(folder_id: uuid.UUID) -> SyncData | None: def get_for_folder(folder_id: uuid.UUID) -> SyncData | None:
@ -81,6 +94,12 @@ class SyncData(SQLModel, table=True):
s.exec(delete(SyncData).where(SyncData.folder_id == folder_id)) s.exec(delete(SyncData).where(SyncData.folder_id == folder_id))
s.commit() s.commit()
@staticmethod
def delete_all():
with Session(engine) as s:
s.exec(delete(SyncData))
s.commit()
sqlite_file_name = "database.db" sqlite_file_name = "database.db"
sqlite_url = f"sqlite:///{sqlite_file_name}" sqlite_url = f"sqlite:///{sqlite_file_name}"

View File

@ -29,7 +29,9 @@ class User(pydantic.BaseModel):
url = "/users/current" url = "/users/current"
response = RequestClient().client.get(url, params=params) response = RequestClient().client.get(url, params=params)
if not response.is_success: if not response.is_success:
QMessageBox.warning(None, "Error getting permissions", response.text) QMessageBox.warning(
None, "Error getting permissions", response.text
)
return return
return User.model_validate_json(response.text) return User.model_validate_json(response.text)

12
desktop_client/utils.py Normal file
View File

@ -0,0 +1,12 @@
import os
import sys
from functools import cache
_DEFAULT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
@cache
def resource_path(relative_path: str) -> str:
"""Get absolute path to resource, works for dev and for PyInstaller"""
base_path = getattr(sys, "_MEIPASS", _DEFAULT)
return os.path.join(base_path, relative_path)

43
poetry.lock generated
View File

@ -427,18 +427,18 @@ testing = ["pytest (>=6)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-ena
[[package]] [[package]]
name = "jaraco-context" name = "jaraco-context"
version = "5.3.0" version = "6.0.1"
description = "Useful decorators and context managers" description = "Useful decorators and context managers"
optional = false optional = false
python-versions = ">=3.8" python-versions = ">=3.8"
files = [ files = [
{file = "jaraco.context-5.3.0-py3-none-any.whl", hash = "sha256:3e16388f7da43d384a1a7cd3452e72e14732ac9fe459678773a3608a812bf266"}, {file = "jaraco.context-6.0.1-py3-none-any.whl", hash = "sha256:f797fc481b490edb305122c9181830a3a5b76d84ef6d1aef2fb9b47ab956f9e4"},
{file = "jaraco.context-5.3.0.tar.gz", hash = "sha256:c2f67165ce1f9be20f32f650f25d8edfc1646a8aeee48ae06fb35f90763576d2"}, {file = "jaraco_context-6.0.1.tar.gz", hash = "sha256:9bae4ea555cf0b14938dc0aee7c9f32ed303aa20a3b73e7dc80111628792d1b3"},
] ]
[package.extras] [package.extras]
docs = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"]
testing = ["portend", "pytest (>=6,!=8.1.1)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)", "pytest-mypy", "pytest-ruff (>=0.2.1)"] test = ["portend", "pytest (>=6,!=8.1.*)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)", "pytest-mypy", "pytest-ruff (>=0.2.1)"]
[[package]] [[package]]
name = "jaraco-functools" name = "jaraco-functools"
@ -788,6 +788,20 @@ files = [
{file = "PyQt6_sip-13.8.0.tar.gz", hash = "sha256:2f74cf3d6d9cab5152bd9f49d570b2dfb87553ebb5c4919abfde27f5b9fd69d4"}, {file = "PyQt6_sip-13.8.0.tar.gz", hash = "sha256:2f74cf3d6d9cab5152bd9f49d570b2dfb87553ebb5c4919abfde27f5b9fd69d4"},
] ]
[[package]]
name = "python-dateutil"
version = "2.9.0.post0"
description = "Extensions to the standard Python datetime module"
optional = false
python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7"
files = [
{file = "python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3"},
{file = "python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427"},
]
[package.dependencies]
six = ">=1.5"
[[package]] [[package]]
name = "python-dotenv" name = "python-dotenv"
version = "1.0.1" version = "1.0.1"
@ -804,13 +818,13 @@ cli = ["click (>=5.0)"]
[[package]] [[package]]
name = "pywin32-ctypes" name = "pywin32-ctypes"
version = "0.2.2" version = "0.2.3"
description = "A (partial) reimplementation of pywin32 using ctypes/cffi" description = "A (partial) reimplementation of pywin32 using ctypes/cffi"
optional = false optional = false
python-versions = ">=3.6" python-versions = ">=3.6"
files = [ files = [
{file = "pywin32-ctypes-0.2.2.tar.gz", hash = "sha256:3426e063bdd5fd4df74a14fa3cf80a0b42845a87e1d1e81f6549f9daec593a60"}, {file = "pywin32-ctypes-0.2.3.tar.gz", hash = "sha256:d162dc04946d704503b2edc4d55f3dba5c1d539ead017afa00142c38b9885755"},
{file = "pywin32_ctypes-0.2.2-py3-none-any.whl", hash = "sha256:bf490a1a709baf35d688fe0ecf980ed4de11d2b3e37b51e5442587a75d9957e7"}, {file = "pywin32_ctypes-0.2.3-py3-none-any.whl", hash = "sha256:8a1513379d709975552d202d942d9837758905c8d01eb82b8bcc30918929e7b8"},
] ]
[[package]] [[package]]
@ -828,6 +842,17 @@ files = [
cryptography = ">=2.0" cryptography = ">=2.0"
jeepney = ">=0.6" jeepney = ">=0.6"
[[package]]
name = "six"
version = "1.16.0"
description = "Python 2 and 3 compatibility utilities"
optional = false
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*"
files = [
{file = "six-1.16.0-py2.py3-none-any.whl", hash = "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254"},
{file = "six-1.16.0.tar.gz", hash = "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926"},
]
[[package]] [[package]]
name = "sniffio" name = "sniffio"
version = "1.3.1" version = "1.3.1"
@ -955,4 +980,4 @@ files = [
[metadata] [metadata]
lock-version = "2.0" lock-version = "2.0"
python-versions = "^3.12" python-versions = "^3.12"
content-hash = "6e84cef360a53faa7bba2d9bb57f01093ff0291c28c02aff4727b6c21fd6d715" content-hash = "fd70e86969d9632a562a6755e8ff2f7e9d38e795da2f3c5c04eabe77d5723fdb"

View File

@ -13,6 +13,7 @@ pyqt6 = "^6.7.1"
keyring = "^25.3.0" keyring = "^25.3.0"
python-dotenv = "^1.0.1" python-dotenv = "^1.0.1"
sqlmodel = "^0.0.21" sqlmodel = "^0.0.21"
python-dateutil = "^2.9.0.post0"
[tool.poetry.group.dev.dependencies] [tool.poetry.group.dev.dependencies]